feat: add graph member log previews

This commit is contained in:
777genius 2026-05-07 15:18:21 +03:00
parent fcca3649bf
commit 9505ef8485
35 changed files with 4377 additions and 79 deletions

View file

@ -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);

View file

@ -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,

View file

@ -67,6 +67,7 @@ export interface GraphLayoutPort {
version: GraphLayoutVersion;
mode?: GraphLayoutMode;
showActivity?: boolean;
showLogs?: boolean;
ownerOrder: string[];
slotAssignments: Record<string, GraphOwnerSlotAssignment>;
}

View file

@ -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,

View file

@ -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 };
}

View file

@ -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>
);
};

View file

@ -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}
/>
</>
);
}}

View file

@ -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}
/>
</>
);
}}

View file

@ -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>;
}

View file

@ -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';

View file

@ -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;
}

View file

@ -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(),
};
}

View file

@ -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>;
}

View file

@ -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,
};
}
}

View file

@ -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');
});
});

View file

@ -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)));
}

View file

@ -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);
});
});

View file

@ -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

View file

@ -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,
};
}

View file

@ -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();
});
});

View file

@ -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);
}

View file

@ -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,
};
}
}

View file

@ -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

View file

@ -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,
};
}
}

View file

@ -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,
};
}
}

View file

@ -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' }],
});
});
});

View file

@ -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'

View file

@ -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),
};
}

View file

@ -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' },
}
);
});
});

View file

@ -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),
};

View file

@ -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.
},

View file

@ -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();
});
});
});

View file

@ -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();
});
});
});

View file

@ -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