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.
|
* All state in refs — no React re-renders.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useRef, useCallback } from 'react';
|
import { useRef, useCallback, useMemo } from 'react';
|
||||||
import type { GraphNode } from '../ports/types';
|
import type { GraphNode } from '../ports/types';
|
||||||
import { CAMERA, ANIM, NODE, TASK_PILL } from '../constants/canvas-constants';
|
import { CAMERA, ANIM, NODE, TASK_PILL } from '../constants/canvas-constants';
|
||||||
import type { WorldBounds } from '../layout/launchAnchor';
|
import type { WorldBounds } from '../layout/launchAnchor';
|
||||||
|
|
@ -170,17 +170,31 @@ export function useGraphCamera(): UseGraphCameraResult {
|
||||||
t.zoom = Math.max(CAMERA.minZoom, t.zoom / 1.2);
|
t.zoom = Math.max(CAMERA.minZoom, t.zoom / 1.2);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return useMemo(
|
||||||
transformRef,
|
() => ({
|
||||||
screenToWorld,
|
transformRef,
|
||||||
worldToScreen,
|
screenToWorld,
|
||||||
handleWheel,
|
worldToScreen,
|
||||||
handlePanStart,
|
handleWheel,
|
||||||
handlePanMove,
|
handlePanStart,
|
||||||
handlePanEnd,
|
handlePanMove,
|
||||||
zoomToFit,
|
handlePanEnd,
|
||||||
zoomIn,
|
zoomToFit,
|
||||||
zoomOut,
|
zoomIn,
|
||||||
updateInertia,
|
zoomOut,
|
||||||
};
|
updateInertia,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
screenToWorld,
|
||||||
|
worldToScreen,
|
||||||
|
handleWheel,
|
||||||
|
handlePanStart,
|
||||||
|
handlePanMove,
|
||||||
|
handlePanEnd,
|
||||||
|
zoomToFit,
|
||||||
|
zoomIn,
|
||||||
|
zoomOut,
|
||||||
|
updateInertia,
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* Delegates hit testing to strategy pattern.
|
* Delegates hit testing to strategy pattern.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useRef, useCallback } from 'react';
|
import { useRef, useCallback, useMemo } from 'react';
|
||||||
import type { GraphNode } from '../ports/types';
|
import type { GraphNode } from '../ports/types';
|
||||||
import { ANIM } from '../constants/canvas-constants';
|
import { ANIM } from '../constants/canvas-constants';
|
||||||
import { findNodeAt } from '../canvas/hit-detection';
|
import { findNodeAt } from '../canvas/hit-detection';
|
||||||
|
|
@ -81,13 +81,16 @@ export function useGraphInteraction(
|
||||||
return findNodeAt(wx, wy, nodes);
|
return findNodeAt(wx, wy, nodes);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return useMemo(
|
||||||
hoveredNodeId,
|
() => ({
|
||||||
dragNodeId,
|
hoveredNodeId,
|
||||||
isDragging,
|
dragNodeId,
|
||||||
handleMouseDown,
|
isDragging,
|
||||||
handleMouseMove,
|
handleMouseDown,
|
||||||
handleMouseUp,
|
handleMouseMove,
|
||||||
handleDoubleClick,
|
handleMouseUp,
|
||||||
};
|
handleDoubleClick,
|
||||||
|
}),
|
||||||
|
[handleDoubleClick, handleMouseDown, handleMouseMove, handleMouseUp]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { ANIM_SPEED, NODE } from '../constants/canvas-constants';
|
import { ANIM_SPEED, NODE } from '../constants/canvas-constants';
|
||||||
import { getStateColor } from '../constants/colors';
|
import { getStateColor } from '../constants/colors';
|
||||||
|
|
@ -239,19 +239,29 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return useMemo(
|
||||||
stateRef,
|
() => ({
|
||||||
updateData,
|
stateRef,
|
||||||
tick,
|
updateData,
|
||||||
setNodePosition,
|
tick,
|
||||||
clearNodePosition,
|
setNodePosition,
|
||||||
clearTransientOwnerPositions,
|
clearNodePosition,
|
||||||
resolveNearestOwnerSlot,
|
clearTransientOwnerPositions,
|
||||||
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
|
resolveNearestOwnerSlot,
|
||||||
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
|
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
|
||||||
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
|
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
|
||||||
getExtraWorldBounds: () => extraWorldBoundsRef.current,
|
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
|
||||||
};
|
getExtraWorldBounds: () => extraWorldBoundsRef.current,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
updateData,
|
||||||
|
tick,
|
||||||
|
setNodePosition,
|
||||||
|
clearNodePosition,
|
||||||
|
clearTransientOwnerPositions,
|
||||||
|
resolveNearestOwnerSlot,
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySnapshotToNodes(
|
function applySnapshotToNodes(
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import {
|
||||||
findNodeAt,
|
findNodeAt,
|
||||||
getEdgeMidpoint,
|
getEdgeMidpoint,
|
||||||
} from '../canvas/hit-detection';
|
} from '../canvas/hit-detection';
|
||||||
import { ANIM_SPEED } from '../constants/canvas-constants';
|
import { ANIM, ANIM_SPEED } from '../constants/canvas-constants';
|
||||||
import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor';
|
import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor';
|
||||||
|
|
||||||
export interface GraphViewProps {
|
export interface GraphViewProps {
|
||||||
|
|
@ -148,13 +148,6 @@ export function GraphView({
|
||||||
// ─── Hooks ──────────────────────────────────────────────────────────────
|
// ─── Hooks ──────────────────────────────────────────────────────────────
|
||||||
const simulation = useGraphSimulation();
|
const simulation = useGraphSimulation();
|
||||||
const camera = useGraphCamera();
|
const camera = useGraphCamera();
|
||||||
|
|
||||||
// Stable refs for RAF loop (avoid recreating animate on hook identity change)
|
|
||||||
const simulationRef = useRef(simulation);
|
|
||||||
simulationRef.current = simulation;
|
|
||||||
const cameraRef = useRef(camera);
|
|
||||||
cameraRef.current = camera;
|
|
||||||
|
|
||||||
const interaction = useGraphInteraction(
|
const interaction = useGraphInteraction(
|
||||||
useCallback(
|
useCallback(
|
||||||
(nodeId: string, x: number, y: number) => {
|
(nodeId: string, x: number, y: number) => {
|
||||||
|
|
@ -164,6 +157,20 @@ export function GraphView({
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Stable refs for RAF loop (avoid recreating animate on hook identity change)
|
||||||
|
const simulationRef = useRef(simulation);
|
||||||
|
simulationRef.current = simulation;
|
||||||
|
const cameraRef = useRef(camera);
|
||||||
|
cameraRef.current = camera;
|
||||||
|
const interactionRef = useRef(interaction);
|
||||||
|
interactionRef.current = interaction;
|
||||||
|
const processActivePointerMoveRef = useRef<((clientX: number, clientY: number) => boolean) | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const completePointerInteractionRef = useRef<((clientX: number, clientY: number) => void) | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
const getVisibleNodes = useCallback(
|
const getVisibleNodes = useCallback(
|
||||||
(nodes: GraphNode[]): GraphNode[] =>
|
(nodes: GraphNode[]): GraphNode[] =>
|
||||||
nodes.filter((node) => {
|
nodes.filter((node) => {
|
||||||
|
|
@ -433,16 +440,16 @@ export function GraphView({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!isSurfaceActive) {
|
if (isSurfaceActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
interaction.handleMouseUp();
|
interactionRef.current.handleMouseUp();
|
||||||
simulation.clearTransientOwnerPositions();
|
simulationRef.current.clearTransientOwnerPositions();
|
||||||
dragPreviewRef.current = null;
|
dragPreviewRef.current = null;
|
||||||
isPanningRef.current = false;
|
isPanningRef.current = false;
|
||||||
edgeMouseDownRef.current = null;
|
edgeMouseDownRef.current = null;
|
||||||
setInteractionGuards(false);
|
setInteractionGuards(false);
|
||||||
}, [interaction, isSurfaceActive, simulation]);
|
}, [isSurfaceActive, setInteractionGuards]);
|
||||||
|
|
||||||
const handleWheel = useCallback(
|
const handleWheel = useCallback(
|
||||||
(e: WheelEvent) => {
|
(e: WheelEvent) => {
|
||||||
|
|
@ -454,7 +461,13 @@ export function GraphView({
|
||||||
|
|
||||||
// ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─
|
// ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─
|
||||||
const isPanningRef = useRef(false);
|
const isPanningRef = useRef(false);
|
||||||
const edgeMouseDownRef = useRef<{ id: string; x: number; y: number } | null>(null);
|
const edgeMouseDownRef = useRef<{
|
||||||
|
id: string;
|
||||||
|
worldX: number;
|
||||||
|
worldY: number;
|
||||||
|
clientX: number;
|
||||||
|
clientY: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const handleMouseDown = useCallback(
|
const handleMouseDown = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
|
|
@ -491,7 +504,13 @@ export function GraphView({
|
||||||
if (hitEdge) {
|
if (hitEdge) {
|
||||||
markUserInteracted();
|
markUserInteracted();
|
||||||
isPanningRef.current = false;
|
isPanningRef.current = false;
|
||||||
edgeMouseDownRef.current = { id: hitEdge, x: world.x, y: world.y };
|
edgeMouseDownRef.current = {
|
||||||
|
id: hitEdge,
|
||||||
|
worldX: world.x,
|
||||||
|
worldY: world.y,
|
||||||
|
clientX: e.clientX,
|
||||||
|
clientY: e.clientY,
|
||||||
|
};
|
||||||
hoveredEdgeIdRef.current = hitEdge;
|
hoveredEdgeIdRef.current = hitEdge;
|
||||||
} else {
|
} else {
|
||||||
// Hit empty space → pan
|
// Hit empty space → pan
|
||||||
|
|
@ -518,11 +537,6 @@ export function GraphView({
|
||||||
|
|
||||||
const processActivePointerMove = useCallback(
|
const processActivePointerMove = useCallback(
|
||||||
(clientX: number, clientY: number) => {
|
(clientX: number, clientY: number) => {
|
||||||
if (!activePrimaryInteractionRef.current) {
|
|
||||||
dragPreviewRef.current = null;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPanningRef.current) {
|
if (isPanningRef.current) {
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
document.getSelection()?.removeAllRanges();
|
document.getSelection()?.removeAllRanges();
|
||||||
|
|
@ -531,6 +545,36 @@ export function GraphView({
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const edgeMouseDown = edgeMouseDownRef.current;
|
||||||
|
if (
|
||||||
|
edgeMouseDown &&
|
||||||
|
!interaction.dragNodeId.current &&
|
||||||
|
!interaction.isDragging.current
|
||||||
|
) {
|
||||||
|
const dx = clientX - edgeMouseDown.clientX;
|
||||||
|
const dy = clientY - edgeMouseDown.clientY;
|
||||||
|
if (dx * dx + dy * dy > ANIM.dragThresholdPx * ANIM.dragThresholdPx) {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.getSelection()?.removeAllRanges();
|
||||||
|
}
|
||||||
|
hoveredEdgeIdRef.current = null;
|
||||||
|
edgeMouseDownRef.current = null;
|
||||||
|
isPanningRef.current = true;
|
||||||
|
camera.handlePanStart(edgeMouseDown.clientX, edgeMouseDown.clientY);
|
||||||
|
camera.handlePanMove(clientX, clientY);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!activePrimaryInteractionRef.current &&
|
||||||
|
!interaction.dragNodeId.current &&
|
||||||
|
!interaction.isDragging.current
|
||||||
|
) {
|
||||||
|
dragPreviewRef.current = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const canvas = canvasHandle.current?.getCanvas();
|
const canvas = canvasHandle.current?.getCanvas();
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
dragPreviewRef.current = null;
|
dragPreviewRef.current = null;
|
||||||
|
|
@ -627,8 +671,8 @@ export function GraphView({
|
||||||
if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) {
|
if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) {
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top);
|
const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top);
|
||||||
const dx = world.x - edgeMouseDownRef.current.x;
|
const dx = world.x - edgeMouseDownRef.current.worldX;
|
||||||
const dy = world.y - edgeMouseDownRef.current.y;
|
const dy = world.y - edgeMouseDownRef.current.worldY;
|
||||||
if (dx * dx + dy * dy <= 25) {
|
if (dx * dx + dy * dy <= 25) {
|
||||||
clickedEdgeId = edgeMouseDownRef.current.id;
|
clickedEdgeId = edgeMouseDownRef.current.id;
|
||||||
}
|
}
|
||||||
|
|
@ -656,6 +700,8 @@ export function GraphView({
|
||||||
},
|
},
|
||||||
[camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation]
|
[camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation]
|
||||||
);
|
);
|
||||||
|
processActivePointerMoveRef.current = processActivePointerMove;
|
||||||
|
completePointerInteractionRef.current = completePointerInteraction;
|
||||||
|
|
||||||
const handleMouseMove = useCallback(
|
const handleMouseMove = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
|
|
@ -711,36 +757,40 @@ export function GraphView({
|
||||||
if (
|
if (
|
||||||
!activePrimaryInteractionRef.current &&
|
!activePrimaryInteractionRef.current &&
|
||||||
!isPanningRef.current &&
|
!isPanningRef.current &&
|
||||||
!interaction.dragNodeId.current &&
|
!interactionRef.current.dragNodeId.current &&
|
||||||
!interaction.isDragging.current &&
|
!interactionRef.current.isDragging.current &&
|
||||||
!edgeMouseDownRef.current
|
!edgeMouseDownRef.current
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
processActivePointerMove(event.clientX, event.clientY);
|
processActivePointerMoveRef.current?.(event.clientX, event.clientY);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWindowMouseUp = (event: MouseEvent): void => {
|
const handleWindowMouseUp = (event: MouseEvent): void => {
|
||||||
if (
|
if (
|
||||||
!activePrimaryInteractionRef.current &&
|
!activePrimaryInteractionRef.current &&
|
||||||
!isPanningRef.current &&
|
!isPanningRef.current &&
|
||||||
!interaction.dragNodeId.current &&
|
!interactionRef.current.dragNodeId.current &&
|
||||||
!interaction.isDragging.current &&
|
!interactionRef.current.isDragging.current &&
|
||||||
!edgeMouseDownRef.current
|
!edgeMouseDownRef.current
|
||||||
) {
|
) {
|
||||||
setInteractionGuards(false);
|
setInteractionGuards(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
completePointerInteraction(event.clientX, event.clientY);
|
completePointerInteractionRef.current?.(event.clientX, event.clientY);
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearInteraction = (): void => {
|
const clearInteraction = (): void => {
|
||||||
if (!activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.isDragging.current) {
|
if (
|
||||||
|
!activePrimaryInteractionRef.current &&
|
||||||
|
!isPanningRef.current &&
|
||||||
|
!interactionRef.current.isDragging.current
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
interaction.handleMouseUp();
|
interactionRef.current.handleMouseUp();
|
||||||
camera.handlePanEnd();
|
cameraRef.current.handlePanEnd();
|
||||||
isPanningRef.current = false;
|
isPanningRef.current = false;
|
||||||
edgeMouseDownRef.current = null;
|
edgeMouseDownRef.current = null;
|
||||||
dragPreviewRef.current = null;
|
dragPreviewRef.current = null;
|
||||||
|
|
@ -756,9 +806,14 @@ export function GraphView({
|
||||||
window.removeEventListener('mouseup', handleWindowMouseUp);
|
window.removeEventListener('mouseup', handleWindowMouseUp);
|
||||||
window.removeEventListener('blur', clearInteraction);
|
window.removeEventListener('blur', clearInteraction);
|
||||||
window.removeEventListener('dragstart', clearInteraction);
|
window.removeEventListener('dragstart', clearInteraction);
|
||||||
|
};
|
||||||
|
}, [setInteractionGuards]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
setInteractionGuards(false);
|
setInteractionGuards(false);
|
||||||
};
|
};
|
||||||
}, [camera, completePointerInteraction, interaction, processActivePointerMove, setInteractionGuards]);
|
}, [setInteractionGuards]);
|
||||||
|
|
||||||
const handleDoubleClick = useCallback(
|
const handleDoubleClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* TeamGraphAdapter — transforms Zustand TeamData → GraphDataPort.
|
* TeamGraphAdapter — transforms store-backed team graph input → GraphDataPort.
|
||||||
*
|
*
|
||||||
* This adapter owns the graph projection from team runtime state into the
|
* This adapter owns the graph projection from team runtime state into the
|
||||||
* reusable package port model. Renderer hooks may still read store state, but
|
* reusable package port model. Renderer hooks may still read store state, but
|
||||||
|
|
@ -57,12 +57,18 @@ import type {
|
||||||
LeadActivityState,
|
LeadActivityState,
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
MemberSpawnStatusesSnapshot,
|
MemberSpawnStatusesSnapshot,
|
||||||
TeamData,
|
ResolvedTeamMember,
|
||||||
TeamProcess,
|
TeamProcess,
|
||||||
TeamProvisioningProgress,
|
TeamProvisioningProgress,
|
||||||
|
TeamViewSnapshot,
|
||||||
} from '@shared/types/team';
|
} from '@shared/types/team';
|
||||||
import type { LeadContextUsage } from '@shared/types/team';
|
import type { LeadContextUsage } from '@shared/types/team';
|
||||||
|
|
||||||
|
export interface TeamGraphData extends TeamViewSnapshot {
|
||||||
|
members: ResolvedTeamMember[];
|
||||||
|
messageFeed: InboxMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
export class TeamGraphAdapter {
|
export class TeamGraphAdapter {
|
||||||
// ─── ES #private fields ──────────────────────────────────────────────────
|
// ─── ES #private fields ──────────────────────────────────────────────────
|
||||||
#lastTeamName = '';
|
#lastTeamName = '';
|
||||||
|
|
@ -89,7 +95,7 @@ export class TeamGraphAdapter {
|
||||||
* Adapt team data into a GraphDataPort snapshot.
|
* Adapt team data into a GraphDataPort snapshot.
|
||||||
*/
|
*/
|
||||||
adapt(
|
adapt(
|
||||||
teamData: TeamData | null,
|
teamData: TeamGraphData | null,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
||||||
leadActivity?: LeadActivityState,
|
leadActivity?: LeadActivityState,
|
||||||
|
|
@ -190,7 +196,7 @@ export class TeamGraphAdapter {
|
||||||
this.#buildMessageParticles(
|
this.#buildMessageParticles(
|
||||||
particles,
|
particles,
|
||||||
nodes,
|
nodes,
|
||||||
teamData.messages,
|
teamData.messageFeed,
|
||||||
teamName,
|
teamName,
|
||||||
leadId,
|
leadId,
|
||||||
leadName,
|
leadName,
|
||||||
|
|
@ -233,11 +239,11 @@ export class TeamGraphAdapter {
|
||||||
|
|
||||||
// ─── Private: node builders ──────────────────────────────────────────────
|
// ─── Private: node builders ──────────────────────────────────────────────
|
||||||
|
|
||||||
static #getLeadMemberName(data: TeamData, teamName: string): string {
|
static #getLeadMemberName(data: TeamGraphData, teamName: string): string {
|
||||||
return getGraphLeadMemberName(data, teamName);
|
return getGraphLeadMemberName(data, teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
static #buildMemberNodeIdByAlias(data: TeamData, teamName: string): Map<string, string> {
|
static #buildMemberNodeIdByAlias(data: TeamGraphData, teamName: string): Map<string, string> {
|
||||||
return buildGraphMemberNodeIdAliasMap(
|
return buildGraphMemberNodeIdAliasMap(
|
||||||
teamName,
|
teamName,
|
||||||
data.members.filter((member) => !isLeadMember(member))
|
data.members.filter((member) => !isLeadMember(member))
|
||||||
|
|
@ -245,7 +251,7 @@ export class TeamGraphAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
static #buildLayoutPort(
|
static #buildLayoutPort(
|
||||||
data: TeamData,
|
data: TeamGraphData,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
slotAssignments?: Record<string, GraphOwnerSlotAssignment>
|
slotAssignments?: Record<string, GraphOwnerSlotAssignment>
|
||||||
): GraphLayoutPort {
|
): GraphLayoutPort {
|
||||||
|
|
@ -263,7 +269,7 @@ export class TeamGraphAdapter {
|
||||||
);
|
);
|
||||||
const assignedStableOwnerIds = new Set(Object.keys(slotAssignments ?? {}));
|
const assignedStableOwnerIds = new Set(Object.keys(slotAssignments ?? {}));
|
||||||
|
|
||||||
const pushMember = (member: TeamData['members'][number] | undefined): void => {
|
const pushMember = (member: TeamGraphData['members'][number] | undefined): void => {
|
||||||
if (!member) {
|
if (!member) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -315,7 +321,7 @@ export class TeamGraphAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
static #collectDuplicateStableOwnerIds(
|
static #collectDuplicateStableOwnerIds(
|
||||||
members: readonly TeamData['members'][number][]
|
members: readonly TeamGraphData['members'][number][]
|
||||||
): string[] {
|
): string[] {
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
for (const member of members) {
|
for (const member of members) {
|
||||||
|
|
@ -337,9 +343,9 @@ export class TeamGraphAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
static #getRuntimeLabel(
|
static #getRuntimeLabel(
|
||||||
providerId: TeamData['members'][number]['providerId'],
|
providerId: ResolvedTeamMember['providerId'],
|
||||||
model: TeamData['members'][number]['model'],
|
model: ResolvedTeamMember['model'],
|
||||||
effort: TeamData['members'][number]['effort']
|
effort: ResolvedTeamMember['effort']
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
return formatTeamRuntimeSummary(providerId, model, effort);
|
return formatTeamRuntimeSummary(providerId, model, effort);
|
||||||
}
|
}
|
||||||
|
|
@ -360,7 +366,7 @@ export class TeamGraphAdapter {
|
||||||
#buildLeadNode(
|
#buildLeadNode(
|
||||||
nodes: GraphNode[],
|
nodes: GraphNode[],
|
||||||
leadId: string,
|
leadId: string,
|
||||||
data: TeamData,
|
data: TeamGraphData,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
leadName: string,
|
leadName: string,
|
||||||
pendingApprovalAgents?: Set<string>,
|
pendingApprovalAgents?: Set<string>,
|
||||||
|
|
@ -456,7 +462,7 @@ export class TeamGraphAdapter {
|
||||||
nodes: GraphNode[],
|
nodes: GraphNode[],
|
||||||
edges: GraphEdge[],
|
edges: GraphEdge[],
|
||||||
leadId: string,
|
leadId: string,
|
||||||
data: TeamData,
|
data: TeamGraphData,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberNodeIdByAlias: ReadonlyMap<string, string>,
|
memberNodeIdByAlias: ReadonlyMap<string, string>,
|
||||||
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
||||||
|
|
@ -560,14 +566,14 @@ export class TeamGraphAdapter {
|
||||||
#buildTaskNodes(
|
#buildTaskNodes(
|
||||||
nodes: GraphNode[],
|
nodes: GraphNode[],
|
||||||
edges: GraphEdge[],
|
edges: GraphEdge[],
|
||||||
data: TeamData,
|
data: TeamGraphData,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
commentReadState?: Record<string, unknown>,
|
commentReadState?: Record<string, unknown>,
|
||||||
memberNodeIdByAlias?: ReadonlyMap<string, string>,
|
memberNodeIdByAlias?: ReadonlyMap<string, string>,
|
||||||
leadId?: string,
|
leadId?: string,
|
||||||
leadName?: string
|
leadName?: string
|
||||||
): void {
|
): void {
|
||||||
const taskStateById = new Map<string, Pick<TeamData['tasks'][number], 'status'>>();
|
const taskStateById = new Map<string, Pick<TeamGraphData['tasks'][number], 'status'>>();
|
||||||
const taskDisplayIds = new Map<string, string>();
|
const taskDisplayIds = new Map<string, string>();
|
||||||
const memberColorByName = new Map<string, string>();
|
const memberColorByName = new Map<string, string>();
|
||||||
|
|
||||||
|
|
@ -752,7 +758,7 @@ export class TeamGraphAdapter {
|
||||||
#buildProcessNodes(
|
#buildProcessNodes(
|
||||||
nodes: GraphNode[],
|
nodes: GraphNode[],
|
||||||
edges: GraphEdge[],
|
edges: GraphEdge[],
|
||||||
data: TeamData,
|
data: TeamGraphData,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberNodeIdByAlias?: ReadonlyMap<string, string>
|
memberNodeIdByAlias?: ReadonlyMap<string, string>
|
||||||
): void {
|
): void {
|
||||||
|
|
@ -830,7 +836,7 @@ export class TeamGraphAdapter {
|
||||||
|
|
||||||
#attachActivityFeeds(
|
#attachActivityFeeds(
|
||||||
nodes: GraphNode[],
|
nodes: GraphNode[],
|
||||||
data: TeamData,
|
data: TeamGraphData,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
leadId: string,
|
leadId: string,
|
||||||
leadName: string
|
leadName: string
|
||||||
|
|
@ -847,7 +853,10 @@ export class TeamGraphAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
const entriesByOwnerNodeId = buildInlineActivityEntries({
|
const entriesByOwnerNodeId = buildInlineActivityEntries({
|
||||||
data,
|
data: {
|
||||||
|
...data,
|
||||||
|
messages: data.messageFeed,
|
||||||
|
},
|
||||||
teamName,
|
teamName,
|
||||||
leadId,
|
leadId,
|
||||||
leadName,
|
leadName,
|
||||||
|
|
@ -1008,7 +1017,7 @@ export class TeamGraphAdapter {
|
||||||
|
|
||||||
#buildCommentParticles(
|
#buildCommentParticles(
|
||||||
particles: GraphParticle[],
|
particles: GraphParticle[],
|
||||||
data: TeamData,
|
data: TeamGraphData,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
leadId: string,
|
leadId: string,
|
||||||
leadName: string,
|
leadName: string,
|
||||||
|
|
@ -1101,8 +1110,8 @@ export class TeamGraphAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
static #buildMemberException(
|
static #buildMemberException(
|
||||||
runtimeAdvisory: TeamData['members'][number]['runtimeAdvisory'],
|
runtimeAdvisory: ResolvedTeamMember['runtimeAdvisory'],
|
||||||
providerId: TeamData['members'][number]['providerId'],
|
providerId: ResolvedTeamMember['providerId'],
|
||||||
spawn: MemberSpawnStatusEntry | undefined,
|
spawn: MemberSpawnStatusEntry | undefined,
|
||||||
pendingApproval: boolean
|
pendingApproval: boolean
|
||||||
): Pick<GraphNode, 'exceptionTone' | 'exceptionLabel'> | undefined {
|
): Pick<GraphNode, 'exceptionTone' | 'exceptionLabel'> | undefined {
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,34 @@
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { selectTeamDataForName } from '@renderer/store/slices/teamSlice';
|
import {
|
||||||
|
selectResolvedMembersForTeamName,
|
||||||
|
selectTeamDataForName,
|
||||||
|
selectTeamMessages,
|
||||||
|
} from '@renderer/store/slices/teamSlice';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import type { TeamData, TeamSummary } from '@shared/types/team';
|
import type { TeamGraphData } from '../adapters/TeamGraphAdapter';
|
||||||
|
import type { TeamSummary } from '@shared/types/team';
|
||||||
|
|
||||||
export function useGraphActivityContext(teamName: string): {
|
export function useGraphActivityContext(teamName: string): {
|
||||||
teamData: TeamData | null;
|
teamData: TeamGraphData | null;
|
||||||
teams: TeamSummary[];
|
teams: TeamSummary[];
|
||||||
} {
|
} {
|
||||||
return useStore(
|
return useStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => {
|
||||||
teamData: selectTeamDataForName(state, teamName),
|
const snapshot = selectTeamDataForName(state, teamName);
|
||||||
teams: state.teams,
|
const members = selectResolvedMembersForTeamName(state, teamName);
|
||||||
}))
|
const messages = selectTeamMessages(state, teamName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
teamData: snapshot
|
||||||
|
? {
|
||||||
|
...snapshot,
|
||||||
|
members,
|
||||||
|
messageFeed: messages,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
teams: state.teams,
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,11 @@ import { useCallback, useMemo, useState } from 'react';
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { CreateTaskDialog } from '@renderer/components/team/dialogs/CreateTaskDialog';
|
import { CreateTaskDialog } from '@renderer/components/team/dialogs/CreateTaskDialog';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { isTeamProvisioningActive, selectTeamDataForName } from '@renderer/store/slices/teamSlice';
|
import {
|
||||||
|
isTeamProvisioningActive,
|
||||||
|
selectResolvedMembersForTeamName,
|
||||||
|
selectTeamDataForName,
|
||||||
|
} from '@renderer/store/slices/teamSlice';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import type { TaskRef } from '@shared/types';
|
import type { TaskRef } from '@shared/types';
|
||||||
|
|
@ -25,19 +29,17 @@ export function useGraphCreateTaskDialog(teamName: string): UseGraphCreateTaskDi
|
||||||
});
|
});
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const { teamData, createTeamTask, isTeamProvisioning } = useStore(
|
const { teamData, activeMembers, createTeamTask, isTeamProvisioning } = useStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
teamData: selectTeamDataForName(state, teamName),
|
teamData: selectTeamDataForName(state, teamName),
|
||||||
|
activeMembers: selectResolvedMembersForTeamName(state, teamName).filter(
|
||||||
|
(member) => !member.removedAt
|
||||||
|
),
|
||||||
createTeamTask: state.createTeamTask,
|
createTeamTask: state.createTeamTask,
|
||||||
isTeamProvisioning: isTeamProvisioningActive(state, teamName),
|
isTeamProvisioning: isTeamProvisioningActive(state, teamName),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeMembers = useMemo(
|
|
||||||
() => (teamData?.members ?? []).filter((member) => !member.removedAt),
|
|
||||||
[teamData?.members]
|
|
||||||
);
|
|
||||||
|
|
||||||
const openCreateTaskDialog = useCallback((owner = ''): void => {
|
const openCreateTaskDialog = useCallback((owner = ''): void => {
|
||||||
setDialogState({
|
setDialogState({
|
||||||
open: true,
|
open: true,
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,34 @@
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import {
|
import {
|
||||||
getCurrentProvisioningProgressForTeam,
|
getCurrentProvisioningProgressForTeam,
|
||||||
|
selectResolvedMembersForTeamName,
|
||||||
selectTeamDataForName,
|
selectTeamDataForName,
|
||||||
} from '@renderer/store/slices/teamSlice';
|
} from '@renderer/store/slices/teamSlice';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
import type { TeamGraphData } from '../adapters/TeamGraphAdapter';
|
||||||
|
|
||||||
export function useGraphMemberPopoverContext(teamName: string, memberName: string) {
|
export function useGraphMemberPopoverContext(teamName: string, memberName: string) {
|
||||||
return useStore(
|
return useStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => {
|
||||||
teamData: teamName ? selectTeamDataForName(state, teamName) : null,
|
const snapshot = teamName ? selectTeamDataForName(state, teamName) : null;
|
||||||
spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined,
|
const teamMembers = teamName ? selectResolvedMembersForTeamName(state, teamName) : [];
|
||||||
leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined,
|
|
||||||
progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null,
|
return {
|
||||||
memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined,
|
teamData: snapshot
|
||||||
memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined,
|
? {
|
||||||
}))
|
...snapshot,
|
||||||
|
members: teamMembers,
|
||||||
|
messageFeed: [],
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
teamMembers,
|
||||||
|
spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined,
|
||||||
|
leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined,
|
||||||
|
progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null,
|
||||||
|
memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined,
|
||||||
|
memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,20 +10,25 @@ import { useStore } from '@renderer/store';
|
||||||
import {
|
import {
|
||||||
getCurrentProvisioningProgressForTeam,
|
getCurrentProvisioningProgressForTeam,
|
||||||
isTeamGraphSlotPersistenceDisabled,
|
isTeamGraphSlotPersistenceDisabled,
|
||||||
|
selectResolvedMembersForTeamName,
|
||||||
selectTeamDataForName,
|
selectTeamDataForName,
|
||||||
|
selectTeamMessages,
|
||||||
} from '@renderer/store/slices/teamSlice';
|
} from '@renderer/store/slices/teamSlice';
|
||||||
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter';
|
import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter';
|
||||||
|
|
||||||
|
import type { TeamGraphData } from '../adapters/TeamGraphAdapter';
|
||||||
import type { GraphDataPort } from '@claude-teams/agent-graph';
|
import type { GraphDataPort } from '@claude-teams/agent-graph';
|
||||||
|
|
||||||
export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
const adapterRef = useRef<TeamGraphAdapter>(TeamGraphAdapter.create());
|
const adapterRef = useRef<TeamGraphAdapter>(TeamGraphAdapter.create());
|
||||||
|
|
||||||
const {
|
const {
|
||||||
teamData,
|
teamSnapshot,
|
||||||
|
members,
|
||||||
|
messages,
|
||||||
spawnStatuses,
|
spawnStatuses,
|
||||||
leadActivity,
|
leadActivity,
|
||||||
leadContext,
|
leadContext,
|
||||||
|
|
@ -38,7 +43,9 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
ensureTeamGraphSlotAssignments,
|
ensureTeamGraphSlotAssignments,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
teamData: selectTeamDataForName(s, teamName),
|
teamSnapshot: selectTeamDataForName(s, teamName),
|
||||||
|
members: selectResolvedMembersForTeamName(s, teamName),
|
||||||
|
messages: selectTeamMessages(s, teamName),
|
||||||
spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
|
spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
|
||||||
leadActivity: teamName ? s.leadActivityByTeam[teamName] : undefined,
|
leadActivity: teamName ? s.leadActivityByTeam[teamName] : undefined,
|
||||||
leadContext: teamName ? s.leadContextByTeam[teamName] : undefined,
|
leadContext: teamName ? s.leadContextByTeam[teamName] : undefined,
|
||||||
|
|
@ -64,6 +71,17 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
return agents;
|
return agents;
|
||||||
}, [pendingApprovals, teamName]);
|
}, [pendingApprovals, teamName]);
|
||||||
|
|
||||||
|
const teamData = useMemo<TeamGraphData | null>(() => {
|
||||||
|
if (!teamSnapshot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...teamSnapshot,
|
||||||
|
members,
|
||||||
|
messageFeed: messages,
|
||||||
|
};
|
||||||
|
}, [members, messages, teamSnapshot]);
|
||||||
|
|
||||||
const commentReadState = useSyncExternalStore(subscribe, getSnapshot);
|
const commentReadState = useSyncExternalStore(subscribe, getSnapshot);
|
||||||
|
|
||||||
const effectiveSlotAssignments = useMemo(() => {
|
const effectiveSlotAssignments = useMemo(() => {
|
||||||
|
|
@ -97,9 +115,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
const currentAssignment = slotAssignments[stableOwnerId];
|
const currentAssignment = slotAssignments[stableOwnerId];
|
||||||
const defaultAssignment = defaultSeed.assignments[stableOwnerId];
|
const defaultAssignment = defaultSeed.assignments[stableOwnerId];
|
||||||
return (
|
return (
|
||||||
currentAssignment &&
|
currentAssignment?.ringIndex === defaultAssignment?.ringIndex &&
|
||||||
defaultAssignment &&
|
|
||||||
currentAssignment.ringIndex === defaultAssignment.ringIndex &&
|
|
||||||
currentAssignment.sectorIndex === defaultAssignment.sectorIndex
|
currentAssignment.sectorIndex === defaultAssignment.sectorIndex
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamSlice';
|
||||||
|
|
||||||
import { parseGraphMemberNodeId } from '../../core/domain/graphOwnerIdentity';
|
import { parseGraphMemberNodeId } from '../../core/domain/graphOwnerIdentity';
|
||||||
|
|
||||||
|
|
@ -8,6 +9,7 @@ import type { GraphOwnerSlotAssignment } from '@claude-teams/agent-graph';
|
||||||
|
|
||||||
export function useTeamGraphSurfaceActions(teamName: string): {
|
export function useTeamGraphSurfaceActions(teamName: string): {
|
||||||
openTeamPage: () => void;
|
openTeamPage: () => void;
|
||||||
|
resetOwnerSlotAssignmentsToDefaults: () => void;
|
||||||
commitOwnerSlotDrop: (payload: {
|
commitOwnerSlotDrop: (payload: {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
assignment: GraphOwnerSlotAssignment;
|
assignment: GraphOwnerSlotAssignment;
|
||||||
|
|
@ -19,6 +21,13 @@ export function useTeamGraphSurfaceActions(teamName: string): {
|
||||||
useStore.getState().openTeamTab(teamName);
|
useStore.getState().openTeamTab(teamName);
|
||||||
}, [teamName]);
|
}, [teamName]);
|
||||||
|
|
||||||
|
const resetOwnerSlotAssignmentsToDefaults = useCallback(() => {
|
||||||
|
if (!isTeamGraphSlotPersistenceDisabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
useStore.getState().resetTeamGraphSlotAssignmentsToDefaults(teamName);
|
||||||
|
}, [teamName]);
|
||||||
|
|
||||||
const commitOwnerSlotDrop = useCallback(
|
const commitOwnerSlotDrop = useCallback(
|
||||||
(payload: {
|
(payload: {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
|
|
@ -51,6 +60,7 @@ export function useTeamGraphSurfaceActions(teamName: string): {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
openTeamPage,
|
openTeamPage,
|
||||||
|
resetOwnerSlotAssignmentsToDefaults,
|
||||||
commitOwnerSlotDrop,
|
commitOwnerSlotDrop,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
* into ui/, hooks/, or core/ directly.
|
* into ui/, hooks/, or core/ directly.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export type { InlineActivityEntry } from '../core/domain/buildInlineActivityEntries';
|
||||||
export { buildInlineActivityEntries } from '../core/domain/buildInlineActivityEntries';
|
export { buildInlineActivityEntries } from '../core/domain/buildInlineActivityEntries';
|
||||||
export { buildGraphMemberNodeIdForMember } from '../core/domain/graphOwnerIdentity';
|
export { buildGraphMemberNodeIdForMember } from '../core/domain/graphOwnerIdentity';
|
||||||
export { TeamGraphAdapter } from './adapters/TeamGraphAdapter';
|
export { TeamGraphAdapter } from './adapters/TeamGraphAdapter';
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
|
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
|
||||||
import {
|
import {
|
||||||
resolveMessageRenderProps,
|
|
||||||
type MessageContext,
|
type MessageContext,
|
||||||
|
resolveMessageRenderProps,
|
||||||
} from '@renderer/components/team/activity/activityMessageContext';
|
} from '@renderer/components/team/activity/activityMessageContext';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
type InlineActivityEntry,
|
type InlineActivityEntry,
|
||||||
} from '../../core/domain/buildInlineActivityEntries';
|
} from '../../core/domain/buildInlineActivityEntries';
|
||||||
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
|
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
|
||||||
|
|
||||||
import { GraphActivityCard } from './GraphActivityCard';
|
import { GraphActivityCard } from './GraphActivityCard';
|
||||||
|
|
||||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||||
|
|
@ -74,6 +75,9 @@ export const GraphActivityHud = ({
|
||||||
const connectorPathRefs = useRef(new Map<string, SVGPathElement | null>());
|
const connectorPathRefs = useRef(new Map<string, SVGPathElement | null>());
|
||||||
const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(null);
|
const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(null);
|
||||||
const { teamData, teams } = useGraphActivityContext(teamName);
|
const { teamData, teams } = useGraphActivityContext(teamName);
|
||||||
|
const teamSnapshot = teamData;
|
||||||
|
const members = teamData?.members ?? [];
|
||||||
|
const messages = teamData?.messageFeed ?? [];
|
||||||
|
|
||||||
const ownerNodes = useMemo(
|
const ownerNodes = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -84,21 +88,27 @@ export const GraphActivityHud = ({
|
||||||
[nodes]
|
[nodes]
|
||||||
);
|
);
|
||||||
const leadNodeId = ownerNodes.find((node) => node.kind === 'lead')?.id ?? `lead:${teamName}`;
|
const leadNodeId = ownerNodes.find((node) => node.kind === 'lead')?.id ?? `lead:${teamName}`;
|
||||||
const leadName = teamData ? getGraphLeadMemberName(teamData, teamName) : `${teamName}-lead`;
|
const leadName = teamSnapshot
|
||||||
|
? getGraphLeadMemberName({ members }, teamName)
|
||||||
|
: `${teamName}-lead`;
|
||||||
const ownerNodeIds = useMemo(() => new Set(ownerNodes.map((node) => node.id)), [ownerNodes]);
|
const ownerNodeIds = useMemo(() => new Set(ownerNodes.map((node) => node.id)), [ownerNodes]);
|
||||||
const entryMapByOwnerNodeId = useMemo(() => {
|
const entryMapByOwnerNodeId = useMemo(() => {
|
||||||
if (!teamData) {
|
if (!teamSnapshot) {
|
||||||
return new Map<string, InlineActivityEntry[]>();
|
return new Map<string, InlineActivityEntry[]>();
|
||||||
}
|
}
|
||||||
return buildInlineActivityEntries({
|
return buildInlineActivityEntries({
|
||||||
data: teamData,
|
data: {
|
||||||
|
members,
|
||||||
|
tasks: teamSnapshot.tasks,
|
||||||
|
messages,
|
||||||
|
},
|
||||||
teamName,
|
teamName,
|
||||||
leadId: leadNodeId,
|
leadId: leadNodeId,
|
||||||
leadName,
|
leadName,
|
||||||
ownerNodeIds,
|
ownerNodeIds,
|
||||||
});
|
});
|
||||||
}, [leadName, leadNodeId, ownerNodeIds, teamData, teamName]);
|
}, [leadName, leadNodeId, members, messages, ownerNodeIds, teamName, teamSnapshot]);
|
||||||
const messageContext = useMemo(() => buildMessageContext(teamData?.members), [teamData?.members]);
|
const messageContext = useMemo(() => buildMessageContext(members), [members]);
|
||||||
const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams);
|
const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams);
|
||||||
const { readSet } = useTeamMessagesRead(teamName);
|
const { readSet } = useTeamMessagesRead(teamName);
|
||||||
|
|
||||||
|
|
@ -350,7 +360,7 @@ export const GraphActivityHud = ({
|
||||||
};
|
};
|
||||||
}, [enabled, forwardWheelToGraph, visibleLanes]);
|
}, [enabled, forwardWheelToGraph, visibleLanes]);
|
||||||
|
|
||||||
if (!enabled || !teamData || visibleLanes.length === 0) {
|
if (!enabled || !teamSnapshot || visibleLanes.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -477,7 +487,7 @@ export const GraphActivityHud = ({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
members={teamData.members}
|
members={members}
|
||||||
onMemberClick={handleMemberClick}
|
onMemberClick={handleMemberClick}
|
||||||
onTaskIdClick={onOpenTaskDetail}
|
onTaskIdClick={onOpenTaskDetail}
|
||||||
teamNames={teamNames}
|
teamNames={teamNames}
|
||||||
|
|
|
||||||
|
|
@ -292,14 +292,21 @@ const MemberPopoverContent = ({
|
||||||
? node.domainRef.teamName
|
? node.domainRef.teamName
|
||||||
: '';
|
: '';
|
||||||
const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64);
|
const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64);
|
||||||
const { teamData, spawnEntry, leadActivity, progress, memberSpawnSnapshot, memberSpawnStatuses } =
|
const {
|
||||||
useGraphMemberPopoverContext(teamName, memberName);
|
teamData,
|
||||||
const member = teamData?.members.find((candidate) => candidate.name === memberName) ?? null;
|
teamMembers,
|
||||||
|
spawnEntry,
|
||||||
|
leadActivity,
|
||||||
|
progress,
|
||||||
|
memberSpawnSnapshot,
|
||||||
|
memberSpawnStatuses,
|
||||||
|
} = useGraphMemberPopoverContext(teamName, memberName);
|
||||||
|
const member = teamMembers.find((candidate) => candidate.name === memberName) ?? null;
|
||||||
const provisioningPresentation =
|
const provisioningPresentation =
|
||||||
teamData && teamName
|
teamData && teamName
|
||||||
? buildTeamProvisioningPresentation({
|
? buildTeamProvisioningPresentation({
|
||||||
progress,
|
progress,
|
||||||
members: teamData.members,
|
members: teamMembers,
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
memberSpawnSnapshot,
|
memberSpawnSnapshot,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,6 @@ export const TeamGraphOverlay = ({
|
||||||
const openCreateTask = useCallback(() => {
|
const openCreateTask = useCallback(() => {
|
||||||
openCreateTaskDialog('');
|
openCreateTaskDialog('');
|
||||||
}, [openCreateTaskDialog]);
|
}, [openCreateTaskDialog]);
|
||||||
|
|
||||||
const events: GraphEventPort = {
|
const events: GraphEventPort = {
|
||||||
onNodeDoubleClick: useCallback(
|
onNodeDoubleClick: useCallback(
|
||||||
(ref: GraphDomainRef) => {
|
(ref: GraphDomainRef) => {
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,6 @@ export const TeamGraphTab = ({
|
||||||
const openCreateTask = useCallback(() => {
|
const openCreateTask = useCallback(() => {
|
||||||
openCreateTaskDialog('');
|
openCreateTaskDialog('');
|
||||||
}, [openCreateTaskDialog]);
|
}, [openCreateTaskDialog]);
|
||||||
|
|
||||||
// Task action dispatchers
|
// Task action dispatchers
|
||||||
const dispatchTaskAction = useCallback(
|
const dispatchTaskAction = useCallback(
|
||||||
(action: string) => (taskId: string) =>
|
(action: string) => (taskId: string) =>
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { type DashboardRecentProject } from '@features/recent-projects/contracts';
|
import { type DashboardRecentProject } from '@features/recent-projects/contracts';
|
||||||
import { api, isElectronMode } from '@renderer/api';
|
import { api, isElectronMode } from '@renderer/api';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { buildTaskCountsByProject, normalizePath } from '@renderer/utils/pathNormalize';
|
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
||||||
|
import { buildTaskCountsByProject } from '@renderer/utils/pathNormalize';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter';
|
import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter';
|
||||||
|
import { buildActiveTeamsByProject } from '../utils/activeProjectTeams';
|
||||||
import {
|
import {
|
||||||
sortRecentProjectsByDisplayPriority,
|
sortRecentProjectsByDisplayPriority,
|
||||||
subscribeRecentProjectOpenHistory,
|
subscribeRecentProjectOpenHistory,
|
||||||
|
|
@ -62,16 +64,27 @@ export function useRecentProjectsSection(
|
||||||
openProjectPath: (projectPath: string) => Promise<void>;
|
openProjectPath: (projectPath: string) => Promise<void>;
|
||||||
selectProjectFolder: () => Promise<void>;
|
selectProjectFolder: () => Promise<void>;
|
||||||
} {
|
} {
|
||||||
const { globalTasks, globalTasksInitialized, globalTasksLoading, fetchAllTasks, teams } =
|
const {
|
||||||
useStore(
|
globalTasks,
|
||||||
useShallow((state) => ({
|
globalTasksInitialized,
|
||||||
globalTasks: state.globalTasks,
|
globalTasksLoading,
|
||||||
globalTasksInitialized: state.globalTasksInitialized,
|
fetchAllTasks,
|
||||||
globalTasksLoading: state.globalTasksLoading,
|
teams,
|
||||||
fetchAllTasks: state.fetchAllTasks,
|
provisioningRuns,
|
||||||
teams: state.teams,
|
currentProvisioningRunIdByTeam,
|
||||||
}))
|
provisioningSnapshotByTeam,
|
||||||
);
|
} = useStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
globalTasks: state.globalTasks,
|
||||||
|
globalTasksInitialized: state.globalTasksInitialized,
|
||||||
|
globalTasksLoading: state.globalTasksLoading,
|
||||||
|
fetchAllTasks: state.fetchAllTasks,
|
||||||
|
teams: state.teams,
|
||||||
|
provisioningRuns: state.provisioningRuns,
|
||||||
|
currentProvisioningRunIdByTeam: state.currentProvisioningRunIdByTeam,
|
||||||
|
provisioningSnapshotByTeam: state.provisioningSnapshotByTeam,
|
||||||
|
}))
|
||||||
|
);
|
||||||
const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []);
|
const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []);
|
||||||
const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject();
|
const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject();
|
||||||
const [recentProjects, setRecentProjects] = useState<DashboardRecentProject[]>(
|
const [recentProjects, setRecentProjects] = useState<DashboardRecentProject[]>(
|
||||||
|
|
@ -92,6 +105,21 @@ export function useRecentProjectsSection(
|
||||||
const recentProjectsRef = useRef<DashboardRecentProject[]>(
|
const recentProjectsRef = useRef<DashboardRecentProject[]>(
|
||||||
initialSnapshot?.payload.projects ?? []
|
initialSnapshot?.payload.projects ?? []
|
||||||
);
|
);
|
||||||
|
const provisioningState = useMemo(
|
||||||
|
() => ({ currentProvisioningRunIdByTeam, provisioningRuns }),
|
||||||
|
[currentProvisioningRunIdByTeam, provisioningRuns]
|
||||||
|
);
|
||||||
|
const provisioningTeamNames = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.keys(currentProvisioningRunIdByTeam).filter((teamName) =>
|
||||||
|
isTeamProvisioningActive(provisioningState, teamName)
|
||||||
|
),
|
||||||
|
[currentProvisioningRunIdByTeam, provisioningState]
|
||||||
|
);
|
||||||
|
const provisioningTeamNamesKey = useMemo(
|
||||||
|
() => [...provisioningTeamNames].sort().join('\u0000'),
|
||||||
|
[provisioningTeamNames]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
recentProjectsRef.current = recentProjects;
|
recentProjectsRef.current = recentProjects;
|
||||||
|
|
@ -173,7 +201,7 @@ export function useRecentProjectsSection(
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [teams]);
|
}, [provisioningTeamNamesKey, teams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchQuery.trim()) {
|
if (!searchQuery.trim()) {
|
||||||
|
|
@ -189,25 +217,13 @@ export function useRecentProjectsSection(
|
||||||
const taskCountsByProject = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]);
|
const taskCountsByProject = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]);
|
||||||
|
|
||||||
const activeTeamsByProject = useMemo(() => {
|
const activeTeamsByProject = useMemo(() => {
|
||||||
const aliveSet = new Set(aliveTeams);
|
return buildActiveTeamsByProject({
|
||||||
const teamsByProject = new Map<string, TeamSummary[]>();
|
teams,
|
||||||
|
aliveTeamNames: aliveTeams,
|
||||||
for (const team of teams) {
|
provisioningTeamNames,
|
||||||
if (!team.projectPath || !aliveSet.has(team.teamName)) {
|
provisioningSnapshotByTeam,
|
||||||
continue;
|
});
|
||||||
}
|
}, [aliveTeams, provisioningSnapshotByTeam, provisioningTeamNames, teams]);
|
||||||
|
|
||||||
const key = normalizePath(team.projectPath);
|
|
||||||
const existing = teamsByProject.get(key);
|
|
||||||
if (existing) {
|
|
||||||
existing.push(team);
|
|
||||||
} else {
|
|
||||||
teamsByProject.set(key, [team]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return teamsByProject;
|
|
||||||
}, [aliveTeams, teams]);
|
|
||||||
|
|
||||||
const decoratedCards = useMemo(
|
const decoratedCards = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
|
||||||
|
|
@ -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));
|
const foldedMatch = lookup.folded.get(foldHistoryPath(normalizedPath));
|
||||||
if (!foldedMatch || foldedMatch.exactPaths.size !== 1) {
|
if (foldedMatch?.exactPaths.size !== 1) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -471,7 +471,7 @@ export class TmuxWslService {
|
||||||
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', POWERSHELL_FEATURE_QUERY],
|
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', POWERSHELL_FEATURE_QUERY],
|
||||||
6_000
|
6_000
|
||||||
);
|
);
|
||||||
if (!result || result.exitCode !== 0 || !result.stdout.trim()) {
|
if (result?.exitCode !== 0 || !result.stdout.trim()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@ import {
|
||||||
} from './services/extensions';
|
} from './services/extensions';
|
||||||
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
||||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||||
|
import { clearAutoResumeService } from './services/team/AutoResumeService';
|
||||||
import {
|
import {
|
||||||
buildTeamControlApiBaseUrl,
|
buildTeamControlApiBaseUrl,
|
||||||
clearTeamControlApiState,
|
clearTeamControlApiState,
|
||||||
|
|
@ -100,7 +101,6 @@ import {
|
||||||
type TeamReconcileTrigger,
|
type TeamReconcileTrigger,
|
||||||
} from './services/team/TeamReconcileDrainScheduler';
|
} from './services/team/TeamReconcileDrainScheduler';
|
||||||
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
|
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
|
||||||
import { clearAutoResumeService } from './services/team/AutoResumeService';
|
|
||||||
import { getAppIconPath } from './utils/appIcon';
|
import { getAppIconPath } from './utils/appIcon';
|
||||||
import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder';
|
import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder';
|
||||||
import {
|
import {
|
||||||
|
|
@ -564,6 +564,13 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
const teamName = row.teamName.trim();
|
const teamName = row.teamName.trim();
|
||||||
const detail = typeof row.detail === 'string' ? row.detail : '';
|
const detail = typeof row.detail === 'string' ? row.detail : '';
|
||||||
|
|
||||||
|
if (
|
||||||
|
teamDataService &&
|
||||||
|
(row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config')
|
||||||
|
) {
|
||||||
|
teamDataService.invalidateMessageFeed(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Inbox change events: relay to lead + native OS notifications ---
|
// --- Inbox change events: relay to lead + native OS notifications ---
|
||||||
if (row.type === 'inbox') {
|
if (row.type === 'inbox') {
|
||||||
if (reconcileScheduler) {
|
if (reconcileScheduler) {
|
||||||
|
|
@ -906,6 +913,12 @@ async function initializeServices(): Promise<void> {
|
||||||
});
|
});
|
||||||
|
|
||||||
const forwardTeamChange = (event: TeamChangeEvent): void => {
|
const forwardTeamChange = (event: TeamChangeEvent): void => {
|
||||||
|
if (
|
||||||
|
teamDataService &&
|
||||||
|
(event.type === 'inbox' || event.type === 'lead-message' || event.type === 'config')
|
||||||
|
) {
|
||||||
|
teamDataService.invalidateMessageFeed(event.teamName);
|
||||||
|
}
|
||||||
safeSendToRenderer(mainWindow, TEAM_CHANGE, event);
|
safeSendToRenderer(mainWindow, TEAM_CHANGE, event);
|
||||||
httpServer?.broadcast('team-change', event);
|
httpServer?.broadcast('team-change', event);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,14 @@ import {
|
||||||
TEAM_DELETE_DRAFT,
|
TEAM_DELETE_DRAFT,
|
||||||
TEAM_DELETE_TASK_ATTACHMENT,
|
TEAM_DELETE_TASK_ATTACHMENT,
|
||||||
TEAM_DELETE_TEAM,
|
TEAM_DELETE_TEAM,
|
||||||
|
TEAM_GET_AGENT_RUNTIME,
|
||||||
TEAM_GET_ALL_TASKS,
|
TEAM_GET_ALL_TASKS,
|
||||||
TEAM_GET_ATTACHMENTS,
|
TEAM_GET_ATTACHMENTS,
|
||||||
TEAM_GET_AGENT_RUNTIME,
|
|
||||||
TEAM_GET_CLAUDE_LOGS,
|
TEAM_GET_CLAUDE_LOGS,
|
||||||
TEAM_GET_DATA,
|
TEAM_GET_DATA,
|
||||||
TEAM_GET_DELETED_TASKS,
|
TEAM_GET_DELETED_TASKS,
|
||||||
TEAM_GET_LOGS_FOR_TASK,
|
TEAM_GET_LOGS_FOR_TASK,
|
||||||
|
TEAM_GET_MEMBER_ACTIVITY_META,
|
||||||
TEAM_GET_MEMBER_LOGS,
|
TEAM_GET_MEMBER_LOGS,
|
||||||
TEAM_GET_MEMBER_STATS,
|
TEAM_GET_MEMBER_STATS,
|
||||||
TEAM_GET_MESSAGES_PAGE,
|
TEAM_GET_MESSAGES_PAGE,
|
||||||
|
|
@ -59,8 +60,8 @@ import {
|
||||||
TEAM_SEND_MESSAGE,
|
TEAM_SEND_MESSAGE,
|
||||||
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
||||||
TEAM_SET_PROJECT_BRANCH_TRACKING,
|
TEAM_SET_PROJECT_BRANCH_TRACKING,
|
||||||
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
|
||||||
TEAM_SET_TASK_CLARIFICATION,
|
TEAM_SET_TASK_CLARIFICATION,
|
||||||
|
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
||||||
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
||||||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||||||
TEAM_SOFT_DELETE_TASK,
|
TEAM_SOFT_DELETE_TASK,
|
||||||
|
|
@ -96,7 +97,7 @@ import {
|
||||||
parseStandaloneSlashCommand,
|
parseStandaloneSlashCommand,
|
||||||
} from '@shared/utils/slashCommands';
|
} from '@shared/utils/slashCommands';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
|
import { app, BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
|
@ -166,7 +167,6 @@ import type {
|
||||||
LeadContextUsageSnapshot,
|
LeadContextUsageSnapshot,
|
||||||
MemberFullStats,
|
MemberFullStats,
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
TeamAgentRuntimeSnapshot,
|
|
||||||
MemberSpawnStatusesSnapshot,
|
MemberSpawnStatusesSnapshot,
|
||||||
MessagesPage,
|
MessagesPage,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
|
|
@ -174,15 +174,16 @@ import type {
|
||||||
TaskAttachmentMeta,
|
TaskAttachmentMeta,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
TaskRef,
|
TaskRef,
|
||||||
|
TeamAgentRuntimeSnapshot,
|
||||||
TeamClaudeLogsQuery,
|
TeamClaudeLogsQuery,
|
||||||
TeamClaudeLogsResponse,
|
TeamClaudeLogsResponse,
|
||||||
TeamConfig,
|
TeamConfig,
|
||||||
TeamCreateConfigRequest,
|
TeamCreateConfigRequest,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamCreateResponse,
|
TeamCreateResponse,
|
||||||
TeamData,
|
|
||||||
TeamLaunchRequest,
|
TeamLaunchRequest,
|
||||||
TeamLaunchResponse,
|
TeamLaunchResponse,
|
||||||
|
TeamMemberActivityMeta,
|
||||||
TeamMessageNotificationData,
|
TeamMessageNotificationData,
|
||||||
TeamProvisioningPrepareResult,
|
TeamProvisioningPrepareResult,
|
||||||
TeamProvisioningProgress,
|
TeamProvisioningProgress,
|
||||||
|
|
@ -190,6 +191,7 @@ import type {
|
||||||
TeamTask,
|
TeamTask,
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
TeamUpdateConfigRequest,
|
TeamUpdateConfigRequest,
|
||||||
|
TeamViewSnapshot,
|
||||||
ToolApprovalFileContent,
|
ToolApprovalFileContent,
|
||||||
ToolApprovalSettings,
|
ToolApprovalSettings,
|
||||||
UpdateKanbanPatch,
|
UpdateKanbanPatch,
|
||||||
|
|
@ -206,6 +208,16 @@ const logger = createLogger('IPC:teams');
|
||||||
const seenRateLimitKeys = new Set<string>();
|
const seenRateLimitKeys = new Set<string>();
|
||||||
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
|
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
|
||||||
|
|
||||||
|
function noteHeavyTeamDataWorkerFallback(operation: string): void {
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
`[${operation}] team-data-worker unavailable in packaged runtime; falling back to main-thread execution for heavy message/activity path`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function getDurableLeadTeammateRoster(
|
async function getDurableLeadTeammateRoster(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
leadName: string
|
leadName: string
|
||||||
|
|
@ -436,6 +448,19 @@ function checkApiErrorMessages(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scanTeamMessageNotifications(
|
||||||
|
messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
|
||||||
|
teamName: string,
|
||||||
|
teamDisplayName: string,
|
||||||
|
projectPath?: string
|
||||||
|
): void {
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
checkRateLimitMessages(messages, teamName, teamDisplayName, projectPath);
|
||||||
|
checkApiErrorMessages(messages, teamName, teamDisplayName, projectPath);
|
||||||
|
}
|
||||||
|
|
||||||
let teamDataService: TeamDataService | null = null;
|
let teamDataService: TeamDataService | null = null;
|
||||||
let teamProvisioningService: TeamProvisioningService | null = null;
|
let teamProvisioningService: TeamProvisioningService | null = null;
|
||||||
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
|
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
|
||||||
|
|
@ -519,6 +544,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning);
|
ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning);
|
||||||
ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage);
|
ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage);
|
||||||
ipcMain.handle(TEAM_GET_MESSAGES_PAGE, handleGetMessagesPage);
|
ipcMain.handle(TEAM_GET_MESSAGES_PAGE, handleGetMessagesPage);
|
||||||
|
ipcMain.handle(TEAM_GET_MEMBER_ACTIVITY_META, handleGetMemberActivityMeta);
|
||||||
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
|
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
|
||||||
ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview);
|
ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview);
|
||||||
ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban);
|
ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban);
|
||||||
|
|
@ -595,6 +621,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING);
|
ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING);
|
||||||
ipcMain.removeHandler(TEAM_SEND_MESSAGE);
|
ipcMain.removeHandler(TEAM_SEND_MESSAGE);
|
||||||
ipcMain.removeHandler(TEAM_GET_MESSAGES_PAGE);
|
ipcMain.removeHandler(TEAM_GET_MESSAGES_PAGE);
|
||||||
|
ipcMain.removeHandler(TEAM_GET_MEMBER_ACTIVITY_META);
|
||||||
ipcMain.removeHandler(TEAM_CREATE_TASK);
|
ipcMain.removeHandler(TEAM_CREATE_TASK);
|
||||||
ipcMain.removeHandler(TEAM_REQUEST_REVIEW);
|
ipcMain.removeHandler(TEAM_REQUEST_REVIEW);
|
||||||
ipcMain.removeHandler(TEAM_UPDATE_KANBAN);
|
ipcMain.removeHandler(TEAM_UPDATE_KANBAN);
|
||||||
|
|
@ -772,14 +799,14 @@ async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<Te
|
||||||
async function handleGetData(
|
async function handleGetData(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: unknown
|
teamName: unknown
|
||||||
): Promise<IpcResult<TeamData>> {
|
): Promise<IpcResult<TeamViewSnapshot>> {
|
||||||
const validated = validateTeamName(teamName);
|
const validated = validateTeamName(teamName);
|
||||||
if (!validated.valid) {
|
if (!validated.valid) {
|
||||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||||
}
|
}
|
||||||
const tn = validated.value!;
|
const tn = validated.value!;
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let data: TeamData;
|
let data: TeamViewSnapshot;
|
||||||
setCurrentMainOp('team:getData');
|
setCurrentMainOp('team:getData');
|
||||||
try {
|
try {
|
||||||
// Prefer worker thread to keep main event loop responsive
|
// Prefer worker thread to keep main event loop responsive
|
||||||
|
|
@ -791,9 +818,11 @@ async function handleGetData(
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
|
`[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}`
|
||||||
);
|
);
|
||||||
|
noteHeavyTeamDataWorkerFallback('teams:getData');
|
||||||
data = await getTeamDataService().getTeamData(tn);
|
data = await getTeamDataService().getTeamData(tn);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
noteHeavyTeamDataWorkerFallback('teams:getData');
|
||||||
data = await getTeamDataService().getTeamData(tn);
|
data = await getTeamDataService().getTeamData(tn);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -833,22 +862,30 @@ async function handleGetData(
|
||||||
|
|
||||||
const displayName = data.config.name || tn;
|
const displayName = data.config.name || tn;
|
||||||
const projectPath = data.config.projectPath;
|
const projectPath = data.config.projectPath;
|
||||||
|
|
||||||
const live = provisioning.getLiveLeadProcessMessages(tn);
|
const live = provisioning.getLiveLeadProcessMessages(tn);
|
||||||
|
const durableMessages = Array.isArray((data as { messages?: unknown }).messages)
|
||||||
|
? ((data as { messages?: typeof live }).messages ?? [])
|
||||||
|
: [];
|
||||||
|
|
||||||
if (live.length === 0) {
|
if (live.length === 0) {
|
||||||
checkRateLimitMessages(
|
if (durableMessages.length > 0) {
|
||||||
data.messages,
|
checkRateLimitMessages(
|
||||||
tn,
|
durableMessages,
|
||||||
displayName,
|
tn,
|
||||||
projectPath,
|
displayName,
|
||||||
isAlive,
|
projectPath,
|
||||||
currentLeadSessionId
|
isAlive,
|
||||||
);
|
currentLeadSessionId
|
||||||
checkApiErrorMessages(data.messages, tn, displayName, projectPath);
|
);
|
||||||
|
checkApiErrorMessages(durableMessages, tn, displayName, projectPath);
|
||||||
|
} else {
|
||||||
|
scanTeamMessageNotifications(live, tn, displayName, projectPath);
|
||||||
|
}
|
||||||
return { success: true, data: { ...data, isAlive } };
|
return { success: true, data: { ...data, isAlive } };
|
||||||
}
|
}
|
||||||
let merged = mergeLiveLeadProcessMessages(data.messages, live);
|
|
||||||
if (data.messages.length >= 50) {
|
let merged = mergeLiveLeadProcessMessages(durableMessages, live);
|
||||||
|
if (durableMessages.length >= 50) {
|
||||||
try {
|
try {
|
||||||
const newestPage = await teamDataService.getMessagesPage(tn, {
|
const newestPage = await teamDataService.getMessagesPage(tn, {
|
||||||
limit: 50,
|
limit: 50,
|
||||||
|
|
@ -866,7 +903,7 @@ async function handleGetData(
|
||||||
|
|
||||||
checkRateLimitMessages(merged, tn, displayName, projectPath, isAlive, currentLeadSessionId);
|
checkRateLimitMessages(merged, tn, displayName, projectPath, isAlive, currentLeadSessionId);
|
||||||
checkApiErrorMessages(merged, tn, displayName, projectPath);
|
checkApiErrorMessages(merged, tn, displayName, projectPath);
|
||||||
return { success: true, data: { ...data, isAlive, messages: merged } };
|
return { success: true, data: { ...data, isAlive } };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleGetTaskChangePresence(
|
async function handleGetTaskChangePresence(
|
||||||
|
|
@ -1767,19 +1804,89 @@ async function handleGetMessagesPage(
|
||||||
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||||
}
|
}
|
||||||
const opts = (options && typeof options === 'object' ? options : {}) as {
|
const opts = (options && typeof options === 'object' ? options : {}) as {
|
||||||
beforeTimestamp?: string;
|
cursor?: string | null;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
};
|
};
|
||||||
const limit = Math.min(Math.max(1, opts.limit ?? 50), 200);
|
const limit = Math.min(Math.max(1, opts.limit ?? 50), 200);
|
||||||
const beforeTimestamp =
|
const cursor =
|
||||||
typeof opts.beforeTimestamp === 'string' ? opts.beforeTimestamp : undefined;
|
typeof opts.cursor === 'string' ? opts.cursor : opts.cursor === null ? null : undefined;
|
||||||
|
|
||||||
return wrapTeamHandler('getMessagesPage', async () => {
|
return wrapTeamHandler('getMessagesPage', async () => {
|
||||||
const service = getTeamDataService();
|
let page: MessagesPage;
|
||||||
const liveMessages = beforeTimestamp
|
const notificationContext = await getTeamDataService().getTeamNotificationContext(vTeam.value!);
|
||||||
? undefined
|
const liveMessages =
|
||||||
: getTeamProvisioningService().getLiveLeadProcessMessages(vTeam.value!);
|
cursor == null ? getTeamProvisioningService().getLiveLeadProcessMessages(vTeam.value!) : [];
|
||||||
return service.getMessagesPage(vTeam.value!, { beforeTimestamp, limit, liveMessages });
|
|
||||||
|
if (liveMessages.length > 0) {
|
||||||
|
page = await getTeamDataService().getMessagesPage(vTeam.value!, {
|
||||||
|
cursor,
|
||||||
|
limit,
|
||||||
|
liveMessages,
|
||||||
|
});
|
||||||
|
scanTeamMessageNotifications(
|
||||||
|
page.messages,
|
||||||
|
vTeam.value!,
|
||||||
|
notificationContext.displayName,
|
||||||
|
notificationContext.projectPath
|
||||||
|
);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker = getTeamDataWorkerClient();
|
||||||
|
if (worker.isAvailable()) {
|
||||||
|
try {
|
||||||
|
page = await worker.getMessagesPage(vTeam.value!, { cursor, limit });
|
||||||
|
scanTeamMessageNotifications(
|
||||||
|
page.messages,
|
||||||
|
vTeam.value!,
|
||||||
|
notificationContext.displayName,
|
||||||
|
notificationContext.projectPath
|
||||||
|
);
|
||||||
|
return page;
|
||||||
|
} catch (workerErr) {
|
||||||
|
logger.warn(
|
||||||
|
`[teams:getMessagesPage] worker failed, falling back: ${
|
||||||
|
workerErr instanceof Error ? workerErr.message : workerErr
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
noteHeavyTeamDataWorkerFallback('teams:getMessagesPage');
|
||||||
|
page = await getTeamDataService().getMessagesPage(vTeam.value!, { cursor, limit });
|
||||||
|
scanTeamMessageNotifications(
|
||||||
|
page.messages,
|
||||||
|
vTeam.value!,
|
||||||
|
notificationContext.displayName,
|
||||||
|
notificationContext.projectPath
|
||||||
|
);
|
||||||
|
return page;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGetMemberActivityMeta(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
teamName: unknown
|
||||||
|
): Promise<IpcResult<TeamMemberActivityMeta>> {
|
||||||
|
const vTeam = validateTeamName(teamName);
|
||||||
|
if (!vTeam.valid) {
|
||||||
|
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapTeamHandler('getMemberActivityMeta', async () => {
|
||||||
|
const worker = getTeamDataWorkerClient();
|
||||||
|
if (worker.isAvailable()) {
|
||||||
|
try {
|
||||||
|
return await worker.getMemberActivityMeta(vTeam.value!);
|
||||||
|
} catch (workerErr) {
|
||||||
|
logger.warn(
|
||||||
|
`[teams:getMemberActivityMeta] worker failed, falling back: ${
|
||||||
|
workerErr instanceof Error ? workerErr.message : workerErr
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
noteHeavyTeamDataWorkerFallback('teams:getMemberActivityMeta');
|
||||||
|
return getTeamDataService().getMemberActivityMeta(vTeam.value!);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ export class ApiKeyService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!request.value) throw new Error('Key value is required');
|
if (!request.value) throw new Error('Key value is required');
|
||||||
if (request.scope === 'project' && (!request.projectPath || !request.projectPath.trim())) {
|
if (request.scope === 'project' && !request.projectPath?.trim()) {
|
||||||
throw new Error('Project-scoped API keys require a project path');
|
throw new Error('Project-scoped API keys require a project path');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ async function buildManagementCliEnvForBinary(binaryPath: string): Promise<NodeJ
|
||||||
});
|
});
|
||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtensionsRuntimeAdapter {
|
export interface ExtensionsRuntimeAdapter {
|
||||||
readonly flavor: CliFlavor;
|
readonly flavor: CliFlavor;
|
||||||
buildManagementCliEnv(binaryPath: string): Promise<NodeJS.ProcessEnv>;
|
buildManagementCliEnv(binaryPath: string): Promise<NodeJS.ProcessEnv>;
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,6 @@ function isSensitiveCliFlag(flag: string): boolean {
|
||||||
const normalizedFlag = flag.toLowerCase().replace(/^--/, '').replace(/[-_]/g, '');
|
const normalizedFlag = flag.toLowerCase().replace(/^--/, '').replace(/[-_]/g, '');
|
||||||
return SENSITIVE_FLAG_NAMES.has(normalizedFlag);
|
return SENSITIVE_FLAG_NAMES.has(normalizedFlag);
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractJsonObject<T>(raw: string): T {
|
function extractJsonObject<T>(raw: string): T {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -515,8 +515,7 @@ export class ConfigManager {
|
||||||
ignoredRepositories:
|
ignoredRepositories:
|
||||||
loadedNotifications.ignoredRepositories ??
|
loadedNotifications.ignoredRepositories ??
|
||||||
DEFAULT_CONFIG.notifications.ignoredRepositories,
|
DEFAULT_CONFIG.notifications.ignoredRepositories,
|
||||||
snoozedUntil:
|
snoozedUntil: loadedNotifications.snoozedUntil ?? DEFAULT_CONFIG.notifications.snoozedUntil,
|
||||||
loadedNotifications.snoozedUntil ?? DEFAULT_CONFIG.notifications.snoozedUntil,
|
|
||||||
snoozeMinutes:
|
snoozeMinutes:
|
||||||
loadedNotifications.snoozeMinutes ?? DEFAULT_CONFIG.notifications.snoozeMinutes,
|
loadedNotifications.snoozeMinutes ?? DEFAULT_CONFIG.notifications.snoozeMinutes,
|
||||||
includeSubagentErrors:
|
includeSubagentErrors:
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@ function classifyFailedProbe(
|
||||||
|
|
||||||
export class CliProviderModelAvailabilityService {
|
export class CliProviderModelAvailabilityService {
|
||||||
private readonly cache = new Map<string, ProviderModelAvailabilityCacheEntry>();
|
private readonly cache = new Map<string, ProviderModelAvailabilityCacheEntry>();
|
||||||
private readonly queue: Array<() => void> = [];
|
private readonly queue: (() => void)[] = [];
|
||||||
private activeProbeCount = 0;
|
private activeProbeCount = 0;
|
||||||
|
|
||||||
constructor(private readonly onUpdate?: ProviderAvailabilityUpdateHandler) {}
|
constructor(private readonly onUpdate?: ProviderAvailabilityUpdateHandler) {}
|
||||||
|
|
|
||||||
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';
|
} from './cache/LeadSessionParseCache';
|
||||||
import { atomicWriteAsync } from './atomicWrite';
|
import { atomicWriteAsync } from './atomicWrite';
|
||||||
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
|
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
|
||||||
|
import { MemberActivityMetaService } from './MemberActivityMetaService';
|
||||||
import {
|
import {
|
||||||
getLiveLeadProcessMessageKey,
|
getLiveLeadProcessMessageKey,
|
||||||
mergeLiveLeadProcessMessages,
|
mergeLiveLeadProcessMessages,
|
||||||
|
|
@ -44,6 +45,7 @@ import { TeamKanbanManager } from './TeamKanbanManager';
|
||||||
import { TeamMemberResolver } from './TeamMemberResolver';
|
import { TeamMemberResolver } from './TeamMemberResolver';
|
||||||
import { TeamMemberRuntimeAdvisoryService } from './TeamMemberRuntimeAdvisoryService';
|
import { TeamMemberRuntimeAdvisoryService } from './TeamMemberRuntimeAdvisoryService';
|
||||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||||
|
import { TeamMessageFeedService } from './TeamMessageFeedService';
|
||||||
import { TeamMetaStore } from './TeamMetaStore';
|
import { TeamMetaStore } from './TeamMetaStore';
|
||||||
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||||
import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal';
|
import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal';
|
||||||
|
|
@ -63,7 +65,6 @@ import type {
|
||||||
KanbanColumnId,
|
KanbanColumnId,
|
||||||
KanbanState,
|
KanbanState,
|
||||||
MessagesPage,
|
MessagesPage,
|
||||||
ResolvedTeamMember,
|
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
TaskAttachmentMeta,
|
TaskAttachmentMeta,
|
||||||
|
|
@ -72,13 +73,14 @@ import type {
|
||||||
TaskRef,
|
TaskRef,
|
||||||
TeamConfig,
|
TeamConfig,
|
||||||
TeamCreateConfigRequest,
|
TeamCreateConfigRequest,
|
||||||
TeamData,
|
|
||||||
TeamMember,
|
TeamMember,
|
||||||
|
TeamMemberActivityMeta,
|
||||||
TeamProcess,
|
TeamProcess,
|
||||||
TeamSummary,
|
TeamSummary,
|
||||||
TeamTask,
|
TeamTask,
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
TeamTaskWithKanban,
|
TeamTaskWithKanban,
|
||||||
|
TeamViewSnapshot,
|
||||||
ToolCallMeta,
|
ToolCallMeta,
|
||||||
UpdateKanbanPatch,
|
UpdateKanbanPatch,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
@ -96,6 +98,14 @@ const TASK_MAP_YIELD_EVERY = 250;
|
||||||
const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification';
|
const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification';
|
||||||
const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
|
const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000;
|
||||||
|
|
||||||
|
function requireCanonicalMessageId(message: InboxMessage): string {
|
||||||
|
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
|
||||||
|
if (messageId.length > 0) {
|
||||||
|
return messageId;
|
||||||
|
}
|
||||||
|
throw new Error('Canonical team message is missing effective messageId');
|
||||||
|
}
|
||||||
|
|
||||||
interface EligibleTaskCommentNotification {
|
interface EligibleTaskCommentNotification {
|
||||||
key: string;
|
key: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
|
|
@ -160,6 +170,8 @@ export class TeamDataService {
|
||||||
private taskChangePresenceRepository: TaskChangePresenceRepository | null = null;
|
private taskChangePresenceRepository: TaskChangePresenceRepository | null = null;
|
||||||
private teamLogSourceTracker: TeamLogSourceTracker | null = null;
|
private teamLogSourceTracker: TeamLogSourceTracker | null = null;
|
||||||
private fileWatchReconcileDiagnostics = new Map<string, FileWatchReconcileDiagnostics>();
|
private fileWatchReconcileDiagnostics = new Map<string, FileWatchReconcileDiagnostics>();
|
||||||
|
private readonly messageFeedService: TeamMessageFeedService;
|
||||||
|
private readonly memberActivityMetaService: MemberActivityMetaService;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||||
|
|
@ -184,7 +196,15 @@ export class TeamDataService {
|
||||||
private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver(
|
private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver(
|
||||||
configReader
|
configReader
|
||||||
)
|
)
|
||||||
) {}
|
) {
|
||||||
|
this.messageFeedService = new TeamMessageFeedService({
|
||||||
|
getConfig: (teamName) => this.configReader.getConfig(teamName),
|
||||||
|
getInboxMessages: (teamName) => this.inboxReader.getMessages(teamName),
|
||||||
|
getLeadSessionMessages: (teamName, config) => this.extractLeadSessionTexts(teamName, config),
|
||||||
|
getSentMessages: (teamName) => this.sentMessagesStore.readMessages(teamName),
|
||||||
|
});
|
||||||
|
this.memberActivityMetaService = new MemberActivityMetaService(this.messageFeedService);
|
||||||
|
}
|
||||||
|
|
||||||
private getController(teamName: string): AgentTeamsController {
|
private getController(teamName: string): AgentTeamsController {
|
||||||
return this.controllerFactory(teamName);
|
return this.controllerFactory(teamName);
|
||||||
|
|
@ -623,7 +643,7 @@ export class TeamDataService {
|
||||||
await fs.promises.rm(tasksDir, { recursive: true, force: true });
|
await fs.promises.rm(tasksDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTeamData(teamName: string): Promise<TeamData> {
|
async getTeamData(teamName: string): Promise<TeamViewSnapshot> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const marks: Record<string, number> = {};
|
const marks: Record<string, number> = {};
|
||||||
const mark = (label: string): void => {
|
const mark = (label: string): void => {
|
||||||
|
|
@ -727,12 +747,6 @@ export class TeamDataService {
|
||||||
warningText: 'Inboxes failed to load',
|
warningText: 'Inboxes failed to load',
|
||||||
load: () => this.inboxReader.listInboxNames(teamName),
|
load: () => this.inboxReader.listInboxNames(teamName),
|
||||||
});
|
});
|
||||||
const sentMessagesStep = startReadStep({
|
|
||||||
label: 'sentMessages',
|
|
||||||
createFallback: () => [],
|
|
||||||
warningText: 'Sent messages failed to load',
|
|
||||||
load: () => this.sentMessagesStore.readMessages(teamName),
|
|
||||||
});
|
|
||||||
const metaMembersStep = startReadStep({
|
const metaMembersStep = startReadStep({
|
||||||
label: 'metaMembers',
|
label: 'metaMembers',
|
||||||
createFallback: () => [],
|
createFallback: () => [],
|
||||||
|
|
@ -757,40 +771,8 @@ export class TeamDataService {
|
||||||
load: () => this.taskReader.getTasks(teamName),
|
load: () => this.taskReader.getTasks(teamName),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const messagesStep = runWithConcurrencyLimit(() =>
|
const [tasksStepResult, inboxNamesStepResult, metaMembersStepResult, kanbanStateStepResult] =
|
||||||
startReadStep({
|
await Promise.all([tasksStep, inboxNamesStep, metaMembersStep, kanbanStateStep]);
|
||||||
label: 'messages',
|
|
||||||
createFallback: () => [],
|
|
||||||
warningText: 'Messages failed to load',
|
|
||||||
load: () => this.inboxReader.getMessages(teamName),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const leadTextsStep = runWithConcurrencyLimit(() =>
|
|
||||||
startReadStep({
|
|
||||||
label: 'leadTexts',
|
|
||||||
createFallback: () => [],
|
|
||||||
warningText: 'Lead session texts failed to load',
|
|
||||||
load: () => this.extractLeadSessionTexts(teamName, config),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const [
|
|
||||||
tasksStepResult,
|
|
||||||
inboxNamesStepResult,
|
|
||||||
messagesStepResult,
|
|
||||||
leadTextsStepResult,
|
|
||||||
sentMessagesStepResult,
|
|
||||||
metaMembersStepResult,
|
|
||||||
kanbanStateStepResult,
|
|
||||||
] = await Promise.all([
|
|
||||||
tasksStep,
|
|
||||||
inboxNamesStep,
|
|
||||||
messagesStep,
|
|
||||||
leadTextsStep,
|
|
||||||
sentMessagesStep,
|
|
||||||
metaMembersStep,
|
|
||||||
kanbanStateStep,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// After parallelizing the top read phase, these marks no longer represent
|
// After parallelizing the top read phase, these marks no longer represent
|
||||||
// serial stage boundaries. They now capture the actual completion time for
|
// serial stage boundaries. They now capture the actual completion time for
|
||||||
|
|
@ -798,178 +780,18 @@ export class TeamDataService {
|
||||||
// diagnostics useful without mutating marks from concurrent branches.
|
// diagnostics useful without mutating marks from concurrent branches.
|
||||||
marks.tasks = tasksStepResult.completedAt;
|
marks.tasks = tasksStepResult.completedAt;
|
||||||
marks.inboxNames = inboxNamesStepResult.completedAt;
|
marks.inboxNames = inboxNamesStepResult.completedAt;
|
||||||
marks.messages = messagesStepResult.completedAt;
|
|
||||||
marks.leadTexts = leadTextsStepResult.completedAt;
|
|
||||||
marks.sentMessages = sentMessagesStepResult.completedAt;
|
|
||||||
marks.metaMembers = metaMembersStepResult.completedAt;
|
marks.metaMembers = metaMembersStepResult.completedAt;
|
||||||
marks.kanbanState = kanbanStateStepResult.completedAt;
|
marks.kanbanState = kanbanStateStepResult.completedAt;
|
||||||
|
|
||||||
if (tasksStepResult.warning) warnings.push(tasksStepResult.warning);
|
if (tasksStepResult.warning) warnings.push(tasksStepResult.warning);
|
||||||
if (inboxNamesStepResult.warning) warnings.push(inboxNamesStepResult.warning);
|
if (inboxNamesStepResult.warning) warnings.push(inboxNamesStepResult.warning);
|
||||||
if (messagesStepResult.warning) warnings.push(messagesStepResult.warning);
|
|
||||||
if (leadTextsStepResult.warning) warnings.push(leadTextsStepResult.warning);
|
|
||||||
if (sentMessagesStepResult.warning) warnings.push(sentMessagesStepResult.warning);
|
|
||||||
if (metaMembersStepResult.warning) warnings.push(metaMembersStepResult.warning);
|
if (metaMembersStepResult.warning) warnings.push(metaMembersStepResult.warning);
|
||||||
if (kanbanStateStepResult.warning) warnings.push(kanbanStateStepResult.warning);
|
if (kanbanStateStepResult.warning) warnings.push(kanbanStateStepResult.warning);
|
||||||
|
|
||||||
const tasks: TeamTask[] = tasksStepResult.value;
|
const tasks: TeamTask[] = tasksStepResult.value;
|
||||||
const inboxNames: string[] = inboxNamesStepResult.value;
|
const inboxNames: string[] = inboxNamesStepResult.value;
|
||||||
let messages: InboxMessage[] = messagesStepResult.value;
|
|
||||||
const leadTexts: InboxMessage[] = leadTextsStepResult.value;
|
|
||||||
const sentMessages: InboxMessage[] = sentMessagesStepResult.value;
|
|
||||||
mark('postStart');
|
mark('postStart');
|
||||||
|
|
||||||
if (leadTexts.length > 0) {
|
|
||||||
messages = [...messages, ...leadTexts];
|
|
||||||
}
|
|
||||||
if (sentMessages.length > 0) {
|
|
||||||
messages = [...messages, ...sentMessages];
|
|
||||||
}
|
|
||||||
mark('mergeMessages');
|
|
||||||
|
|
||||||
// Dedup: if a lead_process message text is also present in lead_session, prefer lead_session.
|
|
||||||
// This avoids double-rendering when we persist lead process messages and later load the lead JSONL.
|
|
||||||
// Exception: lead_process messages with `to` field are captured SendMessage — never dedup those.
|
|
||||||
if (leadTexts.length > 0) {
|
|
||||||
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
|
|
||||||
const getLeadThoughtFingerprint = (
|
|
||||||
msg: Pick<InboxMessage, 'from' | 'text' | 'leadSessionId'>
|
|
||||||
) => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`;
|
|
||||||
const leadSessionFingerprints = new Set<string>();
|
|
||||||
for (const msg of leadTexts) {
|
|
||||||
if (msg.source !== 'lead_session') continue;
|
|
||||||
leadSessionFingerprints.add(getLeadThoughtFingerprint(msg));
|
|
||||||
}
|
|
||||||
messages = messages.filter((m) => {
|
|
||||||
if (m.source !== 'lead_process') return true;
|
|
||||||
// Captured SendMessage messages (with recipient) are real messages — never dedup
|
|
||||||
if (m.to) return true;
|
|
||||||
const fp = getLeadThoughtFingerprint(m);
|
|
||||||
return !leadSessionFingerprints.has(fp);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
mark('dedupLeadTexts');
|
|
||||||
|
|
||||||
// Dedup exact message copies that can appear as both live lead_process rows and
|
|
||||||
// their persisted inbox/sent-message counterpart. If the messageId is identical,
|
|
||||||
// keep a single row so the UI does not show the same SendMessage twice
|
|
||||||
// (for example "LIVE" plus the stored copy).
|
|
||||||
const duplicateMessageIds = new Set<string>();
|
|
||||||
const messageIdCounts = new Map<string, number>();
|
|
||||||
for (const msg of messages) {
|
|
||||||
const id = typeof msg.messageId === 'string' ? msg.messageId.trim() : '';
|
|
||||||
if (!id) continue;
|
|
||||||
const nextCount = (messageIdCounts.get(id) ?? 0) + 1;
|
|
||||||
messageIdCounts.set(id, nextCount);
|
|
||||||
if (nextCount > 1) duplicateMessageIds.add(id);
|
|
||||||
}
|
|
||||||
if (duplicateMessageIds.size > 0) {
|
|
||||||
const choosePreferredMessage = (
|
|
||||||
current: InboxMessage,
|
|
||||||
candidate: InboxMessage
|
|
||||||
): InboxMessage => {
|
|
||||||
const score = (msg: InboxMessage): number => {
|
|
||||||
let value = 0;
|
|
||||||
if (msg.source !== 'lead_process') value += 4;
|
|
||||||
if (msg.read === false) value += 2;
|
|
||||||
if (msg.relayOfMessageId) value += 1;
|
|
||||||
if (msg.summary) value += 1;
|
|
||||||
if (msg.to) value += 1;
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
const currentScore = score(current);
|
|
||||||
const candidateScore = score(candidate);
|
|
||||||
if (candidateScore !== currentScore) {
|
|
||||||
return candidateScore > currentScore ? candidate : current;
|
|
||||||
}
|
|
||||||
const currentTs = Date.parse(current.timestamp);
|
|
||||||
const candidateTs = Date.parse(candidate.timestamp);
|
|
||||||
if (
|
|
||||||
Number.isFinite(currentTs) &&
|
|
||||||
Number.isFinite(candidateTs) &&
|
|
||||||
candidateTs !== currentTs
|
|
||||||
) {
|
|
||||||
return candidateTs > currentTs ? candidate : current;
|
|
||||||
}
|
|
||||||
return current;
|
|
||||||
};
|
|
||||||
|
|
||||||
const dedupedById = new Map<string, InboxMessage>();
|
|
||||||
const dedupedWithoutId: InboxMessage[] = [];
|
|
||||||
for (const msg of messages) {
|
|
||||||
const id = typeof msg.messageId === 'string' ? msg.messageId.trim() : '';
|
|
||||||
if (!id) {
|
|
||||||
dedupedWithoutId.push(msg);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const existing = dedupedById.get(id);
|
|
||||||
if (!existing) {
|
|
||||||
dedupedById.set(id, msg);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
dedupedById.set(id, choosePreferredMessage(existing, msg));
|
|
||||||
}
|
|
||||||
messages = [...dedupedWithoutId, ...dedupedById.values()];
|
|
||||||
}
|
|
||||||
mark('dedupMessageIds');
|
|
||||||
|
|
||||||
messages = this.linkPassiveUserReplySummaries(messages);
|
|
||||||
mark('linkPassiveUserReplySummaries');
|
|
||||||
|
|
||||||
// Enrich inbox messages without leadSessionId by assigning the nearest neighbor's
|
|
||||||
// session ID (by timestamp). This avoids the old forward-only propagation bug.
|
|
||||||
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
|
|
||||||
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
|
||||||
|
|
||||||
const anchors: { index: number; time: number; sessionId: string }[] = [];
|
|
||||||
for (let i = 0; i < messages.length; i++) {
|
|
||||||
if (messages[i].leadSessionId) {
|
|
||||||
anchors.push({
|
|
||||||
index: i,
|
|
||||||
time: Date.parse(messages[i].timestamp),
|
|
||||||
sessionId: messages[i].leadSessionId!,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (anchors.length > 0) {
|
|
||||||
let anchorIdx = 0;
|
|
||||||
for (let i = 0; i < messages.length; i++) {
|
|
||||||
if (messages[i].leadSessionId) {
|
|
||||||
while (anchorIdx < anchors.length - 1 && anchors[anchorIdx].index < i) {
|
|
||||||
anchorIdx++;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const msgTime = Date.parse(messages[i].timestamp);
|
|
||||||
let bestAnchor = anchors[0];
|
|
||||||
let bestDist = Math.abs(msgTime - bestAnchor.time);
|
|
||||||
for (const anchor of anchors) {
|
|
||||||
const dist = Math.abs(msgTime - anchor.time);
|
|
||||||
if (dist < bestDist) {
|
|
||||||
bestDist = dist;
|
|
||||||
bestAnchor = anchor;
|
|
||||||
} else if (dist > bestDist && anchor.time > msgTime) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
messages[i].leadSessionId = bestAnchor.sessionId;
|
|
||||||
}
|
|
||||||
} else if (config.leadSessionId) {
|
|
||||||
for (const msg of messages) {
|
|
||||||
msg.leadSessionId = config.leadSessionId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mark('attachLeadSessionIds');
|
|
||||||
|
|
||||||
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
|
||||||
this.annotateSlashCommandResponses(messages);
|
|
||||||
|
|
||||||
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
|
||||||
mark('normalizeMessages');
|
|
||||||
|
|
||||||
const metaMembers: TeamConfig['members'] = metaMembersStepResult.value;
|
const metaMembers: TeamConfig['members'] = metaMembersStepResult.value;
|
||||||
const kanbanState: KanbanState = kanbanStateStepResult.value;
|
const kanbanState: KanbanState = kanbanStateStepResult.value;
|
||||||
|
|
||||||
|
|
@ -1001,8 +823,7 @@ export class TeamDataService {
|
||||||
config,
|
config,
|
||||||
metaMembers,
|
metaMembers,
|
||||||
inboxNames,
|
inboxNames,
|
||||||
tasksWithKanban,
|
tasksWithKanban
|
||||||
messages
|
|
||||||
);
|
);
|
||||||
mark('resolveMembers');
|
mark('resolveMembers');
|
||||||
|
|
||||||
|
|
@ -1037,30 +858,13 @@ export class TeamDataService {
|
||||||
|
|
||||||
const totalMs = Date.now() - startedAt;
|
const totalMs = Date.now() - startedAt;
|
||||||
if (totalMs >= 1500) {
|
if (totalMs >= 1500) {
|
||||||
const counts = `counts=tasks:${tasks.length},messages:${messages.length},inboxNames:${inboxNames.length},leadTexts:${leadTexts.length},sent:${sentMessages.length},members:${members.length},processes:${processes.length}`;
|
const counts = `counts=tasks:${tasks.length},inboxNames:${inboxNames.length},members:${members.length},processes:${processes.length}`;
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`getTeamData team=${teamName} slow total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince(
|
`getTeamData team=${teamName} slow total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince(
|
||||||
'inboxNames'
|
'inboxNames'
|
||||||
)} messages=${msSince('messages')} leadTexts=${msSince('leadTexts')} sent=${msSince(
|
|
||||||
'sentMessages'
|
|
||||||
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince(
|
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince(
|
||||||
'kanbanGc'
|
'kanbanGc'
|
||||||
)} post=${msBetween(
|
)} post=${msBetween('postStart', 'attachKanban')}/loadPresenceIndex=${msBetween(
|
||||||
'postStart',
|
|
||||||
'mergeMessages'
|
|
||||||
)}/dedupLead=${msBetween('mergeMessages', 'dedupLeadTexts')}/dedupIds=${msBetween(
|
|
||||||
'dedupLeadTexts',
|
|
||||||
'dedupMessageIds'
|
|
||||||
)}/attachLeadSession=${msBetween(
|
|
||||||
'dedupMessageIds',
|
|
||||||
'attachLeadSessionIds'
|
|
||||||
)}/normalizeMessages=${msBetween(
|
|
||||||
'attachLeadSessionIds',
|
|
||||||
'normalizeMessages'
|
|
||||||
)}/attachKanban=${msBetween(
|
|
||||||
'normalizeMessages',
|
|
||||||
'attachKanban'
|
|
||||||
)}/loadPresenceIndex=${msBetween(
|
|
||||||
'attachKanban',
|
'attachKanban',
|
||||||
'loadPresenceIndex'
|
'loadPresenceIndex'
|
||||||
)}/changePresence=${msBetween(
|
)}/changePresence=${msBetween(
|
||||||
|
|
@ -1089,21 +893,14 @@ export class TeamDataService {
|
||||||
this.processHealthTeams.delete(teamName);
|
this.processHealthTeams.delete(teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap messages to keep IPC payloads small. Full history is available
|
|
||||||
// via the paginated getMessagesPage() API. We still include a small
|
|
||||||
// batch here for backward compatibility (notifications, dedup, etc.).
|
|
||||||
const MAX_RETURN_MESSAGES = 50;
|
|
||||||
const cappedMessages =
|
|
||||||
messages.length > MAX_RETURN_MESSAGES ? messages.slice(0, MAX_RETURN_MESSAGES) : messages;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
teamName,
|
teamName,
|
||||||
config,
|
config,
|
||||||
tasks: tasksWithKanban,
|
tasks: tasksWithKanban,
|
||||||
members,
|
members,
|
||||||
messages: cappedMessages,
|
|
||||||
kanbanState,
|
kanbanState,
|
||||||
processes,
|
processes,
|
||||||
|
isAlive: hasAlive,
|
||||||
warnings: warnings.length > 0 ? warnings : undefined,
|
warnings: warnings.length > 0 ? warnings : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -1114,112 +911,35 @@ export class TeamDataService {
|
||||||
*/
|
*/
|
||||||
async getMessagesPage(
|
async getMessagesPage(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
options: { beforeTimestamp?: string; limit: number; liveMessages?: InboxMessage[] }
|
options: { cursor?: string | null; limit: number; liveMessages?: InboxMessage[] }
|
||||||
): Promise<MessagesPage> {
|
): Promise<MessagesPage> {
|
||||||
const config = await this.configReader.getConfig(teamName);
|
const feed = await this.messageFeedService.getFeed(teamName);
|
||||||
if (!config) {
|
const newestDurableMessages = feed.messages;
|
||||||
return { messages: [], nextCursor: null, hasMore: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect all messages from the same sources as getTeamData
|
|
||||||
let messages: InboxMessage[] = [];
|
|
||||||
|
|
||||||
const [inboxMessages, leadTexts, sentMessages] = await Promise.all([
|
|
||||||
this.inboxReader.getMessages(teamName).catch(() => [] as InboxMessage[]),
|
|
||||||
this.extractLeadSessionTexts(teamName, config).catch(() => [] as InboxMessage[]),
|
|
||||||
this.sentMessagesStore.readMessages(teamName).catch(() => [] as InboxMessage[]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
messages = [...inboxMessages, ...leadTexts, ...sentMessages];
|
|
||||||
|
|
||||||
// Dedup lead_session vs lead_process (same logic as getTeamData)
|
|
||||||
if (leadTexts.length > 0) {
|
|
||||||
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
|
|
||||||
const getFingerprint = (msg: Pick<InboxMessage, 'from' | 'text' | 'leadSessionId'>) =>
|
|
||||||
`${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`;
|
|
||||||
const leadSessionFingerprints = new Set<string>();
|
|
||||||
for (const msg of leadTexts) {
|
|
||||||
if (msg.source === 'lead_session') leadSessionFingerprints.add(getFingerprint(msg));
|
|
||||||
}
|
|
||||||
messages = messages.filter((m) => {
|
|
||||||
if (m.source !== 'lead_process') return true;
|
|
||||||
if (m.to) return true;
|
|
||||||
return !leadSessionFingerprints.has(getFingerprint(m));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enrich: propagate leadSessionId to messages missing it (same as getTeamData)
|
|
||||||
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
|
|
||||||
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
|
||||||
const anchors: { time: number; sessionId: string }[] = [];
|
|
||||||
for (const msg of messages) {
|
|
||||||
if (msg.leadSessionId) {
|
|
||||||
anchors.push({ time: Date.parse(msg.timestamp), sessionId: msg.leadSessionId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (anchors.length > 0) {
|
|
||||||
for (const msg of messages) {
|
|
||||||
if (msg.leadSessionId) continue;
|
|
||||||
const msgTime = Date.parse(msg.timestamp);
|
|
||||||
let best = anchors[0];
|
|
||||||
let bestDist = Math.abs(msgTime - best.time);
|
|
||||||
for (const a of anchors) {
|
|
||||||
const dist = Math.abs(msgTime - a.time);
|
|
||||||
if (dist < bestDist) {
|
|
||||||
bestDist = dist;
|
|
||||||
best = a;
|
|
||||||
} else if (dist > bestDist && a.time > msgTime) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
msg.leadSessionId = best.sessionId;
|
|
||||||
}
|
|
||||||
} else if (config.leadSessionId) {
|
|
||||||
for (const msg of messages) {
|
|
||||||
msg.leadSessionId = config.leadSessionId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enrich: annotate slash command responses
|
|
||||||
this.annotateSlashCommandResponses(messages);
|
|
||||||
|
|
||||||
// Sort newest-first, with stable tie-breaker by messageId
|
|
||||||
messages.sort((a, b) => {
|
|
||||||
const diff = Date.parse(b.timestamp) - Date.parse(a.timestamp);
|
|
||||||
if (diff !== 0) return diff;
|
|
||||||
return (a.messageId ?? '').localeCompare(b.messageId ?? '');
|
|
||||||
});
|
|
||||||
|
|
||||||
const newestDurableMessages = messages;
|
|
||||||
const durableMessageIndexByKey = new Map(
|
const durableMessageIndexByKey = new Map(
|
||||||
newestDurableMessages.map((message, index) => [getLiveLeadProcessMessageKey(message), index])
|
newestDurableMessages.map((message, index) => [getLiveLeadProcessMessageKey(message), index])
|
||||||
);
|
);
|
||||||
|
let messages = newestDurableMessages;
|
||||||
|
|
||||||
// Apply cursor filter. Cursor format: "timestamp|messageId" (compound)
|
if (options.cursor) {
|
||||||
// to handle multiple messages sharing the same timestamp.
|
const [cursorTs, cursorId] = options.cursor.split('|');
|
||||||
if (options.beforeTimestamp) {
|
|
||||||
const [cursorTs, cursorId] = options.beforeTimestamp.split('|');
|
|
||||||
const cursorMs = Date.parse(cursorTs);
|
const cursorMs = Date.parse(cursorTs);
|
||||||
messages = messages.filter((m) => {
|
messages = messages.filter((m) => {
|
||||||
const ms = Date.parse(m.timestamp);
|
const ms = Date.parse(m.timestamp);
|
||||||
if (ms < cursorMs) return true;
|
if (ms < cursorMs) return true;
|
||||||
if (ms > cursorMs) return false;
|
if (ms > cursorMs) return false;
|
||||||
// Same timestamp — use messageId tie-breaker
|
|
||||||
if (!cursorId) return false;
|
if (!cursorId) return false;
|
||||||
return (m.messageId ?? '').localeCompare(cursorId) > 0;
|
return requireCanonicalMessageId(m).localeCompare(cursorId) > 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paginate
|
|
||||||
const hasMore = messages.length > options.limit;
|
const hasMore = messages.length > options.limit;
|
||||||
const page = messages.slice(0, options.limit);
|
const page = messages.slice(0, options.limit);
|
||||||
const lastMsg = page[page.length - 1];
|
const lastMsg = page[page.length - 1];
|
||||||
const nextCursor =
|
const nextCursor =
|
||||||
hasMore && lastMsg ? `${lastMsg.timestamp}|${lastMsg.messageId ?? ''}` : null;
|
hasMore && lastMsg ? `${lastMsg.timestamp}|${requireCanonicalMessageId(lastMsg)}` : null;
|
||||||
|
|
||||||
if (options.beforeTimestamp || !options.liveMessages?.length) {
|
if (options.cursor || !options.liveMessages?.length) {
|
||||||
return { messages: page, nextCursor, hasMore };
|
return { messages: page, nextCursor, hasMore, feedRevision: feed.feedRevision };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge live lead thoughts against the full durable newest-page history so we do not
|
// Merge live lead thoughts against the full durable newest-page history so we do not
|
||||||
|
|
@ -1230,7 +950,12 @@ export class TeamDataService {
|
||||||
).slice(0, options.limit);
|
).slice(0, options.limit);
|
||||||
|
|
||||||
if (displayMessages.length === 0) {
|
if (displayMessages.length === 0) {
|
||||||
return { messages: displayMessages, nextCursor: null, hasMore: false };
|
return {
|
||||||
|
messages: displayMessages,
|
||||||
|
nextCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
feedRevision: feed.feedRevision,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastDurableDisplayed: InboxMessage | null = null;
|
let lastDurableDisplayed: InboxMessage | null = null;
|
||||||
|
|
@ -1251,6 +976,7 @@ export class TeamDataService {
|
||||||
? `${boundary.timestamp}|${boundary.messageId ?? ''}`
|
? `${boundary.timestamp}|${boundary.messageId ?? ''}`
|
||||||
: null,
|
: null,
|
||||||
hasMore: newestDurableMessages.length > 0,
|
hasMore: newestDurableMessages.length > 0,
|
||||||
|
feedRevision: feed.feedRevision,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1265,15 +991,31 @@ export class TeamDataService {
|
||||||
? `${lastDurableDisplayed.timestamp}|${lastDurableDisplayed.messageId ?? ''}`
|
? `${lastDurableDisplayed.timestamp}|${lastDurableDisplayed.messageId ?? ''}`
|
||||||
: null,
|
: null,
|
||||||
hasMore: durableHasMore,
|
hasMore: durableHasMore,
|
||||||
|
feedRevision: feed.feedRevision,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMessageFeed(
|
||||||
|
teamName: string
|
||||||
|
): Promise<{ teamName: string; feedRevision: string; messages: InboxMessage[] }> {
|
||||||
|
return this.messageFeedService.getFeed(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMemberActivityMeta(teamName: string): Promise<TeamMemberActivityMeta> {
|
||||||
|
return this.memberActivityMetaService.getMeta(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateMessageFeed(teamName: string): void {
|
||||||
|
this.messageFeedService.invalidate(teamName);
|
||||||
|
this.memberActivityMetaService.invalidate(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enriches members with gitBranch when their cwd differs from the lead's.
|
* Enriches members with gitBranch when their cwd differs from the lead's.
|
||||||
* Mutates members in-place for efficiency (called right after resolveMembers).
|
* Mutates members in-place for efficiency (called right after resolveMembers).
|
||||||
*/
|
*/
|
||||||
private async enrichMemberBranches(
|
private async enrichMemberBranches(
|
||||||
members: ResolvedTeamMember[],
|
members: TeamViewSnapshot['members'],
|
||||||
config: TeamConfig
|
config: TeamConfig
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const leadEntry = config.members?.find((member) => isLeadMember(member));
|
const leadEntry = config.members?.find((member) => isLeadMember(member));
|
||||||
|
|
@ -1945,7 +1687,7 @@ export class TeamDataService {
|
||||||
slashCommand: slashCommandMeta,
|
slashCommand: slashCommandMeta,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return this.getController(teamName).messages.sendMessage({
|
const result = this.getController(teamName).messages.sendMessage({
|
||||||
member: enrichedRequest.member,
|
member: enrichedRequest.member,
|
||||||
from: enrichedRequest.from,
|
from: enrichedRequest.from,
|
||||||
text: enrichedRequest.text,
|
text: enrichedRequest.text,
|
||||||
|
|
@ -1966,6 +1708,8 @@ export class TeamDataService {
|
||||||
leadSessionId: enrichedRequest.leadSessionId,
|
leadSessionId: enrichedRequest.leadSessionId,
|
||||||
attachments: enrichedRequest.attachments,
|
attachments: enrichedRequest.attachments,
|
||||||
}) as SendMessageResult;
|
}) as SendMessageResult;
|
||||||
|
this.invalidateMessageFeed(teamName);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveLeadNameFromConfig(config: TeamConfig | null): string {
|
private resolveLeadNameFromConfig(config: TeamConfig | null): string {
|
||||||
|
|
@ -2522,6 +2266,23 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getTeamNotificationContext(teamName: string): Promise<{
|
||||||
|
displayName: string;
|
||||||
|
projectPath?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const config = await this.configReader.getConfig(teamName);
|
||||||
|
const displayName = config?.name?.trim() || teamName;
|
||||||
|
const projectPath =
|
||||||
|
typeof config?.projectPath === 'string' && config.projectPath.trim().length > 0
|
||||||
|
? config.projectPath
|
||||||
|
: undefined;
|
||||||
|
return { displayName, projectPath };
|
||||||
|
} catch {
|
||||||
|
return { displayName: teamName };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async requestReview(teamName: string, taskId: string): Promise<void> {
|
async requestReview(teamName: string, taskId: string): Promise<void> {
|
||||||
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||||
this.getController(teamName).review.requestReview(taskId, {
|
this.getController(teamName).review.requestReview(taskId, {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,12 @@ import { Worker } from 'node:worker_threads';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import type { TeamDataWorkerRequest, TeamDataWorkerResponse } from './teamDataWorkerTypes';
|
import type { TeamDataWorkerRequest, TeamDataWorkerResponse } from './teamDataWorkerTypes';
|
||||||
import type { MemberLogSummary, TeamData } from '@shared/types';
|
import type {
|
||||||
|
MemberLogSummary,
|
||||||
|
MessagesPage,
|
||||||
|
TeamMemberActivityMeta,
|
||||||
|
TeamViewSnapshot,
|
||||||
|
} from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('Service:TeamDataWorkerClient');
|
const logger = createLogger('Service:TeamDataWorkerClient');
|
||||||
const WORKER_CALL_TIMEOUT_MS = 30_000;
|
const WORKER_CALL_TIMEOUT_MS = 30_000;
|
||||||
|
|
@ -25,16 +30,20 @@ function makeId(): string {
|
||||||
return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`;
|
return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveWorkerPath(): string | null {
|
function getWorkerPathCandidates(): string[] {
|
||||||
const baseDir =
|
const baseDir =
|
||||||
typeof __dirname === 'string' && __dirname.length > 0
|
typeof __dirname === 'string' && __dirname.length > 0
|
||||||
? __dirname
|
? __dirname
|
||||||
: path.dirname(fileURLToPath(import.meta.url));
|
: path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
const candidates = [
|
return [
|
||||||
path.join(baseDir, 'team-data-worker.cjs'),
|
path.join(baseDir, 'team-data-worker.cjs'),
|
||||||
path.join(process.cwd(), 'dist-electron', 'main', 'team-data-worker.cjs'),
|
path.join(process.cwd(), 'dist-electron', 'main', 'team-data-worker.cjs'),
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWorkerPath(): string | null {
|
||||||
|
const candidates = getWorkerPathCandidates();
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -75,7 +84,9 @@ export class TeamDataWorkerClient {
|
||||||
isAvailable(): boolean {
|
isAvailable(): boolean {
|
||||||
if (!this.workerPath && !this.warnedUnavailable) {
|
if (!this.workerPath && !this.warnedUnavailable) {
|
||||||
this.warnedUnavailable = true;
|
this.warnedUnavailable = true;
|
||||||
logger.debug('team-data-worker not found; falling back to main-thread execution');
|
logger.warn(
|
||||||
|
`team-data-worker not found; heavy team data paths may fall back to main-thread execution. expectedOneOf=${getWorkerPathCandidates().join(',')}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return this.workerPath !== null;
|
return this.workerPath !== null;
|
||||||
}
|
}
|
||||||
|
|
@ -144,9 +155,22 @@ export class TeamDataWorkerClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTeamData(teamName: string): Promise<TeamData> {
|
async getTeamData(teamName: string): Promise<TeamViewSnapshot> {
|
||||||
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
|
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
|
||||||
return this.call('getTeamData', { teamName }) as Promise<TeamData>;
|
return this.call('getTeamData', { teamName }) as Promise<TeamViewSnapshot>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMessagesPage(
|
||||||
|
teamName: string,
|
||||||
|
options: { cursor?: string | null; limit: number }
|
||||||
|
): Promise<MessagesPage> {
|
||||||
|
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
|
||||||
|
return this.call('getMessagesPage', { teamName, options }) as Promise<MessagesPage>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMemberActivityMeta(teamName: string): Promise<TeamMemberActivityMeta> {
|
||||||
|
if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName');
|
||||||
|
return this.call('getMemberActivityMeta', { teamName }) as Promise<TeamMemberActivityMeta>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findLogsForTask(
|
async findLogsForTask(
|
||||||
|
|
|
||||||
|
|
@ -223,8 +223,7 @@ export function createPersistedLaunchSnapshot(params: {
|
||||||
for (const name of expectedMembers) {
|
for (const name of expectedMembers) {
|
||||||
const member = members[name];
|
const member = members[name];
|
||||||
if (
|
if (
|
||||||
member &&
|
member?.launchState === 'starting' &&
|
||||||
member.launchState === 'starting' &&
|
|
||||||
!member.agentToolAccepted &&
|
!member.agentToolAccepted &&
|
||||||
!member.runtimeAlive &&
|
!member.runtimeAlive &&
|
||||||
!member.bootstrapConfirmed &&
|
!member.bootstrapConfirmed &&
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,11 @@
|
||||||
|
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||||
import {
|
import {
|
||||||
createCliAutoSuffixNameGuard,
|
createCliAutoSuffixNameGuard,
|
||||||
createCliProvisionerNameGuard,
|
createCliProvisionerNameGuard,
|
||||||
} from '@shared/utils/teamMemberName';
|
} from '@shared/utils/teamMemberName';
|
||||||
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
||||||
|
|
||||||
import type {
|
import type { TeamConfig, TeamMember, TeamMemberSnapshot, TeamTaskWithKanban } from '@shared/types';
|
||||||
InboxMessage,
|
|
||||||
MemberStatus,
|
|
||||||
ResolvedTeamMember,
|
|
||||||
TeamConfig,
|
|
||||||
TeamMember,
|
|
||||||
TeamTaskWithKanban,
|
|
||||||
} from '@shared/types';
|
|
||||||
|
|
||||||
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||||
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
|
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
|
||||||
|
|
@ -63,9 +57,8 @@ export class TeamMemberResolver {
|
||||||
config: TeamConfig,
|
config: TeamConfig,
|
||||||
metaMembers: TeamConfig['members'],
|
metaMembers: TeamConfig['members'],
|
||||||
inboxNames: string[],
|
inboxNames: string[],
|
||||||
tasks: TeamTaskWithKanban[],
|
tasks: TeamTaskWithKanban[]
|
||||||
messages: InboxMessage[]
|
): TeamMemberSnapshot[] {
|
||||||
): ResolvedTeamMember[] {
|
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
const explicitNames = new Set<string>();
|
const explicitNames = new Set<string>();
|
||||||
const seenNames = new Set<string>();
|
const seenNames = new Set<string>();
|
||||||
|
|
@ -216,7 +209,7 @@ export class TeamMemberResolver {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const members: ResolvedTeamMember[] = [];
|
const members: TeamMemberSnapshot[] = [];
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
const ownedTasks = tasks.filter((task) => task.owner === name);
|
const ownedTasks = tasks.filter((task) => task.owner === name);
|
||||||
const currentTask =
|
const currentTask =
|
||||||
|
|
@ -226,21 +219,15 @@ export class TeamMemberResolver {
|
||||||
task.reviewState !== 'approved' &&
|
task.reviewState !== 'approved' &&
|
||||||
task.kanbanColumn !== 'approved'
|
task.kanbanColumn !== 'approved'
|
||||||
) ?? null;
|
) ?? null;
|
||||||
const memberMessages = messages.filter((message) => message.from === name);
|
|
||||||
const latestMessage = memberMessages[0] ?? null;
|
|
||||||
const status = this.resolveStatus(latestMessage, currentTask !== null);
|
|
||||||
const configMember = configMemberMap.get(name);
|
const configMember = configMemberMap.get(name);
|
||||||
const metaMember = metaMemberMap.get(name);
|
const metaMember = metaMemberMap.get(name);
|
||||||
const agentId = configMember?.agentId ?? metaMember?.agentId;
|
const agentId = configMember?.agentId ?? metaMember?.agentId;
|
||||||
members.push({
|
members.push({
|
||||||
name,
|
name,
|
||||||
agentId,
|
agentId,
|
||||||
status,
|
|
||||||
currentTaskId: currentTask?.id ?? null,
|
currentTaskId: currentTask?.id ?? null,
|
||||||
taskCount: ownedTasks.length,
|
taskCount: ownedTasks.length,
|
||||||
messageCount: memberMessages.length,
|
color: configMember?.color ?? metaMember?.color ?? getMemberColorByName(name),
|
||||||
lastActiveAt: latestMessage?.timestamp ?? null,
|
|
||||||
color: latestMessage?.color ?? configMember?.color ?? metaMember?.color,
|
|
||||||
agentType: configMember?.agentType ?? metaMember?.agentType,
|
agentType: configMember?.agentType ?? metaMember?.agentType,
|
||||||
role: configMember?.role ?? metaMember?.role,
|
role: configMember?.role ?? metaMember?.role,
|
||||||
workflow: configMember?.workflow ?? metaMember?.workflow,
|
workflow: configMember?.workflow ?? metaMember?.workflow,
|
||||||
|
|
@ -277,45 +264,4 @@ export class TeamMemberResolver {
|
||||||
});
|
});
|
||||||
return members;
|
return members;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveStatus(message: InboxMessage | null, hasActiveTask: boolean): MemberStatus {
|
|
||||||
if (!message) {
|
|
||||||
// Member exists in config but has no messages yet —
|
|
||||||
// if they own an in_progress task they're clearly active, otherwise idle
|
|
||||||
return hasActiveTask ? 'active' : 'idle';
|
|
||||||
}
|
|
||||||
|
|
||||||
const structured = this.parseStructuredMessage(message.text);
|
|
||||||
if (structured) {
|
|
||||||
const typed = structured as { type?: string; approve?: boolean; approved?: boolean };
|
|
||||||
if (
|
|
||||||
(typed.type === 'shutdown_response' &&
|
|
||||||
(typed.approve === true || typed.approved === true)) ||
|
|
||||||
typed.type === 'shutdown_approved'
|
|
||||||
) {
|
|
||||||
return 'terminated';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ageMs = Date.now() - Date.parse(message.timestamp);
|
|
||||||
if (Number.isNaN(ageMs)) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
if (ageMs < 5 * 60 * 1000) {
|
|
||||||
return 'active';
|
|
||||||
}
|
|
||||||
return 'idle';
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseStructuredMessage(text: string): Record<string, unknown> | null {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(text) as unknown;
|
|
||||||
if (parsed && typeof parsed === 'object') {
|
|
||||||
return parsed as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore plain text.
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 { atomicWriteAsync } from './atomicWrite';
|
||||||
import { peekAutoResumeService } from './AutoResumeService';
|
import { peekAutoResumeService } from './AutoResumeService';
|
||||||
import { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
|
import { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
|
||||||
|
import { getConfiguredCliCommandLabel } from './cliFlavor';
|
||||||
import { withFileLock } from './fileLock';
|
import { withFileLock } from './fileLock';
|
||||||
import {
|
import {
|
||||||
type ClassifiedMainProcessIdle,
|
type ClassifiedMainProcessIdle,
|
||||||
|
|
@ -721,6 +722,8 @@ interface ProvisioningRun {
|
||||||
>;
|
>;
|
||||||
/** Agent tool_use_id -> teammate name for persistent teammate spawns. */
|
/** Agent tool_use_id -> teammate name for persistent teammate spawns. */
|
||||||
memberSpawnToolUseIds: Map<string, string>;
|
memberSpawnToolUseIds: Map<string, string>;
|
||||||
|
/** Per-member latest processed lead-inbox bootstrap signal cursor for the current live run. */
|
||||||
|
memberSpawnLeadInboxCursorByMember: Map<string, MemberSpawnInboxCursor>;
|
||||||
/** Highest accepted deterministic bootstrap event sequence for this run. */
|
/** Highest accepted deterministic bootstrap event sequence for this run. */
|
||||||
lastDeterministicBootstrapSeq: number;
|
lastDeterministicBootstrapSeq: number;
|
||||||
/** Throttles config/inbox audit work triggered by frequent status polling. */
|
/** Throttles config/inbox audit work triggered by frequent status polling. */
|
||||||
|
|
@ -839,6 +842,75 @@ function matchesTeamMemberIdentity(leftName: string, rightName: string): boolean
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MemberSpawnInboxCursor {
|
||||||
|
timestamp: string;
|
||||||
|
messageId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LeadInboxMemberSpawnMessage = InboxMessage & { messageId: string };
|
||||||
|
|
||||||
|
function compareMemberSpawnInboxCursor(
|
||||||
|
left: MemberSpawnInboxCursor,
|
||||||
|
right: MemberSpawnInboxCursor
|
||||||
|
): number {
|
||||||
|
const leftMs = Date.parse(left.timestamp);
|
||||||
|
const rightMs = Date.parse(right.timestamp);
|
||||||
|
const leftValid = Number.isFinite(leftMs);
|
||||||
|
const rightValid = Number.isFinite(rightMs);
|
||||||
|
|
||||||
|
if (leftValid && rightValid && leftMs !== rightMs) {
|
||||||
|
return leftMs - rightMs;
|
||||||
|
}
|
||||||
|
if (leftValid !== rightValid) {
|
||||||
|
return leftValid ? -1 : 1;
|
||||||
|
}
|
||||||
|
return left.messageId.localeCompare(right.messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMemberSpawnInboxCursor(
|
||||||
|
message: Pick<InboxMessage, 'timestamp' | 'messageId'>
|
||||||
|
): MemberSpawnInboxCursor | null {
|
||||||
|
const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
|
||||||
|
if (!messageId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
messageId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxMemberSpawnInboxCursor(
|
||||||
|
left: MemberSpawnInboxCursor | undefined,
|
||||||
|
right: MemberSpawnInboxCursor
|
||||||
|
): MemberSpawnInboxCursor {
|
||||||
|
if (!left) {
|
||||||
|
return right;
|
||||||
|
}
|
||||||
|
return compareMemberSpawnInboxCursor(left, right) >= 0 ? left : right;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMemberSpawnHeartbeatTimestampNewer(
|
||||||
|
previous: string | undefined,
|
||||||
|
incoming: string | undefined
|
||||||
|
): boolean {
|
||||||
|
const normalizedIncoming = incoming?.trim();
|
||||||
|
if (!normalizedIncoming) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const normalizedPrevious = previous?.trim();
|
||||||
|
if (!normalizedPrevious) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousMs = Date.parse(normalizedPrevious);
|
||||||
|
const incomingMs = Date.parse(normalizedIncoming);
|
||||||
|
if (Number.isFinite(previousMs) && Number.isFinite(incomingMs)) {
|
||||||
|
return incomingMs > previousMs;
|
||||||
|
}
|
||||||
|
return normalizedIncoming > normalizedPrevious;
|
||||||
|
}
|
||||||
|
|
||||||
function stripWrappedCliFlagValue(raw: string | undefined): string | undefined {
|
function stripWrappedCliFlagValue(raw: string | undefined): string | undefined {
|
||||||
const trimmed = raw?.trim();
|
const trimmed = raw?.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
|
|
@ -1339,6 +1411,8 @@ ${buildCanonicalSendMessageExample({ to: leadName, summary: 'short update', mess
|
||||||
After member_briefing succeeds:
|
After member_briefing succeeds:
|
||||||
- Do NOT send a "ready", "online", "status accepted", or other acknowledgement-only message just to confirm you started successfully.
|
- Do NOT send a "ready", "online", "status accepted", or other acknowledgement-only message just to confirm you started successfully.
|
||||||
- If bootstrap succeeded and you have no task yet, stay silent and wait for task assignments.
|
- If bootstrap succeeded and you have no task yet, stay silent and wait for task assignments.
|
||||||
|
- If bootstrap succeeded and you have no task, produce ZERO assistant text for that turn and end it immediately after the successful tool result.
|
||||||
|
- Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after bootstrap.
|
||||||
- Only SendMessage the lead after bootstrap when there is a real blocker, a failed bootstrap, an explicit question, an urgent coordination need, or a completed task result to report.
|
- Only SendMessage the lead after bootstrap when there is a real blocker, a failed bootstrap, an explicit question, an urgent coordination need, or a completed task result to report.
|
||||||
- Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence.
|
- Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence.
|
||||||
- When you later receive work or reconnect after a restart, use task_briefing as your compact queue view. Use task_get when you need the full task context before starting a pending/needsFix task or when the in_progress briefing details are not enough.
|
- When you later receive work or reconnect after a restart, use task_briefing as your compact queue view. Use task_get when you need the full task context before starting a pending/needsFix task or when the in_progress briefing details are not enough.
|
||||||
|
|
@ -1409,6 +1483,8 @@ ${actionModeProtocol}
|
||||||
After member_briefing succeeds:
|
After member_briefing succeeds:
|
||||||
- Do NOT send a "ready", "online", "status accepted", or other acknowledgement-only message just to confirm you reconnected successfully.
|
- Do NOT send a "ready", "online", "status accepted", or other acknowledgement-only message just to confirm you reconnected successfully.
|
||||||
- If reconnect bootstrap succeeded and you have no immediate blocker or question, stay silent and continue with your queue.
|
- If reconnect bootstrap succeeded and you have no immediate blocker or question, stay silent and continue with your queue.
|
||||||
|
- If reconnect bootstrap succeeded and you have no immediate blocker, question, or task, produce ZERO assistant text for that turn and end it immediately.
|
||||||
|
- Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after reconnect bootstrap.
|
||||||
- Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence.
|
- Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence.
|
||||||
- Use task_briefing as your compact queue view.
|
- Use task_briefing as your compact queue view.
|
||||||
- If task_briefing shows any in_progress task, resume/finish those first. Call task_get only if you need more context than task_briefing already gave you.
|
- If task_briefing shows any in_progress task, resume/finish those first. Call task_get only if you need more context than task_briefing already gave you.
|
||||||
|
|
@ -3094,12 +3170,15 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const runStartedAtMs = Date.parse(run.startedAt);
|
const runStartedAtMs = Date.parse(run.startedAt);
|
||||||
const expectedMembers = Array.isArray(run.expectedMembers) ? run.expectedMembers : [];
|
const expectedMembers = new Set(Array.isArray(run.expectedMembers) ? run.expectedMembers : []);
|
||||||
const teammateMessages = leadInboxMessages
|
const teammateMessages = leadInboxMessages
|
||||||
.filter((message) => {
|
.filter((message): message is LeadInboxMemberSpawnMessage => {
|
||||||
const from = typeof message.from === 'string' ? message.from.trim() : '';
|
const from = typeof message.from === 'string' ? message.from.trim() : '';
|
||||||
if (!from || from === leadName || from === 'user' || from === 'system') return false;
|
if (!from || from === leadName || from === 'user' || from === 'system') return false;
|
||||||
if (!expectedMembers.includes(from)) return false;
|
if (!expectedMembers.has(from)) return false;
|
||||||
|
if (typeof message.messageId !== 'string' || message.messageId.trim().length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const messageTs = Date.parse(message.timestamp);
|
const messageTs = Date.parse(message.timestamp);
|
||||||
if (
|
if (
|
||||||
Number.isFinite(messageTs) &&
|
Number.isFinite(messageTs) &&
|
||||||
|
|
@ -3110,24 +3189,67 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
return typeof message.text === 'string' && message.text.trim().length > 0;
|
return typeof message.text === 'string' && message.text.trim().length > 0;
|
||||||
})
|
})
|
||||||
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
.sort((left, right) =>
|
||||||
|
compareMemberSpawnInboxCursor(
|
||||||
for (const message of teammateMessages) {
|
{ timestamp: left.timestamp, messageId: left.messageId },
|
||||||
const from = message.from.trim();
|
{ timestamp: right.timestamp, messageId: right.messageId }
|
||||||
const reason = extractBootstrapFailureReason(message.text);
|
)
|
||||||
if (reason) {
|
|
||||||
this.setMemberSpawnStatus(run, from, 'error', reason);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
this.setMemberSpawnStatus(
|
|
||||||
run,
|
|
||||||
from,
|
|
||||||
'online',
|
|
||||||
undefined,
|
|
||||||
'heartbeat',
|
|
||||||
extractHeartbeatTimestamp(message.text, message.timestamp)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const messagesByMember = new Map<string, LeadInboxMemberSpawnMessage[]>();
|
||||||
|
for (const message of teammateMessages) {
|
||||||
|
const memberName = message.from.trim();
|
||||||
|
const bucket = messagesByMember.get(memberName) ?? [];
|
||||||
|
bucket.push(message);
|
||||||
|
messagesByMember.set(memberName, bucket);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [memberName, messages] of messagesByMember.entries()) {
|
||||||
|
const currentCursor = run.memberSpawnLeadInboxCursorByMember.get(memberName);
|
||||||
|
let nextCursor = currentCursor;
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
const messageCursor = toMemberSpawnInboxCursor(message);
|
||||||
|
const effectiveCursor = nextCursor ?? currentCursor;
|
||||||
|
if (messageCursor && effectiveCursor) {
|
||||||
|
if (compareMemberSpawnInboxCursor(messageCursor, effectiveCursor) <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.applyLeadInboxSpawnSignal(run, memberName, message);
|
||||||
|
if (messageCursor) {
|
||||||
|
nextCursor = maxMemberSpawnInboxCursor(nextCursor, messageCursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
nextCursor &&
|
||||||
|
(currentCursor == null || compareMemberSpawnInboxCursor(nextCursor, currentCursor) > 0)
|
||||||
|
) {
|
||||||
|
run.memberSpawnLeadInboxCursorByMember.set(memberName, nextCursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyLeadInboxSpawnSignal(
|
||||||
|
run: ProvisioningRun,
|
||||||
|
memberName: string,
|
||||||
|
message: LeadInboxMemberSpawnMessage
|
||||||
|
): void {
|
||||||
|
const reason = extractBootstrapFailureReason(message.text);
|
||||||
|
if (reason) {
|
||||||
|
this.setMemberSpawnStatus(run, memberName, 'error', reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setMemberSpawnStatus(
|
||||||
|
run,
|
||||||
|
memberName,
|
||||||
|
'online',
|
||||||
|
undefined,
|
||||||
|
'heartbeat',
|
||||||
|
extractHeartbeatTimestamp(message.text, message.timestamp)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private persistSentMessage(teamName: string, message: InboxMessage): void {
|
private persistSentMessage(teamName: string, message: InboxMessage): void {
|
||||||
|
|
@ -3725,8 +3847,14 @@ export class TeamProvisioningService {
|
||||||
next.livenessSource = livenessSource;
|
next.livenessSource = livenessSource;
|
||||||
next.firstSpawnAcceptedAt = prev.firstSpawnAcceptedAt ?? updatedAt;
|
next.firstSpawnAcceptedAt = prev.firstSpawnAcceptedAt ?? updatedAt;
|
||||||
if (livenessSource === 'heartbeat') {
|
if (livenessSource === 'heartbeat') {
|
||||||
|
const incomingHeartbeatAt = heartbeatAt?.trim() || updatedAt;
|
||||||
next.bootstrapConfirmed = true;
|
next.bootstrapConfirmed = true;
|
||||||
next.lastHeartbeatAt = heartbeatAt?.trim() || prev.lastHeartbeatAt || updatedAt;
|
next.lastHeartbeatAt = isMemberSpawnHeartbeatTimestampNewer(
|
||||||
|
prev.lastHeartbeatAt,
|
||||||
|
incomingHeartbeatAt
|
||||||
|
)
|
||||||
|
? incomingHeartbeatAt
|
||||||
|
: prev.lastHeartbeatAt;
|
||||||
}
|
}
|
||||||
next.hardFailure = false;
|
next.hardFailure = false;
|
||||||
next.error = undefined;
|
next.error = undefined;
|
||||||
|
|
@ -5612,6 +5740,7 @@ export class TeamProvisioningService {
|
||||||
request.members.map((m) => [m.name, createInitialMemberSpawnStatusEntry()])
|
request.members.map((m) => [m.name, createInitialMemberSpawnStatusEntry()])
|
||||||
),
|
),
|
||||||
memberSpawnToolUseIds: new Map(),
|
memberSpawnToolUseIds: new Map(),
|
||||||
|
memberSpawnLeadInboxCursorByMember: new Map(),
|
||||||
lastDeterministicBootstrapSeq: 0,
|
lastDeterministicBootstrapSeq: 0,
|
||||||
lastMemberSpawnAuditAt: 0,
|
lastMemberSpawnAuditAt: 0,
|
||||||
lastMemberSpawnAuditConfigReadWarningAt: 0,
|
lastMemberSpawnAuditConfigReadWarningAt: 0,
|
||||||
|
|
@ -6192,6 +6321,7 @@ export class TeamProvisioningService {
|
||||||
expectedMembers.map((name) => [name, createInitialMemberSpawnStatusEntry()])
|
expectedMembers.map((name) => [name, createInitialMemberSpawnStatusEntry()])
|
||||||
),
|
),
|
||||||
memberSpawnToolUseIds: new Map(),
|
memberSpawnToolUseIds: new Map(),
|
||||||
|
memberSpawnLeadInboxCursorByMember: new Map(),
|
||||||
lastDeterministicBootstrapSeq: 0,
|
lastDeterministicBootstrapSeq: 0,
|
||||||
lastMemberSpawnAuditAt: 0,
|
lastMemberSpawnAuditAt: 0,
|
||||||
lastMemberSpawnAuditConfigReadWarningAt: 0,
|
lastMemberSpawnAuditConfigReadWarningAt: 0,
|
||||||
|
|
@ -12968,6 +13098,7 @@ export class TeamProvisioningService {
|
||||||
providerId: TeamProviderId | undefined = 'anthropic'
|
providerId: TeamProviderId | undefined = 'anthropic'
|
||||||
): Promise<{ warning?: string }> {
|
): Promise<{ warning?: string }> {
|
||||||
const resolvedProviderId = resolveTeamProviderId(providerId);
|
const resolvedProviderId = resolveTeamProviderId(providerId);
|
||||||
|
const cliCommandLabel = getConfiguredCliCommandLabel();
|
||||||
try {
|
try {
|
||||||
const versionProbe = await this.spawnProbe(
|
const versionProbe = await this.spawnProbe(
|
||||||
claudePath,
|
claudePath,
|
||||||
|
|
@ -12979,9 +13110,9 @@ export class TeamProvisioningService {
|
||||||
if (versionProbe.exitCode !== 0) {
|
if (versionProbe.exitCode !== 0) {
|
||||||
const errorText =
|
const errorText =
|
||||||
buildCombinedLogs(versionProbe.stdout, versionProbe.stderr) ||
|
buildCombinedLogs(versionProbe.stdout, versionProbe.stderr) ||
|
||||||
`Claude CLI exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`;
|
`${cliCommandLabel} exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`;
|
||||||
return {
|
return {
|
||||||
warning: `Claude CLI binary failed to start correctly. Details: ${errorText}`,
|
warning: `${cliCommandLabel} binary failed to start correctly. Details: ${errorText}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -12992,7 +13123,7 @@ export class TeamProvisioningService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
warning: `Claude CLI binary failed to start. Details: ${message}`,
|
warning: `${cliCommandLabel} binary failed to start. Details: ${message}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -13054,7 +13185,7 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
warning:
|
warning:
|
||||||
'Preflight check for `claude -p` did not complete. ' +
|
`Preflight check for \`${cliCommandLabel} -p\` did not complete. ` +
|
||||||
`Proceeding anyway. Details: ${message}`,
|
`Proceeding anyway. Details: ${message}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -13075,13 +13206,15 @@ export class TeamProvisioningService {
|
||||||
const hint = isAuthFailure
|
const hint = isAuthFailure
|
||||||
? resolvedProviderId === 'codex'
|
? resolvedProviderId === 'codex'
|
||||||
? 'Codex provider is not authenticated for `-p` mode. ' +
|
? 'Codex provider is not authenticated for `-p` mode. ' +
|
||||||
'Run `claude-multimodel auth login --provider codex` and retry.' +
|
`Authenticate Codex in ${cliCommandLabel} and retry.` +
|
||||||
(attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
|
(attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
|
||||||
: 'Claude CLI `-p` mode is not authenticated. ' +
|
: `${cliCommandLabel} \`-p\` mode is not authenticated. ` +
|
||||||
'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' +
|
(cliCommandLabel === 'claude'
|
||||||
|
? 'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. '
|
||||||
|
: `Authenticate Anthropic in ${cliCommandLabel} and retry. `) +
|
||||||
'For automation/headless use, set ANTHROPIC_API_KEY.' +
|
'For automation/headless use, set ANTHROPIC_API_KEY.' +
|
||||||
(attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
|
(attempt > 1 ? ` (failed after ${attempt} attempts)` : '')
|
||||||
: `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`;
|
: `${cliCommandLabel} preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`;
|
||||||
return { warning: hint };
|
return { warning: hint };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -13122,7 +13255,7 @@ export class TeamProvisioningService {
|
||||||
const targetCwd = cwd ?? process.cwd();
|
const targetCwd = cwd ?? process.cwd();
|
||||||
const probeResult = await this.getCachedOrProbeResult(targetCwd, 'anthropic');
|
const probeResult = await this.getCachedOrProbeResult(targetCwd, 'anthropic');
|
||||||
if (!probeResult?.claudePath) {
|
if (!probeResult?.claudePath) {
|
||||||
throw new Error('Claude CLI not found');
|
throw new Error(`${getConfiguredCliCommandLabel()} not found`);
|
||||||
}
|
}
|
||||||
const { env } = await this.buildProvisioningEnv();
|
const { env } = await this.buildProvisioningEnv();
|
||||||
const result = await this.spawnProbe(
|
const result = await this.spawnProbe(
|
||||||
|
|
@ -13135,7 +13268,7 @@ export class TeamProvisioningService {
|
||||||
const output = (result.stdout + '\n' + result.stderr).trim();
|
const output = (result.stdout + '\n' + result.stderr).trim();
|
||||||
if (!output) {
|
if (!output) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`claude --help returned empty output (exit code: ${String(result.exitCode)})`
|
`${getConfiguredCliCommandLabel()} --help returned empty output (exit code: ${String(result.exitCode)})`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.helpOutputCache = output;
|
this.helpOutputCache = output;
|
||||||
|
|
@ -13493,7 +13626,7 @@ export class TeamProvisioningService {
|
||||||
const timeoutHandle = setTimeout(() => {
|
const timeoutHandle = setTimeout(() => {
|
||||||
settled = true;
|
settled = true;
|
||||||
killProcessTree(child);
|
killProcessTree(child);
|
||||||
reject(new Error(`Timeout running: claude ${args.join(' ')}`));
|
reject(new Error(`Timeout running: ${getConfiguredCliCommandLabel()} ${args.join(' ')}`));
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
|
||||||
const maybeResolveEarly = (): void => {
|
const maybeResolveEarly = (): void => {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { atomicWriteAsync } from '@main/utils/atomicWrite';
|
||||||
import { extractCwd } from '@main/utils/jsonl';
|
import { extractCwd } from '@main/utils/jsonl';
|
||||||
import {
|
import {
|
||||||
encodePath,
|
encodePath,
|
||||||
|
|
@ -5,7 +6,6 @@ import {
|
||||||
getProjectsBasePath,
|
getProjectsBasePath,
|
||||||
getTeamsBasePath,
|
getTeamsBasePath,
|
||||||
} from '@main/utils/pathDecoder';
|
} from '@main/utils/pathDecoder';
|
||||||
import { atomicWriteAsync } from '@main/utils/atomicWrite';
|
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { createReadStream, type Dirent } from 'fs';
|
import { createReadStream, type Dirent } from 'fs';
|
||||||
|
|
@ -219,7 +219,8 @@ export class TeamTranscriptProjectResolver {
|
||||||
...config,
|
...config,
|
||||||
projectPath: resolution.effectiveProjectPath,
|
projectPath: resolution.effectiveProjectPath,
|
||||||
projectPathHistory: this.buildRepairedProjectPathHistory(
|
projectPathHistory: this.buildRepairedProjectPathHistory(
|
||||||
config,
|
config.projectPath,
|
||||||
|
config.projectPathHistory,
|
||||||
resolution.effectiveProjectPath
|
resolution.effectiveProjectPath
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
@ -598,25 +599,11 @@ export class TeamTranscriptProjectResolver {
|
||||||
|
|
||||||
parsed.projectPath = normalizedNextPath;
|
parsed.projectPath = normalizedNextPath;
|
||||||
|
|
||||||
const history: string[] = [];
|
parsed.projectPathHistory = this.buildRepairedProjectPathHistory(
|
||||||
const seen = new Set<string>();
|
rawProjectPath,
|
||||||
const pushHistory = (value: unknown): void => {
|
parsed.projectPathHistory,
|
||||||
const normalized = normalizeProjectPathCandidate(value);
|
normalizedNextPath
|
||||||
if (!normalized || normalized === normalizedNextPath || seen.has(normalized)) {
|
);
|
||||||
return;
|
|
||||||
}
|
|
||||||
seen.add(normalized);
|
|
||||||
history.push(normalized);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Array.isArray(parsed.projectPathHistory)) {
|
|
||||||
for (const value of parsed.projectPathHistory) {
|
|
||||||
pushHistory(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pushHistory(rawProjectPath);
|
|
||||||
|
|
||||||
parsed.projectPathHistory = history.slice(-500);
|
|
||||||
await atomicWriteAsync(configPath, JSON.stringify(parsed, null, 2));
|
await atomicWriteAsync(configPath, JSON.stringify(parsed, null, 2));
|
||||||
logger.info(
|
logger.info(
|
||||||
`[${teamName}] Repaired transcript projectPath via exact session match: ${normalizedNextPath}`
|
`[${teamName}] Repaired transcript projectPath via exact session match: ${normalizedNextPath}`
|
||||||
|
|
@ -663,7 +650,11 @@ export class TeamTranscriptProjectResolver {
|
||||||
return orderedSessionIds;
|
return orderedSessionIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildRepairedProjectPathHistory(config: TeamConfig, nextProjectPath: string): string[] {
|
private buildRepairedProjectPathHistory(
|
||||||
|
currentProjectPath: unknown,
|
||||||
|
rawProjectPathHistory: unknown,
|
||||||
|
nextProjectPath: string
|
||||||
|
): string[] {
|
||||||
const normalizedNextPath = normalizeProjectPathCandidate(nextProjectPath);
|
const normalizedNextPath = normalizeProjectPathCandidate(nextProjectPath);
|
||||||
const history: string[] = [];
|
const history: string[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
@ -676,12 +667,12 @@ export class TeamTranscriptProjectResolver {
|
||||||
history.push(normalized);
|
history.push(normalized);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (Array.isArray(config.projectPathHistory)) {
|
if (Array.isArray(rawProjectPathHistory)) {
|
||||||
for (const value of config.projectPathHistory) {
|
for (const value of rawProjectPathHistory) {
|
||||||
pushHistory(value);
|
pushHistory(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pushHistory(config.projectPath);
|
pushHistory(currentProjectPath);
|
||||||
|
|
||||||
return history.slice(-500);
|
return history.slice(-500);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { BranchStatusService } from './BranchStatusService';
|
||||||
export { CascadeGuard } from './CascadeGuard';
|
export { CascadeGuard } from './CascadeGuard';
|
||||||
export { ChangeExtractorService } from './ChangeExtractorService';
|
export { ChangeExtractorService } from './ChangeExtractorService';
|
||||||
|
|
@ -16,12 +22,6 @@ export { BoardTaskActivityService } from './taskLogs/activity/BoardTaskActivityS
|
||||||
export { BoardTaskExactLogDetailService } from './taskLogs/exact/BoardTaskExactLogDetailService';
|
export { BoardTaskExactLogDetailService } from './taskLogs/exact/BoardTaskExactLogDetailService';
|
||||||
export { BoardTaskExactLogsService } from './taskLogs/exact/BoardTaskExactLogsService';
|
export { BoardTaskExactLogsService } from './taskLogs/exact/BoardTaskExactLogsService';
|
||||||
export { BoardTaskLogStreamService } from './taskLogs/stream/BoardTaskLogStreamService';
|
export { BoardTaskLogStreamService } from './taskLogs/stream/BoardTaskLogStreamService';
|
||||||
export {
|
|
||||||
AutoResumeService,
|
|
||||||
clearAutoResumeService,
|
|
||||||
getAutoResumeService,
|
|
||||||
initializeAutoResumeService,
|
|
||||||
} from './AutoResumeService';
|
|
||||||
export { TeamAttachmentStore } from './TeamAttachmentStore';
|
export { TeamAttachmentStore } from './TeamAttachmentStore';
|
||||||
export { TeamBackupService } from './TeamBackupService';
|
export { TeamBackupService } from './TeamBackupService';
|
||||||
export { TeamConfigReader } from './TeamConfigReader';
|
export { TeamConfigReader } from './TeamConfigReader';
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,12 @@
|
||||||
* Shared request/response types for the team-data-worker thread.
|
* Shared request/response types for the team-data-worker thread.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { MemberLogSummary, TeamData } from '@shared/types';
|
import type {
|
||||||
|
MemberLogSummary,
|
||||||
|
MessagesPage,
|
||||||
|
TeamMemberActivityMeta,
|
||||||
|
TeamViewSnapshot,
|
||||||
|
} from '@shared/types';
|
||||||
|
|
||||||
// ── Payloads ──
|
// ── Payloads ──
|
||||||
|
|
||||||
|
|
@ -10,6 +15,18 @@ export interface GetTeamDataPayload {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GetMessagesPagePayload {
|
||||||
|
teamName: string;
|
||||||
|
options: {
|
||||||
|
cursor?: string | null;
|
||||||
|
limit: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetMemberActivityMetaPayload {
|
||||||
|
teamName: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FindLogsForTaskPayload {
|
export interface FindLogsForTaskPayload {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
|
|
@ -25,8 +42,14 @@ export interface FindLogsForTaskPayload {
|
||||||
|
|
||||||
export type TeamDataWorkerRequest =
|
export type TeamDataWorkerRequest =
|
||||||
| { id: string; op: 'getTeamData'; payload: GetTeamDataPayload }
|
| { id: string; op: 'getTeamData'; payload: GetTeamDataPayload }
|
||||||
|
| { id: string; op: 'getMessagesPage'; payload: GetMessagesPagePayload }
|
||||||
|
| { id: string; op: 'getMemberActivityMeta'; payload: GetMemberActivityMetaPayload }
|
||||||
| { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload };
|
| { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload };
|
||||||
|
|
||||||
export type TeamDataWorkerResponse =
|
export type TeamDataWorkerResponse =
|
||||||
| { id: string; ok: true; result: TeamData | MemberLogSummary[] }
|
| {
|
||||||
|
id: string;
|
||||||
|
ok: true;
|
||||||
|
result: TeamViewSnapshot | MessagesPage | TeamMemberActivityMeta | MemberLogSummary[];
|
||||||
|
}
|
||||||
| { id: string; ok: false; error: string };
|
| { id: string; ok: false; error: string };
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,19 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => {
|
||||||
respond({ id: msg.id, ok: true, result });
|
respond({ id: msg.id, ok: true, result });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'getMessagesPage': {
|
||||||
|
const result = await teamDataService.getMessagesPage(
|
||||||
|
msg.payload.teamName,
|
||||||
|
msg.payload.options
|
||||||
|
);
|
||||||
|
respond({ id: msg.id, ok: true, result });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'getMemberActivityMeta': {
|
||||||
|
const result = await teamDataService.getMemberActivityMeta(msg.payload.teamName);
|
||||||
|
respond({ id: msg.id, ok: true, result });
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'findLogsForTask': {
|
case 'findLogsForTask': {
|
||||||
const { teamName, taskId, options } = msg.payload;
|
const { teamName, taskId, options } = msg.payload;
|
||||||
const intervalsKey = options?.intervals
|
const intervalsKey = options?.intervals
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,9 @@ export const TEAM_SEND_MESSAGE = 'team:sendMessage';
|
||||||
/** Paginated messages for timeline/messages panel */
|
/** Paginated messages for timeline/messages panel */
|
||||||
export const TEAM_GET_MESSAGES_PAGE = 'team:getMessagesPage';
|
export const TEAM_GET_MESSAGES_PAGE = 'team:getMessagesPage';
|
||||||
|
|
||||||
|
/** Lightweight message-derived member activity facts */
|
||||||
|
export const TEAM_GET_MEMBER_ACTIVITY_META = 'team:getMemberActivityMeta';
|
||||||
|
|
||||||
/** Request review for task */
|
/** Request review for task */
|
||||||
export const TEAM_REQUEST_REVIEW = 'team:requestReview';
|
export const TEAM_REQUEST_REVIEW = 'team:requestReview';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,7 @@ import {
|
||||||
TEAM_GET_DATA,
|
TEAM_GET_DATA,
|
||||||
TEAM_GET_DELETED_TASKS,
|
TEAM_GET_DELETED_TASKS,
|
||||||
TEAM_GET_LOGS_FOR_TASK,
|
TEAM_GET_LOGS_FOR_TASK,
|
||||||
|
TEAM_GET_MEMBER_ACTIVITY_META,
|
||||||
TEAM_GET_MEMBER_LOGS,
|
TEAM_GET_MEMBER_LOGS,
|
||||||
TEAM_GET_MEMBER_STATS,
|
TEAM_GET_MEMBER_STATS,
|
||||||
TEAM_GET_MESSAGES_PAGE,
|
TEAM_GET_MESSAGES_PAGE,
|
||||||
|
|
@ -299,9 +300,9 @@ import type {
|
||||||
TeamCreateConfigRequest,
|
TeamCreateConfigRequest,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamCreateResponse,
|
TeamCreateResponse,
|
||||||
TeamData,
|
|
||||||
TeamLaunchRequest,
|
TeamLaunchRequest,
|
||||||
TeamLaunchResponse,
|
TeamLaunchResponse,
|
||||||
|
TeamMemberActivityMeta,
|
||||||
TeamMessageNotificationData,
|
TeamMessageNotificationData,
|
||||||
TeamProvisioningPrepareResult,
|
TeamProvisioningPrepareResult,
|
||||||
TeamProvisioningProgress,
|
TeamProvisioningProgress,
|
||||||
|
|
@ -309,6 +310,7 @@ import type {
|
||||||
TeamTask,
|
TeamTask,
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
TeamUpdateConfigRequest,
|
TeamUpdateConfigRequest,
|
||||||
|
TeamViewSnapshot,
|
||||||
ToolApprovalEvent,
|
ToolApprovalEvent,
|
||||||
ToolApprovalFileContent,
|
ToolApprovalFileContent,
|
||||||
ToolApprovalSettings,
|
ToolApprovalSettings,
|
||||||
|
|
@ -829,7 +831,7 @@ const electronAPI: ElectronAPI = {
|
||||||
return invokeIpcWithResult<TeamSummary[]>(TEAM_LIST);
|
return invokeIpcWithResult<TeamSummary[]>(TEAM_LIST);
|
||||||
},
|
},
|
||||||
getData: async (teamName: string) => {
|
getData: async (teamName: string) => {
|
||||||
return invokeIpcWithResult<TeamData>(TEAM_GET_DATA, teamName);
|
return invokeIpcWithResult<TeamViewSnapshot>(TEAM_GET_DATA, teamName);
|
||||||
},
|
},
|
||||||
getTaskChangePresence: async (teamName: string) => {
|
getTaskChangePresence: async (teamName: string) => {
|
||||||
return invokeIpcWithResult<Record<string, TaskChangePresenceState>>(
|
return invokeIpcWithResult<Record<string, TaskChangePresenceState>>(
|
||||||
|
|
@ -897,10 +899,13 @@ const electronAPI: ElectronAPI = {
|
||||||
},
|
},
|
||||||
getMessagesPage: async (
|
getMessagesPage: async (
|
||||||
teamName: string,
|
teamName: string,
|
||||||
options?: { beforeTimestamp?: string; limit?: number }
|
options?: { cursor?: string | null; limit?: number }
|
||||||
) => {
|
) => {
|
||||||
return invokeIpcWithResult<MessagesPage>(TEAM_GET_MESSAGES_PAGE, teamName, options);
|
return invokeIpcWithResult<MessagesPage>(TEAM_GET_MESSAGES_PAGE, teamName, options);
|
||||||
},
|
},
|
||||||
|
getMemberActivityMeta: async (teamName: string) => {
|
||||||
|
return invokeIpcWithResult<TeamMemberActivityMeta>(TEAM_GET_MEMBER_ACTIVITY_META, teamName);
|
||||||
|
},
|
||||||
createTask: async (teamName: string, request: CreateTaskRequest) => {
|
createTask: async (teamName: string, request: CreateTaskRequest) => {
|
||||||
return invokeIpcWithResult<TeamTask>(TEAM_CREATE_TASK, teamName, request);
|
return invokeIpcWithResult<TeamTask>(TEAM_CREATE_TASK, teamName, request);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -59,15 +59,16 @@ import type {
|
||||||
TeamClaudeLogsResponse,
|
TeamClaudeLogsResponse,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamCreateResponse,
|
TeamCreateResponse,
|
||||||
TeamData,
|
|
||||||
TeamLaunchRequest,
|
TeamLaunchRequest,
|
||||||
TeamLaunchResponse,
|
TeamLaunchResponse,
|
||||||
|
TeamMemberActivityMeta,
|
||||||
TeamProvisioningPrepareResult,
|
TeamProvisioningPrepareResult,
|
||||||
TeamProvisioningProgress,
|
TeamProvisioningProgress,
|
||||||
TeamsAPI,
|
TeamsAPI,
|
||||||
TeamSummary,
|
TeamSummary,
|
||||||
TeamTask,
|
TeamTask,
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
|
TeamViewSnapshot,
|
||||||
TmuxAPI,
|
TmuxAPI,
|
||||||
TmuxStatus,
|
TmuxStatus,
|
||||||
TriggerTestResult,
|
TriggerTestResult,
|
||||||
|
|
@ -678,7 +679,7 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
console.warn('[HttpAPIClient] teams API is not available in browser mode');
|
console.warn('[HttpAPIClient] teams API is not available in browser mode');
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
getData: async (_teamName: string): Promise<TeamData> => {
|
getData: async (_teamName: string): Promise<TeamViewSnapshot> => {
|
||||||
throw new Error('Teams detail is not available in browser mode');
|
throw new Error('Teams detail is not available in browser mode');
|
||||||
},
|
},
|
||||||
getTaskChangePresence: async (): Promise<
|
getTaskChangePresence: async (): Promise<
|
||||||
|
|
@ -746,7 +747,15 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
throw new Error('Team messaging is not available in browser mode');
|
throw new Error('Team messaging is not available in browser mode');
|
||||||
},
|
},
|
||||||
getMessagesPage: async () => {
|
getMessagesPage: async () => {
|
||||||
return { messages: [], nextCursor: null, hasMore: false };
|
return { messages: [], nextCursor: null, hasMore: false, feedRevision: 'empty' };
|
||||||
|
},
|
||||||
|
getMemberActivityMeta: async (_teamName: string): Promise<TeamMemberActivityMeta> => {
|
||||||
|
return {
|
||||||
|
teamName: _teamName,
|
||||||
|
computedAt: new Date(0).toISOString(),
|
||||||
|
members: {},
|
||||||
|
feedRevision: 'empty',
|
||||||
|
};
|
||||||
},
|
},
|
||||||
createTask: async (_teamName: string, _request: CreateTaskRequest): Promise<TeamTask> => {
|
createTask: async (_teamName: string, _request: CreateTaskRequest): Promise<TeamTask> => {
|
||||||
throw new Error('Team task creation is not available in browser mode');
|
throw new Error('Team task creation is not available in browser mode');
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
|
||||||
import { useTabUI } from '@renderer/hooks/useTabUI';
|
import { useTabUI } from '@renderer/hooks/useTabUI';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||||
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
|
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
||||||
|
|
@ -398,7 +399,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
|
||||||
// Get team members for @mention highlighting and team names for @team linkification
|
// Get team members for @mention highlighting and team names for @team linkification
|
||||||
const { members, teams } = useStore(
|
const { members, teams } = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
members: s.selectedTeamData?.members,
|
members: selectResolvedMembersForTeamName(s, s.selectedTeamName),
|
||||||
teams: s.teams,
|
teams: s.teams,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||||
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
|
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
|
||||||
import { formatTokensCompact } from '@renderer/utils/formatters';
|
import { formatTokensCompact } from '@renderer/utils/formatters';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
|
@ -86,7 +87,9 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
|
|
||||||
// Get team members for @mention highlighting
|
// Get team members for @mention highlighting
|
||||||
const members = useStore(useShallow((s) => s.selectedTeamData?.members));
|
const members = useStore(
|
||||||
|
useShallow((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName))
|
||||||
|
);
|
||||||
const memberColorMap = useMemo(
|
const memberColorMap = useMemo(
|
||||||
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
||||||
[members]
|
[members]
|
||||||
|
|
|
||||||
|
|
@ -71,10 +71,21 @@ interface MarkdownViewerProps {
|
||||||
onTeamClick?: (teamName: string) => void;
|
onTeamClick?: (teamName: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CompactMarkdownPreviewProps {
|
||||||
|
content: string;
|
||||||
|
className?: string;
|
||||||
|
/** Optional precomputed team color map to avoid subscribing to the full team list. */
|
||||||
|
teamColorByName?: ReadonlyMap<string, string>;
|
||||||
|
/** Optional team click handler to avoid subscribing to store in leaf renderers. */
|
||||||
|
onTeamClick?: (teamName: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const EMPTY_TEAMS: { teamName?: string; displayName?: string; color?: string }[] = [];
|
const EMPTY_TEAMS: { teamName?: string; displayName?: string; color?: string }[] = [];
|
||||||
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
|
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
|
||||||
const NOOP_TEAM_CLICK = (): void => undefined;
|
const NOOP_TEAM_CLICK = (): void => undefined;
|
||||||
|
|
||||||
|
type ViewerMarkdownMode = 'default' | 'compact-preview';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Helpers
|
// Helpers
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -322,53 +333,89 @@ function createViewerMarkdownComponents(
|
||||||
isLight = false,
|
isLight = false,
|
||||||
teamColorByName: ReadonlyMap<string, string> = new Map(),
|
teamColorByName: ReadonlyMap<string, string> = new Map(),
|
||||||
onTeamClick?: (teamName: string) => void,
|
onTeamClick?: (teamName: string) => void,
|
||||||
copyCodeBlocks: boolean = false
|
copyCodeBlocks: boolean = false,
|
||||||
|
mode: ViewerMarkdownMode = 'default'
|
||||||
): Components {
|
): Components {
|
||||||
const hl = (children: React.ReactNode): React.ReactNode =>
|
const hl = (children: React.ReactNode): React.ReactNode =>
|
||||||
searchCtx ? highlightSearchInChildren(children, searchCtx) : children;
|
searchCtx ? highlightSearchInChildren(children, searchCtx) : children;
|
||||||
|
const isCompactPreview = mode === 'compact-preview';
|
||||||
|
|
||||||
|
const renderCompactInline = (
|
||||||
|
children: React.ReactNode,
|
||||||
|
className: string,
|
||||||
|
style: React.CSSProperties
|
||||||
|
): React.ReactElement => (
|
||||||
|
<span className={className} style={style}>
|
||||||
|
{hl(children)}{' '}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Headings
|
// Headings
|
||||||
h1: ({ children }) => (
|
h1: ({ children }) =>
|
||||||
<h1 className="mb-2 mt-4 text-xl font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
isCompactPreview ? (
|
||||||
{hl(children)}
|
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
|
||||||
</h1>
|
) : (
|
||||||
),
|
<h1 className="mb-2 mt-4 text-xl font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||||
h2: ({ children }) => (
|
{hl(children)}
|
||||||
<h2 className="mb-2 mt-4 text-lg font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
</h1>
|
||||||
{hl(children)}
|
),
|
||||||
</h2>
|
h2: ({ children }) =>
|
||||||
),
|
isCompactPreview ? (
|
||||||
h3: ({ children }) => (
|
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
|
||||||
<h3 className="mb-2 mt-3 text-base font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
) : (
|
||||||
{hl(children)}
|
<h2 className="mb-2 mt-4 text-lg font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||||
</h3>
|
{hl(children)}
|
||||||
),
|
</h2>
|
||||||
h4: ({ children }) => (
|
),
|
||||||
<h4 className="mb-1 mt-3 text-sm font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
h3: ({ children }) =>
|
||||||
{hl(children)}
|
isCompactPreview ? (
|
||||||
</h4>
|
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
|
||||||
),
|
) : (
|
||||||
h5: ({ children }) => (
|
<h3
|
||||||
<h5 className="mb-1 mt-2 text-sm font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
|
className="mb-2 mt-3 text-base font-semibold first:mt-0"
|
||||||
{hl(children)}
|
style={{ color: PROSE_HEADING }}
|
||||||
</h5>
|
>
|
||||||
),
|
{hl(children)}
|
||||||
h6: ({ children }) => (
|
</h3>
|
||||||
<h6 className="mb-1 mt-2 text-xs font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
|
),
|
||||||
{hl(children)}
|
h4: ({ children }) =>
|
||||||
</h6>
|
isCompactPreview ? (
|
||||||
),
|
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
|
||||||
|
) : (
|
||||||
|
<h4 className="mb-1 mt-3 text-sm font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||||
|
{hl(children)}
|
||||||
|
</h4>
|
||||||
|
),
|
||||||
|
h5: ({ children }) =>
|
||||||
|
isCompactPreview ? (
|
||||||
|
renderCompactInline(children, 'font-medium', { color: PROSE_HEADING })
|
||||||
|
) : (
|
||||||
|
<h5 className="mb-1 mt-2 text-sm font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||||
|
{hl(children)}
|
||||||
|
</h5>
|
||||||
|
),
|
||||||
|
h6: ({ children }) =>
|
||||||
|
isCompactPreview ? (
|
||||||
|
renderCompactInline(children, 'font-medium', { color: PROSE_HEADING })
|
||||||
|
) : (
|
||||||
|
<h6 className="mb-1 mt-2 text-xs font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
|
||||||
|
{hl(children)}
|
||||||
|
</h6>
|
||||||
|
),
|
||||||
|
|
||||||
// Paragraphs
|
// Paragraphs
|
||||||
p: ({ children }) => (
|
p: ({ children }) =>
|
||||||
<p
|
isCompactPreview ? (
|
||||||
className="my-2 text-sm leading-relaxed first:mt-0 last:mb-0"
|
renderCompactInline(children, '', { color: PROSE_BODY })
|
||||||
style={{ color: PROSE_BODY }}
|
) : (
|
||||||
>
|
<p
|
||||||
{hl(children)}
|
className="my-2 text-sm leading-relaxed first:mt-0 last:mb-0"
|
||||||
</p>
|
style={{ color: PROSE_BODY }}
|
||||||
),
|
>
|
||||||
|
{hl(children)}
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
|
||||||
// Links — inline element, no hl(); parent block element's hl() descends here
|
// Links — inline element, no hl(); parent block element's hl() descends here
|
||||||
// task:// links render with TaskTooltip + are clickable via ancestor onClickCapture
|
// task:// links render with TaskTooltip + are clickable via ancestor onClickCapture
|
||||||
|
|
@ -570,6 +617,20 @@ function createViewerMarkdownComponents(
|
||||||
|
|
||||||
// Code blocks — intercept mermaid diagrams at the pre level
|
// Code blocks — intercept mermaid diagrams at the pre level
|
||||||
pre: ({ children, node }) => {
|
pre: ({ children, node }) => {
|
||||||
|
if (isCompactPreview) {
|
||||||
|
const compactText = extractTextFromReactNode(children).trim();
|
||||||
|
return (
|
||||||
|
<code
|
||||||
|
className="break-all rounded px-1.5 py-0.5 font-mono text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: PROSE_CODE_BG,
|
||||||
|
color: PROSE_CODE_TEXT,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{compactText}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
// Check if this pre contains a mermaid code block
|
// Check if this pre contains a mermaid code block
|
||||||
const codeEl = node?.children?.[0];
|
const codeEl = node?.children?.[0];
|
||||||
if (codeEl && 'tagName' in codeEl && codeEl.tagName === 'code' && 'properties' in codeEl) {
|
if (codeEl && 'tagName' in codeEl && codeEl.tagName === 'code' && 'properties' in codeEl) {
|
||||||
|
|
@ -596,74 +657,107 @@ function createViewerMarkdownComponents(
|
||||||
},
|
},
|
||||||
|
|
||||||
// Blockquotes
|
// Blockquotes
|
||||||
blockquote: ({ children }) => (
|
blockquote: ({ children }) =>
|
||||||
<blockquote
|
isCompactPreview ? (
|
||||||
className="my-3 border-l-4 pl-4 italic"
|
renderCompactInline(children, 'italic', { color: PROSE_MUTED })
|
||||||
style={{
|
) : (
|
||||||
borderColor: PROSE_BLOCKQUOTE_BORDER,
|
<blockquote
|
||||||
color: PROSE_MUTED,
|
className="my-3 border-l-4 pl-4 italic"
|
||||||
}}
|
style={{
|
||||||
>
|
borderColor: PROSE_BLOCKQUOTE_BORDER,
|
||||||
{hl(children)}
|
color: PROSE_MUTED,
|
||||||
</blockquote>
|
}}
|
||||||
),
|
>
|
||||||
|
{hl(children)}
|
||||||
|
</blockquote>
|
||||||
|
),
|
||||||
|
|
||||||
// Lists
|
// Lists
|
||||||
ul: ({ children }) => (
|
ul: ({ children }) =>
|
||||||
<ul className="my-2 list-disc space-y-1 pl-5" style={{ color: PROSE_BODY }}>
|
isCompactPreview ? (
|
||||||
{children}
|
<span>{children}</span>
|
||||||
</ul>
|
) : (
|
||||||
),
|
<ul className="my-2 list-disc space-y-1 pl-5" style={{ color: PROSE_BODY }}>
|
||||||
ol: ({ children }) => (
|
{children}
|
||||||
<ol className="my-2 list-decimal space-y-1 pl-5" style={{ color: PROSE_BODY }}>
|
</ul>
|
||||||
{children}
|
),
|
||||||
</ol>
|
ol: ({ children }) =>
|
||||||
),
|
isCompactPreview ? (
|
||||||
li: ({ children }) => (
|
<span>{children}</span>
|
||||||
<li className="text-sm" style={{ color: PROSE_BODY }}>
|
) : (
|
||||||
{hl(children)}
|
<ol className="my-2 list-decimal space-y-1 pl-5" style={{ color: PROSE_BODY }}>
|
||||||
</li>
|
{children}
|
||||||
),
|
</ol>
|
||||||
|
),
|
||||||
|
li: ({ children }) =>
|
||||||
|
isCompactPreview ? (
|
||||||
|
<span className="inline" style={{ color: PROSE_BODY }}>
|
||||||
|
• {hl(children)}{' '}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<li className="text-sm" style={{ color: PROSE_BODY }}>
|
||||||
|
{hl(children)}
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
|
||||||
// Tables
|
// Tables
|
||||||
table: ({ children }) => (
|
table: ({ children }) =>
|
||||||
<div className="my-3 overflow-x-auto">
|
isCompactPreview ? (
|
||||||
<table
|
<span>{children}</span>
|
||||||
className="min-w-full border-collapse text-sm"
|
) : (
|
||||||
style={{ borderColor: PROSE_TABLE_BORDER }}
|
<div className="my-3 overflow-x-auto">
|
||||||
|
<table
|
||||||
|
className="min-w-full border-collapse text-sm"
|
||||||
|
style={{ borderColor: PROSE_TABLE_BORDER }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
thead: ({ children }) =>
|
||||||
|
isCompactPreview ? (
|
||||||
|
<span>{children}</span>
|
||||||
|
) : (
|
||||||
|
<thead style={{ backgroundColor: PROSE_TABLE_HEADER_BG }}>{children}</thead>
|
||||||
|
),
|
||||||
|
th: ({ children }) =>
|
||||||
|
isCompactPreview ? (
|
||||||
|
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
|
||||||
|
) : (
|
||||||
|
<th
|
||||||
|
className="px-3 py-2 text-left font-semibold"
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${PROSE_TABLE_BORDER}`,
|
||||||
|
color: PROSE_HEADING,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{hl(children)}
|
||||||
</table>
|
</th>
|
||||||
</div>
|
),
|
||||||
),
|
td: ({ children }) =>
|
||||||
thead: ({ children }) => (
|
isCompactPreview ? (
|
||||||
<thead style={{ backgroundColor: PROSE_TABLE_HEADER_BG }}>{children}</thead>
|
renderCompactInline(children, '', { color: PROSE_BODY })
|
||||||
),
|
) : (
|
||||||
th: ({ children }) => (
|
<td
|
||||||
<th
|
className="px-3 py-2"
|
||||||
className="px-3 py-2 text-left font-semibold"
|
style={{
|
||||||
style={{
|
border: `1px solid ${PROSE_TABLE_BORDER}`,
|
||||||
border: `1px solid ${PROSE_TABLE_BORDER}`,
|
color: PROSE_BODY,
|
||||||
color: PROSE_HEADING,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{hl(children)}
|
||||||
{hl(children)}
|
</td>
|
||||||
</th>
|
),
|
||||||
),
|
|
||||||
td: ({ children }) => (
|
|
||||||
<td
|
|
||||||
className="px-3 py-2"
|
|
||||||
style={{
|
|
||||||
border: `1px solid ${PROSE_TABLE_BORDER}`,
|
|
||||||
color: PROSE_BODY,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{hl(children)}
|
|
||||||
</td>
|
|
||||||
),
|
|
||||||
|
|
||||||
// Horizontal rule
|
// Horizontal rule
|
||||||
hr: () => <hr className="my-4" style={{ borderColor: PROSE_TABLE_BORDER }} />,
|
hr: () =>
|
||||||
|
isCompactPreview ? (
|
||||||
|
<span className="mx-1" style={{ color: PROSE_TABLE_BORDER }}>
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<hr className="my-4" style={{ borderColor: PROSE_TABLE_BORDER }} />
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -679,6 +773,78 @@ const LARGE_PREVIEW_CHARS = 30_000;
|
||||||
// Component
|
// Component
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
function useResolvedViewerTeamContext(
|
||||||
|
providedTeamColorByName?: ReadonlyMap<string, string>,
|
||||||
|
providedOnTeamClick?: (teamName: string) => void
|
||||||
|
): {
|
||||||
|
teamColorByName: ReadonlyMap<string, string>;
|
||||||
|
onTeamClick?: (teamName: string) => void;
|
||||||
|
} {
|
||||||
|
const teams = useStore(useShallow((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams)));
|
||||||
|
const openTeamTab = useStore((s) => (providedOnTeamClick ? NOOP_TEAM_CLICK : s.openTeamTab));
|
||||||
|
|
||||||
|
const fallbackTeamColorByName = React.useMemo(() => {
|
||||||
|
const result = new Map<string, string>();
|
||||||
|
for (const team of teams) {
|
||||||
|
if (team.teamName) {
|
||||||
|
result.set(team.teamName, team.color ?? '');
|
||||||
|
}
|
||||||
|
if (team.displayName) {
|
||||||
|
result.set(team.displayName, team.color ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [teams]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
teamColorByName: providedTeamColorByName ?? fallbackTeamColorByName ?? EMPTY_TEAM_COLOR_MAP,
|
||||||
|
onTeamClick: providedOnTeamClick ?? openTeamTab,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CompactMarkdownPreview: React.FC<CompactMarkdownPreviewProps> = React.memo(
|
||||||
|
function CompactMarkdownPreview({
|
||||||
|
content,
|
||||||
|
className = '',
|
||||||
|
teamColorByName: providedTeamColorByName,
|
||||||
|
onTeamClick: providedOnTeamClick,
|
||||||
|
}) {
|
||||||
|
const { isLight } = useTheme();
|
||||||
|
const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext(
|
||||||
|
providedTeamColorByName,
|
||||||
|
providedOnTeamClick
|
||||||
|
);
|
||||||
|
|
||||||
|
const components = React.useMemo(
|
||||||
|
() =>
|
||||||
|
createViewerMarkdownComponents(
|
||||||
|
null,
|
||||||
|
isLight,
|
||||||
|
teamColorByName,
|
||||||
|
onTeamClick,
|
||||||
|
false,
|
||||||
|
'compact-preview'
|
||||||
|
),
|
||||||
|
[isLight, onTeamClick, teamColorByName]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`min-w-0 overflow-hidden ${className}`}>
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={REHYPE_PLUGINS_NO_HIGHLIGHT}
|
||||||
|
components={components}
|
||||||
|
urlTransform={allowCustomProtocols}
|
||||||
|
allowElement={isAllowedElement}
|
||||||
|
unwrapDisallowed
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||||
content,
|
content,
|
||||||
maxHeight = 'max-h-96',
|
maxHeight = 'max-h-96',
|
||||||
|
|
@ -695,24 +861,10 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||||
const [showRaw, setShowRaw] = React.useState(false);
|
const [showRaw, setShowRaw] = React.useState(false);
|
||||||
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
|
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const teams = useStore(useShallow((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams)));
|
const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext(
|
||||||
const openTeamTab = useStore((s) => (providedOnTeamClick ? NOOP_TEAM_CLICK : s.openTeamTab));
|
providedTeamColorByName,
|
||||||
|
providedOnTeamClick
|
||||||
const fallbackTeamColorByName = React.useMemo(() => {
|
);
|
||||||
const result = new Map<string, string>();
|
|
||||||
for (const team of teams) {
|
|
||||||
if (team.teamName) {
|
|
||||||
result.set(team.teamName, team.color ?? '');
|
|
||||||
}
|
|
||||||
if (team.displayName) {
|
|
||||||
result.set(team.displayName, team.color ?? '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}, [teams]);
|
|
||||||
const teamColorByName =
|
|
||||||
providedTeamColorByName ?? fallbackTeamColorByName ?? EMPTY_TEAM_COLOR_MAP;
|
|
||||||
const onTeamClick = providedOnTeamClick ?? openTeamTab;
|
|
||||||
|
|
||||||
const isTooLarge = content.length > MAX_MARKDOWN_CHARS;
|
const isTooLarge = content.length > MAX_MARKDOWN_CHARS;
|
||||||
const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS;
|
const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS;
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { resolveSkillProjectPath } from './skillProjectUtils';
|
import { resolveSkillProjectPath } from './skillProjectUtils';
|
||||||
|
|
||||||
import type { SkillValidationIssue } from '@shared/types';
|
import type { SkillValidationIssue } from '@shared/types/extensions';
|
||||||
|
|
||||||
interface SkillDetailDialogProps {
|
interface SkillDetailDialogProps {
|
||||||
skillId: string | null;
|
skillId: string | null;
|
||||||
|
|
|
||||||
|
|
@ -144,11 +144,13 @@ export const SidebarTaskItem = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const showTeamRow = showTeamName && !hideTeamName;
|
const showTeamRow = showTeamName && !hideTeamName;
|
||||||
|
const unreadBackgroundClass =
|
||||||
|
unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex w-full cursor-pointer flex-col justify-center border-b px-2 py-1.5 text-left transition-colors hover:bg-surface-raised ${unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.08]') : ''} ${task.teamDeleted ? 'opacity-50' : ''}`}
|
className={`flex w-full cursor-pointer flex-col justify-center border-b px-2 py-1.5 text-left transition-colors hover:bg-surface-raised ${unreadBackgroundClass} ${task.teamDeleted ? 'opacity-50' : ''}`}
|
||||||
style={{ borderColor: 'var(--color-border)' }}
|
style={{ borderColor: 'var(--color-border)' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isRenaming) {
|
if (!isRenaming) {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||||
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
||||||
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
|
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
|
||||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||||
|
|
@ -70,14 +71,16 @@ export const TaskTooltip = ({
|
||||||
children,
|
children,
|
||||||
side = 'top',
|
side = 'top',
|
||||||
}: TaskTooltipProps): React.JSX.Element => {
|
}: TaskTooltipProps): React.JSX.Element => {
|
||||||
const { selectedTeamName, selectedTeamData, globalTasks, teamByName } = useStore(
|
const { selectedTeamName, selectedTeamData, selectedTeamMembers, globalTasks, teamByName } =
|
||||||
useShallow((s) => ({
|
useStore(
|
||||||
selectedTeamName: s.selectedTeamName,
|
useShallow((s) => ({
|
||||||
selectedTeamData: s.selectedTeamData,
|
selectedTeamName: s.selectedTeamName,
|
||||||
globalTasks: s.globalTasks,
|
selectedTeamData: s.selectedTeamData,
|
||||||
teamByName: s.teamByName,
|
selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName),
|
||||||
}))
|
globalTasks: s.globalTasks,
|
||||||
);
|
teamByName: s.teamByName,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
const task = useMemo(() => {
|
const task = useMemo(() => {
|
||||||
if (teamName && selectedTeamName === teamName) {
|
if (teamName && selectedTeamName === teamName) {
|
||||||
|
|
@ -105,13 +108,13 @@ export const TaskTooltip = ({
|
||||||
|
|
||||||
const members = useMemo(() => {
|
const members = useMemo(() => {
|
||||||
if (teamName && selectedTeamName === teamName) {
|
if (teamName && selectedTeamName === teamName) {
|
||||||
return selectedTeamData?.members ?? [];
|
return selectedTeamMembers;
|
||||||
}
|
}
|
||||||
if (!teamName && task && selectedTeamName === (task as { teamName?: string }).teamName) {
|
if (!teamName && task && selectedTeamName === (task as { teamName?: string }).teamName) {
|
||||||
return selectedTeamData?.members ?? [];
|
return selectedTeamMembers;
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}, [selectedTeamData, selectedTeamName, teamName, task]);
|
}, [selectedTeamMembers, selectedTeamName, teamName, task]);
|
||||||
|
|
||||||
const colorMap = useMemo(
|
const colorMap = useMemo(
|
||||||
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
() => (members ? buildMemberColorMap(members) : new Map<string, string>()),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,14 @@
|
||||||
import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import {
|
||||||
|
lazy,
|
||||||
|
memo,
|
||||||
|
Suspense,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index';
|
import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index';
|
||||||
|
|
@ -23,6 +33,9 @@ import { useStore } from '@renderer/store';
|
||||||
import {
|
import {
|
||||||
getCurrentProvisioningProgressForTeam,
|
getCurrentProvisioningProgressForTeam,
|
||||||
isTeamProvisioningActive,
|
isTeamProvisioningActive,
|
||||||
|
selectResolvedMemberForTeamName,
|
||||||
|
selectResolvedMembersForTeamName,
|
||||||
|
selectTeamMemberSnapshotsForName,
|
||||||
} from '@renderer/store/slices/teamSlice';
|
} from '@renderer/store/slices/teamSlice';
|
||||||
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
||||||
import { sumContextInjectionTokens } from '@renderer/utils/contextMath';
|
import { sumContextInjectionTokens } from '@renderer/utils/contextMath';
|
||||||
|
|
@ -109,10 +122,10 @@ import type { ContextInjection } from '@renderer/types/contextInjection';
|
||||||
import type { Session } from '@renderer/types/data';
|
import type { Session } from '@renderer/types/data';
|
||||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||||
import type {
|
import type {
|
||||||
TeamAgentRuntimeEntry,
|
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
TaskRef,
|
TaskRef,
|
||||||
|
TeamAgentRuntimeEntry,
|
||||||
TeamTaskWithKanban,
|
TeamTaskWithKanban,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
import type { EditorSelectionAction } from '@shared/types/editor';
|
import type { EditorSelectionAction } from '@shared/types/editor';
|
||||||
|
|
@ -833,8 +846,9 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
||||||
}: TeamMemberDetailDialogBridgeProps): React.JSX.Element | null {
|
}: TeamMemberDetailDialogBridgeProps): React.JSX.Element | null {
|
||||||
const {
|
const {
|
||||||
leadActivity,
|
leadActivity,
|
||||||
|
liveMember,
|
||||||
progress,
|
progress,
|
||||||
members: launchMembers,
|
launchMembers,
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
memberSpawnSnapshot,
|
memberSpawnSnapshot,
|
||||||
spawnEntry,
|
spawnEntry,
|
||||||
|
|
@ -842,8 +856,9 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
leadActivity: s.leadActivityByTeam[teamName],
|
leadActivity: s.leadActivityByTeam[teamName],
|
||||||
|
liveMember: member ? selectResolvedMemberForTeamName(s, teamName, member.name) : null,
|
||||||
progress: getCurrentProvisioningProgressForTeam(s, teamName),
|
progress: getCurrentProvisioningProgressForTeam(s, teamName),
|
||||||
members: s.selectedTeamName === teamName ? (s.selectedTeamData?.members ?? []) : [],
|
launchMembers: selectTeamMemberSnapshotsForName(s, teamName),
|
||||||
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
||||||
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
||||||
spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined,
|
spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined,
|
||||||
|
|
@ -867,7 +882,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
||||||
<MemberDetailDialog
|
<MemberDetailDialog
|
||||||
{...props}
|
{...props}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
member={member}
|
member={liveMember ?? member}
|
||||||
isLaunchSettling={isLaunchSettling}
|
isLaunchSettling={isLaunchSettling}
|
||||||
leadActivity={leadActivity}
|
leadActivity={leadActivity}
|
||||||
spawnEntry={spawnEntry}
|
spawnEntry={spawnEntry}
|
||||||
|
|
@ -917,7 +932,6 @@ export const TeamDetailView = ({
|
||||||
);
|
);
|
||||||
const provisioningBannerRef = useRef<HTMLDivElement>(null);
|
const provisioningBannerRef = useRef<HTMLDivElement>(null);
|
||||||
const wasProvisioningRef = useRef(false);
|
const wasProvisioningRef = useRef(false);
|
||||||
const pendingReplyRefreshTimerRef = useRef<number | null>(null);
|
|
||||||
const handleOpenGraphTab = useCallback(() => {
|
const handleOpenGraphTab = useCallback(() => {
|
||||||
const state = useStore.getState();
|
const state = useStore.getState();
|
||||||
const displayName = state.teamByName[teamName]?.displayName ?? teamName;
|
const displayName = state.teamByName[teamName]?.displayName ?? teamName;
|
||||||
|
|
@ -994,7 +1008,7 @@ export const TeamDetailView = ({
|
||||||
initialActivityFilter,
|
initialActivityFilter,
|
||||||
} = (e as CustomEvent).detail ?? {};
|
} = (e as CustomEvent).detail ?? {};
|
||||||
if (tn !== teamName || !data) return;
|
if (tn !== teamName || !data) return;
|
||||||
const member = data.members.find((m: { name: string }) => m.name === memberName);
|
const member = members.find((m: { name: string }) => m.name === memberName);
|
||||||
if (member) {
|
if (member) {
|
||||||
setSelectedMember(member);
|
setSelectedMember(member);
|
||||||
setSelectedMemberView({
|
setSelectedMemberView({
|
||||||
|
|
@ -1155,6 +1169,7 @@ export const TeamDetailView = ({
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
|
members,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
projects,
|
projects,
|
||||||
|
|
@ -1185,6 +1200,7 @@ export const TeamDetailView = ({
|
||||||
clearProvisioningError,
|
clearProvisioningError,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
refreshTeamData,
|
refreshTeamData,
|
||||||
|
syncTeamPendingReplyRefresh,
|
||||||
kanbanFilterQuery,
|
kanbanFilterQuery,
|
||||||
clearKanbanFilter,
|
clearKanbanFilter,
|
||||||
softDeleteTask,
|
softDeleteTask,
|
||||||
|
|
@ -1231,9 +1247,11 @@ export const TeamDetailView = ({
|
||||||
clearProvisioningError: s.clearProvisioningError,
|
clearProvisioningError: s.clearProvisioningError,
|
||||||
isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false,
|
isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false,
|
||||||
data: s.selectedTeamName === teamName ? s.selectedTeamData : null,
|
data: s.selectedTeamName === teamName ? s.selectedTeamData : null,
|
||||||
|
members: selectResolvedMembersForTeamName(s, teamName),
|
||||||
loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false,
|
loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false,
|
||||||
error: s.selectedTeamName === teamName ? s.selectedTeamError : null,
|
error: s.selectedTeamName === teamName ? s.selectedTeamError : null,
|
||||||
refreshTeamData: s.refreshTeamData,
|
refreshTeamData: s.refreshTeamData,
|
||||||
|
syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh,
|
||||||
kanbanFilterQuery: s.kanbanFilterQuery,
|
kanbanFilterQuery: s.kanbanFilterQuery,
|
||||||
clearKanbanFilter: s.clearKanbanFilter,
|
clearKanbanFilter: s.clearKanbanFilter,
|
||||||
softDeleteTask: s.softDeleteTask,
|
softDeleteTask: s.softDeleteTask,
|
||||||
|
|
@ -1267,13 +1285,12 @@ export const TeamDetailView = ({
|
||||||
diagnostic.count += 1;
|
diagnostic.count += 1;
|
||||||
|
|
||||||
const commitMs = performance.now() - renderStartedAtRef.current;
|
const commitMs = performance.now() - renderStartedAtRef.current;
|
||||||
const messagesCount = data?.messages.length ?? 0;
|
|
||||||
const tasksCount = data?.tasks.length ?? 0;
|
const tasksCount = data?.tasks.length ?? 0;
|
||||||
const membersCount = data?.members.length ?? 0;
|
const membersCount = members.length;
|
||||||
const processesCount = data?.processes.length ?? 0;
|
const processesCount = data?.processes.length ?? 0;
|
||||||
const shouldWarnSlow = commitMs >= TEAM_DETAIL_COMMIT_WARN_MS;
|
const shouldWarnSlow = commitMs >= TEAM_DETAIL_COMMIT_WARN_MS;
|
||||||
const shouldWarnBurst = diagnostic.count >= TEAM_DETAIL_RENDER_BURST_WARN_COUNT;
|
const shouldWarnBurst = diagnostic.count >= TEAM_DETAIL_RENDER_BURST_WARN_COUNT;
|
||||||
const shouldWarnLarge = messagesCount >= 150 || tasksCount >= 80;
|
const shouldWarnLarge = tasksCount >= 80;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(shouldWarnSlow || shouldWarnBurst || shouldWarnLarge) &&
|
(shouldWarnSlow || shouldWarnBurst || shouldWarnLarge) &&
|
||||||
|
|
@ -1285,7 +1302,7 @@ export const TeamDetailView = ({
|
||||||
now - diagnostic.windowStartedAt
|
now - diagnostic.windowStartedAt
|
||||||
} activeTab=${isThisTabActive ? 'yes' : 'no'} paneFocused=${isPaneFocused ? 'yes' : 'no'} loading=${
|
} activeTab=${isThisTabActive ? 'yes' : 'no'} paneFocused=${isPaneFocused ? 'yes' : 'no'} loading=${
|
||||||
loading ? 'yes' : 'no'
|
loading ? 'yes' : 'no'
|
||||||
} messages=${messagesCount} tasks=${tasksCount} members=${membersCount} processes=${processesCount} panel=${messagesPanelMode}`
|
} tasks=${tasksCount} members=${membersCount} processes=${processesCount} panel=${messagesPanelMode}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -1399,36 +1416,34 @@ export const TeamDetailView = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const leadSessionId = data?.config.leadSessionId ?? null;
|
const leadSessionId = data?.config.leadSessionId ?? null;
|
||||||
|
const pendingReplyRefreshSourceId = useId();
|
||||||
const sessionHistoryKey = useMemo(
|
const sessionHistoryKey = useMemo(
|
||||||
() => (data?.config.sessionHistory ?? []).join('|'),
|
() => (data?.config.sessionHistory ?? []).join('|'),
|
||||||
[data?.config.sessionHistory]
|
[data?.config.sessionHistory]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keep team message state fresh while we are explicitly waiting for a reply.
|
// Keep team message state fresh while we are explicitly waiting for a reply.
|
||||||
// Use a delayed single-shot refresh instead of a tight polling loop so we
|
// This stays enabled even for hidden mounted tabs, because the waiting state
|
||||||
// don't keep rewriting the whole team snapshot every 2 seconds.
|
// is renderer-local and should keep its lightweight polling until resolved.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pendingReplyRefreshTimerRef.current != null) {
|
const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0;
|
||||||
window.clearTimeout(pendingReplyRefreshTimerRef.current);
|
syncTeamPendingReplyRefresh(
|
||||||
pendingReplyRefreshTimerRef.current = null;
|
teamName,
|
||||||
}
|
pendingReplyRefreshSourceId,
|
||||||
|
Boolean(data?.isAlive) && hasPendingReplies,
|
||||||
if (!isThisTabActive) return;
|
TEAM_PENDING_REPLY_REFRESH_DELAY_MS
|
||||||
if (!data?.isAlive) return;
|
);
|
||||||
if (Object.keys(pendingRepliesByMember).length === 0) return;
|
|
||||||
|
|
||||||
pendingReplyRefreshTimerRef.current = window.setTimeout(() => {
|
|
||||||
pendingReplyRefreshTimerRef.current = null;
|
|
||||||
void refreshTeamData(teamName, { withDedup: true });
|
|
||||||
}, TEAM_PENDING_REPLY_REFRESH_DELAY_MS);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (pendingReplyRefreshTimerRef.current != null) {
|
syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId, false);
|
||||||
window.clearTimeout(pendingReplyRefreshTimerRef.current);
|
|
||||||
pendingReplyRefreshTimerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [isThisTabActive, data, pendingRepliesByMember, refreshTeamData, teamName]);
|
}, [
|
||||||
|
data?.isAlive,
|
||||||
|
pendingRepliesByMember,
|
||||||
|
pendingReplyRefreshSourceId,
|
||||||
|
syncTeamPendingReplyRefresh,
|
||||||
|
teamName,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
|
|
@ -1462,9 +1477,9 @@ export const TeamDetailView = ({
|
||||||
// Live git branch tracking for the lead project and member worktrees
|
// Live git branch tracking for the lead project and member worktrees
|
||||||
const teamProjectPath = data?.config.projectPath?.trim() ?? null;
|
const teamProjectPath = data?.config.projectPath?.trim() ?? null;
|
||||||
const leadProjectPath = useMemo(() => {
|
const leadProjectPath = useMemo(() => {
|
||||||
const explicitLeadPath = data?.members.find((member) => isLeadMember(member))?.cwd?.trim();
|
const explicitLeadPath = members.find((member) => isLeadMember(member))?.cwd?.trim();
|
||||||
return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath;
|
return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath;
|
||||||
}, [data?.members, teamProjectPath]);
|
}, [members, teamProjectPath]);
|
||||||
const branchSyncPaths = useMemo(() => {
|
const branchSyncPaths = useMemo(() => {
|
||||||
const uniquePaths = new Map<string, string>();
|
const uniquePaths = new Map<string, string>();
|
||||||
const addPath = (candidate: string | null | undefined): void => {
|
const addPath = (candidate: string | null | undefined): void => {
|
||||||
|
|
@ -1476,12 +1491,12 @@ export const TeamDetailView = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
addPath(leadProjectPath);
|
addPath(leadProjectPath);
|
||||||
for (const member of data?.members ?? []) {
|
for (const member of members) {
|
||||||
addPath(member.cwd);
|
addPath(member.cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(uniquePaths.values());
|
return Array.from(uniquePaths.values());
|
||||||
}, [data?.members, leadProjectPath]);
|
}, [members, leadProjectPath]);
|
||||||
useBranchSync(branchSyncPaths, { live: true });
|
useBranchSync(branchSyncPaths, { live: true });
|
||||||
const trackedBranches = useStore(
|
const trackedBranches = useStore(
|
||||||
useShallow((s) =>
|
useShallow((s) =>
|
||||||
|
|
@ -1499,7 +1514,7 @@ export const TeamDetailView = ({
|
||||||
const membersWithLiveBranches = useMemo(() => {
|
const membersWithLiveBranches = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
|
|
||||||
return data.members.map((member) => {
|
return members.map((member) => {
|
||||||
const memberPath = member.cwd?.trim();
|
const memberPath = member.cwd?.trim();
|
||||||
const nextGitBranch =
|
const nextGitBranch =
|
||||||
memberPath && !isLeadMember(member) && leadBranch !== null
|
memberPath && !isLeadMember(member) && leadBranch !== null
|
||||||
|
|
@ -1521,7 +1536,7 @@ export const TeamDetailView = ({
|
||||||
}
|
}
|
||||||
return nextMember;
|
return nextMember;
|
||||||
});
|
});
|
||||||
}, [data, leadBranch, trackedBranches]);
|
}, [leadBranch, members, trackedBranches]);
|
||||||
|
|
||||||
// Filter sessions to team-only using sessionHistory + leadSessionId
|
// Filter sessions to team-only using sessionHistory + leadSessionId
|
||||||
const teamSessionIds = useMemo(() => {
|
const teamSessionIds = useMemo(() => {
|
||||||
|
|
@ -1885,7 +1900,6 @@ export const TeamDetailView = ({
|
||||||
mountPoint: messagesPanelMountPoint,
|
mountPoint: messagesPanelMountPoint,
|
||||||
members: activeMembers,
|
members: activeMembers,
|
||||||
tasks: data?.tasks ?? [],
|
tasks: data?.tasks ?? [],
|
||||||
messages: data?.messages ?? [],
|
|
||||||
isTeamAlive: data?.isAlive,
|
isTeamAlive: data?.isAlive,
|
||||||
timeWindow,
|
timeWindow,
|
||||||
teamSessionIds,
|
teamSessionIds,
|
||||||
|
|
@ -1903,7 +1917,6 @@ export const TeamDetailView = ({
|
||||||
activeMembers,
|
activeMembers,
|
||||||
data?.config.leadSessionId,
|
data?.config.leadSessionId,
|
||||||
data?.isAlive,
|
data?.isAlive,
|
||||||
data?.messages,
|
|
||||||
data?.tasks,
|
data?.tasks,
|
||||||
handleCreateTaskFromMessage,
|
handleCreateTaskFromMessage,
|
||||||
handleOpenTask,
|
handleOpenTask,
|
||||||
|
|
@ -2588,7 +2601,7 @@ export const TeamDetailView = ({
|
||||||
open={requestChangesTaskId !== null}
|
open={requestChangesTaskId !== null}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
taskId={requestChangesTaskId}
|
taskId={requestChangesTaskId}
|
||||||
members={data?.members ?? []}
|
members={members}
|
||||||
onCancel={() => setRequestChangesTaskId(null)}
|
onCancel={() => setRequestChangesTaskId(null)}
|
||||||
onSubmit={(comment, taskRefs) => {
|
onSubmit={(comment, taskRefs) => {
|
||||||
if (!requestChangesTaskId) {
|
if (!requestChangesTaskId) {
|
||||||
|
|
@ -2615,7 +2628,6 @@ export const TeamDetailView = ({
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
members={membersWithLiveBranches}
|
members={membersWithLiveBranches}
|
||||||
tasks={data.tasks}
|
tasks={data.tasks}
|
||||||
messages={data.messages}
|
|
||||||
initialTab={selectedMemberView?.initialTab}
|
initialTab={selectedMemberView?.initialTab}
|
||||||
initialActivityFilter={selectedMemberView?.initialActivityFilter}
|
initialActivityFilter={selectedMemberView?.initialActivityFilter}
|
||||||
isTeamAlive={data.isAlive}
|
isTeamAlive={data.isAlive}
|
||||||
|
|
@ -2966,7 +2978,7 @@ export const TeamDetailView = ({
|
||||||
if (task) setSelectedTask(task);
|
if (task) setSelectedTask(task);
|
||||||
}}
|
}}
|
||||||
onOpenMemberProfile={(memberName, options) => {
|
onOpenMemberProfile={(memberName, options) => {
|
||||||
const member = data.members.find((m) => m.name === memberName);
|
const member = members.find((m) => m.name === memberName);
|
||||||
if (member) {
|
if (member) {
|
||||||
setSelectedMember(member);
|
setSelectedMember(member);
|
||||||
setSelectedMemberView({
|
setSelectedMemberView({
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ import type {
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamLaunchRequest,
|
TeamLaunchRequest,
|
||||||
|
TeamMemberSnapshot,
|
||||||
TeamSummary,
|
TeamSummary,
|
||||||
TeamSummaryMember,
|
TeamSummaryMember,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
@ -94,6 +95,17 @@ function folderName(fullPath: string): string {
|
||||||
return getBaseName(fullPath) || fullPath;
|
return getBaseName(fullPath) || fullPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveLaunchDialogMembers(members: readonly TeamMemberSnapshot[]): ResolvedTeamMember[] {
|
||||||
|
return members.map((member) => {
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
status: member.currentTaskId ? 'active' : 'idle',
|
||||||
|
messageCount: 0,
|
||||||
|
lastActiveAt: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderMemberChips(members: TeamSummaryMember[], isLight: boolean): React.JSX.Element {
|
function renderMemberChips(members: TeamSummaryMember[], isLight: boolean): React.JSX.Element {
|
||||||
const teamColorMap = buildMemberColorMap(members);
|
const teamColorMap = buildMemberColorMap(members);
|
||||||
return (
|
return (
|
||||||
|
|
@ -625,7 +637,7 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
try {
|
try {
|
||||||
const data = await api.teams.getData(teamName);
|
const data = await api.teams.getData(teamName);
|
||||||
setLaunchDialogTeamName(teamName);
|
setLaunchDialogTeamName(teamName);
|
||||||
setLaunchDialogMembers(data.members ?? []);
|
setLaunchDialogMembers(resolveLaunchDialogMembers(data.members ?? []));
|
||||||
setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath);
|
setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath);
|
||||||
setLaunchDialogOpen(true);
|
setLaunchDialogOpen(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||||
import { shortenDisplayPath } from '@renderer/utils/pathDisplay';
|
import { shortenDisplayPath } from '@renderer/utils/pathDisplay';
|
||||||
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
|
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
|
||||||
import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react';
|
import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react';
|
||||||
|
|
@ -149,6 +150,7 @@ export const ToolApprovalSheet: React.FC = () => {
|
||||||
teams,
|
teams,
|
||||||
selectedTeamName,
|
selectedTeamName,
|
||||||
selectedTeamData,
|
selectedTeamData,
|
||||||
|
selectedTeamMembers,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
pendingApprovals: s.pendingApprovals,
|
pendingApprovals: s.pendingApprovals,
|
||||||
|
|
@ -157,6 +159,7 @@ export const ToolApprovalSheet: React.FC = () => {
|
||||||
teams: s.teams,
|
teams: s.teams,
|
||||||
selectedTeamName: s.selectedTeamName,
|
selectedTeamName: s.selectedTeamName,
|
||||||
selectedTeamData: s.selectedTeamData,
|
selectedTeamData: s.selectedTeamData,
|
||||||
|
selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
|
|
@ -273,9 +276,9 @@ export const ToolApprovalSheet: React.FC = () => {
|
||||||
// Resolve teammate color for MemberBadge (when source !== 'lead')
|
// Resolve teammate color for MemberBadge (when source !== 'lead')
|
||||||
const sourceColor = useMemo(() => {
|
const sourceColor = useMemo(() => {
|
||||||
if (!current || current.source === 'lead') return undefined;
|
if (!current || current.source === 'lead') return undefined;
|
||||||
const member = selectedTeamData?.members?.find((m) => m.name === current.source);
|
const member = selectedTeamMembers.find((m) => m.name === current.source);
|
||||||
return member?.color;
|
return member?.color;
|
||||||
}, [current, selectedTeamData?.members]);
|
}, [current, selectedTeamMembers]);
|
||||||
|
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
import { Fragment, memo, useCallback, useMemo } from 'react';
|
import { Fragment, memo, useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import {
|
||||||
|
CompactMarkdownPreview,
|
||||||
|
MarkdownViewer,
|
||||||
|
} from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import { CopyButton } from '@renderer/components/common/CopyButton';
|
import { CopyButton } from '@renderer/components/common/CopyButton';
|
||||||
import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay';
|
import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay';
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import { TaskTooltip } from '@renderer/components/team/TaskTooltip';
|
import { TaskTooltip } from '@renderer/components/team/TaskTooltip';
|
||||||
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
|
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@renderer/components/ui/tooltip';
|
||||||
import {
|
import {
|
||||||
CARD_BG,
|
CARD_BG,
|
||||||
CARD_BG_ZEBRA,
|
CARD_BG_ZEBRA,
|
||||||
|
|
@ -780,7 +788,7 @@ export const ActivityItem = memo(
|
||||||
if (!isCrossTeamAny || !strippedText) return '';
|
if (!isCrossTeamAny || !strippedText) return '';
|
||||||
const oneLine = strippedText.replace(/\n+/g, ' ').trim();
|
const oneLine = strippedText.replace(/\n+/g, ' ').trim();
|
||||||
if (!oneLine) return '';
|
if (!oneLine) return '';
|
||||||
return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine;
|
return oneLine;
|
||||||
}, [isCrossTeamAny, strippedText]);
|
}, [isCrossTeamAny, strippedText]);
|
||||||
|
|
||||||
const rawSummary = useMemo(() => {
|
const rawSummary = useMemo(() => {
|
||||||
|
|
@ -806,8 +814,7 @@ export const ActivityItem = memo(
|
||||||
// Fallback: use the beginning of message text as preview for plain-text messages
|
// Fallback: use the beginning of message text as preview for plain-text messages
|
||||||
const plain = getSanitizedInboxMessageText(message).trim();
|
const plain = getSanitizedInboxMessageText(message).trim();
|
||||||
if (!plain) return '';
|
if (!plain) return '';
|
||||||
const oneLine = plain.replace(/\n+/g, ' ');
|
return plain.replace(/\n+/g, ' ');
|
||||||
return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine;
|
|
||||||
}, [
|
}, [
|
||||||
crossTeamPreview,
|
crossTeamPreview,
|
||||||
isSlashCommandMessage,
|
isSlashCommandMessage,
|
||||||
|
|
@ -819,6 +826,46 @@ export const ActivityItem = memo(
|
||||||
structured,
|
structured,
|
||||||
]);
|
]);
|
||||||
const summaryText = extractMarkdownPlainText(rawSummary);
|
const summaryText = extractMarkdownPlainText(rawSummary);
|
||||||
|
const compactPreviewMarkdown = useMemo(() => {
|
||||||
|
if (idleSemantic?.hasPeerSummary && idleSemantic.peerSummary) {
|
||||||
|
return idleSemantic.peerSummary;
|
||||||
|
}
|
||||||
|
if (isSlashCommandResult && message.commandOutput) {
|
||||||
|
return message.summary || getCommandOutputSummary(message.text);
|
||||||
|
}
|
||||||
|
if (isSlashCommandMessage && slashCommandMeta) {
|
||||||
|
if (slashCommandMeta.args) {
|
||||||
|
const oneLine = slashCommandMeta.args.replace(/\n+/g, ' ').trim();
|
||||||
|
return `${slashCommandMeta.command} ${oneLine}`;
|
||||||
|
}
|
||||||
|
return slashCommandMeta.command;
|
||||||
|
}
|
||||||
|
if (crossTeamPreview) return crossTeamPreview;
|
||||||
|
|
||||||
|
const formattedDisplayText = displayText?.trim() ?? '';
|
||||||
|
if (formattedDisplayText) {
|
||||||
|
return formattedDisplayText;
|
||||||
|
}
|
||||||
|
|
||||||
|
return summaryText || rawSummary;
|
||||||
|
}, [
|
||||||
|
crossTeamPreview,
|
||||||
|
displayText,
|
||||||
|
idleSemantic,
|
||||||
|
isSlashCommandMessage,
|
||||||
|
isSlashCommandResult,
|
||||||
|
message,
|
||||||
|
message.commandOutput,
|
||||||
|
rawSummary,
|
||||||
|
slashCommandMeta,
|
||||||
|
summaryText,
|
||||||
|
]);
|
||||||
|
const compactPreviewTooltipText = useMemo(() => {
|
||||||
|
const normalized = extractMarkdownPlainText(compactPreviewMarkdown)
|
||||||
|
.replace(/\n+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return normalized || compactPreviewMarkdown;
|
||||||
|
}, [compactPreviewMarkdown]);
|
||||||
const commentTaskRef =
|
const commentTaskRef =
|
||||||
message.messageKind === 'task_comment_notification' ? (message.taskRefs?.[0] ?? null) : null;
|
message.messageKind === 'task_comment_notification' ? (message.taskRefs?.[0] ?? null) : null;
|
||||||
const commentTaskDisplayId =
|
const commentTaskDisplayId =
|
||||||
|
|
@ -1178,13 +1225,109 @@ export const ActivityItem = memo(
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<TooltipProvider delayDuration={1000}>
|
||||||
className="mt-1 min-w-0 truncate text-[11px]"
|
<Tooltip>
|
||||||
style={{ color: CARD_TEXT_LIGHT }}
|
<TooltipTrigger asChild>
|
||||||
title={summaryText || rawSummary}
|
<div>
|
||||||
>
|
<CompactMarkdownPreview
|
||||||
{summaryContent}
|
content={compactPreviewMarkdown}
|
||||||
|
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
|
||||||
|
teamColorByName={teamColorByName}
|
||||||
|
onTeamClick={onTeamClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
className="max-w-sm whitespace-normal break-words"
|
||||||
|
>
|
||||||
|
{compactPreviewTooltipText}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
) : !isExpanded ? (
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{isUnread ? (
|
||||||
|
<span
|
||||||
|
className="size-2 shrink-0 rounded-full bg-blue-500"
|
||||||
|
title="Unread"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{showChevron ? (
|
||||||
|
<ChevronRight
|
||||||
|
className="size-3 shrink-0 transition-transform duration-150"
|
||||||
|
style={{
|
||||||
|
color: CARD_ICON_MUTED,
|
||||||
|
transform: isExpanded ? 'rotate(90deg)' : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{crossTeamOrigin ? (
|
||||||
|
<CrossTeamTeamBadge teamName={crossTeamOrigin.teamName} onClick={onTeamClick} />
|
||||||
|
) : null}
|
||||||
|
{senderBadge}
|
||||||
|
{!compactHeader && formattedRole && !isSlashCommandResult ? (
|
||||||
|
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
{formattedRole}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{messageTypeBadge}
|
||||||
|
{leadSourceBadge}
|
||||||
|
{statusBadge}
|
||||||
|
{recipientBadge}
|
||||||
|
<div className="relative ml-auto flex shrink-0 items-center">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
onExpand && expandItemKey
|
||||||
|
? 'text-[10px] transition-opacity group-hover:opacity-0'
|
||||||
|
: 'text-[10px]'
|
||||||
|
}
|
||||||
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
|
>
|
||||||
|
{timestamp}
|
||||||
|
</span>
|
||||||
|
{onExpand && expandItemKey && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Expand message"
|
||||||
|
className="absolute right-0 top-1/2 -translate-y-1/2 rounded p-0.5 opacity-0 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500/50 group-hover:opacity-100"
|
||||||
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onExpand(expandItemKey);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Maximize2 size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<TooltipProvider delayDuration={1000}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div>
|
||||||
|
<CompactMarkdownPreview
|
||||||
|
content={compactPreviewMarkdown}
|
||||||
|
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
|
||||||
|
teamColorByName={teamColorByName}
|
||||||
|
onTeamClick={onTeamClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
className="max-w-sm whitespace-normal break-words"
|
||||||
|
>
|
||||||
|
{compactPreviewTooltipText}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,14 @@ import {
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
|
import { CompactMarkdownPreview } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@renderer/components/ui/tooltip';
|
||||||
import {
|
import {
|
||||||
CARD_BG,
|
CARD_BG,
|
||||||
CARD_BG_ZEBRA,
|
CARD_BG_ZEBRA,
|
||||||
|
|
@ -26,12 +32,14 @@ import {
|
||||||
areThoughtMessagesEquivalentForRender,
|
areThoughtMessagesEquivalentForRender,
|
||||||
} from '@renderer/utils/messageRenderEquality';
|
} from '@renderer/utils/messageRenderEquality';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
|
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
|
||||||
import { isThoughtProtocolNoise } from '@shared/utils/inboxNoise';
|
import { isThoughtProtocolNoise } from '@shared/utils/inboxNoise';
|
||||||
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
|
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
|
||||||
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
|
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
|
||||||
import { ChevronDown, ChevronRight, ChevronUp, Maximize2 } from 'lucide-react';
|
import { ChevronDown, ChevronRight, ChevronUp, Maximize2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { buildThoughtDisplayContent } from './activityMarkdown';
|
||||||
import {
|
import {
|
||||||
AnimatedHeightReveal,
|
AnimatedHeightReveal,
|
||||||
ENTRY_REVEAL_ANIMATION_MS,
|
ENTRY_REVEAL_ANIMATION_MS,
|
||||||
|
|
@ -582,18 +590,30 @@ const LeadThoughtsGroupRowComponent = ({
|
||||||
return calls.length > 0 ? calls : undefined;
|
return calls.length > 0 ? calls : undefined;
|
||||||
}, [thoughts]);
|
}, [thoughts]);
|
||||||
|
|
||||||
// Extract text preview for header: use newest thought's text, fallback through group
|
// Reuse the same markdown preprocessing as the expanded thought body.
|
||||||
const headerTextPreview = useMemo(() => {
|
const compactPreviewMarkdown = useMemo(() => {
|
||||||
// Try newest first (most relevant), then scan for any text
|
// Try newest first (most relevant), then scan for any text
|
||||||
for (const t of thoughts) {
|
for (const t of thoughts) {
|
||||||
if (t.text && t.text.trim()) {
|
if (t.text && t.text.trim()) {
|
||||||
const plain = extractMarkdownPlainText(t.text);
|
const stripped = stripAgentBlocks(t.text).trim();
|
||||||
const firstLine = plain.split('\n').find((l) => l.trim().length > 0) ?? '';
|
if (stripped) {
|
||||||
return firstLine.trim();
|
return buildThoughtDisplayContent(t, memberColorMap, teamNames, {
|
||||||
|
preserveLineBreaks: false,
|
||||||
|
stripAgentOnlyBlocks: true,
|
||||||
|
})
|
||||||
|
.replace(/\n+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return totalToolSummary;
|
||||||
}, [thoughts]);
|
}, [memberColorMap, teamNames, thoughts, totalToolSummary]);
|
||||||
|
const compactPreviewTooltipText = useMemo(() => {
|
||||||
|
const normalized = extractMarkdownPlainText(compactPreviewMarkdown ?? '')
|
||||||
|
.replace(/\n+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return normalized || compactPreviewMarkdown;
|
||||||
|
}, [compactPreviewMarkdown]);
|
||||||
|
|
||||||
// Detect if any thought in this group is an API error
|
// Detect if any thought in this group is an API error
|
||||||
const hasApiError = useMemo(() => thoughts.some((t) => isApiErrorMessage(t.text)), [thoughts]);
|
const hasApiError = useMemo(() => thoughts.some((t) => isApiErrorMessage(t.text)), [thoughts]);
|
||||||
|
|
@ -756,7 +776,6 @@ const LeadThoughtsGroupRowComponent = ({
|
||||||
? formatTime(oldest.timestamp)
|
? formatTime(oldest.timestamp)
|
||||||
: `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`;
|
: `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`;
|
||||||
const useCompactCollapsedHeader = compactHeader && !isBodyVisible;
|
const useCompactCollapsedHeader = compactHeader && !isBodyVisible;
|
||||||
const compactPreviewText = headerTextPreview ?? totalToolSummary;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedHeightReveal animate={isNew} containerRef={ref} style={{ overflowAnchor: 'none' }}>
|
<AnimatedHeightReveal animate={isNew} containerRef={ref} style={{ overflowAnchor: 'none' }}>
|
||||||
|
|
@ -829,14 +848,113 @@ const LeadThoughtsGroupRowComponent = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{compactPreviewText ? (
|
{compactPreviewMarkdown ? (
|
||||||
<div
|
<TooltipProvider delayDuration={1000}>
|
||||||
className="mt-1 min-w-0 truncate text-[11px]"
|
<Tooltip>
|
||||||
style={{ color: headerTextPreview ? CARD_TEXT_LIGHT : CARD_ICON_MUTED }}
|
<TooltipTrigger asChild>
|
||||||
title={compactPreviewText}
|
<div>
|
||||||
>
|
<CompactMarkdownPreview
|
||||||
{compactPreviewText}
|
content={compactPreviewMarkdown}
|
||||||
|
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
|
||||||
|
teamColorByName={teamColorByName}
|
||||||
|
onTeamClick={onTeamClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
className="max-w-sm whitespace-normal break-words"
|
||||||
|
>
|
||||||
|
{compactPreviewTooltipText}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : !isBodyVisible ? (
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{canToggleBodyVisibility && !compactHeader ? (
|
||||||
|
<ChevronRight
|
||||||
|
className="size-3 shrink-0 transition-transform duration-150"
|
||||||
|
style={{
|
||||||
|
color: CARD_ICON_MUTED,
|
||||||
|
transform: isBodyVisible ? 'rotate(90deg)' : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!compactHeader ? (
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<img
|
||||||
|
src={agentAvatarUrl(leadName, 24)}
|
||||||
|
alt=""
|
||||||
|
className="size-5 rounded-full bg-[var(--color-surface-raised)]"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<LiveThoughtStatusBadge
|
||||||
|
canBeLive={canBeLive}
|
||||||
|
isTeamAlive={isTeamAlive}
|
||||||
|
leadActivity={leadActivity}
|
||||||
|
leadContextUpdatedAt={leadContextUpdatedAt}
|
||||||
|
newestTimestamp={newest.timestamp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<MemberBadge name={leadName} color={memberColor} hideAvatar />
|
||||||
|
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
{thoughts.length} thoughts
|
||||||
|
</span>
|
||||||
|
<div className="relative ml-auto flex shrink-0 items-center">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
onExpand && expandItemKey
|
||||||
|
? 'text-[10px] transition-opacity group-hover:opacity-0'
|
||||||
|
: 'text-[10px]'
|
||||||
|
}
|
||||||
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
|
>
|
||||||
|
{timestampLabel}
|
||||||
|
</span>
|
||||||
|
{onExpand && expandItemKey && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Expand thoughts"
|
||||||
|
className="absolute right-0 top-1/2 -translate-y-1/2 rounded p-0.5 opacity-0 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500/50 group-hover:opacity-100"
|
||||||
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onExpand(expandItemKey);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Maximize2 size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{compactPreviewMarkdown ? (
|
||||||
|
<TooltipProvider delayDuration={1000}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div>
|
||||||
|
<CompactMarkdownPreview
|
||||||
|
content={compactPreviewMarkdown}
|
||||||
|
className="mt-1 line-clamp-2 w-full min-w-0 max-w-full break-words text-[11px] leading-4"
|
||||||
|
teamColorByName={teamColorByName}
|
||||||
|
onTeamClick={onTeamClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
className="max-w-sm whitespace-normal break-words"
|
||||||
|
>
|
||||||
|
{compactPreviewTooltipText}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -871,26 +989,7 @@ const LeadThoughtsGroupRowComponent = ({
|
||||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
{thoughts.length} thoughts
|
{thoughts.length} thoughts
|
||||||
</span>
|
</span>
|
||||||
{!isBodyVisible && headerTextPreview ? (
|
{totalToolSummary ? (
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span
|
|
||||||
className="min-w-0 flex-1 cursor-default truncate text-[10px]"
|
|
||||||
style={{ color: CARD_TEXT_LIGHT }}
|
|
||||||
>
|
|
||||||
{headerTextPreview}
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
{totalToolSummary ? (
|
|
||||||
<TooltipContent side="bottom" className="max-w-[420px] font-mono text-[11px]">
|
|
||||||
<ToolSummaryTooltipContent
|
|
||||||
toolCalls={allToolCalls}
|
|
||||||
toolSummary={totalToolSummary}
|
|
||||||
/>
|
|
||||||
</TooltipContent>
|
|
||||||
) : null}
|
|
||||||
</Tooltip>
|
|
||||||
) : totalToolSummary ? (
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="cursor-default text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
<span className="cursor-default text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,16 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer
|
||||||
import { CopyButton } from '@renderer/components/common/CopyButton';
|
import { CopyButton } from '@renderer/components/common/CopyButton';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { CARD_ICON_MUTED, CARD_TEXT_LIGHT } from '@renderer/constants/cssVariables';
|
import { CARD_ICON_MUTED, CARD_TEXT_LIGHT } from '@renderer/constants/cssVariables';
|
||||||
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
|
||||||
import {
|
import {
|
||||||
areStringArraysEqual,
|
areStringArraysEqual,
|
||||||
areStringMapsEqual,
|
areStringMapsEqual,
|
||||||
areThoughtMessagesEquivalentForRender,
|
areThoughtMessagesEquivalentForRender,
|
||||||
} from '@renderer/utils/messageRenderEquality';
|
} from '@renderer/utils/messageRenderEquality';
|
||||||
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
||||||
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
|
import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
|
||||||
import { stripTeammateMessageBlocks } from '@shared/utils/inboxNoise';
|
|
||||||
import { Reply } from 'lucide-react';
|
import { Reply } from 'lucide-react';
|
||||||
|
|
||||||
|
import { buildThoughtDisplayContent } from './activityMarkdown';
|
||||||
import { formatTimeWithSec, ToolSummaryTooltipContent } from './LeadThoughtsGroup';
|
import { formatTimeWithSec, ToolSummaryTooltipContent } from './LeadThoughtsGroup';
|
||||||
|
|
||||||
import type { InboxMessage } from '@shared/types';
|
import type { InboxMessage } from '@shared/types';
|
||||||
|
|
@ -42,17 +41,10 @@ export const ThoughtBodyContent = memo(
|
||||||
onTeamClick,
|
onTeamClick,
|
||||||
}: ThoughtBodyContentProps): JSX.Element {
|
}: ThoughtBodyContentProps): JSX.Element {
|
||||||
const displayContent = useMemo(() => {
|
const displayContent = useMemo(() => {
|
||||||
// Strip leaked protocol XML (<teammate-message> blocks) before rendering
|
return buildThoughtDisplayContent(thought, memberColorMap, teamNames, {
|
||||||
let text = stripTeammateMessageBlocks(thought.text).replace(/\n/g, ' \n');
|
preserveLineBreaks: true,
|
||||||
text = linkifyTaskIdsInMarkdown(text, thought.taskRefs);
|
stripAgentOnlyBlocks: true,
|
||||||
if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) {
|
});
|
||||||
text = linkifyAllMentionsInMarkdown(
|
|
||||||
text,
|
|
||||||
(memberColorMap ?? new Map()) as Map<string, string>,
|
|
||||||
teamNames
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return text;
|
|
||||||
}, [thought.text, thought.taskRefs, memberColorMap, teamNames]);
|
}, [thought.text, thought.taskRefs, memberColorMap, teamNames]);
|
||||||
|
|
||||||
const handleTaskLinkClick = useCallback(
|
const handleTaskLinkClick = useCallback(
|
||||||
|
|
|
||||||
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 { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import {
|
import {
|
||||||
getTeamModelSelectionError,
|
getTeamModelSelectionError,
|
||||||
normalizeTeamModelForUi,
|
normalizeExplicitTeamModelForUi,
|
||||||
} from '@renderer/utils/teamModelAvailability';
|
} from '@renderer/utils/teamModelAvailability';
|
||||||
import {
|
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
|
||||||
getTeamProviderLabel as getCatalogTeamProviderLabel,
|
|
||||||
normalizeTeamModelForUi as normalizeCatalogTeamModelForUi,
|
|
||||||
} from '@renderer/utils/teamModelCatalog';
|
|
||||||
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
|
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
|
||||||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||||
|
|
@ -56,7 +53,9 @@ import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
|
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
||||||
import {
|
import {
|
||||||
|
buildReusableProviderPrepareModelResults,
|
||||||
getProviderPrepareCachedSnapshot,
|
getProviderPrepareCachedSnapshot,
|
||||||
type ProviderPrepareDiagnosticsModelResult,
|
type ProviderPrepareDiagnosticsModelResult,
|
||||||
runProviderPrepareDiagnostics,
|
runProviderPrepareDiagnostics,
|
||||||
|
|
@ -111,7 +110,7 @@ function getStoredTeamModel(providerId: TeamProviderId): string {
|
||||||
if (stored === null) {
|
if (stored === null) {
|
||||||
return providerId === 'anthropic' ? 'opus' : '';
|
return providerId === 'anthropic' ? 'opus' : '';
|
||||||
}
|
}
|
||||||
return normalizeCatalogTeamModelForUi(providerId, stored === '__default__' ? '' : stored);
|
return normalizeExplicitTeamModelForUi(providerId, stored === '__default__' ? '' : stored);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEphemeralRenderedProjectPath(projectPath: string | null | undefined): boolean {
|
function isEphemeralRenderedProjectPath(projectPath: string | null | undefined): boolean {
|
||||||
|
|
@ -127,14 +126,6 @@ function getProviderLabel(providerId: TeamProviderId): string {
|
||||||
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
|
return getCatalogTeamProviderLabel(providerId) ?? 'Anthropic';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPrepareModelCacheKey(
|
|
||||||
cwd: string,
|
|
||||||
providerId: TeamProviderId,
|
|
||||||
backendSummary: string | null | undefined
|
|
||||||
): string {
|
|
||||||
return `${cwd}::${providerId}::${backendSummary ?? ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function alignProvisioningChecks(
|
function alignProvisioningChecks(
|
||||||
existingChecks: ProvisioningProviderCheck[],
|
existingChecks: ProvisioningProviderCheck[],
|
||||||
providerIds: TeamProviderId[]
|
providerIds: TeamProviderId[]
|
||||||
|
|
@ -408,7 +399,7 @@ export const CreateTeamDialog = ({
|
||||||
}, [advancedKey]);
|
}, [advancedKey]);
|
||||||
|
|
||||||
const setSelectedModel = (value: string): void => {
|
const setSelectedModel = (value: string): void => {
|
||||||
const normalizedValue = normalizeTeamModelForUi(selectedProviderId, value);
|
const normalizedValue = normalizeExplicitTeamModelForUi(selectedProviderId, value);
|
||||||
setSelectedModelRaw(normalizedValue);
|
setSelectedModelRaw(normalizedValue);
|
||||||
localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, normalizedValue);
|
localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, normalizedValue);
|
||||||
};
|
};
|
||||||
|
|
@ -646,7 +637,12 @@ export const CreateTeamDialog = ({
|
||||||
return Array.from(next);
|
return Array.from(next);
|
||||||
})();
|
})();
|
||||||
const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
|
const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
|
||||||
const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary);
|
const cacheKey = buildProviderPrepareModelCacheKey({
|
||||||
|
cwd: effectiveCwd,
|
||||||
|
providerId,
|
||||||
|
backendSummary,
|
||||||
|
limitContext,
|
||||||
|
});
|
||||||
const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
|
const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
|
||||||
const cachedSnapshot = getProviderPrepareCachedSnapshot({
|
const cachedSnapshot = getProviderPrepareCachedSnapshot({
|
||||||
providerId,
|
providerId,
|
||||||
|
|
@ -716,7 +712,7 @@ export const CreateTeamDialog = ({
|
||||||
}
|
}
|
||||||
prepareModelResultsCacheRef.current.set(
|
prepareModelResultsCacheRef.current.set(
|
||||||
plan.cacheKey,
|
plan.cacheKey,
|
||||||
plan.prepResult.modelResultsById
|
buildReusableProviderPrepareModelResults(plan.prepResult.modelResultsById)
|
||||||
);
|
);
|
||||||
checks = updateProviderCheck(checks, plan.providerId, {
|
checks = updateProviderCheck(checks, plan.providerId, {
|
||||||
status: plan.prepResult.status,
|
status: plan.prepResult.status,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useEffect, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||||
import { buildTaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest';
|
import { buildTaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest';
|
||||||
import { ExternalLink } from 'lucide-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
@ -24,6 +25,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
||||||
closeGlobalTaskDetail,
|
closeGlobalTaskDetail,
|
||||||
selectedTeamName,
|
selectedTeamName,
|
||||||
selectedTeamData,
|
selectedTeamData,
|
||||||
|
selectedTeamMembers,
|
||||||
selectedTeamLoading,
|
selectedTeamLoading,
|
||||||
selectedTeamError,
|
selectedTeamError,
|
||||||
selectTeam,
|
selectTeam,
|
||||||
|
|
@ -36,6 +38,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
||||||
closeGlobalTaskDetail: s.closeGlobalTaskDetail,
|
closeGlobalTaskDetail: s.closeGlobalTaskDetail,
|
||||||
selectedTeamName: s.selectedTeamName,
|
selectedTeamName: s.selectedTeamName,
|
||||||
selectedTeamData: s.selectedTeamData,
|
selectedTeamData: s.selectedTeamData,
|
||||||
|
selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName),
|
||||||
selectedTeamLoading: s.selectedTeamLoading,
|
selectedTeamLoading: s.selectedTeamLoading,
|
||||||
selectedTeamError: s.selectedTeamError,
|
selectedTeamError: s.selectedTeamError,
|
||||||
selectTeam: s.selectTeam,
|
selectTeam: s.selectTeam,
|
||||||
|
|
@ -94,8 +97,8 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
||||||
}, [globalTaskDetail, globalTasks, isFullTeamLoaded, selectedTeamData]);
|
}, [globalTaskDetail, globalTasks, isFullTeamLoaded, selectedTeamData]);
|
||||||
|
|
||||||
const activeMembers = useMemo(
|
const activeMembers = useMemo(
|
||||||
() => (isFullTeamLoaded ? (selectedTeamData?.members.filter((m) => !m.removedAt) ?? []) : []),
|
() => (isFullTeamLoaded ? selectedTeamMembers.filter((m) => !m.removedAt) : []),
|
||||||
[isFullTeamLoaded, selectedTeamData]
|
[isFullTeamLoaded, selectedTeamMembers]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenTeam = useCallback((): void => {
|
const handleOpenTeam = useCallback((): void => {
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,10 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
|
||||||
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
import {
|
||||||
|
isTeamProvisioningActive,
|
||||||
|
selectResolvedMembersForTeamName,
|
||||||
|
} from '@renderer/store/slices/teamSlice';
|
||||||
import {
|
import {
|
||||||
isGeminiUiFrozen,
|
isGeminiUiFrozen,
|
||||||
normalizeCreateLaunchProviderForUi,
|
normalizeCreateLaunchProviderForUi,
|
||||||
|
|
@ -45,12 +48,9 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
import {
|
import {
|
||||||
getTeamModelSelectionError,
|
getTeamModelSelectionError,
|
||||||
normalizeTeamModelForUi,
|
normalizeExplicitTeamModelForUi,
|
||||||
} from '@renderer/utils/teamModelAvailability';
|
} from '@renderer/utils/teamModelAvailability';
|
||||||
import {
|
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
|
||||||
getTeamProviderLabel as getCatalogTeamProviderLabel,
|
|
||||||
normalizeTeamModelForUi as normalizeCatalogTeamModelForUi,
|
|
||||||
} from '@renderer/utils/teamModelCatalog';
|
|
||||||
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
|
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
|
||||||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
import {
|
import {
|
||||||
|
|
@ -72,7 +72,9 @@ import { EffortLevelSelector } from './EffortLevelSelector';
|
||||||
import { resolveLaunchDialogPrefill } from './launchDialogPrefill';
|
import { resolveLaunchDialogPrefill } from './launchDialogPrefill';
|
||||||
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
|
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
||||||
import {
|
import {
|
||||||
|
buildReusableProviderPrepareModelResults,
|
||||||
getProviderPrepareCachedSnapshot,
|
getProviderPrepareCachedSnapshot,
|
||||||
type ProviderPrepareDiagnosticsModelResult,
|
type ProviderPrepareDiagnosticsModelResult,
|
||||||
runProviderPrepareDiagnostics,
|
runProviderPrepareDiagnostics,
|
||||||
|
|
@ -109,14 +111,6 @@ import type {
|
||||||
UpdateSchedulePatch,
|
UpdateSchedulePatch,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
function buildPrepareModelCacheKey(
|
|
||||||
cwd: string,
|
|
||||||
providerId: TeamProviderId,
|
|
||||||
backendSummary: string | null | undefined
|
|
||||||
): string {
|
|
||||||
return `${cwd}::${providerId}::${backendSummary ?? ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function alignProvisioningChecks(
|
function alignProvisioningChecks(
|
||||||
existingChecks: ProvisioningProviderCheck[],
|
existingChecks: ProvisioningProviderCheck[],
|
||||||
providerIds: TeamProviderId[]
|
providerIds: TeamProviderId[]
|
||||||
|
|
@ -195,7 +189,7 @@ function getStoredTeamModel(providerId: TeamProviderId): string {
|
||||||
if (stored === null) {
|
if (stored === null) {
|
||||||
return providerId === 'anthropic' ? 'opus' : '';
|
return providerId === 'anthropic' ? 'opus' : '';
|
||||||
}
|
}
|
||||||
return normalizeCatalogTeamModelForUi(providerId, stored === '__default__' ? '' : stored);
|
return normalizeExplicitTeamModelForUi(providerId, stored === '__default__' ? '' : stored);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProviderLabel(providerId: TeamProviderId): string {
|
function getProviderLabel(providerId: TeamProviderId): string {
|
||||||
|
|
@ -319,7 +313,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
||||||
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
|
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
|
||||||
const prepareRequestSeqRef = useRef(0);
|
const prepareRequestSeqRef = useRef(0);
|
||||||
const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []);
|
const storeMembers = useStore((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName));
|
||||||
const previousLaunchParams = useStore((s) =>
|
const previousLaunchParams = useStore((s) =>
|
||||||
effectiveTeamName ? s.launchParamsByTeam[effectiveTeamName] : undefined
|
effectiveTeamName ? s.launchParamsByTeam[effectiveTeamName] : undefined
|
||||||
);
|
);
|
||||||
|
|
@ -467,7 +461,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
};
|
};
|
||||||
|
|
||||||
const setSelectedModel = (value: string): void => {
|
const setSelectedModel = (value: string): void => {
|
||||||
const normalizedValue = normalizeTeamModelForUi(selectedProviderId, value);
|
const normalizedValue = normalizeExplicitTeamModelForUi(selectedProviderId, value);
|
||||||
setSelectedModelRaw(normalizedValue);
|
setSelectedModelRaw(normalizedValue);
|
||||||
localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, normalizedValue);
|
localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, normalizedValue);
|
||||||
};
|
};
|
||||||
|
|
@ -932,7 +926,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
const providerPlans = selectedMemberProviders.map((providerId) => {
|
const providerPlans = selectedMemberProviders.map((providerId) => {
|
||||||
const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? [];
|
const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? [];
|
||||||
const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
|
const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
|
||||||
const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary);
|
const cacheKey = buildProviderPrepareModelCacheKey({
|
||||||
|
cwd: effectiveCwd,
|
||||||
|
providerId,
|
||||||
|
backendSummary,
|
||||||
|
limitContext,
|
||||||
|
});
|
||||||
const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
|
const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
|
||||||
const cachedSnapshot = getProviderPrepareCachedSnapshot({
|
const cachedSnapshot = getProviderPrepareCachedSnapshot({
|
||||||
providerId,
|
providerId,
|
||||||
|
|
@ -1000,7 +999,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
} else if (plan.prepResult.status === 'notes') {
|
} else if (plan.prepResult.status === 'notes') {
|
||||||
anyNotes = true;
|
anyNotes = true;
|
||||||
}
|
}
|
||||||
prepareModelResultsCacheRef.current.set(plan.cacheKey, plan.prepResult.modelResultsById);
|
prepareModelResultsCacheRef.current.set(
|
||||||
|
plan.cacheKey,
|
||||||
|
buildReusableProviderPrepareModelResults(plan.prepResult.modelResultsById)
|
||||||
|
);
|
||||||
checks = updateProviderCheck(checks, plan.providerId, {
|
checks = updateProviderCheck(checks, plan.providerId, {
|
||||||
status: plan.prepResult.status,
|
status: plan.prepResult.status,
|
||||||
backendSummary: plan.backendSummary,
|
backendSummary: plan.backendSummary,
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@ function summarizeDetail(
|
||||||
) {
|
) {
|
||||||
return 'CLI binary could not be started';
|
return 'CLI binary could not be started';
|
||||||
}
|
}
|
||||||
if (lower.includes('preflight check for `claude -p` did not complete')) {
|
if (lower.includes('preflight check for `') && lower.includes('-p` did not complete')) {
|
||||||
return 'CLI preflight did not complete';
|
return 'CLI preflight did not complete';
|
||||||
}
|
}
|
||||||
if (lower.includes('not authenticated') || lower.includes('not logged in')) {
|
if (lower.includes('not authenticated') || lower.includes('not logged in')) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
|
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
|
||||||
import { normalizeTeamModelForUi as normalizeCatalogTeamModelForUi } from '@renderer/utils/teamModelCatalog';
|
import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
||||||
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
|
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
|
|
@ -102,7 +102,7 @@ export function resolveLaunchDialogPrefill({
|
||||||
return {
|
return {
|
||||||
providerId,
|
providerId,
|
||||||
model: matchingModel
|
model: matchingModel
|
||||||
? normalizeCatalogTeamModelForUi(providerId, matchingModel)
|
? normalizeExplicitTeamModelForUi(providerId, matchingModel)
|
||||||
: getStoredModel(providerId),
|
: getStoredModel(providerId),
|
||||||
effort,
|
effort,
|
||||||
limitContext,
|
limitContext,
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
modelResultsById: Record<string, ProviderPrepareDiagnosticsModelResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildReusableProviderPrepareModelResults(
|
||||||
|
modelResultsById: Record<string, ProviderPrepareDiagnosticsModelResult>
|
||||||
|
): Record<string, ProviderPrepareDiagnosticsModelResult> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(modelResultsById).filter(([, result]) => result.status !== 'notes')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function escapeRegExp(value: string): string {
|
function escapeRegExp(value: string): string {
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
}
|
}
|
||||||
|
|
@ -187,6 +195,29 @@ function getResultReason(modelId: string, result: TeamProvisioningPrepareResult)
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getModelScopedEntries(modelId: string, result: TeamProvisioningPrepareResult): string[] {
|
||||||
|
const escapedModelId = escapeRegExp(modelId);
|
||||||
|
const scopedPattern = new RegExp(`^Selected model ${escapedModelId}\\b`, 'i');
|
||||||
|
return [...(result.details ?? []), ...(result.warnings ?? []), result.message]
|
||||||
|
.map((entry) => entry?.trim() ?? '')
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((entry) => scopedPattern.test(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScopedModelReason(modelId: string, entries: string[]): string | null {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const stripped = stripSelectedModelPrefix(modelId, entry);
|
||||||
|
if (!stripped) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const normalized = normalizeModelReason(stripped);
|
||||||
|
if (normalized) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function buildModelFailureLine(
|
function buildModelFailureLine(
|
||||||
providerId: TeamProviderId,
|
providerId: TeamProviderId,
|
||||||
modelId: string,
|
modelId: string,
|
||||||
|
|
@ -201,6 +232,116 @@ function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string
|
||||||
return [...(result.details ?? []), ...(result.warnings ?? [])];
|
return [...(result.details ?? []), ...(result.warnings ?? [])];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractTimedOutPreflightProbeModelId(detail: string): string | null {
|
||||||
|
const trimmed = detail.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!trimmed.toLowerCase().includes('preflight check for `') ||
|
||||||
|
!trimmed.toLowerCase().includes('-p` did not complete')
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const match = /--model\s+([^\s]+)/i.exec(trimmed);
|
||||||
|
return match?.[1]?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function suppressSupersededRuntimeWarnings(params: {
|
||||||
|
runtimeDetailLines: string[];
|
||||||
|
runtimeWarnings: string[];
|
||||||
|
modelResultsById: Map<string, ProviderPrepareDiagnosticsModelResult>;
|
||||||
|
}): {
|
||||||
|
runtimeDetailLines: string[];
|
||||||
|
runtimeWarnings: string[];
|
||||||
|
} {
|
||||||
|
const suppressedEntries = new Set<string>();
|
||||||
|
|
||||||
|
for (const warning of params.runtimeWarnings) {
|
||||||
|
const probedModelId = extractTimedOutPreflightProbeModelId(warning);
|
||||||
|
if (!probedModelId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (params.modelResultsById.get(probedModelId)?.status !== 'ready') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
suppressedEntries.add(warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
runtimeDetailLines: params.runtimeDetailLines.filter(
|
||||||
|
(detail) => !suppressedEntries.has(detail)
|
||||||
|
),
|
||||||
|
runtimeWarnings: params.runtimeWarnings.filter((warning) => !suppressedEntries.has(warning)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveModelResultFromBatch(
|
||||||
|
providerId: TeamProviderId,
|
||||||
|
modelId: string,
|
||||||
|
result: TeamProvisioningPrepareResult,
|
||||||
|
isOnlyModel: boolean
|
||||||
|
): ProviderPrepareDiagnosticsModelResult {
|
||||||
|
const modelScopedEntries = getModelScopedEntries(modelId, result);
|
||||||
|
const normalizedReason =
|
||||||
|
getScopedModelReason(modelId, modelScopedEntries) ??
|
||||||
|
(isOnlyModel ? normalizeModelReason(result.message) : null);
|
||||||
|
|
||||||
|
const hasVerifiedLine = modelScopedEntries.some((entry) =>
|
||||||
|
/selected model .* verified for launch\./i.test(entry)
|
||||||
|
);
|
||||||
|
if (hasVerifiedLine) {
|
||||||
|
return {
|
||||||
|
status: 'ready',
|
||||||
|
line: buildModelSuccessLine(providerId, modelId),
|
||||||
|
warningLine: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasUnavailableLine = modelScopedEntries.some((entry) =>
|
||||||
|
/selected model .* is unavailable\./i.test(entry)
|
||||||
|
);
|
||||||
|
if (hasUnavailableLine || (!result.ready && isOnlyModel)) {
|
||||||
|
return {
|
||||||
|
status: 'failed',
|
||||||
|
line: buildModelFailureLine(providerId, modelId, 'unavailable', normalizedReason),
|
||||||
|
warningLine: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasVerificationWarningLine = modelScopedEntries.some((entry) =>
|
||||||
|
/selected model .* could not be verified\./i.test(entry)
|
||||||
|
);
|
||||||
|
if (hasVerificationWarningLine || ((result.warnings?.length ?? 0) > 0 && isOnlyModel)) {
|
||||||
|
const line = buildModelFailureLine(providerId, modelId, 'check failed', normalizedReason);
|
||||||
|
return {
|
||||||
|
status: 'notes',
|
||||||
|
line,
|
||||||
|
warningLine: line,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.ready) {
|
||||||
|
return {
|
||||||
|
status: 'ready',
|
||||||
|
line: buildModelSuccessLine(providerId, modelId),
|
||||||
|
warningLine: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = buildModelFailureLine(
|
||||||
|
providerId,
|
||||||
|
modelId,
|
||||||
|
'check failed',
|
||||||
|
normalizedReason ?? 'Model verification failed'
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 'notes',
|
||||||
|
line,
|
||||||
|
warningLine: line,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function runProviderPrepareDiagnostics({
|
export async function runProviderPrepareDiagnostics({
|
||||||
cwd,
|
cwd,
|
||||||
providerId,
|
providerId,
|
||||||
|
|
@ -254,7 +395,7 @@ export async function runProviderPrepareDiagnostics({
|
||||||
const modelLines = new Map<string, string>();
|
const modelLines = new Map<string, string>();
|
||||||
let completedCount = 0;
|
let completedCount = 0;
|
||||||
let hasFailure = false;
|
let hasFailure = false;
|
||||||
let hasNotes = runtimeWarnings.length > 0;
|
let hasNotes = false;
|
||||||
const modelWarnings: string[] = [];
|
const modelWarnings: string[] = [];
|
||||||
|
|
||||||
for (const modelId of orderedModelIds) {
|
for (const modelId of orderedModelIds) {
|
||||||
|
|
@ -289,73 +430,64 @@ export async function runProviderPrepareDiagnostics({
|
||||||
|
|
||||||
emitProgress();
|
emitProgress();
|
||||||
|
|
||||||
await Promise.all(
|
const uncachedModelIds = orderedModelIds.filter((modelId) => !modelResultsById.has(modelId));
|
||||||
orderedModelIds
|
if (uncachedModelIds.length > 0) {
|
||||||
.filter((modelId) => !modelResultsById.has(modelId))
|
try {
|
||||||
.map(async (modelId) => {
|
const batchedModelResult = await prepareProvisioning(
|
||||||
try {
|
cwd,
|
||||||
const modelResult = await prepareProvisioning(
|
providerId,
|
||||||
cwd,
|
[providerId],
|
||||||
providerId,
|
uncachedModelIds,
|
||||||
[providerId],
|
limitContext
|
||||||
[modelId],
|
);
|
||||||
limitContext
|
|
||||||
);
|
|
||||||
if (!modelResult.ready) {
|
|
||||||
hasFailure = true;
|
|
||||||
const line = buildModelFailureLine(
|
|
||||||
providerId,
|
|
||||||
modelId,
|
|
||||||
'unavailable',
|
|
||||||
getResultReason(modelId, modelResult) ?? normalizeModelReason(modelResult.message)
|
|
||||||
);
|
|
||||||
modelLines.set(modelId, line);
|
|
||||||
modelResultsById.set(modelId, {
|
|
||||||
status: 'failed',
|
|
||||||
line,
|
|
||||||
warningLine: null,
|
|
||||||
});
|
|
||||||
} else if ((modelResult.warnings?.length ?? 0) > 0) {
|
|
||||||
hasNotes = true;
|
|
||||||
const reason = getResultReason(modelId, modelResult);
|
|
||||||
const line = buildModelFailureLine(providerId, modelId, 'check failed', reason);
|
|
||||||
modelLines.set(modelId, line);
|
|
||||||
modelWarnings.push(line);
|
|
||||||
modelResultsById.set(modelId, {
|
|
||||||
status: 'notes',
|
|
||||||
line,
|
|
||||||
warningLine: line,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const line = buildModelSuccessLine(providerId, modelId);
|
|
||||||
modelLines.set(modelId, line);
|
|
||||||
modelResultsById.set(modelId, {
|
|
||||||
status: 'ready',
|
|
||||||
line,
|
|
||||||
warningLine: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
hasNotes = true;
|
|
||||||
const reason = normalizeModelReason(
|
|
||||||
error instanceof Error ? error.message.trim() : String(error).trim()
|
|
||||||
);
|
|
||||||
const line = buildModelFailureLine(providerId, modelId, 'check failed', reason || null);
|
|
||||||
modelLines.set(modelId, line);
|
|
||||||
modelWarnings.push(line);
|
|
||||||
modelResultsById.set(modelId, {
|
|
||||||
status: 'notes',
|
|
||||||
line,
|
|
||||||
warningLine: line,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
completedCount += 1;
|
|
||||||
emitProgress();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const dedupedWarnings = Array.from(new Set([...runtimeWarnings, ...modelWarnings]));
|
for (const modelId of uncachedModelIds) {
|
||||||
|
const resolvedResult = resolveModelResultFromBatch(
|
||||||
|
providerId,
|
||||||
|
modelId,
|
||||||
|
batchedModelResult,
|
||||||
|
uncachedModelIds.length === 1
|
||||||
|
);
|
||||||
|
modelLines.set(modelId, resolvedResult.line);
|
||||||
|
modelResultsById.set(modelId, resolvedResult);
|
||||||
|
if (resolvedResult.status === 'failed') {
|
||||||
|
hasFailure = true;
|
||||||
|
} else if (resolvedResult.status === 'notes') {
|
||||||
|
hasNotes = true;
|
||||||
|
}
|
||||||
|
if (resolvedResult.warningLine) {
|
||||||
|
modelWarnings.push(resolvedResult.warningLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
hasNotes = true;
|
||||||
|
const reason = normalizeModelReason(
|
||||||
|
error instanceof Error ? error.message.trim() : String(error).trim()
|
||||||
|
);
|
||||||
|
for (const modelId of uncachedModelIds) {
|
||||||
|
const line = buildModelFailureLine(providerId, modelId, 'check failed', reason || null);
|
||||||
|
modelLines.set(modelId, line);
|
||||||
|
modelWarnings.push(line);
|
||||||
|
modelResultsById.set(modelId, {
|
||||||
|
status: 'notes',
|
||||||
|
line,
|
||||||
|
warningLine: line,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
completedCount += uncachedModelIds.length;
|
||||||
|
emitProgress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredRuntime = suppressSupersededRuntimeWarnings({
|
||||||
|
runtimeDetailLines,
|
||||||
|
runtimeWarnings,
|
||||||
|
modelResultsById,
|
||||||
|
});
|
||||||
|
const dedupedWarnings = Array.from(
|
||||||
|
new Set([...filteredRuntime.runtimeWarnings, ...modelWarnings])
|
||||||
|
);
|
||||||
const selectedModelResultsById = Object.fromEntries(
|
const selectedModelResultsById = Object.fromEntries(
|
||||||
orderedModelIds
|
orderedModelIds
|
||||||
.map((modelId) => [modelId, modelResultsById.get(modelId)] as const)
|
.map((modelId) => [modelId, modelResultsById.get(modelId)] as const)
|
||||||
|
|
@ -365,9 +497,9 @@ export async function runProviderPrepareDiagnostics({
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: hasFailure ? 'failed' : hasNotes ? 'notes' : 'ready',
|
status: hasFailure ? 'failed' : hasNotes || dedupedWarnings.length > 0 ? 'notes' : 'ready',
|
||||||
details: [
|
details: [
|
||||||
...runtimeDetailLines,
|
...filteredRuntime.runtimeDetailLines,
|
||||||
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
|
...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''),
|
||||||
],
|
],
|
||||||
warnings: dedupedWarnings,
|
warnings: dedupedWarnings,
|
||||||
|
|
|
||||||
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);
|
onViewChanges!(task.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : canDisplay && task.changePresence === 'no_changes' ? (
|
|
||||||
<span className="inline-flex h-6 shrink-0 items-center rounded-full border border-[var(--color-border)] px-2 text-[10px] text-[var(--color-text-muted)]">
|
|
||||||
No changes
|
|
||||||
</span>
|
|
||||||
) : null}
|
) : null}
|
||||||
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
|
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
|
||||||
{onDeleteTask ? (
|
{onDeleteTask ? (
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import type { TeamTaskWithKanban } from '@shared/types';
|
||||||
interface CurrentTaskIndicatorProps {
|
interface CurrentTaskIndicatorProps {
|
||||||
task: TeamTaskWithKanban;
|
task: TeamTaskWithKanban;
|
||||||
borderColor: string;
|
borderColor: string;
|
||||||
/** Max characters for the subject before truncating */
|
|
||||||
maxSubjectLength?: number;
|
maxSubjectLength?: number;
|
||||||
activityLabel?: string;
|
activityLabel?: string;
|
||||||
onOpenTask?: () => void;
|
onOpenTask?: () => void;
|
||||||
|
|
@ -19,21 +18,24 @@ interface CurrentTaskIndicatorProps {
|
||||||
export const CurrentTaskIndicator = ({
|
export const CurrentTaskIndicator = ({
|
||||||
task,
|
task,
|
||||||
borderColor,
|
borderColor,
|
||||||
maxSubjectLength = 36,
|
maxSubjectLength,
|
||||||
activityLabel = 'working on',
|
activityLabel = 'working on',
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
}: CurrentTaskIndicatorProps): React.JSX.Element => {
|
}: CurrentTaskIndicatorProps): React.JSX.Element => {
|
||||||
const truncated = task.subject.length > maxSubjectLength;
|
const subjectText =
|
||||||
const subjectText = truncated ? `${task.subject.slice(0, maxSubjectLength)}…` : task.subject;
|
typeof maxSubjectLength === 'number' &&
|
||||||
|
maxSubjectLength > 0 &&
|
||||||
|
task.subject.length > maxSubjectLength
|
||||||
|
? `${task.subject.slice(0, maxSubjectLength)}…`
|
||||||
|
: task.subject;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||||
<Loader2 className="size-3 shrink-0 animate-spin" style={{ color: borderColor }} />
|
<Loader2 className="size-3 shrink-0 animate-spin" style={{ color: borderColor }} />
|
||||||
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">{activityLabel}</span>
|
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">{activityLabel}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="min-w-0 shrink truncate rounded px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
className="min-w-0 flex-1 truncate rounded px-1.5 py-0.5 text-left text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
||||||
style={{ border: `1px solid ${borderColor}40` }}
|
|
||||||
title="Open task"
|
title="Open task"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -49,6 +51,6 @@ export const CurrentTaskIndicator = ({
|
||||||
>
|
>
|
||||||
{formatTaskDisplayLabel(task)} {subjectText}
|
{formatTaskDisplayLabel(task)} {subjectText}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { getTeamColorSet, getThemedBadge, scaleColorAlpha } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import {
|
import {
|
||||||
|
|
@ -101,6 +101,7 @@ export const MemberCard = ({
|
||||||
const completed = taskCounts?.completed ?? 0;
|
const completed = taskCounts?.completed ?? 0;
|
||||||
const totalTasks = pending + inProgress + completed;
|
const totalTasks = pending + inProgress + completed;
|
||||||
const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
|
const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
|
||||||
|
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
||||||
const activityTask = currentTask ?? reviewTask ?? null;
|
const activityTask = currentTask ?? reviewTask ?? null;
|
||||||
const activityTitle = currentTask
|
const activityTitle = currentTask
|
||||||
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
|
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
|
||||||
|
|
@ -111,7 +112,8 @@ export const MemberCard = ({
|
||||||
!isRemoved &&
|
!isRemoved &&
|
||||||
presenceLabel === 'starting' &&
|
presenceLabel === 'starting' &&
|
||||||
spawnLaunchState !== 'failed_to_start' &&
|
spawnLaunchState !== 'failed_to_start' &&
|
||||||
!activityTask;
|
!activityTask &&
|
||||||
|
!runtimeSummary;
|
||||||
const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask;
|
const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask;
|
||||||
const showRuntimeAdvisoryBadge =
|
const showRuntimeAdvisoryBadge =
|
||||||
!isRemoved &&
|
!isRemoved &&
|
||||||
|
|
@ -119,18 +121,14 @@ export const MemberCard = ({
|
||||||
!showStartingBadge &&
|
!showStartingBadge &&
|
||||||
spawnStatus !== 'error' &&
|
spawnStatus !== 'error' &&
|
||||||
(Boolean(activityTask) || !isAwaitingReply);
|
(Boolean(activityTask) || !isAwaitingReply);
|
||||||
const cardTint = scaleColorAlpha(getThemedBadge(colors, isLight), 0.5);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`rounded transition-opacity duration-300 ${isRemoved ? 'opacity-50' : ''} ${spawnCardClass}`}
|
className={`rounded transition-opacity duration-300 ${isRemoved ? 'opacity-50' : ''} ${spawnCardClass}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="group relative cursor-pointer rounded px-2 py-1.5"
|
className="group relative cursor-pointer rounded py-1.5"
|
||||||
style={{
|
style={undefined}
|
||||||
borderLeft: `3px solid ${colors.border}`,
|
|
||||||
background: `linear-gradient(to right, ${cardTint}, transparent)`,
|
|
||||||
}}
|
|
||||||
title={activityTitle}
|
title={activityTitle}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|
@ -145,19 +143,27 @@ export const MemberCard = ({
|
||||||
<div className="pointer-events-none absolute inset-0 rounded transition-colors group-hover:bg-white/5" />
|
<div className="pointer-events-none absolute inset-0 rounded transition-colors group-hover:bg-white/5" />
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
<img
|
<div
|
||||||
src={agentAvatarUrl(member.name)}
|
className="rounded-full border-2 p-[1px]"
|
||||||
alt={member.name}
|
style={{
|
||||||
className="size-7 rounded-full bg-[var(--color-surface-raised)]"
|
borderColor: colors.border,
|
||||||
loading="lazy"
|
boxShadow: isLight ? 'none' : `0 0 0 1px ${colors.badge}`,
|
||||||
/>
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={agentAvatarUrl(member.name)}
|
||||||
|
alt={member.name}
|
||||||
|
className="size-7 rounded-full bg-[var(--color-surface-raised)]"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<span
|
<span
|
||||||
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
|
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
|
||||||
aria-label={presenceLabel}
|
aria-label={presenceLabel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex min-w-0 items-center gap-1.5 truncate text-sm">
|
<div className="flex min-w-0 items-center gap-1.5 text-sm">
|
||||||
<span className="shrink-0 font-medium text-[var(--color-text)]">
|
<span className="shrink-0 font-medium text-[var(--color-text)]">
|
||||||
{displayMemberName(member.name)}
|
{displayMemberName(member.name)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -209,20 +215,16 @@ export const MemberCard = ({
|
||||||
style={{ backgroundColor: 'var(--skeleton-base)' }}
|
style={{ backgroundColor: 'var(--skeleton-base)' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : runtimeSummary ? (
|
) : runtimeSummary || roleLabel ? (
|
||||||
<div className="mt-0.5 text-[10px] font-medium text-[var(--color-text-muted)]">
|
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||||
{runtimeSummary}
|
{runtimeSummary ? <span className="min-w-0 truncate">{runtimeSummary}</span> : null}
|
||||||
|
{runtimeSummary && roleLabel ? (
|
||||||
|
<span className="shrink-0 opacity-60">•</span>
|
||||||
|
) : null}
|
||||||
|
{roleLabel ? <span className="shrink-0">{roleLabel}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
|
||||||
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
|
||||||
return roleLabel ? (
|
|
||||||
<span className="hidden shrink-0 text-xs text-[var(--color-text-muted)] sm:inline">
|
|
||||||
{roleLabel}
|
|
||||||
</span>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
{showStartingBadge ? (
|
{showStartingBadge ? (
|
||||||
<span className="flex shrink-0 items-center gap-1">
|
<span className="flex shrink-0 items-center gap-1">
|
||||||
<Loader2
|
<Loader2
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import {
|
|
||||||
buildGraphMemberNodeIdForMember,
|
|
||||||
buildInlineActivityEntries,
|
|
||||||
} from '@features/agent-graph/renderer';
|
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||||
import { useMemberStats } from '@renderer/hooks/useMemberStats';
|
import { useMemberStats } from '@renderer/hooks/useMemberStats';
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
|
import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice';
|
||||||
import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
|
import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import {
|
import {
|
||||||
|
|
@ -20,6 +18,7 @@ import {
|
||||||
UserMinus,
|
UserMinus,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { buildMemberActivityEntries } from './memberActivityEntries';
|
||||||
import { MemberDetailHeader } from './MemberDetailHeader';
|
import { MemberDetailHeader } from './MemberDetailHeader';
|
||||||
import { MemberDetailStats } from './MemberDetailStats';
|
import { MemberDetailStats } from './MemberDetailStats';
|
||||||
import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes';
|
import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes';
|
||||||
|
|
@ -28,15 +27,14 @@ import { MemberMessagesTab } from './MemberMessagesTab';
|
||||||
import { MemberStatsTab } from './MemberStatsTab';
|
import { MemberStatsTab } from './MemberStatsTab';
|
||||||
import { MemberTasksTab } from './MemberTasksTab';
|
import { MemberTasksTab } from './MemberTasksTab';
|
||||||
|
|
||||||
|
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
|
||||||
import type {
|
import type {
|
||||||
InboxMessage,
|
|
||||||
LeadActivityState,
|
LeadActivityState,
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
TeamAgentRuntimeEntry,
|
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
|
TeamAgentRuntimeEntry,
|
||||||
TeamTaskWithKanban,
|
TeamTaskWithKanban,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
|
|
||||||
|
|
||||||
interface MemberDetailDialogProps {
|
interface MemberDetailDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -44,7 +42,6 @@ interface MemberDetailDialogProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
members: ResolvedTeamMember[];
|
members: ResolvedTeamMember[];
|
||||||
tasks: TeamTaskWithKanban[];
|
tasks: TeamTaskWithKanban[];
|
||||||
messages: InboxMessage[];
|
|
||||||
initialTab?: MemberDetailTab;
|
initialTab?: MemberDetailTab;
|
||||||
initialActivityFilter?: MemberActivityFilter;
|
initialActivityFilter?: MemberActivityFilter;
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
|
|
@ -71,7 +68,6 @@ export const MemberDetailDialog = ({
|
||||||
teamName,
|
teamName,
|
||||||
members,
|
members,
|
||||||
tasks,
|
tasks,
|
||||||
messages,
|
|
||||||
initialTab = 'tasks',
|
initialTab = 'tasks',
|
||||||
initialActivityFilter = 'all',
|
initialActivityFilter = 'all',
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
|
|
@ -95,33 +91,20 @@ export const MemberDetailDialog = ({
|
||||||
() => (member ? tasks.filter((t) => t.owner === member.name) : []),
|
() => (member ? tasks.filter((t) => t.owner === member.name) : []),
|
||||||
[tasks, member]
|
[tasks, member]
|
||||||
);
|
);
|
||||||
|
const memberMessages = useStore((state) =>
|
||||||
const seedMemberMessages = useMemo(
|
selectMemberMessagesForTeamMember(state, teamName, member?.name ?? null)
|
||||||
() => (member ? messages.filter((m) => m.from === member.name || m.to === member.name) : []),
|
|
||||||
[messages, member]
|
|
||||||
);
|
);
|
||||||
const memberMessages = seedMemberMessages;
|
|
||||||
const memberActivityCount = useMemo(() => {
|
const memberActivityCount = useMemo(() => {
|
||||||
if (!member) {
|
if (!member) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const leadId = `lead:${teamName}`;
|
return buildMemberActivityEntries({
|
||||||
const leadName =
|
|
||||||
members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`;
|
|
||||||
const ownerNodeId =
|
|
||||||
member.name === leadName ? leadId : buildGraphMemberNodeIdForMember(teamName, member);
|
|
||||||
const entries = buildInlineActivityEntries({
|
|
||||||
data: {
|
|
||||||
members,
|
|
||||||
tasks,
|
|
||||||
messages: memberMessages,
|
|
||||||
},
|
|
||||||
teamName,
|
teamName,
|
||||||
leadId,
|
memberName: member.name,
|
||||||
leadName,
|
members,
|
||||||
ownerNodeIds: new Set([leadId, ownerNodeId]),
|
tasks,
|
||||||
});
|
messages: memberMessages,
|
||||||
return (entries.get(ownerNodeId) ?? []).length;
|
}).length;
|
||||||
}, [member, memberMessages, members, tasks, teamName]);
|
}, [member, memberMessages, members, tasks, teamName]);
|
||||||
|
|
||||||
const inProgressTasks = useMemo(
|
const inProgressTasks = useMemo(
|
||||||
|
|
@ -236,7 +219,6 @@ export const MemberDetailDialog = ({
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="activity">
|
<TabsContent value="activity">
|
||||||
<MemberMessagesTab
|
<MemberMessagesTab
|
||||||
messages={memberMessages}
|
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
memberName={member.name}
|
memberName={member.name}
|
||||||
members={members}
|
members={members}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,13 @@ import {
|
||||||
} from '@renderer/constants/teamColors';
|
} from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice';
|
import {
|
||||||
|
getCurrentProvisioningProgressForTeam,
|
||||||
|
selectResolvedMemberForTeamName,
|
||||||
|
selectTeamIsAliveForName,
|
||||||
|
selectTeamMemberSnapshotsForName,
|
||||||
|
selectTeamTasksForName,
|
||||||
|
} from '@renderer/store/slices/teamSlice';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import {
|
import {
|
||||||
agentAvatarUrl,
|
agentAvatarUrl,
|
||||||
|
|
@ -17,6 +23,7 @@ import {
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { ExternalLink } from 'lucide-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps';
|
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps';
|
||||||
|
|
||||||
|
|
@ -38,7 +45,7 @@ interface MemberHoverCardProps {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps children in a HoverCard that shows member info on hover.
|
* Wraps children in a HoverCard that shows member info on hover.
|
||||||
* Reads member data from the store (selectedTeamData.members).
|
* Reads member data from the team snapshot + resolved member selectors.
|
||||||
* Falls back to a simple wrapper when member data is unavailable.
|
* Falls back to a simple wrapper when member data is unavailable.
|
||||||
*/
|
*/
|
||||||
export const MemberHoverCard = ({
|
export const MemberHoverCard = ({
|
||||||
|
|
@ -53,20 +60,22 @@ export const MemberHoverCard = ({
|
||||||
const effectiveTeamName = teamName ?? selectedTeamName;
|
const effectiveTeamName = teamName ?? selectedTeamName;
|
||||||
const {
|
const {
|
||||||
member,
|
member,
|
||||||
members,
|
teamMembers,
|
||||||
|
tasks,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
progress,
|
progress,
|
||||||
memberSpawnSnapshot,
|
memberSpawnSnapshot,
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
spawnEntry,
|
spawnEntry,
|
||||||
leadActivity,
|
leadActivity,
|
||||||
} = useStore((s) => {
|
} = useStore(
|
||||||
const isSelectedTeam = Boolean(effectiveTeamName && s.selectedTeamName === effectiveTeamName);
|
useShallow((s) => ({
|
||||||
const selectedTeamData = isSelectedTeam ? s.selectedTeamData : null;
|
member: effectiveTeamName
|
||||||
return {
|
? selectResolvedMemberForTeamName(s, effectiveTeamName, name)
|
||||||
member: selectedTeamData?.members.find((m) => m.name === name) ?? null,
|
: null,
|
||||||
members: selectedTeamData?.members ?? [],
|
teamMembers: effectiveTeamName ? selectTeamMemberSnapshotsForName(s, effectiveTeamName) : [],
|
||||||
isTeamAlive: selectedTeamData?.isAlive,
|
tasks: effectiveTeamName ? selectTeamTasksForName(s, effectiveTeamName) : [],
|
||||||
|
isTeamAlive: effectiveTeamName ? selectTeamIsAliveForName(s, effectiveTeamName) : undefined,
|
||||||
progress: effectiveTeamName
|
progress: effectiveTeamName
|
||||||
? getCurrentProvisioningProgressForTeam(s, effectiveTeamName)
|
? getCurrentProvisioningProgressForTeam(s, effectiveTeamName)
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -80,21 +89,16 @@ export const MemberHoverCard = ({
|
||||||
? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name]
|
? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name]
|
||||||
: undefined,
|
: undefined,
|
||||||
leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined,
|
leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined,
|
||||||
};
|
}))
|
||||||
});
|
|
||||||
const openMemberProfile = useStore((s) => s.openMemberProfile);
|
|
||||||
const tasks = useStore((s) =>
|
|
||||||
effectiveTeamName && s.selectedTeamName === effectiveTeamName
|
|
||||||
? s.selectedTeamData?.tasks
|
|
||||||
: undefined
|
|
||||||
);
|
);
|
||||||
|
const openMemberProfile = useStore((s) => s.openMemberProfile);
|
||||||
|
|
||||||
if (!member) {
|
if (!member) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const launchJoinMilestones = getLaunchJoinMilestonesFromMembers({
|
const launchJoinMilestones = getLaunchJoinMilestonesFromMembers({
|
||||||
members,
|
members: teamMembers,
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
memberSpawnSnapshot,
|
memberSpawnSnapshot,
|
||||||
});
|
});
|
||||||
|
|
@ -117,10 +121,9 @@ export const MemberHoverCard = ({
|
||||||
const presenceLabel = launchPresentation.presenceLabel;
|
const presenceLabel = launchPresentation.presenceLabel;
|
||||||
const dotClass = launchPresentation.dotClass;
|
const dotClass = launchPresentation.dotClass;
|
||||||
const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle;
|
const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle;
|
||||||
const currentTask: TeamTaskWithKanban | null =
|
const currentTask: TeamTaskWithKanban | null = member.currentTaskId
|
||||||
member.currentTaskId && tasks
|
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
|
||||||
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
|
: null;
|
||||||
: null;
|
|
||||||
const reviewTask: TeamTaskWithKanban | null = tasks
|
const reviewTask: TeamTaskWithKanban | null = tasks
|
||||||
? (tasks.find(
|
? (tasks.find(
|
||||||
(task) =>
|
(task) =>
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,9 @@ import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
|
||||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||||
import type {
|
import type {
|
||||||
LeadActivityState,
|
LeadActivityState,
|
||||||
TeamAgentRuntimeEntry,
|
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
|
TeamAgentRuntimeEntry,
|
||||||
TeamTaskWithKanban,
|
TeamTaskWithKanban,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { buildInlineActivityEntries } from '@features/agent-graph/renderer';
|
|
||||||
import { api } from '@renderer/api';
|
|
||||||
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
|
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
|
||||||
import {
|
import {
|
||||||
buildMessageContext,
|
buildMessageContext,
|
||||||
|
|
@ -10,17 +8,18 @@ import {
|
||||||
import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog';
|
import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||||
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
|
import { useStore } from '@renderer/store';
|
||||||
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
import { buildMemberActivityEntries } from './memberActivityEntries';
|
||||||
|
|
||||||
import type { MemberActivityFilter } from './memberDetailTypes';
|
import type { MemberActivityFilter } from './memberDetailTypes';
|
||||||
import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup';
|
import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup';
|
||||||
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
interface MemberMessagesTabProps {
|
interface MemberMessagesTabProps {
|
||||||
messages: InboxMessage[];
|
|
||||||
teamName: string;
|
teamName: string;
|
||||||
memberName: string;
|
memberName: string;
|
||||||
members: ResolvedTeamMember[];
|
members: ResolvedTeamMember[];
|
||||||
|
|
@ -31,7 +30,6 @@ interface MemberMessagesTabProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_MESSAGES = 100;
|
const MAX_MESSAGES = 100;
|
||||||
const MEMBER_MESSAGES_PAGE_SIZE = 50;
|
|
||||||
const FILTER_OPTIONS: readonly { value: MemberActivityFilter; label: string }[] = [
|
const FILTER_OPTIONS: readonly { value: MemberActivityFilter; label: string }[] = [
|
||||||
{ value: 'all', label: 'All' },
|
{ value: 'all', label: 'All' },
|
||||||
{ value: 'messages', label: 'Messages' },
|
{ value: 'messages', label: 'Messages' },
|
||||||
|
|
@ -39,7 +37,6 @@ const FILTER_OPTIONS: readonly { value: MemberActivityFilter; label: string }[]
|
||||||
];
|
];
|
||||||
|
|
||||||
export const MemberMessagesTab = ({
|
export const MemberMessagesTab = ({
|
||||||
messages,
|
|
||||||
teamName,
|
teamName,
|
||||||
memberName,
|
memberName,
|
||||||
members,
|
members,
|
||||||
|
|
@ -48,21 +45,16 @@ export const MemberMessagesTab = ({
|
||||||
onCreateTask,
|
onCreateTask,
|
||||||
onTaskClick,
|
onTaskClick,
|
||||||
}: MemberMessagesTabProps): React.JSX.Element => {
|
}: MemberMessagesTabProps): React.JSX.Element => {
|
||||||
const [pagedMessages, setPagedMessages] = useState<InboxMessage[]>([]);
|
|
||||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
|
||||||
const [hasMore, setHasMore] = useState(false);
|
|
||||||
const [initialPageLoading, setInitialPageLoading] = useState(false);
|
|
||||||
const [loadingOlderMessages, setLoadingOlderMessages] = useState(false);
|
|
||||||
const [activityFilter, setActivityFilter] = useState<MemberActivityFilter>(initialFilter);
|
const [activityFilter, setActivityFilter] = useState<MemberActivityFilter>(initialFilter);
|
||||||
const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(null);
|
const [expandedItem, setExpandedItem] = useState<TimelineItem | null>(null);
|
||||||
const { readSet } = useTeamMessagesRead(teamName);
|
const { messages, messagesState, loadOlderTeamMessages } = useStore(
|
||||||
const leadId = `lead:${teamName}`;
|
useShallow((s) => ({
|
||||||
const leadName = useMemo(
|
messages: selectMemberMessagesForTeamMember(s, teamName, memberName),
|
||||||
() => members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`,
|
messagesState: teamName ? s.teamMessagesByName[teamName] : undefined,
|
||||||
[members, teamName]
|
loadOlderTeamMessages: s.loadOlderTeamMessages,
|
||||||
|
}))
|
||||||
);
|
);
|
||||||
const ownerNodeId = memberName === leadName ? leadId : `member:${teamName}:${memberName}`;
|
const { readSet } = useTeamMessagesRead(teamName);
|
||||||
const ownerNodeIds = useMemo(() => new Set([leadId, ownerNodeId]), [leadId, ownerNodeId]);
|
|
||||||
const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
|
const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
|
||||||
const messageContext = useMemo(() => buildMessageContext(members), [members]);
|
const messageContext = useMemo(() => buildMessageContext(members), [members]);
|
||||||
|
|
||||||
|
|
@ -70,108 +62,45 @@ export const MemberMessagesTab = ({
|
||||||
setActivityFilter(initialFilter);
|
setActivityFilter(initialFilter);
|
||||||
}, [initialFilter, memberName, teamName]);
|
}, [initialFilter, memberName, teamName]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
setPagedMessages([]);
|
|
||||||
setNextCursor(null);
|
|
||||||
setHasMore(false);
|
|
||||||
setInitialPageLoading(true);
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const page = await api.teams.getMessagesPage(teamName, {
|
|
||||||
limit: MEMBER_MESSAGES_PAGE_SIZE,
|
|
||||||
});
|
|
||||||
if (cancelled) return;
|
|
||||||
const memberPageMessages = page.messages.filter(
|
|
||||||
(message) => message.from === memberName || message.to === memberName
|
|
||||||
);
|
|
||||||
setPagedMessages(memberPageMessages);
|
|
||||||
setNextCursor(page.nextCursor);
|
|
||||||
setHasMore(page.hasMore);
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) {
|
|
||||||
setPagedMessages([]);
|
|
||||||
setNextCursor(null);
|
|
||||||
setHasMore(false);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) {
|
|
||||||
setInitialPageLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [teamName, memberName]);
|
|
||||||
|
|
||||||
const loadOlderMessages = useCallback(async () => {
|
const loadOlderMessages = useCallback(async () => {
|
||||||
if (!nextCursor || loadingOlderMessages) return;
|
if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) {
|
||||||
setLoadingOlderMessages(true);
|
return;
|
||||||
try {
|
|
||||||
const page = await api.teams.getMessagesPage(teamName, {
|
|
||||||
beforeTimestamp: nextCursor,
|
|
||||||
limit: MEMBER_MESSAGES_PAGE_SIZE,
|
|
||||||
});
|
|
||||||
const memberPageMessages = page.messages.filter(
|
|
||||||
(message) => message.from === memberName || message.to === memberName
|
|
||||||
);
|
|
||||||
setPagedMessages((prev) => mergeTeamMessages(prev, memberPageMessages));
|
|
||||||
setNextCursor(page.nextCursor);
|
|
||||||
setHasMore(page.hasMore);
|
|
||||||
} catch {
|
|
||||||
// best-effort
|
|
||||||
} finally {
|
|
||||||
setLoadingOlderMessages(false);
|
|
||||||
}
|
}
|
||||||
}, [loadingOlderMessages, memberName, nextCursor, teamName]);
|
await loadOlderTeamMessages(teamName);
|
||||||
|
}, [loadOlderTeamMessages, messagesState, teamName]);
|
||||||
|
|
||||||
const effectiveMessages = useMemo(
|
const loading = (messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false);
|
||||||
() => mergeTeamMessages(messages, pagedMessages),
|
const loadingOlderMessages = messagesState?.loadingOlder ?? false;
|
||||||
[messages, pagedMessages]
|
const hasMore = messagesState?.hasMore ?? false;
|
||||||
);
|
|
||||||
|
|
||||||
const filteredMessages = useMemo(
|
|
||||||
() =>
|
|
||||||
filterTeamMessages(effectiveMessages, {
|
|
||||||
timeWindow: null,
|
|
||||||
filter: { from: new Set(), to: new Set(), showNoise: true },
|
|
||||||
searchQuery: '',
|
|
||||||
}),
|
|
||||||
[effectiveMessages]
|
|
||||||
);
|
|
||||||
|
|
||||||
const activityEntries = useMemo(() => {
|
const activityEntries = useMemo(() => {
|
||||||
const entriesByOwner = buildInlineActivityEntries({
|
return buildMemberActivityEntries({
|
||||||
data: {
|
|
||||||
members,
|
|
||||||
tasks,
|
|
||||||
messages: filteredMessages,
|
|
||||||
},
|
|
||||||
teamName,
|
teamName,
|
||||||
leadId,
|
memberName,
|
||||||
leadName,
|
members,
|
||||||
ownerNodeIds,
|
tasks,
|
||||||
|
messages,
|
||||||
});
|
});
|
||||||
return (entriesByOwner.get(ownerNodeId) ?? []).slice(0, MAX_MESSAGES);
|
}, [memberName, members, messages, tasks, teamName]);
|
||||||
}, [filteredMessages, leadId, leadName, members, ownerNodeId, ownerNodeIds, tasks, teamName]);
|
const visibleActivityEntries = useMemo(
|
||||||
|
() => activityEntries.slice(0, MAX_MESSAGES),
|
||||||
|
[activityEntries]
|
||||||
|
);
|
||||||
|
|
||||||
const displayEntries = useMemo(() => {
|
const displayEntries = useMemo(() => {
|
||||||
switch (activityFilter) {
|
switch (activityFilter) {
|
||||||
case 'messages':
|
case 'messages':
|
||||||
return activityEntries.filter(
|
return visibleActivityEntries.filter(
|
||||||
(entry) => entry.message.messageKind !== 'task_comment_notification'
|
(entry) => entry.message.messageKind !== 'task_comment_notification'
|
||||||
);
|
);
|
||||||
case 'comments':
|
case 'comments':
|
||||||
return activityEntries.filter(
|
return visibleActivityEntries.filter(
|
||||||
(entry) => entry.message.messageKind === 'task_comment_notification'
|
(entry) => entry.message.messageKind === 'task_comment_notification'
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return activityEntries;
|
return visibleActivityEntries;
|
||||||
}
|
}
|
||||||
}, [activityEntries, activityFilter]);
|
}, [activityFilter, visibleActivityEntries]);
|
||||||
|
|
||||||
const expandedItemsByKey = useMemo(() => {
|
const expandedItemsByKey = useMemo(() => {
|
||||||
const items = new Map<string, TimelineItem>();
|
const items = new Map<string, TimelineItem>();
|
||||||
|
|
@ -201,6 +130,7 @@ export const MemberMessagesTab = ({
|
||||||
[onTaskClick, taskMap, tasks]
|
[onTaskClick, taskMap, tasks]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const initialPageLoading = loading && activityEntries.length === 0;
|
||||||
const emptyStateText = initialPageLoading
|
const emptyStateText = initialPageLoading
|
||||||
? 'Loading activity...'
|
? 'Loading activity...'
|
||||||
: activityFilter === 'comments'
|
: activityFilter === 'comments'
|
||||||
|
|
@ -209,9 +139,10 @@ export const MemberMessagesTab = ({
|
||||||
? hasMore
|
? hasMore
|
||||||
? 'No loaded messages for this member yet'
|
? 'No loaded messages for this member yet'
|
||||||
: 'No messages with this member'
|
: 'No messages with this member'
|
||||||
: 'No activity with this member';
|
: hasMore
|
||||||
const canLoadOlderMessages =
|
? 'No loaded activity for this member yet'
|
||||||
hasMore && activityFilter !== 'comments' && displayEntries.length > 0;
|
: 'No activity with this member';
|
||||||
|
const canLoadOlderMessages = hasMore && activityFilter !== 'comments';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
|
||||||
|
|
@ -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 { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||||
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
|
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { normalizeTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
||||||
import { normalizeTeamModelForUi as normalizeCatalogTeamModelForUi } from '@renderer/utils/teamModelCatalog';
|
|
||||||
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
|
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
|
|
||||||
|
|
@ -34,7 +32,6 @@ function newDraftId(): string {
|
||||||
|
|
||||||
export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
|
export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
|
||||||
const providerId = initial?.providerId;
|
const providerId = initial?.providerId;
|
||||||
const normalizedModel = extractProviderScopedBaseModel(initial?.model ?? '', providerId) ?? '';
|
|
||||||
return {
|
return {
|
||||||
id: initial?.id ?? newDraftId(),
|
id: initial?.id ?? newDraftId(),
|
||||||
name: initial?.name ?? '',
|
name: initial?.name ?? '',
|
||||||
|
|
@ -42,7 +39,7 @@ export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
|
||||||
customRole: initial?.customRole ?? '',
|
customRole: initial?.customRole ?? '',
|
||||||
workflow: initial?.workflow,
|
workflow: initial?.workflow,
|
||||||
providerId,
|
providerId,
|
||||||
model: normalizeCatalogTeamModelForUi(providerId, normalizedModel),
|
model: normalizeExplicitTeamModelForUi(providerId, initial?.model ?? ''),
|
||||||
effort: initial?.effort,
|
effort: initial?.effort,
|
||||||
removedAt: initial?.removedAt,
|
removedAt: initial?.removedAt,
|
||||||
};
|
};
|
||||||
|
|
@ -221,7 +218,7 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning
|
||||||
}
|
}
|
||||||
const model = member.model?.trim();
|
const model = member.model?.trim();
|
||||||
if (model) {
|
if (model) {
|
||||||
result.model = normalizeTeamModelForUi(providerId, model);
|
result.model = normalizeExplicitTeamModelForUi(providerId, model);
|
||||||
}
|
}
|
||||||
const effort = normalizeDraftEffort(member.effort);
|
const effort = normalizeDraftEffort(member.effort);
|
||||||
if (effort) {
|
if (effort) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Sheet, type SheetRef } from 'react-modal-sheet';
|
import { Sheet, type SheetRef } from 'react-modal-sheet';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
|
|
@ -9,11 +8,10 @@ import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMe
|
||||||
import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
|
import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
|
||||||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages';
|
import { selectTeamMessages } from '@renderer/store/slices/teamSlice';
|
||||||
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
|
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
|
||||||
import {
|
import {
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
ChevronsDownUp,
|
ChevronsDownUp,
|
||||||
|
|
@ -53,9 +51,6 @@ interface TimeWindow {
|
||||||
end: number;
|
end: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const logger = createLogger('Component:MessagesPanel');
|
|
||||||
const MESSAGES_PANEL_FILTER_WARN_MS = 8;
|
|
||||||
const MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS = 6;
|
|
||||||
const BOTTOM_SHEET_HEADER_HEIGHT = 40;
|
const BOTTOM_SHEET_HEADER_HEIGHT = 40;
|
||||||
const BOTTOM_SHEET_COLLAPSED_SNAP_INDEX = 1;
|
const BOTTOM_SHEET_COLLAPSED_SNAP_INDEX = 1;
|
||||||
const BOTTOM_SHEET_COMPOSER_SNAP_INDEX = 2;
|
const BOTTOM_SHEET_COMPOSER_SNAP_INDEX = 2;
|
||||||
|
|
@ -70,8 +65,6 @@ interface MessagesPanelProps {
|
||||||
members: ResolvedTeamMember[];
|
members: ResolvedTeamMember[];
|
||||||
/** All team tasks. */
|
/** All team tasks. */
|
||||||
tasks: TeamTaskWithKanban[];
|
tasks: TeamTaskWithKanban[];
|
||||||
/** All raw messages from team data. */
|
|
||||||
messages: InboxMessage[];
|
|
||||||
/** Whether the team is alive. */
|
/** Whether the team is alive. */
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
/** Live lead activity status for the current team. */
|
/** Live lead activity status for the current team. */
|
||||||
|
|
@ -107,7 +100,6 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
mountPoint,
|
mountPoint,
|
||||||
members,
|
members,
|
||||||
tasks,
|
tasks,
|
||||||
messages,
|
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
leadActivity,
|
leadActivity,
|
||||||
leadContextUpdatedAt,
|
leadContextUpdatedAt,
|
||||||
|
|
@ -130,6 +122,9 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
lastSendMessageResult,
|
lastSendMessageResult,
|
||||||
teams,
|
teams,
|
||||||
openTeamTab,
|
openTeamTab,
|
||||||
|
messages,
|
||||||
|
messagesState,
|
||||||
|
loadOlderTeamMessages,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
sendTeamMessage: s.sendTeamMessage,
|
sendTeamMessage: s.sendTeamMessage,
|
||||||
|
|
@ -139,76 +134,24 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
lastSendMessageResult: s.lastSendMessageResult,
|
lastSendMessageResult: s.lastSendMessageResult,
|
||||||
teams: s.teams,
|
teams: s.teams,
|
||||||
openTeamTab: s.openTeamTab,
|
openTeamTab: s.openTeamTab,
|
||||||
|
messages: selectTeamMessages(s, teamName),
|
||||||
|
messagesState: teamName ? s.teamMessagesByName[teamName] : undefined,
|
||||||
|
loadOlderTeamMessages: s.loadOlderTeamMessages,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Paginated message fetching ──
|
|
||||||
// Messages are now fetched via getMessagesPage API instead of coming
|
|
||||||
// from getTeamData. The `messages` prop is used as initial seed if non-empty.
|
|
||||||
const PAGE_SIZE = 50;
|
|
||||||
const [fetchedMessages, setFetchedMessages] = useState<InboxMessage[]>([]);
|
|
||||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
|
||||||
const [hasMore, setHasMore] = useState(false);
|
|
||||||
const [loadingOlderMessages, setLoadingOlderMessages] = useState(false);
|
|
||||||
const fetchIdRef = useRef(0);
|
|
||||||
|
|
||||||
// Initial fetch on mount or team change
|
|
||||||
useEffect(() => {
|
|
||||||
const id = ++fetchIdRef.current;
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE });
|
|
||||||
if (fetchIdRef.current !== id) return;
|
|
||||||
setFetchedMessages(page.messages);
|
|
||||||
setNextCursor(page.nextCursor);
|
|
||||||
setHasMore(page.hasMore);
|
|
||||||
} catch {
|
|
||||||
// Fallback: use prop messages if API fails
|
|
||||||
if (fetchIdRef.current === id && messages.length > 0) {
|
|
||||||
setFetchedMessages(messages);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [teamName]); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally only on teamName change
|
|
||||||
|
|
||||||
// Auto-refresh: poll for NEW messages only (prepend to head).
|
|
||||||
// Does NOT touch nextCursor/hasMore — those belong to the "Load older" flow.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isTeamAlive && leadActivity !== 'active') return;
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE });
|
|
||||||
setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages));
|
|
||||||
} catch {
|
|
||||||
// best-effort
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [teamName, isTeamAlive, leadActivity]);
|
|
||||||
|
|
||||||
const loadOlderMessages = useCallback(async () => {
|
const loadOlderMessages = useCallback(async () => {
|
||||||
if (!nextCursor || loadingOlderMessages) return;
|
if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) {
|
||||||
setLoadingOlderMessages(true);
|
return;
|
||||||
try {
|
|
||||||
const page = await api.teams.getMessagesPage(teamName, {
|
|
||||||
beforeTimestamp: nextCursor,
|
|
||||||
limit: PAGE_SIZE,
|
|
||||||
});
|
|
||||||
setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages));
|
|
||||||
setNextCursor(page.nextCursor);
|
|
||||||
setHasMore(page.hasMore);
|
|
||||||
} catch {
|
|
||||||
// best-effort
|
|
||||||
} finally {
|
|
||||||
setLoadingOlderMessages(false);
|
|
||||||
}
|
}
|
||||||
}, [loadingOlderMessages, nextCursor, teamName]);
|
await loadOlderTeamMessages(teamName);
|
||||||
|
}, [loadOlderTeamMessages, messagesState, teamName]);
|
||||||
|
|
||||||
// Use fetched messages, fall back to prop messages during initial load
|
const messagesLoading =
|
||||||
const effectiveMessages = useMemo(() => {
|
(messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false);
|
||||||
if (fetchedMessages.length === 0) return messages;
|
const loadingOlderMessages = messagesState?.loadingOlder ?? false;
|
||||||
return mergeTeamMessages(fetchedMessages, messages);
|
const hasMore = messagesState?.hasMore ?? false;
|
||||||
}, [fetchedMessages, messages]);
|
const effectiveMessages = messages;
|
||||||
|
|
||||||
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const sidebarScrollRef = useRef<HTMLDivElement | null>(null);
|
const sidebarScrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
@ -323,41 +266,21 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
}, [position, mountPoint]);
|
}, [position, mountPoint]);
|
||||||
|
|
||||||
const filteredMessages = useMemo(() => {
|
const filteredMessages = useMemo(() => {
|
||||||
const startedAt = performance.now();
|
return filterTeamMessages(effectiveMessages, {
|
||||||
const result = filterTeamMessages(effectiveMessages, {
|
|
||||||
timeWindow,
|
timeWindow,
|
||||||
filter: messagesFilter,
|
filter: messagesFilter,
|
||||||
searchQuery: messagesSearchQuery,
|
searchQuery: messagesSearchQuery,
|
||||||
});
|
});
|
||||||
const ms = performance.now() - startedAt;
|
}, [effectiveMessages, messagesFilter, messagesSearchQuery, timeWindow]);
|
||||||
if (ms >= MESSAGES_PANEL_FILTER_WARN_MS) {
|
|
||||||
logger.warn(
|
|
||||||
`[perf] filter team=${teamName} stage=messages ms=${ms.toFixed(1)} input=${effectiveMessages.length} output=${result.length} searchLen=${messagesSearchQuery.trim().length} noise=${
|
|
||||||
messagesFilter.showNoise ? 'on' : 'off'
|
|
||||||
}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}, [effectiveMessages, messagesFilter, messagesSearchQuery, teamName, timeWindow]);
|
|
||||||
|
|
||||||
const activityTimelineMessages = useMemo(() => {
|
const activityTimelineMessages = useMemo(() => {
|
||||||
const startedAt = performance.now();
|
return filterTeamMessages(effectiveMessages, {
|
||||||
const result = filterTeamMessages(effectiveMessages, {
|
|
||||||
includePassiveIdlePeerSummariesWhenNoiseHidden: true,
|
includePassiveIdlePeerSummariesWhenNoiseHidden: true,
|
||||||
timeWindow,
|
timeWindow,
|
||||||
filter: messagesFilter,
|
filter: messagesFilter,
|
||||||
searchQuery: messagesSearchQuery,
|
searchQuery: messagesSearchQuery,
|
||||||
});
|
});
|
||||||
const ms = performance.now() - startedAt;
|
}, [effectiveMessages, messagesFilter, messagesSearchQuery, timeWindow]);
|
||||||
if (ms >= MESSAGES_PANEL_FILTER_WARN_MS) {
|
|
||||||
logger.warn(
|
|
||||||
`[perf] filter team=${teamName} stage=timeline ms=${ms.toFixed(1)} input=${effectiveMessages.length} output=${result.length} searchLen=${messagesSearchQuery.trim().length} noise=${
|
|
||||||
messagesFilter.showNoise ? 'on' : 'off'
|
|
||||||
}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}, [effectiveMessages, messagesFilter, messagesSearchQuery, teamName, timeWindow]);
|
|
||||||
|
|
||||||
const replyCandidateMessages = useMemo(
|
const replyCandidateMessages = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -371,33 +294,21 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
|
|
||||||
// Resolve the expanded item from filtered messages
|
// Resolve the expanded item from filtered messages
|
||||||
const expandedItem = useMemo<TimelineItem | null>(() => {
|
const expandedItem = useMemo<TimelineItem | null>(() => {
|
||||||
const startedAt = performance.now();
|
if (!expandedItemKey) {
|
||||||
if (!expandedItemKey) return null;
|
return null;
|
||||||
|
}
|
||||||
if (!expandedItemKey.startsWith('thoughts-')) {
|
if (!expandedItemKey.startsWith('thoughts-')) {
|
||||||
const msg = activityTimelineMessages.find((m) => toMessageKey(m) === expandedItemKey);
|
const msg = activityTimelineMessages.find((m) => toMessageKey(m) === expandedItemKey);
|
||||||
const result: TimelineItem | null = msg ? { type: 'message', message: msg } : null;
|
return msg ? { type: 'message', message: msg } : null;
|
||||||
const ms = performance.now() - startedAt;
|
|
||||||
if (ms >= MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS) {
|
|
||||||
logger.warn(
|
|
||||||
`[perf] expandedItem team=${teamName} ms=${ms.toFixed(1)} mode=message timelineMessages=${activityTimelineMessages.length}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
const allItems = groupTimelineItems(activityTimelineMessages);
|
const allItems = groupTimelineItems(activityTimelineMessages);
|
||||||
const result =
|
return (
|
||||||
allItems.find(
|
allItems.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.type === 'lead-thoughts' && getThoughtGroupKey(item.group) === expandedItemKey
|
item.type === 'lead-thoughts' && getThoughtGroupKey(item.group) === expandedItemKey
|
||||||
) ?? null;
|
) ?? null
|
||||||
const ms = performance.now() - startedAt;
|
);
|
||||||
if (ms >= MESSAGES_PANEL_EXPANDED_ITEM_WARN_MS) {
|
}, [expandedItemKey, activityTimelineMessages]);
|
||||||
logger.warn(
|
|
||||||
`[perf] expandedItem team=${teamName} ms=${ms.toFixed(1)} mode=thoughts timelineMessages=${activityTimelineMessages.length} groups=${allItems.length}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}, [expandedItemKey, activityTimelineMessages, teamName]);
|
|
||||||
|
|
||||||
// Auto-clear stale expanded key
|
// Auto-clear stale expanded key
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,14 @@ import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import type {
|
import type {
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
MemberSpawnStatusesSnapshot,
|
MemberSpawnStatusesSnapshot,
|
||||||
ResolvedTeamMember,
|
|
||||||
TeamProvisioningProgress,
|
TeamProvisioningProgress,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
|
interface LaunchJoinMemberLike {
|
||||||
|
name: string;
|
||||||
|
removedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** Display steps for the provisioning stepper (0-indexed). */
|
/** Display steps for the provisioning stepper (0-indexed). */
|
||||||
export const DISPLAY_STEPS = [
|
export const DISPLAY_STEPS = [
|
||||||
{ key: 'starting', label: 'Starting' },
|
{ key: 'starting', label: 'Starting' },
|
||||||
|
|
@ -52,7 +56,7 @@ export function getLaunchJoinMilestonesFromMembers({
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
memberSpawnSnapshot,
|
memberSpawnSnapshot,
|
||||||
}: {
|
}: {
|
||||||
members: readonly ResolvedTeamMember[];
|
members: readonly LaunchJoinMemberLike[];
|
||||||
memberSpawnStatuses?: MemberSpawnStatusCollection;
|
memberSpawnStatuses?: MemberSpawnStatusCollection;
|
||||||
memberSpawnSnapshot?: Pick<MemberSpawnStatusesSnapshot, 'expectedMembers' | 'summary'>;
|
memberSpawnSnapshot?: Pick<MemberSpawnStatusesSnapshot, 'expectedMembers' | 'summary'>;
|
||||||
}): LaunchJoinMilestones {
|
}): LaunchJoinMilestones {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useMemo } from 'react';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import {
|
import {
|
||||||
getCurrentProvisioningProgressForTeam,
|
getCurrentProvisioningProgressForTeam,
|
||||||
selectTeamDataForName,
|
selectTeamMemberSnapshotsForName,
|
||||||
} from '@renderer/store/slices/teamSlice';
|
} from '@renderer/store/slices/teamSlice';
|
||||||
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
@ -20,7 +20,7 @@ export function useTeamProvisioningPresentation(teamName: string): {
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
progress: getCurrentProvisioningProgressForTeam(s, teamName),
|
progress: getCurrentProvisioningProgressForTeam(s, teamName),
|
||||||
cancelProvisioning: s.cancelProvisioning,
|
cancelProvisioning: s.cancelProvisioning,
|
||||||
teamMembers: selectTeamDataForName(s, teamName)?.members ?? [],
|
teamMembers: selectTeamMemberSnapshotsForName(s, teamName),
|
||||||
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
||||||
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import {
|
||||||
|
selectResolvedMembersForTeamName,
|
||||||
|
selectTeamDataForName,
|
||||||
|
} from '@renderer/store/slices/teamSlice';
|
||||||
import { createEncodedTaskReference } from '@renderer/utils/taskReferenceUtils';
|
import { createEncodedTaskReference } from '@renderer/utils/taskReferenceUtils';
|
||||||
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
|
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
@ -57,11 +61,13 @@ function isVisibleTask(task: TeamTaskWithKanban | GlobalTask): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTaskSuggestions(currentTeamName: string | null): UseTaskSuggestionsResult {
|
export function useTaskSuggestions(currentTeamName: string | null): UseTaskSuggestionsResult {
|
||||||
const { globalTasks, selectedTeamName, selectedTeamData, teamByName } = useStore(
|
const { globalTasks, currentTeamData, currentTeamMembers, teamByName } = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
globalTasks: s.globalTasks,
|
globalTasks: s.globalTasks,
|
||||||
selectedTeamName: s.selectedTeamName,
|
currentTeamData: currentTeamName ? selectTeamDataForName(s, currentTeamName) : null,
|
||||||
selectedTeamData: s.selectedTeamData,
|
currentTeamMembers: currentTeamName
|
||||||
|
? selectResolvedMembersForTeamName(s, currentTeamName)
|
||||||
|
: [],
|
||||||
teamByName: s.teamByName,
|
teamByName: s.teamByName,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
@ -73,14 +79,10 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge
|
||||||
if (currentTeamName) {
|
if (currentTeamName) {
|
||||||
const currentTeamSummary = teamByName[currentTeamName];
|
const currentTeamSummary = teamByName[currentTeamName];
|
||||||
const currentTeamDisplayName = currentTeamSummary?.displayName || currentTeamName;
|
const currentTeamDisplayName = currentTeamSummary?.displayName || currentTeamName;
|
||||||
const currentTeamMembers =
|
|
||||||
selectedTeamName === currentTeamName && selectedTeamData
|
|
||||||
? selectedTeamData.members
|
|
||||||
: (currentTeamSummary?.members ?? []);
|
|
||||||
const currentTeamTasks =
|
const currentTeamTasks =
|
||||||
selectedTeamName === currentTeamName && selectedTeamData
|
currentTeamData?.tasks ?? globalTasks.filter((task) => task.teamName === currentTeamName);
|
||||||
? selectedTeamData.tasks
|
const currentTeamMemberColors =
|
||||||
: globalTasks.filter((task) => task.teamName === currentTeamName);
|
currentTeamMembers.length > 0 ? currentTeamMembers : (currentTeamSummary?.members ?? []);
|
||||||
|
|
||||||
for (const task of currentTeamTasks) {
|
for (const task of currentTeamTasks) {
|
||||||
if (!isVisibleTask(task)) continue;
|
if (!isVisibleTask(task)) continue;
|
||||||
|
|
@ -91,7 +93,7 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge
|
||||||
teamDisplayName: currentTeamDisplayName,
|
teamDisplayName: currentTeamDisplayName,
|
||||||
teamColor: currentTeamSummary?.color,
|
teamColor: currentTeamSummary?.color,
|
||||||
isCurrentTeamTask: true,
|
isCurrentTeamTask: true,
|
||||||
ownerColor: currentTeamMembers.find((member) => member.name === task.owner)?.color,
|
ownerColor: currentTeamMemberColors.find((member) => member.name === task.owner)?.color,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +125,7 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge
|
||||||
});
|
});
|
||||||
|
|
||||||
return tasks.map(buildTaskSuggestion);
|
return tasks.map(buildTaskSuggestion);
|
||||||
}, [currentTeamName, globalTasks, selectedTeamData, selectedTeamName, teamByName]);
|
}, [currentTeamData, currentTeamMembers, currentTeamName, globalTasks, teamByName]);
|
||||||
|
|
||||||
return { suggestions };
|
return { suggestions };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,9 @@ import { createTabSlice } from './slices/tabSlice';
|
||||||
import { createTabUISlice } from './slices/tabUISlice';
|
import { createTabUISlice } from './slices/tabUISlice';
|
||||||
import {
|
import {
|
||||||
createTeamSlice,
|
createTeamSlice,
|
||||||
|
getActiveTeamPendingReplyWaits,
|
||||||
getLastResolvedTeamDataRefreshAt,
|
getLastResolvedTeamDataRefreshAt,
|
||||||
|
hasActiveTeamPendingReplyWait,
|
||||||
isTeamDataRefreshPending,
|
isTeamDataRefreshPending,
|
||||||
selectTeamDataForName,
|
selectTeamDataForName,
|
||||||
} from './slices/teamSlice';
|
} from './slices/teamSlice';
|
||||||
|
|
@ -65,6 +67,7 @@ const TEAM_CHANGE_EVENT_BURST_WARN_COUNT = 8;
|
||||||
const TEAM_CHANGE_EVENT_WARN_THROTTLE_MS = 2_000;
|
const TEAM_CHANGE_EVENT_WARN_THROTTLE_MS = 2_000;
|
||||||
const TEAM_VISIBLE_IDLE_WATCHDOG_POLL_MS = 10_000;
|
const TEAM_VISIBLE_IDLE_WATCHDOG_POLL_MS = 10_000;
|
||||||
const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000;
|
const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000;
|
||||||
|
const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_000;
|
||||||
const CURRENT_APP_VERSION =
|
const CURRENT_APP_VERSION =
|
||||||
typeof __APP_VERSION__ === 'string' ? normalizeVersion(__APP_VERSION__) : '0.0.0';
|
typeof __APP_VERSION__ === 'string' ? normalizeVersion(__APP_VERSION__) : '0.0.0';
|
||||||
const logger = createLogger('Store:index');
|
const logger = createLogger('Store:index');
|
||||||
|
|
@ -237,10 +240,12 @@ export function initializeNotificationListeners(): () => void {
|
||||||
const teamLastRelevantActivityAt = new Map<string, number>();
|
const teamLastRelevantActivityAt = new Map<string, number>();
|
||||||
const teamLastIdleWatchdogRefreshAt = new Map<string, number>();
|
const teamLastIdleWatchdogRefreshAt = new Map<string, number>();
|
||||||
let teamRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
let teamRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
let teamMessageRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
let teamPresenceRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
let teamPresenceRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
let memberSpawnRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
let memberSpawnRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
let toolActivityTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
let toolActivityTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
let inProgressChangePresencePollInFlight = false;
|
let inProgressChangePresencePollInFlight = false;
|
||||||
|
let teamMessageFallbackPollInFlight = false;
|
||||||
const inProgressChangePresenceCursorByTeam = new Map<string, number>();
|
const inProgressChangePresenceCursorByTeam = new Map<string, number>();
|
||||||
|
|
||||||
let teamListRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
let teamListRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
@ -252,6 +257,23 @@ export function initializeNotificationListeners(): () => void {
|
||||||
const TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS = 500;
|
const TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS = 500;
|
||||||
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
|
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
|
||||||
const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500;
|
const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500;
|
||||||
|
const refreshTrackedTeamMessages = async (teamName: string): Promise<void> => {
|
||||||
|
if (!teamName || !shouldRefreshTeamMessages(teamName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = useStore.getState();
|
||||||
|
try {
|
||||||
|
const headResult = await current.refreshTeamMessagesHead(teamName);
|
||||||
|
const latest = useStore.getState();
|
||||||
|
const meta = latest.memberActivityMetaByTeam[teamName];
|
||||||
|
if (headResult.feedChanged || meta?.feedRevision !== headResult.feedRevision) {
|
||||||
|
await latest.refreshMemberActivityMeta(teamName);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best-effort refresh for message-driven events and fallback polling only.
|
||||||
|
}
|
||||||
|
};
|
||||||
const scheduleMemberSpawnStatusesRefresh = (teamName: string | null | undefined): void => {
|
const scheduleMemberSpawnStatusesRefresh = (teamName: string | null | undefined): void => {
|
||||||
if (!teamName || !isTeamVisibleInAnyPane(teamName)) {
|
if (!teamName || !isTeamVisibleInAnyPane(teamName)) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -265,6 +287,19 @@ export function initializeNotificationListeners(): () => void {
|
||||||
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
|
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
|
||||||
memberSpawnRefreshTimers.set(teamName, timer);
|
memberSpawnRefreshTimers.set(teamName, timer);
|
||||||
};
|
};
|
||||||
|
const scheduleTrackedTeamMessageRefresh = (teamName: string | null | undefined): void => {
|
||||||
|
if (!teamName || !shouldRefreshTeamMessages(teamName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (teamMessageRefreshTimers.has(teamName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
teamMessageRefreshTimers.delete(teamName);
|
||||||
|
void refreshTrackedTeamMessages(teamName);
|
||||||
|
}, TEAM_REFRESH_THROTTLE_MS);
|
||||||
|
teamMessageRefreshTimers.set(teamName, timer);
|
||||||
|
};
|
||||||
const buildToolActivityTimerKey = (
|
const buildToolActivityTimerKey = (
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberName: string,
|
memberName: string,
|
||||||
|
|
@ -587,6 +622,18 @@ export function initializeNotificationListeners(): () => void {
|
||||||
return getVisibleTeamNamesInAnyPane().has(teamName);
|
return getVisibleTeamNamesInAnyPane().has(teamName);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const shouldRefreshTeamMessages = (teamName: string): boolean => {
|
||||||
|
return isTeamVisibleInAnyPane(teamName) || hasActiveTeamPendingReplyWait(teamName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTrackedTeamMessageRefreshTeams = (): Set<string> => {
|
||||||
|
const tracked = getVisibleTeamNamesInAnyPane();
|
||||||
|
for (const teamName of getActiveTeamPendingReplyWaits()) {
|
||||||
|
tracked.add(teamName);
|
||||||
|
}
|
||||||
|
return tracked;
|
||||||
|
};
|
||||||
|
|
||||||
const getTrackedChangePresenceTeams = (): Set<string> => {
|
const getTrackedChangePresenceTeams = (): Set<string> => {
|
||||||
const state = useStore.getState();
|
const state = useStore.getState();
|
||||||
const tracked = new Set<string>();
|
const tracked = new Set<string>();
|
||||||
|
|
@ -627,6 +674,26 @@ export function initializeNotificationListeners(): () => void {
|
||||||
return activeTab.teamName;
|
return activeTab.teamName;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pollTrackedTeamMessageFallback = async (): Promise<void> => {
|
||||||
|
if (teamMessageFallbackPollInFlight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamNames = getTrackedTeamMessageRefreshTeams();
|
||||||
|
if (teamNames.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
teamMessageFallbackPollInFlight = true;
|
||||||
|
try {
|
||||||
|
await Promise.allSettled(
|
||||||
|
Array.from(teamNames, (teamName) => refreshTrackedTeamMessages(teamName))
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
teamMessageFallbackPollInFlight = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const pollFocusedVisibleTeamIdleWatchdog = async (): Promise<void> => {
|
const pollFocusedVisibleTeamIdleWatchdog = async (): Promise<void> => {
|
||||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
||||||
return;
|
return;
|
||||||
|
|
@ -863,11 +930,18 @@ export function initializeNotificationListeners(): () => void {
|
||||||
cleanupFns.push(() => {
|
cleanupFns.push(() => {
|
||||||
clearInterval(teamIdleWatchdogTimer);
|
clearInterval(teamIdleWatchdogTimer);
|
||||||
});
|
});
|
||||||
|
const teamMessageFallbackPollTimer = setInterval(() => {
|
||||||
|
void pollTrackedTeamMessageFallback();
|
||||||
|
}, TEAM_MESSAGE_FALLBACK_POLL_MS);
|
||||||
|
cleanupFns.push(() => {
|
||||||
|
clearInterval(teamMessageFallbackPollTimer);
|
||||||
|
});
|
||||||
|
|
||||||
if (api.teams?.onTeamChange) {
|
if (api.teams?.onTeamChange) {
|
||||||
const cleanup = api.teams.onTeamChange((_event: unknown, event: TeamChangeEvent) => {
|
const cleanup = api.teams.onTeamChange((_event: unknown, event: TeamChangeEvent) => {
|
||||||
const visibleTeam = Boolean(event.teamName) && isTeamVisibleInAnyPane(event.teamName);
|
const messageRefreshRelevant =
|
||||||
noteTeamChangeEventBurst(event.teamName, event.type, visibleTeam);
|
Boolean(event.teamName) && shouldRefreshTeamMessages(event.teamName);
|
||||||
|
noteTeamChangeEventBurst(event.teamName, event.type, messageRefreshRelevant);
|
||||||
|
|
||||||
const isIgnoredRuntimeRun = (() => {
|
const isIgnoredRuntimeRun = (() => {
|
||||||
if (!event.runId) return false;
|
if (!event.runId) return false;
|
||||||
|
|
@ -924,24 +998,26 @@ export function initializeNotificationListeners(): () => void {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const cachedTeamData = prev.teamDataCacheByName[event.teamName];
|
const baseTeamData =
|
||||||
if (cachedTeamData) {
|
prev.teamDataCacheByName[event.teamName] ??
|
||||||
|
(prev.selectedTeamName === event.teamName ? prev.selectedTeamData : null);
|
||||||
|
const nextTeamData =
|
||||||
|
baseTeamData && baseTeamData.isAlive !== (nextActivity !== 'offline')
|
||||||
|
? {
|
||||||
|
...baseTeamData,
|
||||||
|
isAlive: nextActivity !== 'offline',
|
||||||
|
}
|
||||||
|
: baseTeamData;
|
||||||
|
|
||||||
|
if (nextTeamData) {
|
||||||
nextState.teamDataCacheByName = {
|
nextState.teamDataCacheByName = {
|
||||||
...prev.teamDataCacheByName,
|
...prev.teamDataCacheByName,
|
||||||
[event.teamName]: {
|
[event.teamName]: nextTeamData,
|
||||||
...cachedTeamData,
|
|
||||||
isAlive: nextActivity !== 'offline',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep TeamDetailView in sync: it historically relied on selectedTeamData.isAlive,
|
if (prev.selectedTeamName === event.teamName && nextTeamData) {
|
||||||
// which isn't refreshed for lead-activity events.
|
nextState.selectedTeamData = nextTeamData;
|
||||||
if (prev.selectedTeamName === event.teamName && prev.selectedTeamData) {
|
|
||||||
nextState.selectedTeamData = {
|
|
||||||
...prev.selectedTeamData,
|
|
||||||
isAlive: nextActivity !== 'offline',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear context data when lead goes offline
|
// Clear context data when lead goes offline
|
||||||
|
|
@ -1122,29 +1198,19 @@ export function initializeNotificationListeners(): () => void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === 'inbox' || event.type === 'config' || event.type === 'process') {
|
if (event.type === 'inbox') {
|
||||||
scheduleMemberSpawnStatusesRefresh(event.teamName);
|
scheduleTrackedTeamMessageRefresh(event.teamName);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live lead-message events: only refresh the visible team detail, not team/task lists.
|
// Live lead-message events refresh only the tracked message feed surface
|
||||||
// This keeps the refresh lightweight and prevents one noisy team from starving another.
|
// (visible team or local pending-reply wait), not the structural snapshot.
|
||||||
if (event.type === 'lead-message') {
|
if (event.type === 'lead-message') {
|
||||||
if (isStaleRuntimeEvent) {
|
if (isStaleRuntimeEvent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
seedCurrentRunIdIfMissing();
|
seedCurrentRunIdIfMissing();
|
||||||
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
|
scheduleTrackedTeamMessageRefresh(event.teamName);
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (teamRefreshTimers.has(event.teamName)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
teamRefreshTimers.delete(event.teamName);
|
|
||||||
const current = useStore.getState();
|
|
||||||
void current.refreshTeamData(event.teamName, { withDedup: true });
|
|
||||||
}, TEAM_REFRESH_THROTTLE_MS);
|
|
||||||
teamRefreshTimers.set(event.teamName, timer);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1205,6 +1271,8 @@ export function initializeNotificationListeners(): () => void {
|
||||||
cleanup();
|
cleanup();
|
||||||
for (const t of teamRefreshTimers.values()) clearTimeout(t);
|
for (const t of teamRefreshTimers.values()) clearTimeout(t);
|
||||||
teamRefreshTimers = new Map();
|
teamRefreshTimers = new Map();
|
||||||
|
for (const t of teamMessageRefreshTimers.values()) clearTimeout(t);
|
||||||
|
teamMessageRefreshTimers = new Map();
|
||||||
for (const t of teamPresenceRefreshTimers.values()) clearTimeout(t);
|
for (const t of teamPresenceRefreshTimers.values()) clearTimeout(t);
|
||||||
teamPresenceRefreshTimers = new Map();
|
teamPresenceRefreshTimers = new Map();
|
||||||
for (const t of memberSpawnRefreshTimers.values()) clearTimeout(t);
|
for (const t of memberSpawnRefreshTimers.values()) clearTimeout(t);
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,7 @@ export interface ExtensionsSlice {
|
||||||
// Slice Creator
|
// Slice Creator
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
let pluginFetchInFlight: { key: string; promise: Promise<void> } | null = null;
|
let pluginFetchInFlight: { key: string; promise: Promise<void>; token: symbol } | null = null;
|
||||||
let pluginCatalogRequestSeq = 0;
|
let pluginCatalogRequestSeq = 0;
|
||||||
const pluginSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
const pluginSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
const mcpSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
const mcpSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
@ -409,6 +409,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestSeq = ++pluginCatalogRequestSeq;
|
const requestSeq = ++pluginCatalogRequestSeq;
|
||||||
|
const requestToken = Symbol('pluginCatalogRequest');
|
||||||
set({ pluginCatalogLoading: true, pluginCatalogError: null });
|
set({ pluginCatalogLoading: true, pluginCatalogError: null });
|
||||||
|
|
||||||
let currentPromise: Promise<void> | null = null;
|
let currentPromise: Promise<void> | null = null;
|
||||||
|
|
@ -468,13 +469,13 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (currentPromise && pluginFetchInFlight?.promise === currentPromise) {
|
if (pluginFetchInFlight?.token === requestToken) {
|
||||||
pluginFetchInFlight = null;
|
pluginFetchInFlight = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
pluginFetchInFlight = { key: requestKey, promise: currentPromise };
|
pluginFetchInFlight = { key: requestKey, promise: currentPromise, token: requestToken };
|
||||||
await currentPromise;
|
await currentPromise;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -148,11 +148,7 @@ export function buildDisplayItems(
|
||||||
// Build display items
|
// Build display items
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
// Skip the last output step
|
// Skip the last output step
|
||||||
if (
|
if (step.id === lastOutputStepRef?.id && step.type === lastOutputStepRef.type) {
|
||||||
lastOutputStepRef &&
|
|
||||||
step.id === lastOutputStepRef.id &&
|
|
||||||
step.type === lastOutputStepRef.type
|
|
||||||
) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import {
|
import {
|
||||||
getProviderScopedTeamModelLabel,
|
getProviderScopedTeamModelLabel,
|
||||||
isSupportedAnthropicTeamModel,
|
|
||||||
getRuntimeAwareTeamModelUiDisabledReason,
|
getRuntimeAwareTeamModelUiDisabledReason,
|
||||||
getTeamProviderLabel,
|
getTeamProviderLabel,
|
||||||
getTeamProviderModelOptions,
|
getTeamProviderModelOptions,
|
||||||
|
|
@ -12,11 +11,13 @@ import {
|
||||||
GPT_5_2_CODEX_UI_DISABLED_REASON,
|
GPT_5_2_CODEX_UI_DISABLED_REASON,
|
||||||
GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL,
|
GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL,
|
||||||
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
|
GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON,
|
||||||
|
isSupportedAnthropicTeamModel,
|
||||||
normalizeTeamModelForUi as normalizeCatalogTeamModelForUi,
|
normalizeTeamModelForUi as normalizeCatalogTeamModelForUi,
|
||||||
sortTeamProviderModels,
|
sortTeamProviderModels,
|
||||||
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
|
TEAM_MODEL_UI_DISABLED_BADGE_LABEL,
|
||||||
type TeamProviderModelOption,
|
type TeamProviderModelOption,
|
||||||
} from './teamModelCatalog';
|
} from './teamModelCatalog';
|
||||||
|
import { extractProviderScopedBaseModel } from './teamModelContext';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
CliProviderId,
|
CliProviderId,
|
||||||
|
|
@ -237,6 +238,14 @@ export function isTeamModelAvailableForUi(
|
||||||
return getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available';
|
return getRuntimeModelAvailability(providerId, trimmed, providerStatus) === 'available';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeExplicitTeamModelForUi(
|
||||||
|
providerId: SupportedProviderId | undefined,
|
||||||
|
model: string | undefined
|
||||||
|
): string {
|
||||||
|
const normalized = extractProviderScopedBaseModel(model, providerId) ?? '';
|
||||||
|
return normalizeCatalogTeamModelForUi(providerId, normalized).trim();
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeTeamModelForUi(
|
export function normalizeTeamModelForUi(
|
||||||
providerId: SupportedProviderId | undefined,
|
providerId: SupportedProviderId | undefined,
|
||||||
model: string | undefined,
|
model: string | undefined,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
|
import { parseModelString } from '@shared/utils/modelParser';
|
||||||
import {
|
import {
|
||||||
filterVisibleProviderRuntimeModels,
|
filterVisibleProviderRuntimeModels,
|
||||||
GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL,
|
GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL,
|
||||||
GPT_5_2_CODEX_UI_DISABLED_MODEL,
|
GPT_5_2_CODEX_UI_DISABLED_MODEL,
|
||||||
GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL,
|
GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL,
|
||||||
} from '@shared/utils/providerModelVisibility';
|
} from '@shared/utils/providerModelVisibility';
|
||||||
import { parseModelString } from '@shared/utils/modelParser';
|
|
||||||
|
|
||||||
import type { CliProviderId, CliProviderStatus, TeamProviderId } from '@shared/types';
|
import type { CliProviderId, CliProviderStatus, TeamProviderId } from '@shared/types';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
import type {
|
import type {
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
MemberSpawnStatusesSnapshot,
|
MemberSpawnStatusesSnapshot,
|
||||||
ResolvedTeamMember,
|
|
||||||
TeamProvisioningProgress,
|
TeamProvisioningProgress,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -17,6 +16,17 @@ type MemberSpawnStatusCollection =
|
||||||
| Map<string, MemberSpawnStatusEntry>
|
| Map<string, MemberSpawnStatusEntry>
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
|
interface ProvisioningMemberLike {
|
||||||
|
name: string;
|
||||||
|
removedAt?: number;
|
||||||
|
agentType?: string;
|
||||||
|
status?: string;
|
||||||
|
currentTaskId?: string | null;
|
||||||
|
taskCount?: number;
|
||||||
|
lastActiveAt?: string | null;
|
||||||
|
messageCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface FailedSpawnDetail {
|
interface FailedSpawnDetail {
|
||||||
name: string;
|
name: string;
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
|
|
@ -138,7 +148,7 @@ export function buildTeamProvisioningPresentation({
|
||||||
memberSpawnSnapshot,
|
memberSpawnSnapshot,
|
||||||
}: {
|
}: {
|
||||||
progress: TeamProvisioningProgress | null | undefined;
|
progress: TeamProvisioningProgress | null | undefined;
|
||||||
members: readonly ResolvedTeamMember[];
|
members: readonly ProvisioningMemberLike[];
|
||||||
memberSpawnStatuses?: MemberSpawnStatusCollection;
|
memberSpawnStatuses?: MemberSpawnStatusCollection;
|
||||||
memberSpawnSnapshot?: Pick<MemberSpawnStatusesSnapshot, 'expectedMembers' | 'summary'>;
|
memberSpawnSnapshot?: Pick<MemberSpawnStatusesSnapshot, 'expectedMembers' | 'summary'>;
|
||||||
}): TeamProvisioningPresentation | null {
|
}): TeamProvisioningPresentation | null {
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,6 @@ import type {
|
||||||
LeadContextUsageSnapshot,
|
LeadContextUsageSnapshot,
|
||||||
MemberFullStats,
|
MemberFullStats,
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
TeamAgentRuntimeSnapshot,
|
|
||||||
MemberSpawnStatusesSnapshot,
|
MemberSpawnStatusesSnapshot,
|
||||||
MessagesPage,
|
MessagesPage,
|
||||||
ProjectBranchChangeEvent,
|
ProjectBranchChangeEvent,
|
||||||
|
|
@ -66,6 +65,7 @@ import type {
|
||||||
TaskAttachmentMeta,
|
TaskAttachmentMeta,
|
||||||
TaskChangePresenceState,
|
TaskChangePresenceState,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
|
TeamAgentRuntimeSnapshot,
|
||||||
TeamChangeEvent,
|
TeamChangeEvent,
|
||||||
TeamClaudeLogsQuery,
|
TeamClaudeLogsQuery,
|
||||||
TeamClaudeLogsResponse,
|
TeamClaudeLogsResponse,
|
||||||
|
|
@ -73,9 +73,9 @@ import type {
|
||||||
TeamCreateConfigRequest,
|
TeamCreateConfigRequest,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamCreateResponse,
|
TeamCreateResponse,
|
||||||
TeamData,
|
|
||||||
TeamLaunchRequest,
|
TeamLaunchRequest,
|
||||||
TeamLaunchResponse,
|
TeamLaunchResponse,
|
||||||
|
TeamMemberActivityMeta,
|
||||||
TeamMessageNotificationData,
|
TeamMessageNotificationData,
|
||||||
TeamProvisioningPrepareResult,
|
TeamProvisioningPrepareResult,
|
||||||
TeamProvisioningProgress,
|
TeamProvisioningProgress,
|
||||||
|
|
@ -83,6 +83,7 @@ import type {
|
||||||
TeamTask,
|
TeamTask,
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
TeamUpdateConfigRequest,
|
TeamUpdateConfigRequest,
|
||||||
|
TeamViewSnapshot,
|
||||||
ToolApprovalEvent,
|
ToolApprovalEvent,
|
||||||
ToolApprovalFileContent,
|
ToolApprovalFileContent,
|
||||||
ToolApprovalSettings,
|
ToolApprovalSettings,
|
||||||
|
|
@ -427,7 +428,7 @@ export interface HttpServerAPI {
|
||||||
|
|
||||||
export interface TeamsAPI {
|
export interface TeamsAPI {
|
||||||
list: () => Promise<TeamSummary[]>;
|
list: () => Promise<TeamSummary[]>;
|
||||||
getData: (teamName: string) => Promise<TeamData>;
|
getData: (teamName: string) => Promise<TeamViewSnapshot>;
|
||||||
getTaskChangePresence: (teamName: string) => Promise<Record<string, TaskChangePresenceState>>;
|
getTaskChangePresence: (teamName: string) => Promise<Record<string, TaskChangePresenceState>>;
|
||||||
setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise<void>;
|
setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise<void>;
|
||||||
setToolActivityTracking: (teamName: string, enabled: boolean) => Promise<void>;
|
setToolActivityTracking: (teamName: string, enabled: boolean) => Promise<void>;
|
||||||
|
|
@ -451,8 +452,9 @@ export interface TeamsAPI {
|
||||||
sendMessage: (teamName: string, request: SendMessageRequest) => Promise<SendMessageResult>;
|
sendMessage: (teamName: string, request: SendMessageRequest) => Promise<SendMessageResult>;
|
||||||
getMessagesPage: (
|
getMessagesPage: (
|
||||||
teamName: string,
|
teamName: string,
|
||||||
options?: { beforeTimestamp?: string; limit?: number }
|
options?: { cursor?: string | null; limit?: number }
|
||||||
) => Promise<MessagesPage>;
|
) => Promise<MessagesPage>;
|
||||||
|
getMemberActivityMeta: (teamName: string) => Promise<TeamMemberActivityMeta>;
|
||||||
createTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
|
createTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
|
||||||
requestReview: (teamName: string, taskId: string) => Promise<void>;
|
requestReview: (teamName: string, taskId: string) => Promise<void>;
|
||||||
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
|
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
|
||||||
|
|
|
||||||
|
|
@ -606,6 +606,11 @@ export interface MessagesPage {
|
||||||
/** Opaque cursor string for fetching older messages. Null when no more pages. */
|
/** Opaque cursor string for fetching older messages. Null when no more pages. */
|
||||||
nextCursor: string | null;
|
nextCursor: string | null;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
|
/**
|
||||||
|
* Content-stable revision of the full normalized feed that produced this page.
|
||||||
|
* Changes only when the semantic message feed changes.
|
||||||
|
*/
|
||||||
|
feedRevision: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AgentActionMode = 'do' | 'ask' | 'delegate';
|
export type AgentActionMode = 'do' | 'ask' | 'delegate';
|
||||||
|
|
@ -733,12 +738,44 @@ export interface TeamProcess {
|
||||||
stoppedAt?: string;
|
stoppedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamData {
|
export interface TeamMemberSnapshot {
|
||||||
|
name: string;
|
||||||
|
agentId?: string;
|
||||||
|
currentTaskId: string | null;
|
||||||
|
taskCount: number;
|
||||||
|
color?: string;
|
||||||
|
agentType?: string;
|
||||||
|
role?: string;
|
||||||
|
workflow?: string;
|
||||||
|
providerId?: TeamProviderId;
|
||||||
|
model?: string;
|
||||||
|
effort?: EffortLevel;
|
||||||
|
cwd?: string;
|
||||||
|
/** Set only when member's git branch differs from the lead's branch. */
|
||||||
|
gitBranch?: string;
|
||||||
|
runtimeAdvisory?: MemberRuntimeAdvisory;
|
||||||
|
removedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberActivityMetaEntry {
|
||||||
|
memberName: string;
|
||||||
|
lastAuthoredMessageAt: string | null;
|
||||||
|
messageCountExact: number;
|
||||||
|
latestAuthoredMessageSignalsTermination: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamMemberActivityMeta {
|
||||||
|
teamName: string;
|
||||||
|
computedAt: string;
|
||||||
|
members: Record<string, MemberActivityMetaEntry>;
|
||||||
|
feedRevision: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamViewSnapshot {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
config: TeamConfig;
|
config: TeamConfig;
|
||||||
tasks: TeamTaskWithKanban[];
|
tasks: TeamTaskWithKanban[];
|
||||||
members: ResolvedTeamMember[];
|
members: TeamMemberSnapshot[];
|
||||||
messages: InboxMessage[];
|
|
||||||
kanbanState: KanbanState;
|
kanbanState: KanbanState;
|
||||||
processes: TeamProcess[];
|
processes: TeamProcess[];
|
||||||
warnings?: string[];
|
warnings?: string[];
|
||||||
|
|
|
||||||
|
|
@ -159,13 +159,7 @@ export function hasInstallationInScope(
|
||||||
return installations.some((installation) => installation.scope === scope);
|
return installations.some((installation) => installation.scope === scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function summarizeInstallationScopes(scopes: InstallScope[]): string | null {
|
||||||
* Build a concise install-status label for plugin badges.
|
|
||||||
*/
|
|
||||||
export function getInstallationSummaryLabel(
|
|
||||||
installations: Pick<InstalledPluginEntry, 'scope'>[]
|
|
||||||
): string | null {
|
|
||||||
const scopes = Array.from(new Set(installations.map((installation) => installation.scope)));
|
|
||||||
if (scopes.length === 0) {
|
if (scopes.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -175,6 +169,7 @@ export function getInstallationSummaryLabel(
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (scopes[0]) {
|
switch (scopes[0]) {
|
||||||
|
case 'global':
|
||||||
case 'user':
|
case 'user':
|
||||||
return 'Installed globally';
|
return 'Installed globally';
|
||||||
case 'project':
|
case 'project':
|
||||||
|
|
@ -186,6 +181,16 @@ export function getInstallationSummaryLabel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a concise install-status label for plugin badges.
|
||||||
|
*/
|
||||||
|
export function getInstallationSummaryLabel(
|
||||||
|
installations: Pick<InstalledPluginEntry, 'scope'>[]
|
||||||
|
): string | null {
|
||||||
|
const scopes = Array.from(new Set(installations.map((installation) => installation.scope)));
|
||||||
|
return summarizeInstallationScopes(scopes);
|
||||||
|
}
|
||||||
|
|
||||||
const MCP_SCOPE_PRIORITY: Record<InstalledMcpEntry['scope'], number> = {
|
const MCP_SCOPE_PRIORITY: Record<InstalledMcpEntry['scope'], number> = {
|
||||||
local: 0,
|
local: 0,
|
||||||
project: 1,
|
project: 1,
|
||||||
|
|
@ -216,25 +221,7 @@ export function getMcpInstallationSummaryLabel(
|
||||||
installations: Pick<InstalledMcpEntry, 'scope'>[]
|
installations: Pick<InstalledMcpEntry, 'scope'>[]
|
||||||
): string | null {
|
): string | null {
|
||||||
const scopes = Array.from(new Set(installations.map((installation) => installation.scope)));
|
const scopes = Array.from(new Set(installations.map((installation) => installation.scope)));
|
||||||
if (scopes.length === 0) {
|
return summarizeInstallationScopes(scopes);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scopes.length > 1) {
|
|
||||||
return `Installed in ${scopes.length} scopes`;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (scopes[0]) {
|
|
||||||
case 'global':
|
|
||||||
case 'user':
|
|
||||||
return 'Installed globally';
|
|
||||||
case 'project':
|
|
||||||
return 'Installed in project';
|
|
||||||
case 'local':
|
|
||||||
return 'Installed locally';
|
|
||||||
default:
|
|
||||||
return 'Installed';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -104,10 +104,10 @@ function parseRelativeResetDuration(text: string): number | null {
|
||||||
const match = LEADING_TIME_VALUE_RE.exec(tail);
|
const match = LEADING_TIME_VALUE_RE.exec(tail);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
|
|
||||||
const amount = Number.parseFloat(match[1]!);
|
const amount = Number.parseFloat(match[1]);
|
||||||
if (!Number.isFinite(amount) || amount < 0) return null;
|
if (!Number.isFinite(amount) || amount < 0) return null;
|
||||||
|
|
||||||
const unit = match[2]!.toLowerCase();
|
const unit = match[2].toLowerCase();
|
||||||
if (['second', 'seconds', 'sec', 'secs', 's'].includes(unit)) {
|
if (['second', 'seconds', 'sec', 'secs', 's'].includes(unit)) {
|
||||||
return Math.round(amount * 1000);
|
return Math.round(amount * 1000);
|
||||||
}
|
}
|
||||||
|
|
@ -157,7 +157,7 @@ function parseAbsoluteResetClockTime(text: string, now: Date): Date | null {
|
||||||
const afterMatch = tail.slice(tzTokenLength);
|
const afterMatch = tail.slice(tzTokenLength);
|
||||||
if (DAY_SHIFT_QUALIFIER_RE.test(afterMatch)) return null;
|
if (DAY_SHIFT_QUALIFIER_RE.test(afterMatch)) return null;
|
||||||
|
|
||||||
const hourRaw = Number.parseInt(match[1]!, 10);
|
const hourRaw = Number.parseInt(match[1], 10);
|
||||||
const minuteRaw = match[2] ? Number.parseInt(match[2], 10) : 0;
|
const minuteRaw = match[2] ? Number.parseInt(match[2], 10) : 0;
|
||||||
const ampm = match[3]?.toLowerCase() ?? null;
|
const ampm = match[3]?.toLowerCase() ?? null;
|
||||||
const parenthesizedTz = parenthesizedTzMatch?.[1]?.toUpperCase() ?? '';
|
const parenthesizedTz = parenthesizedTzMatch?.[1]?.toUpperCase() ?? '';
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export interface TeamGraphDefaultLayoutSeed {
|
||||||
assignments: Record<string, GraphOwnerSlotAssignment>;
|
assignments: Record<string, GraphOwnerSlotAssignment>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SMALL_TEAM_CARDINAL_SLOT_PRESETS: ReadonlyArray<ReadonlyArray<GraphOwnerSlotAssignment>> = [
|
const SMALL_TEAM_CARDINAL_SLOT_PRESETS: readonly (readonly GraphOwnerSlotAssignment[])[] = [
|
||||||
[],
|
[],
|
||||||
[{ ringIndex: 0, sectorIndex: 0 }],
|
[{ ringIndex: 0, sectorIndex: 0 }],
|
||||||
[
|
[
|
||||||
|
|
@ -83,7 +83,7 @@ export function buildTeamGraphDefaultLayoutSeed(
|
||||||
const preset = SMALL_TEAM_CARDINAL_SLOT_PRESETS[orderedVisibleOwnerIds.length];
|
const preset = SMALL_TEAM_CARDINAL_SLOT_PRESETS[orderedVisibleOwnerIds.length];
|
||||||
const assignments: Record<string, GraphOwnerSlotAssignment> = {};
|
const assignments: Record<string, GraphOwnerSlotAssignment> = {};
|
||||||
|
|
||||||
if (preset && preset.length === orderedVisibleOwnerIds.length) {
|
if (preset?.length === orderedVisibleOwnerIds.length) {
|
||||||
orderedVisibleOwnerIds.forEach((stableOwnerId, index) => {
|
orderedVisibleOwnerIds.forEach((stableOwnerId, index) => {
|
||||||
assignments[stableOwnerId] = preset[index]!;
|
assignments[stableOwnerId] = preset[index]!;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ describe('normalizeDashboardRecentProjectsPayload', () => {
|
||||||
normalizeDashboardRecentProjectsPayload({
|
normalizeDashboardRecentProjectsPayload({
|
||||||
degraded: false,
|
degraded: false,
|
||||||
projects: null,
|
projects: null,
|
||||||
} as unknown as { degraded: boolean; projects: null })
|
} as unknown as Parameters<typeof normalizeDashboardRecentProjectsPayload>[0])
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
BoardTaskExactLogSummariesResponse,
|
||||||
InboxMessage,
|
InboxMessage,
|
||||||
MessagesPage,
|
MessagesPage,
|
||||||
|
TeamViewSnapshot,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamProvisioningProgress,
|
TeamProvisioningProgress,
|
||||||
} from '@shared/types/team';
|
} from '@shared/types/team';
|
||||||
|
|
||||||
vi.mock('electron', () => ({
|
vi.mock('electron', () => ({
|
||||||
app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp') },
|
app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp'), isPackaged: false },
|
||||||
Notification: Object.assign(vi.fn(), { isSupported: vi.fn(() => false) }),
|
Notification: Object.assign(vi.fn(), { isSupported: vi.fn(() => false) }),
|
||||||
BrowserWindow: { getAllWindows: vi.fn(() => []) },
|
BrowserWindow: { getAllWindows: vi.fn(() => []) },
|
||||||
}));
|
}));
|
||||||
|
|
@ -35,6 +36,8 @@ const { mockTeamDataWorkerClient } = vi.hoisted(() => ({
|
||||||
mockTeamDataWorkerClient: {
|
mockTeamDataWorkerClient: {
|
||||||
isAvailable: vi.fn(),
|
isAvailable: vi.fn(),
|
||||||
getTeamData: vi.fn(),
|
getTeamData: vi.fn(),
|
||||||
|
getMessagesPage: vi.fn(),
|
||||||
|
getMemberActivityMeta: vi.fn(),
|
||||||
findLogsForTask: vi.fn(),
|
findLogsForTask: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
@ -63,6 +66,8 @@ import {
|
||||||
TEAM_CREATE_TASK,
|
TEAM_CREATE_TASK,
|
||||||
TEAM_DELETE_TEAM,
|
TEAM_DELETE_TEAM,
|
||||||
TEAM_GET_DATA,
|
TEAM_GET_DATA,
|
||||||
|
TEAM_GET_MEMBER_ACTIVITY_META,
|
||||||
|
TEAM_GET_MESSAGES_PAGE,
|
||||||
TEAM_LAUNCH,
|
TEAM_LAUNCH,
|
||||||
TEAM_LIST,
|
TEAM_LIST,
|
||||||
TEAM_PREPARE_PROVISIONING,
|
TEAM_PREPARE_PROVISIONING,
|
||||||
|
|
@ -81,7 +86,6 @@ import {
|
||||||
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
|
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
|
||||||
TEAM_GET_MEMBER_LOGS,
|
TEAM_GET_MEMBER_LOGS,
|
||||||
TEAM_GET_MEMBER_STATS,
|
TEAM_GET_MEMBER_STATS,
|
||||||
TEAM_GET_MESSAGES_PAGE,
|
|
||||||
TEAM_START_TASK,
|
TEAM_START_TASK,
|
||||||
TEAM_UPDATE_CONFIG,
|
TEAM_UPDATE_CONFIG,
|
||||||
TEAM_UPDATE_KANBAN,
|
TEAM_UPDATE_KANBAN,
|
||||||
|
|
@ -133,23 +137,38 @@ describe('ipc teams handlers', () => {
|
||||||
|
|
||||||
const service = {
|
const service = {
|
||||||
listTeams: vi.fn(async () => [{ teamName: 'my-team', displayName: 'My Team' }]),
|
listTeams: vi.fn(async () => [{ teamName: 'my-team', displayName: 'My Team' }]),
|
||||||
getTeamData: vi.fn(async () => ({
|
getTeamData: vi.fn(async (): Promise<TeamViewSnapshot & { messages?: InboxMessage[] }> => ({
|
||||||
teamName: 'my-team',
|
teamName: 'my-team',
|
||||||
config: { name: 'My Team' },
|
config: { name: 'My Team' },
|
||||||
tasks: [],
|
tasks: [],
|
||||||
members: [],
|
members: [],
|
||||||
messages: [] as InboxMessage[],
|
|
||||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||||
processes: [],
|
processes: [],
|
||||||
})),
|
})),
|
||||||
|
getMessageFeed: vi.fn(async () => ({
|
||||||
|
teamName: 'my-team',
|
||||||
|
feedRevision: 'rev-1',
|
||||||
|
messages: [] as InboxMessage[],
|
||||||
|
})),
|
||||||
getMessagesPage: vi.fn(async (..._args: unknown[]): Promise<MessagesPage> => ({
|
getMessagesPage: vi.fn(async (..._args: unknown[]): Promise<MessagesPage> => ({
|
||||||
messages: [] as InboxMessage[],
|
messages: [] as InboxMessage[],
|
||||||
nextCursor: null,
|
nextCursor: null,
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
|
feedRevision: 'rev-1',
|
||||||
|
})),
|
||||||
|
getMemberActivityMeta: vi.fn(async () => ({
|
||||||
|
teamName: 'my-team',
|
||||||
|
computedAt: '2026-03-12T10:00:00.000Z',
|
||||||
|
members: {},
|
||||||
|
feedRevision: 'rev-1',
|
||||||
})),
|
})),
|
||||||
getTaskChangePresence: vi.fn(async () => ({ 'task-1': 'has_changes' })),
|
getTaskChangePresence: vi.fn(async () => ({ 'task-1': 'has_changes' })),
|
||||||
reconcileTeamArtifacts: vi.fn(async () => undefined),
|
reconcileTeamArtifacts: vi.fn(async () => undefined),
|
||||||
setTaskChangePresenceTracking: vi.fn(() => undefined),
|
setTaskChangePresenceTracking: vi.fn(() => undefined),
|
||||||
|
getTeamNotificationContext: vi.fn(async () => ({
|
||||||
|
displayName: 'My Team',
|
||||||
|
projectPath: '/tmp/project',
|
||||||
|
})),
|
||||||
deleteTeam: vi.fn(async () => undefined),
|
deleteTeam: vi.fn(async () => undefined),
|
||||||
getLeadMemberName: vi.fn(async () => 'team-lead'),
|
getLeadMemberName: vi.fn(async () => 'team-lead'),
|
||||||
getTeamDisplayName: vi.fn(async () => 'My Team'),
|
getTeamDisplayName: vi.fn(async () => 'My Team'),
|
||||||
|
|
@ -241,6 +260,8 @@ describe('ipc teams handlers', () => {
|
||||||
mockGetMembersMeta.mockResolvedValue([]);
|
mockGetMembersMeta.mockResolvedValue([]);
|
||||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
||||||
mockTeamDataWorkerClient.getTeamData.mockReset();
|
mockTeamDataWorkerClient.getTeamData.mockReset();
|
||||||
|
mockTeamDataWorkerClient.getMessagesPage.mockReset();
|
||||||
|
mockTeamDataWorkerClient.getMemberActivityMeta.mockReset();
|
||||||
mockTeamDataWorkerClient.findLogsForTask.mockReset();
|
mockTeamDataWorkerClient.findLogsForTask.mockReset();
|
||||||
initializeTeamHandlers(
|
initializeTeamHandlers(
|
||||||
service as never,
|
service as never,
|
||||||
|
|
@ -250,6 +271,7 @@ describe('ipc teams handlers', () => {
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
|
undefined,
|
||||||
boardTaskActivityService as never,
|
boardTaskActivityService as never,
|
||||||
boardTaskActivityDetailService as never,
|
boardTaskActivityDetailService as never,
|
||||||
boardTaskLogStreamService as never,
|
boardTaskLogStreamService as never,
|
||||||
|
|
@ -266,6 +288,8 @@ describe('ipc teams handlers', () => {
|
||||||
it('registers all expected handlers', () => {
|
it('registers all expected handlers', () => {
|
||||||
expect(handlers.has(TEAM_LIST)).toBe(true);
|
expect(handlers.has(TEAM_LIST)).toBe(true);
|
||||||
expect(handlers.has(TEAM_GET_DATA)).toBe(true);
|
expect(handlers.has(TEAM_GET_DATA)).toBe(true);
|
||||||
|
expect(handlers.has(TEAM_GET_MESSAGES_PAGE)).toBe(true);
|
||||||
|
expect(handlers.has(TEAM_GET_MEMBER_ACTIVITY_META)).toBe(true);
|
||||||
expect(handlers.has(TEAM_GET_TASK_CHANGE_PRESENCE)).toBe(true);
|
expect(handlers.has(TEAM_GET_TASK_CHANGE_PRESENCE)).toBe(true);
|
||||||
expect(handlers.has(TEAM_SET_CHANGE_PRESENCE_TRACKING)).toBe(true);
|
expect(handlers.has(TEAM_SET_CHANGE_PRESENCE_TRACKING)).toBe(true);
|
||||||
expect(handlers.has(TEAM_DELETE_TEAM)).toBe(true);
|
expect(handlers.has(TEAM_DELETE_TEAM)).toBe(true);
|
||||||
|
|
@ -594,7 +618,6 @@ describe('ipc teams handlers', () => {
|
||||||
config: { name: 'My Team' },
|
config: { name: 'My Team' },
|
||||||
tasks: [],
|
tasks: [],
|
||||||
members: [],
|
members: [],
|
||||||
messages: [] as InboxMessage[],
|
|
||||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||||
processes: [],
|
processes: [],
|
||||||
});
|
});
|
||||||
|
|
@ -773,24 +796,7 @@ describe('ipc teams handlers', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('dedups live lead replies when lead_session already has same text', async () => {
|
it('keeps TEAM_GET_DATA structural and does not expose message transport', async () => {
|
||||||
service.getTeamData.mockResolvedValueOnce({
|
|
||||||
teamName: 'my-team',
|
|
||||||
config: { name: 'My Team' },
|
|
||||||
tasks: [],
|
|
||||||
members: [],
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
from: 'team-lead',
|
|
||||||
text: 'Hello there',
|
|
||||||
timestamp: '2026-02-23T10:00:00.000Z',
|
|
||||||
read: true,
|
|
||||||
source: 'lead_session' as const,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
|
||||||
processes: [],
|
|
||||||
});
|
|
||||||
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
||||||
{
|
{
|
||||||
from: 'team-lead',
|
from: 'team-lead',
|
||||||
|
|
@ -805,12 +811,31 @@ describe('ipc teams handlers', () => {
|
||||||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
||||||
const result = (await getDataHandler({} as never, 'my-team')) as {
|
const result = (await getDataHandler({} as never, 'my-team')) as {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: { messages: { source?: string }[] };
|
data: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
const sources = result.data.messages.map((m) => m.source);
|
expect(result.data.teamName).toBe('my-team');
|
||||||
expect(sources.filter((s) => s === 'lead_process')).toHaveLength(0);
|
expect(result.data).not.toHaveProperty('messages');
|
||||||
expect(sources.filter((s) => s === 'lead_session')).toHaveLength(1);
|
expect(service.getMessageFeed).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back TEAM_GET_DATA to the main thread in packaged runtime when worker is unavailable', async () => {
|
||||||
|
const electron = await import('electron');
|
||||||
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
||||||
|
(electron.app as { isPackaged: boolean }).isPackaged = true;
|
||||||
|
|
||||||
|
const handler = handlers.get(TEAM_GET_DATA)!;
|
||||||
|
const result = (await handler({} as never, 'my-team')) as {
|
||||||
|
success: boolean;
|
||||||
|
data?: { teamName: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data?.teamName).toBe('my-team');
|
||||||
|
expect(service.getTeamData).toHaveBeenCalledWith('my-team');
|
||||||
|
vi.mocked(console.error).mockClear();
|
||||||
|
|
||||||
|
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not let a live duplicate of the same session rate-limit reply delay auto-resume', async () => {
|
it('does not let a live duplicate of the same session rate-limit reply delay auto-resume', async () => {
|
||||||
|
|
@ -867,7 +892,7 @@ describe('ipc teams handlers', () => {
|
||||||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
||||||
const result = (await getDataHandler({} as never, 'my-team')) as {
|
const result = (await getDataHandler({} as never, 'my-team')) as {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: { messages: Array<{ source?: string; messageId?: string }> };
|
data: { messages?: InboxMessage[] };
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
|
|
@ -888,51 +913,141 @@ describe('ipc teams handlers', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('merges early live messages before durable lead_session backfill exists', async () => {
|
it('uses the team-data worker for TEAM_GET_MESSAGES_PAGE when available', async () => {
|
||||||
// Simulate: team just became readable but lead_session JSONL hasn't been written yet.
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
||||||
// Only live in-memory messages exist from the provisioning process.
|
mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({
|
||||||
service.getTeamData.mockResolvedValueOnce({
|
messages: [
|
||||||
teamName: 'my-team',
|
{
|
||||||
config: { name: 'My Team' },
|
from: 'team-lead',
|
||||||
tasks: [],
|
text: 'Hello there',
|
||||||
members: [],
|
timestamp: '2026-02-23T10:00:01.000Z',
|
||||||
messages: [], // No durable messages yet
|
read: true,
|
||||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
source: 'lead_session' as const,
|
||||||
processes: [],
|
messageId: 'msg-1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
feedRevision: 'rev-worker',
|
||||||
});
|
});
|
||||||
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
|
||||||
{
|
|
||||||
from: 'team-lead',
|
|
||||||
text: 'Команда создана. Запускаю тиммейтов.',
|
|
||||||
timestamp: '2026-02-23T10:00:00.000Z',
|
|
||||||
read: true,
|
|
||||||
source: 'lead_process' as const,
|
|
||||||
messageId: 'lead-turn-run-1-1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
from: 'team-lead',
|
|
||||||
text: 'All teammates online!',
|
|
||||||
timestamp: '2026-02-23T10:00:01.000Z',
|
|
||||||
read: true,
|
|
||||||
source: 'lead_process' as const,
|
|
||||||
messageId: 'lead-turn-run-1-2',
|
|
||||||
to: 'user',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!;
|
||||||
const result = (await getDataHandler({} as never, 'my-team')) as {
|
const result = (await handler({} as never, 'my-team', {
|
||||||
success: boolean;
|
limit: 50,
|
||||||
data: { messages: { source?: string; text: string }[] };
|
})) as { success: boolean; data: { feedRevision: string } };
|
||||||
};
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
// Both live messages should appear since there's no durable backfill yet
|
expect(result.data.feedRevision).toBe('rev-worker');
|
||||||
// Sorted by timestamp descending (newest first)
|
expect(mockTeamDataWorkerClient.getMessagesPage).toHaveBeenCalledWith('my-team', {
|
||||||
expect(result.data.messages).toHaveLength(2);
|
cursor: undefined,
|
||||||
expect(result.data.messages[0].source).toBe('lead_process');
|
limit: 50,
|
||||||
expect(result.data.messages[0].text).toBe('All teammates online!');
|
});
|
||||||
expect(result.data.messages[1].source).toBe('lead_process');
|
expect(service.getMessagesPage).not.toHaveBeenCalled();
|
||||||
expect(result.data.messages[1].text).toBe('Команда создана. Запускаю тиммейтов.');
|
});
|
||||||
|
|
||||||
|
it('scans rate-limit notifications from message-page results without hydrating TEAM_GET_DATA feed', async () => {
|
||||||
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
||||||
|
mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
from: 'team-lead',
|
||||||
|
text: "You've hit your limit. Please wait a bit before retrying.",
|
||||||
|
timestamp: '2026-02-23T10:00:01.000Z',
|
||||||
|
read: true,
|
||||||
|
source: 'lead_session' as const,
|
||||||
|
messageId: 'msg-rate-limit-1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
feedRevision: 'rev-worker',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!;
|
||||||
|
const result = (await handler({} as never, 'my-team', {
|
||||||
|
limit: 50,
|
||||||
|
})) as { success: boolean; data: { feedRevision: string } };
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data.feedRevision).toBe('rev-worker');
|
||||||
|
expect(mockAddTeamNotification).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
teamEventType: 'rate_limit',
|
||||||
|
teamName: 'my-team',
|
||||||
|
teamDisplayName: 'My Team',
|
||||||
|
from: 'team-lead',
|
||||||
|
dedupeKey: 'rate-limit:my-team:msg-rate-limit-1',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(service.getMessageFeed).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back TEAM_GET_MESSAGES_PAGE to the main thread in packaged runtime when worker is unavailable', async () => {
|
||||||
|
const electron = await import('electron');
|
||||||
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
||||||
|
(electron.app as { isPackaged: boolean }).isPackaged = true;
|
||||||
|
|
||||||
|
const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!;
|
||||||
|
const result = (await handler({} as never, 'my-team', {
|
||||||
|
limit: 50,
|
||||||
|
})) as { success: boolean; data?: { feedRevision: string } };
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data?.feedRevision).toBe('rev-1');
|
||||||
|
expect(service.getMessagesPage).toHaveBeenCalledWith('my-team', {
|
||||||
|
cursor: undefined,
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
vi.mocked(console.error).mockClear();
|
||||||
|
|
||||||
|
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the team-data worker for TEAM_GET_MEMBER_ACTIVITY_META when available', async () => {
|
||||||
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(true);
|
||||||
|
mockTeamDataWorkerClient.getMemberActivityMeta.mockResolvedValueOnce({
|
||||||
|
teamName: 'my-team',
|
||||||
|
computedAt: '2026-03-12T10:00:00.000Z',
|
||||||
|
members: {
|
||||||
|
alice: {
|
||||||
|
memberName: 'alice',
|
||||||
|
lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z',
|
||||||
|
messageCountExact: 4,
|
||||||
|
latestAuthoredMessageSignalsTermination: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
feedRevision: 'rev-worker',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = handlers.get(TEAM_GET_MEMBER_ACTIVITY_META)!;
|
||||||
|
const result = (await handler({} as never, 'my-team')) as {
|
||||||
|
success: boolean;
|
||||||
|
data: { feedRevision: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data.feedRevision).toBe('rev-worker');
|
||||||
|
expect(mockTeamDataWorkerClient.getMemberActivityMeta).toHaveBeenCalledWith('my-team');
|
||||||
|
expect(service.getMemberActivityMeta).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back TEAM_GET_MEMBER_ACTIVITY_META to the main thread in packaged runtime when worker is unavailable', async () => {
|
||||||
|
const electron = await import('electron');
|
||||||
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
||||||
|
(electron.app as { isPackaged: boolean }).isPackaged = true;
|
||||||
|
|
||||||
|
const handler = handlers.get(TEAM_GET_MEMBER_ACTIVITY_META)!;
|
||||||
|
const result = (await handler({} as never, 'my-team')) as {
|
||||||
|
success: boolean;
|
||||||
|
data?: { feedRevision: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data?.feedRevision).toBe('rev-1');
|
||||||
|
expect(service.getMemberActivityMeta).toHaveBeenCalledWith('my-team');
|
||||||
|
vi.mocked(console.error).mockClear();
|
||||||
|
|
||||||
|
(electron.app as { isPackaged: boolean }).isPackaged = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rebuilds only the remaining auto-resume delay from persisted rate-limit history', async () => {
|
it('rebuilds only the remaining auto-resume delay from persisted rate-limit history', async () => {
|
||||||
|
|
@ -1316,6 +1431,7 @@ describe('ipc teams handlers', () => {
|
||||||
],
|
],
|
||||||
nextCursor: null,
|
nextCursor: null,
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
|
feedRevision: 'rev-1',
|
||||||
});
|
});
|
||||||
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
||||||
{
|
{
|
||||||
|
|
@ -1332,7 +1448,7 @@ describe('ipc teams handlers', () => {
|
||||||
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
const getDataHandler = handlers.get(TEAM_GET_DATA)!;
|
||||||
const result = (await getDataHandler({} as never, 'my-team')) as {
|
const result = (await getDataHandler({} as never, 'my-team')) as {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: { messages: InboxMessage[] };
|
data: { messages?: InboxMessage[] };
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
|
|
@ -1345,7 +1461,7 @@ describe('ipc teams handlers', () => {
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
expect(result.data.messages.map((message) => message.messageId)).toEqual(['durable-0']);
|
expect(result.data.messages).toHaveLength(50);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('overlays live lead_process messages onto the newest messages page', async () => {
|
it('overlays live lead_process messages onto the newest messages page', async () => {
|
||||||
|
|
@ -1365,7 +1481,8 @@ describe('ipc teams handlers', () => {
|
||||||
].sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp)),
|
].sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp)),
|
||||||
nextCursor: '2026-02-23T10:00:00.000Z|durable-1',
|
nextCursor: '2026-02-23T10:00:00.000Z|durable-1',
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
};
|
feedRevision: 'rev-1',
|
||||||
|
} satisfies MessagesPage;
|
||||||
});
|
});
|
||||||
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
||||||
{
|
{
|
||||||
|
|
@ -1393,7 +1510,7 @@ describe('ipc teams handlers', () => {
|
||||||
expect(result.data.hasMore).toBe(true);
|
expect(result.data.hasMore).toBe(true);
|
||||||
expect(service.getMessagesPage).toHaveBeenCalledWith('my-team', {
|
expect(service.getMessagesPage).toHaveBeenCalledWith('my-team', {
|
||||||
limit: 20,
|
limit: 20,
|
||||||
beforeTimestamp: undefined,
|
cursor: undefined,
|
||||||
liveMessages: expect.arrayContaining([
|
liveMessages: expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
source: 'lead_process',
|
source: 'lead_process',
|
||||||
|
|
@ -1421,7 +1538,8 @@ describe('ipc teams handlers', () => {
|
||||||
],
|
],
|
||||||
nextCursor: null,
|
nextCursor: null,
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
};
|
feedRevision: 'rev-1',
|
||||||
|
} satisfies MessagesPage;
|
||||||
});
|
});
|
||||||
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
||||||
{
|
{
|
||||||
|
|
@ -1461,11 +1579,12 @@ describe('ipc teams handlers', () => {
|
||||||
],
|
],
|
||||||
nextCursor: null,
|
nextCursor: null,
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
|
feedRevision: 'rev-1',
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = (await handlers.get(TEAM_GET_MESSAGES_PAGE)!({} as never, 'my-team', {
|
const result = (await handlers.get(TEAM_GET_MESSAGES_PAGE)!({} as never, 'my-team', {
|
||||||
limit: 20,
|
limit: 20,
|
||||||
beforeTimestamp: '2026-02-23T10:00:00.000Z|cursor',
|
cursor: '2026-02-23T10:00:00.000Z|cursor',
|
||||||
})) as {
|
})) as {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: { messages: InboxMessage[] };
|
data: { messages: InboxMessage[] };
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,41 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
import { ClaudeExtensionsAdapter } from '@main/services/extensions/runtime/ExtensionsRuntimeAdapter';
|
import { ClaudeExtensionsAdapter } from '@main/services/extensions/runtime/ExtensionsRuntimeAdapter';
|
||||||
import { McpConfigStateReader } from '@main/services/extensions/runtime/McpConfigStateReader';
|
import { McpConfigStateReader } from '@main/services/extensions/runtime/McpConfigStateReader';
|
||||||
import { McpInstallationStateService } from '@main/services/extensions/state/McpInstallationStateService';
|
import { McpInstallationStateService } from '@main/services/extensions/state/McpInstallationStateService';
|
||||||
|
|
||||||
|
const TEST_ROOT = path.parse(process.cwd()).root || path.sep;
|
||||||
|
const MOCK_HOME_PATH = path.join(TEST_ROOT, 'tmp', 'mock-home');
|
||||||
|
const PROJECT_A_PATH = path.join(TEST_ROOT, 'tmp', 'project-a');
|
||||||
|
const PROJECT_B_PATH = path.join(TEST_ROOT, 'tmp', 'project-b');
|
||||||
|
|
||||||
|
function normalizeMockPath(filePath: unknown): string {
|
||||||
|
return String(filePath).replaceAll('\\', '/');
|
||||||
|
}
|
||||||
|
|
||||||
vi.mock('@main/utils/pathDecoder', () => ({
|
vi.mock('@main/utils/pathDecoder', () => ({
|
||||||
getHomeDir: () => '/tmp/mock-home',
|
getHomeDir: () => {
|
||||||
getClaudeBasePath: () => '/tmp/mock-home/.claude',
|
const cwd = process.cwd();
|
||||||
|
const windowsRoot = cwd.match(/^[A-Za-z]:[\\/]/)?.[0] ?? null;
|
||||||
|
const root = windowsRoot ?? '/';
|
||||||
|
const sep = windowsRoot ? '\\' : '/';
|
||||||
|
return `${root}${root.endsWith(sep) ? '' : sep}tmp${sep}mock-home`;
|
||||||
|
},
|
||||||
|
getClaudeBasePath: () => {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const windowsRoot = cwd.match(/^[A-Za-z]:[\\/]/)?.[0] ?? null;
|
||||||
|
const root = windowsRoot ?? '/';
|
||||||
|
const sep = windowsRoot ? '\\' : '/';
|
||||||
|
const mockHome = `${root}${root.endsWith(sep) ? '' : sep}tmp${sep}mock-home`;
|
||||||
|
return `${mockHome}${sep}.claude`;
|
||||||
|
},
|
||||||
setClaudeBasePathOverride: vi.fn(),
|
setClaudeBasePathOverride: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('node:fs/promises');
|
vi.mock('node:fs/promises');
|
||||||
|
|
||||||
function toPortablePath(filePath: unknown): string {
|
|
||||||
return String(filePath).replaceAll('\\', '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('McpInstallationStateService', () => {
|
describe('McpInstallationStateService', () => {
|
||||||
let service: McpInstallationStateService;
|
let service: McpInstallationStateService;
|
||||||
const mockedFs = vi.mocked(fs);
|
const mockedFs = vi.mocked(fs);
|
||||||
|
|
@ -35,14 +54,14 @@ describe('McpInstallationStateService', () => {
|
||||||
describe('getInstalled', () => {
|
describe('getInstalled', () => {
|
||||||
it('includes local scope from the current project entry in ~/.claude.json', async () => {
|
it('includes local scope from the current project entry in ~/.claude.json', async () => {
|
||||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||||
const normalizedPath = toPortablePath(filePath);
|
const normalizedPath = normalizeMockPath(filePath);
|
||||||
if (normalizedPath === '/tmp/mock-home/.claude.json') {
|
if (normalizedPath === normalizeMockPath(path.join(MOCK_HOME_PATH, '.claude.json'))) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
context7: { command: 'npx -y @upstash/context7-mcp' },
|
context7: { command: 'npx -y @upstash/context7-mcp' },
|
||||||
},
|
},
|
||||||
projects: {
|
projects: {
|
||||||
'/tmp/project-a': {
|
[PROJECT_A_PATH]: {
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
stripe: { url: 'https://mcp.stripe.com' },
|
stripe: { url: 'https://mcp.stripe.com' },
|
||||||
},
|
},
|
||||||
|
|
@ -51,7 +70,7 @@ describe('McpInstallationStateService', () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedPath === '/tmp/project-a/.mcp.json') {
|
if (normalizedPath === normalizeMockPath(path.join(PROJECT_A_PATH, '.mcp.json'))) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
paypal: { url: 'https://mcp.paypal.com/mcp' },
|
paypal: { url: 'https://mcp.paypal.com/mcp' },
|
||||||
|
|
@ -62,7 +81,7 @@ describe('McpInstallationStateService', () => {
|
||||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||||
});
|
});
|
||||||
|
|
||||||
const entries = await service.getInstalled('/tmp/project-a');
|
const entries = await service.getInstalled(PROJECT_A_PATH);
|
||||||
|
|
||||||
expect(entries).toEqual([
|
expect(entries).toEqual([
|
||||||
{ name: 'context7', scope: 'user', transport: 'stdio' },
|
{ name: 'context7', scope: 'user', transport: 'stdio' },
|
||||||
|
|
@ -73,8 +92,8 @@ describe('McpInstallationStateService', () => {
|
||||||
|
|
||||||
it('caches results within TTL for the same project path', async () => {
|
it('caches results within TTL for the same project path', async () => {
|
||||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||||
const normalizedPath = toPortablePath(filePath);
|
const normalizedPath = normalizeMockPath(filePath);
|
||||||
if (normalizedPath === '/tmp/mock-home/.claude.json') {
|
if (normalizedPath === normalizeMockPath(path.join(MOCK_HOME_PATH, '.claude.json'))) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
context7: { command: 'npx -y @upstash/context7-mcp' },
|
context7: { command: 'npx -y @upstash/context7-mcp' },
|
||||||
|
|
@ -82,7 +101,7 @@ describe('McpInstallationStateService', () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedPath === '/tmp/project-a/.mcp.json') {
|
if (normalizedPath === normalizeMockPath(path.join(PROJECT_A_PATH, '.mcp.json'))) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
'repo-a-server': { url: 'https://repo-a.example.com/mcp' },
|
'repo-a-server': { url: 'https://repo-a.example.com/mcp' },
|
||||||
|
|
@ -93,27 +112,27 @@ describe('McpInstallationStateService', () => {
|
||||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||||
});
|
});
|
||||||
|
|
||||||
await service.getInstalled('/tmp/project-a');
|
await service.getInstalled(PROJECT_A_PATH);
|
||||||
await service.getInstalled('/tmp/project-a');
|
await service.getInstalled(PROJECT_A_PATH);
|
||||||
|
|
||||||
expect(mockedFs.readFile).toHaveBeenCalledTimes(2);
|
expect(mockedFs.readFile).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('caches results independently per project path', async () => {
|
it('caches results independently per project path', async () => {
|
||||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||||
const normalizedPath = toPortablePath(filePath);
|
const normalizedPath = normalizeMockPath(filePath);
|
||||||
if (normalizedPath === '/tmp/mock-home/.claude.json') {
|
if (normalizedPath === normalizeMockPath(path.join(MOCK_HOME_PATH, '.claude.json'))) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
context7: { command: 'npx -y @upstash/context7-mcp' },
|
context7: { command: 'npx -y @upstash/context7-mcp' },
|
||||||
},
|
},
|
||||||
projects: {
|
projects: {
|
||||||
'/tmp/project-a': {
|
[PROJECT_A_PATH]: {
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
stripe: { url: 'https://mcp.stripe.com' },
|
stripe: { url: 'https://mcp.stripe.com' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'/tmp/project-b': {
|
[PROJECT_B_PATH]: {
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
github: { command: 'uvx github-mcp' },
|
github: { command: 'uvx github-mcp' },
|
||||||
},
|
},
|
||||||
|
|
@ -122,7 +141,7 @@ describe('McpInstallationStateService', () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedPath === '/tmp/project-a/.mcp.json') {
|
if (normalizedPath === normalizeMockPath(path.join(PROJECT_A_PATH, '.mcp.json'))) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
'repo-a-server': { url: 'https://repo-a.example.com/mcp' },
|
'repo-a-server': { url: 'https://repo-a.example.com/mcp' },
|
||||||
|
|
@ -130,7 +149,7 @@ describe('McpInstallationStateService', () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedPath === '/tmp/project-b/.mcp.json') {
|
if (normalizedPath === normalizeMockPath(path.join(PROJECT_B_PATH, '.mcp.json'))) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
'repo-b-server': { command: 'uvx repo-b-mcp' },
|
'repo-b-server': { command: 'uvx repo-b-mcp' },
|
||||||
|
|
@ -141,8 +160,8 @@ describe('McpInstallationStateService', () => {
|
||||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectAEntries = await service.getInstalled('/tmp/project-a');
|
const projectAEntries = await service.getInstalled(PROJECT_A_PATH);
|
||||||
const projectBEntries = await service.getInstalled('/tmp/project-b');
|
const projectBEntries = await service.getInstalled(PROJECT_B_PATH);
|
||||||
|
|
||||||
expect(projectAEntries).toEqual([
|
expect(projectAEntries).toEqual([
|
||||||
{ name: 'context7', scope: 'user', transport: 'stdio' },
|
{ name: 'context7', scope: 'user', transport: 'stdio' },
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,30 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
import { PluginInstallationStateService } from '@main/services/extensions/state/PluginInstallationStateService';
|
import { PluginInstallationStateService } from '@main/services/extensions/state/PluginInstallationStateService';
|
||||||
|
|
||||||
// Mock pathDecoder to control ~/.claude path
|
const TEST_ROOT = path.parse(process.cwd()).root || path.sep;
|
||||||
vi.mock('@main/utils/pathDecoder', () => ({
|
const MOCK_CLAUDE_BASE_PATH = path.join(TEST_ROOT, 'tmp', 'mock-claude');
|
||||||
getClaudeBasePath: () => '/tmp/mock-claude',
|
const PROJECT_A_PATH = path.join(TEST_ROOT, 'tmp', 'project-a');
|
||||||
}));
|
const PROJECT_B_PATH = path.join(TEST_ROOT, 'tmp', 'project-b');
|
||||||
|
|
||||||
// Mock filesystem
|
function normalizeMockPath(filePath: unknown): string {
|
||||||
vi.mock('node:fs/promises');
|
|
||||||
|
|
||||||
function toPortablePath(filePath: unknown): string {
|
|
||||||
return String(filePath).replaceAll('\\', '/');
|
return String(filePath).replaceAll('\\', '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vi.mock('@main/utils/pathDecoder', () => ({
|
||||||
|
getClaudeBasePath: () => {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const windowsRoot = cwd.match(/^[A-Za-z]:[\\/]/)?.[0] ?? null;
|
||||||
|
const root = windowsRoot ?? '/';
|
||||||
|
const sep = windowsRoot ? '\\' : '/';
|
||||||
|
return `${root}tmp${sep}mock-claude`;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('node:fs/promises');
|
||||||
|
|
||||||
describe('PluginInstallationStateService', () => {
|
describe('PluginInstallationStateService', () => {
|
||||||
let service: PluginInstallationStateService;
|
let service: PluginInstallationStateService;
|
||||||
const mockedFs = vi.mocked(fs);
|
const mockedFs = vi.mocked(fs);
|
||||||
|
|
@ -31,7 +41,7 @@ describe('PluginInstallationStateService', () => {
|
||||||
describe('getInstalledPlugins', () => {
|
describe('getInstalledPlugins', () => {
|
||||||
it('returns user-scoped plugins enabled in user settings', async () => {
|
it('returns user-scoped plugins enabled in user settings', async () => {
|
||||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||||
const normalizedPath = toPortablePath(filePath);
|
const normalizedPath = normalizeMockPath(filePath);
|
||||||
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
version: 2,
|
version: 2,
|
||||||
|
|
@ -56,7 +66,7 @@ describe('PluginInstallationStateService', () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedPath === '/tmp/mock-claude/settings.json') {
|
if (normalizedPath === normalizeMockPath(path.join(MOCK_CLAUDE_BASE_PATH, 'settings.json'))) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
enabledPlugins: {
|
enabledPlugins: {
|
||||||
'context7@claude-plugins-official': true,
|
'context7@claude-plugins-official': true,
|
||||||
|
|
@ -79,7 +89,7 @@ describe('PluginInstallationStateService', () => {
|
||||||
|
|
||||||
it('includes project and local scopes only for the active project', async () => {
|
it('includes project and local scopes only for the active project', async () => {
|
||||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||||
const normalizedPath = toPortablePath(filePath);
|
const normalizedPath = normalizeMockPath(filePath);
|
||||||
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
version: 2,
|
version: 2,
|
||||||
|
|
@ -109,7 +119,7 @@ describe('PluginInstallationStateService', () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedPath === '/tmp/mock-claude/settings.json') {
|
if (normalizedPath === normalizeMockPath(path.join(MOCK_CLAUDE_BASE_PATH, 'settings.json'))) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
enabledPlugins: {
|
enabledPlugins: {
|
||||||
'context7@claude-plugins-official': true,
|
'context7@claude-plugins-official': true,
|
||||||
|
|
@ -117,7 +127,7 @@ describe('PluginInstallationStateService', () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedPath === '/tmp/project-a/.claude/settings.json') {
|
if (normalizedPath === normalizeMockPath(path.join(PROJECT_A_PATH, '.claude', 'settings.json'))) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
enabledPlugins: {
|
enabledPlugins: {
|
||||||
'typescript-lsp@claude-plugins-official': true,
|
'typescript-lsp@claude-plugins-official': true,
|
||||||
|
|
@ -125,7 +135,10 @@ describe('PluginInstallationStateService', () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedPath === '/tmp/project-a/.claude/settings.local.json') {
|
if (
|
||||||
|
normalizedPath ===
|
||||||
|
normalizeMockPath(path.join(PROJECT_A_PATH, '.claude', 'settings.local.json'))
|
||||||
|
) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
enabledPlugins: {
|
enabledPlugins: {
|
||||||
'formatter@claude-plugins-official': true,
|
'formatter@claude-plugins-official': true,
|
||||||
|
|
@ -136,7 +149,7 @@ describe('PluginInstallationStateService', () => {
|
||||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||||
});
|
});
|
||||||
|
|
||||||
const entries = await service.getInstalledPlugins('/tmp/project-a');
|
const entries = await service.getInstalledPlugins(PROJECT_A_PATH);
|
||||||
|
|
||||||
expect(entries.map((entry) => [entry.pluginId, entry.scope])).toEqual([
|
expect(entries.map((entry) => [entry.pluginId, entry.scope])).toEqual([
|
||||||
['context7@claude-plugins-official', 'user'],
|
['context7@claude-plugins-official', 'user'],
|
||||||
|
|
@ -147,7 +160,7 @@ describe('PluginInstallationStateService', () => {
|
||||||
|
|
||||||
it('does not leak another project scope into the current project', async () => {
|
it('does not leak another project scope into the current project', async () => {
|
||||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||||
const normalizedPath = toPortablePath(filePath);
|
const normalizedPath = normalizeMockPath(filePath);
|
||||||
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
version: 2,
|
version: 2,
|
||||||
|
|
@ -170,7 +183,7 @@ describe('PluginInstallationStateService', () => {
|
||||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||||
});
|
});
|
||||||
|
|
||||||
const entries = await service.getInstalledPlugins('/tmp/project-b');
|
const entries = await service.getInstalledPlugins(PROJECT_B_PATH);
|
||||||
|
|
||||||
expect(entries).toEqual([]);
|
expect(entries).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
@ -186,7 +199,7 @@ describe('PluginInstallationStateService', () => {
|
||||||
|
|
||||||
it('returns empty array for unexpected version', async () => {
|
it('returns empty array for unexpected version', async () => {
|
||||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||||
const normalizedPath = toPortablePath(filePath);
|
const normalizedPath = normalizeMockPath(filePath);
|
||||||
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
||||||
return JSON.stringify({ version: 1, plugins: {} });
|
return JSON.stringify({ version: 1, plugins: {} });
|
||||||
}
|
}
|
||||||
|
|
@ -202,7 +215,7 @@ describe('PluginInstallationStateService', () => {
|
||||||
|
|
||||||
it('caches within TTL', async () => {
|
it('caches within TTL', async () => {
|
||||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||||
const normalizedPath = toPortablePath(filePath);
|
const normalizedPath = normalizeMockPath(filePath);
|
||||||
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
||||||
return JSON.stringify({ version: 2, plugins: {} });
|
return JSON.stringify({ version: 2, plugins: {} });
|
||||||
}
|
}
|
||||||
|
|
@ -220,7 +233,7 @@ describe('PluginInstallationStateService', () => {
|
||||||
|
|
||||||
it('caches results independently per project path', async () => {
|
it('caches results independently per project path', async () => {
|
||||||
mockedFs.readFile.mockImplementation(async (filePath) => {
|
mockedFs.readFile.mockImplementation(async (filePath) => {
|
||||||
const normalizedPath = toPortablePath(filePath);
|
const normalizedPath = normalizeMockPath(filePath);
|
||||||
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
if (normalizedPath.endsWith('/plugins/installed_plugins.json')) {
|
||||||
return JSON.stringify({ version: 2, plugins: {} });
|
return JSON.stringify({ version: 2, plugins: {} });
|
||||||
}
|
}
|
||||||
|
|
@ -230,8 +243,8 @@ describe('PluginInstallationStateService', () => {
|
||||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||||
});
|
});
|
||||||
|
|
||||||
await service.getInstalledPlugins('/tmp/project-a');
|
await service.getInstalledPlugins(PROJECT_A_PATH);
|
||||||
await service.getInstalledPlugins('/tmp/project-b');
|
await service.getInstalledPlugins(PROJECT_B_PATH);
|
||||||
|
|
||||||
expect(mockedFs.readFile).toHaveBeenCalledTimes(8);
|
expect(mockedFs.readFile).toHaveBeenCalledTimes(8);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -292,6 +292,9 @@ describe('CliInstallerService', () => {
|
||||||
expect.objectContaining({ modelId: 'gpt-5.4-mini', status: 'checking' }),
|
expect.objectContaining({ modelId: 'gpt-5.4-mini', status: 'checking' }),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
expect(verifiedProvider?.modelAvailability).not.toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ modelId: 'gpt-5.2-codex' })])
|
||||||
|
);
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
const latestCodexProvider = service
|
const latestCodexProvider = service
|
||||||
|
|
@ -307,6 +310,12 @@ describe('CliInstallerService', () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(execCli).not.toHaveBeenCalledWith(
|
||||||
|
'/usr/local/bin/claude',
|
||||||
|
expect.arrayContaining(['--model', 'gpt-5.2-codex']),
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
|
||||||
const statusEvents = mockWindow.webContents.send.mock.calls
|
const statusEvents = mockWindow.webContents.send.mock.calls
|
||||||
.filter((call: unknown[]) => call[0] === 'cliInstaller:progress')
|
.filter((call: unknown[]) => call[0] === 'cliInstaller:progress')
|
||||||
.map((call: unknown[]) => call[1] as { type?: string; status?: { providers?: unknown[] } })
|
.map((call: unknown[]) => call[1] as { type?: string; status?: { providers?: unknown[] } })
|
||||||
|
|
@ -323,12 +332,29 @@ describe('CliInstallerService', () => {
|
||||||
'modelAvailability' in provider &&
|
'modelAvailability' in provider &&
|
||||||
(provider as { providerId?: string }).providerId === 'codex' &&
|
(provider as { providerId?: string }).providerId === 'codex' &&
|
||||||
Array.isArray((provider as { modelAvailability?: unknown[] }).modelAvailability) &&
|
Array.isArray((provider as { modelAvailability?: unknown[] }).modelAvailability) &&
|
||||||
(provider as { modelAvailability: Array<{ status?: string }> }).modelAvailability.some(
|
(provider as { modelAvailability: Array<{ modelId?: string; status?: string }> })
|
||||||
(item) => item.status === 'unavailable'
|
.modelAvailability.some(
|
||||||
)
|
(item) => item.modelId === 'gpt-5.4' && item.status === 'available'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
statusEvents.some((event) =>
|
||||||
|
event.status?.providers?.some(
|
||||||
|
(provider) =>
|
||||||
|
typeof provider === 'object' &&
|
||||||
|
provider !== null &&
|
||||||
|
'providerId' in provider &&
|
||||||
|
'modelAvailability' in provider &&
|
||||||
|
(provider as { providerId?: string }).providerId === 'codex' &&
|
||||||
|
Array.isArray((provider as { modelAvailability?: unknown[] }).modelAvailability) &&
|
||||||
|
(provider as { modelAvailability: Array<{ modelId?: string }> }).modelAvailability.some(
|
||||||
|
(item) => item.modelId === 'gpt-5.2-codex'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ describe('CliProviderModelAvailabilityService', () => {
|
||||||
expect(execCliMock).toHaveBeenCalledTimes(2);
|
expect(execCliMock).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks unsupported models as unavailable with the runtime reason', async () => {
|
it('marks visible unsupported models as unavailable with the runtime reason', async () => {
|
||||||
buildProviderAwareCliEnvMock.mockResolvedValue({
|
buildProviderAwareCliEnvMock.mockResolvedValue({
|
||||||
env: { HOME: '/Users/tester' },
|
env: { HOME: '/Users/tester' },
|
||||||
connectionIssues: {},
|
connectionIssues: {},
|
||||||
|
|
|
||||||
|
|
@ -826,7 +826,6 @@ describe('BoardTaskLogStreamService integration', () => {
|
||||||
expect(bashCommands).not.toContain('echo alien');
|
expect(bashCommands).not.toContain('echo alien');
|
||||||
expect(rawMessages.some((message) => message.uuid === 'u-bash-alice-real')).toBe(false);
|
expect(rawMessages.some((message) => message.uuid === 'u-bash-alice-real')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to createdAt/updatedAt time window when workIntervals are missing', async () => {
|
it('falls back to createdAt/updatedAt time window when workIntervals are missing', async () => {
|
||||||
const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-created-window-'));
|
const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-created-window-'));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,9 @@ import { TeamDataService } from '../../../../src/main/services/team/TeamDataServ
|
||||||
import type {
|
import type {
|
||||||
InboxMessage,
|
InboxMessage,
|
||||||
KanbanState,
|
KanbanState,
|
||||||
|
ResolvedTeamMember,
|
||||||
TeamConfig,
|
TeamConfig,
|
||||||
TeamData,
|
TeamProcess,
|
||||||
TeamTask,
|
TeamTask,
|
||||||
TeamTaskWithKanban,
|
TeamTaskWithKanban,
|
||||||
} from '../../../../src/shared/types/team';
|
} from '../../../../src/shared/types/team';
|
||||||
|
|
@ -338,10 +339,9 @@ function createGetTeamDataHarness(options: {
|
||||||
config: TeamConfig,
|
config: TeamConfig,
|
||||||
metaMembers: TeamConfig['members'],
|
metaMembers: TeamConfig['members'],
|
||||||
inboxNames: string[],
|
inboxNames: string[],
|
||||||
tasks: TeamTaskWithKanban[],
|
tasks: TeamTaskWithKanban[]
|
||||||
messages: InboxMessage[]
|
) => ResolvedTeamMember[];
|
||||||
) => TeamData['members'];
|
listProcesses?: () => TeamProcess[];
|
||||||
listProcesses?: () => TeamData['processes'];
|
|
||||||
getMemberAdvisories?: () => Promise<Map<string, unknown>>;
|
getMemberAdvisories?: () => Promise<Map<string, unknown>>;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const getConfig = vi.fn(async () =>
|
const getConfig = vi.fn(async () =>
|
||||||
|
|
@ -449,7 +449,7 @@ function createGetTeamDataHarness(options: {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildResolvedMember(name: string): TeamData['members'][number] {
|
function buildResolvedMember(name: string): ResolvedTeamMember {
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
status: 'unknown',
|
status: 'unknown',
|
||||||
|
|
@ -726,6 +726,39 @@ describe('TeamDataService', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns lightweight notification context from config without hydrating team data', async () => {
|
||||||
|
const getConfig = vi.fn(async () => ({
|
||||||
|
name: 'My Team',
|
||||||
|
projectPath: '/Users/dev/my-project',
|
||||||
|
members: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const service = new TeamDataService(
|
||||||
|
{
|
||||||
|
listTeams: vi.fn(),
|
||||||
|
getConfig,
|
||||||
|
} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
(() => ({ processes: { listProcesses: vi.fn(() => []) } })) as never
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.getTeamNotificationContext('my-team');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
displayName: 'My Team',
|
||||||
|
projectPath: '/Users/dev/my-project',
|
||||||
|
});
|
||||||
|
expect(getConfig).toHaveBeenCalledWith('my-team');
|
||||||
|
});
|
||||||
|
|
||||||
it('creates task with status pending when startImmediately is false', async () => {
|
it('creates task with status pending when startImmediately is false', async () => {
|
||||||
const createTaskMock = vi.fn((task) => ({ ...task, status: 'pending' }));
|
const createTaskMock = vi.fn((task) => ({ ...task, status: 'pending' }));
|
||||||
const service = new TeamDataService(
|
const service = new TeamDataService(
|
||||||
|
|
@ -2535,8 +2568,8 @@ describe('TeamDataService', () => {
|
||||||
} as never
|
} as never
|
||||||
);
|
);
|
||||||
|
|
||||||
const data = await service.getTeamData('my-team');
|
const feed = await service.getMessageFeed('my-team');
|
||||||
const costResult = data.messages.find((message) => message.messageId === 'lead-thought-1');
|
const costResult = feed.messages.find((message) => message.messageId === 'lead-thought-1');
|
||||||
|
|
||||||
expect(costResult).toMatchObject({
|
expect(costResult).toMatchObject({
|
||||||
messageKind: 'slash_command_result',
|
messageKind: 'slash_command_result',
|
||||||
|
|
@ -2605,8 +2638,8 @@ describe('TeamDataService', () => {
|
||||||
} as never
|
} as never
|
||||||
);
|
);
|
||||||
|
|
||||||
const data = await service.getTeamData('my-team');
|
const feed = await service.getMessageFeed('my-team');
|
||||||
const result = data.messages.find((message) => message.messageId === 'passive-idle-dup-1');
|
const result = feed.messages.find((message) => message.messageId === 'passive-idle-dup-1');
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
expect(result?.source).not.toBe('lead_process');
|
expect(result?.source).not.toBe('lead_process');
|
||||||
|
|
@ -2680,8 +2713,8 @@ describe('TeamDataService', () => {
|
||||||
sentMessages: [userReplyRow],
|
sentMessages: [userReplyRow],
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await service.getTeamData('my-team');
|
const feed = await service.getMessageFeed('my-team');
|
||||||
const linked = data.messages.find((message) => message.messageId === 'passive-user-summary-1');
|
const linked = feed.messages.find((message) => message.messageId === 'passive-user-summary-1');
|
||||||
|
|
||||||
expect(linked?.relayOfMessageId).toBe('user-reply-1');
|
expect(linked?.relayOfMessageId).toBe('user-reply-1');
|
||||||
expect(passiveSummaryRow.relayOfMessageId).toBeUndefined();
|
expect(passiveSummaryRow.relayOfMessageId).toBeUndefined();
|
||||||
|
|
@ -2716,8 +2749,8 @@ describe('TeamDataService', () => {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await service.getTeamData('my-team');
|
const feed = await service.getMessageFeed('my-team');
|
||||||
const linked = data.messages.find(
|
const linked = feed.messages.find(
|
||||||
(message) => message.messageId === 'passive-user-summary-contains-1'
|
(message) => message.messageId === 'passive-user-summary-contains-1'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -2753,8 +2786,8 @@ describe('TeamDataService', () => {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await service.getTeamData('my-team');
|
const feed = await service.getMessageFeed('my-team');
|
||||||
const linked = data.messages.find((message) => message.messageId === 'passive-user-summary-old-1');
|
const linked = feed.messages.find((message) => message.messageId === 'passive-user-summary-old-1');
|
||||||
|
|
||||||
expect(linked?.relayOfMessageId).toBeUndefined();
|
expect(linked?.relayOfMessageId).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
@ -2788,8 +2821,8 @@ describe('TeamDataService', () => {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await service.getTeamData('my-team');
|
const feed = await service.getMessageFeed('my-team');
|
||||||
const linked = data.messages.find((message) => message.messageId === 'passive-bob-summary-1');
|
const linked = feed.messages.find((message) => message.messageId === 'passive-bob-summary-1');
|
||||||
|
|
||||||
expect(linked?.relayOfMessageId).toBeUndefined();
|
expect(linked?.relayOfMessageId).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
@ -2823,8 +2856,8 @@ describe('TeamDataService', () => {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await service.getTeamData('my-team');
|
const feed = await service.getMessageFeed('my-team');
|
||||||
const linked = data.messages.find(
|
const linked = feed.messages.find(
|
||||||
(message) => message.messageId === 'passive-user-summary-sender-1'
|
(message) => message.messageId === 'passive-user-summary-sender-1'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -2870,8 +2903,8 @@ describe('TeamDataService', () => {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await service.getTeamData('my-team');
|
const feed = await service.getMessageFeed('my-team');
|
||||||
const linked = data.messages.find(
|
const linked = feed.messages.find(
|
||||||
(message) => message.messageId === 'passive-user-summary-ambiguous-1'
|
(message) => message.messageId === 'passive-user-summary-ambiguous-1'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -3362,11 +3395,11 @@ describe('TeamDataService', () => {
|
||||||
const fixture = await createResolverBackedLeadFixture();
|
const fixture = await createResolverBackedLeadFixture();
|
||||||
const service = createResolverBackedService();
|
const service = createResolverBackedService();
|
||||||
|
|
||||||
const data = await service.getTeamData(fixture.teamName);
|
const feed = await service.getMessageFeed(fixture.teamName);
|
||||||
const persistedConfig = JSON.parse(await fs.readFile(fixture.configPath, 'utf8')) as TeamConfig;
|
const persistedConfig = JSON.parse(await fs.readFile(fixture.configPath, 'utf8')) as TeamConfig;
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
data.messages.find(
|
feed.messages.find(
|
||||||
(message) =>
|
(message) =>
|
||||||
message.source === 'lead_session' &&
|
message.source === 'lead_session' &&
|
||||||
message.text.includes('recovered through the transcript resolver')
|
message.text.includes('recovered through the transcript resolver')
|
||||||
|
|
@ -3478,8 +3511,6 @@ describe('TeamDataService', () => {
|
||||||
it('starts light reads immediately, bounds heavy reads, and keeps processes outside the parallel phase', async () => {
|
it('starts light reads immediately, bounds heavy reads, and keeps processes outside the parallel phase', async () => {
|
||||||
const order: string[] = [];
|
const order: string[] = [];
|
||||||
const tasksDeferred = createDeferred<TeamTask[]>();
|
const tasksDeferred = createDeferred<TeamTask[]>();
|
||||||
const messagesDeferred = createDeferred<InboxMessage[]>();
|
|
||||||
const leadTextsDeferred = createDeferred<InboxMessage[]>();
|
|
||||||
|
|
||||||
const harness = createGetTeamDataHarness({
|
const harness = createGetTeamDataHarness({
|
||||||
getTasks: async () => {
|
getTasks: async () => {
|
||||||
|
|
@ -3490,10 +3521,6 @@ describe('TeamDataService', () => {
|
||||||
order.push('inboxNames:start');
|
order.push('inboxNames:start');
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
getMessages: async () => {
|
|
||||||
order.push('messages:start');
|
|
||||||
return messagesDeferred.promise;
|
|
||||||
},
|
|
||||||
getMembers: async () => {
|
getMembers: async () => {
|
||||||
order.push('meta:start');
|
order.push('meta:start');
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -3502,10 +3529,6 @@ describe('TeamDataService', () => {
|
||||||
order.push('kanban:start');
|
order.push('kanban:start');
|
||||||
return { teamName: 'my-team', reviewers: [], tasks: {} };
|
return { teamName: 'my-team', reviewers: [], tasks: {} };
|
||||||
},
|
},
|
||||||
readMessages: async () => {
|
|
||||||
order.push('sent:start');
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
resolveMembers: () => {
|
resolveMembers: () => {
|
||||||
order.push('resolveMembers');
|
order.push('resolveMembers');
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -3527,39 +3550,21 @@ describe('TeamDataService', () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation(
|
|
||||||
async () => {
|
|
||||||
order.push('leadTexts:start');
|
|
||||||
return leadTextsDeferred.promise;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const pending = harness.service.getTeamData('my-team');
|
const pending = harness.service.getTeamData('my-team');
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
expect(order).toEqual(
|
expect(order).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
'inboxNames:start',
|
'inboxNames:start',
|
||||||
'sent:start',
|
|
||||||
'meta:start',
|
'meta:start',
|
||||||
'kanban:start',
|
'kanban:start',
|
||||||
'tasks:start',
|
'tasks:start',
|
||||||
'messages:start',
|
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
expect(order).not.toContain('leadTexts:start');
|
|
||||||
expect(order).not.toContain('processes:start');
|
expect(order).not.toContain('processes:start');
|
||||||
|
expect(order).not.toContain('leadTexts:start');
|
||||||
|
|
||||||
tasksDeferred.resolve([]);
|
tasksDeferred.resolve([]);
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
expect(order).toContain('leadTexts:start');
|
|
||||||
expect(order.indexOf('tasks:start')).toBeLessThan(order.indexOf('messages:start'));
|
|
||||||
expect(order.indexOf('messages:start')).toBeLessThan(order.indexOf('leadTexts:start'));
|
|
||||||
expect(order).not.toContain('processes:start');
|
|
||||||
|
|
||||||
messagesDeferred.resolve([]);
|
|
||||||
leadTextsDeferred.resolve([]);
|
|
||||||
|
|
||||||
const data = await pending;
|
const data = await pending;
|
||||||
|
|
||||||
|
|
@ -3569,7 +3574,7 @@ describe('TeamDataService', () => {
|
||||||
pid: 101,
|
pid: 101,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
expect(order.indexOf('leadTexts:start')).toBeLessThan(order.indexOf('processes:start'));
|
expect(order).not.toContain('leadTexts:start');
|
||||||
expect(order.indexOf('resolveMembers')).toBeLessThan(order.indexOf('processes:start'));
|
expect(order.indexOf('resolveMembers')).toBeLessThan(order.indexOf('processes:start'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -3614,47 +3619,64 @@ describe('TeamDataService', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('surfaces isAlive in the structural snapshot from live process state', async () => {
|
||||||
|
const aliveHarness = createGetTeamDataHarness({
|
||||||
|
listProcesses: () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'proc-1',
|
||||||
|
label: 'Lead',
|
||||||
|
pid: 101,
|
||||||
|
registeredAt: '2026-04-09T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
] satisfies TeamProcess[],
|
||||||
|
});
|
||||||
|
const offlineHarness = createGetTeamDataHarness({
|
||||||
|
listProcesses: () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'proc-1',
|
||||||
|
label: 'Lead',
|
||||||
|
pid: 101,
|
||||||
|
registeredAt: '2026-04-09T10:00:00.000Z',
|
||||||
|
stoppedAt: '2026-04-09T10:05:00.000Z',
|
||||||
|
},
|
||||||
|
] satisfies TeamProcess[],
|
||||||
|
});
|
||||||
|
|
||||||
|
const aliveData = await aliveHarness.service.getTeamData('my-team');
|
||||||
|
const offlineData = await offlineHarness.service.getTeamData('my-team');
|
||||||
|
|
||||||
|
expect(aliveData.isAlive).toBe(true);
|
||||||
|
expect(offlineData.isAlive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps warning order deterministic even when read failures settle out of order', async () => {
|
it('keeps warning order deterministic even when read failures settle out of order', async () => {
|
||||||
const tasksDeferred = createDeferred<TeamTask[]>();
|
const tasksDeferred = createDeferred<TeamTask[]>();
|
||||||
const inboxDeferred = createDeferred<string[]>();
|
const inboxDeferred = createDeferred<string[]>();
|
||||||
const messagesDeferred = createDeferred<InboxMessage[]>();
|
|
||||||
const leadTextsDeferred = createDeferred<InboxMessage[]>();
|
|
||||||
const sentDeferred = createDeferred<InboxMessage[]>();
|
|
||||||
const metaDeferred = createDeferred<TeamConfig['members']>();
|
const metaDeferred = createDeferred<TeamConfig['members']>();
|
||||||
const kanbanDeferred = createDeferred<KanbanState>();
|
const kanbanDeferred = createDeferred<KanbanState>();
|
||||||
|
|
||||||
const harness = createGetTeamDataHarness({
|
const harness = createGetTeamDataHarness({
|
||||||
getTasks: async () => tasksDeferred.promise,
|
getTasks: async () => tasksDeferred.promise,
|
||||||
listInboxNames: async () => inboxDeferred.promise,
|
listInboxNames: async () => inboxDeferred.promise,
|
||||||
getMessages: async () => messagesDeferred.promise,
|
|
||||||
getMembers: async () => metaDeferred.promise,
|
getMembers: async () => metaDeferred.promise,
|
||||||
getState: async () => kanbanDeferred.promise,
|
getState: async () => kanbanDeferred.promise,
|
||||||
readMessages: async () => sentDeferred.promise,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation(
|
|
||||||
async () => leadTextsDeferred.promise
|
|
||||||
);
|
|
||||||
|
|
||||||
const pending = harness.service.getTeamData('my-team');
|
const pending = harness.service.getTeamData('my-team');
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
sentDeferred.reject(new Error('sent failed'));
|
|
||||||
kanbanDeferred.reject(new Error('kanban failed'));
|
kanbanDeferred.reject(new Error('kanban failed'));
|
||||||
tasksDeferred.reject(new Error('tasks failed'));
|
tasksDeferred.reject(new Error('tasks failed'));
|
||||||
metaDeferred.reject(new Error('meta failed'));
|
metaDeferred.reject(new Error('meta failed'));
|
||||||
inboxDeferred.reject(new Error('inbox failed'));
|
inboxDeferred.reject(new Error('inbox failed'));
|
||||||
leadTextsDeferred.reject(new Error('lead failed'));
|
|
||||||
messagesDeferred.reject(new Error('messages failed'));
|
|
||||||
|
|
||||||
const data = await pending;
|
const data = await pending;
|
||||||
|
|
||||||
expect(data.warnings).toEqual([
|
expect(data.warnings).toEqual([
|
||||||
'Tasks failed to load',
|
'Tasks failed to load',
|
||||||
'Inboxes failed to load',
|
'Inboxes failed to load',
|
||||||
'Messages failed to load',
|
|
||||||
'Lead session texts failed to load',
|
|
||||||
'Sent messages failed to load',
|
|
||||||
'Member metadata failed to load',
|
'Member metadata failed to load',
|
||||||
'Kanban state failed to load',
|
'Kanban state failed to load',
|
||||||
]);
|
]);
|
||||||
|
|
@ -3698,9 +3720,9 @@ describe('TeamDataService', () => {
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const data = await harness.service.getTeamData('my-team');
|
const feed = await harness.service.getMessageFeed('my-team');
|
||||||
|
|
||||||
expect(data.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1', 'inbox-1']);
|
expect(feed.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1', 'inbox-1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves assembled messages and resolver inputs when inbox messages fail', async () => {
|
it('preserves assembled messages and resolver inputs when inbox messages fail', async () => {
|
||||||
|
|
@ -3749,11 +3771,10 @@ describe('TeamDataService', () => {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const data = await harness.service.getTeamData('my-team');
|
const data = await harness.service.getTeamData('my-team');
|
||||||
|
const feed = await harness.service.getMessageFeed('my-team');
|
||||||
|
|
||||||
expect(data.warnings).toEqual(
|
expect(data.warnings).toEqual(expect.arrayContaining(['Kanban state failed to load']));
|
||||||
expect.arrayContaining(['Messages failed to load', 'Kanban state failed to load'])
|
expect(feed.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1']);
|
||||||
);
|
|
||||||
expect(data.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1']);
|
|
||||||
expect(resolveMembersSpy).toHaveBeenCalledWith(
|
expect(resolveMembersSpy).toHaveBeenCalledWith(
|
||||||
buildDefaultTeamConfig(),
|
buildDefaultTeamConfig(),
|
||||||
metaMembers,
|
metaMembers,
|
||||||
|
|
@ -3763,10 +3784,6 @@ describe('TeamDataService', () => {
|
||||||
id: 'task-1',
|
id: 'task-1',
|
||||||
subject: 'Investigate rollout',
|
subject: 'Investigate rollout',
|
||||||
}),
|
}),
|
||||||
],
|
|
||||||
[
|
|
||||||
expect.objectContaining({ messageId: 'sent-1' }),
|
|
||||||
expect.objectContaining({ messageId: 'lead-1' }),
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -3805,16 +3822,11 @@ describe('TeamDataService', () => {
|
||||||
it('degrades a queued heavy sync throw to warning and still completes the snapshot', async () => {
|
it('degrades a queued heavy sync throw to warning and still completes the snapshot', async () => {
|
||||||
const order: string[] = [];
|
const order: string[] = [];
|
||||||
const tasksDeferred = createDeferred<TeamTask[]>();
|
const tasksDeferred = createDeferred<TeamTask[]>();
|
||||||
const messagesDeferred = createDeferred<InboxMessage[]>();
|
|
||||||
const harness = createGetTeamDataHarness({
|
const harness = createGetTeamDataHarness({
|
||||||
getTasks: async () => {
|
getTasks: async () => {
|
||||||
order.push('tasks:start');
|
order.push('tasks:start');
|
||||||
return tasksDeferred.promise;
|
return tasksDeferred.promise;
|
||||||
},
|
},
|
||||||
getMessages: async () => {
|
|
||||||
order.push('messages:start');
|
|
||||||
return messagesDeferred.promise;
|
|
||||||
},
|
|
||||||
listProcesses: () => {
|
listProcesses: () => {
|
||||||
order.push('processes:start');
|
order.push('processes:start');
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -3832,14 +3844,9 @@ describe('TeamDataService', () => {
|
||||||
expect(order).not.toContain('leadTexts:start');
|
expect(order).not.toContain('leadTexts:start');
|
||||||
|
|
||||||
tasksDeferred.resolve([]);
|
tasksDeferred.resolve([]);
|
||||||
await flushMicrotasks();
|
|
||||||
|
|
||||||
expect(order).toContain('leadTexts:start');
|
|
||||||
|
|
||||||
messagesDeferred.resolve([]);
|
|
||||||
const data = await pending;
|
const data = await pending;
|
||||||
|
|
||||||
expect(data.warnings).toEqual(expect.arrayContaining(['Lead session texts failed to load']));
|
expect(data.warnings ?? []).not.toContain('Lead session texts failed to load');
|
||||||
expect(order).toContain('processes:start');
|
expect(order).toContain('processes:start');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -3977,7 +3984,7 @@ describe('TeamDataService', () => {
|
||||||
expect(page1.hasMore).toBe(true);
|
expect(page1.hasMore).toBe(true);
|
||||||
|
|
||||||
const page2 = await service.getMessagesPage('my-team', {
|
const page2 = await service.getMessagesPage('my-team', {
|
||||||
beforeTimestamp: page1.nextCursor!,
|
cursor: page1.nextCursor!,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
});
|
});
|
||||||
// Should get the remaining 2 messages, not lose the one with same timestamp
|
// Should get the remaining 2 messages, not lose the one with same timestamp
|
||||||
|
|
@ -4011,6 +4018,41 @@ describe('TeamDataService', () => {
|
||||||
expect(result?.messageKind).toBe('slash_command_result');
|
expect(result?.messageKind).toBe('slash_command_result');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes stable effective message ids before pagination and cursoring', async () => {
|
||||||
|
const msgs = [
|
||||||
|
{
|
||||||
|
from: 'alice',
|
||||||
|
text: 'same-ts-a',
|
||||||
|
timestamp: '2026-01-01T00:00:02.000Z',
|
||||||
|
source: 'inbox' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'bob',
|
||||||
|
text: 'same-ts-b',
|
||||||
|
timestamp: '2026-01-01T00:00:02.000Z',
|
||||||
|
source: 'inbox' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'carol',
|
||||||
|
text: 'older',
|
||||||
|
timestamp: '2026-01-01T00:00:01.000Z',
|
||||||
|
source: 'inbox' as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const service = createPaginationService(msgs);
|
||||||
|
|
||||||
|
const page1 = await service.getMessagesPage('my-team', { limit: 1 });
|
||||||
|
const page2 = await service.getMessagesPage('my-team', {
|
||||||
|
cursor: page1.nextCursor!,
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(page1.messages[0]?.messageId).toMatch(/^inbox-/);
|
||||||
|
expect(page1.nextCursor).toContain(page1.messages[0]!.messageId!);
|
||||||
|
expect(page2.messages.every((message) => Boolean(message.messageId))).toBe(true);
|
||||||
|
expect(new Set([...page1.messages, ...page2.messages].map((message) => message.messageId)).size).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
it('dedups newest-page live overlay against durable lead thoughts that already paged off the first page', async () => {
|
it('dedups newest-page live overlay against durable lead thoughts that already paged off the first page', async () => {
|
||||||
const fillerMessages = Array.from({ length: 55 }, (_, index) => ({
|
const fillerMessages = Array.from({ length: 55 }, (_, index) => ({
|
||||||
from: 'alice',
|
from: 'alice',
|
||||||
|
|
@ -4089,7 +4131,7 @@ describe('TeamDataService', () => {
|
||||||
|
|
||||||
const page2 = await service.getMessagesPage('my-team', {
|
const page2 = await service.getMessagesPage('my-team', {
|
||||||
limit: 10,
|
limit: 10,
|
||||||
beforeTimestamp: page1.nextCursor!,
|
cursor: page1.nextCursor!,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(page2.messages.map((message) => message.messageId)).toEqual(['durable-2', 'durable-1']);
|
expect(page2.messages.map((message) => message.messageId)).toEqual(['durable-2', 'durable-1']);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest';
|
||||||
import { TeamMemberResolver } from '../../../../src/main/services/team/TeamMemberResolver';
|
import { TeamMemberResolver } from '../../../../src/main/services/team/TeamMemberResolver';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
InboxMessage,
|
|
||||||
TeamConfig,
|
TeamConfig,
|
||||||
TeamTask,
|
TeamTask,
|
||||||
TeamTaskWithKanban,
|
TeamTaskWithKanban,
|
||||||
|
|
@ -24,13 +23,8 @@ describe('TeamMemberResolver', () => {
|
||||||
{ id: '1', subject: 'Visible task', status: 'pending', owner: 'alice' },
|
{ id: '1', subject: 'Visible task', status: 'pending', owner: 'alice' },
|
||||||
{ id: '2', subject: 'Ghost task', status: 'pending', owner: 'stranger' },
|
{ id: '2', subject: 'Ghost task', status: 'pending', owner: 'stranger' },
|
||||||
];
|
];
|
||||||
const now = new Date().toISOString();
|
|
||||||
const messages: InboxMessage[] = [
|
|
||||||
{ from: 'bob', text: 'ready', timestamp: now, read: false, color: 'green' },
|
|
||||||
{ from: 'user', text: 'system note', timestamp: now, read: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks, messages);
|
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks);
|
||||||
const names = members.map((member) => member.name);
|
const names = members.map((member) => member.name);
|
||||||
|
|
||||||
expect(names).toHaveLength(3);
|
expect(names).toHaveLength(3);
|
||||||
|
|
@ -62,9 +56,8 @@ describe('TeamMemberResolver', () => {
|
||||||
];
|
];
|
||||||
const inboxNames = ['user', 'alice'];
|
const inboxNames = ['user', 'alice'];
|
||||||
const tasks: TeamTask[] = [];
|
const tasks: TeamTask[] = [];
|
||||||
const messages: InboxMessage[] = [];
|
|
||||||
|
|
||||||
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks, messages);
|
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks);
|
||||||
const names = members.map((m) => m.name);
|
const names = members.map((m) => m.name);
|
||||||
|
|
||||||
expect(names).not.toContain('user');
|
expect(names).not.toContain('user');
|
||||||
|
|
@ -81,9 +74,8 @@ describe('TeamMemberResolver', () => {
|
||||||
const metaMembers: TeamConfig['members'] = [{ name: 'alice', agentType: 'general-purpose' }];
|
const metaMembers: TeamConfig['members'] = [{ name: 'alice', agentType: 'general-purpose' }];
|
||||||
const inboxNames = ['alice', 'team-best.user', 'dream-team.team-lead'];
|
const inboxNames = ['alice', 'team-best.user', 'dream-team.team-lead'];
|
||||||
const tasks: TeamTask[] = [];
|
const tasks: TeamTask[] = [];
|
||||||
const messages: InboxMessage[] = [];
|
|
||||||
|
|
||||||
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks, messages);
|
const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks);
|
||||||
const names = members.map((m) => m.name);
|
const names = members.map((m) => m.name);
|
||||||
|
|
||||||
expect(names).toContain('alice');
|
expect(names).toContain('alice');
|
||||||
|
|
@ -104,7 +96,7 @@ describe('TeamMemberResolver', () => {
|
||||||
];
|
];
|
||||||
const inboxNames = ['a3975f80d37fbcea1', 'alice', 'a68a8f6a643e59bfd'];
|
const inboxNames = ['a3975f80d37fbcea1', 'alice', 'a68a8f6a643e59bfd'];
|
||||||
|
|
||||||
const members = resolver.resolveMembers(config, metaMembers, inboxNames, [], []);
|
const members = resolver.resolveMembers(config, metaMembers, inboxNames, []);
|
||||||
const names = members.map((m) => m.name);
|
const names = members.map((m) => m.name);
|
||||||
|
|
||||||
expect(names).toContain('alice');
|
expect(names).toContain('alice');
|
||||||
|
|
@ -124,7 +116,7 @@ describe('TeamMemberResolver', () => {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const members = resolver.resolveMembers(config, [], ['ops.bot'], [], []);
|
const members = resolver.resolveMembers(config, [], ['ops.bot'], []);
|
||||||
const names = members.map((m) => m.name);
|
const names = members.map((m) => m.name);
|
||||||
|
|
||||||
expect(names).toContain('ops.bot');
|
expect(names).toContain('ops.bot');
|
||||||
|
|
@ -141,7 +133,6 @@ describe('TeamMemberResolver', () => {
|
||||||
config,
|
config,
|
||||||
[],
|
[],
|
||||||
['cross-team:team-alpha-super', 'cross-team-team-alpha-super', 'alice'],
|
['cross-team:team-alpha-super', 'cross-team-team-alpha-super', 'alice'],
|
||||||
[],
|
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
const names = members.map((m) => m.name);
|
const names = members.map((m) => m.name);
|
||||||
|
|
@ -163,7 +154,6 @@ describe('TeamMemberResolver', () => {
|
||||||
config,
|
config,
|
||||||
[],
|
[],
|
||||||
['cross_team_send', 'cross_team_list_targets', 'alice'],
|
['cross_team_send', 'cross_team_list_targets', 'alice'],
|
||||||
[],
|
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
const names = members.map((m) => m.name);
|
const names = members.map((m) => m.name);
|
||||||
|
|
@ -185,7 +175,6 @@ describe('TeamMemberResolver', () => {
|
||||||
config,
|
config,
|
||||||
[],
|
[],
|
||||||
['cross_team::team-alpha-super', 'cross_team--team-alpha-super', 'alice'],
|
['cross_team::team-alpha-super', 'cross_team--team-alpha-super', 'alice'],
|
||||||
[],
|
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
const names = members.map((m) => m.name);
|
const names = members.map((m) => m.name);
|
||||||
|
|
@ -206,7 +195,7 @@ describe('TeamMemberResolver', () => {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const members = resolver.resolveMembers(config, [], ['ops.bot'], [], []);
|
const members = resolver.resolveMembers(config, [], ['ops.bot'], []);
|
||||||
const names = members.map((m) => m.name);
|
const names = members.map((m) => m.name);
|
||||||
|
|
||||||
expect(names).toContain('Ops.Bot');
|
expect(names).toContain('Ops.Bot');
|
||||||
|
|
@ -222,7 +211,7 @@ describe('TeamMemberResolver', () => {
|
||||||
const tasks: TeamTaskWithKanban[] = [
|
const tasks: TeamTaskWithKanban[] = [
|
||||||
{ id: 't1', subject: 'Work', status: 'in_progress', owner: 'bob' },
|
{ id: 't1', subject: 'Work', status: 'in_progress', owner: 'bob' },
|
||||||
];
|
];
|
||||||
const members = resolver.resolveMembers(config, [], [], tasks, []);
|
const members = resolver.resolveMembers(config, [], [], tasks);
|
||||||
const bob = members.find((m) => m.name === 'bob');
|
const bob = members.find((m) => m.name === 'bob');
|
||||||
expect(bob?.currentTaskId).toBe('t1');
|
expect(bob?.currentTaskId).toBe('t1');
|
||||||
});
|
});
|
||||||
|
|
@ -243,7 +232,7 @@ describe('TeamMemberResolver', () => {
|
||||||
kanbanColumn: 'approved',
|
kanbanColumn: 'approved',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const members = resolver.resolveMembers(config, [], [], tasks, []);
|
const members = resolver.resolveMembers(config, [], [], tasks);
|
||||||
const bob = members.find((m) => m.name === 'bob');
|
const bob = members.find((m) => m.name === 'bob');
|
||||||
expect(bob?.currentTaskId).toBeNull();
|
expect(bob?.currentTaskId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
@ -264,7 +253,7 @@ describe('TeamMemberResolver', () => {
|
||||||
// kanbanColumn not set — stale data scenario
|
// kanbanColumn not set — stale data scenario
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const members = resolver.resolveMembers(config, [], [], tasks, []);
|
const members = resolver.resolveMembers(config, [], [], tasks);
|
||||||
const bob = members.find((m) => m.name === 'bob');
|
const bob = members.find((m) => m.name === 'bob');
|
||||||
expect(bob?.currentTaskId).toBeNull();
|
expect(bob?.currentTaskId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
@ -281,7 +270,7 @@ describe('TeamMemberResolver', () => {
|
||||||
// Teammates sometimes send messages to "lead" instead of "team-lead",
|
// Teammates sometimes send messages to "lead" instead of "team-lead",
|
||||||
// creating a separate inbox file that the resolver picks up.
|
// creating a separate inbox file that the resolver picks up.
|
||||||
const inboxNames = ['team-lead', 'lead', 'alice'];
|
const inboxNames = ['team-lead', 'lead', 'alice'];
|
||||||
const members = resolver.resolveMembers(config, [], inboxNames, [], []);
|
const members = resolver.resolveMembers(config, [], inboxNames, []);
|
||||||
const names = members.map((m) => m.name);
|
const names = members.map((m) => m.name);
|
||||||
|
|
||||||
expect(names).toContain('team-lead');
|
expect(names).toContain('team-lead');
|
||||||
|
|
@ -295,7 +284,7 @@ describe('TeamMemberResolver', () => {
|
||||||
name: 'Team',
|
name: 'Team',
|
||||||
members: [{ name: 'lead', agentType: 'team-lead', role: 'lead' }],
|
members: [{ name: 'lead', agentType: 'team-lead', role: 'lead' }],
|
||||||
};
|
};
|
||||||
const members = resolver.resolveMembers(config, [], ['lead'], [], []);
|
const members = resolver.resolveMembers(config, [], ['lead'], []);
|
||||||
const names = members.map((m) => m.name);
|
const names = members.map((m) => m.name);
|
||||||
|
|
||||||
expect(names).toContain('lead');
|
expect(names).toContain('lead');
|
||||||
|
|
@ -310,7 +299,7 @@ describe('TeamMemberResolver', () => {
|
||||||
const tasks: TeamTaskWithKanban[] = [
|
const tasks: TeamTaskWithKanban[] = [
|
||||||
{ id: 't1', subject: 'Work', status: 'completed', owner: 'bob' },
|
{ id: 't1', subject: 'Work', status: 'completed', owner: 'bob' },
|
||||||
];
|
];
|
||||||
const members = resolver.resolveMembers(config, [], [], tasks, []);
|
const members = resolver.resolveMembers(config, [], [], tasks);
|
||||||
const bob = members.find((m) => m.name === 'bob');
|
const bob = members.find((m) => m.name === 'bob');
|
||||||
expect(bob?.currentTaskId).toBeNull();
|
expect(bob?.currentTaskId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,65 @@ function writeLaunchState(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createMemberSpawnStatusEntry(
|
||||||
|
overrides: Record<string, unknown> = {}
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
status: 'waiting',
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
error: undefined,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
runtimeAlive: false,
|
||||||
|
livenessSource: undefined,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: false,
|
||||||
|
agentToolAccepted: true,
|
||||||
|
firstSpawnAcceptedAt: new Date().toISOString(),
|
||||||
|
lastHeartbeatAt: undefined,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMemberSpawnRun(params?: {
|
||||||
|
runId?: string;
|
||||||
|
teamName?: string;
|
||||||
|
startedAt?: string;
|
||||||
|
expectedMembers?: string[];
|
||||||
|
memberSpawnStatuses?: Map<string, Record<string, unknown>>;
|
||||||
|
memberSpawnLeadInboxCursorByMember?: Map<string, { timestamp: string; messageId: string }>;
|
||||||
|
}) {
|
||||||
|
const teamName = params?.teamName ?? 'member-spawn-team';
|
||||||
|
const expectedMembers = params?.expectedMembers ?? ['alice'];
|
||||||
|
const memberSpawnStatuses =
|
||||||
|
params?.memberSpawnStatuses ??
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
expectedMembers[0]!,
|
||||||
|
createMemberSpawnStatusEntry({
|
||||||
|
firstSpawnAcceptedAt: new Date(Date.now() - 5_000).toISOString(),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
runId: params?.runId ?? 'run-member-spawn-1',
|
||||||
|
teamName,
|
||||||
|
startedAt: params?.startedAt ?? new Date(Date.now() - 60_000).toISOString(),
|
||||||
|
request: {
|
||||||
|
members: [],
|
||||||
|
},
|
||||||
|
expectedMembers,
|
||||||
|
memberSpawnStatuses,
|
||||||
|
memberSpawnToolUseIds: new Map(),
|
||||||
|
memberSpawnLeadInboxCursorByMember:
|
||||||
|
params?.memberSpawnLeadInboxCursorByMember ?? new Map(),
|
||||||
|
provisioningOutputParts: [],
|
||||||
|
activeToolCalls: new Map(),
|
||||||
|
isLaunch: false,
|
||||||
|
provisioningComplete: false,
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
describe('TeamProvisioningService', () => {
|
describe('TeamProvisioningService', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
@ -1181,4 +1240,311 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(result.statuses.jack?.hardFailureReason).toContain('requested model is not available');
|
expect(result.statuses.jack?.hardFailureReason).toContain('requested model is not available');
|
||||||
expect(result.teamLaunchState).toBe('partial_failure');
|
expect(result.teamLaunchState).toBe('partial_failure');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not reprocess already-seen teammate lead inbox messages', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
startedAt: '2026-04-16T09:00:00.000Z',
|
||||||
|
memberSpawnLeadInboxCursorByMember: new Map([
|
||||||
|
[
|
||||||
|
'alice',
|
||||||
|
{
|
||||||
|
timestamp: '2026-04-16T10:00:00.000Z',
|
||||||
|
messageId: 'msg-2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([
|
||||||
|
{
|
||||||
|
from: 'alice',
|
||||||
|
text: 'heartbeat',
|
||||||
|
timestamp: '2026-04-16T10:00:00.000Z',
|
||||||
|
messageId: 'msg-1',
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'alice',
|
||||||
|
text: 'heartbeat',
|
||||||
|
timestamp: '2026-04-16T10:00:00.000Z',
|
||||||
|
messageId: 'msg-2',
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const applySignalSpy = vi.spyOn(svc as any, 'applyLeadInboxSpawnSignal');
|
||||||
|
|
||||||
|
await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run);
|
||||||
|
|
||||||
|
expect(applySignalSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('processes an unseen teammate heartbeat on the first refresh', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
startedAt: '2026-04-16T09:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([
|
||||||
|
{
|
||||||
|
from: 'alice',
|
||||||
|
text: '{"type":"heartbeat","timestamp":"2026-04-16T10:00:00.000Z"}',
|
||||||
|
timestamp: '2026-04-16T10:00:00.000Z',
|
||||||
|
messageId: 'msg-1',
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run);
|
||||||
|
|
||||||
|
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
hardFailure: false,
|
||||||
|
lastHeartbeatAt: '2026-04-16T10:00:00.000Z',
|
||||||
|
});
|
||||||
|
expect(run.memberSpawnLeadInboxCursorByMember.get('alice')).toEqual({
|
||||||
|
timestamp: '2026-04-16T10:00:00.000Z',
|
||||||
|
messageId: 'msg-1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores teammate lead inbox signals that predate the current run', async () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
startedAt: '2026-04-16T10:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([
|
||||||
|
{
|
||||||
|
from: 'alice',
|
||||||
|
text: '{"type":"heartbeat","timestamp":"2026-04-16T09:59:59.000Z"}',
|
||||||
|
timestamp: '2026-04-16T09:59:59.000Z',
|
||||||
|
messageId: 'msg-early',
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const applySignalSpy = vi.spyOn(svc as any, 'applyLeadInboxSpawnSignal');
|
||||||
|
|
||||||
|
await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run);
|
||||||
|
|
||||||
|
expect(applySignalSpy).not.toHaveBeenCalled();
|
||||||
|
expect(run.memberSpawnLeadInboxCursorByMember.size).toBe(0);
|
||||||
|
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
|
||||||
|
status: 'waiting',
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores an unseen older lead inbox signal without replaying older state', async () => {
|
||||||
|
const latestHeartbeatAt = '2026-04-16T10:05:00.000Z';
|
||||||
|
const existingEntry = createMemberSpawnStatusEntry({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
runtimeAlive: true,
|
||||||
|
livenessSource: 'heartbeat',
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
lastHeartbeatAt: latestHeartbeatAt,
|
||||||
|
});
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
startedAt: '2026-04-16T09:00:00.000Z',
|
||||||
|
memberSpawnStatuses: new Map([['alice', existingEntry]]),
|
||||||
|
memberSpawnLeadInboxCursorByMember: new Map([
|
||||||
|
[
|
||||||
|
'alice',
|
||||||
|
{
|
||||||
|
timestamp: latestHeartbeatAt,
|
||||||
|
messageId: 'msg-3',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
|
||||||
|
vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([
|
||||||
|
{
|
||||||
|
from: 'alice',
|
||||||
|
text: 'Bootstrap failed: unsupported model',
|
||||||
|
timestamp: '2026-04-16T10:04:00.000Z',
|
||||||
|
messageId: 'msg-2b',
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'alice',
|
||||||
|
text: 'heartbeat',
|
||||||
|
timestamp: latestHeartbeatAt,
|
||||||
|
messageId: 'msg-3',
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const applySignalSpy = vi.spyOn(svc as any, 'applyLeadInboxSpawnSignal');
|
||||||
|
|
||||||
|
await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run);
|
||||||
|
|
||||||
|
expect(applySignalSpy).not.toHaveBeenCalled();
|
||||||
|
expect(run.memberSpawnStatuses.get('alice')).toBe(existingEntry);
|
||||||
|
expect(run.memberSpawnLeadInboxCursorByMember.get('alice')).toEqual({
|
||||||
|
timestamp: latestHeartbeatAt,
|
||||||
|
messageId: 'msg-3',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies an unseen newer failure signal and transitions the member to failed_to_start', async () => {
|
||||||
|
const latestHeartbeatAt = '2026-04-16T10:00:00.000Z';
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
startedAt: '2026-04-16T09:00:00.000Z',
|
||||||
|
memberSpawnStatuses: new Map([
|
||||||
|
[
|
||||||
|
'alice',
|
||||||
|
createMemberSpawnStatusEntry({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
runtimeAlive: true,
|
||||||
|
livenessSource: 'heartbeat',
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
lastHeartbeatAt: latestHeartbeatAt,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
memberSpawnLeadInboxCursorByMember: new Map([
|
||||||
|
[
|
||||||
|
'alice',
|
||||||
|
{
|
||||||
|
timestamp: latestHeartbeatAt,
|
||||||
|
messageId: 'msg-1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
|
||||||
|
vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([
|
||||||
|
{
|
||||||
|
from: 'alice',
|
||||||
|
text: 'Bootstrap failed: unsupported model',
|
||||||
|
timestamp: '2026-04-16T10:01:00.000Z',
|
||||||
|
messageId: 'msg-2',
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run);
|
||||||
|
|
||||||
|
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
|
||||||
|
status: 'error',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
hardFailure: true,
|
||||||
|
hardFailureReason: 'Bootstrap failed: unsupported model',
|
||||||
|
});
|
||||||
|
expect(run.memberSpawnLeadInboxCursorByMember.get('alice')).toEqual({
|
||||||
|
timestamp: '2026-04-16T10:01:00.000Z',
|
||||||
|
messageId: 'msg-2',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies an unseen same-timestamp signal with a greater messageId and advances the cursor', async () => {
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
startedAt: '2026-04-16T09:00:00.000Z',
|
||||||
|
memberSpawnLeadInboxCursorByMember: new Map([
|
||||||
|
[
|
||||||
|
'alice',
|
||||||
|
{
|
||||||
|
timestamp: '2026-04-16T10:00:00.000Z',
|
||||||
|
messageId: 'msg-2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
|
||||||
|
vi.spyOn((svc as any).inboxReader, 'getMessagesFor').mockResolvedValue([
|
||||||
|
{
|
||||||
|
from: 'alice',
|
||||||
|
text: 'heartbeat',
|
||||||
|
timestamp: '2026-04-16T10:00:00.000Z',
|
||||||
|
messageId: 'msg-2',
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'alice',
|
||||||
|
text: 'heartbeat',
|
||||||
|
timestamp: '2026-04-16T10:00:00.000Z',
|
||||||
|
messageId: 'msg-3',
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const applySignalSpy = vi.spyOn(svc as any, 'applyLeadInboxSpawnSignal');
|
||||||
|
|
||||||
|
await (svc as any).refreshMemberSpawnStatusesFromLeadInbox(run);
|
||||||
|
|
||||||
|
expect(applySignalSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(applySignalSpy).toHaveBeenCalledWith(
|
||||||
|
run,
|
||||||
|
'alice',
|
||||||
|
expect.objectContaining({ messageId: 'msg-3' })
|
||||||
|
);
|
||||||
|
expect(run.memberSpawnLeadInboxCursorByMember.get('alice')).toEqual({
|
||||||
|
timestamp: '2026-04-16T10:00:00.000Z',
|
||||||
|
messageId: 'msg-3',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not bump lastHeartbeatAt for an equal heartbeat timestamp', () => {
|
||||||
|
const existingEntry = createMemberSpawnStatusEntry({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
runtimeAlive: true,
|
||||||
|
livenessSource: 'heartbeat',
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
lastHeartbeatAt: '2026-04-16T10:00:00.000Z',
|
||||||
|
});
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
memberSpawnStatuses: new Map([['alice', existingEntry]]),
|
||||||
|
});
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
|
||||||
|
(svc as any).setMemberSpawnStatus(
|
||||||
|
run,
|
||||||
|
'alice',
|
||||||
|
'online',
|
||||||
|
undefined,
|
||||||
|
'heartbeat',
|
||||||
|
'2026-04-16T10:00:00.000Z'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(run.memberSpawnStatuses.get('alice')).toBe(existingEntry);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not bump lastHeartbeatAt for an older heartbeat timestamp', () => {
|
||||||
|
const existingEntry = createMemberSpawnStatusEntry({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
runtimeAlive: true,
|
||||||
|
livenessSource: 'heartbeat',
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
lastHeartbeatAt: '2026-04-16T10:00:00.000Z',
|
||||||
|
});
|
||||||
|
const run = createMemberSpawnRun({
|
||||||
|
memberSpawnStatuses: new Map([['alice', existingEntry]]),
|
||||||
|
});
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
|
||||||
|
(svc as any).setMemberSpawnStatus(
|
||||||
|
run,
|
||||||
|
'alice',
|
||||||
|
'online',
|
||||||
|
undefined,
|
||||||
|
'heartbeat',
|
||||||
|
'2026-04-16T09:59:59.000Z'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(run.memberSpawnStatuses.get('alice')).toBe(existingEntry);
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue