mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
fix(native-chat): guard duplicate launches and bound sync recovery
This commit is contained in:
@@ -18,6 +18,9 @@ import type { AppState } from '@/store/types'
|
||||
// Why: the real entry-action module pulls in runtime IPC + the app store; the
|
||||
// keyboard behavior under test only needs a controllable option list.
|
||||
const entryOptionsMock = vi.hoisted(() => ({ options: [] as TabEntryOption[] }))
|
||||
const structuredLaunchMock = vi.hoisted(() => ({
|
||||
status: 'idle' as 'idle' | 'pending' | 'unknown'
|
||||
}))
|
||||
vi.mock('./tab-create-entry-action', () => ({
|
||||
getTabEntryOptions: () => entryOptionsMock.options,
|
||||
createTabEntryAllowAbsolutePathsSelector: () => () => true,
|
||||
@@ -35,6 +38,9 @@ vi.mock('@/lib/agent-catalog', () => ({
|
||||
getAgentCatalog: () => [],
|
||||
AgentIcon: () => null
|
||||
}))
|
||||
vi.mock('@/lib/structured-agent-session-launch', () => ({
|
||||
useStructuredCodexLaunchStatus: () => structuredLaunchMock.status
|
||||
}))
|
||||
|
||||
import TabBarCreateEntry from './TabBarCreateEntry'
|
||||
|
||||
@@ -170,6 +176,7 @@ afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
vi.clearAllMocks()
|
||||
structuredLaunchMock.status = 'idle'
|
||||
})
|
||||
|
||||
describe('TabBarCreateEntry keyboard navigation', () => {
|
||||
@@ -260,6 +267,29 @@ describe('TabBarCreateEntry keyboard navigation', () => {
|
||||
expect(onLaunchAgent).toHaveBeenCalledWith('gemini')
|
||||
})
|
||||
|
||||
it('does not relaunch Codex when a structured launch is already pending', () => {
|
||||
structuredLaunchMock.status = 'pending'
|
||||
const agentOptions: TabAgentLaunchOption[] = [
|
||||
{ agent: 'codex', aliases: ['codex'], label: 'Codex' }
|
||||
]
|
||||
const onLaunchAgent = vi.fn()
|
||||
mount(
|
||||
<TabBarCreateEntry
|
||||
worktreeId="wt"
|
||||
groupId="g"
|
||||
menuOpen
|
||||
agentOptions={agentOptions}
|
||||
onOpenEntry={vi.fn().mockResolvedValue(undefined)}
|
||||
onLaunchAgent={onLaunchAgent}
|
||||
/>
|
||||
)
|
||||
|
||||
setQuery('cod')
|
||||
submitForm()
|
||||
|
||||
expect(onLaunchAgent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('exposes the highlighted row to assistive tech via aria-activedescendant', () => {
|
||||
entryOptionsMock.options = [fileOption('a.ts'), fileOption('b.ts'), fileOption('c.ts')]
|
||||
mount(
|
||||
|
||||
@@ -228,6 +228,9 @@ function TabBarCreateEntrySession({
|
||||
return
|
||||
}
|
||||
if (selectedOption.kind === 'agent') {
|
||||
if (selectedOption.option.agent === 'codex' && structuredCodexLaunchStatus === 'pending') {
|
||||
return
|
||||
}
|
||||
onLaunchAgent?.(selectedOption.option.agent)
|
||||
onDidOpenEntry?.()
|
||||
return
|
||||
|
||||
@@ -49,9 +49,10 @@ const store = {
|
||||
detectedWorktreesByRepo: {},
|
||||
allWorktrees: vi.fn(() => store.worktreesByRepo['repo-1']),
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] },
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [{ contentType: 'agent-session', entityId: 'codex-session-1', worktreeId: 'wt-1' }]
|
||||
},
|
||||
unifiedTabsByWorktree: {} as Record<
|
||||
string,
|
||||
{ contentType: string; entityId: string; worktreeId: string }[]
|
||||
>,
|
||||
openFiles: [] as { id: string; worktreeId: string }[],
|
||||
browserTabsByWorktree: {} as Record<string, { id: string }[]>,
|
||||
tabBarOrderByWorktree: {} as Record<string, string[]>,
|
||||
@@ -105,6 +106,9 @@ vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({
|
||||
describe('structured chat adoption guard on the launch path', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
store.unifiedTabsByWorktree = {
|
||||
'wt-1': [{ contentType: 'agent-session', entityId: 'codex-session-1', worktreeId: 'wt-1' }]
|
||||
}
|
||||
store.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }]
|
||||
store.projects = [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } }]
|
||||
mockCreateTab.mockReturnValue({ id: 'tab-1' })
|
||||
@@ -199,6 +203,7 @@ describe('structured chat adoption guard on the launch path', () => {
|
||||
})
|
||||
|
||||
it('keeps the single-flight reservation until the published tab inventory is refreshed', async () => {
|
||||
store.unifiedTabsByWorktree = {}
|
||||
let resolveRefresh!: (snapshots: unknown[]) => void
|
||||
mockRefreshLocalStructuredSessionTabs.mockImplementationOnce(
|
||||
() => new Promise<unknown[]>((resolve) => (resolveRefresh = resolve))
|
||||
@@ -212,6 +217,9 @@ describe('structured chat adoption guard on the launch path', () => {
|
||||
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
|
||||
|
||||
expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(1)
|
||||
store.unifiedTabsByWorktree['wt-1'] = [
|
||||
{ contentType: 'agent-session', entityId: 'codex-session-1', worktreeId: 'wt-1' }
|
||||
]
|
||||
resolveRefresh([
|
||||
{ worktree: 'wt-1', tabs: [{ type: 'agent-session', sessionId: 'codex-session-1' }] }
|
||||
])
|
||||
@@ -219,6 +227,7 @@ describe('structured chat adoption guard on the launch path', () => {
|
||||
})
|
||||
|
||||
it('does not create a sibling when post-create visibility proof is unknown', async () => {
|
||||
store.unifiedTabsByWorktree = {}
|
||||
const firstIntent = structuredLaunchIntent('wt-1', 'codex-session-1')
|
||||
const secondIntent = structuredLaunchIntent('wt-1', 'codex-session-2')
|
||||
mockCreateStructuredCodexSessionLaunchIntent
|
||||
@@ -231,9 +240,15 @@ describe('structured chat adoption guard on the launch path', () => {
|
||||
mockRefreshLocalStructuredSessionTabs
|
||||
.mockRejectedValueOnce(new Error('inventory unavailable'))
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
{ worktree: 'wt-1', tabs: [{ type: 'agent-session', sessionId: 'codex-session-1' }] }
|
||||
])
|
||||
.mockImplementationOnce(() => {
|
||||
// The inventory refresh also publishes the host snapshot into the renderer projection.
|
||||
store.unifiedTabsByWorktree['wt-1'] = [
|
||||
{ contentType: 'agent-session', entityId: firstIntent.sessionId, worktreeId: 'wt-1' }
|
||||
]
|
||||
return Promise.resolve([
|
||||
{ worktree: 'wt-1', tabs: [{ type: 'agent-session', sessionId: firstIntent.sessionId }] }
|
||||
])
|
||||
})
|
||||
.mockResolvedValueOnce([
|
||||
{ worktree: 'wt-1', tabs: [{ type: 'agent-session', sessionId: 'codex-session-2' }] }
|
||||
])
|
||||
@@ -256,7 +271,7 @@ describe('structured chat adoption guard on the launch path', () => {
|
||||
{ contentType: 'agent-session', entityId: secondIntent.sessionId, worktreeId: 'wt-1' }
|
||||
]
|
||||
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
|
||||
await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(4))
|
||||
await vi.waitFor(() => expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(3))
|
||||
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledTimes(2)
|
||||
expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(3)
|
||||
expect(mockLaunchStructuredCodexSession.mock.calls[2]?.[0]).toBe(secondIntent)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
|
||||
import type { Tab } from '../../../shared/tab-types'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import type { WorktreeRuntimeOwnerState } from '../lib/worktree-runtime-owner'
|
||||
import { buildPersistedUnifiedTabSessionData } from '../lib/workspace-session-unified-tabs'
|
||||
import { buildHydratedTabState } from '../store/slices/tabs-hydration'
|
||||
import {
|
||||
@@ -212,6 +213,34 @@ describe('local structured session tab projection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('forgets publisher versions when a worktree is removed', () => {
|
||||
type OwnerState = WebSessionTabsSyncState & WorktreeRuntimeOwnerState
|
||||
const owner = {
|
||||
id: WORKTREE_ID,
|
||||
repoId: 'repo-1',
|
||||
hostId: null,
|
||||
runtimeOwnerEnvironmentId: null
|
||||
}
|
||||
let state = {
|
||||
...createSnapshot(),
|
||||
worktreesByRepo: { 'repo-1': [owner] }
|
||||
} as OwnerState
|
||||
state = applyLocalStructuredSessionTabSnapshots(state, [
|
||||
structuredInventory('epoch-1', 10, 'session-old')
|
||||
])
|
||||
state = applyLocalStructuredSessionTabSnapshots(
|
||||
{ ...state, worktreesByRepo: {}, unifiedTabsByWorktree: {} },
|
||||
[]
|
||||
)
|
||||
state = applyLocalStructuredSessionTabSnapshots(
|
||||
{ ...state, worktreesByRepo: { 'repo-1': [owner] } },
|
||||
[structuredInventory('epoch-1', 1, 'session-new')]
|
||||
)
|
||||
expect(state.unifiedTabsByWorktree[WORKTREE_ID]).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ entityId: 'session-new' })])
|
||||
)
|
||||
})
|
||||
|
||||
it('drops terminal topology while retaining structured tabs', () => {
|
||||
const snapshot = {
|
||||
worktree: 'workspace-1',
|
||||
|
||||
@@ -105,6 +105,24 @@ export function applyLocalStructuredSessionTabSnapshots<
|
||||
snapshotVersion: snapshot.snapshotVersion
|
||||
})
|
||||
}
|
||||
// Drop publisher cursors for worktrees that no longer exist. Without this,
|
||||
// every deleted worktree leaves an entry for the lifetime of the renderer.
|
||||
const knownWorktreeIds = new Set<string>(Object.keys(next.unifiedTabsByWorktree))
|
||||
for (const worktrees of Object.values(next.worktreesByRepo ?? {})) {
|
||||
for (const worktree of worktrees) {
|
||||
knownWorktreeIds.add(worktree.id)
|
||||
}
|
||||
}
|
||||
for (const detected of Object.values(next.detectedWorktreesByRepo ?? {})) {
|
||||
for (const worktree of detected.worktrees) {
|
||||
knownWorktreeIds.add(worktree.id)
|
||||
}
|
||||
}
|
||||
for (const worktreeId of localStructuredSessionVersionByWorktree.keys()) {
|
||||
if (!knownWorktreeIds.has(worktreeId)) {
|
||||
localStructuredSessionVersionByWorktree.delete(worktreeId)
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -150,6 +168,7 @@ async function startLocalStructuredSessionTabsSync(args: {
|
||||
return
|
||||
}
|
||||
let subscriptionGeneration = 0
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const subscribeCurrent = async (): Promise<void> => {
|
||||
if (args.isDisposed()) {
|
||||
return
|
||||
@@ -171,20 +190,37 @@ async function startLocalStructuredSessionTabsSync(args: {
|
||||
// Reattach with one refresh so a runtime-restart boundary cannot strand stale tabs.
|
||||
subscriptionGeneration += 1
|
||||
handle?.unsubscribe()
|
||||
void refreshLocalStructuredSessionTabs()
|
||||
.catch((error) => console.warn('[structured-session-tabs] resync failed', error))
|
||||
.finally(() => {
|
||||
if (!args.isDisposed()) {
|
||||
void subscribeCurrent()
|
||||
}
|
||||
})
|
||||
if (reconnectTimer !== null) {
|
||||
clearTimeout(reconnectTimer)
|
||||
}
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
void refreshLocalStructuredSessionTabs()
|
||||
.catch((error) => console.warn('[structured-session-tabs] resync failed', error))
|
||||
.finally(() => {
|
||||
if (!args.isDisposed()) {
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
void subscribeCurrent().catch((error) =>
|
||||
console.warn('[structured-session-tabs] resubscribe failed', error)
|
||||
)
|
||||
}, 250)
|
||||
}
|
||||
})
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
)
|
||||
if (args.isDisposed() || generation !== subscriptionGeneration) {
|
||||
handle.unsubscribe()
|
||||
} else {
|
||||
args.setUnsubscribe(handle.unsubscribe)
|
||||
args.setUnsubscribe(() => {
|
||||
if (reconnectTimer !== null) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
handle?.unsubscribe()
|
||||
})
|
||||
}
|
||||
}
|
||||
await subscribeCurrent()
|
||||
|
||||
Reference in New Issue
Block a user