From 55d348d671b533be525dab0f40f7460db4c3fcbe Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:25:08 -0700 Subject: [PATCH] feat(native-chat): show an agent message's sender by its current title, clickable on the queued card too (#26357) * feat(native-chat): name an agent's message by its current title, and open it from the queued card The sender line on another agent's message and on its queued card now shows the name Orca shows for that agent right now (its sidebar agent row or chat tab name), falling back to the name recorded when it was sent. The queued card's names open the sender, as the transcript row's already do. * fix(orchestration): name a CLI agent sender by its tab's own name, not just its agent A terminal agent's recorded sender name used only a manual tab rename, else the agent type, so mail from CLI agents read as "Codex" or "Claude". The host now names every sender the way Orca shows it: a chat by its rename or saved name, a terminal agent by its sidebar agent row's name (rename, quick command, generated or meaningful live title; a split pane only by its own title). The task title and generic agent label are fallbacks. The renderer shows the live own name, else the recorded one. * fix(orchestration): preserve known sender names in message snapshots * Preserve sender names across chat clears and terminal reuse * fix: keep sender links on the recipient host across older runtimes * test: type sender naming fixtures with their declared identities * Fix sender ownership publication after clearing a chat * Drop /clear sender lineage now that /clear keeps the same conversation Main's #26579 keeps a native chat in the same session across /clear, so a message's sender id still names the live chat. Remove the host's orchestration-root projection, the optional status field, and the renderer's lineage lookup and feed capability probe that only served it. --- .../orca-runtime-stop-requested-pty-ids.ts | 8 +- .../orchestration-sender-name.ts | 63 +++- ...runtime-orchestration-sender-names.test.ts | 176 ++++++++- .../runtime-orchestration-sender-names.ts | 89 ++++- .../NativeChatAgentMessageRow.test.tsx | 42 ++- .../NativeChatAgentMessageSenders.test.tsx | 184 ++++++++++ .../NativeChatAgentMessageSenders.tsx | 90 +++-- .../NativeChatQueuedMessageCard.tsx | 13 +- .../NativeChatQueuedMessageList.test.tsx | 36 +- .../NativeChatQueuedMessageList.tsx | 4 + .../NativeChatStructuredSession.tsx | 1 + ...ve-chat-agent-message-sender-host.test.tsx | 333 ++++++++++++++++++ ...hat-agent-message-sender-identity.test.tsx | 191 ++++++++++ .../native-chat-agent-message-sender-label.ts | 76 +++- src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/fr.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- ...e-ai-vault-structured-session-host.test.ts | 96 +++++ ...ai-vault-structured-session-reveal.test.ts | 16 + ...tivate-ai-vault-structured-session.test.ts | 42 ++- .../activate-ai-vault-structured-session.ts | 78 ++-- .../src/lib/open-agent-message-sender.test.ts | 97 ++++- .../src/lib/open-agent-message-sender.ts | 60 +++- ...structured-agent-session-tab-activation.ts | 37 +- 27 files changed, 1571 insertions(+), 173 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-agent-message-sender-host.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-agent-message-sender-identity.test.tsx create mode 100644 src/renderer/src/lib/activate-ai-vault-structured-session-host.test.ts 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 }