fix(team): dedup duplicate SendMessage entries and show lead online during provisioning

- Add content-based dedup in handleGetData merge: when two directed messages
  have identical from+to+text within a 5-second window, keep only the first.
  Fixes duplicate display caused by both CLI and our persistInboxMessage
  writing to the same inbox file.

- Reorder checks in getMemberDotClass/getPresenceLabel: check leadActivity
  before isTeamProvisioning so the lead shows green dot when its process
  is already running during team setup.
This commit is contained in:
iliya 2026-03-23 15:16:09 +02:00
parent 26e21251d0
commit 935128e26b
2 changed files with 26 additions and 4 deletions

View file

@ -557,6 +557,12 @@ async function handleGetData(
// messageIds inside the same session (e.g. lead-turn-* re-emits).
const leadProcessTextFingerprints = new Set<string>();
// Content-based dedup for SendMessage captures: Claude Code CLI and our
// persistInboxMessage both write to inboxes/{member}.json, producing two entries
// with identical content but different messageIds. Track content fingerprints
// (from+to+text) with timestamps to collapse them within a 5-second window.
const contentSeen = new Map<string, number>(); // fingerprint → timestamp ms
const merged: typeof data.messages = [];
const seen = new Set<string>();
for (const msg of [...data.messages, ...live]) {
@ -572,6 +578,19 @@ async function handleGetData(
}
leadProcessTextFingerprints.add(fp);
}
// Content dedup for directed messages (SendMessage captures):
// same from+to+text within 5 seconds = duplicate from CLI + our persist.
if (typeof msg.to === 'string' && msg.to.trim().length > 0) {
const contentFp = `${msg.from}\0${msg.to}\0${(msg.text ?? '').replace(/\s+/g, ' ').slice(0, 100)}`;
const msgMs = Date.parse(msg.timestamp);
const existingMs = contentSeen.get(contentFp);
if (existingMs !== undefined && Math.abs(msgMs - existingMs) <= 5000) {
continue; // duplicate within 5s window — skip
}
contentSeen.set(contentFp, msgMs);
}
const key = keyFor(msg);
if (seen.has(key)) continue;
seen.add(key);

View file

@ -42,13 +42,15 @@ export function getMemberDotClass(
): string {
if (member.status === 'terminated') return STATUS_DOT_COLORS.terminated;
if (member.removedAt) return STATUS_DOT_COLORS.terminated;
if (isTeamProvisioning) return STATUS_DOT_COLORS.unknown;
if (isTeamAlive === false) return STATUS_DOT_COLORS.terminated;
// Lead activity check BEFORE provisioning fallback — when the lead process
// is running (CLI logs present), show green even during provisioning.
if (leadActivity && isLeadMember(member)) {
return leadActivity === 'active'
? `${STATUS_DOT_COLORS.active} animate-pulse`
: STATUS_DOT_COLORS.active;
}
if (isTeamProvisioning) return STATUS_DOT_COLORS.unknown;
if (isTeamAlive === false) return STATUS_DOT_COLORS.terminated;
// When team is alive, all non-terminated members are online
if (isTeamAlive) {
if (member.currentTaskId) return `${STATUS_DOT_COLORS.active} animate-pulse`;
@ -67,8 +69,7 @@ export function getPresenceLabel(
leadContextPercent?: number
): string {
if (member.status === 'terminated') return 'terminated';
if (isTeamProvisioning) return 'connecting';
if (isTeamAlive === false) return 'offline';
// Lead activity check before provisioning fallback (mirrors getMemberDotClass order).
if (leadActivity && isLeadMember(member)) {
if (leadActivity === 'active') {
return leadContextPercent != null && leadContextPercent > 0
@ -77,6 +78,8 @@ export function getPresenceLabel(
}
return 'ready';
}
if (isTeamProvisioning) return 'connecting';
if (isTeamAlive === false) return 'offline';
if (member.status === 'unknown') return 'idle';
return member.currentTaskId ? 'working' : 'idle';
}