Merge branch 'dev' into spike/team-snapshot-split-plan

This commit is contained in:
777genius 2026-04-18 18:21:25 +03:00
commit 2fd06fcd48
48 changed files with 4007 additions and 249 deletions

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -978,6 +978,7 @@ async function initializeServices(): Promise<void> {
boardTaskExactLogsService,
boardTaskExactLogDetailService,
teammateToolTracker ?? undefined,
teamLogSourceTracker,
branchStatusService ?? undefined,
{
rewire: rewireContextEvents,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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`} />

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(
() =>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 () => {

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

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

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

View file

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

View file

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

View file

@ -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 () => {

View file

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

View file

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

View file

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

View file

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

View file

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