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:
parent
9f5d2d005d
commit
5e704ddc78
9 changed files with 328 additions and 36 deletions
|
|
@ -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)}`);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue