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. * 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 type { GraphNode } from '../ports/types';
import { CAMERA, ANIM, NODE, TASK_PILL } from '../constants/canvas-constants'; import { CAMERA, ANIM, NODE, TASK_PILL } from '../constants/canvas-constants';
import type { WorldBounds } from '../layout/launchAnchor'; import type { WorldBounds } from '../layout/launchAnchor';
@ -170,17 +170,31 @@ export function useGraphCamera(): UseGraphCameraResult {
t.zoom = Math.max(CAMERA.minZoom, t.zoom / 1.2); t.zoom = Math.max(CAMERA.minZoom, t.zoom / 1.2);
}, []); }, []);
return { return useMemo(
transformRef, () => ({
screenToWorld, transformRef,
worldToScreen, screenToWorld,
handleWheel, worldToScreen,
handlePanStart, handleWheel,
handlePanMove, handlePanStart,
handlePanEnd, handlePanMove,
zoomToFit, handlePanEnd,
zoomIn, zoomToFit,
zoomOut, zoomIn,
updateInertia, zoomOut,
}; updateInertia,
}),
[
screenToWorld,
worldToScreen,
handleWheel,
handlePanStart,
handlePanMove,
handlePanEnd,
zoomToFit,
zoomIn,
zoomOut,
updateInertia,
]
);
} }

View file

