merge: member log stream v2

# Conflicts:
#	src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx
#	test/main/services/team/TeamMemberLogsFinder.test.ts
This commit is contained in:
777genius 2026-05-07 15:24:04 +03:00
commit 8caa962dec
76 changed files with 12162 additions and 553 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -68,6 +68,7 @@ export interface UseGraphSimulationResult {
} | null; } | null;
getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null; getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null;
getActivityWorldRect: (nodeId: string) => StableRect | null; getActivityWorldRect: (nodeId: string) => StableRect | null;
getLogWorldRect: (nodeId: string) => StableRect | null;
getExtraWorldBounds: () => WorldBounds[]; getExtraWorldBounds: () => WorldBounds[];
} }
@ -86,6 +87,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
const dragOwnerPositionsRef = useRef(new Map<string, { x: number; y: number }>()); const dragOwnerPositionsRef = useRef(new Map<string, { x: number; y: number }>());
const launchAnchorPositionsRef = 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 activityRectByNodeIdRef = useRef(new Map<string, StableRect>());
const logRectByNodeIdRef = useRef(new Map<string, StableRect>());
const extraWorldBoundsRef = useRef<WorldBounds[]>([]); const extraWorldBoundsRef = useRef<WorldBounds[]>([]);
const prevNodeIdsRef = useRef(new Set<string>()); const prevNodeIdsRef = useRef(new Set<string>());
@ -112,6 +114,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
dragOwnerPositionsRef, dragOwnerPositionsRef,
launchAnchorPositionsRef, launchAnchorPositionsRef,
activityRectByNodeIdRef, activityRectByNodeIdRef,
logRectByNodeIdRef,
extraWorldBoundsRef, extraWorldBoundsRef,
}); });
return; return;
@ -132,6 +135,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
dragOwnerPositionsRef, dragOwnerPositionsRef,
launchAnchorPositionsRef, launchAnchorPositionsRef,
activityRectByNodeIdRef, activityRectByNodeIdRef,
logRectByNodeIdRef,
extraWorldBoundsRef, extraWorldBoundsRef,
fillMissingFallbackPositions: true, fillMissingFallbackPositions: true,
}); });
@ -144,6 +148,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
layoutSnapshotRef, layoutSnapshotRef,
launchAnchorPositionsRef, launchAnchorPositionsRef,
activityRectByNodeIdRef, activityRectByNodeIdRef,
logRectByNodeIdRef,
extraWorldBoundsRef, extraWorldBoundsRef,
}); });
}, []); }, []);
@ -264,6 +269,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
dragOwnerPositionsRef.current.clear(); dragOwnerPositionsRef.current.clear();
launchAnchorPositionsRef.current.clear(); launchAnchorPositionsRef.current.clear();
activityRectByNodeIdRef.current.clear(); activityRectByNodeIdRef.current.clear();
logRectByNodeIdRef.current.clear();
extraWorldBoundsRef.current = []; extraWorldBoundsRef.current = [];
layoutSnapshotRef.current = null; layoutSnapshotRef.current = null;
lastValidSnapshotByTeamRef.current.clear(); lastValidSnapshotByTeamRef.current.clear();
@ -283,6 +289,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
getLaunchAnchorWorldPosition: (leadNodeId: string) => getLaunchAnchorWorldPosition: (leadNodeId: string) =>
launchAnchorPositionsRef.current.get(leadNodeId) ?? null, launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null, getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
getLogWorldRect: (nodeId: string) => logRectByNodeIdRef.current.get(nodeId) ?? null,
getExtraWorldBounds: () => extraWorldBoundsRef.current, getExtraWorldBounds: () => extraWorldBoundsRef.current,
}), }),
[ [
@ -352,6 +359,7 @@ function commitSnapshotGeometry(args: {
dragOwnerPositionsRef: { current: ReadonlyMap<string, { x: number; y: number }> }; dragOwnerPositionsRef: { current: ReadonlyMap<string, { x: number; y: number }> };
launchAnchorPositionsRef: { current: Map<string, { x: number; y: number }> }; launchAnchorPositionsRef: { current: Map<string, { x: number; y: number }> };
activityRectByNodeIdRef: { current: Map<string, StableRect> }; activityRectByNodeIdRef: { current: Map<string, StableRect> };
logRectByNodeIdRef: { current: Map<string, StableRect> };
extraWorldBoundsRef: { current: WorldBounds[] }; extraWorldBoundsRef: { current: WorldBounds[] };
fillMissingFallbackPositions?: boolean; fillMissingFallbackPositions?: boolean;
}): void { }): void {
@ -364,6 +372,7 @@ function commitSnapshotGeometry(args: {
dragOwnerPositionsRef, dragOwnerPositionsRef,
launchAnchorPositionsRef, launchAnchorPositionsRef,
activityRectByNodeIdRef, activityRectByNodeIdRef,
logRectByNodeIdRef,
extraWorldBoundsRef, extraWorldBoundsRef,
fillMissingFallbackPositions = false, fillMissingFallbackPositions = false,
} = args; } = args;
@ -377,10 +386,12 @@ function commitSnapshotGeometry(args: {
launchAnchorPositionsRef.current.clear(); launchAnchorPositionsRef.current.clear();
activityRectByNodeIdRef.current.clear(); activityRectByNodeIdRef.current.clear();
logRectByNodeIdRef.current.clear();
extraWorldBoundsRef.current = snapshotToWorldBounds(snapshot); extraWorldBoundsRef.current = snapshotToWorldBounds(snapshot);
for (const frame of getTranslatedMemberFrames(snapshot, dragOwnerPositionsRef.current)) { for (const frame of getTranslatedMemberFrames(snapshot, dragOwnerPositionsRef.current)) {
activityRectByNodeIdRef.current.set(frame.ownerId, frame.activityColumnRect); activityRectByNodeIdRef.current.set(frame.ownerId, frame.activityColumnRect);
logRectByNodeIdRef.current.set(frame.ownerId, frame.logColumnRect);
} }
if (snapshot.leadNodeId) { if (snapshot.leadNodeId) {
@ -388,6 +399,7 @@ function commitSnapshotGeometry(args: {
snapshot.leadNodeId, snapshot.leadNodeId,
snapshot.leadSlotFrame.activityColumnRect snapshot.leadSlotFrame.activityColumnRect
); );
logRectByNodeIdRef.current.set(snapshot.leadNodeId, snapshot.leadSlotFrame.logColumnRect);
} }
} }
@ -396,6 +408,7 @@ function resetToFallbackLayout(args: {
layoutSnapshotRef: { current: StableSlotLayoutSnapshot | null }; layoutSnapshotRef: { current: StableSlotLayoutSnapshot | null };
launchAnchorPositionsRef: { current: Map<string, { x: number; y: number }> }; launchAnchorPositionsRef: { current: Map<string, { x: number; y: number }> };
activityRectByNodeIdRef: { current: Map<string, StableRect> }; activityRectByNodeIdRef: { current: Map<string, StableRect> };
logRectByNodeIdRef: { current: Map<string, StableRect> };
extraWorldBoundsRef: { current: WorldBounds[] }; extraWorldBoundsRef: { current: WorldBounds[] };
}): void { }): void {
const { const {
@ -403,12 +416,14 @@ function resetToFallbackLayout(args: {
layoutSnapshotRef, layoutSnapshotRef,
launchAnchorPositionsRef, launchAnchorPositionsRef,
activityRectByNodeIdRef, activityRectByNodeIdRef,
logRectByNodeIdRef,
extraWorldBoundsRef, extraWorldBoundsRef,
} = args; } = args;
layoutSnapshotRef.current = null; layoutSnapshotRef.current = null;
launchAnchorPositionsRef.current.clear(); launchAnchorPositionsRef.current.clear();
activityRectByNodeIdRef.current.clear(); activityRectByNodeIdRef.current.clear();
logRectByNodeIdRef.current.clear();
extraWorldBoundsRef.current = []; extraWorldBoundsRef.current = [];
fallbackPositionNodes(nodes); fallbackPositionNodes(nodes);
KanbanLayoutEngine.layout(nodes); KanbanLayoutEngine.layout(nodes);

View file

@ -23,6 +23,8 @@ export interface OwnerFootprint {
radialDepth: number; radialDepth: number;
activityColumnWidth: number; activityColumnWidth: number;
activityColumnHeight: number; activityColumnHeight: number;
logColumnWidth: number;
logColumnHeight: number;
processBandWidth: number; processBandWidth: number;
kanbanBandWidth: number; kanbanBandWidth: number;
kanbanBandHeight: number; kanbanBandHeight: number;
@ -42,6 +44,7 @@ export interface SlotFrame {
ownerY: number; ownerY: number;
boardBandRect: StableRect; boardBandRect: StableRect;
activityColumnRect: StableRect; activityColumnRect: StableRect;
logColumnRect: StableRect;
processBandRect: StableRect; processBandRect: StableRect;
kanbanBandRect: StableRect; kanbanBandRect: StableRect;
taskColumnCount: number; taskColumnCount: number;
@ -108,6 +111,11 @@ const SLOT_GEOMETRY = {
ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight + ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight +
ACTIVITY_LANE.overflowHeight, ACTIVITY_LANE.overflowHeight,
activityColumnWidth: ACTIVITY_LANE.width, activityColumnWidth: ACTIVITY_LANE.width,
logColumnHeight:
ACTIVITY_LANE.headerHeight +
ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight +
ACTIVITY_LANE.overflowHeight,
logColumnWidth: 260,
ownerToProcessGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, ownerToProcessGap: STABLE_SLOT_GEOMETRY.slotVerticalGap,
processToBoardGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, processToBoardGap: STABLE_SLOT_GEOMETRY.slotVerticalGap,
boardColumnGap: 24, boardColumnGap: 24,
@ -231,6 +239,7 @@ function buildCentralCollisionRects(args: {
args.leadCoreRect, args.leadCoreRect,
args.leadSlotFrame.processBandRect, args.leadSlotFrame.processBandRect,
args.leadSlotFrame.activityColumnRect, args.leadSlotFrame.activityColumnRect,
args.leadSlotFrame.logColumnRect,
args.leadSlotFrame.kanbanBandRect, args.leadSlotFrame.kanbanBandRect,
]; ];
if (args.unassignedTaskRect) { if (args.unassignedTaskRect) {
@ -247,6 +256,7 @@ function buildLeadCentralReservedBlock(args: {
args.leadCoreRect, args.leadCoreRect,
args.leadSlotFrame.processBandRect, args.leadSlotFrame.processBandRect,
args.leadSlotFrame.activityColumnRect, args.leadSlotFrame.activityColumnRect,
args.leadSlotFrame.logColumnRect,
args.leadSlotFrame.kanbanBandRect, args.leadSlotFrame.kanbanBandRect,
]); ]);
} }
@ -270,6 +280,7 @@ export function computeOwnerFootprints(
): OwnerFootprint[] { ): OwnerFootprint[] {
const ownerNodes = nodes.filter((node) => node.kind === 'member'); const ownerNodes = nodes.filter((node) => node.kind === 'member');
const showActivity = layout?.showActivity ?? true; const showActivity = layout?.showActivity ?? true;
const showLogs = layout?.showLogs ?? showActivity;
const ownerNodeById = new Map(ownerNodes.map((node) => [node.id, node] as const)); const ownerNodeById = new Map(ownerNodes.map((node) => [node.id, node] as const));
const taskColumnsByOwnerId = new Map<string, Set<string>>(); const taskColumnsByOwnerId = new Map<string, Set<string>>();
const processCountByOwnerId = new Map<string, number>(); const processCountByOwnerId = new Map<string, number>();
@ -304,6 +315,7 @@ export function computeOwnerFootprints(
taskColumnCount: taskColumnsByOwnerId.get(ownerId)?.size ?? 0, taskColumnCount: taskColumnsByOwnerId.get(ownerId)?.size ?? 0,
processCount: processCountByOwnerId.get(ownerId) ?? 0, processCount: processCountByOwnerId.get(ownerId) ?? 0,
showActivity, showActivity,
showLogs,
}), }),
]; ];
}); });
@ -331,6 +343,7 @@ function computeOwnerFootprintForOwnerId(
taskColumnCount: taskColumns.size, taskColumnCount: taskColumns.size,
processCount, processCount,
showActivity: layout?.showActivity ?? true, showActivity: layout?.showActivity ?? true,
showLogs: layout?.showLogs ?? layout?.showActivity ?? true,
}); });
} }
@ -339,17 +352,28 @@ function buildOwnerFootprint(args: {
taskColumnCount: number; taskColumnCount: number;
processCount: number; processCount: number;
showActivity: boolean; showActivity: boolean;
showLogs: boolean;
}): OwnerFootprint { }): OwnerFootprint {
const activityColumnWidth = args.showActivity ? SLOT_GEOMETRY.activityColumnWidth : 0; const activityColumnWidth = args.showActivity ? SLOT_GEOMETRY.activityColumnWidth : 0;
const activityColumnHeight = args.showActivity ? SLOT_GEOMETRY.activityColumnHeight : 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 = const kanbanBandWidth =
args.taskColumnCount <= 1 args.taskColumnCount <= 1
? TASK_PILL.width ? TASK_PILL.width
: TASK_PILL.width + (args.taskColumnCount - 1) * KANBAN_ZONE.columnWidth; : TASK_PILL.width + (args.taskColumnCount - 1) * KANBAN_ZONE.columnWidth;
const processBandWidth = computeProcessBandWidth(args.processCount); const processBandWidth = computeProcessBandWidth(args.processCount);
const boardBandWidth = activityColumnWidth + activityToKanbanGap + kanbanBandWidth; const boardBandWidth =
const boardBandHeight = Math.max(activityColumnHeight, SLOT_GEOMETRY.kanbanBandHeight); activityColumnWidth + activityToLogGap + logColumnWidth + feedToKanbanGap + kanbanBandWidth;
const boardBandHeight = Math.max(
activityColumnHeight,
logColumnHeight,
SLOT_GEOMETRY.kanbanBandHeight
);
const innerContentWidth = Math.max(SLOT_GEOMETRY.ownerMinWidth, processBandWidth, boardBandWidth); const innerContentWidth = Math.max(SLOT_GEOMETRY.ownerMinWidth, processBandWidth, boardBandWidth);
const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2; const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2;
const slotHeight = const slotHeight =
@ -377,6 +401,8 @@ function buildOwnerFootprint(args: {
radialDepth, radialDepth,
activityColumnWidth, activityColumnWidth,
activityColumnHeight, activityColumnHeight,
logColumnWidth,
logColumnHeight,
processBandWidth, processBandWidth,
kanbanBandWidth, kanbanBandWidth,
kanbanBandHeight: SLOT_GEOMETRY.kanbanBandHeight, kanbanBandHeight: SLOT_GEOMETRY.kanbanBandHeight,
@ -651,6 +677,7 @@ function validateStaticSnapshotRects(
['leadSlotFrame.bounds', snapshot.leadSlotFrame.bounds], ['leadSlotFrame.bounds', snapshot.leadSlotFrame.bounds],
['leadSlotFrame.boardBandRect', snapshot.leadSlotFrame.boardBandRect], ['leadSlotFrame.boardBandRect', snapshot.leadSlotFrame.boardBandRect],
['leadSlotFrame.activityColumnRect', snapshot.leadSlotFrame.activityColumnRect], ['leadSlotFrame.activityColumnRect', snapshot.leadSlotFrame.activityColumnRect],
['leadSlotFrame.logColumnRect', snapshot.leadSlotFrame.logColumnRect],
['leadSlotFrame.processBandRect', snapshot.leadSlotFrame.processBandRect], ['leadSlotFrame.processBandRect', snapshot.leadSlotFrame.processBandRect],
['leadSlotFrame.kanbanBandRect', snapshot.leadSlotFrame.kanbanBandRect], ['leadSlotFrame.kanbanBandRect', snapshot.leadSlotFrame.kanbanBandRect],
['leadActivityRect', snapshot.leadActivityRect], ['leadActivityRect', snapshot.leadActivityRect],
@ -697,6 +724,9 @@ function validateLeadSnapshotRects(
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) { if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) {
return { valid: false, reason: 'leadActivityRect must fit inside leadCentralReservedBlock' }; 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 ( if (
!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.processBandRect) !rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.processBandRect)
) { ) {
@ -795,6 +825,9 @@ function validateSlotFrameGeometry(
if (!rectContainsRect(frame.bounds, frame.activityColumnRect)) { if (!rectContainsRect(frame.bounds, frame.activityColumnRect)) {
return { valid: false, reason: `activityColumnRect escapes ${label}` }; 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)) { if (!rectContainsRect(frame.bounds, frame.processBandRect)) {
return { valid: false, reason: `processBandRect escapes ${label}` }; return { valid: false, reason: `processBandRect escapes ${label}` };
} }
@ -807,6 +840,12 @@ function validateSlotFrameGeometry(
reason: `activityColumnRect escapes boardBandRect in ${label}`, 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)) { if (!rectContainsRect(frame.boardBandRect, frame.kanbanBandRect)) {
return { return {
valid: false, valid: false,
@ -819,6 +858,18 @@ function validateSlotFrameGeometry(
reason: `activityColumnRect overlaps kanbanBandRect in ${label}`, 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)) { if (!pointInRect(frame.ownerX, frame.ownerY, frame.bounds)) {
return { valid: false, reason: `owner anchor escapes ${label}` }; 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, ownerY: frame.ownerY + dy,
boardBandRect: translateRect(frame.boardBandRect, dx, dy), boardBandRect: translateRect(frame.boardBandRect, dx, dy),
activityColumnRect: translateRect(frame.activityColumnRect, dx, dy), activityColumnRect: translateRect(frame.activityColumnRect, dx, dy),
logColumnRect: translateRect(frame.logColumnRect, dx, dy),
processBandRect: translateRect(frame.processBandRect, dx, dy), processBandRect: translateRect(frame.processBandRect, dx, dy),
kanbanBandRect: translateRect(frame.kanbanBandRect, dx, dy), kanbanBandRect: translateRect(frame.kanbanBandRect, dx, dy),
}; };
@ -1296,9 +1348,22 @@ function buildSlotFrameAtOwnerAnchor(
footprint.activityColumnWidth, footprint.activityColumnWidth,
footprint.activityColumnHeight 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( const kanbanBandRect = createRect(
activityColumnRect.right + activityToKanbanGap, logColumnRect.right + feedToKanbanGap,
boardBandRect.top, boardBandRect.top,
footprint.kanbanBandWidth, footprint.kanbanBandWidth,
footprint.kanbanBandHeight footprint.kanbanBandHeight
@ -1314,6 +1379,7 @@ function buildSlotFrameAtOwnerAnchor(
ownerY, ownerY,
boardBandRect, boardBandRect,
activityColumnRect, activityColumnRect,
logColumnRect,
processBandRect, processBandRect,
kanbanBandRect, kanbanBandRect,
taskColumnCount: footprint.taskColumnCount, taskColumnCount: footprint.taskColumnCount,

View file

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

View file

@ -82,6 +82,7 @@ export interface GraphViewProps {
leadNodeId: string leadNodeId: string
) => { x: number; y: number; scale: number; visible: boolean } | null; ) => { x: number; y: number; scale: number; visible: boolean } | null;
getActivityWorldRect: (ownerNodeId: string) => StableRect | null; getActivityWorldRect: (ownerNodeId: string) => StableRect | null;
getLogWorldRect: (ownerNodeId: string) => StableRect | null;
getTransientHandoffSnapshot: (options?: { getTransientHandoffSnapshot: (options?: {
focusNodeIds?: ReadonlySet<string> | null; focusNodeIds?: ReadonlySet<string> | null;
focusEdgeIds?: ReadonlySet<string> | null; focusEdgeIds?: ReadonlySet<string> | null;
@ -137,6 +138,7 @@ export function GraphView({
? { ? {
...data.layout, ...data.layout,
showActivity: filters.showActivity, showActivity: filters.showActivity,
showLogs: filters.showActivity,
} }
: data.layout, : data.layout,
[data.layout, filters.showActivity] [data.layout, filters.showActivity]
@ -295,6 +297,10 @@ export function GraphView({
(ownerNodeId: string) => simulationRef.current.getActivityWorldRect(ownerNodeId), (ownerNodeId: string) => simulationRef.current.getActivityWorldRect(ownerNodeId),
[] []
); );
const getLogWorldRect = useCallback(
(ownerNodeId: string) => simulationRef.current.getLogWorldRect(ownerNodeId),
[]
);
const getTransientHandoffSnapshot = useCallback( const getTransientHandoffSnapshot = useCallback(
(options?: { (options?: {
focusNodeIds?: ReadonlySet<string> | null; focusNodeIds?: ReadonlySet<string> | null;
@ -1092,6 +1098,7 @@ export function GraphView({
filters, filters,
getLaunchAnchorScreenPlacement, getLaunchAnchorScreenPlacement,
getActivityWorldRect, getActivityWorldRect,
getLogWorldRect,
getTransientHandoffSnapshot, getTransientHandoffSnapshot,
getCameraZoom, getCameraZoom,
worldToScreen: camera.worldToScreen, 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 { GraphActivityHud } from './GraphActivityHud';
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover'; import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
import { GraphMemberLogPreviewHud } from './GraphMemberLogPreviewHud';
import { GraphNodePopover } from './GraphNodePopover'; import { GraphNodePopover } from './GraphNodePopover';
import { GraphProvisioningHud } from './GraphProvisioningHud'; import { GraphProvisioningHud } from './GraphProvisioningHud';
import { GraphTransientHandoffHud } from './GraphTransientHandoffHud'; import { GraphTransientHandoffHud } from './GraphTransientHandoffHud';
@ -148,6 +149,14 @@ export const TeamGraphOverlay = ({
width: number; width: number;
height: number; height: number;
} | null; } | null;
getLogWorldRect?: (ownerNodeId: string) => {
left: number;
top: number;
right: number;
bottom: number;
width: number;
height: number;
} | null;
getCameraZoom?: () => number; getCameraZoom?: () => number;
getTransientHandoffSnapshot?: (options?: { getTransientHandoffSnapshot?: (options?: {
focusNodeIds?: ReadonlySet<string> | null; focusNodeIds?: ReadonlySet<string> | null;
@ -186,6 +195,17 @@ export const TeamGraphOverlay = ({
onOpenTaskDetail={onOpenTaskDetail} onOpenTaskDetail={onOpenTaskDetail}
onOpenMemberProfile={onOpenMemberProfile} 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 { GraphActivityHud } from './GraphActivityHud';
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover'; import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
import { GraphMemberLogPreviewHud } from './GraphMemberLogPreviewHud';
import { GraphNodePopover } from './GraphNodePopover'; import { GraphNodePopover } from './GraphNodePopover';
import { GraphProvisioningHud } from './GraphProvisioningHud'; import { GraphProvisioningHud } from './GraphProvisioningHud';
import { GraphTransientHandoffHud } from './GraphTransientHandoffHud'; import { GraphTransientHandoffHud } from './GraphTransientHandoffHud';
@ -168,6 +169,14 @@ export const TeamGraphTab = ({
width: number; width: number;
height: number; height: number;
} | null; } | null;
getLogWorldRect?: (ownerNodeId: string) => {
left: number;
top: number;
right: number;
bottom: number;
width: number;
height: number;
} | null;
getCameraZoom?: () => number; getCameraZoom?: () => number;
getTransientHandoffSnapshot?: (options?: { getTransientHandoffSnapshot?: (options?: {
focusNodeIds?: ReadonlySet<string> | null; focusNodeIds?: ReadonlySet<string> | null;
@ -207,6 +216,17 @@ export const TeamGraphTab = ({
onOpenTaskDetail={dispatchOpenTask} onOpenTaskDetail={dispatchOpenTask}
onOpenMemberProfile={dispatchOpenProfile} 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

@ -0,0 +1,20 @@
import type {
MemberLogPreviewRequestOptions,
MemberLogPreviewResponse,
MemberLogStreamRequestOptions,
MemberLogStreamResponse,
} from './dto';
export interface MemberLogStreamApi {
getMemberLogStream(
teamName: string,
memberName: string,
options?: MemberLogStreamRequestOptions
): Promise<MemberLogStreamResponse>;
getMemberLogPreviews(
teamName: string,
memberNames: string[],
options?: MemberLogPreviewRequestOptions
): Promise<MemberLogPreviewResponse>;
setMemberLogStreamTracking(teamName: string, enabled: boolean): Promise<void>;
}

View file

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

@ -0,0 +1,112 @@
import type { BoardTaskLogParticipant, BoardTaskLogSegment } from '@shared/types';
export type MemberLogStreamProvider =
| 'claude_transcript'
| 'opencode_runtime'
| 'codex_native_trace';
export type MemberLogStreamSource =
| 'member_transcript'
| 'member_mixed_runtime'
| 'member_runtime_only'
| 'member_empty';
export interface MemberLogStreamRequestOptions {
limitSegments?: number;
since?: string;
laneId?: string;
forceRefresh?: boolean;
}
export interface MemberLogPreviewRequestOptions {
maxItemsPerMember?: number;
textLimit?: number;
laneIdsByMember?: Record<string, string>;
forceRefresh?: boolean;
}
export interface MemberLogStreamCoverage {
provider: MemberLogStreamProvider;
status: 'included' | 'partial' | 'skipped';
reason?: string;
}
export interface MemberLogStreamWarning {
code:
| 'opencode_ambiguous_lane'
| 'opencode_missing_runtime_session'
| 'opencode_runtime_unavailable'
| 'opencode_runtime_timeout'
| 'codex_member_wide_not_supported'
| 'large_log_window_limited'
| 'segment_message_window_limited'
| 'message_content_limited'
| 'unreadable_transcript_file';
message: string;
}
export interface MemberLogStreamMetadata {
scannedTranscriptFileCount: number;
includedTranscriptFileCount: number;
droppedSegmentCount: number;
droppedChunkCount: number;
droppedMessageCount: number;
}
export interface MemberLogStreamSegmentSource {
provider: MemberLogStreamProvider;
label: string;
sessionId?: string;
laneId?: string;
messageCount?: number;
truncated?: boolean;
}
export interface MemberLogStreamSegment extends BoardTaskLogSegment {
source: MemberLogStreamSegmentSource;
}
export interface MemberLogStreamResponse {
participants: BoardTaskLogParticipant[];
defaultFilter: string;
segments: MemberLogStreamSegment[];
source: MemberLogStreamSource;
coverage: MemberLogStreamCoverage[];
warnings: MemberLogStreamWarning[];
truncated: boolean;
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

@ -0,0 +1,4 @@
export type * from './api';
export * from './channels';
export type * from './dto';
export * from './normalize';

View file

@ -0,0 +1,93 @@
import type {
MemberLogPreviewMember,
MemberLogPreviewResponse,
MemberLogStreamResponse,
} from './dto';
export function createEmptyMemberLogStreamResponse(
generatedAt = new Date().toISOString()
): MemberLogStreamResponse {
return {
participants: [],
defaultFilter: 'all',
segments: [],
source: 'member_empty',
coverage: [],
warnings: [],
truncated: false,
generatedAt,
metadata: {
scannedTranscriptFileCount: 0,
includedTranscriptFileCount: 0,
droppedSegmentCount: 0,
droppedChunkCount: 0,
droppedMessageCount: 0,
},
};
}
export function normalizeMemberLogStreamResponse(
response: MemberLogStreamResponse | null | undefined
): MemberLogStreamResponse {
if (!response) {
return createEmptyMemberLogStreamResponse();
}
return {
...createEmptyMemberLogStreamResponse(response.generatedAt),
...response,
participants: Array.isArray(response.participants) ? response.participants : [],
segments: Array.isArray(response.segments) ? response.segments : [],
coverage: Array.isArray(response.coverage) ? response.coverage : [],
warnings: Array.isArray(response.warnings) ? response.warnings : [],
metadata: {
...createEmptyMemberLogStreamResponse(response.generatedAt).metadata,
...(response.metadata ?? {}),
},
};
}
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,3 @@
export interface ClockPort {
now(): number;
}

View file

@ -0,0 +1,5 @@
export interface LoggerPort {
debug?(message: string, ...args: unknown[]): void;
warn(message: string, ...args: unknown[]): void;
error(message: string, ...args: unknown[]): void;
}

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,40 @@
import type {
MemberLogStreamCoverage,
MemberLogStreamProvider,
MemberLogStreamSegment,
MemberLogStreamWarning,
} from '../../../contracts';
import type { MemberLogStreamBudget } from '../../domain/models/MemberLogStreamBudget';
import type { BoardTaskLogParticipant } from '@shared/types';
export interface MemberLogStreamSourceInput {
teamName: string;
memberName: string;
laneId?: string;
budget: MemberLogStreamBudget;
sinceMs?: number | null;
forceRefresh?: boolean;
}
export interface MemberLogStreamSourceMetadata {
scannedTranscriptFileCount?: number;
includedTranscriptFileCount?: number;
droppedSegmentCount?: number;
droppedChunkCount?: number;
droppedMessageCount?: number;
}
export interface MemberLogStreamSourceResult {
provider: MemberLogStreamProvider;
status: MemberLogStreamCoverage['status'];
reason?: string;
participants: BoardTaskLogParticipant[];
segments: MemberLogStreamSegment[];
warnings: MemberLogStreamWarning[];
metadata?: MemberLogStreamSourceMetadata;
}
export interface MemberLogStreamSource {
readonly provider: MemberLogStreamProvider;
load(input: MemberLogStreamSourceInput): Promise<MemberLogStreamSourceResult>;
}

View file

@ -0,0 +1,3 @@
export interface MemberLogStreamTrackingPort {
setTracking(teamName: string, enabled: boolean): Promise<void>;
}

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,144 @@
import { createEmptyMemberLogStreamResponse } from '../../../contracts';
import {
clampMemberLogStreamSegmentLimit,
DEFAULT_MEMBER_LOG_STREAM_BUDGET,
} from '../../domain/models/MemberLogStreamBudget';
import { buildMemberLogStreamResponse } from '../../domain/policies/memberLogStreamMergePolicy';
import type { MemberLogStreamResponse } from '../../../contracts';
import type { MemberLogStreamBudget } from '../../domain/models/MemberLogStreamBudget';
import type { ClockPort } from '../ports/ClockPort';
import type { LoggerPort } from '../ports/LoggerPort';
import type {
MemberLogStreamSource,
MemberLogStreamSourceResult,
} from '../ports/MemberLogStreamSource';
export interface GetMemberLogStreamInput {
teamName: string;
memberName: string;
limitSegments?: number;
sinceMs?: number | null;
laneId?: string;
forceRefresh?: boolean;
}
interface GetMemberLogStreamUseCaseDeps {
sources: readonly MemberLogStreamSource[];
clock: ClockPort;
logger: LoggerPort;
budget?: Partial<MemberLogStreamBudget>;
}
function stableInputKey(input: GetMemberLogStreamInput, limitSegments: number): string {
return JSON.stringify([
input.teamName,
input.memberName,
limitSegments,
input.sinceMs ?? null,
input.laneId ?? '',
input.forceRefresh === true,
]);
}
export class GetMemberLogStreamUseCase {
private readonly budget: MemberLogStreamBudget;
private readonly inFlight = new Map<string, Promise<MemberLogStreamResponse>>();
constructor(private readonly deps: GetMemberLogStreamUseCaseDeps) {
this.budget = { ...DEFAULT_MEMBER_LOG_STREAM_BUDGET, ...(deps.budget ?? {}) };
}
async execute(input: GetMemberLogStreamInput): Promise<MemberLogStreamResponse> {
const limitSegments = clampMemberLogStreamSegmentLimit(input.limitSegments, this.budget);
const key = stableInputKey(input, limitSegments);
const existing = this.inFlight.get(key);
if (existing) {
return existing;
}
const promise = this.buildResponse(input, limitSegments).finally(() => {
this.inFlight.delete(key);
});
this.inFlight.set(key, promise);
return promise;
}
private async buildResponse(
input: GetMemberLogStreamInput,
limitSegments: number
): Promise<MemberLogStreamResponse> {
if (this.deps.sources.length === 0) {
return createEmptyMemberLogStreamResponse(new Date(this.deps.clock.now()).toISOString());
}
const sourceInput = {
teamName: input.teamName,
memberName: input.memberName,
laneId: input.laneId,
budget: this.budget,
sinceMs: input.sinceMs,
forceRefresh: input.forceRefresh,
};
const settled = await Promise.all(
this.deps.sources.map(async (source): Promise<MemberLogStreamSourceResult> => {
try {
return await source.load(sourceInput);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.deps.logger.warn(
`Member log stream source ${source.provider} failed for ${input.teamName}/${input.memberName}: ${message}`
);
return {
provider: source.provider,
status: 'skipped',
reason: message,
participants: [],
segments: [],
warnings: [
{
code:
source.provider === 'opencode_runtime'
? 'opencode_runtime_unavailable'
: 'unreadable_transcript_file',
message,
},
],
};
}
})
);
const metadata = {
scannedTranscriptFileCount: 0,
includedTranscriptFileCount: 0,
droppedSegmentCount: 0,
droppedChunkCount: 0,
droppedMessageCount: 0,
};
for (const result of settled) {
metadata.scannedTranscriptFileCount += result.metadata?.scannedTranscriptFileCount ?? 0;
metadata.includedTranscriptFileCount += result.metadata?.includedTranscriptFileCount ?? 0;
metadata.droppedSegmentCount += result.metadata?.droppedSegmentCount ?? 0;
metadata.droppedChunkCount += result.metadata?.droppedChunkCount ?? 0;
metadata.droppedMessageCount += result.metadata?.droppedMessageCount ?? 0;
}
return buildMemberLogStreamResponse({
participants: settled.flatMap((result) => result.participants),
segments: settled.flatMap((result) => result.segments),
coverage: settled.map((result) => ({
provider: result.provider,
status: result.status,
...(result.reason ? { reason: result.reason } : {}),
})),
warnings: settled.flatMap((result) => result.warnings),
generatedAt: new Date(this.deps.clock.now()).toISOString(),
budget: this.budget,
limitSegments,
metadata,
});
}
}

View file

@ -0,0 +1,9 @@
import type { MemberLogStreamTrackingPort } from '../ports/MemberLogStreamTrackingPort';
export class SetMemberLogStreamTrackingUseCase {
constructor(private readonly tracking: MemberLogStreamTrackingPort) {}
async execute(teamName: string, enabled: boolean): Promise<void> {
await this.tracking.setTracking(teamName, enabled);
}
}

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,145 @@
import { describe, expect, it, vi } from 'vitest';
import { GetMemberLogStreamUseCase } from '../GetMemberLogStreamUseCase';
import type { MemberLogStreamSegment } from '../../../../contracts';
import type {
MemberLogStreamSource,
MemberLogStreamSourceResult,
} from '../../ports/MemberLogStreamSource';
import type { BoardTaskLogParticipant } from '@shared/types';
const generatedAt = Date.parse('2026-02-01T00:00:00.000Z');
const participant: BoardTaskLogParticipant = {
key: 'member:alice',
label: 'alice',
role: 'member',
isLead: false,
isSidechain: false,
};
function segment(id: string): MemberLogStreamSegment {
return {
id,
participantKey: participant.key,
actor: {
memberName: 'alice',
role: 'member',
sessionId: `session-${id}`,
isSidechain: false,
},
startTimestamp: '2026-02-01T00:00:00.000Z',
endTimestamp: '2026-02-01T00:00:00.000Z',
chunks: [],
source: {
provider: 'claude_transcript',
label: 'Claude transcript',
sessionId: `session-${id}`,
},
};
}
function includedResult(id: string): MemberLogStreamSourceResult {
return {
provider: 'claude_transcript',
status: 'included',
participants: [participant],
segments: [segment(id)],
warnings: [],
metadata: {
scannedTranscriptFileCount: 1,
includedTranscriptFileCount: 1,
},
};
}
describe('GetMemberLogStreamUseCase', () => {
it('keeps the stream fail-soft when one source throws', async () => {
const logger = { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() };
const useCase = new GetMemberLogStreamUseCase({
sources: [
{
provider: 'claude_transcript',
load: vi.fn().mockResolvedValue(includedResult('ok')),
},
{
provider: 'opencode_runtime',
load: vi.fn().mockRejectedValue(new Error('runtime down')),
},
],
clock: { now: () => generatedAt },
logger,
});
const response = await useCase.execute({
teamName: 'alpha-team',
memberName: 'alice',
});
expect(response.segments.map((item) => item.id)).toEqual(['ok']);
expect(response.coverage).toEqual([
{ provider: 'claude_transcript', status: 'included' },
{ provider: 'opencode_runtime', status: 'skipped', reason: 'runtime down' },
]);
expect(response.warnings).toEqual([
{ code: 'opencode_runtime_unavailable', message: 'runtime down' },
]);
expect(response.generatedAt).toBe('2026-02-01T00:00:00.000Z');
expect(logger.warn).toHaveBeenCalledWith(
'Member log stream source opencode_runtime failed for alpha-team/alice: runtime down'
);
});
it('joins identical in-flight requests and releases the key after completion', async () => {
const resolveLoad: ((value: MemberLogStreamSourceResult) => void)[] = [];
const load = vi.fn(
() =>
new Promise<MemberLogStreamSourceResult>((resolve) => {
resolveLoad.push(resolve);
})
);
const source: MemberLogStreamSource = {
provider: 'claude_transcript',
load,
};
const useCase = new GetMemberLogStreamUseCase({
sources: [source],
clock: { now: () => generatedAt },
logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
});
const first = useCase.execute({
teamName: 'alpha-team',
memberName: 'alice',
limitSegments: 5,
forceRefresh: true,
});
const second = useCase.execute({
teamName: 'alpha-team',
memberName: 'alice',
limitSegments: 5,
forceRefresh: true,
});
expect(load).toHaveBeenCalledTimes(1);
resolveLoad[0]?.(includedResult('joined'));
const [firstResponse, secondResponse] = await Promise.all([first, second]);
expect(firstResponse.segments.map((item) => item.id)).toEqual(['joined']);
expect(secondResponse.segments.map((item) => item.id)).toEqual(['joined']);
const third = useCase.execute({
teamName: 'alpha-team',
memberName: 'alice',
limitSegments: 5,
forceRefresh: true,
});
expect(load).toHaveBeenCalledTimes(2);
resolveLoad[1]?.(includedResult('after-release'));
await expect(third).resolves.toMatchObject({
segments: [{ id: 'after-release' } as MemberLogStreamSegment],
});
});
});

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,35 @@
export interface MemberLogStreamBudget {
maxTranscriptFiles: number;
maxSegments: number;
maxChunks: number;
maxSourceMessages: number;
maxMessagesPerSegment: number;
maxTotalContentChars: number;
maxMessageContentChars: number;
maxToolResultContentChars: number;
openCodeMessageLimit: number;
openCodeTimeoutMs: number;
}
export const DEFAULT_MEMBER_LOG_STREAM_BUDGET: MemberLogStreamBudget = {
maxTranscriptFiles: 40,
maxSegments: 30,
maxChunks: 250,
maxSourceMessages: 1200,
maxMessagesPerSegment: 300,
maxTotalContentChars: 800_000,
maxMessageContentChars: 80_000,
maxToolResultContentChars: 120_000,
openCodeMessageLimit: 400,
openCodeTimeoutMs: 5_000,
};
export function clampMemberLogStreamSegmentLimit(
requested: number | undefined,
budget: MemberLogStreamBudget = DEFAULT_MEMBER_LOG_STREAM_BUDGET
): number {
if (typeof requested !== 'number' || !Number.isFinite(requested)) {
return budget.maxSegments;
}
return Math.max(1, Math.min(80, Math.floor(requested), budget.maxSegments));
}

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

View file

@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_MEMBER_LOG_STREAM_BUDGET } from '../../models/MemberLogStreamBudget';
import { buildMemberLogStreamResponse } from '../memberLogStreamMergePolicy';
import type { MemberLogStreamSegment } from '../../../../contracts';
import type { BoardTaskLogParticipant } from '@shared/types';
const participant: BoardTaskLogParticipant = {
key: 'member:alice',
label: 'alice',
role: 'member',
isLead: false,
isSidechain: false,
};
function segment(
id: string,
timestamp: string,
provider: MemberLogStreamSegment['source']['provider'] = 'claude_transcript'
): MemberLogStreamSegment {
return {
id,
participantKey: participant.key,
actor: {
memberName: 'alice',
role: 'member',
sessionId: `session-${id}`,
isSidechain: false,
},
startTimestamp: timestamp,
endTimestamp: timestamp,
chunks: [],
source: {
provider,
label: provider,
sessionId: `session-${id}`,
},
};
}
describe('buildMemberLogStreamResponse', () => {
it('sorts segments chronologically, keeps the recent limit, and marks bounded windows as truncated', () => {
const response = buildMemberLogStreamResponse({
participants: [participant, participant],
segments: [
segment('newest', '2026-01-01T00:03:00.000Z'),
segment('oldest', '2026-01-01T00:01:00.000Z'),
segment('middle', '2026-01-01T00:02:00.000Z'),
],
coverage: [
{ provider: 'codex_native_trace', status: 'skipped' },
{ provider: 'claude_transcript', status: 'included' },
],
warnings: [],
generatedAt: '2026-01-01T00:04:00.000Z',
budget: DEFAULT_MEMBER_LOG_STREAM_BUDGET,
limitSegments: 2,
metadata: {
scannedTranscriptFileCount: 3,
includedTranscriptFileCount: 3,
droppedSegmentCount: 0,
droppedChunkCount: 0,
droppedMessageCount: 0,
},
});
expect(response.segments.map((item) => item.id)).toEqual(['middle', 'newest']);
expect(response.participants).toEqual([participant]);
expect(response.coverage.map((item) => item.provider)).toEqual([
'claude_transcript',
'codex_native_trace',
]);
expect(response.truncated).toBe(true);
expect(response.metadata.droppedSegmentCount).toBe(1);
expect(response.warnings).toEqual([
{
code: 'large_log_window_limited',
message: 'Showing a bounded recent member log stream to keep the popup responsive.',
},
]);
});
it('classifies mixed transcript and runtime streams without relying on coverage-only data', () => {
const mixed = buildMemberLogStreamResponse({
participants: [participant],
segments: [
segment('claude', '2026-01-01T00:01:00.000Z', 'claude_transcript'),
segment('opencode', '2026-01-01T00:02:00.000Z', 'opencode_runtime'),
],
coverage: [
{ provider: 'claude_transcript', status: 'included' },
{ provider: 'opencode_runtime', status: 'included' },
{ provider: 'codex_native_trace', status: 'skipped' },
],
warnings: [],
generatedAt: '2026-01-01T00:03:00.000Z',
budget: DEFAULT_MEMBER_LOG_STREAM_BUDGET,
limitSegments: 10,
metadata: {
scannedTranscriptFileCount: 1,
includedTranscriptFileCount: 1,
droppedSegmentCount: 0,
droppedChunkCount: 0,
droppedMessageCount: 0,
},
});
expect(mixed.source).toBe('member_mixed_runtime');
});
});

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

@ -0,0 +1,147 @@
import type {
MemberLogStreamCoverage,
MemberLogStreamProvider,
MemberLogStreamResponse,
MemberLogStreamSegment,
MemberLogStreamSource,
MemberLogStreamWarning,
} from '../../../contracts';
import type { MemberLogStreamBudget } from '../models/MemberLogStreamBudget';
import type { BoardTaskLogParticipant } from '@shared/types';
export const MEMBER_LOG_STREAM_PROVIDER_ORDER: readonly MemberLogStreamProvider[] = [
'claude_transcript',
'opencode_runtime',
'codex_native_trace',
];
function getSegmentStartMs(segment: MemberLogStreamSegment): number {
const parsed = Date.parse(segment.startTimestamp);
return Number.isFinite(parsed) ? parsed : 0;
}
function dedupeParticipants(
participants: readonly BoardTaskLogParticipant[]
): BoardTaskLogParticipant[] {
const deduped = new Map<string, BoardTaskLogParticipant>();
for (const participant of participants) {
if (!deduped.has(participant.key)) {
deduped.set(participant.key, participant);
}
}
return [...deduped.values()];
}
export function inferMemberLogStreamSource(
segments: readonly MemberLogStreamSegment[]
): MemberLogStreamSource {
if (segments.length === 0) {
return 'member_empty';
}
const hasTranscript = segments.some((segment) => segment.source.provider === 'claude_transcript');
const hasRuntime = segments.some((segment) => segment.source.provider === 'opencode_runtime');
if (hasTranscript && hasRuntime) {
return 'member_mixed_runtime';
}
if (hasRuntime) {
return 'member_runtime_only';
}
return 'member_transcript';
}
export function buildMemberLogStreamResponse(input: {
participants: readonly BoardTaskLogParticipant[];
segments: readonly MemberLogStreamSegment[];
coverage: readonly MemberLogStreamCoverage[];
warnings: readonly MemberLogStreamWarning[];
generatedAt: string;
budget: MemberLogStreamBudget;
limitSegments: number;
metadata: {
scannedTranscriptFileCount: number;
includedTranscriptFileCount: number;
droppedSegmentCount: number;
droppedChunkCount: number;
droppedMessageCount: number;
};
}): MemberLogStreamResponse {
const warnings = [...input.warnings];
const sorted = [...input.segments].sort((left, right) => {
const byTime = getSegmentStartMs(left) - getSegmentStartMs(right);
return byTime !== 0 ? byTime : left.id.localeCompare(right.id);
});
let droppedSegmentCount = input.metadata.droppedSegmentCount;
let droppedChunkCount = input.metadata.droppedChunkCount;
let limitedSegments = sorted;
const maxSegments = Math.min(input.limitSegments, input.budget.maxSegments);
if (limitedSegments.length > maxSegments) {
droppedSegmentCount += limitedSegments.length - maxSegments;
limitedSegments = limitedSegments.slice(-maxSegments);
}
const totalChunks = limitedSegments.reduce((sum, segment) => sum + segment.chunks.length, 0);
if (totalChunks > input.budget.maxChunks) {
const retained: MemberLogStreamSegment[] = [];
let remaining = input.budget.maxChunks;
for (const segment of [...limitedSegments].reverse()) {
if (remaining <= 0) {
droppedSegmentCount += 1;
continue;
}
if (segment.chunks.length <= remaining) {
retained.push(segment);
remaining -= segment.chunks.length;
continue;
}
const keptChunks = segment.chunks.slice(-remaining);
droppedChunkCount += segment.chunks.length - keptChunks.length;
retained.push({
...segment,
chunks: keptChunks,
source: { ...segment.source, truncated: true },
});
remaining = 0;
}
const retainedInDisplayOrder = [...retained].reverse();
limitedSegments = retainedInDisplayOrder;
}
const truncated =
droppedSegmentCount > input.metadata.droppedSegmentCount ||
droppedChunkCount > input.metadata.droppedChunkCount ||
input.metadata.droppedMessageCount > 0 ||
limitedSegments.some((segment) => segment.source.truncated);
if (truncated && !warnings.some((warning) => warning.code === 'large_log_window_limited')) {
warnings.push({
code: 'large_log_window_limited',
message: 'Showing a bounded recent member log stream to keep the popup responsive.',
});
}
const participants = dedupeParticipants(input.participants);
return {
participants,
defaultFilter: participants.length === 1 ? (participants[0]?.key ?? 'all') : 'all',
segments: limitedSegments,
source: inferMemberLogStreamSource(limitedSegments),
coverage: [...input.coverage].sort(
(left, right) =>
MEMBER_LOG_STREAM_PROVIDER_ORDER.indexOf(left.provider) -
MEMBER_LOG_STREAM_PROVIDER_ORDER.indexOf(right.provider)
),
warnings,
truncated,
generatedAt: input.generatedAt,
metadata: {
scannedTranscriptFileCount: input.metadata.scannedTranscriptFileCount,
includedTranscriptFileCount: input.metadata.includedTranscriptFileCount,
droppedSegmentCount,
droppedChunkCount,
droppedMessageCount: input.metadata.droppedMessageCount,
},
};
}

View file

@ -0,0 +1,274 @@
import { describe, expect, it, vi } from 'vitest';
import {
MEMBER_LOG_STREAM_GET,
MEMBER_LOG_STREAM_GET_PREVIEWS,
MEMBER_LOG_STREAM_SET_TRACKING,
} from '../../../../../contracts';
import {
registerMemberLogStreamIpc,
removeMemberLogStreamIpc,
} from '../registerMemberLogStreamIpc';
import type { MemberLogPreviewResponse, MemberLogStreamResponse } from '../../../../../contracts';
import type { MemberLogStreamFeatureFacade } from '../../../../composition/createMemberLogStreamFeature';
import type { IpcMainInvokeEvent } from 'electron';
vi.mock('@shared/utils/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
function emptyResponse(): MemberLogStreamResponse {
return {
participants: [],
defaultFilter: 'all',
segments: [],
source: 'member_empty',
coverage: [],
warnings: [],
truncated: false,
generatedAt: '2026-03-01T00:00:00.000Z',
metadata: {
scannedTranscriptFileCount: 0,
includedTranscriptFileCount: 0,
droppedSegmentCount: 0,
droppedChunkCount: 0,
droppedMessageCount: 0,
},
};
}
function emptyPreviewResponse(): MemberLogPreviewResponse {
return {
members: [],
generatedAt: '2026-03-01T00:00:00.000Z',
};
}
function createFakeIpcMain(): {
handlers: Map<string, (...args: unknown[]) => unknown>;
ipcMain: {
handle: ReturnType<typeof vi.fn>;
removeHandler: ReturnType<typeof vi.fn>;
};
} {
const handlers = new Map<string, (...args: unknown[]) => unknown>();
return {
handlers,
ipcMain: {
handle: vi.fn((channel: string, handler: (...args: unknown[]) => unknown) => {
handlers.set(channel, handler);
}),
removeHandler: vi.fn((channel: string) => {
handlers.delete(channel);
}),
},
};
}
describe('registerMemberLogStreamIpc', () => {
it('validates and normalizes getMemberLogStream options before calling the feature facade', async () => {
const { handlers, ipcMain } = createFakeIpcMain();
const getMemberLogStream = vi.fn().mockResolvedValue(emptyResponse());
const feature: MemberLogStreamFeatureFacade = {
getMemberLogStream,
getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()),
setMemberLogStreamTracking: vi.fn(),
};
registerMemberLogStreamIpc(ipcMain as never, feature);
const result = await handlers.get(MEMBER_LOG_STREAM_GET)?.(
{} as IpcMainInvokeEvent,
'alpha-team',
'alice',
{
limitSegments: 200,
since: '2026-03-01T12:34:56.000Z',
laneId: ' secondary:opencode:alice ',
forceRefresh: true,
}
);
expect(result).toEqual({ success: true, data: emptyResponse() });
expect(getMemberLogStream).toHaveBeenCalledWith({
teamName: 'alpha-team',
memberName: 'alice',
limitSegments: 80,
sinceMs: Date.parse('2026-03-01T12:34:56.000Z'),
laneId: 'secondary:opencode:alice',
forceRefresh: true,
});
});
it('rejects unknown options and unsafe runtime lane ids', async () => {
const { handlers, ipcMain } = createFakeIpcMain();
const getMemberLogStream = vi.fn().mockResolvedValue(emptyResponse());
const feature: MemberLogStreamFeatureFacade = {
getMemberLogStream,
getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()),
setMemberLogStreamTracking: vi.fn(),
};
registerMemberLogStreamIpc(ipcMain as never, feature);
const get = handlers.get(MEMBER_LOG_STREAM_GET)!;
await expect(
get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { unknown: true })
).resolves.toEqual({
success: false,
error: 'Unknown getMemberLogStream option: unknown',
});
await expect(
get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { laneId: '../bad' })
).resolves.toEqual({
success: false,
error: 'laneId contains invalid characters',
});
expect(getMemberLogStream).not.toHaveBeenCalled();
});
it('accepts primary lane ids and rejects malformed optional values', async () => {
const { handlers, ipcMain } = createFakeIpcMain();
const getMemberLogStream = vi.fn().mockResolvedValue(emptyResponse());
const feature: MemberLogStreamFeatureFacade = {
getMemberLogStream,
getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()),
setMemberLogStreamTracking: vi.fn(),
};
registerMemberLogStreamIpc(ipcMain as never, feature);
const get = handlers.get(MEMBER_LOG_STREAM_GET)!;
await expect(
get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { laneId: 'primary' })
).resolves.toEqual({ success: true, data: emptyResponse() });
expect(getMemberLogStream).toHaveBeenCalledWith({
teamName: 'alpha-team',
memberName: 'alice',
laneId: 'primary',
});
getMemberLogStream.mockClear();
await expect(
get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { since: 'not-a-date' })
).resolves.toEqual({
success: false,
error: 'since must be a valid timestamp',
});
await expect(
get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { forceRefresh: 'true' })
).resolves.toEqual({
success: false,
error: 'forceRefresh must be a boolean',
});
await expect(
get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { laneId: 'bad\nlane' })
).resolves.toEqual({
success: false,
error: 'laneId contains invalid characters',
});
await expect(
get({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { laneId: 'x'.repeat(257) })
).resolves.toEqual({
success: false,
error: 'laneId exceeds max length (256)',
});
expect(getMemberLogStream).not.toHaveBeenCalled();
});
it('validates tracking calls and unregisters both handlers', async () => {
const { handlers, ipcMain } = createFakeIpcMain();
const setMemberLogStreamTracking = vi.fn().mockResolvedValue(undefined);
const feature: MemberLogStreamFeatureFacade = {
getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()),
getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()),
setMemberLogStreamTracking,
};
registerMemberLogStreamIpc(ipcMain as never, feature);
const setTracking = handlers.get(MEMBER_LOG_STREAM_SET_TRACKING)!;
await expect(setTracking({} as IpcMainInvokeEvent, 'alpha-team', true)).resolves.toEqual({
success: true,
});
await expect(setTracking({} as IpcMainInvokeEvent, 'alpha-team', 'yes')).resolves.toEqual({
success: false,
error: 'enabled must be a boolean',
});
expect(setMemberLogStreamTracking).toHaveBeenCalledWith('alpha-team', true);
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

@ -0,0 +1,333 @@
import { validateMemberName, validateTeamName } from '@main/ipc/guards';
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 {
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;
value?: T;
error?: string;
}
function validateOptionalRuntimeLaneId(value: unknown): ValidationResult<string | undefined> {
if (value == null) return { valid: true, value: undefined };
if (typeof value !== 'string') return { valid: false, error: 'laneId must be a string' };
const trimmed = value.trim();
if (!trimmed) return { valid: true, value: undefined };
if (trimmed.length > 256) return { valid: false, error: 'laneId exceeds max length (256)' };
if (
trimmed.includes('/') ||
trimmed.includes('\\') ||
[...trimmed].some((char) => {
const code = char.charCodeAt(0);
return code <= 31 || code === 127;
})
) {
return { valid: false, error: 'laneId contains invalid characters' };
}
return { valid: true, value: trimmed };
}
function normalizeOptions(options: unknown): ValidationResult<{
limitSegments?: number;
sinceMs?: number | null;
laneId?: 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_OPTION_KEYS.has(key)) {
return { valid: false, error: `Unknown getMemberLogStream option: ${key}` };
}
}
let limitSegments: number | undefined;
if (record.limitSegments != null) {
if (typeof record.limitSegments !== 'number' || !Number.isFinite(record.limitSegments)) {
return { valid: false, error: 'limitSegments must be a finite number' };
}
limitSegments = Math.max(1, Math.min(80, Math.floor(record.limitSegments)));
}
let sinceMs: number | null | undefined;
if (record.since != null) {
if (typeof record.since !== 'string') {
return { valid: false, error: 'since must be an ISO timestamp string' };
}
const parsed = Date.parse(record.since);
if (!Number.isFinite(parsed)) {
return { valid: false, error: 'since must be a valid timestamp' };
}
sinceMs = parsed;
}
const lane = validateOptionalRuntimeLaneId(record.laneId);
if (!lane.valid) {
return { valid: false, error: lane.error };
}
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: {
...(limitSegments !== undefined ? { limitSegments } : {}),
...(sinceMs !== undefined ? { sinceMs } : {}),
...(lane.value !== undefined ? { laneId: lane.value } : {}),
...(forceRefresh !== undefined ? { forceRefresh } : {}),
},
};
}
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
): void {
ipcMain.handle(
MEMBER_LOG_STREAM_GET,
async (
_event: IpcMainInvokeEvent,
teamName: unknown,
memberName: unknown,
options?: MemberLogStreamRequestOptions
): Promise<IpcResult<MemberLogStreamResponse>> => {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) {
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
}
const vMember = validateMemberName(memberName);
if (!vMember.valid) {
return { success: false, error: vMember.error ?? 'Invalid memberName' };
}
const vOptions = normalizeOptions(options);
if (!vOptions.valid) {
return { success: false, error: vOptions.error ?? 'Invalid options' };
}
try {
const response = await feature.getMemberLogStream({
teamName: vTeam.value!,
memberName: vMember.value!,
...vOptions.value!,
});
return { success: true, data: normalizeMemberLogStreamResponse(response) };
} catch (error) {
logger.error('Failed to load member log stream', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to load member log stream',
};
}
}
);
ipcMain.handle(
MEMBER_LOG_STREAM_SET_TRACKING,
async (
_event: IpcMainInvokeEvent,
teamName: unknown,
enabled: unknown
): Promise<IpcResult<void>> => {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) {
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
}
if (typeof enabled !== 'boolean') {
return { success: false, error: 'enabled must be a boolean' };
}
try {
await feature.setMemberLogStreamTracking(vTeam.value!, enabled);
return { success: true };
} catch (error) {
logger.error('Failed to update member log stream tracking', error);
return {
success: false,
error:
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',
};
}
}
);
}
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

@ -0,0 +1,168 @@
import { applyMemberLogMessageBudget } from '../../../infrastructure/memberLogMessageBudget';
import {
buildMemberActor,
buildMemberParticipant,
buildSegmentId,
dedupeMemberLogRefs,
shortHash,
withSegmentSource,
} from './memberLogStreamSourceUtils';
import type { MemberLogStreamWarning } from '../../../../contracts';
import type { LoggerPort } from '../../../../core/application/ports/LoggerPort';
import type {
MemberLogStreamSource,
MemberLogStreamSourceInput,
MemberLogStreamSourceResult,
} 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 { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder';
import type { ParsedMessage } from '@main/types';
function filterSourceMessageBudget(
messages: readonly ParsedMessage[],
remaining: number
): { messages: ParsedMessage[]; dropped: number; limited: boolean } {
if (remaining <= 0) {
return { messages: [], dropped: messages.length, limited: messages.length > 0 };
}
if (messages.length <= remaining) {
return { messages: [...messages], dropped: 0, limited: false };
}
return {
messages: messages.slice(-remaining),
dropped: messages.length - remaining,
limited: true,
};
}
export class ClaudeMemberTranscriptStreamSource implements MemberLogStreamSource {
readonly provider = 'claude_transcript' as const;
constructor(
private readonly logsFinder: TeamMemberLogsFinder,
private readonly parser: BoardTaskExactLogStrictParser,
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder,
private readonly logger: LoggerPort
) {}
async load(input: MemberLogStreamSourceInput): Promise<MemberLogStreamSourceResult> {
const warnings: MemberLogStreamWarning[] = [];
const refs = await this.logsFinder.findRecentMemberLogFileRefsByMember(
input.teamName,
[input.memberName],
{
mtimeSinceMs: input.sinceMs ?? null,
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: `Showing ${cappedRefs.length} recent transcript files for this member.`,
});
}
const parsedByPath = await this.parser.parseFiles(cappedRefs.map((ref) => ref.filePath));
const participant = buildMemberParticipant(input.memberName);
const segments = [];
let remainingSourceMessages = input.budget.maxSourceMessages;
let includedTranscriptFileCount = 0;
let droppedMessageCount = 0;
let contentLimited = false;
let windowLimited = false;
for (const ref of cappedRefs) {
const parsedMessages = parsedByPath.get(ref.filePath) ?? [];
if (parsedMessages.length === 0) continue;
const sourceBudgeted = filterSourceMessageBudget(parsedMessages, remainingSourceMessages);
remainingSourceMessages -= sourceBudgeted.messages.length;
droppedMessageCount += sourceBudgeted.dropped;
windowLimited = windowLimited || sourceBudgeted.limited;
const budgeted = applyMemberLogMessageBudget(sourceBudgeted.messages, input.budget);
droppedMessageCount += budgeted.droppedMessageCount;
contentLimited = contentLimited || budgeted.contentLimited;
windowLimited = windowLimited || budgeted.segmentWindowLimited;
if (budgeted.messages.length === 0) continue;
const chunks = this.chunkBuilder.buildBundleChunks(budgeted.messages);
if (chunks.length === 0) continue;
const first = budgeted.messages[0];
const last = budgeted.messages[budgeted.messages.length - 1];
if (!first || !last) continue;
includedTranscriptFileCount += 1;
const role = ref.kind === 'lead_session' ? 'lead' : 'member';
segments.push(
withSegmentSource(
{
id: buildSegmentId({
provider: this.provider,
teamName: input.teamName,
memberName: input.memberName,
sessionId: ref.sessionId,
fingerprint: shortHash(`${ref.filePath}:${ref.mtimeMs}:${ref.sizeBytes ?? ''}`),
startTimestamp: first.timestamp.toISOString(),
}),
participantKey: participant.key,
actor: buildMemberActor({
memberName: input.memberName,
sessionId: ref.sessionId,
role,
}),
startTimestamp: first.timestamp.toISOString(),
endTimestamp: last.timestamp.toISOString(),
chunks,
},
{
provider: this.provider,
label: role === 'lead' ? 'Claude lead transcript' : 'Claude transcript',
sessionId: ref.sessionId,
messageCount: budgeted.messages.length,
truncated: budgeted.droppedMessageCount > 0 || budgeted.contentLimited,
}
)
);
}
if (windowLimited) {
warnings.push({
code: 'segment_message_window_limited',
message: 'Some transcript sessions were trimmed to recent messages.',
});
}
if (contentLimited) {
warnings.push({
code: 'message_content_limited',
message: 'Some large message content was truncated before rendering.',
});
}
this.logger.debug?.(
`Claude member log stream ${input.teamName}/${input.memberName}: refs=${refs.length}, segments=${segments.length}`
);
return {
provider: this.provider,
status: segments.length > 0 ? 'included' : 'skipped',
reason: segments.length > 0 ? undefined : 'no_member_transcripts',
participants: segments.length > 0 ? [participant] : [],
segments,
warnings,
metadata: {
scannedTranscriptFileCount: refs.length,
includedTranscriptFileCount,
droppedSegmentCount: droppedRefCount,
droppedMessageCount,
},
};
}
}

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,41 @@
import { isLeadMember } from '@shared/utils/leadDetection';
import type {
MemberLogStreamSource,
MemberLogStreamSourceInput,
MemberLogStreamSourceResult,
} from '../../../../core/application/ports/MemberLogStreamSource';
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
export class CodexNativeMemberTraceStreamSource implements MemberLogStreamSource {
readonly provider = 'codex_native_trace' as const;
constructor(private readonly configReader: TeamConfigReader) {}
async load(input: MemberLogStreamSourceInput): Promise<MemberLogStreamSourceResult> {
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',
participants: [],
segments: [],
warnings: isCodexMember
? [
{
code: 'codex_member_wide_not_supported',
message: 'Codex member-wide native trace is not available in this variant yet.',
},
]
: [],
};
}
}

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

@ -0,0 +1,249 @@
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { mapOpenCodeRuntimeTranscriptMessagesToParsedMessages } from '@main/services/team/taskLogs/stream/OpenCodeRuntimeProjectionMapper';
import { applyMemberLogMessageBudget } from '../../../infrastructure/memberLogMessageBudget';
import {
buildMemberActor,
buildMemberParticipant,
buildSegmentId,
normalizeMemberName,
withSegmentSource,
} from './memberLogStreamSourceUtils';
import type { MemberLogStreamWarning } from '../../../../contracts';
import type {
MemberLogStreamSource,
MemberLogStreamSourceInput,
MemberLogStreamSourceResult,
} from '../../../../core/application/ports/MemberLogStreamSource';
import type { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService';
import type { BoardTaskExactLogChunkBuilder } from '@main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder';
interface BinaryResolverLike {
resolve(): Promise<string | null>;
}
const CACHE_TTL_MS = 1_500;
function classifyOpenCodeError(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 transcript timed out; showing other member logs only.',
};
}
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 transcript is unavailable: ${message}`,
};
}
export class OpenCodeMemberRuntimeStreamSource implements MemberLogStreamSource {
readonly provider = 'opencode_runtime' as const;
private readonly cache = new Map<
string,
{ expiresAt: number; result: MemberLogStreamSourceResult }
>();
private readonly inFlight = new Map<string, Promise<MemberLogStreamSourceResult>>();
constructor(
private readonly runtimeBridge: ClaudeMultimodelBridgeService,
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder,
private readonly binaryResolver: BinaryResolverLike = ClaudeBinaryResolver
) {}
async load(input: MemberLogStreamSourceInput): Promise<MemberLogStreamSourceResult> {
const cacheKey = [
input.teamName,
normalizeMemberName(input.memberName),
input.laneId ?? '',
input.budget.openCodeMessageLimit,
].join('::');
if (!input.forceRefresh) {
const cached = this.cache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.result;
}
}
const existing = this.inFlight.get(cacheKey);
if (existing) {
return existing;
}
const promise = this.buildResult(input)
.then((result) => {
this.cache.set(cacheKey, { expiresAt: Date.now() + CACHE_TTL_MS, result });
return result;
})
.finally(() => {
this.inFlight.delete(cacheKey);
});
this.inFlight.set(cacheKey, promise);
return promise;
}
private async buildResult(
input: MemberLogStreamSourceInput
): Promise<MemberLogStreamSourceResult> {
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());
if (parsedMessages.length === 0) {
return {
provider: this.provider,
status: 'skipped',
reason: 'opencode_missing_runtime_session',
participants: [],
segments: [],
warnings: [],
};
}
const budgeted = applyMemberLogMessageBudget(parsedMessages, input.budget);
if (budgeted.messages.length === 0) {
return {
provider: this.provider,
status: 'skipped',
reason: 'opencode_no_renderable_chunks',
participants: [],
segments: [],
warnings: [],
};
}
const chunks = this.chunkBuilder.buildBundleChunks(budgeted.messages);
if (chunks.length === 0) {
return {
provider: this.provider,
status: 'skipped',
reason: 'opencode_no_renderable_chunks',
participants: [],
segments: [],
warnings: [],
};
}
const first = budgeted.messages[0];
const last = budgeted.messages[budgeted.messages.length - 1];
if (!first || !last) {
return this.skipped(
'opencode_missing_runtime_session',
'OpenCode runtime projection was empty.'
);
}
const participant = buildMemberParticipant(input.memberName);
const sessionId =
transcript?.sessionId ??
first.sessionId ??
`opencode:${normalizeMemberName(input.memberName)}`;
const segment = withSegmentSource(
{
id: buildSegmentId({
provider: this.provider,
teamName: input.teamName,
memberName: input.memberName,
sessionId,
fingerprint: `${sessionId}:${input.laneId ?? ''}:${budgeted.messages.length}`,
startTimestamp: first.timestamp.toISOString(),
}),
participantKey: participant.key,
actor: buildMemberActor({
memberName: input.memberName,
sessionId,
role: 'member',
}),
startTimestamp: first.timestamp.toISOString(),
endTimestamp: last.timestamp.toISOString(),
chunks,
},
{
provider: this.provider,
label: 'OpenCode runtime',
sessionId,
...(input.laneId ? { laneId: input.laneId } : {}),
messageCount: budgeted.messages.length,
truncated:
budgeted.droppedMessageCount > 0 ||
budgeted.segmentWindowLimited ||
budgeted.contentLimited,
}
);
const warnings: MemberLogStreamWarning[] = [];
if (budgeted.segmentWindowLimited) {
warnings.push({
code: 'segment_message_window_limited',
message: 'OpenCode runtime stream was trimmed to recent messages.',
});
}
if (budgeted.contentLimited) {
warnings.push({
code: 'message_content_limited',
message: 'Some large OpenCode runtime content was truncated before rendering.',
});
}
return {
provider: this.provider,
status: 'included',
participants: [participant],
segments: [segment],
warnings,
metadata: {
droppedMessageCount: budgeted.droppedMessageCount,
},
};
} catch (error) {
const warning = classifyOpenCodeError(error);
return this.skipped(warning.code, warning.message, warning);
}
}
private skipped(
code: MemberLogStreamWarning['code'],
reason: string,
warning: MemberLogStreamWarning = { code, message: reason }
): MemberLogStreamSourceResult {
return {
provider: this.provider,
status: 'skipped',
reason,
participants: [],
segments: [],
warnings: [warning],
};
}
}

View file

@ -0,0 +1,441 @@
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';
function parsedMessage(uuid: string, timestamp: string): ParsedMessage {
return {
uuid,
parentUuid: null,
type: 'assistant',
timestamp: new Date(timestamp),
role: 'assistant',
content: `message ${uuid}`,
isSidechain: true,
isMeta: false,
sessionId: 'session-1',
toolCalls: [],
toolResults: [],
};
}
function fakeChunk(id: string): EnhancedChunk {
return {
id,
chunkType: 'ai',
startTime: new Date('2026-04-04T00:00:00.000Z'),
endTime: new Date('2026-04-04T00:00:01.000Z'),
durationMs: 1_000,
metrics: {
durationMs: 1_000,
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
messageCount: 1,
},
responses: [],
processes: [],
sidechainMessages: [],
toolExecutions: [],
semanticSteps: [],
rawMessages: [],
};
}
function sourceInput(
overrides: Partial<MemberLogStreamSourceInput> = {}
): MemberLogStreamSourceInput {
return {
teamName: 'alpha-team',
memberName: 'alice',
budget: DEFAULT_MEMBER_LOG_STREAM_BUDGET,
...overrides,
};
}
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[]) => {
const parsed = new Map<string, ParsedMessage[]>();
parsed.set('/transcripts/larger.jsonl', [
parsedMessage('msg-1', '2026-04-04T00:00:00.000Z'),
parsedMessage('msg-2', '2026-04-04T00:01:00.000Z'),
]);
expect(paths).toEqual(['/transcripts/larger.jsonl']);
return parsed;
});
const chunkBuilder = {
buildBundleChunks: vi.fn(() => [fakeChunk('chunk-1')]),
};
const source = new ClaudeMemberTranscriptStreamSource(
{
findRecentMemberLogFileRefsByMember: vi.fn().mockResolvedValue([
{
memberName: 'alice',
sessionId: 'session-1',
filePath: '/transcripts/smaller.jsonl',
mtimeMs: 10,
sizeBytes: 1_000,
messageCount: 1,
kind: 'subagent',
},
{
memberName: 'alice',
sessionId: 'session-1',
filePath: '/transcripts/larger.jsonl',
mtimeMs: 20,
sizeBytes: 5_000,
messageCount: 10,
kind: 'subagent',
},
]),
} as never,
{ parseFiles } as never,
chunkBuilder as never,
{ warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
);
const result = await source.load(sourceInput());
expect(result.status).toBe('included');
expect(parseFiles).toHaveBeenCalledWith(['/transcripts/larger.jsonl']);
expect(result.segments).toHaveLength(1);
expect(result.segments[0]?.id).not.toContain('/transcripts');
expect(result.segments[0]?.source).toMatchObject({
provider: 'claude_transcript',
sessionId: 'session-1',
messageCount: 2,
});
});
});
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({
sessionId: 'opencode-session',
logProjection: {
messages: [0, 1, 2].map((index) => ({
uuid: `opencode-${index}`,
parentUuid: index === 0 ? null : `opencode-${index - 1}`,
type: 'assistant',
timestamp: `2026-04-04T00:00:0${index}.000Z`,
role: 'assistant',
content: `long OpenCode runtime message ${index} ${'x'.repeat(80)}`,
toolCalls: [],
toolResults: [],
isMeta: false,
sessionId: 'opencode-session',
})),
},
});
const buildBundleChunks = vi.fn((_: ParsedMessage[]) => [fakeChunk('opencode-budgeted-chunk')]);
const source = new OpenCodeMemberRuntimeStreamSource(
{ getOpenCodeTranscript } as never,
{ buildBundleChunks } as never,
{ resolve: vi.fn().mockResolvedValue('/mock/orchestrator') }
);
const result = await source.load(
sourceInput({
budget: {
...DEFAULT_MEMBER_LOG_STREAM_BUDGET,
maxMessagesPerSegment: 2,
maxTotalContentChars: 60,
maxMessageContentChars: 40,
},
})
);
expect(result.status).toBe('included');
expect(result.metadata?.droppedMessageCount).toBe(1);
expect(result.warnings.map((warning) => warning.code)).toEqual(
expect.arrayContaining(['segment_message_window_limited', 'message_content_limited'])
);
expect(result.segments[0]?.source).toMatchObject({
provider: 'opencode_runtime',
messageCount: 2,
truncated: true,
});
expect(buildBundleChunks).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ uuid: 'opencode-1' }),
expect.objectContaining({ uuid: 'opencode-2' }),
])
);
expect(JSON.stringify(buildBundleChunks.mock.calls[0]?.[0])).toContain(
'[content truncated by member log stream budget]'
);
});
it('joins active bridge calls, uses TTL cache, and lets forceRefresh bypass completed cache only', 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: 'hello',
toolCalls: [],
toolResults: [],
isMeta: false,
sessionId: 'opencode-session',
},
],
},
});
const source = new OpenCodeMemberRuntimeStreamSource(
{ getOpenCodeTranscript } as never,
{ buildBundleChunks: vi.fn(() => [fakeChunk('opencode-chunk')]) } as never,
{ resolve: vi.fn().mockResolvedValue('/mock/orchestrator') }
);
const input = sourceInput({ laneId: 'secondary:opencode:alice' });
const [first, second] = await Promise.all([source.load(input), source.load(input)]);
expect(first.status).toBe('included');
expect(second.status).toBe('included');
expect(getOpenCodeTranscript).toHaveBeenCalledTimes(1);
await source.load(input);
expect(getOpenCodeTranscript).toHaveBeenCalledTimes(1);
await source.load({ ...input, forceRefresh: true });
expect(getOpenCodeTranscript).toHaveBeenCalledTimes(2);
expect(getOpenCodeTranscript).toHaveBeenLastCalledWith(
'/mock/orchestrator',
expect.objectContaining({
teamId: 'alpha-team',
memberName: 'alice',
laneId: 'secondary:opencode:alice',
timeoutMs: DEFAULT_MEMBER_LOG_STREAM_BUDGET.openCodeTimeoutMs,
})
);
});
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')),
} as never,
{ buildBundleChunks: vi.fn(() => [fakeChunk('opencode-chunk')]) } as never,
{ resolve: vi.fn().mockResolvedValue('/mock/orchestrator') }
);
const result = await source.load(sourceInput());
expect(result).toMatchObject({
provider: 'opencode_runtime',
status: 'skipped',
warnings: [
{
code: 'opencode_ambiguous_lane',
message: 'OpenCode runtime session is ambiguous without a safe lane id.',
},
],
});
});
});
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({
getConfig: vi.fn().mockResolvedValue({
members: [{ name: 'alice', providerId: 'codex' }],
}),
} as never);
const nonCodexSource = new CodexNativeMemberTraceStreamSource({
getConfig: vi.fn().mockResolvedValue({
members: [{ name: 'alice', providerId: 'opencode' }],
}),
} as never);
await expect(codexSource.load(sourceInput())).resolves.toMatchObject({
status: 'skipped',
warnings: [{ code: 'codex_member_wide_not_supported' }],
});
await expect(nonCodexSource.load(sourceInput())).resolves.toMatchObject({
status: 'skipped',
warnings: [],
});
});
});
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

@ -0,0 +1,116 @@
import { createHash } from 'crypto';
import type { MemberLogStreamProvider, MemberLogStreamSegmentSource } from '../../../../contracts';
import type { MemberLogFileRef } from '@main/services/team/TeamMemberLogsFinder';
import type {
BoardTaskLogActor,
BoardTaskLogParticipant,
BoardTaskLogSegment,
} from '@shared/types';
export function normalizeMemberName(value: string): string {
return value.trim().toLowerCase();
}
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'
): BoardTaskLogParticipant {
const isLead = role === 'lead';
return {
key: `member:${normalizeMemberName(memberName)}`,
label: memberName,
role,
isLead,
isSidechain: !isLead,
};
}
export function buildMemberActor(input: {
memberName: string;
sessionId: string;
role?: 'member' | 'lead';
}): BoardTaskLogActor {
const role = input.role ?? 'member';
return {
memberName: input.memberName,
role,
sessionId: input.sessionId,
isSidechain: role !== 'lead',
};
}
export function shortHash(value: string): string {
return createHash('sha256').update(value).digest('hex').slice(0, 12);
}
export function buildSegmentId(input: {
provider: MemberLogStreamProvider;
teamName: string;
memberName: string;
sessionId: string;
fingerprint: string;
startTimestamp: string;
}): string {
return [
input.provider,
normalizeTeamName(input.teamName),
normalizeMemberName(input.memberName),
input.sessionId,
shortHash(`${input.fingerprint}:${input.startTimestamp}`),
].join(':');
}
export function withSegmentSource<T extends BoardTaskLogSegment>(
segment: T,
source: MemberLogStreamSegmentSource
): T & { source: MemberLogStreamSegmentSource } {
return { ...segment, source };
}

View file

@ -0,0 +1,101 @@
import { BoardTaskExactLogChunkBuilder } from '@main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder';
import { BoardTaskExactLogStrictParser } from '@main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser';
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
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 { 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';
import type { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder';
export interface MemberLogStreamFeatureFacade {
getMemberLogStream(input: GetMemberLogStreamInput): Promise<MemberLogStreamResponse>;
getMemberLogPreviews(input: GetMemberLogPreviewsInput): Promise<MemberLogPreviewResponse>;
setMemberLogStreamTracking(teamName: string, enabled: boolean): Promise<void>;
}
class TeamLogSourceTrackerMemberStreamPort implements MemberLogStreamTrackingPort {
constructor(private readonly tracker: TeamLogSourceTracker) {}
async setTracking(teamName: string, enabled: boolean): Promise<void> {
if (enabled) {
await this.tracker.enableTracking(teamName, 'member_log_stream');
return;
}
await this.tracker.disableTracking(teamName, 'member_log_stream');
}
}
export function createMemberLogStreamFeature(deps: {
logsFinder: TeamMemberLogsFinder;
logSourceTracker: TeamLogSourceTracker;
runtimeBridge: ClaudeMultimodelBridgeService;
configReader?: TeamConfigReader;
logger: LoggerPort;
}): MemberLogStreamFeatureFacade {
const chunkBuilder = new BoardTaskExactLogChunkBuilder();
const strictParser = new BoardTaskExactLogStrictParser();
const configReader = deps.configReader ?? new TeamConfigReader();
const sources = [
new ClaudeMemberTranscriptStreamSource(
deps.logsFinder,
strictParser,
chunkBuilder,
deps.logger
),
new OpenCodeMemberRuntimeStreamSource(deps.runtimeBridge, chunkBuilder),
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)
);
return {
getMemberLogStream: async (input) => {
if (!isMemberLogStreamReadEnabled()) {
return createEmptyMemberLogStreamResponse();
}
return getUseCase.execute(input);
},
getMemberLogPreviews: async (input) => {
if (!isMemberLogStreamReadEnabled()) {
return createEmptyMemberLogPreviewResponse();
}
return getPreviewsUseCase.execute(input);
},
setMemberLogStreamTracking: (teamName, enabled) => trackingUseCase.execute(teamName, enabled),
};
}

View file

@ -0,0 +1,18 @@
function readEnabledFlag(value: string | undefined, defaultValue: boolean): boolean {
if (value == null) {
return defaultValue;
}
const normalized = value.trim().toLowerCase();
if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') {
return false;
}
if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes') {
return true;
}
return defaultValue;
}
export function isMemberLogStreamReadEnabled(): boolean {
return readEnabledFlag(process.env.CLAUDE_TEAM_MEMBER_LOG_STREAM_READ_ENABLED, true);
}

View file

@ -0,0 +1,8 @@
export {
registerMemberLogStreamIpc,
removeMemberLogStreamIpc,
} from './adapters/input/ipc/registerMemberLogStreamIpc';
export {
createMemberLogStreamFeature,
type MemberLogStreamFeatureFacade,
} from './composition/createMemberLogStreamFeature';

View file

@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_MEMBER_LOG_STREAM_BUDGET } from '../../../core/domain/models/MemberLogStreamBudget';
import { applyMemberLogMessageBudget } from '../memberLogMessageBudget';
import type { MemberLogStreamBudget } from '../../../core/domain/models/MemberLogStreamBudget';
import type { ParsedMessage } from '@main/types';
function budget(overrides: Partial<MemberLogStreamBudget>): MemberLogStreamBudget {
return { ...DEFAULT_MEMBER_LOG_STREAM_BUDGET, ...overrides };
}
function message(overrides: Partial<ParsedMessage>): ParsedMessage {
return {
uuid: overrides.uuid ?? 'msg-1',
parentUuid: null,
type: 'assistant',
timestamp: new Date('2026-04-01T00:00:00.000Z'),
content: '',
isSidechain: true,
isMeta: false,
toolCalls: [],
toolResults: [],
...overrides,
};
}
describe('applyMemberLogMessageBudget', () => {
it('truncates oversized toolUseResult content, preserves ids, and reports content limiting', () => {
const result = applyMemberLogMessageBudget(
[
message({
type: 'user',
role: 'user',
isMeta: true,
sourceToolUseID: 'tool-1',
toolUseResult: {
toolUseId: 'tool-1',
content: 'x'.repeat(200),
stdout: 'y'.repeat(200),
},
}),
],
budget({
maxToolResultContentChars: 80,
maxTotalContentChars: 120,
})
);
const toolUseResult = result.messages[0]?.toolUseResult;
expect(result.contentLimited).toBe(true);
expect(toolUseResult?.toolUseId).toBe('tool-1');
expect(String(toolUseResult?.content)).toContain(
'[content truncated by member log stream budget]'
);
expect(String(toolUseResult?.stdout)).toContain(
'[content truncated by member log stream budget]'
);
});
it('drops orphan tool results after window trimming instead of rendering unpaired results', () => {
const result = applyMemberLogMessageBudget(
[
message({
uuid: 'assistant-1',
toolCalls: [{ id: 'tool-1', name: 'Bash', input: {}, isTask: false }],
}),
message({
uuid: 'result-1',
type: 'user',
role: 'user',
isMeta: true,
sourceToolUseID: 'tool-1',
toolResults: [{ toolUseId: 'tool-1', content: 'done', isError: false }],
}),
],
budget({ maxMessagesPerSegment: 1 })
);
expect(result.segmentWindowLimited).toBe(true);
expect(result.messages).toEqual([]);
expect(result.droppedMessageCount).toBe(2);
});
it('keeps JSON-looking output visible when it does not exceed the content budget', () => {
const result = applyMemberLogMessageBudget(
[message({ content: '{"status":"ok","value":42}' })],
budget({
maxMessageContentChars: 1_000,
maxTotalContentChars: 1_000,
})
);
expect(result.contentLimited).toBe(false);
expect(result.messages[0]?.content).toBe('{"status":"ok","value":42}');
});
});

View file

@ -0,0 +1,254 @@
import type { MemberLogStreamBudget } from '../../core/domain/models/MemberLogStreamBudget';
import type { ContentBlock, ParsedMessage, ToolResult, ToolUseResultData } from '@main/types';
export interface MessageBudgetResult {
messages: ParsedMessage[];
droppedMessageCount: number;
segmentWindowLimited: boolean;
contentLimited: boolean;
}
const CONTENT_LIMIT_SUFFIX = '\n\n[content truncated by member log stream budget]';
const TOOL_RESULT_ID_KEYS = new Set([
'id',
'toolUseId',
'tool_use_id',
'sourceToolUseID',
'sourceToolAssistantUUID',
'uuid',
'parentUuid',
]);
function truncateString(value: string, limit: number): { value: string; truncated: boolean } {
if (value.length <= limit) {
return { value, truncated: false };
}
const allowed = Math.max(0, limit - CONTENT_LIMIT_SUFFIX.length);
return { value: `${value.slice(0, allowed)}${CONTENT_LIMIT_SUFFIX}`, truncated: true };
}
function buildAssistantToolUseIds(messages: readonly ParsedMessage[]): Set<string> {
const ids = new Set<string>();
for (const message of messages) {
if (message.type !== 'assistant') {
continue;
}
for (const toolCall of message.toolCalls) {
ids.add(toolCall.id);
}
if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type === 'tool_use') {
ids.add(block.id);
}
}
}
}
return ids;
}
function dropOrphanToolResults(messages: readonly ParsedMessage[]): ParsedMessage[] {
const assistantToolUseIds = buildAssistantToolUseIds(messages);
return messages.filter((message) => {
if (!message.isMeta && message.toolResults.length === 0 && !message.sourceToolUseID) {
return true;
}
const toolUseIds = [
message.sourceToolUseID,
...message.toolResults.map((toolResult) => toolResult.toolUseId),
].filter((value): value is string => typeof value === 'string' && value.length > 0);
if (toolUseIds.length === 0) {
return true;
}
return toolUseIds.some((toolUseId) => assistantToolUseIds.has(toolUseId));
});
}
function trimMessageWindow(
messages: readonly ParsedMessage[],
maxMessages: number
): { messages: ParsedMessage[]; droppedMessageCount: number; limited: boolean } {
if (messages.length <= maxMessages) {
return { messages: [...messages], droppedMessageCount: 0, limited: false };
}
const sliced = messages.slice(-maxMessages);
const paired = dropOrphanToolResults(sliced);
return {
messages: paired,
droppedMessageCount: messages.length - paired.length,
limited: true,
};
}
function truncateContentBlock(
block: ContentBlock,
budget: MemberLogStreamBudget,
total: { remaining: number }
): { block: ContentBlock; truncated: boolean } {
if (total.remaining <= 0) {
if (block.type === 'text') {
return { block: { ...block, text: CONTENT_LIMIT_SUFFIX.trim() }, truncated: true };
}
if (block.type === 'thinking') {
return { block: { ...block, thinking: CONTENT_LIMIT_SUFFIX.trim() }, truncated: true };
}
if (block.type === 'tool_result') {
return { block: { ...block, content: CONTENT_LIMIT_SUFFIX.trim() }, truncated: true };
}
return { block, truncated: false };
}
if (block.type === 'text') {
const limit = Math.min(budget.maxMessageContentChars, total.remaining);
const truncated = truncateString(block.text, limit);
total.remaining -= truncated.value.length;
return { block: { ...block, text: truncated.value }, truncated: truncated.truncated };
}
if (block.type === 'thinking') {
const limit = Math.min(budget.maxMessageContentChars, total.remaining);
const truncated = truncateString(block.thinking, limit);
total.remaining -= truncated.value.length;
return { block: { ...block, thinking: truncated.value }, truncated: truncated.truncated };
}
if (block.type === 'tool_result') {
if (typeof block.content === 'string') {
const limit = Math.min(budget.maxToolResultContentChars, total.remaining);
const truncated = truncateString(block.content, limit);
total.remaining -= truncated.value.length;
return { block: { ...block, content: truncated.value }, truncated: truncated.truncated };
}
const nested = block.content.map((item) => truncateContentBlock(item, budget, total));
return {
block: { ...block, content: nested.map((item) => item.block) },
truncated: nested.some((item) => item.truncated),
};
}
return { block, truncated: false };
}
function truncateToolResult(
toolResult: ToolResult,
budget: MemberLogStreamBudget,
total: { remaining: number }
): { toolResult: ToolResult; truncated: boolean } {
if (typeof toolResult.content !== 'string') {
return { toolResult, truncated: false };
}
const limit = Math.min(budget.maxToolResultContentChars, Math.max(0, total.remaining));
const truncated = truncateString(toolResult.content, limit);
total.remaining -= truncated.value.length;
return {
toolResult: { ...toolResult, content: truncated.value },
truncated: truncated.truncated,
};
}
function truncateUnknownToolResultValue(
value: unknown,
budget: MemberLogStreamBudget,
total: { remaining: number },
key?: string
): { value: unknown; truncated: boolean } {
if (typeof value === 'string') {
if (key && TOOL_RESULT_ID_KEYS.has(key)) {
return { value, truncated: false };
}
const limit = Math.min(budget.maxToolResultContentChars, Math.max(0, total.remaining));
const truncated = truncateString(value, limit);
total.remaining = Math.max(0, total.remaining - truncated.value.length);
return { value: truncated.value, truncated: truncated.truncated };
}
if (Array.isArray(value)) {
let truncated = false;
const mapped = value.map((item) => {
const result = truncateUnknownToolResultValue(item, budget, total);
truncated = truncated || result.truncated;
return result.value;
});
return { value: mapped, truncated };
}
if (value && typeof value === 'object') {
let truncated = false;
const mapped: Record<string, unknown> = {};
for (const [childKey, childValue] of Object.entries(value)) {
const result = truncateUnknownToolResultValue(childValue, budget, total, childKey);
truncated = truncated || result.truncated;
mapped[childKey] = result.value;
}
return { value: mapped, truncated };
}
return { value, truncated: false };
}
function truncateToolUseResult(
toolUseResult: ToolUseResultData | undefined,
budget: MemberLogStreamBudget,
total: { remaining: number }
): { toolUseResult: ToolUseResultData | undefined; truncated: boolean } {
if (!toolUseResult) {
return { toolUseResult, truncated: false };
}
const result = truncateUnknownToolResultValue(toolUseResult, budget, total);
return {
toolUseResult: result.value as ToolUseResultData,
truncated: result.truncated,
};
}
function truncateMessageContent(
message: ParsedMessage,
budget: MemberLogStreamBudget,
total: { remaining: number }
): { message: ParsedMessage; truncated: boolean } {
let truncated = false;
let content: ParsedMessage['content'];
if (typeof message.content === 'string') {
const limit = Math.min(budget.maxMessageContentChars, Math.max(0, total.remaining));
const result = truncateString(message.content, limit);
total.remaining -= result.value.length;
truncated = result.truncated;
content = result.value;
} else {
const mapped = message.content.map((block) => truncateContentBlock(block, budget, total));
truncated = mapped.some((item) => item.truncated);
content = mapped.map((item) => item.block);
}
const toolResults = message.toolResults.map((toolResult) =>
truncateToolResult(toolResult, budget, total)
);
const toolUseResult = truncateToolUseResult(message.toolUseResult, budget, total);
return {
message: {
...message,
content,
toolResults: toolResults.map((item) => item.toolResult),
...(toolUseResult.toolUseResult ? { toolUseResult: toolUseResult.toolUseResult } : {}),
},
truncated: truncated || toolResults.some((item) => item.truncated) || toolUseResult.truncated,
};
}
export function applyMemberLogMessageBudget(
messages: readonly ParsedMessage[],
budget: MemberLogStreamBudget
): MessageBudgetResult {
const windowed = trimMessageWindow(messages, budget.maxMessagesPerSegment);
const total = { remaining: budget.maxTotalContentChars };
const truncated = windowed.messages.map((message) =>
truncateMessageContent(message, budget, total)
);
return {
messages: truncated.map((item) => item.message),
droppedMessageCount: windowed.droppedMessageCount,
segmentWindowLimited: windowed.limited,
contentLimited: truncated.some((item) => item.truncated),
};
}

View file

@ -0,0 +1,125 @@
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';
const mocks = vi.hoisted(() => ({
ipcRenderer: {
invoke: vi.fn(),
},
}));
vi.mock('electron', () => ({
ipcRenderer: mocks.ipcRenderer,
}));
describe('createMemberLogStreamBridge', () => {
beforeEach(() => {
mocks.ipcRenderer.invoke.mockReset();
});
it('forwards member log stream IPC requests and normalizes response payloads', async () => {
mocks.ipcRenderer.invoke.mockResolvedValueOnce({
success: true,
data: {
participants: [],
segments: [],
generatedAt: '2026-04-02T00:00:00.000Z',
},
});
const bridge = createMemberLogStreamBridge();
const response = await bridge.getMemberLogStream('alpha-team', 'alice', {
limitSegments: 30,
laneId: 'secondary:opencode:alice',
forceRefresh: true,
});
expect(response).toMatchObject({
participants: [],
segments: [],
source: 'member_empty',
generatedAt: '2026-04-02T00:00:00.000Z',
metadata: {
scannedTranscriptFileCount: 0,
includedTranscriptFileCount: 0,
droppedSegmentCount: 0,
droppedChunkCount: 0,
droppedMessageCount: 0,
},
});
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith(
MEMBER_LOG_STREAM_GET,
'alpha-team',
'alice',
{
limitSegments: 30,
laneId: 'secondary:opencode:alice',
forceRefresh: true,
}
);
});
it('forwards tracking calls and throws IPC errors', async () => {
mocks.ipcRenderer.invoke
.mockResolvedValueOnce({ success: true })
.mockResolvedValueOnce({ success: false, error: 'bad lane' });
const bridge = createMemberLogStreamBridge();
await expect(bridge.setMemberLogStreamTracking('alpha-team', true)).resolves.toBeUndefined();
await expect(bridge.getMemberLogStream('alpha-team', 'alice')).rejects.toThrow('bad lane');
expect(mocks.ipcRenderer.invoke).toHaveBeenNthCalledWith(
1,
MEMBER_LOG_STREAM_SET_TRACKING,
'alpha-team',
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

@ -0,0 +1,59 @@
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,
} from '../contracts';
import type { IpcResult } from '@shared/types';
async function invokeIpcWithResult<T>(channel: string, ...args: unknown[]): Promise<T> {
const result = (await ipcRenderer.invoke(channel, ...args)) as IpcResult<T>;
if (!result.success) {
throw new Error(result.error ?? 'Unknown error');
}
return result.data as T;
}
export function createMemberLogStreamBridge(): MemberLogStreamApi {
return {
getMemberLogStream: async (
teamName: string,
memberName: string,
options?: MemberLogStreamRequestOptions
): Promise<MemberLogStreamResponse> =>
normalizeMemberLogStreamResponse(
await invokeIpcWithResult<MemberLogStreamResponse>(
MEMBER_LOG_STREAM_GET,
teamName,
memberName,
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

@ -0,0 +1 @@
export { createMemberLogStreamBridge } from './createMemberLogStreamBridge';

View file

@ -0,0 +1,78 @@
import { useEffect, useMemo } from 'react';
import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { useMemberLogStream } from '../hooks/useMemberLogStream';
import { ExecutionLogStreamView } from '../ui/ExecutionLogStreamView';
import type { MemberLogStreamSegment } from '../../contracts';
import type { ResolvedTeamMember } from '@shared/types';
interface MemberLogStreamSectionProps {
teamName: string;
member: ResolvedTeamMember;
enabled?: boolean;
onInitialLoadErrorChange?: (hasError: boolean) => void;
}
function describeMemberStream(): string {
return 'Member-scoped transcript and runtime logs rendered with the same execution-log components used in Task Log Stream.';
}
function getSegmentMetaLabel(segment: MemberLogStreamSegment): string {
const details = [segment.source.label];
if (segment.source.laneId) {
details.push(`lane ${segment.source.laneId}`);
} else if (segment.source.sessionId) {
details.push(`session ${segment.source.sessionId.slice(0, 8)}`);
}
return details.join(' · ');
}
function buildMemberSegmentRenderKey(segment: MemberLogStreamSegment): string {
const firstChunkId = segment.chunks[0]?.id;
return `${segment.id}:${firstChunkId ?? segment.startTimestamp}`;
}
export function MemberLogStreamSection({
teamName,
member,
enabled = true,
onInitialLoadErrorChange,
}: Readonly<MemberLogStreamSectionProps>): React.JSX.Element {
const teamMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName));
const { stream, loading, error } = useMemberLogStream({ teamName, member, enabled });
const hasInitialLoadError = Boolean(error && !stream && !loading);
const boundedHistoryNote = useMemo(() => {
if (!stream) return null;
const isBounded =
stream.truncated ||
stream.warnings.some((warning) => warning.code === 'large_log_window_limited');
return isBounded ? 'Showing a bounded recent member log stream.' : null;
}, [stream]);
useEffect(() => {
onInitialLoadErrorChange?.(hasInitialLoadError);
}, [hasInitialLoadError, onInitialLoadErrorChange]);
return (
<ExecutionLogStreamView
title="Logs"
description={describeMemberStream()}
stream={stream}
loading={loading}
error={error}
teamName={teamName}
teamMembers={teamMembers}
loadingText="Loading member log stream..."
emptyTitle="No log stream entries were found for this member yet."
emptyDescription="Member-scoped transcript or runtime logs will appear here when available."
selectionResetKey={`${teamName}:${member.name}`}
boundedHistoryNote={boundedHistoryNote}
forceSegmentHeaders
buildSegmentRenderKey={buildMemberSegmentRenderKey}
getSegmentMetaLabel={getSegmentMetaLabel}
/>
);
}

View file

@ -0,0 +1,326 @@
import React, { act, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useMemberLogStream } from '../useMemberLogStream';
import type { MemberLogStreamResponse } from '../../../contracts';
import type { ResolvedTeamMember } from '@shared/types';
const apiMock = vi.hoisted(() => ({
memberLogStream: {
getMemberLogStream: vi.fn(),
setMemberLogStreamTracking: 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 member(name: string): ResolvedTeamMember {
return {
name,
status: 'idle',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
};
}
function response(generatedAt: string): MemberLogStreamResponse {
return {
participants: [],
defaultFilter: 'all',
segments: [],
source: 'member_empty',
coverage: [],
warnings: [],
truncated: false,
generatedAt,
metadata: {
scannedTranscriptFileCount: 0,
includedTranscriptFileCount: 0,
droppedSegmentCount: 0,
droppedChunkCount: 0,
droppedMessageCount: 0,
},
};
}
const HookProbe = ({
teamName,
selectedMember,
enabled = true,
onState,
}: {
teamName: string;
selectedMember: ResolvedTeamMember;
enabled?: boolean;
onState: (state: ReturnType<typeof useMemberLogStream>) => void;
}): React.JSX.Element | null => {
const state = useMemberLogStream({ teamName, member: selectedMember, enabled });
useEffect(() => {
onState(state);
}, [onState, state]);
return null;
};
describe('useMemberLogStream', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
apiMock.memberLogStream.getMemberLogStream.mockReset();
apiMock.memberLogStream.setMemberLogStreamTracking.mockReset();
apiMock.memberLogStream.setMemberLogStreamTracking.mockResolvedValue(undefined);
apiMock.teams.onTeamChange.mockReset();
apiMock.teams.onTeamChange.mockReturnValue(() => undefined);
});
afterEach(() => {
document.body.innerHTML = '';
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('does not let an older in-flight member request drive a pending reload after member key changes', async () => {
const aliceLoad = createDeferred<MemberLogStreamResponse>();
const bobLoad = createDeferred<MemberLogStreamResponse>();
apiMock.memberLogStream.getMemberLogStream
.mockReturnValueOnce(aliceLoad.promise)
.mockReturnValueOnce(bobLoad.promise);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onState = vi.fn((_: ReturnType<typeof useMemberLogStream>) => undefined);
const latestState = (): ReturnType<typeof useMemberLogStream> | undefined =>
onState.mock.calls.at(-1)?.[0];
await act(async () => {
root.render(
<HookProbe teamName="alpha-team" selectedMember={member('alice')} onState={onState} />
);
await Promise.resolve();
});
await act(async () => {
root.render(
<HookProbe teamName="alpha-team" selectedMember={member('bob')} onState={onState} />
);
await Promise.resolve();
});
const requestedMembers = apiMock.memberLogStream.getMemberLogStream.mock.calls.map(
(call: unknown[]) => String(call[1])
);
expect(requestedMembers).toEqual(['alice', 'bob']);
await act(async () => {
aliceLoad.resolve(response('2026-04-03T00:00:00.000Z'));
await Promise.resolve();
});
expect(latestState()?.stream).toBeNull();
await act(async () => {
bobLoad.resolve(response('2026-04-03T00:01:00.000Z'));
await Promise.resolve();
});
expect(latestState()?.stream?.generatedAt).toBe('2026-04-03T00:01:00.000Z');
act(() => {
root.unmount();
});
});
it('reloads on same-team log events with forceRefresh only for source changes', async () => {
vi.useFakeTimers();
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.getMemberLogStream
.mockResolvedValueOnce(response('2026-04-03T00:00:00.000Z'))
.mockResolvedValueOnce(response('2026-04-03T00:01:00.000Z'))
.mockResolvedValueOnce(response('2026-04-03T00:02:00.000Z'));
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
<HookProbe
teamName="alpha-team"
selectedMember={member('alice')}
onState={() => undefined}
/>
);
await Promise.resolve();
});
expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(1);
await act(async () => {
teamChangeListener?.(null, { teamName: 'other-team', type: 'log-source-change' });
vi.advanceTimersByTime(700);
await Promise.resolve();
});
await act(async () => {
teamChangeListener?.(null, { teamName: 'alpha-team', type: 'tool-activity' });
vi.advanceTimersByTime(700);
await Promise.resolve();
});
expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(1);
await act(async () => {
teamChangeListener?.(null, { teamName: 'alpha-team', type: 'log-source-change' });
vi.advanceTimersByTime(700);
await Promise.resolve();
});
expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(2);
expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenLastCalledWith(
'alpha-team',
'alice',
expect.objectContaining({ forceRefresh: true })
);
await act(async () => {
teamChangeListener?.(null, { teamName: 'alpha-team', type: 'task-log-change' });
vi.advanceTimersByTime(700);
await Promise.resolve();
});
expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(3);
expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenLastCalledWith(
'alpha-team',
'alice',
expect.not.objectContaining({ forceRefresh: true })
);
act(() => {
root.unmount();
});
});
it('releases stale in-flight state when the section is disabled before a request finishes', async () => {
const firstLoad = createDeferred<MemberLogStreamResponse>();
apiMock.memberLogStream.getMemberLogStream
.mockReturnValueOnce(firstLoad.promise)
.mockResolvedValueOnce(response('2026-04-03T00:02:00.000Z'));
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onState = vi.fn((_: ReturnType<typeof useMemberLogStream>) => undefined);
const latestState = (): ReturnType<typeof useMemberLogStream> | undefined =>
onState.mock.calls.at(-1)?.[0];
const selectedMember = member('alice');
await act(async () => {
root.render(
<HookProbe
teamName="alpha-team"
selectedMember={selectedMember}
enabled
onState={onState}
/>
);
await Promise.resolve();
});
expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(1);
await act(async () => {
root.render(
<HookProbe
teamName="alpha-team"
selectedMember={selectedMember}
enabled={false}
onState={onState}
/>
);
await Promise.resolve();
});
await act(async () => {
firstLoad.resolve(response('2026-04-03T00:01:00.000Z'));
await Promise.resolve();
});
expect(latestState()?.stream).toBeNull();
await act(async () => {
root.render(
<HookProbe
teamName="alpha-team"
selectedMember={selectedMember}
enabled
onState={onState}
/>
);
await Promise.resolve();
});
expect(apiMock.memberLogStream.getMemberLogStream).toHaveBeenCalledTimes(2);
expect(latestState()?.stream?.generatedAt).toBe('2026-04-03T00:02:00.000Z');
act(() => {
root.unmount();
});
});
it('passes an OpenCode lane only for OpenCode-owned members', async () => {
apiMock.memberLogStream.getMemberLogStream.mockResolvedValue(
response('2026-04-03T00:00:00.000Z')
);
const staleLaneMember: ResolvedTeamMember = {
...member('alice'),
providerId: 'anthropic',
laneId: 'secondary:opencode:alice',
laneOwnerProviderId: 'opencode',
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
<HookProbe
teamName="alpha-team"
selectedMember={staleLaneMember}
onState={() => undefined}
/>
);
await Promise.resolve();
});
const request = apiMock.memberLogStream.getMemberLogStream.mock.calls[0] as
| [string, string, { laneId?: unknown }]
| undefined;
expect(request?.[0]).toBe('alpha-team');
expect(request?.[1]).toBe('alice');
expect(request?.[2].laneId).toBeUndefined();
act(() => {
root.unmount();
});
});
});

View file

@ -0,0 +1,197 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '@renderer/api';
import {
type MemberLogStreamRequestOptions,
type MemberLogStreamResponse,
normalizeMemberLogStreamResponse,
} from '../../contracts';
import { normalizeExecutionLogStream } from '../ui/ExecutionLogStreamView';
import type { ResolvedTeamMember } from '@shared/types';
const LIVE_RELOAD_DEBOUNCE_MS = 650;
function getSafeOpenCodeLaneId(member: ResolvedTeamMember): string | undefined {
if (member.providerId !== 'opencode') return undefined;
if (member.laneOwnerProviderId !== 'opencode') return undefined;
const laneId = member.laneId?.trim();
return laneId ? laneId : undefined;
}
export function useMemberLogStream(input: {
teamName: string;
member: ResolvedTeamMember;
enabled?: boolean;
}): {
stream: MemberLogStreamResponse | null;
loading: boolean;
error: string | null;
reload: (options?: { forceRefresh?: boolean; background?: boolean }) => Promise<void>;
} {
const enabled = input.enabled ?? true;
const [stream, setStream] = useState<MemberLogStreamResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const streamRef = useRef<MemberLogStreamResponse | null>(null);
const activeLoadKeyRef = useRef<string | null>(null);
const pendingReloadRef = useRef<{ key: string; forceRefresh?: boolean } | null>(null);
const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const requestSeqRef = useRef(0);
const memberName = input.member.name;
const openCodeLaneId = getSafeOpenCodeLaneId(input.member);
const streamKey = `${input.teamName}:${memberName}:${openCodeLaneId ?? ''}`;
useEffect(() => {
streamRef.current = stream;
}, [stream]);
const loadStream = useCallback(
async (options?: { forceRefresh?: boolean; background?: boolean }): Promise<void> => {
if (!enabled) return;
if (activeLoadKeyRef.current === streamKey) {
const existingPending = pendingReloadRef.current;
pendingReloadRef.current = {
key: streamKey,
forceRefresh:
(existingPending?.key === streamKey && existingPending.forceRefresh) ||
options?.forceRefresh,
};
return;
}
activeLoadKeyRef.current = streamKey;
const background = options?.background ?? false;
const hadExistingStream = streamRef.current != null;
const requestSeq = requestSeqRef.current + 1;
requestSeqRef.current = requestSeq;
if (!background) setLoading(true);
setError((prev) => (background ? prev : null));
try {
const requestOptions: MemberLogStreamRequestOptions = {
limitSegments: 30,
...(options?.forceRefresh ? { forceRefresh: true } : {}),
};
if (openCodeLaneId) {
requestOptions.laneId = openCodeLaneId;
}
const response = normalizeExecutionLogStream(
normalizeMemberLogStreamResponse(
await api.memberLogStream.getMemberLogStream(input.teamName, memberName, requestOptions)
)
);
if (requestSeqRef.current !== requestSeq) return;
setStream(response);
setError(null);
} catch (loadError) {
if (requestSeqRef.current !== requestSeq) return;
if (!background || streamRef.current == null) {
setError(
loadError instanceof Error ? loadError.message : 'Failed to load member log stream'
);
setStream(null);
}
} finally {
const isCurrentRequest =
requestSeqRef.current === requestSeq && activeLoadKeyRef.current === streamKey;
if (isCurrentRequest && (!background || !hadExistingStream)) {
setLoading(false);
}
if (isCurrentRequest) {
activeLoadKeyRef.current = null;
}
const pending = pendingReloadRef.current;
if (pending?.key === streamKey) {
pendingReloadRef.current = null;
}
if (isCurrentRequest && pending?.key === streamKey && enabled) {
void loadStream({ background: true, forceRefresh: pending.forceRefresh });
}
}
},
[enabled, input.teamName, memberName, openCodeLaneId, streamKey]
);
useEffect(() => {
requestSeqRef.current += 1;
setStream(null);
streamRef.current = null;
setError(null);
setLoading(enabled);
pendingReloadRef.current = null;
activeLoadKeyRef.current = null;
if (reloadTimerRef.current) {
clearTimeout(reloadTimerRef.current);
reloadTimerRef.current = null;
}
if (enabled) {
void loadStream();
}
}, [enabled, streamKey, loadStream]);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
void api.memberLogStream
.setMemberLogStreamTracking(input.teamName, true)
.catch(() => undefined);
return () => {
if (cancelled) return;
cancelled = true;
void api.memberLogStream
.setMemberLogStreamTracking(input.teamName, false)
.catch(() => undefined);
};
}, [enabled, input.teamName]);
useEffect(() => {
if (!enabled) return;
const scheduleReload = (forceRefresh: boolean): void => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
reloadTimerRef.current = setTimeout(() => {
reloadTimerRef.current = null;
void loadStream({ background: true, forceRefresh });
}, LIVE_RELOAD_DEBOUNCE_MS);
};
const unsubscribe = api.teams.onTeamChange?.((_event, event) => {
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, loadStream]);
return { stream, loading, error, reload: loadStream };
}

View file

@ -0,0 +1,7 @@
export { MemberLogStreamSection } from './adapters/MemberLogStreamSection';
export {
buildDefaultExecutionSegmentRenderKey,
ExecutionLogStreamView,
normalizeExecutionLogStream,
} from './ui/ExecutionLogStreamView';
export { isMemberLogStreamUiEnabled } from './utils/featureGates';

View file

@ -0,0 +1,363 @@
import { useEffect, useMemo, useState } from 'react';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
import {
getTeamColorSet,
getThemedBadge,
getThemedBorder,
getThemedText,
} from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { asEnhancedChunkArray } from '@renderer/types/data';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { isLeadMember } from '@shared/utils/leadDetection';
import { AlertCircle, Clock, FileText, Loader2 } from 'lucide-react';
import type {
BoardTaskLogActor,
BoardTaskLogParticipant,
BoardTaskLogSegment,
ResolvedTeamMember,
} from '@shared/types';
interface ExecutionLogStreamLike {
participants: BoardTaskLogParticipant[];
defaultFilter: string;
segments: BoardTaskLogSegment[];
}
interface ParticipantVisual {
name: string;
color?: string;
}
export interface ExecutionLogStreamViewProps<TStream extends ExecutionLogStreamLike> {
title: string;
description: string;
stream: TStream | null;
loading: boolean;
error: string | null;
teamName: string;
teamMembers: readonly ResolvedTeamMember[];
loadingText: string;
emptyTitle: string;
emptyDescription: string;
selectionResetKey: string;
boundedHistoryNote?: string | null;
forceSegmentHeaders?: boolean;
buildSegmentRenderKey?: (segment: TStream['segments'][number]) => string;
getSegmentMetaLabel?: (segment: TStream['segments'][number]) => string | null;
}
function formatRelativeTime(isoString: string): string {
const date = new Date(isoString);
const diffMs = Date.now() - date.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMin / 60);
const diffDays = Math.floor(diffHours / 24);
if (!Number.isFinite(diffMs)) return '--';
if (diffMin < 1) return 'just now';
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
}
function actorLabel(actor: BoardTaskLogActor): string {
if (actor.memberName) return actor.memberName;
if (actor.role === 'lead' || actor.isSidechain === false) return 'lead session';
if (actor.agentId) return `member ${actor.agentId.slice(0, 8)}`;
return `member session ${actor.sessionId.slice(0, 8)}`;
}
export function normalizeExecutionLogStream<TStream extends ExecutionLogStreamLike>(
response: TStream
): TStream {
return {
...response,
segments: response.segments.map((segment) => ({
...segment,
chunks: asEnhancedChunkArray(segment.chunks) ?? [],
})),
};
}
export function buildDefaultExecutionSegmentRenderKey(segment: BoardTaskLogSegment): string {
const firstChunkId = segment.chunks[0]?.id;
if (firstChunkId) {
return `${segment.participantKey}:${firstChunkId}`;
}
return `${segment.participantKey}:${segment.startTimestamp}`;
}
function buildParticipantVisualMap(
stream: ExecutionLogStreamLike | null,
members: readonly ResolvedTeamMember[],
memberColorMap: ReadonlyMap<string, string>
): Map<string, ParticipantVisual> {
const visuals = new Map<string, ParticipantVisual>();
const leadMember = members.find((member) => isLeadMember(member));
for (const participant of stream?.participants ?? []) {
const matchingSegment = stream?.segments.find(
(segment) => segment.participantKey === participant.key
);
const name =
matchingSegment?.actor.memberName ??
(participant.isLead ? leadMember?.name : undefined) ??
participant.label;
visuals.set(participant.key, {
name,
color: memberColorMap.get(name) ?? memberColorMap.get(participant.label),
});
}
for (const segment of stream?.segments ?? []) {
if (visuals.has(segment.participantKey)) continue;
const name = segment.actor.memberName ?? actorLabel(segment.actor);
visuals.set(segment.participantKey, { name, color: memberColorMap.get(name) });
}
return visuals;
}
const SegmentMarker = <TSegment extends BoardTaskLogSegment>({
segment,
visual,
teamName,
metaLabel,
}: {
segment: TSegment;
visual?: ParticipantVisual;
teamName: string;
metaLabel?: string | null;
}): React.JSX.Element => (
<div className="mb-2 flex flex-wrap items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
{visual ? (
<MemberBadge
name={visual.name}
color={visual.color}
teamName={teamName}
size="xs"
disableHoverCard
/>
) : null}
{metaLabel ? <span>{metaLabel}</span> : null}
<span className="flex items-center gap-1">
<Clock size={10} />
{formatRelativeTime(segment.endTimestamp)}
</span>
</div>
);
const SegmentBlock = <TSegment extends BoardTaskLogSegment>({
segment,
showHeader,
teamName,
visual,
metaLabel,
}: {
segment: TSegment;
showHeader: boolean;
teamName: string;
visual?: ParticipantVisual;
metaLabel?: string | null;
}): React.JSX.Element => (
<div className="min-w-0 overflow-hidden">
{showHeader ? (
<SegmentMarker segment={segment} visual={visual} teamName={teamName} metaLabel={metaLabel} />
) : null}
<MemberExecutionLog
chunks={segment.chunks}
memberName={segment.actor.memberName}
memberColor={visual?.color}
teamName={teamName}
hideMemberHeading={showHeader && Boolean(segment.actor.memberName)}
/>
</div>
);
const ParticipantFilterChip = ({
label,
selected,
visual,
teamName,
onClick,
}: {
label: string;
selected: boolean;
visual?: ParticipantVisual;
teamName: string;
onClick: () => void;
}): React.JSX.Element => {
const { isLight } = useTheme();
const colors = getTeamColorSet(visual?.color ?? '');
const borderColor = selected ? getThemedBorder(colors, isLight) : 'var(--color-border)';
const backgroundColor = selected ? getThemedBadge(colors, isLight) : 'transparent';
const textColor = selected ? getThemedText(colors, isLight) : 'var(--color-text-muted)';
return (
<button
type="button"
className="rounded-full border px-2 py-1 text-[11px] transition-colors hover:text-[var(--color-text)]"
style={{ borderColor, backgroundColor, color: textColor }}
onClick={onClick}
>
{visual ? (
<MemberBadge
name={visual.name}
color={visual.color}
teamName={teamName}
size="xs"
disableHoverCard
/>
) : (
label
)}
</button>
);
};
export function ExecutionLogStreamView<TStream extends ExecutionLogStreamLike>({
title,
description,
stream,
loading,
error,
teamName,
teamMembers,
loadingText,
emptyTitle,
emptyDescription,
selectionResetKey,
boundedHistoryNote,
forceSegmentHeaders = false,
buildSegmentRenderKey,
getSegmentMetaLabel,
}: Readonly<ExecutionLogStreamViewProps<TStream>>): React.JSX.Element {
const [selectedParticipantKey, setSelectedParticipantKey] = useState<string>('all');
const participants = stream?.participants ?? [];
const memberColorMap = useMemo(() => buildMemberColorMap([...teamMembers]), [teamMembers]);
const participantVisuals = useMemo(
() => buildParticipantVisualMap(stream, teamMembers, memberColorMap),
[memberColorMap, stream, teamMembers]
);
useEffect(() => {
if (!stream) {
setSelectedParticipantKey('all');
return;
}
setSelectedParticipantKey(stream.defaultFilter);
}, [selectionResetKey, stream]);
useEffect(() => {
if (!stream) return;
const availableParticipantKeys = new Set([
'all',
...stream.participants.map((participant) => participant.key),
]);
setSelectedParticipantKey((prev) =>
availableParticipantKeys.has(prev) ? prev : stream.defaultFilter
);
}, [stream]);
const showChips = participants.length > 1;
const visibleSegments = useMemo(() => {
const source = stream?.segments ?? [];
const filtered =
selectedParticipantKey === 'all'
? source
: source.filter((segment) => segment.participantKey === selectedParticipantKey);
return [...filtered].reverse();
}, [selectedParticipantKey, stream?.segments]);
const showSegmentHeaders =
forceSegmentHeaders ||
participants.length > 1 ||
(selectedParticipantKey !== 'all' && visibleSegments.length > 1);
const renderKey = buildSegmentRenderKey ?? buildDefaultExecutionSegmentRenderKey;
if (loading) {
return (
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase text-[var(--color-text-muted)]">{title}</h4>
<div className="flex items-center gap-2 py-4 text-xs text-[var(--color-text-muted)]">
<Loader2 size={12} className="animate-spin" />
{loadingText}
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase text-[var(--color-text-muted)]">{title}</h4>
<div className="flex items-center gap-2 py-4 text-xs text-red-400">
<AlertCircle size={14} />
{error}
</div>
</div>
);
}
return (
<div className="space-y-3">
<h4 className="text-xs font-semibold uppercase text-[var(--color-text-muted)]">{title}</h4>
<p className="text-xs text-[var(--color-text-muted)]">{description}</p>
{boundedHistoryNote ? (
<p className="text-[11px] text-amber-300">{boundedHistoryNote}</p>
) : null}
{showChips ? (
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
className={`rounded-full border px-2.5 py-1 text-[11px] transition-colors ${
selectedParticipantKey === 'all'
? 'bg-[var(--color-accent)]/10 border-[var(--color-accent)] text-[var(--color-text)]'
: 'border-[var(--color-border)] text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
}`}
onClick={() => setSelectedParticipantKey('all')}
>
All
</button>
{participants.map((participant) => (
<ParticipantFilterChip
key={participant.key}
label={participant.label}
selected={selectedParticipantKey === participant.key}
visual={participantVisuals.get(participant.key)}
teamName={teamName}
onClick={() => setSelectedParticipantKey(participant.key)}
/>
))}
</div>
) : null}
{visibleSegments.length === 0 ? (
<div className="py-8 text-center text-xs text-[var(--color-text-muted)]">
<FileText size={20} className="mx-auto mb-2 opacity-40" />
{emptyTitle}
<p className="mt-1 text-[10px] opacity-60">{emptyDescription}</p>
</div>
) : (
<div className="space-y-6">
{visibleSegments.map((segment) => (
<SegmentBlock
key={renderKey(segment)}
segment={segment}
showHeader={showSegmentHeaders}
teamName={teamName}
visual={participantVisuals.get(segment.participantKey)}
metaLabel={getSegmentMetaLabel?.(segment)}
/>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,18 @@
function readEnabledFlag(value: unknown, defaultValue: boolean): boolean {
if (typeof value !== 'string') {
return defaultValue;
}
const normalized = value.trim().toLowerCase();
if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') {
return false;
}
if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes') {
return true;
}
return defaultValue;
}
export function isMemberLogStreamUiEnabled(): boolean {
return readEnabledFlag(import.meta.env.VITE_MEMBER_LOG_STREAM_UI_ENABLED, true);
}

View file

@ -30,6 +30,11 @@ import {
type CodexModelCatalogFeatureFacade, type CodexModelCatalogFeatureFacade,
createCodexModelCatalogFeature, createCodexModelCatalogFeature,
} from '@features/codex-model-catalog/main'; } from '@features/codex-model-catalog/main';
import {
createMemberLogStreamFeature,
registerMemberLogStreamIpc,
removeMemberLogStreamIpc,
} from '@features/member-log-stream/main';
import { import {
buildMemberWorkSyncRuntimeTurnSettledEnvironment, buildMemberWorkSyncRuntimeTurnSettledEnvironment,
createMemberWorkSyncFeature, createMemberWorkSyncFeature,
@ -49,6 +54,7 @@ import {
removeRuntimeProviderManagementIpc, removeRuntimeProviderManagementIpc,
type RuntimeProviderManagementFeatureFacade, type RuntimeProviderManagementFeatureFacade,
} from '@features/runtime-provider-management/main'; } from '@features/runtime-provider-management/main';
import { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService';
import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy'; import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy';
import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService'; import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService';
import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository'; import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
@ -1138,6 +1144,13 @@ async function initializeServices(): Promise<void> {
undefined, undefined,
teamTranscriptSourceLocator teamTranscriptSourceLocator
); );
const memberLogStreamFeature = createMemberLogStreamFeature({
logsFinder: teamMemberLogsFinder,
logSourceTracker: teamLogSourceTracker,
runtimeBridge: new ClaudeMultimodelBridgeService(),
configReader: taskLogConfigReader,
logger: createLogger('Feature:MemberLogStream'),
});
const teamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService( const teamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(
teamMemberLogsFinder teamMemberLogsFinder
); );
@ -1483,6 +1496,7 @@ async function initializeServices(): Promise<void> {
registerRecentProjectsIpc(ipcMain, recentProjectsFeature); registerRecentProjectsIpc(ipcMain, recentProjectsFeature);
registerRuntimeProviderManagementIpc(ipcMain, runtimeProviderManagementFeature); registerRuntimeProviderManagementIpc(ipcMain, runtimeProviderManagementFeature);
registerMemberWorkSyncIpc(ipcMain, memberWorkSyncFeature); registerMemberWorkSyncIpc(ipcMain, memberWorkSyncFeature);
registerMemberLogStreamIpc(ipcMain, memberLogStreamFeature);
// Forward SSH state changes to renderer and HTTP SSE clients // Forward SSH state changes to renderer and HTTP SSE clients
sshConnectionManager.on('state-change', (status: unknown) => { sshConnectionManager.on('state-change', (status: unknown) => {
@ -1672,6 +1686,7 @@ async function shutdownServices(): Promise<void> {
removeRecentProjectsIpc(ipcMain); removeRecentProjectsIpc(ipcMain);
removeRuntimeProviderManagementIpc(ipcMain); removeRuntimeProviderManagementIpc(ipcMain);
removeMemberWorkSyncIpc(ipcMain); removeMemberWorkSyncIpc(ipcMain);
removeMemberLogStreamIpc(ipcMain);
}); });
await runShutdownStep('team backup dispose', () => teamBackupService?.dispose()); await runShutdownStep('team backup dispose', () => teamBackupService?.dispose());

View file

@ -925,6 +925,8 @@ export class ClaudeMultimodelBridgeService {
teamId: string; teamId: string;
memberName: string; memberName: string;
limit?: number; limit?: number;
laneId?: string;
timeoutMs?: number;
} }
): Promise<OpenCodeRuntimeTranscriptResponse['transcript'] | null> { ): Promise<OpenCodeRuntimeTranscriptResponse['transcript'] | null> {
const { env } = await this.buildCliEnv(binaryPath); const { env } = await this.buildCliEnv(binaryPath);
@ -943,12 +945,15 @@ export class ClaudeMultimodelBridgeService {
if (typeof params.limit === 'number') { if (typeof params.limit === 'number') {
args.push('--limit', String(params.limit)); args.push('--limit', String(params.limit));
} }
if (typeof params.laneId === 'string' && params.laneId.trim().length > 0) {
args.push('--lane', params.laneId.trim());
}
const outputDir = await mkdtemp(path.join(tmpdir(), 'opencode-transcript-')); const outputDir = await mkdtemp(path.join(tmpdir(), 'opencode-transcript-'));
const outputPath = path.join(outputDir, 'transcript.json'); const outputPath = path.join(outputDir, 'transcript.json');
try { try {
await execCli(binaryPath, [...args, '--output', outputPath], { await execCli(binaryPath, [...args, '--output', outputPath], {
timeout: PROVIDER_STATUS_TIMEOUT_MS, timeout: params.timeoutMs ?? PROVIDER_STATUS_TIMEOUT_MS,
env, env,
}); });
const parsed = extractJsonObject<OpenCodeRuntimeTranscriptResponse>( const parsed = extractJsonObject<OpenCodeRuntimeTranscriptResponse>(

View file

@ -39,6 +39,7 @@ export type TeamLogSourceTrackingConsumer =
| 'change_presence' | 'change_presence'
| 'tool_activity' | 'tool_activity'
| 'task_log_stream' | 'task_log_stream'
| 'member_log_stream'
| 'stall_monitor'; | 'stall_monitor';
interface TrackingState { interface TrackingState {

View file

@ -112,8 +112,19 @@ export interface MemberLogFileRef {
sessionId: string; sessionId: string;
filePath: string; filePath: string;
mtimeMs: number; mtimeMs: number;
sizeBytes?: number;
messageCount?: number;
kind?: 'lead_session' | 'member_session' | 'subagent';
} }
type FindRecentMemberLogFileRefsOptions =
| number
| null
| {
mtimeSinceMs?: number | null;
forceRefresh?: boolean;
};
export interface TeamLogSourceLiveContext { export interface TeamLogSourceLiveContext {
projectDir: string; projectDir: string;
projectPath?: string; projectPath?: string;
@ -966,8 +977,15 @@ export class TeamMemberLogsFinder {
async findRecentMemberLogFileRefsByMember( async findRecentMemberLogFileRefsByMember(
teamName: string, teamName: string,
memberNames: readonly string[], memberNames: readonly string[],
mtimeSinceMs?: number | null options?: FindRecentMemberLogFileRefsOptions
): Promise<MemberLogFileRef[]> { ): Promise<MemberLogFileRef[]> {
const parsedOptions =
typeof options === 'number' || options === null
? { mtimeSinceMs: options ?? null, forceRefresh: false }
: {
mtimeSinceMs: options?.mtimeSinceMs ?? null,
forceRefresh: options?.forceRefresh === true,
};
const requestedMembersByKey = new Map<string, string>(); const requestedMembersByKey = new Map<string, string>();
for (const memberName of memberNames) { for (const memberName of memberNames) {
const trimmed = memberName.trim(); const trimmed = memberName.trim();
@ -983,12 +1001,18 @@ export class TeamMemberLogsFinder {
return []; return [];
} }
const discovery = await this.discoverProjectSessions(teamName); const discovery = await this.discoverProjectSessions(teamName, {
forceRefresh: parsedOptions.forceRefresh,
});
if (!discovery) { if (!discovery) {
return []; return [];
} }
const { projectDir, sessionIds, knownMembers, config } = discovery; const { projectDir, sessionIds, knownMembers, config } = discovery;
const scopedKnownMembers = new Set(knownMembers);
for (const memberKey of requestedMembersByKey.keys()) {
scopedKnownMembers.add(memberKey);
}
const refs: MemberLogFileRef[] = []; const refs: MemberLogFileRef[] = [];
const seenFilePaths = new Set<string>(); const seenFilePaths = new Set<string>();
const pushRef = (ref: MemberLogFileRef): void => { const pushRef = (ref: MemberLogFileRef): void => {
@ -1006,12 +1030,17 @@ export class TeamMemberLogsFinder {
const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`); const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`);
try { try {
const stat = await fs.stat(leadJsonl); const stat = await fs.stat(leadJsonl);
if (stat.isFile()) { if (
stat.isFile() &&
(parsedOptions.mtimeSinceMs == null || stat.mtimeMs >= parsedOptions.mtimeSinceMs)
) {
pushRef({ pushRef({
memberName: requestedMembersByKey.get(leadKey) ?? leadMemberName, memberName: requestedMembersByKey.get(leadKey) ?? leadMemberName,
sessionId: config.leadSessionId, sessionId: config.leadSessionId,
filePath: leadJsonl, filePath: leadJsonl,
mtimeMs: stat.mtimeMs, mtimeMs: stat.mtimeMs,
sizeBytes: stat.size,
kind: 'lead_session',
}); });
} }
} catch { } catch {
@ -1026,20 +1055,20 @@ export class TeamMemberLogsFinder {
if (!stat.isFile()) { if (!stat.isFile()) {
return null; return null;
} }
if (mtimeSinceMs != null && stat.mtimeMs < mtimeSinceMs) { if (parsedOptions.mtimeSinceMs != null && stat.mtimeMs < parsedOptions.mtimeSinceMs) {
return null; return null;
} }
const attribution = const attribution =
candidate.kind === 'subagent' candidate.kind === 'subagent'
? await this.getCachedSubagentAttribution( ? await this.getCachedSubagentAttribution(
candidate.filePath, candidate.filePath,
knownMembers, scopedKnownMembers,
stat.mtimeMs stat.mtimeMs
) )
: await this.getCachedMemberSessionAttribution( : await this.getCachedMemberSessionAttribution(
candidate.filePath, candidate.filePath,
teamName, teamName,
knownMembers, scopedKnownMembers,
stat.mtimeMs stat.mtimeMs
); );
if (!attribution) { if (!attribution) {
@ -1055,6 +1084,8 @@ export class TeamMemberLogsFinder {
sessionId: candidate.sessionId, sessionId: candidate.sessionId,
filePath: candidate.filePath, filePath: candidate.filePath,
mtimeMs: stat.mtimeMs, mtimeMs: stat.mtimeMs,
sizeBytes: stat.size,
kind: candidate.kind,
} satisfies MemberLogFileRef; } satisfies MemberLogFileRef;
} catch { } catch {
return null; return null;

View file

@ -0,0 +1,126 @@
import { sanitizeDisplayContent } from '@shared/utils/contentSanitizer';
import type {
OpenCodeRuntimeTranscriptLogContentBlock,
OpenCodeRuntimeTranscriptLogMessage,
} from '../../../runtime/ClaudeMultimodelBridgeService';
import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types';
function mapOpenCodeContentBlock(
block: OpenCodeRuntimeTranscriptLogContentBlock
): ContentBlock | null {
switch (block.type) {
case 'text': {
const text = sanitizeDisplayContent(block.text);
return text.length > 0 ? { type: 'text', text } : null;
}
case 'thinking':
return {
type: 'thinking',
thinking: block.thinking,
signature: block.signature,
};
case 'tool_use':
return {
type: 'tool_use',
id: block.id,
name: block.name,
input: block.input,
};
case 'tool_result':
return {
type: 'tool_result',
tool_use_id: block.tool_use_id,
content: Array.isArray(block.content)
? block.content
.map(mapOpenCodeContentBlock)
.filter((item): item is ContentBlock => item !== null)
: block.content,
...(block.is_error ? { is_error: true } : {}),
};
default:
return null;
}
}
function buildToolUseResultData(
message: OpenCodeRuntimeTranscriptLogMessage
): ToolUseResultData | undefined {
if (!message.sourceToolUseID || message.toolResults.length !== 1) {
return undefined;
}
const toolResult = message.toolResults[0];
if (!toolResult) {
return undefined;
}
return {
toolUseId: toolResult.toolUseId,
content: toolResult.content,
isError: toolResult.isError,
};
}
export function mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage(
message: OpenCodeRuntimeTranscriptLogMessage
): ParsedMessage | null {
const timestamp = new Date(message.timestamp);
if (Number.isNaN(timestamp.getTime())) {
return null;
}
const normalizedContent: ContentBlock[] | string =
typeof message.content === 'string'
? sanitizeDisplayContent(message.content)
: message.content
.map(mapOpenCodeContentBlock)
.filter((item): item is ContentBlock => item !== null);
const toolCalls = message.toolCalls.map((toolCall) => ({
id: toolCall.id,
name: toolCall.name,
input: toolCall.input,
isTask: toolCall.isTask,
...(toolCall.taskDescription ? { taskDescription: toolCall.taskDescription } : {}),
...(toolCall.taskSubagentType ? { taskSubagentType: toolCall.taskSubagentType } : {}),
}));
const toolResults = message.toolResults.map((toolResult) => ({
toolUseId: toolResult.toolUseId,
content: toolResult.content,
isError: toolResult.isError,
}));
const toolUseResult = buildToolUseResultData(message);
return {
uuid: message.uuid,
parentUuid: message.parentUuid,
type: message.type,
timestamp,
role: message.role,
content: normalizedContent,
model: message.model,
agentName: message.agentName,
isSidechain: true,
isMeta: message.isMeta,
sessionId: message.sessionId,
toolCalls,
toolResults,
...(message.sourceToolUseID ? { sourceToolUseID: message.sourceToolUseID } : {}),
...(message.sourceToolAssistantUUID
? { sourceToolAssistantUUID: message.sourceToolAssistantUUID }
: {}),
...(toolUseResult ? { toolUseResult } : {}),
...(message.subtype ? { subtype: message.subtype } : {}),
...(message.level ? { level: message.level } : {}),
};
}
export function mapOpenCodeRuntimeTranscriptMessagesToParsedMessages(
messages: readonly OpenCodeRuntimeTranscriptLogMessage[]
): ParsedMessage[] {
return messages
.map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage)
.filter((message): message is ParsedMessage => message !== null);
}

View file

@ -1,4 +1,3 @@
import { sanitizeDisplayContent } from '@shared/utils/contentSanitizer';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { ClaudeMultimodelBridgeService } from '../../../runtime/ClaudeMultimodelBridgeService'; import { ClaudeMultimodelBridgeService } from '../../../runtime/ClaudeMultimodelBridgeService';
@ -7,17 +6,15 @@ import { ClaudeBinaryResolver } from '../../ClaudeBinaryResolver';
import { TeamTaskReader } from '../../TeamTaskReader'; import { TeamTaskReader } from '../../TeamTaskReader';
import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder'; import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder';
import { mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage } from './OpenCodeRuntimeProjectionMapper';
import { OpenCodeTaskLogAttributionStore } from './OpenCodeTaskLogAttributionStore'; import { OpenCodeTaskLogAttributionStore } from './OpenCodeTaskLogAttributionStore';
import type { import type { OpenCodeRuntimeTranscriptLogMessage } from '../../../runtime/ClaudeMultimodelBridgeService';
OpenCodeRuntimeTranscriptLogContentBlock,
OpenCodeRuntimeTranscriptLogMessage,
} from '../../../runtime/ClaudeMultimodelBridgeService';
import type { import type {
OpenCodeTaskLogAttributionReader, OpenCodeTaskLogAttributionReader,
OpenCodeTaskLogAttributionRecord, OpenCodeTaskLogAttributionRecord,
} from './OpenCodeTaskLogAttributionStore'; } from './OpenCodeTaskLogAttributionStore';
import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types'; import type { ParsedMessage } from '@main/types';
import type { import type {
BoardTaskLogActor, BoardTaskLogActor,
BoardTaskLogParticipant, BoardTaskLogParticipant,
@ -431,7 +428,7 @@ function hasForeignTeamTaskMarker(
} }
return projectedMessages return projectedMessages
.map(toParsedMessage) .map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage)
.filter((message): message is ParsedMessage => message !== null) .filter((message): message is ParsedMessage => message !== null)
.some((message) => .some((message) =>
message.toolCalls.some((toolCall) => { message.toolCalls.some((toolCall) => {
@ -758,7 +755,7 @@ function buildTaskMarkerProjection(
): TaskMarkerProjection | null { ): TaskMarkerProjection | null {
const parsedMessages = sortParsedMessagesByTime( const parsedMessages = sortParsedMessagesByTime(
projectedMessages projectedMessages
.map(toParsedMessage) .map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage)
.filter((message): message is ParsedMessage => message !== null) .filter((message): message is ParsedMessage => message !== null)
); );
const taskRefs = buildTaskRefSet(task); const taskRefs = buildTaskRefSet(task);
@ -919,7 +916,7 @@ function filterMessagesForAttribution(
record: OpenCodeTaskLogAttributionRecord record: OpenCodeTaskLogAttributionRecord
): ParsedMessage[] { ): ParsedMessage[] {
const parsedMessages = messages const parsedMessages = messages
.map(toParsedMessage) .map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage)
.filter((message): message is ParsedMessage => message !== null); .filter((message): message is ParsedMessage => message !== null);
const hasMessageBounds = Boolean(record.startMessageUuid || record.endMessageUuid); const hasMessageBounds = Boolean(record.startMessageUuid || record.endMessageUuid);
@ -936,115 +933,6 @@ function filterMessagesForAttribution(
.sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime()); .sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime());
} }
function mapOpenCodeContentBlock(
block: OpenCodeRuntimeTranscriptLogContentBlock
): ContentBlock | null {
switch (block.type) {
case 'text': {
const text = sanitizeDisplayContent(block.text);
return text.length > 0 ? { type: 'text', text } : null;
}
case 'thinking':
return {
type: 'thinking',
thinking: block.thinking,
signature: block.signature,
};
case 'tool_use':
return {
type: 'tool_use',
id: block.id,
name: block.name,
input: block.input,
};
case 'tool_result':
return {
type: 'tool_result',
tool_use_id: block.tool_use_id,
content: Array.isArray(block.content)
? block.content
.map(mapOpenCodeContentBlock)
.filter((item): item is ContentBlock => item !== null)
: block.content,
...(block.is_error ? { is_error: true } : {}),
};
default:
return null;
}
}
function buildToolUseResultData(
message: OpenCodeRuntimeTranscriptLogMessage
): ToolUseResultData | undefined {
if (!message.sourceToolUseID || message.toolResults.length !== 1) {
return undefined;
}
const toolResult = message.toolResults[0];
if (!toolResult) {
return undefined;
}
return {
toolUseId: toolResult.toolUseId,
content: toolResult.content,
isError: toolResult.isError,
};
}
function toParsedMessage(message: OpenCodeRuntimeTranscriptLogMessage): ParsedMessage | null {
const timestamp = new Date(message.timestamp);
if (Number.isNaN(timestamp.getTime())) {
return null;
}
const normalizedContent: ContentBlock[] | string =
typeof message.content === 'string'
? sanitizeDisplayContent(message.content)
: message.content
.map(mapOpenCodeContentBlock)
.filter((item): item is ContentBlock => item !== null);
const toolCalls = message.toolCalls.map((toolCall) => ({
id: toolCall.id,
name: toolCall.name,
input: toolCall.input,
isTask: toolCall.isTask,
...(toolCall.taskDescription ? { taskDescription: toolCall.taskDescription } : {}),
...(toolCall.taskSubagentType ? { taskSubagentType: toolCall.taskSubagentType } : {}),
}));
const toolResults = message.toolResults.map((toolResult) => ({
toolUseId: toolResult.toolUseId,
content: toolResult.content,
isError: toolResult.isError,
}));
const toolUseResult = buildToolUseResultData(message);
return {
uuid: message.uuid,
parentUuid: message.parentUuid,
type: message.type,
timestamp,
role: message.role,
content: normalizedContent,
model: message.model,
agentName: message.agentName,
isSidechain: true,
isMeta: message.isMeta,
sessionId: message.sessionId,
toolCalls,
toolResults,
...(message.sourceToolUseID ? { sourceToolUseID: message.sourceToolUseID } : {}),
...(message.sourceToolAssistantUUID
? { sourceToolAssistantUUID: message.sourceToolAssistantUUID }
: {}),
...(toolUseResult ? { toolUseResult } : {}),
...(message.subtype ? { subtype: message.subtype } : {}),
...(message.level ? { level: message.level } : {}),
};
}
export class OpenCodeTaskLogStreamSource { export class OpenCodeTaskLogStreamSource {
private readonly cache = new Map< private readonly cache = new Map<
string, string,
@ -1187,7 +1075,7 @@ export class OpenCodeTaskLogStreamSource {
const filteredMessages = const filteredMessages =
markerProjection?.messages ?? markerProjection?.messages ??
projectedMessages projectedMessages
.map(toParsedMessage) .map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage)
.filter((message): message is ParsedMessage => message !== null) .filter((message): message is ParsedMessage => message !== null)
.filter((message) => isWithinTimeWindows(message.timestamp, timeWindows)) .filter((message) => isWithinTimeWindows(message.timestamp, timeWindows))
.sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime()); .sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime());

View file

@ -1,4 +1,5 @@
import { createCodexAccountBridge } from '@features/codex-account/preload'; import { createCodexAccountBridge } from '@features/codex-account/preload';
import { createMemberLogStreamBridge } from '@features/member-log-stream/preload';
import { createMemberWorkSyncBridge } from '@features/member-work-sync/preload'; import { createMemberWorkSyncBridge } from '@features/member-work-sync/preload';
import { createRecentProjectsBridge } from '@features/recent-projects/preload'; import { createRecentProjectsBridge } from '@features/recent-projects/preload';
import { createRuntimeProviderManagementBridge } from '@features/runtime-provider-management/preload'; import { createRuntimeProviderManagementBridge } from '@features/runtime-provider-management/preload';
@ -478,6 +479,7 @@ const electronAPI: ElectronAPI = {
...createRecentProjectsBridge(), ...createRecentProjectsBridge(),
runtimeProviderManagement: createRuntimeProviderManagementBridge(ipcRenderer), runtimeProviderManagement: createRuntimeProviderManagementBridge(ipcRenderer),
memberWorkSync: createMemberWorkSyncBridge(ipcRenderer), memberWorkSync: createMemberWorkSyncBridge(ipcRenderer),
memberLogStream: createMemberLogStreamBridge(),
getAppVersion: () => ipcRenderer.invoke('get-app-version'), getAppVersion: () => ipcRenderer.invoke('get-app-version'),
getProjects: () => ipcRenderer.invoke('get-projects'), getProjects: () => ipcRenderer.invoke('get-projects'),
getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId), getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId),

View file

@ -6,7 +6,13 @@
* to run in a regular browser connected to an HTTP server. * to run in a regular browser connected to an HTTP server.
*/ */
import {
createEmptyMemberLogPreviewResponse,
createEmptyMemberLogStreamResponse,
} from '@features/member-log-stream/contracts';
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
import type { MemberLogStreamApi } from '@features/member-log-stream/contracts';
import type { DashboardRecentProjectsPayload } from '@features/recent-projects/contracts'; import type { DashboardRecentProjectsPayload } from '@features/recent-projects/contracts';
import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts'; import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts';
import type { import type {
@ -251,6 +257,20 @@ export class HttpAPIClient implements ElectronAPI {
getDashboardRecentProjects = (): Promise<DashboardRecentProjectsPayload> => getDashboardRecentProjects = (): Promise<DashboardRecentProjectsPayload> =>
this.get<DashboardRecentProjectsPayload>('/api/dashboard/recent-projects'); this.get<DashboardRecentProjectsPayload>('/api/dashboard/recent-projects');
memberLogStream: MemberLogStreamApi = {
getMemberLogStream: async () => {
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.
},
};
getProjects = (): Promise<Project[]> => this.get<Project[]>('/api/projects'); getProjects = (): Promise<Project[]> => this.get<Project[]>('/api/projects');
getSessions = (projectId: string): Promise<Session[]> => getSessions = (projectId: string): Promise<Session[]> =>

View file

@ -1,5 +1,9 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import {
isMemberLogStreamUiEnabled,
MemberLogStreamSection,
} from '@features/member-log-stream/renderer';
// import { MemberWorkSyncStatusPanel } from '@features/member-work-sync/renderer'; // import { MemberWorkSyncStatusPanel } from '@features/member-work-sync/renderer';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog'; import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
@ -179,6 +183,7 @@ export const MemberDetailDialog = ({
const [activeTab, setActiveTab] = useState<MemberDetailTab>(initialTab); const [activeTab, setActiveTab] = useState<MemberDetailTab>(initialTab);
const [restarting, setRestarting] = useState(false); const [restarting, setRestarting] = useState(false);
const [restartError, setRestartError] = useState<string | null>(null); const [restartError, setRestartError] = useState<string | null>(null);
const [showLegacyLogsFallback, setShowLegacyLogsFallback] = useState(false);
const runtimeSummary = useMemo( const runtimeSummary = useMemo(
() => () =>
@ -249,6 +254,7 @@ export const MemberDetailDialog = ({
setActiveTab(initialTab); setActiveTab(initialTab);
setRestartError(null); setRestartError(null);
setRestarting(false); setRestarting(false);
setShowLegacyLogsFallback(false);
}, [initialTab, member, open]); }, [initialTab, member, open]);
const { const {
@ -258,6 +264,7 @@ export const MemberDetailDialog = ({
} = useMemberStats(teamName, member?.name ?? null); } = useMemberStats(teamName, member?.name ?? null);
const totalTokens = memberStats ? memberStats.inputTokens + memberStats.outputTokens : null; const totalTokens = memberStats ? memberStats.inputTokens + memberStats.outputTokens : null;
const memberLogStreamEnabled = isMemberLogStreamUiEnabled();
if (!member) return null; if (!member) return null;
@ -368,7 +375,26 @@ export const MemberDetailDialog = ({
/> />
</TabsContent> </TabsContent>
<TabsContent value="logs" className="min-w-0 overflow-hidden"> <TabsContent value="logs" className="min-w-0 overflow-hidden">
<MemberLogsTab teamName={teamName} memberName={member.name} /> {memberLogStreamEnabled ? (
<div className="space-y-4">
<MemberLogStreamSection
teamName={teamName}
member={member}
enabled={open && activeTab === 'logs'}
onInitialLoadErrorChange={setShowLegacyLogsFallback}
/>
{showLegacyLogsFallback ? (
<div className="rounded-md border border-[var(--color-border)] p-3">
<div className="mb-3 text-xs font-semibold uppercase text-[var(--color-text-muted)]">
Legacy Logs Fallback
</div>
<MemberLogsTab teamName={teamName} memberName={member.name} />
</div>
) : null}
</div>
) : (
<MemberLogsTab teamName={teamName} memberName={member.name} />
)}
</TabsContent> </TabsContent>
</Tabs> </Tabs>

View file

@ -1,29 +1,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
import { import {
getTeamColorSet, ExecutionLogStreamView,
getThemedBadge, normalizeExecutionLogStream,
getThemedBorder, } from '@features/member-log-stream/renderer';
getThemedText, import { api } from '@renderer/api';
} from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { asEnhancedChunkArray } from '@renderer/types/data';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents'; import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents';
import { isLeadMember } from '@shared/utils/leadDetection';
import { AlertCircle, Clock, FileText, Loader2 } from 'lucide-react';
import type { import type { BoardTaskLogStreamResponse } from '@shared/types';
BoardTaskLogActor,
BoardTaskLogSegment,
BoardTaskLogStreamResponse,
ResolvedTeamMember,
} from '@shared/types';
interface TaskLogStreamSectionProps { interface TaskLogStreamSectionProps {
teamName: string; teamName: string;
@ -34,54 +20,6 @@ interface TaskLogStreamSectionProps {
const LIVE_RELOAD_DEBOUNCE_MS = 350; const LIVE_RELOAD_DEBOUNCE_MS = 350;
function formatRelativeTime(isoString: string): string {
const date = new Date(isoString);
const diffMs = Date.now() - date.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMin / 60);
const diffDays = Math.floor(diffHours / 24);
if (!Number.isFinite(diffMs)) return '--';
if (diffMin < 1) return 'just now';
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
}
function actorLabel(actor: BoardTaskLogActor): string {
if (actor.memberName) {
return actor.memberName;
}
if (actor.role === 'lead' || actor.isSidechain === false) {
return 'lead session';
}
if (actor.agentId) {
return `member ${actor.agentId.slice(0, 8)}`;
}
return `member session ${actor.sessionId.slice(0, 8)}`;
}
function normalizeResponse(response: BoardTaskLogStreamResponse): BoardTaskLogStreamResponse {
return {
participants: response.participants,
defaultFilter: response.defaultFilter,
source: response.source,
runtimeProjection: response.runtimeProjection,
segments: response.segments.map((segment) => ({
...segment,
chunks: asEnhancedChunkArray(segment.chunks) ?? [],
})),
};
}
function buildStableSegmentRenderKey(segment: BoardTaskLogSegment): string {
const firstChunkId = segment.chunks[0]?.id;
if (firstChunkId) {
return `${segment.participantKey}:${firstChunkId}`;
}
return `${segment.participantKey}:${segment.startTimestamp}`;
}
function describeStreamSource(stream: BoardTaskLogStreamResponse | null): string { function describeStreamSource(stream: BoardTaskLogStreamResponse | null): string {
if (stream?.source === 'opencode_runtime_attribution') { if (stream?.source === 'opencode_runtime_attribution') {
return 'Task-scoped OpenCode runtime logs projected from explicit task attribution into the same execution-log components used in Logs.'; return 'Task-scoped OpenCode runtime logs projected from explicit task attribution into the same execution-log components used in Logs.';
@ -110,142 +48,6 @@ function describeStreamSource(stream: BoardTaskLogStreamResponse | null): string
return 'Task-scoped transcript logs rendered with the same execution-log components used in Logs.'; return 'Task-scoped transcript logs rendered with the same execution-log components used in Logs.';
} }
interface ParticipantVisual {
name: string;
color?: string;
}
function buildParticipantVisualMap(
stream: BoardTaskLogStreamResponse | null,
members: readonly ResolvedTeamMember[],
memberColorMap: ReadonlyMap<string, string>
): Map<string, ParticipantVisual> {
const visuals = new Map<string, ParticipantVisual>();
const leadMember = members.find((member) => isLeadMember(member));
for (const participant of stream?.participants ?? []) {
const matchingSegment = stream?.segments.find(
(segment) => segment.participantKey === participant.key
);
const name =
matchingSegment?.actor.memberName ??
(participant.isLead ? leadMember?.name : undefined) ??
participant.label;
visuals.set(participant.key, {
name,
color: memberColorMap.get(name) ?? memberColorMap.get(participant.label),
});
}
for (const segment of stream?.segments ?? []) {
if (visuals.has(segment.participantKey)) {
continue;
}
const name = segment.actor.memberName ?? actorLabel(segment.actor);
visuals.set(segment.participantKey, {
name,
color: memberColorMap.get(name),
});
}
return visuals;
}
const SegmentMarker = ({
segment,
visual,
teamName,
}: {
segment: BoardTaskLogSegment;
visual?: ParticipantVisual;
teamName: string;
}): React.JSX.Element => {
return (
<div className="mb-2 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
{visual ? (
<MemberBadge
name={visual.name}
color={visual.color}
teamName={teamName}
size="xs"
disableHoverCard
/>
) : null}
<span className="flex items-center gap-1">
<Clock size={10} />
{formatRelativeTime(segment.endTimestamp)}
</span>
</div>
);
};
const SegmentBlock = ({
segment,
showHeader,
teamName,
visual,
}: {
segment: BoardTaskLogSegment;
showHeader: boolean;
teamName: string;
visual?: ParticipantVisual;
}): React.JSX.Element => {
return (
<div className="min-w-0 overflow-hidden">
{showHeader ? <SegmentMarker segment={segment} visual={visual} teamName={teamName} /> : null}
<MemberExecutionLog
chunks={segment.chunks}
memberName={segment.actor.memberName}
memberColor={visual?.color}
teamName={teamName}
hideMemberHeading={showHeader && Boolean(segment.actor.memberName)}
/>
</div>
);
};
const ParticipantFilterChip = ({
label,
selected,
visual,
teamName,
onClick,
}: {
label: string;
selected: boolean;
visual?: ParticipantVisual;
teamName: string;
onClick: () => void;
}): React.JSX.Element => {
const { isLight } = useTheme();
const colors = getTeamColorSet(visual?.color ?? '');
const borderColor = selected ? getThemedBorder(colors, isLight) : 'var(--color-border)';
const backgroundColor = selected ? getThemedBadge(colors, isLight) : 'transparent';
const textColor = selected ? getThemedText(colors, isLight) : 'var(--color-text-muted)';
return (
<button
type="button"
className="rounded-full border px-2 py-1 text-[11px] transition-colors hover:text-[var(--color-text)]"
style={{ borderColor, backgroundColor, color: textColor }}
onClick={onClick}
>
{visual ? (
<MemberBadge
name={visual.name}
color={visual.color}
teamName={teamName}
size="xs"
disableHoverCard
/>
) : (
label
)}
</button>
);
};
export const TaskLogStreamSection = ({ export const TaskLogStreamSection = ({
teamName, teamName,
taskId, taskId,
@ -255,7 +57,6 @@ export const TaskLogStreamSection = ({
const [stream, setStream] = useState<BoardTaskLogStreamResponse | null>(null); const [stream, setStream] = useState<BoardTaskLogStreamResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedParticipantKey, setSelectedParticipantKey] = useState<'all' | string>('all');
const teamMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName)); const teamMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName));
const requestSeqRef = useRef(0); const requestSeqRef = useRef(0);
const streamRef = useRef<BoardTaskLogStreamResponse | null>(null); const streamRef = useRef<BoardTaskLogStreamResponse | null>(null);
@ -266,41 +67,25 @@ export const TaskLogStreamSection = ({
}, [stream]); }, [stream]);
const loadStream = useCallback( const loadStream = useCallback(
async (options?: { resetSelection?: boolean; background?: boolean }): Promise<void> => { async (options?: { background?: boolean }): Promise<void> => {
const resetSelection = options?.resetSelection ?? false;
const background = options?.background ?? false; const background = options?.background ?? false;
const hadExistingStream = streamRef.current != null; const hadExistingStream = streamRef.current != null;
const requestSeq = requestSeqRef.current + 1; const requestSeq = requestSeqRef.current + 1;
requestSeqRef.current = requestSeq; requestSeqRef.current = requestSeq;
if (!background) { if (!background) setLoading(true);
setLoading(true);
}
setError((prev) => (background ? prev : null)); setError((prev) => (background ? prev : null));
try { try {
const response = normalizeResponse(await api.teams.getTaskLogStream(teamName, taskId)); const response = normalizeExecutionLogStream(
if (requestSeqRef.current !== requestSeq) { await api.teams.getTaskLogStream(teamName, taskId)
return; );
} if (requestSeqRef.current !== requestSeq) return;
setStream(response); setStream(response);
setSelectedParticipantKey((prev) => {
if (resetSelection) {
return response.defaultFilter;
}
const availableParticipantKeys = new Set([
'all',
...response.participants.map((participant) => participant.key),
]);
return availableParticipantKeys.has(prev) ? prev : response.defaultFilter;
});
setError(null); setError(null);
} catch (loadError) { } catch (loadError) {
if (requestSeqRef.current !== requestSeq) { if (requestSeqRef.current !== requestSeq) return;
return;
}
if (!background || streamRef.current == null) { if (!background || streamRef.current == null) {
setError( setError(
loadError instanceof Error ? loadError.message : 'Failed to load task log stream' loadError instanceof Error ? loadError.message : 'Failed to load task log stream'
@ -320,13 +105,12 @@ export const TaskLogStreamSection = ({
setStream(null); setStream(null);
streamRef.current = null; streamRef.current = null;
setError(null); setError(null);
setSelectedParticipantKey('all');
requestSeqRef.current += 1; requestSeqRef.current += 1;
if (reloadTimerRef.current) { if (reloadTimerRef.current) {
clearTimeout(reloadTimerRef.current); clearTimeout(reloadTimerRef.current);
reloadTimerRef.current = null; reloadTimerRef.current = null;
} }
void loadStream({ resetSelection: true }); void loadStream();
}, [loadStream]); }, [loadStream]);
const previousTaskMetaRef = useRef({ taskId, taskStatus }); const previousTaskMetaRef = useRef({ taskId, taskStatus });
@ -335,10 +119,7 @@ export const TaskLogStreamSection = ({
const previousTaskMeta = previousTaskMetaRef.current; const previousTaskMeta = previousTaskMetaRef.current;
previousTaskMetaRef.current = { taskId, taskStatus }; previousTaskMetaRef.current = { taskId, taskStatus };
if (previousTaskMeta.taskId !== taskId) { if (previousTaskMeta.taskId !== taskId) return;
return;
}
if ( if (
previousTaskMeta.taskStatus === 'in_progress' && previousTaskMeta.taskStatus === 'in_progress' &&
taskStatus && taskStatus &&
@ -358,12 +139,8 @@ export const TaskLogStreamSection = ({
} }
const scheduleReload = (): void => { const scheduleReload = (): void => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
return; if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
}
if (reloadTimerRef.current) {
clearTimeout(reloadTimerRef.current);
}
reloadTimerRef.current = setTimeout(() => { reloadTimerRef.current = setTimeout(() => {
reloadTimerRef.current = null; reloadTimerRef.current = null;
void loadStream({ background: true }); void loadStream({ background: true });
@ -371,22 +148,15 @@ export const TaskLogStreamSection = ({
}; };
const unsubscribe = api.teams.onTeamChange?.((_event, event) => { const unsubscribe = api.teams.onTeamChange?.((_event, event) => {
if (event.teamName !== teamName) { if (event.teamName !== teamName) return;
return;
}
const shouldReload = const shouldReload =
event.type === 'log-source-change' || event.type === 'log-source-change' ||
(isTaskLogActivityChangeEvent(event) && event.taskId === taskId); (isTaskLogActivityChangeEvent(event) && event.taskId === taskId);
if (!shouldReload) { if (shouldReload) scheduleReload();
return;
}
scheduleReload();
}); });
const handleVisibilityChange = (): void => { const handleVisibilityChange = (): void => {
if (document.visibilityState === 'visible') { if (document.visibilityState === 'visible') scheduleReload();
scheduleReload();
}
}; };
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
@ -401,115 +171,25 @@ export const TaskLogStreamSection = ({
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener('visibilitychange', handleVisibilityChange);
} }
if (typeof unsubscribe === 'function') { if (typeof unsubscribe === 'function') unsubscribe();
unsubscribe();
}
}; };
}, [liveEnabled, loadStream, taskId, teamName]); }, [liveEnabled, loadStream, taskId, teamName]);
const participants = stream?.participants ?? [];
const memberColorMap = useMemo(() => buildMemberColorMap(teamMembers), [teamMembers]);
const participantVisuals = useMemo(
() => buildParticipantVisualMap(stream, teamMembers, memberColorMap),
[memberColorMap, stream, teamMembers]
);
const showChips = participants.length > 1;
const streamDescription = useMemo(() => describeStreamSource(stream), [stream]); const streamDescription = useMemo(() => describeStreamSource(stream), [stream]);
const visibleSegments = useMemo(() => {
const source = stream?.segments ?? [];
const filtered =
selectedParticipantKey === 'all'
? source
: source.filter((segment) => segment.participantKey === selectedParticipantKey);
return [...filtered].reverse();
}, [selectedParticipantKey, stream?.segments]);
const showSegmentHeaders =
participants.length > 1 || (selectedParticipantKey !== 'all' && visibleSegments.length > 1);
if (loading) {
return (
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
Task Log Stream
</h4>
<div className="flex items-center gap-2 py-4 text-xs text-[var(--color-text-muted)]">
<Loader2 size={12} className="animate-spin" />
Loading task log stream...
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
Task Log Stream
</h4>
<div className="flex items-center gap-2 py-4 text-xs text-red-400">
<AlertCircle size={14} />
{error}
</div>
</div>
);
}
return ( return (
<div className="space-y-3"> <ExecutionLogStreamView
<h4 className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-muted)]"> title="Task Log Stream"
Task Log Stream description={streamDescription}
</h4> stream={stream}
<p className="text-xs text-[var(--color-text-muted)]">{streamDescription}</p> loading={loading}
error={error}
{showChips ? ( teamName={teamName}
<div className="flex flex-wrap items-center gap-1.5"> teamMembers={teamMembers}
<button loadingText="Loading task log stream..."
type="button" emptyTitle="No task log stream yet"
className={`rounded-full border px-2.5 py-1 text-[11px] transition-colors ${ emptyDescription="Task-linked logs will appear here when transcript metadata or runtime projection is available."
selectedParticipantKey === 'all' selectionResetKey={`${teamName}:${taskId}`}
? 'bg-[var(--color-accent)]/10 border-[var(--color-accent)] text-[var(--color-text)]' />
: 'border-[var(--color-border)] text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
}`}
onClick={() => setSelectedParticipantKey('all')}
>
All
</button>
{participants.map((participant) => (
<ParticipantFilterChip
key={participant.key}
label={participant.label}
selected={selectedParticipantKey === participant.key}
visual={participantVisuals.get(participant.key)}
teamName={teamName}
onClick={() => setSelectedParticipantKey(participant.key)}
/>
))}
</div>
) : null}
{visibleSegments.length === 0 ? (
<div className="py-8 text-center text-xs text-[var(--color-text-muted)]">
<FileText size={20} className="mx-auto mb-2 opacity-40" />
No task log stream yet
<p className="mt-1 text-[10px] opacity-60">
Task-linked logs will appear here when transcript metadata or runtime projection is
available.
</p>
</div>
) : (
<div className="space-y-6">
{visibleSegments.map((segment) => (
<SegmentBlock
key={buildStableSegmentRenderKey(segment)}
segment={segment}
showHeader={showSegmentHeaders}
teamName={teamName}
visual={participantVisuals.get(segment.participantKey)}
/>
))}
</div>
)}
</div>
); );
}; };

View file

@ -98,6 +98,7 @@ import type { TerminalAPI } from './terminal';
import type { TmuxAPI } from './tmux'; import type { TmuxAPI } from './tmux';
import type { WaterfallData } from './visualization'; import type { WaterfallData } from './visualization';
import type { CodexAccountElectronApi } from '@features/codex-account/contracts'; import type { CodexAccountElectronApi } from '@features/codex-account/contracts';
import type { MemberLogStreamApi } from '@features/member-log-stream/contracts';
import type { import type {
MemberWorkSyncMetricsRequest, MemberWorkSyncMetricsRequest,
MemberWorkSyncReportRequest, MemberWorkSyncReportRequest,
@ -904,6 +905,9 @@ export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElec
// Member actionable-work sync diagnostics API // Member actionable-work sync diagnostics API
memberWorkSync: MemberWorkSyncElectronApi; memberWorkSync: MemberWorkSyncElectronApi;
// Member log stream API
memberLogStream: MemberLogStreamApi;
// tmux runtime diagnostics API // tmux runtime diagnostics API
tmux: TmuxAPI; tmux: TmuxAPI;

View file

@ -808,8 +808,14 @@ export interface ResolvedTeamMember {
workflow?: string; workflow?: string;
isolation?: 'worktree'; isolation?: 'worktree';
providerId?: TeamProviderId; providerId?: TeamProviderId;
providerBackendId?: TeamProviderBackendId;
model?: string; model?: string;
effort?: EffortLevel; effort?: EffortLevel;
selectedFastMode?: TeamFastMode;
resolvedFastMode?: boolean;
laneId?: string;
laneKind?: 'primary' | 'secondary';
laneOwnerProviderId?: TeamProviderId;
cwd?: string; cwd?: string;
/** Set only when member's git branch differs from the lead's branch. */ /** Set only when member's git branch differs from the lead's branch. */
gitBranch?: string; gitBranch?: string;
@ -910,7 +916,13 @@ export interface TeamViewSnapshot {
export type EffortLevel = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'; export type EffortLevel = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
export type TeamProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode'; export type TeamProviderId = 'anthropic' | 'codex' | 'gemini' | 'opencode';
export type TeamProviderBackendId = 'auto' | 'adapter' | 'api' | 'cli-sdk' | 'codex-native'; export type TeamProviderBackendId =
| 'auto'
| 'adapter'
| 'api'
| 'cli-sdk'
| 'codex-native'
| 'opencode-cli';
export type TeamFastMode = 'inherit' | 'on' | 'off'; export type TeamFastMode = 'inherit' | 'on' | 'off';
export interface ProviderModelLaunchIdentity { export interface ProviderModelLaunchIdentity {

View file

@ -954,8 +954,8 @@ describe('ClaudeMultimodelBridgeService', () => {
messageCount: 2, messageCount: 2,
toolCallCount: 1, toolCallCount: 1,
errorCount: 0, errorCount: 0,
latestAssistantText: '/tmp/project', latestAssistantText: '/Users/tester/project',
latestAssistantPreview: '/tmp/project', latestAssistantPreview: '/Users/tester/project',
messages: [], messages: [],
diagnostics: [], diagnostics: [],
logProjection: { logProjection: {
@ -1027,6 +1027,65 @@ describe('ClaudeMultimodelBridgeService', () => {
}); });
}); });
it('passes OpenCode lane and popup timeout to the runtime transcript command', async () => {
execCliMock.mockImplementation(async (_binaryPath, args) => {
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
if (
normalizedArgs.startsWith(
'runtime transcript --json --provider opencode --team team-a --member alice --projection-only --limit 20 --lane secondary:opencode:alice --output '
)
) {
const outputIndex = Array.isArray(args) ? args.indexOf('--output') : -1;
const outputPath =
outputIndex >= 0 && Array.isArray(args) ? String(args[outputIndex + 1] ?? '') : '';
await writeFile(
outputPath,
JSON.stringify({
schemaVersion: 1,
providerId: 'opencode',
transcript: {
sessionId: 'session-lane',
durableState: 'idle',
messages: [],
diagnostics: [],
logProjection: {
messages: [],
},
},
}),
'utf8'
);
return Promise.resolve({
stdout: '',
stderr: '',
exitCode: 0,
});
}
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
});
const { ClaudeMultimodelBridgeService } =
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
const service = new ClaudeMultimodelBridgeService();
const transcript = await service.getOpenCodeTranscript('/mock/agent_teams_orchestrator', {
teamId: 'team-a',
memberName: 'alice',
limit: 20,
laneId: ' secondary:opencode:alice ',
timeoutMs: 1_234,
});
expect(transcript?.sessionId).toBe('session-lane');
expect(execCliMock).toHaveBeenCalledWith(
'/mock/agent_teams_orchestrator',
expect.arrayContaining(['--lane', 'secondary:opencode:alice']),
expect.objectContaining({ timeout: 1_234 })
);
});
it('loads a large real OpenCode projection fixture through output-file transcript delivery', async () => { it('loads a large real OpenCode projection fixture through output-file transcript delivery', async () => {
const fixturePath = path.resolve( const fixturePath = path.resolve(
process.cwd(), process.cwd(),

View file

@ -37,39 +37,43 @@ describe('TeamMemberLogsFinder', () => {
await fs.mkdir(projectRoot, { recursive: true }); await fs.mkdir(projectRoot, { recursive: true });
const projectResolver = { const projectResolver = {
getLiveBaseContext: vi.fn(async () => ({ getLiveBaseContext: vi.fn(() =>
projectDir: projectRoot, Promise.resolve({
projectId: '-Users-test-live-context', projectDir: projectRoot,
config, projectId: '-Users-test-live-context',
})), config,
getContext: vi.fn(async () => { })
throw new Error('broad context must not be used for live tracking'); ),
}), getContext: vi.fn(() =>
Promise.reject(new Error('broad context must not be used for live tracking'))
),
}; };
const launchStateStore = { const launchStateStore = {
read: vi.fn(async () => ({ read: vi.fn(() =>
version: 2, Promise.resolve({
teamName, version: 2,
updatedAt: '2026-05-03T12:00:00.000Z', teamName,
leadSessionId: 'lead-session', updatedAt: '2026-05-03T12:00:00.000Z',
launchPhase: 'active', leadSessionId: 'lead-session',
expectedMembers: ['bob'], launchPhase: 'active',
members: { expectedMembers: ['bob'],
bob: { members: {
name: 'bob', bob: {
launchState: 'runtime_pending_bootstrap', name: 'bob',
agentToolAccepted: true, launchState: 'runtime_pending_bootstrap',
runtimeAlive: false, agentToolAccepted: true,
bootstrapConfirmed: false, runtimeAlive: false,
hardFailure: false, bootstrapConfirmed: false,
runtimeSessionId: 'runtime-bob', hardFailure: false,
cwd: runtimeProjectPath, runtimeSessionId: 'runtime-bob',
updatedAt: '2026-05-03T12:00:00.000Z', cwd: runtimeProjectPath,
updatedAt: '2026-05-03T12:00:00.000Z',
},
}, },
}, summary: {},
summary: {}, teamLaunchState: 'partial_pending',
teamLaunchState: 'partial_pending', })
})), ),
}; };
const finder = new TeamMemberLogsFinder( const finder = new TeamMemberLogsFinder(
@ -429,6 +433,97 @@ describe('TeamMemberLogsFinder', () => {
expect(refs.some((ref) => ref.memberName === 'Tom')).toBe(false); expect(refs.some((ref) => ref.memberName === 'Tom')).toBe(false);
}); });
it('applies recent-ref object options to discovery, lead refs, metadata, and requested-member attribution', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
setClaudeBasePathOverride(tmpDir);
const teamName = 'member-stream-ref-options';
const projectPath = '/Users/test/member-stream-ref-options';
const projectId = '-Users-test-member-stream-ref-options';
const leadSessionId = 'lead-session';
const recentSince = Date.now() - 10 * 60_000;
const old = new Date(Date.now() - 30 * 60_000);
const now = new Date();
const projectRoot = path.join(tmpDir, 'projects', projectId);
const subagentsDir = path.join(projectRoot, leadSessionId, 'subagents');
await fs.mkdir(subagentsDir, { recursive: true });
const leadPath = path.join(projectRoot, `${leadSessionId}.jsonl`);
await fs.writeFile(
leadPath,
JSON.stringify({
timestamp: old.toISOString(),
type: 'user',
message: { role: 'user', content: `Lead for team "${teamName}" (${teamName})` },
}) + '\n',
'utf8'
);
await fs.utimes(leadPath, old, old);
const zoePath = path.join(subagentsDir, 'agent-zoe.jsonl');
await fs.writeFile(
zoePath,
[
JSON.stringify({
timestamp: now.toISOString(),
type: 'user',
message: {
role: 'user',
content: `You are Zoe, a developer on team "${teamName}" (${teamName}).`,
},
}),
JSON.stringify({
timestamp: now.toISOString(),
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'Ready' }] },
}),
].join('\n') + '\n',
'utf8'
);
await fs.utimes(zoePath, now, now);
const projectResolver = {
getContext: vi.fn(() =>
Promise.resolve({
projectDir: projectRoot,
projectId,
sessionIds: [leadSessionId],
config: {
name: teamName,
projectPath,
leadSessionId,
members: [{ name: 'team-lead', agentType: 'team-lead' }],
},
})
),
};
const finder = new TeamMemberLogsFinder(
undefined,
undefined,
undefined,
projectResolver as never
);
const refs = await finder.findRecentMemberLogFileRefsByMember(teamName, ['team-lead', 'Zoe'], {
mtimeSinceMs: recentSince,
forceRefresh: true,
});
expect(projectResolver.getContext).toHaveBeenCalledWith(
teamName,
expect.objectContaining({ forceRefresh: true })
);
expect(refs).toEqual([
expect.objectContaining({
memberName: 'Zoe',
filePath: zoePath,
kind: 'subagent',
sizeBytes: expect.any(Number),
}),
]);
expect(refs.some((ref) => ref.filePath === leadPath)).toBe(false);
});
it('listAttributedSubagentFiles only returns files from the current lead session for live tracking', async () => { it('listAttributedSubagentFiles only returns files from the current lead session for live tracking', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
setClaudeBasePathOverride(tmpDir); setClaudeBasePathOverride(tmpDir);
@ -463,18 +558,22 @@ describe('TeamMemberLogsFinder', () => {
await fs.mkdir(path.join(projectRoot, currentSessionId, 'subagents'), { recursive: true }); await fs.mkdir(path.join(projectRoot, currentSessionId, 'subagents'), { recursive: true });
await fs.mkdir(path.join(projectRoot, oldSessionId, 'subagents'), { recursive: true }); await fs.mkdir(path.join(projectRoot, oldSessionId, 'subagents'), { recursive: true });
const attributedLog = [ const attributedLog =
JSON.stringify({ [
timestamp: '2026-01-01T00:00:01.000Z', JSON.stringify({
type: 'user', timestamp: '2026-01-01T00:00:01.000Z',
message: { role: 'user', content: `You are alice, a developer on team "${teamName}" (${teamName}).` }, type: 'user',
}), message: {
JSON.stringify({ role: 'user',
timestamp: '2026-01-01T00:00:02.000Z', content: `You are alice, a developer on team "${teamName}" (${teamName}).`,
type: 'assistant', },
message: { role: 'assistant', content: [{ type: 'text', text: 'OK' }] }, }),
}), JSON.stringify({
].join('\n') + '\n'; timestamp: '2026-01-01T00:00:02.000Z',
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'OK' }] },
}),
].join('\n') + '\n';
await fs.writeFile( await fs.writeFile(
path.join(projectRoot, currentSessionId, 'subagents', 'agent-current.jsonl'), path.join(projectRoot, currentSessionId, 'subagents', 'agent-current.jsonl'),
@ -1270,7 +1369,11 @@ describe('TeamMemberLogsFinder', () => {
message: { message: {
role: 'assistant', role: 'assistant',
content: [ content: [
{ type: 'tool_use', name: 'TaskUpdate', input: { taskId: '5', status: 'in_progress' } }, {
type: 'tool_use',
name: 'TaskUpdate',
input: { taskId: '5', status: 'in_progress' },
},
], ],
}, },
}), }),
@ -1424,7 +1527,11 @@ describe('TeamMemberLogsFinder', () => {
message: { message: {
role: 'assistant', role: 'assistant',
content: [ content: [
{ type: 'tool_use', name: 'TaskUpdate', input: { taskId: '3', status: 'in_progress' } }, {
type: 'tool_use',
name: 'TaskUpdate',
input: { taskId: '3', status: 'in_progress' },
},
], ],
}, },
}), }),

View file

@ -6,6 +6,10 @@ import { useStore } from '@renderer/store';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
const memberLogStreamMockState = vi.hoisted(() => ({
uiEnabled: true,
}));
vi.mock('@renderer/hooks/useMemberStats', () => ({ vi.mock('@renderer/hooks/useMemberStats', () => ({
useMemberStats: () => ({ useMemberStats: () => ({
stats: null, stats: null,
@ -110,11 +114,32 @@ vi.mock('@renderer/components/team/members/MemberLogsTab', () => ({
MemberLogsTab: () => React.createElement('div', null, 'logs-tab'), MemberLogsTab: () => React.createElement('div', null, 'logs-tab'),
})); }));
vi.mock('@features/member-log-stream/renderer', async () => {
const ReactModule = await import('react');
return {
isMemberLogStreamUiEnabled: () => memberLogStreamMockState.uiEnabled,
MemberLogStreamSection: ({
onInitialLoadErrorChange,
}: {
onInitialLoadErrorChange?: (hasError: boolean) => void;
}) =>
ReactModule.createElement(
'button',
{
type: 'button',
onClick: () => onInitialLoadErrorChange?.(true),
},
'member-log-stream'
),
};
});
import { MemberDetailDialog } from '@renderer/components/team/members/MemberDetailDialog'; import { MemberDetailDialog } from '@renderer/components/team/members/MemberDetailDialog';
describe('MemberDetailDialog activity count', () => { describe('MemberDetailDialog activity count', () => {
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
memberLogStreamMockState.uiEnabled = true;
useStore.setState({ useStore.setState({
teamMessagesByName: { teamMessagesByName: {
'demo-team': { 'demo-team': {
@ -139,6 +164,98 @@ describe('MemberDetailDialog activity count', () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it('renders legacy member logs directly when the member log stream UI gate is off', async () => {
memberLogStreamMockState.uiEnabled = false;
const member: ResolvedTeamMember = {
name: 'jack',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberDetailDialog, {
open: true,
member,
teamName: 'demo-team',
members: [member],
tasks: [],
initialTab: 'logs',
onClose: () => undefined,
onSendMessage: () => undefined,
onAssignTask: () => undefined,
onTaskClick: () => undefined,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('logs-tab');
expect(host.textContent).not.toContain('member-log-stream');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('keeps the stream visible and renders legacy fallback after an initial stream error', async () => {
const member: ResolvedTeamMember = {
name: 'jack',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberDetailDialog, {
open: true,
member,
teamName: 'demo-team',
members: [member],
tasks: [],
initialTab: 'logs',
onClose: () => undefined,
onSendMessage: () => undefined,
onAssignTask: () => undefined,
onTaskClick: () => undefined,
})
);
await Promise.resolve();
});
const streamButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('member-log-stream')
);
expect(streamButton).not.toBeUndefined();
await act(async () => {
streamButton?.click();
await Promise.resolve();
});
expect(host.textContent).toContain('member-log-stream');
expect(host.textContent).toContain('Legacy Logs Fallback');
expect(host.textContent).toContain('logs-tab');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('counts task comments in the Activity badge even when messageCount is zero', async () => { it('counts task comments in the Activity badge even when messageCount is zero', async () => {
const member: ResolvedTeamMember = { const member: ResolvedTeamMember = {
name: 'jack', name: 'jack',
@ -348,7 +465,7 @@ describe('MemberDetailDialog activity count', () => {
messageCount: 0, messageCount: 0,
providerId: 'opencode', providerId: 'opencode',
}; };
const onRestartMember = vi.fn(async () => undefined); const onRestartMember = vi.fn(() => Promise.resolve(undefined));
const host = document.createElement('div'); const host = document.createElement('div');
document.body.appendChild(host); document.body.appendChild(host);
const root = createRoot(host); const root = createRoot(host);
@ -424,7 +541,7 @@ describe('MemberDetailDialog activity count', () => {
messageCount: 0, messageCount: 0,
providerId: 'opencode', providerId: 'opencode',
}; };
const onRestartMember = vi.fn(async () => undefined); const onRestartMember = vi.fn(() => Promise.resolve(undefined));
const host = document.createElement('div'); const host = document.createElement('div');
document.body.appendChild(host); document.body.appendChild(host);
const root = createRoot(host); const root = createRoot(host);

View file

@ -0,0 +1,378 @@
/* eslint-disable security/detect-non-literal-fs-filename -- Fixture E2E reads a repo fixture and writes temp JSONL. */
import { readFile, rm, stat, writeFile, mkdtemp } from 'fs/promises';
import os from 'os';
import path from 'path';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { GetMemberLogStreamUseCase } from '../../../../../src/features/member-log-stream/core/application/use-cases/GetMemberLogStreamUseCase';
import {
type MemberLogStreamRequestOptions,
type MemberLogStreamResponse,
} from '../../../../../src/features/member-log-stream/contracts';
import { ClaudeMemberTranscriptStreamSource } from '../../../../../src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptStreamSource';
import { OpenCodeMemberRuntimeStreamSource } from '../../../../../src/features/member-log-stream/main/adapters/output/sources/OpenCodeMemberRuntimeStreamSource';
import { BoardTaskExactLogChunkBuilder } from '../../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder';
import { BoardTaskExactLogStrictParser } from '../../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser';
import { TooltipProvider } from '../../../../../src/renderer/components/ui/tooltip';
import type { OpenCodeRuntimeTranscriptResponse } from '../../../../../src/main/services/runtime/ClaudeMultimodelBridgeService';
import type { MemberLogFileRef } from '../../../../../src/main/services/team/TeamMemberLogsFinder';
import type { ResolvedTeamMember } from '../../../../../src/shared/types';
const TEAM_NAME = 'relay-works-10';
const MEMBER_NAME = 'jack';
const LANE_ID = 'secondary:opencode:jack';
const GENERATED_AT = '2026-04-24T20:40:00.000Z';
const FIXTURE_PATH = path.resolve(
process.cwd(),
'test/fixtures/team/opencode/relay-works-10-jack-projection-transcript.json'
);
const tempDirs: string[] = [];
const apiState = {
getMemberLogStream:
vi.fn<
(
teamName: string,
memberName: string,
options?: MemberLogStreamRequestOptions
) => Promise<MemberLogStreamResponse>
>(),
setMemberLogStreamTracking: vi.fn<(teamName: string, enabled: boolean) => Promise<void>>(),
onTeamChange: vi.fn<(callback: (event: unknown, data: unknown) => void) => () => void>(),
};
vi.mock('@renderer/api', () => ({
api: {
memberLogStream: {
getMemberLogStream: (...args: Parameters<typeof apiState.getMemberLogStream>) =>
apiState.getMemberLogStream(...args),
setMemberLogStreamTracking: (
...args: Parameters<typeof apiState.setMemberLogStreamTracking>
) => apiState.setMemberLogStreamTracking(...args),
},
teams: {
onTeamChange: (...args: Parameters<typeof apiState.onTeamChange>) =>
apiState.onTeamChange(...args),
},
},
}));
import { MemberLogStreamSection } from '../../../../../src/features/member-log-stream/renderer';
function flushMicrotasks(): Promise<void> {
return Promise.resolve();
}
function flushAsyncWork(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
async function waitForText(
host: HTMLElement,
predicate: (text: string) => boolean
): Promise<string> {
let text = '';
for (let attempt = 0; attempt < 25; attempt += 1) {
await act(async () => {
await flushAsyncWork();
});
text = host.textContent ?? '';
if (predicate(text)) {
return text;
}
}
return text;
}
async function loadOpenCodeFixtureTranscript(): Promise<
NonNullable<OpenCodeRuntimeTranscriptResponse['transcript']>
> {
const parsed = JSON.parse(
await readFile(FIXTURE_PATH, 'utf8')
) as OpenCodeRuntimeTranscriptResponse;
if (parsed.providerId !== 'opencode' || !parsed.transcript) {
throw new Error('Invalid OpenCode transcript fixture');
}
return parsed.transcript;
}
async function createClaudeTranscriptRef(): Promise<MemberLogFileRef> {
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'member-log-stream-e2e-'));
tempDirs.push(tempDir);
const filePath = path.join(tempDir, 'jack-claude-session.jsonl');
const rows = [
{
parentUuid: null,
isSidechain: true,
userType: 'external',
cwd: '/Users/tester/project',
sessionId: 'claude-session-jack',
version: '1.0.0',
gitBranch: 'main',
agentName: MEMBER_NAME,
type: 'system',
uuid: 'claude-init',
timestamp: '2026-04-24T20:25:00.000Z',
subtype: 'init',
level: 'info',
isMeta: false,
content: 'member session started',
},
{
parentUuid: 'claude-init',
isSidechain: true,
userType: 'external',
cwd: '/Users/tester/project',
sessionId: 'claude-session-jack',
version: '1.0.0',
gitBranch: 'main',
agentName: MEMBER_NAME,
type: 'user',
uuid: 'claude-user-1',
timestamp: '2026-04-24T20:25:01.000Z',
isMeta: false,
message: {
role: 'user',
content: 'Collect member-wide evidence for calculator behavior.',
},
},
{
parentUuid: 'claude-user-1',
isSidechain: true,
userType: 'external',
cwd: '/Users/tester/project',
sessionId: 'claude-session-jack',
version: '1.0.0',
gitBranch: 'main',
agentName: MEMBER_NAME,
type: 'assistant',
uuid: 'claude-assistant-1',
requestId: 'req-claude-1',
timestamp: '2026-04-24T20:25:03.000Z',
message: {
role: 'assistant',
id: 'msg-claude-1',
type: 'message',
model: 'claude-sonnet-4-5-20250929',
content: [
{
type: 'text',
text: 'Member-wide Claude transcript final note for Jack.',
},
],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 12, output_tokens: 16 },
},
},
];
await writeFile(filePath, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
const fileStat = await stat(filePath);
return {
memberName: MEMBER_NAME,
sessionId: 'claude-session-jack',
filePath,
mtimeMs: fileStat.mtimeMs,
sizeBytes: fileStat.size,
messageCount: rows.length,
kind: 'subagent',
};
}
async function createFixtureUseCase(): Promise<{
useCase: GetMemberLogStreamUseCase;
getOpenCodeTranscript: ReturnType<typeof vi.fn>;
findRecentMemberLogFileRefsByMember: ReturnType<typeof vi.fn>;
}> {
const claudeRef = await createClaudeTranscriptRef();
const openCodeTranscript = await loadOpenCodeFixtureTranscript();
const findRecentMemberLogFileRefsByMember = vi.fn(() => Promise.resolve([claudeRef]));
const getOpenCodeTranscript = vi.fn(() => Promise.resolve(openCodeTranscript));
const chunkBuilder = new BoardTaskExactLogChunkBuilder();
const sources = [
new ClaudeMemberTranscriptStreamSource(
{ findRecentMemberLogFileRefsByMember } as never,
new BoardTaskExactLogStrictParser(),
chunkBuilder,
{ warn: vi.fn(), error: vi.fn(), debug: vi.fn() }
),
new OpenCodeMemberRuntimeStreamSource(
{ getOpenCodeTranscript } as never,
chunkBuilder,
{ resolve: vi.fn(() => Promise.resolve('/Users/tester/agent_teams_orchestrator')) }
),
];
return {
useCase: new GetMemberLogStreamUseCase({
sources,
clock: { now: () => Date.parse(GENERATED_AT) },
logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}),
getOpenCodeTranscript,
findRecentMemberLogFileRefsByMember,
};
}
function createMember(): ResolvedTeamMember {
return {
name: MEMBER_NAME,
status: 'idle',
currentTaskId: null,
taskCount: 2,
lastActiveAt: '2026-04-24T20:34:00.000Z',
messageCount: 12,
color: 'blue',
providerId: 'opencode',
laneId: LANE_ID,
laneKind: 'secondary',
laneOwnerProviderId: 'opencode',
};
}
function stubMatchMedia(): void {
const matchMedia = vi.fn((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
vi.stubGlobal('matchMedia', matchMedia);
}
function expectCapturedResponse(
value: MemberLogStreamResponse | null
): MemberLogStreamResponse {
expect(value).not.toBeNull();
return value!;
}
describe('MemberLogStreamSection real fixture e2e', () => {
afterEach(async () => {
document.body.innerHTML = '';
apiState.getMemberLogStream.mockReset();
apiState.setMemberLogStreamTracking.mockReset();
apiState.onTeamChange.mockReset();
vi.unstubAllGlobals();
await Promise.all(
tempDirs.splice(0, tempDirs.length).map((dirPath) =>
rm(dirPath, { recursive: true, force: true })
)
);
});
it('renders member-wide Claude transcript and OpenCode runtime logs through the member Logs UI', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
stubMatchMedia();
apiState.onTeamChange.mockImplementation(() => () => undefined);
apiState.setMemberLogStreamTracking.mockResolvedValue(undefined);
const { useCase, getOpenCodeTranscript, findRecentMemberLogFileRefsByMember } =
await createFixtureUseCase();
const capturedResponseRef: { current: MemberLogStreamResponse | null } = { current: null };
apiState.getMemberLogStream.mockImplementation(async (teamName, memberName, options) => {
const response = await useCase.execute({
teamName,
memberName,
limitSegments: options?.limitSegments,
laneId: options?.laneId,
forceRefresh: options?.forceRefresh,
});
capturedResponseRef.current = response;
return response;
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(
TooltipProvider,
null,
React.createElement(MemberLogStreamSection, {
teamName: TEAM_NAME,
member: createMember(),
})
)
);
await flushMicrotasks();
});
const text = await waitForText(host, (content) =>
content.includes('Member-wide Claude transcript final note for Jack.')
);
expect(text).toContain('Logs');
expect(text).toContain('Member-scoped transcript and runtime logs');
expect(text).toContain('Claude transcript');
expect(text).toContain('OpenCode runtime');
expect(text).toContain('Calculator behavior');
expect(text).toContain('Logic smoke check');
expect(text).toContain('Collect member-wide evidence for calculator behavior.');
const capturedResponse = expectCapturedResponse(capturedResponseRef.current);
expect(capturedResponse).toMatchObject({
source: 'member_mixed_runtime',
defaultFilter: 'member:jack',
generatedAt: GENERATED_AT,
metadata: {
scannedTranscriptFileCount: 1,
includedTranscriptFileCount: 1,
},
});
expect(capturedResponse.coverage).toEqual(
expect.arrayContaining([
{ provider: 'claude_transcript', status: 'included' },
{ provider: 'opencode_runtime', status: 'included' },
])
);
expect(JSON.stringify(capturedResponse.segments)).toContain('Keyboard handlers added');
expect(apiState.getMemberLogStream).toHaveBeenCalledWith(
TEAM_NAME,
MEMBER_NAME,
expect.objectContaining({
limitSegments: 30,
laneId: LANE_ID,
})
);
expect(findRecentMemberLogFileRefsByMember).toHaveBeenCalledWith(
TEAM_NAME,
[MEMBER_NAME],
expect.objectContaining({ forceRefresh: false })
);
expect(getOpenCodeTranscript).toHaveBeenCalledWith(
'/Users/tester/agent_teams_orchestrator',
expect.objectContaining({
teamId: TEAM_NAME,
memberName: MEMBER_NAME,
laneId: LANE_ID,
limit: 400,
timeoutMs: 5_000,
})
);
await act(async () => {
root.unmount();
await flushMicrotasks();
});
expect(apiState.setMemberLogStreamTracking).toHaveBeenCalledWith(TEAM_NAME, true);
expect(apiState.setMemberLogStreamTracking).toHaveBeenCalledWith(TEAM_NAME, false);
});
});

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 }); 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 teamName = 'team-process-width';
const lead = createLead(teamName); const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice'); const alice = createMember(teamName, 'agent-alice', 'alice');
@ -170,9 +170,14 @@ describe('stable slot layout planner', () => {
const frame = snapshot?.memberSlotFrames[0]; const frame = snapshot?.memberSlotFrames[0];
expect(frame).toBeDefined(); expect(frame).toBeDefined();
expect(frame?.boardBandRect.top).toBe(frame?.activityColumnRect.top); 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?.boardBandRect.top).toBe(frame?.kanbanBandRect.top);
expect(frame?.activityColumnRect.left).toBe(frame?.boardBandRect.left); 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.width).toBe(computeProcessBandWidth(0));
expect(frame?.processBandRect.height).toBe(STABLE_SLOT_GEOMETRY.processBandHeight); expect(frame?.processBandRect.height).toBe(STABLE_SLOT_GEOMETRY.processBandHeight);
}); });
@ -289,12 +294,12 @@ describe('stable slot layout planner', () => {
version: 'stable-slots-v1', version: 'stable-slots-v1',
ownerOrder: members.map((member) => member.id), ownerOrder: members.map((member) => member.id),
slotAssignments: { slotAssignments: {
[members[0]!.id]: { ringIndex: 0, sectorIndex: 0 }, [members[0].id]: { ringIndex: 0, sectorIndex: 0 },
[members[1]!.id]: { ringIndex: 0, sectorIndex: 1 }, [members[1].id]: { ringIndex: 0, sectorIndex: 1 },
[members[2]!.id]: { ringIndex: 0, sectorIndex: 2 }, [members[2].id]: { ringIndex: 0, sectorIndex: 2 },
[members[3]!.id]: { ringIndex: 0, sectorIndex: 3 }, [members[3].id]: { ringIndex: 0, sectorIndex: 3 },
[members[4]!.id]: { ringIndex: 0, sectorIndex: 4 }, [members[4].id]: { ringIndex: 0, sectorIndex: 4 },
[members[5]!.id]: { ringIndex: 0, sectorIndex: 5 }, [members[5].id]: { ringIndex: 0, sectorIndex: 5 },
}, },
}; };
@ -307,8 +312,8 @@ describe('stable slot layout planner', () => {
expect(snapshot).not.toBeNull(); expect(snapshot).not.toBeNull();
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true }); expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
for (const frame of snapshot!.memberSlotFrames) { for (const frame of snapshot.memberSlotFrames) {
for (const centralRect of snapshot!.centralCollisionRects) { for (const centralRect of snapshot.centralCollisionRects) {
if (!rectsOverlapVertically(frame.bounds, centralRect)) { if (!rectsOverlapVertically(frame.bounds, centralRect)) {
continue; continue;
} }
@ -346,6 +351,7 @@ describe('stable slot layout planner', () => {
expect(footprint).toBeDefined(); expect(footprint).toBeDefined();
expect(footprint?.activityColumnWidth).toBe(ACTIVITY_LANE.width); expect(footprint?.activityColumnWidth).toBe(ACTIVITY_LANE.width);
expect(footprint?.logColumnWidth).toBe(260);
expect(footprint?.activityColumnHeight).toBe( expect(footprint?.activityColumnHeight).toBe(
ACTIVITY_LANE.headerHeight + ACTIVITY_LANE.headerHeight +
ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight + ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight +
@ -381,11 +387,15 @@ describe('stable slot layout planner', () => {
expect(footprint).toBeDefined(); expect(footprint).toBeDefined();
expect(footprint?.activityColumnWidth).toBe(0); expect(footprint?.activityColumnWidth).toBe(0);
expect(footprint?.activityColumnHeight).toBe(0); expect(footprint?.activityColumnHeight).toBe(0);
expect(footprint?.logColumnWidth).toBe(0);
expect(footprint?.logColumnHeight).toBe(0);
expect(footprint?.boardBandWidth).toBe(footprint?.kanbanBandWidth); expect(footprint?.boardBandWidth).toBe(footprint?.kanbanBandWidth);
expect(snapshot).not.toBeNull(); expect(snapshot).not.toBeNull();
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true }); expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
expect(frame?.activityColumnRect.width).toBe(0); expect(frame?.activityColumnRect.width).toBe(0);
expect(frame?.activityColumnRect.height).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); expect(frame?.kanbanBandRect.left).toBe(frame?.boardBandRect.left);
}); });
@ -414,19 +424,19 @@ describe('stable slot layout planner', () => {
expect(frame).toBeDefined(); expect(frame).toBeDefined();
expect(footprint).toBeDefined(); expect(footprint).toBeDefined();
const legacyHorizontalExtent = snapshot!.runtimeCentralExclusion.right; const legacyHorizontalExtent = snapshot.runtimeCentralExclusion.right;
const legacyVerticalExtent = Math.abs(snapshot!.runtimeCentralExclusion.top); const legacyVerticalExtent = Math.abs(snapshot.runtimeCentralExclusion.top);
const legacyRequiredX = const legacyRequiredX =
(legacyHorizontalExtent + footprint!.slotWidth / 2 + STABLE_SLOT_GEOMETRY.ringPadding) / (legacyHorizontalExtent + footprint.slotWidth / 2 + STABLE_SLOT_GEOMETRY.ringPadding) /
Math.abs(sectorVector.x); Math.abs(sectorVector.x);
const legacyRequiredY = const legacyRequiredY =
(legacyVerticalExtent + footprint!.slotHeight / 2 + STABLE_SLOT_GEOMETRY.ringPadding) / (legacyVerticalExtent + footprint.slotHeight / 2 + STABLE_SLOT_GEOMETRY.ringPadding) /
Math.abs(sectorVector.y); Math.abs(sectorVector.y);
const legacyMinRadius = Math.max(legacyRequiredX, legacyRequiredY, 0); const legacyMinRadius = Math.max(legacyRequiredX, legacyRequiredY, 0);
const actualRadius = Math.abs(frame!.ownerX / sectorVector.x); const actualRadius = Math.abs(frame.ownerX / sectorVector.x);
expect(actualRadius).toBeLessThan(legacyMinRadius); expect(actualRadius).toBeLessThan(legacyMinRadius);
expect(snapshot!.centralCollisionRects.some((rect) => rectsOverlap(frame!.bounds, rect))).toBe( expect(snapshot.centralCollisionRects.some((rect) => rectsOverlap(frame.bounds, rect))).toBe(
false false
); );
}); });
@ -974,7 +984,7 @@ describe('stable slot layout planner', () => {
}); });
expect(snapshot).not.toBeNull(); expect(snapshot).not.toBeNull();
const targetFrame = snapshot!.memberSlotFrames[1]!; const targetFrame = snapshot.memberSlotFrames[1];
expect( expect(
resolveNearestSlotAssignment({ resolveNearestSlotAssignment({
@ -982,7 +992,7 @@ describe('stable slot layout planner', () => {
ownerX: targetFrame.ownerX, ownerX: targetFrame.ownerX,
ownerY: targetFrame.ownerY, ownerY: targetFrame.ownerY,
nodes: [lead, ...members], nodes: [lead, ...members],
snapshot: snapshot!, snapshot,
layout, layout,
}) })
).toBeNull(); ).toBeNull();
@ -992,7 +1002,7 @@ describe('stable slot layout planner', () => {
ownerId: members[0].id, ownerId: members[0].id,
ownerX: targetFrame.ownerX, ownerX: targetFrame.ownerX,
ownerY: targetFrame.ownerY, ownerY: targetFrame.ownerY,
snapshot: snapshot!, snapshot,
}) })
).toEqual({ ).toEqual({
targetOwnerId: members[1].id, targetOwnerId: members[1].id,
@ -1072,6 +1082,7 @@ describe('stable slot layout planner', () => {
expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadCoreRect); expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadCoreRect);
expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.processBandRect); expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.processBandRect);
expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.activityColumnRect); expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.activityColumnRect);
expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.logColumnRect);
expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.kanbanBandRect); expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.kanbanBandRect);
expect(snapshot!.leadCentralReservedBlock.width).toBeLessThan( expect(snapshot!.leadCentralReservedBlock.width).toBeLessThan(
snapshot!.leadSlotFrame.bounds.width snapshot!.leadSlotFrame.bounds.width