diff --git a/src/renderer/src/lib/agent-session-launch-plan.ts b/src/renderer/src/lib/agent-session-launch-plan.ts index e7a0a8f9fb1..b8f718164f6 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.ts @@ -68,6 +68,8 @@ export type AgentSessionLaunchTarget = { executionHostId?: ExecutionHostId /** The saved selection that host said create will seed. */ seedOptions?: Readonly> + /** The tab group the chat opens in. */ + groupId?: string } export type AgentSessionLaunchPlan = Readonly & { @@ -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 ) diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts index da3294c54eb..dc3be132f7e 100644 --- a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -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, tabBarOrderByWorktree: {} as Record, diff --git a/src/renderer/src/lib/structured-agent-launch-settlement.ts b/src/renderer/src/lib/structured-agent-launch-settlement.ts index 9815f71b48a..69c3a58ddff 100644 --- a/src/renderer/src/lib/structured-agent-launch-settlement.ts +++ b/src/renderer/src/lib/structured-agent-launch-settlement.ts @@ -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, diff --git a/src/renderer/src/lib/structured-agent-session-chat-tab-group.ts b/src/renderer/src/lib/structured-agent-session-chat-tab-group.ts new file mode 100644 index 00000000000..89a6bd8cb9a --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-chat-tab-group.ts @@ -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 +} diff --git a/src/renderer/src/lib/structured-agent-session-empty-chat-reuse.test.ts b/src/renderer/src/lib/structured-agent-session-empty-chat-reuse.test.ts new file mode 100644 index 00000000000..5ca6ee99f6c --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-empty-chat-reuse.test.ts @@ -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(), + liveSessions: new Set() +})) + +type StoreState = { + unifiedTabsByWorktree: Record + groupsByWorktree: Record + activeGroupIdByWorktree: Record + nativeChatLaunchDraftByTabId: Record +} +/** 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 { + 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 { + 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 { + 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]]) + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-idle-empty-chat.ts b/src/renderer/src/lib/structured-agent-session-idle-empty-chat.ts new file mode 100644 index 00000000000..45d7970217e --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-idle-empty-chat.ts @@ -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 } +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-callers.ts b/src/renderer/src/lib/structured-agent-session-launch-callers.ts index 0843a326ac2..56e7b9b9af5 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-callers.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-callers.ts @@ -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> + /** The tab group the chat opens in; a request with no text reuses an empty chat only there. */ + targetGroupId?: string } export type StructuredLaunchCaller = { diff --git a/src/renderer/src/lib/structured-agent-session-launch-empty-chat.ts b/src/renderer/src/lib/structured-agent-session-launch-empty-chat.ts index e8c5becd685..1d11c7b03d4 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-empty-chat.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-empty-chat.ts @@ -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 ) } diff --git a/src/renderer/src/lib/structured-agent-session-launch-holders.ts b/src/renderer/src/lib/structured-agent-session-launch-holders.ts index 1d3cb384e20..2b9b62a1473 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-holders.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-holders.ts @@ -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 | 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 | 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) ) ) } diff --git a/src/renderer/src/lib/structured-agent-session-launch-request.ts b/src/renderer/src/lib/structured-agent-session-launch-request.ts index 77b9041af39..f200b81d7ad 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-request.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-request.ts @@ -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. */ diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts index beae204167a..e5a75c23743 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts @@ -11,6 +11,7 @@ vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ unifiedTabsByWorktree: {}, + activeGroupIdByWorktree: {}, createUnifiedTab: mocks.createUnifiedTab, setActiveTabType: vi.fn() }) diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts index 665779a75cc..1d61a35f72d 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts @@ -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.') }