@ -3,7 +3,7 @@
* Delegates hit testing to strategy pattern. * Delegates hit testing to strategy pattern.
*/ */
import { useRef, useCallback } from 'react'; import { useRef, useCallback, useMemo } from 'react';
import type { GraphNode } from '../ports/types'; import type { GraphNode } from '../ports/types';
import { ANIM } from '../constants/canvas-constants'; import { ANIM } from '../constants/canvas-constants';
import { findNodeAt } from '../canvas/hit-detection'; import { findNodeAt } from '../canvas/hit-detection';
@ -81,13 +81,16 @@ export function useGraphInteraction(
return findNodeAt(wx, wy, nodes); return findNodeAt(wx, wy, nodes);
}, []); }, []);
return { return useMemo(
hoveredNodeId, () => ({
dragNodeId, hoveredNodeId,
isDragging, dragNodeId,
handleMouseDown, isDragging,
handleMouseMove, handleMouseDown,
handleMouseUp, handleMouseMove,
handleDoubleClick, 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 { ANIM_SPEED, NODE } from '../constants/canvas-constants';
import { getStateColor } from '../constants/colors'; import { getStateColor } from '../constants/colors';
@ -239,19 +239,29 @@ export function useGraphSimulation(): UseGraphSimulationResult {
}; };
}, []); }, []);
return { return useMemo(
stateRef, () => ({
updateData, stateRef,
tick, updateData,
setNodePosition, tick,
clearNodePosition, setNodePosition,
clearTransientOwnerPositions, clearNodePosition,
resolveNearestOwnerSlot, clearTransientOwnerPositions,
getLaunchAnchorWorldPosition: (leadNodeId: string) => resolveNearestOwnerSlot,
launchAnchorPositionsRef.current.get(leadNodeId) ?? null, getLaunchAnchorWorldPosition: (leadNodeId: string) =>
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null, launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
getExtraWorldBounds: () => extraWorldBoundsRef.current, getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
}; getExtraWorldBounds: () => extraWorldBoundsRef.current,
}),
[
updateData,
tick,
setNodePosition,
clearNodePosition,
clearTransientOwnerPositions,
resolveNearestOwnerSlot,
]
);
} }
function applySnapshotToNodes( function applySnapshotToNodes(

View file

@ -32,7 +32,7 @@ import {
findNodeAt, findNodeAt,
getEdgeMidpoint, getEdgeMidpoint,
} from '../canvas/hit-detection'; } 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'; import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor';
export interface GraphViewProps { export interface GraphViewProps {
@ -148,13 +148,6 @@ export function GraphView({
// ─── Hooks ────────────────────────────────────────────────────────────── // ─── Hooks ──────────────────────────────────────────────────────────────
const simulation = useGraphSimulation(); const simulation = useGraphSimulation();
const camera = useGraphCamera(); 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( const interaction = useGraphInteraction(
useCallback( useCallback(
(nodeId: string, x: number, y: number) => { (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( const getVisibleNodes = useCallback(
(nodes: GraphNode[]): GraphNode[] => (nodes: GraphNode[]): GraphNode[] =>
nodes.filter((node) => { nodes.filter((node) => {
@ -433,16 +440,16 @@ export function GraphView({
}, []); }, []);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!isSurfaceActive) { if (isSurfaceActive) {
return; return;
} }
interaction.handleMouseUp(); interactionRef.current.handleMouseUp();
simulation.clearTransientOwnerPositions(); simulationRef.current.clearTransientOwnerPositions();
dragPreviewRef.current = null; dragPreviewRef.current = null;
isPanningRef.current = false; isPanningRef.current = false;
edgeMouseDownRef.current = null; edgeMouseDownRef.current = null;
setInteractionGuards(false); setInteractionGuards(false);
}, [interaction, isSurfaceActive, simulation]); }, [isSurfaceActive, setInteractionGuards]);
const handleWheel = useCallback( const handleWheel = useCallback(
(e: WheelEvent) => { (e: WheelEvent) => {
@ -454,7 +461,13 @@ export function GraphView({
// ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─ // ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─
const isPanningRef = useRef(false); 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( const handleMouseDown = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
@ -491,7 +504,13 @@ export function GraphView({
if (hitEdge) { if (hitEdge) {
markUserInteracted(); markUserInteracted();
isPanningRef.current = false; 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; hoveredEdgeIdRef.current = hitEdge;
} else { } else {
// Hit empty space → pan // Hit empty space → pan
@ -518,11 +537,6 @@ export function GraphView({
const processActivePointerMove = useCallback( const processActivePointerMove = useCallback(
(clientX: number, clientY: number) => { (clientX: number, clientY: number) => {
if (!activePrimaryInteractionRef.current) {
dragPreviewRef.current = null;
return false;
}
if (isPanningRef.current) { if (isPanningRef.current) {
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
document.getSelection()?.removeAllRanges(); document.getSelection()?.removeAllRanges();
@ -531,6 +545,36 @@ export function GraphView({
return true; 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(); const canvas = canvasHandle.current?.getCanvas();
if (!canvas) { if (!canvas) {
dragPreviewRef.current = null; dragPreviewRef.current = null;
@ -627,8 +671,8 @@ export function GraphView({
if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) { if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top); const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top);
const dx = world.x - edgeMouseDownRef.current.x; const dx = world.x - edgeMouseDownRef.current.worldX;
const dy = world.y - edgeMouseDownRef.current.y; const dy = world.y - edgeMouseDownRef.current.worldY;
if (dx * dx + dy * dy <= 25) { if (dx * dx + dy * dy <= 25) {
clickedEdgeId = edgeMouseDownRef.current.id; clickedEdgeId = edgeMouseDownRef.current.id;
} }
@ -656,6 +700,8 @@ export function GraphView({
}, },
[camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation] [camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation]
); );
processActivePointerMoveRef.current = processActivePointerMove;
completePointerInteractionRef.current = completePointerInteraction;
const handleMouseMove = useCallback( const handleMouseMove = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
@ -711,36 +757,40 @@ export function GraphView({
if ( if (
!activePrimaryInteractionRef.current && !activePrimaryInteractionRef.current &&
!isPanningRef.current && !isPanningRef.current &&
!interaction.dragNodeId.current && !interactionRef.current.dragNodeId.current &&
!interaction.isDragging.current && !interactionRef.current.isDragging.current &&
!edgeMouseDownRef.current !edgeMouseDownRef.current
) { ) {
return; return;
} }
event.preventDefault(); event.preventDefault();
processActivePointerMove(event.clientX, event.clientY); processActivePointerMoveRef.current?.(event.clientX, event.clientY);
}; };
const handleWindowMouseUp = (event: MouseEvent): void => { const handleWindowMouseUp = (event: MouseEvent): void => {
if ( if (
!activePrimaryInteractionRef.current && !activePrimaryInteractionRef.current &&
!isPanningRef.current && !isPanningRef.current &&
!interaction.dragNodeId.current && !interactionRef.current.dragNodeId.current &&
!interaction.isDragging.current && !interactionRef.current.isDragging.current &&
!edgeMouseDownRef.current !edgeMouseDownRef.current
) { ) {
setInteractionGuards(false); setInteractionGuards(false);
return; return;
} }
completePointerInteraction(event.clientX, event.clientY); completePointerInteractionRef.current?.(event.clientX, event.clientY);
}; };
const clearInteraction = (): void => { const clearInteraction = (): void => {
if (!activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.isDragging.current) { if (
!activePrimaryInteractionRef.current &&
!isPanningRef.current &&
!interactionRef.current.isDragging.current
) {
return; return;
} }
interaction.handleMouseUp(); interactionRef.current.handleMouseUp();
camera.handlePanEnd(); cameraRef.current.handlePanEnd();
isPanningRef.current = false; isPanningRef.current = false;
edgeMouseDownRef.current = null; edgeMouseDownRef.current = null;
dragPreviewRef.current = null; dragPreviewRef.current = null;
@ -756,9 +806,14 @@ export function GraphView({
window.removeEventListener('mouseup', handleWindowMouseUp); window.removeEventListener('mouseup', handleWindowMouseUp);
window.removeEventListener('blur', clearInteraction); window.removeEventListener('blur', clearInteraction);
window.removeEventListener('dragstart', clearInteraction); window.removeEventListener('dragstart', clearInteraction);
};
}, [setInteractionGuards]);
useEffect(() => {
return () => {
setInteractionGuards(false); setInteractionGuards(false);
}; };
}, [camera, completePointerInteraction, interaction, processActivePointerMove, setInteractionGuards]); }, [setInteractionGuards]);
const handleDoubleClick = useCallback( const handleDoubleClick = useCallback(
(e: React.MouseEvent) => { (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 * This adapter owns the graph projection from team runtime state into the
* reusable package port model. Renderer hooks may still read store state, but * reusable package port model. Renderer hooks may still read store state, but
@ -57,12 +57,18 @@ import type {
LeadActivityState, LeadActivityState,
MemberSpawnStatusEntry, MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot, MemberSpawnStatusesSnapshot,
TeamData, ResolvedTeamMember,
TeamProcess, TeamProcess,
TeamProvisioningProgress, TeamProvisioningProgress,
TeamViewSnapshot,
} from '@shared/types/team'; } from '@shared/types/team';
import type { LeadContextUsage } from '@shared/types/team'; import type { LeadContextUsage } from '@shared/types/team';
export interface TeamGraphData extends TeamViewSnapshot {
members: ResolvedTeamMember[];
messageFeed: InboxMessage[];
}
export class TeamGraphAdapter { export class TeamGraphAdapter {
// ─── ES #private fields ────────────────────────────────────────────────── // ─── ES #private fields ──────────────────────────────────────────────────
#lastTeamName = ''; #lastTeamName = '';
@ -89,7 +95,7 @@ export class TeamGraphAdapter {
* Adapt team data into a GraphDataPort snapshot. * Adapt team data into a GraphDataPort snapshot.
*/ */
adapt( adapt(
teamData: TeamData | null, teamData: TeamGraphData | null,
teamName: string, teamName: string,
spawnStatuses?: Record<string, MemberSpawnStatusEntry>, spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
leadActivity?: LeadActivityState, leadActivity?: LeadActivityState,
@ -190,7 +196,7 @@ export class TeamGraphAdapter {
this.#buildMessageParticles( this.#buildMessageParticles(
particles, particles,
nodes, nodes,
teamData.messages, teamData.messageFeed,
teamName, teamName,
leadId, leadId,
leadName, leadName,
@ -233,11 +239,11 @@ export class TeamGraphAdapter {
// ─── Private: node builders ────────────────────────────────────────────── // ─── Private: node builders ──────────────────────────────────────────────
static #getLeadMemberName(data: TeamData, teamName: string): string { static #getLeadMemberName(data: TeamGraphData, teamName: string): string {
return getGraphLeadMemberName(data, teamName); return getGraphLeadMemberName(data, teamName);
} }
static #buildMemberNodeIdByAlias(data: TeamData, teamName: string): Map<string, string> { static #buildMemberNodeIdByAlias(data: TeamGraphData, teamName: string): Map<string, string> {
return buildGraphMemberNodeIdAliasMap( return buildGraphMemberNodeIdAliasMap(
teamName, teamName,
data.members.filter((member) => !isLeadMember(member)) data.members.filter((member) => !isLeadMember(member))
@ -245,7 +251,7 @@ export class TeamGraphAdapter {
} }
static #buildLayoutPort( static #buildLayoutPort(
data: TeamData, data: TeamGraphData,
teamName: string, teamName: string,
slotAssignments?: Record<string, GraphOwnerSlotAssignment> slotAssignments?: Record<string, GraphOwnerSlotAssignment>
): GraphLayoutPort { ): GraphLayoutPort {
@ -263,7 +269,7 @@ export class TeamGraphAdapter {
); );
const assignedStableOwnerIds = new Set(Object.keys(slotAssignments ?? {})); 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) { if (!member) {
return; return;
} }
@ -315,7 +321,7 @@ export class TeamGraphAdapter {
} }
static #collectDuplicateStableOwnerIds( static #collectDuplicateStableOwnerIds(
members: readonly TeamData['members'][number][] members: readonly TeamGraphData['members'][number][]
): string[] { ): string[] {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
for (const member of members) { for (const member of members) {
@ -337,9 +343,9 @@ export class TeamGraphAdapter {
} }
static #getRuntimeLabel( static #getRuntimeLabel(
providerId: TeamData['members'][number]['providerId'], providerId: ResolvedTeamMember['providerId'],
model: TeamData['members'][number]['model'], model: ResolvedTeamMember['model'],
effort: TeamData['members'][number]['effort'] effort: ResolvedTeamMember['effort']
): string | undefined { ): string | undefined {
return formatTeamRuntimeSummary(providerId, model, effort); return formatTeamRuntimeSummary(providerId, model, effort);
} }
@ -360,7 +366,7 @@ export class TeamGraphAdapter {
#buildLeadNode( #buildLeadNode(
nodes: GraphNode[], nodes: GraphNode[],
leadId: string, leadId: string,
data: TeamData, data: TeamGraphData,
teamName: string, teamName: string,
leadName: string, leadName: string,
pendingApprovalAgents?: Set<string>, pendingApprovalAgents?: Set<string>,
@ -456,7 +462,7 @@ export class TeamGraphAdapter {
nodes: GraphNode[], nodes: GraphNode[],
edges: GraphEdge[], edges: GraphEdge[],
leadId: string, leadId: string,
data: TeamData, data: TeamGraphData,
teamName: string, teamName: string,
memberNodeIdByAlias: ReadonlyMap<string, string>, memberNodeIdByAlias: ReadonlyMap<string, string>,
spawnStatuses?: Record<string, MemberSpawnStatusEntry>, spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
@ -560,14 +566,14 @@ export class TeamGraphAdapter {
#buildTaskNodes( #buildTaskNodes(
nodes: GraphNode[], nodes: GraphNode[],
edges: GraphEdge[], edges: GraphEdge[],
data: TeamData, data: TeamGraphData,
teamName: string, teamName: string,
commentReadState?: Record<string, unknown>, commentReadState?: Record<string, unknown>,
memberNodeIdByAlias?: ReadonlyMap<string, string>, memberNodeIdByAlias?: ReadonlyMap<string, string>,
leadId?: string, leadId?: string,
leadName?: string leadName?: string
): void { ): void {
const taskStateById = new Map<string, Pick<TeamData['tasks'][number], 'status'>>(); const taskStateById = new Map<string, Pick<TeamGraphData['tasks'][number], 'status'>>();
const taskDisplayIds = new Map<string, string>(); const taskDisplayIds = new Map<string, string>();
const memberColorByName = new Map<string, string>(); const memberColorByName = new Map<string, string>();
@ -752,7 +758,7 @@ export class TeamGraphAdapter {
#buildProcessNodes( #buildProcessNodes(
nodes: GraphNode[], nodes: GraphNode[],
edges: GraphEdge[], edges: GraphEdge[],
data: TeamData, data: TeamGraphData,
teamName: string, teamName: string,
memberNodeIdByAlias?: ReadonlyMap<string, string> memberNodeIdByAlias?: ReadonlyMap<string, string>
): void { ): void {
@ -830,7 +836,7 @@ export class TeamGraphAdapter {
#attachActivityFeeds( #attachActivityFeeds(
nodes: GraphNode[], nodes: GraphNode[],
data: TeamData, data: TeamGraphData,
teamName: string, teamName: string,
leadId: string, leadId: string,
leadName: string leadName: string
@ -847,7 +853,10 @@ export class TeamGraphAdapter {
} }
const entriesByOwnerNodeId = buildInlineActivityEntries({ const entriesByOwnerNodeId = buildInlineActivityEntries({
data, data: {
...data,
messages: data.messageFeed,
},
teamName, teamName,
leadId, leadId,
leadName, leadName,
@ -1008,7 +1017,7 @@ export class TeamGraphAdapter {
#buildCommentParticles( #buildCommentParticles(
particles: GraphParticle[], particles: GraphParticle[],
data: TeamData, data: TeamGraphData,
teamName: string, teamName: string,
leadId: string, leadId: string,
leadName: string, leadName: string,
@ -1101,8 +1110,8 @@ export class TeamGraphAdapter {
} }
static #buildMemberException( static #buildMemberException(
runtimeAdvisory: TeamData['members'][number]['runtimeAdvisory'], runtimeAdvisory: ResolvedTeamMember['runtimeAdvisory'],
providerId: TeamData['members'][number]['providerId'], providerId: ResolvedTeamMember['providerId'],
spawn: MemberSpawnStatusEntry | undefined, spawn: MemberSpawnStatusEntry | undefined,
pendingApproval: boolean pendingApproval: boolean
): Pick<GraphNode, 'exceptionTone' | 'exceptionLabel'> | undefined { ): Pick<GraphNode, 'exceptionTone' | 'exceptionLabel'> | undefined {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -79,7 +79,6 @@ export const TeamGraphTab = ({
const openCreateTask = useCallback(() => { const openCreateTask = useCallback(() => {
openCreateTaskDialog(''); openCreateTaskDialog('');
}, [openCreateTaskDialog]); }, [openCreateTaskDialog]);
// Task action dispatchers // Task action dispatchers
const dispatchTaskAction = useCallback( const dispatchTaskAction = useCallback(
(action: string) => (taskId: string) => (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 { type DashboardRecentProject } from '@features/recent-projects/contracts';
import { api, isElectronMode } from '@renderer/api'; import { api, isElectronMode } from '@renderer/api';
import { useStore } from '@renderer/store'; 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 { useShallow } from 'zustand/react/shallow';
import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter'; import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter';
import { buildActiveTeamsByProject } from '../utils/activeProjectTeams';
import { import {
sortRecentProjectsByDisplayPriority, sortRecentProjectsByDisplayPriority,
subscribeRecentProjectOpenHistory, subscribeRecentProjectOpenHistory,
@ -62,16 +64,27 @@ export function useRecentProjectsSection(
openProjectPath: (projectPath: string) => Promise<void>; openProjectPath: (projectPath: string) => Promise<void>;
selectProjectFolder: () => Promise<void>; selectProjectFolder: () => Promise<void>;
} { } {
const { globalTasks, globalTasksInitialized, globalTasksLoading, fetchAllTasks, teams } = const {
useStore( globalTasks,
useShallow((state) => ({ globalTasksInitialized,
globalTasks: state.globalTasks, globalTasksLoading,
globalTasksInitialized: state.globalTasksInitialized, fetchAllTasks,
globalTasksLoading: state.globalTasksLoading, teams,
fetchAllTasks: state.fetchAllTasks, provisioningRuns,
teams: state.teams, 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 initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []);
const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject(); const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject();
const [recentProjects, setRecentProjects] = useState<DashboardRecentProject[]>( const [recentProjects, setRecentProjects] = useState<DashboardRecentProject[]>(
@ -92,6 +105,21 @@ export function useRecentProjectsSection(
const recentProjectsRef = useRef<DashboardRecentProject[]>( const recentProjectsRef = useRef<DashboardRecentProject[]>(
initialSnapshot?.payload.projects ?? [] 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(() => { useEffect(() => {
recentProjectsRef.current = recentProjects; recentProjectsRef.current = recentProjects;
@ -173,7 +201,7 @@ export function useRecentProjectsSection(
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [teams]); }, [provisioningTeamNamesKey, teams]);
useEffect(() => { useEffect(() => {
if (!searchQuery.trim()) { if (!searchQuery.trim()) {
@ -189,25 +217,13 @@ export function useRecentProjectsSection(
const taskCountsByProject = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]); const taskCountsByProject = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]);
const activeTeamsByProject = useMemo(() => { const activeTeamsByProject = useMemo(() => {
const aliveSet = new Set(aliveTeams); return buildActiveTeamsByProject({
const teamsByProject = new Map<string, TeamSummary[]>(); teams,
aliveTeamNames: aliveTeams,
for (const team of teams) { provisioningTeamNames,
if (!team.projectPath || !aliveSet.has(team.teamName)) { provisioningSnapshotByTeam,
continue; });
} }, [aliveTeams, provisioningSnapshotByTeam, provisioningTeamNames, teams]);
const key = normalizePath(team.projectPath);
const existing = teamsByProject.get(key);
if (existing) {
existing.push(team);
} else {
teamsByProject.set(key, [team]);
}
}
return teamsByProject;
}, [aliveTeams, teams]);
const decoratedCards = useMemo( 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)); const foldedMatch = lookup.folded.get(foldHistoryPath(normalizedPath));
if (!foldedMatch || foldedMatch.exactPaths.size !== 1) { if (foldedMatch?.exactPaths.size !== 1) {
return 0; return 0;
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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 { atomicWriteAsync } from './atomicWrite';
import { peekAutoResumeService } from './AutoResumeService'; import { peekAutoResumeService } from './AutoResumeService';
import { ClaudeBinaryResolver } from './ClaudeBinaryResolver'; import { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
import { getConfiguredCliCommandLabel } from './cliFlavor';
import { withFileLock } from './fileLock'; import { withFileLock } from './fileLock';
import { import {
type ClassifiedMainProcessIdle, type ClassifiedMainProcessIdle,
@ -721,6 +722,8 @@ interface ProvisioningRun {
>; >;
/** Agent tool_use_id -> teammate name for persistent teammate spawns. */ /** Agent tool_use_id -> teammate name for persistent teammate spawns. */
memberSpawnToolUseIds: Map<string, string>; 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. */ /** Highest accepted deterministic bootstrap event sequence for this run. */
lastDeterministicBootstrapSeq: number; lastDeterministicBootstrapSeq: number;
/** Throttles config/inbox audit work triggered by frequent status polling. */ /** 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 { function stripWrappedCliFlagValue(raw: string | undefined): string | undefined {
const trimmed = raw?.trim(); const trimmed = raw?.trim();
if (!trimmed) { if (!trimmed) {
@ -1339,6 +1411,8 @@ ${buildCanonicalSendMessageExample({ to: leadName, summary: 'short update', mess
After member_briefing succeeds: After member_briefing succeeds:
- Do NOT send a "ready", "online", "status accepted", or other acknowledgement-only message just to confirm you started successfully. - 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 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. - 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. - 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. - 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: After member_briefing succeeds:
- Do NOT send a "ready", "online", "status accepted", or other acknowledgement-only message just to confirm you reconnected successfully. - 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 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. - 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. - 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. - 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 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 const teammateMessages = leadInboxMessages
.filter((message) => { .filter((message): message is LeadInboxMemberSpawnMessage => {
const from = typeof message.from === 'string' ? message.from.trim() : ''; const from = typeof message.from === 'string' ? message.from.trim() : '';
if (!from || from === leadName || from === 'user' || from === 'system') return false; 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); const messageTs = Date.parse(message.timestamp);
if ( if (
Number.isFinite(messageTs) && Number.isFinite(messageTs) &&
@ -3110,24 +3189,67 @@ export class TeamProvisioningService {
} }
return typeof message.text === 'string' && message.text.trim().length > 0; return typeof message.text === 'string' && message.text.trim().length > 0;
}) })
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); .sort((left, right) =>
compareMemberSpawnInboxCursor(
for (const message of teammateMessages) { { timestamp: left.timestamp, messageId: left.messageId },
const from = message.from.trim(); { timestamp: right.timestamp, messageId: right.messageId }
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)
); );
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 { private persistSentMessage(teamName: string, message: InboxMessage): void {
@ -3725,8 +3847,14 @@ export class TeamProvisioningService {
next.livenessSource = livenessSource; next.livenessSource = livenessSource;
next.firstSpawnAcceptedAt = prev.firstSpawnAcceptedAt ?? updatedAt; next.firstSpawnAcceptedAt = prev.firstSpawnAcceptedAt ?? updatedAt;
if (livenessSource === 'heartbeat') { if (livenessSource === 'heartbeat') {
const incomingHeartbeatAt = heartbeatAt?.trim() || updatedAt;
next.bootstrapConfirmed = true; next.bootstrapConfirmed = true;
next.lastHeartbeatAt = heartbeatAt?.trim() || prev.lastHeartbeatAt || updatedAt; next.lastHeartbeatAt = isMemberSpawnHeartbeatTimestampNewer(
prev.lastHeartbeatAt,
incomingHeartbeatAt
)
? incomingHeartbeatAt
: prev.lastHeartbeatAt;
} }
next.hardFailure = false; next.hardFailure = false;
next.error = undefined; next.error = undefined;
@ -5612,6 +5740,7 @@ export class TeamProvisioningService {
request.members.map((m) => [m.name, createInitialMemberSpawnStatusEntry()]) request.members.map((m) => [m.name, createInitialMemberSpawnStatusEntry()])
), ),
memberSpawnToolUseIds: new Map(), memberSpawnToolUseIds: new Map(),
memberSpawnLeadInboxCursorByMember: new Map(),
lastDeterministicBootstrapSeq: 0, lastDeterministicBootstrapSeq: 0,
lastMemberSpawnAuditAt: 0, lastMemberSpawnAuditAt: 0,
lastMemberSpawnAuditConfigReadWarningAt: 0, lastMemberSpawnAuditConfigReadWarningAt: 0,
@ -6192,6 +6321,7 @@ export class TeamProvisioningService {
expectedMembers.map((name) => [name, createInitialMemberSpawnStatusEntry()]) expectedMembers.map((name) => [name, createInitialMemberSpawnStatusEntry()])
), ),
memberSpawnToolUseIds: new Map(), memberSpawnToolUseIds: new Map(),
memberSpawnLeadInboxCursorByMember: new Map(),
lastDeterministicBootstrapSeq: 0, lastDeterministicBootstrapSeq: 0,
lastMemberSpawnAuditAt: 0, lastMemberSpawnAuditAt: 0,
lastMemberSpawnAuditConfigReadWarningAt: 0, lastMemberSpawnAuditConfigReadWarningAt: 0,
@ -12968,6 +13098,7 @@ export class TeamProvisioningService {
providerId: TeamProviderId | undefined = 'anthropic' providerId: TeamProviderId | undefined = 'anthropic'
): Promise<{ warning?: string }> { ): Promise<{ warning?: string }> {
const resolvedProviderId = resolveTeamProviderId(providerId); const resolvedProviderId = resolveTeamProviderId(providerId);
const cliCommandLabel = getConfiguredCliCommandLabel();
try { try {
const versionProbe = await this.spawnProbe( const versionProbe = await this.spawnProbe(
claudePath, claudePath,
@ -12979,9 +13110,9 @@ export class TeamProvisioningService {
if (versionProbe.exitCode !== 0) { if (versionProbe.exitCode !== 0) {
const errorText = const errorText =
buildCombinedLogs(versionProbe.stdout, versionProbe.stderr) || 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 { return {
warning: `Claude CLI binary failed to start correctly. Details: ${errorText}`, warning: `${cliCommandLabel} binary failed to start correctly. Details: ${errorText}`,
}; };
} }
} catch (error) { } catch (error) {
@ -12992,7 +13123,7 @@ export class TeamProvisioningService {
}; };
} }
return { 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 { return {
warning: warning:
'Preflight check for `claude -p` did not complete. ' + `Preflight check for \`${cliCommandLabel} -p\` did not complete. ` +
`Proceeding anyway. Details: ${message}`, `Proceeding anyway. Details: ${message}`,
}; };
} }
@ -13075,13 +13206,15 @@ export class TeamProvisioningService {
const hint = isAuthFailure const hint = isAuthFailure
? resolvedProviderId === 'codex' ? resolvedProviderId === 'codex'
? 'Codex provider is not authenticated for `-p` mode. ' + ? '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)` : '') (attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
: 'Claude CLI `-p` mode is not authenticated. ' + : `${cliCommandLabel} \`-p\` mode is not authenticated. ` +
'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' + (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.' + 'For automation/headless use, set ANTHROPIC_API_KEY.' +
(attempt > 1 ? ` (failed after ${attempt} attempts)` : '') (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 }; return { warning: hint };
} }
@ -13122,7 +13255,7 @@ export class TeamProvisioningService {
const targetCwd = cwd ?? process.cwd(); const targetCwd = cwd ?? process.cwd();
const probeResult = await this.getCachedOrProbeResult(targetCwd, 'anthropic'); const probeResult = await this.getCachedOrProbeResult(targetCwd, 'anthropic');
if (!probeResult?.claudePath) { if (!probeResult?.claudePath) {
throw new Error('Claude CLI not found'); throw new Error(`${getConfiguredCliCommandLabel()} not found`);
} }
const { env } = await this.buildProvisioningEnv(); const { env } = await this.buildProvisioningEnv();
const result = await this.spawnProbe( const result = await this.spawnProbe(
@ -13135,7 +13268,7 @@ export class TeamProvisioningService {
const output = (result.stdout + '\n' + result.stderr).trim(); const output = (result.stdout + '\n' + result.stderr).trim();
if (!output) { if (!output) {
throw new Error( 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; this.helpOutputCache = output;
@ -13493,7 +13626,7 @@ export class TeamProvisioningService {
const timeoutHandle = setTimeout(() => { const timeoutHandle = setTimeout(() => {
settled = true; settled = true;
killProcessTree(child); killProcessTree(child);
reject(new Error(`Timeout running: claude ${args.join(' ')}`)); reject(new Error(`Timeout running: ${getConfiguredCliCommandLabel()} ${args.join(' ')}`));
}, timeoutMs); }, timeoutMs);
const maybeResolveEarly = (): void => { const maybeResolveEarly = (): void => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -71,10 +71,21 @@ interface MarkdownViewerProps {
onTeamClick?: (teamName: string) => void; 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_TEAMS: { teamName?: string; displayName?: string; color?: string }[] = [];
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>(); const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
const NOOP_TEAM_CLICK = (): void => undefined; const NOOP_TEAM_CLICK = (): void => undefined;
type ViewerMarkdownMode = 'default' | 'compact-preview';
// ============================================================================= // =============================================================================
// Helpers // Helpers
// ============================================================================= // =============================================================================
@ -322,53 +333,89 @@ function createViewerMarkdownComponents(
isLight = false, isLight = false,
teamColorByName: ReadonlyMap<string, string> = new Map(), teamColorByName: ReadonlyMap<string, string> = new Map(),
onTeamClick?: (teamName: string) => void, onTeamClick?: (teamName: string) => void,
copyCodeBlocks: boolean = false copyCodeBlocks: boolean = false,
mode: ViewerMarkdownMode = 'default'
): Components { ): Components {
const hl = (children: React.ReactNode): React.ReactNode => const hl = (children: React.ReactNode): React.ReactNode =>
searchCtx ? highlightSearchInChildren(children, searchCtx) : children; 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 { return {
// Headings // Headings
h1: ({ children }) => ( h1: ({ children }) =>
<h1 className="mb-2 mt-4 text-xl font-semibold first:mt-0" style={{ color: PROSE_HEADING }}> isCompactPreview ? (
{hl(children)} renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
</h1> ) : (
), <h1 className="mb-2 mt-4 text-xl font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
h2: ({ children }) => ( {hl(children)}
<h2 className="mb-2 mt-4 text-lg font-semibold first:mt-0" style={{ color: PROSE_HEADING }}> </h1>
{hl(children)} ),
</h2> h2: ({ children }) =>
), isCompactPreview ? (
h3: ({ children }) => ( 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)} <h2 className="mb-2 mt-4 text-lg font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
</h3> {hl(children)}
), </h2>
h4: ({ children }) => ( ),
<h4 className="mb-1 mt-3 text-sm font-semibold first:mt-0" style={{ color: PROSE_HEADING }}> h3: ({ children }) =>
{hl(children)} isCompactPreview ? (
</h4> renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
), ) : (
h5: ({ children }) => ( <h3
<h5 className="mb-1 mt-2 text-sm font-medium first:mt-0" style={{ color: PROSE_HEADING }}> className="mb-2 mt-3 text-base font-semibold first:mt-0"
{hl(children)} style={{ color: PROSE_HEADING }}
</h5> >
), {hl(children)}
h6: ({ children }) => ( </h3>
<h6 className="mb-1 mt-2 text-xs font-medium first:mt-0" style={{ color: PROSE_HEADING }}> ),
{hl(children)} h4: ({ children }) =>
</h6> 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 // Paragraphs
p: ({ children }) => ( p: ({ children }) =>
<p isCompactPreview ? (
className="my-2 text-sm leading-relaxed first:mt-0 last:mb-0" renderCompactInline(children, '', { color: PROSE_BODY })
style={{ color: PROSE_BODY }} ) : (
> <p
{hl(children)} className="my-2 text-sm leading-relaxed first:mt-0 last:mb-0"
</p> style={{ color: PROSE_BODY }}
), >
{hl(children)}
</p>
),
// Links — inline element, no hl(); parent block element's hl() descends here // Links — inline element, no hl(); parent block element's hl() descends here
// task:// links render with TaskTooltip + are clickable via ancestor onClickCapture // 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 // Code blocks — intercept mermaid diagrams at the pre level
pre: ({ children, node }) => { 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 // Check if this pre contains a mermaid code block
const codeEl = node?.children?.[0]; const codeEl = node?.children?.[0];
if (codeEl && 'tagName' in codeEl && codeEl.tagName === 'code' && 'properties' in codeEl) { if (codeEl && 'tagName' in codeEl && codeEl.tagName === 'code' && 'properties' in codeEl) {
@ -596,74 +657,107 @@ function createViewerMarkdownComponents(
}, },
// Blockquotes // Blockquotes
blockquote: ({ children }) => ( blockquote: ({ children }) =>
<blockquote isCompactPreview ? (
className="my-3 border-l-4 pl-4 italic" renderCompactInline(children, 'italic', { color: PROSE_MUTED })
style={{ ) : (
borderColor: PROSE_BLOCKQUOTE_BORDER, <blockquote
color: PROSE_MUTED, className="my-3 border-l-4 pl-4 italic"
}} style={{
> borderColor: PROSE_BLOCKQUOTE_BORDER,
{hl(children)} color: PROSE_MUTED,
</blockquote> }}
), >
{hl(children)}
</blockquote>
),
// Lists // Lists
ul: ({ children }) => ( ul: ({ children }) =>
<ul className="my-2 list-disc space-y-1 pl-5" style={{ color: PROSE_BODY }}> isCompactPreview ? (
{children} <span>{children}</span>
</ul> ) : (
), <ul className="my-2 list-disc space-y-1 pl-5" style={{ color: PROSE_BODY }}>
ol: ({ children }) => ( {children}
<ol className="my-2 list-decimal space-y-1 pl-5" style={{ color: PROSE_BODY }}> </ul>
{children} ),
</ol> ol: ({ children }) =>
), isCompactPreview ? (
li: ({ children }) => ( <span>{children}</span>
<li className="text-sm" style={{ color: PROSE_BODY }}> ) : (
{hl(children)} <ol className="my-2 list-decimal space-y-1 pl-5" style={{ color: PROSE_BODY }}>
</li> {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 // Tables
table: ({ children }) => ( table: ({ children }) =>
<div className="my-3 overflow-x-auto"> isCompactPreview ? (
<table <span>{children}</span>
className="min-w-full border-collapse text-sm" ) : (
style={{ borderColor: PROSE_TABLE_BORDER }} <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} {hl(children)}
</table> </th>
</div> ),
), td: ({ children }) =>
thead: ({ children }) => ( isCompactPreview ? (
<thead style={{ backgroundColor: PROSE_TABLE_HEADER_BG }}>{children}</thead> renderCompactInline(children, '', { color: PROSE_BODY })
), ) : (
th: ({ children }) => ( <td
<th className="px-3 py-2"
className="px-3 py-2 text-left font-semibold" style={{
style={{ border: `1px solid ${PROSE_TABLE_BORDER}`,
border: `1px solid ${PROSE_TABLE_BORDER}`, color: PROSE_BODY,
color: PROSE_HEADING, }}
}} >
> {hl(children)}
{hl(children)} </td>
</th> ),
),
td: ({ children }) => (
<td
className="px-3 py-2"
style={{
border: `1px solid ${PROSE_TABLE_BORDER}`,
color: PROSE_BODY,
}}
>
{hl(children)}
</td>
),
// Horizontal rule // 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 // 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> = ({ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
content, content,
maxHeight = 'max-h-96', maxHeight = 'max-h-96',
@ -695,24 +861,10 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
const [showRaw, setShowRaw] = React.useState(false); const [showRaw, setShowRaw] = React.useState(false);
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
const { isLight } = useTheme(); const { isLight } = useTheme();
const teams = useStore(useShallow((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams))); const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext(
const openTeamTab = useStore((s) => (providedOnTeamClick ? NOOP_TEAM_CLICK : s.openTeamTab)); providedTeamColorByName,
providedOnTeamClick
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 isTooLarge = content.length > MAX_MARKDOWN_CHARS; const isTooLarge = content.length > MAX_MARKDOWN_CHARS;
const disableHighlight = content.length > DISABLE_HIGHLIGHT_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 { resolveSkillProjectPath } from './skillProjectUtils';
import type { SkillValidationIssue } from '@shared/types'; import type { SkillValidationIssue } from '@shared/types/extensions';
interface SkillDetailDialogProps { interface SkillDetailDialogProps {
skillId: string | null; skillId: string | null;

View file

@ -144,11 +144,13 @@ export const SidebarTaskItem = ({
); );
const showTeamRow = showTeamName && !hideTeamName; const showTeamRow = showTeamName && !hideTeamName;
const unreadBackgroundClass =
unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : '';
return ( return (
<button <button
type="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)' }} style={{ borderColor: 'var(--color-border)' }}
onClick={() => { onClick={() => {
if (!isRenaming) { if (!isRenaming) {

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1,20 @@
import { Fragment, memo, useCallback, useMemo } from 'react'; 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 { CopyButton } from '@renderer/components/common/CopyButton';
import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay'; import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay';
import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { TaskTooltip } from '@renderer/components/team/TaskTooltip'; import { TaskTooltip } from '@renderer/components/team/TaskTooltip';
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; 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 { import {
CARD_BG, CARD_BG,
CARD_BG_ZEBRA, CARD_BG_ZEBRA,
@ -780,7 +788,7 @@ export const ActivityItem = memo(
if (!isCrossTeamAny || !strippedText) return ''; if (!isCrossTeamAny || !strippedText) return '';
const oneLine = strippedText.replace(/\n+/g, ' ').trim(); const oneLine = strippedText.replace(/\n+/g, ' ').trim();
if (!oneLine) return ''; if (!oneLine) return '';
return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine; return oneLine;
}, [isCrossTeamAny, strippedText]); }, [isCrossTeamAny, strippedText]);
const rawSummary = useMemo(() => { const rawSummary = useMemo(() => {
@ -806,8 +814,7 @@ export const ActivityItem = memo(
// Fallback: use the beginning of message text as preview for plain-text messages // Fallback: use the beginning of message text as preview for plain-text messages
const plain = getSanitizedInboxMessageText(message).trim(); const plain = getSanitizedInboxMessageText(message).trim();
if (!plain) return ''; if (!plain) return '';
const oneLine = plain.replace(/\n+/g, ' '); return plain.replace(/\n+/g, ' ');
return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine;
}, [ }, [
crossTeamPreview, crossTeamPreview,
isSlashCommandMessage, isSlashCommandMessage,
@ -819,6 +826,46 @@ export const ActivityItem = memo(
structured, structured,
]); ]);
const summaryText = extractMarkdownPlainText(rawSummary); 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 = const commentTaskRef =
message.messageKind === 'task_comment_notification' ? (message.taskRefs?.[0] ?? null) : null; message.messageKind === 'task_comment_notification' ? (message.taskRefs?.[0] ?? null) : null;
const commentTaskDisplayId = const commentTaskDisplayId =
@ -1178,13 +1225,109 @@ export const ActivityItem = memo(
)} )}
</div> </div>
</div> </div>
<div <TooltipProvider delayDuration={1000}>
className="mt-1 min-w-0 truncate text-[11px]" <Tooltip>
style={{ color: CARD_TEXT_LIGHT }} <TooltipTrigger asChild>
title={summaryText || rawSummary} <div>
> <CompactMarkdownPreview
{summaryContent} 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> </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> </div>
) : ( ) : (
<> <>

View file

@ -9,8 +9,14 @@ import {
useState, useState,
} from 'react'; } from 'react';
import { CompactMarkdownPreview } from '@renderer/components/chat/viewers/MarkdownViewer';
import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
import { import {
CARD_BG, CARD_BG,
CARD_BG_ZEBRA, CARD_BG_ZEBRA,
@ -26,12 +32,14 @@ import {
areThoughtMessagesEquivalentForRender, areThoughtMessagesEquivalentForRender,
} from '@renderer/utils/messageRenderEquality'; } from '@renderer/utils/messageRenderEquality';
import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
import { isThoughtProtocolNoise } from '@shared/utils/inboxNoise'; import { isThoughtProtocolNoise } from '@shared/utils/inboxNoise';
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
import { ChevronDown, ChevronRight, ChevronUp, Maximize2 } from 'lucide-react'; import { ChevronDown, ChevronRight, ChevronUp, Maximize2 } from 'lucide-react';
import { buildThoughtDisplayContent } from './activityMarkdown';
import { import {
AnimatedHeightReveal, AnimatedHeightReveal,
ENTRY_REVEAL_ANIMATION_MS, ENTRY_REVEAL_ANIMATION_MS,
@ -582,18 +590,30 @@ const LeadThoughtsGroupRowComponent = ({
return calls.length > 0 ? calls : undefined; return calls.length > 0 ? calls : undefined;
}, [thoughts]); }, [thoughts]);
// Extract text preview for header: use newest thought's text, fallback through group // Reuse the same markdown preprocessing as the expanded thought body.
const headerTextPreview = useMemo(() => { const compactPreviewMarkdown = useMemo(() => {
// Try newest first (most relevant), then scan for any text // Try newest first (most relevant), then scan for any text
for (const t of thoughts) { for (const t of thoughts) {
if (t.text && t.text.trim()) { if (t.text && t.text.trim()) {
const plain = extractMarkdownPlainText(t.text); const stripped = stripAgentBlocks(t.text).trim();
const firstLine = plain.split('\n').find((l) => l.trim().length > 0) ?? ''; if (stripped) {
return firstLine.trim(); return buildThoughtDisplayContent(t, memberColorMap, teamNames, {
preserveLineBreaks: false,
stripAgentOnlyBlocks: true,
})
.replace(/\n+/g, ' ')
.trim();
}
} }
} }
return null; return totalToolSummary;
}, [thoughts]); }, [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 // Detect if any thought in this group is an API error
const hasApiError = useMemo(() => thoughts.some((t) => isApiErrorMessage(t.text)), [thoughts]); const hasApiError = useMemo(() => thoughts.some((t) => isApiErrorMessage(t.text)), [thoughts]);
@ -756,7 +776,6 @@ const LeadThoughtsGroupRowComponent = ({
? formatTime(oldest.timestamp) ? formatTime(oldest.timestamp)
: `${formatTime(oldest.timestamp)}${formatTime(newest.timestamp)}`; : `${formatTime(oldest.timestamp)}${formatTime(newest.timestamp)}`;
const useCompactCollapsedHeader = compactHeader && !isBodyVisible; const useCompactCollapsedHeader = compactHeader && !isBodyVisible;
const compactPreviewText = headerTextPreview ?? totalToolSummary;
return ( return (
<AnimatedHeightReveal animate={isNew} containerRef={ref} style={{ overflowAnchor: 'none' }}> <AnimatedHeightReveal animate={isNew} containerRef={ref} style={{ overflowAnchor: 'none' }}>
@ -829,14 +848,113 @@ const LeadThoughtsGroupRowComponent = ({
)} )}
</div> </div>
</div> </div>
{compactPreviewText ? ( {compactPreviewMarkdown ? (
<div <TooltipProvider delayDuration={1000}>
className="mt-1 min-w-0 truncate text-[11px]" <Tooltip>
style={{ color: headerTextPreview ? CARD_TEXT_LIGHT : CARD_ICON_MUTED }} <TooltipTrigger asChild>
title={compactPreviewText} <div>
> <CompactMarkdownPreview
{compactPreviewText} 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>
</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} ) : null}
</div> </div>
) : ( ) : (
@ -871,26 +989,7 @@ const LeadThoughtsGroupRowComponent = ({
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}> <span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{thoughts.length} thoughts {thoughts.length} thoughts
</span> </span>
{!isBodyVisible && headerTextPreview ? ( {totalToolSummary ? (
<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 ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="cursor-default text-[10px]" style={{ color: CARD_ICON_MUTED }}> <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 { CopyButton } from '@renderer/components/common/CopyButton';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { CARD_ICON_MUTED, CARD_TEXT_LIGHT } from '@renderer/constants/cssVariables'; import { CARD_ICON_MUTED, CARD_TEXT_LIGHT } from '@renderer/constants/cssVariables';
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
import { import {
areStringArraysEqual, areStringArraysEqual,
areStringMapsEqual, areStringMapsEqual,
areThoughtMessagesEquivalentForRender, areThoughtMessagesEquivalentForRender,
} from '@renderer/utils/messageRenderEquality'; } from '@renderer/utils/messageRenderEquality';
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
import { stripTeammateMessageBlocks } from '@shared/utils/inboxNoise';
import { Reply } from 'lucide-react'; import { Reply } from 'lucide-react';
import { buildThoughtDisplayContent } from './activityMarkdown';
import { formatTimeWithSec, ToolSummaryTooltipContent } from './LeadThoughtsGroup'; import { formatTimeWithSec, ToolSummaryTooltipContent } from './LeadThoughtsGroup';
import type { InboxMessage } from '@shared/types'; import type { InboxMessage } from '@shared/types';
@ -42,17 +41,10 @@ export const ThoughtBodyContent = memo(
onTeamClick, onTeamClick,
}: ThoughtBodyContentProps): JSX.Element { }: ThoughtBodyContentProps): JSX.Element {
const displayContent = useMemo(() => { const displayContent = useMemo(() => {
// Strip leaked protocol XML (<teammate-message> blocks) before rendering return buildThoughtDisplayContent(thought, memberColorMap, teamNames, {
let text = stripTeammateMessageBlocks(thought.text).replace(/\n/g, ' \n'); preserveLineBreaks: true,
text = linkifyTaskIdsInMarkdown(text, thought.taskRefs); stripAgentOnlyBlocks: true,
if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) { });
text = linkifyAllMentionsInMarkdown(
text,
(memberColorMap ?? new Map()) as Map<string, string>,
teamNames
);
}
return text;
}, [thought.text, thought.taskRefs, memberColorMap, teamNames]); }, [thought.text, thought.taskRefs, memberColorMap, teamNames]);
const handleTaskLinkClick = useCallback( 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 { normalizePath } from '@renderer/utils/pathNormalize';
import { import {
getTeamModelSelectionError, getTeamModelSelectionError,
normalizeTeamModelForUi, normalizeExplicitTeamModelForUi,
} from '@renderer/utils/teamModelAvailability'; } from '@renderer/utils/teamModelAvailability';
import { import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
getTeamProviderLabel as getCatalogTeamProviderLabel,
normalizeTeamModelForUi as normalizeCatalogTeamModelForUi,
} from '@renderer/utils/teamModelCatalog';
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; 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 { AdvancedCliSection } from './AdvancedCliSection';
import { OptionalSettingsSection } from './OptionalSettingsSection'; import { OptionalSettingsSection } from './OptionalSettingsSection';
import { ProjectPathSelector } from './ProjectPathSelector'; import { ProjectPathSelector } from './ProjectPathSelector';
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
import { import {
buildReusableProviderPrepareModelResults,
getProviderPrepareCachedSnapshot, getProviderPrepareCachedSnapshot,
type ProviderPrepareDiagnosticsModelResult, type ProviderPrepareDiagnosticsModelResult,
runProviderPrepareDiagnostics, runProviderPrepareDiagnostics,
@ -111,7 +110,7 @@ function getStoredTeamModel(providerId: TeamProviderId): string {
if (stored === null) { if (stored === null) {
return providerId === 'anthropic' ? 'opus' : ''; return providerId === 'anthropic' ? 'opus' : '';
} }
return normalizeCatalogTeamModelForUi(providerId, stored === '__default__' ? '' : stored); return normalizeExplicitTeamModelForUi(providerId, stored === '__default__' ? '' : stored);
} }
function isEphemeralRenderedProjectPath(projectPath: string | null | undefined): boolean { function isEphemeralRenderedProjectPath(projectPath: string | null | undefined): boolean {
@ -127,14 +126,6 @@ function getProviderLabel(providerId: TeamProviderId): string {
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic'; return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
} }
function buildPrepareModelCacheKey(
cwd: string,
providerId: TeamProviderId,
backendSummary: string | null | undefined
): string {
return `${cwd}::${providerId}::${backendSummary ?? ''}`;
}
function alignProvisioningChecks( function alignProvisioningChecks(
existingChecks: ProvisioningProviderCheck[], existingChecks: ProvisioningProviderCheck[],
providerIds: TeamProviderId[] providerIds: TeamProviderId[]
@ -408,7 +399,7 @@ export const CreateTeamDialog = ({
}, [advancedKey]); }, [advancedKey]);
const setSelectedModel = (value: string): void => { const setSelectedModel = (value: string): void => {
const normalizedValue = normalizeTeamModelForUi(selectedProviderId, value); const normalizedValue = normalizeExplicitTeamModelForUi(selectedProviderId, value);
setSelectedModelRaw(normalizedValue); setSelectedModelRaw(normalizedValue);
localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, normalizedValue); localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, normalizedValue);
}; };
@ -646,7 +637,12 @@ export const CreateTeamDialog = ({
return Array.from(next); return Array.from(next);
})(); })();
const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null; 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 cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
const cachedSnapshot = getProviderPrepareCachedSnapshot({ const cachedSnapshot = getProviderPrepareCachedSnapshot({
providerId, providerId,
@ -716,7 +712,7 @@ export const CreateTeamDialog = ({
} }
prepareModelResultsCacheRef.current.set( prepareModelResultsCacheRef.current.set(
plan.cacheKey, plan.cacheKey,
plan.prepResult.modelResultsById buildReusableProviderPrepareModelResults(plan.prepResult.modelResultsById)
); );
checks = updateProviderCheck(checks, plan.providerId, { checks = updateProviderCheck(checks, plan.providerId, {
status: plan.prepResult.status, status: plan.prepResult.status,

View file

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

View file

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

View file

@ -135,7 +135,7 @@ function summarizeDetail(
) { ) {
return 'CLI binary could not be started'; 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'; return 'CLI preflight did not complete';
} }
if (lower.includes('not authenticated') || lower.includes('not logged in')) { if (lower.includes('not authenticated') || lower.includes('not logged in')) {

View file

@ -1,5 +1,5 @@
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze'; 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 { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
@ -102,7 +102,7 @@ export function resolveLaunchDialogPrefill({
return { return {
providerId, providerId,
model: matchingModel model: matchingModel
? normalizeCatalogTeamModelForUi(providerId, matchingModel) ? normalizeExplicitTeamModelForUi(providerId, matchingModel)
: getStoredModel(providerId), : getStoredModel(providerId),
effort, effort,
limitContext, 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>; 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 { function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
} }
@ -187,6 +195,29 @@ function getResultReason(modelId: string, result: TeamProvisioningPrepareResult)
return null; 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( function buildModelFailureLine(
providerId: TeamProviderId, providerId: TeamProviderId,
modelId: string, modelId: string,
@ -201,6 +232,116 @@ function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string
return [...(result.details ?? []), ...(result.warnings ?? [])]; 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({ export async function runProviderPrepareDiagnostics({
cwd, cwd,
providerId, providerId,
@ -254,7 +395,7 @@ export async function runProviderPrepareDiagnostics({
const modelLines = new Map<string, string>(); const modelLines = new Map<string, string>();
let completedCount = 0; let completedCount = 0;
let hasFailure = false; let hasFailure = false;
let hasNotes = runtimeWarnings.length > 0; let hasNotes = false;
const modelWarnings: string[] = []; const modelWarnings: string[] = [];
for (const modelId of orderedModelIds) { for (const modelId of orderedModelIds) {
@ -289,73 +430,64 @@ export async function runProviderPrepareDiagnostics({
emitProgress(); emitProgress();
await Promise.all( const uncachedModelIds = orderedModelIds.filter((modelId) => !modelResultsById.has(modelId));
orderedModelIds if (uncachedModelIds.length > 0) {
.filter((modelId) => !modelResultsById.has(modelId)) try {
.map(async (modelId) => { const batchedModelResult = await prepareProvisioning(
try { cwd,
const modelResult = await prepareProvisioning( providerId,
cwd, [providerId],
providerId, uncachedModelIds,
[providerId], limitContext
[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 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( const selectedModelResultsById = Object.fromEntries(
orderedModelIds orderedModelIds
.map((modelId) => [modelId, modelResultsById.get(modelId)] as const) .map((modelId) => [modelId, modelResultsById.get(modelId)] as const)
@ -365,9 +497,9 @@ export async function runProviderPrepareDiagnostics({
); );
return { return {
status: hasFailure ? 'failed' : hasNotes ? 'notes' : 'ready', status: hasFailure ? 'failed' : hasNotes || dedupedWarnings.length > 0 ? 'notes' : 'ready',
details: [ details: [
...runtimeDetailLines, ...filteredRuntime.runtimeDetailLines,
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''), ...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
], ],
warnings: dedupedWarnings, 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); 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} ) : null}
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} /> <UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
{onDeleteTask ? ( {onDeleteTask ? (

View file

@ -6,7 +6,6 @@ import type { TeamTaskWithKanban } from '@shared/types';
interface CurrentTaskIndicatorProps { interface CurrentTaskIndicatorProps {
task: TeamTaskWithKanban; task: TeamTaskWithKanban;
borderColor: string; borderColor: string;
/** Max characters for the subject before truncating */
maxSubjectLength?: number; maxSubjectLength?: number;
activityLabel?: string; activityLabel?: string;
onOpenTask?: () => void; onOpenTask?: () => void;
@ -19,21 +18,24 @@ interface CurrentTaskIndicatorProps {
export const CurrentTaskIndicator = ({ export const CurrentTaskIndicator = ({
task, task,
borderColor, borderColor,
maxSubjectLength = 36, maxSubjectLength,
activityLabel = 'working on', activityLabel = 'working on',
onOpenTask, onOpenTask,
}: CurrentTaskIndicatorProps): React.JSX.Element => { }: CurrentTaskIndicatorProps): React.JSX.Element => {
const truncated = task.subject.length > maxSubjectLength; const subjectText =
const subjectText = truncated ? `${task.subject.slice(0, maxSubjectLength)}` : task.subject; typeof maxSubjectLength === 'number' &&
maxSubjectLength > 0 &&
task.subject.length > maxSubjectLength
? `${task.subject.slice(0, maxSubjectLength)}`
: task.subject;
return ( 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 }} /> <Loader2 className="size-3 shrink-0 animate-spin" style={{ color: borderColor }} />
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">{activityLabel}</span> <span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">{activityLabel}</span>
<button <button
type="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)]" 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)]"
style={{ border: `1px solid ${borderColor}40` }}
title="Open task" title="Open task"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@ -49,6 +51,6 @@ export const CurrentTaskIndicator = ({
> >
{formatTaskDisplayLabel(task)} {subjectText} {formatTaskDisplayLabel(task)} {subjectText}
</button> </button>
</> </div>
); );
}; };

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -159,13 +159,7 @@ export function hasInstallationInScope(
return installations.some((installation) => installation.scope === scope); return installations.some((installation) => installation.scope === scope);
} }
/** function summarizeInstallationScopes(scopes: InstallScope[]): string | null {
* 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)));
if (scopes.length === 0) { if (scopes.length === 0) {
return null; return null;
} }
@ -175,6 +169,7 @@ export function getInstallationSummaryLabel(
} }
switch (scopes[0]) { switch (scopes[0]) {
case 'global':
case 'user': case 'user':
return 'Installed globally'; return 'Installed globally';
case 'project': 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> = { const MCP_SCOPE_PRIORITY: Record<InstalledMcpEntry['scope'], number> = {
local: 0, local: 0,
project: 1, project: 1,
@ -216,25 +221,7 @@ export function getMcpInstallationSummaryLabel(
installations: Pick<InstalledMcpEntry, 'scope'>[] installations: Pick<InstalledMcpEntry, 'scope'>[]
): string | null { ): string | null {
const scopes = Array.from(new Set(installations.map((installation) => installation.scope))); const scopes = Array.from(new Set(installations.map((installation) => installation.scope)));
if (scopes.length === 0) { return summarizeInstallationScopes(scopes);
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';
}
} }
/** /**

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -292,6 +292,9 @@ describe('CliInstallerService', () => {
expect.objectContaining({ modelId: 'gpt-5.4-mini', status: 'checking' }), 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(() => { await vi.waitFor(() => {
const latestCodexProvider = service 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 const statusEvents = mockWindow.webContents.send.mock.calls
.filter((call: unknown[]) => call[0] === 'cliInstaller:progress') .filter((call: unknown[]) => call[0] === 'cliInstaller:progress')
.map((call: unknown[]) => call[1] as { type?: string; status?: { providers?: unknown[] } }) .map((call: unknown[]) => call[1] as { type?: string; status?: { providers?: unknown[] } })
@ -323,12 +332,29 @@ describe('CliInstallerService', () => {
'modelAvailability' in provider && 'modelAvailability' in provider &&
(provider as { providerId?: string }).providerId === 'codex' && (provider as { providerId?: string }).providerId === 'codex' &&
Array.isArray((provider as { modelAvailability?: unknown[] }).modelAvailability) && Array.isArray((provider as { modelAvailability?: unknown[] }).modelAvailability) &&
(provider as { modelAvailability: Array<{ status?: string }> }).modelAvailability.some( (provider as { modelAvailability: Array<{ modelId?: string; status?: string }> })
(item) => item.status === 'unavailable' .modelAvailability.some(
) (item) => item.modelId === 'gpt-5.4' && item.status === 'available'
)
) )
) )
).toBe(true); ).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); 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({ buildProviderAwareCliEnvMock.mockResolvedValue({
env: { HOME: '/Users/tester' }, env: { HOME: '/Users/tester' },
connectionIssues: {}, connectionIssues: {},

View file

@ -826,7 +826,6 @@ describe('BoardTaskLogStreamService integration', () => {
expect(bashCommands).not.toContain('echo alien'); expect(bashCommands).not.toContain('echo alien');
expect(rawMessages.some((message) => message.uuid === 'u-bash-alice-real')).toBe(false); 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 () => { 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-')); const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-created-window-'));
tempDirs.push(dir); tempDirs.push(dir);

View file

@ -13,8 +13,9 @@ import { TeamDataService } from '../../../../src/main/services/team/TeamDataServ
import type { import type {
InboxMessage, InboxMessage,
KanbanState, KanbanState,
ResolvedTeamMember,
TeamConfig, TeamConfig,
TeamData, TeamProcess,
TeamTask, TeamTask,
TeamTaskWithKanban, TeamTaskWithKanban,
} from '../../../../src/shared/types/team'; } from '../../../../src/shared/types/team';
@ -338,10 +339,9 @@ function createGetTeamDataHarness(options: {
config: TeamConfig, config: TeamConfig,
metaMembers: TeamConfig['members'], metaMembers: TeamConfig['members'],
inboxNames: string[], inboxNames: string[],
tasks: TeamTaskWithKanban[], tasks: TeamTaskWithKanban[]
messages: InboxMessage[] ) => ResolvedTeamMember[];
) => TeamData['members']; listProcesses?: () => TeamProcess[];
listProcesses?: () => TeamData['processes'];
getMemberAdvisories?: () => Promise<Map<string, unknown>>; getMemberAdvisories?: () => Promise<Map<string, unknown>>;
} = {}) { } = {}) {
const getConfig = vi.fn(async () => const getConfig = vi.fn(async () =>
@ -449,7 +449,7 @@ function createGetTeamDataHarness(options: {
}; };
} }
function buildResolvedMember(name: string): TeamData['members'][number] { function buildResolvedMember(name: string): ResolvedTeamMember {
return { return {
name, name,
status: 'unknown', 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 () => { it('creates task with status pending when startImmediately is false', async () => {
const createTaskMock = vi.fn((task) => ({ ...task, status: 'pending' })); const createTaskMock = vi.fn((task) => ({ ...task, status: 'pending' }));
const service = new TeamDataService( const service = new TeamDataService(
@ -2535,8 +2568,8 @@ describe('TeamDataService', () => {
} as never } as never
); );
const data = await service.getTeamData('my-team'); const feed = await service.getMessageFeed('my-team');
const costResult = data.messages.find((message) => message.messageId === 'lead-thought-1'); const costResult = feed.messages.find((message) => message.messageId === 'lead-thought-1');
expect(costResult).toMatchObject({ expect(costResult).toMatchObject({
messageKind: 'slash_command_result', messageKind: 'slash_command_result',
@ -2605,8 +2638,8 @@ describe('TeamDataService', () => {
} as never } as never
); );
const data = await service.getTeamData('my-team'); const feed = await service.getMessageFeed('my-team');
const result = data.messages.find((message) => message.messageId === 'passive-idle-dup-1'); const result = feed.messages.find((message) => message.messageId === 'passive-idle-dup-1');
expect(result).toBeDefined(); expect(result).toBeDefined();
expect(result?.source).not.toBe('lead_process'); expect(result?.source).not.toBe('lead_process');
@ -2680,8 +2713,8 @@ describe('TeamDataService', () => {
sentMessages: [userReplyRow], sentMessages: [userReplyRow],
}); });
const data = await service.getTeamData('my-team'); const feed = await service.getMessageFeed('my-team');
const linked = data.messages.find((message) => message.messageId === 'passive-user-summary-1'); const linked = feed.messages.find((message) => message.messageId === 'passive-user-summary-1');
expect(linked?.relayOfMessageId).toBe('user-reply-1'); expect(linked?.relayOfMessageId).toBe('user-reply-1');
expect(passiveSummaryRow.relayOfMessageId).toBeUndefined(); expect(passiveSummaryRow.relayOfMessageId).toBeUndefined();
@ -2716,8 +2749,8 @@ describe('TeamDataService', () => {
], ],
}); });
const data = await service.getTeamData('my-team'); const feed = await service.getMessageFeed('my-team');
const linked = data.messages.find( const linked = feed.messages.find(
(message) => message.messageId === 'passive-user-summary-contains-1' (message) => message.messageId === 'passive-user-summary-contains-1'
); );
@ -2753,8 +2786,8 @@ describe('TeamDataService', () => {
], ],
}); });
const data = await service.getTeamData('my-team'); const feed = await service.getMessageFeed('my-team');
const linked = data.messages.find((message) => message.messageId === 'passive-user-summary-old-1'); const linked = feed.messages.find((message) => message.messageId === 'passive-user-summary-old-1');
expect(linked?.relayOfMessageId).toBeUndefined(); expect(linked?.relayOfMessageId).toBeUndefined();
}); });
@ -2788,8 +2821,8 @@ describe('TeamDataService', () => {
], ],
}); });
const data = await service.getTeamData('my-team'); const feed = await service.getMessageFeed('my-team');
const linked = data.messages.find((message) => message.messageId === 'passive-bob-summary-1'); const linked = feed.messages.find((message) => message.messageId === 'passive-bob-summary-1');
expect(linked?.relayOfMessageId).toBeUndefined(); expect(linked?.relayOfMessageId).toBeUndefined();
}); });
@ -2823,8 +2856,8 @@ describe('TeamDataService', () => {
], ],
}); });
const data = await service.getTeamData('my-team'); const feed = await service.getMessageFeed('my-team');
const linked = data.messages.find( const linked = feed.messages.find(
(message) => message.messageId === 'passive-user-summary-sender-1' (message) => message.messageId === 'passive-user-summary-sender-1'
); );
@ -2870,8 +2903,8 @@ describe('TeamDataService', () => {
], ],
}); });
const data = await service.getTeamData('my-team'); const feed = await service.getMessageFeed('my-team');
const linked = data.messages.find( const linked = feed.messages.find(
(message) => message.messageId === 'passive-user-summary-ambiguous-1' (message) => message.messageId === 'passive-user-summary-ambiguous-1'
); );
@ -3362,11 +3395,11 @@ describe('TeamDataService', () => {
const fixture = await createResolverBackedLeadFixture(); const fixture = await createResolverBackedLeadFixture();
const service = createResolverBackedService(); 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; const persistedConfig = JSON.parse(await fs.readFile(fixture.configPath, 'utf8')) as TeamConfig;
expect( expect(
data.messages.find( feed.messages.find(
(message) => (message) =>
message.source === 'lead_session' && message.source === 'lead_session' &&
message.text.includes('recovered through the transcript resolver') 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 () => { it('starts light reads immediately, bounds heavy reads, and keeps processes outside the parallel phase', async () => {
const order: string[] = []; const order: string[] = [];
const tasksDeferred = createDeferred<TeamTask[]>(); const tasksDeferred = createDeferred<TeamTask[]>();
const messagesDeferred = createDeferred<InboxMessage[]>();
const leadTextsDeferred = createDeferred<InboxMessage[]>();
const harness = createGetTeamDataHarness({ const harness = createGetTeamDataHarness({
getTasks: async () => { getTasks: async () => {
@ -3490,10 +3521,6 @@ describe('TeamDataService', () => {
order.push('inboxNames:start'); order.push('inboxNames:start');
return []; return [];
}, },
getMessages: async () => {
order.push('messages:start');
return messagesDeferred.promise;
},
getMembers: async () => { getMembers: async () => {
order.push('meta:start'); order.push('meta:start');
return []; return [];
@ -3502,10 +3529,6 @@ describe('TeamDataService', () => {
order.push('kanban:start'); order.push('kanban:start');
return { teamName: 'my-team', reviewers: [], tasks: {} }; return { teamName: 'my-team', reviewers: [], tasks: {} };
}, },
readMessages: async () => {
order.push('sent:start');
return [];
},
resolveMembers: () => { resolveMembers: () => {
order.push('resolveMembers'); order.push('resolveMembers');
return []; return [];
@ -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'); const pending = harness.service.getTeamData('my-team');
await flushMicrotasks(); await flushMicrotasks();
expect(order).toEqual( expect(order).toEqual(
expect.arrayContaining([ expect.arrayContaining([
'inboxNames:start', 'inboxNames:start',
'sent:start',
'meta:start', 'meta:start',
'kanban:start', 'kanban:start',
'tasks:start', 'tasks:start',
'messages:start',
]) ])
); );
expect(order).not.toContain('leadTexts:start');
expect(order).not.toContain('processes:start'); expect(order).not.toContain('processes:start');
expect(order).not.toContain('leadTexts:start');
tasksDeferred.resolve([]); tasksDeferred.resolve([]);
await flushMicrotasks();
expect(order).toContain('leadTexts:start');
expect(order.indexOf('tasks:start')).toBeLessThan(order.indexOf('messages:start'));
expect(order.indexOf('messages:start')).toBeLessThan(order.indexOf('leadTexts:start'));
expect(order).not.toContain('processes:start');
messagesDeferred.resolve([]);
leadTextsDeferred.resolve([]);
const data = await pending; const data = await pending;
@ -3569,7 +3574,7 @@ describe('TeamDataService', () => {
pid: 101, pid: 101,
}), }),
]); ]);
expect(order.indexOf('leadTexts:start')).toBeLessThan(order.indexOf('processes:start')); expect(order).not.toContain('leadTexts:start');
expect(order.indexOf('resolveMembers')).toBeLessThan(order.indexOf('processes:start')); expect(order.indexOf('resolveMembers')).toBeLessThan(order.indexOf('processes:start'));
}); });
@ -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 () => { it('keeps warning order deterministic even when read failures settle out of order', async () => {
const tasksDeferred = createDeferred<TeamTask[]>(); const tasksDeferred = createDeferred<TeamTask[]>();
const inboxDeferred = createDeferred<string[]>(); const inboxDeferred = createDeferred<string[]>();
const messagesDeferred = createDeferred<InboxMessage[]>();
const leadTextsDeferred = createDeferred<InboxMessage[]>();
const sentDeferred = createDeferred<InboxMessage[]>();
const metaDeferred = createDeferred<TeamConfig['members']>(); const metaDeferred = createDeferred<TeamConfig['members']>();
const kanbanDeferred = createDeferred<KanbanState>(); const kanbanDeferred = createDeferred<KanbanState>();
const harness = createGetTeamDataHarness({ const harness = createGetTeamDataHarness({
getTasks: async () => tasksDeferred.promise, getTasks: async () => tasksDeferred.promise,
listInboxNames: async () => inboxDeferred.promise, listInboxNames: async () => inboxDeferred.promise,
getMessages: async () => messagesDeferred.promise,
getMembers: async () => metaDeferred.promise, getMembers: async () => metaDeferred.promise,
getState: async () => kanbanDeferred.promise, getState: async () => kanbanDeferred.promise,
readMessages: async () => sentDeferred.promise,
}); });
vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation(
async () => leadTextsDeferred.promise
);
const pending = harness.service.getTeamData('my-team'); const pending = harness.service.getTeamData('my-team');
await flushMicrotasks(); await flushMicrotasks();
sentDeferred.reject(new Error('sent failed'));
kanbanDeferred.reject(new Error('kanban failed')); kanbanDeferred.reject(new Error('kanban failed'));
tasksDeferred.reject(new Error('tasks failed')); tasksDeferred.reject(new Error('tasks failed'));
metaDeferred.reject(new Error('meta failed')); metaDeferred.reject(new Error('meta failed'));
inboxDeferred.reject(new Error('inbox failed')); inboxDeferred.reject(new Error('inbox failed'));
leadTextsDeferred.reject(new Error('lead failed'));
messagesDeferred.reject(new Error('messages failed'));
const data = await pending; const data = await pending;
expect(data.warnings).toEqual([ expect(data.warnings).toEqual([
'Tasks failed to load', 'Tasks failed to load',
'Inboxes failed to load', 'Inboxes failed to load',
'Messages failed to load',
'Lead session texts failed to load',
'Sent messages failed to load',
'Member metadata failed to load', 'Member metadata failed to load',
'Kanban state failed to load', 'Kanban state failed to load',
]); ]);
@ -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 () => { 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 data = await harness.service.getTeamData('my-team');
const feed = await harness.service.getMessageFeed('my-team');
expect(data.warnings).toEqual( expect(data.warnings).toEqual(expect.arrayContaining(['Kanban state failed to load']));
expect.arrayContaining(['Messages failed to load', 'Kanban state failed to load']) expect(feed.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1']);
);
expect(data.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1']);
expect(resolveMembersSpy).toHaveBeenCalledWith( expect(resolveMembersSpy).toHaveBeenCalledWith(
buildDefaultTeamConfig(), buildDefaultTeamConfig(),
metaMembers, metaMembers,
@ -3763,10 +3784,6 @@ describe('TeamDataService', () => {
id: 'task-1', id: 'task-1',
subject: 'Investigate rollout', subject: 'Investigate rollout',
}), }),
],
[
expect.objectContaining({ messageId: 'sent-1' }),
expect.objectContaining({ messageId: 'lead-1' }),
] ]
); );
}); });
@ -3805,16 +3822,11 @@ describe('TeamDataService', () => {
it('degrades a queued heavy sync throw to warning and still completes the snapshot', async () => { it('degrades a queued heavy sync throw to warning and still completes the snapshot', async () => {
const order: string[] = []; const order: string[] = [];
const tasksDeferred = createDeferred<TeamTask[]>(); const tasksDeferred = createDeferred<TeamTask[]>();
const messagesDeferred = createDeferred<InboxMessage[]>();
const harness = createGetTeamDataHarness({ const harness = createGetTeamDataHarness({
getTasks: async () => { getTasks: async () => {
order.push('tasks:start'); order.push('tasks:start');
return tasksDeferred.promise; return tasksDeferred.promise;
}, },
getMessages: async () => {
order.push('messages:start');
return messagesDeferred.promise;
},
listProcesses: () => { listProcesses: () => {
order.push('processes:start'); order.push('processes:start');
return []; return [];
@ -3832,14 +3844,9 @@ describe('TeamDataService', () => {
expect(order).not.toContain('leadTexts:start'); expect(order).not.toContain('leadTexts:start');
tasksDeferred.resolve([]); tasksDeferred.resolve([]);
await flushMicrotasks();
expect(order).toContain('leadTexts:start');
messagesDeferred.resolve([]);
const data = await pending; const data = await pending;
expect(data.warnings).toEqual(expect.arrayContaining(['Lead session texts failed to load'])); expect(data.warnings ?? []).not.toContain('Lead session texts failed to load');
expect(order).toContain('processes:start'); expect(order).toContain('processes:start');
}); });
@ -3977,7 +3984,7 @@ describe('TeamDataService', () => {
expect(page1.hasMore).toBe(true); expect(page1.hasMore).toBe(true);
const page2 = await service.getMessagesPage('my-team', { const page2 = await service.getMessagesPage('my-team', {
beforeTimestamp: page1.nextCursor!, cursor: page1.nextCursor!,
limit: 10, limit: 10,
}); });
// Should get the remaining 2 messages, not lose the one with same timestamp // Should get the remaining 2 messages, not lose the one with same timestamp
@ -4011,6 +4018,41 @@ describe('TeamDataService', () => {
expect(result?.messageKind).toBe('slash_command_result'); expect(result?.messageKind).toBe('slash_command_result');
}); });
it('normalizes stable effective message ids before pagination and cursoring', async () => {
const msgs = [
{
from: 'alice',
text: 'same-ts-a',
timestamp: '2026-01-01T00:00:02.000Z',
source: 'inbox' as const,
},
{
from: 'bob',
text: 'same-ts-b',
timestamp: '2026-01-01T00:00:02.000Z',
source: 'inbox' as const,
},
{
from: 'carol',
text: 'older',
timestamp: '2026-01-01T00:00:01.000Z',
source: 'inbox' as const,
},
];
const service = createPaginationService(msgs);
const page1 = await service.getMessagesPage('my-team', { limit: 1 });
const page2 = await service.getMessagesPage('my-team', {
cursor: page1.nextCursor!,
limit: 10,
});
expect(page1.messages[0]?.messageId).toMatch(/^inbox-/);
expect(page1.nextCursor).toContain(page1.messages[0]!.messageId!);
expect(page2.messages.every((message) => Boolean(message.messageId))).toBe(true);
expect(new Set([...page1.messages, ...page2.messages].map((message) => message.messageId)).size).toBe(3);
});
it('dedups newest-page live overlay against durable lead thoughts that already paged off the first page', async () => { 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) => ({ const fillerMessages = Array.from({ length: 55 }, (_, index) => ({
from: 'alice', from: 'alice',
@ -4089,7 +4131,7 @@ describe('TeamDataService', () => {
const page2 = await service.getMessagesPage('my-team', { const page2 = await service.getMessagesPage('my-team', {
limit: 10, limit: 10,
beforeTimestamp: page1.nextCursor!, cursor: page1.nextCursor!,
}); });
expect(page2.messages.map((message) => message.messageId)).toEqual(['durable-2', 'durable-1']); 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 { TeamMemberResolver } from '../../../../src/main/services/team/TeamMemberResolver';
import type { import type {
InboxMessage,
TeamConfig, TeamConfig,
TeamTask, TeamTask,
TeamTaskWithKanban, TeamTaskWithKanban,
@ -24,13 +23,8 @@ describe('TeamMemberResolver', () => {
{ id: '1', subject: 'Visible task', status: 'pending', owner: 'alice' }, { id: '1', subject: 'Visible task', status: 'pending', owner: 'alice' },
{ id: '2', subject: 'Ghost task', status: 'pending', owner: 'stranger' }, { id: '2', subject: 'Ghost task', status: 'pending', owner: 'stranger' },
]; ];
const now = new Date().toISOString();
const messages: InboxMessage[] = [
{ from: 'bob', text: 'ready', timestamp: now, read: false, color: 'green' },
{ from: 'user', text: 'system note', timestamp: now, read: false },
];
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks, messages); const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks);
const names = members.map((member) => member.name); const names = members.map((member) => member.name);
expect(names).toHaveLength(3); expect(names).toHaveLength(3);
@ -62,9 +56,8 @@ describe('TeamMemberResolver', () => {
]; ];
const inboxNames = ['user', 'alice']; const inboxNames = ['user', 'alice'];
const tasks: TeamTask[] = []; const tasks: TeamTask[] = [];
const messages: InboxMessage[] = [];
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks, messages); const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks);
const names = members.map((m) => m.name); const names = members.map((m) => m.name);
expect(names).not.toContain('user'); expect(names).not.toContain('user');
@ -81,9 +74,8 @@ describe('TeamMemberResolver', () => {
const metaMembers: TeamConfig['members'] = [{ name: 'alice', agentType: 'general-purpose' }]; const metaMembers: TeamConfig['members'] = [{ name: 'alice', agentType: 'general-purpose' }];
const inboxNames = ['alice', 'team-best.user', 'dream-team.team-lead']; const inboxNames = ['alice', 'team-best.user', 'dream-team.team-lead'];
const tasks: TeamTask[] = []; const tasks: TeamTask[] = [];
const messages: InboxMessage[] = [];
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks, messages); const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks);
const names = members.map((m) => m.name); const names = members.map((m) => m.name);
expect(names).toContain('alice'); expect(names).toContain('alice');
@ -104,7 +96,7 @@ describe('TeamMemberResolver', () => {
]; ];
const inboxNames = ['a3975f80d37fbcea1', 'alice', 'a68a8f6a643e59bfd']; const inboxNames = ['a3975f80d37fbcea1', 'alice', 'a68a8f6a643e59bfd'];
const members = resolver.resolveMembers(config, metaMembers, inboxNames, [], []); const members = resolver.resolveMembers(config, metaMembers, inboxNames, []);
const names = members.map((m) => m.name); const names = members.map((m) => m.name);
expect(names).toContain('alice'); expect(names).toContain('alice');
@ -124,7 +116,7 @@ describe('TeamMemberResolver', () => {
], ],
}; };
const members = resolver.resolveMembers(config, [], ['ops.bot'], [], []); const members = resolver.resolveMembers(config, [], ['ops.bot'], []);
const names = members.map((m) => m.name); const names = members.map((m) => m.name);
expect(names).toContain('ops.bot'); expect(names).toContain('ops.bot');
@ -141,7 +133,6 @@ describe('TeamMemberResolver', () => {
config, config,
[], [],
['cross-team:team-alpha-super', 'cross-team-team-alpha-super', 'alice'], ['cross-team:team-alpha-super', 'cross-team-team-alpha-super', 'alice'],
[],
[] []
); );
const names = members.map((m) => m.name); const names = members.map((m) => m.name);
@ -163,7 +154,6 @@ describe('TeamMemberResolver', () => {
config, config,
[], [],
['cross_team_send', 'cross_team_list_targets', 'alice'], ['cross_team_send', 'cross_team_list_targets', 'alice'],
[],
[] []
); );
const names = members.map((m) => m.name); const names = members.map((m) => m.name);
@ -185,7 +175,6 @@ describe('TeamMemberResolver', () => {
config, config,
[], [],
['cross_team::team-alpha-super', 'cross_team--team-alpha-super', 'alice'], ['cross_team::team-alpha-super', 'cross_team--team-alpha-super', 'alice'],
[],
[] []
); );
const names = members.map((m) => m.name); const names = members.map((m) => m.name);
@ -206,7 +195,7 @@ describe('TeamMemberResolver', () => {
], ],
}; };
const members = resolver.resolveMembers(config, [], ['ops.bot'], [], []); const members = resolver.resolveMembers(config, [], ['ops.bot'], []);
const names = members.map((m) => m.name); const names = members.map((m) => m.name);
expect(names).toContain('Ops.Bot'); expect(names).toContain('Ops.Bot');
@ -222,7 +211,7 @@ describe('TeamMemberResolver', () => {
const tasks: TeamTaskWithKanban[] = [ const tasks: TeamTaskWithKanban[] = [
{ id: 't1', subject: 'Work', status: 'in_progress', owner: 'bob' }, { id: 't1', subject: 'Work', status: 'in_progress', owner: 'bob' },
]; ];
const members = resolver.resolveMembers(config, [], [], tasks, []); const members = resolver.resolveMembers(config, [], [], tasks);
const bob = members.find((m) => m.name === 'bob'); const bob = members.find((m) => m.name === 'bob');
expect(bob?.currentTaskId).toBe('t1'); expect(bob?.currentTaskId).toBe('t1');
}); });
@ -243,7 +232,7 @@ describe('TeamMemberResolver', () => {
kanbanColumn: 'approved', kanbanColumn: 'approved',
}, },
]; ];
const members = resolver.resolveMembers(config, [], [], tasks, []); const members = resolver.resolveMembers(config, [], [], tasks);
const bob = members.find((m) => m.name === 'bob'); const bob = members.find((m) => m.name === 'bob');
expect(bob?.currentTaskId).toBeNull(); expect(bob?.currentTaskId).toBeNull();
}); });
@ -264,7 +253,7 @@ describe('TeamMemberResolver', () => {
// kanbanColumn not set — stale data scenario // kanbanColumn not set — stale data scenario
}, },
]; ];
const members = resolver.resolveMembers(config, [], [], tasks, []); const members = resolver.resolveMembers(config, [], [], tasks);
const bob = members.find((m) => m.name === 'bob'); const bob = members.find((m) => m.name === 'bob');
expect(bob?.currentTaskId).toBeNull(); expect(bob?.currentTaskId).toBeNull();
}); });
@ -281,7 +270,7 @@ describe('TeamMemberResolver', () => {
// Teammates sometimes send messages to "lead" instead of "team-lead", // Teammates sometimes send messages to "lead" instead of "team-lead",
// creating a separate inbox file that the resolver picks up. // creating a separate inbox file that the resolver picks up.
const inboxNames = ['team-lead', 'lead', 'alice']; const inboxNames = ['team-lead', 'lead', 'alice'];
const members = resolver.resolveMembers(config, [], inboxNames, [], []); const members = resolver.resolveMembers(config, [], inboxNames, []);
const names = members.map((m) => m.name); const names = members.map((m) => m.name);
expect(names).toContain('team-lead'); expect(names).toContain('team-lead');
@ -295,7 +284,7 @@ describe('TeamMemberResolver', () => {
name: 'Team', name: 'Team',
members: [{ name: 'lead', agentType: 'team-lead', role: 'lead' }], members: [{ name: 'lead', agentType: 'team-lead', role: 'lead' }],
}; };
const members = resolver.resolveMembers(config, [], ['lead'], [], []); const members = resolver.resolveMembers(config, [], ['lead'], []);
const names = members.map((m) => m.name); const names = members.map((m) => m.name);
expect(names).toContain('lead'); expect(names).toContain('lead');
@ -310,7 +299,7 @@ describe('TeamMemberResolver', () => {
const tasks: TeamTaskWithKanban[] = [ const tasks: TeamTaskWithKanban[] = [
{ id: 't1', subject: 'Work', status: 'completed', owner: 'bob' }, { id: 't1', subject: 'Work', status: 'completed', owner: 'bob' },
]; ];
const members = resolver.resolveMembers(config, [], [], tasks, []); const members = resolver.resolveMembers(config, [], [], tasks);
const bob = members.find((m) => m.name === 'bob'); const bob = members.find((m) => m.name === 'bob');
expect(bob?.currentTaskId).toBeNull(); expect(bob?.currentTaskId).toBeNull();
}); });

View file

@ -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', () => { describe('TeamProvisioningService', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@ -1181,4 +1240,311 @@ describe('TeamProvisioningService', () => {
expect(result.statuses.jack?.hardFailureReason).toContain('requested model is not available'); expect(result.statuses.jack?.hardFailureReason).toContain('requested model is not available');
expect(result.teamLaunchState).toBe('partial_failure'); 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