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:
parent
c40c61f099
commit
2eb814bb70
17 changed files with 689 additions and 251 deletions
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)]"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
98
src/renderer/components/team/messages/ActionModeSelector.tsx
Normal file
98
src/renderer/components/team/messages/ActionModeSelector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue