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:
commit
8caa962dec
76 changed files with 12162 additions and 553 deletions
1231
docs/team-management/member-log-stream-v2-implementation-plan.md
Normal file
1231
docs/team-management/member-log-stream-v2-implementation-plan.md
Normal file
File diff suppressed because it is too large
Load diff
2046
docs/team-management/member-log-stream-v2-research-addendum.md
Normal file
2046
docs/team-management/member-log-stream-v2-research-addendum.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -68,6 +68,7 @@ export interface UseGraphSimulationResult {
|
|||
} | null;
|
||||
getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null;
|
||||
getActivityWorldRect: (nodeId: string) => StableRect | null;
|
||||
getLogWorldRect: (nodeId: string) => StableRect | null;
|
||||
getExtraWorldBounds: () => WorldBounds[];
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +87,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
const dragOwnerPositionsRef = useRef(new Map<string, { x: number; y: number }>());
|
||||
const launchAnchorPositionsRef = useRef(new Map<string, { x: number; y: number }>());
|
||||
const activityRectByNodeIdRef = useRef(new Map<string, StableRect>());
|
||||
const logRectByNodeIdRef = useRef(new Map<string, StableRect>());
|
||||
const extraWorldBoundsRef = useRef<WorldBounds[]>([]);
|
||||
|
||||
const prevNodeIdsRef = useRef(new Set<string>());
|
||||
|
|
@ -112,6 +114,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
dragOwnerPositionsRef,
|
||||
launchAnchorPositionsRef,
|
||||
activityRectByNodeIdRef,
|
||||
logRectByNodeIdRef,
|
||||
extraWorldBoundsRef,
|
||||
});
|
||||
return;
|
||||
|
|
@ -132,6 +135,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
dragOwnerPositionsRef,
|
||||
launchAnchorPositionsRef,
|
||||
activityRectByNodeIdRef,
|
||||
logRectByNodeIdRef,
|
||||
extraWorldBoundsRef,
|
||||
fillMissingFallbackPositions: true,
|
||||
});
|
||||
|
|
@ -144,6 +148,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
layoutSnapshotRef,
|
||||
launchAnchorPositionsRef,
|
||||
activityRectByNodeIdRef,
|
||||
logRectByNodeIdRef,
|
||||
extraWorldBoundsRef,
|
||||
});
|
||||
}, []);
|
||||
|
|
@ -264,6 +269,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
dragOwnerPositionsRef.current.clear();
|
||||
launchAnchorPositionsRef.current.clear();
|
||||
activityRectByNodeIdRef.current.clear();
|
||||
logRectByNodeIdRef.current.clear();
|
||||
extraWorldBoundsRef.current = [];
|
||||
layoutSnapshotRef.current = null;
|
||||
lastValidSnapshotByTeamRef.current.clear();
|
||||
|
|
@ -283,6 +289,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
|
|||
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
|
||||
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
|
||||
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
|
||||
getLogWorldRect: (nodeId: string) => logRectByNodeIdRef.current.get(nodeId) ?? null,
|
||||
getExtraWorldBounds: () => extraWorldBoundsRef.current,
|
||||
}),
|
||||
[
|
||||
|
|
@ -352,6 +359,7 @@ function commitSnapshotGeometry(args: {
|
|||
dragOwnerPositionsRef: { current: ReadonlyMap<string, { x: number; y: number }> };
|
||||
launchAnchorPositionsRef: { current: Map<string, { x: number; y: number }> };
|
||||
activityRectByNodeIdRef: { current: Map<string, StableRect> };
|
||||
logRectByNodeIdRef: { current: Map<string, StableRect> };
|
||||
extraWorldBoundsRef: { current: WorldBounds[] };
|
||||
fillMissingFallbackPositions?: boolean;
|
||||
}): void {
|
||||
|
|
@ -364,6 +372,7 @@ function commitSnapshotGeometry(args: {
|
|||
dragOwnerPositionsRef,
|
||||
launchAnchorPositionsRef,
|
||||
activityRectByNodeIdRef,
|
||||
logRectByNodeIdRef,
|
||||
extraWorldBoundsRef,
|
||||
fillMissingFallbackPositions = false,
|
||||
} = args;
|
||||
|
|
@ -377,10 +386,12 @@ function commitSnapshotGeometry(args: {
|
|||
|
||||
launchAnchorPositionsRef.current.clear();
|
||||
activityRectByNodeIdRef.current.clear();
|
||||
logRectByNodeIdRef.current.clear();
|
||||
extraWorldBoundsRef.current = snapshotToWorldBounds(snapshot);
|
||||
|
||||
for (const frame of getTranslatedMemberFrames(snapshot, dragOwnerPositionsRef.current)) {
|
||||
activityRectByNodeIdRef.current.set(frame.ownerId, frame.activityColumnRect);
|
||||
logRectByNodeIdRef.current.set(frame.ownerId, frame.logColumnRect);
|
||||
}
|
||||
|
||||
if (snapshot.leadNodeId) {
|
||||
|
|
@ -388,6 +399,7 @@ function commitSnapshotGeometry(args: {
|
|||
snapshot.leadNodeId,
|
||||
snapshot.leadSlotFrame.activityColumnRect
|
||||
);
|
||||
logRectByNodeIdRef.current.set(snapshot.leadNodeId, snapshot.leadSlotFrame.logColumnRect);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -396,6 +408,7 @@ function resetToFallbackLayout(args: {
|
|||
layoutSnapshotRef: { current: StableSlotLayoutSnapshot | null };
|
||||
launchAnchorPositionsRef: { current: Map<string, { x: number; y: number }> };
|
||||
activityRectByNodeIdRef: { current: Map<string, StableRect> };
|
||||
logRectByNodeIdRef: { current: Map<string, StableRect> };
|
||||
extraWorldBoundsRef: { current: WorldBounds[] };
|
||||
}): void {
|
||||
const {
|
||||
|
|
@ -403,12 +416,14 @@ function resetToFallbackLayout(args: {
|
|||
layoutSnapshotRef,
|
||||
launchAnchorPositionsRef,
|
||||
activityRectByNodeIdRef,
|
||||
logRectByNodeIdRef,
|
||||
extraWorldBoundsRef,
|
||||
} = args;
|
||||
|
||||
layoutSnapshotRef.current = null;
|
||||
launchAnchorPositionsRef.current.clear();
|
||||
activityRectByNodeIdRef.current.clear();
|
||||
logRectByNodeIdRef.current.clear();
|
||||
extraWorldBoundsRef.current = [];
|
||||
fallbackPositionNodes(nodes);
|
||||
KanbanLayoutEngine.layout(nodes);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export interface OwnerFootprint {
|
|||
radialDepth: number;
|
||||
activityColumnWidth: number;
|
||||
activityColumnHeight: number;
|
||||
logColumnWidth: number;
|
||||
logColumnHeight: number;
|
||||
processBandWidth: number;
|
||||
kanbanBandWidth: number;
|
||||
kanbanBandHeight: number;
|
||||
|
|
@ -42,6 +44,7 @@ export interface SlotFrame {
|
|||
ownerY: number;
|
||||
boardBandRect: StableRect;
|
||||
activityColumnRect: StableRect;
|
||||
logColumnRect: StableRect;
|
||||
processBandRect: StableRect;
|
||||
kanbanBandRect: StableRect;
|
||||
taskColumnCount: number;
|
||||
|
|
@ -108,6 +111,11 @@ const SLOT_GEOMETRY = {
|
|||
ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight +
|
||||
ACTIVITY_LANE.overflowHeight,
|
||||
activityColumnWidth: ACTIVITY_LANE.width,
|
||||
logColumnHeight:
|
||||
ACTIVITY_LANE.headerHeight +
|
||||
ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight +
|
||||
ACTIVITY_LANE.overflowHeight,
|
||||
logColumnWidth: 260,
|
||||
ownerToProcessGap: STABLE_SLOT_GEOMETRY.slotVerticalGap,
|
||||
processToBoardGap: STABLE_SLOT_GEOMETRY.slotVerticalGap,
|
||||
boardColumnGap: 24,
|
||||
|
|
@ -231,6 +239,7 @@ function buildCentralCollisionRects(args: {
|
|||
args.leadCoreRect,
|
||||
args.leadSlotFrame.processBandRect,
|
||||
args.leadSlotFrame.activityColumnRect,
|
||||
args.leadSlotFrame.logColumnRect,
|
||||
args.leadSlotFrame.kanbanBandRect,
|
||||
];
|
||||
if (args.unassignedTaskRect) {
|
||||
|
|
@ -247,6 +256,7 @@ function buildLeadCentralReservedBlock(args: {
|
|||
args.leadCoreRect,
|
||||
args.leadSlotFrame.processBandRect,
|
||||
args.leadSlotFrame.activityColumnRect,
|
||||
args.leadSlotFrame.logColumnRect,
|
||||
args.leadSlotFrame.kanbanBandRect,
|
||||
]);
|
||||
}
|
||||
|
|
@ -270,6 +280,7 @@ export function computeOwnerFootprints(
|
|||
): OwnerFootprint[] {
|
||||
const ownerNodes = nodes.filter((node) => node.kind === 'member');
|
||||
const showActivity = layout?.showActivity ?? true;
|
||||
const showLogs = layout?.showLogs ?? showActivity;
|
||||
const ownerNodeById = new Map(ownerNodes.map((node) => [node.id, node] as const));
|
||||
const taskColumnsByOwnerId = new Map<string, Set<string>>();
|
||||
const processCountByOwnerId = new Map<string, number>();
|
||||
|
|
@ -304,6 +315,7 @@ export function computeOwnerFootprints(
|
|||
taskColumnCount: taskColumnsByOwnerId.get(ownerId)?.size ?? 0,
|
||||
processCount: processCountByOwnerId.get(ownerId) ?? 0,
|
||||
showActivity,
|
||||
showLogs,
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
|
@ -331,6 +343,7 @@ function computeOwnerFootprintForOwnerId(
|
|||
taskColumnCount: taskColumns.size,
|
||||
processCount,
|
||||
showActivity: layout?.showActivity ?? true,
|
||||
showLogs: layout?.showLogs ?? layout?.showActivity ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -339,17 +352,28 @@ function buildOwnerFootprint(args: {
|
|||
taskColumnCount: number;
|
||||
processCount: number;
|
||||
showActivity: boolean;
|
||||
showLogs: boolean;
|
||||
}): OwnerFootprint {
|
||||
const activityColumnWidth = args.showActivity ? SLOT_GEOMETRY.activityColumnWidth : 0;
|
||||
const activityColumnHeight = args.showActivity ? SLOT_GEOMETRY.activityColumnHeight : 0;
|
||||
const activityToKanbanGap = args.showActivity ? SLOT_GEOMETRY.boardColumnGap : 0;
|
||||
const logColumnWidth = args.showLogs ? SLOT_GEOMETRY.logColumnWidth : 0;
|
||||
const logColumnHeight = args.showLogs ? SLOT_GEOMETRY.logColumnHeight : 0;
|
||||
const activityToLogGap =
|
||||
activityColumnWidth > 0 && logColumnWidth > 0 ? SLOT_GEOMETRY.boardColumnGap : 0;
|
||||
const feedToKanbanGap =
|
||||
activityColumnWidth > 0 || logColumnWidth > 0 ? SLOT_GEOMETRY.boardColumnGap : 0;
|
||||
const kanbanBandWidth =
|
||||
args.taskColumnCount <= 1
|
||||
? TASK_PILL.width
|
||||
: TASK_PILL.width + (args.taskColumnCount - 1) * KANBAN_ZONE.columnWidth;
|
||||
const processBandWidth = computeProcessBandWidth(args.processCount);
|
||||
const boardBandWidth = activityColumnWidth + activityToKanbanGap + kanbanBandWidth;
|
||||
const boardBandHeight = Math.max(activityColumnHeight, SLOT_GEOMETRY.kanbanBandHeight);
|
||||
const boardBandWidth =
|
||||
activityColumnWidth + activityToLogGap + logColumnWidth + feedToKanbanGap + kanbanBandWidth;
|
||||
const boardBandHeight = Math.max(
|
||||
activityColumnHeight,
|
||||
logColumnHeight,
|
||||
SLOT_GEOMETRY.kanbanBandHeight
|
||||
);
|
||||
const innerContentWidth = Math.max(SLOT_GEOMETRY.ownerMinWidth, processBandWidth, boardBandWidth);
|
||||
const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2;
|
||||
const slotHeight =
|
||||
|
|
@ -377,6 +401,8 @@ function buildOwnerFootprint(args: {
|
|||
radialDepth,
|
||||
activityColumnWidth,
|
||||
activityColumnHeight,
|
||||
logColumnWidth,
|
||||
logColumnHeight,
|
||||
processBandWidth,
|
||||
kanbanBandWidth,
|
||||
kanbanBandHeight: SLOT_GEOMETRY.kanbanBandHeight,
|
||||
|
|
@ -651,6 +677,7 @@ function validateStaticSnapshotRects(
|
|||
['leadSlotFrame.bounds', snapshot.leadSlotFrame.bounds],
|
||||
['leadSlotFrame.boardBandRect', snapshot.leadSlotFrame.boardBandRect],
|
||||
['leadSlotFrame.activityColumnRect', snapshot.leadSlotFrame.activityColumnRect],
|
||||
['leadSlotFrame.logColumnRect', snapshot.leadSlotFrame.logColumnRect],
|
||||
['leadSlotFrame.processBandRect', snapshot.leadSlotFrame.processBandRect],
|
||||
['leadSlotFrame.kanbanBandRect', snapshot.leadSlotFrame.kanbanBandRect],
|
||||
['leadActivityRect', snapshot.leadActivityRect],
|
||||
|
|
@ -697,6 +724,9 @@ function validateLeadSnapshotRects(
|
|||
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) {
|
||||
return { valid: false, reason: 'leadActivityRect must fit inside leadCentralReservedBlock' };
|
||||
}
|
||||
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.logColumnRect)) {
|
||||
return { valid: false, reason: 'lead logColumnRect must fit inside leadCentralReservedBlock' };
|
||||
}
|
||||
if (
|
||||
!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.processBandRect)
|
||||
) {
|
||||
|
|
@ -795,6 +825,9 @@ function validateSlotFrameGeometry(
|
|||
if (!rectContainsRect(frame.bounds, frame.activityColumnRect)) {
|
||||
return { valid: false, reason: `activityColumnRect escapes ${label}` };
|
||||
}
|
||||
if (!rectContainsRect(frame.bounds, frame.logColumnRect)) {
|
||||
return { valid: false, reason: `logColumnRect escapes ${label}` };
|
||||
}
|
||||
if (!rectContainsRect(frame.bounds, frame.processBandRect)) {
|
||||
return { valid: false, reason: `processBandRect escapes ${label}` };
|
||||
}
|
||||
|
|
@ -807,6 +840,12 @@ function validateSlotFrameGeometry(
|
|||
reason: `activityColumnRect escapes boardBandRect in ${label}`,
|
||||
};
|
||||
}
|
||||
if (!rectContainsRect(frame.boardBandRect, frame.logColumnRect)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `logColumnRect escapes boardBandRect in ${label}`,
|
||||
};
|
||||
}
|
||||
if (!rectContainsRect(frame.boardBandRect, frame.kanbanBandRect)) {
|
||||
return {
|
||||
valid: false,
|
||||
|
|
@ -819,6 +858,18 @@ function validateSlotFrameGeometry(
|
|||
reason: `activityColumnRect overlaps kanbanBandRect in ${label}`,
|
||||
};
|
||||
}
|
||||
if (rectsOverlap(frame.activityColumnRect, frame.logColumnRect)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `activityColumnRect overlaps logColumnRect in ${label}`,
|
||||
};
|
||||
}
|
||||
if (rectsOverlap(frame.logColumnRect, frame.kanbanBandRect)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `logColumnRect overlaps kanbanBandRect in ${label}`,
|
||||
};
|
||||
}
|
||||
if (!pointInRect(frame.ownerX, frame.ownerY, frame.bounds)) {
|
||||
return { valid: false, reason: `owner anchor escapes ${label}` };
|
||||
}
|
||||
|
|
@ -853,6 +904,7 @@ export function translateSlotFrame(frame: SlotFrame, dx: number, dy: number): Sl
|
|||
ownerY: frame.ownerY + dy,
|
||||
boardBandRect: translateRect(frame.boardBandRect, dx, dy),
|
||||
activityColumnRect: translateRect(frame.activityColumnRect, dx, dy),
|
||||
logColumnRect: translateRect(frame.logColumnRect, dx, dy),
|
||||
processBandRect: translateRect(frame.processBandRect, dx, dy),
|
||||
kanbanBandRect: translateRect(frame.kanbanBandRect, dx, dy),
|
||||
};
|
||||
|
|
@ -1296,9 +1348,22 @@ function buildSlotFrameAtOwnerAnchor(
|
|||
footprint.activityColumnWidth,
|
||||
footprint.activityColumnHeight
|
||||
);
|
||||
const activityToKanbanGap = footprint.activityColumnWidth > 0 ? SLOT_GEOMETRY.boardColumnGap : 0;
|
||||
const activityToLogGap =
|
||||
footprint.activityColumnWidth > 0 && footprint.logColumnWidth > 0
|
||||
? SLOT_GEOMETRY.boardColumnGap
|
||||
: 0;
|
||||
const logColumnRect = createRect(
|
||||
activityColumnRect.right + activityToLogGap,
|
||||
boardBandRect.top,
|
||||
footprint.logColumnWidth,
|
||||
footprint.logColumnHeight
|
||||
);
|
||||
const feedToKanbanGap =
|
||||
footprint.activityColumnWidth > 0 || footprint.logColumnWidth > 0
|
||||
? SLOT_GEOMETRY.boardColumnGap
|
||||
: 0;
|
||||
const kanbanBandRect = createRect(
|
||||
activityColumnRect.right + activityToKanbanGap,
|
||||
logColumnRect.right + feedToKanbanGap,
|
||||
boardBandRect.top,
|
||||
footprint.kanbanBandWidth,
|
||||
footprint.kanbanBandHeight
|
||||
|
|
@ -1314,6 +1379,7 @@ function buildSlotFrameAtOwnerAnchor(
|
|||
ownerY,
|
||||
boardBandRect,
|
||||
activityColumnRect,
|
||||
logColumnRect,
|
||||
processBandRect,
|
||||
kanbanBandRect,
|
||||
taskColumnCount: footprint.taskColumnCount,
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export interface GraphLayoutPort {
|
|||
version: GraphLayoutVersion;
|
||||
mode?: GraphLayoutMode;
|
||||
showActivity?: boolean;
|
||||
showLogs?: boolean;
|
||||
ownerOrder: string[];
|
||||
slotAssignments: Record<string, GraphOwnerSlotAssignment>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ export interface GraphViewProps {
|
|||
leadNodeId: string
|
||||
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
||||
getActivityWorldRect: (ownerNodeId: string) => StableRect | null;
|
||||
getLogWorldRect: (ownerNodeId: string) => StableRect | null;
|
||||
getTransientHandoffSnapshot: (options?: {
|
||||
focusNodeIds?: ReadonlySet<string> | null;
|
||||
focusEdgeIds?: ReadonlySet<string> | null;
|
||||
|
|
@ -137,6 +138,7 @@ export function GraphView({
|
|||
? {
|
||||
...data.layout,
|
||||
showActivity: filters.showActivity,
|
||||
showLogs: filters.showActivity,
|
||||
}
|
||||
: data.layout,
|
||||
[data.layout, filters.showActivity]
|
||||
|
|
@ -295,6 +297,10 @@ export function GraphView({
|
|||
(ownerNodeId: string) => simulationRef.current.getActivityWorldRect(ownerNodeId),
|
||||
[]
|
||||
);
|
||||
const getLogWorldRect = useCallback(
|
||||
(ownerNodeId: string) => simulationRef.current.getLogWorldRect(ownerNodeId),
|
||||
[]
|
||||
);
|
||||
const getTransientHandoffSnapshot = useCallback(
|
||||
(options?: {
|
||||
focusNodeIds?: ReadonlySet<string> | null;
|
||||
|
|
@ -1092,6 +1098,7 @@ export function GraphView({
|
|||
filters,
|
||||
getLaunchAnchorScreenPlacement,
|
||||
getActivityWorldRect,
|
||||
getLogWorldRect,
|
||||
getTransientHandoffSnapshot,
|
||||
getCameraZoom,
|
||||
worldToScreen: camera.worldToScreen,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,362 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
type MemberLogPreviewMember,
|
||||
type MemberLogPreviewRequestOptions,
|
||||
normalizeMemberLogPreviewResponse,
|
||||
} from '@features/member-log-stream/contracts';
|
||||
import { api } from '@renderer/api';
|
||||
|
||||
import type { ResolvedTeamMember, TeamChangeEvent } from '@shared/types/team';
|
||||
|
||||
const LIVE_RELOAD_DEBOUNCE_MS = 650;
|
||||
const PREVIEW_CACHE_TTL_MS = 3_500;
|
||||
const DEFAULT_MAX_ITEMS = 3;
|
||||
const DEFAULT_TEXT_LIMIT = 200;
|
||||
|
||||
function normalizeMemberName(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function buildRequestKey(input: {
|
||||
teamName: string;
|
||||
memberNames: readonly string[];
|
||||
laneIdsByMember: Readonly<Record<string, string>>;
|
||||
maxItemsPerMember: number;
|
||||
textLimit: number;
|
||||
forceRefresh?: boolean;
|
||||
}): string {
|
||||
const laneEntries = Object.entries(input.laneIdsByMember)
|
||||
.map(([memberName, laneId]) => [normalizeMemberName(memberName), laneId.trim()] as const)
|
||||
.filter(([, laneId]) => laneId.length > 0)
|
||||
.sort((left, right) => left[0].localeCompare(right[0]));
|
||||
return JSON.stringify([
|
||||
input.teamName,
|
||||
input.memberNames.map(normalizeMemberName),
|
||||
laneEntries,
|
||||
input.maxItemsPerMember,
|
||||
input.textLimit,
|
||||
input.forceRefresh === true,
|
||||
]);
|
||||
}
|
||||
|
||||
function memberMapFromResponse(
|
||||
members: readonly MemberLogPreviewMember[]
|
||||
): Map<string, MemberLogPreviewMember> {
|
||||
return new Map(members.map((member) => [normalizeMemberName(member.memberName), member]));
|
||||
}
|
||||
|
||||
function mergeMemberPreviews(
|
||||
base: Map<string, MemberLogPreviewMember>,
|
||||
members: Iterable<MemberLogPreviewMember>
|
||||
): Map<string, MemberLogPreviewMember> {
|
||||
const next = new Map(base);
|
||||
for (const member of members) {
|
||||
next.set(normalizeMemberName(member.memberName), member);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function laneIdForMember(
|
||||
memberName: string,
|
||||
laneIdsByMember: Readonly<Record<string, string>>
|
||||
): string {
|
||||
return (
|
||||
laneIdsByMember[memberName]?.trim() ??
|
||||
laneIdsByMember[normalizeMemberName(memberName)]?.trim() ??
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function buildMemberCacheKey(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
laneIdsByMember: Readonly<Record<string, string>>;
|
||||
maxItemsPerMember: number;
|
||||
textLimit: number;
|
||||
}): string {
|
||||
return JSON.stringify([
|
||||
input.teamName,
|
||||
normalizeMemberName(input.memberName),
|
||||
laneIdForMember(input.memberName, input.laneIdsByMember),
|
||||
input.maxItemsPerMember,
|
||||
input.textLimit,
|
||||
]);
|
||||
}
|
||||
|
||||
export function getSafeGraphLogPreviewLaneId(
|
||||
member: ResolvedTeamMember | undefined
|
||||
): string | undefined {
|
||||
if (!member) return undefined;
|
||||
if (member.providerId !== 'opencode') return undefined;
|
||||
if (member.laneOwnerProviderId !== 'opencode') return undefined;
|
||||
const laneId = member.laneId?.trim();
|
||||
return laneId ? laneId : undefined;
|
||||
}
|
||||
|
||||
export function buildGraphLogPreviewLaneIdsByMember(
|
||||
members: readonly ResolvedTeamMember[]
|
||||
): Record<string, string> {
|
||||
const laneIdsByMember: Record<string, string> = {};
|
||||
for (const member of members) {
|
||||
const laneId = getSafeGraphLogPreviewLaneId(member);
|
||||
if (!laneId) continue;
|
||||
laneIdsByMember[member.name] = laneId;
|
||||
laneIdsByMember[normalizeMemberName(member.name)] = laneId;
|
||||
}
|
||||
return laneIdsByMember;
|
||||
}
|
||||
|
||||
export function useGraphMemberLogPreviews(input: {
|
||||
teamName: string;
|
||||
memberNames: readonly string[];
|
||||
laneIdsByMember?: Readonly<Record<string, string>>;
|
||||
enabled?: boolean;
|
||||
maxItemsPerMember?: number;
|
||||
textLimit?: number;
|
||||
}): {
|
||||
previewsByMember: Map<string, MemberLogPreviewMember>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
reload: (options?: { forceRefresh?: boolean; background?: boolean }) => Promise<void>;
|
||||
} {
|
||||
const enabled = input.enabled ?? true;
|
||||
const maxItemsPerMember = Math.max(
|
||||
1,
|
||||
Math.min(3, Math.floor(input.maxItemsPerMember ?? DEFAULT_MAX_ITEMS))
|
||||
);
|
||||
const textLimit = Math.max(80, Math.min(240, Math.floor(input.textLimit ?? DEFAULT_TEXT_LIMIT)));
|
||||
const laneIdsByMember = useMemo(
|
||||
() => ({ ...(input.laneIdsByMember ?? {}) }),
|
||||
[input.laneIdsByMember]
|
||||
);
|
||||
const memberNames = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const memberName of input.memberNames) {
|
||||
const trimmed = memberName.trim();
|
||||
if (!trimmed) continue;
|
||||
const key = normalizeMemberName(trimmed);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(trimmed);
|
||||
}
|
||||
return result;
|
||||
}, [input.memberNames]);
|
||||
const memberKey = useMemo(() => memberNames.map(normalizeMemberName).join('|'), [memberNames]);
|
||||
const [previewsByMember, setPreviewsByMember] = useState(
|
||||
new Map<string, MemberLogPreviewMember>()
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const cacheRef = useRef(new Map<string, { expiresAt: number; member: MemberLogPreviewMember }>());
|
||||
const previewsByMemberRef = useRef(previewsByMember);
|
||||
const inFlightRef = useRef(new Map<string, Promise<Map<string, MemberLogPreviewMember>>>());
|
||||
const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const teamNameRef = useRef(input.teamName);
|
||||
|
||||
useEffect(() => {
|
||||
previewsByMemberRef.current = previewsByMember;
|
||||
}, [previewsByMember]);
|
||||
|
||||
useEffect(() => {
|
||||
if (teamNameRef.current !== input.teamName) {
|
||||
teamNameRef.current = input.teamName;
|
||||
cacheRef.current.clear();
|
||||
inFlightRef.current.clear();
|
||||
setPreviewsByMember(new Map());
|
||||
}
|
||||
if (!enabled || memberNames.length === 0) {
|
||||
setLoading(false);
|
||||
}
|
||||
setError(null);
|
||||
}, [enabled, input.teamName, memberKey, memberNames.length]);
|
||||
|
||||
const loadPreviews = useCallback(
|
||||
async (options?: { forceRefresh?: boolean; background?: boolean }): Promise<void> => {
|
||||
if (!enabled || memberNames.length === 0) {
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const membersToRequest: string[] = [];
|
||||
const cachedMembers: MemberLogPreviewMember[] = [];
|
||||
let hasMissingPreview = false;
|
||||
|
||||
for (const memberName of memberNames) {
|
||||
const cacheKey = buildMemberCacheKey({
|
||||
teamName: input.teamName,
|
||||
memberName,
|
||||
laneIdsByMember,
|
||||
maxItemsPerMember,
|
||||
textLimit,
|
||||
});
|
||||
const cached = cacheRef.current.get(cacheKey);
|
||||
if (cached) {
|
||||
cachedMembers.push(cached.member);
|
||||
}
|
||||
if (options?.forceRefresh || !cached || cached.expiresAt <= now) {
|
||||
membersToRequest.push(memberName);
|
||||
}
|
||||
const normalizedMemberName = normalizeMemberName(memberName);
|
||||
if (!cached && !previewsByMemberRef.current.has(normalizedMemberName)) {
|
||||
hasMissingPreview = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (cachedMembers.length > 0) {
|
||||
setPreviewsByMember((current) => mergeMemberPreviews(current, cachedMembers));
|
||||
}
|
||||
|
||||
if (membersToRequest.length === 0) {
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestKey = buildRequestKey({
|
||||
teamName: input.teamName,
|
||||
memberNames: membersToRequest,
|
||||
laneIdsByMember,
|
||||
maxItemsPerMember,
|
||||
textLimit,
|
||||
forceRefresh: options?.forceRefresh,
|
||||
});
|
||||
const requestTeamName = input.teamName;
|
||||
|
||||
if (!options?.background && hasMissingPreview) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
try {
|
||||
let request = inFlightRef.current.get(requestKey);
|
||||
if (!request) {
|
||||
const requestOptions: MemberLogPreviewRequestOptions = {
|
||||
maxItemsPerMember,
|
||||
textLimit,
|
||||
...(Object.keys(laneIdsByMember).length > 0 ? { laneIdsByMember } : {}),
|
||||
...(options?.forceRefresh ? { forceRefresh: true } : {}),
|
||||
};
|
||||
request = api.memberLogStream
|
||||
.getMemberLogPreviews(input.teamName, membersToRequest, requestOptions)
|
||||
.then((response) => {
|
||||
const normalized = normalizeMemberLogPreviewResponse(response);
|
||||
const members = memberMapFromResponse(normalized.members);
|
||||
for (const member of members.values()) {
|
||||
cacheRef.current.set(
|
||||
buildMemberCacheKey({
|
||||
teamName: input.teamName,
|
||||
memberName: member.memberName,
|
||||
laneIdsByMember,
|
||||
maxItemsPerMember,
|
||||
textLimit,
|
||||
}),
|
||||
{
|
||||
expiresAt: Date.now() + PREVIEW_CACHE_TTL_MS,
|
||||
member,
|
||||
}
|
||||
);
|
||||
}
|
||||
return members;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlightRef.current.delete(requestKey);
|
||||
});
|
||||
inFlightRef.current.set(requestKey, request);
|
||||
}
|
||||
|
||||
const members = await request;
|
||||
if (teamNameRef.current !== requestTeamName) {
|
||||
return;
|
||||
}
|
||||
setPreviewsByMember((current) => mergeMemberPreviews(current, members.values()));
|
||||
setError(null);
|
||||
} catch (loadError) {
|
||||
if (teamNameRef.current !== requestTeamName) {
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
loadError instanceof Error ? loadError.message : 'Failed to load graph log previews'
|
||||
);
|
||||
} finally {
|
||||
if (teamNameRef.current === requestTeamName) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[enabled, input.teamName, laneIdsByMember, maxItemsPerMember, memberNames, textLimit]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || memberNames.length === 0) {
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
if (reloadTimerRef.current) {
|
||||
clearTimeout(reloadTimerRef.current);
|
||||
}
|
||||
reloadTimerRef.current = setTimeout(() => {
|
||||
reloadTimerRef.current = null;
|
||||
void loadPreviews();
|
||||
}, LIVE_RELOAD_DEBOUNCE_MS);
|
||||
return () => {
|
||||
if (reloadTimerRef.current) {
|
||||
clearTimeout(reloadTimerRef.current);
|
||||
reloadTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, loadPreviews, memberKey, memberNames.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const scheduleReload = (forceRefresh: boolean): void => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
|
||||
if (memberNames.length === 0) return;
|
||||
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
|
||||
reloadTimerRef.current = setTimeout(() => {
|
||||
reloadTimerRef.current = null;
|
||||
void loadPreviews({ background: true, forceRefresh });
|
||||
}, LIVE_RELOAD_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const unsubscribe = api.teams.onTeamChange?.((_event: unknown, event: TeamChangeEvent) => {
|
||||
if (event.teamName !== input.teamName) return;
|
||||
if (event.type === 'log-source-change') {
|
||||
scheduleReload(true);
|
||||
return;
|
||||
}
|
||||
if (event.type === 'task-log-change') {
|
||||
scheduleReload(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleVisibilityChange = (): void => {
|
||||
if (document.visibilityState === 'visible') scheduleReload(false);
|
||||
};
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (reloadTimerRef.current) {
|
||||
clearTimeout(reloadTimerRef.current);
|
||||
reloadTimerRef.current = null;
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}
|
||||
if (typeof unsubscribe === 'function') unsubscribe();
|
||||
};
|
||||
}, [enabled, input.teamName, loadPreviews, memberNames.length]);
|
||||
|
||||
return { previewsByMember, loading, error, reload: loadPreviews };
|
||||
}
|
||||
|
|
@ -0,0 +1,382 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
Brain,
|
||||
CheckCircle2,
|
||||
MessageSquareText,
|
||||
Terminal,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
|
||||
import {
|
||||
buildGraphLogPreviewLaneIdsByMember,
|
||||
useGraphMemberLogPreviews,
|
||||
} from '../hooks/useGraphMemberLogPreviews';
|
||||
|
||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||
import type {
|
||||
MemberLogPreviewItem,
|
||||
MemberLogPreviewMember,
|
||||
} from '@features/member-log-stream/contracts';
|
||||
import type {
|
||||
MemberActivityFilter,
|
||||
MemberDetailTab,
|
||||
} from '@renderer/components/team/members/memberDetailTypes';
|
||||
|
||||
const LOG_PREVIEW_FALLBACK_WIDTH = 260;
|
||||
const LOG_PREVIEW_FALLBACK_HEIGHT = 292;
|
||||
|
||||
interface StableRectLike {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface GraphMemberLogPreviewHudProps {
|
||||
teamName: string;
|
||||
nodes: GraphNode[];
|
||||
getLogWorldRect?: (ownerNodeId: string) => StableRectLike | null;
|
||||
getCameraZoom?: () => number;
|
||||
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
||||
getViewportSize?: () => { width: number; height: number };
|
||||
focusNodeIds: ReadonlySet<string> | null;
|
||||
enabled?: boolean;
|
||||
onOpenMemberProfile?: (
|
||||
memberName: string,
|
||||
options?: {
|
||||
initialTab?: MemberDetailTab;
|
||||
initialActivityFilter?: MemberActivityFilter;
|
||||
}
|
||||
) => void;
|
||||
}
|
||||
|
||||
function normalizeMemberName(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp: string): string {
|
||||
const parsed = Date.parse(timestamp);
|
||||
if (!Number.isFinite(parsed)) return '';
|
||||
const diffMs = Date.now() - parsed;
|
||||
if (diffMs < 60_000) return 'now';
|
||||
const minutes = Math.floor(diffMs / 60_000);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
|
||||
function itemIcon(item: MemberLogPreviewItem): React.JSX.Element {
|
||||
const className = 'size-3.5 shrink-0';
|
||||
const title = item.title.trim().toLowerCase();
|
||||
if (
|
||||
title === 'send message' ||
|
||||
title === 'message sent' ||
|
||||
title === 'add comment' ||
|
||||
title === 'comment added'
|
||||
) {
|
||||
return <MessageSquareText className={`${className} text-sky-300`} />;
|
||||
}
|
||||
if (item.tone === 'error') {
|
||||
return <AlertCircle className={`${className} text-rose-300`} />;
|
||||
}
|
||||
if (item.kind === 'tool_result') {
|
||||
return <CheckCircle2 className={`${className} text-emerald-300`} />;
|
||||
}
|
||||
if (item.kind === 'tool_use') {
|
||||
return <Terminal className={`${className} text-amber-300`} />;
|
||||
}
|
||||
if (item.kind === 'thinking') {
|
||||
return <Brain className={`${className} text-sky-300`} />;
|
||||
}
|
||||
return <MessageSquareText className={`${className} text-slate-300`} />;
|
||||
}
|
||||
|
||||
function resolveEmptyText(preview: MemberLogPreviewMember | undefined, loading: boolean): string {
|
||||
if (loading && !preview) return 'Loading logs';
|
||||
if (preview?.warnings.some((warning) => warning.code === 'codex_member_wide_not_supported')) {
|
||||
return 'Unsupported provider';
|
||||
}
|
||||
return 'No recent logs';
|
||||
}
|
||||
|
||||
function setShellHidden(shell: HTMLDivElement): void {
|
||||
shell.style.opacity = '0';
|
||||
shell.style.pointerEvents = 'none';
|
||||
}
|
||||
|
||||
export const GraphMemberLogPreviewHud = ({
|
||||
teamName,
|
||||
nodes,
|
||||
getLogWorldRect = () => null,
|
||||
getCameraZoom = () => 1,
|
||||
worldToScreen,
|
||||
getViewportSize,
|
||||
focusNodeIds,
|
||||
enabled = true,
|
||||
onOpenMemberProfile,
|
||||
}: GraphMemberLogPreviewHudProps): React.JSX.Element | null => {
|
||||
const worldLayerRef = useRef<HTMLDivElement | null>(null);
|
||||
const shellRefs = useRef(new Map<string, HTMLDivElement | null>());
|
||||
const visibleKeyRef = useRef('');
|
||||
const [visibleMemberNames, setVisibleMemberNames] = useState<string[]>([]);
|
||||
const { teamData } = useGraphActivityContext(teamName);
|
||||
const members = teamData?.members ?? [];
|
||||
const laneIdsByMember = useMemo(() => buildGraphLogPreviewLaneIdsByMember(members), [members]);
|
||||
const ownerNodes = useMemo(
|
||||
() =>
|
||||
nodes.filter((node): node is GraphNode & { kind: 'lead' | 'member' } => {
|
||||
return (
|
||||
(node.kind === 'lead' || node.kind === 'member') &&
|
||||
(node.domainRef.kind === 'lead' || node.domainRef.kind === 'member')
|
||||
);
|
||||
}),
|
||||
[nodes]
|
||||
);
|
||||
const { previewsByMember, loading } = useGraphMemberLogPreviews({
|
||||
teamName,
|
||||
memberNames: visibleMemberNames,
|
||||
laneIdsByMember,
|
||||
enabled: enabled && visibleMemberNames.length > 0,
|
||||
maxItemsPerMember: 3,
|
||||
textLimit: 200,
|
||||
});
|
||||
|
||||
const openLogs = useCallback(
|
||||
(memberName: string) => {
|
||||
onOpenMemberProfile?.(memberName, { initialTab: 'logs' });
|
||||
},
|
||||
[onOpenMemberProfile]
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled || ownerNodes.length === 0) {
|
||||
for (const shell of shellRefs.current.values()) {
|
||||
if (shell) setShellHidden(shell);
|
||||
}
|
||||
setVisibleMemberNames([]);
|
||||
visibleKeyRef.current = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let frameId = 0;
|
||||
const updatePositions = (): void => {
|
||||
const worldLayer = worldLayerRef.current;
|
||||
if (worldLayer && worldToScreen) {
|
||||
const origin = worldToScreen(0, 0);
|
||||
const zoom = Math.max(getCameraZoom(), 0.001);
|
||||
worldLayer.style.transform = `translate(${Math.round(origin.x)}px, ${Math.round(origin.y)}px) scale(${zoom.toFixed(3)})`;
|
||||
}
|
||||
|
||||
const visibleNames: string[] = [];
|
||||
for (const node of ownerNodes) {
|
||||
const shell = shellRefs.current.get(node.id);
|
||||
if (!shell) continue;
|
||||
|
||||
const laneRect = getLogWorldRect(node.id);
|
||||
if (!laneRect || !worldToScreen || laneRect.width <= 0 || laneRect.height <= 0) {
|
||||
setShellHidden(shell);
|
||||
continue;
|
||||
}
|
||||
|
||||
const zoom = Math.max(getCameraZoom(), 0.001);
|
||||
const screenTopLeft = worldToScreen(laneRect.left, laneRect.top);
|
||||
const widthScreen = Math.max(1, laneRect.width * zoom);
|
||||
const heightScreen = Math.max(1, laneRect.height * zoom);
|
||||
const viewport = getViewportSize?.();
|
||||
const laneVisible =
|
||||
!viewport ||
|
||||
(screenTopLeft.x + widthScreen > -80 &&
|
||||
screenTopLeft.x < viewport.width + 80 &&
|
||||
screenTopLeft.y + heightScreen > -80 &&
|
||||
screenTopLeft.y < viewport.height + 80);
|
||||
if (!laneVisible) {
|
||||
setShellHidden(shell);
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseOpacity = focusNodeIds && !focusNodeIds.has(node.id) ? 0.25 : 1;
|
||||
shell.style.opacity = String(baseOpacity);
|
||||
shell.style.pointerEvents = 'auto';
|
||||
shell.style.left = `${Math.round(laneRect.left)}px`;
|
||||
shell.style.top = `${Math.round(laneRect.top)}px`;
|
||||
shell.style.width = `${Math.round(laneRect.width)}px`;
|
||||
shell.style.height = `${Math.round(laneRect.height)}px`;
|
||||
if (node.domainRef.kind === 'lead' || node.domainRef.kind === 'member') {
|
||||
visibleNames.push(node.domainRef.memberName);
|
||||
}
|
||||
}
|
||||
|
||||
const nextVisibleKey = visibleNames
|
||||
.map(normalizeMemberName)
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.join('|');
|
||||
if (nextVisibleKey !== visibleKeyRef.current) {
|
||||
visibleKeyRef.current = nextVisibleKey;
|
||||
setVisibleMemberNames(visibleNames);
|
||||
}
|
||||
|
||||
frameId = window.requestAnimationFrame(updatePositions);
|
||||
};
|
||||
|
||||
updatePositions();
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [
|
||||
enabled,
|
||||
focusNodeIds,
|
||||
getCameraZoom,
|
||||
getLogWorldRect,
|
||||
getViewportSize,
|
||||
ownerNodes,
|
||||
worldToScreen,
|
||||
]);
|
||||
|
||||
const forwardWheelToGraph = useCallback((event: WheelEvent, shell: HTMLDivElement) => {
|
||||
const graphRoot = shell.closest('.team-graph-view');
|
||||
const canvas = graphRoot?.querySelector('canvas');
|
||||
if (!(canvas instanceof HTMLCanvasElement)) {
|
||||
return;
|
||||
}
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
canvas.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
deltaX: event.deltaX,
|
||||
deltaY: event.deltaY,
|
||||
deltaMode: event.deltaMode,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
ctrlKey: event.ctrlKey,
|
||||
shiftKey: event.shiftKey,
|
||||
altKey: event.altKey,
|
||||
metaKey: event.metaKey,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
const listeners: { shell: HTMLDivElement; handler: (event: WheelEvent) => void }[] = [];
|
||||
for (const node of ownerNodes) {
|
||||
const shell = shellRefs.current.get(node.id);
|
||||
if (!shell) continue;
|
||||
const handler = (event: WheelEvent): void => forwardWheelToGraph(event, shell);
|
||||
shell.addEventListener('wheel', handler, { passive: false });
|
||||
listeners.push({ shell, handler });
|
||||
}
|
||||
return () => {
|
||||
for (const { shell, handler } of listeners) {
|
||||
shell.removeEventListener('wheel', handler);
|
||||
}
|
||||
};
|
||||
}, [enabled, forwardWheelToGraph, ownerNodes]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(memberName: string, item: MemberLogPreviewItem) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="block h-14 min-h-14 w-full min-w-0 overflow-hidden rounded-md border border-white/10 bg-[rgba(8,14,28,0.52)] px-2.5 py-2 text-left text-[10px] leading-3 text-slate-400 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]"
|
||||
onClick={() => openLogs(memberName)}
|
||||
>
|
||||
<span
|
||||
className="mr-1.5 inline-flex size-4 shrink-0 translate-y-0.5 items-center justify-center rounded bg-white/5 align-middle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{itemIcon(item)}
|
||||
</span>
|
||||
<span className="align-baseline text-[11px] font-medium leading-4 text-slate-200">
|
||||
{item.title}
|
||||
</span>{' '}
|
||||
<span className="align-baseline text-[9px] leading-4 text-slate-500">
|
||||
{formatRelativeTime(item.timestamp)}
|
||||
</span>{' '}
|
||||
<span className="align-baseline">{item.preview || item.sourceLabel || 'Log event'}</span>
|
||||
</button>
|
||||
),
|
||||
[openLogs]
|
||||
);
|
||||
|
||||
if (!enabled || ownerNodes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={worldLayerRef}
|
||||
className="pointer-events-none absolute left-0 top-0 z-[8] origin-top-left select-none"
|
||||
>
|
||||
{ownerNodes.map((node) => {
|
||||
const laneRect = getLogWorldRect(node.id);
|
||||
const laneWidth = laneRect?.width ?? LOG_PREVIEW_FALLBACK_WIDTH;
|
||||
const laneHeight = laneRect?.height ?? LOG_PREVIEW_FALLBACK_HEIGHT;
|
||||
const memberName =
|
||||
node.domainRef.kind === 'lead' || node.domainRef.kind === 'member'
|
||||
? node.domainRef.memberName
|
||||
: node.label;
|
||||
const preview = previewsByMember.get(normalizeMemberName(memberName));
|
||||
const items = preview?.items ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
ref={(element) => {
|
||||
shellRefs.current.set(node.id, element);
|
||||
}}
|
||||
className="pointer-events-auto absolute z-10 origin-top-left select-none opacity-0"
|
||||
style={{
|
||||
width: `${laneWidth}px`,
|
||||
maxWidth: `${laneWidth}px`,
|
||||
height: `${laneHeight}px`,
|
||||
}}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full min-w-0 max-w-full flex-col overflow-hidden">
|
||||
<div className="mb-1 flex h-5 min-h-5 items-center gap-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
|
||||
<Wrench className="size-3 text-slate-500" />
|
||||
Logs
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||
{items.length > 0 ? (
|
||||
items.slice(0, 3).map((item) => renderItem(memberName, item))
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-14 min-h-14 items-center rounded-md border border-dashed border-white/10 bg-[rgba(8,14,28,0.28)] px-3 text-left text-[11px] text-slate-400/60"
|
||||
onClick={() => openLogs(memberName)}
|
||||
>
|
||||
{resolveEmptyText(preview, loading)}
|
||||
</button>
|
||||
)}
|
||||
{preview && preview.overflowCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 min-h-8 w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]"
|
||||
onClick={() => openLogs(memberName)}
|
||||
>
|
||||
+{preview.overflowCount} more
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -15,6 +15,7 @@ import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions'
|
|||
|
||||
import { GraphActivityHud } from './GraphActivityHud';
|
||||
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
|
||||
import { GraphMemberLogPreviewHud } from './GraphMemberLogPreviewHud';
|
||||
import { GraphNodePopover } from './GraphNodePopover';
|
||||
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
||||
import { GraphTransientHandoffHud } from './GraphTransientHandoffHud';
|
||||
|
|
@ -148,6 +149,14 @@ export const TeamGraphOverlay = ({
|
|||
width: number;
|
||||
height: number;
|
||||
} | null;
|
||||
getLogWorldRect?: (ownerNodeId: string) => {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null;
|
||||
getCameraZoom?: () => number;
|
||||
getTransientHandoffSnapshot?: (options?: {
|
||||
focusNodeIds?: ReadonlySet<string> | null;
|
||||
|
|
@ -186,6 +195,17 @@ export const TeamGraphOverlay = ({
|
|||
onOpenTaskDetail={onOpenTaskDetail}
|
||||
onOpenMemberProfile={onOpenMemberProfile}
|
||||
/>
|
||||
<GraphMemberLogPreviewHud
|
||||
teamName={teamName}
|
||||
nodes={graphData.nodes}
|
||||
getLogWorldRect={extraHudProps.getLogWorldRect}
|
||||
getCameraZoom={extraHudProps.getCameraZoom}
|
||||
worldToScreen={extraHudProps.worldToScreen}
|
||||
getViewportSize={getViewportSize}
|
||||
focusNodeIds={focusNodeIds}
|
||||
enabled={filters?.showActivity ?? true}
|
||||
onOpenMemberProfile={onOpenMemberProfile}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions'
|
|||
|
||||
import { GraphActivityHud } from './GraphActivityHud';
|
||||
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
|
||||
import { GraphMemberLogPreviewHud } from './GraphMemberLogPreviewHud';
|
||||
import { GraphNodePopover } from './GraphNodePopover';
|
||||
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
||||
import { GraphTransientHandoffHud } from './GraphTransientHandoffHud';
|
||||
|
|
@ -168,6 +169,14 @@ export const TeamGraphTab = ({
|
|||
width: number;
|
||||
height: number;
|
||||
} | null;
|
||||
getLogWorldRect?: (ownerNodeId: string) => {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null;
|
||||
getCameraZoom?: () => number;
|
||||
getTransientHandoffSnapshot?: (options?: {
|
||||
focusNodeIds?: ReadonlySet<string> | null;
|
||||
|
|
@ -207,6 +216,17 @@ export const TeamGraphTab = ({
|
|||
onOpenTaskDetail={dispatchOpenTask}
|
||||
onOpenMemberProfile={dispatchOpenProfile}
|
||||
/>
|
||||
<GraphMemberLogPreviewHud
|
||||
teamName={teamName}
|
||||
nodes={graphData.nodes}
|
||||
getLogWorldRect={extraHudProps.getLogWorldRect}
|
||||
getCameraZoom={extraHudProps.getCameraZoom}
|
||||
worldToScreen={extraHudProps.worldToScreen}
|
||||
getViewportSize={getViewportSize}
|
||||
focusNodeIds={focusNodeIds}
|
||||
enabled={isActive && (filters?.showActivity ?? true)}
|
||||
onOpenMemberProfile={dispatchOpenProfile}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
|
|
|
|||
20
src/features/member-log-stream/contracts/api.ts
Normal file
20
src/features/member-log-stream/contracts/api.ts
Normal 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>;
|
||||
}
|
||||
3
src/features/member-log-stream/contracts/channels.ts
Normal file
3
src/features/member-log-stream/contracts/channels.ts
Normal 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';
|
||||
112
src/features/member-log-stream/contracts/dto.ts
Normal file
112
src/features/member-log-stream/contracts/dto.ts
Normal 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;
|
||||
}
|
||||
4
src/features/member-log-stream/contracts/index.ts
Normal file
4
src/features/member-log-stream/contracts/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export type * from './api';
|
||||
export * from './channels';
|
||||
export type * from './dto';
|
||||
export * from './normalize';
|
||||
93
src/features/member-log-stream/contracts/normalize.ts
Normal file
93
src/features/member-log-stream/contracts/normalize.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export interface ClockPort {
|
||||
now(): number;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import type {
|
||||
MemberLogPreviewItem,
|
||||
MemberLogStreamCoverage,
|
||||
MemberLogStreamProvider,
|
||||
MemberLogStreamWarning,
|
||||
} from '../../../contracts';
|
||||
import type { MemberLogPreviewBudget } from '../../domain/models/MemberLogPreviewBudget';
|
||||
|
||||
export interface MemberLogPreviewSourceInput {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
laneId?: string;
|
||||
budget: MemberLogPreviewBudget;
|
||||
maxItems: number;
|
||||
textLimit: number;
|
||||
forceRefresh?: boolean;
|
||||
}
|
||||
|
||||
export interface MemberLogPreviewSourceResult {
|
||||
provider: MemberLogStreamProvider;
|
||||
status: MemberLogStreamCoverage['status'];
|
||||
reason?: string;
|
||||
items: MemberLogPreviewItem[];
|
||||
warnings: MemberLogStreamWarning[];
|
||||
truncated: boolean;
|
||||
overflowCount: number;
|
||||
}
|
||||
|
||||
export interface MemberLogPreviewSource {
|
||||
readonly provider: MemberLogStreamProvider;
|
||||
loadPreview(input: MemberLogPreviewSourceInput): Promise<MemberLogPreviewSourceResult>;
|
||||
}
|
||||
|
|
@ -0,0 +1,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>;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export interface MemberLogStreamTrackingPort {
|
||||
setTracking(teamName: string, enabled: boolean): Promise<void>;
|
||||
}
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
import { createEmptyMemberLogPreviewResponse } from '../../../contracts';
|
||||
import {
|
||||
clampMemberLogPreviewItemLimit,
|
||||
clampMemberLogPreviewTextLimit,
|
||||
DEFAULT_MEMBER_LOG_PREVIEW_BUDGET,
|
||||
} from '../../domain/models/MemberLogPreviewBudget';
|
||||
import { buildMemberLogPreviewMember } from '../../domain/policies/memberLogPreviewMergePolicy';
|
||||
|
||||
import type {
|
||||
MemberLogPreviewResponse,
|
||||
MemberLogStreamProvider,
|
||||
MemberLogStreamWarning,
|
||||
} from '../../../contracts';
|
||||
import type { MemberLogPreviewBudget } from '../../domain/models/MemberLogPreviewBudget';
|
||||
import type { ClockPort } from '../ports/ClockPort';
|
||||
import type { LoggerPort } from '../ports/LoggerPort';
|
||||
import type {
|
||||
MemberLogPreviewSource,
|
||||
MemberLogPreviewSourceResult,
|
||||
} from '../ports/MemberLogPreviewSource';
|
||||
|
||||
export interface GetMemberLogPreviewsInput {
|
||||
teamName: string;
|
||||
memberNames: string[];
|
||||
maxItemsPerMember?: number;
|
||||
textLimit?: number;
|
||||
laneIdsByMember?: Record<string, string>;
|
||||
forceRefresh?: boolean;
|
||||
}
|
||||
|
||||
interface GetMemberLogPreviewsUseCaseDeps {
|
||||
sources: readonly MemberLogPreviewSource[];
|
||||
clock: ClockPort;
|
||||
logger: LoggerPort;
|
||||
budget?: Partial<MemberLogPreviewBudget>;
|
||||
}
|
||||
|
||||
interface NormalizedMemberRequest {
|
||||
memberName: string;
|
||||
laneId?: string;
|
||||
}
|
||||
|
||||
function normalizeMemberName(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeMembers(
|
||||
memberNames: readonly string[],
|
||||
laneIdsByMember: Record<string, string> | undefined,
|
||||
maxMembers: number
|
||||
): NormalizedMemberRequest[] {
|
||||
const result: NormalizedMemberRequest[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const memberName of memberNames) {
|
||||
const trimmed = memberName.trim();
|
||||
if (!trimmed) continue;
|
||||
const key = normalizeMemberName(trimmed);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const laneId = laneIdsByMember?.[trimmed] ?? laneIdsByMember?.[key];
|
||||
result.push({
|
||||
memberName: trimmed,
|
||||
...(laneId ? { laneId } : {}),
|
||||
});
|
||||
if (result.length >= maxMembers) break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function stableInputKey(input: {
|
||||
teamName: string;
|
||||
members: readonly NormalizedMemberRequest[];
|
||||
maxItems: number;
|
||||
textLimit: number;
|
||||
forceRefresh?: boolean;
|
||||
}): string {
|
||||
return JSON.stringify([
|
||||
input.teamName,
|
||||
input.members.map((member) => [normalizeMemberName(member.memberName), member.laneId ?? '']),
|
||||
input.maxItems,
|
||||
input.textLimit,
|
||||
input.forceRefresh === true,
|
||||
]);
|
||||
}
|
||||
|
||||
function warningForSourceFailure(
|
||||
provider: MemberLogStreamProvider,
|
||||
message: string
|
||||
): MemberLogStreamWarning {
|
||||
return {
|
||||
code:
|
||||
provider === 'opencode_runtime'
|
||||
? 'opencode_runtime_unavailable'
|
||||
: 'unreadable_transcript_file',
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
export class GetMemberLogPreviewsUseCase {
|
||||
private readonly budget: MemberLogPreviewBudget;
|
||||
private readonly inFlight = new Map<string, Promise<MemberLogPreviewResponse>>();
|
||||
|
||||
constructor(private readonly deps: GetMemberLogPreviewsUseCaseDeps) {
|
||||
this.budget = { ...DEFAULT_MEMBER_LOG_PREVIEW_BUDGET, ...(deps.budget ?? {}) };
|
||||
}
|
||||
|
||||
async execute(input: GetMemberLogPreviewsInput): Promise<MemberLogPreviewResponse> {
|
||||
const maxItems = clampMemberLogPreviewItemLimit(input.maxItemsPerMember, this.budget);
|
||||
const textLimit = clampMemberLogPreviewTextLimit(input.textLimit, this.budget);
|
||||
const members = normalizeMembers(
|
||||
input.memberNames,
|
||||
input.laneIdsByMember,
|
||||
this.budget.maxMembers
|
||||
);
|
||||
if (members.length === 0) {
|
||||
return createEmptyMemberLogPreviewResponse(new Date(this.deps.clock.now()).toISOString());
|
||||
}
|
||||
|
||||
const key = stableInputKey({
|
||||
teamName: input.teamName,
|
||||
members,
|
||||
maxItems,
|
||||
textLimit,
|
||||
forceRefresh: input.forceRefresh,
|
||||
});
|
||||
const existing = this.inFlight.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const promise = this.buildResponse({
|
||||
input,
|
||||
members,
|
||||
maxItems,
|
||||
textLimit,
|
||||
}).finally(() => {
|
||||
this.inFlight.delete(key);
|
||||
});
|
||||
this.inFlight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
private async buildResponse(args: {
|
||||
input: GetMemberLogPreviewsInput;
|
||||
members: readonly NormalizedMemberRequest[];
|
||||
maxItems: number;
|
||||
textLimit: number;
|
||||
}): Promise<MemberLogPreviewResponse> {
|
||||
const generatedAt = new Date(this.deps.clock.now()).toISOString();
|
||||
if (this.deps.sources.length === 0) {
|
||||
return createEmptyMemberLogPreviewResponse(generatedAt);
|
||||
}
|
||||
|
||||
const members = await Promise.all(
|
||||
args.members.map(async (member) => {
|
||||
const sourceResults = await Promise.all(
|
||||
this.deps.sources.map((source): Promise<MemberLogPreviewSourceResult> => {
|
||||
return source
|
||||
.loadPreview({
|
||||
teamName: args.input.teamName,
|
||||
memberName: member.memberName,
|
||||
laneId: member.laneId,
|
||||
budget: this.budget,
|
||||
maxItems: args.maxItems,
|
||||
textLimit: args.textLimit,
|
||||
forceRefresh: args.input.forceRefresh,
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.deps.logger.warn(
|
||||
`Member log preview source ${source.provider} failed for ${args.input.teamName}/${member.memberName}: ${message}`
|
||||
);
|
||||
return {
|
||||
provider: source.provider,
|
||||
status: 'skipped',
|
||||
reason: message,
|
||||
items: [],
|
||||
warnings: [warningForSourceFailure(source.provider, message)],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
};
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return buildMemberLogPreviewMember({
|
||||
memberName: member.memberName,
|
||||
sourceResults: sourceResults.map((result) => ({
|
||||
coverage: {
|
||||
provider: result.provider,
|
||||
status: result.status,
|
||||
...(result.reason ? { reason: result.reason } : {}),
|
||||
},
|
||||
items: result.items,
|
||||
warnings: result.warnings,
|
||||
truncated: result.truncated,
|
||||
overflowCount: result.overflowCount,
|
||||
})),
|
||||
generatedAt,
|
||||
maxItems: args.maxItems,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
members,
|
||||
generatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { GetMemberLogPreviewsUseCase } from '../GetMemberLogPreviewsUseCase';
|
||||
|
||||
import type {
|
||||
MemberLogPreviewSource,
|
||||
MemberLogPreviewSourceInput,
|
||||
} from '../../ports/MemberLogPreviewSource';
|
||||
|
||||
function source(
|
||||
provider: MemberLogPreviewSource['provider'],
|
||||
loadPreview: (
|
||||
input: MemberLogPreviewSourceInput
|
||||
) => ReturnType<MemberLogPreviewSource['loadPreview']>
|
||||
): MemberLogPreviewSource {
|
||||
return { provider, loadPreview };
|
||||
}
|
||||
|
||||
describe('GetMemberLogPreviewsUseCase', () => {
|
||||
it('dedupes members, clamps options, and merges source coverage per member', async () => {
|
||||
const loadPreview = vi.fn(async (input: MemberLogPreviewSourceInput) => ({
|
||||
provider: 'claude_transcript' as const,
|
||||
status: 'included' as const,
|
||||
items: [
|
||||
{
|
||||
id: `item:${input.memberName}`,
|
||||
kind: 'text' as const,
|
||||
provider: 'claude_transcript' as const,
|
||||
timestamp: '2026-04-01T12:00:00.000Z',
|
||||
title: 'Assistant',
|
||||
preview: input.memberName,
|
||||
tone: 'neutral' as const,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
}));
|
||||
const useCase = new GetMemberLogPreviewsUseCase({
|
||||
sources: [source('claude_transcript', loadPreview)],
|
||||
clock: { now: () => Date.parse('2026-04-01T12:01:00.000Z') },
|
||||
logger: { warn: vi.fn(), error: vi.fn() },
|
||||
});
|
||||
|
||||
const response = await useCase.execute({
|
||||
teamName: 'alpha-team',
|
||||
memberNames: ['alice', 'Alice', 'bob'],
|
||||
maxItemsPerMember: 99,
|
||||
textLimit: 999,
|
||||
laneIdsByMember: { alice: 'secondary:opencode:alice' },
|
||||
});
|
||||
|
||||
expect(response.members.map((member) => member.memberName)).toEqual(['alice', 'bob']);
|
||||
expect(loadPreview).toHaveBeenCalledTimes(2);
|
||||
expect(loadPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
memberName: 'alice',
|
||||
maxItems: 3,
|
||||
textLimit: 200,
|
||||
laneId: 'secondary:opencode:alice',
|
||||
})
|
||||
);
|
||||
expect(response.members[0]?.coverage).toEqual([
|
||||
{ provider: 'claude_transcript', status: 'included' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('dedupes in-flight identical batch requests', async () => {
|
||||
const loadPreview = vi.fn(async (_input: MemberLogPreviewSourceInput) => ({
|
||||
provider: 'codex_native_trace' as const,
|
||||
status: 'skipped' as const,
|
||||
reason: 'codex_member_wide_not_supported',
|
||||
items: [],
|
||||
warnings: [
|
||||
{
|
||||
code: 'codex_member_wide_not_supported' as const,
|
||||
message: 'Codex member-wide native trace is not available in this variant yet.',
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
}));
|
||||
const useCase = new GetMemberLogPreviewsUseCase({
|
||||
sources: [source('codex_native_trace', loadPreview)],
|
||||
clock: { now: () => Date.parse('2026-04-01T12:01:00.000Z') },
|
||||
logger: { warn: vi.fn(), error: vi.fn() },
|
||||
});
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
useCase.execute({ teamName: 'alpha-team', memberNames: ['codex'] }),
|
||||
useCase.execute({ teamName: 'alpha-team', memberNames: ['codex'] }),
|
||||
]);
|
||||
|
||||
expect(first).toEqual(second);
|
||||
expect(loadPreview).toHaveBeenCalledTimes(1);
|
||||
expect(first.members[0]?.warnings[0]?.code).toBe('codex_member_wide_not_supported');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,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],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
export interface MemberLogPreviewBudget {
|
||||
maxMembers: number;
|
||||
maxItemsPerMember: number;
|
||||
maxTextChars: number;
|
||||
maxTranscriptFiles: number;
|
||||
maxSourceMessagesPerProvider: number;
|
||||
openCodeMessageLimit: number;
|
||||
openCodeTimeoutMs: number;
|
||||
cacheTtlMs: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_MEMBER_LOG_PREVIEW_BUDGET: MemberLogPreviewBudget = {
|
||||
maxMembers: 40,
|
||||
maxItemsPerMember: 3,
|
||||
maxTextChars: 200,
|
||||
maxTranscriptFiles: 8,
|
||||
maxSourceMessagesPerProvider: 120,
|
||||
openCodeMessageLimit: 80,
|
||||
openCodeTimeoutMs: 2_500,
|
||||
cacheTtlMs: 3_000,
|
||||
};
|
||||
|
||||
export function clampMemberLogPreviewItemLimit(
|
||||
requested: number | undefined,
|
||||
budget: MemberLogPreviewBudget = DEFAULT_MEMBER_LOG_PREVIEW_BUDGET
|
||||
): number {
|
||||
if (typeof requested !== 'number' || !Number.isFinite(requested)) {
|
||||
return budget.maxItemsPerMember;
|
||||
}
|
||||
return Math.max(1, Math.min(3, budget.maxItemsPerMember, Math.floor(requested)));
|
||||
}
|
||||
|
||||
export function clampMemberLogPreviewTextLimit(
|
||||
requested: number | undefined,
|
||||
budget: MemberLogPreviewBudget = DEFAULT_MEMBER_LOG_PREVIEW_BUDGET
|
||||
): number {
|
||||
if (typeof requested !== 'number' || !Number.isFinite(requested)) {
|
||||
return budget.maxTextChars;
|
||||
}
|
||||
return Math.max(80, Math.min(240, budget.maxTextChars, Math.floor(requested)));
|
||||
}
|
||||
|
|
@ -0,0 +1,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));
|
||||
}
|
||||
|
|
@ -0,0 +1,361 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { extractMemberLogPreviewItems } from '../memberLogPreviewExtractor';
|
||||
|
||||
import type { MemberLogPreviewParsedMessage } from '../memberLogPreviewExtractor';
|
||||
|
||||
function message(
|
||||
overrides: Partial<MemberLogPreviewParsedMessage> & {
|
||||
uuid: string;
|
||||
timestamp: string;
|
||||
}
|
||||
): MemberLogPreviewParsedMessage {
|
||||
const { uuid, timestamp, ...rest } = overrides;
|
||||
return {
|
||||
uuid,
|
||||
parentUuid: null,
|
||||
type: 'assistant',
|
||||
role: 'assistant',
|
||||
timestamp: new Date(timestamp),
|
||||
content: '',
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
...rest,
|
||||
} as MemberLogPreviewParsedMessage;
|
||||
}
|
||||
|
||||
describe('memberLogPreviewExtractor', () => {
|
||||
it('extracts bounded assistant text previews newest first', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'claude_transcript',
|
||||
maxItems: 3,
|
||||
textLimit: 120,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'old',
|
||||
timestamp: '2026-04-01T10:00:00.000Z',
|
||||
content: [{ type: 'text', text: 'older answer' }],
|
||||
}),
|
||||
message({
|
||||
uuid: 'new',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: [{ type: 'text', text: '<system-reminder>latest answer</system-reminder>' }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'text',
|
||||
title: 'Assistant',
|
||||
preview: 'latest answer',
|
||||
});
|
||||
expect(result.items[1]?.preview).toBe('older answer');
|
||||
});
|
||||
|
||||
it('extracts tool_use input and tool_result output without rendering huge payloads', () => {
|
||||
const hugeOutput = 'x'.repeat(10_000);
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'opencode_runtime',
|
||||
maxItems: 3,
|
||||
textLimit: 160,
|
||||
sourceId: 'session-1',
|
||||
sourceLabel: 'OpenCode runtime',
|
||||
laneId: 'secondary:opencode:alice',
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'tool-call',
|
||||
timestamp: '2026-04-01T10:00:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu-1',
|
||||
name: 'Bash',
|
||||
input: { command: 'pnpm test -- --runInBand', ignored: hugeOutput },
|
||||
},
|
||||
],
|
||||
}),
|
||||
message({
|
||||
uuid: 'tool-result',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'toolu-1',
|
||||
content: hugeOutput,
|
||||
is_error: true,
|
||||
},
|
||||
],
|
||||
toolResults: [],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Tool error',
|
||||
tone: 'error',
|
||||
laneId: 'secondary:opencode:alice',
|
||||
});
|
||||
expect(result.items[0]?.preview?.length).toBeLessThanOrEqual(160);
|
||||
expect(result.items[1]).toMatchObject({
|
||||
kind: 'tool_use',
|
||||
title: 'Bash',
|
||||
preview: 'pnpm test -- --runInBand',
|
||||
});
|
||||
expect(result.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('formats SendMessage and message_send payloads without raw JSON noise', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'claude_transcript',
|
||||
maxItems: 3,
|
||||
textLimit: 160,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'send-call',
|
||||
timestamp: '2026-04-01T10:00:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool-send',
|
||||
name: 'mcp__agent-teams__message_send',
|
||||
input: {
|
||||
to: 'team-lead',
|
||||
from: 'tom',
|
||||
summary: '#abc done',
|
||||
text: 'Detailed body should stay secondary',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
message({
|
||||
uuid: 'send-result',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: '',
|
||||
toolResults: [
|
||||
{
|
||||
toolUseId: 'tool-send',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
deliveredToInbox: true,
|
||||
message: {
|
||||
from: 'tom',
|
||||
to: 'team-lead',
|
||||
text: 'Detailed body',
|
||||
summary: '#abc done',
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Message sent',
|
||||
preview: 'Message sent to team-lead - #abc done',
|
||||
});
|
||||
expect(result.items[1]).toMatchObject({
|
||||
kind: 'tool_use',
|
||||
title: 'Send message',
|
||||
preview: 'to team-lead: #abc done',
|
||||
});
|
||||
expect(JSON.stringify(result.items)).not.toContain('deliveredToInbox');
|
||||
});
|
||||
|
||||
it('formats task comment result payloads without raw JSON noise', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'claude_transcript',
|
||||
maxItems: 3,
|
||||
textLimit: 160,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'comment-result',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-comment',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
taskId: 'task-799',
|
||||
comment: {
|
||||
id: 'comment-1',
|
||||
author: 'tom',
|
||||
text: 'Done with UI review',
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Comment added',
|
||||
preview: 'Comment by tom on #task-799: Done with UI review',
|
||||
});
|
||||
expect(JSON.stringify(result.items)).not.toContain('"comment"');
|
||||
});
|
||||
|
||||
it('formats plain board tool results through the paired tool_use context', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'claude_transcript',
|
||||
maxItems: 3,
|
||||
textLimit: 160,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'complete-call',
|
||||
timestamp: '2026-04-01T10:00:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool-complete',
|
||||
name: 'mcp__agent-teams__task_complete',
|
||||
input: { teamName: 'demo', taskId: 'abc12345', actor: 'tom' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
message({
|
||||
uuid: 'complete-result',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-complete',
|
||||
content: 'ok',
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Task completed',
|
||||
preview: 'Completed #abc12345',
|
||||
toolName: 'mcp__agent-teams__task_complete',
|
||||
});
|
||||
expect(result.items[1]).toMatchObject({
|
||||
kind: 'tool_use',
|
||||
title: 'Complete task',
|
||||
preview: '#abc12345',
|
||||
});
|
||||
});
|
||||
|
||||
it('formats wrapped Agent Teams task responses', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'claude_transcript',
|
||||
maxItems: 3,
|
||||
textLimit: 160,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'task-result',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-task-get',
|
||||
content: JSON.stringify({
|
||||
agent_teams_task_get_response: {
|
||||
task: {
|
||||
id: 'abc12345-0000-0000-0000-000000000000',
|
||||
displayId: 'abc12345',
|
||||
title: 'Fix preview alignment',
|
||||
status: 'in_progress',
|
||||
owner: 'tom',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Task loaded',
|
||||
preview: '#abc12345: Fix preview alignment, status in_progress, owner tom',
|
||||
});
|
||||
expect(JSON.stringify(result.items)).not.toContain('agent_teams_task_get_response');
|
||||
});
|
||||
|
||||
it('keeps orphan tool results visible because graph preview is diagnostic', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'claude_transcript',
|
||||
maxItems: 3,
|
||||
textLimit: 120,
|
||||
messages: [
|
||||
message({
|
||||
uuid: 'orphan',
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: '2026-04-01T10:01:00.000Z',
|
||||
content: '',
|
||||
toolResults: [
|
||||
{
|
||||
toolUseId: 'missing-call',
|
||||
content: 'orphan result still matters',
|
||||
isError: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
title: 'Tool result',
|
||||
preview: 'orphan result still matters',
|
||||
tone: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
it('caps preview items at three and reports overflow', () => {
|
||||
const result = extractMemberLogPreviewItems({
|
||||
provider: 'claude_transcript',
|
||||
maxItems: 3,
|
||||
textLimit: 120,
|
||||
messages: [1, 2, 3, 4].map((index) =>
|
||||
message({
|
||||
uuid: `m-${index}`,
|
||||
timestamp: `2026-04-01T10:0${index}:00.000Z`,
|
||||
content: [{ type: 'text', text: `message ${index}` }],
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.items.map((item) => item.preview)).toEqual([
|
||||
'message 4',
|
||||
'message 3',
|
||||
'message 2',
|
||||
]);
|
||||
expect(result.overflowCount).toBe(1);
|
||||
expect(result.truncated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildMemberLogPreviewMember } from '../memberLogPreviewMergePolicy';
|
||||
|
||||
import type { MemberLogPreviewItem } from '../../../../contracts';
|
||||
|
||||
function item(id: string, timestamp: string): MemberLogPreviewItem {
|
||||
return {
|
||||
id,
|
||||
kind: 'text',
|
||||
provider: 'claude_transcript',
|
||||
timestamp,
|
||||
title: 'Assistant',
|
||||
preview: id,
|
||||
tone: 'neutral',
|
||||
};
|
||||
}
|
||||
|
||||
describe('memberLogPreviewMergePolicy', () => {
|
||||
it('merges sources newest first with stable tie break and max three items', () => {
|
||||
const member = buildMemberLogPreviewMember({
|
||||
memberName: 'alice',
|
||||
generatedAt: '2026-04-01T12:00:00.000Z',
|
||||
maxItems: 3,
|
||||
sourceResults: [
|
||||
{
|
||||
coverage: { provider: 'opencode_runtime', status: 'included' },
|
||||
items: [item('b', '2026-04-01T12:00:00.000Z'), item('a', '2026-04-01T12:00:00.000Z')],
|
||||
warnings: [],
|
||||
},
|
||||
{
|
||||
coverage: { provider: 'claude_transcript', status: 'included' },
|
||||
items: [
|
||||
item('newest', '2026-04-01T12:01:00.000Z'),
|
||||
item('oldest', '2026-04-01T11:59:00.000Z'),
|
||||
],
|
||||
warnings: [{ code: 'large_log_window_limited', message: 'limited' }],
|
||||
overflowCount: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(member.items.map((preview) => preview.id)).toEqual(['newest', 'a', 'b']);
|
||||
expect(member.coverage.map((coverage) => coverage.provider)).toEqual([
|
||||
'claude_transcript',
|
||||
'opencode_runtime',
|
||||
]);
|
||||
expect(member.truncated).toBe(true);
|
||||
expect(member.overflowCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
|
||||
import type {
|
||||
MemberLogPreviewSource,
|
||||
MemberLogPreviewSourceInput,
|
||||
MemberLogPreviewSourceResult,
|
||||
} from '../../../../core/application/ports/MemberLogPreviewSource';
|
||||
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||
|
||||
export class CodexNativeMemberTracePreviewSource implements MemberLogPreviewSource {
|
||||
readonly provider = 'codex_native_trace' as const;
|
||||
|
||||
constructor(private readonly configReader: TeamConfigReader) {}
|
||||
|
||||
async loadPreview(input: MemberLogPreviewSourceInput): Promise<MemberLogPreviewSourceResult> {
|
||||
const config = await this.configReader.getConfig(input.teamName).catch(() => null);
|
||||
const member = config?.members?.find(
|
||||
(item) => item.name.trim().toLowerCase() === input.memberName.trim().toLowerCase()
|
||||
);
|
||||
const isCodexMember =
|
||||
member?.providerId === 'codex' ||
|
||||
member?.providerBackendId === 'codex-native' ||
|
||||
(member ? false : isLeadMember({ name: input.memberName }));
|
||||
|
||||
return {
|
||||
provider: this.provider,
|
||||
status: 'skipped',
|
||||
reason: 'codex_member_wide_not_supported',
|
||||
items: [],
|
||||
warnings: isCodexMember
|
||||
? [
|
||||
{
|
||||
code: 'codex_member_wide_not_supported',
|
||||
message: 'Codex member-wide native trace is not available in this variant yet.',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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.',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
18
src/features/member-log-stream/main/featureGates.ts
Normal file
18
src/features/member-log-stream/main/featureGates.ts
Normal 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);
|
||||
}
|
||||
8
src/features/member-log-stream/main/index.ts
Normal file
8
src/features/member-log-stream/main/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export {
|
||||
registerMemberLogStreamIpc,
|
||||
removeMemberLogStreamIpc,
|
||||
} from './adapters/input/ipc/registerMemberLogStreamIpc';
|
||||
export {
|
||||
createMemberLogStreamFeature,
|
||||
type MemberLogStreamFeatureFacade,
|
||||
} from './composition/createMemberLogStreamFeature';
|
||||
|
|
@ -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}');
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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' },
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
1
src/features/member-log-stream/preload/index.ts
Normal file
1
src/features/member-log-stream/preload/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { createMemberLogStreamBridge } from './createMemberLogStreamBridge';
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
7
src/features/member-log-stream/renderer/index.ts
Normal file
7
src/features/member-log-stream/renderer/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export { MemberLogStreamSection } from './adapters/MemberLogStreamSection';
|
||||
export {
|
||||
buildDefaultExecutionSegmentRenderKey,
|
||||
ExecutionLogStreamView,
|
||||
normalizeExecutionLogStream,
|
||||
} from './ui/ExecutionLogStreamView';
|
||||
export { isMemberLogStreamUiEnabled } from './utils/featureGates';
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -30,6 +30,11 @@ import {
|
|||
type CodexModelCatalogFeatureFacade,
|
||||
createCodexModelCatalogFeature,
|
||||
} from '@features/codex-model-catalog/main';
|
||||
import {
|
||||
createMemberLogStreamFeature,
|
||||
registerMemberLogStreamIpc,
|
||||
removeMemberLogStreamIpc,
|
||||
} from '@features/member-log-stream/main';
|
||||
import {
|
||||
buildMemberWorkSyncRuntimeTurnSettledEnvironment,
|
||||
createMemberWorkSyncFeature,
|
||||
|
|
@ -49,6 +54,7 @@ import {
|
|||
removeRuntimeProviderManagementIpc,
|
||||
type RuntimeProviderManagementFeatureFacade,
|
||||
} from '@features/runtime-provider-management/main';
|
||||
import { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService';
|
||||
import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy';
|
||||
import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService';
|
||||
import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
|
||||
|
|
@ -1138,6 +1144,13 @@ async function initializeServices(): Promise<void> {
|
|||
undefined,
|
||||
teamTranscriptSourceLocator
|
||||
);
|
||||
const memberLogStreamFeature = createMemberLogStreamFeature({
|
||||
logsFinder: teamMemberLogsFinder,
|
||||
logSourceTracker: teamLogSourceTracker,
|
||||
runtimeBridge: new ClaudeMultimodelBridgeService(),
|
||||
configReader: taskLogConfigReader,
|
||||
logger: createLogger('Feature:MemberLogStream'),
|
||||
});
|
||||
const teamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(
|
||||
teamMemberLogsFinder
|
||||
);
|
||||
|
|
@ -1483,6 +1496,7 @@ async function initializeServices(): Promise<void> {
|
|||
registerRecentProjectsIpc(ipcMain, recentProjectsFeature);
|
||||
registerRuntimeProviderManagementIpc(ipcMain, runtimeProviderManagementFeature);
|
||||
registerMemberWorkSyncIpc(ipcMain, memberWorkSyncFeature);
|
||||
registerMemberLogStreamIpc(ipcMain, memberLogStreamFeature);
|
||||
|
||||
// Forward SSH state changes to renderer and HTTP SSE clients
|
||||
sshConnectionManager.on('state-change', (status: unknown) => {
|
||||
|
|
@ -1672,6 +1686,7 @@ async function shutdownServices(): Promise<void> {
|
|||
removeRecentProjectsIpc(ipcMain);
|
||||
removeRuntimeProviderManagementIpc(ipcMain);
|
||||
removeMemberWorkSyncIpc(ipcMain);
|
||||
removeMemberLogStreamIpc(ipcMain);
|
||||
});
|
||||
|
||||
await runShutdownStep('team backup dispose', () => teamBackupService?.dispose());
|
||||
|
|
|
|||
|
|
@ -925,6 +925,8 @@ export class ClaudeMultimodelBridgeService {
|
|||
teamId: string;
|
||||
memberName: string;
|
||||
limit?: number;
|
||||
laneId?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
): Promise<OpenCodeRuntimeTranscriptResponse['transcript'] | null> {
|
||||
const { env } = await this.buildCliEnv(binaryPath);
|
||||
|
|
@ -943,12 +945,15 @@ export class ClaudeMultimodelBridgeService {
|
|||
if (typeof params.limit === 'number') {
|
||||
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 outputPath = path.join(outputDir, 'transcript.json');
|
||||
try {
|
||||
await execCli(binaryPath, [...args, '--output', outputPath], {
|
||||
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
||||
timeout: params.timeoutMs ?? PROVIDER_STATUS_TIMEOUT_MS,
|
||||
env,
|
||||
});
|
||||
const parsed = extractJsonObject<OpenCodeRuntimeTranscriptResponse>(
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export type TeamLogSourceTrackingConsumer =
|
|||
| 'change_presence'
|
||||
| 'tool_activity'
|
||||
| 'task_log_stream'
|
||||
| 'member_log_stream'
|
||||
| 'stall_monitor';
|
||||
|
||||
interface TrackingState {
|
||||
|
|
|
|||
|
|
@ -112,8 +112,19 @@ export interface MemberLogFileRef {
|
|||
sessionId: string;
|
||||
filePath: string;
|
||||
mtimeMs: number;
|
||||
sizeBytes?: number;
|
||||
messageCount?: number;
|
||||
kind?: 'lead_session' | 'member_session' | 'subagent';
|
||||
}
|
||||
|
||||
type FindRecentMemberLogFileRefsOptions =
|
||||
| number
|
||||
| null
|
||||
| {
|
||||
mtimeSinceMs?: number | null;
|
||||
forceRefresh?: boolean;
|
||||
};
|
||||
|
||||
export interface TeamLogSourceLiveContext {
|
||||
projectDir: string;
|
||||
projectPath?: string;
|
||||
|
|
@ -966,8 +977,15 @@ export class TeamMemberLogsFinder {
|
|||
async findRecentMemberLogFileRefsByMember(
|
||||
teamName: string,
|
||||
memberNames: readonly string[],
|
||||
mtimeSinceMs?: number | null
|
||||
options?: FindRecentMemberLogFileRefsOptions
|
||||
): 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>();
|
||||
for (const memberName of memberNames) {
|
||||
const trimmed = memberName.trim();
|
||||
|
|
@ -983,12 +1001,18 @@ export class TeamMemberLogsFinder {
|
|||
return [];
|
||||
}
|
||||
|
||||
const discovery = await this.discoverProjectSessions(teamName);
|
||||
const discovery = await this.discoverProjectSessions(teamName, {
|
||||
forceRefresh: parsedOptions.forceRefresh,
|
||||
});
|
||||
if (!discovery) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { projectDir, sessionIds, knownMembers, config } = discovery;
|
||||
const scopedKnownMembers = new Set(knownMembers);
|
||||
for (const memberKey of requestedMembersByKey.keys()) {
|
||||
scopedKnownMembers.add(memberKey);
|
||||
}
|
||||
const refs: MemberLogFileRef[] = [];
|
||||
const seenFilePaths = new Set<string>();
|
||||
const pushRef = (ref: MemberLogFileRef): void => {
|
||||
|
|
@ -1006,12 +1030,17 @@ export class TeamMemberLogsFinder {
|
|||
const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`);
|
||||
try {
|
||||
const stat = await fs.stat(leadJsonl);
|
||||
if (stat.isFile()) {
|
||||
if (
|
||||
stat.isFile() &&
|
||||
(parsedOptions.mtimeSinceMs == null || stat.mtimeMs >= parsedOptions.mtimeSinceMs)
|
||||
) {
|
||||
pushRef({
|
||||
memberName: requestedMembersByKey.get(leadKey) ?? leadMemberName,
|
||||
sessionId: config.leadSessionId,
|
||||
filePath: leadJsonl,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
sizeBytes: stat.size,
|
||||
kind: 'lead_session',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -1026,20 +1055,20 @@ export class TeamMemberLogsFinder {
|
|||
if (!stat.isFile()) {
|
||||
return null;
|
||||
}
|
||||
if (mtimeSinceMs != null && stat.mtimeMs < mtimeSinceMs) {
|
||||
if (parsedOptions.mtimeSinceMs != null && stat.mtimeMs < parsedOptions.mtimeSinceMs) {
|
||||
return null;
|
||||
}
|
||||
const attribution =
|
||||
candidate.kind === 'subagent'
|
||||
? await this.getCachedSubagentAttribution(
|
||||
candidate.filePath,
|
||||
knownMembers,
|
||||
scopedKnownMembers,
|
||||
stat.mtimeMs
|
||||
)
|
||||
: await this.getCachedMemberSessionAttribution(
|
||||
candidate.filePath,
|
||||
teamName,
|
||||
knownMembers,
|
||||
scopedKnownMembers,
|
||||
stat.mtimeMs
|
||||
);
|
||||
if (!attribution) {
|
||||
|
|
@ -1055,6 +1084,8 @@ export class TeamMemberLogsFinder {
|
|||
sessionId: candidate.sessionId,
|
||||
filePath: candidate.filePath,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
sizeBytes: stat.size,
|
||||
kind: candidate.kind,
|
||||
} satisfies MemberLogFileRef;
|
||||
} catch {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { sanitizeDisplayContent } from '@shared/utils/contentSanitizer';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { ClaudeMultimodelBridgeService } from '../../../runtime/ClaudeMultimodelBridgeService';
|
||||
|
|
@ -7,17 +6,15 @@ import { ClaudeBinaryResolver } from '../../ClaudeBinaryResolver';
|
|||
import { TeamTaskReader } from '../../TeamTaskReader';
|
||||
import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder';
|
||||
|
||||
import { mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage } from './OpenCodeRuntimeProjectionMapper';
|
||||
import { OpenCodeTaskLogAttributionStore } from './OpenCodeTaskLogAttributionStore';
|
||||
|
||||
import type {
|
||||
OpenCodeRuntimeTranscriptLogContentBlock,
|
||||
OpenCodeRuntimeTranscriptLogMessage,
|
||||
} from '../../../runtime/ClaudeMultimodelBridgeService';
|
||||
import type { OpenCodeRuntimeTranscriptLogMessage } from '../../../runtime/ClaudeMultimodelBridgeService';
|
||||
import type {
|
||||
OpenCodeTaskLogAttributionReader,
|
||||
OpenCodeTaskLogAttributionRecord,
|
||||
} from './OpenCodeTaskLogAttributionStore';
|
||||
import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types';
|
||||
import type { ParsedMessage } from '@main/types';
|
||||
import type {
|
||||
BoardTaskLogActor,
|
||||
BoardTaskLogParticipant,
|
||||
|
|
@ -431,7 +428,7 @@ function hasForeignTeamTaskMarker(
|
|||
}
|
||||
|
||||
return projectedMessages
|
||||
.map(toParsedMessage)
|
||||
.map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage)
|
||||
.filter((message): message is ParsedMessage => message !== null)
|
||||
.some((message) =>
|
||||
message.toolCalls.some((toolCall) => {
|
||||
|
|
@ -758,7 +755,7 @@ function buildTaskMarkerProjection(
|
|||
): TaskMarkerProjection | null {
|
||||
const parsedMessages = sortParsedMessagesByTime(
|
||||
projectedMessages
|
||||
.map(toParsedMessage)
|
||||
.map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage)
|
||||
.filter((message): message is ParsedMessage => message !== null)
|
||||
);
|
||||
const taskRefs = buildTaskRefSet(task);
|
||||
|
|
@ -919,7 +916,7 @@ function filterMessagesForAttribution(
|
|||
record: OpenCodeTaskLogAttributionRecord
|
||||
): ParsedMessage[] {
|
||||
const parsedMessages = messages
|
||||
.map(toParsedMessage)
|
||||
.map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage)
|
||||
.filter((message): message is ParsedMessage => message !== null);
|
||||
|
||||
const hasMessageBounds = Boolean(record.startMessageUuid || record.endMessageUuid);
|
||||
|
|
@ -936,115 +933,6 @@ function filterMessagesForAttribution(
|
|||
.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 {
|
||||
private readonly cache = new Map<
|
||||
string,
|
||||
|
|
@ -1187,7 +1075,7 @@ export class OpenCodeTaskLogStreamSource {
|
|||
const filteredMessages =
|
||||
markerProjection?.messages ??
|
||||
projectedMessages
|
||||
.map(toParsedMessage)
|
||||
.map(mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage)
|
||||
.filter((message): message is ParsedMessage => message !== null)
|
||||
.filter((message) => isWithinTimeWindows(message.timestamp, timeWindows))
|
||||
.sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime());
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { createCodexAccountBridge } from '@features/codex-account/preload';
|
||||
import { createMemberLogStreamBridge } from '@features/member-log-stream/preload';
|
||||
import { createMemberWorkSyncBridge } from '@features/member-work-sync/preload';
|
||||
import { createRecentProjectsBridge } from '@features/recent-projects/preload';
|
||||
import { createRuntimeProviderManagementBridge } from '@features/runtime-provider-management/preload';
|
||||
|
|
@ -478,6 +479,7 @@ const electronAPI: ElectronAPI = {
|
|||
...createRecentProjectsBridge(),
|
||||
runtimeProviderManagement: createRuntimeProviderManagementBridge(ipcRenderer),
|
||||
memberWorkSync: createMemberWorkSyncBridge(ipcRenderer),
|
||||
memberLogStream: createMemberLogStreamBridge(),
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
getProjects: () => ipcRenderer.invoke('get-projects'),
|
||||
getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,13 @@
|
|||
* 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 { MemberLogStreamApi } from '@features/member-log-stream/contracts';
|
||||
import type { DashboardRecentProjectsPayload } from '@features/recent-projects/contracts';
|
||||
import type { RuntimeProviderManagementApi } from '@features/runtime-provider-management/contracts';
|
||||
import type {
|
||||
|
|
@ -251,6 +257,20 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
getDashboardRecentProjects = (): Promise<DashboardRecentProjectsPayload> =>
|
||||
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');
|
||||
|
||||
getSessions = (projectId: string): Promise<Session[]> =>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
isMemberLogStreamUiEnabled,
|
||||
MemberLogStreamSection,
|
||||
} from '@features/member-log-stream/renderer';
|
||||
// import { MemberWorkSyncStatusPanel } from '@features/member-work-sync/renderer';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
|
||||
|
|
@ -179,6 +183,7 @@ export const MemberDetailDialog = ({
|
|||
const [activeTab, setActiveTab] = useState<MemberDetailTab>(initialTab);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [restartError, setRestartError] = useState<string | null>(null);
|
||||
const [showLegacyLogsFallback, setShowLegacyLogsFallback] = useState(false);
|
||||
|
||||
const runtimeSummary = useMemo(
|
||||
() =>
|
||||
|
|
@ -249,6 +254,7 @@ export const MemberDetailDialog = ({
|
|||
setActiveTab(initialTab);
|
||||
setRestartError(null);
|
||||
setRestarting(false);
|
||||
setShowLegacyLogsFallback(false);
|
||||
}, [initialTab, member, open]);
|
||||
|
||||
const {
|
||||
|
|
@ -258,6 +264,7 @@ export const MemberDetailDialog = ({
|
|||
} = useMemberStats(teamName, member?.name ?? null);
|
||||
|
||||
const totalTokens = memberStats ? memberStats.inputTokens + memberStats.outputTokens : null;
|
||||
const memberLogStreamEnabled = isMemberLogStreamUiEnabled();
|
||||
|
||||
if (!member) return null;
|
||||
|
||||
|
|
@ -368,7 +375,26 @@ export const MemberDetailDialog = ({
|
|||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="logs" className="min-w-0 overflow-hidden">
|
||||
{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>
|
||||
</Tabs>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,29 +1,15 @@
|
|||
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 {
|
||||
getTeamColorSet,
|
||||
getThemedBadge,
|
||||
getThemedBorder,
|
||||
getThemedText,
|
||||
} from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
ExecutionLogStreamView,
|
||||
normalizeExecutionLogStream,
|
||||
} from '@features/member-log-stream/renderer';
|
||||
import { api } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
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 { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { AlertCircle, Clock, FileText, Loader2 } from 'lucide-react';
|
||||
|
||||
import type {
|
||||
BoardTaskLogActor,
|
||||
BoardTaskLogSegment,
|
||||
BoardTaskLogStreamResponse,
|
||||
ResolvedTeamMember,
|
||||
} from '@shared/types';
|
||||
import type { BoardTaskLogStreamResponse } from '@shared/types';
|
||||
|
||||
interface TaskLogStreamSectionProps {
|
||||
teamName: string;
|
||||
|
|
@ -34,54 +20,6 @@ interface TaskLogStreamSectionProps {
|
|||
|
||||
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 {
|
||||
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.';
|
||||
|
|
@ -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.';
|
||||
}
|
||||
|
||||
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 = ({
|
||||
teamName,
|
||||
taskId,
|
||||
|
|
@ -255,7 +57,6 @@ export const TaskLogStreamSection = ({
|
|||
const [stream, setStream] = useState<BoardTaskLogStreamResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedParticipantKey, setSelectedParticipantKey] = useState<'all' | string>('all');
|
||||
const teamMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName));
|
||||
const requestSeqRef = useRef(0);
|
||||
const streamRef = useRef<BoardTaskLogStreamResponse | null>(null);
|
||||
|
|
@ -266,41 +67,25 @@ export const TaskLogStreamSection = ({
|
|||
}, [stream]);
|
||||
|
||||
const loadStream = useCallback(
|
||||
async (options?: { resetSelection?: boolean; background?: boolean }): Promise<void> => {
|
||||
const resetSelection = options?.resetSelection ?? false;
|
||||
async (options?: { background?: boolean }): Promise<void> => {
|
||||
const background = options?.background ?? false;
|
||||
const hadExistingStream = streamRef.current != null;
|
||||
const requestSeq = requestSeqRef.current + 1;
|
||||
requestSeqRef.current = requestSeq;
|
||||
|
||||
if (!background) {
|
||||
setLoading(true);
|
||||
}
|
||||
if (!background) setLoading(true);
|
||||
setError((prev) => (background ? prev : null));
|
||||
|
||||
try {
|
||||
const response = normalizeResponse(await api.teams.getTaskLogStream(teamName, taskId));
|
||||
if (requestSeqRef.current !== requestSeq) {
|
||||
return;
|
||||
}
|
||||
const response = normalizeExecutionLogStream(
|
||||
await api.teams.getTaskLogStream(teamName, taskId)
|
||||
);
|
||||
if (requestSeqRef.current !== requestSeq) return;
|
||||
|
||||
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);
|
||||
} catch (loadError) {
|
||||
if (requestSeqRef.current !== requestSeq) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestSeqRef.current !== requestSeq) return;
|
||||
if (!background || streamRef.current == null) {
|
||||
setError(
|
||||
loadError instanceof Error ? loadError.message : 'Failed to load task log stream'
|
||||
|
|
@ -320,13 +105,12 @@ export const TaskLogStreamSection = ({
|
|||
setStream(null);
|
||||
streamRef.current = null;
|
||||
setError(null);
|
||||
setSelectedParticipantKey('all');
|
||||
requestSeqRef.current += 1;
|
||||
if (reloadTimerRef.current) {
|
||||
clearTimeout(reloadTimerRef.current);
|
||||
reloadTimerRef.current = null;
|
||||
}
|
||||
void loadStream({ resetSelection: true });
|
||||
void loadStream();
|
||||
}, [loadStream]);
|
||||
|
||||
const previousTaskMetaRef = useRef({ taskId, taskStatus });
|
||||
|
|
@ -335,10 +119,7 @@ export const TaskLogStreamSection = ({
|
|||
const previousTaskMeta = previousTaskMetaRef.current;
|
||||
previousTaskMetaRef.current = { taskId, taskStatus };
|
||||
|
||||
if (previousTaskMeta.taskId !== taskId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousTaskMeta.taskId !== taskId) return;
|
||||
if (
|
||||
previousTaskMeta.taskStatus === 'in_progress' &&
|
||||
taskStatus &&
|
||||
|
|
@ -358,12 +139,8 @@ export const TaskLogStreamSection = ({
|
|||
}
|
||||
|
||||
const scheduleReload = (): void => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
||||
return;
|
||||
}
|
||||
if (reloadTimerRef.current) {
|
||||
clearTimeout(reloadTimerRef.current);
|
||||
}
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
|
||||
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
|
||||
reloadTimerRef.current = setTimeout(() => {
|
||||
reloadTimerRef.current = null;
|
||||
void loadStream({ background: true });
|
||||
|
|
@ -371,22 +148,15 @@ export const TaskLogStreamSection = ({
|
|||
};
|
||||
|
||||
const unsubscribe = api.teams.onTeamChange?.((_event, event) => {
|
||||
if (event.teamName !== teamName) {
|
||||
return;
|
||||
}
|
||||
if (event.teamName !== teamName) return;
|
||||
const shouldReload =
|
||||
event.type === 'log-source-change' ||
|
||||
(isTaskLogActivityChangeEvent(event) && event.taskId === taskId);
|
||||
if (!shouldReload) {
|
||||
return;
|
||||
}
|
||||
scheduleReload();
|
||||
if (shouldReload) scheduleReload();
|
||||
});
|
||||
|
||||
const handleVisibilityChange = (): void => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
scheduleReload();
|
||||
}
|
||||
if (document.visibilityState === 'visible') scheduleReload();
|
||||
};
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
|
|
@ -401,115 +171,25 @@ export const TaskLogStreamSection = ({
|
|||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}
|
||||
if (typeof unsubscribe === 'function') {
|
||||
unsubscribe();
|
||||
}
|
||||
if (typeof unsubscribe === 'function') unsubscribe();
|
||||
};
|
||||
}, [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 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 (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||
Task Log Stream
|
||||
</h4>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">{streamDescription}</p>
|
||||
|
||||
{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)}
|
||||
<ExecutionLogStreamView
|
||||
title="Task Log Stream"
|
||||
description={streamDescription}
|
||||
stream={stream}
|
||||
loading={loading}
|
||||
error={error}
|
||||
teamName={teamName}
|
||||
onClick={() => setSelectedParticipantKey(participant.key)}
|
||||
teamMembers={teamMembers}
|
||||
loadingText="Loading task log stream..."
|
||||
emptyTitle="No task log stream yet"
|
||||
emptyDescription="Task-linked logs will appear here when transcript metadata or runtime projection is available."
|
||||
selectionResetKey={`${teamName}:${taskId}`}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ import type { TerminalAPI } from './terminal';
|
|||
import type { TmuxAPI } from './tmux';
|
||||
import type { WaterfallData } from './visualization';
|
||||
import type { CodexAccountElectronApi } from '@features/codex-account/contracts';
|
||||
import type { MemberLogStreamApi } from '@features/member-log-stream/contracts';
|
||||
import type {
|
||||
MemberWorkSyncMetricsRequest,
|
||||
MemberWorkSyncReportRequest,
|
||||
|
|
@ -904,6 +905,9 @@ export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElec
|
|||
// Member actionable-work sync diagnostics API
|
||||
memberWorkSync: MemberWorkSyncElectronApi;
|
||||
|
||||
// Member log stream API
|
||||
memberLogStream: MemberLogStreamApi;
|
||||
|
||||
// tmux runtime diagnostics API
|
||||
tmux: TmuxAPI;
|
||||
|
||||
|
|
|
|||
|
|
@ -808,8 +808,14 @@ export interface ResolvedTeamMember {
|
|||
workflow?: string;
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
selectedFastMode?: TeamFastMode;
|
||||
resolvedFastMode?: boolean;
|
||||
laneId?: string;
|
||||
laneKind?: 'primary' | 'secondary';
|
||||
laneOwnerProviderId?: TeamProviderId;
|
||||
cwd?: string;
|
||||
/** Set only when member's git branch differs from the lead's branch. */
|
||||
gitBranch?: string;
|
||||
|
|
@ -910,7 +916,13 @@ export interface TeamViewSnapshot {
|
|||
|
||||
export type EffortLevel = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
||||
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 interface ProviderModelLaunchIdentity {
|
||||
|
|
|
|||
|
|
@ -954,8 +954,8 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
messageCount: 2,
|
||||
toolCallCount: 1,
|
||||
errorCount: 0,
|
||||
latestAssistantText: '/tmp/project',
|
||||
latestAssistantPreview: '/tmp/project',
|
||||
latestAssistantText: '/Users/tester/project',
|
||||
latestAssistantPreview: '/Users/tester/project',
|
||||
messages: [],
|
||||
diagnostics: [],
|
||||
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 () => {
|
||||
const fixturePath = path.resolve(
|
||||
process.cwd(),
|
||||
|
|
|
|||
|
|
@ -37,17 +37,20 @@ describe('TeamMemberLogsFinder', () => {
|
|||
await fs.mkdir(projectRoot, { recursive: true });
|
||||
|
||||
const projectResolver = {
|
||||
getLiveBaseContext: vi.fn(async () => ({
|
||||
getLiveBaseContext: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
projectDir: projectRoot,
|
||||
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 = {
|
||||
read: vi.fn(async () => ({
|
||||
read: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
version: 2,
|
||||
teamName,
|
||||
updatedAt: '2026-05-03T12:00:00.000Z',
|
||||
|
|
@ -69,7 +72,8 @@ describe('TeamMemberLogsFinder', () => {
|
|||
},
|
||||
summary: {},
|
||||
teamLaunchState: 'partial_pending',
|
||||
})),
|
||||
})
|
||||
),
|
||||
};
|
||||
|
||||
const finder = new TeamMemberLogsFinder(
|
||||
|
|
@ -429,6 +433,97 @@ describe('TeamMemberLogsFinder', () => {
|
|||
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 () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
|
|
@ -463,11 +558,15 @@ describe('TeamMemberLogsFinder', () => {
|
|||
await fs.mkdir(path.join(projectRoot, currentSessionId, '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',
|
||||
type: 'user',
|
||||
message: { role: 'user', content: `You are alice, a developer on team "${teamName}" (${teamName}).` },
|
||||
message: {
|
||||
role: 'user',
|
||||
content: `You are alice, a developer on team "${teamName}" (${teamName}).`,
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-01-01T00:00:02.000Z',
|
||||
|
|
@ -1270,7 +1369,11 @@ describe('TeamMemberLogsFinder', () => {
|
|||
message: {
|
||||
role: 'assistant',
|
||||
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: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', name: 'TaskUpdate', input: { taskId: '3', status: 'in_progress' } },
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: 'TaskUpdate',
|
||||
input: { taskId: '3', status: 'in_progress' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ import { useStore } from '@renderer/store';
|
|||
|
||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
const memberLogStreamMockState = vi.hoisted(() => ({
|
||||
uiEnabled: true,
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/hooks/useMemberStats', () => ({
|
||||
useMemberStats: () => ({
|
||||
stats: null,
|
||||
|
|
@ -110,11 +114,32 @@ vi.mock('@renderer/components/team/members/MemberLogsTab', () => ({
|
|||
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';
|
||||
|
||||
describe('MemberDetailDialog activity count', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
memberLogStreamMockState.uiEnabled = true;
|
||||
useStore.setState({
|
||||
teamMessagesByName: {
|
||||
'demo-team': {
|
||||
|
|
@ -139,6 +164,98 @@ describe('MemberDetailDialog activity count', () => {
|
|||
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 () => {
|
||||
const member: ResolvedTeamMember = {
|
||||
name: 'jack',
|
||||
|
|
@ -348,7 +465,7 @@ describe('MemberDetailDialog activity count', () => {
|
|||
messageCount: 0,
|
||||
providerId: 'opencode',
|
||||
};
|
||||
const onRestartMember = vi.fn(async () => undefined);
|
||||
const onRestartMember = vi.fn(() => Promise.resolve(undefined));
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
|
@ -424,7 +541,7 @@ describe('MemberDetailDialog activity count', () => {
|
|||
messageCount: 0,
|
||||
providerId: 'opencode',
|
||||
};
|
||||
const onRestartMember = vi.fn(async () => undefined);
|
||||
const onRestartMember = vi.fn(() => Promise.resolve(undefined));
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { GraphMemberLogPreviewHud } from '@features/agent-graph/renderer/ui/GraphMemberLogPreviewHud';
|
||||
|
||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||
|
||||
const previewsByMember = new Map([
|
||||
[
|
||||
'team-lead',
|
||||
{
|
||||
memberName: 'team-lead',
|
||||
items: [
|
||||
{
|
||||
id: 'lead-preview-1',
|
||||
kind: 'text' as const,
|
||||
provider: 'claude_transcript' as const,
|
||||
timestamp: '2026-04-03T00:00:00.000Z',
|
||||
title: 'Assistant',
|
||||
preview: 'lead log preview',
|
||||
tone: 'neutral' as const,
|
||||
},
|
||||
],
|
||||
coverage: [{ provider: 'claude_transcript' as const, status: 'included' as const }],
|
||||
warnings: [],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
generatedAt: '2026-04-03T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
[
|
||||
'alice',
|
||||
{
|
||||
memberName: 'alice',
|
||||
items: [
|
||||
{
|
||||
id: 'preview-1',
|
||||
kind: 'tool_use' as const,
|
||||
provider: 'claude_transcript' as const,
|
||||
timestamp: '2026-04-03T00:00:00.000Z',
|
||||
title: 'Bash',
|
||||
preview: 'pnpm test',
|
||||
tone: 'warning' as const,
|
||||
},
|
||||
],
|
||||
coverage: [{ provider: 'claude_transcript' as const, status: 'included' as const }],
|
||||
warnings: [],
|
||||
truncated: true,
|
||||
overflowCount: 2,
|
||||
generatedAt: '2026-04-03T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
vi.mock('@features/agent-graph/renderer/hooks/useGraphMemberLogPreviews', () => ({
|
||||
buildGraphLogPreviewLaneIdsByMember: () => ({ alice: 'secondary:opencode:alice' }),
|
||||
useGraphMemberLogPreviews: () => ({
|
||||
previewsByMember,
|
||||
loading: false,
|
||||
error: null,
|
||||
reload: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@features/agent-graph/renderer/hooks/useGraphActivityContext', () => ({
|
||||
useGraphActivityContext: () => ({
|
||||
teamData: {
|
||||
members: [
|
||||
{
|
||||
name: 'alice',
|
||||
status: 'active',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
lastActiveAt: null,
|
||||
messageCount: 0,
|
||||
providerId: 'opencode',
|
||||
laneOwnerProviderId: 'opencode',
|
||||
laneId: 'secondary:opencode:alice',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('GraphMemberLogPreviewHud', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn(() => 1)
|
||||
);
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-03T00:01:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('opens the member profile on the logs tab when a preview row or overflow is clicked', async () => {
|
||||
const node: GraphNode = {
|
||||
id: 'member:alpha-team:alice',
|
||||
kind: 'member',
|
||||
label: 'alice',
|
||||
state: 'active',
|
||||
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' },
|
||||
};
|
||||
const onOpenMemberProfile = vi.fn();
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<GraphMemberLogPreviewHud
|
||||
teamName="alpha-team"
|
||||
nodes={[node]}
|
||||
getLogWorldRect={() => ({
|
||||
left: 40,
|
||||
top: 80,
|
||||
right: 300,
|
||||
bottom: 372,
|
||||
width: 260,
|
||||
height: 292,
|
||||
})}
|
||||
getCameraZoom={() => 1}
|
||||
worldToScreen={(x, y) => ({ x, y })}
|
||||
getViewportSize={() => ({ width: 1200, height: 800 })}
|
||||
focusNodeIds={null}
|
||||
onOpenMemberProfile={onOpenMemberProfile}
|
||||
/>
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const row = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('pnpm test')
|
||||
);
|
||||
expect(row).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
row?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onOpenMemberProfile).toHaveBeenCalledWith('alice', { initialTab: 'logs' });
|
||||
|
||||
const moreButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('+2 more')
|
||||
);
|
||||
expect(moreButton).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
moreButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onOpenMemberProfile).toHaveBeenCalledTimes(2);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders lead log previews and opens the lead profile logs tab', async () => {
|
||||
const leadNode: GraphNode = {
|
||||
id: 'lead:alpha-team',
|
||||
kind: 'lead',
|
||||
label: 'alpha-team',
|
||||
state: 'active',
|
||||
domainRef: { kind: 'lead', teamName: 'alpha-team', memberName: 'team-lead' },
|
||||
};
|
||||
const onOpenMemberProfile = vi.fn();
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<GraphMemberLogPreviewHud
|
||||
teamName="alpha-team"
|
||||
nodes={[leadNode]}
|
||||
getLogWorldRect={() => ({
|
||||
left: 40,
|
||||
top: 80,
|
||||
right: 300,
|
||||
bottom: 372,
|
||||
width: 260,
|
||||
height: 292,
|
||||
})}
|
||||
getCameraZoom={() => 1}
|
||||
worldToScreen={(x, y) => ({ x, y })}
|
||||
getViewportSize={() => ({ width: 1200, height: 800 })}
|
||||
focusNodeIds={null}
|
||||
onOpenMemberProfile={onOpenMemberProfile}
|
||||
/>
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const row = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('lead log preview')
|
||||
);
|
||||
expect(row).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
row?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onOpenMemberProfile).toHaveBeenCalledWith('team-lead', { initialTab: 'logs' });
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
import React, { act, useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useGraphMemberLogPreviews } from '@features/agent-graph/renderer/hooks/useGraphMemberLogPreviews';
|
||||
|
||||
import type { MemberLogPreviewResponse } from '@features/member-log-stream/contracts';
|
||||
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
memberLogStream: {
|
||||
getMemberLogPreviews: vi.fn(),
|
||||
},
|
||||
teams: {
|
||||
onTeamChange: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function createDeferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function response(memberName: string, generatedAt: string): MemberLogPreviewResponse {
|
||||
return {
|
||||
generatedAt,
|
||||
members: [
|
||||
{
|
||||
memberName,
|
||||
items: [
|
||||
{
|
||||
id: `${memberName}:${generatedAt}`,
|
||||
kind: 'text',
|
||||
provider: 'claude_transcript',
|
||||
timestamp: generatedAt,
|
||||
title: 'Assistant',
|
||||
preview: memberName,
|
||||
tone: 'neutral',
|
||||
},
|
||||
],
|
||||
coverage: [{ provider: 'claude_transcript', status: 'included' }],
|
||||
warnings: [],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
generatedAt,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const HookProbe = ({
|
||||
teamName,
|
||||
memberNames,
|
||||
laneIdsByMember,
|
||||
enabled = true,
|
||||
onState,
|
||||
}: {
|
||||
teamName: string;
|
||||
memberNames: string[];
|
||||
laneIdsByMember?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
onState: (state: ReturnType<typeof useGraphMemberLogPreviews>) => void;
|
||||
}): React.JSX.Element | null => {
|
||||
const state = useGraphMemberLogPreviews({
|
||||
teamName,
|
||||
memberNames,
|
||||
laneIdsByMember,
|
||||
enabled,
|
||||
});
|
||||
useEffect(() => {
|
||||
onState(state);
|
||||
}, [onState, state]);
|
||||
return null;
|
||||
};
|
||||
|
||||
describe('useGraphMemberLogPreviews', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
apiMock.memberLogStream.getMemberLogPreviews.mockReset();
|
||||
apiMock.teams.onTeamChange.mockReset();
|
||||
apiMock.teams.onTeamChange.mockReturnValue(() => undefined);
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
value: 'visible',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('debounces visible member batch requests and passes safe lane ids', async () => {
|
||||
apiMock.memberLogStream.getMemberLogPreviews.mockResolvedValue(
|
||||
response('alice', '2026-04-03T00:00:00.000Z')
|
||||
);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<HookProbe
|
||||
teamName="alpha-team"
|
||||
memberNames={['alice', 'alice']}
|
||||
laneIdsByMember={{ alice: 'secondary:opencode:alice' }}
|
||||
onState={() => undefined}
|
||||
/>
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledWith(
|
||||
'alpha-team',
|
||||
['alice'],
|
||||
expect.objectContaining({
|
||||
maxItemsPerMember: 3,
|
||||
textLimit: 200,
|
||||
laneIdsByMember: { alice: 'secondary:opencode:alice' },
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps completed previews cached after the visible member set changes', async () => {
|
||||
const aliceLoad = createDeferred<MemberLogPreviewResponse>();
|
||||
const bobLoad = createDeferred<MemberLogPreviewResponse>();
|
||||
apiMock.memberLogStream.getMemberLogPreviews
|
||||
.mockReturnValueOnce(aliceLoad.promise)
|
||||
.mockReturnValueOnce(bobLoad.promise);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const states: ReturnType<typeof useGraphMemberLogPreviews>[] = [];
|
||||
const onState = vi.fn((state: ReturnType<typeof useGraphMemberLogPreviews>) => {
|
||||
states.push(state);
|
||||
});
|
||||
const latestState = (): ReturnType<typeof useGraphMemberLogPreviews> | undefined =>
|
||||
states.at(-1);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={onState} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookProbe teamName="alpha-team" memberNames={['bob']} onState={onState} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
aliceLoad.resolve(response('alice', '2026-04-03T00:00:00.000Z'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice');
|
||||
|
||||
await act(async () => {
|
||||
bobLoad.resolve(response('bob', '2026-04-03T00:01:00.000Z'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.previewsByMember.get('bob')?.items[0]?.preview).toBe('bob');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps cached previews while pan or zoom changes the visible member batch', async () => {
|
||||
const bobLoad = createDeferred<MemberLogPreviewResponse>();
|
||||
apiMock.memberLogStream.getMemberLogPreviews
|
||||
.mockResolvedValueOnce(response('alice', '2026-04-03T00:00:00.000Z'))
|
||||
.mockReturnValueOnce(bobLoad.promise);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const states: ReturnType<typeof useGraphMemberLogPreviews>[] = [];
|
||||
const onState = vi.fn((state: ReturnType<typeof useGraphMemberLogPreviews>) => {
|
||||
states.push(state);
|
||||
});
|
||||
const latestState = (): ReturnType<typeof useGraphMemberLogPreviews> | undefined =>
|
||||
states.at(-1);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={onState} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice');
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookProbe teamName="alpha-team" memberNames={[]} onState={onState} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice');
|
||||
|
||||
await act(async () => {
|
||||
root.render(<HookProbe teamName="alpha-team" memberNames={['bob']} onState={onState} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice');
|
||||
|
||||
await act(async () => {
|
||||
bobLoad.resolve(response('bob', '2026-04-03T00:01:00.000Z'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(latestState()?.previewsByMember.get('bob')?.items[0]?.preview).toBe('bob');
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('reloads visible members on log-source events with force refresh', async () => {
|
||||
let teamChangeListener:
|
||||
| ((event: unknown, data: { teamName: string; type: string }) => void)
|
||||
| null = null;
|
||||
apiMock.teams.onTeamChange.mockImplementation((callback) => {
|
||||
teamChangeListener = callback as typeof teamChangeListener;
|
||||
return () => undefined;
|
||||
});
|
||||
apiMock.memberLogStream.getMemberLogPreviews.mockResolvedValue(
|
||||
response('alice', '2026-04-03T00:00:00.000Z')
|
||||
);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<HookProbe teamName="alpha-team" memberNames={['alice']} onState={() => undefined} />
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
teamChangeListener?.(null, { teamName: 'alpha-team', type: 'log-source-change' });
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2);
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith(
|
||||
'alpha-team',
|
||||
['alice'],
|
||||
expect.objectContaining({ forceRefresh: true })
|
||||
);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -149,7 +149,7 @@ describe('stable slot layout planner', () => {
|
|||
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('builds a board band that contains both the activity column and kanban band', () => {
|
||||
it('builds a board band that contains activity, logs, and kanban without overlap', () => {
|
||||
const teamName = 'team-process-width';
|
||||
const lead = createLead(teamName);
|
||||
const alice = createMember(teamName, 'agent-alice', 'alice');
|
||||
|
|
@ -170,9 +170,14 @@ describe('stable slot layout planner', () => {
|
|||
const frame = snapshot?.memberSlotFrames[0];
|
||||
expect(frame).toBeDefined();
|
||||
expect(frame?.boardBandRect.top).toBe(frame?.activityColumnRect.top);
|
||||
expect(frame?.boardBandRect.top).toBe(frame?.logColumnRect.top);
|
||||
expect(frame?.boardBandRect.top).toBe(frame?.kanbanBandRect.top);
|
||||
expect(frame?.activityColumnRect.left).toBe(frame?.boardBandRect.left);
|
||||
expect(frame?.kanbanBandRect.left).toBeGreaterThan(frame?.activityColumnRect.right ?? 0);
|
||||
expect(frame?.logColumnRect.left).toBeGreaterThan(frame?.activityColumnRect.right ?? 0);
|
||||
expect(frame?.kanbanBandRect.left).toBeGreaterThan(frame?.logColumnRect.right ?? 0);
|
||||
expect(rectsOverlap(frame!.activityColumnRect, frame!.logColumnRect)).toBe(false);
|
||||
expect(rectsOverlap(frame!.logColumnRect, frame!.kanbanBandRect)).toBe(false);
|
||||
expect(rectsOverlap(frame!.logColumnRect, frame!.processBandRect)).toBe(false);
|
||||
expect(frame?.processBandRect.width).toBe(computeProcessBandWidth(0));
|
||||
expect(frame?.processBandRect.height).toBe(STABLE_SLOT_GEOMETRY.processBandHeight);
|
||||
});
|
||||
|
|
@ -289,12 +294,12 @@ describe('stable slot layout planner', () => {
|
|||
version: 'stable-slots-v1',
|
||||
ownerOrder: members.map((member) => member.id),
|
||||
slotAssignments: {
|
||||
[members[0]!.id]: { ringIndex: 0, sectorIndex: 0 },
|
||||
[members[1]!.id]: { ringIndex: 0, sectorIndex: 1 },
|
||||
[members[2]!.id]: { ringIndex: 0, sectorIndex: 2 },
|
||||
[members[3]!.id]: { ringIndex: 0, sectorIndex: 3 },
|
||||
[members[4]!.id]: { ringIndex: 0, sectorIndex: 4 },
|
||||
[members[5]!.id]: { ringIndex: 0, sectorIndex: 5 },
|
||||
[members[0].id]: { ringIndex: 0, sectorIndex: 0 },
|
||||
[members[1].id]: { ringIndex: 0, sectorIndex: 1 },
|
||||
[members[2].id]: { ringIndex: 0, sectorIndex: 2 },
|
||||
[members[3].id]: { ringIndex: 0, sectorIndex: 3 },
|
||||
[members[4].id]: { ringIndex: 0, sectorIndex: 4 },
|
||||
[members[5].id]: { ringIndex: 0, sectorIndex: 5 },
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -307,8 +312,8 @@ describe('stable slot layout planner', () => {
|
|||
expect(snapshot).not.toBeNull();
|
||||
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
|
||||
|
||||
for (const frame of snapshot!.memberSlotFrames) {
|
||||
for (const centralRect of snapshot!.centralCollisionRects) {
|
||||
for (const frame of snapshot.memberSlotFrames) {
|
||||
for (const centralRect of snapshot.centralCollisionRects) {
|
||||
if (!rectsOverlapVertically(frame.bounds, centralRect)) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -346,6 +351,7 @@ describe('stable slot layout planner', () => {
|
|||
|
||||
expect(footprint).toBeDefined();
|
||||
expect(footprint?.activityColumnWidth).toBe(ACTIVITY_LANE.width);
|
||||
expect(footprint?.logColumnWidth).toBe(260);
|
||||
expect(footprint?.activityColumnHeight).toBe(
|
||||
ACTIVITY_LANE.headerHeight +
|
||||
ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight +
|
||||
|
|
@ -381,11 +387,15 @@ describe('stable slot layout planner', () => {
|
|||
expect(footprint).toBeDefined();
|
||||
expect(footprint?.activityColumnWidth).toBe(0);
|
||||
expect(footprint?.activityColumnHeight).toBe(0);
|
||||
expect(footprint?.logColumnWidth).toBe(0);
|
||||
expect(footprint?.logColumnHeight).toBe(0);
|
||||
expect(footprint?.boardBandWidth).toBe(footprint?.kanbanBandWidth);
|
||||
expect(snapshot).not.toBeNull();
|
||||
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
|
||||
expect(frame?.activityColumnRect.width).toBe(0);
|
||||
expect(frame?.activityColumnRect.height).toBe(0);
|
||||
expect(frame?.logColumnRect.width).toBe(0);
|
||||
expect(frame?.logColumnRect.height).toBe(0);
|
||||
expect(frame?.kanbanBandRect.left).toBe(frame?.boardBandRect.left);
|
||||
});
|
||||
|
||||
|
|
@ -414,19 +424,19 @@ describe('stable slot layout planner', () => {
|
|||
expect(frame).toBeDefined();
|
||||
expect(footprint).toBeDefined();
|
||||
|
||||
const legacyHorizontalExtent = snapshot!.runtimeCentralExclusion.right;
|
||||
const legacyVerticalExtent = Math.abs(snapshot!.runtimeCentralExclusion.top);
|
||||
const legacyHorizontalExtent = snapshot.runtimeCentralExclusion.right;
|
||||
const legacyVerticalExtent = Math.abs(snapshot.runtimeCentralExclusion.top);
|
||||
const legacyRequiredX =
|
||||
(legacyHorizontalExtent + footprint!.slotWidth / 2 + STABLE_SLOT_GEOMETRY.ringPadding) /
|
||||
(legacyHorizontalExtent + footprint.slotWidth / 2 + STABLE_SLOT_GEOMETRY.ringPadding) /
|
||||
Math.abs(sectorVector.x);
|
||||
const legacyRequiredY =
|
||||
(legacyVerticalExtent + footprint!.slotHeight / 2 + STABLE_SLOT_GEOMETRY.ringPadding) /
|
||||
(legacyVerticalExtent + footprint.slotHeight / 2 + STABLE_SLOT_GEOMETRY.ringPadding) /
|
||||
Math.abs(sectorVector.y);
|
||||
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(snapshot!.centralCollisionRects.some((rect) => rectsOverlap(frame!.bounds, rect))).toBe(
|
||||
expect(snapshot.centralCollisionRects.some((rect) => rectsOverlap(frame.bounds, rect))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
|
@ -974,7 +984,7 @@ describe('stable slot layout planner', () => {
|
|||
});
|
||||
|
||||
expect(snapshot).not.toBeNull();
|
||||
const targetFrame = snapshot!.memberSlotFrames[1]!;
|
||||
const targetFrame = snapshot.memberSlotFrames[1];
|
||||
|
||||
expect(
|
||||
resolveNearestSlotAssignment({
|
||||
|
|
@ -982,7 +992,7 @@ describe('stable slot layout planner', () => {
|
|||
ownerX: targetFrame.ownerX,
|
||||
ownerY: targetFrame.ownerY,
|
||||
nodes: [lead, ...members],
|
||||
snapshot: snapshot!,
|
||||
snapshot,
|
||||
layout,
|
||||
})
|
||||
).toBeNull();
|
||||
|
|
@ -992,7 +1002,7 @@ describe('stable slot layout planner', () => {
|
|||
ownerId: members[0].id,
|
||||
ownerX: targetFrame.ownerX,
|
||||
ownerY: targetFrame.ownerY,
|
||||
snapshot: snapshot!,
|
||||
snapshot,
|
||||
})
|
||||
).toEqual({
|
||||
targetOwnerId: members[1].id,
|
||||
|
|
@ -1072,6 +1082,7 @@ describe('stable slot layout planner', () => {
|
|||
expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadCoreRect);
|
||||
expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.processBandRect);
|
||||
expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.activityColumnRect);
|
||||
expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.logColumnRect);
|
||||
expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.kanbanBandRect);
|
||||
expect(snapshot!.leadCentralReservedBlock.width).toBeLessThan(
|
||||
snapshot!.leadSlotFrame.bounds.width
|
||||
|
|
|
|||
Loading…
Reference in a new issue