diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 740b1c34..e77fa0de 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -21,6 +21,7 @@ import { } from '@shared/constants/agentBlocks'; import { CROSS_TEAM_PREFIX_TAG, + CROSS_TEAM_SOURCE, CROSS_TEAM_SENT_SOURCE, parseCrossTeamPrefix, parseCrossTeamReplyPrefix, @@ -2740,11 +2741,39 @@ export class TeamProvisioningService { if (unread.length === 0) return 0; + const outboundCrossTeamConversations = new Set( + leadInboxMessages.flatMap((message) => { + if (message.source !== CROSS_TEAM_SENT_SOURCE) return []; + const conversationId = message.conversationId?.trim(); + const targetTeam = + typeof message.to === 'string' ? message.to.split('.', 1)[0]?.trim() : ''; + if (!conversationId || !targetTeam) return []; + return [`${conversationId}\0${targetTeam}`]; + }) + ); + + const isCrossTeamReplyToOwnOutbound = (message: InboxMessage): boolean => { + if (message.source !== CROSS_TEAM_SOURCE) return false; + const conversationId = + message.replyToConversationId?.trim() ?? + message.conversationId?.trim() ?? + parseCrossTeamPrefix(message.text)?.conversationId; + if (!conversationId) return false; + const sourceTeam = message.from.includes('.') ? message.from.split('.', 1)[0]?.trim() : ''; + if (!sourceTeam) return false; + return outboundCrossTeamConversations.has(`${conversationId}\0${sourceTeam}`); + }; + // Ignore (and auto-mark read) internal coordination noise like idle/shutdown messages. // Also ignore local sender-copy rows for cross-team traffic: those exist only so the UI // can show outbound activity and must not be re-injected into the live lead as new work. + // Incoming replies to our own outbound cross-team conversations should also remain visible + // in team history without waking the local lead into a reply loop. const ignoredUnread = unread.filter( - (m) => isInboxNoiseMessage(m.text) || m.source === CROSS_TEAM_SENT_SOURCE + (m) => + isInboxNoiseMessage(m.text) || + m.source === CROSS_TEAM_SENT_SOURCE || + isCrossTeamReplyToOwnOutbound(m) ); if (ignoredUnread.length > 0) { try { @@ -2755,7 +2784,10 @@ export class TeamProvisioningService { } const actionableUnread = unread.filter( - (m) => !isInboxNoiseMessage(m.text) && m.source !== CROSS_TEAM_SENT_SOURCE + (m) => + !isInboxNoiseMessage(m.text) && + m.source !== CROSS_TEAM_SENT_SOURCE && + !isCrossTeamReplyToOwnOutbound(m) ); if (actionableUnread.length === 0) return 0; diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 46652a8c..60aae217 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -13,6 +13,7 @@ import { api, isElectronMode } from '@renderer/api'; import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel'; import { TerminalModal } from '@renderer/components/terminal/TerminalModal'; import { useCliInstaller } from '@renderer/hooks/useCliInstaller'; +import { useStore } from '@renderer/store'; import { formatBytes } from '@renderer/utils/formatters'; import { AlertTriangle, @@ -20,6 +21,7 @@ import { Download, Loader2, LogIn, + Puzzle, RefreshCw, Terminal, } from 'lucide-react'; @@ -107,6 +109,109 @@ const ErrorDisplay = ({ ); }; +// ============================================================================= +// Installed banner (extracted sub-component) +// ============================================================================= + +interface InstalledBannerProps { + cliStatus: NonNullable['cliStatus']>; + cliStatusLoading: boolean; + cliStatusError: string | null; + isBusy: boolean; + onInstall: () => void; + onRefresh: () => void; + variant: BannerVariant; +} + +const InstalledBanner = ({ + cliStatus, + cliStatusLoading, + cliStatusError, + isBusy, + onInstall, + onRefresh, + variant, +}: InstalledBannerProps): React.JSX.Element => { + const openExtensionsTab = useStore((s) => s.openExtensionsTab); + const styles = VARIANT_STYLES[variant]; + + return ( +
+
+
+ +
+
+ + Claude CLI v{cliStatus.installedVersion ?? 'unknown'} + + + {/* Update / Check for Updates — inline next to version */} + {cliStatus.updateAvailable ? ( + + ) : ( + + )} + + {cliStatus.authLoggedIn && ( + + Authenticated + + )} +
+ {cliStatus.binaryPath && ( + + )} +
+
+ + {/* Extensions button — only when installed + authenticated */} + {cliStatus.authLoggedIn && ( + + )} +
+ {cliStatusError && !cliStatusLoading && ( +

+ Failed to check for updates. Check your network connection and try again. +

+ )} +
+ ); +}; + // ============================================================================= // Main Component // ============================================================================= @@ -448,70 +553,14 @@ export const CliStatusBanner = (): React.JSX.Element | null => { // Installed — show version, path, update info return ( -
-
-
- -
-
- - Claude CLI v{cliStatus.installedVersion ?? 'unknown'} - - {cliStatus.authLoggedIn && ( - - Authenticated - - )} - {cliStatus.updateAvailable && cliStatus.latestVersion && ( - - → v{cliStatus.latestVersion} - - )} -
- {cliStatus.binaryPath && ( - - )} -
-
- - {/* Action button */} - {cliStatus.updateAvailable ? ( - - ) : ( - - )} -
- {cliStatusError && !cliStatusLoading && ( -

- Failed to check for updates. Check your network connection and try again. -

- )} -
+ ); }; diff --git a/src/renderer/components/extensions/plugins/PluginCard.tsx b/src/renderer/components/extensions/plugins/PluginCard.tsx index 0bcb142e..c3e4e550 100644 --- a/src/renderer/components/extensions/plugins/PluginCard.tsx +++ b/src/renderer/components/extensions/plugins/PluginCard.tsx @@ -29,9 +29,17 @@ export const PluginCard = ({ plugin, onClick }: PluginCardProps): React.JSX.Elem const installError = useStore((s) => s.installErrors[plugin.pluginId]); return ( - + ); }; diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index e2c6a06b..95f4e7ce 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -58,6 +58,14 @@ function parseQualifiedRecipient( }; } +function parseCrossTeamPseudoRecipient(value: string | undefined): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed.startsWith('cross-team:')) return null; + const teamName = trimmed.slice('cross-team:'.length).trim(); + return teamName.length > 0 ? teamName : null; +} + export function isQualifiedExternalRecipient( value: string | undefined, teamName: string, @@ -69,6 +77,20 @@ export function isQualifiedExternalRecipient( return !localMemberNames?.has(value?.trim() ?? ''); } +export function getCrossTeamSentTarget( + value: string | undefined, + teamName: string, + localMemberNames?: Set +): string | null { + const pseudoTarget = parseCrossTeamPseudoRecipient(value); + if (pseudoTarget) return pseudoTarget; + const recipient = parseQualifiedRecipient(value); + if (!recipient) return null; + if (recipient.teamName === teamName) return null; + if (localMemberNames?.has(value?.trim() ?? '')) return null; + return recipient.teamName; +} + interface ActivityItemProps { message: InboxMessage; teamName: string; @@ -293,11 +315,15 @@ export const ActivityItem = ({ [message.text] ); const qualifiedRecipient = useMemo(() => parseQualifiedRecipient(message.to), [message.to]); + const crossTeamSentTarget = useMemo( + () => getCrossTeamSentTarget(message.to, teamName, localMemberNames), + [message.to, teamName, localMemberNames] + ); const isCrossTeam = message.source === CROSS_TEAM_SOURCE || parsedCrossTeamPrefix !== null; const isCrossTeamSent = message.source === CROSS_TEAM_SENT_SOURCE || parsedCrossTeamReplyPrefix !== null || - isQualifiedExternalRecipient(message.to, teamName, localMemberNames); + crossTeamSentTarget !== null; const isCrossTeamAny = isCrossTeam || isCrossTeamSent; const crossTeamOrigin = useMemo(() => { if (!isCrossTeam) return null; @@ -311,12 +337,13 @@ export const ActivityItem = ({ }, [isCrossTeam, message.from, parsedCrossTeamPrefix]); const crossTeamTarget = useMemo(() => { if (!isCrossTeamSent) return null; + if (crossTeamSentTarget) return crossTeamSentTarget; if (qualifiedRecipient) return qualifiedRecipient.teamName; if (!message.to) return null; const dot = message.to.indexOf('.'); if (dot <= 0) return message.to; return message.to.substring(0, dot); - }, [isCrossTeamSent, message.to, qualifiedRecipient]); + }, [crossTeamSentTarget, isCrossTeamSent, message.to, qualifiedRecipient]); // Strip agent-only blocks + normalize escape sequences (before linkification) const strippedText = useMemo(() => { @@ -533,9 +560,12 @@ export const ActivityItem = ({ → diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index b06ee16f..ba7f5cf9 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -368,6 +368,47 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { expect(updatedInbox[0]?.messageId).toBe('m-cross-team-sent-1'); }); + it('does not relay returned cross-team replies back into the originating lead', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + seedConfig(teamName); + seedLeadInbox(teamName, [ + { + from: 'user', + to: 'other-team.team-lead', + text: 'Original outbound request', + timestamp: '2026-02-23T10:00:00.000Z', + read: true, + source: 'cross_team_sent', + messageId: 'm-cross-team-sent-1', + conversationId: 'conv-1', + }, + { + from: 'other-team.team-lead', + to: 'team-lead', + text: '[Cross-team from other-team.team-lead | conversation:conv-1 | replyToConversation:conv-1] Reply back to origin.', + timestamp: '2026-02-23T10:01:00.000Z', + read: false, + source: 'cross_team', + messageId: 'm-cross-team-reply-1', + conversationId: 'conv-1', + replyToConversationId: 'conv-1', + }, + ]); + + const { writeSpy } = attachAliveRun(service, teamName); + const relayed = await service.relayLeadInboxMessages(teamName); + + expect(relayed).toBe(0); + expect(writeSpy).toHaveBeenCalledTimes(0); + + const updatedInbox = JSON.parse( + hoisted.files.get(`/mock/teams/${teamName}/inboxes/team-lead.json`) ?? '[]' + ) as Array<{ messageId?: string; read?: boolean }>; + expect(updatedInbox).toHaveLength(2); + expect(updatedInbox[1]?.messageId).toBe('m-cross-team-reply-1'); + }); + it('relays unread teammate inbox messages through the live team process', async () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; diff --git a/test/renderer/components/team/activity/ActivityItem.test.ts b/test/renderer/components/team/activity/ActivityItem.test.ts index 7425d4d1..6f20ad17 100644 --- a/test/renderer/components/team/activity/ActivityItem.test.ts +++ b/test/renderer/components/team/activity/ActivityItem.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + getCrossTeamSentTarget, getSystemMessageLabel, isQualifiedExternalRecipient, } from '@renderer/components/team/activity/ActivityItem'; @@ -28,4 +29,13 @@ describe('ActivityItem legacy system message fallback', () => { true ); }); + + it('recognizes pseudo cross-team recipients in activity rows', () => { + expect(getCrossTeamSentTarget('cross-team:team-best', 'my-team', new Set(['ops.bot']))).toBe( + 'team-best' + ); + expect(getCrossTeamSentTarget('team-best.user', 'my-team', new Set(['ops.bot']))).toBe( + 'team-best' + ); + }); });