From 8398d29fc050f0a7b4c673def06f2376ef14cf99 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 15 Apr 2026 17:38:21 +0300 Subject: [PATCH 1/6] fix(team): recover root member session logs --- src/main/services/team/TeamConfigReader.ts | 53 +- .../services/team/TeamMemberLogsFinder.ts | 652 ++++++++++++------ .../team/TeamTranscriptProjectResolver.ts | 297 ++++++++ src/main/services/team/TeammateToolTracker.ts | 2 +- .../discovery/TeamTranscriptSourceLocator.ts | 111 +-- .../components/team/members/MemberLogsTab.tsx | 31 +- src/shared/types/team.ts | 11 +- .../team/TeamMemberLogsFinder.test.ts | 114 ++- .../team/TeamTranscriptSourceLocator.test.ts | 114 +++ 9 files changed, 1066 insertions(+), 319 deletions(-) create mode 100644 src/main/services/team/TeamTranscriptProjectResolver.ts create mode 100644 test/main/services/team/TeamTranscriptSourceLocator.test.ts diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 7143cf53..f8c4fd49 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -32,6 +32,51 @@ const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200; const MAX_LAUNCH_STATE_BYTES = 32 * 1024; const TEAM_LAUNCH_STATE_FILE = 'launch-state.json'; +function normalizeProjectPathCandidate(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function resolveProjectPathFromConfig( + config: Pick +): string | undefined { + const direct = normalizeProjectPathCandidate(config.projectPath); + if (direct) { + return direct; + } + + const leadMemberCwd = (config.members ?? []).find((member) => isLeadMember(member))?.cwd; + const leadResolved = normalizeProjectPathCandidate(leadMemberCwd); + if (leadResolved) { + return leadResolved; + } + + const distinctMemberCwds = Array.from( + new Set( + (config.members ?? []) + .map((member) => normalizeProjectPathCandidate(member.cwd)) + .filter((cwd): cwd is string => Boolean(cwd)) + ) + ); + if (distinctMemberCwds.length === 1) { + return distinctMemberCwds[0]; + } + + if (Array.isArray(config.projectPathHistory)) { + for (let i = config.projectPathHistory.length - 1; i >= 0; i--) { + const historyValue = normalizeProjectPathCandidate(config.projectPathHistory[i]); + if (historyValue) { + return historyValue; + } + } + } + + return undefined; +} + interface LaunchStateSummary { partialLaunchFailure?: true; expectedMemberCount?: number; @@ -250,10 +295,7 @@ export class TeamConfigReader { typeof config.color === 'string' && config.color.trim().length > 0 ? config.color : undefined; - projectPath = - typeof config.projectPath === 'string' && config.projectPath.trim().length > 0 - ? config.projectPath - : undefined; + projectPath = resolveProjectPathFromConfig(config); leadSessionId = typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 ? config.leadSessionId @@ -470,7 +512,8 @@ export class TeamConfigReader { if (typeof config.name !== 'string' || config.name.trim() === '') { return null; } - return config; + const resolvedProjectPath = resolveProjectPathFromConfig(config); + return resolvedProjectPath ? { ...config, projectPath: resolvedProjectPath } : config; } catch (error) { if (error instanceof FileReadTimeoutError) { logger.warn(`[getConfig] ${error.message}`); diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index c25220ac..b2dfdbe9 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -1,4 +1,3 @@ -import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder'; import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser'; @@ -14,8 +13,13 @@ import { import { TeamConfigReader } from './TeamConfigReader'; import { TeamInboxReader } from './TeamInboxReader'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; +import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver'; -import type { MemberLogSummary, MemberSubagentLogSummary } from '@shared/types'; +import type { + MemberLogSummary, + MemberSessionLogSummary, + MemberSubagentLogSummary, +} from '@shared/types'; const logger = createLogger('Service:TeamMemberLogsFinder'); @@ -76,23 +80,32 @@ interface SubagentAttribution { firstTimestamp: string | null; } -function trimTrailingSlashes(value: string): string { - let end = value.length; - while (end > 0) { - const ch = value.charCodeAt(end - 1); - // '/' or '\' - if (ch === 47 || ch === 92) { - end--; - continue; - } - break; - } - return end === value.length ? value : value.slice(0, end); +interface RootSessionAttribution { + detectedMember: string; + description: string; + firstTimestamp: string | null; } +type LogCandidate = + | { + kind: 'subagent'; + filePath: string; + sessionId: string; + fileName: string; + } + | { + kind: 'member_session'; + filePath: string; + sessionId: string; + fileName: string; + }; + export class TeamMemberLogsFinder { private readonly fileMentionsCache = new Map(); - private readonly attributionCache = new Map(); + private readonly attributionCache = new Map< + string, + SubagentAttribution | RootSessionAttribution | null + >(); private readonly discoveryCache = new Map< string, { @@ -104,7 +117,10 @@ export class TeamMemberLogsFinder { constructor( private readonly configReader: TeamConfigReader = new TeamConfigReader(), private readonly inboxReader: TeamInboxReader = new TeamInboxReader(), - private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore() + private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(), + private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver( + configReader + ) ) {} async findMemberLogs( @@ -134,8 +150,8 @@ export class TeamMemberLogsFinder { } // ── Collect and parallel-scan subagent files ── - const candidates = await this.collectSubagentCandidates(projectDir, sessionIds); - const settled: (MemberSubagentLogSummary | null)[] = new Array(candidates.length).fill(null); + const candidates = await this.collectLogCandidates(projectDir, sessionIds, config); + const settled: (MemberLogSummary | null)[] = new Array(candidates.length).fill(null); let nextIdx = 0; const scanWorker = async (): Promise => { @@ -152,17 +168,27 @@ export class TeamMemberLogsFinder { continue; } } - const summary = await this.parseSubagentSummary( - c.filePath, - projectId, - c.sessionId, - c.fileName, - memberName, - knownMembers - ); + const summary = + c.kind === 'subagent' + ? await this.parseSubagentSummary( + c.filePath, + projectId, + c.sessionId, + c.fileName, + memberName, + knownMembers + ) + : await this.parseMemberSessionSummary( + c.filePath, + projectId, + c.sessionId, + memberName, + teamName, + knownMembers + ); if (summary) settled[idx] = summary; } catch (err) { - logger.warn(`Failed to parse subagent summary: ${c.filePath}`, err); + logger.warn(`Failed to parse member log summary: ${c.filePath}`, err); } } }; @@ -192,7 +218,7 @@ export class TeamMemberLogsFinder { this.discoveryCache.delete(teamName); } - const discovery = await this.discoverProjectSessions(teamName); + const discovery = await this.discoverProjectSessions(teamName, options); if (!discovery) { return null; } @@ -258,7 +284,7 @@ export class TeamMemberLogsFinder { const tLead = performance.now(); // ── Collect all subagent file candidates ── - const candidates = await this.collectSubagentCandidates(projectDir, sessionIds); + const candidates = await this.collectLogCandidates(projectDir, sessionIds, config); // ── Parallel scan with concurrency limit ── const settled: (MemberLogSummary | null)[] = new Array(candidates.length).fill(null); @@ -273,20 +299,41 @@ export class TeamMemberLogsFinder { if (!(await this.fileMentionsTaskIdCached(c.filePath, teamName, taskId, false, sinceMs))) continue; mentionHits++; - const attribution = await this.attributeSubagent(c.filePath, knownMembers); - if (!attribution) continue; - const summary = await this.parseSubagentSummary( - c.filePath, - projectId, - c.sessionId, - c.fileName, - attribution.detectedMember, - knownMembers, - attribution - ); + const summary = + c.kind === 'subagent' + ? await (async (): Promise => { + const attribution = await this.attributeSubagent(c.filePath, knownMembers); + if (!attribution) return null; + return this.parseSubagentSummary( + c.filePath, + projectId, + c.sessionId, + c.fileName, + attribution.detectedMember, + knownMembers, + attribution + ); + })() + : await (async (): Promise => { + const attribution = await this.attributeMemberSession( + c.filePath, + teamName, + knownMembers + ); + if (!attribution) return null; + return this.parseMemberSessionSummary( + c.filePath, + projectId, + c.sessionId, + attribution.detectedMember, + teamName, + knownMembers, + attribution + ); + })(); if (summary) settled[idx] = summary; } catch (err) { - logger.warn(`Failed to scan subagent file: ${c.filePath}`, err); + logger.warn(`Failed to scan member log file: ${c.filePath}`, err); } } }; @@ -368,14 +415,18 @@ export class TeamMemberLogsFinder { const key = log.kind === 'subagent' ? `subagent:${log.sessionId}:${log.subagentId}` - : `lead:${log.sessionId}`; + : log.kind === 'member_session' + ? `member:${log.sessionId}` + : `lead:${log.sessionId}`; seen.add(key); } for (const log of filteredOwnerLogs) { const key = log.kind === 'subagent' ? `subagent:${log.sessionId}:${log.subagentId}` - : `lead:${log.sessionId}`; + : log.kind === 'member_session' + ? `member:${log.sessionId}` + : `lead:${log.sessionId}`; if (!seen.has(key)) { seen.add(key); results.push(log); @@ -455,16 +506,28 @@ export class TeamMemberLogsFinder { const sinceMs = this.deriveSinceMs(options); const { projectDir, config, sessionIds, knownMembers } = discovery; - const refs: { filePath: string; memberName: string; sortTime: number }[] = []; + const refs: { + kind: LogCandidate['kind'] | 'lead_session'; + filePath: string; + memberName: string; + sessionId: string; + sortTime: number; + }[] = []; const seen = new Set(); const leadMemberName = config.members?.find((m) => isLeadMemberCheck(m))?.name?.trim() || 'team-lead'; - const pushRef = (filePath: string, memberName: string, sortTime = 0): void => { - const key = `${memberName.toLowerCase()}:${filePath}`; + const pushRef = ( + filePath: string, + memberName: string, + sortTime = 0, + kind: LogCandidate['kind'] | 'lead_session' = 'member_session', + sessionId = '' + ): void => { + const key = `${kind}:${sessionId}:${memberName.toLowerCase()}:${filePath}`; if (seen.has(key)) return; seen.add(key); - refs.push({ filePath, memberName, sortTime }); + refs.push({ kind, filePath, memberName, sessionId, sortTime }); }; if (config.leadSessionId) { @@ -473,7 +536,13 @@ export class TeamMemberLogsFinder { await fs.access(leadJsonl); if (await this.fileMentionsTaskIdCached(leadJsonl, teamName, taskId, true, sinceMs)) { const firstTimestamp = await this.probeFirstTimestamp(leadJsonl); - pushRef(leadJsonl, leadMemberName, await this.getSortTime(leadJsonl, firstTimestamp)); + pushRef( + leadJsonl, + leadMemberName, + await this.getSortTime(leadJsonl, firstTimestamp), + 'lead_session', + config.leadSessionId + ); } } catch { // file missing or unreadable @@ -482,7 +551,7 @@ export class TeamMemberLogsFinder { const tLead = performance.now(); // ── Collect all subagent file candidates ── - const candidates = await this.collectSubagentCandidates(projectDir, sessionIds); + const candidates = await this.collectLogCandidates(projectDir, sessionIds, config); // ── Parallel scan with concurrency limit ── let nextIdx = 0; @@ -496,15 +565,20 @@ export class TeamMemberLogsFinder { if (!(await this.fileMentionsTaskIdCached(c.filePath, teamName, taskId, false, sinceMs))) continue; mentionHits++; - const attribution = await this.attributeSubagent(c.filePath, knownMembers); + const attribution = + c.kind === 'subagent' + ? await this.attributeSubagent(c.filePath, knownMembers) + : await this.attributeMemberSession(c.filePath, teamName, knownMembers); if (!attribution) continue; pushRef( c.filePath, attribution.detectedMember, - await this.getSortTime(c.filePath, attribution.firstTimestamp) + await this.getSortTime(c.filePath, attribution.firstTimestamp), + c.kind, + c.sessionId ); } catch (err) { - logger.warn(`Failed to scan subagent file: ${c.filePath}`, err); + logger.warn(`Failed to scan member log file: ${c.filePath}`, err); } } }; @@ -585,7 +659,11 @@ export class TeamMemberLogsFinder { pushRef( log.filePath, log.memberName ?? normalizedOwner, - Number.isFinite(new Date(log.startTime).getTime()) ? new Date(log.startTime).getTime() : 0 + Number.isFinite(new Date(log.startTime).getTime()) + ? new Date(log.startTime).getTime() + : 0, + log.kind === 'lead_session' ? 'lead_session' : log.kind, + log.sessionId ); } } @@ -595,22 +673,28 @@ export class TeamMemberLogsFinder { { const refsByKey = new Map(); const leadRefs: (typeof refs)[0][] = []; + const memberSessionRefsByKey = new Map(); for (const ref of refs) { - if (ref.memberName.toLowerCase() === leadMemberName.toLowerCase()) { + if (ref.kind === 'lead_session') { leadRefs.push(ref); continue; } - const parts = ref.filePath.split(path.sep); - const subagentsIdx = parts.lastIndexOf('subagents'); - const sessionId = subagentsIdx > 0 ? parts[subagentsIdx - 1] : ''; - const key = `${sessionId}:${ref.memberName.toLowerCase()}`; + if (ref.kind === 'member_session') { + const key = `member:${ref.sessionId}:${ref.memberName.toLowerCase()}`; + const existing = memberSessionRefsByKey.get(key); + if (!existing || ref.sortTime > existing.sortTime) { + memberSessionRefsByKey.set(key, ref); + } + continue; + } + const key = `${ref.sessionId}:${ref.memberName.toLowerCase()}`; const existing = refsByKey.get(key); if (!existing || ref.sortTime > existing.sortTime) { refsByKey.set(key, ref); } } refs.length = 0; - refs.push(...leadRefs, ...refsByKey.values()); + refs.push(...leadRefs, ...memberSessionRefsByKey.values(), ...refsByKey.values()); } const sortedRefs = [...refs].sort((a, b) => b.sortTime - a.sortTime); @@ -650,43 +734,32 @@ export class TeamMemberLogsFinder { } } - for (const sessionId of sessionIds) { - const subagentsDir = path.join(projectDir, sessionId, 'subagents'); - - let files: string[]; + const candidates = await this.collectLogCandidates(projectDir, sessionIds, config); + for (const candidate of candidates) { + let mtimeMs = 0; try { - files = await fs.readdir(subagentsDir); + mtimeMs = (await fs.stat(candidate.filePath)).mtimeMs; } catch { continue; } - - for (const file of files) { - if (!file.startsWith('agent-') || !file.endsWith('.jsonl')) continue; - if (file.startsWith('agent-acompact')) continue; - - const filePath = path.join(subagentsDir, file); - // Quick attribution check — only Phase 1 (no full-file streaming) - let mtimeMs = 0; - try { - mtimeMs = (await fs.stat(filePath)).mtimeMs; - } catch { - continue; - } - const attribution = await this.getCachedSubagentAttribution( - filePath, - knownMembers, - mtimeMs - ); - if (attribution?.detectedMember.toLowerCase() === memberName.trim().toLowerCase()) { - paths.push(filePath); - } + const attribution = + candidate.kind === 'subagent' + ? await this.getCachedSubagentAttribution(candidate.filePath, knownMembers, mtimeMs) + : await this.getCachedMemberSessionAttribution( + candidate.filePath, + teamName, + knownMembers, + mtimeMs + ); + if (attribution?.detectedMember.toLowerCase() === memberName.trim().toLowerCase()) { + paths.push(candidate.filePath); } } return paths; } - async listAttributedSubagentFiles( + async listAttributedMemberFiles( teamName: string ): Promise<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }[]> { const discovery = await this.discoverProjectSessions(teamName); @@ -697,13 +770,7 @@ export class TeamMemberLogsFinder { 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 candidates = await this.collectLogCandidates(projectDir, sessionIds, config); const results: { memberName: string; sessionId: string; @@ -714,12 +781,27 @@ export class TeamMemberLogsFinder { const settled = await Promise.all( candidates.map(async (candidate) => { try { + if ( + candidate.kind === 'subagent' && + currentLeadSessionId && + candidate.sessionId !== currentLeadSessionId + ) { + return null; + } const stat = await fs.stat(candidate.filePath); - const attribution = await this.getCachedSubagentAttribution( - candidate.filePath, - knownMembers, - stat.mtimeMs - ); + const attribution = + candidate.kind === 'subagent' + ? await this.getCachedSubagentAttribution( + candidate.filePath, + knownMembers, + stat.mtimeMs + ) + : await this.getCachedMemberSessionAttribution( + candidate.filePath, + teamName, + knownMembers, + stat.mtimeMs + ); if (!attribution) return null; return { memberName: attribution.detectedMember, @@ -737,7 +819,34 @@ export class TeamMemberLogsFinder { if (item) results.push(item); } - return results; + const latestRootSessionsByMember = new Map< + string, + { memberName: string; sessionId: string; filePath: string; mtimeMs: number } + >(); + const passthrough: typeof results = []; + + for (const item of results) { + if ( + !item.filePath.endsWith('.jsonl') || + item.filePath.includes(`${path.sep}subagents${path.sep}`) + ) { + passthrough.push(item); + continue; + } + const key = item.memberName.toLowerCase(); + const existing = latestRootSessionsByMember.get(key); + if (!existing || item.mtimeMs > existing.mtimeMs) { + latestRootSessionsByMember.set(key, item); + } + } + + return [...passthrough, ...latestRootSessionsByMember.values()]; + } + + async listAttributedSubagentFiles( + teamName: string + ): Promise<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }[]> { + return this.listAttributedMemberFiles(teamName); } /** @@ -783,97 +892,32 @@ export class TeamMemberLogsFinder { return false; } - private async discoverProjectSessions(teamName: string): Promise<{ + private async discoverProjectSessions( + teamName: string, + options?: { forceRefresh?: boolean } + ): Promise<{ projectDir: string; projectId: string; config: NonNullable>>; sessionIds: string[]; knownMembers: Set; } | null> { - // Check discovery cache — avoids re-reading config/dirs within rapid successive calls - const cached = this.discoveryCache.get(teamName); - if (cached && cached.expiresAt > Date.now()) { - return cached.result; + if (options?.forceRefresh) { + this.discoveryCache.delete(teamName); + } else { + // Check discovery cache — avoids re-reading config/dirs within rapid successive calls + const cached = this.discoveryCache.get(teamName); + if (cached && cached.expiresAt > Date.now()) { + return cached.result; + } } - const config = await this.configReader.getConfig(teamName); - if (!config?.projectPath) { - logger.debug(`No projectPath for team "${teamName}"`); + const context = await this.projectResolver.getContext(teamName, options); + if (!context) { + logger.debug(`No transcript context for team "${teamName}"`); return null; } - - const normalizedProjectPath = trimTrailingSlashes(config.projectPath); - let projectId = encodePath(normalizedProjectPath); - let baseDir = extractBaseDir(projectId); - let projectDir = path.join(getProjectsBasePath(), baseDir); - - // If the encoded directory doesn't exist (symlink/cwd mismatch), fall back to locating - // the project directory by leadSessionId which is unique and reliable. - try { - const stat = await fs.stat(projectDir); - if (!stat.isDirectory()) { - throw new Error('not a directory'); - } - } catch { - const leadSessionId = - typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 - ? config.leadSessionId.trim() - : null; - if (leadSessionId) { - const projectsBase = getProjectsBasePath(); - try { - const entries = await fs.readdir(projectsBase, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const candidateDir = path.join(projectsBase, entry.name); - const leadPath = path.join(candidateDir, `${leadSessionId}.jsonl`); - try { - await fs.access(leadPath); - projectDir = candidateDir; - projectId = entry.name; - baseDir = entry.name; - break; - } catch { - // not this project - } - } - } catch { - // ignore - } - } - } - - const knownSessionIds = new Set(); - if (config.leadSessionId) { - knownSessionIds.add(config.leadSessionId); - } - if (Array.isArray(config.sessionHistory)) { - for (const sid of config.sessionHistory) { - if (typeof sid === 'string' && sid.trim().length > 0) { - knownSessionIds.add(sid.trim()); - } - } - } - - const discoveredSessionIds = await this.listSessionDirs(projectDir); - let sessionIds: string[]; - if (knownSessionIds.size > 0) { - const verified: string[] = []; - for (const sid of knownSessionIds) { - const sidDir = path.join(projectDir, sid); - try { - const stat = await fs.stat(sidDir); - if (stat.isDirectory()) verified.push(sid); - } catch { - // dir doesn't exist - } - } - // Prefer config-backed sessions first, but also include any live session dirs that have - // appeared on disk and are not yet reflected in config/sessionHistory. - sessionIds = Array.from(new Set([...verified, ...discoveredSessionIds])); - } else { - sessionIds = discoveredSessionIds; - } + const { config, projectDir, projectId, sessionIds } = context; const knownMembers = new Set( (config.members ?? []) @@ -927,16 +971,42 @@ export class TeamMemberLogsFinder { return { ...discovery, isLeadMember }; } - /** - * Collect all subagent JSONL file candidates across session directories. - * Filters out non-agent files and compact files (agent-acompact*). - */ - private async collectSubagentCandidates( + private async collectLogCandidates( projectDir: string, - sessionIds: string[] - ): Promise<{ filePath: string; sessionId: string; fileName: string }[]> { - const candidates: { filePath: string; sessionId: string; fileName: string }[] = []; + sessionIds: string[], + config: NonNullable>> + ): Promise { + const candidates: LogCandidate[] = []; + const leadSessionIds = new Set(); + if (typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0) { + leadSessionIds.add(config.leadSessionId.trim()); + } + if (Array.isArray(config.sessionHistory)) { + for (const sessionId of config.sessionHistory) { + if (typeof sessionId === 'string' && sessionId.trim().length > 0) { + leadSessionIds.add(sessionId.trim()); + } + } + } + for (const sessionId of sessionIds) { + const mainTranscript = path.join(projectDir, `${sessionId}.jsonl`); + if (!leadSessionIds.has(sessionId)) { + try { + const stat = await fs.stat(mainTranscript); + if (stat.isFile()) { + candidates.push({ + kind: 'member_session', + filePath: mainTranscript, + sessionId, + fileName: path.basename(mainTranscript), + }); + } + } catch { + // missing root transcript + } + } + const subagentsDir = path.join(projectDir, sessionId, 'subagents'); let dirFiles: string[]; try { @@ -947,7 +1017,12 @@ export class TeamMemberLogsFinder { for (const f of dirFiles) { if (!f.startsWith('agent-') || !f.endsWith('.jsonl') || f.startsWith('agent-acompact')) continue; - candidates.push({ filePath: path.join(subagentsDir, f), sessionId, fileName: f }); + candidates.push({ + kind: 'subagent', + filePath: path.join(subagentsDir, f), + sessionId, + fileName: f, + }); } } return candidates; @@ -1201,16 +1276,6 @@ export class TeamMemberLogsFinder { return null; } - private async listSessionDirs(projectDir: string): Promise { - try { - const dirEntries = await fs.readdir(projectDir, { withFileTypes: true }); - return dirEntries.filter((e) => e.isDirectory()).map((e) => e.name); - } catch { - logger.debug(`Cannot read project dir: ${projectDir}`); - return []; - } - } - private async getCachedSubagentAttribution( filePath: string, knownMembers: Set, @@ -1229,6 +1294,25 @@ export class TeamMemberLogsFinder { return attribution; } + private async getCachedMemberSessionAttribution( + filePath: string, + teamName: string, + knownMembers: Set, + mtimeMs: number + ): Promise { + const cacheKey = `${filePath}:${mtimeMs}:${teamName}:member-session`; + if (this.attributionCache.has(cacheKey)) { + return (this.attributionCache.get(cacheKey) as RootSessionAttribution | null) ?? null; + } + const attribution = await this.attributeMemberSession(filePath, teamName, knownMembers); + this.attributionCache.set(cacheKey, attribution); + if (this.attributionCache.size > ATTRIBUTION_CACHE_MAX) { + const oldestKey = this.attributionCache.keys().next().value; + if (oldestKey) this.attributionCache.delete(oldestKey); + } + return attribution; + } + private async parseSubagentSummary( filePath: string, projectId: string, @@ -1292,6 +1376,60 @@ export class TeamMemberLogsFinder { }; } + private async parseMemberSessionSummary( + filePath: string, + projectId: string, + sessionId: string, + targetMember: string, + teamName: string, + knownMembers: Set, + precomputedAttribution?: RootSessionAttribution + ): Promise { + const attribution = + precomputedAttribution ?? + (await this.attributeMemberSession(filePath, teamName, knownMembers)); + if (!attribution) { + return null; + } + + if (attribution.detectedMember.toLowerCase() !== targetMember.toLowerCase()) { + return null; + } + + const metadata = await this.streamFileMetadata(filePath); + const firstTimestamp = + metadata.firstTimestamp ?? attribution.firstTimestamp ?? (await this.getFileMtime(filePath)); + const lastTimestamp = metadata.lastTimestamp ?? firstTimestamp; + + const startTime = new Date(firstTimestamp); + const endTime = new Date(lastTimestamp); + const durationMs = endTime.getTime() - startTime.getTime(); + + let isOngoing = false; + try { + const stat = await fs.stat(filePath); + isOngoing = Date.now() - stat.mtimeMs < 60_000; + } catch { + // ignore + } + + return { + kind: 'member_session', + sessionId, + projectId, + description: attribution.description || `${targetMember} session`, + memberName: targetMember, + startTime: firstTimestamp, + durationMs: Math.max(0, durationMs), + messageCount: metadata.messageCount, + isOngoing, + filePath, + lastOutputPreview: metadata.lastOutputPreview ?? undefined, + lastThinkingPreview: metadata.lastThinkingPreview ?? undefined, + recentPreviews: metadata.recentPreviews.length > 0 ? metadata.recentPreviews : undefined, + }; + } + /** * Phase 1: Scan first ATTRIBUTION_SCAN_LINES lines for member detection signals * and extract a human-readable description from the first user message. @@ -1421,6 +1559,122 @@ export class TeamMemberLogsFinder { return { detectedMember: best.member, description, firstTimestamp }; } + private async attributeMemberSession( + filePath: string, + teamName: string, + knownMembers: Set + ): Promise { + const lines: string[] = []; + + try { + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + let count = 0; + for await (const line of rl) { + if (count >= ATTRIBUTION_SCAN_LINES) break; + const trimmed = line.trim(); + if (!trimmed) continue; + lines.push(trimmed); + count++; + } + rl.close(); + stream.destroy(); + } catch { + return null; + } + + if (lines.length === 0) { + return null; + } + + const normalizedTeam = teamName.trim().toLowerCase(); + let detectedMember: string | null = null; + let description = ''; + let firstTimestamp: string | null = null; + let teamMatched = false; + + for (const line of lines) { + if (!firstTimestamp) { + firstTimestamp = this.extractTimestampFromLine(line); + } + + try { + const entry = JSON.parse(line) as Record; + const directTeamName = + typeof entry.teamName === 'string' ? entry.teamName.trim().toLowerCase() : null; + if (directTeamName === normalizedTeam) { + teamMatched = true; + } + + if (!detectedMember && typeof entry.agentName === 'string') { + const normalizedMember = entry.agentName.trim().toLowerCase(); + if (normalizedMember.length > 0 && knownMembers.has(normalizedMember)) { + detectedMember = entry.agentName.trim(); + } + } + + const process = entry.process as Record | undefined; + const processTeam = process?.team as Record | undefined; + if (!detectedMember && typeof processTeam?.memberName === 'string') { + const normalizedMember = processTeam.memberName.trim().toLowerCase(); + if (normalizedMember.length > 0 && knownMembers.has(normalizedMember)) { + detectedMember = processTeam.memberName.trim(); + } + } + if (!teamMatched) { + const processTeamName = + typeof processTeam?.teamName === 'string' + ? processTeam.teamName.trim().toLowerCase() + : null; + if (processTeamName === normalizedTeam) { + teamMatched = true; + } + } + + const role = this.extractRole(entry); + const textContent = this.extractTextContent(entry); + if (!teamMatched && textContent && textContent.toLowerCase().includes(normalizedTeam)) { + if ( + textContent.toLowerCase().includes(`on team "${normalizedTeam}"`) || + textContent.toLowerCase().includes(`on team '${normalizedTeam}'`) || + textContent.toLowerCase().includes(`(${normalizedTeam})`) + ) { + teamMatched = true; + } + } + + if (role === 'user' && textContent && !description) { + const normalizedText = textContent.trim(); + if ( + normalizedText.length > 0 && + normalizedText !== 'Warmup' && + !normalizedText.startsWith('You are bootstrapping into team') && + !normalizedText.startsWith('Member briefing for ') + ) { + description = normalizedText.slice(0, 200); + } + } + } catch { + // ignore malformed lines + } + + if (teamMatched && detectedMember && description) { + break; + } + } + + if (!teamMatched || !detectedMember) { + return null; + } + + return { + detectedMember, + description: description || `${detectedMember} session`, + firstTimestamp, + }; + } + /** * Select the best detection signal by precedence. * Signals are collected in file order, so find() returns the earliest occurrence diff --git a/src/main/services/team/TeamTranscriptProjectResolver.ts b/src/main/services/team/TeamTranscriptProjectResolver.ts new file mode 100644 index 00000000..4099ce1d --- /dev/null +++ b/src/main/services/team/TeamTranscriptProjectResolver.ts @@ -0,0 +1,297 @@ +import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import { createReadStream, type Dirent } from 'fs'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as readline from 'readline'; + +import { TeamConfigReader } from './TeamConfigReader'; + +import type { TeamConfig } from '@shared/types'; + +const logger = createLogger('Service:TeamTranscriptProjectResolver'); + +const SESSION_DISCOVERY_CACHE_TTL = 30_000; +const TEAM_AFFINITY_SCAN_LINES = 40; +const ROOT_DISCOVERY_CONCURRENCY = 12; + +function trimTrailingSlashes(value: string): string { + let end = value.length; + while (end > 0) { + const ch = value.charCodeAt(end - 1); + if (ch === 47 || ch === 92) { + end -= 1; + continue; + } + break; + } + return end === value.length ? value : value.slice(0, end); +} + +function isSessionDirectoryName(name: string): boolean { + return name !== 'memory' && !name.startsWith('.'); +} + +function extractTextContent(entry: Record): string | null { + if (typeof entry.content === 'string') { + return entry.content; + } + if (Array.isArray(entry.content)) { + const textParts = (entry.content as Record[]) + .filter((part) => part.type === 'text' && typeof part.text === 'string') + .map((part) => part.text as string); + if (textParts.length > 0) { + return textParts.join(' '); + } + } + if (entry.message && typeof entry.message === 'object') { + return extractTextContent(entry.message as Record); + } + return null; +} + +function extractDirectTeamName(entry: Record): string | null { + if (typeof entry.teamName === 'string') { + return entry.teamName.trim().toLowerCase(); + } + + const process = entry.process as Record | undefined; + const processTeam = process?.team as Record | undefined; + if (typeof processTeam?.teamName === 'string') { + return processTeam.teamName.trim().toLowerCase(); + } + + return null; +} + +function lineMentionsTeam(text: string, teamName: string): boolean { + const normalizedText = text.trim().toLowerCase(); + const normalizedTeam = teamName.trim().toLowerCase(); + if (!normalizedText.includes(normalizedTeam)) { + return false; + } + return ( + normalizedText.includes(`on team "${normalizedTeam}"`) || + normalizedText.includes(`on team '${normalizedTeam}'`) || + normalizedText.includes(`team "${normalizedTeam}"`) || + normalizedText.includes(`team '${normalizedTeam}'`) || + normalizedText.includes(`(${normalizedTeam})`) + ); +} + +function collectKnownSessionIds(config: TeamConfig): string[] { + const knownSessionIds = new Set(); + const push = (value: unknown): void => { + if (typeof value !== 'string') { + return; + } + const trimmed = value.trim(); + if (trimmed.length > 0) { + knownSessionIds.add(trimmed); + } + }; + + push(config.leadSessionId); + if (Array.isArray(config.sessionHistory)) { + for (const sessionId of config.sessionHistory) { + push(sessionId); + } + } + + return [...knownSessionIds]; +} + +export interface TeamTranscriptProjectContext { + projectDir: string; + projectId: string; + config: TeamConfig; + sessionIds: string[]; +} + +export class TeamTranscriptProjectResolver { + private readonly contextCache = new Map< + string, + { value: TeamTranscriptProjectContext; expiresAt: number } + >(); + + constructor(private readonly configReader: TeamConfigReader = new TeamConfigReader()) {} + + async getContext( + teamName: string, + options?: { forceRefresh?: boolean } + ): Promise { + if (options?.forceRefresh) { + this.contextCache.delete(teamName); + } + + const cached = this.contextCache.get(teamName); + if (cached && cached.expiresAt > Date.now()) { + return cached.value; + } + + const config = await this.configReader.getConfig(teamName); + if (!config?.projectPath) { + return null; + } + + const { projectDir, projectId } = await this.resolveProjectDirectory(config); + const sessionIds = await this.discoverSessionIds(teamName, projectDir, config); + const value = { projectDir, projectId, config, sessionIds }; + this.contextCache.set(teamName, { + value, + expiresAt: Date.now() + SESSION_DISCOVERY_CACHE_TTL, + }); + return value; + } + + private async resolveProjectDirectory( + config: TeamConfig + ): Promise<{ projectDir: string; projectId: string }> { + const normalizedProjectPath = trimTrailingSlashes(config.projectPath ?? ''); + let projectId = encodePath(normalizedProjectPath); + let projectDir = path.join(getProjectsBasePath(), extractBaseDir(projectId)); + + try { + const stat = await fs.stat(projectDir); + if (!stat.isDirectory()) { + throw new Error('not a directory'); + } + return { projectDir, projectId }; + } catch { + const leadSessionId = + typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 + ? config.leadSessionId.trim() + : null; + if (!leadSessionId) { + return { projectDir, projectId }; + } + + try { + const projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true }); + for (const entry of projectEntries) { + if (!entry.isDirectory()) continue; + const candidateDir = path.join(getProjectsBasePath(), entry.name); + try { + await fs.access(path.join(candidateDir, `${leadSessionId}.jsonl`)); + projectDir = candidateDir; + projectId = entry.name; + break; + } catch { + // not this project + } + } + } catch { + // best-effort fallback + } + } + + return { projectDir, projectId }; + } + + private async discoverSessionIds( + teamName: string, + projectDir: string, + config: TeamConfig + ): Promise { + const knownSessionIds = collectKnownSessionIds(config); + const [teamRootSessionIds, sessionDirIds] = await Promise.all([ + this.listTeamRootSessionIds(projectDir, teamName), + this.listSessionDirIds(projectDir), + ]); + + return Array.from(new Set([...knownSessionIds, ...teamRootSessionIds, ...sessionDirIds])).sort( + (left, right) => left.localeCompare(right) + ); + } + + private async listSessionDirIds(projectDir: string): Promise { + try { + const dirEntries = await fs.readdir(projectDir, { withFileTypes: true }); + return dirEntries + .filter((entry) => entry.isDirectory() && isSessionDirectoryName(entry.name)) + .map((entry) => entry.name); + } catch { + logger.debug(`Cannot read transcript project dir: ${projectDir}`); + return []; + } + } + + private async listTeamRootSessionIds(projectDir: string, teamName: string): Promise { + let dirEntries: Dirent[]; + try { + dirEntries = await fs.readdir(projectDir, { withFileTypes: true }); + } catch { + logger.debug(`Cannot read transcript project dir: ${projectDir}`); + return []; + } + + const rootJsonlEntries = dirEntries.filter( + (entry) => entry.isFile() && entry.name.endsWith('.jsonl') + ); + const discovered = new Set(); + let nextIndex = 0; + + const worker = async (): Promise => { + while (nextIndex < rootJsonlEntries.length) { + const index = nextIndex++; + const entry = rootJsonlEntries[index]; + const filePath = path.join(projectDir, entry.name); + if (!(await this.fileBelongsToTeam(filePath, teamName))) { + continue; + } + discovered.add(entry.name.slice(0, -'.jsonl'.length)); + } + }; + + await Promise.all( + Array.from({ length: Math.min(ROOT_DISCOVERY_CONCURRENCY, rootJsonlEntries.length) }, () => + worker() + ) + ); + + return [...discovered]; + } + + private async fileBelongsToTeam(filePath: string, teamName: string): Promise { + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + const normalizedTeam = teamName.trim().toLowerCase(); + + try { + let inspected = 0; + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + inspected += 1; + try { + const entry = JSON.parse(trimmed) as Record; + const directTeamName = extractDirectTeamName(entry); + if (directTeamName === normalizedTeam) { + return true; + } + + const textContent = extractTextContent(entry); + if (textContent && lineMentionsTeam(textContent, normalizedTeam)) { + return true; + } + } catch { + // ignore malformed head lines + } + + if (inspected >= TEAM_AFFINITY_SCAN_LINES) { + break; + } + } + } catch { + return false; + } finally { + rl.close(); + stream.destroy(); + } + + return false; + } +} diff --git a/src/main/services/team/TeammateToolTracker.ts b/src/main/services/team/TeammateToolTracker.ts index 5a4416b9..c19e9ced 100644 --- a/src/main/services/team/TeammateToolTracker.ts +++ b/src/main/services/team/TeammateToolTracker.ts @@ -145,7 +145,7 @@ export class TeammateToolTracker { const state = this.stateByTeam.get(teamName); if (!state?.enabled || state.epoch !== expectedEpoch) return; - const attributedFiles = await this.logsFinder.listAttributedSubagentFiles(teamName); + const attributedFiles = await this.logsFinder.listAttributedMemberFiles(teamName); const currentState = this.stateByTeam.get(teamName); if (!currentState?.enabled || currentState.epoch !== expectedEpoch) return; diff --git a/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts b/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts index 1daee3cb..8dbd7e4d 100644 --- a/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts +++ b/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts @@ -1,27 +1,10 @@ -import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder'; -import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs/promises'; import * as path from 'path'; -import { TeamConfigReader } from '../../TeamConfigReader'; +import { TeamTranscriptProjectResolver } from '../../TeamTranscriptProjectResolver'; import type { TeamConfig } from '@shared/types'; -const logger = createLogger('Service:TeamTranscriptSourceLocator'); - -function trimTrailingSlashes(value: string): string { - let end = value.length; - while (end > 0) { - const ch = value.charCodeAt(end - 1); - if (ch === 47 || ch === 92) { - end -= 1; - continue; - } - break; - } - return end === value.length ? value : value.slice(0, end); -} - export interface TeamTranscriptSourceContext { projectDir: string; projectId: string; @@ -31,50 +14,17 @@ export interface TeamTranscriptSourceContext { } export class TeamTranscriptSourceLocator { - constructor(private readonly configReader: TeamConfigReader = new TeamConfigReader()) {} + constructor( + private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver() + ) {} async getContext(teamName: string): Promise { - const config = await this.configReader.getConfig(teamName); - if (!config?.projectPath) { + const context = await this.projectResolver.getContext(teamName); + if (!context) { return null; } - const normalizedProjectPath = trimTrailingSlashes(config.projectPath); - let projectId = encodePath(normalizedProjectPath); - let projectDir = path.join(getProjectsBasePath(), extractBaseDir(projectId)); - - try { - const stat = await fs.stat(projectDir); - if (!stat.isDirectory()) { - throw new Error('not a directory'); - } - } catch { - const leadSessionId = - typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 - ? config.leadSessionId.trim() - : null; - if (leadSessionId) { - try { - const projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true }); - for (const entry of projectEntries) { - if (!entry.isDirectory()) continue; - const candidateDir = path.join(getProjectsBasePath(), entry.name); - try { - await fs.access(path.join(candidateDir, `${leadSessionId}.jsonl`)); - projectDir = candidateDir; - projectId = entry.name; - break; - } catch { - // not this project - } - } - } catch { - // best-effort fallback - } - } - } - - const sessionIds = await this.discoverSessionIds(projectDir, config); + const { projectDir, projectId, config, sessionIds } = context; const transcriptFiles = await this.listTranscriptFilesForSessions(projectDir, sessionIds); return { projectDir, projectId, config, sessionIds, transcriptFiles }; } @@ -83,51 +33,6 @@ export class TeamTranscriptSourceLocator { const context = await this.getContext(teamName); return context?.transcriptFiles ?? []; } - - private async discoverSessionIds(projectDir: string, config: TeamConfig): Promise { - const knownSessionIds = new Set(); - if (typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0) { - knownSessionIds.add(config.leadSessionId.trim()); - } - if (Array.isArray(config.sessionHistory)) { - for (const sessionId of config.sessionHistory) { - if (typeof sessionId === 'string' && sessionId.trim().length > 0) { - knownSessionIds.add(sessionId.trim()); - } - } - } - - let discoveredSessionDirs: string[] = []; - try { - const dirEntries = await fs.readdir(projectDir, { withFileTypes: true }); - discoveredSessionDirs = dirEntries - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name); - } catch { - logger.debug(`Cannot read transcript project dir: ${projectDir}`); - } - - if (knownSessionIds.size === 0) { - return discoveredSessionDirs.sort(); - } - - const verifiedSessionIds: string[] = []; - for (const sessionId of knownSessionIds) { - try { - const stat = await fs.stat(path.join(projectDir, sessionId)); - if (stat.isDirectory()) { - verifiedSessionIds.push(sessionId); - } - } catch { - // ignore stale config session - } - } - - return Array.from( - new Set([...knownSessionIds, ...verifiedSessionIds, ...discoveredSessionDirs]) - ).sort(); - } - private async listTranscriptFilesForSessions( projectDir: string, sessionIds: string[] @@ -160,6 +65,6 @@ export class TeamTranscriptSourceLocator { } } - return [...transcriptFiles].sort(); + return [...transcriptFiles].sort((left, right) => left.localeCompare(right)); } } diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index 316c2c17..d559cc70 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -183,9 +183,13 @@ export const MemberLogsTab = ({ }, []); const getRowId = useCallback((log: MemberLogSummary): string => { - return log.kind === 'subagent' - ? `subagent:${log.sessionId}:${log.subagentId}` - : `lead:${log.sessionId}`; + if (log.kind === 'subagent') { + return `subagent:${log.sessionId}:${log.subagentId}`; + } + if (log.kind === 'member_session') { + return `member:${log.sessionId}`; + } + return `lead:${log.sessionId}`; }, []); const sortedLogs = useMemo(() => { @@ -250,7 +254,9 @@ export const MemberLogsTab = ({ if (!shouldShowPreview) return null; if (showSubagentPreview) { - const candidates = sortedLogs.filter((l) => l.kind === 'subagent'); + const candidates = sortedLogs.filter( + (l) => l.kind === 'subagent' || l.kind === 'member_session' + ); if (candidates.length === 0) return null; if (taskOwner) { @@ -515,6 +521,10 @@ export const MemberLogsTab = ({ ); return d?.chunks ?? null; } + if (log.kind === 'member_session') { + const d = await api.getSessionDetail(log.projectId, log.sessionId, options); + return d ? asEnhancedChunkArray(d.chunks) : null; + } const d = await api.getSessionDetail(log.projectId, log.sessionId, options); return d ? asEnhancedChunkArray(d.chunks) : null; }, @@ -766,7 +776,7 @@ const LogCard = ({ {log.description} - {log.kind === 'lead_session' && ( + {(log.kind === 'lead_session' || log.kind === 'member_session') && ( - Full team lead session logs — useful for global orchestration context, not - specific to this agent + {log.kind === 'lead_session' + ? 'Full team lead session logs - useful for global orchestration context, not specific to this agent' + : 'Full persistent teammate session logs - useful when work runs in a root member session instead of a subagent file'} )} @@ -838,7 +849,11 @@ const LogCard = ({
)} diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index d2f3935f..dcc8dfdf 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -1040,7 +1040,7 @@ export interface MemberSubagentSummary { isOngoing: boolean; } -export type MemberLogKind = 'subagent' | 'lead_session'; +export type MemberLogKind = 'subagent' | 'lead_session' | 'member_session'; export interface MemberLogSummaryBase { kind: MemberLogKind; @@ -1071,7 +1071,14 @@ export interface MemberLeadSessionLogSummary extends MemberLogSummaryBase { kind: 'lead_session'; } -export type MemberLogSummary = MemberSubagentLogSummary | MemberLeadSessionLogSummary; +export interface MemberSessionLogSummary extends MemberLogSummaryBase { + kind: 'member_session'; +} + +export type MemberLogSummary = + | MemberSubagentLogSummary + | MemberLeadSessionLogSummary + | MemberSessionLogSummary; export interface FileLineStats { added: number; diff --git a/test/main/services/team/TeamMemberLogsFinder.test.ts b/test/main/services/team/TeamMemberLogsFinder.test.ts index 9c7d559e..a8c02313 100644 --- a/test/main/services/team/TeamMemberLogsFinder.test.ts +++ b/test/main/services/team/TeamMemberLogsFinder.test.ts @@ -97,6 +97,118 @@ describe('TeamMemberLogsFinder', () => { expect(lead?.projectId).toBe(projectId); }); + it('returns root member sessions when config.projectPath is missing but member cwd is present', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 'signal-ops-root'; + const projectPath = '/Users/test/signal-ops-root'; + const projectId = '-Users-test-signal-ops-root'; + const leadSessionId = 'lead-root'; + const memberSessionId = 'member-bob-root'; + + await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, 'teams', teamName, 'config.json'), + JSON.stringify( + { + name: teamName, + leadSessionId, + members: [ + { name: 'team-lead', agentType: 'team-lead', cwd: projectPath }, + { name: 'bob', agentType: 'general-purpose', cwd: projectPath }, + ], + }, + null, + 2 + ), + 'utf8' + ); + + const projectRoot = path.join(tmpDir, 'projects', projectId); + await fs.mkdir(projectRoot, { recursive: true }); + + await fs.writeFile( + path.join(projectRoot, `${leadSessionId}.jsonl`), + JSON.stringify({ + timestamp: '2026-04-15T14:02:00.000Z', + type: 'user', + teamName, + agentName: 'team-lead', + message: { role: 'user', content: `Lead for team "${teamName}" (${teamName})` }, + }) + '\n', + 'utf8' + ); + + await fs.writeFile( + path.join(projectRoot, `${memberSessionId}.jsonl`), + [ + JSON.stringify({ + timestamp: '2026-04-15T14:02:01.000Z', + type: 'user', + teamName, + agentName: 'bob', + message: { + role: 'user', + content: `You are bootstrapping into team "${teamName}" as member "bob".`, + }, + }), + JSON.stringify({ + timestamp: '2026-04-15T14:02:05.000Z', + type: 'assistant', + teamName, + agentName: 'bob', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'call-task-start', + name: 'mcp__agent-teams__task_start', + input: { + teamName, + taskId: 'task-root-1', + }, + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const finder = new TeamMemberLogsFinder(); + const bobLogs = await finder.findMemberLogs(teamName, 'bob'); + const taskLogs = await finder.findLogsForTask(teamName, 'task-root-1'); + const attributedFiles = await finder.listAttributedMemberFiles(teamName); + + expect(bobLogs).toHaveLength(1); + expect(bobLogs[0]?.kind).toBe('member_session'); + if (bobLogs[0]?.kind === 'member_session') { + expect(bobLogs[0].sessionId).toBe(memberSessionId); + expect(bobLogs[0].projectId).toBe(projectId); + expect(bobLogs[0].memberName?.toLowerCase()).toBe('bob'); + expect(bobLogs[0].filePath).toBe(path.join(projectRoot, `${memberSessionId}.jsonl`)); + } + + expect( + taskLogs.some( + (log) => + log.kind === 'member_session' && + log.sessionId === memberSessionId && + log.memberName?.toLowerCase() === 'bob' + ) + ).toBe(true); + expect(attributedFiles).toEqual([ + { + memberName: 'bob', + sessionId: memberSessionId, + filePath: path.join(projectRoot, `${memberSessionId}.jsonl`), + mtimeMs: expect.any(Number), + }, + ]); + }); + 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); @@ -841,7 +953,7 @@ describe('TeamMemberLogsFinder', () => { { type: 'tool_use', name: 'Bash', - input: { command: 'node \"teamctl.js\" --team demo task start task-42' }, + input: { command: 'node "teamctl.js" --team demo task start task-42' }, }, ], }, diff --git a/test/main/services/team/TeamTranscriptSourceLocator.test.ts b/test/main/services/team/TeamTranscriptSourceLocator.test.ts new file mode 100644 index 00000000..254ecb8f --- /dev/null +++ b/test/main/services/team/TeamTranscriptSourceLocator.test.ts @@ -0,0 +1,114 @@ +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import * as fs from 'fs/promises'; + +import { TeamTranscriptSourceLocator } from '../../../../src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator'; +import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; + +describe('TeamTranscriptSourceLocator', () => { + let tmpDir: string | null = null; + + afterEach(async () => { + setClaudeBasePathOverride(null); + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + tmpDir = null; + } + }); + + it('recovers projectPath from member cwd and includes only team-related root sessions', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-transcripts-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 'signal-ops-test'; + const projectPath = '/Users/test/signal-ops'; + const projectId = '-Users-test-signal-ops'; + const leadSessionId = 'lead-session'; + const memberSessionId = 'member-bob'; + + await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, 'teams', teamName, 'config.json'), + JSON.stringify( + { + name: teamName, + leadSessionId, + members: [ + { name: 'team-lead', agentType: 'team-lead', cwd: projectPath }, + { name: 'bob', agentType: 'general-purpose', cwd: projectPath }, + ], + }, + null, + 2 + ), + 'utf8' + ); + + const projectRoot = path.join(tmpDir, 'projects', projectId); + await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true }); + + await fs.writeFile( + path.join(projectRoot, `${leadSessionId}.jsonl`), + JSON.stringify({ + timestamp: '2026-04-15T14:02:00.000Z', + type: 'user', + teamName, + agentName: 'team-lead', + message: { role: 'user', content: `Lead for team "${teamName}" (${teamName})` }, + }) + '\n', + 'utf8' + ); + await fs.writeFile( + path.join(projectRoot, `${memberSessionId}.jsonl`), + JSON.stringify({ + timestamp: '2026-04-15T14:02:01.000Z', + type: 'user', + teamName, + agentName: 'bob', + message: { + role: 'user', + content: `You are bootstrapping into team "${teamName}" as member "bob".`, + }, + }) + '\n', + 'utf8' + ); + await fs.writeFile( + path.join(projectRoot, 'unrelated-session.jsonl'), + JSON.stringify({ + timestamp: '2026-04-15T14:02:02.000Z', + type: 'user', + message: { role: 'user', content: 'Unrelated solo session' }, + }) + '\n', + 'utf8' + ); + await fs.writeFile( + path.join(projectRoot, leadSessionId, 'subagents', 'agent-worker.jsonl'), + JSON.stringify({ + timestamp: '2026-04-15T14:02:03.000Z', + type: 'user', + message: { role: 'user', content: `You are bob, a developer on team "${teamName}".` }, + }) + '\n', + 'utf8' + ); + + const locator = new TeamTranscriptSourceLocator(); + const context = await locator.getContext(teamName); + const transcriptFiles = await locator.listTranscriptFiles(teamName); + + expect(context).not.toBeNull(); + expect(context?.projectId).toBe(projectId); + expect(context?.config.projectPath).toBe(projectPath); + expect(context?.sessionIds).toEqual(expect.arrayContaining([leadSessionId, memberSessionId])); + expect(context?.sessionIds).not.toContain('unrelated-session'); + expect(transcriptFiles).toEqual( + expect.arrayContaining([ + path.join(projectRoot, `${leadSessionId}.jsonl`), + path.join(projectRoot, `${memberSessionId}.jsonl`), + path.join(projectRoot, leadSessionId, 'subagents', 'agent-worker.jsonl'), + ]) + ); + expect(transcriptFiles).not.toContain(path.join(projectRoot, 'unrelated-session.jsonl')); + }); +}); From 77d3e9f7d8089f52a1a2d94146a70166df251dae Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 15 Apr 2026 22:40:15 +0300 Subject: [PATCH 2/6] fix(agent-graph): stabilize member slot layout --- .../src/constants/canvas-constants.ts | 2 + .../src/hooks/useGraphSimulation.ts | 40 ++- .../agent-graph/src/layout/kanbanLayout.ts | 39 +-- .../agent-graph/src/layout/stableSlots.ts | 196 +++++++----- packages/agent-graph/src/ui/GraphView.tsx | 62 +--- .../core/domain/buildInlineActivityEntries.ts | 33 +-- .../core/domain/graphOwnerIdentity.ts | 25 ++ .../renderer/adapters/TeamGraphAdapter.ts | 65 ++-- .../renderer/ui/GraphActivityHud.tsx | 279 ++++++++++-------- .../renderer/ui/TeamGraphOverlay.tsx | 23 +- .../agent-graph/renderer/ui/TeamGraphTab.tsx | 23 +- .../agent-graph/GraphActivityHud.test.ts | 38 ++- .../agent-graph/TeamGraphAdapter.test.ts | 74 +++++ .../buildInlineActivityEntries.test.ts | 68 +++++ .../agent-graph/useGraphSimulation.test.ts | 191 +++++++++--- 15 files changed, 724 insertions(+), 434 deletions(-) diff --git a/packages/agent-graph/src/constants/canvas-constants.ts b/packages/agent-graph/src/constants/canvas-constants.ts index e45353da..30f6363f 100644 --- a/packages/agent-graph/src/constants/canvas-constants.ts +++ b/packages/agent-graph/src/constants/canvas-constants.ts @@ -262,6 +262,8 @@ export const KANBAN_ZONE = { columnWidth: 180, /** Row height: pill (36) + gap (10) */ rowHeight: 46, + /** Space reserved for column header label */ + headerHeight: 20, /** Zone starts this far below member node center */ offsetY: 70, /** Column sequence: pending → wip → done → review → approved */ diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index ae2e8e39..e1eed487 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -9,6 +9,7 @@ import { translateSlotFrame, validateStableSlotLayout, type StableSlotLayoutSnapshot, + type StableRect, type SlotFrame, } from '../layout/stableSlots'; import { KanbanLayoutEngine } from '../layout/kanbanLayout'; @@ -47,7 +48,7 @@ export interface UseGraphSimulationResult { displacedAssignment?: GraphOwnerSlotAssignment; } | null; getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null; - getActivityAnchorWorldPosition: (nodeId: string) => { x: number; y: number } | null; + getActivityWorldRect: (nodeId: string) => StableRect | null; getExtraWorldBounds: () => WorldBounds[]; } @@ -65,7 +66,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { const lastValidSnapshotByTeamRef = useRef(new Map()); const dragOwnerPositionsRef = useRef(new Map()); const launchAnchorPositionsRef = useRef(new Map()); - const activityAnchorPositionsRef = useRef(new Map()); + const activityRectByNodeIdRef = useRef(new Map()); const extraWorldBoundsRef = useRef([]); const prevNodeIdsRef = useRef(new Set()); @@ -91,7 +92,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { lastValidSnapshotByTeamRef, dragOwnerPositionsRef, launchAnchorPositionsRef, - activityAnchorPositionsRef, + activityRectByNodeIdRef, extraWorldBoundsRef, }); return; @@ -111,7 +112,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { lastValidSnapshotByTeamRef, dragOwnerPositionsRef, launchAnchorPositionsRef, - activityAnchorPositionsRef, + activityRectByNodeIdRef, extraWorldBoundsRef, fillMissingFallbackPositions: true, }); @@ -123,7 +124,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { nodes: state.nodes, layoutSnapshotRef, launchAnchorPositionsRef, - activityAnchorPositionsRef, + activityRectByNodeIdRef, extraWorldBoundsRef, }); }, []); @@ -220,7 +221,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { return () => { dragOwnerPositionsRef.current.clear(); launchAnchorPositionsRef.current.clear(); - activityAnchorPositionsRef.current.clear(); + activityRectByNodeIdRef.current.clear(); extraWorldBoundsRef.current = []; layoutSnapshotRef.current = null; lastValidSnapshotByTeamRef.current.clear(); @@ -236,8 +237,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { resolveNearestOwnerSlot, getLaunchAnchorWorldPosition: (leadNodeId: string) => launchAnchorPositionsRef.current.get(leadNodeId) ?? null, - getActivityAnchorWorldPosition: (nodeId: string) => - activityAnchorPositionsRef.current.get(nodeId) ?? null, + getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null, getExtraWorldBounds: () => extraWorldBoundsRef.current, }; } @@ -294,7 +294,7 @@ function commitSnapshotGeometry(args: { lastValidSnapshotByTeamRef: { current: Map }; dragOwnerPositionsRef: { current: ReadonlyMap }; launchAnchorPositionsRef: { current: Map }; - activityAnchorPositionsRef: { current: Map }; + activityRectByNodeIdRef: { current: Map }; extraWorldBoundsRef: { current: WorldBounds[] }; fillMissingFallbackPositions?: boolean; }): void { @@ -306,7 +306,7 @@ function commitSnapshotGeometry(args: { lastValidSnapshotByTeamRef, dragOwnerPositionsRef, launchAnchorPositionsRef, - activityAnchorPositionsRef, + activityRectByNodeIdRef, extraWorldBoundsRef, fillMissingFallbackPositions = false, } = args; @@ -319,7 +319,7 @@ function commitSnapshotGeometry(args: { } launchAnchorPositionsRef.current.clear(); - activityAnchorPositionsRef.current.clear(); + activityRectByNodeIdRef.current.clear(); extraWorldBoundsRef.current = snapshotToWorldBounds(snapshot); if (snapshot.leadNodeId && snapshot.launchAnchor) { @@ -327,36 +327,32 @@ function commitSnapshotGeometry(args: { } for (const frame of getTranslatedMemberFrames(snapshot, dragOwnerPositionsRef.current)) { - activityAnchorPositionsRef.current.set(frame.ownerId, { - x: frame.activityRect.left, - y: frame.activityRect.top, - }); + activityRectByNodeIdRef.current.set(frame.ownerId, frame.activityColumnRect); } - activityAnchorPositionsRef.current.set(`lead:${teamName}`, { - x: snapshot.leadActivityRect.left, - y: snapshot.leadActivityRect.top, - }); + if (snapshot.leadNodeId) { + activityRectByNodeIdRef.current.set(snapshot.leadNodeId, snapshot.leadActivityRect); + } } function resetToFallbackLayout(args: { nodes: GraphNode[]; layoutSnapshotRef: { current: StableSlotLayoutSnapshot | null }; launchAnchorPositionsRef: { current: Map }; - activityAnchorPositionsRef: { current: Map }; + activityRectByNodeIdRef: { current: Map }; extraWorldBoundsRef: { current: WorldBounds[] }; }): void { const { nodes, layoutSnapshotRef, launchAnchorPositionsRef, - activityAnchorPositionsRef, + activityRectByNodeIdRef, extraWorldBoundsRef, } = args; layoutSnapshotRef.current = null; launchAnchorPositionsRef.current.clear(); - activityAnchorPositionsRef.current.clear(); + activityRectByNodeIdRef.current.clear(); extraWorldBoundsRef.current = []; fallbackPositionNodes(nodes); KanbanLayoutEngine.layout(nodes); diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index 9356f7c0..ce0fb704 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -11,7 +11,6 @@ import type { GraphNode } from '../ports/types'; import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants'; import { COLORS } from '../constants/colors'; import { resolveActivityLaneSide } from './activityLane'; -import type { ActivityLaneWorldBounds } from './activityLane'; import type { SlotFrame, StableRect } from './stableSlots'; /** Column header info for rendering */ @@ -43,8 +42,6 @@ const COLUMN_LABELS: Record = { approved: { label: 'Approved', color: COLORS.reviewApproved }, }; -const ACTIVITY_KANBAN_CLEARANCE = 24; - export function getOwnerKanbanBaseX(args: { ownerX: number; ownerKind: GraphNode['kind']; @@ -91,7 +88,6 @@ export class KanbanLayoutEngine { static layout( nodes: GraphNode[], options?: { - activityLaneBounds?: readonly ActivityLaneWorldBounds[]; memberSlotFrames?: readonly SlotFrame[]; unassignedTaskRect?: StableRect | null; } @@ -100,7 +96,6 @@ export class KanbanLayoutEngine { nodeMap.clear(); for (const n of nodes) nodeMap.set(n.id, n); const leadX = nodes.find((node) => node.kind === 'lead')?.x ?? null; - const activityLaneBounds = options?.activityLaneBounds ?? []; const memberSlotFrameByOwnerId = new Map( (options?.memberSlotFrames ?? []).map((frame) => [frame.ownerId, frame] as const) ); @@ -148,7 +143,6 @@ export class KanbanLayoutEngine { owner, ownerId, leadX, - activityLaneBounds, memberSlotFrameByOwnerId.get(ownerId) ?? null ); if (zoneInfo) this.zones.push(zoneInfo); @@ -164,11 +158,9 @@ export class KanbanLayoutEngine { owner: GraphNode, ownerId: string, leadX: number | null, - activityLaneBounds: readonly ActivityLaneWorldBounds[], slotFrame: SlotFrame | null ): KanbanZoneInfo | null { - const { columnWidth, rowHeight, offsetY, columns } = KANBAN_ZONE; - const headerHeight = 20; // space for column header label + const { columnWidth, rowHeight, offsetY, columns, headerHeight } = KANBAN_ZONE; const ownerX = owner.x ?? 0; const ownerY = owner.y ?? 0; @@ -193,8 +185,6 @@ export class KanbanLayoutEngine { if (activeColumns.length === 0) return null; - // Keep kanban columns on the open side of the owner, away from the reserved activity lane. - // This makes member lanes reserve real visual space instead of only affecting the force layout. let baseX = getOwnerKanbanBaseX({ ownerX, ownerKind: owner.kind, @@ -205,27 +195,10 @@ export class KanbanLayoutEngine { let baseY: number; if (slotFrame) { - baseX = slotFrame.taskBandRect.left + TASK_PILL.width / 2; - baseY = slotFrame.taskBandRect.top; + baseX = slotFrame.kanbanBandRect.left + TASK_PILL.width / 2; + baseY = slotFrame.kanbanBandRect.top; } else { - const taskZoneLeft = baseX - TASK_PILL.width / 2; - const taskZoneRight = - baseX + (activeColumns.length - 1) * columnWidth + TASK_PILL.width / 2; - const overlappingActivityBottom = activityLaneBounds.reduce((maxBottom, bounds) => { - if (bounds.ownerId === ownerId) { - return Math.max(maxBottom, bounds.bottom); - } - if (!rangesOverlap(taskZoneLeft, taskZoneRight, bounds.left, bounds.right)) { - return maxBottom; - } - return Math.max(maxBottom, bounds.bottom); - }, -Infinity); - baseY = Math.max( - ownerY + offsetY, - overlappingActivityBottom > -Infinity - ? overlappingActivityBottom + ACTIVITY_KANBAN_CLEARANCE - : -Infinity - ); + baseY = ownerY + offsetY; } // Build headers + position tasks @@ -376,7 +349,3 @@ export class KanbanLayoutEngine { } } } - -function rangesOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean { - return aStart < bEnd && bStart < aEnd; -} diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts index cf11900f..448095e2 100644 --- a/packages/agent-graph/src/layout/stableSlots.ts +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -1,6 +1,6 @@ import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants'; import type { GraphLayoutPort, GraphNode, GraphOwnerSlotAssignment } from '../ports/types'; -import { ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_LANE } from './activityLane'; +import { ACTIVITY_LANE } from './activityLane'; import { LAUNCH_ANCHOR_LAYOUT, type WorldBounds } from './launchAnchor'; import { STABLE_SLOT_GEOMETRY, @@ -24,11 +24,13 @@ export interface OwnerFootprint { slotHeight: number; widthBucket: StableSlotWidthBucket; radialDepth: number; - activityWidth: number; - activityHeight: number; - processRailWidth: number; - taskBandWidth: number; - taskBandHeight: number; + activityColumnWidth: number; + activityColumnHeight: number; + processBandWidth: number; + kanbanBandWidth: number; + kanbanBandHeight: number; + boardBandWidth: number; + boardBandHeight: number; taskColumnCount: number; processCount: number; } @@ -41,9 +43,10 @@ export interface SlotFrame { bounds: StableRect; ownerX: number; ownerY: number; - activityRect: StableRect; + boardBandRect: StableRect; + activityColumnRect: StableRect; processBandRect: StableRect; - taskBandRect: StableRect; + kanbanBandRect: StableRect; taskColumnCount: number; } @@ -93,17 +96,24 @@ type RingLayoutStateMap = ReadonlyMap; const SLOT_GEOMETRY = { ...STABLE_SLOT_GEOMETRY, - activityHeight: ACTIVITY_ANCHOR_LAYOUT.reservedHeight, - activityWidth: ACTIVITY_LANE.width, - activityToOwnerGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, - ownerToProcessGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, - processToTaskGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, - taskBandHeight: + activityColumnHeight: ACTIVITY_LANE.headerHeight + + ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight + + ACTIVITY_LANE.overflowHeight, + activityColumnWidth: ACTIVITY_LANE.width, + ownerToProcessGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, + processToBoardGap: STABLE_SLOT_GEOMETRY.slotVerticalGap, + boardColumnGap: 24, + processRailMinWidth: STABLE_SLOT_GEOMETRY.processRailWidth, + kanbanBandHeight: + KANBAN_ZONE.headerHeight + STABLE_SLOT_GEOMETRY.taskMaxVisibleRows * KANBAN_ZONE.rowHeight, centralPadding: STABLE_SLOT_GEOMETRY.centralSafetyPadding, } as const; +const PROCESS_RAIL_NODE_GAP = 42; +const PROCESS_RAIL_NODE_FOOTPRINT = 28; + const SECTOR_VECTORS = STABLE_SLOT_SECTOR_VECTORS; export function buildStableSlotLayoutSnapshot({ @@ -119,9 +129,9 @@ export function buildStableSlotLayoutSnapshot({ const leadCoreRect = createCenteredRect(0, 0, 200, 168); const leadActivityRect = createRect( leadCoreRect.left - SLOT_GEOMETRY.centralBlockGap - ACTIVITY_LANE.width, - -ACTIVITY_ANCHOR_LAYOUT.reservedHeight / 2, + -SLOT_GEOMETRY.activityColumnHeight / 2, ACTIVITY_LANE.width, - ACTIVITY_ANCHOR_LAYOUT.reservedHeight + SLOT_GEOMETRY.activityColumnHeight ); const launchHudRect = createRect( leadCoreRect.right + SLOT_GEOMETRY.centralBlockGap, @@ -211,37 +221,42 @@ export function computeOwnerFootprints( } const taskColumnCount = taskColumnsByOwnerId.get(ownerId)?.size ?? 0; - const taskBandWidth = + const kanbanBandWidth = taskColumnCount <= 1 ? TASK_PILL.width : TASK_PILL.width + (taskColumnCount - 1) * KANBAN_ZONE.columnWidth; + const processCount = processCountByOwnerId.get(ownerId) ?? 0; + const processBandWidth = computeProcessBandWidth(processCount); + const boardBandWidth = + SLOT_GEOMETRY.activityColumnWidth + + SLOT_GEOMETRY.boardColumnGap + + kanbanBandWidth; + const boardBandHeight = Math.max( + SLOT_GEOMETRY.activityColumnHeight, + SLOT_GEOMETRY.kanbanBandHeight + ); const innerContentWidth = Math.max( - SLOT_GEOMETRY.activityWidth, SLOT_GEOMETRY.ownerMinWidth, - SLOT_GEOMETRY.processRailWidth, - taskBandWidth + processBandWidth, + boardBandWidth ); const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2; const slotHeight = SLOT_GEOMETRY.memberSlotInnerPadding * 2 + - SLOT_GEOMETRY.activityHeight + - SLOT_GEOMETRY.activityToOwnerGap + SLOT_GEOMETRY.ownerBandHeight + SLOT_GEOMETRY.ownerToProcessGap + SLOT_GEOMETRY.processBandHeight + - SLOT_GEOMETRY.processToTaskGap + - SLOT_GEOMETRY.taskBandHeight; + SLOT_GEOMETRY.processToBoardGap + + boardBandHeight; const radialDepth = Math.max( SLOT_GEOMETRY.memberSlotInnerPadding + - SLOT_GEOMETRY.activityHeight + - SLOT_GEOMETRY.activityToOwnerGap + SLOT_GEOMETRY.ownerBandHeight / 2, SLOT_GEOMETRY.memberSlotInnerPadding + SLOT_GEOMETRY.ownerBandHeight / 2 + SLOT_GEOMETRY.ownerToProcessGap + SLOT_GEOMETRY.processBandHeight + - SLOT_GEOMETRY.processToTaskGap + - SLOT_GEOMETRY.taskBandHeight + SLOT_GEOMETRY.processToBoardGap + + boardBandHeight ); return [ @@ -251,13 +266,15 @@ export function computeOwnerFootprints( slotHeight, widthBucket: classifyWidthBucket(slotWidth), radialDepth, - activityWidth: SLOT_GEOMETRY.activityWidth, - activityHeight: SLOT_GEOMETRY.activityHeight, - processRailWidth: SLOT_GEOMETRY.processRailWidth, - taskBandWidth, - taskBandHeight: SLOT_GEOMETRY.taskBandHeight, + activityColumnWidth: SLOT_GEOMETRY.activityColumnWidth, + activityColumnHeight: SLOT_GEOMETRY.activityColumnHeight, + processBandWidth, + kanbanBandWidth, + kanbanBandHeight: SLOT_GEOMETRY.kanbanBandHeight, + boardBandWidth, + boardBandHeight, taskColumnCount, - processCount: processCountByOwnerId.get(ownerId) ?? 0, + processCount, } satisfies OwnerFootprint, ]; }); @@ -273,6 +290,16 @@ export function classifyWidthBucket(width: number): StableSlotWidthBucket { return 'L'; } +export function computeProcessBandWidth(processCount: number): number { + if (processCount <= 1) { + return SLOT_GEOMETRY.processRailMinWidth; + } + + const occupiedWidth = + (processCount - 1) * PROCESS_RAIL_NODE_GAP + PROCESS_RAIL_NODE_FOOTPRINT; + return Math.max(SLOT_GEOMETRY.processRailMinWidth, occupiedWidth); +} + export function resolveNearestSlotAssignment(args: { ownerId: string; ownerX: number; @@ -461,14 +488,35 @@ function validateMemberSlotFrame( if (rectsOverlap(frame.bounds, snapshot.runtimeCentralExclusion)) { return { valid: false, reason: `slot frame for ${frame.ownerId} overlaps runtimeCentralExclusion` }; } - if (!rectContainsRect(frame.bounds, frame.activityRect)) { - return { valid: false, reason: `activityRect escapes slot bounds for ${frame.ownerId}` }; + if (!rectContainsRect(frame.bounds, frame.boardBandRect)) { + return { valid: false, reason: `boardBandRect escapes slot bounds for ${frame.ownerId}` }; + } + if (!rectContainsRect(frame.bounds, frame.activityColumnRect)) { + return { valid: false, reason: `activityColumnRect escapes slot bounds for ${frame.ownerId}` }; } if (!rectContainsRect(frame.bounds, frame.processBandRect)) { return { valid: false, reason: `processBandRect escapes slot bounds for ${frame.ownerId}` }; } - if (!rectContainsRect(frame.bounds, frame.taskBandRect)) { - return { valid: false, reason: `taskBandRect escapes slot bounds for ${frame.ownerId}` }; + if (!rectContainsRect(frame.bounds, frame.kanbanBandRect)) { + return { valid: false, reason: `kanbanBandRect escapes slot bounds for ${frame.ownerId}` }; + } + if (!rectContainsRect(frame.boardBandRect, frame.activityColumnRect)) { + return { + valid: false, + reason: `activityColumnRect escapes boardBandRect for ${frame.ownerId}`, + }; + } + if (!rectContainsRect(frame.boardBandRect, frame.kanbanBandRect)) { + return { + valid: false, + reason: `kanbanBandRect escapes boardBandRect for ${frame.ownerId}`, + }; + } + if (rectsOverlap(frame.activityColumnRect, frame.kanbanBandRect)) { + return { + valid: false, + reason: `activityColumnRect overlaps kanbanBandRect for ${frame.ownerId}`, + }; } if (!pointInRect(frame.ownerX, frame.ownerY, frame.bounds)) { return { valid: false, reason: `owner anchor escapes slot bounds for ${frame.ownerId}` }; @@ -502,9 +550,10 @@ export function translateSlotFrame(frame: SlotFrame, dx: number, dy: number): Sl bounds: translateRect(frame.bounds, dx, dy), ownerX: frame.ownerX + dx, ownerY: frame.ownerY + dy, - activityRect: translateRect(frame.activityRect, dx, dy), + boardBandRect: translateRect(frame.boardBandRect, dx, dy), + activityColumnRect: translateRect(frame.activityColumnRect, dx, dy), processBandRect: translateRect(frame.processBandRect, dx, dy), - taskBandRect: translateRect(frame.taskBandRect, dx, dy), + kanbanBandRect: translateRect(frame.kanbanBandRect, dx, dy), }; } @@ -554,7 +603,7 @@ function buildUnassignedTaskRect( columnCount <= 1 ? TASK_PILL.width : TASK_PILL.width + (columnCount - 1) * KANBAN_ZONE.columnWidth; - const height = SLOT_GEOMETRY.taskBandHeight; + const height = SLOT_GEOMETRY.kanbanBandHeight; return createRect( -width / 2, leadCentralReservedBlock.bottom + SLOT_GEOMETRY.unassignedGap, @@ -695,32 +744,36 @@ function buildSlotFrame( const ownerX = vector.x * radius; const ownerY = vector.y * radius; const slotTop = - ownerY - - (SLOT_GEOMETRY.memberSlotInnerPadding + - SLOT_GEOMETRY.activityHeight + - SLOT_GEOMETRY.activityToOwnerGap + - SLOT_GEOMETRY.ownerBandHeight / 2); - const bounds = createRect(ownerX - footprint.slotWidth / 2, slotTop, footprint.slotWidth, footprint.slotHeight); - const activityRect = createRect( - bounds.left + (bounds.width - footprint.activityWidth) / 2, - bounds.top + SLOT_GEOMETRY.memberSlotInnerPadding, - footprint.activityWidth, - footprint.activityHeight + ownerY - (SLOT_GEOMETRY.memberSlotInnerPadding + SLOT_GEOMETRY.ownerBandHeight / 2); + const bounds = createRect( + ownerX - footprint.slotWidth / 2, + slotTop, + footprint.slotWidth, + footprint.slotHeight ); const processBandRect = createRect( - bounds.left + (bounds.width - footprint.processRailWidth) / 2, - activityRect.bottom + - SLOT_GEOMETRY.activityToOwnerGap + - SLOT_GEOMETRY.ownerBandHeight + - SLOT_GEOMETRY.ownerToProcessGap, - footprint.processRailWidth, + bounds.left + (bounds.width - footprint.processBandWidth) / 2, + ownerY + SLOT_GEOMETRY.ownerBandHeight / 2 + SLOT_GEOMETRY.ownerToProcessGap, + footprint.processBandWidth, SLOT_GEOMETRY.processBandHeight ); - const taskBandRect = createRect( - bounds.left + (bounds.width - footprint.taskBandWidth) / 2, - processBandRect.bottom + SLOT_GEOMETRY.processToTaskGap, - footprint.taskBandWidth, - footprint.taskBandHeight + const boardBandRect = createRect( + bounds.left + (bounds.width - footprint.boardBandWidth) / 2, + processBandRect.bottom + SLOT_GEOMETRY.processToBoardGap, + footprint.boardBandWidth, + footprint.boardBandHeight + ); + const activityColumnRect = createRect( + boardBandRect.left, + boardBandRect.top, + footprint.activityColumnWidth, + footprint.activityColumnHeight + ); + const kanbanBandRect = createRect( + activityColumnRect.right + SLOT_GEOMETRY.boardColumnGap, + boardBandRect.top, + footprint.kanbanBandWidth, + footprint.kanbanBandHeight ); return { @@ -731,9 +784,10 @@ function buildSlotFrame( bounds, ownerX, ownerY, - activityRect, + boardBandRect, + activityColumnRect, processBandRect, - taskBandRect, + kanbanBandRect, taskColumnCount: footprint.taskColumnCount, }; } @@ -964,11 +1018,7 @@ function tryBuildValidSlotFrame( if (!frame) { return null; } - if ( - args.placedFrames.some((existing) => - rectsOverlapWithGap(existing.bounds, frame.bounds, SLOT_GEOMETRY.ringPadding) - ) - ) { + if (!isSlotFramePlacementValid(frame, args.placedFrames, args.centralExclusion)) { return null; } args.usedSlotKeys.add(slotKey); @@ -1079,11 +1129,7 @@ function computeSlotDirectionalDepths( footprint: OwnerFootprint, vector: { x: number; y: number } ): { outwardDepth: number; inwardDepth: number } { - const ownerLocalY = - SLOT_GEOMETRY.memberSlotInnerPadding + - SLOT_GEOMETRY.activityHeight + - SLOT_GEOMETRY.activityToOwnerGap + - SLOT_GEOMETRY.ownerBandHeight / 2; + const ownerLocalY = SLOT_GEOMETRY.memberSlotInnerPadding + SLOT_GEOMETRY.ownerBandHeight / 2; const topOffset = -ownerLocalY; const bottomOffset = footprint.slotHeight - ownerLocalY; const halfWidth = footprint.slotWidth / 2; diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index 807238ac..052c1465 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -16,6 +16,7 @@ import type { GraphDataPort } from '../ports/GraphDataPort'; import type { GraphEventPort } from '../ports/GraphEventPort'; import type { GraphConfigPort } from '../ports/GraphConfigPort'; import type { GraphEdge, GraphNode, GraphOwnerSlotAssignment } from '../ports/types'; +import type { StableRect } from '../layout/stableSlots'; import { GraphCanvas, type GraphCanvasHandle } from './GraphCanvas'; import { GraphControls, type GraphFilterState } from './GraphControls'; import { GraphOverlay } from './GraphOverlay'; @@ -31,7 +32,6 @@ import { getEdgeMidpoint, } from '../canvas/hit-detection'; import { ANIM_SPEED } from '../constants/canvas-constants'; -import { getActivityAnchorScreenPlacement as buildActivityAnchorScreenPlacement } from '../layout/activityLane'; import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor'; export interface GraphViewProps { @@ -70,19 +70,11 @@ export interface GraphViewProps { getLaunchAnchorScreenPlacement: ( leadNodeId: string, ) => { x: number; y: number; scale: number; visible: boolean } | null; - getActivityAnchorScreenPlacement: ( - ownerNodeId: string, - ) => { x: number; y: number; scale: number; visible: boolean } | null; - getActivityAnchorWorldPosition: ( - ownerNodeId: string, - ) => { x: number; y: number } | null; + getActivityWorldRect: (ownerNodeId: string) => StableRect | null; getCameraZoom: () => number; worldToScreen: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition: (nodeId: string) => { x: number; y: number } | null; getViewportSize: () => { width: number; height: number }; - getNodeScreenPosition: ( - nodeId: string, - ) => { x: number; y: number; visible: boolean } | null; focusNodeIds: ReadonlySet | null; }) => React.ReactNode; } @@ -240,49 +232,11 @@ export function GraphView({ viewportHeight: viewport.height, }); }, [getViewportSize]); - const getActivityAnchorScreenPlacement = useCallback((ownerNodeId: string) => { - const anchor = simulationRef.current.getActivityAnchorWorldPosition(ownerNodeId); - if (!anchor) { - return null; - } - const viewport = getViewportSize(); - if (viewport.width <= 0 || viewport.height <= 0) { - return null; - } - const transform = cameraRef.current.transformRef.current; - return buildActivityAnchorScreenPlacement({ - anchorX: anchor.x, - anchorY: anchor.y, - cameraX: transform.x, - cameraY: transform.y, - zoom: transform.zoom, - viewportWidth: viewport.width, - viewportHeight: viewport.height, - }); - }, [getViewportSize]); - const getActivityAnchorWorldPosition = useCallback( - (ownerNodeId: string) => simulationRef.current.getActivityAnchorWorldPosition(ownerNodeId), - [], - ); const getCameraZoom = useCallback(() => cameraRef.current.transformRef.current.zoom, []); - const getNodeScreenPosition = useCallback((nodeId: string) => { - const viewport = getViewportSize(); - if (viewport.width <= 0 || viewport.height <= 0) { - return null; - } - const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId); - if (node?.x == null || node?.y == null) { - return null; - } - const transform = cameraRef.current.transformRef.current; - const x = node.x * transform.zoom + transform.x; - const y = node.y * transform.zoom + transform.y; - return { - x, - y, - visible: x > -80 && x < viewport.width + 80 && y > -80 && y < viewport.height + 80, - }; - }, [getViewportSize]); + const getActivityWorldRect = useCallback( + (ownerNodeId: string) => simulationRef.current.getActivityWorldRect(ownerNodeId), + [] + ); const getNodeWorldPosition = useCallback((nodeId: string) => { const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId); if (node?.x == null || node?.y == null) { @@ -800,13 +754,11 @@ export function GraphView({
{renderHud({ getLaunchAnchorScreenPlacement, - getActivityAnchorScreenPlacement, - getActivityAnchorWorldPosition, + getActivityWorldRect, getCameraZoom, worldToScreen: camera.worldToScreen, getNodeWorldPosition, getViewportSize, - getNodeScreenPosition, focusNodeIds: focusState.focusNodeIds, })}
diff --git a/src/features/agent-graph/core/domain/buildInlineActivityEntries.ts b/src/features/agent-graph/core/domain/buildInlineActivityEntries.ts index 13304ff3..a5ddfa5c 100644 --- a/src/features/agent-graph/core/domain/buildInlineActivityEntries.ts +++ b/src/features/agent-graph/core/domain/buildInlineActivityEntries.ts @@ -3,7 +3,7 @@ import { getIdleGraphLabel } from '@shared/utils/idleNotificationSemantics'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { isLeadMember } from '@shared/utils/leadDetection'; -import { buildGraphMemberNodeIdForMember } from './graphOwnerIdentity'; +import { buildGraphMemberNodeIdAliasMap } from './graphOwnerIdentity'; import type { GraphActivityItem } from '@claude-teams/agent-graph'; import type { @@ -53,10 +53,9 @@ export function buildInlineActivityEntries({ ownerNodeIds, }: BuildInlineActivityEntriesArgs): Map { const entriesByOwnerNodeId = new Map(); - const memberNodeIdByName = new Map( - data.members - .filter((member) => !isLeadMember(member)) - .map((member) => [member.name, buildGraphMemberNodeIdForMember(teamName, member)] as const) + const memberNodeIdByAlias = buildGraphMemberNodeIdAliasMap( + teamName, + data.members.filter((member) => !isLeadMember(member)) ); const appendEntry = (entry: InlineActivityEntry): void => { @@ -95,7 +94,7 @@ export function buildInlineActivityEntries({ leadId, leadName, ownerNodeIds, - memberNodeIdByName, + memberNodeIdByAlias, }); if (!ownerNodeId) { continue; @@ -140,7 +139,7 @@ export function buildInlineActivityEntries({ leadId, leadName, ownerNodeIds, - memberNodeIdByName, + memberNodeIdByAlias, }); if (!ownerNodeId) { continue; @@ -220,16 +219,16 @@ function resolveMessageOwnerNodeId(args: { leadId: string; leadName: string; ownerNodeIds: ReadonlySet; - memberNodeIdByName: ReadonlyMap; + memberNodeIdByAlias: ReadonlyMap; }): string | null { - const { message, leadId, leadName, ownerNodeIds, memberNodeIdByName } = args; + const { message, leadId, leadName, ownerNodeIds, memberNodeIdByAlias } = args; if (message.source === 'cross_team' || message.source === 'cross_team_sent') { return leadId; } - const fromId = resolveParticipantId(message.from ?? '', leadId, leadName, memberNodeIdByName); + const fromId = resolveParticipantId(message.from ?? '', leadId, leadName, memberNodeIdByAlias); const toId = message.to - ? resolveParticipantId(message.to, leadId, leadName, memberNodeIdByName) + ? resolveParticipantId(message.to, leadId, leadName, memberNodeIdByAlias) : leadId; if (toId !== leadId && ownerNodeIds.has(toId)) { @@ -247,17 +246,17 @@ function resolveCommentOwnerNodeId(args: { leadId: string; leadName: string; ownerNodeIds: ReadonlySet; - memberNodeIdByName: ReadonlyMap; + memberNodeIdByAlias: ReadonlyMap; }): string | null { - const { taskOwner, author, leadId, leadName, ownerNodeIds, memberNodeIdByName } = args; + const { taskOwner, author, leadId, leadName, ownerNodeIds, memberNodeIdByAlias } = args; if (taskOwner) { - const ownerId = resolveParticipantId(taskOwner, leadId, leadName, memberNodeIdByName); + const ownerId = resolveParticipantId(taskOwner, leadId, leadName, memberNodeIdByAlias); if (ownerNodeIds.has(ownerId)) { return ownerId; } } - const authorId = resolveParticipantId(author, leadId, leadName, memberNodeIdByName); + const authorId = resolveParticipantId(author, leadId, leadName, memberNodeIdByAlias); if (ownerNodeIds.has(authorId)) { return authorId; } @@ -367,7 +366,7 @@ function resolveParticipantId( name: string, leadId: string, leadName: string | undefined, - memberNodeIdByName: ReadonlyMap + memberNodeIdByAlias: ReadonlyMap ): string { const normalized = name.trim().toLowerCase(); if (normalized === 'user' || normalized === 'team-lead') { @@ -376,7 +375,7 @@ function resolveParticipantId( if (normalized === leadName?.trim().toLowerCase()) { return leadId; } - return memberNodeIdByName.get(name) ?? leadId; + return memberNodeIdByAlias.get(name) ?? leadId; } function buildParticipantLabel(name: string | undefined, leadName: string): string { diff --git a/src/features/agent-graph/core/domain/graphOwnerIdentity.ts b/src/features/agent-graph/core/domain/graphOwnerIdentity.ts index bb050616..02a02aa8 100644 --- a/src/features/agent-graph/core/domain/graphOwnerIdentity.ts +++ b/src/features/agent-graph/core/domain/graphOwnerIdentity.ts @@ -17,6 +17,31 @@ export function buildGraphMemberNodeIdForMember( return buildGraphMemberNodeId(teamName, getGraphStableOwnerId(member)); } +export function buildGraphMemberNodeIdAliasMap( + teamName: string, + members: readonly StableTeamOwnerLike[] +): Map { + const aliases = new Map(); + + for (const member of members) { + const stableOwnerId = getGraphStableOwnerId(member).trim(); + if (!stableOwnerId) { + continue; + } + aliases.set(stableOwnerId, buildGraphMemberNodeId(teamName, stableOwnerId)); + } + + for (const member of members) { + const memberName = member.name.trim(); + if (!memberName || aliases.has(memberName)) { + continue; + } + aliases.set(memberName, buildGraphMemberNodeIdForMember(teamName, member)); + } + + return aliases; +} + export function parseGraphMemberNodeId(nodeId: string, teamName?: string): string | null { const prefix = teamName ? `member:${teamName}:` : 'member:'; if (!nodeId.startsWith(prefix)) { diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 9586f4d0..86b53c9f 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -30,6 +30,7 @@ import { } from '../../core/domain/buildInlineActivityEntries'; import { collapseOverflowStacksWithMeta } from '../../core/domain/collapseOverflowStacks'; import { + buildGraphMemberNodeIdAliasMap, buildGraphMemberNodeIdForMember, getGraphStableOwnerId, GRAPH_STABLE_SLOT_LAYOUT_VERSION, @@ -134,7 +135,7 @@ export class TeamGraphAdapter { const leadId = `lead:${teamName}`; const leadName = TeamGraphAdapter.#getLeadMemberName(teamData, teamName); - const memberNodeIdByName = TeamGraphAdapter.#buildMemberNodeIdByName(teamData, teamName); + const memberNodeIdByAlias = TeamGraphAdapter.#buildMemberNodeIdByAlias(teamData, teamName); const provisioningPresentation = buildTeamProvisioningPresentation({ progress: provisioningProgress, members: teamData.members, @@ -164,7 +165,7 @@ export class TeamGraphAdapter { leadId, teamData, teamName, - memberNodeIdByName, + memberNodeIdByAlias, spawnStatuses, pendingApprovalAgents, activeTools, @@ -173,8 +174,8 @@ export class TeamGraphAdapter { isTeamProvisioning, isLaunchSettling ); - this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState, memberNodeIdByName); - this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByName); + this.#buildTaskNodes(nodes, edges, teamData, teamName, commentReadState, memberNodeIdByAlias); + this.#buildProcessNodes(nodes, edges, teamData, teamName, memberNodeIdByAlias); this.#attachActivityFeeds(nodes, teamData, teamName, leadId, leadName); this.#buildMessageParticles( particles, @@ -184,7 +185,7 @@ export class TeamGraphAdapter { leadId, leadName, edges, - memberNodeIdByName + memberNodeIdByAlias ); this.#buildCommentParticles( particles, @@ -193,7 +194,7 @@ export class TeamGraphAdapter { leadId, leadName, edges, - memberNodeIdByName + memberNodeIdByAlias ); return { @@ -226,11 +227,10 @@ export class TeamGraphAdapter { return getGraphLeadMemberName(data, teamName); } - static #buildMemberNodeIdByName(data: TeamData, teamName: string): Map { - return new Map( - data.members - .filter((member) => !isLeadMember(member)) - .map((member) => [member.name, buildGraphMemberNodeIdForMember(teamName, member)] as const) + static #buildMemberNodeIdByAlias(data: TeamData, teamName: string): Map { + return buildGraphMemberNodeIdAliasMap( + teamName, + data.members.filter((member) => !isLeadMember(member)) ); } @@ -464,7 +464,7 @@ export class TeamGraphAdapter { leadId: string, data: TeamData, teamName: string, - memberNodeIdByName: ReadonlyMap, + memberNodeIdByAlias: ReadonlyMap, spawnStatuses?: Record, pendingApprovalAgents?: Set, activeTools?: Record>, @@ -478,7 +478,7 @@ export class TeamGraphAdapter { if (isLeadMember(member)) continue; const memberId = - memberNodeIdByName.get(member.name) ?? buildGraphMemberNodeIdForMember(teamName, member); + memberNodeIdByAlias.get(member.name) ?? buildGraphMemberNodeIdForMember(teamName, member); const spawn = spawnStatuses?.[member.name]; const activeTool = TeamGraphAdapter.#selectVisibleTool( activeTools?.[member.name], @@ -568,7 +568,7 @@ export class TeamGraphAdapter { data: TeamData, teamName: string, commentReadState?: Record, - memberNodeIdByName?: ReadonlyMap + memberNodeIdByAlias?: ReadonlyMap ): void { const taskStateById = new Map>(); const taskDisplayIds = new Map(); @@ -589,7 +589,7 @@ export class TeamGraphAdapter { for (const task of data.tasks) { if (task.status === 'deleted') continue; const taskId = `task:${teamName}:${task.id}`; - const ownerMemberId = task.owner ? (memberNodeIdByName?.get(task.owner) ?? null) : null; + const ownerMemberId = task.owner ? (memberNodeIdByAlias?.get(task.owner) ?? null) : null; const kanbanTaskState = data.kanbanState.tasks[task.id]; const reviewerName = resolveTaskReviewer(task, kanbanTaskState); const isReviewCycle = isTaskInReviewCycle(task); @@ -752,11 +752,11 @@ export class TeamGraphAdapter { edges: GraphEdge[], data: TeamData, teamName: string, - memberNodeIdByName?: ReadonlyMap + memberNodeIdByAlias?: ReadonlyMap ): void { for (const { process: proc, ownerId } of TeamGraphAdapter.#selectRelevantProcesses( data.processes, - memberNodeIdByName + memberNodeIdByAlias )) { const procId = `process:${teamName}:${proc.id}`; @@ -786,13 +786,13 @@ export class TeamGraphAdapter { static #selectRelevantProcesses( processes: readonly TeamProcess[], - memberNodeIdByName?: ReadonlyMap + memberNodeIdByAlias?: ReadonlyMap ): { process: TeamProcess; ownerId: string }[] { const selectedByOwnerId = new Map(); for (const process of processes) { const ownerId = process.registeredBy - ? (memberNodeIdByName?.get(process.registeredBy) ?? null) + ? (memberNodeIdByAlias?.get(process.registeredBy) ?? null) : null; if (!ownerId) { continue; @@ -872,7 +872,7 @@ export class TeamGraphAdapter { leadId: string, leadName: string, edges: GraphEdge[], - memberNodeIdByName: ReadonlyMap + memberNodeIdByAlias: ReadonlyMap ): void { const ordered = [...messages].reverse(); @@ -960,7 +960,7 @@ export class TeamGraphAdapter { leadId, leadName, edges, - memberNodeIdByName + memberNodeIdByAlias ); if (!edgeId) continue; @@ -970,7 +970,7 @@ export class TeamGraphAdapter { msg.from ?? '', leadId, leadName, - memberNodeIdByName + memberNodeIdByAlias ); const isFromTeammate = fromId !== leadId; @@ -1011,7 +1011,7 @@ export class TeamGraphAdapter { leadId: string, leadName: string, edges: GraphEdge[], - memberNodeIdByName: ReadonlyMap + memberNodeIdByAlias: ReadonlyMap ): void { // First call: record current comment counts without creating particles. // This prevents pre-existing comments from spawning particles when the graph opens. @@ -1052,7 +1052,7 @@ export class TeamGraphAdapter { newComment.author, leadId, leadName, - memberNodeIdByName + memberNodeIdByAlias ); const taskNodeId = `task:${teamName}:${task.id}`; const authorEdge = @@ -1187,7 +1187,7 @@ export class TeamGraphAdapter { leadId: string, leadName: string, edges: GraphEdge[], - memberNodeIdByName: ReadonlyMap + memberNodeIdByAlias: ReadonlyMap ): string | null { const { from, to } = msg; @@ -1196,9 +1196,14 @@ export class TeamGraphAdapter { from, leadId, leadName, - memberNodeIdByName + memberNodeIdByAlias + ); + const toId = TeamGraphAdapter.#resolveParticipantId( + to, + leadId, + leadName, + memberNodeIdByAlias ); - const toId = TeamGraphAdapter.#resolveParticipantId(to, leadId, leadName, memberNodeIdByName); return ( edges.find((e) => e.source === fromId && e.target === toId)?.id ?? edges.find((e) => e.source === toId && e.target === fromId)?.id ?? @@ -1211,7 +1216,7 @@ export class TeamGraphAdapter { from, leadId, leadName, - memberNodeIdByName + memberNodeIdByAlias ); return ( edges.find( @@ -1229,12 +1234,12 @@ export class TeamGraphAdapter { name: string, leadId: string, leadName: string | undefined, - memberNodeIdByName: ReadonlyMap + memberNodeIdByAlias: ReadonlyMap ): string { const normalized = name.trim().toLowerCase(); if (normalized === 'user' || normalized === 'team-lead') return leadId; if (normalized === leadName?.trim().toLowerCase()) return leadId; - return memberNodeIdByName.get(name) ?? leadId; + return memberNodeIdByAlias.get(name) ?? leadId; } /** Extract external team name from cross-team "from" field like "team-b.alice" */ diff --git a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx index b157fb78..42bb1a73 100644 --- a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_LANE } from '@claude-teams/agent-graph'; +import { ACTIVITY_LANE } from '@claude-teams/agent-graph'; import { ActivityItem } from '@renderer/components/team/activity/ActivityItem'; import { buildMessageContext, @@ -26,18 +26,26 @@ import type { } from '@renderer/components/team/members/memberDetailTypes'; import type { ResolvedTeamMember } from '@shared/types/team'; +const ACTIVITY_SHELL_HEIGHT = + ACTIVITY_LANE.headerHeight + + ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight + + ACTIVITY_LANE.overflowHeight; + interface GraphActivityHudProps { teamName: string; nodes: GraphNode[]; - getActivityAnchorScreenPlacement: ( - ownerNodeId: string - ) => { x: number; y: number; scale: number; visible: boolean } | null; - getActivityAnchorWorldPosition?: (ownerNodeId: string) => { x: number; y: number } | null; + getActivityWorldRect?: (ownerNodeId: string) => { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + } | null; getCameraZoom?: () => number; worldToScreen?: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; getViewportSize?: () => { width: number; height: number }; - getNodeScreenPosition?: (nodeId: string) => { x: number; y: number; visible: boolean } | null; focusNodeIds: ReadonlySet | null; enabled?: boolean; onOpenTaskDetail?: (taskId: string) => void; @@ -53,13 +61,11 @@ interface GraphActivityHudProps { export const GraphActivityHud = ({ teamName, nodes, - getActivityAnchorScreenPlacement, - getActivityAnchorWorldPosition = () => null, + getActivityWorldRect = () => null, getCameraZoom = () => 1, worldToScreen, getNodeWorldPosition = () => null, getViewportSize, - getNodeScreenPosition = () => null, focusNodeIds, enabled = true, onOpenTaskDetail, @@ -125,7 +131,9 @@ export const GraphActivityHud = ({ overflowCount, }; }) - .filter((lane) => lane.entries.length > 0 || lane.overflowCount > 0); + .filter( + (lane) => lane.node.kind === 'member' || lane.entries.length > 0 || lane.overflowCount > 0 + ); }, [entryMapByOwnerNodeId, ownerNodes]); useLayoutEffect(() => { @@ -157,7 +165,7 @@ export const GraphActivityHud = ({ shell: HTMLDivElement; connector: SVGSVGElement | null; connectorPath: SVGPathElement | null; - laneTopLeft: { x: number; y: number }; + laneRect: NonNullable>; nodeWorld: { x: number; y: number }; }[] = []; @@ -169,10 +177,9 @@ export const GraphActivityHud = ({ const connector = connectorRefs.current.get(lane.node.id) ?? null; const connectorPath = connectorPathRefs.current.get(lane.node.id) ?? null; - const placement = getActivityAnchorScreenPlacement(lane.node.id); - const laneTopLeft = getActivityAnchorWorldPosition(lane.node.id); + const laneRect = getActivityWorldRect(lane.node.id); const nodeWorld = getNodeWorldPosition(lane.node.id); - if (!placement || !laneTopLeft || !nodeWorld) { + if (!laneRect || !nodeWorld || !worldToScreen) { shell.style.opacity = '0'; if (connector) { connector.style.opacity = '0'; @@ -180,19 +187,18 @@ export const GraphActivityHud = ({ continue; } - const scale = Math.max(getCameraZoom(), 0.001); - const widthScreen = Math.max(1, (shell.offsetWidth || ACTIVITY_LANE.width) * scale); - const heightScreen = Math.max(1, (shell.offsetHeight || 220) * scale); + const zoom = Math.max(getCameraZoom(), 0.001); + const screenTopLeft = worldToScreen(laneRect.left, laneRect.top); + const widthScreen = Math.max(1, laneRect.width * zoom); + const heightScreen = Math.max(1, laneRect.height * zoom); const viewport = getViewportSize?.(); - const laneVisible = viewport - ? placement.x + widthScreen > -80 && - placement.x < viewport.width + 80 && - placement.y + heightScreen > -80 && - placement.y < viewport.height + 80 - : placement.visible; - - const nodeScreen = getNodeScreenPosition(lane.node.id); - if (!nodeScreen?.visible || !laneVisible) { + const laneVisible = + !viewport || + (screenTopLeft.x + widthScreen > -80 && + screenTopLeft.x < viewport.width + 80 && + screenTopLeft.y + heightScreen > -80 && + screenTopLeft.y < viewport.height + 80); + if (!laneVisible) { shell.style.opacity = '0'; if (connector) { connector.style.opacity = '0'; @@ -205,31 +211,23 @@ export const GraphActivityHud = ({ shell, connector, connectorPath, - laneTopLeft, + laneRect, nodeWorld, }); } for (const entry of measurableLanes) { - const { lane, shell, connector, connectorPath, laneTopLeft, nodeWorld } = entry; + const { lane, shell, connector, connectorPath, laneRect, nodeWorld } = entry; const baseOpacity = focusNodeIds && !focusNodeIds.has(lane.node.id) ? 0.25 : 1; - const widthWorld = shell.offsetWidth || ACTIVITY_LANE.width; - const heightWorld = shell.offsetHeight || 220; - const ownerBottomLimit = - nodeWorld.y + - (lane.node.kind === 'lead' - ? ACTIVITY_ANCHOR_LAYOUT.leadOffsetY + ACTIVITY_ANCHOR_LAYOUT.reservedHeight - : ACTIVITY_ANCHOR_LAYOUT.memberOffsetY + ACTIVITY_ANCHOR_LAYOUT.reservedHeight); - const adjustedLaneTop = Math.min(laneTopLeft.y, ownerBottomLimit - heightWorld); shell.style.opacity = String(baseOpacity); - shell.style.left = `${Math.round(laneTopLeft.x)}px`; - shell.style.top = `${Math.round(adjustedLaneTop)}px`; + shell.style.left = `${Math.round(laneRect.left)}px`; + shell.style.top = `${Math.round(laneRect.top)}px`; shell.style.transform = ''; if (connector && connectorPath) { - const endX = laneTopLeft.x + widthWorld / 2; - const endY = adjustedLaneTop + heightWorld - 6; + const endX = laneRect.left + laneRect.width / 2; + const endY = laneRect.top >= nodeWorld.y ? laneRect.top + 10 : laneRect.bottom - 10; const startX = nodeWorld.x; const startY = nodeWorld.y - 18; const minX = Math.min(startX, endX); @@ -273,11 +271,9 @@ export const GraphActivityHud = ({ }, [ enabled, focusNodeIds, - getActivityAnchorScreenPlacement, - getActivityAnchorWorldPosition, + getActivityWorldRect, getCameraZoom, getNodeWorldPosition, - getNodeScreenPosition, getViewportSize, worldToScreen, visibleLanes, @@ -341,7 +337,9 @@ export const GraphActivityHud = ({ if (!(canvas instanceof HTMLCanvasElement)) { return; } - event.preventDefault(); + if (event.cancelable) { + event.preventDefault(); + } canvas.dispatchEvent( new WheelEvent('wheel', { deltaX: event.deltaX, @@ -395,91 +393,118 @@ export const GraphActivityHud = ({ > {visibleLanes.map((lane) => (
- { - connectorRefs.current.set(lane.node.id, element); - }} - className="pointer-events-none absolute z-[9] overflow-visible opacity-0" - > - { - connectorPathRefs.current.set(lane.node.id, element); - }} - d="" - fill="none" - stroke="rgba(148, 163, 184, 0.3)" - strokeWidth="1.25" - strokeLinecap="round" - strokeDasharray="3 4" - /> - -
{ - shellRefs.current.set(lane.node.id, element); - }} - className="pointer-events-auto absolute z-10 origin-top-left opacity-0" - style={{ width: `${ACTIVITY_LANE.width}px`, maxWidth: `${ACTIVITY_LANE.width}px` }} - > -
- Activity -
-
- {lane.entries.map((entry, index) => { - const messageKey = toMessageKey(entry.message); - const renderProps = resolveMessageRenderProps(entry.message, messageContext); - const timelineItem: TimelineItem = { type: 'message', message: entry.message }; - const isUnread = !entry.message.read && !readSet.has(messageKey); + {(() => { + const laneRect = getActivityWorldRect(lane.node.id); + const laneWidth = laneRect?.width ?? ACTIVITY_LANE.width; + const laneHeight = laneRect?.height ?? ACTIVITY_SHELL_HEIGHT; - return ( -
handleMessageClick(timelineItem)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - handleMessageClick(timelineItem); - } - }} - > - -
- ); - })} - - {lane.overflowCount > 0 ? ( - - ) : null} -
-
+ { + connectorPathRefs.current.set(lane.node.id, element); + }} + d="" + fill="none" + stroke="rgba(148, 163, 184, 0.3)" + strokeWidth="1.25" + strokeLinecap="round" + strokeDasharray="3 4" + /> + +
{ + shellRefs.current.set(lane.node.id, element); + }} + className="pointer-events-auto absolute z-10 origin-top-left opacity-0" + style={{ + width: `${laneWidth}px`, + maxWidth: `${laneWidth}px`, + height: `${laneHeight}px`, + }} + > +
+
+ Activity +
+
+ {lane.entries.length === 0 && lane.overflowCount === 0 ? ( +
+ No recent activity +
+ ) : null} + {lane.entries.map((entry, index) => { + const messageKey = toMessageKey(entry.message); + const renderProps = resolveMessageRenderProps( + entry.message, + messageContext + ); + const timelineItem: TimelineItem = { + type: 'message', + message: entry.message, + }; + const isUnread = !entry.message.read && !readSet.has(messageKey); + + return ( +
handleMessageClick(timelineItem)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleMessageClick(timelineItem); + } + }} + > + +
+ ); + })} + + {lane.overflowCount > 0 ? ( + + ) : null} +
+
+
+ + ); + })()}
))} diff --git a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index fbf6287e..74df726b 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -131,33 +131,30 @@ export const TeamGraphOverlay = ({ renderHud={(hudProps) => { const extraHudProps = hudProps as typeof hudProps & { getViewportSize?: () => { width: number; height: number }; - getActivityAnchorWorldPosition?: ( - ownerNodeId: string - ) => { x: number; y: number } | null; + getActivityWorldRect?: (ownerNodeId: string) => { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + } | null; getCameraZoom?: () => number; worldToScreen?: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; }; - const { - getLaunchAnchorScreenPlacement, - getActivityAnchorScreenPlacement, - getViewportSize, - getNodeScreenPosition, - focusNodeIds, - } = extraHudProps; + const { getLaunchAnchorScreenPlacement, getViewportSize, focusNodeIds } = extraHudProps; return ( <> { const extraHudProps = hudProps as typeof hudProps & { getViewportSize?: () => { width: number; height: number }; - getActivityAnchorWorldPosition?: ( - ownerNodeId: string - ) => { x: number; y: number } | null; + getActivityWorldRect?: (ownerNodeId: string) => { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + } | null; getCameraZoom?: () => number; worldToScreen?: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; }; - const { - getLaunchAnchorScreenPlacement, - getActivityAnchorScreenPlacement, - getViewportSize, - getNodeScreenPosition, - focusNodeIds, - } = extraHudProps; + const { getLaunchAnchorScreenPlacement, getViewportSize, focusNodeIds } = extraHudProps; return ( <> { React.createElement(GraphActivityHud, { teamName: 'demo-team', nodes: [node], - getActivityAnchorScreenPlacement: () => ({ x: 40, y: 80, scale: 1, visible: true }), + getActivityWorldRect: () => ({ + left: 40, + top: 80, + right: 336, + bottom: 372, + width: 296, + height: 292, + }), + getCameraZoom: () => 1, + worldToScreen: (x: number, y: number) => ({ x, y }), + getNodeWorldPosition: () => ({ x: 120, y: 40 }), + getViewportSize: () => ({ width: 1200, height: 800 }), focusNodeIds: null, onOpenMemberProfile, }) @@ -256,7 +266,7 @@ describe('GraphActivityHud', () => { }); }); - it('keeps the activity lane above the owner label area when packed anchor drifts too low', async () => { + it('pins the activity lane to the provided world rect without post-hoc repositioning', async () => { const message: InboxMessage = { from: 'team-lead', to: 'jack', @@ -307,17 +317,23 @@ describe('GraphActivityHud', () => { document.body.appendChild(host); const root = createRoot(host); const nodeWorld = { x: 320, y: 300 }; - const packedAnchor = { x: 120, y: 260 }; + const laneRect = { + left: 120, + top: 340, + right: 416, + bottom: 632, + width: 296, + height: 292, + }; await act(async () => { root.render( React.createElement(GraphActivityHud, { teamName: 'demo-team', nodes: [node], - getActivityAnchorScreenPlacement: () => ({ x: 40, y: 80, scale: 1, visible: true }), - getActivityAnchorWorldPosition: () => packedAnchor, + getActivityWorldRect: () => laneRect, + getCameraZoom: () => 1, getNodeWorldPosition: () => nodeWorld, - getNodeScreenPosition: () => ({ x: 400, y: 300, visible: true }), getViewportSize: () => ({ width: 1200, height: 800 }), worldToScreen: (x: number, y: number) => ({ x, y }), focusNodeIds: null, @@ -328,12 +344,8 @@ describe('GraphActivityHud', () => { const shell = host.querySelector('.z-10'); expect(shell).not.toBeNull(); - const expectedTop = - nodeWorld.y + - ACTIVITY_ANCHOR_LAYOUT.memberOffsetY + - ACTIVITY_ANCHOR_LAYOUT.reservedHeight - - 220; - expect((shell as HTMLDivElement).style.top).toBe(`${Math.round(expectedTop)}px`); + expect((shell as HTMLDivElement).style.left).toBe(`${laneRect.left}px`); + expect((shell as HTMLDivElement).style.top).toBe(`${laneRect.top}px`); await act(async () => { root.unmount(); diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 4c2ba66f..af760759 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -736,6 +736,80 @@ describe('TeamGraphAdapter particles', () => { ]); }); + it('resolves task and process owners by stable owner id aliases, not only member names', () => { + const adapter = TeamGraphAdapter.create(); + + const graph = adapter.adapt( + createBaseTeamData({ + config: { + name: 'My Team', + members: [ + { name: 'team-lead', agentId: 'lead-agent' }, + { name: 'alice', agentId: 'agent-alice' }, + ], + projectPath: '/repo', + }, + members: [ + { + name: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + agentType: 'team-lead', + agentId: 'lead-agent', + }, + { + name: 'alice', + status: 'active', + currentTaskId: null, + taskCount: 1, + lastActiveAt: null, + messageCount: 0, + agentId: 'agent-alice', + }, + ], + tasks: [ + { + id: 'task-owned-by-stable-id', + displayId: '#42', + subject: 'Stable owner task', + owner: 'agent-alice', + status: 'completed', + comments: [], + reviewState: 'none', + } as TeamTaskWithKanban, + ], + processes: [ + { + id: 'proc-owned-by-stable-id', + label: 'Stable owner process', + pid: 4242, + registeredBy: 'agent-alice', + registeredAt: '2026-03-28T19:00:02.000Z', + }, + ], + }), + 'my-team' + ); + + expect(findNode(graph, 'task:my-team:task-owned-by-stable-id')).toMatchObject({ + ownerId: 'member:my-team:agent-alice', + taskStatus: 'completed', + }); + expect(findNode(graph, 'process:my-team:proc-owned-by-stable-id')).toMatchObject({ + ownerId: 'member:my-team:agent-alice', + }); + expect( + graph.edges.some( + (edge) => + edge.id === + 'edge:own:member:my-team:agent-alice:task:my-team:task-owned-by-stable-id' + ) + ).toBe(true); + }); + it('skips noisy idle inbox rows in the activity feed while keeping cross-team traffic on the lead lane', () => { const adapter = TeamGraphAdapter.create(); diff --git a/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts b/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts index 6c97a6ff..2d556459 100644 --- a/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts +++ b/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts @@ -199,4 +199,72 @@ describe('buildInlineActivityEntries', () => { taskRefs: [{ taskId: 'task-1', displayId: '#8fdd6803', teamName: 'my-team' }], }); }); + + it('routes comment activity to a member lane when task.owner is stored as stable owner id', () => { + const data = createBaseTeamData({ + config: { + name: 'My Team', + members: [{ name: 'team-lead', agentId: 'lead-agent' }, { name: 'jack', agentId: 'agent-jack' }], + projectPath: '/repo', + }, + tasks: [ + { + id: 'task-stable-owner', + displayId: '#91', + subject: 'Stable owner routing', + owner: 'agent-jack', + status: 'in_progress', + comments: [ + { + id: 'comment-stable-owner', + author: 'team-lead', + text: 'Проверь финальную сводку перед merge', + createdAt: '2026-03-28T19:00:03.000Z', + type: 'regular', + }, + ], + reviewState: 'none', + } as unknown as TeamTaskWithKanban, + ], + members: [ + { + name: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + agentType: 'team-lead', + agentId: 'lead-agent', + }, + { + name: 'jack', + status: 'active', + currentTaskId: null, + taskCount: 1, + lastActiveAt: null, + messageCount: 0, + agentId: 'agent-jack', + }, + ], + }); + + const entries = buildInlineActivityEntries({ + data, + teamName: 'my-team', + leadId: 'lead:my-team', + leadName: getGraphLeadMemberName(data, 'my-team'), + ownerNodeIds: new Set(['lead:my-team', 'member:my-team:agent-jack']), + }); + + expect(entries.get('member:my-team:agent-jack')).toEqual([ + expect.objectContaining({ + graphItem: expect.objectContaining({ + id: 'activity:comment:my-team:task-stable-owner:comment-stable-owner', + title: '#91 Stable owner routing', + taskId: 'task-stable-owner', + }), + }), + ]); + }); }); diff --git a/test/renderer/features/agent-graph/useGraphSimulation.test.ts b/test/renderer/features/agent-graph/useGraphSimulation.test.ts index 7abed6f4..96e084ed 100644 --- a/test/renderer/features/agent-graph/useGraphSimulation.test.ts +++ b/test/renderer/features/agent-graph/useGraphSimulation.test.ts @@ -3,12 +3,15 @@ import { describe, expect, it, vi } from 'vitest'; import { buildStableSlotLayoutSnapshot, computeOwnerFootprints, + computeProcessBandWidth, resolveNearestSlotAssignment, snapshotToWorldBounds, validateStableSlotLayout, } from '../../../../packages/agent-graph/src/layout/stableSlots'; +import { KanbanLayoutEngine } from '../../../../packages/agent-graph/src/layout/kanbanLayout'; +import { TASK_PILL } from '../../../../packages/agent-graph/src/constants/canvas-constants'; +import { ACTIVITY_LANE } from '../../../../packages/agent-graph/src/layout/activityLane'; import { STABLE_SLOT_GEOMETRY } from '../../../../packages/agent-graph/src/layout/stableSlotGeometry'; -import { ACTIVITY_ANCHOR_LAYOUT } from '../../../../packages/agent-graph/src/layout/activityLane'; import type { GraphLayoutPort, GraphNode } from '@claude-teams/agent-graph'; @@ -51,6 +54,17 @@ function createTask( }; } +function createProcess(teamName: string, processId: string, ownerId: string): GraphNode { + return { + id: `process:${teamName}:${processId}`, + kind: 'process', + label: processId, + state: 'active', + ownerId, + domainRef: { kind: 'process', teamName, processId }, + }; +} + describe('stable slot layout planner', () => { it('does not build a stable slot snapshot when the lead is missing', () => { const snapshot = buildStableSlotLayoutSnapshot({ @@ -96,7 +110,7 @@ describe('stable slot layout planner', () => { expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true }); }); - it('keeps a fixed process rail width centered inside the owner slot', () => { + it('builds a board band that contains both the activity column and kanban band', () => { const teamName = 'team-process-width'; const lead = createLead(teamName); const alice = createMember(teamName, 'agent-alice', 'alice'); @@ -116,11 +130,61 @@ describe('stable slot layout planner', () => { const frame = snapshot?.memberSlotFrames[0]; expect(frame).toBeDefined(); - expect(frame?.processBandRect.width).toBe(STABLE_SLOT_GEOMETRY.processRailWidth); - expect(frame?.processBandRect.left).toBeCloseTo( - (frame?.bounds.left ?? 0) + ((frame?.bounds.width ?? 0) - STABLE_SLOT_GEOMETRY.processRailWidth) / 2, - 6 + expect(frame?.boardBandRect.top).toBe(frame?.activityColumnRect.top); + expect(frame?.boardBandRect.top).toBe(frame?.kanbanBandRect.top); + expect(frame?.activityColumnRect.left).toBe(frame?.boardBandRect.left); + expect(frame?.kanbanBandRect.left).toBeGreaterThan(frame?.activityColumnRect.right ?? 0); + expect(frame?.processBandRect.width).toBe(computeProcessBandWidth(0)); + }); + + it('reserves a full empty activity column and minimum kanban width for idle members', () => { + const teamName = 'team-empty-slot'; + const lead = createLead(teamName); + const alice = createMember(teamName, 'agent-alice', 'alice'); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [alice.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 1 }, + }, + }; + + const [footprint] = computeOwnerFootprints([lead, alice], layout); + + expect(footprint).toBeDefined(); + expect(footprint?.activityColumnWidth).toBe(ACTIVITY_LANE.width); + expect(footprint?.activityColumnHeight).toBe( + ACTIVITY_LANE.headerHeight + + ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight + + ACTIVITY_LANE.overflowHeight ); + expect(footprint?.kanbanBandWidth).toBe(TASK_PILL.width); + expect(footprint?.boardBandHeight).toBe( + Math.max(footprint?.activityColumnHeight ?? 0, footprint?.kanbanBandHeight ?? 0) + ); + }); + + it('grows process band width when an owner has multiple visible process nodes', () => { + const teamName = 'team-process-growth'; + const lead = createLead(teamName); + const alice = createMember(teamName, 'agent-alice', 'alice'); + const processes = Array.from({ length: 7 }, (_, index) => + createProcess(teamName, `proc-${index + 1}`, alice.id) + ); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [alice.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 1 }, + }, + }; + + const [footprint] = computeOwnerFootprints([lead, alice, ...processes], layout); + + expect(footprint).toBeDefined(); + expect(footprint?.processCount).toBe(7); + expect(footprint?.processBandWidth).toBe(computeProcessBandWidth(7)); + expect((footprint?.processBandWidth ?? 0) > STABLE_SLOT_GEOMETRY.processRailWidth).toBe(true); }); it('includes full topology bounds for fit, not only activity overlays', () => { @@ -252,40 +316,34 @@ describe('stable slot layout planner', () => { it('computes the next ring radius from previous ring depth, not member count', () => { const teamName = 'team-ring-depth'; const lead = createLead(teamName); - const members = Array.from({ length: 7 }, (_, index) => - createMember(teamName, `agent-${index + 1}`, `member-${index + 1}`) - ); + const first = createMember(teamName, 'agent-first', 'member-1'); + const second = createMember(teamName, 'agent-second', 'member-2'); const layout: GraphLayoutPort = { version: 'stable-slots-v1', - ownerOrder: members.map((member) => member.id), - slotAssignments: Object.fromEntries( - members.map((member, index) => [ - member.id, - { - ringIndex: index < 6 ? 0 : 1, - sectorIndex: index % 6, - }, - ]) - ), + ownerOrder: [first.id, second.id], + slotAssignments: { + [first.id]: { ringIndex: 0, sectorIndex: 1 }, + [second.id]: { ringIndex: 1, sectorIndex: 1 }, + }, }; const snapshot = buildStableSlotLayoutSnapshot({ teamName, - nodes: [lead, ...members], + nodes: [lead, first, second], layout, }); - const footprints = computeOwnerFootprints([lead, ...members], layout); + const footprints = computeOwnerFootprints([lead, first, second], layout); const firstRingFrame = snapshot?.memberSlotFrames.find( - (frame) => frame.ringIndex === 0 && frame.sectorIndex === 0 + (frame) => frame.ownerId === first.id ); const secondRingFrame = snapshot?.memberSlotFrames.find( - (frame) => frame.ringIndex === 1 && frame.sectorIndex === 0 + (frame) => frame.ownerId === second.id ); expect(snapshot).not.toBeNull(); expect(firstRingFrame).toBeDefined(); expect(secondRingFrame).toBeDefined(); - const firstFootprint = footprints[0]; + const firstFootprint = footprints.find((footprint) => footprint.ownerId === first.id); expect(firstFootprint).toBeDefined(); if (!firstFootprint) { throw new Error('expected first footprint for ring-depth test'); @@ -293,17 +351,82 @@ describe('stable slot layout planner', () => { const ringDelta = Math.hypot(secondRingFrame!.ownerX, secondRingFrame!.ownerY) - Math.hypot(firstRingFrame!.ownerX, firstRingFrame!.ownerY); - const ownerAnchorOffsetY = - STABLE_SLOT_GEOMETRY.memberSlotInnerPadding + - ACTIVITY_ANCHOR_LAYOUT.reservedHeight + - STABLE_SLOT_GEOMETRY.slotVerticalGap + - STABLE_SLOT_GEOMETRY.ownerBandHeight / 2; - const expectedRingDelta = - ownerAnchorOffsetY + - (firstFootprint.slotHeight - ownerAnchorOffsetY) + - STABLE_SLOT_GEOMETRY.ringGap; + const sectorVector = { x: 0.82, y: -0.57 }; + const ownerLocalY = + STABLE_SLOT_GEOMETRY.memberSlotInnerPadding + STABLE_SLOT_GEOMETRY.ownerBandHeight / 2; + const topOffset = -ownerLocalY; + const bottomOffset = firstFootprint.slotHeight - ownerLocalY; + const halfWidth = firstFootprint.slotWidth / 2; + const vectorLength = Math.hypot(sectorVector.x, sectorVector.y) || 1; + const unitX = sectorVector.x / vectorLength; + const unitY = sectorVector.y / vectorLength; + const cornerProjections = [ + { x: -halfWidth, y: topOffset }, + { x: halfWidth, y: topOffset }, + { x: halfWidth, y: bottomOffset }, + { x: -halfWidth, y: bottomOffset }, + ].map((corner) => corner.x * unitX + corner.y * unitY); + const outwardDepth = Math.max(...cornerProjections); + const inwardDepth = Math.max(...cornerProjections.map((projection) => -projection)); + const expectedRingDelta = outwardDepth + inwardDepth + STABLE_SLOT_GEOMETRY.ringGap; - expect(ringDelta).toBeCloseTo(expectedRingDelta, 6); + expect(Math.abs(ringDelta - expectedRingDelta)).toBeLessThan(2); + }); + + it('keeps owned tasks out of unassigned topology when default sector candidates near the lead are invalid', () => { + const teamName = 'team-owned-tasks'; + const lead = createLead(teamName); + const members = [ + createMember(teamName, 'agent-alice', 'alice'), + createMember(teamName, 'agent-bob', 'bob'), + createMember(teamName, 'agent-tom', 'tom'), + createMember(teamName, 'agent-jack', 'jack'), + ]; + const tasks = [ + createTask(teamName, 'task-a', members[0].id, { taskStatus: 'completed' }), + createTask(teamName, 'task-b', members[1].id, { taskStatus: 'completed' }), + createTask(teamName, 'task-c', members[2].id, { taskStatus: 'completed' }), + createTask(teamName, 'task-d', members[3].id, { taskStatus: 'completed' }), + ]; + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: members.map((member) => member.id), + slotAssignments: {}, + }; + + const nodes = [lead, ...members, ...tasks]; + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes, + layout, + }); + + expect(snapshot).not.toBeNull(); + expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true }); + expect(snapshot?.unassignedTaskRect).toBeNull(); + + const memberSlotFrames = snapshot!.memberSlotFrames; + for (const frame of memberSlotFrames) { + const ownerNode = nodes.find((node) => node.id === frame.ownerId); + if (!ownerNode) { + continue; + } + ownerNode.x = frame.ownerX; + ownerNode.y = frame.ownerY; + } + KanbanLayoutEngine.layout(nodes, { + memberSlotFrames, + unassignedTaskRect: snapshot!.unassignedTaskRect, + }); + + for (const task of tasks) { + const ownerFrame = memberSlotFrames.find((frame) => frame.ownerId === task.ownerId); + expect(ownerFrame).toBeDefined(); + expect(task.x).toBeGreaterThanOrEqual(ownerFrame!.kanbanBandRect.left); + expect(task.x).toBeLessThanOrEqual(ownerFrame!.kanbanBandRect.right); + expect(task.y).toBeGreaterThanOrEqual(ownerFrame!.kanbanBandRect.top); + expect(task.y).toBeLessThanOrEqual(ownerFrame!.kanbanBandRect.bottom); + } }); it('keeps the same sector and spills to the next outer ring when the saved slot is already occupied', () => { From d81a45f15b15fc16e4b728ec3c3bfab01e33db99 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 10:53:52 +0300 Subject: [PATCH 3/6] fix(agent-graph): use directional central exclusion --- .../agent-graph/src/layout/stableSlots.ts | 245 ++++++++++++++---- .../agent-graph/useGraphSimulation.test.ts | 202 ++++++++++++++- 2 files changed, 395 insertions(+), 52 deletions(-) diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts index 448095e2..e606c3cd 100644 --- a/packages/agent-graph/src/layout/stableSlots.ts +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -60,6 +60,7 @@ export interface StableSlotLayoutSnapshot { launchAnchor: { x: number; y: number } | null; leadCentralReservedBlock: StableRect; runtimeCentralExclusion: StableRect; + centralCollisionRects: StableRect[]; memberSlotFrames: SlotFrame[]; memberSlotFrameByOwnerId: Map; unassignedTaskRect: StableRect | null; @@ -113,6 +114,7 @@ const SLOT_GEOMETRY = { const PROCESS_RAIL_NODE_GAP = 42; const PROCESS_RAIL_NODE_FOOTPRINT = 28; +const GEOMETRY_EPSILON = 0.001; const SECTOR_VECTORS = STABLE_SLOT_SECTOR_VECTORS; @@ -143,27 +145,30 @@ export function buildStableSlotLayoutSnapshot({ const ownerFootprints = computeOwnerFootprints(nodes, layout); const unassignedTaskRect = buildUnassignedTaskRect(nodes, leadCentralReservedBlock); + const centralCollisionRects = buildCentralCollisionRects({ + leadCoreRect, + leadActivityRect, + launchHudRect, + unassignedTaskRect, + }); const runtimeCentralExclusion = padRect( - unionRects( - unassignedTaskRect - ? [leadCentralReservedBlock, unassignedTaskRect] - : [leadCentralReservedBlock] - ), + unionRects(centralCollisionRects), SLOT_GEOMETRY.centralPadding ); - const memberSlotFrames = planOwnerSlots(ownerFootprints, runtimeCentralExclusion, layout); + const memberSlotFrames = planOwnerSlots( + ownerFootprints, + centralCollisionRects, + runtimeCentralExclusion, + layout + ); const memberSlotFrameByOwnerId = new Map( memberSlotFrames.map((frame) => [frame.ownerId, frame] as const) ); const fitBounds = unionRects( [ - leadCentralReservedBlock, - leadActivityRect, - launchHudRect, runtimeCentralExclusion, ...memberSlotFrames.map((frame) => frame.bounds), - ...(unassignedTaskRect ? [unassignedTaskRect] : []), ].filter(Boolean) ); @@ -180,6 +185,7 @@ export function buildStableSlotLayoutSnapshot({ }, leadCentralReservedBlock, runtimeCentralExclusion, + centralCollisionRects, memberSlotFrames, memberSlotFrameByOwnerId, unassignedTaskRect, @@ -187,6 +193,33 @@ export function buildStableSlotLayoutSnapshot({ }; } +function buildCentralCollisionRects(args: { + leadCoreRect: StableRect; + leadActivityRect: StableRect; + launchHudRect: StableRect; + unassignedTaskRect: StableRect | null; +}): StableRect[] { + const rects = [args.leadCoreRect, args.leadActivityRect, args.launchHudRect]; + if (args.unassignedTaskRect) { + rects.push(args.unassignedTaskRect); + } + return rects; +} + +function padCentralCollisionRects( + rects: readonly StableRect[], + padding: number +): StableRect[] { + return rects.map((rect) => padRect(rect, padding)); +} + +function rectOverlapsAnyCentralRect( + rect: StableRect, + centralCollisionRects: readonly StableRect[] +): boolean { + return centralCollisionRects.some((centralRect) => rectsOverlap(rect, centralRect)); +} + export function computeOwnerFootprints( nodes: GraphNode[], layout?: GraphLayoutPort @@ -347,6 +380,7 @@ export function resolveNearestSlotAssignment(args: { footprintByOwnerId, currentFrame, existingFrames, + centralCollisionRects: args.snapshot.centralCollisionRects, runtimeCentralExclusion: args.snapshot.runtimeCentralExclusion, ringStates, pointerX: args.ownerX, @@ -418,6 +452,9 @@ function validateStaticSnapshotRects( ['leadCentralReservedBlock', snapshot.leadCentralReservedBlock], ['runtimeCentralExclusion', snapshot.runtimeCentralExclusion], ['fitBounds', snapshot.fitBounds], + ...snapshot.centralCollisionRects.map( + (rect, index) => [`centralCollisionRects[${index}]`, rect] as [string, StableRect] + ), ]; if (snapshot.unassignedTaskRect) { @@ -452,11 +489,19 @@ function validateLeadSnapshotRects( if (!rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.leadCentralReservedBlock)) { return { valid: false, reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock' }; } + const paddedCentralCollisionRects = padCentralCollisionRects( + snapshot.centralCollisionRects, + SLOT_GEOMETRY.centralPadding + ); if ( - snapshot.unassignedTaskRect && - !rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.unassignedTaskRect) + paddedCentralCollisionRects.some( + (rect) => !rectContainsRect(snapshot.runtimeCentralExclusion, rect) + ) ) { - return { valid: false, reason: 'runtimeCentralExclusion must contain unassignedTaskRect' }; + return { + valid: false, + reason: 'runtimeCentralExclusion must contain all centralCollisionRects', + }; } return null; @@ -485,8 +530,11 @@ function validateMemberSlotFrame( } seenAssignments.add(assignmentKey); - if (rectsOverlap(frame.bounds, snapshot.runtimeCentralExclusion)) { - return { valid: false, reason: `slot frame for ${frame.ownerId} overlaps runtimeCentralExclusion` }; + if (rectOverlapsAnyCentralRect(frame.bounds, snapshot.centralCollisionRects)) { + return { + valid: false, + reason: `slot frame for ${frame.ownerId} overlaps centralCollisionRects`, + }; } if (!rectContainsRect(frame.bounds, frame.boardBandRect)) { return { valid: false, reason: `boardBandRect escapes slot bounds for ${frame.ownerId}` }; @@ -614,7 +662,8 @@ function buildUnassignedTaskRect( function planOwnerSlots( ownerFootprints: OwnerFootprint[], - centralExclusion: StableRect, + centralCollisionRects: readonly StableRect[], + runtimeCentralExclusion: StableRect, layout?: GraphLayoutPort ): SlotFrame[] { const placedFrames: SlotFrame[] = []; @@ -626,7 +675,8 @@ function planOwnerSlots( for (const footprint of ownerFootprints) { const resolvedFrame = resolveOwnerSlotFrame({ footprint, - centralExclusion, + centralCollisionRects, + runtimeCentralExclusion, ringStates, preferredAssignment: preferredAssignments.get(footprint.ownerId), usedSlotKeys, @@ -667,7 +717,8 @@ function buildPreferredAssignmentsMap( function resolveOwnerSlotFrame(args: { footprint: OwnerFootprint; - centralExclusion: StableRect; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; ringStates: RingLayoutStateMap; preferredAssignment?: GraphOwnerSlotAssignment; usedSlotKeys: Set; @@ -676,7 +727,8 @@ function resolveOwnerSlotFrame(args: { }): SlotFrame { const { footprint, - centralExclusion, + centralCollisionRects, + runtimeCentralExclusion, ringStates, preferredAssignment, usedSlotKeys, @@ -690,7 +742,8 @@ function resolveOwnerSlotFrame(args: { const directMatch = findFirstValidSlotFrame({ candidateAssignments: candidates, footprint, - centralExclusion, + centralCollisionRects, + runtimeCentralExclusion, ringStates, usedSlotKeys, placedFrames, @@ -706,7 +759,8 @@ function resolveOwnerSlotFrame(args: { const spilloverMatch = findFirstValidSlotFrame({ candidateAssignments: spilloverCandidates, footprint, - centralExclusion, + centralCollisionRects, + runtimeCentralExclusion, ringStates, usedSlotKeys, placedFrames, @@ -717,7 +771,8 @@ function resolveOwnerSlotFrame(args: { return buildEmergencyFallbackSlotFrame({ footprint, - centralExclusion, + centralCollisionRects, + runtimeCentralExclusion, ringStates, usedSlotKeys, placedOwnerCount: placedFrames.length, @@ -728,19 +783,29 @@ function resolveOwnerSlotFrame(args: { function buildSlotFrame( footprint: OwnerFootprint, assignment: GraphOwnerSlotAssignment, - centralExclusion: StableRect, + centralCollisionRects: readonly StableRect[], + runtimeCentralExclusion: StableRect, options: { ringStates: RingLayoutStateMap } ): SlotFrame | null { - const vector = SECTOR_VECTORS[assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0]; const radius = resolveRingRadiusForAssignment({ assignment, footprint, - centralExclusion, + centralCollisionRects, + runtimeCentralExclusion, ringStates: options.ringStates, }); if (radius == null) { return null; } + return buildSlotFrameAtRadius(footprint, assignment, radius); +} + +function buildSlotFrameAtRadius( + footprint: OwnerFootprint, + assignment: GraphOwnerSlotAssignment, + radius: number +): SlotFrame { + const vector = SECTOR_VECTORS[assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0]; const ownerX = vector.x * radius; const ownerY = vector.y * radius; const slotTop = @@ -844,7 +909,8 @@ function ownerFootprintsSpillBudget(placedOwnerCount: number): number { function buildEmergencyFallbackSlotFrame(args: { footprint: OwnerFootprint; - centralExclusion: StableRect; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; ringStates: RingLayoutStateMap; usedSlotKeys: Set; placedOwnerCount: number; @@ -855,9 +921,15 @@ function buildEmergencyFallbackSlotFrame(args: { sectorIndex: 0, }; args.usedSlotKeys.add(buildAssignmentKey(assignment)); - const frame = buildSlotFrame(args.footprint, assignment, args.centralExclusion, { - ringStates: args.ringStates, - }); + const frame = buildSlotFrame( + args.footprint, + assignment, + args.centralCollisionRects, + args.runtimeCentralExclusion, + { + ringStates: args.ringStates, + } + ); if (!frame) { throw new Error(`failed to build emergency fallback slot frame for ${args.footprint.ownerId}`); } @@ -871,6 +943,7 @@ function rankNearestSlotAssignmentResult(args: { footprintByOwnerId: ReadonlyMap; currentFrame: SlotFrame; existingFrames: readonly SlotFrame[]; + centralCollisionRects: readonly StableRect[]; runtimeCentralExclusion: StableRect; ringStates: RingLayoutStateMap; pointerX: number; @@ -883,14 +956,21 @@ function rankNearestSlotAssignmentResult(args: { footprintByOwnerId, currentFrame, existingFrames, + centralCollisionRects, runtimeCentralExclusion, ringStates, pointerX, pointerY, } = args; - const frame = buildSlotFrame(footprint, assignment, runtimeCentralExclusion, { - ringStates, - }); + const frame = buildSlotFrame( + footprint, + assignment, + centralCollisionRects, + runtimeCentralExclusion, + { + ringStates, + } + ); if (!frame) { return null; } @@ -900,6 +980,7 @@ function rankNearestSlotAssignmentResult(args: { occupiedFrame, footprintByOwnerId, currentFrame, + centralCollisionRects, runtimeCentralExclusion, ringStates, }); @@ -908,8 +989,8 @@ function rankNearestSlotAssignmentResult(args: { } const otherFrames = existingFrames.filter((existing) => existing.ownerId !== occupiedFrame.ownerId); if ( - !isSlotFramePlacementValid(frame, otherFrames, runtimeCentralExclusion) || - !isSlotFramePlacementValid(displacedFrame, otherFrames, runtimeCentralExclusion) || + !isSlotFramePlacementValid(frame, otherFrames, centralCollisionRects) || + !isSlotFramePlacementValid(displacedFrame, otherFrames, centralCollisionRects) || rectsOverlapWithGap(frame.bounds, displacedFrame.bounds, SLOT_GEOMETRY.ringPadding) ) { return null; @@ -927,7 +1008,7 @@ function rankNearestSlotAssignmentResult(args: { }); } - if (!isSlotFramePlacementValid(frame, existingFrames, runtimeCentralExclusion)) { + if (!isSlotFramePlacementValid(frame, existingFrames, centralCollisionRects)) { return null; } @@ -943,6 +1024,7 @@ function buildDisplacedFrameForNearestAssignment(args: { occupiedFrame: SlotFrame; footprintByOwnerId: ReadonlyMap; currentFrame: SlotFrame; + centralCollisionRects: readonly StableRect[]; runtimeCentralExclusion: StableRect; ringStates: RingLayoutStateMap; }): SlotFrame | null { @@ -956,6 +1038,7 @@ function buildDisplacedFrameForNearestAssignment(args: { ringIndex: args.currentFrame.ringIndex, sectorIndex: args.currentFrame.sectorIndex, }, + args.centralCollisionRects, args.runtimeCentralExclusion, { ringStates: args.ringStates } ); @@ -982,7 +1065,8 @@ function buildRankedNearestSlotAssignmentResult(args: { function findFirstValidSlotFrame(args: { candidateAssignments: readonly GraphOwnerSlotAssignment[]; footprint: OwnerFootprint; - centralExclusion: StableRect; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; ringStates: RingLayoutStateMap; usedSlotKeys: Set; placedFrames: readonly SlotFrame[]; @@ -1000,7 +1084,8 @@ function findFirstValidSlotFrame(args: { function tryBuildValidSlotFrame( args: { footprint: OwnerFootprint; - centralExclusion: StableRect; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; ringStates: RingLayoutStateMap; usedSlotKeys: Set; placedFrames: readonly SlotFrame[]; @@ -1012,13 +1097,19 @@ function tryBuildValidSlotFrame( if (args.usedSlotKeys.has(slotKey) && !isSameAssignment(args.preferredAssignment, assignment)) { return null; } - const frame = buildSlotFrame(args.footprint, assignment, args.centralExclusion, { - ringStates: args.ringStates, - }); + const frame = buildSlotFrame( + args.footprint, + assignment, + args.centralCollisionRects, + args.runtimeCentralExclusion, + { + ringStates: args.ringStates, + } + ); if (!frame) { return null; } - if (!isSlotFramePlacementValid(frame, args.placedFrames, args.centralExclusion)) { + if (!isSlotFramePlacementValid(frame, args.placedFrames, args.centralCollisionRects)) { return null; } args.usedSlotKeys.add(slotKey); @@ -1152,12 +1243,18 @@ function computeSlotDirectionalDepths( function resolveRingRadiusForAssignment(args: { assignment: GraphOwnerSlotAssignment; footprint: OwnerFootprint; - centralExclusion: StableRect; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; ringStates: RingLayoutStateMap; }): number | null { const vector = SECTOR_VECTORS[args.assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0]; - const minRadius = computeMinimumRingRadius(vector, args.footprint, args.centralExclusion); + const minRadius = resolveMinimumDirectionalRadius({ + assignment: args.assignment, + footprint: args.footprint, + centralCollisionRects: args.centralCollisionRects, + runtimeCentralExclusion: args.runtimeCentralExclusion, + }); const directionalDepths = computeSlotDirectionalDepths(args.footprint, vector); const ringState = resolveVirtualRingState( args.assignment.sectorIndex, @@ -1211,7 +1308,48 @@ function buildSectorRingStateKey(sectorIndex: number, ringIndex: number): string return `${sectorIndex}:${ringIndex}`; } -function computeMinimumRingRadius( +function resolveMinimumDirectionalRadius(args: { + assignment: GraphOwnerSlotAssignment; + footprint: OwnerFootprint; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; +}): number { + const legacyRadiusHint = computeLegacyMinimumRingRadius( + SECTOR_VECTORS[args.assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0], + args.footprint, + args.runtimeCentralExclusion + ); + const overlapsCentralCollision = (radius: number): boolean => { + const frame = buildSlotFrameAtRadius(args.footprint, args.assignment, radius); + return rectOverlapsAnyCentralRect(frame.bounds, args.centralCollisionRects); + }; + + if (!overlapsCentralCollision(0)) { + return 0; + } + + let low = 0; + let high = Math.max(legacyRadiusHint, SLOT_GEOMETRY.ringGap); + let expansionCount = 0; + while (overlapsCentralCollision(high) && expansionCount < 24) { + low = high; + high = Math.max(high * 2, high + SLOT_GEOMETRY.ringGap); + expansionCount += 1; + } + + for (let iteration = 0; iteration < 24; iteration += 1) { + const mid = (low + high) / 2; + if (overlapsCentralCollision(mid)) { + low = mid; + } else { + high = mid; + } + } + + return Math.ceil(high); +} + +function computeLegacyMinimumRingRadius( vector: { x: number; y: number }, footprint: OwnerFootprint, centralExclusion: StableRect @@ -1253,15 +1391,20 @@ function rectsOverlap(a: StableRect, b: StableRect): boolean { function rectContainsRect(outer: StableRect, inner: StableRect): boolean { return ( - inner.left >= outer.left && - inner.right <= outer.right && - inner.top >= outer.top && - inner.bottom <= outer.bottom + inner.left >= outer.left - GEOMETRY_EPSILON && + inner.right <= outer.right + GEOMETRY_EPSILON && + inner.top >= outer.top - GEOMETRY_EPSILON && + inner.bottom <= outer.bottom + GEOMETRY_EPSILON ); } function pointInRect(x: number, y: number, rect: StableRect): boolean { - return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; + return ( + x >= rect.left - GEOMETRY_EPSILON && + x <= rect.right + GEOMETRY_EPSILON && + y >= rect.top - GEOMETRY_EPSILON && + y <= rect.bottom + GEOMETRY_EPSILON + ); } function isFiniteRect(rect: StableRect): boolean { @@ -1278,12 +1421,12 @@ function isFiniteRect(rect: StableRect): boolean { function isSlotFramePlacementValid( frame: SlotFrame, existingFrames: readonly SlotFrame[], - runtimeCentralExclusion: StableRect + centralCollisionRects: readonly StableRect[] ): boolean { if (!isFiniteRect(frame.bounds)) { return false; } - if (rectsOverlap(frame.bounds, runtimeCentralExclusion)) { + if (rectOverlapsAnyCentralRect(frame.bounds, centralCollisionRects)) { return false; } return !existingFrames.some((existing) => diff --git a/test/renderer/features/agent-graph/useGraphSimulation.test.ts b/test/renderer/features/agent-graph/useGraphSimulation.test.ts index 96e084ed..7622f3a6 100644 --- a/test/renderer/features/agent-graph/useGraphSimulation.test.ts +++ b/test/renderer/features/agent-graph/useGraphSimulation.test.ts @@ -6,12 +6,16 @@ import { computeProcessBandWidth, resolveNearestSlotAssignment, snapshotToWorldBounds, + translateSlotFrame, validateStableSlotLayout, } from '../../../../packages/agent-graph/src/layout/stableSlots'; import { KanbanLayoutEngine } from '../../../../packages/agent-graph/src/layout/kanbanLayout'; import { TASK_PILL } from '../../../../packages/agent-graph/src/constants/canvas-constants'; import { ACTIVITY_LANE } from '../../../../packages/agent-graph/src/layout/activityLane'; -import { STABLE_SLOT_GEOMETRY } from '../../../../packages/agent-graph/src/layout/stableSlotGeometry'; +import { + STABLE_SLOT_GEOMETRY, + STABLE_SLOT_SECTOR_VECTORS, +} from '../../../../packages/agent-graph/src/layout/stableSlotGeometry'; import type { GraphLayoutPort, GraphNode } from '@claude-teams/agent-graph'; @@ -65,6 +69,18 @@ function createProcess(teamName: string, processId: string, ownerId: string): Gr }; } +function rectsOverlap( + left: { left: number; right: number; top: number; bottom: number }, + right: { left: number; right: number; top: number; bottom: number } +): boolean { + return ( + left.left < right.right && + left.right > right.left && + left.top < right.bottom && + left.bottom > right.top + ); +} + describe('stable slot layout planner', () => { it('does not build a stable slot snapshot when the lead is missing', () => { const snapshot = buildStableSlotLayoutSnapshot({ @@ -164,6 +180,48 @@ describe('stable slot layout planner', () => { ); }); + it('keeps diagonal ring-zero sectors closer than the legacy coarse central box radius', () => { + const teamName = 'team-directional-radius'; + const lead = createLead(teamName); + const alice = createMember(teamName, 'agent-alice', 'alice'); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [alice.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 1 }, + }, + }; + + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, alice], + layout, + }); + const [footprint] = computeOwnerFootprints([lead, alice], layout); + const frame = snapshot?.memberSlotFrames[0]; + const sectorVector = STABLE_SLOT_SECTOR_VECTORS[1]; + + expect(snapshot).not.toBeNull(); + expect(frame).toBeDefined(); + expect(footprint).toBeDefined(); + + const legacyHorizontalExtent = snapshot!.runtimeCentralExclusion.right; + const legacyVerticalExtent = Math.abs(snapshot!.runtimeCentralExclusion.top); + const legacyRequiredX = + (legacyHorizontalExtent + footprint!.slotWidth / 2 + STABLE_SLOT_GEOMETRY.ringPadding) / + Math.abs(sectorVector.x); + const legacyRequiredY = + (legacyVerticalExtent + footprint!.slotHeight / 2 + STABLE_SLOT_GEOMETRY.ringPadding) / + Math.abs(sectorVector.y); + const legacyMinRadius = Math.max(legacyRequiredX, legacyRequiredY, 0); + const actualRadius = Math.abs(frame!.ownerX / sectorVector.x); + + expect(actualRadius).toBeLessThan(legacyMinRadius); + expect( + snapshot!.centralCollisionRects.some((rect) => rectsOverlap(frame!.bounds, rect)) + ).toBe(false); + }); + it('grows process band width when an owner has multiple visible process nodes', () => { const teamName = 'team-process-growth'; const lead = createLead(teamName); @@ -251,6 +309,51 @@ describe('stable slot layout planner', () => { expect(validateStableSlotLayout(invalid).valid).toBe(false); }); + it('rejects member frames that overlap lead activity and launch central collision rects', () => { + const teamName = 'team-central-rects'; + const lead = createLead(teamName); + const alice = createMember(teamName, 'agent-alice', 'alice'); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [alice.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 1 }, + }, + }; + + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, alice], + layout, + }); + + expect(snapshot).not.toBeNull(); + const [frame] = snapshot!.memberSlotFrames; + const overlappingLeadActivity = translateSlotFrame( + frame, + snapshot!.leadActivityRect.left - frame.bounds.left + 1, + snapshot!.leadActivityRect.top - frame.bounds.top + 1 + ); + const overlappingLaunchHud = translateSlotFrame( + frame, + snapshot!.launchHudRect.left - frame.bounds.left + 1, + snapshot!.launchHudRect.top - frame.bounds.top + 1 + ); + + expect( + validateStableSlotLayout({ + ...snapshot!, + memberSlotFrames: [overlappingLeadActivity], + }).valid + ).toBe(false); + expect( + validateStableSlotLayout({ + ...snapshot!, + memberSlotFrames: [overlappingLaunchHud], + }).valid + ).toBe(false); + }); + it('prefers the occupied target slot when dragging near another owner anchor', () => { const teamName = 'team-b'; const lead = createLead(teamName); @@ -290,6 +393,64 @@ describe('stable slot layout planner', () => { expect(nearest?.displacedAssignment).toEqual({ ringIndex: 0, sectorIndex: 1 }); }); + it('keeps nearest-slot drag resolution on the same central collision model as the planner', () => { + const teamName = 'team-drag-central-collision'; + const lead = createLead(teamName); + const alice = createMember(teamName, 'agent-alice', 'alice'); + const bob = createMember(teamName, 'agent-bob', 'bob'); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [alice.id, bob.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 1 }, + [bob.id]: { ringIndex: 0, sectorIndex: 2 }, + }, + }; + + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, alice, bob], + layout, + }); + + expect(snapshot).not.toBeNull(); + const nearest = resolveNearestSlotAssignment({ + ownerId: alice.id, + ownerX: snapshot!.leadActivityRect.left + snapshot!.leadActivityRect.width / 2, + ownerY: snapshot!.leadActivityRect.top + snapshot!.leadActivityRect.height / 2, + nodes: [lead, alice, bob], + snapshot: snapshot!, + layout, + }); + + expect(nearest).not.toBeNull(); + const replannedSnapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, alice, bob], + layout: { + ...layout, + slotAssignments: { + ...layout.slotAssignments, + [alice.id]: nearest!.assignment, + ...(nearest?.displacedOwnerId && nearest.displacedAssignment + ? { [nearest.displacedOwnerId]: nearest.displacedAssignment } + : {}), + }, + }, + }); + const replannedFrame = replannedSnapshot?.memberSlotFrames.find( + (frame) => frame.ownerId === alice.id + ); + + expect(replannedSnapshot).not.toBeNull(); + expect(replannedFrame).toBeDefined(); + expect( + replannedSnapshot!.centralCollisionRects.some((rect) => + rectsOverlap(replannedFrame!.bounds, rect) + ) + ).toBe(false); + }); + it('treats tasks with missing owner nodes as unassigned topology actors', () => { const teamName = 'team-orphan-task'; const lead = createLead(teamName); @@ -313,6 +474,45 @@ describe('stable slot layout planner', () => { expect(snapshot?.unassignedTaskRect).not.toBeNull(); }); + it('rejects member frames that overlap the unassigned central collision rect', () => { + const teamName = 'team-unassigned-central-rect'; + const lead = createLead(teamName); + const alice = createMember(teamName, 'agent-alice', 'alice'); + const orphanTask = createTask( + teamName, + 'task-orphan', + 'member:team-unassigned-central-rect:ghost' + ); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [alice.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 1 }, + }, + }; + + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, alice, orphanTask], + layout, + }); + + expect(snapshot?.unassignedTaskRect).not.toBeNull(); + const [frame] = snapshot!.memberSlotFrames; + const overlappingUnassigned = translateSlotFrame( + frame, + snapshot!.unassignedTaskRect!.left - frame.bounds.left + 1, + snapshot!.unassignedTaskRect!.top - frame.bounds.top + 1 + ); + + expect( + validateStableSlotLayout({ + ...snapshot!, + memberSlotFrames: [overlappingUnassigned], + }).valid + ).toBe(false); + }); + it('computes the next ring radius from previous ring depth, not member count', () => { const teamName = 'team-ring-depth'; const lead = createLead(teamName); From c303a236a5107c010e80afb285762aa039142c4e Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 11:26:30 +0300 Subject: [PATCH 4/6] feat(agent-graph): unify lead slot layout defaults --- .../src/hooks/useGraphSimulation.ts | 21 +- .../agent-graph/src/layout/kanbanLayout.ts | 27 +- .../agent-graph/src/layout/stableSlots.ts | 256 ++++++++++------ packages/agent-graph/src/ui/GraphControls.tsx | 280 +++++++++--------- packages/agent-graph/src/ui/GraphView.tsx | 3 + .../renderer/ui/GraphProvisioningHud.tsx | 126 ++------ .../renderer/ui/TeamGraphOverlay.tsx | 12 +- .../agent-graph/renderer/ui/TeamGraphTab.tsx | 15 +- src/renderer/store/slices/teamSlice.ts | 60 +++- .../agent-graph/GraphProvisioningHud.test.ts | 10 +- .../agent-graph/useGraphSimulation.test.ts | 75 +++-- test/renderer/store/teamSlice.test.ts | 48 ++- 12 files changed, 523 insertions(+), 410 deletions(-) diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index e1eed487..5da1294c 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -251,14 +251,15 @@ function applySnapshotToNodes( const translatedFrameByOwnerId = new Map( translatedFrames.map((frame) => [frame.ownerId, frame] as const) ); + const leadFrame = snapshot.leadSlotFrame; const leadId = snapshot.leadNodeId; for (const node of nodes) { if (node.kind === 'lead' && node.id === leadId) { - node.x = 0; - node.y = 0; - node.fx = 0; - node.fy = 0; + node.x = leadFrame.ownerX; + node.y = leadFrame.ownerY; + node.fx = leadFrame.ownerX; + node.fy = leadFrame.ownerY; node.vx = 0; node.vy = 0; continue; @@ -278,9 +279,10 @@ function applySnapshotToNodes( } } - positionProcessNodes(nodes, translatedFrames); + positionProcessNodes(nodes, [snapshot.leadSlotFrame, ...translatedFrames]); KanbanLayoutEngine.layout(nodes, { memberSlotFrames: translatedFrames, + leadSlotFrame: snapshot.leadSlotFrame, unassignedTaskRect: snapshot.unassignedTaskRect, }); positionCrossTeamNodes(nodes, snapshot.fitBounds); @@ -322,16 +324,15 @@ function commitSnapshotGeometry(args: { activityRectByNodeIdRef.current.clear(); extraWorldBoundsRef.current = snapshotToWorldBounds(snapshot); - if (snapshot.leadNodeId && snapshot.launchAnchor) { - launchAnchorPositionsRef.current.set(snapshot.leadNodeId, snapshot.launchAnchor); - } - for (const frame of getTranslatedMemberFrames(snapshot, dragOwnerPositionsRef.current)) { activityRectByNodeIdRef.current.set(frame.ownerId, frame.activityColumnRect); } if (snapshot.leadNodeId) { - activityRectByNodeIdRef.current.set(snapshot.leadNodeId, snapshot.leadActivityRect); + activityRectByNodeIdRef.current.set( + snapshot.leadNodeId, + snapshot.leadSlotFrame.activityColumnRect + ); } } diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index ce0fb704..4c1475ad 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -10,7 +10,6 @@ import type { GraphNode } from '../ports/types'; import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants'; import { COLORS } from '../constants/colors'; -import { resolveActivityLaneSide } from './activityLane'; import type { SlotFrame, StableRect } from './stableSlots'; /** Column header info for rendering */ @@ -49,7 +48,7 @@ export function getOwnerKanbanBaseX(args: { columnWidth: number; leadX?: number | null; }): number { - const { ownerX, ownerKind, activeColumnCount, columnWidth, leadX } = args; + const { ownerX, ownerKind, activeColumnCount, columnWidth } = args; if (activeColumnCount <= 0) { return ownerX; } @@ -58,17 +57,7 @@ export function getOwnerKanbanBaseX(args: { return ownerX - (activeColumnCount * columnWidth) / 2; } - const side = resolveActivityLaneSide({ - nodeKind: ownerKind, - nodeX: ownerX, - leadX, - }); - - if (side === 'left') { - return ownerX; - } - - return ownerX - (activeColumnCount - 1) * columnWidth; + return ownerX - ((activeColumnCount - 1) * columnWidth) / 2; } export class KanbanLayoutEngine { @@ -89,6 +78,7 @@ export class KanbanLayoutEngine { nodes: GraphNode[], options?: { memberSlotFrames?: readonly SlotFrame[]; + leadSlotFrame?: SlotFrame | null; unassignedTaskRect?: StableRect | null; } ): void { @@ -96,9 +86,12 @@ export class KanbanLayoutEngine { nodeMap.clear(); for (const n of nodes) nodeMap.set(n.id, n); const leadX = nodes.find((node) => node.kind === 'lead')?.x ?? null; - const memberSlotFrameByOwnerId = new Map( + const ownerSlotFrameByOwnerId = new Map( (options?.memberSlotFrames ?? []).map((frame) => [frame.ownerId, frame] as const) ); + if (options?.leadSlotFrame) { + ownerSlotFrameByOwnerId.set(options.leadSlotFrame.ownerId, options.leadSlotFrame); + } const tasksByOwner = this.#tasksByOwner; tasksByOwner.clear(); @@ -110,10 +103,10 @@ export class KanbanLayoutEngine { return false; } if (owner.kind === 'lead') { - return true; + return ownerSlotFrameByOwnerId.has(ownerId); } if (owner.kind === 'member') { - return memberSlotFrameByOwnerId.has(ownerId); + return ownerSlotFrameByOwnerId.has(ownerId); } return false; }; @@ -143,7 +136,7 @@ export class KanbanLayoutEngine { owner, ownerId, leadX, - memberSlotFrameByOwnerId.get(ownerId) ?? null + ownerSlotFrameByOwnerId.get(ownerId) ?? null ); if (zoneInfo) this.zones.push(zoneInfo); } diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts index e606c3cd..76b9f110 100644 --- a/packages/agent-graph/src/layout/stableSlots.ts +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -1,7 +1,7 @@ import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants'; import type { GraphLayoutPort, GraphNode, GraphOwnerSlotAssignment } from '../ports/types'; import { ACTIVITY_LANE } from './activityLane'; -import { LAUNCH_ANCHOR_LAYOUT, type WorldBounds } from './launchAnchor'; +import type { WorldBounds } from './launchAnchor'; import { STABLE_SLOT_GEOMETRY, STABLE_SLOT_SECTOR_VECTORS, @@ -55,6 +55,7 @@ export interface StableSlotLayoutSnapshot { teamName: string; leadNodeId: string | null; leadCoreRect: StableRect; + leadSlotFrame: SlotFrame; leadActivityRect: StableRect; launchHudRect: StableRect; launchAnchor: { x: number; y: number } | null; @@ -128,27 +129,21 @@ export function buildStableSlotLayoutSnapshot({ return null; } - const leadCoreRect = createCenteredRect(0, 0, 200, 168); - const leadActivityRect = createRect( - leadCoreRect.left - SLOT_GEOMETRY.centralBlockGap - ACTIVITY_LANE.width, - -SLOT_GEOMETRY.activityColumnHeight / 2, - ACTIVITY_LANE.width, - SLOT_GEOMETRY.activityColumnHeight + const leadCoreRect = createCenteredRect(0, 0, 200, 96); + const leadFootprint = computeOwnerFootprintForOwnerId(nodes, leadNode.id); + const leadSlotFrame = buildSlotFrameAtRadius( + leadFootprint, + { ringIndex: 0, sectorIndex: 0 }, + 0 ); - const launchHudRect = createRect( - leadCoreRect.right + SLOT_GEOMETRY.centralBlockGap, - -LAUNCH_ANCHOR_LAYOUT.compactHeight / 2, - LAUNCH_ANCHOR_LAYOUT.compactWidth, - LAUNCH_ANCHOR_LAYOUT.compactHeight - ); - const leadCentralReservedBlock = unionRects([leadCoreRect, leadActivityRect, launchHudRect]); + const leadActivityRect = leadSlotFrame.activityColumnRect; + const launchHudRect = createRect(leadCoreRect.right, leadCoreRect.top, 0, 0); + const leadCentralReservedBlock = leadSlotFrame.bounds; const ownerFootprints = computeOwnerFootprints(nodes, layout); const unassignedTaskRect = buildUnassignedTaskRect(nodes, leadCentralReservedBlock); const centralCollisionRects = buildCentralCollisionRects({ - leadCoreRect, - leadActivityRect, - launchHudRect, + leadCentralReservedBlock, unassignedTaskRect, }); const runtimeCentralExclusion = padRect( @@ -177,12 +172,10 @@ export function buildStableSlotLayoutSnapshot({ teamName, leadNodeId: leadNode.id, leadCoreRect, + leadSlotFrame, leadActivityRect, launchHudRect, - launchAnchor: { - x: launchHudRect.left + launchHudRect.width / 2, - y: launchHudRect.top + launchHudRect.height / 2, - }, + launchAnchor: null, leadCentralReservedBlock, runtimeCentralExclusion, centralCollisionRects, @@ -194,12 +187,10 @@ export function buildStableSlotLayoutSnapshot({ } function buildCentralCollisionRects(args: { - leadCoreRect: StableRect; - leadActivityRect: StableRect; - launchHudRect: StableRect; + leadCentralReservedBlock: StableRect; unassignedTaskRect: StableRect | null; }): StableRect[] { - const rects = [args.leadCoreRect, args.leadActivityRect, args.launchHudRect]; + const rects = [args.leadCentralReservedBlock]; if (args.unassignedTaskRect) { rects.push(args.unassignedTaskRect); } @@ -253,64 +244,96 @@ export function computeOwnerFootprints( return []; } - const taskColumnCount = taskColumnsByOwnerId.get(ownerId)?.size ?? 0; - const kanbanBandWidth = - taskColumnCount <= 1 - ? TASK_PILL.width - : TASK_PILL.width + (taskColumnCount - 1) * KANBAN_ZONE.columnWidth; - const processCount = processCountByOwnerId.get(ownerId) ?? 0; - const processBandWidth = computeProcessBandWidth(processCount); - const boardBandWidth = - SLOT_GEOMETRY.activityColumnWidth + - SLOT_GEOMETRY.boardColumnGap + - kanbanBandWidth; - const boardBandHeight = Math.max( - SLOT_GEOMETRY.activityColumnHeight, - SLOT_GEOMETRY.kanbanBandHeight - ); - const innerContentWidth = Math.max( - SLOT_GEOMETRY.ownerMinWidth, - processBandWidth, - boardBandWidth - ); - const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2; - const slotHeight = - SLOT_GEOMETRY.memberSlotInnerPadding * 2 + - SLOT_GEOMETRY.ownerBandHeight + + return [ + buildOwnerFootprint({ + ownerId, + taskColumnCount: taskColumnsByOwnerId.get(ownerId)?.size ?? 0, + processCount: processCountByOwnerId.get(ownerId) ?? 0, + }), + ]; + }); +} + +function computeOwnerFootprintForOwnerId( + nodes: readonly GraphNode[], + ownerId: string +): OwnerFootprint { + const taskColumns = new Set(); + let processCount = 0; + + for (const node of nodes) { + if (node.kind === 'task' && node.ownerId === ownerId) { + taskColumns.add(resolveTaskColumnKey(node)); + } + if (node.kind === 'process' && node.ownerId === ownerId) { + processCount += 1; + } + } + + return buildOwnerFootprint({ + ownerId, + taskColumnCount: taskColumns.size, + processCount, + }); +} + +function buildOwnerFootprint(args: { + ownerId: string; + taskColumnCount: number; + processCount: number; +}): OwnerFootprint { + const kanbanBandWidth = + args.taskColumnCount <= 1 + ? TASK_PILL.width + : TASK_PILL.width + (args.taskColumnCount - 1) * KANBAN_ZONE.columnWidth; + const processBandWidth = computeProcessBandWidth(args.processCount); + const boardBandWidth = + SLOT_GEOMETRY.activityColumnWidth + + SLOT_GEOMETRY.boardColumnGap + + kanbanBandWidth; + const boardBandHeight = Math.max( + SLOT_GEOMETRY.activityColumnHeight, + SLOT_GEOMETRY.kanbanBandHeight + ); + const innerContentWidth = Math.max( + SLOT_GEOMETRY.ownerMinWidth, + processBandWidth, + boardBandWidth + ); + const slotWidth = innerContentWidth + SLOT_GEOMETRY.memberSlotInnerPadding * 2; + const slotHeight = + SLOT_GEOMETRY.memberSlotInnerPadding * 2 + + SLOT_GEOMETRY.ownerBandHeight + + SLOT_GEOMETRY.ownerToProcessGap + + SLOT_GEOMETRY.processBandHeight + + SLOT_GEOMETRY.processToBoardGap + + boardBandHeight; + const radialDepth = Math.max( + SLOT_GEOMETRY.memberSlotInnerPadding + SLOT_GEOMETRY.ownerBandHeight / 2, + SLOT_GEOMETRY.memberSlotInnerPadding + + SLOT_GEOMETRY.ownerBandHeight / 2 + SLOT_GEOMETRY.ownerToProcessGap + SLOT_GEOMETRY.processBandHeight + SLOT_GEOMETRY.processToBoardGap + - boardBandHeight; - const radialDepth = Math.max( - SLOT_GEOMETRY.memberSlotInnerPadding + - SLOT_GEOMETRY.ownerBandHeight / 2, - SLOT_GEOMETRY.memberSlotInnerPadding + - SLOT_GEOMETRY.ownerBandHeight / 2 + - SLOT_GEOMETRY.ownerToProcessGap + - SLOT_GEOMETRY.processBandHeight + - SLOT_GEOMETRY.processToBoardGap + - boardBandHeight - ); + boardBandHeight + ); - return [ - { - ownerId, - slotWidth, - slotHeight, - widthBucket: classifyWidthBucket(slotWidth), - radialDepth, - activityColumnWidth: SLOT_GEOMETRY.activityColumnWidth, - activityColumnHeight: SLOT_GEOMETRY.activityColumnHeight, - processBandWidth, - kanbanBandWidth, - kanbanBandHeight: SLOT_GEOMETRY.kanbanBandHeight, - boardBandWidth, - boardBandHeight, - taskColumnCount, - processCount, - } satisfies OwnerFootprint, - ]; - }); + return { + ownerId: args.ownerId, + slotWidth, + slotHeight, + widthBucket: classifyWidthBucket(slotWidth), + radialDepth, + activityColumnWidth: SLOT_GEOMETRY.activityColumnWidth, + activityColumnHeight: SLOT_GEOMETRY.activityColumnHeight, + processBandWidth, + kanbanBandWidth, + kanbanBandHeight: SLOT_GEOMETRY.kanbanBandHeight, + boardBandWidth, + boardBandHeight, + taskColumnCount: args.taskColumnCount, + processCount: args.processCount, + } satisfies OwnerFootprint; } export function classifyWidthBucket(width: number): StableSlotWidthBucket { @@ -447,6 +470,11 @@ function validateStaticSnapshotRects( ): StableSlotLayoutValidationResult | null { const staticRects: [string, StableRect][] = [ ['leadCoreRect', snapshot.leadCoreRect], + ['leadSlotFrame.bounds', snapshot.leadSlotFrame.bounds], + ['leadSlotFrame.boardBandRect', snapshot.leadSlotFrame.boardBandRect], + ['leadSlotFrame.activityColumnRect', snapshot.leadSlotFrame.activityColumnRect], + ['leadSlotFrame.processBandRect', snapshot.leadSlotFrame.processBandRect], + ['leadSlotFrame.kanbanBandRect', snapshot.leadSlotFrame.kanbanBandRect], ['leadActivityRect', snapshot.leadActivityRect], ['launchHudRect', snapshot.launchHudRect], ['leadCentralReservedBlock', snapshot.leadCentralReservedBlock], @@ -477,14 +505,34 @@ function validateStaticSnapshotRects( function validateLeadSnapshotRects( snapshot: StableSlotLayoutSnapshot ): StableSlotLayoutValidationResult | null { + const leadFrameValidation = validateSlotFrameGeometry( + snapshot.leadSlotFrame, + snapshot.fitBounds, + `leadSlotFrame(${snapshot.leadSlotFrame.ownerId})` + ); + if (leadFrameValidation) { + return leadFrameValidation; + } if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadCoreRect)) { return { valid: false, reason: 'leadCoreRect must fit inside leadCentralReservedBlock' }; } if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadActivityRect)) { return { valid: false, reason: 'leadActivityRect must fit inside leadCentralReservedBlock' }; } - if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.launchHudRect)) { - return { valid: false, reason: 'launchHudRect must fit inside leadCentralReservedBlock' }; + if (snapshot.leadActivityRect.left !== snapshot.leadSlotFrame.activityColumnRect.left) { + return { + valid: false, + reason: 'leadActivityRect must mirror leadSlotFrame.activityColumnRect', + }; + } + if (snapshot.leadActivityRect.top !== snapshot.leadSlotFrame.activityColumnRect.top) { + return { + valid: false, + reason: 'leadActivityRect must mirror leadSlotFrame.activityColumnRect', + }; + } + if (!rectContainsRect(snapshot.leadCentralReservedBlock, snapshot.leadSlotFrame.bounds)) { + return { valid: false, reason: 'leadSlotFrame must fit inside leadCentralReservedBlock' }; } if (!rectContainsRect(snapshot.runtimeCentralExclusion, snapshot.leadCentralReservedBlock)) { return { valid: false, reason: 'runtimeCentralExclusion must contain leadCentralReservedBlock' }; @@ -513,11 +561,13 @@ function validateMemberSlotFrame( seenOwnerIds: Set, seenAssignments: Set ): StableSlotLayoutValidationResult | null { - if (!isFiniteRect(frame.bounds)) { - return { valid: false, reason: `slot frame for ${frame.ownerId} contains non-finite bounds` }; - } - if (!Number.isFinite(frame.ownerX) || !Number.isFinite(frame.ownerY)) { - return { valid: false, reason: `slot frame for ${frame.ownerId} contains non-finite anchor` }; + const geometryValidation = validateSlotFrameGeometry( + frame, + snapshot.fitBounds, + `slot frame for ${frame.ownerId}` + ); + if (geometryValidation) { + return geometryValidation; } if (seenOwnerIds.has(frame.ownerId)) { return { valid: false, reason: `duplicate owner frame for ${frame.ownerId}` }; @@ -536,41 +586,55 @@ function validateMemberSlotFrame( reason: `slot frame for ${frame.ownerId} overlaps centralCollisionRects`, }; } + return null; +} + +function validateSlotFrameGeometry( + frame: SlotFrame, + fitBounds: StableRect, + label: string +): StableSlotLayoutValidationResult | null { + if (!isFiniteRect(frame.bounds)) { + return { valid: false, reason: `${label} contains non-finite bounds` }; + } + if (!Number.isFinite(frame.ownerX) || !Number.isFinite(frame.ownerY)) { + return { valid: false, reason: `${label} contains non-finite anchor` }; + } if (!rectContainsRect(frame.bounds, frame.boardBandRect)) { - return { valid: false, reason: `boardBandRect escapes slot bounds for ${frame.ownerId}` }; + return { valid: false, reason: `boardBandRect escapes ${label}` }; } if (!rectContainsRect(frame.bounds, frame.activityColumnRect)) { - return { valid: false, reason: `activityColumnRect escapes slot bounds for ${frame.ownerId}` }; + return { valid: false, reason: `activityColumnRect escapes ${label}` }; } if (!rectContainsRect(frame.bounds, frame.processBandRect)) { - return { valid: false, reason: `processBandRect escapes slot bounds for ${frame.ownerId}` }; + return { valid: false, reason: `processBandRect escapes ${label}` }; } if (!rectContainsRect(frame.bounds, frame.kanbanBandRect)) { - return { valid: false, reason: `kanbanBandRect escapes slot bounds for ${frame.ownerId}` }; + return { valid: false, reason: `kanbanBandRect escapes ${label}` }; } if (!rectContainsRect(frame.boardBandRect, frame.activityColumnRect)) { return { valid: false, - reason: `activityColumnRect escapes boardBandRect for ${frame.ownerId}`, + reason: `activityColumnRect escapes boardBandRect in ${label}`, }; } if (!rectContainsRect(frame.boardBandRect, frame.kanbanBandRect)) { return { valid: false, - reason: `kanbanBandRect escapes boardBandRect for ${frame.ownerId}`, + reason: `kanbanBandRect escapes boardBandRect in ${label}`, }; } if (rectsOverlap(frame.activityColumnRect, frame.kanbanBandRect)) { return { valid: false, - reason: `activityColumnRect overlaps kanbanBandRect for ${frame.ownerId}`, + reason: `activityColumnRect overlaps kanbanBandRect in ${label}`, }; } if (!pointInRect(frame.ownerX, frame.ownerY, frame.bounds)) { - return { valid: false, reason: `owner anchor escapes slot bounds for ${frame.ownerId}` }; + return { valid: false, reason: `owner anchor escapes ${label}` }; } - if (!rectContainsRect(snapshot.fitBounds, frame.bounds)) { - return { valid: false, reason: `slot frame for ${frame.ownerId} escapes fitBounds` }; + if (!rectContainsRect(fitBounds, frame.bounds)) { + return { valid: false, reason: `${label} escapes fitBounds` }; } return null; diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index 4d0318f4..e933c9c1 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -48,6 +48,7 @@ export interface GraphControlsProps { teamName: string; teamColor?: string; isAlive?: boolean; + topToolbarContent?: React.ReactNode; } const TOPBAR_BUTTON_SIZE = 25; @@ -67,6 +68,7 @@ export function GraphControls({ onToggleSidebar, isSidebarVisible = true, teamColor, + topToolbarContent, }: GraphControlsProps): React.JSX.Element { const [isSettingsOpen, setIsSettingsOpen] = useState(false); const settingsRef = useRef(null); @@ -105,160 +107,170 @@ export function GraphControls({ return ( <> -
- {onToggleSidebar ? ( -
- - ) : ( - - ) - } - toolbar - title={isSidebarVisible ? 'Hide sidebar' : 'Show sidebar'} - /> -
- ) : null} - {onOpenTeamPage ? ( -
- } - toolbar - title="Open team page" - /> -
- ) : null} - {onCreateTask ? ( -
- } - toolbar - title="Create task" - /> -
- ) : null} -
- -
-
- toggle('paused')} - icon={filters.paused ? : } - toolbar - title={filters.paused ? 'Resume animation' : 'Pause animation'} - /> +
+
+ {onToggleSidebar ? ( +
+ + ) : ( + + ) + } + toolbar + title={isSidebarVisible ? 'Hide sidebar' : 'Show sidebar'} + /> +
+ ) : null} + {onOpenTeamPage ? ( +
+ } + toolbar + title="Open team page" + /> +
+ ) : null} + {onCreateTask ? ( +
+ } + toolbar + title="Create task" + /> +
+ ) : null}
-
+
+ {topToolbarContent ? ( +
+ {topToolbarContent} +
+ ) : null} +
+ +
setIsSettingsOpen((value) => !value)} - icon={} - active={isSettingsOpen} + onClick={() => toggle('paused')} + icon={filters.paused ? : } toolbar - title="Graph settings" + title={filters.paused ? 'Resume animation' : 'Pause animation'} />
- {isSettingsOpen && ( +
- toggle('showTasks')} - icon={} - label="Tasks" - block - /> - toggle('showProcesses')} - icon={} - label="Processes" - block - /> - toggle('showEdges')} - icon={filters.showEdges ? : } - label="Edges" - block + setIsSettingsOpen((value) => !value)} + icon={} + active={isSettingsOpen} + toolbar + title="Graph settings" />
- )} -
-
- {onRequestPinAsTab && ( - } - toolbar - title="Pin as tab" - /> - )} - {onRequestFullscreen && ( - } - toolbar - title="Fullscreen" - /> - )} - {onRequestClose && ( - } - toolbar - title="Close graph" - /> - )} + {isSettingsOpen && ( +
+ toggle('showTasks')} + icon={} + label="Tasks" + block + /> + toggle('showProcesses')} + icon={} + label="Processes" + block + /> + toggle('showEdges')} + icon={filters.showEdges ? : } + label="Edges" + block + /> +
+ )} +
+ +
+ {onRequestPinAsTab && ( + } + toolbar + title="Pin as tab" + /> + )} + {onRequestFullscreen && ( + } + toolbar + title="Fullscreen" + /> + )} + {onRequestClose && ( + } + toolbar + title="Close graph" + /> + )} +
diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index 052c1465..7daf3e57 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -47,6 +47,7 @@ export interface GraphViewProps { onCreateTask?: () => void; onToggleSidebar?: () => void; isSidebarVisible?: boolean; + renderTopToolbarContent?: () => React.ReactNode; onOwnerSlotDrop?: (payload: { nodeId: string; assignment: GraphOwnerSlotAssignment; @@ -92,6 +93,7 @@ export function GraphView({ onCreateTask, onToggleSidebar, isSidebarVisible = true, + renderTopToolbarContent, onOwnerSlotDrop, renderOverlay, renderEdgeOverlay, @@ -748,6 +750,7 @@ export function GraphView({ teamName={data.teamName} teamColor={data.teamColor} isAlive={data.isAlive} + topToolbarContent={renderTopToolbarContent?.()} /> {renderHud ? ( diff --git a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx index 4f2a90c7..26f6f3ff 100644 --- a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { DISPLAY_STEPS } from '@renderer/components/team/provisioningSteps'; import { StepProgressBar } from '@renderer/components/team/StepProgressBar'; @@ -13,7 +13,7 @@ import { DialogTitle, } from '@renderer/components/ui/dialog'; import { cn } from '@renderer/lib/utils'; -import { AlertTriangle, CheckCircle2, ExternalLink, Loader2, X } from 'lucide-react'; +import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'; import type { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; import type { CSSProperties } from 'react'; @@ -51,28 +51,28 @@ function getToneClasses(tone: TeamProvisioningPresentation['compactTone']): { return { border: 'border-red-400/35 bg-[rgba(26,10,16,0.92)]', badge: 'border-red-500/30 text-red-300', - icon: , + icon: , iconClassName: 'text-red-400', }; case 'warning': return { border: 'border-amber-400/35 bg-[rgba(31,18,8,0.92)]', badge: 'border-amber-500/30 text-amber-200', - icon: , + icon: , iconClassName: 'text-amber-400', }; case 'success': return { border: 'border-emerald-400/35 bg-[rgba(8,24,18,0.92)]', badge: 'border-emerald-500/30 text-emerald-200', - icon: , + icon: , iconClassName: 'text-emerald-400', }; default: return { border: 'border-cyan-400/25 bg-[rgba(8,14,26,0.92)]', badge: 'border-cyan-500/20 text-cyan-200', - icon: , + icon: , iconClassName: 'text-cyan-300', }; } @@ -80,26 +80,17 @@ function getToneClasses(tone: TeamProvisioningPresentation['compactTone']): { export interface GraphProvisioningHudProps { teamName: string; - leadNodeId: string | null; - getLaunchAnchorScreenPlacement: ( - leadNodeId: string - ) => { x: number; y: number; scale: number; visible: boolean } | null; enabled?: boolean; } export const GraphProvisioningHud = ({ teamName, - leadNodeId, - getLaunchAnchorScreenPlacement, enabled = true, }: GraphProvisioningHudProps): React.JSX.Element | null => { const { presentation, runInstanceKey } = useTeamProvisioningPresentation(teamName); - const shellRef = useRef(null); const lastActiveStepRef = useRef(-1); const [detailsOpen, setDetailsOpen] = useState(false); - const [dismissed, setDismissed] = useState(false); - const shouldRender = - enabled && shouldRenderLaunchHud(presentation) && !dismissed && Boolean(leadNodeId); + const shouldRender = enabled && shouldRenderLaunchHud(presentation); const tone = presentation ? getToneClasses(presentation.compactTone) : null; const errorStepIndex = presentation?.isFailed ? lastActiveStepRef.current >= 0 @@ -109,63 +100,21 @@ export const GraphProvisioningHud = ({ useEffect(() => { setDetailsOpen(false); - setDismissed(false); lastActiveStepRef.current = -1; }, [runInstanceKey, teamName]); - useEffect(() => { - if (!shouldRender || !leadNodeId) { - setDetailsOpen(false); - } - }, [leadNodeId, shouldRender]); - useEffect(() => { if (presentation && !presentation.isFailed && presentation.currentStepIndex >= 0) { lastActiveStepRef.current = presentation.currentStepIndex; } }, [presentation]); - useLayoutEffect(() => { - if (!shouldRender || !leadNodeId) { - return; - } - let frameId = 0; - const updatePosition = (): void => { - const shell = shellRef.current; - if (!shell) { - frameId = window.requestAnimationFrame(updatePosition); - return; - } - const placement = getLaunchAnchorScreenPlacement(leadNodeId); - if (!placement) { - shell.style.opacity = '0'; - frameId = window.requestAnimationFrame(updatePosition); - return; - } - - if (!placement.visible) { - shell.style.opacity = '0'; - frameId = window.requestAnimationFrame(updatePosition); - return; - } - - shell.style.opacity = '1'; - shell.style.transform = `translate(${Math.round(placement.x)}px, ${Math.round(placement.y)}px) scale(${placement.scale.toFixed(3)})`; - frameId = window.requestAnimationFrame(updatePosition); - }; - - updatePosition(); - return () => { - window.cancelAnimationFrame(frameId); - }; - }, [getLaunchAnchorScreenPlacement, leadNodeId, shouldRender]); - const compactLabel = useMemo(() => { if (!presentation?.compactDetail) { return null; } - return presentation.compactDetail.length > 88 - ? `${presentation.compactDetail.slice(0, 88)}...` + return presentation.compactDetail.length > 54 + ? `${presentation.compactDetail.slice(0, 54)}...` : presentation.compactDetail; }, [presentation?.compactDetail]); @@ -174,21 +123,21 @@ export const GraphProvisioningHud = ({ } return ( -
-
+ - -
- -
+
+ @@ -254,6 +184,6 @@ export const GraphProvisioningHud = ({
- + ); }; diff --git a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index 74df726b..e5c7c6da 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -58,10 +58,6 @@ export const TeamGraphOverlay = ({ const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); const effectiveSidebarVisible = sidebarVisible ?? persistedSidebarVisible; const handleToggleSidebar = onToggleSidebar ?? toggleSidebarVisible; - const leadNodeId = useMemo( - () => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null, - [graphData.nodes] - ); // Task action dispatchers (same pattern as TeamGraphTab) const dispatchTaskAction = useCallback( @@ -126,6 +122,7 @@ export const TeamGraphOverlay = ({ onCreateTask={openCreateTask} onToggleSidebar={handleToggleSidebar} isSidebarVisible={effectiveSidebarVisible} + renderTopToolbarContent={() => } onOwnerSlotDrop={commitOwnerSlotDrop} className="team-graph-view min-w-0 flex-1" renderHud={(hudProps) => { @@ -143,7 +140,7 @@ export const TeamGraphOverlay = ({ worldToScreen?: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; }; - const { getLaunchAnchorScreenPlacement, getViewportSize, focusNodeIds } = extraHudProps; + const { getViewportSize, focusNodeIds } = extraHudProps; return ( <> @@ -159,11 +156,6 @@ export const TeamGraphOverlay = ({ onOpenTaskDetail={onOpenTaskDetail} onOpenMemberProfile={onOpenMemberProfile} /> - ); }} diff --git a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx index 31976bf6..65f6bda0 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphTab.tsx @@ -46,10 +46,6 @@ export const TeamGraphTab = ({ }: TeamGraphTabProps): React.JSX.Element => { const graphData = useTeamGraphAdapter(teamName); const { openTeamPage, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName); - const leadNodeId = useMemo( - () => graphData.nodes.find((node) => node.kind === 'lead')?.id ?? null, - [graphData.nodes] - ); const [fullscreen, setFullscreen] = useState(false); const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility(); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); @@ -149,6 +145,9 @@ export const TeamGraphTab = ({ onCreateTask={openCreateTask} onToggleSidebar={toggleSidebarVisible} isSidebarVisible={sidebarVisible} + renderTopToolbarContent={() => ( + + )} onOwnerSlotDrop={commitOwnerSlotDrop} renderHud={(hudProps) => { const extraHudProps = hudProps as typeof hudProps & { @@ -165,7 +164,7 @@ export const TeamGraphTab = ({ worldToScreen?: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; }; - const { getLaunchAnchorScreenPlacement, getViewportSize, focusNodeIds } = extraHudProps; + const { getViewportSize, focusNodeIds } = extraHudProps; return ( <> @@ -182,12 +181,6 @@ export const TeamGraphTab = ({ onOpenTaskDetail={dispatchOpenTask} onOpenMemberProfile={dispatchOpenProfile} /> - ); }} diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index e1d10700..8b9ca8e5 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -86,6 +86,27 @@ interface RefreshTeamDataOptions { } type TeamGraphSlotAssignments = Record; +type TeamGraphMemberSeedInput = Pick; + +const SMALL_TEAM_CARDINAL_SLOT_PRESETS: ReadonlyArray> = [ + [], + [{ ringIndex: 0, sectorIndex: 0 }], + [ + { ringIndex: 0, sectorIndex: 5 }, + { ringIndex: 0, sectorIndex: 1 }, + ], + [ + { ringIndex: 0, sectorIndex: 5 }, + { ringIndex: 0, sectorIndex: 1 }, + { ringIndex: 0, sectorIndex: 3 }, + ], + [ + { ringIndex: 0, sectorIndex: 5 }, + { ringIndex: 0, sectorIndex: 1 }, + { ringIndex: 0, sectorIndex: 4 }, + { ringIndex: 0, sectorIndex: 2 }, + ], +]; export function isTeamDataRefreshPending(teamName: string): boolean { return ( @@ -943,7 +964,7 @@ export function selectTeamDataForName( function migrateStableSlotAssignmentsForMembers( assignments: TeamGraphSlotAssignments | undefined, - members: readonly Pick[] + members: readonly TeamGraphMemberSeedInput[] ): { assignments: TeamGraphSlotAssignments; changed: boolean } { const nextAssignments: TeamGraphSlotAssignments = { ...(assignments ?? {}) }; let changed = false; @@ -970,6 +991,36 @@ function migrateStableSlotAssignmentsForMembers( return { assignments: nextAssignments, changed }; } +function seedStableSlotAssignmentsForMembers( + assignments: TeamGraphSlotAssignments, + members: readonly TeamGraphMemberSeedInput[] +): { assignments: TeamGraphSlotAssignments; changed: boolean } { + const visibleMembers = members.filter((member) => !member.removedAt); + if (visibleMembers.length === 0 || visibleMembers.length > 4) { + return { assignments, changed: false }; + } + + const visibleStableOwnerIds = visibleMembers.map((member) => getStableTeamOwnerId(member)); + const hasAnyVisibleAssignments = visibleStableOwnerIds.some( + (stableOwnerId) => assignments[stableOwnerId] != null + ); + if (hasAnyVisibleAssignments) { + return { assignments, changed: false }; + } + + const preset = SMALL_TEAM_CARDINAL_SLOT_PRESETS[visibleMembers.length]; + if (!preset || preset.length !== visibleMembers.length) { + return { assignments, changed: false }; + } + + const nextAssignments: TeamGraphSlotAssignments = { ...assignments }; + visibleMembers.forEach((member, index) => { + nextAssignments[getStableTeamOwnerId(member)] = preset[index]!; + }); + + return { assignments: nextAssignments, changed: true }; +} + function isVisibleInActiveTeamSurface( state: Pick, teamName: string | null | undefined @@ -1077,7 +1128,7 @@ export interface TeamSlice { clearKanbanFilter: () => void; ensureTeamGraphSlotAssignments: ( teamName: string, - members: readonly Pick[] + members: readonly TeamGraphMemberSeedInput[] ) => void; setTeamGraphOwnerSlotAssignment: ( teamName: string, @@ -1783,10 +1834,11 @@ export const createTeamSlice: StateCreator = (set, const currentAssignments = nextSlotAssignmentsByTeam[teamName]; const migrated = migrateStableSlotAssignmentsForMembers(currentAssignments, members); - if (migrated.changed) { + const seeded = seedStableSlotAssignmentsForMembers(migrated.assignments, members); + if (migrated.changed || seeded.changed) { nextSlotAssignmentsByTeam = { ...nextSlotAssignmentsByTeam, - [teamName]: migrated.assignments, + [teamName]: seeded.assignments, }; changed = true; } diff --git a/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts b/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts index 49a29299..383f7951 100644 --- a/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts +++ b/test/renderer/features/agent-graph/GraphProvisioningHud.test.ts @@ -46,8 +46,6 @@ vi.mock('@renderer/components/team/TeamProvisioningPanel', () => ({ ), })); -const placement = { x: 120, y: 80, scale: 1, visible: true }; - describe('GraphProvisioningHud', () => { afterEach(() => { document.body.innerHTML = ''; @@ -77,8 +75,6 @@ describe('GraphProvisioningHud', () => { root.render( React.createElement(GraphProvisioningHud, { teamName: 'northstar-core', - leadNodeId: 'lead:northstar-core', - getLaunchAnchorScreenPlacement: () => placement, }) ); await Promise.resolve(); @@ -117,14 +113,12 @@ describe('GraphProvisioningHud', () => { root.render( React.createElement(GraphProvisioningHud, { teamName: 'northstar-core', - leadNodeId: 'lead:northstar-core', - getLaunchAnchorScreenPlacement: () => placement, }) ); await Promise.resolve(); }); - const openButton = host.querySelector('button[aria-label="Open full launch details"]'); + const openButton = host.querySelector('button[aria-label="Open launch details"]'); expect(openButton).not.toBeNull(); await act(async () => { @@ -165,8 +159,6 @@ describe('GraphProvisioningHud', () => { root.render( React.createElement(GraphProvisioningHud, { teamName: 'northstar-core', - leadNodeId: 'lead:northstar-core', - getLaunchAnchorScreenPlacement: () => placement, enabled: false, }) ); diff --git a/test/renderer/features/agent-graph/useGraphSimulation.test.ts b/test/renderer/features/agent-graph/useGraphSimulation.test.ts index 7622f3a6..2db1509f 100644 --- a/test/renderer/features/agent-graph/useGraphSimulation.test.ts +++ b/test/renderer/features/agent-graph/useGraphSimulation.test.ts @@ -98,7 +98,7 @@ describe('stable slot layout planner', () => { expect(snapshot).toBeNull(); }); - it('builds launch and activity geometry around the central lead block', () => { + it('builds lead activity inside the same central owner slot topology', () => { const teamName = 'team-a'; const lead = createLead(teamName); const alice = createMember(teamName, 'agent-alice', 'alice'); @@ -118,11 +118,13 @@ describe('stable slot layout planner', () => { expect(snapshot).not.toBeNull(); expect(snapshot?.leadNodeId).toBe(lead.id); - expect(snapshot?.launchAnchor).not.toBeNull(); + expect(snapshot?.launchAnchor).toBeNull(); + expect(snapshot?.leadSlotFrame.ownerId).toBe(lead.id); expect(snapshot?.memberSlotFrames).toHaveLength(1); expect(snapshot?.memberSlotFrames[0]?.ownerId).toBe(alice.id); - expect(snapshot?.leadActivityRect.left).toBeLessThan(snapshot?.leadCoreRect.left ?? 0); - expect(snapshot?.fitBounds.right).toBeGreaterThan(snapshot?.leadCoreRect.right ?? 0); + expect(snapshot?.leadActivityRect.top).toBeGreaterThan(snapshot?.leadCoreRect.bottom ?? 0); + expect(snapshot?.leadSlotFrame.activityColumnRect.left).toBe(snapshot?.leadActivityRect.left); + expect(snapshot?.leadSlotFrame.activityColumnRect.top).toBe(snapshot?.leadActivityRect.top); expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true }); }); @@ -309,7 +311,7 @@ describe('stable slot layout planner', () => { expect(validateStableSlotLayout(invalid).valid).toBe(false); }); - it('rejects member frames that overlap lead activity and launch central collision rects', () => { + it('rejects member frames that overlap the lead central reserved block', () => { const teamName = 'team-central-rects'; const lead = createLead(teamName); const alice = createMember(teamName, 'agent-alice', 'alice'); @@ -329,27 +331,16 @@ describe('stable slot layout planner', () => { expect(snapshot).not.toBeNull(); const [frame] = snapshot!.memberSlotFrames; - const overlappingLeadActivity = translateSlotFrame( + const overlappingLeadBlock = translateSlotFrame( frame, - snapshot!.leadActivityRect.left - frame.bounds.left + 1, - snapshot!.leadActivityRect.top - frame.bounds.top + 1 - ); - const overlappingLaunchHud = translateSlotFrame( - frame, - snapshot!.launchHudRect.left - frame.bounds.left + 1, - snapshot!.launchHudRect.top - frame.bounds.top + 1 + snapshot!.leadCentralReservedBlock.left - frame.bounds.left + 1, + snapshot!.leadCentralReservedBlock.top - frame.bounds.top + 1 ); expect( validateStableSlotLayout({ ...snapshot!, - memberSlotFrames: [overlappingLeadActivity], - }).valid - ).toBe(false); - expect( - validateStableSlotLayout({ - ...snapshot!, - memberSlotFrames: [overlappingLaunchHud], + memberSlotFrames: [overlappingLeadBlock], }).valid ).toBe(false); }); @@ -629,6 +620,50 @@ describe('stable slot layout planner', () => { } }); + it('positions lead-owned tasks inside the lead kanban band instead of unassigned', () => { + const teamName = 'team-lead-owned-tasks'; + const lead = createLead(teamName); + const alice = createMember(teamName, 'agent-alice', 'alice'); + const leadTasks = [ + createTask(teamName, 'lead-a', lead.id, { taskStatus: 'completed' }), + createTask(teamName, 'lead-b', lead.id, { taskStatus: 'in_progress' }), + ]; + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [alice.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 1 }, + }, + }; + + const nodes = [lead, alice, ...leadTasks]; + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes, + layout, + }); + + expect(snapshot).not.toBeNull(); + expect(snapshot?.unassignedTaskRect).toBeNull(); + lead.x = snapshot!.leadSlotFrame.ownerX; + lead.y = snapshot!.leadSlotFrame.ownerY; + alice.x = snapshot!.memberSlotFrames[0]?.ownerX; + alice.y = snapshot!.memberSlotFrames[0]?.ownerY; + + KanbanLayoutEngine.layout(nodes, { + leadSlotFrame: snapshot!.leadSlotFrame, + memberSlotFrames: snapshot!.memberSlotFrames, + unassignedTaskRect: snapshot!.unassignedTaskRect, + }); + + for (const task of leadTasks) { + expect(task.x).toBeGreaterThanOrEqual(snapshot!.leadSlotFrame.kanbanBandRect.left); + expect(task.x).toBeLessThanOrEqual(snapshot!.leadSlotFrame.kanbanBandRect.right); + expect(task.y).toBeGreaterThanOrEqual(snapshot!.leadSlotFrame.kanbanBandRect.top); + expect(task.y).toBeLessThanOrEqual(snapshot!.leadSlotFrame.kanbanBandRect.bottom); + } + }); + it('keeps the same sector and spills to the next outer ring when the saved slot is already occupied', () => { const teamName = 'team-wide-spill'; const lead = createLead(teamName); diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index d91c919f..a7e9e286 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -259,6 +259,48 @@ describe('teamSlice actions', () => { }); }); + it('seeds first-open cardinal slot defaults for small visible teams with no saved placements', () => { + const store = createSliceStore(); + + store.getState().ensureTeamGraphSlotAssignments('my-team', [ + { name: 'alice', agentId: 'agent-alice' }, + { name: 'bob', agentId: 'agent-bob' }, + { name: 'tom', agentId: 'agent-tom' }, + { name: 'jack', agentId: 'agent-jack' }, + ]); + + expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ + 'agent-alice': { ringIndex: 0, sectorIndex: 5 }, + 'agent-bob': { ringIndex: 0, sectorIndex: 1 }, + 'agent-tom': { ringIndex: 0, sectorIndex: 4 }, + 'agent-jack': { ringIndex: 0, sectorIndex: 2 }, + }); + }); + + it('seeds visible members even when only hidden owners have saved placements', () => { + const store = createSliceStore(); + store.setState({ + slotLayoutVersion: 'stable-slots-v1', + slotAssignmentsByTeam: { + 'my-team': { + 'agent-hidden': { ringIndex: 2, sectorIndex: 4 }, + }, + }, + }); + + store.getState().ensureTeamGraphSlotAssignments('my-team', [ + { name: 'hidden', agentId: 'agent-hidden', removedAt: '2026-04-16T08:00:00.000Z' }, + { name: 'alice', agentId: 'agent-alice' }, + { name: 'bob', agentId: 'agent-bob' }, + ]); + + expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ + 'agent-hidden': { ringIndex: 2, sectorIndex: 4 }, + 'agent-alice': { ringIndex: 0, sectorIndex: 5 }, + 'agent-bob': { ringIndex: 0, sectorIndex: 1 }, + }); + }); + it('resets stale slot assignments when slot layout version mismatches', () => { const store = createSliceStore(); store.setState({ @@ -278,7 +320,11 @@ describe('teamSlice actions', () => { ]); expect(store.getState().slotLayoutVersion).toBe('stable-slots-v1'); - expect(store.getState().slotAssignmentsByTeam).toEqual({}); + expect(store.getState().slotAssignmentsByTeam).toEqual({ + 'my-team': { + 'agent-alice': { ringIndex: 0, sectorIndex: 0 }, + }, + }); }); it('keeps hidden-member slot assignments so the same stable owner can reuse them later', () => { From 46304421498a4e296d9cd7ce8e409cb93f610790 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 13:05:16 +0300 Subject: [PATCH 5/6] fix(agent-graph): stabilize slot layout interactions --- .../src/hooks/useGraphSimulation.ts | 12 + .../agent-graph/src/layout/stableSlots.ts | 274 +++++++++++++++++- packages/agent-graph/src/ui/GraphCanvas.tsx | 57 ++++ packages/agent-graph/src/ui/GraphView.tsx | 194 ++++++++++--- .../renderer/hooks/useTeamGraphAdapter.ts | 21 +- .../renderer/ui/TeamGraphOverlay.tsx | 15 +- .../agent-graph/renderer/ui/TeamGraphTab.tsx | 15 +- src/renderer/store/slices/teamSlice.ts | 126 +++++++- .../agent-graph/useGraphSimulation.test.ts | 133 +++++++++ test/renderer/store/teamSlice.test.ts | 112 ++++++- 10 files changed, 890 insertions(+), 69 deletions(-) diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index 5da1294c..cd4d62ad 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -38,6 +38,7 @@ export interface UseGraphSimulationResult { tick: (dt: number) => void; setNodePosition: (nodeId: string, x: number, y: number) => void; clearNodePosition: (nodeId: string) => void; + clearTransientOwnerPositions: () => void; resolveNearestOwnerSlot: ( nodeId: string, x: number, @@ -46,6 +47,8 @@ export interface UseGraphSimulationResult { assignment: GraphOwnerSlotAssignment; displacedOwnerId?: string; displacedAssignment?: GraphOwnerSlotAssignment; + previewOwnerX: number; + previewOwnerY: number; } | null; getLaunchAnchorWorldPosition: (leadNodeId: string) => { x: number; y: number } | null; getActivityWorldRect: (nodeId: string) => StableRect | null; @@ -199,6 +202,14 @@ export function useGraphSimulation(): UseGraphSimulationResult { [applyCurrentLayout] ); + const clearTransientOwnerPositions = useCallback(() => { + if (dragOwnerPositionsRef.current.size === 0) { + return; + } + dragOwnerPositionsRef.current.clear(); + applyCurrentLayout(); + }, [applyCurrentLayout]); + const resolveNearestOwnerSlot = useCallback( (nodeId: string, x: number, y: number) => { const snapshot = layoutSnapshotRef.current; @@ -234,6 +245,7 @@ export function useGraphSimulation(): UseGraphSimulationResult { tick, setNodePosition, clearNodePosition, + clearTransientOwnerPositions, resolveNearestOwnerSlot, getLaunchAnchorWorldPosition: (leadNodeId: string) => launchAnchorPositionsRef.current.get(leadNodeId) ?? null, diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts index 76b9f110..070323ea 100644 --- a/packages/agent-graph/src/layout/stableSlots.ts +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -77,6 +77,8 @@ interface NearestSlotAssignmentResult { assignment: GraphOwnerSlotAssignment; displacedOwnerId?: string; displacedAssignment?: GraphOwnerSlotAssignment; + previewOwnerX: number; + previewOwnerY: number; } interface RankedNearestSlotAssignmentResult extends NearestSlotAssignmentResult { @@ -116,8 +118,41 @@ const SLOT_GEOMETRY = { const PROCESS_RAIL_NODE_GAP = 42; const PROCESS_RAIL_NODE_FOOTPRINT = 28; const GEOMETRY_EPSILON = 0.001; +const SMALL_TEAM_CARDINAL_RADIUS_STEP = 24; const SECTOR_VECTORS = STABLE_SLOT_SECTOR_VECTORS; +const SMALL_TEAM_CARDINAL_LAYOUTS: ReadonlyArray< + ReadonlyArray<{ + assignment: GraphOwnerSlotAssignment; + vector: { x: number; y: number }; + }> +> = [ + [], + [{ assignment: { ringIndex: 0, sectorIndex: 0 }, vector: { x: 0, y: -1 } }], + [ + { assignment: { ringIndex: 0, sectorIndex: 0 }, vector: { x: -1, y: 0 } }, + { assignment: { ringIndex: 0, sectorIndex: 1 }, vector: { x: 1, y: 0 } }, + ], + [ + { assignment: { ringIndex: 0, sectorIndex: 0 }, vector: { x: 0, y: -1 } }, + { assignment: { ringIndex: 0, sectorIndex: 1 }, vector: { x: -1, y: 0 } }, + { assignment: { ringIndex: 0, sectorIndex: 2 }, vector: { x: 1, y: 0 } }, + ], + [ + { assignment: { ringIndex: 0, sectorIndex: 0 }, vector: { x: 0, y: -1 } }, + { assignment: { ringIndex: 0, sectorIndex: 1 }, vector: { x: 1, y: 0 } }, + { assignment: { ringIndex: 0, sectorIndex: 2 }, vector: { x: 0, y: 1 } }, + { assignment: { ringIndex: 0, sectorIndex: 3 }, vector: { x: -1, y: 0 } }, + ], +]; + +const SMALL_TEAM_CARDINAL_ASSIGNMENTS: ReadonlyArray> = + SMALL_TEAM_CARDINAL_LAYOUTS.map((layout) => layout.map((slot) => slot.assignment)); +const SMALL_TEAM_CARDINAL_VECTOR_BY_ASSIGNMENT_KEY = new Map( + SMALL_TEAM_CARDINAL_LAYOUTS.flatMap((layout) => + layout.map((slot) => [buildAssignmentKey(slot.assignment), slot.vector] as const) + ) +); export function buildStableSlotLayoutSnapshot({ teamName, @@ -378,6 +413,17 @@ export function resolveNearestSlotAssignment(args: { return null; } + const strictSmallTeamCandidate = resolveStrictSmallTeamNearestSlotAssignment({ + ownerId: args.ownerId, + ownerX: args.ownerX, + ownerY: args.ownerY, + currentFrame, + snapshot: args.snapshot, + }); + if (strictSmallTeamCandidate) { + return strictSmallTeamCandidate; + } + const existingFrames = args.snapshot.memberSlotFrames.filter((frame) => frame.ownerId !== args.ownerId); const maxOccupiedRing = existingFrames.reduce((max, frame) => Math.max(max, frame.ringIndex), 0); const candidateAssignments = buildCandidateAssignments( @@ -423,10 +469,93 @@ export function resolveNearestSlotAssignment(args: { assignment: best.assignment, displacedOwnerId: best.displacedOwnerId, displacedAssignment: best.displacedAssignment, + previewOwnerX: best.previewOwnerX, + previewOwnerY: best.previewOwnerY, } : null; } +function resolveStrictSmallTeamNearestSlotAssignment(args: { + ownerId: string; + ownerX: number; + ownerY: number; + currentFrame: SlotFrame; + snapshot: StableSlotLayoutSnapshot; +}): NearestSlotAssignmentResult | null { + const strictFrames = getStrictSmallTeamFrames(args.snapshot.memberSlotFrames); + if (!strictFrames) { + return null; + } + + let best: + | { + frame: SlotFrame; + distanceSquared: number; + } + | null = null; + for (const frame of strictFrames) { + const dx = frame.ownerX - args.ownerX; + const dy = frame.ownerY - args.ownerY; + const distanceSquared = dx * dx + dy * dy; + if (!best || distanceSquared < best.distanceSquared) { + best = { frame, distanceSquared }; + } + } + + if (!best) { + return null; + } + + const targetFrame = best.frame; + if (targetFrame.ownerId === args.ownerId) { + return { + assignment: { + ringIndex: targetFrame.ringIndex, + sectorIndex: targetFrame.sectorIndex, + }, + previewOwnerX: targetFrame.ownerX, + previewOwnerY: targetFrame.ownerY, + }; + } + + return { + assignment: { + ringIndex: targetFrame.ringIndex, + sectorIndex: targetFrame.sectorIndex, + }, + displacedOwnerId: targetFrame.ownerId, + displacedAssignment: { + ringIndex: args.currentFrame.ringIndex, + sectorIndex: args.currentFrame.sectorIndex, + }, + previewOwnerX: targetFrame.ownerX, + previewOwnerY: targetFrame.ownerY, + }; +} + +function getStrictSmallTeamFrames(frames: readonly SlotFrame[]): readonly SlotFrame[] | null { + if (frames.length === 0 || frames.length > 4) { + return null; + } + const preset = SMALL_TEAM_CARDINAL_ASSIGNMENTS[frames.length]; + if (!preset || preset.length !== frames.length) { + return null; + } + + const actualAssignmentKeys = frames + .map((frame) => buildAssignmentKey({ ringIndex: frame.ringIndex, sectorIndex: frame.sectorIndex })) + .sort(); + const presetAssignmentKeys = preset.map((assignment) => buildAssignmentKey(assignment)).sort(); + + for (let index = 0; index < presetAssignmentKeys.length; index += 1) { + if (actualAssignmentKeys[index] !== presetAssignmentKeys[index]) { + return null; + } + } + + return frames; +} + export function validateStableSlotLayout( snapshot: StableSlotLayoutSnapshot ): StableSlotLayoutValidationResult { @@ -730,6 +859,18 @@ function planOwnerSlots( runtimeCentralExclusion: StableRect, layout?: GraphLayoutPort ): SlotFrame[] { + const strictSmallTeamFrames = shouldUseStrictSmallTeamCardinalLayout(ownerFootprints, layout) + ? planStrictSmallTeamOwnerSlots( + ownerFootprints, + centralCollisionRects, + runtimeCentralExclusion, + layout + ) + : null; + if (strictSmallTeamFrames) { + return strictSmallTeamFrames; + } + const placedFrames: SlotFrame[] = []; const preferredAssignments = buildPreferredAssignmentsMap(layout?.slotAssignments); const usedSlotKeys = new Set(); @@ -754,6 +895,105 @@ function planOwnerSlots( return placedFrames; } +function shouldUseStrictSmallTeamCardinalLayout( + ownerFootprints: readonly OwnerFootprint[], + layout?: GraphLayoutPort +): boolean { + if (ownerFootprints.length === 0 || ownerFootprints.length > 4) { + return false; + } + + const preset = SMALL_TEAM_CARDINAL_ASSIGNMENTS[ownerFootprints.length]; + if (!preset || preset.length !== ownerFootprints.length) { + return false; + } + + const actualAssignmentKeys = ownerFootprints + .map((footprint) => layout?.slotAssignments?.[footprint.ownerId]) + .filter((assignment): assignment is GraphOwnerSlotAssignment => assignment != null) + .map((assignment) => buildAssignmentKey(assignment)) + .sort(); + const presetAssignmentKeys = preset.map((assignment) => buildAssignmentKey(assignment)).sort(); + + if (actualAssignmentKeys.length !== presetAssignmentKeys.length) { + return false; + } + + for (let index = 0; index < presetAssignmentKeys.length; index += 1) { + if (actualAssignmentKeys[index] !== presetAssignmentKeys[index]) { + return false; + } + } + + return true; +} + +function planStrictSmallTeamOwnerSlots( + ownerFootprints: readonly OwnerFootprint[], + centralCollisionRects: readonly StableRect[], + runtimeCentralExclusion: StableRect, + layout?: GraphLayoutPort +): SlotFrame[] | null { + if (ownerFootprints.length === 0 || ownerFootprints.length > 4) { + return null; + } + + const preset = SMALL_TEAM_CARDINAL_LAYOUTS[ownerFootprints.length]; + if (!preset || preset.length !== ownerFootprints.length) { + return null; + } + + const slotConfigs = ownerFootprints.map((footprint) => { + const assignment = layout?.slotAssignments?.[footprint.ownerId]; + if (!assignment) { + return null; + } + const vector = SMALL_TEAM_CARDINAL_VECTOR_BY_ASSIGNMENT_KEY.get(buildAssignmentKey(assignment)); + if (!vector) { + return null; + } + return { + footprint, + assignment, + vector, + }; + }); + + if (slotConfigs.some((slot) => slot == null)) { + return null; + } + + let radius = Math.max( + ...slotConfigs.map((slot) => + resolveMinimumDirectionalRadiusForVector({ + vector: slot!.vector, + footprint: slot!.footprint, + centralCollisionRects, + runtimeCentralExclusion, + }) + ) + ); + + for (let iteration = 0; iteration < 48; iteration += 1) { + const frames = slotConfigs.map((slot) => + buildSlotFrameAtRadiusWithVector(slot!.footprint, slot!.assignment, radius, slot!.vector) + ); + const allValid = frames.every((frame, frameIndex) => + isSlotFramePlacementValid( + frame, + frames.filter((_, index) => index !== frameIndex), + centralCollisionRects + ) + ); + if (allValid) { + return frames; + } + radius += SMALL_TEAM_CARDINAL_RADIUS_STEP; + } + + return null; +} + function buildPreferredAssignmentsMap( assignments?: Record ): Map { @@ -870,6 +1110,15 @@ function buildSlotFrameAtRadius( radius: number ): SlotFrame { const vector = SECTOR_VECTORS[assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0]; + return buildSlotFrameAtRadiusWithVector(footprint, assignment, radius, vector); +} + +function buildSlotFrameAtRadiusWithVector( + footprint: OwnerFootprint, + assignment: GraphOwnerSlotAssignment, + radius: number, + vector: { x: number; y: number } +): SlotFrame { const ownerX = vector.x * radius; const ownerY = vector.y * radius; const slotTop = @@ -1122,6 +1371,8 @@ function buildRankedNearestSlotAssignmentResult(args: { assignment: args.assignment, displacedOwnerId: args.displacedOwnerId, displacedAssignment: args.displacedAssignment, + previewOwnerX: args.frame.ownerX, + previewOwnerY: args.frame.ownerY, distanceSquared: dx * dx + dy * dy, }; } @@ -1377,14 +1628,33 @@ function resolveMinimumDirectionalRadius(args: { footprint: OwnerFootprint; centralCollisionRects: readonly StableRect[]; runtimeCentralExclusion: StableRect; +}): number { + return resolveMinimumDirectionalRadiusForVector({ + vector: SECTOR_VECTORS[args.assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0], + footprint: args.footprint, + centralCollisionRects: args.centralCollisionRects, + runtimeCentralExclusion: args.runtimeCentralExclusion, + }); +} + +function resolveMinimumDirectionalRadiusForVector(args: { + vector: { x: number; y: number }; + footprint: OwnerFootprint; + centralCollisionRects: readonly StableRect[]; + runtimeCentralExclusion: StableRect; }): number { const legacyRadiusHint = computeLegacyMinimumRingRadius( - SECTOR_VECTORS[args.assignment.sectorIndex % SECTOR_VECTORS.length] ?? SECTOR_VECTORS[0], + args.vector, args.footprint, args.runtimeCentralExclusion ); const overlapsCentralCollision = (radius: number): boolean => { - const frame = buildSlotFrameAtRadius(args.footprint, args.assignment, radius); + const frame = buildSlotFrameAtRadiusWithVector( + args.footprint, + { ringIndex: 0, sectorIndex: 0 }, + radius, + args.vector + ); return rectOverlapsAnyCentralRect(frame.bounds, args.centralCollisionRects); }; diff --git a/packages/agent-graph/src/ui/GraphCanvas.tsx b/packages/agent-graph/src/ui/GraphCanvas.tsx index 1e8c893d..516e3a5f 100644 --- a/packages/agent-graph/src/ui/GraphCanvas.tsx +++ b/packages/agent-graph/src/ui/GraphCanvas.tsx @@ -24,6 +24,7 @@ import { drawAgents, drawCrossTeamNodes } from '../canvas/draw-agents'; import { drawTasks, drawColumnHeaders } from '../canvas/draw-tasks'; import { drawProcesses } from '../canvas/draw-processes'; import { drawEffects, type VisualEffect } from '../canvas/draw-effects'; +import { drawHexagon } from '../canvas/draw-misc'; import { BloomRenderer } from '../canvas/bloom-renderer'; import { KanbanLayoutEngine } from '../layout/kanbanLayout'; import { @@ -36,6 +37,7 @@ import { updateTransientHandoffState, } from './transientHandoffs'; import type { CameraTransform } from '../hooks/useGraphCamera'; +import { NODE } from '../constants/canvas-constants'; // ─── Draw State (passed by ref, not by props — no React re-renders) ───────── @@ -53,6 +55,14 @@ export interface GraphDrawState { hoveredEdgeId: string | null; focusNodeIds: ReadonlySet | null; focusEdgeIds: ReadonlySet | null; + dragPreview: + | { + nodeId: string; + x: number; + y: number; + color?: string | null; + } + | null; } export interface GraphCanvasHandle { @@ -341,6 +351,9 @@ export const GraphCanvas = forwardRef(funct state.focusNodeIds, zoom ); + if (state.dragPreview) { + drawOwnerSlotPreview(ctx, state.dragPreview, state.time); + } // 2d. Effects drawEffects(ctx, state.effects); @@ -437,3 +450,47 @@ export const GraphCanvas = forwardRef(funct ); }); + +function drawOwnerSlotPreview( + ctx: CanvasRenderingContext2D, + preview: NonNullable, + time: number +): void { + const radius = NODE.radiusMember; + const outerRadius = radius + 18; + const innerRadius = radius + 8; + const glowRadius = radius + 34; + const color = preview.color ?? '#8bd3ff'; + const pulse = 0.35 + 0.15 * Math.sin(time * 6); + + ctx.save(); + ctx.globalAlpha = 0.7 + pulse; + ctx.setLineDash([8, 6]); + ctx.lineDashOffset = -time * 48; + ctx.lineWidth = 2.5; + + drawHexagon(ctx, preview.x, preview.y, outerRadius); + ctx.strokeStyle = color; + ctx.stroke(); + + ctx.setLineDash([]); + drawHexagon(ctx, preview.x, preview.y, innerRadius); + ctx.fillStyle = 'rgba(120, 190, 255, 0.08)'; + ctx.fill(); + + const glow = ctx.createRadialGradient( + preview.x, + preview.y, + radius * 0.45, + preview.x, + preview.y, + glowRadius + ); + glow.addColorStop(0, 'rgba(120, 190, 255, 0.12)'); + glow.addColorStop(1, 'rgba(120, 190, 255, 0)'); + ctx.beginPath(); + ctx.arc(preview.x, preview.y, glowRadius, 0, Math.PI * 2); + ctx.fillStyle = glow; + ctx.fill(); + ctx.restore(); +} diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index 7daf3e57..a842bbf9 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -43,6 +43,7 @@ export interface GraphViewProps { onRequestClose?: () => void; onRequestPinAsTab?: () => void; onRequestFullscreen?: () => void; + isSurfaceActive?: boolean; onOpenTeamPage?: () => void; onCreateTask?: () => void; onToggleSidebar?: () => void; @@ -89,6 +90,7 @@ export function GraphView({ onRequestClose, onRequestPinAsTab, onRequestFullscreen, + isSurfaceActive = true, onOpenTeamPage, onCreateTask, onToggleSidebar, @@ -127,6 +129,12 @@ export function GraphView({ const allowAutoFitRef = useRef(true); const nodeMapRef = useRef(new Map()); const nodeMapNodesRef = useRef(null); + const dragPreviewRef = useRef<{ + nodeId: string; + x: number; + y: number; + color?: string | null; + } | null>(null); // ─── Hooks ────────────────────────────────────────────────────────────── const simulation = useGraphSimulation(); @@ -284,6 +292,7 @@ export function GraphView({ hoveredEdgeId: hoveredEdgeIdRef.current, focusNodeIds: focusState.focusNodeIds, focusEdgeIds: focusState.focusEdgeIds, + dragPreview: dragPreviewRef.current, }); rafRef.current = requestAnimationFrame(animate); @@ -370,6 +379,17 @@ export function GraphView({ allowAutoFitRef.current = false; }, []); + useLayoutEffect(() => { + if (!isSurfaceActive) { + return; + } + interaction.handleMouseUp(); + simulation.clearTransientOwnerPositions(); + dragPreviewRef.current = null; + isPanningRef.current = false; + edgeMouseDownRef.current = null; + }, [interaction, isSurfaceActive, simulation]); + const handleWheel = useCallback( (e: WheelEvent) => { markUserInteracted(); @@ -385,6 +405,7 @@ export function GraphView({ const handleMouseDown = useCallback( (e: React.MouseEvent) => { if (e.button !== 0) return; // only left click + dragPreviewRef.current = null; const canvas = canvasHandle.current?.getCanvas(); if (!canvas) return; @@ -435,59 +456,64 @@ export function GraphView({ ] ); - const handleMouseMove = useCallback( - (e: React.MouseEvent) => { - // Dragging with left button held - if (e.buttons & 1) { - if (isPanningRef.current) { - camera.handlePanMove(e.clientX, e.clientY); - return; - } - const canvas = canvasHandle.current?.getCanvas(); - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); - interaction.handleMouseMove(world.x, world.y, getVisibleNodes(simulation.stateRef.current.nodes)); - return; + const processActivePointerMove = useCallback( + (clientX: number, clientY: number, buttons: number) => { + if ((buttons & 1) === 0) { + dragPreviewRef.current = null; + return false; + } + + if (isPanningRef.current) { + camera.handlePanMove(clientX, clientY); + return true; } - // No button held — hover detection + cursor update const canvas = canvasHandle.current?.getCanvas(); - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); - const nodes = getVisibleNodes(simulation.stateRef.current.nodes); - const visibleNodeIds = new Set(nodes.map((node) => node.id)); - const edges = getVisibleEdges(simulation.stateRef.current.edges, visibleNodeIds); - - const hoveredNodeId = findNodeAt(world.x, world.y, nodes); - interaction.hoveredNodeId.current = hoveredNodeId; - - if (hoveredNodeId) { - hoveredEdgeIdRef.current = null; - canvas.style.cursor = 'pointer'; - return; + if (!canvas) { + dragPreviewRef.current = null; + return false; } - const nodeMap = getNodeMap(nodes); - const interactiveEdges = getInteractiveEdges(canvas, nodes, edges); - hoveredEdgeIdRef.current = findEdgeAt(world.x, world.y, interactiveEdges, nodeMap); - canvas.style.cursor = hoveredEdgeIdRef.current ? 'pointer' : 'grab'; + const rect = canvas.getBoundingClientRect(); + const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top); + interaction.handleMouseMove(world.x, world.y, getVisibleNodes(simulation.stateRef.current.nodes)); + + const draggedNodeId = interaction.dragNodeId.current; + if (interaction.isDragging.current && draggedNodeId) { + const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId); + if (draggedNode?.kind === 'member') { + const nearest = simulation.resolveNearestOwnerSlot(draggedNodeId, world.x, world.y); + if (nearest) { + dragPreviewRef.current = { + nodeId: draggedNodeId, + x: nearest.previewOwnerX, + y: nearest.previewOwnerY, + color: draggedNode.color, + }; + return true; + } + } + } + + dragPreviewRef.current = null; + return true; }, - [camera, getInteractiveEdges, getNodeMap, getVisibleEdges, getVisibleNodes, interaction, simulation.stateRef] + [camera, getVisibleNodes, interaction, simulation] ); - const handleMouseUp = useCallback( - (e: React.MouseEvent) => { + const completePointerInteraction = useCallback( + (clientX: number, clientY: number) => { const draggedNodeId = interaction.dragNodeId.current; const wasDragging = interaction.isDragging.current; if (isPanningRef.current) { camera.handlePanEnd(); isPanningRef.current = false; - setSelectedNodeId(null); // hide popover after pan + dragPreviewRef.current = null; + setSelectedNodeId(null); setSelectedEdgeId(null); edgeMouseDownRef.current = null; + interaction.handleMouseUp(); return; } @@ -510,11 +536,13 @@ export function GraphView({ requestAnimationFrame(() => { simulation.clearNodePosition(draggedNodeId); }); + dragPreviewRef.current = null; edgeMouseDownRef.current = null; return; } } simulation.clearNodePosition(draggedNodeId); + dragPreviewRef.current = null; edgeMouseDownRef.current = null; return; } @@ -529,7 +557,7 @@ export function GraphView({ let clickedEdgeId: string | null = null; if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) { const rect = canvas.getBoundingClientRect(); - const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); + const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top); const dx = world.x - edgeMouseDownRef.current.x; const dy = world.y - edgeMouseDownRef.current.y; if (dx * dx + dy * dy <= 25) { @@ -548,17 +576,103 @@ export function GraphView({ events?.onEdgeClick?.(edge); } } else { - setSelectedNodeId(null); // click on empty space — hide popover + setSelectedNodeId(null); setSelectedEdgeId(null); } if (!interaction.isDragging.current && !clickedEdgeId) { events?.onBackgroundClick?.(); } } + dragPreviewRef.current = null; }, - [camera, data.teamName, events, interaction, onOwnerSlotDrop, simulation] + [camera, events, interaction, onOwnerSlotDrop, simulation] ); + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (processActivePointerMove(e.clientX, e.clientY, e.buttons)) { + return; + } + + dragPreviewRef.current = null; + + const canvas = canvasHandle.current?.getCanvas(); + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); + const nodes = getVisibleNodes(simulation.stateRef.current.nodes); + const visibleNodeIds = new Set(nodes.map((node) => node.id)); + const edges = getVisibleEdges(simulation.stateRef.current.edges, visibleNodeIds); + + const hoveredNodeId = findNodeAt(world.x, world.y, nodes); + interaction.hoveredNodeId.current = hoveredNodeId; + + if (hoveredNodeId) { + hoveredEdgeIdRef.current = null; + canvas.style.cursor = 'pointer'; + return; + } + + const nodeMap = getNodeMap(nodes); + const interactiveEdges = getInteractiveEdges(canvas, nodes, edges); + hoveredEdgeIdRef.current = findEdgeAt(world.x, world.y, interactiveEdges, nodeMap); + canvas.style.cursor = hoveredEdgeIdRef.current ? 'pointer' : 'grab'; + }, + [ + camera, + getInteractiveEdges, + getNodeMap, + getVisibleEdges, + getVisibleNodes, + interaction, + processActivePointerMove, + simulation.stateRef, + ] + ); + + const handleMouseUp = useCallback( + (e: React.MouseEvent) => { + completePointerInteraction(e.clientX, e.clientY); + }, + [completePointerInteraction] + ); + + useEffect(() => { + const handleWindowMouseMove = (event: MouseEvent): void => { + if ((event.buttons & 1) === 0) { + return; + } + if ( + !isPanningRef.current && + !interaction.dragNodeId.current && + !interaction.isDragging.current && + !edgeMouseDownRef.current + ) { + return; + } + processActivePointerMove(event.clientX, event.clientY, event.buttons); + }; + + const handleWindowMouseUp = (event: MouseEvent): void => { + if ( + !isPanningRef.current && + !interaction.dragNodeId.current && + !interaction.isDragging.current && + !edgeMouseDownRef.current + ) { + return; + } + completePointerInteraction(event.clientX, event.clientY); + }; + + window.addEventListener('mousemove', handleWindowMouseMove); + window.addEventListener('mouseup', handleWindowMouseUp); + return () => { + window.removeEventListener('mousemove', handleWindowMouseMove); + window.removeEventListener('mouseup', handleWindowMouseUp); + }; + }, [completePointerInteraction, interaction, processActivePointerMove]); + const handleDoubleClick = useCallback( (e: React.MouseEvent) => { const canvas = canvasHandle.current?.getCanvas(); diff --git a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts index 88e2127e..7f33931e 100644 --- a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts @@ -8,7 +8,10 @@ import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react'; import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; import { + getDefaultTeamGraphSlotAssignmentsForMembers, getCurrentProvisioningProgressForTeam, + hasAppliedDefaultTeamGraphSlotAssignments, + isTeamGraphSlotPersistenceDisabled, selectTeamDataForName, } from '@renderer/store/slices/teamSlice'; import { useShallow } from 'zustand/react/shallow'; @@ -62,6 +65,20 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { const commentReadState = useSyncExternalStore(subscribe, getSnapshot); + const effectiveSlotAssignments = useMemo(() => { + if (!teamData) { + return slotAssignments; + } + if (!isTeamGraphSlotPersistenceDisabled()) { + return slotAssignments; + } + if (hasAppliedDefaultTeamGraphSlotAssignments(teamName)) { + return slotAssignments; + } + const defaults = getDefaultTeamGraphSlotAssignmentsForMembers(teamData.members); + return Object.keys(defaults).length === 0 ? undefined : defaults; + }, [slotAssignments, teamData, teamName]); + useEffect(() => { if (!teamName || !teamData) { return; @@ -84,7 +101,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { commentReadState, provisioningProgress, memberSpawnSnapshot, - slotAssignments + effectiveSlotAssignments ), [ teamData, @@ -99,7 +116,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { commentReadState, provisioningProgress, memberSpawnSnapshot, - slotAssignments, + effectiveSlotAssignments, ] ); } diff --git a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx index e5c7c6da..9780bef1 100644 --- a/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx +++ b/src/features/agent-graph/renderer/ui/TeamGraphOverlay.tsx @@ -3,10 +3,12 @@ * Follows the exact ProjectEditorOverlay pattern (lazy-loaded, fixed z-50). */ -import { useCallback, useMemo } from 'react'; +import { useCallback, useLayoutEffect, useMemo } from 'react'; import { GraphView } from '@claude-teams/agent-graph'; import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost'; +import { useStore } from '@renderer/store'; +import { isTeamGraphSlotPersistenceDisabled } from '@renderer/store/slices/teamSlice'; import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog'; import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility'; @@ -53,6 +55,9 @@ export const TeamGraphOverlay = ({ }: TeamGraphOverlayProps): React.JSX.Element => { const graphData = useTeamGraphAdapter(teamName); const { openTeamPage: openTeamTab, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName); + const resetTeamGraphSlotAssignmentsToDefaults = useStore( + (s) => s.resetTeamGraphSlotAssignmentsToDefaults + ); const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility(); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); @@ -86,6 +91,13 @@ export const TeamGraphOverlay = ({ openCreateTaskDialog(''); }, [openCreateTaskDialog]); + useLayoutEffect(() => { + if (!isTeamGraphSlotPersistenceDisabled()) { + return; + } + resetTeamGraphSlotAssignmentsToDefaults(teamName); + }, [resetTeamGraphSlotAssignmentsToDefaults, teamName]); + const events: GraphEventPort = { onNodeDoubleClick: useCallback( (ref: GraphDomainRef) => { @@ -116,6 +128,7 @@ export const TeamGraphOverlay = ({ { const graphData = useTeamGraphAdapter(teamName); const { openTeamPage, commitOwnerSlotDrop } = useTeamGraphSurfaceActions(teamName); + const resetTeamGraphSlotAssignmentsToDefaults = useStore( + (s) => s.resetTeamGraphSlotAssignmentsToDefaults + ); const [fullscreen, setFullscreen] = useState(false); const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility(); const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName); @@ -76,6 +81,13 @@ export const TeamGraphTab = ({ openCreateTaskDialog(''); }, [openCreateTaskDialog]); + useLayoutEffect(() => { + if (!isTeamGraphSlotPersistenceDisabled() || !isActive) { + return; + } + resetTeamGraphSlotAssignmentsToDefaults(teamName); + }, [isActive, resetTeamGraphSlotAssignmentsToDefaults, teamName]); + // Task action dispatchers const dispatchTaskAction = useCallback( (action: string) => (taskId: string) => @@ -140,6 +152,7 @@ export const TeamGraphTab = ({ events={events} className="team-graph-view size-full" suspendAnimation={!isActive} + isSurfaceActive={isActive} onRequestFullscreen={() => setFullscreen(true)} onOpenTeamPage={openTeamPage} onCreateTask={openCreateTask} diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 8b9ca8e5..9434292c 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -10,6 +10,7 @@ import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; +import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; @@ -55,6 +56,7 @@ import type { import type { StateCreator } from 'zustand'; const GRAPH_STABLE_SLOT_LAYOUT_VERSION = 'stable-slots-v1' as const; +const DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS = true; const logger = createLogger('teamSlice'); const TEAM_GET_DATA_TIMEOUT_MS = 30_000; @@ -81,6 +83,7 @@ const teamRefreshBurstDiagnostics = new Map< { windowStartedAt: number; count: number; lastWarnAt: number } >(); const memberSpawnUiEqualLastWarnAtByTeam = new Map(); +const sessionDefaultGraphSlotAssignmentsAppliedByTeam = new Set(); interface RefreshTeamDataOptions { withDedup?: boolean; } @@ -92,20 +95,20 @@ const SMALL_TEAM_CARDINAL_SLOT_PRESETS: ReadonlyArray !member.removedAt); + const visibleMembers = members.filter((member) => !member.removedAt && !isLeadMember(member)); if (visibleMembers.length === 0 || visibleMembers.length > 4) { return { assignments, changed: false }; } @@ -1021,6 +1025,44 @@ function seedStableSlotAssignmentsForMembers( return { assignments: nextAssignments, changed: true }; } +function areTeamGraphSlotAssignmentsEqual( + left: TeamGraphSlotAssignments | undefined, + right: TeamGraphSlotAssignments | undefined +): boolean { + const leftEntries = Object.entries(left ?? {}); + const rightEntries = Object.entries(right ?? {}); + if (leftEntries.length !== rightEntries.length) { + return false; + } + + for (const [stableOwnerId, leftAssignment] of leftEntries) { + const rightAssignment = right?.[stableOwnerId]; + if ( + !rightAssignment || + rightAssignment.ringIndex !== leftAssignment.ringIndex || + rightAssignment.sectorIndex !== leftAssignment.sectorIndex + ) { + return false; + } + } + + return true; +} + +export function getDefaultTeamGraphSlotAssignmentsForMembers( + members: readonly TeamGraphMemberSeedInput[] +): TeamGraphSlotAssignments { + return seedStableSlotAssignmentsForMembers({}, members).assignments; +} + +export function isTeamGraphSlotPersistenceDisabled(): boolean { + return DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS; +} + +export function hasAppliedDefaultTeamGraphSlotAssignments(teamName: string): boolean { + return sessionDefaultGraphSlotAssignmentsAppliedByTeam.has(teamName); +} + function isVisibleInActiveTeamSurface( state: Pick, teamName: string | null | undefined @@ -1829,9 +1871,34 @@ export const createTeamSlice: StateCreator = (set, if (state.slotLayoutVersion !== GRAPH_STABLE_SLOT_LAYOUT_VERSION) { nextState.slotLayoutVersion = GRAPH_STABLE_SLOT_LAYOUT_VERSION; nextSlotAssignmentsByTeam = {}; + sessionDefaultGraphSlotAssignmentsAppliedByTeam.clear(); changed = true; } + if (DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS) { + if (!sessionDefaultGraphSlotAssignmentsAppliedByTeam.has(teamName)) { + const currentAssignments = nextSlotAssignmentsByTeam[teamName]; + const defaultAssignments = getDefaultTeamGraphSlotAssignmentsForMembers(members); + if (!areTeamGraphSlotAssignmentsEqual(currentAssignments, defaultAssignments)) { + nextSlotAssignmentsByTeam = { ...nextSlotAssignmentsByTeam }; + if (Object.keys(defaultAssignments).length === 0) { + delete nextSlotAssignmentsByTeam[teamName]; + } else { + nextSlotAssignmentsByTeam[teamName] = defaultAssignments; + } + changed = true; + } + sessionDefaultGraphSlotAssignmentsAppliedByTeam.add(teamName); + } + + if (!changed) { + return {}; + } + + nextState.slotAssignmentsByTeam = nextSlotAssignmentsByTeam; + return nextState; + } + const currentAssignments = nextSlotAssignmentsByTeam[teamName]; const migrated = migrateStableSlotAssignmentsForMembers(currentAssignments, members); const seeded = seedStableSlotAssignmentsForMembers(migrated.assignments, members); @@ -1979,6 +2046,7 @@ export const createTeamSlice: StateCreator = (set, clearTeamGraphSlotAssignments: (teamName) => { set((state) => { if (!teamName) { + sessionDefaultGraphSlotAssignmentsAppliedByTeam.clear(); if ( Object.keys(state.slotAssignmentsByTeam).length === 0 && state.slotLayoutVersion === GRAPH_STABLE_SLOT_LAYOUT_VERSION @@ -1997,6 +2065,7 @@ export const createTeamSlice: StateCreator = (set, const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam }; delete nextAssignmentsByTeam[teamName]; + sessionDefaultGraphSlotAssignmentsAppliedByTeam.delete(teamName); return { slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION, slotAssignmentsByTeam: nextAssignmentsByTeam, @@ -2006,13 +2075,48 @@ export const createTeamSlice: StateCreator = (set, resetTeamGraphSlotAssignmentsToDefaults: (teamName) => { set((state) => { + if (!DISABLE_PERSISTED_TEAM_GRAPH_SLOT_ASSIGNMENTS) { + const currentAssignments = state.slotAssignmentsByTeam[teamName]; + if (!currentAssignments || Object.keys(currentAssignments).length === 0) { + return {}; + } + + const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam }; + delete nextAssignmentsByTeam[teamName]; + return { + slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION, + slotAssignmentsByTeam: nextAssignmentsByTeam, + }; + } + + const teamData = selectTeamDataForName(state, teamName); + const defaultAssignments = teamData + ? getDefaultTeamGraphSlotAssignmentsForMembers(teamData.members) + : {}; const currentAssignments = state.slotAssignmentsByTeam[teamName]; - if (!currentAssignments || Object.keys(currentAssignments).length === 0) { + const hasCurrentAssignments = + currentAssignments && Object.keys(currentAssignments).length > 0; + + if ( + areTeamGraphSlotAssignmentsEqual(currentAssignments, defaultAssignments) && + sessionDefaultGraphSlotAssignmentsAppliedByTeam.has(teamName) + ) { return {}; } const nextAssignmentsByTeam = { ...state.slotAssignmentsByTeam }; - delete nextAssignmentsByTeam[teamName]; + if (Object.keys(defaultAssignments).length === 0) { + delete nextAssignmentsByTeam[teamName]; + sessionDefaultGraphSlotAssignmentsAppliedByTeam.delete(teamName); + } else { + nextAssignmentsByTeam[teamName] = defaultAssignments; + sessionDefaultGraphSlotAssignmentsAppliedByTeam.add(teamName); + } + + if (!hasCurrentAssignments && Object.keys(defaultAssignments).length === 0) { + return {}; + } + return { slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION, slotAssignmentsByTeam: nextAssignmentsByTeam, diff --git a/test/renderer/features/agent-graph/useGraphSimulation.test.ts b/test/renderer/features/agent-graph/useGraphSimulation.test.ts index 2db1509f..a485d673 100644 --- a/test/renderer/features/agent-graph/useGraphSimulation.test.ts +++ b/test/renderer/features/agent-graph/useGraphSimulation.test.ts @@ -155,6 +155,97 @@ describe('stable slot layout planner', () => { expect(frame?.processBandRect.width).toBe(computeProcessBandWidth(0)); }); + it('uses strict cardinal owner slots for teams with up to four members', () => { + const teamName = 'team-cardinal-four'; + const lead = createLead(teamName); + const top = createMember(teamName, 'agent-top', 'top'); + const right = createMember(teamName, 'agent-right', 'right'); + const bottom = createMember(teamName, 'agent-bottom', 'bottom'); + const left = createMember(teamName, 'agent-left', 'left'); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [top.id, right.id, bottom.id, left.id], + slotAssignments: { + [top.id]: { ringIndex: 0, sectorIndex: 0 }, + [right.id]: { ringIndex: 0, sectorIndex: 1 }, + [bottom.id]: { ringIndex: 0, sectorIndex: 2 }, + [left.id]: { ringIndex: 0, sectorIndex: 3 }, + }, + }; + + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, top, right, bottom, left], + layout, + }); + + expect(snapshot).not.toBeNull(); + + const topFrame = snapshot!.memberSlotFrameByOwnerId.get(top.id)!; + const rightFrame = snapshot!.memberSlotFrameByOwnerId.get(right.id)!; + const bottomFrame = snapshot!.memberSlotFrameByOwnerId.get(bottom.id)!; + const leftFrame = snapshot!.memberSlotFrameByOwnerId.get(left.id)!; + + expect(Math.abs(topFrame.ownerX)).toBeLessThan(1); + expect(topFrame.ownerY).toBeLessThan(0); + + expect(rightFrame.ownerX).toBeGreaterThan(0); + expect(Math.abs(rightFrame.ownerY)).toBeLessThan(1); + + expect(Math.abs(bottomFrame.ownerX)).toBeLessThan(1); + expect(bottomFrame.ownerY).toBeGreaterThan(0); + + expect(leftFrame.ownerX).toBeLessThan(0); + expect(Math.abs(leftFrame.ownerY)).toBeLessThan(1); + + expect(Math.abs(Math.abs(leftFrame.ownerX) - Math.abs(rightFrame.ownerX))).toBeLessThan(1); + expect(Math.abs(Math.abs(topFrame.ownerY) - Math.abs(bottomFrame.ownerY))).toBeLessThan(1); + }); + + it('uses strict cardinal owner slots even when ownerOrder differs from assignment order', () => { + const teamName = 'team-cardinal-misaligned-order'; + const lead = createLead(teamName); + const alice = createMember(teamName, 'agent-alice', 'alice'); + const bob = createMember(teamName, 'agent-bob', 'bob'); + const tom = createMember(teamName, 'agent-tom', 'tom'); + const jack = createMember(teamName, 'agent-jack', 'jack'); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [jack.id, alice.id, tom.id, bob.id], + slotAssignments: { + [alice.id]: { ringIndex: 0, sectorIndex: 0 }, + [bob.id]: { ringIndex: 0, sectorIndex: 1 }, + [tom.id]: { ringIndex: 0, sectorIndex: 2 }, + [jack.id]: { ringIndex: 0, sectorIndex: 3 }, + }, + }; + + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, alice, bob, tom, jack], + layout, + }); + + expect(snapshot).not.toBeNull(); + + const aliceFrame = snapshot!.memberSlotFrameByOwnerId.get(alice.id)!; + const bobFrame = snapshot!.memberSlotFrameByOwnerId.get(bob.id)!; + const tomFrame = snapshot!.memberSlotFrameByOwnerId.get(tom.id)!; + const jackFrame = snapshot!.memberSlotFrameByOwnerId.get(jack.id)!; + + expect(Math.abs(aliceFrame.ownerX)).toBeLessThan(1); + expect(aliceFrame.ownerY).toBeLessThan(0); + + expect(bobFrame.ownerX).toBeGreaterThan(0); + expect(Math.abs(bobFrame.ownerY)).toBeLessThan(1); + + expect(Math.abs(tomFrame.ownerX)).toBeLessThan(1); + expect(tomFrame.ownerY).toBeGreaterThan(0); + + expect(jackFrame.ownerX).toBeLessThan(0); + expect(Math.abs(jackFrame.ownerY)).toBeLessThan(1); + }); + it('reserves a full empty activity column and minimum kanban width for idle members', () => { const teamName = 'team-empty-slot'; const lead = createLead(teamName); @@ -384,6 +475,48 @@ describe('stable slot layout planner', () => { expect(nearest?.displacedAssignment).toEqual({ ringIndex: 0, sectorIndex: 1 }); }); + it('keeps drag resolution inside strict cardinal slots for four-member teams', () => { + const teamName = 'team-cardinal-drag'; + const lead = createLead(teamName); + const top = createMember(teamName, 'agent-top', 'top'); + const right = createMember(teamName, 'agent-right', 'right'); + const bottom = createMember(teamName, 'agent-bottom', 'bottom'); + const left = createMember(teamName, 'agent-left', 'left'); + const layout: GraphLayoutPort = { + version: 'stable-slots-v1', + ownerOrder: [top.id, right.id, bottom.id, left.id], + slotAssignments: { + [top.id]: { ringIndex: 0, sectorIndex: 0 }, + [right.id]: { ringIndex: 0, sectorIndex: 1 }, + [bottom.id]: { ringIndex: 0, sectorIndex: 2 }, + [left.id]: { ringIndex: 0, sectorIndex: 3 }, + }, + }; + + const snapshot = buildStableSlotLayoutSnapshot({ + teamName, + nodes: [lead, top, right, bottom, left], + layout, + }); + + expect(snapshot).not.toBeNull(); + const rightFrame = snapshot!.memberSlotFrameByOwnerId.get(right.id)!; + + const nearest = resolveNearestSlotAssignment({ + ownerId: top.id, + ownerX: rightFrame.ownerX, + ownerY: rightFrame.ownerY, + nodes: [lead, top, right, bottom, left], + snapshot: snapshot!, + layout, + }); + + expect(nearest).not.toBeNull(); + expect(nearest?.assignment).toEqual({ ringIndex: 0, sectorIndex: 1 }); + expect(nearest?.displacedOwnerId).toBe(right.id); + expect(nearest?.displacedAssignment).toEqual({ ringIndex: 0, sectorIndex: 0 }); + }); + it('keeps nearest-slot drag resolution on the same central collision model as the planner', () => { const teamName = 'team-drag-central-collision'; const lead = createLead(teamName); diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index a7e9e286..787ef9af 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -221,7 +221,7 @@ describe('teamSlice actions', () => { expect(store.getState().warmTaskChangeSummaries).not.toHaveBeenCalled(); }); - it('commits an owner slot drop atomically even when prior assignments were sparse', () => { + it('commits owner slot drops in the current session while persistence is disabled', () => { const store = createSliceStore(); store.getState().commitTeamGraphOwnerSlotDrop( @@ -238,7 +238,7 @@ describe('teamSlice actions', () => { }); }); - it('migrates fallback name-based slot assignments to agentId-based stable owner ids', () => { + it('replaces persisted slot assignments with defaults while persistence is disabled', () => { const store = createSliceStore(); store.setState({ slotLayoutVersion: 'stable-slots-v1', @@ -255,7 +255,8 @@ describe('teamSlice actions', () => { ]); expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ - 'agent-alice': { ringIndex: 0, sectorIndex: 3 }, + 'agent-alice': { ringIndex: 0, sectorIndex: 0 }, + 'agent-bob': { ringIndex: 0, sectorIndex: 1 }, }); }); @@ -270,14 +271,33 @@ describe('teamSlice actions', () => { ]); expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ - 'agent-alice': { ringIndex: 0, sectorIndex: 5 }, + 'agent-alice': { ringIndex: 0, sectorIndex: 0 }, 'agent-bob': { ringIndex: 0, sectorIndex: 1 }, - 'agent-tom': { ringIndex: 0, sectorIndex: 4 }, - 'agent-jack': { ringIndex: 0, sectorIndex: 2 }, + 'agent-tom': { ringIndex: 0, sectorIndex: 2 }, + 'agent-jack': { ringIndex: 0, sectorIndex: 3 }, }); }); - it('seeds visible members even when only hidden owners have saved placements', () => { + it('ignores the lead member when deriving small-team cardinal defaults', () => { + const store = createSliceStore(); + + store.getState().ensureTeamGraphSlotAssignments('my-team', [ + { name: 'team-lead', agentId: 'lead-id' }, + { name: 'alice', agentId: 'agent-alice' }, + { name: 'bob', agentId: 'agent-bob' }, + { name: 'tom', agentId: 'agent-tom' }, + { name: 'jack', agentId: 'agent-jack' }, + ]); + + expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ + 'agent-alice': { ringIndex: 0, sectorIndex: 0 }, + 'agent-bob': { ringIndex: 0, sectorIndex: 1 }, + 'agent-tom': { ringIndex: 0, sectorIndex: 2 }, + 'agent-jack': { ringIndex: 0, sectorIndex: 3 }, + }); + }); + + it('drops hidden persisted slot assignments and reseeds visible members while persistence is disabled', () => { const store = createSliceStore(); store.setState({ slotLayoutVersion: 'stable-slots-v1', @@ -295,8 +315,7 @@ describe('teamSlice actions', () => { ]); expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ - 'agent-hidden': { ringIndex: 2, sectorIndex: 4 }, - 'agent-alice': { ringIndex: 0, sectorIndex: 5 }, + 'agent-alice': { ringIndex: 0, sectorIndex: 0 }, 'agent-bob': { ringIndex: 0, sectorIndex: 1 }, }); }); @@ -327,7 +346,7 @@ describe('teamSlice actions', () => { }); }); - it('keeps hidden-member slot assignments so the same stable owner can reuse them later', () => { + it('ignores hidden-member persisted slot assignments while persistence is disabled', () => { const store = createSliceStore(); store.setState({ slotLayoutVersion: 'stable-slots-v1', @@ -344,8 +363,77 @@ describe('teamSlice actions', () => { ]); expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ - 'agent-hidden': { ringIndex: 1, sectorIndex: 5 }, - 'agent-visible': { ringIndex: 0, sectorIndex: 2 }, + 'agent-visible': { ringIndex: 0, sectorIndex: 0 }, + }); + }); + + it('does not reseed a team again after defaults were applied once in the session', () => { + const store = createSliceStore(); + + store.getState().ensureTeamGraphSlotAssignments('my-team', [ + { name: 'alice', agentId: 'agent-alice' }, + { name: 'bob', agentId: 'agent-bob' }, + ]); + + store.getState().setTeamGraphOwnerSlotAssignment('my-team', 'agent-alice', { + ringIndex: 1, + sectorIndex: 4, + }); + + store.getState().ensureTeamGraphSlotAssignments('my-team', [ + { name: 'alice', agentId: 'agent-alice' }, + { name: 'bob', agentId: 'agent-bob' }, + ]); + + expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ + 'agent-alice': { ringIndex: 1, sectorIndex: 4 }, + 'agent-bob': { ringIndex: 0, sectorIndex: 1 }, + }); + }); + + it('resets graph slot assignments back to defaults when reopening the graph surface', () => { + const store = createSliceStore(); + store.setState({ + teamDataCacheByName: { + 'my-team': { + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [ + { name: 'alice', agentId: 'agent-alice' }, + { name: 'bob', agentId: 'agent-bob' }, + { name: 'tom', agentId: 'agent-tom' }, + { name: 'jack', agentId: 'agent-jack' }, + ], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }, + }, + }); + + store.getState().ensureTeamGraphSlotAssignments('my-team', [ + { name: 'alice', agentId: 'agent-alice' }, + { name: 'bob', agentId: 'agent-bob' }, + { name: 'tom', agentId: 'agent-tom' }, + { name: 'jack', agentId: 'agent-jack' }, + ]); + + store.getState().commitTeamGraphOwnerSlotDrop( + 'my-team', + 'agent-alice', + { ringIndex: 0, sectorIndex: 2 }, + 'agent-tom', + { ringIndex: 0, sectorIndex: 0 } + ); + + store.getState().resetTeamGraphSlotAssignmentsToDefaults('my-team'); + + expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({ + 'agent-alice': { ringIndex: 0, sectorIndex: 0 }, + 'agent-bob': { ringIndex: 0, sectorIndex: 1 }, + 'agent-tom': { ringIndex: 0, sectorIndex: 2 }, + 'agent-jack': { ringIndex: 0, sectorIndex: 3 }, }); }); From 345fd3e41d2e74848249ec865edf55d61ea1b451 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 13:16:28 +0300 Subject: [PATCH 6/6] fix(recent-projects): recover codex projects after degraded startup --- src/features/recent-projects/contracts/api.ts | 4 +- src/features/recent-projects/contracts/dto.ts | 5 ++ .../recent-projects/contracts/index.ts | 1 + .../recent-projects/contracts/normalize.ts | 31 +++++++ .../ListDashboardRecentProjectsResponse.ts | 1 + .../ports/RecentProjectsSourcePort.ts | 9 ++- .../ListDashboardRecentProjectsUseCase.ts | 32 ++++++-- .../input/http/registerRecentProjectsHttp.ts | 14 +++- .../input/ipc/registerRecentProjectsIpc.ts | 14 +++- .../DashboardRecentProjectsPresenter.ts | 38 +++++---- .../ClaudeRecentProjectsSourceAdapter.ts | 12 ++- .../CodexRecentProjectsSourceAdapter.ts | 28 +++++-- .../createRecentProjectsFeature.ts | 14 ++-- .../hooks/useRecentProjectsSection.ts | 40 +++++++++- .../utils/recentProjectsClientCache.ts | 43 ++++++---- src/renderer/api/httpClient.ts | 6 +- ...lizeDashboardRecentProjectsPayload.test.ts | 50 ++++++++++++ ...ListDashboardRecentProjectsUseCase.test.ts | 71 ++++++++++++++++ .../CodexRecentProjectsSourceAdapter.test.ts | 51 +++++++----- .../utils/recentProjectsClientCache.test.ts | 80 ++++++++++++++----- 20 files changed, 437 insertions(+), 107 deletions(-) create mode 100644 src/features/recent-projects/contracts/normalize.ts create mode 100644 test/features/recent-projects/contracts/normalizeDashboardRecentProjectsPayload.test.ts diff --git a/src/features/recent-projects/contracts/api.ts b/src/features/recent-projects/contracts/api.ts index 285ce11e..c1a74622 100644 --- a/src/features/recent-projects/contracts/api.ts +++ b/src/features/recent-projects/contracts/api.ts @@ -1,5 +1,5 @@ -import type { DashboardRecentProject } from './dto'; +import type { DashboardRecentProjectsPayload } from './dto'; export interface RecentProjectsElectronApi { - getDashboardRecentProjects(): Promise; + getDashboardRecentProjects(): Promise; } diff --git a/src/features/recent-projects/contracts/dto.ts b/src/features/recent-projects/contracts/dto.ts index 253ac36e..bdb4eda0 100644 --- a/src/features/recent-projects/contracts/dto.ts +++ b/src/features/recent-projects/contracts/dto.ts @@ -17,3 +17,8 @@ export interface DashboardRecentProject { openTarget: DashboardRecentProjectOpenTarget; primaryBranch?: string; } + +export interface DashboardRecentProjectsPayload { + projects: DashboardRecentProject[]; + degraded: boolean; +} diff --git a/src/features/recent-projects/contracts/index.ts b/src/features/recent-projects/contracts/index.ts index 69f32f5a..41e0bc74 100644 --- a/src/features/recent-projects/contracts/index.ts +++ b/src/features/recent-projects/contracts/index.ts @@ -1,3 +1,4 @@ export type * from './api'; export * from './channels'; export type * from './dto'; +export * from './normalize'; diff --git a/src/features/recent-projects/contracts/normalize.ts b/src/features/recent-projects/contracts/normalize.ts new file mode 100644 index 00000000..e38ce700 --- /dev/null +++ b/src/features/recent-projects/contracts/normalize.ts @@ -0,0 +1,31 @@ +import type { DashboardRecentProject, DashboardRecentProjectsPayload } from './dto'; + +export type DashboardRecentProjectsPayloadLike = + | DashboardRecentProjectsPayload + | DashboardRecentProject[] + | null + | undefined; + +export function normalizeDashboardRecentProjectsPayload( + value: DashboardRecentProjectsPayloadLike +): DashboardRecentProjectsPayload | null { + if (!value) { + return null; + } + + if (Array.isArray(value)) { + return { + projects: value, + degraded: false, + }; + } + + if (!Array.isArray(value.projects)) { + return null; + } + + return { + projects: value.projects, + degraded: value.degraded === true, + }; +} diff --git a/src/features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse.ts b/src/features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse.ts index 7016a81f..0800ee06 100644 --- a/src/features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse.ts +++ b/src/features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse.ts @@ -2,4 +2,5 @@ import type { RecentProjectAggregate } from '../../domain/models/RecentProjectAg export interface ListDashboardRecentProjectsResponse { projects: RecentProjectAggregate[]; + degraded: boolean; } diff --git a/src/features/recent-projects/core/application/ports/RecentProjectsSourcePort.ts b/src/features/recent-projects/core/application/ports/RecentProjectsSourcePort.ts index 004a8d72..cba03607 100644 --- a/src/features/recent-projects/core/application/ports/RecentProjectsSourcePort.ts +++ b/src/features/recent-projects/core/application/ports/RecentProjectsSourcePort.ts @@ -1,7 +1,14 @@ import type { RecentProjectCandidate } from '../../domain/models/RecentProjectCandidate'; +export interface RecentProjectsSourceResult { + candidates: RecentProjectCandidate[]; + degraded: boolean; +} + +export type RecentProjectsSourcePayload = RecentProjectsSourceResult | RecentProjectCandidate[]; + export interface RecentProjectsSourcePort { readonly sourceId?: string; readonly timeoutMs?: number; - list(): Promise; + list(): Promise; } diff --git a/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts index fefd56d8..348fc1b4 100644 --- a/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts +++ b/src/features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase.ts @@ -6,7 +6,11 @@ import type { ClockPort } from '../ports/ClockPort'; import type { ListDashboardRecentProjectsOutputPort } from '../ports/ListDashboardRecentProjectsOutputPort'; import type { LoggerPort } from '../ports/LoggerPort'; import type { RecentProjectsCachePort } from '../ports/RecentProjectsCachePort'; -import type { RecentProjectsSourcePort } from '../ports/RecentProjectsSourcePort'; +import type { + RecentProjectsSourcePayload, + RecentProjectsSourcePort, + RecentProjectsSourceResult, +} from '../ports/RecentProjectsSourcePort'; const DEFAULT_CACHE_TTL_MS = 10_000; const DEFAULT_DEGRADED_CACHE_TTL_MS = 1_500; @@ -16,6 +20,20 @@ interface SourceLoadResult { degraded: boolean; } +function normalizeSourcePayload(payload: RecentProjectsSourcePayload): RecentProjectsSourceResult { + if (Array.isArray(payload)) { + return { + candidates: payload, + degraded: false, + }; + } + + return { + candidates: payload.candidates, + degraded: payload.degraded === true, + }; +} + export interface ListDashboardRecentProjectsDeps { sources: RecentProjectsSourcePort[]; cache: RecentProjectsCachePort; @@ -66,6 +84,7 @@ export class ListDashboardRecentProjectsUseCase { const hasDegradedSources = results.some((result) => result.degraded); const response: ListDashboardRecentProjectsResponse = { projects: mergeRecentProjectCandidates(successful), + degraded: hasDegradedSources, }; if (hasDegradedSources && stale && response.projects.length === 0) { @@ -111,10 +130,10 @@ export class ListDashboardRecentProjectsUseCase { source .list() .then( - (candidates) => + (payload) => ({ kind: 'success', - candidates, + payload: normalizeSourcePayload(payload), }) as const ) .catch( @@ -130,7 +149,7 @@ export class ListDashboardRecentProjectsUseCase { ]); if (result.kind === 'success') { - return { candidates: result.candidates, degraded: false }; + return result.payload; } if (result.kind === 'timeout') { @@ -161,10 +180,7 @@ export class ListDashboardRecentProjectsUseCase { sourceIndex: number ): Promise { try { - return { - candidates: await source.list(), - degraded: false, - }; + return normalizeSourcePayload(await source.list()); } catch (error) { this.deps.logger.warn('recent-projects source failed', { sourceId, diff --git a/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts b/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts index 991cab02..ac3001f0 100644 --- a/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts +++ b/src/features/recent-projects/main/adapters/input/http/registerRecentProjectsHttp.ts @@ -1,6 +1,7 @@ import { DASHBOARD_RECENT_PROJECTS_ROUTE, - type DashboardRecentProject, + normalizeDashboardRecentProjectsPayload, + type DashboardRecentProjectsPayload, } from '@features/recent-projects/contracts'; import { createLogger } from '@shared/utils/logger'; @@ -13,12 +14,17 @@ export function registerRecentProjectsHttp( app: FastifyInstance, feature: RecentProjectsFeatureFacade ): void { - app.get(DASHBOARD_RECENT_PROJECTS_ROUTE, async (): Promise => { + app.get(DASHBOARD_RECENT_PROJECTS_ROUTE, async (): Promise => { try { - return await feature.listDashboardRecentProjects(); + return ( + normalizeDashboardRecentProjectsPayload(await feature.listDashboardRecentProjects()) ?? { + projects: [], + degraded: true, + } + ); } catch (error) { logger.error('Failed to load dashboard recent projects via HTTP', error); - return []; + return { projects: [], degraded: true }; } }); } diff --git a/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts b/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts index 906c97b5..a18ea436 100644 --- a/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts +++ b/src/features/recent-projects/main/adapters/input/ipc/registerRecentProjectsIpc.ts @@ -1,4 +1,7 @@ -import { GET_DASHBOARD_RECENT_PROJECTS } from '@features/recent-projects/contracts'; +import { + GET_DASHBOARD_RECENT_PROJECTS, + normalizeDashboardRecentProjectsPayload, +} from '@features/recent-projects/contracts'; import { createLogger } from '@shared/utils/logger'; import type { RecentProjectsFeatureFacade } from '@features/recent-projects/main/composition/createRecentProjectsFeature'; @@ -12,10 +15,15 @@ export function registerRecentProjectsIpc( ): void { ipcMain.handle(GET_DASHBOARD_RECENT_PROJECTS, async () => { try { - return await feature.listDashboardRecentProjects(); + return ( + normalizeDashboardRecentProjectsPayload(await feature.listDashboardRecentProjects()) ?? { + projects: [], + degraded: true, + } + ); } catch (error) { logger.error('Failed to load dashboard recent projects via IPC', error); - return []; + return { projects: [], degraded: true }; } }); } diff --git a/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts b/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts index 638c662e..c9a3e5fb 100644 --- a/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts +++ b/src/features/recent-projects/main/adapters/output/presenters/DashboardRecentProjectsPresenter.ts @@ -1,21 +1,27 @@ -import type { DashboardRecentProject } from '@features/recent-projects/contracts'; +import type { + DashboardRecentProject, + DashboardRecentProjectsPayload, +} from '@features/recent-projects/contracts'; import type { ListDashboardRecentProjectsResponse } from '@features/recent-projects/core/application/models/ListDashboardRecentProjectsResponse'; import type { ListDashboardRecentProjectsOutputPort } from '@features/recent-projects/core/application/ports/ListDashboardRecentProjectsOutputPort'; -export class DashboardRecentProjectsPresenter implements ListDashboardRecentProjectsOutputPort< - DashboardRecentProject[] -> { - present(response: ListDashboardRecentProjectsResponse): DashboardRecentProject[] { - return response.projects.map((aggregate) => ({ - id: aggregate.identity, - name: aggregate.displayName, - primaryPath: aggregate.primaryPath, - associatedPaths: aggregate.associatedPaths, - mostRecentActivity: aggregate.lastActivityAt, - providerIds: aggregate.providerIds, - source: aggregate.source, - openTarget: aggregate.openTarget, - primaryBranch: aggregate.branchName, - })); +export class DashboardRecentProjectsPresenter implements ListDashboardRecentProjectsOutputPort { + present(response: ListDashboardRecentProjectsResponse): DashboardRecentProjectsPayload { + return { + degraded: response.degraded, + projects: response.projects.map( + (aggregate): DashboardRecentProject => ({ + id: aggregate.identity, + name: aggregate.displayName, + primaryPath: aggregate.primaryPath, + associatedPaths: aggregate.associatedPaths, + mostRecentActivity: aggregate.lastActivityAt, + providerIds: aggregate.providerIds, + source: aggregate.source, + openTarget: aggregate.openTarget, + primaryBranch: aggregate.branchName, + }) + ), + }; } } diff --git a/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts index 2d5aa3c5..700b9122 100644 --- a/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/ClaudeRecentProjectsSourceAdapter.ts @@ -3,7 +3,10 @@ import { WorktreeGrouper } from '@main/services/discovery/WorktreeGrouper'; import { getProjectsBasePath } from '@main/utils/pathDecoder'; import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort'; -import type { RecentProjectsSourcePort } from '@features/recent-projects/core/application/ports/RecentProjectsSourcePort'; +import type { + RecentProjectsSourcePort, + RecentProjectsSourceResult, +} from '@features/recent-projects/core/application/ports/RecentProjectsSourcePort'; import type { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate'; import type { ServiceContext } from '@main/services'; import type { RepositoryGroup, Worktree } from '@main/types'; @@ -47,7 +50,7 @@ export class ClaudeRecentProjectsSourceAdapter implements RecentProjectsSourcePo private readonly logger: LoggerPort ) {} - async list(): Promise { + async list(): Promise { const activeContext = this.getActiveContext(); const groups = activeContext.type === 'local' @@ -63,7 +66,10 @@ export class ClaudeRecentProjectsSourceAdapter implements RecentProjectsSourcePo contextId: activeContext.id, }); - return candidates; + return { + candidates, + degraded: false, + }; } async #groupLocalProjects(activeContext: ServiceContext): Promise { diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts index fa0af4a9..f597fcfe 100644 --- a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts @@ -2,7 +2,10 @@ import { normalizeIdentityPath } from '@features/recent-projects/main/infrastruc import path from 'path'; import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort'; -import type { RecentProjectsSourcePort } from '@features/recent-projects/core/application/ports/RecentProjectsSourcePort'; +import type { + RecentProjectsSourcePort, + RecentProjectsSourceResult, +} from '@features/recent-projects/core/application/ports/RecentProjectsSourcePort'; import type { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate'; import type { CodexAppServerClient, @@ -34,6 +37,10 @@ function normalizeTimestamp(value: number | undefined): number { return value < 1_000_000_000_000 ? value * 1000 : value; } +function isDegradedThreadResult(result: CodexRecentThreadsResult): boolean { + return Boolean(result.live.error || result.archived.error); +} + export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePort { readonly sourceId = 'codex'; readonly timeoutMs = CODEX_SOURCE_TIMEOUT_MS; @@ -49,21 +56,28 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor } ) {} - async list(): Promise { + async list(): Promise { const activeContext = this.deps.getActiveContext(); const localContext = this.deps.getLocalContext(); if (activeContext.type !== 'local' || activeContext.id !== localContext?.id) { - return []; + return { + candidates: [], + degraded: false, + }; } const binaryPath = await this.deps.resolveBinary(); if (!binaryPath) { this.deps.logger.info('codex recent-projects source skipped - binary unavailable'); - return []; + return { + candidates: [], + degraded: false, + }; } const threadSegments = await this.#listRecentThreadsSafe(binaryPath); + const degraded = isDegradedThreadResult(threadSegments); this.#logSegmentFailure(threadSegments, 'live'); this.#logSegmentFailure(threadSegments, 'archived'); const liveThreads = threadSegments.live.threads; @@ -79,9 +93,13 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor this.deps.logger.info('codex recent-projects source loaded', { count: candidates.length, + degraded, }); - return candidates; + return { + candidates, + degraded, + }; } async #listRecentThreads(binaryPath: string): Promise { diff --git a/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts index effb5a8c..f77986e8 100644 --- a/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts +++ b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts @@ -10,11 +10,14 @@ import { RecentProjectIdentityResolver } from '../infrastructure/identity/Recent import type { ClockPort } from '../../core/application/ports/ClockPort'; import type { LoggerPort } from '../../core/application/ports/LoggerPort'; -import type { DashboardRecentProject } from '@features/recent-projects/contracts'; +import { + normalizeDashboardRecentProjectsPayload, + type DashboardRecentProjectsPayload, +} from '@features/recent-projects/contracts'; import type { ServiceContext } from '@main/services'; export interface RecentProjectsFeatureFacade { - listDashboardRecentProjects(): Promise; + listDashboardRecentProjects(): Promise; } export function createRecentProjectsFeature(deps: { @@ -22,7 +25,7 @@ export function createRecentProjectsFeature(deps: { getLocalContext: () => ServiceContext | undefined; logger: LoggerPort; }): RecentProjectsFeatureFacade { - const cache = new InMemoryRecentProjectsCache(); + const cache = new InMemoryRecentProjectsCache(); const presenter = new DashboardRecentProjectsPresenter(); const clock: ClockPort = { now: () => Date.now() }; const jsonRpcStdioClient = new JsonRpcStdioClient(deps.logger); @@ -48,9 +51,10 @@ export function createRecentProjectsFeature(deps: { }); return { - listDashboardRecentProjects: () => { + listDashboardRecentProjects: async () => { const activeContext = deps.getActiveContext(); - return useCase.execute(`dashboard-recent-projects:${activeContext.id}`); + const payload = await useCase.execute(`dashboard-recent-projects:${activeContext.id}`); + return normalizeDashboardRecentProjectsPayload(payload) ?? { projects: [], degraded: true }; }, }; } diff --git a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts index 63d2c223..baa2f48b 100644 --- a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts +++ b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts @@ -23,6 +23,9 @@ import type { TeamSummary } from '@shared/types'; const INITIAL_RECENT_PROJECTS = 11; const LOAD_MORE_STEP = 8; +const DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS = 1_500; +const DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS = 5_000; +const DEGRADED_RECENT_PROJECTS_FAST_RETRY_LIMIT = 3; function matchesSearch(project: DashboardRecentProject, query: string): boolean { if (!query) { @@ -72,7 +75,13 @@ export function useRecentProjectsSection( const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []); const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject(); const [recentProjects, setRecentProjects] = useState( - initialSnapshot?.projects ?? [] + initialSnapshot?.payload.projects ?? [] + ); + const [recentProjectsDegraded, setRecentProjectsDegraded] = useState( + initialSnapshot?.payload.degraded ?? false + ); + const [degradedRefreshCount, setDegradedRefreshCount] = useState( + initialSnapshot?.payload.degraded ? 1 : 0 ); const [loading, setLoading] = useState(initialSnapshot == null); const [error, setError] = useState(null); @@ -80,7 +89,9 @@ export function useRecentProjectsSection( const [aliveTeams, setAliveTeams] = useState([]); const [openHistoryVersion, setOpenHistoryVersion] = useState(0); const hasFetchedTasksRef = useRef(globalTasksInitialized); - const recentProjectsRef = useRef(initialSnapshot?.projects ?? []); + const recentProjectsRef = useRef( + initialSnapshot?.payload.projects ?? [] + ); useEffect(() => { recentProjectsRef.current = recentProjects; @@ -95,11 +106,13 @@ export function useRecentProjectsSection( } setError(null); try { - const projects = await loadRecentProjectsWithClientCache( + const payload = await loadRecentProjectsWithClientCache( () => api.getDashboardRecentProjects(), options ); - setRecentProjects(projects); + setRecentProjects(payload.projects); + setRecentProjectsDegraded(payload.degraded); + setDegradedRefreshCount((current) => (payload.degraded ? current + 1 : 0)); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : 'Failed to load recent projects'); } finally { @@ -116,6 +129,25 @@ export function useRecentProjectsSection( void reload({ force: snapshot != null }); }, [reload]); + useEffect(() => { + if (!recentProjectsDegraded) { + return; + } + + const delayMs = + degradedRefreshCount <= DEGRADED_RECENT_PROJECTS_FAST_RETRY_LIMIT + ? DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS + : DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS; + + const timer = window.setTimeout(() => { + void reload({ force: true }); + }, delayMs); + + return () => { + window.clearTimeout(timer); + }; + }, [degradedRefreshCount, recentProjectsDegraded, reload]); + useEffect(() => { if (recentProjects.length === 0 || hasFetchedTasksRef.current || globalTasksInitialized) { hasFetchedTasksRef.current = hasFetchedTasksRef.current || globalTasksInitialized; diff --git a/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts b/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts index e28d89b2..dc804d0d 100644 --- a/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts +++ b/src/features/recent-projects/renderer/utils/recentProjectsClientCache.ts @@ -1,38 +1,52 @@ -import type { DashboardRecentProject } from '@features/recent-projects/contracts'; +import type { + DashboardRecentProjectsPayloadLike, + DashboardRecentProjectsPayload, +} from '@features/recent-projects/contracts'; +import { normalizeDashboardRecentProjectsPayload } from '@features/recent-projects/contracts'; const RECENT_PROJECTS_CLIENT_CACHE_TTL_MS = 15_000; +const RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS = 1_500; -let cachedProjects: DashboardRecentProject[] | null = null; +let cachedPayload: DashboardRecentProjectsPayloadLike = null; let cachedAt = 0; -let inFlightLoad: Promise | null = null; +let inFlightLoad: Promise | null = null; export interface RecentProjectsClientSnapshot { - projects: DashboardRecentProject[]; + payload: DashboardRecentProjectsPayload; fetchedAt: number; isStale: boolean; } export function getRecentProjectsClientSnapshot(): RecentProjectsClientSnapshot | null { - if (!cachedProjects) { + const normalizedPayload = normalizeDashboardRecentProjectsPayload(cachedPayload); + if (!normalizedPayload) { return null; } + if (cachedPayload !== normalizedPayload) { + cachedPayload = normalizedPayload; + } + + const ttlMs = normalizedPayload.degraded + ? RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS + : RECENT_PROJECTS_CLIENT_CACHE_TTL_MS; + return { - projects: cachedProjects, + payload: normalizedPayload, fetchedAt: cachedAt, - isStale: Date.now() - cachedAt > RECENT_PROJECTS_CLIENT_CACHE_TTL_MS, + isStale: Date.now() - cachedAt > ttlMs, }; } export async function loadRecentProjectsWithClientCache( - loader: () => Promise, + loader: () => Promise, options?: { force?: boolean } -): Promise { +): Promise { const force = options?.force ?? false; const snapshot = getRecentProjectsClientSnapshot(); if (!force && snapshot && !snapshot.isStale) { - return snapshot.projects; + return snapshot.payload; } if (inFlightLoad) { @@ -40,10 +54,11 @@ export async function loadRecentProjectsWithClientCache( } const request = loader() - .then((projects) => { - cachedProjects = projects; + .then((payloadLike) => { + const normalizedPayload = normalizeDashboardRecentProjectsPayload(payloadLike); + cachedPayload = normalizedPayload; cachedAt = Date.now(); - return projects; + return normalizedPayload ?? { projects: [], degraded: true }; }) .finally(() => { if (inFlightLoad === request) { @@ -56,7 +71,7 @@ export async function loadRecentProjectsWithClientCache( } export function __resetRecentProjectsClientCacheForTests(): void { - cachedProjects = null; + cachedPayload = null; cachedAt = 0; inFlightLoad = null; } diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 5f2df4aa..d3983c79 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -6,7 +6,7 @@ * to run in a regular browser connected to an HTTP server. */ -import type { DashboardRecentProject } from '@features/recent-projects/contracts'; +import type { DashboardRecentProjectsPayload } from '@features/recent-projects/contracts'; import type { AppConfig, AttachmentFileData, @@ -217,8 +217,8 @@ export class HttpAPIClient implements ElectronAPI { getAppVersion = (): Promise => this.get('/api/version'); - getDashboardRecentProjects = (): Promise => - this.get('/api/dashboard/recent-projects'); + getDashboardRecentProjects = (): Promise => + this.get('/api/dashboard/recent-projects'); getProjects = (): Promise => this.get('/api/projects'); diff --git a/test/features/recent-projects/contracts/normalizeDashboardRecentProjectsPayload.test.ts b/test/features/recent-projects/contracts/normalizeDashboardRecentProjectsPayload.test.ts new file mode 100644 index 00000000..de8345a6 --- /dev/null +++ b/test/features/recent-projects/contracts/normalizeDashboardRecentProjectsPayload.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { + normalizeDashboardRecentProjectsPayload, + type DashboardRecentProject, +} from '@features/recent-projects/contracts'; + +const project = (id: string): DashboardRecentProject => ({ + id, + name: id, + primaryPath: `/tmp/${id}`, + associatedPaths: [`/tmp/${id}`], + mostRecentActivity: Date.parse('2026-04-14T12:00:00.000Z'), + providerIds: ['anthropic'], + source: 'claude', + openTarget: { + type: 'synthetic-path', + path: `/tmp/${id}`, + }, +}); + +describe('normalizeDashboardRecentProjectsPayload', () => { + it('keeps payload objects intact except for degraded normalization', () => { + expect( + normalizeDashboardRecentProjectsPayload({ + degraded: true, + projects: [project('alpha')], + }) + ).toEqual({ + degraded: true, + projects: [project('alpha')], + }); + }); + + it('normalizes legacy project arrays into healthy payloads', () => { + expect(normalizeDashboardRecentProjectsPayload([project('alpha')])).toEqual({ + degraded: false, + projects: [project('alpha')], + }); + }); + + it('returns null for malformed payloads', () => { + expect( + normalizeDashboardRecentProjectsPayload({ + degraded: false, + projects: null, + } as unknown as { degraded: boolean; projects: null }) + ).toBeNull(); + }); +}); diff --git a/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts b/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts index 14fedfc6..b68d88f3 100644 --- a/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts +++ b/test/features/recent-projects/core/application/ListDashboardRecentProjectsUseCase.test.ts @@ -140,6 +140,7 @@ describe('ListDashboardRecentProjectsUseCase', () => { sources: ['mixed'], }); expect(output.present).toHaveBeenCalledWith({ + degraded: true, projects: [ expect.objectContaining({ identity: 'repo:alpha', @@ -299,6 +300,7 @@ describe('ListDashboardRecentProjectsUseCase', () => { sources: ['claude'], }); expect(output.present).toHaveBeenCalledWith({ + degraded: true, projects: [ expect.objectContaining({ identity: 'repo:fresh', @@ -370,4 +372,73 @@ describe('ListDashboardRecentProjectsUseCase', () => { durationMs: 200, }); }); + + it('treats explicitly degraded source payloads as degraded even when they resolve successfully', async () => { + const cache: RecentProjectsCachePort = { + get: vi.fn().mockResolvedValue(null), + getStale: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + }; + const output: ListDashboardRecentProjectsOutputPort = { + present: vi.fn((response: ListDashboardRecentProjectsResponse) => ({ + ids: response.projects.map((project) => project.identity), + sources: response.projects.map((project) => project.source), + })), + }; + const sources: RecentProjectsSourcePort[] = [ + { + sourceId: 'claude', + list: vi.fn().mockResolvedValue([ + makeCandidate({ + identity: 'repo:alpha', + providerIds: ['anthropic'], + sourceKind: 'claude', + }), + ]), + }, + { + sourceId: 'codex', + list: vi.fn().mockResolvedValue({ + candidates: [], + degraded: true, + }), + }, + ]; + const logger = createLogger(); + + const useCase = new ListDashboardRecentProjectsUseCase({ + sources, + cache, + output, + clock: { now: () => 25_000 }, + logger, + }); + + await expect(useCase.execute('recent-projects:explicit-degraded')).resolves.toEqual({ + ids: ['repo:alpha'], + sources: ['claude'], + }); + + expect(output.present).toHaveBeenCalledWith({ + degraded: true, + projects: [ + expect.objectContaining({ + identity: 'repo:alpha', + source: 'claude', + }), + ], + }); + expect(cache.set).toHaveBeenCalledWith( + 'recent-projects:explicit-degraded', + { ids: ['repo:alpha'], sources: ['claude'] }, + 1_500 + ); + expect(logger.info).toHaveBeenCalledWith('recent-projects loaded', { + cacheKey: 'recent-projects:explicit-degraded', + count: 1, + degradedSources: 1, + cacheTtlMs: 1_500, + durationMs: 0, + }); + }); }); diff --git a/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts b/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts index d963ae88..40b2f9ee 100644 --- a/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts +++ b/test/features/recent-projects/main/adapters/output/CodexRecentProjectsSourceAdapter.test.ts @@ -57,12 +57,15 @@ describe('CodexRecentProjectsSourceAdapter', () => { logger, }); - await expect(adapter.list()).resolves.toEqual([ - expect.objectContaining({ - identity: 'repo:headless', - primaryPath: '/Users/belief/dev/projects/headless', - }), - ]); + await expect(adapter.list()).resolves.toEqual({ + candidates: [ + expect.objectContaining({ + identity: 'repo:headless', + primaryPath: '/Users/belief/dev/projects/headless', + }), + ], + degraded: true, + }); expect(logger.info).toHaveBeenCalledWith( 'codex recent-projects archived thread list degraded', @@ -110,20 +113,23 @@ describe('CodexRecentProjectsSourceAdapter', () => { logger, }); - await expect(adapter.list()).resolves.toEqual([ - expect.objectContaining({ - identity: 'repo:headless', - displayName: 'headless', - primaryPath: '/Users/belief/dev/projects/headless', - providerIds: ['codex'], - sourceKind: 'codex', - openTarget: { - type: 'synthetic-path', - path: '/Users/belief/dev/projects/headless', - }, - branchName: 'main', - }), - ]); + await expect(adapter.list()).resolves.toEqual({ + candidates: [ + expect.objectContaining({ + identity: 'repo:headless', + displayName: 'headless', + primaryPath: '/Users/belief/dev/projects/headless', + providerIds: ['codex'], + sourceKind: 'codex', + openTarget: { + type: 'synthetic-path', + path: '/Users/belief/dev/projects/headless', + }, + branchName: 'main', + }), + ], + degraded: true, + }); expect(appServerClient.listRecentThreads).toHaveBeenCalledTimes(1); expect(appServerClient.listRecentLiveThreads).toHaveBeenCalledTimes(1); @@ -153,7 +159,10 @@ describe('CodexRecentProjectsSourceAdapter', () => { logger, }); - await expect(adapter.list()).resolves.toEqual([]); + await expect(adapter.list()).resolves.toEqual({ + candidates: [], + degraded: true, + }); expect(appServerClient.listRecentLiveThreads).not.toHaveBeenCalled(); }); }); diff --git a/test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts b/test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts index 7f9acf74..d23e49a9 100644 --- a/test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts +++ b/test/features/recent-projects/renderer/utils/recentProjectsClientCache.test.ts @@ -6,7 +6,10 @@ import { loadRecentProjectsWithClientCache, } from '@features/recent-projects/renderer/utils/recentProjectsClientCache'; -import type { DashboardRecentProject } from '@features/recent-projects/contracts'; +import type { + DashboardRecentProject, + DashboardRecentProjectsPayload, +} from '@features/recent-projects/contracts'; const project = (id: string): DashboardRecentProject => ({ id, @@ -22,6 +25,15 @@ const project = (id: string): DashboardRecentProject => ({ }, }); +const payload = ( + id: string, + overrides: Partial = {} +): DashboardRecentProjectsPayload => ({ + projects: [project(id)], + degraded: false, + ...overrides, +}); + describe('recentProjectsClientCache', () => { afterEach(() => { __resetRecentProjectsClientCacheForTests(); @@ -30,13 +42,13 @@ describe('recentProjectsClientCache', () => { }); it('returns cached projects while the client cache is fresh', async () => { - const loader = vi.fn().mockResolvedValue([project('alpha')]); + const loader = vi.fn().mockResolvedValue(payload('alpha')); - await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual([project('alpha')]); - await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual([project('alpha')]); + await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha')); + await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha')); expect(loader).toHaveBeenCalledTimes(1); - expect(getRecentProjectsClientSnapshot()?.projects).toEqual([project('alpha')]); + expect(getRecentProjectsClientSnapshot()?.payload).toEqual(payload('alpha')); }); it('revalidates stale cache without dropping the previous snapshot', async () => { @@ -44,38 +56,38 @@ describe('recentProjectsClientCache', () => { vi.setSystemTime(new Date('2026-04-14T12:00:00.000Z')); const loader = vi - .fn<() => Promise>() - .mockResolvedValueOnce([project('alpha')]) - .mockResolvedValueOnce([project('beta')]); + .fn<() => Promise>() + .mockResolvedValueOnce(payload('alpha')) + .mockResolvedValueOnce(payload('beta')); await loadRecentProjectsWithClientCache(loader); vi.setSystemTime(new Date('2026-04-14T12:00:16.000Z')); expect(getRecentProjectsClientSnapshot()).toMatchObject({ - projects: [project('alpha')], + payload: payload('alpha'), isStale: true, }); - await expect(loadRecentProjectsWithClientCache(loader, { force: true })).resolves.toEqual([ - project('beta'), - ]); + await expect(loadRecentProjectsWithClientCache(loader, { force: true })).resolves.toEqual( + payload('beta') + ); expect(loader).toHaveBeenCalledTimes(2); expect(getRecentProjectsClientSnapshot()).toMatchObject({ - projects: [project('beta')], + payload: payload('beta'), isStale: false, }); }); it('deduplicates concurrent client refreshes', async () => { const resolveLoaderRef: { - current: ((projects: DashboardRecentProject[]) => void) | null; + current: ((payload: DashboardRecentProjectsPayload) => void) | null; } = { current: null, }; const loader = vi.fn( () => - new Promise((resolve) => { + new Promise((resolve) => { resolveLoaderRef.current = resolve; }) ); @@ -85,9 +97,41 @@ describe('recentProjectsClientCache', () => { expect(loader).toHaveBeenCalledTimes(1); - resolveLoaderRef.current?.([project('alpha')]); + resolveLoaderRef.current?.(payload('alpha')); - await expect(first).resolves.toEqual([project('alpha')]); - await expect(second).resolves.toEqual([project('alpha')]); + await expect(first).resolves.toEqual(payload('alpha')); + await expect(second).resolves.toEqual(payload('alpha')); + }); + + it('marks degraded payload snapshots stale faster than healthy payloads', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-14T12:00:00.000Z')); + + const loader = vi + .fn<() => Promise>() + .mockResolvedValueOnce(payload('alpha', { degraded: true })); + + await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual( + payload('alpha', { degraded: true }) + ); + + vi.setSystemTime(new Date('2026-04-14T12:00:01.000Z')); + expect(getRecentProjectsClientSnapshot()).toMatchObject({ + payload: payload('alpha', { degraded: true }), + isStale: false, + }); + + vi.setSystemTime(new Date('2026-04-14T12:00:02.000Z')); + expect(getRecentProjectsClientSnapshot()).toMatchObject({ + payload: payload('alpha', { degraded: true }), + isStale: true, + }); + }); + + it('normalizes legacy array responses from the loader during mixed-version dev reloads', async () => { + const loader = vi.fn<() => Promise>().mockResolvedValue([project('alpha')]); + + await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha')); + expect(getRecentProjectsClientSnapshot()?.payload).toEqual(payload('alpha')); }); });