mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Prevent tab icons from briefly showing the wrong agent (#5860)
This commit is contained in:
@@ -1,5 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hasCompletedTabAgent, resolveCompletedTabAgent, resolveTabAgent } from './tab-agent'
|
||||
import {
|
||||
hasCompletedTabAgent,
|
||||
resolveCompletedTabAgent,
|
||||
resolveFocusedCompletedTabAgent,
|
||||
resolveFocusedTabAgent,
|
||||
resolveSiblingCompletedTabAgent,
|
||||
resolveSiblingTabAgent,
|
||||
resolveTabAgent
|
||||
} from './tab-agent'
|
||||
import type { AgentStatusEntry, AgentType } from '../../../shared/agent-status-types'
|
||||
import type { TerminalLayoutSnapshot } from '../../../shared/types'
|
||||
|
||||
@@ -40,6 +48,15 @@ describe('resolveTabAgent', () => {
|
||||
expect(resolveTabAgent(map, layout(LEAF_B), 'tab-1')).toBe('codex')
|
||||
})
|
||||
|
||||
it('exposes focused and sibling hook identity separately', () => {
|
||||
const map = {
|
||||
[`tab-1:${LEAF_A}`]: entry(`tab-1:${LEAF_A}`, 'claude'),
|
||||
[`tab-1:${LEAF_B}`]: entry(`tab-1:${LEAF_B}`, 'codex')
|
||||
}
|
||||
expect(resolveFocusedTabAgent(map, layout(LEAF_A), 'tab-1')).toBe('claude')
|
||||
expect(resolveSiblingTabAgent(map, layout(LEAF_A), 'tab-1')).toBe('codex')
|
||||
})
|
||||
|
||||
it('falls back to any agent pane when the focused pane is a plain terminal', () => {
|
||||
// Focused leaf A has no entry (it's a shell); the split sibling runs Codex.
|
||||
const map = { [`tab-1:${LEAF_B}`]: entry(`tab-1:${LEAF_B}`, 'codex') }
|
||||
@@ -51,6 +68,13 @@ describe('resolveTabAgent', () => {
|
||||
expect(resolveTabAgent(map, undefined, 'tab-1')).toBe('droid')
|
||||
})
|
||||
|
||||
it('treats same-tab hook identity as focused when the layout is missing', () => {
|
||||
const map = { [`tab-1:${LEAF_A}`]: entry(`tab-1:${LEAF_A}`, 'codex') }
|
||||
|
||||
expect(resolveFocusedTabAgent(map, undefined, 'tab-1')).toBe('codex')
|
||||
expect(resolveSiblingTabAgent(map, undefined, 'tab-1')).toBeNull()
|
||||
})
|
||||
|
||||
it("keeps the terminal glyph for an agent that didn't identify itself", () => {
|
||||
const map = { [`tab-1:${LEAF_A}`]: entry(`tab-1:${LEAF_A}`, 'unknown') }
|
||||
expect(resolveTabAgent(map, layout(LEAF_A), 'tab-1')).toBeNull()
|
||||
@@ -77,6 +101,22 @@ describe('resolveTabAgent', () => {
|
||||
expect(resolveCompletedTabAgent(map, 'tab-1')).toBe('openclaude')
|
||||
})
|
||||
|
||||
it('exposes focused and sibling completed hook identity separately', () => {
|
||||
const map = {
|
||||
[`tab-1:${LEAF_A}`]: {
|
||||
...entry(`tab-1:${LEAF_A}`, 'claude'),
|
||||
state: 'done' as const
|
||||
},
|
||||
[`tab-1:${LEAF_B}`]: {
|
||||
...entry(`tab-1:${LEAF_B}`, 'codex'),
|
||||
state: 'done' as const
|
||||
}
|
||||
}
|
||||
|
||||
expect(resolveFocusedCompletedTabAgent(map, layout(LEAF_A), 'tab-1')).toBe('claude')
|
||||
expect(resolveSiblingCompletedTabAgent(map, layout(LEAF_A), 'tab-1')).toBe('codex')
|
||||
})
|
||||
|
||||
it('keeps the terminal glyph for an agent Orca has no icon for', () => {
|
||||
const map = { [`tab-1:${LEAF_A}`]: entry(`tab-1:${LEAF_A}`, 'totally-custom-agent') }
|
||||
expect(resolveTabAgent(map, layout(LEAF_A), 'tab-1')).toBeNull()
|
||||
|
||||
@@ -18,16 +18,48 @@ export function resolveTabAgent(
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
|
||||
layout: TerminalLayoutSnapshot | undefined,
|
||||
tabId: string
|
||||
): TuiAgent | null {
|
||||
return (
|
||||
resolveFocusedTabAgent(agentStatusByPaneKey, layout, tabId) ??
|
||||
resolveSiblingTabAgent(agentStatusByPaneKey, layout, tabId)
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveFocusedTabAgent(
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
|
||||
layout: TerminalLayoutSnapshot | undefined,
|
||||
tabId: string
|
||||
): TuiAgent | null {
|
||||
const activeLeafId = layout?.activeLeafId
|
||||
if (activeLeafId && isTerminalLeafId(activeLeafId)) {
|
||||
const focused = agentFromStatusEntry(agentStatusByPaneKey[makePaneKey(tabId, activeLeafId)])
|
||||
if (focused) {
|
||||
return focused
|
||||
}
|
||||
return agentFromStatusEntry(agentStatusByPaneKey[makePaneKey(tabId, activeLeafId)])
|
||||
}
|
||||
// Why: hook events can arrive while the terminal layout is temporarily
|
||||
// unmounted; with no focused leaf to compare, same-tab hook status is primary.
|
||||
return resolveAnyTabAgent(agentStatusByPaneKey, tabId)
|
||||
}
|
||||
|
||||
export function resolveSiblingTabAgent(
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
|
||||
layout: TerminalLayoutSnapshot | undefined,
|
||||
tabId: string
|
||||
): TuiAgent | null {
|
||||
const activeLeafId =
|
||||
layout?.activeLeafId && isTerminalLeafId(layout.activeLeafId) ? layout.activeLeafId : null
|
||||
if (!activeLeafId) {
|
||||
return null
|
||||
}
|
||||
return resolveAnyTabAgent(agentStatusByPaneKey, tabId, activeLeafId)
|
||||
}
|
||||
|
||||
function resolveAnyTabAgent(
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
|
||||
tabId: string,
|
||||
excludedLeafId?: string
|
||||
): TuiAgent | null {
|
||||
for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) {
|
||||
if (parsePaneKey(paneKey)?.tabId === tabId) {
|
||||
const parsedPaneKey = parsePaneKey(paneKey)
|
||||
if (parsedPaneKey?.tabId === tabId && parsedPaneKey.leafId !== excludedLeafId) {
|
||||
const agent = agentFromStatusEntry(entry)
|
||||
if (agent) {
|
||||
return agent
|
||||
@@ -53,11 +85,49 @@ export function hasCompletedTabAgent(
|
||||
|
||||
export function resolveCompletedTabAgent(
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
|
||||
tabId: string,
|
||||
layout?: TerminalLayoutSnapshot
|
||||
): TuiAgent | null {
|
||||
return (
|
||||
resolveFocusedCompletedTabAgent(agentStatusByPaneKey, layout, tabId) ??
|
||||
resolveSiblingCompletedTabAgent(agentStatusByPaneKey, layout, tabId)
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveFocusedCompletedTabAgent(
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
|
||||
layout: TerminalLayoutSnapshot | undefined,
|
||||
tabId: string
|
||||
): TuiAgent | null {
|
||||
const activeLeafId = layout?.activeLeafId
|
||||
if (activeLeafId && isTerminalLeafId(activeLeafId)) {
|
||||
return completedAgentFromStatusEntry(agentStatusByPaneKey[makePaneKey(tabId, activeLeafId)])
|
||||
}
|
||||
return resolveAnyCompletedTabAgent(agentStatusByPaneKey, tabId)
|
||||
}
|
||||
|
||||
export function resolveSiblingCompletedTabAgent(
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
|
||||
layout: TerminalLayoutSnapshot | undefined,
|
||||
tabId: string
|
||||
): TuiAgent | null {
|
||||
const activeLeafId =
|
||||
layout?.activeLeafId && isTerminalLeafId(layout.activeLeafId) ? layout.activeLeafId : null
|
||||
if (!activeLeafId) {
|
||||
return null
|
||||
}
|
||||
return resolveAnyCompletedTabAgent(agentStatusByPaneKey, tabId, activeLeafId)
|
||||
}
|
||||
|
||||
function resolveAnyCompletedTabAgent(
|
||||
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
|
||||
tabId: string,
|
||||
excludedLeafId?: string
|
||||
): TuiAgent | null {
|
||||
for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) {
|
||||
if (entry.state === 'done' && parsePaneKey(paneKey)?.tabId === tabId) {
|
||||
const agent = agentTypeToIconAgent(entry.agentType)
|
||||
const parsedPaneKey = parsePaneKey(paneKey)
|
||||
if (parsedPaneKey?.tabId === tabId && parsedPaneKey.leafId !== excludedLeafId) {
|
||||
const agent = completedAgentFromStatusEntry(entry)
|
||||
if (agent) {
|
||||
return agent
|
||||
}
|
||||
@@ -65,3 +135,10 @@ export function resolveCompletedTabAgent(
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function completedAgentFromStatusEntry(entry: AgentStatusEntry | undefined): TuiAgent | null {
|
||||
if (!entry || entry.state !== 'done') {
|
||||
return null
|
||||
}
|
||||
return agentTypeToIconAgent(entry.agentType)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveTabAgentFromSignals } from './use-tab-agent'
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
|
||||
import { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
import type { TerminalLayoutSnapshot, TerminalTab, TuiAgent } from '../../../shared/types'
|
||||
import { resolveTabAgentFromSignals, useTabAgent } from './use-tab-agent'
|
||||
|
||||
const initialAppState = useAppStore.getInitialState()
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
let latestHookAgent: TuiAgent | null | undefined
|
||||
const hookRoots: Root[] = []
|
||||
|
||||
function HookProbe({ tab }: { tab: TerminalTab }): null {
|
||||
latestHookAgent = useTabAgent(tab)
|
||||
return null
|
||||
}
|
||||
|
||||
async function renderHookProbe(tab: TerminalTab): Promise<Root> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
hookRoots.push(root)
|
||||
await act(async () => {
|
||||
root.render(createElement(HookProbe, { tab }))
|
||||
})
|
||||
await flushHookEffects()
|
||||
return root
|
||||
}
|
||||
|
||||
async function rerenderHookProbe(root: Root, tab: TerminalTab): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(createElement(HookProbe, { tab }))
|
||||
})
|
||||
await flushHookEffects()
|
||||
}
|
||||
|
||||
async function flushHookEffects(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
function agentStatus(paneKey: string, state: AgentStatusEntry['state']): AgentStatusEntry {
|
||||
return {
|
||||
state,
|
||||
prompt: '',
|
||||
updatedAt: 1,
|
||||
stateStartedAt: 1,
|
||||
agentType: 'codex',
|
||||
paneKey,
|
||||
stateHistory: []
|
||||
}
|
||||
}
|
||||
|
||||
function completedAgentStatus(paneKey: string): AgentStatusEntry {
|
||||
return agentStatus(paneKey, 'done')
|
||||
}
|
||||
|
||||
function workingAgentStatus(paneKey: string): AgentStatusEntry {
|
||||
return agentStatus(paneKey, 'working')
|
||||
}
|
||||
|
||||
function twoPaneLayout(): TerminalLayoutSnapshot {
|
||||
return {
|
||||
root: null,
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: {
|
||||
[LEAF_ID]: 'pty-focus',
|
||||
[SECOND_LEAF_ID]: 'pty-sibling'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveTabAgentFromSignals', () => {
|
||||
it('uses a recognized foreground agent as the live local source of truth', () => {
|
||||
@@ -57,7 +134,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
title: '⠋ OpenClaude',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'claude'
|
||||
launchAgent: undefined
|
||||
})
|
||||
).toBe('openclaude')
|
||||
})
|
||||
@@ -106,7 +183,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
).toBe('openclaude')
|
||||
})
|
||||
|
||||
it('uses Claude-owned title identity before OpenClaude launch intent when hooks have not arrived', () => {
|
||||
it('keeps launch identity over title identity while hooks have not arrived', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
@@ -118,7 +195,69 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'openclaude'
|
||||
})
|
||||
).toBe('claude')
|
||||
).toBe('openclaude')
|
||||
})
|
||||
|
||||
it("keeps Codex launch intent over Claude's generic spinner title fallback", () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '⠸ codex-quarter-flash-202606191419',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
).toBe('codex')
|
||||
})
|
||||
|
||||
it('does not infer Claude identity from a generic spinner title without context', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '⠸ investigating startup',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: undefined
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not infer Claude identity from generic dot or star status titles', () => {
|
||||
for (const title of ['. investigating startup', '* investigating startup', '✳ investigating']) {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title,
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: undefined
|
||||
})
|
||||
).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps launch identity over explicit title identity until stronger signals arrive', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '⠸ Claude Code',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
).toBe('codex')
|
||||
})
|
||||
|
||||
it("uses Codex hook identity over Claude's generic task-title heuristic", () => {
|
||||
@@ -136,7 +275,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
).toBe('codex')
|
||||
})
|
||||
|
||||
it('keeps explicit Claude Code titles authoritative over stale OpenClaude launch intent', () => {
|
||||
it('keeps launch identity over explicit Claude Code titles without hook or foreground evidence', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
@@ -148,7 +287,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'openclaude'
|
||||
})
|
||||
).toBe('claude')
|
||||
).toBe('openclaude')
|
||||
})
|
||||
|
||||
it('lets shell foreground clear the icon after an agent was observed running', () => {
|
||||
@@ -211,7 +350,55 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
).toBe('claude')
|
||||
})
|
||||
|
||||
it('does not let launch intent turn Claude-owned task text into Gemini', () => {
|
||||
it('lets focused-pane hook identity override launch metadata in split tabs', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: true,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: 'Terminal 1',
|
||||
hookAgent: 'claude',
|
||||
siblingHookAgent: 'gemini',
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
).toBe('claude')
|
||||
})
|
||||
|
||||
it('keeps unresolved launch metadata ahead of sibling-pane hook fallback', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: 'Terminal 1',
|
||||
hookAgent: null,
|
||||
siblingHookAgent: 'claude',
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
).toBe('codex')
|
||||
})
|
||||
|
||||
it('uses sibling-pane hook fallback when no launch metadata exists', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: 'Terminal 1',
|
||||
hookAgent: null,
|
||||
siblingHookAgent: 'claude',
|
||||
hasCompletedHook: false,
|
||||
launchAgent: undefined
|
||||
})
|
||||
).toBe('claude')
|
||||
})
|
||||
|
||||
it('keeps launch identity over Claude-owned task text without hook or foreground evidence', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
@@ -223,10 +410,10 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'gemini'
|
||||
})
|
||||
).toBe('claude')
|
||||
).toBe('gemini')
|
||||
})
|
||||
|
||||
it('does not let launch intent turn Claude-owned task text into OpenCode', () => {
|
||||
it('keeps launch identity over Claude-owned punctuation-prefixed task text', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
@@ -238,7 +425,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'opencode'
|
||||
})
|
||||
).toBe('claude')
|
||||
).toBe('opencode')
|
||||
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
@@ -251,35 +438,50 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
hasCompletedHook: false,
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
).toBe('codex')
|
||||
})
|
||||
|
||||
it('treats Claude-prefixed title text as Claude only when it names Claude', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '✳ Claude Code',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: undefined
|
||||
})
|
||||
).toBe('claude')
|
||||
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '. Claude Code compare Opencode',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: undefined
|
||||
})
|
||||
).toBe('claude')
|
||||
})
|
||||
|
||||
it('treats Claude-prefixed task text as Claude before launch intent when no hook arrived', () => {
|
||||
it('keeps local launch identity when only a shell title suggests exit', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
hasObservedAgentSignal: true,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '✳ Gemini CLI',
|
||||
title: 'zsh',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: undefined
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
).toBe('claude')
|
||||
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
hasObservedAgentSignal: false,
|
||||
shellForegroundAfterAgentSignal: false,
|
||||
isRemote: false,
|
||||
title: '. Compare Opencode Vs Orca',
|
||||
hookAgent: null,
|
||||
hasCompletedHook: false,
|
||||
launchAgent: undefined
|
||||
})
|
||||
).toBe('claude')
|
||||
).toBe('codex')
|
||||
})
|
||||
|
||||
it('skips local foreground authority for remote worktrees', () => {
|
||||
@@ -313,7 +515,7 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
).toBe('codex')
|
||||
})
|
||||
|
||||
it('suppresses stale local launch intent after a completed hook and shell title', () => {
|
||||
it('keeps local launch identity after a completed hook until foreground proves shell exit', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
foreground: undefined,
|
||||
@@ -325,6 +527,146 @@ describe('resolveTabAgentFromSignals', () => {
|
||||
hasCompletedHook: true,
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
).toBeNull()
|
||||
).toBe('claude')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTabAgent', () => {
|
||||
const originalApi = window.api
|
||||
const getForegroundProcess = vi.fn()
|
||||
const clearTabLaunchAgent = vi.fn()
|
||||
const baseTab: TerminalTab = {
|
||||
id: 'tab-1',
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: 'wt-1',
|
||||
title: 'Terminal 1',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
launchAgent: 'codex'
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
latestHookAgent = undefined
|
||||
getForegroundProcess.mockReset()
|
||||
clearTabLaunchAgent.mockReset()
|
||||
useAppStore.setState(initialAppState, true)
|
||||
useAppStore.setState({
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-1'] },
|
||||
agentStatusByPaneKey: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
clearTabLaunchAgent
|
||||
})
|
||||
window.api = {
|
||||
...originalApi,
|
||||
pty: {
|
||||
...originalApi?.pty,
|
||||
getForegroundProcess
|
||||
}
|
||||
} as typeof window.api
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
hookRoots.splice(0).forEach((root) => {
|
||||
act(() => root.unmount())
|
||||
})
|
||||
document.body.replaceChildren()
|
||||
useAppStore.setState(initialAppState, true)
|
||||
window.api = originalApi
|
||||
})
|
||||
|
||||
it('uses unrecognized non-shell foreground as launch lifecycle evidence', async () => {
|
||||
getForegroundProcess.mockResolvedValueOnce('node').mockResolvedValueOnce('zsh')
|
||||
|
||||
const root = await renderHookProbe(baseTab)
|
||||
|
||||
expect(latestHookAgent).toBe('codex')
|
||||
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
|
||||
|
||||
await rerenderHookProbe(root, { ...baseTab, title: 'zsh' })
|
||||
|
||||
expect(clearTabLaunchAgent).toHaveBeenCalledExactlyOnceWith('tab-1')
|
||||
expect(latestHookAgent).toBeNull()
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('uses completed local hook status as launch lifecycle evidence after remount', async () => {
|
||||
const paneKey = makePaneKey('tab-1', LEAF_ID)
|
||||
getForegroundProcess.mockResolvedValueOnce('zsh')
|
||||
useAppStore.setState({
|
||||
agentStatusByPaneKey: {
|
||||
[paneKey]: completedAgentStatus(paneKey)
|
||||
}
|
||||
})
|
||||
|
||||
await renderHookProbe({ ...baseTab, title: 'zsh' })
|
||||
|
||||
expect(clearTabLaunchAgent).toHaveBeenCalledExactlyOnceWith('tab-1')
|
||||
expect(latestHookAgent).toBeNull()
|
||||
expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
})
|
||||
|
||||
it('treats paired runtime PTYs as remote-like for completed hook fallback', async () => {
|
||||
const paneKey = makePaneKey('tab-1', LEAF_ID)
|
||||
useAppStore.setState({
|
||||
ptyIdsByTabId: { 'tab-1': ['remote:web-env-1@@terminal-1'] },
|
||||
agentStatusByPaneKey: {
|
||||
[paneKey]: completedAgentStatus(paneKey)
|
||||
}
|
||||
})
|
||||
|
||||
await renderHookProbe({
|
||||
...baseTab,
|
||||
ptyId: 'remote:web-env-1@@terminal-1',
|
||||
title: 'zsh',
|
||||
launchAgent: undefined
|
||||
})
|
||||
|
||||
expect(latestHookAgent).toBe('codex')
|
||||
expect(getForegroundProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not let a split-tab fallback PTY suppress missing-layout hook identity', async () => {
|
||||
const paneKey = makePaneKey('tab-1', LEAF_ID)
|
||||
useAppStore.setState({
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-shell', 'pty-agent'] },
|
||||
terminalLayoutsByTabId: {},
|
||||
agentStatusByPaneKey: {
|
||||
[paneKey]: workingAgentStatus(paneKey)
|
||||
}
|
||||
})
|
||||
|
||||
await renderHookProbe({
|
||||
...baseTab,
|
||||
title: 'zsh',
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
|
||||
expect(latestHookAgent).toBe('codex')
|
||||
expect(getForegroundProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not use completed sibling hook status as focused launch lifecycle evidence', async () => {
|
||||
const siblingPaneKey = makePaneKey('tab-1', SECOND_LEAF_ID)
|
||||
getForegroundProcess.mockResolvedValueOnce('zsh')
|
||||
useAppStore.setState({
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-focus', 'pty-sibling'] },
|
||||
terminalLayoutsByTabId: { 'tab-1': twoPaneLayout() },
|
||||
agentStatusByPaneKey: {
|
||||
[siblingPaneKey]: completedAgentStatus(siblingPaneKey)
|
||||
}
|
||||
})
|
||||
|
||||
await renderHookProbe({
|
||||
...baseTab,
|
||||
ptyId: 'pty-focus',
|
||||
title: 'zsh',
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
|
||||
expect(latestHookAgent).toBe('claude')
|
||||
expect(clearTabLaunchAgent).not.toHaveBeenCalled()
|
||||
expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-focus')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { recognizeAgentProcess } from '../../../shared/agent-process-recognition'
|
||||
import { isShellProcess, getAgentLabel } from '../../../shared/agent-detection'
|
||||
import { isShellProcess, getAgentLabel, titleHasAgentName } from '../../../shared/agent-detection'
|
||||
import { worktreeUsesRemoteConnection } from '@/store/slices/terminals'
|
||||
import { resolveCompletedTabAgent, resolveTabAgent } from './tab-agent'
|
||||
import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
|
||||
import {
|
||||
resolveFocusedCompletedTabAgent,
|
||||
resolveFocusedTabAgent,
|
||||
resolveSiblingCompletedTabAgent,
|
||||
resolveSiblingTabAgent
|
||||
} from './tab-agent'
|
||||
import type { TerminalTab, TuiAgent } from '../../../shared/types'
|
||||
|
||||
// Maps getAgentLabel()'s product labels to TuiAgent ids — the fallback for
|
||||
@@ -33,8 +39,47 @@ function agentFromTitle(title: string): TuiAgent | null {
|
||||
return label ? (TITLE_LABEL_TO_AGENT[label] ?? null) : null
|
||||
}
|
||||
|
||||
function getTitleForegroundKey(title: string): string {
|
||||
function containsBrailleSpinner(title: string): boolean {
|
||||
for (const char of title) {
|
||||
const codePoint = char.codePointAt(0)
|
||||
if (codePoint !== undefined && codePoint >= 0x2800 && codePoint <= 0x28ff) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function hasGenericClaudeStatusPrefix(title: string): boolean {
|
||||
return (
|
||||
containsBrailleSpinner(title) ||
|
||||
title.startsWith('✳ ') ||
|
||||
title === '✳' ||
|
||||
title.startsWith('. ') ||
|
||||
title.startsWith('* ')
|
||||
)
|
||||
}
|
||||
|
||||
function isGenericClaudeStatusClaim(title: string, titleAgent: TuiAgent | null): boolean {
|
||||
return (
|
||||
titleAgent === 'claude' &&
|
||||
hasGenericClaudeStatusPrefix(title) &&
|
||||
!titleHasAgentName(title, 'claude')
|
||||
)
|
||||
}
|
||||
|
||||
function agentFromTabTitle(title: string): TuiAgent | null {
|
||||
const titleAgent = agentFromTitle(title)
|
||||
if (isGenericClaudeStatusClaim(title, titleAgent)) {
|
||||
// Why: bare Claude status prefixes are activity evidence, not identity.
|
||||
// Keep them out of tab icons so task/worktree titles cannot become Claude
|
||||
// without a hook, launch intent, foreground process, or explicit name.
|
||||
return null
|
||||
}
|
||||
return titleAgent
|
||||
}
|
||||
|
||||
function getTitleForegroundKey(title: string, launchAgent?: TuiAgent): string {
|
||||
const titleAgent = launchAgent ? null : agentFromTabTitle(title)
|
||||
if (titleAgent) {
|
||||
return `agent:${titleAgent}`
|
||||
}
|
||||
@@ -59,23 +104,27 @@ export function resolveTabAgentFromSignals(args: {
|
||||
isRemote: boolean
|
||||
title: string
|
||||
hookAgent: TuiAgent | null
|
||||
siblingHookAgent?: TuiAgent | null
|
||||
hasCompletedHook: boolean
|
||||
completedHookAgent?: TuiAgent | null
|
||||
launchAgent?: TuiAgent
|
||||
}): TuiAgent | null {
|
||||
const titleAgent = agentFromTitle(args.title)
|
||||
const launchAgent = args.launchAgent ?? null
|
||||
const titleAgent = launchAgent ? null : agentFromTabTitle(args.title)
|
||||
const titleLooksShell = isShellProcess(args.title)
|
||||
// Why: remote panes cannot cheaply prove shell foreground after hook exit,
|
||||
// so keep the last completed hook identity instead of flashing unknown.
|
||||
const completedHookAgent =
|
||||
!args.isRemote && titleLooksShell && args.hasCompletedHook ? null : args.completedHookAgent
|
||||
const hookAgent = args.hookAgent ?? completedHookAgent ?? null
|
||||
const launchAgent =
|
||||
args.hasCompletedHook || (titleLooksShell && args.hasObservedAgentSignal)
|
||||
? null
|
||||
: (args.launchAgent ?? null)
|
||||
const focusedHookAgent = args.hookAgent ?? null
|
||||
const fallbackHookAgent = args.siblingHookAgent ?? completedHookAgent ?? null
|
||||
const localShellForegroundClearedLaunch =
|
||||
!args.isRemote && args.foreground === null && args.shellForegroundAfterAgentSignal
|
||||
const remoteCompletedHookAtShellTitle = args.isRemote && titleLooksShell && args.hasCompletedHook
|
||||
const activeLaunchAgent =
|
||||
localShellForegroundClearedLaunch || remoteCompletedHookAtShellTitle ? null : launchAgent
|
||||
if (args.isRemote || args.foreground === undefined) {
|
||||
return hookAgent ?? titleAgent ?? launchAgent
|
||||
return focusedHookAgent ?? activeLaunchAgent ?? fallbackHookAgent ?? titleAgent
|
||||
}
|
||||
if (args.foreground) {
|
||||
return args.foreground
|
||||
@@ -85,7 +134,7 @@ export function resolveTabAgentFromSignals(args: {
|
||||
if (args.shellForegroundAfterAgentSignal) {
|
||||
return null
|
||||
}
|
||||
return hookAgent ?? titleAgent ?? launchAgent
|
||||
return focusedHookAgent ?? activeLaunchAgent ?? fallbackHookAgent ?? titleAgent
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,18 +150,34 @@ export function resolveTabAgentFromSignals(args: {
|
||||
* a recognized shell authoritatively means "no agent".
|
||||
* 2. Hook status — accurate provider identity from native integrations, and
|
||||
* available for SSH/remote panes where foreground polling is too costly.
|
||||
* 3. Title — catches agents whose process name isn't self-identifying (Claude
|
||||
* runs as `node`; its "✳ Claude Code" title still identifies it).
|
||||
* 4. launchAgent — what Orca launched here; instant bootstrap before any check.
|
||||
* 3. launchAgent — what Orca launched here; instant bootstrap before hooks or
|
||||
* foreground polling arrive, and the owned identity for startup windows.
|
||||
* 4. Title — legacy/unknown-session fallback only. It is ignored while
|
||||
* launchAgent exists, and generic spinner-only titles do not identify an agent.
|
||||
*/
|
||||
export function useTabAgent(tab: TerminalTab): TuiAgent | null {
|
||||
const hookAgent = useAppStore((s) =>
|
||||
resolveTabAgent(s.agentStatusByPaneKey, s.terminalLayoutsByTabId[tab.id], tab.id)
|
||||
const focusedHookAgent = useAppStore((s) =>
|
||||
resolveFocusedTabAgent(s.agentStatusByPaneKey, s.terminalLayoutsByTabId[tab.id], tab.id)
|
||||
)
|
||||
const completedHookAgent = useAppStore((s) =>
|
||||
resolveCompletedTabAgent(s.agentStatusByPaneKey, tab.id)
|
||||
const siblingHookAgent = useAppStore((s) =>
|
||||
resolveSiblingTabAgent(s.agentStatusByPaneKey, s.terminalLayoutsByTabId[tab.id], tab.id)
|
||||
)
|
||||
const hasCompletedHook = completedHookAgent !== null
|
||||
const focusedCompletedHookAgent = useAppStore((s) =>
|
||||
resolveFocusedCompletedTabAgent(
|
||||
s.agentStatusByPaneKey,
|
||||
s.terminalLayoutsByTabId[tab.id],
|
||||
tab.id
|
||||
)
|
||||
)
|
||||
const siblingCompletedHookAgent = useAppStore((s) =>
|
||||
resolveSiblingCompletedTabAgent(
|
||||
s.agentStatusByPaneKey,
|
||||
s.terminalLayoutsByTabId[tab.id],
|
||||
tab.id
|
||||
)
|
||||
)
|
||||
const completedHookAgent = focusedCompletedHookAgent ?? siblingCompletedHookAgent
|
||||
const hasCompletedHook = focusedCompletedHookAgent !== null
|
||||
const clearTabLaunchAgent = useAppStore((s) => s.clearTabLaunchAgent)
|
||||
|
||||
// The focused pane's PTY (single-pane tabs have exactly one leaf).
|
||||
@@ -120,9 +185,24 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
|
||||
const layout = s.terminalLayoutsByTabId[tab.id]
|
||||
const activeLeafId = layout?.activeLeafId
|
||||
const leafPty = activeLeafId ? layout?.ptyIdsByLeafId?.[activeLeafId] : undefined
|
||||
return leafPty ?? s.ptyIdsByTabId[tab.id]?.[0] ?? null
|
||||
if (leafPty) {
|
||||
return leafPty
|
||||
}
|
||||
const ptyIds = s.ptyIdsByTabId[tab.id] ?? []
|
||||
// Why: without a focused leaf, a split tab's first PTY can be a sibling
|
||||
// shell. Only single-PTY fallback foreground is authoritative.
|
||||
return ptyIds.length === 1 ? ptyIds[0]! : null
|
||||
})
|
||||
const isRemote = useAppStore((s) => worktreeUsesRemoteConnection(s, tab.worktreeId))
|
||||
const hasRemoteRuntimePty = useAppStore((s) => {
|
||||
const layout = s.terminalLayoutsByTabId[tab.id]
|
||||
const ptyIds = new Set(s.ptyIdsByTabId[tab.id] ?? [])
|
||||
for (const ptyId of Object.values(layout?.ptyIdsByLeafId ?? {})) {
|
||||
ptyIds.add(ptyId)
|
||||
}
|
||||
return [...ptyIds].some((ptyId) => parseRemoteRuntimePtyId(ptyId) !== null)
|
||||
})
|
||||
const isRemoteWorktree = useAppStore((s) => worktreeUsesRemoteConnection(s, tab.worktreeId))
|
||||
const isRemoteLike = isRemoteWorktree || hasRemoteRuntimePty
|
||||
|
||||
// undefined = no conclusive local reading (defer to title/hook/launchAgent);
|
||||
// null = foreground is a shell; TuiAgent = recognized agent process.
|
||||
@@ -130,24 +210,28 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
|
||||
const [hasObservedAgentSignal, setHasObservedAgentSignal] = useState(false)
|
||||
const [shellForegroundAfterAgentSignal, setShellForegroundAfterAgentSignal] = useState(false)
|
||||
const hasObservedAgentSignalRef = useRef(false)
|
||||
const titleForegroundKey = getTitleForegroundKey(tab.title)
|
||||
const titleForegroundKey = getTitleForegroundKey(tab.title, tab.launchAgent)
|
||||
|
||||
useEffect(() => {
|
||||
setForeground(undefined)
|
||||
setHasObservedAgentSignal(false)
|
||||
hasObservedAgentSignalRef.current = false
|
||||
setShellForegroundAfterAgentSignal(false)
|
||||
}, [ptyId, isRemote])
|
||||
}, [ptyId, isRemoteLike])
|
||||
|
||||
useEffect(() => {
|
||||
if (agentFromTitle(tab.title) || hookAgent) {
|
||||
const fallbackAgentSignal =
|
||||
!tab.launchAgent && (agentFromTabTitle(tab.title) || siblingHookAgent)
|
||||
// Why: a completed structured hook proves a launched agent existed, but
|
||||
// local launch cleanup still waits for current foreground-shell evidence.
|
||||
if (focusedHookAgent || hasCompletedHook || fallbackAgentSignal) {
|
||||
hasObservedAgentSignalRef.current = true
|
||||
setHasObservedAgentSignal(true)
|
||||
}
|
||||
}, [hookAgent, tab.title])
|
||||
}, [focusedHookAgent, hasCompletedHook, siblingHookAgent, tab.launchAgent, tab.title])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ptyId || isRemote) {
|
||||
if (!ptyId || isRemoteLike) {
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
@@ -169,6 +253,13 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
|
||||
setShellForegroundAfterAgentSignal(hasObservedAgentSignalRef.current)
|
||||
setForeground(null)
|
||||
} else {
|
||||
if (process && tab.launchAgent) {
|
||||
// Why: for Orca-owned launches, an unrecognized non-shell process
|
||||
// is enough lifecycle evidence to clear launch intent when the pane
|
||||
// later returns to a shell, without using title text as identity.
|
||||
hasObservedAgentSignalRef.current = true
|
||||
setHasObservedAgentSignal(true)
|
||||
}
|
||||
setForeground(undefined)
|
||||
}
|
||||
})
|
||||
@@ -180,27 +271,24 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [ptyId, isRemote, titleForegroundKey])
|
||||
}, [ptyId, isRemoteLike, tab.launchAgent, titleForegroundKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (!tab.launchAgent) {
|
||||
return
|
||||
}
|
||||
const titleLooksShell = isShellProcess(tab.title)
|
||||
const titleAgent = agentFromTitle(tab.title)
|
||||
const foregroundSawExitedAgent =
|
||||
!isRemote && foreground === null && shellForegroundAfterAgentSignal && !titleAgent
|
||||
const titleSawExitedAgent = titleLooksShell && hasObservedAgentSignal
|
||||
const remoteHookCompletedAtShellTitle = isRemote && hasCompletedHook && titleLooksShell
|
||||
if (foregroundSawExitedAgent || titleSawExitedAgent || remoteHookCompletedAtShellTitle) {
|
||||
!isRemoteLike && foreground === null && shellForegroundAfterAgentSignal
|
||||
const remoteHookCompletedAtShellTitle = isRemoteLike && hasCompletedHook && titleLooksShell
|
||||
if (foregroundSawExitedAgent || remoteHookCompletedAtShellTitle) {
|
||||
clearTabLaunchAgent(tab.id)
|
||||
}
|
||||
}, [
|
||||
clearTabLaunchAgent,
|
||||
foreground,
|
||||
hasCompletedHook,
|
||||
hasObservedAgentSignal,
|
||||
isRemote,
|
||||
isRemoteLike,
|
||||
shellForegroundAfterAgentSignal,
|
||||
tab.id,
|
||||
tab.launchAgent,
|
||||
@@ -211,9 +299,10 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null {
|
||||
foreground,
|
||||
hasObservedAgentSignal,
|
||||
shellForegroundAfterAgentSignal,
|
||||
isRemote,
|
||||
isRemote: isRemoteLike,
|
||||
title: tab.title,
|
||||
hookAgent,
|
||||
hookAgent: focusedHookAgent,
|
||||
siblingHookAgent,
|
||||
hasCompletedHook,
|
||||
completedHookAgent,
|
||||
launchAgent: tab.launchAgent
|
||||
|
||||
@@ -629,7 +629,7 @@ describe('applyWebSessionTabsSnapshot', () => {
|
||||
expect(patch.activeTabIdByWorktree?.[WT]).toBe(mirroredId)
|
||||
})
|
||||
|
||||
it('preserves mirrored launch intent when a later host snapshot omits it', () => {
|
||||
it('drops mirrored launch intent when a later host snapshot omits it', () => {
|
||||
const existingTab: TerminalTab = {
|
||||
id: toWebTerminalSurfaceTabId('host-tab-1'),
|
||||
ptyId: 'remote:web-env-1@@terminal-1',
|
||||
@@ -666,9 +666,9 @@ describe('applyWebSessionTabsSnapshot', () => {
|
||||
|
||||
expect(patch.tabsByWorktree?.[WT]?.[0]).toMatchObject({
|
||||
id: existingTab.id,
|
||||
title: 'zsh',
|
||||
launchAgent: 'codex'
|
||||
title: 'zsh'
|
||||
})
|
||||
expect(patch.tabsByWorktree?.[WT]?.[0]?.launchAgent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves quick command labels from host terminal surfaces', () => {
|
||||
|
||||
@@ -507,9 +507,7 @@ function buildMirroredTerminalTabs(
|
||||
surfaces.find((surface) => surface.quickCommandLabel?.trim())?.quickCommandLabel?.trim() ||
|
||||
existing?.quickCommandLabel?.trim()
|
||||
const launchAgent =
|
||||
activeSurface.launchAgent ??
|
||||
surfaces.find((surface) => surface.launchAgent)?.launchAgent ??
|
||||
existing?.launchAgent
|
||||
activeSurface.launchAgent ?? surfaces.find((surface) => surface.launchAgent)?.launchAgent
|
||||
// Why: tab color/pin echo back through host snapshots, so prefer the client's
|
||||
// own record (kept authoritative in tabsByWorktree by the pin/color setters)
|
||||
// and fall back to the host value only when this client has no prior tab —
|
||||
@@ -533,9 +531,8 @@ function buildMirroredTerminalTabs(
|
||||
isPinned,
|
||||
sortOrder: sortOffset + index,
|
||||
createdAt: existing?.createdAt ?? now + index,
|
||||
// Why: runtime snapshots can omit launchAgent after the process settles;
|
||||
// keep the client-side launch intent so completed remote tabs do not
|
||||
// briefly lose their provider icon between host status snapshots.
|
||||
// Why: launchAgent is host-owned lifecycle metadata. If the host stops
|
||||
// publishing it, mirrored clients must not resurrect stale startup intent.
|
||||
...(launchAgent ? { launchAgent } : {})
|
||||
},
|
||||
hostTabId: parentTabId,
|
||||
|
||||
Reference in New Issue
Block a user