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:
parent
8aaf53ae4a
commit
26f7b9158f
32 changed files with 1286 additions and 100 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ export const COLORS = {
|
|||
|
||||
// Particle kind colors
|
||||
particleMessage: '#66ccff',
|
||||
particleInboxMessage: '#66ccff',
|
||||
particleTaskComment: '#ff9ad5',
|
||||
particleTaskAssign: '#ffbb44',
|
||||
particleReviewRequest: '#f59e0b',
|
||||
particleReviewResponse: '#22c55e',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
133
src/main/services/team/BranchStatusService.ts
Normal file
133
src/main/services/team/BranchStatusService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export { CascadeGuard } from './CascadeGuard';
|
||||
export { BranchStatusService } from './BranchStatusService';
|
||||
export { ChangeExtractorService } from './ChangeExtractorService';
|
||||
export { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
|
||||
export { CrossTeamOutbox } from './CrossTeamOutbox';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 } };
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
99
test/main/services/team/BranchStatusService.test.ts
Normal file
99
test/main/services/team/BranchStatusService.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
474
test/renderer/features/agent-graph/TeamGraphAdapter.test.ts
Normal file
474
test/renderer/features/agent-graph/TeamGraphAdapter.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue