refactor(team): split team detail snapshot from messages activity
This commit is contained in:
parent
2cfbfef3b3
commit
1173a4942a
53 changed files with 6990 additions and 1219 deletions
3221
docs/research/team-detail-snapshot-messages-activity-plan.md
Normal file
3221
docs/research/team-detail-snapshot-messages-activity-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* TeamGraphAdapter — transforms Zustand TeamData → GraphDataPort.
|
||||
* TeamGraphAdapter — transforms store-backed team graph input → GraphDataPort.
|
||||
*
|
||||
* This adapter owns the graph projection from team runtime state into the
|
||||
* reusable package port model. Renderer hooks may still read store state, but
|
||||
|
|
@ -55,12 +55,18 @@ import type {
|
|||
LeadActivityState,
|
||||
MemberSpawnStatusEntry,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
TeamData,
|
||||
ResolvedTeamMember,
|
||||
TeamProcess,
|
||||
TeamProvisioningProgress,
|
||||
TeamViewSnapshot,
|
||||
} from '@shared/types/team';
|
||||
import type { LeadContextUsage } from '@shared/types/team';
|
||||
|
||||
export interface TeamGraphData extends TeamViewSnapshot {
|
||||
members: ResolvedTeamMember[];
|
||||
messageFeed: InboxMessage[];
|
||||
}
|
||||
|
||||
export class TeamGraphAdapter {
|
||||
// ─── ES #private fields ──────────────────────────────────────────────────
|
||||
#lastTeamName = '';
|
||||
|
|
@ -87,7 +93,7 @@ export class TeamGraphAdapter {
|
|||
* Adapt team data into a GraphDataPort snapshot.
|
||||
*/
|
||||
adapt(
|
||||
teamData: TeamData | null,
|
||||
teamData: TeamGraphData | null,
|
||||
teamName: string,
|
||||
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
||||
leadActivity?: LeadActivityState,
|
||||
|
|
@ -179,7 +185,7 @@ export class TeamGraphAdapter {
|
|||
this.#buildMessageParticles(
|
||||
particles,
|
||||
nodes,
|
||||
teamData.messages,
|
||||
teamData.messageFeed,
|
||||
teamName,
|
||||
leadId,
|
||||
leadName,
|
||||
|
|
@ -222,11 +228,11 @@ export class TeamGraphAdapter {
|
|||
|
||||
// ─── Private: node builders ──────────────────────────────────────────────
|
||||
|
||||
static #getLeadMemberName(data: TeamData, teamName: string): string {
|
||||
static #getLeadMemberName(data: TeamGraphData, teamName: string): string {
|
||||
return getGraphLeadMemberName(data, teamName);
|
||||
}
|
||||
|
||||
static #buildMemberNodeIdByName(data: TeamData, teamName: string): Map<string, string> {
|
||||
static #buildMemberNodeIdByName(data: TeamGraphData, teamName: string): Map<string, string> {
|
||||
return new Map(
|
||||
data.members
|
||||
.filter((member) => !isLeadMember(member))
|
||||
|
|
@ -235,7 +241,7 @@ export class TeamGraphAdapter {
|
|||
}
|
||||
|
||||
static #buildLayoutPort(
|
||||
data: TeamData,
|
||||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
slotAssignments?: Record<string, GraphOwnerSlotAssignment>
|
||||
): GraphLayoutPort {
|
||||
|
|
@ -252,7 +258,7 @@ export class TeamGraphAdapter {
|
|||
(data.config.members ?? []).map((member) => getGraphStableOwnerId(member))
|
||||
);
|
||||
|
||||
const pushMember = (member: TeamData['members'][number] | undefined): void => {
|
||||
const pushMember = (member: TeamGraphData['members'][number] | undefined): void => {
|
||||
if (!member) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -322,7 +328,7 @@ export class TeamGraphAdapter {
|
|||
}
|
||||
|
||||
static #collectDuplicateStableOwnerIds(
|
||||
members: readonly TeamData['members'][number][]
|
||||
members: readonly TeamGraphData['members'][number][]
|
||||
): string[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const member of members) {
|
||||
|
|
@ -344,9 +350,9 @@ export class TeamGraphAdapter {
|
|||
}
|
||||
|
||||
static #getRuntimeLabel(
|
||||
providerId: TeamData['members'][number]['providerId'],
|
||||
model: TeamData['members'][number]['model'],
|
||||
effort: TeamData['members'][number]['effort']
|
||||
providerId: ResolvedTeamMember['providerId'],
|
||||
model: ResolvedTeamMember['model'],
|
||||
effort: ResolvedTeamMember['effort']
|
||||
): string | undefined {
|
||||
return formatTeamRuntimeSummary(providerId, model, effort);
|
||||
}
|
||||
|
|
@ -367,7 +373,7 @@ export class TeamGraphAdapter {
|
|||
#buildLeadNode(
|
||||
nodes: GraphNode[],
|
||||
leadId: string,
|
||||
data: TeamData,
|
||||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
leadName: string,
|
||||
pendingApprovalAgents?: Set<string>,
|
||||
|
|
@ -462,7 +468,7 @@ export class TeamGraphAdapter {
|
|||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
leadId: string,
|
||||
data: TeamData,
|
||||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
memberNodeIdByName: ReadonlyMap<string, string>,
|
||||
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
||||
|
|
@ -565,12 +571,12 @@ export class TeamGraphAdapter {
|
|||
#buildTaskNodes(
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
data: TeamData,
|
||||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
commentReadState?: Record<string, unknown>,
|
||||
memberNodeIdByName?: ReadonlyMap<string, string>
|
||||
): void {
|
||||
const taskStateById = new Map<string, Pick<TeamData['tasks'][number], 'status'>>();
|
||||
const taskStateById = new Map<string, Pick<TeamGraphData['tasks'][number], 'status'>>();
|
||||
const taskDisplayIds = new Map<string, string>();
|
||||
const memberColorByName = new Map<string, string>();
|
||||
|
||||
|
|
@ -750,7 +756,7 @@ export class TeamGraphAdapter {
|
|||
#buildProcessNodes(
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
data: TeamData,
|
||||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
memberNodeIdByName?: ReadonlyMap<string, string>
|
||||
): void {
|
||||
|
|
@ -828,7 +834,7 @@ export class TeamGraphAdapter {
|
|||
|
||||
#attachActivityFeeds(
|
||||
nodes: GraphNode[],
|
||||
data: TeamData,
|
||||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
leadId: string,
|
||||
leadName: string
|
||||
|
|
@ -845,7 +851,10 @@ export class TeamGraphAdapter {
|
|||
}
|
||||
|
||||
const entriesByOwnerNodeId = buildInlineActivityEntries({
|
||||
data,
|
||||
data: {
|
||||
...data,
|
||||
messages: data.messageFeed,
|
||||
},
|
||||
teamName,
|
||||
leadId,
|
||||
leadName,
|
||||
|
|
@ -1006,7 +1015,7 @@ export class TeamGraphAdapter {
|
|||
|
||||
#buildCommentParticles(
|
||||
particles: GraphParticle[],
|
||||
data: TeamData,
|
||||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
leadId: string,
|
||||
leadName: string,
|
||||
|
|
@ -1099,8 +1108,8 @@ export class TeamGraphAdapter {
|
|||
}
|
||||
|
||||
static #buildMemberException(
|
||||
runtimeAdvisory: TeamData['members'][number]['runtimeAdvisory'],
|
||||
providerId: TeamData['members'][number]['providerId'],
|
||||
runtimeAdvisory: ResolvedTeamMember['runtimeAdvisory'],
|
||||
providerId: ResolvedTeamMember['providerId'],
|
||||
spawn: MemberSpawnStatusEntry | undefined,
|
||||
pendingApproval: boolean
|
||||
): Pick<GraphNode, 'exceptionTone' | 'exceptionLabel'> | undefined {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,34 @@
|
|||
import { useStore } from '@renderer/store';
|
||||
import { selectTeamDataForName } from '@renderer/store/slices/teamSlice';
|
||||
import {
|
||||
selectResolvedMembersForTeamName,
|
||||
selectTeamDataForName,
|
||||
selectTeamMessages,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { TeamData, TeamSummary } from '@shared/types/team';
|
||||
import type { TeamSummary } from '@shared/types/team';
|
||||
import type { TeamGraphData } from '../adapters/TeamGraphAdapter';
|
||||
|
||||
export function useGraphActivityContext(teamName: string): {
|
||||
teamData: TeamData | null;
|
||||
teamData: TeamGraphData | null;
|
||||
teams: TeamSummary[];
|
||||
} {
|
||||
return useStore(
|
||||
useShallow((state) => ({
|
||||
teamData: selectTeamDataForName(state, teamName),
|
||||
teams: state.teams,
|
||||
}))
|
||||
useShallow((state) => {
|
||||
const snapshot = selectTeamDataForName(state, teamName);
|
||||
const members = selectResolvedMembersForTeamName(state, teamName);
|
||||
const messages = selectTeamMessages(state, teamName);
|
||||
|
||||
return {
|
||||
teamData: snapshot
|
||||
? {
|
||||
...snapshot,
|
||||
members,
|
||||
messageFeed: messages,
|
||||
}
|
||||
: null,
|
||||
teams: state.teams,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@ import { useCallback, useMemo, useState } from 'react';
|
|||
import { api } from '@renderer/api';
|
||||
import { CreateTaskDialog } from '@renderer/components/team/dialogs/CreateTaskDialog';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { isTeamProvisioningActive, selectTeamDataForName } from '@renderer/store/slices/teamSlice';
|
||||
import {
|
||||
isTeamProvisioningActive,
|
||||
selectResolvedMembersForTeamName,
|
||||
selectTeamDataForName,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { TaskRef } from '@shared/types';
|
||||
|
|
@ -25,19 +29,17 @@ export function useGraphCreateTaskDialog(teamName: string): UseGraphCreateTaskDi
|
|||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const { teamData, createTeamTask, isTeamProvisioning } = useStore(
|
||||
const { teamData, activeMembers, createTeamTask, isTeamProvisioning } = useStore(
|
||||
useShallow((state) => ({
|
||||
teamData: selectTeamDataForName(state, teamName),
|
||||
activeMembers: selectResolvedMembersForTeamName(state, teamName).filter(
|
||||
(member) => !member.removedAt
|
||||
),
|
||||
createTeamTask: state.createTeamTask,
|
||||
isTeamProvisioning: isTeamProvisioningActive(state, teamName),
|
||||
}))
|
||||
);
|
||||
|
||||
const activeMembers = useMemo(
|
||||
() => (teamData?.members ?? []).filter((member) => !member.removedAt),
|
||||
[teamData?.members]
|
||||
);
|
||||
|
||||
const openCreateTaskDialog = useCallback((owner = ''): void => {
|
||||
setDialogState({
|
||||
open: true,
|
||||
|
|
|
|||
|
|
@ -1,19 +1,34 @@
|
|||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
getCurrentProvisioningProgressForTeam,
|
||||
selectResolvedMembersForTeamName,
|
||||
selectTeamDataForName,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { TeamGraphData } from '../adapters/TeamGraphAdapter';
|
||||
|
||||
export function useGraphMemberPopoverContext(teamName: string, memberName: string) {
|
||||
return useStore(
|
||||
useShallow((state) => ({
|
||||
teamData: teamName ? selectTeamDataForName(state, teamName) : null,
|
||||
spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined,
|
||||
leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined,
|
||||
progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null,
|
||||
memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined,
|
||||
memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined,
|
||||
}))
|
||||
useShallow((state) => {
|
||||
const snapshot = teamName ? selectTeamDataForName(state, teamName) : null;
|
||||
const teamMembers = teamName ? selectResolvedMembersForTeamName(state, teamName) : [];
|
||||
|
||||
return {
|
||||
teamData: snapshot
|
||||
? {
|
||||
...snapshot,
|
||||
members: teamMembers,
|
||||
messageFeed: [],
|
||||
}
|
||||
: null,
|
||||
teamMembers,
|
||||
spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined,
|
||||
leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined,
|
||||
progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null,
|
||||
memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined,
|
||||
memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,19 +9,24 @@ import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage';
|
|||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
getCurrentProvisioningProgressForTeam,
|
||||
selectResolvedMembersForTeamName,
|
||||
selectTeamDataForName,
|
||||
selectTeamMessages,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter';
|
||||
|
||||
import type { GraphDataPort } from '@claude-teams/agent-graph';
|
||||
import type { TeamGraphData } from '../adapters/TeamGraphAdapter';
|
||||
|
||||
export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||
const adapterRef = useRef<TeamGraphAdapter>(TeamGraphAdapter.create());
|
||||
|
||||
const {
|
||||
teamData,
|
||||
teamSnapshot,
|
||||
members,
|
||||
messages,
|
||||
spawnStatuses,
|
||||
leadActivity,
|
||||
leadContext,
|
||||
|
|
@ -35,7 +40,9 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
|||
ensureTeamGraphSlotAssignments,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
teamData: selectTeamDataForName(s, teamName),
|
||||
teamSnapshot: selectTeamDataForName(s, teamName),
|
||||
members: selectResolvedMembersForTeamName(s, teamName),
|
||||
messages: selectTeamMessages(s, teamName),
|
||||
spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
|
||||
leadActivity: teamName ? s.leadActivityByTeam[teamName] : undefined,
|
||||
leadContext: teamName ? s.leadContextByTeam[teamName] : undefined,
|
||||
|
|
@ -60,6 +67,17 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
|||
return agents;
|
||||
}, [pendingApprovals, teamName]);
|
||||
|
||||
const teamData = useMemo<TeamGraphData | null>(() => {
|
||||
if (!teamSnapshot) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...teamSnapshot,
|
||||
members,
|
||||
messageFeed: messages,
|
||||
};
|
||||
}, [members, messages, teamSnapshot]);
|
||||
|
||||
const commentReadState = useSyncExternalStore(subscribe, getSnapshot);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -71,6 +71,9 @@ export const GraphActivityHud = ({
|
|||
const connectorPathRefs = useRef(new Map<string, SVGPathElement | null>());
|
||||
const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(null);
|
||||
const { teamData, teams } = useGraphActivityContext(teamName);
|
||||
const teamSnapshot = teamData;
|
||||
const members = teamData?.members ?? [];
|
||||
const messages = teamData?.messageFeed ?? [];
|
||||
|
||||
const ownerNodes = useMemo(
|
||||
() =>
|
||||
|
|
@ -81,21 +84,27 @@ export const GraphActivityHud = ({
|
|||
[nodes]
|
||||
);
|
||||
const leadNodeId = ownerNodes.find((node) => node.kind === 'lead')?.id ?? `lead:${teamName}`;
|
||||
const leadName = teamData ? getGraphLeadMemberName(teamData, teamName) : `${teamName}-lead`;
|
||||
const leadName = teamSnapshot
|
||||
? getGraphLeadMemberName({ members }, teamName)
|
||||
: `${teamName}-lead`;
|
||||
const ownerNodeIds = useMemo(() => new Set(ownerNodes.map((node) => node.id)), [ownerNodes]);
|
||||
const entryMapByOwnerNodeId = useMemo(() => {
|
||||
if (!teamData) {
|
||||
if (!teamSnapshot) {
|
||||
return new Map<string, InlineActivityEntry[]>();
|
||||
}
|
||||
return buildInlineActivityEntries({
|
||||
data: teamData,
|
||||
data: {
|
||||
members,
|
||||
tasks: teamSnapshot.tasks,
|
||||
messages,
|
||||
},
|
||||
teamName,
|
||||
leadId: leadNodeId,
|
||||
leadName,
|
||||
ownerNodeIds,
|
||||
});
|
||||
}, [leadName, leadNodeId, ownerNodeIds, teamData, teamName]);
|
||||
const messageContext = useMemo(() => buildMessageContext(teamData?.members), [teamData?.members]);
|
||||
}, [leadName, leadNodeId, members, messages, ownerNodeIds, teamName, teamSnapshot]);
|
||||
const messageContext = useMemo(() => buildMessageContext(members), [members]);
|
||||
const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams);
|
||||
const { readSet } = useTeamMessagesRead(teamName);
|
||||
|
||||
|
|
@ -383,7 +392,7 @@ export const GraphActivityHud = ({
|
|||
};
|
||||
}, [enabled, forwardWheelToGraph, visibleLanes]);
|
||||
|
||||
if (!enabled || !teamData || visibleLanes.length === 0) {
|
||||
if (!enabled || !teamSnapshot || visibleLanes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -493,7 +502,7 @@ export const GraphActivityHud = ({
|
|||
}
|
||||
}}
|
||||
teamName={teamName}
|
||||
members={teamData.members}
|
||||
members={members}
|
||||
onMemberClick={handleMemberClick}
|
||||
onTaskIdClick={onOpenTaskDetail}
|
||||
teamNames={teamNames}
|
||||
|
|
|
|||
|
|
@ -292,14 +292,21 @@ const MemberPopoverContent = ({
|
|||
? node.domainRef.teamName
|
||||
: '';
|
||||
const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64);
|
||||
const { teamData, spawnEntry, leadActivity, progress, memberSpawnSnapshot, memberSpawnStatuses } =
|
||||
useGraphMemberPopoverContext(teamName, memberName);
|
||||
const member = teamData?.members.find((candidate) => candidate.name === memberName) ?? null;
|
||||
const {
|
||||
teamData,
|
||||
teamMembers,
|
||||
spawnEntry,
|
||||
leadActivity,
|
||||
progress,
|
||||
memberSpawnSnapshot,
|
||||
memberSpawnStatuses,
|
||||
} = useGraphMemberPopoverContext(teamName, memberName);
|
||||
const member = teamMembers.find((candidate) => candidate.name === memberName) ?? null;
|
||||
const provisioningPresentation =
|
||||
teamData && teamName
|
||||
? buildTeamProvisioningPresentation({
|
||||
progress,
|
||||
members: teamData.members,
|
||||
members: teamMembers,
|
||||
memberSpawnStatuses,
|
||||
memberSpawnSnapshot,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -562,6 +562,13 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
const teamName = row.teamName.trim();
|
||||
const detail = typeof row.detail === 'string' ? row.detail : '';
|
||||
|
||||
if (
|
||||
teamDataService &&
|
||||
(row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config')
|
||||
) {
|
||||
teamDataService.invalidateMessageFeed(teamName);
|
||||
}
|
||||
|
||||
// --- Inbox change events: relay to lead + native OS notifications ---
|
||||
if (row.type === 'inbox') {
|
||||
if (reconcileScheduler) {
|
||||
|
|
@ -900,6 +907,12 @@ async function initializeServices(): Promise<void> {
|
|||
});
|
||||
|
||||
const forwardTeamChange = (event: TeamChangeEvent): void => {
|
||||
if (
|
||||
teamDataService &&
|
||||
(event.type === 'inbox' || event.type === 'lead-message' || event.type === 'config')
|
||||
) {
|
||||
teamDataService.invalidateMessageFeed(event.teamName);
|
||||
}
|
||||
safeSendToRenderer(mainWindow, TEAM_CHANGE, event);
|
||||
httpServer?.broadcast('team-change', event);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
TEAM_GET_DATA,
|
||||
TEAM_GET_DELETED_TASKS,
|
||||
TEAM_GET_LOGS_FOR_TASK,
|
||||
TEAM_GET_MEMBER_ACTIVITY_META,
|
||||
TEAM_GET_MEMBER_LOGS,
|
||||
TEAM_GET_MEMBER_STATS,
|
||||
TEAM_GET_MESSAGES_PAGE,
|
||||
|
|
@ -92,7 +93,7 @@ import {
|
|||
parseStandaloneSlashCommand,
|
||||
} from '@shared/utils/slashCommands';
|
||||
import crypto from 'crypto';
|
||||
import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
|
||||
import { app, BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
|
|
@ -170,15 +171,16 @@ import type {
|
|||
TeamCreateConfigRequest,
|
||||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
TeamData,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamMemberActivityMeta,
|
||||
TeamMessageNotificationData,
|
||||
TeamProvisioningPrepareResult,
|
||||
TeamProvisioningProgress,
|
||||
TeamSummary,
|
||||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
TeamViewSnapshot,
|
||||
TeamUpdateConfigRequest,
|
||||
ToolApprovalFileContent,
|
||||
ToolApprovalSettings,
|
||||
|
|
@ -196,6 +198,17 @@ const logger = createLogger('IPC:teams');
|
|||
const seenRateLimitKeys = new Set<string>();
|
||||
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
|
||||
|
||||
function ensureHeavyTeamDataWorkerFallbackAllowed(operation: string): void {
|
||||
if (!app.isPackaged) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error(
|
||||
`[${operation}] team-data-worker unavailable in packaged runtime; refusing main-thread fallback for heavy message/activity path`
|
||||
);
|
||||
throw new Error('TEAM_DATA_WORKER_UNAVAILABLE');
|
||||
}
|
||||
|
||||
async function getDurableLeadTeammateRoster(
|
||||
teamName: string,
|
||||
leadName: string
|
||||
|
|
@ -385,6 +398,19 @@ function checkApiErrorMessages(
|
|||
}
|
||||
}
|
||||
|
||||
function scanTeamMessageNotifications(
|
||||
messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
|
||||
teamName: string,
|
||||
teamDisplayName: string,
|
||||
projectPath?: string
|
||||
): void {
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
checkRateLimitMessages(messages, teamName, teamDisplayName, projectPath);
|
||||
checkApiErrorMessages(messages, teamName, teamDisplayName, projectPath);
|
||||
}
|
||||
|
||||
let teamDataService: TeamDataService | null = null;
|
||||
let teamProvisioningService: TeamProvisioningService | null = null;
|
||||
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
|
||||
|
|
@ -463,6 +489,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning);
|
||||
ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage);
|
||||
ipcMain.handle(TEAM_GET_MESSAGES_PAGE, handleGetMessagesPage);
|
||||
ipcMain.handle(TEAM_GET_MEMBER_ACTIVITY_META, handleGetMemberActivityMeta);
|
||||
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
|
||||
ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview);
|
||||
ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban);
|
||||
|
|
@ -535,6 +562,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING);
|
||||
ipcMain.removeHandler(TEAM_SEND_MESSAGE);
|
||||
ipcMain.removeHandler(TEAM_GET_MESSAGES_PAGE);
|
||||
ipcMain.removeHandler(TEAM_GET_MEMBER_ACTIVITY_META);
|
||||
ipcMain.removeHandler(TEAM_CREATE_TASK);
|
||||
ipcMain.removeHandler(TEAM_REQUEST_REVIEW);
|
||||
ipcMain.removeHandler(TEAM_UPDATE_KANBAN);
|
||||
|
|
@ -702,14 +730,14 @@ async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<Te
|
|||
async function handleGetData(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
): Promise<IpcResult<TeamData>> {
|
||||
): Promise<IpcResult<TeamViewSnapshot>> {
|
||||
const validated = validateTeamName(teamName);
|
||||
if (!validated.valid) {
|
||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
}
|
||||
const tn = validated.value!;
|
||||
const startedAt = Date.now();
|
||||
let data: TeamData;
|
||||
let data: TeamViewSnapshot;
|
||||
setCurrentMainOp('team:getData');
|
||||
try {
|
||||
// Prefer worker thread to keep main event loop responsive
|
||||
|
|
@ -721,9 +749,11 @@ async function handleGetData(
|
|||
logger.warn(
|
||||
`[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
|
||||
);
|
||||
ensureHeavyTeamDataWorkerFallbackAllowed('teams:getData');
|
||||
data = await getTeamDataService().getTeamData(tn);
|
||||
}
|
||||
} else {
|
||||
ensureHeavyTeamDataWorkerFallbackAllowed('teams:getData');
|
||||
data = await getTeamDataService().getTeamData(tn);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -762,92 +792,9 @@ async function handleGetData(
|
|||
|
||||
const displayName = data.config.name || tn;
|
||||
const projectPath = data.config.projectPath;
|
||||
|
||||
const live = provisioning.getLiveLeadProcessMessages(tn);
|
||||
if (live.length === 0) {
|
||||
checkRateLimitMessages(data.messages, tn, displayName, projectPath);
|
||||
checkApiErrorMessages(data.messages, tn, displayName, projectPath);
|
||||
return { success: true, data: { ...data, isAlive } };
|
||||
}
|
||||
|
||||
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
|
||||
const isLeadThoughtLike = (msg: { source?: unknown; to?: string }): boolean =>
|
||||
!msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
|
||||
const getLeadThoughtFingerprint = (msg: {
|
||||
from: string;
|
||||
text: string;
|
||||
leadSessionId?: string;
|
||||
}): string => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text)}`;
|
||||
|
||||
// Collect fingerprints only for thought-like lead messages. Include leadSessionId so a
|
||||
// repeated thought in a new session does not get collapsed into an old session's history.
|
||||
const existingTextFingerprints = new Set<string>();
|
||||
for (const msg of data.messages) {
|
||||
if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue;
|
||||
if (!isLeadThoughtLike(msg)) continue;
|
||||
existingTextFingerprints.add(getLeadThoughtFingerprint(msg));
|
||||
}
|
||||
|
||||
const keyFor = (m: {
|
||||
messageId?: string;
|
||||
timestamp: string;
|
||||
from: string;
|
||||
text: string;
|
||||
}): string => {
|
||||
if (typeof m.messageId === 'string' && m.messageId.trim().length > 0) {
|
||||
return m.messageId;
|
||||
}
|
||||
return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`;
|
||||
};
|
||||
|
||||
// Text-based fingerprints for live lead thoughts to catch duplicates with different
|
||||
// messageIds inside the same session (e.g. lead-turn-* re-emits).
|
||||
const leadProcessTextFingerprints = new Set<string>();
|
||||
|
||||
// Content-based dedup for SendMessage captures: Claude Code CLI and our
|
||||
// persistInboxMessage both write to inboxes/{member}.json, producing two entries
|
||||
// with identical content but different messageIds. Track content fingerprints
|
||||
// (from+to+text) with timestamps to collapse them within a 5-second window.
|
||||
const contentSeen = new Map<string, number>(); // fingerprint → timestamp ms
|
||||
|
||||
const merged: typeof data.messages = [];
|
||||
const seen = new Set<string>();
|
||||
for (const msg of [...data.messages, ...live]) {
|
||||
if ((msg as { source?: unknown }).source === 'lead_process' && !msg.to) {
|
||||
const fp = getLeadThoughtFingerprint(msg);
|
||||
// Skip if the same thought already exists in persisted history for the same session.
|
||||
if (existingTextFingerprints.has(fp)) {
|
||||
continue;
|
||||
}
|
||||
// Dedup live lead_process thoughts with the same text in the same session.
|
||||
if (leadProcessTextFingerprints.has(fp)) {
|
||||
continue;
|
||||
}
|
||||
leadProcessTextFingerprints.add(fp);
|
||||
}
|
||||
|
||||
// Content dedup for directed messages (SendMessage captures):
|
||||
// same from+to+text within 5 seconds = duplicate from CLI + our persist.
|
||||
if (typeof msg.to === 'string' && msg.to.trim().length > 0) {
|
||||
const contentFp = `${msg.from}\0${msg.to}\0${(msg.text ?? '').replace(/\s+/g, ' ').slice(0, 100)}`;
|
||||
const msgMs = Date.parse(msg.timestamp);
|
||||
const existingMs = contentSeen.get(contentFp);
|
||||
if (existingMs !== undefined && Math.abs(msgMs - existingMs) <= 5000) {
|
||||
continue; // duplicate within 5s window — skip
|
||||
}
|
||||
contentSeen.set(contentFp, msgMs);
|
||||
}
|
||||
|
||||
const key = keyFor(msg);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
merged.push(msg);
|
||||
}
|
||||
merged.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
||||
|
||||
checkRateLimitMessages(merged, tn, displayName, projectPath);
|
||||
checkApiErrorMessages(merged, tn, displayName, projectPath);
|
||||
return { success: true, data: { ...data, isAlive, messages: merged } };
|
||||
scanTeamMessageNotifications(live, tn, displayName, projectPath);
|
||||
return { success: true, data: { ...data, isAlive } };
|
||||
}
|
||||
|
||||
async function handleGetTaskChangePresence(
|
||||
|
|
@ -1698,16 +1645,71 @@ async function handleGetMessagesPage(
|
|||
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||
}
|
||||
const opts = (options && typeof options === 'object' ? options : {}) as {
|
||||
beforeTimestamp?: string;
|
||||
cursor?: string | null;
|
||||
limit?: number;
|
||||
};
|
||||
const limit = Math.min(Math.max(1, opts.limit ?? 50), 200);
|
||||
const beforeTimestamp =
|
||||
typeof opts.beforeTimestamp === 'string' ? opts.beforeTimestamp : undefined;
|
||||
const cursor =
|
||||
typeof opts.cursor === 'string' ? opts.cursor : opts.cursor === null ? null : undefined;
|
||||
|
||||
return wrapTeamHandler('getMessagesPage', async () => {
|
||||
const service = getTeamDataService();
|
||||
return service.getMessagesPage(vTeam.value!, { beforeTimestamp, limit });
|
||||
let page: MessagesPage;
|
||||
const notificationContext = await getTeamDataService().getTeamNotificationContext(vTeam.value!);
|
||||
const worker = getTeamDataWorkerClient();
|
||||
if (worker.isAvailable()) {
|
||||
try {
|
||||
page = await worker.getMessagesPage(vTeam.value!, { cursor, limit });
|
||||
scanTeamMessageNotifications(
|
||||
page.messages,
|
||||
vTeam.value!,
|
||||
notificationContext.displayName,
|
||||
notificationContext.projectPath
|
||||
);
|
||||
return page;
|
||||
} catch (workerErr) {
|
||||
logger.warn(
|
||||
`[teams:getMessagesPage] worker failed, falling back: ${
|
||||
workerErr instanceof Error ? workerErr.message : workerErr
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
ensureHeavyTeamDataWorkerFallbackAllowed('teams:getMessagesPage');
|
||||
page = await getTeamDataService().getMessagesPage(vTeam.value!, { cursor, limit });
|
||||
scanTeamMessageNotifications(
|
||||
page.messages,
|
||||
vTeam.value!,
|
||||
notificationContext.displayName,
|
||||
notificationContext.projectPath
|
||||
);
|
||||
return page;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleGetMemberActivityMeta(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
): Promise<IpcResult<TeamMemberActivityMeta>> {
|
||||
const vTeam = validateTeamName(teamName);
|
||||
if (!vTeam.valid) {
|
||||
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||
}
|
||||
|
||||
return wrapTeamHandler('getMemberActivityMeta', async () => {
|
||||
const worker = getTeamDataWorkerClient();
|
||||
if (worker.isAvailable()) {
|
||||
try {
|
||||
return await worker.getMemberActivityMeta(vTeam.value!);
|
||||
} catch (workerErr) {
|
||||
logger.warn(
|
||||
`[teams:getMemberActivityMeta] worker failed, falling back: ${
|
||||
workerErr instanceof Error ? workerErr.message : workerErr
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
ensureHeavyTeamDataWorkerFallbackAllowed('teams:getMemberActivityMeta');
|
||||
return getTeamDataService().getMemberActivityMeta(vTeam.value!);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
128
src/main/services/team/MemberActivityMetaService.ts
Normal file
128
src/main/services/team/MemberActivityMetaService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ import {
|
|||
} from './cache/LeadSessionParseCache';
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
|
||||
import { MemberActivityMetaService } from './MemberActivityMetaService';
|
||||
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
|
||||
import { TeamConfigReader } from './TeamConfigReader';
|
||||
import { TeamInboxReader } from './TeamInboxReader';
|
||||
|
|
@ -46,6 +47,7 @@ import { TeamInboxWriter } from './TeamInboxWriter';
|
|||
import { TeamKanbanManager } from './TeamKanbanManager';
|
||||
import { TeamMemberResolver } from './TeamMemberResolver';
|
||||
import { TeamMemberRuntimeAdvisoryService } from './TeamMemberRuntimeAdvisoryService';
|
||||
import { TeamMessageFeedService } from './TeamMessageFeedService';
|
||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||
import { TeamMetaStore } from './TeamMetaStore';
|
||||
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||
|
|
@ -65,7 +67,6 @@ import type {
|
|||
KanbanColumnId,
|
||||
KanbanState,
|
||||
MessagesPage,
|
||||
ResolvedTeamMember,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
TaskAttachmentMeta,
|
||||
|
|
@ -74,13 +75,14 @@ import type {
|
|||
TaskRef,
|
||||
TeamConfig,
|
||||
TeamCreateConfigRequest,
|
||||
TeamData,
|
||||
TeamMemberActivityMeta,
|
||||
TeamMember,
|
||||
TeamProcess,
|
||||
TeamSummary,
|
||||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
TeamTaskWithKanban,
|
||||
TeamViewSnapshot,
|
||||
ToolCallMeta,
|
||||
UpdateKanbanPatch,
|
||||
} from '@shared/types';
|
||||
|
|
@ -98,6 +100,14 @@ const TASK_MAP_YIELD_EVERY = 250;
|
|||
const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification';
|
||||
const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
|
||||
|
||||
function requireCanonicalMessageId(message: InboxMessage): string {
|
||||
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
|
||||
if (messageId.length > 0) {
|
||||
return messageId;
|
||||
}
|
||||
throw new Error('Canonical team message is missing effective messageId');
|
||||
}
|
||||
|
||||
interface EligibleTaskCommentNotification {
|
||||
key: string;
|
||||
messageId: string;
|
||||
|
|
@ -162,6 +172,8 @@ export class TeamDataService {
|
|||
private taskChangePresenceRepository: TaskChangePresenceRepository | null = null;
|
||||
private teamLogSourceTracker: TeamLogSourceTracker | null = null;
|
||||
private fileWatchReconcileDiagnostics = new Map<string, FileWatchReconcileDiagnostics>();
|
||||
private readonly messageFeedService: TeamMessageFeedService;
|
||||
private readonly memberActivityMetaService: MemberActivityMetaService;
|
||||
|
||||
constructor(
|
||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||
|
|
@ -183,7 +195,15 @@ export class TeamDataService {
|
|||
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(),
|
||||
private memberRuntimeAdvisoryService: TeamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(),
|
||||
private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache()
|
||||
) {}
|
||||
) {
|
||||
this.messageFeedService = new TeamMessageFeedService({
|
||||
getConfig: (teamName) => this.configReader.getConfig(teamName),
|
||||
getInboxMessages: (teamName) => this.inboxReader.getMessages(teamName),
|
||||
getLeadSessionMessages: (config) => this.extractLeadSessionTexts(config),
|
||||
getSentMessages: (teamName) => this.sentMessagesStore.readMessages(teamName),
|
||||
});
|
||||
this.memberActivityMetaService = new MemberActivityMetaService(this.messageFeedService);
|
||||
}
|
||||
|
||||
private getController(teamName: string): AgentTeamsController {
|
||||
return this.controllerFactory(teamName);
|
||||
|
|
@ -622,7 +642,7 @@ export class TeamDataService {
|
|||
await fs.promises.rm(tasksDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async getTeamData(teamName: string): Promise<TeamData> {
|
||||
async getTeamData(teamName: string): Promise<TeamViewSnapshot> {
|
||||
const startedAt = Date.now();
|
||||
const marks: Record<string, number> = {};
|
||||
const mark = (label: string): void => {
|
||||
|
|
@ -726,12 +746,6 @@ export class TeamDataService {
|
|||
warningText: 'Inboxes failed to load',
|
||||
load: () => this.inboxReader.listInboxNames(teamName),
|
||||
});
|
||||
const sentMessagesStep = startReadStep({
|
||||
label: 'sentMessages',
|
||||
createFallback: () => [],
|
||||
warningText: 'Sent messages failed to load',
|
||||
load: () => this.sentMessagesStore.readMessages(teamName),
|
||||
});
|
||||
const metaMembersStep = startReadStep({
|
||||
label: 'metaMembers',
|
||||
createFallback: () => [],
|
||||
|
|
@ -756,40 +770,8 @@ export class TeamDataService {
|
|||
load: () => this.taskReader.getTasks(teamName),
|
||||
})
|
||||
);
|
||||
const messagesStep = runWithConcurrencyLimit(() =>
|
||||
startReadStep({
|
||||
label: 'messages',
|
||||
createFallback: () => [],
|
||||
warningText: 'Messages failed to load',
|
||||
load: () => this.inboxReader.getMessages(teamName),
|
||||
})
|
||||
);
|
||||
const leadTextsStep = runWithConcurrencyLimit(() =>
|
||||
startReadStep({
|
||||
label: 'leadTexts',
|
||||
createFallback: () => [],
|
||||
warningText: 'Lead session texts failed to load',
|
||||
load: () => this.extractLeadSessionTexts(config),
|
||||
})
|
||||
);
|
||||
|
||||
const [
|
||||
tasksStepResult,
|
||||
inboxNamesStepResult,
|
||||
messagesStepResult,
|
||||
leadTextsStepResult,
|
||||
sentMessagesStepResult,
|
||||
metaMembersStepResult,
|
||||
kanbanStateStepResult,
|
||||
] = await Promise.all([
|
||||
tasksStep,
|
||||
inboxNamesStep,
|
||||
messagesStep,
|
||||
leadTextsStep,
|
||||
sentMessagesStep,
|
||||
metaMembersStep,
|
||||
kanbanStateStep,
|
||||
]);
|
||||
const [tasksStepResult, inboxNamesStepResult, metaMembersStepResult, kanbanStateStepResult] =
|
||||
await Promise.all([tasksStep, inboxNamesStep, metaMembersStep, kanbanStateStep]);
|
||||
|
||||
// After parallelizing the top read phase, these marks no longer represent
|
||||
// serial stage boundaries. They now capture the actual completion time for
|
||||
|
|
@ -797,178 +779,18 @@ export class TeamDataService {
|
|||
// diagnostics useful without mutating marks from concurrent branches.
|
||||
marks.tasks = tasksStepResult.completedAt;
|
||||
marks.inboxNames = inboxNamesStepResult.completedAt;
|
||||
marks.messages = messagesStepResult.completedAt;
|
||||
marks.leadTexts = leadTextsStepResult.completedAt;
|
||||
marks.sentMessages = sentMessagesStepResult.completedAt;
|
||||
marks.metaMembers = metaMembersStepResult.completedAt;
|
||||
marks.kanbanState = kanbanStateStepResult.completedAt;
|
||||
|
||||
if (tasksStepResult.warning) warnings.push(tasksStepResult.warning);
|
||||
if (inboxNamesStepResult.warning) warnings.push(inboxNamesStepResult.warning);
|
||||
if (messagesStepResult.warning) warnings.push(messagesStepResult.warning);
|
||||
if (leadTextsStepResult.warning) warnings.push(leadTextsStepResult.warning);
|
||||
if (sentMessagesStepResult.warning) warnings.push(sentMessagesStepResult.warning);
|
||||
if (metaMembersStepResult.warning) warnings.push(metaMembersStepResult.warning);
|
||||
if (kanbanStateStepResult.warning) warnings.push(kanbanStateStepResult.warning);
|
||||
|
||||
const tasks: TeamTask[] = tasksStepResult.value;
|
||||
const inboxNames: string[] = inboxNamesStepResult.value;
|
||||
let messages: InboxMessage[] = messagesStepResult.value;
|
||||
const leadTexts: InboxMessage[] = leadTextsStepResult.value;
|
||||
const sentMessages: InboxMessage[] = sentMessagesStepResult.value;
|
||||
mark('postStart');
|
||||
|
||||
if (leadTexts.length > 0) {
|
||||
messages = [...messages, ...leadTexts];
|
||||
}
|
||||
if (sentMessages.length > 0) {
|
||||
messages = [...messages, ...sentMessages];
|
||||
}
|
||||
mark('mergeMessages');
|
||||
|
||||
// Dedup: if a lead_process message text is also present in lead_session, prefer lead_session.
|
||||
// This avoids double-rendering when we persist lead process messages and later load the lead JSONL.
|
||||
// Exception: lead_process messages with `to` field are captured SendMessage — never dedup those.
|
||||
if (leadTexts.length > 0) {
|
||||
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
|
||||
const getLeadThoughtFingerprint = (
|
||||
msg: Pick<InboxMessage, 'from' | 'text' | 'leadSessionId'>
|
||||
) => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`;
|
||||
const leadSessionFingerprints = new Set<string>();
|
||||
for (const msg of leadTexts) {
|
||||
if (msg.source !== 'lead_session') continue;
|
||||
leadSessionFingerprints.add(getLeadThoughtFingerprint(msg));
|
||||
}
|
||||
messages = messages.filter((m) => {
|
||||
if (m.source !== 'lead_process') return true;
|
||||
// Captured SendMessage messages (with recipient) are real messages — never dedup
|
||||
if (m.to) return true;
|
||||
const fp = getLeadThoughtFingerprint(m);
|
||||
return !leadSessionFingerprints.has(fp);
|
||||
});
|
||||
}
|
||||
mark('dedupLeadTexts');
|
||||
|
||||
// Dedup exact message copies that can appear as both live lead_process rows and
|
||||
// their persisted inbox/sent-message counterpart. If the messageId is identical,
|
||||
// keep a single row so the UI does not show the same SendMessage twice
|
||||
// (for example "LIVE" plus the stored copy).
|
||||
const duplicateMessageIds = new Set<string>();
|
||||
const messageIdCounts = new Map<string, number>();
|
||||
for (const msg of messages) {
|
||||
const id = typeof msg.messageId === 'string' ? msg.messageId.trim() : '';
|
||||
if (!id) continue;
|
||||
const nextCount = (messageIdCounts.get(id) ?? 0) + 1;
|
||||
messageIdCounts.set(id, nextCount);
|
||||
if (nextCount > 1) duplicateMessageIds.add(id);
|
||||
}
|
||||
if (duplicateMessageIds.size > 0) {
|
||||
const choosePreferredMessage = (
|
||||
current: InboxMessage,
|
||||
candidate: InboxMessage
|
||||
): InboxMessage => {
|
||||
const score = (msg: InboxMessage): number => {
|
||||
let value = 0;
|
||||
if (msg.source !== 'lead_process') value += 4;
|
||||
if (msg.read === false) value += 2;
|
||||
if (msg.relayOfMessageId) value += 1;
|
||||
if (msg.summary) value += 1;
|
||||
if (msg.to) value += 1;
|
||||
return value;
|
||||
};
|
||||
const currentScore = score(current);
|
||||
const candidateScore = score(candidate);
|
||||
if (candidateScore !== currentScore) {
|
||||
return candidateScore > currentScore ? candidate : current;
|
||||
}
|
||||
const currentTs = Date.parse(current.timestamp);
|
||||
const candidateTs = Date.parse(candidate.timestamp);
|
||||
if (
|
||||
Number.isFinite(currentTs) &&
|
||||
Number.isFinite(candidateTs) &&
|
||||
candidateTs !== currentTs
|
||||
) {
|
||||
return candidateTs > currentTs ? candidate : current;
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
const dedupedById = new Map<string, InboxMessage>();
|
||||
const dedupedWithoutId: InboxMessage[] = [];
|
||||
for (const msg of messages) {
|
||||
const id = typeof msg.messageId === 'string' ? msg.messageId.trim() : '';
|
||||
if (!id) {
|
||||
dedupedWithoutId.push(msg);
|
||||
continue;
|
||||
}
|
||||
const existing = dedupedById.get(id);
|
||||
if (!existing) {
|
||||
dedupedById.set(id, msg);
|
||||
continue;
|
||||
}
|
||||
dedupedById.set(id, choosePreferredMessage(existing, msg));
|
||||
}
|
||||
messages = [...dedupedWithoutId, ...dedupedById.values()];
|
||||
}
|
||||
mark('dedupMessageIds');
|
||||
|
||||
messages = this.linkPassiveUserReplySummaries(messages);
|
||||
mark('linkPassiveUserReplySummaries');
|
||||
|
||||
// Enrich inbox messages without leadSessionId by assigning the nearest neighbor's
|
||||
// session ID (by timestamp). This avoids the old forward-only propagation bug.
|
||||
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
|
||||
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
||||
|
||||
const anchors: { index: number; time: number; sessionId: string }[] = [];
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (messages[i].leadSessionId) {
|
||||
anchors.push({
|
||||
index: i,
|
||||
time: Date.parse(messages[i].timestamp),
|
||||
sessionId: messages[i].leadSessionId!,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (anchors.length > 0) {
|
||||
let anchorIdx = 0;
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (messages[i].leadSessionId) {
|
||||
while (anchorIdx < anchors.length - 1 && anchors[anchorIdx].index < i) {
|
||||
anchorIdx++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const msgTime = Date.parse(messages[i].timestamp);
|
||||
let bestAnchor = anchors[0];
|
||||
let bestDist = Math.abs(msgTime - bestAnchor.time);
|
||||
for (const anchor of anchors) {
|
||||
const dist = Math.abs(msgTime - anchor.time);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestAnchor = anchor;
|
||||
} else if (dist > bestDist && anchor.time > msgTime) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
messages[i].leadSessionId = bestAnchor.sessionId;
|
||||
}
|
||||
} else if (config.leadSessionId) {
|
||||
for (const msg of messages) {
|
||||
msg.leadSessionId = config.leadSessionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
mark('attachLeadSessionIds');
|
||||
|
||||
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
||||
this.annotateSlashCommandResponses(messages);
|
||||
|
||||
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
||||
mark('normalizeMessages');
|
||||
|
||||
const metaMembers: TeamConfig['members'] = metaMembersStepResult.value;
|
||||
const kanbanState: KanbanState = kanbanStateStepResult.value;
|
||||
|
||||
|
|
@ -1000,8 +822,7 @@ export class TeamDataService {
|
|||
config,
|
||||
metaMembers,
|
||||
inboxNames,
|
||||
tasksWithKanban,
|
||||
messages
|
||||
tasksWithKanban
|
||||
);
|
||||
mark('resolveMembers');
|
||||
|
||||
|
|
@ -1036,30 +857,13 @@ export class TeamDataService {
|
|||
|
||||
const totalMs = Date.now() - startedAt;
|
||||
if (totalMs >= 1500) {
|
||||
const counts = `counts=tasks:${tasks.length},messages:${messages.length},inboxNames:${inboxNames.length},leadTexts:${leadTexts.length},sent:${sentMessages.length},members:${members.length},processes:${processes.length}`;
|
||||
const counts = `counts=tasks:${tasks.length},inboxNames:${inboxNames.length},members:${members.length},processes:${processes.length}`;
|
||||
logger.warn(
|
||||
`getTeamData team=${teamName} slow total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince(
|
||||
'inboxNames'
|
||||
)} messages=${msSince('messages')} leadTexts=${msSince('leadTexts')} sent=${msSince(
|
||||
'sentMessages'
|
||||
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince(
|
||||
'kanbanGc'
|
||||
)} post=${msBetween(
|
||||
'postStart',
|
||||
'mergeMessages'
|
||||
)}/dedupLead=${msBetween('mergeMessages', 'dedupLeadTexts')}/dedupIds=${msBetween(
|
||||
'dedupLeadTexts',
|
||||
'dedupMessageIds'
|
||||
)}/attachLeadSession=${msBetween(
|
||||
'dedupMessageIds',
|
||||
'attachLeadSessionIds'
|
||||
)}/normalizeMessages=${msBetween(
|
||||
'attachLeadSessionIds',
|
||||
'normalizeMessages'
|
||||
)}/attachKanban=${msBetween(
|
||||
'normalizeMessages',
|
||||
'attachKanban'
|
||||
)}/loadPresenceIndex=${msBetween(
|
||||
)} post=${msBetween('postStart', 'attachKanban')}/loadPresenceIndex=${msBetween(
|
||||
'attachKanban',
|
||||
'loadPresenceIndex'
|
||||
)}/changePresence=${msBetween(
|
||||
|
|
@ -1088,21 +892,14 @@ export class TeamDataService {
|
|||
this.processHealthTeams.delete(teamName);
|
||||
}
|
||||
|
||||
// Cap messages to keep IPC payloads small. Full history is available
|
||||
// via the paginated getMessagesPage() API. We still include a small
|
||||
// batch here for backward compatibility (notifications, dedup, etc.).
|
||||
const MAX_RETURN_MESSAGES = 50;
|
||||
const cappedMessages =
|
||||
messages.length > MAX_RETURN_MESSAGES ? messages.slice(0, MAX_RETURN_MESSAGES) : messages;
|
||||
|
||||
return {
|
||||
teamName,
|
||||
config,
|
||||
tasks: tasksWithKanban,
|
||||
members,
|
||||
messages: cappedMessages,
|
||||
kanbanState,
|
||||
processes,
|
||||
isAlive: hasAlive,
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
}
|
||||
|
|
@ -1113,106 +910,45 @@ export class TeamDataService {
|
|||
*/
|
||||
async getMessagesPage(
|
||||
teamName: string,
|
||||
options: { beforeTimestamp?: string; limit: number }
|
||||
options: { cursor?: string | null; limit: number }
|
||||
): Promise<MessagesPage> {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
if (!config) {
|
||||
return { messages: [], nextCursor: null, hasMore: false };
|
||||
}
|
||||
const feed = await this.messageFeedService.getFeed(teamName);
|
||||
let messages = feed.messages;
|
||||
|
||||
// Collect all messages from the same sources as getTeamData
|
||||
let messages: InboxMessage[] = [];
|
||||
|
||||
const [inboxMessages, leadTexts, sentMessages] = await Promise.all([
|
||||
this.inboxReader.getMessages(teamName).catch(() => [] as InboxMessage[]),
|
||||
this.extractLeadSessionTexts(config).catch(() => [] as InboxMessage[]),
|
||||
this.sentMessagesStore.readMessages(teamName).catch(() => [] as InboxMessage[]),
|
||||
]);
|
||||
|
||||
messages = [...inboxMessages, ...leadTexts, ...sentMessages];
|
||||
|
||||
// Dedup lead_session vs lead_process (same logic as getTeamData)
|
||||
if (leadTexts.length > 0) {
|
||||
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
|
||||
const getFingerprint = (msg: Pick<InboxMessage, 'from' | 'text' | 'leadSessionId'>) =>
|
||||
`${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`;
|
||||
const leadSessionFingerprints = new Set<string>();
|
||||
for (const msg of leadTexts) {
|
||||
if (msg.source === 'lead_session') leadSessionFingerprints.add(getFingerprint(msg));
|
||||
}
|
||||
messages = messages.filter((m) => {
|
||||
if (m.source !== 'lead_process') return true;
|
||||
if (m.to) return true;
|
||||
return !leadSessionFingerprints.has(getFingerprint(m));
|
||||
});
|
||||
}
|
||||
|
||||
// Enrich: propagate leadSessionId to messages missing it (same as getTeamData)
|
||||
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
|
||||
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
||||
const anchors: { time: number; sessionId: string }[] = [];
|
||||
for (const msg of messages) {
|
||||
if (msg.leadSessionId) {
|
||||
anchors.push({ time: Date.parse(msg.timestamp), sessionId: msg.leadSessionId });
|
||||
}
|
||||
}
|
||||
if (anchors.length > 0) {
|
||||
for (const msg of messages) {
|
||||
if (msg.leadSessionId) continue;
|
||||
const msgTime = Date.parse(msg.timestamp);
|
||||
let best = anchors[0];
|
||||
let bestDist = Math.abs(msgTime - best.time);
|
||||
for (const a of anchors) {
|
||||
const dist = Math.abs(msgTime - a.time);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
best = a;
|
||||
} else if (dist > bestDist && a.time > msgTime) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
msg.leadSessionId = best.sessionId;
|
||||
}
|
||||
} else if (config.leadSessionId) {
|
||||
for (const msg of messages) {
|
||||
msg.leadSessionId = config.leadSessionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich: annotate slash command responses
|
||||
this.annotateSlashCommandResponses(messages);
|
||||
|
||||
// Sort newest-first, with stable tie-breaker by messageId
|
||||
messages.sort((a, b) => {
|
||||
const diff = Date.parse(b.timestamp) - Date.parse(a.timestamp);
|
||||
if (diff !== 0) return diff;
|
||||
return (a.messageId ?? '').localeCompare(b.messageId ?? '');
|
||||
});
|
||||
|
||||
// Apply cursor filter. Cursor format: "timestamp|messageId" (compound)
|
||||
// to handle multiple messages sharing the same timestamp.
|
||||
if (options.beforeTimestamp) {
|
||||
const [cursorTs, cursorId] = options.beforeTimestamp.split('|');
|
||||
if (options.cursor) {
|
||||
const [cursorTs, cursorId] = options.cursor.split('|');
|
||||
const cursorMs = Date.parse(cursorTs);
|
||||
messages = messages.filter((m) => {
|
||||
const ms = Date.parse(m.timestamp);
|
||||
if (ms < cursorMs) return true;
|
||||
if (ms > cursorMs) return false;
|
||||
// Same timestamp — use messageId tie-breaker
|
||||
if (!cursorId) return false;
|
||||
return (m.messageId ?? '').localeCompare(cursorId) > 0;
|
||||
return requireCanonicalMessageId(m).localeCompare(cursorId) > 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Paginate
|
||||
const hasMore = messages.length > options.limit;
|
||||
const page = messages.slice(0, options.limit);
|
||||
const lastMsg = page[page.length - 1];
|
||||
const nextCursor =
|
||||
hasMore && lastMsg ? `${lastMsg.timestamp}|${lastMsg.messageId ?? ''}` : null;
|
||||
hasMore && lastMsg ? `${lastMsg.timestamp}|${requireCanonicalMessageId(lastMsg)}` : null;
|
||||
|
||||
return { messages: page, nextCursor, hasMore };
|
||||
return { messages: page, nextCursor, hasMore, feedRevision: feed.feedRevision };
|
||||
}
|
||||
|
||||
async getMessageFeed(
|
||||
teamName: string
|
||||
): Promise<{ teamName: string; feedRevision: string; messages: InboxMessage[] }> {
|
||||
return this.messageFeedService.getFeed(teamName);
|
||||
}
|
||||
|
||||
async getMemberActivityMeta(teamName: string): Promise<TeamMemberActivityMeta> {
|
||||
return this.memberActivityMetaService.getMeta(teamName);
|
||||
}
|
||||
|
||||
invalidateMessageFeed(teamName: string): void {
|
||||
this.messageFeedService.invalidate(teamName);
|
||||
this.memberActivityMetaService.invalidate(teamName);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1220,7 +956,7 @@ export class TeamDataService {
|
|||
* Mutates members in-place for efficiency (called right after resolveMembers).
|
||||
*/
|
||||
private async enrichMemberBranches(
|
||||
members: ResolvedTeamMember[],
|
||||
members: TeamViewSnapshot['members'],
|
||||
config: TeamConfig
|
||||
): Promise<void> {
|
||||
const leadEntry = config.members?.find((member) => isLeadMember(member));
|
||||
|
|
@ -1892,7 +1628,7 @@ export class TeamDataService {
|
|||
slashCommand: slashCommandMeta,
|
||||
};
|
||||
}
|
||||
return this.getController(teamName).messages.sendMessage({
|
||||
const result = this.getController(teamName).messages.sendMessage({
|
||||
member: enrichedRequest.member,
|
||||
from: enrichedRequest.from,
|
||||
text: enrichedRequest.text,
|
||||
|
|
@ -1913,6 +1649,8 @@ export class TeamDataService {
|
|||
leadSessionId: enrichedRequest.leadSessionId,
|
||||
attachments: enrichedRequest.attachments,
|
||||
}) as SendMessageResult;
|
||||
this.invalidateMessageFeed(teamName);
|
||||
return result;
|
||||
}
|
||||
|
||||
private resolveLeadNameFromConfig(config: TeamConfig | null): string {
|
||||
|
|
@ -2469,6 +2207,23 @@ export class TeamDataService {
|
|||
}
|
||||
}
|
||||
|
||||
async getTeamNotificationContext(teamName: string): Promise<{
|
||||
displayName: string;
|
||||
projectPath?: string;
|
||||
}> {
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const displayName = config?.name?.trim() || teamName;
|
||||
const projectPath =
|
||||
typeof config?.projectPath === 'string' && config.projectPath.trim().length > 0
|
||||
? config.projectPath
|
||||
: undefined;
|
||||
return { displayName, projectPath };
|
||||
} catch {
|
||||
return { displayName: teamName };
|
||||
}
|
||||
}
|
||||
|
||||
async requestReview(teamName: string, taskId: string): Promise<void> {
|
||||
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||
this.getController(teamName).review.requestReview(taskId, {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,12 @@ import { Worker } from 'node:worker_threads';
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { TeamDataWorkerRequest, TeamDataWorkerResponse } from './teamDataWorkerTypes';
|
||||
import type { MemberLogSummary, TeamData } from '@shared/types';
|
||||
import type {
|
||||
MemberLogSummary,
|
||||
MessagesPage,
|
||||
TeamMemberActivityMeta,
|
||||
TeamViewSnapshot,
|
||||
} from '@shared/types';
|
||||
|
||||
const logger = createLogger('Service:TeamDataWorkerClient');
|
||||
const WORKER_CALL_TIMEOUT_MS = 30_000;
|
||||
|
|
@ -25,16 +30,20 @@ function makeId(): string {
|
|||
return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`;
|
||||
}
|
||||
|
||||
function resolveWorkerPath(): string | null {
|
||||
function getWorkerPathCandidates(): string[] {
|
||||
const baseDir =
|
||||
typeof __dirname === 'string' && __dirname.length > 0
|
||||
? __dirname
|
||||
: path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const candidates = [
|
||||
return [
|
||||
path.join(baseDir, 'team-data-worker.cjs'),
|
||||
path.join(process.cwd(), 'dist-electron', 'main', 'team-data-worker.cjs'),
|
||||
];
|
||||
}
|
||||
|
||||
function resolveWorkerPath(): string | null {
|
||||
const candidates = getWorkerPathCandidates();
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
|
|
@ -75,7 +84,9 @@ export class TeamDataWorkerClient {
|
|||
isAvailable(): boolean {
|
||||
if (!this.workerPath && !this.warnedUnavailable) {
|
||||
this.warnedUnavailable = true;
|
||||
logger.debug('team-data-worker not found; falling back to main-thread execution');
|
||||
logger.warn(
|
||||
`team-data-worker not found; heavy team data paths may fall back to main-thread execution. expectedOneOf=${getWorkerPathCandidates().join(',')}`
|
||||
);
|
||||
}
|
||||
return this.workerPath !== null;
|
||||
}
|
||||
|
|
@ -144,9 +155,22 @@ export class TeamDataWorkerClient {
|
|||
});
|
||||
}
|
||||
|
||||
async getTeamData(teamName: string): Promise<TeamData> {
|
||||
async getTeamData(teamName: string): Promise<TeamViewSnapshot> {
|
||||
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
|
||||
return this.call('getTeamData', { teamName }) as Promise<TeamData>;
|
||||
return this.call('getTeamData', { teamName }) as Promise<TeamViewSnapshot>;
|
||||
}
|
||||
|
||||
async getMessagesPage(
|
||||
teamName: string,
|
||||
options: { cursor?: string | null; limit: number }
|
||||
): Promise<MessagesPage> {
|
||||
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
|
||||
return this.call('getMessagesPage', { teamName, options }) as Promise<MessagesPage>;
|
||||
}
|
||||
|
||||
async getMemberActivityMeta(teamName: string): Promise<TeamMemberActivityMeta> {
|
||||
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
|
||||
return this.call('getMemberActivityMeta', { teamName }) as Promise<TeamMemberActivityMeta>;
|
||||
}
|
||||
|
||||
async findLogsForTask(
|
||||
|
|
|
|||
|
|
@ -3,15 +3,9 @@ import {
|
|||
createCliProvisionerNameGuard,
|
||||
} from '@shared/utils/teamMemberName';
|
||||
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
|
||||
import type {
|
||||
InboxMessage,
|
||||
MemberStatus,
|
||||
ResolvedTeamMember,
|
||||
TeamConfig,
|
||||
TeamMember,
|
||||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
import type { TeamConfig, TeamMember, TeamMemberSnapshot, TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
|
||||
|
|
@ -63,9 +57,8 @@ export class TeamMemberResolver {
|
|||
config: TeamConfig,
|
||||
metaMembers: TeamConfig['members'],
|
||||
inboxNames: string[],
|
||||
tasks: TeamTaskWithKanban[],
|
||||
messages: InboxMessage[]
|
||||
): ResolvedTeamMember[] {
|
||||
tasks: TeamTaskWithKanban[]
|
||||
): TeamMemberSnapshot[] {
|
||||
const names = new Set<string>();
|
||||
const explicitNames = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
|
|
@ -216,7 +209,7 @@ export class TeamMemberResolver {
|
|||
}
|
||||
}
|
||||
|
||||
const members: ResolvedTeamMember[] = [];
|
||||
const members: TeamMemberSnapshot[] = [];
|
||||
for (const name of names) {
|
||||
const ownedTasks = tasks.filter((task) => task.owner === name);
|
||||
const currentTask =
|
||||
|
|
@ -226,21 +219,15 @@ export class TeamMemberResolver {
|
|||
task.reviewState !== 'approved' &&
|
||||
task.kanbanColumn !== 'approved'
|
||||
) ?? null;
|
||||
const memberMessages = messages.filter((message) => message.from === name);
|
||||
const latestMessage = memberMessages[0] ?? null;
|
||||
const status = this.resolveStatus(latestMessage, currentTask !== null);
|
||||
const configMember = configMemberMap.get(name);
|
||||
const metaMember = metaMemberMap.get(name);
|
||||
const agentId = configMember?.agentId ?? metaMember?.agentId;
|
||||
members.push({
|
||||
name,
|
||||
agentId,
|
||||
status,
|
||||
currentTaskId: currentTask?.id ?? null,
|
||||
taskCount: ownedTasks.length,
|
||||
messageCount: memberMessages.length,
|
||||
lastActiveAt: latestMessage?.timestamp ?? null,
|
||||
color: latestMessage?.color ?? configMember?.color ?? metaMember?.color,
|
||||
color: configMember?.color ?? metaMember?.color ?? getMemberColorByName(name),
|
||||
agentType: configMember?.agentType ?? metaMember?.agentType,
|
||||
role: configMember?.role ?? metaMember?.role,
|
||||
workflow: configMember?.workflow ?? metaMember?.workflow,
|
||||
|
|
@ -277,45 +264,4 @@ export class TeamMemberResolver {
|
|||
});
|
||||
return members;
|
||||
}
|
||||
|
||||
private resolveStatus(message: InboxMessage | null, hasActiveTask: boolean): MemberStatus {
|
||||
if (!message) {
|
||||
// Member exists in config but has no messages yet —
|
||||
// if they own an in_progress task they're clearly active, otherwise idle
|
||||
return hasActiveTask ? 'active' : 'idle';
|
||||
}
|
||||
|
||||
const structured = this.parseStructuredMessage(message.text);
|
||||
if (structured) {
|
||||
const typed = structured as { type?: string; approve?: boolean; approved?: boolean };
|
||||
if (
|
||||
(typed.type === 'shutdown_response' &&
|
||||
(typed.approve === true || typed.approved === true)) ||
|
||||
typed.type === 'shutdown_approved'
|
||||
) {
|
||||
return 'terminated';
|
||||
}
|
||||
}
|
||||
|
||||
const ageMs = Date.now() - Date.parse(message.timestamp);
|
||||
if (Number.isNaN(ageMs)) {
|
||||
return 'unknown';
|
||||
}
|
||||
if (ageMs < 5 * 60 * 1000) {
|
||||
return 'active';
|
||||
}
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
private parseStructuredMessage(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// Ignore plain text.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
409
src/main/services/team/TeamMessageFeedService.ts
Normal file
409
src/main/services/team/TeamMessageFeedService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,12 @@
|
|||
* Shared request/response types for the team-data-worker thread.
|
||||
*/
|
||||
|
||||
import type { MemberLogSummary, TeamData } from '@shared/types';
|
||||
import type {
|
||||
MemberLogSummary,
|
||||
MessagesPage,
|
||||
TeamMemberActivityMeta,
|
||||
TeamViewSnapshot,
|
||||
} from '@shared/types';
|
||||
|
||||
// ── Payloads ──
|
||||
|
||||
|
|
@ -10,6 +15,18 @@ export interface GetTeamDataPayload {
|
|||
teamName: string;
|
||||
}
|
||||
|
||||
export interface GetMessagesPagePayload {
|
||||
teamName: string;
|
||||
options: {
|
||||
cursor?: string | null;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetMemberActivityMetaPayload {
|
||||
teamName: string;
|
||||
}
|
||||
|
||||
export interface FindLogsForTaskPayload {
|
||||
teamName: string;
|
||||
taskId: string;
|
||||
|
|
@ -25,8 +42,14 @@ export interface FindLogsForTaskPayload {
|
|||
|
||||
export type TeamDataWorkerRequest =
|
||||
| { id: string; op: 'getTeamData'; payload: GetTeamDataPayload }
|
||||
| { id: string; op: 'getMessagesPage'; payload: GetMessagesPagePayload }
|
||||
| { id: string; op: 'getMemberActivityMeta'; payload: GetMemberActivityMetaPayload }
|
||||
| { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload };
|
||||
|
||||
export type TeamDataWorkerResponse =
|
||||
| { id: string; ok: true; result: TeamData | MemberLogSummary[] }
|
||||
| {
|
||||
id: string;
|
||||
ok: true;
|
||||
result: TeamViewSnapshot | MessagesPage | TeamMemberActivityMeta | MemberLogSummary[];
|
||||
}
|
||||
| { id: string; ok: false; error: string };
|
||||
|
|
|
|||
|
|
@ -42,6 +42,19 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => {
|
|||
respond({ id: msg.id, ok: true, result });
|
||||
break;
|
||||
}
|
||||
case 'getMessagesPage': {
|
||||
const result = await teamDataService.getMessagesPage(
|
||||
msg.payload.teamName,
|
||||
msg.payload.options
|
||||
);
|
||||
respond({ id: msg.id, ok: true, result });
|
||||
break;
|
||||
}
|
||||
case 'getMemberActivityMeta': {
|
||||
const result = await teamDataService.getMemberActivityMeta(msg.payload.teamName);
|
||||
respond({ id: msg.id, ok: true, result });
|
||||
break;
|
||||
}
|
||||
case 'findLogsForTask': {
|
||||
const { teamName, taskId, options } = msg.payload;
|
||||
const intervalsKey = options?.intervals
|
||||
|
|
|
|||
|
|
@ -234,6 +234,9 @@ export const TEAM_SEND_MESSAGE = 'team:sendMessage';
|
|||
/** Paginated messages for timeline/messages panel */
|
||||
export const TEAM_GET_MESSAGES_PAGE = 'team:getMessagesPage';
|
||||
|
||||
/** Lightweight message-derived member activity facts */
|
||||
export const TEAM_GET_MEMBER_ACTIVITY_META = 'team:getMemberActivityMeta';
|
||||
|
||||
/** Request review for task */
|
||||
export const TEAM_REQUEST_REVIEW = 'team:requestReview';
|
||||
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ import {
|
|||
TEAM_GET_DATA,
|
||||
TEAM_GET_DELETED_TASKS,
|
||||
TEAM_GET_LOGS_FOR_TASK,
|
||||
TEAM_GET_MEMBER_ACTIVITY_META,
|
||||
TEAM_GET_MEMBER_LOGS,
|
||||
TEAM_GET_MEMBER_STATS,
|
||||
TEAM_GET_MESSAGES_PAGE,
|
||||
|
|
@ -263,6 +264,7 @@ import type {
|
|||
LeadContextUsageSnapshot,
|
||||
MemberFullStats,
|
||||
MemberLogSummary,
|
||||
TeamMemberActivityMeta,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
MessagesPage,
|
||||
NotificationTrigger,
|
||||
|
|
@ -292,10 +294,10 @@ import type {
|
|||
TeamCreateConfigRequest,
|
||||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
TeamData,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamMessageNotificationData,
|
||||
TeamViewSnapshot,
|
||||
TeamProvisioningPrepareResult,
|
||||
TeamProvisioningProgress,
|
||||
TeamSummary,
|
||||
|
|
@ -822,7 +824,7 @@ const electronAPI: ElectronAPI = {
|
|||
return invokeIpcWithResult<TeamSummary[]>(TEAM_LIST);
|
||||
},
|
||||
getData: async (teamName: string) => {
|
||||
return invokeIpcWithResult<TeamData>(TEAM_GET_DATA, teamName);
|
||||
return invokeIpcWithResult<TeamViewSnapshot>(TEAM_GET_DATA, teamName);
|
||||
},
|
||||
getTaskChangePresence: async (teamName: string) => {
|
||||
return invokeIpcWithResult<Record<string, TaskChangePresenceState>>(
|
||||
|
|
@ -883,10 +885,13 @@ const electronAPI: ElectronAPI = {
|
|||
},
|
||||
getMessagesPage: async (
|
||||
teamName: string,
|
||||
options?: { beforeTimestamp?: string; limit?: number }
|
||||
options?: { cursor?: string | null; limit?: number }
|
||||
) => {
|
||||
return invokeIpcWithResult<MessagesPage>(TEAM_GET_MESSAGES_PAGE, teamName, options);
|
||||
},
|
||||
getMemberActivityMeta: async (teamName: string) => {
|
||||
return invokeIpcWithResult<TeamMemberActivityMeta>(TEAM_GET_MEMBER_ACTIVITY_META, teamName);
|
||||
},
|
||||
createTask: async (teamName: string, request: CreateTaskRequest) => {
|
||||
return invokeIpcWithResult<TeamTask>(TEAM_CREATE_TASK, teamName, request);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -54,25 +54,26 @@ import type {
|
|||
SshLastConnection,
|
||||
SubagentDetail,
|
||||
TeamChangeEvent,
|
||||
UpdateSchedulePatch,
|
||||
TeamClaudeLogsQuery,
|
||||
TeamClaudeLogsResponse,
|
||||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
TeamData,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamMemberActivityMeta,
|
||||
TeamProvisioningPrepareResult,
|
||||
TeamProvisioningProgress,
|
||||
TeamsAPI,
|
||||
TeamSummary,
|
||||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
TeamViewSnapshot,
|
||||
TmuxAPI,
|
||||
TmuxStatus,
|
||||
TriggerTestResult,
|
||||
UpdateKanbanPatch,
|
||||
UpdaterAPI,
|
||||
UpdateSchedulePatch,
|
||||
WaterfallData,
|
||||
WslClaudeRootCandidate,
|
||||
} from '@shared/types';
|
||||
|
|
@ -677,7 +678,7 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
console.warn('[HttpAPIClient] teams API is not available in browser mode');
|
||||
return [];
|
||||
},
|
||||
getData: async (_teamName: string): Promise<TeamData> => {
|
||||
getData: async (_teamName: string): Promise<TeamViewSnapshot> => {
|
||||
throw new Error('Teams detail is not available in browser mode');
|
||||
},
|
||||
getTaskChangePresence: async (): Promise<
|
||||
|
|
@ -740,7 +741,15 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
throw new Error('Team messaging is not available in browser mode');
|
||||
},
|
||||
getMessagesPage: async () => {
|
||||
return { messages: [], nextCursor: null, hasMore: false };
|
||||
return { messages: [], nextCursor: null, hasMore: false, feedRevision: 'empty' };
|
||||
},
|
||||
getMemberActivityMeta: async (_teamName: string): Promise<TeamMemberActivityMeta> => {
|
||||
return {
|
||||
teamName: _teamName,
|
||||
computedAt: new Date(0).toISOString(),
|
||||
members: {},
|
||||
feedRevision: 'empty',
|
||||
};
|
||||
},
|
||||
createTask: async (_teamName: string, _request: CreateTaskRequest): Promise<TeamTask> => {
|
||||
throw new Error('Team task creation is not available in browser mode');
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
|
|||
import { useTabUI } from '@renderer/hooks/useTabUI';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
||||
|
|
@ -398,7 +399,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
|
|||
// Get team members for @mention highlighting and team names for @team linkification
|
||||
const { members, teams } = useStore(
|
||||
useShallow((s) => ({
|
||||
members: s.selectedTeamData?.members,
|
||||
members: selectResolvedMembersForTeamName(s, s.selectedTeamName),
|
||||
teams: s.teams,
|
||||
}))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
|
||||
import { formatTokensCompact } from '@renderer/utils/formatters';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
|
|
@ -86,7 +87,9 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
|
|||
const { isLight } = useTheme();
|
||||
|
||||
// Get team members for @mention highlighting
|
||||
const members = useStore(useShallow((s) => s.selectedTeamData?.members));
|
||||
const members = useStore(
|
||||
useShallow((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName))
|
||||
);
|
||||
const memberColorMap = useMemo(
|
||||
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
||||
[members]
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer
|
|||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
||||
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
|
||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||
|
|
@ -70,14 +71,16 @@ export const TaskTooltip = ({
|
|||
children,
|
||||
side = 'top',
|
||||
}: TaskTooltipProps): React.JSX.Element => {
|
||||
const { selectedTeamName, selectedTeamData, globalTasks, teamByName } = useStore(
|
||||
useShallow((s) => ({
|
||||
selectedTeamName: s.selectedTeamName,
|
||||
selectedTeamData: s.selectedTeamData,
|
||||
globalTasks: s.globalTasks,
|
||||
teamByName: s.teamByName,
|
||||
}))
|
||||
);
|
||||
const { selectedTeamName, selectedTeamData, selectedTeamMembers, globalTasks, teamByName } =
|
||||
useStore(
|
||||
useShallow((s) => ({
|
||||
selectedTeamName: s.selectedTeamName,
|
||||
selectedTeamData: s.selectedTeamData,
|
||||
selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName),
|
||||
globalTasks: s.globalTasks,
|
||||
teamByName: s.teamByName,
|
||||
}))
|
||||
);
|
||||
|
||||
const task = useMemo(() => {
|
||||
if (teamName && selectedTeamName === teamName) {
|
||||
|
|
@ -105,13 +108,13 @@ export const TaskTooltip = ({
|
|||
|
||||
const members = useMemo(() => {
|
||||
if (teamName && selectedTeamName === teamName) {
|
||||
return selectedTeamData?.members ?? [];
|
||||
return selectedTeamMembers;
|
||||
}
|
||||
if (!teamName && task && selectedTeamName === (task as { teamName?: string }).teamName) {
|
||||
return selectedTeamData?.members ?? [];
|
||||
return selectedTeamMembers;
|
||||
}
|
||||
return [];
|
||||
}, [selectedTeamData, selectedTeamName, teamName, task]);
|
||||
}, [selectedTeamMembers, selectedTeamName, teamName, task]);
|
||||
|
||||
const colorMap = useMemo(
|
||||
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ import { useStore } from '@renderer/store';
|
|||
import {
|
||||
getCurrentProvisioningProgressForTeam,
|
||||
isTeamProvisioningActive,
|
||||
selectResolvedMemberForTeamName,
|
||||
selectResolvedMembersForTeamName,
|
||||
selectTeamMemberSnapshotsForName,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
||||
import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath';
|
||||
|
|
@ -740,16 +743,18 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
|||
}: TeamMemberDetailDialogBridgeProps): React.JSX.Element | null {
|
||||
const {
|
||||
leadActivity,
|
||||
liveMember,
|
||||
progress,
|
||||
members: launchMembers,
|
||||
launchMembers,
|
||||
memberSpawnStatuses,
|
||||
memberSpawnSnapshot,
|
||||
spawnEntry,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
leadActivity: s.leadActivityByTeam[teamName],
|
||||
liveMember: member ? selectResolvedMemberForTeamName(s, teamName, member.name) : null,
|
||||
progress: getCurrentProvisioningProgressForTeam(s, teamName),
|
||||
members: s.selectedTeamName === teamName ? (s.selectedTeamData?.members ?? []) : [],
|
||||
launchMembers: selectTeamMemberSnapshotsForName(s, teamName),
|
||||
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
||||
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
||||
spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined,
|
||||
|
|
@ -772,7 +777,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
|||
<MemberDetailDialog
|
||||
{...props}
|
||||
teamName={teamName}
|
||||
member={member}
|
||||
member={liveMember ?? member}
|
||||
isLaunchSettling={isLaunchSettling}
|
||||
leadActivity={leadActivity}
|
||||
spawnEntry={spawnEntry}
|
||||
|
|
@ -821,7 +826,6 @@ export const TeamDetailView = ({
|
|||
);
|
||||
const provisioningBannerRef = useRef<HTMLDivElement>(null);
|
||||
const wasProvisioningRef = useRef(false);
|
||||
const pendingReplyRefreshTimerRef = useRef<number | null>(null);
|
||||
const handleOpenGraphTab = useCallback(() => {
|
||||
const state = useStore.getState();
|
||||
const displayName = state.teamByName[teamName]?.displayName ?? teamName;
|
||||
|
|
@ -898,7 +902,7 @@ export const TeamDetailView = ({
|
|||
initialActivityFilter,
|
||||
} = (e as CustomEvent).detail ?? {};
|
||||
if (tn !== teamName || !data) return;
|
||||
const member = data.members.find((m: { name: string }) => m.name === memberName);
|
||||
const member = members.find((m: { name: string }) => m.name === memberName);
|
||||
if (member) {
|
||||
setSelectedMember(member);
|
||||
setSelectedMemberView({
|
||||
|
|
@ -1059,6 +1063,7 @@ export const TeamDetailView = ({
|
|||
|
||||
const {
|
||||
data,
|
||||
members,
|
||||
loading,
|
||||
error,
|
||||
projects,
|
||||
|
|
@ -1088,6 +1093,7 @@ export const TeamDetailView = ({
|
|||
clearProvisioningError,
|
||||
isTeamProvisioning,
|
||||
refreshTeamData,
|
||||
syncTeamPendingReplyRefresh,
|
||||
kanbanFilterQuery,
|
||||
clearKanbanFilter,
|
||||
softDeleteTask,
|
||||
|
|
@ -1133,9 +1139,11 @@ export const TeamDetailView = ({
|
|||
clearProvisioningError: s.clearProvisioningError,
|
||||
isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false,
|
||||
data: s.selectedTeamName === teamName ? s.selectedTeamData : null,
|
||||
members: selectResolvedMembersForTeamName(s, teamName),
|
||||
loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false,
|
||||
error: s.selectedTeamName === teamName ? s.selectedTeamError : null,
|
||||
refreshTeamData: s.refreshTeamData,
|
||||
syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh,
|
||||
kanbanFilterQuery: s.kanbanFilterQuery,
|
||||
clearKanbanFilter: s.clearKanbanFilter,
|
||||
softDeleteTask: s.softDeleteTask,
|
||||
|
|
@ -1169,13 +1177,12 @@ export const TeamDetailView = ({
|
|||
diagnostic.count += 1;
|
||||
|
||||
const commitMs = performance.now() - renderStartedAtRef.current;
|
||||
const messagesCount = data?.messages.length ?? 0;
|
||||
const tasksCount = data?.tasks.length ?? 0;
|
||||
const membersCount = data?.members.length ?? 0;
|
||||
const membersCount = members.length;
|
||||
const processesCount = data?.processes.length ?? 0;
|
||||
const shouldWarnSlow = commitMs >= TEAM_DETAIL_COMMIT_WARN_MS;
|
||||
const shouldWarnBurst = diagnostic.count >= TEAM_DETAIL_RENDER_BURST_WARN_COUNT;
|
||||
const shouldWarnLarge = messagesCount >= 150 || tasksCount >= 80;
|
||||
const shouldWarnLarge = tasksCount >= 80;
|
||||
|
||||
if (
|
||||
(shouldWarnSlow || shouldWarnBurst || shouldWarnLarge) &&
|
||||
|
|
@ -1187,7 +1194,7 @@ export const TeamDetailView = ({
|
|||
now - diagnostic.windowStartedAt
|
||||
} activeTab=${isThisTabActive ? 'yes' : 'no'} paneFocused=${isPaneFocused ? 'yes' : 'no'} loading=${
|
||||
loading ? 'yes' : 'no'
|
||||
} messages=${messagesCount} tasks=${tasksCount} members=${membersCount} processes=${processesCount} panel=${messagesPanelMode}`
|
||||
} tasks=${tasksCount} members=${membersCount} processes=${processesCount} panel=${messagesPanelMode}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -1307,30 +1314,20 @@ export const TeamDetailView = ({
|
|||
);
|
||||
|
||||
// Keep team message state fresh while we are explicitly waiting for a reply.
|
||||
// Use a delayed single-shot refresh instead of a tight polling loop so we
|
||||
// don't keep rewriting the whole team snapshot every 2 seconds.
|
||||
// This stays enabled even for hidden mounted tabs, because the waiting state
|
||||
// is renderer-local and should keep its lightweight polling until resolved.
|
||||
useEffect(() => {
|
||||
if (pendingReplyRefreshTimerRef.current != null) {
|
||||
window.clearTimeout(pendingReplyRefreshTimerRef.current);
|
||||
pendingReplyRefreshTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (!isThisTabActive) return;
|
||||
if (!data?.isAlive) return;
|
||||
if (Object.keys(pendingRepliesByMember).length === 0) return;
|
||||
|
||||
pendingReplyRefreshTimerRef.current = window.setTimeout(() => {
|
||||
pendingReplyRefreshTimerRef.current = null;
|
||||
void refreshTeamData(teamName, { withDedup: true });
|
||||
}, TEAM_PENDING_REPLY_REFRESH_DELAY_MS);
|
||||
const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0;
|
||||
syncTeamPendingReplyRefresh(
|
||||
teamName,
|
||||
Boolean(data?.isAlive) && hasPendingReplies,
|
||||
TEAM_PENDING_REPLY_REFRESH_DELAY_MS
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (pendingReplyRefreshTimerRef.current != null) {
|
||||
window.clearTimeout(pendingReplyRefreshTimerRef.current);
|
||||
pendingReplyRefreshTimerRef.current = null;
|
||||
}
|
||||
syncTeamPendingReplyRefresh(teamName, false);
|
||||
};
|
||||
}, [isThisTabActive, data, pendingRepliesByMember, refreshTeamData, teamName]);
|
||||
}, [data?.isAlive, pendingRepliesByMember, syncTeamPendingReplyRefresh, teamName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
|
|
@ -1364,9 +1361,9 @@ export const TeamDetailView = ({
|
|||
// Live git branch tracking for the lead project and member worktrees
|
||||
const teamProjectPath = data?.config.projectPath?.trim() ?? null;
|
||||
const leadProjectPath = useMemo(() => {
|
||||
const explicitLeadPath = data?.members.find((member) => isLeadMember(member))?.cwd?.trim();
|
||||
const explicitLeadPath = members.find((member) => isLeadMember(member))?.cwd?.trim();
|
||||
return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath;
|
||||
}, [data?.members, teamProjectPath]);
|
||||
}, [members, teamProjectPath]);
|
||||
const branchSyncPaths = useMemo(() => {
|
||||
const uniquePaths = new Map<string, string>();
|
||||
const addPath = (candidate: string | null | undefined): void => {
|
||||
|
|
@ -1378,12 +1375,12 @@ export const TeamDetailView = ({
|
|||
};
|
||||
|
||||
addPath(leadProjectPath);
|
||||
for (const member of data?.members ?? []) {
|
||||
for (const member of members) {
|
||||
addPath(member.cwd);
|
||||
}
|
||||
|
||||
return Array.from(uniquePaths.values());
|
||||
}, [data?.members, leadProjectPath]);
|
||||
}, [members, leadProjectPath]);
|
||||
useBranchSync(branchSyncPaths, { live: true });
|
||||
const trackedBranches = useStore(
|
||||
useShallow((s) =>
|
||||
|
|
@ -1401,7 +1398,7 @@ export const TeamDetailView = ({
|
|||
const membersWithLiveBranches = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
return data.members.map((member) => {
|
||||
return members.map((member) => {
|
||||
const memberPath = member.cwd?.trim();
|
||||
const nextGitBranch =
|
||||
memberPath && !isLeadMember(member) && leadBranch !== null
|
||||
|
|
@ -1423,7 +1420,7 @@ export const TeamDetailView = ({
|
|||
}
|
||||
return nextMember;
|
||||
});
|
||||
}, [data, leadBranch, trackedBranches]);
|
||||
}, [leadBranch, members, trackedBranches]);
|
||||
|
||||
// Filter sessions to team-only using sessionHistory + leadSessionId
|
||||
const teamSessionIds = useMemo(() => {
|
||||
|
|
@ -1787,7 +1784,6 @@ export const TeamDetailView = ({
|
|||
mountPoint: messagesPanelMountPoint,
|
||||
members: activeMembers,
|
||||
tasks: data?.tasks ?? [],
|
||||
messages: data?.messages ?? [],
|
||||
isTeamAlive: data?.isAlive,
|
||||
timeWindow,
|
||||
teamSessionIds,
|
||||
|
|
@ -1805,7 +1801,6 @@ export const TeamDetailView = ({
|
|||
activeMembers,
|
||||
data?.config.leadSessionId,
|
||||
data?.isAlive,
|
||||
data?.messages,
|
||||
data?.tasks,
|
||||
handleCreateTaskFromMessage,
|
||||
handleOpenTask,
|
||||
|
|
@ -2482,7 +2477,7 @@ export const TeamDetailView = ({
|
|||
open={requestChangesTaskId !== null}
|
||||
teamName={teamName}
|
||||
taskId={requestChangesTaskId}
|
||||
members={data?.members ?? []}
|
||||
members={members}
|
||||
onCancel={() => setRequestChangesTaskId(null)}
|
||||
onSubmit={(comment, taskRefs) => {
|
||||
if (!requestChangesTaskId) {
|
||||
|
|
@ -2509,7 +2504,6 @@ export const TeamDetailView = ({
|
|||
teamName={teamName}
|
||||
members={membersWithLiveBranches}
|
||||
tasks={data.tasks}
|
||||
messages={data.messages}
|
||||
initialTab={selectedMemberView?.initialTab}
|
||||
initialActivityFilter={selectedMemberView?.initialActivityFilter}
|
||||
isTeamAlive={data.isAlive}
|
||||
|
|
@ -2858,7 +2852,7 @@ export const TeamDetailView = ({
|
|||
if (task) setSelectedTask(task);
|
||||
}}
|
||||
onOpenMemberProfile={(memberName, options) => {
|
||||
const member = data.members.find((m) => m.name === memberName);
|
||||
const member = members.find((m) => m.name === memberName);
|
||||
if (member) {
|
||||
setSelectedMember(member);
|
||||
setSelectedMemberView({
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import {
|
|||
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
|
||||
import type { TeamListFilterState } from './TeamListFilterPopover';
|
||||
import type {
|
||||
TeamMemberSnapshot,
|
||||
ResolvedTeamMember,
|
||||
TeamCreateRequest,
|
||||
TeamLaunchRequest,
|
||||
|
|
@ -94,6 +95,17 @@ function folderName(fullPath: string): string {
|
|||
return getBaseName(fullPath) || fullPath;
|
||||
}
|
||||
|
||||
function resolveLaunchDialogMembers(members: readonly TeamMemberSnapshot[]): ResolvedTeamMember[] {
|
||||
return members.map((member) => {
|
||||
return {
|
||||
...member,
|
||||
status: member.currentTaskId ? 'active' : 'idle',
|
||||
messageCount: 0,
|
||||
lastActiveAt: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderMemberChips(members: TeamSummaryMember[], isLight: boolean): React.JSX.Element {
|
||||
const teamColorMap = buildMemberColorMap(members);
|
||||
return (
|
||||
|
|
@ -625,7 +637,7 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
try {
|
||||
const data = await api.teams.getData(teamName);
|
||||
setLaunchDialogTeamName(teamName);
|
||||
setLaunchDialogMembers(data.members ?? []);
|
||||
setLaunchDialogMembers(resolveLaunchDialogMembers(data.members ?? []));
|
||||
setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath);
|
||||
setLaunchDialogOpen(true);
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
import { shortenDisplayPath } from '@renderer/utils/pathDisplay';
|
||||
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
|
||||
import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react';
|
||||
|
|
@ -149,6 +150,7 @@ export const ToolApprovalSheet: React.FC = () => {
|
|||
teams,
|
||||
selectedTeamName,
|
||||
selectedTeamData,
|
||||
selectedTeamMembers,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
pendingApprovals: s.pendingApprovals,
|
||||
|
|
@ -157,6 +159,7 @@ export const ToolApprovalSheet: React.FC = () => {
|
|||
teams: s.teams,
|
||||
selectedTeamName: s.selectedTeamName,
|
||||
selectedTeamData: s.selectedTeamData,
|
||||
selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName),
|
||||
}))
|
||||
);
|
||||
const { isLight } = useTheme();
|
||||
|
|
@ -273,9 +276,9 @@ export const ToolApprovalSheet: React.FC = () => {
|
|||
// Resolve teammate color for MemberBadge (when source !== 'lead')
|
||||
const sourceColor = useMemo(() => {
|
||||
if (!current || current.source === 'lead') return undefined;
|
||||
const member = selectedTeamData?.members?.find((m) => m.name === current.source);
|
||||
const member = selectedTeamMembers.find((m) => m.name === current.source);
|
||||
return member?.color;
|
||||
}, [current, selectedTeamData?.members]);
|
||||
}, [current, selectedTeamMembers]);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
import { buildTaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
|
@ -24,6 +25,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
|||
closeGlobalTaskDetail,
|
||||
selectedTeamName,
|
||||
selectedTeamData,
|
||||
selectedTeamMembers,
|
||||
selectedTeamLoading,
|
||||
selectedTeamError,
|
||||
selectTeam,
|
||||
|
|
@ -36,6 +38,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
|||
closeGlobalTaskDetail: s.closeGlobalTaskDetail,
|
||||
selectedTeamName: s.selectedTeamName,
|
||||
selectedTeamData: s.selectedTeamData,
|
||||
selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName),
|
||||
selectedTeamLoading: s.selectedTeamLoading,
|
||||
selectedTeamError: s.selectedTeamError,
|
||||
selectTeam: s.selectTeam,
|
||||
|
|
@ -94,8 +97,8 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
|||
}, [globalTaskDetail, globalTasks, isFullTeamLoaded, selectedTeamData]);
|
||||
|
||||
const activeMembers = useMemo(
|
||||
() => (isFullTeamLoaded ? (selectedTeamData?.members.filter((m) => !m.removedAt) ?? []) : []),
|
||||
[isFullTeamLoaded, selectedTeamData]
|
||||
() => (isFullTeamLoaded ? selectedTeamMembers.filter((m) => !m.removedAt) : []),
|
||||
[isFullTeamLoaded, selectedTeamMembers]
|
||||
);
|
||||
|
||||
const handleOpenTeam = useCallback((): void => {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,10 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
|
|||
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
||||
import {
|
||||
isTeamProvisioningActive,
|
||||
selectResolvedMembersForTeamName,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import {
|
||||
isGeminiUiFrozen,
|
||||
normalizeCreateLaunchProviderForUi,
|
||||
|
|
@ -281,7 +284,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
||||
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
|
||||
const prepareRequestSeqRef = useRef(0);
|
||||
const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []);
|
||||
const storeMembers = useStore((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName));
|
||||
const previousLaunchParams = useStore((s) =>
|
||||
effectiveTeamName ? s.launchParamsByTeam[effectiveTeamName] : undefined
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
buildGraphMemberNodeIdForMember,
|
||||
buildInlineActivityEntries,
|
||||
} from '@features/agent-graph/renderer';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||
|
|
@ -20,7 +16,6 @@ import { MemberStatsTab } from './MemberStatsTab';
|
|||
import { MemberTasksTab } from './MemberTasksTab';
|
||||
|
||||
import type {
|
||||
InboxMessage,
|
||||
LeadActivityState,
|
||||
MemberSpawnStatusEntry,
|
||||
ResolvedTeamMember,
|
||||
|
|
@ -33,7 +28,6 @@ interface MemberDetailDialogProps {
|
|||
teamName: string;
|
||||
members: ResolvedTeamMember[];
|
||||
tasks: TeamTaskWithKanban[];
|
||||
messages: InboxMessage[];
|
||||
initialTab?: MemberDetailTab;
|
||||
initialActivityFilter?: MemberActivityFilter;
|
||||
isTeamAlive?: boolean;
|
||||
|
|
@ -57,7 +51,6 @@ export const MemberDetailDialog = ({
|
|||
teamName,
|
||||
members,
|
||||
tasks,
|
||||
messages,
|
||||
initialTab = 'tasks',
|
||||
initialActivityFilter = 'all',
|
||||
isTeamAlive,
|
||||
|
|
@ -78,34 +71,7 @@ export const MemberDetailDialog = ({
|
|||
() => (member ? tasks.filter((t) => t.owner === member.name) : []),
|
||||
[tasks, member]
|
||||
);
|
||||
|
||||
const seedMemberMessages = useMemo(
|
||||
() => (member ? messages.filter((m) => m.from === member.name || m.to === member.name) : []),
|
||||
[messages, member]
|
||||
);
|
||||
const memberMessages = seedMemberMessages;
|
||||
const memberActivityCount = useMemo(() => {
|
||||
if (!member) {
|
||||
return 0;
|
||||
}
|
||||
const leadId = `lead:${teamName}`;
|
||||
const leadName =
|
||||
members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`;
|
||||
const ownerNodeId =
|
||||
member.name === leadName ? leadId : buildGraphMemberNodeIdForMember(teamName, member);
|
||||
const entries = buildInlineActivityEntries({
|
||||
data: {
|
||||
members,
|
||||
tasks,
|
||||
messages: memberMessages,
|
||||
},
|
||||
teamName,
|
||||
leadId,
|
||||
leadName,
|
||||
ownerNodeIds: new Set([leadId, ownerNodeId]),
|
||||
});
|
||||
return (entries.get(ownerNodeId) ?? []).length;
|
||||
}, [member, memberMessages, members, tasks, teamName]);
|
||||
const memberActivityCount = member?.messageCount ?? 0;
|
||||
|
||||
const inProgressTasks = useMemo(
|
||||
() => memberTasks.filter((t) => t.status === 'in_progress').length,
|
||||
|
|
@ -206,7 +172,6 @@ export const MemberDetailDialog = ({
|
|||
</TabsContent>
|
||||
<TabsContent value="activity">
|
||||
<MemberMessagesTab
|
||||
messages={memberMessages}
|
||||
teamName={teamName}
|
||||
memberName={member.name}
|
||||
members={members}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,13 @@ import {
|
|||
} from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice';
|
||||
import {
|
||||
getCurrentProvisioningProgressForTeam,
|
||||
selectResolvedMemberForTeamName,
|
||||
selectTeamIsAliveForName,
|
||||
selectTeamMemberSnapshotsForName,
|
||||
selectTeamTasksForName,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import {
|
||||
agentAvatarUrl,
|
||||
|
|
@ -17,6 +23,7 @@ import {
|
|||
} from '@renderer/utils/memberHelpers';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps';
|
||||
|
||||
|
|
@ -38,7 +45,7 @@ interface MemberHoverCardProps {
|
|||
|
||||
/**
|
||||
* Wraps children in a HoverCard that shows member info on hover.
|
||||
* Reads member data from the store (selectedTeamData.members).
|
||||
* Reads member data from the team snapshot + resolved member selectors.
|
||||
* Falls back to a simple wrapper when member data is unavailable.
|
||||
*/
|
||||
export const MemberHoverCard = ({
|
||||
|
|
@ -53,20 +60,22 @@ export const MemberHoverCard = ({
|
|||
const effectiveTeamName = teamName ?? selectedTeamName;
|
||||
const {
|
||||
member,
|
||||
members,
|
||||
teamMembers,
|
||||
tasks,
|
||||
isTeamAlive,
|
||||
progress,
|
||||
memberSpawnSnapshot,
|
||||
memberSpawnStatuses,
|
||||
spawnEntry,
|
||||
leadActivity,
|
||||
} = useStore((s) => {
|
||||
const isSelectedTeam = Boolean(effectiveTeamName && s.selectedTeamName === effectiveTeamName);
|
||||
const selectedTeamData = isSelectedTeam ? s.selectedTeamData : null;
|
||||
return {
|
||||
member: selectedTeamData?.members.find((m) => m.name === name) ?? null,
|
||||
members: selectedTeamData?.members ?? [],
|
||||
isTeamAlive: selectedTeamData?.isAlive,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
member: effectiveTeamName
|
||||
? selectResolvedMemberForTeamName(s, effectiveTeamName, name)
|
||||
: null,
|
||||
teamMembers: effectiveTeamName ? selectTeamMemberSnapshotsForName(s, effectiveTeamName) : [],
|
||||
tasks: effectiveTeamName ? selectTeamTasksForName(s, effectiveTeamName) : [],
|
||||
isTeamAlive: effectiveTeamName ? selectTeamIsAliveForName(s, effectiveTeamName) : undefined,
|
||||
progress: effectiveTeamName
|
||||
? getCurrentProvisioningProgressForTeam(s, effectiveTeamName)
|
||||
: null,
|
||||
|
|
@ -80,21 +89,16 @@ export const MemberHoverCard = ({
|
|||
? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name]
|
||||
: undefined,
|
||||
leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined,
|
||||
};
|
||||
});
|
||||
const openMemberProfile = useStore((s) => s.openMemberProfile);
|
||||
const tasks = useStore((s) =>
|
||||
effectiveTeamName && s.selectedTeamName === effectiveTeamName
|
||||
? s.selectedTeamData?.tasks
|
||||
: undefined
|
||||
}))
|
||||
);
|
||||
const openMemberProfile = useStore((s) => s.openMemberProfile);
|
||||
|
||||
if (!member) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const launchJoinMilestones = getLaunchJoinMilestonesFromMembers({
|
||||
members,
|
||||
members: teamMembers,
|
||||
memberSpawnStatuses,
|
||||
memberSpawnSnapshot,
|
||||
});
|
||||
|
|
@ -117,10 +121,9 @@ export const MemberHoverCard = ({
|
|||
const presenceLabel = launchPresentation.presenceLabel;
|
||||
const dotClass = launchPresentation.dotClass;
|
||||
const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle;
|
||||
const currentTask: TeamTaskWithKanban | null =
|
||||
member.currentTaskId && tasks
|
||||
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
|
||||
: null;
|
||||
const currentTask: TeamTaskWithKanban | null = member.currentTaskId
|
||||
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
|
||||
: null;
|
||||
const reviewTask: TeamTaskWithKanban | null = tasks
|
||||
? (tasks.find(
|
||||
(task) =>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { buildInlineActivityEntries } from '@features/agent-graph/renderer';
|
||||
import { api } from '@renderer/api';
|
||||
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
|
||||
import {
|
||||
buildMessageContext,
|
||||
|
|
@ -10,17 +9,18 @@ import {
|
|||
import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice';
|
||||
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { MemberActivityFilter } from './memberDetailTypes';
|
||||
import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup';
|
||||
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
interface MemberMessagesTabProps {
|
||||
messages: InboxMessage[];
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
members: ResolvedTeamMember[];
|
||||
|
|
@ -31,7 +31,6 @@ interface MemberMessagesTabProps {
|
|||
}
|
||||
|
||||
const MAX_MESSAGES = 100;
|
||||
const MEMBER_MESSAGES_PAGE_SIZE = 50;
|
||||
const FILTER_OPTIONS: readonly { value: MemberActivityFilter; label: string }[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'messages', label: 'Messages' },
|
||||
|
|
@ -39,7 +38,6 @@ const FILTER_OPTIONS: readonly { value: MemberActivityFilter; label: string }[]
|
|||
];
|
||||
|
||||
export const MemberMessagesTab = ({
|
||||
messages,
|
||||
teamName,
|
||||
memberName,
|
||||
members,
|
||||
|
|
@ -48,12 +46,15 @@ export const MemberMessagesTab = ({
|
|||
onCreateTask,
|
||||
onTaskClick,
|
||||
}: MemberMessagesTabProps): React.JSX.Element => {
|
||||
const [pagedMessages, setPagedMessages] = useState<InboxMessage[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activityFilter, setActivityFilter] = useState<MemberActivityFilter>(initialFilter);
|
||||
const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(null);
|
||||
const { messages, messagesState, loadOlderTeamMessages } = useStore(
|
||||
useShallow((s) => ({
|
||||
messages: selectMemberMessagesForTeamMember(s, teamName, memberName),
|
||||
messagesState: teamName ? s.teamMessagesByName[teamName] : undefined,
|
||||
loadOlderTeamMessages: s.loadOlderTeamMessages,
|
||||
}))
|
||||
);
|
||||
const { readSet } = useTeamMessagesRead(teamName);
|
||||
const leadId = `lead:${teamName}`;
|
||||
const leadName = useMemo(
|
||||
|
|
@ -69,75 +70,24 @@ export const MemberMessagesTab = ({
|
|||
setActivityFilter(initialFilter);
|
||||
}, [initialFilter, memberName, teamName]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setPagedMessages([]);
|
||||
setNextCursor(null);
|
||||
setHasMore(false);
|
||||
setLoading(true);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const page = await api.teams.getMessagesPage(teamName, {
|
||||
limit: MEMBER_MESSAGES_PAGE_SIZE,
|
||||
});
|
||||
if (cancelled) return;
|
||||
const memberPageMessages = page.messages.filter(
|
||||
(message) => message.from === memberName || message.to === memberName
|
||||
);
|
||||
setPagedMessages(memberPageMessages);
|
||||
setNextCursor(page.nextCursor);
|
||||
setHasMore(page.hasMore);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setPagedMessages([]);
|
||||
setNextCursor(null);
|
||||
setHasMore(false);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [teamName, memberName]);
|
||||
|
||||
const loadOlderMessages = useCallback(async () => {
|
||||
if (!nextCursor || loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const page = await api.teams.getMessagesPage(teamName, {
|
||||
beforeTimestamp: nextCursor,
|
||||
limit: MEMBER_MESSAGES_PAGE_SIZE,
|
||||
});
|
||||
const memberPageMessages = page.messages.filter(
|
||||
(message) => message.from === memberName || message.to === memberName
|
||||
);
|
||||
setPagedMessages((prev) => mergeTeamMessages(prev, memberPageMessages));
|
||||
setNextCursor(page.nextCursor);
|
||||
setHasMore(page.hasMore);
|
||||
} catch {
|
||||
// best-effort
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) {
|
||||
return;
|
||||
}
|
||||
}, [teamName, memberName, nextCursor, loading]);
|
||||
await loadOlderTeamMessages(teamName);
|
||||
}, [loadOlderTeamMessages, messagesState, teamName]);
|
||||
|
||||
const effectiveMessages = useMemo(
|
||||
() => mergeTeamMessages(messages, pagedMessages),
|
||||
[messages, pagedMessages]
|
||||
);
|
||||
const loading = (messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false);
|
||||
const hasMore = messagesState?.hasMore ?? false;
|
||||
|
||||
const filteredMessages = useMemo(
|
||||
() =>
|
||||
filterTeamMessages(effectiveMessages, {
|
||||
filterTeamMessages(messages, {
|
||||
timeWindow: null,
|
||||
filter: { from: new Set(), to: new Set(), showNoise: true },
|
||||
searchQuery: '',
|
||||
}),
|
||||
[effectiveMessages]
|
||||
[messages]
|
||||
);
|
||||
|
||||
const activityEntries = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Sheet, type SheetRef } from 'react-modal-sheet';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
|
|
@ -9,7 +8,7 @@ import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMe
|
|||
import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
|
||||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
|
||||
import { selectTeamMessages } from '@renderer/store/slices/teamSlice';
|
||||
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
|
||||
|
|
@ -70,8 +69,6 @@ interface MessagesPanelProps {
|
|||
members: ResolvedTeamMember[];
|
||||
/** All team tasks. */
|
||||
tasks: TeamTaskWithKanban[];
|
||||
/** All raw messages from team data. */
|
||||
messages: InboxMessage[];
|
||||
/** Whether the team is alive. */
|
||||
isTeamAlive?: boolean;
|
||||
/** Live lead activity status for the current team. */
|
||||
|
|
@ -109,7 +106,6 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
mountPoint,
|
||||
members,
|
||||
tasks,
|
||||
messages,
|
||||
isTeamAlive,
|
||||
leadActivity,
|
||||
leadContextUpdatedAt,
|
||||
|
|
@ -133,6 +129,9 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
lastSendMessageResult,
|
||||
teams,
|
||||
openTeamTab,
|
||||
messages,
|
||||
messagesState,
|
||||
loadOlderTeamMessages,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
sendTeamMessage: s.sendTeamMessage,
|
||||
|
|
@ -142,79 +141,23 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
lastSendMessageResult: s.lastSendMessageResult,
|
||||
teams: s.teams,
|
||||
openTeamTab: s.openTeamTab,
|
||||
messages: selectTeamMessages(s, teamName),
|
||||
messagesState: teamName ? s.teamMessagesByName[teamName] : undefined,
|
||||
loadOlderTeamMessages: s.loadOlderTeamMessages,
|
||||
}))
|
||||
);
|
||||
|
||||
// ── Paginated message fetching ──
|
||||
// Messages are now fetched via getMessagesPage API instead of coming
|
||||
// from getTeamData. The `messages` prop is used as initial seed if non-empty.
|
||||
const PAGE_SIZE = 50;
|
||||
const [fetchedMessages, setFetchedMessages] = useState<InboxMessage[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [messagesLoading, setMessagesLoading] = useState(false);
|
||||
const fetchIdRef = useRef(0);
|
||||
|
||||
// Initial fetch on mount or team change
|
||||
useEffect(() => {
|
||||
const id = ++fetchIdRef.current;
|
||||
setMessagesLoading(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE });
|
||||
if (fetchIdRef.current !== id) return;
|
||||
setFetchedMessages(page.messages);
|
||||
setNextCursor(page.nextCursor);
|
||||
setHasMore(page.hasMore);
|
||||
} catch {
|
||||
// Fallback: use prop messages if API fails
|
||||
if (fetchIdRef.current === id && messages.length > 0) {
|
||||
setFetchedMessages(messages);
|
||||
}
|
||||
} finally {
|
||||
if (fetchIdRef.current === id) setMessagesLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [teamName]); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally only on teamName change
|
||||
|
||||
// Auto-refresh: poll for NEW messages only (prepend to head).
|
||||
// Does NOT touch nextCursor/hasMore — those belong to the "Load older" flow.
|
||||
useEffect(() => {
|
||||
if (!isTeamAlive && leadActivity !== 'active') return;
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE });
|
||||
setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages));
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [teamName, isTeamAlive, leadActivity]);
|
||||
|
||||
const loadOlderMessages = useCallback(async () => {
|
||||
if (!nextCursor || messagesLoading) return;
|
||||
setMessagesLoading(true);
|
||||
try {
|
||||
const page = await api.teams.getMessagesPage(teamName, {
|
||||
beforeTimestamp: nextCursor,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages));
|
||||
setNextCursor(page.nextCursor);
|
||||
setHasMore(page.hasMore);
|
||||
} catch {
|
||||
// best-effort
|
||||
} finally {
|
||||
setMessagesLoading(false);
|
||||
if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) {
|
||||
return;
|
||||
}
|
||||
}, [teamName, nextCursor, messagesLoading]);
|
||||
await loadOlderTeamMessages(teamName);
|
||||
}, [loadOlderTeamMessages, messagesState, teamName]);
|
||||
|
||||
// Use fetched messages, fall back to prop messages during initial load
|
||||
const effectiveMessages = useMemo(() => {
|
||||
if (fetchedMessages.length === 0) return messages;
|
||||
return mergeTeamMessages(fetchedMessages, messages);
|
||||
}, [fetchedMessages, messages]);
|
||||
const messagesLoading =
|
||||
(messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false);
|
||||
const hasMore = messagesState?.hasMore ?? false;
|
||||
const effectiveMessages = messages;
|
||||
|
||||
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const sidebarScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
|
|||
|
|
@ -3,10 +3,14 @@ import { isLeadMember } from '@shared/utils/leadDetection';
|
|||
import type {
|
||||
MemberSpawnStatusEntry,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
ResolvedTeamMember,
|
||||
TeamProvisioningProgress,
|
||||
} from '@shared/types';
|
||||
|
||||
interface LaunchJoinMemberLike {
|
||||
name: string;
|
||||
removedAt?: number;
|
||||
}
|
||||
|
||||
/** Display steps for the provisioning stepper (0-indexed). */
|
||||
export const DISPLAY_STEPS = [
|
||||
{ key: 'starting', label: 'Starting' },
|
||||
|
|
@ -52,7 +56,7 @@ export function getLaunchJoinMilestonesFromMembers({
|
|||
memberSpawnStatuses,
|
||||
memberSpawnSnapshot,
|
||||
}: {
|
||||
members: readonly ResolvedTeamMember[];
|
||||
members: readonly LaunchJoinMemberLike[];
|
||||
memberSpawnStatuses?: MemberSpawnStatusCollection;
|
||||
memberSpawnSnapshot?: Pick<MemberSpawnStatusesSnapshot, 'expectedMembers' | 'summary'>;
|
||||
}): LaunchJoinMilestones {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useMemo } from 'react';
|
|||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
getCurrentProvisioningProgressForTeam,
|
||||
selectTeamDataForName,
|
||||
selectTeamMemberSnapshotsForName,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
|
@ -20,7 +20,7 @@ export function useTeamProvisioningPresentation(teamName: string): {
|
|||
useShallow((s) => ({
|
||||
progress: getCurrentProvisioningProgressForTeam(s, teamName),
|
||||
cancelProvisioning: s.cancelProvisioning,
|
||||
teamMembers: selectTeamDataForName(s, teamName)?.members ?? [],
|
||||
teamMembers: selectTeamMemberSnapshotsForName(s, teamName),
|
||||
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
||||
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
selectResolvedMembersForTeamName,
|
||||
selectTeamDataForName,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { createEncodedTaskReference } from '@renderer/utils/taskReferenceUtils';
|
||||
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
|
@ -57,11 +61,13 @@ function isVisibleTask(task: TeamTaskWithKanban | GlobalTask): boolean {
|
|||
}
|
||||
|
||||
export function useTaskSuggestions(currentTeamName: string | null): UseTaskSuggestionsResult {
|
||||
const { globalTasks, selectedTeamName, selectedTeamData, teamByName } = useStore(
|
||||
const { globalTasks, currentTeamData, currentTeamMembers, teamByName } = useStore(
|
||||
useShallow((s) => ({
|
||||
globalTasks: s.globalTasks,
|
||||
selectedTeamName: s.selectedTeamName,
|
||||
selectedTeamData: s.selectedTeamData,
|
||||
currentTeamData: currentTeamName ? selectTeamDataForName(s, currentTeamName) : null,
|
||||
currentTeamMembers: currentTeamName
|
||||
? selectResolvedMembersForTeamName(s, currentTeamName)
|
||||
: [],
|
||||
teamByName: s.teamByName,
|
||||
}))
|
||||
);
|
||||
|
|
@ -73,14 +79,10 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge
|
|||
if (currentTeamName) {
|
||||
const currentTeamSummary = teamByName[currentTeamName];
|
||||
const currentTeamDisplayName = currentTeamSummary?.displayName || currentTeamName;
|
||||
const currentTeamMembers =
|
||||
selectedTeamName === currentTeamName && selectedTeamData
|
||||
? selectedTeamData.members
|
||||
: (currentTeamSummary?.members ?? []);
|
||||
const currentTeamTasks =
|
||||
selectedTeamName === currentTeamName && selectedTeamData
|
||||
? selectedTeamData.tasks
|
||||
: globalTasks.filter((task) => task.teamName === currentTeamName);
|
||||
currentTeamData?.tasks ?? globalTasks.filter((task) => task.teamName === currentTeamName);
|
||||
const currentTeamMemberColors =
|
||||
currentTeamMembers.length > 0 ? currentTeamMembers : (currentTeamSummary?.members ?? []);
|
||||
|
||||
for (const task of currentTeamTasks) {
|
||||
if (!isVisibleTask(task)) continue;
|
||||
|
|
@ -91,7 +93,7 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge
|
|||
teamDisplayName: currentTeamDisplayName,
|
||||
teamColor: currentTeamSummary?.color,
|
||||
isCurrentTeamTask: true,
|
||||
ownerColor: currentTeamMembers.find((member) => member.name === task.owner)?.color,
|
||||
ownerColor: currentTeamMemberColors.find((member) => member.name === task.owner)?.color,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -123,7 +125,7 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge
|
|||
});
|
||||
|
||||
return tasks.map(buildTaskSuggestion);
|
||||
}, [currentTeamName, globalTasks, selectedTeamData, selectedTeamName, teamByName]);
|
||||
}, [currentTeamData, currentTeamMembers, currentTeamName, globalTasks, teamByName]);
|
||||
|
||||
return { suggestions };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@ import { createTabSlice } from './slices/tabSlice';
|
|||
import { createTabUISlice } from './slices/tabUISlice';
|
||||
import {
|
||||
createTeamSlice,
|
||||
getActiveTeamPendingReplyWaits,
|
||||
getLastResolvedTeamDataRefreshAt,
|
||||
hasActiveTeamPendingReplyWait,
|
||||
isTeamDataRefreshPending,
|
||||
selectTeamDataForName,
|
||||
} from './slices/teamSlice';
|
||||
|
|
@ -65,6 +67,7 @@ const TEAM_CHANGE_EVENT_BURST_WARN_COUNT = 8;
|
|||
const TEAM_CHANGE_EVENT_WARN_THROTTLE_MS = 2_000;
|
||||
const TEAM_VISIBLE_IDLE_WATCHDOG_POLL_MS = 10_000;
|
||||
const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000;
|
||||
const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_000;
|
||||
const CURRENT_APP_VERSION =
|
||||
typeof __APP_VERSION__ === 'string' ? normalizeVersion(__APP_VERSION__) : '0.0.0';
|
||||
const logger = createLogger('Store:index');
|
||||
|
|
@ -237,10 +240,12 @@ export function initializeNotificationListeners(): () => void {
|
|||
const teamLastRelevantActivityAt = new Map<string, number>();
|
||||
const teamLastIdleWatchdogRefreshAt = new Map<string, number>();
|
||||
let teamRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let teamMessageRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let teamPresenceRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let memberSpawnRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let toolActivityTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let inProgressChangePresencePollInFlight = false;
|
||||
let teamMessageFallbackPollInFlight = false;
|
||||
const inProgressChangePresenceCursorByTeam = new Map<string, number>();
|
||||
|
||||
let teamListRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
|
@ -252,6 +257,23 @@ export function initializeNotificationListeners(): () => void {
|
|||
const TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS = 500;
|
||||
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
|
||||
const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500;
|
||||
const refreshTrackedTeamMessages = async (teamName: string): Promise<void> => {
|
||||
if (!teamName || !shouldRefreshTeamMessages(teamName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = useStore.getState();
|
||||
try {
|
||||
const headResult = await current.refreshTeamMessagesHead(teamName);
|
||||
const latest = useStore.getState();
|
||||
const meta = latest.memberActivityMetaByTeam[teamName];
|
||||
if (headResult.feedChanged || !meta || meta.feedRevision !== headResult.feedRevision) {
|
||||
await latest.refreshMemberActivityMeta(teamName);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort refresh for message-driven events and fallback polling only.
|
||||
}
|
||||
};
|
||||
const scheduleMemberSpawnStatusesRefresh = (teamName: string | null | undefined): void => {
|
||||
if (!teamName || !isTeamVisibleInAnyPane(teamName)) {
|
||||
return;
|
||||
|
|
@ -265,6 +287,19 @@ export function initializeNotificationListeners(): () => void {
|
|||
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
|
||||
memberSpawnRefreshTimers.set(teamName, timer);
|
||||
};
|
||||
const scheduleTrackedTeamMessageRefresh = (teamName: string | null | undefined): void => {
|
||||
if (!teamName || !shouldRefreshTeamMessages(teamName)) {
|
||||
return;
|
||||
}
|
||||
if (teamMessageRefreshTimers.has(teamName)) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
teamMessageRefreshTimers.delete(teamName);
|
||||
void refreshTrackedTeamMessages(teamName);
|
||||
}, TEAM_REFRESH_THROTTLE_MS);
|
||||
teamMessageRefreshTimers.set(teamName, timer);
|
||||
};
|
||||
const buildToolActivityTimerKey = (
|
||||
teamName: string,
|
||||
memberName: string,
|
||||
|
|
@ -587,6 +622,18 @@ export function initializeNotificationListeners(): () => void {
|
|||
return getVisibleTeamNamesInAnyPane().has(teamName);
|
||||
};
|
||||
|
||||
const shouldRefreshTeamMessages = (teamName: string): boolean => {
|
||||
return isTeamVisibleInAnyPane(teamName) || hasActiveTeamPendingReplyWait(teamName);
|
||||
};
|
||||
|
||||
const getTrackedTeamMessageRefreshTeams = (): Set<string> => {
|
||||
const tracked = getVisibleTeamNamesInAnyPane();
|
||||
for (const teamName of getActiveTeamPendingReplyWaits()) {
|
||||
tracked.add(teamName);
|
||||
}
|
||||
return tracked;
|
||||
};
|
||||
|
||||
const getTrackedChangePresenceTeams = (): Set<string> => {
|
||||
const state = useStore.getState();
|
||||
const tracked = new Set<string>();
|
||||
|
|
@ -627,6 +674,26 @@ export function initializeNotificationListeners(): () => void {
|
|||
return activeTab.teamName;
|
||||
};
|
||||
|
||||
const pollTrackedTeamMessageFallback = async (): Promise<void> => {
|
||||
if (teamMessageFallbackPollInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const teamNames = getTrackedTeamMessageRefreshTeams();
|
||||
if (teamNames.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
teamMessageFallbackPollInFlight = true;
|
||||
try {
|
||||
await Promise.allSettled(
|
||||
Array.from(teamNames, (teamName) => refreshTrackedTeamMessages(teamName))
|
||||
);
|
||||
} finally {
|
||||
teamMessageFallbackPollInFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
const pollFocusedVisibleTeamIdleWatchdog = async (): Promise<void> => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
||||
return;
|
||||
|
|
@ -863,11 +930,18 @@ export function initializeNotificationListeners(): () => void {
|
|||
cleanupFns.push(() => {
|
||||
clearInterval(teamIdleWatchdogTimer);
|
||||
});
|
||||
const teamMessageFallbackPollTimer = setInterval(() => {
|
||||
void pollTrackedTeamMessageFallback();
|
||||
}, TEAM_MESSAGE_FALLBACK_POLL_MS);
|
||||
cleanupFns.push(() => {
|
||||
clearInterval(teamMessageFallbackPollTimer);
|
||||
});
|
||||
|
||||
if (api.teams?.onTeamChange) {
|
||||
const cleanup = api.teams.onTeamChange((_event: unknown, event: TeamChangeEvent) => {
|
||||
const visibleTeam = Boolean(event.teamName) && isTeamVisibleInAnyPane(event.teamName);
|
||||
noteTeamChangeEventBurst(event.teamName, event.type, visibleTeam);
|
||||
const messageRefreshRelevant =
|
||||
Boolean(event.teamName) && shouldRefreshTeamMessages(event.teamName);
|
||||
noteTeamChangeEventBurst(event.teamName, event.type, messageRefreshRelevant);
|
||||
|
||||
const isIgnoredRuntimeRun = (() => {
|
||||
if (!event.runId) return false;
|
||||
|
|
@ -924,24 +998,26 @@ export function initializeNotificationListeners(): () => void {
|
|||
},
|
||||
};
|
||||
|
||||
const cachedTeamData = prev.teamDataCacheByName[event.teamName];
|
||||
if (cachedTeamData) {
|
||||
const baseTeamData =
|
||||
prev.teamDataCacheByName[event.teamName] ??
|
||||
(prev.selectedTeamName === event.teamName ? prev.selectedTeamData : null);
|
||||
const nextTeamData =
|
||||
baseTeamData && baseTeamData.isAlive !== (nextActivity !== 'offline')
|
||||
? {
|
||||
...baseTeamData,
|
||||
isAlive: nextActivity !== 'offline',
|
||||
}
|
||||
: baseTeamData;
|
||||
|
||||
if (nextTeamData) {
|
||||
nextState.teamDataCacheByName = {
|
||||
...prev.teamDataCacheByName,
|
||||
[event.teamName]: {
|
||||
...cachedTeamData,
|
||||
isAlive: nextActivity !== 'offline',
|
||||
},
|
||||
[event.teamName]: nextTeamData,
|
||||
};
|
||||
}
|
||||
|
||||
// Keep TeamDetailView in sync: it historically relied on selectedTeamData.isAlive,
|
||||
// which isn't refreshed for lead-activity events.
|
||||
if (prev.selectedTeamName === event.teamName && prev.selectedTeamData) {
|
||||
nextState.selectedTeamData = {
|
||||
...prev.selectedTeamData,
|
||||
isAlive: nextActivity !== 'offline',
|
||||
};
|
||||
if (prev.selectedTeamName === event.teamName && nextTeamData) {
|
||||
nextState.selectedTeamData = nextTeamData;
|
||||
}
|
||||
|
||||
// Clear context data when lead goes offline
|
||||
|
|
@ -1122,29 +1198,19 @@ export function initializeNotificationListeners(): () => void {
|
|||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'inbox' || event.type === 'config' || event.type === 'process') {
|
||||
scheduleMemberSpawnStatusesRefresh(event.teamName);
|
||||
if (event.type === 'inbox') {
|
||||
scheduleTrackedTeamMessageRefresh(event.teamName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Live lead-message events: only refresh the visible team detail, not team/task lists.
|
||||
// This keeps the refresh lightweight and prevents one noisy team from starving another.
|
||||
// Live lead-message events refresh only the tracked message feed surface
|
||||
// (visible team or local pending-reply wait), not the structural snapshot.
|
||||
if (event.type === 'lead-message') {
|
||||
if (isStaleRuntimeEvent) {
|
||||
return;
|
||||
}
|
||||
seedCurrentRunIdIfMissing();
|
||||
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
|
||||
return;
|
||||
}
|
||||
if (teamRefreshTimers.has(event.teamName)) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
teamRefreshTimers.delete(event.teamName);
|
||||
const current = useStore.getState();
|
||||
void current.refreshTeamData(event.teamName, { withDedup: true });
|
||||
}, TEAM_REFRESH_THROTTLE_MS);
|
||||
teamRefreshTimers.set(event.teamName, timer);
|
||||
scheduleTrackedTeamMessageRefresh(event.teamName);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1205,6 +1271,8 @@ export function initializeNotificationListeners(): () => void {
|
|||
cleanup();
|
||||
for (const t of teamRefreshTimers.values()) clearTimeout(t);
|
||||
teamRefreshTimers = new Map();
|
||||
for (const t of teamMessageRefreshTimers.values()) clearTimeout(t);
|
||||
teamMessageRefreshTimers = new Map();
|
||||
for (const t of teamPresenceRefreshTimers.values()) clearTimeout(t);
|
||||
teamPresenceRefreshTimers = new Map();
|
||||
for (const t of memberSpawnRefreshTimers.values()) clearTimeout(t);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -8,7 +8,6 @@ import {
|
|||
import type {
|
||||
MemberSpawnStatusEntry,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
ResolvedTeamMember,
|
||||
TeamProvisioningProgress,
|
||||
} from '@shared/types';
|
||||
|
||||
|
|
@ -17,6 +16,11 @@ type MemberSpawnStatusCollection =
|
|||
| Map<string, MemberSpawnStatusEntry>
|
||||
| undefined;
|
||||
|
||||
interface ProvisioningMemberLike {
|
||||
name: string;
|
||||
removedAt?: number;
|
||||
}
|
||||
|
||||
const ACTIVE_PROVISIONING_STATES = new Set([
|
||||
'validating',
|
||||
'spawning',
|
||||
|
|
@ -66,7 +70,7 @@ export function buildTeamProvisioningPresentation({
|
|||
memberSpawnSnapshot,
|
||||
}: {
|
||||
progress: TeamProvisioningProgress | null | undefined;
|
||||
members: readonly ResolvedTeamMember[];
|
||||
members: readonly ProvisioningMemberLike[];
|
||||
memberSpawnStatuses?: MemberSpawnStatusCollection;
|
||||
memberSpawnSnapshot?: Pick<MemberSpawnStatusesSnapshot, 'expectedMembers' | 'summary'>;
|
||||
}): TeamProvisioningPresentation | null {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import type {
|
|||
KanbanColumnId,
|
||||
LeadActivitySnapshot,
|
||||
LeadContextUsageSnapshot,
|
||||
TeamMemberActivityMeta,
|
||||
MemberFullStats,
|
||||
MemberLogSummary,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
|
|
@ -71,10 +72,10 @@ import type {
|
|||
TeamCreateConfigRequest,
|
||||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
TeamData,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamMessageNotificationData,
|
||||
TeamViewSnapshot,
|
||||
TeamProvisioningPrepareResult,
|
||||
TeamProvisioningProgress,
|
||||
TeamSummary,
|
||||
|
|
@ -425,7 +426,7 @@ export interface HttpServerAPI {
|
|||
|
||||
export interface TeamsAPI {
|
||||
list: () => Promise<TeamSummary[]>;
|
||||
getData: (teamName: string) => Promise<TeamData>;
|
||||
getData: (teamName: string) => Promise<TeamViewSnapshot>;
|
||||
getTaskChangePresence: (teamName: string) => Promise<Record<string, TaskChangePresenceState>>;
|
||||
setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise<void>;
|
||||
setToolActivityTracking: (teamName: string, enabled: boolean) => Promise<void>;
|
||||
|
|
@ -446,8 +447,9 @@ export interface TeamsAPI {
|
|||
sendMessage: (teamName: string, request: SendMessageRequest) => Promise<SendMessageResult>;
|
||||
getMessagesPage: (
|
||||
teamName: string,
|
||||
options?: { beforeTimestamp?: string; limit?: number }
|
||||
options?: { cursor?: string | null; limit?: number }
|
||||
) => Promise<MessagesPage>;
|
||||
getMemberActivityMeta: (teamName: string) => Promise<TeamMemberActivityMeta>;
|
||||
createTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
|
||||
requestReview: (teamName: string, taskId: string) => Promise<void>;
|
||||
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
|
||||
|
|
|
|||
|
|
@ -602,6 +602,11 @@ export interface MessagesPage {
|
|||
/** Opaque cursor string for fetching older messages. Null when no more pages. */
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
/**
|
||||
* Content-stable revision of the full normalized feed that produced this page.
|
||||
* Changes only when the semantic message feed changes.
|
||||
*/
|
||||
feedRevision: string;
|
||||
}
|
||||
|
||||
export type AgentActionMode = 'do' | 'ask' | 'delegate';
|
||||
|
|
@ -729,12 +734,44 @@ export interface TeamProcess {
|
|||
stoppedAt?: string;
|
||||
}
|
||||
|
||||
export interface TeamData {
|
||||
export interface TeamMemberSnapshot {
|
||||
name: string;
|
||||
agentId?: string;
|
||||
currentTaskId: string | null;
|
||||
taskCount: number;
|
||||
color?: string;
|
||||
agentType?: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
cwd?: string;
|
||||
/** Set only when member's git branch differs from the lead's branch. */
|
||||
gitBranch?: string;
|
||||
runtimeAdvisory?: MemberRuntimeAdvisory;
|
||||
removedAt?: number;
|
||||
}
|
||||
|
||||
export interface MemberActivityMetaEntry {
|
||||
memberName: string;
|
||||
lastAuthoredMessageAt: string | null;
|
||||
messageCountExact: number;
|
||||
latestAuthoredMessageSignalsTermination: boolean;
|
||||
}
|
||||
|
||||
export interface TeamMemberActivityMeta {
|
||||
teamName: string;
|
||||
computedAt: string;
|
||||
members: Record<string, MemberActivityMetaEntry>;
|
||||
feedRevision: string;
|
||||
}
|
||||
|
||||
export interface TeamViewSnapshot {
|
||||
teamName: string;
|
||||
config: TeamConfig;
|
||||
tasks: TeamTaskWithKanban[];
|
||||
members: ResolvedTeamMember[];
|
||||
messages: InboxMessage[];
|
||||
members: TeamMemberSnapshot[];
|
||||
kanbanState: KanbanState;
|
||||
processes: TeamProcess[];
|
||||
warnings?: string[];
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import type {
|
|||
} from '@shared/types/team';
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp') },
|
||||
app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp'), isPackaged: false },
|
||||
Notification: Object.assign(vi.fn(), { isSupported: vi.fn(() => false) }),
|
||||
BrowserWindow: { getAllWindows: vi.fn(() => []) },
|
||||
}));
|
||||
|
|
@ -34,6 +34,8 @@ const { mockTeamDataWorkerClient } = vi.hoisted(() => ({
|
|||
mockTeamDataWorkerClient: {
|
||||
isAvailable: vi.fn(),
|
||||
getTeamData: vi.fn(),
|
||||
getMessagesPage: vi.fn(),
|
||||
getMemberActivityMeta: vi.fn(),
|
||||
findLogsForTask: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
|
@ -62,6 +64,8 @@ import {
|
|||
TEAM_CREATE_TASK,
|
||||
TEAM_DELETE_TEAM,
|
||||
TEAM_GET_DATA,
|
||||
TEAM_GET_MEMBER_ACTIVITY_META,
|
||||
TEAM_GET_MESSAGES_PAGE,
|
||||
TEAM_LAUNCH,
|
||||
TEAM_LIST,
|
||||
TEAM_PREPARE_PROVISIONING,
|
||||
|
|
@ -135,13 +139,33 @@ describe('ipc teams handlers', () => {
|
|||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: [] as InboxMessage[],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
})),
|
||||
getMessageFeed: vi.fn(async () => ({
|
||||
teamName: 'my-team',
|
||||
feedRevision: 'rev-1',
|
||||
messages: [] as InboxMessage[],
|
||||
})),
|
||||
getMessagesPage: vi.fn(async () => ({
|
||||
messages: [] as InboxMessage[],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
feedRevision: 'rev-1',
|
||||
})),
|
||||
getMemberActivityMeta: vi.fn(async () => ({
|
||||
teamName: 'my-team',
|
||||
computedAt: '2026-03-12T10:00:00.000Z',
|
||||
members: {},
|
||||
feedRevision: 'rev-1',
|
||||
})),
|
||||
getTaskChangePresence: vi.fn(async () => ({ 'task-1': 'has_changes' })),
|
||||
reconcileTeamArtifacts: vi.fn(async () => undefined),
|
||||
setTaskChangePresenceTracking: vi.fn(() => undefined),
|
||||
getTeamNotificationContext: vi.fn(async () => ({
|
||||
displayName: 'My Team',
|
||||
projectPath: '/tmp/project',
|
||||
})),
|
||||
deleteTeam: vi.fn(async () => undefined),
|
||||
getLeadMemberName: vi.fn(async () => 'team-lead'),
|
||||
getTeamDisplayName: vi.fn(async () => 'My Team'),
|
||||
|
|
@ -231,6 +255,8 @@ describe('ipc teams handlers', () => {
|
|||
mockGetMembersMeta.mockResolvedValue([]);
|
||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
||||
mockTeamDataWorkerClient.getTeamData.mockReset();
|
||||
mockTeamDataWorkerClient.getMessagesPage.mockReset();
|
||||
mockTeamDataWorkerClient.getMemberActivityMeta.mockReset();
|
||||
mockTeamDataWorkerClient.findLogsForTask.mockReset();
|
||||
initializeTeamHandlers(
|
||||
service as never,
|
||||
|
|
@ -252,6 +278,8 @@ describe('ipc teams handlers', () => {
|
|||
it('registers all expected handlers', () => {
|
||||
expect(handlers.has(TEAM_LIST)).toBe(true);
|
||||
expect(handlers.has(TEAM_GET_DATA)).toBe(true);
|
||||
expect(handlers.has(TEAM_GET_MESSAGES_PAGE)).toBe(true);
|
||||
expect(handlers.has(TEAM_GET_MEMBER_ACTIVITY_META)).toBe(true);
|
||||
expect(handlers.has(TEAM_GET_TASK_CHANGE_PRESENCE)).toBe(true);
|
||||
expect(handlers.has(TEAM_SET_CHANGE_PRESENCE_TRACKING)).toBe(true);
|
||||
expect(handlers.has(TEAM_DELETE_TEAM)).toBe(true);
|
||||
|
|
@ -580,7 +608,6 @@ describe('ipc teams handlers', () => {
|
|||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: [] as InboxMessage[],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
|
|
@ -759,24 +786,7 @@ describe('ipc teams handlers', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('dedups live lead replies when lead_session already has same text', async () => {
|
||||
service.getTeamData.mockResolvedValueOnce({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: [
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: 'Hello there',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: true,
|
||||
source: 'lead_session' as const,
|
||||
},
|
||||
],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
it('keeps TEAM_GET_DATA structural and does not expose message transport', async () => {
|
||||
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
||||
{
|
||||
from: 'team-lead',
|
||||
|
|
@ -791,59 +801,159 @@ describe('ipc teams handlers', () => {
|
|||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
||||
const result = (await getDataHandler({} as never, 'my-team')) as {
|
||||
success: boolean;
|
||||
data: { messages: { source?: string }[] };
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(result.success).toBe(true);
|
||||
const sources = result.data.messages.map((m) => m.source);
|
||||
expect(sources.filter((s) => s === 'lead_process')).toHaveLength(0);
|
||||
expect(sources.filter((s) => s === 'lead_session')).toHaveLength(1);
|
||||
expect(result.data.teamName).toBe('my-team');
|
||||
expect(result.data).not.toHaveProperty('messages');
|
||||
expect(service.getMessageFeed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('merges early live messages before durable lead_session backfill exists', async () => {
|
||||
// Simulate: team just became readable but lead_session JSONL hasn't been written yet.
|
||||
// Only live in-memory messages exist from the provisioning process.
|
||||
service.getTeamData.mockResolvedValueOnce({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: [], // No durable messages yet
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: 'Команда создана. Запускаю тиммейтов.',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: true,
|
||||
source: 'lead_process' as const,
|
||||
messageId: 'lead-turn-run-1-1',
|
||||
},
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: 'All teammates online!',
|
||||
timestamp: '2026-02-23T10:00:01.000Z',
|
||||
read: true,
|
||||
source: 'lead_process' as const,
|
||||
messageId: 'lead-turn-run-1-2',
|
||||
to: 'user',
|
||||
},
|
||||
]);
|
||||
it('rejects TEAM_GET_DATA fallback in packaged runtime when worker is unavailable', async () => {
|
||||
const electron = await import('electron');
|
||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
||||
(electron.app as { isPackaged: boolean }).isPackaged = true;
|
||||
|
||||
const handler = handlers.get(TEAM_GET_DATA)!;
|
||||
const result = (await handler({} as never, 'my-team')) as { success: boolean; error?: string };
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('TEAM_DATA_WORKER_UNAVAILABLE');
|
||||
expect(service.getTeamData).not.toHaveBeenCalled();
|
||||
vi.mocked(console.error).mockClear();
|
||||
|
||||
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
||||
});
|
||||
|
||||
it('uses the team-data worker for TEAM_GET_MESSAGES_PAGE when available', async () => {
|
||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
||||
mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({
|
||||
messages: [
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: 'Hello there',
|
||||
timestamp: '2026-02-23T10:00:01.000Z',
|
||||
read: true,
|
||||
source: 'lead_session' as const,
|
||||
messageId: 'msg-1',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
feedRevision: 'rev-worker',
|
||||
});
|
||||
|
||||
const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!;
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
limit: 50,
|
||||
})) as { success: boolean; data: { feedRevision: string } };
|
||||
|
||||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
||||
const result = (await getDataHandler({} as never, 'my-team')) as {
|
||||
success: boolean;
|
||||
data: { messages: { source?: string; text: string }[] };
|
||||
};
|
||||
expect(result.success).toBe(true);
|
||||
// Both live messages should appear since there's no durable backfill yet
|
||||
// Sorted by timestamp descending (newest first)
|
||||
expect(result.data.messages).toHaveLength(2);
|
||||
expect(result.data.messages[0].source).toBe('lead_process');
|
||||
expect(result.data.messages[0].text).toBe('All teammates online!');
|
||||
expect(result.data.messages[1].source).toBe('lead_process');
|
||||
expect(result.data.messages[1].text).toBe('Команда создана. Запускаю тиммейтов.');
|
||||
expect(result.data.feedRevision).toBe('rev-worker');
|
||||
expect(mockTeamDataWorkerClient.getMessagesPage).toHaveBeenCalledWith('my-team', {
|
||||
cursor: undefined,
|
||||
limit: 50,
|
||||
});
|
||||
expect(service.getMessagesPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scans rate-limit notifications from message-page results without hydrating TEAM_GET_DATA feed', async () => {
|
||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
||||
mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({
|
||||
messages: [
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: "You've hit your limit. Please wait a bit before retrying.",
|
||||
timestamp: '2026-02-23T10:00:01.000Z',
|
||||
read: true,
|
||||
source: 'lead_session' as const,
|
||||
messageId: 'msg-rate-limit-1',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
feedRevision: 'rev-worker',
|
||||
});
|
||||
|
||||
const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!;
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
limit: 50,
|
||||
})) as { success: boolean; data: { feedRevision: string } };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.feedRevision).toBe('rev-worker');
|
||||
expect(mockAddTeamNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamEventType: 'rate_limit',
|
||||
teamName: 'my-team',
|
||||
teamDisplayName: 'My Team',
|
||||
from: 'team-lead',
|
||||
dedupeKey: 'rate-limit:my-team:msg-rate-limit-1',
|
||||
})
|
||||
);
|
||||
expect(service.getMessageFeed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects heavy TEAM_GET_MESSAGES_PAGE fallback in packaged runtime when worker is unavailable', async () => {
|
||||
const electron = await import('electron');
|
||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
||||
(electron.app as { isPackaged: boolean }).isPackaged = true;
|
||||
|
||||
const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!;
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
limit: 50,
|
||||
})) as { success: boolean; error?: string };
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('TEAM_DATA_WORKER_UNAVAILABLE');
|
||||
expect(service.getMessagesPage).not.toHaveBeenCalled();
|
||||
vi.mocked(console.error).mockClear();
|
||||
|
||||
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
||||
});
|
||||
|
||||
it('uses the team-data worker for TEAM_GET_MEMBER_ACTIVITY_META when available', async () => {
|
||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
||||
mockTeamDataWorkerClient.getMemberActivityMeta.mockResolvedValueOnce({
|
||||
teamName: 'my-team',
|
||||
computedAt: '2026-03-12T10:00:00.000Z',
|
||||
members: {
|
||||
alice: {
|
||||
memberName: 'alice',
|
||||
lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z',
|
||||
messageCountExact: 4,
|
||||
latestAuthoredMessageSignalsTermination: false,
|
||||
},
|
||||
},
|
||||
feedRevision: 'rev-worker',
|
||||
});
|
||||
|
||||
const handler = handlers.get(TEAM_GET_MEMBER_ACTIVITY_META)!;
|
||||
const result = (await handler({} as never, 'my-team')) as {
|
||||
success: boolean;
|
||||
data: { feedRevision: string };
|
||||
};
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.feedRevision).toBe('rev-worker');
|
||||
expect(mockTeamDataWorkerClient.getMemberActivityMeta).toHaveBeenCalledWith('my-team');
|
||||
expect(service.getMemberActivityMeta).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects heavy TEAM_GET_MEMBER_ACTIVITY_META fallback in packaged runtime when worker is unavailable', async () => {
|
||||
const electron = await import('electron');
|
||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
||||
(electron.app as { isPackaged: boolean }).isPackaged = true;
|
||||
|
||||
const handler = handlers.get(TEAM_GET_MEMBER_ACTIVITY_META)!;
|
||||
const result = (await handler({} as never, 'my-team')) as { success: boolean; error?: string };
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('TEAM_DATA_WORKER_UNAVAILABLE');
|
||||
expect(service.getMemberActivityMeta).not.toHaveBeenCalled();
|
||||
vi.mocked(console.error).mockClear();
|
||||
|
||||
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
||||
});
|
||||
|
||||
it('keeps TEAM_GET_DATA read-only and never triggers reconcile side effects', async () => {
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ import { TeamDataService } from '../../../../src/main/services/team/TeamDataServ
|
|||
import type {
|
||||
InboxMessage,
|
||||
KanbanState,
|
||||
ResolvedTeamMember,
|
||||
TeamConfig,
|
||||
TeamData,
|
||||
TeamProcess,
|
||||
TeamTask,
|
||||
TeamTaskWithKanban,
|
||||
} from '../../../../src/shared/types/team';
|
||||
|
|
@ -240,10 +241,9 @@ function createGetTeamDataHarness(options: {
|
|||
config: TeamConfig,
|
||||
metaMembers: TeamConfig['members'],
|
||||
inboxNames: string[],
|
||||
tasks: TeamTaskWithKanban[],
|
||||
messages: InboxMessage[]
|
||||
) => TeamData['members'];
|
||||
listProcesses?: () => TeamData['processes'];
|
||||
tasks: TeamTaskWithKanban[]
|
||||
) => ResolvedTeamMember[];
|
||||
listProcesses?: () => TeamProcess[];
|
||||
getMemberAdvisories?: () => Promise<Map<string, unknown>>;
|
||||
} = {}) {
|
||||
const getConfig = vi.fn(async () =>
|
||||
|
|
@ -351,7 +351,7 @@ function createGetTeamDataHarness(options: {
|
|||
};
|
||||
}
|
||||
|
||||
function buildResolvedMember(name: string): TeamData['members'][number] {
|
||||
function buildResolvedMember(name: string): ResolvedTeamMember {
|
||||
return {
|
||||
name,
|
||||
status: 'unknown',
|
||||
|
|
@ -628,6 +628,39 @@ describe('TeamDataService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('returns lightweight notification context from config without hydrating team data', async () => {
|
||||
const getConfig = vi.fn(async () => ({
|
||||
name: 'My Team',
|
||||
projectPath: '/Users/dev/my-project',
|
||||
members: [],
|
||||
}));
|
||||
|
||||
const service = new TeamDataService(
|
||||
{
|
||||
listTeams: vi.fn(),
|
||||
getConfig,
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
(() => ({ processes: { listProcesses: vi.fn(() => []) } })) as never
|
||||
);
|
||||
|
||||
const result = await service.getTeamNotificationContext('my-team');
|
||||
|
||||
expect(result).toEqual({
|
||||
displayName: 'My Team',
|
||||
projectPath: '/Users/dev/my-project',
|
||||
});
|
||||
expect(getConfig).toHaveBeenCalledWith('my-team');
|
||||
});
|
||||
|
||||
it('creates task with status pending when startImmediately is false', async () => {
|
||||
const createTaskMock = vi.fn((task) => ({ ...task, status: 'pending' }));
|
||||
const service = new TeamDataService(
|
||||
|
|
@ -2437,8 +2470,8 @@ describe('TeamDataService', () => {
|
|||
} as never
|
||||
);
|
||||
|
||||
const data = await service.getTeamData('my-team');
|
||||
const costResult = data.messages.find((message) => message.messageId === 'lead-thought-1');
|
||||
const feed = await service.getMessageFeed('my-team');
|
||||
const costResult = feed.messages.find((message) => message.messageId === 'lead-thought-1');
|
||||
|
||||
expect(costResult).toMatchObject({
|
||||
messageKind: 'slash_command_result',
|
||||
|
|
@ -2507,8 +2540,8 @@ describe('TeamDataService', () => {
|
|||
} as never
|
||||
);
|
||||
|
||||
const data = await service.getTeamData('my-team');
|
||||
const result = data.messages.find((message) => message.messageId === 'passive-idle-dup-1');
|
||||
const feed = await service.getMessageFeed('my-team');
|
||||
const result = feed.messages.find((message) => message.messageId === 'passive-idle-dup-1');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.source).not.toBe('lead_process');
|
||||
|
|
@ -2582,8 +2615,8 @@ describe('TeamDataService', () => {
|
|||
sentMessages: [userReplyRow],
|
||||
});
|
||||
|
||||
const data = await service.getTeamData('my-team');
|
||||
const linked = data.messages.find((message) => message.messageId === 'passive-user-summary-1');
|
||||
const feed = await service.getMessageFeed('my-team');
|
||||
const linked = feed.messages.find((message) => message.messageId === 'passive-user-summary-1');
|
||||
|
||||
expect(linked?.relayOfMessageId).toBe('user-reply-1');
|
||||
expect(passiveSummaryRow.relayOfMessageId).toBeUndefined();
|
||||
|
|
@ -2618,8 +2651,8 @@ describe('TeamDataService', () => {
|
|||
],
|
||||
});
|
||||
|
||||
const data = await service.getTeamData('my-team');
|
||||
const linked = data.messages.find(
|
||||
const feed = await service.getMessageFeed('my-team');
|
||||
const linked = feed.messages.find(
|
||||
(message) => message.messageId === 'passive-user-summary-contains-1'
|
||||
);
|
||||
|
||||
|
|
@ -2655,8 +2688,8 @@ describe('TeamDataService', () => {
|
|||
],
|
||||
});
|
||||
|
||||
const data = await service.getTeamData('my-team');
|
||||
const linked = data.messages.find((message) => message.messageId === 'passive-user-summary-old-1');
|
||||
const feed = await service.getMessageFeed('my-team');
|
||||
const linked = feed.messages.find((message) => message.messageId === 'passive-user-summary-old-1');
|
||||
|
||||
expect(linked?.relayOfMessageId).toBeUndefined();
|
||||
});
|
||||
|
|
@ -2690,8 +2723,8 @@ describe('TeamDataService', () => {
|
|||
],
|
||||
});
|
||||
|
||||
const data = await service.getTeamData('my-team');
|
||||
const linked = data.messages.find((message) => message.messageId === 'passive-bob-summary-1');
|
||||
const feed = await service.getMessageFeed('my-team');
|
||||
const linked = feed.messages.find((message) => message.messageId === 'passive-bob-summary-1');
|
||||
|
||||
expect(linked?.relayOfMessageId).toBeUndefined();
|
||||
});
|
||||
|
|
@ -2725,8 +2758,8 @@ describe('TeamDataService', () => {
|
|||
],
|
||||
});
|
||||
|
||||
const data = await service.getTeamData('my-team');
|
||||
const linked = data.messages.find(
|
||||
const feed = await service.getMessageFeed('my-team');
|
||||
const linked = feed.messages.find(
|
||||
(message) => message.messageId === 'passive-user-summary-sender-1'
|
||||
);
|
||||
|
||||
|
|
@ -2772,8 +2805,8 @@ describe('TeamDataService', () => {
|
|||
],
|
||||
});
|
||||
|
||||
const data = await service.getTeamData('my-team');
|
||||
const linked = data.messages.find(
|
||||
const feed = await service.getMessageFeed('my-team');
|
||||
const linked = feed.messages.find(
|
||||
(message) => message.messageId === 'passive-user-summary-ambiguous-1'
|
||||
);
|
||||
|
||||
|
|
@ -3281,8 +3314,6 @@ describe('TeamDataService', () => {
|
|||
it('starts light reads immediately, bounds heavy reads, and keeps processes outside the parallel phase', async () => {
|
||||
const order: string[] = [];
|
||||
const tasksDeferred = createDeferred<TeamTask[]>();
|
||||
const messagesDeferred = createDeferred<InboxMessage[]>();
|
||||
const leadTextsDeferred = createDeferred<InboxMessage[]>();
|
||||
|
||||
const harness = createGetTeamDataHarness({
|
||||
getTasks: async () => {
|
||||
|
|
@ -3293,10 +3324,6 @@ describe('TeamDataService', () => {
|
|||
order.push('inboxNames:start');
|
||||
return [];
|
||||
},
|
||||
getMessages: async () => {
|
||||
order.push('messages:start');
|
||||
return messagesDeferred.promise;
|
||||
},
|
||||
getMembers: async () => {
|
||||
order.push('meta:start');
|
||||
return [];
|
||||
|
|
@ -3305,10 +3332,6 @@ describe('TeamDataService', () => {
|
|||
order.push('kanban:start');
|
||||
return { teamName: 'my-team', reviewers: [], tasks: {} };
|
||||
},
|
||||
readMessages: async () => {
|
||||
order.push('sent:start');
|
||||
return [];
|
||||
},
|
||||
resolveMembers: () => {
|
||||
order.push('resolveMembers');
|
||||
return [];
|
||||
|
|
@ -3330,39 +3353,21 @@ describe('TeamDataService', () => {
|
|||
},
|
||||
});
|
||||
|
||||
vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation(
|
||||
async () => {
|
||||
order.push('leadTexts:start');
|
||||
return leadTextsDeferred.promise;
|
||||
}
|
||||
);
|
||||
|
||||
const pending = harness.service.getTeamData('my-team');
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(order).toEqual(
|
||||
expect.arrayContaining([
|
||||
'inboxNames:start',
|
||||
'sent:start',
|
||||
'meta:start',
|
||||
'kanban:start',
|
||||
'tasks:start',
|
||||
'messages:start',
|
||||
])
|
||||
);
|
||||
expect(order).not.toContain('leadTexts:start');
|
||||
expect(order).not.toContain('processes:start');
|
||||
expect(order).not.toContain('leadTexts:start');
|
||||
|
||||
tasksDeferred.resolve([]);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(order).toContain('leadTexts:start');
|
||||
expect(order.indexOf('tasks:start')).toBeLessThan(order.indexOf('messages:start'));
|
||||
expect(order.indexOf('messages:start')).toBeLessThan(order.indexOf('leadTexts:start'));
|
||||
expect(order).not.toContain('processes:start');
|
||||
|
||||
messagesDeferred.resolve([]);
|
||||
leadTextsDeferred.resolve([]);
|
||||
|
||||
const data = await pending;
|
||||
|
||||
|
|
@ -3372,7 +3377,7 @@ describe('TeamDataService', () => {
|
|||
pid: 101,
|
||||
}),
|
||||
]);
|
||||
expect(order.indexOf('leadTexts:start')).toBeLessThan(order.indexOf('processes:start'));
|
||||
expect(order).not.toContain('leadTexts:start');
|
||||
expect(order.indexOf('resolveMembers')).toBeLessThan(order.indexOf('processes:start'));
|
||||
});
|
||||
|
||||
|
|
@ -3417,47 +3422,64 @@ describe('TeamDataService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('surfaces isAlive in the structural snapshot from live process state', async () => {
|
||||
const aliveHarness = createGetTeamDataHarness({
|
||||
listProcesses: () =>
|
||||
[
|
||||
{
|
||||
id: 'proc-1',
|
||||
label: 'Lead',
|
||||
pid: 101,
|
||||
registeredAt: '2026-04-09T10:00:00.000Z',
|
||||
},
|
||||
] satisfies TeamProcess[],
|
||||
});
|
||||
const offlineHarness = createGetTeamDataHarness({
|
||||
listProcesses: () =>
|
||||
[
|
||||
{
|
||||
id: 'proc-1',
|
||||
label: 'Lead',
|
||||
pid: 101,
|
||||
registeredAt: '2026-04-09T10:00:00.000Z',
|
||||
stoppedAt: '2026-04-09T10:05:00.000Z',
|
||||
},
|
||||
] satisfies TeamProcess[],
|
||||
});
|
||||
|
||||
const aliveData = await aliveHarness.service.getTeamData('my-team');
|
||||
const offlineData = await offlineHarness.service.getTeamData('my-team');
|
||||
|
||||
expect(aliveData.isAlive).toBe(true);
|
||||
expect(offlineData.isAlive).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps warning order deterministic even when read failures settle out of order', async () => {
|
||||
const tasksDeferred = createDeferred<TeamTask[]>();
|
||||
const inboxDeferred = createDeferred<string[]>();
|
||||
const messagesDeferred = createDeferred<InboxMessage[]>();
|
||||
const leadTextsDeferred = createDeferred<InboxMessage[]>();
|
||||
const sentDeferred = createDeferred<InboxMessage[]>();
|
||||
const metaDeferred = createDeferred<TeamConfig['members']>();
|
||||
const kanbanDeferred = createDeferred<KanbanState>();
|
||||
|
||||
const harness = createGetTeamDataHarness({
|
||||
getTasks: async () => tasksDeferred.promise,
|
||||
listInboxNames: async () => inboxDeferred.promise,
|
||||
getMessages: async () => messagesDeferred.promise,
|
||||
getMembers: async () => metaDeferred.promise,
|
||||
getState: async () => kanbanDeferred.promise,
|
||||
readMessages: async () => sentDeferred.promise,
|
||||
});
|
||||
|
||||
vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation(
|
||||
async () => leadTextsDeferred.promise
|
||||
);
|
||||
|
||||
const pending = harness.service.getTeamData('my-team');
|
||||
await flushMicrotasks();
|
||||
|
||||
sentDeferred.reject(new Error('sent failed'));
|
||||
kanbanDeferred.reject(new Error('kanban failed'));
|
||||
tasksDeferred.reject(new Error('tasks failed'));
|
||||
metaDeferred.reject(new Error('meta failed'));
|
||||
inboxDeferred.reject(new Error('inbox failed'));
|
||||
leadTextsDeferred.reject(new Error('lead failed'));
|
||||
messagesDeferred.reject(new Error('messages failed'));
|
||||
|
||||
const data = await pending;
|
||||
|
||||
expect(data.warnings).toEqual([
|
||||
'Tasks failed to load',
|
||||
'Inboxes failed to load',
|
||||
'Messages failed to load',
|
||||
'Lead session texts failed to load',
|
||||
'Sent messages failed to load',
|
||||
'Member metadata failed to load',
|
||||
'Kanban state failed to load',
|
||||
]);
|
||||
|
|
@ -3501,9 +3523,9 @@ describe('TeamDataService', () => {
|
|||
},
|
||||
]);
|
||||
|
||||
const data = await harness.service.getTeamData('my-team');
|
||||
const feed = await harness.service.getMessageFeed('my-team');
|
||||
|
||||
expect(data.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1', 'inbox-1']);
|
||||
expect(feed.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1', 'inbox-1']);
|
||||
});
|
||||
|
||||
it('preserves assembled messages and resolver inputs when inbox messages fail', async () => {
|
||||
|
|
@ -3552,11 +3574,10 @@ describe('TeamDataService', () => {
|
|||
]);
|
||||
|
||||
const data = await harness.service.getTeamData('my-team');
|
||||
const feed = await harness.service.getMessageFeed('my-team');
|
||||
|
||||
expect(data.warnings).toEqual(
|
||||
expect.arrayContaining(['Messages failed to load', 'Kanban state failed to load'])
|
||||
);
|
||||
expect(data.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1']);
|
||||
expect(data.warnings).toEqual(expect.arrayContaining(['Kanban state failed to load']));
|
||||
expect(feed.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1']);
|
||||
expect(resolveMembersSpy).toHaveBeenCalledWith(
|
||||
buildDefaultTeamConfig(),
|
||||
metaMembers,
|
||||
|
|
@ -3566,10 +3587,6 @@ describe('TeamDataService', () => {
|
|||
id: 'task-1',
|
||||
subject: 'Investigate rollout',
|
||||
}),
|
||||
],
|
||||
[
|
||||
expect.objectContaining({ messageId: 'sent-1' }),
|
||||
expect.objectContaining({ messageId: 'lead-1' }),
|
||||
]
|
||||
);
|
||||
});
|
||||
|
|
@ -3608,16 +3625,11 @@ describe('TeamDataService', () => {
|
|||
it('degrades a queued heavy sync throw to warning and still completes the snapshot', async () => {
|
||||
const order: string[] = [];
|
||||
const tasksDeferred = createDeferred<TeamTask[]>();
|
||||
const messagesDeferred = createDeferred<InboxMessage[]>();
|
||||
const harness = createGetTeamDataHarness({
|
||||
getTasks: async () => {
|
||||
order.push('tasks:start');
|
||||
return tasksDeferred.promise;
|
||||
},
|
||||
getMessages: async () => {
|
||||
order.push('messages:start');
|
||||
return messagesDeferred.promise;
|
||||
},
|
||||
listProcesses: () => {
|
||||
order.push('processes:start');
|
||||
return [];
|
||||
|
|
@ -3635,14 +3647,9 @@ describe('TeamDataService', () => {
|
|||
expect(order).not.toContain('leadTexts:start');
|
||||
|
||||
tasksDeferred.resolve([]);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(order).toContain('leadTexts:start');
|
||||
|
||||
messagesDeferred.resolve([]);
|
||||
const data = await pending;
|
||||
|
||||
expect(data.warnings).toEqual(expect.arrayContaining(['Lead session texts failed to load']));
|
||||
expect(data.warnings ?? []).not.toContain('Lead session texts failed to load');
|
||||
expect(order).toContain('processes:start');
|
||||
});
|
||||
|
||||
|
|
@ -3780,7 +3787,7 @@ describe('TeamDataService', () => {
|
|||
expect(page1.hasMore).toBe(true);
|
||||
|
||||
const page2 = await service.getMessagesPage('my-team', {
|
||||
beforeTimestamp: page1.nextCursor!,
|
||||
cursor: page1.nextCursor!,
|
||||
limit: 10,
|
||||
});
|
||||
// Should get the remaining 2 messages, not lose the one with same timestamp
|
||||
|
|
@ -3813,5 +3820,40 @@ describe('TeamDataService', () => {
|
|||
const result = page.messages.find((m) => m.messageId === 'resp1');
|
||||
expect(result?.messageKind).toBe('slash_command_result');
|
||||
});
|
||||
|
||||
it('normalizes stable effective message ids before pagination and cursoring', async () => {
|
||||
const msgs = [
|
||||
{
|
||||
from: 'alice',
|
||||
text: 'same-ts-a',
|
||||
timestamp: '2026-01-01T00:00:02.000Z',
|
||||
source: 'inbox' as const,
|
||||
},
|
||||
{
|
||||
from: 'bob',
|
||||
text: 'same-ts-b',
|
||||
timestamp: '2026-01-01T00:00:02.000Z',
|
||||
source: 'inbox' as const,
|
||||
},
|
||||
{
|
||||
from: 'carol',
|
||||
text: 'older',
|
||||
timestamp: '2026-01-01T00:00:01.000Z',
|
||||
source: 'inbox' as const,
|
||||
},
|
||||
];
|
||||
const service = createPaginationService(msgs);
|
||||
|
||||
const page1 = await service.getMessagesPage('my-team', { limit: 1 });
|
||||
const page2 = await service.getMessagesPage('my-team', {
|
||||
cursor: page1.nextCursor!,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(page1.messages[0]?.messageId).toMatch(/^inbox-/);
|
||||
expect(page1.nextCursor).toContain(page1.messages[0]!.messageId!);
|
||||
expect(page2.messages.every((message) => Boolean(message.messageId))).toBe(true);
|
||||
expect(new Set([...page1.messages, ...page2.messages].map((message) => message.messageId)).size).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest';
|
|||
import { TeamMemberResolver } from '../../../../src/main/services/team/TeamMemberResolver';
|
||||
|
||||
import type {
|
||||
InboxMessage,
|
||||
TeamConfig,
|
||||
TeamTask,
|
||||
TeamTaskWithKanban,
|
||||
|
|
@ -24,13 +23,8 @@ describe('TeamMemberResolver', () => {
|
|||
{ id: '1', subject: 'Visible task', status: 'pending', owner: 'alice' },
|
||||
{ id: '2', subject: 'Ghost task', status: 'pending', owner: 'stranger' },
|
||||
];
|
||||
const now = new Date().toISOString();
|
||||
const messages: InboxMessage[] = [
|
||||
{ from: 'bob', text: 'ready', timestamp: now, read: false, color: 'green' },
|
||||
{ from: 'user', text: 'system note', timestamp: now, read: false },
|
||||
];
|
||||
|
||||
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks, messages);
|
||||
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks);
|
||||
const names = members.map((member) => member.name);
|
||||
|
||||
expect(names).toHaveLength(3);
|
||||
|
|
@ -62,9 +56,8 @@ describe('TeamMemberResolver', () => {
|
|||
];
|
||||
const inboxNames = ['user', 'alice'];
|
||||
const tasks: TeamTask[] = [];
|
||||
const messages: InboxMessage[] = [];
|
||||
|
||||
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks, messages);
|
||||
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks);
|
||||
const names = members.map((m) => m.name);
|
||||
|
||||
expect(names).not.toContain('user');
|
||||
|
|
@ -81,9 +74,8 @@ describe('TeamMemberResolver', () => {
|
|||
const metaMembers: TeamConfig['members'] = [{ name: 'alice', agentType: 'general-purpose' }];
|
||||
const inboxNames = ['alice', 'team-best.user', 'dream-team.team-lead'];
|
||||
const tasks: TeamTask[] = [];
|
||||
const messages: InboxMessage[] = [];
|
||||
|
||||
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks, messages);
|
||||
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks);
|
||||
const names = members.map((m) => m.name);
|
||||
|
||||
expect(names).toContain('alice');
|
||||
|
|
@ -104,7 +96,7 @@ describe('TeamMemberResolver', () => {
|
|||
];
|
||||
const inboxNames = ['a3975f80d37fbcea1', 'alice', 'a68a8f6a643e59bfd'];
|
||||
|
||||
const members = resolver.resolveMembers(config, metaMembers, inboxNames, [], []);
|
||||
const members = resolver.resolveMembers(config, metaMembers, inboxNames, []);
|
||||
const names = members.map((m) => m.name);
|
||||
|
||||
expect(names).toContain('alice');
|
||||
|
|
@ -124,7 +116,7 @@ describe('TeamMemberResolver', () => {
|
|||
],
|
||||
};
|
||||
|
||||
const members = resolver.resolveMembers(config, [], ['ops.bot'], [], []);
|
||||
const members = resolver.resolveMembers(config, [], ['ops.bot'], []);
|
||||
const names = members.map((m) => m.name);
|
||||
|
||||
expect(names).toContain('ops.bot');
|
||||
|
|
@ -141,7 +133,6 @@ describe('TeamMemberResolver', () => {
|
|||
config,
|
||||
[],
|
||||
['cross-team:team-alpha-super', 'cross-team-team-alpha-super', 'alice'],
|
||||
[],
|
||||
[]
|
||||
);
|
||||
const names = members.map((m) => m.name);
|
||||
|
|
@ -163,7 +154,6 @@ describe('TeamMemberResolver', () => {
|
|||
config,
|
||||
[],
|
||||
['cross_team_send', 'cross_team_list_targets', 'alice'],
|
||||
[],
|
||||
[]
|
||||
);
|
||||
const names = members.map((m) => m.name);
|
||||
|
|
@ -185,7 +175,6 @@ describe('TeamMemberResolver', () => {
|
|||
config,
|
||||
[],
|
||||
['cross_team::team-alpha-super', 'cross_team--team-alpha-super', 'alice'],
|
||||
[],
|
||||
[]
|
||||
);
|
||||
const names = members.map((m) => m.name);
|
||||
|
|
@ -206,7 +195,7 @@ describe('TeamMemberResolver', () => {
|
|||
],
|
||||
};
|
||||
|
||||
const members = resolver.resolveMembers(config, [], ['ops.bot'], [], []);
|
||||
const members = resolver.resolveMembers(config, [], ['ops.bot'], []);
|
||||
const names = members.map((m) => m.name);
|
||||
|
||||
expect(names).toContain('Ops.Bot');
|
||||
|
|
@ -222,7 +211,7 @@ describe('TeamMemberResolver', () => {
|
|||
const tasks: TeamTaskWithKanban[] = [
|
||||
{ id: 't1', subject: 'Work', status: 'in_progress', owner: 'bob' },
|
||||
];
|
||||
const members = resolver.resolveMembers(config, [], [], tasks, []);
|
||||
const members = resolver.resolveMembers(config, [], [], tasks);
|
||||
const bob = members.find((m) => m.name === 'bob');
|
||||
expect(bob?.currentTaskId).toBe('t1');
|
||||
});
|
||||
|
|
@ -243,7 +232,7 @@ describe('TeamMemberResolver', () => {
|
|||
kanbanColumn: 'approved',
|
||||
},
|
||||
];
|
||||
const members = resolver.resolveMembers(config, [], [], tasks, []);
|
||||
const members = resolver.resolveMembers(config, [], [], tasks);
|
||||
const bob = members.find((m) => m.name === 'bob');
|
||||
expect(bob?.currentTaskId).toBeNull();
|
||||
});
|
||||
|
|
@ -264,7 +253,7 @@ describe('TeamMemberResolver', () => {
|
|||
// kanbanColumn not set — stale data scenario
|
||||
},
|
||||
];
|
||||
const members = resolver.resolveMembers(config, [], [], tasks, []);
|
||||
const members = resolver.resolveMembers(config, [], [], tasks);
|
||||
const bob = members.find((m) => m.name === 'bob');
|
||||
expect(bob?.currentTaskId).toBeNull();
|
||||
});
|
||||
|
|
@ -281,7 +270,7 @@ describe('TeamMemberResolver', () => {
|
|||
// Teammates sometimes send messages to "lead" instead of "team-lead",
|
||||
// creating a separate inbox file that the resolver picks up.
|
||||
const inboxNames = ['team-lead', 'lead', 'alice'];
|
||||
const members = resolver.resolveMembers(config, [], inboxNames, [], []);
|
||||
const members = resolver.resolveMembers(config, [], inboxNames, []);
|
||||
const names = members.map((m) => m.name);
|
||||
|
||||
expect(names).toContain('team-lead');
|
||||
|
|
@ -295,7 +284,7 @@ describe('TeamMemberResolver', () => {
|
|||
name: 'Team',
|
||||
members: [{ name: 'lead', agentType: 'team-lead', role: 'lead' }],
|
||||
};
|
||||
const members = resolver.resolveMembers(config, [], ['lead'], [], []);
|
||||
const members = resolver.resolveMembers(config, [], ['lead'], []);
|
||||
const names = members.map((m) => m.name);
|
||||
|
||||
expect(names).toContain('lead');
|
||||
|
|
@ -310,7 +299,7 @@ describe('TeamMemberResolver', () => {
|
|||
const tasks: TeamTaskWithKanban[] = [
|
||||
{ id: 't1', subject: 'Work', status: 'completed', owner: 'bob' },
|
||||
];
|
||||
const members = resolver.resolveMembers(config, [], [], tasks, []);
|
||||
const members = resolver.resolveMembers(config, [], [], tasks);
|
||||
const bob = members.find((m) => m.name === 'bob');
|
||||
expect(bob?.currentTaskId).toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -39,6 +39,16 @@ vi.mock('@renderer/store/slices/teamSlice', () => ({
|
|||
selectTeamDataForName: (_state: typeof storeState, teamName: string) =>
|
||||
storeState.teamDataCacheByName[teamName] ??
|
||||
(storeState.selectedTeamName === teamName ? storeState.selectedTeamData : null),
|
||||
selectTeamMemberSnapshotsForName: (_state: typeof storeState, teamName: string) =>
|
||||
(
|
||||
storeState.teamDataCacheByName[teamName] ??
|
||||
(storeState.selectedTeamName === teamName ? storeState.selectedTeamData : null)
|
||||
)?.members ?? [],
|
||||
selectResolvedMembersForTeamName: (_state: typeof storeState, teamName: string) =>
|
||||
(
|
||||
storeState.teamDataCacheByName[teamName] ??
|
||||
(storeState.selectedTeamName === teamName ? storeState.selectedTeamData : null)
|
||||
)?.members ?? [],
|
||||
}));
|
||||
|
||||
vi.mock('zustand/react/shallow', () => ({
|
||||
|
|
|
|||
|
|
@ -61,6 +61,16 @@ vi.mock('@renderer/store', () => ({
|
|||
|
||||
vi.mock('@renderer/store/slices/teamSlice', () => ({
|
||||
getCurrentProvisioningProgressForTeam: () => storeState.progress,
|
||||
selectResolvedMemberForTeamName: (state: typeof storeState, teamName: string, memberName: string) =>
|
||||
(state.selectedTeamName === teamName ? state.selectedTeamData : null)?.members.find(
|
||||
(candidate) => candidate.name === memberName
|
||||
) ?? null,
|
||||
selectTeamMemberSnapshotsForName: (state: typeof storeState, teamName: string) =>
|
||||
(state.selectedTeamName === teamName ? state.selectedTeamData : null)?.members ?? [],
|
||||
selectTeamTasksForName: (state: typeof storeState, teamName: string) =>
|
||||
(state.selectedTeamName === teamName ? state.selectedTeamData : null)?.tasks ?? [],
|
||||
selectTeamIsAliveForName: (state: typeof storeState, teamName: string) =>
|
||||
(state.selectedTeamName === teamName ? state.selectedTeamData : null)?.isAlive,
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/hooks/useTheme', () => ({
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MemberMessagesTab } from '@renderer/components/team/members/MemberMessagesTab';
|
||||
import { useStore } from '@renderer/store';
|
||||
|
||||
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
|
|
@ -48,11 +49,27 @@ describe('MemberMessagesTab', () => {
|
|||
nextCursor: null,
|
||||
hasMore: false,
|
||||
});
|
||||
useStore.setState({
|
||||
teamMessagesByName: {
|
||||
'demo-team': {
|
||||
canonicalMessages: [],
|
||||
optimisticMessages: [],
|
||||
feedRevision: 'rev-empty',
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
lastFetchedAt: null,
|
||||
loadingHead: false,
|
||||
loadingOlder: false,
|
||||
headHydrated: true,
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
getMessagesPage.mockReset();
|
||||
useStore.setState({ teamMessagesByName: {} } as never);
|
||||
});
|
||||
|
||||
it('shows both messages and comments by default and filters them separately', async () => {
|
||||
|
|
@ -110,10 +127,25 @@ describe('MemberMessagesTab', () => {
|
|||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
useStore.setState({
|
||||
teamMessagesByName: {
|
||||
'demo-team': {
|
||||
canonicalMessages: messages,
|
||||
optimisticMessages: [],
|
||||
feedRevision: 'rev-1',
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
lastFetchedAt: Date.now(),
|
||||
loadingHead: false,
|
||||
loadingOlder: false,
|
||||
headHydrated: true,
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberMessagesTab, {
|
||||
messages,
|
||||
teamName: 'demo-team',
|
||||
memberName: 'jack',
|
||||
members,
|
||||
|
|
@ -123,6 +155,8 @@ describe('MemberMessagesTab', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getMessagesPage).not.toHaveBeenCalled();
|
||||
|
||||
const getRenderedKinds = () =>
|
||||
Array.from(host.querySelectorAll('[data-testid="activity-item"]')).map((node) =>
|
||||
node.getAttribute('data-kind')
|
||||
|
|
@ -209,10 +243,35 @@ describe('MemberMessagesTab', () => {
|
|||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
useStore.setState({
|
||||
teamMessagesByName: {
|
||||
'demo-team': {
|
||||
canonicalMessages: [
|
||||
{
|
||||
from: 'team-lead',
|
||||
to: 'alice',
|
||||
text: 'Message for another member',
|
||||
summary: 'Message for another member',
|
||||
timestamp: '2026-04-13T13:34:00.000Z',
|
||||
read: false,
|
||||
messageId: 'msg-other-member',
|
||||
},
|
||||
],
|
||||
optimisticMessages: [],
|
||||
feedRevision: 'rev-older',
|
||||
nextCursor: 'older-cursor',
|
||||
hasMore: true,
|
||||
lastFetchedAt: Date.now(),
|
||||
loadingHead: false,
|
||||
loadingOlder: false,
|
||||
headHydrated: true,
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberMessagesTab, {
|
||||
messages: [],
|
||||
teamName: 'demo-team',
|
||||
memberName: 'jack',
|
||||
members,
|
||||
|
|
@ -222,6 +281,7 @@ describe('MemberMessagesTab', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getMessagesPage).not.toHaveBeenCalled();
|
||||
expect(host.textContent).toContain('No activity with this member');
|
||||
expect(host.textContent).not.toContain('Load older messages');
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,21 @@ const storeState = {
|
|||
lastSendMessageResult: null,
|
||||
teams: [],
|
||||
openTeamTab: vi.fn(),
|
||||
loadOlderTeamMessages: vi.fn().mockResolvedValue(undefined),
|
||||
teamMessagesByName: {} as Record<
|
||||
string,
|
||||
{
|
||||
canonicalMessages: InboxMessage[];
|
||||
optimisticMessages: InboxMessage[];
|
||||
feedRevision: string | null;
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
lastFetchedAt: number | null;
|
||||
loadingHead: boolean;
|
||||
loadingOlder: boolean;
|
||||
headHydrated: boolean;
|
||||
}
|
||||
>,
|
||||
};
|
||||
|
||||
const readHookState = {
|
||||
|
|
@ -146,6 +161,8 @@ describe('MessagesPanel idle summary invariants', () => {
|
|||
storeState.sendTeamMessage.mockClear();
|
||||
storeState.sendCrossTeamMessage.mockClear();
|
||||
storeState.openTeamTab.mockClear();
|
||||
storeState.loadOlderTeamMessages.mockClear();
|
||||
storeState.teamMessagesByName = {};
|
||||
});
|
||||
|
||||
it('keeps read passive peer summaries in the activity timeline while unread badge only counts filtered unread messages', async () => {
|
||||
|
|
@ -175,6 +192,17 @@ describe('MessagesPanel idle summary invariants', () => {
|
|||
];
|
||||
|
||||
await act(async () => {
|
||||
storeState.teamMessagesByName['atlas-hq'] = {
|
||||
canonicalMessages: messages,
|
||||
optimisticMessages: [],
|
||||
feedRevision: 'rev-1',
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
lastFetchedAt: Date.now(),
|
||||
loadingHead: false,
|
||||
loadingOlder: false,
|
||||
headHydrated: true,
|
||||
};
|
||||
root.render(
|
||||
React.createElement(MessagesPanel, {
|
||||
teamName: 'atlas-hq',
|
||||
|
|
@ -182,7 +210,6 @@ describe('MessagesPanel idle summary invariants', () => {
|
|||
onPositionChange: vi.fn(),
|
||||
members: [],
|
||||
tasks: [],
|
||||
messages,
|
||||
timeWindow: null,
|
||||
teamSessionIds: new Set<string>(),
|
||||
pendingRepliesByMember: {},
|
||||
|
|
@ -226,6 +253,17 @@ describe('MessagesPanel idle summary invariants', () => {
|
|||
];
|
||||
|
||||
await act(async () => {
|
||||
storeState.teamMessagesByName['atlas-hq'] = {
|
||||
canonicalMessages: messages,
|
||||
optimisticMessages: [],
|
||||
feedRevision: 'rev-1',
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
lastFetchedAt: Date.now(),
|
||||
loadingHead: false,
|
||||
loadingOlder: false,
|
||||
headHydrated: true,
|
||||
};
|
||||
root.render(
|
||||
React.createElement(MessagesPanel, {
|
||||
teamName: 'atlas-hq',
|
||||
|
|
@ -233,7 +271,6 @@ describe('MessagesPanel idle summary invariants', () => {
|
|||
onPositionChange: vi.fn(),
|
||||
members: [],
|
||||
tasks: [],
|
||||
messages,
|
||||
timeWindow: null,
|
||||
teamSessionIds: new Set<string>(),
|
||||
pendingRepliesByMember: { alice: pendingSentAtMs },
|
||||
|
|
@ -260,6 +297,17 @@ describe('MessagesPanel idle summary invariants', () => {
|
|||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
storeState.teamMessagesByName['atlas-hq'] = {
|
||||
canonicalMessages: [makeMessage()],
|
||||
optimisticMessages: [],
|
||||
feedRevision: 'rev-1',
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
lastFetchedAt: Date.now(),
|
||||
loadingHead: false,
|
||||
loadingOlder: false,
|
||||
headHydrated: true,
|
||||
};
|
||||
root.render(
|
||||
React.createElement(MessagesPanel, {
|
||||
teamName: 'atlas-hq',
|
||||
|
|
@ -268,7 +316,6 @@ describe('MessagesPanel idle summary invariants', () => {
|
|||
onPositionChange: vi.fn(),
|
||||
members: [],
|
||||
tasks: [],
|
||||
messages: [makeMessage()],
|
||||
timeWindow: null,
|
||||
teamSessionIds: new Set<string>(),
|
||||
pendingRepliesByMember: {},
|
||||
|
|
|
|||
|
|
@ -16,11 +16,10 @@ const teamState = {
|
|||
{ name: 'jack', agentType: 'developer' },
|
||||
],
|
||||
tasks: [],
|
||||
messages: [],
|
||||
},
|
||||
teamDataCacheByName: new Map<
|
||||
string,
|
||||
{ members: Record<string, unknown>[]; tasks: unknown[]; messages: unknown[] }
|
||||
{ members: Record<string, unknown>[]; tasks: unknown[] }
|
||||
>([
|
||||
[
|
||||
'demo-team',
|
||||
|
|
@ -30,7 +29,6 @@ const teamState = {
|
|||
{ name: 'jack', agentType: 'developer' },
|
||||
],
|
||||
tasks: [],
|
||||
messages: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
|
|
@ -55,6 +53,12 @@ vi.mock('@renderer/store/slices/teamSlice', () => ({
|
|||
selectTeamDataForName: (_state: typeof teamState, teamName: string) =>
|
||||
teamState.teamDataCacheByName.get(teamName) ??
|
||||
(teamState.selectedTeamName === teamName ? teamState.selectedTeamData : null),
|
||||
selectResolvedMembersForTeamName: (_state: typeof teamState, teamName: string) =>
|
||||
(
|
||||
teamState.teamDataCacheByName.get(teamName) ??
|
||||
(teamState.selectedTeamName === teamName ? teamState.selectedTeamData : null)
|
||||
)?.members ?? [],
|
||||
selectTeamMessages: () => [],
|
||||
}));
|
||||
|
||||
vi.mock('zustand/react/shallow', () => ({
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { TeamGraphAdapter } from '@features/agent-graph/renderer/adapters/TeamGraphAdapter';
|
||||
import {
|
||||
TeamGraphAdapter,
|
||||
type TeamGraphData,
|
||||
} from '@features/agent-graph/renderer/adapters/TeamGraphAdapter';
|
||||
|
||||
import type { InboxMessage, TeamData, TeamTaskWithKanban } from '@shared/types/team';
|
||||
import type { InboxMessage, TeamTaskWithKanban } from '@shared/types/team';
|
||||
import type { GraphDataPort } from '@claude-teams/agent-graph';
|
||||
|
||||
function createBaseTeamData(
|
||||
overrides?: Partial<TeamData> & {
|
||||
overrides?: Partial<TeamGraphData> & {
|
||||
tasks?: TeamTaskWithKanban[];
|
||||
messages?: InboxMessage[];
|
||||
}
|
||||
): TeamData {
|
||||
): TeamGraphData {
|
||||
const { messages, ...restOverrides } = overrides ?? {};
|
||||
return {
|
||||
teamName: 'my-team',
|
||||
config: {
|
||||
|
|
@ -46,11 +50,11 @@ function createBaseTeamData(
|
|||
},
|
||||
],
|
||||
tasks: [],
|
||||
messages: [],
|
||||
messageFeed: messages ?? [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
isAlive: true,
|
||||
...overrides,
|
||||
...restOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +1,20 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
type ActivityEntrySourceData,
|
||||
buildInlineActivityEntries,
|
||||
getGraphLeadMemberName,
|
||||
} from '@features/agent-graph/core/domain/buildInlineActivityEntries';
|
||||
|
||||
import type { InboxMessage, TeamData, TeamTaskWithKanban } from '@shared/types/team';
|
||||
import type { InboxMessage, TeamTaskWithKanban } from '@shared/types/team';
|
||||
|
||||
function createBaseTeamData(
|
||||
overrides?: Partial<TeamData> & {
|
||||
overrides?: Partial<ActivityEntrySourceData> & {
|
||||
tasks?: TeamTaskWithKanban[];
|
||||
messages?: InboxMessage[];
|
||||
}
|
||||
): TeamData {
|
||||
): ActivityEntrySourceData {
|
||||
return {
|
||||
teamName: 'my-team',
|
||||
config: {
|
||||
name: 'My Team',
|
||||
members: [{ name: 'team-lead' }, { name: 'alice' }, { name: 'bob' }],
|
||||
projectPath: '/repo',
|
||||
},
|
||||
members: [
|
||||
{
|
||||
name: 'team-lead',
|
||||
|
|
@ -49,9 +44,6 @@ function createBaseTeamData(
|
|||
],
|
||||
tasks: [],
|
||||
messages: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
isAlive: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ vi.mock('@renderer/api', () => ({
|
|||
}));
|
||||
|
||||
import { initializeNotificationListeners, useStore } from '../../../src/renderer/store';
|
||||
import { __resetTeamSliceModuleStateForTests } from '../../../src/renderer/store/slices/teamSlice';
|
||||
import { api } from '@renderer/api';
|
||||
|
||||
describe('team change throttling', () => {
|
||||
|
|
@ -69,17 +70,29 @@ describe('team change throttling', () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
__resetTeamSliceModuleStateForTests();
|
||||
const fetchTeams = vi.fn(async () => undefined);
|
||||
const fetchMemberSpawnStatuses = vi.fn(async () => undefined);
|
||||
const refreshTeamData = vi.fn(async () => undefined);
|
||||
const refreshTeamMessagesHead = vi.fn(async () => ({
|
||||
feedChanged: true,
|
||||
headChanged: true,
|
||||
feedRevision: 'rev-1',
|
||||
}));
|
||||
const refreshMemberActivityMeta = vi.fn(async () => undefined);
|
||||
const refreshTeamChangePresence = vi.fn(async () => undefined);
|
||||
|
||||
useStore.setState({
|
||||
fetchTeams,
|
||||
fetchMemberSpawnStatuses,
|
||||
refreshTeamData,
|
||||
refreshTeamMessagesHead,
|
||||
refreshMemberActivityMeta,
|
||||
refreshTeamChangePresence,
|
||||
selectedTeamName: null,
|
||||
selectedTeamData: null,
|
||||
teamDataCacheByName: {},
|
||||
memberActivityMetaByTeam: {},
|
||||
paneLayout: {
|
||||
focusedPaneId: 'p1',
|
||||
panes: [
|
||||
|
|
@ -103,6 +116,7 @@ describe('team change throttling', () => {
|
|||
afterEach(() => {
|
||||
cleanup?.();
|
||||
cleanup = null;
|
||||
__resetTeamSliceModuleStateForTests();
|
||||
vi.mocked(console.warn).mockClear();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
|
@ -149,10 +163,12 @@ describe('team change throttling', () => {
|
|||
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('lead-message refreshes detail only, not team list or tasks', async () => {
|
||||
it('lead-message refreshes message head only, not team list, tasks, or structural detail', async () => {
|
||||
const state = useStore.getState();
|
||||
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
|
||||
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
||||
const refreshTeamMessagesHeadSpy = vi.spyOn(state, 'refreshTeamMessagesHead');
|
||||
const refreshMemberActivityMetaSpy = vi.spyOn(state, 'refreshMemberActivityMeta');
|
||||
|
||||
// Emit a lead-message event
|
||||
hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' });
|
||||
|
|
@ -161,9 +177,11 @@ describe('team change throttling', () => {
|
|||
await vi.advanceTimersByTimeAsync(2100);
|
||||
expect(fetchTeamsSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Should trigger refreshTeamData at 800ms
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1);
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
||||
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
||||
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledTimes(1);
|
||||
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('my-team');
|
||||
expect(refreshMemberActivityMetaSpy).toHaveBeenCalledTimes(1);
|
||||
expect(refreshMemberActivityMetaSpy).toHaveBeenCalledWith('my-team');
|
||||
});
|
||||
|
||||
it('lead-message refreshes visible graph tabs even when the team is not selected', async () => {
|
||||
|
|
@ -174,7 +192,6 @@ describe('team change throttling', () => {
|
|||
config: { name: 'Other Team', members: [], projectPath: '/repo' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: [],
|
||||
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
},
|
||||
|
|
@ -192,11 +209,88 @@ describe('team change throttling', () => {
|
|||
} as never);
|
||||
|
||||
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
|
||||
const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead');
|
||||
|
||||
hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
||||
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
||||
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('my-team');
|
||||
});
|
||||
|
||||
it('lead-message refreshes hidden teams with an active pending-reply wait state', async () => {
|
||||
useStore.getState().syncTeamPendingReplyRefresh('other-team', true, 60_000);
|
||||
useStore.setState({
|
||||
paneLayout: {
|
||||
focusedPaneId: 'p1',
|
||||
panes: [
|
||||
{
|
||||
id: 'p1',
|
||||
widthFraction: 1,
|
||||
tabs: [{ id: 't1', type: 'team', teamName: 'my-team', label: 'my-team' }],
|
||||
activeTabId: 't1',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never);
|
||||
|
||||
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
|
||||
const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead');
|
||||
const refreshMemberActivityMetaSpy = vi.spyOn(useStore.getState(), 'refreshMemberActivityMeta');
|
||||
|
||||
hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'other-team' });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
||||
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('other-team');
|
||||
expect(refreshMemberActivityMetaSpy).toHaveBeenCalledWith('other-team');
|
||||
});
|
||||
|
||||
it('lead-message does not refresh hidden inactive teams without pending replies', async () => {
|
||||
useStore.setState({
|
||||
paneLayout: {
|
||||
focusedPaneId: 'p1',
|
||||
panes: [
|
||||
{
|
||||
id: 'p1',
|
||||
widthFraction: 1,
|
||||
tabs: [{ id: 't1', type: 'team', teamName: 'my-team', label: 'my-team' }],
|
||||
activeTabId: 't1',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never);
|
||||
|
||||
const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead');
|
||||
const refreshMemberActivityMetaSpy = vi.spyOn(useStore.getState(), 'refreshMemberActivityMeta');
|
||||
|
||||
hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'other-team' });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
expect(refreshTeamMessagesHeadSpy).not.toHaveBeenCalledWith('other-team');
|
||||
expect(refreshMemberActivityMetaSpy).not.toHaveBeenCalledWith('other-team');
|
||||
});
|
||||
|
||||
it('member-spawn refreshes spawn statuses without forcing structural refresh', async () => {
|
||||
const fetchMemberSpawnStatusesSpy = vi.spyOn(useStore.getState(), 'fetchMemberSpawnStatuses');
|
||||
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
|
||||
|
||||
hoisted.onTeamChangeCb?.({}, { type: 'member-spawn', teamName: 'my-team' });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledWith('my-team');
|
||||
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('inbox/config/process do not refresh member spawn statuses by default', async () => {
|
||||
const fetchMemberSpawnStatusesSpy = vi.spyOn(useStore.getState(), 'fetchMemberSpawnStatuses');
|
||||
|
||||
hoisted.onTeamChangeCb?.({}, { type: 'inbox', teamName: 'my-team' });
|
||||
hoisted.onTeamChangeCb?.({}, { type: 'config', teamName: 'my-team' });
|
||||
hoisted.onTeamChangeCb?.({}, { type: 'process', teamName: 'my-team' });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
expect(fetchMemberSpawnStatusesSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lead-message does not call fetchAllTasks', async () => {
|
||||
|
|
@ -209,6 +303,17 @@ describe('team change throttling', () => {
|
|||
expect(fetchAllTasksSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fallback polling refreshes hidden teams with an active pending-reply wait state', async () => {
|
||||
useStore.getState().syncTeamPendingReplyRefresh('other-team', true, 60_000);
|
||||
const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead');
|
||||
const refreshMemberActivityMetaSpy = vi.spyOn(useStore.getState(), 'refreshMemberActivityMeta');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('other-team');
|
||||
expect(refreshMemberActivityMetaSpy).toHaveBeenCalledWith('other-team');
|
||||
});
|
||||
|
||||
it('log-source-change refreshes only task change presence', async () => {
|
||||
useStore.setState({
|
||||
selectedTeamName: 'my-team',
|
||||
|
|
@ -217,7 +322,6 @@ describe('team change throttling', () => {
|
|||
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
},
|
||||
|
|
@ -248,7 +352,6 @@ describe('team change throttling', () => {
|
|||
config: { name: 'Other Team', members: [], projectPath: '/repo' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: [],
|
||||
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
},
|
||||
|
|
@ -258,7 +361,6 @@ describe('team change throttling', () => {
|
|||
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
},
|
||||
|
|
@ -318,7 +420,6 @@ describe('team change throttling', () => {
|
|||
},
|
||||
],
|
||||
members: [],
|
||||
messages: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
},
|
||||
|
|
@ -354,7 +455,6 @@ describe('team change throttling', () => {
|
|||
config: { name: 'Other Team', members: [], projectPath: '/repo' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: [],
|
||||
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
},
|
||||
|
|
@ -387,7 +487,6 @@ describe('team change throttling', () => {
|
|||
},
|
||||
],
|
||||
members: [],
|
||||
messages: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
},
|
||||
|
|
@ -448,6 +547,7 @@ describe('team change throttling', () => {
|
|||
|
||||
const state = useStore.getState();
|
||||
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
||||
const refreshTeamMessagesHeadSpy = vi.spyOn(state, 'refreshTeamMessagesHead');
|
||||
|
||||
// Fire rapid events for my-team (throttled)
|
||||
hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' });
|
||||
|
|
@ -459,9 +559,10 @@ describe('team change throttling', () => {
|
|||
await vi.advanceTimersByTimeAsync(800);
|
||||
|
||||
// Both teams should get exactly 1 refresh each
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2);
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
||||
expect(refreshTeamDataSpy).toHaveBeenCalledWith('other-team', { withDedup: true });
|
||||
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
||||
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledTimes(2);
|
||||
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('my-team');
|
||||
expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('other-team');
|
||||
});
|
||||
|
||||
it('keeps auto change presence tracking disabled even after selected team data is hydrated', async () => {
|
||||
|
|
@ -477,7 +578,6 @@ describe('team change throttling', () => {
|
|||
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
},
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -20,12 +20,6 @@ describe('buildTeamProvisioningPresentation', () => {
|
|||
members: [
|
||||
{
|
||||
name: 'team-lead',
|
||||
agentType: 'team-lead',
|
||||
status: 'active',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
lastActiveAt: null,
|
||||
messageCount: 0,
|
||||
},
|
||||
],
|
||||
memberSpawnStatuses: {},
|
||||
|
|
|
|||
Loading…
Reference in a new issue