mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
Merge #24917's ready head 6a86ad7e36 (empty-chat reuse per split) into C2
Takes #24917's round 7 as written: a new chat with no text reuses an empty chat (still starting, or published and idle) in the tab group it was opened from, with the group threaded from the provisional tab to the launch request. C2's requestId, carriedNoteKeys and notes hand-off wiring is unchanged. - The empty-chat check reads the composer's images from the draft store, not the composer hook: the launch path must not load the composer, which closes the store import cycle C2 already removed once. - Two cases pin carried notes beside an empty chat: a notes send (it always has text) never reuses an idle chat and stages its one message with its notes, in its own chat or in the starting empty chat it takes.
This commit is contained in:
@@ -68,6 +68,8 @@ export type AgentSessionLaunchTarget = {
|
||||
executionHostId?: ExecutionHostId
|
||||
/** The saved selection that host said create will seed. */
|
||||
seedOptions?: Readonly<Record<string, string>>
|
||||
/** The tab group the chat opens in. */
|
||||
groupId?: string
|
||||
}
|
||||
|
||||
export type AgentSessionLaunchPlan = Readonly<AgentSessionLaunchVerdict> & {
|
||||
@@ -115,7 +117,8 @@ function beginStructuredPlanLaunch(
|
||||
{
|
||||
...structuredLaunchOptions(verdict),
|
||||
...(executionHostId ? { executionHostId } : {}),
|
||||
...(target?.seedOptions ? { hostSeedOptions: target.seedOptions } : {})
|
||||
...(target?.seedOptions ? { hostSeedOptions: target.seedOptions } : {}),
|
||||
...(target?.groupId ? { targetGroupId: target.groupId } : {})
|
||||
},
|
||||
hooks
|
||||
)
|
||||
|
||||
@@ -101,6 +101,7 @@ const store = {
|
||||
allWorktrees: vi.fn(() => store.worktreesByRepo['repo-1']),
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] },
|
||||
unifiedTabsByWorktree: emptyUnifiedTabsByWorktree,
|
||||
activeGroupIdByWorktree: {},
|
||||
openFiles: [] as { id: string; worktreeId: string }[],
|
||||
browserTabsByWorktree: {} as Record<string, { id: string }[]>,
|
||||
tabBarOrderByWorktree: {} as Record<string, string[]>,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type StructuredAgentLaunchOptions
|
||||
} from '@/lib/structured-agent-session-launch'
|
||||
import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt'
|
||||
import { findIdleEmptyStructuredChat } from '@/lib/structured-agent-session-idle-empty-chat'
|
||||
|
||||
export type StructuredAgentLaunchSettlement =
|
||||
| {
|
||||
@@ -101,6 +102,30 @@ export function beginStructuredAgentLaunchSettlement(
|
||||
options: StructuredAgentLaunchOptions,
|
||||
hooks: StructuredAgentLaunchHooks
|
||||
): StructuredAgentLaunchHandle {
|
||||
// A new chat with nothing to say reuses an empty published one open here (the launch joins an
|
||||
// empty starting one); the reused chat is not this caller's to cancel.
|
||||
const idle =
|
||||
options.resumeFrom || options.prompt?.trim()
|
||||
? undefined
|
||||
: findIdleEmptyStructuredChat(
|
||||
worktreeId,
|
||||
agent,
|
||||
options.executionHostId,
|
||||
options.targetGroupId
|
||||
)
|
||||
if (idle) {
|
||||
return {
|
||||
...idle,
|
||||
settlement: Promise.resolve().then((): StructuredAgentLaunchSettlement => {
|
||||
if (hooks.signal?.aborted) {
|
||||
return { kind: 'cancelled', sessionId: idle.sessionId }
|
||||
}
|
||||
hooks.onStructuredReady?.(idle.sessionId)
|
||||
return { kind: 'structured', sessionId: idle.sessionId }
|
||||
}),
|
||||
cancel: () => {}
|
||||
}
|
||||
}
|
||||
const launch = startStructuredAgentLaunch(worktreeId, agent, options)
|
||||
return {
|
||||
sessionId: launch.sessionId,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
/** The tab group a chat's tab sits in, if it has one in this workspace. */
|
||||
export function structuredChatTabGroupId(
|
||||
worktreeId: string,
|
||||
sessionId: string
|
||||
): string | undefined {
|
||||
const tabId = structuredAgentSessionTabId(sessionId)
|
||||
return useAppStore.getState().unifiedTabsByWorktree[worktreeId]?.find((tab) => tab.id === tabId)
|
||||
?.groupId
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts'
|
||||
import type { Tab, TabGroup } from '../../../shared/tab-types'
|
||||
import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire'
|
||||
import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createIntent: vi.fn(),
|
||||
launch: vi.fn(),
|
||||
callStructuredAgentSession: vi.fn(),
|
||||
refreshTabs: vi.fn(),
|
||||
activateTab: vi.fn(),
|
||||
focusGroup: vi.fn(),
|
||||
statusBySession: new Map<string, AgentSessionStatusSummary['status']>(),
|
||||
liveSessions: new Set<string>()
|
||||
}))
|
||||
|
||||
type StoreState = {
|
||||
unifiedTabsByWorktree: Record<string, Tab[]>
|
||||
groupsByWorktree: Record<string, TabGroup[]>
|
||||
activeGroupIdByWorktree: Record<string, string>
|
||||
nativeChatLaunchDraftByTabId: Record<string, { text: string; adopted?: boolean }>
|
||||
}
|
||||
/** A workspace split into a left (active) and a right tab group. */
|
||||
function emptyStoreState(): StoreState {
|
||||
const group = (id: string): TabGroup => ({
|
||||
id,
|
||||
worktreeId: 'wt-reuse',
|
||||
activeTabId: null,
|
||||
tabOrder: []
|
||||
})
|
||||
return {
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: { 'wt-reuse': [group('group-left'), group('group-right')] },
|
||||
activeGroupIdByWorktree: { 'wt-reuse': 'group-left' },
|
||||
nativeChatLaunchDraftByTabId: {}
|
||||
}
|
||||
}
|
||||
const store = vi.hoisted((): { state: StoreState } => ({ state: emptyStoreState() }))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), info: vi.fn(), message: vi.fn() } }))
|
||||
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
|
||||
vi.mock('@/lib/agent-catalog', () => ({
|
||||
getAgentLabel: () => 'Codex',
|
||||
getAgentCatalog: () => [{ id: 'codex', label: 'Codex' }]
|
||||
}))
|
||||
vi.mock('@/lib/launch-structured-agent-session', () => {
|
||||
class StructuredAgentSessionCreateRefusalError extends Error {}
|
||||
class StructuredAgentSessionOwnerUnresolvedError extends Error {}
|
||||
return {
|
||||
createStructuredAgentSessionLaunchIntent: mocks.createIntent,
|
||||
retryStructuredAgentSessionLaunchIntent: vi.fn(),
|
||||
abandonStructuredAgentSessionLaunchIntent: vi.fn(),
|
||||
launchStructuredAgentSession: mocks.launch,
|
||||
StructuredAgentSessionCreateRefusalError,
|
||||
StructuredAgentSessionOwnerUnresolvedError
|
||||
}
|
||||
})
|
||||
vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({
|
||||
refreshLocalStructuredSessionTabs: mocks.refreshTabs
|
||||
}))
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: mocks.callStructuredAgentSession
|
||||
}))
|
||||
vi.mock('@/runtime/structured-agent-session-status-feed', () => ({
|
||||
getStructuredAgentSessionStatusFeed: () => ({
|
||||
getSessionObservation: (sessionId: string) =>
|
||||
mocks.liveSessions.has(sessionId) ? 'live' : 'unverifiable',
|
||||
getSnapshot: () =>
|
||||
new Map(
|
||||
[...mocks.statusBySession].map(([sessionId, status]) => [sessionId, { sessionId, status }])
|
||||
)
|
||||
})
|
||||
}))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => ({
|
||||
...store.state,
|
||||
createUnifiedTab: (
|
||||
worktreeId: string,
|
||||
contentType: Tab['contentType'],
|
||||
init: { targetGroupId?: string }
|
||||
) => {
|
||||
const groupId =
|
||||
init.targetGroupId ?? store.state.activeGroupIdByWorktree[worktreeId] ?? 'group-left'
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch passes the agent-session fields a structured tab carries.
|
||||
const tab = { ...init, contentType, worktreeId, groupId, createdAt: 1 } as Tab
|
||||
store.state.unifiedTabsByWorktree[worktreeId] = [
|
||||
...(store.state.unifiedTabsByWorktree[worktreeId] ?? []),
|
||||
tab
|
||||
]
|
||||
return tab
|
||||
},
|
||||
activateTab: mocks.activateTab,
|
||||
focusGroup: mocks.focusGroup,
|
||||
setActiveTabType: vi.fn(),
|
||||
seedNativeChatLaunchDraft: vi.fn(),
|
||||
clearNativeChatLaunchDraft: vi.fn()
|
||||
}),
|
||||
subscribe: () => () => undefined
|
||||
}
|
||||
}))
|
||||
|
||||
import {
|
||||
appendStructuredAgentSessionOutboxMessage,
|
||||
readOutbox
|
||||
} from '@/components/native-chat/structured-agent-session-outbox-storage'
|
||||
import { clearNativeChatAttachmentCacheForTests } from '@/components/native-chat/use-native-chat-composer-attachments'
|
||||
import { appendNativeChatAttachmentCache } from '@/components/native-chat/native-chat-draft-images'
|
||||
import {
|
||||
clearNativeChatDraftCacheForTests,
|
||||
writeNativeChatDraftCache
|
||||
} from '@/components/native-chat/native-chat-draft-cache'
|
||||
import {
|
||||
structuredAgentSessionPaneKey,
|
||||
structuredAgentSessionTabId
|
||||
} from '../../../shared/structured-agent-session-projection'
|
||||
import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan'
|
||||
import {
|
||||
beginStructuredAgentSessionProvisionalLaunch,
|
||||
type StructuredAgentSessionProvisionalLaunch
|
||||
} from './structured-agent-session-provisional-tab'
|
||||
import { getStructuredAgentSessionLaunchLifecycle } from './structured-agent-session-launch'
|
||||
import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence'
|
||||
import { resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry'
|
||||
|
||||
const WORKTREE_ID = 'wt-reuse'
|
||||
|
||||
function launchIntent(
|
||||
sessionId: string,
|
||||
worktreeId = WORKTREE_ID
|
||||
): StructuredAgentSessionLaunchIntent {
|
||||
return {
|
||||
worktreeId,
|
||||
sessionId,
|
||||
executionHostId: 'local',
|
||||
target: { kind: 'local' },
|
||||
agent: 'codex',
|
||||
params: {
|
||||
envelope: {
|
||||
sessionId,
|
||||
clientOperationId: `operation-${sessionId}`,
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: `fingerprint-${sessionId}`
|
||||
},
|
||||
worktree: `id:${worktreeId}`,
|
||||
agent: 'codex'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function published(...sessionIds: string[]): RuntimeMobileSessionTabsResult[] {
|
||||
return [WORKTREE_ID, 'wt-other'].map((worktree) => ({
|
||||
worktree,
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: sessionIds.map((sessionId) => ({
|
||||
type: 'agent-session',
|
||||
id: `tab-${sessionId}`,
|
||||
title: 'Codex',
|
||||
sessionId,
|
||||
agent: 'codex',
|
||||
isActive: false
|
||||
}))
|
||||
}))
|
||||
}
|
||||
|
||||
const first = launchIntent('session-first')
|
||||
const second = launchIntent('session-second')
|
||||
|
||||
/** A pick from the + menu, new-tab search or the new-agent shortcut: its own action, no text. */
|
||||
function pick(
|
||||
requestId: string,
|
||||
overrides: {
|
||||
agent?: 'claude' | 'codex'
|
||||
worktreeId?: string
|
||||
prompt?: string
|
||||
/** The split the pick was made in; none means the workspace's active group. */
|
||||
group?: string
|
||||
/** Send keys of the notes the prompt was built from. */
|
||||
carriedNoteKeys?: string[]
|
||||
} = {}
|
||||
): Exclude<StructuredAgentSessionProvisionalLaunch, { sessionId: null }> {
|
||||
const launch = beginStructuredAgentSessionProvisionalLaunch({
|
||||
plan: adoptAgentSessionLaunchVerdict({
|
||||
route: 'structured-native-chat',
|
||||
requestId,
|
||||
agent: overrides.agent ?? 'codex',
|
||||
worktreeId: overrides.worktreeId ?? WORKTREE_ID,
|
||||
executionHostId: 'local',
|
||||
...(overrides.prompt
|
||||
? { prompt: overrides.prompt, promptDelivery: 'submit-after-ready' as const }
|
||||
: {}),
|
||||
...(overrides.carriedNoteKeys ? { carriedNoteKeys: overrides.carriedNoteKeys } : {})
|
||||
}),
|
||||
hooks: {},
|
||||
...(overrides.group ? { targetGroupId: overrides.group } : {})
|
||||
})
|
||||
if (!launch || launch.sessionId === null) {
|
||||
throw new Error('expected a local chat')
|
||||
}
|
||||
return launch
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
/** The first chat published and its host's journal holds no request. */
|
||||
async function publishIdle(sessionId: string): Promise<void> {
|
||||
await flush()
|
||||
expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, sessionId)).toBeNull()
|
||||
mocks.liveSessions.add(sessionId)
|
||||
mocks.statusBySession.set(sessionId, null)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
localStorage.clear()
|
||||
clearNativeChatDraftCacheForTests()
|
||||
clearNativeChatAttachmentCacheForTests()
|
||||
resetStructuredAgentLaunchPersistenceForTests()
|
||||
resetStructuredAgentLaunchRegistryForTests()
|
||||
mocks.statusBySession.clear()
|
||||
mocks.liveSessions.clear()
|
||||
store.state = emptyStoreState()
|
||||
mocks.createIntent
|
||||
.mockReturnValueOnce(first)
|
||||
.mockReturnValueOnce(second)
|
||||
.mockReturnValueOnce(launchIntent('session-third'))
|
||||
mocks.launch.mockImplementation((intent: StructuredAgentSessionLaunchIntent) =>
|
||||
Promise.resolve({ sessionId: intent.sessionId, fence: 1 })
|
||||
)
|
||||
mocks.refreshTabs.mockResolvedValue(published(first.sessionId, second.sessionId))
|
||||
mocks.callStructuredAgentSession.mockResolvedValue({
|
||||
ok: true,
|
||||
value: { submission: { dispatchState: 'accepted' } }
|
||||
})
|
||||
})
|
||||
|
||||
describe('a second "new chat" with no text', () => {
|
||||
it('focuses the empty chat still starting instead of opening another', () => {
|
||||
mocks.launch.mockImplementation(() => new Promise(() => undefined))
|
||||
const firstPick = pick('plus-pick-1')
|
||||
const secondPick = pick('plus-pick-2')
|
||||
|
||||
expect(secondPick.sessionId).toBe(firstPick.sessionId)
|
||||
expect(mocks.createIntent).toHaveBeenCalledOnce()
|
||||
expect(store.state.unifiedTabsByWorktree[WORKTREE_ID]).toHaveLength(1)
|
||||
expect(mocks.activateTab).toHaveBeenCalledWith(structuredAgentSessionTabId(first.sessionId), {
|
||||
worktreeId: WORKTREE_ID
|
||||
})
|
||||
})
|
||||
|
||||
it('focuses the empty chat that published and sits idle, and reports that session', async () => {
|
||||
pick('plus-pick-1')
|
||||
await publishIdle(first.sessionId)
|
||||
|
||||
const secondPick = pick('plus-pick-2')
|
||||
|
||||
expect(secondPick.sessionId).toBe(first.sessionId)
|
||||
expect(mocks.createIntent).toHaveBeenCalledOnce()
|
||||
expect(mocks.activateTab).toHaveBeenCalledWith(structuredAgentSessionTabId(first.sessionId), {
|
||||
worktreeId: WORKTREE_ID
|
||||
})
|
||||
await expect(secondPick.settlement).resolves.toEqual({
|
||||
kind: 'structured',
|
||||
sessionId: first.sessionId
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a new chat when the idle chat has a typed draft', async () => {
|
||||
pick('plus-pick-1')
|
||||
await publishIdle(first.sessionId)
|
||||
const tabId = structuredAgentSessionTabId(first.sessionId)
|
||||
writeNativeChatDraftCache(structuredAgentSessionPaneKey(tabId, first.sessionId), 'half a q')
|
||||
|
||||
expect(pick('plus-pick-2').sessionId).toBe(second.sessionId)
|
||||
})
|
||||
|
||||
it('opens a new chat when the idle chat has an image in its composer', async () => {
|
||||
pick('plus-pick-1')
|
||||
await publishIdle(first.sessionId)
|
||||
const tabId = structuredAgentSessionTabId(first.sessionId)
|
||||
appendNativeChatAttachmentCache(structuredAgentSessionPaneKey(tabId, first.sessionId), [
|
||||
{ id: 'shot', path: '/tmp/shot.png' }
|
||||
])
|
||||
|
||||
expect(pick('plus-pick-2').sessionId).toBe(second.sessionId)
|
||||
})
|
||||
|
||||
it('opens a new chat when the idle chat holds a launch draft its composer has not taken', async () => {
|
||||
pick('plus-pick-1')
|
||||
await publishIdle(first.sessionId)
|
||||
store.state.nativeChatLaunchDraftByTabId[structuredAgentSessionTabId(first.sessionId)] = {
|
||||
text: 'PR context'
|
||||
}
|
||||
|
||||
expect(pick('plus-pick-2').sessionId).toBe(second.sessionId)
|
||||
})
|
||||
|
||||
it('opens a new chat when its host holds a sent message', async () => {
|
||||
pick('plus-pick-1')
|
||||
await publishIdle(first.sessionId)
|
||||
mocks.statusBySession.set(first.sessionId, 'idle')
|
||||
|
||||
expect(pick('plus-pick-2').sessionId).toBe(second.sessionId)
|
||||
})
|
||||
|
||||
it('opens a new chat when its host cannot be heard from', async () => {
|
||||
pick('plus-pick-1')
|
||||
await publishIdle(first.sessionId)
|
||||
mocks.liveSessions.clear()
|
||||
|
||||
expect(pick('plus-pick-2').sessionId).toBe(second.sessionId)
|
||||
})
|
||||
|
||||
it('opens a new chat when the starting chat has a message queued', () => {
|
||||
mocks.launch.mockImplementation(() => new Promise(() => undefined))
|
||||
const firstPick = pick('plus-pick-1')
|
||||
appendStructuredAgentSessionOutboxMessage(firstPick.sessionId, 'my own question')
|
||||
|
||||
expect(pick('plus-pick-2').sessionId).toBe(second.sessionId)
|
||||
})
|
||||
|
||||
it('never reuses a chat whose start failed', async () => {
|
||||
const { StructuredAgentSessionCreateRefusalError } =
|
||||
await import('@/lib/launch-structured-agent-session')
|
||||
mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('refused'))
|
||||
pick('plus-pick-1')
|
||||
await flush()
|
||||
expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, first.sessionId)).toBe('failed')
|
||||
mocks.liveSessions.add(first.sessionId)
|
||||
mocks.statusBySession.set(first.sessionId, null)
|
||||
|
||||
expect(pick('plus-pick-2').sessionId).toBe(second.sessionId)
|
||||
})
|
||||
|
||||
it('never reuses a chat whose start is unconfirmed', async () => {
|
||||
mocks.launch.mockRejectedValueOnce(new Error('answer lost'))
|
||||
mocks.refreshTabs.mockResolvedValue(published())
|
||||
pick('plus-pick-1')
|
||||
await flush()
|
||||
expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, first.sessionId)).toBe(
|
||||
'visibility-unknown'
|
||||
)
|
||||
mocks.liveSessions.add(first.sessionId)
|
||||
mocks.statusBySession.set(first.sessionId, null)
|
||||
|
||||
expect(pick('plus-pick-2').sessionId).toBe(second.sessionId)
|
||||
})
|
||||
|
||||
it("never reuses another agent's or another workspace's idle empty chat", async () => {
|
||||
mocks.createIntent
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(first)
|
||||
.mockReturnValueOnce({ ...second, agent: 'claude' })
|
||||
.mockReturnValueOnce(launchIntent('session-third', 'wt-other'))
|
||||
pick('plus-pick-1')
|
||||
await publishIdle(first.sessionId)
|
||||
|
||||
expect(pick('claude-pick', { agent: 'claude' }).sessionId).toBe(second.sessionId)
|
||||
expect(pick('other-pick', { worktreeId: 'wt-other' }).sessionId).toBe('session-third')
|
||||
})
|
||||
|
||||
it("never reuses another agent's or another workspace's starting empty chat", async () => {
|
||||
mocks.launch.mockImplementation(() => new Promise(() => undefined))
|
||||
mocks.createIntent
|
||||
.mockReset()
|
||||
.mockReturnValueOnce(first)
|
||||
.mockReturnValueOnce({ ...second, agent: 'claude' })
|
||||
.mockReturnValueOnce(launchIntent('session-third', 'wt-other'))
|
||||
pick('plus-pick-1')
|
||||
|
||||
expect(pick('claude-pick', { agent: 'claude' }).sessionId).toBe(second.sessionId)
|
||||
expect(pick('other-pick', { worktreeId: 'wt-other' }).sessionId).toBe('session-third')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a second "new chat" with no text in another split', () => {
|
||||
function tabOf(sessionId: string): Tab | undefined {
|
||||
return store.state.unifiedTabsByWorktree[WORKTREE_ID]?.find(
|
||||
(tab) => tab.id === structuredAgentSessionTabId(sessionId)
|
||||
)
|
||||
}
|
||||
|
||||
it('opens a new chat in its own split beside an idle empty one, and focus stays there', async () => {
|
||||
pick('plus-pick-1', { group: 'group-left' })
|
||||
await publishIdle(first.sessionId)
|
||||
|
||||
const right = pick('plus-pick-2', { group: 'group-right' })
|
||||
|
||||
expect(right.sessionId).toBe(second.sessionId)
|
||||
expect(tabOf(second.sessionId)?.groupId).toBe('group-right')
|
||||
expect(mocks.focusGroup).not.toHaveBeenCalled()
|
||||
expect(mocks.activateTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reuses the idle empty chat in the split it was picked in', async () => {
|
||||
pick('plus-pick-1', { group: 'group-right' })
|
||||
await publishIdle(first.sessionId)
|
||||
|
||||
expect(pick('plus-pick-2', { group: 'group-right' }).sessionId).toBe(first.sessionId)
|
||||
expect(mocks.focusGroup).toHaveBeenCalledWith(WORKTREE_ID, 'group-right')
|
||||
})
|
||||
|
||||
it('opens a new chat in its own split beside a starting empty one', () => {
|
||||
mocks.launch.mockImplementation(() => new Promise(() => undefined))
|
||||
pick('plus-pick-1', { group: 'group-left' })
|
||||
|
||||
const right = pick('plus-pick-2', { group: 'group-right' })
|
||||
|
||||
expect(right.sessionId).toBe(second.sessionId)
|
||||
expect(tabOf(second.sessionId)?.groupId).toBe('group-right')
|
||||
expect(mocks.focusGroup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reuses the starting empty chat in the split it was picked in', () => {
|
||||
mocks.launch.mockImplementation(() => new Promise(() => undefined))
|
||||
const firstPick = pick('plus-pick-1', { group: 'group-right' })
|
||||
|
||||
expect(pick('plus-pick-2', { group: 'group-right' }).sessionId).toBe(firstPick.sessionId)
|
||||
expect(mocks.createIntent).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// The dashboard and other callers name no group: the workspace's active one is where they open.
|
||||
it('treats a pick that names no split as made in the active one', async () => {
|
||||
pick('plus-pick-1', { group: 'group-right' })
|
||||
await publishIdle(first.sessionId)
|
||||
|
||||
expect(pick('dashboard-pick').sessionId).toBe(second.sessionId)
|
||||
expect(tabOf(second.sessionId)?.groupId).toBe('group-left')
|
||||
store.state.activeGroupIdByWorktree[WORKTREE_ID] = 'group-right'
|
||||
expect(pick('dashboard-pick-2').sessionId).toBe(first.sessionId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a "new chat" with text', () => {
|
||||
it('opens its own chat beside an idle empty one', async () => {
|
||||
pick('plus-pick-1')
|
||||
await publishIdle(first.sessionId)
|
||||
|
||||
const notes = pick('notes-send', { prompt: 'review notes' })
|
||||
|
||||
expect(notes.sessionId).toBe(second.sessionId)
|
||||
await expect(notes.promptDeliveryResult).resolves.toEqual({
|
||||
delivered: true,
|
||||
failureNotified: false
|
||||
})
|
||||
})
|
||||
|
||||
it('opens its own chat in its own split beside an empty chat starting in another', () => {
|
||||
mocks.launch.mockImplementation(() => new Promise(() => undefined))
|
||||
const blank = pick('plus-pick-1', { group: 'group-left' })
|
||||
|
||||
const notes = pick('notes-send', { prompt: 'review notes', group: 'group-right' })
|
||||
|
||||
expect(notes.sessionId).toBe(second.sessionId)
|
||||
expect(
|
||||
store.state.unifiedTabsByWorktree[WORKTREE_ID]?.find(
|
||||
(tab) => tab.entityId === second.sessionId
|
||||
)?.groupId
|
||||
).toBe('group-right')
|
||||
expect(mocks.focusGroup).not.toHaveBeenCalled()
|
||||
expect(readOutbox(blank.sessionId)).toEqual([])
|
||||
expect(readOutbox(second.sessionId).map((entry) => entry.body.blocks)).toEqual([
|
||||
[{ type: 'text', text: 'review notes' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('takes an empty chat starting in its own split', () => {
|
||||
mocks.launch.mockImplementation(() => new Promise(() => undefined))
|
||||
const blank = pick('plus-pick-1', { group: 'group-right' })
|
||||
|
||||
expect(pick('notes-send', { prompt: 'review notes', group: 'group-right' }).sessionId).toBe(
|
||||
blank.sessionId
|
||||
)
|
||||
})
|
||||
|
||||
it('takes an empty chat still starting, as before', () => {
|
||||
mocks.launch.mockImplementation(() => new Promise(() => undefined))
|
||||
const blank = pick('plus-pick-1')
|
||||
|
||||
expect(pick('notes-send', { prompt: 'review notes' }).sessionId).toBe(blank.sessionId)
|
||||
})
|
||||
})
|
||||
|
||||
// A notes send always carries text, so it never reuses an idle chat; its notes travel with its one
|
||||
// staged message, wherever that goes.
|
||||
describe('a notes send beside an empty chat', () => {
|
||||
const NOTES = ['note-a', 'note-b']
|
||||
|
||||
it('stages its message once, with its notes, in its own chat beside an idle empty one', async () => {
|
||||
pick('plus-pick-1')
|
||||
await publishIdle(first.sessionId)
|
||||
|
||||
const notes = pick('notes-send', { prompt: 'review notes', carriedNoteKeys: NOTES })
|
||||
|
||||
expect(notes.sessionId).toBe(second.sessionId)
|
||||
expect(readOutbox(first.sessionId)).toEqual([])
|
||||
expect(
|
||||
readOutbox(second.sessionId).map((entry) => [entry.body.blocks, entry.carriedNoteKeys])
|
||||
).toEqual([[[{ type: 'text', text: 'review notes' }], NOTES]])
|
||||
})
|
||||
|
||||
it('stages its message once, with its notes, in the empty chat it takes while starting', () => {
|
||||
mocks.launch.mockImplementation(() => new Promise(() => undefined))
|
||||
const blank = pick('plus-pick-1')
|
||||
|
||||
const notes = pick('notes-send', { prompt: 'review notes', carriedNoteKeys: NOTES })
|
||||
|
||||
expect(notes.sessionId).toBe(blank.sessionId)
|
||||
expect(
|
||||
readOutbox(blank.sessionId).map((entry) => [entry.body.blocks, entry.carriedNoteKeys])
|
||||
).toEqual([[[{ type: 'text', text: 'review notes' }], NOTES]])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { Tab } from '../../../shared/tab-types'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
structuredAgentSessionOwnerForTab,
|
||||
structuredAgentSessionTargetForHost
|
||||
} from '@/runtime/structured-agent-session-owner'
|
||||
import { getStructuredAgentSessionStatusFeed } from '@/runtime/structured-agent-session-status-feed'
|
||||
import { getStructuredAgentSessionLaunchLifecycle } from './structured-agent-session-launch-registry'
|
||||
import { isStructuredLaunchChatEmpty } from './structured-agent-session-launch-empty-chat'
|
||||
|
||||
export type IdleEmptyStructuredChat = { sessionId: string; executionHostId: ExecutionHostId }
|
||||
|
||||
/** Published, and its host's journal holds no request (a null status). A resumed chat's journal
|
||||
* holds the imported conversation, so it never reads as empty. */
|
||||
function hostHoldsNoRequest(tab: Tab, executionHostId: ExecutionHostId): boolean {
|
||||
const lifecycle = getStructuredAgentSessionLaunchLifecycle(tab.worktreeId, tab.entityId)
|
||||
const target = structuredAgentSessionTargetForHost(executionHostId)
|
||||
if ((lifecycle !== null && lifecycle !== 'published') || !target) {
|
||||
return false
|
||||
}
|
||||
const feed = getStructuredAgentSessionStatusFeed(target)
|
||||
// Why live only: a summary cached across a lost stream may predate a message the host took.
|
||||
return (
|
||||
feed.getSessionObservation(tab.entityId) === 'live' &&
|
||||
feed.getSnapshot().get(tab.entityId)?.status === null
|
||||
)
|
||||
}
|
||||
|
||||
/** A launch draft its composer has not taken in yet (a chat opened in the background). */
|
||||
function holdsUnadoptedLaunchDraft(tabId: string): boolean {
|
||||
const draft = useAppStore.getState().nativeChatLaunchDraftByTabId[tabId]
|
||||
return Boolean(draft && !draft.adopted && !draft.resolved && draft.text.trim())
|
||||
}
|
||||
|
||||
/** An open chat for `agent` in this workspace's `groupId` (else any group) that nothing was ever
|
||||
* sent into and whose composer is untouched. Prefers the group's active tab, else the newest. */
|
||||
export function findIdleEmptyStructuredChat(
|
||||
worktreeId: string,
|
||||
agent: AgentSessionHandleProvider,
|
||||
executionHostId?: ExecutionHostId,
|
||||
groupId?: string
|
||||
): IdleEmptyStructuredChat | undefined {
|
||||
const state = useAppStore.getState()
|
||||
const candidates: (IdleEmptyStructuredChat & { tab: Tab })[] = []
|
||||
for (const tab of state.unifiedTabsByWorktree[worktreeId] ?? []) {
|
||||
const owner =
|
||||
tab.contentType === 'agent-session' && tab.agentSessionAgent === agent
|
||||
? structuredAgentSessionOwnerForTab(state, tab)
|
||||
: null
|
||||
if (
|
||||
owner &&
|
||||
(!groupId || tab.groupId === groupId) &&
|
||||
(!executionHostId || owner === executionHostId) &&
|
||||
hostHoldsNoRequest(tab, owner) &&
|
||||
isStructuredLaunchChatEmpty(tab.entityId) &&
|
||||
!holdsUnadoptedLaunchDraft(tab.id)
|
||||
) {
|
||||
candidates.push({ sessionId: tab.entityId, executionHostId: owner, tab })
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
const focusGroupId = groupId ?? state.activeGroupIdByWorktree[worktreeId]
|
||||
const focusedTabId = state.groupsByWorktree[worktreeId]?.find(
|
||||
(group) => group.id === focusGroupId
|
||||
)?.activeTabId
|
||||
const chosen =
|
||||
candidates.find((candidate) => candidate.tab.id === focusedTabId) ??
|
||||
candidates.toSorted((a, b) => a.tab.createdAt - b.tab.createdAt).at(-1)
|
||||
return chosen && { sessionId: chosen.sessionId, executionHostId: chosen.executionHostId }
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export type StructuredAgentLaunchOptions = {
|
||||
executionHostId?: ExecutionHostId
|
||||
/** The saved selection a paired host reported it will seed; read only by the starting caller. */
|
||||
hostSeedOptions?: Readonly<Record<string, string>>
|
||||
/** The tab group the chat opens in; a request with no text reuses an empty chat only there. */
|
||||
targetGroupId?: string
|
||||
}
|
||||
|
||||
export type StructuredLaunchCaller = {
|
||||
|
||||
@@ -4,13 +4,16 @@ import {
|
||||
} from '../../../shared/structured-agent-session-projection'
|
||||
import { getStructuredAgentSessionOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage'
|
||||
import { readNativeChatDraftCache } from '@/components/native-chat/native-chat-draft-cache'
|
||||
// The draft store, not the composer hook: the launch must not load the composer (a store cycle).
|
||||
import { readNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store'
|
||||
|
||||
/** A starting chat is empty until its user sends into it or types in its composer; after that it
|
||||
* is theirs, and another request's text never goes into it. */
|
||||
/** A starting chat is empty until its user sends into it or puts text or images in its composer;
|
||||
* after that it is theirs, and another request never goes into it. */
|
||||
export function isStructuredLaunchChatEmpty(sessionId: string): boolean {
|
||||
const paneKey = structuredAgentSessionPaneKey(structuredAgentSessionTabId(sessionId), sessionId)
|
||||
return (
|
||||
getStructuredAgentSessionOutbox(sessionId).length === 0 &&
|
||||
readNativeChatDraftCache(paneKey).trim() === ''
|
||||
readNativeChatDraftCache(paneKey).trim() === '' &&
|
||||
readNativeChatComposerDraft(paneKey).images.length === 0
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
} from './structured-agent-session-launch-request'
|
||||
import type { AgentLaunchRequestId } from './agent-launch-request-id'
|
||||
import { isStructuredLaunchChatEmpty } from './structured-agent-session-launch-empty-chat'
|
||||
import { structuredChatTabGroupId } from './structured-agent-session-chat-tab-group'
|
||||
|
||||
// Why: coalescing stops one user action delivered twice (a double click) racing into two chats. Any
|
||||
// other action, a failed or unconfirmed launch, or a Retry/re-check of one is not that race: a new
|
||||
@@ -43,25 +44,31 @@ export function structuredLaunchesHoldingIdentity(
|
||||
)
|
||||
}
|
||||
|
||||
/** An empty chat (a + pick, the empty-workspace default) still starting: the first request with text
|
||||
* claims it once, and from then on it is that request's chat. A resume is never empty, nor a chat
|
||||
* its user has already sent or typed into. */
|
||||
export function claimableStructuredLaunchAttempt(
|
||||
state: StructuredLaunchState,
|
||||
request: StructuredLaunchRequest
|
||||
/** A blank first attempt (a + pick, the empty-workspace default) still starting, which its user has
|
||||
* not sent or typed into. A resume is never empty. */
|
||||
function emptyStructuredLaunchAttempt(
|
||||
state: StructuredLaunchState
|
||||
): Extract<StructuredLaunchAttempt, { kind: 'first' }> | undefined {
|
||||
const { attempt } = state.callers
|
||||
return !state.intent.params.resumeFrom &&
|
||||
attempt.kind === 'first' &&
|
||||
attempt.blank &&
|
||||
request.hasText &&
|
||||
isStructuredLaunchChatEmpty(state.intent.sessionId)
|
||||
? attempt
|
||||
: undefined
|
||||
}
|
||||
|
||||
/** The launch a new start of `request` joins: one it re-delivers, else an empty chat it claims. The
|
||||
* newest wins if a retried resume holds the identity too. */
|
||||
/** The first request with text claims an empty starting chat once; from then on it is that
|
||||
* request's chat. */
|
||||
export function claimableStructuredLaunchAttempt(
|
||||
state: StructuredLaunchState,
|
||||
request: StructuredLaunchRequest
|
||||
): Extract<StructuredLaunchAttempt, { kind: 'first' }> | undefined {
|
||||
return request.hasText ? emptyStructuredLaunchAttempt(state) : undefined
|
||||
}
|
||||
|
||||
/** The launch a new start of `request` joins: one it re-delivers, else an empty starting chat in the
|
||||
* tab group it opens in, which a request with text claims and one without reuses. The newest wins. */
|
||||
export function getJoinableStructuredLaunchState(
|
||||
identity: string,
|
||||
request: StructuredLaunchRequest
|
||||
@@ -69,8 +76,12 @@ export function getJoinableStructuredLaunchState(
|
||||
const matches = (candidate: string): boolean => candidate === identity
|
||||
return (
|
||||
structuredLaunchesHoldingIdentity(matches, request.id).at(-1) ??
|
||||
structuredLaunchesHoldingIdentity(matches).findLast((state) =>
|
||||
claimableStructuredLaunchAttempt(state, request)
|
||||
structuredLaunchesHoldingIdentity(matches).findLast(
|
||||
(state) =>
|
||||
emptyStructuredLaunchAttempt(state) &&
|
||||
(!request.groupId ||
|
||||
structuredChatTabGroupId(state.intent.worktreeId, state.intent.sessionId) ===
|
||||
request.groupId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox'
|
||||
import type { AgentLaunchRequestId } from './agent-launch-request-id'
|
||||
|
||||
/** What a new start brings: the user action it serves, and whether it carries text. */
|
||||
export type StructuredLaunchRequest = { id: AgentLaunchRequestId; hasText: boolean }
|
||||
/** What a new start brings: the user action it serves, whether it carries text, and the tab group
|
||||
* it opens in. */
|
||||
export type StructuredLaunchRequest = {
|
||||
id: AgentLaunchRequestId
|
||||
hasText: boolean
|
||||
groupId?: string
|
||||
}
|
||||
|
||||
/** A new start's own create keeps its request and the text it staged; a Retry or re-check of an
|
||||
* existing chat is no request of its own. */
|
||||
@@ -19,8 +24,13 @@ export type StructuredLaunchAttempt =
|
||||
export function structuredLaunchRequest(options: {
|
||||
requestId: AgentLaunchRequestId
|
||||
prompt?: string
|
||||
targetGroupId?: string
|
||||
}): StructuredLaunchRequest {
|
||||
return { id: options.requestId, hasText: (options.prompt?.trim() ?? '') !== '' }
|
||||
return {
|
||||
id: options.requestId,
|
||||
hasText: (options.prompt?.trim() ?? '') !== '',
|
||||
...(options.targetGroupId ? { groupId: options.targetGroupId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** The first attempt `requestId` re-delivers, whose text is already staged or seeded. */
|
||||
|
||||
@@ -11,6 +11,7 @@ vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => ({
|
||||
unifiedTabsByWorktree: {},
|
||||
activeGroupIdByWorktree: {},
|
||||
createUnifiedTab: mocks.createUnifiedTab,
|
||||
setActiveTabType: vi.fn()
|
||||
})
|
||||
|
||||
@@ -152,11 +152,15 @@ export function beginStructuredAgentSessionProvisionalLaunch(
|
||||
}
|
||||
|
||||
function beginLocalProvisionalLaunch(args: ProvisionalLaunchArgs): LocalProvisionalLaunch | null {
|
||||
const handle = args.plan.begin(args.hooks, args.target)
|
||||
const worktreeId = args.target?.worktreeId ?? args.plan.worktreeId
|
||||
// The group the tab opens in: the caller's, else the workspace's active one.
|
||||
const groupId =
|
||||
args.targetGroupId ??
|
||||
(worktreeId ? useAppStore.getState().activeGroupIdByWorktree[worktreeId] : undefined)
|
||||
const handle = args.plan.begin(args.hooks, groupId ? { ...args.target, groupId } : args.target)
|
||||
if (!handle) {
|
||||
return null
|
||||
}
|
||||
const worktreeId = args.target?.worktreeId ?? args.plan.worktreeId
|
||||
if (!worktreeId || (args.plan.agent !== 'claude' && args.plan.agent !== 'codex')) {
|
||||
throw new Error('A provisional structured launch needs its workspace and provider.')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user