feat: enhance cross-team messaging and activity components

- Introduced logic to prevent relaying cross-team replies back into the originating lead, ensuring cleaner message handling.
- Added utility functions for parsing cross-team recipients and determining targets for cross-team messages.
- Updated ActivityItem and TeamProvisioningService to utilize new parsing methods for improved recipient qualification.
- Enhanced tests to validate the handling of cross-team replies and recipient parsing.
This commit is contained in:
iliya 2026-03-10 11:26:52 +02:00
parent 4e82102ceb
commit 0feb5e650f
6 changed files with 247 additions and 76 deletions

View file

@ -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;

View file

@ -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<ReturnType<typeof useCliInstaller>['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 (
<div
className={`mb-6 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Terminal className="size-4 shrink-0" style={{ color: 'var(--color-text-muted)' }} />
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm" style={{ color: 'var(--color-text)' }}>
Claude CLI v{cliStatus.installedVersion ?? 'unknown'}
</span>
{/* Update / Check for Updates — inline next to version */}
{cliStatus.updateAvailable ? (
<button
onClick={onInstall}
disabled={isBusy}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium text-white transition-colors disabled:opacity-50"
style={{ backgroundColor: '#3b82f6' }}
>
<Download className="size-3" />
Update to v{cliStatus.latestVersion}
</button>
) : (
<button
onClick={onRefresh}
disabled={cliStatusLoading}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs transition-colors hover:bg-white/5 disabled:opacity-50"
style={{ color: 'var(--color-text-muted)' }}
>
<RefreshCw className={cliStatusLoading ? 'size-3 animate-spin' : 'size-3'} />
{cliStatusLoading ? 'Checking...' : 'Check for Updates'}
</button>
)}
{cliStatus.authLoggedIn && (
<span className="text-xs" style={{ color: '#4ade80' }}>
Authenticated
</span>
)}
</div>
{cliStatus.binaryPath && (
<button
className="truncate font-mono text-xs hover:underline"
style={{ color: 'var(--color-text-muted)' }}
title={`Reveal in file manager: ${cliStatus.binaryPath}`}
onClick={() => void api.showInFolder(cliStatus.binaryPath!)}
>
{cliStatus.binaryPath}
</button>
)}
</div>
</div>
{/* Extensions button — only when installed + authenticated */}
{cliStatus.authLoggedIn && (
<button
onClick={openExtensionsTab}
className="flex shrink-0 items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
<Puzzle className="size-3.5" />
Extensions
</button>
)}
</div>
{cliStatusError && !cliStatusLoading && (
<p className="mt-2 text-xs" style={{ color: '#f87171' }}>
Failed to check for updates. Check your network connection and try again.
</p>
)}
</div>
);
};
// =============================================================================
// Main Component
// =============================================================================
@ -448,70 +553,14 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
// Installed — show version, path, update info
return (
<div
className={`mb-6 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Terminal className="size-4 shrink-0" style={{ color: 'var(--color-text-muted)' }} />
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm" style={{ color: 'var(--color-text)' }}>
Claude CLI v{cliStatus.installedVersion ?? 'unknown'}
</span>
{cliStatus.authLoggedIn && (
<span className="text-xs" style={{ color: '#4ade80' }}>
Authenticated
</span>
)}
{cliStatus.updateAvailable && cliStatus.latestVersion && (
<span className="text-xs" style={{ color: '#60a5fa' }}>
&rarr; v{cliStatus.latestVersion}
</span>
)}
</div>
{cliStatus.binaryPath && (
<button
className="truncate font-mono text-xs hover:underline"
style={{ color: 'var(--color-text-muted)' }}
title={`Reveal in file manager: ${cliStatus.binaryPath}`}
onClick={() => void api.showInFolder(cliStatus.binaryPath!)}
>
{cliStatus.binaryPath}
</button>
)}
</div>
</div>
{/* Action button */}
{cliStatus.updateAvailable ? (
<button
onClick={handleInstall}
disabled={isBusy}
className="flex shrink-0 items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium text-white transition-colors disabled:opacity-50"
style={{ backgroundColor: '#3b82f6' }}
>
<Download className="size-3.5" />
Update
</button>
) : (
<button
onClick={handleRefresh}
disabled={cliStatusLoading}
className="flex shrink-0 items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
<RefreshCw className={cliStatusLoading ? 'size-3.5 animate-spin' : 'size-3.5'} />
{cliStatusLoading ? 'Checking...' : 'Check for Updates'}
</button>
)}
</div>
{cliStatusError && !cliStatusLoading && (
<p className="mt-2 text-xs" style={{ color: '#f87171' }}>
Failed to check for updates. Check your network connection and try again.
</p>
)}
</div>
<InstalledBanner
cliStatus={cliStatus}
cliStatusLoading={cliStatusLoading}
cliStatusError={cliStatusError ?? null}
isBusy={isBusy}
onInstall={handleInstall}
onRefresh={handleRefresh}
variant={variant}
/>
);
};

View file

@ -29,9 +29,17 @@ export const PluginCard = ({ plugin, onClick }: PluginCardProps): React.JSX.Elem
const installError = useStore((s) => s.installErrors[plugin.pluginId]);
return (
<button
<div
role="button"
tabIndex={0}
onClick={() => onClick(plugin.pluginId)}
className={`flex w-full flex-col gap-2 rounded-lg border p-4 text-left transition-all duration-200 hover:border-border-emphasis hover:bg-surface-raised hover:shadow-[0_0_12px_rgba(255,255,255,0.02)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] ${
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick(plugin.pluginId);
}
}}
className={`flex w-full cursor-pointer flex-col gap-2 rounded-lg border p-4 text-left transition-all duration-200 hover:border-border-emphasis hover:bg-surface-raised hover:shadow-[0_0_12px_rgba(255,255,255,0.02)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] ${
plugin.isInstalled ? 'border-l-2 border-border border-l-emerald-500/30' : 'border-border'
}`}
>
@ -75,7 +83,8 @@ export const PluginCard = ({ plugin, onClick }: PluginCardProps): React.JSX.Elem
</span>
<InstallCountBadge count={plugin.installCount} />
</div>
<div className="shrink-0">
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
<InstallButton
state={installProgress}
isInstalled={plugin.isInstalled}
@ -86,6 +95,6 @@ export const PluginCard = ({ plugin, onClick }: PluginCardProps): React.JSX.Elem
/>
</div>
</div>
</button>
</div>
);
};

View file

@ -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>
): 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 = ({
&rarr;
</span>
<MemberBadge
name={qualifiedRecipient?.memberName ?? message.to}
color={recipientColor}
hideAvatar={(qualifiedRecipient?.memberName ?? message.to) === 'user'}
name={qualifiedRecipient?.memberName ?? crossTeamSentTarget ?? message.to}
color={crossTeamSentTarget ? 'purple' : recipientColor}
hideAvatar={
crossTeamSentTarget !== null ||
(qualifiedRecipient?.memberName ?? message.to) === 'user'
}
onClick={onMemberNameClick}
/>
</>

View file

@ -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';

View file

@ -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'
);
});
});