Merge branch 'dev' into spike/team-snapshot-split-plan
This commit is contained in:
commit
2fd06fcd48
48 changed files with 4007 additions and 249 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -3,7 +3,10 @@ import { HANDOFF_CARD, NODE, TASK_PILL, MIN_VISIBLE_OPACITY } from '../constants
|
|||
import type { CameraTransform } from '../hooks/useGraphCamera';
|
||||
import { getHandoffAnchorTarget } from '../layout/launchAnchor';
|
||||
import type { GraphNode } from '../ports/types';
|
||||
import type { TransientHandoffCard } from '../ui/transientHandoffs';
|
||||
import {
|
||||
getTransientHandoffCardAlpha,
|
||||
type TransientHandoffCard,
|
||||
} from '../ui/transientHandoffs';
|
||||
import { truncateText } from './draw-misc';
|
||||
import { hexWithAlpha, measureTextCached } from './render-cache';
|
||||
|
||||
|
|
@ -20,24 +23,24 @@ export function drawHandoffCards(
|
|||
const { cards, nodeMap, time, camera, viewport } = params;
|
||||
if (cards.length === 0) return;
|
||||
|
||||
const stackIndexByDestination = new Map<string, number>();
|
||||
const stackIndexByAnchor = new Map<string, number>();
|
||||
let drawnCount = 0;
|
||||
|
||||
for (const card of cards) {
|
||||
if (drawnCount >= HANDOFF_CARD.maxVisible) break;
|
||||
const destinationNode = nodeMap.get(card.destinationNodeId);
|
||||
if (!destinationNode || destinationNode.x == null || destinationNode.y == null) continue;
|
||||
const anchorNode = nodeMap.get(card.anchorNodeId);
|
||||
if (!anchorNode || anchorNode.x == null || anchorNode.y == null) continue;
|
||||
|
||||
const alpha = getCardAlpha(card, time);
|
||||
const alpha = getTransientHandoffCardAlpha(card, time);
|
||||
if (alpha <= MIN_VISIBLE_OPACITY) continue;
|
||||
|
||||
const previewLines = buildPreviewLines(ctx, card.preview);
|
||||
const height = HANDOFF_CARD.baseHeight + previewLines.length * HANDOFF_CARD.previewLineHeight;
|
||||
const stackIndex = stackIndexByDestination.get(card.destinationNodeId) ?? 0;
|
||||
stackIndexByDestination.set(card.destinationNodeId, stackIndex + 1);
|
||||
const stackIndex = stackIndexByAnchor.get(card.anchorNodeId) ?? 0;
|
||||
stackIndexByAnchor.set(card.anchorNodeId, stackIndex + 1);
|
||||
|
||||
const position = getCardPosition({
|
||||
node: destinationNode,
|
||||
node: anchorNode,
|
||||
camera,
|
||||
viewport,
|
||||
height,
|
||||
|
|
@ -59,15 +62,6 @@ export function drawHandoffCards(
|
|||
}
|
||||
}
|
||||
|
||||
function getCardAlpha(card: TransientHandoffCard, time: number): number {
|
||||
const fadeIn = Math.min(1, (time - card.activatedAt) / HANDOFF_CARD.fadeInSeconds);
|
||||
const fadeOutRemaining = card.expiresAt - time;
|
||||
const fadeOut = fadeOutRemaining <= HANDOFF_CARD.fadeOutSeconds
|
||||
? Math.max(0, fadeOutRemaining / HANDOFF_CARD.fadeOutSeconds)
|
||||
: 1;
|
||||
return Math.max(0, Math.min(1, fadeIn * fadeOut));
|
||||
}
|
||||
|
||||
function getCardPosition(params: {
|
||||
node: GraphNode;
|
||||
camera: CameraTransform;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@
|
|||
export { GraphView } from './ui/GraphView';
|
||||
export type { GraphViewProps } from './ui/GraphView';
|
||||
export { ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_LANE } from './layout/activityLane';
|
||||
export { getTransientHandoffCardAlpha } from './ui/transientHandoffs';
|
||||
export type { TransientHandoffCard } from './ui/transientHandoffs';
|
||||
|
||||
// ─── Port Interfaces (for adapters in host project) ─────────────────────────
|
||||
export type { GraphDataPort } from './ports/GraphDataPort';
|
||||
|
|
|
|||
|
|
@ -173,12 +173,16 @@ export function buildStableSlotLayoutSnapshot({
|
|||
);
|
||||
const leadActivityRect = leadSlotFrame.activityColumnRect;
|
||||
const launchHudRect = createRect(leadCoreRect.right, leadCoreRect.top, 0, 0);
|
||||
const leadCentralReservedBlock = leadSlotFrame.bounds;
|
||||
const leadCentralReservedBlock = buildLeadCentralReservedBlock({
|
||||
leadCoreRect,
|
||||
leadSlotFrame,
|
||||
});
|
||||
|
||||
const ownerFootprints = computeOwnerFootprints(nodes, layout);
|
||||
const unassignedTaskRect = buildUnassignedTaskRect(nodes, leadCentralReservedBlock);
|
||||
const centralCollisionRects = buildCentralCollisionRects({
|
||||
leadCentralReservedBlock,
|
||||
leadCoreRect,
|
||||
leadSlotFrame,
|
||||
unassignedTaskRect,
|
||||
});
|
||||
const runtimeCentralExclusion = padRect(
|
||||
|
|
@ -222,16 +226,34 @@ export function buildStableSlotLayoutSnapshot({
|
|||
}
|
||||
|
||||
function buildCentralCollisionRects(args: {
|
||||
leadCentralReservedBlock: StableRect;
|
||||
leadCoreRect: StableRect;
|
||||
leadSlotFrame: SlotFrame;
|
||||
unassignedTaskRect: StableRect | null;
|
||||
}): StableRect[] {
|
||||
const rects = [args.leadCentralReservedBlock];
|
||||
const rects = [
|
||||
args.leadCoreRect,
|
||||
args.leadSlotFrame.processBandRect,
|
||||
args.leadSlotFrame.activityColumnRect,
|
||||
args.leadSlotFrame.kanbanBandRect,
|
||||
];
|
||||
if (args.unassignedTaskRect) {
|
||||
rects.push(args.unassignedTaskRect);
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
function buildLeadCentralReservedBlock(args: {
|
||||
leadCoreRect: StableRect;
|
||||
leadSlotFrame: SlotFrame;
|
||||
}): StableRect {
|
||||
return unionRects([
|
||||
args.leadCoreRect,
|
||||
args.leadSlotFrame.processBandRect,
|
||||
args.leadSlotFrame.activityColumnRect,
|
||||
args.leadSlotFrame.kanbanBandRect,
|
||||
]);
|
||||
}
|
||||
|
||||
function padCentralCollisionRects(
|
||||
rects: readonly StableRect[],
|
||||
padding: number
|
||||
|
|
@ -648,6 +670,12 @@ function validateLeadSnapshotRects(
|
|||
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) {
|
||||
return { valid: false, reason: 'leadActivityRect must fit inside leadCentralReservedBlock' };
|
||||
}
|
||||
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.processBandRect)) {
|
||||
return { valid: false, reason: 'lead processBandRect must fit inside leadCentralReservedBlock' };
|
||||
}
|
||||
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.kanbanBandRect)) {
|
||||
return { valid: false, reason: 'lead kanbanBandRect must fit inside leadCentralReservedBlock' };
|
||||
}
|
||||
if (snapshot.leadActivityRect.left !== snapshot.leadSlotFrame.activityColumnRect.left) {
|
||||
return {
|
||||
valid: false,
|
||||
|
|
@ -660,9 +688,6 @@ function validateLeadSnapshotRects(
|
|||
reason: 'leadActivityRect must mirror leadSlotFrame.activityColumnRect',
|
||||
};
|
||||
}
|
||||
if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.bounds)) {
|
||||
return { valid: false, reason: 'leadSlotFrame must fit inside leadCentralReservedBlock' };
|
||||
}
|
||||
if (!rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.leadCentralReservedBlock)) {
|
||||
return { valid: false, reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
import {
|
||||
createTransientHandoffState,
|
||||
selectRenderableTransientHandoffCards,
|
||||
type TransientHandoffCard,
|
||||
updateTransientHandoffState,
|
||||
} from './transientHandoffs';
|
||||
import type { CameraTransform } from '../hooks/useGraphCamera';
|
||||
|
|
@ -70,6 +71,14 @@ export interface GraphCanvasHandle {
|
|||
draw: (state: GraphDrawState) => void;
|
||||
/** Get the canvas element for coordinate transforms */
|
||||
getCanvas: () => HTMLCanvasElement | null;
|
||||
/** Read current transient handoff cards for DOM HUD rendering */
|
||||
getTransientHandoffSnapshot: (options?: {
|
||||
focusNodeIds?: ReadonlySet<string> | null;
|
||||
focusEdgeIds?: ReadonlySet<string> | null;
|
||||
}) => {
|
||||
cards: TransientHandoffCard[];
|
||||
time: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GraphCanvasProps {
|
||||
|
|
@ -163,6 +172,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
|||
const activeParticleEdgesCache = useRef(new Set<string>());
|
||||
const handoffStateRef = useRef(createTransientHandoffState());
|
||||
const lastTeamNameRef = useRef<string | null>(null);
|
||||
const lastDrawTimeRef = useRef(0);
|
||||
|
||||
// Imperative draw function — called from RAF, NOT from React render
|
||||
useImperativeHandle(
|
||||
|
|
@ -181,6 +191,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
|||
if (w === 0 || h === 0) return;
|
||||
|
||||
try {
|
||||
lastDrawTimeRef.current = state.time;
|
||||
if (lastTeamNameRef.current !== state.teamName) {
|
||||
handoffStateRef.current = createTransientHandoffState();
|
||||
lastTeamNameRef.current = state.teamName;
|
||||
|
|
@ -309,9 +320,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
|||
focusNodeIds: state.focusNodeIds,
|
||||
focusEdgeIds: prioritizedEdgeIds ?? state.focusEdgeIds,
|
||||
}
|
||||
).filter(
|
||||
(card) => card.destinationKind !== 'lead' && card.destinationKind !== 'member'
|
||||
);
|
||||
).filter((card) => card.anchorKind !== 'lead' && card.anchorKind !== 'member');
|
||||
drawParticles(ctx, renderableParticles, edgeMap, nodeMap, state.time, prioritizedEdgeIds);
|
||||
|
||||
// 2c. Visible nodes only (back to front: process → task → member/lead)
|
||||
|
|
@ -419,6 +428,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
|||
}
|
||||
},
|
||||
getCanvas: () => canvasRef.current,
|
||||
getTransientHandoffSnapshot: (options) => ({
|
||||
cards: selectRenderableTransientHandoffCards(handoffStateRef.current, options),
|
||||
time: lastDrawTimeRef.current,
|
||||
}),
|
||||
}),
|
||||
[showHexGrid, showStarField, bloomIntensity]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { GraphControls, type GraphFilterState } from './GraphControls';
|
|||
import { GraphOverlay } from './GraphOverlay';
|
||||
import { GraphEdgeOverlay } from './GraphEdgeOverlay';
|
||||
import { buildFocusState } from './buildFocusState';
|
||||
import type { TransientHandoffCard } from './transientHandoffs';
|
||||
import { useGraphSimulation } from '../hooks/useGraphSimulation';
|
||||
import { useGraphCamera } from '../hooks/useGraphCamera';
|
||||
import { useGraphInteraction } from '../hooks/useGraphInteraction';
|
||||
|
|
@ -73,11 +74,16 @@ export interface GraphViewProps {
|
|||
leadNodeId: string,
|
||||
) => { x: number; y: number; scale: number; visible: boolean } | null;
|
||||
getActivityWorldRect: (ownerNodeId: string) => StableRect | null;
|
||||
getTransientHandoffSnapshot: (options?: {
|
||||
focusNodeIds?: ReadonlySet<string> | null;
|
||||
focusEdgeIds?: ReadonlySet<string> | null;
|
||||
}) => { cards: TransientHandoffCard[]; time: number };
|
||||
getCameraZoom: () => number;
|
||||
worldToScreen: (x: number, y: number) => { x: number; y: number };
|
||||
getNodeWorldPosition: (nodeId: string) => { x: number; y: number } | null;
|
||||
getViewportSize: () => { width: number; height: number };
|
||||
focusNodeIds: ReadonlySet<string> | null;
|
||||
focusEdgeIds: ReadonlySet<string> | null;
|
||||
}) => React.ReactNode;
|
||||
}
|
||||
|
||||
|
|
@ -250,6 +256,17 @@ export function GraphView({
|
|||
(ownerNodeId: string) => simulationRef.current.getActivityWorldRect(ownerNodeId),
|
||||
[]
|
||||
);
|
||||
const getTransientHandoffSnapshot = useCallback(
|
||||
(options?: {
|
||||
focusNodeIds?: ReadonlySet<string> | null;
|
||||
focusEdgeIds?: ReadonlySet<string> | null;
|
||||
}) =>
|
||||
canvasHandle.current?.getTransientHandoffSnapshot(options) ?? {
|
||||
cards: [],
|
||||
time: 0,
|
||||
},
|
||||
[]
|
||||
);
|
||||
const getNodeWorldPosition = useCallback((nodeId: string) => {
|
||||
const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId);
|
||||
if (node?.x == null || node?.y == null) {
|
||||
|
|
@ -946,11 +963,13 @@ export function GraphView({
|
|||
{renderHud({
|
||||
getLaunchAnchorScreenPlacement,
|
||||
getActivityWorldRect,
|
||||
getTransientHandoffSnapshot,
|
||||
getCameraZoom,
|
||||
worldToScreen: camera.worldToScreen,
|
||||
getNodeWorldPosition,
|
||||
getViewportSize,
|
||||
focusNodeIds: focusState.focusNodeIds,
|
||||
focusEdgeIds: focusState.focusEdgeIds,
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,16 @@ export interface TransientHandoffCard {
|
|||
edgeId: string;
|
||||
sourceNodeId: string;
|
||||
destinationNodeId: string;
|
||||
anchorNodeId: string;
|
||||
anchorKind: GraphNode['kind'];
|
||||
sourceLabel: string;
|
||||
destinationLabel: string;
|
||||
destinationKind: GraphNode['kind'];
|
||||
kind: HandoffParticleKind;
|
||||
color: string;
|
||||
preview?: string;
|
||||
relatedTaskId?: string;
|
||||
relatedTaskDisplayId?: string;
|
||||
count: number;
|
||||
activatedAt: number;
|
||||
updatedAt: number;
|
||||
|
|
@ -70,6 +74,12 @@ export function updateTransientHandoffState(
|
|||
const sourceNode = nodeMap.get(sourceNodeId);
|
||||
const destinationNode = nodeMap.get(destinationNodeId);
|
||||
if (!sourceNode || !destinationNode) continue;
|
||||
const anchorNode =
|
||||
destinationNode.kind === 'lead' || destinationNode.kind === 'member'
|
||||
? destinationNode
|
||||
: sourceNode.kind === 'lead' || sourceNode.kind === 'member'
|
||||
? sourceNode
|
||||
: destinationNode;
|
||||
|
||||
const previewText = normalizePreviewText(particle.preview ?? particle.label);
|
||||
if (particle.kind === 'inbox_message' && isLowSignalInboxPreview(previewText)) {
|
||||
|
|
@ -86,12 +96,16 @@ export function updateTransientHandoffState(
|
|||
edgeId: edge.id,
|
||||
sourceNodeId,
|
||||
destinationNodeId,
|
||||
anchorNodeId: anchorNode.id,
|
||||
anchorKind: anchorNode.kind,
|
||||
sourceLabel: sourceNode.label,
|
||||
destinationLabel: destinationNode.label,
|
||||
destinationKind: destinationNode.kind,
|
||||
kind: particle.kind,
|
||||
color: particle.color,
|
||||
preview: previewText ?? existing?.preview,
|
||||
relatedTaskId: edge.sourceTaskIds?.[0] ?? edge.targetTaskIds?.[0],
|
||||
relatedTaskDisplayId: buildTaskDisplayId(edge.sourceTaskIds?.[0] ?? edge.targetTaskIds?.[0]),
|
||||
count: nextCount,
|
||||
activatedAt: existing?.activatedAt ?? time,
|
||||
updatedAt: time,
|
||||
|
|
@ -112,19 +126,19 @@ export function selectRenderableTransientHandoffCards(
|
|||
const focusEdgeIds = options?.focusEdgeIds ?? null;
|
||||
const hasFocus = (focusNodeIds?.size ?? 0) > 0 || (focusEdgeIds?.size ?? 0) > 0;
|
||||
|
||||
const byDestination = new Map<string, TransientHandoffCard[]>();
|
||||
const byAnchor = new Map<string, TransientHandoffCard[]>();
|
||||
for (const card of state.cardsByKey.values()) {
|
||||
if (hasFocus && !isCardInFocus(card, focusNodeIds, focusEdgeIds)) continue;
|
||||
const destinationCards = byDestination.get(card.destinationNodeId);
|
||||
if (destinationCards) {
|
||||
destinationCards.push(card);
|
||||
const anchorCards = byAnchor.get(card.anchorNodeId);
|
||||
if (anchorCards) {
|
||||
anchorCards.push(card);
|
||||
} else {
|
||||
byDestination.set(card.destinationNodeId, [card]);
|
||||
byAnchor.set(card.anchorNodeId, [card]);
|
||||
}
|
||||
}
|
||||
|
||||
const selected: TransientHandoffCard[] = [];
|
||||
for (const cards of byDestination.values()) {
|
||||
for (const cards of byAnchor.values()) {
|
||||
cards.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
selected.push(...cards.slice(0, HANDOFF_CARD.maxPerDestination));
|
||||
}
|
||||
|
|
@ -145,10 +159,21 @@ function isCardInFocus(
|
|||
return (
|
||||
!!focusEdgeIds?.has(card.edgeId) ||
|
||||
!!focusNodeIds?.has(card.sourceNodeId) ||
|
||||
!!focusNodeIds?.has(card.destinationNodeId)
|
||||
!!focusNodeIds?.has(card.destinationNodeId) ||
|
||||
!!focusNodeIds?.has(card.anchorNodeId)
|
||||
);
|
||||
}
|
||||
|
||||
export function getTransientHandoffCardAlpha(card: TransientHandoffCard, time: number): number {
|
||||
const fadeIn = Math.min(1, (time - card.activatedAt) / HANDOFF_CARD.fadeInSeconds);
|
||||
const fadeOutRemaining = card.expiresAt - time;
|
||||
const fadeOut =
|
||||
fadeOutRemaining <= HANDOFF_CARD.fadeOutSeconds
|
||||
? Math.max(0, fadeOutRemaining / HANDOFF_CARD.fadeOutSeconds)
|
||||
: 1;
|
||||
return Math.max(0, Math.min(1, fadeIn * fadeOut));
|
||||
}
|
||||
|
||||
function normalizePreviewText(text: string | undefined): string | undefined {
|
||||
if (!text) return undefined;
|
||||
const normalized = text
|
||||
|
|
@ -161,3 +186,10 @@ function normalizePreviewText(text: string | undefined): string | undefined {
|
|||
function isLowSignalInboxPreview(preview: string | undefined): boolean {
|
||||
return preview === 'idle';
|
||||
}
|
||||
|
||||
function buildTaskDisplayId(taskId: string | undefined): string | undefined {
|
||||
if (!taskId) {
|
||||
return undefined;
|
||||
}
|
||||
return taskId.slice(0, 8);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -181,7 +181,16 @@ export class TeamGraphAdapter {
|
|||
isTeamProvisioning,
|
||||
isLaunchSettling
|
||||
);
|
||||
this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState, memberNodeIdByAlias);
|
||||
this.#buildTaskNodes(
|
||||
nodes,
|
||||
edges,
|
||||
teamData,
|
||||
teamName,
|
||||
commentReadState,
|
||||
memberNodeIdByAlias,
|
||||
leadId,
|
||||
leadName
|
||||
);
|
||||
this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByAlias);
|
||||
this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName);
|
||||
this.#buildMessageParticles(
|
||||
|
|
@ -560,7 +569,9 @@ export class TeamGraphAdapter {
|
|||
data: TeamGraphData,
|
||||
teamName: string,
|
||||
commentReadState?: Record<string, unknown>,
|
||||
memberNodeIdByAlias?: ReadonlyMap<string, string>
|
||||
memberNodeIdByAlias?: ReadonlyMap<string, string>,
|
||||
leadId?: string,
|
||||
leadName?: string
|
||||
): void {
|
||||
const taskStateById = new Map<string, Pick<TeamGraphData['tasks'][number], 'status'>>();
|
||||
const taskDisplayIds = new Map<string, string>();
|
||||
|
|
@ -581,7 +592,12 @@ export class TeamGraphAdapter {
|
|||
for (const task of data.tasks) {
|
||||
if (task.status === 'deleted') continue;
|
||||
const taskId = `task:${teamName}:${task.id}`;
|
||||
const ownerMemberId = task.owner ? (memberNodeIdByAlias?.get(task.owner) ?? null) : null;
|
||||
const ownerMemberId =
|
||||
leadId && memberNodeIdByAlias
|
||||
? TeamGraphAdapter.#resolveTaskOwnerId(task.owner, leadId, leadName, memberNodeIdByAlias)
|
||||
: task.owner
|
||||
? (memberNodeIdByAlias?.get(task.owner) ?? null)
|
||||
: null;
|
||||
const kanbanTaskState = data.kanbanState.tasks[task.id];
|
||||
const reviewerName = resolveTaskReviewer(task, kanbanTaskState);
|
||||
const isReviewCycle = isTaskInReviewCycle(task);
|
||||
|
|
@ -1237,6 +1253,25 @@ export class TeamGraphAdapter {
|
|||
return memberNodeIdByAlias.get(name) ?? leadId;
|
||||
}
|
||||
|
||||
static #resolveTaskOwnerId(
|
||||
ownerName: string | null | undefined,
|
||||
leadId: string,
|
||||
leadName: string | undefined,
|
||||
memberNodeIdByAlias: ReadonlyMap<string, string>
|
||||
): string | null {
|
||||
if (!ownerName?.trim()) {
|
||||
return null;
|
||||
}
|
||||
const normalized = ownerName.trim().toLowerCase();
|
||||
if (normalized === 'user' || normalized === 'team-lead') {
|
||||
return leadId;
|
||||
}
|
||||
if (normalized === leadName?.trim().toLowerCase()) {
|
||||
return leadId;
|
||||
}
|
||||
return memberNodeIdByAlias.get(ownerName) ?? null;
|
||||
}
|
||||
|
||||
/** Extract external team name from cross-team "from" field like "team-b.alice" */
|
||||
static #extractExternalTeamName(from: string): string | null {
|
||||
const dotIdx = from.indexOf('.');
|
||||
|
|
|
|||
96
src/features/agent-graph/renderer/ui/GraphActivityCard.tsx
Normal file
96
src/features/agent-graph/renderer/ui/GraphActivityCard.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
|
||||
import {
|
||||
resolveMessageRenderProps,
|
||||
type MessageContext,
|
||||
} from '@renderer/components/team/activity/activityMessageContext';
|
||||
|
||||
import type {
|
||||
MemberActivityFilter,
|
||||
MemberDetailTab,
|
||||
} from '@renderer/components/team/members/memberDetailTypes';
|
||||
import type { InboxMessage } from '@shared/types';
|
||||
|
||||
interface GraphActivityCardProps {
|
||||
message: InboxMessage;
|
||||
teamName: string;
|
||||
messageContext: MessageContext;
|
||||
teamNames: string[];
|
||||
teamColorByName: ReadonlyMap<string, string>;
|
||||
isUnread?: boolean;
|
||||
zebraShade?: boolean;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
onOpenTaskDetail?: (taskId: string) => void;
|
||||
onOpenMemberProfile?: (
|
||||
memberName: string,
|
||||
options?: {
|
||||
initialTab?: MemberDetailTab;
|
||||
initialActivityFilter?: MemberActivityFilter;
|
||||
}
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const GraphActivityCard = ({
|
||||
message,
|
||||
teamName,
|
||||
messageContext,
|
||||
teamNames,
|
||||
teamColorByName,
|
||||
isUnread = false,
|
||||
zebraShade = false,
|
||||
className,
|
||||
onClick,
|
||||
onOpenTaskDetail,
|
||||
onOpenMemberProfile,
|
||||
}: GraphActivityCardProps): React.JSX.Element => {
|
||||
const renderProps = resolveMessageRenderProps(message, messageContext);
|
||||
const interactive = Boolean(onClick);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'h-[72px] min-h-[72px] min-w-0 max-w-full overflow-hidden',
|
||||
interactive ? 'cursor-pointer' : '',
|
||||
className ?? '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
role={interactive ? 'button' : undefined}
|
||||
tabIndex={interactive ? 0 : undefined}
|
||||
onClick={onClick}
|
||||
onKeyDown={
|
||||
interactive
|
||||
? (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onClick?.();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<ActivityItem
|
||||
message={message}
|
||||
teamName={teamName}
|
||||
compactHeader
|
||||
collapseMode="managed"
|
||||
isCollapsed
|
||||
canToggleCollapse={false}
|
||||
isUnread={isUnread}
|
||||
memberRole={renderProps.memberRole}
|
||||
memberColor={renderProps.memberColor}
|
||||
recipientColor={renderProps.recipientColor}
|
||||
memberColorMap={messageContext.colorMap}
|
||||
localMemberNames={messageContext.localMemberNames}
|
||||
onMemberNameClick={(memberName) => onOpenMemberProfile?.(memberName)}
|
||||
onTaskIdClick={onOpenTaskDetail}
|
||||
zebraShade={zebraShade}
|
||||
teamNames={teamNames}
|
||||
teamColorByName={teamColorByName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,11 +1,7 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ACTIVITY_LANE } from '@claude-teams/agent-graph';
|
||||
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
|
||||
import {
|
||||
buildMessageContext,
|
||||
resolveMessageRenderProps,
|
||||
} from '@renderer/components/team/activity/activityMessageContext';
|
||||
import { buildMessageContext } from '@renderer/components/team/activity/activityMessageContext';
|
||||
import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog';
|
||||
import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta';
|
||||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||
|
|
@ -17,6 +13,7 @@ import {
|
|||
type InlineActivityEntry,
|
||||
} from '../../core/domain/buildInlineActivityEntries';
|
||||
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
|
||||
import { GraphActivityCard } from './GraphActivityCard';
|
||||
|
||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||
import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup';
|
||||
|
|
@ -288,38 +285,10 @@ export const GraphActivityHud = ({
|
|||
visibleLanes,
|
||||
]);
|
||||
|
||||
const expandedItemsByKey = useMemo(() => {
|
||||
const items = new Map<string, TimelineItem>();
|
||||
for (const lane of visibleLanes) {
|
||||
for (const entry of lane.entries) {
|
||||
const key = toMessageKey(entry.message);
|
||||
items.set(key, { type: 'message', message: entry.message });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [visibleLanes]);
|
||||
|
||||
const handleExpandItem = useCallback(
|
||||
(key: string) => {
|
||||
const next = expandedItemsByKey.get(key);
|
||||
if (next) {
|
||||
setExpandedItem(next);
|
||||
}
|
||||
},
|
||||
[expandedItemsByKey]
|
||||
);
|
||||
|
||||
const handleMessageClick = useCallback((item: TimelineItem) => {
|
||||
setExpandedItem(item);
|
||||
}, []);
|
||||
|
||||
const handleMemberNameClick = useCallback(
|
||||
(memberName: string) => {
|
||||
onOpenMemberProfile?.(memberName);
|
||||
},
|
||||
[onOpenMemberProfile]
|
||||
);
|
||||
|
||||
const handleMemberClick = useCallback(
|
||||
(member: ResolvedTeamMember) => {
|
||||
onOpenMemberProfile?.(member.name);
|
||||
|
|
@ -453,10 +422,6 @@ export const GraphActivityHud = ({
|
|||
) : null}
|
||||
{lane.entries.map((entry, index) => {
|
||||
const messageKey = toMessageKey(entry.message);
|
||||
const renderProps = resolveMessageRenderProps(
|
||||
entry.message,
|
||||
messageContext
|
||||
);
|
||||
const timelineItem: TimelineItem = {
|
||||
type: 'message',
|
||||
message: entry.message,
|
||||
|
|
@ -477,26 +442,17 @@ export const GraphActivityHud = ({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<ActivityItem
|
||||
<GraphActivityCard
|
||||
message={entry.message}
|
||||
teamName={teamName}
|
||||
compactHeader
|
||||
collapseMode="managed"
|
||||
isCollapsed
|
||||
canToggleCollapse={false}
|
||||
isUnread={isUnread}
|
||||
expandItemKey={messageKey}
|
||||
onExpand={handleExpandItem}
|
||||
memberRole={renderProps.memberRole}
|
||||
memberColor={renderProps.memberColor}
|
||||
recipientColor={renderProps.recipientColor}
|
||||
memberColorMap={messageContext.colorMap}
|
||||
localMemberNames={messageContext.localMemberNames}
|
||||
onMemberNameClick={handleMemberNameClick}
|
||||
onTaskIdClick={onOpenTaskDetail}
|
||||
zebraShade={index % 2 === 1}
|
||||
messageContext={messageContext}
|
||||
teamNames={teamNames}
|
||||
teamColorByName={teamColorByName}
|
||||
isUnread={isUnread}
|
||||
zebraShade={index % 2 === 1}
|
||||
onClick={() => handleMessageClick(timelineItem)}
|
||||
onOpenTaskDetail={onOpenTaskDetail}
|
||||
onOpenMemberProfile={onOpenMemberProfile}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
ACTIVITY_LANE,
|
||||
getTransientHandoffCardAlpha,
|
||||
type TransientHandoffCard,
|
||||
} from '@claude-teams/agent-graph';
|
||||
import { buildMessageContext } from '@renderer/components/team/activity/activityMessageContext';
|
||||
import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta';
|
||||
|
||||
import { useGraphActivityContext } from '../hooks/useGraphActivityContext';
|
||||
|
||||
import { buildTransientHandoffMessage } from './buildTransientHandoffMessage';
|
||||
import { GraphActivityCard } from './GraphActivityCard';
|
||||
|
||||
interface GraphTransientHandoffHudProps {
|
||||
teamName: string;
|
||||
getTransientHandoffSnapshot?: (options?: {
|
||||
focusNodeIds?: ReadonlySet<string> | null;
|
||||
focusEdgeIds?: ReadonlySet<string> | null;
|
||||
}) => { cards: TransientHandoffCard[]; time: number };
|
||||
getCameraZoom?: () => number;
|
||||
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
||||
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
||||
focusNodeIds: ReadonlySet<string> | null;
|
||||
focusEdgeIds: ReadonlySet<string> | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const CARD_WIDTH = ACTIVITY_LANE.width;
|
||||
const CARD_HEIGHT = 72;
|
||||
const STACK_GAP = 10;
|
||||
|
||||
export const GraphTransientHandoffHud = ({
|
||||
teamName,
|
||||
getTransientHandoffSnapshot = () => ({ cards: [], time: 0 }),
|
||||
getCameraZoom = () => 1,
|
||||
worldToScreen,
|
||||
getNodeWorldPosition = () => null,
|
||||
focusNodeIds,
|
||||
focusEdgeIds,
|
||||
enabled = true,
|
||||
}: GraphTransientHandoffHudProps): React.JSX.Element | null => {
|
||||
const worldLayerRef = useRef<HTMLDivElement | null>(null);
|
||||
const shellRefs = useRef(new Map<string, HTMLDivElement | null>());
|
||||
const signatureRef = useRef('');
|
||||
const [cards, setCards] = useState<TransientHandoffCard[]>([]);
|
||||
const { teamData, teams } = useGraphActivityContext(teamName);
|
||||
const messageContext = useMemo(() => buildMessageContext(teamData?.members), [teamData?.members]);
|
||||
const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams);
|
||||
|
||||
useEffect(() => {
|
||||
signatureRef.current = '';
|
||||
setCards([]);
|
||||
}, [teamName]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled) {
|
||||
setCards([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let frameId = 0;
|
||||
const tick = (): void => {
|
||||
const snapshot = getTransientHandoffSnapshot({
|
||||
focusNodeIds,
|
||||
focusEdgeIds,
|
||||
});
|
||||
const nextCards = snapshot.cards.filter(
|
||||
(card) => card.anchorKind === 'lead' || card.anchorKind === 'member'
|
||||
);
|
||||
const nextSignature = nextCards
|
||||
.map((card) => `${card.key}:${card.count}:${card.updatedAt}:${card.anchorNodeId}`)
|
||||
.join('|');
|
||||
if (nextSignature !== signatureRef.current) {
|
||||
signatureRef.current = nextSignature;
|
||||
setCards(nextCards);
|
||||
}
|
||||
|
||||
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 stackIndexByAnchor = new Map<string, number>();
|
||||
for (const card of nextCards) {
|
||||
const shell = shellRefs.current.get(card.key);
|
||||
if (!shell) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nodeWorld = getNodeWorldPosition(card.anchorNodeId);
|
||||
const alpha = getTransientHandoffCardAlpha(card, snapshot.time);
|
||||
if (!nodeWorld || !worldToScreen || alpha <= 0.001) {
|
||||
shell.style.opacity = '0';
|
||||
continue;
|
||||
}
|
||||
|
||||
const stackIndex = stackIndexByAnchor.get(card.anchorNodeId) ?? 0;
|
||||
stackIndexByAnchor.set(card.anchorNodeId, stackIndex + 1);
|
||||
const lift = stackIndex * (CARD_HEIGHT * 0.34 + STACK_GAP);
|
||||
const scale = 0.94 + alpha * 0.06;
|
||||
|
||||
shell.style.left = `${Math.round(nodeWorld.x)}px`;
|
||||
shell.style.top = `${Math.round(nodeWorld.y)}px`;
|
||||
shell.style.opacity = String(alpha);
|
||||
shell.style.transform = `translate(-50%, calc(-50% - ${lift.toFixed(1)}px)) scale(${scale.toFixed(3)})`;
|
||||
}
|
||||
|
||||
frameId = window.requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
tick();
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [
|
||||
enabled,
|
||||
focusEdgeIds,
|
||||
focusNodeIds,
|
||||
getCameraZoom,
|
||||
getNodeWorldPosition,
|
||||
getTransientHandoffSnapshot,
|
||||
worldToScreen,
|
||||
]);
|
||||
|
||||
const handoffMessages = useMemo(
|
||||
() =>
|
||||
cards.map((card, index) => ({
|
||||
card,
|
||||
message: buildTransientHandoffMessage(teamName, card),
|
||||
zebraShade: index % 2 === 1,
|
||||
})),
|
||||
[cards, teamName]
|
||||
);
|
||||
|
||||
if (!enabled || !teamData || cards.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={worldLayerRef}
|
||||
className="pointer-events-none absolute left-0 top-0 z-[9] origin-top-left select-none"
|
||||
>
|
||||
{handoffMessages.map(({ card, message, zebraShade }) => (
|
||||
<div
|
||||
key={card.key}
|
||||
ref={(element) => {
|
||||
shellRefs.current.set(card.key, element);
|
||||
}}
|
||||
className="pointer-events-none absolute z-[9] origin-center opacity-0 transition-opacity duration-150 ease-out"
|
||||
style={{
|
||||
width: `${CARD_WIDTH}px`,
|
||||
maxWidth: `${CARD_WIDTH}px`,
|
||||
}}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<GraphActivityCard
|
||||
message={message}
|
||||
teamName={teamName}
|
||||
messageContext={messageContext}
|
||||
teamNames={teamNames}
|
||||
teamColorByName={teamColorByName}
|
||||
zebraShade={zebraShade}
|
||||
className="pointer-events-none drop-shadow-[0_0_22px_rgba(94,234,212,0.12)]"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -18,6 +18,7 @@ import { GraphActivityHud } from './GraphActivityHud';
|
|||
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
|
||||
import { GraphNodePopover } from './GraphNodePopover';
|
||||
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
||||
import { GraphTransientHandoffHud } from './GraphTransientHandoffHud';
|
||||
|
||||
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
|
||||
import type {
|
||||
|
|
@ -140,13 +141,30 @@ export const TeamGraphOverlay = ({
|
|||
height: number;
|
||||
} | null;
|
||||
getCameraZoom?: () => number;
|
||||
getTransientHandoffSnapshot?: (options?: {
|
||||
focusNodeIds?: ReadonlySet<string> | null;
|
||||
focusEdgeIds?: ReadonlySet<string> | null;
|
||||
}) => {
|
||||
cards: import('@claude-teams/agent-graph').TransientHandoffCard[];
|
||||
time: number;
|
||||
};
|
||||
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
||||
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
||||
focusEdgeIds?: ReadonlySet<string> | null;
|
||||
};
|
||||
const { getViewportSize, focusNodeIds } = extraHudProps;
|
||||
|
||||
return (
|
||||
<>
|
||||
<GraphTransientHandoffHud
|
||||
teamName={teamName}
|
||||
getTransientHandoffSnapshot={extraHudProps.getTransientHandoffSnapshot}
|
||||
getCameraZoom={extraHudProps.getCameraZoom}
|
||||
worldToScreen={extraHudProps.worldToScreen}
|
||||
getNodeWorldPosition={extraHudProps.getNodeWorldPosition}
|
||||
focusNodeIds={focusNodeIds}
|
||||
focusEdgeIds={extraHudProps.focusEdgeIds ?? null}
|
||||
/>
|
||||
<GraphActivityHud
|
||||
teamName={teamName}
|
||||
nodes={graphData.nodes}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { GraphActivityHud } from './GraphActivityHud';
|
|||
import { GraphBlockingEdgePopover } from './GraphBlockingEdgePopover';
|
||||
import { GraphNodePopover } from './GraphNodePopover';
|
||||
import { GraphProvisioningHud } from './GraphProvisioningHud';
|
||||
import { GraphTransientHandoffHud } from './GraphTransientHandoffHud';
|
||||
|
||||
import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph';
|
||||
import type {
|
||||
|
|
@ -164,13 +165,31 @@ export const TeamGraphTab = ({
|
|||
height: number;
|
||||
} | null;
|
||||
getCameraZoom?: () => number;
|
||||
getTransientHandoffSnapshot?: (options?: {
|
||||
focusNodeIds?: ReadonlySet<string> | null;
|
||||
focusEdgeIds?: ReadonlySet<string> | null;
|
||||
}) => {
|
||||
cards: import('@claude-teams/agent-graph').TransientHandoffCard[];
|
||||
time: number;
|
||||
};
|
||||
worldToScreen?: (x: number, y: number) => { x: number; y: number };
|
||||
getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
|
||||
focusEdgeIds?: ReadonlySet<string> | null;
|
||||
};
|
||||
const { getViewportSize, focusNodeIds } = extraHudProps;
|
||||
|
||||
return (
|
||||
<>
|
||||
<GraphTransientHandoffHud
|
||||
teamName={teamName}
|
||||
getTransientHandoffSnapshot={extraHudProps.getTransientHandoffSnapshot}
|
||||
getCameraZoom={extraHudProps.getCameraZoom}
|
||||
worldToScreen={extraHudProps.worldToScreen}
|
||||
getNodeWorldPosition={extraHudProps.getNodeWorldPosition}
|
||||
focusNodeIds={focusNodeIds}
|
||||
focusEdgeIds={extraHudProps.focusEdgeIds ?? null}
|
||||
enabled={isActive}
|
||||
/>
|
||||
<GraphActivityHud
|
||||
teamName={teamName}
|
||||
nodes={graphData.nodes}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import type { TransientHandoffCard } from '@claude-teams/agent-graph';
|
||||
import type { InboxMessage, TaskRef } from '@shared/types';
|
||||
|
||||
function buildTaskRefs(teamName: string, card: TransientHandoffCard): TaskRef[] | undefined {
|
||||
if (!card.relatedTaskId) {
|
||||
return undefined;
|
||||
}
|
||||
return [
|
||||
{
|
||||
taskId: card.relatedTaskId,
|
||||
displayId: card.relatedTaskDisplayId ?? card.relatedTaskId.slice(0, 8),
|
||||
teamName,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildSummary(card: TransientHandoffCard): string {
|
||||
const preview = card.preview?.trim();
|
||||
if (preview) {
|
||||
return preview;
|
||||
}
|
||||
if (card.kind === 'task_assign' && card.relatedTaskDisplayId) {
|
||||
return `Task ${card.relatedTaskDisplayId} assigned`;
|
||||
}
|
||||
if (card.kind === 'task_comment' && card.relatedTaskDisplayId) {
|
||||
return `${card.relatedTaskDisplayId} updated`;
|
||||
}
|
||||
return `${card.sourceLabel} -> ${card.destinationLabel}`;
|
||||
}
|
||||
|
||||
function buildText(card: TransientHandoffCard): string {
|
||||
const preview = card.preview?.trim();
|
||||
switch (card.kind) {
|
||||
case 'task_assign': {
|
||||
const taskLabel = card.relatedTaskDisplayId ?? card.relatedTaskId ?? 'task';
|
||||
return `New task assigned to you: ${taskLabel}${preview ? ` - ${preview}` : ''}`;
|
||||
}
|
||||
case 'task_comment':
|
||||
return preview ?? `${card.sourceLabel} added a comment`;
|
||||
case 'review_request':
|
||||
return preview ?? `Review requested by ${card.sourceLabel}`;
|
||||
case 'review_response':
|
||||
return preview ?? `Review response from ${card.sourceLabel}`;
|
||||
case 'inbox_message':
|
||||
default:
|
||||
return preview ?? `${card.sourceLabel} -> ${card.destinationLabel}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTransientHandoffMessage(
|
||||
teamName: string,
|
||||
card: TransientHandoffCard
|
||||
): InboxMessage {
|
||||
const messageKind = card.kind === 'task_comment' ? 'task_comment_notification' : 'default';
|
||||
const taskRefs = buildTaskRefs(teamName, card);
|
||||
|
||||
return {
|
||||
from: card.sourceLabel,
|
||||
to: card.destinationLabel,
|
||||
text: buildText(card),
|
||||
timestamp: new Date(card.updatedAt * 1000).toISOString(),
|
||||
read: true,
|
||||
summary: buildSummary(card),
|
||||
color: card.color,
|
||||
messageId: `graph-handoff:${card.key}`,
|
||||
source: 'inbox',
|
||||
messageKind,
|
||||
taskRefs,
|
||||
};
|
||||
}
|
||||
|
|
@ -978,6 +978,7 @@ async function initializeServices(): Promise<void> {
|
|||
boardTaskExactLogsService,
|
||||
boardTaskExactLogDetailService,
|
||||
teammateToolTracker ?? undefined,
|
||||
teamLogSourceTracker,
|
||||
branchStatusService ?? undefined,
|
||||
{
|
||||
rewire: rewireContextEvents,
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ import type {
|
|||
ServiceContextRegistry,
|
||||
SshConnectionManager,
|
||||
TeamDataService,
|
||||
TeamLogSourceTracker,
|
||||
TeammateToolTracker,
|
||||
TeamMemberLogsFinder,
|
||||
TeamProvisioningService,
|
||||
|
|
@ -141,6 +142,7 @@ export function initializeIpcHandlers(
|
|||
boardTaskExactLogsService: BoardTaskExactLogsService,
|
||||
boardTaskExactLogDetailService: BoardTaskExactLogDetailService,
|
||||
teammateToolTracker: TeammateToolTracker | undefined,
|
||||
teamLogSourceTracker: TeamLogSourceTracker | undefined,
|
||||
branchStatusService: BranchStatusService | undefined,
|
||||
contextCallbacks: {
|
||||
rewire: (context: ServiceContext) => void;
|
||||
|
|
@ -184,6 +186,7 @@ export function initializeIpcHandlers(
|
|||
memberStatsComputer,
|
||||
teamBackupService,
|
||||
teammateToolTracker,
|
||||
teamLogSourceTracker,
|
||||
branchStatusService,
|
||||
boardTaskActivityService,
|
||||
boardTaskActivityDetailService,
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ import {
|
|||
TEAM_SEND_MESSAGE,
|
||||
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
||||
TEAM_SET_PROJECT_BRANCH_TRACKING,
|
||||
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
||||
TEAM_SET_TASK_CLARIFICATION,
|
||||
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
||||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||||
|
|
@ -136,6 +137,7 @@ import type {
|
|||
BranchStatusService,
|
||||
MemberStatsComputer,
|
||||
TeamDataService,
|
||||
TeamLogSourceTracker,
|
||||
TeammateToolTracker,
|
||||
TeamMemberLogsFinder,
|
||||
TeamProvisioningService,
|
||||
|
|
@ -460,6 +462,7 @@ let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
|
|||
let memberStatsComputer: MemberStatsComputer | null = null;
|
||||
let teamBackupService: TeamBackupService | null = null;
|
||||
let teammateToolTracker: TeammateToolTracker | null = null;
|
||||
let teamLogSourceTracker: TeamLogSourceTracker | null = null;
|
||||
let branchStatusService: BranchStatusService | null = null;
|
||||
let boardTaskActivityService: BoardTaskActivityService | null = null;
|
||||
let boardTaskActivityDetailService: BoardTaskActivityDetailService | null = null;
|
||||
|
|
@ -496,6 +499,7 @@ export function initializeTeamHandlers(
|
|||
statsComputer?: MemberStatsComputer,
|
||||
backupService?: TeamBackupService,
|
||||
toolTracker?: TeammateToolTracker,
|
||||
logSourceTracker?: TeamLogSourceTracker,
|
||||
branchTracker?: BranchStatusService,
|
||||
taskActivityService?: BoardTaskActivityService,
|
||||
taskActivityDetailService?: BoardTaskActivityDetailService,
|
||||
|
|
@ -510,6 +514,7 @@ export function initializeTeamHandlers(
|
|||
memberStatsComputer = statsComputer ?? null;
|
||||
teamBackupService = backupService ?? null;
|
||||
teammateToolTracker = toolTracker ?? null;
|
||||
teamLogSourceTracker = logSourceTracker ?? null;
|
||||
branchStatusService = branchTracker ?? null;
|
||||
boardTaskActivityService = taskActivityService ?? null;
|
||||
boardTaskActivityDetailService = taskActivityDetailService ?? null;
|
||||
|
|
@ -524,6 +529,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_GET_TASK_CHANGE_PRESENCE, handleGetTaskChangePresence);
|
||||
ipcMain.handle(TEAM_SET_CHANGE_PRESENCE_TRACKING, handleSetChangePresenceTracking);
|
||||
ipcMain.handle(TEAM_SET_PROJECT_BRANCH_TRACKING, handleSetProjectBranchTracking);
|
||||
ipcMain.handle(TEAM_SET_TASK_LOG_STREAM_TRACKING, handleSetTaskLogStreamTracking);
|
||||
ipcMain.handle(TEAM_SET_TOOL_ACTIVITY_TRACKING, handleSetToolActivityTracking);
|
||||
ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs);
|
||||
ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning);
|
||||
|
|
@ -597,6 +603,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_GET_TASK_CHANGE_PRESENCE);
|
||||
ipcMain.removeHandler(TEAM_SET_CHANGE_PRESENCE_TRACKING);
|
||||
ipcMain.removeHandler(TEAM_SET_PROJECT_BRANCH_TRACKING);
|
||||
ipcMain.removeHandler(TEAM_SET_TASK_LOG_STREAM_TRACKING);
|
||||
ipcMain.removeHandler(TEAM_SET_TOOL_ACTIVITY_TRACKING);
|
||||
ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS);
|
||||
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
|
||||
|
|
@ -684,6 +691,13 @@ function getTeammateToolTracker(): TeammateToolTracker {
|
|||
return teammateToolTracker;
|
||||
}
|
||||
|
||||
function getTeamLogSourceTracker(): TeamLogSourceTracker {
|
||||
if (!teamLogSourceTracker) {
|
||||
throw new Error('Team log source tracker is not initialized');
|
||||
}
|
||||
return teamLogSourceTracker;
|
||||
}
|
||||
|
||||
function getBranchStatusService(): BranchStatusService {
|
||||
if (!branchStatusService) {
|
||||
throw new Error('Branch status service is not initialized');
|
||||
|
|
@ -948,6 +962,28 @@ async function handleSetToolActivityTracking(
|
|||
});
|
||||
}
|
||||
|
||||
async function handleSetTaskLogStreamTracking(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
enabled: unknown
|
||||
): Promise<IpcResult<void>> {
|
||||
const validated = validateTeamName(teamName);
|
||||
if (!validated.valid) {
|
||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
}
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return { success: false, error: 'enabled must be a boolean' };
|
||||
}
|
||||
|
||||
return wrapTeamHandler('setTaskLogStreamTracking', async () => {
|
||||
if (enabled) {
|
||||
await getTeamLogSourceTracker().enableTracking(validated.value!, 'task_log_stream');
|
||||
return;
|
||||
}
|
||||
await getTeamLogSourceTracker().disableTracking(validated.value!, 'task_log_stream');
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDeleteTeam(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
|
|
|
|||
|
|
@ -2697,9 +2697,11 @@ export class TeamDataService {
|
|||
}
|
||||
const leadName =
|
||||
transcriptContext.config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead';
|
||||
const sessionIds = Array.from(
|
||||
new Set([...this.getRecentLeadSessionIds(config), ...transcriptContext.sessionIds])
|
||||
);
|
||||
const knownLeadSessionIds = this.getRecentLeadSessionIds(config);
|
||||
if (knownLeadSessionIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const sessionIds = knownLeadSessionIds;
|
||||
if (sessionIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,13 +14,15 @@ import type { TeamChangeEvent } from '@shared/types';
|
|||
import type { FSWatcher } from 'chokidar';
|
||||
|
||||
const logger = createLogger('Service:TeamLogSourceTracker');
|
||||
const BOARD_TASK_LOG_FRESHNESS_DIRNAME = '.board-task-log-freshness';
|
||||
const BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX = '.json';
|
||||
|
||||
interface TeamLogSourceSnapshot {
|
||||
projectFingerprint: string | null;
|
||||
logSourceGeneration: string | null;
|
||||
}
|
||||
|
||||
export type TeamLogSourceTrackingConsumer = 'change_presence' | 'tool_activity';
|
||||
export type TeamLogSourceTrackingConsumer = 'change_presence' | 'tool_activity' | 'task_log_stream';
|
||||
|
||||
interface TrackingState {
|
||||
watcher: FSWatcher | null;
|
||||
|
|
@ -31,7 +33,7 @@ interface TrackingState {
|
|||
recomputePromise: Promise<TeamLogSourceSnapshot> | null;
|
||||
recomputeVersion: number | null;
|
||||
snapshot: TeamLogSourceSnapshot;
|
||||
consumers: Set<TeamLogSourceTrackingConsumer>;
|
||||
consumerCounts: Map<TeamLogSourceTrackingConsumer, number>;
|
||||
lifecycleVersion: number;
|
||||
}
|
||||
|
||||
|
|
@ -67,19 +69,29 @@ export class TeamLogSourceTracker {
|
|||
consumer: TeamLogSourceTrackingConsumer
|
||||
): Promise<TeamLogSourceSnapshot> {
|
||||
const state = this.getOrCreateState(teamName);
|
||||
if (!state.consumers.has(consumer)) {
|
||||
state.consumers.add(consumer);
|
||||
const activeConsumerCountBefore = this.getActiveConsumerCount(state);
|
||||
state.consumerCounts.set(consumer, (state.consumerCounts.get(consumer) ?? 0) + 1);
|
||||
if (activeConsumerCountBefore === 0) {
|
||||
state.lifecycleVersion += 1;
|
||||
}
|
||||
|
||||
if (
|
||||
state.initializePromise &&
|
||||
state.initializeVersion === state.lifecycleVersion &&
|
||||
state.consumers.size > 0
|
||||
this.getActiveConsumerCount(state) > 0
|
||||
) {
|
||||
return state.initializePromise;
|
||||
}
|
||||
|
||||
if (
|
||||
activeConsumerCountBefore > 0 &&
|
||||
(state.watcher !== null ||
|
||||
state.projectDir !== null ||
|
||||
state.snapshot.logSourceGeneration !== null)
|
||||
) {
|
||||
return { ...state.snapshot };
|
||||
}
|
||||
|
||||
const initializeVersion = state.lifecycleVersion;
|
||||
const initializePromise = this.initializeTeam(teamName, initializeVersion)
|
||||
.catch((error) => {
|
||||
|
|
@ -118,13 +130,21 @@ export class TeamLogSourceTracker {
|
|||
recomputePromise: null,
|
||||
recomputeVersion: null,
|
||||
snapshot: { projectFingerprint: null, logSourceGeneration: null },
|
||||
consumers: new Set(),
|
||||
consumerCounts: new Map(),
|
||||
lifecycleVersion: 0,
|
||||
};
|
||||
this.stateByTeam.set(teamName, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private getActiveConsumerCount(state: TrackingState): number {
|
||||
let count = 0;
|
||||
for (const value of state.consumerCounts.values()) {
|
||||
count += value;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async stopTracking(teamName: string): Promise<void> {
|
||||
await this.disableTracking(teamName, 'change_presence');
|
||||
}
|
||||
|
|
@ -138,15 +158,24 @@ export class TeamLogSourceTracker {
|
|||
return { projectFingerprint: null, logSourceGeneration: null };
|
||||
}
|
||||
|
||||
if (state.consumers.has(consumer)) {
|
||||
state.consumers.delete(consumer);
|
||||
state.lifecycleVersion += 1;
|
||||
const currentConsumerCount = state.consumerCounts.get(consumer) ?? 0;
|
||||
if (currentConsumerCount > 1) {
|
||||
state.consumerCounts.set(consumer, currentConsumerCount - 1);
|
||||
return { ...state.snapshot };
|
||||
}
|
||||
|
||||
if (state.consumers.size > 0) {
|
||||
if (currentConsumerCount === 1) {
|
||||
state.consumerCounts.delete(consumer);
|
||||
}
|
||||
|
||||
if (this.getActiveConsumerCount(state) > 0) {
|
||||
return { ...state.snapshot };
|
||||
}
|
||||
|
||||
if (currentConsumerCount > 0) {
|
||||
state.lifecycleVersion += 1;
|
||||
}
|
||||
|
||||
if (state.refreshTimer) {
|
||||
clearTimeout(state.refreshTimer);
|
||||
state.refreshTimer = null;
|
||||
|
|
@ -164,7 +193,11 @@ export class TeamLogSourceTracker {
|
|||
|
||||
private isTrackingCurrent(teamName: string, expectedVersion: number): boolean {
|
||||
const state = this.stateByTeam.get(teamName);
|
||||
return !!state && state.consumers.size > 0 && state.lifecycleVersion === expectedVersion;
|
||||
return (
|
||||
!!state &&
|
||||
this.getActiveConsumerCount(state) > 0 &&
|
||||
state.lifecycleVersion === expectedVersion
|
||||
);
|
||||
}
|
||||
|
||||
private async initializeTeam(
|
||||
|
|
@ -207,7 +240,11 @@ export class TeamLogSourceTracker {
|
|||
expectedVersion: number
|
||||
): Promise<void> {
|
||||
const state = this.stateByTeam.get(teamName);
|
||||
if (!state || state.consumers.size === 0 || state.lifecycleVersion !== expectedVersion) {
|
||||
if (
|
||||
!state ||
|
||||
this.getActiveConsumerCount(state) === 0 ||
|
||||
state.lifecycleVersion !== expectedVersion
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (state.projectDir === projectDir && state.watcher) {
|
||||
|
|
@ -240,9 +277,15 @@ export class TeamLogSourceTracker {
|
|||
},
|
||||
});
|
||||
|
||||
const scheduleRecompute = (): void => {
|
||||
const scheduleRecompute = (changedPath?: string): void => {
|
||||
const current = this.stateByTeam.get(teamName);
|
||||
if (!current || current.consumers.size === 0) {
|
||||
if (!current || this.getActiveConsumerCount(current) === 0 || !current.projectDir) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
changedPath &&
|
||||
this.handleTaskLogFreshnessSignalChange(teamName, current.projectDir, changedPath)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (current.refreshTimer) {
|
||||
|
|
@ -264,15 +307,65 @@ export class TeamLogSourceTracker {
|
|||
});
|
||||
}
|
||||
|
||||
private handleTaskLogFreshnessSignalChange(
|
||||
teamName: string,
|
||||
projectDir: string,
|
||||
changedPath: string
|
||||
): boolean {
|
||||
const signalDir = path.join(projectDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME);
|
||||
const relativePath = path.relative(signalDir, changedPath);
|
||||
if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
||||
return path.normalize(changedPath) === path.normalize(signalDir);
|
||||
}
|
||||
|
||||
if (relativePath === '.') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (relativePath.includes(path.sep)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const taskId = this.decodeTaskLogFreshnessTaskId(relativePath);
|
||||
if (!taskId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.emitter?.({
|
||||
type: 'task-log-change',
|
||||
teamName,
|
||||
taskId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private decodeTaskLogFreshnessTaskId(fileName: string): string | null {
|
||||
if (!fileName.endsWith(BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const encodedTaskId = fileName.slice(0, -BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX.length);
|
||||
if (!encodedTaskId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const taskId = decodeURIComponent(encodedTaskId);
|
||||
return taskId.trim().length > 0 ? taskId : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async recompute(teamName: string): Promise<TeamLogSourceSnapshot> {
|
||||
const state = this.getOrCreateState(teamName);
|
||||
if (state.consumers.size === 0) {
|
||||
if (this.getActiveConsumerCount(state) === 0) {
|
||||
return state.snapshot;
|
||||
}
|
||||
if (
|
||||
state.recomputePromise &&
|
||||
state.recomputeVersion === state.lifecycleVersion &&
|
||||
state.consumers.size > 0
|
||||
this.getActiveConsumerCount(state) > 0
|
||||
) {
|
||||
return state.recomputePromise;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,14 +161,47 @@ function extractBoardToolOutputText(
|
|||
return null;
|
||||
}
|
||||
|
||||
const normalizedToolName = toolName.trim().toLowerCase();
|
||||
const payload = parsedPayload as Record<string, unknown>;
|
||||
if (toolName === 'task_add_comment' || toolName === 'task_get_comment') {
|
||||
if (normalizedToolName === 'task_add_comment' || normalizedToolName === 'task_get_comment') {
|
||||
const comment = payload.comment as Record<string, unknown> | undefined;
|
||||
if (typeof comment?.text === 'string' && comment.text.trim().length > 0) {
|
||||
return comment.text;
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedToolName === 'sendmessage') {
|
||||
const routing = payload.routing as Record<string, unknown> | undefined;
|
||||
const deliveryMessage =
|
||||
typeof payload.message === 'string' && payload.message.trim().length > 0
|
||||
? payload.message.trim()
|
||||
: null;
|
||||
const summary =
|
||||
typeof routing?.summary === 'string' && routing.summary.trim().length > 0
|
||||
? routing.summary.trim()
|
||||
: null;
|
||||
const target =
|
||||
typeof routing?.target === 'string' && routing.target.trim().length > 0
|
||||
? routing.target.trim()
|
||||
: null;
|
||||
|
||||
if (deliveryMessage && summary) {
|
||||
return `${deliveryMessage} - ${summary}`;
|
||||
}
|
||||
if (summary && target) {
|
||||
return `Message sent to ${target} - ${summary}`;
|
||||
}
|
||||
if (summary) {
|
||||
return summary;
|
||||
}
|
||||
if (deliveryMessage) {
|
||||
return deliveryMessage;
|
||||
}
|
||||
if (target) {
|
||||
return `Message sent to ${target}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -289,12 +322,67 @@ function sanitizeToolResultContent(
|
|||
};
|
||||
}
|
||||
|
||||
function sanitizeToolResultPayloadValue(
|
||||
value: string | unknown[],
|
||||
canonicalToolName?: string
|
||||
): string | unknown[] {
|
||||
if (typeof value === 'string') {
|
||||
const parsedPayload = parseJsonLikeString(value);
|
||||
const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload);
|
||||
if (typeof extractedText === 'string') {
|
||||
return extractedText;
|
||||
}
|
||||
return parsedPayload ? '' : value;
|
||||
}
|
||||
|
||||
const jsonText = collectTextBlockText(value);
|
||||
const parsedPayload = parseJsonLikeString(jsonText);
|
||||
const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload);
|
||||
if (typeof extractedText === 'string') {
|
||||
return extractedText;
|
||||
}
|
||||
|
||||
const sanitizedChildren = value
|
||||
.map((child) => {
|
||||
if (
|
||||
typeof child === 'object' &&
|
||||
child !== null &&
|
||||
'type' in child &&
|
||||
child.type === 'text' &&
|
||||
'text' in child &&
|
||||
typeof child.text === 'string'
|
||||
) {
|
||||
return looksLikeJsonPayload(child.text) ? null : { ...child };
|
||||
}
|
||||
return child;
|
||||
})
|
||||
.filter((child) => child !== null);
|
||||
|
||||
if (parsedPayload && sanitizedChildren.length === value.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return sanitizedChildren.length > 0 ? sanitizedChildren : '';
|
||||
}
|
||||
|
||||
function sanitizeJsonLikeToolResultPayloads(
|
||||
messages: ParsedMessage[],
|
||||
canonicalToolName?: string
|
||||
): ParsedMessage[] {
|
||||
return messages.map((message) => {
|
||||
let nextMessage = message;
|
||||
let toolResultsChanged = false;
|
||||
const nextToolResults = message.toolResults.map((toolResult) => {
|
||||
const nextContent = sanitizeToolResultPayloadValue(toolResult.content, canonicalToolName);
|
||||
if (JSON.stringify(nextContent) !== JSON.stringify(toolResult.content)) {
|
||||
toolResultsChanged = true;
|
||||
return {
|
||||
...toolResult,
|
||||
content: nextContent,
|
||||
};
|
||||
}
|
||||
return toolResult;
|
||||
});
|
||||
|
||||
const rawToolUseResult = message.toolUseResult as unknown;
|
||||
if (
|
||||
|
|
@ -388,12 +476,20 @@ function sanitizeJsonLikeToolResultPayloads(
|
|||
});
|
||||
|
||||
if (!changed) {
|
||||
return nextMessage;
|
||||
if (!toolResultsChanged) {
|
||||
return nextMessage;
|
||||
}
|
||||
|
||||
return {
|
||||
...nextMessage,
|
||||
toolResults: nextToolResults,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...nextMessage,
|
||||
content: nextContent,
|
||||
toolResults: toolResultsChanged ? nextToolResults : nextMessage.toolResults,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -1011,6 +1107,15 @@ export class BoardTaskLogStreamService {
|
|||
continue;
|
||||
}
|
||||
|
||||
const inferredToolName = [...messageToolUseIds]
|
||||
.map((toolUseId) => toolNameByUseId.get(toolUseId))
|
||||
.find((toolName): toolName is string => typeof toolName === 'string');
|
||||
const sanitizedMessages = sanitizeJsonLikeToolResultPayloads([message], inferredToolName);
|
||||
const prunedMessages = pruneEmptyInternalToolResultMessages(sanitizedMessages);
|
||||
if (prunedMessages.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
inferredSlices.push({
|
||||
id: `inferred:${filePath}:${message.uuid}`,
|
||||
timestamp: message.timestamp.toISOString(),
|
||||
|
|
@ -1018,7 +1123,7 @@ export class BoardTaskLogStreamService {
|
|||
sortOrder: index,
|
||||
participantKey: buildParticipantKey(actor),
|
||||
actor,
|
||||
filteredMessages: [message],
|
||||
filteredMessages: prunedMessages,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -219,6 +219,9 @@ export const TEAM_SET_CHANGE_PRESENCE_TRACKING = 'team:setChangePresenceTracking
|
|||
/** Enable or disable live teammate tool activity tracking for a visible team tab */
|
||||
export const TEAM_SET_TOOL_ACTIVITY_TRACKING = 'team:setToolActivityTracking';
|
||||
|
||||
/** Enable or disable task log stream invalidation tracking for an open task log panel */
|
||||
export const TEAM_SET_TASK_LOG_STREAM_TRACKING = 'team:setTaskLogStreamTracking';
|
||||
|
||||
/** Get buffered Claude CLI logs (paged, newest-first) */
|
||||
export const TEAM_GET_CLAUDE_LOGS = 'team:getClaudeLogs';
|
||||
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ import {
|
|||
TEAM_SAVE_TASK_ATTACHMENT,
|
||||
TEAM_SEND_MESSAGE,
|
||||
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
||||
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
||||
TEAM_SET_PROJECT_BRANCH_TRACKING,
|
||||
TEAM_SET_TASK_CLARIFICATION,
|
||||
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
||||
|
|
@ -836,6 +837,9 @@ const electronAPI: ElectronAPI = {
|
|||
setChangePresenceTracking: async (teamName: string, enabled: boolean) => {
|
||||
return invokeIpcWithResult<void>(TEAM_SET_CHANGE_PRESENCE_TRACKING, teamName, enabled);
|
||||
},
|
||||
setTaskLogStreamTracking: async (teamName: string, enabled: boolean) => {
|
||||
return invokeIpcWithResult<void>(TEAM_SET_TASK_LOG_STREAM_TRACKING, teamName, enabled);
|
||||
},
|
||||
setToolActivityTracking: async (teamName: string, enabled: boolean) => {
|
||||
return invokeIpcWithResult<void>(TEAM_SET_TOOL_ACTIVITY_TRACKING, teamName, enabled);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -689,6 +689,9 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
setChangePresenceTracking: async (): Promise<void> => {
|
||||
// Not available in browser mode — no-op.
|
||||
},
|
||||
setTaskLogStreamTracking: async (): Promise<void> => {
|
||||
// Not available in browser mode — no-op.
|
||||
},
|
||||
setToolActivityTracking: async (): Promise<void> => {
|
||||
// Not available in browser mode — no-op.
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ interface OngoingIndicatorProps {
|
|||
showLabel?: boolean;
|
||||
/** Custom label text */
|
||||
label?: string;
|
||||
/** Accessible title/tooltip text */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -24,11 +26,12 @@ export const OngoingIndicator = ({
|
|||
size = 'sm',
|
||||
showLabel = false,
|
||||
label = 'Session in progress...',
|
||||
title = label,
|
||||
}: Readonly<OngoingIndicatorProps>): React.JSX.Element => {
|
||||
const dotSize = size === 'sm' ? 'h-2 w-2' : 'h-2.5 w-2.5';
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2" title="Session in progress">
|
||||
<span className="inline-flex items-center gap-2" title={title}>
|
||||
<span className={`relative flex ${dotSize} shrink-0`}>
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-green-400 opacity-75" />
|
||||
<span className={`relative inline-flex rounded-full ${dotSize} bg-green-500`} />
|
||||
|
|
|
|||
|
|
@ -48,12 +48,6 @@ interface ActivityTimelineProps {
|
|||
expandOverrides?: Set<string>;
|
||||
/** Called when user toggles expand/collapse override on a specific message. */
|
||||
onToggleExpandOverride?: (key: string) => void;
|
||||
/**
|
||||
* All session IDs belonging to this team (current + history).
|
||||
* Used together with currentLeadSessionId to suppress only the reconnect boundary
|
||||
* from the current live session back into the team's previous session history.
|
||||
*/
|
||||
teamSessionIds?: Set<string>;
|
||||
/** Current lead session ID for the active team, if known. */
|
||||
currentLeadSessionId?: string;
|
||||
/** Whether the current team is alive. */
|
||||
|
|
@ -281,7 +275,6 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
|||
allCollapsed,
|
||||
expandOverrides,
|
||||
onToggleExpandOverride,
|
||||
teamSessionIds,
|
||||
currentLeadSessionId,
|
||||
isTeamAlive,
|
||||
leadActivity,
|
||||
|
|
@ -425,10 +418,13 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
|||
setVisibleCount(Infinity);
|
||||
};
|
||||
|
||||
const getItemSessionId = (item: TimelineItem): string | undefined =>
|
||||
item.type === 'lead-thoughts'
|
||||
? item.group.thoughts[0].leadSessionId
|
||||
: item.message.leadSessionId;
|
||||
const getItemSessionAnchorId = (item: TimelineItem): string | undefined => {
|
||||
if (item.type === 'lead-thoughts') {
|
||||
return item.group.thoughts[0]?.leadSessionId;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Pin the newest thought group (if first) so it stays at the top and doesn't jump.
|
||||
const pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null;
|
||||
|
|
@ -535,32 +531,29 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
|||
// Session boundary separator (messages sorted desc — new on top)
|
||||
let sessionSeparator: React.JSX.Element | null = null;
|
||||
if (realIndex > 0) {
|
||||
const prevSessionId = getItemSessionId(timelineItems[realIndex - 1]);
|
||||
const currSessionId = getItemSessionId(item);
|
||||
if (prevSessionId && currSessionId && prevSessionId !== currSessionId) {
|
||||
// Suppress only the boundary between the current live session and the team's
|
||||
// older session history. Older historical session boundaries should still render.
|
||||
const isReconnectBoundary =
|
||||
!!currentLeadSessionId &&
|
||||
teamSessionIds &&
|
||||
teamSessionIds.has(prevSessionId) &&
|
||||
teamSessionIds.has(currSessionId) &&
|
||||
(prevSessionId === currentLeadSessionId || currSessionId === currentLeadSessionId);
|
||||
if (!isReconnectBoundary) {
|
||||
sessionSeparator = (
|
||||
<div
|
||||
className="flex items-center gap-3"
|
||||
style={{ paddingTop: 45, paddingBottom: 45 }}
|
||||
>
|
||||
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
|
||||
<span className="whitespace-nowrap text-[11px] font-medium text-blue-600 dark:text-blue-400">
|
||||
New session
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
|
||||
</div>
|
||||
);
|
||||
const currSessionId = getItemSessionAnchorId(item);
|
||||
let prevSessionId: string | undefined;
|
||||
for (let searchIndex = realIndex - 1; searchIndex >= 0; searchIndex -= 1) {
|
||||
const candidateSessionId = getItemSessionAnchorId(timelineItems[searchIndex]);
|
||||
if (candidateSessionId) {
|
||||
prevSessionId = candidateSessionId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (prevSessionId && currSessionId && prevSessionId !== currSessionId) {
|
||||
sessionSeparator = (
|
||||
<div
|
||||
className="flex items-center gap-3"
|
||||
style={{ paddingTop: 45, paddingBottom: 45 }}
|
||||
>
|
||||
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
|
||||
<span className="whitespace-nowrap text-[11px] font-medium text-blue-600 dark:text-blue-400">
|
||||
New session
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (item.type === 'lead-thoughts') {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||
|
||||
import { api } from '@renderer/api';
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { OngoingIndicator } from '@renderer/components/common/OngoingIndicator';
|
||||
import {
|
||||
ImageLightbox,
|
||||
LightboxLockProvider,
|
||||
|
|
@ -156,6 +157,8 @@ export const TaskDetailDialog = ({
|
|||
|
||||
const [logsRefreshing, setLogsRefreshing] = useState(false);
|
||||
const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false);
|
||||
const [logsSectionOpen, setLogsSectionOpen] = useState(false);
|
||||
const [taskLogActivityActive, setTaskLogActivityActive] = useState(false);
|
||||
const [changesSectionOpen, setChangesSectionOpen] = useState(false);
|
||||
const [taskChangesFiles, setTaskChangesFiles] = useState<FileChangeSummary[] | null>(null);
|
||||
const [taskChangesLoading, setTaskChangesLoading] = useState(false);
|
||||
|
|
@ -231,6 +234,8 @@ export const TaskDetailDialog = ({
|
|||
setTaskChangesError(null);
|
||||
setLogsRefreshing(false);
|
||||
setExecutionPreviewOnline(false);
|
||||
setLogsSectionOpen(false);
|
||||
setTaskLogActivityActive(false);
|
||||
}, [open, currentTask?.id]);
|
||||
|
||||
const [replyTo, setReplyTo] = useState<{
|
||||
|
|
@ -1258,16 +1263,23 @@ export const TaskDetailDialog = ({
|
|||
key={`task-logs:${currentTask.id}`}
|
||||
title="Task Logs"
|
||||
icon={<ScrollText size={14} />}
|
||||
headerExtra={
|
||||
taskLogActivityActive ? (
|
||||
<OngoingIndicator size="sm" title="New task logs arriving" />
|
||||
) : null
|
||||
}
|
||||
contentClassName="pl-2.5 overflow-visible"
|
||||
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
||||
headerContentClassName="pl-6"
|
||||
defaultOpen={false}
|
||||
onOpenChange={setLogsSectionOpen}
|
||||
keepMounted
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<TaskLogsPanel
|
||||
teamName={teamName}
|
||||
task={currentTask}
|
||||
isOpen={logsSectionOpen}
|
||||
taskSince={taskSince}
|
||||
isExecutionRefreshing={logsRefreshing}
|
||||
isExecutionPreviewOnline={executionPreviewOnline}
|
||||
|
|
@ -1275,6 +1287,7 @@ export const TaskDetailDialog = ({
|
|||
showSubagentPreview={Boolean(currentTask.owner) && !isLeadOwnedTask}
|
||||
showLeadPreview={allowLeadExecutionPreview && isLeadOwnedTask}
|
||||
onPreviewOnlineChange={setExecutionPreviewOnline}
|
||||
onTaskLogActivityChange={setTaskLogActivityActive}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTeamSection>
|
||||
|
|
|
|||
|
|
@ -28,10 +28,7 @@ export const MemberExecutionLog = ({
|
|||
const conversation = useMemo(() => transformChunksToConversation(chunks, [], false), [chunks]);
|
||||
|
||||
// Show newest groups first — most recent activity is most relevant in execution logs.
|
||||
const orderedItems = useMemo(
|
||||
() => [...conversation.items].reverse(),
|
||||
[conversation.items]
|
||||
);
|
||||
const orderedItems = useMemo(() => [...conversation.items].reverse(), [conversation.items]);
|
||||
|
||||
// Store collapsed groups instead of expanded: by default, everything is expanded.
|
||||
// This avoids resetting state in an effect when conversation changes.
|
||||
|
|
@ -179,6 +176,8 @@ const AIExecutionGroup = ({
|
|||
return enhanceAIGroup({ ...group, processes: filteredProcesses });
|
||||
}, [group, memberName]);
|
||||
const hasToggleContent = enhanced.displayItems.length > 0;
|
||||
const visibleLastOutput =
|
||||
enhanced.lastOutput?.type === 'tool_result' ? null : enhanced.lastOutput;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 border-l-2 pl-3" style={{ borderColor: 'var(--chat-ai-border)' }}>
|
||||
|
|
@ -219,7 +218,7 @@ const AIExecutionGroup = ({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<LastOutputDisplay lastOutput={enhanced.lastOutput} aiGroupId={group.id} />
|
||||
<LastOutputDisplay lastOutput={visibleLastOutput} aiGroupId={group.id} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ interface MemberLogsTabProps {
|
|||
teamName: string;
|
||||
memberName?: string;
|
||||
taskId?: string;
|
||||
enabled?: boolean;
|
||||
/** When viewing task logs: include owner's sessions when task is in_progress */
|
||||
taskOwner?: string;
|
||||
taskStatus?: string;
|
||||
|
|
@ -100,6 +101,7 @@ export const MemberLogsTab = ({
|
|||
teamName,
|
||||
memberName,
|
||||
taskId,
|
||||
enabled = true,
|
||||
taskOwner,
|
||||
taskStatus,
|
||||
taskWorkIntervals,
|
||||
|
|
@ -375,6 +377,7 @@ export const MemberLogsTab = ({
|
|||
const previewHasMore = allPreviewMessages.length > previewVisibleCount;
|
||||
|
||||
const previewOnline = useMemo((): boolean => {
|
||||
if (!enabled) return false;
|
||||
if (!previewLog) return false;
|
||||
// Determine the most recent activity timestamp from preview messages
|
||||
const newest = previewMessages[0];
|
||||
|
|
@ -398,7 +401,7 @@ export const MemberLogsTab = ({
|
|||
if (taskStatus === 'in_progress') return ageMs <= 60_000;
|
||||
// Completed/other tasks — shorter window
|
||||
return ageMs <= 15_000;
|
||||
}, [previewLog, previewMessages, taskStatus]);
|
||||
}, [enabled, previewLog, previewMessages, taskStatus]);
|
||||
|
||||
const expandedLogSummary = useMemo(() => {
|
||||
if (!expandedId) return null;
|
||||
|
|
@ -443,6 +446,17 @@ export const MemberLogsTab = ({
|
|||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const shouldAutoRefresh = taskId != null && taskStatus === 'in_progress';
|
||||
if (!enabled) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
refreshCountRef.current = 0;
|
||||
if (refreshHideTimeoutRef.current) {
|
||||
clearTimeout(refreshHideTimeoutRef.current);
|
||||
refreshHideTimeoutRef.current = null;
|
||||
}
|
||||
setRefreshing(false);
|
||||
};
|
||||
}
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
let didBeginRefreshing = false;
|
||||
|
|
@ -505,7 +519,17 @@ export const MemberLogsTab = ({
|
|||
setRefreshing(false);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- intervalsKey + taskSince drive refresh; deps intentionally minimal to avoid refetch loops
|
||||
}, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey, taskSince, isTabActive]);
|
||||
}, [
|
||||
enabled,
|
||||
teamName,
|
||||
memberName,
|
||||
taskId,
|
||||
taskOwner,
|
||||
taskStatus,
|
||||
intervalsKey,
|
||||
taskSince,
|
||||
isTabActive,
|
||||
]);
|
||||
|
||||
const fetchDetailForLog = useCallback(
|
||||
async (
|
||||
|
|
@ -532,6 +556,9 @@ export const MemberLogsTab = ({
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
if (!shouldShowPreview) {
|
||||
setPreviewChunks(null);
|
||||
return;
|
||||
|
|
@ -557,9 +584,10 @@ export const MemberLogsTab = ({
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fetchDetailForLog, previewLog, shouldShowPreview, intervalsKey]);
|
||||
}, [enabled, fetchDetailForLog, previewLog, shouldShowPreview, intervalsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (!shouldShowPreview) return;
|
||||
if (!previewLog) return;
|
||||
|
||||
|
|
@ -594,9 +622,11 @@ export const MemberLogsTab = ({
|
|||
taskStatus,
|
||||
intervalsKey,
|
||||
isTabActive,
|
||||
enabled,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
|
||||
if (!expandedLogSummary) return;
|
||||
if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return;
|
||||
|
|
@ -634,6 +664,7 @@ export const MemberLogsTab = ({
|
|||
taskStatus,
|
||||
intervalsKey,
|
||||
isTabActive,
|
||||
enabled,
|
||||
]);
|
||||
|
||||
const handleExpand = useCallback(
|
||||
|
|
|
|||
|
|
@ -73,8 +73,6 @@ interface MessagesPanelProps {
|
|||
leadContextUpdatedAt?: string;
|
||||
/** Time window for filtering. */
|
||||
timeWindow: TimeWindow | null;
|
||||
/** Team session IDs for timeline. */
|
||||
teamSessionIds: Set<string>;
|
||||
/** Current lead session ID. */
|
||||
currentLeadSessionId?: string;
|
||||
/** Pending replies tracker (shared with parent for MemberList). */
|
||||
|
|
@ -106,7 +104,6 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
leadActivity,
|
||||
leadContextUpdatedAt,
|
||||
timeWindow,
|
||||
teamSessionIds,
|
||||
currentLeadSessionId,
|
||||
pendingRepliesByMember,
|
||||
onPendingReplyChange,
|
||||
|
|
@ -569,7 +566,6 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
allCollapsed={messagesCollapsed}
|
||||
expandOverrides={expandedSet}
|
||||
onToggleExpandOverride={toggleExpandOverride}
|
||||
teamSessionIds={teamSessionIds}
|
||||
currentLeadSessionId={currentLeadSessionId}
|
||||
isTeamAlive={isTeamAlive}
|
||||
leadActivity={leadActivity}
|
||||
|
|
@ -755,7 +751,6 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
allCollapsed={messagesCollapsed}
|
||||
expandOverrides={expandedSet}
|
||||
onToggleExpandOverride={toggleExpandOverride}
|
||||
teamSessionIds={teamSessionIds}
|
||||
currentLeadSessionId={currentLeadSessionId}
|
||||
isTeamAlive={isTeamAlive}
|
||||
leadActivity={leadActivity}
|
||||
|
|
@ -1042,7 +1037,6 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
allCollapsed={messagesCollapsed}
|
||||
expandOverrides={expandedSet}
|
||||
onToggleExpandOverride={toggleExpandOverride}
|
||||
teamSessionIds={teamSessionIds}
|
||||
currentLeadSessionId={currentLeadSessionId}
|
||||
isTeamAlive={isTeamAlive}
|
||||
leadActivity={leadActivity}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { asEnhancedChunkArray } from '@renderer/types/data';
|
||||
|
|
@ -26,6 +26,7 @@ import type {
|
|||
interface TaskActivitySectionProps {
|
||||
teamName: string;
|
||||
taskId: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
function isHighSignalTaskActivityEntry(entry: BoardTaskActivityEntry): boolean {
|
||||
|
|
@ -262,12 +263,14 @@ const Row = ({
|
|||
export const TaskActivitySection = ({
|
||||
teamName,
|
||||
taskId,
|
||||
enabled = true,
|
||||
}: TaskActivitySectionProps): React.JSX.Element => {
|
||||
const [detailStates, setDetailStates] = useState<Record<string, ActivityDetailState>>({});
|
||||
const [entries, setEntries] = useState<BoardTaskActivityEntry[]>([]);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(enabled);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const hasLoadedRef = useRef(false);
|
||||
|
||||
const fetchDetail = useCallback(
|
||||
async (entry: BoardTaskActivityEntry): Promise<void> => {
|
||||
|
|
@ -325,13 +328,27 @@ export const TaskActivitySection = ({
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
setEntries([]);
|
||||
setExpandedId(null);
|
||||
setDetailStates({});
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoading(enabled);
|
||||
hasLoadedRef.current = false;
|
||||
}, [taskId, teamName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!enabled) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const load = async (showSpinner: boolean): Promise<void> => {
|
||||
try {
|
||||
|
|
@ -344,6 +361,7 @@ export const TaskActivitySection = ({
|
|||
const result = await api.teams.getTaskActivity(teamName, taskId);
|
||||
if (!cancelled) {
|
||||
setEntries(result);
|
||||
hasLoadedRef.current = true;
|
||||
}
|
||||
} catch (loadError) {
|
||||
if (!cancelled) {
|
||||
|
|
@ -357,7 +375,7 @@ export const TaskActivitySection = ({
|
|||
}
|
||||
};
|
||||
|
||||
void load(true);
|
||||
void load(!hasLoadedRef.current);
|
||||
const intervalId = window.setInterval(() => {
|
||||
void load(false);
|
||||
}, 8000);
|
||||
|
|
@ -366,7 +384,7 @@ export const TaskActivitySection = ({
|
|||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [teamName, taskId]);
|
||||
}, [enabled, teamName, taskId]);
|
||||
|
||||
const visibleEntries = useMemo(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
|
||||
|
|
@ -14,8 +14,12 @@ import type {
|
|||
interface TaskLogStreamSectionProps {
|
||||
teamName: string;
|
||||
taskId: string;
|
||||
taskStatus?: string;
|
||||
liveEnabled?: boolean;
|
||||
}
|
||||
|
||||
const LIVE_RELOAD_DEBOUNCE_MS = 350;
|
||||
|
||||
function formatRelativeTime(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
|
|
@ -86,39 +90,160 @@ const SegmentBlock = ({
|
|||
export const TaskLogStreamSection = ({
|
||||
teamName,
|
||||
taskId,
|
||||
taskStatus,
|
||||
liveEnabled = true,
|
||||
}: TaskLogStreamSectionProps): React.JSX.Element => {
|
||||
const [stream, setStream] = useState<BoardTaskLogStreamResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedParticipantKey, setSelectedParticipantKey] = useState<'all' | string>('all');
|
||||
const requestSeqRef = useRef(0);
|
||||
const streamRef = useRef<BoardTaskLogStreamResponse | null>(null);
|
||||
const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
streamRef.current = stream;
|
||||
}, [stream]);
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
try {
|
||||
const loadStream = useCallback(
|
||||
async (options?: { resetSelection?: boolean; background?: boolean }): Promise<void> => {
|
||||
const resetSelection = options?.resetSelection ?? false;
|
||||
const background = options?.background ?? false;
|
||||
const hadExistingStream = streamRef.current != null;
|
||||
const requestSeq = requestSeqRef.current + 1;
|
||||
requestSeqRef.current = requestSeq;
|
||||
|
||||
if (!background) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
setError((prev) => (background ? prev : null));
|
||||
|
||||
try {
|
||||
const response = normalizeResponse(await api.teams.getTaskLogStream(teamName, taskId));
|
||||
if (cancelled) return;
|
||||
if (requestSeqRef.current !== requestSeq) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStream(response);
|
||||
setSelectedParticipantKey(response.defaultFilter);
|
||||
setSelectedParticipantKey((prev) => {
|
||||
if (resetSelection) {
|
||||
return response.defaultFilter;
|
||||
}
|
||||
const availableParticipantKeys = new Set([
|
||||
'all',
|
||||
...response.participants.map((participant) => participant.key),
|
||||
]);
|
||||
return availableParticipantKeys.has(prev) ? prev : response.defaultFilter;
|
||||
});
|
||||
setError(null);
|
||||
} catch (loadError) {
|
||||
if (cancelled) return;
|
||||
setError(loadError instanceof Error ? loadError.message : 'Failed to load task log stream');
|
||||
setStream(null);
|
||||
if (requestSeqRef.current !== requestSeq) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!background || streamRef.current == null) {
|
||||
setError(
|
||||
loadError instanceof Error ? loadError.message : 'Failed to load task log stream'
|
||||
);
|
||||
setStream(null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
if (requestSeqRef.current === requestSeq && (!background || !hadExistingStream)) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[taskId, teamName]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setStream(null);
|
||||
streamRef.current = null;
|
||||
setError(null);
|
||||
setSelectedParticipantKey('all');
|
||||
requestSeqRef.current += 1;
|
||||
if (reloadTimerRef.current) {
|
||||
clearTimeout(reloadTimerRef.current);
|
||||
reloadTimerRef.current = null;
|
||||
}
|
||||
void loadStream({ resetSelection: true });
|
||||
}, [loadStream]);
|
||||
|
||||
const previousTaskMetaRef = useRef({ taskId, taskStatus });
|
||||
|
||||
useEffect(() => {
|
||||
const previousTaskMeta = previousTaskMetaRef.current;
|
||||
previousTaskMetaRef.current = { taskId, taskStatus };
|
||||
|
||||
if (previousTaskMeta.taskId !== taskId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
previousTaskMeta.taskStatus === 'in_progress' &&
|
||||
taskStatus &&
|
||||
taskStatus !== 'in_progress'
|
||||
) {
|
||||
void loadStream({ background: true });
|
||||
}
|
||||
}, [loadStream, taskId, taskStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!liveEnabled) {
|
||||
if (reloadTimerRef.current) {
|
||||
clearTimeout(reloadTimerRef.current);
|
||||
reloadTimerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const scheduleReload = (): void => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
||||
return;
|
||||
}
|
||||
if (reloadTimerRef.current) {
|
||||
clearTimeout(reloadTimerRef.current);
|
||||
}
|
||||
reloadTimerRef.current = setTimeout(() => {
|
||||
reloadTimerRef.current = null;
|
||||
void loadStream({ background: true });
|
||||
}, LIVE_RELOAD_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
const unsubscribe = api.teams.onTeamChange?.((_event, event) => {
|
||||
if (
|
||||
event.teamName !== teamName ||
|
||||
event.type !== 'task-log-change' ||
|
||||
event.taskId !== taskId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
scheduleReload();
|
||||
});
|
||||
|
||||
const handleVisibilityChange = (): void => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
scheduleReload();
|
||||
}
|
||||
};
|
||||
}, [taskId, teamName]);
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
}, [liveEnabled, loadStream, taskId, teamName]);
|
||||
|
||||
const participants = stream?.participants ?? [];
|
||||
const showChips = participants.length > 1;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||
|
||||
import { ExecutionSessionsSection } from './ExecutionSessionsSection';
|
||||
|
|
@ -14,6 +15,7 @@ type TaskLogsTab = 'activity' | 'stream' | 'sessions';
|
|||
interface TaskLogsPanelProps {
|
||||
teamName: string;
|
||||
task: TeamTaskWithKanban;
|
||||
isOpen?: boolean;
|
||||
taskSince?: string;
|
||||
isExecutionRefreshing?: boolean;
|
||||
isExecutionPreviewOnline?: boolean;
|
||||
|
|
@ -21,11 +23,15 @@ interface TaskLogsPanelProps {
|
|||
showSubagentPreview?: boolean;
|
||||
showLeadPreview?: boolean;
|
||||
onPreviewOnlineChange?: (isOnline: boolean) => void;
|
||||
onTaskLogActivityChange?: (isActive: boolean) => void;
|
||||
}
|
||||
|
||||
const TASK_LOG_ACTIVITY_PULSE_MS = 1800;
|
||||
|
||||
export const TaskLogsPanel = ({
|
||||
teamName,
|
||||
task,
|
||||
isOpen = true,
|
||||
taskSince,
|
||||
isExecutionRefreshing = false,
|
||||
isExecutionPreviewOnline = false,
|
||||
|
|
@ -33,6 +39,7 @@ export const TaskLogsPanel = ({
|
|||
showSubagentPreview = false,
|
||||
showLeadPreview = false,
|
||||
onPreviewOnlineChange,
|
||||
onTaskLogActivityChange,
|
||||
}: TaskLogsPanelProps): React.JSX.Element => {
|
||||
const availableTabs = useMemo<TaskLogsTab[]>(() => {
|
||||
const tabs: TaskLogsTab[] = [];
|
||||
|
|
@ -48,6 +55,10 @@ export const TaskLogsPanel = ({
|
|||
|
||||
const defaultTab = availableTabs[0] ?? 'sessions';
|
||||
const [activeTab, setActiveTab] = useState<TaskLogsTab>(defaultTab);
|
||||
const [isTaskLogActivityActive, setIsTaskLogActivityActive] = useState(false);
|
||||
const [hasOpenedContent, setHasOpenedContent] = useState(isOpen);
|
||||
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const taskLogTrackingEnabled = task.status === 'in_progress' && availableTabs.includes('stream');
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab(defaultTab);
|
||||
|
|
@ -59,6 +70,77 @@ export const TaskLogsPanel = ({
|
|||
}
|
||||
}, [activeTab, availableTabs, defaultTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setHasOpenedContent(true);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
onTaskLogActivityChange?.(isTaskLogActivityActive);
|
||||
}, [isTaskLogActivityActive, onTaskLogActivityChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pulseTimerRef.current) {
|
||||
clearTimeout(pulseTimerRef.current);
|
||||
pulseTimerRef.current = null;
|
||||
}
|
||||
setIsTaskLogActivityActive(false);
|
||||
}, [task.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskLogTrackingEnabled || !api.teams.setTaskLogStreamTracking) {
|
||||
return;
|
||||
}
|
||||
|
||||
void Promise.resolve(api.teams.setTaskLogStreamTracking(teamName, true)).catch(() => undefined);
|
||||
return () => {
|
||||
void Promise.resolve(api.teams.setTaskLogStreamTracking(teamName, false)).catch(
|
||||
() => undefined
|
||||
);
|
||||
};
|
||||
}, [taskLogTrackingEnabled, teamName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskLogTrackingEnabled) {
|
||||
if (pulseTimerRef.current) {
|
||||
clearTimeout(pulseTimerRef.current);
|
||||
pulseTimerRef.current = null;
|
||||
}
|
||||
setIsTaskLogActivityActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = api.teams.onTeamChange?.((_event, event) => {
|
||||
if (
|
||||
event.teamName !== teamName ||
|
||||
event.type !== 'task-log-change' ||
|
||||
event.taskId !== task.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTaskLogActivityActive(true);
|
||||
if (pulseTimerRef.current) {
|
||||
clearTimeout(pulseTimerRef.current);
|
||||
}
|
||||
pulseTimerRef.current = setTimeout(() => {
|
||||
pulseTimerRef.current = null;
|
||||
setIsTaskLogActivityActive(false);
|
||||
}, TASK_LOG_ACTIVITY_PULSE_MS);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (pulseTimerRef.current) {
|
||||
clearTimeout(pulseTimerRef.current);
|
||||
pulseTimerRef.current = null;
|
||||
}
|
||||
if (typeof unsubscribe === 'function') {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
}, [task.id, taskLogTrackingEnabled, teamName]);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
|
|
@ -81,34 +163,42 @@ export const TaskLogsPanel = ({
|
|||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{availableTabs.includes('stream') ? (
|
||||
{availableTabs.includes('stream') && hasOpenedContent ? (
|
||||
<TabsContent value="stream" className="mt-0">
|
||||
<TaskLogStreamSection teamName={teamName} taskId={task.id} />
|
||||
<TaskLogStreamSection
|
||||
teamName={teamName}
|
||||
taskId={task.id}
|
||||
taskStatus={task.status}
|
||||
liveEnabled={isOpen && task.status === 'in_progress'}
|
||||
/>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{availableTabs.includes('activity') ? (
|
||||
{availableTabs.includes('activity') && hasOpenedContent ? (
|
||||
<TabsContent value="activity" className="mt-0">
|
||||
<TaskActivitySection teamName={teamName} taskId={task.id} />
|
||||
<TaskActivitySection teamName={teamName} taskId={task.id} enabled={isOpen} />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
<TabsContent value="sessions" className="mt-0">
|
||||
<ExecutionSessionsSection
|
||||
teamName={teamName}
|
||||
taskId={task.id}
|
||||
taskOwner={task.owner}
|
||||
taskStatus={task.status}
|
||||
taskWorkIntervals={task.workIntervals}
|
||||
taskSince={taskSince}
|
||||
isRefreshing={isExecutionRefreshing}
|
||||
isPreviewOnline={isExecutionPreviewOnline}
|
||||
onRefreshingChange={onRefreshingChange}
|
||||
showSubagentPreview={showSubagentPreview}
|
||||
showLeadPreview={showLeadPreview}
|
||||
onPreviewOnlineChange={onPreviewOnlineChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
{hasOpenedContent ? (
|
||||
<TabsContent value="sessions" className="mt-0">
|
||||
<ExecutionSessionsSection
|
||||
teamName={teamName}
|
||||
taskId={task.id}
|
||||
taskOwner={task.owner}
|
||||
taskStatus={task.status}
|
||||
taskWorkIntervals={task.workIntervals}
|
||||
taskSince={taskSince}
|
||||
isRefreshing={isExecutionRefreshing}
|
||||
isPreviewOnline={isExecutionPreviewOnline}
|
||||
enabled={isOpen}
|
||||
onRefreshingChange={onRefreshingChange}
|
||||
showSubagentPreview={showSubagentPreview}
|
||||
showLeadPreview={showLeadPreview}
|
||||
onPreviewOnlineChange={onPreviewOnlineChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -430,6 +430,7 @@ export interface TeamsAPI {
|
|||
getTaskChangePresence: (teamName: string) => Promise<Record<string, TaskChangePresenceState>>;
|
||||
setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise<void>;
|
||||
setToolActivityTracking: (teamName: string, enabled: boolean) => Promise<void>;
|
||||
setTaskLogStreamTracking: (teamName: string, enabled: boolean) => Promise<void>;
|
||||
getClaudeLogs: (teamName: string, query?: TeamClaudeLogsQuery) => Promise<TeamClaudeLogsResponse>;
|
||||
deleteTeam: (teamName: string) => Promise<void>;
|
||||
restoreTeam: (teamName: string) => Promise<void>;
|
||||
|
|
|
|||
|
|
@ -912,6 +912,7 @@ export interface TeamChangeEvent {
|
|||
| 'config'
|
||||
| 'inbox'
|
||||
| 'log-source-change'
|
||||
| 'task-log-change'
|
||||
| 'task'
|
||||
| 'lead-activity'
|
||||
| 'lead-context'
|
||||
|
|
@ -922,6 +923,7 @@ export interface TeamChangeEvent {
|
|||
teamName: string;
|
||||
runId?: string;
|
||||
detail?: string;
|
||||
taskId?: string;
|
||||
}
|
||||
|
||||
export interface ProjectBranchChangeEvent {
|
||||
|
|
|
|||
|
|
@ -586,6 +586,189 @@ describe('BoardTaskLogStreamService integration', () => {
|
|||
expect(toolNames).toContain('mcp__agent-teams__task_complete');
|
||||
});
|
||||
|
||||
it('sanitizes inferred SendMessage results instead of surfacing raw json payloads', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-inferred-sendmessage-'));
|
||||
tempDirs.push(dir);
|
||||
const transcriptPath = path.join(dir, 'session.jsonl');
|
||||
const task = createTask({
|
||||
owner: 'tom',
|
||||
workIntervals: [
|
||||
{
|
||||
startedAt: '2026-04-12T15:36:00.000Z',
|
||||
completedAt: '2026-04-12T15:40:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const lines = [
|
||||
createAssistantEntry({
|
||||
uuid: 'a-start',
|
||||
timestamp: '2026-04-12T15:36:00.000Z',
|
||||
requestId: 'req-start',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'call-task-start',
|
||||
name: 'mcp__agent-teams__task_start',
|
||||
input: {
|
||||
teamName: TEAM_NAME,
|
||||
taskId: TASK_ID,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
createUserEntry({
|
||||
uuid: 'u-start',
|
||||
timestamp: '2026-04-12T15:36:00.120Z',
|
||||
sourceToolAssistantUUID: 'a-start',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'call-task-start',
|
||||
content: 'ok',
|
||||
},
|
||||
],
|
||||
boardTaskLinks: [
|
||||
{
|
||||
schemaVersion: 1,
|
||||
toolUseId: 'call-task-start',
|
||||
task: {
|
||||
ref: TASK_ID,
|
||||
refKind: 'canonical',
|
||||
canonicalId: TASK_ID,
|
||||
},
|
||||
targetRole: 'subject',
|
||||
linkKind: 'lifecycle',
|
||||
taskArgumentSlot: 'taskId',
|
||||
actorContext: {
|
||||
relation: 'idle',
|
||||
},
|
||||
},
|
||||
],
|
||||
boardTaskToolActions: [
|
||||
{
|
||||
schemaVersion: 1,
|
||||
toolUseId: 'call-task-start',
|
||||
canonicalToolName: 'task_start',
|
||||
},
|
||||
],
|
||||
toolUseResult: {
|
||||
toolUseId: 'call-task-start',
|
||||
content: '{"id":"c414cd52"}',
|
||||
},
|
||||
}),
|
||||
createAssistantEntry({
|
||||
uuid: 'a-send',
|
||||
timestamp: '2026-04-12T15:36:10.000Z',
|
||||
requestId: 'req-send',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'call-send',
|
||||
name: 'SendMessage',
|
||||
input: {
|
||||
to: 'team-lead',
|
||||
summary: '#abc done',
|
||||
message: 'Detailed body',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
createUserEntry({
|
||||
uuid: 'u-send',
|
||||
timestamp: '2026-04-12T15:36:10.200Z',
|
||||
sourceToolAssistantUUID: 'a-send',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'call-send',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: "Message sent to team-lead's inbox",
|
||||
routing: {
|
||||
target: '@team-lead',
|
||||
summary: '#abc done',
|
||||
content: 'Detailed body',
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
toolUseResult: {
|
||||
success: true,
|
||||
message: "Message sent to team-lead's inbox",
|
||||
routing: {
|
||||
target: '@team-lead',
|
||||
summary: '#abc done',
|
||||
content: 'Detailed body',
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
await writeFile(
|
||||
transcriptPath,
|
||||
`${lines.map((line) => JSON.stringify(line)).join('\n')}\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const recordSource = {
|
||||
getTaskRecords: async () => buildRecordsFromTranscript(transcriptPath, task),
|
||||
};
|
||||
const taskReader = {
|
||||
getTasks: async () => [task],
|
||||
getDeletedTasks: async () => [] as TeamTask[],
|
||||
};
|
||||
const transcriptSourceLocator = {
|
||||
getContext: async () =>
|
||||
({
|
||||
transcriptFiles: [transcriptPath],
|
||||
config: {
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
}) as never,
|
||||
};
|
||||
|
||||
const service = new BoardTaskLogStreamService(
|
||||
recordSource as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
taskReader as never,
|
||||
transcriptSourceLocator as never,
|
||||
);
|
||||
const response = await service.getTaskLogStream(TEAM_NAME, task.id);
|
||||
const rawMessages = flattenRawMessages(response);
|
||||
const sendResult = rawMessages.find((message) => message.uuid === 'u-send');
|
||||
const semanticToolResult = response.segments
|
||||
.flatMap((segment) => segment.chunks)
|
||||
.flatMap((chunk) => ('semanticSteps' in chunk ? (chunk.semanticSteps ?? []) : []))
|
||||
.find((step) => step.type === 'tool_result' && step.id === 'call-send');
|
||||
|
||||
expect(rawMessages.flatMap((message) => message.toolCalls.map((toolCall) => toolCall.name))).toContain(
|
||||
'SendMessage'
|
||||
);
|
||||
expect(sendResult?.toolResults).toEqual([
|
||||
{
|
||||
toolUseId: 'call-send',
|
||||
content: "Message sent to team-lead's inbox - #abc done",
|
||||
isError: false,
|
||||
},
|
||||
]);
|
||||
expect(semanticToolResult).toMatchObject({
|
||||
id: 'call-send',
|
||||
type: 'tool_result',
|
||||
content: expect.objectContaining({
|
||||
toolResultContent: "Message sent to team-lead's inbox - #abc done",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a real-format transcript fixture and surfaces fallback worker logs for the task owner only', async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-real-fixture-'));
|
||||
tempDirs.push(dir);
|
||||
|
|
|
|||
|
|
@ -630,4 +630,154 @@ describe('BoardTaskLogStreamService', () => {
|
|||
});
|
||||
expect(toolResultMessage?.toolUseResult).toEqual({ toolUseId: 'tool-1', content: 'useful comment' });
|
||||
});
|
||||
|
||||
it('sanitizes SendMessage json payloads into a concise human-readable result', async () => {
|
||||
const bob = {
|
||||
memberName: 'bob',
|
||||
role: 'member' as const,
|
||||
sessionId: 'session-bob',
|
||||
agentId: 'agent-bob',
|
||||
isSidechain: true,
|
||||
};
|
||||
const candidate = {
|
||||
...makeCandidate('c1', '2026-04-12T16:00:00.000Z', bob, 'tool-send'),
|
||||
actionCategory: 'execution' as const,
|
||||
canonicalToolName: 'SendMessage',
|
||||
};
|
||||
|
||||
const recordSource = {
|
||||
getTaskRecords: vi.fn(async () => candidate.records),
|
||||
};
|
||||
const summarySelector = {
|
||||
selectSummaries: vi.fn(() => [candidate]),
|
||||
};
|
||||
const strictParser = {
|
||||
parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])),
|
||||
};
|
||||
const detailSelector = {
|
||||
selectDetail: vi.fn(() => ({
|
||||
id: 'c1',
|
||||
timestamp: '2026-04-12T16:00:00.000Z',
|
||||
actor: bob,
|
||||
source: {
|
||||
filePath: '/tmp/task.jsonl',
|
||||
messageUuid: 'assistant-send',
|
||||
toolUseId: 'tool-send',
|
||||
sourceOrder: 1,
|
||||
},
|
||||
records: candidate.records,
|
||||
filteredMessages: [
|
||||
{
|
||||
uuid: 'assistant-send',
|
||||
parentUuid: null,
|
||||
type: 'assistant' as const,
|
||||
timestamp: new Date('2026-04-12T16:00:00.000Z'),
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool-send',
|
||||
name: 'SendMessage',
|
||||
input: { to: 'team-lead', summary: '#abc done' },
|
||||
} as never,
|
||||
],
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
isMeta: false,
|
||||
isCompactSummary: false,
|
||||
},
|
||||
{
|
||||
uuid: 'user-send-result',
|
||||
parentUuid: 'assistant-send',
|
||||
type: 'user' as const,
|
||||
timestamp: new Date('2026-04-12T16:00:02.000Z'),
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-send',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: "Message sent to team-lead's inbox",
|
||||
routing: {
|
||||
target: '@team-lead',
|
||||
summary: '#abc done',
|
||||
content: 'Detailed body that should not leak into the preview.',
|
||||
},
|
||||
}),
|
||||
} as never,
|
||||
],
|
||||
} as never,
|
||||
],
|
||||
toolCalls: [],
|
||||
toolResults: [
|
||||
{
|
||||
toolUseId: 'tool-send',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({
|
||||
success: true,
|
||||
message: "Message sent to team-lead's inbox",
|
||||
routing: {
|
||||
target: '@team-lead',
|
||||
summary: '#abc done',
|
||||
content: 'Detailed body that should not leak into the preview.',
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
},
|
||||
],
|
||||
sourceToolUseID: 'tool-send',
|
||||
sourceToolAssistantUUID: 'assistant-send',
|
||||
toolUseResult: {
|
||||
success: true,
|
||||
message: "Message sent to team-lead's inbox",
|
||||
routing: {
|
||||
target: '@team-lead',
|
||||
summary: '#abc done',
|
||||
content: 'Detailed body that should not leak into the preview.',
|
||||
},
|
||||
},
|
||||
isSidechain: false,
|
||||
isMeta: false,
|
||||
isCompactSummary: false,
|
||||
},
|
||||
],
|
||||
})),
|
||||
};
|
||||
const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]);
|
||||
|
||||
const service = new BoardTaskLogStreamService(
|
||||
recordSource as never,
|
||||
summarySelector as never,
|
||||
strictParser as never,
|
||||
detailSelector as never,
|
||||
{ buildBundleChunks } as never,
|
||||
);
|
||||
|
||||
await service.getTaskLogStream('demo', 'task-a');
|
||||
|
||||
const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[];
|
||||
const toolResultMessage = mergedMessages.find((message) => message.uuid === 'user-send-result');
|
||||
const content = Array.isArray(toolResultMessage?.content) ? toolResultMessage.content : [];
|
||||
expect(content[0]).toMatchObject({
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-send',
|
||||
content: "Message sent to team-lead's inbox - #abc done",
|
||||
});
|
||||
expect(toolResultMessage?.toolResults).toEqual([
|
||||
{
|
||||
toolUseId: 'tool-send',
|
||||
content: "Message sent to team-lead's inbox - #abc done",
|
||||
isError: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3437,7 +3437,7 @@ describe('TeamDataService', () => {
|
|||
expect(persistedConfig.projectPath).toBe(fixture.staleProjectPath);
|
||||
});
|
||||
|
||||
it('uses resolver-discovered session ids when config has no leadSessionId or sessionHistory', async () => {
|
||||
it('does not guess lead_session messages from resolver-discovered session ids when config has no leadSessionId or sessionHistory', async () => {
|
||||
const fixture = await createResolverBackedLeadFixture({
|
||||
leadSessionId: undefined,
|
||||
sessionFileId: 'lead-discovered',
|
||||
|
|
@ -3446,13 +3446,48 @@ describe('TeamDataService', () => {
|
|||
|
||||
const page = await service.getMessagesPage(fixture.teamName, { limit: 10 });
|
||||
|
||||
expect(page.messages.some((message) => message.source === 'lead_session')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not mix resolver-discovered non-lead session ids into durable lead_session messages when config already knows the lead session', async () => {
|
||||
const fixture = await createResolverBackedLeadFixture();
|
||||
await fs.writeFile(
|
||||
path.join(fixture.actualProjectDir, 'member-1.jsonl'),
|
||||
`${JSON.stringify({
|
||||
teamName: fixture.teamName,
|
||||
type: 'assistant',
|
||||
timestamp: '2026-04-18T10:05:00.000Z',
|
||||
cwd: fixture.actualProjectPath,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Member bootstrap noise that should never appear as a lead_session thought in the team activity timeline.',
|
||||
},
|
||||
],
|
||||
},
|
||||
})}\n`,
|
||||
'utf8'
|
||||
);
|
||||
const service = createResolverBackedService();
|
||||
|
||||
const page = await service.getMessagesPage(fixture.teamName, { limit: 20 });
|
||||
const leadSessionMessages = page.messages.filter((message) => message.source === 'lead_session');
|
||||
|
||||
expect(
|
||||
page.messages.find(
|
||||
(message) =>
|
||||
message.source === 'lead_session' &&
|
||||
message.text.includes('recovered through the transcript resolver')
|
||||
leadSessionMessages.some((message) =>
|
||||
message.text.includes('recovered through the transcript resolver')
|
||||
)
|
||||
).toBeTruthy();
|
||||
).toBe(true);
|
||||
expect(
|
||||
leadSessionMessages.some((message) =>
|
||||
message.text.includes('Member bootstrap noise that should never appear')
|
||||
)
|
||||
).toBe(false);
|
||||
expect(new Set(leadSessionMessages.map((message) => message.leadSessionId))).toEqual(
|
||||
new Set(['lead-1'])
|
||||
);
|
||||
});
|
||||
|
||||
it('fails fast when config is missing before any read-phase step starts', async () => {
|
||||
|
|
|
|||
119
test/main/services/team/TeamLogSourceTracker.test.ts
Normal file
119
test/main/services/team/TeamLogSourceTracker.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { TeamLogSourceTracker } from '../../../../src/main/services/team/TeamLogSourceTracker';
|
||||
|
||||
import type { TeamMemberLogsFinder } from '../../../../src/main/services/team/TeamMemberLogsFinder';
|
||||
import type { TeamChangeEvent } from '../../../../src/shared/types';
|
||||
|
||||
describe('TeamLogSourceTracker', () => {
|
||||
let tempDir: string | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('emits task-log-change for matching runtime freshness signals without broad log-source-change', async () => {
|
||||
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-'));
|
||||
|
||||
const logsFinder = {
|
||||
getLogSourceWatchContext: vi.fn(async () => ({
|
||||
projectDir: tempDir!,
|
||||
sessionIds: [],
|
||||
})),
|
||||
} as unknown as TeamMemberLogsFinder;
|
||||
|
||||
const tracker = new TeamLogSourceTracker(logsFinder);
|
||||
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
|
||||
tracker.setEmitter(emitter);
|
||||
|
||||
await tracker.enableTracking('demo', 'change_presence');
|
||||
emitter.mockClear();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const taskId = '123e4567-e89b-12d3-a456-426614174999';
|
||||
const signalDir = path.join(tempDir, '.board-task-log-freshness');
|
||||
await mkdir(signalDir, { recursive: true });
|
||||
await writeFile(path.join(signalDir, `${encodeURIComponent(taskId)}.json`), '{"ok":true}');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(emitter).toHaveBeenCalledWith({
|
||||
type: 'task-log-change',
|
||||
teamName: 'demo',
|
||||
taskId,
|
||||
});
|
||||
});
|
||||
|
||||
expect(emitter.mock.calls.map(([event]) => event.type)).not.toContain('log-source-change');
|
||||
|
||||
await tracker.disableTracking('demo', 'change_presence');
|
||||
});
|
||||
|
||||
it('keeps task-log tracking alive until the last consumer unsubscribes', async () => {
|
||||
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-refcount-'));
|
||||
|
||||
const logsFinder = {
|
||||
getLogSourceWatchContext: vi.fn(async () => ({
|
||||
projectDir: tempDir!,
|
||||
sessionIds: [],
|
||||
})),
|
||||
} as unknown as TeamMemberLogsFinder;
|
||||
|
||||
const tracker = new TeamLogSourceTracker(logsFinder);
|
||||
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
|
||||
tracker.setEmitter(emitter);
|
||||
|
||||
await tracker.enableTracking('demo', 'task_log_stream');
|
||||
await tracker.enableTracking('demo', 'task_log_stream');
|
||||
emitter.mockClear();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
await tracker.disableTracking('demo', 'task_log_stream');
|
||||
|
||||
const taskId = '223e4567-e89b-12d3-a456-426614174999';
|
||||
const signalDir = path.join(tempDir, '.board-task-log-freshness');
|
||||
await mkdir(signalDir, { recursive: true });
|
||||
await writeFile(path.join(signalDir, `${encodeURIComponent(taskId)}.json`), '{"ok":true}');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(emitter).toHaveBeenCalledWith({
|
||||
type: 'task-log-change',
|
||||
teamName: 'demo',
|
||||
taskId,
|
||||
});
|
||||
});
|
||||
|
||||
emitter.mockClear();
|
||||
await tracker.disableTracking('demo', 'task_log_stream');
|
||||
await writeFile(path.join(signalDir, `${encodeURIComponent(taskId)}.json`), '{"ok":false}');
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
|
||||
expect(emitter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not reinitialize when another consumer joins an already tracked team', async () => {
|
||||
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-init-'));
|
||||
|
||||
const logsFinder = {
|
||||
getLogSourceWatchContext: vi.fn(async () => ({
|
||||
projectDir: tempDir!,
|
||||
sessionIds: [],
|
||||
})),
|
||||
} as unknown as TeamMemberLogsFinder;
|
||||
|
||||
const tracker = new TeamLogSourceTracker(logsFinder);
|
||||
|
||||
await tracker.enableTracking('demo', 'tool_activity');
|
||||
await tracker.enableTracking('demo', 'task_log_stream');
|
||||
|
||||
expect(logsFinder.getLogSourceWatchContext).toHaveBeenCalledTimes(1);
|
||||
|
||||
await tracker.disableTracking('demo', 'task_log_stream');
|
||||
await tracker.disableTracking('demo', 'tool_activity');
|
||||
});
|
||||
});
|
||||
156
test/renderer/components/team/activity/ActivityTimeline.test.ts
Normal file
156
test/renderer/components/team/activity/ActivityTimeline.test.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import React from 'react';
|
||||
import { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { InboxMessage } from '@shared/types';
|
||||
|
||||
vi.mock('@renderer/components/team/activity/ActivityItem', () => ({
|
||||
ActivityItem: ({ message }: { message: InboxMessage }) =>
|
||||
React.createElement('div', { 'data-testid': 'activity-item' }, message.text),
|
||||
isNoiseMessage: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/activity/AnimatedHeightReveal', () => ({
|
||||
ENTRY_REVEAL_ANIMATION_MS: 220,
|
||||
AnimatedHeightReveal: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/activity/useNewItemKeys', () => ({
|
||||
useNewItemKeys: () => new Set<string>(),
|
||||
}));
|
||||
|
||||
import { ActivityTimeline } from '@renderer/components/team/activity/ActivityTimeline';
|
||||
|
||||
function makeMessage(overrides: Partial<InboxMessage> = {}): InboxMessage {
|
||||
return {
|
||||
from: 'team-lead',
|
||||
text: 'message',
|
||||
timestamp: '2026-04-18T13:00:00.000Z',
|
||||
read: true,
|
||||
source: 'inbox',
|
||||
messageId: 'message-id',
|
||||
leadSessionId: 'lead-session-1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ActivityTimeline session separators', () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('does not render New session for regular message rows even when their session ids differ', async () => {
|
||||
const root = createRoot(container);
|
||||
const messages: InboxMessage[] = [
|
||||
makeMessage({
|
||||
messageId: 'member-newest',
|
||||
text: 'member newest',
|
||||
leadSessionId: 'member-session-2',
|
||||
from: 'alice',
|
||||
source: 'inbox',
|
||||
}),
|
||||
makeMessage({
|
||||
messageId: 'member-older',
|
||||
text: 'member older',
|
||||
leadSessionId: 'member-session-1',
|
||||
from: 'alice',
|
||||
source: 'inbox',
|
||||
}),
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(ActivityTimeline, { messages, teamName: 'demo-team' }));
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain('New session');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders New session between lead thought groups from different sessions', async () => {
|
||||
const root = createRoot(container);
|
||||
const messages: InboxMessage[] = [
|
||||
makeMessage({
|
||||
messageId: 'thought-newest',
|
||||
text: 'lead thought newest',
|
||||
leadSessionId: 'lead-session-2',
|
||||
from: 'team-lead',
|
||||
source: 'lead_session',
|
||||
}),
|
||||
makeMessage({
|
||||
messageId: 'regular-between',
|
||||
text: 'regular message between sessions',
|
||||
leadSessionId: 'member-session-1',
|
||||
from: 'alice',
|
||||
source: 'inbox',
|
||||
}),
|
||||
makeMessage({
|
||||
messageId: 'thought-older',
|
||||
text: 'lead thought older',
|
||||
leadSessionId: 'lead-session-1',
|
||||
from: 'team-lead',
|
||||
source: 'lead_session',
|
||||
}),
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(ActivityTimeline, { messages, teamName: 'demo-team' }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('New session');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('still renders New session when the newest thought belongs to currentLeadSessionId', async () => {
|
||||
const root = createRoot(container);
|
||||
const messages: InboxMessage[] = [
|
||||
makeMessage({
|
||||
messageId: 'thought-current',
|
||||
text: 'current lead thought',
|
||||
leadSessionId: 'lead-session-current',
|
||||
from: 'team-lead',
|
||||
source: 'lead_session',
|
||||
}),
|
||||
makeMessage({
|
||||
messageId: 'thought-history',
|
||||
text: 'historical lead thought',
|
||||
leadSessionId: 'lead-session-history',
|
||||
from: 'team-lead',
|
||||
source: 'lead_session',
|
||||
}),
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(ActivityTimeline, {
|
||||
messages,
|
||||
teamName: 'demo-team',
|
||||
currentLeadSessionId: 'lead-session-current',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('New session');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
129
test/renderer/components/team/members/MemberExecutionLog.test.ts
Normal file
129
test/renderer/components/team/members/MemberExecutionLog.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const transformState = {
|
||||
items: [] as Array<{ type: 'ai'; group: Record<string, unknown> }>,
|
||||
};
|
||||
|
||||
const enhanceState = {
|
||||
value: null as null | Record<string, unknown>,
|
||||
};
|
||||
|
||||
vi.mock('@renderer/utils/groupTransformer', () => ({
|
||||
transformChunksToConversation: () => ({
|
||||
items: transformState.items,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/utils/aiGroupEnhancer', () => ({
|
||||
enhanceAIGroup: (group: Record<string, unknown>) => ({
|
||||
...group,
|
||||
...(enhanceState.value ?? {}),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/chat/LastOutputDisplay', () => ({
|
||||
LastOutputDisplay: ({ lastOutput }: { lastOutput: unknown }) => {
|
||||
if (!lastOutput) {
|
||||
return null;
|
||||
}
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'last-output' },
|
||||
JSON.stringify(lastOutput)
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
|
||||
|
||||
function flushMicrotasks(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function setSingleAiGroup(): void {
|
||||
transformState.items = [
|
||||
{
|
||||
type: 'ai',
|
||||
group: {
|
||||
id: 'group-1',
|
||||
steps: [],
|
||||
responses: [],
|
||||
processes: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe('MemberExecutionLog', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
transformState.items = [];
|
||||
enhanceState.value = null;
|
||||
});
|
||||
|
||||
it('suppresses duplicated last tool_result banners in execution-log mode', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
setSingleAiGroup();
|
||||
enhanceState.value = {
|
||||
displayItems: [],
|
||||
itemsSummary: '1 tool',
|
||||
lastOutput: {
|
||||
type: 'tool_result',
|
||||
toolName: 'Read',
|
||||
toolResult: 'raw file body',
|
||||
isError: false,
|
||||
timestamp: new Date('2026-04-18T13:23:12.982Z'),
|
||||
},
|
||||
};
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(MemberExecutionLog, { chunks: [] }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-testid="last-output"]')).toBeNull();
|
||||
expect(host.textContent).not.toContain('raw file body');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps plain text last output visible', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
setSingleAiGroup();
|
||||
enhanceState.value = {
|
||||
displayItems: [],
|
||||
itemsSummary: '1 output',
|
||||
lastOutput: {
|
||||
type: 'text',
|
||||
text: 'final answer',
|
||||
timestamp: new Date('2026-04-18T13:23:12.982Z'),
|
||||
},
|
||||
};
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(MemberExecutionLog, { chunks: [] }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-testid="last-output"]')).not.toBeNull();
|
||||
expect(host.textContent).toContain('final answer');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -211,7 +211,6 @@ describe('MessagesPanel idle summary invariants', () => {
|
|||
members: [],
|
||||
tasks: [],
|
||||
timeWindow: null,
|
||||
teamSessionIds: new Set<string>(),
|
||||
pendingRepliesByMember: {},
|
||||
onPendingReplyChange: vi.fn(),
|
||||
})
|
||||
|
|
@ -272,7 +271,6 @@ describe('MessagesPanel idle summary invariants', () => {
|
|||
members: [],
|
||||
tasks: [],
|
||||
timeWindow: null,
|
||||
teamSessionIds: new Set<string>(),
|
||||
pendingRepliesByMember: { alice: pendingSentAtMs },
|
||||
onPendingReplyChange,
|
||||
})
|
||||
|
|
@ -327,7 +325,6 @@ describe('MessagesPanel idle summary invariants', () => {
|
|||
members: [],
|
||||
tasks: [],
|
||||
timeWindow: null,
|
||||
teamSessionIds: new Set<string>(),
|
||||
pendingRepliesByMember: { alice: pendingSentAtMs },
|
||||
onPendingReplyChange,
|
||||
})
|
||||
|
|
@ -376,7 +373,6 @@ describe('MessagesPanel idle summary invariants', () => {
|
|||
members: [],
|
||||
tasks: [],
|
||||
timeWindow: null,
|
||||
teamSessionIds: new Set<string>(),
|
||||
pendingRepliesByMember: {},
|
||||
onPendingReplyChange: vi.fn(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -241,6 +241,120 @@ describe('TaskActivitySection', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not load activity while disabled', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(TaskActivitySection, {
|
||||
teamName: 'demo',
|
||||
taskId: 'task-a',
|
||||
enabled: false,
|
||||
})
|
||||
);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(apiState.getTaskActivity).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves loaded activity while disabled and refreshes again on re-enable', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
apiState.getTaskActivity
|
||||
.mockResolvedValueOnce([
|
||||
makeEntry({
|
||||
id: 'started',
|
||||
timestamp: '2026-04-13T10:34:00.000Z',
|
||||
linkKind: 'lifecycle',
|
||||
action: {
|
||||
canonicalToolName: 'task_start',
|
||||
category: 'status',
|
||||
},
|
||||
}),
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
makeEntry({
|
||||
id: 'started',
|
||||
timestamp: '2026-04-13T10:34:00.000Z',
|
||||
linkKind: 'lifecycle',
|
||||
action: {
|
||||
canonicalToolName: 'task_start',
|
||||
category: 'status',
|
||||
},
|
||||
}),
|
||||
makeEntry({
|
||||
id: 'viewed',
|
||||
timestamp: '2026-04-13T10:35:00.000Z',
|
||||
linkKind: 'board_action',
|
||||
action: {
|
||||
canonicalToolName: 'task_get',
|
||||
category: 'read',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(TaskActivitySection, {
|
||||
teamName: 'demo',
|
||||
taskId: 'task-a',
|
||||
enabled: true,
|
||||
})
|
||||
);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Started work');
|
||||
expect(apiState.getTaskActivity).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(TaskActivitySection, {
|
||||
teamName: 'demo',
|
||||
taskId: 'task-a',
|
||||
enabled: false,
|
||||
})
|
||||
);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Started work');
|
||||
expect(apiState.getTaskActivity).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(TaskActivitySection, {
|
||||
teamName: 'demo',
|
||||
taskId: 'task-a',
|
||||
enabled: true,
|
||||
})
|
||||
);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Started work');
|
||||
expect(host.textContent).toContain('Viewed task');
|
||||
expect(apiState.getTaskActivity).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
});
|
||||
|
||||
it('loads inline detail lazily and renders metadata plus a linked tool card', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
apiState.getTaskActivity.mockResolvedValue([
|
||||
|
|
|
|||
|
|
@ -404,8 +404,8 @@ describe('TaskLogStreamSection integration', () => {
|
|||
expect(text).toContain('Edit');
|
||||
expect(text).toContain('Claude');
|
||||
expect(text).toContain('3 tool calls');
|
||||
expect(text).toContain('Audit complete');
|
||||
expect(text).not.toContain('[]');
|
||||
expect(text).not.toContain('Audit complete');
|
||||
expect(text).not.toContain('lead session');
|
||||
|
||||
await act(async () => {
|
||||
|
|
|
|||
|
|
@ -2,12 +2,15 @@ import React, { act } from 'react';
|
|||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { TeamChangeEvent } from '../../../../../src/shared/types';
|
||||
import type { BoardTaskLogStreamResponse } from '../../../../../src/shared/types';
|
||||
|
||||
const apiState = {
|
||||
getTaskLogStream: vi.fn<
|
||||
(teamName: string, taskId: string) => Promise<BoardTaskLogStreamResponse>
|
||||
>(),
|
||||
onTeamChange: vi.fn<(callback: (event: unknown, data: TeamChangeEvent) => void) => () => void>(),
|
||||
setTaskLogStreamTracking: vi.fn<(teamName: string, enabled: boolean) => Promise<void>>(),
|
||||
};
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
|
|
@ -15,6 +18,10 @@ vi.mock('@renderer/api', () => ({
|
|||
teams: {
|
||||
getTaskLogStream: (...args: Parameters<typeof apiState.getTaskLogStream>) =>
|
||||
apiState.getTaskLogStream(...args),
|
||||
onTeamChange: (...args: Parameters<typeof apiState.onTeamChange>) =>
|
||||
apiState.onTeamChange(...args),
|
||||
setTaskLogStreamTracking: (...args: Parameters<typeof apiState.setTaskLogStreamTracking>) =>
|
||||
apiState.setTaskLogStreamTracking(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
|
@ -40,10 +47,46 @@ function flushMicrotasks(): Promise<void> {
|
|||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function buildParticipant(key: string, label: string) {
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
role: 'member' as const,
|
||||
isLead: false,
|
||||
isSidechain: true,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSegment(args: {
|
||||
id: string;
|
||||
participantKey: string;
|
||||
memberName: string;
|
||||
startTimestamp: string;
|
||||
endTimestamp: string;
|
||||
}) {
|
||||
return {
|
||||
id: args.id,
|
||||
participantKey: args.participantKey,
|
||||
actor: {
|
||||
memberName: args.memberName,
|
||||
role: 'member' as const,
|
||||
sessionId: `${args.memberName}-session-${args.id}`,
|
||||
agentId: `${args.memberName}-agent`,
|
||||
isSidechain: true,
|
||||
},
|
||||
startTimestamp: args.startTimestamp,
|
||||
endTimestamp: args.endTimestamp,
|
||||
chunks: [{ id: `chunk-${args.id}`, chunkType: 'user', rawMessages: [] }] as never,
|
||||
};
|
||||
}
|
||||
|
||||
describe('TaskLogStreamSection', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
apiState.getTaskLogStream.mockReset();
|
||||
apiState.onTeamChange.mockReset();
|
||||
apiState.setTaskLogStreamTracking.mockReset();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
|
|
@ -175,6 +218,7 @@ describe('TaskLogStreamSection', () => {
|
|||
|
||||
it('honors a participant default filter from the stream response', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
apiState.onTeamChange.mockImplementation(() => () => undefined);
|
||||
apiState.getTaskLogStream.mockResolvedValueOnce({
|
||||
participants: [
|
||||
{
|
||||
|
|
@ -220,4 +264,248 @@ describe('TaskLogStreamSection', () => {
|
|||
await flushMicrotasks();
|
||||
});
|
||||
});
|
||||
|
||||
it('live-refreshes on matching task-log changes and preserves the selected participant filter', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
vi.useFakeTimers();
|
||||
|
||||
let handler: ((event: unknown, data: TeamChangeEvent) => void) | null = null;
|
||||
apiState.onTeamChange.mockImplementation((callback) => {
|
||||
handler = callback;
|
||||
return () => {
|
||||
handler = null;
|
||||
};
|
||||
});
|
||||
|
||||
apiState.getTaskLogStream
|
||||
.mockResolvedValueOnce({
|
||||
participants: [
|
||||
buildParticipant('member:tom', 'tom'),
|
||||
buildParticipant('member:alice', 'alice'),
|
||||
],
|
||||
defaultFilter: 'all',
|
||||
segments: [
|
||||
buildSegment({
|
||||
id: 'tom-1',
|
||||
participantKey: 'member:tom',
|
||||
memberName: 'tom',
|
||||
startTimestamp: '2026-04-12T16:00:00.000Z',
|
||||
endTimestamp: '2026-04-12T16:01:00.000Z',
|
||||
}),
|
||||
buildSegment({
|
||||
id: 'alice-1',
|
||||
participantKey: 'member:alice',
|
||||
memberName: 'alice',
|
||||
startTimestamp: '2026-04-12T16:02:00.000Z',
|
||||
endTimestamp: '2026-04-12T16:03:00.000Z',
|
||||
}),
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
participants: [
|
||||
buildParticipant('member:tom', 'tom'),
|
||||
buildParticipant('member:alice', 'alice'),
|
||||
],
|
||||
defaultFilter: 'all',
|
||||
segments: [
|
||||
buildSegment({
|
||||
id: 'tom-1',
|
||||
participantKey: 'member:tom',
|
||||
memberName: 'tom',
|
||||
startTimestamp: '2026-04-12T16:00:00.000Z',
|
||||
endTimestamp: '2026-04-12T16:01:00.000Z',
|
||||
}),
|
||||
buildSegment({
|
||||
id: 'alice-1',
|
||||
participantKey: 'member:alice',
|
||||
memberName: 'alice',
|
||||
startTimestamp: '2026-04-12T16:02:00.000Z',
|
||||
endTimestamp: '2026-04-12T16:03:00.000Z',
|
||||
}),
|
||||
buildSegment({
|
||||
id: 'tom-2',
|
||||
participantKey: 'member:tom',
|
||||
memberName: 'tom',
|
||||
startTimestamp: '2026-04-12T16:04:00.000Z',
|
||||
endTimestamp: '2026-04-12T16:05:00.000Z',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(TaskLogStreamSection, { teamName: 'demo', taskId: 'task-a' }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
const tomButton = [...host.querySelectorAll('button')].find(
|
||||
(button) => button.textContent?.trim() === 'tom'
|
||||
);
|
||||
expect(tomButton).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
tomButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(
|
||||
[...host.querySelectorAll('[data-testid="member-execution-log"]')].map((node) => node.textContent)
|
||||
).toEqual(['tom:1']);
|
||||
|
||||
expect(handler).toBeTypeOf('function');
|
||||
|
||||
await act(async () => {
|
||||
handler?.(null, { teamName: 'other-team', type: 'task-log-change', taskId: 'task-a' });
|
||||
vi.advanceTimersByTime(400);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-b' });
|
||||
vi.advanceTimersByTime(400);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-a' });
|
||||
vi.advanceTimersByTime(400);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
[...host.querySelectorAll('[data-testid="member-execution-log"]')].map((node) => node.textContent)
|
||||
).toEqual(['tom:1', 'tom:1']);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not subscribe to live refresh when live mode is disabled', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
apiState.onTeamChange.mockImplementation(() => () => undefined);
|
||||
apiState.getTaskLogStream.mockResolvedValueOnce({
|
||||
participants: [buildParticipant('member:tom', 'tom')],
|
||||
defaultFilter: 'all',
|
||||
segments: [
|
||||
buildSegment({
|
||||
id: 'tom-1',
|
||||
participantKey: 'member:tom',
|
||||
memberName: 'tom',
|
||||
startTimestamp: '2026-04-12T16:00:00.000Z',
|
||||
endTimestamp: '2026-04-12T16:01:00.000Z',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(TaskLogStreamSection, {
|
||||
teamName: 'demo',
|
||||
taskId: 'task-a',
|
||||
liveEnabled: false,
|
||||
})
|
||||
);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1);
|
||||
expect(apiState.onTeamChange).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
});
|
||||
|
||||
it('revalidates once when the task leaves in-progress state', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
apiState.getTaskLogStream
|
||||
.mockResolvedValueOnce({
|
||||
participants: [buildParticipant('member:tom', 'tom')],
|
||||
defaultFilter: 'all',
|
||||
segments: [
|
||||
buildSegment({
|
||||
id: 'tom-1',
|
||||
participantKey: 'member:tom',
|
||||
memberName: 'tom',
|
||||
startTimestamp: '2026-04-12T16:00:00.000Z',
|
||||
endTimestamp: '2026-04-12T16:01:00.000Z',
|
||||
}),
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
participants: [buildParticipant('member:tom', 'tom')],
|
||||
defaultFilter: 'all',
|
||||
segments: [
|
||||
buildSegment({
|
||||
id: 'tom-1',
|
||||
participantKey: 'member:tom',
|
||||
memberName: 'tom',
|
||||
startTimestamp: '2026-04-12T16:00:00.000Z',
|
||||
endTimestamp: '2026-04-12T16:01:00.000Z',
|
||||
}),
|
||||
buildSegment({
|
||||
id: 'tom-2',
|
||||
participantKey: 'member:tom',
|
||||
memberName: 'tom',
|
||||
startTimestamp: '2026-04-12T16:02:00.000Z',
|
||||
endTimestamp: '2026-04-12T16:03:00.000Z',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(TaskLogStreamSection, {
|
||||
teamName: 'demo',
|
||||
taskId: 'task-a',
|
||||
taskStatus: 'in_progress',
|
||||
liveEnabled: true,
|
||||
})
|
||||
);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(TaskLogStreamSection, {
|
||||
teamName: 'demo',
|
||||
taskId: 'task-a',
|
||||
taskStatus: 'completed',
|
||||
liveEnabled: false,
|
||||
})
|
||||
);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(apiState.getTaskLogStream).toHaveBeenCalledTimes(2);
|
||||
expect(host.querySelectorAll('[data-testid="member-execution-log"]')).toHaveLength(2);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,25 +4,61 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import { TaskLogsPanel } from '../../../../../src/renderer/components/team/taskLogs/TaskLogsPanel';
|
||||
|
||||
import type { TeamChangeEvent } from '../../../../../src/shared/types';
|
||||
import type { TeamTaskWithKanban } from '../../../../../src/shared/types';
|
||||
|
||||
const apiState = {
|
||||
onTeamChange: vi.fn<(callback: (event: unknown, data: TeamChangeEvent) => void) => () => void>(),
|
||||
setTaskLogStreamTracking: vi.fn<(teamName: string, enabled: boolean) => Promise<void>>(),
|
||||
};
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
api: {
|
||||
teams: {
|
||||
onTeamChange: (...args: Parameters<typeof apiState.onTeamChange>) =>
|
||||
apiState.onTeamChange(...args),
|
||||
setTaskLogStreamTracking: (...args: Parameters<typeof apiState.setTaskLogStreamTracking>) =>
|
||||
apiState.setTaskLogStreamTracking(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const featureGateState = {
|
||||
activityEnabled: true,
|
||||
exactLogsEnabled: true,
|
||||
};
|
||||
|
||||
const taskActivityProps = vi.hoisted(() => ({
|
||||
calls: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock('../../../../../src/renderer/components/team/taskLogs/TaskActivitySection', () => ({
|
||||
TaskActivitySection: () => React.createElement('div', { 'data-testid': 'task-activity' }, 'activity'),
|
||||
TaskActivitySection: (props: Record<string, unknown>) => {
|
||||
taskActivityProps.calls.push(props);
|
||||
return React.createElement('div', { 'data-testid': 'task-activity' }, 'activity');
|
||||
},
|
||||
}));
|
||||
|
||||
const taskLogStreamProps = vi.hoisted(() => ({
|
||||
calls: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
const executionSessionsProps = vi.hoisted(() => ({
|
||||
calls: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock('../../../../../src/renderer/components/team/taskLogs/TaskLogStreamSection', () => ({
|
||||
TaskLogStreamSection: () =>
|
||||
React.createElement('div', { 'data-testid': 'task-log-stream' }, 'stream'),
|
||||
TaskLogStreamSection: (props: Record<string, unknown>) => {
|
||||
taskLogStreamProps.calls.push(props);
|
||||
return React.createElement('div', { 'data-testid': 'task-log-stream' }, 'stream');
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../../../src/renderer/components/team/taskLogs/ExecutionSessionsSection', () => ({
|
||||
ExecutionSessionsSection: () =>
|
||||
React.createElement('div', { 'data-testid': 'execution-sessions' }, 'sessions'),
|
||||
ExecutionSessionsSection: (props: Record<string, unknown>) => {
|
||||
executionSessionsProps.calls.push(props);
|
||||
return React.createElement('div', { 'data-testid': 'execution-sessions' }, 'sessions');
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../../../src/renderer/components/team/taskLogs/featureGates', () => ({
|
||||
|
|
@ -128,6 +164,12 @@ describe('TaskLogsPanel', () => {
|
|||
document.body.innerHTML = '';
|
||||
featureGateState.activityEnabled = true;
|
||||
featureGateState.exactLogsEnabled = true;
|
||||
taskActivityProps.calls = [];
|
||||
taskLogStreamProps.calls = [];
|
||||
executionSessionsProps.calls = [];
|
||||
apiState.onTeamChange.mockReset();
|
||||
apiState.setTaskLogStreamTracking.mockReset();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
|
|
@ -147,6 +189,12 @@ describe('TaskLogsPanel', () => {
|
|||
expect(host.textContent).toContain('Execution Sessions');
|
||||
expect(findTabButton(host, 'Task Log Stream')?.getAttribute('data-state')).toBe('active');
|
||||
expect(host.querySelector('[data-testid="task-log-stream"]')).not.toBeNull();
|
||||
expect(taskLogStreamProps.calls.at(-1)).toMatchObject({
|
||||
teamName: 'demo',
|
||||
taskId: 'task-1',
|
||||
taskStatus: 'in_progress',
|
||||
liveEnabled: true,
|
||||
});
|
||||
|
||||
const activityTab = findTabButton(host, 'Task Activity');
|
||||
expect(activityTab).not.toBeNull();
|
||||
|
|
@ -158,6 +206,11 @@ describe('TaskLogsPanel', () => {
|
|||
|
||||
expect(findTabButton(host, 'Task Activity')?.getAttribute('data-state')).toBe('active');
|
||||
expect(host.querySelector('[data-testid="task-activity"]')).not.toBeNull();
|
||||
expect(taskActivityProps.calls.at(-1)).toMatchObject({
|
||||
teamName: 'demo',
|
||||
taskId: 'task-1',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const sessionsTab = findTabButton(host, 'Execution Sessions');
|
||||
expect(sessionsTab).not.toBeNull();
|
||||
|
|
@ -169,6 +222,11 @@ describe('TaskLogsPanel', () => {
|
|||
|
||||
expect(findTabButton(host, 'Execution Sessions')?.getAttribute('data-state')).toBe('active');
|
||||
expect(host.querySelector('[data-testid="execution-sessions"]')).not.toBeNull();
|
||||
expect(executionSessionsProps.calls.at(-1)).toMatchObject({
|
||||
teamName: 'demo',
|
||||
taskId: 'task-1',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
@ -192,6 +250,234 @@ describe('TaskLogsPanel', () => {
|
|||
expect(findTabButton(host, 'Task Activity')?.getAttribute('data-state')).toBe('active');
|
||||
expect(host.querySelector('[data-testid="task-activity"]')).not.toBeNull();
|
||||
expect(host.textContent).not.toContain('Task Log Stream');
|
||||
expect(apiState.setTaskLogStreamTracking).not.toHaveBeenCalled();
|
||||
expect(apiState.onTeamChange).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mount Task Activity content while the section is collapsed and stream is disabled', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
featureGateState.exactLogsEnabled = false;
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(TaskLogsPanel, {
|
||||
teamName: 'demo',
|
||||
task: makeTask(),
|
||||
isOpen: false,
|
||||
})
|
||||
);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-testid="task-log-stream"]')).toBeNull();
|
||||
expect(host.querySelector('[data-testid="task-activity"]')).toBeNull();
|
||||
expect(taskLogStreamProps.calls).toHaveLength(0);
|
||||
expect(apiState.setTaskLogStreamTracking).not.toHaveBeenCalled();
|
||||
expect(apiState.onTeamChange).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps task-log tracking active across tab switches and pulses on matching live updates', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
vi.useFakeTimers();
|
||||
|
||||
const activityStates: boolean[] = [];
|
||||
let handler: ((event: unknown, data: TeamChangeEvent) => void) | null = null;
|
||||
apiState.onTeamChange.mockImplementation((callback) => {
|
||||
handler = callback;
|
||||
return () => {
|
||||
handler = null;
|
||||
};
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(TaskLogsPanel, {
|
||||
teamName: 'demo',
|
||||
task: makeTask(),
|
||||
onTaskLogActivityChange: (isActive: boolean) => activityStates.push(isActive),
|
||||
})
|
||||
);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(apiState.setTaskLogStreamTracking).toHaveBeenCalledTimes(1);
|
||||
expect(apiState.setTaskLogStreamTracking).toHaveBeenCalledWith('demo', true);
|
||||
expect(handler).toBeTypeOf('function');
|
||||
expect(activityStates).toEqual([false]);
|
||||
|
||||
const activityTab = findTabButton(host, 'Task Activity');
|
||||
expect(activityTab).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
activityTab?.click();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(apiState.setTaskLogStreamTracking).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
handler?.(null, { teamName: 'other-team', type: 'task-log-change', taskId: 'task-1' });
|
||||
handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-2' });
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(activityStates).toEqual([false]);
|
||||
|
||||
await act(async () => {
|
||||
handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-1' });
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(activityStates).toEqual([false, true]);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1800);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(activityStates).toEqual([false, true, false]);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(apiState.setTaskLogStreamTracking).toHaveBeenLastCalledWith('demo', false);
|
||||
});
|
||||
|
||||
it('does not mount Task Log Stream content while the section is collapsed but still pulses on matching updates', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
vi.useFakeTimers();
|
||||
|
||||
const activityStates: boolean[] = [];
|
||||
let handler: ((event: unknown, data: TeamChangeEvent) => void) | null = null;
|
||||
apiState.onTeamChange.mockImplementation((callback) => {
|
||||
handler = callback;
|
||||
return () => {
|
||||
handler = null;
|
||||
};
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(TaskLogsPanel, {
|
||||
teamName: 'demo',
|
||||
task: makeTask(),
|
||||
isOpen: false,
|
||||
onTaskLogActivityChange: (isActive: boolean) => activityStates.push(isActive),
|
||||
})
|
||||
);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[data-testid="task-log-stream"]')).toBeNull();
|
||||
expect(taskLogStreamProps.calls).toHaveLength(0);
|
||||
expect(apiState.setTaskLogStreamTracking).toHaveBeenCalledWith('demo', true);
|
||||
expect(handler).toBeTypeOf('function');
|
||||
expect(activityStates).toEqual([false]);
|
||||
|
||||
await act(async () => {
|
||||
handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-1' });
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(activityStates).toEqual([false, true]);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1800);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(activityStates).toEqual([false, true, false]);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(apiState.setTaskLogStreamTracking).toHaveBeenLastCalledWith('demo', false);
|
||||
});
|
||||
|
||||
it('pauses mounted activity and sessions tabs when the section collapses', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(TaskLogsPanel, { teamName: 'demo', task: makeTask() }));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
const activityTab = findTabButton(host, 'Task Activity');
|
||||
expect(activityTab).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
activityTab?.click();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(taskActivityProps.calls.at(-1)).toMatchObject({ enabled: true });
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(TaskLogsPanel, {
|
||||
teamName: 'demo',
|
||||
task: makeTask(),
|
||||
isOpen: false,
|
||||
})
|
||||
);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(taskActivityProps.calls.at(-1)).toMatchObject({ enabled: false });
|
||||
|
||||
const sessionsTab = findTabButton(host, 'Execution Sessions');
|
||||
expect(sessionsTab).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(TaskLogsPanel, { teamName: 'demo', task: makeTask() }));
|
||||
sessionsTab?.click();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(executionSessionsProps.calls.at(-1)).toMatchObject({ enabled: true });
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(TaskLogsPanel, {
|
||||
teamName: 'demo',
|
||||
task: makeTask(),
|
||||
isOpen: false,
|
||||
})
|
||||
);
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(executionSessionsProps.calls.at(-1)).toMatchObject({ enabled: false });
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -717,6 +717,63 @@ describe('TeamGraphAdapter particles', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('maps lead-owned tasks onto the lead board without routing unknown owners to lead', () => {
|
||||
const adapter = TeamGraphAdapter.create();
|
||||
|
||||
const graph = adapter.adapt(
|
||||
createBaseTeamData({
|
||||
config: {
|
||||
name: 'My Team',
|
||||
members: [{ name: 'olivia', agentType: 'lead' }, { name: 'alice' }],
|
||||
projectPath: '/repo',
|
||||
},
|
||||
members: [
|
||||
{
|
||||
name: 'olivia',
|
||||
status: 'active',
|
||||
currentTaskId: null,
|
||||
taskCount: 1,
|
||||
lastActiveAt: null,
|
||||
messageCount: 0,
|
||||
agentType: 'lead',
|
||||
},
|
||||
{
|
||||
name: 'alice',
|
||||
status: 'active',
|
||||
currentTaskId: null,
|
||||
taskCount: 1,
|
||||
lastActiveAt: null,
|
||||
messageCount: 0,
|
||||
},
|
||||
],
|
||||
tasks: [
|
||||
{
|
||||
id: 'lead-task',
|
||||
displayId: '#11',
|
||||
subject: 'Lead summary',
|
||||
owner: 'olivia',
|
||||
status: 'in_progress',
|
||||
comments: [],
|
||||
reviewState: 'none',
|
||||
} as TeamTaskWithKanban,
|
||||
{
|
||||
id: 'unknown-task',
|
||||
displayId: '#12',
|
||||
subject: 'Unknown owner',
|
||||
owner: 'ghost',
|
||||
status: 'in_progress',
|
||||
comments: [],
|
||||
reviewState: 'none',
|
||||
} as TeamTaskWithKanban,
|
||||
],
|
||||
}),
|
||||
'my-team'
|
||||
);
|
||||
|
||||
expect(findNode(graph, 'task:my-team:lead-task')?.ownerId).toBe('lead:my-team');
|
||||
expect(findNode(graph, 'task:my-team:unknown-task')?.ownerId).toBeNull();
|
||||
});
|
||||
|
||||
it('builds member activity feeds from inbox messages in newest-first order', () => {
|
||||
const adapter = TeamGraphAdapter.create();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildTransientHandoffMessage } from '../../../../src/features/agent-graph/renderer/ui/buildTransientHandoffMessage';
|
||||
|
||||
import type { TransientHandoffCard } from '@claude-teams/agent-graph';
|
||||
|
||||
function buildCard(overrides: Partial<TransientHandoffCard> = {}): TransientHandoffCard {
|
||||
return {
|
||||
key: 'edge-1:fwd:task_comment',
|
||||
edgeId: 'edge-1',
|
||||
sourceNodeId: 'member:bob',
|
||||
destinationNodeId: 'task:abc',
|
||||
anchorNodeId: 'member:bob',
|
||||
anchorKind: 'member',
|
||||
sourceLabel: 'bob',
|
||||
destinationLabel: 'abc12345',
|
||||
destinationKind: 'task',
|
||||
kind: 'task_comment',
|
||||
color: '#22c55e',
|
||||
preview: 'Dependency resolved',
|
||||
relatedTaskId: 'abc12345def67890',
|
||||
relatedTaskDisplayId: 'abc12345',
|
||||
count: 1,
|
||||
activatedAt: 10,
|
||||
updatedAt: 11,
|
||||
expiresAt: 15,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildTransientHandoffMessage', () => {
|
||||
it('builds task comment notifications with task refs', () => {
|
||||
const message = buildTransientHandoffMessage('signal-ops-2', buildCard());
|
||||
|
||||
expect(message.messageKind).toBe('task_comment_notification');
|
||||
expect(message.from).toBe('bob');
|
||||
expect(message.taskRefs).toEqual([
|
||||
{
|
||||
taskId: 'abc12345def67890',
|
||||
displayId: 'abc12345',
|
||||
teamName: 'signal-ops-2',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds task assign text that ActivityItem recognizes as task badge', () => {
|
||||
const message = buildTransientHandoffMessage(
|
||||
'signal-ops-2',
|
||||
buildCard({
|
||||
key: 'edge-2:fwd:task_assign',
|
||||
kind: 'task_assign',
|
||||
destinationNodeId: 'member:bob',
|
||||
destinationKind: 'member',
|
||||
destinationLabel: 'bob',
|
||||
})
|
||||
);
|
||||
|
||||
expect(message.messageKind).toBe('default');
|
||||
expect(message.text.startsWith('New task assigned to you:')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -797,6 +797,40 @@ describe('stable slot layout planner', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('builds central collisions from occupied lead sub-rects instead of the full lead slot bounds', () => {
|
||||
const teamName = 'team-lead-central-collision';
|
||||
const lead = createLead(teamName);
|
||||
const alice = createMember(teamName, 'agent-alice', 'alice');
|
||||
const leadTasks = [
|
||||
createTask(teamName, 'lead-a', lead.id, { taskStatus: 'completed' }),
|
||||
createTask(teamName, 'lead-b', lead.id, { taskStatus: 'in_progress' }),
|
||||
];
|
||||
const layout: GraphLayoutPort = {
|
||||
version: 'stable-slots-v1',
|
||||
ownerOrder: [alice.id],
|
||||
slotAssignments: {
|
||||
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
const nodes = [lead, alice, ...leadTasks];
|
||||
const snapshot = buildStableSlotLayoutSnapshot({
|
||||
teamName,
|
||||
nodes,
|
||||
layout,
|
||||
});
|
||||
|
||||
expect(snapshot).not.toBeNull();
|
||||
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.kanbanBandRect);
|
||||
expect(snapshot!.leadCentralReservedBlock.width).toBeLessThan(snapshot!.leadSlotFrame.bounds.width);
|
||||
expect(snapshot!.leadCentralReservedBlock.height).toBeLessThan(
|
||||
snapshot!.leadSlotFrame.bounds.height
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the same sector and spills to the next outer ring when the saved slot is already occupied', () => {
|
||||
const teamName = 'team-wide-spill';
|
||||
const lead = createLead(teamName);
|
||||
|
|
|
|||
Loading…
Reference in a new issue