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