mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
fix(native-chat): make structured launches observable and recoverable
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { Settings as SettingsIcon } from 'lucide-react'
|
||||
import { Loader2, Settings as SettingsIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { DropdownMenuItem, DropdownMenuShortcut } from '@/components/ui/dropdown-menu'
|
||||
import { getAgentCatalog, AgentIcon } from '@/lib/agent-catalog'
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
filterEnabledTuiAgents
|
||||
} from '../../../../shared/tui-agent-selection'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useStructuredCodexLaunchStatus } from '@/lib/structured-agent-session-launch'
|
||||
|
||||
export type QuickLaunchAgentMenuItemsProps = {
|
||||
worktreeId: string
|
||||
@@ -116,6 +117,7 @@ function QuickLaunchAgentMenuItemsInner({
|
||||
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
|
||||
const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent')
|
||||
const structuredCodexLaunchStatus = useStructuredCodexLaunchStatus(worktreeId)
|
||||
|
||||
const openAgentSettings = useCallback(() => {
|
||||
openSettingsTarget({ pane: 'agents', repoId: null })
|
||||
@@ -197,21 +199,31 @@ function QuickLaunchAgentMenuItemsInner({
|
||||
{agents.map((agent) => {
|
||||
const entry = getCatalogEntry(agent)
|
||||
const label = entry?.label ?? agent
|
||||
const isStructuredCodexPending =
|
||||
agent === 'codex' && structuredCodexLaunchStatus === 'pending'
|
||||
const menuLabel = isStructuredCodexPending ? 'Starting Codex chat…' : label
|
||||
const showsDefaultAgentShortcut =
|
||||
newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={agent}
|
||||
disabled={isStructuredCodexPending}
|
||||
onSelect={() => runLaunch(agent)}
|
||||
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
|
||||
title={translate(
|
||||
'auto.components.tab.bar.QuickLaunchButton.ec2adf093e',
|
||||
'Launch {{value0}} in a new terminal',
|
||||
{ value0: label }
|
||||
isStructuredCodexPending
|
||||
? 'Starting Codex chat…'
|
||||
: 'Launch {{value0}} in a new terminal',
|
||||
isStructuredCodexPending ? undefined : { value0: label }
|
||||
)}
|
||||
>
|
||||
<AgentIcon agent={agent} size={14} />
|
||||
<span className="flex-1">{label}</span>
|
||||
{isStructuredCodexPending ? (
|
||||
<Loader2 className="size-3.5 shrink-0 animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<AgentIcon agent={agent} size={14} />
|
||||
)}
|
||||
<span className="flex-1">{menuLabel}</span>
|
||||
{showsDefaultAgentShortcut ? (
|
||||
<DropdownMenuShortcut>{newAgentShortcut}</DropdownMenuShortcut>
|
||||
) : null}
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
getTabEntryOmniboxPlaceholder
|
||||
} from './tab-create-entry-copy'
|
||||
import { EMPTY_AGENT_OPTIONS, EMPTY_MENU_OPTIONS } from './tab-create-entry-empty-options'
|
||||
import { useStructuredCodexLaunchStatus } from '@/lib/structured-agent-session-launch'
|
||||
import type { TabEntryActionClassification } from './tab-create-entry-classifier'
|
||||
import type { TabBarCreateEntryProps } from './tab-create-entry-props'
|
||||
|
||||
@@ -59,6 +60,7 @@ function TabBarCreateEntrySession({
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [switchError, setSwitchError] = useState<string | null>(null)
|
||||
const [selectionGuidance, setSelectionGuidance] = useState<string | null>(null)
|
||||
const structuredCodexLaunchStatus = useStructuredCodexLaunchStatus(worktreeId)
|
||||
// null = follow ranking (deferred tabs can prepend); set on arrow keys only.
|
||||
const [pinnedOptionId, setPinnedOptionId] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -374,8 +376,26 @@ function TabBarCreateEntrySession({
|
||||
id={resultOptionDomId(index)}
|
||||
option={option}
|
||||
selected={index === activeSelectedIndex}
|
||||
disabled={disabled || pending}
|
||||
loading={pending && index === activeSelectedIndex}
|
||||
labelOverride={
|
||||
option.kind === 'agent' &&
|
||||
option.option.agent === 'codex' &&
|
||||
structuredCodexLaunchStatus === 'pending'
|
||||
? 'Starting Codex chat…'
|
||||
: undefined
|
||||
}
|
||||
disabled={
|
||||
disabled ||
|
||||
pending ||
|
||||
(option.kind === 'agent' &&
|
||||
option.option.agent === 'codex' &&
|
||||
structuredCodexLaunchStatus === 'pending')
|
||||
}
|
||||
loading={
|
||||
(pending && index === activeSelectedIndex) ||
|
||||
(option.kind === 'agent' &&
|
||||
option.option.agent === 'codex' &&
|
||||
structuredCodexLaunchStatus === 'pending')
|
||||
}
|
||||
onClick={() => {
|
||||
setSelectionGuidance(null)
|
||||
submitOption(option)
|
||||
|
||||
@@ -44,6 +44,7 @@ export function EntryStatusRow({
|
||||
export function EntryActionRow({
|
||||
disabled = false,
|
||||
id,
|
||||
labelOverride,
|
||||
loading = false,
|
||||
onClick,
|
||||
option,
|
||||
@@ -51,12 +52,13 @@ export function EntryActionRow({
|
||||
}: {
|
||||
disabled?: boolean
|
||||
id: string
|
||||
labelOverride?: string
|
||||
loading?: boolean
|
||||
onClick: () => void
|
||||
option: ActiveOption
|
||||
selected: boolean
|
||||
}): React.JSX.Element {
|
||||
const presentation = getActionPresentation(option)
|
||||
const presentation = getActionPresentation(option, labelOverride)
|
||||
|
||||
const row = (
|
||||
<button
|
||||
@@ -150,7 +152,10 @@ function getOpenTabIcon(option: Extract<ActiveOption, { kind: 'tab' }>['option']
|
||||
return <GitCompare className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
}
|
||||
|
||||
function getActionPresentation(option: ActiveOption): {
|
||||
function getActionPresentation(
|
||||
option: ActiveOption,
|
||||
labelOverride?: string
|
||||
): {
|
||||
detail: string
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
@@ -201,7 +206,9 @@ function getActionPresentation(option: ActiveOption): {
|
||||
return {
|
||||
detail: option.option.label,
|
||||
icon: <AgentIcon agent={option.option.agent} size={14} />,
|
||||
label: translate('auto.components.tab.bar.TabBarCreateEntry.b27864279e', 'Launch agent'),
|
||||
label:
|
||||
labelOverride ??
|
||||
translate('auto.components.tab.bar.TabBarCreateEntry.b27864279e', 'Launch agent'),
|
||||
showDetail: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ 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' }]
|
||||
},
|
||||
openFiles: [] as { id: string; worktreeId: string }[],
|
||||
browserTabsByWorktree: {} as Record<string, { id: string }[]>,
|
||||
tabBarOrderByWorktree: {} as Record<string, string[]>,
|
||||
@@ -249,6 +252,9 @@ describe('structured chat adoption guard on the launch path', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
// A successful retry must release the reservation so a later launch can start normally.
|
||||
store.unifiedTabsByWorktree['wt-1'] = [
|
||||
{ contentType: 'agent-session', entityId: secondIntent.sessionId, worktreeId: 'wt-1' }
|
||||
]
|
||||
launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
|
||||
await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(4))
|
||||
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledTimes(2)
|
||||
|
||||
@@ -5,7 +5,8 @@ import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-ses
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createIntent: vi.fn(),
|
||||
launch: vi.fn(),
|
||||
abandonIntent: vi.fn()
|
||||
abandonIntent: vi.fn(),
|
||||
rendererTabs: {} as Record<string, unknown[]>
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
@@ -33,6 +34,12 @@ vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => ({ unifiedTabsByWorktree: mocks.rendererTabs })
|
||||
}
|
||||
}))
|
||||
|
||||
import {
|
||||
StructuredAgentSessionCreateRefusalError,
|
||||
type StructuredAgentSessionLaunchIntent
|
||||
@@ -64,6 +71,9 @@ function launchIntent(
|
||||
}
|
||||
|
||||
function publishedSnapshot(worktreeId: string, sessionId: string): RuntimeMobileSessionTabsResult {
|
||||
mocks.rendererTabs[worktreeId] = [
|
||||
{ contentType: 'agent-session', entityId: sessionId, worktreeId }
|
||||
]
|
||||
return {
|
||||
worktree: worktreeId,
|
||||
publicationEpoch: 'epoch-1',
|
||||
@@ -93,6 +103,7 @@ async function flushLaunchSettlement(): Promise<void> {
|
||||
describe('startStructuredCodexLaunch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.rendererTabs = {}
|
||||
mocks.createIntent.mockImplementation((worktreeId: string) => launchIntent(worktreeId))
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
createStructuredCodexSessionLaunchIntent,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
} from '@/lib/launch-structured-codex-session'
|
||||
import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
type StructuredLaunchState = {
|
||||
intent: StructuredAgentSessionLaunchIntent
|
||||
@@ -16,7 +18,37 @@ type StructuredLaunchState = {
|
||||
cancelled: boolean
|
||||
}
|
||||
|
||||
export type StructuredCodexLaunchStatus = 'idle' | 'pending' | 'unknown'
|
||||
|
||||
const pendingStructuredLaunchesByWorktree = new Map<string, StructuredLaunchState>()
|
||||
const structuredLaunchListeners = new Set<() => void>()
|
||||
|
||||
function notifyStructuredLaunchListeners(): void {
|
||||
for (const listener of structuredLaunchListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeStructuredCodexLaunchStatus(listener: () => void): () => void {
|
||||
structuredLaunchListeners.add(listener)
|
||||
return () => structuredLaunchListeners.delete(listener)
|
||||
}
|
||||
|
||||
export function getStructuredCodexLaunchStatus(worktreeId: string): StructuredCodexLaunchStatus {
|
||||
const state = pendingStructuredLaunchesByWorktree.get(worktreeId)
|
||||
if (!state) {
|
||||
return 'idle'
|
||||
}
|
||||
return state.visibilityUnknown ? 'unknown' : 'pending'
|
||||
}
|
||||
|
||||
export function useStructuredCodexLaunchStatus(worktreeId: string): StructuredCodexLaunchStatus {
|
||||
return useSyncExternalStore(
|
||||
subscribeStructuredCodexLaunchStatus,
|
||||
() => getStructuredCodexLaunchStatus(worktreeId),
|
||||
() => 'idle'
|
||||
)
|
||||
}
|
||||
|
||||
class StructuredAgentSessionLaunchCancelledError extends Error {
|
||||
constructor() {
|
||||
@@ -43,6 +75,7 @@ function trackLaunchSettlement(
|
||||
pendingStructuredLaunchesByWorktree.get(worktreeId) === state
|
||||
) {
|
||||
pendingStructuredLaunchesByWorktree.delete(worktreeId)
|
||||
notifyStructuredLaunchListeners()
|
||||
}
|
||||
},
|
||||
() => {
|
||||
@@ -52,6 +85,7 @@ function trackLaunchSettlement(
|
||||
pendingStructuredLaunchesByWorktree.get(worktreeId) === state
|
||||
) {
|
||||
pendingStructuredLaunchesByWorktree.delete(worktreeId)
|
||||
notifyStructuredLaunchListeners()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -69,6 +103,17 @@ async function verifyPublishedSession(intent: StructuredAgentSessionLaunchIntent
|
||||
if (!published) {
|
||||
throw new Error('structured session tab publication unavailable')
|
||||
}
|
||||
const adopted = useAppStore
|
||||
.getState()
|
||||
.unifiedTabsByWorktree[intent.worktreeId]?.some(
|
||||
(tab) =>
|
||||
tab.contentType === 'agent-session' &&
|
||||
tab.entityId === intent.sessionId &&
|
||||
tab.worktreeId === intent.worktreeId
|
||||
)
|
||||
if (!adopted) {
|
||||
throw new Error('structured session tab adoption unavailable')
|
||||
}
|
||||
return intent.sessionId
|
||||
}
|
||||
|
||||
@@ -89,6 +134,7 @@ async function retrySameIntent(state: StructuredLaunchState, priorError: unknown
|
||||
return await verifyPublishedSession(state.intent)
|
||||
} catch {
|
||||
state.visibilityUnknown = true
|
||||
notifyStructuredLaunchListeners()
|
||||
throw error ?? priorError
|
||||
}
|
||||
}
|
||||
@@ -125,6 +171,7 @@ async function launchAndReconcile(state: StructuredLaunchState): Promise<string>
|
||||
async function reconcileUnknownLaunch(state: StructuredLaunchState): Promise<string> {
|
||||
throwIfLaunchCancelled(state)
|
||||
state.visibilityUnknown = false
|
||||
notifyStructuredLaunchListeners()
|
||||
try {
|
||||
return await verifyPublishedSession(state.intent)
|
||||
} catch (error) {
|
||||
@@ -149,6 +196,7 @@ function launchStructuredCodexSessionOnce(worktreeId: string): Promise<string> {
|
||||
}
|
||||
state.promise = launchAndReconcile(state)
|
||||
pendingStructuredLaunchesByWorktree.set(worktreeId, state)
|
||||
notifyStructuredLaunchListeners()
|
||||
trackLaunchSettlement(worktreeId, state, state.promise)
|
||||
return state.promise
|
||||
}
|
||||
@@ -161,6 +209,7 @@ export function cancelStructuredCodexLaunch(worktreeId: string, sessionId: strin
|
||||
}
|
||||
state.cancelled = true
|
||||
pendingStructuredLaunchesByWorktree.delete(worktreeId)
|
||||
notifyStructuredLaunchListeners()
|
||||
abandonStructuredAgentSessionLaunchIntent(state.intent)
|
||||
return true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user