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

View file

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

View file

@ -902,11 +902,13 @@ ${persistentContext}
Steps (execute in this exact order): 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} ${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 { ): { teamName: string; memberName: string } | null {
const trimmed = recipient.trim(); const trimmed = recipient.trim();
if (localRecipientNames.has(trimmed)) return null; 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('.'); const dot = trimmed.indexOf('.');
if (dot <= 0 || dot === trimmed.length - 1) return null; if (dot <= 0 || dot === trimmed.length - 1) return null;
const teamName = trimmed.slice(0, dot).trim(); const teamName = trimmed.slice(0, dot).trim();
@ -1239,6 +1248,19 @@ export class TeamProvisioningService {
return { teamName, memberName }; 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 { private persistSentMessage(teamName: string, message: InboxMessage): void {
try { try {
createController({ createController({
@ -2561,6 +2583,9 @@ export class TeamProvisioningService {
} }
async relayMemberInboxMessages(teamName: string, memberName: string): Promise<number> { async relayMemberInboxMessages(teamName: string, memberName: string): Promise<number> {
if (this.isCrossTeamPseudoRecipientName(memberName)) {
return 0;
}
const relayKey = this.getMemberRelayKey(teamName, memberName); const relayKey = this.getMemberRelayKey(teamName, memberName);
const existing = this.memberInboxRelayInFlight.get(relayKey); const existing = this.memberInboxRelayInFlight.get(relayKey);
if (existing) { if (existing) {
@ -3155,7 +3180,9 @@ export class TeamProvisioningService {
} }
const msg: InboxMessage = { const msg: InboxMessage = {
from: 'user', from: 'user',
to: `${crossTeamRecipient.teamName}.${crossTeamRecipient.memberName}`, to: recipient.startsWith('cross-team:')
? recipient
: `${crossTeamRecipient.teamName}.${crossTeamRecipient.memberName}`,
text: strippedCrossTeamContent, text: strippedCrossTeamContent,
timestamp, timestamp,
read: true, read: true,

View file

@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { Layers } from 'lucide-react';
import { ActivityItem, isNoiseMessage } from './ActivityItem'; import { ActivityItem, isNoiseMessage } from './ActivityItem';
import { AnimatedHeightReveal } from './AnimatedHeightReveal'; import { AnimatedHeightReveal } from './AnimatedHeightReveal';
@ -9,6 +10,7 @@ import { findNewestMessageIndex, resolveTimelineCollapseState } from './collapse
import { import {
getThoughtGroupKey, getThoughtGroupKey,
groupTimelineItems, groupTimelineItems,
isCompactionMessage,
isLeadThought, isLeadThought,
LeadThoughtsGroupRow, LeadThoughtsGroupRow,
} from './LeadThoughtsGroup'; } from './LeadThoughtsGroup';
@ -55,6 +57,29 @@ interface ActivityTimelineProps {
const VIEWPORT_THRESHOLD = 0.15; const VIEWPORT_THRESHOLD = 0.15;
const MESSAGES_PAGE_SIZE = 30; 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 = ({ const MessageRowWithObserver = ({
message, message,
teamName, teamName,
@ -239,6 +264,7 @@ export const ActivityTimeline = ({
cardCount++; cardCount++;
} else { } else {
if (isNoiseMessage(item.message.text)) continue; if (isNoiseMessage(item.message.text)) continue;
if (isCompactionMessage(item.message)) continue;
if (cardCount % 2 === 1) result.add(i); if (cardCount % 2 === 1) result.add(i);
cardCount++; cardCount++;
} }
@ -420,6 +446,18 @@ export const ActivityTimeline = ({
} }
const { message } = item; 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 info = memberInfo.get(message.from);
const recipientInfo = message.to ? memberInfo.get(message.to) : undefined; const recipientInfo = message.to ? memberInfo.get(message.to) : undefined;
const recipientColor = const recipientColor =

View file

@ -36,12 +36,21 @@ export interface LeadThoughtGroup {
thoughts: InboxMessage[]; 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 * Check if a message is an intermediate lead "thought" (assistant text) rather than
* an official message (SendMessage, direct reply, inbox, etc.). * an official message (SendMessage, direct reply, inbox, etc.).
*/ */
export function isLeadThought(msg: InboxMessage): boolean { export function isLeadThought(msg: InboxMessage): boolean {
if (typeof msg.to === 'string' && msg.to.trim().length > 0) return false; 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_session') return true;
if (msg.source === 'lead_process') return true; if (msg.source === 'lead_process') return true;
return false; return false;

View file

@ -8,6 +8,7 @@ interface CurrentTaskIndicatorProps {
borderColor: string; borderColor: string;
/** Max characters for the subject before truncating */ /** Max characters for the subject before truncating */
maxSubjectLength?: number; maxSubjectLength?: number;
activityLabel?: string;
onOpenTask?: () => void; onOpenTask?: () => void;
} }
@ -19,6 +20,7 @@ export const CurrentTaskIndicator = ({
task, task,
borderColor, borderColor,
maxSubjectLength = 36, maxSubjectLength = 36,
activityLabel = 'working on',
onOpenTask, onOpenTask,
}: CurrentTaskIndicatorProps): React.JSX.Element => { }: CurrentTaskIndicatorProps): React.JSX.Element => {
const truncated = task.subject.length > maxSubjectLength; const truncated = task.subject.length > maxSubjectLength;
@ -27,7 +29,7 @@ export const CurrentTaskIndicator = ({
return ( return (
<> <>
<Loader2 className="size-3 shrink-0 animate-spin" style={{ color: borderColor }} /> <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 <button
type="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)]" 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; isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState; leadActivity?: LeadActivityState;
currentTask?: TeamTaskWithKanban | null; currentTask?: TeamTaskWithKanban | null;
reviewTask?: TeamTaskWithKanban | null;
isAwaitingReply?: boolean; isAwaitingReply?: boolean;
isRemoved?: boolean; isRemoved?: boolean;
onOpenTask?: () => void; onOpenTask?: () => void;
@ -36,6 +37,7 @@ export const MemberCard = ({
isTeamProvisioning, isTeamProvisioning,
leadActivity, leadActivity,
currentTask, currentTask,
reviewTask,
isAwaitingReply, isAwaitingReply,
isRemoved, isRemoved,
onOpenTask, onOpenTask,
@ -57,6 +59,13 @@ export const MemberCard = ({
const completed = taskCounts?.completed ?? 0; const completed = taskCounts?.completed ?? 0;
const totalTasks = pending + inProgress + completed; const totalTasks = pending + inProgress + completed;
const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0; 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 ( return (
<div className={isRemoved ? 'rounded opacity-50' : 'rounded'}> <div className={isRemoved ? 'rounded opacity-50' : 'rounded'}>
@ -66,11 +75,7 @@ export const MemberCard = ({
borderLeft: `3px solid ${colors.border}`, borderLeft: `3px solid ${colors.border}`,
background: `linear-gradient(to right, ${getThemedBadge(colors, isLight)}, transparent)`, background: `linear-gradient(to right, ${getThemedBadge(colors, isLight)}, transparent)`,
}} }}
title={ title={activityTitle}
member.currentTaskId
? `Current task: #${deriveTaskDisplayId(member.currentTaskId)}`
: undefined
}
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={onClick} onClick={onClick}
@ -103,14 +108,15 @@ export const MemberCard = ({
{member.gitBranch} {member.gitBranch}
</span> </span>
) : null} ) : null}
{currentTask ? ( {activityTask && activityLabel ? (
<CurrentTaskIndicator <CurrentTaskIndicator
task={currentTask} task={activityTask}
borderColor={colors.border} borderColor={colors.border}
activityLabel={activityLabel}
onOpenTask={onOpenTask} onOpenTask={onOpenTask}
/> />
) : null} ) : null}
{!currentTask && isAwaitingReply ? ( {!activityTask && isAwaitingReply ? (
<> <>
<Loader2 <Loader2
className="size-3 shrink-0 animate-spin" className="size-3 shrink-0 animate-spin"
@ -139,13 +145,7 @@ export const MemberCard = ({
<Badge <Badge
variant="secondary" 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)]'}`} 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={ title={isRemoved ? 'This member has been removed' : activityTitle}
isRemoved
? 'This member has been removed'
: member.currentTaskId
? `Current task: #${deriveTaskDisplayId(member.currentTaskId)}`
: undefined
}
> >
{isRemoved ? 'removed' : presenceLabel} {isRemoved ? 'removed' : presenceLabel}
</Badge> </Badge>

View file

@ -69,6 +69,16 @@ export const MemberHoverCard = ({
member.currentTaskId && tasks member.currentTaskId && tasks
? (tasks.find((t) => t.id === member.currentTaskId) ?? null) ? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
: 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 ( return (
<HoverCard openDelay={300} closeDelay={200}> <HoverCard openDelay={300} closeDelay={200}>
@ -116,13 +126,14 @@ export const MemberHoverCard = ({
</div> </div>
{/* Current task */} {/* 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"> <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 <CurrentTaskIndicator
task={currentTask} task={activityTask}
borderColor={colors.border} borderColor={colors.border}
maxSubjectLength={28} maxSubjectLength={28}
onOpenTask={onOpenTask ? () => onOpenTask(currentTask) : undefined} activityLabel={activityLabel}
onOpenTask={onOpenTask ? () => onOpenTask(activityTask) : undefined}
/> />
</div> </div>
)} )}

View file

@ -56,6 +56,14 @@ export const MemberList = ({
const renderCard = (member: ResolvedTeamMember, isRemoved: boolean): React.JSX.Element => { const renderCard = (member: ResolvedTeamMember, isRemoved: boolean): React.JSX.Element => {
const currentTask = const currentTask =
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null; 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]); const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
return ( return (
<MemberCard <MemberCard
@ -67,9 +75,14 @@ export const MemberList = ({
isTeamProvisioning={isTeamProvisioning} isTeamProvisioning={isTeamProvisioning}
leadActivity={member.agentType === 'team-lead' ? leadActivity : undefined} leadActivity={member.agentType === 'team-lead' ? leadActivity : undefined}
currentTask={isRemoved ? null : currentTask} currentTask={isRemoved ? null : currentTask}
reviewTask={isRemoved ? null : reviewTask}
isAwaitingReply={isRemoved ? false : awaitingReply} isAwaitingReply={isRemoved ? false : awaitingReply}
isRemoved={isRemoved} isRemoved={isRemoved}
onOpenTask={currentTask && !isRemoved ? () => onOpenTask?.(currentTask) : undefined} onOpenTask={
!isRemoved && (currentTask ?? reviewTask)
? () => onOpenTask?.((currentTask ?? reviewTask)!)
: undefined
}
onClick={() => onMemberClick?.(member)} onClick={() => onMemberClick?.(member)}
onSendMessage={() => onSendMessage?.(member)} onSendMessage={() => onSendMessage?.(member)}
onAssignTask={() => onAssignTask?.(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 { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList';
import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay'; import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay';
import { ActionModeSelector } from '@renderer/components/team/messages/ActionModeSelector';
import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; 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 { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector';
import type { AttachmentPayload, ResolvedTeamMember, SendMessageResult } from '@shared/types'; import type { AttachmentPayload, ResolvedTeamMember, SendMessageResult } from '@shared/types';
interface MessageComposerProps { interface MessageComposerProps {
@ -58,6 +60,7 @@ export const MessageComposer = ({
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [imageRestrictionError, setImageRestrictionError] = useState<string | null>(null); const [imageRestrictionError, setImageRestrictionError] = useState<string | null>(null);
const imageRestrictionTimerRef = useRef(0); const imageRestrictionTimerRef = useRef(0);
const [actionMode, setActionMode] = useState<ActionMode>('do');
// Cross-team state // Cross-team state
const [selectedTeam, setSelectedTeam] = useState<string | null>(null); const [selectedTeam, setSelectedTeam] = useState<string | null>(null);
@ -128,6 +131,15 @@ export const MessageComposer = ({
const selectedMember = members.find((m) => m.name === recipient); const selectedMember = members.find((m) => m.name === recipient);
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined; const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead'; 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 // NOTE: lead context ring disabled — usage formula is inaccurate
// const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead'; // const isLeadAgentRecipient = selectedMember?.agentType === 'team-lead';
// const leadContext = useStore((s) => // const leadContext = useStore((s) =>
@ -351,246 +363,346 @@ export const MessageComposer = ({
</span> </span>
) : null} ) : null}
{/* Cross-team selector */} {/* Combined team + member selector */}
{crossTeamTargets.length > 0 ? ( {crossTeamTargets.length > 0 ? (
<Popover open={teamSelectorOpen} onOpenChange={setTeamSelectorOpen}> <div
<PopoverTrigger asChild> className={cn(
<button 'inline-flex items-center rounded-full border text-xs transition-colors',
type="button" isCrossTeam ? 'border-[var(--cross-team-border)]' : 'border-[var(--color-border)]'
className={cn( )}
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors', >
isCrossTeam <Popover open={teamSelectorOpen} onOpenChange={setTeamSelectorOpen}>
? 'border-[var(--cross-team-border)] bg-[var(--cross-team-bg)] text-purple-400 hover:border-purple-400/30' <PopoverTrigger asChild>
: 'border-[var(--color-border)] hover:border-[var(--color-border-emphasis)] hover:bg-[var(--color-surface-raised)]' <button
)} type="button"
> className={cn(
{isCrossTeam ? ( '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
<span ? 'hover:bg-[var(--cross-team-bg)]/80 bg-[var(--cross-team-bg)] text-purple-400'
className="inline-block size-2 shrink-0 rounded-full" : 'hover:bg-[var(--color-surface-raised)]'
style={{ )}
backgroundColor: selectedTarget >
? selectedTarget.color {isCrossTeam ? (
? getTeamColorSet(selectedTarget.color).border <>
: nameColorSet(selectedTarget.displayName).border <span
: undefined, className="inline-block size-2 shrink-0 rounded-full"
}} style={{
/> backgroundColor: selectedTarget
{selectedTarget?.leadName ? ( ? selectedTarget.color
<MemberBadge ? getTeamColorSet(selectedTarget.color).border
name={selectedTarget.leadName} : nameColorSet(selectedTarget.displayName).border
color={selectedTarget.leadColor} : undefined,
size="sm" }}
/> />
) : 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 ? ( {currentTeamColor ? (
<span <span
className="inline-block size-2 shrink-0 rounded-full" className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: currentTeamColor }} style={{ backgroundColor: currentTeamColor }}
/> />
) : null} ) : 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)]" /> <ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
</button> </button>
</PopoverTrigger> </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"> <div className="max-h-48 space-y-0.5 overflow-y-auto">
{/* Current team option */} {/* eslint-disable-next-line sonarjs/function-return-type -- IIFE rendering mixed elements/null */}
<button {(() => {
type="button" const query = recipientSearch.toLowerCase().trim();
className={cn( const filtered = query
'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)]', ? members.filter((m) => m.name.toLowerCase().includes(query))
!isCrossTeam && 'bg-[var(--color-surface-raised)]' : members;
)} if (filtered.length === 0) {
onClick={() => { return (
setSelectedTeam(null); <div className="px-2 py-3 text-center text-xs text-[var(--color-text-muted)]">
setTeamSelectorOpen(false); No results
}}
>
{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}
</div> </div>
{isSelected ? ( );
<Check size={12} className="ml-auto shrink-0 text-purple-400" /> }
) : null} const sorted = [...filtered].sort((a, b) => {
</button> 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> </div>
</PopoverContent> </PopoverContent>
</Popover> </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>
</div> </div>
@ -616,6 +728,13 @@ export const MessageComposer = ({
maxLength={MAX_TEXT_LENGTH} maxLength={MAX_TEXT_LENGTH}
disabled={sending} disabled={sending}
hintText={crossTeamHintText} hintText={crossTeamHintText}
cornerActionLeft={
<ActionModeSelector
value={actionMode}
onChange={setActionMode}
showDelegate={isLeadRecipient}
/>
}
cornerAction={ cornerAction={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* NOTE: ContextRing disabled — usage formula is inaccurate */} {/* NOTE: ContextRing disabled — usage formula is inaccurate */}

View file

@ -197,6 +197,8 @@ interface MentionableTextareaProps extends Omit<
footerRight?: React.ReactNode; footerRight?: React.ReactNode;
/** Content rendered in the bottom-right corner inside the textarea (e.g. send button) */ /** Content rendered in the bottom-right corner inside the textarea (e.g. send button) */
cornerAction?: React.ReactNode; 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 */ /** Inline code chips to display as badges */
chips?: InlineChip[]; chips?: InlineChip[];
/** Called when a chip is removed (by X button, backspace, or reconciliation) */ /** 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, showHint = true,
footerRight, footerRight,
cornerAction, cornerAction,
cornerActionLeft,
chips = [], chips = [],
onChipRemove, onChipRemove,
projectPath, projectPath,
@ -654,7 +657,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
ref={backdropRef} ref={backdropRef}
className={cn( 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)]', '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={{ style={{
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
@ -700,7 +703,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
onKeyDown={composedHandleKeyDown} onKeyDown={composedHandleKeyDown}
onSelect={composedHandleSelect} onSelect={composedHandleSelect}
{...textareaProps} {...textareaProps}
className={cn(className, cornerAction && 'pb-12 pr-[4.25rem]')} className={cn(className, (cornerAction || cornerActionLeft) && 'pb-12')}
onScroll={handleScroll} onScroll={handleScroll}
style={textareaStyle} style={textareaStyle}
/> />
@ -720,6 +723,12 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
<div className="pointer-events-auto">{cornerAction}</div> <div className="pointer-events-auto">{cornerAction}</div>
</div> </div>
) : null} ) : 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> </div>
{showFooter ? ( {showFooter ? (

View file

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

View file

@ -107,6 +107,28 @@ describe('TeamMemberResolver', () => {
expect(names).toContain('ops.bot'); 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', () => { it('keeps dotted names when config casing differs from inbox casing', () => {
const resolver = new TeamMemberResolver(); const resolver = new TeamMemberResolver();
const config: TeamConfig = { const config: TeamConfig = {

View file

@ -472,6 +472,49 @@ describe('TeamProvisioningService pre-ready live messages', () => {
expect(hoisted.appendSentMessage).not.toHaveBeenCalled(); 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 () => { it('does not push a duplicate live row when cross-team fallback deduplicates', async () => {
const service = new TeamProvisioningService(); const service = new TeamProvisioningService();
seedConfig('my-team'); 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('SOLO MODE: This team CURRENTLY has ZERO teammates.');
expect(prompt).toContain('Execute tasks sequentially and keep the board + user updated'); 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 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('task_start');
expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`); expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`);
expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`); 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); const prompt = extractPromptFromWrite(writeSpy);
expect(prompt).toContain('The team has been reconnected after a restart.'); 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('Hidden internal instructions rule (IMPORTANT):');
expect(prompt).toContain(` ${AGENT_BLOCK_OPEN}`); expect(prompt).toContain(` ${AGENT_BLOCK_OPEN}`);
expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`); expect(prompt).toContain(` ${AGENT_BLOCK_CLOSE}`);

View file

@ -434,4 +434,25 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
expect(payload).toContain('recipient=\\"alice\\"'); expect(payload).toContain('recipient=\\"alice\\"');
expect(payload).toContain('Please retry with logging enabled.'); 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);
});
}); });