fix(graph): enhance particle rendering for task comments

- Added visual differentiation for 'task_comment' particles, adjusting size and glow effects.
- Updated drawing functions to handle new particle kind, ensuring proper rendering in the graph.
- Introduced a merge function for particles to prevent duplicates during state updates.
- Enhanced color constants for better visual representation of different particle types.
This commit is contained in:
iliya 2026-03-28 22:55:01 +02:00
parent 8aaf53ae4a
commit 26f7b9158f
32 changed files with 1286 additions and 100 deletions

View file

@ -42,6 +42,7 @@ export function drawParticles(
const baseSize = (p.size ?? 1) * 3;
// Differentiate visual by particle kind
const size = p.kind === 'spawn' ? baseSize * 1.5
: p.kind === 'task_comment' ? baseSize * 1.15
: p.kind === 'review_request' || p.kind === 'review_response' ? baseSize * 1.2
: baseSize;
@ -49,8 +50,8 @@ export function drawParticles(
const phaseOffset = p.id.charCodeAt(Math.min(5, p.id.length - 1)) * 0.1;
const wobbleAmp = BEAM.wobble.amp;
drawParticleTrail(ctx, source, target, cp, p.progress, color, size, wobbleAmp, phaseOffset, time);
drawParticleCore(ctx, source, target, cp, p.progress, color, size, wobbleAmp, phaseOffset, time);
drawParticleTrail(ctx, source, target, cp, p.progress, color, size, wobbleAmp, phaseOffset, time, p.kind);
drawParticleCore(ctx, source, target, cp, p.progress, color, size, wobbleAmp, phaseOffset, time, p.kind);
// Label
if (p.label && p.progress > PARTICLE_DRAW.labelMinT && p.progress < PARTICLE_DRAW.labelMaxT) {
@ -104,8 +105,9 @@ function drawParticleTrail(
wobbleAmp: number,
phaseOffset: number,
time: number,
kind: GraphParticle['kind'],
): void {
const trailSegments = 6;
const trailSegments = kind === 'task_comment' ? 4 : 6;
const trailStep = BEAM.wobble.trailOffset / trailSegments;
for (let i = trailSegments; i >= 1; i--) {
@ -114,10 +116,18 @@ function drawParticleTrail(
const alpha = (1 - i / trailSegments) * 0.3;
const trailSize = size * (1 - i / trailSegments) * 0.5;
ctx.fillStyle = hexWithAlpha(color, alpha);
ctx.beginPath();
ctx.arc(pos.x, pos.y, trailSize, 0, Math.PI * 2);
ctx.fill();
if (kind === 'task_comment') {
ctx.strokeStyle = hexWithAlpha(color, alpha);
ctx.lineWidth = Math.max(0.8, trailSize * 0.45);
ctx.beginPath();
ctx.arc(pos.x, pos.y, trailSize * 1.15, 0, Math.PI * 2);
ctx.stroke();
} else {
ctx.fillStyle = hexWithAlpha(color, alpha);
ctx.beginPath();
ctx.arc(pos.x, pos.y, trailSize, 0, Math.PI * 2);
ctx.fill();
}
}
}
@ -132,14 +142,29 @@ function drawParticleCore(
wobbleAmp: number,
phaseOffset: number,
time: number,
kind: GraphParticle['kind'],
): void {
const pos = getWobbledPosition(source, target, cp, progress, wobbleAmp, phaseOffset, time);
// Glow sprite
const glowR = PARTICLE_DRAW.glowRadius;
const sprite = getGlowSprite(color, glowR, 0.4, 0);
const sprite = getGlowSprite(color, glowR, kind === 'task_comment' ? 0.28 : 0.4, 0);
ctx.drawImage(sprite, pos.x - glowR, pos.y - glowR);
if (kind === 'task_comment') {
ctx.strokeStyle = color;
ctx.lineWidth = 1.8;
ctx.beginPath();
ctx.arc(pos.x, pos.y, size * 1.1, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(pos.x, pos.y, size * 0.35, 0, Math.PI * 2);
ctx.fill();
return;
}
// Core dot
ctx.fillStyle = color;
ctx.beginPath();

View file

@ -55,6 +55,8 @@ export const COLORS = {
// Particle kind colors
particleMessage: '#66ccff',
particleInboxMessage: '#66ccff',
particleTaskComment: '#ff9ad5',
particleTaskAssign: '#ffbb44',
particleReviewRequest: '#f59e0b',
particleReviewResponse: '#22c55e',

View file

@ -217,7 +217,7 @@ export function useGraphSimulation(): UseGraphSimulationResult {
state.nodes = nodes;
state.edges = edges;
state.particles = particles;
state.particles = mergeParticles(state.particles, particles);
syncSimulation(nodes, edges);
}, [syncSimulation]);
@ -237,6 +237,23 @@ export function useGraphSimulation(): UseGraphSimulationResult {
return { stateRef, updateData, tick };
}
function mergeParticles(
existing: GraphParticle[],
incoming: GraphParticle[],
): GraphParticle[] {
if (existing.length === 0) return incoming;
if (incoming.length === 0) return existing;
const merged = existing.slice();
const seen = new Set(existing.map((particle) => particle.id));
for (const particle of incoming) {
if (seen.has(particle.id)) continue;
merged.push(particle);
seen.add(particle.id);
}
return merged;
}
// ─── Frame Tick (pure function) ─────────────────────────────────────────────
function tickFrame(

View file

@ -22,7 +22,8 @@ export type GraphNodeState =
export type GraphEdgeType = 'parent-child' | 'ownership' | 'blocking' | 'related' | 'message';
export type GraphParticleKind =
| 'message'
| 'inbox_message'
| 'task_comment'
| 'task_assign'
| 'review_request'
| 'review_response'

View file

@ -29,6 +29,7 @@ export interface GraphViewProps {
events?: GraphEventPort;
config?: Partial<GraphConfigPort>;
className?: string;
suspendAnimation?: boolean;
onRequestClose?: () => void;
onRequestPinAsTab?: () => void;
onRequestFullscreen?: () => void;
@ -45,6 +46,7 @@ export function GraphView({
events,
config,
className,
suspendAnimation = false,
onRequestClose,
onRequestPinAsTab,
onRequestFullscreen,
@ -58,6 +60,7 @@ export function GraphView({
showEdges: true,
paused: !(config?.animationEnabled ?? true),
});
const effectivePaused = filters.paused || suspendAnimation;
// Ref mirror of selectedNodeId — read by RAF loop to avoid recreating animate on selection change
const selectedNodeIdRef = useRef<string | null>(null);
@ -155,7 +158,7 @@ export function GraphView({
// Start/stop RAF
useEffect(() => {
if (!filters.paused) {
if (!effectivePaused) {
runningRef.current = true;
lastTimeRef.current = 0;
rafRef.current = requestAnimationFrame(animate);
@ -167,7 +170,7 @@ export function GraphView({
runningRef.current = false;
cancelAnimationFrame(rafRef.current);
};
}, [filters.paused, animate]);
}, [effectivePaused, animate]);
// ─── Auto-fit: center graph immediately when data arrives ──────────────
const hasAutoFit = useRef(false);

View file

@ -38,6 +38,7 @@ import {
SKILLS_CHANGED,
SSH_STATUS,
TEAM_CHANGE,
TEAM_PROJECT_BRANCH_CHANGE,
TEAM_TOOL_APPROVAL_EVENT,
WINDOW_FULLSCREEN_CHANGED,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
@ -97,6 +98,7 @@ import {
configManager,
LocalFileSystemProvider,
MemberStatsComputer,
BranchStatusService,
NotificationManager,
PtyTerminalService,
ServiceContext,
@ -391,6 +393,7 @@ let httpServer: HttpServer;
let schedulerService: SchedulerService;
let skillsWatcherService: SkillsWatcherService | null = null;
let teamBackupService: TeamBackupService | null = null;
let branchStatusService: BranchStatusService | null = null;
let rendererRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
let rendererRecoveryAttempts = 0;
@ -786,6 +789,9 @@ function initializeServices(): void {
const taskChangePresenceRepository = new JsonTaskChangePresenceRepository();
const teamLogSourceTracker = new TeamLogSourceTracker(teamMemberLogsFinder);
let teammateToolTracker: TeammateToolTracker | null = null;
branchStatusService = new BranchStatusService((event) => {
safeSendToRenderer(mainWindow, TEAM_PROJECT_BRANCH_CHANGE, event);
});
const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder);
const taskBoundaryParser = new TaskBoundaryParser();
const changeExtractor = new ChangeExtractorService(teamMemberLogsFinder, taskBoundaryParser);
@ -891,6 +897,7 @@ function initializeServices(): void {
teamMemberLogsFinder,
memberStatsComputer,
teammateToolTracker ?? undefined,
branchStatusService ?? undefined,
{
rewire: rewireContextEvents,
full: onContextSwitched,
@ -1043,6 +1050,8 @@ function shutdownServices(): void {
if (teamDataService) {
teamDataService.stopProcessHealthPolling();
}
branchStatusService?.dispose();
branchStatusService = null;
// Stop scheduled task execution and croner jobs
if (schedulerService) {
@ -1198,6 +1207,7 @@ function createWindow(): void {
mainWindow.webContents.on('did-start-loading', () => {
markRendererUnavailable(mainWindow);
branchStatusService?.resetAllTracking();
});
// Set traffic light position + notify renderer on first load, and auto-check for updates
@ -1343,6 +1353,7 @@ function createWindow(): void {
mainWindow.webContents.on('render-process-gone', (_event, details) => {
logger.error('Renderer process gone:', details.reason, details.exitCode);
markRendererUnavailable(mainWindow);
branchStatusService?.resetAllTracking();
const activeContext = contextRegistry.getActive();
activeContext?.stopFileWatcher();
if (mainWindow) {

View file

@ -89,6 +89,7 @@ import { registerWindowHandlers, removeWindowHandlers } from './window';
import type {
ChangeExtractorService,
BranchStatusService,
CliInstallerService,
FileContentResolver,
GitDiffFallback,
@ -129,6 +130,7 @@ export function initializeIpcHandlers(
teamMemberLogsFinder: TeamMemberLogsFinder,
memberStatsComputer: MemberStatsComputer,
teammateToolTracker: TeammateToolTracker | undefined,
branchStatusService: BranchStatusService | undefined,
contextCallbacks: {
rewire: (context: ServiceContext) => void;
full: (context: ServiceContext) => void;
@ -170,7 +172,8 @@ export function initializeIpcHandlers(
teamMemberLogsFinder,
memberStatsComputer,
teamBackupService,
teammateToolTracker
teammateToolTracker,
branchStatusService
);
initializeConfigHandlers({
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,

View file

@ -48,6 +48,7 @@ import {
TEAM_SAVE_TASK_ATTACHMENT,
TEAM_SEND_MESSAGE,
TEAM_SET_CHANGE_PRESENCE_TRACKING,
TEAM_SET_PROJECT_BRANCH_TRACKING,
TEAM_SET_TOOL_ACTIVITY_TRACKING,
TEAM_SET_TASK_CLARIFICATION,
TEAM_SHOW_MESSAGE_NOTIFICATION,
@ -110,6 +111,7 @@ import {
} from './guards';
import type {
BranchStatusService,
MemberStatsComputer,
TeammateToolTracker,
TeamDataService,
@ -275,6 +277,7 @@ let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
let memberStatsComputer: MemberStatsComputer | null = null;
let teamBackupService: TeamBackupService | null = null;
let teammateToolTracker: TeammateToolTracker | null = null;
let branchStatusService: BranchStatusService | null = null;
const attachmentStore = new TeamAttachmentStore();
const taskAttachmentStore = new TeamTaskAttachmentStore();
@ -304,7 +307,8 @@ export function initializeTeamHandlers(
logsFinder?: TeamMemberLogsFinder,
statsComputer?: MemberStatsComputer,
backupService?: TeamBackupService,
toolTracker?: TeammateToolTracker
toolTracker?: TeammateToolTracker,
branchTracker?: BranchStatusService
): void {
teamDataService = service;
teamProvisioningService = provisioningService;
@ -312,6 +316,7 @@ export function initializeTeamHandlers(
memberStatsComputer = statsComputer ?? null;
teamBackupService = backupService ?? null;
teammateToolTracker = toolTracker ?? null;
branchStatusService = branchTracker ?? null;
}
export function registerTeamHandlers(ipcMain: IpcMain): void {
@ -319,6 +324,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_GET_DATA, handleGetData);
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_TOOL_ACTIVITY_TRACKING, handleSetToolActivityTracking);
ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs);
ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning);
@ -384,6 +390,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_GET_DATA);
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_TOOL_ACTIVITY_TRACKING);
ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS);
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
@ -464,6 +471,13 @@ function getTeammateToolTracker(): TeammateToolTracker {
return teammateToolTracker;
}
function getBranchStatusService(): BranchStatusService {
if (!branchStatusService) {
throw new Error('Branch status service is not initialized');
}
return branchStatusService;
}
async function wrapTeamHandler<T>(
operation: string,
handler: () => Promise<T>
@ -669,6 +683,23 @@ async function handleSetChangePresenceTracking(
});
}
async function handleSetProjectBranchTracking(
_event: IpcMainInvokeEvent,
projectPath: unknown,
enabled: unknown
): Promise<IpcResult<void>> {
if (typeof projectPath !== 'string' || projectPath.trim().length === 0) {
return { success: false, error: 'projectPath must be a non-empty string' };
}
if (typeof enabled !== 'boolean') {
return { success: false, error: 'enabled must be a boolean' };
}
return wrapTeamHandler('setProjectBranchTracking', async () => {
await getBranchStatusService().setTracking(projectPath.trim(), enabled);
});
}
async function handleSetToolActivityTracking(
_event: IpcMainInvokeEvent,
teamName: unknown,

View file

@ -463,9 +463,15 @@ class GitIdentityResolver {
* @param projectPath - The filesystem path to check
* @returns Branch name or null
*/
async getBranch(projectPath: string): Promise<string | null> {
async getBranch(
projectPath: string,
options?: {
forceRefresh?: boolean;
}
): Promise<string | null> {
const forceRefresh = options?.forceRefresh === true;
const cached = this.branchCache.get(projectPath);
if (cached && cached.expiry > Date.now()) {
if (!forceRefresh && cached && cached.expiry > Date.now()) {
return cached.value;
}

View file

@ -0,0 +1,133 @@
import * as path from 'path';
import { createLogger } from '@shared/utils/logger';
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
import type { ProjectBranchChangeEvent } from '@shared/types';
const logger = createLogger('Service:BranchStatus');
const POLL_INTERVAL_MS = 20_000;
interface BranchResolver {
getBranch(projectPath: string, options?: { forceRefresh?: boolean }): Promise<string | null>;
}
interface TrackedPathState {
actualPath: string;
refCount: number;
token: number;
}
const UNSET_BRANCH = Symbol('unset-branch');
export class BranchStatusService {
private readonly trackedPaths = new Map<string, TrackedPathState>();
private readonly inFlightChecks = new Map<string, Promise<void>>();
private readonly lastEmittedBranchByPath = new Map<string, string | null | typeof UNSET_BRANCH>();
private pollTimer: ReturnType<typeof setInterval> | null = null;
private nextToken = 1;
constructor(
private readonly emitBranchChange: (event: ProjectBranchChangeEvent) => void,
private readonly resolver: BranchResolver = gitIdentityResolver
) {}
async setTracking(projectPath: string, enabled: boolean): Promise<void> {
const trimmed = projectPath.trim();
if (!trimmed) return;
const normalizedPath = path.normalize(trimmed);
if (!enabled) {
this.unsubscribe(normalizedPath);
return;
}
const existing = this.trackedPaths.get(normalizedPath);
if (existing) {
existing.refCount += 1;
return;
}
this.trackedPaths.set(normalizedPath, {
actualPath: normalizedPath,
refCount: 1,
token: this.nextToken++,
});
this.startPollingIfNeeded();
await this.checkPath(normalizedPath, false);
}
dispose(): void {
this.resetAllTracking();
}
resetAllTracking(): void {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
this.trackedPaths.clear();
this.inFlightChecks.clear();
this.lastEmittedBranchByPath.clear();
}
private unsubscribe(normalizedPath: string): void {
const existing = this.trackedPaths.get(normalizedPath);
if (!existing) return;
existing.refCount -= 1;
if (existing.refCount > 0) return;
this.trackedPaths.delete(normalizedPath);
this.inFlightChecks.delete(normalizedPath);
this.lastEmittedBranchByPath.delete(normalizedPath);
if (this.trackedPaths.size === 0 && this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
}
private startPollingIfNeeded(): void {
if (this.pollTimer || this.trackedPaths.size === 0) return;
this.pollTimer = setInterval(() => {
for (const normalizedPath of this.trackedPaths.keys()) {
void this.checkPath(normalizedPath, true);
}
}, POLL_INTERVAL_MS);
}
private async checkPath(normalizedPath: string, forceRefresh: boolean): Promise<void> {
const tracked = this.trackedPaths.get(normalizedPath);
if (!tracked) return;
const expectedToken = tracked.token;
if (this.inFlightChecks.has(normalizedPath)) {
return this.inFlightChecks.get(normalizedPath);
}
const promise = (async () => {
try {
const branch = await this.resolver.getBranch(tracked.actualPath, { forceRefresh });
const latestTracked = this.trackedPaths.get(normalizedPath);
if (!latestTracked || latestTracked.token !== expectedToken) return;
const previous = this.lastEmittedBranchByPath.get(normalizedPath) ?? UNSET_BRANCH;
if (previous !== UNSET_BRANCH && previous === branch) {
return;
}
this.lastEmittedBranchByPath.set(normalizedPath, branch);
this.emitBranchChange({
projectPath: latestTracked.actualPath,
branch,
});
} catch (error) {
logger.debug(
`Failed to resolve branch for ${normalizedPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
})().finally(() => {
this.inFlightChecks.delete(normalizedPath);
});
this.inFlightChecks.set(normalizedPath, promise);
return promise;
}
}

View file

@ -688,8 +688,18 @@ export class TeamMemberLogsFinder {
const discovery = await this.discoverProjectSessions(teamName);
if (!discovery) return [];
const { projectDir, sessionIds, knownMembers } = discovery;
const candidates = await this.collectSubagentCandidates(projectDir, sessionIds);
const { projectDir, sessionIds, knownMembers, config } = discovery;
const currentLeadSessionId =
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
? config.leadSessionId.trim()
: null;
// Live teammate tool tracking should follow the current team run, not historical
// lead sessions kept in sessionHistory or lingering on disk.
const candidateSessionIds =
currentLeadSessionId && sessionIds.includes(currentLeadSessionId)
? [currentLeadSessionId]
: sessionIds;
const candidates = await this.collectSubagentCandidates(projectDir, candidateSessionIds);
const results: Array<{
memberName: string;
sessionId: string;

View file

@ -1,4 +1,5 @@
export { CascadeGuard } from './CascadeGuard';
export { BranchStatusService } from './BranchStatusService';
export { ChangeExtractorService } from './ChangeExtractorService';
export { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
export { CrossTeamOutbox } from './CrossTeamOutbox';

View file

@ -319,6 +319,12 @@ export const TEAM_ADD_TASK_COMMENT = 'team:addTaskComment';
/** Get current git branch for a project path (live read from .git/HEAD) */
export const TEAM_GET_PROJECT_BRANCH = 'team:getProjectBranch';
/** Enable or disable background tracking for a project path's git branch */
export const TEAM_SET_PROJECT_BRANCH_TRACKING = 'team:setProjectBranchTracking';
/** Push event: tracked project branch changed (main → renderer) */
export const TEAM_PROJECT_BRANCH_CHANGE = 'team:projectBranchChange';
/** Add a new member to an existing team */
export const TEAM_ADD_MEMBER = 'team:addMember';

View file

@ -126,6 +126,7 @@ import {
TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS,
TEAM_GET_PROJECT_BRANCH,
TEAM_PROJECT_BRANCH_CHANGE,
TEAM_GET_SAVED_REQUEST,
TEAM_GET_TASK_ATTACHMENT,
TEAM_GET_TASK_CHANGE_PRESENCE,
@ -150,6 +151,7 @@ import {
TEAM_SAVE_TASK_ATTACHMENT,
TEAM_SEND_MESSAGE,
TEAM_SET_CHANGE_PRESENCE_TRACKING,
TEAM_SET_PROJECT_BRANCH_TRACKING,
TEAM_SET_TOOL_ACTIVITY_TRACKING,
TEAM_SET_TASK_CLARIFICATION,
TEAM_SHOW_MESSAGE_NOTIFICATION,
@ -266,6 +268,7 @@ import type {
TaskChangePresenceState,
TaskChangeSetV2,
TaskComment,
ProjectBranchChangeEvent,
TeamChangeEvent,
TeamClaudeLogsQuery,
TeamClaudeLogsResponse,
@ -958,6 +961,9 @@ const electronAPI: ElectronAPI = {
getProjectBranch: async (projectPath: string) => {
return invokeIpcWithResult<string | null>(TEAM_GET_PROJECT_BRANCH, projectPath);
},
setProjectBranchTracking: async (projectPath: string, enabled: boolean) => {
return invokeIpcWithResult<void>(TEAM_SET_PROJECT_BRANCH_TRACKING, projectPath, enabled);
},
getAttachments: async (teamName: string, messageId: string) => {
return invokeIpcWithResult<AttachmentFileData[]>(TEAM_GET_ATTACHMENTS, teamName, messageId);
},
@ -1066,6 +1072,20 @@ const electronAPI: ElectronAPI = {
mimeType
);
},
onProjectBranchChange: (
callback: (event: unknown, data: ProjectBranchChangeEvent) => void
): (() => void) => {
ipcRenderer.on(
TEAM_PROJECT_BRANCH_CHANGE,
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
return (): void => {
ipcRenderer.removeListener(
TEAM_PROJECT_BRANCH_CHANGE,
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
);
};
},
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
ipcRenderer.on(
TEAM_CHANGE,

View file

@ -839,6 +839,9 @@ export class HttpAPIClient implements ElectronAPI {
getProjectBranch: async (_projectPath: string): Promise<string | null> => {
return null;
},
setProjectBranchTracking: async (): Promise<void> => {
// Not available in browser mode — no-op.
},
getAttachments: async (
_teamName: string,
_messageId: string
@ -918,6 +921,9 @@ export class HttpAPIClient implements ElectronAPI {
): Promise<void> => {
throw new Error('Task attachments are not available in browser mode');
},
onProjectBranchChange: (): (() => void) => {
return () => {};
},
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
return this.addEventListener('team-change', (data: unknown) =>
callback(null, data as TeamChangeEvent)

View file

@ -68,7 +68,7 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => {
{tab.type === 'schedules' && <SchedulesView />}
{tab.type === 'graph' && (
<TabUIProvider tabId={tab.id}>
<TeamGraphTab teamName={tab.teamName ?? ''} />
<TeamGraphTab teamName={tab.teamName ?? ''} isActive={isActive} />
</TabUIProvider>
)}
</div>

View file

@ -674,6 +674,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
() => (teamProjectPath ? [teamProjectPath] : []),
[teamProjectPath]
);
// Live branch sync now uses main-side background tracking instead of renderer polling.
useBranchSync(branchSyncPaths, { live: true });
const leadBranch = useStore((s) =>
teamProjectPath ? (s.branchByPath[normalizePath(teamProjectPath)] ?? null) : null

View file

@ -69,6 +69,21 @@ export function isLeadThought(msg: InboxMessage): boolean {
return false;
}
/**
* Check if a message from lead session/process is protocol noise that should be
* completely excluded from the timeline (not shown as thoughts OR standalone messages).
*
* When `isLeadThought` returns false due to `isThoughtProtocolNoise`, the message
* falls through to become a standalone ActivityItem but ActivityItem can't parse
* noise JSON wrapped in `<teammate-message>` tags. This helper catches those cases
* so `groupTimelineItems` can skip them entirely.
*/
function isLeadSessionNoise(msg: InboxMessage): boolean {
if (msg.source !== 'lead_session' && msg.source !== 'lead_process') return false;
if (typeof msg.to === 'string' && msg.to.trim().length > 0) return false;
return isThoughtProtocolNoise(msg.text);
}
export type TimelineItem =
| { type: 'message'; message: InboxMessage }
| { type: 'lead-thoughts'; group: LeadThoughtGroup };
@ -109,6 +124,12 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
}
pendingThoughts.push(msg);
} else {
// Skip lead session/process messages that are protocol noise — they should
// not appear in the timeline at all (neither as thoughts nor as standalone messages).
// isLeadThought already rejects these from thoughts, but without this guard
// they fall through as standalone ActivityItem cards that can't parse the noise JSON.
// Check BEFORE flushThoughts() so noise between two thoughts doesn't split the group.
if (isLeadSessionNoise(msg)) continue;
flushThoughts();
result.push({ type: 'message', message: msg });
}

View file

@ -67,6 +67,42 @@ export class TeamGraphAdapter {
// Simple hash for change detection (avoids full deep equality)
const totalComments = teamData.tasks.reduce((sum, t) => sum + (t.comments?.length ?? 0), 0);
const memberKey = teamData.members
.map(
(member) =>
`${member.name}:${member.status}:${member.currentTaskId ?? ''}:${member.role ?? ''}:${member.color ?? ''}:${member.agentType ?? ''}:${member.removedAt ?? ''}`
)
.sort()
.join('|');
const taskKey = teamData.tasks
.map(
(task) =>
`${task.id}:${task.status}:${task.owner ?? ''}:${task.reviewState ?? ''}:${task.displayId ?? ''}:${task.subject}:${task.updatedAt ?? ''}`
)
.sort()
.join('|');
const processKey = teamData.processes
.map(
(proc) =>
`${proc.id}:${proc.label}:${proc.registeredBy ?? ''}:${proc.url ?? ''}:${proc.stoppedAt ?? ''}`
)
.sort()
.join('|');
const messageKey = teamData.messages
.slice(0, 25)
.map((msg) => TeamGraphAdapter.#getMessageParticleKey(msg))
.join('|');
const commentKey = teamData.tasks
.map((task) => {
const comments = task.comments ?? [];
const tail = comments
.slice(Math.max(0, comments.length - 5))
.map((comment) => `${comment.id}:${comment.author}:${comment.createdAt}`)
.join(',');
return `${task.id}:${comments.length}:${tail}`;
})
.sort()
.join('|');
const approvalKey = pendingApprovalAgents?.size
? Array.from(pendingApprovalAgents).sort().join(',')
: '';
@ -107,7 +143,7 @@ export class TeamGraphAdapter {
.sort()
.join('|')
: '';
const hash = `${teamData.teamName}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.isAlive}:${leadContext?.percent}:${totalComments}:${approvalKey}:${activeToolKey}:${finishedVisibleKey}:${historyKey}`;
const hash = `${teamData.teamName}:${teamData.config.name ?? ''}:${teamData.config.color ?? ''}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.processes.length}:${teamData.isAlive}:${leadContext?.percent}:${totalComments}:${memberKey}:${taskKey}:${processKey}:${messageKey}:${commentKey}:${approvalKey}:${activeToolKey}:${finishedVisibleKey}:${historyKey}`;
if (hash === this.#lastDataHash && teamName === this.#lastTeamName) {
return this.#cachedResult;
}
@ -156,8 +192,8 @@ export class TeamGraphAdapter {
);
this.#buildTaskNodes(nodes, edges, teamData, teamName);
this.#buildProcessNodes(nodes, edges, teamData, teamName);
this.#buildMessageParticles(particles, teamData.messages, teamName, leadId, edges);
this.#buildCommentParticles(particles, teamData, teamName, edges);
this.#buildMessageParticles(particles, teamData.messages, teamName, leadId, leadName, edges);
this.#buildCommentParticles(particles, teamData, teamName, leadId, leadName, edges);
this.#cachedResult = {
nodes,
@ -436,38 +472,38 @@ export class TeamGraphAdapter {
messages: readonly InboxMessage[],
teamName: string,
leadId: string,
leadName: string,
edges: GraphEdge[]
): void {
const recent = messages.slice(-20);
const ordered = [...messages].reverse();
// First call: record all existing message IDs without creating particles.
// This prevents old messages from spawning particles when the graph opens.
if (!this.#initialMessagesSeen) {
this.#initialMessagesSeen = true;
for (const msg of recent) {
const msgKey = msg.messageId ?? msg.timestamp;
for (const msg of ordered) {
const msgKey = TeamGraphAdapter.#getMessageParticleKey(msg);
this.#seenMessageIds.add(msgKey);
}
return;
}
// Subsequent calls: only create particles for messages not yet seen.
for (const msg of recent) {
const msgKey = msg.messageId ?? msg.timestamp;
for (const msg of ordered) {
const msgKey = TeamGraphAdapter.#getMessageParticleKey(msg);
if (this.#seenMessageIds.has(msgKey)) continue;
this.#seenMessageIds.add(msgKey);
const edgeId = TeamGraphAdapter.#resolveMessageEdge(msg, teamName, leadId, edges);
const edgeId = TeamGraphAdapter.#resolveMessageEdge(msg, teamName, leadId, leadName, edges);
if (!edgeId) continue;
const ts = typeof msg.timestamp === 'string' ? new Date(msg.timestamp).getTime() : 0;
particles.push({
id: `particle:msg:${msgKey}`,
id: `particle:msg:${teamName}:${msgKey}`,
edgeId,
progress: (ts % 800) / 1000,
kind: 'message',
progress: 0,
kind: 'inbox_message',
color: msg.color ?? '#66ccff',
label: msg.summary ?? undefined,
label: TeamGraphAdapter.#buildParticleLabel(msg.summary ?? msg.text, 'inbox'),
});
}
}
@ -476,6 +512,8 @@ export class TeamGraphAdapter {
particles: GraphParticle[],
data: TeamData,
teamName: string,
leadId: string,
leadName: string,
edges: GraphEdge[]
): void {
// First call: record current comment counts without creating particles.
@ -500,22 +538,46 @@ export class TeamGraphAdapter {
const prevCount = this.#seenCommentCounts.get(task.id) ?? 0;
const currentCount = task.comments?.length ?? 0;
if (currentCount > prevCount && prevCount > 0) {
// New comment(s) detected — create a particle from the author to the task
const newComment = task.comments![currentCount - 1];
const authorNodeId = `member:${teamName}:${newComment.author}`;
const taskNodeId = `task:${teamName}:${task.id}`;
const authorEdge = edges.find((e) => e.source === authorNodeId && e.target === taskNodeId);
if (currentCount > prevCount) {
for (let index = prevCount; index < currentCount; index += 1) {
const newComment = task.comments?.[index];
if (!newComment) continue;
const authorNodeId = TeamGraphAdapter.#resolveParticipantId(
newComment.author,
teamName,
leadId,
leadName
);
const taskNodeId = `task:${teamName}:${task.id}`;
const authorEdge =
edges.find((e) => e.source === authorNodeId && e.target === taskNodeId) ??
edges.find((e) => e.source === taskNodeId && e.target === authorNodeId);
if (authorEdge) {
particles.push({
id: `particle:comment:${task.id}:${currentCount}`,
edgeId: authorEdge.id,
progress: 0,
kind: 'message',
color: memberColors.get(newComment.author) ?? '#cc88ff',
label: '\u{1F4AC}',
});
const edgeId =
authorEdge?.id ??
(() => {
const syntheticEdgeId = `edge:msg:${authorNodeId}:${taskNodeId}`;
if (!edges.some((edge) => edge.id === syntheticEdgeId)) {
edges.push({
id: syntheticEdgeId,
source: authorNodeId,
target: taskNodeId,
type: 'message',
});
}
return syntheticEdgeId;
})();
if (authorNodeId) {
particles.push({
id: `particle:comment:${teamName}:${task.id}:${index + 1}`,
edgeId,
progress: 0,
kind: 'task_comment',
color: memberColors.get(newComment.author) ?? '#cc88ff',
label: TeamGraphAdapter.#buildParticleLabel(newComment.text, 'comment'),
});
}
}
}
@ -588,13 +650,14 @@ export class TeamGraphAdapter {
msg: InboxMessage,
teamName: string,
leadId: string,
leadName: string,
edges: GraphEdge[]
): string | null {
const { from, to } = msg;
if (from && to) {
const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId);
const toId = TeamGraphAdapter.#resolveParticipantId(to, teamName, leadId);
const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId, leadName);
const toId = TeamGraphAdapter.#resolveParticipantId(to, teamName, leadId, leadName);
return (
edges.find((e) => e.source === fromId && e.target === toId)?.id ??
edges.find((e) => e.source === toId && e.target === fromId)?.id ??
@ -603,7 +666,7 @@ export class TeamGraphAdapter {
}
if (from && !to) {
const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId);
const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId, leadName);
return (
edges.find(
(e) =>
@ -616,8 +679,39 @@ export class TeamGraphAdapter {
return null;
}
static #resolveParticipantId(name: string, teamName: string, leadId: string): string {
if (name === 'user' || name === 'team-lead') return leadId;
static #resolveParticipantId(
name: string,
teamName: string,
leadId: string,
leadName?: string
): string {
const normalized = name.trim().toLowerCase();
if (normalized === 'user' || normalized === 'team-lead') return leadId;
if (leadName && normalized === leadName.trim().toLowerCase()) return leadId;
return `member:${teamName}:${name}`;
}
static #buildParticleLabel(
text: string | undefined,
kind: 'inbox' | 'comment',
max = 26
): string | undefined {
const normalized = text?.replace(/\s+/g, ' ').trim();
const prefix = kind === 'comment' ? '\u{1F4AC}' : '\u{2709}';
if (!normalized) return prefix;
const clipped =
normalized.length > max
? `${normalized.slice(0, Math.max(0, max - 1)).trimEnd()}\u2026`
: normalized;
return `${prefix} ${clipped}`;
}
static #getMessageParticleKey(msg: InboxMessage): string {
if (msg.messageId && msg.messageId.trim().length > 0) {
return msg.messageId;
}
return [msg.timestamp, msg.from ?? '', msg.to ?? '', msg.summary ?? '', msg.text ?? ''].join(
'\u0000'
);
}
}

View file

@ -18,9 +18,13 @@ const TeamGraphOverlay = lazy(() =>
export interface TeamGraphTabProps {
teamName: string;
isActive?: boolean;
}
export const TeamGraphTab = ({ teamName }: TeamGraphTabProps): React.JSX.Element => {
export const TeamGraphTab = ({
teamName,
isActive = true,
}: TeamGraphTabProps): React.JSX.Element => {
const graphData = useTeamGraphAdapter(teamName);
const [fullscreen, setFullscreen] = useState(false);
@ -69,6 +73,7 @@ export const TeamGraphTab = ({ teamName }: TeamGraphTabProps): React.JSX.Element
data={graphData}
events={events}
className="size-full"
suspendAnimation={!isActive}
onRequestFullscreen={() => setFullscreen(true)}
renderOverlay={({ node, onClose }) => (
<GraphNodePopover

View file

@ -1,60 +1,37 @@
/**
* Centralized git branch polling hook.
* Centralized git branch sync hook.
*
* Provides two modes:
* - `live: false` (default) one-shot fetch on mount / path change
* - `live: true` continuous polling with ref-counted shared timer
* - `live: true` background tracking in main with ref-counted subscriptions
*
* Data is stored in the Zustand store (`branchByPath`) so any component
* can read it via `useStore(s => s.branchByPath)`.
*
* The module-level polling manager guarantees:
* - A single shared `setInterval` across all live subscribers
* - Deduplication: N components subscribing to the same path = 1 poll
* - Automatic cleanup: timer stops when all subscribers unmount
* The module-level tracking manager guarantees:
* - Deduplication: N components subscribing to the same path = 1 background tracker
* - Automatic cleanup: tracking stops when all subscribers unmount
*/
import { useEffect, useMemo } from 'react';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import { normalizePath } from '@renderer/utils/pathNormalize';
// =============================================================================
// Constants
// =============================================================================
const POLL_INTERVAL_MS = 6_000;
// =============================================================================
// Module-level polling manager (singleton, outside React lifecycle)
// Module-level tracking manager (singleton, outside React lifecycle)
// =============================================================================
const livePaths = new Map<string, { actualPath: string; refCount: number }>();
let pollTimer: ReturnType<typeof setInterval> | null = null;
function startPollingIfNeeded(): void {
if (pollTimer || livePaths.size === 0) return;
pollTimer = setInterval(() => {
const paths = Array.from(livePaths.values()).map((v) => v.actualPath);
void useStore.getState().fetchBranches(paths);
}, POLL_INTERVAL_MS);
}
function stopPollingIfEmpty(): void {
if (pollTimer && livePaths.size === 0) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function subscribe(normalizedKey: string, actualPath: string): void {
const entry = livePaths.get(normalizedKey);
if (entry) {
entry.refCount++;
} else {
livePaths.set(normalizedKey, { actualPath, refCount: 1 });
void api.teams?.setProjectBranchTracking?.(actualPath, true).catch(() => undefined);
}
startPollingIfNeeded();
}
function unsubscribe(normalizedKey: string): void {
@ -63,8 +40,8 @@ function unsubscribe(normalizedKey: string): void {
entry.refCount--;
if (entry.refCount <= 0) {
livePaths.delete(normalizedKey);
void api.teams?.setProjectBranchTracking?.(entry.actualPath, false).catch(() => undefined);
}
stopPollingIfEmpty();
}
// =============================================================================
@ -75,7 +52,7 @@ function unsubscribe(normalizedKey: string): void {
* Sync git branch data for the given project paths into the store.
*
* @param paths - Raw project paths to resolve branches for
* @param options.live - When true, keeps polling every 6s while mounted
* @param options.live - When true, enables main-side branch tracking while mounted
*/
export function useBranchSync(paths: string[], options?: { live?: boolean }): void {
const live = options?.live ?? false;

View file

@ -5,6 +5,7 @@
import { api } from '@renderer/api';
import { syncRendererTelemetry } from '@renderer/sentry';
import { cleanupStale as cleanupCommentReadState } from '@renderer/services/commentReadStorage';
import { normalizePath } from '@renderer/utils/pathNormalize';
import {
buildTaskChangePresenceKey,
buildTaskChangeRequestOptions,
@ -1003,6 +1004,29 @@ export function initializeNotificationListeners(): () => void {
}
}
if (api.teams?.onProjectBranchChange) {
const cleanup = api.teams.onProjectBranchChange((_event: unknown, event) => {
if (!event?.projectPath) return;
const normalizedPath = normalizePath(event.projectPath);
if (!normalizedPath) return;
useStore.setState((prev) => {
const current = prev.branchByPath[normalizedPath];
if (current === event.branch) {
return {};
}
return {
branchByPath: {
...prev.branchByPath,
[normalizedPath]: event.branch,
},
};
});
});
if (typeof cleanup === 'function') {
cleanupFns.push(cleanup);
}
}
// Tool approval events from CLI control_request protocol
if (api.teams?.onToolApprovalEvent) {
const cleanup = api.teams.onToolApprovalEvent((_event: unknown, data: unknown) => {

View file

@ -372,11 +372,11 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
}
}
// For team tabs, re-select the team so global selectedTeamData matches this tab.
// For team and graph tabs, re-select the team so global selectedTeamData matches this tab.
// Without this, switching between team A and team B tabs leaves stale data
// because each TeamDetailView is kept mounted (CSS display-toggle) and its
// useEffect(teamName) only fires once on mount.
if (tab.type === 'team' && tab.teamName) {
if ((tab.type === 'team' || tab.type === 'graph') && tab.teamName) {
if (state.selectedTeamName !== tab.teamName) {
// Different team -- full reload (also auto-selects project via selectTeam)
void state.selectTeam(tab.teamName);

View file

@ -930,17 +930,31 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
setMessagesPanelWidth: (width: number) => set({ messagesPanelWidth: width }),
fetchBranches: async (paths: string[]) => {
const results: Record<string, string | null> = {};
for (const p of paths) {
try {
const branch = await api.teams.getProjectBranch(p);
results[normalizePath(p)] = branch;
} catch {
results[normalizePath(p)] = null;
}
}
const entries = await Promise.all(
paths.map(async (p) => {
try {
const branch = await api.teams.getProjectBranch(p);
return [normalizePath(p), branch] as const;
} catch {
return [normalizePath(p), null] as const;
}
})
);
const results: Record<string, string | null> = Object.fromEntries(entries);
if (Object.keys(results).length > 0) {
set((state) => ({ branchByPath: { ...state.branchByPath, ...results } }));
set((state) => {
let changed = false;
for (const [key, value] of Object.entries(results)) {
if (state.branchByPath[key] !== value) {
changed = true;
break;
}
}
if (!changed) {
return {};
}
return { branchByPath: { ...state.branchByPath, ...results } };
});
}
},

View file

@ -51,6 +51,7 @@ import type {
MemberFullStats,
MemberLogSummary,
MemberSpawnStatusesSnapshot,
ProjectBranchChangeEvent,
ReplaceMembersRequest,
SendMessageRequest,
SendMessageResult,
@ -489,6 +490,7 @@ export interface TeamsAPI {
value: 'lead' | 'user' | null
) => Promise<void>;
getProjectBranch: (projectPath: string) => Promise<string | null>;
setProjectBranchTracking: (projectPath: string, enabled: boolean) => Promise<void>;
getAttachments: (teamName: string, messageId: string) => Promise<AttachmentFileData[]>;
killProcess: (teamName: string, pid: number) => Promise<void>;
getLeadActivity: (teamName: string) => Promise<LeadActivitySnapshot>;
@ -530,6 +532,9 @@ export interface TeamsAPI {
attachmentId: string,
mimeType: string
) => Promise<void>;
onProjectBranchChange: (
callback: (event: unknown, data: ProjectBranchChangeEvent) => void
) => () => void;
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;
onProvisioningProgress: (
callback: (event: unknown, data: TeamProvisioningProgress) => void

View file

@ -579,6 +579,11 @@ export interface TeamChangeEvent {
detail?: string;
}
export interface ProjectBranchChangeEvent {
projectPath: string;
branch: string | null;
}
/** Per-member spawn status entry, exposed to renderer via IPC. */
export interface MemberSpawnStatusEntry {
status: MemberSpawnStatus;

View file

@ -108,14 +108,23 @@ export function isOnlyTeammateMessageBlocks(text: string): boolean {
// Combined protocol noise check for lead thoughts
// ---------------------------------------------------------------------------
/**
* Detects `<teammate-message>` opening tags (even without closing tag).
* Claude's lead model sometimes echoes raw teammate message XML in assistant
* text output these are always protocol artifacts, never real user content.
*/
const TEAMMATE_MESSAGE_OPEN_RE = /^\s*<teammate-message\s/;
/**
* Returns true if a lead thought text is entirely protocol noise and should
* be hidden from the user. Covers:
* 1. Structured JSON noise (idle_notification, shutdown_*, etc.)
* 2. Text that consists solely of `<teammate-message>` XML blocks
* 2. Text that consists solely of `<teammate-message>` XML blocks (closed tags)
* 3. Text starting with an unclosed `<teammate-message>` tag
*/
export function isThoughtProtocolNoise(text: string): boolean {
if (isInboxNoiseMessage(text)) return true;
if (isOnlyTeammateMessageBlocks(text)) return true;
if (TEAMMATE_MESSAGE_OPEN_RE.test(text)) return true;
return false;
}

View file

@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { BranchStatusService } from '@main/services/team/BranchStatusService';
import type { ProjectBranchChangeEvent } from '@shared/types';
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
describe('BranchStatusService', () => {
afterEach(() => {
vi.useRealTimers();
});
it('emits initial branch and only pushes later when the branch actually changes', async () => {
vi.useFakeTimers();
const getBranch = vi
.fn()
.mockResolvedValueOnce('main')
.mockResolvedValueOnce('main')
.mockResolvedValueOnce('feature/refactor');
const events: ProjectBranchChangeEvent[] = [];
const service = new BranchStatusService((event) => events.push(event), { getBranch });
await service.setTracking('/repo', true);
expect(events).toEqual([{ projectPath: '/repo', branch: 'main' }]);
await vi.advanceTimersByTimeAsync(20_000);
expect(events).toHaveLength(1);
await vi.advanceTimersByTimeAsync(20_000);
expect(events).toEqual([
{ projectPath: '/repo', branch: 'main' },
{ projectPath: '/repo', branch: 'feature/refactor' },
]);
service.dispose();
});
it('stops polling once the last subscriber unsubscribes', async () => {
vi.useFakeTimers();
const getBranch = vi.fn().mockResolvedValue('main');
const service = new BranchStatusService(() => undefined, { getBranch });
await service.setTracking('/repo', true);
await service.setTracking('/repo', true);
expect(getBranch).toHaveBeenCalledTimes(1);
await service.setTracking('/repo', false);
await vi.advanceTimersByTimeAsync(20_000);
expect(getBranch).toHaveBeenCalledTimes(2);
await service.setTracking('/repo', false);
await vi.advanceTimersByTimeAsync(40_000);
expect(getBranch).toHaveBeenCalledTimes(2);
service.dispose();
});
it('drops stale in-flight branch results after disable and re-enable', async () => {
const first = createDeferred<string | null>();
const second = createDeferred<string | null>();
const getBranch = vi
.fn()
.mockImplementationOnce(() => first.promise)
.mockImplementationOnce(() => second.promise);
const events: ProjectBranchChangeEvent[] = [];
const service = new BranchStatusService((event) => events.push(event), { getBranch });
const firstEnable = service.setTracking('/repo', true);
await Promise.resolve();
await service.setTracking('/repo', false);
const secondEnable = service.setTracking('/repo', true);
await Promise.resolve();
first.resolve('main');
await firstEnable;
expect(events).toEqual([]);
second.resolve('feature/refactor');
await secondEnable;
expect(events).toEqual([{ projectPath: '/repo', branch: 'feature/refactor' }]);
service.dispose();
});
});

View file

@ -97,6 +97,72 @@ describe('TeamMemberLogsFinder', () => {
expect(lead?.projectId).toBe(projectId);
});
it('listAttributedSubagentFiles only returns files from the current lead session for live tracking', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
setClaudeBasePathOverride(tmpDir);
const teamName = 'live-tools';
const projectPath = '/Users/test/live-tools';
const projectId = '-Users-test-live-tools';
const currentSessionId = 'session-current';
const oldSessionId = 'session-old';
await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true });
await fs.writeFile(
path.join(tmpDir, 'teams', teamName, 'config.json'),
JSON.stringify(
{
name: teamName,
projectPath,
leadSessionId: currentSessionId,
sessionHistory: [oldSessionId],
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'alice', agentType: 'general-purpose' },
],
},
null,
2
),
'utf8'
);
const projectRoot = path.join(tmpDir, 'projects', projectId);
await fs.mkdir(path.join(projectRoot, currentSessionId, 'subagents'), { recursive: true });
await fs.mkdir(path.join(projectRoot, oldSessionId, 'subagents'), { recursive: true });
const attributedLog = [
JSON.stringify({
timestamp: '2026-01-01T00:00:01.000Z',
type: 'user',
message: { role: 'user', content: `You are alice, a developer on team "${teamName}" (${teamName}).` },
}),
JSON.stringify({
timestamp: '2026-01-01T00:00:02.000Z',
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'OK' }] },
}),
].join('\n') + '\n';
await fs.writeFile(
path.join(projectRoot, currentSessionId, 'subagents', 'agent-current.jsonl'),
attributedLog,
'utf8'
);
await fs.writeFile(
path.join(projectRoot, oldSessionId, 'subagents', 'agent-old.jsonl'),
attributedLog,
'utf8'
);
const finder = new TeamMemberLogsFinder();
const files = await finder.listAttributedSubagentFiles(teamName);
expect(files).toHaveLength(1);
expect(files[0]?.sessionId).toBe(currentSessionId);
expect(files[0]?.filePath).toContain(path.join(currentSessionId, 'subagents'));
});
it('detects member via teammate_id attribute in <teammate-message> tag', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
setClaudeBasePathOverride(tmpDir);

View file

@ -7,6 +7,17 @@ import {
import type { InboxMessage } from '../../../../../src/shared/types';
function makeLeadSessionMsg(text: string, overrides?: Partial<InboxMessage>): InboxMessage {
return {
from: 'team-lead',
text,
timestamp: '2026-03-28T18:30:00.000Z',
read: true,
source: 'lead_session',
...overrides,
};
}
describe('LeadThoughtsGroup', () => {
it('does not classify slash command results as lead thoughts', () => {
const resultMessage: InboxMessage = {
@ -30,4 +41,81 @@ describe('LeadThoughtsGroup', () => {
},
]);
});
describe('teammate-message noise filtering', () => {
it('excludes closed <teammate-message> blocks with idle_notification from timeline', () => {
const noise = makeLeadSessionMsg(
'<teammate-message teammate_id="tom" color="blue"> {"type":"idle_notification","from":"tom","timestamp":"2026-03-28T18:30:49.102Z","idleReason":"available"}</teammate-message>'
);
expect(isLeadThought(noise)).toBe(false);
expect(groupTimelineItems([noise])).toEqual([]);
});
it('excludes unclosed <teammate-message> blocks with idle_notification from timeline', () => {
const noise = makeLeadSessionMsg(
'<teammate-message teammate_id="tom" color="blue"> {"type":"idle_notification","from":"tom","timestamp":"2026-03-28T18:30:49.102Z","idleReason":"available"}'
);
expect(isLeadThought(noise)).toBe(false);
expect(groupTimelineItems([noise])).toEqual([]);
});
it('excludes <teammate-message> blocks with shutdown_request from timeline', () => {
const noise = makeLeadSessionMsg(
'<teammate-message teammate_id="bob" color="green"> {"type":"shutdown_request"}</teammate-message>'
);
expect(isLeadThought(noise)).toBe(false);
expect(groupTimelineItems([noise])).toEqual([]);
});
it('excludes raw idle_notification JSON from timeline', () => {
const noise = makeLeadSessionMsg(
'{"type":"idle_notification","from":"alice","idleReason":"available"}'
);
expect(isLeadThought(noise)).toBe(false);
expect(groupTimelineItems([noise])).toEqual([]);
});
it('does not exclude noise messages with a recipient (captured SendMessage)', () => {
const sendMsg = makeLeadSessionMsg(
'{"type":"idle_notification","from":"tom","idleReason":"available"}',
{ to: 'alice' }
);
// Has a recipient, so isLeadThought returns false (line 61), but isLeadSessionNoise
// also returns false because `to` is non-empty — message should appear in timeline.
expect(groupTimelineItems([sendMsg])).toEqual([
{ type: 'message', message: sendMsg },
]);
});
it('does not exclude non-lead noise messages from timeline', () => {
const inboxMsg: InboxMessage = {
from: 'tom',
text: '{"type":"idle_notification","from":"tom","idleReason":"available"}',
timestamp: '2026-03-28T18:30:00.000Z',
read: true,
// No source — regular inbox message
};
expect(groupTimelineItems([inboxMsg])).toEqual([
{ type: 'message', message: inboxMsg },
]);
});
it('keeps regular lead thoughts alongside noise', () => {
const thought = makeLeadSessionMsg('Team is ready. Distributing tasks...');
const noise = makeLeadSessionMsg(
'<teammate-message teammate_id="tom" color="blue"> {"type":"idle_notification","from":"tom","idleReason":"available"}</teammate-message>'
);
const thought2 = makeLeadSessionMsg('Assigned task #1 to bob.');
const items = groupTimelineItems([thought, noise, thought2]);
// Noise is excluded; both thoughts should be grouped
expect(items.length).toBe(1);
expect(items[0].type).toBe('lead-thoughts');
if (items[0].type === 'lead-thoughts') {
expect(items[0].group.thoughts).toHaveLength(2);
expect(items[0].group.thoughts[0].text).toBe(thought.text);
expect(items[0].group.thoughts[1].text).toBe(thought2.text);
}
});
});
});

View file

@ -0,0 +1,474 @@
import { describe, expect, it } from 'vitest';
import { TeamGraphAdapter } from '@renderer/features/agent-graph/adapters/TeamGraphAdapter';
import type { InboxMessage, TeamData, TeamTaskWithKanban } from '@shared/types/team';
function createBaseTeamData(
overrides?: Partial<TeamData> & {
tasks?: TeamTaskWithKanban[];
messages?: InboxMessage[];
}
): TeamData {
return {
teamName: 'my-team',
config: {
name: 'My Team',
members: [{ name: 'team-lead' }, { name: 'alice' }, { name: 'bob' }],
projectPath: '/repo',
},
members: [
{
name: 'team-lead',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
agentType: 'team-lead',
},
{
name: 'alice',
status: 'active',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
},
{
name: 'bob',
status: 'active',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
},
],
tasks: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
isAlive: true,
...overrides,
};
}
describe('TeamGraphAdapter particles', () => {
it('creates a message particle for a new incoming message from the newest message set', () => {
const adapter = TeamGraphAdapter.create();
const baseline = createBaseTeamData();
adapter.adapt(baseline, 'my-team');
const next = createBaseTeamData({
messages: [
{
from: 'alice',
to: 'team-lead',
text: 'Please check the latest build output now',
timestamp: '2026-03-28T19:00:01.000Z',
read: false,
messageId: 'msg-new',
},
],
});
const graph = adapter.adapt(next, 'my-team');
expect(graph.particles).toHaveLength(1);
expect(graph.particles[0]).toMatchObject({
kind: 'inbox_message',
progress: 0,
label: '✉ Please check the latest b…',
});
});
it('creates a comment particle for the first new task comment with preview text', () => {
const adapter = TeamGraphAdapter.create();
const baseline = createBaseTeamData({
tasks: [
{
id: 'task-1',
displayId: '#1',
subject: 'Investigate',
owner: 'alice',
status: 'in_progress',
comments: [],
reviewState: 'none',
} as TeamTaskWithKanban,
],
});
adapter.adapt(baseline, 'my-team');
const next = createBaseTeamData({
tasks: [
{
id: 'task-1',
displayId: '#1',
subject: 'Investigate',
owner: 'alice',
status: 'in_progress',
comments: [
{
id: 'comment-1',
author: 'alice',
text: 'Need clarification on the acceptance criteria before I continue',
createdAt: '2026-03-28T19:00:02.000Z',
type: 'regular',
},
],
reviewState: 'none',
} as TeamTaskWithKanban,
],
});
const graph = adapter.adapt(next, 'my-team');
expect(graph.particles).toHaveLength(1);
expect(graph.particles[0]).toMatchObject({
kind: 'task_comment',
label: '💬 Need clarification on the…',
});
});
it('creates a synthetic message edge for comments from non-owner participants', () => {
const adapter = TeamGraphAdapter.create();
const baseline = createBaseTeamData({
tasks: [
{
id: 'task-2',
displayId: '#2',
subject: 'Fix regression',
owner: 'bob',
status: 'in_progress',
comments: [],
reviewState: 'none',
} as TeamTaskWithKanban,
],
});
adapter.adapt(baseline, 'my-team');
const next = createBaseTeamData({
tasks: [
{
id: 'task-2',
displayId: '#2',
subject: 'Fix regression',
owner: 'bob',
status: 'in_progress',
comments: [
{
id: 'comment-2',
author: 'alice',
text: 'I found the root cause, handing notes over now',
createdAt: '2026-03-28T19:00:03.000Z',
type: 'regular',
},
],
reviewState: 'none',
} as TeamTaskWithKanban,
],
});
const graph = adapter.adapt(next, 'my-team');
expect(graph.particles).toHaveLength(1);
expect(graph.particles[0]).toMatchObject({
kind: 'task_comment',
label: '💬 I found the root cause, h…',
});
expect(
graph.edges.some((edge) => edge.id === 'edge:msg:member:my-team:alice:task:my-team:task-2')
).toBe(true);
});
it('does not collapse two new inbox particles that share a timestamp but differ in content', () => {
const adapter = TeamGraphAdapter.create();
adapter.adapt(createBaseTeamData(), 'my-team');
const next = createBaseTeamData({
messages: [
{
from: 'alice',
to: 'team-lead',
text: 'First payload',
timestamp: '2026-03-28T19:00:01.000Z',
read: false,
},
{
from: 'bob',
to: 'team-lead',
text: 'Second payload',
timestamp: '2026-03-28T19:00:01.000Z',
read: false,
},
],
});
const graph = adapter.adapt(next, 'my-team');
expect(graph.particles).toHaveLength(2);
expect(graph.particles.every((particle) => particle.kind === 'inbox_message')).toBe(true);
});
it('creates particles for each newly appended task comment, not only the latest one', () => {
const adapter = TeamGraphAdapter.create();
const baseline = createBaseTeamData({
tasks: [
{
id: 'task-4',
displayId: '#4',
subject: 'Burst comments',
owner: 'alice',
status: 'in_progress',
comments: [],
reviewState: 'none',
} as TeamTaskWithKanban,
],
});
adapter.adapt(baseline, 'my-team');
const next = createBaseTeamData({
tasks: [
{
id: 'task-4',
displayId: '#4',
subject: 'Burst comments',
owner: 'alice',
status: 'in_progress',
comments: [
{
id: 'comment-4a',
author: 'alice',
text: 'First burst comment',
createdAt: '2026-03-28T19:00:06.000Z',
type: 'regular',
},
{
id: 'comment-4b',
author: 'bob',
text: 'Second burst comment',
createdAt: '2026-03-28T19:00:07.000Z',
type: 'regular',
},
],
reviewState: 'none',
} as TeamTaskWithKanban,
],
});
const graph = adapter.adapt(next, 'my-team');
expect(graph.particles).toHaveLength(2);
expect(graph.particles.every((particle) => particle.kind === 'task_comment')).toBe(true);
});
it('maps the real lead name to the lead node for inbox messages and task comments', () => {
const adapter = TeamGraphAdapter.create();
const baseline = createBaseTeamData({
config: {
name: 'My Team',
members: [{ name: 'olivia', agentType: 'lead' }, { name: 'alice' }],
projectPath: '/repo',
},
members: [
{
name: 'olivia',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
agentType: 'lead',
},
{
name: 'alice',
status: 'active',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
},
],
tasks: [
{
id: 'task-3',
displayId: '#3',
subject: 'Review notes',
owner: 'alice',
status: 'in_progress',
comments: [],
reviewState: 'none',
} as TeamTaskWithKanban,
],
messages: [],
});
adapter.adapt(baseline, 'my-team');
const next = createBaseTeamData({
config: baseline.config,
members: baseline.members,
tasks: [
{
id: 'task-3',
displayId: '#3',
subject: 'Review notes',
owner: 'alice',
status: 'in_progress',
comments: [
{
id: 'comment-3',
author: 'olivia',
text: 'Please tighten the acceptance criteria before merge',
createdAt: '2026-03-28T19:00:04.000Z',
type: 'regular',
},
],
reviewState: 'none',
} as TeamTaskWithKanban,
],
messages: [
{
from: 'olivia',
to: 'alice',
text: 'Please pick this up next',
timestamp: '2026-03-28T19:00:05.000Z',
read: false,
messageId: 'lead-msg-1',
},
],
});
const graph = adapter.adapt(next, 'my-team');
expect(graph.particles).toHaveLength(2);
expect(graph.particles.map((particle) => particle.kind).sort()).toEqual([
'inbox_message',
'task_comment',
]);
});
it('creates inbox particles for all unseen messages, not only the newest 20', () => {
const adapter = TeamGraphAdapter.create();
adapter.adapt(createBaseTeamData(), 'my-team');
const messages: InboxMessage[] = Array.from({ length: 25 }, (_, index) => ({
from: index % 2 === 0 ? 'alice' : 'bob',
to: 'team-lead',
text: `Payload ${index + 1}`,
timestamp: `2026-03-28T19:00:${String(index).padStart(2, '0')}.000Z`,
read: false,
messageId: `msg-${index + 1}`,
}));
const graph = adapter.adapt(createBaseTeamData({ messages }), 'my-team');
expect(graph.particles).toHaveLength(25);
expect(graph.particles.every((particle) => particle.kind === 'inbox_message')).toBe(true);
});
it('scopes inbox particle ids by team name to avoid cross-team collisions', () => {
const adapter = TeamGraphAdapter.create();
adapter.adapt(createBaseTeamData({ teamName: 'team-a' }), 'team-a');
const graph = adapter.adapt(
createBaseTeamData({
teamName: 'team-a',
messages: [
{
from: 'alice',
to: 'team-lead',
text: 'Same payload',
timestamp: '2026-03-28T19:10:00.000Z',
read: false,
messageId: 'shared-msg',
},
],
}),
'team-a'
);
expect(graph.particles[0]?.id).toBe('particle:msg:team-a:shared-msg');
});
it('does not return a cached snapshot when message content changes at the same list length', () => {
const adapter = TeamGraphAdapter.create();
adapter.adapt(
createBaseTeamData({
messages: [
{
from: 'alice',
to: 'team-lead',
text: 'Old payload',
timestamp: '2026-03-28T19:20:00.000Z',
read: false,
messageId: 'msg-old',
},
],
}),
'my-team'
);
const graph = adapter.adapt(
createBaseTeamData({
messages: [
{
from: 'bob',
to: 'team-lead',
text: 'New payload',
timestamp: '2026-03-28T19:20:01.000Z',
read: false,
messageId: 'msg-new',
},
],
}),
'my-team'
);
expect(graph.particles).toHaveLength(1);
expect(graph.particles[0]).toMatchObject({
id: 'particle:msg:my-team:msg-new',
kind: 'inbox_message',
});
});
it('does not return a cached snapshot when a member status changes at the same list size', () => {
const adapter = TeamGraphAdapter.create();
adapter.adapt(createBaseTeamData(), 'my-team');
const graph = adapter.adapt(
createBaseTeamData({
members: [
{
name: 'team-lead',
status: 'active',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
agentType: 'team-lead',
},
{
name: 'alice',
status: 'idle',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
},
{
name: 'bob',
status: 'active',
currentTaskId: null,
taskCount: 1,
lastActiveAt: null,
messageCount: 0,
},
],
}),
'my-team'
);
const alice = graph.nodes.find((node) => node.id === 'member:my-team:alice');
expect(alice?.state).toBe('idle');
});
});

View file

@ -341,6 +341,35 @@ describe('tabSlice', () => {
// Sidebar state should be preserved (not cleared) when switching to dashboard
expect(store.getState().selectedProjectId).toBe('project-2');
});
it('should re-select the team when switching to a graph tab for another team', () => {
const selectTeamSpy = vi.fn(async () => undefined);
store.setState({
selectedTeamName: 'team-a',
selectedTeamData: {
teamName: 'team-a',
config: { name: 'Team A', projectPath: '/repo/a' },
members: [],
tasks: [],
messages: [],
kanbanState: { teamName: 'team-a', reviewers: [], tasks: {} },
processes: [],
isAlive: true,
},
selectTeam: selectTeamSpy,
} as never);
store.getState().openTab({
type: 'graph',
teamName: 'team-b',
label: 'Team B Graph',
});
const graphTabId = store.getState().activeTabId!;
store.getState().setActiveTab(graphTabId);
expect(selectTeamSpy).toHaveBeenCalledWith('team-b');
});
});
describe('saveTabScrollPosition', () => {