feat: enhance cross-team messaging with conversation metadata
- Introduced conversationId and replyToConversationId to support threaded replies in cross-team messages. - Updated message formatting to include conversation metadata in the message prefix. - Enhanced CrossTeamService to infer conversation metadata when not explicitly provided. - Improved tests to validate the handling of conversation IDs and ensure correct message routing. - Updated UI components to display pending replies and manage cross-team interactions more effectively.
This commit is contained in:
parent
b09c4e4fd0
commit
4a2b8baaf5
22 changed files with 802 additions and 119 deletions
|
|
@ -137,6 +137,12 @@ function sendCrossTeamMessage(context, flags) {
|
|||
const fromTeam = context.teamName;
|
||||
const toTeam = typeof flags.toTeam === 'string' ? flags.toTeam.trim() : '';
|
||||
const fromMember = typeof flags.fromMember === 'string' ? flags.fromMember.trim() : 'team-lead';
|
||||
const replyToConversationId =
|
||||
typeof flags.replyToConversationId === 'string' ? flags.replyToConversationId.trim() : '';
|
||||
const conversationId =
|
||||
typeof flags.conversationId === 'string' && flags.conversationId.trim()
|
||||
? flags.conversationId.trim()
|
||||
: replyToConversationId || '';
|
||||
const text = typeof flags.text === 'string' ? flags.text : '';
|
||||
const summary = typeof flags.summary === 'string' ? flags.summary.trim() : undefined;
|
||||
const chainDepth = typeof flags.chainDepth === 'number' ? flags.chainDepth : 0;
|
||||
|
|
@ -167,7 +173,12 @@ function sendCrossTeamMessage(context, flags) {
|
|||
|
||||
// Format
|
||||
const from = `${fromTeam}.${fromMember}`;
|
||||
const formattedText = formatCrossTeamText(from, chainDepth, text);
|
||||
const resolvedConversationId =
|
||||
conversationId || (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`);
|
||||
const formattedText = formatCrossTeamText(from, chainDepth, text, {
|
||||
conversationId: resolvedConversationId,
|
||||
replyToConversationId: replyToConversationId || undefined,
|
||||
});
|
||||
const messageId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
||||
const dedupeKey = buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary);
|
||||
|
||||
|
|
@ -200,6 +211,8 @@ function sendCrossTeamMessage(context, flags) {
|
|||
summary: summary || `Cross-team message from ${fromTeam}`,
|
||||
messageId,
|
||||
source: CROSS_TEAM_SOURCE,
|
||||
conversationId: resolvedConversationId,
|
||||
replyToConversationId: replyToConversationId || undefined,
|
||||
});
|
||||
writeJson(inboxPath, list);
|
||||
});
|
||||
|
|
@ -216,6 +229,8 @@ function sendCrossTeamMessage(context, flags) {
|
|||
fromTeam,
|
||||
fromMember,
|
||||
toTeam,
|
||||
conversationId: resolvedConversationId,
|
||||
replyToConversationId: replyToConversationId || undefined,
|
||||
text,
|
||||
summary,
|
||||
chainDepth,
|
||||
|
|
|
|||
|
|
@ -5,12 +5,19 @@ const CROSS_TEAM_PREFIX_TAG = 'Cross-team from';
|
|||
const CROSS_TEAM_SOURCE = 'cross_team';
|
||||
const CROSS_TEAM_SENT_SOURCE = 'cross_team_sent';
|
||||
|
||||
function formatCrossTeamPrefix(from, chainDepth) {
|
||||
return `[${CROSS_TEAM_PREFIX_TAG} ${from} | depth:${chainDepth}]`;
|
||||
function formatCrossTeamPrefix(from, chainDepth, meta) {
|
||||
const parts = [`${CROSS_TEAM_PREFIX_TAG} ${from}`, `depth:${chainDepth}`];
|
||||
if (meta && meta.conversationId) {
|
||||
parts.push(`conversation:${meta.conversationId}`);
|
||||
}
|
||||
if (meta && meta.replyToConversationId) {
|
||||
parts.push(`replyTo:${meta.replyToConversationId}`);
|
||||
}
|
||||
return `[${parts.join(' | ')}]`;
|
||||
}
|
||||
|
||||
function formatCrossTeamText(from, chainDepth, text) {
|
||||
return `${formatCrossTeamPrefix(from, chainDepth)}\n${text}`;
|
||||
function formatCrossTeamText(from, chainDepth, text, meta) {
|
||||
return `${formatCrossTeamPrefix(from, chainDepth, meta)}\n${text}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
|
|
|||
|
|
@ -76,6 +76,12 @@ function buildMessage(flags, defaults) {
|
|||
...(typeof flags.leadSessionId === 'string' && flags.leadSessionId.trim()
|
||||
? { leadSessionId: flags.leadSessionId.trim() }
|
||||
: {}),
|
||||
...(typeof flags.conversationId === 'string' && flags.conversationId.trim()
|
||||
? { conversationId: flags.conversationId.trim() }
|
||||
: {}),
|
||||
...(typeof flags.replyToConversationId === 'string' && flags.replyToConversationId.trim()
|
||||
? { replyToConversationId: flags.replyToConversationId.trim() }
|
||||
: {}),
|
||||
...(typeof flags.color === 'string' && flags.color.trim() ? { color: flags.color.trim() } : {}),
|
||||
...(typeof flags.toolSummary === 'string' && flags.toolSummary.trim()
|
||||
? { toolSummary: flags.toolSummary.trim() }
|
||||
|
|
|
|||
|
|
@ -60,7 +60,9 @@ describe('crossTeam module', () => {
|
|||
expect(inbox).toHaveLength(1);
|
||||
expect(inbox[0].source).toBe(CROSS_TEAM_SOURCE);
|
||||
expect(inbox[0].from).toBe('team-a.lead');
|
||||
expect(inbox[0].text).toContain(`[${CROSS_TEAM_PREFIX_TAG} team-a.lead | depth:0]`);
|
||||
expect(inbox[0].text).toContain(`[${CROSS_TEAM_PREFIX_TAG} team-a.lead | depth:0`);
|
||||
expect(inbox[0].conversationId).toBeTruthy();
|
||||
expect(inbox[0].text).toContain(`conversation:${inbox[0].conversationId}`);
|
||||
});
|
||||
|
||||
it('records outbox entry', () => {
|
||||
|
|
@ -84,6 +86,34 @@ describe('crossTeam module', () => {
|
|||
const outbox = controller.crossTeam.getCrossTeamOutbox();
|
||||
expect(outbox).toHaveLength(1);
|
||||
expect(outbox[0].toTeam).toBe('team-b');
|
||||
expect(outbox[0].conversationId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('preserves reply conversation metadata for explicit replies', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Answering the open question',
|
||||
replyToConversationId: 'conv-123',
|
||||
});
|
||||
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json');
|
||||
const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(inbox[0].conversationId).toBe('conv-123');
|
||||
expect(inbox[0].replyToConversationId).toBe('conv-123');
|
||||
expect(inbox[0].text).toContain('conversation:conv-123');
|
||||
expect(inbox[0].text).toContain('replyTo:conv-123');
|
||||
});
|
||||
|
||||
it('deduplicates the same recent cross-team request', () => {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ async function handleSend(
|
|||
fromTeam: String(req.fromTeam ?? ''),
|
||||
fromMember: String(req.fromMember ?? ''),
|
||||
toTeam: String(req.toTeam ?? ''),
|
||||
conversationId: typeof req.conversationId === 'string' ? req.conversationId : undefined,
|
||||
replyToConversationId:
|
||||
typeof req.replyToConversationId === 'string' ? req.replyToConversationId : undefined,
|
||||
text: String(req.text ?? ''),
|
||||
summary: typeof req.summary === 'string' ? req.summary : undefined,
|
||||
chainDepth: typeof req.chainDepth === 'number' ? req.chainDepth : undefined,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,19 @@ export class CrossTeamService {
|
|||
async send(request: CrossTeamSendRequest): Promise<CrossTeamSendResult> {
|
||||
const { fromTeam, fromMember, toTeam, text, summary } = request;
|
||||
const chainDepth = request.chainDepth ?? 0;
|
||||
const inferredReplyMeta =
|
||||
!request.conversationId && !request.replyToConversationId
|
||||
? (this.provisioning?.resolveCrossTeamReplyMetadata(fromTeam, toTeam) ?? null)
|
||||
: null;
|
||||
const replyToConversationId =
|
||||
request.replyToConversationId?.trim() ||
|
||||
inferredReplyMeta?.replyToConversationId ||
|
||||
undefined;
|
||||
const conversationId =
|
||||
request.conversationId?.trim() ||
|
||||
inferredReplyMeta?.conversationId ||
|
||||
replyToConversationId ||
|
||||
randomUUID();
|
||||
|
||||
// 1. Validate
|
||||
if (!TEAM_NAME_PATTERN.test(fromTeam)) {
|
||||
|
|
@ -71,13 +84,18 @@ export class CrossTeamService {
|
|||
|
||||
// 3. Format
|
||||
const from = `${fromTeam}.${fromMember}`;
|
||||
const formattedText = formatCrossTeamText(from, chainDepth, text);
|
||||
const formattedText = formatCrossTeamText(from, chainDepth, text, {
|
||||
conversationId,
|
||||
replyToConversationId,
|
||||
});
|
||||
const messageId = randomUUID();
|
||||
const outboxMessage: CrossTeamMessage = {
|
||||
messageId,
|
||||
fromTeam,
|
||||
fromMember,
|
||||
toTeam,
|
||||
conversationId,
|
||||
replyToConversationId,
|
||||
text,
|
||||
summary,
|
||||
chainDepth,
|
||||
|
|
@ -96,6 +114,8 @@ export class CrossTeamService {
|
|||
from,
|
||||
summary: summary ?? `Cross-team message from ${fromTeam}`,
|
||||
source: CROSS_TEAM_SOURCE,
|
||||
conversationId,
|
||||
replyToConversationId,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -113,6 +133,8 @@ export class CrossTeamService {
|
|||
to: `${toTeam}.${leadName}`,
|
||||
summary: summary ?? `Cross-team message to ${toTeam}`,
|
||||
source: CROSS_TEAM_SENT_SOURCE,
|
||||
conversationId,
|
||||
replyToConversationId,
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
logger.warn(
|
||||
|
|
|
|||
|
|
@ -103,6 +103,9 @@ export class TeamInboxReader {
|
|||
messageId: row.messageId,
|
||||
source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
|
||||
leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined,
|
||||
conversationId: typeof row.conversationId === 'string' ? row.conversationId : undefined,
|
||||
replyToConversationId:
|
||||
typeof row.replyToConversationId === 'string' ? row.replyToConversationId : undefined,
|
||||
attachments: Array.isArray(row.attachments) ? row.attachments : undefined,
|
||||
toolSummary: typeof row.toolSummary === 'string' ? row.toolSummary : undefined,
|
||||
toolCalls: Array.isArray(row.toolCalls)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ export class TeamInboxWriter {
|
|||
attachments: attachmentMeta?.length ? attachmentMeta : undefined,
|
||||
...(request.source && { source: request.source }),
|
||||
...(request.leadSessionId && { leadSessionId: request.leadSessionId }),
|
||||
...(request.conversationId && { conversationId: request.conversationId }),
|
||||
...(request.replyToConversationId && {
|
||||
replyToConversationId: request.replyToConversationId,
|
||||
}),
|
||||
};
|
||||
|
||||
await withFileLock(inboxPath, async () => {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,11 @@ import {
|
|||
AGENT_BLOCK_OPEN,
|
||||
stripAgentBlocks,
|
||||
} from '@shared/constants/agentBlocks';
|
||||
import { CROSS_TEAM_PREFIX_TAG } from '@shared/constants/crossTeam';
|
||||
import {
|
||||
CROSS_TEAM_PREFIX_TAG,
|
||||
CROSS_TEAM_SENT_SOURCE,
|
||||
parseCrossTeamPrefix,
|
||||
} from '@shared/constants/crossTeam';
|
||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
||||
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||
|
|
@ -176,6 +180,10 @@ interface ProvisioningRun {
|
|||
rejectOnce: (error: string) => void;
|
||||
timeoutHandle: NodeJS.Timeout;
|
||||
} | null;
|
||||
activeCrossTeamReplyHints: Array<{
|
||||
toTeam: string;
|
||||
conversationId: string;
|
||||
}>;
|
||||
/** Monotonic counter for individual lead assistant messages. */
|
||||
leadMsgSeq: number;
|
||||
/** Accumulated tool_use details between text messages. */
|
||||
|
|
@ -575,6 +583,8 @@ Communication protocol (CRITICAL — you are running headless, no one sees your
|
|||
- Keep cross-team requests high-signal: one focused request per topic, with clear next action and desired outcome.
|
||||
- Before sending a follow-up on the same topic, check "cross_team_get_outbox" so you do not resend the same request unnecessarily.
|
||||
- If you receive a message that is clearly from another team (for example prefixed with "[${CROSS_TEAM_PREFIX_TAG} ...]"), treat it as an actionable cross-team request and respond to the originating team with "cross_team_send" when a reply, decision, or status update is needed.
|
||||
- Cross-team requests may include a stable conversationId in their metadata. When you reply to that thread, preserve the same conversationId and pass replyToConversationId with that same value so the system can correlate the reply reliably.
|
||||
- If the relay prompt shows explicit cross-team reply metadata/instructions for a message, follow that metadata exactly when calling "cross_team_send".
|
||||
- When a cross-team request arrives, do NOT appear silent: first emit a brief plain-text status update visible in your own team's Messages/Activity (for example: "Accepted cross-team request from @other-team. Investigating and delegating now."), then do the research, task creation, or delegation work.
|
||||
- For cross-team work, your canonical progress trail should be team-visible first. Use plain text updates, task comments, and task state changes so your own team can see what is happening.
|
||||
- Do not wait silently on another team: if cross-team coordination is blocking progress, send the request promptly, then continue any useful local work that does not depend on that answer.
|
||||
|
|
@ -1847,6 +1857,7 @@ export class TeamProvisioningService {
|
|||
isLaunch: false,
|
||||
fsPhase: 'waiting_config',
|
||||
leadRelayCapture: null,
|
||||
activeCrossTeamReplyHints: [],
|
||||
leadMsgSeq: 0,
|
||||
pendingToolCalls: [],
|
||||
lastLeadTextEmitMs: 0,
|
||||
|
|
@ -2169,6 +2180,7 @@ export class TeamProvisioningService {
|
|||
isLaunch: true,
|
||||
fsPhase: 'waiting_members',
|
||||
leadRelayCapture: null,
|
||||
activeCrossTeamReplyHints: [],
|
||||
leadMsgSeq: 0,
|
||||
pendingToolCalls: [],
|
||||
lastLeadTextEmitMs: 0,
|
||||
|
|
@ -2556,10 +2568,26 @@ export class TeamProvisioningService {
|
|||
`Messages:`,
|
||||
...batch.flatMap((m, idx) => {
|
||||
const summaryLine = m.summary?.trim() ? `Summary: ${m.summary.trim()}` : null;
|
||||
const crossTeamMeta =
|
||||
m.source === 'cross_team'
|
||||
? {
|
||||
origin: parseCrossTeamPrefix(m.text),
|
||||
sourceTeam: m.from.includes('.') ? m.from.split('.', 1)[0] : null,
|
||||
}
|
||||
: null;
|
||||
const conversationId = m.conversationId ?? crossTeamMeta?.origin?.conversationId;
|
||||
const replyInstructions =
|
||||
crossTeamMeta?.sourceTeam && conversationId
|
||||
? [
|
||||
` Cross-team conversationId: ${conversationId}`,
|
||||
` If replying with cross_team_send to ${crossTeamMeta.sourceTeam}, set conversationId="${conversationId}" and replyToConversationId="${conversationId}".`,
|
||||
]
|
||||
: [];
|
||||
return [
|
||||
`${idx + 1}) From: ${m.from || 'unknown'}`,
|
||||
` Timestamp: ${m.timestamp}`,
|
||||
...(summaryLine ? [` ${summaryLine}`] : []),
|
||||
...replyInstructions,
|
||||
` Text:`,
|
||||
...m.text.split('\n').map((line) => ` ${line}`),
|
||||
``,
|
||||
|
|
@ -2657,22 +2685,33 @@ export class TeamProvisioningService {
|
|||
if (unread.length === 0) return 0;
|
||||
|
||||
// Ignore (and auto-mark read) internal coordination noise like idle/shutdown messages.
|
||||
// These frequently appear when teammates are idle/available and should not prompt
|
||||
// the lead to respond with "No action needed."
|
||||
const noiseUnread = unread.filter((m) => isInboxNoiseMessage(m.text));
|
||||
if (noiseUnread.length > 0) {
|
||||
// Also ignore local sender-copy rows for cross-team traffic: those exist only so the UI
|
||||
// can show outbound activity and must not be re-injected into the live lead as new work.
|
||||
const ignoredUnread = unread.filter(
|
||||
(m) => isInboxNoiseMessage(m.text) || m.source === CROSS_TEAM_SENT_SOURCE
|
||||
);
|
||||
if (ignoredUnread.length > 0) {
|
||||
try {
|
||||
await this.markInboxMessagesRead(teamName, leadName, noiseUnread);
|
||||
await this.markInboxMessagesRead(teamName, leadName, ignoredUnread);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
const actionableUnread = unread.filter((m) => !isInboxNoiseMessage(m.text));
|
||||
const actionableUnread = unread.filter(
|
||||
(m) => !isInboxNoiseMessage(m.text) && m.source !== CROSS_TEAM_SENT_SOURCE
|
||||
);
|
||||
if (actionableUnread.length === 0) return 0;
|
||||
|
||||
const MAX_RELAY = 10;
|
||||
const batch = actionableUnread.slice(0, MAX_RELAY);
|
||||
run.activeCrossTeamReplyHints = batch.flatMap((m) => {
|
||||
if (m.source !== 'cross_team') return [];
|
||||
const sourceTeam = m.from.includes('.') ? m.from.split('.', 1)[0] : '';
|
||||
const conversationId = m.conversationId ?? parseCrossTeamPrefix(m.text)?.conversationId;
|
||||
if (!sourceTeam || !conversationId) return [];
|
||||
return [{ toTeam: sourceTeam, conversationId }];
|
||||
});
|
||||
|
||||
const message = [
|
||||
`You have new inbox messages addressed to you (team lead "${leadName}").`,
|
||||
|
|
@ -3048,6 +3087,24 @@ export class TeamProvisioningService {
|
|||
this.liveLeadProcessMessages.set(teamName, list);
|
||||
}
|
||||
|
||||
resolveCrossTeamReplyMetadata(
|
||||
teamName: string,
|
||||
toTeam: string
|
||||
): { conversationId: string; replyToConversationId: string } | null {
|
||||
const runId = this.activeByTeam.get(teamName);
|
||||
if (!runId) return null;
|
||||
const run = this.runs.get(runId);
|
||||
if (!run || run.activeCrossTeamReplyHints.length === 0) return null;
|
||||
|
||||
const matches = run.activeCrossTeamReplyHints.filter((hint) => hint.toTeam === toTeam);
|
||||
if (matches.length !== 1) return null;
|
||||
|
||||
return {
|
||||
conversationId: matches[0].conversationId,
|
||||
replyToConversationId: matches[0].conversationId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an InboxMessage from assistant text and push it into the live cache.
|
||||
* Used for both pre-ready (provisioning) and post-ready assistant text.
|
||||
|
|
@ -3370,6 +3427,7 @@ export class TeamProvisioningService {
|
|||
capture.resolveOnce(combined);
|
||||
}
|
||||
// Clear silent relay flag after any successful turn.
|
||||
run.activeCrossTeamReplyHints = [];
|
||||
run.silentUserDmForward = null;
|
||||
if (run.silentUserDmForwardClearHandle) {
|
||||
clearTimeout(run.silentUserDmForwardClearHandle);
|
||||
|
|
@ -3398,6 +3456,7 @@ export class TeamProvisioningService {
|
|||
run.leadRelayCapture.rejectOnce(errorMsg);
|
||||
}
|
||||
// Clear silent relay flag after any errored turn.
|
||||
run.activeCrossTeamReplyHints = [];
|
||||
run.silentUserDmForward = null;
|
||||
if (run.silentUserDmForwardClearHandle) {
|
||||
clearTimeout(run.silentUserDmForwardClearHandle);
|
||||
|
|
@ -4129,6 +4188,7 @@ export class TeamProvisioningService {
|
|||
this.activeByTeam.delete(run.teamName);
|
||||
this.leadInboxRelayInFlight.delete(run.teamName);
|
||||
this.relayedLeadInboxMessageIds.delete(run.teamName);
|
||||
run.activeCrossTeamReplyHints = [];
|
||||
for (const key of Array.from(this.memberInboxRelayInFlight.keys())) {
|
||||
if (key.startsWith(`${run.teamName}:`)) {
|
||||
this.memberInboxRelayInFlight.delete(key);
|
||||
|
|
|
|||
|
|
@ -78,6 +78,9 @@ export class TeamSentMessagesStore {
|
|||
attachments: Array.isArray(row.attachments) ? row.attachments : undefined,
|
||||
source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
|
||||
leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined,
|
||||
conversationId: typeof row.conversationId === 'string' ? row.conversationId : undefined,
|
||||
replyToConversationId:
|
||||
typeof row.replyToConversationId === 'string' ? row.replyToConversationId : undefined,
|
||||
toolSummary: typeof row.toolSummary === 'string' ? row.toolSummary : undefined,
|
||||
toolCalls: Array.isArray(row.toolCalls)
|
||||
? (row.toolCalls as unknown[])
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { cn } from '@renderer/lib/utils';
|
|||
import { useStore } from '@renderer/store';
|
||||
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
||||
import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath';
|
||||
import { computePendingCrossTeamReplies } from '@renderer/utils/crossTeamPendingReplies';
|
||||
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
||||
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
|
|
@ -640,6 +641,32 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]);
|
||||
|
||||
const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]);
|
||||
const pendingCrossTeamReplies = useMemo(
|
||||
() => computePendingCrossTeamReplies(data?.messages ?? []),
|
||||
[data?.messages]
|
||||
);
|
||||
|
||||
/** Whether the Status block has any visible items (pending replies or active tasks). */
|
||||
const hasStatusItems = useMemo(() => {
|
||||
const members = data?.members ?? [];
|
||||
const tasks = data?.tasks ?? [];
|
||||
|
||||
// Check pending replies (mirrors PendingRepliesBlock logic)
|
||||
const hasPendingReplies = Object.keys(pendingRepliesByMember).some((name) =>
|
||||
members.some((m) => m.name === name)
|
||||
);
|
||||
if (hasPendingReplies) return true;
|
||||
if (pendingCrossTeamReplies.length > 0) return true;
|
||||
|
||||
// Check active tasks (mirrors ActiveTasksBlock logic)
|
||||
const tMap = new Map(tasks.map((t) => [t.id, t]));
|
||||
return members.some((m) => {
|
||||
if (!m.currentTaskId) return false;
|
||||
const task = tMap.get(m.currentTaskId);
|
||||
if (task && (task.reviewState === 'approved' || task.status === 'completed')) return false;
|
||||
return true;
|
||||
});
|
||||
}, [data?.members, data?.tasks, pendingRepliesByMember, pendingCrossTeamReplies.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || Object.keys(pendingRepliesByMember).length === 0) return;
|
||||
|
|
@ -1559,35 +1586,42 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
});
|
||||
}}
|
||||
/>
|
||||
<div className="mb-[35px]">
|
||||
<button
|
||||
type="button"
|
||||
className="mb-1.5 flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => setStatusBlockCollapsed((prev) => !prev)}
|
||||
aria-label={statusBlockCollapsed ? 'Expand status' : 'Collapse status'}
|
||||
>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={`shrink-0 transition-transform duration-150 ${statusBlockCollapsed ? '' : 'rotate-90'}`}
|
||||
/>
|
||||
Status
|
||||
</button>
|
||||
{!statusBlockCollapsed && (
|
||||
<>
|
||||
<PendingRepliesBlock
|
||||
members={data.members}
|
||||
pendingRepliesByMember={pendingRepliesByMember}
|
||||
onMemberClick={setSelectedMember}
|
||||
/>
|
||||
<ActiveTasksBlock
|
||||
members={data.members}
|
||||
tasks={data.tasks}
|
||||
onMemberClick={setSelectedMember}
|
||||
onTaskClick={setSelectedTask}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Status block: button floats right (absolute, no layout impact);
|
||||
expanded content renders full-width in normal flow. */}
|
||||
{hasStatusItems && (
|
||||
<>
|
||||
<div className="relative h-0">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute -top-[19px] right-0 z-10 flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => setStatusBlockCollapsed((prev) => !prev)}
|
||||
aria-label={statusBlockCollapsed ? 'Expand status' : 'Collapse status'}
|
||||
>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={`shrink-0 transition-transform duration-150 ${statusBlockCollapsed ? '' : 'rotate-90'}`}
|
||||
/>
|
||||
Status
|
||||
</button>
|
||||
</div>
|
||||
{!statusBlockCollapsed && (
|
||||
<div className="mt-5">
|
||||
<PendingRepliesBlock
|
||||
members={data.members}
|
||||
pendingRepliesByMember={pendingRepliesByMember}
|
||||
pendingCrossTeamReplies={pendingCrossTeamReplies}
|
||||
onMemberClick={setSelectedMember}
|
||||
/>
|
||||
<ActiveTasksBlock
|
||||
members={data.members}
|
||||
tasks={data.tasks}
|
||||
onMemberClick={setSelectedMember}
|
||||
onTaskClick={setSelectedTask}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ActivityTimeline
|
||||
messages={filteredMessages}
|
||||
teamName={teamName}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
|||
import { linkifyMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
|
||||
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Reply } from 'lucide-react';
|
||||
|
||||
import { linkifyTaskIdsInMarkdown } from './ActivityItem';
|
||||
|
|
@ -485,6 +486,19 @@ export const LeadThoughtsGroupRow = ({
|
|||
return calls.length > 0 ? calls : undefined;
|
||||
}, [thoughts]);
|
||||
|
||||
// Extract text preview for header: use newest thought's text, fallback through group
|
||||
const headerTextPreview = useMemo(() => {
|
||||
// Try newest first (most relevant), then scan for any text
|
||||
for (const t of thoughts) {
|
||||
if (t.text && t.text.trim()) {
|
||||
const plain = extractMarkdownPlainText(t.text);
|
||||
const firstLine = plain.split('\n').find((l) => l.trim().length > 0) ?? '';
|
||||
return firstLine.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [thoughts]);
|
||||
|
||||
// Live = process alive AND (lead is in active turn OR context recently updated OR fresh thought)
|
||||
const computeIsLive = useCallback(
|
||||
() =>
|
||||
|
|
@ -716,7 +730,26 @@ export const LeadThoughtsGroupRow = ({
|
|||
? formatTime(oldest.timestamp)
|
||||
: `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`}
|
||||
</span>
|
||||
{totalToolSummary && (
|
||||
{!isBodyVisible && headerTextPreview ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="min-w-0 flex-1 cursor-default truncate text-[10px]"
|
||||
style={{ color: CARD_TEXT_LIGHT }}
|
||||
>
|
||||
{headerTextPreview}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{totalToolSummary ? (
|
||||
<TooltipContent side="bottom" className="max-w-[420px] font-mono text-[11px]">
|
||||
<ToolSummaryTooltipContent
|
||||
toolCalls={allToolCalls}
|
||||
toolSummary={totalToolSummary}
|
||||
/>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
) : totalToolSummary ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
|
|
@ -730,23 +763,9 @@ export const LeadThoughtsGroupRow = ({
|
|||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Last thought preview when body is collapsed */}
|
||||
{!isBodyVisible && newest.text && (
|
||||
<div
|
||||
className="truncate border-t px-3 py-1 text-[11px]"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
color: CARD_TEXT_LIGHT,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
{newest.text.slice(0, 200)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scrollable body — live thoughts follow bottom unless user scrolls up */}
|
||||
{isBodyVisible ? (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -3,31 +3,49 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
|
|||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { agentAvatarUrl, buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
import type { ResolvedTeamMember } from '@shared/types';
|
||||
|
||||
export interface PendingCrossTeamReply {
|
||||
teamName: string;
|
||||
sentAtMs: number;
|
||||
}
|
||||
|
||||
interface PendingRepliesBlockProps {
|
||||
members: ResolvedTeamMember[];
|
||||
pendingRepliesByMember: Record<string, number>;
|
||||
pendingCrossTeamReplies?: PendingCrossTeamReply[];
|
||||
onMemberClick?: (member: ResolvedTeamMember) => void;
|
||||
}
|
||||
|
||||
export const PendingRepliesBlock = ({
|
||||
members,
|
||||
pendingRepliesByMember,
|
||||
pendingCrossTeamReplies = [],
|
||||
onMemberClick,
|
||||
}: PendingRepliesBlockProps): React.JSX.Element | null => {
|
||||
const { isLight } = useTheme();
|
||||
const colorMap = buildMemberColorMap(members);
|
||||
const pending = Object.entries(pendingRepliesByMember)
|
||||
const memberPending = Object.entries(pendingRepliesByMember)
|
||||
.map(([name, sentAtMs]) => ({
|
||||
kind: 'member' as const,
|
||||
member: members.find((m) => m.name === name) ?? null,
|
||||
name,
|
||||
sentAtMs,
|
||||
}))
|
||||
.filter((p): p is { member: ResolvedTeamMember; name: string; sentAtMs: number } => !!p.member)
|
||||
.sort((a, b) => b.sentAtMs - a.sentAtMs);
|
||||
.filter(
|
||||
(p): p is { kind: 'member'; member: ResolvedTeamMember; name: string; sentAtMs: number } =>
|
||||
!!p.member
|
||||
);
|
||||
const teamPending = pendingCrossTeamReplies.map((entry) => ({
|
||||
kind: 'team' as const,
|
||||
teamName: entry.teamName,
|
||||
sentAtMs: entry.sentAtMs,
|
||||
}));
|
||||
const pending = [...memberPending, ...teamPending].sort((a, b) => b.sentAtMs - a.sentAtMs);
|
||||
|
||||
if (pending.length === 0) return null;
|
||||
|
||||
|
|
@ -36,16 +54,89 @@ export const PendingRepliesBlock = ({
|
|||
<p className="text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Awaiting replies
|
||||
</p>
|
||||
{pending.map(({ member, sentAtMs }) => {
|
||||
const colors = getTeamColorSet(colorMap.get(member.name) ?? '');
|
||||
const roleLabel = formatAgentRole(
|
||||
member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined)
|
||||
);
|
||||
const since = formatDistanceToNowStrict(sentAtMs, { addSuffix: true });
|
||||
{pending.map((entry) => {
|
||||
const since = formatDistanceToNowStrict(entry.sentAtMs, { addSuffix: true });
|
||||
|
||||
if (entry.kind === 'member') {
|
||||
const { member } = entry;
|
||||
const colors = getTeamColorSet(colorMap.get(member.name) ?? '');
|
||||
const roleLabel = formatAgentRole(
|
||||
member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined)
|
||||
);
|
||||
|
||||
return (
|
||||
<article
|
||||
key={`pending-reply:${member.name}:${entry.sentAtMs}`}
|
||||
className="activity-card-enter-animate overflow-hidden rounded-md"
|
||||
style={{
|
||||
backgroundColor: CARD_BG,
|
||||
border: CARD_BORDER_STYLE,
|
||||
borderLeft: `3px solid ${colors.border}`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<span className="relative inline-flex shrink-0">
|
||||
<img
|
||||
src={agentAvatarUrl(member.name, 24)}
|
||||
alt=""
|
||||
className="size-5 rounded-full bg-[var(--color-surface-raised)]"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="absolute -bottom-0.5 -right-0.5 flex h-2.5 w-2.5">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-70" />
|
||||
<span className="relative inline-flex size-full rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
</span>
|
||||
{onMemberClick ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
||||
style={{
|
||||
backgroundColor: getThemedBadge(colors, isLight),
|
||||
color: colors.text,
|
||||
border: `1px solid ${colors.border}40`,
|
||||
}}
|
||||
onClick={() => onMemberClick(member)}
|
||||
title="Open member"
|
||||
>
|
||||
{member.name}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
||||
style={{
|
||||
backgroundColor: getThemedBadge(colors, isLight),
|
||||
color: colors.text,
|
||||
border: `1px solid ${colors.border}40`,
|
||||
}}
|
||||
>
|
||||
{member.name}
|
||||
</span>
|
||||
)}
|
||||
{roleLabel ? (
|
||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
{roleLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-[10px]"
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
title="Message sent, awaiting reply"
|
||||
>
|
||||
awaiting reply
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
{since}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const colors = nameColorSet(entry.teamName, isLight);
|
||||
return (
|
||||
<article
|
||||
key={`pending-reply:${member.name}:${sentAtMs}`}
|
||||
key={`pending-reply:team:${entry.teamName}:${entry.sentAtMs}`}
|
||||
className="activity-card-enter-animate overflow-hidden rounded-md"
|
||||
style={{
|
||||
backgroundColor: CARD_BG,
|
||||
|
|
@ -54,53 +145,31 @@ export const PendingRepliesBlock = ({
|
|||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<span className="relative inline-flex shrink-0">
|
||||
<img
|
||||
src={agentAvatarUrl(member.name, 24)}
|
||||
alt=""
|
||||
className="size-5 rounded-full bg-[var(--color-surface-raised)]"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="relative inline-flex shrink-0 items-center justify-center rounded-full bg-[var(--color-surface-raised)] p-1">
|
||||
<Users size={12} style={{ color: colors.border }} />
|
||||
<span className="absolute -bottom-0.5 -right-0.5 flex h-2.5 w-2.5">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-70" />
|
||||
<span className="relative inline-flex size-full rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
</span>
|
||||
{onMemberClick ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
||||
style={{
|
||||
backgroundColor: getThemedBadge(colors, isLight),
|
||||
color: colors.text,
|
||||
border: `1px solid ${colors.border}40`,
|
||||
}}
|
||||
onClick={() => onMemberClick(member)}
|
||||
title="Open member"
|
||||
>
|
||||
{member.name}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
||||
style={{
|
||||
backgroundColor: getThemedBadge(colors, isLight),
|
||||
color: colors.text,
|
||||
border: `1px solid ${colors.border}40`,
|
||||
}}
|
||||
>
|
||||
{member.name}
|
||||
</span>
|
||||
)}
|
||||
{roleLabel ? (
|
||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
{roleLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
||||
style={{
|
||||
backgroundColor: getThemedBadge(colors, isLight),
|
||||
color: colors.text,
|
||||
border: `1px solid ${colors.border}40`,
|
||||
}}
|
||||
title={entry.teamName}
|
||||
>
|
||||
{entry.teamName}
|
||||
</span>
|
||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
external team
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-[10px]"
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
title="Message sent, awaiting reply"
|
||||
title="Cross-team message sent, awaiting reply"
|
||||
>
|
||||
awaiting reply
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -90,11 +90,11 @@ const diffSpecificTheme = EditorView.theme({
|
|||
},
|
||||
'.cm-insertedLine': { backgroundColor: 'var(--diff-cm-changed-bg) !important' },
|
||||
'.cm-deletedLine': { backgroundColor: 'var(--diff-cm-deleted-bg) !important' },
|
||||
// Merge toolbar — absolute, Y set dynamically by mousemove handler
|
||||
// Merge toolbar — absolute, Y and right set dynamically by mousemove handler
|
||||
'.cm-deletedChunk .cm-chunkButtons': {
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
insetInlineEnd: '8px',
|
||||
right: '8px',
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
|
|
@ -467,6 +467,17 @@ export const CodeMirrorDiffView = ({
|
|||
|
||||
// Merge toolbar: always visible for nearest chunk, follows cursor when hovering on chunk
|
||||
if (showMergeControls) {
|
||||
// Helper: pin chunkButtons to right edge of visible viewport, accounting for horizontal scroll
|
||||
const pinToViewportRight = (
|
||||
btnContainer: HTMLElement,
|
||||
parentRect: DOMRect,
|
||||
scroller: Element
|
||||
): void => {
|
||||
const scrollLeft = scroller.scrollLeft;
|
||||
// When scrolled right, shift the button left so it stays visible
|
||||
btnContainer.style.right = `${-scrollLeft + 8}px`;
|
||||
};
|
||||
|
||||
// Helper: position a chunkButtons container so it's below the change block,
|
||||
// but clamped to the visible viewport if that would be off-screen.
|
||||
const positionAtBottom = (chunkEl: Element, scroller: Element): void => {
|
||||
|
|
@ -482,6 +493,7 @@ export const CodeMirrorDiffView = ({
|
|||
targetY = scrollerRect.bottom - tbHeight;
|
||||
}
|
||||
btnContainer.style.top = `${targetY - parentRect.top}px`;
|
||||
pinToViewportRight(btnContainer, parentRect, scroller);
|
||||
};
|
||||
|
||||
const positionAtCursor = (chunkEl: Element, clientY: number, scroller: Element): void => {
|
||||
|
|
@ -499,6 +511,7 @@ export const CodeMirrorDiffView = ({
|
|||
targetY = scrollerRect.top;
|
||||
}
|
||||
btnContainer.style.top = `${targetY - parentRect.top}px`;
|
||||
pinToViewportRight(btnContainer, parentRect, scroller);
|
||||
};
|
||||
|
||||
// Find which chunk index the mouse is directly over (deleted or inserted area)
|
||||
|
|
@ -581,6 +594,20 @@ export const CodeMirrorDiffView = ({
|
|||
}
|
||||
return false;
|
||||
},
|
||||
scroll(_event, view) {
|
||||
// Reposition active toolbar on horizontal scroll so buttons stay at viewport edge
|
||||
const activeToolbar = view.dom.querySelector('.cm-merge-toolbar-active');
|
||||
if (activeToolbar) {
|
||||
const chunkEl = activeToolbar.closest('.cm-deletedChunk');
|
||||
if (chunkEl) {
|
||||
const btnContainer = chunkEl.querySelector<HTMLElement>('.cm-chunkButtons');
|
||||
if (btnContainer) {
|
||||
pinToViewportRight(btnContainer, chunkEl.getBoundingClientRect(), view.scrollDOM);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -219,6 +219,8 @@ const portionCollapseTheme = EditorView.theme({
|
|||
minHeight: '28px',
|
||||
cursor: 'default',
|
||||
userSelect: 'none',
|
||||
position: 'sticky',
|
||||
left: '0',
|
||||
},
|
||||
|
||||
'.cm-portion-collapse-text': {
|
||||
|
|
|
|||
82
src/renderer/utils/crossTeamPendingReplies.ts
Normal file
82
src/renderer/utils/crossTeamPendingReplies.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import type { InboxMessage } from '@shared/types';
|
||||
|
||||
export interface PendingCrossTeamReply {
|
||||
teamName: string;
|
||||
sentAtMs: number;
|
||||
conversationId?: string;
|
||||
}
|
||||
|
||||
function parseQualifiedTeamName(value: string | undefined): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const dot = trimmed.indexOf('.');
|
||||
if (dot <= 0) return null;
|
||||
return trimmed.slice(0, dot);
|
||||
}
|
||||
|
||||
export function computePendingCrossTeamReplies(
|
||||
messages: InboxMessage[] | null | undefined
|
||||
): PendingCrossTeamReply[] {
|
||||
if (!messages || messages.length === 0) return [];
|
||||
|
||||
const latestSentByTeam = new Map<string, number>();
|
||||
const latestInboundByTeam = new Map<string, number>();
|
||||
const latestSentByConversation = new Map<
|
||||
string,
|
||||
{ teamName: string; sentAtMs: number; conversationId: string }
|
||||
>();
|
||||
const latestInboundByConversation = new Map<string, number>();
|
||||
|
||||
for (const message of messages) {
|
||||
const timestampMs = Date.parse(message.timestamp);
|
||||
if (!Number.isFinite(timestampMs)) continue;
|
||||
|
||||
if (message.source === 'cross_team_sent') {
|
||||
const teamName = parseQualifiedTeamName(message.to);
|
||||
if (!teamName) continue;
|
||||
if (message.conversationId) {
|
||||
const existing = latestSentByConversation.get(message.conversationId);
|
||||
if (!existing || timestampMs > existing.sentAtMs) {
|
||||
latestSentByConversation.set(message.conversationId, {
|
||||
teamName,
|
||||
sentAtMs: timestampMs,
|
||||
conversationId: message.conversationId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
latestSentByTeam.set(teamName, Math.max(latestSentByTeam.get(teamName) ?? 0, timestampMs));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.source === 'cross_team') {
|
||||
const teamName = parseQualifiedTeamName(message.from);
|
||||
if (!teamName) continue;
|
||||
if (message.conversationId) {
|
||||
latestInboundByConversation.set(
|
||||
message.conversationId,
|
||||
Math.max(latestInboundByConversation.get(message.conversationId) ?? 0, timestampMs)
|
||||
);
|
||||
} else {
|
||||
latestInboundByTeam.set(
|
||||
teamName,
|
||||
Math.max(latestInboundByTeam.get(teamName) ?? 0, timestampMs)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const exactPending = Array.from(latestSentByConversation.values()).filter(
|
||||
({ conversationId, sentAtMs }) =>
|
||||
sentAtMs > (latestInboundByConversation.get(conversationId) ?? 0)
|
||||
);
|
||||
const teamsCoveredExactly = new Set(exactPending.map((entry) => entry.teamName));
|
||||
const legacyPending = Array.from(latestSentByTeam.entries())
|
||||
.filter(([teamName]) => !teamsCoveredExactly.has(teamName))
|
||||
.filter(([teamName, sentAtMs]) => sentAtMs > (latestInboundByTeam.get(teamName) ?? 0))
|
||||
.map(([teamName, sentAtMs]) => ({ teamName, sentAtMs }))
|
||||
.sort((a, b) => b.sentAtMs - a.sentAtMs);
|
||||
|
||||
return [...exactPending, ...legacyPending].sort((a, b) => b.sentAtMs - a.sentAtMs);
|
||||
}
|
||||
|
|
@ -5,21 +5,68 @@
|
|||
/** Prefix tag that wraps cross-team metadata in stored message text. */
|
||||
export const CROSS_TEAM_PREFIX_TAG = 'Cross-team from';
|
||||
|
||||
/** Build the full prefix line: `[Cross-team from team.member | depth:N]` */
|
||||
export function formatCrossTeamPrefix(from: string, chainDepth: number): string {
|
||||
return `[${CROSS_TEAM_PREFIX_TAG} ${from} | depth:${chainDepth}]`;
|
||||
export interface CrossTeamPrefixMeta {
|
||||
conversationId?: string;
|
||||
replyToConversationId?: string;
|
||||
}
|
||||
|
||||
export interface ParsedCrossTeamPrefix extends CrossTeamPrefixMeta {
|
||||
from: string;
|
||||
chainDepth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full prefix line:
|
||||
* `[Cross-team from team.member | depth:N | conversation:abc | replyTo:def]`
|
||||
*/
|
||||
export function formatCrossTeamPrefix(
|
||||
from: string,
|
||||
chainDepth: number,
|
||||
meta?: CrossTeamPrefixMeta
|
||||
): string {
|
||||
const parts = [`${CROSS_TEAM_PREFIX_TAG} ${from}`, `depth:${chainDepth}`];
|
||||
if (meta?.conversationId) {
|
||||
parts.push(`conversation:${meta.conversationId}`);
|
||||
}
|
||||
if (meta?.replyToConversationId) {
|
||||
parts.push(`replyTo:${meta.replyToConversationId}`);
|
||||
}
|
||||
return `[${parts.join(' | ')}]`;
|
||||
}
|
||||
|
||||
/** Format the full message text with prefix + body. */
|
||||
export function formatCrossTeamText(from: string, chainDepth: number, text: string): string {
|
||||
return `${formatCrossTeamPrefix(from, chainDepth)}\n${text}`;
|
||||
export function formatCrossTeamText(
|
||||
from: string,
|
||||
chainDepth: number,
|
||||
text: string,
|
||||
meta?: CrossTeamPrefixMeta
|
||||
): string {
|
||||
return `${formatCrossTeamPrefix(from, chainDepth, meta)}\n${text}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex that matches the cross-team prefix line at the start of a message.
|
||||
* Captures nothing — use `.replace(CROSS_TEAM_PREFIX_RE, '')` to strip it.
|
||||
* Compatible with legacy rows that only contain `depth`.
|
||||
*/
|
||||
export const CROSS_TEAM_PREFIX_RE = /^\[Cross-team from [^\]]+\]\n?/;
|
||||
export const CROSS_TEAM_PREFIX_RE =
|
||||
/^\[Cross-team from (?<from>[^\]|]+?) \| depth:(?<depth>\d+)(?: \| conversation:(?<conversationId>[^\]|]+))?(?: \| replyTo:(?<replyToConversationId>[^\]|]+))?\]\n?/;
|
||||
|
||||
/** Parse metadata from a cross-team prefix line. */
|
||||
export function parseCrossTeamPrefix(text: string): ParsedCrossTeamPrefix | null {
|
||||
const match = text.match(CROSS_TEAM_PREFIX_RE);
|
||||
if (!match?.groups) return null;
|
||||
|
||||
const from = match.groups.from?.trim();
|
||||
const chainDepth = Number.parseInt(match.groups.depth ?? '', 10);
|
||||
if (!from || !Number.isFinite(chainDepth)) return null;
|
||||
|
||||
return {
|
||||
from,
|
||||
chainDepth,
|
||||
conversationId: match.groups.conversationId?.trim() || undefined,
|
||||
replyToConversationId: match.groups.replyToConversationId?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Strip the cross-team prefix from message text (for UI display). */
|
||||
export function stripCrossTeamPrefix(text: string): string {
|
||||
|
|
|
|||
|
|
@ -256,6 +256,10 @@ export interface InboxMessage {
|
|||
attachments?: AttachmentMeta[];
|
||||
/** Lead session ID that produced this message (for session boundary detection). */
|
||||
leadSessionId?: string;
|
||||
/** Stable cross-team thread ID shared across request/reply turns. */
|
||||
conversationId?: string;
|
||||
/** Explicit parent conversation/message reference for replies. */
|
||||
replyToConversationId?: string;
|
||||
/** Tool usage summary from assistant message, e.g. "3 tools (2 Read, Bash)" */
|
||||
toolSummary?: string;
|
||||
/** Structured tool call details for tooltip display. */
|
||||
|
|
@ -273,6 +277,8 @@ export interface SendMessageRequest {
|
|||
source?: InboxMessage['source'];
|
||||
/** Lead session ID for session boundary detection. */
|
||||
leadSessionId?: string;
|
||||
conversationId?: string;
|
||||
replyToConversationId?: string;
|
||||
}
|
||||
|
||||
export interface SendMessageResult {
|
||||
|
|
@ -609,6 +615,8 @@ export interface CrossTeamMessage {
|
|||
fromTeam: string;
|
||||
fromMember: string;
|
||||
toTeam: string;
|
||||
conversationId?: string;
|
||||
replyToConversationId?: string;
|
||||
text: string;
|
||||
summary?: string;
|
||||
chainDepth: number;
|
||||
|
|
@ -619,6 +627,8 @@ export interface CrossTeamSendRequest {
|
|||
fromTeam: string;
|
||||
fromMember: string;
|
||||
toTeam: string;
|
||||
conversationId?: string;
|
||||
replyToConversationId?: string;
|
||||
text: string;
|
||||
summary?: string;
|
||||
chainDepth?: number;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { CrossTeamService } from '@main/services/team/CrossTeamService';
|
|||
import {
|
||||
CROSS_TEAM_SENT_SOURCE,
|
||||
CROSS_TEAM_SOURCE,
|
||||
formatCrossTeamText,
|
||||
parseCrossTeamPrefix,
|
||||
} from '@shared/constants/crossTeam';
|
||||
|
||||
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||
|
|
@ -55,6 +55,7 @@ describe('CrossTeamService', () => {
|
|||
let provisioning: {
|
||||
isTeamAlive: ReturnType<typeof vi.fn>;
|
||||
relayLeadInboxMessages: ReturnType<typeof vi.fn>;
|
||||
resolveCrossTeamReplyMetadata: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -71,6 +72,7 @@ describe('CrossTeamService', () => {
|
|||
provisioning = {
|
||||
isTeamAlive: vi.fn().mockReturnValue(false),
|
||||
relayLeadInboxMessages: vi.fn().mockResolvedValue(0),
|
||||
resolveCrossTeamReplyMetadata: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
|
||||
service = new CrossTeamService(
|
||||
|
|
@ -99,7 +101,11 @@ describe('CrossTeamService', () => {
|
|||
expect(req.member).toBe('team-lead');
|
||||
expect(req.source).toBe(CROSS_TEAM_SOURCE);
|
||||
expect(req.from).toBe('team-a.lead');
|
||||
expect(req.text).toBe(formatCrossTeamText('team-a.lead', 0, 'Hello from team-a'));
|
||||
expect(req.text).toContain('Hello from team-a');
|
||||
const prefix = parseCrossTeamPrefix(req.text);
|
||||
expect(prefix?.from).toBe('team-a.lead');
|
||||
expect(prefix?.chainDepth).toBe(0);
|
||||
expect(prefix?.conversationId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('writes sender copy to fromTeam inbox as user_sent', async () => {
|
||||
|
|
@ -116,6 +122,45 @@ describe('CrossTeamService', () => {
|
|||
expect(senderReq.source).toBe(CROSS_TEAM_SENT_SOURCE);
|
||||
expect(senderReq.to).toBe('team-b.team-lead');
|
||||
expect(senderReq.text).toBe('Hello from team-a');
|
||||
expect(senderReq.conversationId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('reuses replyToConversationId as the conversationId for replies', async () => {
|
||||
await service.send(
|
||||
makeRequest({
|
||||
replyToConversationId: 'conv-123',
|
||||
text: 'Here is the answer',
|
||||
})
|
||||
);
|
||||
|
||||
const [, req] = inboxWriter.sendMessage.mock.calls[0];
|
||||
expect(req.conversationId).toBe('conv-123');
|
||||
expect(req.replyToConversationId).toBe('conv-123');
|
||||
});
|
||||
|
||||
it('auto-infers reply conversation metadata from provisioning hint when omitted', async () => {
|
||||
provisioning.resolveCrossTeamReplyMetadata.mockReturnValue({
|
||||
conversationId: 'conv-auto',
|
||||
replyToConversationId: 'conv-auto',
|
||||
});
|
||||
|
||||
await service.send(makeRequest({ fromTeam: 'team-a', toTeam: 'team-b' }));
|
||||
|
||||
const [, req] = inboxWriter.sendMessage.mock.calls[0];
|
||||
expect(req.conversationId).toBe('conv-auto');
|
||||
expect(req.replyToConversationId).toBe('conv-auto');
|
||||
expect(provisioning.resolveCrossTeamReplyMetadata).toHaveBeenCalledWith('team-a', 'team-b');
|
||||
});
|
||||
|
||||
it('does not ask provisioning for reply metadata when request already carries conversation ids', async () => {
|
||||
await service.send(
|
||||
makeRequest({
|
||||
conversationId: 'conv-explicit',
|
||||
replyToConversationId: 'conv-explicit',
|
||||
})
|
||||
);
|
||||
|
||||
expect(provisioning.resolveCrossTeamReplyMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls relayLeadInboxMessages when team is alive', async () => {
|
||||
|
|
|
|||
|
|
@ -282,6 +282,8 @@ describe('TeamProvisioningService post-compact lifecycle', () => {
|
|||
expect(text).toContain('blocked by another team');
|
||||
expect(text).toContain('one focused request per topic');
|
||||
expect(text).toContain('If you receive a message that is clearly from another team');
|
||||
expect(text).toContain('preserve the same conversationId');
|
||||
expect(text).toContain('replyToConversationId');
|
||||
expect(text).toContain('Do not wait silently on another team');
|
||||
expect(text).toContain('Golden format for cross-team requests');
|
||||
expect(text).toContain('Golden format for cross-team replies');
|
||||
|
|
|
|||
|
|
@ -316,6 +316,58 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
expect(hoisted.appendSentMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves cross-team reply metadata only for a single matching team hint', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
seedConfig(teamName);
|
||||
attachAliveRun(service, teamName);
|
||||
|
||||
const run = (service as unknown as { runs: Map<string, unknown> }).runs.get('run-1') as {
|
||||
activeCrossTeamReplyHints: Array<{ toTeam: string; conversationId: string }>;
|
||||
};
|
||||
run.activeCrossTeamReplyHints = [{ toTeam: 'other-team', conversationId: 'conv-1' }];
|
||||
|
||||
expect(service.resolveCrossTeamReplyMetadata(teamName, 'other-team')).toEqual({
|
||||
conversationId: 'conv-1',
|
||||
replyToConversationId: 'conv-1',
|
||||
});
|
||||
|
||||
run.activeCrossTeamReplyHints = [
|
||||
{ toTeam: 'other-team', conversationId: 'conv-1' },
|
||||
{ toTeam: 'other-team', conversationId: 'conv-2' },
|
||||
];
|
||||
expect(service.resolveCrossTeamReplyMetadata(teamName, 'other-team')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not relay cross-team sender copies back into the live lead', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
seedConfig(teamName);
|
||||
seedLeadInbox(teamName, [
|
||||
{
|
||||
from: 'user',
|
||||
to: 'other-team.team-lead',
|
||||
text: 'How is the progress on that task?',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: false,
|
||||
source: 'cross_team_sent',
|
||||
messageId: 'm-cross-team-sent-1',
|
||||
},
|
||||
]);
|
||||
|
||||
const { writeSpy } = attachAliveRun(service, teamName);
|
||||
const relayed = await service.relayLeadInboxMessages(teamName);
|
||||
|
||||
expect(relayed).toBe(0);
|
||||
expect(writeSpy).toHaveBeenCalledTimes(0);
|
||||
|
||||
const updatedInbox = JSON.parse(
|
||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/team-lead.json`) ?? '[]'
|
||||
) as Array<{ messageId?: string }>;
|
||||
expect(updatedInbox).toHaveLength(1);
|
||||
expect(updatedInbox[0]?.messageId).toBe('m-cross-team-sent-1');
|
||||
});
|
||||
|
||||
it('relays unread teammate inbox messages through the live team process', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
|
|
|
|||
141
test/renderer/utils/crossTeamPendingReplies.test.ts
Normal file
141
test/renderer/utils/crossTeamPendingReplies.test.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { computePendingCrossTeamReplies } from '@renderer/utils/crossTeamPendingReplies';
|
||||
|
||||
import type { InboxMessage } from '@shared/types';
|
||||
|
||||
function makeMessage(overrides: Partial<InboxMessage> = {}): InboxMessage {
|
||||
return {
|
||||
from: 'user',
|
||||
text: 'hello',
|
||||
timestamp: '2026-03-09T12:00:00.000Z',
|
||||
read: true,
|
||||
messageId: 'msg-1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('computePendingCrossTeamReplies', () => {
|
||||
it('returns pending entry for outbound cross-team message without reply', () => {
|
||||
const result = computePendingCrossTeamReplies([
|
||||
makeMessage({
|
||||
conversationId: 'conv-1',
|
||||
source: 'cross_team_sent',
|
||||
to: 'team-best.team-lead',
|
||||
timestamp: '2026-03-09T12:00:00.000Z',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
conversationId: 'conv-1',
|
||||
teamName: 'team-best',
|
||||
sentAtMs: Date.parse('2026-03-09T12:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('clears pending entry when a newer cross-team reply arrives in the same conversation', () => {
|
||||
const result = computePendingCrossTeamReplies([
|
||||
makeMessage({
|
||||
conversationId: 'conv-1',
|
||||
source: 'cross_team_sent',
|
||||
to: 'team-best.team-lead',
|
||||
timestamp: '2026-03-09T12:00:00.000Z',
|
||||
}),
|
||||
makeMessage({
|
||||
conversationId: 'conv-1',
|
||||
replyToConversationId: 'conv-1',
|
||||
from: 'team-best.team-lead',
|
||||
source: 'cross_team',
|
||||
timestamp: '2026-03-09T12:05:00.000Z',
|
||||
messageId: 'msg-2',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps pending entry when the latest outbound is newer than the last reply', () => {
|
||||
const result = computePendingCrossTeamReplies([
|
||||
makeMessage({
|
||||
conversationId: 'conv-1',
|
||||
replyToConversationId: 'conv-1',
|
||||
from: 'team-best.team-lead',
|
||||
source: 'cross_team',
|
||||
timestamp: '2026-03-09T12:05:00.000Z',
|
||||
messageId: 'msg-1-reply',
|
||||
}),
|
||||
makeMessage({
|
||||
conversationId: 'conv-1',
|
||||
source: 'cross_team_sent',
|
||||
to: 'team-best.team-lead',
|
||||
timestamp: '2026-03-09T12:10:00.000Z',
|
||||
messageId: 'msg-2',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
conversationId: 'conv-1',
|
||||
teamName: 'team-best',
|
||||
sentAtMs: Date.parse('2026-03-09T12:10:00.000Z'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a pending conversation even when another team message arrives in a different conversation', () => {
|
||||
const result = computePendingCrossTeamReplies([
|
||||
makeMessage({
|
||||
conversationId: 'conv-1',
|
||||
source: 'cross_team_sent',
|
||||
to: 'team-best.team-lead',
|
||||
timestamp: '2026-03-09T12:00:00.000Z',
|
||||
}),
|
||||
makeMessage({
|
||||
conversationId: 'conv-2',
|
||||
from: 'team-best.team-lead',
|
||||
source: 'cross_team',
|
||||
timestamp: '2026-03-09T12:05:00.000Z',
|
||||
messageId: 'msg-2',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
conversationId: 'conv-1',
|
||||
teamName: 'team-best',
|
||||
sentAtMs: Date.parse('2026-03-09T12:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores non-cross-team messages', () => {
|
||||
const result = computePendingCrossTeamReplies([
|
||||
makeMessage({
|
||||
from: 'alice',
|
||||
to: 'team-lead',
|
||||
timestamp: '2026-03-09T12:00:00.000Z',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('falls back to legacy team-level matching when conversationId is missing', () => {
|
||||
const result = computePendingCrossTeamReplies([
|
||||
makeMessage({
|
||||
source: 'cross_team_sent',
|
||||
to: 'team-best.team-lead',
|
||||
timestamp: '2026-03-09T12:00:00.000Z',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
teamName: 'team-best',
|
||||
sentAtMs: Date.parse('2026-03-09T12:00:00.000Z'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue