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

View file

@ -1,17 +1,34 @@
import { useStore } from '@renderer/store'; 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 { 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): { export function useGraphActivityContext(teamName: string): {
teamData: TeamData | null; teamData: TeamGraphData | null;
teams: TeamSummary[]; teams: TeamSummary[];
} { } {
return useStore( return useStore(
useShallow((state) => ({ useShallow((state) => {
teamData: selectTeamDataForName(state, teamName), const snapshot = selectTeamDataForName(state, teamName);
teams: state.teams, 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 { api } from '@renderer/api';
import { CreateTaskDialog } from '@renderer/components/team/dialogs/CreateTaskDialog'; import { CreateTaskDialog } from '@renderer/components/team/dialogs/CreateTaskDialog';
import { useStore } from '@renderer/store'; 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 { useShallow } from 'zustand/react/shallow';
import type { TaskRef } from '@shared/types'; import type { TaskRef } from '@shared/types';
@ -25,19 +29,17 @@ export function useGraphCreateTaskDialog(teamName: string): UseGraphCreateTaskDi
}); });
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const { teamData, createTeamTask, isTeamProvisioning } = useStore( const { teamData, activeMembers, createTeamTask, isTeamProvisioning } = useStore(
useShallow((state) => ({ useShallow((state) => ({
teamData: selectTeamDataForName(state, teamName), teamData: selectTeamDataForName(state, teamName),
activeMembers: selectResolvedMembersForTeamName(state, teamName).filter(
(member) => !member.removedAt
),
createTeamTask: state.createTeamTask, createTeamTask: state.createTeamTask,
isTeamProvisioning: isTeamProvisioningActive(state, teamName), isTeamProvisioning: isTeamProvisioningActive(state, teamName),
})) }))
); );
const activeMembers = useMemo(
() => (teamData?.members ?? []).filter((member) => !member.removedAt),
[teamData?.members]
);
const openCreateTaskDialog = useCallback((owner = ''): void => { const openCreateTaskDialog = useCallback((owner = ''): void => {
setDialogState({ setDialogState({
open: true, open: true,

View file

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

View file

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

View file

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

View file

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

View file

@ -22,6 +22,7 @@ import {
TEAM_GET_DATA, TEAM_GET_DATA,
TEAM_GET_DELETED_TASKS, TEAM_GET_DELETED_TASKS,
TEAM_GET_LOGS_FOR_TASK, TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_MEMBER_ACTIVITY_META,
TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS, TEAM_GET_MEMBER_STATS,
TEAM_GET_MESSAGES_PAGE, TEAM_GET_MESSAGES_PAGE,
@ -92,7 +93,7 @@ import {
parseStandaloneSlashCommand, parseStandaloneSlashCommand,
} from '@shared/utils/slashCommands'; } from '@shared/utils/slashCommands';
import crypto from 'crypto'; 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 fs from 'fs';
import * as path from 'path'; import * as path from 'path';
@ -170,15 +171,16 @@ import type {
TeamCreateConfigRequest, TeamCreateConfigRequest,
TeamCreateRequest, TeamCreateRequest,
TeamCreateResponse, TeamCreateResponse,
TeamData,
TeamLaunchRequest, TeamLaunchRequest,
TeamLaunchResponse, TeamLaunchResponse,
TeamMemberActivityMeta,
TeamMessageNotificationData, TeamMessageNotificationData,
TeamProvisioningPrepareResult, TeamProvisioningPrepareResult,
TeamProvisioningProgress, TeamProvisioningProgress,
TeamSummary, TeamSummary,
TeamTask, TeamTask,
TeamTaskStatus, TeamTaskStatus,
TeamViewSnapshot,
TeamUpdateConfigRequest, TeamUpdateConfigRequest,
ToolApprovalFileContent, ToolApprovalFileContent,
ToolApprovalSettings, ToolApprovalSettings,
@ -196,6 +198,17 @@ const logger = createLogger('IPC:teams');
const seenRateLimitKeys = new Set<string>(); const seenRateLimitKeys = new Set<string>();
const SEEN_RATE_LIMIT_KEYS_MAX = 500; 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( async function getDurableLeadTeammateRoster(
teamName: string, teamName: string,
leadName: 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 teamDataService: TeamDataService | null = null;
let teamProvisioningService: TeamProvisioningService | null = null; let teamProvisioningService: TeamProvisioningService | null = null;
let teamMemberLogsFinder: TeamMemberLogsFinder | 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_CANCEL_PROVISIONING, handleCancelProvisioning);
ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage); ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage);
ipcMain.handle(TEAM_GET_MESSAGES_PAGE, handleGetMessagesPage); ipcMain.handle(TEAM_GET_MESSAGES_PAGE, handleGetMessagesPage);
ipcMain.handle(TEAM_GET_MEMBER_ACTIVITY_META, handleGetMemberActivityMeta);
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask); ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview); ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview);
ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban); ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban);
@ -535,6 +562,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING); ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING);
ipcMain.removeHandler(TEAM_SEND_MESSAGE); ipcMain.removeHandler(TEAM_SEND_MESSAGE);
ipcMain.removeHandler(TEAM_GET_MESSAGES_PAGE); ipcMain.removeHandler(TEAM_GET_MESSAGES_PAGE);
ipcMain.removeHandler(TEAM_GET_MEMBER_ACTIVITY_META);
ipcMain.removeHandler(TEAM_CREATE_TASK); ipcMain.removeHandler(TEAM_CREATE_TASK);
ipcMain.removeHandler(TEAM_REQUEST_REVIEW); ipcMain.removeHandler(TEAM_REQUEST_REVIEW);
ipcMain.removeHandler(TEAM_UPDATE_KANBAN); ipcMain.removeHandler(TEAM_UPDATE_KANBAN);
@ -702,14 +730,14 @@ async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<Te
async function handleGetData( async function handleGetData(
_event: IpcMainInvokeEvent, _event: IpcMainInvokeEvent,
teamName: unknown teamName: unknown
): Promise<IpcResult<TeamData>> { ): Promise<IpcResult<TeamViewSnapshot>> {
const validated = validateTeamName(teamName); const validated = validateTeamName(teamName);
if (!validated.valid) { if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' }; return { success: false, error: validated.error ?? 'Invalid teamName' };
} }
const tn = validated.value!; const tn = validated.value!;
const startedAt = Date.now(); const startedAt = Date.now();
let data: TeamData; let data: TeamViewSnapshot;
setCurrentMainOp('team:getData'); setCurrentMainOp('team:getData');
try { try {
// Prefer worker thread to keep main event loop responsive // Prefer worker thread to keep main event loop responsive
@ -721,9 +749,11 @@ async function handleGetData(
logger.warn( logger.warn(
`[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}` `[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
); );
ensureHeavyTeamDataWorkerFallbackAllowed('teams:getData');
data = await getTeamDataService().getTeamData(tn); data = await getTeamDataService().getTeamData(tn);
} }
} else { } else {
ensureHeavyTeamDataWorkerFallbackAllowed('teams:getData');
data = await getTeamDataService().getTeamData(tn); data = await getTeamDataService().getTeamData(tn);
} }
} catch (error) { } catch (error) {
@ -762,92 +792,9 @@ async function handleGetData(
const displayName = data.config.name || tn; const displayName = data.config.name || tn;
const projectPath = data.config.projectPath; const projectPath = data.config.projectPath;
const live = provisioning.getLiveLeadProcessMessages(tn); const live = provisioning.getLiveLeadProcessMessages(tn);
if (live.length === 0) { scanTeamMessageNotifications(live, tn, displayName, projectPath);
checkRateLimitMessages(data.messages, tn, displayName, projectPath); return { success: true, data: { ...data, isAlive } };
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 } };
} }
async function handleGetTaskChangePresence( async function handleGetTaskChangePresence(
@ -1698,16 +1645,71 @@ async function handleGetMessagesPage(
return { success: false, error: vTeam.error ?? 'Invalid teamName' }; return { success: false, error: vTeam.error ?? 'Invalid teamName' };
} }
const opts = (options && typeof options === 'object' ? options : {}) as { const opts = (options && typeof options === 'object' ? options : {}) as {
beforeTimestamp?: string; cursor?: string | null;
limit?: number; limit?: number;
}; };
const limit = Math.min(Math.max(1, opts.limit ?? 50), 200); const limit = Math.min(Math.max(1, opts.limit ?? 50), 200);
const beforeTimestamp = const cursor =
typeof opts.beforeTimestamp === 'string' ? opts.beforeTimestamp : undefined; typeof opts.cursor === 'string' ? opts.cursor : opts.cursor === null ? null : undefined;
return wrapTeamHandler('getMessagesPage', async () => { return wrapTeamHandler('getMessagesPage', async () => {
const service = getTeamDataService(); let page: MessagesPage;
return service.getMessagesPage(vTeam.value!, { beforeTimestamp, limit }); 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'; } from './cache/LeadSessionParseCache';
import { atomicWriteAsync } from './atomicWrite'; import { atomicWriteAsync } from './atomicWrite';
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor'; import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
import { MemberActivityMetaService } from './MemberActivityMetaService';
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils'; import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
import { TeamConfigReader } from './TeamConfigReader'; import { TeamConfigReader } from './TeamConfigReader';
import { TeamInboxReader } from './TeamInboxReader'; import { TeamInboxReader } from './TeamInboxReader';
@ -46,6 +47,7 @@ import { TeamInboxWriter } from './TeamInboxWriter';
import { TeamKanbanManager } from './TeamKanbanManager'; import { TeamKanbanManager } from './TeamKanbanManager';
import { TeamMemberResolver } from './TeamMemberResolver'; import { TeamMemberResolver } from './TeamMemberResolver';
import { TeamMemberRuntimeAdvisoryService } from './TeamMemberRuntimeAdvisoryService'; import { TeamMemberRuntimeAdvisoryService } from './TeamMemberRuntimeAdvisoryService';
import { TeamMessageFeedService } from './TeamMessageFeedService';
import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamMetaStore } from './TeamMetaStore'; import { TeamMetaStore } from './TeamMetaStore';
import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamSentMessagesStore } from './TeamSentMessagesStore';
@ -65,7 +67,6 @@ import type {
KanbanColumnId, KanbanColumnId,
KanbanState, KanbanState,
MessagesPage, MessagesPage,
ResolvedTeamMember,
SendMessageRequest, SendMessageRequest,
SendMessageResult, SendMessageResult,
TaskAttachmentMeta, TaskAttachmentMeta,
@ -74,13 +75,14 @@ import type {
TaskRef, TaskRef,
TeamConfig, TeamConfig,
TeamCreateConfigRequest, TeamCreateConfigRequest,
TeamData, TeamMemberActivityMeta,
TeamMember, TeamMember,
TeamProcess, TeamProcess,
TeamSummary, TeamSummary,
TeamTask, TeamTask,
TeamTaskStatus, TeamTaskStatus,
TeamTaskWithKanban, TeamTaskWithKanban,
TeamViewSnapshot,
ToolCallMeta, ToolCallMeta,
UpdateKanbanPatch, UpdateKanbanPatch,
} from '@shared/types'; } from '@shared/types';
@ -98,6 +100,14 @@ const TASK_MAP_YIELD_EVERY = 250;
const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification'; const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification';
const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000; 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 { interface EligibleTaskCommentNotification {
key: string; key: string;
messageId: string; messageId: string;
@ -162,6 +172,8 @@ export class TeamDataService {
private taskChangePresenceRepository: TaskChangePresenceRepository | null = null; private taskChangePresenceRepository: TaskChangePresenceRepository | null = null;
private teamLogSourceTracker: TeamLogSourceTracker | null = null; private teamLogSourceTracker: TeamLogSourceTracker | null = null;
private fileWatchReconcileDiagnostics = new Map<string, FileWatchReconcileDiagnostics>(); private fileWatchReconcileDiagnostics = new Map<string, FileWatchReconcileDiagnostics>();
private readonly messageFeedService: TeamMessageFeedService;
private readonly memberActivityMetaService: MemberActivityMetaService;
constructor( constructor(
private readonly configReader: TeamConfigReader = new TeamConfigReader(), private readonly configReader: TeamConfigReader = new TeamConfigReader(),
@ -183,7 +195,15 @@ export class TeamDataService {
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(), private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(),
private memberRuntimeAdvisoryService: TeamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(), private memberRuntimeAdvisoryService: TeamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(),
private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache() 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 { private getController(teamName: string): AgentTeamsController {
return this.controllerFactory(teamName); return this.controllerFactory(teamName);
@ -622,7 +642,7 @@ export class TeamDataService {
await fs.promises.rm(tasksDir, { recursive: true, force: true }); 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 startedAt = Date.now();
const marks: Record<string, number> = {}; const marks: Record<string, number> = {};
const mark = (label: string): void => { const mark = (label: string): void => {
@ -726,12 +746,6 @@ export class TeamDataService {
warningText: 'Inboxes failed to load', warningText: 'Inboxes failed to load',
load: () => this.inboxReader.listInboxNames(teamName), 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({ const metaMembersStep = startReadStep({
label: 'metaMembers', label: 'metaMembers',
createFallback: () => [], createFallback: () => [],
@ -756,40 +770,8 @@ export class TeamDataService {
load: () => this.taskReader.getTasks(teamName), load: () => this.taskReader.getTasks(teamName),
}) })
); );
const messagesStep = runWithConcurrencyLimit(() => const [tasksStepResult, inboxNamesStepResult, metaMembersStepResult, kanbanStateStepResult] =
startReadStep({ await Promise.all([tasksStep, inboxNamesStep, metaMembersStep, kanbanStateStep]);
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,
]);
// After parallelizing the top read phase, these marks no longer represent // After parallelizing the top read phase, these marks no longer represent
// serial stage boundaries. They now capture the actual completion time for // 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. // diagnostics useful without mutating marks from concurrent branches.
marks.tasks = tasksStepResult.completedAt; marks.tasks = tasksStepResult.completedAt;
marks.inboxNames = inboxNamesStepResult.completedAt; marks.inboxNames = inboxNamesStepResult.completedAt;
marks.messages = messagesStepResult.completedAt;
marks.leadTexts = leadTextsStepResult.completedAt;
marks.sentMessages = sentMessagesStepResult.completedAt;
marks.metaMembers = metaMembersStepResult.completedAt; marks.metaMembers = metaMembersStepResult.completedAt;
marks.kanbanState = kanbanStateStepResult.completedAt; marks.kanbanState = kanbanStateStepResult.completedAt;
if (tasksStepResult.warning) warnings.push(tasksStepResult.warning); if (tasksStepResult.warning) warnings.push(tasksStepResult.warning);
if (inboxNamesStepResult.warning) warnings.push(inboxNamesStepResult.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 (metaMembersStepResult.warning) warnings.push(metaMembersStepResult.warning);
if (kanbanStateStepResult.warning) warnings.push(kanbanStateStepResult.warning); if (kanbanStateStepResult.warning) warnings.push(kanbanStateStepResult.warning);
const tasks: TeamTask[] = tasksStepResult.value; const tasks: TeamTask[] = tasksStepResult.value;
const inboxNames: string[] = inboxNamesStepResult.value; const inboxNames: string[] = inboxNamesStepResult.value;
let messages: InboxMessage[] = messagesStepResult.value;
const leadTexts: InboxMessage[] = leadTextsStepResult.value;
const sentMessages: InboxMessage[] = sentMessagesStepResult.value;
mark('postStart'); 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 metaMembers: TeamConfig['members'] = metaMembersStepResult.value;
const kanbanState: KanbanState = kanbanStateStepResult.value; const kanbanState: KanbanState = kanbanStateStepResult.value;
@ -1000,8 +822,7 @@ export class TeamDataService {
config, config,
metaMembers, metaMembers,
inboxNames, inboxNames,
tasksWithKanban, tasksWithKanban
messages
); );
mark('resolveMembers'); mark('resolveMembers');
@ -1036,30 +857,13 @@ export class TeamDataService {
const totalMs = Date.now() - startedAt; const totalMs = Date.now() - startedAt;
if (totalMs >= 1500) { 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( logger.warn(
`getTeamData team=${teamName} slow total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince( `getTeamData team=${teamName} slow total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince(
'inboxNames' 'inboxNames'
)} messages=${msSince('messages')} leadTexts=${msSince('leadTexts')} sent=${msSince(
'sentMessages'
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince( )} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince(
'kanbanGc' 'kanbanGc'
)} post=${msBetween( )} post=${msBetween('postStart', 'attachKanban')}/loadPresenceIndex=${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(
'attachKanban', 'attachKanban',
'loadPresenceIndex' 'loadPresenceIndex'
)}/changePresence=${msBetween( )}/changePresence=${msBetween(
@ -1088,21 +892,14 @@ export class TeamDataService {
this.processHealthTeams.delete(teamName); 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 { return {
teamName, teamName,
config, config,
tasks: tasksWithKanban, tasks: tasksWithKanban,
members, members,
messages: cappedMessages,
kanbanState, kanbanState,
processes, processes,
isAlive: hasAlive,
warnings: warnings.length > 0 ? warnings : undefined, warnings: warnings.length > 0 ? warnings : undefined,
}; };
} }
@ -1113,106 +910,45 @@ export class TeamDataService {
*/ */
async getMessagesPage( async getMessagesPage(
teamName: string, teamName: string,
options: { beforeTimestamp?: string; limit: number } options: { cursor?: string | null; limit: number }
): Promise<MessagesPage> { ): Promise<MessagesPage> {
const config = await this.configReader.getConfig(teamName); const feed = await this.messageFeedService.getFeed(teamName);
if (!config) { let messages = feed.messages;
return { messages: [], nextCursor: null, hasMore: false };
}
// Collect all messages from the same sources as getTeamData if (options.cursor) {
let messages: InboxMessage[] = []; const [cursorTs, cursorId] = options.cursor.split('|');
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('|');
const cursorMs = Date.parse(cursorTs); const cursorMs = Date.parse(cursorTs);
messages = messages.filter((m) => { messages = messages.filter((m) => {
const ms = Date.parse(m.timestamp); const ms = Date.parse(m.timestamp);
if (ms < cursorMs) return true; if (ms < cursorMs) return true;
if (ms > cursorMs) return false; if (ms > cursorMs) return false;
// Same timestamp — use messageId tie-breaker
if (!cursorId) return false; if (!cursorId) return false;
return (m.messageId ?? '').localeCompare(cursorId) > 0; return requireCanonicalMessageId(m).localeCompare(cursorId) > 0;
}); });
} }
// Paginate
const hasMore = messages.length > options.limit; const hasMore = messages.length > options.limit;
const page = messages.slice(0, options.limit); const page = messages.slice(0, options.limit);
const lastMsg = page[page.length - 1]; const lastMsg = page[page.length - 1];
const nextCursor = 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). * Mutates members in-place for efficiency (called right after resolveMembers).
*/ */
private async enrichMemberBranches( private async enrichMemberBranches(
members: ResolvedTeamMember[], members: TeamViewSnapshot['members'],
config: TeamConfig config: TeamConfig
): Promise<void> { ): Promise<void> {
const leadEntry = config.members?.find((member) => isLeadMember(member)); const leadEntry = config.members?.find((member) => isLeadMember(member));
@ -1892,7 +1628,7 @@ export class TeamDataService {
slashCommand: slashCommandMeta, slashCommand: slashCommandMeta,
}; };
} }
return this.getController(teamName).messages.sendMessage({ const result = this.getController(teamName).messages.sendMessage({
member: enrichedRequest.member, member: enrichedRequest.member,
from: enrichedRequest.from, from: enrichedRequest.from,
text: enrichedRequest.text, text: enrichedRequest.text,
@ -1913,6 +1649,8 @@ export class TeamDataService {
leadSessionId: enrichedRequest.leadSessionId, leadSessionId: enrichedRequest.leadSessionId,
attachments: enrichedRequest.attachments, attachments: enrichedRequest.attachments,
}) as SendMessageResult; }) as SendMessageResult;
this.invalidateMessageFeed(teamName);
return result;
} }
private resolveLeadNameFromConfig(config: TeamConfig | null): string { 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> { async requestReview(teamName: string, taskId: string): Promise<void> {
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName); const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
this.getController(teamName).review.requestReview(taskId, { 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 { createLogger } from '@shared/utils/logger';
import type { TeamDataWorkerRequest, TeamDataWorkerResponse } from './teamDataWorkerTypes'; 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 logger = createLogger('Service:TeamDataWorkerClient');
const WORKER_CALL_TIMEOUT_MS = 30_000; const WORKER_CALL_TIMEOUT_MS = 30_000;
@ -25,16 +30,20 @@ function makeId(): string {
return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`; return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`;
} }
function resolveWorkerPath(): string | null { function getWorkerPathCandidates(): string[] {
const baseDir = const baseDir =
typeof __dirname === 'string' && __dirname.length > 0 typeof __dirname === 'string' && __dirname.length > 0
? __dirname ? __dirname
: path.dirname(fileURLToPath(import.meta.url)); : path.dirname(fileURLToPath(import.meta.url));
const candidates = [ return [
path.join(baseDir, 'team-data-worker.cjs'), path.join(baseDir, 'team-data-worker.cjs'),
path.join(process.cwd(), 'dist-electron', 'main', '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) { for (const candidate of candidates) {
try { try {
@ -75,7 +84,9 @@ export class TeamDataWorkerClient {
isAvailable(): boolean { isAvailable(): boolean {
if (!this.workerPath && !this.warnedUnavailable) { if (!this.workerPath && !this.warnedUnavailable) {
this.warnedUnavailable = true; 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; 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'); 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( async findLogsForTask(

View file

@ -3,15 +3,9 @@ import {
createCliProvisionerNameGuard, createCliProvisionerNameGuard,
} from '@shared/utils/teamMemberName'; } from '@shared/utils/teamMemberName';
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
import { getMemberColorByName } from '@shared/constants/memberColors';
import type { import type { TeamConfig, TeamMember, TeamMemberSnapshot, TeamTaskWithKanban } from '@shared/types';
InboxMessage,
MemberStatus,
ResolvedTeamMember,
TeamConfig,
TeamMember,
TeamTaskWithKanban,
} from '@shared/types';
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([ const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
@ -63,9 +57,8 @@ export class TeamMemberResolver {
config: TeamConfig, config: TeamConfig,
metaMembers: TeamConfig['members'], metaMembers: TeamConfig['members'],
inboxNames: string[], inboxNames: string[],
tasks: TeamTaskWithKanban[], tasks: TeamTaskWithKanban[]
messages: InboxMessage[] ): TeamMemberSnapshot[] {
): ResolvedTeamMember[] {
const names = new Set<string>(); const names = new Set<string>();
const explicitNames = new Set<string>(); const explicitNames = new Set<string>();
const seenNames = 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) { for (const name of names) {
const ownedTasks = tasks.filter((task) => task.owner === name); const ownedTasks = tasks.filter((task) => task.owner === name);
const currentTask = const currentTask =
@ -226,21 +219,15 @@ export class TeamMemberResolver {
task.reviewState !== 'approved' && task.reviewState !== 'approved' &&
task.kanbanColumn !== 'approved' task.kanbanColumn !== 'approved'
) ?? null; ) ?? 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 configMember = configMemberMap.get(name);
const metaMember = metaMemberMap.get(name); const metaMember = metaMemberMap.get(name);
const agentId = configMember?.agentId ?? metaMember?.agentId; const agentId = configMember?.agentId ?? metaMember?.agentId;
members.push({ members.push({
name, name,
agentId, agentId,
status,
currentTaskId: currentTask?.id ?? null, currentTaskId: currentTask?.id ?? null,
taskCount: ownedTasks.length, taskCount: ownedTasks.length,
messageCount: memberMessages.length, color: configMember?.color ?? metaMember?.color ?? getMemberColorByName(name),
lastActiveAt: latestMessage?.timestamp ?? null,
color: latestMessage?.color ?? configMember?.color ?? metaMember?.color,
agentType: configMember?.agentType ?? metaMember?.agentType, agentType: configMember?.agentType ?? metaMember?.agentType,
role: configMember?.role ?? metaMember?.role, role: configMember?.role ?? metaMember?.role,
workflow: configMember?.workflow ?? metaMember?.workflow, workflow: configMember?.workflow ?? metaMember?.workflow,
@ -277,45 +264,4 @@ export class TeamMemberResolver {
}); });
return members; 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. * 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 ── // ── Payloads ──
@ -10,6 +15,18 @@ export interface GetTeamDataPayload {
teamName: string; teamName: string;
} }
export interface GetMessagesPagePayload {
teamName: string;
options: {
cursor?: string | null;
limit: number;
};
}
export interface GetMemberActivityMetaPayload {
teamName: string;
}
export interface FindLogsForTaskPayload { export interface FindLogsForTaskPayload {
teamName: string; teamName: string;
taskId: string; taskId: string;
@ -25,8 +42,14 @@ export interface FindLogsForTaskPayload {
export type TeamDataWorkerRequest = export type TeamDataWorkerRequest =
| { id: string; op: 'getTeamData'; payload: GetTeamDataPayload } | { id: string; op: 'getTeamData'; payload: GetTeamDataPayload }
| { id: string; op: 'getMessagesPage'; payload: GetMessagesPagePayload }
| { id: string; op: 'getMemberActivityMeta'; payload: GetMemberActivityMetaPayload }
| { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload }; | { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload };
export type TeamDataWorkerResponse = 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 }; | { 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 }); respond({ id: msg.id, ok: true, result });
break; 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': { case 'findLogsForTask': {
const { teamName, taskId, options } = msg.payload; const { teamName, taskId, options } = msg.payload;
const intervalsKey = options?.intervals const intervalsKey = options?.intervals

View file

@ -234,6 +234,9 @@ export const TEAM_SEND_MESSAGE = 'team:sendMessage';
/** Paginated messages for timeline/messages panel */ /** Paginated messages for timeline/messages panel */
export const TEAM_GET_MESSAGES_PAGE = 'team:getMessagesPage'; 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 */ /** Request review for task */
export const TEAM_REQUEST_REVIEW = 'team:requestReview'; export const TEAM_REQUEST_REVIEW = 'team:requestReview';

View file

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

View file

@ -54,25 +54,26 @@ import type {
SshLastConnection, SshLastConnection,
SubagentDetail, SubagentDetail,
TeamChangeEvent, TeamChangeEvent,
UpdateSchedulePatch,
TeamClaudeLogsQuery, TeamClaudeLogsQuery,
TeamClaudeLogsResponse, TeamClaudeLogsResponse,
TeamCreateRequest, TeamCreateRequest,
TeamCreateResponse, TeamCreateResponse,
TeamData,
TeamLaunchRequest, TeamLaunchRequest,
TeamLaunchResponse, TeamLaunchResponse,
TeamMemberActivityMeta,
TeamProvisioningPrepareResult, TeamProvisioningPrepareResult,
TeamProvisioningProgress, TeamProvisioningProgress,
TeamsAPI, TeamsAPI,
TeamSummary, TeamSummary,
TeamTask, TeamTask,
TeamTaskStatus, TeamTaskStatus,
TeamViewSnapshot,
TmuxAPI, TmuxAPI,
TmuxStatus, TmuxStatus,
TriggerTestResult, TriggerTestResult,
UpdateKanbanPatch, UpdateKanbanPatch,
UpdaterAPI, UpdaterAPI,
UpdateSchedulePatch,
WaterfallData, WaterfallData,
WslClaudeRootCandidate, WslClaudeRootCandidate,
} from '@shared/types'; } from '@shared/types';
@ -677,7 +678,7 @@ export class HttpAPIClient implements ElectronAPI {
console.warn('[HttpAPIClient] teams API is not available in browser mode'); console.warn('[HttpAPIClient] teams API is not available in browser mode');
return []; return [];
}, },
getData: async (_teamName: string): Promise<TeamData> => { getData: async (_teamName: string): Promise<TeamViewSnapshot> => {
throw new Error('Teams detail is not available in browser mode'); throw new Error('Teams detail is not available in browser mode');
}, },
getTaskChangePresence: async (): Promise< getTaskChangePresence: async (): Promise<
@ -740,7 +741,15 @@ export class HttpAPIClient implements ElectronAPI {
throw new Error('Team messaging is not available in browser mode'); throw new Error('Team messaging is not available in browser mode');
}, },
getMessagesPage: async () => { 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> => { createTask: async (_teamName: string, _request: CreateTaskRequest): Promise<TeamTask> => {
throw new Error('Team task creation is not available in browser mode'); 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 { useTabUI } from '@renderer/hooks/useTabUI';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins'; import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; 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 // Get team members for @mention highlighting and team names for @team linkification
const { members, teams } = useStore( const { members, teams } = useStore(
useShallow((s) => ({ useShallow((s) => ({
members: s.selectedTeamData?.members, members: selectResolvedMembersForTeamName(s, s.selectedTeamName),
teams: s.teams, teams: s.teams,
})) }))
); );

View file

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

View file

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

View file

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

View file

@ -56,6 +56,7 @@ import {
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog'; import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
import type { TeamListFilterState } from './TeamListFilterPopover'; import type { TeamListFilterState } from './TeamListFilterPopover';
import type { import type {
TeamMemberSnapshot,
ResolvedTeamMember, ResolvedTeamMember,
TeamCreateRequest, TeamCreateRequest,
TeamLaunchRequest, TeamLaunchRequest,
@ -94,6 +95,17 @@ function folderName(fullPath: string): string {
return getBaseName(fullPath) || fullPath; 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 { function renderMemberChips(members: TeamSummaryMember[], isLight: boolean): React.JSX.Element {
const teamColorMap = buildMemberColorMap(members); const teamColorMap = buildMemberColorMap(members);
return ( return (
@ -625,7 +637,7 @@ export const TeamListView = (): React.JSX.Element => {
try { try {
const data = await api.teams.getData(teamName); const data = await api.teams.getData(teamName);
setLaunchDialogTeamName(teamName); setLaunchDialogTeamName(teamName);
setLaunchDialogMembers(data.members ?? []); setLaunchDialogMembers(resolveLaunchDialogMembers(data.members ?? []));
setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath); setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath);
setLaunchDialogOpen(true); setLaunchDialogOpen(true);
} catch (err) { } 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 { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { shortenDisplayPath } from '@renderer/utils/pathDisplay'; import { shortenDisplayPath } from '@renderer/utils/pathDisplay';
import { highlightLines } from '@renderer/utils/syntaxHighlighter'; import { highlightLines } from '@renderer/utils/syntaxHighlighter';
import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react'; import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react';
@ -149,6 +150,7 @@ export const ToolApprovalSheet: React.FC = () => {
teams, teams,
selectedTeamName, selectedTeamName,
selectedTeamData, selectedTeamData,
selectedTeamMembers,
} = useStore( } = useStore(
useShallow((s) => ({ useShallow((s) => ({
pendingApprovals: s.pendingApprovals, pendingApprovals: s.pendingApprovals,
@ -157,6 +159,7 @@ export const ToolApprovalSheet: React.FC = () => {
teams: s.teams, teams: s.teams,
selectedTeamName: s.selectedTeamName, selectedTeamName: s.selectedTeamName,
selectedTeamData: s.selectedTeamData, selectedTeamData: s.selectedTeamData,
selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName),
})) }))
); );
const { isLight } = useTheme(); const { isLight } = useTheme();
@ -273,9 +276,9 @@ export const ToolApprovalSheet: React.FC = () => {
// Resolve teammate color for MemberBadge (when source !== 'lead') // Resolve teammate color for MemberBadge (when source !== 'lead')
const sourceColor = useMemo(() => { const sourceColor = useMemo(() => {
if (!current || current.source === 'lead') return undefined; 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; return member?.color;
}, [current, selectedTeamData?.members]); }, [current, selectedTeamMembers]);
if (!current) return null; if (!current) return null;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,6 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Sheet, type SheetRef } from 'react-modal-sheet'; import { Sheet, type SheetRef } from 'react-modal-sheet';
import { api } from '@renderer/api';
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; 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 { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { useStore } from '@renderer/store'; 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 { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics'; import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
@ -70,8 +69,6 @@ interface MessagesPanelProps {
members: ResolvedTeamMember[]; members: ResolvedTeamMember[];
/** All team tasks. */ /** All team tasks. */
tasks: TeamTaskWithKanban[]; tasks: TeamTaskWithKanban[];
/** All raw messages from team data. */
messages: InboxMessage[];
/** Whether the team is alive. */ /** Whether the team is alive. */
isTeamAlive?: boolean; isTeamAlive?: boolean;
/** Live lead activity status for the current team. */ /** Live lead activity status for the current team. */
@ -109,7 +106,6 @@ export const MessagesPanel = memo(function MessagesPanel({
mountPoint, mountPoint,
members, members,
tasks, tasks,
messages,
isTeamAlive, isTeamAlive,
leadActivity, leadActivity,
leadContextUpdatedAt, leadContextUpdatedAt,
@ -133,6 +129,9 @@ export const MessagesPanel = memo(function MessagesPanel({
lastSendMessageResult, lastSendMessageResult,
teams, teams,
openTeamTab, openTeamTab,
messages,
messagesState,
loadOlderTeamMessages,
} = useStore( } = useStore(
useShallow((s) => ({ useShallow((s) => ({
sendTeamMessage: s.sendTeamMessage, sendTeamMessage: s.sendTeamMessage,
@ -142,79 +141,23 @@ export const MessagesPanel = memo(function MessagesPanel({
lastSendMessageResult: s.lastSendMessageResult, lastSendMessageResult: s.lastSendMessageResult,
teams: s.teams, teams: s.teams,
openTeamTab: s.openTeamTab, 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 () => { const loadOlderMessages = useCallback(async () => {
if (!nextCursor || messagesLoading) return; if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) {
setMessagesLoading(true); return;
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);
} }
}, [teamName, nextCursor, messagesLoading]); await loadOlderTeamMessages(teamName);
}, [loadOlderTeamMessages, messagesState, teamName]);
// Use fetched messages, fall back to prop messages during initial load const messagesLoading =
const effectiveMessages = useMemo(() => { (messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false);
if (fetchedMessages.length === 0) return messages; const hasMore = messagesState?.hasMore ?? false;
return mergeTeamMessages(fetchedMessages, messages); const effectiveMessages = messages;
}, [fetchedMessages, messages]);
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null); const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const sidebarScrollRef = useRef<HTMLDivElement | null>(null); const sidebarScrollRef = useRef<HTMLDivElement | null>(null);

View file

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

View file

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

View file

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

View file

@ -35,7 +35,9 @@ import { createTabSlice } from './slices/tabSlice';
import { createTabUISlice } from './slices/tabUISlice'; import { createTabUISlice } from './slices/tabUISlice';
import { import {
createTeamSlice, createTeamSlice,
getActiveTeamPendingReplyWaits,
getLastResolvedTeamDataRefreshAt, getLastResolvedTeamDataRefreshAt,
hasActiveTeamPendingReplyWait,
isTeamDataRefreshPending, isTeamDataRefreshPending,
selectTeamDataForName, selectTeamDataForName,
} from './slices/teamSlice'; } 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_CHANGE_EVENT_WARN_THROTTLE_MS = 2_000;
const TEAM_VISIBLE_IDLE_WATCHDOG_POLL_MS = 10_000; const TEAM_VISIBLE_IDLE_WATCHDOG_POLL_MS = 10_000;
const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000; const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000;
const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_000;
const CURRENT_APP_VERSION = const CURRENT_APP_VERSION =
typeof __APP_VERSION__ === 'string' ? normalizeVersion(__APP_VERSION__) : '0.0.0'; typeof __APP_VERSION__ === 'string' ? normalizeVersion(__APP_VERSION__) : '0.0.0';
const logger = createLogger('Store:index'); const logger = createLogger('Store:index');
@ -237,10 +240,12 @@ export function initializeNotificationListeners(): () => void {
const teamLastRelevantActivityAt = new Map<string, number>(); const teamLastRelevantActivityAt = new Map<string, number>();
const teamLastIdleWatchdogRefreshAt = new Map<string, number>(); const teamLastIdleWatchdogRefreshAt = new Map<string, number>();
let teamRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>(); 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 teamPresenceRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let memberSpawnRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>(); let memberSpawnRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let toolActivityTimers = new Map<string, ReturnType<typeof setTimeout>>(); let toolActivityTimers = new Map<string, ReturnType<typeof setTimeout>>();
let inProgressChangePresencePollInFlight = false; let inProgressChangePresencePollInFlight = false;
let teamMessageFallbackPollInFlight = false;
const inProgressChangePresenceCursorByTeam = new Map<string, number>(); const inProgressChangePresenceCursorByTeam = new Map<string, number>();
let teamListRefreshTimer: ReturnType<typeof setTimeout> | null = null; 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_MEMBER_SPAWN_REFRESH_THROTTLE_MS = 500;
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000; const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500; 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 => { const scheduleMemberSpawnStatusesRefresh = (teamName: string | null | undefined): void => {
if (!teamName || !isTeamVisibleInAnyPane(teamName)) { if (!teamName || !isTeamVisibleInAnyPane(teamName)) {
return; return;
@ -265,6 +287,19 @@ export function initializeNotificationListeners(): () => void {
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS); }, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
memberSpawnRefreshTimers.set(teamName, timer); 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 = ( const buildToolActivityTimerKey = (
teamName: string, teamName: string,
memberName: string, memberName: string,
@ -587,6 +622,18 @@ export function initializeNotificationListeners(): () => void {
return getVisibleTeamNamesInAnyPane().has(teamName); 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 getTrackedChangePresenceTeams = (): Set<string> => {
const state = useStore.getState(); const state = useStore.getState();
const tracked = new Set<string>(); const tracked = new Set<string>();
@ -627,6 +674,26 @@ export function initializeNotificationListeners(): () => void {
return activeTab.teamName; 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> => { const pollFocusedVisibleTeamIdleWatchdog = async (): Promise<void> => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
return; return;
@ -863,11 +930,18 @@ export function initializeNotificationListeners(): () => void {
cleanupFns.push(() => { cleanupFns.push(() => {
clearInterval(teamIdleWatchdogTimer); clearInterval(teamIdleWatchdogTimer);
}); });
const teamMessageFallbackPollTimer = setInterval(() => {
void pollTrackedTeamMessageFallback();
}, TEAM_MESSAGE_FALLBACK_POLL_MS);
cleanupFns.push(() => {
clearInterval(teamMessageFallbackPollTimer);
});
if (api.teams?.onTeamChange) { if (api.teams?.onTeamChange) {
const cleanup = api.teams.onTeamChange((_event: unknown, event: TeamChangeEvent) => { const cleanup = api.teams.onTeamChange((_event: unknown, event: TeamChangeEvent) => {
const visibleTeam = Boolean(event.teamName) && isTeamVisibleInAnyPane(event.teamName); const messageRefreshRelevant =
noteTeamChangeEventBurst(event.teamName, event.type, visibleTeam); Boolean(event.teamName) && shouldRefreshTeamMessages(event.teamName);
noteTeamChangeEventBurst(event.teamName, event.type, messageRefreshRelevant);
const isIgnoredRuntimeRun = (() => { const isIgnoredRuntimeRun = (() => {
if (!event.runId) return false; if (!event.runId) return false;
@ -924,24 +998,26 @@ export function initializeNotificationListeners(): () => void {
}, },
}; };
const cachedTeamData = prev.teamDataCacheByName[event.teamName]; const baseTeamData =
if (cachedTeamData) { 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 = { nextState.teamDataCacheByName = {
...prev.teamDataCacheByName, ...prev.teamDataCacheByName,
[event.teamName]: { [event.teamName]: nextTeamData,
...cachedTeamData,
isAlive: nextActivity !== 'offline',
},
}; };
} }
// Keep TeamDetailView in sync: it historically relied on selectedTeamData.isAlive, if (prev.selectedTeamName === event.teamName && nextTeamData) {
// which isn't refreshed for lead-activity events. nextState.selectedTeamData = nextTeamData;
if (prev.selectedTeamName === event.teamName && prev.selectedTeamData) {
nextState.selectedTeamData = {
...prev.selectedTeamData,
isAlive: nextActivity !== 'offline',
};
} }
// Clear context data when lead goes offline // Clear context data when lead goes offline
@ -1122,29 +1198,19 @@ export function initializeNotificationListeners(): () => void {
return; return;
} }
if (event.type === 'inbox' || event.type === 'config' || event.type === 'process') { if (event.type === 'inbox') {
scheduleMemberSpawnStatusesRefresh(event.teamName); scheduleTrackedTeamMessageRefresh(event.teamName);
return;
} }
// Live lead-message events: only refresh the visible team detail, not team/task lists. // Live lead-message events refresh only the tracked message feed surface
// This keeps the refresh lightweight and prevents one noisy team from starving another. // (visible team or local pending-reply wait), not the structural snapshot.
if (event.type === 'lead-message') { if (event.type === 'lead-message') {
if (isStaleRuntimeEvent) { if (isStaleRuntimeEvent) {
return; return;
} }
seedCurrentRunIdIfMissing(); seedCurrentRunIdIfMissing();
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) { scheduleTrackedTeamMessageRefresh(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);
return; return;
} }
@ -1205,6 +1271,8 @@ export function initializeNotificationListeners(): () => void {
cleanup(); cleanup();
for (const t of teamRefreshTimers.values()) clearTimeout(t); for (const t of teamRefreshTimers.values()) clearTimeout(t);
teamRefreshTimers = new Map(); teamRefreshTimers = new Map();
for (const t of teamMessageRefreshTimers.values()) clearTimeout(t);
teamMessageRefreshTimers = new Map();
for (const t of teamPresenceRefreshTimers.values()) clearTimeout(t); for (const t of teamPresenceRefreshTimers.values()) clearTimeout(t);
teamPresenceRefreshTimers = new Map(); teamPresenceRefreshTimers = new Map();
for (const t of memberSpawnRefreshTimers.values()) clearTimeout(t); 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 { import type {
MemberSpawnStatusEntry, MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot, MemberSpawnStatusesSnapshot,
ResolvedTeamMember,
TeamProvisioningProgress, TeamProvisioningProgress,
} from '@shared/types'; } from '@shared/types';
@ -17,6 +16,11 @@ type MemberSpawnStatusCollection =
| Map<string, MemberSpawnStatusEntry> | Map<string, MemberSpawnStatusEntry>
| undefined; | undefined;
interface ProvisioningMemberLike {
name: string;
removedAt?: number;
}
const ACTIVE_PROVISIONING_STATES = new Set([ const ACTIVE_PROVISIONING_STATES = new Set([
'validating', 'validating',
'spawning', 'spawning',
@ -66,7 +70,7 @@ export function buildTeamProvisioningPresentation({
memberSpawnSnapshot, memberSpawnSnapshot,
}: { }: {
progress: TeamProvisioningProgress | null | undefined; progress: TeamProvisioningProgress | null | undefined;
members: readonly ResolvedTeamMember[]; members: readonly ProvisioningMemberLike[];
memberSpawnStatuses?: MemberSpawnStatusCollection; memberSpawnStatuses?: MemberSpawnStatusCollection;
memberSpawnSnapshot?: Pick<MemberSpawnStatusesSnapshot, 'expectedMembers' | 'summary'>; memberSpawnSnapshot?: Pick<MemberSpawnStatusesSnapshot, 'expectedMembers' | 'summary'>;
}): TeamProvisioningPresentation | null { }): TeamProvisioningPresentation | null {

View file

@ -53,6 +53,7 @@ import type {
KanbanColumnId, KanbanColumnId,
LeadActivitySnapshot, LeadActivitySnapshot,
LeadContextUsageSnapshot, LeadContextUsageSnapshot,
TeamMemberActivityMeta,
MemberFullStats, MemberFullStats,
MemberLogSummary, MemberLogSummary,
MemberSpawnStatusesSnapshot, MemberSpawnStatusesSnapshot,
@ -71,10 +72,10 @@ import type {
TeamCreateConfigRequest, TeamCreateConfigRequest,
TeamCreateRequest, TeamCreateRequest,
TeamCreateResponse, TeamCreateResponse,
TeamData,
TeamLaunchRequest, TeamLaunchRequest,
TeamLaunchResponse, TeamLaunchResponse,
TeamMessageNotificationData, TeamMessageNotificationData,
TeamViewSnapshot,
TeamProvisioningPrepareResult, TeamProvisioningPrepareResult,
TeamProvisioningProgress, TeamProvisioningProgress,
TeamSummary, TeamSummary,
@ -425,7 +426,7 @@ export interface HttpServerAPI {
export interface TeamsAPI { export interface TeamsAPI {
list: () => Promise<TeamSummary[]>; list: () => Promise<TeamSummary[]>;
getData: (teamName: string) => Promise<TeamData>; getData: (teamName: string) => Promise<TeamViewSnapshot>;
getTaskChangePresence: (teamName: string) => Promise<Record<string, TaskChangePresenceState>>; getTaskChangePresence: (teamName: string) => Promise<Record<string, TaskChangePresenceState>>;
setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise<void>; setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise<void>;
setToolActivityTracking: (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>; sendMessage: (teamName: string, request: SendMessageRequest) => Promise<SendMessageResult>;
getMessagesPage: ( getMessagesPage: (
teamName: string, teamName: string,
options?: { beforeTimestamp?: string; limit?: number } options?: { cursor?: string | null; limit?: number }
) => Promise<MessagesPage>; ) => Promise<MessagesPage>;
getMemberActivityMeta: (teamName: string) => Promise<TeamMemberActivityMeta>;
createTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>; createTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
requestReview: (teamName: string, taskId: string) => Promise<void>; requestReview: (teamName: string, taskId: string) => Promise<void>;
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => 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. */ /** Opaque cursor string for fetching older messages. Null when no more pages. */
nextCursor: string | null; nextCursor: string | null;
hasMore: boolean; 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'; export type AgentActionMode = 'do' | 'ask' | 'delegate';
@ -729,12 +734,44 @@ export interface TeamProcess {
stoppedAt?: string; 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; teamName: string;
config: TeamConfig; config: TeamConfig;
tasks: TeamTaskWithKanban[]; tasks: TeamTaskWithKanban[];
members: ResolvedTeamMember[]; members: TeamMemberSnapshot[];
messages: InboxMessage[];
kanbanState: KanbanState; kanbanState: KanbanState;
processes: TeamProcess[]; processes: TeamProcess[];
warnings?: string[]; warnings?: string[];

View file

@ -12,7 +12,7 @@ import type {
} from '@shared/types/team'; } from '@shared/types/team';
vi.mock('electron', () => ({ 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) }), Notification: Object.assign(vi.fn(), { isSupported: vi.fn(() => false) }),
BrowserWindow: { getAllWindows: vi.fn(() => []) }, BrowserWindow: { getAllWindows: vi.fn(() => []) },
})); }));
@ -34,6 +34,8 @@ const { mockTeamDataWorkerClient } = vi.hoisted(() => ({
mockTeamDataWorkerClient: { mockTeamDataWorkerClient: {
isAvailable: vi.fn(), isAvailable: vi.fn(),
getTeamData: vi.fn(), getTeamData: vi.fn(),
getMessagesPage: vi.fn(),
getMemberActivityMeta: vi.fn(),
findLogsForTask: vi.fn(), findLogsForTask: vi.fn(),
}, },
})); }));
@ -62,6 +64,8 @@ import {
TEAM_CREATE_TASK, TEAM_CREATE_TASK,
TEAM_DELETE_TEAM, TEAM_DELETE_TEAM,
TEAM_GET_DATA, TEAM_GET_DATA,
TEAM_GET_MEMBER_ACTIVITY_META,
TEAM_GET_MESSAGES_PAGE,
TEAM_LAUNCH, TEAM_LAUNCH,
TEAM_LIST, TEAM_LIST,
TEAM_PREPARE_PROVISIONING, TEAM_PREPARE_PROVISIONING,
@ -135,13 +139,33 @@ describe('ipc teams handlers', () => {
config: { name: 'My Team' }, config: { name: 'My Team' },
tasks: [], tasks: [],
members: [], members: [],
messages: [] as InboxMessage[],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [], 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' })), getTaskChangePresence: vi.fn(async () => ({ 'task-1': 'has_changes' })),
reconcileTeamArtifacts: vi.fn(async () => undefined), reconcileTeamArtifacts: vi.fn(async () => undefined),
setTaskChangePresenceTracking: vi.fn(() => undefined), setTaskChangePresenceTracking: vi.fn(() => undefined),
getTeamNotificationContext: vi.fn(async () => ({
displayName: 'My Team',
projectPath: '/tmp/project',
})),
deleteTeam: vi.fn(async () => undefined), deleteTeam: vi.fn(async () => undefined),
getLeadMemberName: vi.fn(async () => 'team-lead'), getLeadMemberName: vi.fn(async () => 'team-lead'),
getTeamDisplayName: vi.fn(async () => 'My Team'), getTeamDisplayName: vi.fn(async () => 'My Team'),
@ -231,6 +255,8 @@ describe('ipc teams handlers', () => {
mockGetMembersMeta.mockResolvedValue([]); mockGetMembersMeta.mockResolvedValue([]);
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false); mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
mockTeamDataWorkerClient.getTeamData.mockReset(); mockTeamDataWorkerClient.getTeamData.mockReset();
mockTeamDataWorkerClient.getMessagesPage.mockReset();
mockTeamDataWorkerClient.getMemberActivityMeta.mockReset();
mockTeamDataWorkerClient.findLogsForTask.mockReset(); mockTeamDataWorkerClient.findLogsForTask.mockReset();
initializeTeamHandlers( initializeTeamHandlers(
service as never, service as never,
@ -252,6 +278,8 @@ describe('ipc teams handlers', () => {
it('registers all expected handlers', () => { it('registers all expected handlers', () => {
expect(handlers.has(TEAM_LIST)).toBe(true); expect(handlers.has(TEAM_LIST)).toBe(true);
expect(handlers.has(TEAM_GET_DATA)).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_GET_TASK_CHANGE_PRESENCE)).toBe(true);
expect(handlers.has(TEAM_SET_CHANGE_PRESENCE_TRACKING)).toBe(true); expect(handlers.has(TEAM_SET_CHANGE_PRESENCE_TRACKING)).toBe(true);
expect(handlers.has(TEAM_DELETE_TEAM)).toBe(true); expect(handlers.has(TEAM_DELETE_TEAM)).toBe(true);
@ -580,7 +608,6 @@ describe('ipc teams handlers', () => {
config: { name: 'My Team' }, config: { name: 'My Team' },
tasks: [], tasks: [],
members: [], members: [],
messages: [] as InboxMessage[],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [], processes: [],
}); });
@ -759,24 +786,7 @@ describe('ipc teams handlers', () => {
}); });
}); });
it('dedups live lead replies when lead_session already has same text', async () => { it('keeps TEAM_GET_DATA structural and does not expose message transport', 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: [],
});
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([ provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
{ {
from: 'team-lead', from: 'team-lead',
@ -791,59 +801,159 @@ describe('ipc teams handlers', () => {
const getDataHandler = handlers.get(TEAM_GET_DATA)!; const getDataHandler = handlers.get(TEAM_GET_DATA)!;
const result = (await getDataHandler({} as never, 'my-team')) as { const result = (await getDataHandler({} as never, 'my-team')) as {
success: boolean; success: boolean;
data: { messages: { source?: string }[] }; data: Record<string, unknown>;
}; };
expect(result.success).toBe(true); expect(result.success).toBe(true);
const sources = result.data.messages.map((m) => m.source); expect(result.data.teamName).toBe('my-team');
expect(sources.filter((s) => s === 'lead_process')).toHaveLength(0); expect(result.data).not.toHaveProperty('messages');
expect(sources.filter((s) => s === 'lead_session')).toHaveLength(1); expect(service.getMessageFeed).not.toHaveBeenCalled();
}); });
it('merges early live messages before durable lead_session backfill exists', async () => { it('rejects TEAM_GET_DATA fallback in packaged runtime when worker is unavailable', async () => {
// Simulate: team just became readable but lead_session JSONL hasn't been written yet. const electron = await import('electron');
// Only live in-memory messages exist from the provisioning process. mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
service.getTeamData.mockResolvedValueOnce({ (electron.app as { isPackaged: boolean }).isPackaged = true;
teamName: 'my-team',
config: { name: 'My Team' }, const handler = handlers.get(TEAM_GET_DATA)!;
tasks: [], const result = (await handler({} as never, 'my-team')) as { success: boolean; error?: string };
members: [],
messages: [], // No durable messages yet expect(result.success).toBe(false);
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, expect(result.error).toContain('TEAM_DATA_WORKER_UNAVAILABLE');
processes: [], expect(service.getTeamData).not.toHaveBeenCalled();
}); vi.mocked(console.error).mockClear();
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
{ (electron.app as { isPackaged: boolean }).isPackaged = false;
from: 'team-lead', });
text: 'Команда создана. Запускаю тиммейтов.',
timestamp: '2026-02-23T10:00:00.000Z', it('uses the team-data worker for TEAM_GET_MESSAGES_PAGE when available', async () => {
read: true, mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
source: 'lead_process' as const, mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({
messageId: 'lead-turn-run-1-1', messages: [
}, {
{ from: 'team-lead',
from: 'team-lead', text: 'Hello there',
text: 'All teammates online!', timestamp: '2026-02-23T10:00:01.000Z',
timestamp: '2026-02-23T10:00:01.000Z', read: true,
read: true, source: 'lead_session' as const,
source: 'lead_process' as const, messageId: 'msg-1',
messageId: 'lead-turn-run-1-2', },
to: 'user', ],
}, 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); expect(result.success).toBe(true);
// Both live messages should appear since there's no durable backfill yet expect(result.data.feedRevision).toBe('rev-worker');
// Sorted by timestamp descending (newest first) expect(mockTeamDataWorkerClient.getMessagesPage).toHaveBeenCalledWith('my-team', {
expect(result.data.messages).toHaveLength(2); cursor: undefined,
expect(result.data.messages[0].source).toBe('lead_process'); limit: 50,
expect(result.data.messages[0].text).toBe('All teammates online!'); });
expect(result.data.messages[1].source).toBe('lead_process'); expect(service.getMessagesPage).not.toHaveBeenCalled();
expect(result.data.messages[1].text).toBe('Команда создана. Запускаю тиммейтов.'); });
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 () => { 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 { import type {
InboxMessage, InboxMessage,
KanbanState, KanbanState,
ResolvedTeamMember,
TeamConfig, TeamConfig,
TeamData, TeamProcess,
TeamTask, TeamTask,
TeamTaskWithKanban, TeamTaskWithKanban,
} from '../../../../src/shared/types/team'; } from '../../../../src/shared/types/team';
@ -240,10 +241,9 @@ function createGetTeamDataHarness(options: {
config: TeamConfig, config: TeamConfig,
metaMembers: TeamConfig['members'], metaMembers: TeamConfig['members'],
inboxNames: string[], inboxNames: string[],
tasks: TeamTaskWithKanban[], tasks: TeamTaskWithKanban[]
messages: InboxMessage[] ) => ResolvedTeamMember[];
) => TeamData['members']; listProcesses?: () => TeamProcess[];
listProcesses?: () => TeamData['processes'];
getMemberAdvisories?: () => Promise<Map<string, unknown>>; getMemberAdvisories?: () => Promise<Map<string, unknown>>;
} = {}) { } = {}) {
const getConfig = vi.fn(async () => 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 { return {
name, name,
status: 'unknown', 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 () => { it('creates task with status pending when startImmediately is false', async () => {
const createTaskMock = vi.fn((task) => ({ ...task, status: 'pending' })); const createTaskMock = vi.fn((task) => ({ ...task, status: 'pending' }));
const service = new TeamDataService( const service = new TeamDataService(
@ -2437,8 +2470,8 @@ describe('TeamDataService', () => {
} as never } as never
); );
const data = await service.getTeamData('my-team'); const feed = await service.getMessageFeed('my-team');
const costResult = data.messages.find((message) => message.messageId === 'lead-thought-1'); const costResult = feed.messages.find((message) => message.messageId === 'lead-thought-1');
expect(costResult).toMatchObject({ expect(costResult).toMatchObject({
messageKind: 'slash_command_result', messageKind: 'slash_command_result',
@ -2507,8 +2540,8 @@ describe('TeamDataService', () => {
} as never } as never
); );
const data = await service.getTeamData('my-team'); const feed = await service.getMessageFeed('my-team');
const result = data.messages.find((message) => message.messageId === 'passive-idle-dup-1'); const result = feed.messages.find((message) => message.messageId === 'passive-idle-dup-1');
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(result?.source).not.toBe('lead_process'); expect(result?.source).not.toBe('lead_process');
@ -2582,8 +2615,8 @@ describe('TeamDataService', () => {
sentMessages: [userReplyRow], sentMessages: [userReplyRow],
}); });
const data = await service.getTeamData('my-team'); const feed = await service.getMessageFeed('my-team');
const linked = data.messages.find((message) => message.messageId === 'passive-user-summary-1'); const linked = feed.messages.find((message) => message.messageId === 'passive-user-summary-1');
expect(linked?.relayOfMessageId).toBe('user-reply-1'); expect(linked?.relayOfMessageId).toBe('user-reply-1');
expect(passiveSummaryRow.relayOfMessageId).toBeUndefined(); expect(passiveSummaryRow.relayOfMessageId).toBeUndefined();
@ -2618,8 +2651,8 @@ describe('TeamDataService', () => {
], ],
}); });
const data = await service.getTeamData('my-team'); const feed = await service.getMessageFeed('my-team');
const linked = data.messages.find( const linked = feed.messages.find(
(message) => message.messageId === 'passive-user-summary-contains-1' (message) => message.messageId === 'passive-user-summary-contains-1'
); );
@ -2655,8 +2688,8 @@ describe('TeamDataService', () => {
], ],
}); });
const data = await service.getTeamData('my-team'); const feed = await service.getMessageFeed('my-team');
const linked = data.messages.find((message) => message.messageId === 'passive-user-summary-old-1'); const linked = feed.messages.find((message) => message.messageId === 'passive-user-summary-old-1');
expect(linked?.relayOfMessageId).toBeUndefined(); expect(linked?.relayOfMessageId).toBeUndefined();
}); });
@ -2690,8 +2723,8 @@ describe('TeamDataService', () => {
], ],
}); });
const data = await service.getTeamData('my-team'); const feed = await service.getMessageFeed('my-team');
const linked = data.messages.find((message) => message.messageId === 'passive-bob-summary-1'); const linked = feed.messages.find((message) => message.messageId === 'passive-bob-summary-1');
expect(linked?.relayOfMessageId).toBeUndefined(); expect(linked?.relayOfMessageId).toBeUndefined();
}); });
@ -2725,8 +2758,8 @@ describe('TeamDataService', () => {
], ],
}); });
const data = await service.getTeamData('my-team'); const feed = await service.getMessageFeed('my-team');
const linked = data.messages.find( const linked = feed.messages.find(
(message) => message.messageId === 'passive-user-summary-sender-1' (message) => message.messageId === 'passive-user-summary-sender-1'
); );
@ -2772,8 +2805,8 @@ describe('TeamDataService', () => {
], ],
}); });
const data = await service.getTeamData('my-team'); const feed = await service.getMessageFeed('my-team');
const linked = data.messages.find( const linked = feed.messages.find(
(message) => message.messageId === 'passive-user-summary-ambiguous-1' (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 () => { it('starts light reads immediately, bounds heavy reads, and keeps processes outside the parallel phase', async () => {
const order: string[] = []; const order: string[] = [];
const tasksDeferred = createDeferred<TeamTask[]>(); const tasksDeferred = createDeferred<TeamTask[]>();
const messagesDeferred = createDeferred<InboxMessage[]>();
const leadTextsDeferred = createDeferred<InboxMessage[]>();
const harness = createGetTeamDataHarness({ const harness = createGetTeamDataHarness({
getTasks: async () => { getTasks: async () => {
@ -3293,10 +3324,6 @@ describe('TeamDataService', () => {
order.push('inboxNames:start'); order.push('inboxNames:start');
return []; return [];
}, },
getMessages: async () => {
order.push('messages:start');
return messagesDeferred.promise;
},
getMembers: async () => { getMembers: async () => {
order.push('meta:start'); order.push('meta:start');
return []; return [];
@ -3305,10 +3332,6 @@ describe('TeamDataService', () => {
order.push('kanban:start'); order.push('kanban:start');
return { teamName: 'my-team', reviewers: [], tasks: {} }; return { teamName: 'my-team', reviewers: [], tasks: {} };
}, },
readMessages: async () => {
order.push('sent:start');
return [];
},
resolveMembers: () => { resolveMembers: () => {
order.push('resolveMembers'); order.push('resolveMembers');
return []; 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'); const pending = harness.service.getTeamData('my-team');
await flushMicrotasks(); await flushMicrotasks();
expect(order).toEqual( expect(order).toEqual(
expect.arrayContaining([ expect.arrayContaining([
'inboxNames:start', 'inboxNames:start',
'sent:start',
'meta:start', 'meta:start',
'kanban:start', 'kanban:start',
'tasks:start', 'tasks:start',
'messages:start',
]) ])
); );
expect(order).not.toContain('leadTexts:start');
expect(order).not.toContain('processes:start'); expect(order).not.toContain('processes:start');
expect(order).not.toContain('leadTexts:start');
tasksDeferred.resolve([]); 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; const data = await pending;
@ -3372,7 +3377,7 @@ describe('TeamDataService', () => {
pid: 101, 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')); 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 () => { it('keeps warning order deterministic even when read failures settle out of order', async () => {
const tasksDeferred = createDeferred<TeamTask[]>(); const tasksDeferred = createDeferred<TeamTask[]>();
const inboxDeferred = createDeferred<string[]>(); const inboxDeferred = createDeferred<string[]>();
const messagesDeferred = createDeferred<InboxMessage[]>();
const leadTextsDeferred = createDeferred<InboxMessage[]>();
const sentDeferred = createDeferred<InboxMessage[]>();
const metaDeferred = createDeferred<TeamConfig['members']>(); const metaDeferred = createDeferred<TeamConfig['members']>();
const kanbanDeferred = createDeferred<KanbanState>(); const kanbanDeferred = createDeferred<KanbanState>();
const harness = createGetTeamDataHarness({ const harness = createGetTeamDataHarness({
getTasks: async () => tasksDeferred.promise, getTasks: async () => tasksDeferred.promise,
listInboxNames: async () => inboxDeferred.promise, listInboxNames: async () => inboxDeferred.promise,
getMessages: async () => messagesDeferred.promise,
getMembers: async () => metaDeferred.promise, getMembers: async () => metaDeferred.promise,
getState: async () => kanbanDeferred.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'); const pending = harness.service.getTeamData('my-team');
await flushMicrotasks(); await flushMicrotasks();
sentDeferred.reject(new Error('sent failed'));
kanbanDeferred.reject(new Error('kanban failed')); kanbanDeferred.reject(new Error('kanban failed'));
tasksDeferred.reject(new Error('tasks failed')); tasksDeferred.reject(new Error('tasks failed'));
metaDeferred.reject(new Error('meta failed')); metaDeferred.reject(new Error('meta failed'));
inboxDeferred.reject(new Error('inbox failed')); inboxDeferred.reject(new Error('inbox failed'));
leadTextsDeferred.reject(new Error('lead failed'));
messagesDeferred.reject(new Error('messages failed'));
const data = await pending; const data = await pending;
expect(data.warnings).toEqual([ expect(data.warnings).toEqual([
'Tasks failed to load', 'Tasks failed to load',
'Inboxes 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', 'Member metadata failed to load',
'Kanban state 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 () => { 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 data = await harness.service.getTeamData('my-team');
const feed = await harness.service.getMessageFeed('my-team');
expect(data.warnings).toEqual( expect(data.warnings).toEqual(expect.arrayContaining(['Kanban state failed to load']));
expect.arrayContaining(['Messages failed to load', 'Kanban state failed to load']) expect(feed.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1']);
);
expect(data.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1']);
expect(resolveMembersSpy).toHaveBeenCalledWith( expect(resolveMembersSpy).toHaveBeenCalledWith(
buildDefaultTeamConfig(), buildDefaultTeamConfig(),
metaMembers, metaMembers,
@ -3566,10 +3587,6 @@ describe('TeamDataService', () => {
id: 'task-1', id: 'task-1',
subject: 'Investigate rollout', 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 () => { it('degrades a queued heavy sync throw to warning and still completes the snapshot', async () => {
const order: string[] = []; const order: string[] = [];
const tasksDeferred = createDeferred<TeamTask[]>(); const tasksDeferred = createDeferred<TeamTask[]>();
const messagesDeferred = createDeferred<InboxMessage[]>();
const harness = createGetTeamDataHarness({ const harness = createGetTeamDataHarness({
getTasks: async () => { getTasks: async () => {
order.push('tasks:start'); order.push('tasks:start');
return tasksDeferred.promise; return tasksDeferred.promise;
}, },
getMessages: async () => {
order.push('messages:start');
return messagesDeferred.promise;
},
listProcesses: () => { listProcesses: () => {
order.push('processes:start'); order.push('processes:start');
return []; return [];
@ -3635,14 +3647,9 @@ describe('TeamDataService', () => {
expect(order).not.toContain('leadTexts:start'); expect(order).not.toContain('leadTexts:start');
tasksDeferred.resolve([]); tasksDeferred.resolve([]);
await flushMicrotasks();
expect(order).toContain('leadTexts:start');
messagesDeferred.resolve([]);
const data = await pending; 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'); expect(order).toContain('processes:start');
}); });
@ -3780,7 +3787,7 @@ describe('TeamDataService', () => {
expect(page1.hasMore).toBe(true); expect(page1.hasMore).toBe(true);
const page2 = await service.getMessagesPage('my-team', { const page2 = await service.getMessagesPage('my-team', {
beforeTimestamp: page1.nextCursor!, cursor: page1.nextCursor!,
limit: 10, limit: 10,
}); });
// Should get the remaining 2 messages, not lose the one with same timestamp // 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'); const result = page.messages.find((m) => m.messageId === 'resp1');
expect(result?.messageKind).toBe('slash_command_result'); 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 { TeamMemberResolver } from '../../../../src/main/services/team/TeamMemberResolver';
import type { import type {
InboxMessage,
TeamConfig, TeamConfig,
TeamTask, TeamTask,
TeamTaskWithKanban, TeamTaskWithKanban,
@ -24,13 +23,8 @@ describe('TeamMemberResolver', () => {
{ id: '1', subject: 'Visible task', status: 'pending', owner: 'alice' }, { id: '1', subject: 'Visible task', status: 'pending', owner: 'alice' },
{ id: '2', subject: 'Ghost task', status: 'pending', owner: 'stranger' }, { 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); const names = members.map((member) => member.name);
expect(names).toHaveLength(3); expect(names).toHaveLength(3);
@ -62,9 +56,8 @@ describe('TeamMemberResolver', () => {
]; ];
const inboxNames = ['user', 'alice']; const inboxNames = ['user', 'alice'];
const tasks: TeamTask[] = []; 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); const names = members.map((m) => m.name);
expect(names).not.toContain('user'); expect(names).not.toContain('user');
@ -81,9 +74,8 @@ describe('TeamMemberResolver', () => {
const metaMembers: TeamConfig['members'] = [{ name: 'alice', agentType: 'general-purpose' }]; const metaMembers: TeamConfig['members'] = [{ name: 'alice', agentType: 'general-purpose' }];
const inboxNames = ['alice', 'team-best.user', 'dream-team.team-lead']; const inboxNames = ['alice', 'team-best.user', 'dream-team.team-lead'];
const tasks: TeamTask[] = []; 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); const names = members.map((m) => m.name);
expect(names).toContain('alice'); expect(names).toContain('alice');
@ -104,7 +96,7 @@ describe('TeamMemberResolver', () => {
]; ];
const inboxNames = ['a3975f80d37fbcea1', 'alice', 'a68a8f6a643e59bfd']; 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); const names = members.map((m) => m.name);
expect(names).toContain('alice'); 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); const names = members.map((m) => m.name);
expect(names).toContain('ops.bot'); expect(names).toContain('ops.bot');
@ -141,7 +133,6 @@ describe('TeamMemberResolver', () => {
config, config,
[], [],
['cross-team:team-alpha-super', 'cross-team-team-alpha-super', 'alice'], ['cross-team:team-alpha-super', 'cross-team-team-alpha-super', 'alice'],
[],
[] []
); );
const names = members.map((m) => m.name); const names = members.map((m) => m.name);
@ -163,7 +154,6 @@ describe('TeamMemberResolver', () => {
config, config,
[], [],
['cross_team_send', 'cross_team_list_targets', 'alice'], ['cross_team_send', 'cross_team_list_targets', 'alice'],
[],
[] []
); );
const names = members.map((m) => m.name); const names = members.map((m) => m.name);
@ -185,7 +175,6 @@ describe('TeamMemberResolver', () => {
config, config,
[], [],
['cross_team::team-alpha-super', 'cross_team--team-alpha-super', 'alice'], ['cross_team::team-alpha-super', 'cross_team--team-alpha-super', 'alice'],
[],
[] []
); );
const names = members.map((m) => m.name); 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); const names = members.map((m) => m.name);
expect(names).toContain('Ops.Bot'); expect(names).toContain('Ops.Bot');
@ -222,7 +211,7 @@ describe('TeamMemberResolver', () => {
const tasks: TeamTaskWithKanban[] = [ const tasks: TeamTaskWithKanban[] = [
{ id: 't1', subject: 'Work', status: 'in_progress', owner: 'bob' }, { 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'); const bob = members.find((m) => m.name === 'bob');
expect(bob?.currentTaskId).toBe('t1'); expect(bob?.currentTaskId).toBe('t1');
}); });
@ -243,7 +232,7 @@ describe('TeamMemberResolver', () => {
kanbanColumn: 'approved', kanbanColumn: 'approved',
}, },
]; ];
const members = resolver.resolveMembers(config, [], [], tasks, []); const members = resolver.resolveMembers(config, [], [], tasks);
const bob = members.find((m) => m.name === 'bob'); const bob = members.find((m) => m.name === 'bob');
expect(bob?.currentTaskId).toBeNull(); expect(bob?.currentTaskId).toBeNull();
}); });
@ -264,7 +253,7 @@ describe('TeamMemberResolver', () => {
// kanbanColumn not set — stale data scenario // 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'); const bob = members.find((m) => m.name === 'bob');
expect(bob?.currentTaskId).toBeNull(); expect(bob?.currentTaskId).toBeNull();
}); });
@ -281,7 +270,7 @@ describe('TeamMemberResolver', () => {
// Teammates sometimes send messages to "lead" instead of "team-lead", // Teammates sometimes send messages to "lead" instead of "team-lead",
// creating a separate inbox file that the resolver picks up. // creating a separate inbox file that the resolver picks up.
const inboxNames = ['team-lead', 'lead', 'alice']; 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); const names = members.map((m) => m.name);
expect(names).toContain('team-lead'); expect(names).toContain('team-lead');
@ -295,7 +284,7 @@ describe('TeamMemberResolver', () => {
name: 'Team', name: 'Team',
members: [{ name: 'lead', agentType: 'team-lead', role: 'lead' }], 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); const names = members.map((m) => m.name);
expect(names).toContain('lead'); expect(names).toContain('lead');
@ -310,7 +299,7 @@ describe('TeamMemberResolver', () => {
const tasks: TeamTaskWithKanban[] = [ const tasks: TeamTaskWithKanban[] = [
{ id: 't1', subject: 'Work', status: 'completed', owner: 'bob' }, { 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'); const bob = members.find((m) => m.name === 'bob');
expect(bob?.currentTaskId).toBeNull(); expect(bob?.currentTaskId).toBeNull();
}); });

View file

@ -39,6 +39,16 @@ vi.mock('@renderer/store/slices/teamSlice', () => ({
selectTeamDataForName: (_state: typeof storeState, teamName: string) => selectTeamDataForName: (_state: typeof storeState, teamName: string) =>
storeState.teamDataCacheByName[teamName] ?? storeState.teamDataCacheByName[teamName] ??
(storeState.selectedTeamName === teamName ? storeState.selectedTeamData : null), (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', () => ({ vi.mock('zustand/react/shallow', () => ({

View file

@ -61,6 +61,16 @@ vi.mock('@renderer/store', () => ({
vi.mock('@renderer/store/slices/teamSlice', () => ({ vi.mock('@renderer/store/slices/teamSlice', () => ({
getCurrentProvisioningProgressForTeam: () => storeState.progress, 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', () => ({ 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MemberMessagesTab } from '@renderer/components/team/members/MemberMessagesTab'; import { MemberMessagesTab } from '@renderer/components/team/members/MemberMessagesTab';
import { useStore } from '@renderer/store';
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
@ -48,11 +49,27 @@ describe('MemberMessagesTab', () => {
nextCursor: null, nextCursor: null,
hasMore: false, 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(() => { afterEach(() => {
document.body.innerHTML = ''; document.body.innerHTML = '';
getMessagesPage.mockReset(); getMessagesPage.mockReset();
useStore.setState({ teamMessagesByName: {} } as never);
}); });
it('shows both messages and comments by default and filters them separately', async () => { it('shows both messages and comments by default and filters them separately', async () => {
@ -110,10 +127,25 @@ describe('MemberMessagesTab', () => {
document.body.appendChild(host); document.body.appendChild(host);
const root = createRoot(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 () => { await act(async () => {
root.render( root.render(
React.createElement(MemberMessagesTab, { React.createElement(MemberMessagesTab, {
messages,
teamName: 'demo-team', teamName: 'demo-team',
memberName: 'jack', memberName: 'jack',
members, members,
@ -123,6 +155,8 @@ describe('MemberMessagesTab', () => {
await Promise.resolve(); await Promise.resolve();
}); });
expect(getMessagesPage).not.toHaveBeenCalled();
const getRenderedKinds = () => const getRenderedKinds = () =>
Array.from(host.querySelectorAll('[data-testid="activity-item"]')).map((node) => Array.from(host.querySelectorAll('[data-testid="activity-item"]')).map((node) =>
node.getAttribute('data-kind') node.getAttribute('data-kind')
@ -209,10 +243,35 @@ describe('MemberMessagesTab', () => {
document.body.appendChild(host); document.body.appendChild(host);
const root = createRoot(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 () => { await act(async () => {
root.render( root.render(
React.createElement(MemberMessagesTab, { React.createElement(MemberMessagesTab, {
messages: [],
teamName: 'demo-team', teamName: 'demo-team',
memberName: 'jack', memberName: 'jack',
members, members,
@ -222,6 +281,7 @@ describe('MemberMessagesTab', () => {
await Promise.resolve(); await Promise.resolve();
}); });
expect(getMessagesPage).not.toHaveBeenCalled();
expect(host.textContent).toContain('No activity with this member'); expect(host.textContent).toContain('No activity with this member');
expect(host.textContent).not.toContain('Load older messages'); expect(host.textContent).not.toContain('Load older messages');

View file

@ -12,6 +12,21 @@ const storeState = {
lastSendMessageResult: null, lastSendMessageResult: null,
teams: [], teams: [],
openTeamTab: vi.fn(), 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 = { const readHookState = {
@ -146,6 +161,8 @@ describe('MessagesPanel idle summary invariants', () => {
storeState.sendTeamMessage.mockClear(); storeState.sendTeamMessage.mockClear();
storeState.sendCrossTeamMessage.mockClear(); storeState.sendCrossTeamMessage.mockClear();
storeState.openTeamTab.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 () => { 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 () => { 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( root.render(
React.createElement(MessagesPanel, { React.createElement(MessagesPanel, {
teamName: 'atlas-hq', teamName: 'atlas-hq',
@ -182,7 +210,6 @@ describe('MessagesPanel idle summary invariants', () => {
onPositionChange: vi.fn(), onPositionChange: vi.fn(),
members: [], members: [],
tasks: [], tasks: [],
messages,
timeWindow: null, timeWindow: null,
teamSessionIds: new Set<string>(), teamSessionIds: new Set<string>(),
pendingRepliesByMember: {}, pendingRepliesByMember: {},
@ -226,6 +253,17 @@ describe('MessagesPanel idle summary invariants', () => {
]; ];
await act(async () => { 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( root.render(
React.createElement(MessagesPanel, { React.createElement(MessagesPanel, {
teamName: 'atlas-hq', teamName: 'atlas-hq',
@ -233,7 +271,6 @@ describe('MessagesPanel idle summary invariants', () => {
onPositionChange: vi.fn(), onPositionChange: vi.fn(),
members: [], members: [],
tasks: [], tasks: [],
messages,
timeWindow: null, timeWindow: null,
teamSessionIds: new Set<string>(), teamSessionIds: new Set<string>(),
pendingRepliesByMember: { alice: pendingSentAtMs }, pendingRepliesByMember: { alice: pendingSentAtMs },
@ -260,6 +297,17 @@ describe('MessagesPanel idle summary invariants', () => {
const root = createRoot(host); const root = createRoot(host);
await act(async () => { 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( root.render(
React.createElement(MessagesPanel, { React.createElement(MessagesPanel, {
teamName: 'atlas-hq', teamName: 'atlas-hq',
@ -268,7 +316,6 @@ describe('MessagesPanel idle summary invariants', () => {
onPositionChange: vi.fn(), onPositionChange: vi.fn(),
members: [], members: [],
tasks: [], tasks: [],
messages: [makeMessage()],
timeWindow: null, timeWindow: null,
teamSessionIds: new Set<string>(), teamSessionIds: new Set<string>(),
pendingRepliesByMember: {}, pendingRepliesByMember: {},

View file

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

View file

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

View file

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

View file

@ -62,6 +62,7 @@ vi.mock('@renderer/api', () => ({
})); }));
import { initializeNotificationListeners, useStore } from '../../../src/renderer/store'; import { initializeNotificationListeners, useStore } from '../../../src/renderer/store';
import { __resetTeamSliceModuleStateForTests } from '../../../src/renderer/store/slices/teamSlice';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
describe('team change throttling', () => { describe('team change throttling', () => {
@ -69,17 +70,29 @@ describe('team change throttling', () => {
beforeEach(async () => { beforeEach(async () => {
vi.useFakeTimers(); vi.useFakeTimers();
__resetTeamSliceModuleStateForTests();
const fetchTeams = vi.fn(async () => undefined); const fetchTeams = vi.fn(async () => undefined);
const fetchMemberSpawnStatuses = vi.fn(async () => undefined);
const refreshTeamData = 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); const refreshTeamChangePresence = vi.fn(async () => undefined);
useStore.setState({ useStore.setState({
fetchTeams, fetchTeams,
fetchMemberSpawnStatuses,
refreshTeamData, refreshTeamData,
refreshTeamMessagesHead,
refreshMemberActivityMeta,
refreshTeamChangePresence, refreshTeamChangePresence,
selectedTeamName: null, selectedTeamName: null,
selectedTeamData: null, selectedTeamData: null,
teamDataCacheByName: {}, teamDataCacheByName: {},
memberActivityMetaByTeam: {},
paneLayout: { paneLayout: {
focusedPaneId: 'p1', focusedPaneId: 'p1',
panes: [ panes: [
@ -103,6 +116,7 @@ describe('team change throttling', () => {
afterEach(() => { afterEach(() => {
cleanup?.(); cleanup?.();
cleanup = null; cleanup = null;
__resetTeamSliceModuleStateForTests();
vi.mocked(console.warn).mockClear(); vi.mocked(console.warn).mockClear();
vi.useRealTimers(); vi.useRealTimers();
}); });
@ -149,10 +163,12 @@ describe('team change throttling', () => {
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2); 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 state = useStore.getState();
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams'); const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
const refreshTeamMessagesHeadSpy = vi.spyOn(state, 'refreshTeamMessagesHead');
const refreshMemberActivityMetaSpy = vi.spyOn(state, 'refreshMemberActivityMeta');
// Emit a lead-message event // Emit a lead-message event
hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' }); hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' });
@ -161,9 +177,11 @@ describe('team change throttling', () => {
await vi.advanceTimersByTimeAsync(2100); await vi.advanceTimersByTimeAsync(2100);
expect(fetchTeamsSpy).not.toHaveBeenCalled(); expect(fetchTeamsSpy).not.toHaveBeenCalled();
// Should trigger refreshTeamData at 800ms expect(refreshTeamDataSpy).not.toHaveBeenCalled();
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1); expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledTimes(1);
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); 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 () => { 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' }, config: { name: 'Other Team', members: [], projectPath: '/repo' },
tasks: [], tasks: [],
members: [], members: [],
messages: [],
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
processes: [], processes: [],
}, },
@ -192,11 +209,88 @@ describe('team change throttling', () => {
} as never); } as never);
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData'); const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead');
hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' }); hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' });
await vi.advanceTimersByTimeAsync(800); 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 () => { it('lead-message does not call fetchAllTasks', async () => {
@ -209,6 +303,17 @@ describe('team change throttling', () => {
expect(fetchAllTasksSpy).not.toHaveBeenCalled(); 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 () => { it('log-source-change refreshes only task change presence', async () => {
useStore.setState({ useStore.setState({
selectedTeamName: 'my-team', selectedTeamName: 'my-team',
@ -217,7 +322,6 @@ describe('team change throttling', () => {
config: { name: 'My Team', members: [], projectPath: '/repo' }, config: { name: 'My Team', members: [], projectPath: '/repo' },
tasks: [], tasks: [],
members: [], members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [], processes: [],
}, },
@ -248,7 +352,6 @@ describe('team change throttling', () => {
config: { name: 'Other Team', members: [], projectPath: '/repo' }, config: { name: 'Other Team', members: [], projectPath: '/repo' },
tasks: [], tasks: [],
members: [], members: [],
messages: [],
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
processes: [], processes: [],
}, },
@ -258,7 +361,6 @@ describe('team change throttling', () => {
config: { name: 'My Team', members: [], projectPath: '/repo' }, config: { name: 'My Team', members: [], projectPath: '/repo' },
tasks: [], tasks: [],
members: [], members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [], processes: [],
}, },
@ -318,7 +420,6 @@ describe('team change throttling', () => {
}, },
], ],
members: [], members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [], processes: [],
}, },
@ -354,7 +455,6 @@ describe('team change throttling', () => {
config: { name: 'Other Team', members: [], projectPath: '/repo' }, config: { name: 'Other Team', members: [], projectPath: '/repo' },
tasks: [], tasks: [],
members: [], members: [],
messages: [],
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
processes: [], processes: [],
}, },
@ -387,7 +487,6 @@ describe('team change throttling', () => {
}, },
], ],
members: [], members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [], processes: [],
}, },
@ -448,6 +547,7 @@ describe('team change throttling', () => {
const state = useStore.getState(); const state = useStore.getState();
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
const refreshTeamMessagesHeadSpy = vi.spyOn(state, 'refreshTeamMessagesHead');
// Fire rapid events for my-team (throttled) // Fire rapid events for my-team (throttled)
hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' }); hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' });
@ -459,9 +559,10 @@ describe('team change throttling', () => {
await vi.advanceTimersByTimeAsync(800); await vi.advanceTimersByTimeAsync(800);
// Both teams should get exactly 1 refresh each // Both teams should get exactly 1 refresh each
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2); expect(refreshTeamDataSpy).not.toHaveBeenCalled();
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledTimes(2);
expect(refreshTeamDataSpy).toHaveBeenCalledWith('other-team', { withDedup: true }); 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 () => { 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' }, config: { name: 'My Team', members: [], projectPath: '/repo' },
tasks: [], tasks: [],
members: [], members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [], processes: [],
}, },

File diff suppressed because it is too large Load diff

View file

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