From fe9b35825c864ac82e97a0ba40d8c242f6aad72f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:25:48 -0700 Subject: [PATCH 1/3] fix(native-chat): a new chat with no text reuses an empty one instead of stacking another Two bare "+ > Claude" picks (or new-tab search, the new-agent shortcut, the dashboard) opened two empty chats. A request with no text now focuses an existing empty chat for that agent in that workspace: one still starting (joined in the launch, as a re-delivery is) or one that published and sits idle (its host's journal holds no request, read live from the status feed). Empty also means nothing queued, no composer text or images, and no launch draft its composer has not taken. Failed, unconfirmed, resumed and other-agent/workspace chats are never reused. A request with text still opens its own chat, or claims an empty starting one as before; request-id dedupe is unchanged. --- .../lib/structured-agent-launch-settlement.ts | 20 + ...red-agent-session-empty-chat-reuse.test.ts | 383 ++++++++++++++++++ ...tructured-agent-session-idle-empty-chat.ts | 72 ++++ ...uctured-agent-session-launch-empty-chat.ts | 8 +- ...structured-agent-session-launch-holders.ts | 26 +- 5 files changed, 496 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/lib/structured-agent-session-empty-chat-reuse.test.ts create mode 100644 src/renderer/src/lib/structured-agent-session-idle-empty-chat.ts diff --git a/src/renderer/src/lib/structured-agent-launch-settlement.ts b/src/renderer/src/lib/structured-agent-launch-settlement.ts index 9815f71b48a..e6d24912330 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,25 @@ 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) + 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-empty-chat-reuse.test.ts b/src/renderer/src/lib/structured-agent-session-empty-chat-reuse.test.ts new file mode 100644 index 00000000000..fd897145fa9 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-empty-chat-reuse.test.ts @@ -0,0 +1,383 @@ +// @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 +} +function emptyStoreState(): StoreState { + return { + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + activeGroupIdByWorktree: {}, + 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: object) => { + // 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: 'group-1', 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 } from '@/components/native-chat/structured-agent-session-outbox-storage' +import { + appendNativeChatAttachmentCache, + clearNativeChatAttachmentCacheForTests +} from '@/components/native-chat/use-native-chat-composer-attachments' +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 } = {} +): 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 } + : {}) + }), + hooks: {} + }) + 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 "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('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) + }) +}) 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..ebe4c771933 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-idle-empty-chat.ts @@ -0,0 +1,72 @@ +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 that nothing was ever sent into and whose composer is + * untouched. Prefers the one in focus, else the newest. */ +export function findIdleEmptyStructuredChat( + worktreeId: string, + agent: AgentSessionHandleProvider, + executionHostId?: ExecutionHostId +): 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 && + (!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 activeGroupId = state.activeGroupIdByWorktree[worktreeId] + const focusedTabId = state.groupsByWorktree[worktreeId]?.find( + (group) => group.id === activeGroupId + )?.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-empty-chat.ts b/src/renderer/src/lib/structured-agent-session-launch-empty-chat.ts index e8c5becd685..57efb3fedc8 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,15 @@ 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' +import { readNativeChatAttachmentCache } from '@/components/native-chat/use-native-chat-composer-attachments' -/** 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() === '' && + readNativeChatAttachmentCache(paneKey).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..ba2e40653b6 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-holders.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-holders.ts @@ -43,25 +43,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, which + * a request with text claims and one without reuses. The newest wins. */ export function getJoinableStructuredLaunchState( identity: string, request: StructuredLaunchRequest @@ -70,7 +76,7 @@ export function getJoinableStructuredLaunchState( return ( structuredLaunchesHoldingIdentity(matches, request.id).at(-1) ?? structuredLaunchesHoldingIdentity(matches).findLast((state) => - claimableStructuredLaunchAttempt(state, request) + emptyStructuredLaunchAttempt(state) ) ) } From cf16d921fe82299b876a8b153810af006868e24a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:53:05 -0700 Subject: [PATCH 2/3] fix(native-chat): a new chat with no text reuses an empty chat only in the split it was opened from Pressing + in the right-hand split focused an empty chat sitting in the left-hand split. The reuse now looks only in the tab group the pick targets (the caller's group, else the workspace's active group, which is where its tab would open); a pick in another split opens a new chat there. Applies to both an empty chat still starting and one that published and sits idle. --- .../src/lib/agent-session-launch-plan.ts | 5 +- ...launch-agent-structured-chat-guard.test.ts | 1 + .../lib/structured-agent-launch-settlement.ts | 7 +- ...structured-agent-session-chat-tab-group.ts | 12 +++ ...red-agent-session-empty-chat-reuse.test.ts | 90 +++++++++++++++++-- ...tructured-agent-session-idle-empty-chat.ts | 12 +-- ...structured-agent-session-launch-callers.ts | 2 + ...structured-agent-session-launch-holders.ts | 12 ++- ...structured-agent-session-launch-request.ts | 16 +++- ...ured-agent-session-provisional-tab.test.ts | 1 + ...tructured-agent-session-provisional-tab.ts | 8 +- 11 files changed, 145 insertions(+), 21 deletions(-) create mode 100644 src/renderer/src/lib/structured-agent-session-chat-tab-group.ts diff --git a/src/renderer/src/lib/agent-session-launch-plan.ts b/src/renderer/src/lib/agent-session-launch-plan.ts index 590891a2b17..070b147faa9 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.ts @@ -65,6 +65,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 & { @@ -111,7 +113,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 e213a5b80fc..64d748ec858 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 e6d24912330..69c3a58ddff 100644 --- a/src/renderer/src/lib/structured-agent-launch-settlement.ts +++ b/src/renderer/src/lib/structured-agent-launch-settlement.ts @@ -107,7 +107,12 @@ export function beginStructuredAgentLaunchSettlement( const idle = options.resumeFrom || options.prompt?.trim() ? undefined - : findIdleEmptyStructuredChat(worktreeId, agent, options.executionHostId) + : findIdleEmptyStructuredChat( + worktreeId, + agent, + options.executionHostId, + options.targetGroupId + ) if (idle) { return { ...idle, 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 index fd897145fa9..aa267b764d5 100644 --- 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 @@ -23,11 +23,18 @@ type StoreState = { 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: {}, - activeGroupIdByWorktree: {}, + groupsByWorktree: { 'wt-reuse': [group('group-left'), group('group-right')] }, + activeGroupIdByWorktree: { 'wt-reuse': 'group-left' }, nativeChatLaunchDraftByTabId: {} } } @@ -71,9 +78,15 @@ vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ ...store.state, - createUnifiedTab: (worktreeId: string, contentType: Tab['contentType'], init: object) => { + 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: 'group-1', createdAt: 1 } as Tab + const tab = { ...init, contentType, worktreeId, groupId, createdAt: 1 } as Tab store.state.unifiedTabsByWorktree[worktreeId] = [ ...(store.state.unifiedTabsByWorktree[worktreeId] ?? []), tab @@ -162,7 +175,13 @@ 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 } = {} + overrides: { + agent?: 'claude' | 'codex' + worktreeId?: string + prompt?: string + /** The split the pick was made in; none means the workspace's active group. */ + group?: string + } = {} ): Exclude { const launch = beginStructuredAgentSessionProvisionalLaunch({ plan: adoptAgentSessionLaunchVerdict({ @@ -175,7 +194,8 @@ function pick( ? { prompt: overrides.prompt, promptDelivery: 'submit-after-ready' as const } : {}) }), - hooks: {} + hooks: {}, + ...(overrides.group ? { targetGroupId: overrides.group } : {}) }) if (!launch || launch.sessionId === null) { throw new Error('expected a local chat') @@ -360,6 +380,64 @@ describe('a second "new chat" with no text', () => { }) }) +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') 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 index ebe4c771933..45d7970217e 100644 --- a/src/renderer/src/lib/structured-agent-session-idle-empty-chat.ts +++ b/src/renderer/src/lib/structured-agent-session-idle-empty-chat.ts @@ -34,12 +34,13 @@ function holdsUnadoptedLaunchDraft(tabId: string): boolean { return Boolean(draft && !draft.adopted && !draft.resolved && draft.text.trim()) } -/** An open chat for `agent` in this workspace that nothing was ever sent into and whose composer is - * untouched. Prefers the one in focus, else the newest. */ +/** 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 + executionHostId?: ExecutionHostId, + groupId?: string ): IdleEmptyStructuredChat | undefined { const state = useAppStore.getState() const candidates: (IdleEmptyStructuredChat & { tab: Tab })[] = [] @@ -50,6 +51,7 @@ export function findIdleEmptyStructuredChat( : null if ( owner && + (!groupId || tab.groupId === groupId) && (!executionHostId || owner === executionHostId) && hostHoldsNoRequest(tab, owner) && isStructuredLaunchChatEmpty(tab.entityId) && @@ -61,9 +63,9 @@ export function findIdleEmptyStructuredChat( if (candidates.length === 0) { return undefined } - const activeGroupId = state.activeGroupIdByWorktree[worktreeId] + const focusGroupId = groupId ?? state.activeGroupIdByWorktree[worktreeId] const focusedTabId = state.groupsByWorktree[worktreeId]?.find( - (group) => group.id === activeGroupId + (group) => group.id === focusGroupId )?.activeTabId const chosen = candidates.find((candidate) => candidate.tab.id === focusedTabId) ?? 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 427f3ecf46e..b131ede7391 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-callers.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-callers.ts @@ -20,6 +20,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-holders.ts b/src/renderer/src/lib/structured-agent-session-launch-holders.ts index ba2e40653b6..09081e8c01d 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 @@ -67,7 +68,7 @@ export function claimableStructuredLaunchAttempt( } /** The launch a new start of `request` joins: one it re-delivers, else an empty starting chat, which - * a request with text claims and one without reuses. The newest wins. */ + * a request with text claims and one without reuses in its own tab group. The newest wins. */ export function getJoinableStructuredLaunchState( identity: string, request: StructuredLaunchRequest @@ -75,8 +76,13 @@ export function getJoinableStructuredLaunchState( const matches = (candidate: string): boolean => candidate === identity return ( structuredLaunchesHoldingIdentity(matches, request.id).at(-1) ?? - structuredLaunchesHoldingIdentity(matches).findLast((state) => - emptyStructuredLaunchAttempt(state) + structuredLaunchesHoldingIdentity(matches).findLast( + (state) => + emptyStructuredLaunchAttempt(state) && + (request.hasText || + !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.') } From 6a86ad7e363ea6c0251e96c40e2275f779c99ce5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:00:26 -0700 Subject: [PATCH 3/3] fix(native-chat): text sent to a new agent claims an empty starting chat only in its own split Notes sent from the right-hand split could land in, and focus, an empty chat still starting in the left-hand one. A request with text now claims an empty starting chat only in the tab group it opens in, the same rule a request without text follows; elsewhere it opens its own chat there. --- ...red-agent-session-empty-chat-reuse.test.ts | 33 ++++++++++++++++++- ...structured-agent-session-launch-holders.ts | 7 ++-- 2 files changed, 35 insertions(+), 5 deletions(-) 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 index aa267b764d5..9f4f2b0c459 100644 --- 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 @@ -103,7 +103,10 @@ vi.mock('@/store', () => ({ } })) -import { appendStructuredAgentSessionOutboxMessage } from '@/components/native-chat/structured-agent-session-outbox-storage' +import { + appendStructuredAgentSessionOutboxMessage, + readOutbox +} from '@/components/native-chat/structured-agent-session-outbox-storage' import { appendNativeChatAttachmentCache, clearNativeChatAttachmentCacheForTests @@ -452,6 +455,34 @@ describe('a "new chat" with text', () => { }) }) + 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') 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 09081e8c01d..2b9b62a1473 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-holders.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-holders.ts @@ -67,8 +67,8 @@ export function claimableStructuredLaunchAttempt( return request.hasText ? emptyStructuredLaunchAttempt(state) : undefined } -/** The launch a new start of `request` joins: one it re-delivers, else an empty starting chat, which - * a request with text claims and one without reuses in its own tab group. The newest wins. */ +/** 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 @@ -79,8 +79,7 @@ export function getJoinableStructuredLaunchState( structuredLaunchesHoldingIdentity(matches).findLast( (state) => emptyStructuredLaunchAttempt(state) && - (request.hasText || - !request.groupId || + (!request.groupId || structuredChatTabGroupId(state.intent.worktreeId, state.intent.sessionId) === request.groupId) )