diff --git a/packages/agent-graph/src/canvas/draw-particles.ts b/packages/agent-graph/src/canvas/draw-particles.ts index 863aa250..554c59cc 100644 --- a/packages/agent-graph/src/canvas/draw-particles.ts +++ b/packages/agent-graph/src/canvas/draw-particles.ts @@ -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(); diff --git a/packages/agent-graph/src/constants/colors.ts b/packages/agent-graph/src/constants/colors.ts index 8474d1e6..72537d31 100644 --- a/packages/agent-graph/src/constants/colors.ts +++ b/packages/agent-graph/src/constants/colors.ts @@ -55,6 +55,8 @@ export const COLORS = { // Particle kind colors particleMessage: '#66ccff', + particleInboxMessage: '#66ccff', + particleTaskComment: '#ff9ad5', particleTaskAssign: '#ffbb44', particleReviewRequest: '#f59e0b', particleReviewResponse: '#22c55e', diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index 1f104cfc..a8494fcb 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -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( diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index b0662f5e..8517e081 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -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' diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index 24c0f031..fe401daa 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -29,6 +29,7 @@ export interface GraphViewProps { events?: GraphEventPort; config?: Partial; 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(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); diff --git a/src/main/index.ts b/src/main/index.ts index 3793f78f..3c060d7f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 | 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) { diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 9d2934ca..35fcaa8b 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -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, diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 773d3bfb..4b313bfe 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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( operation: string, handler: () => Promise @@ -669,6 +683,23 @@ async function handleSetChangePresenceTracking( }); } +async function handleSetProjectBranchTracking( + _event: IpcMainInvokeEvent, + projectPath: unknown, + enabled: unknown +): Promise> { + 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, diff --git a/src/main/services/parsing/GitIdentityResolver.ts b/src/main/services/parsing/GitIdentityResolver.ts index 03043082..3200c1af 100644 --- a/src/main/services/parsing/GitIdentityResolver.ts +++ b/src/main/services/parsing/GitIdentityResolver.ts @@ -463,9 +463,15 @@ class GitIdentityResolver { * @param projectPath - The filesystem path to check * @returns Branch name or null */ - async getBranch(projectPath: string): Promise { + async getBranch( + projectPath: string, + options?: { + forceRefresh?: boolean; + } + ): Promise { + 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; } diff --git a/src/main/services/team/BranchStatusService.ts b/src/main/services/team/BranchStatusService.ts new file mode 100644 index 00000000..3f7bc1f5 --- /dev/null +++ b/src/main/services/team/BranchStatusService.ts @@ -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; +} + +interface TrackedPathState { + actualPath: string; + refCount: number; + token: number; +} + +const UNSET_BRANCH = Symbol('unset-branch'); + +export class BranchStatusService { + private readonly trackedPaths = new Map(); + private readonly inFlightChecks = new Map>(); + private readonly lastEmittedBranchByPath = new Map(); + private pollTimer: ReturnType | null = null; + private nextToken = 1; + + constructor( + private readonly emitBranchChange: (event: ProjectBranchChangeEvent) => void, + private readonly resolver: BranchResolver = gitIdentityResolver + ) {} + + async setTracking(projectPath: string, enabled: boolean): Promise { + 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 { + 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; + } +} diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index f4a70432..ed3eea68 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -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; diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index afb09e5e..27a14f0d 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -1,4 +1,5 @@ export { CascadeGuard } from './CascadeGuard'; +export { BranchStatusService } from './BranchStatusService'; export { ChangeExtractorService } from './ChangeExtractorService'; export { ClaudeBinaryResolver } from './ClaudeBinaryResolver'; export { CrossTeamOutbox } from './CrossTeamOutbox'; diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 8d523c6f..509321c7 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -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'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 85eb3fdf..6caa2728 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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(TEAM_GET_PROJECT_BRANCH, projectPath); }, + setProjectBranchTracking: async (projectPath: string, enabled: boolean) => { + return invokeIpcWithResult(TEAM_SET_PROJECT_BRANCH_TRACKING, projectPath, enabled); + }, getAttachments: async (teamName: string, messageId: string) => { return invokeIpcWithResult(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, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 419c4dde..d24b976c 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -839,6 +839,9 @@ export class HttpAPIClient implements ElectronAPI { getProjectBranch: async (_projectPath: string): Promise => { return null; }, + setProjectBranchTracking: async (): Promise => { + // Not available in browser mode — no-op. + }, getAttachments: async ( _teamName: string, _messageId: string @@ -918,6 +921,9 @@ export class HttpAPIClient implements ElectronAPI { ): Promise => { 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) diff --git a/src/renderer/components/layout/PaneContent.tsx b/src/renderer/components/layout/PaneContent.tsx index 1219722e..fb661611 100644 --- a/src/renderer/components/layout/PaneContent.tsx +++ b/src/renderer/components/layout/PaneContent.tsx @@ -68,7 +68,7 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => { {tab.type === 'schedules' && } {tab.type === 'graph' && ( - + )} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 021305fb..0a5104c9 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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 diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 765e8861..39c0508f 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -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 `` 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 }); } diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index 32252e9a..724893b2 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -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' + ); + } } diff --git a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx index 951d3079..4577c7d3 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx @@ -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 }) => ( 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(); -let pollTimer: ReturnType | 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; diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 72632133..6a32b2d0 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -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) => { diff --git a/src/renderer/store/slices/tabSlice.ts b/src/renderer/store/slices/tabSlice.ts index 4fc1e8c4..4bcf5e1f 100644 --- a/src/renderer/store/slices/tabSlice.ts +++ b/src/renderer/store/slices/tabSlice.ts @@ -372,11 +372,11 @@ export const createTabSlice: StateCreator = (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); diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 914b776f..6d1fac51 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -930,17 +930,31 @@ export const createTeamSlice: StateCreator = (set, setMessagesPanelWidth: (width: number) => set({ messagesPanelWidth: width }), fetchBranches: async (paths: string[]) => { - const results: Record = {}; - 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 = 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 } }; + }); } }, diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 43f94377..b0485fa0 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -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; getProjectBranch: (projectPath: string) => Promise; + setProjectBranchTracking: (projectPath: string, enabled: boolean) => Promise; getAttachments: (teamName: string, messageId: string) => Promise; killProcess: (teamName: string, pid: number) => Promise; getLeadActivity: (teamName: string) => Promise; @@ -530,6 +532,9 @@ export interface TeamsAPI { attachmentId: string, mimeType: string ) => Promise; + onProjectBranchChange: ( + callback: (event: unknown, data: ProjectBranchChangeEvent) => void + ) => () => void; onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void; onProvisioningProgress: ( callback: (event: unknown, data: TeamProvisioningProgress) => void diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 59f236f9..eaec6531 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -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; diff --git a/src/shared/utils/inboxNoise.ts b/src/shared/utils/inboxNoise.ts index 5a85497c..dff92e57 100644 --- a/src/shared/utils/inboxNoise.ts +++ b/src/shared/utils/inboxNoise.ts @@ -108,14 +108,23 @@ export function isOnlyTeammateMessageBlocks(text: string): boolean { // Combined protocol noise check for lead thoughts // --------------------------------------------------------------------------- +/** + * Detects `` 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*