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:
commit
cd4e9ccba8
128 changed files with 12420 additions and 2013 deletions
3221
docs/research/team-detail-snapshot-messages-activity-plan.md
Normal file
3221
docs/research/team-detail-snapshot-messages-activity-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -4,7 +4,7 @@
|
|||
* All state in refs — no React re-renders.
|
||||
*/
|
||||
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { useRef, useCallback, useMemo } from 'react';
|
||||
import type { GraphNode } from '../ports/types';
|
||||
import { CAMERA, ANIM, NODE, TASK_PILL } from '../constants/canvas-constants';
|
||||
import type { WorldBounds } from '../layout/launchAnchor';
|
||||
|
|
@ -170,17 +170,31 @@ export function useGraphCamera(): UseGraphCameraResult {
|
|||
t.zoom = Math.max(CAMERA.minZoom, t.zoom / 1.2);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
transformRef,
|
||||
screenToWorld,
|
||||
worldToScreen,
|
||||
handleWheel,
|
||||
handlePanStart,
|
||||
handlePanMove,
|
||||
handlePanEnd,
|
||||
zoomToFit,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
updateInertia,
|
||||
};
|
||||
return useMemo(
|
||||
() => ({
|
||||
transformRef,
|
||||
screenToWorld,
|
||||
worldToScreen,
|
||||
handleWheel,
|
||||
handlePanStart,
|
||||
handlePanMove,
|
||||
handlePanEnd,
|
||||
zoomToFit,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
updateInertia,
|
||||
}),
|
||||
[
|
||||
screenToWorld,
|
||||
worldToScreen,
|
||||
handleWheel,
|
||||
handlePanStart,
|
||||
handlePanMove,
|
||||
handlePanEnd,
|
||||
zoomToFit,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
updateInertia,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* Delegates hit testing to strategy pattern.
|
||||
*/
|
||||
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { useRef, useCallback, useMemo } from 'react';
|
||||
import type { GraphNode } from '../ports/types';
|
||||
import { ANIM } from '../constants/canvas-constants';
|
||||
import { findNodeAt } from '../canvas/hit-detection';
|
||||
|
|
@ -81,13 +81,16 @@ export function useGraphInteraction(
|
|||
return findNodeAt(wx, wy, nodes);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
hoveredNodeId,
|
||||
dragNodeId,
|
||||
isDragging,
|
||||
handleMouseDown,
|
||||
handleMouseMove,
|
||||
handleMouseUp,
|
||||
handleDoubleClick,
|
||||
};
|
||||
return useMemo(
|
||||
() => ({
|
||||
hoveredNodeId,
|
||||
dragNodeId,
|
||||
isDragging,
|
||||
handleMouseDown,
|
||||
handleMouseMove,
|
||||
handleMouseUp,
|
||||
handleDoubleClick,
|
||||
}),
|
||||
[handleDoubleClick, handleMouseDown, handleMouseMove, handleMouseUp]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { ANIM_SPEED, NODE } from '../constants/canvas-constants';
|
||||
import { getStateColor } from '../constants/colors';
|
||||
|
|
@ -239,19 +239,29 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
stateRef,
|
||||
updateData,
|
||||
tick,
|
||||
setNodePosition,
|
||||
clearNodePosition,
|
||||
clearTransientOwnerPositions,
|
||||
resolveNearestOwnerSlot,
|
||||
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
|
||||
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
|
||||
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
|
||||
getExtraWorldBounds: () => extraWorldBoundsRef.current,
|
||||
};
|
||||
return useMemo(
|
||||
() => ({
|
||||
stateRef,
|
||||
updateData,
|
||||
tick,
|
||||
setNodePosition,
|
||||
clearNodePosition,
|
||||
clearTransientOwnerPositions,
|
||||
resolveNearestOwnerSlot,
|
||||
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
|
||||
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
|
||||
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
|
||||
getExtraWorldBounds: () => extraWorldBoundsRef.current,
|
||||
}),
|
||||
[
|
||||
updateData,
|
||||
tick,
|
||||
setNodePosition,
|
||||
clearNodePosition,
|
||||
clearTransientOwnerPositions,
|
||||
resolveNearestOwnerSlot,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
function applySnapshotToNodes(
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import {
|
|||
findNodeAt,
|
||||
getEdgeMidpoint,
|
||||
} from '../canvas/hit-detection';
|
||||
import { ANIM_SPEED } from '../constants/canvas-constants';
|
||||
import { ANIM, ANIM_SPEED } from '../constants/canvas-constants';
|
||||
import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor';
|
||||
|
||||
export interface GraphViewProps {
|
||||
|
|
@ -148,13 +148,6 @@ export function GraphView({
|
|||
// ─── Hooks ──────────────────────────────────────────────────────────────
|
||||
const simulation = useGraphSimulation();
|
||||
const camera = useGraphCamera();
|
||||
|
||||
// Stable refs for RAF loop (avoid recreating animate on hook identity change)
|
||||
const simulationRef = useRef(simulation);
|
||||
simulationRef.current = simulation;
|
||||
const cameraRef = useRef(camera);
|
||||
cameraRef.current = camera;
|
||||
|
||||
const interaction = useGraphInteraction(
|
||||
useCallback(
|
||||
(nodeId: string, x: number, y: number) => {
|
||||
|
|
@ -164,6 +157,20 @@ export function GraphView({
|
|||
)
|
||||
);
|
||||
|
||||
// Stable refs for RAF loop (avoid recreating animate on hook identity change)
|
||||
const simulationRef = useRef(simulation);
|
||||
simulationRef.current = simulation;
|
||||
const cameraRef = useRef(camera);
|
||||
cameraRef.current = camera;
|
||||
const interactionRef = useRef(interaction);
|
||||
interactionRef.current = interaction;
|
||||
const processActivePointerMoveRef = useRef<((clientX: number, clientY: number) => boolean) | null>(
|
||||
null
|
||||
);
|
||||
const completePointerInteractionRef = useRef<((clientX: number, clientY: number) => void) | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const getVisibleNodes = useCallback(
|
||||
(nodes: GraphNode[]): GraphNode[] =>
|
||||
nodes.filter((node) => {
|
||||
|
|
@ -433,16 +440,16 @@ export function GraphView({
|
|||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isSurfaceActive) {
|
||||
if (isSurfaceActive) {
|
||||
return;
|
||||
}
|
||||
interaction.handleMouseUp();
|
||||
simulation.clearTransientOwnerPositions();
|
||||
interactionRef.current.handleMouseUp();
|
||||
simulationRef.current.clearTransientOwnerPositions();
|
||||
dragPreviewRef.current = null;
|
||||
isPanningRef.current = false;
|
||||
edgeMouseDownRef.current = null;
|
||||
setInteractionGuards(false);
|
||||
}, [interaction, isSurfaceActive, simulation]);
|
||||
}, [isSurfaceActive, setInteractionGuards]);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: WheelEvent) => {
|
||||
|
|
@ -454,7 +461,13 @@ export function GraphView({
|
|||
|
||||
// ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─
|
||||
const isPanningRef = useRef(false);
|
||||
const edgeMouseDownRef = useRef<{ id: string; x: number; y: number } | null>(null);
|
||||
const edgeMouseDownRef = useRef<{
|
||||
id: string;
|
||||
worldX: number;
|
||||
worldY: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
|
|
@ -491,7 +504,13 @@ export function GraphView({
|
|||
if (hitEdge) {
|
||||
markUserInteracted();
|
||||
isPanningRef.current = false;
|
||||
edgeMouseDownRef.current = { id: hitEdge, x: world.x, y: world.y };
|
||||
edgeMouseDownRef.current = {
|
||||
id: hitEdge,
|
||||
worldX: world.x,
|
||||
worldY: world.y,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
};
|
||||
hoveredEdgeIdRef.current = hitEdge;
|
||||
} else {
|
||||
// Hit empty space → pan
|
||||
|
|
@ -518,11 +537,6 @@ export function GraphView({
|
|||
|
||||
const processActivePointerMove = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
if (!activePrimaryInteractionRef.current) {
|
||||
dragPreviewRef.current = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPanningRef.current) {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.getSelection()?.removeAllRanges();
|
||||
|
|
@ -531,6 +545,36 @@ export function GraphView({
|
|||
return true;
|
||||
}
|
||||
|
||||
const edgeMouseDown = edgeMouseDownRef.current;
|
||||
if (
|
||||
edgeMouseDown &&
|
||||
!interaction.dragNodeId.current &&
|
||||
!interaction.isDragging.current
|
||||
) {
|
||||
const dx = clientX - edgeMouseDown.clientX;
|
||||
const dy = clientY - edgeMouseDown.clientY;
|
||||
if (dx * dx + dy * dy > ANIM.dragThresholdPx * ANIM.dragThresholdPx) {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.getSelection()?.removeAllRanges();
|
||||
}
|
||||
hoveredEdgeIdRef.current = null;
|
||||
edgeMouseDownRef.current = null;
|
||||
isPanningRef.current = true;
|
||||
camera.handlePanStart(edgeMouseDown.clientX, edgeMouseDown.clientY);
|
||||
camera.handlePanMove(clientX, clientY);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!activePrimaryInteractionRef.current &&
|
||||
!interaction.dragNodeId.current &&
|
||||
!interaction.isDragging.current
|
||||
) {
|
||||
dragPreviewRef.current = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
const canvas = canvasHandle.current?.getCanvas();
|
||||
if (!canvas) {
|
||||
dragPreviewRef.current = null;
|
||||
|
|
@ -627,8 +671,8 @@ export function GraphView({
|
|||
if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top);
|
||||
const dx = world.x - edgeMouseDownRef.current.x;
|
||||
const dy = world.y - edgeMouseDownRef.current.y;
|
||||
const dx = world.x - edgeMouseDownRef.current.worldX;
|
||||
const dy = world.y - edgeMouseDownRef.current.worldY;
|
||||
if (dx * dx + dy * dy <= 25) {
|
||||
clickedEdgeId = edgeMouseDownRef.current.id;
|
||||
}
|
||||
|
|
@ -656,6 +700,8 @@ export function GraphView({
|
|||
},
|
||||
[camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation]
|
||||
);
|
||||
processActivePointerMoveRef.current = processActivePointerMove;
|
||||
completePointerInteractionRef.current = completePointerInteraction;
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
|
|
@ -711,36 +757,40 @@ export function GraphView({
|
|||
if (
|
||||
!activePrimaryInteractionRef.current &&
|
||||
!isPanningRef.current &&
|
||||
!interaction.dragNodeId.current &&
|
||||
!interaction.isDragging.current &&
|
||||
!interactionRef.current.dragNodeId.current &&
|
||||
!interactionRef.current.isDragging.current &&
|
||||
!edgeMouseDownRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
processActivePointerMove(event.clientX, event.clientY);
|
||||
processActivePointerMoveRef.current?.(event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
const handleWindowMouseUp = (event: MouseEvent): void => {
|
||||
if (
|
||||
!activePrimaryInteractionRef.current &&
|
||||
!isPanningRef.current &&
|
||||
!interaction.dragNodeId.current &&
|
||||
!interaction.isDragging.current &&
|
||||
!interactionRef.current.dragNodeId.current &&
|
||||
!interactionRef.current.isDragging.current &&
|
||||
!edgeMouseDownRef.current
|
||||
) {
|
||||
setInteractionGuards(false);
|
||||
return;
|
||||
}
|
||||
completePointerInteraction(event.clientX, event.clientY);
|
||||
completePointerInteractionRef.current?.(event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
const clearInteraction = (): void => {
|
||||
if (!activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.isDragging.current) {
|
||||
if (
|
||||
!activePrimaryInteractionRef.current &&
|
||||
!isPanningRef.current &&
|
||||
!interactionRef.current.isDragging.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
interaction.handleMouseUp();
|
||||
camera.handlePanEnd();
|
||||
interactionRef.current.handleMouseUp();
|
||||
cameraRef.current.handlePanEnd();
|
||||
isPanningRef.current = false;
|
||||
edgeMouseDownRef.current = null;
|
||||
dragPreviewRef.current = null;
|
||||
|
|
@ -756,9 +806,14 @@ export function GraphView({
|
|||
window.removeEventListener('mouseup', handleWindowMouseUp);
|
||||
window.removeEventListener('blur', clearInteraction);
|
||||
window.removeEventListener('dragstart', clearInteraction);
|
||||
};
|
||||
}, [setInteractionGuards]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setInteractionGuards(false);
|
||||
};
|
||||
}, [camera, completePointerInteraction, interaction, processActivePointerMove, setInteractionGuards]);
|
||||
}, [setInteractionGuards]);
|
||||
|
||||
const handleDoubleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* TeamGraphAdapter — transforms Zustand TeamData → GraphDataPort.
|
||||
* TeamGraphAdapter — transforms store-backed team graph input → GraphDataPort.
|
||||
*
|
||||
* This adapter owns the graph projection from team runtime state into the
|
||||
* reusable package port model. Renderer hooks may still read store state, but
|
||||
|
|
@ -57,12 +57,18 @@ import type {
|
|||
LeadActivityState,
|
||||
MemberSpawnStatusEntry,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
TeamData,
|
||||
ResolvedTeamMember,
|
||||
TeamProcess,
|
||||
TeamProvisioningProgress,
|
||||
TeamViewSnapshot,
|
||||
} from '@shared/types/team';
|
||||
import type { LeadContextUsage } from '@shared/types/team';
|
||||
|
||||
export interface TeamGraphData extends TeamViewSnapshot {
|
||||
members: ResolvedTeamMember[];
|
||||
messageFeed: InboxMessage[];
|
||||
}
|
||||
|
||||
export class TeamGraphAdapter {
|
||||
// ─── ES #private fields ──────────────────────────────────────────────────
|
||||
#lastTeamName = '';
|
||||
|
|
@ -89,7 +95,7 @@ export class TeamGraphAdapter {
|
|||
* Adapt team data into a GraphDataPort snapshot.
|
||||
*/
|
||||
adapt(
|
||||
teamData: TeamData | null,
|
||||
teamData: TeamGraphData | null,
|
||||
teamName: string,
|
||||
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
||||
leadActivity?: LeadActivityState,
|
||||
|
|
@ -190,7 +196,7 @@ export class TeamGraphAdapter {
|
|||
this.#buildMessageParticles(
|
||||
particles,
|
||||
nodes,
|
||||
teamData.messages,
|
||||
teamData.messageFeed,
|
||||
teamName,
|
||||
leadId,
|
||||
leadName,
|
||||
|
|
@ -233,11 +239,11 @@ export class TeamGraphAdapter {
|
|||
|
||||
// ─── Private: node builders ──────────────────────────────────────────────
|
||||
|
||||
static #getLeadMemberName(data: TeamData, teamName: string): string {
|
||||
static #getLeadMemberName(data: TeamGraphData, teamName: string): string {
|
||||
return getGraphLeadMemberName(data, teamName);
|
||||
}
|
||||
|
||||
static #buildMemberNodeIdByAlias(data: TeamData, teamName: string): Map<string, string> {
|
||||
static #buildMemberNodeIdByAlias(data: TeamGraphData, teamName: string): Map<string, string> {
|
||||
return buildGraphMemberNodeIdAliasMap(
|
||||
teamName,
|
||||
data.members.filter((member) => !isLeadMember(member))
|
||||
|
|
@ -245,7 +251,7 @@ export class TeamGraphAdapter {
|
|||
}
|
||||
|
||||
static #buildLayoutPort(
|
||||
data: TeamData,
|
||||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
slotAssignments?: Record<string, GraphOwnerSlotAssignment>
|
||||
): GraphLayoutPort {
|
||||
|
|
@ -263,7 +269,7 @@ export class TeamGraphAdapter {
|
|||
);
|
||||
const assignedStableOwnerIds = new Set(Object.keys(slotAssignments ?? {}));
|
||||
|
||||
const pushMember = (member: TeamData['members'][number] | undefined): void => {
|
||||
const pushMember = (member: TeamGraphData['members'][number] | undefined): void => {
|
||||
if (!member) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -315,7 +321,7 @@ export class TeamGraphAdapter {
|
|||
}
|
||||
|
||||
static #collectDuplicateStableOwnerIds(
|
||||
members: readonly TeamData['members'][number][]
|
||||
members: readonly TeamGraphData['members'][number][]
|
||||
): string[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const member of members) {
|
||||
|
|
@ -337,9 +343,9 @@ export class TeamGraphAdapter {
|
|||
}
|
||||
|
||||
static #getRuntimeLabel(
|
||||
providerId: TeamData['members'][number]['providerId'],
|
||||
model: TeamData['members'][number]['model'],
|
||||
effort: TeamData['members'][number]['effort']
|
||||
providerId: ResolvedTeamMember['providerId'],
|
||||
model: ResolvedTeamMember['model'],
|
||||
effort: ResolvedTeamMember['effort']
|
||||
): string | undefined {
|
||||
return formatTeamRuntimeSummary(providerId, model, effort);
|
||||
}
|
||||
|
|
@ -360,7 +366,7 @@ export class TeamGraphAdapter {
|
|||
#buildLeadNode(
|
||||
nodes: GraphNode[],
|
||||
leadId: string,
|
||||
data: TeamData,
|
||||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
leadName: string,
|
||||
pendingApprovalAgents?: Set<string>,
|
||||
|
|
@ -456,7 +462,7 @@ export class TeamGraphAdapter {
|
|||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
leadId: string,
|
||||
data: TeamData,
|
||||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>,
|
||||
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
||||
|
|
@ -560,14 +566,14 @@ export class TeamGraphAdapter {
|
|||
#buildTaskNodes(
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
data: TeamData,
|
||||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
commentReadState?: Record<string, unknown>,
|
||||
memberNodeIdByAlias?: ReadonlyMap<string, string>,
|
||||
leadId?: string,
|
||||
leadName?: string
|
||||
): void {
|
||||
const taskStateById = new Map<string, Pick<TeamData['tasks'][number], 'status'>>();
|
||||
const taskStateById = new Map<string, Pick<TeamGraphData['tasks'][number], 'status'>>();
|
||||
const taskDisplayIds = new Map<string, string>();
|
||||
const memberColorByName = new Map<string, string>();
|
||||
|
||||
|
|
@ -752,7 +758,7 @@ export class TeamGraphAdapter {
|
|||
#buildProcessNodes(
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
data: TeamData,
|
||||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
memberNodeIdByAlias?: ReadonlyMap<string, string>
|
||||
): void {
|
||||
|
|
@ -830,7 +836,7 @@ export class TeamGraphAdapter {
|
|||
|
||||
#attachActivityFeeds(
|
||||
nodes: GraphNode[],
|
||||
data: TeamData,
|
||||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
leadId: string,
|
||||
leadName: string
|
||||
|
|
@ -847,7 +853,10 @@ export class TeamGraphAdapter {
|
|||
}
|
||||
|
||||
const entriesByOwnerNodeId = buildInlineActivityEntries({
|
||||
data,
|
||||
data: {
|
||||
...data,
|
||||
messages: data.messageFeed,
|
||||
},
|
||||
teamName,
|
||||
leadId,
|
||||
leadName,
|
||||
|
|
@ -1008,7 +1017,7 @@ export class TeamGraphAdapter {
|
|||
|
||||
#buildCommentParticles(
|
||||
particles: GraphParticle[],
|
||||
data: TeamData,
|
||||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
leadId: string,
|
||||
leadName: string,
|
||||
|
|
@ -1101,8 +1110,8 @@ export class TeamGraphAdapter {
|
|||
}
|
||||
|
||||
static #buildMemberException(
|
||||
runtimeAdvisory: TeamData['members'][number]['runtimeAdvisory'],
|
||||
providerId: TeamData['members'][number]['providerId'],
|
||||
runtimeAdvisory: ResolvedTeamMember['runtimeAdvisory'],
|
||||
providerId: ResolvedTeamMember['providerId'],
|
||||
spawn: MemberSpawnStatusEntry | undefined,
|
||||
pendingApproval: boolean
|
||||
): Pick<GraphNode, 'exceptionTone' | 'exceptionLabel'> | undefined {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,34 @@
|
|||
import { useStore } from '@renderer/store';
|
||||
import { selectTeamDataForName } from '@renderer/store/slices/teamSlice';
|
||||
import {
|
||||
selectResolvedMembersForTeamName,
|
||||
selectTeamDataForName,
|
||||
selectTeamMessages,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { TeamData, TeamSummary } from '@shared/types/team';
|
||||
import type { TeamGraphData } from '../adapters/TeamGraphAdapter';
|
||||
import type { TeamSummary } from '@shared/types/team';
|
||||
|
||||
export function useGraphActivityContext(teamName: string): {
|
||||
teamData: TeamData | null;
|
||||
teamData: TeamGraphData | null;
|
||||
teams: TeamSummary[];
|
||||
} {
|
||||
return useStore(
|
||||
useShallow((state) => ({
|
||||
teamData: selectTeamDataForName(state, teamName),
|
||||
teams: state.teams,
|
||||
}))
|
||||
useShallow((state) => {
|
||||
const snapshot = selectTeamDataForName(state, teamName);
|
||||
const members = selectResolvedMembersForTeamName(state, teamName);
|
||||
const messages = selectTeamMessages(state, teamName);
|
||||
|
||||
return {
|
||||
teamData: snapshot
|
||||
? {
|
||||
...snapshot,
|
||||
members,
|
||||
messageFeed: messages,
|
||||
}
|
||||
: null,
|
||||
teams: state.teams,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@ import { useCallback, useMemo, useState } from 'react';
|
|||
import { api } from '@renderer/api';
|
||||
import { CreateTaskDialog } from '@renderer/components/team/dialogs/CreateTaskDialog';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { isTeamProvisioningActive, selectTeamDataForName } from '@renderer/store/slices/teamSlice';
|
||||
import {
|
||||
isTeamProvisioningActive,
|
||||
selectResolvedMembersForTeamName,
|
||||
selectTeamDataForName,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { TaskRef } from '@shared/types';
|
||||
|
|
@ -25,19 +29,17 @@ export function useGraphCreateTaskDialog(teamName: string): UseGraphCreateTaskDi
|
|||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const { teamData, createTeamTask, isTeamProvisioning } = useStore(
|
||||
const { teamData, activeMembers, createTeamTask, isTeamProvisioning } = useStore(
|
||||
useShallow((state) => ({
|
||||
teamData: selectTeamDataForName(state, teamName),
|
||||
activeMembers: selectResolvedMembersForTeamName(state, teamName).filter(
|
||||
(member) => !member.removedAt
|
||||
),
|
||||
createTeamTask: state.createTeamTask,
|
||||
isTeamProvisioning: isTeamProvisioningActive(state, teamName),
|
||||
}))
|
||||
);
|
||||
|
||||
const activeMembers = useMemo(
|
||||
() => (teamData?.members ?? []).filter((member) => !member.removedAt),
|
||||
[teamData?.members]
|
||||
);
|
||||
|
||||
const openCreateTaskDialog = useCallback((owner = ''): void => {
|
||||
setDialogState({
|
||||
open: true,
|
||||
|
|
|
|||
|
|
@ -1,19 +1,34 @@
|
|||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
getCurrentProvisioningProgressForTeam,
|
||||
selectResolvedMembersForTeamName,
|
||||
selectTeamDataForName,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import type { TeamGraphData } from '../adapters/TeamGraphAdapter';
|
||||
|
||||
export function useGraphMemberPopoverContext(teamName: string, memberName: string) {
|
||||
return useStore(
|
||||
useShallow((state) => ({
|
||||
teamData: teamName ? selectTeamDataForName(state, teamName) : null,
|
||||
spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined,
|
||||
leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined,
|
||||
progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null,
|
||||
memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined,
|
||||
memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined,
|
||||
}))
|
||||
useShallow((state) => {
|
||||
const snapshot = teamName ? selectTeamDataForName(state, teamName) : null;
|
||||
const teamMembers = teamName ? selectResolvedMembersForTeamName(state, teamName) : [];
|
||||
|
||||
return {
|
||||
teamData: snapshot
|
||||
? {
|
||||
...snapshot,
|
||||
members: teamMembers,
|
||||
messageFeed: [],
|
||||
}
|
||||
: null,
|
||||
teamMembers,
|
||||
spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined,
|
||||
leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined,
|
||||
progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null,
|
||||
memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined,
|
||||
memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,20 +10,25 @@ import { useStore } from '@renderer/store';
|
|||
import {
|
||||
getCurrentProvisioningProgressForTeam,
|
||||
isTeamGraphSlotPersistenceDisabled,
|
||||
selectResolvedMembersForTeamName,
|
||||
selectTeamDataForName,
|
||||
selectTeamMessages,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter';
|
||||
|
||||
import type { TeamGraphData } from '../adapters/TeamGraphAdapter';
|
||||
import type { GraphDataPort } from '@claude-teams/agent-graph';
|
||||
|
||||
export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||
const adapterRef = useRef<TeamGraphAdapter>(TeamGraphAdapter.create());
|
||||
|
||||
const {
|
||||
teamData,
|
||||
teamSnapshot,
|
||||
members,
|
||||
messages,
|
||||
spawnStatuses,
|
||||
leadActivity,
|
||||
leadContext,
|
||||
|
|
@ -38,7 +43,9 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
|||
ensureTeamGraphSlotAssignments,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
teamData: selectTeamDataForName(s, teamName),
|
||||
teamSnapshot: selectTeamDataForName(s, teamName),
|
||||
members: selectResolvedMembersForTeamName(s, teamName),
|
||||
messages: selectTeamMessages(s, teamName),
|
||||
spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
|
||||
leadActivity: teamName ? s.leadActivityByTeam[teamName] : undefined,
|
||||
leadContext: teamName ? s.leadContextByTeam[teamName] : undefined,
|
||||
|
|
@ -64,6 +71,17 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
|||
return agents;
|
||||
}, [pendingApprovals, teamName]);
|
||||
|
||||
const teamData = useMemo<TeamGraphData | null>(() => {
|
||||
if (!teamSnapshot) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...teamSnapshot,
|
||||
members,
|
||||
messageFeed: messages,
|
||||
};
|
||||
}, [members, messages, teamSnapshot]);
|
||||
|
||||
const commentReadState = useSyncExternalStore(subscribe, getSnapshot);
|
||||
|
||||
const effectiveSlotAssignments = useMemo(() => {
|
||||
|
|
@ -97,9 +115,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
|||
const currentAssignment = slotAssignments[stableOwnerId];
|
||||
const defaultAssignment = defaultSeed.assignments[stableOwnerId];
|
||||
return (
|
||||
currentAssignment &&
|
||||
defaultAssignment &&
|
||||
currentAssignment.ringIndex === defaultAssignment.ringIndex &&
|
||||
currentAssignment?.ringIndex === defaultAssignment?.ringIndex &&
|
||||
currentAssignment.sectorIndex === defaultAssignment.sectorIndex
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamSlice';
|
||||
|
||||
import { parseGraphMemberNodeId } from '../../core/domain/graphOwnerIdentity';
|
||||
|
||||
|
|
@ -8,6 +9,7 @@ import type { GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
|
|||
|
||||
export function useTeamGraphSurfaceActions(teamName: string): {
|
||||
openTeamPage: () => void;
|
||||
resetOwnerSlotAssignmentsToDefaults: () => void;
|
||||
commitOwnerSlotDrop: (payload: {
|
||||
nodeId: string;
|
||||
assignment: GraphOwnerSlotAssignment;
|
||||
|
|
@ -19,6 +21,13 @@ export function useTeamGraphSurfaceActions(teamName: string): {
|
|||
useStore.getState().openTeamTab(teamName);
|
||||
}, [teamName]);
|
||||
|
||||
const resetOwnerSlotAssignmentsToDefaults = useCallback(() => {
|
||||
if (!isTeamGraphSlotPersistenceDisabled()) {
|
||||
return;
|
||||
}
|
||||
useStore.getState().resetTeamGraphSlotAssignmentsToDefaults(teamName);
|
||||
}, [teamName]);
|
||||
|
||||
const commitOwnerSlotDrop = useCallback(
|
||||
(payload: {
|
||||
nodeId: string;
|
||||
|
|
@ -51,6 +60,7 @@ export function useTeamGraphSurfaceActions(teamName: string): {
|
|||
|
||||
return {
|
||||
openTeamPage,
|
||||
resetOwnerSlotAssignmentsToDefaults,
|
||||
commitOwnerSlotDrop,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
* into ui/, hooks/, or core/ directly.
|
||||
*/
|
||||
|
||||
export type { InlineActivityEntry } from '../core/domain/buildInlineActivityEntries';
|
||||
export { buildInlineActivityEntries } from '../core/domain/buildInlineActivityEntries';
|
||||
export { buildGraphMemberNodeIdForMember } from '../core/domain/graphOwnerIdentity';
|
||||
export { TeamGraphAdapter } from './adapters/TeamGraphAdapter';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
|
||||
import {
|
||||
resolveMessageRenderProps,
|
||||
type MessageContext,
|
||||
resolveMessageRenderProps,
|
||||
} from '@renderer/components/team/activity/activityMessageContext';
|
||||
|
||||
import type {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
type InlineActivityEntry,
|
||||
} from '../../core/domain/buildInlineActivityEntries';
|
||||
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
|
||||
|
||||
import { GraphActivityCard } from './GraphActivityCard';
|
||||
|
||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||
|
|
@ -74,6 +75,9 @@ export const GraphActivityHud = ({
|
|||
const connectorPathRefs = useRef(new Map<string, SVGPathElement | null>());
|
||||
const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(null);
|
||||
const { teamData, teams } = useGraphActivityContext(teamName);
|
||||
const teamSnapshot = teamData;
|
||||
const members = teamData?.members ?? [];
|
||||
const messages = teamData?.messageFeed ?? [];
|
||||
|
||||
const ownerNodes = useMemo(
|
||||
() =>
|
||||
|
|
@ -84,21 +88,27 @@ export const GraphActivityHud = ({
|
|||
[nodes]
|
||||
);
|
||||
const leadNodeId = ownerNodes.find((node) => node.kind === 'lead')?.id ?? `lead:${teamName}`;
|
||||
const leadName = teamData ? getGraphLeadMemberName(teamData, teamName) : `${teamName}-lead`;
|
||||
const leadName = teamSnapshot
|
||||
? getGraphLeadMemberName({ members }, teamName)
|
||||
: `${teamName}-lead`;
|
||||
const ownerNodeIds = useMemo(() => new Set(ownerNodes.map((node) => node.id)), [ownerNodes]);
|
||||
const entryMapByOwnerNodeId = useMemo(() => {
|
||||
if (!teamData) {
|
||||
if (!teamSnapshot) {
|
||||
return new Map<string, InlineActivityEntry[]>();
|
||||
}
|
||||
return buildInlineActivityEntries({
|
||||
data: teamData,
|
||||
data: {
|
||||
members,
|
||||
tasks: teamSnapshot.tasks,
|
||||
messages,
|
||||
},
|
||||
teamName,
|
||||
leadId: leadNodeId,
|
||||
leadName,
|
||||
ownerNodeIds,
|
||||
});
|
||||
}, [leadName, leadNodeId, ownerNodeIds, teamData, teamName]);
|
||||
const messageContext = useMemo(() => buildMessageContext(teamData?.members), [teamData?.members]);
|
||||
}, [leadName, leadNodeId, members, messages, ownerNodeIds, teamName, teamSnapshot]);
|
||||
const messageContext = useMemo(() => buildMessageContext(members), [members]);
|
||||
const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams);
|
||||
const { readSet } = useTeamMessagesRead(teamName);
|
||||
|
||||
|
|
@ -350,7 +360,7 @@ export const GraphActivityHud = ({
|
|||
};
|
||||
}, [enabled, forwardWheelToGraph, visibleLanes]);
|
||||
|
||||
if (!enabled || !teamData || visibleLanes.length === 0) {
|
||||
if (!enabled || !teamSnapshot || visibleLanes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -477,7 +487,7 @@ export const GraphActivityHud = ({
|
|||
}
|
||||
}}
|
||||
teamName={teamName}
|
||||
members={teamData.members}
|
||||
members={members}
|
||||
onMemberClick={handleMemberClick}
|
||||
onTaskIdClick={onOpenTaskDetail}
|
||||
teamNames={teamNames}
|
||||
|
|
|
|||
|
|
@ -292,14 +292,21 @@ const MemberPopoverContent = ({
|
|||
? node.domainRef.teamName
|
||||
: '';
|
||||
const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64);
|
||||
const { teamData, spawnEntry, leadActivity, progress, memberSpawnSnapshot, memberSpawnStatuses } =
|
||||
useGraphMemberPopoverContext(teamName, memberName);
|
||||
const member = teamData?.members.find((candidate) => candidate.name === memberName) ?? null;
|
||||
const {
|
||||
teamData,
|
||||
teamMembers,
|
||||
spawnEntry,
|
||||
leadActivity,
|
||||
progress,
|
||||
memberSpawnSnapshot,
|
||||
memberSpawnStatuses,
|
||||
} = useGraphMemberPopoverContext(teamName, memberName);
|
||||
const member = teamMembers.find((candidate) => candidate.name === memberName) ?? null;
|
||||
const provisioningPresentation =
|
||||
teamData && teamName
|
||||
? buildTeamProvisioningPresentation({
|
||||
progress,
|
||||
members: teamData.members,
|
||||
members: teamMembers,
|
||||
memberSpawnStatuses,
|
||||
memberSpawnSnapshot,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -89,7 +89,6 @@ export const TeamGraphOverlay = ({
|
|||
const openCreateTask = useCallback(() => {
|
||||
openCreateTaskDialog('');
|
||||
}, [openCreateTaskDialog]);
|
||||
|
||||
const events: GraphEventPort = {
|
||||
onNodeDoubleClick: useCallback(
|
||||
(ref: GraphDomainRef) => {
|
||||
|
|
|
|||
|
|
@ -79,7 +79,6 @@ export const TeamGraphTab = ({
|
|||
const openCreateTask = useCallback(() => {
|
||||
openCreateTaskDialog('');
|
||||
}, [openCreateTaskDialog]);
|
||||
|
||||
// Task action dispatchers
|
||||
const dispatchTaskAction = useCallback(
|
||||
(action: string) => (taskId: string) =>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||
import { type DashboardRecentProject } from '@features/recent-projects/contracts';
|
||||
import { api, isElectronMode } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildTaskCountsByProject, normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
||||
import { buildTaskCountsByProject } from '@renderer/utils/pathNormalize';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter';
|
||||
import { buildActiveTeamsByProject } from '../utils/activeProjectTeams';
|
||||
import {
|
||||
sortRecentProjectsByDisplayPriority,
|
||||
subscribeRecentProjectOpenHistory,
|
||||
|
|
@ -62,16 +64,27 @@ export function useRecentProjectsSection(
|
|||
openProjectPath: (projectPath: string) => Promise<void>;
|
||||
selectProjectFolder: () => Promise<void>;
|
||||
} {
|
||||
const { globalTasks, globalTasksInitialized, globalTasksLoading, fetchAllTasks, teams } =
|
||||
useStore(
|
||||
useShallow((state) => ({
|
||||
globalTasks: state.globalTasks,
|
||||
globalTasksInitialized: state.globalTasksInitialized,
|
||||
globalTasksLoading: state.globalTasksLoading,
|
||||
fetchAllTasks: state.fetchAllTasks,
|
||||
teams: state.teams,
|
||||
}))
|
||||
);
|
||||
const {
|
||||
globalTasks,
|
||||
globalTasksInitialized,
|
||||
globalTasksLoading,
|
||||
fetchAllTasks,
|
||||
teams,
|
||||
provisioningRuns,
|
||||
currentProvisioningRunIdByTeam,
|
||||
provisioningSnapshotByTeam,
|
||||
} = useStore(
|
||||
useShallow((state) => ({
|
||||
globalTasks: state.globalTasks,
|
||||
globalTasksInitialized: state.globalTasksInitialized,
|
||||
globalTasksLoading: state.globalTasksLoading,
|
||||
fetchAllTasks: state.fetchAllTasks,
|
||||
teams: state.teams,
|
||||
provisioningRuns: state.provisioningRuns,
|
||||
currentProvisioningRunIdByTeam: state.currentProvisioningRunIdByTeam,
|
||||
provisioningSnapshotByTeam: state.provisioningSnapshotByTeam,
|
||||
}))
|
||||
);
|
||||
const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []);
|
||||
const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject();
|
||||
const [recentProjects, setRecentProjects] = useState<DashboardRecentProject[]>(
|
||||
|
|
@ -92,6 +105,21 @@ export function useRecentProjectsSection(
|
|||
const recentProjectsRef = useRef<DashboardRecentProject[]>(
|
||||
initialSnapshot?.payload.projects ?? []
|
||||
);
|
||||
const provisioningState = useMemo(
|
||||
() => ({ currentProvisioningRunIdByTeam, provisioningRuns }),
|
||||
[currentProvisioningRunIdByTeam, provisioningRuns]
|
||||
);
|
||||
const provisioningTeamNames = useMemo(
|
||||
() =>
|
||||
Object.keys(currentProvisioningRunIdByTeam).filter((teamName) =>
|
||||
isTeamProvisioningActive(provisioningState, teamName)
|
||||
),
|
||||
[currentProvisioningRunIdByTeam, provisioningState]
|
||||
);
|
||||
const provisioningTeamNamesKey = useMemo(
|
||||
() => [...provisioningTeamNames].sort().join('\u0000'),
|
||||
[provisioningTeamNames]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
recentProjectsRef.current = recentProjects;
|
||||
|
|
@ -173,7 +201,7 @@ export function useRecentProjectsSection(
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [teams]);
|
||||
}, [provisioningTeamNamesKey, teams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
|
|
@ -189,25 +217,13 @@ export function useRecentProjectsSection(
|
|||
const taskCountsByProject = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]);
|
||||
|
||||
const activeTeamsByProject = useMemo(() => {
|
||||
const aliveSet = new Set(aliveTeams);
|
||||
const teamsByProject = new Map<string, TeamSummary[]>();
|
||||
|
||||
for (const team of teams) {
|
||||
if (!team.projectPath || !aliveSet.has(team.teamName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = normalizePath(team.projectPath);
|
||||
const existing = teamsByProject.get(key);
|
||||
if (existing) {
|
||||
existing.push(team);
|
||||
} else {
|
||||
teamsByProject.set(key, [team]);
|
||||
}
|
||||
}
|
||||
|
||||
return teamsByProject;
|
||||
}, [aliveTeams, teams]);
|
||||
return buildActiveTeamsByProject({
|
||||
teams,
|
||||
aliveTeamNames: aliveTeams,
|
||||
provisioningTeamNames,
|
||||
provisioningSnapshotByTeam,
|
||||
});
|
||||
}, [aliveTeams, provisioningSnapshotByTeam, provisioningTeamNames, teams]);
|
||||
|
||||
const decoratedCards = useMemo(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -157,7 +157,7 @@ function resolveHistoryOpenedAt(lookup: HistoryLookup, projectPath: string): num
|
|||
}
|
||||
|
||||
const foldedMatch = lookup.folded.get(foldHistoryPath(normalizedPath));
|
||||
if (!foldedMatch || foldedMatch.exactPaths.size !== 1) {
|
||||
if (foldedMatch?.exactPaths.size !== 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -471,7 +471,7 @@ export class TmuxWslService {
|
|||
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', POWERSHELL_FEATURE_QUERY],
|
||||
6_000
|
||||
);
|
||||
if (!result || result.exitCode !== 0 || !result.stdout.trim()) {
|
||||
if (result?.exitCode !== 0 || !result.stdout.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ import {
|
|||
} from './services/extensions';
|
||||
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||
import { clearAutoResumeService } from './services/team/AutoResumeService';
|
||||
import {
|
||||
buildTeamControlApiBaseUrl,
|
||||
clearTeamControlApiState,
|
||||
|
|
@ -100,7 +101,6 @@ import {
|
|||
type TeamReconcileTrigger,
|
||||
} from './services/team/TeamReconcileDrainScheduler';
|
||||
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
|
||||
import { clearAutoResumeService } from './services/team/AutoResumeService';
|
||||
import { getAppIconPath } from './utils/appIcon';
|
||||
import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder';
|
||||
import {
|
||||
|
|
@ -564,6 +564,13 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
const teamName = row.teamName.trim();
|
||||
const detail = typeof row.detail === 'string' ? row.detail : '';
|
||||
|
||||
if (
|
||||
teamDataService &&
|
||||
(row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config')
|
||||
) {
|
||||
teamDataService.invalidateMessageFeed(teamName);
|
||||
}
|
||||
|
||||
// --- Inbox change events: relay to lead + native OS notifications ---
|
||||
if (row.type === 'inbox') {
|
||||
if (reconcileScheduler) {
|
||||
|
|
@ -906,6 +913,12 @@ async function initializeServices(): Promise<void> {
|
|||
});
|
||||
|
||||
const forwardTeamChange = (event: TeamChangeEvent): void => {
|
||||
if (
|
||||
teamDataService &&
|
||||
(event.type === 'inbox' || event.type === 'lead-message' || event.type === 'config')
|
||||
) {
|
||||
teamDataService.invalidateMessageFeed(event.teamName);
|
||||
}
|
||||
safeSendToRenderer(mainWindow, TEAM_CHANGE, event);
|
||||
httpServer?.broadcast('team-change', event);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,13 +16,14 @@ import {
|
|||
TEAM_DELETE_DRAFT,
|
||||
TEAM_DELETE_TASK_ATTACHMENT,
|
||||
TEAM_DELETE_TEAM,
|
||||
TEAM_GET_AGENT_RUNTIME,
|
||||
TEAM_GET_ALL_TASKS,
|
||||
TEAM_GET_ATTACHMENTS,
|
||||
TEAM_GET_AGENT_RUNTIME,
|
||||
TEAM_GET_CLAUDE_LOGS,
|
||||
TEAM_GET_DATA,
|
||||
TEAM_GET_DELETED_TASKS,
|
||||
TEAM_GET_LOGS_FOR_TASK,
|
||||
TEAM_GET_MEMBER_ACTIVITY_META,
|
||||
TEAM_GET_MEMBER_LOGS,
|
||||
TEAM_GET_MEMBER_STATS,
|
||||
TEAM_GET_MESSAGES_PAGE,
|
||||
|
|
@ -59,8 +60,8 @@ import {
|
|||
TEAM_SEND_MESSAGE,
|
||||
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
||||
TEAM_SET_PROJECT_BRANCH_TRACKING,
|
||||
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
||||
TEAM_SET_TASK_CLARIFICATION,
|
||||
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
||||
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
||||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||||
TEAM_SOFT_DELETE_TASK,
|
||||
|
|
@ -96,7 +97,7 @@ import {
|
|||
parseStandaloneSlashCommand,
|
||||
} from '@shared/utils/slashCommands';
|
||||
import crypto from 'crypto';
|
||||
import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
|
||||
import { app, BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
|
|
@ -166,7 +167,6 @@ import type {
|
|||
LeadContextUsageSnapshot,
|
||||
MemberFullStats,
|
||||
MemberLogSummary,
|
||||
TeamAgentRuntimeSnapshot,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
MessagesPage,
|
||||
SendMessageRequest,
|
||||
|
|
@ -174,15 +174,16 @@ import type {
|
|||
TaskAttachmentMeta,
|
||||
TaskComment,
|
||||
TaskRef,
|
||||
TeamAgentRuntimeSnapshot,
|
||||
TeamClaudeLogsQuery,
|
||||
TeamClaudeLogsResponse,
|
||||
TeamConfig,
|
||||
TeamCreateConfigRequest,
|
||||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
TeamData,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamMemberActivityMeta,
|
||||
TeamMessageNotificationData,
|
||||
TeamProvisioningPrepareResult,
|
||||
TeamProvisioningProgress,
|
||||
|
|
@ -190,6 +191,7 @@ import type {
|
|||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
TeamUpdateConfigRequest,
|
||||
TeamViewSnapshot,
|
||||
ToolApprovalFileContent,
|
||||
ToolApprovalSettings,
|
||||
UpdateKanbanPatch,
|
||||
|
|
@ -206,6 +208,16 @@ const logger = createLogger('IPC:teams');
|
|||
const seenRateLimitKeys = new Set<string>();
|
||||
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
|
||||
|
||||
function noteHeavyTeamDataWorkerFallback(operation: string): void {
|
||||
if (!app.isPackaged) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error(
|
||||
`[${operation}] team-data-worker unavailable in packaged runtime; falling back to main-thread execution for heavy message/activity path`
|
||||
);
|
||||
}
|
||||
|
||||
async function getDurableLeadTeammateRoster(
|
||||
teamName: string,
|
||||
leadName: string
|
||||
|
|
@ -436,6 +448,19 @@ function checkApiErrorMessages(
|
|||
}
|
||||
}
|
||||
|
||||
function scanTeamMessageNotifications(
|
||||
messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
|
||||
teamName: string,
|
||||
teamDisplayName: string,
|
||||
projectPath?: string
|
||||
): void {
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
checkRateLimitMessages(messages, teamName, teamDisplayName, projectPath);
|
||||
checkApiErrorMessages(messages, teamName, teamDisplayName, projectPath);
|
||||
}
|
||||
|
||||
let teamDataService: TeamDataService | null = null;
|
||||
let teamProvisioningService: TeamProvisioningService | null = null;
|
||||
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
|
||||
|
|
@ -519,6 +544,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning);
|
||||
ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage);
|
||||
ipcMain.handle(TEAM_GET_MESSAGES_PAGE, handleGetMessagesPage);
|
||||
ipcMain.handle(TEAM_GET_MEMBER_ACTIVITY_META, handleGetMemberActivityMeta);
|
||||
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
|
||||
ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview);
|
||||
ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban);
|
||||
|
|
@ -595,6 +621,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING);
|
||||
ipcMain.removeHandler(TEAM_SEND_MESSAGE);
|
||||
ipcMain.removeHandler(TEAM_GET_MESSAGES_PAGE);
|
||||
ipcMain.removeHandler(TEAM_GET_MEMBER_ACTIVITY_META);
|
||||
ipcMain.removeHandler(TEAM_CREATE_TASK);
|
||||
ipcMain.removeHandler(TEAM_REQUEST_REVIEW);
|
||||
ipcMain.removeHandler(TEAM_UPDATE_KANBAN);
|
||||
|
|
@ -772,14 +799,14 @@ async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<Te
|
|||
async function handleGetData(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
): Promise<IpcResult<TeamData>> {
|
||||
): Promise<IpcResult<TeamViewSnapshot>> {
|
||||
const validated = validateTeamName(teamName);
|
||||
if (!validated.valid) {
|
||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
}
|
||||
const tn = validated.value!;
|
||||
const startedAt = Date.now();
|
||||
let data: TeamData;
|
||||
let data: TeamViewSnapshot;
|
||||
setCurrentMainOp('team:getData');
|
||||
try {
|
||||
// Prefer worker thread to keep main event loop responsive
|
||||
|
|
@ -791,9 +818,11 @@ async function handleGetData(
|
|||
logger.warn(
|
||||
`[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
|
||||
);
|
||||
noteHeavyTeamDataWorkerFallback('teams:getData');
|
||||
data = await getTeamDataService().getTeamData(tn);
|
||||
}
|
||||
} else {
|
||||
noteHeavyTeamDataWorkerFallback('teams:getData');
|
||||
data = await getTeamDataService().getTeamData(tn);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -833,22 +862,30 @@ async function handleGetData(
|
|||
|
||||
const displayName = data.config.name || tn;
|
||||
const projectPath = data.config.projectPath;
|
||||
|
||||
const live = provisioning.getLiveLeadProcessMessages(tn);
|
||||
const durableMessages = Array.isArray((data as { messages?: unknown }).messages)
|
||||
? ((data as { messages?: typeof live }).messages ?? [])
|
||||
: [];
|
||||
|
||||
if (live.length === 0) {
|
||||
checkRateLimitMessages(
|
||||
data.messages,
|
||||
tn,
|
||||
displayName,
|
||||
projectPath,
|
||||
isAlive,
|
||||
currentLeadSessionId
|
||||
);
|
||||
checkApiErrorMessages(data.messages, tn, displayName, projectPath);
|
||||
if (durableMessages.length > 0) {
|
||||
checkRateLimitMessages(
|
||||
durableMessages,
|
||||
tn,
|
||||
displayName,
|
||||
projectPath,
|
||||
isAlive,
|
||||
currentLeadSessionId
|
||||
);
|
||||
checkApiErrorMessages(durableMessages, tn, displayName, projectPath);
|
||||
} else {
|
||||
scanTeamMessageNotifications(live, tn, displayName, projectPath);
|
||||
}
|
||||
return { success: true, data: { ...data, isAlive } };
|
||||
}
|
||||
let merged = mergeLiveLeadProcessMessages(data.messages, live);
|
||||
if (data.messages.length >= 50) {
|
||||
|
||||
let merged = mergeLiveLeadProcessMessages(durableMessages, live);
|
||||
if (durableMessages.length >= 50) {
|
||||
try {
|
||||
const newestPage = await teamDataService.getMessagesPage(tn, {
|
||||
limit: 50,
|
||||
|
|
@ -866,7 +903,7 @@ async function handleGetData(
|
|||
|
||||
checkRateLimitMessages(merged, tn, displayName, projectPath, isAlive, currentLeadSessionId);
|
||||
checkApiErrorMessages(merged, tn, displayName, projectPath);
|
||||
return { success: true, data: { ...data, isAlive, messages: merged } };
|
||||
return { success: true, data: { ...data, isAlive } };
|
||||
}
|
||||
|
||||
async function handleGetTaskChangePresence(
|
||||
|
|
@ -1767,19 +1804,89 @@ async function handleGetMessagesPage(
|
|||
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||
}
|
||||
const opts = (options && typeof options === 'object' ? options : {}) as {
|
||||
beforeTimestamp?: string;
|
||||
cursor?: string | null;
|
||||
limit?: number;
|
||||
};
|
||||
const limit = Math.min(Math.max(1, opts.limit ?? 50), 200);
|
||||
const beforeTimestamp =
|
||||
typeof opts.beforeTimestamp === 'string' ? opts.beforeTimestamp : undefined;
|
||||
const cursor =
|
||||
typeof opts.cursor === 'string' ? opts.cursor : opts.cursor === null ? null : undefined;
|
||||
|
||||
return wrapTeamHandler('getMessagesPage', async () => {
|
||||
const service = getTeamDataService();
|
||||
const liveMessages = beforeTimestamp
|
||||
? undefined
|
||||
: getTeamProvisioningService().getLiveLeadProcessMessages(vTeam.value!);
|
||||
return service.getMessagesPage(vTeam.value!, { beforeTimestamp, limit, liveMessages });
|
||||
let page: MessagesPage;
|
||||
const notificationContext = await getTeamDataService().getTeamNotificationContext(vTeam.value!);
|
||||
const liveMessages =
|
||||
cursor == null ? getTeamProvisioningService().getLiveLeadProcessMessages(vTeam.value!) : [];
|
||||
|
||||
if (liveMessages.length > 0) {
|
||||
page = await getTeamDataService().getMessagesPage(vTeam.value!, {
|
||||
cursor,
|
||||
limit,
|
||||
liveMessages,
|
||||
});
|
||||
scanTeamMessageNotifications(
|
||||
page.messages,
|
||||
vTeam.value!,
|
||||
notificationContext.displayName,
|
||||
notificationContext.projectPath
|
||||
);
|
||||
return page;
|
||||
}
|
||||
|
||||
const worker = getTeamDataWorkerClient();
|
||||
if (worker.isAvailable()) {
|
||||
try {
|
||||
page = await worker.getMessagesPage(vTeam.value!, { cursor, limit });
|
||||
scanTeamMessageNotifications(
|
||||
page.messages,
|
||||
vTeam.value!,
|
||||
notificationContext.displayName,
|
||||
notificationContext.projectPath
|
||||
);
|
||||
return page;
|
||||
} catch (workerErr) {
|
||||
logger.warn(
|
||||
`[teams:getMessagesPage] worker failed, falling back: ${
|
||||
workerErr instanceof Error ? workerErr.message : workerErr
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
noteHeavyTeamDataWorkerFallback('teams:getMessagesPage');
|
||||
page = await getTeamDataService().getMessagesPage(vTeam.value!, { cursor, limit });
|
||||
scanTeamMessageNotifications(
|
||||
page.messages,
|
||||
vTeam.value!,
|
||||
notificationContext.displayName,
|
||||
notificationContext.projectPath
|
||||
);
|
||||
return page;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleGetMemberActivityMeta(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
): Promise<IpcResult<TeamMemberActivityMeta>> {
|
||||
const vTeam = validateTeamName(teamName);
|
||||
if (!vTeam.valid) {
|
||||
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||
}
|
||||
|
||||
return wrapTeamHandler('getMemberActivityMeta', async () => {
|
||||
const worker = getTeamDataWorkerClient();
|
||||
if (worker.isAvailable()) {
|
||||
try {
|
||||
return await worker.getMemberActivityMeta(vTeam.value!);
|
||||
} catch (workerErr) {
|
||||
logger.warn(
|
||||
`[teams:getMemberActivityMeta] worker failed, falling back: ${
|
||||
workerErr instanceof Error ? workerErr.message : workerErr
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
noteHeavyTeamDataWorkerFallback('teams:getMemberActivityMeta');
|
||||
return getTeamDataService().getMemberActivityMeta(vTeam.value!);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ export class ApiKeyService {
|
|||
);
|
||||
}
|
||||
if (!request.value) throw new Error('Key value is required');
|
||||
if (request.scope === 'project' && (!request.projectPath || !request.projectPath.trim())) {
|
||||
if (request.scope === 'project' && !request.projectPath?.trim()) {
|
||||
throw new Error('Project-scoped API keys require a project path');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ async function buildManagementCliEnvForBinary(binaryPath: string): Promise<NodeJ
|
|||
});
|
||||
return env;
|
||||
}
|
||||
|
||||
export interface ExtensionsRuntimeAdapter {
|
||||
readonly flavor: CliFlavor;
|
||||
buildManagementCliEnv(binaryPath: string): Promise<NodeJS.ProcessEnv>;
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ function isSensitiveCliFlag(flag: string): boolean {
|
|||
const normalizedFlag = flag.toLowerCase().replace(/^--/, '').replace(/[-_]/g, '');
|
||||
return SENSITIVE_FLAG_NAMES.has(normalizedFlag);
|
||||
}
|
||||
|
||||
function extractJsonObject<T>(raw: string): T {
|
||||
const trimmed = raw.trim();
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -515,8 +515,7 @@ export class ConfigManager {
|
|||
ignoredRepositories:
|
||||
loadedNotifications.ignoredRepositories ??
|
||||
DEFAULT_CONFIG.notifications.ignoredRepositories,
|
||||
snoozedUntil:
|
||||
loadedNotifications.snoozedUntil ?? DEFAULT_CONFIG.notifications.snoozedUntil,
|
||||
snoozedUntil: loadedNotifications.snoozedUntil ?? DEFAULT_CONFIG.notifications.snoozedUntil,
|
||||
snoozeMinutes:
|
||||
loadedNotifications.snoozeMinutes ?? DEFAULT_CONFIG.notifications.snoozeMinutes,
|
||||
includeSubagentErrors:
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ function classifyFailedProbe(
|
|||
|
||||
export class CliProviderModelAvailabilityService {
|
||||
private readonly cache = new Map<string, ProviderModelAvailabilityCacheEntry>();
|
||||
private readonly queue: Array<() => void> = [];
|
||||
private readonly queue: (() => void)[] = [];
|
||||
private activeProbeCount = 0;
|
||||
|
||||
constructor(private readonly onUpdate?: ProviderAvailabilityUpdateHandler) {}
|
||||
|
|
|
|||
128
src/main/services/team/MemberActivityMetaService.ts
Normal file
128
src/main/services/team/MemberActivityMetaService.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import type { TeamMessageFeedService } from './TeamMessageFeedService';
|
||||
import type { InboxMessage, MemberActivityMetaEntry, TeamMemberActivityMeta } from '@shared/types';
|
||||
|
||||
interface MemberActivityMetaCacheEntry {
|
||||
feedRevision: string;
|
||||
meta: TeamMemberActivityMeta;
|
||||
}
|
||||
|
||||
function messageSignalsTermination(message: InboxMessage | null | undefined): boolean {
|
||||
if (!message) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(message.text) as {
|
||||
type?: string;
|
||||
approve?: boolean;
|
||||
approved?: boolean;
|
||||
};
|
||||
return (
|
||||
(parsed.type === 'shutdown_response' &&
|
||||
(parsed.approve === true || parsed.approved === true)) ||
|
||||
parsed.type === 'shutdown_approved'
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function areMemberActivityEntriesEqual(
|
||||
left: MemberActivityMetaEntry | undefined,
|
||||
right: MemberActivityMetaEntry
|
||||
): boolean {
|
||||
if (!left) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
left.memberName === right.memberName &&
|
||||
left.lastAuthoredMessageAt === right.lastAuthoredMessageAt &&
|
||||
left.messageCountExact === right.messageCountExact &&
|
||||
left.latestAuthoredMessageSignalsTermination === right.latestAuthoredMessageSignalsTermination
|
||||
);
|
||||
}
|
||||
|
||||
function structurallyShareMemberFacts(
|
||||
previous: Record<string, MemberActivityMetaEntry> | undefined,
|
||||
next: Record<string, MemberActivityMetaEntry>
|
||||
): Record<string, MemberActivityMetaEntry> {
|
||||
if (!previous) {
|
||||
return next;
|
||||
}
|
||||
|
||||
const nextKeys = Object.keys(next);
|
||||
const previousKeys = Object.keys(previous);
|
||||
let changed = nextKeys.length !== previousKeys.length;
|
||||
const shared: Record<string, MemberActivityMetaEntry> = {};
|
||||
|
||||
for (const key of nextKeys) {
|
||||
const nextEntry = next[key];
|
||||
const previousEntry = previous[key];
|
||||
if (!areMemberActivityEntriesEqual(previousEntry, nextEntry)) {
|
||||
changed = true;
|
||||
shared[key] = nextEntry;
|
||||
continue;
|
||||
}
|
||||
shared[key] = previousEntry;
|
||||
}
|
||||
|
||||
return changed ? shared : previous;
|
||||
}
|
||||
|
||||
export class MemberActivityMetaService {
|
||||
private readonly cacheByTeam = new Map<string, MemberActivityMetaCacheEntry>();
|
||||
|
||||
constructor(private readonly feedService: TeamMessageFeedService) {}
|
||||
|
||||
invalidate(teamName: string): void {
|
||||
this.cacheByTeam.delete(teamName);
|
||||
}
|
||||
|
||||
async getMeta(teamName: string): Promise<TeamMemberActivityMeta> {
|
||||
const feed = await this.feedService.getFeed(teamName);
|
||||
const cached = this.cacheByTeam.get(teamName);
|
||||
if (cached?.feedRevision === feed.feedRevision) {
|
||||
return cached.meta;
|
||||
}
|
||||
|
||||
const latestByMember = new Map<string, InboxMessage>();
|
||||
const countsByMember = new Map<string, number>();
|
||||
|
||||
for (const message of feed.messages) {
|
||||
const memberName = typeof message.from === 'string' ? message.from.trim() : '';
|
||||
if (!memberName || memberName === 'user' || memberName === 'system') {
|
||||
continue;
|
||||
}
|
||||
|
||||
countsByMember.set(memberName, (countsByMember.get(memberName) ?? 0) + 1);
|
||||
if (!latestByMember.has(memberName)) {
|
||||
latestByMember.set(memberName, message);
|
||||
}
|
||||
}
|
||||
|
||||
const nextMembers = Object.fromEntries(
|
||||
Array.from(new Set([...countsByMember.keys(), ...latestByMember.keys()]))
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.map((memberName) => {
|
||||
const latestMessage = latestByMember.get(memberName) ?? null;
|
||||
return [
|
||||
memberName,
|
||||
{
|
||||
memberName,
|
||||
lastAuthoredMessageAt: latestMessage?.timestamp ?? null,
|
||||
messageCountExact: countsByMember.get(memberName) ?? 0,
|
||||
latestAuthoredMessageSignalsTermination: messageSignalsTermination(latestMessage),
|
||||
},
|
||||
] as const;
|
||||
})
|
||||
);
|
||||
const members = structurallyShareMemberFacts(cached?.meta.members, nextMembers);
|
||||
|
||||
const meta: TeamMemberActivityMeta = {
|
||||
teamName,
|
||||
computedAt: new Date().toISOString(),
|
||||
members,
|
||||
feedRevision: feed.feedRevision,
|
||||
};
|
||||
|
||||
this.cacheByTeam.set(teamName, { feedRevision: feed.feedRevision, meta });
|
||||
return meta;
|
||||
}
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ import {
|
|||
} from './cache/LeadSessionParseCache';
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
|
||||
import { MemberActivityMetaService } from './MemberActivityMetaService';
|
||||
import {
|
||||
getLiveLeadProcessMessageKey,
|
||||
mergeLiveLeadProcessMessages,
|
||||
|
|
@ -44,6 +45,7 @@ import { TeamKanbanManager } from './TeamKanbanManager';
|
|||
import { TeamMemberResolver } from './TeamMemberResolver';
|
||||
import { TeamMemberRuntimeAdvisoryService } from './TeamMemberRuntimeAdvisoryService';
|
||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||
import { TeamMessageFeedService } from './TeamMessageFeedService';
|
||||
import { TeamMetaStore } from './TeamMetaStore';
|
||||
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||
import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal';
|
||||
|
|
@ -63,7 +65,6 @@ import type {
|
|||
KanbanColumnId,
|
||||
KanbanState,
|
||||
MessagesPage,
|
||||
ResolvedTeamMember,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
TaskAttachmentMeta,
|
||||
|
|
@ -72,13 +73,14 @@ import type {
|
|||
TaskRef,
|
||||
TeamConfig,
|
||||
TeamCreateConfigRequest,
|
||||
TeamData,
|
||||
TeamMember,
|
||||
TeamMemberActivityMeta,
|
||||
TeamProcess,
|
||||
TeamSummary,
|
||||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
TeamTaskWithKanban,
|
||||
TeamViewSnapshot,
|
||||
ToolCallMeta,
|
||||
UpdateKanbanPatch,
|
||||
} from '@shared/types';
|
||||
|
|
@ -96,6 +98,14 @@ const TASK_MAP_YIELD_EVERY = 250;
|
|||
const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification';
|
||||
const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
|
||||
|
||||
function requireCanonicalMessageId(message: InboxMessage): string {
|
||||
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
|
||||
if (messageId.length > 0) {
|
||||
return messageId;
|
||||
}
|
||||
throw new Error('Canonical team message is missing effective messageId');
|
||||
}
|
||||
|
||||
interface EligibleTaskCommentNotification {
|
||||
key: string;
|
||||
messageId: string;
|
||||
|
|
@ -160,6 +170,8 @@ export class TeamDataService {
|
|||
private taskChangePresenceRepository: TaskChangePresenceRepository | null = null;
|
||||
private teamLogSourceTracker: TeamLogSourceTracker | null = null;
|
||||
private fileWatchReconcileDiagnostics = new Map<string, FileWatchReconcileDiagnostics>();
|
||||
private readonly messageFeedService: TeamMessageFeedService;
|
||||
private readonly memberActivityMetaService: MemberActivityMetaService;
|
||||
|
||||
constructor(
|
||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||
|
|
@ -184,7 +196,15 @@ export class TeamDataService {
|
|||
private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver(
|
||||
configReader
|
||||
)
|
||||
) {}
|
||||
) {
|
||||
this.messageFeedService = new TeamMessageFeedService({
|
||||
getConfig: (teamName) => this.configReader.getConfig(teamName),
|
||||
getInboxMessages: (teamName) => this.inboxReader.getMessages(teamName),
|
||||
getLeadSessionMessages: (teamName, config) => this.extractLeadSessionTexts(teamName, config),
|
||||
getSentMessages: (teamName) => this.sentMessagesStore.readMessages(teamName),
|
||||
});
|
||||
this.memberActivityMetaService = new MemberActivityMetaService(this.messageFeedService);
|
||||
}
|
||||
|
||||
private getController(teamName: string): AgentTeamsController {
|
||||
return this.controllerFactory(teamName);
|
||||
|
|
@ -623,7 +643,7 @@ export class TeamDataService {
|
|||
await fs.promises.rm(tasksDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async getTeamData(teamName: string): Promise<TeamData> {
|
||||
async getTeamData(teamName: string): Promise<TeamViewSnapshot> {
|
||||
const startedAt = Date.now();
|
||||
const marks: Record<string, number> = {};
|
||||
const mark = (label: string): void => {
|
||||
|
|
@ -727,12 +747,6 @@ export class TeamDataService {
|
|||
warningText: 'Inboxes failed to load',
|
||||
load: () => this.inboxReader.listInboxNames(teamName),
|
||||
});
|
||||
const sentMessagesStep = startReadStep({
|
||||
label: 'sentMessages',
|
||||
createFallback: () => [],
|
||||
warningText: 'Sent messages failed to load',
|
||||
load: () => this.sentMessagesStore.readMessages(teamName),
|
||||
});
|
||||
const metaMembersStep = startReadStep({
|
||||
label: 'metaMembers',
|
||||
createFallback: () => [],
|
||||
|
|
@ -757,40 +771,8 @@ export class TeamDataService {
|
|||
load: () => this.taskReader.getTasks(teamName),
|
||||
})
|
||||
);
|
||||
const messagesStep = runWithConcurrencyLimit(() =>
|
||||
startReadStep({
|
||||
label: 'messages',
|
||||
createFallback: () => [],
|
||||
warningText: 'Messages failed to load',
|
||||
load: () => this.inboxReader.getMessages(teamName),
|
||||
})
|
||||
);
|
||||
const leadTextsStep = runWithConcurrencyLimit(() =>
|
||||
startReadStep({
|
||||
label: 'leadTexts',
|
||||
createFallback: () => [],
|
||||
warningText: 'Lead session texts failed to load',
|
||||
load: () => this.extractLeadSessionTexts(teamName, config),
|
||||
})
|
||||
);
|
||||
|
||||
const [
|
||||
tasksStepResult,
|
||||
inboxNamesStepResult,
|
||||
messagesStepResult,
|
||||
leadTextsStepResult,
|
||||
sentMessagesStepResult,
|
||||
metaMembersStepResult,
|
||||
kanbanStateStepResult,
|
||||
] = await Promise.all([
|
||||
tasksStep,
|
||||
inboxNamesStep,
|
||||
messagesStep,
|
||||
leadTextsStep,
|
||||
sentMessagesStep,
|
||||
metaMembersStep,
|
||||
kanbanStateStep,
|
||||
]);
|
||||
const [tasksStepResult, inboxNamesStepResult, metaMembersStepResult, kanbanStateStepResult] =
|
||||
await Promise.all([tasksStep, inboxNamesStep, metaMembersStep, kanbanStateStep]);
|
||||
|
||||
// After parallelizing the top read phase, these marks no longer represent
|
||||
// serial stage boundaries. They now capture the actual completion time for
|
||||
|
|
@ -798,178 +780,18 @@ export class TeamDataService {
|
|||
// diagnostics useful without mutating marks from concurrent branches.
|
||||
marks.tasks = tasksStepResult.completedAt;
|
||||
marks.inboxNames = inboxNamesStepResult.completedAt;
|
||||
marks.messages = messagesStepResult.completedAt;
|
||||
marks.leadTexts = leadTextsStepResult.completedAt;
|
||||
marks.sentMessages = sentMessagesStepResult.completedAt;
|
||||
marks.metaMembers = metaMembersStepResult.completedAt;
|
||||
marks.kanbanState = kanbanStateStepResult.completedAt;
|
||||
|
||||
if (tasksStepResult.warning) warnings.push(tasksStepResult.warning);
|
||||
if (inboxNamesStepResult.warning) warnings.push(inboxNamesStepResult.warning);
|
||||
if (messagesStepResult.warning) warnings.push(messagesStepResult.warning);
|
||||
if (leadTextsStepResult.warning) warnings.push(leadTextsStepResult.warning);
|
||||
if (sentMessagesStepResult.warning) warnings.push(sentMessagesStepResult.warning);
|
||||
if (metaMembersStepResult.warning) warnings.push(metaMembersStepResult.warning);
|
||||
if (kanbanStateStepResult.warning) warnings.push(kanbanStateStepResult.warning);
|
||||
|
||||
const tasks: TeamTask[] = tasksStepResult.value;
|
||||
const inboxNames: string[] = inboxNamesStepResult.value;
|
||||
let messages: InboxMessage[] = messagesStepResult.value;
|
||||
const leadTexts: InboxMessage[] = leadTextsStepResult.value;
|
||||
const sentMessages: InboxMessage[] = sentMessagesStepResult.value;
|
||||
mark('postStart');
|
||||
|
||||
if (leadTexts.length > 0) {
|
||||
messages = [...messages, ...leadTexts];
|
||||
}
|
||||
if (sentMessages.length > 0) {
|
||||
messages = [...messages, ...sentMessages];
|
||||
}
|
||||
mark('mergeMessages');
|
||||
|
||||
// Dedup: if a lead_process message text is also present in lead_session, prefer lead_session.
|
||||
// This avoids double-rendering when we persist lead process messages and later load the lead JSONL.
|
||||
// Exception: lead_process messages with `to` field are captured SendMessage — never dedup those.
|
||||
if (leadTexts.length > 0) {
|
||||
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
|
||||
const getLeadThoughtFingerprint = (
|
||||
msg: Pick<InboxMessage, 'from' | 'text' | 'leadSessionId'>
|
||||
) => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`;
|
||||
const leadSessionFingerprints = new Set<string>();
|
||||
for (const msg of leadTexts) {
|
||||
if (msg.source !== 'lead_session') continue;
|
||||
leadSessionFingerprints.add(getLeadThoughtFingerprint(msg));
|
||||
}
|
||||
messages = messages.filter((m) => {
|
||||
if (m.source !== 'lead_process') return true;
|
||||
// Captured SendMessage messages (with recipient) are real messages — never dedup
|
||||
if (m.to) return true;
|
||||
const fp = getLeadThoughtFingerprint(m);
|
||||
return !leadSessionFingerprints.has(fp);
|
||||
});
|
||||
}
|
||||
mark('dedupLeadTexts');
|
||||
|
||||
// Dedup exact message copies that can appear as both live lead_process rows and
|
||||
// their persisted inbox/sent-message counterpart. If the messageId is identical,
|
||||
// keep a single row so the UI does not show the same SendMessage twice
|
||||
// (for example "LIVE" plus the stored copy).
|
||||
const duplicateMessageIds = new Set<string>();
|
||||
const messageIdCounts = new Map<string, number>();
|
||||
for (const msg of messages) {
|
||||
const id = typeof msg.messageId === 'string' ? msg.messageId.trim() : '';
|
||||
if (!id) continue;
|
||||
const nextCount = (messageIdCounts.get(id) ?? 0) + 1;
|
||||
messageIdCounts.set(id, nextCount);
|
||||
if (nextCount > 1) duplicateMessageIds.add(id);
|
||||
}
|
||||
if (duplicateMessageIds.size > 0) {
|
||||
const choosePreferredMessage = (
|
||||
current: InboxMessage,
|
||||
candidate: InboxMessage
|
||||
): InboxMessage => {
|
||||
const score = (msg: InboxMessage): number => {
|
||||
let value = 0;
|
||||
if (msg.source !== 'lead_process') value += 4;
|
||||
if (msg.read === false) value += 2;
|
||||
if (msg.relayOfMessageId) value += 1;
|
||||
if (msg.summary) value += 1;
|
||||
if (msg.to) value += 1;
|
||||
return value;
|
||||
};
|
||||
const currentScore = score(current);
|
||||
const candidateScore = score(candidate);
|
||||
if (candidateScore !== currentScore) {
|
||||
return candidateScore > currentScore ? candidate : current;
|
||||
}
|
||||
const currentTs = Date.parse(current.timestamp);
|
||||
const candidateTs = Date.parse(candidate.timestamp);
|
||||
if (
|
||||
Number.isFinite(currentTs) &&
|
||||
Number.isFinite(candidateTs) &&
|
||||
candidateTs !== currentTs
|
||||
) {
|
||||
return candidateTs > currentTs ? candidate : current;
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
const dedupedById = new Map<string, InboxMessage>();
|
||||
const dedupedWithoutId: InboxMessage[] = [];
|
||||
for (const msg of messages) {
|
||||
const id = typeof msg.messageId === 'string' ? msg.messageId.trim() : '';
|
||||
if (!id) {
|
||||
dedupedWithoutId.push(msg);
|
||||
continue;
|
||||
}
|
||||
const existing = dedupedById.get(id);
|
||||
if (!existing) {
|
||||
dedupedById.set(id, msg);
|
||||
continue;
|
||||
}
|
||||
dedupedById.set(id, choosePreferredMessage(existing, msg));
|
||||
}
|
||||
messages = [...dedupedWithoutId, ...dedupedById.values()];
|
||||
}
|
||||
mark('dedupMessageIds');
|
||||
|
||||
messages = this.linkPassiveUserReplySummaries(messages);
|
||||
mark('linkPassiveUserReplySummaries');
|
||||
|
||||
// Enrich inbox messages without leadSessionId by assigning the nearest neighbor's
|
||||
// session ID (by timestamp). This avoids the old forward-only propagation bug.
|
||||
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
|
||||
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
||||
|
||||
const anchors: { index: number; time: number; sessionId: string }[] = [];
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (messages[i].leadSessionId) {
|
||||
anchors.push({
|
||||
index: i,
|
||||
time: Date.parse(messages[i].timestamp),
|
||||
sessionId: messages[i].leadSessionId!,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (anchors.length > 0) {
|
||||
let anchorIdx = 0;
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (messages[i].leadSessionId) {
|
||||
while (anchorIdx < anchors.length - 1 && anchors[anchorIdx].index < i) {
|
||||
anchorIdx++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const msgTime = Date.parse(messages[i].timestamp);
|
||||
let bestAnchor = anchors[0];
|
||||
let bestDist = Math.abs(msgTime - bestAnchor.time);
|
||||
for (const anchor of anchors) {
|
||||
const dist = Math.abs(msgTime - anchor.time);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestAnchor = anchor;
|
||||
} else if (dist > bestDist && anchor.time > msgTime) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
messages[i].leadSessionId = bestAnchor.sessionId;
|
||||
}
|
||||
} else if (config.leadSessionId) {
|
||||
for (const msg of messages) {
|
||||
msg.leadSessionId = config.leadSessionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
mark('attachLeadSessionIds');
|
||||
|
||||
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
||||
this.annotateSlashCommandResponses(messages);
|
||||
|
||||
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
||||
mark('normalizeMessages');
|
||||
|
||||
const metaMembers: TeamConfig['members'] = metaMembersStepResult.value;
|
||||
const kanbanState: KanbanState = kanbanStateStepResult.value;
|
||||
|
||||
|
|
@ -1001,8 +823,7 @@ export class TeamDataService {
|
|||
config,
|
||||
metaMembers,
|
||||
inboxNames,
|
||||
tasksWithKanban,
|
||||
messages
|
||||
tasksWithKanban
|
||||
);
|
||||
mark('resolveMembers');
|
||||
|
||||
|
|
@ -1037,30 +858,13 @@ export class TeamDataService {
|
|||
|
||||
const totalMs = Date.now() - startedAt;
|
||||
if (totalMs >= 1500) {
|
||||
const counts = `counts=tasks:${tasks.length},messages:${messages.length},inboxNames:${inboxNames.length},leadTexts:${leadTexts.length},sent:${sentMessages.length},members:${members.length},processes:${processes.length}`;
|
||||
const counts = `counts=tasks:${tasks.length},inboxNames:${inboxNames.length},members:${members.length},processes:${processes.length}`;
|
||||
logger.warn(
|
||||
`getTeamData team=${teamName} slow total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince(
|
||||
'inboxNames'
|
||||
)} messages=${msSince('messages')} leadTexts=${msSince('leadTexts')} sent=${msSince(
|
||||
'sentMessages'
|
||||
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince(
|
||||
'kanbanGc'
|
||||
)} post=${msBetween(
|
||||
'postStart',
|
||||
'mergeMessages'
|
||||
)}/dedupLead=${msBetween('mergeMessages', 'dedupLeadTexts')}/dedupIds=${msBetween(
|
||||
'dedupLeadTexts',
|
||||
'dedupMessageIds'
|
||||
)}/attachLeadSession=${msBetween(
|
||||
'dedupMessageIds',
|
||||
'attachLeadSessionIds'
|
||||
)}/normalizeMessages=${msBetween(
|
||||
'attachLeadSessionIds',
|
||||
'normalizeMessages'
|
||||
)}/attachKanban=${msBetween(
|
||||
'normalizeMessages',
|
||||
'attachKanban'
|
||||
)}/loadPresenceIndex=${msBetween(
|
||||
)} post=${msBetween('postStart', 'attachKanban')}/loadPresenceIndex=${msBetween(
|
||||
'attachKanban',
|
||||
'loadPresenceIndex'
|
||||
)}/changePresence=${msBetween(
|
||||
|
|
@ -1089,21 +893,14 @@ export class TeamDataService {
|
|||
this.processHealthTeams.delete(teamName);
|
||||
}
|
||||
|
||||
// Cap messages to keep IPC payloads small. Full history is available
|
||||
// via the paginated getMessagesPage() API. We still include a small
|
||||
// batch here for backward compatibility (notifications, dedup, etc.).
|
||||
const MAX_RETURN_MESSAGES = 50;
|
||||
const cappedMessages =
|
||||
messages.length > MAX_RETURN_MESSAGES ? messages.slice(0, MAX_RETURN_MESSAGES) : messages;
|
||||
|
||||
return {
|
||||
teamName,
|
||||
config,
|
||||
tasks: tasksWithKanban,
|
||||
members,
|
||||
messages: cappedMessages,
|
||||
kanbanState,
|
||||
processes,
|
||||
isAlive: hasAlive,
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
}
|
||||
|
|
@ -1114,112 +911,35 @@ export class TeamDataService {
|
|||
*/
|
||||
async getMessagesPage(
|
||||
teamName: string,
|
||||
options: { beforeTimestamp?: string; limit: number; liveMessages?: InboxMessage[] }
|
||||
options: { cursor?: string | null; limit: number; liveMessages?: InboxMessage[] }
|
||||
): Promise<MessagesPage> {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
if (!config) {
|
||||
return { messages: [], nextCursor: null, hasMore: false };
|
||||
}
|
||||
|
||||
// Collect all messages from the same sources as getTeamData
|
||||
let messages: InboxMessage[] = [];
|
||||
|
||||
const [inboxMessages, leadTexts, sentMessages] = await Promise.all([
|
||||
this.inboxReader.getMessages(teamName).catch(() => [] as InboxMessage[]),
|
||||
this.extractLeadSessionTexts(teamName, config).catch(() => [] as InboxMessage[]),
|
||||
this.sentMessagesStore.readMessages(teamName).catch(() => [] as InboxMessage[]),
|
||||
]);
|
||||
|
||||
messages = [...inboxMessages, ...leadTexts, ...sentMessages];
|
||||
|
||||
// Dedup lead_session vs lead_process (same logic as getTeamData)
|
||||
if (leadTexts.length > 0) {
|
||||
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
|
||||
const getFingerprint = (msg: Pick<InboxMessage, 'from' | 'text' | 'leadSessionId'>) =>
|
||||
`${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`;
|
||||
const leadSessionFingerprints = new Set<string>();
|
||||
for (const msg of leadTexts) {
|
||||
if (msg.source === 'lead_session') leadSessionFingerprints.add(getFingerprint(msg));
|
||||
}
|
||||
messages = messages.filter((m) => {
|
||||
if (m.source !== 'lead_process') return true;
|
||||
if (m.to) return true;
|
||||
return !leadSessionFingerprints.has(getFingerprint(m));
|
||||
});
|
||||
}
|
||||
|
||||
// Enrich: propagate leadSessionId to messages missing it (same as getTeamData)
|
||||
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
|
||||
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
||||
const anchors: { time: number; sessionId: string }[] = [];
|
||||
for (const msg of messages) {
|
||||
if (msg.leadSessionId) {
|
||||
anchors.push({ time: Date.parse(msg.timestamp), sessionId: msg.leadSessionId });
|
||||
}
|
||||
}
|
||||
if (anchors.length > 0) {
|
||||
for (const msg of messages) {
|
||||
if (msg.leadSessionId) continue;
|
||||
const msgTime = Date.parse(msg.timestamp);
|
||||
let best = anchors[0];
|
||||
let bestDist = Math.abs(msgTime - best.time);
|
||||
for (const a of anchors) {
|
||||
const dist = Math.abs(msgTime - a.time);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
best = a;
|
||||
} else if (dist > bestDist && a.time > msgTime) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
msg.leadSessionId = best.sessionId;
|
||||
}
|
||||
} else if (config.leadSessionId) {
|
||||
for (const msg of messages) {
|
||||
msg.leadSessionId = config.leadSessionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich: annotate slash command responses
|
||||
this.annotateSlashCommandResponses(messages);
|
||||
|
||||
// Sort newest-first, with stable tie-breaker by messageId
|
||||
messages.sort((a, b) => {
|
||||
const diff = Date.parse(b.timestamp) - Date.parse(a.timestamp);
|
||||
if (diff !== 0) return diff;
|
||||
return (a.messageId ?? '').localeCompare(b.messageId ?? '');
|
||||
});
|
||||
|
||||
const newestDurableMessages = messages;
|
||||
const feed = await this.messageFeedService.getFeed(teamName);
|
||||
const newestDurableMessages = feed.messages;
|
||||
const durableMessageIndexByKey = new Map(
|
||||
newestDurableMessages.map((message, index) => [getLiveLeadProcessMessageKey(message), index])
|
||||
);
|
||||
let messages = newestDurableMessages;
|
||||
|
||||
// Apply cursor filter. Cursor format: "timestamp|messageId" (compound)
|
||||
// to handle multiple messages sharing the same timestamp.
|
||||
if (options.beforeTimestamp) {
|
||||
const [cursorTs, cursorId] = options.beforeTimestamp.split('|');
|
||||
if (options.cursor) {
|
||||
const [cursorTs, cursorId] = options.cursor.split('|');
|
||||
const cursorMs = Date.parse(cursorTs);
|
||||
messages = messages.filter((m) => {
|
||||
const ms = Date.parse(m.timestamp);
|
||||
if (ms < cursorMs) return true;
|
||||
if (ms > cursorMs) return false;
|
||||
// Same timestamp — use messageId tie-breaker
|
||||
if (!cursorId) return false;
|
||||
return (m.messageId ?? '').localeCompare(cursorId) > 0;
|
||||
return requireCanonicalMessageId(m).localeCompare(cursorId) > 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Paginate
|
||||
const hasMore = messages.length > options.limit;
|
||||
const page = messages.slice(0, options.limit);
|
||||
const lastMsg = page[page.length - 1];
|
||||
const nextCursor =
|
||||
hasMore && lastMsg ? `${lastMsg.timestamp}|${lastMsg.messageId ?? ''}` : null;
|
||||
hasMore && lastMsg ? `${lastMsg.timestamp}|${requireCanonicalMessageId(lastMsg)}` : null;
|
||||
|
||||
if (options.beforeTimestamp || !options.liveMessages?.length) {
|
||||
return { messages: page, nextCursor, hasMore };
|
||||
if (options.cursor || !options.liveMessages?.length) {
|
||||
return { messages: page, nextCursor, hasMore, feedRevision: feed.feedRevision };
|
||||
}
|
||||
|
||||
// Merge live lead thoughts against the full durable newest-page history so we do not
|
||||
|
|
@ -1230,7 +950,12 @@ export class TeamDataService {
|
|||
).slice(0, options.limit);
|
||||
|
||||
if (displayMessages.length === 0) {
|
||||
return { messages: displayMessages, nextCursor: null, hasMore: false };
|
||||
return {
|
||||
messages: displayMessages,
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
feedRevision: feed.feedRevision,
|
||||
};
|
||||
}
|
||||
|
||||
let lastDurableDisplayed: InboxMessage | null = null;
|
||||
|
|
@ -1251,6 +976,7 @@ export class TeamDataService {
|
|||
? `${boundary.timestamp}|${boundary.messageId ?? ''}`
|
||||
: null,
|
||||
hasMore: newestDurableMessages.length > 0,
|
||||
feedRevision: feed.feedRevision,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1265,15 +991,31 @@ export class TeamDataService {
|
|||
? `${lastDurableDisplayed.timestamp}|${lastDurableDisplayed.messageId ?? ''}`
|
||||
: null,
|
||||
hasMore: durableHasMore,
|
||||
feedRevision: feed.feedRevision,
|
||||
};
|
||||
}
|
||||
|
||||
async getMessageFeed(
|
||||
teamName: string
|
||||
): Promise<{ teamName: string; feedRevision: string; messages: InboxMessage[] }> {
|
||||
return this.messageFeedService.getFeed(teamName);
|
||||
}
|
||||
|
||||
async getMemberActivityMeta(teamName: string): Promise<TeamMemberActivityMeta> {
|
||||
return this.memberActivityMetaService.getMeta(teamName);
|
||||
}
|
||||
|
||||
invalidateMessageFeed(teamName: string): void {
|
||||
this.messageFeedService.invalidate(teamName);
|
||||
this.memberActivityMetaService.invalidate(teamName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches members with gitBranch when their cwd differs from the lead's.
|
||||
* Mutates members in-place for efficiency (called right after resolveMembers).
|
||||
*/
|
||||
private async enrichMemberBranches(
|
||||
members: ResolvedTeamMember[],
|
||||
members: TeamViewSnapshot['members'],
|
||||
config: TeamConfig
|
||||
): Promise<void> {
|
||||
const leadEntry = config.members?.find((member) => isLeadMember(member));
|
||||
|
|
@ -1945,7 +1687,7 @@ export class TeamDataService {
|
|||
slashCommand: slashCommandMeta,
|
||||
};
|
||||
}
|
||||
return this.getController(teamName).messages.sendMessage({
|
||||
const result = this.getController(teamName).messages.sendMessage({
|
||||
member: enrichedRequest.member,
|
||||
from: enrichedRequest.from,
|
||||
text: enrichedRequest.text,
|
||||
|
|
@ -1966,6 +1708,8 @@ export class TeamDataService {
|
|||
leadSessionId: enrichedRequest.leadSessionId,
|
||||
attachments: enrichedRequest.attachments,
|
||||
}) as SendMessageResult;
|
||||
this.invalidateMessageFeed(teamName);
|
||||
return result;
|
||||
}
|
||||
|
||||
private resolveLeadNameFromConfig(config: TeamConfig | null): string {
|
||||
|
|
@ -2522,6 +2266,23 @@ export class TeamDataService {
|
|||
}
|
||||
}
|
||||
|
||||
async getTeamNotificationContext(teamName: string): Promise<{
|
||||
displayName: string;
|
||||
projectPath?: string;
|
||||
}> {
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
const displayName = config?.name?.trim() || teamName;
|
||||
const projectPath =
|
||||
typeof config?.projectPath === 'string' && config.projectPath.trim().length > 0
|
||||
? config.projectPath
|
||||
: undefined;
|
||||
return { displayName, projectPath };
|
||||
} catch {
|
||||
return { displayName: teamName };
|
||||
}
|
||||
}
|
||||
|
||||
async requestReview(teamName: string, taskId: string): Promise<void> {
|
||||
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||
this.getController(teamName).review.requestReview(taskId, {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,12 @@ import { Worker } from 'node:worker_threads';
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { TeamDataWorkerRequest, TeamDataWorkerResponse } from './teamDataWorkerTypes';
|
||||
import type { MemberLogSummary, TeamData } from '@shared/types';
|
||||
import type {
|
||||
MemberLogSummary,
|
||||
MessagesPage,
|
||||
TeamMemberActivityMeta,
|
||||
TeamViewSnapshot,
|
||||
} from '@shared/types';
|
||||
|
||||
const logger = createLogger('Service:TeamDataWorkerClient');
|
||||
const WORKER_CALL_TIMEOUT_MS = 30_000;
|
||||
|
|
@ -25,16 +30,20 @@ function makeId(): string {
|
|||
return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`;
|
||||
}
|
||||
|
||||
function resolveWorkerPath(): string | null {
|
||||
function getWorkerPathCandidates(): string[] {
|
||||
const baseDir =
|
||||
typeof __dirname === 'string' && __dirname.length > 0
|
||||
? __dirname
|
||||
: path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const candidates = [
|
||||
return [
|
||||
path.join(baseDir, 'team-data-worker.cjs'),
|
||||
path.join(process.cwd(), 'dist-electron', 'main', 'team-data-worker.cjs'),
|
||||
];
|
||||
}
|
||||
|
||||
function resolveWorkerPath(): string | null {
|
||||
const candidates = getWorkerPathCandidates();
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
|
|
@ -75,7 +84,9 @@ export class TeamDataWorkerClient {
|
|||
isAvailable(): boolean {
|
||||
if (!this.workerPath && !this.warnedUnavailable) {
|
||||
this.warnedUnavailable = true;
|
||||
logger.debug('team-data-worker not found; falling back to main-thread execution');
|
||||
logger.warn(
|
||||
`team-data-worker not found; heavy team data paths may fall back to main-thread execution. expectedOneOf=${getWorkerPathCandidates().join(',')}`
|
||||
);
|
||||
}
|
||||
return this.workerPath !== null;
|
||||
}
|
||||
|
|
@ -144,9 +155,22 @@ export class TeamDataWorkerClient {
|
|||
});
|
||||
}
|
||||
|
||||
async getTeamData(teamName: string): Promise<TeamData> {
|
||||
async getTeamData(teamName: string): Promise<TeamViewSnapshot> {
|
||||
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
|
||||
return this.call('getTeamData', { teamName }) as Promise<TeamData>;
|
||||
return this.call('getTeamData', { teamName }) as Promise<TeamViewSnapshot>;
|
||||
}
|
||||
|
||||
async getMessagesPage(
|
||||
teamName: string,
|
||||
options: { cursor?: string | null; limit: number }
|
||||
): Promise<MessagesPage> {
|
||||
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
|
||||
return this.call('getMessagesPage', { teamName, options }) as Promise<MessagesPage>;
|
||||
}
|
||||
|
||||
async getMemberActivityMeta(teamName: string): Promise<TeamMemberActivityMeta> {
|
||||
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
|
||||
return this.call('getMemberActivityMeta', { teamName }) as Promise<TeamMemberActivityMeta>;
|
||||
}
|
||||
|
||||
async findLogsForTask(
|
||||
|
|
|
|||
|
|
@ -223,8 +223,7 @@ export function createPersistedLaunchSnapshot(params: {
|
|||
for (const name of expectedMembers) {
|
||||
const member = members[name];
|
||||
if (
|
||||
member &&
|
||||
member.launchState === 'starting' &&
|
||||
member?.launchState === 'starting' &&
|
||||
!member.agentToolAccepted &&
|
||||
!member.runtimeAlive &&
|
||||
!member.bootstrapConfirmed &&
|
||||
|
|
|
|||
|
|
@ -1,17 +1,11 @@
|
|||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import {
|
||||
createCliAutoSuffixNameGuard,
|
||||
createCliProvisionerNameGuard,
|
||||
} from '@shared/utils/teamMemberName';
|
||||
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
||||
|
||||
import type {
|
||||
InboxMessage,
|
||||
MemberStatus,
|
||||
ResolvedTeamMember,
|
||||
TeamConfig,
|
||||
TeamMember,
|
||||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
import type { TeamConfig, TeamMember, TeamMemberSnapshot, TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
|
||||
|
|
@ -63,9 +57,8 @@ export class TeamMemberResolver {
|
|||
config: TeamConfig,
|
||||
metaMembers: TeamConfig['members'],
|
||||
inboxNames: string[],
|
||||
tasks: TeamTaskWithKanban[],
|
||||
messages: InboxMessage[]
|
||||
): ResolvedTeamMember[] {
|
||||
tasks: TeamTaskWithKanban[]
|
||||
): TeamMemberSnapshot[] {
|
||||
const names = new Set<string>();
|
||||
const explicitNames = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
|
|
@ -216,7 +209,7 @@ export class TeamMemberResolver {
|
|||
}
|
||||
}
|
||||
|
||||
const members: ResolvedTeamMember[] = [];
|
||||
const members: TeamMemberSnapshot[] = [];
|
||||
for (const name of names) {
|
||||
const ownedTasks = tasks.filter((task) => task.owner === name);
|
||||
const currentTask =
|
||||
|
|
@ -226,21 +219,15 @@ export class TeamMemberResolver {
|
|||
task.reviewState !== 'approved' &&
|
||||
task.kanbanColumn !== 'approved'
|
||||
) ?? null;
|
||||
const memberMessages = messages.filter((message) => message.from === name);
|
||||
const latestMessage = memberMessages[0] ?? null;
|
||||
const status = this.resolveStatus(latestMessage, currentTask !== null);
|
||||
const configMember = configMemberMap.get(name);
|
||||
const metaMember = metaMemberMap.get(name);
|
||||
const agentId = configMember?.agentId ?? metaMember?.agentId;
|
||||
members.push({
|
||||
name,
|
||||
agentId,
|
||||
status,
|
||||
currentTaskId: currentTask?.id ?? null,
|
||||
taskCount: ownedTasks.length,
|
||||
messageCount: memberMessages.length,
|
||||
lastActiveAt: latestMessage?.timestamp ?? null,
|
||||
color: latestMessage?.color ?? configMember?.color ?? metaMember?.color,
|
||||
color: configMember?.color ?? metaMember?.color ?? getMemberColorByName(name),
|
||||
agentType: configMember?.agentType ?? metaMember?.agentType,
|
||||
role: configMember?.role ?? metaMember?.role,
|
||||
workflow: configMember?.workflow ?? metaMember?.workflow,
|
||||
|
|
@ -277,45 +264,4 @@ export class TeamMemberResolver {
|
|||
});
|
||||
return members;
|
||||
}
|
||||
|
||||
private resolveStatus(message: InboxMessage | null, hasActiveTask: boolean): MemberStatus {
|
||||
if (!message) {
|
||||
// Member exists in config but has no messages yet —
|
||||
// if they own an in_progress task they're clearly active, otherwise idle
|
||||
return hasActiveTask ? 'active' : 'idle';
|
||||
}
|
||||
|
||||
const structured = this.parseStructuredMessage(message.text);
|
||||
if (structured) {
|
||||
const typed = structured as { type?: string; approve?: boolean; approved?: boolean };
|
||||
if (
|
||||
(typed.type === 'shutdown_response' &&
|
||||
(typed.approve === true || typed.approved === true)) ||
|
||||
typed.type === 'shutdown_approved'
|
||||
) {
|
||||
return 'terminated';
|
||||
}
|
||||
}
|
||||
|
||||
const ageMs = Date.now() - Date.parse(message.timestamp);
|
||||
if (Number.isNaN(ageMs)) {
|
||||
return 'unknown';
|
||||
}
|
||||
if (ageMs < 5 * 60 * 1000) {
|
||||
return 'active';
|
||||
}
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
private parseStructuredMessage(text: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// Ignore plain text.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
408
src/main/services/team/TeamMessageFeedService.ts
Normal file
408
src/main/services/team/TeamMessageFeedService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -90,6 +90,7 @@ import { buildActionModeProtocol } from './actionModeInstructions';
|
|||
import { atomicWriteAsync } from './atomicWrite';
|
||||
import { peekAutoResumeService } from './AutoResumeService';
|
||||
import { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
|
||||
import { getConfiguredCliCommandLabel } from './cliFlavor';
|
||||
import { withFileLock } from './fileLock';
|
||||
import {
|
||||
type ClassifiedMainProcessIdle,
|
||||
|
|
@ -721,6 +722,8 @@ interface ProvisioningRun {
|
|||
>;
|
||||
/** Agent tool_use_id -> teammate name for persistent teammate spawns. */
|
||||
memberSpawnToolUseIds: Map<string, string>;
|
||||
/** Per-member latest processed lead-inbox bootstrap signal cursor for the current live run. */
|
||||
memberSpawnLeadInboxCursorByMember: Map<string, MemberSpawnInboxCursor>;
|
||||
/** Highest accepted deterministic bootstrap event sequence for this run. */
|
||||
lastDeterministicBootstrapSeq: number;
|
||||
/** Throttles config/inbox audit work triggered by frequent status polling. */
|
||||
|
|
@ -839,6 +842,75 @@ function matchesTeamMemberIdentity(leftName: string, rightName: string): boolean
|
|||
);
|
||||
}
|
||||
|
||||
interface MemberSpawnInboxCursor {
|
||||
timestamp: string;
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
type LeadInboxMemberSpawnMessage = InboxMessage & { messageId: string };
|
||||
|
||||
function compareMemberSpawnInboxCursor(
|
||||
left: MemberSpawnInboxCursor,
|
||||
right: MemberSpawnInboxCursor
|
||||
): number {
|
||||
const leftMs = Date.parse(left.timestamp);
|
||||
const rightMs = Date.parse(right.timestamp);
|
||||
const leftValid = Number.isFinite(leftMs);
|
||||
const rightValid = Number.isFinite(rightMs);
|
||||
|
||||
if (leftValid && rightValid && leftMs !== rightMs) {
|
||||
return leftMs - rightMs;
|
||||
}
|
||||
if (leftValid !== rightValid) {
|
||||
return leftValid ? -1 : 1;
|
||||
}
|
||||
return left.messageId.localeCompare(right.messageId);
|
||||
}
|
||||
|
||||
function toMemberSpawnInboxCursor(
|
||||
message: Pick<InboxMessage, 'timestamp' | 'messageId'>
|
||||
): MemberSpawnInboxCursor | null {
|
||||
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
|
||||
if (!messageId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
timestamp: message.timestamp,
|
||||
messageId,
|
||||
};
|
||||
}
|
||||
|
||||
function maxMemberSpawnInboxCursor(
|
||||
left: MemberSpawnInboxCursor | undefined,
|
||||
right: MemberSpawnInboxCursor
|
||||
): MemberSpawnInboxCursor {
|
||||
if (!left) {
|
||||
return right;
|
||||
}
|
||||
return compareMemberSpawnInboxCursor(left, right) >= 0 ? left : right;
|
||||
}
|
||||
|
||||
function isMemberSpawnHeartbeatTimestampNewer(
|
||||
previous: string | undefined,
|
||||
incoming: string | undefined
|
||||
): boolean {
|
||||
const normalizedIncoming = incoming?.trim();
|
||||
if (!normalizedIncoming) {
|
||||
return false;
|
||||
}
|
||||
const normalizedPrevious = previous?.trim();
|
||||
if (!normalizedPrevious) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const previousMs = Date.parse(normalizedPrevious);
|
||||
const incomingMs = Date.parse(normalizedIncoming);
|
||||
if (Number.isFinite(previousMs) && Number.isFinite(incomingMs)) {
|
||||
return incomingMs > previousMs;
|
||||
}
|
||||
return normalizedIncoming > normalizedPrevious;
|
||||
}
|
||||
|
||||
function stripWrappedCliFlagValue(raw: string | undefined): string | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) {
|
||||
|
|
@ -1339,6 +1411,8 @@ ${buildCanonicalSendMessageExample({ to: leadName, summary: 'short update', mess
|
|||
After member_briefing succeeds:
|
||||
- Do NOT send a "ready", "online", "status accepted", or other acknowledgement-only message just to confirm you started successfully.
|
||||
- If bootstrap succeeded and you have no task yet, stay silent and wait for task assignments.
|
||||
- If bootstrap succeeded and you have no task, produce ZERO assistant text for that turn and end it immediately after the successful tool result.
|
||||
- Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after bootstrap.
|
||||
- Only SendMessage the lead after bootstrap when there is a real blocker, a failed bootstrap, an explicit question, an urgent coordination need, or a completed task result to report.
|
||||
- Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence.
|
||||
- When you later receive work or reconnect after a restart, use task_briefing as your compact queue view. Use task_get when you need the full task context before starting a pending/needsFix task or when the in_progress briefing details are not enough.
|
||||
|
|
@ -1409,6 +1483,8 @@ ${actionModeProtocol}
|
|||
After member_briefing succeeds:
|
||||
- Do NOT send a "ready", "online", "status accepted", or other acknowledgement-only message just to confirm you reconnected successfully.
|
||||
- If reconnect bootstrap succeeded and you have no immediate blocker or question, stay silent and continue with your queue.
|
||||
- If reconnect bootstrap succeeded and you have no immediate blocker, question, or task, produce ZERO assistant text for that turn and end it immediately.
|
||||
- Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after reconnect bootstrap.
|
||||
- Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence.
|
||||
- Use task_briefing as your compact queue view.
|
||||
- If task_briefing shows any in_progress task, resume/finish those first. Call task_get only if you need more context than task_briefing already gave you.
|
||||
|
|
@ -3094,12 +3170,15 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
const runStartedAtMs = Date.parse(run.startedAt);
|
||||
const expectedMembers = Array.isArray(run.expectedMembers) ? run.expectedMembers : [];
|
||||
const expectedMembers = new Set(Array.isArray(run.expectedMembers) ? run.expectedMembers : []);
|
||||
const teammateMessages = leadInboxMessages
|
||||
.filter((message) => {
|
||||
.filter((message): message is LeadInboxMemberSpawnMessage => {
|
||||
const from = typeof message.from === 'string' ? message.from.trim() : '';
|
||||
if (!from || from === leadName || from === 'user' || from === 'system') return false;
|
||||
if (!expectedMembers.includes(from)) return false;
|
||||
if (!expectedMembers.has(from)) return false;
|
||||
if (typeof message.messageId !== 'string' || message.messageId.trim().length === 0) {
|
||||
return false;
|
||||
}
|
||||
const messageTs = Date.parse(message.timestamp);
|
||||
if (
|
||||
Number.isFinite(messageTs) &&
|
||||
|
|
@ -3110,24 +3189,67 @@ export class TeamProvisioningService {
|
|||
}
|
||||
return typeof message.text === 'string' && message.text.trim().length > 0;
|
||||
})
|
||||
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
||||
|
||||
for (const message of teammateMessages) {
|
||||
const from = message.from.trim();
|
||||
const reason = extractBootstrapFailureReason(message.text);
|
||||
if (reason) {
|
||||
this.setMemberSpawnStatus(run, from, 'error', reason);
|
||||
continue;
|
||||
}
|
||||
this.setMemberSpawnStatus(
|
||||
run,
|
||||
from,
|
||||
'online',
|
||||
undefined,
|
||||
'heartbeat',
|
||||
extractHeartbeatTimestamp(message.text, message.timestamp)
|
||||
.sort((left, right) =>
|
||||
compareMemberSpawnInboxCursor(
|
||||
{ timestamp: left.timestamp, messageId: left.messageId },
|
||||
{ timestamp: right.timestamp, messageId: right.messageId }
|
||||
)
|
||||
);
|
||||
|
||||
const messagesByMember = new Map<string, LeadInboxMemberSpawnMessage[]>();
|
||||
for (const message of teammateMessages) {
|
||||
const memberName = message.from.trim();
|
||||
const bucket = messagesByMember.get(memberName) ?? [];
|
||||
bucket.push(message);
|
||||
messagesByMember.set(memberName, bucket);
|
||||
}
|
||||
|
||||
for (const [memberName, messages] of messagesByMember.entries()) {
|
||||
const currentCursor = run.memberSpawnLeadInboxCursorByMember.get(memberName);
|
||||
let nextCursor = currentCursor;
|
||||
|
||||
for (const message of messages) {
|
||||
const messageCursor = toMemberSpawnInboxCursor(message);
|
||||
const effectiveCursor = nextCursor ?? currentCursor;
|
||||
if (messageCursor && effectiveCursor) {
|
||||
if (compareMemberSpawnInboxCursor(messageCursor, effectiveCursor) <= 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
this.applyLeadInboxSpawnSignal(run, memberName, message);
|
||||
if (messageCursor) {
|
||||
nextCursor = maxMemberSpawnInboxCursor(nextCursor, messageCursor);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
nextCursor &&
|
||||
(currentCursor == null || compareMemberSpawnInboxCursor(nextCursor, currentCursor) > 0)
|
||||
) {
|
||||
run.memberSpawnLeadInboxCursorByMember.set(memberName, nextCursor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyLeadInboxSpawnSignal(
|
||||
run: ProvisioningRun,
|
||||
memberName: string,
|
||||
message: LeadInboxMemberSpawnMessage
|
||||
): void {
|
||||
const reason = extractBootstrapFailureReason(message.text);
|
||||
if (reason) {
|
||||
this.setMemberSpawnStatus(run, memberName, 'error', reason);
|
||||
return;
|
||||
}
|
||||
this.setMemberSpawnStatus(
|
||||
run,
|
||||
memberName,
|
||||
'online',
|
||||
undefined,
|
||||
'heartbeat',
|
||||
extractHeartbeatTimestamp(message.text, message.timestamp)
|
||||
);
|
||||
}
|
||||
|
||||
private persistSentMessage(teamName: string, message: InboxMessage): void {
|
||||
|
|
@ -3725,8 +3847,14 @@ export class TeamProvisioningService {
|
|||
next.livenessSource = livenessSource;
|
||||
next.firstSpawnAcceptedAt = prev.firstSpawnAcceptedAt ?? updatedAt;
|
||||
if (livenessSource === 'heartbeat') {
|
||||
const incomingHeartbeatAt = heartbeatAt?.trim() || updatedAt;
|
||||
next.bootstrapConfirmed = true;
|
||||
next.lastHeartbeatAt = heartbeatAt?.trim() || prev.lastHeartbeatAt || updatedAt;
|
||||
next.lastHeartbeatAt = isMemberSpawnHeartbeatTimestampNewer(
|
||||
prev.lastHeartbeatAt,
|
||||
incomingHeartbeatAt
|
||||
)
|
||||
? incomingHeartbeatAt
|
||||
: prev.lastHeartbeatAt;
|
||||
}
|
||||
next.hardFailure = false;
|
||||
next.error = undefined;
|
||||
|
|
@ -5612,6 +5740,7 @@ export class TeamProvisioningService {
|
|||
request.members.map((m) => [m.name, createInitialMemberSpawnStatusEntry()])
|
||||
),
|
||||
memberSpawnToolUseIds: new Map(),
|
||||
memberSpawnLeadInboxCursorByMember: new Map(),
|
||||
lastDeterministicBootstrapSeq: 0,
|
||||
lastMemberSpawnAuditAt: 0,
|
||||
lastMemberSpawnAuditConfigReadWarningAt: 0,
|
||||
|
|
@ -6192,6 +6321,7 @@ export class TeamProvisioningService {
|
|||
expectedMembers.map((name) => [name, createInitialMemberSpawnStatusEntry()])
|
||||
),
|
||||
memberSpawnToolUseIds: new Map(),
|
||||
memberSpawnLeadInboxCursorByMember: new Map(),
|
||||
lastDeterministicBootstrapSeq: 0,
|
||||
lastMemberSpawnAuditAt: 0,
|
||||
lastMemberSpawnAuditConfigReadWarningAt: 0,
|
||||
|
|
@ -12968,6 +13098,7 @@ export class TeamProvisioningService {
|
|||
providerId: TeamProviderId | undefined = 'anthropic'
|
||||
): Promise<{ warning?: string }> {
|
||||
const resolvedProviderId = resolveTeamProviderId(providerId);
|
||||
const cliCommandLabel = getConfiguredCliCommandLabel();
|
||||
try {
|
||||
const versionProbe = await this.spawnProbe(
|
||||
claudePath,
|
||||
|
|
@ -12979,9 +13110,9 @@ export class TeamProvisioningService {
|
|||
if (versionProbe.exitCode !== 0) {
|
||||
const errorText =
|
||||
buildCombinedLogs(versionProbe.stdout, versionProbe.stderr) ||
|
||||
`Claude CLI exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`;
|
||||
`${cliCommandLabel} exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`;
|
||||
return {
|
||||
warning: `Claude CLI binary failed to start correctly. Details: ${errorText}`,
|
||||
warning: `${cliCommandLabel} binary failed to start correctly. Details: ${errorText}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -12992,7 +13123,7 @@ export class TeamProvisioningService {
|
|||
};
|
||||
}
|
||||
return {
|
||||
warning: `Claude CLI binary failed to start. Details: ${message}`,
|
||||
warning: `${cliCommandLabel} binary failed to start. Details: ${message}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -13054,7 +13185,7 @@ export class TeamProvisioningService {
|
|||
}
|
||||
return {
|
||||
warning:
|
||||
'Preflight check for `claude -p` did not complete. ' +
|
||||
`Preflight check for \`${cliCommandLabel} -p\` did not complete. ` +
|
||||
`Proceeding anyway. Details: ${message}`,
|
||||
};
|
||||
}
|
||||
|
|
@ -13075,13 +13206,15 @@ export class TeamProvisioningService {
|
|||
const hint = isAuthFailure
|
||||
? resolvedProviderId === 'codex'
|
||||
? 'Codex provider is not authenticated for `-p` mode. ' +
|
||||
'Run `claude-multimodel auth login --provider codex` and retry.' +
|
||||
`Authenticate Codex in ${cliCommandLabel} and retry.` +
|
||||
(attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
|
||||
: 'Claude CLI `-p` mode is not authenticated. ' +
|
||||
'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' +
|
||||
: `${cliCommandLabel} \`-p\` mode is not authenticated. ` +
|
||||
(cliCommandLabel === 'claude'
|
||||
? 'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. '
|
||||
: `Authenticate Anthropic in ${cliCommandLabel} and retry. `) +
|
||||
'For automation/headless use, set ANTHROPIC_API_KEY.' +
|
||||
(attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
|
||||
: `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`;
|
||||
: `${cliCommandLabel} preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`;
|
||||
return { warning: hint };
|
||||
}
|
||||
|
||||
|
|
@ -13122,7 +13255,7 @@ export class TeamProvisioningService {
|
|||
const targetCwd = cwd ?? process.cwd();
|
||||
const probeResult = await this.getCachedOrProbeResult(targetCwd, 'anthropic');
|
||||
if (!probeResult?.claudePath) {
|
||||
throw new Error('Claude CLI not found');
|
||||
throw new Error(`${getConfiguredCliCommandLabel()} not found`);
|
||||
}
|
||||
const { env } = await this.buildProvisioningEnv();
|
||||
const result = await this.spawnProbe(
|
||||
|
|
@ -13135,7 +13268,7 @@ export class TeamProvisioningService {
|
|||
const output = (result.stdout + '\n' + result.stderr).trim();
|
||||
if (!output) {
|
||||
throw new Error(
|
||||
`claude --help returned empty output (exit code: ${String(result.exitCode)})`
|
||||
`${getConfiguredCliCommandLabel()} --help returned empty output (exit code: ${String(result.exitCode)})`
|
||||
);
|
||||
}
|
||||
this.helpOutputCache = output;
|
||||
|
|
@ -13493,7 +13626,7 @@ export class TeamProvisioningService {
|
|||
const timeoutHandle = setTimeout(() => {
|
||||
settled = true;
|
||||
killProcessTree(child);
|
||||
reject(new Error(`Timeout running: claude ${args.join(' ')}`));
|
||||
reject(new Error(`Timeout running: ${getConfiguredCliCommandLabel()} ${args.join(' ')}`));
|
||||
}, timeoutMs);
|
||||
|
||||
const maybeResolveEarly = (): void => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { atomicWriteAsync } from '@main/utils/atomicWrite';
|
||||
import { extractCwd } from '@main/utils/jsonl';
|
||||
import {
|
||||
encodePath,
|
||||
|
|
@ -5,7 +6,6 @@ import {
|
|||
getProjectsBasePath,
|
||||
getTeamsBasePath,
|
||||
} from '@main/utils/pathDecoder';
|
||||
import { atomicWriteAsync } from '@main/utils/atomicWrite';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { createReadStream, type Dirent } from 'fs';
|
||||
|
|
@ -219,7 +219,8 @@ export class TeamTranscriptProjectResolver {
|
|||
...config,
|
||||
projectPath: resolution.effectiveProjectPath,
|
||||
projectPathHistory: this.buildRepairedProjectPathHistory(
|
||||
config,
|
||||
config.projectPath,
|
||||
config.projectPathHistory,
|
||||
resolution.effectiveProjectPath
|
||||
),
|
||||
}
|
||||
|
|
@ -598,25 +599,11 @@ export class TeamTranscriptProjectResolver {
|
|||
|
||||
parsed.projectPath = normalizedNextPath;
|
||||
|
||||
const history: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const pushHistory = (value: unknown): void => {
|
||||
const normalized = normalizeProjectPathCandidate(value);
|
||||
if (!normalized || normalized === normalizedNextPath || seen.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
seen.add(normalized);
|
||||
history.push(normalized);
|
||||
};
|
||||
|
||||
if (Array.isArray(parsed.projectPathHistory)) {
|
||||
for (const value of parsed.projectPathHistory) {
|
||||
pushHistory(value);
|
||||
}
|
||||
}
|
||||
pushHistory(rawProjectPath);
|
||||
|
||||
parsed.projectPathHistory = history.slice(-500);
|
||||
parsed.projectPathHistory = this.buildRepairedProjectPathHistory(
|
||||
rawProjectPath,
|
||||
parsed.projectPathHistory,
|
||||
normalizedNextPath
|
||||
);
|
||||
await atomicWriteAsync(configPath, JSON.stringify(parsed, null, 2));
|
||||
logger.info(
|
||||
`[${teamName}] Repaired transcript projectPath via exact session match: ${normalizedNextPath}`
|
||||
|
|
@ -663,7 +650,11 @@ export class TeamTranscriptProjectResolver {
|
|||
return orderedSessionIds;
|
||||
}
|
||||
|
||||
private buildRepairedProjectPathHistory(config: TeamConfig, nextProjectPath: string): string[] {
|
||||
private buildRepairedProjectPathHistory(
|
||||
currentProjectPath: unknown,
|
||||
rawProjectPathHistory: unknown,
|
||||
nextProjectPath: string
|
||||
): string[] {
|
||||
const normalizedNextPath = normalizeProjectPathCandidate(nextProjectPath);
|
||||
const history: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
|
@ -676,12 +667,12 @@ export class TeamTranscriptProjectResolver {
|
|||
history.push(normalized);
|
||||
};
|
||||
|
||||
if (Array.isArray(config.projectPathHistory)) {
|
||||
for (const value of config.projectPathHistory) {
|
||||
if (Array.isArray(rawProjectPathHistory)) {
|
||||
for (const value of rawProjectPathHistory) {
|
||||
pushHistory(value);
|
||||
}
|
||||
}
|
||||
pushHistory(config.projectPath);
|
||||
pushHistory(currentProjectPath);
|
||||
|
||||
return history.slice(-500);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
export {
|
||||
AutoResumeService,
|
||||
clearAutoResumeService,
|
||||
getAutoResumeService,
|
||||
initializeAutoResumeService,
|
||||
} from './AutoResumeService';
|
||||
export { BranchStatusService } from './BranchStatusService';
|
||||
export { CascadeGuard } from './CascadeGuard';
|
||||
export { ChangeExtractorService } from './ChangeExtractorService';
|
||||
|
|
@ -16,12 +22,6 @@ export { BoardTaskActivityService } from './taskLogs/activity/BoardTaskActivityS
|
|||
export { BoardTaskExactLogDetailService } from './taskLogs/exact/BoardTaskExactLogDetailService';
|
||||
export { BoardTaskExactLogsService } from './taskLogs/exact/BoardTaskExactLogsService';
|
||||
export { BoardTaskLogStreamService } from './taskLogs/stream/BoardTaskLogStreamService';
|
||||
export {
|
||||
AutoResumeService,
|
||||
clearAutoResumeService,
|
||||
getAutoResumeService,
|
||||
initializeAutoResumeService,
|
||||
} from './AutoResumeService';
|
||||
export { TeamAttachmentStore } from './TeamAttachmentStore';
|
||||
export { TeamBackupService } from './TeamBackupService';
|
||||
export { TeamConfigReader } from './TeamConfigReader';
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@
|
|||
* Shared request/response types for the team-data-worker thread.
|
||||
*/
|
||||
|
||||
import type { MemberLogSummary, TeamData } from '@shared/types';
|
||||
import type {
|
||||
MemberLogSummary,
|
||||
MessagesPage,
|
||||
TeamMemberActivityMeta,
|
||||
TeamViewSnapshot,
|
||||
} from '@shared/types';
|
||||
|
||||
// ── Payloads ──
|
||||
|
||||
|
|
@ -10,6 +15,18 @@ export interface GetTeamDataPayload {
|
|||
teamName: string;
|
||||
}
|
||||
|
||||
export interface GetMessagesPagePayload {
|
||||
teamName: string;
|
||||
options: {
|
||||
cursor?: string | null;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetMemberActivityMetaPayload {
|
||||
teamName: string;
|
||||
}
|
||||
|
||||
export interface FindLogsForTaskPayload {
|
||||
teamName: string;
|
||||
taskId: string;
|
||||
|
|
@ -25,8 +42,14 @@ export interface FindLogsForTaskPayload {
|
|||
|
||||
export type TeamDataWorkerRequest =
|
||||
| { id: string; op: 'getTeamData'; payload: GetTeamDataPayload }
|
||||
| { id: string; op: 'getMessagesPage'; payload: GetMessagesPagePayload }
|
||||
| { id: string; op: 'getMemberActivityMeta'; payload: GetMemberActivityMetaPayload }
|
||||
| { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload };
|
||||
|
||||
export type TeamDataWorkerResponse =
|
||||
| { id: string; ok: true; result: TeamData | MemberLogSummary[] }
|
||||
| {
|
||||
id: string;
|
||||
ok: true;
|
||||
result: TeamViewSnapshot | MessagesPage | TeamMemberActivityMeta | MemberLogSummary[];
|
||||
}
|
||||
| { id: string; ok: false; error: string };
|
||||
|
|
|
|||
|
|
@ -42,6 +42,19 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => {
|
|||
respond({ id: msg.id, ok: true, result });
|
||||
break;
|
||||
}
|
||||
case 'getMessagesPage': {
|
||||
const result = await teamDataService.getMessagesPage(
|
||||
msg.payload.teamName,
|
||||
msg.payload.options
|
||||
);
|
||||
respond({ id: msg.id, ok: true, result });
|
||||
break;
|
||||
}
|
||||
case 'getMemberActivityMeta': {
|
||||
const result = await teamDataService.getMemberActivityMeta(msg.payload.teamName);
|
||||
respond({ id: msg.id, ok: true, result });
|
||||
break;
|
||||
}
|
||||
case 'findLogsForTask': {
|
||||
const { teamName, taskId, options } = msg.payload;
|
||||
const intervalsKey = options?.intervals
|
||||
|
|
|
|||
|
|
@ -237,6 +237,9 @@ export const TEAM_SEND_MESSAGE = 'team:sendMessage';
|
|||
/** Paginated messages for timeline/messages panel */
|
||||
export const TEAM_GET_MESSAGES_PAGE = 'team:getMessagesPage';
|
||||
|
||||
/** Lightweight message-derived member activity facts */
|
||||
export const TEAM_GET_MEMBER_ACTIVITY_META = 'team:getMemberActivityMeta';
|
||||
|
||||
/** Request review for task */
|
||||
export const TEAM_REQUEST_REVIEW = 'team:requestReview';
|
||||
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ import {
|
|||
TEAM_GET_DATA,
|
||||
TEAM_GET_DELETED_TASKS,
|
||||
TEAM_GET_LOGS_FOR_TASK,
|
||||
TEAM_GET_MEMBER_ACTIVITY_META,
|
||||
TEAM_GET_MEMBER_LOGS,
|
||||
TEAM_GET_MEMBER_STATS,
|
||||
TEAM_GET_MESSAGES_PAGE,
|
||||
|
|
@ -299,9 +300,9 @@ import type {
|
|||
TeamCreateConfigRequest,
|
||||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
TeamData,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamMemberActivityMeta,
|
||||
TeamMessageNotificationData,
|
||||
TeamProvisioningPrepareResult,
|
||||
TeamProvisioningProgress,
|
||||
|
|
@ -309,6 +310,7 @@ import type {
|
|||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
TeamUpdateConfigRequest,
|
||||
TeamViewSnapshot,
|
||||
ToolApprovalEvent,
|
||||
ToolApprovalFileContent,
|
||||
ToolApprovalSettings,
|
||||
|
|
@ -829,7 +831,7 @@ const electronAPI: ElectronAPI = {
|
|||
return invokeIpcWithResult<TeamSummary[]>(TEAM_LIST);
|
||||
},
|
||||
getData: async (teamName: string) => {
|
||||
return invokeIpcWithResult<TeamData>(TEAM_GET_DATA, teamName);
|
||||
return invokeIpcWithResult<TeamViewSnapshot>(TEAM_GET_DATA, teamName);
|
||||
},
|
||||
getTaskChangePresence: async (teamName: string) => {
|
||||
return invokeIpcWithResult<Record<string, TaskChangePresenceState>>(
|
||||
|
|
@ -897,10 +899,13 @@ const electronAPI: ElectronAPI = {
|
|||
},
|
||||
getMessagesPage: async (
|
||||
teamName: string,
|
||||
options?: { beforeTimestamp?: string; limit?: number }
|
||||
options?: { cursor?: string | null; limit?: number }
|
||||
) => {
|
||||
return invokeIpcWithResult<MessagesPage>(TEAM_GET_MESSAGES_PAGE, teamName, options);
|
||||
},
|
||||
getMemberActivityMeta: async (teamName: string) => {
|
||||
return invokeIpcWithResult<TeamMemberActivityMeta>(TEAM_GET_MEMBER_ACTIVITY_META, teamName);
|
||||
},
|
||||
createTask: async (teamName: string, request: CreateTaskRequest) => {
|
||||
return invokeIpcWithResult<TeamTask>(TEAM_CREATE_TASK, teamName, request);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -59,15 +59,16 @@ import type {
|
|||
TeamClaudeLogsResponse,
|
||||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
TeamData,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamMemberActivityMeta,
|
||||
TeamProvisioningPrepareResult,
|
||||
TeamProvisioningProgress,
|
||||
TeamsAPI,
|
||||
TeamSummary,
|
||||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
TeamViewSnapshot,
|
||||
TmuxAPI,
|
||||
TmuxStatus,
|
||||
TriggerTestResult,
|
||||
|
|
@ -678,7 +679,7 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
console.warn('[HttpAPIClient] teams API is not available in browser mode');
|
||||
return [];
|
||||
},
|
||||
getData: async (_teamName: string): Promise<TeamData> => {
|
||||
getData: async (_teamName: string): Promise<TeamViewSnapshot> => {
|
||||
throw new Error('Teams detail is not available in browser mode');
|
||||
},
|
||||
getTaskChangePresence: async (): Promise<
|
||||
|
|
@ -746,7 +747,15 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
throw new Error('Team messaging is not available in browser mode');
|
||||
},
|
||||
getMessagesPage: async () => {
|
||||
return { messages: [], nextCursor: null, hasMore: false };
|
||||
return { messages: [], nextCursor: null, hasMore: false, feedRevision: 'empty' };
|
||||
},
|
||||
getMemberActivityMeta: async (_teamName: string): Promise<TeamMemberActivityMeta> => {
|
||||
return {
|
||||
teamName: _teamName,
|
||||
computedAt: new Date(0).toISOString(),
|
||||
members: {},
|
||||
feedRevision: 'empty',
|
||||
};
|
||||
},
|
||||
createTask: async (_teamName: string, _request: CreateTaskRequest): Promise<TeamTask> => {
|
||||
throw new Error('Team task creation is not available in browser mode');
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
|
|||
import { useTabUI } from '@renderer/hooks/useTabUI';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
||||
|
|
@ -398,7 +399,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
|
|||
// Get team members for @mention highlighting and team names for @team linkification
|
||||
const { members, teams } = useStore(
|
||||
useShallow((s) => ({
|
||||
members: s.selectedTeamData?.members,
|
||||
members: selectResolvedMembersForTeamName(s, s.selectedTeamName),
|
||||
teams: s.teams,
|
||||
}))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
|
||||
import { formatTokensCompact } from '@renderer/utils/formatters';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
|
|
@ -86,7 +87,9 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
|
|||
const { isLight } = useTheme();
|
||||
|
||||
// Get team members for @mention highlighting
|
||||
const members = useStore(useShallow((s) => s.selectedTeamData?.members));
|
||||
const members = useStore(
|
||||
useShallow((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName))
|
||||
);
|
||||
const memberColorMap = useMemo(
|
||||
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
||||
[members]
|
||||
|
|
|
|||
|
|
@ -71,10 +71,21 @@ interface MarkdownViewerProps {
|
|||
onTeamClick?: (teamName: string) => void;
|
||||
}
|
||||
|
||||
interface CompactMarkdownPreviewProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
/** Optional precomputed team color map to avoid subscribing to the full team list. */
|
||||
teamColorByName?: ReadonlyMap<string, string>;
|
||||
/** Optional team click handler to avoid subscribing to store in leaf renderers. */
|
||||
onTeamClick?: (teamName: string) => void;
|
||||
}
|
||||
|
||||
const EMPTY_TEAMS: { teamName?: string; displayName?: string; color?: string }[] = [];
|
||||
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
|
||||
const NOOP_TEAM_CLICK = (): void => undefined;
|
||||
|
||||
type ViewerMarkdownMode = 'default' | 'compact-preview';
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
|
@ -322,53 +333,89 @@ function createViewerMarkdownComponents(
|
|||
isLight = false,
|
||||
teamColorByName: ReadonlyMap<string, string> = new Map(),
|
||||
onTeamClick?: (teamName: string) => void,
|
||||
copyCodeBlocks: boolean = false
|
||||
copyCodeBlocks: boolean = false,
|
||||
mode: ViewerMarkdownMode = 'default'
|
||||
): Components {
|
||||
const hl = (children: React.ReactNode): React.ReactNode =>
|
||||
searchCtx ? highlightSearchInChildren(children, searchCtx) : children;
|
||||
const isCompactPreview = mode === 'compact-preview';
|
||||
|
||||
const renderCompactInline = (
|
||||
children: React.ReactNode,
|
||||
className: string,
|
||||
style: React.CSSProperties
|
||||
): React.ReactElement => (
|
||||
<span className={className} style={style}>
|
||||
{hl(children)}{' '}
|
||||
</span>
|
||||
);
|
||||
|
||||
return {
|
||||
// Headings
|
||||
h1: ({ children }) => (
|
||||
<h1 className="mb-2 mt-4 text-xl font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||
{hl(children)}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="mb-2 mt-4 text-lg font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||
{hl(children)}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="mb-2 mt-3 text-base font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||
{hl(children)}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className="mb-1 mt-3 text-sm font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||
{hl(children)}
|
||||
</h4>
|
||||
),
|
||||
h5: ({ children }) => (
|
||||
<h5 className="mb-1 mt-2 text-sm font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||
{hl(children)}
|
||||
</h5>
|
||||
),
|
||||
h6: ({ children }) => (
|
||||
<h6 className="mb-1 mt-2 text-xs font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||
{hl(children)}
|
||||
</h6>
|
||||
),
|
||||
h1: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
|
||||
) : (
|
||||
<h1 className="mb-2 mt-4 text-xl font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||
{hl(children)}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
|
||||
) : (
|
||||
<h2 className="mb-2 mt-4 text-lg font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||
{hl(children)}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
|
||||
) : (
|
||||
<h3
|
||||
className="mb-2 mt-3 text-base font-semibold first:mt-0"
|
||||
style={{ color: PROSE_HEADING }}
|
||||
>
|
||||
{hl(children)}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
|
||||
) : (
|
||||
<h4 className="mb-1 mt-3 text-sm font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||
{hl(children)}
|
||||
</h4>
|
||||
),
|
||||
h5: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
renderCompactInline(children, 'font-medium', { color: PROSE_HEADING })
|
||||
) : (
|
||||
<h5 className="mb-1 mt-2 text-sm font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||
{hl(children)}
|
||||
</h5>
|
||||
),
|
||||
h6: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
renderCompactInline(children, 'font-medium', { color: PROSE_HEADING })
|
||||
) : (
|
||||
<h6 className="mb-1 mt-2 text-xs font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||
{hl(children)}
|
||||
</h6>
|
||||
),
|
||||
|
||||
// Paragraphs
|
||||
p: ({ children }) => (
|
||||
<p
|
||||
className="my-2 text-sm leading-relaxed first:mt-0 last:mb-0"
|
||||
style={{ color: PROSE_BODY }}
|
||||
>
|
||||
{hl(children)}
|
||||
</p>
|
||||
),
|
||||
p: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
renderCompactInline(children, '', { color: PROSE_BODY })
|
||||
) : (
|
||||
<p
|
||||
className="my-2 text-sm leading-relaxed first:mt-0 last:mb-0"
|
||||
style={{ color: PROSE_BODY }}
|
||||
>
|
||||
{hl(children)}
|
||||
</p>
|
||||
),
|
||||
|
||||
// Links — inline element, no hl(); parent block element's hl() descends here
|
||||
// task:// links render with TaskTooltip + are clickable via ancestor onClickCapture
|
||||
|
|
@ -570,6 +617,20 @@ function createViewerMarkdownComponents(
|
|||
|
||||
// Code blocks — intercept mermaid diagrams at the pre level
|
||||
pre: ({ children, node }) => {
|
||||
if (isCompactPreview) {
|
||||
const compactText = extractTextFromReactNode(children).trim();
|
||||
return (
|
||||
<code
|
||||
className="break-all rounded px-1.5 py-0.5 font-mono text-xs"
|
||||
style={{
|
||||
backgroundColor: PROSE_CODE_BG,
|
||||
color: PROSE_CODE_TEXT,
|
||||
}}
|
||||
>
|
||||
{compactText}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
// Check if this pre contains a mermaid code block
|
||||
const codeEl = node?.children?.[0];
|
||||
if (codeEl && 'tagName' in codeEl && codeEl.tagName === 'code' && 'properties' in codeEl) {
|
||||
|
|
@ -596,74 +657,107 @@ function createViewerMarkdownComponents(
|
|||
},
|
||||
|
||||
// Blockquotes
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote
|
||||
className="my-3 border-l-4 pl-4 italic"
|
||||
style={{
|
||||
borderColor: PROSE_BLOCKQUOTE_BORDER,
|
||||
color: PROSE_MUTED,
|
||||
}}
|
||||
>
|
||||
{hl(children)}
|
||||
</blockquote>
|
||||
),
|
||||
blockquote: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
renderCompactInline(children, 'italic', { color: PROSE_MUTED })
|
||||
) : (
|
||||
<blockquote
|
||||
className="my-3 border-l-4 pl-4 italic"
|
||||
style={{
|
||||
borderColor: PROSE_BLOCKQUOTE_BORDER,
|
||||
color: PROSE_MUTED,
|
||||
}}
|
||||
>
|
||||
{hl(children)}
|
||||
</blockquote>
|
||||
),
|
||||
|
||||
// Lists
|
||||
ul: ({ children }) => (
|
||||
<ul className="my-2 list-disc space-y-1 pl-5" style={{ color: PROSE_BODY }}>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="my-2 list-decimal space-y-1 pl-5" style={{ color: PROSE_BODY }}>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="text-sm" style={{ color: PROSE_BODY }}>
|
||||
{hl(children)}
|
||||
</li>
|
||||
),
|
||||
ul: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
<span>{children}</span>
|
||||
) : (
|
||||
<ul className="my-2 list-disc space-y-1 pl-5" style={{ color: PROSE_BODY }}>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
<span>{children}</span>
|
||||
) : (
|
||||
<ol className="my-2 list-decimal space-y-1 pl-5" style={{ color: PROSE_BODY }}>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
<span className="inline" style={{ color: PROSE_BODY }}>
|
||||
• {hl(children)}{' '}
|
||||
</span>
|
||||
) : (
|
||||
<li className="text-sm" style={{ color: PROSE_BODY }}>
|
||||
{hl(children)}
|
||||
</li>
|
||||
),
|
||||
|
||||
// Tables
|
||||
table: ({ children }) => (
|
||||
<div className="my-3 overflow-x-auto">
|
||||
<table
|
||||
className="min-w-full border-collapse text-sm"
|
||||
style={{ borderColor: PROSE_TABLE_BORDER }}
|
||||
table: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
<span>{children}</span>
|
||||
) : (
|
||||
<div className="my-3 overflow-x-auto">
|
||||
<table
|
||||
className="min-w-full border-collapse text-sm"
|
||||
style={{ borderColor: PROSE_TABLE_BORDER }}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
<span>{children}</span>
|
||||
) : (
|
||||
<thead style={{ backgroundColor: PROSE_TABLE_HEADER_BG }}>{children}</thead>
|
||||
),
|
||||
th: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
|
||||
) : (
|
||||
<th
|
||||
className="px-3 py-2 text-left font-semibold"
|
||||
style={{
|
||||
border: `1px solid ${PROSE_TABLE_BORDER}`,
|
||||
color: PROSE_HEADING,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead style={{ backgroundColor: PROSE_TABLE_HEADER_BG }}>{children}</thead>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th
|
||||
className="px-3 py-2 text-left font-semibold"
|
||||
style={{
|
||||
border: `1px solid ${PROSE_TABLE_BORDER}`,
|
||||
color: PROSE_HEADING,
|
||||
}}
|
||||
>
|
||||
{hl(children)}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td
|
||||
className="px-3 py-2"
|
||||
style={{
|
||||
border: `1px solid ${PROSE_TABLE_BORDER}`,
|
||||
color: PROSE_BODY,
|
||||
}}
|
||||
>
|
||||
{hl(children)}
|
||||
</td>
|
||||
),
|
||||
{hl(children)}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) =>
|
||||
isCompactPreview ? (
|
||||
renderCompactInline(children, '', { color: PROSE_BODY })
|
||||
) : (
|
||||
<td
|
||||
className="px-3 py-2"
|
||||
style={{
|
||||
border: `1px solid ${PROSE_TABLE_BORDER}`,
|
||||
color: PROSE_BODY,
|
||||
}}
|
||||
>
|
||||
{hl(children)}
|
||||
</td>
|
||||
),
|
||||
|
||||
// Horizontal rule
|
||||
hr: () => <hr className="my-4" style={{ borderColor: PROSE_TABLE_BORDER }} />,
|
||||
hr: () =>
|
||||
isCompactPreview ? (
|
||||
<span className="mx-1" style={{ color: PROSE_TABLE_BORDER }}>
|
||||
·
|
||||
</span>
|
||||
) : (
|
||||
<hr className="my-4" style={{ borderColor: PROSE_TABLE_BORDER }} />
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -679,6 +773,78 @@ const LARGE_PREVIEW_CHARS = 30_000;
|
|||
// Component
|
||||
// =============================================================================
|
||||
|
||||
function useResolvedViewerTeamContext(
|
||||
providedTeamColorByName?: ReadonlyMap<string, string>,
|
||||
providedOnTeamClick?: (teamName: string) => void
|
||||
): {
|
||||
teamColorByName: ReadonlyMap<string, string>;
|
||||
onTeamClick?: (teamName: string) => void;
|
||||
} {
|
||||
const teams = useStore(useShallow((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams)));
|
||||
const openTeamTab = useStore((s) => (providedOnTeamClick ? NOOP_TEAM_CLICK : s.openTeamTab));
|
||||
|
||||
const fallbackTeamColorByName = React.useMemo(() => {
|
||||
const result = new Map<string, string>();
|
||||
for (const team of teams) {
|
||||
if (team.teamName) {
|
||||
result.set(team.teamName, team.color ?? '');
|
||||
}
|
||||
if (team.displayName) {
|
||||
result.set(team.displayName, team.color ?? '');
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [teams]);
|
||||
|
||||
return {
|
||||
teamColorByName: providedTeamColorByName ?? fallbackTeamColorByName ?? EMPTY_TEAM_COLOR_MAP,
|
||||
onTeamClick: providedOnTeamClick ?? openTeamTab,
|
||||
};
|
||||
}
|
||||
|
||||
export const CompactMarkdownPreview: React.FC<CompactMarkdownPreviewProps> = React.memo(
|
||||
function CompactMarkdownPreview({
|
||||
content,
|
||||
className = '',
|
||||
teamColorByName: providedTeamColorByName,
|
||||
onTeamClick: providedOnTeamClick,
|
||||
}) {
|
||||
const { isLight } = useTheme();
|
||||
const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext(
|
||||
providedTeamColorByName,
|
||||
providedOnTeamClick
|
||||
);
|
||||
|
||||
const components = React.useMemo(
|
||||
() =>
|
||||
createViewerMarkdownComponents(
|
||||
null,
|
||||
isLight,
|
||||
teamColorByName,
|
||||
onTeamClick,
|
||||
false,
|
||||
'compact-preview'
|
||||
),
|
||||
[isLight, onTeamClick, teamColorByName]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`min-w-0 overflow-hidden ${className}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={REHYPE_PLUGINS_NO_HIGHLIGHT}
|
||||
components={components}
|
||||
urlTransform={allowCustomProtocols}
|
||||
allowElement={isAllowedElement}
|
||||
unwrapDisallowed
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||
content,
|
||||
maxHeight = 'max-h-96',
|
||||
|
|
@ -695,24 +861,10 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||
const [showRaw, setShowRaw] = React.useState(false);
|
||||
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
|
||||
const { isLight } = useTheme();
|
||||
const teams = useStore(useShallow((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams)));
|
||||
const openTeamTab = useStore((s) => (providedOnTeamClick ? NOOP_TEAM_CLICK : s.openTeamTab));
|
||||
|
||||
const fallbackTeamColorByName = React.useMemo(() => {
|
||||
const result = new Map<string, string>();
|
||||
for (const team of teams) {
|
||||
if (team.teamName) {
|
||||
result.set(team.teamName, team.color ?? '');
|
||||
}
|
||||
if (team.displayName) {
|
||||
result.set(team.displayName, team.color ?? '');
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [teams]);
|
||||
const teamColorByName =
|
||||
providedTeamColorByName ?? fallbackTeamColorByName ?? EMPTY_TEAM_COLOR_MAP;
|
||||
const onTeamClick = providedOnTeamClick ?? openTeamTab;
|
||||
const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext(
|
||||
providedTeamColorByName,
|
||||
providedOnTeamClick
|
||||
);
|
||||
|
||||
const isTooLarge = content.length > MAX_MARKDOWN_CHARS;
|
||||
const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS;
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import { useShallow } from 'zustand/react/shallow';
|
|||
|
||||
import { resolveSkillProjectPath } from './skillProjectUtils';
|
||||
|
||||
import type { SkillValidationIssue } from '@shared/types';
|
||||
import type { SkillValidationIssue } from '@shared/types/extensions';
|
||||
|
||||
interface SkillDetailDialogProps {
|
||||
skillId: string | null;
|
||||
|
|
|
|||
|
|
@ -144,11 +144,13 @@ export const SidebarTaskItem = ({
|
|||
);
|
||||
|
||||
const showTeamRow = showTeamName && !hideTeamName;
|
||||
const unreadBackgroundClass =
|
||||
unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : '';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full cursor-pointer flex-col justify-center border-b px-2 py-1.5 text-left transition-colors hover:bg-surface-raised ${unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.08]') : ''} ${task.teamDeleted ? 'opacity-50' : ''}`}
|
||||
className={`flex w-full cursor-pointer flex-col justify-center border-b px-2 py-1.5 text-left transition-colors hover:bg-surface-raised ${unreadBackgroundClass} ${task.teamDeleted ? 'opacity-50' : ''}`}
|
||||
style={{ borderColor: 'var(--color-border)' }}
|
||||
onClick={() => {
|
||||
if (!isRenaming) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer
|
|||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
||||
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
|
||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||
|
|
@ -70,14 +71,16 @@ export const TaskTooltip = ({
|
|||
children,
|
||||
side = 'top',
|
||||
}: TaskTooltipProps): React.JSX.Element => {
|
||||
const { selectedTeamName, selectedTeamData, globalTasks, teamByName } = useStore(
|
||||
useShallow((s) => ({
|
||||
selectedTeamName: s.selectedTeamName,
|
||||
selectedTeamData: s.selectedTeamData,
|
||||
globalTasks: s.globalTasks,
|
||||
teamByName: s.teamByName,
|
||||
}))
|
||||
);
|
||||
const { selectedTeamName, selectedTeamData, selectedTeamMembers, globalTasks, teamByName } =
|
||||
useStore(
|
||||
useShallow((s) => ({
|
||||
selectedTeamName: s.selectedTeamName,
|
||||
selectedTeamData: s.selectedTeamData,
|
||||
selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName),
|
||||
globalTasks: s.globalTasks,
|
||||
teamByName: s.teamByName,
|
||||
}))
|
||||
);
|
||||
|
||||
const task = useMemo(() => {
|
||||
if (teamName && selectedTeamName === teamName) {
|
||||
|
|
@ -105,13 +108,13 @@ export const TaskTooltip = ({
|
|||
|
||||
const members = useMemo(() => {
|
||||
if (teamName && selectedTeamName === teamName) {
|
||||
return selectedTeamData?.members ?? [];
|
||||
return selectedTeamMembers;
|
||||
}
|
||||
if (!teamName && task && selectedTeamName === (task as { teamName?: string }).teamName) {
|
||||
return selectedTeamData?.members ?? [];
|
||||
return selectedTeamMembers;
|
||||
}
|
||||
return [];
|
||||
}, [selectedTeamData, selectedTeamName, teamName, task]);
|
||||
}, [selectedTeamMembers, selectedTeamName, teamName, task]);
|
||||
|
||||
const colorMap = useMemo(
|
||||
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,14 @@
|
|||
import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
lazy,
|
||||
memo,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index';
|
||||
|
|
@ -23,6 +33,9 @@ import { useStore } from '@renderer/store';
|
|||
import {
|
||||
getCurrentProvisioningProgressForTeam,
|
||||
isTeamProvisioningActive,
|
||||
selectResolvedMemberForTeamName,
|
||||
selectResolvedMembersForTeamName,
|
||||
selectTeamMemberSnapshotsForName,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
||||
import { sumContextInjectionTokens } from '@renderer/utils/contextMath';
|
||||
|
|
@ -109,10 +122,10 @@ import type { ContextInjection } from '@renderer/types/contextInjection';
|
|||
import type { Session } from '@renderer/types/data';
|
||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||
import type {
|
||||
TeamAgentRuntimeEntry,
|
||||
MemberSpawnStatusEntry,
|
||||
ResolvedTeamMember,
|
||||
TaskRef,
|
||||
TeamAgentRuntimeEntry,
|
||||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
import type { EditorSelectionAction } from '@shared/types/editor';
|
||||
|
|
@ -833,8 +846,9 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
|||
}: TeamMemberDetailDialogBridgeProps): React.JSX.Element | null {
|
||||
const {
|
||||
leadActivity,
|
||||
liveMember,
|
||||
progress,
|
||||
members: launchMembers,
|
||||
launchMembers,
|
||||
memberSpawnStatuses,
|
||||
memberSpawnSnapshot,
|
||||
spawnEntry,
|
||||
|
|
@ -842,8 +856,9 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
|||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
leadActivity: s.leadActivityByTeam[teamName],
|
||||
liveMember: member ? selectResolvedMemberForTeamName(s, teamName, member.name) : null,
|
||||
progress: getCurrentProvisioningProgressForTeam(s, teamName),
|
||||
members: s.selectedTeamName === teamName ? (s.selectedTeamData?.members ?? []) : [],
|
||||
launchMembers: selectTeamMemberSnapshotsForName(s, teamName),
|
||||
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
||||
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
||||
spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined,
|
||||
|
|
@ -867,7 +882,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
|||
<MemberDetailDialog
|
||||
{...props}
|
||||
teamName={teamName}
|
||||
member={member}
|
||||
member={liveMember ?? member}
|
||||
isLaunchSettling={isLaunchSettling}
|
||||
leadActivity={leadActivity}
|
||||
spawnEntry={spawnEntry}
|
||||
|
|
@ -917,7 +932,6 @@ export const TeamDetailView = ({
|
|||
);
|
||||
const provisioningBannerRef = useRef<HTMLDivElement>(null);
|
||||
const wasProvisioningRef = useRef(false);
|
||||
const pendingReplyRefreshTimerRef = useRef<number | null>(null);
|
||||
const handleOpenGraphTab = useCallback(() => {
|
||||
const state = useStore.getState();
|
||||
const displayName = state.teamByName[teamName]?.displayName ?? teamName;
|
||||
|
|
@ -994,7 +1008,7 @@ export const TeamDetailView = ({
|
|||
initialActivityFilter,
|
||||
} = (e as CustomEvent).detail ?? {};
|
||||
if (tn !== teamName || !data) return;
|
||||
const member = data.members.find((m: { name: string }) => m.name === memberName);
|
||||
const member = members.find((m: { name: string }) => m.name === memberName);
|
||||
if (member) {
|
||||
setSelectedMember(member);
|
||||
setSelectedMemberView({
|
||||
|
|
@ -1155,6 +1169,7 @@ export const TeamDetailView = ({
|
|||
|
||||
const {
|
||||
data,
|
||||
members,
|
||||
loading,
|
||||
error,
|
||||
projects,
|
||||
|
|
@ -1185,6 +1200,7 @@ export const TeamDetailView = ({
|
|||
clearProvisioningError,
|
||||
isTeamProvisioning,
|
||||
refreshTeamData,
|
||||
syncTeamPendingReplyRefresh,
|
||||
kanbanFilterQuery,
|
||||
clearKanbanFilter,
|
||||
softDeleteTask,
|
||||
|
|
@ -1231,9 +1247,11 @@ export const TeamDetailView = ({
|
|||
clearProvisioningError: s.clearProvisioningError,
|
||||
isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false,
|
||||
data: s.selectedTeamName === teamName ? s.selectedTeamData : null,
|
||||
members: selectResolvedMembersForTeamName(s, teamName),
|
||||
loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false,
|
||||
error: s.selectedTeamName === teamName ? s.selectedTeamError : null,
|
||||
refreshTeamData: s.refreshTeamData,
|
||||
syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh,
|
||||
kanbanFilterQuery: s.kanbanFilterQuery,
|
||||
clearKanbanFilter: s.clearKanbanFilter,
|
||||
softDeleteTask: s.softDeleteTask,
|
||||
|
|
@ -1267,13 +1285,12 @@ export const TeamDetailView = ({
|
|||
diagnostic.count += 1;
|
||||
|
||||
const commitMs = performance.now() - renderStartedAtRef.current;
|
||||
const messagesCount = data?.messages.length ?? 0;
|
||||
const tasksCount = data?.tasks.length ?? 0;
|
||||
const membersCount = data?.members.length ?? 0;
|
||||
const membersCount = members.length;
|
||||
const processesCount = data?.processes.length ?? 0;
|
||||
const shouldWarnSlow = commitMs >= TEAM_DETAIL_COMMIT_WARN_MS;
|
||||
const shouldWarnBurst = diagnostic.count >= TEAM_DETAIL_RENDER_BURST_WARN_COUNT;
|
||||
const shouldWarnLarge = messagesCount >= 150 || tasksCount >= 80;
|
||||
const shouldWarnLarge = tasksCount >= 80;
|
||||
|
||||
if (
|
||||
(shouldWarnSlow || shouldWarnBurst || shouldWarnLarge) &&
|
||||
|
|
@ -1285,7 +1302,7 @@ export const TeamDetailView = ({
|
|||
now - diagnostic.windowStartedAt
|
||||
} activeTab=${isThisTabActive ? 'yes' : 'no'} paneFocused=${isPaneFocused ? 'yes' : 'no'} loading=${
|
||||
loading ? 'yes' : 'no'
|
||||
} messages=${messagesCount} tasks=${tasksCount} members=${membersCount} processes=${processesCount} panel=${messagesPanelMode}`
|
||||
} tasks=${tasksCount} members=${membersCount} processes=${processesCount} panel=${messagesPanelMode}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -1399,36 +1416,34 @@ export const TeamDetailView = ({
|
|||
);
|
||||
|
||||
const leadSessionId = data?.config.leadSessionId ?? null;
|
||||
const pendingReplyRefreshSourceId = useId();
|
||||
const sessionHistoryKey = useMemo(
|
||||
() => (data?.config.sessionHistory ?? []).join('|'),
|
||||
[data?.config.sessionHistory]
|
||||
);
|
||||
|
||||
// Keep team message state fresh while we are explicitly waiting for a reply.
|
||||
// Use a delayed single-shot refresh instead of a tight polling loop so we
|
||||
// don't keep rewriting the whole team snapshot every 2 seconds.
|
||||
// This stays enabled even for hidden mounted tabs, because the waiting state
|
||||
// is renderer-local and should keep its lightweight polling until resolved.
|
||||
useEffect(() => {
|
||||
if (pendingReplyRefreshTimerRef.current != null) {
|
||||
window.clearTimeout(pendingReplyRefreshTimerRef.current);
|
||||
pendingReplyRefreshTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (!isThisTabActive) return;
|
||||
if (!data?.isAlive) return;
|
||||
if (Object.keys(pendingRepliesByMember).length === 0) return;
|
||||
|
||||
pendingReplyRefreshTimerRef.current = window.setTimeout(() => {
|
||||
pendingReplyRefreshTimerRef.current = null;
|
||||
void refreshTeamData(teamName, { withDedup: true });
|
||||
}, TEAM_PENDING_REPLY_REFRESH_DELAY_MS);
|
||||
const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0;
|
||||
syncTeamPendingReplyRefresh(
|
||||
teamName,
|
||||
pendingReplyRefreshSourceId,
|
||||
Boolean(data?.isAlive) && hasPendingReplies,
|
||||
TEAM_PENDING_REPLY_REFRESH_DELAY_MS
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (pendingReplyRefreshTimerRef.current != null) {
|
||||
window.clearTimeout(pendingReplyRefreshTimerRef.current);
|
||||
pendingReplyRefreshTimerRef.current = null;
|
||||
}
|
||||
syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId, false);
|
||||
};
|
||||
}, [isThisTabActive, data, pendingRepliesByMember, refreshTeamData, teamName]);
|
||||
}, [
|
||||
data?.isAlive,
|
||||
pendingRepliesByMember,
|
||||
pendingReplyRefreshSourceId,
|
||||
syncTeamPendingReplyRefresh,
|
||||
teamName,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
|
|
@ -1462,9 +1477,9 @@ export const TeamDetailView = ({
|
|||
// Live git branch tracking for the lead project and member worktrees
|
||||
const teamProjectPath = data?.config.projectPath?.trim() ?? null;
|
||||
const leadProjectPath = useMemo(() => {
|
||||
const explicitLeadPath = data?.members.find((member) => isLeadMember(member))?.cwd?.trim();
|
||||
const explicitLeadPath = members.find((member) => isLeadMember(member))?.cwd?.trim();
|
||||
return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath;
|
||||
}, [data?.members, teamProjectPath]);
|
||||
}, [members, teamProjectPath]);
|
||||
const branchSyncPaths = useMemo(() => {
|
||||
const uniquePaths = new Map<string, string>();
|
||||
const addPath = (candidate: string | null | undefined): void => {
|
||||
|
|
@ -1476,12 +1491,12 @@ export const TeamDetailView = ({
|
|||
};
|
||||
|
||||
addPath(leadProjectPath);
|
||||
for (const member of data?.members ?? []) {
|
||||
for (const member of members) {
|
||||
addPath(member.cwd);
|
||||
}
|
||||
|
||||
return Array.from(uniquePaths.values());
|
||||
}, [data?.members, leadProjectPath]);
|
||||
}, [members, leadProjectPath]);
|
||||
useBranchSync(branchSyncPaths, { live: true });
|
||||
const trackedBranches = useStore(
|
||||
useShallow((s) =>
|
||||
|
|
@ -1499,7 +1514,7 @@ export const TeamDetailView = ({
|
|||
const membersWithLiveBranches = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
return data.members.map((member) => {
|
||||
return members.map((member) => {
|
||||
const memberPath = member.cwd?.trim();
|
||||
const nextGitBranch =
|
||||
memberPath && !isLeadMember(member) && leadBranch !== null
|
||||
|
|
@ -1521,7 +1536,7 @@ export const TeamDetailView = ({
|
|||
}
|
||||
return nextMember;
|
||||
});
|
||||
}, [data, leadBranch, trackedBranches]);
|
||||
}, [leadBranch, members, trackedBranches]);
|
||||
|
||||
// Filter sessions to team-only using sessionHistory + leadSessionId
|
||||
const teamSessionIds = useMemo(() => {
|
||||
|
|
@ -1885,7 +1900,6 @@ export const TeamDetailView = ({
|
|||
mountPoint: messagesPanelMountPoint,
|
||||
members: activeMembers,
|
||||
tasks: data?.tasks ?? [],
|
||||
messages: data?.messages ?? [],
|
||||
isTeamAlive: data?.isAlive,
|
||||
timeWindow,
|
||||
teamSessionIds,
|
||||
|
|
@ -1903,7 +1917,6 @@ export const TeamDetailView = ({
|
|||
activeMembers,
|
||||
data?.config.leadSessionId,
|
||||
data?.isAlive,
|
||||
data?.messages,
|
||||
data?.tasks,
|
||||
handleCreateTaskFromMessage,
|
||||
handleOpenTask,
|
||||
|
|
@ -2588,7 +2601,7 @@ export const TeamDetailView = ({
|
|||
open={requestChangesTaskId !== null}
|
||||
teamName={teamName}
|
||||
taskId={requestChangesTaskId}
|
||||
members={data?.members ?? []}
|
||||
members={members}
|
||||
onCancel={() => setRequestChangesTaskId(null)}
|
||||
onSubmit={(comment, taskRefs) => {
|
||||
if (!requestChangesTaskId) {
|
||||
|
|
@ -2615,7 +2628,6 @@ export const TeamDetailView = ({
|
|||
teamName={teamName}
|
||||
members={membersWithLiveBranches}
|
||||
tasks={data.tasks}
|
||||
messages={data.messages}
|
||||
initialTab={selectedMemberView?.initialTab}
|
||||
initialActivityFilter={selectedMemberView?.initialActivityFilter}
|
||||
isTeamAlive={data.isAlive}
|
||||
|
|
@ -2966,7 +2978,7 @@ export const TeamDetailView = ({
|
|||
if (task) setSelectedTask(task);
|
||||
}}
|
||||
onOpenMemberProfile={(memberName, options) => {
|
||||
const member = data.members.find((m) => m.name === memberName);
|
||||
const member = members.find((m) => m.name === memberName);
|
||||
if (member) {
|
||||
setSelectedMember(member);
|
||||
setSelectedMemberView({
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import type {
|
|||
ResolvedTeamMember,
|
||||
TeamCreateRequest,
|
||||
TeamLaunchRequest,
|
||||
TeamMemberSnapshot,
|
||||
TeamSummary,
|
||||
TeamSummaryMember,
|
||||
} from '@shared/types';
|
||||
|
|
@ -94,6 +95,17 @@ function folderName(fullPath: string): string {
|
|||
return getBaseName(fullPath) || fullPath;
|
||||
}
|
||||
|
||||
function resolveLaunchDialogMembers(members: readonly TeamMemberSnapshot[]): ResolvedTeamMember[] {
|
||||
return members.map((member) => {
|
||||
return {
|
||||
...member,
|
||||
status: member.currentTaskId ? 'active' : 'idle',
|
||||
messageCount: 0,
|
||||
lastActiveAt: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderMemberChips(members: TeamSummaryMember[], isLight: boolean): React.JSX.Element {
|
||||
const teamColorMap = buildMemberColorMap(members);
|
||||
return (
|
||||
|
|
@ -625,7 +637,7 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
try {
|
||||
const data = await api.teams.getData(teamName);
|
||||
setLaunchDialogTeamName(teamName);
|
||||
setLaunchDialogMembers(data.members ?? []);
|
||||
setLaunchDialogMembers(resolveLaunchDialogMembers(data.members ?? []));
|
||||
setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath);
|
||||
setLaunchDialogOpen(true);
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
import { shortenDisplayPath } from '@renderer/utils/pathDisplay';
|
||||
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
|
||||
import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react';
|
||||
|
|
@ -149,6 +150,7 @@ export const ToolApprovalSheet: React.FC = () => {
|
|||
teams,
|
||||
selectedTeamName,
|
||||
selectedTeamData,
|
||||
selectedTeamMembers,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
pendingApprovals: s.pendingApprovals,
|
||||
|
|
@ -157,6 +159,7 @@ export const ToolApprovalSheet: React.FC = () => {
|
|||
teams: s.teams,
|
||||
selectedTeamName: s.selectedTeamName,
|
||||
selectedTeamData: s.selectedTeamData,
|
||||
selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName),
|
||||
}))
|
||||
);
|
||||
const { isLight } = useTheme();
|
||||
|
|
@ -273,9 +276,9 @@ export const ToolApprovalSheet: React.FC = () => {
|
|||
// Resolve teammate color for MemberBadge (when source !== 'lead')
|
||||
const sourceColor = useMemo(() => {
|
||||
if (!current || current.source === 'lead') return undefined;
|
||||
const member = selectedTeamData?.members?.find((m) => m.name === current.source);
|
||||
const member = selectedTeamMembers.find((m) => m.name === current.source);
|
||||
return member?.color;
|
||||
}, [current, selectedTeamData?.members]);
|
||||
}, [current, selectedTeamMembers]);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
import { Fragment, memo, useCallback, useMemo } from 'react';
|
||||
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import {
|
||||
CompactMarkdownPreview,
|
||||
MarkdownViewer,
|
||||
} from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { CopyButton } from '@renderer/components/common/CopyButton';
|
||||
import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay';
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { TaskTooltip } from '@renderer/components/team/TaskTooltip';
|
||||
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@renderer/components/ui/tooltip';
|
||||
import {
|
||||
CARD_BG,
|
||||
CARD_BG_ZEBRA,
|
||||
|
|
@ -780,7 +788,7 @@ export const ActivityItem = memo(
|
|||
if (!isCrossTeamAny || !strippedText) return '';
|
||||
const oneLine = strippedText.replace(/\n+/g, ' ').trim();
|
||||
if (!oneLine) return '';
|
||||
return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine;
|
||||
return oneLine;
|
||||
}, [isCrossTeamAny, strippedText]);
|
||||
|
||||
const rawSummary = useMemo(() => {
|
||||
|
|
@ -806,8 +814,7 @@ export const ActivityItem = memo(
|
|||
// Fallback: use the beginning of message text as preview for plain-text messages
|
||||
const plain = getSanitizedInboxMessageText(message).trim();
|
||||
if (!plain) return '';
|
||||
const oneLine = plain.replace(/\n+/g, ' ');
|
||||
return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine;
|
||||
return plain.replace(/\n+/g, ' ');
|
||||
}, [
|
||||
crossTeamPreview,
|
||||
isSlashCommandMessage,
|
||||
|
|
@ -819,6 +826,46 @@ export const ActivityItem = memo(
|
|||
structured,
|
||||
]);
|
||||
const summaryText = extractMarkdownPlainText(rawSummary);
|
||||
const compactPreviewMarkdown = useMemo(() => {
|
||||
if (idleSemantic?.hasPeerSummary && idleSemantic.peerSummary) {
|
||||
return idleSemantic.peerSummary;
|
||||
}
|
||||
if (isSlashCommandResult && message.commandOutput) {
|
||||
return message.summary || getCommandOutputSummary(message.text);
|
||||
}
|
||||
if (isSlashCommandMessage && slashCommandMeta) {
|
||||
if (slashCommandMeta.args) {
|
||||
const oneLine = slashCommandMeta.args.replace(/\n+/g, ' ').trim();
|
||||
return `${slashCommandMeta.command} ${oneLine}`;
|
||||
}
|
||||
return slashCommandMeta.command;
|
||||
}
|
||||
if (crossTeamPreview) return crossTeamPreview;
|
||||
|
||||
const formattedDisplayText = displayText?.trim() ?? '';
|
||||
if (formattedDisplayText) {
|
||||
return formattedDisplayText;
|
||||
}
|
||||
|
||||
return summaryText || rawSummary;
|
||||
}, [
|
||||
crossTeamPreview,
|
||||
displayText,
|
||||
idleSemantic,
|
||||
isSlashCommandMessage,
|
||||
isSlashCommandResult,
|
||||
message,
|
||||
message.commandOutput,
|
||||
rawSummary,
|
||||
slashCommandMeta,
|
||||
summaryText,
|
||||
]);
|
||||
const compactPreviewTooltipText = useMemo(() => {
|
||||
const normalized = extractMarkdownPlainText(compactPreviewMarkdown)
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim();
|
||||
return normalized || compactPreviewMarkdown;
|
||||
}, [compactPreviewMarkdown]);
|
||||
const commentTaskRef =
|
||||
message.messageKind === 'task_comment_notification' ? (message.taskRefs?.[0] ?? null) : null;
|
||||
const commentTaskDisplayId =
|
||||
|
|
@ -1178,13 +1225,109 @@ export const ActivityItem = memo(
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 min-w-0 truncate text-[11px]"
|
||||
style={{ color: CARD_TEXT_LIGHT }}
|
||||
title={summaryText || rawSummary}
|
||||
>
|
||||
{summaryContent}
|
||||
<TooltipProvider delayDuration={1000}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<CompactMarkdownPreview
|
||||
content={compactPreviewMarkdown}
|
||||
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
|
||||
teamColorByName={teamColorByName}
|
||||
onTeamClick={onTeamClick}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="max-w-sm whitespace-normal break-words"
|
||||
>
|
||||
{compactPreviewTooltipText}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
) : !isExpanded ? (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{isUnread ? (
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full bg-blue-500"
|
||||
title="Unread"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{showChevron ? (
|
||||
<ChevronRight
|
||||
className="size-3 shrink-0 transition-transform duration-150"
|
||||
style={{
|
||||
color: CARD_ICON_MUTED,
|
||||
transform: isExpanded ? 'rotate(90deg)' : undefined,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{crossTeamOrigin ? (
|
||||
<CrossTeamTeamBadge teamName={crossTeamOrigin.teamName} onClick={onTeamClick} />
|
||||
) : null}
|
||||
{senderBadge}
|
||||
{!compactHeader && formattedRole && !isSlashCommandResult ? (
|
||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
{formattedRole}
|
||||
</span>
|
||||
) : null}
|
||||
{messageTypeBadge}
|
||||
{leadSourceBadge}
|
||||
{statusBadge}
|
||||
{recipientBadge}
|
||||
<div className="relative ml-auto flex shrink-0 items-center">
|
||||
<span
|
||||
className={
|
||||
onExpand && expandItemKey
|
||||
? 'text-[10px] transition-opacity group-hover:opacity-0'
|
||||
: 'text-[10px]'
|
||||
}
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
>
|
||||
{timestamp}
|
||||
</span>
|
||||
{onExpand && expandItemKey && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Expand message"
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 rounded p-0.5 opacity-0 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500/50 group-hover:opacity-100"
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExpand(expandItemKey);
|
||||
}}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TooltipProvider delayDuration={1000}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<CompactMarkdownPreview
|
||||
content={compactPreviewMarkdown}
|
||||
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
|
||||
teamColorByName={teamColorByName}
|
||||
onTeamClick={onTeamClick}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="max-w-sm whitespace-normal break-words"
|
||||
>
|
||||
{compactPreviewTooltipText}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -9,8 +9,14 @@ import {
|
|||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { CompactMarkdownPreview } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@renderer/components/ui/tooltip';
|
||||
import {
|
||||
CARD_BG,
|
||||
CARD_BG_ZEBRA,
|
||||
|
|
@ -26,12 +32,14 @@ import {
|
|||
areThoughtMessagesEquivalentForRender,
|
||||
} from '@renderer/utils/messageRenderEquality';
|
||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
|
||||
import { isThoughtProtocolNoise } from '@shared/utils/inboxNoise';
|
||||
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
|
||||
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Maximize2 } from 'lucide-react';
|
||||
|
||||
import { buildThoughtDisplayContent } from './activityMarkdown';
|
||||
import {
|
||||
AnimatedHeightReveal,
|
||||
ENTRY_REVEAL_ANIMATION_MS,
|
||||
|
|
@ -582,18 +590,30 @@ const LeadThoughtsGroupRowComponent = ({
|
|||
return calls.length > 0 ? calls : undefined;
|
||||
}, [thoughts]);
|
||||
|
||||
// Extract text preview for header: use newest thought's text, fallback through group
|
||||
const headerTextPreview = useMemo(() => {
|
||||
// Reuse the same markdown preprocessing as the expanded thought body.
|
||||
const compactPreviewMarkdown = useMemo(() => {
|
||||
// Try newest first (most relevant), then scan for any text
|
||||
for (const t of thoughts) {
|
||||
if (t.text && t.text.trim()) {
|
||||
const plain = extractMarkdownPlainText(t.text);
|
||||
const firstLine = plain.split('\n').find((l) => l.trim().length > 0) ?? '';
|
||||
return firstLine.trim();
|
||||
const stripped = stripAgentBlocks(t.text).trim();
|
||||
if (stripped) {
|
||||
return buildThoughtDisplayContent(t, memberColorMap, teamNames, {
|
||||
preserveLineBreaks: false,
|
||||
stripAgentOnlyBlocks: true,
|
||||
})
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [thoughts]);
|
||||
return totalToolSummary;
|
||||
}, [memberColorMap, teamNames, thoughts, totalToolSummary]);
|
||||
const compactPreviewTooltipText = useMemo(() => {
|
||||
const normalized = extractMarkdownPlainText(compactPreviewMarkdown ?? '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim();
|
||||
return normalized || compactPreviewMarkdown;
|
||||
}, [compactPreviewMarkdown]);
|
||||
|
||||
// Detect if any thought in this group is an API error
|
||||
const hasApiError = useMemo(() => thoughts.some((t) => isApiErrorMessage(t.text)), [thoughts]);
|
||||
|
|
@ -756,7 +776,6 @@ const LeadThoughtsGroupRowComponent = ({
|
|||
? formatTime(oldest.timestamp)
|
||||
: `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`;
|
||||
const useCompactCollapsedHeader = compactHeader && !isBodyVisible;
|
||||
const compactPreviewText = headerTextPreview ?? totalToolSummary;
|
||||
|
||||
return (
|
||||
<AnimatedHeightReveal animate={isNew} containerRef={ref} style={{ overflowAnchor: 'none' }}>
|
||||
|
|
@ -829,14 +848,113 @@ const LeadThoughtsGroupRowComponent = ({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{compactPreviewText ? (
|
||||
<div
|
||||
className="mt-1 min-w-0 truncate text-[11px]"
|
||||
style={{ color: headerTextPreview ? CARD_TEXT_LIGHT : CARD_ICON_MUTED }}
|
||||
title={compactPreviewText}
|
||||
>
|
||||
{compactPreviewText}
|
||||
{compactPreviewMarkdown ? (
|
||||
<TooltipProvider delayDuration={1000}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<CompactMarkdownPreview
|
||||
content={compactPreviewMarkdown}
|
||||
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
|
||||
teamColorByName={teamColorByName}
|
||||
onTeamClick={onTeamClick}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="max-w-sm whitespace-normal break-words"
|
||||
>
|
||||
{compactPreviewTooltipText}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</div>
|
||||
) : !isBodyVisible ? (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{canToggleBodyVisibility && !compactHeader ? (
|
||||
<ChevronRight
|
||||
className="size-3 shrink-0 transition-transform duration-150"
|
||||
style={{
|
||||
color: CARD_ICON_MUTED,
|
||||
transform: isBodyVisible ? 'rotate(90deg)' : undefined,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{!compactHeader ? (
|
||||
<div className="relative shrink-0">
|
||||
<img
|
||||
src={agentAvatarUrl(leadName, 24)}
|
||||
alt=""
|
||||
className="size-5 rounded-full bg-[var(--color-surface-raised)]"
|
||||
loading="lazy"
|
||||
/>
|
||||
<LiveThoughtStatusBadge
|
||||
canBeLive={canBeLive}
|
||||
isTeamAlive={isTeamAlive}
|
||||
leadActivity={leadActivity}
|
||||
leadContextUpdatedAt={leadContextUpdatedAt}
|
||||
newestTimestamp={newest.timestamp}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<MemberBadge name={leadName} color={memberColor} hideAvatar />
|
||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
{thoughts.length} thoughts
|
||||
</span>
|
||||
<div className="relative ml-auto flex shrink-0 items-center">
|
||||
<span
|
||||
className={
|
||||
onExpand && expandItemKey
|
||||
? 'text-[10px] transition-opacity group-hover:opacity-0'
|
||||
: 'text-[10px]'
|
||||
}
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
>
|
||||
{timestampLabel}
|
||||
</span>
|
||||
{onExpand && expandItemKey && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Expand thoughts"
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 rounded p-0.5 opacity-0 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500/50 group-hover:opacity-100"
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExpand(expandItemKey);
|
||||
}}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{compactPreviewMarkdown ? (
|
||||
<TooltipProvider delayDuration={1000}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<CompactMarkdownPreview
|
||||
content={compactPreviewMarkdown}
|
||||
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
|
||||
teamColorByName={teamColorByName}
|
||||
onTeamClick={onTeamClick}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="max-w-sm whitespace-normal break-words"
|
||||
>
|
||||
{compactPreviewTooltipText}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -871,26 +989,7 @@ const LeadThoughtsGroupRowComponent = ({
|
|||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
{thoughts.length} thoughts
|
||||
</span>
|
||||
{!isBodyVisible && headerTextPreview ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="min-w-0 flex-1 cursor-default truncate text-[10px]"
|
||||
style={{ color: CARD_TEXT_LIGHT }}
|
||||
>
|
||||
{headerTextPreview}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{totalToolSummary ? (
|
||||
<TooltipContent side="bottom" className="max-w-[420px] font-mono text-[11px]">
|
||||
<ToolSummaryTooltipContent
|
||||
toolCalls={allToolCalls}
|
||||
toolSummary={totalToolSummary}
|
||||
/>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
) : totalToolSummary ? (
|
||||
{totalToolSummary ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
|
|
|
|||
|
|
@ -4,17 +4,16 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer
|
|||
import { CopyButton } from '@renderer/components/common/CopyButton';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { CARD_ICON_MUTED, CARD_TEXT_LIGHT } from '@renderer/constants/cssVariables';
|
||||
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
||||
import {
|
||||
areStringArraysEqual,
|
||||
areStringMapsEqual,
|
||||
areThoughtMessagesEquivalentForRender,
|
||||
} from '@renderer/utils/messageRenderEquality';
|
||||
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
||||
import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
||||
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
|
||||
import { stripTeammateMessageBlocks } from '@shared/utils/inboxNoise';
|
||||
import { Reply } from 'lucide-react';
|
||||
|
||||
import { buildThoughtDisplayContent } from './activityMarkdown';
|
||||
import { formatTimeWithSec, ToolSummaryTooltipContent } from './LeadThoughtsGroup';
|
||||
|
||||
import type { InboxMessage } from '@shared/types';
|
||||
|
|
@ -42,17 +41,10 @@ export const ThoughtBodyContent = memo(
|
|||
onTeamClick,
|
||||
}: ThoughtBodyContentProps): JSX.Element {
|
||||
const displayContent = useMemo(() => {
|
||||
// Strip leaked protocol XML (<teammate-message> blocks) before rendering
|
||||
let text = stripTeammateMessageBlocks(thought.text).replace(/\n/g, ' \n');
|
||||
text = linkifyTaskIdsInMarkdown(text, thought.taskRefs);
|
||||
if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) {
|
||||
text = linkifyAllMentionsInMarkdown(
|
||||
text,
|
||||
(memberColorMap ?? new Map()) as Map<string, string>,
|
||||
teamNames
|
||||
);
|
||||
}
|
||||
return text;
|
||||
return buildThoughtDisplayContent(thought, memberColorMap, teamNames, {
|
||||
preserveLineBreaks: true,
|
||||
stripAgentOnlyBlocks: true,
|
||||
});
|
||||
}, [thought.text, thought.taskRefs, memberColorMap, teamNames]);
|
||||
|
||||
const handleTaskLinkClick = useCallback(
|
||||
|
|
|
|||
36
src/renderer/components/team/activity/activityMarkdown.ts
Normal file
36
src/renderer/components/team/activity/activityMarkdown.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -43,12 +43,9 @@ import {
|
|||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import {
|
||||
getTeamModelSelectionError,
|
||||
normalizeTeamModelForUi,
|
||||
normalizeExplicitTeamModelForUi,
|
||||
} from '@renderer/utils/teamModelAvailability';
|
||||
import {
|
||||
getTeamProviderLabel as getCatalogTeamProviderLabel,
|
||||
normalizeTeamModelForUi as normalizeCatalogTeamModelForUi,
|
||||
} from '@renderer/utils/teamModelCatalog';
|
||||
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
|
||||
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
|
||||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||
|
|
@ -56,7 +53,9 @@ import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
|||
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
||||
import {
|
||||
buildReusableProviderPrepareModelResults,
|
||||
getProviderPrepareCachedSnapshot,
|
||||
type ProviderPrepareDiagnosticsModelResult,
|
||||
runProviderPrepareDiagnostics,
|
||||
|
|
@ -111,7 +110,7 @@ function getStoredTeamModel(providerId: TeamProviderId): string {
|
|||
if (stored === null) {
|
||||
return providerId === 'anthropic' ? 'opus' : '';
|
||||
}
|
||||
return normalizeCatalogTeamModelForUi(providerId, stored === '__default__' ? '' : stored);
|
||||
return normalizeExplicitTeamModelForUi(providerId, stored === '__default__' ? '' : stored);
|
||||
}
|
||||
|
||||
function isEphemeralRenderedProjectPath(projectPath: string | null | undefined): boolean {
|
||||
|
|
@ -127,14 +126,6 @@ function getProviderLabel(providerId: TeamProviderId): string {
|
|||
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
|
||||
}
|
||||
|
||||
function buildPrepareModelCacheKey(
|
||||
cwd: string,
|
||||
providerId: TeamProviderId,
|
||||
backendSummary: string | null | undefined
|
||||
): string {
|
||||
return `${cwd}::${providerId}::${backendSummary ?? ''}`;
|
||||
}
|
||||
|
||||
function alignProvisioningChecks(
|
||||
existingChecks: ProvisioningProviderCheck[],
|
||||
providerIds: TeamProviderId[]
|
||||
|
|
@ -408,7 +399,7 @@ export const CreateTeamDialog = ({
|
|||
}, [advancedKey]);
|
||||
|
||||
const setSelectedModel = (value: string): void => {
|
||||
const normalizedValue = normalizeTeamModelForUi(selectedProviderId, value);
|
||||
const normalizedValue = normalizeExplicitTeamModelForUi(selectedProviderId, value);
|
||||
setSelectedModelRaw(normalizedValue);
|
||||
localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, normalizedValue);
|
||||
};
|
||||
|
|
@ -646,7 +637,12 @@ export const CreateTeamDialog = ({
|
|||
return Array.from(next);
|
||||
})();
|
||||
const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
|
||||
const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary);
|
||||
const cacheKey = buildProviderPrepareModelCacheKey({
|
||||
cwd: effectiveCwd,
|
||||
providerId,
|
||||
backendSummary,
|
||||
limitContext,
|
||||
});
|
||||
const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
|
||||
const cachedSnapshot = getProviderPrepareCachedSnapshot({
|
||||
providerId,
|
||||
|
|
@ -716,7 +712,7 @@ export const CreateTeamDialog = ({
|
|||
}
|
||||
prepareModelResultsCacheRef.current.set(
|
||||
plan.cacheKey,
|
||||
plan.prepResult.modelResultsById
|
||||
buildReusableProviderPrepareModelResults(plan.prepResult.modelResultsById)
|
||||
);
|
||||
checks = updateProviderCheck(checks, plan.providerId, {
|
||||
status: plan.prepResult.status,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
import { buildTaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
|
@ -24,6 +25,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
|||
closeGlobalTaskDetail,
|
||||
selectedTeamName,
|
||||
selectedTeamData,
|
||||
selectedTeamMembers,
|
||||
selectedTeamLoading,
|
||||
selectedTeamError,
|
||||
selectTeam,
|
||||
|
|
@ -36,6 +38,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
|||
closeGlobalTaskDetail: s.closeGlobalTaskDetail,
|
||||
selectedTeamName: s.selectedTeamName,
|
||||
selectedTeamData: s.selectedTeamData,
|
||||
selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName),
|
||||
selectedTeamLoading: s.selectedTeamLoading,
|
||||
selectedTeamError: s.selectedTeamError,
|
||||
selectTeam: s.selectTeam,
|
||||
|
|
@ -94,8 +97,8 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
|||
}, [globalTaskDetail, globalTasks, isFullTeamLoaded, selectedTeamData]);
|
||||
|
||||
const activeMembers = useMemo(
|
||||
() => (isFullTeamLoaded ? (selectedTeamData?.members.filter((m) => !m.removedAt) ?? []) : []),
|
||||
[isFullTeamLoaded, selectedTeamData]
|
||||
() => (isFullTeamLoaded ? selectedTeamMembers.filter((m) => !m.removedAt) : []),
|
||||
[isFullTeamLoaded, selectedTeamMembers]
|
||||
);
|
||||
|
||||
const handleOpenTeam = useCallback((): void => {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,10 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
|
|||
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
||||
import {
|
||||
isTeamProvisioningActive,
|
||||
selectResolvedMembersForTeamName,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import {
|
||||
isGeminiUiFrozen,
|
||||
normalizeCreateLaunchProviderForUi,
|
||||
|
|
@ -45,12 +48,9 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
|
|||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
import {
|
||||
getTeamModelSelectionError,
|
||||
normalizeTeamModelForUi,
|
||||
normalizeExplicitTeamModelForUi,
|
||||
} from '@renderer/utils/teamModelAvailability';
|
||||
import {
|
||||
getTeamProviderLabel as getCatalogTeamProviderLabel,
|
||||
normalizeTeamModelForUi as normalizeCatalogTeamModelForUi,
|
||||
} from '@renderer/utils/teamModelCatalog';
|
||||
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
|
||||
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
|
||||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import {
|
||||
|
|
@ -72,7 +72,9 @@ import { EffortLevelSelector } from './EffortLevelSelector';
|
|||
import { resolveLaunchDialogPrefill } from './launchDialogPrefill';
|
||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
||||
import {
|
||||
buildReusableProviderPrepareModelResults,
|
||||
getProviderPrepareCachedSnapshot,
|
||||
type ProviderPrepareDiagnosticsModelResult,
|
||||
runProviderPrepareDiagnostics,
|
||||
|
|
@ -109,14 +111,6 @@ import type {
|
|||
UpdateSchedulePatch,
|
||||
} from '@shared/types';
|
||||
|
||||
function buildPrepareModelCacheKey(
|
||||
cwd: string,
|
||||
providerId: TeamProviderId,
|
||||
backendSummary: string | null | undefined
|
||||
): string {
|
||||
return `${cwd}::${providerId}::${backendSummary ?? ''}`;
|
||||
}
|
||||
|
||||
function alignProvisioningChecks(
|
||||
existingChecks: ProvisioningProviderCheck[],
|
||||
providerIds: TeamProviderId[]
|
||||
|
|
@ -195,7 +189,7 @@ function getStoredTeamModel(providerId: TeamProviderId): string {
|
|||
if (stored === null) {
|
||||
return providerId === 'anthropic' ? 'opus' : '';
|
||||
}
|
||||
return normalizeCatalogTeamModelForUi(providerId, stored === '__default__' ? '' : stored);
|
||||
return normalizeExplicitTeamModelForUi(providerId, stored === '__default__' ? '' : stored);
|
||||
}
|
||||
|
||||
function getProviderLabel(providerId: TeamProviderId): string {
|
||||
|
|
@ -319,7 +313,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
||||
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
|
||||
const prepareRequestSeqRef = useRef(0);
|
||||
const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []);
|
||||
const storeMembers = useStore((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName));
|
||||
const previousLaunchParams = useStore((s) =>
|
||||
effectiveTeamName ? s.launchParamsByTeam[effectiveTeamName] : undefined
|
||||
);
|
||||
|
|
@ -467,7 +461,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
};
|
||||
|
||||
const setSelectedModel = (value: string): void => {
|
||||
const normalizedValue = normalizeTeamModelForUi(selectedProviderId, value);
|
||||
const normalizedValue = normalizeExplicitTeamModelForUi(selectedProviderId, value);
|
||||
setSelectedModelRaw(normalizedValue);
|
||||
localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, normalizedValue);
|
||||
};
|
||||
|
|
@ -932,7 +926,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
const providerPlans = selectedMemberProviders.map((providerId) => {
|
||||
const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? [];
|
||||
const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
|
||||
const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary);
|
||||
const cacheKey = buildProviderPrepareModelCacheKey({
|
||||
cwd: effectiveCwd,
|
||||
providerId,
|
||||
backendSummary,
|
||||
limitContext,
|
||||
});
|
||||
const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
|
||||
const cachedSnapshot = getProviderPrepareCachedSnapshot({
|
||||
providerId,
|
||||
|
|
@ -1000,7 +999,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
} else if (plan.prepResult.status === 'notes') {
|
||||
anyNotes = true;
|
||||
}
|
||||
prepareModelResultsCacheRef.current.set(plan.cacheKey, plan.prepResult.modelResultsById);
|
||||
prepareModelResultsCacheRef.current.set(
|
||||
plan.cacheKey,
|
||||
buildReusableProviderPrepareModelResults(plan.prepResult.modelResultsById)
|
||||
);
|
||||
checks = updateProviderCheck(checks, plan.providerId, {
|
||||
status: plan.prepResult.status,
|
||||
backendSummary: plan.backendSummary,
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ function summarizeDetail(
|
|||
) {
|
||||
return 'CLI binary could not be started';
|
||||
}
|
||||
if (lower.includes('preflight check for `claude -p` did not complete')) {
|
||||
if (lower.includes('preflight check for `') && lower.includes('-p` did not complete')) {
|
||||
return 'CLI preflight did not complete';
|
||||
}
|
||||
if (lower.includes('not authenticated') || lower.includes('not logged in')) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
|
||||
import { normalizeTeamModelForUi as normalizeCatalogTeamModelForUi } from '@renderer/utils/teamModelCatalog';
|
||||
import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
||||
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
|
@ -102,7 +102,7 @@ export function resolveLaunchDialogPrefill({
|
|||
return {
|
||||
providerId,
|
||||
model: matchingModel
|
||||
? normalizeCatalogTeamModelForUi(providerId, matchingModel)
|
||||
? normalizeExplicitTeamModelForUi(providerId, matchingModel)
|
||||
: getStoredModel(providerId),
|
||||
effort,
|
||||
limitContext,
|
||||
|
|
|
|||
|
|
@ -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('::');
|
||||
}
|
||||
|
|
@ -39,6 +39,14 @@ export interface ProviderPrepareDiagnosticsResult {
|
|||
modelResultsById: Record<string, ProviderPrepareDiagnosticsModelResult>;
|
||||
}
|
||||
|
||||
export function buildReusableProviderPrepareModelResults(
|
||||
modelResultsById: Record<string, ProviderPrepareDiagnosticsModelResult>
|
||||
): Record<string, ProviderPrepareDiagnosticsModelResult> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(modelResultsById).filter(([, result]) => result.status !== 'notes')
|
||||
);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
|
@ -187,6 +195,29 @@ function getResultReason(modelId: string, result: TeamProvisioningPrepareResult)
|
|||
return null;
|
||||
}
|
||||
|
||||
function getModelScopedEntries(modelId: string, result: TeamProvisioningPrepareResult): string[] {
|
||||
const escapedModelId = escapeRegExp(modelId);
|
||||
const scopedPattern = new RegExp(`^Selected model ${escapedModelId}\\b`, 'i');
|
||||
return [...(result.details ?? []), ...(result.warnings ?? []), result.message]
|
||||
.map((entry) => entry?.trim() ?? '')
|
||||
.filter(Boolean)
|
||||
.filter((entry) => scopedPattern.test(entry));
|
||||
}
|
||||
|
||||
function getScopedModelReason(modelId: string, entries: string[]): string | null {
|
||||
for (const entry of entries) {
|
||||
const stripped = stripSelectedModelPrefix(modelId, entry);
|
||||
if (!stripped) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeModelReason(stripped);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildModelFailureLine(
|
||||
providerId: TeamProviderId,
|
||||
modelId: string,
|
||||
|
|
@ -201,6 +232,116 @@ function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string
|
|||
return [...(result.details ?? []), ...(result.warnings ?? [])];
|
||||
}
|
||||
|
||||
function extractTimedOutPreflightProbeModelId(detail: string): string | null {
|
||||
const trimmed = detail.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!trimmed.toLowerCase().includes('preflight check for `') ||
|
||||
!trimmed.toLowerCase().includes('-p` did not complete')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const match = /--model\s+([^\s]+)/i.exec(trimmed);
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
function suppressSupersededRuntimeWarnings(params: {
|
||||
runtimeDetailLines: string[];
|
||||
runtimeWarnings: string[];
|
||||
modelResultsById: Map<string, ProviderPrepareDiagnosticsModelResult>;
|
||||
}): {
|
||||
runtimeDetailLines: string[];
|
||||
runtimeWarnings: string[];
|
||||
} {
|
||||
const suppressedEntries = new Set<string>();
|
||||
|
||||
for (const warning of params.runtimeWarnings) {
|
||||
const probedModelId = extractTimedOutPreflightProbeModelId(warning);
|
||||
if (!probedModelId) {
|
||||
continue;
|
||||
}
|
||||
if (params.modelResultsById.get(probedModelId)?.status !== 'ready') {
|
||||
continue;
|
||||
}
|
||||
suppressedEntries.add(warning);
|
||||
}
|
||||
|
||||
return {
|
||||
runtimeDetailLines: params.runtimeDetailLines.filter(
|
||||
(detail) => !suppressedEntries.has(detail)
|
||||
),
|
||||
runtimeWarnings: params.runtimeWarnings.filter((warning) => !suppressedEntries.has(warning)),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveModelResultFromBatch(
|
||||
providerId: TeamProviderId,
|
||||
modelId: string,
|
||||
result: TeamProvisioningPrepareResult,
|
||||
isOnlyModel: boolean
|
||||
): ProviderPrepareDiagnosticsModelResult {
|
||||
const modelScopedEntries = getModelScopedEntries(modelId, result);
|
||||
const normalizedReason =
|
||||
getScopedModelReason(modelId, modelScopedEntries) ??
|
||||
(isOnlyModel ? normalizeModelReason(result.message) : null);
|
||||
|
||||
const hasVerifiedLine = modelScopedEntries.some((entry) =>
|
||||
/selected model .* verified for launch\./i.test(entry)
|
||||
);
|
||||
if (hasVerifiedLine) {
|
||||
return {
|
||||
status: 'ready',
|
||||
line: buildModelSuccessLine(providerId, modelId),
|
||||
warningLine: null,
|
||||
};
|
||||
}
|
||||
|
||||
const hasUnavailableLine = modelScopedEntries.some((entry) =>
|
||||
/selected model .* is unavailable\./i.test(entry)
|
||||
);
|
||||
if (hasUnavailableLine || (!result.ready && isOnlyModel)) {
|
||||
return {
|
||||
status: 'failed',
|
||||
line: buildModelFailureLine(providerId, modelId, 'unavailable', normalizedReason),
|
||||
warningLine: null,
|
||||
};
|
||||
}
|
||||
|
||||
const hasVerificationWarningLine = modelScopedEntries.some((entry) =>
|
||||
/selected model .* could not be verified\./i.test(entry)
|
||||
);
|
||||
if (hasVerificationWarningLine || ((result.warnings?.length ?? 0) > 0 && isOnlyModel)) {
|
||||
const line = buildModelFailureLine(providerId, modelId, 'check failed', normalizedReason);
|
||||
return {
|
||||
status: 'notes',
|
||||
line,
|
||||
warningLine: line,
|
||||
};
|
||||
}
|
||||
|
||||
if (result.ready) {
|
||||
return {
|
||||
status: 'ready',
|
||||
line: buildModelSuccessLine(providerId, modelId),
|
||||
warningLine: null,
|
||||
};
|
||||
}
|
||||
|
||||
const line = buildModelFailureLine(
|
||||
providerId,
|
||||
modelId,
|
||||
'check failed',
|
||||
normalizedReason ?? 'Model verification failed'
|
||||
);
|
||||
return {
|
||||
status: 'notes',
|
||||
line,
|
||||
warningLine: line,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runProviderPrepareDiagnostics({
|
||||
cwd,
|
||||
providerId,
|
||||
|
|
@ -254,7 +395,7 @@ export async function runProviderPrepareDiagnostics({
|
|||
const modelLines = new Map<string, string>();
|
||||
let completedCount = 0;
|
||||
let hasFailure = false;
|
||||
let hasNotes = runtimeWarnings.length > 0;
|
||||
let hasNotes = false;
|
||||
const modelWarnings: string[] = [];
|
||||
|
||||
for (const modelId of orderedModelIds) {
|
||||
|
|
@ -289,73 +430,64 @@ export async function runProviderPrepareDiagnostics({
|
|||
|
||||
emitProgress();
|
||||
|
||||
await Promise.all(
|
||||
orderedModelIds
|
||||
.filter((modelId) => !modelResultsById.has(modelId))
|
||||
.map(async (modelId) => {
|
||||
try {
|
||||
const modelResult = await prepareProvisioning(
|
||||
cwd,
|
||||
providerId,
|
||||
[providerId],
|
||||
[modelId],
|
||||
limitContext
|
||||
);
|
||||
if (!modelResult.ready) {
|
||||
hasFailure = true;
|
||||
const line = buildModelFailureLine(
|
||||
providerId,
|
||||
modelId,
|
||||
'unavailable',
|
||||
getResultReason(modelId, modelResult) ?? normalizeModelReason(modelResult.message)
|
||||
);
|
||||
modelLines.set(modelId, line);
|
||||
modelResultsById.set(modelId, {
|
||||
status: 'failed',
|
||||
line,
|
||||
warningLine: null,
|
||||
});
|
||||
} else if ((modelResult.warnings?.length ?? 0) > 0) {
|
||||
hasNotes = true;
|
||||
const reason = getResultReason(modelId, modelResult);
|
||||
const line = buildModelFailureLine(providerId, modelId, 'check failed', reason);
|
||||
modelLines.set(modelId, line);
|
||||
modelWarnings.push(line);
|
||||
modelResultsById.set(modelId, {
|
||||
status: 'notes',
|
||||
line,
|
||||
warningLine: line,
|
||||
});
|
||||
} else {
|
||||
const line = buildModelSuccessLine(providerId, modelId);
|
||||
modelLines.set(modelId, line);
|
||||
modelResultsById.set(modelId, {
|
||||
status: 'ready',
|
||||
line,
|
||||
warningLine: null,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
hasNotes = true;
|
||||
const reason = normalizeModelReason(
|
||||
error instanceof Error ? error.message.trim() : String(error).trim()
|
||||
);
|
||||
const line = buildModelFailureLine(providerId, modelId, 'check failed', reason || null);
|
||||
modelLines.set(modelId, line);
|
||||
modelWarnings.push(line);
|
||||
modelResultsById.set(modelId, {
|
||||
status: 'notes',
|
||||
line,
|
||||
warningLine: line,
|
||||
});
|
||||
} finally {
|
||||
completedCount += 1;
|
||||
emitProgress();
|
||||
}
|
||||
})
|
||||
);
|
||||
const uncachedModelIds = orderedModelIds.filter((modelId) => !modelResultsById.has(modelId));
|
||||
if (uncachedModelIds.length > 0) {
|
||||
try {
|
||||
const batchedModelResult = await prepareProvisioning(
|
||||
cwd,
|
||||
providerId,
|
||||
[providerId],
|
||||
uncachedModelIds,
|
||||
limitContext
|
||||
);
|
||||
|
||||
const dedupedWarnings = Array.from(new Set([...runtimeWarnings, ...modelWarnings]));
|
||||
for (const modelId of uncachedModelIds) {
|
||||
const resolvedResult = resolveModelResultFromBatch(
|
||||
providerId,
|
||||
modelId,
|
||||
batchedModelResult,
|
||||
uncachedModelIds.length === 1
|
||||
);
|
||||
modelLines.set(modelId, resolvedResult.line);
|
||||
modelResultsById.set(modelId, resolvedResult);
|
||||
if (resolvedResult.status === 'failed') {
|
||||
hasFailure = true;
|
||||
} else if (resolvedResult.status === 'notes') {
|
||||
hasNotes = true;
|
||||
}
|
||||
if (resolvedResult.warningLine) {
|
||||
modelWarnings.push(resolvedResult.warningLine);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
hasNotes = true;
|
||||
const reason = normalizeModelReason(
|
||||
error instanceof Error ? error.message.trim() : String(error).trim()
|
||||
);
|
||||
for (const modelId of uncachedModelIds) {
|
||||
const line = buildModelFailureLine(providerId, modelId, 'check failed', reason || null);
|
||||
modelLines.set(modelId, line);
|
||||
modelWarnings.push(line);
|
||||
modelResultsById.set(modelId, {
|
||||
status: 'notes',
|
||||
line,
|
||||
warningLine: line,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
completedCount += uncachedModelIds.length;
|
||||
emitProgress();
|
||||
}
|
||||
}
|
||||
|
||||
const filteredRuntime = suppressSupersededRuntimeWarnings({
|
||||
runtimeDetailLines,
|
||||
runtimeWarnings,
|
||||
modelResultsById,
|
||||
});
|
||||
const dedupedWarnings = Array.from(
|
||||
new Set([...filteredRuntime.runtimeWarnings, ...modelWarnings])
|
||||
);
|
||||
const selectedModelResultsById = Object.fromEntries(
|
||||
orderedModelIds
|
||||
.map((modelId) => [modelId, modelResultsById.get(modelId)] as const)
|
||||
|
|
@ -365,9 +497,9 @@ export async function runProviderPrepareDiagnostics({
|
|||
);
|
||||
|
||||
return {
|
||||
status: hasFailure ? 'failed' : hasNotes ? 'notes' : 'ready',
|
||||
status: hasFailure ? 'failed' : hasNotes || dedupedWarnings.length > 0 ? 'notes' : 'ready',
|
||||
details: [
|
||||
...runtimeDetailLines,
|
||||
...filteredRuntime.runtimeDetailLines,
|
||||
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
|
||||
],
|
||||
warnings: dedupedWarnings,
|
||||
|
|
|
|||
162
src/renderer/components/team/kanban/KanbanTaskCard.test.tsx
Normal file
162
src/renderer/components/team/kanban/KanbanTaskCard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -268,10 +268,6 @@ export const KanbanTaskCard = memo(
|
|||
onViewChanges!(task.id);
|
||||
}}
|
||||
/>
|
||||
) : canDisplay && task.changePresence === 'no_changes' ? (
|
||||
<span className="inline-flex h-6 shrink-0 items-center rounded-full border border-[var(--color-border)] px-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
No changes
|
||||
</span>
|
||||
) : null}
|
||||
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
|
||||
{onDeleteTask ? (
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import type { TeamTaskWithKanban } from '@shared/types';
|
|||
interface CurrentTaskIndicatorProps {
|
||||
task: TeamTaskWithKanban;
|
||||
borderColor: string;
|
||||
/** Max characters for the subject before truncating */
|
||||
maxSubjectLength?: number;
|
||||
activityLabel?: string;
|
||||
onOpenTask?: () => void;
|
||||
|
|
@ -19,21 +18,24 @@ interface CurrentTaskIndicatorProps {
|
|||
export const CurrentTaskIndicator = ({
|
||||
task,
|
||||
borderColor,
|
||||
maxSubjectLength = 36,
|
||||
maxSubjectLength,
|
||||
activityLabel = 'working on',
|
||||
onOpenTask,
|
||||
}: CurrentTaskIndicatorProps): React.JSX.Element => {
|
||||
const truncated = task.subject.length > maxSubjectLength;
|
||||
const subjectText = truncated ? `${task.subject.slice(0, maxSubjectLength)}…` : task.subject;
|
||||
const subjectText =
|
||||
typeof maxSubjectLength === 'number' &&
|
||||
maxSubjectLength > 0 &&
|
||||
task.subject.length > maxSubjectLength
|
||||
? `${task.subject.slice(0, maxSubjectLength)}…`
|
||||
: task.subject;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<Loader2 className="size-3 shrink-0 animate-spin" style={{ color: borderColor }} />
|
||||
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">{activityLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 shrink truncate rounded px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
||||
style={{ border: `1px solid ${borderColor}40` }}
|
||||
className="min-w-0 flex-1 truncate rounded px-1.5 py-0.5 text-left text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
||||
title="Open task"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -49,6 +51,6 @@ export const CurrentTaskIndicator = ({
|
|||
>
|
||||
{formatTaskDisplayLabel(task)} {subjectText}
|
||||
</button>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet, getThemedBadge, scaleColorAlpha } from '@renderer/constants/teamColors';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import {
|
||||
|
|
@ -101,6 +101,7 @@ export const MemberCard = ({
|
|||
const completed = taskCounts?.completed ?? 0;
|
||||
const totalTasks = pending + inProgress + completed;
|
||||
const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
|
||||
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
||||
const activityTask = currentTask ?? reviewTask ?? null;
|
||||
const activityTitle = currentTask
|
||||
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
|
||||
|
|
@ -111,7 +112,8 @@ export const MemberCard = ({
|
|||
!isRemoved &&
|
||||
presenceLabel === 'starting' &&
|
||||
spawnLaunchState !== 'failed_to_start' &&
|
||||
!activityTask;
|
||||
!activityTask &&
|
||||
!runtimeSummary;
|
||||
const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask;
|
||||
const showRuntimeAdvisoryBadge =
|
||||
!isRemoved &&
|
||||
|
|
@ -119,18 +121,14 @@ export const MemberCard = ({
|
|||
!showStartingBadge &&
|
||||
spawnStatus !== 'error' &&
|
||||
(Boolean(activityTask) || !isAwaitingReply);
|
||||
const cardTint = scaleColorAlpha(getThemedBadge(colors, isLight), 0.5);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded transition-opacity duration-300 ${isRemoved ? 'opacity-50' : ''} ${spawnCardClass}`}
|
||||
>
|
||||
<div
|
||||
className="group relative cursor-pointer rounded px-2 py-1.5"
|
||||
style={{
|
||||
borderLeft: `3px solid ${colors.border}`,
|
||||
background: `linear-gradient(to right, ${cardTint}, transparent)`,
|
||||
}}
|
||||
className="group relative cursor-pointer rounded py-1.5"
|
||||
style={undefined}
|
||||
title={activityTitle}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
|
|
@ -145,19 +143,27 @@ export const MemberCard = ({
|
|||
<div className="pointer-events-none absolute inset-0 rounded transition-colors group-hover:bg-white/5" />
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="relative shrink-0">
|
||||
<img
|
||||
src={agentAvatarUrl(member.name)}
|
||||
alt={member.name}
|
||||
className="size-7 rounded-full bg-[var(--color-surface-raised)]"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div
|
||||
className="rounded-full border-2 p-[1px]"
|
||||
style={{
|
||||
borderColor: colors.border,
|
||||
boxShadow: isLight ? 'none' : `0 0 0 1px ${colors.badge}`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={agentAvatarUrl(member.name)}
|
||||
alt={member.name}
|
||||
className="size-7 rounded-full bg-[var(--color-surface-raised)]"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
|
||||
aria-label={presenceLabel}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5 truncate text-sm">
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-sm">
|
||||
<span className="shrink-0 font-medium text-[var(--color-text)]">
|
||||
{displayMemberName(member.name)}
|
||||
</span>
|
||||
|
|
@ -209,20 +215,16 @@ export const MemberCard = ({
|
|||
style={{ backgroundColor: 'var(--skeleton-base)' }}
|
||||
/>
|
||||
</div>
|
||||
) : runtimeSummary ? (
|
||||
<div className="mt-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||
{runtimeSummary}
|
||||
) : runtimeSummary || roleLabel ? (
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||
{runtimeSummary ? <span className="min-w-0 truncate">{runtimeSummary}</span> : null}
|
||||
{runtimeSummary && roleLabel ? (
|
||||
<span className="shrink-0 opacity-60">•</span>
|
||||
) : null}
|
||||
{roleLabel ? <span className="shrink-0">{roleLabel}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{(() => {
|
||||
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
||||
return roleLabel ? (
|
||||
<span className="hidden shrink-0 text-xs text-[var(--color-text-muted)] sm:inline">
|
||||
{roleLabel}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
{showStartingBadge ? (
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Loader2
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
buildGraphMemberNodeIdForMember,
|
||||
buildInlineActivityEntries,
|
||||
} from '@features/agent-graph/renderer';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||
import { useMemberStats } from '@renderer/hooks/useMemberStats';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice';
|
||||
import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import {
|
||||
|
|
@ -20,6 +18,7 @@ import {
|
|||
UserMinus,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { buildMemberActivityEntries } from './memberActivityEntries';
|
||||
import { MemberDetailHeader } from './MemberDetailHeader';
|
||||
import { MemberDetailStats } from './MemberDetailStats';
|
||||
import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes';
|
||||
|
|
@ -28,15 +27,14 @@ import { MemberMessagesTab } from './MemberMessagesTab';
|
|||
import { MemberStatsTab } from './MemberStatsTab';
|
||||
import { MemberTasksTab } from './MemberTasksTab';
|
||||
|
||||
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
|
||||
import type {
|
||||
InboxMessage,
|
||||
LeadActivityState,
|
||||
MemberSpawnStatusEntry,
|
||||
TeamAgentRuntimeEntry,
|
||||
ResolvedTeamMember,
|
||||
TeamAgentRuntimeEntry,
|
||||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
|
||||
|
||||
interface MemberDetailDialogProps {
|
||||
open: boolean;
|
||||
|
|
@ -44,7 +42,6 @@ interface MemberDetailDialogProps {
|
|||
teamName: string;
|
||||
members: ResolvedTeamMember[];
|
||||
tasks: TeamTaskWithKanban[];
|
||||
messages: InboxMessage[];
|
||||
initialTab?: MemberDetailTab;
|
||||
initialActivityFilter?: MemberActivityFilter;
|
||||
isTeamAlive?: boolean;
|
||||
|
|
@ -71,7 +68,6 @@ export const MemberDetailDialog = ({
|
|||
teamName,
|
||||
members,
|
||||
tasks,
|
||||
messages,
|
||||
initialTab = 'tasks',
|
||||
initialActivityFilter = 'all',
|
||||
isTeamAlive,
|
||||
|
|
@ -95,33 +91,20 @@ export const MemberDetailDialog = ({
|
|||
() => (member ? tasks.filter((t) => t.owner === member.name) : []),
|
||||
[tasks, member]
|
||||
);
|
||||
|
||||
const seedMemberMessages = useMemo(
|
||||
() => (member ? messages.filter((m) => m.from === member.name || m.to === member.name) : []),
|
||||
[messages, member]
|
||||
const memberMessages = useStore((state) =>
|
||||
selectMemberMessagesForTeamMember(state, teamName, member?.name ?? null)
|
||||
);
|
||||
const memberMessages = seedMemberMessages;
|
||||
const memberActivityCount = useMemo(() => {
|
||||
if (!member) {
|
||||
return 0;
|
||||
}
|
||||
const leadId = `lead:${teamName}`;
|
||||
const leadName =
|
||||
members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`;
|
||||
const ownerNodeId =
|
||||
member.name === leadName ? leadId : buildGraphMemberNodeIdForMember(teamName, member);
|
||||
const entries = buildInlineActivityEntries({
|
||||
data: {
|
||||
members,
|
||||
tasks,
|
||||
messages: memberMessages,
|
||||
},
|
||||
return buildMemberActivityEntries({
|
||||
teamName,
|
||||
leadId,
|
||||
leadName,
|
||||
ownerNodeIds: new Set([leadId, ownerNodeId]),
|
||||
});
|
||||
return (entries.get(ownerNodeId) ?? []).length;
|
||||
memberName: member.name,
|
||||
members,
|
||||
tasks,
|
||||
messages: memberMessages,
|
||||
}).length;
|
||||
}, [member, memberMessages, members, tasks, teamName]);
|
||||
|
||||
const inProgressTasks = useMemo(
|
||||
|
|
@ -236,7 +219,6 @@ export const MemberDetailDialog = ({
|
|||
</TabsContent>
|
||||
<TabsContent value="activity">
|
||||
<MemberMessagesTab
|
||||
messages={memberMessages}
|
||||
teamName={teamName}
|
||||
memberName={member.name}
|
||||
members={members}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,13 @@ import {
|
|||
} from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice';
|
||||
import {
|
||||
getCurrentProvisioningProgressForTeam,
|
||||
selectResolvedMemberForTeamName,
|
||||
selectTeamIsAliveForName,
|
||||
selectTeamMemberSnapshotsForName,
|
||||
selectTeamTasksForName,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import {
|
||||
agentAvatarUrl,
|
||||
|
|
@ -17,6 +23,7 @@ import {
|
|||
} from '@renderer/utils/memberHelpers';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps';
|
||||
|
||||
|
|
@ -38,7 +45,7 @@ interface MemberHoverCardProps {
|
|||
|
||||
/**
|
||||
* Wraps children in a HoverCard that shows member info on hover.
|
||||
* Reads member data from the store (selectedTeamData.members).
|
||||
* Reads member data from the team snapshot + resolved member selectors.
|
||||
* Falls back to a simple wrapper when member data is unavailable.
|
||||
*/
|
||||
export const MemberHoverCard = ({
|
||||
|
|
@ -53,20 +60,22 @@ export const MemberHoverCard = ({
|
|||
const effectiveTeamName = teamName ?? selectedTeamName;
|
||||
const {
|
||||
member,
|
||||
members,
|
||||
teamMembers,
|
||||
tasks,
|
||||
isTeamAlive,
|
||||
progress,
|
||||
memberSpawnSnapshot,
|
||||
memberSpawnStatuses,
|
||||
spawnEntry,
|
||||
leadActivity,
|
||||
} = useStore((s) => {
|
||||
const isSelectedTeam = Boolean(effectiveTeamName && s.selectedTeamName === effectiveTeamName);
|
||||
const selectedTeamData = isSelectedTeam ? s.selectedTeamData : null;
|
||||
return {
|
||||
member: selectedTeamData?.members.find((m) => m.name === name) ?? null,
|
||||
members: selectedTeamData?.members ?? [],
|
||||
isTeamAlive: selectedTeamData?.isAlive,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
member: effectiveTeamName
|
||||
? selectResolvedMemberForTeamName(s, effectiveTeamName, name)
|
||||
: null,
|
||||
teamMembers: effectiveTeamName ? selectTeamMemberSnapshotsForName(s, effectiveTeamName) : [],
|
||||
tasks: effectiveTeamName ? selectTeamTasksForName(s, effectiveTeamName) : [],
|
||||
isTeamAlive: effectiveTeamName ? selectTeamIsAliveForName(s, effectiveTeamName) : undefined,
|
||||
progress: effectiveTeamName
|
||||
? getCurrentProvisioningProgressForTeam(s, effectiveTeamName)
|
||||
: null,
|
||||
|
|
@ -80,21 +89,16 @@ export const MemberHoverCard = ({
|
|||
? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name]
|
||||
: undefined,
|
||||
leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined,
|
||||
};
|
||||
});
|
||||
const openMemberProfile = useStore((s) => s.openMemberProfile);
|
||||
const tasks = useStore((s) =>
|
||||
effectiveTeamName && s.selectedTeamName === effectiveTeamName
|
||||
? s.selectedTeamData?.tasks
|
||||
: undefined
|
||||
}))
|
||||
);
|
||||
const openMemberProfile = useStore((s) => s.openMemberProfile);
|
||||
|
||||
if (!member) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const launchJoinMilestones = getLaunchJoinMilestonesFromMembers({
|
||||
members,
|
||||
members: teamMembers,
|
||||
memberSpawnStatuses,
|
||||
memberSpawnSnapshot,
|
||||
});
|
||||
|
|
@ -117,10 +121,9 @@ export const MemberHoverCard = ({
|
|||
const presenceLabel = launchPresentation.presenceLabel;
|
||||
const dotClass = launchPresentation.dotClass;
|
||||
const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle;
|
||||
const currentTask: TeamTaskWithKanban | null =
|
||||
member.currentTaskId && tasks
|
||||
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
|
||||
: null;
|
||||
const currentTask: TeamTaskWithKanban | null = member.currentTaskId
|
||||
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
|
||||
: null;
|
||||
const reviewTask: TeamTaskWithKanban | null = tasks
|
||||
? (tasks.find(
|
||||
(task) =>
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
|
|||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||
import type {
|
||||
LeadActivityState,
|
||||
TeamAgentRuntimeEntry,
|
||||
MemberSpawnStatusEntry,
|
||||
ResolvedTeamMember,
|
||||
TeamAgentRuntimeEntry,
|
||||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { buildInlineActivityEntries } from '@features/agent-graph/renderer';
|
||||
import { api } from '@renderer/api';
|
||||
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
|
||||
import {
|
||||
buildMessageContext,
|
||||
|
|
@ -10,17 +8,18 @@ import {
|
|||
import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
|
||||
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice';
|
||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { buildMemberActivityEntries } from './memberActivityEntries';
|
||||
|
||||
import type { MemberActivityFilter } from './memberDetailTypes';
|
||||
import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup';
|
||||
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
interface MemberMessagesTabProps {
|
||||
messages: InboxMessage[];
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
members: ResolvedTeamMember[];
|
||||
|
|
@ -31,7 +30,6 @@ interface MemberMessagesTabProps {
|
|||
}
|
||||
|
||||
const MAX_MESSAGES = 100;
|
||||
const MEMBER_MESSAGES_PAGE_SIZE = 50;
|
||||
const FILTER_OPTIONS: readonly { value: MemberActivityFilter; label: string }[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'messages', label: 'Messages' },
|
||||
|
|
@ -39,7 +37,6 @@ const FILTER_OPTIONS: readonly { value: MemberActivityFilter; label: string }[]
|
|||
];
|
||||
|
||||
export const MemberMessagesTab = ({
|
||||
messages,
|
||||
teamName,
|
||||
memberName,
|
||||
members,
|
||||
|
|
@ -48,21 +45,16 @@ export const MemberMessagesTab = ({
|
|||
onCreateTask,
|
||||
onTaskClick,
|
||||
}: MemberMessagesTabProps): React.JSX.Element => {
|
||||
const [pagedMessages, setPagedMessages] = useState<InboxMessage[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [initialPageLoading, setInitialPageLoading] = useState(false);
|
||||
const [loadingOlderMessages, setLoadingOlderMessages] = useState(false);
|
||||
const [activityFilter, setActivityFilter] = useState<MemberActivityFilter>(initialFilter);
|
||||
const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(null);
|
||||
const { readSet } = useTeamMessagesRead(teamName);
|
||||
const leadId = `lead:${teamName}`;
|
||||
const leadName = useMemo(
|
||||
() => members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`,
|
||||
[members, teamName]
|
||||
const { messages, messagesState, loadOlderTeamMessages } = useStore(
|
||||
useShallow((s) => ({
|
||||
messages: selectMemberMessagesForTeamMember(s, teamName, memberName),
|
||||
messagesState: teamName ? s.teamMessagesByName[teamName] : undefined,
|
||||
loadOlderTeamMessages: s.loadOlderTeamMessages,
|
||||
}))
|
||||
);
|
||||
const ownerNodeId = memberName === leadName ? leadId : `member:${teamName}:${memberName}`;
|
||||
const ownerNodeIds = useMemo(() => new Set([leadId, ownerNodeId]), [leadId, ownerNodeId]);
|
||||
const { readSet } = useTeamMessagesRead(teamName);
|
||||
const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
|
||||
const messageContext = useMemo(() => buildMessageContext(members), [members]);
|
||||
|
||||
|
|
@ -70,108 +62,45 @@ export const MemberMessagesTab = ({
|
|||
setActivityFilter(initialFilter);
|
||||
}, [initialFilter, memberName, teamName]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setPagedMessages([]);
|
||||
setNextCursor(null);
|
||||
setHasMore(false);
|
||||
setInitialPageLoading(true);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const page = await api.teams.getMessagesPage(teamName, {
|
||||
limit: MEMBER_MESSAGES_PAGE_SIZE,
|
||||
});
|
||||
if (cancelled) return;
|
||||
const memberPageMessages = page.messages.filter(
|
||||
(message) => message.from === memberName || message.to === memberName
|
||||
);
|
||||
setPagedMessages(memberPageMessages);
|
||||
setNextCursor(page.nextCursor);
|
||||
setHasMore(page.hasMore);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setPagedMessages([]);
|
||||
setNextCursor(null);
|
||||
setHasMore(false);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setInitialPageLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [teamName, memberName]);
|
||||
|
||||
const loadOlderMessages = useCallback(async () => {
|
||||
if (!nextCursor || loadingOlderMessages) return;
|
||||
setLoadingOlderMessages(true);
|
||||
try {
|
||||
const page = await api.teams.getMessagesPage(teamName, {
|
||||
beforeTimestamp: nextCursor,
|
||||
limit: MEMBER_MESSAGES_PAGE_SIZE,
|
||||
});
|
||||
const memberPageMessages = page.messages.filter(
|
||||
(message) => message.from === memberName || message.to === memberName
|
||||
);
|
||||
setPagedMessages((prev) => mergeTeamMessages(prev, memberPageMessages));
|
||||
setNextCursor(page.nextCursor);
|
||||
setHasMore(page.hasMore);
|
||||
} catch {
|
||||
// best-effort
|
||||
} finally {
|
||||
setLoadingOlderMessages(false);
|
||||
if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) {
|
||||
return;
|
||||
}
|
||||
}, [loadingOlderMessages, memberName, nextCursor, teamName]);
|
||||
await loadOlderTeamMessages(teamName);
|
||||
}, [loadOlderTeamMessages, messagesState, teamName]);
|
||||
|
||||
const effectiveMessages = useMemo(
|
||||
() => mergeTeamMessages(messages, pagedMessages),
|
||||
[messages, pagedMessages]
|
||||
);
|
||||
|
||||
const filteredMessages = useMemo(
|
||||
() =>
|
||||
filterTeamMessages(effectiveMessages, {
|
||||
timeWindow: null,
|
||||
filter: { from: new Set(), to: new Set(), showNoise: true },
|
||||
searchQuery: '',
|
||||
}),
|
||||
[effectiveMessages]
|
||||
);
|
||||
const loading = (messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false);
|
||||
const loadingOlderMessages = messagesState?.loadingOlder ?? false;
|
||||
const hasMore = messagesState?.hasMore ?? false;
|
||||
|
||||
const activityEntries = useMemo(() => {
|
||||
const entriesByOwner = buildInlineActivityEntries({
|
||||
data: {
|
||||
members,
|
||||
tasks,
|
||||
messages: filteredMessages,
|
||||
},
|
||||
return buildMemberActivityEntries({
|
||||
teamName,
|
||||
leadId,
|
||||
leadName,
|
||||
ownerNodeIds,
|
||||
memberName,
|
||||
members,
|
||||
tasks,
|
||||
messages,
|
||||
});
|
||||
return (entriesByOwner.get(ownerNodeId) ?? []).slice(0, MAX_MESSAGES);
|
||||
}, [filteredMessages, leadId, leadName, members, ownerNodeId, ownerNodeIds, tasks, teamName]);
|
||||
}, [memberName, members, messages, tasks, teamName]);
|
||||
const visibleActivityEntries = useMemo(
|
||||
() => activityEntries.slice(0, MAX_MESSAGES),
|
||||
[activityEntries]
|
||||
);
|
||||
|
||||
const displayEntries = useMemo(() => {
|
||||
switch (activityFilter) {
|
||||
case 'messages':
|
||||
return activityEntries.filter(
|
||||
return visibleActivityEntries.filter(
|
||||
(entry) => entry.message.messageKind !== 'task_comment_notification'
|
||||
);
|
||||
case 'comments':
|
||||
return activityEntries.filter(
|
||||
return visibleActivityEntries.filter(
|
||||
(entry) => entry.message.messageKind === 'task_comment_notification'
|
||||
);
|
||||
default:
|
||||
return activityEntries;
|
||||
return visibleActivityEntries;
|
||||
}
|
||||
}, [activityEntries, activityFilter]);
|
||||
}, [activityFilter, visibleActivityEntries]);
|
||||
|
||||
const expandedItemsByKey = useMemo(() => {
|
||||
const items = new Map<string, TimelineItem>();
|
||||
|
|
@ -201,6 +130,7 @@ export const MemberMessagesTab = ({
|
|||
[onTaskClick, taskMap, tasks]
|
||||
);
|
||||
|
||||
const initialPageLoading = loading && activityEntries.length === 0;
|
||||
const emptyStateText = initialPageLoading
|
||||
? 'Loading activity...'
|
||||
: activityFilter === 'comments'
|
||||
|
|
@ -209,9 +139,10 @@ export const MemberMessagesTab = ({
|
|||
? hasMore
|
||||
? 'No loaded messages for this member yet'
|
||||
: 'No messages with this member'
|
||||
: 'No activity with this member';
|
||||
const canLoadOlderMessages =
|
||||
hasMore && activityFilter !== 'comments' && displayEntries.length > 0;
|
||||
: hasMore
|
||||
? 'No loaded activity for this member yet'
|
||||
: 'No activity with this member';
|
||||
const canLoadOlderMessages = hasMore && activityFilter !== 'comments';
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
|
|
|
|||
|
|
@ -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) ?? [];
|
||||
}
|
||||
|
|
@ -2,9 +2,7 @@ import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRole
|
|||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
||||
import { normalizeTeamModelForUi as normalizeCatalogTeamModelForUi } from '@renderer/utils/teamModelCatalog';
|
||||
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
|
||||
import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
||||
|
|
@ -34,7 +32,6 @@ function newDraftId(): string {
|
|||
|
||||
export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
|
||||
const providerId = initial?.providerId;
|
||||
const normalizedModel = extractProviderScopedBaseModel(initial?.model ?? '', providerId) ?? '';
|
||||
return {
|
||||
id: initial?.id ?? newDraftId(),
|
||||
name: initial?.name ?? '',
|
||||
|
|
@ -42,7 +39,7 @@ export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
|
|||
customRole: initial?.customRole ?? '',
|
||||
workflow: initial?.workflow,
|
||||
providerId,
|
||||
model: normalizeCatalogTeamModelForUi(providerId, normalizedModel),
|
||||
model: normalizeExplicitTeamModelForUi(providerId, initial?.model ?? ''),
|
||||
effort: initial?.effort,
|
||||
removedAt: initial?.removedAt,
|
||||
};
|
||||
|
|
@ -221,7 +218,7 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning
|
|||
}
|
||||
const model = member.model?.trim();
|
||||
if (model) {
|
||||
result.model = normalizeTeamModelForUi(providerId, model);
|
||||
result.model = normalizeExplicitTeamModelForUi(providerId, model);
|
||||
}
|
||||
const effort = normalizeDraftEffort(member.effort);
|
||||
if (effort) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Sheet, type SheetRef } from 'react-modal-sheet';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
|
|
@ -9,11 +8,10 @@ import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMe
|
|||
import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
|
||||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
|
||||
import { selectTeamMessages } from '@renderer/store/slices/teamSlice';
|
||||
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import {
|
||||
CheckCheck,
|
||||
ChevronsDownUp,
|
||||
|
|
@ -53,9 +51,6 @@ interface TimeWindow {
|
|||
end: number;
|
||||
}
|
||||
|
||||
const logger = createLogger('Component:MessagesPanel');
|
||||
const MESSAGES_PANEL_FILTER_WARN_MS = 8;
|
||||
const MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS = 6;
|
||||
const BOTTOM_SHEET_HEADER_HEIGHT = 40;
|
||||
const BOTTOM_SHEET_COLLAPSED_SNAP_INDEX = 1;
|
||||
const BOTTOM_SHEET_COMPOSER_SNAP_INDEX = 2;
|
||||
|
|
@ -70,8 +65,6 @@ interface MessagesPanelProps {
|
|||
members: ResolvedTeamMember[];
|
||||
/** All team tasks. */
|
||||
tasks: TeamTaskWithKanban[];
|
||||
/** All raw messages from team data. */
|
||||
messages: InboxMessage[];
|
||||
/** Whether the team is alive. */
|
||||
isTeamAlive?: boolean;
|
||||
/** Live lead activity status for the current team. */
|
||||
|
|
@ -107,7 +100,6 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
mountPoint,
|
||||
members,
|
||||
tasks,
|
||||
messages,
|
||||
isTeamAlive,
|
||||
leadActivity,
|
||||
leadContextUpdatedAt,
|
||||
|
|
@ -130,6 +122,9 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
lastSendMessageResult,
|
||||
teams,
|
||||
openTeamTab,
|
||||
messages,
|
||||
messagesState,
|
||||
loadOlderTeamMessages,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
sendTeamMessage: s.sendTeamMessage,
|
||||
|
|
@ -139,76 +134,24 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
lastSendMessageResult: s.lastSendMessageResult,
|
||||
teams: s.teams,
|
||||
openTeamTab: s.openTeamTab,
|
||||
messages: selectTeamMessages(s, teamName),
|
||||
messagesState: teamName ? s.teamMessagesByName[teamName] : undefined,
|
||||
loadOlderTeamMessages: s.loadOlderTeamMessages,
|
||||
}))
|
||||
);
|
||||
|
||||
// ── Paginated message fetching ──
|
||||
// Messages are now fetched via getMessagesPage API instead of coming
|
||||
// from getTeamData. The `messages` prop is used as initial seed if non-empty.
|
||||
const PAGE_SIZE = 50;
|
||||
const [fetchedMessages, setFetchedMessages] = useState<InboxMessage[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loadingOlderMessages, setLoadingOlderMessages] = useState(false);
|
||||
const fetchIdRef = useRef(0);
|
||||
|
||||
// Initial fetch on mount or team change
|
||||
useEffect(() => {
|
||||
const id = ++fetchIdRef.current;
|
||||
void (async () => {
|
||||
try {
|
||||
const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE });
|
||||
if (fetchIdRef.current !== id) return;
|
||||
setFetchedMessages(page.messages);
|
||||
setNextCursor(page.nextCursor);
|
||||
setHasMore(page.hasMore);
|
||||
} catch {
|
||||
// Fallback: use prop messages if API fails
|
||||
if (fetchIdRef.current === id && messages.length > 0) {
|
||||
setFetchedMessages(messages);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [teamName]); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally only on teamName change
|
||||
|
||||
// Auto-refresh: poll for NEW messages only (prepend to head).
|
||||
// Does NOT touch nextCursor/hasMore — those belong to the "Load older" flow.
|
||||
useEffect(() => {
|
||||
if (!isTeamAlive && leadActivity !== 'active') return;
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE });
|
||||
setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages));
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [teamName, isTeamAlive, leadActivity]);
|
||||
|
||||
const loadOlderMessages = useCallback(async () => {
|
||||
if (!nextCursor || loadingOlderMessages) return;
|
||||
setLoadingOlderMessages(true);
|
||||
try {
|
||||
const page = await api.teams.getMessagesPage(teamName, {
|
||||
beforeTimestamp: nextCursor,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages));
|
||||
setNextCursor(page.nextCursor);
|
||||
setHasMore(page.hasMore);
|
||||
} catch {
|
||||
// best-effort
|
||||
} finally {
|
||||
setLoadingOlderMessages(false);
|
||||
if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) {
|
||||
return;
|
||||
}
|
||||
}, [loadingOlderMessages, nextCursor, teamName]);
|
||||
await loadOlderTeamMessages(teamName);
|
||||
}, [loadOlderTeamMessages, messagesState, teamName]);
|
||||
|
||||
// Use fetched messages, fall back to prop messages during initial load
|
||||
const effectiveMessages = useMemo(() => {
|
||||
if (fetchedMessages.length === 0) return messages;
|
||||
return mergeTeamMessages(fetchedMessages, messages);
|
||||
}, [fetchedMessages, messages]);
|
||||
const messagesLoading =
|
||||
(messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false);
|
||||
const loadingOlderMessages = messagesState?.loadingOlder ?? false;
|
||||
const hasMore = messagesState?.hasMore ?? false;
|
||||
const effectiveMessages = messages;
|
||||
|
||||
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const sidebarScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -323,41 +266,21 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
}, [position, mountPoint]);
|
||||
|
||||
const filteredMessages = useMemo(() => {
|
||||
const startedAt = performance.now();
|
||||
const result = filterTeamMessages(effectiveMessages, {
|
||||
return filterTeamMessages(effectiveMessages, {
|
||||
timeWindow,
|
||||
filter: messagesFilter,
|
||||
searchQuery: messagesSearchQuery,
|
||||
});
|
||||
const ms = performance.now() - startedAt;
|
||||
if (ms >= MESSAGES_PANEL_FILTER_WARN_MS) {
|
||||
logger.warn(
|
||||
`[perf] filter team=${teamName} stage=messages ms=${ms.toFixed(1)} input=${effectiveMessages.length} output=${result.length} searchLen=${messagesSearchQuery.trim().length} noise=${
|
||||
messagesFilter.showNoise ? 'on' : 'off'
|
||||
}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [effectiveMessages, messagesFilter, messagesSearchQuery, teamName, timeWindow]);
|
||||
}, [effectiveMessages, messagesFilter, messagesSearchQuery, timeWindow]);
|
||||
|
||||
const activityTimelineMessages = useMemo(() => {
|
||||
const startedAt = performance.now();
|
||||
const result = filterTeamMessages(effectiveMessages, {
|
||||
return filterTeamMessages(effectiveMessages, {
|
||||
includePassiveIdlePeerSummariesWhenNoiseHidden: true,
|
||||
timeWindow,
|
||||
filter: messagesFilter,
|
||||
searchQuery: messagesSearchQuery,
|
||||
});
|
||||
const ms = performance.now() - startedAt;
|
||||
if (ms >= MESSAGES_PANEL_FILTER_WARN_MS) {
|
||||
logger.warn(
|
||||
`[perf] filter team=${teamName} stage=timeline ms=${ms.toFixed(1)} input=${effectiveMessages.length} output=${result.length} searchLen=${messagesSearchQuery.trim().length} noise=${
|
||||
messagesFilter.showNoise ? 'on' : 'off'
|
||||
}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [effectiveMessages, messagesFilter, messagesSearchQuery, teamName, timeWindow]);
|
||||
}, [effectiveMessages, messagesFilter, messagesSearchQuery, timeWindow]);
|
||||
|
||||
const replyCandidateMessages = useMemo(
|
||||
() =>
|
||||
|
|
@ -371,33 +294,21 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
|
||||
// Resolve the expanded item from filtered messages
|
||||
const expandedItem = useMemo<TimelineItem | null>(() => {
|
||||
const startedAt = performance.now();
|
||||
if (!expandedItemKey) return null;
|
||||
if (!expandedItemKey) {
|
||||
return null;
|
||||
}
|
||||
if (!expandedItemKey.startsWith('thoughts-')) {
|
||||
const msg = activityTimelineMessages.find((m) => toMessageKey(m) === expandedItemKey);
|
||||
const result: TimelineItem | null = msg ? { type: 'message', message: msg } : null;
|
||||
const ms = performance.now() - startedAt;
|
||||
if (ms >= MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS) {
|
||||
logger.warn(
|
||||
`[perf] expandedItem team=${teamName} ms=${ms.toFixed(1)} mode=message timelineMessages=${activityTimelineMessages.length}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
return msg ? { type: 'message', message: msg } : null;
|
||||
}
|
||||
const allItems = groupTimelineItems(activityTimelineMessages);
|
||||
const result =
|
||||
return (
|
||||
allItems.find(
|
||||
(item) =>
|
||||
item.type === 'lead-thoughts' && getThoughtGroupKey(item.group) === expandedItemKey
|
||||
) ?? null;
|
||||
const ms = performance.now() - startedAt;
|
||||
if (ms >= MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS) {
|
||||
logger.warn(
|
||||
`[perf] expandedItem team=${teamName} ms=${ms.toFixed(1)} mode=thoughts timelineMessages=${activityTimelineMessages.length} groups=${allItems.length}`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [expandedItemKey, activityTimelineMessages, teamName]);
|
||||
) ?? null
|
||||
);
|
||||
}, [expandedItemKey, activityTimelineMessages]);
|
||||
|
||||
// Auto-clear stale expanded key
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -3,10 +3,14 @@ import { isLeadMember } from '@shared/utils/leadDetection';
|
|||
import type {
|
||||
MemberSpawnStatusEntry,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
ResolvedTeamMember,
|
||||
TeamProvisioningProgress,
|
||||
} from '@shared/types';
|
||||
|
||||
interface LaunchJoinMemberLike {
|
||||
name: string;
|
||||
removedAt?: number;
|
||||
}
|
||||
|
||||
/** Display steps for the provisioning stepper (0-indexed). */
|
||||
export const DISPLAY_STEPS = [
|
||||
{ key: 'starting', label: 'Starting' },
|
||||
|
|
@ -52,7 +56,7 @@ export function getLaunchJoinMilestonesFromMembers({
|
|||
memberSpawnStatuses,
|
||||
memberSpawnSnapshot,
|
||||
}: {
|
||||
members: readonly ResolvedTeamMember[];
|
||||
members: readonly LaunchJoinMemberLike[];
|
||||
memberSpawnStatuses?: MemberSpawnStatusCollection;
|
||||
memberSpawnSnapshot?: Pick<MemberSpawnStatusesSnapshot, 'expectedMembers' | 'summary'>;
|
||||
}): LaunchJoinMilestones {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useMemo } from 'react';
|
|||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
getCurrentProvisioningProgressForTeam,
|
||||
selectTeamDataForName,
|
||||
selectTeamMemberSnapshotsForName,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
|
@ -20,7 +20,7 @@ export function useTeamProvisioningPresentation(teamName: string): {
|
|||
useShallow((s) => ({
|
||||
progress: getCurrentProvisioningProgressForTeam(s, teamName),
|
||||
cancelProvisioning: s.cancelProvisioning,
|
||||
teamMembers: selectTeamDataForName(s, teamName)?.members ?? [],
|
||||
teamMembers: selectTeamMemberSnapshotsForName(s, teamName),
|
||||
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
||||
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
selectResolvedMembersForTeamName,
|
||||
selectTeamDataForName,
|
||||
} from '@renderer/store/slices/teamSlice';
|
||||
import { createEncodedTaskReference } from '@renderer/utils/taskReferenceUtils';
|
||||
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
|
@ -57,11 +61,13 @@ function isVisibleTask(task: TeamTaskWithKanban | GlobalTask): boolean {
|
|||
}
|
||||
|
||||
export function useTaskSuggestions(currentTeamName: string | null): UseTaskSuggestionsResult {
|
||||
const { globalTasks, selectedTeamName, selectedTeamData, teamByName } = useStore(
|
||||
const { globalTasks, currentTeamData, currentTeamMembers, teamByName } = useStore(
|
||||
useShallow((s) => ({
|
||||
globalTasks: s.globalTasks,
|
||||
selectedTeamName: s.selectedTeamName,
|
||||
selectedTeamData: s.selectedTeamData,
|
||||
currentTeamData: currentTeamName ? selectTeamDataForName(s, currentTeamName) : null,
|
||||
currentTeamMembers: currentTeamName
|
||||
? selectResolvedMembersForTeamName(s, currentTeamName)
|
||||
: [],
|
||||
teamByName: s.teamByName,
|
||||
}))
|
||||
);
|
||||
|
|
@ -73,14 +79,10 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge
|
|||
if (currentTeamName) {
|
||||
const currentTeamSummary = teamByName[currentTeamName];
|
||||
const currentTeamDisplayName = currentTeamSummary?.displayName || currentTeamName;
|
||||
const currentTeamMembers =
|
||||
selectedTeamName === currentTeamName && selectedTeamData
|
||||
? selectedTeamData.members
|
||||
: (currentTeamSummary?.members ?? []);
|
||||
const currentTeamTasks =
|
||||
selectedTeamName === currentTeamName && selectedTeamData
|
||||
? selectedTeamData.tasks
|
||||
: globalTasks.filter((task) => task.teamName === currentTeamName);
|
||||
currentTeamData?.tasks ?? globalTasks.filter((task) => task.teamName === currentTeamName);
|
||||
const currentTeamMemberColors =
|
||||
currentTeamMembers.length > 0 ? currentTeamMembers : (currentTeamSummary?.members ?? []);
|
||||
|
||||
for (const task of currentTeamTasks) {
|
||||
if (!isVisibleTask(task)) continue;
|
||||
|
|
@ -91,7 +93,7 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge
|
|||
teamDisplayName: currentTeamDisplayName,
|
||||
teamColor: currentTeamSummary?.color,
|
||||
isCurrentTeamTask: true,
|
||||
ownerColor: currentTeamMembers.find((member) => member.name === task.owner)?.color,
|
||||
ownerColor: currentTeamMemberColors.find((member) => member.name === task.owner)?.color,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -123,7 +125,7 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge
|
|||
});
|
||||
|
||||
return tasks.map(buildTaskSuggestion);
|
||||
}, [currentTeamName, globalTasks, selectedTeamData, selectedTeamName, teamByName]);
|
||||
}, [currentTeamData, currentTeamMembers, currentTeamName, globalTasks, teamByName]);
|
||||
|
||||
return { suggestions };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@ import { createTabSlice } from './slices/tabSlice';
|
|||
import { createTabUISlice } from './slices/tabUISlice';
|
||||
import {
|
||||
createTeamSlice,
|
||||
getActiveTeamPendingReplyWaits,
|
||||
getLastResolvedTeamDataRefreshAt,
|
||||
hasActiveTeamPendingReplyWait,
|
||||
isTeamDataRefreshPending,
|
||||
selectTeamDataForName,
|
||||
} from './slices/teamSlice';
|
||||
|
|
@ -65,6 +67,7 @@ const TEAM_CHANGE_EVENT_BURST_WARN_COUNT = 8;
|
|||
const TEAM_CHANGE_EVENT_WARN_THROTTLE_MS = 2_000;
|
||||
const TEAM_VISIBLE_IDLE_WATCHDOG_POLL_MS = 10_000;
|
||||
const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000;
|
||||
const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_000;
|
||||
const CURRENT_APP_VERSION =
|
||||
typeof __APP_VERSION__ === 'string' ? normalizeVersion(__APP_VERSION__) : '0.0.0';
|
||||
const logger = createLogger('Store:index');
|
||||
|
|
@ -237,10 +240,12 @@ export function initializeNotificationListeners(): () => void {
|
|||
const teamLastRelevantActivityAt = new Map<string, number>();
|
||||
const teamLastIdleWatchdogRefreshAt = new Map<string, number>();
|
||||
let teamRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let teamMessageRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let teamPresenceRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let memberSpawnRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let toolActivityTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let inProgressChangePresencePollInFlight = false;
|
||||
let teamMessageFallbackPollInFlight = false;
|
||||
const inProgressChangePresenceCursorByTeam = new Map<string, number>();
|
||||
|
||||
let teamListRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
|
@ -252,6 +257,23 @@ export function initializeNotificationListeners(): () => void {
|
|||
const TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS = 500;
|
||||
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
|
||||
const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500;
|
||||
const refreshTrackedTeamMessages = async (teamName: string): Promise<void> => {
|
||||
if (!teamName || !shouldRefreshTeamMessages(teamName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = useStore.getState();
|
||||
try {
|
||||
const headResult = await current.refreshTeamMessagesHead(teamName);
|
||||
const latest = useStore.getState();
|
||||
const meta = latest.memberActivityMetaByTeam[teamName];
|
||||
if (headResult.feedChanged || meta?.feedRevision !== headResult.feedRevision) {
|
||||
await latest.refreshMemberActivityMeta(teamName);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort refresh for message-driven events and fallback polling only.
|
||||
}
|
||||
};
|
||||
const scheduleMemberSpawnStatusesRefresh = (teamName: string | null | undefined): void => {
|
||||
if (!teamName || !isTeamVisibleInAnyPane(teamName)) {
|
||||
return;
|
||||
|
|
@ -265,6 +287,19 @@ export function initializeNotificationListeners(): () => void {
|
|||
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
|
||||
memberSpawnRefreshTimers.set(teamName, timer);
|
||||
};
|
||||
const scheduleTrackedTeamMessageRefresh = (teamName: string | null | undefined): void => {
|
||||
if (!teamName || !shouldRefreshTeamMessages(teamName)) {
|
||||
return;
|
||||
}
|
||||
if (teamMessageRefreshTimers.has(teamName)) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
teamMessageRefreshTimers.delete(teamName);
|
||||
void refreshTrackedTeamMessages(teamName);
|
||||
}, TEAM_REFRESH_THROTTLE_MS);
|
||||
teamMessageRefreshTimers.set(teamName, timer);
|
||||
};
|
||||
const buildToolActivityTimerKey = (
|
||||
teamName: string,
|
||||
memberName: string,
|
||||
|
|
@ -587,6 +622,18 @@ export function initializeNotificationListeners(): () => void {
|
|||
return getVisibleTeamNamesInAnyPane().has(teamName);
|
||||
};
|
||||
|
||||
const shouldRefreshTeamMessages = (teamName: string): boolean => {
|
||||
return isTeamVisibleInAnyPane(teamName) || hasActiveTeamPendingReplyWait(teamName);
|
||||
};
|
||||
|
||||
const getTrackedTeamMessageRefreshTeams = (): Set<string> => {
|
||||
const tracked = getVisibleTeamNamesInAnyPane();
|
||||
for (const teamName of getActiveTeamPendingReplyWaits()) {
|
||||
tracked.add(teamName);
|
||||
}
|
||||
return tracked;
|
||||
};
|
||||
|
||||
const getTrackedChangePresenceTeams = (): Set<string> => {
|
||||
const state = useStore.getState();
|
||||
const tracked = new Set<string>();
|
||||
|
|
@ -627,6 +674,26 @@ export function initializeNotificationListeners(): () => void {
|
|||
return activeTab.teamName;
|
||||
};
|
||||
|
||||
const pollTrackedTeamMessageFallback = async (): Promise<void> => {
|
||||
if (teamMessageFallbackPollInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const teamNames = getTrackedTeamMessageRefreshTeams();
|
||||
if (teamNames.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
teamMessageFallbackPollInFlight = true;
|
||||
try {
|
||||
await Promise.allSettled(
|
||||
Array.from(teamNames, (teamName) => refreshTrackedTeamMessages(teamName))
|
||||
);
|
||||
} finally {
|
||||
teamMessageFallbackPollInFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
const pollFocusedVisibleTeamIdleWatchdog = async (): Promise<void> => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
||||
return;
|
||||
|
|
@ -863,11 +930,18 @@ export function initializeNotificationListeners(): () => void {
|
|||
cleanupFns.push(() => {
|
||||
clearInterval(teamIdleWatchdogTimer);
|
||||
});
|
||||
const teamMessageFallbackPollTimer = setInterval(() => {
|
||||
void pollTrackedTeamMessageFallback();
|
||||
}, TEAM_MESSAGE_FALLBACK_POLL_MS);
|
||||
cleanupFns.push(() => {
|
||||
clearInterval(teamMessageFallbackPollTimer);
|
||||
});
|
||||
|
||||
if (api.teams?.onTeamChange) {
|
||||
const cleanup = api.teams.onTeamChange((_event: unknown, event: TeamChangeEvent) => {
|
||||
const visibleTeam = Boolean(event.teamName) && isTeamVisibleInAnyPane(event.teamName);
|
||||
noteTeamChangeEventBurst(event.teamName, event.type, visibleTeam);
|
||||
const messageRefreshRelevant =
|
||||
Boolean(event.teamName) && shouldRefreshTeamMessages(event.teamName);
|
||||
noteTeamChangeEventBurst(event.teamName, event.type, messageRefreshRelevant);
|
||||
|
||||
const isIgnoredRuntimeRun = (() => {
|
||||
if (!event.runId) return false;
|
||||
|
|
@ -924,24 +998,26 @@ export function initializeNotificationListeners(): () => void {
|
|||
},
|
||||
};
|
||||
|
||||
const cachedTeamData = prev.teamDataCacheByName[event.teamName];
|
||||
if (cachedTeamData) {
|
||||
const baseTeamData =
|
||||
prev.teamDataCacheByName[event.teamName] ??
|
||||
(prev.selectedTeamName === event.teamName ? prev.selectedTeamData : null);
|
||||
const nextTeamData =
|
||||
baseTeamData && baseTeamData.isAlive !== (nextActivity !== 'offline')
|
||||
? {
|
||||
...baseTeamData,
|
||||
isAlive: nextActivity !== 'offline',
|
||||
}
|
||||
: baseTeamData;
|
||||
|
||||
if (nextTeamData) {
|
||||
nextState.teamDataCacheByName = {
|
||||
...prev.teamDataCacheByName,
|
||||
[event.teamName]: {
|
||||
...cachedTeamData,
|
||||
isAlive: nextActivity !== 'offline',
|
||||
},
|
||||
[event.teamName]: nextTeamData,
|
||||
};
|
||||
}
|
||||
|
||||
// Keep TeamDetailView in sync: it historically relied on selectedTeamData.isAlive,
|
||||
// which isn't refreshed for lead-activity events.
|
||||
if (prev.selectedTeamName === event.teamName && prev.selectedTeamData) {
|
||||
nextState.selectedTeamData = {
|
||||
...prev.selectedTeamData,
|
||||
isAlive: nextActivity !== 'offline',
|
||||
};
|
||||
if (prev.selectedTeamName === event.teamName && nextTeamData) {
|
||||
nextState.selectedTeamData = nextTeamData;
|
||||
}
|
||||
|
||||
// Clear context data when lead goes offline
|
||||
|
|
@ -1122,29 +1198,19 @@ export function initializeNotificationListeners(): () => void {
|
|||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'inbox' || event.type === 'config' || event.type === 'process') {
|
||||
scheduleMemberSpawnStatusesRefresh(event.teamName);
|
||||
if (event.type === 'inbox') {
|
||||
scheduleTrackedTeamMessageRefresh(event.teamName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Live lead-message events: only refresh the visible team detail, not team/task lists.
|
||||
// This keeps the refresh lightweight and prevents one noisy team from starving another.
|
||||
// Live lead-message events refresh only the tracked message feed surface
|
||||
// (visible team or local pending-reply wait), not the structural snapshot.
|
||||
if (event.type === 'lead-message') {
|
||||
if (isStaleRuntimeEvent) {
|
||||
return;
|
||||
}
|
||||
seedCurrentRunIdIfMissing();
|
||||
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
|
||||
return;
|
||||
}
|
||||
if (teamRefreshTimers.has(event.teamName)) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
teamRefreshTimers.delete(event.teamName);
|
||||
const current = useStore.getState();
|
||||
void current.refreshTeamData(event.teamName, { withDedup: true });
|
||||
}, TEAM_REFRESH_THROTTLE_MS);
|
||||
teamRefreshTimers.set(event.teamName, timer);
|
||||
scheduleTrackedTeamMessageRefresh(event.teamName);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1205,6 +1271,8 @@ export function initializeNotificationListeners(): () => void {
|
|||
cleanup();
|
||||
for (const t of teamRefreshTimers.values()) clearTimeout(t);
|
||||
teamRefreshTimers = new Map();
|
||||
for (const t of teamMessageRefreshTimers.values()) clearTimeout(t);
|
||||
teamMessageRefreshTimers = new Map();
|
||||
for (const t of teamPresenceRefreshTimers.values()) clearTimeout(t);
|
||||
teamPresenceRefreshTimers = new Map();
|
||||
for (const t of memberSpawnRefreshTimers.values()) clearTimeout(t);
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ export interface ExtensionsSlice {
|
|||
// Slice Creator
|
||||
// =============================================================================
|
||||
|
||||
let pluginFetchInFlight: { key: string; promise: Promise<void> } | null = null;
|
||||
let pluginFetchInFlight: { key: string; promise: Promise<void>; token: symbol } | null = null;
|
||||
let pluginCatalogRequestSeq = 0;
|
||||
const pluginSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const mcpSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
|
@ -409,6 +409,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
}
|
||||
|
||||
const requestSeq = ++pluginCatalogRequestSeq;
|
||||
const requestToken = Symbol('pluginCatalogRequest');
|
||||
set({ pluginCatalogLoading: true, pluginCatalogError: null });
|
||||
|
||||
let currentPromise: Promise<void> | null = null;
|
||||
|
|
@ -468,13 +469,13 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
};
|
||||
});
|
||||
} finally {
|
||||
if (currentPromise && pluginFetchInFlight?.promise === currentPromise) {
|
||||
if (pluginFetchInFlight?.token === requestToken) {
|
||||
pluginFetchInFlight = null;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
pluginFetchInFlight = { key: requestKey, promise: currentPromise };
|
||||
pluginFetchInFlight = { key: requestKey, promise: currentPromise, token: requestToken };
|
||||
await currentPromise;
|
||||
},
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -148,11 +148,7 @@ export function buildDisplayItems(
|
|||
// Build display items
|
||||
for (const step of steps) {
|
||||
// Skip the last output step
|
||||
if (
|
||||
lastOutputStepRef &&
|
||||
step.id === lastOutputStepRef.id &&
|
||||
step.type === lastOutputStepRef.type
|
||||
) {
|
||||
if (step.id === lastOutputStepRef?.id && step.type === lastOutputStepRef.type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import {
|
||||
getProviderScopedTeamModelLabel,
|
||||
isSupportedAnthropicTeamModel,
|
||||
getRuntimeAwareTeamModelUiDisabledReason,
|
||||
getTeamProviderLabel,
|
||||
getTeamProviderModelOptions,
|
||||
|
|
@ -12,11 +11,13 @@ import {
|
|||
GPT_5_2_CODEX_UI_DISABLED_REASON,
|
||||
GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL,
|
||||
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
|
||||
isSupportedAnthropicTeamModel,
|
||||
normalizeTeamModelForUi as normalizeCatalogTeamModelForUi,
|
||||
sortTeamProviderModels,
|
||||
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
|
||||
type TeamProviderModelOption,
|
||||
} from './teamModelCatalog';
|
||||
import { extractProviderScopedBaseModel } from './teamModelContext';
|
||||
|
||||
import type {
|
||||
CliProviderId,
|
||||
|
|
@ -237,6 +238,14 @@ export function isTeamModelAvailableForUi(
|
|||
return getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available';
|
||||
}
|
||||
|
||||
export function normalizeExplicitTeamModelForUi(
|
||||
providerId: SupportedProviderId | undefined,
|
||||
model: string | undefined
|
||||
): string {
|
||||
const normalized = extractProviderScopedBaseModel(model, providerId) ?? '';
|
||||
return normalizeCatalogTeamModelForUi(providerId, normalized).trim();
|
||||
}
|
||||
|
||||
export function normalizeTeamModelForUi(
|
||||
providerId: SupportedProviderId | undefined,
|
||||
model: string | undefined,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { parseModelString } from '@shared/utils/modelParser';
|
||||
import {
|
||||
filterVisibleProviderRuntimeModels,
|
||||
GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL,
|
||||
GPT_5_2_CODEX_UI_DISABLED_MODEL,
|
||||
GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL,
|
||||
} from '@shared/utils/providerModelVisibility';
|
||||
import { parseModelString } from '@shared/utils/modelParser';
|
||||
|
||||
import type { CliProviderId, CliProviderStatus, TeamProviderId } from '@shared/types';
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
import type {
|
||||
MemberSpawnStatusEntry,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
ResolvedTeamMember,
|
||||
TeamProvisioningProgress,
|
||||
} from '@shared/types';
|
||||
|
||||
|
|
@ -17,6 +16,17 @@ type MemberSpawnStatusCollection =
|
|||
| Map<string, MemberSpawnStatusEntry>
|
||||
| undefined;
|
||||
|
||||
interface ProvisioningMemberLike {
|
||||
name: string;
|
||||
removedAt?: number;
|
||||
agentType?: string;
|
||||
status?: string;
|
||||
currentTaskId?: string | null;
|
||||
taskCount?: number;
|
||||
lastActiveAt?: string | null;
|
||||
messageCount?: number;
|
||||
}
|
||||
|
||||
interface FailedSpawnDetail {
|
||||
name: string;
|
||||
reason: string | null;
|
||||
|
|
@ -138,7 +148,7 @@ export function buildTeamProvisioningPresentation({
|
|||
memberSpawnSnapshot,
|
||||
}: {
|
||||
progress: TeamProvisioningProgress | null | undefined;
|
||||
members: readonly ResolvedTeamMember[];
|
||||
members: readonly ProvisioningMemberLike[];
|
||||
memberSpawnStatuses?: MemberSpawnStatusCollection;
|
||||
memberSpawnSnapshot?: Pick<MemberSpawnStatusesSnapshot, 'expectedMembers' | 'summary'>;
|
||||
}): TeamProvisioningPresentation | null {
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ import type {
|
|||
LeadContextUsageSnapshot,
|
||||
MemberFullStats,
|
||||
MemberLogSummary,
|
||||
TeamAgentRuntimeSnapshot,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
MessagesPage,
|
||||
ProjectBranchChangeEvent,
|
||||
|
|
@ -66,6 +65,7 @@ import type {
|
|||
TaskAttachmentMeta,
|
||||
TaskChangePresenceState,
|
||||
TaskComment,
|
||||
TeamAgentRuntimeSnapshot,
|
||||
TeamChangeEvent,
|
||||
TeamClaudeLogsQuery,
|
||||
TeamClaudeLogsResponse,
|
||||
|
|
@ -73,9 +73,9 @@ import type {
|
|||
TeamCreateConfigRequest,
|
||||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
TeamData,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamMemberActivityMeta,
|
||||
TeamMessageNotificationData,
|
||||
TeamProvisioningPrepareResult,
|
||||
TeamProvisioningProgress,
|
||||
|
|
@ -83,6 +83,7 @@ import type {
|
|||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
TeamUpdateConfigRequest,
|
||||
TeamViewSnapshot,
|
||||
ToolApprovalEvent,
|
||||
ToolApprovalFileContent,
|
||||
ToolApprovalSettings,
|
||||
|
|
@ -427,7 +428,7 @@ export interface HttpServerAPI {
|
|||
|
||||
export interface TeamsAPI {
|
||||
list: () => Promise<TeamSummary[]>;
|
||||
getData: (teamName: string) => Promise<TeamData>;
|
||||
getData: (teamName: string) => Promise<TeamViewSnapshot>;
|
||||
getTaskChangePresence: (teamName: string) => Promise<Record<string, TaskChangePresenceState>>;
|
||||
setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise<void>;
|
||||
setToolActivityTracking: (teamName: string, enabled: boolean) => Promise<void>;
|
||||
|
|
@ -451,8 +452,9 @@ export interface TeamsAPI {
|
|||
sendMessage: (teamName: string, request: SendMessageRequest) => Promise<SendMessageResult>;
|
||||
getMessagesPage: (
|
||||
teamName: string,
|
||||
options?: { beforeTimestamp?: string; limit?: number }
|
||||
options?: { cursor?: string | null; limit?: number }
|
||||
) => Promise<MessagesPage>;
|
||||
getMemberActivityMeta: (teamName: string) => Promise<TeamMemberActivityMeta>;
|
||||
createTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
|
||||
requestReview: (teamName: string, taskId: string) => Promise<void>;
|
||||
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
|
||||
|
|
|
|||
|
|
@ -606,6 +606,11 @@ export interface MessagesPage {
|
|||
/** Opaque cursor string for fetching older messages. Null when no more pages. */
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
/**
|
||||
* Content-stable revision of the full normalized feed that produced this page.
|
||||
* Changes only when the semantic message feed changes.
|
||||
*/
|
||||
feedRevision: string;
|
||||
}
|
||||
|
||||
export type AgentActionMode = 'do' | 'ask' | 'delegate';
|
||||
|
|
@ -733,12 +738,44 @@ export interface TeamProcess {
|
|||
stoppedAt?: string;
|
||||
}
|
||||
|
||||
export interface TeamData {
|
||||
export interface TeamMemberSnapshot {
|
||||
name: string;
|
||||
agentId?: string;
|
||||
currentTaskId: string | null;
|
||||
taskCount: number;
|
||||
color?: string;
|
||||
agentType?: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: TeamProviderId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
cwd?: string;
|
||||
/** Set only when member's git branch differs from the lead's branch. */
|
||||
gitBranch?: string;
|
||||
runtimeAdvisory?: MemberRuntimeAdvisory;
|
||||
removedAt?: number;
|
||||
}
|
||||
|
||||
export interface MemberActivityMetaEntry {
|
||||
memberName: string;
|
||||
lastAuthoredMessageAt: string | null;
|
||||
messageCountExact: number;
|
||||
latestAuthoredMessageSignalsTermination: boolean;
|
||||
}
|
||||
|
||||
export interface TeamMemberActivityMeta {
|
||||
teamName: string;
|
||||
computedAt: string;
|
||||
members: Record<string, MemberActivityMetaEntry>;
|
||||
feedRevision: string;
|
||||
}
|
||||
|
||||
export interface TeamViewSnapshot {
|
||||
teamName: string;
|
||||
config: TeamConfig;
|
||||
tasks: TeamTaskWithKanban[];
|
||||
members: ResolvedTeamMember[];
|
||||
messages: InboxMessage[];
|
||||
members: TeamMemberSnapshot[];
|
||||
kanbanState: KanbanState;
|
||||
processes: TeamProcess[];
|
||||
warnings?: string[];
|
||||
|
|
|
|||
|
|
@ -159,13 +159,7 @@ export function hasInstallationInScope(
|
|||
return installations.some((installation) => installation.scope === scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a concise install-status label for plugin badges.
|
||||
*/
|
||||
export function getInstallationSummaryLabel(
|
||||
installations: Pick<InstalledPluginEntry, 'scope'>[]
|
||||
): string | null {
|
||||
const scopes = Array.from(new Set(installations.map((installation) => installation.scope)));
|
||||
function summarizeInstallationScopes(scopes: InstallScope[]): string | null {
|
||||
if (scopes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -175,6 +169,7 @@ export function getInstallationSummaryLabel(
|
|||
}
|
||||
|
||||
switch (scopes[0]) {
|
||||
case 'global':
|
||||
case 'user':
|
||||
return 'Installed globally';
|
||||
case 'project':
|
||||
|
|
@ -186,6 +181,16 @@ export function getInstallationSummaryLabel(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a concise install-status label for plugin badges.
|
||||
*/
|
||||
export function getInstallationSummaryLabel(
|
||||
installations: Pick<InstalledPluginEntry, 'scope'>[]
|
||||
): string | null {
|
||||
const scopes = Array.from(new Set(installations.map((installation) => installation.scope)));
|
||||
return summarizeInstallationScopes(scopes);
|
||||
}
|
||||
|
||||
const MCP_SCOPE_PRIORITY: Record<InstalledMcpEntry['scope'], number> = {
|
||||
local: 0,
|
||||
project: 1,
|
||||
|
|
@ -216,25 +221,7 @@ export function getMcpInstallationSummaryLabel(
|
|||
installations: Pick<InstalledMcpEntry, 'scope'>[]
|
||||
): string | null {
|
||||
const scopes = Array.from(new Set(installations.map((installation) => installation.scope)));
|
||||
if (scopes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (scopes.length > 1) {
|
||||
return `Installed in ${scopes.length} scopes`;
|
||||
}
|
||||
|
||||
switch (scopes[0]) {
|
||||
case 'global':
|
||||
case 'user':
|
||||
return 'Installed globally';
|
||||
case 'project':
|
||||
return 'Installed in project';
|
||||
case 'local':
|
||||
return 'Installed locally';
|
||||
default:
|
||||
return 'Installed';
|
||||
}
|
||||
return summarizeInstallationScopes(scopes);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -104,10 +104,10 @@ function parseRelativeResetDuration(text: string): number | null {
|
|||
const match = LEADING_TIME_VALUE_RE.exec(tail);
|
||||
if (!match) return null;
|
||||
|
||||
const amount = Number.parseFloat(match[1]!);
|
||||
const amount = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(amount) || amount < 0) return null;
|
||||
|
||||
const unit = match[2]!.toLowerCase();
|
||||
const unit = match[2].toLowerCase();
|
||||
if (['second', 'seconds', 'sec', 'secs', 's'].includes(unit)) {
|
||||
return Math.round(amount * 1000);
|
||||
}
|
||||
|
|
@ -157,7 +157,7 @@ function parseAbsoluteResetClockTime(text: string, now: Date): Date | null {
|
|||
const afterMatch = tail.slice(tzTokenLength);
|
||||
if (DAY_SHIFT_QUALIFIER_RE.test(afterMatch)) return null;
|
||||
|
||||
const hourRaw = Number.parseInt(match[1]!, 10);
|
||||
const hourRaw = Number.parseInt(match[1], 10);
|
||||
const minuteRaw = match[2] ? Number.parseInt(match[2], 10) : 0;
|
||||
const ampm = match[3]?.toLowerCase() ?? null;
|
||||
const parenthesizedTz = parenthesizedTzMatch?.[1]?.toUpperCase() ?? '';
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export interface TeamGraphDefaultLayoutSeed {
|
|||
assignments: Record<string, GraphOwnerSlotAssignment>;
|
||||
}
|
||||
|
||||
const SMALL_TEAM_CARDINAL_SLOT_PRESETS: ReadonlyArray<ReadonlyArray<GraphOwnerSlotAssignment>> = [
|
||||
const SMALL_TEAM_CARDINAL_SLOT_PRESETS: readonly (readonly GraphOwnerSlotAssignment[])[] = [
|
||||
[],
|
||||
[{ ringIndex: 0, sectorIndex: 0 }],
|
||||
[
|
||||
|
|
@ -83,7 +83,7 @@ export function buildTeamGraphDefaultLayoutSeed(
|
|||
const preset = SMALL_TEAM_CARDINAL_SLOT_PRESETS[orderedVisibleOwnerIds.length];
|
||||
const assignments: Record<string, GraphOwnerSlotAssignment> = {};
|
||||
|
||||
if (preset && preset.length === orderedVisibleOwnerIds.length) {
|
||||
if (preset?.length === orderedVisibleOwnerIds.length) {
|
||||
orderedVisibleOwnerIds.forEach((stableOwnerId, index) => {
|
||||
assignments[stableOwnerId] = preset[index]!;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ describe('normalizeDashboardRecentProjectsPayload', () => {
|
|||
normalizeDashboardRecentProjectsPayload({
|
||||
degraded: false,
|
||||
projects: null,
|
||||
} as unknown as { degraded: boolean; projects: null })
|
||||
} as unknown as Parameters<typeof normalizeDashboardRecentProjectsPayload>[0])
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -8,12 +8,13 @@ import type {
|
|||
BoardTaskExactLogSummariesResponse,
|
||||
InboxMessage,
|
||||
MessagesPage,
|
||||
TeamViewSnapshot,
|
||||
TeamCreateRequest,
|
||||
TeamProvisioningProgress,
|
||||
} from '@shared/types/team';
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp') },
|
||||
app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp'), isPackaged: false },
|
||||
Notification: Object.assign(vi.fn(), { isSupported: vi.fn(() => false) }),
|
||||
BrowserWindow: { getAllWindows: vi.fn(() => []) },
|
||||
}));
|
||||
|
|
@ -35,6 +36,8 @@ const { mockTeamDataWorkerClient } = vi.hoisted(() => ({
|
|||
mockTeamDataWorkerClient: {
|
||||
isAvailable: vi.fn(),
|
||||
getTeamData: vi.fn(),
|
||||
getMessagesPage: vi.fn(),
|
||||
getMemberActivityMeta: vi.fn(),
|
||||
findLogsForTask: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
|
@ -63,6 +66,8 @@ import {
|
|||
TEAM_CREATE_TASK,
|
||||
TEAM_DELETE_TEAM,
|
||||
TEAM_GET_DATA,
|
||||
TEAM_GET_MEMBER_ACTIVITY_META,
|
||||
TEAM_GET_MESSAGES_PAGE,
|
||||
TEAM_LAUNCH,
|
||||
TEAM_LIST,
|
||||
TEAM_PREPARE_PROVISIONING,
|
||||
|
|
@ -81,7 +86,6 @@ import {
|
|||
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
|
||||
TEAM_GET_MEMBER_LOGS,
|
||||
TEAM_GET_MEMBER_STATS,
|
||||
TEAM_GET_MESSAGES_PAGE,
|
||||
TEAM_START_TASK,
|
||||
TEAM_UPDATE_CONFIG,
|
||||
TEAM_UPDATE_KANBAN,
|
||||
|
|
@ -133,23 +137,38 @@ describe('ipc teams handlers', () => {
|
|||
|
||||
const service = {
|
||||
listTeams: vi.fn(async () => [{ teamName: 'my-team', displayName: 'My Team' }]),
|
||||
getTeamData: vi.fn(async () => ({
|
||||
getTeamData: vi.fn(async (): Promise<TeamViewSnapshot & { messages?: InboxMessage[] }> => ({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: [] as InboxMessage[],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
})),
|
||||
getMessageFeed: vi.fn(async () => ({
|
||||
teamName: 'my-team',
|
||||
feedRevision: 'rev-1',
|
||||
messages: [] as InboxMessage[],
|
||||
})),
|
||||
getMessagesPage: vi.fn(async (..._args: unknown[]): Promise<MessagesPage> => ({
|
||||
messages: [] as InboxMessage[],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
feedRevision: 'rev-1',
|
||||
})),
|
||||
getMemberActivityMeta: vi.fn(async () => ({
|
||||
teamName: 'my-team',
|
||||
computedAt: '2026-03-12T10:00:00.000Z',
|
||||
members: {},
|
||||
feedRevision: 'rev-1',
|
||||
})),
|
||||
getTaskChangePresence: vi.fn(async () => ({ 'task-1': 'has_changes' })),
|
||||
reconcileTeamArtifacts: vi.fn(async () => undefined),
|
||||
setTaskChangePresenceTracking: vi.fn(() => undefined),
|
||||
getTeamNotificationContext: vi.fn(async () => ({
|
||||
displayName: 'My Team',
|
||||
projectPath: '/tmp/project',
|
||||
})),
|
||||
deleteTeam: vi.fn(async () => undefined),
|
||||
getLeadMemberName: vi.fn(async () => 'team-lead'),
|
||||
getTeamDisplayName: vi.fn(async () => 'My Team'),
|
||||
|
|
@ -241,6 +260,8 @@ describe('ipc teams handlers', () => {
|
|||
mockGetMembersMeta.mockResolvedValue([]);
|
||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
||||
mockTeamDataWorkerClient.getTeamData.mockReset();
|
||||
mockTeamDataWorkerClient.getMessagesPage.mockReset();
|
||||
mockTeamDataWorkerClient.getMemberActivityMeta.mockReset();
|
||||
mockTeamDataWorkerClient.findLogsForTask.mockReset();
|
||||
initializeTeamHandlers(
|
||||
service as never,
|
||||
|
|
@ -250,6 +271,7 @@ describe('ipc teams handlers', () => {
|
|||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
boardTaskActivityService as never,
|
||||
boardTaskActivityDetailService as never,
|
||||
boardTaskLogStreamService as never,
|
||||
|
|
@ -266,6 +288,8 @@ describe('ipc teams handlers', () => {
|
|||
it('registers all expected handlers', () => {
|
||||
expect(handlers.has(TEAM_LIST)).toBe(true);
|
||||
expect(handlers.has(TEAM_GET_DATA)).toBe(true);
|
||||
expect(handlers.has(TEAM_GET_MESSAGES_PAGE)).toBe(true);
|
||||
expect(handlers.has(TEAM_GET_MEMBER_ACTIVITY_META)).toBe(true);
|
||||
expect(handlers.has(TEAM_GET_TASK_CHANGE_PRESENCE)).toBe(true);
|
||||
expect(handlers.has(TEAM_SET_CHANGE_PRESENCE_TRACKING)).toBe(true);
|
||||
expect(handlers.has(TEAM_DELETE_TEAM)).toBe(true);
|
||||
|
|
@ -594,7 +618,6 @@ describe('ipc teams handlers', () => {
|
|||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: [] as InboxMessage[],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
|
|
@ -773,24 +796,7 @@ describe('ipc teams handlers', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('dedups live lead replies when lead_session already has same text', async () => {
|
||||
service.getTeamData.mockResolvedValueOnce({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: [
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: 'Hello there',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: true,
|
||||
source: 'lead_session' as const,
|
||||
},
|
||||
],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
});
|
||||
it('keeps TEAM_GET_DATA structural and does not expose message transport', async () => {
|
||||
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
||||
{
|
||||
from: 'team-lead',
|
||||
|
|
@ -805,12 +811,31 @@ describe('ipc teams handlers', () => {
|
|||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
||||
const result = (await getDataHandler({} as never, 'my-team')) as {
|
||||
success: boolean;
|
||||
data: { messages: { source?: string }[] };
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(result.success).toBe(true);
|
||||
const sources = result.data.messages.map((m) => m.source);
|
||||
expect(sources.filter((s) => s === 'lead_process')).toHaveLength(0);
|
||||
expect(sources.filter((s) => s === 'lead_session')).toHaveLength(1);
|
||||
expect(result.data.teamName).toBe('my-team');
|
||||
expect(result.data).not.toHaveProperty('messages');
|
||||
expect(service.getMessageFeed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back TEAM_GET_DATA to the main thread in packaged runtime when worker is unavailable', async () => {
|
||||
const electron = await import('electron');
|
||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
||||
(electron.app as { isPackaged: boolean }).isPackaged = true;
|
||||
|
||||
const handler = handlers.get(TEAM_GET_DATA)!;
|
||||
const result = (await handler({} as never, 'my-team')) as {
|
||||
success: boolean;
|
||||
data?: { teamName: string };
|
||||
};
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.teamName).toBe('my-team');
|
||||
expect(service.getTeamData).toHaveBeenCalledWith('my-team');
|
||||
vi.mocked(console.error).mockClear();
|
||||
|
||||
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
||||
});
|
||||
|
||||
it('does not let a live duplicate of the same session rate-limit reply delay auto-resume', async () => {
|
||||
|
|
@ -867,7 +892,7 @@ describe('ipc teams handlers', () => {
|
|||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
||||
const result = (await getDataHandler({} as never, 'my-team')) as {
|
||||
success: boolean;
|
||||
data: { messages: Array<{ source?: string; messageId?: string }> };
|
||||
data: { messages?: InboxMessage[] };
|
||||
};
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
|
@ -888,51 +913,141 @@ describe('ipc teams handlers', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('merges early live messages before durable lead_session backfill exists', async () => {
|
||||
// Simulate: team just became readable but lead_session JSONL hasn't been written yet.
|
||||
// Only live in-memory messages exist from the provisioning process.
|
||||
service.getTeamData.mockResolvedValueOnce({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
messages: [], // No durable messages yet
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
it('uses the team-data worker for TEAM_GET_MESSAGES_PAGE when available', async () => {
|
||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
||||
mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({
|
||||
messages: [
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: 'Hello there',
|
||||
timestamp: '2026-02-23T10:00:01.000Z',
|
||||
read: true,
|
||||
source: 'lead_session' as const,
|
||||
messageId: 'msg-1',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
feedRevision: 'rev-worker',
|
||||
});
|
||||
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: 'Команда создана. Запускаю тиммейтов.',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: true,
|
||||
source: 'lead_process' as const,
|
||||
messageId: 'lead-turn-run-1-1',
|
||||
},
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: 'All teammates online!',
|
||||
timestamp: '2026-02-23T10:00:01.000Z',
|
||||
read: true,
|
||||
source: 'lead_process' as const,
|
||||
messageId: 'lead-turn-run-1-2',
|
||||
to: 'user',
|
||||
},
|
||||
]);
|
||||
|
||||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
||||
const result = (await getDataHandler({} as never, 'my-team')) as {
|
||||
success: boolean;
|
||||
data: { messages: { source?: string; text: string }[] };
|
||||
};
|
||||
const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!;
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
limit: 50,
|
||||
})) as { success: boolean; data: { feedRevision: string } };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Both live messages should appear since there's no durable backfill yet
|
||||
// Sorted by timestamp descending (newest first)
|
||||
expect(result.data.messages).toHaveLength(2);
|
||||
expect(result.data.messages[0].source).toBe('lead_process');
|
||||
expect(result.data.messages[0].text).toBe('All teammates online!');
|
||||
expect(result.data.messages[1].source).toBe('lead_process');
|
||||
expect(result.data.messages[1].text).toBe('Команда создана. Запускаю тиммейтов.');
|
||||
expect(result.data.feedRevision).toBe('rev-worker');
|
||||
expect(mockTeamDataWorkerClient.getMessagesPage).toHaveBeenCalledWith('my-team', {
|
||||
cursor: undefined,
|
||||
limit: 50,
|
||||
});
|
||||
expect(service.getMessagesPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scans rate-limit notifications from message-page results without hydrating TEAM_GET_DATA feed', async () => {
|
||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
||||
mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({
|
||||
messages: [
|
||||
{
|
||||
from: 'team-lead',
|
||||
text: "You've hit your limit. Please wait a bit before retrying.",
|
||||
timestamp: '2026-02-23T10:00:01.000Z',
|
||||
read: true,
|
||||
source: 'lead_session' as const,
|
||||
messageId: 'msg-rate-limit-1',
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
feedRevision: 'rev-worker',
|
||||
});
|
||||
|
||||
const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!;
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
limit: 50,
|
||||
})) as { success: boolean; data: { feedRevision: string } };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.feedRevision).toBe('rev-worker');
|
||||
expect(mockAddTeamNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamEventType: 'rate_limit',
|
||||
teamName: 'my-team',
|
||||
teamDisplayName: 'My Team',
|
||||
from: 'team-lead',
|
||||
dedupeKey: 'rate-limit:my-team:msg-rate-limit-1',
|
||||
})
|
||||
);
|
||||
expect(service.getMessageFeed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back TEAM_GET_MESSAGES_PAGE to the main thread in packaged runtime when worker is unavailable', async () => {
|
||||
const electron = await import('electron');
|
||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
||||
(electron.app as { isPackaged: boolean }).isPackaged = true;
|
||||
|
||||
const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!;
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
limit: 50,
|
||||
})) as { success: boolean; data?: { feedRevision: string } };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.feedRevision).toBe('rev-1');
|
||||
expect(service.getMessagesPage).toHaveBeenCalledWith('my-team', {
|
||||
cursor: undefined,
|
||||
limit: 50,
|
||||
});
|
||||
vi.mocked(console.error).mockClear();
|
||||
|
||||
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
||||
});
|
||||
|
||||
it('uses the team-data worker for TEAM_GET_MEMBER_ACTIVITY_META when available', async () => {
|
||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
||||
mockTeamDataWorkerClient.getMemberActivityMeta.mockResolvedValueOnce({
|
||||
teamName: 'my-team',
|
||||
computedAt: '2026-03-12T10:00:00.000Z',
|
||||
members: {
|
||||
alice: {
|
||||
memberName: 'alice',
|
||||
lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z',
|
||||
messageCountExact: 4,
|
||||
latestAuthoredMessageSignalsTermination: false,
|
||||
},
|
||||
},
|
||||
feedRevision: 'rev-worker',
|
||||
});
|
||||
|
||||
const handler = handlers.get(TEAM_GET_MEMBER_ACTIVITY_META)!;
|
||||
const result = (await handler({} as never, 'my-team')) as {
|
||||
success: boolean;
|
||||
data: { feedRevision: string };
|
||||
};
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.feedRevision).toBe('rev-worker');
|
||||
expect(mockTeamDataWorkerClient.getMemberActivityMeta).toHaveBeenCalledWith('my-team');
|
||||
expect(service.getMemberActivityMeta).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back TEAM_GET_MEMBER_ACTIVITY_META to the main thread in packaged runtime when worker is unavailable', async () => {
|
||||
const electron = await import('electron');
|
||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
||||
(electron.app as { isPackaged: boolean }).isPackaged = true;
|
||||
|
||||
const handler = handlers.get(TEAM_GET_MEMBER_ACTIVITY_META)!;
|
||||
const result = (await handler({} as never, 'my-team')) as {
|
||||
success: boolean;
|
||||
data?: { feedRevision: string };
|
||||
};
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.feedRevision).toBe('rev-1');
|
||||
expect(service.getMemberActivityMeta).toHaveBeenCalledWith('my-team');
|
||||
vi.mocked(console.error).mockClear();
|
||||
|
||||
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
||||
});
|
||||
|
||||
it('rebuilds only the remaining auto-resume delay from persisted rate-limit history', async () => {
|
||||
|
|
@ -1316,6 +1431,7 @@ describe('ipc teams handlers', () => {
|
|||
],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
feedRevision: 'rev-1',
|
||||
});
|
||||
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
||||
{
|
||||
|
|
@ -1332,7 +1448,7 @@ describe('ipc teams handlers', () => {
|
|||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
||||
const result = (await getDataHandler({} as never, 'my-team')) as {
|
||||
success: boolean;
|
||||
data: { messages: InboxMessage[] };
|
||||
data: { messages?: InboxMessage[] };
|
||||
};
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
|
@ -1345,7 +1461,7 @@ describe('ipc teams handlers', () => {
|
|||
}),
|
||||
]),
|
||||
});
|
||||
expect(result.data.messages.map((message) => message.messageId)).toEqual(['durable-0']);
|
||||
expect(result.data.messages).toHaveLength(50);
|
||||
});
|
||||
|
||||
it('overlays live lead_process messages onto the newest messages page', async () => {
|
||||
|
|
@ -1365,7 +1481,8 @@ describe('ipc teams handlers', () => {
|
|||
].sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp)),
|
||||
nextCursor: '2026-02-23T10:00:00.000Z|durable-1',
|
||||
hasMore: true,
|
||||
};
|
||||
feedRevision: 'rev-1',
|
||||
} satisfies MessagesPage;
|
||||
});
|
||||
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
||||
{
|
||||
|
|
@ -1393,7 +1510,7 @@ describe('ipc teams handlers', () => {
|
|||
expect(result.data.hasMore).toBe(true);
|
||||
expect(service.getMessagesPage).toHaveBeenCalledWith('my-team', {
|
||||
limit: 20,
|
||||
beforeTimestamp: undefined,
|
||||
cursor: undefined,
|
||||
liveMessages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
source: 'lead_process',
|
||||
|
|
@ -1421,7 +1538,8 @@ describe('ipc teams handlers', () => {
|
|||
],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
};
|
||||
feedRevision: 'rev-1',
|
||||
} satisfies MessagesPage;
|
||||
});
|
||||
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
||||
{
|
||||
|
|
@ -1461,11 +1579,12 @@ describe('ipc teams handlers', () => {
|
|||
],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
feedRevision: 'rev-1',
|
||||
});
|
||||
|
||||
const result = (await handlers.get(TEAM_GET_MESSAGES_PAGE)!({} as never, 'my-team', {
|
||||
limit: 20,
|
||||
beforeTimestamp: '2026-02-23T10:00:00.000Z|cursor',
|
||||
cursor: '2026-02-23T10:00:00.000Z|cursor',
|
||||
})) as {
|
||||
success: boolean;
|
||||
data: { messages: InboxMessage[] };
|
||||
|
|
|
|||
|
|
@ -1,22 +1,41 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { ClaudeExtensionsAdapter } from '@main/services/extensions/runtime/ExtensionsRuntimeAdapter';
|
||||
import { McpConfigStateReader } from '@main/services/extensions/runtime/McpConfigStateReader';
|
||||
import { McpInstallationStateService } from '@main/services/extensions/state/McpInstallationStateService';
|
||||
|
||||
const TEST_ROOT = path.parse(process.cwd()).root || path.sep;
|
||||
const MOCK_HOME_PATH = path.join(TEST_ROOT, 'tmp', 'mock-home');
|
||||
const PROJECT_A_PATH = path.join(TEST_ROOT, 'tmp', 'project-a');
|
||||
const PROJECT_B_PATH = path.join(TEST_ROOT, 'tmp', 'project-b');
|
||||
|
||||
function normalizeMockPath(filePath: unknown): string {
|
||||
return String(filePath).replaceAll('\\', '/');
|
||||
}
|
||||
|
||||
vi.mock('@main/utils/pathDecoder', () => ({
|
||||
getHomeDir: () => '/tmp/mock-home',
|
||||
getClaudeBasePath: () => '/tmp/mock-home/.claude',
|
||||
getHomeDir: () => {
|
||||
const cwd = process.cwd();
|
||||
const windowsRoot = cwd.match(/^[A-Za-z]:[\\/]/)?.[0] ?? null;
|
||||
const root = windowsRoot ?? '/';
|
||||
const sep = windowsRoot ? '\\' : '/';
|
||||
return `${root}${root.endsWith(sep) ? '' : sep}tmp${sep}mock-home`;
|
||||
},
|
||||
getClaudeBasePath: () => {
|
||||
const cwd = process.cwd();
|
||||
const windowsRoot = cwd.match(/^[A-Za-z]:[\\/]/)?.[0] ?? null;
|
||||
const root = windowsRoot ?? '/';
|
||||
const sep = windowsRoot ? '\\' : '/';
|
||||
const mockHome = `${root}${root.endsWith(sep) ? '' : sep}tmp${sep}mock-home`;
|
||||
return `${mockHome}${sep}.claude`;
|
||||
},
|
||||
setClaudeBasePathOverride: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
|
||||
function toPortablePath(filePath: unknown): string {
|
||||
return String(filePath).replaceAll('\\', '/');
|
||||
}
|
||||
|
||||
describe('McpInstallationStateService', () => {
|
||||
let service: McpInstallationStateService;
|
||||
const mockedFs = vi.mocked(fs);
|
||||
|
|
@ -35,14 +54,14 @@ describe('McpInstallationStateService', () => {
|
|||
describe('getInstalled', () => {
|
||||
it('includes local scope from the current project entry in ~/.claude.json', async () => {
|
||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||
const normalizedPath = toPortablePath(filePath);
|
||||
if (normalizedPath === '/tmp/mock-home/.claude.json') {
|
||||
const normalizedPath = normalizeMockPath(filePath);
|
||||
if (normalizedPath === normalizeMockPath(path.join(MOCK_HOME_PATH, '.claude.json'))) {
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
context7: { command: 'npx -y @upstash/context7-mcp' },
|
||||
},
|
||||
projects: {
|
||||
'/tmp/project-a': {
|
||||
[PROJECT_A_PATH]: {
|
||||
mcpServers: {
|
||||
stripe: { url: 'https://mcp.stripe.com' },
|
||||
},
|
||||
|
|
@ -51,7 +70,7 @@ describe('McpInstallationStateService', () => {
|
|||
});
|
||||
}
|
||||
|
||||
if (normalizedPath === '/tmp/project-a/.mcp.json') {
|
||||
if (normalizedPath === normalizeMockPath(path.join(PROJECT_A_PATH, '.mcp.json'))) {
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
paypal: { url: 'https://mcp.paypal.com/mcp' },
|
||||
|
|
@ -62,7 +81,7 @@ describe('McpInstallationStateService', () => {
|
|||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
const entries = await service.getInstalled('/tmp/project-a');
|
||||
const entries = await service.getInstalled(PROJECT_A_PATH);
|
||||
|
||||
expect(entries).toEqual([
|
||||
{ name: 'context7', scope: 'user', transport: 'stdio' },
|
||||
|
|
@ -73,8 +92,8 @@ describe('McpInstallationStateService', () => {
|
|||
|
||||
it('caches results within TTL for the same project path', async () => {
|
||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||
const normalizedPath = toPortablePath(filePath);
|
||||
if (normalizedPath === '/tmp/mock-home/.claude.json') {
|
||||
const normalizedPath = normalizeMockPath(filePath);
|
||||
if (normalizedPath === normalizeMockPath(path.join(MOCK_HOME_PATH, '.claude.json'))) {
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
context7: { command: 'npx -y @upstash/context7-mcp' },
|
||||
|
|
@ -82,7 +101,7 @@ describe('McpInstallationStateService', () => {
|
|||
});
|
||||
}
|
||||
|
||||
if (normalizedPath === '/tmp/project-a/.mcp.json') {
|
||||
if (normalizedPath === normalizeMockPath(path.join(PROJECT_A_PATH, '.mcp.json'))) {
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
'repo-a-server': { url: 'https://repo-a.example.com/mcp' },
|
||||
|
|
@ -93,27 +112,27 @@ describe('McpInstallationStateService', () => {
|
|||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
await service.getInstalled('/tmp/project-a');
|
||||
await service.getInstalled('/tmp/project-a');
|
||||
await service.getInstalled(PROJECT_A_PATH);
|
||||
await service.getInstalled(PROJECT_A_PATH);
|
||||
|
||||
expect(mockedFs.readFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('caches results independently per project path', async () => {
|
||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||
const normalizedPath = toPortablePath(filePath);
|
||||
if (normalizedPath === '/tmp/mock-home/.claude.json') {
|
||||
const normalizedPath = normalizeMockPath(filePath);
|
||||
if (normalizedPath === normalizeMockPath(path.join(MOCK_HOME_PATH, '.claude.json'))) {
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
context7: { command: 'npx -y @upstash/context7-mcp' },
|
||||
},
|
||||
projects: {
|
||||
'/tmp/project-a': {
|
||||
[PROJECT_A_PATH]: {
|
||||
mcpServers: {
|
||||
stripe: { url: 'https://mcp.stripe.com' },
|
||||
},
|
||||
},
|
||||
'/tmp/project-b': {
|
||||
[PROJECT_B_PATH]: {
|
||||
mcpServers: {
|
||||
github: { command: 'uvx github-mcp' },
|
||||
},
|
||||
|
|
@ -122,7 +141,7 @@ describe('McpInstallationStateService', () => {
|
|||
});
|
||||
}
|
||||
|
||||
if (normalizedPath === '/tmp/project-a/.mcp.json') {
|
||||
if (normalizedPath === normalizeMockPath(path.join(PROJECT_A_PATH, '.mcp.json'))) {
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
'repo-a-server': { url: 'https://repo-a.example.com/mcp' },
|
||||
|
|
@ -130,7 +149,7 @@ describe('McpInstallationStateService', () => {
|
|||
});
|
||||
}
|
||||
|
||||
if (normalizedPath === '/tmp/project-b/.mcp.json') {
|
||||
if (normalizedPath === normalizeMockPath(path.join(PROJECT_B_PATH, '.mcp.json'))) {
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
'repo-b-server': { command: 'uvx repo-b-mcp' },
|
||||
|
|
@ -141,8 +160,8 @@ describe('McpInstallationStateService', () => {
|
|||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
const projectAEntries = await service.getInstalled('/tmp/project-a');
|
||||
const projectBEntries = await service.getInstalled('/tmp/project-b');
|
||||
const projectAEntries = await service.getInstalled(PROJECT_A_PATH);
|
||||
const projectBEntries = await service.getInstalled(PROJECT_B_PATH);
|
||||
|
||||
expect(projectAEntries).toEqual([
|
||||
{ name: 'context7', scope: 'user', transport: 'stdio' },
|
||||
|
|
|
|||
|
|
@ -1,20 +1,30 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { PluginInstallationStateService } from '@main/services/extensions/state/PluginInstallationStateService';
|
||||
|
||||
// Mock pathDecoder to control ~/.claude path
|
||||
vi.mock('@main/utils/pathDecoder', () => ({
|
||||
getClaudeBasePath: () => '/tmp/mock-claude',
|
||||
}));
|
||||
const TEST_ROOT = path.parse(process.cwd()).root || path.sep;
|
||||
const MOCK_CLAUDE_BASE_PATH = path.join(TEST_ROOT, 'tmp', 'mock-claude');
|
||||
const PROJECT_A_PATH = path.join(TEST_ROOT, 'tmp', 'project-a');
|
||||
const PROJECT_B_PATH = path.join(TEST_ROOT, 'tmp', 'project-b');
|
||||
|
||||
// Mock filesystem
|
||||
vi.mock('node:fs/promises');
|
||||
|
||||
function toPortablePath(filePath: unknown): string {
|
||||
function normalizeMockPath(filePath: unknown): string {
|
||||
return String(filePath).replaceAll('\\', '/');
|
||||
}
|
||||
|
||||
vi.mock('@main/utils/pathDecoder', () => ({
|
||||
getClaudeBasePath: () => {
|
||||
const cwd = process.cwd();
|
||||
const windowsRoot = cwd.match(/^[A-Za-z]:[\\/]/)?.[0] ?? null;
|
||||
const root = windowsRoot ?? '/';
|
||||
const sep = windowsRoot ? '\\' : '/';
|
||||
return `${root}tmp${sep}mock-claude`;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
|
||||
describe('PluginInstallationStateService', () => {
|
||||
let service: PluginInstallationStateService;
|
||||
const mockedFs = vi.mocked(fs);
|
||||
|
|
@ -31,7 +41,7 @@ describe('PluginInstallationStateService', () => {
|
|||
describe('getInstalledPlugins', () => {
|
||||
it('returns user-scoped plugins enabled in user settings', async () => {
|
||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||
const normalizedPath = toPortablePath(filePath);
|
||||
const normalizedPath = normalizeMockPath(filePath);
|
||||
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
||||
return JSON.stringify({
|
||||
version: 2,
|
||||
|
|
@ -56,7 +66,7 @@ describe('PluginInstallationStateService', () => {
|
|||
});
|
||||
}
|
||||
|
||||
if (normalizedPath === '/tmp/mock-claude/settings.json') {
|
||||
if (normalizedPath === normalizeMockPath(path.join(MOCK_CLAUDE_BASE_PATH, 'settings.json'))) {
|
||||
return JSON.stringify({
|
||||
enabledPlugins: {
|
||||
'context7@claude-plugins-official': true,
|
||||
|
|
@ -79,7 +89,7 @@ describe('PluginInstallationStateService', () => {
|
|||
|
||||
it('includes project and local scopes only for the active project', async () => {
|
||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||
const normalizedPath = toPortablePath(filePath);
|
||||
const normalizedPath = normalizeMockPath(filePath);
|
||||
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
||||
return JSON.stringify({
|
||||
version: 2,
|
||||
|
|
@ -109,7 +119,7 @@ describe('PluginInstallationStateService', () => {
|
|||
});
|
||||
}
|
||||
|
||||
if (normalizedPath === '/tmp/mock-claude/settings.json') {
|
||||
if (normalizedPath === normalizeMockPath(path.join(MOCK_CLAUDE_BASE_PATH, 'settings.json'))) {
|
||||
return JSON.stringify({
|
||||
enabledPlugins: {
|
||||
'context7@claude-plugins-official': true,
|
||||
|
|
@ -117,7 +127,7 @@ describe('PluginInstallationStateService', () => {
|
|||
});
|
||||
}
|
||||
|
||||
if (normalizedPath === '/tmp/project-a/.claude/settings.json') {
|
||||
if (normalizedPath === normalizeMockPath(path.join(PROJECT_A_PATH, '.claude', 'settings.json'))) {
|
||||
return JSON.stringify({
|
||||
enabledPlugins: {
|
||||
'typescript-lsp@claude-plugins-official': true,
|
||||
|
|
@ -125,7 +135,10 @@ describe('PluginInstallationStateService', () => {
|
|||
});
|
||||
}
|
||||
|
||||
if (normalizedPath === '/tmp/project-a/.claude/settings.local.json') {
|
||||
if (
|
||||
normalizedPath ===
|
||||
normalizeMockPath(path.join(PROJECT_A_PATH, '.claude', 'settings.local.json'))
|
||||
) {
|
||||
return JSON.stringify({
|
||||
enabledPlugins: {
|
||||
'formatter@claude-plugins-official': true,
|
||||
|
|
@ -136,7 +149,7 @@ describe('PluginInstallationStateService', () => {
|
|||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
const entries = await service.getInstalledPlugins('/tmp/project-a');
|
||||
const entries = await service.getInstalledPlugins(PROJECT_A_PATH);
|
||||
|
||||
expect(entries.map((entry) => [entry.pluginId, entry.scope])).toEqual([
|
||||
['context7@claude-plugins-official', 'user'],
|
||||
|
|
@ -147,7 +160,7 @@ describe('PluginInstallationStateService', () => {
|
|||
|
||||
it('does not leak another project scope into the current project', async () => {
|
||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||
const normalizedPath = toPortablePath(filePath);
|
||||
const normalizedPath = normalizeMockPath(filePath);
|
||||
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
||||
return JSON.stringify({
|
||||
version: 2,
|
||||
|
|
@ -170,7 +183,7 @@ describe('PluginInstallationStateService', () => {
|
|||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
const entries = await service.getInstalledPlugins('/tmp/project-b');
|
||||
const entries = await service.getInstalledPlugins(PROJECT_B_PATH);
|
||||
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
|
@ -186,7 +199,7 @@ describe('PluginInstallationStateService', () => {
|
|||
|
||||
it('returns empty array for unexpected version', async () => {
|
||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||
const normalizedPath = toPortablePath(filePath);
|
||||
const normalizedPath = normalizeMockPath(filePath);
|
||||
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
||||
return JSON.stringify({ version: 1, plugins: {} });
|
||||
}
|
||||
|
|
@ -202,7 +215,7 @@ describe('PluginInstallationStateService', () => {
|
|||
|
||||
it('caches within TTL', async () => {
|
||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||
const normalizedPath = toPortablePath(filePath);
|
||||
const normalizedPath = normalizeMockPath(filePath);
|
||||
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
||||
return JSON.stringify({ version: 2, plugins: {} });
|
||||
}
|
||||
|
|
@ -220,7 +233,7 @@ describe('PluginInstallationStateService', () => {
|
|||
|
||||
it('caches results independently per project path', async () => {
|
||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||
const normalizedPath = toPortablePath(filePath);
|
||||
const normalizedPath = normalizeMockPath(filePath);
|
||||
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
||||
return JSON.stringify({ version: 2, plugins: {} });
|
||||
}
|
||||
|
|
@ -230,8 +243,8 @@ describe('PluginInstallationStateService', () => {
|
|||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
await service.getInstalledPlugins('/tmp/project-a');
|
||||
await service.getInstalledPlugins('/tmp/project-b');
|
||||
await service.getInstalledPlugins(PROJECT_A_PATH);
|
||||
await service.getInstalledPlugins(PROJECT_B_PATH);
|
||||
|
||||
expect(mockedFs.readFile).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -292,6 +292,9 @@ describe('CliInstallerService', () => {
|
|||
expect.objectContaining({ modelId: 'gpt-5.4-mini', status: 'checking' }),
|
||||
])
|
||||
);
|
||||
expect(verifiedProvider?.modelAvailability).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ modelId: 'gpt-5.2-codex' })])
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const latestCodexProvider = service
|
||||
|
|
@ -307,6 +310,12 @@ describe('CliInstallerService', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
expect(execCli).not.toHaveBeenCalledWith(
|
||||
'/usr/local/bin/claude',
|
||||
expect.arrayContaining(['--model', 'gpt-5.2-codex']),
|
||||
expect.anything()
|
||||
);
|
||||
|
||||
const statusEvents = mockWindow.webContents.send.mock.calls
|
||||
.filter((call: unknown[]) => call[0] === 'cliInstaller:progress')
|
||||
.map((call: unknown[]) => call[1] as { type?: string; status?: { providers?: unknown[] } })
|
||||
|
|
@ -323,12 +332,29 @@ describe('CliInstallerService', () => {
|
|||
'modelAvailability' in provider &&
|
||||
(provider as { providerId?: string }).providerId === 'codex' &&
|
||||
Array.isArray((provider as { modelAvailability?: unknown[] }).modelAvailability) &&
|
||||
(provider as { modelAvailability: Array<{ status?: string }> }).modelAvailability.some(
|
||||
(item) => item.status === 'unavailable'
|
||||
)
|
||||
(provider as { modelAvailability: Array<{ modelId?: string; status?: string }> })
|
||||
.modelAvailability.some(
|
||||
(item) => item.modelId === 'gpt-5.4' && item.status === 'available'
|
||||
)
|
||||
)
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
statusEvents.some((event) =>
|
||||
event.status?.providers?.some(
|
||||
(provider) =>
|
||||
typeof provider === 'object' &&
|
||||
provider !== null &&
|
||||
'providerId' in provider &&
|
||||
'modelAvailability' in provider &&
|
||||
(provider as { providerId?: string }).providerId === 'codex' &&
|
||||
Array.isArray((provider as { modelAvailability?: unknown[] }).modelAvailability) &&
|
||||
(provider as { modelAvailability: Array<{ modelId?: string }> }).modelAvailability.some(
|
||||
(item) => item.modelId === 'gpt-5.2-codex'
|
||||
)
|
||||
)
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ describe('CliProviderModelAvailabilityService', () => {
|
|||
expect(execCliMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('marks unsupported models as unavailable with the runtime reason', async () => {
|
||||
it('marks visible unsupported models as unavailable with the runtime reason', async () => {
|
||||
buildProviderAwareCliEnvMock.mockResolvedValue({
|
||||
env: { HOME: '/Users/tester' },
|
||||
connectionIssues: {},
|
||||
|
|
|
|||
|
|
@ -826,7 +826,6 @@ describe('BoardTaskLogStreamService integration', () => {
|
|||
expect(bashCommands).not.toContain('echo alien');
|
||||
expect(rawMessages.some((message) => message.uuid === 'u-bash-alice-real')).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to createdAt/updatedAt time window when workIntervals are missing', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-created-window-'));
|
||||
tempDirs.push(dir);
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@ import { TeamDataService } from '../../../../src/main/services/team/TeamDataServ
|
|||
import type {
|
||||
InboxMessage,
|
||||
KanbanState,
|
||||
ResolvedTeamMember,
|
||||
TeamConfig,
|
||||
TeamData,
|
||||
TeamProcess,
|
||||
TeamTask,
|
||||
TeamTaskWithKanban,
|
||||
} from '../../../../src/shared/types/team';
|
||||
|
|
@ -338,10 +339,9 @@ function createGetTeamDataHarness(options: {
|
|||
config: TeamConfig,
|
||||
metaMembers: TeamConfig['members'],
|
||||
inboxNames: string[],
|
||||
tasks: TeamTaskWithKanban[],
|
||||
messages: InboxMessage[]
|
||||
) => TeamData['members'];
|
||||
listProcesses?: () => TeamData['processes'];
|
||||
tasks: TeamTaskWithKanban[]
|
||||
) => ResolvedTeamMember[];
|
||||
listProcesses?: () => TeamProcess[];
|
||||
getMemberAdvisories?: () => Promise<Map<string, unknown>>;
|
||||
} = {}) {
|
||||
const getConfig = vi.fn(async () =>
|
||||
|
|
@ -449,7 +449,7 @@ function createGetTeamDataHarness(options: {
|
|||
};
|
||||
}
|
||||
|
||||
function buildResolvedMember(name: string): TeamData['members'][number] {
|
||||
function buildResolvedMember(name: string): ResolvedTeamMember {
|
||||
return {
|
||||
name,
|
||||
status: 'unknown',
|
||||
|
|
@ -726,6 +726,39 @@ describe('TeamDataService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('returns lightweight notification context from config without hydrating team data', async () => {
|
||||
const getConfig = vi.fn(async () => ({
|
||||
name: 'My Team',
|
||||
projectPath: '/Users/dev/my-project',
|
||||
members: [],
|
||||
}));
|
||||
|
||||
const service = new TeamDataService(
|
||||
{
|
||||
listTeams: vi.fn(),
|
||||
getConfig,
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
(() => ({ processes: { listProcesses: vi.fn(() => []) } })) as never
|
||||
);
|
||||
|
||||
const result = await service.getTeamNotificationContext('my-team');
|
||||
|
||||
expect(result).toEqual({
|
||||
displayName: 'My Team',
|
||||
projectPath: '/Users/dev/my-project',
|
||||
});
|
||||
expect(getConfig).toHaveBeenCalledWith('my-team');
|
||||
});
|
||||
|
||||
it('creates task with status pending when startImmediately is false', async () => {
|
||||
const createTaskMock = vi.fn((task) => ({ ...task, status: 'pending' }));
|
||||
const service = new TeamDataService(
|
||||
|
|
@ -2535,8 +2568,8 @@ describe('TeamDataService', () => {
|
|||
} as never
|
||||
);
|
||||
|
||||
const data = await service.getTeamData('my-team');
|
||||
const costResult = data.messages.find((message) => message.messageId === 'lead-thought-1');
|
||||
const feed = await service.getMessageFeed('my-team');
|
||||
const costResult = feed.messages.find((message) => message.messageId === 'lead-thought-1');
|
||||
|
||||
expect(costResult).toMatchObject({
|
||||
messageKind: 'slash_command_result',
|
||||
|
|
@ -2605,8 +2638,8 @@ describe('TeamDataService', () => {
|
|||
} as never
|
||||
);
|
||||
|
||||
const data = await service.getTeamData('my-team');
|
||||
const result = data.messages.find((message) => message.messageId === 'passive-idle-dup-1');
|
||||
const feed = await service.getMessageFeed('my-team');
|
||||
const result = feed.messages.find((message) => message.messageId === 'passive-idle-dup-1');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.source).not.toBe('lead_process');
|
||||
|
|
@ -2680,8 +2713,8 @@ describe('TeamDataService', () => {
|
|||
sentMessages: [userReplyRow],
|
||||
});
|
||||
|
||||
const data = await service.getTeamData('my-team');
|
||||
const linked = data.messages.find((message) => message.messageId === 'passive-user-summary-1');
|
||||
const feed = await service.getMessageFeed('my-team');
|
||||
const linked = feed.messages.find((message) => message.messageId === 'passive-user-summary-1');
|
||||
|
||||
expect(linked?.relayOfMessageId).toBe('user-reply-1');
|
||||
expect(passiveSummaryRow.relayOfMessageId).toBeUndefined();
|
||||
|
|
@ -2716,8 +2749,8 @@ describe('TeamDataService', () => {
|
|||
],
|
||||
});
|
||||
|
||||
const data = await service.getTeamData('my-team');
|
||||
const linked = data.messages.find(
|
||||
const feed = await service.getMessageFeed('my-team');
|
||||
const linked = feed.messages.find(
|
||||
(message) => message.messageId === 'passive-user-summary-contains-1'
|
||||
);
|
||||
|
||||
|
|
@ -2753,8 +2786,8 @@ describe('TeamDataService', () => {
|
|||
],
|
||||
});
|
||||
|
||||
const data = await service.getTeamData('my-team');
|
||||
const linked = data.messages.find((message) => message.messageId === 'passive-user-summary-old-1');
|
||||
const feed = await service.getMessageFeed('my-team');
|
||||
const linked = feed.messages.find((message) => message.messageId === 'passive-user-summary-old-1');
|
||||
|
||||
expect(linked?.relayOfMessageId).toBeUndefined();
|
||||
});
|
||||
|
|
@ -2788,8 +2821,8 @@ describe('TeamDataService', () => {
|
|||
],
|
||||
});
|
||||
|
||||
const data = await service.getTeamData('my-team');
|
||||
const linked = data.messages.find((message) => message.messageId === 'passive-bob-summary-1');
|
||||
const feed = await service.getMessageFeed('my-team');
|
||||
const linked = feed.messages.find((message) => message.messageId === 'passive-bob-summary-1');
|
||||
|
||||
expect(linked?.relayOfMessageId).toBeUndefined();
|
||||
});
|
||||
|
|
@ -2823,8 +2856,8 @@ describe('TeamDataService', () => {
|
|||
],
|
||||
});
|
||||
|
||||
const data = await service.getTeamData('my-team');
|
||||
const linked = data.messages.find(
|
||||
const feed = await service.getMessageFeed('my-team');
|
||||
const linked = feed.messages.find(
|
||||
(message) => message.messageId === 'passive-user-summary-sender-1'
|
||||
);
|
||||
|
||||
|
|
@ -2870,8 +2903,8 @@ describe('TeamDataService', () => {
|
|||
],
|
||||
});
|
||||
|
||||
const data = await service.getTeamData('my-team');
|
||||
const linked = data.messages.find(
|
||||
const feed = await service.getMessageFeed('my-team');
|
||||
const linked = feed.messages.find(
|
||||
(message) => message.messageId === 'passive-user-summary-ambiguous-1'
|
||||
);
|
||||
|
||||
|
|
@ -3362,11 +3395,11 @@ describe('TeamDataService', () => {
|
|||
const fixture = await createResolverBackedLeadFixture();
|
||||
const service = createResolverBackedService();
|
||||
|
||||
const data = await service.getTeamData(fixture.teamName);
|
||||
const feed = await service.getMessageFeed(fixture.teamName);
|
||||
const persistedConfig = JSON.parse(await fs.readFile(fixture.configPath, 'utf8')) as TeamConfig;
|
||||
|
||||
expect(
|
||||
data.messages.find(
|
||||
feed.messages.find(
|
||||
(message) =>
|
||||
message.source === 'lead_session' &&
|
||||
message.text.includes('recovered through the transcript resolver')
|
||||
|
|
@ -3478,8 +3511,6 @@ describe('TeamDataService', () => {
|
|||
it('starts light reads immediately, bounds heavy reads, and keeps processes outside the parallel phase', async () => {
|
||||
const order: string[] = [];
|
||||
const tasksDeferred = createDeferred<TeamTask[]>();
|
||||
const messagesDeferred = createDeferred<InboxMessage[]>();
|
||||
const leadTextsDeferred = createDeferred<InboxMessage[]>();
|
||||
|
||||
const harness = createGetTeamDataHarness({
|
||||
getTasks: async () => {
|
||||
|
|
@ -3490,10 +3521,6 @@ describe('TeamDataService', () => {
|
|||
order.push('inboxNames:start');
|
||||
return [];
|
||||
},
|
||||
getMessages: async () => {
|
||||
order.push('messages:start');
|
||||
return messagesDeferred.promise;
|
||||
},
|
||||
getMembers: async () => {
|
||||
order.push('meta:start');
|
||||
return [];
|
||||
|
|
@ -3502,10 +3529,6 @@ describe('TeamDataService', () => {
|
|||
order.push('kanban:start');
|
||||
return { teamName: 'my-team', reviewers: [], tasks: {} };
|
||||
},
|
||||
readMessages: async () => {
|
||||
order.push('sent:start');
|
||||
return [];
|
||||
},
|
||||
resolveMembers: () => {
|
||||
order.push('resolveMembers');
|
||||
return [];
|
||||
|
|
@ -3527,39 +3550,21 @@ describe('TeamDataService', () => {
|
|||
},
|
||||
});
|
||||
|
||||
vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation(
|
||||
async () => {
|
||||
order.push('leadTexts:start');
|
||||
return leadTextsDeferred.promise;
|
||||
}
|
||||
);
|
||||
|
||||
const pending = harness.service.getTeamData('my-team');
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(order).toEqual(
|
||||
expect.arrayContaining([
|
||||
'inboxNames:start',
|
||||
'sent:start',
|
||||
'meta:start',
|
||||
'kanban:start',
|
||||
'tasks:start',
|
||||
'messages:start',
|
||||
])
|
||||
);
|
||||
expect(order).not.toContain('leadTexts:start');
|
||||
expect(order).not.toContain('processes:start');
|
||||
expect(order).not.toContain('leadTexts:start');
|
||||
|
||||
tasksDeferred.resolve([]);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(order).toContain('leadTexts:start');
|
||||
expect(order.indexOf('tasks:start')).toBeLessThan(order.indexOf('messages:start'));
|
||||
expect(order.indexOf('messages:start')).toBeLessThan(order.indexOf('leadTexts:start'));
|
||||
expect(order).not.toContain('processes:start');
|
||||
|
||||
messagesDeferred.resolve([]);
|
||||
leadTextsDeferred.resolve([]);
|
||||
|
||||
const data = await pending;
|
||||
|
||||
|
|
@ -3569,7 +3574,7 @@ describe('TeamDataService', () => {
|
|||
pid: 101,
|
||||
}),
|
||||
]);
|
||||
expect(order.indexOf('leadTexts:start')).toBeLessThan(order.indexOf('processes:start'));
|
||||
expect(order).not.toContain('leadTexts:start');
|
||||
expect(order.indexOf('resolveMembers')).toBeLessThan(order.indexOf('processes:start'));
|
||||
});
|
||||
|
||||
|
|
@ -3614,47 +3619,64 @@ describe('TeamDataService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('surfaces isAlive in the structural snapshot from live process state', async () => {
|
||||
const aliveHarness = createGetTeamDataHarness({
|
||||
listProcesses: () =>
|
||||
[
|
||||
{
|
||||
id: 'proc-1',
|
||||
label: 'Lead',
|
||||
pid: 101,
|
||||
registeredAt: '2026-04-09T10:00:00.000Z',
|
||||
},
|
||||
] satisfies TeamProcess[],
|
||||
});
|
||||
const offlineHarness = createGetTeamDataHarness({
|
||||
listProcesses: () =>
|
||||
[
|
||||
{
|
||||
id: 'proc-1',
|
||||
label: 'Lead',
|
||||
pid: 101,
|
||||
registeredAt: '2026-04-09T10:00:00.000Z',
|
||||
stoppedAt: '2026-04-09T10:05:00.000Z',
|
||||
},
|
||||
] satisfies TeamProcess[],
|
||||
});
|
||||
|
||||
const aliveData = await aliveHarness.service.getTeamData('my-team');
|
||||
const offlineData = await offlineHarness.service.getTeamData('my-team');
|
||||
|
||||
expect(aliveData.isAlive).toBe(true);
|
||||
expect(offlineData.isAlive).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps warning order deterministic even when read failures settle out of order', async () => {
|
||||
const tasksDeferred = createDeferred<TeamTask[]>();
|
||||
const inboxDeferred = createDeferred<string[]>();
|
||||
const messagesDeferred = createDeferred<InboxMessage[]>();
|
||||
const leadTextsDeferred = createDeferred<InboxMessage[]>();
|
||||
const sentDeferred = createDeferred<InboxMessage[]>();
|
||||
const metaDeferred = createDeferred<TeamConfig['members']>();
|
||||
const kanbanDeferred = createDeferred<KanbanState>();
|
||||
|
||||
const harness = createGetTeamDataHarness({
|
||||
getTasks: async () => tasksDeferred.promise,
|
||||
listInboxNames: async () => inboxDeferred.promise,
|
||||
getMessages: async () => messagesDeferred.promise,
|
||||
getMembers: async () => metaDeferred.promise,
|
||||
getState: async () => kanbanDeferred.promise,
|
||||
readMessages: async () => sentDeferred.promise,
|
||||
});
|
||||
|
||||
vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation(
|
||||
async () => leadTextsDeferred.promise
|
||||
);
|
||||
|
||||
const pending = harness.service.getTeamData('my-team');
|
||||
await flushMicrotasks();
|
||||
|
||||
sentDeferred.reject(new Error('sent failed'));
|
||||
kanbanDeferred.reject(new Error('kanban failed'));
|
||||
tasksDeferred.reject(new Error('tasks failed'));
|
||||
metaDeferred.reject(new Error('meta failed'));
|
||||
inboxDeferred.reject(new Error('inbox failed'));
|
||||
leadTextsDeferred.reject(new Error('lead failed'));
|
||||
messagesDeferred.reject(new Error('messages failed'));
|
||||
|
||||
const data = await pending;
|
||||
|
||||
expect(data.warnings).toEqual([
|
||||
'Tasks failed to load',
|
||||
'Inboxes failed to load',
|
||||
'Messages failed to load',
|
||||
'Lead session texts failed to load',
|
||||
'Sent messages failed to load',
|
||||
'Member metadata failed to load',
|
||||
'Kanban state failed to load',
|
||||
]);
|
||||
|
|
@ -3698,9 +3720,9 @@ describe('TeamDataService', () => {
|
|||
},
|
||||
]);
|
||||
|
||||
const data = await harness.service.getTeamData('my-team');
|
||||
const feed = await harness.service.getMessageFeed('my-team');
|
||||
|
||||
expect(data.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1', 'inbox-1']);
|
||||
expect(feed.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1', 'inbox-1']);
|
||||
});
|
||||
|
||||
it('preserves assembled messages and resolver inputs when inbox messages fail', async () => {
|
||||
|
|
@ -3749,11 +3771,10 @@ describe('TeamDataService', () => {
|
|||
]);
|
||||
|
||||
const data = await harness.service.getTeamData('my-team');
|
||||
const feed = await harness.service.getMessageFeed('my-team');
|
||||
|
||||
expect(data.warnings).toEqual(
|
||||
expect.arrayContaining(['Messages failed to load', 'Kanban state failed to load'])
|
||||
);
|
||||
expect(data.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1']);
|
||||
expect(data.warnings).toEqual(expect.arrayContaining(['Kanban state failed to load']));
|
||||
expect(feed.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1']);
|
||||
expect(resolveMembersSpy).toHaveBeenCalledWith(
|
||||
buildDefaultTeamConfig(),
|
||||
metaMembers,
|
||||
|
|
@ -3763,10 +3784,6 @@ describe('TeamDataService', () => {
|
|||
id: 'task-1',
|
||||
subject: 'Investigate rollout',
|
||||
}),
|
||||
],
|
||||
[
|
||||
expect.objectContaining({ messageId: 'sent-1' }),
|
||||
expect.objectContaining({ messageId: 'lead-1' }),
|
||||
]
|
||||
);
|
||||
});
|
||||
|
|
@ -3805,16 +3822,11 @@ describe('TeamDataService', () => {
|
|||
it('degrades a queued heavy sync throw to warning and still completes the snapshot', async () => {
|
||||
const order: string[] = [];
|
||||
const tasksDeferred = createDeferred<TeamTask[]>();
|
||||
const messagesDeferred = createDeferred<InboxMessage[]>();
|
||||
const harness = createGetTeamDataHarness({
|
||||
getTasks: async () => {
|
||||
order.push('tasks:start');
|
||||
return tasksDeferred.promise;
|
||||
},
|
||||
getMessages: async () => {
|
||||
order.push('messages:start');
|
||||
return messagesDeferred.promise;
|
||||
},
|
||||
listProcesses: () => {
|
||||
order.push('processes:start');
|
||||
return [];
|
||||
|
|
@ -3832,14 +3844,9 @@ describe('TeamDataService', () => {
|
|||
expect(order).not.toContain('leadTexts:start');
|
||||
|
||||
tasksDeferred.resolve([]);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(order).toContain('leadTexts:start');
|
||||
|
||||
messagesDeferred.resolve([]);
|
||||
const data = await pending;
|
||||
|
||||
expect(data.warnings).toEqual(expect.arrayContaining(['Lead session texts failed to load']));
|
||||
expect(data.warnings ?? []).not.toContain('Lead session texts failed to load');
|
||||
expect(order).toContain('processes:start');
|
||||
});
|
||||
|
||||
|
|
@ -3977,7 +3984,7 @@ describe('TeamDataService', () => {
|
|||
expect(page1.hasMore).toBe(true);
|
||||
|
||||
const page2 = await service.getMessagesPage('my-team', {
|
||||
beforeTimestamp: page1.nextCursor!,
|
||||
cursor: page1.nextCursor!,
|
||||
limit: 10,
|
||||
});
|
||||
// Should get the remaining 2 messages, not lose the one with same timestamp
|
||||
|
|
@ -4011,6 +4018,41 @@ describe('TeamDataService', () => {
|
|||
expect(result?.messageKind).toBe('slash_command_result');
|
||||
});
|
||||
|
||||
it('normalizes stable effective message ids before pagination and cursoring', async () => {
|
||||
const msgs = [
|
||||
{
|
||||
from: 'alice',
|
||||
text: 'same-ts-a',
|
||||
timestamp: '2026-01-01T00:00:02.000Z',
|
||||
source: 'inbox' as const,
|
||||
},
|
||||
{
|
||||
from: 'bob',
|
||||
text: 'same-ts-b',
|
||||
timestamp: '2026-01-01T00:00:02.000Z',
|
||||
source: 'inbox' as const,
|
||||
},
|
||||
{
|
||||
from: 'carol',
|
||||
text: 'older',
|
||||
timestamp: '2026-01-01T00:00:01.000Z',
|
||||
source: 'inbox' as const,
|
||||
},
|
||||
];
|
||||
const service = createPaginationService(msgs);
|
||||
|
||||
const page1 = await service.getMessagesPage('my-team', { limit: 1 });
|
||||
const page2 = await service.getMessagesPage('my-team', {
|
||||
cursor: page1.nextCursor!,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(page1.messages[0]?.messageId).toMatch(/^inbox-/);
|
||||
expect(page1.nextCursor).toContain(page1.messages[0]!.messageId!);
|
||||
expect(page2.messages.every((message) => Boolean(message.messageId))).toBe(true);
|
||||
expect(new Set([...page1.messages, ...page2.messages].map((message) => message.messageId)).size).toBe(3);
|
||||
});
|
||||
|
||||
it('dedups newest-page live overlay against durable lead thoughts that already paged off the first page', async () => {
|
||||
const fillerMessages = Array.from({ length: 55 }, (_, index) => ({
|
||||
from: 'alice',
|
||||
|
|
@ -4089,7 +4131,7 @@ describe('TeamDataService', () => {
|
|||
|
||||
const page2 = await service.getMessagesPage('my-team', {
|
||||
limit: 10,
|
||||
beforeTimestamp: page1.nextCursor!,
|
||||
cursor: page1.nextCursor!,
|
||||
});
|
||||
|
||||
expect(page2.messages.map((message) => message.messageId)).toEqual(['durable-2', 'durable-1']);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest';
|
|||
import { TeamMemberResolver } from '../../../../src/main/services/team/TeamMemberResolver';
|
||||
|
||||
import type {
|
||||
InboxMessage,
|
||||
TeamConfig,
|
||||
TeamTask,
|
||||
TeamTaskWithKanban,
|
||||
|
|
@ -24,13 +23,8 @@ describe('TeamMemberResolver', () => {
|
|||
{ id: '1', subject: 'Visible task', status: 'pending', owner: 'alice' },
|
||||
{ id: '2', subject: 'Ghost task', status: 'pending', owner: 'stranger' },
|
||||
];
|
||||
const now = new Date().toISOString();
|
||||
const messages: InboxMessage[] = [
|
||||
{ from: 'bob', text: 'ready', timestamp: now, read: false, color: 'green' },
|
||||
{ from: 'user', text: 'system note', timestamp: now, read: false },
|
||||
];
|
||||
|
||||
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks, messages);
|
||||
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks);
|
||||
const names = members.map((member) => member.name);
|
||||
|
||||
expect(names).toHaveLength(3);
|
||||
|
|
@ -62,9 +56,8 @@ describe('TeamMemberResolver', () => {
|
|||
];
|
||||
const inboxNames = ['user', 'alice'];
|
||||
const tasks: TeamTask[] = [];
|
||||
const messages: InboxMessage[] = [];
|
||||
|
||||
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks, messages);
|
||||
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks);
|
||||
const names = members.map((m) => m.name);
|
||||
|
||||
expect(names).not.toContain('user');
|
||||
|
|
@ -81,9 +74,8 @@ describe('TeamMemberResolver', () => {
|
|||
const metaMembers: TeamConfig['members'] = [{ name: 'alice', agentType: 'general-purpose' }];
|
||||
const inboxNames = ['alice', 'team-best.user', 'dream-team.team-lead'];
|
||||
const tasks: TeamTask[] = [];
|
||||
const messages: InboxMessage[] = [];
|
||||
|
||||
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks, messages);
|
||||
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks);
|
||||
const names = members.map((m) => m.name);
|
||||
|
||||
expect(names).toContain('alice');
|
||||
|
|
@ -104,7 +96,7 @@ describe('TeamMemberResolver', () => {
|
|||
];
|
||||
const inboxNames = ['a3975f80d37fbcea1', 'alice', 'a68a8f6a643e59bfd'];
|
||||
|
||||
const members = resolver.resolveMembers(config, metaMembers, inboxNames, [], []);
|
||||
const members = resolver.resolveMembers(config, metaMembers, inboxNames, []);
|
||||
const names = members.map((m) => m.name);
|
||||
|
||||
expect(names).toContain('alice');
|
||||
|
|
@ -124,7 +116,7 @@ describe('TeamMemberResolver', () => {
|
|||
],
|
||||
};
|
||||
|
||||
const members = resolver.resolveMembers(config, [], ['ops.bot'], [], []);
|
||||
const members = resolver.resolveMembers(config, [], ['ops.bot'], []);
|
||||
const names = members.map((m) => m.name);
|
||||
|
||||
expect(names).toContain('ops.bot');
|
||||
|
|
@ -141,7 +133,6 @@ describe('TeamMemberResolver', () => {
|
|||
config,
|
||||
[],
|
||||
['cross-team:team-alpha-super', 'cross-team-team-alpha-super', 'alice'],
|
||||
[],
|
||||
[]
|
||||
);
|
||||
const names = members.map((m) => m.name);
|
||||
|
|
@ -163,7 +154,6 @@ describe('TeamMemberResolver', () => {
|
|||
config,
|
||||
[],
|
||||
['cross_team_send', 'cross_team_list_targets', 'alice'],
|
||||
[],
|
||||
[]
|
||||
);
|
||||
const names = members.map((m) => m.name);
|
||||
|
|
@ -185,7 +175,6 @@ describe('TeamMemberResolver', () => {
|
|||
config,
|
||||
[],
|
||||
['cross_team::team-alpha-super', 'cross_team--team-alpha-super', 'alice'],
|
||||
[],
|
||||
[]
|
||||
);
|
||||
const names = members.map((m) => m.name);
|
||||
|
|
@ -206,7 +195,7 @@ describe('TeamMemberResolver', () => {
|
|||
],
|
||||
};
|
||||
|
||||
const members = resolver.resolveMembers(config, [], ['ops.bot'], [], []);
|
||||
const members = resolver.resolveMembers(config, [], ['ops.bot'], []);
|
||||
const names = members.map((m) => m.name);
|
||||
|
||||
expect(names).toContain('Ops.Bot');
|
||||
|
|
@ -222,7 +211,7 @@ describe('TeamMemberResolver', () => {
|
|||
const tasks: TeamTaskWithKanban[] = [
|
||||
{ id: 't1', subject: 'Work', status: 'in_progress', owner: 'bob' },
|
||||
];
|
||||
const members = resolver.resolveMembers(config, [], [], tasks, []);
|
||||
const members = resolver.resolveMembers(config, [], [], tasks);
|
||||
const bob = members.find((m) => m.name === 'bob');
|
||||
expect(bob?.currentTaskId).toBe('t1');
|
||||
});
|
||||
|
|
@ -243,7 +232,7 @@ describe('TeamMemberResolver', () => {
|
|||
kanbanColumn: 'approved',
|
||||
},
|
||||
];
|
||||
const members = resolver.resolveMembers(config, [], [], tasks, []);
|
||||
const members = resolver.resolveMembers(config, [], [], tasks);
|
||||
const bob = members.find((m) => m.name === 'bob');
|
||||
expect(bob?.currentTaskId).toBeNull();
|
||||
});
|
||||
|
|
@ -264,7 +253,7 @@ describe('TeamMemberResolver', () => {
|
|||
// kanbanColumn not set — stale data scenario
|
||||
},
|
||||
];
|
||||
const members = resolver.resolveMembers(config, [], [], tasks, []);
|
||||
const members = resolver.resolveMembers(config, [], [], tasks);
|
||||
const bob = members.find((m) => m.name === 'bob');
|
||||
expect(bob?.currentTaskId).toBeNull();
|
||||
});
|
||||
|
|
@ -281,7 +270,7 @@ describe('TeamMemberResolver', () => {
|
|||
// Teammates sometimes send messages to "lead" instead of "team-lead",
|
||||
// creating a separate inbox file that the resolver picks up.
|
||||
const inboxNames = ['team-lead', 'lead', 'alice'];
|
||||
const members = resolver.resolveMembers(config, [], inboxNames, [], []);
|
||||
const members = resolver.resolveMembers(config, [], inboxNames, []);
|
||||
const names = members.map((m) => m.name);
|
||||
|
||||
expect(names).toContain('team-lead');
|
||||
|
|
@ -295,7 +284,7 @@ describe('TeamMemberResolver', () => {
|
|||
name: 'Team',
|
||||
members: [{ name: 'lead', agentType: 'team-lead', role: 'lead' }],
|
||||
};
|
||||
const members = resolver.resolveMembers(config, [], ['lead'], [], []);
|
||||
const members = resolver.resolveMembers(config, [], ['lead'], []);
|
||||
const names = members.map((m) => m.name);
|
||||
|
||||
expect(names).toContain('lead');
|
||||
|
|
@ -310,7 +299,7 @@ describe('TeamMemberResolver', () => {
|
|||
const tasks: TeamTaskWithKanban[] = [
|
||||
{ id: 't1', subject: 'Work', status: 'completed', owner: 'bob' },
|
||||
];
|
||||
const members = resolver.resolveMembers(config, [], [], tasks, []);
|
||||
const members = resolver.resolveMembers(config, [], [], tasks);
|
||||
const bob = members.find((m) => m.name === 'bob');
|
||||
expect(bob?.currentTaskId).toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -175,6 +175,65 @@ function writeLaunchState(
|
|||
);
|
||||
}
|
||||
|
||||
function createMemberSpawnStatusEntry(
|
||||
overrides: Record<string, unknown> = {}
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
error: undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
runtimeAlive: false,
|
||||
livenessSource: undefined,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
agentToolAccepted: true,
|
||||
firstSpawnAcceptedAt: new Date().toISOString(),
|
||||
lastHeartbeatAt: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMemberSpawnRun(params?: {
|
||||
runId?: string;
|
||||
teamName?: string;
|
||||
startedAt?: string;
|
||||
expectedMembers?: string[];
|
||||
memberSpawnStatuses?: Map<string, Record<string, unknown>>;
|
||||
memberSpawnLeadInboxCursorByMember?: Map<string, { timestamp: string; messageId: string }>;
|
||||
}) {
|
||||
const teamName = params?.teamName ?? 'member-spawn-team';
|
||||
const expectedMembers = params?.expectedMembers ?? ['alice'];
|
||||
const memberSpawnStatuses =
|
||||
params?.memberSpawnStatuses ??
|
||||
new Map([
|
||||
[
|
||||
expectedMembers[0]!,
|
||||
createMemberSpawnStatusEntry({
|
||||
firstSpawnAcceptedAt: new Date(Date.now() - 5_000).toISOString(),
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
return {
|
||||
runId: params?.runId ?? 'run-member-spawn-1',
|
||||
teamName,
|
||||
startedAt: params?.startedAt ?? new Date(Date.now() - 60_000).toISOString(),
|
||||
request: {
|
||||
members: [],
|
||||
},
|
||||
expectedMembers,
|
||||
memberSpawnStatuses,
|
||||
memberSpawnToolUseIds: new Map(),
|
||||
memberSpawnLeadInboxCursorByMember:
|
||||
params?.memberSpawnLeadInboxCursorByMember ?? new Map(),
|
||||
provisioningOutputParts: [],
|
||||
activeToolCalls: new Map(),
|
||||
isLaunch: false,
|
||||
provisioningComplete: false,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('TeamProvisioningService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -1181,4 +1240,311 @@ describe('TeamProvisioningService', () => {
|
|||
expect(result.statuses.jack?.hardFailureReason).toContain('requested model is not available');
|
||||
expect(result.teamLaunchState).toBe('partial_failure');
|
||||
});
|
||||
|
||||
it('does not reprocess already-seen teammate lead inbox messages', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
startedAt: '2026-04-16T09:00:00.000Z',
|
||||
memberSpawnLeadInboxCursorByMember: new Map([
|
||||
[
|
||||
'alice',
|
||||
{
|
||||
timestamp: '2026-04-16T10:00:00.000Z',
|
||||
messageId: 'msg-2',
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([
|
||||
{
|
||||
from: 'alice',
|
||||
text: 'heartbeat',
|
||||
timestamp: '2026-04-16T10:00:00.000Z',
|
||||
messageId: 'msg-1',
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
from: 'alice',
|
||||
text: 'heartbeat',
|
||||
timestamp: '2026-04-16T10:00:00.000Z',
|
||||
messageId: 'msg-2',
|
||||
read: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const applySignalSpy = vi.spyOn(svc as any, 'applyLeadInboxSpawnSignal');
|
||||
|
||||
await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run);
|
||||
|
||||
expect(applySignalSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('processes an unseen teammate heartbeat on the first refresh', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
startedAt: '2026-04-16T09:00:00.000Z',
|
||||
});
|
||||
|
||||
vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([
|
||||
{
|
||||
from: 'alice',
|
||||
text: '{"type":"heartbeat","timestamp":"2026-04-16T10:00:00.000Z"}',
|
||||
timestamp: '2026-04-16T10:00:00.000Z',
|
||||
messageId: 'msg-1',
|
||||
read: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run);
|
||||
|
||||
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
lastHeartbeatAt: '2026-04-16T10:00:00.000Z',
|
||||
});
|
||||
expect(run.memberSpawnLeadInboxCursorByMember.get('alice')).toEqual({
|
||||
timestamp: '2026-04-16T10:00:00.000Z',
|
||||
messageId: 'msg-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores teammate lead inbox signals that predate the current run', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
startedAt: '2026-04-16T10:00:00.000Z',
|
||||
});
|
||||
|
||||
vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([
|
||||
{
|
||||
from: 'alice',
|
||||
text: '{"type":"heartbeat","timestamp":"2026-04-16T09:59:59.000Z"}',
|
||||
timestamp: '2026-04-16T09:59:59.000Z',
|
||||
messageId: 'msg-early',
|
||||
read: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const applySignalSpy = vi.spyOn(svc as any, 'applyLeadInboxSpawnSignal');
|
||||
|
||||
await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run);
|
||||
|
||||
expect(applySignalSpy).not.toHaveBeenCalled();
|
||||
expect(run.memberSpawnLeadInboxCursorByMember.size).toBe(0);
|
||||
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
bootstrapConfirmed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores an unseen older lead inbox signal without replaying older state', async () => {
|
||||
const latestHeartbeatAt = '2026-04-16T10:05:00.000Z';
|
||||
const existingEntry = createMemberSpawnStatusEntry({
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
runtimeAlive: true,
|
||||
livenessSource: 'heartbeat',
|
||||
bootstrapConfirmed: true,
|
||||
lastHeartbeatAt: latestHeartbeatAt,
|
||||
});
|
||||
const run = createMemberSpawnRun({
|
||||
startedAt: '2026-04-16T09:00:00.000Z',
|
||||
memberSpawnStatuses: new Map([['alice', existingEntry]]),
|
||||
memberSpawnLeadInboxCursorByMember: new Map([
|
||||
[
|
||||
'alice',
|
||||
{
|
||||
timestamp: latestHeartbeatAt,
|
||||
messageId: 'msg-3',
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([
|
||||
{
|
||||
from: 'alice',
|
||||
text: 'Bootstrap failed: unsupported model',
|
||||
timestamp: '2026-04-16T10:04:00.000Z',
|
||||
messageId: 'msg-2b',
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
from: 'alice',
|
||||
text: 'heartbeat',
|
||||
timestamp: latestHeartbeatAt,
|
||||
messageId: 'msg-3',
|
||||
read: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const applySignalSpy = vi.spyOn(svc as any, 'applyLeadInboxSpawnSignal');
|
||||
|
||||
await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run);
|
||||
|
||||
expect(applySignalSpy).not.toHaveBeenCalled();
|
||||
expect(run.memberSpawnStatuses.get('alice')).toBe(existingEntry);
|
||||
expect(run.memberSpawnLeadInboxCursorByMember.get('alice')).toEqual({
|
||||
timestamp: latestHeartbeatAt,
|
||||
messageId: 'msg-3',
|
||||
});
|
||||
});
|
||||
|
||||
it('applies an unseen newer failure signal and transitions the member to failed_to_start', async () => {
|
||||
const latestHeartbeatAt = '2026-04-16T10:00:00.000Z';
|
||||
const run = createMemberSpawnRun({
|
||||
startedAt: '2026-04-16T09:00:00.000Z',
|
||||
memberSpawnStatuses: new Map([
|
||||
[
|
||||
'alice',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
runtimeAlive: true,
|
||||
livenessSource: 'heartbeat',
|
||||
bootstrapConfirmed: true,
|
||||
lastHeartbeatAt: latestHeartbeatAt,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
memberSpawnLeadInboxCursorByMember: new Map([
|
||||
[
|
||||
'alice',
|
||||
{
|
||||
timestamp: latestHeartbeatAt,
|
||||
messageId: 'msg-1',
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([
|
||||
{
|
||||
from: 'alice',
|
||||
text: 'Bootstrap failed: unsupported model',
|
||||
timestamp: '2026-04-16T10:01:00.000Z',
|
||||
messageId: 'msg-2',
|
||||
read: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run);
|
||||
|
||||
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'Bootstrap failed: unsupported model',
|
||||
});
|
||||
expect(run.memberSpawnLeadInboxCursorByMember.get('alice')).toEqual({
|
||||
timestamp: '2026-04-16T10:01:00.000Z',
|
||||
messageId: 'msg-2',
|
||||
});
|
||||
});
|
||||
|
||||
it('applies an unseen same-timestamp signal with a greater messageId and advances the cursor', async () => {
|
||||
const run = createMemberSpawnRun({
|
||||
startedAt: '2026-04-16T09:00:00.000Z',
|
||||
memberSpawnLeadInboxCursorByMember: new Map([
|
||||
[
|
||||
'alice',
|
||||
{
|
||||
timestamp: '2026-04-16T10:00:00.000Z',
|
||||
messageId: 'msg-2',
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([
|
||||
{
|
||||
from: 'alice',
|
||||
text: 'heartbeat',
|
||||
timestamp: '2026-04-16T10:00:00.000Z',
|
||||
messageId: 'msg-2',
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
from: 'alice',
|
||||
text: 'heartbeat',
|
||||
timestamp: '2026-04-16T10:00:00.000Z',
|
||||
messageId: 'msg-3',
|
||||
read: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const applySignalSpy = vi.spyOn(svc as any, 'applyLeadInboxSpawnSignal');
|
||||
|
||||
await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run);
|
||||
|
||||
expect(applySignalSpy).toHaveBeenCalledTimes(1);
|
||||
expect(applySignalSpy).toHaveBeenCalledWith(
|
||||
run,
|
||||
'alice',
|
||||
expect.objectContaining({ messageId: 'msg-3' })
|
||||
);
|
||||
expect(run.memberSpawnLeadInboxCursorByMember.get('alice')).toEqual({
|
||||
timestamp: '2026-04-16T10:00:00.000Z',
|
||||
messageId: 'msg-3',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not bump lastHeartbeatAt for an equal heartbeat timestamp', () => {
|
||||
const existingEntry = createMemberSpawnStatusEntry({
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
runtimeAlive: true,
|
||||
livenessSource: 'heartbeat',
|
||||
bootstrapConfirmed: true,
|
||||
lastHeartbeatAt: '2026-04-16T10:00:00.000Z',
|
||||
});
|
||||
const run = createMemberSpawnRun({
|
||||
memberSpawnStatuses: new Map([['alice', existingEntry]]),
|
||||
});
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
(svc as any).setMemberSpawnStatus(
|
||||
run,
|
||||
'alice',
|
||||
'online',
|
||||
undefined,
|
||||
'heartbeat',
|
||||
'2026-04-16T10:00:00.000Z'
|
||||
);
|
||||
|
||||
expect(run.memberSpawnStatuses.get('alice')).toBe(existingEntry);
|
||||
});
|
||||
|
||||
it('does not bump lastHeartbeatAt for an older heartbeat timestamp', () => {
|
||||
const existingEntry = createMemberSpawnStatusEntry({
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
runtimeAlive: true,
|
||||
livenessSource: 'heartbeat',
|
||||
bootstrapConfirmed: true,
|
||||
lastHeartbeatAt: '2026-04-16T10:00:00.000Z',
|
||||
});
|
||||
const run = createMemberSpawnRun({
|
||||
memberSpawnStatuses: new Map([['alice', existingEntry]]),
|
||||
});
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
(svc as any).setMemberSpawnStatus(
|
||||
run,
|
||||
'alice',
|
||||
'online',
|
||||
undefined,
|
||||
'heartbeat',
|
||||
'2026-04-16T09:59:59.000Z'
|
||||
);
|
||||
|
||||
expect(run.memberSpawnStatuses.get('alice')).toBe(existingEntry);
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue