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);
|
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[] = [];
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)]"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
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 { 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 */}
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue