feat: add cross-team messaging UI with team selector and visual distinction

- Team selector dropdown in MessageComposer (left of recipient selector)
- Cross-team mode locks recipient to team-lead, disables attachments
- Sender copy written to fromTeam inbox with source 'cross_team_sent'
- Purple accent styling for cross-team messages (incoming + outgoing)
- "from team-x" / "to team-x" badges in ActivityItem
- Strip [Cross-team from ...] prefix in UI (kept in stored text for CLI)
- CSS variables for cross-team theme (dark + light)
- Store: fetchCrossTeamTargets (all teams), sendCrossTeamMessage action
- Tests updated for sender copy and formatted text prefix
This commit is contained in:
iliya 2026-03-09 20:53:45 +02:00
parent 9f5d2d005d
commit 5e704ddc78
9 changed files with 328 additions and 36 deletions

View file

@ -76,7 +76,7 @@ export class CrossTeamService {
const formattedText = `[Cross-team from ${from} | depth:${chainDepth}]\n${text}`;
const messageId = randomUUID();
// 5. Inbox write (TeamInboxWriter handles file lock + in-process lock internally)
// 5. Inbox write to TARGET team (TeamInboxWriter handles file lock + in-process lock internally)
await this.inboxWriter.sendMessage(toTeam, {
member: leadName,
text: formattedText,
@ -85,7 +85,24 @@ export class CrossTeamService {
source: 'cross_team',
});
// 6. Best-effort relay (if online)
// 6. Write "sent" copy to SENDER's inbox so the message appears in their activity
const senderLeadName = (await this.dataService.getLeadMemberName(fromTeam)) ?? 'team-lead';
void this.inboxWriter
.sendMessage(fromTeam, {
member: senderLeadName,
text,
from: 'user',
to: `${toTeam}.${leadName}`,
summary: summary ?? `Cross-team message to ${toTeam}`,
source: 'cross_team_sent',
})
.catch((e: unknown) => {
logger.warn(
`Failed to write sender copy for ${fromTeam}: ${e instanceof Error ? e.message : String(e)}`
);
});
// 7. Best-effort relay (if online)
if (this.provisioning?.isTeamAlive(toTeam)) {
void this.provisioning.relayLeadInboxMessages(toTeam).catch((e: unknown) => {
logger.warn(`Cross-team relay to ${toTeam}: ${e instanceof Error ? e.message : String(e)}`);

View file

@ -23,7 +23,7 @@ export class TeamInboxWriter {
const payload: InboxMessage = {
from: request.from ?? 'user',
to: request.member,
to: request.to ?? request.member,
text: request.text,
timestamp: new Date().toISOString(),
read: false,

View file

@ -215,6 +215,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
updateTaskStatus,
updateTaskOwner,
sendTeamMessage,
sendCrossTeamMessage,
requestReview,
createTeamTask,
startTask,
@ -255,6 +256,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
updateTaskStatus: s.updateTaskStatus,
updateTaskOwner: s.updateTaskOwner,
sendTeamMessage: s.sendTeamMessage,
sendCrossTeamMessage: s.sendCrossTeamMessage,
requestReview: s.requestReview,
createTeamTask: s.createTeamTask,
startTask: s.startTask,
@ -1567,6 +1569,15 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
});
});
}}
onCrossTeamSend={(toTeam, text, summary) => {
void sendCrossTeamMessage({
fromTeam: teamName,
fromMember: 'user',
toTeam,
text,
summary,
});
}}
/>
<div className="mb-[35px]">
<button

View file

@ -275,14 +275,37 @@ export const ActivityItem = ({
const isManaged = isManagedCollapseState(collapseState);
const isExpanded = isManaged ? !collapseState.isCollapsed : true;
const isCrossTeam = message.source === 'cross_team';
const isCrossTeamSent = message.source === 'cross_team_sent';
const isCrossTeamAny = isCrossTeam || isCrossTeamSent;
const crossTeamOrigin = useMemo(() => {
if (!isCrossTeam) return null;
const dot = message.from.indexOf('.');
if (dot <= 0 || dot === message.from.length - 1) return null;
return {
teamName: message.from.substring(0, dot),
memberName: message.from.substring(dot + 1),
};
}, [isCrossTeam, message.from]);
const crossTeamTarget = useMemo(() => {
if (!isCrossTeamSent || !message.to) return null;
const dot = message.to.indexOf('.');
if (dot <= 0) return message.to;
return message.to.substring(0, dot);
}, [isCrossTeamSent, message.to]);
// Strip agent-only blocks + normalize escape sequences (before linkification)
const strippedText = useMemo(() => {
if (structured) return null;
const stripped = stripAgentBlocks(message.text).trim();
let stripped = stripAgentBlocks(message.text).trim();
if (!stripped) return null; // All content was agent-only blocks → show summary instead
// Strip legacy cross-team prefix (e.g. "[Cross-team from team.lead | depth:0]\n")
if (isCrossTeamAny) {
stripped = stripped.replace(/^\[Cross-team from [^\]]+\]\n?/, '');
}
// Normalize literal \n from historical CLI-produced text to real newlines
return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
}, [structured, message.text]);
}, [structured, message.text, isCrossTeamAny]);
// Parse reply BEFORE linkification — linkifyMentionsInMarkdown transforms @name
// into markdown links which breaks the reply regex matcher
@ -325,7 +348,7 @@ export const ActivityItem = ({
const isHeaderClickable = isManaged ? collapseState.canToggle : false;
const showChevron = isHeaderClickable;
const isUserSent = message.source === 'user_sent';
const isUserSent = message.source === 'user_sent' || isCrossTeamSent;
const isSystemMessage = message.from === 'system';
const onManagedToggle = isManaged ? collapseState.onToggle : undefined;
const handleHeaderToggle = isHeaderClickable
@ -342,23 +365,29 @@ export const ActivityItem = ({
backgroundColor:
rateLimited || isApiError
? 'var(--tool-result-error-bg)'
: isSystemMessage
? 'var(--system-activity-bg)'
: zebraShade
? CARD_BG_ZEBRA
: CARD_BG,
: isCrossTeamAny
? 'var(--cross-team-bg)'
: isSystemMessage
? 'var(--system-activity-bg)'
: zebraShade
? CARD_BG_ZEBRA
: CARD_BG,
border:
rateLimited || isApiError
? '1px solid var(--tool-result-error-border)'
: isSystemMessage
? '1px solid var(--system-activity-border)'
: CARD_BORDER_STYLE,
: isCrossTeamAny
? '1px solid var(--cross-team-border)'
: isSystemMessage
? '1px solid var(--system-activity-border)'
: CARD_BORDER_STYLE,
borderLeft:
rateLimited || isApiError
? '3px solid var(--tool-result-error-text)'
: isSystemMessage
? '3px solid var(--system-activity-accent)'
: `3px solid ${getThemedBorder(colors, isLight)}`,
: isCrossTeamAny
? '3px solid var(--cross-team-accent)'
: isSystemMessage
? '3px solid var(--system-activity-accent)'
: `3px solid ${getThemedBorder(colors, isLight)}`,
}}
>
{/* Header — div with role=button (cannot use <button> due to nested buttons inside) */}
@ -398,8 +427,8 @@ export const ActivityItem = ({
{/* Sender avatar + name badge */}
<MemberBadge
name={message.from}
color={memberColor ?? message.color}
name={crossTeamOrigin ? crossTeamOrigin.memberName : message.from}
color={isCrossTeamAny ? 'purple' : (memberColor ?? message.color)}
hideAvatar={message.from === 'user' || message.from === 'system'}
onClick={onMemberNameClick}
/>
@ -433,6 +462,26 @@ export const ActivityItem = ({
</span>
) : null}
{/* Cross-team origin badge */}
{isCrossTeam && crossTeamOrigin ? (
<span
className="inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={{ backgroundColor: 'rgba(168, 85, 247, 0.15)', color: '#c084fc' }}
>
from {crossTeamOrigin.teamName}
</span>
) : null}
{/* Cross-team sent badge */}
{isCrossTeamSent && crossTeamTarget ? (
<span
className="inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={{ backgroundColor: 'rgba(168, 85, 247, 0.15)', color: '#c084fc' }}
>
to {crossTeamTarget}
</span>
) : null}
{/* Rate limit warning badge */}
{rateLimited ? (
<span className="inline-flex items-center gap-1 rounded-full bg-red-500/20 px-1.5 py-0.5 text-[10px] font-medium text-red-400">

View file

@ -13,7 +13,16 @@ import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { MAX_TEXT_LENGTH } from '@shared/constants';
import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react';
import {
AlertCircle,
ArrowRightLeft,
Check,
ChevronDown,
ImagePlus,
Mic,
Search,
Send,
} from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { AttachmentPayload, LeadContextUsage, ResolvedTeamMember } from '@shared/types';
@ -30,6 +39,7 @@ interface MessageComposerProps {
summary?: string,
attachments?: AttachmentPayload[]
) => void;
onCrossTeamSend?: (toTeam: string, text: string, summary?: string) => void;
}
/** Circular progress indicator for lead context usage. */
@ -91,6 +101,7 @@ export const MessageComposer = ({
sending,
sendError,
onSend,
onCrossTeamSend,
}: MessageComposerProps): React.JSX.Element => {
const [recipient, setRecipient] = useState<string>(() => {
const lead = members.find((m) => m.role === 'lead' || m.name === 'team-lead');
@ -105,6 +116,26 @@ export const MessageComposer = ({
const [imageRestrictionError, setImageRestrictionError] = useState<string | null>(null);
const imageRestrictionTimerRef = useRef(0);
// Cross-team state
const [selectedTeam, setSelectedTeam] = useState<string | null>(null);
const [teamSelectorOpen, setTeamSelectorOpen] = useState(false);
const allCrossTeamTargets = useStore((s) => s.crossTeamTargets);
const fetchCrossTeamTargets = useStore((s) => s.fetchCrossTeamTargets);
useEffect(() => {
void fetchCrossTeamTargets();
}, [fetchCrossTeamTargets]);
// Always filter out current team on the UI side (store is global, shared across tabs)
const crossTeamTargets = useMemo(
() => allCrossTeamTargets.filter((t) => t.teamName !== teamName),
[allCrossTeamTargets, teamName]
);
const isCrossTeam = selectedTeam !== null;
const targetDisplayName =
crossTeamTargets.find((t) => t.teamName === selectedTeam)?.displayName ?? selectedTeam;
// Members load async with team data; keep recipient stable if valid, otherwise default to lead/first.
useEffect(() => {
if (recipient && members.some((m) => m.name === recipient)) {
@ -150,7 +181,7 @@ export const MessageComposer = ({
// const leadContext = useStore((s) =>
// isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
// );
const supportsAttachments = isLeadRecipient;
const supportsAttachments = isLeadRecipient && !isCrossTeam;
const canAttach = supportsAttachments && draft.canAddMore;
const attachmentsBlocked = draft.attachments.length > 0 && !supportsAttachments;
const canSend =
@ -158,7 +189,8 @@ export const MessageComposer = ({
trimmed.length > 0 &&
trimmed.length <= MAX_TEXT_LENGTH &&
!sending &&
!attachmentsBlocked;
!attachmentsBlocked &&
(!isCrossTeam || onCrossTeamSend !== undefined);
// Track whether we initiated a send — clear draft only on confirmed success
const pendingSendRef = useRef(false);
@ -167,14 +199,28 @@ export const MessageComposer = ({
if (!canSend) return;
pendingSendRef.current = true;
const serialized = serializeChipsWithText(trimmed, draft.chips);
// Summary should stay compact (no expanded chip markdown)
onSend(
recipient,
serialized,
trimmed,
draft.attachments.length > 0 ? draft.attachments : undefined
);
}, [canSend, recipient, trimmed, onSend, draft.attachments, draft.chips]);
if (isCrossTeam && selectedTeam && onCrossTeamSend) {
onCrossTeamSend(selectedTeam, serialized, trimmed);
} else {
// Summary should stay compact (no expanded chip markdown)
onSend(
recipient,
serialized,
trimmed,
draft.attachments.length > 0 ? draft.attachments : undefined
);
}
}, [
canSend,
recipient,
trimmed,
onSend,
onCrossTeamSend,
isCrossTeam,
selectedTeam,
draft.attachments,
draft.chips,
]);
// Clear draft only after send completes successfully (sending: true → false, no error)
useEffect(() => {
@ -352,11 +398,109 @@ export const MessageComposer = ({
</span>
) : null}
<Popover open={recipientOpen} onOpenChange={setRecipientOpen}>
{/* Cross-team selector */}
{crossTeamTargets.length > 0 ? (
<Popover open={teamSelectorOpen} onOpenChange={setTeamSelectorOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors',
isCrossTeam
? 'border-[var(--cross-team-border)] bg-[var(--cross-team-bg)] text-purple-400 hover:border-purple-400/30'
: 'border-[var(--color-border)] hover:border-[var(--color-border-emphasis)] hover:bg-[var(--color-surface-raised)]'
)}
>
{isCrossTeam ? (
<>
<ArrowRightLeft size={11} className="shrink-0" />
<span className="max-w-[100px] truncate">{targetDisplayName}</span>
</>
) : (
<span className="text-[var(--color-text-secondary)]">This team</span>
)}
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-56 p-1.5">
<div className="max-h-48 space-y-0.5 overflow-y-auto">
{/* Current team option */}
<button
type="button"
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]',
!isCrossTeam && 'bg-[var(--color-surface-raised)]'
)}
onClick={() => {
setSelectedTeam(null);
setTeamSelectorOpen(false);
}}
>
<span className="truncate text-[var(--color-text)]">This team</span>
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
current
</span>
{!isCrossTeam ? (
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
) : null}
</button>
{/* Separator */}
<div className="my-1 h-px bg-[var(--color-border)]" />
{/* Other teams */}
{crossTeamTargets.map((target) => {
const isSelected = selectedTeam === target.teamName;
return (
<button
key={target.teamName}
type="button"
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]',
isSelected && 'bg-[var(--cross-team-bg)]'
)}
onClick={() => {
setSelectedTeam(target.teamName);
setRecipient('team-lead');
setTeamSelectorOpen(false);
}}
>
<ArrowRightLeft size={11} className="shrink-0 text-purple-400" />
<div className="min-w-0 flex-1">
<div className="truncate text-[var(--color-text)]">
{target.displayName}
</div>
{target.description ? (
<div className="truncate text-[10px] text-[var(--color-text-muted)]">
{target.description}
</div>
) : null}
</div>
{isSelected ? (
<Check size={12} className="ml-auto shrink-0 text-purple-400" />
) : null}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
) : null}
<Popover
open={isCrossTeam ? false : recipientOpen}
onOpenChange={isCrossTeam ? undefined : setRecipientOpen}
>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] px-2.5 py-1 text-xs transition-colors hover:border-[var(--color-border-emphasis)] hover:bg-[var(--color-surface-raised)]"
className={cn(
'inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] px-2.5 py-1 text-xs transition-colors',
isCrossTeam
? 'cursor-default opacity-60'
: 'hover:border-[var(--color-border-emphasis)] hover:bg-[var(--color-surface-raised)]'
)}
disabled={isCrossTeam}
>
{recipient ? (
<MemberBadge
@ -462,7 +606,9 @@ export const MessageComposer = ({
placeholder={
isProvisioning
? 'Team is launching... message will be queued for inbox delivery.'
: 'Write a message... (Enter to send, Shift+Enter for new line)'
: isCrossTeam
? `Cross-team message to ${targetDisplayName ?? 'team'}...`
: 'Write a message... (Enter to send, Shift+Enter for new line)'
}
value={draft.text}
onValueChange={draft.setText}

View file

@ -208,6 +208,11 @@
--system-activity-border: rgba(59, 130, 246, 0.12);
--system-activity-accent: rgba(96, 165, 250, 0.5);
/* Cross-team communication */
--cross-team-bg: rgba(168, 85, 247, 0.06);
--cross-team-border: rgba(168, 85, 247, 0.12);
--cross-team-accent: rgba(192, 132, 252, 0.5);
/* Info style — banners, status indicators */
--info-bg: rgba(59, 130, 246, 0.08);
--info-border: rgba(59, 130, 246, 0.25);
@ -471,6 +476,11 @@
--system-activity-border: rgba(59, 130, 246, 0.25);
--system-activity-accent: rgba(37, 99, 235, 0.7);
/* Cross-team communication */
--cross-team-bg: rgba(168, 85, 247, 0.08);
--cross-team-border: rgba(168, 85, 247, 0.25);
--cross-team-accent: rgba(124, 58, 237, 0.7);
/* Info style — banners, status indicators */
--info-bg: rgba(59, 130, 246, 0.1);
--info-border: rgba(37, 99, 235, 0.3);

View file

@ -69,6 +69,7 @@ import type {
AddMemberRequest,
CommentAttachmentPayload,
CreateTaskRequest,
CrossTeamSendRequest,
GlobalTask,
KanbanColumnId,
LeadActivityState,
@ -295,6 +296,10 @@ export interface TeamSlice {
selectTeam: (teamName: string, opts?: { skipProjectAutoSelect?: boolean }) => Promise<void>;
refreshTeamData: (teamName: string) => Promise<void>;
sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise<void>;
crossTeamTargets: { teamName: string; displayName: string; description?: string }[];
crossTeamTargetsLoading: boolean;
fetchCrossTeamTargets: () => Promise<void>;
sendCrossTeamMessage: (request: CrossTeamSendRequest) => Promise<void>;
requestReview: (teamName: string, taskId: string) => Promise<void>;
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
updateKanbanColumnOrder: (
@ -438,6 +443,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
sendingMessage: false,
sendMessageError: null,
lastSendMessageResult: null,
crossTeamTargets: [],
crossTeamTargetsLoading: false,
reviewActionError: null,
provisioningRuns: {},
provisioningStartedAtFloorByTeam: {},
@ -873,6 +880,39 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
}
},
fetchCrossTeamTargets: async () => {
set({ crossTeamTargetsLoading: true });
try {
const targets = await api.crossTeam.listTargets();
set({ crossTeamTargets: targets, crossTeamTargetsLoading: false });
} catch (error) {
logger.error('fetchCrossTeamTargets failed', error);
set({ crossTeamTargets: [], crossTeamTargetsLoading: false });
}
},
sendCrossTeamMessage: async (request: CrossTeamSendRequest) => {
set({ sendingMessage: true, sendMessageError: null, lastSendMessageResult: null });
try {
const result = await api.crossTeam.send(request);
set({
sendingMessage: false,
sendMessageError: null,
lastSendMessageResult: {
messageId: result.messageId,
deliveredToInbox: result.deliveredToInbox,
},
});
await get().refreshTeamData(request.fromTeam);
} catch (error) {
set({
sendingMessage: false,
lastSendMessageResult: null,
sendMessageError: mapSendMessageError(error),
});
}
},
requestReview: async (teamName: string, taskId: string) => {
try {
set({ reviewActionError: null });

View file

@ -251,7 +251,8 @@ export interface InboxMessage {
| 'lead_process'
| 'user_sent'
| 'system_notification'
| 'cross_team';
| 'cross_team'
| 'cross_team_sent';
attachments?: AttachmentMeta[];
/** Lead session ID that produced this message (for session boundary detection). */
leadSessionId?: string;
@ -266,6 +267,8 @@ export interface SendMessageRequest {
text: string;
summary?: string;
from?: string;
/** Override the `to` field in the stored message (defaults to `member`). */
to?: string;
attachments?: AttachmentPayload[];
source?: InboxMessage['source'];
/** Lead session ID for session boundary detection. */

View file

@ -82,14 +82,30 @@ describe('CrossTeamService', () => {
expect(result.deliveredToInbox).toBe(true);
expect(result.messageId).toBeDefined();
expect(inboxWriter.sendMessage).toHaveBeenCalledOnce();
// First call: target team inbox, second call: sender copy (best-effort)
const [teamName, req] = inboxWriter.sendMessage.mock.calls[0];
expect(teamName).toBe('team-b');
expect(req.member).toBe('team-lead');
expect(req.source).toBe('cross_team');
expect(req.from).toBe('team-a.lead');
expect(req.text).toContain('[Cross-team from team-a.lead | depth:0]');
expect(req.text).toBe('[Cross-team from team-a.lead | depth:0]\nHello from team-a');
});
it('writes sender copy to fromTeam inbox as user_sent', async () => {
await service.send(makeRequest());
// Wait for the best-effort sender copy (void promise)
await vi.waitFor(() => {
expect(inboxWriter.sendMessage).toHaveBeenCalledTimes(2);
});
const [senderTeam, senderReq] = inboxWriter.sendMessage.mock.calls[1];
expect(senderTeam).toBe('team-a');
expect(senderReq.from).toBe('user');
expect(senderReq.source).toBe('cross_team_sent');
expect(senderReq.to).toBe('team-b.team-lead');
expect(senderReq.text).toBe('Hello from team-a');
});
it('calls relayLeadInboxMessages when team is alive', async () => {