feat: enhance team task handling and cross-team messaging

- Updated TeamDataService to attach kanban compatibility with reviewer information for tasks.
- Introduced new utility functions in TeamMemberResolver and TeamProvisioningService to handle cross-team pseudo recipients and improve member resolution.
- Enhanced ActivityTimeline and Member components to display current and review tasks more effectively.
- Added tests to validate the handling of cross-team inbox names and task assignments.
- Improved MessageComposer to support action mode selection for lead recipients.
This commit is contained in:
iliya 2026-03-10 12:22:10 +02:00
parent c40c61f099
commit 2eb814bb70
17 changed files with 689 additions and 251 deletions

View file

@ -111,12 +111,16 @@ export class TeamDataService {
return normalizeReviewState(task.reviewState);
}
private attachKanbanCompatibility(task: TeamTask): TeamTaskWithKanban {
private attachKanbanCompatibility(
task: TeamTask,
kanbanTaskState?: KanbanState['tasks'][string]
): TeamTaskWithKanban {
const reviewState = this.resolveTaskReviewState(task);
return {
...task,
reviewState,
kanbanColumn: getKanbanColumnFromReviewState(reviewState),
reviewer: kanbanTaskState?.reviewer ?? null,
};
}
@ -403,7 +407,7 @@ export class TeamDataService {
mark('kanbanGc');
const tasksWithKanban: TeamTaskWithKanban[] = tasks.map((task) =>
this.attachKanbanCompatibility(task)
this.attachKanbanCompatibility(task, kanbanState.tasks[task.id])
);
const members = this.memberResolver.resolveMembers(
@ -422,7 +426,7 @@ export class TeamDataService {
mark('syncComments');
const tasksToReturn: TeamTaskWithKanban[] = tasks.map((task) =>
this.attachKanbanCompatibility(task)
this.attachKanbanCompatibility(task, kanbanState.tasks[task.id])
);
let processes: TeamProcess[] = [];

View file

@ -19,6 +19,19 @@ function looksLikeQualifiedExternalRecipient(name: string): boolean {
return TEAM_NAME_PATTERN.test(teamName) && memberName.length > 0;
}
function looksLikeCrossTeamPseudoRecipient(name: string): boolean {
const trimmed = name.trim();
if (trimmed.startsWith('cross-team:')) {
const teamName = trimmed.slice('cross-team:'.length).trim();
return TEAM_NAME_PATTERN.test(teamName);
}
if (trimmed.startsWith('cross-team-')) {
const teamName = trimmed.slice('cross-team-'.length).trim();
return TEAM_NAME_PATTERN.test(teamName);
}
return false;
}
export class TeamMemberResolver {
resolveMembers(
config: TeamConfig,
@ -62,6 +75,9 @@ export class TeamMemberResolver {
for (const inboxName of inboxNames) {
if (typeof inboxName === 'string' && inboxName.trim() !== '') {
const trimmed = inboxName.trim();
if (looksLikeCrossTeamPseudoRecipient(trimmed)) {
continue;
}
if (
!explicitNames.has(trimmed.toLowerCase()) &&
looksLikeQualifiedExternalRecipient(trimmed)

View file

@ -902,11 +902,13 @@ ${persistentContext}
Steps (execute in this exact order):
1) Read team config at ~/.claude/teams/${request.teamName}/config.json understand current team state.
1) Restore/start the existing teammates first. Do NOT delay this reconnect turn by reading internal config files before teammates are back online.
${step2And3Block}
4) After all steps, output a short summary of reconnected members and what happens next.
4) If something about team state looks unclear or inconsistent, you MAY inspect ~/.claude/teams/${request.teamName}/config.json after teammates are restored (or immediately in solo mode). Treat it as a diagnostic cross-check, not as the first reconnect action.
5) After all steps, output a short summary of reconnected members and what happens next.
`;
}
@ -1229,6 +1231,13 @@ export class TeamProvisioningService {
): { teamName: string; memberName: string } | null {
const trimmed = recipient.trim();
if (localRecipientNames.has(trimmed)) return null;
if (trimmed.startsWith('cross-team:')) {
const teamName = trimmed.slice('cross-team:'.length).trim();
if (!TEAM_NAME_PATTERN.test(teamName) || teamName === currentTeam) {
return null;
}
return { teamName, memberName: 'team-lead' };
}
const dot = trimmed.indexOf('.');
if (dot <= 0 || dot === trimmed.length - 1) return null;
const teamName = trimmed.slice(0, dot).trim();
@ -1239,6 +1248,19 @@ export class TeamProvisioningService {
return { teamName, memberName };
}
private isCrossTeamPseudoRecipientName(name: string): boolean {
const trimmed = name.trim();
if (trimmed.startsWith('cross-team:')) {
const teamName = trimmed.slice('cross-team:'.length).trim();
return TEAM_NAME_PATTERN.test(teamName);
}
if (trimmed.startsWith('cross-team-')) {
const teamName = trimmed.slice('cross-team-'.length).trim();
return TEAM_NAME_PATTERN.test(teamName);
}
return false;
}
private persistSentMessage(teamName: string, message: InboxMessage): void {
try {
createController({
@ -2561,6 +2583,9 @@ export class TeamProvisioningService {
}
async relayMemberInboxMessages(teamName: string, memberName: string): Promise<number> {
if (this.isCrossTeamPseudoRecipientName(memberName)) {
return 0;
}
const relayKey = this.getMemberRelayKey(teamName, memberName);
const existing = this.memberInboxRelayInFlight.get(relayKey);
if (existing) {
@ -3155,7 +3180,9 @@ export class TeamProvisioningService {
}
const msg: InboxMessage = {
from: 'user',
to: `${crossTeamRecipient.teamName}.${crossTeamRecipient.memberName}`,
to: recipient.startsWith('cross-team:')
? recipient
: `${crossTeamRecipient.teamName}.${crossTeamRecipient.memberName}`,
text: strippedCrossTeamContent,
timestamp,
read: true,

View file

@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { Layers } from 'lucide-react';
import { ActivityItem, isNoiseMessage } from './ActivityItem';
import { AnimatedHeightReveal } from './AnimatedHeightReveal';
@ -9,6 +10,7 @@ import { findNewestMessageIndex, resolveTimelineCollapseState } from './collapse
import {
getThoughtGroupKey,
groupTimelineItems,
isCompactionMessage,
isLeadThought,
LeadThoughtsGroupRow,
} from './LeadThoughtsGroup';
@ -55,6 +57,29 @@ interface ActivityTimelineProps {
const VIEWPORT_THRESHOLD = 0.15;
const MESSAGES_PAGE_SIZE = 30;
/** Inline compaction boundary divider — styled like session separators but with amber accent. */
const CompactionDivider = ({ message }: { message: InboxMessage }): React.JSX.Element => (
<div className="flex items-center gap-3" style={{ paddingTop: 16, paddingBottom: 16 }}>
<div
className="h-px flex-1"
style={{ backgroundColor: 'var(--tool-call-text)', opacity: 0.3 }}
/>
<div className="flex shrink-0 items-center gap-2 px-3">
<Layers size={12} style={{ color: 'var(--tool-call-text)' }} />
<span
className="whitespace-nowrap text-[11px] font-medium"
style={{ color: 'var(--tool-call-text)' }}
>
{message.text}
</span>
</div>
<div
className="h-px flex-1"
style={{ backgroundColor: 'var(--tool-call-text)', opacity: 0.3 }}
/>
</div>
);
const MessageRowWithObserver = ({
message,
teamName,
@ -239,6 +264,7 @@ export const ActivityTimeline = ({
cardCount++;
} else {
if (isNoiseMessage(item.message.text)) continue;
if (isCompactionMessage(item.message)) continue;
if (cardCount % 2 === 1) result.add(i);
cardCount++;
}
@ -420,6 +446,18 @@ export const ActivityTimeline = ({
}
const { message } = item;
// Compaction boundary — render as a divider instead of a regular message card
if (isCompactionMessage(message)) {
const messageKey = toMessageKey(message);
return (
<React.Fragment key={messageKey}>
{sessionSeparator}
<CompactionDivider message={message} />
</React.Fragment>
);
}
const info = memberInfo.get(message.from);
const recipientInfo = message.to ? memberInfo.get(message.to) : undefined;
const recipientColor =

View file

@ -36,12 +36,21 @@ export interface LeadThoughtGroup {
thoughts: InboxMessage[];
}
/**
* Check if a message is a context compaction boundary (system event from lead process).
*/
export function isCompactionMessage(msg: InboxMessage): boolean {
return msg.from === 'system' && !!msg.messageId?.startsWith('compact-');
}
/**
* Check if a message is an intermediate lead "thought" (assistant text) rather than
* an official message (SendMessage, direct reply, inbox, etc.).
*/
export function isLeadThought(msg: InboxMessage): boolean {
if (typeof msg.to === 'string' && msg.to.trim().length > 0) return false;
// Compaction boundary events are system messages, not lead thoughts
if (isCompactionMessage(msg)) return false;
if (msg.source === 'lead_session') return true;
if (msg.source === 'lead_process') return true;
return false;

View file

@ -8,6 +8,7 @@ interface CurrentTaskIndicatorProps {
borderColor: string;
/** Max characters for the subject before truncating */
maxSubjectLength?: number;
activityLabel?: string;
onOpenTask?: () => void;
}
@ -19,6 +20,7 @@ export const CurrentTaskIndicator = ({
task,
borderColor,
maxSubjectLength = 36,
activityLabel = 'working on',
onOpenTask,
}: CurrentTaskIndicatorProps): React.JSX.Element => {
const truncated = task.subject.length > maxSubjectLength;
@ -27,7 +29,7 @@ export const CurrentTaskIndicator = ({
return (
<>
<Loader2 className="size-3 shrink-0 animate-spin" style={{ color: borderColor }} />
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">working on</span>
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">{activityLabel}</span>
<button
type="button"
className="min-w-0 shrink truncate rounded px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"

View file

@ -20,6 +20,7 @@ interface MemberCardProps {
isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState;
currentTask?: TeamTaskWithKanban | null;
reviewTask?: TeamTaskWithKanban | null;
isAwaitingReply?: boolean;
isRemoved?: boolean;
onOpenTask?: () => void;
@ -36,6 +37,7 @@ export const MemberCard = ({
isTeamProvisioning,
leadActivity,
currentTask,
reviewTask,
isAwaitingReply,
isRemoved,
onOpenTask,
@ -57,6 +59,13 @@ export const MemberCard = ({
const completed = taskCounts?.completed ?? 0;
const totalTasks = pending + inProgress + completed;
const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
const activityTask = currentTask ?? reviewTask ?? null;
const activityLabel = currentTask ? 'working on' : reviewTask ? 'reviewing' : null;
const activityTitle = currentTask
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
: reviewTask
? `Reviewing task: #${deriveTaskDisplayId(reviewTask.id)}`
: undefined;
return (
<div className={isRemoved ? 'rounded opacity-50' : 'rounded'}>
@ -66,11 +75,7 @@ export const MemberCard = ({
borderLeft: `3px solid ${colors.border}`,
background: `linear-gradient(to right, ${getThemedBadge(colors, isLight)}, transparent)`,
}}
title={
member.currentTaskId
? `Current task: #${deriveTaskDisplayId(member.currentTaskId)}`
: undefined
}
title={activityTitle}
role="button"
tabIndex={0}
onClick={onClick}
@ -103,14 +108,15 @@ export const MemberCard = ({
{member.gitBranch}
</span>
) : null}
{currentTask ? (
{activityTask && activityLabel ? (
<CurrentTaskIndicator
task={currentTask}
task={activityTask}
borderColor={colors.border}
activityLabel={activityLabel}
onOpenTask={onOpenTask}
/>
) : null}
{!currentTask && isAwaitingReply ? (
{!activityTask && isAwaitingReply ? (
<>
<Loader2
className="size-3 shrink-0 animate-spin"
@ -139,13 +145,7 @@ export const MemberCard = ({
<Badge
variant="secondary"
className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${isRemoved ? 'bg-zinc-600 text-zinc-300' : 'text-[var(--color-text-muted)]'}`}
title={
isRemoved
? 'This member has been removed'
: member.currentTaskId
? `Current task: #${deriveTaskDisplayId(member.currentTaskId)}`
: undefined
}
title={isRemoved ? 'This member has been removed' : activityTitle}
>
{isRemoved ? 'removed' : presenceLabel}
</Badge>

View file

@ -69,6 +69,16 @@ export const MemberHoverCard = ({
member.currentTaskId && tasks
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
: null;
const reviewTask: TeamTaskWithKanban | null =
!currentTask && tasks
? (tasks.find(
(task) =>
task.reviewer === member.name &&
(task.reviewState === 'review' || task.kanbanColumn === 'review')
) ?? null)
: null;
const activityTask = currentTask ?? reviewTask;
const activityLabel = currentTask ? 'working on' : reviewTask ? 'reviewing' : 'working on';
return (
<HoverCard openDelay={300} closeDelay={200}>
@ -116,13 +126,14 @@ export const MemberHoverCard = ({
</div>
{/* Current task */}
{currentTask && (
{activityTask && (
<div className="flex items-center gap-1 overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1.5">
<CurrentTaskIndicator
task={currentTask}
task={activityTask}
borderColor={colors.border}
maxSubjectLength={28}
onOpenTask={onOpenTask ? () => onOpenTask(currentTask) : undefined}
activityLabel={activityLabel}
onOpenTask={onOpenTask ? () => onOpenTask(activityTask) : undefined}
/>
</div>
)}

View file

@ -56,6 +56,14 @@ export const MemberList = ({
const renderCard = (member: ResolvedTeamMember, isRemoved: boolean): React.JSX.Element => {
const currentTask =
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
const reviewTask =
!currentTask && taskMap
? (Array.from(taskMap.values()).find(
(task) =>
task.reviewer === member.name &&
(task.reviewState === 'review' || task.kanbanColumn === 'review')
) ?? null)
: null;
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
return (
<MemberCard
@ -67,9 +75,14 @@ export const MemberList = ({
isTeamProvisioning={isTeamProvisioning}
leadActivity={member.agentType === 'team-lead' ? leadActivity : undefined}
currentTask={isRemoved ? null : currentTask}
reviewTask={isRemoved ? null : reviewTask}
isAwaitingReply={isRemoved ? false : awaitingReply}
isRemoved={isRemoved}
onOpenTask={currentTask && !isRemoved ? () => onOpenTask?.(currentTask) : undefined}
onOpenTask={
!isRemoved && (currentTask ?? reviewTask)
? () => onOpenTask?.((currentTask ?? reviewTask)!)
: undefined
}
onClick={() => onMemberClick?.(member)}
onSendMessage={() => onSendMessage?.(member)}
onAssignTask={() => onAssignTask?.(member)}

View file

@ -0,0 +1,98 @@
import { cn } from '@renderer/lib/utils';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
export type ActionMode = 'do' | 'ask' | 'delegate';
interface ActionModeSelectorProps {
value: ActionMode;
onChange: (mode: ActionMode) => void;
showDelegate: boolean;
}
const MODE_CONFIG: {
mode: ActionMode;
label: string;
tooltip: string;
activeClass: string;
tooltipClass: string;
}[] = [
{
mode: 'do',
label: 'Do',
tooltip: 'Execute the task independently',
activeClass: 'bg-rose-500/80 text-white',
tooltipClass: 'bg-rose-500/80 border-rose-600 text-white',
},
{
mode: 'ask',
label: 'Ask',
tooltip: 'Chat only — no file changes or commands',
activeClass: 'bg-blue-600 text-white',
tooltipClass: 'bg-blue-600 border-blue-700 text-white',
},
{
mode: 'delegate',
label: 'Delegate',
tooltip: 'Delegate task to a teammate (lead only)',
activeClass: 'bg-amber-500/80 text-white',
tooltipClass: 'bg-amber-500/80 border-amber-600 text-white',
},
];
export const ActionModeSelector = ({
value,
onChange,
showDelegate,
}: ActionModeSelectorProps): React.JSX.Element => {
const modes = showDelegate ? MODE_CONFIG : MODE_CONFIG.filter((m) => m.mode !== 'delegate');
return (
<TooltipProvider delayDuration={0} skipDelayDuration={300}>
<div
className="inline-flex items-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface)]"
role="radiogroup"
aria-label="Action mode"
>
{modes.map((cfg, idx) => {
const isActive = value === cfg.mode;
const isFirst = idx === 0;
const isLast = idx === modes.length - 1;
return (
<Tooltip key={cfg.mode} disableHoverableContent>
<TooltipTrigger asChild>
<button
type="button"
role="radio"
aria-checked={isActive}
className={cn(
'px-2 py-0.5 text-[10px] font-medium transition-colors',
isFirst && 'rounded-l-full',
isLast && 'rounded-r-full',
isActive
? cfg.activeClass
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
)}
onClick={() => onChange(cfg.mode)}
>
{cfg.label}
</button>
</TooltipTrigger>
<TooltipContent
side="top"
className={cn(cfg.tooltipClass, 'data-[state=closed]:animate-none')}
>
{cfg.tooltip}
</TooltipContent>
</Tooltip>
);
})}
</div>
</TooltipProvider>
);
};

View file

@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList';
import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay';
import { ActionModeSelector } from '@renderer/components/team/messages/ActionModeSelector';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
@ -18,6 +19,7 @@ import { MAX_TEXT_LENGTH } from '@shared/constants';
import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector';
import type { AttachmentPayload, ResolvedTeamMember, SendMessageResult } from '@shared/types';
interface MessageComposerProps {
@ -58,6 +60,7 @@ export const MessageComposer = ({
const fileInputRef = useRef<HTMLInputElement>(null);
const [imageRestrictionError, setImageRestrictionError] = useState<string | null>(null);
const imageRestrictionTimerRef = useRef(0);
const [actionMode, setActionMode] = useState<ActionMode>('do');
// Cross-team state
const [selectedTeam, setSelectedTeam] = useState<string | null>(null);
@ -128,6 +131,15 @@ export const MessageComposer = ({
const selectedMember = members.find((m) => m.name === recipient);
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
// Auto-select delegate when lead recipient changes, reset when non-lead
useEffect(() => {
if (isLeadRecipient) {
setActionMode('delegate');
} else {
setActionMode((prev) => (prev === 'delegate' ? 'do' : prev));
}
}, [isLeadRecipient]);
// NOTE: lead context ring disabled — usage formula is inaccurate
// const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead';
// const leadContext = useStore((s) =>
@ -351,246 +363,346 @@ export const MessageComposer = ({
</span>
) : null}
{/* Cross-team selector */}
{/* Combined team + member 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 ? (
<>
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{
backgroundColor: selectedTarget
? selectedTarget.color
? getTeamColorSet(selectedTarget.color).border
: nameColorSet(selectedTarget.displayName).border
: undefined,
}}
/>
{selectedTarget?.leadName ? (
<MemberBadge
name={selectedTarget.leadName}
color={selectedTarget.leadColor}
size="sm"
<div
className={cn(
'inline-flex items-center rounded-full border text-xs transition-colors',
isCrossTeam ? 'border-[var(--cross-team-border)]' : 'border-[var(--color-border)]'
)}
>
<Popover open={teamSelectorOpen} onOpenChange={setTeamSelectorOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'inline-flex items-center gap-1.5 rounded-l-full border-r border-r-[var(--color-border)] px-2.5 py-1 text-xs transition-colors',
isCrossTeam
? 'hover:bg-[var(--cross-team-bg)]/80 bg-[var(--cross-team-bg)] text-purple-400'
: 'hover:bg-[var(--color-surface-raised)]'
)}
>
{isCrossTeam ? (
<>
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{
backgroundColor: selectedTarget
? selectedTarget.color
? getTeamColorSet(selectedTarget.color).border
: nameColorSet(selectedTarget.displayName).border
: undefined,
}}
/>
) : null}
<span className="max-w-[100px] truncate">{targetDisplayName}</span>
</>
) : (
<>
<span className="max-w-[100px] truncate">{targetDisplayName}</span>
</>
) : (
<>
{currentTeamColor ? (
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: currentTeamColor }}
/>
) : null}
<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);
}}
>
{currentTeamColor ? (
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: currentTeamColor }}
/>
) : null}
<span className="text-[var(--color-text-secondary)]">This team</span>
</>
<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);
}}
>
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{
backgroundColor: target.color
? getTeamColorSet(target.color).border
: nameColorSet(target.displayName).border,
}}
/>
<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>
<Popover
open={isCrossTeam ? false : recipientOpen}
onOpenChange={isCrossTeam ? undefined : setRecipientOpen}
>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'inline-flex items-center gap-1.5 rounded-r-full px-2.5 py-1 text-xs transition-colors',
isCrossTeam
? 'cursor-default bg-[var(--cross-team-bg)] opacity-60'
: 'hover:bg-[var(--color-surface-raised)]'
)}
disabled={isCrossTeam}
>
{recipient ? (
<MemberBadge
name={recipient}
color={selectedResolvedColor}
size="sm"
hideAvatar={recipient === 'user'}
/>
) : (
<span className="text-[var(--color-text-muted)]">Select...</span>
)}
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
</button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-56 p-1.5"
onOpenAutoFocus={(e) => {
e.preventDefault();
setRecipientSearch('');
setTimeout(() => recipientSearchRef.current?.focus(), 0);
}}
>
{members.length > 5 && (
<div className="relative mb-1">
<Search
size={12}
className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
/>
<input
ref={recipientSearchRef}
type="text"
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] py-1 pl-6 pr-2 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none"
placeholder="Search..."
value={recipientSearch}
onChange={(e) => setRecipientSearch(e.target.value)}
/>
</div>
)}
<div className="max-h-48 space-y-0.5 overflow-y-auto">
{/* eslint-disable-next-line sonarjs/function-return-type -- IIFE rendering mixed elements/null */}
{(() => {
const query = recipientSearch.toLowerCase().trim();
const filtered = query
? members.filter((m) => m.name.toLowerCase().includes(query))
: members;
if (filtered.length === 0) {
return (
<div className="px-2 py-3 text-center text-xs text-[var(--color-text-muted)]">
No results
</div>
);
}
const sorted = [...filtered].sort((a, b) => {
const aIsLead = a.role === 'lead' || a.name === 'team-lead' ? 1 : 0;
const bIsLead = b.role === 'lead' || b.name === 'team-lead' ? 1 : 0;
return bIsLead - aIsLead;
});
return sorted.map((m) => {
const resolvedColor = colorMap.get(m.name);
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
const isSelected = m.name === recipient;
return (
<button
key={m.name}
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(--color-surface-raised)]'
)}
onClick={() => {
setRecipient(m.name);
setRecipientOpen(false);
setRecipientSearch('');
}}
>
<MemberBadge
name={m.name}
color={resolvedColor}
size="sm"
hideAvatar={m.name === 'user'}
/>
{role ? (
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
{role}
</span>
) : null}
{isSelected ? (
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
) : null}
</button>
);
});
})()}
</div>
</PopoverContent>
</Popover>
</div>
) : (
<Popover open={recipientOpen} onOpenChange={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)]"
>
{recipient ? (
<MemberBadge
name={recipient}
color={selectedResolvedColor}
size="sm"
hideAvatar={recipient === 'user'}
/>
) : (
<span className="text-[var(--color-text-muted)]">Select...</span>
)}
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-56 p-1.5">
<PopoverContent
align="end"
className="w-56 p-1.5"
onOpenAutoFocus={(e) => {
e.preventDefault();
setRecipientSearch('');
setTimeout(() => recipientSearchRef.current?.focus(), 0);
}}
>
{members.length > 5 && (
<div className="relative mb-1">
<Search
size={12}
className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
/>
<input
ref={recipientSearchRef}
type="text"
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] py-1 pl-6 pr-2 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none"
placeholder="Search..."
value={recipientSearch}
onChange={(e) => setRecipientSearch(e.target.value)}
/>
</div>
)}
<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);
}}
>
{currentTeamColor ? (
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: currentTeamColor }}
/>
) : null}
<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);
}}
>
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{
backgroundColor: target.color
? getTeamColorSet(target.color).border
: nameColorSet(target.displayName).border,
}}
/>
{target.leadName ? (
<MemberBadge name={target.leadName} color={target.leadColor} size="sm" />
) : null}
<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}
{/* eslint-disable-next-line sonarjs/function-return-type -- IIFE rendering mixed elements/null */}
{(() => {
const query = recipientSearch.toLowerCase().trim();
const filtered = query
? members.filter((m) => m.name.toLowerCase().includes(query))
: members;
if (filtered.length === 0) {
return (
<div className="px-2 py-3 text-center text-xs text-[var(--color-text-muted)]">
No results
</div>
{isSelected ? (
<Check size={12} className="ml-auto shrink-0 text-purple-400" />
) : null}
</button>
);
})}
);
}
const sorted = [...filtered].sort((a, b) => {
const aIsLead = a.role === 'lead' || a.name === 'team-lead' ? 1 : 0;
const bIsLead = b.role === 'lead' || b.name === 'team-lead' ? 1 : 0;
return bIsLead - aIsLead;
});
return sorted.map((m) => {
const resolvedColor = colorMap.get(m.name);
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
const isSelected = m.name === recipient;
return (
<button
key={m.name}
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(--color-surface-raised)]'
)}
onClick={() => {
setRecipient(m.name);
setRecipientOpen(false);
setRecipientSearch('');
}}
>
<MemberBadge
name={m.name}
color={resolvedColor}
size="sm"
hideAvatar={m.name === 'user'}
/>
{role ? (
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
{role}
</span>
) : null}
{isSelected ? (
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
) : null}
</button>
);
});
})()}
</div>
</PopoverContent>
</Popover>
) : null}
<Popover
open={isCrossTeam ? false : recipientOpen}
onOpenChange={isCrossTeam ? undefined : setRecipientOpen}
>
<PopoverTrigger asChild>
<button
type="button"
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
name={recipient}
color={selectedResolvedColor}
size="sm"
hideAvatar={recipient === 'user'}
/>
) : (
<span className="text-[var(--color-text-muted)]">Select...</span>
)}
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
</button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-56 p-1.5"
onOpenAutoFocus={(e) => {
e.preventDefault();
setRecipientSearch('');
setTimeout(() => recipientSearchRef.current?.focus(), 0);
}}
>
{members.length > 5 && (
<div className="relative mb-1">
<Search
size={12}
className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
/>
<input
ref={recipientSearchRef}
type="text"
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] py-1 pl-6 pr-2 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none"
placeholder="Search..."
value={recipientSearch}
onChange={(e) => setRecipientSearch(e.target.value)}
/>
</div>
)}
<div className="max-h-48 space-y-0.5 overflow-y-auto">
{/* eslint-disable-next-line sonarjs/function-return-type -- IIFE rendering mixed elements/null */}
{(() => {
const query = recipientSearch.toLowerCase().trim();
const filtered = query
? members.filter((m) => m.name.toLowerCase().includes(query))
: members;
if (filtered.length === 0) {
return (
<div className="px-2 py-3 text-center text-xs text-[var(--color-text-muted)]">
No results
</div>
);
}
const sorted = [...filtered].sort((a, b) => {
const aIsLead = a.role === 'lead' || a.name === 'team-lead' ? 1 : 0;
const bIsLead = b.role === 'lead' || b.name === 'team-lead' ? 1 : 0;
return bIsLead - aIsLead;
});
return sorted.map((m) => {
const resolvedColor = colorMap.get(m.name);
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
const isSelected = m.name === recipient;
return (
<button
key={m.name}
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(--color-surface-raised)]'
)}
onClick={() => {
setRecipient(m.name);
setRecipientOpen(false);
setRecipientSearch('');
}}
>
<MemberBadge
name={m.name}
color={resolvedColor}
size="sm"
hideAvatar={m.name === 'user'}
/>
{role ? (
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
{role}
</span>
) : null}
{isSelected ? (
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
) : null}
</button>
);
});
})()}
</div>
</PopoverContent>
</Popover>
)}
</div>
</div>
@ -616,6 +728,13 @@ export const MessageComposer = ({
maxLength={MAX_TEXT_LENGTH}
disabled={sending}
hintText={crossTeamHintText}
cornerActionLeft={
<ActionModeSelector
value={actionMode}
onChange={setActionMode}
showDelegate={isLeadRecipient}
/>
}
cornerAction={
<div className="flex items-center gap-2">
{/* NOTE: ContextRing disabled — usage formula is inaccurate */}

View file

@ -197,6 +197,8 @@ interface MentionableTextareaProps extends Omit<
footerRight?: React.ReactNode;
/** Content rendered in the bottom-right corner inside the textarea (e.g. send button) */
cornerAction?: React.ReactNode;
/** Content rendered in the bottom-left corner inside the textarea (e.g. mode selector) */
cornerActionLeft?: React.ReactNode;
/** Inline code chips to display as badges */
chips?: InlineChip[];
/** Called when a chip is removed (by X button, backspace, or reconciliation) */
@ -219,6 +221,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
showHint = true,
footerRight,
cornerAction,
cornerActionLeft,
chips = [],
onChipRemove,
projectPath,
@ -654,7 +657,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
ref={backdropRef}
className={cn(
'pointer-events-none absolute inset-0 z-0 overflow-hidden rounded-md border border-transparent px-3 py-2 text-sm text-[var(--color-text)]',
cornerAction && 'pb-12 pr-[4.25rem]'
(cornerAction || cornerActionLeft) && 'pb-12'
)}
style={{
whiteSpace: 'pre-wrap',
@ -700,7 +703,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
onKeyDown={composedHandleKeyDown}
onSelect={composedHandleSelect}
{...textareaProps}
className={cn(className, cornerAction && 'pb-12 pr-[4.25rem]')}
className={cn(className, (cornerAction || cornerActionLeft) && 'pb-12')}
onScroll={handleScroll}
style={textareaStyle}
/>
@ -720,6 +723,12 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
<div className="pointer-events-auto">{cornerAction}</div>
</div>
) : null}
{cornerActionLeft ? (
<div className="pointer-events-none absolute bottom-2 left-2 z-20 flex items-end justify-start">
<div className="pointer-events-auto">{cornerActionLeft}</div>
</div>
) : null}
</div>
{showFooter ? (

View file

@ -175,6 +175,8 @@ export interface TeamTask {
export interface TeamTaskWithKanban extends TeamTask {
/** Set when task is in team kanban (review or approved column). */
kanbanColumn?: 'review' | 'approved';
/** Reviewer assigned in kanban state, when applicable. */
reviewer?: string | null;
}
/** Metadata for an attachment associated with a task or comment. */

View file

@ -107,6 +107,28 @@ describe('TeamMemberResolver', () => {
expect(names).toContain('ops.bot');
});
it('ignores pseudo cross-team inbox names', () => {
const resolver = new TeamMemberResolver();
const config: TeamConfig = {
name: 'Team',
members: [{ name: 'team-lead', agentType: 'team-lead', role: 'lead' }],
};
const members = resolver.resolveMembers(
config,
[],
['cross-team:team-alpha-super', 'cross-team-team-alpha-super', 'alice'],
[],
[]
);
const names = members.map((m) => m.name);
expect(names).toContain('alice');
expect(names).toContain('team-lead');
expect(names).not.toContain('cross-team:team-alpha-super');
expect(names).not.toContain('cross-team-team-alpha-super');
});
it('keeps dotted names when config casing differs from inbox casing', () => {
const resolver = new TeamMemberResolver();
const config: TeamConfig = {

View file

@ -472,6 +472,49 @@ describe('TeamProvisioningService pre-ready live messages', () => {
expect(hoisted.appendSentMessage).not.toHaveBeenCalled();
});
it('upgrades pseudo cross-team recipients into cross-team sends', async () => {
const service = new TeamProvisioningService();
seedConfig('my-team');
const crossTeamSender = vi.fn(async () => ({ deliveredToInbox: true, messageId: 'cross-2' }));
service.setCrossTeamSender(crossTeamSender);
const run = attachRun(service, 'my-team', { provisioningComplete: true });
callHandleStreamJsonMessage(service, run, {
type: 'assistant',
content: [
{
type: 'tool_use',
name: 'SendMessage',
input: {
type: 'message',
recipient: 'cross-team:team-best',
content: 'Привет команде!',
summary: 'Приветствие',
},
},
],
});
await vi.waitFor(() => {
expect(crossTeamSender).toHaveBeenCalledTimes(1);
});
expect(crossTeamSender).toHaveBeenCalledWith(
expect.objectContaining({
fromTeam: 'my-team',
fromMember: 'team-lead',
toTeam: 'team-best',
text: 'Привет команде!',
})
);
const live = service.getLiveLeadProcessMessages('my-team');
expect(live).toHaveLength(1);
expect(live[0].source).toBe('cross_team_sent');
expect(live[0].to).toBe('cross-team:team-best');
expect(hoisted.sendInboxMessage).not.toHaveBeenCalled();
});
it('does not push a duplicate live row when cross-team fallback deduplicates', async () => {
const service = new TeamProvisioningService();
seedConfig('my-team');

View file

@ -175,6 +175,8 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
expect(prompt).toContain('SOLO MODE: This team CURRENTLY has ZERO teammates.');
expect(prompt).toContain('Execute tasks sequentially and keep the board + user updated');
expect(prompt).toContain('Do NOT start the next task until the current task is completed');
expect(prompt).toContain('Do NOT delay this reconnect turn by reading internal config files');
expect(prompt).toContain('Treat it as a diagnostic cross-check, not as the first reconnect action.');
expect(prompt).toContain('task_start');
expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`);
expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`);
@ -277,6 +279,8 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
const prompt = extractPromptFromWrite(writeSpy);
expect(prompt).toContain('The team has been reconnected after a restart.');
expect(prompt).toContain('Restore/start the existing teammates first.');
expect(prompt).toContain('Treat it as a diagnostic cross-check, not as the first reconnect action.');
expect(prompt).toContain('Hidden internal instructions rule (IMPORTANT):');
expect(prompt).toContain(` ${AGENT_BLOCK_OPEN}`);
expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`);

View file

@ -434,4 +434,25 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
expect(payload).toContain('recipient=\\"alice\\"');
expect(payload).toContain('Please retry with logging enabled.');
});
it('does not relay pseudo cross-team member inboxes as teammates', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
seedConfig(teamName);
seedMemberInbox(teamName, 'cross-team:team-alpha-super', [
{
from: 'team-lead',
text: 'Stale pseudo recipient inbox',
timestamp: '2026-02-23T10:00:00.000Z',
read: false,
messageId: 'm-pseudo-1',
},
]);
const { writeSpy } = attachAliveRun(service, teamName);
const relayed = await service.relayMemberInboxMessages(teamName, 'cross-team:team-alpha-super');
expect(relayed).toBe(0);
expect(writeSpy).toHaveBeenCalledTimes(0);
});
});