feat: add graph member log previews
This commit is contained in:
parent
fcca3649bf
commit
9505ef8485
35 changed files with 4377 additions and 79 deletions
|
|
@ -68,6 +68,7 @@ export interface UseGraphSimulationResult {
|
|||
} | null;
|
||||
getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null;
|
||||
getActivityWorldRect: (nodeId: string) => StableRect | null;
|
||||
getLogWorldRect: (nodeId: string) => StableRect | null;
|
||||
getExtraWorldBounds: () => WorldBounds[];
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +87,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
const dragOwnerPositionsRef = useRef(new Map<string, { x: number; y: number }>());
|
||||
const launchAnchorPositionsRef = useRef(new Map<string, { x: number; y: number }>());
|
||||
const activityRectByNodeIdRef = useRef(new Map<string, StableRect>());
|
||||
const logRectByNodeIdRef = useRef(new Map<string, StableRect>());
|
||||
const extraWorldBoundsRef = useRef<WorldBounds[]>([]);
|
||||
|
||||
const prevNodeIdsRef = useRef(new Set<string>());
|
||||
|
|
@ -112,6 +114,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
dragOwnerPositionsRef,
|
||||
launchAnchorPositionsRef,
|
||||
activityRectByNodeIdRef,
|
||||
logRectByNodeIdRef,
|
||||
extraWorldBoundsRef,
|
||||
});
|
||||
return;
|
||||
|
|
@ -132,6 +135,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
dragOwnerPositionsRef,
|
||||
launchAnchorPositionsRef,
|
||||
activityRectByNodeIdRef,
|
||||
logRectByNodeIdRef,
|
||||
extraWorldBoundsRef,
|
||||
fillMissingFallbackPositions: true,
|
||||
});
|
||||
|
|
@ -144,6 +148,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
layoutSnapshotRef,
|
||||
launchAnchorPositionsRef,
|
||||
activityRectByNodeIdRef,
|
||||
logRectByNodeIdRef,
|
||||
extraWorldBoundsRef,
|
||||
});
|
||||
}, []);
|
||||
|
|
@ -264,6 +269,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
dragOwnerPositionsRef.current.clear();
|
||||
launchAnchorPositionsRef.current.clear();
|
||||
activityRectByNodeIdRef.current.clear();
|
||||
logRectByNodeIdRef.current.clear();
|
||||
extraWorldBoundsRef.current = [];
|
||||
layoutSnapshotRef.current = null;
|
||||
lastValidSnapshotByTeamRef.current.clear();
|
||||
|
|
@ -283,6 +289,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
|
||||
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
|
||||
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
|
||||
getLogWorldRect: (nodeId: string) => logRectByNodeIdRef.current.get(nodeId) ?? null,
|
||||
getExtraWorldBounds: () => extraWorldBoundsRef.current,
|
||||
}),
|
||||
[
|
||||
|
|
@ -352,6 +359,7 @@ function commitSnapshotGeometry(args: {
|
|||
dragOwnerPositionsRef: { current: ReadonlyMap<string, { x: number; y: number }> };
|
||||
launchAnchorPositionsRef: { current: Map<string, { x: number; y: number }> };
|
||||
activityRectByNodeIdRef: { current: Map<string, StableRect> };
|
||||
logRectByNodeIdRef: { current: Map<string, StableRect> };
|
||||
extraWorldBoundsRef: { current: WorldBounds[] };
|
||||
fillMissingFallbackPositions?: boolean;
|
||||
}): void {
|
||||
|
|
@ -364,6 +372,7 @@ function commitSnapshotGeometry(args: {
|
|||
dragOwnerPositionsRef,
|
||||
launchAnchorPositionsRef,
|
||||
activityRectByNodeIdRef,
|
||||
logRectByNodeIdRef,
|
||||
extraWorldBoundsRef,
|
||||
fillMissingFallbackPositions = false,
|
||||
} = args;
|
||||
|
|
@ -377,10 +386,12 @@ function commitSnapshotGeometry(args: {
|
|||
|
||||
launchAnchorPositionsRef.current.clear();
|
||||
activityRectByNodeIdRef.current.clear();
|
||||
logRectByNodeIdRef.current.clear();
|
||||
extraWorldBoundsRef.current = snapshotToWorldBounds(snapshot);
|
||||
|
||||
for (const frame of getTranslatedMemberFrames(snapshot, dragOwnerPositionsRef.current)) {
|
||||
activityRectByNodeIdRef.current.set(frame.ownerId, frame.activityColumnRect);
|
||||
logRectByNodeIdRef.current.set(frame.ownerId, frame.logColumnRect);
|
||||
}
|
||||
|
||||
if (snapshot.leadNodeId) {
|
||||
|
|
@ -388,6 +399,7 @@ function commitSnapshotGeometry(args: {
|
|||
snapshot.leadNodeId,
|
||||
snapshot.leadSlotFrame.activityColumnRect
|
||||
);
|
||||
logRectByNodeIdRef.current.set(snapshot.leadNodeId, snapshot.leadSlotFrame.logColumnRect);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -396,6 +408,7 @@ function resetToFallbackLayout(args: {
|
|||
layoutSnapshotRef: { current: StableSlotLayoutSnapshot | null };
|
||||
launchAnchorPositionsRef: { current: Map<string, { x: number; y: number }> };
|
||||
activityRectByNodeIdRef: { current: Map<string, StableRect> };
|
||||
logRectByNodeIdRef: { current: Map<string, StableRect> };
|
||||
extraWorldBoundsRef: { current: WorldBounds[] };
|
||||
}): void {
|
||||
const {
|
||||
|
|
@ -403,12 +416,14 @@ function resetToFallbackLayout(args: {
|
|||
layoutSnapshotRef,
|
||||
launchAnchorPositionsRef,
|
||||
activityRectByNodeIdRef,
|
||||
logRectByNodeIdRef,
|
||||
extraWorldBoundsRef,
|
||||
} = args;
|
||||
|
||||
layoutSnapshotRef.current = null;
|
||||
launchAnchorPositionsRef.current.clear();
|
||||
activityRectByNodeIdRef.current.clear();
|
||||
logRectByNodeIdRef.current.clear();
|
||||
extraWorldBoundsRef.current = [];
|
||||
fallbackPositionNodes(nodes);
|
||||
KanbanLayoutEngine.layout(nodes);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export interface OwnerFootprint {
|
|||
radialDepth: number;
|
||||
activityColumnWidth: number;
|
||||
activityColumnHeight: number;
|
||||
logColumnWidth: number;
|
||||
logColumnHeight: number;
|
||||
processBandWidth: number;
|
||||
kanbanBandWidth: number;
|
||||
kanbanBandHeight: number;
|
||||
|
|
@ -42,6 +44,7 @@ export interface SlotFrame {
|
|||
ownerY: number;
|
||||
boardBandRect: StableRect;
|
||||
activityColumnRect: StableRect;
|
||||
logColumnRect: StableRect;
|
||||
processBandRect: StableRect;
|
||||
kanbanBandRect: StableRect;
|
||||
taskColumnCount: number;
|
||||
|
|
@ -108,6 +111,11 @@ const SLOT_GEOMETRY = {
|
|||
ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight +
|
||||
ACTIVITY_LANE.overflowHeight,
|
||||
activityColumnWidth: ACTIVITY_LANE.width,
|
||||
logColumnHeight:
|
||||
ACTIVITY_LANE.headerHeight +
|
||||
ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight +
|
||||
ACTIVITY_LANE.overflowHeight,
|
||||
logColumnWidth: 260,
|
||||
ownerToProcessGap: STABLE_SLOT_GEOMETRY.slotVerticalGap,
|
||||
processToBoardGap: STABLE_SLOT_GEOMETRY.slotVerticalGap,
|
||||
boardColumnGap: 24,
|
||||
|
|
@ -231,6 +239,7 @@ function buildCentralCollisionRects(args: {
|
|||
args.leadCoreRect,
|
||||
args.leadSlotFrame.processBandRect,
|
||||
args.leadSlotFrame.activityColumnRect,
|
||||
args.leadSlotFrame.logColumnRect,
|
||||
args.leadSlotFrame.kanbanBandRect,
|
||||
];
|
||||
if (args.unassignedTaskRect) {
|
||||
|
|
@ -247,6 +256,7 @@ function buildLeadCentralReservedBlock(args: {
|
|||
args.leadCoreRect,
|
||||
args.leadSlotFrame.processBandRect,
|
||||
args.leadSlotFrame.activityColumnRect,
|
||||
args.leadSlotFrame.logColumnRect,
|
||||
args.leadSlotFrame.kanbanBandRect,
|
||||
]);
|
||||
}
|
||||
|
|
@ -270,6 +280,7 @@ export function computeOwnerFootprints(
|
|||
): OwnerFootprint[] {
|
||||
const ownerNodes = nodes.filter((node) => node.kind === 'member');
|
||||
const showActivity = layout?.showActivity ?? true;
|
||||
const showLogs = layout?.showLogs ?? showActivity;
|
||||
const ownerNodeById = new Map(ownerNodes.map((node) => [node.id, node] as const));
|
||||
const taskColumnsByOwnerId = new Map<string, Set<string>>();
|
||||
const processCountByOwnerId = new Map<string, number>();
|
||||
|
|
@ -304,6 +315,7 @@ export function computeOwnerFootprints(
|
|||
taskColumnCount: taskColumnsByOwnerId.get(ownerId)?.size ?? 0,
|
||||
processCount: processCountByOwnerId.get(ownerId) ?? 0,
|
||||
showActivity,
|
||||
showLogs,
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
|
@ -331,6 +343,7 @@ function computeOwnerFootprintForOwnerId(
|
|||
taskColumnCount: taskColumns.size,
|
||||
processCount,
|
||||
showActivity: layout?.showActivity ?? true,
|
||||
showLogs: layout?.showLogs ?? layout?.showActivity ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -339,17 +352,28 @@ function buildOwnerFootprint(args: {
|
|||
taskColumnCount: number;
|
||||
processCount: number;
|
||||
showActivity: boolean;
|
||||
showLogs: boolean;
|
||||
}): OwnerFootprint {
|
||||
const activityColumnWidth = args.showActivity ? SLOT_GEOMETRY.activityColumnWidth : 0;
|
||||
const activityColumnHeight = args.showActivity ? SLOT_GEOMETRY.activityColumnHeight : 0;
|
||||
const activityToKanbanGap = args.showActivity ? SLOT_GEOMETRY.boardColumnGap : 0;
|
||||
const logColumnWidth = args.showLogs ? SLOT_GEOMETRY.logColumnWidth : 0;
|
||||
const logColumnHeight = args.showLogs ? SLOT_GEOMETRY.logColumnHeight : 0;
|
||||
const activityToLogGap =
|
||||
activityColumnWidth > 0 && logColumnWidth > 0 ? SLOT_GEOMETRY.boardColumnGap : 0;
|
||||
const feedToKanbanGap =
|
||||
activityColumnWidth > 0 || logColumnWidth > 0 ? SLOT_GEOMETRY.boardColumnGap : 0;
|
||||
const kanbanBandWidth =
|
||||
args.taskColumnCount <= 1
|
||||
? TASK_PILL.width
|
||||
: TASK_PILL.width + (args.taskColumnCount - 1) * KANBAN_ZONE.columnWidth;
|
||||
const processBandWidth = computeProcessBandWidth(args.processCount);
|
||||
const boardBandWidth = activityColumnWidth + activityToKanbanGap + kanbanBandWidth;
|
||||
const boardBandHeight = Math.max(activityColumnHeight, SLOT_GEOMETRY.kanbanBandHeight);
|
||||
const boardBandWidth =
|
||||
activityColumnWidth + activityToLogGap + logColumnWidth + feedToKanbanGap + kanbanBandWidth;
|
||||
const boardBandHeight = Math.max(
|
||||
activityColumnHeight,
|
||||
logColumnHeight,
|
||||
SLOT_GEOMETRY.kanbanBandHeight
|
||||
);
|
||||
const innerContentWidth = Math.max(SLOT_GEOMETRY.ownerMinWidth, processBandWidth, boardBandWidth);
|
||||
const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2;
|
||||
const slotHeight =
|
||||
|
|
@ -377,6 +401,8 @@ function buildOwnerFootprint(args: {
|
|||
radialDepth,
|
||||
activityColumnWidth,
|
||||
activityColumnHeight,
|
||||
logColumnWidth,
|
||||
logColumnHeight,
|
||||
processBandWidth,
|
||||
kanbanBandWidth,
|
||||
kanbanBandHeight: SLOT_GEOMETRY.kanbanBandHeight,
|
||||
|
|
@ -651,6 +677,7 @@ function validateStaticSnapshotRects(
|
|||
['leadSlotFrame.bounds', snapshot.leadSlotFrame.bounds],
|
||||
['leadSlotFrame.boardBandRect', snapshot.leadSlotFrame.boardBandRect],
|
||||
['leadSlotFrame.activityColumnRect', snapshot.leadSlotFrame.activityColumnRect],
|
||||
['leadSlotFrame.logColumnRect', snapshot.leadSlotFrame.logColumnRect],
|
||||
['leadSlotFrame.processBandRect', snapshot.leadSlotFrame.processBandRect],
|
||||
['leadSlotFrame.kanbanBandRect', snapshot.leadSlotFrame.kanbanBandRect],
|
||||
['leadActivityRect', snapshot.leadActivityRect],
|
||||
|
|
@ -697,6 +724,9 @@ function validateLeadSnapshotRects(
|
|||
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) {
|
||||
return { valid: false, reason: 'leadActivityRect must fit inside leadCentralReservedBlock' };
|
||||
}
|
||||
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.logColumnRect)) {
|
||||
return { valid: false, reason: 'lead logColumnRect must fit inside leadCentralReservedBlock' };
|
||||
}
|
||||
if (
|
||||
!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.processBandRect)
|
||||
) {
|
||||
|
|
@ -795,6 +825,9 @@ function validateSlotFrameGeometry(
|
|||
if (!rectContainsRect(frame.bounds, frame.activityColumnRect)) {
|
||||
return { valid: false, reason: `activityColumnRect escapes ${label}` };
|
||||
}
|
||||
if (!rectContainsRect(frame.bounds, frame.logColumnRect)) {
|
||||
return { valid: false, reason: `logColumnRect escapes ${label}` };
|
||||
}
|
||||
if (!rectContainsRect(frame.bounds, frame.processBandRect)) {
|
||||
return { valid: false, reason: `processBandRect escapes ${label}` };
|
||||
}
|
||||
|
|
@ -807,6 +840,12 @@ function validateSlotFrameGeometry(
|
|||
reason: `activityColumnRect escapes boardBandRect in ${label}`,
|
||||
};
|
||||
}
|
||||
if (!rectContainsRect(frame.boardBandRect, frame.logColumnRect)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `logColumnRect escapes boardBandRect in ${label}`,
|
||||
};
|
||||
}
|
||||
if (!rectContainsRect(frame.boardBandRect, frame.kanbanBandRect)) {
|
||||
return {
|
||||
valid: false,
|
||||
|
|
@ -819,6 +858,18 @@ function validateSlotFrameGeometry(
|
|||
reason: `activityColumnRect overlaps kanbanBandRect in ${label}`,
|
||||
};
|
||||
}
|
||||
if (rectsOverlap(frame.activityColumnRect, frame.logColumnRect)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `activityColumnRect overlaps logColumnRect in ${label}`,
|
||||
};
|
||||
}
|
||||
if (rectsOverlap(frame.logColumnRect, frame.kanbanBandRect)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `logColumnRect overlaps kanbanBandRect in ${label}`,
|
||||
};
|
||||
}
|
||||
if (!pointInRect(frame.ownerX, frame.ownerY, frame.bounds)) {
|
||||
return { valid: false, reason: `owner anchor escapes ${label}` };
|
||||
}
|
||||
|
|
@ -853,6 +904,7 @@ export function translateSlotFrame(frame: SlotFrame, dx: number, dy: number): Sl
|
|||
ownerY: frame.ownerY + dy,
|
||||
boardBandRect: translateRect(frame.boardBandRect, dx, dy),
|
||||
activityColumnRect: translateRect(frame.activityColumnRect, dx, dy),
|
||||
logColumnRect: translateRect(frame.logColumnRect, dx, dy),
|
||||
processBandRect: translateRect(frame.processBandRect, dx, dy),
|
||||
kanbanBandRect: translateRect(frame.kanbanBandRect, dx, dy),
|
||||
};
|
||||
|
|
@ -1296,9 +1348,22 @@ function buildSlotFrameAtOwnerAnchor(
|
|||
footprint.activityColumnWidth,
|
||||
footprint.activityColumnHeight
|
||||
);
|
||||
const activityToKanbanGap = footprint.activityColumnWidth > 0 ? SLOT_GEOMETRY.boardColumnGap : 0;
|
||||
const activityToLogGap =
|
||||
footprint.activityColumnWidth > 0 && footprint.logColumnWidth > 0
|
||||
? SLOT_GEOMETRY.boardColumnGap
|
||||
: 0;
|
||||
const logColumnRect = createRect(
|
||||
activityColumnRect.right + activityToLogGap,
|
||||
boardBandRect.top,
|
||||
footprint.logColumnWidth,
|
||||
footprint.logColumnHeight
|
||||
);
|
||||
const feedToKanbanGap =
|
||||
footprint.activityColumnWidth > 0 || footprint.logColumnWidth > 0
|
||||
? SLOT_GEOMETRY.boardColumnGap
|
||||
: 0;
|
||||
const kanbanBandRect = createRect(
|
||||
activityColumnRect.right + activityToKanbanGap,
|
||||
logColumnRect.right + feedToKanbanGap,
|
||||
boardBandRect.top,
|
||||
footprint.kanbanBandWidth,
|
||||
footprint.kanbanBandHeight
|
||||
|
|
@ -1314,6 +1379,7 @@ function buildSlotFrameAtOwnerAnchor(
|
|||
ownerY,
|
||||
boardBandRect,
|
||||
activityColumnRect,
|
||||
logColumnRect,
|
||||
processBandRect,
|
||||
kanbanBandRect,
|
||||
taskColumnCount: footprint.taskColumnCount,
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export interface GraphLayoutPort {
|
|||
version: GraphLayoutVersion;
|
||||
mode?: GraphLayoutMode;
|
||||
showActivity?: boolean;
|
||||
showLogs?: boolean;
|
||||
ownerOrder: string[];
|
||||
slotAssignments: Record<string, GraphOwnerSlotAssignment>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ export interface GraphViewProps {
|
|||
leadNodeId: string
|
||||
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
||||
getActivityWorldRect: (ownerNodeId: string) => StableRect | null;
|
||||
getLogWorldRect: (ownerNodeId: string) => StableRect | null;
|
||||
getTransientHandoffSnapshot: (options?: {
|
||||
focusNodeIds?: ReadonlySet<string> | null;
|
||||
focusEdgeIds?: ReadonlySet<string> | null;
|
||||
|
|
@ -137,6 +138,7 @@ export function GraphView({
|
|||
? {
|
||||
...data.layout,
|
||||
showActivity: filters.showActivity,
|
||||
showLogs: filters.showActivity,
|
||||
}
|
||||
: data.layout,
|
||||
[data.layout, filters.showActivity]
|
||||
|
|
@ -295,6 +297,10 @@ export function GraphView({
|
|||
(ownerNodeId: string) => simulationRef.current.getActivityWorldRect(ownerNodeId),
|
||||
[]
|
||||
);
|
||||
const getLogWorldRect = useCallback(
|
||||
(ownerNodeId: string) => simulationRef.current.getLogWorldRect(ownerNodeId),
|
||||
[]
|
||||
);
|
||||
const getTransientHandoffSnapshot = useCallback(
|
||||
(options?: {
|
||||
focusNodeIds?: ReadonlySet<string> | null;
|
||||
|
|
@ -1092,6 +1098,7 @@ export function GraphView({
|
|||
filters,
|
||||
getLaunchAnchorScreenPlacement,
|
||||
getActivityWorldRect,
|
||||
getLogWorldRect,
|
||||
getTransientHandoffSnapshot,
|
||||
getCameraZoom,
|
||||
worldToScreen: camera.worldToScreen,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,362 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
type MemberLogPreviewMember,
|
||||
type MemberLogPreviewRequestOptions,
|
||||
normalizeMemberLogPreviewResponse,
|
||||
} from '@features/member-log-stream/contracts';
|
||||
import { api } from '@renderer/api';
|
||||
|
||||
import type { ResolvedTeamMember, TeamChangeEvent } from '@shared/types/team';
|
||||
|
||||
const LIVE_RELOAD_DEBOUNCE_MS = 650;
|
||||
const PREVIEW_CACHE_TTL_MS = 3_500;
|
||||
const DEFAULT_MAX_ITEMS = 3;
|
||||
const DEFAULT_TEXT_LIMIT = 200;
|
||||
|
||||
function normalizeMemberName(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function buildRequestKey(input: {
|
||||
teamName: string;
|
||||
memberNames: readonly string[];
|
||||
laneIdsByMember: Readonly<Record<string, string>>;
|
||||
maxItemsPerMember: number;
|
||||
textLimit: number;
|
||||
forceRefresh?: boolean;
|
||||
}): string {
|
||||
const laneEntries = Object.entries(input.laneIdsByMember)
|
||||
.map(([memberName, laneId]) => [normalizeMemberName(memberName), laneId.trim()] as const)
|
||||
.filter(([, laneId]) => laneId.length > 0)
|
||||
.sort((left, right) => left[0].localeCompare(right[0]));
|
||||
return JSON.stringify([
|
||||
input.teamName,
|
||||
input.memberNames.map(normalizeMemberName),
|
||||
laneEntries,
|
||||
input.maxItemsPerMember,
|
||||
input.textLimit,
|
||||
input.forceRefresh === true,
|
||||
]);
|
||||
}
|
||||
|
||||
function memberMapFromResponse(
|
||||
members: readonly MemberLogPreviewMember[]
|
||||
): Map<string, MemberLogPreviewMember> {
|
||||
return new Map(members.map((member) => [normalizeMemberName(member.memberName), member]));
|
||||
}
|
||||
|
||||
function mergeMemberPreviews(
|
||||
base: Map<string, MemberLogPreviewMember>,
|
||||
members: Iterable<MemberLogPreviewMember>
|
||||
): Map<string, MemberLogPreviewMember> {
|
||||
const next = new Map(base);
|
||||
for (const member of members) {
|
||||
next.set(normalizeMemberName(member.memberName), member);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function laneIdForMember(
|
||||
memberName: string,
|
||||
laneIdsByMember: Readonly<Record<string, string>>
|
||||
): string {
|
||||
return (
|
||||
laneIdsByMember[memberName]?.trim() ??
|
||||
laneIdsByMember[normalizeMemberName(memberName)]?.trim() ??
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function buildMemberCacheKey(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
laneIdsByMember: Readonly<Record<string, string>>;
|
||||
maxItemsPerMember: number;
|
||||
textLimit: number;
|
||||
}): string {
|
||||
return JSON.stringify([
|
||||
input.teamName,
|
||||
normalizeMemberName(input.memberName),
|
||||
laneIdForMember(input.memberName, input.laneIdsByMember),
|
||||
input.maxItemsPerMember,
|
||||
input.textLimit,
|
||||
]);
|
||||
}
|
||||
|
||||
export function getSafeGraphLogPreviewLaneId(
|
||||
member: ResolvedTeamMember | undefined
|
||||
): string | undefined {
|
||||
if (!member) return undefined;
|
||||
if (member.providerId !== 'opencode') return undefined;
|
||||
if (member.laneOwnerProviderId !== 'opencode') return undefined;
|
||||
const laneId = member.laneId?.trim();
|
||||
return laneId ? laneId : undefined;
|
||||
}
|
||||
|
||||
export function buildGraphLogPreviewLaneIdsByMember(
|
||||
members: readonly ResolvedTeamMember[]
|
||||
): Record<string, string> {
|
||||
const laneIdsByMember: Record<string, string> = {};
|
||||
for (const member of members) {
|
||||
const laneId = getSafeGraphLogPreviewLaneId(member);
|
||||
if (!laneId) continue;
|
||||
laneIdsByMember[member.name] = laneId;
|
||||
laneIdsByMember[normalizeMemberName(member.name)] = laneId;
|
||||
}
|
||||
return laneIdsByMember;
|
||||
}
|
||||
|
||||
export function useGraphMemberLogPreviews(input: {
|
||||
teamName: string;
|
||||
memberNames: readonly string[];
|
||||
laneIdsByMember?: Readonly<Record<string, string>>;
|
||||
enabled?: boolean;
|
||||
maxItemsPerMember?: number;
|
||||
textLimit?: number;
|
||||
}): {
|
||||
previewsByMember: Map<string, MemberLogPreviewMember>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
reload: (options?: { forceRefresh?: boolean; background?: boolean }) => Promise<void>;
|
||||
} {
|
||||
const enabled = input.enabled ?? true;
|
||||
const maxItemsPerMember = Math.max(
|
||||
1,
|
||||
Math.min(3, Math.floor(input.maxItemsPerMember ?? DEFAULT_MAX_ITEMS))
|
||||
);
|
||||
const textLimit = Math.max(80, Math.min(240, Math.floor(input.textLimit ?? DEFAULT_TEXT_LIMIT)));
|
||||
const laneIdsByMember = useMemo(
|
||||
() => ({ ...(input.laneIdsByMember ?? {}) }),
|
||||
[input.laneIdsByMember]
|
||||
);
|
||||
const memberNames = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const memberName of input.memberNames) {
|
||||
const trimmed = memberName.trim();
|
||||
if (!trimmed) continue;
|
||||
const key = normalizeMemberName(trimmed);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(trimmed);
|
||||
}
|
||||
return result;
|
||||
}, [input.memberNames]);
|
||||
const memberKey = useMemo(() => memberNames.map(normalizeMemberName).join('|'), [memberNames]);
|
||||
const [previewsByMember, setPreviewsByMember] = useState(
|
||||
new Map<string, MemberLogPreviewMember>()
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const cacheRef = useRef(new Map<string, { expiresAt: number; member: MemberLogPreviewMember }>());
|
||||
const previewsByMemberRef = useRef(previewsByMember);
|
||||
const inFlightRef = useRef(new Map<string, Promise<Map<string, MemberLogPreviewMember>>>());
|
||||
const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const teamNameRef = useRef(input.teamName);
|
||||
|
||||
useEffect(() => {
|
||||
previewsByMemberRef.current = previewsByMember;
|
||||
}, [previewsByMember]);
|
||||
|
||||
useEffect(() => {
|
||||
if (teamNameRef.current !== input.teamName) {
|
||||
teamNameRef.current = input.teamName;
|
||||
cacheRef.current.clear();
|
||||
inFlightRef.current.clear();
|
||||
setPreviewsByMember(new Map());
|
||||
}
|
||||
if (!enabled || memberNames.length === 0) {
|
||||
setLoading(false);
|
||||
}
|
||||
setError(null);
|
||||
}, [enabled, input.teamName, memberKey, memberNames.length]);
|
||||
|
||||
const loadPreviews = useCallback(
|
||||
async (options?: { forceRefresh?: boolean; background?: boolean }): Promise<void> => {
|
||||
if (!enabled || memberNames.length === 0) {
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const membersToRequest: string[] = [];
|
||||
const cachedMembers: MemberLogPreviewMember[] = [];
|
||||
let hasMissingPreview = false;
|
||||
|
||||
for (const memberName of memberNames) {
|
||||
const cacheKey = buildMemberCacheKey({
|
||||
teamName: input.teamName,
|
||||
memberName,
|
||||
laneIdsByMember,
|
||||
maxItemsPerMember,
|
||||
textLimit,
|
||||
});
|
||||
const cached = cacheRef.current.get(cacheKey);
|
||||
if (cached) {
|
||||
cachedMembers.push(cached.member);
|
||||
}
|
||||
if (options?.forceRefresh || !cached || cached.expiresAt <= now) {
|
||||
membersToRequest.push(memberName);
|
||||
}
|
||||
const normalizedMemberName = normalizeMemberName(memberName);
|
||||
if (!cached && !previewsByMemberRef.current.has(normalizedMemberName)) {
|
||||
hasMissingPreview = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (cachedMembers.length > 0) {
|
||||
setPreviewsByMember((current) => mergeMemberPreviews(current, cachedMembers));
|
||||
}
|
||||
|
||||
if (membersToRequest.length === 0) {
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestKey = buildRequestKey({
|
||||
teamName: input.teamName,
|
||||
memberNames: membersToRequest,
|
||||
laneIdsByMember,
|
||||
maxItemsPerMember,
|
||||
textLimit,
|
||||
forceRefresh: options?.forceRefresh,
|
||||
});
|
||||
const requestTeamName = input.teamName;
|
||||
|
||||
if (!options?.background && hasMissingPreview) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
try {
|
||||
let request = inFlightRef.current.get(requestKey);
|
||||
if (!request) {
|
||||
const requestOptions: MemberLogPreviewRequestOptions = {
|
||||
maxItemsPerMember,
|
||||
textLimit,
|
||||
...(Object.keys(laneIdsByMember).length > 0 ? { laneIdsByMember } : {}),
|
||||
...(options?.forceRefresh ? { forceRefresh: true } : {}),
|
||||
};
|
||||
request = api.memberLogStream
|
||||
.getMemberLogPreviews(input.teamName, membersToRequest, requestOptions)
|
||||
.then((response) => {
|
||||
const normalized = normalizeMemberLogPreviewResponse(response);
|
||||
const members = memberMapFromResponse(normalized.members);
|
||||
for (const member of members.values()) {
|
||||
cacheRef.current.set(
|
||||
buildMemberCacheKey({
|
||||
teamName: input.teamName,
|
||||
memberName: member.memberName,
|
||||
laneIdsByMember,
|
||||
maxItemsPerMember,
|
||||
textLimit,
|
||||
}),
|
||||
{
|
||||
expiresAt: Date.now() + PREVIEW_CACHE_TTL_MS,
|
||||
member,
|
||||
}
|
||||
);
|
||||
}
|
||||
return members;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlightRef.current.delete(requestKey);
|
||||
});
|
||||
inFlightRef.current.set(requestKey, request);
|
||||
}
|
||||
|
||||
const members = await request;
|
||||
if (teamNameRef.current !== requestTeamName) {
|
||||
return;
|
||||
}
|
||||
setPreviewsByMember((current) => mergeMemberPreviews(current, members.values()));
|
||||
setError(null);
|
||||
} catch (loadError) {
|
||||
if (teamNameRef.current !== requestTeamName) {
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
loadError instanceof Error ? loadError.message : 'Failed to load graph log previews'
|
||||
);
|
||||
} finally {
|
||||
if (teamNameRef.current === requestTeamName) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[enabled, input.teamName, laneIdsByMember, maxItemsPerMember, memberNames, textLimit]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || memberNames.length === 0) {
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
if (reloadTimerRef.current) {
|
||||
clearTimeout(reloadTimerRef.current);
|
||||
}
|
||||
reloadTimerRef.current = setTimeout(() => {
|
||||
reloadTimerRef.current = null;
|
||||
void loadPreviews();
|
||||
}, LIVE_RELOAD_DEBOUNCE_MS);
|
||||
return () => {
|
||||
if (reloadTimerRef.current) {
|
||||
clearTimeout(reloadTimerRef.current);
|
||||
reloadTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, loadPreviews, memberKey, memberNames.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const scheduleReload = (forceRefresh: boolean): void => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
|
||||
if (memberNames.length === 0) return;
|
||||
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
|
||||
reloadTimerRef.current = setTimeout(() => {
|
||||
reloadTimerRef.current = null;
|
||||
void loadPreviews({ background: true, forceRefresh });
|
||||
}, LIVE_RELOAD_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const unsubscribe = api.teams.onTeamChange?.((_event: unknown, event: TeamChangeEvent) => {
|
||||
if (event.teamName !== input.teamName) return;
|
||||
if (event.type === 'log-source-change') {
|
||||
scheduleReload(true);
|
||||
return;
|
||||
}
|
||||
if (event.type === 'task-log-change') {
|
||||
scheduleReload(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleVisibilityChange = (): void => {
|
||||
if (document.visibilityState === 'visible') scheduleReload(false);
|
||||
};
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (reloadTimerRef.current) {
|
||||
clearTimeout(reloadTimerRef.current);
|
||||
reloadTimerRef.current = null;
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}
|
||||
if (typeof unsubscribe === 'function') unsubscribe();
|
||||
};
|
||||
}, [enabled, input.teamName, loadPreviews, memberNames.length]);
|
||||
|
||||
return { previewsByMember, loading, error, reload: loadPreviews };
|
||||
}
|
||||
|
|
@ -0,0 +1,382 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
Brain,
|
||||
CheckCircle2,
|
||||
MessageSquareText,
|
||||
Terminal,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
|
||||
import {
|
||||
buildGraphLogPreviewLaneIdsByMember,
|
||||
useGraphMemberLogPreviews,
|
||||
} from '../hooks/useGraphMemberLogPreviews';
|
||||
|
||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||
import type {
|
||||
MemberLogPreviewItem,
|
||||
MemberLogPreviewMember,
|
||||
} from '@features/member-log-stream/contracts';
|
||||
import type {
|
||||
MemberActivityFilter,
|
||||
MemberDetailTab,
|
||||
} from '@renderer/components/team/members/memberDetailTypes';
|
||||
|
||||
const LOG_PREVIEW_FALLBACK_WIDTH = 260;
|
||||
const LOG_PREVIEW_FALLBACK_HEIGHT = 292;
|
||||
|
||||
interface StableRectLike {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface GraphMemberLogPreviewHudProps {
|
||||
teamName: string;
|
||||
nodes: GraphNode[];
|
||||
getLogWorldRect?: (ownerNodeId: string) => StableRectLike | null;
|
||||
getCameraZoom?: () => number;
|
||||
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
||||
getViewportSize?: () => { width: number; height: number };
|
||||
focusNodeIds: ReadonlySet<string> | null;
|
||||
enabled?: boolean;
|
||||
onOpenMemberProfile?: (
|
||||
memberName: string,
|
||||
options?: {
|
||||
initialTab?: MemberDetailTab;
|
||||
initialActivityFilter?: MemberActivityFilter;
|
||||
}
|
||||
) => void;
|
||||
}
|
||||
|
||||
function normalizeMemberName(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp: string): string {
|
||||
const parsed = Date.parse(timestamp);
|
||||
if (!Number.isFinite(parsed)) return '';
|
||||
const diffMs = Date.now() - parsed;
|
||||
if (diffMs < 60_000) return 'now';
|
||||
const minutes = Math.floor(diffMs / 60_000);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
|
||||
function itemIcon(item: MemberLogPreviewItem): React.JSX.Element {
|
||||
const className = 'size-3.5 shrink-0';
|
||||
const title = item.title.trim().toLowerCase();
|
||||
if (
|
||||
title === 'send message' ||
|
||||
title === 'message sent' ||
|
||||
title === 'add comment' ||
|
||||
title === 'comment added'
|
||||
) {
|
||||
return <MessageSquareText className={`${className} text-sky-300`} />;
|
||||
}
|
||||
if (item.tone === 'error') {
|
||||
return <AlertCircle className={`${className} text-rose-300`} />;
|
||||
}
|
||||
if (item.kind === 'tool_result') {
|
||||
return <CheckCircle2 className={`${className} text-emerald-300`} />;
|
||||
}
|
||||
if (item.kind === 'tool_use') {
|
||||
return <Terminal className={`${className} text-amber-300`} />;
|
||||
}
|
||||
if (item.kind === 'thinking') {
|
||||
return <Brain className={`${className} text-sky-300`} />;
|
||||
}
|
||||
return <MessageSquareText className={`${className} text-slate-300`} />;
|
||||
}
|
||||
|
||||
function resolveEmptyText(preview: MemberLogPreviewMember | undefined, loading: boolean): string {
|
||||
if (loading && !preview) return 'Loading logs';
|
||||
if (preview?.warnings.some((warning) => warning.code === 'codex_member_wide_not_supported')) {
|
||||
return 'Unsupported provider';
|
||||
}
|
||||
return 'No recent logs';
|
||||
}
|
||||
|
||||
function setShellHidden(shell: HTMLDivElement): void {
|
||||
shell.style.opacity = '0';
|
||||
shell.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
export const GraphMemberLogPreviewHud = ({
|
||||
teamName,
|
||||
nodes,
|
||||
getLogWorldRect = () => null,
|
||||
getCameraZoom = () => 1,
|
||||
worldToScreen,
|
||||
getViewportSize,
|
||||
focusNodeIds,
|
||||
enabled = true,
|
||||
onOpenMemberProfile,
|
||||
}: GraphMemberLogPreviewHudProps): React.JSX.Element | null => {
|
||||
const worldLayerRef = useRef<HTMLDivElement | null>(null);
|
||||
const shellRefs = useRef(new Map<string, HTMLDivElement | null>());
|
||||
const visibleKeyRef = useRef('');
|
||||
const [visibleMemberNames, setVisibleMemberNames] = useState<string[]>([]);
|
||||
const { teamData } = useGraphActivityContext(teamName);
|
||||
const members = teamData?.members ?? [];
|
||||
const laneIdsByMember = useMemo(() => buildGraphLogPreviewLaneIdsByMember(members), [members]);
|
||||
const ownerNodes = useMemo(
|
||||
() =>
|
||||
nodes.filter((node): node is GraphNode & { kind: 'lead' | 'member' } => {
|
||||
return (
|
||||
(node.kind === 'lead' || node.kind === 'member') &&
|
||||
(node.domainRef.kind === 'lead' || node.domainRef.kind === 'member')
|
||||
);
|
||||
}),
|
||||
[nodes]
|
||||
);
|
||||
const { previewsByMember, loading } = useGraphMemberLogPreviews({
|
||||
teamName,
|
||||
memberNames: visibleMemberNames,
|
||||
laneIdsByMember,
|
||||
enabled: enabled && visibleMemberNames.length > 0,
|
||||
maxItemsPerMember: 3,
|
||||
textLimit: 200,
|
||||
});
|
||||
|
||||
const openLogs = useCallback(
|
||||
(memberName: string) => {
|
||||
onOpenMemberProfile?.(memberName, { initialTab: 'logs' });
|
||||
},
|
||||
[onOpenMemberProfile]
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled || ownerNodes.length === 0) {
|
||||
for (const shell of shellRefs.current.values()) {
|
||||
if (shell) setShellHidden(shell);
|
||||
}
|
||||
setVisibleMemberNames([]);
|
||||
visibleKeyRef.current = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let frameId = 0;
|
||||
const updatePositions = (): void => {
|
||||
const worldLayer = worldLayerRef.current;
|
||||
if (worldLayer && worldToScreen) {
|
||||
const origin = worldToScreen(0, 0);
|
||||
const zoom = Math.max(getCameraZoom(), 0.001);
|
||||
worldLayer.style.transform = `translate(${Math.round(origin.x)}px, ${Math.round(origin.y)}px) scale(${zoom.toFixed(3)})`;
|
||||
}
|
||||
|
||||
const visibleNames: string[] = [];
|
||||
for (const node of ownerNodes) {
|
||||
const shell = shellRefs.current.get(node.id);
|
||||
if (!shell) continue;
|
||||
|
||||
const laneRect = getLogWorldRect(node.id);
|
||||
if (!laneRect || !worldToScreen || laneRect.width <= 0 || laneRect.height <= 0) {
|
||||
setShellHidden(shell);
|
||||
continue;
|
||||
}
|
||||
|
||||
const zoom = Math.max(getCameraZoom(), 0.001);
|
||||
const screenTopLeft = worldToScreen(laneRect.left, laneRect.top);
|
||||
const widthScreen = Math.max(1, laneRect.width * zoom);
|
||||
const heightScreen = Math.max(1, laneRect.height * zoom);
|
||||
const viewport = getViewportSize?.();
|
||||
const laneVisible =
|
||||
!viewport ||
|
||||
(screenTopLeft.x + widthScreen > -80 &&
|
||||
screenTopLeft.x < viewport.width + 80 &&
|
||||
screenTopLeft.y + heightScreen > -80 &&
|
||||
screenTopLeft.y < viewport.height + 80);
|
||||
if (!laneVisible) {
|
||||
setShellHidden(shell);
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseOpacity = focusNodeIds && !focusNodeIds.has(node.id) ? 0.25 : 1;
|
||||
shell.style.opacity = String(baseOpacity);
|
||||
shell.style.pointerEvents = 'auto';
|
||||
shell.style.left = `${Math.round(laneRect.left)}px`;
|
||||
shell.style.top = `${Math.round(laneRect.top)}px`;
|
||||
shell.style.width = `${Math.round(laneRect.width)}px`;
|
||||
shell.style.height = `${Math.round(laneRect.height)}px`;
|
||||
if (node.domainRef.kind === 'lead' || node.domainRef.kind === 'member') {
|
||||
visibleNames.push(node.domainRef.memberName);
|
||||
}
|
||||
}
|
||||
|
||||
const nextVisibleKey = visibleNames
|
||||
.map(normalizeMemberName)
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.join('|');
|
||||
if (nextVisibleKey !== visibleKeyRef.current) {
|
||||
visibleKeyRef.current = nextVisibleKey;
|
||||
setVisibleMemberNames(visibleNames);
|
||||
}
|
||||
|
||||
frameId = window.requestAnimationFrame(updatePositions);
|
||||
};
|
||||
|
||||
updatePositions();
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [
|
||||
enabled,
|
||||
focusNodeIds,
|
||||
getCameraZoom,
|
||||
getLogWorldRect,
|
||||
getViewportSize,
|
||||
ownerNodes,
|
||||
worldToScreen,
|
||||
]);
|
||||
|
||||
const forwardWheelToGraph = useCallback((event: WheelEvent, shell: HTMLDivElement) => {
|
||||
const graphRoot = shell.closest('.team-graph-view');
|
||||
const canvas = graphRoot?.querySelector('canvas');
|
||||
if (!(canvas instanceof HTMLCanvasElement)) {
|
||||
return;
|
||||
}
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
canvas.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
deltaX: event.deltaX,
|
||||
deltaY: event.deltaY,
|
||||
deltaMode: event.deltaMode,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
ctrlKey: event.ctrlKey,
|
||||
shiftKey: event.shiftKey,
|
||||
altKey: event.altKey,
|
||||
metaKey: event.metaKey,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
const listeners: { shell: HTMLDivElement; handler: (event: WheelEvent) => void }[] = [];
|
||||
for (const node of ownerNodes) {
|
||||
const shell = shellRefs.current.get(node.id);
|
||||
if (!shell) continue;
|
||||
const handler = (event: WheelEvent): void => forwardWheelToGraph(event, shell);
|
||||
shell.addEventListener('wheel', handler, { passive: false });
|
||||
listeners.push({ shell, handler });
|
||||
}
|
||||
return () => {
|
||||
for (const { shell, handler } of listeners) {
|
||||
shell.removeEventListener('wheel', handler);
|
||||
}
|
||||
};
|
||||
}, [enabled, forwardWheelToGraph, ownerNodes]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(memberName: string, item: MemberLogPreviewItem) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="block h-14 min-h-14 w-full min-w-0 overflow-hidden rounded-md border border-white/10 bg-[rgba(8,14,28,0.52)] px-2.5 py-2 text-left text-[10px] leading-3 text-slate-400 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]"
|
||||
onClick={() => openLogs(memberName)}
|
||||
>
|
||||
<span
|
||||
className="mr-1.5 inline-flex size-4 shrink-0 translate-y-0.5 items-center justify-center rounded bg-white/5 align-middle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{itemIcon(item)}
|
||||
</span>
|
||||
<span className="align-baseline text-[11px] font-medium leading-4 text-slate-200">
|
||||
{item.title}
|
||||
</span>{' '}
|
||||
<span className="align-baseline text-[9px] leading-4 text-slate-500">
|
||||
{formatRelativeTime(item.timestamp)}
|
||||
</span>{' '}
|
||||
<span className="align-baseline">{item.preview || item.sourceLabel || 'Log event'}</span>
|
||||
</button>
|
||||
),
|
||||
[openLogs]
|
||||
);
|
||||
|
||||
if (!enabled || ownerNodes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={worldLayerRef}
|
||||
className="pointer-events-none absolute left-0 top-0 z-[8] origin-top-left select-none"
|
||||
>
|
||||
{ownerNodes.map((node) => {
|
||||
const laneRect = getLogWorldRect(node.id);
|
||||
const laneWidth = laneRect?.width ?? LOG_PREVIEW_FALLBACK_WIDTH;
|
||||
const laneHeight = laneRect?.height ?? LOG_PREVIEW_FALLBACK_HEIGHT;
|
||||
const memberName =
|
||||
node.domainRef.kind === 'lead' || node.domainRef.kind === 'member'
|
||||
? node.domainRef.memberName
|
||||
: node.label;
|
||||
const preview = previewsByMember.get(normalizeMemberName(memberName));
|
||||
const items = preview?.items ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
ref={(element) => {
|
||||
shellRefs.current.set(node.id, element);
|
||||
}}
|
||||
className="pointer-events-auto absolute z-10 origin-top-left select-none opacity-0"
|
||||
style={{
|
||||
width: `${laneWidth}px`,
|
||||
maxWidth: `${laneWidth}px`,
|
||||
height: `${laneHeight}px`,
|
||||
}}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full min-w-0 max-w-full flex-col overflow-hidden">
|
||||
<div className="mb-1 flex h-5 min-h-5 items-center gap-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
|
||||
<Wrench className="size-3 text-slate-500" />
|
||||
Logs
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||
{items.length > 0 ? (
|
||||
items.slice(0, 3).map((item) => renderItem(memberName, item))
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-14 min-h-14 items-center rounded-md border border-dashed border-white/10 bg-[rgba(8,14,28,0.28)] px-3 text-left text-[11px] text-slate-400/60"
|
||||
onClick={() => openLogs(memberName)}
|
||||
>
|
||||
{resolveEmptyText(preview, loading)}
|
||||
</button>
|
||||
)}
|
||||
{preview && preview.overflowCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 min-h-8 w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]"
|
||||
onClick={() => openLogs(memberName)}
|
||||
>
|
||||
+{preview.overflowCount} more
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -15,6 +15,7 @@ import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions'
|
|||
|
||||
import { GraphActivityHud } from './GraphActivityHud';
|
||||
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
|
||||
import { GraphMemberLogPreviewHud } from './GraphMemberLogPreviewHud';
|
||||
import { GraphNodePopover } from './GraphNodePopover';
|
||||
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
||||
import { GraphTransientHandoffHud } from './GraphTransientHandoffHud';
|
||||
|
|
@ -148,6 +149,14 @@ export const TeamGraphOverlay = ({
|
|||
width: number;
|
||||
height: number;
|
||||
} | null;
|
||||
getLogWorldRect?: (ownerNodeId: string) => {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null;
|
||||
getCameraZoom?: () => number;
|
||||
getTransientHandoffSnapshot?: (options?: {
|
||||
focusNodeIds?: ReadonlySet<string> | null;
|
||||
|
|
@ -186,6 +195,17 @@ export const TeamGraphOverlay = ({
|
|||
onOpenTaskDetail={onOpenTaskDetail}
|
||||
onOpenMemberProfile={onOpenMemberProfile}
|
||||
/>
|
||||
<GraphMemberLogPreviewHud
|
||||
teamName={teamName}
|
||||
nodes={graphData.nodes}
|
||||
getLogWorldRect={extraHudProps.getLogWorldRect}
|
||||
getCameraZoom={extraHudProps.getCameraZoom}
|
||||
worldToScreen={extraHudProps.worldToScreen}
|
||||
getViewportSize={getViewportSize}
|
||||
focusNodeIds={focusNodeIds}
|
||||
enabled={filters?.showActivity ?? true}
|
||||
onOpenMemberProfile={onOpenMemberProfile}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions'
|
|||
|
||||
import { GraphActivityHud } from './GraphActivityHud';
|
||||
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
|
||||
import { GraphMemberLogPreviewHud } from './GraphMemberLogPreviewHud';
|
||||
import { GraphNodePopover } from './GraphNodePopover';
|
||||
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
||||
import { GraphTransientHandoffHud } from './GraphTransientHandoffHud';
|
||||
|
|
@ -168,6 +169,14 @@ export const TeamGraphTab = ({
|
|||
width: number;
|
||||
height: number;
|
||||
} | null;
|
||||
getLogWorldRect?: (ownerNodeId: string) => {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null;
|
||||
getCameraZoom?: () => number;
|
||||
getTransientHandoffSnapshot?: (options?: {
|
||||
focusNodeIds?: ReadonlySet<string> | null;
|
||||
|
|
@ -207,6 +216,17 @@ export const TeamGraphTab = ({
|
|||
onOpenTaskDetail={dispatchOpenTask}
|
||||
onOpenMemberProfile={dispatchOpenProfile}
|
||||
/>
|
||||
<GraphMemberLogPreviewHud
|
||||
teamName={teamName}
|
||||
nodes={graphData.nodes}
|
||||
getLogWorldRect={extraHudProps.getLogWorldRect}
|
||||
getCameraZoom={extraHudProps.getCameraZoom}
|
||||
worldToScreen={extraHudProps.worldToScreen}
|
||||
getViewportSize={getViewportSize}
|
||||
focusNodeIds={focusNodeIds}
|
||||
enabled={isActive && (filters?.showActivity ?? true)}
|
||||
onOpenMemberProfile={dispatchOpenProfile}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import type { MemberLogStreamRequestOptions, MemberLogStreamResponse } from './dto';
|
||||
import type {
|
||||
MemberLogPreviewRequestOptions,
|
||||
MemberLogPreviewResponse,
|
||||
MemberLogStreamRequestOptions,
|
||||
MemberLogStreamResponse,
|
||||
} from './dto';
|
||||
|
||||
export interface MemberLogStreamApi {
|
||||
getMemberLogStream(
|
||||
|
|
@ -6,5 +11,10 @@ export interface MemberLogStreamApi {
|
|||
memberName: string,
|
||||
options?: MemberLogStreamRequestOptions
|
||||
): Promise<MemberLogStreamResponse>;
|
||||
getMemberLogPreviews(
|
||||
teamName: string,
|
||||
memberNames: string[],
|
||||
options?: MemberLogPreviewRequestOptions
|
||||
): Promise<MemberLogPreviewResponse>;
|
||||
setMemberLogStreamTracking(teamName: string, enabled: boolean): Promise<void>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export const MEMBER_LOG_STREAM_GET = 'member-log-stream:getMemberLogStream';
|
||||
export const MEMBER_LOG_STREAM_GET_PREVIEWS = 'member-log-stream:getMemberLogPreviews';
|
||||
export const MEMBER_LOG_STREAM_SET_TRACKING = 'member-log-stream:setTracking';
|
||||
|
|
|
|||
|
|
@ -18,6 +18,13 @@ export interface MemberLogStreamRequestOptions {
|
|||
forceRefresh?: boolean;
|
||||
}
|
||||
|
||||
export interface MemberLogPreviewRequestOptions {
|
||||
maxItemsPerMember?: number;
|
||||
textLimit?: number;
|
||||
laneIdsByMember?: Record<string, string>;
|
||||
forceRefresh?: boolean;
|
||||
}
|
||||
|
||||
export interface MemberLogStreamCoverage {
|
||||
provider: MemberLogStreamProvider;
|
||||
status: 'included' | 'partial' | 'skipped';
|
||||
|
|
@ -70,3 +77,36 @@ export interface MemberLogStreamResponse {
|
|||
generatedAt: string;
|
||||
metadata: MemberLogStreamMetadata;
|
||||
}
|
||||
|
||||
export type MemberLogPreviewItemKind = 'text' | 'tool_use' | 'tool_result' | 'thinking';
|
||||
|
||||
export type MemberLogPreviewItemTone = 'neutral' | 'success' | 'warning' | 'error';
|
||||
|
||||
export interface MemberLogPreviewItem {
|
||||
id: string;
|
||||
kind: MemberLogPreviewItemKind;
|
||||
provider: MemberLogStreamProvider;
|
||||
timestamp: string;
|
||||
title: string;
|
||||
preview?: string;
|
||||
tone: MemberLogPreviewItemTone;
|
||||
toolName?: string;
|
||||
sourceLabel?: string;
|
||||
sessionId?: string;
|
||||
laneId?: string;
|
||||
}
|
||||
|
||||
export interface MemberLogPreviewMember {
|
||||
memberName: string;
|
||||
items: MemberLogPreviewItem[];
|
||||
coverage: MemberLogStreamCoverage[];
|
||||
warnings: MemberLogStreamWarning[];
|
||||
truncated: boolean;
|
||||
overflowCount: number;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export interface MemberLogPreviewResponse {
|
||||
members: MemberLogPreviewMember[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import type { MemberLogStreamResponse } from './dto';
|
||||
import type {
|
||||
MemberLogPreviewMember,
|
||||
MemberLogPreviewResponse,
|
||||
MemberLogStreamResponse,
|
||||
} from './dto';
|
||||
|
||||
export function createEmptyMemberLogStreamResponse(
|
||||
generatedAt = new Date().toISOString()
|
||||
|
|
@ -42,3 +46,48 @@ export function normalizeMemberLogStreamResponse(
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyMemberLogPreviewResponse(
|
||||
generatedAt = new Date().toISOString()
|
||||
): MemberLogPreviewResponse {
|
||||
return {
|
||||
members: [],
|
||||
generatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMemberLogPreviewMember(member: MemberLogPreviewMember): MemberLogPreviewMember {
|
||||
return {
|
||||
memberName: typeof member.memberName === 'string' ? member.memberName : '',
|
||||
items: Array.isArray(member.items) ? member.items : [],
|
||||
coverage: Array.isArray(member.coverage) ? member.coverage : [],
|
||||
warnings: Array.isArray(member.warnings) ? member.warnings : [],
|
||||
truncated: member.truncated === true,
|
||||
overflowCount:
|
||||
typeof member.overflowCount === 'number' && Number.isFinite(member.overflowCount)
|
||||
? Math.max(0, Math.floor(member.overflowCount))
|
||||
: 0,
|
||||
generatedAt:
|
||||
typeof member.generatedAt === 'string' && member.generatedAt.length > 0
|
||||
? member.generatedAt
|
||||
: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeMemberLogPreviewResponse(
|
||||
response: MemberLogPreviewResponse | null | undefined
|
||||
): MemberLogPreviewResponse {
|
||||
if (!response) {
|
||||
return createEmptyMemberLogPreviewResponse();
|
||||
}
|
||||
|
||||
return {
|
||||
members: Array.isArray(response.members)
|
||||
? response.members.map(normalizeMemberLogPreviewMember)
|
||||
: [],
|
||||
generatedAt:
|
||||
typeof response.generatedAt === 'string' && response.generatedAt.length > 0
|
||||
? response.generatedAt
|
||||
: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import type {
|
||||
MemberLogPreviewItem,
|
||||
MemberLogStreamCoverage,
|
||||
MemberLogStreamProvider,
|
||||
MemberLogStreamWarning,
|
||||
} from '../../../contracts';
|
||||
import type { MemberLogPreviewBudget } from '../../domain/models/MemberLogPreviewBudget';
|
||||
|
||||
export interface MemberLogPreviewSourceInput {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
laneId?: string;
|
||||
budget: MemberLogPreviewBudget;
|
||||
maxItems: number;
|
||||
textLimit: number;
|
||||
forceRefresh?: boolean;
|
||||
}
|
||||
|
||||
export interface MemberLogPreviewSourceResult {
|
||||
provider: MemberLogStreamProvider;
|
||||
status: MemberLogStreamCoverage['status'];
|
||||
reason?: string;
|
||||
items: MemberLogPreviewItem[];
|
||||
warnings: MemberLogStreamWarning[];
|
||||
truncated: boolean;
|
||||
overflowCount: number;
|
||||
}
|
||||
|
||||
export interface MemberLogPreviewSource {
|
||||
readonly provider: MemberLogStreamProvider;
|
||||
loadPreview(input: MemberLogPreviewSourceInput): Promise<MemberLogPreviewSourceResult>;
|
||||
}
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
import { createEmptyMemberLogPreviewResponse } from '../../../contracts';
|
||||
import {
|
||||
clampMemberLogPreviewItemLimit,
|
||||
clampMemberLogPreviewTextLimit,
|
||||
DEFAULT_MEMBER_LOG_PREVIEW_BUDGET,
|
||||
} from '../../domain/models/MemberLogPreviewBudget';
|
||||
import { buildMemberLogPreviewMember } from '../../domain/policies/memberLogPreviewMergePolicy';
|
||||
|
||||
import type {
|
||||
MemberLogPreviewResponse,
|
||||
MemberLogStreamProvider,
|
||||
MemberLogStreamWarning,
|
||||
} from '../../../contracts';
|
||||
import type { MemberLogPreviewBudget } from '../../domain/models/MemberLogPreviewBudget';
|
||||
import type { ClockPort } from '../ports/ClockPort';
|
||||
import type { LoggerPort } from '../ports/LoggerPort';
|
||||
import type {
|
||||
MemberLogPreviewSource,
|
||||
MemberLogPreviewSourceResult,
|
||||
} from '../ports/MemberLogPreviewSource';
|
||||
|
||||
export interface GetMemberLogPreviewsInput {
|
||||
teamName: string;
|
||||
memberNames: string[];
|
||||
maxItemsPerMember?: number;
|
||||
textLimit?: number;
|
||||
laneIdsByMember?: Record<string, string>;
|
||||
forceRefresh?: boolean;
|
||||
}
|
||||
|
||||
interface GetMemberLogPreviewsUseCaseDeps {
|
||||
sources: readonly MemberLogPreviewSource[];
|
||||
clock: ClockPort;
|
||||
logger: LoggerPort;
|
||||
budget?: Partial<MemberLogPreviewBudget>;
|
||||
}
|
||||
|
||||
interface NormalizedMemberRequest {
|
||||
memberName: string;
|
||||
laneId?: string;
|
||||
}
|
||||
|
||||
function normalizeMemberName(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeMembers(
|
||||
memberNames: readonly string[],
|
||||
laneIdsByMember: Record<string, string> | undefined,
|
||||
maxMembers: number
|
||||
): NormalizedMemberRequest[] {
|
||||
const result: NormalizedMemberRequest[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const memberName of memberNames) {
|
||||
const trimmed = memberName.trim();
|
||||
if (!trimmed) continue;
|
||||
const key = normalizeMemberName(trimmed);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const laneId = laneIdsByMember?.[trimmed] ?? laneIdsByMember?.[key];
|
||||
result.push({
|
||||
memberName: trimmed,
|
||||
...(laneId ? { laneId } : {}),
|
||||
});
|
||||
if (result.length >= maxMembers) break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function stableInputKey(input: {
|
||||
teamName: string;
|
||||
members: readonly NormalizedMemberRequest[];
|
||||
maxItems: number;
|
||||
textLimit: number;
|
||||
forceRefresh?: boolean;
|
||||
}): string {
|
||||
return JSON.stringify([
|
||||
input.teamName,
|
||||
input.members.map((member) => [normalizeMemberName(member.memberName), member.laneId ?? '']),
|
||||
input.maxItems,
|
||||
input.textLimit,
|
||||
input.forceRefresh === true,
|
||||
]);
|
||||
}
|
||||
|
||||
function warningForSourceFailure(
|
||||
provider: MemberLogStreamProvider,
|
||||
message: string
|
||||
): MemberLogStreamWarning {
|
||||
return {
|
||||
code:
|
||||
provider === 'opencode_runtime'
|
||||
? 'opencode_runtime_unavailable'
|
||||
: 'unreadable_transcript_file',
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
export class GetMemberLogPreviewsUseCase {
|
||||
private readonly budget: MemberLogPreviewBudget;
|
||||
private readonly inFlight = new Map<string, Promise<MemberLogPreviewResponse>>();
|
||||
|
||||
constructor(private readonly deps: GetMemberLogPreviewsUseCaseDeps) {
|
||||
this.budget = { ...DEFAULT_MEMBER_LOG_PREVIEW_BUDGET, ...(deps.budget ?? {}) };
|
||||
}
|
||||
|
||||
async execute(input: GetMemberLogPreviewsInput): Promise<MemberLogPreviewResponse> {
|
||||
const maxItems = clampMemberLogPreviewItemLimit(input.maxItemsPerMember, this.budget);
|
||||
const textLimit = clampMemberLogPreviewTextLimit(input.textLimit, this.budget);
|
||||
const members = normalizeMembers(
|
||||
input.memberNames,
|
||||
input.laneIdsByMember,
|
||||
this.budget.maxMembers
|
||||
);
|
||||
if (members.length === 0) {
|
||||
return createEmptyMemberLogPreviewResponse(new Date(this.deps.clock.now()).toISOString());
|
||||
}
|
||||
|
||||
const key = stableInputKey({
|
||||
teamName: input.teamName,
|
||||
members,
|
||||
maxItems,
|
||||
textLimit,
|
||||
forceRefresh: input.forceRefresh,
|
||||
});
|
||||
const existing = this.inFlight.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const promise = this.buildResponse({
|
||||
input,
|
||||
members,
|
||||
maxItems,
|
||||
textLimit,
|
||||
}).finally(() => {
|
||||
this.inFlight.delete(key);
|
||||
});
|
||||
this.inFlight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
private async buildResponse(args: {
|
||||
input: GetMemberLogPreviewsInput;
|
||||
members: readonly NormalizedMemberRequest[];
|
||||
maxItems: number;
|
||||
textLimit: number;
|
||||
}): Promise<MemberLogPreviewResponse> {
|
||||
const generatedAt = new Date(this.deps.clock.now()).toISOString();
|
||||
if (this.deps.sources.length === 0) {
|
||||
return createEmptyMemberLogPreviewResponse(generatedAt);
|
||||
}
|
||||
|
||||
const members = await Promise.all(
|
||||
args.members.map(async (member) => {
|
||||
const sourceResults = await Promise.all(
|
||||
this.deps.sources.map((source): Promise<MemberLogPreviewSourceResult> => {
|
||||
return source
|
||||
.loadPreview({
|
||||
teamName: args.input.teamName,
|
||||
memberName: member.memberName,
|
||||
laneId: member.laneId,
|
||||
budget: this.budget,
|
||||
maxItems: args.maxItems,
|
||||
textLimit: args.textLimit,
|
||||
forceRefresh: args.input.forceRefresh,
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.deps.logger.warn(
|
||||
`Member log preview source ${source.provider} failed for ${args.input.teamName}/${member.memberName}: ${message}`
|
||||
);
|
||||
return {
|
||||
provider: source.provider,
|
||||
status: 'skipped',
|
||||
reason: message,
|
||||
items: [],
|
||||
warnings: [warningForSourceFailure(source.provider, message)],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
};
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return buildMemberLogPreviewMember({
|
||||
memberName: member.memberName,
|
||||
sourceResults: sourceResults.map((result) => ({
|
||||
coverage: {
|
||||
provider: result.provider,
|
||||
status: result.status,
|
||||
...(result.reason ? { reason: result.reason } : {}),
|
||||
},
|
||||
items: result.items,
|
||||
warnings: result.warnings,
|
||||
truncated: result.truncated,
|
||||
overflowCount: result.overflowCount,
|
||||
})),
|
||||
generatedAt,
|
||||
maxItems: args.maxItems,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
members,
|
||||
generatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { GetMemberLogPreviewsUseCase } from '../GetMemberLogPreviewsUseCase';
|
||||
|
||||
import type {
|
||||
MemberLogPreviewSource,
|
||||
MemberLogPreviewSourceInput,
|
||||
} from '../../ports/MemberLogPreviewSource';
|
||||
|
||||
function source(
|
||||
provider: MemberLogPreviewSource['provider'],
|
||||
loadPreview: (
|
||||
input: MemberLogPreviewSourceInput
|
||||
) => ReturnType<MemberLogPreviewSource['loadPreview']>
|
||||
): MemberLogPreviewSource {
|
||||
return { provider, loadPreview };
|
||||
}
|
||||
|
||||
describe('GetMemberLogPreviewsUseCase', () => {
|
||||
it('dedupes members, clamps options, and merges source coverage per member', async () => {
|
||||
const loadPreview = vi.fn(async (input: MemberLogPreviewSourceInput) => ({
|
||||
provider: 'claude_transcript' as const,
|
||||
status: 'included' as const,
|
||||
items: [
|
||||
{
|
||||
id: `item:${input.memberName}`,
|
||||
kind: 'text' as const,
|
||||
provider: 'claude_transcript' as const,
|
||||
timestamp: '2026-04-01T12:00:00.000Z',
|
||||
title: 'Assistant',
|
||||
preview: input.memberName,
|
||||
tone: 'neutral' as const,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
}));
|
||||
const useCase = new GetMemberLogPreviewsUseCase({
|
||||
sources: [source('claude_transcript', loadPreview)],
|
||||
clock: { now: () => Date.parse('2026-04-01T12:01:00.000Z') },
|
||||
logger: { warn: vi.fn(), error: vi.fn() },
|
||||
});
|
||||
|
||||
const response = await useCase.execute({
|
||||
teamName: 'alpha-team',
|
||||
memberNames: ['alice', 'Alice', 'bob'],
|
||||
maxItemsPerMember: 99,
|
||||
textLimit: 999,
|
||||
laneIdsByMember: { alice: 'secondary:opencode:alice' },
|
||||
});
|
||||
|
||||
expect(response.members.map((member) => member.memberName)).toEqual(['alice', 'bob']);
|
||||
expect(loadPreview).toHaveBeenCalledTimes(2);
|
||||
expect(loadPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
memberName: 'alice',
|
||||
maxItems: 3,
|
||||
textLimit: 200,
|
||||
laneId: 'secondary:opencode:alice',
|
||||
})
|
||||
);
|
||||
expect(response.members[0]?.coverage).toEqual([
|
||||
{ provider: 'claude_transcript', status: 'included' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('dedupes in-flight identical batch requests', async () => {
|
||||
const loadPreview = vi.fn(async (_input: MemberLogPreviewSourceInput) => ({
|
||||
provider: 'codex_native_trace' as const,
|
||||
status: 'skipped' as const,
|
||||
reason: 'codex_member_wide_not_supported',
|
||||
items: [],
|
||||
warnings: [
|
||||
{
|
||||
code: 'codex_member_wide_not_supported' as const,
|
||||
message: 'Codex member-wide native trace is not available in this variant yet.',
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
}));
|
||||
const useCase = new GetMemberLogPreviewsUseCase({
|
||||
sources: [source('codex_native_trace', loadPreview)],
|
||||
clock: { now: () => Date.parse('2026-04-01T12:01:00.000Z') },
|
||||
logger: { warn: vi.fn(), error: vi.fn() },
|
||||
});
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
useCase.execute({ teamName: 'alpha-team', memberNames: ['codex'] }),
|
||||
useCase.execute({ teamName: 'alpha-team', memberNames: ['codex'] }),
|
||||
]);
|
||||
|
||||
expect(first).toEqual(second);
|
||||
expect(loadPreview).toHaveBeenCalledTimes(1);
|
||||
expect(first.members[0]?.warnings[0]?.code).toBe('codex_member_wide_not_supported');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
export interface MemberLogPreviewBudget {
|
||||
maxMembers: number;
|
||||
maxItemsPerMember: number;
|
||||
maxTextChars: number;
|
||||
maxTranscriptFiles: number;
|
||||
maxSourceMessagesPerProvider: number;
|
||||
openCodeMessageLimit: number;
|
||||
openCodeTimeoutMs: number;
|
||||
cacheTtlMs: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_MEMBER_LOG_PREVIEW_BUDGET: MemberLogPreviewBudget = {
|
||||
maxMembers: 40,
|
||||
maxItemsPerMember: 3,
|
||||
maxTextChars: 200,
|
||||
maxTranscriptFiles: 8,
|
||||
maxSourceMessagesPerProvider: 120,
|
||||
openCodeMessageLimit: 80,
|
||||
openCodeTimeoutMs: 2_500,
|
||||
cacheTtlMs: 3_000,
|
||||
};
|
||||
|
||||
export function clampMemberLogPreviewItemLimit(
|
||||
requested: number | undefined,
|
||||
budget: MemberLogPreviewBudget = DEFAULT_MEMBER_LOG_PREVIEW_BUDGET
|
||||
): number {
|
||||
if (typeof requested !== 'number' || !Number.isFinite(requested)) {
|
||||
return budget.maxItemsPerMember;
|
||||
}
|
||||
return Math.max(1, Math.min(3, budget.maxItemsPerMember, Math.floor(requested)));
|
||||
}
|
||||
|
||||
export function clampMemberLogPreviewTextLimit(
|
||||
requested: number | undefined,
|
||||
budget: MemberLogPreviewBudget = DEFAULT_MEMBER_LOG_PREVIEW_BUDGET
|
||||
): number {
|
||||
if (typeof requested !== 'number' || !Number.isFinite(requested)) {
|
||||
return budget.maxTextChars;
|
||||
}
|
||||
return Math.max(80, Math.min(240, budget.maxTextChars, Math.floor(requested)));
|
||||
}
|
||||
|
|
@ -0,0 +1,361 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { extractMemberLogPreviewItems } from '../memberLogPreviewExtractor';
|
||||
|
||||
import type { MemberLogPreviewParsedMessage } from '../memberLogPreviewExtractor';
|
||||
|
||||
function message(
|
||||
overrides: Partial<MemberLogPreviewParsedMessage> & {
|
||||
uuid: string;
|
||||
timestamp: string;
|
||||
}
|
||||
): MemberLogPreviewParsedMessage {
|
||||
const { uuid, timestamp, ...rest } = overrides;
|
||||
return {
|
||||
uuid,
|
||||
parentUuid: null,
|
||||
type: 'assistant',
|
||||
role: 'assistant',
|
||||
timestamp: new Date(timestamp),
|
||||
content: '',
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
...rest,
|
||||
} as MemberLogPreviewParsedMessage;
|
||||
}
|
||||
|
||||
describe('memberLogPreviewExtractor', () => {
|
||||
it('extracts bounded assistant text previews newest first', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'claude_transcript',
|
||||
maxItems: 3,
|
||||
textLimit: 120,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'old',
|
||||
timestamp: '2026-04-01T10:00:00.000Z',
|
||||
content: [{ type: 'text', text: 'older answer' }],
|
||||
}),
|
||||
message({
|
||||
uuid: 'new',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: [{ type: 'text', text: '<system-reminder>latest answer</system-reminder>' }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'text',
|
||||
title: 'Assistant',
|
||||
preview: 'latest answer',
|
||||
});
|
||||
expect(result.items[1]?.preview).toBe('older answer');
|
||||
});
|
||||
|
||||
it('extracts tool_use input and tool_result output without rendering huge payloads', () => {
|
||||
const hugeOutput = 'x'.repeat(10_000);
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'opencode_runtime',
|
||||
maxItems: 3,
|
||||
textLimit: 160,
|
||||
sourceId: 'session-1',
|
||||
sourceLabel: 'OpenCode runtime',
|
||||
laneId: 'secondary:opencode:alice',
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'tool-call',
|
||||
timestamp: '2026-04-01T10:00:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu-1',
|
||||
name: 'Bash',
|
||||
input: { command: 'pnpm test -- --runInBand', ignored: hugeOutput },
|
||||
},
|
||||
],
|
||||
}),
|
||||
message({
|
||||
uuid: 'tool-result',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'toolu-1',
|
||||
content: hugeOutput,
|
||||
is_error: true,
|
||||
},
|
||||
],
|
||||
toolResults: [],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Tool error',
|
||||
tone: 'error',
|
||||
laneId: 'secondary:opencode:alice',
|
||||
});
|
||||
expect(result.items[0]?.preview?.length).toBeLessThanOrEqual(160);
|
||||
expect(result.items[1]).toMatchObject({
|
||||
kind: 'tool_use',
|
||||
title: 'Bash',
|
||||
preview: 'pnpm test -- --runInBand',
|
||||
});
|
||||
expect(result.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('formats SendMessage and message_send payloads without raw JSON noise', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'claude_transcript',
|
||||
maxItems: 3,
|
||||
textLimit: 160,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'send-call',
|
||||
timestamp: '2026-04-01T10:00:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool-send',
|
||||
name: 'mcp__agent-teams__message_send',
|
||||
input: {
|
||||
to: 'team-lead',
|
||||
from: 'tom',
|
||||
summary: '#abc done',
|
||||
text: 'Detailed body should stay secondary',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
message({
|
||||
uuid: 'send-result',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: '',
|
||||
toolResults: [
|
||||
{
|
||||
toolUseId: 'tool-send',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
deliveredToInbox: true,
|
||||
message: {
|
||||
from: 'tom',
|
||||
to: 'team-lead',
|
||||
text: 'Detailed body',
|
||||
summary: '#abc done',
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Message sent',
|
||||
preview: 'Message sent to team-lead - #abc done',
|
||||
});
|
||||
expect(result.items[1]).toMatchObject({
|
||||
kind: 'tool_use',
|
||||
title: 'Send message',
|
||||
preview: 'to team-lead: #abc done',
|
||||
});
|
||||
expect(JSON.stringify(result.items)).not.toContain('deliveredToInbox');
|
||||
});
|
||||
|
||||
it('formats task comment result payloads without raw JSON noise', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'claude_transcript',
|
||||
maxItems: 3,
|
||||
textLimit: 160,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'comment-result',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-comment',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
taskId: 'task-799',
|
||||
comment: {
|
||||
id: 'comment-1',
|
||||
author: 'tom',
|
||||
text: 'Done with UI review',
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Comment added',
|
||||
preview: 'Comment by tom on #task-799: Done with UI review',
|
||||
});
|
||||
expect(JSON.stringify(result.items)).not.toContain('"comment"');
|
||||
});
|
||||
|
||||
it('formats plain board tool results through the paired tool_use context', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'claude_transcript',
|
||||
maxItems: 3,
|
||||
textLimit: 160,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'complete-call',
|
||||
timestamp: '2026-04-01T10:00:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool-complete',
|
||||
name: 'mcp__agent-teams__task_complete',
|
||||
input: { teamName: 'demo', taskId: 'abc12345', actor: 'tom' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
message({
|
||||
uuid: 'complete-result',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-complete',
|
||||
content: 'ok',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Task completed',
|
||||
preview: 'Completed #abc12345',
|
||||
toolName: 'mcp__agent-teams__task_complete',
|
||||
});
|
||||
expect(result.items[1]).toMatchObject({
|
||||
kind: 'tool_use',
|
||||
title: 'Complete task',
|
||||
preview: '#abc12345',
|
||||
});
|
||||
});
|
||||
|
||||
it('formats wrapped Agent Teams task responses', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'claude_transcript',
|
||||
maxItems: 3,
|
||||
textLimit: 160,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'task-result',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-task-get',
|
||||
content: JSON.stringify({
|
||||
agent_teams_task_get_response: {
|
||||
task: {
|
||||
id: 'abc12345-0000-0000-0000-000000000000',
|
||||
displayId: 'abc12345',
|
||||
title: 'Fix preview alignment',
|
||||
status: 'in_progress',
|
||||
owner: 'tom',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Task loaded',
|
||||
preview: '#abc12345: Fix preview alignment, status in_progress, owner tom',
|
||||
});
|
||||
expect(JSON.stringify(result.items)).not.toContain('agent_teams_task_get_response');
|
||||
});
|
||||
|
||||
it('keeps orphan tool results visible because graph preview is diagnostic', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'claude_transcript',
|
||||
maxItems: 3,
|
||||
textLimit: 120,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'orphan',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: '',
|
||||
toolResults: [
|
||||
{
|
||||
toolUseId: 'missing-call',
|
||||
content: 'orphan result still matters',
|
||||
isError: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Tool result',
|
||||
preview: 'orphan result still matters',
|
||||
tone: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
it('caps preview items at three and reports overflow', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'claude_transcript',
|
||||
maxItems: 3,
|
||||
textLimit: 120,
|
||||
messages: [1, 2, 3, 4].map((index) =>
|
||||
message({
|
||||
uuid: `m-${index}`,
|
||||
timestamp: `2026-04-01T10:0${index}:00.000Z`,
|
||||
content: [{ type: 'text', text: `message ${index}` }],
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.items.map((item) => item.preview)).toEqual([
|
||||
'message 4',
|
||||
'message 3',
|
||||
'message 2',
|
||||
]);
|
||||
expect(result.overflowCount).toBe(1);
|
||||
expect(result.truncated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildMemberLogPreviewMember } from '../memberLogPreviewMergePolicy';
|
||||
|
||||
import type { MemberLogPreviewItem } from '../../../../contracts';
|
||||
|
||||
function item(id: string, timestamp: string): MemberLogPreviewItem {
|
||||
return {
|
||||
id,
|
||||
kind: 'text',
|
||||
provider: 'claude_transcript',
|
||||
timestamp,
|
||||
title: 'Assistant',
|
||||
preview: id,
|
||||
tone: 'neutral',
|
||||
};
|
||||
}
|
||||
|
||||
describe('memberLogPreviewMergePolicy', () => {
|
||||
it('merges sources newest first with stable tie break and max three items', () => {
|
||||
const member = buildMemberLogPreviewMember({
|
||||
memberName: 'alice',
|
||||
generatedAt: '2026-04-01T12:00:00.000Z',
|
||||
maxItems: 3,
|
||||
sourceResults: [
|
||||
{
|
||||
coverage: { provider: 'opencode_runtime', status: 'included' },
|
||||
items: [item('b', '2026-04-01T12:00:00.000Z'), item('a', '2026-04-01T12:00:00.000Z')],
|
||||
warnings: [],
|
||||
},
|
||||
{
|
||||
coverage: { provider: 'claude_transcript', status: 'included' },
|
||||
items: [
|
||||
item('newest', '2026-04-01T12:01:00.000Z'),
|
||||
item('oldest', '2026-04-01T11:59:00.000Z'),
|
||||
],
|
||||
warnings: [{ code: 'large_log_window_limited', message: 'limited' }],
|
||||
overflowCount: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(member.items.map((preview) => preview.id)).toEqual(['newest', 'a', 'b']);
|
||||
expect(member.coverage.map((coverage) => coverage.provider)).toEqual([
|
||||
'claude_transcript',
|
||||
'opencode_runtime',
|
||||
]);
|
||||
expect(member.truncated).toBe(true);
|
||||
expect(member.overflowCount).toBe(2);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,83 @@
|
|||
import { MEMBER_LOG_STREAM_PROVIDER_ORDER } from './memberLogStreamMergePolicy';
|
||||
|
||||
import type {
|
||||
MemberLogPreviewItem,
|
||||
MemberLogPreviewMember,
|
||||
MemberLogStreamCoverage,
|
||||
MemberLogStreamWarning,
|
||||
} from '../../../contracts';
|
||||
|
||||
export interface MemberLogPreviewSourceMergeResult {
|
||||
coverage: MemberLogStreamCoverage;
|
||||
items: readonly MemberLogPreviewItem[];
|
||||
warnings: readonly MemberLogStreamWarning[];
|
||||
truncated?: boolean;
|
||||
overflowCount?: number;
|
||||
}
|
||||
|
||||
function getItemTime(item: MemberLogPreviewItem): number {
|
||||
const parsed = Date.parse(item.timestamp);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function dedupeWarnings(warnings: readonly MemberLogStreamWarning[]): MemberLogStreamWarning[] {
|
||||
const seen = new Set<string>();
|
||||
const result: MemberLogStreamWarning[] = [];
|
||||
for (const warning of warnings) {
|
||||
const key = `${warning.code}:${warning.message}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(warning);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function dedupeItems(items: readonly MemberLogPreviewItem[]): MemberLogPreviewItem[] {
|
||||
const byId = new Map<string, MemberLogPreviewItem>();
|
||||
for (const item of items) {
|
||||
if (!byId.has(item.id)) {
|
||||
byId.set(item.id, item);
|
||||
}
|
||||
}
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
export function buildMemberLogPreviewMember(input: {
|
||||
memberName: string;
|
||||
sourceResults: readonly MemberLogPreviewSourceMergeResult[];
|
||||
generatedAt: string;
|
||||
maxItems: number;
|
||||
}): MemberLogPreviewMember {
|
||||
const maxItems = Math.max(1, Math.min(3, Math.floor(input.maxItems)));
|
||||
const sortedItems = dedupeItems(input.sourceResults.flatMap((result) => [...result.items])).sort(
|
||||
(left, right) => {
|
||||
const byTime = getItemTime(right) - getItemTime(left);
|
||||
return byTime !== 0 ? byTime : left.id.localeCompare(right.id);
|
||||
}
|
||||
);
|
||||
const items = sortedItems.slice(0, maxItems);
|
||||
const sourceOverflow = input.sourceResults.reduce(
|
||||
(sum, result) => sum + Math.max(0, result.overflowCount ?? 0),
|
||||
0
|
||||
);
|
||||
const overCap = Math.max(0, sortedItems.length - items.length);
|
||||
|
||||
return {
|
||||
memberName: input.memberName,
|
||||
items,
|
||||
coverage: input.sourceResults
|
||||
.map((result) => result.coverage)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
MEMBER_LOG_STREAM_PROVIDER_ORDER.indexOf(left.provider) -
|
||||
MEMBER_LOG_STREAM_PROVIDER_ORDER.indexOf(right.provider)
|
||||
),
|
||||
warnings: dedupeWarnings(input.sourceResults.flatMap((result) => [...result.warnings])),
|
||||
truncated:
|
||||
overCap > 0 ||
|
||||
sourceOverflow > 0 ||
|
||||
input.sourceResults.some((result) => result.truncated === true),
|
||||
overflowCount: sourceOverflow + overCap,
|
||||
generatedAt: input.generatedAt,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,12 +1,16 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MEMBER_LOG_STREAM_GET, MEMBER_LOG_STREAM_SET_TRACKING } from '../../../../../contracts';
|
||||
import {
|
||||
MEMBER_LOG_STREAM_GET,
|
||||
MEMBER_LOG_STREAM_GET_PREVIEWS,
|
||||
MEMBER_LOG_STREAM_SET_TRACKING,
|
||||
} from '../../../../../contracts';
|
||||
import {
|
||||
registerMemberLogStreamIpc,
|
||||
removeMemberLogStreamIpc,
|
||||
} from '../registerMemberLogStreamIpc';
|
||||
|
||||
import type { MemberLogStreamResponse } from '../../../../../contracts';
|
||||
import type { MemberLogPreviewResponse, MemberLogStreamResponse } from '../../../../../contracts';
|
||||
import type { MemberLogStreamFeatureFacade } from '../../../../composition/createMemberLogStreamFeature';
|
||||
import type { IpcMainInvokeEvent } from 'electron';
|
||||
|
||||
|
|
@ -39,6 +43,13 @@ function emptyResponse(): MemberLogStreamResponse {
|
|||
};
|
||||
}
|
||||
|
||||
function emptyPreviewResponse(): MemberLogPreviewResponse {
|
||||
return {
|
||||
members: [],
|
||||
generatedAt: '2026-03-01T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeIpcMain(): {
|
||||
handlers: Map<string, (...args: unknown[]) => unknown>;
|
||||
ipcMain: {
|
||||
|
|
@ -66,6 +77,7 @@ describe('registerMemberLogStreamIpc', () => {
|
|||
const getMemberLogStream = vi.fn().mockResolvedValue(emptyResponse());
|
||||
const feature: MemberLogStreamFeatureFacade = {
|
||||
getMemberLogStream,
|
||||
getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()),
|
||||
setMemberLogStreamTracking: vi.fn(),
|
||||
};
|
||||
|
||||
|
|
@ -98,6 +110,7 @@ describe('registerMemberLogStreamIpc', () => {
|
|||
const getMemberLogStream = vi.fn().mockResolvedValue(emptyResponse());
|
||||
const feature: MemberLogStreamFeatureFacade = {
|
||||
getMemberLogStream,
|
||||
getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()),
|
||||
setMemberLogStreamTracking: vi.fn(),
|
||||
};
|
||||
|
||||
|
|
@ -124,6 +137,7 @@ describe('registerMemberLogStreamIpc', () => {
|
|||
const getMemberLogStream = vi.fn().mockResolvedValue(emptyResponse());
|
||||
const feature: MemberLogStreamFeatureFacade = {
|
||||
getMemberLogStream,
|
||||
getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()),
|
||||
setMemberLogStreamTracking: vi.fn(),
|
||||
};
|
||||
|
||||
|
|
@ -172,6 +186,7 @@ describe('registerMemberLogStreamIpc', () => {
|
|||
const setMemberLogStreamTracking = vi.fn().mockResolvedValue(undefined);
|
||||
const feature: MemberLogStreamFeatureFacade = {
|
||||
getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()),
|
||||
getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()),
|
||||
setMemberLogStreamTracking,
|
||||
};
|
||||
|
||||
|
|
@ -190,6 +205,70 @@ describe('registerMemberLogStreamIpc', () => {
|
|||
removeMemberLogStreamIpc(ipcMain as never);
|
||||
|
||||
expect(handlers.has(MEMBER_LOG_STREAM_GET)).toBe(false);
|
||||
expect(handlers.has(MEMBER_LOG_STREAM_GET_PREVIEWS)).toBe(false);
|
||||
expect(handlers.has(MEMBER_LOG_STREAM_SET_TRACKING)).toBe(false);
|
||||
});
|
||||
|
||||
it('validates batch preview requests before calling the feature facade', async () => {
|
||||
const { handlers, ipcMain } = createFakeIpcMain();
|
||||
const getMemberLogPreviews = vi.fn().mockResolvedValue(emptyPreviewResponse());
|
||||
const feature: MemberLogStreamFeatureFacade = {
|
||||
getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()),
|
||||
getMemberLogPreviews,
|
||||
setMemberLogStreamTracking: vi.fn(),
|
||||
};
|
||||
|
||||
registerMemberLogStreamIpc(ipcMain as never, feature);
|
||||
const getPreviews = handlers.get(MEMBER_LOG_STREAM_GET_PREVIEWS)!;
|
||||
|
||||
await expect(
|
||||
getPreviews({} as IpcMainInvokeEvent, 'alpha-team', ['alice', 'bob'], {
|
||||
maxItemsPerMember: 10,
|
||||
textLimit: 999,
|
||||
laneIdsByMember: {
|
||||
alice: ' secondary:opencode:alice ',
|
||||
},
|
||||
forceRefresh: true,
|
||||
})
|
||||
).resolves.toEqual({ success: true, data: emptyPreviewResponse() });
|
||||
expect(getMemberLogPreviews).toHaveBeenCalledWith({
|
||||
teamName: 'alpha-team',
|
||||
memberNames: ['alice', 'bob'],
|
||||
maxItemsPerMember: 3,
|
||||
textLimit: 240,
|
||||
laneIdsByMember: {
|
||||
alice: 'secondary:opencode:alice',
|
||||
},
|
||||
forceRefresh: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unknown batch preview options and unsafe lane maps', async () => {
|
||||
const { handlers, ipcMain } = createFakeIpcMain();
|
||||
const getMemberLogPreviews = vi.fn().mockResolvedValue(emptyPreviewResponse());
|
||||
const feature: MemberLogStreamFeatureFacade = {
|
||||
getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()),
|
||||
getMemberLogPreviews,
|
||||
setMemberLogStreamTracking: vi.fn(),
|
||||
};
|
||||
|
||||
registerMemberLogStreamIpc(ipcMain as never, feature);
|
||||
const getPreviews = handlers.get(MEMBER_LOG_STREAM_GET_PREVIEWS)!;
|
||||
|
||||
await expect(
|
||||
getPreviews({} as IpcMainInvokeEvent, 'alpha-team', ['alice'], { nope: true })
|
||||
).resolves.toEqual({
|
||||
success: false,
|
||||
error: 'Unknown getMemberLogPreviews option: nope',
|
||||
});
|
||||
await expect(
|
||||
getPreviews({} as IpcMainInvokeEvent, 'alpha-team', ['alice'], {
|
||||
laneIdsByMember: { alice: '../bad' },
|
||||
})
|
||||
).resolves.toEqual({
|
||||
success: false,
|
||||
error: 'laneId contains invalid characters',
|
||||
});
|
||||
expect(getMemberLogPreviews).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,17 +3,30 @@ import { createLogger } from '@shared/utils/logger';
|
|||
|
||||
import {
|
||||
MEMBER_LOG_STREAM_GET,
|
||||
MEMBER_LOG_STREAM_GET_PREVIEWS,
|
||||
MEMBER_LOG_STREAM_SET_TRACKING,
|
||||
normalizeMemberLogPreviewResponse,
|
||||
normalizeMemberLogStreamResponse,
|
||||
} from '../../../../contracts';
|
||||
|
||||
import type { MemberLogStreamRequestOptions, MemberLogStreamResponse } from '../../../../contracts';
|
||||
import type {
|
||||
MemberLogPreviewRequestOptions,
|
||||
MemberLogPreviewResponse,
|
||||
MemberLogStreamRequestOptions,
|
||||
MemberLogStreamResponse,
|
||||
} from '../../../../contracts';
|
||||
import type { MemberLogStreamFeatureFacade } from '../../../composition/createMemberLogStreamFeature';
|
||||
import type { IpcResult } from '@shared/types';
|
||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||
|
||||
const logger = createLogger('Feature:MemberLogStream:IPC');
|
||||
const ALLOWED_OPTION_KEYS = new Set(['limitSegments', 'since', 'laneId', 'forceRefresh']);
|
||||
const ALLOWED_PREVIEW_OPTION_KEYS = new Set([
|
||||
'maxItemsPerMember',
|
||||
'textLimit',
|
||||
'laneIdsByMember',
|
||||
'forceRefresh',
|
||||
]);
|
||||
|
||||
interface ValidationResult<T> {
|
||||
valid: boolean;
|
||||
|
|
@ -104,6 +117,106 @@ function normalizeOptions(options: unknown): ValidationResult<{
|
|||
};
|
||||
}
|
||||
|
||||
function validateMemberNames(value: unknown): ValidationResult<string[]> {
|
||||
if (!Array.isArray(value)) {
|
||||
return { valid: false, error: 'memberNames must be an array' };
|
||||
}
|
||||
if (value.length > 80) {
|
||||
return { valid: false, error: 'memberNames exceeds max length (80)' };
|
||||
}
|
||||
const memberNames: string[] = [];
|
||||
for (const item of value) {
|
||||
const vMember = validateMemberName(item);
|
||||
if (!vMember.valid) {
|
||||
return { valid: false, error: vMember.error ?? 'Invalid memberName' };
|
||||
}
|
||||
memberNames.push(vMember.value!);
|
||||
}
|
||||
return { valid: true, value: memberNames };
|
||||
}
|
||||
|
||||
function normalizePreviewOptions(options: unknown): ValidationResult<{
|
||||
maxItemsPerMember?: number;
|
||||
textLimit?: number;
|
||||
laneIdsByMember?: Record<string, string>;
|
||||
forceRefresh?: boolean;
|
||||
}> {
|
||||
if (options == null) {
|
||||
return { valid: true, value: {} };
|
||||
}
|
||||
if (typeof options !== 'object' || Array.isArray(options)) {
|
||||
return { valid: false, error: 'options must be an object' };
|
||||
}
|
||||
|
||||
const record = options as Record<string, unknown>;
|
||||
for (const key of Object.keys(record)) {
|
||||
if (!ALLOWED_PREVIEW_OPTION_KEYS.has(key)) {
|
||||
return { valid: false, error: `Unknown getMemberLogPreviews option: ${key}` };
|
||||
}
|
||||
}
|
||||
|
||||
let maxItemsPerMember: number | undefined;
|
||||
if (record.maxItemsPerMember != null) {
|
||||
if (
|
||||
typeof record.maxItemsPerMember !== 'number' ||
|
||||
!Number.isFinite(record.maxItemsPerMember)
|
||||
) {
|
||||
return { valid: false, error: 'maxItemsPerMember must be a finite number' };
|
||||
}
|
||||
maxItemsPerMember = Math.max(1, Math.min(3, Math.floor(record.maxItemsPerMember)));
|
||||
}
|
||||
|
||||
let textLimit: number | undefined;
|
||||
if (record.textLimit != null) {
|
||||
if (typeof record.textLimit !== 'number' || !Number.isFinite(record.textLimit)) {
|
||||
return { valid: false, error: 'textLimit must be a finite number' };
|
||||
}
|
||||
textLimit = Math.max(80, Math.min(240, Math.floor(record.textLimit)));
|
||||
}
|
||||
|
||||
let laneIdsByMember: Record<string, string> | undefined;
|
||||
if (record.laneIdsByMember != null) {
|
||||
if (typeof record.laneIdsByMember !== 'object' || Array.isArray(record.laneIdsByMember)) {
|
||||
return { valid: false, error: 'laneIdsByMember must be an object' };
|
||||
}
|
||||
laneIdsByMember = {};
|
||||
for (const [memberName, laneId] of Object.entries(
|
||||
record.laneIdsByMember as Record<string, unknown>
|
||||
)) {
|
||||
const vMember = validateMemberName(memberName);
|
||||
if (!vMember.valid) {
|
||||
return { valid: false, error: vMember.error ?? 'Invalid laneIdsByMember key' };
|
||||
}
|
||||
const vLane = validateOptionalRuntimeLaneId(laneId);
|
||||
if (!vLane.valid) {
|
||||
return { valid: false, error: vLane.error ?? 'Invalid laneId' };
|
||||
}
|
||||
if (vLane.value) {
|
||||
laneIdsByMember[vMember.value!] = vLane.value;
|
||||
laneIdsByMember[vMember.value!.toLowerCase()] = vLane.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let forceRefresh: boolean | undefined;
|
||||
if (record.forceRefresh != null) {
|
||||
if (typeof record.forceRefresh !== 'boolean') {
|
||||
return { valid: false, error: 'forceRefresh must be a boolean' };
|
||||
}
|
||||
forceRefresh = record.forceRefresh;
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
value: {
|
||||
...(maxItemsPerMember !== undefined ? { maxItemsPerMember } : {}),
|
||||
...(textLimit !== undefined ? { textLimit } : {}),
|
||||
...(laneIdsByMember !== undefined ? { laneIdsByMember } : {}),
|
||||
...(forceRefresh !== undefined ? { forceRefresh } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function registerMemberLogStreamIpc(
|
||||
ipcMain: IpcMain,
|
||||
feature: MemberLogStreamFeatureFacade
|
||||
|
|
@ -168,9 +281,45 @@ export function registerMemberLogStreamIpc(
|
|||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to update member log stream tracking',
|
||||
error instanceof Error ? error.message : 'Failed to update member log stream tracking',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
MEMBER_LOG_STREAM_GET_PREVIEWS,
|
||||
async (
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
memberNames: unknown,
|
||||
options?: MemberLogPreviewRequestOptions
|
||||
): Promise<IpcResult<MemberLogPreviewResponse>> => {
|
||||
const vTeam = validateTeamName(teamName);
|
||||
if (!vTeam.valid) {
|
||||
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||
}
|
||||
const vMembers = validateMemberNames(memberNames);
|
||||
if (!vMembers.valid) {
|
||||
return { success: false, error: vMembers.error ?? 'Invalid memberNames' };
|
||||
}
|
||||
const vOptions = normalizePreviewOptions(options);
|
||||
if (!vOptions.valid) {
|
||||
return { success: false, error: vOptions.error ?? 'Invalid options' };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await feature.getMemberLogPreviews({
|
||||
teamName: vTeam.value!,
|
||||
memberNames: vMembers.value!,
|
||||
...vOptions.value!,
|
||||
});
|
||||
return { success: true, data: normalizeMemberLogPreviewResponse(response) };
|
||||
} catch (error) {
|
||||
logger.error('Failed to load member log previews', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load member log previews',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -179,5 +328,6 @@ export function registerMemberLogStreamIpc(
|
|||
|
||||
export function removeMemberLogStreamIpc(ipcMain: IpcMain): void {
|
||||
ipcMain.removeHandler(MEMBER_LOG_STREAM_GET);
|
||||
ipcMain.removeHandler(MEMBER_LOG_STREAM_GET_PREVIEWS);
|
||||
ipcMain.removeHandler(MEMBER_LOG_STREAM_SET_TRACKING);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
import { extractMemberLogPreviewItems } from '../../../../core/domain/policies/memberLogPreviewExtractor';
|
||||
|
||||
import { dedupeMemberLogRefs } from './memberLogStreamSourceUtils';
|
||||
|
||||
import type { MemberLogStreamWarning } from '../../../../contracts';
|
||||
import type { LoggerPort } from '../../../../core/application/ports/LoggerPort';
|
||||
import type {
|
||||
MemberLogPreviewSource,
|
||||
MemberLogPreviewSourceInput,
|
||||
MemberLogPreviewSourceResult,
|
||||
} from '../../../../core/application/ports/MemberLogPreviewSource';
|
||||
import type { BoardTaskExactLogStrictParser } from '@main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser';
|
||||
import type { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder';
|
||||
import type { ParsedMessage } from '@main/types';
|
||||
|
||||
function recentMessages(
|
||||
messages: readonly ParsedMessage[],
|
||||
maxMessages: number
|
||||
): { messages: ParsedMessage[]; dropped: number } {
|
||||
if (messages.length <= maxMessages) {
|
||||
return { messages: [...messages], dropped: 0 };
|
||||
}
|
||||
return {
|
||||
messages: messages.slice(-maxMessages),
|
||||
dropped: messages.length - maxMessages,
|
||||
};
|
||||
}
|
||||
|
||||
export class ClaudeMemberTranscriptPreviewSource implements MemberLogPreviewSource {
|
||||
readonly provider = 'claude_transcript' as const;
|
||||
|
||||
constructor(
|
||||
private readonly logsFinder: TeamMemberLogsFinder,
|
||||
private readonly parser: BoardTaskExactLogStrictParser,
|
||||
private readonly logger: LoggerPort
|
||||
) {}
|
||||
|
||||
async loadPreview(input: MemberLogPreviewSourceInput): Promise<MemberLogPreviewSourceResult> {
|
||||
const warnings: MemberLogStreamWarning[] = [];
|
||||
const refs = await this.logsFinder.findRecentMemberLogFileRefsByMember(
|
||||
input.teamName,
|
||||
[input.memberName],
|
||||
{
|
||||
forceRefresh: input.forceRefresh === true,
|
||||
}
|
||||
);
|
||||
const dedupedRefs = dedupeMemberLogRefs(refs);
|
||||
const cappedRefs = dedupedRefs.slice(0, input.budget.maxTranscriptFiles);
|
||||
const droppedRefCount = Math.max(0, dedupedRefs.length - cappedRefs.length);
|
||||
if (droppedRefCount > 0) {
|
||||
warnings.push({
|
||||
code: 'large_log_window_limited',
|
||||
message: `Scanning ${cappedRefs.length} recent transcript files for graph log preview.`,
|
||||
});
|
||||
}
|
||||
|
||||
const parsedByPath = await this.parser.parseFiles(cappedRefs.map((ref) => ref.filePath));
|
||||
const items = [];
|
||||
let droppedMessageCount = 0;
|
||||
let sourceOverflowCount = 0;
|
||||
let sourceTruncated = droppedRefCount > 0;
|
||||
|
||||
for (const ref of cappedRefs) {
|
||||
const parsedMessages = parsedByPath.get(ref.filePath) ?? [];
|
||||
if (parsedMessages.length === 0) continue;
|
||||
|
||||
const limited = recentMessages(parsedMessages, input.budget.maxSourceMessagesPerProvider);
|
||||
droppedMessageCount += limited.dropped;
|
||||
sourceTruncated = sourceTruncated || limited.dropped > 0;
|
||||
|
||||
const extracted = extractMemberLogPreviewItems({
|
||||
messages: limited.messages,
|
||||
provider: this.provider,
|
||||
maxItems: input.maxItems,
|
||||
textLimit: input.textLimit,
|
||||
sourceId: ref.filePath,
|
||||
sourceLabel: ref.kind === 'lead_session' ? 'Claude lead transcript' : 'Claude transcript',
|
||||
sessionId: ref.sessionId,
|
||||
});
|
||||
items.push(...extracted.items);
|
||||
sourceOverflowCount += extracted.overflowCount;
|
||||
sourceTruncated = sourceTruncated || extracted.truncated;
|
||||
}
|
||||
|
||||
if (droppedMessageCount > 0) {
|
||||
warnings.push({
|
||||
code: 'segment_message_window_limited',
|
||||
message: 'Some transcript files were trimmed to recent messages for graph preview.',
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.debug?.(
|
||||
`Claude member log preview ${input.teamName}/${input.memberName}: refs=${refs.length}, items=${items.length}`
|
||||
);
|
||||
|
||||
return {
|
||||
provider: this.provider,
|
||||
status: items.length > 0 ? 'included' : 'skipped',
|
||||
reason: items.length > 0 ? undefined : 'no_member_transcripts',
|
||||
items,
|
||||
warnings,
|
||||
truncated: sourceTruncated,
|
||||
overflowCount: sourceOverflowCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import {
|
|||
buildMemberActor,
|
||||
buildMemberParticipant,
|
||||
buildSegmentId,
|
||||
normalizeMemberName,
|
||||
dedupeMemberLogRefs,
|
||||
shortHash,
|
||||
withSegmentSource,
|
||||
} from './memberLogStreamSourceUtils';
|
||||
|
|
@ -18,55 +18,9 @@ import type {
|
|||
} from '../../../../core/application/ports/MemberLogStreamSource';
|
||||
import type { BoardTaskExactLogChunkBuilder } from '@main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder';
|
||||
import type { BoardTaskExactLogStrictParser } from '@main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser';
|
||||
import type {
|
||||
MemberLogFileRef,
|
||||
TeamMemberLogsFinder,
|
||||
} from '@main/services/team/TeamMemberLogsFinder';
|
||||
import type { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder';
|
||||
import type { ParsedMessage } from '@main/types';
|
||||
|
||||
function isPreferredRef(candidate: MemberLogFileRef, existing: MemberLogFileRef): boolean {
|
||||
const candidateMessageCount = candidate.messageCount ?? -1;
|
||||
const existingMessageCount = existing.messageCount ?? -1;
|
||||
if (candidateMessageCount !== existingMessageCount) {
|
||||
return candidateMessageCount > existingMessageCount;
|
||||
}
|
||||
|
||||
const candidateSize = candidate.sizeBytes ?? -1;
|
||||
const existingSize = existing.sizeBytes ?? -1;
|
||||
if (candidateSize !== existingSize) {
|
||||
return candidateSize > existingSize;
|
||||
}
|
||||
|
||||
return candidate.mtimeMs > existing.mtimeMs;
|
||||
}
|
||||
|
||||
function dedupeMemberLogRefs(refs: readonly MemberLogFileRef[]): MemberLogFileRef[] {
|
||||
const byFilePath = new Map<string, MemberLogFileRef>();
|
||||
const bySession = new Map<string, MemberLogFileRef>();
|
||||
const passthrough: MemberLogFileRef[] = [];
|
||||
|
||||
for (const ref of refs) {
|
||||
if (byFilePath.has(ref.filePath)) continue;
|
||||
byFilePath.set(ref.filePath, ref);
|
||||
|
||||
if (ref.kind === 'lead_session') {
|
||||
passthrough.push(ref);
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${ref.kind ?? 'unknown'}:${normalizeMemberName(ref.memberName)}:${ref.sessionId}`;
|
||||
const existing = bySession.get(key);
|
||||
if (!existing || isPreferredRef(ref, existing)) {
|
||||
bySession.set(key, ref);
|
||||
}
|
||||
}
|
||||
|
||||
return [...passthrough, ...bySession.values()].sort((left, right) => {
|
||||
const byTime = right.mtimeMs - left.mtimeMs;
|
||||
return byTime !== 0 ? byTime : left.filePath.localeCompare(right.filePath);
|
||||
});
|
||||
}
|
||||
|
||||
function filterSourceMessageBudget(
|
||||
messages: readonly ParsedMessage[],
|
||||
remaining: number
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
|
||||
import type {
|
||||
MemberLogPreviewSource,
|
||||
MemberLogPreviewSourceInput,
|
||||
MemberLogPreviewSourceResult,
|
||||
} from '../../../../core/application/ports/MemberLogPreviewSource';
|
||||
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||
|
||||
export class CodexNativeMemberTracePreviewSource implements MemberLogPreviewSource {
|
||||
readonly provider = 'codex_native_trace' as const;
|
||||
|
||||
constructor(private readonly configReader: TeamConfigReader) {}
|
||||
|
||||
async loadPreview(input: MemberLogPreviewSourceInput): Promise<MemberLogPreviewSourceResult> {
|
||||
const config = await this.configReader.getConfig(input.teamName).catch(() => null);
|
||||
const member = config?.members?.find(
|
||||
(item) => item.name.trim().toLowerCase() === input.memberName.trim().toLowerCase()
|
||||
);
|
||||
const isCodexMember =
|
||||
member?.providerId === 'codex' ||
|
||||
member?.providerBackendId === 'codex-native' ||
|
||||
(member ? false : isLeadMember({ name: input.memberName }));
|
||||
|
||||
return {
|
||||
provider: this.provider,
|
||||
status: 'skipped',
|
||||
reason: 'codex_member_wide_not_supported',
|
||||
items: [],
|
||||
warnings: isCodexMember
|
||||
? [
|
||||
{
|
||||
code: 'codex_member_wide_not_supported',
|
||||
message: 'Codex member-wide native trace is not available in this variant yet.',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||
import { mapOpenCodeRuntimeTranscriptMessagesToParsedMessages } from '@main/services/team/taskLogs/stream/OpenCodeRuntimeProjectionMapper';
|
||||
|
||||
import { extractMemberLogPreviewItems } from '../../../../core/domain/policies/memberLogPreviewExtractor';
|
||||
|
||||
import { normalizeMemberName } from './memberLogStreamSourceUtils';
|
||||
|
||||
import type { MemberLogStreamWarning } from '../../../../contracts';
|
||||
import type {
|
||||
MemberLogPreviewSource,
|
||||
MemberLogPreviewSourceInput,
|
||||
MemberLogPreviewSourceResult,
|
||||
} from '../../../../core/application/ports/MemberLogPreviewSource';
|
||||
import type { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService';
|
||||
|
||||
interface BinaryResolverLike {
|
||||
resolve(): Promise<string | null>;
|
||||
}
|
||||
|
||||
function classifyOpenCodePreviewError(error: unknown): MemberLogStreamWarning {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const normalized = message.toLowerCase();
|
||||
if (normalized.includes('timed out') || normalized.includes('timeout')) {
|
||||
return {
|
||||
code: 'opencode_runtime_timeout',
|
||||
message: 'OpenCode runtime preview timed out; graph preview will use other sources.',
|
||||
};
|
||||
}
|
||||
if (
|
||||
normalized.includes('--lane') ||
|
||||
normalized.includes('multiple') ||
|
||||
normalized.includes('ambiguous')
|
||||
) {
|
||||
return {
|
||||
code: 'opencode_ambiguous_lane',
|
||||
message: 'OpenCode runtime session is ambiguous without a safe lane id.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: 'opencode_runtime_unavailable',
|
||||
message: `OpenCode runtime preview is unavailable: ${message}`,
|
||||
};
|
||||
}
|
||||
|
||||
export class OpenCodeMemberRuntimePreviewSource implements MemberLogPreviewSource {
|
||||
readonly provider = 'opencode_runtime' as const;
|
||||
private readonly cache = new Map<
|
||||
string,
|
||||
{ expiresAt: number; result: MemberLogPreviewSourceResult }
|
||||
>();
|
||||
private readonly inFlight = new Map<string, Promise<MemberLogPreviewSourceResult>>();
|
||||
|
||||
constructor(
|
||||
private readonly runtimeBridge: ClaudeMultimodelBridgeService,
|
||||
private readonly binaryResolver: BinaryResolverLike = ClaudeBinaryResolver
|
||||
) {}
|
||||
|
||||
async loadPreview(input: MemberLogPreviewSourceInput): Promise<MemberLogPreviewSourceResult> {
|
||||
const cacheKey = [
|
||||
input.teamName,
|
||||
normalizeMemberName(input.memberName),
|
||||
input.laneId ?? '',
|
||||
input.maxItems,
|
||||
input.textLimit,
|
||||
input.budget.openCodeMessageLimit,
|
||||
].join('::');
|
||||
|
||||
if (!input.forceRefresh) {
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.result;
|
||||
}
|
||||
}
|
||||
|
||||
const inFlightKey = input.forceRefresh ? `${cacheKey}::force` : cacheKey;
|
||||
const existing = this.inFlight.get(inFlightKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const promise = this.buildResult(input)
|
||||
.then((result) => {
|
||||
this.cache.set(cacheKey, {
|
||||
expiresAt: Date.now() + input.budget.cacheTtlMs,
|
||||
result,
|
||||
});
|
||||
return result;
|
||||
})
|
||||
.finally(() => {
|
||||
this.inFlight.delete(inFlightKey);
|
||||
});
|
||||
this.inFlight.set(inFlightKey, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
private async buildResult(
|
||||
input: MemberLogPreviewSourceInput
|
||||
): Promise<MemberLogPreviewSourceResult> {
|
||||
if (!input.laneId) {
|
||||
return {
|
||||
provider: this.provider,
|
||||
status: 'skipped',
|
||||
reason: 'opencode_safe_lane_unavailable',
|
||||
items: [],
|
||||
warnings: [],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const binaryPath = await this.binaryResolver.resolve();
|
||||
if (!binaryPath) {
|
||||
return this.skipped(
|
||||
'opencode_runtime_unavailable',
|
||||
'OpenCode runtime bridge is unavailable.'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const transcript = await this.runtimeBridge.getOpenCodeTranscript(binaryPath, {
|
||||
teamId: input.teamName,
|
||||
memberName: input.memberName,
|
||||
limit: input.budget.openCodeMessageLimit,
|
||||
laneId: input.laneId,
|
||||
timeoutMs: input.budget.openCodeTimeoutMs,
|
||||
});
|
||||
const projectedMessages = transcript?.logProjection?.messages ?? [];
|
||||
const parsedMessages = mapOpenCodeRuntimeTranscriptMessagesToParsedMessages(projectedMessages)
|
||||
.sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime())
|
||||
.slice(-input.budget.maxSourceMessagesPerProvider);
|
||||
if (parsedMessages.length === 0) {
|
||||
return {
|
||||
provider: this.provider,
|
||||
status: 'skipped',
|
||||
reason: 'opencode_missing_runtime_session',
|
||||
items: [],
|
||||
warnings: [],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const sessionId =
|
||||
transcript?.sessionId ??
|
||||
parsedMessages[0]?.sessionId ??
|
||||
`opencode:${normalizeMemberName(input.memberName)}`;
|
||||
const extracted = extractMemberLogPreviewItems({
|
||||
messages: parsedMessages,
|
||||
provider: this.provider,
|
||||
maxItems: input.maxItems,
|
||||
textLimit: input.textLimit,
|
||||
sourceId: sessionId,
|
||||
sourceLabel: 'OpenCode runtime',
|
||||
sessionId,
|
||||
laneId: input.laneId,
|
||||
});
|
||||
|
||||
return {
|
||||
provider: this.provider,
|
||||
status: extracted.items.length > 0 ? 'included' : 'skipped',
|
||||
reason: extracted.items.length > 0 ? undefined : 'opencode_no_renderable_preview',
|
||||
items: extracted.items,
|
||||
warnings: [],
|
||||
truncated: extracted.truncated,
|
||||
overflowCount: extracted.overflowCount,
|
||||
};
|
||||
} catch (error) {
|
||||
const warning = classifyOpenCodePreviewError(error);
|
||||
return this.skipped(warning.code, warning.message, warning);
|
||||
}
|
||||
}
|
||||
|
||||
private skipped(
|
||||
code: MemberLogStreamWarning['code'],
|
||||
reason: string,
|
||||
warning: MemberLogStreamWarning = { code, message: reason }
|
||||
): MemberLogPreviewSourceResult {
|
||||
return {
|
||||
provider: this.provider,
|
||||
status: 'skipped',
|
||||
reason,
|
||||
items: [],
|
||||
warnings: [warning],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,15 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DEFAULT_MEMBER_LOG_PREVIEW_BUDGET } from '../../../../../core/domain/models/MemberLogPreviewBudget';
|
||||
import { DEFAULT_MEMBER_LOG_STREAM_BUDGET } from '../../../../../core/domain/models/MemberLogStreamBudget';
|
||||
import { ClaudeMemberTranscriptPreviewSource } from '../ClaudeMemberTranscriptPreviewSource';
|
||||
import { ClaudeMemberTranscriptStreamSource } from '../ClaudeMemberTranscriptStreamSource';
|
||||
import { CodexNativeMemberTracePreviewSource } from '../CodexNativeMemberTracePreviewSource';
|
||||
import { CodexNativeMemberTraceStreamSource } from '../CodexNativeMemberTraceStreamSource';
|
||||
import { OpenCodeMemberRuntimePreviewSource } from '../OpenCodeMemberRuntimePreviewSource';
|
||||
import { OpenCodeMemberRuntimeStreamSource } from '../OpenCodeMemberRuntimeStreamSource';
|
||||
|
||||
import type { MemberLogPreviewSourceInput } from '../../../../../core/application/ports/MemberLogPreviewSource';
|
||||
import type { MemberLogStreamSourceInput } from '../../../../../core/application/ports/MemberLogStreamSource';
|
||||
import type { EnhancedChunk, ParsedMessage } from '@main/types';
|
||||
|
||||
|
|
@ -49,7 +54,9 @@ function fakeChunk(id: string): EnhancedChunk {
|
|||
};
|
||||
}
|
||||
|
||||
function sourceInput(overrides: Partial<MemberLogStreamSourceInput> = {}): MemberLogStreamSourceInput {
|
||||
function sourceInput(
|
||||
overrides: Partial<MemberLogStreamSourceInput> = {}
|
||||
): MemberLogStreamSourceInput {
|
||||
return {
|
||||
teamName: 'alpha-team',
|
||||
memberName: 'alice',
|
||||
|
|
@ -58,6 +65,19 @@ function sourceInput(overrides: Partial<MemberLogStreamSourceInput> = {}): Membe
|
|||
};
|
||||
}
|
||||
|
||||
function previewInput(
|
||||
overrides: Partial<MemberLogPreviewSourceInput> = {}
|
||||
): MemberLogPreviewSourceInput {
|
||||
return {
|
||||
teamName: 'alpha-team',
|
||||
memberName: 'alice',
|
||||
budget: DEFAULT_MEMBER_LOG_PREVIEW_BUDGET,
|
||||
maxItems: 3,
|
||||
textLimit: 200,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ClaudeMemberTranscriptStreamSource', () => {
|
||||
it('dedupes cumulative subagent refs by member/session before parsing and keeps path-safe segment ids', async () => {
|
||||
const parseFiles = vi.fn().mockImplementation(async (paths: string[]) => {
|
||||
|
|
@ -114,6 +134,67 @@ describe('ClaudeMemberTranscriptStreamSource', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('ClaudeMemberTranscriptPreviewSource', () => {
|
||||
it('builds compact previews from parsed transcript messages without chunk building', async () => {
|
||||
const parseFiles = vi.fn().mockResolvedValue(
|
||||
new Map<string, ParsedMessage[]>([
|
||||
[
|
||||
'/transcripts/latest.jsonl',
|
||||
[
|
||||
{
|
||||
...parsedMessage('tool-call', '2026-04-04T00:00:00.000Z'),
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu-1',
|
||||
name: 'Bash',
|
||||
input: { command: 'pnpm test', ignored: 'x'.repeat(5_000) },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...parsedMessage('tool-result', '2026-04-04T00:01:00.000Z'),
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'toolu-1',
|
||||
content: 'x'.repeat(5_000),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
],
|
||||
])
|
||||
);
|
||||
const source = new ClaudeMemberTranscriptPreviewSource(
|
||||
{
|
||||
findRecentMemberLogFileRefsByMember: vi.fn().mockResolvedValue([
|
||||
{
|
||||
memberName: 'alice',
|
||||
sessionId: 'session-1',
|
||||
filePath: '/transcripts/latest.jsonl',
|
||||
mtimeMs: 20,
|
||||
sizeBytes: 5_000,
|
||||
messageCount: 2,
|
||||
kind: 'subagent',
|
||||
},
|
||||
]),
|
||||
} as never,
|
||||
{ parseFiles } as never,
|
||||
{ warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
|
||||
);
|
||||
|
||||
const result = await source.loadPreview(previewInput({ textLimit: 160 }));
|
||||
|
||||
expect(result.status).toBe('included');
|
||||
expect(result.items.map((item) => item.kind)).toEqual(['tool_result', 'tool_use']);
|
||||
expect(result.items[0]?.preview?.length).toBeLessThanOrEqual(160);
|
||||
expect(parseFiles).toHaveBeenCalledWith(['/transcripts/latest.jsonl']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenCodeMemberRuntimeStreamSource', () => {
|
||||
it('enforces member message and content budgets before building OpenCode chunks', async () => {
|
||||
const getOpenCodeTranscript = vi.fn().mockResolvedValue({
|
||||
|
|
@ -133,9 +214,7 @@ describe('OpenCodeMemberRuntimeStreamSource', () => {
|
|||
})),
|
||||
},
|
||||
});
|
||||
const buildBundleChunks = vi.fn((_: ParsedMessage[]) => [
|
||||
fakeChunk('opencode-budgeted-chunk'),
|
||||
]);
|
||||
const buildBundleChunks = vi.fn((_: ParsedMessage[]) => [fakeChunk('opencode-budgeted-chunk')]);
|
||||
const source = new OpenCodeMemberRuntimeStreamSource(
|
||||
{ getOpenCodeTranscript } as never,
|
||||
{ buildBundleChunks } as never,
|
||||
|
|
@ -226,7 +305,9 @@ describe('OpenCodeMemberRuntimeStreamSource', () => {
|
|||
it('reports ambiguous OpenCode lane errors as skipped provider warnings', async () => {
|
||||
const source = new OpenCodeMemberRuntimeStreamSource(
|
||||
{
|
||||
getOpenCodeTranscript: vi.fn().mockRejectedValue(new Error('multiple records, pass --lane')),
|
||||
getOpenCodeTranscript: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('multiple records, pass --lane')),
|
||||
} as never,
|
||||
{ buildBundleChunks: vi.fn(() => [fakeChunk('opencode-chunk')]) } as never,
|
||||
{ resolve: vi.fn().mockResolvedValue('/mock/orchestrator') }
|
||||
|
|
@ -247,6 +328,77 @@ describe('OpenCodeMemberRuntimeStreamSource', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('OpenCodeMemberRuntimePreviewSource', () => {
|
||||
it('skips OpenCode preview without a safe lane id before touching the runtime bridge', async () => {
|
||||
const getOpenCodeTranscript = vi.fn();
|
||||
const resolve = vi.fn();
|
||||
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
|
||||
resolve,
|
||||
});
|
||||
|
||||
const result = await source.loadPreview(previewInput());
|
||||
|
||||
expect(result).toMatchObject({
|
||||
provider: 'opencode_runtime',
|
||||
status: 'skipped',
|
||||
reason: 'opencode_safe_lane_unavailable',
|
||||
items: [],
|
||||
warnings: [],
|
||||
});
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
expect(getOpenCodeTranscript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses bounded OpenCode projection messages and preserves safe lane ids', async () => {
|
||||
const getOpenCodeTranscript = vi.fn().mockResolvedValue({
|
||||
sessionId: 'opencode-session',
|
||||
logProjection: {
|
||||
messages: [
|
||||
{
|
||||
uuid: 'opencode-1',
|
||||
parentUuid: null,
|
||||
type: 'assistant',
|
||||
timestamp: '2026-04-04T00:00:00.000Z',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu-1',
|
||||
name: 'Edit',
|
||||
input: { filePath: 'src/app.ts' },
|
||||
},
|
||||
],
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isMeta: false,
|
||||
sessionId: 'opencode-session',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, {
|
||||
resolve: vi.fn().mockResolvedValue('/mock/orchestrator'),
|
||||
});
|
||||
|
||||
const result = await source.loadPreview(previewInput({ laneId: 'secondary:opencode:alice' }));
|
||||
|
||||
expect(result.status).toBe('included');
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_use',
|
||||
title: 'Edit',
|
||||
laneId: 'secondary:opencode:alice',
|
||||
});
|
||||
expect(getOpenCodeTranscript).toHaveBeenCalledWith(
|
||||
'/mock/orchestrator',
|
||||
expect.objectContaining({
|
||||
limit: DEFAULT_MEMBER_LOG_PREVIEW_BUDGET.openCodeMessageLimit,
|
||||
timeoutMs: DEFAULT_MEMBER_LOG_PREVIEW_BUDGET.openCodeTimeoutMs,
|
||||
laneId: 'secondary:opencode:alice',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodexNativeMemberTraceStreamSource', () => {
|
||||
it('returns an honest skipped warning for Codex members only', async () => {
|
||||
const codexSource = new CodexNativeMemberTraceStreamSource({
|
||||
|
|
@ -270,3 +422,20 @@ describe('CodexNativeMemberTraceStreamSource', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodexNativeMemberTracePreviewSource', () => {
|
||||
it('returns unsupported empty coverage for Codex preview without breaking the batch', async () => {
|
||||
const source = new CodexNativeMemberTracePreviewSource({
|
||||
getConfig: vi.fn().mockResolvedValue({
|
||||
members: [{ name: 'alice', providerId: 'codex' }],
|
||||
}),
|
||||
} as never);
|
||||
|
||||
await expect(source.loadPreview(previewInput())).resolves.toMatchObject({
|
||||
provider: 'codex_native_trace',
|
||||
status: 'skipped',
|
||||
items: [],
|
||||
warnings: [{ code: 'codex_member_wide_not_supported' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { createHash } from 'crypto';
|
||||
|
||||
import type {
|
||||
MemberLogStreamProvider,
|
||||
MemberLogStreamSegmentSource,
|
||||
} from '../../../../contracts';
|
||||
import type { MemberLogStreamProvider, MemberLogStreamSegmentSource } from '../../../../contracts';
|
||||
import type { MemberLogFileRef } from '@main/services/team/TeamMemberLogsFinder';
|
||||
import type {
|
||||
BoardTaskLogActor,
|
||||
BoardTaskLogParticipant,
|
||||
|
|
@ -18,6 +16,49 @@ export function normalizeTeamName(value: string): string {
|
|||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isPreferredRef(candidate: MemberLogFileRef, existing: MemberLogFileRef): boolean {
|
||||
const candidateMessageCount = candidate.messageCount ?? -1;
|
||||
const existingMessageCount = existing.messageCount ?? -1;
|
||||
if (candidateMessageCount !== existingMessageCount) {
|
||||
return candidateMessageCount > existingMessageCount;
|
||||
}
|
||||
|
||||
const candidateSize = candidate.sizeBytes ?? -1;
|
||||
const existingSize = existing.sizeBytes ?? -1;
|
||||
if (candidateSize !== existingSize) {
|
||||
return candidateSize > existingSize;
|
||||
}
|
||||
|
||||
return candidate.mtimeMs > existing.mtimeMs;
|
||||
}
|
||||
|
||||
export function dedupeMemberLogRefs(refs: readonly MemberLogFileRef[]): MemberLogFileRef[] {
|
||||
const byFilePath = new Map<string, MemberLogFileRef>();
|
||||
const bySession = new Map<string, MemberLogFileRef>();
|
||||
const passthrough: MemberLogFileRef[] = [];
|
||||
|
||||
for (const ref of refs) {
|
||||
if (byFilePath.has(ref.filePath)) continue;
|
||||
byFilePath.set(ref.filePath, ref);
|
||||
|
||||
if (ref.kind === 'lead_session') {
|
||||
passthrough.push(ref);
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${ref.kind ?? 'unknown'}:${normalizeMemberName(ref.memberName)}:${ref.sessionId}`;
|
||||
const existing = bySession.get(key);
|
||||
if (!existing || isPreferredRef(ref, existing)) {
|
||||
bySession.set(key, ref);
|
||||
}
|
||||
}
|
||||
|
||||
return [...passthrough, ...bySession.values()].sort((left, right) => {
|
||||
const byTime = right.mtimeMs - left.mtimeMs;
|
||||
return byTime !== 0 ? byTime : left.filePath.localeCompare(right.filePath);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildMemberParticipant(
|
||||
memberName: string,
|
||||
role: 'member' | 'lead' = 'member'
|
||||
|
|
|
|||
|
|
@ -2,17 +2,25 @@ import { BoardTaskExactLogChunkBuilder } from '@main/services/team/taskLogs/exac
|
|||
import { BoardTaskExactLogStrictParser } from '@main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser';
|
||||
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||
|
||||
import { createEmptyMemberLogStreamResponse } from '../../contracts';
|
||||
import {
|
||||
createEmptyMemberLogPreviewResponse,
|
||||
createEmptyMemberLogStreamResponse,
|
||||
} from '../../contracts';
|
||||
import { GetMemberLogPreviewsUseCase } from '../../core/application/use-cases/GetMemberLogPreviewsUseCase';
|
||||
import { GetMemberLogStreamUseCase } from '../../core/application/use-cases/GetMemberLogStreamUseCase';
|
||||
import { SetMemberLogStreamTrackingUseCase } from '../../core/application/use-cases/SetMemberLogStreamTrackingUseCase';
|
||||
import { ClaudeMemberTranscriptPreviewSource } from '../adapters/output/sources/ClaudeMemberTranscriptPreviewSource';
|
||||
import { ClaudeMemberTranscriptStreamSource } from '../adapters/output/sources/ClaudeMemberTranscriptStreamSource';
|
||||
import { CodexNativeMemberTracePreviewSource } from '../adapters/output/sources/CodexNativeMemberTracePreviewSource';
|
||||
import { CodexNativeMemberTraceStreamSource } from '../adapters/output/sources/CodexNativeMemberTraceStreamSource';
|
||||
import { OpenCodeMemberRuntimePreviewSource } from '../adapters/output/sources/OpenCodeMemberRuntimePreviewSource';
|
||||
import { OpenCodeMemberRuntimeStreamSource } from '../adapters/output/sources/OpenCodeMemberRuntimeStreamSource';
|
||||
import { isMemberLogStreamReadEnabled } from '../featureGates';
|
||||
|
||||
import type { MemberLogStreamResponse } from '../../contracts';
|
||||
import type { MemberLogPreviewResponse, MemberLogStreamResponse } from '../../contracts';
|
||||
import type { LoggerPort } from '../../core/application/ports/LoggerPort';
|
||||
import type { MemberLogStreamTrackingPort } from '../../core/application/ports/MemberLogStreamTrackingPort';
|
||||
import type { GetMemberLogPreviewsInput } from '../../core/application/use-cases/GetMemberLogPreviewsUseCase';
|
||||
import type { GetMemberLogStreamInput } from '../../core/application/use-cases/GetMemberLogStreamUseCase';
|
||||
import type { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService';
|
||||
import type { TeamLogSourceTracker } from '@main/services/team/TeamLogSourceTracker';
|
||||
|
|
@ -20,6 +28,7 @@ import type { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFin
|
|||
|
||||
export interface MemberLogStreamFeatureFacade {
|
||||
getMemberLogStream(input: GetMemberLogStreamInput): Promise<MemberLogStreamResponse>;
|
||||
getMemberLogPreviews(input: GetMemberLogPreviewsInput): Promise<MemberLogPreviewResponse>;
|
||||
setMemberLogStreamTracking(teamName: string, enabled: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -43,21 +52,33 @@ export function createMemberLogStreamFeature(deps: {
|
|||
logger: LoggerPort;
|
||||
}): MemberLogStreamFeatureFacade {
|
||||
const chunkBuilder = new BoardTaskExactLogChunkBuilder();
|
||||
const strictParser = new BoardTaskExactLogStrictParser();
|
||||
const configReader = deps.configReader ?? new TeamConfigReader();
|
||||
const sources = [
|
||||
new ClaudeMemberTranscriptStreamSource(
|
||||
deps.logsFinder,
|
||||
new BoardTaskExactLogStrictParser(),
|
||||
strictParser,
|
||||
chunkBuilder,
|
||||
deps.logger
|
||||
),
|
||||
new OpenCodeMemberRuntimeStreamSource(deps.runtimeBridge, chunkBuilder),
|
||||
new CodexNativeMemberTraceStreamSource(deps.configReader ?? new TeamConfigReader()),
|
||||
new CodexNativeMemberTraceStreamSource(configReader),
|
||||
];
|
||||
const previewSources = [
|
||||
new ClaudeMemberTranscriptPreviewSource(deps.logsFinder, strictParser, deps.logger),
|
||||
new OpenCodeMemberRuntimePreviewSource(deps.runtimeBridge),
|
||||
new CodexNativeMemberTracePreviewSource(configReader),
|
||||
];
|
||||
const getUseCase = new GetMemberLogStreamUseCase({
|
||||
sources,
|
||||
clock: { now: () => Date.now() },
|
||||
logger: deps.logger,
|
||||
});
|
||||
const getPreviewsUseCase = new GetMemberLogPreviewsUseCase({
|
||||
sources: previewSources,
|
||||
clock: { now: () => Date.now() },
|
||||
logger: deps.logger,
|
||||
});
|
||||
const trackingUseCase = new SetMemberLogStreamTrackingUseCase(
|
||||
new TeamLogSourceTrackerMemberStreamPort(deps.logSourceTracker)
|
||||
);
|
||||
|
|
@ -69,7 +90,12 @@ export function createMemberLogStreamFeature(deps: {
|
|||
}
|
||||
return getUseCase.execute(input);
|
||||
},
|
||||
setMemberLogStreamTracking: (teamName, enabled) =>
|
||||
trackingUseCase.execute(teamName, enabled),
|
||||
getMemberLogPreviews: async (input) => {
|
||||
if (!isMemberLogStreamReadEnabled()) {
|
||||
return createEmptyMemberLogPreviewResponse();
|
||||
}
|
||||
return getPreviewsUseCase.execute(input);
|
||||
},
|
||||
setMemberLogStreamTracking: (teamName, enabled) => trackingUseCase.execute(teamName, enabled),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import {
|
||||
MEMBER_LOG_STREAM_GET,
|
||||
MEMBER_LOG_STREAM_GET_PREVIEWS,
|
||||
MEMBER_LOG_STREAM_SET_TRACKING,
|
||||
} from '../../contracts';
|
||||
import { createMemberLogStreamBridge } from '../createMemberLogStreamBridge';
|
||||
|
|
@ -79,4 +80,46 @@ describe('createMemberLogStreamBridge', () => {
|
|||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards batch member log preview IPC requests and normalizes response payloads', async () => {
|
||||
mocks.ipcRenderer.invoke.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: {
|
||||
members: [
|
||||
{
|
||||
memberName: 'alice',
|
||||
items: [],
|
||||
generatedAt: '2026-04-02T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
generatedAt: '2026-04-02T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
const bridge = createMemberLogStreamBridge();
|
||||
|
||||
const response = await bridge.getMemberLogPreviews('alpha-team', ['alice'], {
|
||||
maxItemsPerMember: 3,
|
||||
textLimit: 200,
|
||||
laneIdsByMember: { alice: 'secondary:opencode:alice' },
|
||||
});
|
||||
|
||||
expect(response.members[0]).toMatchObject({
|
||||
memberName: 'alice',
|
||||
items: [],
|
||||
coverage: [],
|
||||
warnings: [],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
});
|
||||
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith(
|
||||
MEMBER_LOG_STREAM_GET_PREVIEWS,
|
||||
'alpha-team',
|
||||
['alice'],
|
||||
{
|
||||
maxItemsPerMember: 3,
|
||||
textLimit: 200,
|
||||
laneIdsByMember: { alice: 'secondary:opencode:alice' },
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,11 +2,15 @@ import { ipcRenderer } from 'electron';
|
|||
|
||||
import {
|
||||
MEMBER_LOG_STREAM_GET,
|
||||
MEMBER_LOG_STREAM_GET_PREVIEWS,
|
||||
MEMBER_LOG_STREAM_SET_TRACKING,
|
||||
normalizeMemberLogPreviewResponse,
|
||||
normalizeMemberLogStreamResponse,
|
||||
} from '../contracts';
|
||||
|
||||
import type {
|
||||
MemberLogPreviewRequestOptions,
|
||||
MemberLogPreviewResponse,
|
||||
MemberLogStreamApi,
|
||||
MemberLogStreamRequestOptions,
|
||||
MemberLogStreamResponse,
|
||||
|
|
@ -36,6 +40,19 @@ export function createMemberLogStreamBridge(): MemberLogStreamApi {
|
|||
options
|
||||
)
|
||||
),
|
||||
getMemberLogPreviews: async (
|
||||
teamName: string,
|
||||
memberNames: string[],
|
||||
options?: MemberLogPreviewRequestOptions
|
||||
): Promise<MemberLogPreviewResponse> =>
|
||||
normalizeMemberLogPreviewResponse(
|
||||
await invokeIpcWithResult<MemberLogPreviewResponse>(
|
||||
MEMBER_LOG_STREAM_GET_PREVIEWS,
|
||||
teamName,
|
||||
memberNames,
|
||||
options
|
||||
)
|
||||
),
|
||||
setMemberLogStreamTracking: (teamName: string, enabled: boolean): Promise<void> =>
|
||||
invokeIpcWithResult<void>(MEMBER_LOG_STREAM_SET_TRACKING, teamName, enabled),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@
|
|||
* to run in a regular browser connected to an HTTP server.
|
||||
*/
|
||||
|
||||
import { createEmptyMemberLogStreamResponse } from '@features/member-log-stream/contracts';
|
||||
import {
|
||||
createEmptyMemberLogPreviewResponse,
|
||||
createEmptyMemberLogStreamResponse,
|
||||
} from '@features/member-log-stream/contracts';
|
||||
|
||||
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
|
||||
import type { MemberLogStreamApi } from '@features/member-log-stream/contracts';
|
||||
|
|
@ -259,6 +262,10 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
console.warn('[HttpAPIClient] getMemberLogStream is not available in browser mode');
|
||||
return createEmptyMemberLogStreamResponse();
|
||||
},
|
||||
getMemberLogPreviews: async () => {
|
||||
console.warn('[HttpAPIClient] getMemberLogPreviews is not available in browser mode');
|
||||
return createEmptyMemberLogPreviewResponse();
|
||||
},
|
||||
setMemberLogStreamTracking: async () => {
|
||||
// Not available in browser mode - no-op.
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,221 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { GraphMemberLogPreviewHud } from '@features/agent-graph/renderer/ui/GraphMemberLogPreviewHud';
|
||||
|
||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||
|
||||
const previewsByMember = new Map([
|
||||
[
|
||||
'team-lead',
|
||||
{
|
||||
memberName: 'team-lead',
|
||||
items: [
|
||||
{
|
||||
id: 'lead-preview-1',
|
||||
kind: 'text' as const,
|
||||
provider: 'claude_transcript' as const,
|
||||
timestamp: '2026-04-03T00:00:00.000Z',
|
||||
title: 'Assistant',
|
||||
preview: 'lead log preview',
|
||||
tone: 'neutral' as const,
|
||||
},
|
||||
],
|
||||
coverage: [{ provider: 'claude_transcript' as const, status: 'included' as const }],
|
||||
warnings: [],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
generatedAt: '2026-04-03T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
[
|
||||
'alice',
|
||||
{
|
||||
memberName: 'alice',
|
||||
items: [
|
||||
{
|
||||
id: 'preview-1',
|
||||
kind: 'tool_use' as const,
|
||||
provider: 'claude_transcript' as const,
|
||||
timestamp: '2026-04-03T00:00:00.000Z',
|
||||
title: 'Bash',
|
||||
preview: 'pnpm test',
|
||||
tone: 'warning' as const,
|
||||
},
|
||||
],
|
||||
coverage: [{ provider: 'claude_transcript' as const, status: 'included' as const }],
|
||||
warnings: [],
|
||||
truncated: true,
|
||||
overflowCount: 2,
|
||||
generatedAt: '2026-04-03T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
vi.mock('@features/agent-graph/renderer/hooks/useGraphMemberLogPreviews', () => ({
|
||||
buildGraphLogPreviewLaneIdsByMember: () => ({ alice: 'secondary:opencode:alice' }),
|
||||
useGraphMemberLogPreviews: () => ({
|
||||
previewsByMember,
|
||||
loading: false,
|
||||
error: null,
|
||||
reload: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@features/agent-graph/renderer/hooks/useGraphActivityContext', () => ({
|
||||
useGraphActivityContext: () => ({
|
||||
teamData: {
|
||||
members: [
|
||||
{
|
||||
name: 'alice',
|
||||
status: 'active',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
lastActiveAt: null,
|
||||
messageCount: 0,
|
||||
providerId: 'opencode',
|
||||
laneOwnerProviderId: 'opencode',
|
||||
laneId: 'secondary:opencode:alice',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('GraphMemberLogPreviewHud', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn(() => 1)
|
||||
);
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-03T00:01:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('opens the member profile on the logs tab when a preview row or overflow is clicked', async () => {
|
||||
const node: GraphNode = {
|
||||
id: 'member:alpha-team:alice',
|
||||
kind: 'member',
|
||||
label: 'alice',
|
||||
state: 'active',
|
||||
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' },
|
||||
};
|
||||
const onOpenMemberProfile = vi.fn();
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<GraphMemberLogPreviewHud
|
||||
teamName="alpha-team"
|
||||
nodes={[node]}
|
||||
getLogWorldRect={() => ({
|
||||
left: 40,
|
||||
top: 80,
|
||||
right: 300,
|
||||
bottom: 372,
|
||||
width: 260,
|
||||
height: 292,
|
||||
})}
|
||||
getCameraZoom={() => 1}
|
||||
worldToScreen={(x, y) => ({ x, y })}
|
||||
getViewportSize={() => ({ width: 1200, height: 800 })}
|
||||
focusNodeIds={null}
|
||||
onOpenMemberProfile={onOpenMemberProfile}
|
||||
/>
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const row = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('pnpm test')
|
||||
);
|
||||
expect(row).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
row?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onOpenMemberProfile).toHaveBeenCalledWith('alice', { initialTab: 'logs' });
|
||||
|
||||
const moreButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('+2 more')
|
||||
);
|
||||
expect(moreButton).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
moreButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onOpenMemberProfile).toHaveBeenCalledTimes(2);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders lead log previews and opens the lead profile logs tab', async () => {
|
||||
const leadNode: GraphNode = {
|
||||
id: 'lead:alpha-team',
|
||||
kind: 'lead',
|
||||
label: 'alpha-team',
|
||||
state: 'active',
|
||||
domainRef: { kind: 'lead', teamName: 'alpha-team', memberName: 'team-lead' },
|
||||
};
|
||||
const onOpenMemberProfile = vi.fn();
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<GraphMemberLogPreviewHud
|
||||
teamName="alpha-team"
|
||||
nodes={[leadNode]}
|
||||
getLogWorldRect={() => ({
|
||||
left: 40,
|
||||
top: 80,
|
||||
right: 300,
|
||||
bottom: 372,
|
||||
width: 260,
|
||||
height: 292,
|
||||
})}
|
||||
getCameraZoom={() => 1}
|
||||
worldToScreen={(x, y) => ({ x, y })}
|
||||
getViewportSize={() => ({ width: 1200, height: 800 })}
|
||||
focusNodeIds={null}
|
||||
onOpenMemberProfile={onOpenMemberProfile}
|
||||
/>
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const row = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('lead log preview')
|
||||
);
|
||||
expect(row).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
row?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onOpenMemberProfile).toHaveBeenCalledWith('team-lead', { initialTab: 'logs' });
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
import React, { act, useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useGraphMemberLogPreviews } from '@features/agent-graph/renderer/hooks/useGraphMemberLogPreviews';
|
||||
|
||||
import type { MemberLogPreviewResponse } from '@features/member-log-stream/contracts';
|
||||
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
memberLogStream: {
|
||||
getMemberLogPreviews: vi.fn(),
|
||||
},
|
||||
teams: {
|
||||
onTeamChange: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function createDeferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function response(memberName: string, generatedAt: string): MemberLogPreviewResponse {
|
||||
return {
|
||||
generatedAt,
|
||||
members: [
|
||||
{
|
||||
memberName,
|
||||
items: [
|
||||
{
|
||||
id: `${memberName}:${generatedAt}`,
|
||||
kind: 'text',
|
||||
provider: 'claude_transcript',
|
||||
timestamp: generatedAt,
|
||||
title: 'Assistant',
|
||||
preview: memberName,
|
||||
tone: 'neutral',
|
||||
},
|
||||
],
|
||||
coverage: [{ provider: 'claude_transcript', status: 'included' }],
|
||||
warnings: [],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
generatedAt,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const HookProbe = ({
|
||||
teamName,
|
||||
memberNames,
|
||||
laneIdsByMember,
|
||||
enabled = true,
|
||||
onState,
|
||||
}: {
|
||||
teamName: string;
|
||||
memberNames: string[];
|
||||
laneIdsByMember?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
onState: (state: ReturnType<typeof useGraphMemberLogPreviews>) => void;
|
||||
}): React.JSX.Element | null => {
|
||||
const state = useGraphMemberLogPreviews({
|
||||
teamName,
|
||||
memberNames,
|
||||
laneIdsByMember,
|
||||
enabled,
|
||||
});
|
||||
useEffect(() => {
|
||||
onState(state);
|
||||
}, [onState, state]);
|
||||
return null;
|
||||
};
|
||||
|
||||
describe('useGraphMemberLogPreviews', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
apiMock.memberLogStream.getMemberLogPreviews.mockReset();
|
||||
apiMock.teams.onTeamChange.mockReset();
|
||||
apiMock.teams.onTeamChange.mockReturnValue(() => undefined);
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
value: 'visible',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('debounces visible member batch requests and passes safe lane ids', async () => {
|
||||
apiMock.memberLogStream.getMemberLogPreviews.mockResolvedValue(
|
||||
response('alice', '2026-04-03T00:00:00.000Z')
|
||||
);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<HookProbe
|
||||
teamName="alpha-team"
|
||||
memberNames={['alice', 'alice']}
|
||||
laneIdsByMember={{ alice: 'secondary:opencode:alice' }}
|
||||
onState={() => undefined}
|
||||
/>
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledWith(
|
||||
'alpha-team',
|
||||
['alice'],
|
||||
expect.objectContaining({
|
||||
maxItemsPerMember: 3,
|
||||
textLimit: 200,
|
||||
laneIdsByMember: { alice: 'secondary:opencode:alice' },
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps completed previews cached after the visible member set changes', async () => {
|
||||
const aliceLoad = createDeferred<MemberLogPreviewResponse>();
|
||||
const bobLoad = createDeferred<MemberLogPreviewResponse>();
|
||||
apiMock.memberLogStream.getMemberLogPreviews
|
||||
.mockReturnValueOnce(aliceLoad.promise)
|
||||
.mockReturnValueOnce(bobLoad.promise);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const states: ReturnType<typeof useGraphMemberLogPreviews>[] = [];
|
||||
const onState = vi.fn((state: ReturnType<typeof useGraphMemberLogPreviews>) => {
|
||||
states.push(state);
|
||||
});
|
||||
const latestState = (): ReturnType<typeof useGraphMemberLogPreviews> | undefined =>
|
||||
states.at(-1);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={onState} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookProbe teamName="alpha-team" memberNames={['bob']} onState={onState} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
aliceLoad.resolve(response('alice', '2026-04-03T00:00:00.000Z'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice');
|
||||
|
||||
await act(async () => {
|
||||
bobLoad.resolve(response('bob', '2026-04-03T00:01:00.000Z'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.previewsByMember.get('bob')?.items[0]?.preview).toBe('bob');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps cached previews while pan or zoom changes the visible member batch', async () => {
|
||||
const bobLoad = createDeferred<MemberLogPreviewResponse>();
|
||||
apiMock.memberLogStream.getMemberLogPreviews
|
||||
.mockResolvedValueOnce(response('alice', '2026-04-03T00:00:00.000Z'))
|
||||
.mockReturnValueOnce(bobLoad.promise);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const states: ReturnType<typeof useGraphMemberLogPreviews>[] = [];
|
||||
const onState = vi.fn((state: ReturnType<typeof useGraphMemberLogPreviews>) => {
|
||||
states.push(state);
|
||||
});
|
||||
const latestState = (): ReturnType<typeof useGraphMemberLogPreviews> | undefined =>
|
||||
states.at(-1);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={onState} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice');
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookProbe teamName="alpha-team" memberNames={[]} onState={onState} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice');
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookProbe teamName="alpha-team" memberNames={['bob']} onState={onState} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice');
|
||||
|
||||
await act(async () => {
|
||||
bobLoad.resolve(response('bob', '2026-04-03T00:01:00.000Z'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.previewsByMember.get('bob')?.items[0]?.preview).toBe('bob');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('reloads visible members on log-source events with force refresh', async () => {
|
||||
let teamChangeListener:
|
||||
| ((event: unknown, data: { teamName: string; type: string }) => void)
|
||||
| null = null;
|
||||
apiMock.teams.onTeamChange.mockImplementation((callback) => {
|
||||
teamChangeListener = callback as typeof teamChangeListener;
|
||||
return () => undefined;
|
||||
});
|
||||
apiMock.memberLogStream.getMemberLogPreviews.mockResolvedValue(
|
||||
response('alice', '2026-04-03T00:00:00.000Z')
|
||||
);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<HookProbe teamName="alpha-team" memberNames={['alice']} onState={() => undefined} />
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
teamChangeListener?.(null, { teamName: 'alpha-team', type: 'log-source-change' });
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2);
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith(
|
||||
'alpha-team',
|
||||
['alice'],
|
||||
expect.objectContaining({ forceRefresh: true })
|
||||
);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -149,7 +149,7 @@ describe('stable slot layout planner', () => {
|
|||
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('builds a board band that contains both the activity column and kanban band', () => {
|
||||
it('builds a board band that contains activity, logs, and kanban without overlap', () => {
|
||||
const teamName = 'team-process-width';
|
||||
const lead = createLead(teamName);
|
||||
const alice = createMember(teamName, 'agent-alice', 'alice');
|
||||
|
|
@ -170,9 +170,14 @@ describe('stable slot layout planner', () => {
|
|||
const frame = snapshot?.memberSlotFrames[0];
|
||||
expect(frame).toBeDefined();
|
||||
expect(frame?.boardBandRect.top).toBe(frame?.activityColumnRect.top);
|
||||
expect(frame?.boardBandRect.top).toBe(frame?.logColumnRect.top);
|
||||
expect(frame?.boardBandRect.top).toBe(frame?.kanbanBandRect.top);
|
||||
expect(frame?.activityColumnRect.left).toBe(frame?.boardBandRect.left);
|
||||
expect(frame?.kanbanBandRect.left).toBeGreaterThan(frame?.activityColumnRect.right ?? 0);
|
||||
expect(frame?.logColumnRect.left).toBeGreaterThan(frame?.activityColumnRect.right ?? 0);
|
||||
expect(frame?.kanbanBandRect.left).toBeGreaterThan(frame?.logColumnRect.right ?? 0);
|
||||
expect(rectsOverlap(frame!.activityColumnRect, frame!.logColumnRect)).toBe(false);
|
||||
expect(rectsOverlap(frame!.logColumnRect, frame!.kanbanBandRect)).toBe(false);
|
||||
expect(rectsOverlap(frame!.logColumnRect, frame!.processBandRect)).toBe(false);
|
||||
expect(frame?.processBandRect.width).toBe(computeProcessBandWidth(0));
|
||||
expect(frame?.processBandRect.height).toBe(STABLE_SLOT_GEOMETRY.processBandHeight);
|
||||
});
|
||||
|
|
@ -346,6 +351,7 @@ describe('stable slot layout planner', () => {
|
|||
|
||||
expect(footprint).toBeDefined();
|
||||
expect(footprint?.activityColumnWidth).toBe(ACTIVITY_LANE.width);
|
||||
expect(footprint?.logColumnWidth).toBe(260);
|
||||
expect(footprint?.activityColumnHeight).toBe(
|
||||
ACTIVITY_LANE.headerHeight +
|
||||
ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight +
|
||||
|
|
@ -381,11 +387,15 @@ describe('stable slot layout planner', () => {
|
|||
expect(footprint).toBeDefined();
|
||||
expect(footprint?.activityColumnWidth).toBe(0);
|
||||
expect(footprint?.activityColumnHeight).toBe(0);
|
||||
expect(footprint?.logColumnWidth).toBe(0);
|
||||
expect(footprint?.logColumnHeight).toBe(0);
|
||||
expect(footprint?.boardBandWidth).toBe(footprint?.kanbanBandWidth);
|
||||
expect(snapshot).not.toBeNull();
|
||||
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
|
||||
expect(frame?.activityColumnRect.width).toBe(0);
|
||||
expect(frame?.activityColumnRect.height).toBe(0);
|
||||
expect(frame?.logColumnRect.width).toBe(0);
|
||||
expect(frame?.logColumnRect.height).toBe(0);
|
||||
expect(frame?.kanbanBandRect.left).toBe(frame?.boardBandRect.left);
|
||||
});
|
||||
|
||||
|
|
@ -1072,6 +1082,7 @@ describe('stable slot layout planner', () => {
|
|||
expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadCoreRect);
|
||||
expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.processBandRect);
|
||||
expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.activityColumnRect);
|
||||
expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.logColumnRect);
|
||||
expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.kanbanBandRect);
|
||||
expect(snapshot!.leadCentralReservedBlock.width).toBeLessThan(
|
||||
snapshot!.leadSlotFrame.bounds.width
|
||||
|
|
|
|||
Loading…
Reference in a new issue