diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index b4fc758e..419c9158 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -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()); const launchAnchorPositionsRef = useRef(new Map()); const activityRectByNodeIdRef = useRef(new Map()); + const logRectByNodeIdRef = useRef(new Map()); const extraWorldBoundsRef = useRef([]); const prevNodeIdsRef = useRef(new Set()); @@ -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 }; launchAnchorPositionsRef: { current: Map }; activityRectByNodeIdRef: { current: Map }; + logRectByNodeIdRef: { current: Map }; 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 }; activityRectByNodeIdRef: { current: Map }; + logRectByNodeIdRef: { current: Map }; 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); diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts index 0e7aa03f..5f8672c2 100644 --- a/packages/agent-graph/src/layout/stableSlots.ts +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -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>(); const processCountByOwnerId = new Map(); @@ -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, diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index 8a990dbb..7fc62370 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -67,6 +67,7 @@ export interface GraphLayoutPort { version: GraphLayoutVersion; mode?: GraphLayoutMode; showActivity?: boolean; + showLogs?: boolean; ownerOrder: string[]; slotAssignments: Record; } diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index ba143518..5486d139 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -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 | null; focusEdgeIds?: ReadonlySet | 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 | null; @@ -1092,6 +1098,7 @@ export function GraphView({ filters, getLaunchAnchorScreenPlacement, getActivityWorldRect, + getLogWorldRect, getTransientHandoffSnapshot, getCameraZoom, worldToScreen: camera.worldToScreen, diff --git a/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts b/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts new file mode 100644 index 00000000..39049efc --- /dev/null +++ b/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts @@ -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>; + 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 { + return new Map(members.map((member) => [normalizeMemberName(member.memberName), member])); +} + +function mergeMemberPreviews( + base: Map, + members: Iterable +): Map { + const next = new Map(base); + for (const member of members) { + next.set(normalizeMemberName(member.memberName), member); + } + return next; +} + +function laneIdForMember( + memberName: string, + laneIdsByMember: Readonly> +): string { + return ( + laneIdsByMember[memberName]?.trim() ?? + laneIdsByMember[normalizeMemberName(memberName)]?.trim() ?? + '' + ); +} + +function buildMemberCacheKey(input: { + teamName: string; + memberName: string; + laneIdsByMember: Readonly>; + 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 { + const laneIdsByMember: Record = {}; + 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>; + enabled?: boolean; + maxItemsPerMember?: number; + textLimit?: number; +}): { + previewsByMember: Map; + loading: boolean; + error: string | null; + reload: (options?: { forceRefresh?: boolean; background?: boolean }) => Promise; +} { + 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(); + 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() + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const cacheRef = useRef(new Map()); + const previewsByMemberRef = useRef(previewsByMember); + const inFlightRef = useRef(new Map>>()); + const reloadTimerRef = useRef | 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 => { + 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 }; +} diff --git a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx new file mode 100644 index 00000000..26f0e196 --- /dev/null +++ b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx @@ -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 | 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 ; + } + if (item.tone === 'error') { + return ; + } + if (item.kind === 'tool_result') { + return ; + } + if (item.kind === 'tool_use') { + return ; + } + if (item.kind === 'thinking') { + return ; + } + return ; +} + +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(null); + const shellRefs = useRef(new Map()); + const visibleKeyRef = useRef(''); + const [visibleMemberNames, setVisibleMemberNames] = useState([]); + 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) => ( + + ), + [openLogs] + ); + + if (!enabled || ownerNodes.length === 0) { + return null; + } + + return ( +
+ {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 ( +
{ + 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(); + }} + > +
+
+ + Logs +
+
+ {items.length > 0 ? ( + items.slice(0, 3).map((item) => renderItem(memberName, item)) + ) : ( + + )} + {preview && preview.overflowCount > 0 ? ( + + ) : null} +
+
+
+ ); + })} +
+ ); +}; diff --git a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index 3de4ab0a..b9f549d7 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -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 | null; @@ -186,6 +195,17 @@ export const TeamGraphOverlay = ({ onOpenTaskDetail={onOpenTaskDetail} onOpenMemberProfile={onOpenMemberProfile} /> + ); }} diff --git a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx index 14e21ec7..43ba87fb 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx @@ -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 | null; @@ -207,6 +216,17 @@ export const TeamGraphTab = ({ onOpenTaskDetail={dispatchOpenTask} onOpenMemberProfile={dispatchOpenProfile} /> + ); }} diff --git a/src/features/member-log-stream/contracts/api.ts b/src/features/member-log-stream/contracts/api.ts index 01480ada..69283538 100644 --- a/src/features/member-log-stream/contracts/api.ts +++ b/src/features/member-log-stream/contracts/api.ts @@ -1,4 +1,9 @@ -import type { MemberLogStreamRequestOptions, MemberLogStreamResponse } from './dto'; +import type { + MemberLogPreviewRequestOptions, + MemberLogPreviewResponse, + MemberLogStreamRequestOptions, + MemberLogStreamResponse, +} from './dto'; export interface MemberLogStreamApi { getMemberLogStream( @@ -6,5 +11,10 @@ export interface MemberLogStreamApi { memberName: string, options?: MemberLogStreamRequestOptions ): Promise; + getMemberLogPreviews( + teamName: string, + memberNames: string[], + options?: MemberLogPreviewRequestOptions + ): Promise; setMemberLogStreamTracking(teamName: string, enabled: boolean): Promise; } diff --git a/src/features/member-log-stream/contracts/channels.ts b/src/features/member-log-stream/contracts/channels.ts index 88799ef5..a0dc0115 100644 --- a/src/features/member-log-stream/contracts/channels.ts +++ b/src/features/member-log-stream/contracts/channels.ts @@ -1,2 +1,3 @@ export const MEMBER_LOG_STREAM_GET = 'member-log-stream:getMemberLogStream'; +export const MEMBER_LOG_STREAM_GET_PREVIEWS = 'member-log-stream:getMemberLogPreviews'; export const MEMBER_LOG_STREAM_SET_TRACKING = 'member-log-stream:setTracking'; diff --git a/src/features/member-log-stream/contracts/dto.ts b/src/features/member-log-stream/contracts/dto.ts index 2d05c0f5..d0c7cfda 100644 --- a/src/features/member-log-stream/contracts/dto.ts +++ b/src/features/member-log-stream/contracts/dto.ts @@ -18,6 +18,13 @@ export interface MemberLogStreamRequestOptions { forceRefresh?: boolean; } +export interface MemberLogPreviewRequestOptions { + maxItemsPerMember?: number; + textLimit?: number; + laneIdsByMember?: Record; + forceRefresh?: boolean; +} + export interface MemberLogStreamCoverage { provider: MemberLogStreamProvider; status: 'included' | 'partial' | 'skipped'; @@ -70,3 +77,36 @@ export interface MemberLogStreamResponse { generatedAt: string; metadata: MemberLogStreamMetadata; } + +export type MemberLogPreviewItemKind = 'text' | 'tool_use' | 'tool_result' | 'thinking'; + +export type MemberLogPreviewItemTone = 'neutral' | 'success' | 'warning' | 'error'; + +export interface MemberLogPreviewItem { + id: string; + kind: MemberLogPreviewItemKind; + provider: MemberLogStreamProvider; + timestamp: string; + title: string; + preview?: string; + tone: MemberLogPreviewItemTone; + toolName?: string; + sourceLabel?: string; + sessionId?: string; + laneId?: string; +} + +export interface MemberLogPreviewMember { + memberName: string; + items: MemberLogPreviewItem[]; + coverage: MemberLogStreamCoverage[]; + warnings: MemberLogStreamWarning[]; + truncated: boolean; + overflowCount: number; + generatedAt: string; +} + +export interface MemberLogPreviewResponse { + members: MemberLogPreviewMember[]; + generatedAt: string; +} diff --git a/src/features/member-log-stream/contracts/normalize.ts b/src/features/member-log-stream/contracts/normalize.ts index d528d75e..7de2556b 100644 --- a/src/features/member-log-stream/contracts/normalize.ts +++ b/src/features/member-log-stream/contracts/normalize.ts @@ -1,4 +1,8 @@ -import type { MemberLogStreamResponse } from './dto'; +import type { + MemberLogPreviewMember, + MemberLogPreviewResponse, + MemberLogStreamResponse, +} from './dto'; export function createEmptyMemberLogStreamResponse( generatedAt = new Date().toISOString() @@ -42,3 +46,48 @@ export function normalizeMemberLogStreamResponse( }, }; } + +export function createEmptyMemberLogPreviewResponse( + generatedAt = new Date().toISOString() +): MemberLogPreviewResponse { + return { + members: [], + generatedAt, + }; +} + +function normalizeMemberLogPreviewMember(member: MemberLogPreviewMember): MemberLogPreviewMember { + return { + memberName: typeof member.memberName === 'string' ? member.memberName : '', + items: Array.isArray(member.items) ? member.items : [], + coverage: Array.isArray(member.coverage) ? member.coverage : [], + warnings: Array.isArray(member.warnings) ? member.warnings : [], + truncated: member.truncated === true, + overflowCount: + typeof member.overflowCount === 'number' && Number.isFinite(member.overflowCount) + ? Math.max(0, Math.floor(member.overflowCount)) + : 0, + generatedAt: + typeof member.generatedAt === 'string' && member.generatedAt.length > 0 + ? member.generatedAt + : new Date().toISOString(), + }; +} + +export function normalizeMemberLogPreviewResponse( + response: MemberLogPreviewResponse | null | undefined +): MemberLogPreviewResponse { + if (!response) { + return createEmptyMemberLogPreviewResponse(); + } + + return { + members: Array.isArray(response.members) + ? response.members.map(normalizeMemberLogPreviewMember) + : [], + generatedAt: + typeof response.generatedAt === 'string' && response.generatedAt.length > 0 + ? response.generatedAt + : new Date().toISOString(), + }; +} diff --git a/src/features/member-log-stream/core/application/ports/MemberLogPreviewSource.ts b/src/features/member-log-stream/core/application/ports/MemberLogPreviewSource.ts new file mode 100644 index 00000000..f90de451 --- /dev/null +++ b/src/features/member-log-stream/core/application/ports/MemberLogPreviewSource.ts @@ -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; +} diff --git a/src/features/member-log-stream/core/application/use-cases/GetMemberLogPreviewsUseCase.ts b/src/features/member-log-stream/core/application/use-cases/GetMemberLogPreviewsUseCase.ts new file mode 100644 index 00000000..92d8fea2 --- /dev/null +++ b/src/features/member-log-stream/core/application/use-cases/GetMemberLogPreviewsUseCase.ts @@ -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; + forceRefresh?: boolean; +} + +interface GetMemberLogPreviewsUseCaseDeps { + sources: readonly MemberLogPreviewSource[]; + clock: ClockPort; + logger: LoggerPort; + budget?: Partial; +} + +interface NormalizedMemberRequest { + memberName: string; + laneId?: string; +} + +function normalizeMemberName(value: string): string { + return value.trim().toLowerCase(); +} + +function normalizeMembers( + memberNames: readonly string[], + laneIdsByMember: Record | undefined, + maxMembers: number +): NormalizedMemberRequest[] { + const result: NormalizedMemberRequest[] = []; + const seen = new Set(); + 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>(); + + constructor(private readonly deps: GetMemberLogPreviewsUseCaseDeps) { + this.budget = { ...DEFAULT_MEMBER_LOG_PREVIEW_BUDGET, ...(deps.budget ?? {}) }; + } + + async execute(input: GetMemberLogPreviewsInput): Promise { + 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 { + 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 => { + 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, + }; + } +} diff --git a/src/features/member-log-stream/core/application/use-cases/__tests__/GetMemberLogPreviewsUseCase.test.ts b/src/features/member-log-stream/core/application/use-cases/__tests__/GetMemberLogPreviewsUseCase.test.ts new file mode 100644 index 00000000..6d70f931 --- /dev/null +++ b/src/features/member-log-stream/core/application/use-cases/__tests__/GetMemberLogPreviewsUseCase.test.ts @@ -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 { + 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'); + }); +}); diff --git a/src/features/member-log-stream/core/domain/models/MemberLogPreviewBudget.ts b/src/features/member-log-stream/core/domain/models/MemberLogPreviewBudget.ts new file mode 100644 index 00000000..503ca4f2 --- /dev/null +++ b/src/features/member-log-stream/core/domain/models/MemberLogPreviewBudget.ts @@ -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))); +} diff --git a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts new file mode 100644 index 00000000..da45a508 --- /dev/null +++ b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, it } from 'vitest'; + +import { extractMemberLogPreviewItems } from '../memberLogPreviewExtractor'; + +import type { MemberLogPreviewParsedMessage } from '../memberLogPreviewExtractor'; + +function message( + overrides: Partial & { + 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: 'latest answer' }], + }), + ], + }); + + 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); + }); +}); diff --git a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewMergePolicy.test.ts b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewMergePolicy.test.ts new file mode 100644 index 00000000..d9bf6661 --- /dev/null +++ b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewMergePolicy.test.ts @@ -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); + }); +}); diff --git a/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts new file mode 100644 index 00000000..9ca89177 --- /dev/null +++ b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts @@ -0,0 +1,1098 @@ +import type { + MemberLogPreviewItem, + MemberLogPreviewItemKind, + MemberLogPreviewItemTone, + MemberLogStreamProvider, +} from '../../../contracts'; + +export type MemberLogPreviewContentBlock = + | { type: 'text'; text: string } + | { type: 'thinking'; thinking: string; signature?: string } + | { type: 'tool_use'; id: string; name: string; input?: unknown } + | { type: 'tool_result'; tool_use_id: string; content?: unknown; is_error?: boolean } + | { type: 'image'; source?: unknown } + | { type: string; [key: string]: unknown }; + +export interface MemberLogPreviewParsedMessage { + uuid?: string; + type?: string; + role?: string; + timestamp: Date | string; + content: string | MemberLogPreviewContentBlock[]; + toolCalls?: readonly { + id: string; + name: string; + input?: unknown; + isTask?: boolean; + }[]; + toolResults?: readonly { + toolUseId: string; + content: unknown; + isError?: boolean; + }[]; + sourceToolUseID?: string; + toolUseResult?: Record; + sessionId?: string; +} + +export interface ExtractMemberLogPreviewInput { + messages: readonly MemberLogPreviewParsedMessage[]; + provider: MemberLogStreamProvider; + maxItems: number; + textLimit: number; + sourceId?: string; + sourceLabel?: string; + sessionId?: string; + laneId?: string; +} + +export interface ExtractMemberLogPreviewResult { + items: MemberLogPreviewItem[]; + truncated: boolean; + overflowCount: number; +} + +interface Candidate { + item: MemberLogPreviewItem; + timestampMs: number; + order: number; + textTruncated: boolean; +} + +const UNKNOWN_TIMESTAMP_MS = 0; +const TOOL_INPUT_PRIORITY_KEYS = [ + 'command', + 'description', + 'summary', + 'text', + 'message', + 'comment', + 'prompt', + 'to', + 'filePath', + 'file_path', + 'path', + 'url', + 'query', +] as const; +const TOOL_RESULT_PRIORITY_KEYS = [ + 'error', + 'stderr', + 'stdout', + 'content', + 'result', + 'summary', + 'message', + 'status', +] as const; + +interface ValuePreview { + preview: string; + truncated: boolean; + title?: string; +} + +interface KnownPayloadPreview { + title?: string; + text: string; +} + +interface ToolUseContext { + id: string; + name: string; + canonicalName: string; + input?: unknown; +} + +function timestampMs(value: Date | string): number { + const time = value instanceof Date ? value.getTime() : Date.parse(value); + return Number.isFinite(time) ? time : UNKNOWN_TIMESTAMP_MS; +} + +function timestampIso(value: Date | string): string { + const time = timestampMs(value); + return new Date(time || 0).toISOString(); +} + +function stripAngleTags(value: string): string { + let result = ''; + let insideTag = false; + for (const char of value) { + if (char === '<') { + insideTag = true; + result += ' '; + continue; + } + if (char === '>') { + insideTag = false; + result += ' '; + continue; + } + if (!insideTag) { + result += char; + } + } + return result; +} + +function compactWhitespace(value: string): string { + return stripAngleTags(value).replace(/\s+/g, ' ').trim(); +} + +function looksLikeJsonPayload(value: string): boolean { + const trimmed = value.trim(); + return trimmed.startsWith('{') || trimmed.startsWith('['); +} + +function parseJsonLikeString(value: string): unknown { + if (!looksLikeJsonPayload(value)) { + return null; + } + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function truncatePreview(value: string, limit: number): { preview: string; truncated: boolean } { + const compact = compactWhitespace(value); + if (compact.length <= limit) { + return { preview: compact, truncated: false }; + } + const allowed = Math.max(1, limit - 3); + return { preview: `${compact.slice(0, allowed)}...`, truncated: true }; +} + +function stringifyPrimitive(value: unknown): string { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (value == null) return ''; + return ''; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function textFromTextContentBlocks(value: unknown): string | null { + if (!Array.isArray(value)) { + return null; + } + const text = value + .map((item) => { + const record = asRecord(item); + return record?.type === 'text' && typeof record.text === 'string' ? record.text : ''; + }) + .filter(Boolean) + .join(' '); + return text.trim().length > 0 ? text : null; +} + +function unwrapAgentTeamsResponsePayload(payload: Record): { + payload: Record; + wrapperKey?: string; +} { + const wrapperKey = Object.keys(payload).find( + (key) => key.startsWith('agent_teams_') && key.endsWith('_response') + ); + if (!wrapperKey) { + return { payload }; + } + const nested = payload[wrapperKey]; + return { payload: asRecord(nested) ?? payload, wrapperKey }; +} + +function recordFromUnknownWithWrapper( + value: unknown +): { payload: Record; wrapperKey?: string } | null { + const textBlocks = textFromTextContentBlocks(value); + if (textBlocks) { + return recordFromUnknownWithWrapper(textBlocks); + } + + if (typeof value === 'string') { + const parsed = parseJsonLikeString(value); + const record = asRecord(parsed); + return record ? unwrapAgentTeamsResponsePayload(record) : null; + } + + const record = asRecord(value); + if (!record) { + return null; + } + + if (typeof record.content === 'string') { + const nested = recordFromUnknownWithWrapper(record.content); + if (nested) { + return nested; + } + } + + return unwrapAgentTeamsResponsePayload(record); +} + +function recordFromUnknown(value: unknown): Record | null { + return recordFromUnknownWithWrapper(value)?.payload ?? null; +} + +function findPriorityValue( + record: Record, + keys: readonly string[] +): string | null { + for (const key of keys) { + const value = stringifyPrimitive(record[key]); + if (value.trim().length > 0) { + return value; + } + } + return null; +} + +function canonicalToolName(value: string): string { + const trimmed = value.trim(); + const lower = trimmed.toLowerCase(); + const doubleUnderscoreName = lower.split('__').at(-1) ?? lower; + return doubleUnderscoreName + .replace(/^agent-teams_/, '') + .replace(/^agent_teams_/, '') + .replace(/^mcp_/, ''); +} + +function canonicalToolNameFromWrapperKey(value: string | undefined): string | null { + if (!value) return null; + return ( + value + .replace(/^agent_teams_/, '') + .replace(/_response$/, '') + .trim() + .toLowerCase() || null + ); +} + +function formatToolTitle(toolName: string): string { + const canonical = canonicalToolName(toolName); + if (canonical === 'sendmessage' || canonical === 'message_send') return 'Send message'; + if (canonical === 'task_complete') return 'Complete task'; + if (canonical === 'task_add_comment') return 'Add comment'; + if (canonical === 'task_get_comment') return 'Read comment'; + if (canonical === 'task_get') return 'Read task'; + if (canonical === 'task_start') return 'Start task'; + if (canonical === 'task_set_status') return 'Set status'; + if (canonical === 'task_set_owner') return 'Set owner'; + if (canonical === 'task_set_clarification') return 'Set clarification'; + if (canonical === 'task_attach_comment_file') return 'Attach comment file'; + if (canonical === 'review_request') return 'Request review'; + if (canonical === 'review_start') return 'Start review'; + if (canonical === 'runtime_bootstrap_checkin') return 'Runtime check-in'; + if (canonical === 'member_briefing') return 'Member briefing'; + if (canonical === 'task_add') return 'Add task'; + return toolName.trim() || 'Tool use'; +} + +function stringField( + record: Record | null | undefined, + key: string +): string | null { + const value = record?.[key]; + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function formatTaskRef(value: string | null | undefined): string | null { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const withoutHash = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed; + const shortRef = + withoutHash.includes('-') && withoutHash.length > 12 ? withoutHash.slice(0, 8) : withoutHash; + return `#${shortRef}`; +} + +function taskRefFromPayload( + payload: Record, + fallbackInput?: Record | null +): string | null { + const task = asRecord(payload.task); + return formatTaskRef( + stringField(payload, 'displayId') ?? + stringField(task, 'displayId') ?? + stringField(payload, 'taskId') ?? + stringField(fallbackInput ?? undefined, 'taskId') ?? + stringField(payload, 'id') ?? + stringField(task, 'id') + ); +} + +function shortTaskSummary(task: Record | undefined): string | null { + const title = stringField(task, 'title') ?? stringField(task, 'name'); + const status = stringField(task, 'status'); + const owner = stringField(task, 'owner'); + const parts = [title, status ? `status ${status}` : null, owner ? `owner ${owner}` : null].filter( + Boolean + ); + return parts.length > 0 ? parts.join(', ') : null; +} + +function formatTaskStatusPayload( + payload: Record, + fallbackInput?: Record | null +): string | null { + const taskRef = taskRefFromPayload(payload, fallbackInput); + const status = stringField(payload, 'status') ?? stringField(asRecord(payload.task), 'status'); + if (!taskRef || !status) { + return null; + } + return `Task ${taskRef} ${status}`; +} + +function formatTaskCommentPayload( + payload: Record, + fallbackInput?: Record | null +): string | null { + const commentRecord = asRecord(payload.comment) ?? undefined; + const commentText = + stringField(commentRecord, 'text') ?? + stringField(payload, 'text') ?? + stringField(payload, 'comment') ?? + stringField(fallbackInput ?? undefined, 'text'); + const taskRef = taskRefFromPayload(payload, fallbackInput); + if (!commentText) { + return taskRef ? `Comment added to ${taskRef}` : null; + } + + const author = + stringField(commentRecord, 'author') ?? + stringField(payload, 'author') ?? + stringField(fallbackInput ?? undefined, 'from') ?? + stringField(fallbackInput ?? undefined, 'author'); + if (author && taskRef) return `Comment by ${author} on ${taskRef}: ${commentText}`; + if (author) return `Comment by ${author}: ${commentText}`; + if (taskRef) return `Comment on ${taskRef}: ${commentText}`; + return `Comment: ${commentText}`; +} + +function formatTaskToolPayload( + payload: Record, + canonicalToolNameValue: string | null, + fallbackInput?: Record | null +): KnownPayloadPreview | null { + const canonical = canonicalToolNameValue ?? ''; + const taskRef = taskRefFromPayload(payload, fallbackInput); + const task = asRecord(payload.task) ?? undefined; + const taskSummary = shortTaskSummary(task); + const status = stringField(payload, 'status') ?? stringField(task, 'status'); + const owner = + stringField(payload, 'owner') ?? + stringField(task, 'owner') ?? + stringField(fallbackInput ?? undefined, 'owner'); + const clarification = + stringField(payload, 'clarification') ?? + stringField(fallbackInput ?? undefined, 'clarification'); + const filename = + stringField(payload, 'filename') ?? + stringField(payload, 'fileName') ?? + stringField(fallbackInput ?? undefined, 'filename') ?? + stringField(fallbackInput ?? undefined, 'fileName'); + + if (canonical === 'task_add_comment') { + const text = formatTaskCommentPayload(payload, fallbackInput); + return text ? { title: 'Comment added', text } : null; + } + if (canonical === 'task_start') { + return taskRef ? { title: 'Task started', text: `Started ${taskRef}` } : null; + } + if (canonical === 'task_complete') { + return taskRef ? { title: 'Task completed', text: `Completed ${taskRef}` } : null; + } + if (canonical === 'task_get') { + if (taskRef && taskSummary) return { title: 'Task loaded', text: `${taskRef}: ${taskSummary}` }; + return taskRef ? { title: 'Task loaded', text: `Loaded ${taskRef}` } : null; + } + if (canonical === 'task_set_status') { + if (taskRef && status) return { title: 'Task status', text: `${taskRef} -> ${status}` }; + return taskRef ? { title: 'Task status', text: `Updated ${taskRef}` } : null; + } + if (canonical === 'task_set_owner') { + if (taskRef && owner) return { title: 'Task owner', text: `${taskRef} -> ${owner}` }; + return taskRef ? { title: 'Task owner', text: `Updated ${taskRef}` } : null; + } + if (canonical === 'task_set_clarification') { + if (taskRef && clarification) { + return { title: 'Clarification', text: `${taskRef} -> ${clarification}` }; + } + return taskRef ? { title: 'Clarification', text: `Updated ${taskRef}` } : null; + } + if (canonical === 'task_attach_comment_file') { + if (taskRef && filename) return { title: 'Comment file', text: `${filename} on ${taskRef}` }; + return taskRef ? { title: 'Comment file', text: `Attached file to ${taskRef}` } : null; + } + if (canonical === 'review_request') { + const reviewer = + stringField(payload, 'reviewer') ?? stringField(fallbackInput ?? undefined, 'reviewer'); + if (taskRef && reviewer) + return { title: 'Review requested', text: `${taskRef} -> ${reviewer}` }; + return taskRef ? { title: 'Review requested', text: `Requested review for ${taskRef}` } : null; + } + if (canonical === 'review_start') { + return taskRef ? { title: 'Review started', text: `Started review for ${taskRef}` } : null; + } + if (taskRef && status) { + return { title: 'Task update', text: `Task ${taskRef} ${status}` }; + } + if (taskRef && taskSummary) { + return { title: 'Task update', text: `${taskRef}: ${taskSummary}` }; + } + return null; +} + +function formatRuntimePayload( + payload: Record, + canonicalToolNameValue: string | null, + fallbackInput?: Record | null +): KnownPayloadPreview | null { + const canonical = canonicalToolNameValue ?? ''; + if (canonical === 'runtime_bootstrap_checkin') { + const memberName = + stringField(payload, 'memberName') ?? stringField(fallbackInput ?? undefined, 'memberName'); + return { + title: 'Runtime check-in', + text: memberName ? `${memberName} checked in` : 'Runtime checked in', + }; + } + if (canonical === 'member_briefing') { + const memberName = + stringField(payload, 'memberName') ?? stringField(fallbackInput ?? undefined, 'memberName'); + return { + title: 'Member briefing', + text: memberName ? `Loaded briefing for ${memberName}` : 'Loaded member briefing', + }; + } + return null; +} + +function formatErrorPayload(payload: Record): KnownPayloadPreview | null { + const error = + stringField(payload, 'error') ?? + stringField(payload, 'errorMessage') ?? + stringField(payload, 'message'); + if ( + error && + (payload.ok === false || payload.success === false || payload.error || payload.errorMessage) + ) { + return { title: 'Tool error', text: error }; + } + if (payload.ok === false || payload.success === false) { + return { title: 'Tool error', text: 'Tool reported failure' }; + } + return null; +} + +function formatMessageSendPayload(payload: Record): string | null { + const routing = asRecord(payload.routing) ?? undefined; + const messageRecord = asRecord(payload.message) ?? undefined; + const deliveryMessage = stringField(payload, 'message'); + const summary = stringField(messageRecord, 'summary') ?? stringField(routing, 'summary'); + const target = stringField(messageRecord, 'to') ?? stringField(routing, 'target'); + const messageText = + stringField(messageRecord, 'text') ?? + stringField(messageRecord, 'content') ?? + stringField(routing, 'content'); + + if (deliveryMessage && summary) return `${deliveryMessage} - ${summary}`; + if (summary && target) return `Message sent to ${target} - ${summary}`; + if (summary) return summary; + if (deliveryMessage) return deliveryMessage; + if (messageText && target) return `Message sent to ${target} - ${messageText}`; + if (messageText) return messageText; + if (target) return `Message sent to ${target}`; + return null; +} + +function formatMessageSendResultFromInput(payload: Record): string | null { + const target = stringField(payload, 'to') ?? stringField(payload, 'target'); + const summary = + stringField(payload, 'summary') ?? + stringField(payload, 'text') ?? + stringField(payload, 'message') ?? + stringField(payload, 'content'); + if (target && summary) return `Message sent to ${target} - ${summary}`; + if (target) return `Message sent to ${target}`; + if (summary) return summary; + return null; +} + +function formatMessageSendInputPayload(payload: Record): string | null { + const target = stringField(payload, 'to') ?? stringField(payload, 'target'); + const summary = + stringField(payload, 'summary') ?? + stringField(payload, 'text') ?? + stringField(payload, 'message') ?? + stringField(payload, 'content'); + if (target && summary) return `to ${target}: ${summary}`; + if (summary) return summary; + if (target) return `to ${target}`; + return null; +} + +function formatPlainToolResultStatus( + value: string, + toolContext: ToolUseContext | undefined +): KnownPayloadPreview | null { + if (!toolContext) { + return null; + } + const normalized = compactWhitespace(value).toLowerCase(); + if (!['ok', 'done', 'success', 'comment added', 'message sent'].includes(normalized)) { + return null; + } + const fallbackInput = asRecord(toolContext.input); + if (toolContext.canonicalName === 'sendmessage' || toolContext.canonicalName === 'message_send') { + const text = fallbackInput ? formatMessageSendResultFromInput(fallbackInput) : null; + return text ? { title: 'Message sent', text } : null; + } + return ( + formatTaskToolPayload({}, toolContext.canonicalName, fallbackInput) ?? + formatRuntimePayload({}, toolContext.canonicalName, fallbackInput) + ); +} + +function formatTaskToolInputPayload( + canonicalToolNameValue: string, + payload: Record +): string | null { + const taskRef = taskRefFromPayload(payload, payload); + const text = stringField(payload, 'text') ?? stringField(payload, 'comment'); + const status = stringField(payload, 'status'); + const owner = stringField(payload, 'owner'); + const clarification = stringField(payload, 'clarification'); + const reviewer = stringField(payload, 'reviewer'); + + if (canonicalToolNameValue === 'task_add_comment') { + if (taskRef && text) return `on ${taskRef}: ${text}`; + if (taskRef) return `on ${taskRef}`; + if (text) return text; + return null; + } + if (canonicalToolNameValue === 'task_set_status') { + if (taskRef && status) return `${taskRef} -> ${status}`; + } + if (canonicalToolNameValue === 'task_set_owner') { + if (taskRef && owner) return `${taskRef} -> ${owner}`; + } + if (canonicalToolNameValue === 'task_set_clarification') { + if (taskRef && clarification) return `${taskRef} -> ${clarification}`; + } + if (canonicalToolNameValue === 'review_request') { + if (taskRef && reviewer) return `${taskRef} -> ${reviewer}`; + } + if (taskRef) return taskRef; + return null; +} + +function formatKnownPayloadPreview( + value: unknown, + toolContext?: ToolUseContext +): KnownPayloadPreview | null { + const record = recordFromUnknownWithWrapper(value); + if (!record) { + return null; + } + const payload = record.payload; + const fallbackInput = asRecord(toolContext?.input); + const canonical = + toolContext?.canonicalName ?? canonicalToolNameFromWrapperKey(record.wrapperKey) ?? null; + + const errorText = formatErrorPayload(payload); + if (errorText) { + return errorText; + } + const taskToolText = formatTaskToolPayload(payload, canonical, fallbackInput); + if (taskToolText) { + return taskToolText; + } + const runtimeText = formatRuntimePayload(payload, canonical, fallbackInput); + if (runtimeText) { + return runtimeText; + } + const messageText = formatMessageSendPayload(payload); + if (messageText) { + return { title: 'Message sent', text: messageText }; + } + const commentText = formatTaskCommentPayload(payload); + if (commentText) { + return { title: 'Comment added', text: commentText }; + } + const taskText = formatTaskStatusPayload(payload, fallbackInput); + if (taskText) { + return { title: 'Task update', text: taskText }; + } + return null; +} + +function previewUnknownValue( + value: unknown, + limit: number, + priorityKeys: readonly string[], + toolContext?: ToolUseContext +): ValuePreview { + if (typeof value === 'string') { + const known = formatKnownPayloadPreview(value, toolContext); + if (known) { + return { ...truncatePreview(known.text, limit), title: known.title }; + } + const plainStatus = formatPlainToolResultStatus(value, toolContext); + if (plainStatus) { + return { ...truncatePreview(plainStatus.text, limit), title: plainStatus.title }; + } + return truncatePreview(value, limit); + } + if (typeof value === 'number' || typeof value === 'boolean') { + return { preview: String(value), truncated: false }; + } + if (Array.isArray(value)) { + const textBlocks = textFromTextContentBlocks(value); + if (textBlocks) { + return previewUnknownValue(textBlocks, limit, priorityKeys, toolContext); + } + const parts = value + .slice(0, 3) + .map((item) => previewUnknownValue(item, limit, priorityKeys, toolContext).preview) + .filter(Boolean); + return truncatePreview(parts.join(' '), limit); + } + if (value && typeof value === 'object') { + const record = value as Record; + const known = formatKnownPayloadPreview(record, toolContext); + if (known) { + return { ...truncatePreview(known.text, limit), title: known.title }; + } + const priority = findPriorityValue(record, priorityKeys); + if (priority) { + return truncatePreview(priority, limit); + } + const parts = Object.entries(record) + .filter(([, item]) => item != null && typeof item !== 'object') + .slice(0, 4) + .map(([key, item]) => `${key}: ${String(item)}`); + if (parts.length > 0) { + return truncatePreview(parts.join(', '), limit); + } + const keys = Object.keys(record).slice(0, 5); + return truncatePreview(keys.length > 0 ? `fields: ${keys.join(', ')}` : '', limit); + } + return { preview: '', truncated: false }; +} + +function previewToolInputValue(toolName: string, value: unknown, limit: number): ValuePreview { + const canonical = canonicalToolName(toolName); + if (canonical === 'sendmessage' || canonical === 'message_send') { + const payload = recordFromUnknown(value); + const formatted = payload ? formatMessageSendInputPayload(payload) : null; + if (formatted) { + return truncatePreview(formatted, limit); + } + } + const payload = recordFromUnknown(value); + if (payload) { + const taskFormatted = formatTaskToolInputPayload(canonical, payload); + if (taskFormatted) { + return truncatePreview(taskFormatted, limit); + } + } + return previewUnknownValue(value, limit, TOOL_INPUT_PRIORITY_KEYS); +} + +function extractTextPreview( + content: string | MemberLogPreviewContentBlock[], + textLimit: number +): { preview: string; truncated: boolean } | null { + if (typeof content === 'string') { + const text = truncatePreview(content, textLimit); + return text.preview.length > 0 ? text : null; + } + const text = content + .filter((block): block is Extract => { + return block.type === 'text' && typeof block.text === 'string'; + }) + .map((block) => block.text) + .join(' '); + const preview = truncatePreview(text, textLimit); + return preview.preview.length > 0 ? preview : null; +} + +function isToolUseBlock( + block: MemberLogPreviewContentBlock +): block is Extract { + return ( + block.type === 'tool_use' && + typeof (block as { id?: unknown }).id === 'string' && + typeof (block as { name?: unknown }).name === 'string' + ); +} + +function isToolResultBlock( + block: MemberLogPreviewContentBlock +): block is Extract { + return ( + block.type === 'tool_result' && + typeof (block as { tool_use_id?: unknown }).tool_use_id === 'string' + ); +} + +function buildToolUseContexts( + messages: readonly MemberLogPreviewParsedMessage[] +): Map { + const contexts = new Map(); + const addContext = (tool: { id: string; name: string; input?: unknown }): void => { + const id = tool.id.trim(); + if (!id || contexts.has(id)) return; + contexts.set(id, { + id, + name: tool.name, + canonicalName: canonicalToolName(tool.name), + input: tool.input, + }); + }; + + for (const message of messages) { + message.toolCalls?.forEach((toolCall) => addContext(toolCall)); + if (!Array.isArray(message.content)) continue; + message.content.forEach((block) => { + if (!isToolUseBlock(block)) return; + addContext({ + id: block.id, + name: block.name, + input: block.input, + }); + }); + } + return contexts; +} + +function extractThinkingPreview( + content: string | MemberLogPreviewContentBlock[], + textLimit: number +): { preview: string; truncated: boolean } | null { + if (!Array.isArray(content)) { + return null; + } + const text = content + .filter((block): block is Extract => { + return block.type === 'thinking' && typeof block.thinking === 'string'; + }) + .map((block) => block.thinking) + .join(' '); + const preview = truncatePreview(text, textLimit); + return preview.preview.length > 0 ? preview : null; +} + +function resolveMessageRole(message: MemberLogPreviewParsedMessage): string { + return message.role ?? message.type ?? ''; +} + +function buildItemId(input: { + provider: MemberLogStreamProvider; + sourceId: string; + messageId: string; + kind: MemberLogPreviewItemKind; + token: string; +}): string { + return [ + input.provider, + input.sourceId.replace(/\s+/g, '_'), + input.messageId.replace(/\s+/g, '_'), + input.kind, + input.token.replace(/\s+/g, '_'), + ].join(':'); +} + +function buildCandidate(input: { + provider: MemberLogStreamProvider; + sourceId: string; + message: MemberLogPreviewParsedMessage; + messageIndex: number; + blockIndex: number; + kind: MemberLogPreviewItemKind; + title: string; + preview?: string; + tone?: MemberLogPreviewItemTone; + toolName?: string; + sourceLabel?: string; + sessionId?: string; + laneId?: string; + token: string; + textTruncated: boolean; +}): Candidate { + const timestamp = timestampIso(input.message.timestamp); + const messageId = input.message.uuid ?? `message-${input.messageIndex}`; + return { + item: { + id: buildItemId({ + provider: input.provider, + sourceId: input.sourceId, + messageId, + kind: input.kind, + token: input.token, + }), + kind: input.kind, + provider: input.provider, + timestamp, + title: input.title, + ...(input.preview ? { preview: input.preview } : {}), + tone: input.tone ?? 'neutral', + ...(input.toolName ? { toolName: input.toolName } : {}), + ...(input.sourceLabel ? { sourceLabel: input.sourceLabel } : {}), + ...(input.sessionId ? { sessionId: input.sessionId } : {}), + ...(input.laneId ? { laneId: input.laneId } : {}), + }, + timestampMs: timestampMs(input.message.timestamp), + order: input.messageIndex * 1_000 + input.blockIndex, + textTruncated: input.textTruncated, + }; +} + +function collectToolUseCandidates(input: { + message: MemberLogPreviewParsedMessage; + messageIndex: number; + provider: MemberLogStreamProvider; + sourceId: string; + sourceLabel?: string; + sessionId?: string; + laneId?: string; + textLimit: number; +}): Candidate[] { + const candidates: Candidate[] = []; + const seen = new Set(); + const addTool = ( + tool: { id: string; name: string; input?: unknown }, + blockIndex: number + ): void => { + const id = tool.id || `${tool.name}:${blockIndex}`; + if (seen.has(id)) return; + seen.add(id); + const preview = previewToolInputValue(tool.name, tool.input, input.textLimit); + candidates.push( + buildCandidate({ + provider: input.provider, + sourceId: input.sourceId, + message: input.message, + messageIndex: input.messageIndex, + blockIndex, + kind: 'tool_use', + title: formatToolTitle(tool.name), + preview: preview.preview, + tone: 'warning', + toolName: tool.name, + sourceLabel: input.sourceLabel, + sessionId: input.sessionId ?? input.message.sessionId, + laneId: input.laneId, + token: id, + textTruncated: preview.truncated, + }) + ); + }; + + input.message.toolCalls?.forEach((toolCall, index) => addTool(toolCall, 100 + index)); + if (Array.isArray(input.message.content)) { + input.message.content.forEach((block, index) => { + if (!isToolUseBlock(block)) return; + addTool( + { + id: block.id, + name: block.name, + input: block.input, + }, + index + ); + }); + } + + return candidates; +} + +function collectToolResultCandidates(input: { + message: MemberLogPreviewParsedMessage; + messageIndex: number; + provider: MemberLogStreamProvider; + sourceId: string; + sourceLabel?: string; + sessionId?: string; + laneId?: string; + textLimit: number; + toolUseContexts: ReadonlyMap; +}): Candidate[] { + const candidates: Candidate[] = []; + const seen = new Set(); + const addResult = ( + result: { toolUseId: string; content: unknown; isError?: boolean }, + blockIndex: number + ): void => { + const id = result.toolUseId || `result:${blockIndex}`; + if (seen.has(id)) return; + seen.add(id); + const toolContext = input.toolUseContexts.get(id); + const preview = previewUnknownValue( + result.content, + input.textLimit, + TOOL_RESULT_PRIORITY_KEYS, + toolContext + ); + const isError = result.isError === true || preview.title === 'Tool error'; + candidates.push( + buildCandidate({ + provider: input.provider, + sourceId: input.sourceId, + message: input.message, + messageIndex: input.messageIndex, + blockIndex, + kind: 'tool_result', + title: isError ? 'Tool error' : (preview.title ?? 'Tool result'), + preview: preview.preview, + tone: isError ? 'error' : 'success', + toolName: toolContext?.name, + sourceLabel: input.sourceLabel, + sessionId: input.sessionId ?? input.message.sessionId, + laneId: input.laneId, + token: id, + textTruncated: preview.truncated, + }) + ); + }; + + input.message.toolResults?.forEach((result, index) => + addResult( + { + toolUseId: result.toolUseId, + content: result.content, + isError: result.isError, + }, + 200 + index + ) + ); + if (input.message.sourceToolUseID && input.message.toolUseResult) { + addResult( + { + toolUseId: input.message.sourceToolUseID, + content: input.message.toolUseResult, + isError: input.message.toolUseResult.isError === true, + }, + 240 + ); + } + if (Array.isArray(input.message.content)) { + input.message.content.forEach((block, index) => { + if (!isToolResultBlock(block)) return; + addResult( + { + toolUseId: block.tool_use_id, + content: block.content, + isError: block.is_error === true, + }, + index + ); + }); + } + + return candidates; +} + +export function extractMemberLogPreviewItems( + input: ExtractMemberLogPreviewInput +): ExtractMemberLogPreviewResult { + const maxItems = Math.max(1, Math.min(3, Math.floor(input.maxItems))); + const textLimit = Math.max(80, Math.min(240, Math.floor(input.textLimit))); + const sourceId = input.sourceId ?? input.sourceLabel ?? input.provider; + const candidates: Candidate[] = []; + const toolUseContexts = buildToolUseContexts(input.messages); + + input.messages.forEach((message, messageIndex) => { + candidates.push( + ...collectToolUseCandidates({ + message, + messageIndex, + provider: input.provider, + sourceId, + sourceLabel: input.sourceLabel, + sessionId: input.sessionId, + laneId: input.laneId, + textLimit, + }), + ...collectToolResultCandidates({ + message, + messageIndex, + provider: input.provider, + sourceId, + sourceLabel: input.sourceLabel, + sessionId: input.sessionId, + laneId: input.laneId, + textLimit, + toolUseContexts, + }) + ); + + const role = resolveMessageRole(message); + if (role === 'assistant') { + const textPreview = extractTextPreview(message.content, textLimit); + if (textPreview) { + candidates.push( + buildCandidate({ + provider: input.provider, + sourceId, + message, + messageIndex, + blockIndex: 10, + kind: 'text', + title: 'Assistant', + preview: textPreview.preview, + tone: 'neutral', + sourceLabel: input.sourceLabel, + sessionId: input.sessionId ?? message.sessionId, + laneId: input.laneId, + token: 'assistant-text', + textTruncated: textPreview.truncated, + }) + ); + } + + const thinkingPreview = extractThinkingPreview(message.content, textLimit); + if (thinkingPreview) { + candidates.push( + buildCandidate({ + provider: input.provider, + sourceId, + message, + messageIndex, + blockIndex: 9, + kind: 'thinking', + title: 'Thinking', + preview: thinkingPreview.preview, + tone: 'neutral', + sourceLabel: input.sourceLabel, + sessionId: input.sessionId ?? message.sessionId, + laneId: input.laneId, + token: 'thinking', + textTruncated: thinkingPreview.truncated, + }) + ); + } + } + }); + + const sorted = [...candidates]; + sorted.sort((left, right) => { + const byTime = right.timestampMs - left.timestampMs; + if (byTime !== 0) return byTime; + const byOrder = right.order - left.order; + if (byOrder !== 0) return byOrder; + return left.item.id.localeCompare(right.item.id); + }); + const items = sorted.slice(0, maxItems).map((candidate) => candidate.item); + const overflowCount = Math.max(0, sorted.length - items.length); + return { + items, + truncated: overflowCount > 0 || sorted.some((candidate) => candidate.textTruncated), + overflowCount, + }; +} diff --git a/src/features/member-log-stream/core/domain/policies/memberLogPreviewMergePolicy.ts b/src/features/member-log-stream/core/domain/policies/memberLogPreviewMergePolicy.ts new file mode 100644 index 00000000..4cb20e61 --- /dev/null +++ b/src/features/member-log-stream/core/domain/policies/memberLogPreviewMergePolicy.ts @@ -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(); + 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(); + 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, + }; +} diff --git a/src/features/member-log-stream/main/adapters/input/ipc/__tests__/registerMemberLogStreamIpc.test.ts b/src/features/member-log-stream/main/adapters/input/ipc/__tests__/registerMemberLogStreamIpc.test.ts index 322ce182..8f1569fa 100644 --- a/src/features/member-log-stream/main/adapters/input/ipc/__tests__/registerMemberLogStreamIpc.test.ts +++ b/src/features/member-log-stream/main/adapters/input/ipc/__tests__/registerMemberLogStreamIpc.test.ts @@ -1,12 +1,16 @@ import { describe, expect, it, vi } from 'vitest'; -import { MEMBER_LOG_STREAM_GET, MEMBER_LOG_STREAM_SET_TRACKING } from '../../../../../contracts'; +import { + MEMBER_LOG_STREAM_GET, + MEMBER_LOG_STREAM_GET_PREVIEWS, + MEMBER_LOG_STREAM_SET_TRACKING, +} from '../../../../../contracts'; import { registerMemberLogStreamIpc, removeMemberLogStreamIpc, } from '../registerMemberLogStreamIpc'; -import type { MemberLogStreamResponse } from '../../../../../contracts'; +import type { MemberLogPreviewResponse, MemberLogStreamResponse } from '../../../../../contracts'; import type { MemberLogStreamFeatureFacade } from '../../../../composition/createMemberLogStreamFeature'; import type { IpcMainInvokeEvent } from 'electron'; @@ -39,6 +43,13 @@ function emptyResponse(): MemberLogStreamResponse { }; } +function emptyPreviewResponse(): MemberLogPreviewResponse { + return { + members: [], + generatedAt: '2026-03-01T00:00:00.000Z', + }; +} + function createFakeIpcMain(): { handlers: Map unknown>; ipcMain: { @@ -66,6 +77,7 @@ describe('registerMemberLogStreamIpc', () => { const getMemberLogStream = vi.fn().mockResolvedValue(emptyResponse()); const feature: MemberLogStreamFeatureFacade = { getMemberLogStream, + getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()), setMemberLogStreamTracking: vi.fn(), }; @@ -98,6 +110,7 @@ describe('registerMemberLogStreamIpc', () => { const getMemberLogStream = vi.fn().mockResolvedValue(emptyResponse()); const feature: MemberLogStreamFeatureFacade = { getMemberLogStream, + getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()), setMemberLogStreamTracking: vi.fn(), }; @@ -124,6 +137,7 @@ describe('registerMemberLogStreamIpc', () => { const getMemberLogStream = vi.fn().mockResolvedValue(emptyResponse()); const feature: MemberLogStreamFeatureFacade = { getMemberLogStream, + getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()), setMemberLogStreamTracking: vi.fn(), }; @@ -172,6 +186,7 @@ describe('registerMemberLogStreamIpc', () => { const setMemberLogStreamTracking = vi.fn().mockResolvedValue(undefined); const feature: MemberLogStreamFeatureFacade = { getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()), + getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()), setMemberLogStreamTracking, }; @@ -190,6 +205,70 @@ describe('registerMemberLogStreamIpc', () => { removeMemberLogStreamIpc(ipcMain as never); expect(handlers.has(MEMBER_LOG_STREAM_GET)).toBe(false); + expect(handlers.has(MEMBER_LOG_STREAM_GET_PREVIEWS)).toBe(false); expect(handlers.has(MEMBER_LOG_STREAM_SET_TRACKING)).toBe(false); }); + + it('validates batch preview requests before calling the feature facade', async () => { + const { handlers, ipcMain } = createFakeIpcMain(); + const getMemberLogPreviews = vi.fn().mockResolvedValue(emptyPreviewResponse()); + const feature: MemberLogStreamFeatureFacade = { + getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()), + getMemberLogPreviews, + setMemberLogStreamTracking: vi.fn(), + }; + + registerMemberLogStreamIpc(ipcMain as never, feature); + const getPreviews = handlers.get(MEMBER_LOG_STREAM_GET_PREVIEWS)!; + + await expect( + getPreviews({} as IpcMainInvokeEvent, 'alpha-team', ['alice', 'bob'], { + maxItemsPerMember: 10, + textLimit: 999, + laneIdsByMember: { + alice: ' secondary:opencode:alice ', + }, + forceRefresh: true, + }) + ).resolves.toEqual({ success: true, data: emptyPreviewResponse() }); + expect(getMemberLogPreviews).toHaveBeenCalledWith({ + teamName: 'alpha-team', + memberNames: ['alice', 'bob'], + maxItemsPerMember: 3, + textLimit: 240, + laneIdsByMember: { + alice: 'secondary:opencode:alice', + }, + forceRefresh: true, + }); + }); + + it('rejects unknown batch preview options and unsafe lane maps', async () => { + const { handlers, ipcMain } = createFakeIpcMain(); + const getMemberLogPreviews = vi.fn().mockResolvedValue(emptyPreviewResponse()); + const feature: MemberLogStreamFeatureFacade = { + getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()), + getMemberLogPreviews, + setMemberLogStreamTracking: vi.fn(), + }; + + registerMemberLogStreamIpc(ipcMain as never, feature); + const getPreviews = handlers.get(MEMBER_LOG_STREAM_GET_PREVIEWS)!; + + await expect( + getPreviews({} as IpcMainInvokeEvent, 'alpha-team', ['alice'], { nope: true }) + ).resolves.toEqual({ + success: false, + error: 'Unknown getMemberLogPreviews option: nope', + }); + await expect( + getPreviews({} as IpcMainInvokeEvent, 'alpha-team', ['alice'], { + laneIdsByMember: { alice: '../bad' }, + }) + ).resolves.toEqual({ + success: false, + error: 'laneId contains invalid characters', + }); + expect(getMemberLogPreviews).not.toHaveBeenCalled(); + }); }); diff --git a/src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts b/src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts index f7a50ce2..ddc18b93 100644 --- a/src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts +++ b/src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts @@ -3,17 +3,30 @@ import { createLogger } from '@shared/utils/logger'; import { MEMBER_LOG_STREAM_GET, + MEMBER_LOG_STREAM_GET_PREVIEWS, MEMBER_LOG_STREAM_SET_TRACKING, + normalizeMemberLogPreviewResponse, normalizeMemberLogStreamResponse, } from '../../../../contracts'; -import type { MemberLogStreamRequestOptions, MemberLogStreamResponse } from '../../../../contracts'; +import type { + MemberLogPreviewRequestOptions, + MemberLogPreviewResponse, + MemberLogStreamRequestOptions, + MemberLogStreamResponse, +} from '../../../../contracts'; import type { MemberLogStreamFeatureFacade } from '../../../composition/createMemberLogStreamFeature'; import type { IpcResult } from '@shared/types'; import type { IpcMain, IpcMainInvokeEvent } from 'electron'; const logger = createLogger('Feature:MemberLogStream:IPC'); const ALLOWED_OPTION_KEYS = new Set(['limitSegments', 'since', 'laneId', 'forceRefresh']); +const ALLOWED_PREVIEW_OPTION_KEYS = new Set([ + 'maxItemsPerMember', + 'textLimit', + 'laneIdsByMember', + 'forceRefresh', +]); interface ValidationResult { valid: boolean; @@ -104,6 +117,106 @@ function normalizeOptions(options: unknown): ValidationResult<{ }; } +function validateMemberNames(value: unknown): ValidationResult { + 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; + 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; + 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 | 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 + )) { + const vMember = validateMemberName(memberName); + if (!vMember.valid) { + return { valid: false, error: vMember.error ?? 'Invalid laneIdsByMember key' }; + } + const vLane = validateOptionalRuntimeLaneId(laneId); + if (!vLane.valid) { + return { valid: false, error: vLane.error ?? 'Invalid laneId' }; + } + if (vLane.value) { + laneIdsByMember[vMember.value!] = vLane.value; + laneIdsByMember[vMember.value!.toLowerCase()] = vLane.value; + } + } + } + + let forceRefresh: boolean | undefined; + if (record.forceRefresh != null) { + if (typeof record.forceRefresh !== 'boolean') { + return { valid: false, error: 'forceRefresh must be a boolean' }; + } + forceRefresh = record.forceRefresh; + } + + return { + valid: true, + value: { + ...(maxItemsPerMember !== undefined ? { maxItemsPerMember } : {}), + ...(textLimit !== undefined ? { textLimit } : {}), + ...(laneIdsByMember !== undefined ? { laneIdsByMember } : {}), + ...(forceRefresh !== undefined ? { forceRefresh } : {}), + }, + }; +} + export function registerMemberLogStreamIpc( ipcMain: IpcMain, feature: MemberLogStreamFeatureFacade @@ -168,9 +281,45 @@ export function registerMemberLogStreamIpc( return { success: false, error: - error instanceof Error - ? error.message - : 'Failed to update member log stream tracking', + error instanceof Error ? error.message : 'Failed to update member log stream tracking', + }; + } + } + ); + + ipcMain.handle( + MEMBER_LOG_STREAM_GET_PREVIEWS, + async ( + _event: IpcMainInvokeEvent, + teamName: unknown, + memberNames: unknown, + options?: MemberLogPreviewRequestOptions + ): Promise> => { + const vTeam = validateTeamName(teamName); + if (!vTeam.valid) { + return { success: false, error: vTeam.error ?? 'Invalid teamName' }; + } + const vMembers = validateMemberNames(memberNames); + if (!vMembers.valid) { + return { success: false, error: vMembers.error ?? 'Invalid memberNames' }; + } + const vOptions = normalizePreviewOptions(options); + if (!vOptions.valid) { + return { success: false, error: vOptions.error ?? 'Invalid options' }; + } + + try { + const response = await feature.getMemberLogPreviews({ + teamName: vTeam.value!, + memberNames: vMembers.value!, + ...vOptions.value!, + }); + return { success: true, data: normalizeMemberLogPreviewResponse(response) }; + } catch (error) { + logger.error('Failed to load member log previews', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to load member log previews', }; } } @@ -179,5 +328,6 @@ export function registerMemberLogStreamIpc( export function removeMemberLogStreamIpc(ipcMain: IpcMain): void { ipcMain.removeHandler(MEMBER_LOG_STREAM_GET); + ipcMain.removeHandler(MEMBER_LOG_STREAM_GET_PREVIEWS); ipcMain.removeHandler(MEMBER_LOG_STREAM_SET_TRACKING); } diff --git a/src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptPreviewSource.ts b/src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptPreviewSource.ts new file mode 100644 index 00000000..fecff42d --- /dev/null +++ b/src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptPreviewSource.ts @@ -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 { + 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, + }; + } +} diff --git a/src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptStreamSource.ts b/src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptStreamSource.ts index cb255dd6..d26d302c 100644 --- a/src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptStreamSource.ts +++ b/src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptStreamSource.ts @@ -4,7 +4,7 @@ import { buildMemberActor, buildMemberParticipant, buildSegmentId, - normalizeMemberName, + dedupeMemberLogRefs, shortHash, withSegmentSource, } from './memberLogStreamSourceUtils'; @@ -18,55 +18,9 @@ import type { } from '../../../../core/application/ports/MemberLogStreamSource'; import type { BoardTaskExactLogChunkBuilder } from '@main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder'; import type { BoardTaskExactLogStrictParser } from '@main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser'; -import type { - MemberLogFileRef, - TeamMemberLogsFinder, -} from '@main/services/team/TeamMemberLogsFinder'; +import type { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder'; import type { ParsedMessage } from '@main/types'; -function isPreferredRef(candidate: MemberLogFileRef, existing: MemberLogFileRef): boolean { - const candidateMessageCount = candidate.messageCount ?? -1; - const existingMessageCount = existing.messageCount ?? -1; - if (candidateMessageCount !== existingMessageCount) { - return candidateMessageCount > existingMessageCount; - } - - const candidateSize = candidate.sizeBytes ?? -1; - const existingSize = existing.sizeBytes ?? -1; - if (candidateSize !== existingSize) { - return candidateSize > existingSize; - } - - return candidate.mtimeMs > existing.mtimeMs; -} - -function dedupeMemberLogRefs(refs: readonly MemberLogFileRef[]): MemberLogFileRef[] { - const byFilePath = new Map(); - const bySession = new Map(); - const passthrough: MemberLogFileRef[] = []; - - for (const ref of refs) { - if (byFilePath.has(ref.filePath)) continue; - byFilePath.set(ref.filePath, ref); - - if (ref.kind === 'lead_session') { - passthrough.push(ref); - continue; - } - - const key = `${ref.kind ?? 'unknown'}:${normalizeMemberName(ref.memberName)}:${ref.sessionId}`; - const existing = bySession.get(key); - if (!existing || isPreferredRef(ref, existing)) { - bySession.set(key, ref); - } - } - - return [...passthrough, ...bySession.values()].sort((left, right) => { - const byTime = right.mtimeMs - left.mtimeMs; - return byTime !== 0 ? byTime : left.filePath.localeCompare(right.filePath); - }); -} - function filterSourceMessageBudget( messages: readonly ParsedMessage[], remaining: number diff --git a/src/features/member-log-stream/main/adapters/output/sources/CodexNativeMemberTracePreviewSource.ts b/src/features/member-log-stream/main/adapters/output/sources/CodexNativeMemberTracePreviewSource.ts new file mode 100644 index 00000000..2bc20a57 --- /dev/null +++ b/src/features/member-log-stream/main/adapters/output/sources/CodexNativeMemberTracePreviewSource.ts @@ -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 { + 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, + }; + } +} diff --git a/src/features/member-log-stream/main/adapters/output/sources/OpenCodeMemberRuntimePreviewSource.ts b/src/features/member-log-stream/main/adapters/output/sources/OpenCodeMemberRuntimePreviewSource.ts new file mode 100644 index 00000000..281cbdec --- /dev/null +++ b/src/features/member-log-stream/main/adapters/output/sources/OpenCodeMemberRuntimePreviewSource.ts @@ -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; +} + +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>(); + + constructor( + private readonly runtimeBridge: ClaudeMultimodelBridgeService, + private readonly binaryResolver: BinaryResolverLike = ClaudeBinaryResolver + ) {} + + async loadPreview(input: MemberLogPreviewSourceInput): Promise { + 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 { + 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, + }; + } +} diff --git a/src/features/member-log-stream/main/adapters/output/sources/__tests__/memberLogSources.test.ts b/src/features/member-log-stream/main/adapters/output/sources/__tests__/memberLogSources.test.ts index 179f3706..6d86937e 100644 --- a/src/features/member-log-stream/main/adapters/output/sources/__tests__/memberLogSources.test.ts +++ b/src/features/member-log-stream/main/adapters/output/sources/__tests__/memberLogSources.test.ts @@ -1,10 +1,15 @@ import { describe, expect, it, vi } from 'vitest'; +import { DEFAULT_MEMBER_LOG_PREVIEW_BUDGET } from '../../../../../core/domain/models/MemberLogPreviewBudget'; import { DEFAULT_MEMBER_LOG_STREAM_BUDGET } from '../../../../../core/domain/models/MemberLogStreamBudget'; +import { ClaudeMemberTranscriptPreviewSource } from '../ClaudeMemberTranscriptPreviewSource'; import { ClaudeMemberTranscriptStreamSource } from '../ClaudeMemberTranscriptStreamSource'; +import { CodexNativeMemberTracePreviewSource } from '../CodexNativeMemberTracePreviewSource'; import { CodexNativeMemberTraceStreamSource } from '../CodexNativeMemberTraceStreamSource'; +import { OpenCodeMemberRuntimePreviewSource } from '../OpenCodeMemberRuntimePreviewSource'; import { OpenCodeMemberRuntimeStreamSource } from '../OpenCodeMemberRuntimeStreamSource'; +import type { MemberLogPreviewSourceInput } from '../../../../../core/application/ports/MemberLogPreviewSource'; import type { MemberLogStreamSourceInput } from '../../../../../core/application/ports/MemberLogStreamSource'; import type { EnhancedChunk, ParsedMessage } from '@main/types'; @@ -49,7 +54,9 @@ function fakeChunk(id: string): EnhancedChunk { }; } -function sourceInput(overrides: Partial = {}): MemberLogStreamSourceInput { +function sourceInput( + overrides: Partial = {} +): MemberLogStreamSourceInput { return { teamName: 'alpha-team', memberName: 'alice', @@ -58,6 +65,19 @@ function sourceInput(overrides: Partial = {}): Membe }; } +function previewInput( + overrides: Partial = {} +): MemberLogPreviewSourceInput { + return { + teamName: 'alpha-team', + memberName: 'alice', + budget: DEFAULT_MEMBER_LOG_PREVIEW_BUDGET, + maxItems: 3, + textLimit: 200, + ...overrides, + }; +} + describe('ClaudeMemberTranscriptStreamSource', () => { it('dedupes cumulative subagent refs by member/session before parsing and keeps path-safe segment ids', async () => { const parseFiles = vi.fn().mockImplementation(async (paths: string[]) => { @@ -114,6 +134,67 @@ describe('ClaudeMemberTranscriptStreamSource', () => { }); }); +describe('ClaudeMemberTranscriptPreviewSource', () => { + it('builds compact previews from parsed transcript messages without chunk building', async () => { + const parseFiles = vi.fn().mockResolvedValue( + new Map([ + [ + '/transcripts/latest.jsonl', + [ + { + ...parsedMessage('tool-call', '2026-04-04T00:00:00.000Z'), + content: [ + { + type: 'tool_use', + id: 'toolu-1', + name: 'Bash', + input: { command: 'pnpm test', ignored: 'x'.repeat(5_000) }, + }, + ], + }, + { + ...parsedMessage('tool-result', '2026-04-04T00:01:00.000Z'), + type: 'user', + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu-1', + content: 'x'.repeat(5_000), + }, + ], + }, + ], + ], + ]) + ); + const source = new ClaudeMemberTranscriptPreviewSource( + { + findRecentMemberLogFileRefsByMember: vi.fn().mockResolvedValue([ + { + memberName: 'alice', + sessionId: 'session-1', + filePath: '/transcripts/latest.jsonl', + mtimeMs: 20, + sizeBytes: 5_000, + messageCount: 2, + kind: 'subagent', + }, + ]), + } as never, + { parseFiles } as never, + { warn: vi.fn(), error: vi.fn(), debug: vi.fn() } + ); + + const result = await source.loadPreview(previewInput({ textLimit: 160 })); + + expect(result.status).toBe('included'); + expect(result.items.map((item) => item.kind)).toEqual(['tool_result', 'tool_use']); + expect(result.items[0]?.preview?.length).toBeLessThanOrEqual(160); + expect(parseFiles).toHaveBeenCalledWith(['/transcripts/latest.jsonl']); + }); +}); + describe('OpenCodeMemberRuntimeStreamSource', () => { it('enforces member message and content budgets before building OpenCode chunks', async () => { const getOpenCodeTranscript = vi.fn().mockResolvedValue({ @@ -133,9 +214,7 @@ describe('OpenCodeMemberRuntimeStreamSource', () => { })), }, }); - const buildBundleChunks = vi.fn((_: ParsedMessage[]) => [ - fakeChunk('opencode-budgeted-chunk'), - ]); + const buildBundleChunks = vi.fn((_: ParsedMessage[]) => [fakeChunk('opencode-budgeted-chunk')]); const source = new OpenCodeMemberRuntimeStreamSource( { getOpenCodeTranscript } as never, { buildBundleChunks } as never, @@ -226,7 +305,9 @@ describe('OpenCodeMemberRuntimeStreamSource', () => { it('reports ambiguous OpenCode lane errors as skipped provider warnings', async () => { const source = new OpenCodeMemberRuntimeStreamSource( { - getOpenCodeTranscript: vi.fn().mockRejectedValue(new Error('multiple records, pass --lane')), + getOpenCodeTranscript: vi + .fn() + .mockRejectedValue(new Error('multiple records, pass --lane')), } as never, { buildBundleChunks: vi.fn(() => [fakeChunk('opencode-chunk')]) } as never, { resolve: vi.fn().mockResolvedValue('/mock/orchestrator') } @@ -247,6 +328,77 @@ describe('OpenCodeMemberRuntimeStreamSource', () => { }); }); +describe('OpenCodeMemberRuntimePreviewSource', () => { + it('skips OpenCode preview without a safe lane id before touching the runtime bridge', async () => { + const getOpenCodeTranscript = vi.fn(); + const resolve = vi.fn(); + const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, { + resolve, + }); + + const result = await source.loadPreview(previewInput()); + + expect(result).toMatchObject({ + provider: 'opencode_runtime', + status: 'skipped', + reason: 'opencode_safe_lane_unavailable', + items: [], + warnings: [], + }); + expect(resolve).not.toHaveBeenCalled(); + expect(getOpenCodeTranscript).not.toHaveBeenCalled(); + }); + + it('uses bounded OpenCode projection messages and preserves safe lane ids', async () => { + const getOpenCodeTranscript = vi.fn().mockResolvedValue({ + sessionId: 'opencode-session', + logProjection: { + messages: [ + { + uuid: 'opencode-1', + parentUuid: null, + type: 'assistant', + timestamp: '2026-04-04T00:00:00.000Z', + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'toolu-1', + name: 'Edit', + input: { filePath: 'src/app.ts' }, + }, + ], + toolCalls: [], + toolResults: [], + isMeta: false, + sessionId: 'opencode-session', + }, + ], + }, + }); + const source = new OpenCodeMemberRuntimePreviewSource({ getOpenCodeTranscript } as never, { + resolve: vi.fn().mockResolvedValue('/mock/orchestrator'), + }); + + const result = await source.loadPreview(previewInput({ laneId: 'secondary:opencode:alice' })); + + expect(result.status).toBe('included'); + expect(result.items[0]).toMatchObject({ + kind: 'tool_use', + title: 'Edit', + laneId: 'secondary:opencode:alice', + }); + expect(getOpenCodeTranscript).toHaveBeenCalledWith( + '/mock/orchestrator', + expect.objectContaining({ + limit: DEFAULT_MEMBER_LOG_PREVIEW_BUDGET.openCodeMessageLimit, + timeoutMs: DEFAULT_MEMBER_LOG_PREVIEW_BUDGET.openCodeTimeoutMs, + laneId: 'secondary:opencode:alice', + }) + ); + }); +}); + describe('CodexNativeMemberTraceStreamSource', () => { it('returns an honest skipped warning for Codex members only', async () => { const codexSource = new CodexNativeMemberTraceStreamSource({ @@ -270,3 +422,20 @@ describe('CodexNativeMemberTraceStreamSource', () => { }); }); }); + +describe('CodexNativeMemberTracePreviewSource', () => { + it('returns unsupported empty coverage for Codex preview without breaking the batch', async () => { + const source = new CodexNativeMemberTracePreviewSource({ + getConfig: vi.fn().mockResolvedValue({ + members: [{ name: 'alice', providerId: 'codex' }], + }), + } as never); + + await expect(source.loadPreview(previewInput())).resolves.toMatchObject({ + provider: 'codex_native_trace', + status: 'skipped', + items: [], + warnings: [{ code: 'codex_member_wide_not_supported' }], + }); + }); +}); diff --git a/src/features/member-log-stream/main/adapters/output/sources/memberLogStreamSourceUtils.ts b/src/features/member-log-stream/main/adapters/output/sources/memberLogStreamSourceUtils.ts index 5dcbc046..084cb588 100644 --- a/src/features/member-log-stream/main/adapters/output/sources/memberLogStreamSourceUtils.ts +++ b/src/features/member-log-stream/main/adapters/output/sources/memberLogStreamSourceUtils.ts @@ -1,9 +1,7 @@ import { createHash } from 'crypto'; -import type { - MemberLogStreamProvider, - MemberLogStreamSegmentSource, -} from '../../../../contracts'; +import type { MemberLogStreamProvider, MemberLogStreamSegmentSource } from '../../../../contracts'; +import type { MemberLogFileRef } from '@main/services/team/TeamMemberLogsFinder'; import type { BoardTaskLogActor, BoardTaskLogParticipant, @@ -18,6 +16,49 @@ export function normalizeTeamName(value: string): string { return value.trim().toLowerCase(); } +function isPreferredRef(candidate: MemberLogFileRef, existing: MemberLogFileRef): boolean { + const candidateMessageCount = candidate.messageCount ?? -1; + const existingMessageCount = existing.messageCount ?? -1; + if (candidateMessageCount !== existingMessageCount) { + return candidateMessageCount > existingMessageCount; + } + + const candidateSize = candidate.sizeBytes ?? -1; + const existingSize = existing.sizeBytes ?? -1; + if (candidateSize !== existingSize) { + return candidateSize > existingSize; + } + + return candidate.mtimeMs > existing.mtimeMs; +} + +export function dedupeMemberLogRefs(refs: readonly MemberLogFileRef[]): MemberLogFileRef[] { + const byFilePath = new Map(); + const bySession = new Map(); + 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' diff --git a/src/features/member-log-stream/main/composition/createMemberLogStreamFeature.ts b/src/features/member-log-stream/main/composition/createMemberLogStreamFeature.ts index 7cbf3c3f..6ad0cfb1 100644 --- a/src/features/member-log-stream/main/composition/createMemberLogStreamFeature.ts +++ b/src/features/member-log-stream/main/composition/createMemberLogStreamFeature.ts @@ -2,17 +2,25 @@ import { BoardTaskExactLogChunkBuilder } from '@main/services/team/taskLogs/exac import { BoardTaskExactLogStrictParser } from '@main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser'; import { TeamConfigReader } from '@main/services/team/TeamConfigReader'; -import { createEmptyMemberLogStreamResponse } from '../../contracts'; +import { + createEmptyMemberLogPreviewResponse, + createEmptyMemberLogStreamResponse, +} from '../../contracts'; +import { GetMemberLogPreviewsUseCase } from '../../core/application/use-cases/GetMemberLogPreviewsUseCase'; import { GetMemberLogStreamUseCase } from '../../core/application/use-cases/GetMemberLogStreamUseCase'; import { SetMemberLogStreamTrackingUseCase } from '../../core/application/use-cases/SetMemberLogStreamTrackingUseCase'; +import { ClaudeMemberTranscriptPreviewSource } from '../adapters/output/sources/ClaudeMemberTranscriptPreviewSource'; import { ClaudeMemberTranscriptStreamSource } from '../adapters/output/sources/ClaudeMemberTranscriptStreamSource'; +import { CodexNativeMemberTracePreviewSource } from '../adapters/output/sources/CodexNativeMemberTracePreviewSource'; import { CodexNativeMemberTraceStreamSource } from '../adapters/output/sources/CodexNativeMemberTraceStreamSource'; +import { OpenCodeMemberRuntimePreviewSource } from '../adapters/output/sources/OpenCodeMemberRuntimePreviewSource'; import { OpenCodeMemberRuntimeStreamSource } from '../adapters/output/sources/OpenCodeMemberRuntimeStreamSource'; import { isMemberLogStreamReadEnabled } from '../featureGates'; -import type { MemberLogStreamResponse } from '../../contracts'; +import type { MemberLogPreviewResponse, MemberLogStreamResponse } from '../../contracts'; import type { LoggerPort } from '../../core/application/ports/LoggerPort'; import type { MemberLogStreamTrackingPort } from '../../core/application/ports/MemberLogStreamTrackingPort'; +import type { GetMemberLogPreviewsInput } from '../../core/application/use-cases/GetMemberLogPreviewsUseCase'; import type { GetMemberLogStreamInput } from '../../core/application/use-cases/GetMemberLogStreamUseCase'; import type { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService'; import type { TeamLogSourceTracker } from '@main/services/team/TeamLogSourceTracker'; @@ -20,6 +28,7 @@ import type { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFin export interface MemberLogStreamFeatureFacade { getMemberLogStream(input: GetMemberLogStreamInput): Promise; + getMemberLogPreviews(input: GetMemberLogPreviewsInput): Promise; setMemberLogStreamTracking(teamName: string, enabled: boolean): Promise; } @@ -43,21 +52,33 @@ export function createMemberLogStreamFeature(deps: { logger: LoggerPort; }): MemberLogStreamFeatureFacade { const chunkBuilder = new BoardTaskExactLogChunkBuilder(); + const strictParser = new BoardTaskExactLogStrictParser(); + const configReader = deps.configReader ?? new TeamConfigReader(); const sources = [ new ClaudeMemberTranscriptStreamSource( deps.logsFinder, - new BoardTaskExactLogStrictParser(), + strictParser, chunkBuilder, deps.logger ), new OpenCodeMemberRuntimeStreamSource(deps.runtimeBridge, chunkBuilder), - new CodexNativeMemberTraceStreamSource(deps.configReader ?? new TeamConfigReader()), + new CodexNativeMemberTraceStreamSource(configReader), + ]; + const previewSources = [ + new ClaudeMemberTranscriptPreviewSource(deps.logsFinder, strictParser, deps.logger), + new OpenCodeMemberRuntimePreviewSource(deps.runtimeBridge), + new CodexNativeMemberTracePreviewSource(configReader), ]; const getUseCase = new GetMemberLogStreamUseCase({ sources, clock: { now: () => Date.now() }, logger: deps.logger, }); + const getPreviewsUseCase = new GetMemberLogPreviewsUseCase({ + sources: previewSources, + clock: { now: () => Date.now() }, + logger: deps.logger, + }); const trackingUseCase = new SetMemberLogStreamTrackingUseCase( new TeamLogSourceTrackerMemberStreamPort(deps.logSourceTracker) ); @@ -69,7 +90,12 @@ export function createMemberLogStreamFeature(deps: { } return getUseCase.execute(input); }, - setMemberLogStreamTracking: (teamName, enabled) => - trackingUseCase.execute(teamName, enabled), + getMemberLogPreviews: async (input) => { + if (!isMemberLogStreamReadEnabled()) { + return createEmptyMemberLogPreviewResponse(); + } + return getPreviewsUseCase.execute(input); + }, + setMemberLogStreamTracking: (teamName, enabled) => trackingUseCase.execute(teamName, enabled), }; } diff --git a/src/features/member-log-stream/preload/__tests__/createMemberLogStreamBridge.test.ts b/src/features/member-log-stream/preload/__tests__/createMemberLogStreamBridge.test.ts index d6d955f1..50a7e489 100644 --- a/src/features/member-log-stream/preload/__tests__/createMemberLogStreamBridge.test.ts +++ b/src/features/member-log-stream/preload/__tests__/createMemberLogStreamBridge.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MEMBER_LOG_STREAM_GET, + MEMBER_LOG_STREAM_GET_PREVIEWS, MEMBER_LOG_STREAM_SET_TRACKING, } from '../../contracts'; import { createMemberLogStreamBridge } from '../createMemberLogStreamBridge'; @@ -79,4 +80,46 @@ describe('createMemberLogStreamBridge', () => { true ); }); + + it('forwards batch member log preview IPC requests and normalizes response payloads', async () => { + mocks.ipcRenderer.invoke.mockResolvedValueOnce({ + success: true, + data: { + members: [ + { + memberName: 'alice', + items: [], + generatedAt: '2026-04-02T00:00:00.000Z', + }, + ], + generatedAt: '2026-04-02T00:00:00.000Z', + }, + }); + const bridge = createMemberLogStreamBridge(); + + const response = await bridge.getMemberLogPreviews('alpha-team', ['alice'], { + maxItemsPerMember: 3, + textLimit: 200, + laneIdsByMember: { alice: 'secondary:opencode:alice' }, + }); + + expect(response.members[0]).toMatchObject({ + memberName: 'alice', + items: [], + coverage: [], + warnings: [], + truncated: false, + overflowCount: 0, + }); + expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith( + MEMBER_LOG_STREAM_GET_PREVIEWS, + 'alpha-team', + ['alice'], + { + maxItemsPerMember: 3, + textLimit: 200, + laneIdsByMember: { alice: 'secondary:opencode:alice' }, + } + ); + }); }); diff --git a/src/features/member-log-stream/preload/createMemberLogStreamBridge.ts b/src/features/member-log-stream/preload/createMemberLogStreamBridge.ts index fa971599..2c988513 100644 --- a/src/features/member-log-stream/preload/createMemberLogStreamBridge.ts +++ b/src/features/member-log-stream/preload/createMemberLogStreamBridge.ts @@ -2,11 +2,15 @@ import { ipcRenderer } from 'electron'; import { MEMBER_LOG_STREAM_GET, + MEMBER_LOG_STREAM_GET_PREVIEWS, MEMBER_LOG_STREAM_SET_TRACKING, + normalizeMemberLogPreviewResponse, normalizeMemberLogStreamResponse, } from '../contracts'; import type { + MemberLogPreviewRequestOptions, + MemberLogPreviewResponse, MemberLogStreamApi, MemberLogStreamRequestOptions, MemberLogStreamResponse, @@ -36,6 +40,19 @@ export function createMemberLogStreamBridge(): MemberLogStreamApi { options ) ), + getMemberLogPreviews: async ( + teamName: string, + memberNames: string[], + options?: MemberLogPreviewRequestOptions + ): Promise => + normalizeMemberLogPreviewResponse( + await invokeIpcWithResult( + MEMBER_LOG_STREAM_GET_PREVIEWS, + teamName, + memberNames, + options + ) + ), setMemberLogStreamTracking: (teamName: string, enabled: boolean): Promise => invokeIpcWithResult(MEMBER_LOG_STREAM_SET_TRACKING, teamName, enabled), }; diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index f219c606..d1c8f753 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -6,7 +6,10 @@ * to run in a regular browser connected to an HTTP server. */ -import { createEmptyMemberLogStreamResponse } from '@features/member-log-stream/contracts'; +import { + createEmptyMemberLogPreviewResponse, + createEmptyMemberLogStreamResponse, +} from '@features/member-log-stream/contracts'; import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; import type { MemberLogStreamApi } from '@features/member-log-stream/contracts'; @@ -259,6 +262,10 @@ export class HttpAPIClient implements ElectronAPI { console.warn('[HttpAPIClient] getMemberLogStream is not available in browser mode'); return createEmptyMemberLogStreamResponse(); }, + getMemberLogPreviews: async () => { + console.warn('[HttpAPIClient] getMemberLogPreviews is not available in browser mode'); + return createEmptyMemberLogPreviewResponse(); + }, setMemberLogStreamTracking: async () => { // Not available in browser mode - no-op. }, diff --git a/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx b/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx new file mode 100644 index 00000000..c75c49a1 --- /dev/null +++ b/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx @@ -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( + ({ + 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( + ({ + 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(); + }); + }); +}); diff --git a/test/renderer/features/agent-graph/useGraphMemberLogPreviews.test.tsx b/test/renderer/features/agent-graph/useGraphMemberLogPreviews.test.tsx new file mode 100644 index 00000000..66aefaeb --- /dev/null +++ b/test/renderer/features/agent-graph/useGraphMemberLogPreviews.test.tsx @@ -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(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((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; + enabled?: boolean; + onState: (state: ReturnType) => 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( + 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(); + const bobLoad = createDeferred(); + 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[] = []; + const onState = vi.fn((state: ReturnType) => { + states.push(state); + }); + const latestState = (): ReturnType | undefined => + states.at(-1); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); + + await act(async () => { + root.render(); + 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(); + 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[] = []; + const onState = vi.fn((state: ReturnType) => { + states.push(state); + }); + const latestState = (): ReturnType | undefined => + states.at(-1); + + await act(async () => { + root.render(); + 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(); + await Promise.resolve(); + }); + expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice'); + + await act(async () => { + root.render(); + 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( + 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(); + }); + }); +}); diff --git a/test/renderer/features/agent-graph/useGraphSimulation.test.ts b/test/renderer/features/agent-graph/useGraphSimulation.test.ts index 99a012e9..e9dca0aa 100644 --- a/test/renderer/features/agent-graph/useGraphSimulation.test.ts +++ b/test/renderer/features/agent-graph/useGraphSimulation.test.ts @@ -149,7 +149,7 @@ describe('stable slot layout planner', () => { expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true }); }); - it('builds a board band that contains both the activity column and kanban band', () => { + it('builds a board band that contains activity, logs, and kanban without overlap', () => { const teamName = 'team-process-width'; const lead = createLead(teamName); const alice = createMember(teamName, 'agent-alice', 'alice'); @@ -170,9 +170,14 @@ describe('stable slot layout planner', () => { const frame = snapshot?.memberSlotFrames[0]; expect(frame).toBeDefined(); expect(frame?.boardBandRect.top).toBe(frame?.activityColumnRect.top); + expect(frame?.boardBandRect.top).toBe(frame?.logColumnRect.top); expect(frame?.boardBandRect.top).toBe(frame?.kanbanBandRect.top); expect(frame?.activityColumnRect.left).toBe(frame?.boardBandRect.left); - expect(frame?.kanbanBandRect.left).toBeGreaterThan(frame?.activityColumnRect.right ?? 0); + expect(frame?.logColumnRect.left).toBeGreaterThan(frame?.activityColumnRect.right ?? 0); + expect(frame?.kanbanBandRect.left).toBeGreaterThan(frame?.logColumnRect.right ?? 0); + expect(rectsOverlap(frame!.activityColumnRect, frame!.logColumnRect)).toBe(false); + expect(rectsOverlap(frame!.logColumnRect, frame!.kanbanBandRect)).toBe(false); + expect(rectsOverlap(frame!.logColumnRect, frame!.processBandRect)).toBe(false); expect(frame?.processBandRect.width).toBe(computeProcessBandWidth(0)); expect(frame?.processBandRect.height).toBe(STABLE_SLOT_GEOMETRY.processBandHeight); }); @@ -346,6 +351,7 @@ describe('stable slot layout planner', () => { expect(footprint).toBeDefined(); expect(footprint?.activityColumnWidth).toBe(ACTIVITY_LANE.width); + expect(footprint?.logColumnWidth).toBe(260); expect(footprint?.activityColumnHeight).toBe( ACTIVITY_LANE.headerHeight + ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight + @@ -381,11 +387,15 @@ describe('stable slot layout planner', () => { expect(footprint).toBeDefined(); expect(footprint?.activityColumnWidth).toBe(0); expect(footprint?.activityColumnHeight).toBe(0); + expect(footprint?.logColumnWidth).toBe(0); + expect(footprint?.logColumnHeight).toBe(0); expect(footprint?.boardBandWidth).toBe(footprint?.kanbanBandWidth); expect(snapshot).not.toBeNull(); expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true }); expect(frame?.activityColumnRect.width).toBe(0); expect(frame?.activityColumnRect.height).toBe(0); + expect(frame?.logColumnRect.width).toBe(0); + expect(frame?.logColumnRect.height).toBe(0); expect(frame?.kanbanBandRect.left).toBe(frame?.boardBandRect.left); }); @@ -1072,6 +1082,7 @@ describe('stable slot layout planner', () => { expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadCoreRect); expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.processBandRect); expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.activityColumnRect); + expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.logColumnRect); expect(snapshot!.centralCollisionRects).toContain(snapshot!.leadSlotFrame.kanbanBandRect); expect(snapshot!.leadCentralReservedBlock.width).toBeLessThan( snapshot!.leadSlotFrame.bounds.width