refactor(team): split team detail snapshot Merge pull request #58 from 777genius/spike/team-snapshot-split-plan

Merge pull request #58 from 777genius/spike/team-snapshot-split-plan
This commit is contained in:
Илия 2026-04-18 22:33:46 +03:00 committed by GitHub
commit cd4e9ccba8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
128 changed files with 12420 additions and 2013 deletions

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@
* All state in refs no React re-renders.
*/
import { useRef, useCallback } from 'react';
import { useRef, useCallback, useMemo } from 'react';
import type { GraphNode } from '../ports/types';
import { CAMERA, ANIM, NODE, TASK_PILL } from '../constants/canvas-constants';
import type { WorldBounds } from '../layout/launchAnchor';
@ -170,17 +170,31 @@ export function useGraphCamera(): UseGraphCameraResult {
t.zoom = Math.max(CAMERA.minZoom, t.zoom / 1.2);
}, []);
return {
transformRef,
screenToWorld,
worldToScreen,
handleWheel,
handlePanStart,
handlePanMove,
handlePanEnd,
zoomToFit,
zoomIn,
zoomOut,
updateInertia,
};
return useMemo(
() => ({
transformRef,
screenToWorld,
worldToScreen,
handleWheel,
handlePanStart,
handlePanMove,
handlePanEnd,
zoomToFit,
zoomIn,
zoomOut,
updateInertia,
}),
[
screenToWorld,
worldToScreen,
handleWheel,
handlePanStart,
handlePanMove,
handlePanEnd,
zoomToFit,
zoomIn,
zoomOut,
updateInertia,
]
);
}

View file

