diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index a8231eda03e..a959249383d 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -269,7 +269,13 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId getHandleRecord: (handle) => this.handles.get(handle), getPtyAgents: (ptyId) => this.ptysById.get(ptyId), getTerminalPaneKey: (handle) => this.getTerminalPaneKey(handle), - getWorkspaceSession: (worktreeId) => this.getWorkspaceSessionForWorktree(worktreeId) + getWorkspaceSession: (worktreeId) => this.getWorkspaceSessionForWorktree(worktreeId), + getGeneratedTitlesEnabled: () => this.store?.getSettings?.()?.tabAutoGenerateTitle === true, + getAgentStatusSnapshotForPane: (paneKey) => + this.getAgentStatusSnapshotForPaneFn?.(paneKey) ?? + this.getAgentStatusSnapshotFn?.().filter((row) => row.paneKey === paneKey) ?? + [], + getTrackedTitle: (ptyId) => this.getTrackedDisplayTitleForPty(ptyId) }) protected readonly orchestrationMailboxNotifications = diff --git a/src/main/runtime/orchestration/orchestration-sender-name.ts b/src/main/runtime/orchestration/orchestration-sender-name.ts index 55926933687..c3052d59355 100644 --- a/src/main/runtime/orchestration/orchestration-sender-name.ts +++ b/src/main/runtime/orchestration/orchestration-sender-name.ts @@ -1,18 +1,26 @@ -// What a chat shows a message's sender as: names from Orca's own records, most specific first, and -// never a title the agent paints on its own terminal. +// What a chat shows a message's sender as: the name Orca shows for that agent (its chat tab, or its +// sidebar agent row), else the task it was dispatched, else its agent's generic label. import type { AgentMessageSender } from '../../../shared/agent-session-message-source' import { defaultAgentChatLabel } from '../../../shared/agent-session-chat-label' +import { + getAgentRowConversationName, + type ConversationNameTab +} from '../../../shared/agent-row-conversation-name' import type { AgentType } from '../../../shared/agent-status-types' import { formatAgentTypeLabel } from '../../../shared/agent-type-label' +import { structuredChatDisplayName } from '../../../shared/structured-chat-row-name' import type { Tab } from '../../../shared/tab-types' import type { OrchestrationDb } from './db' import type { DispatchContextRow } from './types' import { lineageLiveSession, type AgentSessionRecordReader } from './structured-session-lineage' export type TerminalSenderNaming = { - /** The tab's stored title: a rename, by the person or through the CLI. Never its live title. */ - customTitle: string | null + /** The tab as this host mirrors the workspace session. */ + tab: ConversationNameTab | null + /** Its current pane title; undefined lets a single pane use its mirrored tab title. */ + paneTitle?: string | null + providerSessionId?: string agent: AgentType | null /** Its pane, which an active dispatch still names after the handle was reissued. */ paneKey: string | null @@ -24,6 +32,7 @@ export type SenderNamingSources = { /** The chat's tab as this host mirrors the workspace session. */ chatTab: (worktreeId: string, sessionId: string) => Pick | null terminal: (handle: string) => TerminalSenderNaming | null + generatedTitlesEnabled: boolean } type Party = AgentMessageSender['party'] @@ -37,22 +46,38 @@ export function orchestrationSenderName( const federated = party.address.startsWith('dispatch:') const terminal = !federated && party.terminalHandle ? sources.terminal(party.terminalHandle) : null - const task = dispatchTaskName(party, federated, terminal, sources.db, reportedDispatchId) - if (task) { - return task + const chat = party.orcaSessionId ? chatNaming(party.orcaSessionId, sources) : null + const terminalName = terminal?.tab + ? getAgentRowConversationName( + terminal.tab, + terminal.agent, + sources.generatedTitlesEnabled, + terminal.paneTitle, + terminal.providerSessionId + ) + : null + return ( + chat?.ownName || + terminalName || + dispatchTaskName(party, federated, terminal, sources.db, reportedDispatchId) || + chat?.fallback || + (terminal?.agent ? formatAgentTypeLabel(terminal.agent) : null) + ) +} + +/** A chat's own name (the person's rename, else its saved name) and the label its tab falls back to. */ +function chatNaming( + sessionId: string, + sources: SenderNamingSources +): { ownName: string | null; fallback: string } | null { + const record = sources.records ? lineageLiveSession(sources.records, sessionId) : null + if (!record) { + return null } - if (party.orcaSessionId) { - const record = sources.records ? lineageLiveSession(sources.records, party.orcaSessionId) : null - if (record) { - const tab = sources.chatTab(record.location.workspaceId, record.sessionId) - return tab?.customLabel?.trim() || tab?.label.trim() || defaultAgentChatLabel(record.provider) - } - } - if (terminal) { - const agentLabel = terminal.agent ? formatAgentTypeLabel(terminal.agent) : null - return terminal.customTitle?.trim() || agentLabel - } - return null + const tab = sources.chatTab(record.location.workspaceId, record.sessionId) + const fallback = tab?.label.trim() || defaultAgentChatLabel(record.provider) + const ownName = structuredChatDisplayName(tab?.customLabel, record.conversationName, '') || null + return { ownName, fallback } } /** The task its dispatch was given: the federated `dispatch:` address, or the dispatch a local diff --git a/src/main/runtime/runtime-orchestration-sender-names.test.ts b/src/main/runtime/runtime-orchestration-sender-names.test.ts index 3958493f1d2..08d19fafc11 100644 --- a/src/main/runtime/runtime-orchestration-sender-names.test.ts +++ b/src/main/runtime/runtime-orchestration-sender-names.test.ts @@ -9,6 +9,7 @@ import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { testOrcaSessionId } from '../../shared/orca-session-address-test-fixture' +import { wslHookRelayConnectionId } from '../../shared/wsl-hook-relay-contract' import { OrchestrationDb } from './orchestration/db' import { createRootDispatch } from './orchestration/db/root-dispatch-test-fixture' import { reconcileLifecycleMessage } from './orchestration/lifecycle-reconciliation' @@ -29,10 +30,16 @@ type Session = NonNullable< ConstructorParameters[0]['getWorkspaceSession'] > > +type SenderNamingDeps = ConstructorParameters[0] +type HookRow = ReturnType[number] let db: OrchestrationDb let session: Session let records: Map +let generatedTitles: boolean +let hookRows: HookRow[] +let pty: NonNullable> +let trackedTitle: string | null function names() { return new RuntimeOrchestrationSenderNames({ @@ -41,9 +48,12 @@ function names() { handle === 'term_worker' || handle === 'term_plain' ? { worktreeId: WORKTREE, tabId: `tab_${handle}`, ptyId: `pty_${handle}` } : undefined, - getPtyAgents: () => ({ launchAgent: 'codex' }), + getPtyAgents: () => pty, getTerminalPaneKey: (handle) => `tab_${handle}:leaf`, - getWorkspaceSession: (worktreeId) => (worktreeId === WORKTREE ? session : undefined) + getWorkspaceSession: (worktreeId) => (worktreeId === WORKTREE ? session : undefined), + getGeneratedTitlesEnabled: () => generatedTitles, + getAgentStatusSnapshotForPane: (paneKey) => hookRows.filter((row) => row.paneKey === paneKey), + getTrackedTitle: () => trackedTitle }) } @@ -109,9 +119,34 @@ function chatTab(customLabel: string | null, label: string) { return { contentType: 'agent-session' as const, entityId: CHAT, customLabel, label } } +function providerTitleTab(): NonNullable[string][number] { + return { + id: 'tab_term_plain', + title: '', + aiVaultTitle: { agent: 'claude', sessionId: 'current-session', title: 'Parser work' } + } +} + +function hookRow(overrides: Partial = {}): HookRow { + return { + paneKey: 'tab_term_plain:leaf', + worktreeId: WORKTREE, + agentType: 'claude', + connectionId: null, + launchToken: 'launch-current', + receivedAt: 1, + providerSession: { key: 'session_id', id: 'current-session' }, + ...overrides + } +} + beforeEach(() => { db = new OrchestrationDb(':memory:') session = { unifiedTabs: {}, tabsByWorktree: {} } + generatedTitles = false + hookRows = [] + pty = { launchAgent: 'codex', connectionId: null, launchToken: 'launch-current' } + trackedTitle = null const base = agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: CHAT })) records = new Map([[CHAT, { ...base, location: { ...base.location, workspaceId: WORKTREE } }]]) hostRef.current = { @@ -133,7 +168,7 @@ const terminalParty = (handle: string) => ({ }) describe("a sender's name, from what Orca shows for it", () => { - it("names a local worker by its active dispatch's task, before its tab or agent", () => { + it("names a local worker by its active dispatch's task when its tab has no name of its own", () => { const run = db.createRun({ objective: 'o', coordinatorHandle: 'term_coord', @@ -145,8 +180,11 @@ describe("a sender's name, from what Orca shows for it", () => { displayName: 'Parser port' }) createRootDispatch(db, task.id, 'term_worker') - session.tabsByWorktree = { [WORKTREE]: [{ id: 'tab_term_worker', customTitle: 'Build tab' }] } + session.tabsByWorktree = { [WORKTREE]: [{ id: 'tab_term_worker', title: 'Codex ready' }] } expect(names().nameOf(terminalParty('term_worker'))).toBe('Parser port') + // A name the person gave the worker's tab is the one they know it by. + session.tabsByWorktree = { [WORKTREE]: [{ id: 'tab_term_worker', customTitle: 'Build tab' }] } + expect(names().nameOf(terminalParty('term_worker'))).toBe('Build tab') }) it('names a worker by its task after its own accepted worker_done settled that dispatch', () => { @@ -183,14 +221,142 @@ describe("a sender's name, from what Orca shows for it", () => { expect(names().nameOf(chatParty)).toBe('Auth chat') }) + it("names a chat by its saved name before its task or its tab's default label", () => { + const record = records.get(CHAT) + if (!record) { + throw new Error('chat record missing') + } + records.set(CHAT, { ...record, conversationName: 'Port the lexer' }) + session.unifiedTabs = { [WORKTREE]: [chatTab(null, 'Claude Chat')] } + expect(names().nameOf(chatParty)).toBe('Port the lexer') + }) + + it.each(['rename', 'saved name'])( + 'keeps a chat %s even when it equals the default label', + (kind) => { + const record = records.get(CHAT) + if (!record) { + throw new Error('chat record missing') + } + records.set(CHAT, { + ...record, + conversationName: kind === 'saved name' ? 'Claude Chat' : undefined + }) + session.unifiedTabs = { + [WORKTREE]: [chatTab(kind === 'rename' ? 'Claude Chat' : null, 'Claude Chat')] + } + const { dispatchId } = finishedWorker('Port the parser') + expect(db.getDispatchContextById(dispatchId)?.status).toBe('dispatched') + expect(names().nameOf({ ...chatParty, terminalHandle: 'term_worker' })).toBe('Claude Chat') + } + ) + it("names a chat with no tab by its agent's chat label", () => { expect(names().nameOf(chatParty)).toBe('Claude Chat') }) - it("names a terminal by its tab's stored title, else its agent", () => { + it("names a terminal agent as its sidebar row does: the tab's own name, else its agent", () => { expect(names().nameOf(terminalParty('term_plain'))).toBe('Codex') session.tabsByWorktree = { [WORKTREE]: [{ id: 'tab_term_plain', customTitle: ' Lint ' }] } expect(names().nameOf(terminalParty('term_plain'))).toBe('Lint') + session.tabsByWorktree = { + [WORKTREE]: [{ id: 'tab_term_plain', title: '✳ Fix the sender link' }] + } + expect(names().nameOf(terminalParty('term_plain'))).toBe('Fix the sender link') + // A status the agent paints is not a name. + session.tabsByWorktree = { [WORKTREE]: [{ id: 'tab_term_plain', title: 'Codex ready' }] } + expect(names().nameOf(terminalParty('term_plain'))).toBe('Codex') + }) + + it('uses a generated tab title only while the person has generated titles on', () => { + session.tabsByWorktree = { + [WORKTREE]: [{ id: 'tab_term_plain', title: 'Codex ready', generatedTitle: 'Lint fixes' }] + } + expect(names().nameOf(terminalParty('term_plain'))).toBe('Codex') + generatedTitles = true + expect(names().nameOf(terminalParty('term_plain'))).toBe('Lint fixes') + }) + + it.each([ + { id: 'current-session', expected: 'Parser work' }, + { id: 'another-session', expected: 'Claude' }, + { id: undefined, expected: 'Claude' } + ])('accepts a saved provider title only for the hook row session $id', ({ id, expected }) => { + session.tabsByWorktree = { [WORKTREE]: [providerTitleTab()] } + // The hook's current owner is Claude even though the PTY was launched as Codex. + hookRows = [hookRow({ providerSession: id ? { key: 'session_id', id } : undefined })] + expect(names().nameOf(terminalParty('term_plain'))).toBe(expected) + }) + + it.each<{ label: string; row: Partial }>([ + { label: 'another pane', row: { paneKey: 'tab_sibling:leaf' } }, + { label: 'another workspace', row: { worktreeId: 'folder:other' } }, + { label: 'another launch', row: { launchToken: 'launch-old' } }, + { label: 'another SSH host', row: { connectionId: 'ssh-other' } } + ])('does not use a provider title from $label', ({ row }) => { + session.tabsByWorktree = { [WORKTREE]: [providerTitleTab()] } + hookRows = [hookRow(row)] + expect(names().nameOf(terminalParty('term_plain'))).toBe('Codex') + }) + + it('uses the newest owner row even when it has not reported a provider session', () => { + session.tabsByWorktree = { [WORKTREE]: [providerTitleTab()] } + hookRows = [hookRow(), hookRow({ receivedAt: 2, providerSession: undefined })] + expect(names().nameOf(terminalParty('term_plain'))).toBe('Claude') + }) + + it.each([ + { ptyConnection: 'ssh-current', rowConnection: 'ssh-current', expected: 'Parser work' }, + { ptyConnection: 'ssh-current', rowConnection: 'ssh-other', expected: 'Codex' }, + { + ptyConnection: null, + rowConnection: wslHookRelayConnectionId('Ubuntu'), + expected: 'Parser work' + }, + { ptyConnection: null, rowConnection: wslHookRelayConnectionId('Debian'), expected: 'Codex' } + ])('selects provider identity only on its execution host: $rowConnection', (test) => { + session.tabsByWorktree = { [WORKTREE]: [providerTitleTab()] } + pty = { ...pty, connectionId: test.ptyConnection, wslDistro: 'Ubuntu' } + hookRows = [hookRow({ connectionId: test.rowConnection })] + expect(names().nameOf(terminalParty('term_plain'))).toBe(test.expected) + }) + + it('does not infer provider identity from the cached title without a status row', () => { + session.tabsByWorktree = { + [WORKTREE]: [ + { + id: 'tab_term_plain', + title: 'Codex ready', + aiVaultTitle: { agent: 'codex', sessionId: 'old-session', title: 'Old task' } + } + ] + } + expect(names().nameOf(terminalParty('term_plain'))).toBe('Codex') + }) + + it('names a single pane by its current title before its older mirrored tab title', () => { + session.tabsByWorktree = { + [WORKTREE]: [{ id: 'tab_term_plain', title: 'Old task' }] + } + trackedTitle = '✳ Current task' + expect(names().nameOf(terminalParty('term_plain'))).toBe('Current task') + }) + + it("never names a pane in a split tab by the tab title, which is its focused sibling's", () => { + session.tabsByWorktree = { [WORKTREE]: [{ id: 'tab_term_plain', title: 'Sibling task' }] } + session.terminalLayoutsByTabId = { + tab_term_plain: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'a' }, + second: { type: 'leaf', leafId: 'b' } + } + } + } + expect(names().nameOf(terminalParty('term_plain'))).toBe('Codex') + trackedTitle = '✳ Own pane task' + expect(names().nameOf(terminalParty('term_plain'))).toBe('Own pane task') }) it('names a party nothing records as nothing', () => { diff --git a/src/main/runtime/runtime-orchestration-sender-names.ts b/src/main/runtime/runtime-orchestration-sender-names.ts index ed0c167cf6c..8da055e9f7b 100644 --- a/src/main/runtime/runtime-orchestration-sender-names.ts +++ b/src/main/runtime/runtime-orchestration-sender-names.ts @@ -2,9 +2,11 @@ // speaks for another agent: the mail lane today, a dispatched task next. import type { AgentMessageSender } from '../../shared/agent-session-message-source' -import type { AgentType } from '../../shared/agent-status-types' +import type { ConversationNameTab } from '../../shared/agent-row-conversation-name' +import type { AgentStatusIpcPayload, AgentType } from '../../shared/agent-status-types' +import { parsePaneKey } from '../../shared/stable-pane-id' import type { Tab } from '../../shared/tab-types' -import type { TerminalTab } from '../../shared/terminal-tab-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../shared/terminal-tab-types' import type { OrchestrationDb } from './orchestration/db' import { orchestrationSenderName, @@ -12,25 +14,52 @@ import { } from './orchestration/orchestration-sender-name' import { agentMessageSender } from './orchestration/agent-message-sender' import { readAgentSessionRecordStore } from './orchestration/structured-session-lineage' +import { terminalHostConnectionMatches } from './orchestration/worker-provider-session' type RuntimeSenderNameDeps = { getDb: () => OrchestrationDb | null getHandleRecord: ( handle: string ) => { worktreeId: string; tabId: string; ptyId: string | null } | undefined - getPtyAgents: ( - ptyId: string - ) => { launchAgent?: AgentType | null; foregroundAgent?: AgentType | null } | undefined + getPtyAgents: (ptyId: string) => + | { + launchAgent?: AgentType | null + foregroundAgent?: AgentType | null + connectionId?: string | null + wslDistro?: string | null + launchToken?: string | null + } + | undefined getTerminalPaneKey: (handle: string) => string | null - /** The workspace session this host mirrors, of which only the tabs are read. */ + /** The workspace session this host mirrors. */ getWorkspaceSession: (worktreeId: string) => MirroredTabs | null | undefined + /** The person's "generate tab titles" setting, which decides whether a generated title names a tab. */ + getGeneratedTitlesEnabled: () => boolean + getAgentStatusSnapshotForPane: (paneKey: string) => readonly SenderHookRow[] + getTrackedTitle: (ptyId: string) => string | null } +type SenderHookRow = Pick< + AgentStatusIpcPayload, + | 'paneKey' + | 'worktreeId' + | 'agentType' + | 'providerSession' + | 'receivedAt' + | 'connectionId' + | 'launchToken' +> + type MirroredTabs = { unifiedTabs?: Readonly< Record[]> > - tabsByWorktree?: Readonly[]>> + tabsByWorktree?: Readonly< + Record & Partial)[]> + > + terminalLayoutsByTabId?: Readonly< + Record> + > } export class RuntimeOrchestrationSenderNames { @@ -59,7 +88,8 @@ export class RuntimeOrchestrationSenderNames { ?.unifiedTabs?.[worktreeId]?.find( (tab) => tab.contentType === 'agent-session' && tab.entityId === sessionId ) ?? null, - terminal: (handle) => this.terminalNaming(handle) + terminal: (handle) => this.terminalNaming(handle), + generatedTitlesEnabled: this.deps.getGeneratedTitlesEnabled() }, reportedDispatchId ) @@ -71,13 +101,44 @@ export class RuntimeOrchestrationSenderNames { return null } const pty = record.ptyId ? this.deps.getPtyAgents(record.ptyId) : undefined - const tab = this.deps - .getWorkspaceSession(record.worktreeId) - ?.tabsByWorktree?.[record.worktreeId]?.find((candidate) => candidate.id === record.tabId) + const session = this.deps.getWorkspaceSession(record.worktreeId) + const tab = session?.tabsByWorktree?.[record.worktreeId]?.find( + (candidate) => candidate.id === record.tabId + ) + const paneKey = this.deps.getTerminalPaneKey(handle) + const status = paneKey + ? this.deps + .getAgentStatusSnapshotForPane(paneKey) + .filter( + (row) => + row.paneKey === paneKey && + (!row.worktreeId || row.worktreeId === record.worktreeId) && + terminalHostConnectionMatches(row.connectionId, pty?.connectionId, pty?.wslDistro) && + (!pty?.launchToken || row.launchToken === pty.launchToken) + ) + .sort((left, right) => right.receivedAt - left.receivedAt)[0] + : undefined + const trackedTitle = record.ptyId ? this.deps.getTrackedTitle(record.ptyId) : null return { - customTitle: tab?.customTitle ?? null, - agent: pty?.launchAgent ?? pty?.foregroundAgent ?? null, - paneKey: this.deps.getTerminalPaneKey(handle) + tab: tab ? { ...tab, title: tab.title ?? '', customTitle: tab.customTitle ?? null } : null, + paneTitle: + trackedTitle ?? splitPaneTitle(session?.terminalLayoutsByTabId?.[record.tabId], paneKey), + agent: status?.agentType ?? pty?.launchAgent ?? pty?.foregroundAgent ?? null, + providerSessionId: status?.providerSession?.id, + paneKey } } } + +/** A split tab's title is its focused pane's, so a pane is named only by the title the person gave + * it; undefined for a single-pane tab, whose tab title is its own. */ +function splitPaneTitle( + layout: Pick | undefined, + paneKey: string | null +): string | null | undefined { + if (layout?.root?.type !== 'split') { + return undefined + } + const leafId = paneKey ? parsePaneKey(paneKey)?.leafId : undefined + return (leafId && layout.titlesByLeafId?.[leafId]) || null +} diff --git a/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx index 86fcf30a2cb..4e8a13fae3b 100644 --- a/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx @@ -7,7 +7,6 @@ import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { MessageRow } from './NativeChatMessageRow' import { NativeChatQueuedMessageCard } from './NativeChatQueuedMessageCard' import { TooltipProvider } from '@/components/ui/tooltip' -import { queuedCardSenderLine } from './native-chat-agent-message-sender-label' const openAgentMessageSender = vi.hoisted(() => vi.fn(async () => {})) vi.mock('@/lib/open-agent-message-sender', () => ({ openAgentMessageSender })) @@ -123,8 +122,8 @@ describe("another agent's message in the transcript", () => { }) describe("another agent's queued card", () => { - it('names who it is from on a line the card’s own controls keep their clicks from', () => { - render( + function renderCard(source: AgentMessageSource, chatWorktreeId: string | null = 'wt-chat') { + return render( { text: 'You have 1 orchestration message.', state: 'waiting', hold: 'turn', - from: from([ - { address: 'term_a', name: 'Coder' }, - { address: 'term_b', name: null } - ]) + from: source }} + chatWorktreeId={chatWorktreeId} showsSteerShortcut={false} onSteer={vi.fn()} onDelete={vi.fn()} @@ -146,17 +143,30 @@ describe("another agent's queued card", () => { /> ) - expect(screen.getByText('From Coder, an agent')).toBeInTheDocument() - expect(screen.queryByRole('button', { name: /Coder/ })).not.toBeInTheDocument() + } + + it('names who it is from, and opens the sender from its name as the transcript row does', () => { + const source = from([ + { address: 'term_a', name: 'Coder' }, + { address: 'term_b', name: null } + ]) + const { container } = renderCard(source) + expect(container.textContent).toContain('FromCoder,an agent') + fireEvent.click(screen.getByRole('button', { name: 'Coder' })) + expect(openAgentMessageSender).toHaveBeenCalledWith(source, source.senders[0], 'wt-chat') }) it('counts in "+N" only the senders it does not name, same-named ones included', () => { - expect( - queuedCardSenderLine( - from( - ['term_a', 'term_b', 'term_c', 'term_d'].map((address) => ({ address, name: 'Codex' })) - ) - ) - ).toBe('From Codex, Codex, Codex +1') + renderCard( + from(['term_a', 'term_b', 'term_c', 'term_d'].map((address) => ({ address, name: 'Codex' }))) + ) + expect(screen.getAllByRole('button', { name: 'Codex' })).toHaveLength(3) + expect(screen.getByText('+1')).toBeInTheDocument() + }) + + it('shows the name as plain text where there is no chat to open it from', () => { + renderCard(from([{ address: 'term_a', name: 'Coder' }]), null) + expect(screen.getByText('Coder')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Coder' })).not.toBeInTheDocument() }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.test.tsx b/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.test.tsx new file mode 100644 index 00000000000..28e83f3249c --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.test.tsx @@ -0,0 +1,184 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { AgentMessageSource } from '../../../../shared/agent-session-message-source' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { isOrcaSessionId, type OrcaSessionId } from '../../../../shared/orca-session-address' +import type { Tab } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { NativeChatAgentMessageSenders } from './NativeChatAgentMessageSenders' + +const conversationNames = vi.hoisted(() => new Map()) +vi.mock('@/runtime/structured-conversation-name', () => ({ + useStructuredChatTabConversationName: (tab: { entityId: string } | undefined) => + (tab && conversationNames.get(tab.entityId)) ?? null +})) +vi.mock('@/lib/open-agent-message-sender', () => ({ openAgentMessageSender: vi.fn() })) + +const WORKTREE = 'wt-coder' +const TAB = 'tab-coder' +const LEAF = '11111111-1111-4111-8111-111111111111' +const HANDLE = 'term_coder' +const SESSION = orcaSessionId('22222222-2222-4222-8222-222222222222') +const initialState = useAppStore.getInitialState() + +function orcaSessionId(id: string): OrcaSessionId { + if (!isOrcaSessionId(id)) { + throw new Error(`not a session id: ${id}`) + } + return id +} + +function terminalTab(overrides: Partial = {}): TerminalTab { + return { + id: TAB, + ptyId: 'pty-coder', + worktreeId: WORKTREE, + title: 'Claude Code', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0, + ...overrides + } +} + +function agentRow(terminalHandle: string): AgentStatusEntry { + return { + paneKey: `${TAB}:${LEAF}`, + state: 'working', + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [], + agentType: 'claude', + terminalHandle, + worktreeId: WORKTREE, + tabId: TAB + } +} + +function chatTab(overrides: Partial = {}): Tab { + return { + id: 'chat-tab', + entityId: SESSION, + groupId: 'group', + worktreeId: WORKTREE, + contentType: 'agent-session', + label: 'Claude Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + ...overrides + } +} + +function source( + party: Partial, + name: string | null = 'Recorded name' +): AgentMessageSource { + return { + kind: 'agent', + senders: [ + { + party: { address: HANDLE, terminalHandle: HANDLE, orcaSessionId: null, ...party }, + name + } + ], + orchestration: null + } +} + +function renderSenders(from: AgentMessageSource, queued = false) { + return render( + + ) +} + +beforeEach(() => { + conversationNames.clear() + useAppStore.setState({ activeWorktreeId: 'wt-chat', activeWorkspaceExecutionHostId: 'local' }) +}) + +afterEach(() => { + cleanup() + useAppStore.setState(initialState, true) +}) + +describe('the name a sender is shown under', () => { + it('keeps the host-recorded CLI name even while a different pane title is visible', () => { + useAppStore.setState({ + tabsByWorktree: { [WORKTREE]: [terminalTab({ customTitle: 'Parser worker' })] }, + agentStatusByPaneKey: { [`${TAB}:${LEAF}`]: agentRow(HANDLE) } + }) + renderSenders(source({}, 'Recorded name')) + expect(screen.getByRole('button', { name: 'Recorded name' })).toBeInTheDocument() + expect(screen.queryByText('Parser worker')).not.toBeInTheDocument() + }) + + it('keeps the recorded CLI name after the terminal is renamed', () => { + useAppStore.setState({ + tabsByWorktree: { [WORKTREE]: [terminalTab({ customTitle: 'Old name' })] }, + agentStatusByPaneKey: { [`${TAB}:${LEAF}`]: agentRow(HANDLE) } + }) + renderSenders(source({}), true) + act(() => { + useAppStore.setState({ + tabsByWorktree: { [WORKTREE]: [terminalTab({ customTitle: 'New name' })] } + }) + }) + expect(screen.getByRole('button', { name: 'Recorded name' })).toBeInTheDocument() + expect(screen.queryByText('New name')).not.toBeInTheDocument() + }) + + it("is the chat's tab name for a chat sender open here, as its tab shows it", () => { + conversationNames.set(SESSION, 'Port the parser') + useAppStore.setState({ unifiedTabsByWorktree: { [WORKTREE]: [chatTab()] } }) + renderSenders( + source({ + address: `orca_session_id:${SESSION}`, + terminalHandle: null, + orcaSessionId: SESSION + }) + ) + expect(screen.getByRole('button', { name: 'Port the parser' })).toBeInTheDocument() + }) + + it('keeps the recorded name for a chat sender whose tab has no name of its own', () => { + useAppStore.setState({ unifiedTabsByWorktree: { [WORKTREE]: [chatTab()] } }) + renderSenders( + source( + { address: `orca_session_id:${SESSION}`, terminalHandle: null, orcaSessionId: SESSION }, + 'Port the parser task' + ) + ) + expect(screen.getByRole('button', { name: 'Port the parser task' })).toBeInTheDocument() + }) + + it('keeps the recorded name when its agent row has no name of its own', () => { + useAppStore.setState({ + tabsByWorktree: { [WORKTREE]: [terminalTab({ title: 'Claude Code' })] }, + agentStatusByPaneKey: { [`${TAB}:${LEAF}`]: agentRow(HANDLE) } + }) + renderSenders(source({})) + expect(screen.getByRole('button', { name: 'Recorded name' })).toBeInTheDocument() + }) + + it('keeps the recorded name once the sender is gone from this window', () => { + renderSenders(source({})) + expect(screen.getByRole('button', { name: 'Recorded name' })).toBeInTheDocument() + }) + + it('keeps the recorded name for a sender on another host, whatever runs here', () => { + useAppStore.setState({ + tabsByWorktree: { [WORKTREE]: [terminalTab({ customTitle: 'Local agent' })] }, + agentStatusByPaneKey: { [`${TAB}:${LEAF}`]: agentRow(HANDLE) } + }) + renderSenders(source({ address: 'dispatch:d1' }, 'Remote task')) + expect(screen.getByText('Remote task')).toBeInTheDocument() + expect(screen.queryByText('Local agent')).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx b/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx index 463e1f2c354..4bf83f137eb 100644 --- a/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx @@ -2,47 +2,51 @@ import { Fragment } from 'react' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' import { openAgentMessageSender } from '@/lib/open-agent-message-sender' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import { useNativeChatVisualOwner } from './native-chat-visual-owner' import { agentMessageSendersShown, + type AgentMessageSender, type AgentMessageSource } from '../../../../shared/agent-session-message-source' -import { agentMessageSenderLabel } from './native-chat-agent-message-sender-label' +import { + unnamedSenderLabel, + useAgentMessageSenderLabel +} from './native-chat-agent-message-sender-label' -/** "Message from " over another agent's message; each name opens that agent. Plain text - * where the transcript has no chat to resolve the sender against, and for a sender that runs on - * another host. */ +/** "Message from " over another agent's message, or "From " on its queued card; each + * name opens that agent. Plain text where the transcript has no chat to resolve the sender + * against, and for a sender that runs on another host. */ export function NativeChatAgentMessageSenders({ from, - chatWorktreeId + chatWorktreeId, + queued = false }: { from: AgentMessageSource chatWorktreeId: string | null + queued?: boolean }): React.JSX.Element { const { shown, more } = agentMessageSendersShown(from) + const owner = useNativeChatVisualOwner() + const worktreeId = owner?.worktreeId ?? chatWorktreeId return (
- {translate('components.native-chat.agentMessage.messageFrom', 'Message from')} - {shown.length === 0 ? ( - - {translate('components.native-chat.agentMessage.unnamedSender', 'an agent')} - - ) : null} + + {queued + ? translate('components.native-chat.queuedMessages.fromSender', 'From') + : translate('components.native-chat.agentMessage.messageFrom', 'Message from')} + + {shown.length === 0 ? {unnamedSenderLabel()} : null} {shown.map((sender, index) => ( // Fragment keys: one sender's name and the separator after it. - {chatWorktreeId && opensFromHere(sender) ? ( - - ) : ( - {agentMessageSenderLabel(sender)} - )} - {/* Pulled back over the name's padding, so it reads "A, B" as the queued card does. */} + + {/* Pulled back over the name's padding, so it reads "A, B". */} {index < shown.length - 1 ? ( , @@ -55,7 +59,43 @@ export function NativeChatAgentMessageSenders({ ) } +function SenderName({ + from, + sender, + chatWorktreeId, + target +}: { + from: AgentMessageSource + sender: AgentMessageSender + chatWorktreeId: string | null + target?: RuntimeClientTarget +}): React.JSX.Element { + const label = useAgentMessageSenderLabel(sender, chatWorktreeId, target) + if (!chatWorktreeId || !opensFromHere(sender)) { + return ( + + {label} + + ) + } + return ( + + ) +} + /** A federated sender (`dispatch:`) runs on another host, which this chat's host cannot open. */ -function opensFromHere(sender: AgentMessageSource['senders'][number]): boolean { +function opensFromHere(sender: AgentMessageSender): boolean { return !sender.party.address.startsWith('dispatch:') } diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index 968421b334b..9201e87ef45 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -30,7 +30,7 @@ import { queuedMessageCardSteers, type QueuedMessageCard } from './structured-agent-session-queued-cards' -import { queuedCardSenderLine } from './native-chat-agent-message-sender-label' +import { NativeChatAgentMessageSenders } from './NativeChatAgentMessageSenders' import { useNativeChatClippedLine } from './use-native-chat-clipped-line' import { agentSessionFailureStatedByStartRow } from './structured-agent-session-delivery-notices' import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' @@ -130,6 +130,7 @@ export function queuedMessageCardSendNow(card: QueuedMessageCard): { export function NativeChatQueuedMessageCard({ card, + chatWorktreeId, agentName, statedFailures, showsSteerShortcut, @@ -140,6 +141,8 @@ export function NativeChatQueuedMessageCard({ onTurnOffQueueing }: { card: QueuedMessageCard + /** The chat's worktree, whose host its sender is found on; null shows the sender unlinked. */ + chatWorktreeId: string | null agentName?: string statedFailures?: readonly AgentSessionFailureFact[] /** Only the newest card answers Cmd/Ctrl+Enter; only it may show the chord. */ @@ -175,9 +178,11 @@ export function NativeChatQueuedMessageCard({ )}
{card.from ? ( -

- {queuedCardSenderLine(card.from)} -

+ ) : null} {expanded ? null : (

diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 28c4d9e2b18..50b26c27d3a 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -12,6 +12,11 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) +// A card's sender line opens and names agents through modules this test does not exercise. +vi.mock('@/lib/open-agent-message-sender', () => ({ openAgentMessageSender: vi.fn() })) +vi.mock('@/runtime/structured-conversation-name', () => ({ + useStructuredChatTabConversationName: () => null +})) vi.mock('../../store', () => { const state = { updateSettings: mocks.updateSettings } const useAppStore = (selector: (value: typeof state) => unknown): unknown => selector(state) @@ -41,6 +46,7 @@ function renderList( return render( + return } const view = render( @@ -199,7 +205,10 @@ describe('NativeChatQueuedMessageList', () => { const region = container.querySelector('[aria-live="polite"]') rerender( - + ) expect(container.querySelector('[aria-live="polite"]')).toBe(region) @@ -295,7 +304,7 @@ describe('NativeChatQueuedMessageList', () => { const owner = controller([card({ messageId: 'draft-1', position: 1 })]) render( - + ) const steer = screen.getByRole('button', { name: 'Steer' }) @@ -313,7 +322,11 @@ describe('NativeChatQueuedMessageList', () => { const owner = controller([card({ messageId: 'draft-1', position: 1 })]) render( - + ) const action = screen.getByRole('button', { name }) @@ -489,7 +502,11 @@ describe('NativeChatQueuedMessageList', () => { } render( - + ) const resume = screen.getByRole('button', { name: 'Resume' }) @@ -501,7 +518,11 @@ describe('NativeChatQueuedMessageList', () => { }) render( - + ) const enabled = screen.getByRole('button', { name: 'Resume' }) @@ -755,7 +776,7 @@ describe('NativeChatQueuedMessageList', () => { const rerender = (cards: QueuedMessageCard[]): void => view.rerender( - + ) rerender([...first, card({ messageId: 'mail', position: 3, from })]) @@ -777,6 +798,7 @@ describe('NativeChatQueuedMessageList', () => { view.rerender( + (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() +) +const transport = vi.hoisted(() => ({ + bridge: vi.fn(), + subscribe: vi.fn(), + focusRenderer: vi.fn(() => false), + subscriptions: new Map void>(), + unsupported: false, + olderLookup: false +})) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + ...moduleFactories.structuredAgentSessionClient(), + subscribeStructuredAgentSessionStatus: transport.subscribe +})) +vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) +vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) +vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) +vi.mock('./native-chat-image-runtime-context', () => ({ + useNativeChatImageRuntimeContext: () => ({ + settings: null, + worktreeId: 'shared-workspace', + worktreePath: '/repo' + }) +})) +vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) +vi.mock('./NativeChatEmptyState', () => moduleFactories.nativeChatEmptyState()) +vi.mock('./NativeChatApprovalCard', () => moduleFactories.nativeChatApprovalCard()) +vi.mock('./NativeChatQuestionCard', () => moduleFactories.nativeChatQuestionCard()) +vi.mock('./NativeChatPaneFileDropSurface', () => ({ + NativeChatPaneFileDropSurface: ({ children }: { children: ReactNode }) => children +})) +vi.mock('../tab-group/RetainedPaneHost', () => ({ + RetainedPaneHost: ({ children }: { children: ReactNode }) => children +})) +vi.mock('@/components/terminal-pane/terminal-handle-links', async (original) => ({ + ...(await original>()), + focusRendererTerminalHandle: transport.focusRenderer +})) + +import StructuredAgentSessionPaneOverlayLayer from './StructuredAgentSessionPaneOverlayLayer' +import { clearRuntimeCompatibilityCacheForTests } from '@/runtime/runtime-rpc-client' +import { + getStructuredAgentSessionStatusFeed, + resetStructuredAgentSessionStatusFeedsForTests +} from '@/runtime/structured-agent-session-status-feed' + +const WORKSPACE = 'shared-workspace' +const ROOT = testOrcaSessionId('root-chat') +const initial = useAppStore.getInitialState() +const previousApi = Object.getOwnPropertyDescriptor(window, 'api') +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' +let restoreViewport = () => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) + +function tab(id: string, sessionId: string, name: string, host: 'local' | 'runtime:server-1'): Tab { + return { + id, + entityId: sessionId, + customLabel: name, + executionHostId: host, + worktreeId: WORKSPACE, + groupId: 'group', + contentType: 'agent-session', + label: 'Claude Chat', + color: null, + sortOrder: 0, + createdAt: 0 + } +} +function source(terminal = false): AgentMessageSource { + const address = terminal ? 'term-sender' : `orca_session_id:${ROOT}` + return { + kind: 'agent', + senders: [ + { + party: { + address, + terminalHandle: terminal ? address : null, + orcaSessionId: terminal ? null : ROOT + }, + name: 'Recorded sender' + } + ], + orchestration: { + message: 'mail-notice', + mailbox: 'run:r1', + dispatchId: null, + messages: [{ messageId: 'mail-1', runId: 'r1', from: address }] + } + } +} +function seed(duplicate = false, terminal = false): void { + const from = source(terminal) + mocks.messages = [ + { + id: 'mail', + role: 'user', + timestamp: 1, + source: 'transcript', + blocks: [{ type: 'text', text: 'Agent mail' }], + from + } + ] + mocks.queuedCards = [ + { messageId: 'queued', position: 1, text: 'Agent mail', state: 'waiting', hold: 'turn', from } + ] + useAppStore.setState( + { + ...initial, + activeWorktreeId: WORKSPACE, + activeWorkspaceExecutionHostId: 'local', + focusGroup: vi.fn(), + activateTab: vi.fn(), + setActiveTabType: vi.fn(), + unifiedTabsByWorktree: { + [WORKSPACE]: [ + { + ...tab('recipient', 'recipient-chat', 'Recipient', 'runtime:server-1'), + agentSessionAgent: 'claude' + }, + tab('remote-sender', ROOT, 'Remote rename', 'runtime:server-1'), + tab('local-sender', duplicate ? ROOT : 'local-chat', 'Wrong local name', 'local') + ] + }, + groupsByWorktree: { + [WORKSPACE]: [ + { id: 'group', worktreeId: WORKSPACE, activeTabId: 'recipient', tabOrder: ['recipient'] } + ] + }, + activeGroupIdByWorktree: { [WORKSPACE]: 'group' } + }, + true + ) +} +async function mount(): Promise> { + let view: ReturnType + await act(async () => { + view = render( + + + + ) + }) + return view! +} +function publish(host: string, sessionId: string = ROOT): void { + transport.subscriptions.get(host)?.({ + type: 'snapshot', + sessions: [ + { + sessionId, + workspaceId: WORKSPACE, + agent: 'claude', + status: null, + latestPrompt: '', + updatedAt: 1 + } + ] + }) +} + +beforeEach(() => { + resetStructuredSessionMocks() + resetStructuredAgentSessionStatusFeedsForTests() + clearRuntimeCompatibilityCacheForTests() + transport.subscriptions.clear() + transport.subscribe.mockReset() + transport.focusRenderer.mockClear() + transport.unsupported = false + transport.olderLookup = false + transport.subscribe.mockImplementation( + async (target: RuntimeClientTarget, emit: (event: AgentSessionStatusEvent) => void) => { + const host = target.kind === 'local' ? 'local' : target.environmentId + transport.subscriptions.set(host, emit) + return { unsubscribe: vi.fn(() => transport.subscriptions.delete(host)) } + } + ) + transport.bridge.mockReset() + transport.bridge.mockImplementation( + async ({ method, params }: { method: string; params?: { address?: string } }) => { + if (method === 'orchestration.partyLocation' && transport.olderLookup) { + return { + id: 'reply', + ok: false, + error: { code: 'method_not_found', message: 'older host' }, + _meta: { runtimeId: 'remote' } + } + } + const result = + method === 'status.get' + ? { + runtimeId: 'remote', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: transport.unsupported + ? [] + : [AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY] + } + : method === 'orchestration.partyLocation' + ? { + location: + params?.address === 'term-sender' + ? { kind: 'terminal', handle: 'term-current' } + : { kind: 'chat', sessionId: ROOT, worktreeId: WORKSPACE } + } + : { ok: true } + return { id: 'reply', ok: true, result, _meta: { runtimeId: 'remote' } } + } + ) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + ...window.api, + runtime: { call: transport.bridge }, + runtimeEnvironments: { call: transport.bridge } + } + }) +}) +afterEach(() => { + cleanup() + resetStructuredAgentSessionStatusFeedsForTests() + clearRuntimeCompatibilityCacheForTests() + useAppStore.setState(initial, true) + if (previousApi) { + Object.defineProperty(window, 'api', previousApi) + } else { + Reflect.deleteProperty(window, 'api') + } +}) + +it.each([false, true])( + 'uses the stamped recipient host through queue/transcript names, renames and chat clicks (duplicate IDs %s)', + async (duplicate) => { + seed(duplicate) + const stopLocal = getStructuredAgentSessionStatusFeed({ kind: 'local' }).activate() + await mount() + act(() => { + publish('local', duplicate ? ROOT : 'local-chat') + publish('server-1') + }) + expect(screen.getAllByRole('button', { name: 'Remote rename' })).toHaveLength(2) + act(() => + useAppStore.setState({ + unifiedTabsByWorktree: { + [WORKSPACE]: useAppStore + .getState() + .unifiedTabsByWorktree[WORKSPACE]!.map((t) => + t.id === 'remote-sender' ? { ...t, customLabel: 'Updated remote rename' } : t + ) + } + }) + ) + const links = screen.getAllByRole('button', { name: 'Updated remote rename' }) + for (const link of links) { + await act(async () => fireEvent.click(link)) + expect(transport.bridge).toHaveBeenCalledWith( + expect.objectContaining({ selector: 'server-1', method: 'orchestration.partyLocation' }) + ) + expect(transport.bridge).toHaveBeenCalledWith( + expect.objectContaining({ selector: 'server-1', method: 'session.tabs.activate' }) + ) + expect(useAppStore.getState().activateTab).toHaveBeenLastCalledWith('remote-sender', { + worktreeId: WORKSPACE + }) + } + stopLocal() + } +) + +it('keeps recorded CLI labels and focuses the exact remote pane from both surfaces', async () => { + seed(false, true) + await mount() + for (const link of screen.getAllByRole('button', { name: 'Recorded sender' })) { + await act(async () => fireEvent.click(link)) + expect(transport.focusRenderer).toHaveBeenLastCalledWith('term-current', 'server-1') + expect(transport.bridge).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'server-1', + method: 'terminal.focus', + params: { terminal: 'term-current', navigation: 'host' } + }) + ) + } +}) + +it('keeps older-host direct-root names and both clicks on the stamped host when the workspace is local', async () => { + seed() + transport.unsupported = true + transport.olderLookup = true + useAppStore.setState({ + unifiedTabsByWorktree: { + [WORKSPACE]: [ + useAppStore.getState().unifiedTabsByWorktree[WORKSPACE]![0]!, + tab('wrong-local', ROOT, 'Wrong local name', 'local'), + tab('remote-sender', ROOT, 'Legacy remote rename', 'runtime:server-1') + ] + } + }) + await mount() + for (const link of screen.getAllByRole('button', { name: 'Legacy remote rename' })) { + await act(async () => fireEvent.click(link)) + expect(useAppStore.getState().activateTab).toHaveBeenLastCalledWith('remote-sender', { + worktreeId: WORKSPACE + }) + expect(transport.bridge).toHaveBeenCalledWith( + expect.objectContaining({ selector: 'server-1', method: 'session.tabs.activate' }) + ) + } +}) diff --git a/src/renderer/src/components/native-chat/native-chat-agent-message-sender-identity.test.tsx b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-identity.test.tsx new file mode 100644 index 00000000000..c84294e914b --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-identity.test.tsx @@ -0,0 +1,191 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { AgentSessionStatusSummary } from '../../../../shared/agent-session-wire' +import type { AgentMessageSource } from '../../../../shared/agent-session-message-source' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { testOrcaSessionId } from '../../../../shared/orca-session-address-test-fixture' +import type { Tab } from '../../../../shared/tab-types' +import { NativeChatAgentMessageSenders } from './NativeChatAgentMessageSenders' + +const feeds = vi.hoisted( + () => + new Map< + string, + { + snapshot: ReadonlyMap + listeners: Set<() => void> + live: boolean + activations: number + } + >() +) +vi.mock('@/runtime/structured-agent-session-status-feed', () => ({ + getStructuredAgentSessionStatusFeed: (target: { kind: string; environmentId?: string }) => { + const feed = feeds.get(target.environmentId ?? 'local')! + return { + activate: () => { + feed.activations++ + return () => { + feed.activations-- + } + }, + getSnapshot: () => feed.snapshot, + getSessionObservation: () => (feed.live ? 'live' : 'unverifiable'), + subscribe: (listener: () => void) => { + feed.listeners.add(listener) + return () => { + feed.listeners.delete(listener) + } + } + } + } +})) +const openAgentMessageSender = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/open-agent-message-sender', () => ({ openAgentMessageSender })) + +const ROOT = testOrcaSessionId('root-chat') +const RECIPIENT = 'recipient' +const SENDER = 'sender' +const PANE = 'terminal:11111111-1111-4111-8111-111111111111' +const initial = useAppStore.getInitialState() +const providerA = { key: 'session_id' as const, id: 'provider-a' } + +function summary(sessionId: string): AgentSessionStatusSummary { + return { + sessionId, + workspaceId: SENDER, + agent: 'claude', + status: null, + latestPrompt: '', + updatedAt: 1 + } +} +function tab(sessionId: string, name: string, host: 'local' | 'runtime:remote' = 'local'): Tab { + return { + id: `tab-${host}-${sessionId}`, + entityId: sessionId, + worktreeId: SENDER, + groupId: 'g', + contentType: 'agent-session', + executionHostId: host, + label: 'Claude Chat', + customLabel: name, + color: null, + sortOrder: 0, + createdAt: 0 + } +} +function source(terminal = false): AgentMessageSource { + return { + kind: 'agent', + orchestration: null, + senders: [ + { + party: { + address: terminal ? 'term-a' : `orca_session_id:${ROOT}`, + terminalHandle: terminal ? 'term-a' : null, + orcaSessionId: terminal ? null : ROOT + }, + name: 'Original sender' + } + ] + } +} +function publish(host: string, summaries: AgentSessionStatusSummary[]): void { + const feed = feeds.get(host)! + feed.snapshot = new Map(summaries.map((row) => [row.sessionId, row])) + for (const listener of feed.listeners) { + listener() + } +} +function row(provider = providerA, agentType = 'claude'): AgentStatusEntry { + return { + paneKey: PANE, + terminalHandle: 'term-a', + worktreeId: SENDER, + state: 'working', + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [], + agentType, + providerSession: provider, + connectionId: null + } +} + +beforeEach(() => { + openAgentMessageSender.mockClear() + feeds.set('local', { + snapshot: new Map([[ROOT, summary(ROOT)]]), + listeners: new Set(), + live: true, + activations: 0 + }) + feeds.set('remote', { snapshot: new Map(), listeners: new Set(), live: true, activations: 0 }) + useAppStore.setState( + { ...initial, activeWorktreeId: RECIPIENT, activeWorkspaceExecutionHostId: 'local' }, + true + ) +}) +afterEach(() => { + cleanup() + useAppStore.setState(initial, true) + feeds.clear() +}) + +it('uses the recipient host when identical session text is published by two hosts', () => { + publish('local', [summary(ROOT)]) + publish('remote', [summary(ROOT)]) + useAppStore.setState({ + activeWorkspaceExecutionHostId: 'runtime:remote', + unifiedTabsByWorktree: { + [SENDER]: [tab(ROOT, 'Remote sender', 'runtime:remote'), tab(ROOT, 'Wrong host')] + } + }) + render() + expect(screen.getByRole('button', { name: 'Remote sender' })).toBeInTheDocument() +}) + +it('shows a chat sender by its current rename', () => { + useAppStore.setState({ unifiedTabsByWorktree: { [SENDER]: [tab(ROOT, 'Renamed sender')] } }) + render() + expect(screen.getByRole('button', { name: 'Renamed sender' })).toBeInTheDocument() +}) + +it.each([false, true])( + 'keeps CLI A saved name after provider B and a terminal rename reuse the shell (%s queued)', + (queued) => { + const terminal = { + id: 'terminal', + ptyId: 'pty', + worktreeId: SENDER, + title: 'Claude Code', + customTitle: 'Resumed A', + color: null, + sortOrder: 0, + createdAt: 0 + } + useAppStore.setState({ + agentStatusByPaneKey: { [PANE]: row() }, + tabsByWorktree: { [SENDER]: [terminal] } + }) + const from = source(true) + render() + expect(screen.getByRole('button', { name: 'Original sender' })).toBeInTheDocument() + act(() => { + useAppStore.setState({ + agentStatusByPaneKey: { [PANE]: row({ key: 'session_id', id: 'provider-b' }) }, + tabsByWorktree: { [SENDER]: [{ ...terminal, customTitle: 'Renamed B' }] } + }) + }) + const link = screen.getByRole('button', { name: 'Original sender' }) + expect(link).toBeInTheDocument() + fireEvent.click(link) + expect(openAgentMessageSender).toHaveBeenCalledWith(from, from.senders[0], RECIPIENT) + expect(feeds.get('local')!.activations).toBe(0) + } +) diff --git a/src/renderer/src/components/native-chat/native-chat-agent-message-sender-label.ts b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-label.ts index a2e44282382..85da3fe7435 100644 --- a/src/renderer/src/components/native-chat/native-chat-agent-message-sender-label.ts +++ b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-label.ts @@ -1,25 +1,73 @@ import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { structuredChatTabBySessionId } from '@/lib/structured-chat-tab-index' +import { useStructuredChatTabConversationName } from '@/runtime/structured-conversation-name' import { - agentMessageSendersShown, - type AgentMessageSender, - type AgentMessageSource -} from '../../../../shared/agent-session-message-source' + structuredAgentSessionOwnerForTab, + resolveStructuredAgentSessionOwner, + executionHostIdForStructuredTarget +} from '@/runtime/structured-agent-session-owner' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import type { AgentMessageSender } from '../../../../shared/agent-session-message-source' +import type { Tab } from '../../../../shared/tab-types' +import { getKnownExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' -function unnamedSenderLabel(): string { +export function unnamedSenderLabel(): string { return translate('components.native-chat.agentMessage.unnamedSender', 'an agent') } +/** The name recorded on the message. */ export function agentMessageSenderLabel(sender: AgentMessageSender): string { return sender.name ?? unnamedSenderLabel() } -/** A queued card's plain "From " line; the card's own controls own its clicks. */ -export function queuedCardSenderLine(from: AgentMessageSource): string { - const { shown, more } = agentMessageSendersShown(from) - // One name per sender, repeats included: two senders that share a name are still two. - const listed = - shown.length > 0 ? shown.map(agentMessageSenderLabel).join(', ') : unnamedSenderLabel() - return translate('components.native-chat.queuedMessages.from', 'From {{names}}', { - names: more > 0 ? `${listed} +${more}` : listed - }) +function findChatTab( + state: AppState, + sessionId: string, + executionHostId: string | null +): Tab | undefined { + const tabsByWorktree = state.unifiedTabsByWorktree + for (const worktreeId in tabsByWorktree) { + const tab = structuredChatTabBySessionId(tabsByWorktree, worktreeId, sessionId) + if (tab && structuredAgentSessionOwnerForTab(state, tab) === executionHostId) { + return tab + } + // A workspace bucket can mirror identical session ids from two hosts. + const qualified = + tab && + tabsByWorktree[worktreeId]?.find( + (candidate) => + candidate.contentType === 'agent-session' && + candidate.entityId === sessionId && + structuredAgentSessionOwnerForTab(state, candidate) === executionHostId + ) + if (qualified) { + return qualified + } + } + return undefined +} + +/** Live chat names follow the sender's chat on the receiving chat's host; CLI names stay as recorded. */ +export function useAgentMessageSenderLabel( + sender: AgentMessageSender, + chatWorktreeId: string | null, + target?: RuntimeClientTarget +): string { + const { address, orcaSessionId } = sender.party + const local = !address.startsWith('dispatch:') + const owner = useAppStore((state) => + target + ? executionHostIdForStructuredTarget(target) + : chatWorktreeId && getKnownExecutionHostIdForWorktree(state, chatWorktreeId) + ? resolveStructuredAgentSessionOwner(state, chatWorktreeId) + : null + ) + const chatTab = useAppStore((state) => + local && orcaSessionId ? findChatTab(state, orcaSessionId, owner) : undefined + ) + const conversationName = useStructuredChatTabConversationName(chatTab) + const ownName = chatTab ? chatTab.customLabel?.trim() || conversationName : null + return ownName || sender.name || chatTab?.label.trim() || unnamedSenderLabel() } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c930a12bba8..fa1afe01cbc 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18660,7 +18660,7 @@ "sendConfirmBody_other": "You are about to send a message. Do you want to clear the {{count}} messages previously queued?", "sendConfirmClear": "Clear queue", "sendConfirmSend": "Send message", - "from": "From {{names}}", + "fromSender": "From", "pausedSendFailedWaiting": "Couldn't send — press Send to retry once the agent finishes." }, "thought": "Thought", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 55ca9233887..f1456c9c678 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18270,7 +18270,7 @@ "sendConfirmBody_other": "Estás a punto de enviar un mensaje. ¿Quieres borrar los {{count}} mensajes que había en cola?", "sendConfirmClear": "Vaciar cola", "sendConfirmSend": "Enviar mensaje", - "from": "De {{names}}", + "fromSender": "De", "pausedSendFailedWaiting": "No se pudo enviar. Cuando el agente termine, pulsa Enviar para reintentar." }, "thought": "Pensó", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index cafe11ad225..74954b2510c 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18508,7 +18508,7 @@ "sendConfirmBody_other": "Vous êtes sur le point d'envoyer un message. Voulez-vous effacer les {{count}} messages déjà en file d'attente ?", "sendConfirmClear": "Vider la file d'attente", "sendConfirmSend": "Envoyer le message", - "from": "De {{names}}", + "fromSender": "De", "pausedSendFailedWaiting": "Envoi impossible. Quand l'agent aura terminé, appuyez sur Envoyer pour réessayer." }, "thought": "A réfléchi", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 5f253b62863..71a8d66f164 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18442,7 +18442,7 @@ "sendConfirmBody_other": "メッセージを送信しようとしています。キューに入っている {{count}} 件のメッセージを消去しますか?", "sendConfirmClear": "キューを消去", "sendConfirmSend": "メッセージを送信", - "from": "送信元: {{names}}", + "fromSender": "送信元:", "pausedSendFailedWaiting": "送信できませんでした。Agent の処理が終わったら「送信」を押して再試行してください。" }, "thought": "考えました", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 6d0a4c97515..2a4cee1663b 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18442,7 +18442,7 @@ "sendConfirmBody_other": "메시지를 보내려고 합니다. 대기열에 있는 메시지 {{count}}개를 지울까요?", "sendConfirmClear": "대기열 지우기", "sendConfirmSend": "메시지 보내기", - "from": "보낸 사람: {{names}}", + "fromSender": "보낸 사람:", "pausedSendFailedWaiting": "보낼 수 없습니다. 에이전트가 끝나면 보내기를 눌러 다시 시도하세요." }, "thought": "생각함", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7a744f952aa..b1306527066 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18407,7 +18407,7 @@ "sendConfirmBody_other": "你即将发送一条消息。是否清除之前排队的 {{count}} 条消息?", "sendConfirmClear": "清空队列", "sendConfirmSend": "发送消息", - "from": "来自 {{names}}", + "fromSender": "来自", "pausedSendFailedWaiting": "无法发送。智能体完成后,点按发送以重试。" }, "thought": "已思考", diff --git a/src/renderer/src/lib/activate-ai-vault-structured-session-host.test.ts b/src/renderer/src/lib/activate-ai-vault-structured-session-host.test.ts new file mode 100644 index 00000000000..4a71275c39b --- /dev/null +++ b/src/renderer/src/lib/activate-ai-vault-structured-session-host.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import type { Tab } from '../../../shared/tab-types' + +const mocks = vi.hoisted(() => { + const tabs: Record = {} + return { + call: vi.fn(), + supports: vi.fn(), + apply: vi.fn(), + activateTab: vi.fn(), + focusGroup: vi.fn(), + setActiveTabType: vi.fn(), + state: { + activeWorktreeId: 'workspace', + activeWorkspaceExecutionHostId: 'local' as const, + unifiedTabsByWorktree: tabs + } + } +}) +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ + ...mocks.state, + activateTab: mocks.activateTab, + focusGroup: mocks.focusGroup, + setActiveTabType: mocks.setActiveTabType + }) + } +})) +vi.mock('@/runtime/runtime-rpc-client', async (original) => ({ + ...(await original>()), + callRuntimeRpc: mocks.call, + runtimeEnvironmentSupportsCapability: mocks.supports +})) +vi.mock('@/runtime/local-structured-session-tabs-sync', async (original) => ({ + ...(await original>()), + applyStructuredSessionTabSnapshots: mocks.apply +})) + +import { activateAiVaultStructuredSession } from './activate-ai-vault-structured-session' +import { STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' + +const TARGET = { kind: 'environment', environmentId: 'remote-2' } as const +function tab(id: string, host: 'local' | 'runtime:remote-2'): Tab { + return { + id, + worktreeId: 'workspace', + entityId: 'sender', + executionHostId: host, + groupId: 'group', + contentType: 'agent-session', + label: 'Claude Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} +beforeEach(() => { + vi.clearAllMocks() + mocks.state.unifiedTabsByWorktree = { workspace: [tab('wrong-local', 'local')] } + mocks.supports.mockResolvedValue(true) + mocks.call.mockImplementation(async (_host, method: string) => { + if (method === 'agentSession.reveal') { + // The paired mirror, rather than the local snapshot apply, publishes the revealed tab. + mocks.state.unifiedTabsByWorktree.workspace.push(tab('right-remote', 'runtime:remote-2')) + } + return { ok: true } + }) +}) + +it('keeps the known host through the default refresh/reveal/activation path without applying its snapshot as local', async () => { + await expect( + activateAiVaultStructuredSession( + { + structuredSession: { workspaceId: 'workspace', sessionId: 'sender' } + }, + undefined, + TARGET + ) + ).resolves.toBe(true) + expect(mocks.supports).toHaveBeenCalledWith( + 'remote-2', + STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, + expect.any(Number) + ) + expect(mocks.call.mock.calls.map(([host, method]) => [host, method])).toEqual([ + [TARGET, 'session.tabs.list'], + [TARGET, 'agentSession.reveal'], + [TARGET, 'session.tabs.list'], + [TARGET, 'session.tabs.activate'] + ]) + expect(mocks.apply).not.toHaveBeenCalled() + expect(mocks.activateTab).toHaveBeenCalledOnce() + expect(mocks.activateTab).toHaveBeenCalledWith('right-remote', { worktreeId: 'workspace' }) +}) diff --git a/src/renderer/src/lib/activate-ai-vault-structured-session-reveal.test.ts b/src/renderer/src/lib/activate-ai-vault-structured-session-reveal.test.ts index 0de7f0edb3a..32025b8dad0 100644 --- a/src/renderer/src/lib/activate-ai-vault-structured-session-reveal.test.ts +++ b/src/renderer/src/lib/activate-ai-vault-structured-session-reveal.test.ts @@ -74,6 +74,22 @@ describe('revealStructuredSession', () => { ) }) + it('keeps a known recipient target when the active workspace would resolve locally', async () => { + const host = { kind: 'environment', environmentId: 'recipient-host' } as const + await expect(revealStructuredSession({ ...target, target: host })).resolves.toBe('revealed') + expect(mocks.supports).toHaveBeenCalledWith( + 'recipient-host', + STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, + expect.any(Number) + ) + expect(mocks.call).toHaveBeenCalledWith( + host, + 'agentSession.reveal', + { sessionId: 'session-1' }, + expect.any(Object) + ) + }) + it('reports a paired host that cannot open it rather than sending an unknown method', async () => { // The regression this guards: an older paired host answers method_not_found, which is // indistinguishable from a refusal, so the user is told the chat is gone when it is not. diff --git a/src/renderer/src/lib/activate-ai-vault-structured-session.test.ts b/src/renderer/src/lib/activate-ai-vault-structured-session.test.ts index 1cf5ce751ae..c1f9ef76ae8 100644 --- a/src/renderer/src/lib/activate-ai-vault-structured-session.test.ts +++ b/src/renderer/src/lib/activate-ai-vault-structured-session.test.ts @@ -1,10 +1,9 @@ import { describe, expect, it, vi } from 'vitest' -import type { AiVaultSession } from '../../../shared/ai-vault-types' import { activateAiVaultStructuredSession } from './activate-ai-vault-structured-session' const structuredSession = { structuredSession: { sessionId: 'session-1', workspaceId: 'workspace-1' } -} as AiVaultSession +} function deps(overrides: Partial[1]> = {}) { return { @@ -159,8 +158,45 @@ describe('activateAiVaultStructuredSession', () => { it('ignores a row that is not a structured chat', async () => { const parts = deps() - await expect(activateAiVaultStructuredSession({} as AiVaultSession, parts)).resolves.toBe(false) + await expect(activateAiVaultStructuredSession({}, parts)).resolves.toBe(false) expect(parts.reveal).not.toHaveBeenCalled() }) + + it('carries an explicit recipient host through refresh, reveal and activation', async () => { + const target = { kind: 'environment', environmentId: 'remote-2' } as const + const parts = deps({ + activate: vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(false).mockReturnValue(true) + }) + await expect(activateAiVaultStructuredSession(structuredSession, parts, target)).resolves.toBe( + true + ) + expect(parts.refresh).toHaveBeenCalledWith('workspace-1', target) + expect(parts.reveal).toHaveBeenCalledWith({ + worktreeId: 'workspace-1', + sessionId: 'session-1', + target + }) + expect(parts.activate).toHaveBeenLastCalledWith({ + worktreeId: 'workspace-1', + sessionId: 'session-1', + target + }) + }) + + it('does not coalesce equal session IDs from two hosts while each activation is pending', async () => { + let release!: (outcome: 'gone') => void + const pending = new Promise<'gone'>((resolve) => { + release = resolve + }) + const parts = deps({ activate: vi.fn(() => false), reveal: vi.fn(() => pending) }) + const local = activateAiVaultStructuredSession(structuredSession, parts, { kind: 'local' }) + const remote = activateAiVaultStructuredSession(structuredSession, parts, { + kind: 'environment', + environmentId: 'remote-2' + }) + await vi.waitFor(() => expect(parts.reveal).toHaveBeenCalledTimes(2)) + release('gone') + await Promise.all([local, remote]) + }) }) diff --git a/src/renderer/src/lib/activate-ai-vault-structured-session.ts b/src/renderer/src/lib/activate-ai-vault-structured-session.ts index becd74facb6..7b8fa10c664 100644 --- a/src/renderer/src/lib/activate-ai-vault-structured-session.ts +++ b/src/renderer/src/lib/activate-ai-vault-structured-session.ts @@ -8,7 +8,8 @@ import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' import { callRuntimeRpc, getActiveRuntimeTarget, - runtimeEnvironmentSupportsCapability + runtimeEnvironmentSupportsCapability, + type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' @@ -19,6 +20,7 @@ import { } from '@/runtime/local-structured-session-tabs-sync' import { STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import { isRuntimeCompatBlockError } from '@/runtime/runtime-protocol-compat' +import { executionHostIdForStructuredTarget } from '@/runtime/structured-agent-session-owner' const STRUCTURED_SESSION_RESTORE_TIMEOUT_MS = 5_000 @@ -34,10 +36,11 @@ export type StructuredSessionRevealOutcome = type StructuredSessionActivationDeps = { activate: typeof activateStructuredAgentSessionById - refresh: (worktreeId: string) => Promise + refresh: (worktreeId: string, target?: RuntimeClientTarget) => Promise reveal: (target: { worktreeId: string sessionId: string + target?: RuntimeClientTarget }) => Promise unavailable: () => void gone: () => void @@ -97,7 +100,8 @@ const defaultDeps: StructuredSessionActivationDeps = { */ export async function activateAiVaultStructuredSession( session: Pick, - deps: StructuredSessionActivationDeps = defaultDeps + deps: StructuredSessionActivationDeps = defaultDeps, + target?: RuntimeClientTarget ): Promise { const structured = session.structuredSession if (!structured) { @@ -106,16 +110,26 @@ export async function activateAiVaultStructuredSession( // Why: the click can chain a refresh, a capability probe, a reveal and a second refresh, each // with its own timeout, and nothing on the row says it is working. Without this, an impatient // second click runs the whole sequence again and lands its own toast. - const inFlight = activationsInFlight.get(structured.sessionId) + const hostKey = executionHostIdForStructuredTarget( + target ?? + getActiveRuntimeTarget({ + activeRuntimeEnvironmentId: getRuntimeEnvironmentIdForWorktree( + useAppStore.getState(), + structured.workspaceId + ) + }) + ) + const key = `${hostKey}\0${structured.workspaceId}\0${structured.sessionId}` + const inFlight = activationsInFlight.get(key) if (inFlight) { return inFlight } - const activation = activateStructuredSession(structured, deps) - activationsInFlight.set(structured.sessionId, activation) + const activation = activateStructuredSession(structured, deps, target) + activationsInFlight.set(key, activation) try { return await activation } finally { - activationsInFlight.delete(structured.sessionId) + activationsInFlight.delete(key) } } @@ -123,14 +137,19 @@ const activationsInFlight = new Map>() async function activateStructuredSession( structured: NonNullable, - deps: StructuredSessionActivationDeps + deps: StructuredSessionActivationDeps, + host?: RuntimeClientTarget ): Promise { - const target = { worktreeId: structured.workspaceId, sessionId: structured.sessionId } + const target = { + worktreeId: structured.workspaceId, + sessionId: structured.sessionId, + ...(host ? { target: host } : {}) + } if (!deps.activate(target)) { // A refresh alone can answer, and costs one call instead of two. It is only ever an // optimization, so a refresh that fails must fall through to the reveal rather than end the // click: the host republishing the tab is the repair, and it does not need this to have worked. - const refreshed = await refreshedWithoutThrowing(deps, structured.workspaceId) + const refreshed = await refreshedWithoutThrowing(deps, structured.workspaceId, host) if (!refreshed || !deps.activate(target)) { // The inventory genuinely does not carry this chat: it was closed, or this process never // published it. Ask the host to republish the tab from the record it still holds on disk. @@ -146,7 +165,7 @@ async function activateStructuredSession( } return true } - await refreshedWithoutThrowing(deps, structured.workspaceId) + await refreshedWithoutThrowing(deps, structured.workspaceId, host) if (!deps.activate(target)) { deps.unavailable() return true @@ -163,10 +182,11 @@ async function activateStructuredSession( * thrown end to the click. */ async function refreshedWithoutThrowing( deps: StructuredSessionActivationDeps, - worktreeId: string + worktreeId: string, + target?: RuntimeClientTarget ): Promise { try { - await deps.refresh(worktreeId) + await (target ? deps.refresh(worktreeId, target) : deps.refresh(worktreeId)) return true } catch { return false @@ -182,12 +202,16 @@ async function refreshedWithoutThrowing( export async function revealStructuredSession(target: { worktreeId: string sessionId: string + target?: RuntimeClientTarget }): Promise { - const environmentId = getRuntimeEnvironmentIdForWorktree( - useAppStore.getState(), - target.worktreeId - ) - const host = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }) + const host = + target.target ?? + getActiveRuntimeTarget({ + activeRuntimeEnvironmentId: getRuntimeEnvironmentIdForWorktree( + useAppStore.getState(), + target.worktreeId + ) + }) // Negotiated against the host that will answer this call, not the local one: a paired host runs // its own build, and its method-not-found is indistinguishable from a refusal we should surface. // A local host is this build, so it always has the method and needs no round trip to prove it. @@ -239,26 +263,32 @@ export async function revealStructuredSession(target: { type AgentSessionRevealReply = { ok?: boolean; refusal?: { code?: string } } -async function refreshStructuredSessionTabs(worktreeId: string): Promise { +async function refreshStructuredSessionTabs( + worktreeId: string, + target?: RuntimeClientTarget +): Promise { const state = useAppStore.getState() - const environmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + const host = + target ?? + getActiveRuntimeTarget({ + activeRuntimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, worktreeId) + }) // Every other caller that applies an inventory fences it on the sync generation. Structured chat // can be switched off while this call is in flight, which wipes the mirror; without this the // answer would land afterwards and re-seed a chat row into a renderer that just discarded them. const generation = localStructuredSessionGeneration() const snapshot = await withStructuredSessionRestoreTimeout( callRuntimeRpc( - getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }), + host, 'session.tabs.list', { worktree: toRuntimeWorktreeSelector(worktreeId) }, { timeoutMs: STRUCTURED_SESSION_RESTORE_TIMEOUT_MS } ) ) - if (!isCurrentLocalStructuredSessionGeneration(generation)) { + if (host.kind !== 'local' || !isCurrentLocalStructuredSessionGeneration(generation)) { return } - // No owner scope: the apply discards any worktree whose execution host is not local before it - // reads one, so a paired workspace is carried by the subscription, not by this call. + // Paired inventories arrive through their existing host-scoped subscription. applyStructuredSessionTabSnapshots([snapshot]) } diff --git a/src/renderer/src/lib/open-agent-message-sender.test.ts b/src/renderer/src/lib/open-agent-message-sender.test.ts index ccaac427ad0..a72dc6f9b9e 100644 --- a/src/renderer/src/lib/open-agent-message-sender.test.ts +++ b/src/renderer/src/lib/open-agent-message-sender.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -type TabsByWorktree = Record +type TabsByWorktree = Record< + string, + { contentType: string; entityId: string; worktreeId?: string; executionHostId?: string }[] +> const mocks = vi.hoisted(() => ({ callRuntimeRpc: vi.fn(), @@ -12,7 +15,8 @@ const mocks = vi.hoisted(() => ({ hostCannotOpen: vi.fn(), unavailable: vi.fn(), toastError: vi.fn(), - tabs: new Map([['current', {}]]) + tabs: new Map([['current', {}]]), + missingOwner: false })) vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) @@ -40,8 +44,10 @@ vi.mock('./activate-ai-vault-structured-session', () => ({ unavailable: mocks.unavailable } })) -vi.mock('./worktree-runtime-owner', () => ({ - getRuntimeEnvironmentIdForWorktree: () => 'env-host' +vi.mock('./worktree-runtime-owner', async (original) => ({ + ...(await original>()), + getKnownExecutionHostIdForWorktree: () => (mocks.missingOwner ? null : 'runtime:env-host'), + getExecutionHostIdForWorktree: () => 'runtime:env-host' })) vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ unifiedTabsByWorktree: mocks.tabs.get('current') }) } @@ -102,6 +108,7 @@ const OLDER_HOST = AGENT_SESSION_WRITE_NOTICE_COPY.unsupported beforeEach(() => { vi.clearAllMocks() + mocks.missingOwner = false mocks.tabs.set('current', {}) }) @@ -167,9 +174,13 @@ describe("opening a message's sender", () => { location: { kind: 'chat', sessionId: 'live-session', worktreeId: 'wt-sender' } }) await open(chat) - expect(mocks.activateChat).toHaveBeenCalledWith({ - structuredSession: { workspaceId: 'wt-sender', sessionId: 'live-session' } - }) + expect(mocks.activateChat).toHaveBeenCalledWith( + { + structuredSession: { workspaceId: 'wt-sender', sessionId: 'live-session' } + }, + undefined, + HOST + ) }) it('names what the host lost, not how the sender was addressed', async () => { @@ -204,9 +215,13 @@ describe("opening a message's sender", () => { mocks.callRuntimeRpc.mockRejectedValue(failure('method_not_found')) mocks.tabs.set('current', { 'wt-sender': [{ contentType: 'agent-session', entityId: ROOT }] }) await open(chat) - expect(mocks.activateChat).toHaveBeenCalledWith({ - structuredSession: { workspaceId: 'wt-sender', sessionId: ROOT } - }) + expect(mocks.activateChat).toHaveBeenCalledWith( + { + structuredSession: { workspaceId: 'wt-sender', sessionId: ROOT } + }, + undefined, + HOST + ) mocks.focusRenderer.mockReturnValue(true) await open(terminal) expect(mocks.focusRenderer).toHaveBeenCalledWith('term_a', 'env-host') @@ -244,4 +259,66 @@ describe("opening a message's sender", () => { await Promise.all([first, second]) expect(mocks.callRuntimeRpc).toHaveBeenCalledTimes(1) }) + + it('keeps simultaneous opens of the same address on two known hosts separate', async () => { + let answer!: (value: unknown) => void + mocks.callRuntimeRpc.mockReturnValue( + new Promise((resolve) => { + answer = resolve + }) + ) + const source = sourceFor(chat) + const local = openAgentMessageSender(source, source.senders[0]!, 'wt-chat', { kind: 'local' }) + const remote = openAgentMessageSender(source, source.senders[0]!, 'wt-chat', { + kind: 'environment', + environmentId: 'remote-2' + }) + expect(mocks.callRuntimeRpc).toHaveBeenCalledTimes(2) + expect(mocks.callRuntimeRpc.mock.calls.map(([host]) => host)).toEqual([ + { kind: 'local' }, + { kind: 'environment', environmentId: 'remote-2' } + ]) + answer({ location: null, lost: 'chat' }) + await Promise.all([local, remote]) + }) + + it('qualifies older-host root tabs by the known recipient host even when IDs repeat', async () => { + mocks.callRuntimeRpc.mockRejectedValue(failure('method_not_found')) + mocks.tabs.set('current', { + 'wrong-workspace': [ + { + contentType: 'agent-session', + entityId: ROOT, + worktreeId: 'wrong-workspace', + executionHostId: 'local' + } + ], + 'right-workspace': [ + { + contentType: 'agent-session', + entityId: ROOT, + worktreeId: 'right-workspace', + executionHostId: 'runtime:remote-2' + } + ] + }) + const source = sourceFor(chat) + const target = { kind: 'environment', environmentId: 'remote-2' } as const + await openAgentMessageSender(source, source.senders[0]!, 'wt-chat', target) + expect(mocks.activateChat).toHaveBeenCalledWith( + { + structuredSession: { workspaceId: 'right-workspace', sessionId: ROOT } + }, + undefined, + target + ) + }) + + it('does not dispatch locally when legacy context has no known owner', async () => { + mocks.missingOwner = true + await open(chat) + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + expect(mocks.activateChat).not.toHaveBeenCalled() + expect(mocks.toastError).toHaveBeenCalledWith(UNREACHABLE) + }) }) diff --git a/src/renderer/src/lib/open-agent-message-sender.ts b/src/renderer/src/lib/open-agent-message-sender.ts index 9cc1a526eee..5ee15133945 100644 --- a/src/renderer/src/lib/open-agent-message-sender.ts +++ b/src/renderer/src/lib/open-agent-message-sender.ts @@ -12,7 +12,6 @@ import type { import { useAppStore } from '@/store' import { callRuntimeRpc, - getActiveRuntimeTarget, RuntimeRpcCallError, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' @@ -26,7 +25,13 @@ import { activateAiVaultStructuredSession, structuredSessionOpenFeedback } from './activate-ai-vault-structured-session' -import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' +import { getKnownExecutionHostIdForWorktree } from './worktree-runtime-owner' +import { + executionHostIdForStructuredTarget, + resolveStructuredAgentSessionOwner, + structuredAgentSessionOwnerForTab, + structuredAgentSessionTargetForHost +} from '@/runtime/structured-agent-session-owner' type Sender = AgentMessageSource['senders'][number] /** `older-host`: the chat's host predates the lookup. `update`: one side is too old, either one. @@ -49,14 +54,27 @@ const opensInFlight = new Map>() export function openAgentMessageSender( source: AgentMessageSource, sender: Sender, - chatWorktreeId: string + chatWorktreeId: string, + target?: RuntimeClientTarget ): Promise { - const key = `${chatWorktreeId}\0${sender.party.address}` + const state = useAppStore.getState() + const host = + target ?? + (getKnownExecutionHostIdForWorktree(state, chatWorktreeId) + ? structuredAgentSessionTargetForHost( + resolveStructuredAgentSessionOwner(state, chatWorktreeId) + ) + : null) + if (!host) { + showLookupFailure('unreachable') + return Promise.resolve() + } + const key = `${executionHostIdForStructuredTarget(host)}\0${chatWorktreeId}\0${sender.party.address}` const inFlight = opensInFlight.get(key) if (inFlight) { return inFlight } - const opening = openSender(source, sender, chatWorktreeId) + const opening = openSender(source, sender, host) .catch((error: unknown) => { // Every answer above shows its own words; one that threw showed none. console.warn('[agent-message-sender] opening the sender failed', error) @@ -70,20 +88,16 @@ export function openAgentMessageSender( async function openSender( source: AgentMessageSource, { party }: Sender, - chatWorktreeId: string + host: RuntimeClientTarget ): Promise { - const environmentId = getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), chatWorktreeId) + const environmentId = host.kind === 'environment' ? host.environmentId : null // The mail it carried from this sender, whose pane outlives a handle from an earlier run. A task // carries no mail, so its coordinator is found by address alone. const mail = source.orchestration?.message === 'mail-notice' ? source.orchestration.messages : [] const messageIds = mail .filter((message) => message.from === party.address) .map((message) => message.messageId) - const found = await lookUpSender( - party, - messageIds, - getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }) - ) + const found = await lookUpSender(party, messageIds, host) if (typeof found === 'string') { showLookupFailure(found) return @@ -101,9 +115,10 @@ async function openSender( return } if (location.kind === 'chat') { - await activateAiVaultStructuredSession({ + const session = { structuredSession: { workspaceId: location.worktreeId, sessionId: location.sessionId } - }) + } + await activateAiVaultStructuredSession(session, undefined, host) return } if (location.kind !== 'terminal') { @@ -142,7 +157,7 @@ async function lookUpSender( ) } catch (error) { if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') { - return lookUpOnOlderHost(party) + return lookUpOnOlderHost(party, host) } return isRuntimeCompatBlockError(error) ? 'update' : 'unreachable' } @@ -150,12 +165,21 @@ async function lookUpSender( /** A host before the lookup: a chat open here under its root id, as one never `/clear`ed is, or a * terminal by its handle; anything else needs that host updated. */ -function lookUpOnOlderHost(party: Sender['party']): Lookup { +function lookUpOnOlderHost(party: Sender['party'], host: RuntimeClientTarget): Lookup { const sessionId = party.orcaSessionId if (sessionId) { - const tabsByWorktree = useAppStore.getState().unifiedTabsByWorktree + const state = useAppStore.getState() + const tabsByWorktree = state.unifiedTabsByWorktree for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { - if (tabs.some((tab) => tab.contentType === 'agent-session' && tab.entityId === sessionId)) { + if ( + tabs.some( + (tab) => + tab.contentType === 'agent-session' && + tab.entityId === sessionId && + structuredAgentSessionOwnerForTab(state, tab) === + executionHostIdForStructuredTarget(host) + ) + ) { return located({ kind: 'chat', sessionId, worktreeId }) } } diff --git a/src/renderer/src/lib/structured-agent-session-tab-activation.ts b/src/renderer/src/lib/structured-agent-session-tab-activation.ts index 1bec1d1e843..29dbbec132f 100644 --- a/src/renderer/src/lib/structured-agent-session-tab-activation.ts +++ b/src/renderer/src/lib/structured-agent-session-tab-activation.ts @@ -1,19 +1,30 @@ import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' import { useAppStore } from '@/store' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { + callRuntimeRpc, + getActiveRuntimeTarget, + type RuntimeClientTarget +} from '@/runtime/runtime-rpc-client' +import { + executionHostIdForStructuredTarget, + structuredAgentSessionOwnerForTab +} from '@/runtime/structured-agent-session-owner' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import type { Tab } from '../../../shared/tab-types' export function findStructuredAgentSessionTab( unifiedTabsByWorktree: Readonly>, - args: { workspaceId: string; sessionId: string } + args: { workspaceId: string; sessionId: string; target?: RuntimeClientTarget } ): Tab | null { return ( unifiedTabsByWorktree[args.workspaceId]?.find( (candidate) => candidate.worktreeId === args.workspaceId && candidate.contentType === 'agent-session' && - candidate.entityId === args.sessionId + candidate.entityId === args.sessionId && + (!args.target || + structuredAgentSessionOwnerForTab(useAppStore.getState(), candidate) === + executionHostIdForStructuredTarget(args.target)) ) ?? null ) } @@ -21,10 +32,16 @@ export function findStructuredAgentSessionTab( export function activateStructuredAgentSessionTab(args: { worktreeId: string tabId: string + target?: RuntimeClientTarget }): boolean { const state = useAppStore.getState() const tab = (state.unifiedTabsByWorktree[args.worktreeId] ?? []).find( - (candidate) => candidate.id === args.tabId && candidate.contentType === 'agent-session' + (candidate) => + candidate.id === args.tabId && + candidate.contentType === 'agent-session' && + (!args.target || + structuredAgentSessionOwnerForTab(state, candidate) === + executionHostIdForStructuredTarget(args.target)) ) if (!tab) { return false @@ -34,7 +51,7 @@ export function activateStructuredAgentSessionTab(args: { state.setActiveTabType('agent-session', args.worktreeId) const environmentId = getRuntimeEnvironmentIdForWorktree(state, args.worktreeId) void callRuntimeRpc( - getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }), + args.target ?? getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }), 'session.tabs.activate', { worktree: toRuntimeWorktreeSelector(args.worktreeId), @@ -47,12 +64,18 @@ export function activateStructuredAgentSessionTab(args: { export function activateStructuredAgentSessionById(args: { worktreeId: string sessionId: string + target?: RuntimeClientTarget }): boolean { const tab = findStructuredAgentSessionTab(useAppStore.getState().unifiedTabsByWorktree, { workspaceId: args.worktreeId, - sessionId: args.sessionId + sessionId: args.sessionId, + target: args.target }) return tab - ? activateStructuredAgentSessionTab({ worktreeId: args.worktreeId, tabId: tab.id }) + ? activateStructuredAgentSessionTab({ + worktreeId: args.worktreeId, + tabId: tab.id, + target: args.target + }) : false }