refactor(team): split team detail snapshot from messages activity

This commit is contained in:
777genius 2026-04-15 21:54:38 +03:00
parent 2cfbfef3b3
commit 1173a4942a
53 changed files with 6990 additions and 1219 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
/**
* TeamGraphAdapter transforms Zustand TeamData GraphDataPort.
* TeamGraphAdapter transforms store-backed team graph input GraphDataPort.
*
* This adapter owns the graph projection from team runtime state into the
* reusable package port model. Renderer hooks may still read store state, but
@ -55,12 +55,18 @@ import type {
LeadActivityState,
MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot,
TeamData,
ResolvedTeamMember,
TeamProcess,
TeamProvisioningProgress,
TeamViewSnapshot,
} from '@shared/types/team';
import type { LeadContextUsage } from '@shared/types/team';
export interface TeamGraphData extends TeamViewSnapshot {
members: ResolvedTeamMember[];
messageFeed: InboxMessage[];
}
export class TeamGraphAdapter {
// ─── ES #private fields ──────────────────────────────────────────────────
#lastTeamName = '';
@ -87,7 +93,7 @@ export class TeamGraphAdapter {
* Adapt team data into a GraphDataPort snapshot.
*/
adapt(
teamData: TeamData | null,
teamData: TeamGraphData | null,
teamName: string,
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
leadActivity?: LeadActivityState,
@ -179,7 +185,7 @@ export class TeamGraphAdapter {
this.#buildMessageParticles(
particles,
nodes,
teamData.messages,
teamData.messageFeed,
teamName,
leadId,
leadName,
@ -222,11 +228,11 @@ export class TeamGraphAdapter {
// ─── Private: node builders ──────────────────────────────────────────────
static #getLeadMemberName(data: TeamData, teamName: string): string {
static #getLeadMemberName(data: TeamGraphData, teamName: string): string {
return getGraphLeadMemberName(data, teamName);
}
static #buildMemberNodeIdByName(data: TeamData, teamName: string): Map<string, string> {
static #buildMemberNodeIdByName(data: TeamGraphData, teamName: string): Map<string, string> {
return new Map(
data.members
.filter((member) => !isLeadMember(member))
@ -235,7 +241,7 @@ export class TeamGraphAdapter {
}
static #buildLayoutPort(
data: TeamData,
data: TeamGraphData,
teamName: string,
slotAssignments?: Record<string, GraphOwnerSlotAssignment>
): GraphLayoutPort {
@ -252,7 +258,7 @@ export class TeamGraphAdapter {
(data.config.members ?? []).map((member) => getGraphStableOwnerId(member))
);
const pushMember = (member: TeamData['members'][number] | undefined): void => {
const pushMember = (member: TeamGraphData['members'][number] | undefined): void => {
if (!member) {
return;
}
@ -322,7 +328,7 @@ export class TeamGraphAdapter {
}
static #collectDuplicateStableOwnerIds(
members: readonly TeamData['members'][number][]
members: readonly TeamGraphData['members'][number][]
): string[] {
const counts = new Map<string, number>();
for (const member of members) {
@ -344,9 +350,9 @@ export class TeamGraphAdapter {
}
static #getRuntimeLabel(
providerId: TeamData['members'][number]['providerId'],
model: TeamData['members'][number]['model'],
effort: TeamData['members'][number]['effort']
providerId: ResolvedTeamMember['providerId'],
model: ResolvedTeamMember['model'],
effort: ResolvedTeamMember['effort']
): string | undefined {
return formatTeamRuntimeSummary(providerId, model, effort);
}
@ -367,7 +373,7 @@ export class TeamGraphAdapter {
#buildLeadNode(
nodes: GraphNode[],
leadId: string,
data: TeamData,
data: TeamGraphData,
teamName: string,
leadName: string,
pendingApprovalAgents?: Set<string>,
@ -462,7 +468,7 @@ export class TeamGraphAdapter {
nodes: GraphNode[],
edges: GraphEdge[],
leadId: string,
data: TeamData,
data: TeamGraphData,
teamName: string,
memberNodeIdByName: ReadonlyMap<string, string>,
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
@ -565,12 +571,12 @@ export class TeamGraphAdapter {
#buildTaskNodes(
nodes: GraphNode[],
edges: GraphEdge[],
data: TeamData,
data: TeamGraphData,
teamName: string,
commentReadState?: Record<string, unknown>,
memberNodeIdByName?: ReadonlyMap<string, string>
): void {
const taskStateById = new Map<string, Pick<TeamData['tasks'][number], 'status'>>();
const taskStateById = new Map<string, Pick<TeamGraphData['tasks'][number], 'status'>>();
const taskDisplayIds = new Map<string, string>();
const memberColorByName = new Map<string, string>();
@ -750,7 +756,7 @@ export class TeamGraphAdapter {
#buildProcessNodes(
nodes: GraphNode[],
edges: GraphEdge[],
data: TeamData,
data: TeamGraphData,
teamName: string,
memberNodeIdByName?: ReadonlyMap<string, string>
): void {
@ -828,7 +834,7 @@ export class TeamGraphAdapter {
#attachActivityFeeds(
nodes: GraphNode[],
data: TeamData,
data: TeamGraphData,
teamName: string,
leadId: string,
leadName: string
@ -845,7 +851,10 @@ export class TeamGraphAdapter {
}
const entriesByOwnerNodeId = buildInlineActivityEntries({
data,
data: {
...data,
messages: data.messageFeed,
},
teamName,
leadId,
leadName,
@ -1006,7 +1015,7 @@ export class TeamGraphAdapter {
#buildCommentParticles(
particles: GraphParticle[],
data: TeamData,
data: TeamGraphData,
teamName: string,
leadId: string,
leadName: string,
@ -1099,8 +1108,8 @@ export class TeamGraphAdapter {
}
static #buildMemberException(
runtimeAdvisory: TeamData['members'][number]['runtimeAdvisory'],
providerId: TeamData['members'][number]['providerId'],
runtimeAdvisory: ResolvedTeamMember['runtimeAdvisory'],
providerId: ResolvedTeamMember['providerId'],
spawn: MemberSpawnStatusEntry | undefined,
pendingApproval: boolean
): Pick<GraphNode, 'exceptionTone' | 'exceptionLabel'> | undefined {

View file

@ -1,17 +1,34 @@
import { useStore } from '@renderer/store';
import { selectTeamDataForName } from '@renderer/store/slices/teamSlice';
import {
selectResolvedMembersForTeamName,
selectTeamDataForName,
selectTeamMessages,
} from '@renderer/store/slices/teamSlice';
import { useShallow } from 'zustand/react/shallow';
import type { TeamData, TeamSummary } from '@shared/types/team';
import type { TeamSummary } from '@shared/types/team';
import type { TeamGraphData } from '../adapters/TeamGraphAdapter';
export function useGraphActivityContext(teamName: string): {
teamData: TeamData | null;
teamData: TeamGraphData | null;
teams: TeamSummary[];
} {
return useStore(
useShallow((state) => ({
teamData: selectTeamDataForName(state, teamName),
teams: state.teams,
}))
useShallow((state) => {
const snapshot = selectTeamDataForName(state, teamName);
const members = selectResolvedMembersForTeamName(state, teamName);
const messages = selectTeamMessages(state, teamName);
return {
teamData: snapshot
? {
...snapshot,
members,
messageFeed: messages,
}
: null,
teams: state.teams,
};
})
);
}

View file

@ -3,7 +3,11 @@ import { useCallback, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { CreateTaskDialog } from '@renderer/components/team/dialogs/CreateTaskDialog';
import { useStore } from '@renderer/store';
import { isTeamProvisioningActive, selectTeamDataForName } from '@renderer/store/slices/teamSlice';
import {
isTeamProvisioningActive,
selectResolvedMembersForTeamName,
selectTeamDataForName,
} from '@renderer/store/slices/teamSlice';
import { useShallow } from 'zustand/react/shallow';
import type { TaskRef } from '@shared/types';
@ -25,19 +29,17 @@ export function useGraphCreateTaskDialog(teamName: string): UseGraphCreateTaskDi
});
const [submitting, setSubmitting] = useState(false);
const { teamData, createTeamTask, isTeamProvisioning } = useStore(
const { teamData, activeMembers, createTeamTask, isTeamProvisioning } = useStore(
useShallow((state) => ({
teamData: selectTeamDataForName(state, teamName),
activeMembers: selectResolvedMembersForTeamName(state, teamName).filter(
(member) => !member.removedAt
),
createTeamTask: state.createTeamTask,
isTeamProvisioning: isTeamProvisioningActive(state, teamName),
}))
);
const activeMembers = useMemo(
() => (teamData?.members ?? []).filter((member) => !member.removedAt),
[teamData?.members]
);
const openCreateTaskDialog = useCallback((owner = ''): void => {
setDialogState({
open: true,

View file

@ -1,19 +1,34 @@
import { useStore } from '@renderer/store';
import {
getCurrentProvisioningProgressForTeam,
selectResolvedMembersForTeamName,
selectTeamDataForName,
} from '@renderer/store/slices/teamSlice';
import { useShallow } from 'zustand/react/shallow';
import type { TeamGraphData } from '../adapters/TeamGraphAdapter';
export function useGraphMemberPopoverContext(teamName: string, memberName: string) {
return useStore(
useShallow((state) => ({
teamData: teamName ? selectTeamDataForName(state, teamName) : null,
spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined,
leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined,
progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null,
memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined,
memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined,
}))
useShallow((state) => {
const snapshot = teamName ? selectTeamDataForName(state, teamName) : null;
const teamMembers = teamName ? selectResolvedMembersForTeamName(state, teamName) : [];
return {
teamData: snapshot
? {
...snapshot,
members: teamMembers,
messageFeed: [],
}
: null,
teamMembers,
spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined,
leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined,
progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null,
memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined,
memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined,
};
})
);
}

View file

@ -9,19 +9,24 @@ import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage';
import { useStore } from '@renderer/store';
import {
getCurrentProvisioningProgressForTeam,
selectResolvedMembersForTeamName,
selectTeamDataForName,
selectTeamMessages,
} from '@renderer/store/slices/teamSlice';
import { useShallow } from 'zustand/react/shallow';
import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter';
import type { GraphDataPort } from '@claude-teams/agent-graph';
import type { TeamGraphData } from '../adapters/TeamGraphAdapter';
export function useTeamGraphAdapter(teamName: string): GraphDataPort {
const adapterRef = useRef<TeamGraphAdapter>(TeamGraphAdapter.create());
const {
teamData,
teamSnapshot,
members,
messages,
spawnStatuses,
leadActivity,
leadContext,
@ -35,7 +40,9 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
ensureTeamGraphSlotAssignments,
} = useStore(
useShallow((s) => ({
teamData: selectTeamDataForName(s, teamName),
teamSnapshot: selectTeamDataForName(s, teamName),
members: selectResolvedMembersForTeamName(s, teamName),
messages: selectTeamMessages(s, teamName),
spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
leadActivity: teamName ? s.leadActivityByTeam[teamName] : undefined,
leadContext: teamName ? s.leadContextByTeam[teamName] : undefined,
@ -60,6 +67,17 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
return agents;
}, [pendingApprovals, teamName]);
const teamData = useMemo<TeamGraphData | null>(() => {
if (!teamSnapshot) {
return null;
}
return {
...teamSnapshot,
members,
messageFeed: messages,
};
}, [members, messages, teamSnapshot]);
const commentReadState = useSyncExternalStore(subscribe, getSnapshot);
useEffect(() => {

View file

@ -71,6 +71,9 @@ export const GraphActivityHud = ({
const connectorPathRefs = useRef(new Map<string, SVGPathElement | null>());
const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(null);
const { teamData, teams } = useGraphActivityContext(teamName);
const teamSnapshot = teamData;
const members = teamData?.members ?? [];
const messages = teamData?.messageFeed ?? [];
const ownerNodes = useMemo(
() =>
@ -81,21 +84,27 @@ export const GraphActivityHud = ({
[nodes]
);
const leadNodeId = ownerNodes.find((node) => node.kind === 'lead')?.id ?? `lead:${teamName}`;
const leadName = teamData ? getGraphLeadMemberName(teamData, teamName) : `${teamName}-lead`;
const leadName = teamSnapshot
? getGraphLeadMemberName({ members }, teamName)
: `${teamName}-lead`;
const ownerNodeIds = useMemo(() => new Set(ownerNodes.map((node) => node.id)), [ownerNodes]);
const entryMapByOwnerNodeId = useMemo(() => {
if (!teamData) {
if (!teamSnapshot) {
return new Map<string, InlineActivityEntry[]>();
}
return buildInlineActivityEntries({
data: teamData,
data: {
members,
tasks: teamSnapshot.tasks,
messages,
},
teamName,
leadId: leadNodeId,
leadName,
ownerNodeIds,
});
}, [leadName, leadNodeId, ownerNodeIds, teamData, teamName]);
const messageContext = useMemo(() => buildMessageContext(teamData?.members), [teamData?.members]);
}, [leadName, leadNodeId, members, messages, ownerNodeIds, teamName, teamSnapshot]);
const messageContext = useMemo(() => buildMessageContext(members), [members]);
const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams);
const { readSet } = useTeamMessagesRead(teamName);
@ -383,7 +392,7 @@ export const GraphActivityHud = ({
};
}, [enabled, forwardWheelToGraph, visibleLanes]);
if (!enabled || !teamData || visibleLanes.length === 0) {
if (!enabled || !teamSnapshot || visibleLanes.length === 0) {
return null;
}
@ -493,7 +502,7 @@ export const GraphActivityHud = ({
}
}}
teamName={teamName}
members={teamData.members}
members={members}
onMemberClick={handleMemberClick}
onTaskIdClick={onOpenTaskDetail}
teamNames={teamNames}

View file

@ -292,14 +292,21 @@ const MemberPopoverContent = ({
? node.domainRef.teamName
: '';
const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64);
const { teamData, spawnEntry, leadActivity, progress, memberSpawnSnapshot, memberSpawnStatuses } =
useGraphMemberPopoverContext(teamName, memberName);
const member = teamData?.members.find((candidate) => candidate.name === memberName) ?? null;
const {
teamData,
teamMembers,
spawnEntry,
leadActivity,
progress,
memberSpawnSnapshot,
memberSpawnStatuses,
} = useGraphMemberPopoverContext(teamName, memberName);
const member = teamMembers.find((candidate) => candidate.name === memberName) ?? null;
const provisioningPresentation =
teamData && teamName
? buildTeamProvisioningPresentation({
progress,
members: teamData.members,
members: teamMembers,
memberSpawnStatuses,
memberSpawnSnapshot,
})

View file

@ -562,6 +562,13 @@ function wireFileWatcherEvents(context: ServiceContext): void {
const teamName = row.teamName.trim();
const detail = typeof row.detail === 'string' ? row.detail : '';
if (
teamDataService &&
(row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config')
) {
teamDataService.invalidateMessageFeed(teamName);
}
// --- Inbox change events: relay to lead + native OS notifications ---
if (row.type === 'inbox') {
if (reconcileScheduler) {
@ -900,6 +907,12 @@ async function initializeServices(): Promise<void> {
});
const forwardTeamChange = (event: TeamChangeEvent): void => {
if (
teamDataService &&
(event.type === 'inbox' || event.type === 'lead-message' || event.type === 'config')
) {
teamDataService.invalidateMessageFeed(event.teamName);
}
safeSendToRenderer(mainWindow, TEAM_CHANGE, event);
httpServer?.broadcast('team-change', event);
};

View file

@ -22,6 +22,7 @@ import {
TEAM_GET_DATA,
TEAM_GET_DELETED_TASKS,
TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_MEMBER_ACTIVITY_META,
TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS,
TEAM_GET_MESSAGES_PAGE,
@ -92,7 +93,7 @@ import {
parseStandaloneSlashCommand,
} from '@shared/utils/slashCommands';
import crypto from 'crypto';
import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
import { app, BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
@ -170,15 +171,16 @@ import type {
TeamCreateConfigRequest,
TeamCreateRequest,
TeamCreateResponse,
TeamData,
TeamLaunchRequest,
TeamLaunchResponse,
TeamMemberActivityMeta,
TeamMessageNotificationData,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamSummary,
TeamTask,
TeamTaskStatus,
TeamViewSnapshot,
TeamUpdateConfigRequest,
ToolApprovalFileContent,
ToolApprovalSettings,
@ -196,6 +198,17 @@ const logger = createLogger('IPC:teams');
const seenRateLimitKeys = new Set<string>();
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
function ensureHeavyTeamDataWorkerFallbackAllowed(operation: string): void {
if (!app.isPackaged) {
return;
}
logger.error(
`[${operation}] team-data-worker unavailable in packaged runtime; refusing main-thread fallback for heavy message/activity path`
);
throw new Error('TEAM_DATA_WORKER_UNAVAILABLE');
}
async function getDurableLeadTeammateRoster(
teamName: string,
leadName: string
@ -385,6 +398,19 @@ function checkApiErrorMessages(
}
}
function scanTeamMessageNotifications(
messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
teamName: string,
teamDisplayName: string,
projectPath?: string
): void {
if (messages.length === 0) {
return;
}
checkRateLimitMessages(messages, teamName, teamDisplayName, projectPath);
checkApiErrorMessages(messages, teamName, teamDisplayName, projectPath);
}
let teamDataService: TeamDataService | null = null;
let teamProvisioningService: TeamProvisioningService | null = null;
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
@ -463,6 +489,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning);
ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage);
ipcMain.handle(TEAM_GET_MESSAGES_PAGE, handleGetMessagesPage);
ipcMain.handle(TEAM_GET_MEMBER_ACTIVITY_META, handleGetMemberActivityMeta);
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview);
ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban);
@ -535,6 +562,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING);
ipcMain.removeHandler(TEAM_SEND_MESSAGE);
ipcMain.removeHandler(TEAM_GET_MESSAGES_PAGE);
ipcMain.removeHandler(TEAM_GET_MEMBER_ACTIVITY_META);
ipcMain.removeHandler(TEAM_CREATE_TASK);
ipcMain.removeHandler(TEAM_REQUEST_REVIEW);
ipcMain.removeHandler(TEAM_UPDATE_KANBAN);
@ -702,14 +730,14 @@ async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<Te
async function handleGetData(
_event: IpcMainInvokeEvent,
teamName: unknown
): Promise<IpcResult<TeamData>> {
): Promise<IpcResult<TeamViewSnapshot>> {
const validated = validateTeamName(teamName);
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' };
}
const tn = validated.value!;
const startedAt = Date.now();
let data: TeamData;
let data: TeamViewSnapshot;
setCurrentMainOp('team:getData');
try {
// Prefer worker thread to keep main event loop responsive
@ -721,9 +749,11 @@ async function handleGetData(
logger.warn(
`[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
);
ensureHeavyTeamDataWorkerFallbackAllowed('teams:getData');
data = await getTeamDataService().getTeamData(tn);
}
} else {
ensureHeavyTeamDataWorkerFallbackAllowed('teams:getData');
data = await getTeamDataService().getTeamData(tn);
}
} catch (error) {
@ -762,92 +792,9 @@ async function handleGetData(
const displayName = data.config.name || tn;
const projectPath = data.config.projectPath;
const live = provisioning.getLiveLeadProcessMessages(tn);
if (live.length === 0) {
checkRateLimitMessages(data.messages, tn, displayName, projectPath);
checkApiErrorMessages(data.messages, tn, displayName, projectPath);
return { success: true, data: { ...data, isAlive } };
}
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
const isLeadThoughtLike = (msg: { source?: unknown; to?: string }): boolean =>
!msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
const getLeadThoughtFingerprint = (msg: {
from: string;
text: string;
leadSessionId?: string;
}): string => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text)}`;
// Collect fingerprints only for thought-like lead messages. Include leadSessionId so a
// repeated thought in a new session does not get collapsed into an old session's history.
const existingTextFingerprints = new Set<string>();
for (const msg of data.messages) {
if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue;
if (!isLeadThoughtLike(msg)) continue;
existingTextFingerprints.add(getLeadThoughtFingerprint(msg));
}
const keyFor = (m: {
messageId?: string;
timestamp: string;
from: string;
text: string;
}): string => {
if (typeof m.messageId === 'string' && m.messageId.trim().length > 0) {
return m.messageId;
}
return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`;
};
// Text-based fingerprints for live lead thoughts to catch duplicates with different
// 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]) {
if ((msg as { source?: unknown }).source === 'lead_process' && !msg.to) {
const fp = getLeadThoughtFingerprint(msg);
// Skip if the same thought already exists in persisted history for the same session.
if (existingTextFingerprints.has(fp)) {
continue;
}
// Dedup live lead_process thoughts with the same text in the same session.
if (leadProcessTextFingerprints.has(fp)) {
continue;
}
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);
merged.push(msg);
}
merged.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
checkRateLimitMessages(merged, tn, displayName, projectPath);
checkApiErrorMessages(merged, tn, displayName, projectPath);
return { success: true, data: { ...data, isAlive, messages: merged } };
scanTeamMessageNotifications(live, tn, displayName, projectPath);
return { success: true, data: { ...data, isAlive } };
}
async function handleGetTaskChangePresence(
@ -1698,16 +1645,71 @@ async function handleGetMessagesPage(
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
}
const opts = (options && typeof options === 'object' ? options : {}) as {
beforeTimestamp?: string;
cursor?: string | null;
limit?: number;
};
const limit = Math.min(Math.max(1, opts.limit ?? 50), 200);
const beforeTimestamp =
typeof opts.beforeTimestamp === 'string' ? opts.beforeTimestamp : undefined;
const cursor =
typeof opts.cursor === 'string' ? opts.cursor : opts.cursor === null ? null : undefined;
return wrapTeamHandler('getMessagesPage', async () => {
const service = getTeamDataService();
return service.getMessagesPage(vTeam.value!, { beforeTimestamp, limit });
let page: MessagesPage;
const notificationContext = await getTeamDataService().getTeamNotificationContext(vTeam.value!);
const worker = getTeamDataWorkerClient();
if (worker.isAvailable()) {
try {
page = await worker.getMessagesPage(vTeam.value!, { cursor, limit });
scanTeamMessageNotifications(
page.messages,
vTeam.value!,
notificationContext.displayName,
notificationContext.projectPath
);
return page;
} catch (workerErr) {
logger.warn(
`[teams:getMessagesPage] worker failed, falling back: ${
workerErr instanceof Error ? workerErr.message : workerErr
}`
);
}
}
ensureHeavyTeamDataWorkerFallbackAllowed('teams:getMessagesPage');
page = await getTeamDataService().getMessagesPage(vTeam.value!, { cursor, limit });
scanTeamMessageNotifications(
page.messages,
vTeam.value!,
notificationContext.displayName,
notificationContext.projectPath
);
return page;
});
}
async function handleGetMemberActivityMeta(
_event: IpcMainInvokeEvent,
teamName: unknown
): Promise<IpcResult<TeamMemberActivityMeta>> {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) {
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
}
return wrapTeamHandler('getMemberActivityMeta', async () => {
const worker = getTeamDataWorkerClient();
if (worker.isAvailable()) {
try {
return await worker.getMemberActivityMeta(vTeam.value!);
} catch (workerErr) {
logger.warn(
`[teams:getMemberActivityMeta] worker failed, falling back: ${
workerErr instanceof Error ? workerErr.message : workerErr
}`
);
}
}
ensureHeavyTeamDataWorkerFallbackAllowed('teams:getMemberActivityMeta');
return getTeamDataService().getMemberActivityMeta(vTeam.value!);
});
}

View file

@ -0,0 +1,128 @@
import type { TeamMessageFeedService } from './TeamMessageFeedService';
import type { InboxMessage, MemberActivityMetaEntry, TeamMemberActivityMeta } from '@shared/types';
interface MemberActivityMetaCacheEntry {
feedRevision: string;
meta: TeamMemberActivityMeta;
}
function messageSignalsTermination(message: InboxMessage | null | undefined): boolean {
if (!message) return false;
try {
const parsed = JSON.parse(message.text) as {
type?: string;
approve?: boolean;
approved?: boolean;
};
return (
(parsed.type === 'shutdown_response' &&
(parsed.approve === true || parsed.approved === true)) ||
parsed.type === 'shutdown_approved'
);
} catch {
return false;
}
}
function areMemberActivityEntriesEqual(
left: MemberActivityMetaEntry | undefined,
right: MemberActivityMetaEntry
): boolean {
if (!left) {
return false;
}
return (
left.memberName === right.memberName &&
left.lastAuthoredMessageAt === right.lastAuthoredMessageAt &&
left.messageCountExact === right.messageCountExact &&
left.latestAuthoredMessageSignalsTermination === right.latestAuthoredMessageSignalsTermination
);
}
function structurallyShareMemberFacts(
previous: Record<string, MemberActivityMetaEntry> | undefined,
next: Record<string, MemberActivityMetaEntry>
): Record<string, MemberActivityMetaEntry> {
if (!previous) {
return next;
}
const nextKeys = Object.keys(next);
const previousKeys = Object.keys(previous);
let changed = nextKeys.length !== previousKeys.length;
const shared: Record<string, MemberActivityMetaEntry> = {};
for (const key of nextKeys) {
const nextEntry = next[key];
const previousEntry = previous[key];
if (!areMemberActivityEntriesEqual(previousEntry, nextEntry)) {
changed = true;
shared[key] = nextEntry;
continue;
}
shared[key] = previousEntry;
}
return changed ? shared : previous;
}
export class MemberActivityMetaService {
private readonly cacheByTeam = new Map<string, MemberActivityMetaCacheEntry>();
constructor(private readonly feedService: TeamMessageFeedService) {}
invalidate(teamName: string): void {
this.cacheByTeam.delete(teamName);
}
async getMeta(teamName: string): Promise<TeamMemberActivityMeta> {
const feed = await this.feedService.getFeed(teamName);
const cached = this.cacheByTeam.get(teamName);
if (cached?.feedRevision === feed.feedRevision) {
return cached.meta;
}
const latestByMember = new Map<string, InboxMessage>();
const countsByMember = new Map<string, number>();
for (const message of feed.messages) {
const memberName = typeof message.from === 'string' ? message.from.trim() : '';
if (!memberName || memberName === 'user' || memberName === 'system') {
continue;
}
countsByMember.set(memberName, (countsByMember.get(memberName) ?? 0) + 1);
if (!latestByMember.has(memberName)) {
latestByMember.set(memberName, message);
}
}
const nextMembers = Object.fromEntries(
Array.from(new Set([...countsByMember.keys(), ...latestByMember.keys()]))
.sort((left, right) => left.localeCompare(right))
.map((memberName) => {
const latestMessage = latestByMember.get(memberName) ?? null;
return [
memberName,
{
memberName,
lastAuthoredMessageAt: latestMessage?.timestamp ?? null,
messageCountExact: countsByMember.get(memberName) ?? 0,
latestAuthoredMessageSignalsTermination: messageSignalsTermination(latestMessage),
},
] as const;
})
);
const members = structurallyShareMemberFacts(cached?.meta.members, nextMembers);
const meta: TeamMemberActivityMeta = {
teamName,
computedAt: new Date().toISOString(),
members,
feedRevision: feed.feedRevision,
};
this.cacheByTeam.set(teamName, { feedRevision: feed.feedRevision, meta });
return meta;
}
}

View file

@ -39,6 +39,7 @@ import {
} from './cache/LeadSessionParseCache';
import { atomicWriteAsync } from './atomicWrite';
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
import { MemberActivityMetaService } from './MemberActivityMetaService';
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
import { TeamConfigReader } from './TeamConfigReader';
import { TeamInboxReader } from './TeamInboxReader';
@ -46,6 +47,7 @@ import { TeamInboxWriter } from './TeamInboxWriter';
import { TeamKanbanManager } from './TeamKanbanManager';
import { TeamMemberResolver } from './TeamMemberResolver';
import { TeamMemberRuntimeAdvisoryService } from './TeamMemberRuntimeAdvisoryService';
import { TeamMessageFeedService } from './TeamMessageFeedService';
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamMetaStore } from './TeamMetaStore';
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
@ -65,7 +67,6 @@ import type {
KanbanColumnId,
KanbanState,
MessagesPage,
ResolvedTeamMember,
SendMessageRequest,
SendMessageResult,
TaskAttachmentMeta,
@ -74,13 +75,14 @@ import type {
TaskRef,
TeamConfig,
TeamCreateConfigRequest,
TeamData,
TeamMemberActivityMeta,
TeamMember,
TeamProcess,
TeamSummary,
TeamTask,
TeamTaskStatus,
TeamTaskWithKanban,
TeamViewSnapshot,
ToolCallMeta,
UpdateKanbanPatch,
} from '@shared/types';
@ -98,6 +100,14 @@ const TASK_MAP_YIELD_EVERY = 250;
const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification';
const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
function requireCanonicalMessageId(message: InboxMessage): string {
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
if (messageId.length > 0) {
return messageId;
}
throw new Error('Canonical team message is missing effective messageId');
}
interface EligibleTaskCommentNotification {
key: string;
messageId: string;
@ -162,6 +172,8 @@ export class TeamDataService {
private taskChangePresenceRepository: TaskChangePresenceRepository | null = null;
private teamLogSourceTracker: TeamLogSourceTracker | null = null;
private fileWatchReconcileDiagnostics = new Map<string, FileWatchReconcileDiagnostics>();
private readonly messageFeedService: TeamMessageFeedService;
private readonly memberActivityMetaService: MemberActivityMetaService;
constructor(
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
@ -183,7 +195,15 @@ export class TeamDataService {
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(),
private memberRuntimeAdvisoryService: TeamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(),
private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache()
) {}
) {
this.messageFeedService = new TeamMessageFeedService({
getConfig: (teamName) => this.configReader.getConfig(teamName),
getInboxMessages: (teamName) => this.inboxReader.getMessages(teamName),
getLeadSessionMessages: (config) => this.extractLeadSessionTexts(config),
getSentMessages: (teamName) => this.sentMessagesStore.readMessages(teamName),
});
this.memberActivityMetaService = new MemberActivityMetaService(this.messageFeedService);
}
private getController(teamName: string): AgentTeamsController {
return this.controllerFactory(teamName);
@ -622,7 +642,7 @@ export class TeamDataService {
await fs.promises.rm(tasksDir, { recursive: true, force: true });
}
async getTeamData(teamName: string): Promise<TeamData> {
async getTeamData(teamName: string): Promise<TeamViewSnapshot> {
const startedAt = Date.now();
const marks: Record<string, number> = {};
const mark = (label: string): void => {
@ -726,12 +746,6 @@ export class TeamDataService {
warningText: 'Inboxes failed to load',
load: () => this.inboxReader.listInboxNames(teamName),
});
const sentMessagesStep = startReadStep({
label: 'sentMessages',
createFallback: () => [],
warningText: 'Sent messages failed to load',
load: () => this.sentMessagesStore.readMessages(teamName),
});
const metaMembersStep = startReadStep({
label: 'metaMembers',
createFallback: () => [],
@ -756,40 +770,8 @@ export class TeamDataService {
load: () => this.taskReader.getTasks(teamName),
})
);
const messagesStep = runWithConcurrencyLimit(() =>
startReadStep({
label: 'messages',
createFallback: () => [],
warningText: 'Messages failed to load',
load: () => this.inboxReader.getMessages(teamName),
})
);
const leadTextsStep = runWithConcurrencyLimit(() =>
startReadStep({
label: 'leadTexts',
createFallback: () => [],
warningText: 'Lead session texts failed to load',
load: () => this.extractLeadSessionTexts(config),
})
);
const [
tasksStepResult,
inboxNamesStepResult,
messagesStepResult,
leadTextsStepResult,
sentMessagesStepResult,
metaMembersStepResult,
kanbanStateStepResult,
] = await Promise.all([
tasksStep,
inboxNamesStep,
messagesStep,
leadTextsStep,
sentMessagesStep,
metaMembersStep,
kanbanStateStep,
]);
const [tasksStepResult, inboxNamesStepResult, metaMembersStepResult, kanbanStateStepResult] =
await Promise.all([tasksStep, inboxNamesStep, metaMembersStep, kanbanStateStep]);
// After parallelizing the top read phase, these marks no longer represent
// serial stage boundaries. They now capture the actual completion time for
@ -797,178 +779,18 @@ export class TeamDataService {
// diagnostics useful without mutating marks from concurrent branches.
marks.tasks = tasksStepResult.completedAt;
marks.inboxNames = inboxNamesStepResult.completedAt;
marks.messages = messagesStepResult.completedAt;
marks.leadTexts = leadTextsStepResult.completedAt;
marks.sentMessages = sentMessagesStepResult.completedAt;
marks.metaMembers = metaMembersStepResult.completedAt;
marks.kanbanState = kanbanStateStepResult.completedAt;
if (tasksStepResult.warning) warnings.push(tasksStepResult.warning);
if (inboxNamesStepResult.warning) warnings.push(inboxNamesStepResult.warning);
if (messagesStepResult.warning) warnings.push(messagesStepResult.warning);
if (leadTextsStepResult.warning) warnings.push(leadTextsStepResult.warning);
if (sentMessagesStepResult.warning) warnings.push(sentMessagesStepResult.warning);
if (metaMembersStepResult.warning) warnings.push(metaMembersStepResult.warning);
if (kanbanStateStepResult.warning) warnings.push(kanbanStateStepResult.warning);
const tasks: TeamTask[] = tasksStepResult.value;
const inboxNames: string[] = inboxNamesStepResult.value;
let messages: InboxMessage[] = messagesStepResult.value;
const leadTexts: InboxMessage[] = leadTextsStepResult.value;
const sentMessages: InboxMessage[] = sentMessagesStepResult.value;
mark('postStart');
if (leadTexts.length > 0) {
messages = [...messages, ...leadTexts];
}
if (sentMessages.length > 0) {
messages = [...messages, ...sentMessages];
}
mark('mergeMessages');
// Dedup: if a lead_process message text is also present in lead_session, prefer lead_session.
// This avoids double-rendering when we persist lead process messages and later load the lead JSONL.
// Exception: lead_process messages with `to` field are captured SendMessage — never dedup those.
if (leadTexts.length > 0) {
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
const getLeadThoughtFingerprint = (
msg: Pick<InboxMessage, 'from' | 'text' | 'leadSessionId'>
) => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`;
const leadSessionFingerprints = new Set<string>();
for (const msg of leadTexts) {
if (msg.source !== 'lead_session') continue;
leadSessionFingerprints.add(getLeadThoughtFingerprint(msg));
}
messages = messages.filter((m) => {
if (m.source !== 'lead_process') return true;
// Captured SendMessage messages (with recipient) are real messages — never dedup
if (m.to) return true;
const fp = getLeadThoughtFingerprint(m);
return !leadSessionFingerprints.has(fp);
});
}
mark('dedupLeadTexts');
// Dedup exact message copies that can appear as both live lead_process rows and
// their persisted inbox/sent-message counterpart. If the messageId is identical,
// keep a single row so the UI does not show the same SendMessage twice
// (for example "LIVE" plus the stored copy).
const duplicateMessageIds = new Set<string>();
const messageIdCounts = new Map<string, number>();
for (const msg of messages) {
const id = typeof msg.messageId === 'string' ? msg.messageId.trim() : '';
if (!id) continue;
const nextCount = (messageIdCounts.get(id) ?? 0) + 1;
messageIdCounts.set(id, nextCount);
if (nextCount > 1) duplicateMessageIds.add(id);
}
if (duplicateMessageIds.size > 0) {
const choosePreferredMessage = (
current: InboxMessage,
candidate: InboxMessage
): InboxMessage => {
const score = (msg: InboxMessage): number => {
let value = 0;
if (msg.source !== 'lead_process') value += 4;
if (msg.read === false) value += 2;
if (msg.relayOfMessageId) value += 1;
if (msg.summary) value += 1;
if (msg.to) value += 1;
return value;
};
const currentScore = score(current);
const candidateScore = score(candidate);
if (candidateScore !== currentScore) {
return candidateScore > currentScore ? candidate : current;
}
const currentTs = Date.parse(current.timestamp);
const candidateTs = Date.parse(candidate.timestamp);
if (
Number.isFinite(currentTs) &&
Number.isFinite(candidateTs) &&
candidateTs !== currentTs
) {
return candidateTs > currentTs ? candidate : current;
}
return current;
};
const dedupedById = new Map<string, InboxMessage>();
const dedupedWithoutId: InboxMessage[] = [];
for (const msg of messages) {
const id = typeof msg.messageId === 'string' ? msg.messageId.trim() : '';
if (!id) {
dedupedWithoutId.push(msg);
continue;
}
const existing = dedupedById.get(id);
if (!existing) {
dedupedById.set(id, msg);
continue;
}
dedupedById.set(id, choosePreferredMessage(existing, msg));
}
messages = [...dedupedWithoutId, ...dedupedById.values()];
}
mark('dedupMessageIds');
messages = this.linkPassiveUserReplySummaries(messages);
mark('linkPassiveUserReplySummaries');
// Enrich inbox messages without leadSessionId by assigning the nearest neighbor's
// session ID (by timestamp). This avoids the old forward-only propagation bug.
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
const anchors: { index: number; time: number; sessionId: string }[] = [];
for (let i = 0; i < messages.length; i++) {
if (messages[i].leadSessionId) {
anchors.push({
index: i,
time: Date.parse(messages[i].timestamp),
sessionId: messages[i].leadSessionId!,
});
}
}
if (anchors.length > 0) {
let anchorIdx = 0;
for (let i = 0; i < messages.length; i++) {
if (messages[i].leadSessionId) {
while (anchorIdx < anchors.length - 1 && anchors[anchorIdx].index < i) {
anchorIdx++;
}
continue;
}
const msgTime = Date.parse(messages[i].timestamp);
let bestAnchor = anchors[0];
let bestDist = Math.abs(msgTime - bestAnchor.time);
for (const anchor of anchors) {
const dist = Math.abs(msgTime - anchor.time);
if (dist < bestDist) {
bestDist = dist;
bestAnchor = anchor;
} else if (dist > bestDist && anchor.time > msgTime) {
break;
}
}
messages[i].leadSessionId = bestAnchor.sessionId;
}
} else if (config.leadSessionId) {
for (const msg of messages) {
msg.leadSessionId = config.leadSessionId;
}
}
}
mark('attachLeadSessionIds');
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
this.annotateSlashCommandResponses(messages);
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
mark('normalizeMessages');
const metaMembers: TeamConfig['members'] = metaMembersStepResult.value;
const kanbanState: KanbanState = kanbanStateStepResult.value;
@ -1000,8 +822,7 @@ export class TeamDataService {
config,
metaMembers,
inboxNames,
tasksWithKanban,
messages
tasksWithKanban
);
mark('resolveMembers');
@ -1036,30 +857,13 @@ export class TeamDataService {
const totalMs = Date.now() - startedAt;
if (totalMs >= 1500) {
const counts = `counts=tasks:${tasks.length},messages:${messages.length},inboxNames:${inboxNames.length},leadTexts:${leadTexts.length},sent:${sentMessages.length},members:${members.length},processes:${processes.length}`;
const counts = `counts=tasks:${tasks.length},inboxNames:${inboxNames.length},members:${members.length},processes:${processes.length}`;
logger.warn(
`getTeamData team=${teamName} slow total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince(
'inboxNames'
)} messages=${msSince('messages')} leadTexts=${msSince('leadTexts')} sent=${msSince(
'sentMessages'
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince(
'kanbanGc'
)} post=${msBetween(
'postStart',
'mergeMessages'
)}/dedupLead=${msBetween('mergeMessages', 'dedupLeadTexts')}/dedupIds=${msBetween(
'dedupLeadTexts',
'dedupMessageIds'
)}/attachLeadSession=${msBetween(
'dedupMessageIds',
'attachLeadSessionIds'
)}/normalizeMessages=${msBetween(
'attachLeadSessionIds',
'normalizeMessages'
)}/attachKanban=${msBetween(
'normalizeMessages',
'attachKanban'
)}/loadPresenceIndex=${msBetween(
)} post=${msBetween('postStart', 'attachKanban')}/loadPresenceIndex=${msBetween(
'attachKanban',
'loadPresenceIndex'
)}/changePresence=${msBetween(
@ -1088,21 +892,14 @@ export class TeamDataService {
this.processHealthTeams.delete(teamName);
}
// Cap messages to keep IPC payloads small. Full history is available
// via the paginated getMessagesPage() API. We still include a small
// batch here for backward compatibility (notifications, dedup, etc.).
const MAX_RETURN_MESSAGES = 50;
const cappedMessages =
messages.length > MAX_RETURN_MESSAGES ? messages.slice(0, MAX_RETURN_MESSAGES) : messages;
return {
teamName,
config,
tasks: tasksWithKanban,
members,
messages: cappedMessages,
kanbanState,
processes,
isAlive: hasAlive,
warnings: warnings.length > 0 ? warnings : undefined,
};
}
@ -1113,106 +910,45 @@ export class TeamDataService {
*/
async getMessagesPage(
teamName: string,
options: { beforeTimestamp?: string; limit: number }
options: { cursor?: string | null; limit: number }
): Promise<MessagesPage> {
const config = await this.configReader.getConfig(teamName);
if (!config) {
return { messages: [], nextCursor: null, hasMore: false };
}
const feed = await this.messageFeedService.getFeed(teamName);
let messages = feed.messages;
// Collect all messages from the same sources as getTeamData
let messages: InboxMessage[] = [];
const [inboxMessages, leadTexts, sentMessages] = await Promise.all([
this.inboxReader.getMessages(teamName).catch(() => [] as InboxMessage[]),
this.extractLeadSessionTexts(config).catch(() => [] as InboxMessage[]),
this.sentMessagesStore.readMessages(teamName).catch(() => [] as InboxMessage[]),
]);
messages = [...inboxMessages, ...leadTexts, ...sentMessages];
// Dedup lead_session vs lead_process (same logic as getTeamData)
if (leadTexts.length > 0) {
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
const getFingerprint = (msg: Pick<InboxMessage, 'from' | 'text' | 'leadSessionId'>) =>
`${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`;
const leadSessionFingerprints = new Set<string>();
for (const msg of leadTexts) {
if (msg.source === 'lead_session') leadSessionFingerprints.add(getFingerprint(msg));
}
messages = messages.filter((m) => {
if (m.source !== 'lead_process') return true;
if (m.to) return true;
return !leadSessionFingerprints.has(getFingerprint(m));
});
}
// Enrich: propagate leadSessionId to messages missing it (same as getTeamData)
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
const anchors: { time: number; sessionId: string }[] = [];
for (const msg of messages) {
if (msg.leadSessionId) {
anchors.push({ time: Date.parse(msg.timestamp), sessionId: msg.leadSessionId });
}
}
if (anchors.length > 0) {
for (const msg of messages) {
if (msg.leadSessionId) continue;
const msgTime = Date.parse(msg.timestamp);
let best = anchors[0];
let bestDist = Math.abs(msgTime - best.time);
for (const a of anchors) {
const dist = Math.abs(msgTime - a.time);
if (dist < bestDist) {
bestDist = dist;
best = a;
} else if (dist > bestDist && a.time > msgTime) {
break;
}
}
msg.leadSessionId = best.sessionId;
}
} else if (config.leadSessionId) {
for (const msg of messages) {
msg.leadSessionId = config.leadSessionId;
}
}
}
// Enrich: annotate slash command responses
this.annotateSlashCommandResponses(messages);
// Sort newest-first, with stable tie-breaker by messageId
messages.sort((a, b) => {
const diff = Date.parse(b.timestamp) - Date.parse(a.timestamp);
if (diff !== 0) return diff;
return (a.messageId ?? '').localeCompare(b.messageId ?? '');
});
// Apply cursor filter. Cursor format: "timestamp|messageId" (compound)
// to handle multiple messages sharing the same timestamp.
if (options.beforeTimestamp) {
const [cursorTs, cursorId] = options.beforeTimestamp.split('|');
if (options.cursor) {
const [cursorTs, cursorId] = options.cursor.split('|');
const cursorMs = Date.parse(cursorTs);
messages = messages.filter((m) => {
const ms = Date.parse(m.timestamp);
if (ms < cursorMs) return true;
if (ms > cursorMs) return false;
// Same timestamp — use messageId tie-breaker
if (!cursorId) return false;
return (m.messageId ?? '').localeCompare(cursorId) > 0;
return requireCanonicalMessageId(m).localeCompare(cursorId) > 0;
});
}
// Paginate
const hasMore = messages.length > options.limit;
const page = messages.slice(0, options.limit);
const lastMsg = page[page.length - 1];
const nextCursor =
hasMore && lastMsg ? `${lastMsg.timestamp}|${lastMsg.messageId ?? ''}` : null;
hasMore && lastMsg ? `${lastMsg.timestamp}|${requireCanonicalMessageId(lastMsg)}` : null;
return { messages: page, nextCursor, hasMore };
return { messages: page, nextCursor, hasMore, feedRevision: feed.feedRevision };
}
async getMessageFeed(
teamName: string
): Promise<{ teamName: string; feedRevision: string; messages: InboxMessage[] }> {
return this.messageFeedService.getFeed(teamName);
}
async getMemberActivityMeta(teamName: string): Promise<TeamMemberActivityMeta> {
return this.memberActivityMetaService.getMeta(teamName);
}
invalidateMessageFeed(teamName: string): void {
this.messageFeedService.invalidate(teamName);
this.memberActivityMetaService.invalidate(teamName);
}
/**
@ -1220,7 +956,7 @@ export class TeamDataService {
* Mutates members in-place for efficiency (called right after resolveMembers).
*/
private async enrichMemberBranches(
members: ResolvedTeamMember[],
members: TeamViewSnapshot['members'],
config: TeamConfig
): Promise<void> {
const leadEntry = config.members?.find((member) => isLeadMember(member));
@ -1892,7 +1628,7 @@ export class TeamDataService {
slashCommand: slashCommandMeta,
};
}
return this.getController(teamName).messages.sendMessage({
const result = this.getController(teamName).messages.sendMessage({
member: enrichedRequest.member,
from: enrichedRequest.from,
text: enrichedRequest.text,
@ -1913,6 +1649,8 @@ export class TeamDataService {
leadSessionId: enrichedRequest.leadSessionId,
attachments: enrichedRequest.attachments,
}) as SendMessageResult;
this.invalidateMessageFeed(teamName);
return result;
}
private resolveLeadNameFromConfig(config: TeamConfig | null): string {
@ -2469,6 +2207,23 @@ export class TeamDataService {
}
}
async getTeamNotificationContext(teamName: string): Promise<{
displayName: string;
projectPath?: string;
}> {
try {
const config = await this.configReader.getConfig(teamName);
const displayName = config?.name?.trim() || teamName;
const projectPath =
typeof config?.projectPath === 'string' && config.projectPath.trim().length > 0
? config.projectPath
: undefined;
return { displayName, projectPath };
} catch {
return { displayName: teamName };
}
}
async requestReview(teamName: string, taskId: string): Promise<void> {
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
this.getController(teamName).review.requestReview(taskId, {

View file

@ -14,7 +14,12 @@ import { Worker } from 'node:worker_threads';
import { createLogger } from '@shared/utils/logger';
import type { TeamDataWorkerRequest, TeamDataWorkerResponse } from './teamDataWorkerTypes';
import type { MemberLogSummary, TeamData } from '@shared/types';
import type {
MemberLogSummary,
MessagesPage,
TeamMemberActivityMeta,
TeamViewSnapshot,
} from '@shared/types';
const logger = createLogger('Service:TeamDataWorkerClient');
const WORKER_CALL_TIMEOUT_MS = 30_000;
@ -25,16 +30,20 @@ function makeId(): string {
return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`;
}
function resolveWorkerPath(): string | null {
function getWorkerPathCandidates(): string[] {
const baseDir =
typeof __dirname === 'string' && __dirname.length > 0
? __dirname
: path.dirname(fileURLToPath(import.meta.url));
const candidates = [
return [
path.join(baseDir, 'team-data-worker.cjs'),
path.join(process.cwd(), 'dist-electron', 'main', 'team-data-worker.cjs'),
];
}
function resolveWorkerPath(): string | null {
const candidates = getWorkerPathCandidates();
for (const candidate of candidates) {
try {
@ -75,7 +84,9 @@ export class TeamDataWorkerClient {
isAvailable(): boolean {
if (!this.workerPath && !this.warnedUnavailable) {
this.warnedUnavailable = true;
logger.debug('team-data-worker not found; falling back to main-thread execution');
logger.warn(
`team-data-worker not found; heavy team data paths may fall back to main-thread execution. expectedOneOf=${getWorkerPathCandidates().join(',')}`
);
}
return this.workerPath !== null;
}
@ -144,9 +155,22 @@ export class TeamDataWorkerClient {
});
}
async getTeamData(teamName: string): Promise<TeamData> {
async getTeamData(teamName: string): Promise<TeamViewSnapshot> {
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
return this.call('getTeamData', { teamName }) as Promise<TeamData>;
return this.call('getTeamData', { teamName }) as Promise<TeamViewSnapshot>;
}
async getMessagesPage(
teamName: string,
options: { cursor?: string | null; limit: number }
): Promise<MessagesPage> {
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
return this.call('getMessagesPage', { teamName, options }) as Promise<MessagesPage>;
}
async getMemberActivityMeta(teamName: string): Promise<TeamMemberActivityMeta> {
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
return this.call('getMemberActivityMeta', { teamName }) as Promise<TeamMemberActivityMeta>;
}
async findLogsForTask(

View file

@ -3,15 +3,9 @@ import {
createCliProvisionerNameGuard,
} from '@shared/utils/teamMemberName';
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
import { getMemberColorByName } from '@shared/constants/memberColors';
import type {
InboxMessage,
MemberStatus,
ResolvedTeamMember,
TeamConfig,
TeamMember,
TeamTaskWithKanban,
} from '@shared/types';
import type { TeamConfig, TeamMember, TeamMemberSnapshot, TeamTaskWithKanban } from '@shared/types';
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
@ -63,9 +57,8 @@ export class TeamMemberResolver {
config: TeamConfig,
metaMembers: TeamConfig['members'],
inboxNames: string[],
tasks: TeamTaskWithKanban[],
messages: InboxMessage[]
): ResolvedTeamMember[] {
tasks: TeamTaskWithKanban[]
): TeamMemberSnapshot[] {
const names = new Set<string>();
const explicitNames = new Set<string>();
const seenNames = new Set<string>();
@ -216,7 +209,7 @@ export class TeamMemberResolver {
}
}
const members: ResolvedTeamMember[] = [];
const members: TeamMemberSnapshot[] = [];
for (const name of names) {
const ownedTasks = tasks.filter((task) => task.owner === name);
const currentTask =
@ -226,21 +219,15 @@ export class TeamMemberResolver {
task.reviewState !== 'approved' &&
task.kanbanColumn !== 'approved'
) ?? null;
const memberMessages = messages.filter((message) => message.from === name);
const latestMessage = memberMessages[0] ?? null;
const status = this.resolveStatus(latestMessage, currentTask !== null);
const configMember = configMemberMap.get(name);
const metaMember = metaMemberMap.get(name);
const agentId = configMember?.agentId ?? metaMember?.agentId;
members.push({
name,
agentId,
status,
currentTaskId: currentTask?.id ?? null,
taskCount: ownedTasks.length,
messageCount: memberMessages.length,
lastActiveAt: latestMessage?.timestamp ?? null,
color: latestMessage?.color ?? configMember?.color ?? metaMember?.color,
color: configMember?.color ?? metaMember?.color ?? getMemberColorByName(name),
agentType: configMember?.agentType ?? metaMember?.agentType,
role: configMember?.role ?? metaMember?.role,
workflow: configMember?.workflow ?? metaMember?.workflow,
@ -277,45 +264,4 @@ export class TeamMemberResolver {
});
return members;
}
private resolveStatus(message: InboxMessage | null, hasActiveTask: boolean): MemberStatus {
if (!message) {
// Member exists in config but has no messages yet —
// if they own an in_progress task they're clearly active, otherwise idle
return hasActiveTask ? 'active' : 'idle';
}
const structured = this.parseStructuredMessage(message.text);
if (structured) {
const typed = structured as { type?: string; approve?: boolean; approved?: boolean };
if (
(typed.type === 'shutdown_response' &&
(typed.approve === true || typed.approved === true)) ||
typed.type === 'shutdown_approved'
) {
return 'terminated';
}
}
const ageMs = Date.now() - Date.parse(message.timestamp);
if (Number.isNaN(ageMs)) {
return 'unknown';
}
if (ageMs < 5 * 60 * 1000) {
return 'active';
}
return 'idle';
}
private parseStructuredMessage(text: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(text) as unknown;
if (parsed && typeof parsed === 'object') {
return parsed as Record<string, unknown>;
}
} catch {
// Ignore plain text.
}
return null;
}
}

View file

@ -0,0 +1,409 @@
import { createHash } from 'crypto';
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
import type { InboxMessage, TeamConfig } from '@shared/types';
const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
interface TeamMessageFeedDeps {
getConfig: (teamName: string) => Promise<TeamConfig | null>;
getInboxMessages: (teamName: string) => Promise<InboxMessage[]>;
getLeadSessionMessages: (config: TeamConfig) => Promise<InboxMessage[]>;
getSentMessages: (teamName: string) => Promise<InboxMessage[]>;
}
interface TeamMessageFeedCacheEntry {
feedRevision: string;
messages: InboxMessage[];
}
export interface TeamNormalizedMessageFeed {
teamName: string;
feedRevision: string;
messages: InboxMessage[];
}
function requireCanonicalMessageId(message: InboxMessage): string {
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
if (messageId.length > 0) {
return messageId;
}
throw new Error('Normalized team message is missing effective messageId');
}
function normalizePassiveUserReplyLinkText(value: string | undefined): string {
if (typeof value !== 'string') return '';
return value
.trim()
.toLowerCase()
.replace(/\s+/g, ' ')
.replace(/[.!?…]+$/g, '')
.trim();
}
function extractPassiveUserPeerSummaryBody(text: string): string | null {
const classified = classifyIdleNotificationText(text);
if (classified?.primaryKind !== 'heartbeat' || !classified.peerSummary) {
return null;
}
const match = /^\[to\s+user\]\s*(.*)$/i.exec(classified.peerSummary);
if (!match) {
return null;
}
const body = match[1]?.trim() ?? '';
return body.length > 0 ? body : null;
}
function isLeadThoughtCandidateForSlashResult(message: InboxMessage): boolean {
if (typeof message.to === 'string' && message.to.trim().length > 0) return false;
if (message.from === 'system') return false;
return message.source === 'lead_session' || message.source === 'lead_process';
}
function annotateSlashCommandResponses(messages: InboxMessage[]): void {
let pendingSlash = null as InboxMessage['slashCommand'] | null;
for (const message of messages) {
const slashCommand =
message.source === 'user_sent'
? (message.slashCommand ?? buildStandaloneSlashCommandMeta(message.text))
: null;
if (slashCommand) {
pendingSlash = slashCommand;
continue;
}
if (!pendingSlash) {
continue;
}
if (message.messageKind === 'slash_command_result') {
continue;
}
if (isLeadThoughtCandidateForSlashResult(message)) {
message.messageKind = 'slash_command_result';
message.commandOutput = {
stream: 'stdout',
commandLabel: pendingSlash.command,
};
continue;
}
pendingSlash = null;
}
}
function linkPassiveUserReplySummaries(messages: InboxMessage[]): InboxMessage[] {
const canonicalReplies = messages
.map((message) => {
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
if (!messageId || message.to !== 'user') {
return null;
}
if (classifyIdleNotificationText(message.text)) {
return null;
}
const time = Date.parse(message.timestamp);
if (!Number.isFinite(time)) {
return null;
}
return {
messageId,
from: message.from,
time,
normalizedSummary: normalizePassiveUserReplyLinkText(message.summary),
normalizedText: normalizePassiveUserReplyLinkText(message.text),
};
})
.filter((value): value is NonNullable<typeof value> => value !== null);
if (canonicalReplies.length === 0) {
return messages;
}
let didLink = false;
const linkedMessages = messages.map((message) => {
if (
typeof message.relayOfMessageId === 'string' &&
message.relayOfMessageId.trim().length > 0
) {
return message;
}
const body = extractPassiveUserPeerSummaryBody(message.text);
if (!body) {
return message;
}
const passiveTime = Date.parse(message.timestamp);
if (!Number.isFinite(passiveTime)) {
return message;
}
const normalizedBody = normalizePassiveUserReplyLinkText(body);
if (!normalizedBody) {
return message;
}
const matches = canonicalReplies.filter((candidate) => {
if (candidate.from !== message.from) {
return false;
}
const deltaMs = passiveTime - candidate.time;
if (deltaMs < 0 || deltaMs > PASSIVE_USER_REPLY_LINK_WINDOW_MS) {
return false;
}
if (candidate.normalizedSummary === normalizedBody) {
return true;
}
return normalizedBody.length >= 6 && candidate.normalizedText.includes(normalizedBody);
});
if (matches.length !== 1) {
return message;
}
didLink = true;
return {
...message,
relayOfMessageId: matches[0].messageId,
};
});
return didLink ? linkedMessages : messages;
}
function dedupeLeadProcessCopies(
messages: InboxMessage[],
leadTexts: readonly InboxMessage[]
): InboxMessage[] {
if (leadTexts.length === 0) {
return messages;
}
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
const getFingerprint = (msg: Pick<InboxMessage, 'from' | 'text' | 'leadSessionId'>) =>
`${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`;
const leadSessionFingerprints = new Set<string>();
for (const msg of leadTexts) {
if (msg.source === 'lead_session') {
leadSessionFingerprints.add(getFingerprint(msg));
}
}
return messages.filter((message) => {
if (message.source !== 'lead_process') return true;
if (message.to) return true;
return !leadSessionFingerprints.has(getFingerprint(message));
});
}
function choosePreferredMessage(current: InboxMessage, candidate: InboxMessage): InboxMessage {
const score = (msg: InboxMessage): number => {
let value = 0;
if (msg.source !== 'lead_process') value += 4;
if (msg.read === false) value += 2;
if (msg.relayOfMessageId) value += 1;
if (msg.summary) value += 1;
if (msg.to) value += 1;
return value;
};
const currentScore = score(current);
const candidateScore = score(candidate);
if (candidateScore !== currentScore) {
return candidateScore > currentScore ? candidate : current;
}
const currentTs = Date.parse(current.timestamp);
const candidateTs = Date.parse(candidate.timestamp);
if (Number.isFinite(currentTs) && Number.isFinite(candidateTs) && candidateTs !== currentTs) {
return candidateTs > currentTs ? candidate : current;
}
return current;
}
function dedupeByMessageId(messages: InboxMessage[]): InboxMessage[] {
const dedupedById = new Map<string, InboxMessage>();
const dedupedWithoutId: InboxMessage[] = [];
for (const message of messages) {
const id = typeof message.messageId === 'string' ? message.messageId.trim() : '';
if (!id) {
dedupedWithoutId.push(message);
continue;
}
const existing = dedupedById.get(id);
if (!existing) {
dedupedById.set(id, message);
continue;
}
dedupedById.set(id, choosePreferredMessage(existing, message));
}
return [...dedupedWithoutId, ...dedupedById.values()];
}
function ensureEffectiveMessageIds(messages: InboxMessage[]): InboxMessage[] {
let changed = false;
const normalized = messages.map((message) => {
const effectiveMessageId = getEffectiveInboxMessageId(message);
if (!effectiveMessageId || effectiveMessageId === message.messageId) {
return message;
}
changed = true;
return {
...message,
messageId: effectiveMessageId,
};
});
return changed ? normalized : messages;
}
function attachLeadSessionIds(config: TeamConfig, messages: InboxMessage[]): void {
if (!config.leadSessionId && !messages.some((message) => message.leadSessionId)) {
return;
}
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
const anchors: { time: number; sessionId: string }[] = [];
for (const message of messages) {
if (message.leadSessionId) {
anchors.push({ time: Date.parse(message.timestamp), sessionId: message.leadSessionId });
}
}
if (anchors.length > 0) {
for (const message of messages) {
if (message.leadSessionId) continue;
const messageTime = Date.parse(message.timestamp);
let best = anchors[0];
let bestDistance = Math.abs(messageTime - best.time);
for (const anchor of anchors) {
const distance = Math.abs(messageTime - anchor.time);
if (distance < bestDistance) {
bestDistance = distance;
best = anchor;
} else if (distance > bestDistance && anchor.time > messageTime) {
break;
}
}
message.leadSessionId = best.sessionId;
}
return;
}
if (!config.leadSessionId) {
return;
}
for (const message of messages) {
message.leadSessionId = config.leadSessionId;
}
}
function toFeedRevision(messages: readonly InboxMessage[]): string {
const stableMessages = messages.map((message) => ({
messageId: message.messageId ?? null,
relayOfMessageId: message.relayOfMessageId ?? null,
from: message.from,
to: message.to ?? null,
text: message.text,
timestamp: message.timestamp,
read: message.read,
summary: message.summary ?? null,
color: message.color ?? null,
source: message.source ?? null,
attachments: message.attachments ?? null,
leadSessionId: message.leadSessionId ?? null,
conversationId: message.conversationId ?? null,
replyToConversationId: message.replyToConversationId ?? null,
toolSummary: message.toolSummary ?? null,
toolCalls: message.toolCalls ?? null,
messageKind: message.messageKind ?? null,
slashCommand: message.slashCommand ?? null,
commandOutput: message.commandOutput ?? null,
}));
return createHash('sha256').update(JSON.stringify(stableMessages)).digest('hex').slice(0, 24);
}
export class TeamMessageFeedService {
private readonly cacheByTeam = new Map<string, TeamMessageFeedCacheEntry>();
private readonly dirtyTeams = new Set<string>();
constructor(private readonly deps: TeamMessageFeedDeps) {}
invalidate(teamName: string): void {
this.dirtyTeams.add(teamName);
}
async getFeed(teamName: string): Promise<TeamNormalizedMessageFeed> {
const cached = this.cacheByTeam.get(teamName);
if (cached && !this.dirtyTeams.has(teamName)) {
return {
teamName,
feedRevision: cached.feedRevision,
messages: cached.messages,
};
}
const config = await this.deps.getConfig(teamName);
if (!config) {
const emptyEntry = { feedRevision: toFeedRevision([]), messages: [] };
this.cacheByTeam.set(teamName, emptyEntry);
this.dirtyTeams.delete(teamName);
return { teamName, ...emptyEntry };
}
const [inboxMessages, leadTexts, sentMessages] = await Promise.all([
this.deps.getInboxMessages(teamName).catch(() => [] as InboxMessage[]),
this.deps.getLeadSessionMessages(config).catch(() => [] as InboxMessage[]),
this.deps.getSentMessages(teamName).catch(() => [] as InboxMessage[]),
]);
let messages = [...inboxMessages, ...leadTexts, ...sentMessages];
messages = dedupeLeadProcessCopies(messages, leadTexts);
messages = ensureEffectiveMessageIds(messages);
messages = dedupeByMessageId(messages);
messages = linkPassiveUserReplySummaries(messages);
attachLeadSessionIds(config, messages);
annotateSlashCommandResponses(messages);
messages.sort((left, right) => {
const diff = Date.parse(right.timestamp) - Date.parse(left.timestamp);
if (diff !== 0) return diff;
return requireCanonicalMessageId(left).localeCompare(requireCanonicalMessageId(right));
});
const feedRevision = toFeedRevision(messages);
const nextEntry =
cached && cached.feedRevision === feedRevision
? cached
: {
feedRevision,
messages,
};
this.cacheByTeam.set(teamName, nextEntry);
this.dirtyTeams.delete(teamName);
return {
teamName,
feedRevision: nextEntry.feedRevision,
messages: nextEntry.messages,
};
}
}

View file

@ -2,7 +2,12 @@
* Shared request/response types for the team-data-worker thread.
*/
import type { MemberLogSummary, TeamData } from '@shared/types';
import type {
MemberLogSummary,
MessagesPage,
TeamMemberActivityMeta,
TeamViewSnapshot,
} from '@shared/types';
// ── Payloads ──
@ -10,6 +15,18 @@ export interface GetTeamDataPayload {
teamName: string;
}
export interface GetMessagesPagePayload {
teamName: string;
options: {
cursor?: string | null;
limit: number;
};
}
export interface GetMemberActivityMetaPayload {
teamName: string;
}
export interface FindLogsForTaskPayload {
teamName: string;
taskId: string;
@ -25,8 +42,14 @@ export interface FindLogsForTaskPayload {
export type TeamDataWorkerRequest =
| { id: string; op: 'getTeamData'; payload: GetTeamDataPayload }
| { id: string; op: 'getMessagesPage'; payload: GetMessagesPagePayload }
| { id: string; op: 'getMemberActivityMeta'; payload: GetMemberActivityMetaPayload }
| { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload };
export type TeamDataWorkerResponse =
| { id: string; ok: true; result: TeamData | MemberLogSummary[] }
| {
id: string;
ok: true;
result: TeamViewSnapshot | MessagesPage | TeamMemberActivityMeta | MemberLogSummary[];
}
| { id: string; ok: false; error: string };

View file

@ -42,6 +42,19 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => {
respond({ id: msg.id, ok: true, result });
break;
}
case 'getMessagesPage': {
const result = await teamDataService.getMessagesPage(
msg.payload.teamName,
msg.payload.options
);
respond({ id: msg.id, ok: true, result });
break;
}
case 'getMemberActivityMeta': {
const result = await teamDataService.getMemberActivityMeta(msg.payload.teamName);
respond({ id: msg.id, ok: true, result });
break;
}
case 'findLogsForTask': {
const { teamName, taskId, options } = msg.payload;
const intervalsKey = options?.intervals

View file

@ -234,6 +234,9 @@ export const TEAM_SEND_MESSAGE = 'team:sendMessage';
/** Paginated messages for timeline/messages panel */
export const TEAM_GET_MESSAGES_PAGE = 'team:getMessagesPage';
/** Lightweight message-derived member activity facts */
export const TEAM_GET_MEMBER_ACTIVITY_META = 'team:getMemberActivityMeta';
/** Request review for task */
export const TEAM_REQUEST_REVIEW = 'team:requestReview';

View file

@ -126,6 +126,7 @@ import {
TEAM_GET_DATA,
TEAM_GET_DELETED_TASKS,
TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_MEMBER_ACTIVITY_META,
TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS,
TEAM_GET_MESSAGES_PAGE,
@ -263,6 +264,7 @@ import type {
LeadContextUsageSnapshot,
MemberFullStats,
MemberLogSummary,
TeamMemberActivityMeta,
MemberSpawnStatusesSnapshot,
MessagesPage,
NotificationTrigger,
@ -292,10 +294,10 @@ import type {
TeamCreateConfigRequest,
TeamCreateRequest,
TeamCreateResponse,
TeamData,
TeamLaunchRequest,
TeamLaunchResponse,
TeamMessageNotificationData,
TeamViewSnapshot,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamSummary,
@ -822,7 +824,7 @@ const electronAPI: ElectronAPI = {
return invokeIpcWithResult<TeamSummary[]>(TEAM_LIST);
},
getData: async (teamName: string) => {
return invokeIpcWithResult<TeamData>(TEAM_GET_DATA, teamName);
return invokeIpcWithResult<TeamViewSnapshot>(TEAM_GET_DATA, teamName);
},
getTaskChangePresence: async (teamName: string) => {
return invokeIpcWithResult<Record<string, TaskChangePresenceState>>(
@ -883,10 +885,13 @@ const electronAPI: ElectronAPI = {
},
getMessagesPage: async (
teamName: string,
options?: { beforeTimestamp?: string; limit?: number }
options?: { cursor?: string | null; limit?: number }
) => {
return invokeIpcWithResult<MessagesPage>(TEAM_GET_MESSAGES_PAGE, teamName, options);
},
getMemberActivityMeta: async (teamName: string) => {
return invokeIpcWithResult<TeamMemberActivityMeta>(TEAM_GET_MEMBER_ACTIVITY_META, teamName);
},
createTask: async (teamName: string, request: CreateTaskRequest) => {
return invokeIpcWithResult<TeamTask>(TEAM_CREATE_TASK, teamName, request);
},

View file

@ -54,25 +54,26 @@ import type {
SshLastConnection,
SubagentDetail,
TeamChangeEvent,
UpdateSchedulePatch,
TeamClaudeLogsQuery,
TeamClaudeLogsResponse,
TeamCreateRequest,
TeamCreateResponse,
TeamData,
TeamLaunchRequest,
TeamLaunchResponse,
TeamMemberActivityMeta,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamsAPI,
TeamSummary,
TeamTask,
TeamTaskStatus,
TeamViewSnapshot,
TmuxAPI,
TmuxStatus,
TriggerTestResult,
UpdateKanbanPatch,
UpdaterAPI,
UpdateSchedulePatch,
WaterfallData,
WslClaudeRootCandidate,
} from '@shared/types';
@ -677,7 +678,7 @@ export class HttpAPIClient implements ElectronAPI {
console.warn('[HttpAPIClient] teams API is not available in browser mode');
return [];
},
getData: async (_teamName: string): Promise<TeamData> => {
getData: async (_teamName: string): Promise<TeamViewSnapshot> => {
throw new Error('Teams detail is not available in browser mode');
},
getTaskChangePresence: async (): Promise<
@ -740,7 +741,15 @@ export class HttpAPIClient implements ElectronAPI {
throw new Error('Team messaging is not available in browser mode');
},
getMessagesPage: async () => {
return { messages: [], nextCursor: null, hasMore: false };
return { messages: [], nextCursor: null, hasMore: false, feedRevision: 'empty' };
},
getMemberActivityMeta: async (_teamName: string): Promise<TeamMemberActivityMeta> => {
return {
teamName: _teamName,
computedAt: new Date(0).toISOString(),
members: {},
feedRevision: 'empty',
};
},
createTask: async (_teamName: string, _request: CreateTaskRequest): Promise<TeamTask> => {
throw new Error('Team task creation is not available in browser mode');

View file

@ -7,6 +7,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
import { useTabUI } from '@renderer/hooks/useTabUI';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
@ -398,7 +399,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
// Get team members for @mention highlighting and team names for @team linkification
const { members, teams } = useStore(
useShallow((s) => ({
members: s.selectedTeamData?.members,
members: selectResolvedMembersForTeamName(s, s.selectedTeamName),
teams: s.teams,
}))
);

View file

@ -10,6 +10,7 @@ import {
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
import { formatTokensCompact } from '@renderer/utils/formatters';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
@ -86,7 +87,9 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
const { isLight } = useTheme();
// Get team members for @mention highlighting
const members = useStore(useShallow((s) => s.selectedTeamData?.members));
const members = useStore(
useShallow((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName))
);
const memberColorMap = useMemo(
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
[members]

View file

@ -4,6 +4,7 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
@ -70,14 +71,16 @@ export const TaskTooltip = ({
children,
side = 'top',
}: TaskTooltipProps): React.JSX.Element => {
const { selectedTeamName, selectedTeamData, globalTasks, teamByName } = useStore(
useShallow((s) => ({
selectedTeamName: s.selectedTeamName,
selectedTeamData: s.selectedTeamData,
globalTasks: s.globalTasks,
teamByName: s.teamByName,
}))
);
const { selectedTeamName, selectedTeamData, selectedTeamMembers, globalTasks, teamByName } =
useStore(
useShallow((s) => ({
selectedTeamName: s.selectedTeamName,
selectedTeamData: s.selectedTeamData,
selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName),
globalTasks: s.globalTasks,
teamByName: s.teamByName,
}))
);
const task = useMemo(() => {
if (teamName && selectedTeamName === teamName) {
@ -105,13 +108,13 @@ export const TaskTooltip = ({
const members = useMemo(() => {
if (teamName && selectedTeamName === teamName) {
return selectedTeamData?.members ?? [];
return selectedTeamMembers;
}
if (!teamName && task && selectedTeamName === (task as { teamName?: string }).teamName) {
return selectedTeamData?.members ?? [];
return selectedTeamMembers;
}
return [];
}, [selectedTeamData, selectedTeamName, teamName, task]);
}, [selectedTeamMembers, selectedTeamName, teamName, task]);
const colorMap = useMemo(
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),

View file

@ -23,6 +23,9 @@ import { useStore } from '@renderer/store';
import {
getCurrentProvisioningProgressForTeam,
isTeamProvisioningActive,
selectResolvedMemberForTeamName,
selectResolvedMembersForTeamName,
selectTeamMemberSnapshotsForName,
} from '@renderer/store/slices/teamSlice';
import { createChipFromSelection } from '@renderer/utils/chipUtils';
import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath';
@ -740,16 +743,18 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
}: TeamMemberDetailDialogBridgeProps): React.JSX.Element | null {
const {
leadActivity,
liveMember,
progress,
members: launchMembers,
launchMembers,
memberSpawnStatuses,
memberSpawnSnapshot,
spawnEntry,
} = useStore(
useShallow((s) => ({
leadActivity: s.leadActivityByTeam[teamName],
liveMember: member ? selectResolvedMemberForTeamName(s, teamName, member.name) : null,
progress: getCurrentProvisioningProgressForTeam(s, teamName),
members: s.selectedTeamName === teamName ? (s.selectedTeamData?.members ?? []) : [],
launchMembers: selectTeamMemberSnapshotsForName(s, teamName),
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined,
@ -772,7 +777,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
<MemberDetailDialog
{...props}
teamName={teamName}
member={member}
member={liveMember ?? member}
isLaunchSettling={isLaunchSettling}
leadActivity={leadActivity}
spawnEntry={spawnEntry}
@ -821,7 +826,6 @@ export const TeamDetailView = ({
);
const provisioningBannerRef = useRef<HTMLDivElement>(null);
const wasProvisioningRef = useRef(false);
const pendingReplyRefreshTimerRef = useRef<number | null>(null);
const handleOpenGraphTab = useCallback(() => {
const state = useStore.getState();
const displayName = state.teamByName[teamName]?.displayName ?? teamName;
@ -898,7 +902,7 @@ export const TeamDetailView = ({
initialActivityFilter,
} = (e as CustomEvent).detail ?? {};
if (tn !== teamName || !data) return;
const member = data.members.find((m: { name: string }) => m.name === memberName);
const member = members.find((m: { name: string }) => m.name === memberName);
if (member) {
setSelectedMember(member);
setSelectedMemberView({
@ -1059,6 +1063,7 @@ export const TeamDetailView = ({
const {
data,
members,
loading,
error,
projects,
@ -1088,6 +1093,7 @@ export const TeamDetailView = ({
clearProvisioningError,
isTeamProvisioning,
refreshTeamData,
syncTeamPendingReplyRefresh,
kanbanFilterQuery,
clearKanbanFilter,
softDeleteTask,
@ -1133,9 +1139,11 @@ export const TeamDetailView = ({
clearProvisioningError: s.clearProvisioningError,
isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false,
data: s.selectedTeamName === teamName ? s.selectedTeamData : null,
members: selectResolvedMembersForTeamName(s, teamName),
loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false,
error: s.selectedTeamName === teamName ? s.selectedTeamError : null,
refreshTeamData: s.refreshTeamData,
syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh,
kanbanFilterQuery: s.kanbanFilterQuery,
clearKanbanFilter: s.clearKanbanFilter,
softDeleteTask: s.softDeleteTask,
@ -1169,13 +1177,12 @@ export const TeamDetailView = ({
diagnostic.count += 1;
const commitMs = performance.now() - renderStartedAtRef.current;
const messagesCount = data?.messages.length ?? 0;
const tasksCount = data?.tasks.length ?? 0;
const membersCount = data?.members.length ?? 0;
const membersCount = members.length;
const processesCount = data?.processes.length ?? 0;
const shouldWarnSlow = commitMs >= TEAM_DETAIL_COMMIT_WARN_MS;
const shouldWarnBurst = diagnostic.count >= TEAM_DETAIL_RENDER_BURST_WARN_COUNT;
const shouldWarnLarge = messagesCount >= 150 || tasksCount >= 80;
const shouldWarnLarge = tasksCount >= 80;
if (
(shouldWarnSlow || shouldWarnBurst || shouldWarnLarge) &&
@ -1187,7 +1194,7 @@ export const TeamDetailView = ({
now - diagnostic.windowStartedAt
} activeTab=${isThisTabActive ? 'yes' : 'no'} paneFocused=${isPaneFocused ? 'yes' : 'no'} loading=${
loading ? 'yes' : 'no'
} messages=${messagesCount} tasks=${tasksCount} members=${membersCount} processes=${processesCount} panel=${messagesPanelMode}`
} tasks=${tasksCount} members=${membersCount} processes=${processesCount} panel=${messagesPanelMode}`
);
}
});
@ -1307,30 +1314,20 @@ export const TeamDetailView = ({
);
// Keep team message state fresh while we are explicitly waiting for a reply.
// Use a delayed single-shot refresh instead of a tight polling loop so we
// don't keep rewriting the whole team snapshot every 2 seconds.
// This stays enabled even for hidden mounted tabs, because the waiting state
// is renderer-local and should keep its lightweight polling until resolved.
useEffect(() => {
if (pendingReplyRefreshTimerRef.current != null) {
window.clearTimeout(pendingReplyRefreshTimerRef.current);
pendingReplyRefreshTimerRef.current = null;
}
if (!isThisTabActive) return;
if (!data?.isAlive) return;
if (Object.keys(pendingRepliesByMember).length === 0) return;
pendingReplyRefreshTimerRef.current = window.setTimeout(() => {
pendingReplyRefreshTimerRef.current = null;
void refreshTeamData(teamName, { withDedup: true });
}, TEAM_PENDING_REPLY_REFRESH_DELAY_MS);
const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0;
syncTeamPendingReplyRefresh(
teamName,
Boolean(data?.isAlive) && hasPendingReplies,
TEAM_PENDING_REPLY_REFRESH_DELAY_MS
);
return () => {
if (pendingReplyRefreshTimerRef.current != null) {
window.clearTimeout(pendingReplyRefreshTimerRef.current);
pendingReplyRefreshTimerRef.current = null;
}
syncTeamPendingReplyRefresh(teamName, false);
};
}, [isThisTabActive, data, pendingRepliesByMember, refreshTeamData, teamName]);
}, [data?.isAlive, pendingRepliesByMember, syncTeamPendingReplyRefresh, teamName]);
useEffect(() => {
if (!projectId) return;
@ -1364,9 +1361,9 @@ export const TeamDetailView = ({
// Live git branch tracking for the lead project and member worktrees
const teamProjectPath = data?.config.projectPath?.trim() ?? null;
const leadProjectPath = useMemo(() => {
const explicitLeadPath = data?.members.find((member) => isLeadMember(member))?.cwd?.trim();
const explicitLeadPath = members.find((member) => isLeadMember(member))?.cwd?.trim();
return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath;
}, [data?.members, teamProjectPath]);
}, [members, teamProjectPath]);
const branchSyncPaths = useMemo(() => {
const uniquePaths = new Map<string, string>();
const addPath = (candidate: string | null | undefined): void => {
@ -1378,12 +1375,12 @@ export const TeamDetailView = ({
};
addPath(leadProjectPath);
for (const member of data?.members ?? []) {
for (const member of members) {
addPath(member.cwd);
}
return Array.from(uniquePaths.values());
}, [data?.members, leadProjectPath]);
}, [members, leadProjectPath]);
useBranchSync(branchSyncPaths, { live: true });
const trackedBranches = useStore(
useShallow((s) =>
@ -1401,7 +1398,7 @@ export const TeamDetailView = ({
const membersWithLiveBranches = useMemo(() => {
if (!data) return [];
return data.members.map((member) => {
return members.map((member) => {
const memberPath = member.cwd?.trim();
const nextGitBranch =
memberPath && !isLeadMember(member) && leadBranch !== null
@ -1423,7 +1420,7 @@ export const TeamDetailView = ({
}
return nextMember;
});
}, [data, leadBranch, trackedBranches]);
}, [leadBranch, members, trackedBranches]);
// Filter sessions to team-only using sessionHistory + leadSessionId
const teamSessionIds = useMemo(() => {
@ -1787,7 +1784,6 @@ export const TeamDetailView = ({
mountPoint: messagesPanelMountPoint,
members: activeMembers,
tasks: data?.tasks ?? [],
messages: data?.messages ?? [],
isTeamAlive: data?.isAlive,
timeWindow,
teamSessionIds,
@ -1805,7 +1801,6 @@ export const TeamDetailView = ({
activeMembers,
data?.config.leadSessionId,
data?.isAlive,
data?.messages,
data?.tasks,
handleCreateTaskFromMessage,
handleOpenTask,
@ -2482,7 +2477,7 @@ export const TeamDetailView = ({
open={requestChangesTaskId !== null}
teamName={teamName}
taskId={requestChangesTaskId}
members={data?.members ?? []}
members={members}
onCancel={() => setRequestChangesTaskId(null)}
onSubmit={(comment, taskRefs) => {
if (!requestChangesTaskId) {
@ -2509,7 +2504,6 @@ export const TeamDetailView = ({
teamName={teamName}
members={membersWithLiveBranches}
tasks={data.tasks}
messages={data.messages}
initialTab={selectedMemberView?.initialTab}
initialActivityFilter={selectedMemberView?.initialActivityFilter}
isTeamAlive={data.isAlive}
@ -2858,7 +2852,7 @@ export const TeamDetailView = ({
if (task) setSelectedTask(task);
}}
onOpenMemberProfile={(memberName, options) => {
const member = data.members.find((m) => m.name === memberName);
const member = members.find((m) => m.name === memberName);
if (member) {
setSelectedMember(member);
setSelectedMemberView({

View file

@ -56,6 +56,7 @@ import {
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
import type { TeamListFilterState } from './TeamListFilterPopover';
import type {
TeamMemberSnapshot,
ResolvedTeamMember,
TeamCreateRequest,
TeamLaunchRequest,
@ -94,6 +95,17 @@ function folderName(fullPath: string): string {
return getBaseName(fullPath) || fullPath;
}
function resolveLaunchDialogMembers(members: readonly TeamMemberSnapshot[]): ResolvedTeamMember[] {
return members.map((member) => {
return {
...member,
status: member.currentTaskId ? 'active' : 'idle',
messageCount: 0,
lastActiveAt: null,
};
});
}
function renderMemberChips(members: TeamSummaryMember[], isLight: boolean): React.JSX.Element {
const teamColorMap = buildMemberColorMap(members);
return (
@ -625,7 +637,7 @@ export const TeamListView = (): React.JSX.Element => {
try {
const data = await api.teams.getData(teamName);
setLaunchDialogTeamName(teamName);
setLaunchDialogMembers(data.members ?? []);
setLaunchDialogMembers(resolveLaunchDialogMembers(data.members ?? []));
setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath);
setLaunchDialogOpen(true);
} catch (err) {

View file

@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { shortenDisplayPath } from '@renderer/utils/pathDisplay';
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react';
@ -149,6 +150,7 @@ export const ToolApprovalSheet: React.FC = () => {
teams,
selectedTeamName,
selectedTeamData,
selectedTeamMembers,
} = useStore(
useShallow((s) => ({
pendingApprovals: s.pendingApprovals,
@ -157,6 +159,7 @@ export const ToolApprovalSheet: React.FC = () => {
teams: s.teams,
selectedTeamName: s.selectedTeamName,
selectedTeamData: s.selectedTeamData,
selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName),
}))
);
const { isLight } = useTheme();
@ -273,9 +276,9 @@ export const ToolApprovalSheet: React.FC = () => {
// Resolve teammate color for MemberBadge (when source !== 'lead')
const sourceColor = useMemo(() => {
if (!current || current.source === 'lead') return undefined;
const member = selectedTeamData?.members?.find((m) => m.name === current.source);
const member = selectedTeamMembers.find((m) => m.name === current.source);
return member?.color;
}, [current, selectedTeamData?.members]);
}, [current, selectedTeamMembers]);
if (!current) return null;

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { buildTaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest';
import { ExternalLink } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@ -24,6 +25,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
closeGlobalTaskDetail,
selectedTeamName,
selectedTeamData,
selectedTeamMembers,
selectedTeamLoading,
selectedTeamError,
selectTeam,
@ -36,6 +38,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
closeGlobalTaskDetail: s.closeGlobalTaskDetail,
selectedTeamName: s.selectedTeamName,
selectedTeamData: s.selectedTeamData,
selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName),
selectedTeamLoading: s.selectedTeamLoading,
selectedTeamError: s.selectedTeamError,
selectTeam: s.selectTeam,
@ -94,8 +97,8 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
}, [globalTaskDetail, globalTasks, isFullTeamLoaded, selectedTeamData]);
const activeMembers = useMemo(
() => (isFullTeamLoaded ? (selectedTeamData?.members.filter((m) => !m.removedAt) ?? []) : []),
[isFullTeamLoaded, selectedTeamData]
() => (isFullTeamLoaded ? selectedTeamMembers.filter((m) => !m.removedAt) : []),
[isFullTeamLoaded, selectedTeamMembers]
);
const handleOpenTeam = useCallback((): void => {

View file

@ -36,7 +36,10 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
import {
isTeamProvisioningActive,
selectResolvedMembersForTeamName,
} from '@renderer/store/slices/teamSlice';
import {
isGeminiUiFrozen,
normalizeCreateLaunchProviderForUi,
@ -281,7 +284,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
const prepareRequestSeqRef = useRef(0);
const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []);
const storeMembers = useStore((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName));
const previousLaunchParams = useStore((s) =>
effectiveTeamName ? s.launchParamsByTeam[effectiveTeamName] : undefined
);

View file

@ -1,9 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import {
buildGraphMemberNodeIdForMember,
buildInlineActivityEntries,
} from '@features/agent-graph/renderer';
import { Button } from '@renderer/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
@ -20,7 +16,6 @@ import { MemberStatsTab } from './MemberStatsTab';
import { MemberTasksTab } from './MemberTasksTab';
import type {
InboxMessage,
LeadActivityState,
MemberSpawnStatusEntry,
ResolvedTeamMember,
@ -33,7 +28,6 @@ interface MemberDetailDialogProps {
teamName: string;
members: ResolvedTeamMember[];
tasks: TeamTaskWithKanban[];
messages: InboxMessage[];
initialTab?: MemberDetailTab;
initialActivityFilter?: MemberActivityFilter;
isTeamAlive?: boolean;
@ -57,7 +51,6 @@ export const MemberDetailDialog = ({
teamName,
members,
tasks,
messages,
initialTab = 'tasks',
initialActivityFilter = 'all',
isTeamAlive,
@ -78,34 +71,7 @@ export const MemberDetailDialog = ({
() => (member ? tasks.filter((t) => t.owner === member.name) : []),
[tasks, member]
);
const seedMemberMessages = useMemo(
() => (member ? messages.filter((m) => m.from === member.name || m.to === member.name) : []),
[messages, member]
);
const memberMessages = seedMemberMessages;
const memberActivityCount = useMemo(() => {
if (!member) {
return 0;
}
const leadId = `lead:${teamName}`;
const leadName =
members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`;
const ownerNodeId =
member.name === leadName ? leadId : buildGraphMemberNodeIdForMember(teamName, member);
const entries = buildInlineActivityEntries({
data: {
members,
tasks,
messages: memberMessages,
},
teamName,
leadId,
leadName,
ownerNodeIds: new Set([leadId, ownerNodeId]),
});
return (entries.get(ownerNodeId) ?? []).length;
}, [member, memberMessages, members, tasks, teamName]);
const memberActivityCount = member?.messageCount ?? 0;
const inProgressTasks = useMemo(
() => memberTasks.filter((t) => t.status === 'in_progress').length,
@ -206,7 +172,6 @@ export const MemberDetailDialog = ({
</TabsContent>
<TabsContent value="activity">
<MemberMessagesTab
messages={memberMessages}
teamName={teamName}
memberName={member.name}
members={members}

View file

@ -8,7 +8,13 @@ import {
} from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice';
import {
getCurrentProvisioningProgressForTeam,
selectResolvedMemberForTeamName,
selectTeamIsAliveForName,
selectTeamMemberSnapshotsForName,
selectTeamTasksForName,
} from '@renderer/store/slices/teamSlice';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
agentAvatarUrl,
@ -17,6 +23,7 @@ import {
} from '@renderer/utils/memberHelpers';
import { isLeadMember } from '@shared/utils/leadDetection';
import { ExternalLink } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps';
@ -38,7 +45,7 @@ interface MemberHoverCardProps {
/**
* Wraps children in a HoverCard that shows member info on hover.
* Reads member data from the store (selectedTeamData.members).
* Reads member data from the team snapshot + resolved member selectors.
* Falls back to a simple wrapper when member data is unavailable.
*/
export const MemberHoverCard = ({
@ -53,20 +60,22 @@ export const MemberHoverCard = ({
const effectiveTeamName = teamName ?? selectedTeamName;
const {
member,
members,
teamMembers,
tasks,
isTeamAlive,
progress,
memberSpawnSnapshot,
memberSpawnStatuses,
spawnEntry,
leadActivity,
} = useStore((s) => {
const isSelectedTeam = Boolean(effectiveTeamName && s.selectedTeamName === effectiveTeamName);
const selectedTeamData = isSelectedTeam ? s.selectedTeamData : null;
return {
member: selectedTeamData?.members.find((m) => m.name === name) ?? null,
members: selectedTeamData?.members ?? [],
isTeamAlive: selectedTeamData?.isAlive,
} = useStore(
useShallow((s) => ({
member: effectiveTeamName
? selectResolvedMemberForTeamName(s, effectiveTeamName, name)
: null,
teamMembers: effectiveTeamName ? selectTeamMemberSnapshotsForName(s, effectiveTeamName) : [],
tasks: effectiveTeamName ? selectTeamTasksForName(s, effectiveTeamName) : [],
isTeamAlive: effectiveTeamName ? selectTeamIsAliveForName(s, effectiveTeamName) : undefined,
progress: effectiveTeamName
? getCurrentProvisioningProgressForTeam(s, effectiveTeamName)
: null,
@ -80,21 +89,16 @@ export const MemberHoverCard = ({
? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name]
: undefined,
leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined,
};
});
const openMemberProfile = useStore((s) => s.openMemberProfile);
const tasks = useStore((s) =>
effectiveTeamName && s.selectedTeamName === effectiveTeamName
? s.selectedTeamData?.tasks
: undefined
}))
);
const openMemberProfile = useStore((s) => s.openMemberProfile);
if (!member) {
return <>{children}</>;
}
const launchJoinMilestones = getLaunchJoinMilestonesFromMembers({
members,
members: teamMembers,
memberSpawnStatuses,
memberSpawnSnapshot,
});
@ -117,10 +121,9 @@ export const MemberHoverCard = ({
const presenceLabel = launchPresentation.presenceLabel;
const dotClass = launchPresentation.dotClass;
const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle;
const currentTask: TeamTaskWithKanban | null =
member.currentTaskId && tasks
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
: null;
const currentTask: TeamTaskWithKanban | null = member.currentTaskId
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
: null;
const reviewTask: TeamTaskWithKanban | null = tasks
? (tasks.find(
(task) =>

View file

@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { buildInlineActivityEntries } from '@features/agent-graph/renderer';
import { api } from '@renderer/api';
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
import {
buildMessageContext,
@ -10,17 +9,18 @@ import {
import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog';
import { Button } from '@renderer/components/ui/button';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
import { useStore } from '@renderer/store';
import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice';
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { isLeadMember } from '@shared/utils/leadDetection';
import { useShallow } from 'zustand/react/shallow';
import type { MemberActivityFilter } from './memberDetailTypes';
import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup';
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
interface MemberMessagesTabProps {
messages: InboxMessage[];
teamName: string;
memberName: string;
members: ResolvedTeamMember[];
@ -31,7 +31,6 @@ interface MemberMessagesTabProps {
}
const MAX_MESSAGES = 100;
const MEMBER_MESSAGES_PAGE_SIZE = 50;
const FILTER_OPTIONS: readonly { value: MemberActivityFilter; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'messages', label: 'Messages' },
@ -39,7 +38,6 @@ const FILTER_OPTIONS: readonly { value: MemberActivityFilter; label: string }[]
];
export const MemberMessagesTab = ({
messages,
teamName,
memberName,
members,
@ -48,12 +46,15 @@ export const MemberMessagesTab = ({
onCreateTask,
onTaskClick,
}: MemberMessagesTabProps): React.JSX.Element => {
const [pagedMessages, setPagedMessages] = useState<InboxMessage[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(false);
const [activityFilter, setActivityFilter] = useState<MemberActivityFilter>(initialFilter);
const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(null);
const { messages, messagesState, loadOlderTeamMessages } = useStore(
useShallow((s) => ({
messages: selectMemberMessagesForTeamMember(s, teamName, memberName),
messagesState: teamName ? s.teamMessagesByName[teamName] : undefined,
loadOlderTeamMessages: s.loadOlderTeamMessages,
}))
);
const { readSet } = useTeamMessagesRead(teamName);
const leadId = `lead:${teamName}`;
const leadName = useMemo(
@ -69,75 +70,24 @@ export const MemberMessagesTab = ({
setActivityFilter(initialFilter);
}, [initialFilter, memberName, teamName]);
useEffect(() => {
let cancelled = false;
setPagedMessages([]);
setNextCursor(null);
setHasMore(false);
setLoading(true);
void (async () => {
try {
const page = await api.teams.getMessagesPage(teamName, {
limit: MEMBER_MESSAGES_PAGE_SIZE,
});
if (cancelled) return;
const memberPageMessages = page.messages.filter(
(message) => message.from === memberName || message.to === memberName
);
setPagedMessages(memberPageMessages);
setNextCursor(page.nextCursor);
setHasMore(page.hasMore);
} catch {
if (!cancelled) {
setPagedMessages([]);
setNextCursor(null);
setHasMore(false);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [teamName, memberName]);
const loadOlderMessages = useCallback(async () => {
if (!nextCursor || loading) return;
setLoading(true);
try {
const page = await api.teams.getMessagesPage(teamName, {
beforeTimestamp: nextCursor,
limit: MEMBER_MESSAGES_PAGE_SIZE,
});
const memberPageMessages = page.messages.filter(
(message) => message.from === memberName || message.to === memberName
);
setPagedMessages((prev) => mergeTeamMessages(prev, memberPageMessages));
setNextCursor(page.nextCursor);
setHasMore(page.hasMore);
} catch {
// best-effort
} finally {
setLoading(false);
if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) {
return;
}
}, [teamName, memberName, nextCursor, loading]);
await loadOlderTeamMessages(teamName);
}, [loadOlderTeamMessages, messagesState, teamName]);
const effectiveMessages = useMemo(
() => mergeTeamMessages(messages, pagedMessages),
[messages, pagedMessages]
);
const loading = (messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false);
const hasMore = messagesState?.hasMore ?? false;
const filteredMessages = useMemo(
() =>
filterTeamMessages(effectiveMessages, {
filterTeamMessages(messages, {
timeWindow: null,
filter: { from: new Set(), to: new Set(), showNoise: true },
searchQuery: '',
}),
[effectiveMessages]
[messages]
);
const activityEntries = useMemo(() => {

View file

@ -1,7 +1,6 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Sheet, type SheetRef } from 'react-modal-sheet';
import { api } from '@renderer/api';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@ -9,7 +8,7 @@ import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMe
import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { useStore } from '@renderer/store';
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
import { selectTeamMessages } from '@renderer/store/slices/teamSlice';
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
@ -70,8 +69,6 @@ interface MessagesPanelProps {
members: ResolvedTeamMember[];
/** All team tasks. */
tasks: TeamTaskWithKanban[];
/** All raw messages from team data. */
messages: InboxMessage[];
/** Whether the team is alive. */
isTeamAlive?: boolean;
/** Live lead activity status for the current team. */
@ -109,7 +106,6 @@ export const MessagesPanel = memo(function MessagesPanel({
mountPoint,
members,
tasks,
messages,
isTeamAlive,
leadActivity,
leadContextUpdatedAt,
@ -133,6 +129,9 @@ export const MessagesPanel = memo(function MessagesPanel({
lastSendMessageResult,
teams,
openTeamTab,
messages,
messagesState,
loadOlderTeamMessages,
} = useStore(
useShallow((s) => ({
sendTeamMessage: s.sendTeamMessage,
@ -142,79 +141,23 @@ export const MessagesPanel = memo(function MessagesPanel({
lastSendMessageResult: s.lastSendMessageResult,
teams: s.teams,
openTeamTab: s.openTeamTab,
messages: selectTeamMessages(s, teamName),
messagesState: teamName ? s.teamMessagesByName[teamName] : undefined,
loadOlderTeamMessages: s.loadOlderTeamMessages,
}))
);
// ── Paginated message fetching ──
// Messages are now fetched via getMessagesPage API instead of coming
// from getTeamData. The `messages` prop is used as initial seed if non-empty.
const PAGE_SIZE = 50;
const [fetchedMessages, setFetchedMessages] = useState<InboxMessage[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [messagesLoading, setMessagesLoading] = useState(false);
const fetchIdRef = useRef(0);
// Initial fetch on mount or team change
useEffect(() => {
const id = ++fetchIdRef.current;
setMessagesLoading(true);
void (async () => {
try {
const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE });
if (fetchIdRef.current !== id) return;
setFetchedMessages(page.messages);
setNextCursor(page.nextCursor);
setHasMore(page.hasMore);
} catch {
// Fallback: use prop messages if API fails
if (fetchIdRef.current === id && messages.length > 0) {
setFetchedMessages(messages);
}
} finally {
if (fetchIdRef.current === id) setMessagesLoading(false);
}
})();
}, [teamName]); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally only on teamName change
// Auto-refresh: poll for NEW messages only (prepend to head).
// Does NOT touch nextCursor/hasMore — those belong to the "Load older" flow.
useEffect(() => {
if (!isTeamAlive && leadActivity !== 'active') return;
const interval = setInterval(async () => {
try {
const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE });
setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages));
} catch {
// best-effort
}
}, 5000);
return () => clearInterval(interval);
}, [teamName, isTeamAlive, leadActivity]);
const loadOlderMessages = useCallback(async () => {
if (!nextCursor || messagesLoading) return;
setMessagesLoading(true);
try {
const page = await api.teams.getMessagesPage(teamName, {
beforeTimestamp: nextCursor,
limit: PAGE_SIZE,
});
setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages));
setNextCursor(page.nextCursor);
setHasMore(page.hasMore);
} catch {
// best-effort
} finally {
setMessagesLoading(false);
if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) {
return;
}
}, [teamName, nextCursor, messagesLoading]);
await loadOlderTeamMessages(teamName);
}, [loadOlderTeamMessages, messagesState, teamName]);
// Use fetched messages, fall back to prop messages during initial load
const effectiveMessages = useMemo(() => {
if (fetchedMessages.length === 0) return messages;
return mergeTeamMessages(fetchedMessages, messages);
}, [fetchedMessages, messages]);
const messagesLoading =
(messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false);
const hasMore = messagesState?.hasMore ?? false;
const effectiveMessages = messages;
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const sidebarScrollRef = useRef<HTMLDivElement | null>(null);

View file

@ -3,10 +3,14 @@ import { isLeadMember } from '@shared/utils/leadDetection';
import type {
MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot,
ResolvedTeamMember,
TeamProvisioningProgress,
} from '@shared/types';
interface LaunchJoinMemberLike {
name: string;
removedAt?: number;
}
/** Display steps for the provisioning stepper (0-indexed). */
export const DISPLAY_STEPS = [
{ key: 'starting', label: 'Starting' },
@ -52,7 +56,7 @@ export function getLaunchJoinMilestonesFromMembers({
memberSpawnStatuses,
memberSpawnSnapshot,
}: {
members: readonly ResolvedTeamMember[];
members: readonly LaunchJoinMemberLike[];
memberSpawnStatuses?: MemberSpawnStatusCollection;
memberSpawnSnapshot?: Pick<MemberSpawnStatusesSnapshot, 'expectedMembers' | 'summary'>;
}): LaunchJoinMilestones {

View file

@ -3,7 +3,7 @@ import { useMemo } from 'react';
import { useStore } from '@renderer/store';
import {
getCurrentProvisioningProgressForTeam,
selectTeamDataForName,
selectTeamMemberSnapshotsForName,
} from '@renderer/store/slices/teamSlice';
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
import { useShallow } from 'zustand/react/shallow';
@ -20,7 +20,7 @@ export function useTeamProvisioningPresentation(teamName: string): {
useShallow((s) => ({
progress: getCurrentProvisioningProgressForTeam(s, teamName),
cancelProvisioning: s.cancelProvisioning,
teamMembers: selectTeamDataForName(s, teamName)?.members ?? [],
teamMembers: selectTeamMemberSnapshotsForName(s, teamName),
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
}))

View file

@ -1,6 +1,10 @@
import { useMemo } from 'react';
import { useStore } from '@renderer/store';
import {
selectResolvedMembersForTeamName,
selectTeamDataForName,
} from '@renderer/store/slices/teamSlice';
import { createEncodedTaskReference } from '@renderer/utils/taskReferenceUtils';
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
import { useShallow } from 'zustand/react/shallow';
@ -57,11 +61,13 @@ function isVisibleTask(task: TeamTaskWithKanban | GlobalTask): boolean {
}
export function useTaskSuggestions(currentTeamName: string | null): UseTaskSuggestionsResult {
const { globalTasks, selectedTeamName, selectedTeamData, teamByName } = useStore(
const { globalTasks, currentTeamData, currentTeamMembers, teamByName } = useStore(
useShallow((s) => ({
globalTasks: s.globalTasks,
selectedTeamName: s.selectedTeamName,
selectedTeamData: s.selectedTeamData,
currentTeamData: currentTeamName ? selectTeamDataForName(s, currentTeamName) : null,
currentTeamMembers: currentTeamName
? selectResolvedMembersForTeamName(s, currentTeamName)
: [],
teamByName: s.teamByName,
}))
);
@ -73,14 +79,10 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge
if (currentTeamName) {
const currentTeamSummary = teamByName[currentTeamName];
const currentTeamDisplayName = currentTeamSummary?.displayName || currentTeamName;
const currentTeamMembers =
selectedTeamName === currentTeamName && selectedTeamData
? selectedTeamData.members
: (currentTeamSummary?.members ?? []);
const currentTeamTasks =
selectedTeamName === currentTeamName && selectedTeamData
? selectedTeamData.tasks
: globalTasks.filter((task) => task.teamName === currentTeamName);
currentTeamData?.tasks ?? globalTasks.filter((task) => task.teamName === currentTeamName);
const currentTeamMemberColors =
currentTeamMembers.length > 0 ? currentTeamMembers : (currentTeamSummary?.members ?? []);
for (const task of currentTeamTasks) {
if (!isVisibleTask(task)) continue;
@ -91,7 +93,7 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge
teamDisplayName: currentTeamDisplayName,
teamColor: currentTeamSummary?.color,
isCurrentTeamTask: true,
ownerColor: currentTeamMembers.find((member) => member.name === task.owner)?.color,
ownerColor: currentTeamMemberColors.find((member) => member.name === task.owner)?.color,
});
}
}
@ -123,7 +125,7 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge
});
return tasks.map(buildTaskSuggestion);
}, [currentTeamName, globalTasks, selectedTeamData, selectedTeamName, teamByName]);
}, [currentTeamData, currentTeamMembers, currentTeamName, globalTasks, teamByName]);
return { suggestions };
}

View file

@ -35,7 +35,9 @@ import { createTabSlice } from './slices/tabSlice';
import { createTabUISlice } from './slices/tabUISlice';
import {
createTeamSlice,
getActiveTeamPendingReplyWaits,
getLastResolvedTeamDataRefreshAt,
hasActiveTeamPendingReplyWait,
isTeamDataRefreshPending,
selectTeamDataForName,
} from './slices/teamSlice';
@ -65,6 +67,7 @@ const TEAM_CHANGE_EVENT_BURST_WARN_COUNT = 8;
const TEAM_CHANGE_EVENT_WARN_THROTTLE_MS = 2_000;
const TEAM_VISIBLE_IDLE_WATCHDOG_POLL_MS = 10_000;
const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000;
const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_000;
const CURRENT_APP_VERSION =
typeof __APP_VERSION__ === 'string' ? normalizeVersion(__APP_VERSION__) : '0.0.0';
const logger = createLogger('Store:index');
@ -237,10 +240,12 @@ export function initializeNotificationListeners(): () => void {
const teamLastRelevantActivityAt = new Map<string, number>();
const teamLastIdleWatchdogRefreshAt = new Map<string, number>();
let teamRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamMessageRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamPresenceRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let memberSpawnRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let toolActivityTimers = new Map<string, ReturnType<typeof setTimeout>>();
let inProgressChangePresencePollInFlight = false;
let teamMessageFallbackPollInFlight = false;
const inProgressChangePresenceCursorByTeam = new Map<string, number>();
let teamListRefreshTimer: ReturnType<typeof setTimeout> | null = null;
@ -252,6 +257,23 @@ export function initializeNotificationListeners(): () => void {
const TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS = 500;
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500;
const refreshTrackedTeamMessages = async (teamName: string): Promise<void> => {
if (!teamName || !shouldRefreshTeamMessages(teamName)) {
return;
}
const current = useStore.getState();
try {
const headResult = await current.refreshTeamMessagesHead(teamName);
const latest = useStore.getState();
const meta = latest.memberActivityMetaByTeam[teamName];
if (headResult.feedChanged || !meta || meta.feedRevision !== headResult.feedRevision) {
await latest.refreshMemberActivityMeta(teamName);
}
} catch {
// Best-effort refresh for message-driven events and fallback polling only.
}
};
const scheduleMemberSpawnStatusesRefresh = (teamName: string | null | undefined): void => {
if (!teamName || !isTeamVisibleInAnyPane(teamName)) {
return;
@ -265,6 +287,19 @@ export function initializeNotificationListeners(): () => void {
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
memberSpawnRefreshTimers.set(teamName, timer);
};
const scheduleTrackedTeamMessageRefresh = (teamName: string | null | undefined): void => {
if (!teamName || !shouldRefreshTeamMessages(teamName)) {
return;
}
if (teamMessageRefreshTimers.has(teamName)) {
return;
}
const timer = setTimeout(() => {
teamMessageRefreshTimers.delete(teamName);
void refreshTrackedTeamMessages(teamName);
}, TEAM_REFRESH_THROTTLE_MS);
teamMessageRefreshTimers.set(teamName, timer);
};
const buildToolActivityTimerKey = (
teamName: string,
memberName: string,
@ -587,6 +622,18 @@ export function initializeNotificationListeners(): () => void {
return getVisibleTeamNamesInAnyPane().has(teamName);
};
const shouldRefreshTeamMessages = (teamName: string): boolean => {
return isTeamVisibleInAnyPane(teamName) || hasActiveTeamPendingReplyWait(teamName);
};
const getTrackedTeamMessageRefreshTeams = (): Set<string> => {
const tracked = getVisibleTeamNamesInAnyPane();
for (const teamName of getActiveTeamPendingReplyWaits()) {
tracked.add(teamName);
}
return tracked;
};
const getTrackedChangePresenceTeams = (): Set<string> => {
const state = useStore.getState();
const tracked = new Set<string>();
@ -627,6 +674,26 @@ export function initializeNotificationListeners(): () => void {
return activeTab.teamName;
};
const pollTrackedTeamMessageFallback = async (): Promise<void> => {
if (teamMessageFallbackPollInFlight) {
return;
}
const teamNames = getTrackedTeamMessageRefreshTeams();
if (teamNames.size === 0) {
return;
}
teamMessageFallbackPollInFlight = true;
try {
await Promise.allSettled(
Array.from(teamNames, (teamName) => refreshTrackedTeamMessages(teamName))
);
} finally {
teamMessageFallbackPollInFlight = false;
}
};
const pollFocusedVisibleTeamIdleWatchdog = async (): Promise<void> => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
return;
@ -863,11 +930,18 @@ export function initializeNotificationListeners(): () => void {
cleanupFns.push(() => {
clearInterval(teamIdleWatchdogTimer);
});
const teamMessageFallbackPollTimer = setInterval(() => {
void pollTrackedTeamMessageFallback();
}, TEAM_MESSAGE_FALLBACK_POLL_MS);
cleanupFns.push(() => {
clearInterval(teamMessageFallbackPollTimer);
});
if (api.teams?.onTeamChange) {
const cleanup = api.teams.onTeamChange((_event: unknown, event: TeamChangeEvent) => {
const visibleTeam = Boolean(event.teamName) && isTeamVisibleInAnyPane(event.teamName);
noteTeamChangeEventBurst(event.teamName, event.type, visibleTeam);
const messageRefreshRelevant =
Boolean(event.teamName) && shouldRefreshTeamMessages(event.teamName);
noteTeamChangeEventBurst(event.teamName, event.type, messageRefreshRelevant);
const isIgnoredRuntimeRun = (() => {
if (!event.runId) return false;
@ -924,24 +998,26 @@ export function initializeNotificationListeners(): () => void {
},
};
const cachedTeamData = prev.teamDataCacheByName[event.teamName];
if (cachedTeamData) {
const baseTeamData =
prev.teamDataCacheByName[event.teamName] ??
(prev.selectedTeamName === event.teamName ? prev.selectedTeamData : null);
const nextTeamData =
baseTeamData && baseTeamData.isAlive !== (nextActivity !== 'offline')
? {
...baseTeamData,
isAlive: nextActivity !== 'offline',
}
: baseTeamData;
if (nextTeamData) {
nextState.teamDataCacheByName = {
...prev.teamDataCacheByName,
[event.teamName]: {
...cachedTeamData,
isAlive: nextActivity !== 'offline',
},
[event.teamName]: nextTeamData,
};
}
// Keep TeamDetailView in sync: it historically relied on selectedTeamData.isAlive,
// which isn't refreshed for lead-activity events.
if (prev.selectedTeamName === event.teamName && prev.selectedTeamData) {
nextState.selectedTeamData = {
...prev.selectedTeamData,
isAlive: nextActivity !== 'offline',
};
if (prev.selectedTeamName === event.teamName && nextTeamData) {
nextState.selectedTeamData = nextTeamData;
}
// Clear context data when lead goes offline
@ -1122,29 +1198,19 @@ export function initializeNotificationListeners(): () => void {
return;
}
if (event.type === 'inbox' || event.type === 'config' || event.type === 'process') {
scheduleMemberSpawnStatusesRefresh(event.teamName);
if (event.type === 'inbox') {
scheduleTrackedTeamMessageRefresh(event.teamName);
return;
}
// Live lead-message events: only refresh the visible team detail, not team/task lists.
// This keeps the refresh lightweight and prevents one noisy team from starving another.
// Live lead-message events refresh only the tracked message feed surface
// (visible team or local pending-reply wait), not the structural snapshot.
if (event.type === 'lead-message') {
if (isStaleRuntimeEvent) {
return;
}
seedCurrentRunIdIfMissing();
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
return;
}
if (teamRefreshTimers.has(event.teamName)) {
return;
}
const timer = setTimeout(() => {
teamRefreshTimers.delete(event.teamName);
const current = useStore.getState();
void current.refreshTeamData(event.teamName, { withDedup: true });
}, TEAM_REFRESH_THROTTLE_MS);
teamRefreshTimers.set(event.teamName, timer);
scheduleTrackedTeamMessageRefresh(event.teamName);
return;
}
@ -1205,6 +1271,8 @@ export function initializeNotificationListeners(): () => void {
cleanup();
for (const t of teamRefreshTimers.values()) clearTimeout(t);
teamRefreshTimers = new Map();
for (const t of teamMessageRefreshTimers.values()) clearTimeout(t);
teamMessageRefreshTimers = new Map();
for (const t of teamPresenceRefreshTimers.values()) clearTimeout(t);
teamPresenceRefreshTimers = new Map();
for (const t of memberSpawnRefreshTimers.values()) clearTimeout(t);

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,6 @@ import {
import type {
MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot,
ResolvedTeamMember,
TeamProvisioningProgress,
} from '@shared/types';
@ -17,6 +16,11 @@ type MemberSpawnStatusCollection =
| Map<string, MemberSpawnStatusEntry>
| undefined;
interface ProvisioningMemberLike {
name: string;
removedAt?: number;
}
const ACTIVE_PROVISIONING_STATES = new Set([
'validating',
'spawning',
@ -66,7 +70,7 @@ export function buildTeamProvisioningPresentation({
memberSpawnSnapshot,
}: {
progress: TeamProvisioningProgress | null | undefined;
members: readonly ResolvedTeamMember[];
members: readonly ProvisioningMemberLike[];
memberSpawnStatuses?: MemberSpawnStatusCollection;
memberSpawnSnapshot?: Pick<MemberSpawnStatusesSnapshot, 'expectedMembers' | 'summary'>;
}): TeamProvisioningPresentation | null {

View file

@ -53,6 +53,7 @@ import type {
KanbanColumnId,
LeadActivitySnapshot,
LeadContextUsageSnapshot,
TeamMemberActivityMeta,
MemberFullStats,
MemberLogSummary,
MemberSpawnStatusesSnapshot,
@ -71,10 +72,10 @@ import type {
TeamCreateConfigRequest,
TeamCreateRequest,
TeamCreateResponse,
TeamData,
TeamLaunchRequest,
TeamLaunchResponse,
TeamMessageNotificationData,
TeamViewSnapshot,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamSummary,
@ -425,7 +426,7 @@ export interface HttpServerAPI {
export interface TeamsAPI {
list: () => Promise<TeamSummary[]>;
getData: (teamName: string) => Promise<TeamData>;
getData: (teamName: string) => Promise<TeamViewSnapshot>;
getTaskChangePresence: (teamName: string) => Promise<Record<string, TaskChangePresenceState>>;
setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise<void>;
setToolActivityTracking: (teamName: string, enabled: boolean) => Promise<void>;
@ -446,8 +447,9 @@ export interface TeamsAPI {
sendMessage: (teamName: string, request: SendMessageRequest) => Promise<SendMessageResult>;
getMessagesPage: (
teamName: string,
options?: { beforeTimestamp?: string; limit?: number }
options?: { cursor?: string | null; limit?: number }
) => Promise<MessagesPage>;
getMemberActivityMeta: (teamName: string) => Promise<TeamMemberActivityMeta>;
createTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
requestReview: (teamName: string, taskId: string) => Promise<void>;
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;

View file

@ -602,6 +602,11 @@ export interface MessagesPage {
/** Opaque cursor string for fetching older messages. Null when no more pages. */
nextCursor: string | null;
hasMore: boolean;
/**
* Content-stable revision of the full normalized feed that produced this page.
* Changes only when the semantic message feed changes.
*/
feedRevision: string;
}
export type AgentActionMode = 'do' | 'ask' | 'delegate';
@ -729,12 +734,44 @@ export interface TeamProcess {
stoppedAt?: string;
}
export interface TeamData {
export interface TeamMemberSnapshot {
name: string;
agentId?: string;
currentTaskId: string | null;
taskCount: number;
color?: string;
agentType?: string;
role?: string;
workflow?: string;
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
cwd?: string;
/** Set only when member's git branch differs from the lead's branch. */
gitBranch?: string;
runtimeAdvisory?: MemberRuntimeAdvisory;
removedAt?: number;
}
export interface MemberActivityMetaEntry {
memberName: string;
lastAuthoredMessageAt: string | null;
messageCountExact: number;
latestAuthoredMessageSignalsTermination: boolean;
}
export interface TeamMemberActivityMeta {
teamName: string;
computedAt: string;
members: Record<string, MemberActivityMetaEntry>;
feedRevision: string;
}
export interface TeamViewSnapshot {
teamName: string;
config: TeamConfig;
tasks: TeamTaskWithKanban[];
members: ResolvedTeamMember[];
messages: InboxMessage[];
members: TeamMemberSnapshot[];
kanbanState: KanbanState;
processes: TeamProcess[];
warnings?: string[];

View file

@ -12,7 +12,7 @@ import type {
} from '@shared/types/team';
vi.mock('electron', () => ({
app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp') },
app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp'), isPackaged: false },
Notification: Object.assign(vi.fn(), { isSupported: vi.fn(() => false) }),
BrowserWindow: { getAllWindows: vi.fn(() => []) },
}));
@ -34,6 +34,8 @@ const { mockTeamDataWorkerClient } = vi.hoisted(() => ({
mockTeamDataWorkerClient: {
isAvailable: vi.fn(),
getTeamData: vi.fn(),
getMessagesPage: vi.fn(),
getMemberActivityMeta: vi.fn(),
findLogsForTask: vi.fn(),
},
}));
@ -62,6 +64,8 @@ import {
TEAM_CREATE_TASK,
TEAM_DELETE_TEAM,
TEAM_GET_DATA,
TEAM_GET_MEMBER_ACTIVITY_META,
TEAM_GET_MESSAGES_PAGE,
TEAM_LAUNCH,
TEAM_LIST,
TEAM_PREPARE_PROVISIONING,
@ -135,13 +139,33 @@ describe('ipc teams handlers', () => {
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [] as InboxMessage[],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
})),
getMessageFeed: vi.fn(async () => ({
teamName: 'my-team',
feedRevision: 'rev-1',
messages: [] as InboxMessage[],
})),
getMessagesPage: vi.fn(async () => ({
messages: [] as InboxMessage[],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-1',
})),
getMemberActivityMeta: vi.fn(async () => ({
teamName: 'my-team',
computedAt: '2026-03-12T10:00:00.000Z',
members: {},
feedRevision: 'rev-1',
})),
getTaskChangePresence: vi.fn(async () => ({ 'task-1': 'has_changes' })),
reconcileTeamArtifacts: vi.fn(async () => undefined),
setTaskChangePresenceTracking: vi.fn(() => undefined),
getTeamNotificationContext: vi.fn(async () => ({
displayName: 'My Team',
projectPath: '/tmp/project',
})),
deleteTeam: vi.fn(async () => undefined),
getLeadMemberName: vi.fn(async () => 'team-lead'),
getTeamDisplayName: vi.fn(async () => 'My Team'),
@ -231,6 +255,8 @@ describe('ipc teams handlers', () => {
mockGetMembersMeta.mockResolvedValue([]);
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
mockTeamDataWorkerClient.getTeamData.mockReset();
mockTeamDataWorkerClient.getMessagesPage.mockReset();
mockTeamDataWorkerClient.getMemberActivityMeta.mockReset();
mockTeamDataWorkerClient.findLogsForTask.mockReset();
initializeTeamHandlers(
service as never,
@ -252,6 +278,8 @@ describe('ipc teams handlers', () => {
it('registers all expected handlers', () => {
expect(handlers.has(TEAM_LIST)).toBe(true);
expect(handlers.has(TEAM_GET_DATA)).toBe(true);
expect(handlers.has(TEAM_GET_MESSAGES_PAGE)).toBe(true);
expect(handlers.has(TEAM_GET_MEMBER_ACTIVITY_META)).toBe(true);
expect(handlers.has(TEAM_GET_TASK_CHANGE_PRESENCE)).toBe(true);
expect(handlers.has(TEAM_SET_CHANGE_PRESENCE_TRACKING)).toBe(true);
expect(handlers.has(TEAM_DELETE_TEAM)).toBe(true);
@ -580,7 +608,6 @@ describe('ipc teams handlers', () => {
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [] as InboxMessage[],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
@ -759,24 +786,7 @@ describe('ipc teams handlers', () => {
});
});
it('dedups live lead replies when lead_session already has same text', async () => {
service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [
{
from: 'team-lead',
text: 'Hello there',
timestamp: '2026-02-23T10:00:00.000Z',
read: true,
source: 'lead_session' as const,
},
],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
it('keeps TEAM_GET_DATA structural and does not expose message transport', async () => {
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
{
from: 'team-lead',
@ -791,59 +801,159 @@ describe('ipc teams handlers', () => {
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
const result = (await getDataHandler({} as never, 'my-team')) as {
success: boolean;
data: { messages: { source?: string }[] };
data: Record<string, unknown>;
};
expect(result.success).toBe(true);
const sources = result.data.messages.map((m) => m.source);
expect(sources.filter((s) => s === 'lead_process')).toHaveLength(0);
expect(sources.filter((s) => s === 'lead_session')).toHaveLength(1);
expect(result.data.teamName).toBe('my-team');
expect(result.data).not.toHaveProperty('messages');
expect(service.getMessageFeed).not.toHaveBeenCalled();
});
it('merges early live messages before durable lead_session backfill exists', async () => {
// Simulate: team just became readable but lead_session JSONL hasn't been written yet.
// Only live in-memory messages exist from the provisioning process.
service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [], // No durable messages yet
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
{
from: 'team-lead',
text: 'Команда создана. Запускаю тиммейтов.',
timestamp: '2026-02-23T10:00:00.000Z',
read: true,
source: 'lead_process' as const,
messageId: 'lead-turn-run-1-1',
},
{
from: 'team-lead',
text: 'All teammates online!',
timestamp: '2026-02-23T10:00:01.000Z',
read: true,
source: 'lead_process' as const,
messageId: 'lead-turn-run-1-2',
to: 'user',
},
]);
it('rejects TEAM_GET_DATA fallback in packaged runtime when worker is unavailable', async () => {
const electron = await import('electron');
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
(electron.app as { isPackaged: boolean }).isPackaged = true;
const handler = handlers.get(TEAM_GET_DATA)!;
const result = (await handler({} as never, 'my-team')) as { success: boolean; error?: string };
expect(result.success).toBe(false);
expect(result.error).toContain('TEAM_DATA_WORKER_UNAVAILABLE');
expect(service.getTeamData).not.toHaveBeenCalled();
vi.mocked(console.error).mockClear();
(electron.app as { isPackaged: boolean }).isPackaged = false;
});
it('uses the team-data worker for TEAM_GET_MESSAGES_PAGE when available', async () => {
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({
messages: [
{
from: 'team-lead',
text: 'Hello there',
timestamp: '2026-02-23T10:00:01.000Z',
read: true,
source: 'lead_session' as const,
messageId: 'msg-1',
},
],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-worker',
});
const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!;
const result = (await handler({} as never, 'my-team', {
limit: 50,
})) as { success: boolean; data: { feedRevision: string } };
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
const result = (await getDataHandler({} as never, 'my-team')) as {
success: boolean;
data: { messages: { source?: string; text: string }[] };
};
expect(result.success).toBe(true);
// Both live messages should appear since there's no durable backfill yet
// Sorted by timestamp descending (newest first)
expect(result.data.messages).toHaveLength(2);
expect(result.data.messages[0].source).toBe('lead_process');
expect(result.data.messages[0].text).toBe('All teammates online!');
expect(result.data.messages[1].source).toBe('lead_process');
expect(result.data.messages[1].text).toBe('Команда создана. Запускаю тиммейтов.');
expect(result.data.feedRevision).toBe('rev-worker');
expect(mockTeamDataWorkerClient.getMessagesPage).toHaveBeenCalledWith('my-team', {
cursor: undefined,
limit: 50,
});
expect(service.getMessagesPage).not.toHaveBeenCalled();
});
it('scans rate-limit notifications from message-page results without hydrating TEAM_GET_DATA feed', async () => {
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({
messages: [
{
from: 'team-lead',
text: "You've hit your limit. Please wait a bit before retrying.",
timestamp: '2026-02-23T10:00:01.000Z',
read: true,
source: 'lead_session' as const,
messageId: 'msg-rate-limit-1',
},
],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-worker',
});
const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!;
const result = (await handler({} as never, 'my-team', {
limit: 50,
})) as { success: boolean; data: { feedRevision: string } };
expect(result.success).toBe(true);
expect(result.data.feedRevision).toBe('rev-worker');
expect(mockAddTeamNotification).toHaveBeenCalledWith(
expect.objectContaining({
teamEventType: 'rate_limit',
teamName: 'my-team',
teamDisplayName: 'My Team',
from: 'team-lead',
dedupeKey: 'rate-limit:my-team:msg-rate-limit-1',
})
);
expect(service.getMessageFeed).not.toHaveBeenCalled();
});
it('rejects heavy TEAM_GET_MESSAGES_PAGE fallback in packaged runtime when worker is unavailable', async () => {
const electron = await import('electron');
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
(electron.app as { isPackaged: boolean }).isPackaged = true;
const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!;
const result = (await handler({} as never, 'my-team', {
limit: 50,
})) as { success: boolean; error?: string };
expect(result.success).toBe(false);
expect(result.error).toContain('TEAM_DATA_WORKER_UNAVAILABLE');
expect(service.getMessagesPage).not.toHaveBeenCalled();
vi.mocked(console.error).mockClear();
(electron.app as { isPackaged: boolean }).isPackaged = false;
});
it('uses the team-data worker for TEAM_GET_MEMBER_ACTIVITY_META when available', async () => {
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
mockTeamDataWorkerClient.getMemberActivityMeta.mockResolvedValueOnce({
teamName: 'my-team',
computedAt: '2026-03-12T10:00:00.000Z',
members: {
alice: {
memberName: 'alice',
lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z',
messageCountExact: 4,
latestAuthoredMessageSignalsTermination: false,
},
},
feedRevision: 'rev-worker',
});
const handler = handlers.get(TEAM_GET_MEMBER_ACTIVITY_META)!;
const result = (await handler({} as never, 'my-team')) as {
success: boolean;
data: { feedRevision: string };
};
expect(result.success).toBe(true);
expect(result.data.feedRevision).toBe('rev-worker');
expect(mockTeamDataWorkerClient.getMemberActivityMeta).toHaveBeenCalledWith('my-team');
expect(service.getMemberActivityMeta).not.toHaveBeenCalled();
});
it('rejects heavy TEAM_GET_MEMBER_ACTIVITY_META fallback in packaged runtime when worker is unavailable', async () => {
const electron = await import('electron');
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
(electron.app as { isPackaged: boolean }).isPackaged = true;
const handler = handlers.get(TEAM_GET_MEMBER_ACTIVITY_META)!;
const result = (await handler({} as never, 'my-team')) as { success: boolean; error?: string };
expect(result.success).toBe(false);
expect(result.error).toContain('TEAM_DATA_WORKER_UNAVAILABLE');
expect(service.getMemberActivityMeta).not.toHaveBeenCalled();
vi.mocked(console.error).mockClear();
(electron.app as { isPackaged: boolean }).isPackaged = false;
});
it('keeps TEAM_GET_DATA read-only and never triggers reconcile side effects', async () => {

View file

@ -11,8 +11,9 @@ import { TeamDataService } from '../../../../src/main/services/team/TeamDataServ
import type {
InboxMessage,
KanbanState,
ResolvedTeamMember,
TeamConfig,
TeamData,
TeamProcess,
TeamTask,
TeamTaskWithKanban,
} from '../../../../src/shared/types/team';
@ -240,10 +241,9 @@ function createGetTeamDataHarness(options: {
config: TeamConfig,
metaMembers: TeamConfig['members'],
inboxNames: string[],
tasks: TeamTaskWithKanban[],
messages: InboxMessage[]
) => TeamData['members'];
listProcesses?: () => TeamData['processes'];
tasks: TeamTaskWithKanban[]
) => ResolvedTeamMember[];
listProcesses?: () => TeamProcess[];
getMemberAdvisories?: () => Promise<Map<string, unknown>>;
} = {}) {
const getConfig = vi.fn(async () =>
@ -351,7 +351,7 @@ function createGetTeamDataHarness(options: {
};
}
function buildResolvedMember(name: string): TeamData['members'][number] {
function buildResolvedMember(name: string): ResolvedTeamMember {
return {
name,
status: 'unknown',
@ -628,6 +628,39 @@ describe('TeamDataService', () => {
);
});
it('returns lightweight notification context from config without hydrating team data', async () => {
const getConfig = vi.fn(async () => ({
name: 'My Team',
projectPath: '/Users/dev/my-project',
members: [],
}));
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig,
} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
(() => ({ processes: { listProcesses: vi.fn(() => []) } })) as never
);
const result = await service.getTeamNotificationContext('my-team');
expect(result).toEqual({
displayName: 'My Team',
projectPath: '/Users/dev/my-project',
});
expect(getConfig).toHaveBeenCalledWith('my-team');
});
it('creates task with status pending when startImmediately is false', async () => {
const createTaskMock = vi.fn((task) => ({ ...task, status: 'pending' }));
const service = new TeamDataService(
@ -2437,8 +2470,8 @@ describe('TeamDataService', () => {
} as never
);
const data = await service.getTeamData('my-team');
const costResult = data.messages.find((message) => message.messageId === 'lead-thought-1');
const feed = await service.getMessageFeed('my-team');
const costResult = feed.messages.find((message) => message.messageId === 'lead-thought-1');
expect(costResult).toMatchObject({
messageKind: 'slash_command_result',
@ -2507,8 +2540,8 @@ describe('TeamDataService', () => {
} as never
);
const data = await service.getTeamData('my-team');
const result = data.messages.find((message) => message.messageId === 'passive-idle-dup-1');
const feed = await service.getMessageFeed('my-team');
const result = feed.messages.find((message) => message.messageId === 'passive-idle-dup-1');
expect(result).toBeDefined();
expect(result?.source).not.toBe('lead_process');
@ -2582,8 +2615,8 @@ describe('TeamDataService', () => {
sentMessages: [userReplyRow],
});
const data = await service.getTeamData('my-team');
const linked = data.messages.find((message) => message.messageId === 'passive-user-summary-1');
const feed = await service.getMessageFeed('my-team');
const linked = feed.messages.find((message) => message.messageId === 'passive-user-summary-1');
expect(linked?.relayOfMessageId).toBe('user-reply-1');
expect(passiveSummaryRow.relayOfMessageId).toBeUndefined();
@ -2618,8 +2651,8 @@ describe('TeamDataService', () => {
],
});
const data = await service.getTeamData('my-team');
const linked = data.messages.find(
const feed = await service.getMessageFeed('my-team');
const linked = feed.messages.find(
(message) => message.messageId === 'passive-user-summary-contains-1'
);
@ -2655,8 +2688,8 @@ describe('TeamDataService', () => {
],
});
const data = await service.getTeamData('my-team');
const linked = data.messages.find((message) => message.messageId === 'passive-user-summary-old-1');
const feed = await service.getMessageFeed('my-team');
const linked = feed.messages.find((message) => message.messageId === 'passive-user-summary-old-1');
expect(linked?.relayOfMessageId).toBeUndefined();
});
@ -2690,8 +2723,8 @@ describe('TeamDataService', () => {
],
});
const data = await service.getTeamData('my-team');
const linked = data.messages.find((message) => message.messageId === 'passive-bob-summary-1');
const feed = await service.getMessageFeed('my-team');
const linked = feed.messages.find((message) => message.messageId === 'passive-bob-summary-1');
expect(linked?.relayOfMessageId).toBeUndefined();
});
@ -2725,8 +2758,8 @@ describe('TeamDataService', () => {
],
});
const data = await service.getTeamData('my-team');
const linked = data.messages.find(
const feed = await service.getMessageFeed('my-team');
const linked = feed.messages.find(
(message) => message.messageId === 'passive-user-summary-sender-1'
);
@ -2772,8 +2805,8 @@ describe('TeamDataService', () => {
],
});
const data = await service.getTeamData('my-team');
const linked = data.messages.find(
const feed = await service.getMessageFeed('my-team');
const linked = feed.messages.find(
(message) => message.messageId === 'passive-user-summary-ambiguous-1'
);
@ -3281,8 +3314,6 @@ describe('TeamDataService', () => {
it('starts light reads immediately, bounds heavy reads, and keeps processes outside the parallel phase', async () => {
const order: string[] = [];
const tasksDeferred = createDeferred<TeamTask[]>();
const messagesDeferred = createDeferred<InboxMessage[]>();
const leadTextsDeferred = createDeferred<InboxMessage[]>();
const harness = createGetTeamDataHarness({
getTasks: async () => {
@ -3293,10 +3324,6 @@ describe('TeamDataService', () => {
order.push('inboxNames:start');
return [];
},
getMessages: async () => {
order.push('messages:start');
return messagesDeferred.promise;
},
getMembers: async () => {
order.push('meta:start');
return [];
@ -3305,10 +3332,6 @@ describe('TeamDataService', () => {
order.push('kanban:start');
return { teamName: 'my-team', reviewers: [], tasks: {} };
},
readMessages: async () => {
order.push('sent:start');
return [];
},
resolveMembers: () => {
order.push('resolveMembers');
return [];
@ -3330,39 +3353,21 @@ describe('TeamDataService', () => {
},
});
vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation(
async () => {
order.push('leadTexts:start');
return leadTextsDeferred.promise;
}
);
const pending = harness.service.getTeamData('my-team');
await flushMicrotasks();
expect(order).toEqual(
expect.arrayContaining([
'inboxNames:start',
'sent:start',
'meta:start',
'kanban:start',
'tasks:start',
'messages:start',
])
);
expect(order).not.toContain('leadTexts:start');
expect(order).not.toContain('processes:start');
expect(order).not.toContain('leadTexts:start');
tasksDeferred.resolve([]);
await flushMicrotasks();
expect(order).toContain('leadTexts:start');
expect(order.indexOf('tasks:start')).toBeLessThan(order.indexOf('messages:start'));
expect(order.indexOf('messages:start')).toBeLessThan(order.indexOf('leadTexts:start'));
expect(order).not.toContain('processes:start');
messagesDeferred.resolve([]);
leadTextsDeferred.resolve([]);
const data = await pending;
@ -3372,7 +3377,7 @@ describe('TeamDataService', () => {
pid: 101,
}),
]);
expect(order.indexOf('leadTexts:start')).toBeLessThan(order.indexOf('processes:start'));
expect(order).not.toContain('leadTexts:start');
expect(order.indexOf('resolveMembers')).toBeLessThan(order.indexOf('processes:start'));
});
@ -3417,47 +3422,64 @@ describe('TeamDataService', () => {
);
});
it('surfaces isAlive in the structural snapshot from live process state', async () => {
const aliveHarness = createGetTeamDataHarness({
listProcesses: () =>
[
{
id: 'proc-1',
label: 'Lead',
pid: 101,
registeredAt: '2026-04-09T10:00:00.000Z',
},
] satisfies TeamProcess[],
});
const offlineHarness = createGetTeamDataHarness({
listProcesses: () =>
[
{
id: 'proc-1',
label: 'Lead',
pid: 101,
registeredAt: '2026-04-09T10:00:00.000Z',
stoppedAt: '2026-04-09T10:05:00.000Z',
},
] satisfies TeamProcess[],
});
const aliveData = await aliveHarness.service.getTeamData('my-team');
const offlineData = await offlineHarness.service.getTeamData('my-team');
expect(aliveData.isAlive).toBe(true);
expect(offlineData.isAlive).toBe(false);
});
it('keeps warning order deterministic even when read failures settle out of order', async () => {
const tasksDeferred = createDeferred<TeamTask[]>();
const inboxDeferred = createDeferred<string[]>();
const messagesDeferred = createDeferred<InboxMessage[]>();
const leadTextsDeferred = createDeferred<InboxMessage[]>();
const sentDeferred = createDeferred<InboxMessage[]>();
const metaDeferred = createDeferred<TeamConfig['members']>();
const kanbanDeferred = createDeferred<KanbanState>();
const harness = createGetTeamDataHarness({
getTasks: async () => tasksDeferred.promise,
listInboxNames: async () => inboxDeferred.promise,
getMessages: async () => messagesDeferred.promise,
getMembers: async () => metaDeferred.promise,
getState: async () => kanbanDeferred.promise,
readMessages: async () => sentDeferred.promise,
});
vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation(
async () => leadTextsDeferred.promise
);
const pending = harness.service.getTeamData('my-team');
await flushMicrotasks();
sentDeferred.reject(new Error('sent failed'));
kanbanDeferred.reject(new Error('kanban failed'));
tasksDeferred.reject(new Error('tasks failed'));
metaDeferred.reject(new Error('meta failed'));
inboxDeferred.reject(new Error('inbox failed'));
leadTextsDeferred.reject(new Error('lead failed'));
messagesDeferred.reject(new Error('messages failed'));
const data = await pending;
expect(data.warnings).toEqual([
'Tasks failed to load',
'Inboxes failed to load',
'Messages failed to load',
'Lead session texts failed to load',
'Sent messages failed to load',
'Member metadata failed to load',
'Kanban state failed to load',
]);
@ -3501,9 +3523,9 @@ describe('TeamDataService', () => {
},
]);
const data = await harness.service.getTeamData('my-team');
const feed = await harness.service.getMessageFeed('my-team');
expect(data.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1', 'inbox-1']);
expect(feed.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1', 'inbox-1']);
});
it('preserves assembled messages and resolver inputs when inbox messages fail', async () => {
@ -3552,11 +3574,10 @@ describe('TeamDataService', () => {
]);
const data = await harness.service.getTeamData('my-team');
const feed = await harness.service.getMessageFeed('my-team');
expect(data.warnings).toEqual(
expect.arrayContaining(['Messages failed to load', 'Kanban state failed to load'])
);
expect(data.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1']);
expect(data.warnings).toEqual(expect.arrayContaining(['Kanban state failed to load']));
expect(feed.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1']);
expect(resolveMembersSpy).toHaveBeenCalledWith(
buildDefaultTeamConfig(),
metaMembers,
@ -3566,10 +3587,6 @@ describe('TeamDataService', () => {
id: 'task-1',
subject: 'Investigate rollout',
}),
],
[
expect.objectContaining({ messageId: 'sent-1' }),
expect.objectContaining({ messageId: 'lead-1' }),
]
);
});
@ -3608,16 +3625,11 @@ describe('TeamDataService', () => {
it('degrades a queued heavy sync throw to warning and still completes the snapshot', async () => {
const order: string[] = [];
const tasksDeferred = createDeferred<TeamTask[]>();
const messagesDeferred = createDeferred<InboxMessage[]>();
const harness = createGetTeamDataHarness({
getTasks: async () => {
order.push('tasks:start');
return tasksDeferred.promise;
},
getMessages: async () => {
order.push('messages:start');
return messagesDeferred.promise;
},
listProcesses: () => {
order.push('processes:start');
return [];
@ -3635,14 +3647,9 @@ describe('TeamDataService', () => {
expect(order).not.toContain('leadTexts:start');
tasksDeferred.resolve([]);
await flushMicrotasks();
expect(order).toContain('leadTexts:start');
messagesDeferred.resolve([]);
const data = await pending;
expect(data.warnings).toEqual(expect.arrayContaining(['Lead session texts failed to load']));
expect(data.warnings ?? []).not.toContain('Lead session texts failed to load');
expect(order).toContain('processes:start');
});
@ -3780,7 +3787,7 @@ describe('TeamDataService', () => {
expect(page1.hasMore).toBe(true);
const page2 = await service.getMessagesPage('my-team', {
beforeTimestamp: page1.nextCursor!,
cursor: page1.nextCursor!,
limit: 10,
});
// Should get the remaining 2 messages, not lose the one with same timestamp
@ -3813,5 +3820,40 @@ describe('TeamDataService', () => {
const result = page.messages.find((m) => m.messageId === 'resp1');
expect(result?.messageKind).toBe('slash_command_result');
});
it('normalizes stable effective message ids before pagination and cursoring', async () => {
const msgs = [
{
from: 'alice',
text: 'same-ts-a',
timestamp: '2026-01-01T00:00:02.000Z',
source: 'inbox' as const,
},
{
from: 'bob',
text: 'same-ts-b',
timestamp: '2026-01-01T00:00:02.000Z',
source: 'inbox' as const,
},
{
from: 'carol',
text: 'older',
timestamp: '2026-01-01T00:00:01.000Z',
source: 'inbox' as const,
},
];
const service = createPaginationService(msgs);
const page1 = await service.getMessagesPage('my-team', { limit: 1 });
const page2 = await service.getMessagesPage('my-team', {
cursor: page1.nextCursor!,
limit: 10,
});
expect(page1.messages[0]?.messageId).toMatch(/^inbox-/);
expect(page1.nextCursor).toContain(page1.messages[0]!.messageId!);
expect(page2.messages.every((message) => Boolean(message.messageId))).toBe(true);
expect(new Set([...page1.messages, ...page2.messages].map((message) => message.messageId)).size).toBe(3);
});
});
});

View file

@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest';
import { TeamMemberResolver } from '../../../../src/main/services/team/TeamMemberResolver';
import type {
InboxMessage,
TeamConfig,
TeamTask,
TeamTaskWithKanban,
@ -24,13 +23,8 @@ describe('TeamMemberResolver', () => {
{ id: '1', subject: 'Visible task', status: 'pending', owner: 'alice' },
{ id: '2', subject: 'Ghost task', status: 'pending', owner: 'stranger' },
];
const now = new Date().toISOString();
const messages: InboxMessage[] = [
{ from: 'bob', text: 'ready', timestamp: now, read: false, color: 'green' },
{ from: 'user', text: 'system note', timestamp: now, read: false },
];
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks, messages);
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks);
const names = members.map((member) => member.name);
expect(names).toHaveLength(3);
@ -62,9 +56,8 @@ describe('TeamMemberResolver', () => {
];
const inboxNames = ['user', 'alice'];
const tasks: TeamTask[] = [];
const messages: InboxMessage[] = [];
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks, messages);
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks);
const names = members.map((m) => m.name);
expect(names).not.toContain('user');
@ -81,9 +74,8 @@ describe('TeamMemberResolver', () => {
const metaMembers: TeamConfig['members'] = [{ name: 'alice', agentType: 'general-purpose' }];
const inboxNames = ['alice', 'team-best.user', 'dream-team.team-lead'];
const tasks: TeamTask[] = [];
const messages: InboxMessage[] = [];
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks, messages);
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks);
const names = members.map((m) => m.name);
expect(names).toContain('alice');
@ -104,7 +96,7 @@ describe('TeamMemberResolver', () => {
];
const inboxNames = ['a3975f80d37fbcea1', 'alice', 'a68a8f6a643e59bfd'];
const members = resolver.resolveMembers(config, metaMembers, inboxNames, [], []);
const members = resolver.resolveMembers(config, metaMembers, inboxNames, []);
const names = members.map((m) => m.name);
expect(names).toContain('alice');
@ -124,7 +116,7 @@ describe('TeamMemberResolver', () => {
],
};
const members = resolver.resolveMembers(config, [], ['ops.bot'], [], []);
const members = resolver.resolveMembers(config, [], ['ops.bot'], []);
const names = members.map((m) => m.name);
expect(names).toContain('ops.bot');
@ -141,7 +133,6 @@ describe('TeamMemberResolver', () => {
config,
[],
['cross-team:team-alpha-super', 'cross-team-team-alpha-super', 'alice'],
[],
[]
);
const names = members.map((m) => m.name);
@ -163,7 +154,6 @@ describe('TeamMemberResolver', () => {
config,
[],
['cross_team_send', 'cross_team_list_targets', 'alice'],
[],
[]
);
const names = members.map((m) => m.name);
@ -185,7 +175,6 @@ describe('TeamMemberResolver', () => {
config,
[],
['cross_team::team-alpha-super', 'cross_team--team-alpha-super', 'alice'],
[],
[]
);
const names = members.map((m) => m.name);
@ -206,7 +195,7 @@ describe('TeamMemberResolver', () => {
],
};
const members = resolver.resolveMembers(config, [], ['ops.bot'], [], []);
const members = resolver.resolveMembers(config, [], ['ops.bot'], []);
const names = members.map((m) => m.name);
expect(names).toContain('Ops.Bot');
@ -222,7 +211,7 @@ describe('TeamMemberResolver', () => {
const tasks: TeamTaskWithKanban[] = [
{ id: 't1', subject: 'Work', status: 'in_progress', owner: 'bob' },
];
const members = resolver.resolveMembers(config, [], [], tasks, []);
const members = resolver.resolveMembers(config, [], [], tasks);
const bob = members.find((m) => m.name === 'bob');
expect(bob?.currentTaskId).toBe('t1');
});
@ -243,7 +232,7 @@ describe('TeamMemberResolver', () => {
kanbanColumn: 'approved',
},
];
const members = resolver.resolveMembers(config, [], [], tasks, []);
const members = resolver.resolveMembers(config, [], [], tasks);
const bob = members.find((m) => m.name === 'bob');
expect(bob?.currentTaskId).toBeNull();
});
@ -264,7 +253,7 @@ describe('TeamMemberResolver', () => {
// kanbanColumn not set — stale data scenario
},
];
const members = resolver.resolveMembers(config, [], [], tasks, []);
const members = resolver.resolveMembers(config, [], [], tasks);
const bob = members.find((m) => m.name === 'bob');
expect(bob?.currentTaskId).toBeNull();
});
@ -281,7 +270,7 @@ describe('TeamMemberResolver', () => {
// Teammates sometimes send messages to "lead" instead of "team-lead",
// creating a separate inbox file that the resolver picks up.
const inboxNames = ['team-lead', 'lead', 'alice'];
const members = resolver.resolveMembers(config, [], inboxNames, [], []);
const members = resolver.resolveMembers(config, [], inboxNames, []);
const names = members.map((m) => m.name);
expect(names).toContain('team-lead');
@ -295,7 +284,7 @@ describe('TeamMemberResolver', () => {
name: 'Team',
members: [{ name: 'lead', agentType: 'team-lead', role: 'lead' }],
};
const members = resolver.resolveMembers(config, [], ['lead'], [], []);
const members = resolver.resolveMembers(config, [], ['lead'], []);
const names = members.map((m) => m.name);
expect(names).toContain('lead');
@ -310,7 +299,7 @@ describe('TeamMemberResolver', () => {
const tasks: TeamTaskWithKanban[] = [
{ id: 't1', subject: 'Work', status: 'completed', owner: 'bob' },
];
const members = resolver.resolveMembers(config, [], [], tasks, []);
const members = resolver.resolveMembers(config, [], [], tasks);
const bob = members.find((m) => m.name === 'bob');
expect(bob?.currentTaskId).toBeNull();
});

View file

@ -39,6 +39,16 @@ vi.mock('@renderer/store/slices/teamSlice', () => ({
selectTeamDataForName: (_state: typeof storeState, teamName: string) =>
storeState.teamDataCacheByName[teamName] ??
(storeState.selectedTeamName === teamName ? storeState.selectedTeamData : null),
selectTeamMemberSnapshotsForName: (_state: typeof storeState, teamName: string) =>
(
storeState.teamDataCacheByName[teamName] ??
(storeState.selectedTeamName === teamName ? storeState.selectedTeamData : null)
)?.members ?? [],
selectResolvedMembersForTeamName: (_state: typeof storeState, teamName: string) =>
(
storeState.teamDataCacheByName[teamName] ??
(storeState.selectedTeamName === teamName ? storeState.selectedTeamData : null)
)?.members ?? [],
}));
vi.mock('zustand/react/shallow', () => ({

View file

@ -61,6 +61,16 @@ vi.mock('@renderer/store', () => ({
vi.mock('@renderer/store/slices/teamSlice', () => ({
getCurrentProvisioningProgressForTeam: () => storeState.progress,
selectResolvedMemberForTeamName: (state: typeof storeState, teamName: string, memberName: string) =>
(state.selectedTeamName === teamName ? state.selectedTeamData : null)?.members.find(
(candidate) => candidate.name === memberName
) ?? null,
selectTeamMemberSnapshotsForName: (state: typeof storeState, teamName: string) =>
(state.selectedTeamName === teamName ? state.selectedTeamData : null)?.members ?? [],
selectTeamTasksForName: (state: typeof storeState, teamName: string) =>
(state.selectedTeamName === teamName ? state.selectedTeamData : null)?.tasks ?? [],
selectTeamIsAliveForName: (state: typeof storeState, teamName: string) =>
(state.selectedTeamName === teamName ? state.selectedTeamData : null)?.isAlive,
}));
vi.mock('@renderer/hooks/useTheme', () => ({

View file

@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MemberMessagesTab } from '@renderer/components/team/members/MemberMessagesTab';
import { useStore } from '@renderer/store';
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
@ -48,11 +49,27 @@ describe('MemberMessagesTab', () => {
nextCursor: null,
hasMore: false,
});
useStore.setState({
teamMessagesByName: {
'demo-team': {
canonicalMessages: [],
optimisticMessages: [],
feedRevision: 'rev-empty',
nextCursor: null,
hasMore: false,
lastFetchedAt: null,
loadingHead: false,
loadingOlder: false,
headHydrated: true,
},
},
} as never);
});
afterEach(() => {
document.body.innerHTML = '';
getMessagesPage.mockReset();
useStore.setState({ teamMessagesByName: {} } as never);
});
it('shows both messages and comments by default and filters them separately', async () => {
@ -110,10 +127,25 @@ describe('MemberMessagesTab', () => {
document.body.appendChild(host);
const root = createRoot(host);
useStore.setState({
teamMessagesByName: {
'demo-team': {
canonicalMessages: messages,
optimisticMessages: [],
feedRevision: 'rev-1',
nextCursor: null,
hasMore: false,
lastFetchedAt: Date.now(),
loadingHead: false,
loadingOlder: false,
headHydrated: true,
},
},
} as never);
await act(async () => {
root.render(
React.createElement(MemberMessagesTab, {
messages,
teamName: 'demo-team',
memberName: 'jack',
members,
@ -123,6 +155,8 @@ describe('MemberMessagesTab', () => {
await Promise.resolve();
});
expect(getMessagesPage).not.toHaveBeenCalled();
const getRenderedKinds = () =>
Array.from(host.querySelectorAll('[data-testid="activity-item"]')).map((node) =>
node.getAttribute('data-kind')
@ -209,10 +243,35 @@ describe('MemberMessagesTab', () => {
document.body.appendChild(host);
const root = createRoot(host);
useStore.setState({
teamMessagesByName: {
'demo-team': {
canonicalMessages: [
{
from: 'team-lead',
to: 'alice',
text: 'Message for another member',
summary: 'Message for another member',
timestamp: '2026-04-13T13:34:00.000Z',
read: false,
messageId: 'msg-other-member',
},
],
optimisticMessages: [],
feedRevision: 'rev-older',
nextCursor: 'older-cursor',
hasMore: true,
lastFetchedAt: Date.now(),
loadingHead: false,
loadingOlder: false,
headHydrated: true,
},
},
} as never);
await act(async () => {
root.render(
React.createElement(MemberMessagesTab, {
messages: [],
teamName: 'demo-team',
memberName: 'jack',
members,
@ -222,6 +281,7 @@ describe('MemberMessagesTab', () => {
await Promise.resolve();
});
expect(getMessagesPage).not.toHaveBeenCalled();
expect(host.textContent).toContain('No activity with this member');
expect(host.textContent).not.toContain('Load older messages');

View file

@ -12,6 +12,21 @@ const storeState = {
lastSendMessageResult: null,
teams: [],
openTeamTab: vi.fn(),
loadOlderTeamMessages: vi.fn().mockResolvedValue(undefined),
teamMessagesByName: {} as Record<
string,
{
canonicalMessages: InboxMessage[];
optimisticMessages: InboxMessage[];
feedRevision: string | null;
nextCursor: string | null;
hasMore: boolean;
lastFetchedAt: number | null;
loadingHead: boolean;
loadingOlder: boolean;
headHydrated: boolean;
}
>,
};
const readHookState = {
@ -146,6 +161,8 @@ describe('MessagesPanel idle summary invariants', () => {
storeState.sendTeamMessage.mockClear();
storeState.sendCrossTeamMessage.mockClear();
storeState.openTeamTab.mockClear();
storeState.loadOlderTeamMessages.mockClear();
storeState.teamMessagesByName = {};
});
it('keeps read passive peer summaries in the activity timeline while unread badge only counts filtered unread messages', async () => {
@ -175,6 +192,17 @@ describe('MessagesPanel idle summary invariants', () => {
];
await act(async () => {
storeState.teamMessagesByName['atlas-hq'] = {
canonicalMessages: messages,
optimisticMessages: [],
feedRevision: 'rev-1',
nextCursor: null,
hasMore: false,
lastFetchedAt: Date.now(),
loadingHead: false,
loadingOlder: false,
headHydrated: true,
};
root.render(
React.createElement(MessagesPanel, {
teamName: 'atlas-hq',
@ -182,7 +210,6 @@ describe('MessagesPanel idle summary invariants', () => {
onPositionChange: vi.fn(),
members: [],
tasks: [],
messages,
timeWindow: null,
teamSessionIds: new Set<string>(),
pendingRepliesByMember: {},
@ -226,6 +253,17 @@ describe('MessagesPanel idle summary invariants', () => {
];
await act(async () => {
storeState.teamMessagesByName['atlas-hq'] = {
canonicalMessages: messages,
optimisticMessages: [],
feedRevision: 'rev-1',
nextCursor: null,
hasMore: false,
lastFetchedAt: Date.now(),
loadingHead: false,
loadingOlder: false,
headHydrated: true,
};
root.render(
React.createElement(MessagesPanel, {
teamName: 'atlas-hq',
@ -233,7 +271,6 @@ describe('MessagesPanel idle summary invariants', () => {
onPositionChange: vi.fn(),
members: [],
tasks: [],
messages,
timeWindow: null,
teamSessionIds: new Set<string>(),
pendingRepliesByMember: { alice: pendingSentAtMs },
@ -260,6 +297,17 @@ describe('MessagesPanel idle summary invariants', () => {
const root = createRoot(host);
await act(async () => {
storeState.teamMessagesByName['atlas-hq'] = {
canonicalMessages: [makeMessage()],
optimisticMessages: [],
feedRevision: 'rev-1',
nextCursor: null,
hasMore: false,
lastFetchedAt: Date.now(),
loadingHead: false,
loadingOlder: false,
headHydrated: true,
};
root.render(
React.createElement(MessagesPanel, {
teamName: 'atlas-hq',
@ -268,7 +316,6 @@ describe('MessagesPanel idle summary invariants', () => {
onPositionChange: vi.fn(),
members: [],
tasks: [],
messages: [makeMessage()],
timeWindow: null,
teamSessionIds: new Set<string>(),
pendingRepliesByMember: {},

View file

@ -16,11 +16,10 @@ const teamState = {
{ name: 'jack', agentType: 'developer' },
],
tasks: [],
messages: [],
},
teamDataCacheByName: new Map<
string,
{ members: Record<string, unknown>[]; tasks: unknown[]; messages: unknown[] }
{ members: Record<string, unknown>[]; tasks: unknown[] }
>([
[
'demo-team',
@ -30,7 +29,6 @@ const teamState = {
{ name: 'jack', agentType: 'developer' },
],
tasks: [],
messages: [],
},
],
]),
@ -55,6 +53,12 @@ vi.mock('@renderer/store/slices/teamSlice', () => ({
selectTeamDataForName: (_state: typeof teamState, teamName: string) =>
teamState.teamDataCacheByName.get(teamName) ??
(teamState.selectedTeamName === teamName ? teamState.selectedTeamData : null),
selectResolvedMembersForTeamName: (_state: typeof teamState, teamName: string) =>
(
teamState.teamDataCacheByName.get(teamName) ??
(teamState.selectedTeamName === teamName ? teamState.selectedTeamData : null)
)?.members ?? [],
selectTeamMessages: () => [],
}));
vi.mock('zustand/react/shallow', () => ({

View file

@ -1,16 +1,20 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TeamGraphAdapter } from '@features/agent-graph/renderer/adapters/TeamGraphAdapter';
import {
TeamGraphAdapter,
type TeamGraphData,
} from '@features/agent-graph/renderer/adapters/TeamGraphAdapter';
import type { InboxMessage, TeamData, TeamTaskWithKanban } from '@shared/types/team';
import type { InboxMessage, TeamTaskWithKanban } from '@shared/types/team';
import type { GraphDataPort } from '@claude-teams/agent-graph';
function createBaseTeamData(
overrides?: Partial<TeamData> & {
overrides?: Partial<TeamGraphData> & {
tasks?: TeamTaskWithKanban[];
messages?: InboxMessage[];
}
): TeamData {
): TeamGraphData {
const { messages, ...restOverrides } = overrides ?? {};
return {
teamName: 'my-team',
config: {
@ -46,11 +50,11 @@ function createBaseTeamData(
},
],
tasks: [],
messages: [],
messageFeed: messages ?? [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
isAlive: true,
...overrides,
...restOverrides,
};
}

View file

@ -1,25 +1,20 @@
import { describe, expect, it } from 'vitest';
import {
type ActivityEntrySourceData,
buildInlineActivityEntries,
getGraphLeadMemberName,
} from '@features/agent-graph/core/domain/buildInlineActivityEntries';
import type { InboxMessage, TeamData, TeamTaskWithKanban } from '@shared/types/team';
import type { InboxMessage, TeamTaskWithKanban } from '@shared/types/team';
function createBaseTeamData(
overrides?: Partial<TeamData> & {
overrides?: Partial<ActivityEntrySourceData> & {
tasks?: TeamTaskWithKanban[];
messages?: InboxMessage[];
}
): TeamData {
): ActivityEntrySourceData {
return {
teamName: 'my-team',
config: {
name: 'My Team',
members: [{ name: 'team-lead' }, { name: 'alice' }, { name: 'bob' }],
projectPath: '/repo',
},
members: [
{
name: 'team-lead',
@ -49,9 +44,6 @@ function createBaseTeamData(
],
tasks: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
isAlive: true,
...overrides,
};
}

View file

@ -62,6 +62,7 @@ vi.mock('@renderer/api', () => ({
}));
import { initializeNotificationListeners, useStore } from '../../../src/renderer/store';
import { __resetTeamSliceModuleStateForTests } from '../../../src/renderer/store/slices/teamSlice';
import { api } from '@renderer/api';
describe('team change throttling', () => {
@ -69,17 +70,29 @@ describe('team change throttling', () => {
beforeEach(async () => {
vi.useFakeTimers();
__resetTeamSliceModuleStateForTests();
const fetchTeams = vi.fn(async () => undefined);
const fetchMemberSpawnStatuses = vi.fn(async () => undefined);
const refreshTeamData = vi.fn(async () => undefined);
const refreshTeamMessagesHead = vi.fn(async () => ({
feedChanged: true,
headChanged: true,
feedRevision: 'rev-1',
}));
const refreshMemberActivityMeta = vi.fn(async () => undefined);
const refreshTeamChangePresence = vi.fn(async () => undefined);
useStore.setState({
fetchTeams,
fetchMemberSpawnStatuses,
refreshTeamData,
refreshTeamMessagesHead,
refreshMemberActivityMeta,
refreshTeamChangePresence,
selectedTeamName: null,
selectedTeamData: null,
teamDataCacheByName: {},
memberActivityMetaByTeam: {},
paneLayout: {
focusedPaneId: 'p1',
panes: [
@ -103,6 +116,7 @@ describe('team change throttling', () => {
afterEach(() => {
cleanup?.();
cleanup = null;
__resetTeamSliceModuleStateForTests();
vi.mocked(console.warn).mockClear();
vi.useRealTimers();
});
@ -149,10 +163,12 @@ describe('team change throttling', () => {
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2);
});
it('lead-message refreshes detail only, not team list or tasks', async () => {
it('lead-message refreshes message head only, not team list, tasks, or structural detail', async () => {
const state = useStore.getState();
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
const refreshTeamMessagesHeadSpy = vi.spyOn(state, 'refreshTeamMessagesHead');
const refreshMemberActivityMetaSpy = vi.spyOn(state, 'refreshMemberActivityMeta');
// Emit a lead-message event
hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' });
@ -161,9 +177,11 @@ describe('team change throttling', () => {
await vi.advanceTimersByTimeAsync(2100);
expect(fetchTeamsSpy).not.toHaveBeenCalled();
// Should trigger refreshTeamData at 800ms
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledTimes(1);
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('my-team');
expect(refreshMemberActivityMetaSpy).toHaveBeenCalledTimes(1);
expect(refreshMemberActivityMetaSpy).toHaveBeenCalledWith('my-team');
});
it('lead-message refreshes visible graph tabs even when the team is not selected', async () => {
@ -174,7 +192,6 @@ describe('team change throttling', () => {
config: { name: 'Other Team', members: [], projectPath: '/repo' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
processes: [],
},
@ -192,11 +209,88 @@ describe('team change throttling', () => {
} as never);
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead');
hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' });
await vi.advanceTimersByTimeAsync(800);
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('my-team');
});
it('lead-message refreshes hidden teams with an active pending-reply wait state', async () => {
useStore.getState().syncTeamPendingReplyRefresh('other-team', true, 60_000);
useStore.setState({
paneLayout: {
focusedPaneId: 'p1',
panes: [
{
id: 'p1',
widthFraction: 1,
tabs: [{ id: 't1', type: 'team', teamName: 'my-team', label: 'my-team' }],
activeTabId: 't1',
},
],
},
} as never);
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead');
const refreshMemberActivityMetaSpy = vi.spyOn(useStore.getState(), 'refreshMemberActivityMeta');
hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'other-team' });
await vi.advanceTimersByTimeAsync(800);
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('other-team');
expect(refreshMemberActivityMetaSpy).toHaveBeenCalledWith('other-team');
});
it('lead-message does not refresh hidden inactive teams without pending replies', async () => {
useStore.setState({
paneLayout: {
focusedPaneId: 'p1',
panes: [
{
id: 'p1',
widthFraction: 1,
tabs: [{ id: 't1', type: 'team', teamName: 'my-team', label: 'my-team' }],
activeTabId: 't1',
},
],
},
} as never);
const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead');
const refreshMemberActivityMetaSpy = vi.spyOn(useStore.getState(), 'refreshMemberActivityMeta');
hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'other-team' });
await vi.advanceTimersByTimeAsync(800);
expect(refreshTeamMessagesHeadSpy).not.toHaveBeenCalledWith('other-team');
expect(refreshMemberActivityMetaSpy).not.toHaveBeenCalledWith('other-team');
});
it('member-spawn refreshes spawn statuses without forcing structural refresh', async () => {
const fetchMemberSpawnStatusesSpy = vi.spyOn(useStore.getState(), 'fetchMemberSpawnStatuses');
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
hoisted.onTeamChangeCb?.({}, { type: 'member-spawn', teamName: 'my-team' });
await vi.advanceTimersByTimeAsync(500);
expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledWith('my-team');
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
});
it('inbox/config/process do not refresh member spawn statuses by default', async () => {
const fetchMemberSpawnStatusesSpy = vi.spyOn(useStore.getState(), 'fetchMemberSpawnStatuses');
hoisted.onTeamChangeCb?.({}, { type: 'inbox', teamName: 'my-team' });
hoisted.onTeamChangeCb?.({}, { type: 'config', teamName: 'my-team' });
hoisted.onTeamChangeCb?.({}, { type: 'process', teamName: 'my-team' });
await vi.advanceTimersByTimeAsync(800);
expect(fetchMemberSpawnStatusesSpy).not.toHaveBeenCalled();
});
it('lead-message does not call fetchAllTasks', async () => {
@ -209,6 +303,17 @@ describe('team change throttling', () => {
expect(fetchAllTasksSpy).not.toHaveBeenCalled();
});
it('fallback polling refreshes hidden teams with an active pending-reply wait state', async () => {
useStore.getState().syncTeamPendingReplyRefresh('other-team', true, 60_000);
const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead');
const refreshMemberActivityMetaSpy = vi.spyOn(useStore.getState(), 'refreshMemberActivityMeta');
await vi.advanceTimersByTimeAsync(10_000);
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('other-team');
expect(refreshMemberActivityMetaSpy).toHaveBeenCalledWith('other-team');
});
it('log-source-change refreshes only task change presence', async () => {
useStore.setState({
selectedTeamName: 'my-team',
@ -217,7 +322,6 @@ describe('team change throttling', () => {
config: { name: 'My Team', members: [], projectPath: '/repo' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
@ -248,7 +352,6 @@ describe('team change throttling', () => {
config: { name: 'Other Team', members: [], projectPath: '/repo' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
processes: [],
},
@ -258,7 +361,6 @@ describe('team change throttling', () => {
config: { name: 'My Team', members: [], projectPath: '/repo' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
@ -318,7 +420,6 @@ describe('team change throttling', () => {
},
],
members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
@ -354,7 +455,6 @@ describe('team change throttling', () => {
config: { name: 'Other Team', members: [], projectPath: '/repo' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
processes: [],
},
@ -387,7 +487,6 @@ describe('team change throttling', () => {
},
],
members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
@ -448,6 +547,7 @@ describe('team change throttling', () => {
const state = useStore.getState();
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
const refreshTeamMessagesHeadSpy = vi.spyOn(state, 'refreshTeamMessagesHead');
// Fire rapid events for my-team (throttled)
hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' });
@ -459,9 +559,10 @@ describe('team change throttling', () => {
await vi.advanceTimersByTimeAsync(800);
// Both teams should get exactly 1 refresh each
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2);
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
expect(refreshTeamDataSpy).toHaveBeenCalledWith('other-team', { withDedup: true });
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledTimes(2);
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('my-team');
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('other-team');
});
it('keeps auto change presence tracking disabled even after selected team data is hydrated', async () => {
@ -477,7 +578,6 @@ describe('team change throttling', () => {
config: { name: 'My Team', members: [], projectPath: '/repo' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},

File diff suppressed because it is too large Load diff

View file

@ -20,12 +20,6 @@ describe('buildTeamProvisioningPresentation', () => {
members: [
{
name: 'team-lead',
agentType: 'team-lead',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
},
],
memberSpawnStatuses: {},