@ -3,7 +3,7 @@
* Delegates hit testing to strategy pattern.
*/
import { useRef, useCallback } from 'react';
import { useRef, useCallback, useMemo } from 'react';
import type { GraphNode } from '../ports/types';
import { ANIM } from '../constants/canvas-constants';
import { findNodeAt } from '../canvas/hit-detection';
@ -81,13 +81,16 @@ export function useGraphInteraction(
return findNodeAt(wx, wy, nodes);
}, []);
return {
hoveredNodeId,
dragNodeId,
isDragging,
handleMouseDown,
handleMouseMove,
handleMouseUp,
handleDoubleClick,
};
return useMemo(
() => ({
hoveredNodeId,
dragNodeId,
isDragging,
handleMouseDown,
handleMouseMove,
handleMouseUp,
handleDoubleClick,
}),
[handleDoubleClick, handleMouseDown, handleMouseMove, handleMouseUp]
);
}

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { ANIM_SPEED, NODE } from '../constants/canvas-constants';
import { getStateColor } from '../constants/colors';
@ -239,19 +239,29 @@ export function useGraphSimulation(): UseGraphSimulationResult {
};
}, []);
return {
stateRef,
updateData,
tick,
setNodePosition,
clearNodePosition,
clearTransientOwnerPositions,
resolveNearestOwnerSlot,
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
getExtraWorldBounds: () => extraWorldBoundsRef.current,
};
return useMemo(
() => ({
stateRef,
updateData,
tick,
setNodePosition,
clearNodePosition,
clearTransientOwnerPositions,
resolveNearestOwnerSlot,
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
getExtraWorldBounds: () => extraWorldBoundsRef.current,
}),
[
updateData,
tick,
setNodePosition,
clearNodePosition,
clearTransientOwnerPositions,
resolveNearestOwnerSlot,
]
);
}
function applySnapshotToNodes(

View file

@ -32,7 +32,7 @@ import {
findNodeAt,
getEdgeMidpoint,
} from '../canvas/hit-detection';
import { ANIM_SPEED } from '../constants/canvas-constants';
import { ANIM, ANIM_SPEED } from '../constants/canvas-constants';
import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor';
export interface GraphViewProps {
@ -148,13 +148,6 @@ export function GraphView({
// ─── Hooks ──────────────────────────────────────────────────────────────
const simulation = useGraphSimulation();
const camera = useGraphCamera();
// Stable refs for RAF loop (avoid recreating animate on hook identity change)
const simulationRef = useRef(simulation);
simulationRef.current = simulation;
const cameraRef = useRef(camera);
cameraRef.current = camera;
const interaction = useGraphInteraction(
useCallback(
(nodeId: string, x: number, y: number) => {
@ -164,6 +157,20 @@ export function GraphView({
)
);
// Stable refs for RAF loop (avoid recreating animate on hook identity change)
const simulationRef = useRef(simulation);
simulationRef.current = simulation;
const cameraRef = useRef(camera);
cameraRef.current = camera;
const interactionRef = useRef(interaction);
interactionRef.current = interaction;
const processActivePointerMoveRef = useRef<((clientX: number, clientY: number) => boolean) | null>(
null
);
const completePointerInteractionRef = useRef<((clientX: number, clientY: number) => void) | null>(
null
);
const getVisibleNodes = useCallback(
(nodes: GraphNode[]): GraphNode[] =>
nodes.filter((node) => {
@ -433,16 +440,16 @@ export function GraphView({
}, []);
useLayoutEffect(() => {
if (!isSurfaceActive) {
if (isSurfaceActive) {
return;
}
interaction.handleMouseUp();
simulation.clearTransientOwnerPositions();
interactionRef.current.handleMouseUp();
simulationRef.current.clearTransientOwnerPositions();
dragPreviewRef.current = null;
isPanningRef.current = false;
edgeMouseDownRef.current = null;
setInteractionGuards(false);
}, [interaction, isSurfaceActive, simulation]);
}, [isSurfaceActive, setInteractionGuards]);
const handleWheel = useCallback(
(e: WheelEvent) => {
@ -454,7 +461,13 @@ export function GraphView({
// ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─
const isPanningRef = useRef(false);
const edgeMouseDownRef = useRef<{ id: string; x: number; y: number } | null>(null);
const edgeMouseDownRef = useRef<{
id: string;
worldX: number;
worldY: number;
clientX: number;
clientY: number;
} | null>(null);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
@ -491,7 +504,13 @@ export function GraphView({
if (hitEdge) {
markUserInteracted();
isPanningRef.current = false;
edgeMouseDownRef.current = { id: hitEdge, x: world.x, y: world.y };
edgeMouseDownRef.current = {
id: hitEdge,
worldX: world.x,
worldY: world.y,
clientX: e.clientX,
clientY: e.clientY,
};
hoveredEdgeIdRef.current = hitEdge;
} else {
// Hit empty space → pan
@ -518,11 +537,6 @@ export function GraphView({
const processActivePointerMove = useCallback(
(clientX: number, clientY: number) => {
if (!activePrimaryInteractionRef.current) {
dragPreviewRef.current = null;
return false;
}
if (isPanningRef.current) {
if (typeof document !== 'undefined') {
document.getSelection()?.removeAllRanges();
@ -531,6 +545,36 @@ export function GraphView({
return true;
}
const edgeMouseDown = edgeMouseDownRef.current;
if (
edgeMouseDown &&
!interaction.dragNodeId.current &&
!interaction.isDragging.current
) {
const dx = clientX - edgeMouseDown.clientX;
const dy = clientY - edgeMouseDown.clientY;
if (dx * dx + dy * dy > ANIM.dragThresholdPx * ANIM.dragThresholdPx) {
if (typeof document !== 'undefined') {
document.getSelection()?.removeAllRanges();
}
hoveredEdgeIdRef.current = null;
edgeMouseDownRef.current = null;
isPanningRef.current = true;
camera.handlePanStart(edgeMouseDown.clientX, edgeMouseDown.clientY);
camera.handlePanMove(clientX, clientY);
return true;
}
}
if (
!activePrimaryInteractionRef.current &&
!interaction.dragNodeId.current &&
!interaction.isDragging.current
) {
dragPreviewRef.current = null;
return false;
}
const canvas = canvasHandle.current?.getCanvas();
if (!canvas) {
dragPreviewRef.current = null;
@ -627,8 +671,8 @@ export function GraphView({
if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) {
const rect = canvas.getBoundingClientRect();
const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top);
const dx = world.x - edgeMouseDownRef.current.x;
const dy = world.y - edgeMouseDownRef.current.y;
const dx = world.x - edgeMouseDownRef.current.worldX;
const dy = world.y - edgeMouseDownRef.current.worldY;
if (dx * dx + dy * dy <= 25) {
clickedEdgeId = edgeMouseDownRef.current.id;
}
@ -656,6 +700,8 @@ export function GraphView({
},
[camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation]
);
processActivePointerMoveRef.current = processActivePointerMove;
completePointerInteractionRef.current = completePointerInteraction;
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
@ -711,36 +757,40 @@ export function GraphView({
if (
!activePrimaryInteractionRef.current &&
!isPanningRef.current &&
!interaction.dragNodeId.current &&
!interaction.isDragging.current &&
!interactionRef.current.dragNodeId.current &&
!interactionRef.current.isDragging.current &&
!edgeMouseDownRef.current
) {
return;
}
event.preventDefault();
processActivePointerMove(event.clientX, event.clientY);
processActivePointerMoveRef.current?.(event.clientX, event.clientY);
};
const handleWindowMouseUp = (event: MouseEvent): void => {
if (
!activePrimaryInteractionRef.current &&
!isPanningRef.current &&
!interaction.dragNodeId.current &&
!interaction.isDragging.current &&
!interactionRef.current.dragNodeId.current &&
!interactionRef.current.isDragging.current &&
!edgeMouseDownRef.current
) {
setInteractionGuards(false);
return;
}
completePointerInteraction(event.clientX, event.clientY);
completePointerInteractionRef.current?.(event.clientX, event.clientY);
};
const clearInteraction = (): void => {
if (!activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.isDragging.current) {
if (
!activePrimaryInteractionRef.current &&
!isPanningRef.current &&
!interactionRef.current.isDragging.current
) {
return;
}
interaction.handleMouseUp();
camera.handlePanEnd();
interactionRef.current.handleMouseUp();
cameraRef.current.handlePanEnd();
isPanningRef.current = false;
edgeMouseDownRef.current = null;
dragPreviewRef.current = null;
@ -756,9 +806,14 @@ export function GraphView({
window.removeEventListener('mouseup', handleWindowMouseUp);
window.removeEventListener('blur', clearInteraction);
window.removeEventListener('dragstart', clearInteraction);
};
}, [setInteractionGuards]);
useEffect(() => {
return () => {
setInteractionGuards(false);
};
}, [camera, completePointerInteraction, interaction, processActivePointerMove, setInteractionGuards]);
}, [setInteractionGuards]);
const handleDoubleClick = useCallback(
(e: React.MouseEvent) => {

View file

@ -1,5 +1,5 @@
/**
* TeamGraphAdapter transforms Zustand TeamData GraphDataPort.
* TeamGraphAdapter transforms store-backed team graph input GraphDataPort.
*
* This adapter owns the graph projection from team runtime state into the
* reusable package port model. Renderer hooks may still read store state, but
@ -57,12 +57,18 @@ import type {
LeadActivityState,
MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot,
TeamData,
ResolvedTeamMember,
TeamProcess,
TeamProvisioningProgress,
TeamViewSnapshot,
} from '@shared/types/team';
import type { LeadContextUsage } from '@shared/types/team';
export interface TeamGraphData extends TeamViewSnapshot {
members: ResolvedTeamMember[];
messageFeed: InboxMessage[];
}
export class TeamGraphAdapter {
// ─── ES #private fields ──────────────────────────────────────────────────
#lastTeamName = '';
@ -89,7 +95,7 @@ export class TeamGraphAdapter {
* Adapt team data into a GraphDataPort snapshot.
*/
adapt(
teamData: TeamData | null,
teamData: TeamGraphData | null,
teamName: string,
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
leadActivity?: LeadActivityState,
@ -190,7 +196,7 @@ export class TeamGraphAdapter {
this.#buildMessageParticles(
particles,
nodes,
teamData.messages,
teamData.messageFeed,
teamName,
leadId,
leadName,
@ -233,11 +239,11 @@ export class TeamGraphAdapter {
// ─── Private: node builders ──────────────────────────────────────────────
static #getLeadMemberName(data: TeamData, teamName: string): string {
static #getLeadMemberName(data: TeamGraphData, teamName: string): string {
return getGraphLeadMemberName(data, teamName);
}
static #buildMemberNodeIdByAlias(data: TeamData, teamName: string): Map<string, string> {
static #buildMemberNodeIdByAlias(data: TeamGraphData, teamName: string): Map<string, string> {
return buildGraphMemberNodeIdAliasMap(
teamName,
data.members.filter((member) => !isLeadMember(member))
@ -245,7 +251,7 @@ export class TeamGraphAdapter {
}
static #buildLayoutPort(
data: TeamData,
data: TeamGraphData,
teamName: string,
slotAssignments?: Record<string, GraphOwnerSlotAssignment>
): GraphLayoutPort {
@ -263,7 +269,7 @@ export class TeamGraphAdapter {
);
const assignedStableOwnerIds = new Set(Object.keys(slotAssignments ?? {}));
const pushMember = (member: TeamData['members'][number] | undefined): void => {
const pushMember = (member: TeamGraphData['members'][number] | undefined): void => {
if (!member) {
return;
}
@ -315,7 +321,7 @@ export class TeamGraphAdapter {
}
static #collectDuplicateStableOwnerIds(
members: readonly TeamData['members'][number][]
members: readonly TeamGraphData['members'][number][]
): string[] {
const counts = new Map<string, number>();
for (const member of members) {
@ -337,9 +343,9 @@ export class TeamGraphAdapter {
}
static #getRuntimeLabel(
providerId: TeamData['members'][number]['providerId'],
model: TeamData['members'][number]['model'],
effort: TeamData['members'][number]['effort']
providerId: ResolvedTeamMember['providerId'],
model: ResolvedTeamMember['model'],
effort: ResolvedTeamMember['effort']
): string | undefined {
return formatTeamRuntimeSummary(providerId, model, effort);
}
@ -360,7 +366,7 @@ export class TeamGraphAdapter {
#buildLeadNode(
nodes: GraphNode[],
leadId: string,
data: TeamData,
data: TeamGraphData,
teamName: string,
leadName: string,
pendingApprovalAgents?: Set<string>,
@ -456,7 +462,7 @@ export class TeamGraphAdapter {
nodes: GraphNode[],
edges: GraphEdge[],
leadId: string,
data: TeamData,
data: TeamGraphData,
teamName: string,
memberNodeIdByAlias: ReadonlyMap<string, string>,
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
@ -560,14 +566,14 @@ export class TeamGraphAdapter {
#buildTaskNodes(
nodes: GraphNode[],
edges: GraphEdge[],
data: TeamData,
data: TeamGraphData,
teamName: string,
commentReadState?: Record<string, unknown>,
memberNodeIdByAlias?: ReadonlyMap<string, string>,
leadId?: string,
leadName?: string
): void {
const taskStateById = new Map<string, Pick<TeamData['tasks'][number], 'status'>>();
const taskStateById = new Map<string, Pick<TeamGraphData['tasks'][number], 'status'>>();
const taskDisplayIds = new Map<string, string>();
const memberColorByName = new Map<string, string>();
@ -752,7 +758,7 @@ export class TeamGraphAdapter {
#buildProcessNodes(
nodes: GraphNode[],
edges: GraphEdge[],
data: TeamData,
data: TeamGraphData,
teamName: string,
memberNodeIdByAlias?: ReadonlyMap<string, string>
): void {
@ -830,7 +836,7 @@ export class TeamGraphAdapter {
#attachActivityFeeds(
nodes: GraphNode[],
data: TeamData,
data: TeamGraphData,
teamName: string,
leadId: string,
leadName: string
@ -847,7 +853,10 @@ export class TeamGraphAdapter {
}
const entriesByOwnerNodeId = buildInlineActivityEntries({
data,
data: {
...data,
messages: data.messageFeed,
},
teamName,
leadId,
leadName,
@ -1008,7 +1017,7 @@ export class TeamGraphAdapter {
#buildCommentParticles(
particles: GraphParticle[],
data: TeamData,
data: TeamGraphData,
teamName: string,
leadId: string,
leadName: string,
@ -1101,8 +1110,8 @@ export class TeamGraphAdapter {
}
static #buildMemberException(
runtimeAdvisory: TeamData['members'][number]['runtimeAdvisory'],
providerId: TeamData['members'][number]['providerId'],
runtimeAdvisory: ResolvedTeamMember['runtimeAdvisory'],
providerId: ResolvedTeamMember['providerId'],
spawn: MemberSpawnStatusEntry | undefined,
pendingApproval: boolean
): Pick<GraphNode, 'exceptionTone' | 'exceptionLabel'> | undefined {

View file

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

View file

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

View file

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

View file

@ -10,20 +10,25 @@ import { useStore } from '@renderer/store';
import {
getCurrentProvisioningProgressForTeam,
isTeamGraphSlotPersistenceDisabled,
selectResolvedMembersForTeamName,
selectTeamDataForName,
selectTeamMessages,
} from '@renderer/store/slices/teamSlice';
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
import { useShallow } from 'zustand/react/shallow';
import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter';
import type { TeamGraphData } from '../adapters/TeamGraphAdapter';
import type { GraphDataPort } from '@claude-teams/agent-graph';
export function useTeamGraphAdapter(teamName: string): GraphDataPort {
const adapterRef = useRef<TeamGraphAdapter>(TeamGraphAdapter.create());
const {
teamData,
teamSnapshot,
members,
messages,
spawnStatuses,
leadActivity,
leadContext,
@ -38,7 +43,9 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
ensureTeamGraphSlotAssignments,
} = useStore(
useShallow((s) => ({
teamData: selectTeamDataForName(s, teamName),
teamSnapshot: selectTeamDataForName(s, teamName),
members: selectResolvedMembersForTeamName(s, teamName),
messages: selectTeamMessages(s, teamName),
spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
leadActivity: teamName ? s.leadActivityByTeam[teamName] : undefined,
leadContext: teamName ? s.leadContextByTeam[teamName] : undefined,
@ -64,6 +71,17 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
return agents;
}, [pendingApprovals, teamName]);
const teamData = useMemo<TeamGraphData | null>(() => {
if (!teamSnapshot) {
return null;
}
return {
...teamSnapshot,
members,
messageFeed: messages,
};
}, [members, messages, teamSnapshot]);
const commentReadState = useSyncExternalStore(subscribe, getSnapshot);
const effectiveSlotAssignments = useMemo(() => {
@ -97,9 +115,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
const currentAssignment = slotAssignments[stableOwnerId];
const defaultAssignment = defaultSeed.assignments[stableOwnerId];
return (
currentAssignment &&
defaultAssignment &&
currentAssignment.ringIndex === defaultAssignment.ringIndex &&
currentAssignment?.ringIndex === defaultAssignment?.ringIndex &&
currentAssignment.sectorIndex === defaultAssignment.sectorIndex
);
});

View file

@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { useStore } from '@renderer/store';
import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamSlice';
import { parseGraphMemberNodeId } from '../../core/domain/graphOwnerIdentity';
@ -8,6 +9,7 @@ import type { GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
export function useTeamGraphSurfaceActions(teamName: string): {
openTeamPage: () => void;
resetOwnerSlotAssignmentsToDefaults: () => void;
commitOwnerSlotDrop: (payload: {
nodeId: string;
assignment: GraphOwnerSlotAssignment;
@ -19,6 +21,13 @@ export function useTeamGraphSurfaceActions(teamName: string): {
useStore.getState().openTeamTab(teamName);
}, [teamName]);
const resetOwnerSlotAssignmentsToDefaults = useCallback(() => {
if (!isTeamGraphSlotPersistenceDisabled()) {
return;
}
useStore.getState().resetTeamGraphSlotAssignmentsToDefaults(teamName);
}, [teamName]);
const commitOwnerSlotDrop = useCallback(
(payload: {
nodeId: string;
@ -51,6 +60,7 @@ export function useTeamGraphSurfaceActions(teamName: string): {
return {
openTeamPage,
resetOwnerSlotAssignmentsToDefaults,
commitOwnerSlotDrop,
};
}

View file

@ -5,6 +5,7 @@
* into ui/, hooks/, or core/ directly.
*/
export type { InlineActivityEntry } from '../core/domain/buildInlineActivityEntries';
export { buildInlineActivityEntries } from '../core/domain/buildInlineActivityEntries';
export { buildGraphMemberNodeIdForMember } from '../core/domain/graphOwnerIdentity';
export { TeamGraphAdapter } from './adapters/TeamGraphAdapter';

View file

@ -1,7 +1,7 @@
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
import {
resolveMessageRenderProps,
type MessageContext,
resolveMessageRenderProps,
} from '@renderer/components/team/activity/activityMessageContext';
import type {

View file

@ -13,6 +13,7 @@ import {
type InlineActivityEntry,
} from '../../core/domain/buildInlineActivityEntries';
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
import { GraphActivityCard } from './GraphActivityCard';
import type { GraphNode } from '@claude-teams/agent-graph';
@ -74,6 +75,9 @@ export const GraphActivityHud = ({
const connectorPathRefs = useRef(new Map<string, SVGPathElement | null>());
const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(null);
const { teamData, teams } = useGraphActivityContext(teamName);
const teamSnapshot = teamData;
const members = teamData?.members ?? [];
const messages = teamData?.messageFeed ?? [];
const ownerNodes = useMemo(
() =>
@ -84,21 +88,27 @@ export const GraphActivityHud = ({
[nodes]
);
const leadNodeId = ownerNodes.find((node) => node.kind === 'lead')?.id ?? `lead:${teamName}`;
const leadName = teamData ? getGraphLeadMemberName(teamData, teamName) : `${teamName}-lead`;
const leadName = teamSnapshot
? getGraphLeadMemberName({ members }, teamName)
: `${teamName}-lead`;
const ownerNodeIds = useMemo(() => new Set(ownerNodes.map((node) => node.id)), [ownerNodes]);
const entryMapByOwnerNodeId = useMemo(() => {
if (!teamData) {
if (!teamSnapshot) {
return new Map<string, InlineActivityEntry[]>();
}
return buildInlineActivityEntries({
data: teamData,
data: {
members,
tasks: teamSnapshot.tasks,
messages,
},
teamName,
leadId: leadNodeId,
leadName,
ownerNodeIds,
});
}, [leadName, leadNodeId, ownerNodeIds, teamData, teamName]);
const messageContext = useMemo(() => buildMessageContext(teamData?.members), [teamData?.members]);
}, [leadName, leadNodeId, members, messages, ownerNodeIds, teamName, teamSnapshot]);
const messageContext = useMemo(() => buildMessageContext(members), [members]);
const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams);
const { readSet } = useTeamMessagesRead(teamName);
@ -350,7 +360,7 @@ export const GraphActivityHud = ({
};
}, [enabled, forwardWheelToGraph, visibleLanes]);
if (!enabled || !teamData || visibleLanes.length === 0) {
if (!enabled || !teamSnapshot || visibleLanes.length === 0) {
return null;
}
@ -477,7 +487,7 @@ export const GraphActivityHud = ({
}
}}
teamName={teamName}
members={teamData.members}
members={members}
onMemberClick={handleMemberClick}
onTaskIdClick={onOpenTaskDetail}
teamNames={teamNames}

View file

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

View file

@ -89,7 +89,6 @@ export const TeamGraphOverlay = ({
const openCreateTask = useCallback(() => {
openCreateTaskDialog('');
}, [openCreateTaskDialog]);
const events: GraphEventPort = {
onNodeDoubleClick: useCallback(
(ref: GraphDomainRef) => {

View file

@ -79,7 +79,6 @@ export const TeamGraphTab = ({
const openCreateTask = useCallback(() => {
openCreateTaskDialog('');
}, [openCreateTaskDialog]);
// Task action dispatchers
const dispatchTaskAction = useCallback(
(action: string) => (taskId: string) =>

View file

@ -3,10 +3,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { type DashboardRecentProject } from '@features/recent-projects/contracts';
import { api, isElectronMode } from '@renderer/api';
import { useStore } from '@renderer/store';
import { buildTaskCountsByProject, normalizePath } from '@renderer/utils/pathNormalize';
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
import { buildTaskCountsByProject } from '@renderer/utils/pathNormalize';
import { useShallow } from 'zustand/react/shallow';
import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter';
import { buildActiveTeamsByProject } from '../utils/activeProjectTeams';
import {
sortRecentProjectsByDisplayPriority,
subscribeRecentProjectOpenHistory,
@ -62,16 +64,27 @@ export function useRecentProjectsSection(
openProjectPath: (projectPath: string) => Promise<void>;
selectProjectFolder: () => Promise<void>;
} {
const { globalTasks, globalTasksInitialized, globalTasksLoading, fetchAllTasks, teams } =
useStore(
useShallow((state) => ({
globalTasks: state.globalTasks,
globalTasksInitialized: state.globalTasksInitialized,
globalTasksLoading: state.globalTasksLoading,
fetchAllTasks: state.fetchAllTasks,
teams: state.teams,
}))
);
const {
globalTasks,
globalTasksInitialized,
globalTasksLoading,
fetchAllTasks,
teams,
provisioningRuns,
currentProvisioningRunIdByTeam,
provisioningSnapshotByTeam,
} = useStore(
useShallow((state) => ({
globalTasks: state.globalTasks,
globalTasksInitialized: state.globalTasksInitialized,
globalTasksLoading: state.globalTasksLoading,
fetchAllTasks: state.fetchAllTasks,
teams: state.teams,
provisioningRuns: state.provisioningRuns,
currentProvisioningRunIdByTeam: state.currentProvisioningRunIdByTeam,
provisioningSnapshotByTeam: state.provisioningSnapshotByTeam,
}))
);
const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []);
const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject();
const [recentProjects, setRecentProjects] = useState<DashboardRecentProject[]>(
@ -92,6 +105,21 @@ export function useRecentProjectsSection(
const recentProjectsRef = useRef<DashboardRecentProject[]>(
initialSnapshot?.payload.projects ?? []
);
const provisioningState = useMemo(
() => ({ currentProvisioningRunIdByTeam, provisioningRuns }),
[currentProvisioningRunIdByTeam, provisioningRuns]
);
const provisioningTeamNames = useMemo(
() =>
Object.keys(currentProvisioningRunIdByTeam).filter((teamName) =>
isTeamProvisioningActive(provisioningState, teamName)
),
[currentProvisioningRunIdByTeam, provisioningState]
);
const provisioningTeamNamesKey = useMemo(
() => [...provisioningTeamNames].sort().join('\u0000'),
[provisioningTeamNames]
);
useEffect(() => {
recentProjectsRef.current = recentProjects;
@ -173,7 +201,7 @@ export function useRecentProjectsSection(
return () => {
cancelled = true;
};
}, [teams]);
}, [provisioningTeamNamesKey, teams]);
useEffect(() => {
if (!searchQuery.trim()) {
@ -189,25 +217,13 @@ export function useRecentProjectsSection(
const taskCountsByProject = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]);
const activeTeamsByProject = useMemo(() => {
const aliveSet = new Set(aliveTeams);
const teamsByProject = new Map<string, TeamSummary[]>();
for (const team of teams) {
if (!team.projectPath || !aliveSet.has(team.teamName)) {
continue;
}
const key = normalizePath(team.projectPath);
const existing = teamsByProject.get(key);
if (existing) {
existing.push(team);
} else {
teamsByProject.set(key, [team]);
}
}
return teamsByProject;
}, [aliveTeams, teams]);
return buildActiveTeamsByProject({
teams,
aliveTeamNames: aliveTeams,
provisioningTeamNames,
provisioningSnapshotByTeam,
});
}, [aliveTeams, provisioningSnapshotByTeam, provisioningTeamNames, teams]);
const decoratedCards = useMemo(
() =>

View file

@ -0,0 +1,48 @@
import { normalizePath } from '@renderer/utils/pathNormalize';
import type { TeamSummary } from '@shared/types';
interface BuildActiveTeamsByProjectInput {
teams: TeamSummary[];
aliveTeamNames: readonly string[];
provisioningTeamNames: readonly string[];
provisioningSnapshotByTeam: Record<string, TeamSummary>;
}
export function buildActiveTeamsByProject({
teams,
aliveTeamNames,
provisioningTeamNames,
provisioningSnapshotByTeam,
}: BuildActiveTeamsByProjectInput): Map<string, TeamSummary[]> {
const activeTeamNames = new Set<string>([...aliveTeamNames, ...provisioningTeamNames]);
if (activeTeamNames.size === 0) {
return new Map();
}
const existingTeamNames = new Set(teams.map((team) => team.teamName));
const syntheticProvisioningTeams = provisioningTeamNames
.filter((teamName) => !existingTeamNames.has(teamName))
.map((teamName) => provisioningSnapshotByTeam[teamName])
.filter((team): team is TeamSummary => Boolean(team));
const teamsByProject = new Map<string, TeamSummary[]>();
const visibleTeams =
syntheticProvisioningTeams.length > 0 ? [...teams, ...syntheticProvisioningTeams] : teams;
for (const team of visibleTeams) {
if (!team.projectPath || !activeTeamNames.has(team.teamName)) {
continue;
}
const key = normalizePath(team.projectPath);
const existing = teamsByProject.get(key);
if (existing) {
existing.push(team);
} else {
teamsByProject.set(key, [team]);
}
}
return teamsByProject;
}

View file

@ -157,7 +157,7 @@ function resolveHistoryOpenedAt(lookup: HistoryLookup, projectPath: string): num
}
const foldedMatch = lookup.folded.get(foldHistoryPath(normalizedPath));
if (!foldedMatch || foldedMatch.exactPaths.size !== 1) {
if (foldedMatch?.exactPaths.size !== 1) {
return 0;
}

View file

@ -471,7 +471,7 @@ export class TmuxWslService {
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', POWERSHELL_FEATURE_QUERY],
6_000
);
if (!result || result.exitCode !== 0 || !result.stdout.trim()) {
if (result?.exitCode !== 0 || !result.stdout.trim()) {
return null;
}

View file

@ -88,6 +88,7 @@ import {
} from './services/extensions';
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
import { HttpServer } from './services/infrastructure/HttpServer';
import { clearAutoResumeService } from './services/team/AutoResumeService';
import {
buildTeamControlApiBaseUrl,
clearTeamControlApiState,
@ -100,7 +101,6 @@ import {
type TeamReconcileTrigger,
} from './services/team/TeamReconcileDrainScheduler';
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
import { clearAutoResumeService } from './services/team/AutoResumeService';
import { getAppIconPath } from './utils/appIcon';
import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder';
import {
@ -564,6 +564,13 @@ function wireFileWatcherEvents(context: ServiceContext): void {
const teamName = row.teamName.trim();
const detail = typeof row.detail === 'string' ? row.detail : '';
if (
teamDataService &&
(row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config')
) {
teamDataService.invalidateMessageFeed(teamName);
}
// --- Inbox change events: relay to lead + native OS notifications ---
if (row.type === 'inbox') {
if (reconcileScheduler) {
@ -906,6 +913,12 @@ async function initializeServices(): Promise<void> {
});
const forwardTeamChange = (event: TeamChangeEvent): void => {
if (
teamDataService &&
(event.type === 'inbox' || event.type === 'lead-message' || event.type === 'config')
) {
teamDataService.invalidateMessageFeed(event.teamName);
}
safeSendToRenderer(mainWindow, TEAM_CHANGE, event);
httpServer?.broadcast('team-change', event);
};

View file

@ -16,13 +16,14 @@ import {
TEAM_DELETE_DRAFT,
TEAM_DELETE_TASK_ATTACHMENT,
TEAM_DELETE_TEAM,
TEAM_GET_AGENT_RUNTIME,
TEAM_GET_ALL_TASKS,
TEAM_GET_ATTACHMENTS,
TEAM_GET_AGENT_RUNTIME,
TEAM_GET_CLAUDE_LOGS,
TEAM_GET_DATA,
TEAM_GET_DELETED_TASKS,
TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_MEMBER_ACTIVITY_META,
TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS,
TEAM_GET_MESSAGES_PAGE,
@ -59,8 +60,8 @@ import {
TEAM_SEND_MESSAGE,
TEAM_SET_CHANGE_PRESENCE_TRACKING,
TEAM_SET_PROJECT_BRANCH_TRACKING,
TEAM_SET_TASK_LOG_STREAM_TRACKING,
TEAM_SET_TASK_CLARIFICATION,
TEAM_SET_TASK_LOG_STREAM_TRACKING,
TEAM_SET_TOOL_ACTIVITY_TRACKING,
TEAM_SHOW_MESSAGE_NOTIFICATION,
TEAM_SOFT_DELETE_TASK,
@ -96,7 +97,7 @@ import {
parseStandaloneSlashCommand,
} from '@shared/utils/slashCommands';
import crypto from 'crypto';
import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
import { app, BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
@ -166,7 +167,6 @@ import type {
LeadContextUsageSnapshot,
MemberFullStats,
MemberLogSummary,
TeamAgentRuntimeSnapshot,
MemberSpawnStatusesSnapshot,
MessagesPage,
SendMessageRequest,
@ -174,15 +174,16 @@ import type {
TaskAttachmentMeta,
TaskComment,
TaskRef,
TeamAgentRuntimeSnapshot,
TeamClaudeLogsQuery,
TeamClaudeLogsResponse,
TeamConfig,
TeamCreateConfigRequest,
TeamCreateRequest,
TeamCreateResponse,
TeamData,
TeamLaunchRequest,
TeamLaunchResponse,
TeamMemberActivityMeta,
TeamMessageNotificationData,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
@ -190,6 +191,7 @@ import type {
TeamTask,
TeamTaskStatus,
TeamUpdateConfigRequest,
TeamViewSnapshot,
ToolApprovalFileContent,
ToolApprovalSettings,
UpdateKanbanPatch,
@ -206,6 +208,16 @@ const logger = createLogger('IPC:teams');
const seenRateLimitKeys = new Set<string>();
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
function noteHeavyTeamDataWorkerFallback(operation: string): void {
if (!app.isPackaged) {
return;
}
logger.error(
`[${operation}] team-data-worker unavailable in packaged runtime; falling back to main-thread execution for heavy message/activity path`
);
}
async function getDurableLeadTeammateRoster(
teamName: string,
leadName: string
@ -436,6 +448,19 @@ function checkApiErrorMessages(
}
}
function scanTeamMessageNotifications(
messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
teamName: string,
teamDisplayName: string,
projectPath?: string
): void {
if (messages.length === 0) {
return;
}
checkRateLimitMessages(messages, teamName, teamDisplayName, projectPath);
checkApiErrorMessages(messages, teamName, teamDisplayName, projectPath);
}
let teamDataService: TeamDataService | null = null;
let teamProvisioningService: TeamProvisioningService | null = null;
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
@ -519,6 +544,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning);
ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage);
ipcMain.handle(TEAM_GET_MESSAGES_PAGE, handleGetMessagesPage);
ipcMain.handle(TEAM_GET_MEMBER_ACTIVITY_META, handleGetMemberActivityMeta);
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview);
ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban);
@ -595,6 +621,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING);
ipcMain.removeHandler(TEAM_SEND_MESSAGE);
ipcMain.removeHandler(TEAM_GET_MESSAGES_PAGE);
ipcMain.removeHandler(TEAM_GET_MEMBER_ACTIVITY_META);
ipcMain.removeHandler(TEAM_CREATE_TASK);
ipcMain.removeHandler(TEAM_REQUEST_REVIEW);
ipcMain.removeHandler(TEAM_UPDATE_KANBAN);
@ -772,14 +799,14 @@ async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<Te
async function handleGetData(
_event: IpcMainInvokeEvent,
teamName: unknown
): Promise<IpcResult<TeamData>> {
): Promise<IpcResult<TeamViewSnapshot>> {
const validated = validateTeamName(teamName);
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' };
}
const tn = validated.value!;
const startedAt = Date.now();
let data: TeamData;
let data: TeamViewSnapshot;
setCurrentMainOp('team:getData');
try {
// Prefer worker thread to keep main event loop responsive
@ -791,9 +818,11 @@ async function handleGetData(
logger.warn(
`[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
);
noteHeavyTeamDataWorkerFallback('teams:getData');
data = await getTeamDataService().getTeamData(tn);
}
} else {
noteHeavyTeamDataWorkerFallback('teams:getData');
data = await getTeamDataService().getTeamData(tn);
}
} catch (error) {
@ -833,22 +862,30 @@ async function handleGetData(
const displayName = data.config.name || tn;
const projectPath = data.config.projectPath;
const live = provisioning.getLiveLeadProcessMessages(tn);
const durableMessages = Array.isArray((data as { messages?: unknown }).messages)
? ((data as { messages?: typeof live }).messages ?? [])
: [];
if (live.length === 0) {
checkRateLimitMessages(
data.messages,
tn,
displayName,
projectPath,
isAlive,
currentLeadSessionId
);
checkApiErrorMessages(data.messages, tn, displayName, projectPath);
if (durableMessages.length > 0) {
checkRateLimitMessages(
durableMessages,
tn,
displayName,
projectPath,
isAlive,
currentLeadSessionId
);
checkApiErrorMessages(durableMessages, tn, displayName, projectPath);
} else {
scanTeamMessageNotifications(live, tn, displayName, projectPath);
}
return { success: true, data: { ...data, isAlive } };
}
let merged = mergeLiveLeadProcessMessages(data.messages, live);
if (data.messages.length >= 50) {
let merged = mergeLiveLeadProcessMessages(durableMessages, live);
if (durableMessages.length >= 50) {
try {
const newestPage = await teamDataService.getMessagesPage(tn, {
limit: 50,
@ -866,7 +903,7 @@ async function handleGetData(
checkRateLimitMessages(merged, tn, displayName, projectPath, isAlive, currentLeadSessionId);
checkApiErrorMessages(merged, tn, displayName, projectPath);
return { success: true, data: { ...data, isAlive, messages: merged } };
return { success: true, data: { ...data, isAlive } };
}
async function handleGetTaskChangePresence(
@ -1767,19 +1804,89 @@ async function handleGetMessagesPage(
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
}
const opts = (options && typeof options === 'object' ? options : {}) as {
beforeTimestamp?: string;
cursor?: string | null;
limit?: number;
};
const limit = Math.min(Math.max(1, opts.limit ?? 50), 200);
const beforeTimestamp =
typeof opts.beforeTimestamp === 'string' ? opts.beforeTimestamp : undefined;
const cursor =
typeof opts.cursor === 'string' ? opts.cursor : opts.cursor === null ? null : undefined;
return wrapTeamHandler('getMessagesPage', async () => {
const service = getTeamDataService();
const liveMessages = beforeTimestamp
? undefined
: getTeamProvisioningService().getLiveLeadProcessMessages(vTeam.value!);
return service.getMessagesPage(vTeam.value!, { beforeTimestamp, limit, liveMessages });
let page: MessagesPage;
const notificationContext = await getTeamDataService().getTeamNotificationContext(vTeam.value!);
const liveMessages =
cursor == null ? getTeamProvisioningService().getLiveLeadProcessMessages(vTeam.value!) : [];
if (liveMessages.length > 0) {
page = await getTeamDataService().getMessagesPage(vTeam.value!, {
cursor,
limit,
liveMessages,
});
scanTeamMessageNotifications(
page.messages,
vTeam.value!,
notificationContext.displayName,
notificationContext.projectPath
);
return page;
}
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
}`
);
}
}
noteHeavyTeamDataWorkerFallback('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
}`
);
}
}
noteHeavyTeamDataWorkerFallback('teams:getMemberActivityMeta');
return getTeamDataService().getMemberActivityMeta(vTeam.value!);
});
}

View file

@ -88,7 +88,7 @@ export class ApiKeyService {
);
}
if (!request.value) throw new Error('Key value is required');
if (request.scope === 'project' && (!request.projectPath || !request.projectPath.trim())) {
if (request.scope === 'project' && !request.projectPath?.trim()) {
throw new Error('Project-scoped API keys require a project path');
}

View file

@ -21,7 +21,6 @@ async function buildManagementCliEnvForBinary(binaryPath: string): Promise<NodeJ
});
return env;
}
export interface ExtensionsRuntimeAdapter {
readonly flavor: CliFlavor;
buildManagementCliEnv(binaryPath: string): Promise<NodeJS.ProcessEnv>;

View file

@ -48,7 +48,6 @@ function isSensitiveCliFlag(flag: string): boolean {
const normalizedFlag = flag.toLowerCase().replace(/^--/, '').replace(/[-_]/g, '');
return SENSITIVE_FLAG_NAMES.has(normalizedFlag);
}
function extractJsonObject<T>(raw: string): T {
const trimmed = raw.trim();
try {

View file

@ -515,8 +515,7 @@ export class ConfigManager {
ignoredRepositories:
loadedNotifications.ignoredRepositories ??
DEFAULT_CONFIG.notifications.ignoredRepositories,
snoozedUntil:
loadedNotifications.snoozedUntil ?? DEFAULT_CONFIG.notifications.snoozedUntil,
snoozedUntil: loadedNotifications.snoozedUntil ?? DEFAULT_CONFIG.notifications.snoozedUntil,
snoozeMinutes:
loadedNotifications.snoozeMinutes ?? DEFAULT_CONFIG.notifications.snoozeMinutes,
includeSubagentErrors:

View file

@ -161,7 +161,7 @@ function classifyFailedProbe(
export class CliProviderModelAvailabilityService {
private readonly cache = new Map<string, ProviderModelAvailabilityCacheEntry>();
private readonly queue: Array<() => void> = [];
private readonly queue: (() => void)[] = [];
private activeProbeCount = 0;
constructor(private readonly onUpdate?: ProviderAvailabilityUpdateHandler) {}

View file

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

View file

@ -32,6 +32,7 @@ import {
} from './cache/LeadSessionParseCache';
import { atomicWriteAsync } from './atomicWrite';
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
import { MemberActivityMetaService } from './MemberActivityMetaService';
import {
getLiveLeadProcessMessageKey,
mergeLiveLeadProcessMessages,
@ -44,6 +45,7 @@ import { TeamKanbanManager } from './TeamKanbanManager';
import { TeamMemberResolver } from './TeamMemberResolver';
import { TeamMemberRuntimeAdvisoryService } from './TeamMemberRuntimeAdvisoryService';
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamMessageFeedService } from './TeamMessageFeedService';
import { TeamMetaStore } from './TeamMetaStore';
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal';
@ -63,7 +65,6 @@ import type {
KanbanColumnId,
KanbanState,
MessagesPage,
ResolvedTeamMember,
SendMessageRequest,
SendMessageResult,
TaskAttachmentMeta,
@ -72,13 +73,14 @@ import type {
TaskRef,
TeamConfig,
TeamCreateConfigRequest,
TeamData,
TeamMember,
TeamMemberActivityMeta,
TeamProcess,
TeamSummary,
TeamTask,
TeamTaskStatus,
TeamTaskWithKanban,
TeamViewSnapshot,
ToolCallMeta,
UpdateKanbanPatch,
} from '@shared/types';
@ -96,6 +98,14 @@ const TASK_MAP_YIELD_EVERY = 250;
const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification';
const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
function requireCanonicalMessageId(message: InboxMessage): string {
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
if (messageId.length > 0) {
return messageId;
}
throw new Error('Canonical team message is missing effective messageId');
}
interface EligibleTaskCommentNotification {
key: string;
messageId: string;
@ -160,6 +170,8 @@ export class TeamDataService {
private taskChangePresenceRepository: TaskChangePresenceRepository | null = null;
private teamLogSourceTracker: TeamLogSourceTracker | null = null;
private fileWatchReconcileDiagnostics = new Map<string, FileWatchReconcileDiagnostics>();
private readonly messageFeedService: TeamMessageFeedService;
private readonly memberActivityMetaService: MemberActivityMetaService;
constructor(
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
@ -184,7 +196,15 @@ export class TeamDataService {
private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver(
configReader
)
) {}
) {
this.messageFeedService = new TeamMessageFeedService({
getConfig: (teamName) => this.configReader.getConfig(teamName),
getInboxMessages: (teamName) => this.inboxReader.getMessages(teamName),
getLeadSessionMessages: (teamName, config) => this.extractLeadSessionTexts(teamName, config),
getSentMessages: (teamName) => this.sentMessagesStore.readMessages(teamName),
});
this.memberActivityMetaService = new MemberActivityMetaService(this.messageFeedService);
}
private getController(teamName: string): AgentTeamsController {
return this.controllerFactory(teamName);
@ -623,7 +643,7 @@ export class TeamDataService {
await fs.promises.rm(tasksDir, { recursive: true, force: true });
}
async getTeamData(teamName: string): Promise<TeamData> {
async getTeamData(teamName: string): Promise<TeamViewSnapshot> {
const startedAt = Date.now();
const marks: Record<string, number> = {};
const mark = (label: string): void => {
@ -727,12 +747,6 @@ export class TeamDataService {
warningText: 'Inboxes failed to load',
load: () => this.inboxReader.listInboxNames(teamName),
});
const sentMessagesStep = startReadStep({
label: 'sentMessages',
createFallback: () => [],
warningText: 'Sent messages failed to load',
load: () => this.sentMessagesStore.readMessages(teamName),
});
const metaMembersStep = startReadStep({
label: 'metaMembers',
createFallback: () => [],
@ -757,40 +771,8 @@ export class TeamDataService {
load: () => this.taskReader.getTasks(teamName),
})
);
const messagesStep = runWithConcurrencyLimit(() =>
startReadStep({
label: 'messages',
createFallback: () => [],
warningText: 'Messages failed to load',
load: () => this.inboxReader.getMessages(teamName),
})
);
const leadTextsStep = runWithConcurrencyLimit(() =>
startReadStep({
label: 'leadTexts',
createFallback: () => [],
warningText: 'Lead session texts failed to load',
load: () => this.extractLeadSessionTexts(teamName, config),
})
);
const [
tasksStepResult,
inboxNamesStepResult,
messagesStepResult,
leadTextsStepResult,
sentMessagesStepResult,
metaMembersStepResult,
kanbanStateStepResult,
] = await Promise.all([
tasksStep,
inboxNamesStep,
messagesStep,
leadTextsStep,
sentMessagesStep,
metaMembersStep,
kanbanStateStep,
]);
const [tasksStepResult, inboxNamesStepResult, metaMembersStepResult, kanbanStateStepResult] =
await Promise.all([tasksStep, inboxNamesStep, metaMembersStep, kanbanStateStep]);
// After parallelizing the top read phase, these marks no longer represent
// serial stage boundaries. They now capture the actual completion time for
@ -798,178 +780,18 @@ export class TeamDataService {
// diagnostics useful without mutating marks from concurrent branches.
marks.tasks = tasksStepResult.completedAt;
marks.inboxNames = inboxNamesStepResult.completedAt;
marks.messages = messagesStepResult.completedAt;
marks.leadTexts = leadTextsStepResult.completedAt;
marks.sentMessages = sentMessagesStepResult.completedAt;
marks.metaMembers = metaMembersStepResult.completedAt;
marks.kanbanState = kanbanStateStepResult.completedAt;
if (tasksStepResult.warning) warnings.push(tasksStepResult.warning);
if (inboxNamesStepResult.warning) warnings.push(inboxNamesStepResult.warning);
if (messagesStepResult.warning) warnings.push(messagesStepResult.warning);
if (leadTextsStepResult.warning) warnings.push(leadTextsStepResult.warning);
if (sentMessagesStepResult.warning) warnings.push(sentMessagesStepResult.warning);
if (metaMembersStepResult.warning) warnings.push(metaMembersStepResult.warning);
if (kanbanStateStepResult.warning) warnings.push(kanbanStateStepResult.warning);
const tasks: TeamTask[] = tasksStepResult.value;
const inboxNames: string[] = inboxNamesStepResult.value;
let messages: InboxMessage[] = messagesStepResult.value;
const leadTexts: InboxMessage[] = leadTextsStepResult.value;
const sentMessages: InboxMessage[] = sentMessagesStepResult.value;
mark('postStart');
if (leadTexts.length > 0) {
messages = [...messages, ...leadTexts];
}
if (sentMessages.length > 0) {
messages = [...messages, ...sentMessages];
}
mark('mergeMessages');
// Dedup: if a lead_process message text is also present in lead_session, prefer lead_session.
// This avoids double-rendering when we persist lead process messages and later load the lead JSONL.
// Exception: lead_process messages with `to` field are captured SendMessage — never dedup those.
if (leadTexts.length > 0) {
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
const getLeadThoughtFingerprint = (
msg: Pick<InboxMessage, 'from' | 'text' | 'leadSessionId'>
) => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`;
const leadSessionFingerprints = new Set<string>();
for (const msg of leadTexts) {
if (msg.source !== 'lead_session') continue;
leadSessionFingerprints.add(getLeadThoughtFingerprint(msg));
}
messages = messages.filter((m) => {
if (m.source !== 'lead_process') return true;
// Captured SendMessage messages (with recipient) are real messages — never dedup
if (m.to) return true;
const fp = getLeadThoughtFingerprint(m);
return !leadSessionFingerprints.has(fp);
});
}
mark('dedupLeadTexts');
// Dedup exact message copies that can appear as both live lead_process rows and
// their persisted inbox/sent-message counterpart. If the messageId is identical,
// keep a single row so the UI does not show the same SendMessage twice
// (for example "LIVE" plus the stored copy).
const duplicateMessageIds = new Set<string>();
const messageIdCounts = new Map<string, number>();
for (const msg of messages) {
const id = typeof msg.messageId === 'string' ? msg.messageId.trim() : '';
if (!id) continue;
const nextCount = (messageIdCounts.get(id) ?? 0) + 1;
messageIdCounts.set(id, nextCount);
if (nextCount > 1) duplicateMessageIds.add(id);
}
if (duplicateMessageIds.size > 0) {
const choosePreferredMessage = (
current: InboxMessage,
candidate: InboxMessage
): InboxMessage => {
const score = (msg: InboxMessage): number => {
let value = 0;
if (msg.source !== 'lead_process') value += 4;
if (msg.read === false) value += 2;
if (msg.relayOfMessageId) value += 1;
if (msg.summary) value += 1;
if (msg.to) value += 1;
return value;
};
const currentScore = score(current);
const candidateScore = score(candidate);
if (candidateScore !== currentScore) {
return candidateScore > currentScore ? candidate : current;
}
const currentTs = Date.parse(current.timestamp);
const candidateTs = Date.parse(candidate.timestamp);
if (
Number.isFinite(currentTs) &&
Number.isFinite(candidateTs) &&
candidateTs !== currentTs
) {
return candidateTs > currentTs ? candidate : current;
}
return current;
};
const dedupedById = new Map<string, InboxMessage>();
const dedupedWithoutId: InboxMessage[] = [];
for (const msg of messages) {
const id = typeof msg.messageId === 'string' ? msg.messageId.trim() : '';
if (!id) {
dedupedWithoutId.push(msg);
continue;
}
const existing = dedupedById.get(id);
if (!existing) {
dedupedById.set(id, msg);
continue;
}
dedupedById.set(id, choosePreferredMessage(existing, msg));
}
messages = [...dedupedWithoutId, ...dedupedById.values()];
}
mark('dedupMessageIds');
messages = this.linkPassiveUserReplySummaries(messages);
mark('linkPassiveUserReplySummaries');
// Enrich inbox messages without leadSessionId by assigning the nearest neighbor's
// session ID (by timestamp). This avoids the old forward-only propagation bug.
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
const anchors: { index: number; time: number; sessionId: string }[] = [];
for (let i = 0; i < messages.length; i++) {
if (messages[i].leadSessionId) {
anchors.push({
index: i,
time: Date.parse(messages[i].timestamp),
sessionId: messages[i].leadSessionId!,
});
}
}
if (anchors.length > 0) {
let anchorIdx = 0;
for (let i = 0; i < messages.length; i++) {
if (messages[i].leadSessionId) {
while (anchorIdx < anchors.length - 1 && anchors[anchorIdx].index < i) {
anchorIdx++;
}
continue;
}
const msgTime = Date.parse(messages[i].timestamp);
let bestAnchor = anchors[0];
let bestDist = Math.abs(msgTime - bestAnchor.time);
for (const anchor of anchors) {
const dist = Math.abs(msgTime - anchor.time);
if (dist < bestDist) {
bestDist = dist;
bestAnchor = anchor;
} else if (dist > bestDist && anchor.time > msgTime) {
break;
}
}
messages[i].leadSessionId = bestAnchor.sessionId;
}
} else if (config.leadSessionId) {
for (const msg of messages) {
msg.leadSessionId = config.leadSessionId;
}
}
}
mark('attachLeadSessionIds');
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
this.annotateSlashCommandResponses(messages);
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
mark('normalizeMessages');
const metaMembers: TeamConfig['members'] = metaMembersStepResult.value;
const kanbanState: KanbanState = kanbanStateStepResult.value;
@ -1001,8 +823,7 @@ export class TeamDataService {
config,
metaMembers,
inboxNames,
tasksWithKanban,
messages
tasksWithKanban
);
mark('resolveMembers');
@ -1037,30 +858,13 @@ export class TeamDataService {
const totalMs = Date.now() - startedAt;
if (totalMs >= 1500) {
const counts = `counts=tasks:${tasks.length},messages:${messages.length},inboxNames:${inboxNames.length},leadTexts:${leadTexts.length},sent:${sentMessages.length},members:${members.length},processes:${processes.length}`;
const counts = `counts=tasks:${tasks.length},inboxNames:${inboxNames.length},members:${members.length},processes:${processes.length}`;
logger.warn(
`getTeamData team=${teamName} slow total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince(
'inboxNames'
)} messages=${msSince('messages')} leadTexts=${msSince('leadTexts')} sent=${msSince(
'sentMessages'
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince(
'kanbanGc'
)} post=${msBetween(
'postStart',
'mergeMessages'
)}/dedupLead=${msBetween('mergeMessages', 'dedupLeadTexts')}/dedupIds=${msBetween(
'dedupLeadTexts',
'dedupMessageIds'
)}/attachLeadSession=${msBetween(
'dedupMessageIds',
'attachLeadSessionIds'
)}/normalizeMessages=${msBetween(
'attachLeadSessionIds',
'normalizeMessages'
)}/attachKanban=${msBetween(
'normalizeMessages',
'attachKanban'
)}/loadPresenceIndex=${msBetween(
)} post=${msBetween('postStart', 'attachKanban')}/loadPresenceIndex=${msBetween(
'attachKanban',
'loadPresenceIndex'
)}/changePresence=${msBetween(
@ -1089,21 +893,14 @@ export class TeamDataService {
this.processHealthTeams.delete(teamName);
}
// Cap messages to keep IPC payloads small. Full history is available
// via the paginated getMessagesPage() API. We still include a small
// batch here for backward compatibility (notifications, dedup, etc.).
const MAX_RETURN_MESSAGES = 50;
const cappedMessages =
messages.length > MAX_RETURN_MESSAGES ? messages.slice(0, MAX_RETURN_MESSAGES) : messages;
return {
teamName,
config,
tasks: tasksWithKanban,
members,
messages: cappedMessages,
kanbanState,
processes,
isAlive: hasAlive,
warnings: warnings.length > 0 ? warnings : undefined,
};
}
@ -1114,112 +911,35 @@ export class TeamDataService {
*/
async getMessagesPage(
teamName: string,
options: { beforeTimestamp?: string; limit: number; liveMessages?: InboxMessage[] }
options: { cursor?: string | null; limit: number; liveMessages?: InboxMessage[] }
): Promise<MessagesPage> {
const config = await this.configReader.getConfig(teamName);
if (!config) {
return { messages: [], nextCursor: null, hasMore: false };
}
// Collect all messages from the same sources as getTeamData
let messages: InboxMessage[] = [];
const [inboxMessages, leadTexts, sentMessages] = await Promise.all([
this.inboxReader.getMessages(teamName).catch(() => [] as InboxMessage[]),
this.extractLeadSessionTexts(teamName, 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 ?? '');
});
const newestDurableMessages = messages;
const feed = await this.messageFeedService.getFeed(teamName);
const newestDurableMessages = feed.messages;
const durableMessageIndexByKey = new Map(
newestDurableMessages.map((message, index) => [getLiveLeadProcessMessageKey(message), index])
);
let messages = newestDurableMessages;
// Apply cursor filter. Cursor format: "timestamp|messageId" (compound)
// to handle multiple messages sharing the same timestamp.
if (options.beforeTimestamp) {
const [cursorTs, cursorId] = options.beforeTimestamp.split('|');
if (options.cursor) {
const [cursorTs, cursorId] = options.cursor.split('|');
const cursorMs = Date.parse(cursorTs);
messages = messages.filter((m) => {
const ms = Date.parse(m.timestamp);
if (ms < cursorMs) return true;
if (ms > cursorMs) return false;
// Same timestamp — use messageId tie-breaker
if (!cursorId) return false;
return (m.messageId ?? '').localeCompare(cursorId) > 0;
return requireCanonicalMessageId(m).localeCompare(cursorId) > 0;
});
}
// Paginate
const hasMore = messages.length > options.limit;
const page = messages.slice(0, options.limit);
const lastMsg = page[page.length - 1];
const nextCursor =
hasMore && lastMsg ? `${lastMsg.timestamp}|${lastMsg.messageId ?? ''}` : null;
hasMore && lastMsg ? `${lastMsg.timestamp}|${requireCanonicalMessageId(lastMsg)}` : null;
if (options.beforeTimestamp || !options.liveMessages?.length) {
return { messages: page, nextCursor, hasMore };
if (options.cursor || !options.liveMessages?.length) {
return { messages: page, nextCursor, hasMore, feedRevision: feed.feedRevision };
}
// Merge live lead thoughts against the full durable newest-page history so we do not
@ -1230,7 +950,12 @@ export class TeamDataService {
).slice(0, options.limit);
if (displayMessages.length === 0) {
return { messages: displayMessages, nextCursor: null, hasMore: false };
return {
messages: displayMessages,
nextCursor: null,
hasMore: false,
feedRevision: feed.feedRevision,
};
}
let lastDurableDisplayed: InboxMessage | null = null;
@ -1251,6 +976,7 @@ export class TeamDataService {
? `${boundary.timestamp}|${boundary.messageId ?? ''}`
: null,
hasMore: newestDurableMessages.length > 0,
feedRevision: feed.feedRevision,
};
}
@ -1265,15 +991,31 @@ export class TeamDataService {
? `${lastDurableDisplayed.timestamp}|${lastDurableDisplayed.messageId ?? ''}`
: null,
hasMore: durableHasMore,
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);
}
/**
* Enriches members with gitBranch when their cwd differs from the lead's.
* Mutates members in-place for efficiency (called right after resolveMembers).
*/
private async enrichMemberBranches(
members: ResolvedTeamMember[],
members: TeamViewSnapshot['members'],
config: TeamConfig
): Promise<void> {
const leadEntry = config.members?.find((member) => isLeadMember(member));
@ -1945,7 +1687,7 @@ export class TeamDataService {
slashCommand: slashCommandMeta,
};
}
return this.getController(teamName).messages.sendMessage({
const result = this.getController(teamName).messages.sendMessage({
member: enrichedRequest.member,
from: enrichedRequest.from,
text: enrichedRequest.text,
@ -1966,6 +1708,8 @@ export class TeamDataService {
leadSessionId: enrichedRequest.leadSessionId,
attachments: enrichedRequest.attachments,
}) as SendMessageResult;
this.invalidateMessageFeed(teamName);
return result;
}
private resolveLeadNameFromConfig(config: TeamConfig | null): string {
@ -2522,6 +2266,23 @@ export class TeamDataService {
}
}
async getTeamNotificationContext(teamName: string): Promise<{
displayName: string;
projectPath?: string;
}> {
try {
const config = await this.configReader.getConfig(teamName);
const displayName = config?.name?.trim() || teamName;
const projectPath =
typeof config?.projectPath === 'string' && config.projectPath.trim().length > 0
? config.projectPath
: undefined;
return { displayName, projectPath };
} catch {
return { displayName: teamName };
}
}
async requestReview(teamName: string, taskId: string): Promise<void> {
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
this.getController(teamName).review.requestReview(taskId, {

View file

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

View file

@ -223,8 +223,7 @@ export function createPersistedLaunchSnapshot(params: {
for (const name of expectedMembers) {
const member = members[name];
if (
member &&
member.launchState === 'starting' &&
member?.launchState === 'starting' &&
!member.agentToolAccepted &&
!member.runtimeAlive &&
!member.bootstrapConfirmed &&

View file

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

View file

@ -0,0 +1,408 @@
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
import { createHash } from 'crypto';
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: (teamName: string, 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(teamName, 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?.feedRevision === feedRevision
? cached
: {
feedRevision,
messages,
};
this.cacheByTeam.set(teamName, nextEntry);
this.dirtyTeams.delete(teamName);
return {
teamName,
feedRevision: nextEntry.feedRevision,
messages: nextEntry.messages,
};
}
}

View file

@ -90,6 +90,7 @@ import { buildActionModeProtocol } from './actionModeInstructions';
import { atomicWriteAsync } from './atomicWrite';
import { peekAutoResumeService } from './AutoResumeService';
import { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
import { getConfiguredCliCommandLabel } from './cliFlavor';
import { withFileLock } from './fileLock';
import {
type ClassifiedMainProcessIdle,
@ -721,6 +722,8 @@ interface ProvisioningRun {
>;
/** Agent tool_use_id -> teammate name for persistent teammate spawns. */
memberSpawnToolUseIds: Map<string, string>;
/** Per-member latest processed lead-inbox bootstrap signal cursor for the current live run. */
memberSpawnLeadInboxCursorByMember: Map<string, MemberSpawnInboxCursor>;
/** Highest accepted deterministic bootstrap event sequence for this run. */
lastDeterministicBootstrapSeq: number;
/** Throttles config/inbox audit work triggered by frequent status polling. */
@ -839,6 +842,75 @@ function matchesTeamMemberIdentity(leftName: string, rightName: string): boolean
);
}
interface MemberSpawnInboxCursor {
timestamp: string;
messageId: string;
}
type LeadInboxMemberSpawnMessage = InboxMessage & { messageId: string };
function compareMemberSpawnInboxCursor(
left: MemberSpawnInboxCursor,
right: MemberSpawnInboxCursor
): number {
const leftMs = Date.parse(left.timestamp);
const rightMs = Date.parse(right.timestamp);
const leftValid = Number.isFinite(leftMs);
const rightValid = Number.isFinite(rightMs);
if (leftValid && rightValid && leftMs !== rightMs) {
return leftMs - rightMs;
}
if (leftValid !== rightValid) {
return leftValid ? -1 : 1;
}
return left.messageId.localeCompare(right.messageId);
}
function toMemberSpawnInboxCursor(
message: Pick<InboxMessage, 'timestamp' | 'messageId'>
): MemberSpawnInboxCursor | null {
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
if (!messageId) {
return null;
}
return {
timestamp: message.timestamp,
messageId,
};
}
function maxMemberSpawnInboxCursor(
left: MemberSpawnInboxCursor | undefined,
right: MemberSpawnInboxCursor
): MemberSpawnInboxCursor {
if (!left) {
return right;
}
return compareMemberSpawnInboxCursor(left, right) >= 0 ? left : right;
}
function isMemberSpawnHeartbeatTimestampNewer(
previous: string | undefined,
incoming: string | undefined
): boolean {
const normalizedIncoming = incoming?.trim();
if (!normalizedIncoming) {
return false;
}
const normalizedPrevious = previous?.trim();
if (!normalizedPrevious) {
return true;
}
const previousMs = Date.parse(normalizedPrevious);
const incomingMs = Date.parse(normalizedIncoming);
if (Number.isFinite(previousMs) && Number.isFinite(incomingMs)) {
return incomingMs > previousMs;
}
return normalizedIncoming > normalizedPrevious;
}
function stripWrappedCliFlagValue(raw: string | undefined): string | undefined {
const trimmed = raw?.trim();
if (!trimmed) {
@ -1339,6 +1411,8 @@ ${buildCanonicalSendMessageExample({ to: leadName, summary: 'short update', mess
After member_briefing succeeds:
- Do NOT send a "ready", "online", "status accepted", or other acknowledgement-only message just to confirm you started successfully.
- If bootstrap succeeded and you have no task yet, stay silent and wait for task assignments.
- If bootstrap succeeded and you have no task, produce ZERO assistant text for that turn and end it immediately after the successful tool result.
- Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after bootstrap.
- Only SendMessage the lead after bootstrap when there is a real blocker, a failed bootstrap, an explicit question, an urgent coordination need, or a completed task result to report.
- Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence.
- When you later receive work or reconnect after a restart, use task_briefing as your compact queue view. Use task_get when you need the full task context before starting a pending/needsFix task or when the in_progress briefing details are not enough.
@ -1409,6 +1483,8 @@ ${actionModeProtocol}
After member_briefing succeeds:
- Do NOT send a "ready", "online", "status accepted", or other acknowledgement-only message just to confirm you reconnected successfully.
- If reconnect bootstrap succeeded and you have no immediate blocker or question, stay silent and continue with your queue.
- If reconnect bootstrap succeeded and you have no immediate blocker, question, or task, produce ZERO assistant text for that turn and end it immediately.
- Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after reconnect bootstrap.
- Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence.
- Use task_briefing as your compact queue view.
- If task_briefing shows any in_progress task, resume/finish those first. Call task_get only if you need more context than task_briefing already gave you.
@ -3094,12 +3170,15 @@ export class TeamProvisioningService {
}
const runStartedAtMs = Date.parse(run.startedAt);
const expectedMembers = Array.isArray(run.expectedMembers) ? run.expectedMembers : [];
const expectedMembers = new Set(Array.isArray(run.expectedMembers) ? run.expectedMembers : []);
const teammateMessages = leadInboxMessages
.filter((message) => {
.filter((message): message is LeadInboxMemberSpawnMessage => {
const from = typeof message.from === 'string' ? message.from.trim() : '';
if (!from || from === leadName || from === 'user' || from === 'system') return false;
if (!expectedMembers.includes(from)) return false;
if (!expectedMembers.has(from)) return false;
if (typeof message.messageId !== 'string' || message.messageId.trim().length === 0) {
return false;
}
const messageTs = Date.parse(message.timestamp);
if (
Number.isFinite(messageTs) &&
@ -3110,24 +3189,67 @@ export class TeamProvisioningService {
}
return typeof message.text === 'string' && message.text.trim().length > 0;
})
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
for (const message of teammateMessages) {
const from = message.from.trim();
const reason = extractBootstrapFailureReason(message.text);
if (reason) {
this.setMemberSpawnStatus(run, from, 'error', reason);
continue;
}
this.setMemberSpawnStatus(
run,
from,
'online',
undefined,
'heartbeat',
extractHeartbeatTimestamp(message.text, message.timestamp)
.sort((left, right) =>
compareMemberSpawnInboxCursor(
{ timestamp: left.timestamp, messageId: left.messageId },
{ timestamp: right.timestamp, messageId: right.messageId }
)
);
const messagesByMember = new Map<string, LeadInboxMemberSpawnMessage[]>();
for (const message of teammateMessages) {
const memberName = message.from.trim();
const bucket = messagesByMember.get(memberName) ?? [];
bucket.push(message);
messagesByMember.set(memberName, bucket);
}
for (const [memberName, messages] of messagesByMember.entries()) {
const currentCursor = run.memberSpawnLeadInboxCursorByMember.get(memberName);
let nextCursor = currentCursor;
for (const message of messages) {
const messageCursor = toMemberSpawnInboxCursor(message);
const effectiveCursor = nextCursor ?? currentCursor;
if (messageCursor && effectiveCursor) {
if (compareMemberSpawnInboxCursor(messageCursor, effectiveCursor) <= 0) {
continue;
}
}
this.applyLeadInboxSpawnSignal(run, memberName, message);
if (messageCursor) {
nextCursor = maxMemberSpawnInboxCursor(nextCursor, messageCursor);
}
}
if (
nextCursor &&
(currentCursor == null || compareMemberSpawnInboxCursor(nextCursor, currentCursor) > 0)
) {
run.memberSpawnLeadInboxCursorByMember.set(memberName, nextCursor);
}
}
}
private applyLeadInboxSpawnSignal(
run: ProvisioningRun,
memberName: string,
message: LeadInboxMemberSpawnMessage
): void {
const reason = extractBootstrapFailureReason(message.text);
if (reason) {
this.setMemberSpawnStatus(run, memberName, 'error', reason);
return;
}
this.setMemberSpawnStatus(
run,
memberName,
'online',
undefined,
'heartbeat',
extractHeartbeatTimestamp(message.text, message.timestamp)
);
}
private persistSentMessage(teamName: string, message: InboxMessage): void {
@ -3725,8 +3847,14 @@ export class TeamProvisioningService {
next.livenessSource = livenessSource;
next.firstSpawnAcceptedAt = prev.firstSpawnAcceptedAt ?? updatedAt;
if (livenessSource === 'heartbeat') {
const incomingHeartbeatAt = heartbeatAt?.trim() || updatedAt;
next.bootstrapConfirmed = true;
next.lastHeartbeatAt = heartbeatAt?.trim() || prev.lastHeartbeatAt || updatedAt;
next.lastHeartbeatAt = isMemberSpawnHeartbeatTimestampNewer(
prev.lastHeartbeatAt,
incomingHeartbeatAt
)
? incomingHeartbeatAt
: prev.lastHeartbeatAt;
}
next.hardFailure = false;
next.error = undefined;
@ -5612,6 +5740,7 @@ export class TeamProvisioningService {
request.members.map((m) => [m.name, createInitialMemberSpawnStatusEntry()])
),
memberSpawnToolUseIds: new Map(),
memberSpawnLeadInboxCursorByMember: new Map(),
lastDeterministicBootstrapSeq: 0,
lastMemberSpawnAuditAt: 0,
lastMemberSpawnAuditConfigReadWarningAt: 0,
@ -6192,6 +6321,7 @@ export class TeamProvisioningService {
expectedMembers.map((name) => [name, createInitialMemberSpawnStatusEntry()])
),
memberSpawnToolUseIds: new Map(),
memberSpawnLeadInboxCursorByMember: new Map(),
lastDeterministicBootstrapSeq: 0,
lastMemberSpawnAuditAt: 0,
lastMemberSpawnAuditConfigReadWarningAt: 0,
@ -12968,6 +13098,7 @@ export class TeamProvisioningService {
providerId: TeamProviderId | undefined = 'anthropic'
): Promise<{ warning?: string }> {
const resolvedProviderId = resolveTeamProviderId(providerId);
const cliCommandLabel = getConfiguredCliCommandLabel();
try {
const versionProbe = await this.spawnProbe(
claudePath,
@ -12979,9 +13110,9 @@ export class TeamProvisioningService {
if (versionProbe.exitCode !== 0) {
const errorText =
buildCombinedLogs(versionProbe.stdout, versionProbe.stderr) ||
`Claude CLI exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`;
`${cliCommandLabel} exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`;
return {
warning: `Claude CLI binary failed to start correctly. Details: ${errorText}`,
warning: `${cliCommandLabel} binary failed to start correctly. Details: ${errorText}`,
};
}
} catch (error) {
@ -12992,7 +13123,7 @@ export class TeamProvisioningService {
};
}
return {
warning: `Claude CLI binary failed to start. Details: ${message}`,
warning: `${cliCommandLabel} binary failed to start. Details: ${message}`,
};
}
@ -13054,7 +13185,7 @@ export class TeamProvisioningService {
}
return {
warning:
'Preflight check for `claude -p` did not complete. ' +
`Preflight check for \`${cliCommandLabel} -p\` did not complete. ` +
`Proceeding anyway. Details: ${message}`,
};
}
@ -13075,13 +13206,15 @@ export class TeamProvisioningService {
const hint = isAuthFailure
? resolvedProviderId === 'codex'
? 'Codex provider is not authenticated for `-p` mode. ' +
'Run `claude-multimodel auth login --provider codex` and retry.' +
`Authenticate Codex in ${cliCommandLabel} and retry.` +
(attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
: 'Claude CLI `-p` mode is not authenticated. ' +
'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' +
: `${cliCommandLabel} \`-p\` mode is not authenticated. ` +
(cliCommandLabel === 'claude'
? 'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. '
: `Authenticate Anthropic in ${cliCommandLabel} and retry. `) +
'For automation/headless use, set ANTHROPIC_API_KEY.' +
(attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
: `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`;
: `${cliCommandLabel} preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`;
return { warning: hint };
}
@ -13122,7 +13255,7 @@ export class TeamProvisioningService {
const targetCwd = cwd ?? process.cwd();
const probeResult = await this.getCachedOrProbeResult(targetCwd, 'anthropic');
if (!probeResult?.claudePath) {
throw new Error('Claude CLI not found');
throw new Error(`${getConfiguredCliCommandLabel()} not found`);
}
const { env } = await this.buildProvisioningEnv();
const result = await this.spawnProbe(
@ -13135,7 +13268,7 @@ export class TeamProvisioningService {
const output = (result.stdout + '\n' + result.stderr).trim();
if (!output) {
throw new Error(
`claude --help returned empty output (exit code: ${String(result.exitCode)})`
`${getConfiguredCliCommandLabel()} --help returned empty output (exit code: ${String(result.exitCode)})`
);
}
this.helpOutputCache = output;
@ -13493,7 +13626,7 @@ export class TeamProvisioningService {
const timeoutHandle = setTimeout(() => {
settled = true;
killProcessTree(child);
reject(new Error(`Timeout running: claude ${args.join(' ')}`));
reject(new Error(`Timeout running: ${getConfiguredCliCommandLabel()} ${args.join(' ')}`));
}, timeoutMs);
const maybeResolveEarly = (): void => {

View file

@ -1,3 +1,4 @@
import { atomicWriteAsync } from '@main/utils/atomicWrite';
import { extractCwd } from '@main/utils/jsonl';
import {
encodePath,
@ -5,7 +6,6 @@ import {
getProjectsBasePath,
getTeamsBasePath,
} from '@main/utils/pathDecoder';
import { atomicWriteAsync } from '@main/utils/atomicWrite';
import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { createReadStream, type Dirent } from 'fs';
@ -219,7 +219,8 @@ export class TeamTranscriptProjectResolver {
...config,
projectPath: resolution.effectiveProjectPath,
projectPathHistory: this.buildRepairedProjectPathHistory(
config,
config.projectPath,
config.projectPathHistory,
resolution.effectiveProjectPath
),
}
@ -598,25 +599,11 @@ export class TeamTranscriptProjectResolver {
parsed.projectPath = normalizedNextPath;
const history: string[] = [];
const seen = new Set<string>();
const pushHistory = (value: unknown): void => {
const normalized = normalizeProjectPathCandidate(value);
if (!normalized || normalized === normalizedNextPath || seen.has(normalized)) {
return;
}
seen.add(normalized);
history.push(normalized);
};
if (Array.isArray(parsed.projectPathHistory)) {
for (const value of parsed.projectPathHistory) {
pushHistory(value);
}
}
pushHistory(rawProjectPath);
parsed.projectPathHistory = history.slice(-500);
parsed.projectPathHistory = this.buildRepairedProjectPathHistory(
rawProjectPath,
parsed.projectPathHistory,
normalizedNextPath
);
await atomicWriteAsync(configPath, JSON.stringify(parsed, null, 2));
logger.info(
`[${teamName}] Repaired transcript projectPath via exact session match: ${normalizedNextPath}`
@ -663,7 +650,11 @@ export class TeamTranscriptProjectResolver {
return orderedSessionIds;
}
private buildRepairedProjectPathHistory(config: TeamConfig, nextProjectPath: string): string[] {
private buildRepairedProjectPathHistory(
currentProjectPath: unknown,
rawProjectPathHistory: unknown,
nextProjectPath: string
): string[] {
const normalizedNextPath = normalizeProjectPathCandidate(nextProjectPath);
const history: string[] = [];
const seen = new Set<string>();
@ -676,12 +667,12 @@ export class TeamTranscriptProjectResolver {
history.push(normalized);
};
if (Array.isArray(config.projectPathHistory)) {
for (const value of config.projectPathHistory) {
if (Array.isArray(rawProjectPathHistory)) {
for (const value of rawProjectPathHistory) {
pushHistory(value);
}
}
pushHistory(config.projectPath);
pushHistory(currentProjectPath);
return history.slice(-500);
}

View file

@ -41,3 +41,17 @@ export function getCliFlavorUiOptions(flavor: CliFlavor): CliFlavorUiOptions {
};
}
}
export function getCliFlavorCommandLabel(flavor: CliFlavor): string {
switch (flavor) {
case 'agent_teams_orchestrator':
return 'orchestrator-cli';
case 'claude':
default:
return 'claude';
}
}
export function getConfiguredCliCommandLabel(): string {
return getCliFlavorCommandLabel(getConfiguredCliFlavor());
}

View file

@ -1,3 +1,9 @@
export {
AutoResumeService,
clearAutoResumeService,
getAutoResumeService,
initializeAutoResumeService,
} from './AutoResumeService';
export { BranchStatusService } from './BranchStatusService';
export { CascadeGuard } from './CascadeGuard';
export { ChangeExtractorService } from './ChangeExtractorService';
@ -16,12 +22,6 @@ export { BoardTaskActivityService } from './taskLogs/activity/BoardTaskActivityS
export { BoardTaskExactLogDetailService } from './taskLogs/exact/BoardTaskExactLogDetailService';
export { BoardTaskExactLogsService } from './taskLogs/exact/BoardTaskExactLogsService';
export { BoardTaskLogStreamService } from './taskLogs/stream/BoardTaskLogStreamService';
export {
AutoResumeService,
clearAutoResumeService,
getAutoResumeService,
initializeAutoResumeService,
} from './AutoResumeService';
export { TeamAttachmentStore } from './TeamAttachmentStore';
export { TeamBackupService } from './TeamBackupService';
export { TeamConfigReader } from './TeamConfigReader';

View file

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

View file

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

View file

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

View file

@ -127,6 +127,7 @@ import {
TEAM_GET_DATA,
TEAM_GET_DELETED_TASKS,
TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_MEMBER_ACTIVITY_META,
TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS,
TEAM_GET_MESSAGES_PAGE,
@ -299,9 +300,9 @@ import type {
TeamCreateConfigRequest,
TeamCreateRequest,
TeamCreateResponse,
TeamData,
TeamLaunchRequest,
TeamLaunchResponse,
TeamMemberActivityMeta,
TeamMessageNotificationData,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
@ -309,6 +310,7 @@ import type {
TeamTask,
TeamTaskStatus,
TeamUpdateConfigRequest,
TeamViewSnapshot,
ToolApprovalEvent,
ToolApprovalFileContent,
ToolApprovalSettings,
@ -829,7 +831,7 @@ const electronAPI: ElectronAPI = {
return invokeIpcWithResult<TeamSummary[]>(TEAM_LIST);
},
getData: async (teamName: string) => {
return invokeIpcWithResult<TeamData>(TEAM_GET_DATA, teamName);
return invokeIpcWithResult<TeamViewSnapshot>(TEAM_GET_DATA, teamName);
},
getTaskChangePresence: async (teamName: string) => {
return invokeIpcWithResult<Record<string, TaskChangePresenceState>>(
@ -897,10 +899,13 @@ const electronAPI: ElectronAPI = {
},
getMessagesPage: async (
teamName: string,
options?: { beforeTimestamp?: string; limit?: number }
options?: { cursor?: string | null; limit?: number }
) => {
return invokeIpcWithResult<MessagesPage>(TEAM_GET_MESSAGES_PAGE, teamName, options);
},
getMemberActivityMeta: async (teamName: string) => {
return invokeIpcWithResult<TeamMemberActivityMeta>(TEAM_GET_MEMBER_ACTIVITY_META, teamName);
},
createTask: async (teamName: string, request: CreateTaskRequest) => {
return invokeIpcWithResult<TeamTask>(TEAM_CREATE_TASK, teamName, request);
},

View file

@ -59,15 +59,16 @@ import type {
TeamClaudeLogsResponse,
TeamCreateRequest,
TeamCreateResponse,
TeamData,
TeamLaunchRequest,
TeamLaunchResponse,
TeamMemberActivityMeta,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamsAPI,
TeamSummary,
TeamTask,
TeamTaskStatus,
TeamViewSnapshot,
TmuxAPI,
TmuxStatus,
TriggerTestResult,
@ -678,7 +679,7 @@ export class HttpAPIClient implements ElectronAPI {
console.warn('[HttpAPIClient] teams API is not available in browser mode');
return [];
},
getData: async (_teamName: string): Promise<TeamData> => {
getData: async (_teamName: string): Promise<TeamViewSnapshot> => {
throw new Error('Teams detail is not available in browser mode');
},
getTaskChangePresence: async (): Promise<
@ -746,7 +747,15 @@ export class HttpAPIClient implements ElectronAPI {
throw new Error('Team messaging is not available in browser mode');
},
getMessagesPage: async () => {
return { messages: [], nextCursor: null, hasMore: false };
return { messages: [], nextCursor: null, hasMore: false, feedRevision: 'empty' };
},
getMemberActivityMeta: async (_teamName: string): Promise<TeamMemberActivityMeta> => {
return {
teamName: _teamName,
computedAt: new Date(0).toISOString(),
members: {},
feedRevision: 'empty',
};
},
createTask: async (_teamName: string, _request: CreateTaskRequest): Promise<TeamTask> => {
throw new Error('Team task creation is not available in browser mode');

View file

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

View file

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

View file

@ -71,10 +71,21 @@ interface MarkdownViewerProps {
onTeamClick?: (teamName: string) => void;
}
interface CompactMarkdownPreviewProps {
content: string;
className?: string;
/** Optional precomputed team color map to avoid subscribing to the full team list. */
teamColorByName?: ReadonlyMap<string, string>;
/** Optional team click handler to avoid subscribing to store in leaf renderers. */
onTeamClick?: (teamName: string) => void;
}
const EMPTY_TEAMS: { teamName?: string; displayName?: string; color?: string }[] = [];
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
const NOOP_TEAM_CLICK = (): void => undefined;
type ViewerMarkdownMode = 'default' | 'compact-preview';
// =============================================================================
// Helpers
// =============================================================================
@ -322,53 +333,89 @@ function createViewerMarkdownComponents(
isLight = false,
teamColorByName: ReadonlyMap<string, string> = new Map(),
onTeamClick?: (teamName: string) => void,
copyCodeBlocks: boolean = false
copyCodeBlocks: boolean = false,
mode: ViewerMarkdownMode = 'default'
): Components {
const hl = (children: React.ReactNode): React.ReactNode =>
searchCtx ? highlightSearchInChildren(children, searchCtx) : children;
const isCompactPreview = mode === 'compact-preview';
const renderCompactInline = (
children: React.ReactNode,
className: string,
style: React.CSSProperties
): React.ReactElement => (
<span className={className} style={style}>
{hl(children)}{' '}
</span>
);
return {
// Headings
h1: ({ children }) => (
<h1 className="mb-2 mt-4 text-xl font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h1>
),
h2: ({ children }) => (
<h2 className="mb-2 mt-4 text-lg font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h2>
),
h3: ({ children }) => (
<h3 className="mb-2 mt-3 text-base font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h3>
),
h4: ({ children }) => (
<h4 className="mb-1 mt-3 text-sm font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h4>
),
h5: ({ children }) => (
<h5 className="mb-1 mt-2 text-sm font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h5>
),
h6: ({ children }) => (
<h6 className="mb-1 mt-2 text-xs font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h6>
),
h1: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
) : (
<h1 className="mb-2 mt-4 text-xl font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h1>
),
h2: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
) : (
<h2 className="mb-2 mt-4 text-lg font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h2>
),
h3: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
) : (
<h3
className="mb-2 mt-3 text-base font-semibold first:mt-0"
style={{ color: PROSE_HEADING }}
>
{hl(children)}
</h3>
),
h4: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
) : (
<h4 className="mb-1 mt-3 text-sm font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h4>
),
h5: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, 'font-medium', { color: PROSE_HEADING })
) : (
<h5 className="mb-1 mt-2 text-sm font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h5>
),
h6: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, 'font-medium', { color: PROSE_HEADING })
) : (
<h6 className="mb-1 mt-2 text-xs font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h6>
),
// Paragraphs
p: ({ children }) => (
<p
className="my-2 text-sm leading-relaxed first:mt-0 last:mb-0"
style={{ color: PROSE_BODY }}
>
{hl(children)}
</p>
),
p: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, '', { color: PROSE_BODY })
) : (
<p
className="my-2 text-sm leading-relaxed first:mt-0 last:mb-0"
style={{ color: PROSE_BODY }}
>
{hl(children)}
</p>
),
// Links — inline element, no hl(); parent block element's hl() descends here
// task:// links render with TaskTooltip + are clickable via ancestor onClickCapture
@ -570,6 +617,20 @@ function createViewerMarkdownComponents(
// Code blocks — intercept mermaid diagrams at the pre level
pre: ({ children, node }) => {
if (isCompactPreview) {
const compactText = extractTextFromReactNode(children).trim();
return (
<code
className="break-all rounded px-1.5 py-0.5 font-mono text-xs"
style={{
backgroundColor: PROSE_CODE_BG,
color: PROSE_CODE_TEXT,
}}
>
{compactText}
</code>
);
}
// Check if this pre contains a mermaid code block
const codeEl = node?.children?.[0];
if (codeEl && 'tagName' in codeEl && codeEl.tagName === 'code' && 'properties' in codeEl) {
@ -596,74 +657,107 @@ function createViewerMarkdownComponents(
},
// Blockquotes
blockquote: ({ children }) => (
<blockquote
className="my-3 border-l-4 pl-4 italic"
style={{
borderColor: PROSE_BLOCKQUOTE_BORDER,
color: PROSE_MUTED,
}}
>
{hl(children)}
</blockquote>
),
blockquote: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, 'italic', { color: PROSE_MUTED })
) : (
<blockquote
className="my-3 border-l-4 pl-4 italic"
style={{
borderColor: PROSE_BLOCKQUOTE_BORDER,
color: PROSE_MUTED,
}}
>
{hl(children)}
</blockquote>
),
// Lists
ul: ({ children }) => (
<ul className="my-2 list-disc space-y-1 pl-5" style={{ color: PROSE_BODY }}>
{children}
</ul>
),
ol: ({ children }) => (
<ol className="my-2 list-decimal space-y-1 pl-5" style={{ color: PROSE_BODY }}>
{children}
</ol>
),
li: ({ children }) => (
<li className="text-sm" style={{ color: PROSE_BODY }}>
{hl(children)}
</li>
),
ul: ({ children }) =>
isCompactPreview ? (
<span>{children}</span>
) : (
<ul className="my-2 list-disc space-y-1 pl-5" style={{ color: PROSE_BODY }}>
{children}
</ul>
),
ol: ({ children }) =>
isCompactPreview ? (
<span>{children}</span>
) : (
<ol className="my-2 list-decimal space-y-1 pl-5" style={{ color: PROSE_BODY }}>
{children}
</ol>
),
li: ({ children }) =>
isCompactPreview ? (
<span className="inline" style={{ color: PROSE_BODY }}>
{hl(children)}{' '}
</span>
) : (
<li className="text-sm" style={{ color: PROSE_BODY }}>
{hl(children)}
</li>
),
// Tables
table: ({ children }) => (
<div className="my-3 overflow-x-auto">
<table
className="min-w-full border-collapse text-sm"
style={{ borderColor: PROSE_TABLE_BORDER }}
table: ({ children }) =>
isCompactPreview ? (
<span>{children}</span>
) : (
<div className="my-3 overflow-x-auto">
<table
className="min-w-full border-collapse text-sm"
style={{ borderColor: PROSE_TABLE_BORDER }}
>
{children}
</table>
</div>
),
thead: ({ children }) =>
isCompactPreview ? (
<span>{children}</span>
) : (
<thead style={{ backgroundColor: PROSE_TABLE_HEADER_BG }}>{children}</thead>
),
th: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
) : (
<th
className="px-3 py-2 text-left font-semibold"
style={{
border: `1px solid ${PROSE_TABLE_BORDER}`,
color: PROSE_HEADING,
}}
>
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead style={{ backgroundColor: PROSE_TABLE_HEADER_BG }}>{children}</thead>
),
th: ({ children }) => (
<th
className="px-3 py-2 text-left font-semibold"
style={{
border: `1px solid ${PROSE_TABLE_BORDER}`,
color: PROSE_HEADING,
}}
>
{hl(children)}
</th>
),
td: ({ children }) => (
<td
className="px-3 py-2"
style={{
border: `1px solid ${PROSE_TABLE_BORDER}`,
color: PROSE_BODY,
}}
>
{hl(children)}
</td>
),
{hl(children)}
</th>
),
td: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, '', { color: PROSE_BODY })
) : (
<td
className="px-3 py-2"
style={{
border: `1px solid ${PROSE_TABLE_BORDER}`,
color: PROSE_BODY,
}}
>
{hl(children)}
</td>
),
// Horizontal rule
hr: () => <hr className="my-4" style={{ borderColor: PROSE_TABLE_BORDER }} />,
hr: () =>
isCompactPreview ? (
<span className="mx-1" style={{ color: PROSE_TABLE_BORDER }}>
·
</span>
) : (
<hr className="my-4" style={{ borderColor: PROSE_TABLE_BORDER }} />
),
};
}
@ -679,6 +773,78 @@ const LARGE_PREVIEW_CHARS = 30_000;
// Component
// =============================================================================
function useResolvedViewerTeamContext(
providedTeamColorByName?: ReadonlyMap<string, string>,
providedOnTeamClick?: (teamName: string) => void
): {
teamColorByName: ReadonlyMap<string, string>;
onTeamClick?: (teamName: string) => void;
} {
const teams = useStore(useShallow((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams)));
const openTeamTab = useStore((s) => (providedOnTeamClick ? NOOP_TEAM_CLICK : s.openTeamTab));
const fallbackTeamColorByName = React.useMemo(() => {
const result = new Map<string, string>();
for (const team of teams) {
if (team.teamName) {
result.set(team.teamName, team.color ?? '');
}
if (team.displayName) {
result.set(team.displayName, team.color ?? '');
}
}
return result;
}, [teams]);
return {
teamColorByName: providedTeamColorByName ?? fallbackTeamColorByName ?? EMPTY_TEAM_COLOR_MAP,
onTeamClick: providedOnTeamClick ?? openTeamTab,
};
}
export const CompactMarkdownPreview: React.FC<CompactMarkdownPreviewProps> = React.memo(
function CompactMarkdownPreview({
content,
className = '',
teamColorByName: providedTeamColorByName,
onTeamClick: providedOnTeamClick,
}) {
const { isLight } = useTheme();
const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext(
providedTeamColorByName,
providedOnTeamClick
);
const components = React.useMemo(
() =>
createViewerMarkdownComponents(
null,
isLight,
teamColorByName,
onTeamClick,
false,
'compact-preview'
),
[isLight, onTeamClick, teamColorByName]
);
return (
<div className={`min-w-0 overflow-hidden ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={REHYPE_PLUGINS_NO_HIGHLIGHT}
components={components}
urlTransform={allowCustomProtocols}
allowElement={isAllowedElement}
unwrapDisallowed
>
{content}
</ReactMarkdown>
</div>
);
}
);
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
content,
maxHeight = 'max-h-96',
@ -695,24 +861,10 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
const [showRaw, setShowRaw] = React.useState(false);
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
const { isLight } = useTheme();
const teams = useStore(useShallow((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams)));
const openTeamTab = useStore((s) => (providedOnTeamClick ? NOOP_TEAM_CLICK : s.openTeamTab));
const fallbackTeamColorByName = React.useMemo(() => {
const result = new Map<string, string>();
for (const team of teams) {
if (team.teamName) {
result.set(team.teamName, team.color ?? '');
}
if (team.displayName) {
result.set(team.displayName, team.color ?? '');
}
}
return result;
}, [teams]);
const teamColorByName =
providedTeamColorByName ?? fallbackTeamColorByName ?? EMPTY_TEAM_COLOR_MAP;
const onTeamClick = providedOnTeamClick ?? openTeamTab;
const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext(
providedTeamColorByName,
providedOnTeamClick
);
const isTooLarge = content.length > MAX_MARKDOWN_CHARS;
const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS;

View file

@ -29,7 +29,7 @@ import { useShallow } from 'zustand/react/shallow';
import { resolveSkillProjectPath } from './skillProjectUtils';
import type { SkillValidationIssue } from '@shared/types';
import type { SkillValidationIssue } from '@shared/types/extensions';
interface SkillDetailDialogProps {
skillId: string | null;

View file

@ -144,11 +144,13 @@ export const SidebarTaskItem = ({
);
const showTeamRow = showTeamName && !hideTeamName;
const unreadBackgroundClass =
unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : '';
return (
<button
type="button"
className={`flex w-full cursor-pointer flex-col justify-center border-b px-2 py-1.5 text-left transition-colors hover:bg-surface-raised ${unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.08]') : ''} ${task.teamDeleted ? 'opacity-50' : ''}`}
className={`flex w-full cursor-pointer flex-col justify-center border-b px-2 py-1.5 text-left transition-colors hover:bg-surface-raised ${unreadBackgroundClass} ${task.teamDeleted ? 'opacity-50' : ''}`}
style={{ borderColor: 'var(--color-border)' }}
onClick={() => {
if (!isRenaming) {

View file

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

View file

@ -1,4 +1,14 @@
import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
lazy,
memo,
Suspense,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react';
import { api } from '@renderer/api';
import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index';
@ -23,6 +33,9 @@ import { useStore } from '@renderer/store';
import {
getCurrentProvisioningProgressForTeam,
isTeamProvisioningActive,
selectResolvedMemberForTeamName,
selectResolvedMembersForTeamName,
selectTeamMemberSnapshotsForName,
} from '@renderer/store/slices/teamSlice';
import { createChipFromSelection } from '@renderer/utils/chipUtils';
import { sumContextInjectionTokens } from '@renderer/utils/contextMath';
@ -109,10 +122,10 @@ import type { ContextInjection } from '@renderer/types/contextInjection';
import type { Session } from '@renderer/types/data';
import type { InlineChip } from '@renderer/types/inlineChip';
import type {
TeamAgentRuntimeEntry,
MemberSpawnStatusEntry,
ResolvedTeamMember,
TaskRef,
TeamAgentRuntimeEntry,
TeamTaskWithKanban,
} from '@shared/types';
import type { EditorSelectionAction } from '@shared/types/editor';
@ -833,8 +846,9 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
}: TeamMemberDetailDialogBridgeProps): React.JSX.Element | null {
const {
leadActivity,
liveMember,
progress,
members: launchMembers,
launchMembers,
memberSpawnStatuses,
memberSpawnSnapshot,
spawnEntry,
@ -842,8 +856,9 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
} = useStore(
useShallow((s) => ({
leadActivity: s.leadActivityByTeam[teamName],
liveMember: member ? selectResolvedMemberForTeamName(s, teamName, member.name) : null,
progress: getCurrentProvisioningProgressForTeam(s, teamName),
members: s.selectedTeamName === teamName ? (s.selectedTeamData?.members ?? []) : [],
launchMembers: selectTeamMemberSnapshotsForName(s, teamName),
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined,
@ -867,7 +882,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
<MemberDetailDialog
{...props}
teamName={teamName}
member={member}
member={liveMember ?? member}
isLaunchSettling={isLaunchSettling}
leadActivity={leadActivity}
spawnEntry={spawnEntry}
@ -917,7 +932,6 @@ export const TeamDetailView = ({
);
const provisioningBannerRef = useRef<HTMLDivElement>(null);
const wasProvisioningRef = useRef(false);
const pendingReplyRefreshTimerRef = useRef<number | null>(null);
const handleOpenGraphTab = useCallback(() => {
const state = useStore.getState();
const displayName = state.teamByName[teamName]?.displayName ?? teamName;
@ -994,7 +1008,7 @@ export const TeamDetailView = ({
initialActivityFilter,
} = (e as CustomEvent).detail ?? {};
if (tn !== teamName || !data) return;
const member = data.members.find((m: { name: string }) => m.name === memberName);
const member = members.find((m: { name: string }) => m.name === memberName);
if (member) {
setSelectedMember(member);
setSelectedMemberView({
@ -1155,6 +1169,7 @@ export const TeamDetailView = ({
const {
data,
members,
loading,
error,
projects,
@ -1185,6 +1200,7 @@ export const TeamDetailView = ({
clearProvisioningError,
isTeamProvisioning,
refreshTeamData,
syncTeamPendingReplyRefresh,
kanbanFilterQuery,
clearKanbanFilter,
softDeleteTask,
@ -1231,9 +1247,11 @@ export const TeamDetailView = ({
clearProvisioningError: s.clearProvisioningError,
isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false,
data: s.selectedTeamName === teamName ? s.selectedTeamData : null,
members: selectResolvedMembersForTeamName(s, teamName),
loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false,
error: s.selectedTeamName === teamName ? s.selectedTeamError : null,
refreshTeamData: s.refreshTeamData,
syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh,
kanbanFilterQuery: s.kanbanFilterQuery,
clearKanbanFilter: s.clearKanbanFilter,
softDeleteTask: s.softDeleteTask,
@ -1267,13 +1285,12 @@ export const TeamDetailView = ({
diagnostic.count += 1;
const commitMs = performance.now() - renderStartedAtRef.current;
const messagesCount = data?.messages.length ?? 0;
const tasksCount = data?.tasks.length ?? 0;
const membersCount = data?.members.length ?? 0;
const membersCount = members.length;
const processesCount = data?.processes.length ?? 0;
const shouldWarnSlow = commitMs >= TEAM_DETAIL_COMMIT_WARN_MS;
const shouldWarnBurst = diagnostic.count >= TEAM_DETAIL_RENDER_BURST_WARN_COUNT;
const shouldWarnLarge = messagesCount >= 150 || tasksCount >= 80;
const shouldWarnLarge = tasksCount >= 80;
if (
(shouldWarnSlow || shouldWarnBurst || shouldWarnLarge) &&
@ -1285,7 +1302,7 @@ export const TeamDetailView = ({
now - diagnostic.windowStartedAt
} activeTab=${isThisTabActive ? 'yes' : 'no'} paneFocused=${isPaneFocused ? 'yes' : 'no'} loading=${
loading ? 'yes' : 'no'
} messages=${messagesCount} tasks=${tasksCount} members=${membersCount} processes=${processesCount} panel=${messagesPanelMode}`
} tasks=${tasksCount} members=${membersCount} processes=${processesCount} panel=${messagesPanelMode}`
);
}
});
@ -1399,36 +1416,34 @@ export const TeamDetailView = ({
);
const leadSessionId = data?.config.leadSessionId ?? null;
const pendingReplyRefreshSourceId = useId();
const sessionHistoryKey = useMemo(
() => (data?.config.sessionHistory ?? []).join('|'),
[data?.config.sessionHistory]
);
// Keep team message state fresh while we are explicitly waiting for a reply.
// Use a delayed single-shot refresh instead of a tight polling loop so we
// don't keep rewriting the whole team snapshot every 2 seconds.
// This stays enabled even for hidden mounted tabs, because the waiting state
// is renderer-local and should keep its lightweight polling until resolved.
useEffect(() => {
if (pendingReplyRefreshTimerRef.current != null) {
window.clearTimeout(pendingReplyRefreshTimerRef.current);
pendingReplyRefreshTimerRef.current = null;
}
if (!isThisTabActive) return;
if (!data?.isAlive) return;
if (Object.keys(pendingRepliesByMember).length === 0) return;
pendingReplyRefreshTimerRef.current = window.setTimeout(() => {
pendingReplyRefreshTimerRef.current = null;
void refreshTeamData(teamName, { withDedup: true });
}, TEAM_PENDING_REPLY_REFRESH_DELAY_MS);
const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0;
syncTeamPendingReplyRefresh(
teamName,
pendingReplyRefreshSourceId,
Boolean(data?.isAlive) && hasPendingReplies,
TEAM_PENDING_REPLY_REFRESH_DELAY_MS
);
return () => {
if (pendingReplyRefreshTimerRef.current != null) {
window.clearTimeout(pendingReplyRefreshTimerRef.current);
pendingReplyRefreshTimerRef.current = null;
}
syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId, false);
};
}, [isThisTabActive, data, pendingRepliesByMember, refreshTeamData, teamName]);
}, [
data?.isAlive,
pendingRepliesByMember,
pendingReplyRefreshSourceId,
syncTeamPendingReplyRefresh,
teamName,
]);
useEffect(() => {
if (!projectId) return;
@ -1462,9 +1477,9 @@ export const TeamDetailView = ({
// Live git branch tracking for the lead project and member worktrees
const teamProjectPath = data?.config.projectPath?.trim() ?? null;
const leadProjectPath = useMemo(() => {
const explicitLeadPath = data?.members.find((member) => isLeadMember(member))?.cwd?.trim();
const explicitLeadPath = members.find((member) => isLeadMember(member))?.cwd?.trim();
return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath;
}, [data?.members, teamProjectPath]);
}, [members, teamProjectPath]);
const branchSyncPaths = useMemo(() => {
const uniquePaths = new Map<string, string>();
const addPath = (candidate: string | null | undefined): void => {
@ -1476,12 +1491,12 @@ export const TeamDetailView = ({
};
addPath(leadProjectPath);
for (const member of data?.members ?? []) {
for (const member of members) {
addPath(member.cwd);
}
return Array.from(uniquePaths.values());
}, [data?.members, leadProjectPath]);
}, [members, leadProjectPath]);
useBranchSync(branchSyncPaths, { live: true });
const trackedBranches = useStore(
useShallow((s) =>
@ -1499,7 +1514,7 @@ export const TeamDetailView = ({
const membersWithLiveBranches = useMemo(() => {
if (!data) return [];
return data.members.map((member) => {
return members.map((member) => {
const memberPath = member.cwd?.trim();
const nextGitBranch =
memberPath && !isLeadMember(member) && leadBranch !== null
@ -1521,7 +1536,7 @@ export const TeamDetailView = ({
}
return nextMember;
});
}, [data, leadBranch, trackedBranches]);
}, [leadBranch, members, trackedBranches]);
// Filter sessions to team-only using sessionHistory + leadSessionId
const teamSessionIds = useMemo(() => {
@ -1885,7 +1900,6 @@ export const TeamDetailView = ({
mountPoint: messagesPanelMountPoint,
members: activeMembers,
tasks: data?.tasks ?? [],
messages: data?.messages ?? [],
isTeamAlive: data?.isAlive,
timeWindow,
teamSessionIds,
@ -1903,7 +1917,6 @@ export const TeamDetailView = ({
activeMembers,
data?.config.leadSessionId,
data?.isAlive,
data?.messages,
data?.tasks,
handleCreateTaskFromMessage,
handleOpenTask,
@ -2588,7 +2601,7 @@ export const TeamDetailView = ({
open={requestChangesTaskId !== null}
teamName={teamName}
taskId={requestChangesTaskId}
members={data?.members ?? []}
members={members}
onCancel={() => setRequestChangesTaskId(null)}
onSubmit={(comment, taskRefs) => {
if (!requestChangesTaskId) {
@ -2615,7 +2628,6 @@ export const TeamDetailView = ({
teamName={teamName}
members={membersWithLiveBranches}
tasks={data.tasks}
messages={data.messages}
initialTab={selectedMemberView?.initialTab}
initialActivityFilter={selectedMemberView?.initialActivityFilter}
isTeamAlive={data.isAlive}
@ -2966,7 +2978,7 @@ export const TeamDetailView = ({
if (task) setSelectedTask(task);
}}
onOpenMemberProfile={(memberName, options) => {
const member = data.members.find((m) => m.name === memberName);
const member = members.find((m) => m.name === memberName);
if (member) {
setSelectedMember(member);
setSelectedMemberView({

View file

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

View file

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

View file

@ -1,12 +1,20 @@
import { Fragment, memo, useCallback, useMemo } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import {
CompactMarkdownPreview,
MarkdownViewer,
} from '@renderer/components/chat/viewers/MarkdownViewer';
import { CopyButton } from '@renderer/components/common/CopyButton';
import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { TaskTooltip } from '@renderer/components/team/TaskTooltip';
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
import {
CARD_BG,
CARD_BG_ZEBRA,
@ -780,7 +788,7 @@ export const ActivityItem = memo(
if (!isCrossTeamAny || !strippedText) return '';
const oneLine = strippedText.replace(/\n+/g, ' ').trim();
if (!oneLine) return '';
return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine;
return oneLine;
}, [isCrossTeamAny, strippedText]);
const rawSummary = useMemo(() => {
@ -806,8 +814,7 @@ export const ActivityItem = memo(
// Fallback: use the beginning of message text as preview for plain-text messages
const plain = getSanitizedInboxMessageText(message).trim();
if (!plain) return '';
const oneLine = plain.replace(/\n+/g, ' ');
return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine;
return plain.replace(/\n+/g, ' ');
}, [
crossTeamPreview,
isSlashCommandMessage,
@ -819,6 +826,46 @@ export const ActivityItem = memo(
structured,
]);
const summaryText = extractMarkdownPlainText(rawSummary);
const compactPreviewMarkdown = useMemo(() => {
if (idleSemantic?.hasPeerSummary && idleSemantic.peerSummary) {
return idleSemantic.peerSummary;
}
if (isSlashCommandResult && message.commandOutput) {
return message.summary || getCommandOutputSummary(message.text);
}
if (isSlashCommandMessage && slashCommandMeta) {
if (slashCommandMeta.args) {
const oneLine = slashCommandMeta.args.replace(/\n+/g, ' ').trim();
return `${slashCommandMeta.command} ${oneLine}`;
}
return slashCommandMeta.command;
}
if (crossTeamPreview) return crossTeamPreview;
const formattedDisplayText = displayText?.trim() ?? '';
if (formattedDisplayText) {
return formattedDisplayText;
}
return summaryText || rawSummary;
}, [
crossTeamPreview,
displayText,
idleSemantic,
isSlashCommandMessage,
isSlashCommandResult,
message,
message.commandOutput,
rawSummary,
slashCommandMeta,
summaryText,
]);
const compactPreviewTooltipText = useMemo(() => {
const normalized = extractMarkdownPlainText(compactPreviewMarkdown)
.replace(/\n+/g, ' ')
.trim();
return normalized || compactPreviewMarkdown;
}, [compactPreviewMarkdown]);
const commentTaskRef =
message.messageKind === 'task_comment_notification' ? (message.taskRefs?.[0] ?? null) : null;
const commentTaskDisplayId =
@ -1178,13 +1225,109 @@ export const ActivityItem = memo(
)}
</div>
</div>
<div
className="mt-1 min-w-0 truncate text-[11px]"
style={{ color: CARD_TEXT_LIGHT }}
title={summaryText || rawSummary}
>
{summaryContent}
<TooltipProvider delayDuration={1000}>
<Tooltip>
<TooltipTrigger asChild>
<div>
<CompactMarkdownPreview
content={compactPreviewMarkdown}
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
</div>
</TooltipTrigger>
<TooltipContent
side="bottom"
align="start"
className="max-w-sm whitespace-normal break-words"
>
{compactPreviewTooltipText}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : !isExpanded ? (
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
{isUnread ? (
<span
className="size-2 shrink-0 rounded-full bg-blue-500"
title="Unread"
aria-hidden
/>
) : null}
{showChevron ? (
<ChevronRight
className="size-3 shrink-0 transition-transform duration-150"
style={{
color: CARD_ICON_MUTED,
transform: isExpanded ? 'rotate(90deg)' : undefined,
}}
/>
) : null}
{crossTeamOrigin ? (
<CrossTeamTeamBadge teamName={crossTeamOrigin.teamName} onClick={onTeamClick} />
) : null}
{senderBadge}
{!compactHeader && formattedRole && !isSlashCommandResult ? (
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{formattedRole}
</span>
) : null}
{messageTypeBadge}
{leadSourceBadge}
{statusBadge}
{recipientBadge}
<div className="relative ml-auto flex shrink-0 items-center">
<span
className={
onExpand && expandItemKey
? 'text-[10px] transition-opacity group-hover:opacity-0'
: 'text-[10px]'
}
style={{ color: CARD_ICON_MUTED }}
>
{timestamp}
</span>
{onExpand && expandItemKey && (
<button
type="button"
aria-label="Expand message"
className="absolute right-0 top-1/2 -translate-y-1/2 rounded p-0.5 opacity-0 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500/50 group-hover:opacity-100"
style={{ color: CARD_ICON_MUTED }}
onClick={(e) => {
e.stopPropagation();
onExpand(expandItemKey);
}}
onKeyDown={(e) => e.stopPropagation()}
>
<Maximize2 size={12} />
</button>
)}
</div>
</div>
<TooltipProvider delayDuration={1000}>
<Tooltip>
<TooltipTrigger asChild>
<div>
<CompactMarkdownPreview
content={compactPreviewMarkdown}
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
</div>
</TooltipTrigger>
<TooltipContent
side="bottom"
align="start"
className="max-w-sm whitespace-normal break-words"
>
{compactPreviewTooltipText}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : (
<>

View file

@ -9,8 +9,14 @@ import {
useState,
} from 'react';
import { CompactMarkdownPreview } from '@renderer/components/chat/viewers/MarkdownViewer';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
import {
CARD_BG,
CARD_BG_ZEBRA,
@ -26,12 +32,14 @@ import {
areThoughtMessagesEquivalentForRender,
} from '@renderer/utils/messageRenderEquality';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
import { isThoughtProtocolNoise } from '@shared/utils/inboxNoise';
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
import { ChevronDown, ChevronRight, ChevronUp, Maximize2 } from 'lucide-react';
import { buildThoughtDisplayContent } from './activityMarkdown';
import {
AnimatedHeightReveal,
ENTRY_REVEAL_ANIMATION_MS,
@ -582,18 +590,30 @@ const LeadThoughtsGroupRowComponent = ({
return calls.length > 0 ? calls : undefined;
}, [thoughts]);
// Extract text preview for header: use newest thought's text, fallback through group
const headerTextPreview = useMemo(() => {
// Reuse the same markdown preprocessing as the expanded thought body.
const compactPreviewMarkdown = useMemo(() => {
// Try newest first (most relevant), then scan for any text
for (const t of thoughts) {
if (t.text && t.text.trim()) {
const plain = extractMarkdownPlainText(t.text);
const firstLine = plain.split('\n').find((l) => l.trim().length > 0) ?? '';
return firstLine.trim();
const stripped = stripAgentBlocks(t.text).trim();
if (stripped) {
return buildThoughtDisplayContent(t, memberColorMap, teamNames, {
preserveLineBreaks: false,
stripAgentOnlyBlocks: true,
})
.replace(/\n+/g, ' ')
.trim();
}
}
}
return null;
}, [thoughts]);
return totalToolSummary;
}, [memberColorMap, teamNames, thoughts, totalToolSummary]);
const compactPreviewTooltipText = useMemo(() => {
const normalized = extractMarkdownPlainText(compactPreviewMarkdown ?? '')
.replace(/\n+/g, ' ')
.trim();
return normalized || compactPreviewMarkdown;
}, [compactPreviewMarkdown]);
// Detect if any thought in this group is an API error
const hasApiError = useMemo(() => thoughts.some((t) => isApiErrorMessage(t.text)), [thoughts]);
@ -756,7 +776,6 @@ const LeadThoughtsGroupRowComponent = ({
? formatTime(oldest.timestamp)
: `${formatTime(oldest.timestamp)}${formatTime(newest.timestamp)}`;
const useCompactCollapsedHeader = compactHeader && !isBodyVisible;
const compactPreviewText = headerTextPreview ?? totalToolSummary;
return (
<AnimatedHeightReveal animate={isNew} containerRef={ref} style={{ overflowAnchor: 'none' }}>
@ -829,14 +848,113 @@ const LeadThoughtsGroupRowComponent = ({
)}
</div>
</div>
{compactPreviewText ? (
<div
className="mt-1 min-w-0 truncate text-[11px]"
style={{ color: headerTextPreview ? CARD_TEXT_LIGHT : CARD_ICON_MUTED }}
title={compactPreviewText}
>
{compactPreviewText}
{compactPreviewMarkdown ? (
<TooltipProvider delayDuration={1000}>
<Tooltip>
<TooltipTrigger asChild>
<div>
<CompactMarkdownPreview
content={compactPreviewMarkdown}
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
</div>
</TooltipTrigger>
<TooltipContent
side="bottom"
align="start"
className="max-w-sm whitespace-normal break-words"
>
{compactPreviewTooltipText}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
) : !isBodyVisible ? (
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
{canToggleBodyVisibility && !compactHeader ? (
<ChevronRight
className="size-3 shrink-0 transition-transform duration-150"
style={{
color: CARD_ICON_MUTED,
transform: isBodyVisible ? 'rotate(90deg)' : undefined,
}}
/>
) : null}
{!compactHeader ? (
<div className="relative shrink-0">
<img
src={agentAvatarUrl(leadName, 24)}
alt=""
className="size-5 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
/>
<LiveThoughtStatusBadge
canBeLive={canBeLive}
isTeamAlive={isTeamAlive}
leadActivity={leadActivity}
leadContextUpdatedAt={leadContextUpdatedAt}
newestTimestamp={newest.timestamp}
/>
</div>
) : null}
<MemberBadge name={leadName} color={memberColor} hideAvatar />
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{thoughts.length} thoughts
</span>
<div className="relative ml-auto flex shrink-0 items-center">
<span
className={
onExpand && expandItemKey
? 'text-[10px] transition-opacity group-hover:opacity-0'
: 'text-[10px]'
}
style={{ color: CARD_ICON_MUTED }}
>
{timestampLabel}
</span>
{onExpand && expandItemKey && (
<button
type="button"
aria-label="Expand thoughts"
className="absolute right-0 top-1/2 -translate-y-1/2 rounded p-0.5 opacity-0 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500/50 group-hover:opacity-100"
style={{ color: CARD_ICON_MUTED }}
onClick={(e) => {
e.stopPropagation();
onExpand(expandItemKey);
}}
onKeyDown={(e) => e.stopPropagation()}
>
<Maximize2 size={12} />
</button>
)}
</div>
</div>
{compactPreviewMarkdown ? (
<TooltipProvider delayDuration={1000}>
<Tooltip>
<TooltipTrigger asChild>
<div>
<CompactMarkdownPreview
content={compactPreviewMarkdown}
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
</div>
</TooltipTrigger>
<TooltipContent
side="bottom"
align="start"
className="max-w-sm whitespace-normal break-words"
>
{compactPreviewTooltipText}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
) : (
@ -871,26 +989,7 @@ const LeadThoughtsGroupRowComponent = ({
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{thoughts.length} thoughts
</span>
{!isBodyVisible && headerTextPreview ? (
<Tooltip>
<TooltipTrigger asChild>
<span
className="min-w-0 flex-1 cursor-default truncate text-[10px]"
style={{ color: CARD_TEXT_LIGHT }}
>
{headerTextPreview}
</span>
</TooltipTrigger>
{totalToolSummary ? (
<TooltipContent side="bottom" className="max-w-[420px] font-mono text-[11px]">
<ToolSummaryTooltipContent
toolCalls={allToolCalls}
toolSummary={totalToolSummary}
/>
</TooltipContent>
) : null}
</Tooltip>
) : totalToolSummary ? (
{totalToolSummary ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default text-[10px]" style={{ color: CARD_ICON_MUTED }}>

View file

@ -4,17 +4,16 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer
import { CopyButton } from '@renderer/components/common/CopyButton';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { CARD_ICON_MUTED, CARD_TEXT_LIGHT } from '@renderer/constants/cssVariables';
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
import {
areStringArraysEqual,
areStringMapsEqual,
areThoughtMessagesEquivalentForRender,
} from '@renderer/utils/messageRenderEquality';
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
import { stripTeammateMessageBlocks } from '@shared/utils/inboxNoise';
import { Reply } from 'lucide-react';
import { buildThoughtDisplayContent } from './activityMarkdown';
import { formatTimeWithSec, ToolSummaryTooltipContent } from './LeadThoughtsGroup';
import type { InboxMessage } from '@shared/types';
@ -42,17 +41,10 @@ export const ThoughtBodyContent = memo(
onTeamClick,
}: ThoughtBodyContentProps): JSX.Element {
const displayContent = useMemo(() => {
// Strip leaked protocol XML (<teammate-message> blocks) before rendering
let text = stripTeammateMessageBlocks(thought.text).replace(/\n/g, ' \n');
text = linkifyTaskIdsInMarkdown(text, thought.taskRefs);
if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) {
text = linkifyAllMentionsInMarkdown(
text,
(memberColorMap ?? new Map()) as Map<string, string>,
teamNames
);
}
return text;
return buildThoughtDisplayContent(thought, memberColorMap, teamNames, {
preserveLineBreaks: true,
stripAgentOnlyBlocks: true,
});
}, [thought.text, thought.taskRefs, memberColorMap, teamNames]);
const handleTaskLinkClick = useCallback(

View file

@ -0,0 +1,36 @@
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { stripTeammateMessageBlocks } from '@shared/utils/inboxNoise';
import type { InboxMessage } from '@shared/types';
interface ThoughtDisplayContentOptions {
preserveLineBreaks?: boolean;
stripAgentOnlyBlocks?: boolean;
}
export function buildThoughtDisplayContent(
thought: Pick<InboxMessage, 'text' | 'taskRefs'>,
memberColorMap?: ReadonlyMap<string, string>,
teamNames: string[] = [],
options: ThoughtDisplayContentOptions = {}
): string {
const { preserveLineBreaks = true, stripAgentOnlyBlocks = false } = options;
let text = stripTeammateMessageBlocks(thought.text);
if (stripAgentOnlyBlocks) {
text = stripAgentBlocks(text);
}
if (preserveLineBreaks) {
text = text.replace(/\n/g, ' \n');
}
text = linkifyTaskIdsInMarkdown(text, thought.taskRefs);
if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) {
text = linkifyAllMentionsInMarkdown(
text,
(memberColorMap ?? new Map()) as Map<string, string>,
teamNames
);
}
return text;
}

View file

@ -43,12 +43,9 @@ import {
import { normalizePath } from '@renderer/utils/pathNormalize';
import {
getTeamModelSelectionError,
normalizeTeamModelForUi,
normalizeExplicitTeamModelForUi,
} from '@renderer/utils/teamModelAvailability';
import {
getTeamProviderLabel as getCatalogTeamProviderLabel,
normalizeTeamModelForUi as normalizeCatalogTeamModelForUi,
} from '@renderer/utils/teamModelCatalog';
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
@ -56,7 +53,9 @@ import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
import { AdvancedCliSection } from './AdvancedCliSection';
import { OptionalSettingsSection } from './OptionalSettingsSection';
import { ProjectPathSelector } from './ProjectPathSelector';
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
import {
buildReusableProviderPrepareModelResults,
getProviderPrepareCachedSnapshot,
type ProviderPrepareDiagnosticsModelResult,
runProviderPrepareDiagnostics,
@ -111,7 +110,7 @@ function getStoredTeamModel(providerId: TeamProviderId): string {
if (stored === null) {
return providerId === 'anthropic' ? 'opus' : '';
}
return normalizeCatalogTeamModelForUi(providerId, stored === '__default__' ? '' : stored);
return normalizeExplicitTeamModelForUi(providerId, stored === '__default__' ? '' : stored);
}
function isEphemeralRenderedProjectPath(projectPath: string | null | undefined): boolean {
@ -127,14 +126,6 @@ function getProviderLabel(providerId: TeamProviderId): string {
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
}
function buildPrepareModelCacheKey(
cwd: string,
providerId: TeamProviderId,
backendSummary: string | null | undefined
): string {
return `${cwd}::${providerId}::${backendSummary ?? ''}`;
}
function alignProvisioningChecks(
existingChecks: ProvisioningProviderCheck[],
providerIds: TeamProviderId[]
@ -408,7 +399,7 @@ export const CreateTeamDialog = ({
}, [advancedKey]);
const setSelectedModel = (value: string): void => {
const normalizedValue = normalizeTeamModelForUi(selectedProviderId, value);
const normalizedValue = normalizeExplicitTeamModelForUi(selectedProviderId, value);
setSelectedModelRaw(normalizedValue);
localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, normalizedValue);
};
@ -646,7 +637,12 @@ export const CreateTeamDialog = ({
return Array.from(next);
})();
const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary);
const cacheKey = buildProviderPrepareModelCacheKey({
cwd: effectiveCwd,
providerId,
backendSummary,
limitContext,
});
const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
const cachedSnapshot = getProviderPrepareCachedSnapshot({
providerId,
@ -716,7 +712,7 @@ export const CreateTeamDialog = ({
}
prepareModelResultsCacheRef.current.set(
plan.cacheKey,
plan.prepResult.modelResultsById
buildReusableProviderPrepareModelResults(plan.prepResult.modelResultsById)
);
checks = updateProviderCheck(checks, plan.providerId, {
status: plan.prepResult.status,

View file

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

View file

@ -36,7 +36,10 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
import {
isTeamProvisioningActive,
selectResolvedMembersForTeamName,
} from '@renderer/store/slices/teamSlice';
import {
isGeminiUiFrozen,
normalizeCreateLaunchProviderForUi,
@ -45,12 +48,9 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
import { nameColorSet } from '@renderer/utils/projectColor';
import {
getTeamModelSelectionError,
normalizeTeamModelForUi,
normalizeExplicitTeamModelForUi,
} from '@renderer/utils/teamModelAvailability';
import {
getTeamProviderLabel as getCatalogTeamProviderLabel,
normalizeTeamModelForUi as normalizeCatalogTeamModelForUi,
} from '@renderer/utils/teamModelCatalog';
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import {
@ -72,7 +72,9 @@ import { EffortLevelSelector } from './EffortLevelSelector';
import { resolveLaunchDialogPrefill } from './launchDialogPrefill';
import { OptionalSettingsSection } from './OptionalSettingsSection';
import { ProjectPathSelector } from './ProjectPathSelector';
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
import {
buildReusableProviderPrepareModelResults,
getProviderPrepareCachedSnapshot,
type ProviderPrepareDiagnosticsModelResult,
runProviderPrepareDiagnostics,
@ -109,14 +111,6 @@ import type {
UpdateSchedulePatch,
} from '@shared/types';
function buildPrepareModelCacheKey(
cwd: string,
providerId: TeamProviderId,
backendSummary: string | null | undefined
): string {
return `${cwd}::${providerId}::${backendSummary ?? ''}`;
}
function alignProvisioningChecks(
existingChecks: ProvisioningProviderCheck[],
providerIds: TeamProviderId[]
@ -195,7 +189,7 @@ function getStoredTeamModel(providerId: TeamProviderId): string {
if (stored === null) {
return providerId === 'anthropic' ? 'opus' : '';
}
return normalizeCatalogTeamModelForUi(providerId, stored === '__default__' ? '' : stored);
return normalizeExplicitTeamModelForUi(providerId, stored === '__default__' ? '' : stored);
}
function getProviderLabel(providerId: TeamProviderId): string {
@ -319,7 +313,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
const prepareRequestSeqRef = useRef(0);
const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []);
const storeMembers = useStore((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName));
const previousLaunchParams = useStore((s) =>
effectiveTeamName ? s.launchParamsByTeam[effectiveTeamName] : undefined
);
@ -467,7 +461,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
};
const setSelectedModel = (value: string): void => {
const normalizedValue = normalizeTeamModelForUi(selectedProviderId, value);
const normalizedValue = normalizeExplicitTeamModelForUi(selectedProviderId, value);
setSelectedModelRaw(normalizedValue);
localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, normalizedValue);
};
@ -932,7 +926,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const providerPlans = selectedMemberProviders.map((providerId) => {
const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? [];
const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary);
const cacheKey = buildProviderPrepareModelCacheKey({
cwd: effectiveCwd,
providerId,
backendSummary,
limitContext,
});
const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
const cachedSnapshot = getProviderPrepareCachedSnapshot({
providerId,
@ -1000,7 +999,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
} else if (plan.prepResult.status === 'notes') {
anyNotes = true;
}
prepareModelResultsCacheRef.current.set(plan.cacheKey, plan.prepResult.modelResultsById);
prepareModelResultsCacheRef.current.set(
plan.cacheKey,
buildReusableProviderPrepareModelResults(plan.prepResult.modelResultsById)
);
checks = updateProviderCheck(checks, plan.providerId, {
status: plan.prepResult.status,
backendSummary: plan.backendSummary,

View file

@ -135,7 +135,7 @@ function summarizeDetail(
) {
return 'CLI binary could not be started';
}
if (lower.includes('preflight check for `claude -p` did not complete')) {
if (lower.includes('preflight check for `') && lower.includes('-p` did not complete')) {
return 'CLI preflight did not complete';
}
if (lower.includes('not authenticated') || lower.includes('not logged in')) {

View file

@ -1,5 +1,5 @@
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
import { normalizeTeamModelForUi as normalizeCatalogTeamModelForUi } from '@renderer/utils/teamModelCatalog';
import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability';
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
import { isLeadMember } from '@shared/utils/leadDetection';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
@ -102,7 +102,7 @@ export function resolveLaunchDialogPrefill({
return {
providerId,
model: matchingModel
? normalizeCatalogTeamModelForUi(providerId, matchingModel)
? normalizeExplicitTeamModelForUi(providerId, matchingModel)
: getStoredModel(providerId),
effort,
limitContext,

View file

@ -0,0 +1,20 @@
import type { TeamProviderId } from '@shared/types';
export function buildProviderPrepareModelCacheKey({
cwd,
providerId,
backendSummary,
limitContext,
}: {
cwd: string;
providerId: TeamProviderId;
backendSummary: string | null | undefined;
limitContext: boolean;
}): string {
return [
cwd,
providerId,
backendSummary ?? '',
limitContext ? 'limit-context:on' : 'limit-context:off',
].join('::');
}

View file

@ -39,6 +39,14 @@ export interface ProviderPrepareDiagnosticsResult {
modelResultsById: Record<string, ProviderPrepareDiagnosticsModelResult>;
}
export function buildReusableProviderPrepareModelResults(
modelResultsById: Record<string, ProviderPrepareDiagnosticsModelResult>
): Record<string, ProviderPrepareDiagnosticsModelResult> {
return Object.fromEntries(
Object.entries(modelResultsById).filter(([, result]) => result.status !== 'notes')
);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@ -187,6 +195,29 @@ function getResultReason(modelId: string, result: TeamProvisioningPrepareResult)
return null;
}
function getModelScopedEntries(modelId: string, result: TeamProvisioningPrepareResult): string[] {
const escapedModelId = escapeRegExp(modelId);
const scopedPattern = new RegExp(`^Selected model ${escapedModelId}\\b`, 'i');
return [...(result.details ?? []), ...(result.warnings ?? []), result.message]
.map((entry) => entry?.trim() ?? '')
.filter(Boolean)
.filter((entry) => scopedPattern.test(entry));
}
function getScopedModelReason(modelId: string, entries: string[]): string | null {
for (const entry of entries) {
const stripped = stripSelectedModelPrefix(modelId, entry);
if (!stripped) {
continue;
}
const normalized = normalizeModelReason(stripped);
if (normalized) {
return normalized;
}
}
return null;
}
function buildModelFailureLine(
providerId: TeamProviderId,
modelId: string,
@ -201,6 +232,116 @@ function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string
return [...(result.details ?? []), ...(result.warnings ?? [])];
}
function extractTimedOutPreflightProbeModelId(detail: string): string | null {
const trimmed = detail.trim();
if (!trimmed) {
return null;
}
if (
!trimmed.toLowerCase().includes('preflight check for `') ||
!trimmed.toLowerCase().includes('-p` did not complete')
) {
return null;
}
const match = /--model\s+([^\s]+)/i.exec(trimmed);
return match?.[1]?.trim() || null;
}
function suppressSupersededRuntimeWarnings(params: {
runtimeDetailLines: string[];
runtimeWarnings: string[];
modelResultsById: Map<string, ProviderPrepareDiagnosticsModelResult>;
}): {
runtimeDetailLines: string[];
runtimeWarnings: string[];
} {
const suppressedEntries = new Set<string>();
for (const warning of params.runtimeWarnings) {
const probedModelId = extractTimedOutPreflightProbeModelId(warning);
if (!probedModelId) {
continue;
}
if (params.modelResultsById.get(probedModelId)?.status !== 'ready') {
continue;
}
suppressedEntries.add(warning);
}
return {
runtimeDetailLines: params.runtimeDetailLines.filter(
(detail) => !suppressedEntries.has(detail)
),
runtimeWarnings: params.runtimeWarnings.filter((warning) => !suppressedEntries.has(warning)),
};
}
function resolveModelResultFromBatch(
providerId: TeamProviderId,
modelId: string,
result: TeamProvisioningPrepareResult,
isOnlyModel: boolean
): ProviderPrepareDiagnosticsModelResult {
const modelScopedEntries = getModelScopedEntries(modelId, result);
const normalizedReason =
getScopedModelReason(modelId, modelScopedEntries) ??
(isOnlyModel ? normalizeModelReason(result.message) : null);
const hasVerifiedLine = modelScopedEntries.some((entry) =>
/selected model .* verified for launch\./i.test(entry)
);
if (hasVerifiedLine) {
return {
status: 'ready',
line: buildModelSuccessLine(providerId, modelId),
warningLine: null,
};
}
const hasUnavailableLine = modelScopedEntries.some((entry) =>
/selected model .* is unavailable\./i.test(entry)
);
if (hasUnavailableLine || (!result.ready && isOnlyModel)) {
return {
status: 'failed',
line: buildModelFailureLine(providerId, modelId, 'unavailable', normalizedReason),
warningLine: null,
};
}
const hasVerificationWarningLine = modelScopedEntries.some((entry) =>
/selected model .* could not be verified\./i.test(entry)
);
if (hasVerificationWarningLine || ((result.warnings?.length ?? 0) > 0 && isOnlyModel)) {
const line = buildModelFailureLine(providerId, modelId, 'check failed', normalizedReason);
return {
status: 'notes',
line,
warningLine: line,
};
}
if (result.ready) {
return {
status: 'ready',
line: buildModelSuccessLine(providerId, modelId),
warningLine: null,
};
}
const line = buildModelFailureLine(
providerId,
modelId,
'check failed',
normalizedReason ?? 'Model verification failed'
);
return {
status: 'notes',
line,
warningLine: line,
};
}
export async function runProviderPrepareDiagnostics({
cwd,
providerId,
@ -254,7 +395,7 @@ export async function runProviderPrepareDiagnostics({
const modelLines = new Map<string, string>();
let completedCount = 0;
let hasFailure = false;
let hasNotes = runtimeWarnings.length > 0;
let hasNotes = false;
const modelWarnings: string[] = [];
for (const modelId of orderedModelIds) {
@ -289,73 +430,64 @@ export async function runProviderPrepareDiagnostics({
emitProgress();
await Promise.all(
orderedModelIds
.filter((modelId) => !modelResultsById.has(modelId))
.map(async (modelId) => {
try {
const modelResult = await prepareProvisioning(
cwd,
providerId,
[providerId],
[modelId],
limitContext
);
if (!modelResult.ready) {
hasFailure = true;
const line = buildModelFailureLine(
providerId,
modelId,
'unavailable',
getResultReason(modelId, modelResult) ?? normalizeModelReason(modelResult.message)
);
modelLines.set(modelId, line);
modelResultsById.set(modelId, {
status: 'failed',
line,
warningLine: null,
});
} else if ((modelResult.warnings?.length ?? 0) > 0) {
hasNotes = true;
const reason = getResultReason(modelId, modelResult);
const line = buildModelFailureLine(providerId, modelId, 'check failed', reason);
modelLines.set(modelId, line);
modelWarnings.push(line);
modelResultsById.set(modelId, {
status: 'notes',
line,
warningLine: line,
});
} else {
const line = buildModelSuccessLine(providerId, modelId);
modelLines.set(modelId, line);
modelResultsById.set(modelId, {
status: 'ready',
line,
warningLine: null,
});
}
} catch (error) {
hasNotes = true;
const reason = normalizeModelReason(
error instanceof Error ? error.message.trim() : String(error).trim()
);
const line = buildModelFailureLine(providerId, modelId, 'check failed', reason || null);
modelLines.set(modelId, line);
modelWarnings.push(line);
modelResultsById.set(modelId, {
status: 'notes',
line,
warningLine: line,
});
} finally {
completedCount += 1;
emitProgress();
}
})
);
const uncachedModelIds = orderedModelIds.filter((modelId) => !modelResultsById.has(modelId));
if (uncachedModelIds.length > 0) {
try {
const batchedModelResult = await prepareProvisioning(
cwd,
providerId,
[providerId],
uncachedModelIds,
limitContext
);
const dedupedWarnings = Array.from(new Set([...runtimeWarnings, ...modelWarnings]));
for (const modelId of uncachedModelIds) {
const resolvedResult = resolveModelResultFromBatch(
providerId,
modelId,
batchedModelResult,
uncachedModelIds.length === 1
);
modelLines.set(modelId, resolvedResult.line);
modelResultsById.set(modelId, resolvedResult);
if (resolvedResult.status === 'failed') {
hasFailure = true;
} else if (resolvedResult.status === 'notes') {
hasNotes = true;
}
if (resolvedResult.warningLine) {
modelWarnings.push(resolvedResult.warningLine);
}
}
} catch (error) {
hasNotes = true;
const reason = normalizeModelReason(
error instanceof Error ? error.message.trim() : String(error).trim()
);
for (const modelId of uncachedModelIds) {
const line = buildModelFailureLine(providerId, modelId, 'check failed', reason || null);
modelLines.set(modelId, line);
modelWarnings.push(line);
modelResultsById.set(modelId, {
status: 'notes',
line,
warningLine: line,
});
}
} finally {
completedCount += uncachedModelIds.length;
emitProgress();
}
}
const filteredRuntime = suppressSupersededRuntimeWarnings({
runtimeDetailLines,
runtimeWarnings,
modelResultsById,
});
const dedupedWarnings = Array.from(
new Set([...filteredRuntime.runtimeWarnings, ...modelWarnings])
);
const selectedModelResultsById = Object.fromEntries(
orderedModelIds
.map((modelId) => [modelId, modelResultsById.get(modelId)] as const)
@ -365,9 +497,9 @@ export async function runProviderPrepareDiagnostics({
);
return {
status: hasFailure ? 'failed' : hasNotes ? 'notes' : 'ready',
status: hasFailure ? 'failed' : hasNotes || dedupedWarnings.length > 0 ? 'notes' : 'ready',
details: [
...runtimeDetailLines,
...filteredRuntime.runtimeDetailLines,
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
],
warnings: dedupedWarnings,

View file

@ -0,0 +1,162 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('@renderer/components/team/MemberBadge', () => ({
MemberBadge: ({ name }: { name: string }) => React.createElement('span', null, name),
}));
vi.mock('@renderer/components/team/UnreadCommentsBadge', () => ({
UnreadCommentsBadge: () => null,
}));
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
className,
onClick,
disabled,
'aria-label': ariaLabel,
}: {
children: React.ReactNode;
className?: string;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
disabled?: boolean;
'aria-label'?: string;
}) =>
React.createElement(
'button',
{ className, onClick, disabled, 'aria-label': ariaLabel, type: 'button' },
children
),
}));
vi.mock('@renderer/components/ui/popover', () => ({
Popover: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
PopoverTrigger: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
PopoverContent: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
}));
vi.mock('@renderer/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
TooltipContent: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
}));
vi.mock('@renderer/hooks/useTheme', () => ({
useTheme: () => ({ isLight: false }),
}));
vi.mock('@renderer/hooks/useUnreadCommentCount', () => ({
useUnreadCommentCount: () => 0,
}));
import { KanbanTaskCard } from './KanbanTaskCard';
import type { TeamTaskWithKanban } from '@shared/types/team';
const baseTask: TeamTaskWithKanban = {
id: 'task-1',
displayId: 'abcd1234',
subject: 'Implement safer onboarding flow',
owner: 'alice',
reviewer: '',
status: 'in_progress',
changePresence: 'unknown',
comments: [],
blockedBy: [],
blocks: [],
workIntervals: [],
historyEvents: [],
createdAt: '2026-04-18T10:00:00.000Z',
updatedAt: '2026-04-18T10:10:00.000Z',
} as unknown as TeamTaskWithKanban;
const noop = (): void => undefined;
describe('KanbanTaskCard change badge', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('does not render a No changes badge when changePresence is no_changes', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(KanbanTaskCard, {
task: { ...baseTask, changePresence: 'no_changes' },
teamName: 'my-team',
columnId: 'in_progress',
hasReviewers: true,
compact: false,
taskMap: new Map(),
memberColorMap: new Map([['alice', 'blue']]),
onRequestReview: noop,
onApprove: noop,
onRequestChanges: noop,
onMoveBackToDone: noop,
onStartTask: noop,
onCompleteTask: noop,
onCancelTask: noop,
onViewChanges: noop,
})
);
await Promise.resolve();
});
expect(host.textContent).not.toContain('No changes');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('still renders the Changes action when changePresence is has_changes', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(KanbanTaskCard, {
task: { ...baseTask, changePresence: 'has_changes' },
teamName: 'my-team',
columnId: 'in_progress',
hasReviewers: true,
compact: false,
taskMap: new Map(),
memberColorMap: new Map([['alice', 'blue']]),
onRequestReview: noop,
onApprove: noop,
onRequestChanges: noop,
onMoveBackToDone: noop,
onStartTask: noop,
onCompleteTask: noop,
onCancelTask: noop,
onViewChanges: noop,
})
);
await Promise.resolve();
});
expect(host.querySelector('[aria-label="Changes"]')).not.toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -268,10 +268,6 @@ export const KanbanTaskCard = memo(
onViewChanges!(task.id);
}}
/>
) : canDisplay && task.changePresence === 'no_changes' ? (
<span className="inline-flex h-6 shrink-0 items-center rounded-full border border-[var(--color-border)] px-2 text-[10px] text-[var(--color-text-muted)]">
No changes
</span>
) : null}
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
{onDeleteTask ? (

View file

@ -6,7 +6,6 @@ import type { TeamTaskWithKanban } from '@shared/types';
interface CurrentTaskIndicatorProps {
task: TeamTaskWithKanban;
borderColor: string;
/** Max characters for the subject before truncating */
maxSubjectLength?: number;
activityLabel?: string;
onOpenTask?: () => void;
@ -19,21 +18,24 @@ interface CurrentTaskIndicatorProps {
export const CurrentTaskIndicator = ({
task,
borderColor,
maxSubjectLength = 36,
maxSubjectLength,
activityLabel = 'working on',
onOpenTask,
}: CurrentTaskIndicatorProps): React.JSX.Element => {
const truncated = task.subject.length > maxSubjectLength;
const subjectText = truncated ? `${task.subject.slice(0, maxSubjectLength)}` : task.subject;
const subjectText =
typeof maxSubjectLength === 'number' &&
maxSubjectLength > 0 &&
task.subject.length > maxSubjectLength
? `${task.subject.slice(0, maxSubjectLength)}`
: task.subject;
return (
<>
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<Loader2 className="size-3 shrink-0 animate-spin" style={{ color: borderColor }} />
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">{activityLabel}</span>
<button
type="button"
className="min-w-0 shrink truncate rounded px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
style={{ border: `1px solid ${borderColor}40` }}
className="min-w-0 flex-1 truncate rounded px-1.5 py-0.5 text-left text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
title="Open task"
onClick={(e) => {
e.stopPropagation();
@ -49,6 +51,6 @@ export const CurrentTaskIndicator = ({
>
{formatTaskDisplayLabel(task)} {subjectText}
</button>
</>
</div>
);
};

View file

@ -1,6 +1,6 @@
import { Badge } from '@renderer/components/ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet, getThemedBadge, scaleColorAlpha } from '@renderer/constants/teamColors';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
@ -101,6 +101,7 @@ export const MemberCard = ({
const completed = taskCounts?.completed ?? 0;
const totalTasks = pending + inProgress + completed;
const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
const activityTask = currentTask ?? reviewTask ?? null;
const activityTitle = currentTask
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
@ -111,7 +112,8 @@ export const MemberCard = ({
!isRemoved &&
presenceLabel === 'starting' &&
spawnLaunchState !== 'failed_to_start' &&
!activityTask;
!activityTask &&
!runtimeSummary;
const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask;
const showRuntimeAdvisoryBadge =
!isRemoved &&
@ -119,18 +121,14 @@ export const MemberCard = ({
!showStartingBadge &&
spawnStatus !== 'error' &&
(Boolean(activityTask) || !isAwaitingReply);
const cardTint = scaleColorAlpha(getThemedBadge(colors, isLight), 0.5);
return (
<div
className={`rounded transition-opacity duration-300 ${isRemoved ? 'opacity-50' : ''} ${spawnCardClass}`}
>
<div
className="group relative cursor-pointer rounded px-2 py-1.5"
style={{
borderLeft: `3px solid ${colors.border}`,
background: `linear-gradient(to right, ${cardTint}, transparent)`,
}}
className="group relative cursor-pointer rounded py-1.5"
style={undefined}
title={activityTitle}
role="button"
tabIndex={0}
@ -145,19 +143,27 @@ export const MemberCard = ({
<div className="pointer-events-none absolute inset-0 rounded transition-colors group-hover:bg-white/5" />
<div className="flex items-center gap-2.5">
<div className="relative shrink-0">
<img
src={agentAvatarUrl(member.name)}
alt={member.name}
className="size-7 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
/>
<div
className="rounded-full border-2 p-[1px]"
style={{
borderColor: colors.border,
boxShadow: isLight ? 'none' : `0 0 0 1px ${colors.badge}`,
}}
>
<img
src={agentAvatarUrl(member.name)}
alt={member.name}
className="size-7 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
/>
</div>
<span
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
aria-label={presenceLabel}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5 truncate text-sm">
<div className="flex min-w-0 items-center gap-1.5 text-sm">
<span className="shrink-0 font-medium text-[var(--color-text)]">
{displayMemberName(member.name)}
</span>
@ -209,20 +215,16 @@ export const MemberCard = ({
style={{ backgroundColor: 'var(--skeleton-base)' }}
/>
</div>
) : runtimeSummary ? (
<div className="mt-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
{runtimeSummary}
) : runtimeSummary || roleLabel ? (
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[10px] font-medium text-[var(--color-text-muted)]">
{runtimeSummary ? <span className="min-w-0 truncate">{runtimeSummary}</span> : null}
{runtimeSummary && roleLabel ? (
<span className="shrink-0 opacity-60"></span>
) : null}
{roleLabel ? <span className="shrink-0">{roleLabel}</span> : null}
</div>
) : null}
</div>
{(() => {
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
return roleLabel ? (
<span className="hidden shrink-0 text-xs text-[var(--color-text-muted)] sm:inline">
{roleLabel}
</span>
) : null;
})()}
{showStartingBadge ? (
<span className="flex shrink-0 items-center gap-1">
<Loader2

View file

@ -1,13 +1,11 @@
import { useEffect, useMemo, useState } from 'react';
import {
buildGraphMemberNodeIdForMember,
buildInlineActivityEntries,
} from '@features/agent-graph/renderer';
import { Button } from '@renderer/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import { useMemberStats } from '@renderer/hooks/useMemberStats';
import { useStore } from '@renderer/store';
import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice';
import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
import { isLeadMember } from '@shared/utils/leadDetection';
import {
@ -20,6 +18,7 @@ import {
UserMinus,
} from 'lucide-react';
import { buildMemberActivityEntries } from './memberActivityEntries';
import { MemberDetailHeader } from './MemberDetailHeader';
import { MemberDetailStats } from './MemberDetailStats';
import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes';
@ -28,15 +27,14 @@ import { MemberMessagesTab } from './MemberMessagesTab';
import { MemberStatsTab } from './MemberStatsTab';
import { MemberTasksTab } from './MemberTasksTab';
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
import type {
InboxMessage,
LeadActivityState,
MemberSpawnStatusEntry,
TeamAgentRuntimeEntry,
ResolvedTeamMember,
TeamAgentRuntimeEntry,
TeamTaskWithKanban,
} from '@shared/types';
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
interface MemberDetailDialogProps {
open: boolean;
@ -44,7 +42,6 @@ interface MemberDetailDialogProps {
teamName: string;
members: ResolvedTeamMember[];
tasks: TeamTaskWithKanban[];
messages: InboxMessage[];
initialTab?: MemberDetailTab;
initialActivityFilter?: MemberActivityFilter;
isTeamAlive?: boolean;
@ -71,7 +68,6 @@ export const MemberDetailDialog = ({
teamName,
members,
tasks,
messages,
initialTab = 'tasks',
initialActivityFilter = 'all',
isTeamAlive,
@ -95,33 +91,20 @@ export const MemberDetailDialog = ({
() => (member ? tasks.filter((t) => t.owner === member.name) : []),
[tasks, member]
);
const seedMemberMessages = useMemo(
() => (member ? messages.filter((m) => m.from === member.name || m.to === member.name) : []),
[messages, member]
const memberMessages = useStore((state) =>
selectMemberMessagesForTeamMember(state, teamName, member?.name ?? null)
);
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,
},
return buildMemberActivityEntries({
teamName,
leadId,
leadName,
ownerNodeIds: new Set([leadId, ownerNodeId]),
});
return (entries.get(ownerNodeId) ?? []).length;
memberName: member.name,
members,
tasks,
messages: memberMessages,
}).length;
}, [member, memberMessages, members, tasks, teamName]);
const inProgressTasks = useMemo(
@ -236,7 +219,6 @@ export const MemberDetailDialog = ({
</TabsContent>
<TabsContent value="activity">
<MemberMessagesTab
messages={memberMessages}
teamName={teamName}
memberName={member.name}
members={members}

View file

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

View file

@ -10,9 +10,9 @@ import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type {
LeadActivityState,
TeamAgentRuntimeEntry,
MemberSpawnStatusEntry,
ResolvedTeamMember,
TeamAgentRuntimeEntry,
TeamTaskWithKanban,
} from '@shared/types';

View file

@ -1,7 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { buildInlineActivityEntries } from '@features/agent-graph/renderer';
import { api } from '@renderer/api';
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
import {
buildMessageContext,
@ -10,17 +8,18 @@ import {
import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog';
import { Button } from '@renderer/components/ui/button';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
import { useStore } from '@renderer/store';
import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { isLeadMember } from '@shared/utils/leadDetection';
import { useShallow } from 'zustand/react/shallow';
import { buildMemberActivityEntries } from './memberActivityEntries';
import type { MemberActivityFilter } from './memberDetailTypes';
import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup';
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
interface MemberMessagesTabProps {
messages: InboxMessage[];
teamName: string;
memberName: string;
members: ResolvedTeamMember[];
@ -31,7 +30,6 @@ interface MemberMessagesTabProps {
}
const MAX_MESSAGES = 100;
const MEMBER_MESSAGES_PAGE_SIZE = 50;
const FILTER_OPTIONS: readonly { value: MemberActivityFilter; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'messages', label: 'Messages' },
@ -39,7 +37,6 @@ const FILTER_OPTIONS: readonly { value: MemberActivityFilter; label: string }[]
];
export const MemberMessagesTab = ({
messages,
teamName,
memberName,
members,
@ -48,21 +45,16 @@ export const MemberMessagesTab = ({
onCreateTask,
onTaskClick,
}: MemberMessagesTabProps): React.JSX.Element => {
const [pagedMessages, setPagedMessages] = useState<InboxMessage[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [initialPageLoading, setInitialPageLoading] = useState(false);
const [loadingOlderMessages, setLoadingOlderMessages] = useState(false);
const [activityFilter, setActivityFilter] = useState<MemberActivityFilter>(initialFilter);
const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(null);
const { readSet } = useTeamMessagesRead(teamName);
const leadId = `lead:${teamName}`;
const leadName = useMemo(
() => members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`,
[members, teamName]
const { messages, messagesState, loadOlderTeamMessages } = useStore(
useShallow((s) => ({
messages: selectMemberMessagesForTeamMember(s, teamName, memberName),
messagesState: teamName ? s.teamMessagesByName[teamName] : undefined,
loadOlderTeamMessages: s.loadOlderTeamMessages,
}))
);
const ownerNodeId = memberName === leadName ? leadId : `member:${teamName}:${memberName}`;
const ownerNodeIds = useMemo(() => new Set([leadId, ownerNodeId]), [leadId, ownerNodeId]);
const { readSet } = useTeamMessagesRead(teamName);
const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
const messageContext = useMemo(() => buildMessageContext(members), [members]);
@ -70,108 +62,45 @@ export const MemberMessagesTab = ({
setActivityFilter(initialFilter);
}, [initialFilter, memberName, teamName]);
useEffect(() => {
let cancelled = false;
setPagedMessages([]);
setNextCursor(null);
setHasMore(false);
setInitialPageLoading(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) {
setInitialPageLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [teamName, memberName]);
const loadOlderMessages = useCallback(async () => {
if (!nextCursor || loadingOlderMessages) return;
setLoadingOlderMessages(true);
try {
const page = await api.teams.getMessagesPage(teamName, {
beforeTimestamp: nextCursor,
limit: MEMBER_MESSAGES_PAGE_SIZE,
});
const memberPageMessages = page.messages.filter(
(message) => message.from === memberName || message.to === memberName
);
setPagedMessages((prev) => mergeTeamMessages(prev, memberPageMessages));
setNextCursor(page.nextCursor);
setHasMore(page.hasMore);
} catch {
// best-effort
} finally {
setLoadingOlderMessages(false);
if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) {
return;
}
}, [loadingOlderMessages, memberName, nextCursor, teamName]);
await loadOlderTeamMessages(teamName);
}, [loadOlderTeamMessages, messagesState, teamName]);
const effectiveMessages = useMemo(
() => mergeTeamMessages(messages, pagedMessages),
[messages, pagedMessages]
);
const filteredMessages = useMemo(
() =>
filterTeamMessages(effectiveMessages, {
timeWindow: null,
filter: { from: new Set(), to: new Set(), showNoise: true },
searchQuery: '',
}),
[effectiveMessages]
);
const loading = (messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false);
const loadingOlderMessages = messagesState?.loadingOlder ?? false;
const hasMore = messagesState?.hasMore ?? false;
const activityEntries = useMemo(() => {
const entriesByOwner = buildInlineActivityEntries({
data: {
members,
tasks,
messages: filteredMessages,
},
return buildMemberActivityEntries({
teamName,
leadId,
leadName,
ownerNodeIds,
memberName,
members,
tasks,
messages,
});
return (entriesByOwner.get(ownerNodeId) ?? []).slice(0, MAX_MESSAGES);
}, [filteredMessages, leadId, leadName, members, ownerNodeId, ownerNodeIds, tasks, teamName]);
}, [memberName, members, messages, tasks, teamName]);
const visibleActivityEntries = useMemo(
() => activityEntries.slice(0, MAX_MESSAGES),
[activityEntries]
);
const displayEntries = useMemo(() => {
switch (activityFilter) {
case 'messages':
return activityEntries.filter(
return visibleActivityEntries.filter(
(entry) => entry.message.messageKind !== 'task_comment_notification'
);
case 'comments':
return activityEntries.filter(
return visibleActivityEntries.filter(
(entry) => entry.message.messageKind === 'task_comment_notification'
);
default:
return activityEntries;
return visibleActivityEntries;
}
}, [activityEntries, activityFilter]);
}, [activityFilter, visibleActivityEntries]);
const expandedItemsByKey = useMemo(() => {
const items = new Map<string, TimelineItem>();
@ -201,6 +130,7 @@ export const MemberMessagesTab = ({
[onTaskClick, taskMap, tasks]
);
const initialPageLoading = loading && activityEntries.length === 0;
const emptyStateText = initialPageLoading
? 'Loading activity...'
: activityFilter === 'comments'
@ -209,9 +139,10 @@ export const MemberMessagesTab = ({
? hasMore
? 'No loaded messages for this member yet'
: 'No messages with this member'
: 'No activity with this member';
const canLoadOlderMessages =
hasMore && activityFilter !== 'comments' && displayEntries.length > 0;
: hasMore
? 'No loaded activity for this member yet'
: 'No activity with this member';
const canLoadOlderMessages = hasMore && activityFilter !== 'comments';
return (
<div className="space-y-3">

View file

@ -0,0 +1,42 @@
import { buildInlineActivityEntries } from '@features/agent-graph/renderer';
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
import { isLeadMember } from '@shared/utils/leadDetection';
import type { InlineActivityEntry } from '@features/agent-graph/renderer';
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
export function buildMemberActivityEntries({
teamName,
memberName,
members,
tasks,
messages,
}: {
teamName: string;
memberName: string;
members: ResolvedTeamMember[];
tasks: TeamTaskWithKanban[];
messages: InboxMessage[];
}): InlineActivityEntry[] {
const filteredMessages = filterTeamMessages(messages, {
timeWindow: null,
filter: { from: new Set(), to: new Set(), showNoise: true },
searchQuery: '',
});
const leadId = `lead:${teamName}`;
const leadName = members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`;
const ownerNodeId = memberName === leadName ? leadId : `member:${teamName}:${memberName}`;
const ownerNodeIds = new Set([leadId, ownerNodeId]);
const entriesByOwner = buildInlineActivityEntries({
data: {
members,
tasks,
messages: filteredMessages,
},
teamName,
leadId,
leadName,
ownerNodeIds,
});
return entriesByOwner.get(ownerNodeId) ?? [];
}

View file

@ -2,9 +2,7 @@ import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRole
import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
import { normalizeTeamModelForUi as normalizeCatalogTeamModelForUi } from '@renderer/utils/teamModelCatalog';
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability';
import { isLeadMember } from '@shared/utils/leadDetection';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
@ -34,7 +32,6 @@ function newDraftId(): string {
export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
const providerId = initial?.providerId;
const normalizedModel = extractProviderScopedBaseModel(initial?.model ?? '', providerId) ?? '';
return {
id: initial?.id ?? newDraftId(),
name: initial?.name ?? '',
@ -42,7 +39,7 @@ export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
customRole: initial?.customRole ?? '',
workflow: initial?.workflow,
providerId,
model: normalizeCatalogTeamModelForUi(providerId, normalizedModel),
model: normalizeExplicitTeamModelForUi(providerId, initial?.model ?? ''),
effort: initial?.effort,
removedAt: initial?.removedAt,
};
@ -221,7 +218,7 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning
}
const model = member.model?.trim();
if (model) {
result.model = normalizeTeamModelForUi(providerId, model);
result.model = normalizeExplicitTeamModelForUi(providerId, model);
}
const effort = normalizeDraftEffort(member.effort);
if (effort) {

View file

@ -1,7 +1,6 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Sheet, type SheetRef } from 'react-modal-sheet';
import { api } from '@renderer/api';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@ -9,11 +8,10 @@ import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMe
import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { useStore } from '@renderer/store';
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
import { selectTeamMessages } from '@renderer/store/slices/teamSlice';
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
import { createLogger } from '@shared/utils/logger';
import {
CheckCheck,
ChevronsDownUp,
@ -53,9 +51,6 @@ interface TimeWindow {
end: number;
}
const logger = createLogger('Component:MessagesPanel');
const MESSAGES_PANEL_FILTER_WARN_MS = 8;
const MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS = 6;
const BOTTOM_SHEET_HEADER_HEIGHT = 40;
const BOTTOM_SHEET_COLLAPSED_SNAP_INDEX = 1;
const BOTTOM_SHEET_COMPOSER_SNAP_INDEX = 2;
@ -70,8 +65,6 @@ interface MessagesPanelProps {
members: ResolvedTeamMember[];
/** All team tasks. */
tasks: TeamTaskWithKanban[];
/** All raw messages from team data. */
messages: InboxMessage[];
/** Whether the team is alive. */
isTeamAlive?: boolean;
/** Live lead activity status for the current team. */
@ -107,7 +100,6 @@ export const MessagesPanel = memo(function MessagesPanel({
mountPoint,
members,
tasks,
messages,
isTeamAlive,
leadActivity,
leadContextUpdatedAt,
@ -130,6 +122,9 @@ export const MessagesPanel = memo(function MessagesPanel({
lastSendMessageResult,
teams,
openTeamTab,
messages,
messagesState,
loadOlderTeamMessages,
} = useStore(
useShallow((s) => ({
sendTeamMessage: s.sendTeamMessage,
@ -139,76 +134,24 @@ export const MessagesPanel = memo(function MessagesPanel({
lastSendMessageResult: s.lastSendMessageResult,
teams: s.teams,
openTeamTab: s.openTeamTab,
messages: selectTeamMessages(s, teamName),
messagesState: teamName ? s.teamMessagesByName[teamName] : undefined,
loadOlderTeamMessages: s.loadOlderTeamMessages,
}))
);
// ── Paginated message fetching ──
// Messages are now fetched via getMessagesPage API instead of coming
// from getTeamData. The `messages` prop is used as initial seed if non-empty.
const PAGE_SIZE = 50;
const [fetchedMessages, setFetchedMessages] = useState<InboxMessage[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [loadingOlderMessages, setLoadingOlderMessages] = useState(false);
const fetchIdRef = useRef(0);
// Initial fetch on mount or team change
useEffect(() => {
const id = ++fetchIdRef.current;
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);
}
}
})();
}, [teamName]); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally only on teamName change
// Auto-refresh: poll for NEW messages only (prepend to head).
// Does NOT touch nextCursor/hasMore — those belong to the "Load older" flow.
useEffect(() => {
if (!isTeamAlive && leadActivity !== 'active') return;
const interval = setInterval(async () => {
try {
const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE });
setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages));
} catch {
// best-effort
}
}, 5000);
return () => clearInterval(interval);
}, [teamName, isTeamAlive, leadActivity]);
const loadOlderMessages = useCallback(async () => {
if (!nextCursor || loadingOlderMessages) return;
setLoadingOlderMessages(true);
try {
const page = await api.teams.getMessagesPage(teamName, {
beforeTimestamp: nextCursor,
limit: PAGE_SIZE,
});
setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages));
setNextCursor(page.nextCursor);
setHasMore(page.hasMore);
} catch {
// best-effort
} finally {
setLoadingOlderMessages(false);
if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) {
return;
}
}, [loadingOlderMessages, nextCursor, teamName]);
await loadOlderTeamMessages(teamName);
}, [loadOlderTeamMessages, messagesState, teamName]);
// Use fetched messages, fall back to prop messages during initial load
const effectiveMessages = useMemo(() => {
if (fetchedMessages.length === 0) return messages;
return mergeTeamMessages(fetchedMessages, messages);
}, [fetchedMessages, messages]);
const messagesLoading =
(messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false);
const loadingOlderMessages = messagesState?.loadingOlder ?? false;
const hasMore = messagesState?.hasMore ?? false;
const effectiveMessages = messages;
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const sidebarScrollRef = useRef<HTMLDivElement | null>(null);
@ -323,41 +266,21 @@ export const MessagesPanel = memo(function MessagesPanel({
}, [position, mountPoint]);
const filteredMessages = useMemo(() => {
const startedAt = performance.now();
const result = filterTeamMessages(effectiveMessages, {
return filterTeamMessages(effectiveMessages, {
timeWindow,
filter: messagesFilter,
searchQuery: messagesSearchQuery,
});
const ms = performance.now() - startedAt;
if (ms >= MESSAGES_PANEL_FILTER_WARN_MS) {
logger.warn(
`[perf] filter team=${teamName} stage=messages ms=${ms.toFixed(1)} input=${effectiveMessages.length} output=${result.length} searchLen=${messagesSearchQuery.trim().length} noise=${
messagesFilter.showNoise ? 'on' : 'off'
}`
);
}
return result;
}, [effectiveMessages, messagesFilter, messagesSearchQuery, teamName, timeWindow]);
}, [effectiveMessages, messagesFilter, messagesSearchQuery, timeWindow]);
const activityTimelineMessages = useMemo(() => {
const startedAt = performance.now();
const result = filterTeamMessages(effectiveMessages, {
return filterTeamMessages(effectiveMessages, {
includePassiveIdlePeerSummariesWhenNoiseHidden: true,
timeWindow,
filter: messagesFilter,
searchQuery: messagesSearchQuery,
});
const ms = performance.now() - startedAt;
if (ms >= MESSAGES_PANEL_FILTER_WARN_MS) {
logger.warn(
`[perf] filter team=${teamName} stage=timeline ms=${ms.toFixed(1)} input=${effectiveMessages.length} output=${result.length} searchLen=${messagesSearchQuery.trim().length} noise=${
messagesFilter.showNoise ? 'on' : 'off'
}`
);
}
return result;
}, [effectiveMessages, messagesFilter, messagesSearchQuery, teamName, timeWindow]);
}, [effectiveMessages, messagesFilter, messagesSearchQuery, timeWindow]);
const replyCandidateMessages = useMemo(
() =>
@ -371,33 +294,21 @@ export const MessagesPanel = memo(function MessagesPanel({
// Resolve the expanded item from filtered messages
const expandedItem = useMemo<TimelineItem | null>(() => {
const startedAt = performance.now();
if (!expandedItemKey) return null;
if (!expandedItemKey) {
return null;
}
if (!expandedItemKey.startsWith('thoughts-')) {
const msg = activityTimelineMessages.find((m) => toMessageKey(m) === expandedItemKey);
const result: TimelineItem | null = msg ? { type: 'message', message: msg } : null;
const ms = performance.now() - startedAt;
if (ms >= MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS) {
logger.warn(
`[perf] expandedItem team=${teamName} ms=${ms.toFixed(1)} mode=message timelineMessages=${activityTimelineMessages.length}`
);
}
return result;
return msg ? { type: 'message', message: msg } : null;
}
const allItems = groupTimelineItems(activityTimelineMessages);
const result =
return (
allItems.find(
(item) =>
item.type === 'lead-thoughts' && getThoughtGroupKey(item.group) === expandedItemKey
) ?? null;
const ms = performance.now() - startedAt;
if (ms >= MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS) {
logger.warn(
`[perf] expandedItem team=${teamName} ms=${ms.toFixed(1)} mode=thoughts timelineMessages=${activityTimelineMessages.length} groups=${allItems.length}`
);
}
return result;
}, [expandedItemKey, activityTimelineMessages, teamName]);
) ?? null
);
}, [expandedItemKey, activityTimelineMessages]);
// Auto-clear stale expanded key
useEffect(() => {

View file

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

View file

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

View file

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

View file

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

View file

@ -139,7 +139,7 @@ export interface ExtensionsSlice {
// Slice Creator
// =============================================================================
let pluginFetchInFlight: { key: string; promise: Promise<void> } | null = null;
let pluginFetchInFlight: { key: string; promise: Promise<void>; token: symbol } | null = null;
let pluginCatalogRequestSeq = 0;
const pluginSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
const mcpSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
@ -409,6 +409,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
}
const requestSeq = ++pluginCatalogRequestSeq;
const requestToken = Symbol('pluginCatalogRequest');
set({ pluginCatalogLoading: true, pluginCatalogError: null });
let currentPromise: Promise<void> | null = null;
@ -468,13 +469,13 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
};
});
} finally {
if (currentPromise && pluginFetchInFlight?.promise === currentPromise) {
if (pluginFetchInFlight?.token === requestToken) {
pluginFetchInFlight = null;
}
}
})();
pluginFetchInFlight = { key: requestKey, promise: currentPromise };
pluginFetchInFlight = { key: requestKey, promise: currentPromise, token: requestToken };
await currentPromise;
},

File diff suppressed because it is too large Load diff

View file

@ -148,11 +148,7 @@ export function buildDisplayItems(
// Build display items
for (const step of steps) {
// Skip the last output step
if (
lastOutputStepRef &&
step.id === lastOutputStepRef.id &&
step.type === lastOutputStepRef.type
) {
if (step.id === lastOutputStepRef?.id && step.type === lastOutputStepRef.type) {
continue;
}

View file

@ -1,6 +1,5 @@
import {
getProviderScopedTeamModelLabel,
isSupportedAnthropicTeamModel,
getRuntimeAwareTeamModelUiDisabledReason,
getTeamProviderLabel,
getTeamProviderModelOptions,
@ -12,11 +11,13 @@ import {
GPT_5_2_CODEX_UI_DISABLED_REASON,
GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL,
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
isSupportedAnthropicTeamModel,
normalizeTeamModelForUi as normalizeCatalogTeamModelForUi,
sortTeamProviderModels,
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
type TeamProviderModelOption,
} from './teamModelCatalog';
import { extractProviderScopedBaseModel } from './teamModelContext';
import type {
CliProviderId,
@ -237,6 +238,14 @@ export function isTeamModelAvailableForUi(
return getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available';
}
export function normalizeExplicitTeamModelForUi(
providerId: SupportedProviderId | undefined,
model: string | undefined
): string {
const normalized = extractProviderScopedBaseModel(model, providerId) ?? '';
return normalizeCatalogTeamModelForUi(providerId, normalized).trim();
}
export function normalizeTeamModelForUi(
providerId: SupportedProviderId | undefined,
model: string | undefined,

View file

@ -1,10 +1,10 @@
import { parseModelString } from '@shared/utils/modelParser';
import {
filterVisibleProviderRuntimeModels,
GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL,
GPT_5_2_CODEX_UI_DISABLED_MODEL,
GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL,
} from '@shared/utils/providerModelVisibility';
import { parseModelString } from '@shared/utils/modelParser';
import type { CliProviderId, CliProviderStatus, TeamProviderId } from '@shared/types';

View file

@ -8,7 +8,6 @@ import {
import type {
MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot,
ResolvedTeamMember,
TeamProvisioningProgress,
} from '@shared/types';
@ -17,6 +16,17 @@ type MemberSpawnStatusCollection =
| Map<string, MemberSpawnStatusEntry>
| undefined;
interface ProvisioningMemberLike {
name: string;
removedAt?: number;
agentType?: string;
status?: string;
currentTaskId?: string | null;
taskCount?: number;
lastActiveAt?: string | null;
messageCount?: number;
}
interface FailedSpawnDetail {
name: string;
reason: string | null;
@ -138,7 +148,7 @@ export function buildTeamProvisioningPresentation({
memberSpawnSnapshot,
}: {
progress: TeamProvisioningProgress | null | undefined;
members: readonly ResolvedTeamMember[];
members: readonly ProvisioningMemberLike[];
memberSpawnStatuses?: MemberSpawnStatusCollection;
memberSpawnSnapshot?: Pick<MemberSpawnStatusesSnapshot, 'expectedMembers' | 'summary'>;
}): TeamProvisioningPresentation | null {

View file

@ -56,7 +56,6 @@ import type {
LeadContextUsageSnapshot,
MemberFullStats,
MemberLogSummary,
TeamAgentRuntimeSnapshot,
MemberSpawnStatusesSnapshot,
MessagesPage,
ProjectBranchChangeEvent,
@ -66,6 +65,7 @@ import type {
TaskAttachmentMeta,
TaskChangePresenceState,
TaskComment,
TeamAgentRuntimeSnapshot,
TeamChangeEvent,
TeamClaudeLogsQuery,
TeamClaudeLogsResponse,
@ -73,9 +73,9 @@ import type {
TeamCreateConfigRequest,
TeamCreateRequest,
TeamCreateResponse,
TeamData,
TeamLaunchRequest,
TeamLaunchResponse,
TeamMemberActivityMeta,
TeamMessageNotificationData,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
@ -83,6 +83,7 @@ import type {
TeamTask,
TeamTaskStatus,
TeamUpdateConfigRequest,
TeamViewSnapshot,
ToolApprovalEvent,
ToolApprovalFileContent,
ToolApprovalSettings,
@ -427,7 +428,7 @@ export interface HttpServerAPI {
export interface TeamsAPI {
list: () => Promise<TeamSummary[]>;
getData: (teamName: string) => Promise<TeamData>;
getData: (teamName: string) => Promise<TeamViewSnapshot>;
getTaskChangePresence: (teamName: string) => Promise<Record<string, TaskChangePresenceState>>;
setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise<void>;
setToolActivityTracking: (teamName: string, enabled: boolean) => Promise<void>;
@ -451,8 +452,9 @@ export interface TeamsAPI {
sendMessage: (teamName: string, request: SendMessageRequest) => Promise<SendMessageResult>;
getMessagesPage: (
teamName: string,
options?: { beforeTimestamp?: string; limit?: number }
options?: { cursor?: string | null; limit?: number }
) => Promise<MessagesPage>;
getMemberActivityMeta: (teamName: string) => Promise<TeamMemberActivityMeta>;
createTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
requestReview: (teamName: string, taskId: string) => Promise<void>;
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;

View file

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

View file

@ -159,13 +159,7 @@ export function hasInstallationInScope(
return installations.some((installation) => installation.scope === scope);
}
/**
* Build a concise install-status label for plugin badges.
*/
export function getInstallationSummaryLabel(
installations: Pick<InstalledPluginEntry, 'scope'>[]
): string | null {
const scopes = Array.from(new Set(installations.map((installation) => installation.scope)));
function summarizeInstallationScopes(scopes: InstallScope[]): string | null {
if (scopes.length === 0) {
return null;
}
@ -175,6 +169,7 @@ export function getInstallationSummaryLabel(
}
switch (scopes[0]) {
case 'global':
case 'user':
return 'Installed globally';
case 'project':
@ -186,6 +181,16 @@ export function getInstallationSummaryLabel(
}
}
/**
* Build a concise install-status label for plugin badges.
*/
export function getInstallationSummaryLabel(
installations: Pick<InstalledPluginEntry, 'scope'>[]
): string | null {
const scopes = Array.from(new Set(installations.map((installation) => installation.scope)));
return summarizeInstallationScopes(scopes);
}
const MCP_SCOPE_PRIORITY: Record<InstalledMcpEntry['scope'], number> = {
local: 0,
project: 1,
@ -216,25 +221,7 @@ export function getMcpInstallationSummaryLabel(
installations: Pick<InstalledMcpEntry, 'scope'>[]
): string | null {
const scopes = Array.from(new Set(installations.map((installation) => installation.scope)));
if (scopes.length === 0) {
return null;
}
if (scopes.length > 1) {
return `Installed in ${scopes.length} scopes`;
}
switch (scopes[0]) {
case 'global':
case 'user':
return 'Installed globally';
case 'project':
return 'Installed in project';
case 'local':
return 'Installed locally';
default:
return 'Installed';
}
return summarizeInstallationScopes(scopes);
}
/**

View file

@ -104,10 +104,10 @@ function parseRelativeResetDuration(text: string): number | null {
const match = LEADING_TIME_VALUE_RE.exec(tail);
if (!match) return null;
const amount = Number.parseFloat(match[1]!);
const amount = Number.parseFloat(match[1]);
if (!Number.isFinite(amount) || amount < 0) return null;
const unit = match[2]!.toLowerCase();
const unit = match[2].toLowerCase();
if (['second', 'seconds', 'sec', 'secs', 's'].includes(unit)) {
return Math.round(amount * 1000);
}
@ -157,7 +157,7 @@ function parseAbsoluteResetClockTime(text: string, now: Date): Date | null {
const afterMatch = tail.slice(tzTokenLength);
if (DAY_SHIFT_QUALIFIER_RE.test(afterMatch)) return null;
const hourRaw = Number.parseInt(match[1]!, 10);
const hourRaw = Number.parseInt(match[1], 10);
const minuteRaw = match[2] ? Number.parseInt(match[2], 10) : 0;
const ampm = match[3]?.toLowerCase() ?? null;
const parenthesizedTz = parenthesizedTzMatch?.[1]?.toUpperCase() ?? '';

View file

@ -15,7 +15,7 @@ export interface TeamGraphDefaultLayoutSeed {
assignments: Record<string, GraphOwnerSlotAssignment>;
}
const SMALL_TEAM_CARDINAL_SLOT_PRESETS: ReadonlyArray<ReadonlyArray<GraphOwnerSlotAssignment>> = [
const SMALL_TEAM_CARDINAL_SLOT_PRESETS: readonly (readonly GraphOwnerSlotAssignment[])[] = [
[],
[{ ringIndex: 0, sectorIndex: 0 }],
[
@ -83,7 +83,7 @@ export function buildTeamGraphDefaultLayoutSeed(
const preset = SMALL_TEAM_CARDINAL_SLOT_PRESETS[orderedVisibleOwnerIds.length];
const assignments: Record<string, GraphOwnerSlotAssignment> = {};
if (preset && preset.length === orderedVisibleOwnerIds.length) {
if (preset?.length === orderedVisibleOwnerIds.length) {
orderedVisibleOwnerIds.forEach((stableOwnerId, index) => {
assignments[stableOwnerId] = preset[index]!;
});

View file

@ -44,7 +44,7 @@ describe('normalizeDashboardRecentProjectsPayload', () => {
normalizeDashboardRecentProjectsPayload({
degraded: false,
projects: null,
} as unknown as { degraded: boolean; projects: null })
} as unknown as Parameters<typeof normalizeDashboardRecentProjectsPayload>[0])
).toBeNull();
});
});

View file

@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { buildActiveTeamsByProject } from '@features/recent-projects/renderer/utils/activeProjectTeams';
import type { TeamSummary } from '@shared/types';
function makeTeamSummary(
overrides: Partial<TeamSummary> & Pick<TeamSummary, 'teamName' | 'displayName'>
): TeamSummary {
return {
...overrides,
description: overrides.description ?? '',
memberCount: overrides.memberCount ?? 0,
taskCount: overrides.taskCount ?? 0,
lastActivity: overrides.lastActivity ?? null,
teamName: overrides.teamName,
displayName: overrides.displayName,
};
}
describe('buildActiveTeamsByProject', () => {
it('treats provisioning-active existing teams as active before aliveList catches up', () => {
const lintai = makeTeamSummary({
teamName: 'signal-ops-3',
displayName: 'signal-ops-3',
projectPath: '/Users/test/lintai',
});
const teamsByProject = buildActiveTeamsByProject({
teams: [lintai],
aliveTeamNames: [],
provisioningTeamNames: ['signal-ops-3'],
provisioningSnapshotByTeam: {},
});
expect(teamsByProject.get('/users/test/lintai')).toEqual([lintai]);
});
it('includes synthetic provisioning snapshots for teams not yet present in team summaries', () => {
const provisioningSnapshot = makeTeamSummary({
teamName: 'northstar-team',
displayName: 'Northstar Team',
projectPath: '/Users/test/northstar',
});
const teamsByProject = buildActiveTeamsByProject({
teams: [],
aliveTeamNames: [],
provisioningTeamNames: ['northstar-team'],
provisioningSnapshotByTeam: {
'northstar-team': provisioningSnapshot,
},
});
expect(teamsByProject.get('/users/test/northstar')).toEqual([provisioningSnapshot]);
});
});

View file

@ -8,12 +8,13 @@ import type {
BoardTaskExactLogSummariesResponse,
InboxMessage,
MessagesPage,
TeamViewSnapshot,
TeamCreateRequest,
TeamProvisioningProgress,
} from '@shared/types/team';
vi.mock('electron', () => ({
app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp') },
app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp'), isPackaged: false },
Notification: Object.assign(vi.fn(), { isSupported: vi.fn(() => false) }),
BrowserWindow: { getAllWindows: vi.fn(() => []) },
}));
@ -35,6 +36,8 @@ const { mockTeamDataWorkerClient } = vi.hoisted(() => ({
mockTeamDataWorkerClient: {
isAvailable: vi.fn(),
getTeamData: vi.fn(),
getMessagesPage: vi.fn(),
getMemberActivityMeta: vi.fn(),
findLogsForTask: vi.fn(),
},
}));
@ -63,6 +66,8 @@ import {
TEAM_CREATE_TASK,
TEAM_DELETE_TEAM,
TEAM_GET_DATA,
TEAM_GET_MEMBER_ACTIVITY_META,
TEAM_GET_MESSAGES_PAGE,
TEAM_LAUNCH,
TEAM_LIST,
TEAM_PREPARE_PROVISIONING,
@ -81,7 +86,6 @@ import {
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS,
TEAM_GET_MESSAGES_PAGE,
TEAM_START_TASK,
TEAM_UPDATE_CONFIG,
TEAM_UPDATE_KANBAN,
@ -133,23 +137,38 @@ describe('ipc teams handlers', () => {
const service = {
listTeams: vi.fn(async () => [{ teamName: 'my-team', displayName: 'My Team' }]),
getTeamData: vi.fn(async () => ({
getTeamData: vi.fn(async (): Promise<TeamViewSnapshot & { messages?: InboxMessage[] }> => ({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [] as InboxMessage[],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
})),
getMessageFeed: vi.fn(async () => ({
teamName: 'my-team',
feedRevision: 'rev-1',
messages: [] as InboxMessage[],
})),
getMessagesPage: vi.fn(async (..._args: unknown[]): Promise<MessagesPage> => ({
messages: [] as InboxMessage[],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-1',
})),
getMemberActivityMeta: vi.fn(async () => ({
teamName: 'my-team',
computedAt: '2026-03-12T10:00:00.000Z',
members: {},
feedRevision: 'rev-1',
})),
getTaskChangePresence: vi.fn(async () => ({ 'task-1': 'has_changes' })),
reconcileTeamArtifacts: vi.fn(async () => undefined),
setTaskChangePresenceTracking: vi.fn(() => undefined),
getTeamNotificationContext: vi.fn(async () => ({
displayName: 'My Team',
projectPath: '/tmp/project',
})),
deleteTeam: vi.fn(async () => undefined),
getLeadMemberName: vi.fn(async () => 'team-lead'),
getTeamDisplayName: vi.fn(async () => 'My Team'),
@ -241,6 +260,8 @@ describe('ipc teams handlers', () => {
mockGetMembersMeta.mockResolvedValue([]);
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
mockTeamDataWorkerClient.getTeamData.mockReset();
mockTeamDataWorkerClient.getMessagesPage.mockReset();
mockTeamDataWorkerClient.getMemberActivityMeta.mockReset();
mockTeamDataWorkerClient.findLogsForTask.mockReset();
initializeTeamHandlers(
service as never,
@ -250,6 +271,7 @@ describe('ipc teams handlers', () => {
undefined,
undefined,
undefined,
undefined,
boardTaskActivityService as never,
boardTaskActivityDetailService as never,
boardTaskLogStreamService as never,
@ -266,6 +288,8 @@ describe('ipc teams handlers', () => {
it('registers all expected handlers', () => {
expect(handlers.has(TEAM_LIST)).toBe(true);
expect(handlers.has(TEAM_GET_DATA)).toBe(true);
expect(handlers.has(TEAM_GET_MESSAGES_PAGE)).toBe(true);
expect(handlers.has(TEAM_GET_MEMBER_ACTIVITY_META)).toBe(true);
expect(handlers.has(TEAM_GET_TASK_CHANGE_PRESENCE)).toBe(true);
expect(handlers.has(TEAM_SET_CHANGE_PRESENCE_TRACKING)).toBe(true);
expect(handlers.has(TEAM_DELETE_TEAM)).toBe(true);
@ -594,7 +618,6 @@ describe('ipc teams handlers', () => {
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [] as InboxMessage[],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
@ -773,24 +796,7 @@ describe('ipc teams handlers', () => {
});
});
it('dedups live lead replies when lead_session already has same text', async () => {
service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [
{
from: 'team-lead',
text: 'Hello there',
timestamp: '2026-02-23T10:00:00.000Z',
read: true,
source: 'lead_session' as const,
},
],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
});
it('keeps TEAM_GET_DATA structural and does not expose message transport', async () => {
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
{
from: 'team-lead',
@ -805,12 +811,31 @@ describe('ipc teams handlers', () => {
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
const result = (await getDataHandler({} as never, 'my-team')) as {
success: boolean;
data: { messages: { source?: string }[] };
data: Record<string, unknown>;
};
expect(result.success).toBe(true);
const sources = result.data.messages.map((m) => m.source);
expect(sources.filter((s) => s === 'lead_process')).toHaveLength(0);
expect(sources.filter((s) => s === 'lead_session')).toHaveLength(1);
expect(result.data.teamName).toBe('my-team');
expect(result.data).not.toHaveProperty('messages');
expect(service.getMessageFeed).not.toHaveBeenCalled();
});
it('falls back TEAM_GET_DATA to the main thread in packaged runtime when worker is unavailable', async () => {
const electron = await import('electron');
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
(electron.app as { isPackaged: boolean }).isPackaged = true;
const handler = handlers.get(TEAM_GET_DATA)!;
const result = (await handler({} as never, 'my-team')) as {
success: boolean;
data?: { teamName: string };
};
expect(result.success).toBe(true);
expect(result.data?.teamName).toBe('my-team');
expect(service.getTeamData).toHaveBeenCalledWith('my-team');
vi.mocked(console.error).mockClear();
(electron.app as { isPackaged: boolean }).isPackaged = false;
});
it('does not let a live duplicate of the same session rate-limit reply delay auto-resume', async () => {
@ -867,7 +892,7 @@ describe('ipc teams handlers', () => {
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
const result = (await getDataHandler({} as never, 'my-team')) as {
success: boolean;
data: { messages: Array<{ source?: string; messageId?: string }> };
data: { messages?: InboxMessage[] };
};
expect(result.success).toBe(true);
@ -888,51 +913,141 @@ describe('ipc teams handlers', () => {
}
});
it('merges early live messages before durable lead_session backfill exists', async () => {
// Simulate: team just became readable but lead_session JSONL hasn't been written yet.
// Only live in-memory messages exist from the provisioning process.
service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [], // No durable messages yet
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
it('uses the team-data worker for TEAM_GET_MESSAGES_PAGE when available', async () => {
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({
messages: [
{
from: 'team-lead',
text: 'Hello there',
timestamp: '2026-02-23T10:00:01.000Z',
read: true,
source: 'lead_session' as const,
messageId: 'msg-1',
},
],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-worker',
});
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
{
from: 'team-lead',
text: 'Команда создана. Запускаю тиммейтов.',
timestamp: '2026-02-23T10:00:00.000Z',
read: true,
source: 'lead_process' as const,
messageId: 'lead-turn-run-1-1',
},
{
from: 'team-lead',
text: 'All teammates online!',
timestamp: '2026-02-23T10:00:01.000Z',
read: true,
source: 'lead_process' as const,
messageId: 'lead-turn-run-1-2',
to: 'user',
},
]);
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
const result = (await getDataHandler({} as never, 'my-team')) as {
success: boolean;
data: { messages: { source?: string; text: string }[] };
};
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);
// Both live messages should appear since there's no durable backfill yet
// Sorted by timestamp descending (newest first)
expect(result.data.messages).toHaveLength(2);
expect(result.data.messages[0].source).toBe('lead_process');
expect(result.data.messages[0].text).toBe('All teammates online!');
expect(result.data.messages[1].source).toBe('lead_process');
expect(result.data.messages[1].text).toBe('Команда создана. Запускаю тиммейтов.');
expect(result.data.feedRevision).toBe('rev-worker');
expect(mockTeamDataWorkerClient.getMessagesPage).toHaveBeenCalledWith('my-team', {
cursor: undefined,
limit: 50,
});
expect(service.getMessagesPage).not.toHaveBeenCalled();
});
it('scans rate-limit notifications from message-page results without hydrating TEAM_GET_DATA feed', async () => {
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({
messages: [
{
from: 'team-lead',
text: "You've hit your limit. Please wait a bit before retrying.",
timestamp: '2026-02-23T10:00:01.000Z',
read: true,
source: 'lead_session' as const,
messageId: 'msg-rate-limit-1',
},
],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-worker',
});
const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!;
const result = (await handler({} as never, 'my-team', {
limit: 50,
})) as { success: boolean; data: { feedRevision: string } };
expect(result.success).toBe(true);
expect(result.data.feedRevision).toBe('rev-worker');
expect(mockAddTeamNotification).toHaveBeenCalledWith(
expect.objectContaining({
teamEventType: 'rate_limit',
teamName: 'my-team',
teamDisplayName: 'My Team',
from: 'team-lead',
dedupeKey: 'rate-limit:my-team:msg-rate-limit-1',
})
);
expect(service.getMessageFeed).not.toHaveBeenCalled();
});
it('falls back TEAM_GET_MESSAGES_PAGE to the main thread 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; data?: { feedRevision: string } };
expect(result.success).toBe(true);
expect(result.data?.feedRevision).toBe('rev-1');
expect(service.getMessagesPage).toHaveBeenCalledWith('my-team', {
cursor: undefined,
limit: 50,
});
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('falls back TEAM_GET_MEMBER_ACTIVITY_META to the main thread 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;
data?: { feedRevision: string };
};
expect(result.success).toBe(true);
expect(result.data?.feedRevision).toBe('rev-1');
expect(service.getMemberActivityMeta).toHaveBeenCalledWith('my-team');
vi.mocked(console.error).mockClear();
(electron.app as { isPackaged: boolean }).isPackaged = false;
});
it('rebuilds only the remaining auto-resume delay from persisted rate-limit history', async () => {
@ -1316,6 +1431,7 @@ describe('ipc teams handlers', () => {
],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-1',
});
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
{
@ -1332,7 +1448,7 @@ describe('ipc teams handlers', () => {
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
const result = (await getDataHandler({} as never, 'my-team')) as {
success: boolean;
data: { messages: InboxMessage[] };
data: { messages?: InboxMessage[] };
};
expect(result.success).toBe(true);
@ -1345,7 +1461,7 @@ describe('ipc teams handlers', () => {
}),
]),
});
expect(result.data.messages.map((message) => message.messageId)).toEqual(['durable-0']);
expect(result.data.messages).toHaveLength(50);
});
it('overlays live lead_process messages onto the newest messages page', async () => {
@ -1365,7 +1481,8 @@ describe('ipc teams handlers', () => {
].sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp)),
nextCursor: '2026-02-23T10:00:00.000Z|durable-1',
hasMore: true,
};
feedRevision: 'rev-1',
} satisfies MessagesPage;
});
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
{
@ -1393,7 +1510,7 @@ describe('ipc teams handlers', () => {
expect(result.data.hasMore).toBe(true);
expect(service.getMessagesPage).toHaveBeenCalledWith('my-team', {
limit: 20,
beforeTimestamp: undefined,
cursor: undefined,
liveMessages: expect.arrayContaining([
expect.objectContaining({
source: 'lead_process',
@ -1421,7 +1538,8 @@ describe('ipc teams handlers', () => {
],
nextCursor: null,
hasMore: false,
};
feedRevision: 'rev-1',
} satisfies MessagesPage;
});
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
{
@ -1461,11 +1579,12 @@ describe('ipc teams handlers', () => {
],
nextCursor: null,
hasMore: false,
feedRevision: 'rev-1',
});
const result = (await handlers.get(TEAM_GET_MESSAGES_PAGE)!({} as never, 'my-team', {
limit: 20,
beforeTimestamp: '2026-02-23T10:00:00.000Z|cursor',
cursor: '2026-02-23T10:00:00.000Z|cursor',
})) as {
success: boolean;
data: { messages: InboxMessage[] };

View file

@ -1,22 +1,41 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { ClaudeExtensionsAdapter } from '@main/services/extensions/runtime/ExtensionsRuntimeAdapter';
import { McpConfigStateReader } from '@main/services/extensions/runtime/McpConfigStateReader';
import { McpInstallationStateService } from '@main/services/extensions/state/McpInstallationStateService';
const TEST_ROOT = path.parse(process.cwd()).root || path.sep;
const MOCK_HOME_PATH = path.join(TEST_ROOT, 'tmp', 'mock-home');
const PROJECT_A_PATH = path.join(TEST_ROOT, 'tmp', 'project-a');
const PROJECT_B_PATH = path.join(TEST_ROOT, 'tmp', 'project-b');
function normalizeMockPath(filePath: unknown): string {
return String(filePath).replaceAll('\\', '/');
}
vi.mock('@main/utils/pathDecoder', () => ({
getHomeDir: () => '/tmp/mock-home',
getClaudeBasePath: () => '/tmp/mock-home/.claude',
getHomeDir: () => {
const cwd = process.cwd();
const windowsRoot = cwd.match(/^[A-Za-z]:[\\/]/)?.[0] ?? null;
const root = windowsRoot ?? '/';
const sep = windowsRoot ? '\\' : '/';
return `${root}${root.endsWith(sep) ? '' : sep}tmp${sep}mock-home`;
},
getClaudeBasePath: () => {
const cwd = process.cwd();
const windowsRoot = cwd.match(/^[A-Za-z]:[\\/]/)?.[0] ?? null;
const root = windowsRoot ?? '/';
const sep = windowsRoot ? '\\' : '/';
const mockHome = `${root}${root.endsWith(sep) ? '' : sep}tmp${sep}mock-home`;
return `${mockHome}${sep}.claude`;
},
setClaudeBasePathOverride: vi.fn(),
}));
vi.mock('node:fs/promises');
function toPortablePath(filePath: unknown): string {
return String(filePath).replaceAll('\\', '/');
}
describe('McpInstallationStateService', () => {
let service: McpInstallationStateService;
const mockedFs = vi.mocked(fs);
@ -35,14 +54,14 @@ describe('McpInstallationStateService', () => {
describe('getInstalled', () => {
it('includes local scope from the current project entry in ~/.claude.json', async () => {
mockedFs.readFile.mockImplementation(async (filePath) => {
const normalizedPath = toPortablePath(filePath);
if (normalizedPath === '/tmp/mock-home/.claude.json') {
const normalizedPath = normalizeMockPath(filePath);
if (normalizedPath === normalizeMockPath(path.join(MOCK_HOME_PATH, '.claude.json'))) {
return JSON.stringify({
mcpServers: {
context7: { command: 'npx -y @upstash/context7-mcp' },
},
projects: {
'/tmp/project-a': {
[PROJECT_A_PATH]: {
mcpServers: {
stripe: { url: 'https://mcp.stripe.com' },
},
@ -51,7 +70,7 @@ describe('McpInstallationStateService', () => {
});
}
if (normalizedPath === '/tmp/project-a/.mcp.json') {
if (normalizedPath === normalizeMockPath(path.join(PROJECT_A_PATH, '.mcp.json'))) {
return JSON.stringify({
mcpServers: {
paypal: { url: 'https://mcp.paypal.com/mcp' },
@ -62,7 +81,7 @@ describe('McpInstallationStateService', () => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
const entries = await service.getInstalled('/tmp/project-a');
const entries = await service.getInstalled(PROJECT_A_PATH);
expect(entries).toEqual([
{ name: 'context7', scope: 'user', transport: 'stdio' },
@ -73,8 +92,8 @@ describe('McpInstallationStateService', () => {
it('caches results within TTL for the same project path', async () => {
mockedFs.readFile.mockImplementation(async (filePath) => {
const normalizedPath = toPortablePath(filePath);
if (normalizedPath === '/tmp/mock-home/.claude.json') {
const normalizedPath = normalizeMockPath(filePath);
if (normalizedPath === normalizeMockPath(path.join(MOCK_HOME_PATH, '.claude.json'))) {
return JSON.stringify({
mcpServers: {
context7: { command: 'npx -y @upstash/context7-mcp' },
@ -82,7 +101,7 @@ describe('McpInstallationStateService', () => {
});
}
if (normalizedPath === '/tmp/project-a/.mcp.json') {
if (normalizedPath === normalizeMockPath(path.join(PROJECT_A_PATH, '.mcp.json'))) {
return JSON.stringify({
mcpServers: {
'repo-a-server': { url: 'https://repo-a.example.com/mcp' },
@ -93,27 +112,27 @@ describe('McpInstallationStateService', () => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
await service.getInstalled('/tmp/project-a');
await service.getInstalled('/tmp/project-a');
await service.getInstalled(PROJECT_A_PATH);
await service.getInstalled(PROJECT_A_PATH);
expect(mockedFs.readFile).toHaveBeenCalledTimes(2);
});
it('caches results independently per project path', async () => {
mockedFs.readFile.mockImplementation(async (filePath) => {
const normalizedPath = toPortablePath(filePath);
if (normalizedPath === '/tmp/mock-home/.claude.json') {
const normalizedPath = normalizeMockPath(filePath);
if (normalizedPath === normalizeMockPath(path.join(MOCK_HOME_PATH, '.claude.json'))) {
return JSON.stringify({
mcpServers: {
context7: { command: 'npx -y @upstash/context7-mcp' },
},
projects: {
'/tmp/project-a': {
[PROJECT_A_PATH]: {
mcpServers: {
stripe: { url: 'https://mcp.stripe.com' },
},
},
'/tmp/project-b': {
[PROJECT_B_PATH]: {
mcpServers: {
github: { command: 'uvx github-mcp' },
},
@ -122,7 +141,7 @@ describe('McpInstallationStateService', () => {
});
}
if (normalizedPath === '/tmp/project-a/.mcp.json') {
if (normalizedPath === normalizeMockPath(path.join(PROJECT_A_PATH, '.mcp.json'))) {
return JSON.stringify({
mcpServers: {
'repo-a-server': { url: 'https://repo-a.example.com/mcp' },
@ -130,7 +149,7 @@ describe('McpInstallationStateService', () => {
});
}
if (normalizedPath === '/tmp/project-b/.mcp.json') {
if (normalizedPath === normalizeMockPath(path.join(PROJECT_B_PATH, '.mcp.json'))) {
return JSON.stringify({
mcpServers: {
'repo-b-server': { command: 'uvx repo-b-mcp' },
@ -141,8 +160,8 @@ describe('McpInstallationStateService', () => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
const projectAEntries = await service.getInstalled('/tmp/project-a');
const projectBEntries = await service.getInstalled('/tmp/project-b');
const projectAEntries = await service.getInstalled(PROJECT_A_PATH);
const projectBEntries = await service.getInstalled(PROJECT_B_PATH);
expect(projectAEntries).toEqual([
{ name: 'context7', scope: 'user', transport: 'stdio' },

View file

@ -1,20 +1,30 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { PluginInstallationStateService } from '@main/services/extensions/state/PluginInstallationStateService';
// Mock pathDecoder to control ~/.claude path
vi.mock('@main/utils/pathDecoder', () => ({
getClaudeBasePath: () => '/tmp/mock-claude',
}));
const TEST_ROOT = path.parse(process.cwd()).root || path.sep;
const MOCK_CLAUDE_BASE_PATH = path.join(TEST_ROOT, 'tmp', 'mock-claude');
const PROJECT_A_PATH = path.join(TEST_ROOT, 'tmp', 'project-a');
const PROJECT_B_PATH = path.join(TEST_ROOT, 'tmp', 'project-b');
// Mock filesystem
vi.mock('node:fs/promises');
function toPortablePath(filePath: unknown): string {
function normalizeMockPath(filePath: unknown): string {
return String(filePath).replaceAll('\\', '/');
}
vi.mock('@main/utils/pathDecoder', () => ({
getClaudeBasePath: () => {
const cwd = process.cwd();
const windowsRoot = cwd.match(/^[A-Za-z]:[\\/]/)?.[0] ?? null;
const root = windowsRoot ?? '/';
const sep = windowsRoot ? '\\' : '/';
return `${root}tmp${sep}mock-claude`;
},
}));
vi.mock('node:fs/promises');
describe('PluginInstallationStateService', () => {
let service: PluginInstallationStateService;
const mockedFs = vi.mocked(fs);
@ -31,7 +41,7 @@ describe('PluginInstallationStateService', () => {
describe('getInstalledPlugins', () => {
it('returns user-scoped plugins enabled in user settings', async () => {
mockedFs.readFile.mockImplementation(async (filePath) => {
const normalizedPath = toPortablePath(filePath);
const normalizedPath = normalizeMockPath(filePath);
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
return JSON.stringify({
version: 2,
@ -56,7 +66,7 @@ describe('PluginInstallationStateService', () => {
});
}
if (normalizedPath === '/tmp/mock-claude/settings.json') {
if (normalizedPath === normalizeMockPath(path.join(MOCK_CLAUDE_BASE_PATH, 'settings.json'))) {
return JSON.stringify({
enabledPlugins: {
'context7@claude-plugins-official': true,
@ -79,7 +89,7 @@ describe('PluginInstallationStateService', () => {
it('includes project and local scopes only for the active project', async () => {
mockedFs.readFile.mockImplementation(async (filePath) => {
const normalizedPath = toPortablePath(filePath);
const normalizedPath = normalizeMockPath(filePath);
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
return JSON.stringify({
version: 2,
@ -109,7 +119,7 @@ describe('PluginInstallationStateService', () => {
});
}
if (normalizedPath === '/tmp/mock-claude/settings.json') {
if (normalizedPath === normalizeMockPath(path.join(MOCK_CLAUDE_BASE_PATH, 'settings.json'))) {
return JSON.stringify({
enabledPlugins: {
'context7@claude-plugins-official': true,
@ -117,7 +127,7 @@ describe('PluginInstallationStateService', () => {
});
}
if (normalizedPath === '/tmp/project-a/.claude/settings.json') {
if (normalizedPath === normalizeMockPath(path.join(PROJECT_A_PATH, '.claude', 'settings.json'))) {
return JSON.stringify({
enabledPlugins: {
'typescript-lsp@claude-plugins-official': true,
@ -125,7 +135,10 @@ describe('PluginInstallationStateService', () => {
});
}
if (normalizedPath === '/tmp/project-a/.claude/settings.local.json') {
if (
normalizedPath ===
normalizeMockPath(path.join(PROJECT_A_PATH, '.claude', 'settings.local.json'))
) {
return JSON.stringify({
enabledPlugins: {
'formatter@claude-plugins-official': true,
@ -136,7 +149,7 @@ describe('PluginInstallationStateService', () => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
const entries = await service.getInstalledPlugins('/tmp/project-a');
const entries = await service.getInstalledPlugins(PROJECT_A_PATH);
expect(entries.map((entry) => [entry.pluginId, entry.scope])).toEqual([
['context7@claude-plugins-official', 'user'],
@ -147,7 +160,7 @@ describe('PluginInstallationStateService', () => {
it('does not leak another project scope into the current project', async () => {
mockedFs.readFile.mockImplementation(async (filePath) => {
const normalizedPath = toPortablePath(filePath);
const normalizedPath = normalizeMockPath(filePath);
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
return JSON.stringify({
version: 2,
@ -170,7 +183,7 @@ describe('PluginInstallationStateService', () => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
const entries = await service.getInstalledPlugins('/tmp/project-b');
const entries = await service.getInstalledPlugins(PROJECT_B_PATH);
expect(entries).toEqual([]);
});
@ -186,7 +199,7 @@ describe('PluginInstallationStateService', () => {
it('returns empty array for unexpected version', async () => {
mockedFs.readFile.mockImplementation(async (filePath) => {
const normalizedPath = toPortablePath(filePath);
const normalizedPath = normalizeMockPath(filePath);
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
return JSON.stringify({ version: 1, plugins: {} });
}
@ -202,7 +215,7 @@ describe('PluginInstallationStateService', () => {
it('caches within TTL', async () => {
mockedFs.readFile.mockImplementation(async (filePath) => {
const normalizedPath = toPortablePath(filePath);
const normalizedPath = normalizeMockPath(filePath);
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
return JSON.stringify({ version: 2, plugins: {} });
}
@ -220,7 +233,7 @@ describe('PluginInstallationStateService', () => {
it('caches results independently per project path', async () => {
mockedFs.readFile.mockImplementation(async (filePath) => {
const normalizedPath = toPortablePath(filePath);
const normalizedPath = normalizeMockPath(filePath);
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
return JSON.stringify({ version: 2, plugins: {} });
}
@ -230,8 +243,8 @@ describe('PluginInstallationStateService', () => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
await service.getInstalledPlugins('/tmp/project-a');
await service.getInstalledPlugins('/tmp/project-b');
await service.getInstalledPlugins(PROJECT_A_PATH);
await service.getInstalledPlugins(PROJECT_B_PATH);
expect(mockedFs.readFile).toHaveBeenCalledTimes(8);
});

View file

@ -292,6 +292,9 @@ describe('CliInstallerService', () => {
expect.objectContaining({ modelId: 'gpt-5.4-mini', status: 'checking' }),
])
);
expect(verifiedProvider?.modelAvailability).not.toEqual(
expect.arrayContaining([expect.objectContaining({ modelId: 'gpt-5.2-codex' })])
);
await vi.waitFor(() => {
const latestCodexProvider = service
@ -307,6 +310,12 @@ describe('CliInstallerService', () => {
]);
});
expect(execCli).not.toHaveBeenCalledWith(
'/usr/local/bin/claude',
expect.arrayContaining(['--model', 'gpt-5.2-codex']),
expect.anything()
);
const statusEvents = mockWindow.webContents.send.mock.calls
.filter((call: unknown[]) => call[0] === 'cliInstaller:progress')
.map((call: unknown[]) => call[1] as { type?: string; status?: { providers?: unknown[] } })
@ -323,12 +332,29 @@ describe('CliInstallerService', () => {
'modelAvailability' in provider &&
(provider as { providerId?: string }).providerId === 'codex' &&
Array.isArray((provider as { modelAvailability?: unknown[] }).modelAvailability) &&
(provider as { modelAvailability: Array<{ status?: string }> }).modelAvailability.some(
(item) => item.status === 'unavailable'
)
(provider as { modelAvailability: Array<{ modelId?: string; status?: string }> })
.modelAvailability.some(
(item) => item.modelId === 'gpt-5.4' && item.status === 'available'
)
)
)
).toBe(true);
expect(
statusEvents.some((event) =>
event.status?.providers?.some(
(provider) =>
typeof provider === 'object' &&
provider !== null &&
'providerId' in provider &&
'modelAvailability' in provider &&
(provider as { providerId?: string }).providerId === 'codex' &&
Array.isArray((provider as { modelAvailability?: unknown[] }).modelAvailability) &&
(provider as { modelAvailability: Array<{ modelId?: string }> }).modelAvailability.some(
(item) => item.modelId === 'gpt-5.2-codex'
)
)
)
).toBe(false);
});
});

View file

@ -74,7 +74,7 @@ describe('CliProviderModelAvailabilityService', () => {
expect(execCliMock).toHaveBeenCalledTimes(2);
});
it('marks unsupported models as unavailable with the runtime reason', async () => {
it('marks visible unsupported models as unavailable with the runtime reason', async () => {
buildProviderAwareCliEnvMock.mockResolvedValue({
env: { HOME: '/Users/tester' },
connectionIssues: {},

View file

@ -826,7 +826,6 @@ describe('BoardTaskLogStreamService integration', () => {
expect(bashCommands).not.toContain('echo alien');
expect(rawMessages.some((message) => message.uuid === 'u-bash-alice-real')).toBe(false);
});
it('falls back to createdAt/updatedAt time window when workIntervals are missing', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-created-window-'));
tempDirs.push(dir);

View file

@ -13,8 +13,9 @@ import { TeamDataService } from '../../../../src/main/services/team/TeamDataServ
import type {
InboxMessage,
KanbanState,
ResolvedTeamMember,
TeamConfig,
TeamData,
TeamProcess,
TeamTask,
TeamTaskWithKanban,
} from '../../../../src/shared/types/team';
@ -338,10 +339,9 @@ function createGetTeamDataHarness(options: {
config: TeamConfig,
metaMembers: TeamConfig['members'],
inboxNames: string[],
tasks: TeamTaskWithKanban[],
messages: InboxMessage[]
) => TeamData['members'];
listProcesses?: () => TeamData['processes'];
tasks: TeamTaskWithKanban[]
) => ResolvedTeamMember[];
listProcesses?: () => TeamProcess[];
getMemberAdvisories?: () => Promise<Map<string, unknown>>;
} = {}) {
const getConfig = vi.fn(async () =>
@ -449,7 +449,7 @@ function createGetTeamDataHarness(options: {
};
}
function buildResolvedMember(name: string): TeamData['members'][number] {
function buildResolvedMember(name: string): ResolvedTeamMember {
return {
name,
status: 'unknown',
@ -726,6 +726,39 @@ describe('TeamDataService', () => {
);
});
it('returns lightweight notification context from config without hydrating team data', async () => {
const getConfig = vi.fn(async () => ({
name: 'My Team',
projectPath: '/Users/dev/my-project',
members: [],
}));
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig,
} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
(() => ({ processes: { listProcesses: vi.fn(() => []) } })) as never
);
const result = await service.getTeamNotificationContext('my-team');
expect(result).toEqual({
displayName: 'My Team',
projectPath: '/Users/dev/my-project',
});
expect(getConfig).toHaveBeenCalledWith('my-team');
});
it('creates task with status pending when startImmediately is false', async () => {
const createTaskMock = vi.fn((task) => ({ ...task, status: 'pending' }));
const service = new TeamDataService(
@ -2535,8 +2568,8 @@ describe('TeamDataService', () => {
} as never
);
const data = await service.getTeamData('my-team');
const costResult = data.messages.find((message) => message.messageId === 'lead-thought-1');
const feed = await service.getMessageFeed('my-team');
const costResult = feed.messages.find((message) => message.messageId === 'lead-thought-1');
expect(costResult).toMatchObject({
messageKind: 'slash_command_result',
@ -2605,8 +2638,8 @@ describe('TeamDataService', () => {
} as never
);
const data = await service.getTeamData('my-team');
const result = data.messages.find((message) => message.messageId === 'passive-idle-dup-1');
const feed = await service.getMessageFeed('my-team');
const result = feed.messages.find((message) => message.messageId === 'passive-idle-dup-1');
expect(result).toBeDefined();
expect(result?.source).not.toBe('lead_process');
@ -2680,8 +2713,8 @@ describe('TeamDataService', () => {
sentMessages: [userReplyRow],
});
const data = await service.getTeamData('my-team');
const linked = data.messages.find((message) => message.messageId === 'passive-user-summary-1');
const feed = await service.getMessageFeed('my-team');
const linked = feed.messages.find((message) => message.messageId === 'passive-user-summary-1');
expect(linked?.relayOfMessageId).toBe('user-reply-1');
expect(passiveSummaryRow.relayOfMessageId).toBeUndefined();
@ -2716,8 +2749,8 @@ describe('TeamDataService', () => {
],
});
const data = await service.getTeamData('my-team');
const linked = data.messages.find(
const feed = await service.getMessageFeed('my-team');
const linked = feed.messages.find(
(message) => message.messageId === 'passive-user-summary-contains-1'
);
@ -2753,8 +2786,8 @@ describe('TeamDataService', () => {
],
});
const data = await service.getTeamData('my-team');
const linked = data.messages.find((message) => message.messageId === 'passive-user-summary-old-1');
const feed = await service.getMessageFeed('my-team');
const linked = feed.messages.find((message) => message.messageId === 'passive-user-summary-old-1');
expect(linked?.relayOfMessageId).toBeUndefined();
});
@ -2788,8 +2821,8 @@ describe('TeamDataService', () => {
],
});
const data = await service.getTeamData('my-team');
const linked = data.messages.find((message) => message.messageId === 'passive-bob-summary-1');
const feed = await service.getMessageFeed('my-team');
const linked = feed.messages.find((message) => message.messageId === 'passive-bob-summary-1');
expect(linked?.relayOfMessageId).toBeUndefined();
});
@ -2823,8 +2856,8 @@ describe('TeamDataService', () => {
],
});
const data = await service.getTeamData('my-team');
const linked = data.messages.find(
const feed = await service.getMessageFeed('my-team');
const linked = feed.messages.find(
(message) => message.messageId === 'passive-user-summary-sender-1'
);
@ -2870,8 +2903,8 @@ describe('TeamDataService', () => {
],
});
const data = await service.getTeamData('my-team');
const linked = data.messages.find(
const feed = await service.getMessageFeed('my-team');
const linked = feed.messages.find(
(message) => message.messageId === 'passive-user-summary-ambiguous-1'
);
@ -3362,11 +3395,11 @@ describe('TeamDataService', () => {
const fixture = await createResolverBackedLeadFixture();
const service = createResolverBackedService();
const data = await service.getTeamData(fixture.teamName);
const feed = await service.getMessageFeed(fixture.teamName);
const persistedConfig = JSON.parse(await fs.readFile(fixture.configPath, 'utf8')) as TeamConfig;
expect(
data.messages.find(
feed.messages.find(
(message) =>
message.source === 'lead_session' &&
message.text.includes('recovered through the transcript resolver')
@ -3478,8 +3511,6 @@ describe('TeamDataService', () => {
it('starts light reads immediately, bounds heavy reads, and keeps processes outside the parallel phase', async () => {
const order: string[] = [];
const tasksDeferred = createDeferred<TeamTask[]>();
const messagesDeferred = createDeferred<InboxMessage[]>();
const leadTextsDeferred = createDeferred<InboxMessage[]>();
const harness = createGetTeamDataHarness({
getTasks: async () => {
@ -3490,10 +3521,6 @@ describe('TeamDataService', () => {
order.push('inboxNames:start');
return [];
},
getMessages: async () => {
order.push('messages:start');
return messagesDeferred.promise;
},
getMembers: async () => {
order.push('meta:start');
return [];
@ -3502,10 +3529,6 @@ describe('TeamDataService', () => {
order.push('kanban:start');
return { teamName: 'my-team', reviewers: [], tasks: {} };
},
readMessages: async () => {
order.push('sent:start');
return [];
},
resolveMembers: () => {
order.push('resolveMembers');
return [];
@ -3527,39 +3550,21 @@ describe('TeamDataService', () => {
},
});
vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation(
async () => {
order.push('leadTexts:start');
return leadTextsDeferred.promise;
}
);
const pending = harness.service.getTeamData('my-team');
await flushMicrotasks();
expect(order).toEqual(
expect.arrayContaining([
'inboxNames:start',
'sent:start',
'meta:start',
'kanban:start',
'tasks:start',
'messages:start',
])
);
expect(order).not.toContain('leadTexts:start');
expect(order).not.toContain('processes:start');
expect(order).not.toContain('leadTexts:start');
tasksDeferred.resolve([]);
await flushMicrotasks();
expect(order).toContain('leadTexts:start');
expect(order.indexOf('tasks:start')).toBeLessThan(order.indexOf('messages:start'));
expect(order.indexOf('messages:start')).toBeLessThan(order.indexOf('leadTexts:start'));
expect(order).not.toContain('processes:start');
messagesDeferred.resolve([]);
leadTextsDeferred.resolve([]);
const data = await pending;
@ -3569,7 +3574,7 @@ describe('TeamDataService', () => {
pid: 101,
}),
]);
expect(order.indexOf('leadTexts:start')).toBeLessThan(order.indexOf('processes:start'));
expect(order).not.toContain('leadTexts:start');
expect(order.indexOf('resolveMembers')).toBeLessThan(order.indexOf('processes:start'));
});
@ -3614,47 +3619,64 @@ describe('TeamDataService', () => {
);
});
it('surfaces isAlive in the structural snapshot from live process state', async () => {
const aliveHarness = createGetTeamDataHarness({
listProcesses: () =>
[
{
id: 'proc-1',
label: 'Lead',
pid: 101,
registeredAt: '2026-04-09T10:00:00.000Z',
},
] satisfies TeamProcess[],
});
const offlineHarness = createGetTeamDataHarness({
listProcesses: () =>
[
{
id: 'proc-1',
label: 'Lead',
pid: 101,
registeredAt: '2026-04-09T10:00:00.000Z',
stoppedAt: '2026-04-09T10:05:00.000Z',
},
] satisfies TeamProcess[],
});
const aliveData = await aliveHarness.service.getTeamData('my-team');
const offlineData = await offlineHarness.service.getTeamData('my-team');
expect(aliveData.isAlive).toBe(true);
expect(offlineData.isAlive).toBe(false);
});
it('keeps warning order deterministic even when read failures settle out of order', async () => {
const tasksDeferred = createDeferred<TeamTask[]>();
const inboxDeferred = createDeferred<string[]>();
const messagesDeferred = createDeferred<InboxMessage[]>();
const leadTextsDeferred = createDeferred<InboxMessage[]>();
const sentDeferred = createDeferred<InboxMessage[]>();
const metaDeferred = createDeferred<TeamConfig['members']>();
const kanbanDeferred = createDeferred<KanbanState>();
const harness = createGetTeamDataHarness({
getTasks: async () => tasksDeferred.promise,
listInboxNames: async () => inboxDeferred.promise,
getMessages: async () => messagesDeferred.promise,
getMembers: async () => metaDeferred.promise,
getState: async () => kanbanDeferred.promise,
readMessages: async () => sentDeferred.promise,
});
vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation(
async () => leadTextsDeferred.promise
);
const pending = harness.service.getTeamData('my-team');
await flushMicrotasks();
sentDeferred.reject(new Error('sent failed'));
kanbanDeferred.reject(new Error('kanban failed'));
tasksDeferred.reject(new Error('tasks failed'));
metaDeferred.reject(new Error('meta failed'));
inboxDeferred.reject(new Error('inbox failed'));
leadTextsDeferred.reject(new Error('lead failed'));
messagesDeferred.reject(new Error('messages failed'));
const data = await pending;
expect(data.warnings).toEqual([
'Tasks failed to load',
'Inboxes failed to load',
'Messages failed to load',
'Lead session texts failed to load',
'Sent messages failed to load',
'Member metadata failed to load',
'Kanban state failed to load',
]);
@ -3698,9 +3720,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 () => {
@ -3749,11 +3771,10 @@ describe('TeamDataService', () => {
]);
const data = await harness.service.getTeamData('my-team');
const feed = await harness.service.getMessageFeed('my-team');
expect(data.warnings).toEqual(
expect.arrayContaining(['Messages failed to load', 'Kanban state failed to load'])
);
expect(data.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1']);
expect(data.warnings).toEqual(expect.arrayContaining(['Kanban state failed to load']));
expect(feed.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1']);
expect(resolveMembersSpy).toHaveBeenCalledWith(
buildDefaultTeamConfig(),
metaMembers,
@ -3763,10 +3784,6 @@ describe('TeamDataService', () => {
id: 'task-1',
subject: 'Investigate rollout',
}),
],
[
expect.objectContaining({ messageId: 'sent-1' }),
expect.objectContaining({ messageId: 'lead-1' }),
]
);
});
@ -3805,16 +3822,11 @@ describe('TeamDataService', () => {
it('degrades a queued heavy sync throw to warning and still completes the snapshot', async () => {
const order: string[] = [];
const tasksDeferred = createDeferred<TeamTask[]>();
const messagesDeferred = createDeferred<InboxMessage[]>();
const harness = createGetTeamDataHarness({
getTasks: async () => {
order.push('tasks:start');
return tasksDeferred.promise;
},
getMessages: async () => {
order.push('messages:start');
return messagesDeferred.promise;
},
listProcesses: () => {
order.push('processes:start');
return [];
@ -3832,14 +3844,9 @@ describe('TeamDataService', () => {
expect(order).not.toContain('leadTexts:start');
tasksDeferred.resolve([]);
await flushMicrotasks();
expect(order).toContain('leadTexts:start');
messagesDeferred.resolve([]);
const data = await pending;
expect(data.warnings).toEqual(expect.arrayContaining(['Lead session texts failed to load']));
expect(data.warnings ?? []).not.toContain('Lead session texts failed to load');
expect(order).toContain('processes:start');
});
@ -3977,7 +3984,7 @@ describe('TeamDataService', () => {
expect(page1.hasMore).toBe(true);
const page2 = await service.getMessagesPage('my-team', {
beforeTimestamp: page1.nextCursor!,
cursor: page1.nextCursor!,
limit: 10,
});
// Should get the remaining 2 messages, not lose the one with same timestamp
@ -4011,6 +4018,41 @@ describe('TeamDataService', () => {
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);
});
it('dedups newest-page live overlay against durable lead thoughts that already paged off the first page', async () => {
const fillerMessages = Array.from({ length: 55 }, (_, index) => ({
from: 'alice',
@ -4089,7 +4131,7 @@ describe('TeamDataService', () => {
const page2 = await service.getMessagesPage('my-team', {
limit: 10,
beforeTimestamp: page1.nextCursor!,
cursor: page1.nextCursor!,
});
expect(page2.messages.map((message) => message.messageId)).toEqual(['durable-2', 'durable-1']);

View file

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

View file

@ -175,6 +175,65 @@ function writeLaunchState(
);
}
function createMemberSpawnStatusEntry(
overrides: Record<string, unknown> = {}
): Record<string, unknown> {
return {
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
error: undefined,
updatedAt: new Date().toISOString(),
runtimeAlive: false,
livenessSource: undefined,
bootstrapConfirmed: false,
hardFailure: false,
agentToolAccepted: true,
firstSpawnAcceptedAt: new Date().toISOString(),
lastHeartbeatAt: undefined,
...overrides,
};
}
function createMemberSpawnRun(params?: {
runId?: string;
teamName?: string;
startedAt?: string;
expectedMembers?: string[];
memberSpawnStatuses?: Map<string, Record<string, unknown>>;
memberSpawnLeadInboxCursorByMember?: Map<string, { timestamp: string; messageId: string }>;
}) {
const teamName = params?.teamName ?? 'member-spawn-team';
const expectedMembers = params?.expectedMembers ?? ['alice'];
const memberSpawnStatuses =
params?.memberSpawnStatuses ??
new Map([
[
expectedMembers[0]!,
createMemberSpawnStatusEntry({
firstSpawnAcceptedAt: new Date(Date.now() - 5_000).toISOString(),
}),
],
]);
return {
runId: params?.runId ?? 'run-member-spawn-1',
teamName,
startedAt: params?.startedAt ?? new Date(Date.now() - 60_000).toISOString(),
request: {
members: [],
},
expectedMembers,
memberSpawnStatuses,
memberSpawnToolUseIds: new Map(),
memberSpawnLeadInboxCursorByMember:
params?.memberSpawnLeadInboxCursorByMember ?? new Map(),
provisioningOutputParts: [],
activeToolCalls: new Map(),
isLaunch: false,
provisioningComplete: false,
} as any;
}
describe('TeamProvisioningService', () => {
beforeEach(() => {
vi.clearAllMocks();
@ -1181,4 +1240,311 @@ describe('TeamProvisioningService', () => {
expect(result.statuses.jack?.hardFailureReason).toContain('requested model is not available');
expect(result.teamLaunchState).toBe('partial_failure');
});
it('does not reprocess already-seen teammate lead inbox messages', async () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
startedAt: '2026-04-16T09:00:00.000Z',
memberSpawnLeadInboxCursorByMember: new Map([
[
'alice',
{
timestamp: '2026-04-16T10:00:00.000Z',
messageId: 'msg-2',
},
],
]),
});
vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([
{
from: 'alice',
text: 'heartbeat',
timestamp: '2026-04-16T10:00:00.000Z',
messageId: 'msg-1',
read: false,
},
{
from: 'alice',
text: 'heartbeat',
timestamp: '2026-04-16T10:00:00.000Z',
messageId: 'msg-2',
read: false,
},
]);
const applySignalSpy = vi.spyOn(svc as any, 'applyLeadInboxSpawnSignal');
await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run);
expect(applySignalSpy).not.toHaveBeenCalled();
});
it('processes an unseen teammate heartbeat on the first refresh', async () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
startedAt: '2026-04-16T09:00:00.000Z',
});
vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([
{
from: 'alice',
text: '{"type":"heartbeat","timestamp":"2026-04-16T10:00:00.000Z"}',
timestamp: '2026-04-16T10:00:00.000Z',
messageId: 'msg-1',
read: false,
},
]);
await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run);
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
status: 'online',
launchState: 'confirmed_alive',
bootstrapConfirmed: true,
hardFailure: false,
lastHeartbeatAt: '2026-04-16T10:00:00.000Z',
});
expect(run.memberSpawnLeadInboxCursorByMember.get('alice')).toEqual({
timestamp: '2026-04-16T10:00:00.000Z',
messageId: 'msg-1',
});
});
it('ignores teammate lead inbox signals that predate the current run', async () => {
const svc = new TeamProvisioningService();
const run = createMemberSpawnRun({
startedAt: '2026-04-16T10:00:00.000Z',
});
vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([
{
from: 'alice',
text: '{"type":"heartbeat","timestamp":"2026-04-16T09:59:59.000Z"}',
timestamp: '2026-04-16T09:59:59.000Z',
messageId: 'msg-early',
read: false,
},
]);
const applySignalSpy = vi.spyOn(svc as any, 'applyLeadInboxSpawnSignal');
await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run);
expect(applySignalSpy).not.toHaveBeenCalled();
expect(run.memberSpawnLeadInboxCursorByMember.size).toBe(0);
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
bootstrapConfirmed: false,
});
});
it('ignores an unseen older lead inbox signal without replaying older state', async () => {
const latestHeartbeatAt = '2026-04-16T10:05:00.000Z';
const existingEntry = createMemberSpawnStatusEntry({
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
livenessSource: 'heartbeat',
bootstrapConfirmed: true,
lastHeartbeatAt: latestHeartbeatAt,
});
const run = createMemberSpawnRun({
startedAt: '2026-04-16T09:00:00.000Z',
memberSpawnStatuses: new Map([['alice', existingEntry]]),
memberSpawnLeadInboxCursorByMember: new Map([
[
'alice',
{
timestamp: latestHeartbeatAt,
messageId: 'msg-3',
},
],
]),
});
const svc = new TeamProvisioningService();
vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([
{
from: 'alice',
text: 'Bootstrap failed: unsupported model',
timestamp: '2026-04-16T10:04:00.000Z',
messageId: 'msg-2b',
read: false,
},
{
from: 'alice',
text: 'heartbeat',
timestamp: latestHeartbeatAt,
messageId: 'msg-3',
read: false,
},
]);
const applySignalSpy = vi.spyOn(svc as any, 'applyLeadInboxSpawnSignal');
await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run);
expect(applySignalSpy).not.toHaveBeenCalled();
expect(run.memberSpawnStatuses.get('alice')).toBe(existingEntry);
expect(run.memberSpawnLeadInboxCursorByMember.get('alice')).toEqual({
timestamp: latestHeartbeatAt,
messageId: 'msg-3',
});
});
it('applies an unseen newer failure signal and transitions the member to failed_to_start', async () => {
const latestHeartbeatAt = '2026-04-16T10:00:00.000Z';
const run = createMemberSpawnRun({
startedAt: '2026-04-16T09:00:00.000Z',
memberSpawnStatuses: new Map([
[
'alice',
createMemberSpawnStatusEntry({
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
livenessSource: 'heartbeat',
bootstrapConfirmed: true,
lastHeartbeatAt: latestHeartbeatAt,
}),
],
]),
memberSpawnLeadInboxCursorByMember: new Map([
[
'alice',
{
timestamp: latestHeartbeatAt,
messageId: 'msg-1',
},
],
]),
});
const svc = new TeamProvisioningService();
vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([
{
from: 'alice',
text: 'Bootstrap failed: unsupported model',
timestamp: '2026-04-16T10:01:00.000Z',
messageId: 'msg-2',
read: false,
},
]);
await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run);
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
status: 'error',
launchState: 'failed_to_start',
hardFailure: true,
hardFailureReason: 'Bootstrap failed: unsupported model',
});
expect(run.memberSpawnLeadInboxCursorByMember.get('alice')).toEqual({
timestamp: '2026-04-16T10:01:00.000Z',
messageId: 'msg-2',
});
});
it('applies an unseen same-timestamp signal with a greater messageId and advances the cursor', async () => {
const run = createMemberSpawnRun({
startedAt: '2026-04-16T09:00:00.000Z',
memberSpawnLeadInboxCursorByMember: new Map([
[
'alice',
{
timestamp: '2026-04-16T10:00:00.000Z',
messageId: 'msg-2',
},
],
]),
});
const svc = new TeamProvisioningService();
vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([
{
from: 'alice',
text: 'heartbeat',
timestamp: '2026-04-16T10:00:00.000Z',
messageId: 'msg-2',
read: false,
},
{
from: 'alice',
text: 'heartbeat',
timestamp: '2026-04-16T10:00:00.000Z',
messageId: 'msg-3',
read: false,
},
]);
const applySignalSpy = vi.spyOn(svc as any, 'applyLeadInboxSpawnSignal');
await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run);
expect(applySignalSpy).toHaveBeenCalledTimes(1);
expect(applySignalSpy).toHaveBeenCalledWith(
run,
'alice',
expect.objectContaining({ messageId: 'msg-3' })
);
expect(run.memberSpawnLeadInboxCursorByMember.get('alice')).toEqual({
timestamp: '2026-04-16T10:00:00.000Z',
messageId: 'msg-3',
});
});
it('does not bump lastHeartbeatAt for an equal heartbeat timestamp', () => {
const existingEntry = createMemberSpawnStatusEntry({
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
livenessSource: 'heartbeat',
bootstrapConfirmed: true,
lastHeartbeatAt: '2026-04-16T10:00:00.000Z',
});
const run = createMemberSpawnRun({
memberSpawnStatuses: new Map([['alice', existingEntry]]),
});
const svc = new TeamProvisioningService();
(svc as any).setMemberSpawnStatus(
run,
'alice',
'online',
undefined,
'heartbeat',
'2026-04-16T10:00:00.000Z'
);
expect(run.memberSpawnStatuses.get('alice')).toBe(existingEntry);
});
it('does not bump lastHeartbeatAt for an older heartbeat timestamp', () => {
const existingEntry = createMemberSpawnStatusEntry({
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
livenessSource: 'heartbeat',
bootstrapConfirmed: true,
lastHeartbeatAt: '2026-04-16T10:00:00.000Z',
});
const run = createMemberSpawnRun({
memberSpawnStatuses: new Map([['alice', existingEntry]]),
});
const svc = new TeamProvisioningService();
(svc as any).setMemberSpawnStatus(
run,
'alice',
'online',
undefined,
'heartbeat',
'2026-04-16T09:59:59.000Z'
);
expect(run.memberSpawnStatuses.get('alice')).toBe(existingEntry);
});
});

Some files were not shown because too many files have changed in this diff Show more