From 2cfbfef3b320766f25e4c55ee69a5560502fa839 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 15 Apr 2026 17:38:21 +0300 Subject: [PATCH 01/21] 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 1173a4942ac3a0c42f5f31d49e1ebdeca5de20e7 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 15 Apr 2026 21:54:38 +0300 Subject: [PATCH 02/21] refactor(team): split team detail snapshot from messages activity --- ...-detail-snapshot-messages-activity-plan.md | 3221 +++++++++++++++++ .../renderer/adapters/TeamGraphAdapter.ts | 53 +- .../renderer/hooks/useGraphActivityContext.ts | 31 +- .../hooks/useGraphCreateTaskDialog.tsx | 16 +- .../hooks/useGraphMemberPopoverContext.ts | 31 +- .../renderer/hooks/useTeamGraphAdapter.ts | 22 +- .../renderer/ui/GraphActivityHud.tsx | 23 +- .../renderer/ui/GraphNodePopover.tsx | 15 +- src/main/index.ts | 13 + src/main/ipc/teams.ts | 190 +- .../team/MemberActivityMetaService.ts | 128 + src/main/services/team/TeamDataService.ts | 393 +- .../services/team/TeamDataWorkerClient.ts | 36 +- src/main/services/team/TeamMemberResolver.ts | 66 +- .../services/team/TeamMessageFeedService.ts | 409 +++ src/main/services/team/teamDataWorkerTypes.ts | 27 +- src/main/workers/team-data-worker.ts | 13 + src/preload/constants/ipcChannels.ts | 3 + src/preload/index.ts | 11 +- src/renderer/api/httpClient.ts | 17 +- .../components/chat/UserChatGroup.tsx | 3 +- .../chat/items/TeammateMessageItem.tsx | 5 +- src/renderer/components/team/TaskTooltip.tsx | 25 +- .../components/team/TeamDetailView.tsx | 74 +- src/renderer/components/team/TeamListView.tsx | 14 +- .../components/team/ToolApprovalSheet.tsx | 7 +- .../team/dialogs/GlobalTaskDetailDialog.tsx | 7 +- .../team/dialogs/LaunchTeamDialog.tsx | 7 +- .../team/members/MemberDetailDialog.tsx | 37 +- .../team/members/MemberHoverCard.tsx | 47 +- .../team/members/MemberMessagesTab.tsx | 88 +- .../team/messages/MessagesPanel.tsx | 87 +- .../components/team/provisioningSteps.ts | 8 +- .../team/useTeamProvisioningPresentation.ts | 4 +- src/renderer/hooks/useTaskSuggestions.ts | 26 +- src/renderer/store/index.ts | 130 +- src/renderer/store/slices/teamSlice.ts | 1117 +++++- .../utils/teamProvisioningPresentation.ts | 8 +- src/shared/types/api.ts | 8 +- src/shared/types/team.ts | 43 +- test/main/ipc/teams.test.ts | 246 +- .../services/team/TeamDataService.test.ts | 220 +- .../services/team/TeamMemberResolver.test.ts | 35 +- .../team/TeamProvisioningBanner.test.ts | 10 + .../team/members/MemberHoverCard.test.ts | 10 + .../team/members/MemberMessagesTab.test.ts | 64 +- .../team/messages/MessagesPanel.test.ts | 53 +- .../agent-graph/GraphActivityHud.test.ts | 10 +- .../agent-graph/TeamGraphAdapter.test.ts | 16 +- .../buildInlineActivityEntries.test.ts | 16 +- .../renderer/store/teamChangeThrottle.test.ts | 132 +- test/renderer/store/teamSlice.test.ts | 928 ++++- .../teamProvisioningPresentation.test.ts | 6 - 53 files changed, 6990 insertions(+), 1219 deletions(-) create mode 100644 docs/research/team-detail-snapshot-messages-activity-plan.md create mode 100644 src/main/services/team/MemberActivityMetaService.ts create mode 100644 src/main/services/team/TeamMessageFeedService.ts diff --git a/docs/research/team-detail-snapshot-messages-activity-plan.md b/docs/research/team-detail-snapshot-messages-activity-plan.md new file mode 100644 index 00000000..67f39d77 --- /dev/null +++ b/docs/research/team-detail-snapshot-messages-activity-plan.md @@ -0,0 +1,3221 @@ +# План: TeamDetail Snapshot / Messages / Member Activity Split + +**Дата**: 2026-04-15 +**Статус**: Detailed execution-ready architecture plan +**Цель**: убрать structural render churn из `TeamDetailView` и отделить message-heavy данные от structural snapshot команды + +## Executive Summary + +Выбранный вариант: + +`Split TeamDetail data flow into structural snapshot + paginated messages + member activity meta` +`🎯 9 🛡️ 9 🧠 8` +Примерно `1600-2600` строк изменений + +Это не "ещё один локальный guard", а нормализация границ данных: + +- `getData(teamName)` перестаёт быть transport для message-heavy UI +- `getMessagesPage(teamName, { limit, cursor })` остаётся единственным сообщенческим feed API +- добавляется новый IPC endpoint `getMemberActivityMeta(teamName)` +- renderer хранит structural snapshot отдельно от message cache +- `refreshTeamData()` получает structural sharing + no-op suppression даже после split + +Самое важное: + +- текущий semantic-equality guard перед `set()` - правильная мысль, но это только часть решения +- если сделать только guard, можно снять текущий crash, но архитектурная сцепка `TeamData <-> messages <-> TeamDetailView` останется +- если сделать split + guard вместе, это уже похоже на правильный долгоживущий вариант + +## Quick Execution Path + +Если исполнитель не хочет читать весь документ линейно, безопасный порядок такой: + +1. Сначала новые shared contracts и worker ops. +2. Потом `TeamMessageFeedService` с stable effective identity и `feedRevision`. +3. Потом structural `getData()` и отдельный `MemberActivityMetaService`. +4. Потом store ownership for messages/meta, single-flight и stale-response guards. +5. Потом migration consumers: `MessagesPanel`, `ActivityTimeline`, `MemberDetailDialog`, `MemberMessagesTab`, `MemberHoverCard`, `StatusBlock`, `TeamDetailView`, graph. +6. Потом event routing split. +7. Потом structural sharing + no-op suppression. +8. Только после этого выпиливать legacy fields и compatibility plumbing. + +Неправильный порядок, которого надо избегать: + +1. Сначала менять UI consumers, пока feed/meta/store contracts ещё не зафиксированы. +2. Сначала удалять `TeamData.messages`, пока graph/dialog/messages consumers ещё на нём сидят. +3. Сначала добавлять polling в store без single-flight/coalescing. + +## Locked Decisions + +Ниже решения, которые в этом плане считаются **закрытыми**, а не оставленными "на потом". + +### 1. Naming and transport + +- IPC route name **не меняем**: остаётся `team:getData` +- public method name **не меняем**: остаётся `getData(teamName)` +- но тип ответа **меняем** на новый structural contract `TeamViewSnapshot` +- repo-wide alias вида `type TeamData = TeamViewSnapshot` **не оставляем** после merge + +Причина: + +- transport rename сейчас только раздует diff +- а вот новый тип нужен, чтобы код и тесты перестали мыслить `getData()` как message transport + +Допустимо локально во время промежуточной сборки держать временный compatibility alias, но в merged коде его быть не должно. + +Дополнительное правило: + +- если temporary compatibility alias или adapter переживает тот commit slice, в котором переводится его последний consumer, это уже smell и план выполняется неверно + +### 2. Snapshot is structural only + +- merged код **не должен** читать `messages` из snapshot +- `messages` больше не часть `TeamViewSnapshot` +- `members` в snapshot больше не считаются от full message history + +### 3. Message ownership + +- единственный message feed API в этом PR - `getMessagesPage()` +- новый отдельный `getMessagesHead()` в этом PR **не добавляем** +- если понадобится оптимизация, делаем её **внутри** `getMessagesPage()` или store caching, без второго transport contract +- existing `MessagesPage` contract расширяем полем `feedRevision` +- store action `refreshTeamMessagesHead()` должен возвращать semantic result c минимум двумя флагами: + - `feedChanged` - изменился ли revision всего normalized feed + - `headChanged` - изменился ли реально текущий canonical head slice в store +- но hot path `getMessagesPage()` при этом **обязательно** должен перестать быть full rescan/full normalize на каждый вызов +- для этого в main добавляется shared normalized message feed cache/index, которым пользуются и `getMessagesPage()`, и `getMemberActivityMeta()` + +Причина: + +- исторический backfill может менять exact member activity semantics без видимого изменения top page +- store не должен гадать про full-feed change только по diff первой страницы + +### 4. Message activity ownership + +- exact full-history message-derived facts идут в `getMemberActivityMeta()` +- renderer **не должен** вычислять exact `messageCount` или `lastActiveAt` только по head page messages +- итоговый member `status` как display field **не храним** как final truth в meta +- meta хранит raw facts, а display status собирается в renderer overlay из: + - `lastAuthoredMessageAt` + - `latestAuthoredMessageSignalsTermination` + - `currentTaskId` + - spawn/runtime state + +### 5. `messageCount` semantics + +- в этом PR semantics **сохраняем** +- `messageCount` остаётся **exact historical count** +- для этого закладываем shared normalized feed cache + meta cache по `feedRevision` +- вариант с `recentMessageCount` в этом PR **не принимаем** + +### 6. Pending replies semantics + +- `pendingRepliesByMember` остаётся renderer-local UI state +- `crossTeamPendingReplies` остаётся renderer-derived состоянием от message cache + local TTL +- `TeamMemberActivityMeta` **не становится** ticking transport для этих таймерных состояний + +Причина: + +- эти состояния частично зависят от local wall clock и текущего UX контекста таба +- перенос их в main/meta создаст лишнюю связанность и сломает текущую интерактивную модель + +### 6.1 Frozen semantics in this PR + +Чтобы performance refactor не превратился в скрытый product-change PR, в этом PR **не меняем**: + +- значение и смысл existing pending-reply waiting windows +- значение и смысл cross-team pending TTL badges +- значение coarse fallback polling intervals, кроме случаев where implementation forces tiny mechanical adjustment +- смысл `active` / `idle` member status thresholds +- exact-vs-recent meaning of `messageCount` +- default head page size / default first-screen message density без отдельного явного решения +- текущий default head request limit остаётся `50`, пока не принято отдельное явное решение его менять + +Если какой-то из этих пунктов всё-таки приходится менять ради correctness: + +- это должно быть отдельно отмечено в PR description +- change должен иметь отдельный тест +- и это уже считается product-semantic change, а не "просто часть split" + +### 7. Fetch ownership after migration + +- после split компоненты UI не вызывают `api.teams.getMessagesPage(...)` напрямую +- message fetching ownership переезжает в store actions +- `MessagesPanel`, `MemberMessagesTab`, graph-consumers становятся passive consumers store state + +### 8. Worker boundary + +- raw feed rebuild и meta build не должны неожиданно вернуться на main event loop +- в этом PR используем существующий `team-data-worker` boundary, а не заводим второй отдельный worker +- `getData()`, expensive `getMessagesPage()` rebuild path и `getMemberActivityMeta()` должны идти через одну и ту же worker strategy + +Важная практическая оговорка: + +- текущий `TeamDataWorkerClient` умеет fallback на main-thread execution, если worker artifact недоступен +- для новых hot paths это допустимо только как test/unpacked-dev escape hatch +- packaged runtime не должен молча остаться без worker и продолжить heavy feed rebuild на main loop + +Значит в плане реализации надо предусмотреть: + +- явную проверку availability для packaged runtime +- диагностический log/metric, если worker path не найден +- тест или smoke check, что message/meta ops реально доходят до worker path в нормальном runtime + +Причина: + +- иначе можно исправить renderer stall, но занести новую main-thread stall точку +- в кодовой базе уже есть готовый паттерн для heavy team I/O + +### 9. Polling ownership + +- fallback polling после миграции остаётся, но переезжает в store +- компоненты не владеют polling lifecycle +- polling нужен только как safety net на случай missed file/runtime events + +### 10. Temporary old-shape guard policy + +Если semantic-equality guard на старом mixed `TeamData` shape уже существует или приземлится раньше полного split, его статус в этом плане фиксированный: + +- это **temporary mitigation**, а не final architecture endpoint +- он не является причиной откладывать snapshot/messages/activity split +- новые consumers не должны начинать зависеть от старой mixed compare semantics +- после перехода на `TeamViewSnapshot` final no-op suppression должен работать уже на новом structural shape +- в merged target не должно остаться comparator logic, которое продолжает сравнивать `messages` внутри legacy snapshot только потому, что "так уже было" + +Иначе легко зацементировать старую неверную data boundary под видом performance fix. + +### 11. Store shape ownership + +- canonical owner structural snapshot state после split - `teamDataCacheByName` +- `selectedTeamData` в этом PR можно оставить как convenience field для текущей команды +- но `selectedTeamData` не должен жить отдельной второй жизнью +- если `selectedTeamData` присутствует, он всегда должен ссылаться на тот же object ref, что и `teamDataCacheByName[selectedTeamName]` + +Дополнительная жёсткая оговорка: + +- предпочтительный merged target - удалить `selectedTeamData` целиком, как только это станет механически просто +- сохранять его допустимо только как literal alias/pointer convenience field без собственной логики пересборки и без второго write path +- если для поддержки `selectedTeamData` нужен отдельный код синхронизации, значит поле уже не оправдано и должно быть удалено + +Причина: + +- иначе можно вроде бы "починить snapshot cache", но оставить hidden churn через second selected-only copy +- для no-op suppression важен именно ref reuse одного canonical объекта, а не две почти одинаковые структуры + +### 12. Out of scope for this PR + +- не делаем новый REST API +- не делаем `PaneContent` unmount refactor +- не делаем virtualization как primary fix +- не делаем graph redesign beyond data-source migration +- не делаем вторую параллельную message model "на время" + +## Source Of Truth Map + +Это обязательная карта владения данными. Если при реализации какая-то часть начнёт читаться не отсюда, это почти наверняка путь к регрессии. + +| Concern | Source of truth | Who derives view state | Must not come from | +| --- | --- | --- | --- | +| Structural team detail | `getData()` -> `TeamViewSnapshot` | store selectors / view-model adapters | message cache, `MessagesPanel` props | +| Normalized message feed | main-side shared feed cache/index | `getMessagesPage()`, `getMemberActivityMeta()` | repeated raw full rescans in each consumer | +| Message feed | `getMessagesPage()` -> `teamMessagesByName` + `selectTeamMessages(teamName)` | `MessagesPanel`, `MemberMessagesTab`, graph | `selectedTeamData`, `TeamViewSnapshot` | +| Full-feed freshness | `MessagesPage.feedRevision` + store cache entry revision | refresh routing / meta invalidation | head-slice diff heuristics only | +| Message identity | main-side effective message identity emitted in feed/page responses | store merge, cursor stability, read state, optimistic confirmation | ad-hoc renderer-only fallback identity | +| Exact member activity facts | `getMemberActivityMeta()` -> `memberActivityMetaByTeam` | member list / headers / hover / status presentation | loaded head messages only | +| Member awaiting-reply state | renderer-local `pendingRepliesByMember` | `TeamDetailView`, `MemberList`, `PendingRepliesBlock` | main/meta snapshot | +| Cross-team pending reply TTL state | renderer-derived from message cache + `Date.now()` | `StatusBlock` | main/meta snapshot | +| Spawn liveness | `memberSpawnStatusesByTeam` | member badges / merged display status | message meta | +| Message dedup semantics | main-side message services | renderer only consumes normalized output | renderer re-dedup logic | + +## Hard Invariants + +Если любой из пунктов ниже нарушается, значит реализация ушла в неправильную сторону. + +1. В merged коде не должно остаться чтения `selectedTeamData.messages`. +2. Exact `messageCount` и `lastActiveAt` не считаются в renderer по `selectTeamMessages(teamName)`. +3. `MessagesPanel` и `MemberMessagesTab` не имеют собственного IPC fetching logic после миграции. +4. Main остаётся единственным местом, где выполняется dedup `lead_session` / `lead_process`. +5. Pending-reply timer logic не переезжает в main process. +6. `lead-message` event не вызывает full `refreshTeamData()` по умолчанию. +7. В merged коде не живут две долгоживущие message models одновременно. +8. Message/meta refresh не крутятся бесконтрольно для hidden inactive teams. +9. `getMessagesPage()` и `getMemberActivityMeta()` не делают независимый полный raw rescan истории на каждый hot refresh. +10. Expensive feed rebuild path не выполняется на Electron main event loop. +11. Store не выводит "full feed changed" только по diff первого page slice; для этого используется `feedRevision`. +12. `TeamListView` и любые multi-team overview screens не гидратят messages/meta для каждой команды по умолчанию. +13. `getMessagesPage()` отдаёт stable effective message identity для каждого message row; store merge/cursor logic не живут на двух разных key semantics. +14. `selectedTeamData`, если сохраняется, reuse'ит ref из `teamDataCacheByName`, а не создаёт вторую independent snapshot copy. +15. `feedRevision` отражает состояние full normalized feed, а не время rebuild или raw invalidation fingerprint. +16. Если older history после revision change нельзя склеить без сомнений, canonical older tail сбрасывается, а не показывается mixed inconsistent state. + +## Forbidden Shortcuts + +Ниже shortcuts, которые выглядят как "быстро и почти правильно", но в контексте этого плана считаются ошибкой реализации. + +1. Оставить `messages` в snapshot "пока временно", а потом забыть убрать. +2. Считать `messageCount` / `lastActiveAt` по head page или по уже загруженным сообщениям в renderer. +3. Перенести fetching в store, но оставить прямые `api.teams.getMessagesPage(...)` в `MessagesPanel` или `MemberMessagesTab`. +4. Сделать `refreshMemberActivityMeta()` зависимым только от head-slice diff без `feedRevision`. +5. Держать два merge paths для messages: один в store, второй в компоненте. +6. Позволить packaged runtime тихо выполнять expensive message rebuild path на main thread при пропавшем worker. +7. Сохранить и `teamDataCacheByName`, и отдельно пересобираемый `selectedTeamData`. +8. Начать греть `getMessagesPage()` / `getMemberActivityMeta()` для multi-team overview "ради удобства". + +## 1. Top 3 Variants + +### 1. Full split: structural snapshot + messages cache + member activity meta + +`🎯 9 🛡️ 9 🧠 8` +Примерно `1600-2600` строк + +Идея: + +- `TeamData` больше не является message transport +- сообщения живут в отдельном cache/store path +- member list/status/meta перестают зависеть от нового `messages` array ref на каждый refresh +- `lead-message` и `inbox` events больше не триггерят full detail refresh + +Плюсы: + +- бьёт в корень renderer saturation +- уменьшает payload, churn и layout/paint rework +- делает поведение предсказуемым для долгих soaks +- готовит нормальную основу для graph/activity/members + +Минусы: + +- широкий blast radius +- надо аккуратно мигрировать graph и dialog consumers + +### 2. Только semantic-equality guard перед `set()` в `refreshTeamData` + +`🎯 7 🛡️ 6 🧠 4` +Примерно `250-450` строк + +Идея: + +- оставить `getData()` как есть +- сравнивать новый snapshot с предыдущим +- не вызывать `set()` если semantic state не изменился + +Плюсы: + +- быстро +- скорее всего снимет именно observed "new ref without visible change" + +Минусы: + +- `TeamData` остаётся перегруженным transport'ом +- любой реальный message change всё ещё трогает большой subtree +- архитектурная связка не исправляется +- остаётся риск новых форм churn вокруг graph/member dialogs/status blocks + +### 3. UI-side memoization / virtualization / more throttling без data split + +`🎯 5 🛡️ 5 🧠 6` +Примерно `500-900` строк + +Идея: + +- сильнее мемоизировать `MessagesPanel`, `ActivityTimeline`, `TeamDetailView` +- агрессивнее throttle / debounce refreshes +- возможно добавить virtualization + +Плюсы: + +- может уменьшить симптомы +- полезно как secondary optimization + +Минусы: + +- не чинит wrong data boundary +- будет лечить последствия вместо причины +- легко получить сложную, хрупкую UI-логику + +### Final Choice + +Берём **вариант 1**. +Но важная поправка: semantic guard из варианта 2 всё равно нужен внутри варианта 1. + +## 2. Краткая суть проблемы + +Проблема уже не в `persistLaunchStateSnapshot` storm. Он был причиной A и, судя по логам, уже прижат. + +Текущая причина B выглядит так: + +- `refreshTeamData()` регулярно создаёт новый `selectedTeamData` ref +- `TeamDetailView` подписан на весь `selectedTeamData` +- даже когда по смыслу ничего не изменилось, вниз уходит новый `messages` ref +- `MessagesPanel`, `ActivityTimeline`, member activity derivations и часть graph-related logic заново гонят filter/group/layout/paint +- React Profiler молчит, потому что commit time сам по себе не гигантский, а дорогой кусок сидит в browser layout+paint на 50+ message DOM nodes +- из-за mounted tabs через CSS toggle скрытые team tabs тоже могут держать живые тяжелые subtree + +Итог: + +- sustained long tasks по 150-500ms +- почти нет idle gaps +- heap распухает как следствие sustained work +- дальше уже возможен Chromium/V8 native fault `132/133` + +Это очень похоже на "renderer saturates itself useful-looking no-op work", а не на обычную JS memory leak. + +## 3. Факты из текущего кода + +### 3.1 Что уже хорошо + +`messages` уже частично вынесены: + +- `src/main/services/team/TeamDataService.ts` уже имеет `getMessagesPage()` +- `src/preload/index.ts` уже прокидывает `team:getMessagesPage` +- `src/shared/types/api.ts` уже описывает `TeamsAPI.getMessagesPage(...)` +- `src/renderer/components/team/messages/MessagesPanel.tsx` уже грузит страницы через `getMessagesPage()` +- `src/renderer/components/team/members/MemberMessagesTab.tsx` тоже умеет грузить страницы через `getMessagesPage()` + +То есть messages feed как отдельная boundary уже существует. Это важный факт. + +### 3.2 Что всё ещё дорого даже после split, если это не исправить + +Текущий `getMessagesPage()` в `TeamDataService` на каждый вызов: + +- заново читает inbox / lead texts / sent messages +- заново делает dedup `lead_session` / `lead_process` +- заново делает enrichment `leadSessionId` +- заново сортирует весь массив +- и только потом режет страницу + +Это значит: + +- если после split мы просто чаще зовём `getMessagesPage()` на `lead-message` / `inbox`, можно перенести часть нагрузки из renderer обратно в main +- transport boundary сама по себе не гарантирует дешёвый hot path + +Поэтому shared main-side normalized message feed cache - не nice-to-have, а часть правильного решения. + +### 3.3 Что всё ещё не разделено + +`getData()` всё ещё остаётся смешанным transport'ом: + +- собирает messages +- режет их до `MAX_RETURN_MESSAGES = 50` +- возвращает `messages` внутри `TeamData` +- передаёт `messages` в `TeamMemberResolver.resolveMembers(...)` + +Это означает: + +- даже "structural" refresh тянет message-derived часть модели +- members в snapshot зависят от message history +- новый `TeamData` ref почти гарантирован даже при пустом visible diff + +### 3.4 Где сейчас сцепка особенно сильная + +- `src/renderer/store/slices/teamSlice.ts` - `refreshTeamData()` всегда пишет новый `selectedTeamData` +- `src/renderer/components/team/TeamDetailView.tsx` - подписка на весь `selectedTeamData` +- `src/renderer/components/team/messages/MessagesPanel.tsx` - `effectiveMessages = merge(fetchedMessages, propMessages)` +- `src/renderer/components/team/activity/ActivityTimeline.tsx` - filter/group/visible timeline расчёты идут от whole messages array +- `src/renderer/components/team/members/MemberDetailDialog.tsx` - диалог получает `messages` из team snapshot +- `src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts` - graph всё ещё читает `TeamData.messages` +- `src/renderer/components/layout/PaneContent.tsx` - табы не размонтируются, а скрываются через `display: none` + +### 3.5 Вывод из этих фактов + +Messages уже выделены как feed API, но snapshot модели и renderer subscriptions ещё живут так, как будто messages по-прежнему часть основной detail модели. + +Значит реально надо разделять не "messages вообще", а вот это: + +- structural team snapshot +- message feed +- message-derived lightweight member/team activity meta + +Именно `getMemberActivityMeta` здесь ключевой новый слой. + +## 4. Почему текущий semantic guard - хороший, но недостаточный + +Фраза "semantic-equality guard перед `set()` звучит как самый правильный следующий шаг" по сути верная. + +Но глубже: + +- как immediate mitigation - да, это правильный следующий шаг +- как final architecture - нет, этого мало + +Почему он всё равно нужен: + +- он гасит no-op churn +- он дешёв относительно эффекта +- в кодовой базе уже есть хороший precedent в `fetchMemberSpawnStatuses()` с semantic equality suppression + +Почему его мало: + +- `TeamData` всё ещё останется слишком широким контрактом +- message churn всё ещё будет инвалидировать большой subtree +- graph/member dialogs/status block всё ещё будут сидеть на том же data blob +- сама форма данных останется неправильно сцепленной + +Правильная формулировка: + +> semantic guard нужен обязательно, но как часть split architecture, а не вместо неё + +## 5. Что именно надо разделить + +Здесь важно не запутаться. + +### 5.1 Нет, messages не надо "разделять с нуля" + +Они уже разделены: + +- есть `getMessagesPage()` +- есть pagination +- renderer уже умеет этим пользоваться + +### 5.2 Да, в основном надо разделить `member activity meta` + +Потому что именно она сейчас скрыто живёт внутри `TeamData` через: + +- `ResolvedTeamMember.status` +- `ResolvedTeamMember.messageCount` +- `ResolvedTeamMember.lastActiveAt` +- status blocks и pending replies, которые сейчас фактически упираются в `messages` + +### 5.3 И да, `getData()` надо сделать более structural + +Не в смысле "разрезать на 20 endpoints", а в смысле: + +- убрать из него message-heavy responsibility +- перестать использовать full message array как часть canonical detail snapshot + +То есть ответ на вопрос "мы что в основном разделяем `getMemberActivityMeta`?" такой: + +**Да.** +Но это работает только вместе с тем, что `getData()` перестаёт быть message-derived snapshot'ом. + +## 6. Endpoint ли это REST + +Нет. + +В этом проекте это должен быть **IPC endpoint**, а не REST API endpoint. + +То есть по форме это будет что-то в таком духе: + +- `TEAM_GET_MEMBER_ACTIVITY_META = 'team:getMemberActivityMeta'` +- wiring в `src/main/ipc/teams.ts` +- preload bridge в `src/preload/index.ts` +- тип в `src/shared/types/api.ts` + +Так что слово "endpoint" здесь надо понимать как app-internal IPC surface. + +## 7. Целевая архитектура + +## 7.1 Data boundaries + +Нормальная финальная схема должна выглядеть так: + +1. `getData(teamName)` возвращает **structural snapshot** +2. `getMessagesPage(teamName, { limit, cursor })` возвращает **сообщения** +3. `getMemberActivityMeta(teamName)` возвращает **лёгкие message-derived aggregate данные** + +В renderer это хранится раздельно: + +- `teamDataCacheByName[teamName]` +- `teamMessagesByName[teamName]` +- `memberActivityMetaByTeam[teamName]` + +### Concrete naming note + +Чтобы не плодить в документе две конкурирующие сущности, structural snapshot cache в renderer дальше следует понимать так: + +- концептуально - snapshot cache per team +- конкретно в текущем плане и store shape - `teamDataCacheByName` + +Отдельный bucket `teamSnapshotByName` в этом плане не вводится. + +А UI собирает view-model как overlay: + +- base structural team snapshot +- overlay member activity meta +- overlay latest loaded messages +- overlay member spawn statuses + +## 7.2 Что остаётся в structural snapshot + +Должно остаться: + +- `teamName` +- `config` +- `tasks` +- `kanbanState` +- `processes` +- `warnings` +- `isAlive` +- structural member description из config/meta + +### Важная корректировка по `members` + +Сейчас `ResolvedTeamMember` смешивает structural и message-derived поля. + +Это надо разрулить. + +Есть два пути: + +1. Либо ввести новый тип `TeamMemberSnapshot` +2. Либо оставить `ResolvedTeamMember`, но вытащить из него message-derived смысл в отдельный overlay + +Для надёжности и понятности лучше путь 1. + +### Предлагаемый structural member type + +```ts +export interface TeamMemberSnapshot { + name: string; + currentTaskId: string | null; + taskCount: number; + color?: string; + agentType?: string; + role?: string; + workflow?: string; + providerId?: TeamProviderId; + model?: string; + effort?: EffortLevel; + cwd?: string; + gitBranch?: string; + runtimeAdvisory?: MemberRuntimeAdvisory; + removedAt?: number; +} +``` + +Обратите внимание: + +- тут нет `messageCount` +- тут нет `lastActiveAt` +- тут нет `status`, если он message-derived + +Если нужен unified UI member status, он должен собираться поверх: + +- spawn status +- member activity meta +- active task presence + +## 7.3 Что уходит в member activity meta + +Туда должны уйти поля, которые меняются от message/inbox/head activity: + +```ts +export interface MemberActivityMetaEntry { + memberName: string; + /** + * Последнее сообщение, написанное самим участником. + * Важно: это не "последнее сообщение, где участник упомянут", + * а именно authored activity, чтобы сохранить текущую семантику `lastActiveAt`. + */ + lastAuthoredMessageAt: string | null; + /** Exact historical count of authored messages for this member. */ + messageCountExact: number; + /** + * True, если последнее authored message было terminal signal + * вроде shutdown approval. Это raw fact, а не итоговый display status. + */ + latestAuthoredMessageSignalsTermination: boolean; +} + +export interface TeamMemberActivityMeta { + teamName: string; + computedAt: string; + members: Record; + /** + * Revision shared normalized message feed, на котором собрана meta. + * Если revision не менялся, meta можно переиспользовать без пересчёта. + */ + feedRevision: string; +} +``` + +### Что важно не тащить в этот контракт + +Не надо класть туда: + +- full messages +- rendered timeline groups +- React-specific computed state +- tab-specific UI toggles +- ticking pending-reply booleans, зависящие от local clock +- `crossTeamPendingReplies` с TTL-логикой + +### Важная смысловая граница + +`TeamMemberActivityMeta` хранит только **стабильные message-derived факты**. + +Туда не должны попадать: + +- локальные optimistic "ждём ответ" +- таймерные TTL-состояния +- всё, что должно тикать раз в секунду от `Date.now()` + +## 7.4 Что остаётся у messages + +Messages должны жить только здесь: + +- `getMessagesPage()` +- renderer message cache +- специализированные consumers: `MessagesPanel`, `MemberMessagesTab`, graph/activity features + +Это снимает главный structural problem: + +- message changes больше не обязаны пересоздавать весь team detail snapshot + +## 8. Предлагаемые контракты + +## 8.1 Shared types + +Рекомендуемый набор типов: + +```ts +export interface TeamViewSnapshot { + teamName: string; + config: TeamConfig; + tasks: TeamTaskWithKanban[]; + members: TeamMemberSnapshot[]; + kanbanState: KanbanState; + processes: TeamProcess[]; + warnings?: string[]; + isAlive?: boolean; +} + +export interface TeamMemberSnapshot { + name: string; + currentTaskId: string | null; + taskCount: number; + color?: string; + agentType?: string; + role?: string; + workflow?: string; + providerId?: TeamProviderId; + model?: string; + effort?: EffortLevel; + cwd?: string; + gitBranch?: string; + runtimeAdvisory?: MemberRuntimeAdvisory; + removedAt?: number; +} + +export interface MemberActivityMetaEntry { + memberName: string; + lastAuthoredMessageAt: string | null; + messageCountExact: number; + latestAuthoredMessageSignalsTermination: boolean; +} + +export interface TeamMemberActivityMeta { + teamName: string; + computedAt: string; + members: Record; + feedRevision: string; +} + +export interface MessagesPage { + messages: InboxMessage[]; + nextCursor: string | null; + hasMore: boolean; + /** Revision всего normalized feed, а не только текущего page slice. */ + feedRevision: string; +} +``` + +## 8.2 API surface + +```ts +export interface TeamsAPI { + getData: (teamName: string) => Promise; + getMessagesPage: ( + teamName: string, + options?: { cursor?: string | null; limit?: number } + ) => Promise; + getMemberActivityMeta: (teamName: string) => Promise; +} +``` + +### Paging contract for `getMessagesPage()` + +Здесь нельзя оставлять двоякость между "timestamp paging" и "cursor paging". + +Locked choice: + +- request принимает `cursor`, а не `beforeTimestamp` +- `cursor === null` или отсутствие cursor означает "дай head page" +- `cursor` - opaque compound token, построенный main-side из boundary message, минимум `timestamp|effectiveMessageId` +- older-page semantics строго **exclusive**: response не должен повторно включать boundary row, из которой был выдан `nextCursor` +- `nextCursor === null` означает, что более старой canonical history больше нет + +Следствие: + +- renderer/store не реконструируют cursor самостоятельно +- timestamp-only paging в merged target отсутствует +- equality/merge semantics не зависят от неустойчивого порядка сообщений с одинаковым timestamp + +### Почему можно оставить имя `getData` + +С практической точки зрения это снизит churn: + +- старый IPC name можно не переименовывать сразу +- меняется shape, но не transport route + +Мой вывод: + +- **каноническое понятие** в плане должно называться `TeamViewSnapshot` +- **IPC method name** в этом PR остаётся `getData` + +## 8.3 Old to new field mapping + +Это важная таблица миграции. По ней проще всего проверять, не оставили ли мы hidden legacy coupling. + +| Old place | Old field / responsibility | New owner after split | +| --- | --- | --- | +| `TeamData.messages` | recent message batch | `selectTeamMessages(teamName)` over `canonicalMessages + optimisticMessages` | +| `ResolvedTeamMember.messageCount` | exact historical authored count | `memberActivityMetaByTeam[teamName].members[name].messageCountExact` | +| `ResolvedTeamMember.lastActiveAt` | last authored message timestamp | `memberActivityMetaByTeam[teamName].members[name].lastAuthoredMessageAt` | +| `ResolvedTeamMember.status` | display-ready member status | renderer overlay helper from snapshot + meta + spawn state | +| `MessagesPanel` local fetch state | page loading / cursors / merge | store-owned `teamMessagesByName[teamName]` | +| `MemberMessagesTab` direct IPC fetch | member message loading | store-owned message feed + selector filtering | +| `StatusBlock` snapshot messages prop | cross-team pending TTL derivation | store-backed messages + local timer | + +## 8.4 Member type migration strategy + +Это место нельзя оставлять неявным, потому что сейчас слишком много renderer кода ожидает `ResolvedTeamMember`. + +Правильная миграция такая: + +1. IPC transport перестаёт возвращать `ResolvedTeamMember[]` +2. IPC transport начинает возвращать `TeamMemberSnapshot[]` +3. renderer собирает поверх этого `ResolvedTeamMemberView[]` +4. UI-компоненты постепенно переводятся на `ResolvedTeamMemberView` + +### Важное правило + +`ResolvedTeamMember` больше не должен означать одновременно: + +- и IPC transport type +- и renderer display model + +Это две разные ответственности. + +### Рекомендуемый тип + +```ts +interface ResolvedTeamMemberView extends TeamMemberSnapshot { + status: MemberStatus; + lastActiveAt: string | null; + messageCount: number; + hasPendingReply?: boolean; +} +``` + +### Locked choice + +Для этого PR лучше: + +- оставить `ResolvedTeamMemberView` renderer-only type +- не возвращать его из main +- не держать старый `ResolvedTeamMember` как transport alias "для удобства" + +## 9. Main-process design + +## 9.1 `TeamDataService.getData()` должен стать structural + +Сейчас внутри `getTeamData()` messages делают две большие вещи: + +- сами попадают в response +- участвуют в `resolveMembers(...)` + +Значит надо: + +1. перестать возвращать `messages` в snapshot +2. перестать рассчитывать members от full message array + +### Новый shape flow + +Примерно так: + +```ts +const members = this.memberResolver.resolveStructuralMembers( + config, + metaMembers, + inboxNames, + tasksWithKanban +); + +return { + teamName, + config, + tasks: tasksWithKanban, + members, + kanbanState, + processes, + warnings, +}; +``` + +## 9.2 `TeamMemberResolver` надо разделить + +Сейчас `TeamMemberResolver` делает слишком много: + +- собирает member roster +- считает task ownership +- выводит status/messageCount/lastActiveAt из full messages + +Это надо разрезать. + +### Правильнее так + +`TeamMemberResolver.resolveStructuralMembers(...)` + +Отвечает только за: + +- список имён +- merge config/meta/inbox-derived members +- task ownership +- structural member props + +`MemberActivityMetaService.getMeta(teamName)` + +Отвечает за: + +- last authored activity +- exact historical counts +- terminal message facts + +Это даст нормальный SRP и снимет скрытую message coupling из snapshot. + +## 9.3 Как реализовать `getMemberActivityMeta()` надёжно + +Здесь тонкое место не только в meta, а в общем hot path сообщений. + +Если после split: + +- `getMessagesPage()` сам продолжит на каждый вызов делать raw full rescan + normalize +- и `getMemberActivityMeta()` отдельно тоже будет делать raw full rescan + +то мы просто перенесём часть нагрузки из renderer обратно в main. + +Поэтому правильный вариант такой: + +### Strategy A - shared normalized message feed cache + meta cache by feed revision + +Нужны два слоя. + +### Layer 1 - `TeamMessageFeedService` + +Отвечает за: + +- чтение raw sources +- присвоение каждому message row stable effective identity +- dedup `lead_session` / `lead_process` +- enrichment `leadSessionId` +- annotate slash responses +- stable newest-first sort +- shared cache normalized message feed по `teamName` +- вычисление `feedRevision` + +Важно: + +- этот слой становится единым backend для `getMessagesPage()` +- и единым backend для `getMemberActivityMeta()` +- нельзя оставлять старый inline normalize flow внутри `getMessagesPage()` параллельно с новым сервисом + +Примерно такой contract: + +```ts +interface TeamNormalizedMessageFeed { + teamName: string; + revision: string; + messages: InboxMessage[]; + newestTimestamp: string | null; + builtAt: number; +} +``` + +### `feedRevision` contract + +Это один из самых критичных контрактов всего плана. + +Правило: + +- `feedRevision` - это opaque, но **content-stable** revision full normalized feed +- если normalized feed семантически тот же, `feedRevision` обязан остаться тем же +- если normalized feed реально изменился, `feedRevision` обязан измениться + +Что запрещено: + +- генерировать `feedRevision` от `builtAt` +- генерировать `feedRevision` от `Date.now()` +- протаскивать наружу raw source fingerprint вида "mtime изменился, значит revision новый", если normalized output по факту не изменился + +Разрешённый компромисс: + +- internal source fingerprint может быть более консервативным и использоваться только для решения "rebuild or reuse cache" +- но наружу в `MessagesPage.feedRevision` и `TeamMemberActivityMeta.feedRevision` должен попадать именно revision нормализованного feed result, а не internal invalidation token + +Иначе: + +- `feedChanged` станет почти всегда `true` +- `refreshMemberActivityMeta()` начнёт зря крутиться +- store снова получит churn без реального изменения данных + +### Message identity contract + +Это место нужно зафиксировать жёстко, иначе pagination и merge легко станут источником скрытых дублей. + +Правило: + +- `TeamMessageFeedService` должен выдавать feed, где у каждого message row уже есть stable effective identity +- для этого reuse existing main-side identity semantics вроде `getEffectiveInboxMessageId(...)`, а не вводить ещё один независимый renderer fallback algorithm +- cursor `timestamp|messageId` должен строиться по **effective** message id, а не по "сырым optional ids" + +Следствие: + +- store merge older pages / head refresh / optimistic confirmation работают по одной и той же identity semantics +- read-state keys и message expansion keys не расходятся с transport identity +- исчезает класс багов "дубль после head refresh", когда у одной и той же canonical message в разных местах разные fallback keys + +Locked implementation choice: + +- целевой merged state этого PR - canonical feed rows всегда приходят с non-empty `messageId`, уже нормализованным main-side effective identity +- renderer helpers вроде `toMessageKey()` после этого должны фактически опираться на `messageId` как на normal path +- fallback key branch остаётся только как defensive guard для старых/optimistic/local edge cases, а не как вторая равноправная identity model + +### Cache invalidation strategy for feed service + +Первая реализация должна быть **консервативной**, а не "слишком умной". + +Разрешённый подход: + +- feed service хранит source fingerprint per team +- если fingerprint совпал, возвращаем cached feed +- если fingerprint изменился или есть любая неуверенность, rebuild whole normalized feed + +Что может входить в fingerprint: + +- inbox source revision / mtime / count +- lead session id / session history related revision +- sent messages store revision + +Что не надо делать в первой реализации: + +- partial in-place patching normalized feed несколькими независимыми эвристиками +- сложный delta merge между raw sources до появления профилирования + +Правило: + +- на первом шаге correctness важнее микрооптимизации +- optimisation boundary здесь - reuse cached feed when unchanged, а не умный partial patch when changed +- exposed `feedRevision` после rebuild должен вычисляться по normalized feed result, а не копировать internal fingerprint один в один + +### Layer 2 - `MemberActivityMetaService` + +Отвечает за: + +- построение `TeamMemberActivityMeta` **из normalized feed** +- кэширование результата по `feedRevision` + +Примерно такой cache entry: + +```ts +interface TeamMemberActivityMetaCacheEntry { + teamName: string; + feedRevision: string; + meta: TeamMemberActivityMeta; + builtAt: number; +} +``` + +### Важная деталь про no-op meta churn + +Даже если `feedRevision` изменился, это **не всегда** значит, что поменялись member-facing activity facts. + +Пример: + +- пользователь отправил новое сообщение участнику +- head feed изменился +- exact authored counters самих участников не изменились +- `lastAuthoredMessageAt` участников тоже не изменился + +Следствие: + +- `MemberActivityMetaService` может вернуть новый wrapper с новым `feedRevision` +- но `members` record внутри должен использовать structural sharing для неизменившихся entry +- UI selectors не должны подписываться на `computedAt` как на render-driving поле + +Иначе можно случайно вернуть churn в member list уже после правильного split. + +### Как должна выглядеть зависимость + +```ts +const feed = await teamMessageFeedService.getFeed(teamName); +const meta = await memberActivityMetaService.getMeta(teamName, feed); +``` + +### Почему это лучший баланс для этого PR + +- дорогой raw normalization живёт в одном месте +- `getMessagesPage()` просто режет page из cached normalized feed +- `getMemberActivityMeta()` не трогает raw storage напрямую +- если revision не изменился, meta возвращается без пересчёта +- O(n) meta rebuild по cached normalized feed при текущих observed объёмах сообщений выглядит безопаснее и проще, чем отдельный delta engine + +### Как сохраняем старую authored semantics + +Meta строится по authored activity: + +- `lastAuthoredMessageAt` считается по сообщениям `from === member.name` +- `messageCountExact` - это exact historical count authored messages +- `latestAuthoredMessageSignalsTermination` смотрит на последнее authored message и повторяет старую termination semantics + +То есть member-specific facts не считаются по любому сообщению, где member просто фигурирует в `to`. + +## 9.4 Почему не нужен отдельный delta engine в этом PR + +Отдельный delta engine можно добавить потом, если появятся реальные цифры, что даже meta rebuild по cached normalized feed стал горячей точкой. + +Но в этом PR он не обязателен, потому что: + +- shared feed cache уже убирает главную проблему repeated raw rescans +- solution с `feedRevision` проще тестировать +- меньше риск сломать edge cases и дедуп-семантику + +## 10. Renderer-side design + +## 10.1 Новые store slices + +Нужны отдельные state buckets: + +```ts +interface TeamMessagesCacheEntry { + canonicalMessages: InboxMessage[]; + optimisticMessages: InboxMessage[]; + feedRevision: string | null; + nextCursor: string | null; + hasMore: boolean; + lastFetchedAt: number | null; + loadingHead: boolean; + loadingOlder: boolean; + headHydrated: boolean; +} + +interface TeamSlice { + selectedTeamData: TeamViewSnapshot | null; + teamDataCacheByName: Record; + + teamMessagesByName: Record; + memberActivityMetaByTeam: Record; + + refreshTeamData: (teamName: string, opts?: RefreshTeamDataOptions) => Promise; + refreshTeamMessagesHead: (teamName: string) => Promise; + loadOlderTeamMessages: (teamName: string) => Promise; + refreshMemberActivityMeta: (teamName: string) => Promise; + applyOptimisticTeamMessage: (teamName: string, message: InboxMessage) => void; +} +``` + +### Snapshot/meta bootstrap semantics + +До первой successful hydration отсутствие cache entry - это нормальное состояние. + +Правила: + +- отсутствие `teamDataCacheByName[teamName]` означает "structural snapshot ещё не загружен", а не ошибку +- отсутствие `memberActivityMetaByTeam[teamName]` означает "activity meta ещё не загружена или ещё ни разу успешно не доезжала" +- store не должен создавать фиктивные placeholder-объекты только ради того, чтобы избежать `null` / `undefined` +- UI selectors и view-model layer должны уметь работать с отсутствием этих записей через стабильные fallback selectors, а не через ad-hoc object fabrication в компонентах + +Причина: + +- placeholder wrappers легко создают лишние ref changes и запутывают разницу между "нет данных пока" и "есть пустые данные" +- canonical source of truth должен оставаться простым: entry либо реально есть, либо его ещё нет + +### Non-reactive orchestration internals + +Не весь orchestration state должен жить в observable store. + +Допустимо и желательно держать вне reactive state: + +- in-flight promise maps per team/action +- dirty flags / follow-up flags +- explicit visibility registry +- internal cooldown / debounce bookkeeping + +Нельзя без необходимости тащить эти вещи в публичный reactive store shape, если UI не должен на них рендериться. + +Причина: + +- иначе сам orchestration layer начинает становиться источником re-render churn +- reactive store должен хранить в первую очередь данные и только те control flags, которые реально нужны UI + +### `TeamMessagesCacheEntry` field semantics + +Чтобы не было двух трактовок, значения полей должны пониматься так: + +- `canonicalMessages` - весь **уже загруженный** canonical message window для команды, newest-first, включая head page и все успешно догруженные older pages +- `optimisticMessages` - только локальные ещё не подтверждённые rows +- `feedRevision` - revision full normalized feed, на котором построен текущий canonical head state +- `nextCursor` - cursor для **следующей** older page после самого старого canonical message, уже находящегося в `canonicalMessages` +- `hasMore` - есть ли ещё canonical history старше текущего `nextCursor`; до первой successful head hydration это bootstrap flag и не интерпретируется как terminal exhaustion +- `lastFetchedAt` - timestamp последнего **успешного** canonical message fetch/merge для этой команды; до первого success равен `null` и не обновляется на failed attempt +- `loadingHead` - в полёте primary head refresh для canonical window +- `loadingOlder` - в полёте older-page extension текущего canonical window +- `headHydrated` - был ли хотя бы один успешный canonical head fetch + +Следствие: + +- head refresh обновляет canonical head portion, но не "забывает" уже загруженные older pages +- older-page loading расширяет `canonicalMessages` вниз по истории, а не создаёт отдельный side bucket + +### Bootstrap empty entry + +До первой successful head hydration canonical message entry должен иметь предсказуемый bootstrap state: + +```ts +{ + canonicalMessages: [], + optimisticMessages: [], + feedRevision: null, + nextCursor: null, + hasMore: false, + lastFetchedAt: null, + loadingHead: false, + loadingOlder: false, + headHydrated: false, +} +``` + +Важно: + +- bootstrap `hasMore: false` до first hydration не означает, что history exhausted +- terminal meaning у `hasMore === false` и `nextCursor === null` появляется только после `headHydrated === true` + +### Successful empty head hydration + +У команды может быть корректный successful head refresh и при этом ноль canonical messages. + +В таком случае canonical state должен стать таким: + +```ts +{ + canonicalMessages: [], + optimisticMessages: /* whatever local optimistic rows currently exist */, + feedRevision: "", + nextCursor: null, + hasMore: false, + lastFetchedAt: , + loadingHead: false, + loadingOlder: false, + headHydrated: true, +} +``` + +Важно: + +- empty successful feed **не** оставляет `headHydrated === false` +- empty successful feed **не** оставляет `feedRevision === null` +- иначе команда без history будет вечно выглядеть как "ещё не гидратирована" + +### Pre-hydration optimistic entry + +Если пользователь отправил optimistic message до первого successful head hydration, это допустимое состояние. + +В таком случае: + +- `canonicalMessages` остаётся пустым +- `optimisticMessages` может быть non-empty +- `headHydrated` остаётся `false` до первого successful canonical head fetch +- `feedRevision`, `nextCursor`, `lastFetchedAt` остаются bootstrap/null до первого success + +То есть optimistic rows могут существовать поверх bootstrap entry, не превращая его в hydrated canonical state. + +### `TeamMessagesCacheEntry` state invariants + +Чтобы store не собрал внутренне противоречивое состояние, ниже зафиксированы инварианты: + +- `headHydrated === false` => `canonicalMessages.length === 0` +- `headHydrated === false` => `loadingOlder === false` +- `headHydrated === false` => `feedRevision === null` +- `headHydrated === false` => `nextCursor === null` +- `headHydrated === false` => `lastFetchedAt === null` +- `loadingHead === true && loadingOlder === true` для одной команды в корректной реализации не допускается +- `hasMore === false` => `nextCursor === null` +- `canonicalMessages.length === 0` не означает ошибку само по себе, если `headHydrated === false` +- failed request не имеет права менять `lastFetchedAt` +- любой settled request обязан снять соответствующий loading flag, даже если response был stale-ignored + +Если implementation хочет хранить дополнительный error/debug state, он хранится отдельно от этого entry. + +### Operational definitions + +Чтобы разные исполнители не вкладывали разный смысл в одни и те же слова, ниже фиксированные определения. + +`visible active team` + +- команда, для которой прямо сейчас существует видимый team-detail или graph consumer в UI +- hidden mounted tabs через `display: none` сюда **не** входят только потому, что компонент всё ещё смонтирован +- store должен опираться на явный visibility signal, а не на факт mount'а subtree +- одного факта `selectedTeamName === teamName` недостаточно, чтобы считать команду `visible active team` + +`visibility signal` + +- renderer держит явный per-team visibility registration, а не выводит visibility косвенно из mount state +- минимум `TeamDetailView` container и graph container обязаны регистрировать и снимать этот сигнал при реальном показе/скрытии +- CSS-hidden subtree не считается visible consumer +- fallback polling и event routing consult именно этот explicit signal +- допустим ref-count или set of visible consumers per team, но merged code не должен зависеть от "компонент всё ещё смонтирован, значит команда активна" + +`active local pending-reply wait state` + +- у команды есть хотя бы один unresolved `pendingRepliesByMember` entry, который ещё находится в локальном waiting window +- это именно renderer-local UX reason держать лёгкий message polling +- это не означает, что команда становится structural-refresh priority + +`headHydrated` + +- хотя бы один successful head fetch уже положил canonical head page в store entry +- `headHydrated === false` означает "canonical message source для этой команды ещё не инициализирован" +- optimistic rows могут существовать и до `headHydrated === true`, но не заменяют canonical hydration + +`compatibility adapter` + +- временный branch-local helper, который помогает перевести consumer на новый shape без изменения transport contract обратно +- допустим только в renderer migration path +- не допускается как новый shared type alias, новый IPC compatibility contract или новый main-side legacy field + +### Contract for `refreshTeamMessagesHead()` + +Обычный `Promise` здесь слишком двусмысленный. + +Надёжнее сразу зафиксировать semantic result: + +```ts +interface RefreshTeamMessagesHeadResult { + feedChanged: boolean; + headChanged: boolean; + feedRevision: string | null; +} +``` + +Где: + +- `feedChanged` - изменился revision всего normalized feed относительно store cache +- `headChanged` - изменился реально canonical head slice, который подписан в UI +- `feedRevision` - revision после refresh + +Инварианты: + +- `headChanged === true` подразумевает `feedChanged === true` +- состояние `feedChanged === false && headChanged === true` в корректной реализации невозможно +- состояние `feedChanged === false && headChanged === false` означает, что canonical message inputs для UI не изменились +- состояние `feedChanged === true && headChanged === false` допустимо и означает historical-only/full-feed change без изменения текущего head slice + +Почему это важно: + +- старые сообщения могут доехать в feed без изменения top page +- `memberActivityMeta` зависит от full feed semantics, а не только от head page +- `MessagesPanel` может не перерисоваться, но member activity overlay всё равно должен знать, что full feed поменялся + +### Single-flight request discipline + +Даже правильный data split можно испортить, если store начнёт одновременно запускать 5 одинаковых refresh-запросов на burst events. + +Правило: + +- для каждого `teamName` store держит single-flight orchestration отдельно для: + - `refreshTeamData()` + - `refreshTeamMessagesHead()` + - `loadOlderTeamMessages()` + - `refreshMemberActivityMeta()` +- если такой же запрос уже в полёте, новые триггеры reuse existing promise или ставят один follow-up dirty flag +- store не запускает unbounded parallel head refreshes на каждое событие watcher burst + +Дополнительно: + +- responses применяются только через team-scoped request guard +- stale response после team switch / newer refresh не должен откатывать store назад + +### Canonical message mutation serialization + +Это отдельное правило поверх single-flight: + +- для одного `teamName` canonical message window не должен одновременно мутироваться из `refreshTeamMessagesHead()` и `loadOlderTeamMessages()` +- head refresh и older-page load для одной команды сериализуются через общий canonical-message mutation lane +- если во время `loadingOlder === true` приходит новый head trigger, store помечает team как dirty и выполняет head refresh сразу после завершения текущего canonical mutation +- если во время `loadingHead === true` приходит `loadOlderTeamMessages()`, older load либо reuse'ит уже идущую hydration sequence, либо ждёт её завершения + +Причина: + +- это сильно упрощает merge correctness +- это убирает лишний класс reorder bugs между head refresh и older-page append +- stale-response guards должны остаться как защита, но не быть основной стратегией нормального control flow + +### Что значит `meta stale` + +Чтобы здесь не было произвольных трактовок, `isMemberActivityMetaStale(teamName)` должен означать одно из: + +- meta entry для команды отсутствует +- `memberActivityMeta.feedRevision !== teamMessagesByName[teamName].feedRevision` +- safety TTL для visible active team истёк после длительного watcher silence + +И не должен означать: + +- "прошло немного времени, давайте на всякий случай ещё раз всё пересчитаем" +- "head refresh выполнился, значит meta точно stale" + +### UI selector discipline for activity meta + +Это место надо зафиксировать жёстко, иначе churn легко вернётся через selector layer. + +Правило: + +- UI consumers, которым нужны member facts, не подписываются на whole `TeamMemberActivityMeta` +- UI readers используют selector уровня facts, например `selectMemberActivityFacts(teamName)` +- routing / stale detection logic может отдельно читать `selectMemberActivityFeedRevision(teamName)` и `computedAt`, если это реально нужно + +Причина: + +- `feedRevision` может измениться без изменения member-facing facts +- `computedAt` почти никогда не должен быть render-driving полем +- подписка на весь wrapper снова создаст лишние re-renders в member list / hover / badges + +### Почему это важнее, чем просто больше `useMemo` + +Потому что store boundary определяет, что вообще считается "данные изменились". +Если boundary широкая, никакой `useMemo` потом уже красиво не спасёт. + +### Дополнительное правило + +Store после миграции становится единственной точкой orchestration для: + +- head refresh +- older-page loading +- optimistic message merge +- activity meta refresh +- fallback polling + +Компоненты после миграции только: + +- подписываются на store +- вызывают store actions +- не знают про IPC детали + +### Selector stability rule for merged messages + +Это критично. Иначе можно формально вынести messages из snapshot, но всё равно продолжить churn через новые массивы. + +Правило: + +- `selectTeamMessages(teamName)` обязан возвращать **stable array ref**, если `canonicalMessages` и `optimisticMessages` ref'ы не изменились +- `selectMemberMessages(teamName, memberName)` обязан строиться как memoized derived selector per pair, а не как новый `.filter(...)` на каждый store read +- `mergeTeamMessages()` не должен вызываться "в лоб" внутри обычного selector body без memoization + +Разрешённые варианты: + +- memoized selector factory per `teamName` +- precomputed merged view inside store entry с корректным structural sharing + +Недопустимый вариант: + +- каждый store read создаёт новый merged messages array даже при отсутствии изменений входов + +Иначе `MessagesPanel`, `ActivityTimeline`, graph и member tabs снова начнут получать churn уже после правильного split. + +### Optimistic storage rule + +Чтобы не терять optimistic rows во время canonical refresh, store не должен хранить один "голый" `messages[]`. + +Правильнее: + +- `canonicalMessages` - то, что пришло из main feed +- `optimisticMessages` - локальные optimistic rows, которые ещё не подтверждены canonical feed +- selector `selectTeamMessages(teamName)` возвращает уже merged view + +Это снимает типовую race-проблему: + +- user отправил сообщение +- optimistic row показался +- canonical head page ещё не успела включить это сообщение +- новый head refresh не должен "откатить" optimistic row + +### Cursor and page merge semantics + +Эта часть должна быть описана явно, иначе `loadOlderTeamMessages()` почти гарантированно получит race bugs. + +Правила: + +- `loadOlderTeamMessages()` не должен пытаться грузить older history, пока `headHydrated === false` +- если older load запрошен до first head hydration, store сначала делает `refreshTeamMessagesHead()` и только потом решает, есть ли что догружать +- cursor остаётся compound-format `timestamp|effectiveMessageId` +- older-page request должен помнить `baseFeedRevision`, на котором был выдан его `nextCursor` +- `loadOlderTeamMessages()` всегда использует `nextCursor` из текущего canonical store entry, а не локальное component state +- если `hasMore === false` или `nextCursor === null`, `loadOlderTeamMessages()` делает cheap no-op +- head refresh **не** заменяет весь canonical list целиком, если уже были подгружены older pages +- head refresh обновляет верхнюю часть feed и потом merge'ится с уже загруженной historical частью через единый merge helper +- older-page response тоже merge'ится, а не "append blindly" +- dedup и stable ordering должны reuse existing semantics вроде `mergeTeamMessages()` / shared message key contract +- canonical merge path не должен изобретать второй merge algorithm рядом с existing `mergeTeamMessages()` semantics без отдельной причины и отдельного тестового покрытия + +Особый case: + +- если older-page response приходит уже после нового head refresh или после другого older-page request +- store должен применить результат только если request guard ещё актуален +- иначе response silently ignored, без отката `nextCursor` и без reorder churn + +### Safety fallback for history rewrite / irreconcilable merge + +Нельзя молча предполагать, что история всегда append-only. + +В первой реализации должен быть безопасный fallback: + +- если после `feedRevision` change merge не может надёжно склеить fresh head и уже загруженную older history +- store обязан сбросить только historical tail и оставить свежий canonical head page как новый baseline +- при этом optimistic rows сохраняются отдельно и не теряются + +Триггеры для такого fallback: + +- нарушился stable newest-first ordering invariant после merge +- seam между fresh head и retained history не удаётся дедупнуть по effective identity без противоречий +- boundary anchor вокруг `nextCursor` стал недостоверным после newer revision +- response относится к старому `baseFeedRevision`, а в store уже живёт более новый head baseline + +Что важно: + +- лучше временно потерять локально подгруженный older tail, чем показать смешанное неконсистентное history state +- такой reset допустим только для canonical older window, но не для optimistic messages и не для structural snapshot + +### `selectedTeamData` / cache consistency rule + +Если в store временно живут и `teamDataCacheByName`, и `selectedTeamData`, правило должно быть жёстким: + +- сначала обновляется canonical cache entry per team +- потом `selectedTeamData` просто получает тот же ref, если `selectedTeamName === teamName` +- нельзя отдельно пересобирать `selectedTeamData` "для удобства UI" +- при смене `selectedTeamName` поле `selectedTeamData`, если оно ещё существует, synchronously repoint'ится на `teamDataCacheByName[selectedTeamName] ?? null` +- `selectedTeamData` не имеет права продолжать указывать на snapshot предыдущей команды после того, как `selectedTeamName` уже сменился + +Иначе: + +- no-op suppression может сработать для cache, но не сработать для current selection +- `TeamDetailView` продолжит видеть churn, хотя формально cache уже исправлен + +### Team switch response rule + +При switch `A -> B` store обязан вести себя так: + +- late async response для `A` может обновить только cache entry команды `A` +- late async response для `A` не имеет права переустановить `selectedTeamData`, если `selectedTeamName !== A` +- hydration/open-flow для `B` идёт по обычным правилам `visible active team` +- если cache для `B` уже существует, UI может сразу reuse'ить этот snapshot ref; если cache для `B` ещё нет, допускается `selectedTeamData === null` до первого успешного snapshot refresh + +Цель: + +- не показывать stale snapshot команды `A` под выбранной командой `B` +- не ломать per-team cache reuse ради selected-team convenience field + +### Fallback polling policy + +Polling остаётся как safety net, но только в store и только по строгим правилам: + +- включается для visible active team +- включается для team с active local pending-reply wait state +- не крутится для hidden inactive teams +- не переписывает structural snapshot +- делает только message-head refresh и при необходимости meta refresh + +### Initial visible-team hydration sequence + +Это должно быть описано отдельно, чтобы open-flow не собирался по-разному в разных местах. + +Когда команда становится `visible active team`, store обязан обеспечить такой порядок: + +1. `refreshTeamData(teamName)` для structural snapshot +2. `refreshTeamMessagesHead(teamName)` для canonical head hydration +3. `refreshMemberActivityMeta(teamName)` только после первого head result, если meta отсутствует или stale +4. `fetchMemberSpawnStatuses(teamName)` как независимый overlay refresh + +Допустимо: + +- запускать шаги 1 и 2 параллельно +- reuse shared single-flight/feed-cache между шагами 2 и 3 + +Недопустимо: + +- строить open-flow так, что `MemberDetailDialog`, `ActivityTimeline` или `StatusBlock` начинают сами триггерить свою собственную первичную hydration logic +- считать команду "полностью гидратированной" только потому, что приехал structural snapshot без message head + +### Hidden-team cache retention rule + +Когда команда перестаёт быть `visible active team`: + +- store прекращает background refresh/polling для этой команды, если нет `active local pending-reply wait state` +- уже гидратированные snapshot/message/meta caches **не** очищаются только из-за hide transition +- hide transition сам по себе не должен сбрасывать `headHydrated`, `canonicalMessages`, `memberActivityMetaByTeam[teamName]` или `teamDataCacheByName[teamName]` + +В этом PR не вводится отдельная eviction policy. + +Причина: + +- eager clear-on-hide легко превращает reopen в повторный burst hydration path +- cache retention и background refresh ownership - это разные вещи, их нельзя смешивать + +### Reopen rule after hide + +Если команда была скрыта, а потом снова стала `visible active team`: + +- store reuse'ит уже имеющиеся snapshot/message/meta caches как baseline +- open-flow может поверх этого сделать refresh по обычным visible-team правилам +- reopen не должен вести себя как forced cold-start только из-за предыдущего hide transition + +### Failure semantics for store actions + +Это тоже должно быть однозначно: + +- `refreshTeamData()` failure не очищает предыдущий structural snapshot +- `refreshTeamMessagesHead()` failure не очищает `canonicalMessages`, `nextCursor`, `feedRevision` +- `loadOlderTeamMessages()` failure не откатывает уже загруженную history window +- `refreshMemberActivityMeta()` failure не очищает предыдущий meta facts record +- любой из этих failures обязан снять соответствующий loading flag + +Если нужен user-visible signal: + +- он должен жить отдельным ephemeral error state / logger path +- но не через destructive reset уже загруженных данных + +## 10.2 `refreshTeamData()` после split + +После split `refreshTeamData()` должен заниматься только: + +- structural snapshot +- task change invalidation +- structural sharing +- no-op suppression + +Он **не** должен: + +- догружать messages +- дёргать member activity computations +- быть universal answer на любой `lead-message` + +## 10.3 Новый routing событий + +Правильнее распределить так: + +### `lead-message` + +Должен триггерить: + +- `refreshTeamMessagesHead(teamName)` +- `refreshMemberActivityMeta(teamName)` только если `feedChanged === true` или meta stale + +Но не full `refreshTeamData()` по умолчанию. + +И только если team реально нужна сейчас: + +- видима хотя бы в одном pane +- или у неё есть active local pending-reply wait state + +### `inbox` + +Тоже: + +- `refreshTeamMessagesHead(teamName)` +- `refreshMemberActivityMeta(teamName)` только если `feedChanged === true` или meta stale + +С тем же visibility правилом: + +- visible team +- или active local pending-reply wait state + +### `task` + +Должен триггерить: + +- `refreshTeamData(teamName)` + +### `config` + +Должен триггерить: + +- `refreshTeamData(teamName)` + +Этого достаточно, потому что: + +- roster и `currentTaskId` живут в structural snapshot +- `memberActivityMeta` после split зависит от message feed, а не от config + +### `process` + +Должен триггерить: + +- `refreshTeamData(teamName)` + +### `member-spawn` + +Как и сейчас: + +- `fetchMemberSpawnStatuses(teamName)` + +Но без косвенного втягивания full team detail refresh, если это не требуется. + +### Fallback polling + +Отдельно от event routing store держит лёгкий fallback poll: + +- только для visible active team +- или для team с active local pending-reply wait state +- интервал остаётся coarse, а не tight +- poll вызывает только `refreshTeamMessagesHead()` +- `refreshMemberActivityMeta()` вызывается только вслед за `feedChanged === true` или stale-meta condition + +Это нужно на случай: + +- пропущенных file/runtime events +- длинных сессий с нестабильным watcher delivery + +## 10.4 Пример роутинга + +```ts +if (event.type === 'lead-message' || event.type === 'inbox') { + const { feedChanged } = await refreshTeamMessagesHead(event.teamName); + if (feedChanged || isMemberActivityMetaStale(event.teamName)) { + scheduleMemberActivityMetaRefresh(event.teamName); + } + return; +} + +if (event.type === 'task' || event.type === 'config' || event.type === 'process') { + scheduleTeamSnapshotRefresh(event.teamName); +} +``` + +Это самое большое поведенческое исправление для renderer load pattern. + +### Event matrix without ambiguity + +| Event | Always do | Conditionally do | Must not do by default | +| --- | --- | --- | --- | +| `lead-message` | `refreshTeamMessagesHead()` | `refreshMemberActivityMeta()` when `feedChanged` or meta stale | `refreshTeamData()` | +| `inbox` | `refreshTeamMessagesHead()` | `refreshMemberActivityMeta()` when `feedChanged` or meta stale | `refreshTeamData()` | +| `task` | `refreshTeamData()` | nothing else unless separate UI needs it | blind message refresh | +| `config` | `refreshTeamData()` | nothing else unless separate UI needs it | blind message refresh | +| `process` | `refreshTeamData()` | nothing else unless separate UI needs it | blind message refresh | +| `member-spawn` | `fetchMemberSpawnStatuses()` | presentation overlay recompute in renderer | implicit full snapshot refresh | + +## 10.5 `TeamDetailView` должен перестать читать всё из одного blob + +Сейчас view примерно концептуально живёт так: + +- `data = selectedTeamData` +- `messages = data.messages` +- `members = data.members` + +После split правильнее: + +```ts +const snapshot = useStore(selectTeamSnapshot(teamName)); +const messages = useStore(selectTeamMessages(teamName)); +const memberActivityFacts = useStore(selectMemberActivityFacts(teamName)); +const memberSpawnStatuses = useStore(selectMemberSpawnStatuses(teamName)); +``` + +А дальше уже в selector / adapter layer собирается view model: + +```ts +const membersWithActivity = useMemo( + () => mergeMembersWithActivity(snapshot.members, memberActivityFacts, memberSpawnStatuses), + [snapshot.members, memberActivityFacts, memberSpawnStatuses] +); +``` + +Это делает invalidation адресным: + +- messages change не обязаны ломать tasks/processes/member roster UI +- member meta change не обязана пересоздавать task board + +### Как именно должен собираться member status + +После split итоговый `member.status` больше не приходит готовым из main snapshot. + +Правильная схема: + +- meta даёт raw activity facts +- snapshot даёт `currentTaskId` +- spawn layer даёт runtime/provisioning signals +- renderer helper собирает итоговый display status для UI + +Это важный момент, потому что иначе легко снова смешать transport facts и UI semantics. + +## 10.6 `MessagesPanel` должен работать только от message store + +Сейчас он смешивает: + +- prop seed messages +- fetched page messages + +После split: + +- `MessagesPanel` получает `selectTeamMessages(teamName)` +- optimistic send updates идут прямо в message store +- начальная head hydration делается через store action, а не через prop fallback +- `loadOlderMessages` идёт через store action, а не через прямой IPC call из компонента + +### Это особенно важно + +Пока у `MessagesPanel` есть `prop messages`, snapshot продолжает быть скрытым transport'ом для messages. + +Это надо убрать полностью. + +### И ещё одно важное правило + +`MessagesPanel` не должен стать вторым orchestration layer. + +То есть внутри него не должно остаться: + +- отдельного `fetchIdRef` +- собственного polling lifecycle +- второй логики merge/dedup поверх store ownership + +## 10.7 `ActivityTimeline` + +Это тоже message-heavy consumer, и его нельзя оставлять "подразумеваемым". + +После split: + +- `ActivityTimeline` читает store-backed messages selector или отдельный timeline view-model selector, а не `selectedTeamData.messages` +- timeline grouping/filtering не живёт от старого snapshot prop +- компонент не содержит собственного fetch/polling/orchestration path +- hidden mounted tab не должен получать лишний churn только потому, что timeline подписан слишком широко + +Если для timeline нужен специальный derived selector, это нормально. +Ненормально - снова фильтровать whole snapshot message blob прямо в render path. + +## 10.8 `MemberDetailDialog` / `MemberMessagesTab` / `MemberHoverCard` + +Сейчас dialog получает `messages` из `TeamDetailView`. + +После split: + +- dialog не должен принимать full team messages prop +- `MemberMessagesTab` должен брать member-relevant data из message store через team-scoped selector +- activity count в header должен приходить из `memberActivityMeta`, а не через `buildInlineActivityEntries(messages.filter(...))` на каждый reopen +- `MemberMessagesTab` не должен сам дёргать `api.teams.getMessagesPage(...)` +- `MemberHoverCard` должен читать `memberActivityFacts` или готовый view-model selector, а не whole snapshot wrapper и не whole meta wrapper + +### Пример + +```ts +const memberMeta = memberActivityFacts[member.name]; +const memberActivityCount = memberMeta?.messageCountExact ?? 0; +``` + +Если нужен более богатый recent activity counter, это отдельное future extension, не часть этого PR. + +## 10.9 Agent Graph + +Это один из самых опасных edge points. + +Сейчас graph adapter сидит на `TeamData.messages`. + +Если просто выкинуть `messages` из `TeamData`, graph сломается. + +### Правильный путь + +Graph должен перейти на тот же store-backed source, что и MessagesPanel. + +Locked choice: + +- store subscription живёт в graph hook / container layer +- pure adapter принимает уже готовые данные `(snapshot, messages, memberActivityFacts, teamName)` +- fetching и polling не уезжают внутрь graph adapter + +Примерно так: + +```ts +const snapshot = useStore(selectTeamSnapshot(teamName)); +const messages = useStore(selectTeamMessages(teamName)); +const memberActivityFacts = useStore(selectMemberActivityFacts(teamName)); +const graphData = useMemo( + () => TeamGraphAdapter.adapt(snapshot, messages, memberActivityFacts, teamName), + [snapshot, messages, memberActivityFacts, teamName] +); +``` + +### Почему это важно + +Если graph останется на legacy `TeamData.messages`, вы получите: + +- двойную модель +- race conditions +- скрытую потребность сохранять legacy field дольше, чем нужно + +## 11. Structural sharing and no-op suppression + +Это надо делать даже после split. + +## 11.1 Зачем + +Потому что даже structural snapshot без messages всё равно может пересоздаваться: + +- новые массивы задач +- новый `config` object +- новый `processes` array +- новые `members` array/object refs при одинаковом содержимом + +Если этого не подавить, вы получите меньшую, но всё ещё реальную churn-проблему. + +## 11.2 Принцип + +Нужно не просто "compare then skip". +Нужно **reuse old references for equal subtrees**. + +То есть не так: + +```ts +if (deepEqual(prev, next)) return prev; +return next; +``` + +А так: + +```ts +function structurallyShareTeamSnapshot( + prev: TeamViewSnapshot | null | undefined, + next: TeamViewSnapshot +): TeamViewSnapshot { + if (!prev) return next; + + const sharedConfig = areConfigsEqual(prev.config, next.config) ? prev.config : next.config; + const sharedTasks = reuseArrayIfEqual(prev.tasks, next.tasks, areTasksSemanticallyEqual); + const sharedMembers = reuseArrayIfEqual(prev.members, next.members, areMembersSemanticallyEqual); + const sharedProcesses = reuseArrayIfEqual( + prev.processes, + next.processes, + areProcessesSemanticallyEqual + ); + const sharedWarnings = reuseOptionalArrayIfEqual( + prev.warnings, + next.warnings, + (left, right) => left === right + ); + + if ( + sharedConfig === prev.config && + sharedTasks === prev.tasks && + sharedMembers === prev.members && + sharedProcesses === prev.processes && + prev.isAlive === next.isAlive && + sharedWarnings === prev.warnings + ) { + return prev; + } + + return { + ...next, + config: sharedConfig, + tasks: sharedTasks, + members: sharedMembers, + processes: sharedProcesses, + warnings: sharedWarnings, + }; +} +``` + +Примечание: + +- `warnings` тоже надо пускать через optional-array sharing, а не через голый ref compare +- иначе no-op suppression останется частичной и будет зря пересоздавать snapshot wrapper + +## 11.3 Где надо быть особенно осторожным + +С semantic equality нельзя бездумно игнорировать поля. + +Надо разделять: + +- поля, меняющие видимый UI +- поля, не меняющие видимый UI + +Пример: + +- `updatedAt` у meta - часто можно игнорировать +- `lastHeartbeatAt` - можно игнорировать для member spawn badge equality, если UI его не показывает +- `task.reviewState` игнорировать уже нельзя + +Нужны **целенаправленные semantic comparators**, а не generic deep-equal. + +## 12. Optimistic updates + +Это отдельный опасный блок. + +Сейчас `sendTeamMessage()` оптимистично пушит message в `selectedTeamData.messages`. + +После split надо перенести optimistic update в message store. + +### Правильнее так + +```ts +sendTeamMessage: async (teamName, request) => { + const optimistic = buildOptimisticMessage(request, result.messageId); + get().applyOptimisticTeamMessage(teamName, optimistic); + await get().refreshTeamMessagesHead(teamName); +} +``` + +### Почему здесь не нужен `refreshMemberActivityMeta()` + +Для обычного user -> member send это лишняя работа, потому что: + +- `messageCountExact` считает authored messages самого member +- `lastAuthoredMessageAt` тоже меняется только когда пишет сам member +- pending-reply UX уже покрывается local `pendingRepliesByMember` + +Значит после user send надо: + +- добавить optimistic message в store +- обновить local pending-reply state +- дождаться canonical head refresh + +Но не пересчитывать activity meta сразу же. + +### Send failure rollback semantics + +Если `sendTeamMessage()` завершается ошибкой до canonical confirmation: + +- соответствующая optimistic row удаляется из `optimisticMessages` +- local pending-reply state, поставленный этим send attempt, откатывается +- canonicalMessages не трогаются +- `refreshMemberActivityMeta()` по этому failure не запускается + +Если продукт позже захочет отдельный failed-message UX со статусом retry, это уже отдельное расширение. +В текущем плане failed optimistic send не должен навсегда оставлять висящую pseudo-message row в merged feed. + +### Почему нельзя оставить старую логику + +Потому что она снова начнёт: + +- мутировать snapshot semantics через messages +- держать legacy coupling + +### Отдельно про pending replies + +Local `pendingRepliesByMember` остаётся в renderer: + +- на send отмечаем `sentAtMs` +- на incoming member reply чистим локальное состояние +- delayed waiting refresh в `TeamDetailView` после split должен вызывать `refreshTeamMessagesHead(teamName)`, а не full `refreshTeamData(teamName)` + +### Merge semantics for optimistic rows + +Когда canonical feed в итоге содержит сообщение с тем же `messageId`, store должен: + +- убрать соответствующую optimistic row +- оставить canonical row +- не дублировать обе версии в merged selector + +## 13. Что делать с `messageCount` + +Это один из самых важных product semantics вопросов. + +Сейчас `ResolvedTeamMember.messageCount` - это exact count по full history. + +В этом плане решение уже принято: + +- `messageCount` в v1 split-реализации остаётся **exact historical count** +- значение приходит из `TeamMemberActivityMeta` +- значение не вычисляется в renderer по head page + +Причина: + +- это сохраняет текущую семантику UI и тестов +- это убирает скрытое product-изменение из и так большого performance PR +- это совместимо с shared normalized feed cache + meta-by-revision cache + +Если позже product решит, что exact count не нужен, это отдельный follow-up с отдельным обсуждением UX semantics, но не часть текущего плана. + +## 14. Edge cases и подводные камни + +## 14.1 Hidden tabs still mounted + +Пока `PaneContent` сохраняет tabs mounted, любое широкое store invalidation продолжает работать против вас. + +Следствие: + +- даже после split полезно сделать selectors максимально узкими +- не тянуть `messages` в скрытые team tabs, если они не нужны + +## 14.2 Team switch race + +Если пользователь быстро переключает команды: + +- `refreshTeamMessagesHead(alpha)` может завершиться после перехода на `beta` +- нельзя обновлять `selectedTeamData`-подобный selected-only state без teamName validation + +Нужны team-scoped caches и id guards, как уже сделано в ряде мест. + +И это же правило относится к: + +- older-page responses +- meta refresh responses +- delayed pending-reply refresh timers + +## 14.3 Member removed / renamed + +Если member удалён: + +- structural snapshot убирает его из active списка +- `memberActivityMeta` может ещё содержать старую запись + +Правильнее: + +- не терять meta сразу, если нужен historical dialog +- но UI current member list должен фильтровать по structural roster + +## 14.4 Pending replies semantics + +Сейчас pending replies partly derived from messages. + +После split нельзя потерять: + +- pending reply badges by member +- pending cross-team replies + +Здесь важно не перепутать две разные сущности. + +### Member pending replies + +Это остаётся renderer-local state: + +- источник истины - `pendingRepliesByMember` +- состояние ставится optimistically на send +- очищается, когда message feed показывает фактический reply от участника + +Это **не** надо класть в `TeamMemberActivityMeta`. + +### Cross-team pending replies + +Это остаётся renderer-derived состоянием: + +- источник истины - normalized message cache +- TTL считается локально от `Date.now()` +- `StatusBlock` может продолжать держать свой 1-second timer, но читать он должен уже из store-backed messages, а не из snapshot prop + +Это тоже **не** надо класть в `TeamMemberActivityMeta`. + +### Что тогда делает `TeamMemberActivityMeta` + +Только стабильные message-derived факты: + +- `lastAuthoredMessageAt` +- `messageCountExact` +- `latestAuthoredMessageSignalsTermination` + +## 14.5 New message before head hydration finishes + +Возможна ситуация: + +- открыли team +- `refreshTeamMessagesHead()` ещё в полёте +- пользователь отправил optimistic message +- потом приехала server head page + +Нужно merge по `messageId`, не замену массива вслепую. + +## 14.6 Message edits / dedup / source merging + +У вас уже есть логика dedup lead_session vs lead_process. + +Она должна остаться **единственным source of truth** на main side. + +Renderer не должен заново изобретать dedup semantics. + +## 14.7 `lastHeartbeatAt` и spawn statuses + +Это нельзя снова смешивать с message activity meta. + +Нужно разделять: + +- spawn liveness +- member conversational activity + +Их потом можно поверх объединить в `displayStatus`, но хранить в одном transport не надо. + +## 14.8 Team provisioning / TEAM_DRAFT / transient errors + +`refreshTeamData()` уже аккуратно обрабатывает provisioning-safe сценарии. + +После split надо сохранить тот же принцип для: + +- `refreshTeamMessagesHead()` +- `refreshMemberActivityMeta()` + +То есть: + +- transient failures не должны очищать structural snapshot +- отсутствие message meta не должно рушить весь screen + +## 14.9 Graph and TeamDetail open одновременно + +Если team tab и graph tab открыты одновременно для одной команды: + +- нельзя делать два разных polling loops, читающих одну и ту же head page + +Нужно shared store action / shared cache entry per team. + +## 14.10 Tests that currently assert `data.messages` + +Blast radius тестов реальный: + +- main service tests +- IPC tests +- renderer store tests +- graph adapter tests + +Надо сразу закладывать migration plan: + +- заменить ожидания на snapshot + messages/meta assertions +- не держать временный legacy field дольше, чем нужно + +## 14.11 Team list fan-out risk + +Это место легко пропустить, а потом получить новый performance regression уже не в detail view, а в overview. + +Если `TeamListView` или похожий multi-team экран: + +- делает `getData()` для многих команд +- и после split начнёт "для полноты" ещё дёргать `getMessagesPage()` / `getMemberActivityMeta()` по каждой строке + +то это создаст новый fan-out hot path. + +Правило: + +- message feed и member activity meta гидратятся только для selected / visible team detail contexts +- list/grid overview остаётся на structural snapshot +- если overview позже понадобится activity badge, для него нужен отдельный lightweight aggregate contract, а не скрытый fan-out тяжёлых вызовов + +## 15. Как именно я бы это реализовывал + +## 15.1 Принцип + +Не "фаза 1 как костыль, потом перепишем". + +А один coherent branch/PR, внутри которого есть правильный порядок сборки: + +1. новые типы и IPC surfaces +2. новый store shape +3. message/meta consumers переводятся на новые selectors +4. event routing меняется +5. structural sharing включается +6. legacy `TeamData.messages` usage выпиливается + +То есть rollout последовательный, но не архитектурно компромиссный. + +## 15.2 Пошаговый технический план + +Важно: + +- шаги ниже задают **implementation ownership order**, а не обещание, что каждая микрофаза сама по себе уже merge-safe +- merge-safe checkpoints для PR определяются секциями `Suggested commit slices`, `Mechanical execution checklist` и `Merge gates` +- если отдельный шаг временно делает ветку архитектурно неконсистентной, следующий связанный шаг должен приземляться в том же commit slice до локального smoke +- нельзя останавливать работу на половине coupled migration, если в таком состоянии код снова зависит от legacy mixed snapshot + +### Safe temporary states during migration + +Чтобы не собрать ветку в промежуточное состояние, которое уже компилируется, но архитектурно тянет старые баги, ниже разрешённые и запрещённые промежуточные формы. + +Разрешено временно: + +- держать `ResolvedTeamMemberView` renderer-only adapter, пока consumer-компоненты по очереди переводятся на новый overlay model +- держать branch-local compatibility adapters в renderer containers +- держать `selectedTeamData` как convenience alias, пока canonical owner уже `teamDataCacheByName` + +Но: + +- каждый compatibility adapter должен иметь одного конкретного remaining consumer owner +- adapter удаляется в том же commit slice, где уходит его последний consumer +- нельзя оставлять "универсальный временный adapter", который начинает жить своей отдельной жизнью + +Запрещено даже временно: + +- возвращать новый structural snapshot и одновременно ждать, что компоненты всё ещё возьмут из него `messages` +- перевести store на новый message cache, но оставить direct component fetch/polling "до следующего коммита" +- держать `selectedTeamData` как independently-built copy после того, как появился canonical cache +- держать второй message dedup/merge path в renderer после появления store-owned canonical path + +Если промежуточная ветка попадает в запрещённое состояние, её нельзя считать готовой даже для локального smoke. + +### Step 1 - Ввести новые shared contracts + +Сделать: + +- `TeamViewSnapshot` +- `TeamMemberSnapshot` +- `TeamMemberActivityMeta` +- расширить existing `MessagesPage` полем `feedRevision` +- перевести request shape `getMessagesPage()` c `beforeTimestamp` на `cursor` +- `getMemberActivityMeta()` в `TeamsAPI` + +Проверить: + +- типы компилируются без renderer migration +- paging contract в shared types уже cursor-based, а не timestamp-based + +### Step 2 - Разделить main-side services + +Сделать: + +- `TeamMemberResolver.resolveStructuralMembers(...)` +- новый `MemberActivityMetaService` +- `TeamDataService.getData()` перестаёт возвращать `messages` + +Проверить: + +- `getMessagesPage()` остаётся источником сообщений +- main unit tests покрывают structural snapshot отдельно от messages/meta + +### Step 3 - Добавить renderer caches + +Сделать: + +- `teamMessagesByName` +- `memberActivityMetaByTeam` +- `refreshTeamMessagesHead()` +- `refreshMemberActivityMeta()` +- `applyOptimisticTeamMessage()` +- merged selector over `canonicalMessages + optimisticMessages` + +Проверить: + +- message cache корректно merge'ит optimistic + fetched messages +- canonical refresh не откатывает optimistic row до подтверждения feed + +### Step 4 - Перевести `MessagesPanel` + +Сделать: + +- убрать `messages` prop как canonical input +- читать message entry из store + +Проверить: + +- initial load +- polling +- load older +- optimistic send + +### Step 5 - Перевести `MemberDetailDialog` / `MemberMessagesTab` + +Сделать: + +- dialog больше не получает full messages prop +- count/meta идут из `memberActivityMeta` +- detail tab читает relevant messages из message store + +Проверить: + +- open/close dialog +- team switch +- member switch + +### Step 6 - Перевести TeamDetail selectors + +Сделать: + +- `membersWithActivity` как overlay model +- `StatusBlock` перестаёт читать whole messages blob напрямую из snapshot + +Проверить: + +- pending replies +- active/idle badges +- no visible regression в member list + +### Step 7 - Перевести graph + +Сделать: + +- graph adapter читает `snapshot + messages + memberActivityMeta` + +Проверить: + +- graph не сломан +- graph не заставляет держать legacy `TeamData.messages` + +### Step 8 - Включить event routing split + +Сделать: + +- `lead-message` и `inbox` больше не зовут full `refreshTeamData()` по умолчанию +- зовут messages/meta refresh + +Проверить: + +- burst handling +- dedup +- no stale UI + +### Step 9 - Включить structural sharing + no-op suppression + +Сделать: + +- `structurallyShareTeamSnapshot(prev, next)` +- no-op return если snapshot semantically equal +- если до этого в ветке существует temporary old-shape guard на mixed `TeamData`, на этом шаге он либо удаляется, либо сужается до нового structural snapshot comparator + +Проверить: + +- `selectedTeamData` ref не меняется на no-op refresh +- hidden tabs не получают лишних commits +- в merged target не остаётся comparator, который всё ещё сравнивает legacy `messages` внутри snapshot + +### Step 10 - Удалить legacy coupling + +Сделать: + +- убрать `TeamData.messages` +- убрать prop plumbing `messages={data.messages}` +- обновить тесты + +## 15.3 File-by-file execution map + +Ниже не "точный diff inventory", а практическая карта, куда идти по шагам, чтобы реализация не расползлась. + +### Shared contracts and bridges + +- `src/shared/types/team.ts` + - добавить `TeamViewSnapshot` + - добавить `TeamMemberSnapshot` + - добавить `TeamMemberActivityMeta` + - удалить `messages` из snapshot contract +- `src/shared/types/api.ts` + - изменить `getData(): Promise` + - добавить `getMemberActivityMeta()` +- `src/preload/constants/ipcChannels.ts` + - добавить `TEAM_GET_MEMBER_ACTIVITY_META` +- `src/preload/index.ts` + - прокинуть `getMemberActivityMeta()` + +### Main process + +- `src/main/ipc/teams.ts` + - handler для `team:getMemberActivityMeta` + - `team:getData` теперь возвращает structural snapshot +- `src/main/services/team/TeamMessageFeedService.ts` + - новый shared normalized message feed cache/index + - используется и `getMessagesPage()`, и `getMemberActivityMeta()` +- `src/main/services/team/TeamDataService.ts` + - `getTeamData()` перестаёт включать `messages` + - больше не зовёт old `resolveMembers(..., messages)` + - `getMessagesPage()` перестаёт делать inline full normalize flow + - делегирует page slicing в shared feed service +- `src/main/services/team/TeamMemberResolver.ts` + - split на structural-only resolver +- `src/main/services/team/` + - новый `MemberActivityMetaService.ts` + - cache по `feedRevision` +- `src/main/services/team/TeamDataWorkerClient.ts` + - расширить worker ops для message feed / activity meta path + - обновить типы ответа +- `src/main/services/team/teamDataWorkerTypes.ts` + - добавить request/response ops для messages/meta path +- `src/main/workers/team-data-worker.ts` + - синхронизировать worker result types + - завести обработку new feed/meta ops + +### Renderer store and event routing + +- `src/renderer/store/slices/teamSlice.ts` + - добавить `teamMessagesByName` + - добавить `memberActivityMetaByTeam` + - добавить actions для head refresh / older pages / meta refresh / optimistic merge + - добавить single-flight request guards и stale-response guards + - добавить store-owned fallback polling control + - добавить structural sharing + no-op suppression для snapshot +- `src/renderer/store/index.ts` + - поменять routing team events + - `lead-message` / `inbox` перестают звать full `refreshTeamData()` + +### Renderer consumers + +- `src/renderer/components/team/TeamDetailView.tsx` + - переключить на snapshot + message store + memberActivityMeta + spawn statuses + - pending reply delayed refresh перевести на message-head refresh +- `src/renderer/components/team/messages/MessagesPanel.tsx` + - удалить прямой fetching logic + - читать messages из store +- `src/renderer/components/team/activity/ActivityTimeline.tsx` + - читать messages из store-backed selector или timeline view-model selector + - не держать local fetch/polling/orchestration +- `src/renderer/components/team/messages/StatusBlock.tsx` + - получать messages из store-backed source, не из snapshot prop +- `src/renderer/components/team/members/MemberDetailDialog.tsx` + - убрать `messages` prop +- `src/renderer/components/team/members/MemberMessagesTab.tsx` + - убрать прямой IPC fetch + - использовать store messages + selectors +- `src/renderer/components/team/members/MemberList.tsx` + - читать `hasPendingReply` из local overlay, не из meta +- `src/renderer/components/team/members/MemberHoverCard.tsx` + - читать facts/view-model selector, а не whole meta wrapper или snapshot messages +- `src/renderer/components/layout/PaneContent.tsx` + - не менять в этом PR, только учитывать mounted-hidden behavior + +### Graph + +- `src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts` + - pure adapter принимает `(snapshot, messages, memberActivityFacts, teamName)` +- graph-related tests + - заменить legacy `TeamData.messages` assumptions + +### Tests + +- `test/main/services/team/TeamDataService.test.ts` + - snapshot tests отдельно + - meta tests отдельно +- `test/main/ipc/teams.test.ts` + - новый IPC handler + - убрать ожидания `result.data.messages` +- `test/renderer/store/` + - refresh routing, no-op suppression, optimistic merges +- `test/renderer/components/team/` + - messages panel, member dialog, member list, pending replies +- `test/renderer/features/agent-graph/` + - graph adapter больше не зависит от snapshot messages + +## 15.4 Merge gates + +Это checkpoints, которые должны быть выполнены до merge, иначе PR выглядит "почти доделанным", но архитектурно остаётся дырявым. + +### Gate 1 - No dual message source + +- `MessagesPanel` не читает `messages` из props +- `MemberMessagesTab` не читает `messages` из team snapshot +- graph не читает `TeamData.messages` + +### Gate 2 - Event routing actually split + +- `lead-message` event тестом подтверждённо не вызывает full `refreshTeamData()` +- `inbox` event тоже не тянет full snapshot refresh по умолчанию +- `refreshMemberActivityMeta()` не дёргается без `feedChanged === true` или stale-meta condition + +### Gate 3 - Exact semantics preserved + +- `messageCount` остался exact +- `lastActiveAt` считается по authored messages, как раньше +- terminal message semantics не потеряны +- display status в renderer overlay не сломан для "no message yet but has active task" +- existing pending-reply / TTL / activity threshold constants не поменялись скрытно внутри performance refactor + +### Gate 4 - UI semantics preserved + +- optimistic send не моргает +- pending member replies всё ещё очищаются фактическим reply +- cross-team TTL badges всё ещё работают +- canonical head refresh не откатывает optimistic rows до server confirmation +- member UI не подписан на whole `TeamMemberActivityMeta` wrapper без необходимости + +### Gate 5 - Legacy field gone + +- в shared snapshot contract нет `messages` +- в merged renderer code нет чтения `selectedTeamData.messages` +- `selectedTeamData`, если поле сохранено, не является второй independently-built snapshot copy +- old mixed `TeamData` semantic comparator не пережил migration и не остался permanent hot path guard + +### Gate 6 - Shared feed cache actually used + +- `getMessagesPage()` не содержит второго самостоятельного normalize pipeline +- `MessagesPage` реально несёт `feedRevision`, а store использует его в routing/invalidation +- `getMessagesPage()` режет страницы по stable effective message identity, а не по "сырым optional ids" +- `getMemberActivityMeta()` не читает raw storage напрямую +- оба hot paths сходятся в `TeamMessageFeedService` + +### Gate 7 - Worker boundary preserved + +- expensive feed rebuild path не исполняется на main event loop +- worker ops для messages/meta path реально wired и покрыты тестом / smoke check +- packaged runtime не молча сваливается в main-thread hot path из-за пропавшего worker artifact + +### Gate 8 - Polling ownership preserved + +- `MessagesPanel` и `MemberMessagesTab` не держат собственный polling lifecycle +- fallback polling живёт только в store + +### Gate 8.5 - Single-flight preserved + +- burst events не создают пачку параллельных identical refresh requests на одну и ту же команду +- stale async responses не откатывают store после newer refresh + +### Gate 9 - No overview fan-out + +- `TeamListView` и похожие overview screens не инициируют скрытый fan-out `getMessagesPage()` / `getMemberActivityMeta()` по всем командам +- overview остаётся на structural snapshot semantics + +## 15.5 Suggested commit slices + +Если делать это не одним бесформенным diff, а нормальными кусками, я бы резал так: + +1. `refactor(team): introduce structural team snapshot contracts` + - новые shared types + - новый IPC contract для `getMemberActivityMeta()` + +2. `refactor(team): add shared team message feed cache` + - `TeamMessageFeedService` + - `getMessagesPage()` переводится на shared feed + - worker boundary расширяется для messages/meta path + +3. `refactor(team): split member activity meta from team snapshot` + - `MemberActivityMetaService` + - `TeamMemberResolver` становится structural-only + - `getData()` перестаёт возвращать `messages` + +4. `refactor(renderer): move team message orchestration into store` + - store caches and actions + - event routing split + - store-owned fallback polling + +5. `refactor(renderer): migrate team detail consumers to snapshot plus message store` + - `TeamDetailView` + - `MessagesPanel` + - `ActivityTimeline` + - `MemberDetailDialog` + - `MemberMessagesTab` + - `MemberHoverCard` + - `StatusBlock` + - graph adapter + +6. `test(team): cover snapshot split and message feed ownership` + - main tests + - store tests + - component tests + - graph tests + +Не обязательно коммитить ровно так, но как execution model это сильно снижает хаос. + +## 15.6 Mechanical execution checklist + +Это section для прямого исполнения. Идея простая: не переходить к следующему шагу, пока текущий не прошёл свой exit check. + +### Checklist 0 - Safety prep + +- убедиться, что worktree чистый +- прогнать baseline tests, которые покрывают team detail / messages / graph +- зафиксировать baseline perf probes, если уже есть локальный soak scenario + +Exit criteria: + +- baseline известен +- есть с чем сравнивать после миграции + +### Checklist 1 - Contracts first + +Сделать: + +- ввести `TeamViewSnapshot` +- ввести `TeamMemberSnapshot` +- ввести `TeamMemberActivityMeta` +- расширить existing `MessagesPage` полем `feedRevision` +- перевести `getMessagesPage()` request contract на `cursor` +- описать новые worker request/response contracts для messages/meta path + +Проверить: + +- types compile +- пока можно держать локальные compatibility adapters, но не merged aliases +- если `selectedTeamData` сохраняется на этом шаге, он уже должен reuse'ить ref canonical cache entry +- `beforeTimestamp` больше не фигурирует как canonical paging API в shared contracts + +Exit criteria: + +- transport contracts готовы +- дальше можно переписывать main/services без изобретения типов на ходу + +### Checklist 2 - Shared feed path + +Сделать: + +- добавить `TeamMessageFeedService` +- перевести `getMessagesPage()` на shared feed +- нормализовать stable effective message identity до выдачи page response +- провести expensive rebuild path через worker boundary + +Проверить: + +- один canonical normalize pipeline +- cursor строится по effective identity +- `getMessagesPage()` больше не повторяет old inline normalize flow + +Exit criteria: + +- message feed path централизован +- `getMessagesPage()` уже не является скрытой legacy дырой + +### Checklist 3 - Structural snapshot split + +Сделать: + +- `getData()` перестаёт возвращать `messages` +- `TeamMemberResolver` становится structural-only +- `MemberActivityMetaService` строится от shared feed + +Проверить: + +- snapshot без messages компилируется +- meta даёт `messageCountExact` / `lastAuthoredMessageAt` + +Exit criteria: + +- main-side split завершён +- transport границы больше не смешаны + +### Checklist 4 - Store ownership + +Сделать: + +- store держит `teamMessagesByName` +- store держит `memberActivityMetaByTeam` +- store держит fallback polling +- `refreshTeamMessagesHead()` возвращает semantic result с `feedChanged` / `headChanged` +- store делает single-flight/coalesced refresh orchestration per team +- selector возвращает merged canonical + optimistic messages +- selector layer разделяет `memberActivityFacts` и `memberActivityFeedRevision` + +Проверить: + +- компоненты ещё могут быть не переведены полностью, но orchestration уже в store + +Exit criteria: + +- fetching/polling/optimistic merge больше не размазаны по компонентам + +### Checklist 5 - UI consumers migration + +Сделать: + +- `MessagesPanel` без direct fetch/polling +- `ActivityTimeline` от store-backed messages или timeline view-model selector +- `MemberMessagesTab` без direct fetch/polling +- `MemberDetailDialog` без `messages` prop +- `MemberHoverCard` от facts/view-model selector, не от whole wrappers +- `StatusBlock` от store-backed messages +- `TeamDetailView` собирает overlay model +- UI consumers переходят на data/view-model selectors, а не на whole wrappers + +Проверить: + +- `rg` по renderer не находит direct `api.teams.getMessagesPage(` внутри этих компонентов +- `selectedTeamData.messages` больше не читается +- `MemberMessagesTab` больше не фильтрует whole team messages array прямо в render body +- `ActivityTimeline` больше не строится от snapshot message prop +- `MemberHoverCard` не подписан на whole `TeamMemberActivityMeta` wrapper + +Exit criteria: + +- UI больше не зависит от legacy message transport + +### Checklist 6 - Graph migration + +Сделать: + +- graph adapter читает snapshot + messages + memberActivityFacts + +Проверить: + +- graph open не ломается +- больше нет зависимости от `TeamData.messages` + +Exit criteria: + +- последний крупный consumer legacy messages отрезан + +### Checklist 7 - Cleanup and hard gates + +Сделать: + +- убрать compatibility plumbing +- убрать legacy fields / props +- обновить тесты и perf probes + +Проверить: + +- проходят merge gates +- проходят critical tests +- soak/perf лучше baseline + +Exit criteria: + +- PR не только компилируется, но и реально дошёл до целевого shape + +## 16. Конкретные code patterns + +## 16.1 Reusable array sharing helper + +```ts +function reuseArrayIfEqual( + prev: readonly T[], + next: readonly T[], + areEqual: (left: T, right: T) => boolean +): readonly T[] { + if (prev === next) return prev; + if (prev.length !== next.length) return next; + for (let index = 0; index < prev.length; index += 1) { + if (!areEqual(prev[index], next[index])) { + return next; + } + } + return prev; +} +``` + +## 16.2 Narrow selector pattern + +```ts +const EMPTY_MESSAGES: readonly InboxMessage[] = Object.freeze([]); +const EMPTY_MEMBER_ACTIVITY_FACTS: Readonly> = + Object.freeze({}); +const teamMessagesSelectors = new Map readonly InboxMessage[]>(); +const memberMessagesSelectors = new Map readonly InboxMessage[]>(); + +export function selectTeamSnapshot(teamName: string) { + return (state: AppState) => + state.teamDataCacheByName[teamName] ?? + (state.selectedTeamName === teamName ? state.selectedTeamData : null); +} + +export function selectTeamMessagesEntry(teamName: string) { + return (state: AppState) => state.teamMessagesByName[teamName] ?? null; +} + +function getOrCreateTeamMessagesSelector(teamName: string) { + let selector = teamMessagesSelectors.get(teamName); + if (!selector) { + selector = createMemoizedSelector( + (state: AppState) => state.teamMessagesByName[teamName]?.canonicalMessages ?? EMPTY_MESSAGES, + (state: AppState) => state.teamMessagesByName[teamName]?.optimisticMessages ?? EMPTY_MESSAGES, + (canonicalMessages, optimisticMessages) => + mergeTeamMessages(canonicalMessages, optimisticMessages) + ); + teamMessagesSelectors.set(teamName, selector); + } + return selector; +} + +export function selectTeamMessages(teamName: string) { + return getOrCreateTeamMessagesSelector(teamName); +} + +/** Low-level/internal selector. UI should usually prefer facts/revision selectors below. */ +export function selectMemberActivityMeta(teamName: string) { + return (state: AppState) => state.memberActivityMetaByTeam[teamName] ?? null; +} + +export function selectMemberActivityFacts(teamName: string) { + return (state: AppState) => + state.memberActivityMetaByTeam[teamName]?.members ?? EMPTY_MEMBER_ACTIVITY_FACTS; +} + +export function selectMemberActivityFeedRevision(teamName: string) { + return (state: AppState) => state.memberActivityMetaByTeam[teamName]?.feedRevision ?? null; +} + +function getOrCreateMemberMessagesSelector(teamName: string, memberName: string) { + const key = `${teamName}::${memberName}`; + let selector = memberMessagesSelectors.get(key); + if (!selector) { + selector = createMemoizedSelector(selectTeamMessages(teamName), (messages) => + messages.filter((message) => message.from === memberName || message.to === memberName) + ); + memberMessagesSelectors.set(key, selector); + } + return selector; +} + +export function selectMemberMessages(teamName: string, memberName: string) { + return getOrCreateMemberMessagesSelector(teamName, memberName); +} +``` + +Важно: + +- `createMemoizedSelector` здесь условное имя для любой стабильной selector factory, которую команда уже использует +- важно не конкретное API helper'а, а то, что merged selectors действительно memoized и возвращают stable refs +- fallback на `selectedTeamData` в примере выше нужен только пока поле ещё существует; если `selectedTeamData` удалён, selector упрощается до чтения `teamDataCacheByName` + +### Selector usage rule + +- `TeamDetailView`, `MemberList`, `MemberHoverCard`, `MemberDetailDialog` должны читать facts selector, а не whole meta wrapper +- routing / polling logic может читать revision selector отдельно +- components, которым нужен только message array, не должны подписываться на whole `TeamMessagesCacheEntry`, если им не нужны loading flags +- `MemberMessagesTab` по умолчанию должен читать `selectMemberMessages(teamName, memberName)` или аналогичный memoized selector, а не фильтровать whole array прямо в render body +- empty selector fallbacks должны возвращать stable frozen references, а не новый `{}` / `[]` на каждый вызов + +### Practical selector split + +Для ясности полезно сразу мыслить селекторы тремя слоями: + +- data selectors + - `selectTeamSnapshot(teamName)` + - `selectTeamMessages(teamName)` + - `selectMemberActivityFacts(teamName)` + - `selectMemberMessages(teamName, memberName)` +- control selectors + - `selectTeamMessagesEntry(teamName)` только для loading flags / cursor / hasMore + - `selectMemberActivityFeedRevision(teamName)` только для routing / stale checks +- view-model selectors + - `selectResolvedTeamMembersView(teamName)` + - `selectPendingRepliesView(teamName)` + +Правило: + +- components по умолчанию читают data/view-model selector +- control selectors не должны становиться случайным render dependency для большого UI subtree + +## 16.3 Overlay model builder + +```ts +function mergeMembersWithActivity( + members: TeamMemberSnapshot[], + activityFacts: Record, + spawnStatuses: Record +): ResolvedTeamMemberView[] { + return members.map((member) => { + const activity = activityFacts[member.name]; + const spawn = spawnStatuses[member.name]; + return { + ...member, + lastActiveAt: activity?.lastAuthoredMessageAt ?? null, + messageCount: activity?.messageCountExact ?? 0, + status: resolveDisplayMemberStatus(member, activity, spawn), + }; + }); +} +``` + +### Это важный паттерн + +View-model можно смешивать в renderer. +Transport contract смешивать нельзя. + +### Что добавляется поверх этого отдельно + +`pendingRepliesByMember` overlay надо мержить отдельным путём: + +```ts +const membersWithActivityAndPending = membersWithActivity.map((member) => ({ + ...member, + hasPendingReply: Boolean(pendingRepliesByMember[member.name]), +})); +``` + +То есть: + +- stable facts идут из `memberActivityMeta` +- ephemeral pending-reply UX идёт из local renderer state + +## 16.4 Display status helper + +```ts +function resolveDisplayMemberStatus( + member: TeamMemberSnapshot, + activity: MemberActivityMetaEntry | undefined, + spawn: MemberSpawnStatusEntry | undefined, + nowMs = Date.now() +): MemberStatus { + if (member.removedAt) return 'terminated'; + if (activity?.latestAuthoredMessageSignalsTermination) return 'terminated'; + + const lastAuthoredAt = activity?.lastAuthoredMessageAt; + if (!lastAuthoredAt) { + return member.currentTaskId ? 'active' : 'idle'; + } + + const ts = Date.parse(lastAuthoredAt); + if (!Number.isFinite(ts)) return 'unknown'; + + return nowMs - ts < 5 * 60 * 1000 ? 'active' : 'idle'; +} +``` + +Важно: + +- helper повторяет старую message-authored semantics +- task presence влияет только на case "сообщений ещё не было" +- spawn/runtime state не переписывает этот base status, а накладывается отдельно в presentation helpers + +## 17. Что обязательно не сломать + +Вот места, где проще всего внести регрессию. + +### 17.1 `sendTeamMessage()` UX + +Пользователь не должен видеть: + +- пропадающее только что отправленное сообщение +- дубль optimistic + fetched +- откат scroll position + +### 17.2 `pendingRepliesByMember` + +Если сейчас pending reply badge обновляется от локального state и timers, новый split не должен сделать его laggy. + +### 17.3 Scroll and expanded state in `MessagesPanel` + +Сообщения больше не придут как prop re-seed. +Нужно проверить, что: + +- scroll memory сохраняется +- expanded item state не сбрасывается на каждый head refresh + +### 17.4 `MemberHoverCard` + +Он сейчас читает selected team member data из snapshot. +После split не надо случайно вернуть message-derived churn в hover path. + +### 17.5 `ActivityTimeline` + +Это один из исходных hot consumers, поэтому его нельзя считать "само как-то переедет". + +После split важно проверить: + +- timeline derivations не зависят от `selectedTeamData.messages` +- hidden tab не получает wide invalidation только из-за timeline selectors +- timeline grouping не пересчитывается от whole wrapper change, если message slice фактически не менялся + +### 17.6 TeamListView / global task dialogs + +Они не должны внезапно стать зависимыми от message caches. + +Особенно важно: + +- не тащить `getMessagesPage()` / `getMemberActivityMeta()` в list-row hydration path +- не вводить скрытый fan-out по всем видимым командам +- если `StatusBlock` или похожий badge показывается в overview context, он использует только уже гидратированный cache или structural fallback и не имеет права сам инициировать hidden team hydration + +## 18. Тестовый план + +## 18.1 Main unit tests + +Нужны тесты на: + +- `getData()` не возвращает messages +- structural members строятся без message history +- `getMessagesPage()` возвращает `feedRevision`, описывающий весь normalized feed +- historical-only feed change может обновить `feedRevision` даже если top page slice тот же +- forced rebuild того же normalized feed не меняет `feedRevision` +- successful empty head fetch возвращает non-null `feedRevision` и корректно инициализирует empty canonical state +- каждый message row в page response несёт stable effective identity +- `getMemberActivityMeta()` корректно считает `lastAuthoredMessageAt` +- `getMemberActivityMeta()` сохраняет exact `messageCount` +- `getMemberActivityMeta()` корректно помечает `latestAuthoredMessageSignalsTermination` +- shared message feed cache не пересобирается без изменения feed inputs +- meta cache переиспользуется при том же `feedRevision` +- expensive rebuild path для messages/meta идёт через worker op, а не мимо worker boundary + +## 18.2 Renderer store tests + +Нужны тесты на: + +- `refreshTeamData()` no-op suppression сохраняет ref +- `selectedTeamData` reuse'ит exact same ref as `teamDataCacheByName[selectedTeamName]` +- при `selectedTeamName` switch `selectedTeamData` не продолжает указывать на snapshot предыдущей команды +- отсутствие `teamDataCacheByName[teamName]` и `memberActivityMetaByTeam[teamName]` до first success не заменяется fake placeholder objects +- `refreshTeamMessagesHead()` merge'ит новые head messages +- `refreshTeamMessagesHead()` различает `feedChanged` и `headChanged` +- `refreshTeamMessagesHead()` не возвращает невозможное состояние `feedChanged === false && headChanged === true` +- store single-flight coalescing не допускает burst из параллельных head refresh на одну команду +- head refresh и older-page load для одной команды не мутируют canonical window параллельно +- `loadingHead === true && loadingOlder === true` для одной команды не возникает +- `selectTeamMessages(teamName)` сохраняет stable ref, если canonical/optimistic inputs не менялись +- UI selectors, читающие member activity facts, не re-render'ятся только из-за смены `computedAt` / `feedRevision` +- in-flight/dirty/visibility bookkeeping не становится случайным render-driving reactive state без отдельной причины +- failure в `refreshTeamMessagesHead()` не очищает уже загруженный canonical window +- `lastFetchedAt` остаётся `null` до первого успешного head/message fetch и не меняется на failed request +- failure в `refreshMemberActivityMeta()` не очищает предыдущий facts record +- `loadOlderTeamMessages()` before head hydration не делает некорректный older-page request +- `loadOlderTeamMessages()` при `hasMore === false` делает cheap no-op +- `headHydrated === false` не сочетается с non-empty `canonicalMessages` или с `loadingOlder === true` +- `headHydrated === false` сочетается только с bootstrap `feedRevision/null`, `nextCursor/null` и `lastFetchedAt/null` +- optimistic row может жить поверх `headHydrated === false` bootstrap entry до первого successful head fetch +- optimistic send + fetched confirmation dedup +- failed optimistic send удаляет optimistic row и откатывает local pending-reply state +- optimistic row survives canonical refresh until matching `messageId` appears +- user send сам по себе не триггерит лишний `refreshMemberActivityMeta()` +- `lead-message` event больше не вызывает `refreshTeamData()` +- `task` event по-прежнему вызывает `refreshTeamData()` +- delayed waiting refresh для pending member reply зовёт `refreshTeamMessagesHead()`, а не full snapshot refresh +- hidden inactive team не получает message/meta refresh от чужих событий +- одного `selectedTeamName` без explicit visibility signal недостаточно для запуска visible-team polling/hydration policy +- late response для предыдущей команды после switch не переустанавливает `selectedTeamData` под новую выбранную команду +- hide transition не очищает уже гидратированные snapshot/message/meta caches сам по себе +- reopen после hide reuse'ит существующий cache baseline, а не требует forced cold-start reset +- `refreshMemberActivityMeta()` после lead/inbox идёт только при `feedChanged === true` или stale-meta condition +- historical-only `feedChanged === true` при `headChanged === false` всё равно запускает meta refresh +- older-page response после newer head refresh не откатывает `nextCursor` и не ломает canonical ordering +- irreconcilable merge after `feedRevision` change сбрасывает только canonical older tail и не теряет optimistic rows +- fallback polling запускается только для visible active team или local pending-reply wait state + +## 18.3 Component tests + +Нужны тесты на: + +- `MessagesPanel` initial hydration from store +- `ActivityTimeline` читает store-backed messages/view-model path, а не snapshot prop +- `MemberDetailDialog` without snapshot messages prop +- `MemberHoverCard` читает facts/view-model selector, а не whole meta wrapper +- `StatusBlock` отрабатывает member pending replies из local overlay +- overview `StatusBlock` или аналогичный badge не триггерит hidden team hydration +- graph adapter берёт messages не из snapshot +- `StatusBlock` корректно считает cross-team pending replies из message cache + local TTL +- `MessagesPanel` и `MemberMessagesTab` не содержат собственного polling/fetch orchestration +- older-page loading не ломает scroll/order при одновременном head refresh + +## 18.4 Soak / perf validation + +Нужны реальные runtime probes: + +- count of `refreshTeamData` calls +- count of suppressed no-op snapshot writes +- count of `refreshTeamMessagesHead` +- count of `refreshMemberActivityMeta` +- commit count `TeamDetailView` +- longtask count and max before/after +- IPC payload size before/after for `team:getData` + +## 19. Acceptance criteria + +Фикс можно считать правильным, если одновременно выполняется всё: + +1. `lead-message` storm больше не вызывает repeated `refreshTeamData()` для visible team +2. identical structural snapshot не меняет `selectedTeamData` ref +3. `MessagesPanel` живёт без `data.messages` prop +4. member list/status block не зависят от full messages array inside snapshot +5. graph не зависит от `TeamData.messages` +6. `MessagesPanel` и `MemberMessagesTab` не делают direct IPC fetch из компонента +7. long tasks на 4-member soak заметно падают +8. нет regressions в optimistic send, member dialog, pending replies +9. hot path `getMessagesPage()` больше не делает raw full rescan на каждый visible refresh +10. multi-team overview screens не создают hidden fan-out на `getMessagesPage()` / `getMemberActivityMeta()` +11. burst event storm не порождает параллельную очередь одинаковых head/meta refresh requests + +### Практический perf target + +Хотя бы такой: + +- skip-rate no-op structural refreshes высокий в heartbeat windows +- `team:getData` payload ощутимо меньше +- long tasks больше не накапливаются без видимых изменений UI + +## 19.1 Reviewer checklist + +Это короткий список для финальной проверки PR человеком, который не писал реализацию. + +Reviewer должен уметь ответить "да" на каждый пункт ниже без догадок: + +- `rg` по merged code не находит чтения `selectedTeamData.messages` +- `getData()` типизирован как `TeamViewSnapshot`, а не legacy mixed transport +- `getMessagesPage()` в shared API больше не использует `beforeTimestamp` как canonical paging contract +- `MessagesPage.feedRevision` выглядит как content-stable revision, а не timestamp-like token +- `getMessagesPage()` и `getMemberActivityMeta()` сходятся в один shared feed backend +- `MessagesPanel` и `MemberMessagesTab` не содержат прямых IPC fetch/polling путей +- UI member list читает facts selector или view-model selector, а не whole `TeamMemberActivityMeta` +- `selectedTeamData`, если сохранён, reuse'ит тот же ref, что и canonical cache entry +- worker path для heavy messages/meta rebuild реально задействован в нормальном runtime +- older-history merge имеет safety fallback, а не assumes append-only forever +- tests покрывают `feedChanged === true` при `headChanged === false` + +Если хотя бы на один пункт ответ "не уверен", PR ещё слишком двусмысленный и план выполнен не полностью. + +## 20. Нужен ли future split ещё дальше + +Эта секция не открывает scope текущего PR. + +Правило: + +- ничего из списка ниже не является blocker для merge текущего split +- если реализация текущего PR начинает зависеть от одного из этих future ideas, это уже scope creep и его надо отдельно остановить +- acceptance current PR определяется только секциями выше, а не будущими optional split ideas + +Возможно, но не обязательно сразу. + +### Что имеет смысл split'ить позже, если понадобится + +- task comments/history, если они станут heavy +- graph-specific activity feed +- process diagnostics/log metadata + +### Что не надо split'ить сейчас + +- `config` +- `tasks` +- `kanbanState` +- `processes` + +Они пока выглядят как разумный structural snapshot. + +То есть ответ на вопрос "мы в будущем ещё больше разрежем `getData`?" такой: + +- возможно да +- но **не надо делать это заранее** +- прямо сейчас правильная граница проходит по messages и message-derived member activity + +## 21. Отдельно про Linux task manager и "Electron 12.1 GB" + +Это важно понимать правильно. + +Если на Linux в системном мониторинге видны отдельные строки: + +- `electron` +- `chrome --type=renderer` +- `node` +- `claude-multimodel` + +то это обычно **отдельные OS processes**, а не "всё сложено в electron row". + +Следствие: + +- `electron 12.1 GB` очень похоже на реальный RSS browser/main процесса Electron +- spawned Claude/Codex/node subprocesses обычно не должны магически считаться внутрь этой строки, если они уже видны отдельно + +Это не доказывает leak само по себе, но и не выглядит как "да это просто все дети туда суммировались". + +### Что добавить для подтверждения + +Нужна отдельная main-side telemetry: + +```ts +const mem = process.memoryUsage(); +const metrics = app.getAppMetrics(); +``` + +И логировать хотя бы каждые 30s: + +- `rss` +- `heapUsed` +- `external` +- per-process Electron metrics + +Тогда станет видно: + +- реально ли main/browser process растёт +- есть ли рост после renderer recovery +- совпадает ли это с observed long stalls + +## 22. Мой итоговый вывод + +Если хочется сделать **сразу правильно**, а не делать цепочку полуфиксов, то целевой дизайн должен быть именно таким: + +- `getData(teamName)` -> structural snapshot +- `getMessagesPage(teamName, { limit, cursor })` -> message feed +- `getMemberActivityMeta(teamName)` -> lightweight message-derived overlay +- renderer store хранит их раздельно +- event routing тоже раздельный +- `refreshTeamData()` имеет structural sharing + no-op suppression + +Самый частый неправильный компромисс здесь: + +- "давайте просто сравним новый `TeamData` с предыдущим и всё" + +Это хороший emergency mitigation, но не лучший final state. + +Самый надёжный final state: + +- split boundaries +- убрать message-derived смысл из structural snapshot +- сохранить semantic guard как страховку + +Именно это я считаю вариантом, который ближе всего к "сделать один раз и правильно", а не возвращаться потом ещё на два круга переделки. diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 9586f4d0..57abe5de 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -1,5 +1,5 @@ /** - * TeamGraphAdapter — transforms Zustand TeamData → GraphDataPort. + * TeamGraphAdapter — transforms store-backed team graph input → GraphDataPort. * * This adapter owns the graph projection from team runtime state into the * reusable package port model. Renderer hooks may still read store state, but @@ -55,12 +55,18 @@ import type { LeadActivityState, MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, - TeamData, + ResolvedTeamMember, TeamProcess, TeamProvisioningProgress, + TeamViewSnapshot, } from '@shared/types/team'; import type { LeadContextUsage } from '@shared/types/team'; +export interface TeamGraphData extends TeamViewSnapshot { + members: ResolvedTeamMember[]; + messageFeed: InboxMessage[]; +} + export class TeamGraphAdapter { // ─── ES #private fields ────────────────────────────────────────────────── #lastTeamName = ''; @@ -87,7 +93,7 @@ export class TeamGraphAdapter { * Adapt team data into a GraphDataPort snapshot. */ adapt( - teamData: TeamData | null, + teamData: TeamGraphData | null, teamName: string, spawnStatuses?: Record, leadActivity?: LeadActivityState, @@ -179,7 +185,7 @@ export class TeamGraphAdapter { this.#buildMessageParticles( particles, nodes, - teamData.messages, + teamData.messageFeed, teamName, leadId, leadName, @@ -222,11 +228,11 @@ export class TeamGraphAdapter { // ─── Private: node builders ────────────────────────────────────────────── - static #getLeadMemberName(data: TeamData, teamName: string): string { + static #getLeadMemberName(data: TeamGraphData, teamName: string): string { return getGraphLeadMemberName(data, teamName); } - static #buildMemberNodeIdByName(data: TeamData, teamName: string): Map { + static #buildMemberNodeIdByName(data: TeamGraphData, teamName: string): Map { return new Map( data.members .filter((member) => !isLeadMember(member)) @@ -235,7 +241,7 @@ export class TeamGraphAdapter { } static #buildLayoutPort( - data: TeamData, + data: TeamGraphData, teamName: string, slotAssignments?: Record ): GraphLayoutPort { @@ -252,7 +258,7 @@ export class TeamGraphAdapter { (data.config.members ?? []).map((member) => getGraphStableOwnerId(member)) ); - const pushMember = (member: TeamData['members'][number] | undefined): void => { + const pushMember = (member: TeamGraphData['members'][number] | undefined): void => { if (!member) { return; } @@ -322,7 +328,7 @@ export class TeamGraphAdapter { } static #collectDuplicateStableOwnerIds( - members: readonly TeamData['members'][number][] + members: readonly TeamGraphData['members'][number][] ): string[] { const counts = new Map(); for (const member of members) { @@ -344,9 +350,9 @@ export class TeamGraphAdapter { } static #getRuntimeLabel( - providerId: TeamData['members'][number]['providerId'], - model: TeamData['members'][number]['model'], - effort: TeamData['members'][number]['effort'] + providerId: ResolvedTeamMember['providerId'], + model: ResolvedTeamMember['model'], + effort: ResolvedTeamMember['effort'] ): string | undefined { return formatTeamRuntimeSummary(providerId, model, effort); } @@ -367,7 +373,7 @@ export class TeamGraphAdapter { #buildLeadNode( nodes: GraphNode[], leadId: string, - data: TeamData, + data: TeamGraphData, teamName: string, leadName: string, pendingApprovalAgents?: Set, @@ -462,7 +468,7 @@ export class TeamGraphAdapter { nodes: GraphNode[], edges: GraphEdge[], leadId: string, - data: TeamData, + data: TeamGraphData, teamName: string, memberNodeIdByName: ReadonlyMap, spawnStatuses?: Record, @@ -565,12 +571,12 @@ export class TeamGraphAdapter { #buildTaskNodes( nodes: GraphNode[], edges: GraphEdge[], - data: TeamData, + data: TeamGraphData, teamName: string, commentReadState?: Record, memberNodeIdByName?: ReadonlyMap ): void { - const taskStateById = new Map>(); + const taskStateById = new Map>(); const taskDisplayIds = new Map(); const memberColorByName = new Map(); @@ -750,7 +756,7 @@ export class TeamGraphAdapter { #buildProcessNodes( nodes: GraphNode[], edges: GraphEdge[], - data: TeamData, + data: TeamGraphData, teamName: string, memberNodeIdByName?: ReadonlyMap ): void { @@ -828,7 +834,7 @@ export class TeamGraphAdapter { #attachActivityFeeds( nodes: GraphNode[], - data: TeamData, + data: TeamGraphData, teamName: string, leadId: string, leadName: string @@ -845,7 +851,10 @@ export class TeamGraphAdapter { } const entriesByOwnerNodeId = buildInlineActivityEntries({ - data, + data: { + ...data, + messages: data.messageFeed, + }, teamName, leadId, leadName, @@ -1006,7 +1015,7 @@ export class TeamGraphAdapter { #buildCommentParticles( particles: GraphParticle[], - data: TeamData, + data: TeamGraphData, teamName: string, leadId: string, leadName: string, @@ -1099,8 +1108,8 @@ export class TeamGraphAdapter { } static #buildMemberException( - runtimeAdvisory: TeamData['members'][number]['runtimeAdvisory'], - providerId: TeamData['members'][number]['providerId'], + runtimeAdvisory: ResolvedTeamMember['runtimeAdvisory'], + providerId: ResolvedTeamMember['providerId'], spawn: MemberSpawnStatusEntry | undefined, pendingApproval: boolean ): Pick | undefined { diff --git a/src/features/agent-graph/renderer/hooks/useGraphActivityContext.ts b/src/features/agent-graph/renderer/hooks/useGraphActivityContext.ts index ae50b51d..c633062c 100644 --- a/src/features/agent-graph/renderer/hooks/useGraphActivityContext.ts +++ b/src/features/agent-graph/renderer/hooks/useGraphActivityContext.ts @@ -1,17 +1,34 @@ import { useStore } from '@renderer/store'; -import { selectTeamDataForName } from '@renderer/store/slices/teamSlice'; +import { + selectResolvedMembersForTeamName, + selectTeamDataForName, + selectTeamMessages, +} from '@renderer/store/slices/teamSlice'; import { useShallow } from 'zustand/react/shallow'; -import type { TeamData, TeamSummary } from '@shared/types/team'; +import type { TeamSummary } from '@shared/types/team'; +import type { TeamGraphData } from '../adapters/TeamGraphAdapter'; export function useGraphActivityContext(teamName: string): { - teamData: TeamData | null; + teamData: TeamGraphData | null; teams: TeamSummary[]; } { return useStore( - useShallow((state) => ({ - teamData: selectTeamDataForName(state, teamName), - teams: state.teams, - })) + useShallow((state) => { + const snapshot = selectTeamDataForName(state, teamName); + const members = selectResolvedMembersForTeamName(state, teamName); + const messages = selectTeamMessages(state, teamName); + + return { + teamData: snapshot + ? { + ...snapshot, + members, + messageFeed: messages, + } + : null, + teams: state.teams, + }; + }) ); } diff --git a/src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.tsx b/src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.tsx index 18b4e414..3daa41d8 100644 --- a/src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.tsx +++ b/src/features/agent-graph/renderer/hooks/useGraphCreateTaskDialog.tsx @@ -3,7 +3,11 @@ import { useCallback, useMemo, useState } from 'react'; import { api } from '@renderer/api'; import { CreateTaskDialog } from '@renderer/components/team/dialogs/CreateTaskDialog'; import { useStore } from '@renderer/store'; -import { isTeamProvisioningActive, selectTeamDataForName } from '@renderer/store/slices/teamSlice'; +import { + isTeamProvisioningActive, + selectResolvedMembersForTeamName, + selectTeamDataForName, +} from '@renderer/store/slices/teamSlice'; import { useShallow } from 'zustand/react/shallow'; import type { TaskRef } from '@shared/types'; @@ -25,19 +29,17 @@ export function useGraphCreateTaskDialog(teamName: string): UseGraphCreateTaskDi }); const [submitting, setSubmitting] = useState(false); - const { teamData, createTeamTask, isTeamProvisioning } = useStore( + const { teamData, activeMembers, createTeamTask, isTeamProvisioning } = useStore( useShallow((state) => ({ teamData: selectTeamDataForName(state, teamName), + activeMembers: selectResolvedMembersForTeamName(state, teamName).filter( + (member) => !member.removedAt + ), createTeamTask: state.createTeamTask, isTeamProvisioning: isTeamProvisioningActive(state, teamName), })) ); - const activeMembers = useMemo( - () => (teamData?.members ?? []).filter((member) => !member.removedAt), - [teamData?.members] - ); - const openCreateTaskDialog = useCallback((owner = ''): void => { setDialogState({ open: true, diff --git a/src/features/agent-graph/renderer/hooks/useGraphMemberPopoverContext.ts b/src/features/agent-graph/renderer/hooks/useGraphMemberPopoverContext.ts index 6ac0fdad..92dcf194 100644 --- a/src/features/agent-graph/renderer/hooks/useGraphMemberPopoverContext.ts +++ b/src/features/agent-graph/renderer/hooks/useGraphMemberPopoverContext.ts @@ -1,19 +1,34 @@ import { useStore } from '@renderer/store'; import { getCurrentProvisioningProgressForTeam, + selectResolvedMembersForTeamName, selectTeamDataForName, } from '@renderer/store/slices/teamSlice'; import { useShallow } from 'zustand/react/shallow'; +import type { TeamGraphData } from '../adapters/TeamGraphAdapter'; + export function useGraphMemberPopoverContext(teamName: string, memberName: string) { return useStore( - useShallow((state) => ({ - teamData: teamName ? selectTeamDataForName(state, teamName) : null, - spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined, - leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined, - progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null, - memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined, - memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined, - })) + useShallow((state) => { + const snapshot = teamName ? selectTeamDataForName(state, teamName) : null; + const teamMembers = teamName ? selectResolvedMembersForTeamName(state, teamName) : []; + + return { + teamData: snapshot + ? { + ...snapshot, + members: teamMembers, + messageFeed: [], + } + : null, + teamMembers, + spawnEntry: teamName ? state.memberSpawnStatusesByTeam[teamName]?.[memberName] : undefined, + leadActivity: teamName ? state.leadActivityByTeam[teamName] : undefined, + progress: teamName ? getCurrentProvisioningProgressForTeam(state, teamName) : null, + memberSpawnSnapshot: teamName ? state.memberSpawnSnapshotsByTeam[teamName] : undefined, + memberSpawnStatuses: teamName ? state.memberSpawnStatusesByTeam[teamName] : undefined, + }; + }) ); } diff --git a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts index 88e2127e..214a61c1 100644 --- a/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/hooks/useTeamGraphAdapter.ts @@ -9,19 +9,24 @@ import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; import { getCurrentProvisioningProgressForTeam, + selectResolvedMembersForTeamName, selectTeamDataForName, + selectTeamMessages, } from '@renderer/store/slices/teamSlice'; import { useShallow } from 'zustand/react/shallow'; import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter'; import type { GraphDataPort } from '@claude-teams/agent-graph'; +import type { TeamGraphData } from '../adapters/TeamGraphAdapter'; export function useTeamGraphAdapter(teamName: string): GraphDataPort { const adapterRef = useRef(TeamGraphAdapter.create()); const { - teamData, + teamSnapshot, + members, + messages, spawnStatuses, leadActivity, leadContext, @@ -35,7 +40,9 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { ensureTeamGraphSlotAssignments, } = useStore( useShallow((s) => ({ - teamData: selectTeamDataForName(s, teamName), + teamSnapshot: selectTeamDataForName(s, teamName), + members: selectResolvedMembersForTeamName(s, teamName), + messages: selectTeamMessages(s, teamName), spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined, leadActivity: teamName ? s.leadActivityByTeam[teamName] : undefined, leadContext: teamName ? s.leadContextByTeam[teamName] : undefined, @@ -60,6 +67,17 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { return agents; }, [pendingApprovals, teamName]); + const teamData = useMemo(() => { + if (!teamSnapshot) { + return null; + } + return { + ...teamSnapshot, + members, + messageFeed: messages, + }; + }, [members, messages, teamSnapshot]); + const commentReadState = useSyncExternalStore(subscribe, getSnapshot); useEffect(() => { diff --git a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx index b157fb78..61211dbf 100644 --- a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx @@ -71,6 +71,9 @@ export const GraphActivityHud = ({ const connectorPathRefs = useRef(new Map()); const [expandedItem, setExpandedItem] = useState(null); const { teamData, teams } = useGraphActivityContext(teamName); + const teamSnapshot = teamData; + const members = teamData?.members ?? []; + const messages = teamData?.messageFeed ?? []; const ownerNodes = useMemo( () => @@ -81,21 +84,27 @@ export const GraphActivityHud = ({ [nodes] ); const leadNodeId = ownerNodes.find((node) => node.kind === 'lead')?.id ?? `lead:${teamName}`; - const leadName = teamData ? getGraphLeadMemberName(teamData, teamName) : `${teamName}-lead`; + const leadName = teamSnapshot + ? getGraphLeadMemberName({ members }, teamName) + : `${teamName}-lead`; const ownerNodeIds = useMemo(() => new Set(ownerNodes.map((node) => node.id)), [ownerNodes]); const entryMapByOwnerNodeId = useMemo(() => { - if (!teamData) { + if (!teamSnapshot) { return new Map(); } return buildInlineActivityEntries({ - data: teamData, + data: { + members, + tasks: teamSnapshot.tasks, + messages, + }, teamName, leadId: leadNodeId, leadName, ownerNodeIds, }); - }, [leadName, leadNodeId, ownerNodeIds, teamData, teamName]); - const messageContext = useMemo(() => buildMessageContext(teamData?.members), [teamData?.members]); + }, [leadName, leadNodeId, members, messages, ownerNodeIds, teamName, teamSnapshot]); + const messageContext = useMemo(() => buildMessageContext(members), [members]); const { teamNames, teamColorByName } = useStableTeamMentionMeta(teams); const { readSet } = useTeamMessagesRead(teamName); @@ -383,7 +392,7 @@ export const GraphActivityHud = ({ }; }, [enabled, forwardWheelToGraph, visibleLanes]); - if (!enabled || !teamData || visibleLanes.length === 0) { + if (!enabled || !teamSnapshot || visibleLanes.length === 0) { return null; } @@ -493,7 +502,7 @@ export const GraphActivityHud = ({ } }} teamName={teamName} - members={teamData.members} + members={members} onMemberClick={handleMemberClick} onTaskIdClick={onOpenTaskDetail} teamNames={teamNames} diff --git a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx index afc454ac..f8cf303f 100644 --- a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx +++ b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx @@ -292,14 +292,21 @@ const MemberPopoverContent = ({ ? node.domainRef.teamName : ''; const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64); - const { teamData, spawnEntry, leadActivity, progress, memberSpawnSnapshot, memberSpawnStatuses } = - useGraphMemberPopoverContext(teamName, memberName); - const member = teamData?.members.find((candidate) => candidate.name === memberName) ?? null; + const { + teamData, + teamMembers, + spawnEntry, + leadActivity, + progress, + memberSpawnSnapshot, + memberSpawnStatuses, + } = useGraphMemberPopoverContext(teamName, memberName); + const member = teamMembers.find((candidate) => candidate.name === memberName) ?? null; const provisioningPresentation = teamData && teamName ? buildTeamProvisioningPresentation({ progress, - members: teamData.members, + members: teamMembers, memberSpawnStatuses, memberSpawnSnapshot, }) diff --git a/src/main/index.ts b/src/main/index.ts index b8c79c35..695584d9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -562,6 +562,13 @@ function wireFileWatcherEvents(context: ServiceContext): void { const teamName = row.teamName.trim(); const detail = typeof row.detail === 'string' ? row.detail : ''; + if ( + teamDataService && + (row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config') + ) { + teamDataService.invalidateMessageFeed(teamName); + } + // --- Inbox change events: relay to lead + native OS notifications --- if (row.type === 'inbox') { if (reconcileScheduler) { @@ -900,6 +907,12 @@ async function initializeServices(): Promise { }); const forwardTeamChange = (event: TeamChangeEvent): void => { + if ( + teamDataService && + (event.type === 'inbox' || event.type === 'lead-message' || event.type === 'config') + ) { + teamDataService.invalidateMessageFeed(event.teamName); + } safeSendToRenderer(mainWindow, TEAM_CHANGE, event); httpServer?.broadcast('team-change', event); }; diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index f35e2628..69faa29e 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -22,6 +22,7 @@ import { TEAM_GET_DATA, TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, + TEAM_GET_MEMBER_ACTIVITY_META, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, TEAM_GET_MESSAGES_PAGE, @@ -92,7 +93,7 @@ import { parseStandaloneSlashCommand, } from '@shared/utils/slashCommands'; import crypto from 'crypto'; -import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron'; +import { app, BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron'; import * as fs from 'fs'; import * as path from 'path'; @@ -170,15 +171,16 @@ import type { TeamCreateConfigRequest, TeamCreateRequest, TeamCreateResponse, - TeamData, TeamLaunchRequest, TeamLaunchResponse, + TeamMemberActivityMeta, TeamMessageNotificationData, TeamProvisioningPrepareResult, TeamProvisioningProgress, TeamSummary, TeamTask, TeamTaskStatus, + TeamViewSnapshot, TeamUpdateConfigRequest, ToolApprovalFileContent, ToolApprovalSettings, @@ -196,6 +198,17 @@ const logger = createLogger('IPC:teams'); const seenRateLimitKeys = new Set(); const SEEN_RATE_LIMIT_KEYS_MAX = 500; +function ensureHeavyTeamDataWorkerFallbackAllowed(operation: string): void { + if (!app.isPackaged) { + return; + } + + logger.error( + `[${operation}] team-data-worker unavailable in packaged runtime; refusing main-thread fallback for heavy message/activity path` + ); + throw new Error('TEAM_DATA_WORKER_UNAVAILABLE'); +} + async function getDurableLeadTeammateRoster( teamName: string, leadName: string @@ -385,6 +398,19 @@ function checkApiErrorMessages( } } +function scanTeamMessageNotifications( + messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[], + teamName: string, + teamDisplayName: string, + projectPath?: string +): void { + if (messages.length === 0) { + return; + } + checkRateLimitMessages(messages, teamName, teamDisplayName, projectPath); + checkApiErrorMessages(messages, teamName, teamDisplayName, projectPath); +} + let teamDataService: TeamDataService | null = null; let teamProvisioningService: TeamProvisioningService | null = null; let teamMemberLogsFinder: TeamMemberLogsFinder | null = null; @@ -463,6 +489,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning); ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage); ipcMain.handle(TEAM_GET_MESSAGES_PAGE, handleGetMessagesPage); + ipcMain.handle(TEAM_GET_MEMBER_ACTIVITY_META, handleGetMemberActivityMeta); ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask); ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview); ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban); @@ -535,6 +562,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING); ipcMain.removeHandler(TEAM_SEND_MESSAGE); ipcMain.removeHandler(TEAM_GET_MESSAGES_PAGE); + ipcMain.removeHandler(TEAM_GET_MEMBER_ACTIVITY_META); ipcMain.removeHandler(TEAM_CREATE_TASK); ipcMain.removeHandler(TEAM_REQUEST_REVIEW); ipcMain.removeHandler(TEAM_UPDATE_KANBAN); @@ -702,14 +730,14 @@ async function handleListTeams(_event: IpcMainInvokeEvent): Promise> { +): Promise> { const validated = validateTeamName(teamName); if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; } const tn = validated.value!; const startedAt = Date.now(); - let data: TeamData; + let data: TeamViewSnapshot; setCurrentMainOp('team:getData'); try { // Prefer worker thread to keep main event loop responsive @@ -721,9 +749,11 @@ async function handleGetData( logger.warn( `[teams:getData] worker failed, falling back: ${workerErr instanceof Error ? workerErr.message : workerErr}` ); + ensureHeavyTeamDataWorkerFallbackAllowed('teams:getData'); data = await getTeamDataService().getTeamData(tn); } } else { + ensureHeavyTeamDataWorkerFallbackAllowed('teams:getData'); data = await getTeamDataService().getTeamData(tn); } } catch (error) { @@ -762,92 +792,9 @@ async function handleGetData( const displayName = data.config.name || tn; const projectPath = data.config.projectPath; - const live = provisioning.getLiveLeadProcessMessages(tn); - if (live.length === 0) { - checkRateLimitMessages(data.messages, tn, displayName, projectPath); - checkApiErrorMessages(data.messages, tn, displayName, projectPath); - return { success: true, data: { ...data, isAlive } }; - } - - const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); - const isLeadThoughtLike = (msg: { source?: unknown; to?: string }): boolean => - !msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session'); - const getLeadThoughtFingerprint = (msg: { - from: string; - text: string; - leadSessionId?: string; - }): string => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text)}`; - - // Collect fingerprints only for thought-like lead messages. Include leadSessionId so a - // repeated thought in a new session does not get collapsed into an old session's history. - const existingTextFingerprints = new Set(); - for (const msg of data.messages) { - if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue; - if (!isLeadThoughtLike(msg)) continue; - existingTextFingerprints.add(getLeadThoughtFingerprint(msg)); - } - - const keyFor = (m: { - messageId?: string; - timestamp: string; - from: string; - text: string; - }): string => { - if (typeof m.messageId === 'string' && m.messageId.trim().length > 0) { - return m.messageId; - } - return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`; - }; - - // Text-based fingerprints for live lead thoughts to catch duplicates with different - // messageIds inside the same session (e.g. lead-turn-* re-emits). - const leadProcessTextFingerprints = new Set(); - - // Content-based dedup for SendMessage captures: Claude Code CLI and our - // persistInboxMessage both write to inboxes/{member}.json, producing two entries - // with identical content but different messageIds. Track content fingerprints - // (from+to+text) with timestamps to collapse them within a 5-second window. - const contentSeen = new Map(); // fingerprint → timestamp ms - - const merged: typeof data.messages = []; - const seen = new Set(); - for (const msg of [...data.messages, ...live]) { - if ((msg as { source?: unknown }).source === 'lead_process' && !msg.to) { - const fp = getLeadThoughtFingerprint(msg); - // Skip if the same thought already exists in persisted history for the same session. - if (existingTextFingerprints.has(fp)) { - continue; - } - // Dedup live lead_process thoughts with the same text in the same session. - if (leadProcessTextFingerprints.has(fp)) { - continue; - } - leadProcessTextFingerprints.add(fp); - } - - // Content dedup for directed messages (SendMessage captures): - // same from+to+text within 5 seconds = duplicate from CLI + our persist. - if (typeof msg.to === 'string' && msg.to.trim().length > 0) { - const contentFp = `${msg.from}\0${msg.to}\0${(msg.text ?? '').replace(/\s+/g, ' ').slice(0, 100)}`; - const msgMs = Date.parse(msg.timestamp); - const existingMs = contentSeen.get(contentFp); - if (existingMs !== undefined && Math.abs(msgMs - existingMs) <= 5000) { - continue; // duplicate within 5s window — skip - } - contentSeen.set(contentFp, msgMs); - } - - const key = keyFor(msg); - if (seen.has(key)) continue; - seen.add(key); - merged.push(msg); - } - merged.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); - - checkRateLimitMessages(merged, tn, displayName, projectPath); - checkApiErrorMessages(merged, tn, displayName, projectPath); - return { success: true, data: { ...data, isAlive, messages: merged } }; + scanTeamMessageNotifications(live, tn, displayName, projectPath); + return { success: true, data: { ...data, isAlive } }; } async function handleGetTaskChangePresence( @@ -1698,16 +1645,71 @@ async function handleGetMessagesPage( return { success: false, error: vTeam.error ?? 'Invalid teamName' }; } const opts = (options && typeof options === 'object' ? options : {}) as { - beforeTimestamp?: string; + cursor?: string | null; limit?: number; }; const limit = Math.min(Math.max(1, opts.limit ?? 50), 200); - const beforeTimestamp = - typeof opts.beforeTimestamp === 'string' ? opts.beforeTimestamp : undefined; + const cursor = + typeof opts.cursor === 'string' ? opts.cursor : opts.cursor === null ? null : undefined; return wrapTeamHandler('getMessagesPage', async () => { - const service = getTeamDataService(); - return service.getMessagesPage(vTeam.value!, { beforeTimestamp, limit }); + let page: MessagesPage; + const notificationContext = await getTeamDataService().getTeamNotificationContext(vTeam.value!); + const worker = getTeamDataWorkerClient(); + if (worker.isAvailable()) { + try { + page = await worker.getMessagesPage(vTeam.value!, { cursor, limit }); + scanTeamMessageNotifications( + page.messages, + vTeam.value!, + notificationContext.displayName, + notificationContext.projectPath + ); + return page; + } catch (workerErr) { + logger.warn( + `[teams:getMessagesPage] worker failed, falling back: ${ + workerErr instanceof Error ? workerErr.message : workerErr + }` + ); + } + } + ensureHeavyTeamDataWorkerFallbackAllowed('teams:getMessagesPage'); + page = await getTeamDataService().getMessagesPage(vTeam.value!, { cursor, limit }); + scanTeamMessageNotifications( + page.messages, + vTeam.value!, + notificationContext.displayName, + notificationContext.projectPath + ); + return page; + }); +} + +async function handleGetMemberActivityMeta( + _event: IpcMainInvokeEvent, + teamName: unknown +): Promise> { + const vTeam = validateTeamName(teamName); + if (!vTeam.valid) { + return { success: false, error: vTeam.error ?? 'Invalid teamName' }; + } + + return wrapTeamHandler('getMemberActivityMeta', async () => { + const worker = getTeamDataWorkerClient(); + if (worker.isAvailable()) { + try { + return await worker.getMemberActivityMeta(vTeam.value!); + } catch (workerErr) { + logger.warn( + `[teams:getMemberActivityMeta] worker failed, falling back: ${ + workerErr instanceof Error ? workerErr.message : workerErr + }` + ); + } + } + ensureHeavyTeamDataWorkerFallbackAllowed('teams:getMemberActivityMeta'); + return getTeamDataService().getMemberActivityMeta(vTeam.value!); }); } diff --git a/src/main/services/team/MemberActivityMetaService.ts b/src/main/services/team/MemberActivityMetaService.ts new file mode 100644 index 00000000..3a91dc29 --- /dev/null +++ b/src/main/services/team/MemberActivityMetaService.ts @@ -0,0 +1,128 @@ +import type { TeamMessageFeedService } from './TeamMessageFeedService'; +import type { InboxMessage, MemberActivityMetaEntry, TeamMemberActivityMeta } from '@shared/types'; + +interface MemberActivityMetaCacheEntry { + feedRevision: string; + meta: TeamMemberActivityMeta; +} + +function messageSignalsTermination(message: InboxMessage | null | undefined): boolean { + if (!message) return false; + try { + const parsed = JSON.parse(message.text) as { + type?: string; + approve?: boolean; + approved?: boolean; + }; + return ( + (parsed.type === 'shutdown_response' && + (parsed.approve === true || parsed.approved === true)) || + parsed.type === 'shutdown_approved' + ); + } catch { + return false; + } +} + +function areMemberActivityEntriesEqual( + left: MemberActivityMetaEntry | undefined, + right: MemberActivityMetaEntry +): boolean { + if (!left) { + return false; + } + return ( + left.memberName === right.memberName && + left.lastAuthoredMessageAt === right.lastAuthoredMessageAt && + left.messageCountExact === right.messageCountExact && + left.latestAuthoredMessageSignalsTermination === right.latestAuthoredMessageSignalsTermination + ); +} + +function structurallyShareMemberFacts( + previous: Record | undefined, + next: Record +): Record { + if (!previous) { + return next; + } + + const nextKeys = Object.keys(next); + const previousKeys = Object.keys(previous); + let changed = nextKeys.length !== previousKeys.length; + const shared: Record = {}; + + for (const key of nextKeys) { + const nextEntry = next[key]; + const previousEntry = previous[key]; + if (!areMemberActivityEntriesEqual(previousEntry, nextEntry)) { + changed = true; + shared[key] = nextEntry; + continue; + } + shared[key] = previousEntry; + } + + return changed ? shared : previous; +} + +export class MemberActivityMetaService { + private readonly cacheByTeam = new Map(); + + constructor(private readonly feedService: TeamMessageFeedService) {} + + invalidate(teamName: string): void { + this.cacheByTeam.delete(teamName); + } + + async getMeta(teamName: string): Promise { + const feed = await this.feedService.getFeed(teamName); + const cached = this.cacheByTeam.get(teamName); + if (cached?.feedRevision === feed.feedRevision) { + return cached.meta; + } + + const latestByMember = new Map(); + const countsByMember = new Map(); + + for (const message of feed.messages) { + const memberName = typeof message.from === 'string' ? message.from.trim() : ''; + if (!memberName || memberName === 'user' || memberName === 'system') { + continue; + } + + countsByMember.set(memberName, (countsByMember.get(memberName) ?? 0) + 1); + if (!latestByMember.has(memberName)) { + latestByMember.set(memberName, message); + } + } + + const nextMembers = Object.fromEntries( + Array.from(new Set([...countsByMember.keys(), ...latestByMember.keys()])) + .sort((left, right) => left.localeCompare(right)) + .map((memberName) => { + const latestMessage = latestByMember.get(memberName) ?? null; + return [ + memberName, + { + memberName, + lastAuthoredMessageAt: latestMessage?.timestamp ?? null, + messageCountExact: countsByMember.get(memberName) ?? 0, + latestAuthoredMessageSignalsTermination: messageSignalsTermination(latestMessage), + }, + ] as const; + }) + ); + const members = structurallyShareMemberFacts(cached?.meta.members, nextMembers); + + const meta: TeamMemberActivityMeta = { + teamName, + computedAt: new Date().toISOString(), + members, + feedRevision: feed.feedRevision, + }; + + this.cacheByTeam.set(teamName, { feedRevision: feed.feedRevision, meta }); + return meta; + } +} diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 307cfa67..a8f25489 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -39,6 +39,7 @@ import { } from './cache/LeadSessionParseCache'; import { atomicWriteAsync } from './atomicWrite'; import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor'; +import { MemberActivityMetaService } from './MemberActivityMetaService'; import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils'; import { TeamConfigReader } from './TeamConfigReader'; import { TeamInboxReader } from './TeamInboxReader'; @@ -46,6 +47,7 @@ import { TeamInboxWriter } from './TeamInboxWriter'; import { TeamKanbanManager } from './TeamKanbanManager'; import { TeamMemberResolver } from './TeamMemberResolver'; import { TeamMemberRuntimeAdvisoryService } from './TeamMemberRuntimeAdvisoryService'; +import { TeamMessageFeedService } from './TeamMessageFeedService'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMetaStore } from './TeamMetaStore'; import { TeamSentMessagesStore } from './TeamSentMessagesStore'; @@ -65,7 +67,6 @@ import type { KanbanColumnId, KanbanState, MessagesPage, - ResolvedTeamMember, SendMessageRequest, SendMessageResult, TaskAttachmentMeta, @@ -74,13 +75,14 @@ import type { TaskRef, TeamConfig, TeamCreateConfigRequest, - TeamData, + TeamMemberActivityMeta, TeamMember, TeamProcess, TeamSummary, TeamTask, TeamTaskStatus, TeamTaskWithKanban, + TeamViewSnapshot, ToolCallMeta, UpdateKanbanPatch, } from '@shared/types'; @@ -98,6 +100,14 @@ const TASK_MAP_YIELD_EVERY = 250; const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification'; const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000; +function requireCanonicalMessageId(message: InboxMessage): string { + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (messageId.length > 0) { + return messageId; + } + throw new Error('Canonical team message is missing effective messageId'); +} + interface EligibleTaskCommentNotification { key: string; messageId: string; @@ -162,6 +172,8 @@ export class TeamDataService { private taskChangePresenceRepository: TaskChangePresenceRepository | null = null; private teamLogSourceTracker: TeamLogSourceTracker | null = null; private fileWatchReconcileDiagnostics = new Map(); + private readonly messageFeedService: TeamMessageFeedService; + private readonly memberActivityMetaService: MemberActivityMetaService; constructor( private readonly configReader: TeamConfigReader = new TeamConfigReader(), @@ -183,7 +195,15 @@ export class TeamDataService { private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(), private memberRuntimeAdvisoryService: TeamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(), private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache() - ) {} + ) { + this.messageFeedService = new TeamMessageFeedService({ + getConfig: (teamName) => this.configReader.getConfig(teamName), + getInboxMessages: (teamName) => this.inboxReader.getMessages(teamName), + getLeadSessionMessages: (config) => this.extractLeadSessionTexts(config), + getSentMessages: (teamName) => this.sentMessagesStore.readMessages(teamName), + }); + this.memberActivityMetaService = new MemberActivityMetaService(this.messageFeedService); + } private getController(teamName: string): AgentTeamsController { return this.controllerFactory(teamName); @@ -622,7 +642,7 @@ export class TeamDataService { await fs.promises.rm(tasksDir, { recursive: true, force: true }); } - async getTeamData(teamName: string): Promise { + async getTeamData(teamName: string): Promise { const startedAt = Date.now(); const marks: Record = {}; const mark = (label: string): void => { @@ -726,12 +746,6 @@ export class TeamDataService { warningText: 'Inboxes failed to load', load: () => this.inboxReader.listInboxNames(teamName), }); - const sentMessagesStep = startReadStep({ - label: 'sentMessages', - createFallback: () => [], - warningText: 'Sent messages failed to load', - load: () => this.sentMessagesStore.readMessages(teamName), - }); const metaMembersStep = startReadStep({ label: 'metaMembers', createFallback: () => [], @@ -756,40 +770,8 @@ export class TeamDataService { load: () => this.taskReader.getTasks(teamName), }) ); - const messagesStep = runWithConcurrencyLimit(() => - startReadStep({ - label: 'messages', - createFallback: () => [], - warningText: 'Messages failed to load', - load: () => this.inboxReader.getMessages(teamName), - }) - ); - const leadTextsStep = runWithConcurrencyLimit(() => - startReadStep({ - label: 'leadTexts', - createFallback: () => [], - warningText: 'Lead session texts failed to load', - load: () => this.extractLeadSessionTexts(config), - }) - ); - - const [ - tasksStepResult, - inboxNamesStepResult, - messagesStepResult, - leadTextsStepResult, - sentMessagesStepResult, - metaMembersStepResult, - kanbanStateStepResult, - ] = await Promise.all([ - tasksStep, - inboxNamesStep, - messagesStep, - leadTextsStep, - sentMessagesStep, - metaMembersStep, - kanbanStateStep, - ]); + const [tasksStepResult, inboxNamesStepResult, metaMembersStepResult, kanbanStateStepResult] = + await Promise.all([tasksStep, inboxNamesStep, metaMembersStep, kanbanStateStep]); // After parallelizing the top read phase, these marks no longer represent // serial stage boundaries. They now capture the actual completion time for @@ -797,178 +779,18 @@ export class TeamDataService { // diagnostics useful without mutating marks from concurrent branches. marks.tasks = tasksStepResult.completedAt; marks.inboxNames = inboxNamesStepResult.completedAt; - marks.messages = messagesStepResult.completedAt; - marks.leadTexts = leadTextsStepResult.completedAt; - marks.sentMessages = sentMessagesStepResult.completedAt; marks.metaMembers = metaMembersStepResult.completedAt; marks.kanbanState = kanbanStateStepResult.completedAt; if (tasksStepResult.warning) warnings.push(tasksStepResult.warning); if (inboxNamesStepResult.warning) warnings.push(inboxNamesStepResult.warning); - if (messagesStepResult.warning) warnings.push(messagesStepResult.warning); - if (leadTextsStepResult.warning) warnings.push(leadTextsStepResult.warning); - if (sentMessagesStepResult.warning) warnings.push(sentMessagesStepResult.warning); if (metaMembersStepResult.warning) warnings.push(metaMembersStepResult.warning); if (kanbanStateStepResult.warning) warnings.push(kanbanStateStepResult.warning); const tasks: TeamTask[] = tasksStepResult.value; const inboxNames: string[] = inboxNamesStepResult.value; - let messages: InboxMessage[] = messagesStepResult.value; - const leadTexts: InboxMessage[] = leadTextsStepResult.value; - const sentMessages: InboxMessage[] = sentMessagesStepResult.value; mark('postStart'); - if (leadTexts.length > 0) { - messages = [...messages, ...leadTexts]; - } - if (sentMessages.length > 0) { - messages = [...messages, ...sentMessages]; - } - mark('mergeMessages'); - - // Dedup: if a lead_process message text is also present in lead_session, prefer lead_session. - // This avoids double-rendering when we persist lead process messages and later load the lead JSONL. - // Exception: lead_process messages with `to` field are captured SendMessage — never dedup those. - if (leadTexts.length > 0) { - const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); - const getLeadThoughtFingerprint = ( - msg: Pick - ) => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`; - const leadSessionFingerprints = new Set(); - for (const msg of leadTexts) { - if (msg.source !== 'lead_session') continue; - leadSessionFingerprints.add(getLeadThoughtFingerprint(msg)); - } - messages = messages.filter((m) => { - if (m.source !== 'lead_process') return true; - // Captured SendMessage messages (with recipient) are real messages — never dedup - if (m.to) return true; - const fp = getLeadThoughtFingerprint(m); - return !leadSessionFingerprints.has(fp); - }); - } - mark('dedupLeadTexts'); - - // Dedup exact message copies that can appear as both live lead_process rows and - // their persisted inbox/sent-message counterpart. If the messageId is identical, - // keep a single row so the UI does not show the same SendMessage twice - // (for example "LIVE" plus the stored copy). - const duplicateMessageIds = new Set(); - const messageIdCounts = new Map(); - for (const msg of messages) { - const id = typeof msg.messageId === 'string' ? msg.messageId.trim() : ''; - if (!id) continue; - const nextCount = (messageIdCounts.get(id) ?? 0) + 1; - messageIdCounts.set(id, nextCount); - if (nextCount > 1) duplicateMessageIds.add(id); - } - if (duplicateMessageIds.size > 0) { - const choosePreferredMessage = ( - current: InboxMessage, - candidate: InboxMessage - ): InboxMessage => { - const score = (msg: InboxMessage): number => { - let value = 0; - if (msg.source !== 'lead_process') value += 4; - if (msg.read === false) value += 2; - if (msg.relayOfMessageId) value += 1; - if (msg.summary) value += 1; - if (msg.to) value += 1; - return value; - }; - const currentScore = score(current); - const candidateScore = score(candidate); - if (candidateScore !== currentScore) { - return candidateScore > currentScore ? candidate : current; - } - const currentTs = Date.parse(current.timestamp); - const candidateTs = Date.parse(candidate.timestamp); - if ( - Number.isFinite(currentTs) && - Number.isFinite(candidateTs) && - candidateTs !== currentTs - ) { - return candidateTs > currentTs ? candidate : current; - } - return current; - }; - - const dedupedById = new Map(); - const dedupedWithoutId: InboxMessage[] = []; - for (const msg of messages) { - const id = typeof msg.messageId === 'string' ? msg.messageId.trim() : ''; - if (!id) { - dedupedWithoutId.push(msg); - continue; - } - const existing = dedupedById.get(id); - if (!existing) { - dedupedById.set(id, msg); - continue; - } - dedupedById.set(id, choosePreferredMessage(existing, msg)); - } - messages = [...dedupedWithoutId, ...dedupedById.values()]; - } - mark('dedupMessageIds'); - - messages = this.linkPassiveUserReplySummaries(messages); - mark('linkPassiveUserReplySummaries'); - - // Enrich inbox messages without leadSessionId by assigning the nearest neighbor's - // session ID (by timestamp). This avoids the old forward-only propagation bug. - if (config.leadSessionId || messages.some((m) => m.leadSessionId)) { - messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); - - const anchors: { index: number; time: number; sessionId: string }[] = []; - for (let i = 0; i < messages.length; i++) { - if (messages[i].leadSessionId) { - anchors.push({ - index: i, - time: Date.parse(messages[i].timestamp), - sessionId: messages[i].leadSessionId!, - }); - } - } - - if (anchors.length > 0) { - let anchorIdx = 0; - for (let i = 0; i < messages.length; i++) { - if (messages[i].leadSessionId) { - while (anchorIdx < anchors.length - 1 && anchors[anchorIdx].index < i) { - anchorIdx++; - } - continue; - } - - const msgTime = Date.parse(messages[i].timestamp); - let bestAnchor = anchors[0]; - let bestDist = Math.abs(msgTime - bestAnchor.time); - for (const anchor of anchors) { - const dist = Math.abs(msgTime - anchor.time); - if (dist < bestDist) { - bestDist = dist; - bestAnchor = anchor; - } else if (dist > bestDist && anchor.time > msgTime) { - break; - } - } - messages[i].leadSessionId = bestAnchor.sessionId; - } - } else if (config.leadSessionId) { - for (const msg of messages) { - msg.leadSessionId = config.leadSessionId; - } - } - } - mark('attachLeadSessionIds'); - - messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); - this.annotateSlashCommandResponses(messages); - - messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); - mark('normalizeMessages'); - const metaMembers: TeamConfig['members'] = metaMembersStepResult.value; const kanbanState: KanbanState = kanbanStateStepResult.value; @@ -1000,8 +822,7 @@ export class TeamDataService { config, metaMembers, inboxNames, - tasksWithKanban, - messages + tasksWithKanban ); mark('resolveMembers'); @@ -1036,30 +857,13 @@ export class TeamDataService { const totalMs = Date.now() - startedAt; if (totalMs >= 1500) { - const counts = `counts=tasks:${tasks.length},messages:${messages.length},inboxNames:${inboxNames.length},leadTexts:${leadTexts.length},sent:${sentMessages.length},members:${members.length},processes:${processes.length}`; + const counts = `counts=tasks:${tasks.length},inboxNames:${inboxNames.length},members:${members.length},processes:${processes.length}`; logger.warn( `getTeamData team=${teamName} slow total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince( 'inboxNames' - )} messages=${msSince('messages')} leadTexts=${msSince('leadTexts')} sent=${msSince( - 'sentMessages' )} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince( 'kanbanGc' - )} post=${msBetween( - 'postStart', - 'mergeMessages' - )}/dedupLead=${msBetween('mergeMessages', 'dedupLeadTexts')}/dedupIds=${msBetween( - 'dedupLeadTexts', - 'dedupMessageIds' - )}/attachLeadSession=${msBetween( - 'dedupMessageIds', - 'attachLeadSessionIds' - )}/normalizeMessages=${msBetween( - 'attachLeadSessionIds', - 'normalizeMessages' - )}/attachKanban=${msBetween( - 'normalizeMessages', - 'attachKanban' - )}/loadPresenceIndex=${msBetween( + )} post=${msBetween('postStart', 'attachKanban')}/loadPresenceIndex=${msBetween( 'attachKanban', 'loadPresenceIndex' )}/changePresence=${msBetween( @@ -1088,21 +892,14 @@ export class TeamDataService { this.processHealthTeams.delete(teamName); } - // Cap messages to keep IPC payloads small. Full history is available - // via the paginated getMessagesPage() API. We still include a small - // batch here for backward compatibility (notifications, dedup, etc.). - const MAX_RETURN_MESSAGES = 50; - const cappedMessages = - messages.length > MAX_RETURN_MESSAGES ? messages.slice(0, MAX_RETURN_MESSAGES) : messages; - return { teamName, config, tasks: tasksWithKanban, members, - messages: cappedMessages, kanbanState, processes, + isAlive: hasAlive, warnings: warnings.length > 0 ? warnings : undefined, }; } @@ -1113,106 +910,45 @@ export class TeamDataService { */ async getMessagesPage( teamName: string, - options: { beforeTimestamp?: string; limit: number } + options: { cursor?: string | null; limit: number } ): Promise { - const config = await this.configReader.getConfig(teamName); - if (!config) { - return { messages: [], nextCursor: null, hasMore: false }; - } + const feed = await this.messageFeedService.getFeed(teamName); + let messages = feed.messages; - // Collect all messages from the same sources as getTeamData - let messages: InboxMessage[] = []; - - const [inboxMessages, leadTexts, sentMessages] = await Promise.all([ - this.inboxReader.getMessages(teamName).catch(() => [] as InboxMessage[]), - this.extractLeadSessionTexts(config).catch(() => [] as InboxMessage[]), - this.sentMessagesStore.readMessages(teamName).catch(() => [] as InboxMessage[]), - ]); - - messages = [...inboxMessages, ...leadTexts, ...sentMessages]; - - // Dedup lead_session vs lead_process (same logic as getTeamData) - if (leadTexts.length > 0) { - const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); - const getFingerprint = (msg: Pick) => - `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`; - const leadSessionFingerprints = new Set(); - for (const msg of leadTexts) { - if (msg.source === 'lead_session') leadSessionFingerprints.add(getFingerprint(msg)); - } - messages = messages.filter((m) => { - if (m.source !== 'lead_process') return true; - if (m.to) return true; - return !leadSessionFingerprints.has(getFingerprint(m)); - }); - } - - // Enrich: propagate leadSessionId to messages missing it (same as getTeamData) - if (config.leadSessionId || messages.some((m) => m.leadSessionId)) { - messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); - const anchors: { time: number; sessionId: string }[] = []; - for (const msg of messages) { - if (msg.leadSessionId) { - anchors.push({ time: Date.parse(msg.timestamp), sessionId: msg.leadSessionId }); - } - } - if (anchors.length > 0) { - for (const msg of messages) { - if (msg.leadSessionId) continue; - const msgTime = Date.parse(msg.timestamp); - let best = anchors[0]; - let bestDist = Math.abs(msgTime - best.time); - for (const a of anchors) { - const dist = Math.abs(msgTime - a.time); - if (dist < bestDist) { - bestDist = dist; - best = a; - } else if (dist > bestDist && a.time > msgTime) { - break; - } - } - msg.leadSessionId = best.sessionId; - } - } else if (config.leadSessionId) { - for (const msg of messages) { - msg.leadSessionId = config.leadSessionId; - } - } - } - - // Enrich: annotate slash command responses - this.annotateSlashCommandResponses(messages); - - // Sort newest-first, with stable tie-breaker by messageId - messages.sort((a, b) => { - const diff = Date.parse(b.timestamp) - Date.parse(a.timestamp); - if (diff !== 0) return diff; - return (a.messageId ?? '').localeCompare(b.messageId ?? ''); - }); - - // Apply cursor filter. Cursor format: "timestamp|messageId" (compound) - // to handle multiple messages sharing the same timestamp. - if (options.beforeTimestamp) { - const [cursorTs, cursorId] = options.beforeTimestamp.split('|'); + if (options.cursor) { + const [cursorTs, cursorId] = options.cursor.split('|'); const cursorMs = Date.parse(cursorTs); messages = messages.filter((m) => { const ms = Date.parse(m.timestamp); if (ms < cursorMs) return true; if (ms > cursorMs) return false; - // Same timestamp — use messageId tie-breaker if (!cursorId) return false; - return (m.messageId ?? '').localeCompare(cursorId) > 0; + return requireCanonicalMessageId(m).localeCompare(cursorId) > 0; }); } - // Paginate const hasMore = messages.length > options.limit; const page = messages.slice(0, options.limit); const lastMsg = page[page.length - 1]; const nextCursor = - hasMore && lastMsg ? `${lastMsg.timestamp}|${lastMsg.messageId ?? ''}` : null; + hasMore && lastMsg ? `${lastMsg.timestamp}|${requireCanonicalMessageId(lastMsg)}` : null; - return { messages: page, nextCursor, hasMore }; + return { messages: page, nextCursor, hasMore, feedRevision: feed.feedRevision }; + } + + async getMessageFeed( + teamName: string + ): Promise<{ teamName: string; feedRevision: string; messages: InboxMessage[] }> { + return this.messageFeedService.getFeed(teamName); + } + + async getMemberActivityMeta(teamName: string): Promise { + return this.memberActivityMetaService.getMeta(teamName); + } + + invalidateMessageFeed(teamName: string): void { + this.messageFeedService.invalidate(teamName); + this.memberActivityMetaService.invalidate(teamName); } /** @@ -1220,7 +956,7 @@ export class TeamDataService { * Mutates members in-place for efficiency (called right after resolveMembers). */ private async enrichMemberBranches( - members: ResolvedTeamMember[], + members: TeamViewSnapshot['members'], config: TeamConfig ): Promise { const leadEntry = config.members?.find((member) => isLeadMember(member)); @@ -1892,7 +1628,7 @@ export class TeamDataService { slashCommand: slashCommandMeta, }; } - return this.getController(teamName).messages.sendMessage({ + const result = this.getController(teamName).messages.sendMessage({ member: enrichedRequest.member, from: enrichedRequest.from, text: enrichedRequest.text, @@ -1913,6 +1649,8 @@ export class TeamDataService { leadSessionId: enrichedRequest.leadSessionId, attachments: enrichedRequest.attachments, }) as SendMessageResult; + this.invalidateMessageFeed(teamName); + return result; } private resolveLeadNameFromConfig(config: TeamConfig | null): string { @@ -2469,6 +2207,23 @@ export class TeamDataService { } } + async getTeamNotificationContext(teamName: string): Promise<{ + displayName: string; + projectPath?: string; + }> { + try { + const config = await this.configReader.getConfig(teamName); + const displayName = config?.name?.trim() || teamName; + const projectPath = + typeof config?.projectPath === 'string' && config.projectPath.trim().length > 0 + ? config.projectPath + : undefined; + return { displayName, projectPath }; + } catch { + return { displayName: teamName }; + } + } + async requestReview(teamName: string, taskId: string): Promise { const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName); this.getController(teamName).review.requestReview(taskId, { diff --git a/src/main/services/team/TeamDataWorkerClient.ts b/src/main/services/team/TeamDataWorkerClient.ts index 89d60c73..9b69cc90 100644 --- a/src/main/services/team/TeamDataWorkerClient.ts +++ b/src/main/services/team/TeamDataWorkerClient.ts @@ -14,7 +14,12 @@ import { Worker } from 'node:worker_threads'; import { createLogger } from '@shared/utils/logger'; import type { TeamDataWorkerRequest, TeamDataWorkerResponse } from './teamDataWorkerTypes'; -import type { MemberLogSummary, TeamData } from '@shared/types'; +import type { + MemberLogSummary, + MessagesPage, + TeamMemberActivityMeta, + TeamViewSnapshot, +} from '@shared/types'; const logger = createLogger('Service:TeamDataWorkerClient'); const WORKER_CALL_TIMEOUT_MS = 30_000; @@ -25,16 +30,20 @@ function makeId(): string { return `${Date.now()}-${crypto.randomUUID().slice(0, 12)}`; } -function resolveWorkerPath(): string | null { +function getWorkerPathCandidates(): string[] { const baseDir = typeof __dirname === 'string' && __dirname.length > 0 ? __dirname : path.dirname(fileURLToPath(import.meta.url)); - const candidates = [ + return [ path.join(baseDir, 'team-data-worker.cjs'), path.join(process.cwd(), 'dist-electron', 'main', 'team-data-worker.cjs'), ]; +} + +function resolveWorkerPath(): string | null { + const candidates = getWorkerPathCandidates(); for (const candidate of candidates) { try { @@ -75,7 +84,9 @@ export class TeamDataWorkerClient { isAvailable(): boolean { if (!this.workerPath && !this.warnedUnavailable) { this.warnedUnavailable = true; - logger.debug('team-data-worker not found; falling back to main-thread execution'); + logger.warn( + `team-data-worker not found; heavy team data paths may fall back to main-thread execution. expectedOneOf=${getWorkerPathCandidates().join(',')}` + ); } return this.workerPath !== null; } @@ -144,9 +155,22 @@ export class TeamDataWorkerClient { }); } - async getTeamData(teamName: string): Promise { + async getTeamData(teamName: string): Promise { if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName'); - return this.call('getTeamData', { teamName }) as Promise; + return this.call('getTeamData', { teamName }) as Promise; + } + + async getMessagesPage( + teamName: string, + options: { cursor?: string | null; limit: number } + ): Promise { + if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName'); + return this.call('getMessagesPage', { teamName, options }) as Promise; + } + + async getMemberActivityMeta(teamName: string): Promise { + if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName'); + return this.call('getMemberActivityMeta', { teamName }) as Promise; } async findLogsForTask( diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index 0cafac54..3035d479 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -3,15 +3,9 @@ import { createCliProvisionerNameGuard, } from '@shared/utils/teamMemberName'; import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; +import { getMemberColorByName } from '@shared/constants/memberColors'; -import type { - InboxMessage, - MemberStatus, - ResolvedTeamMember, - TeamConfig, - TeamMember, - TeamTaskWithKanban, -} from '@shared/types'; +import type { TeamConfig, TeamMember, TeamMemberSnapshot, TeamTaskWithKanban } from '@shared/types'; const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([ @@ -63,9 +57,8 @@ export class TeamMemberResolver { config: TeamConfig, metaMembers: TeamConfig['members'], inboxNames: string[], - tasks: TeamTaskWithKanban[], - messages: InboxMessage[] - ): ResolvedTeamMember[] { + tasks: TeamTaskWithKanban[] + ): TeamMemberSnapshot[] { const names = new Set(); const explicitNames = new Set(); const seenNames = new Set(); @@ -216,7 +209,7 @@ export class TeamMemberResolver { } } - const members: ResolvedTeamMember[] = []; + const members: TeamMemberSnapshot[] = []; for (const name of names) { const ownedTasks = tasks.filter((task) => task.owner === name); const currentTask = @@ -226,21 +219,15 @@ export class TeamMemberResolver { task.reviewState !== 'approved' && task.kanbanColumn !== 'approved' ) ?? null; - const memberMessages = messages.filter((message) => message.from === name); - const latestMessage = memberMessages[0] ?? null; - const status = this.resolveStatus(latestMessage, currentTask !== null); const configMember = configMemberMap.get(name); const metaMember = metaMemberMap.get(name); const agentId = configMember?.agentId ?? metaMember?.agentId; members.push({ name, agentId, - status, currentTaskId: currentTask?.id ?? null, taskCount: ownedTasks.length, - messageCount: memberMessages.length, - lastActiveAt: latestMessage?.timestamp ?? null, - color: latestMessage?.color ?? configMember?.color ?? metaMember?.color, + color: configMember?.color ?? metaMember?.color ?? getMemberColorByName(name), agentType: configMember?.agentType ?? metaMember?.agentType, role: configMember?.role ?? metaMember?.role, workflow: configMember?.workflow ?? metaMember?.workflow, @@ -277,45 +264,4 @@ export class TeamMemberResolver { }); return members; } - - private resolveStatus(message: InboxMessage | null, hasActiveTask: boolean): MemberStatus { - if (!message) { - // Member exists in config but has no messages yet — - // if they own an in_progress task they're clearly active, otherwise idle - return hasActiveTask ? 'active' : 'idle'; - } - - const structured = this.parseStructuredMessage(message.text); - if (structured) { - const typed = structured as { type?: string; approve?: boolean; approved?: boolean }; - if ( - (typed.type === 'shutdown_response' && - (typed.approve === true || typed.approved === true)) || - typed.type === 'shutdown_approved' - ) { - return 'terminated'; - } - } - - const ageMs = Date.now() - Date.parse(message.timestamp); - if (Number.isNaN(ageMs)) { - return 'unknown'; - } - if (ageMs < 5 * 60 * 1000) { - return 'active'; - } - return 'idle'; - } - - private parseStructuredMessage(text: string): Record | null { - try { - const parsed = JSON.parse(text) as unknown; - if (parsed && typeof parsed === 'object') { - return parsed as Record; - } - } catch { - // Ignore plain text. - } - return null; - } } diff --git a/src/main/services/team/TeamMessageFeedService.ts b/src/main/services/team/TeamMessageFeedService.ts new file mode 100644 index 00000000..f961e867 --- /dev/null +++ b/src/main/services/team/TeamMessageFeedService.ts @@ -0,0 +1,409 @@ +import { createHash } from 'crypto'; + +import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands'; +import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics'; + +import { getEffectiveInboxMessageId } from './inboxMessageIdentity'; + +import type { InboxMessage, TeamConfig } from '@shared/types'; + +const PASSIVE_USER_REPLY_LINK_WINDOW_MS = 15_000; + +interface TeamMessageFeedDeps { + getConfig: (teamName: string) => Promise; + getInboxMessages: (teamName: string) => Promise; + getLeadSessionMessages: (config: TeamConfig) => Promise; + getSentMessages: (teamName: string) => Promise; +} + +interface TeamMessageFeedCacheEntry { + feedRevision: string; + messages: InboxMessage[]; +} + +export interface TeamNormalizedMessageFeed { + teamName: string; + feedRevision: string; + messages: InboxMessage[]; +} + +function requireCanonicalMessageId(message: InboxMessage): string { + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (messageId.length > 0) { + return messageId; + } + throw new Error('Normalized team message is missing effective messageId'); +} + +function normalizePassiveUserReplyLinkText(value: string | undefined): string { + if (typeof value !== 'string') return ''; + return value + .trim() + .toLowerCase() + .replace(/\s+/g, ' ') + .replace(/[.!?…]+$/g, '') + .trim(); +} + +function extractPassiveUserPeerSummaryBody(text: string): string | null { + const classified = classifyIdleNotificationText(text); + if (classified?.primaryKind !== 'heartbeat' || !classified.peerSummary) { + return null; + } + + const match = /^\[to\s+user\]\s*(.*)$/i.exec(classified.peerSummary); + if (!match) { + return null; + } + + const body = match[1]?.trim() ?? ''; + return body.length > 0 ? body : null; +} + +function isLeadThoughtCandidateForSlashResult(message: InboxMessage): boolean { + if (typeof message.to === 'string' && message.to.trim().length > 0) return false; + if (message.from === 'system') return false; + return message.source === 'lead_session' || message.source === 'lead_process'; +} + +function annotateSlashCommandResponses(messages: InboxMessage[]): void { + let pendingSlash = null as InboxMessage['slashCommand'] | null; + + for (const message of messages) { + const slashCommand = + message.source === 'user_sent' + ? (message.slashCommand ?? buildStandaloneSlashCommandMeta(message.text)) + : null; + + if (slashCommand) { + pendingSlash = slashCommand; + continue; + } + + if (!pendingSlash) { + continue; + } + + if (message.messageKind === 'slash_command_result') { + continue; + } + + if (isLeadThoughtCandidateForSlashResult(message)) { + message.messageKind = 'slash_command_result'; + message.commandOutput = { + stream: 'stdout', + commandLabel: pendingSlash.command, + }; + continue; + } + + pendingSlash = null; + } +} + +function linkPassiveUserReplySummaries(messages: InboxMessage[]): InboxMessage[] { + const canonicalReplies = messages + .map((message) => { + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (!messageId || message.to !== 'user') { + return null; + } + if (classifyIdleNotificationText(message.text)) { + return null; + } + + const time = Date.parse(message.timestamp); + if (!Number.isFinite(time)) { + return null; + } + + return { + messageId, + from: message.from, + time, + normalizedSummary: normalizePassiveUserReplyLinkText(message.summary), + normalizedText: normalizePassiveUserReplyLinkText(message.text), + }; + }) + .filter((value): value is NonNullable => value !== null); + + if (canonicalReplies.length === 0) { + return messages; + } + + let didLink = false; + const linkedMessages = messages.map((message) => { + if ( + typeof message.relayOfMessageId === 'string' && + message.relayOfMessageId.trim().length > 0 + ) { + return message; + } + + const body = extractPassiveUserPeerSummaryBody(message.text); + if (!body) { + return message; + } + + const passiveTime = Date.parse(message.timestamp); + if (!Number.isFinite(passiveTime)) { + return message; + } + + const normalizedBody = normalizePassiveUserReplyLinkText(body); + if (!normalizedBody) { + return message; + } + + const matches = canonicalReplies.filter((candidate) => { + if (candidate.from !== message.from) { + return false; + } + const deltaMs = passiveTime - candidate.time; + if (deltaMs < 0 || deltaMs > PASSIVE_USER_REPLY_LINK_WINDOW_MS) { + return false; + } + if (candidate.normalizedSummary === normalizedBody) { + return true; + } + return normalizedBody.length >= 6 && candidate.normalizedText.includes(normalizedBody); + }); + + if (matches.length !== 1) { + return message; + } + + didLink = true; + return { + ...message, + relayOfMessageId: matches[0].messageId, + }; + }); + + return didLink ? linkedMessages : messages; +} + +function dedupeLeadProcessCopies( + messages: InboxMessage[], + leadTexts: readonly InboxMessage[] +): InboxMessage[] { + if (leadTexts.length === 0) { + return messages; + } + + const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); + const getFingerprint = (msg: Pick) => + `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text ?? '')}`; + + const leadSessionFingerprints = new Set(); + for (const msg of leadTexts) { + if (msg.source === 'lead_session') { + leadSessionFingerprints.add(getFingerprint(msg)); + } + } + + return messages.filter((message) => { + if (message.source !== 'lead_process') return true; + if (message.to) return true; + return !leadSessionFingerprints.has(getFingerprint(message)); + }); +} + +function choosePreferredMessage(current: InboxMessage, candidate: InboxMessage): InboxMessage { + const score = (msg: InboxMessage): number => { + let value = 0; + if (msg.source !== 'lead_process') value += 4; + if (msg.read === false) value += 2; + if (msg.relayOfMessageId) value += 1; + if (msg.summary) value += 1; + if (msg.to) value += 1; + return value; + }; + + const currentScore = score(current); + const candidateScore = score(candidate); + if (candidateScore !== currentScore) { + return candidateScore > currentScore ? candidate : current; + } + + const currentTs = Date.parse(current.timestamp); + const candidateTs = Date.parse(candidate.timestamp); + if (Number.isFinite(currentTs) && Number.isFinite(candidateTs) && candidateTs !== currentTs) { + return candidateTs > currentTs ? candidate : current; + } + + return current; +} + +function dedupeByMessageId(messages: InboxMessage[]): InboxMessage[] { + const dedupedById = new Map(); + const dedupedWithoutId: InboxMessage[] = []; + + for (const message of messages) { + const id = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (!id) { + dedupedWithoutId.push(message); + continue; + } + const existing = dedupedById.get(id); + if (!existing) { + dedupedById.set(id, message); + continue; + } + dedupedById.set(id, choosePreferredMessage(existing, message)); + } + + return [...dedupedWithoutId, ...dedupedById.values()]; +} + +function ensureEffectiveMessageIds(messages: InboxMessage[]): InboxMessage[] { + let changed = false; + const normalized = messages.map((message) => { + const effectiveMessageId = getEffectiveInboxMessageId(message); + if (!effectiveMessageId || effectiveMessageId === message.messageId) { + return message; + } + changed = true; + return { + ...message, + messageId: effectiveMessageId, + }; + }); + + return changed ? normalized : messages; +} + +function attachLeadSessionIds(config: TeamConfig, messages: InboxMessage[]): void { + if (!config.leadSessionId && !messages.some((message) => message.leadSessionId)) { + return; + } + + messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + const anchors: { time: number; sessionId: string }[] = []; + for (const message of messages) { + if (message.leadSessionId) { + anchors.push({ time: Date.parse(message.timestamp), sessionId: message.leadSessionId }); + } + } + + if (anchors.length > 0) { + for (const message of messages) { + if (message.leadSessionId) continue; + const messageTime = Date.parse(message.timestamp); + let best = anchors[0]; + let bestDistance = Math.abs(messageTime - best.time); + for (const anchor of anchors) { + const distance = Math.abs(messageTime - anchor.time); + if (distance < bestDistance) { + bestDistance = distance; + best = anchor; + } else if (distance > bestDistance && anchor.time > messageTime) { + break; + } + } + message.leadSessionId = best.sessionId; + } + return; + } + + if (!config.leadSessionId) { + return; + } + + for (const message of messages) { + message.leadSessionId = config.leadSessionId; + } +} + +function toFeedRevision(messages: readonly InboxMessage[]): string { + const stableMessages = messages.map((message) => ({ + messageId: message.messageId ?? null, + relayOfMessageId: message.relayOfMessageId ?? null, + from: message.from, + to: message.to ?? null, + text: message.text, + timestamp: message.timestamp, + read: message.read, + summary: message.summary ?? null, + color: message.color ?? null, + source: message.source ?? null, + attachments: message.attachments ?? null, + leadSessionId: message.leadSessionId ?? null, + conversationId: message.conversationId ?? null, + replyToConversationId: message.replyToConversationId ?? null, + toolSummary: message.toolSummary ?? null, + toolCalls: message.toolCalls ?? null, + messageKind: message.messageKind ?? null, + slashCommand: message.slashCommand ?? null, + commandOutput: message.commandOutput ?? null, + })); + + return createHash('sha256').update(JSON.stringify(stableMessages)).digest('hex').slice(0, 24); +} + +export class TeamMessageFeedService { + private readonly cacheByTeam = new Map(); + private readonly dirtyTeams = new Set(); + + constructor(private readonly deps: TeamMessageFeedDeps) {} + + invalidate(teamName: string): void { + this.dirtyTeams.add(teamName); + } + + async getFeed(teamName: string): Promise { + const cached = this.cacheByTeam.get(teamName); + if (cached && !this.dirtyTeams.has(teamName)) { + return { + teamName, + feedRevision: cached.feedRevision, + messages: cached.messages, + }; + } + + const config = await this.deps.getConfig(teamName); + if (!config) { + const emptyEntry = { feedRevision: toFeedRevision([]), messages: [] }; + this.cacheByTeam.set(teamName, emptyEntry); + this.dirtyTeams.delete(teamName); + return { teamName, ...emptyEntry }; + } + + const [inboxMessages, leadTexts, sentMessages] = await Promise.all([ + this.deps.getInboxMessages(teamName).catch(() => [] as InboxMessage[]), + this.deps.getLeadSessionMessages(config).catch(() => [] as InboxMessage[]), + this.deps.getSentMessages(teamName).catch(() => [] as InboxMessage[]), + ]); + + let messages = [...inboxMessages, ...leadTexts, ...sentMessages]; + messages = dedupeLeadProcessCopies(messages, leadTexts); + messages = ensureEffectiveMessageIds(messages); + messages = dedupeByMessageId(messages); + messages = linkPassiveUserReplySummaries(messages); + attachLeadSessionIds(config, messages); + annotateSlashCommandResponses(messages); + + messages.sort((left, right) => { + const diff = Date.parse(right.timestamp) - Date.parse(left.timestamp); + if (diff !== 0) return diff; + return requireCanonicalMessageId(left).localeCompare(requireCanonicalMessageId(right)); + }); + + const feedRevision = toFeedRevision(messages); + const nextEntry = + cached && cached.feedRevision === feedRevision + ? cached + : { + feedRevision, + messages, + }; + + this.cacheByTeam.set(teamName, nextEntry); + this.dirtyTeams.delete(teamName); + return { + teamName, + feedRevision: nextEntry.feedRevision, + messages: nextEntry.messages, + }; + } +} diff --git a/src/main/services/team/teamDataWorkerTypes.ts b/src/main/services/team/teamDataWorkerTypes.ts index 329e369f..ca798cc3 100644 --- a/src/main/services/team/teamDataWorkerTypes.ts +++ b/src/main/services/team/teamDataWorkerTypes.ts @@ -2,7 +2,12 @@ * Shared request/response types for the team-data-worker thread. */ -import type { MemberLogSummary, TeamData } from '@shared/types'; +import type { + MemberLogSummary, + MessagesPage, + TeamMemberActivityMeta, + TeamViewSnapshot, +} from '@shared/types'; // ── Payloads ── @@ -10,6 +15,18 @@ export interface GetTeamDataPayload { teamName: string; } +export interface GetMessagesPagePayload { + teamName: string; + options: { + cursor?: string | null; + limit: number; + }; +} + +export interface GetMemberActivityMetaPayload { + teamName: string; +} + export interface FindLogsForTaskPayload { teamName: string; taskId: string; @@ -25,8 +42,14 @@ export interface FindLogsForTaskPayload { export type TeamDataWorkerRequest = | { id: string; op: 'getTeamData'; payload: GetTeamDataPayload } + | { id: string; op: 'getMessagesPage'; payload: GetMessagesPagePayload } + | { id: string; op: 'getMemberActivityMeta'; payload: GetMemberActivityMetaPayload } | { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload }; export type TeamDataWorkerResponse = - | { id: string; ok: true; result: TeamData | MemberLogSummary[] } + | { + id: string; + ok: true; + result: TeamViewSnapshot | MessagesPage | TeamMemberActivityMeta | MemberLogSummary[]; + } | { id: string; ok: false; error: string }; diff --git a/src/main/workers/team-data-worker.ts b/src/main/workers/team-data-worker.ts index 99d1a0dd..871eba11 100644 --- a/src/main/workers/team-data-worker.ts +++ b/src/main/workers/team-data-worker.ts @@ -42,6 +42,19 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => { respond({ id: msg.id, ok: true, result }); break; } + case 'getMessagesPage': { + const result = await teamDataService.getMessagesPage( + msg.payload.teamName, + msg.payload.options + ); + respond({ id: msg.id, ok: true, result }); + break; + } + case 'getMemberActivityMeta': { + const result = await teamDataService.getMemberActivityMeta(msg.payload.teamName); + respond({ id: msg.id, ok: true, result }); + break; + } case 'findLogsForTask': { const { teamName, taskId, options } = msg.payload; const intervalsKey = options?.intervals diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index b584e5b1..008e9b7c 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -234,6 +234,9 @@ export const TEAM_SEND_MESSAGE = 'team:sendMessage'; /** Paginated messages for timeline/messages panel */ export const TEAM_GET_MESSAGES_PAGE = 'team:getMessagesPage'; +/** Lightweight message-derived member activity facts */ +export const TEAM_GET_MEMBER_ACTIVITY_META = 'team:getMemberActivityMeta'; + /** Request review for task */ export const TEAM_REQUEST_REVIEW = 'team:requestReview'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 015f2c8d..84a7121f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -126,6 +126,7 @@ import { TEAM_GET_DATA, TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, + TEAM_GET_MEMBER_ACTIVITY_META, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, TEAM_GET_MESSAGES_PAGE, @@ -263,6 +264,7 @@ import type { LeadContextUsageSnapshot, MemberFullStats, MemberLogSummary, + TeamMemberActivityMeta, MemberSpawnStatusesSnapshot, MessagesPage, NotificationTrigger, @@ -292,10 +294,10 @@ import type { TeamCreateConfigRequest, TeamCreateRequest, TeamCreateResponse, - TeamData, TeamLaunchRequest, TeamLaunchResponse, TeamMessageNotificationData, + TeamViewSnapshot, TeamProvisioningPrepareResult, TeamProvisioningProgress, TeamSummary, @@ -822,7 +824,7 @@ const electronAPI: ElectronAPI = { return invokeIpcWithResult(TEAM_LIST); }, getData: async (teamName: string) => { - return invokeIpcWithResult(TEAM_GET_DATA, teamName); + return invokeIpcWithResult(TEAM_GET_DATA, teamName); }, getTaskChangePresence: async (teamName: string) => { return invokeIpcWithResult>( @@ -883,10 +885,13 @@ const electronAPI: ElectronAPI = { }, getMessagesPage: async ( teamName: string, - options?: { beforeTimestamp?: string; limit?: number } + options?: { cursor?: string | null; limit?: number } ) => { return invokeIpcWithResult(TEAM_GET_MESSAGES_PAGE, teamName, options); }, + getMemberActivityMeta: async (teamName: string) => { + return invokeIpcWithResult(TEAM_GET_MEMBER_ACTIVITY_META, teamName); + }, createTask: async (teamName: string, request: CreateTaskRequest) => { return invokeIpcWithResult(TEAM_CREATE_TASK, teamName, request); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 5f2df4aa..d5afc4a0 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -54,25 +54,26 @@ import type { SshLastConnection, SubagentDetail, TeamChangeEvent, + UpdateSchedulePatch, TeamClaudeLogsQuery, TeamClaudeLogsResponse, TeamCreateRequest, TeamCreateResponse, - TeamData, TeamLaunchRequest, TeamLaunchResponse, + TeamMemberActivityMeta, TeamProvisioningPrepareResult, TeamProvisioningProgress, TeamsAPI, TeamSummary, TeamTask, TeamTaskStatus, + TeamViewSnapshot, TmuxAPI, TmuxStatus, TriggerTestResult, UpdateKanbanPatch, UpdaterAPI, - UpdateSchedulePatch, WaterfallData, WslClaudeRootCandidate, } from '@shared/types'; @@ -677,7 +678,7 @@ export class HttpAPIClient implements ElectronAPI { console.warn('[HttpAPIClient] teams API is not available in browser mode'); return []; }, - getData: async (_teamName: string): Promise => { + getData: async (_teamName: string): Promise => { throw new Error('Teams detail is not available in browser mode'); }, getTaskChangePresence: async (): Promise< @@ -740,7 +741,15 @@ export class HttpAPIClient implements ElectronAPI { throw new Error('Team messaging is not available in browser mode'); }, getMessagesPage: async () => { - return { messages: [], nextCursor: null, hasMore: false }; + return { messages: [], nextCursor: null, hasMore: false, feedRevision: 'empty' }; + }, + getMemberActivityMeta: async (_teamName: string): Promise => { + return { + teamName: _teamName, + computedAt: new Date(0).toISOString(), + members: {}, + feedRevision: 'empty', + }; }, createTask: async (_teamName: string, _request: CreateTaskRequest): Promise => { throw new Error('Team task creation is not available in browser mode'); diff --git a/src/renderer/components/chat/UserChatGroup.tsx b/src/renderer/components/chat/UserChatGroup.tsx index 7cd94409..4baeff2b 100644 --- a/src/renderer/components/chat/UserChatGroup.tsx +++ b/src/renderer/components/chat/UserChatGroup.tsx @@ -7,6 +7,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors' import { useTabUI } from '@renderer/hooks/useTabUI'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; +import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; @@ -398,7 +399,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React. // Get team members for @mention highlighting and team names for @team linkification const { members, teams } = useStore( useShallow((s) => ({ - members: s.selectedTeamData?.members, + members: selectResolvedMembersForTeamName(s, s.selectedTeamName), teams: s.teams, })) ); diff --git a/src/renderer/components/chat/items/TeammateMessageItem.tsx b/src/renderer/components/chat/items/TeammateMessageItem.tsx index 81ab8195..bd5bc904 100644 --- a/src/renderer/components/chat/items/TeammateMessageItem.tsx +++ b/src/renderer/components/chat/items/TeammateMessageItem.tsx @@ -10,6 +10,7 @@ import { import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; +import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting'; import { formatTokensCompact } from '@renderer/utils/formatters'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; @@ -86,7 +87,9 @@ export const TeammateMessageItem: React.FC = ({ const { isLight } = useTheme(); // Get team members for @mention highlighting - const members = useStore(useShallow((s) => s.selectedTeamData?.members)); + const members = useStore( + useShallow((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName)) + ); const memberColorMap = useMemo( () => (members ? buildMemberColorMap(members) : new Map()), [members] diff --git a/src/renderer/components/team/TaskTooltip.tsx b/src/renderer/components/team/TaskTooltip.tsx index 4e5501f4..97471ad5 100644 --- a/src/renderer/components/team/TaskTooltip.tsx +++ b/src/renderer/components/team/TaskTooltip.tsx @@ -4,6 +4,7 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; +import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers'; import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils'; import { getTaskKanbanColumn } from '@shared/utils/reviewState'; @@ -70,14 +71,16 @@ export const TaskTooltip = ({ children, side = 'top', }: TaskTooltipProps): React.JSX.Element => { - const { selectedTeamName, selectedTeamData, globalTasks, teamByName } = useStore( - useShallow((s) => ({ - selectedTeamName: s.selectedTeamName, - selectedTeamData: s.selectedTeamData, - globalTasks: s.globalTasks, - teamByName: s.teamByName, - })) - ); + const { selectedTeamName, selectedTeamData, selectedTeamMembers, globalTasks, teamByName } = + useStore( + useShallow((s) => ({ + selectedTeamName: s.selectedTeamName, + selectedTeamData: s.selectedTeamData, + selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName), + globalTasks: s.globalTasks, + teamByName: s.teamByName, + })) + ); const task = useMemo(() => { if (teamName && selectedTeamName === teamName) { @@ -105,13 +108,13 @@ export const TaskTooltip = ({ const members = useMemo(() => { if (teamName && selectedTeamName === teamName) { - return selectedTeamData?.members ?? []; + return selectedTeamMembers; } if (!teamName && task && selectedTeamName === (task as { teamName?: string }).teamName) { - return selectedTeamData?.members ?? []; + return selectedTeamMembers; } return []; - }, [selectedTeamData, selectedTeamName, teamName, task]); + }, [selectedTeamMembers, selectedTeamName, teamName, task]); const colorMap = useMemo( () => (members ? buildMemberColorMap(members) : new Map()), diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index c53f03c4..9d6d0f71 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -23,6 +23,9 @@ import { useStore } from '@renderer/store'; import { getCurrentProvisioningProgressForTeam, isTeamProvisioningActive, + selectResolvedMemberForTeamName, + selectResolvedMembersForTeamName, + selectTeamMemberSnapshotsForName, } from '@renderer/store/slices/teamSlice'; import { createChipFromSelection } from '@renderer/utils/chipUtils'; import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath'; @@ -740,16 +743,18 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( }: TeamMemberDetailDialogBridgeProps): React.JSX.Element | null { const { leadActivity, + liveMember, progress, - members: launchMembers, + launchMembers, memberSpawnStatuses, memberSpawnSnapshot, spawnEntry, } = useStore( useShallow((s) => ({ leadActivity: s.leadActivityByTeam[teamName], + liveMember: member ? selectResolvedMemberForTeamName(s, teamName, member.name) : null, progress: getCurrentProvisioningProgressForTeam(s, teamName), - members: s.selectedTeamName === teamName ? (s.selectedTeamData?.members ?? []) : [], + launchMembers: selectTeamMemberSnapshotsForName(s, teamName), memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined, @@ -772,7 +777,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( (null); const wasProvisioningRef = useRef(false); - const pendingReplyRefreshTimerRef = useRef(null); const handleOpenGraphTab = useCallback(() => { const state = useStore.getState(); const displayName = state.teamByName[teamName]?.displayName ?? teamName; @@ -898,7 +902,7 @@ export const TeamDetailView = ({ initialActivityFilter, } = (e as CustomEvent).detail ?? {}; if (tn !== teamName || !data) return; - const member = data.members.find((m: { name: string }) => m.name === memberName); + const member = members.find((m: { name: string }) => m.name === memberName); if (member) { setSelectedMember(member); setSelectedMemberView({ @@ -1059,6 +1063,7 @@ export const TeamDetailView = ({ const { data, + members, loading, error, projects, @@ -1088,6 +1093,7 @@ export const TeamDetailView = ({ clearProvisioningError, isTeamProvisioning, refreshTeamData, + syncTeamPendingReplyRefresh, kanbanFilterQuery, clearKanbanFilter, softDeleteTask, @@ -1133,9 +1139,11 @@ export const TeamDetailView = ({ clearProvisioningError: s.clearProvisioningError, isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false, data: s.selectedTeamName === teamName ? s.selectedTeamData : null, + members: selectResolvedMembersForTeamName(s, teamName), loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false, error: s.selectedTeamName === teamName ? s.selectedTeamError : null, refreshTeamData: s.refreshTeamData, + syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh, kanbanFilterQuery: s.kanbanFilterQuery, clearKanbanFilter: s.clearKanbanFilter, softDeleteTask: s.softDeleteTask, @@ -1169,13 +1177,12 @@ export const TeamDetailView = ({ diagnostic.count += 1; const commitMs = performance.now() - renderStartedAtRef.current; - const messagesCount = data?.messages.length ?? 0; const tasksCount = data?.tasks.length ?? 0; - const membersCount = data?.members.length ?? 0; + const membersCount = members.length; const processesCount = data?.processes.length ?? 0; const shouldWarnSlow = commitMs >= TEAM_DETAIL_COMMIT_WARN_MS; const shouldWarnBurst = diagnostic.count >= TEAM_DETAIL_RENDER_BURST_WARN_COUNT; - const shouldWarnLarge = messagesCount >= 150 || tasksCount >= 80; + const shouldWarnLarge = tasksCount >= 80; if ( (shouldWarnSlow || shouldWarnBurst || shouldWarnLarge) && @@ -1187,7 +1194,7 @@ export const TeamDetailView = ({ now - diagnostic.windowStartedAt } activeTab=${isThisTabActive ? 'yes' : 'no'} paneFocused=${isPaneFocused ? 'yes' : 'no'} loading=${ loading ? 'yes' : 'no' - } messages=${messagesCount} tasks=${tasksCount} members=${membersCount} processes=${processesCount} panel=${messagesPanelMode}` + } tasks=${tasksCount} members=${membersCount} processes=${processesCount} panel=${messagesPanelMode}` ); } }); @@ -1307,30 +1314,20 @@ export const TeamDetailView = ({ ); // Keep team message state fresh while we are explicitly waiting for a reply. - // Use a delayed single-shot refresh instead of a tight polling loop so we - // don't keep rewriting the whole team snapshot every 2 seconds. + // This stays enabled even for hidden mounted tabs, because the waiting state + // is renderer-local and should keep its lightweight polling until resolved. useEffect(() => { - if (pendingReplyRefreshTimerRef.current != null) { - window.clearTimeout(pendingReplyRefreshTimerRef.current); - pendingReplyRefreshTimerRef.current = null; - } - - if (!isThisTabActive) return; - if (!data?.isAlive) return; - if (Object.keys(pendingRepliesByMember).length === 0) return; - - pendingReplyRefreshTimerRef.current = window.setTimeout(() => { - pendingReplyRefreshTimerRef.current = null; - void refreshTeamData(teamName, { withDedup: true }); - }, TEAM_PENDING_REPLY_REFRESH_DELAY_MS); + const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0; + syncTeamPendingReplyRefresh( + teamName, + Boolean(data?.isAlive) && hasPendingReplies, + TEAM_PENDING_REPLY_REFRESH_DELAY_MS + ); return () => { - if (pendingReplyRefreshTimerRef.current != null) { - window.clearTimeout(pendingReplyRefreshTimerRef.current); - pendingReplyRefreshTimerRef.current = null; - } + syncTeamPendingReplyRefresh(teamName, false); }; - }, [isThisTabActive, data, pendingRepliesByMember, refreshTeamData, teamName]); + }, [data?.isAlive, pendingRepliesByMember, syncTeamPendingReplyRefresh, teamName]); useEffect(() => { if (!projectId) return; @@ -1364,9 +1361,9 @@ export const TeamDetailView = ({ // Live git branch tracking for the lead project and member worktrees const teamProjectPath = data?.config.projectPath?.trim() ?? null; const leadProjectPath = useMemo(() => { - const explicitLeadPath = data?.members.find((member) => isLeadMember(member))?.cwd?.trim(); + const explicitLeadPath = members.find((member) => isLeadMember(member))?.cwd?.trim(); return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath; - }, [data?.members, teamProjectPath]); + }, [members, teamProjectPath]); const branchSyncPaths = useMemo(() => { const uniquePaths = new Map(); const addPath = (candidate: string | null | undefined): void => { @@ -1378,12 +1375,12 @@ export const TeamDetailView = ({ }; addPath(leadProjectPath); - for (const member of data?.members ?? []) { + for (const member of members) { addPath(member.cwd); } return Array.from(uniquePaths.values()); - }, [data?.members, leadProjectPath]); + }, [members, leadProjectPath]); useBranchSync(branchSyncPaths, { live: true }); const trackedBranches = useStore( useShallow((s) => @@ -1401,7 +1398,7 @@ export const TeamDetailView = ({ const membersWithLiveBranches = useMemo(() => { if (!data) return []; - return data.members.map((member) => { + return members.map((member) => { const memberPath = member.cwd?.trim(); const nextGitBranch = memberPath && !isLeadMember(member) && leadBranch !== null @@ -1423,7 +1420,7 @@ export const TeamDetailView = ({ } return nextMember; }); - }, [data, leadBranch, trackedBranches]); + }, [leadBranch, members, trackedBranches]); // Filter sessions to team-only using sessionHistory + leadSessionId const teamSessionIds = useMemo(() => { @@ -1787,7 +1784,6 @@ export const TeamDetailView = ({ mountPoint: messagesPanelMountPoint, members: activeMembers, tasks: data?.tasks ?? [], - messages: data?.messages ?? [], isTeamAlive: data?.isAlive, timeWindow, teamSessionIds, @@ -1805,7 +1801,6 @@ export const TeamDetailView = ({ activeMembers, data?.config.leadSessionId, data?.isAlive, - data?.messages, data?.tasks, handleCreateTaskFromMessage, handleOpenTask, @@ -2482,7 +2477,7 @@ export const TeamDetailView = ({ open={requestChangesTaskId !== null} teamName={teamName} taskId={requestChangesTaskId} - members={data?.members ?? []} + members={members} onCancel={() => setRequestChangesTaskId(null)} onSubmit={(comment, taskRefs) => { if (!requestChangesTaskId) { @@ -2509,7 +2504,6 @@ export const TeamDetailView = ({ teamName={teamName} members={membersWithLiveBranches} tasks={data.tasks} - messages={data.messages} initialTab={selectedMemberView?.initialTab} initialActivityFilter={selectedMemberView?.initialActivityFilter} isTeamAlive={data.isAlive} @@ -2858,7 +2852,7 @@ export const TeamDetailView = ({ if (task) setSelectedTask(task); }} onOpenMemberProfile={(memberName, options) => { - const member = data.members.find((m) => m.name === memberName); + const member = members.find((m) => m.name === memberName); if (member) { setSelectedMember(member); setSelectedMemberView({ diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index d9ea2670..f9d5e556 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -56,6 +56,7 @@ import { import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog'; import type { TeamListFilterState } from './TeamListFilterPopover'; import type { + TeamMemberSnapshot, ResolvedTeamMember, TeamCreateRequest, TeamLaunchRequest, @@ -94,6 +95,17 @@ function folderName(fullPath: string): string { return getBaseName(fullPath) || fullPath; } +function resolveLaunchDialogMembers(members: readonly TeamMemberSnapshot[]): ResolvedTeamMember[] { + return members.map((member) => { + return { + ...member, + status: member.currentTaskId ? 'active' : 'idle', + messageCount: 0, + lastActiveAt: null, + }; + }); +} + function renderMemberChips(members: TeamSummaryMember[], isLight: boolean): React.JSX.Element { const teamColorMap = buildMemberColorMap(members); return ( @@ -625,7 +637,7 @@ export const TeamListView = (): React.JSX.Element => { try { const data = await api.teams.getData(teamName); setLaunchDialogTeamName(teamName); - setLaunchDialogMembers(data.members ?? []); + setLaunchDialogMembers(resolveLaunchDialogMembers(data.members ?? [])); setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath); setLaunchDialogOpen(true); } catch (err) { diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx index 5666e93d..337792af 100644 --- a/src/renderer/components/team/ToolApprovalSheet.tsx +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; +import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { shortenDisplayPath } from '@renderer/utils/pathDisplay'; import { highlightLines } from '@renderer/utils/syntaxHighlighter'; import { AlertTriangle, FileText, MessageCircleQuestion, Search, Terminal } from 'lucide-react'; @@ -149,6 +150,7 @@ export const ToolApprovalSheet: React.FC = () => { teams, selectedTeamName, selectedTeamData, + selectedTeamMembers, } = useStore( useShallow((s) => ({ pendingApprovals: s.pendingApprovals, @@ -157,6 +159,7 @@ export const ToolApprovalSheet: React.FC = () => { teams: s.teams, selectedTeamName: s.selectedTeamName, selectedTeamData: s.selectedTeamData, + selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName), })) ); const { isLight } = useTheme(); @@ -273,9 +276,9 @@ export const ToolApprovalSheet: React.FC = () => { // Resolve teammate color for MemberBadge (when source !== 'lead') const sourceColor = useMemo(() => { if (!current || current.source === 'lead') return undefined; - const member = selectedTeamData?.members?.find((m) => m.name === current.source); + const member = selectedTeamMembers.find((m) => m.name === current.source); return member?.color; - }, [current, selectedTeamData?.members]); + }, [current, selectedTeamMembers]); if (!current) return null; diff --git a/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx b/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx index 2445a6c6..c24bf8c7 100644 --- a/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useStore } from '@renderer/store'; +import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { buildTaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest'; import { ExternalLink } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -24,6 +25,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { closeGlobalTaskDetail, selectedTeamName, selectedTeamData, + selectedTeamMembers, selectedTeamLoading, selectedTeamError, selectTeam, @@ -36,6 +38,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { closeGlobalTaskDetail: s.closeGlobalTaskDetail, selectedTeamName: s.selectedTeamName, selectedTeamData: s.selectedTeamData, + selectedTeamMembers: selectResolvedMembersForTeamName(s, s.selectedTeamName), selectedTeamLoading: s.selectedTeamLoading, selectedTeamError: s.selectedTeamError, selectTeam: s.selectTeam, @@ -94,8 +97,8 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { }, [globalTaskDetail, globalTasks, isFullTeamLoaded, selectedTeamData]); const activeMembers = useMemo( - () => (isFullTeamLoaded ? (selectedTeamData?.members.filter((m) => !m.removedAt) ?? []) : []), - [isFullTeamLoaded, selectedTeamData] + () => (isFullTeamLoaded ? selectedTeamMembers.filter((m) => !m.removedAt) : []), + [isFullTeamLoaded, selectedTeamMembers] ); const handleOpenTeam = useCallback((): void => { diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index dcca38bb..d3016479 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -36,7 +36,10 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; -import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; +import { + isTeamProvisioningActive, + selectResolvedMembersForTeamName, +} from '@renderer/store/slices/teamSlice'; import { isGeminiUiFrozen, normalizeCreateLaunchProviderForUi, @@ -281,7 +284,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const [prepareWarnings, setPrepareWarnings] = useState([]); const [prepareChecks, setPrepareChecks] = useState([]); const prepareRequestSeqRef = useRef(0); - const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []); + const storeMembers = useStore((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName)); const previousLaunchParams = useStore((s) => effectiveTeamName ? s.launchParamsByTeam[effectiveTeamName] : undefined ); diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 258a2357..495da40a 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -1,9 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { - buildGraphMemberNodeIdForMember, - buildInlineActivityEntries, -} from '@features/agent-graph/renderer'; import { Button } from '@renderer/components/ui/button'; import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; @@ -20,7 +16,6 @@ import { MemberStatsTab } from './MemberStatsTab'; import { MemberTasksTab } from './MemberTasksTab'; import type { - InboxMessage, LeadActivityState, MemberSpawnStatusEntry, ResolvedTeamMember, @@ -33,7 +28,6 @@ interface MemberDetailDialogProps { teamName: string; members: ResolvedTeamMember[]; tasks: TeamTaskWithKanban[]; - messages: InboxMessage[]; initialTab?: MemberDetailTab; initialActivityFilter?: MemberActivityFilter; isTeamAlive?: boolean; @@ -57,7 +51,6 @@ export const MemberDetailDialog = ({ teamName, members, tasks, - messages, initialTab = 'tasks', initialActivityFilter = 'all', isTeamAlive, @@ -78,34 +71,7 @@ export const MemberDetailDialog = ({ () => (member ? tasks.filter((t) => t.owner === member.name) : []), [tasks, member] ); - - const seedMemberMessages = useMemo( - () => (member ? messages.filter((m) => m.from === member.name || m.to === member.name) : []), - [messages, member] - ); - const memberMessages = seedMemberMessages; - const memberActivityCount = useMemo(() => { - if (!member) { - return 0; - } - const leadId = `lead:${teamName}`; - const leadName = - members.find((candidate) => isLeadMember(candidate))?.name ?? `${teamName}-lead`; - const ownerNodeId = - member.name === leadName ? leadId : buildGraphMemberNodeIdForMember(teamName, member); - const entries = buildInlineActivityEntries({ - data: { - members, - tasks, - messages: memberMessages, - }, - teamName, - leadId, - leadName, - ownerNodeIds: new Set([leadId, ownerNodeId]), - }); - return (entries.get(ownerNodeId) ?? []).length; - }, [member, memberMessages, members, tasks, teamName]); + const memberActivityCount = member?.messageCount ?? 0; const inProgressTasks = useMemo( () => memberTasks.filter((t) => t.status === 'in_progress').length, @@ -206,7 +172,6 @@ export const MemberDetailDialog = ({ { - const isSelectedTeam = Boolean(effectiveTeamName && s.selectedTeamName === effectiveTeamName); - const selectedTeamData = isSelectedTeam ? s.selectedTeamData : null; - return { - member: selectedTeamData?.members.find((m) => m.name === name) ?? null, - members: selectedTeamData?.members ?? [], - isTeamAlive: selectedTeamData?.isAlive, + } = useStore( + useShallow((s) => ({ + member: effectiveTeamName + ? selectResolvedMemberForTeamName(s, effectiveTeamName, name) + : null, + teamMembers: effectiveTeamName ? selectTeamMemberSnapshotsForName(s, effectiveTeamName) : [], + tasks: effectiveTeamName ? selectTeamTasksForName(s, effectiveTeamName) : [], + isTeamAlive: effectiveTeamName ? selectTeamIsAliveForName(s, effectiveTeamName) : undefined, progress: effectiveTeamName ? getCurrentProvisioningProgressForTeam(s, effectiveTeamName) : null, @@ -80,21 +89,16 @@ export const MemberHoverCard = ({ ? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name] : undefined, leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined, - }; - }); - const openMemberProfile = useStore((s) => s.openMemberProfile); - const tasks = useStore((s) => - effectiveTeamName && s.selectedTeamName === effectiveTeamName - ? s.selectedTeamData?.tasks - : undefined + })) ); + const openMemberProfile = useStore((s) => s.openMemberProfile); if (!member) { return <>{children}; } const launchJoinMilestones = getLaunchJoinMilestonesFromMembers({ - members, + members: teamMembers, memberSpawnStatuses, memberSpawnSnapshot, }); @@ -117,10 +121,9 @@ export const MemberHoverCard = ({ const presenceLabel = launchPresentation.presenceLabel; const dotClass = launchPresentation.dotClass; const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle; - const currentTask: TeamTaskWithKanban | null = - member.currentTaskId && tasks - ? (tasks.find((t) => t.id === member.currentTaskId) ?? null) - : null; + const currentTask: TeamTaskWithKanban | null = member.currentTaskId + ? (tasks.find((t) => t.id === member.currentTaskId) ?? null) + : null; const reviewTask: TeamTaskWithKanban | null = tasks ? (tasks.find( (task) => diff --git a/src/renderer/components/team/members/MemberMessagesTab.tsx b/src/renderer/components/team/members/MemberMessagesTab.tsx index 3fc87f1c..75984c1a 100644 --- a/src/renderer/components/team/members/MemberMessagesTab.tsx +++ b/src/renderer/components/team/members/MemberMessagesTab.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { buildInlineActivityEntries } from '@features/agent-graph/renderer'; -import { api } from '@renderer/api'; import { ActivityItem } from '@renderer/components/team/activity/ActivityItem'; import { buildMessageContext, @@ -10,17 +9,18 @@ import { import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog'; import { Button } from '@renderer/components/ui/button'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; -import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; +import { useStore } from '@renderer/store'; +import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { isLeadMember } from '@shared/utils/leadDetection'; +import { useShallow } from 'zustand/react/shallow'; import type { MemberActivityFilter } from './memberDetailTypes'; import type { TimelineItem } from '@renderer/components/team/activity/LeadThoughtsGroup'; -import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; +import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; interface MemberMessagesTabProps { - messages: InboxMessage[]; teamName: string; memberName: string; members: ResolvedTeamMember[]; @@ -31,7 +31,6 @@ interface MemberMessagesTabProps { } const MAX_MESSAGES = 100; -const MEMBER_MESSAGES_PAGE_SIZE = 50; const FILTER_OPTIONS: readonly { value: MemberActivityFilter; label: string }[] = [ { value: 'all', label: 'All' }, { value: 'messages', label: 'Messages' }, @@ -39,7 +38,6 @@ const FILTER_OPTIONS: readonly { value: MemberActivityFilter; label: string }[] ]; export const MemberMessagesTab = ({ - messages, teamName, memberName, members, @@ -48,12 +46,15 @@ export const MemberMessagesTab = ({ onCreateTask, onTaskClick, }: MemberMessagesTabProps): React.JSX.Element => { - const [pagedMessages, setPagedMessages] = useState([]); - const [nextCursor, setNextCursor] = useState(null); - const [hasMore, setHasMore] = useState(false); - const [loading, setLoading] = useState(false); const [activityFilter, setActivityFilter] = useState(initialFilter); const [expandedItem, setExpandedItem] = useState(null); + const { messages, messagesState, loadOlderTeamMessages } = useStore( + useShallow((s) => ({ + messages: selectMemberMessagesForTeamMember(s, teamName, memberName), + messagesState: teamName ? s.teamMessagesByName[teamName] : undefined, + loadOlderTeamMessages: s.loadOlderTeamMessages, + })) + ); const { readSet } = useTeamMessagesRead(teamName); const leadId = `lead:${teamName}`; const leadName = useMemo( @@ -69,75 +70,24 @@ export const MemberMessagesTab = ({ setActivityFilter(initialFilter); }, [initialFilter, memberName, teamName]); - useEffect(() => { - let cancelled = false; - setPagedMessages([]); - setNextCursor(null); - setHasMore(false); - setLoading(true); - - void (async () => { - try { - const page = await api.teams.getMessagesPage(teamName, { - limit: MEMBER_MESSAGES_PAGE_SIZE, - }); - if (cancelled) return; - const memberPageMessages = page.messages.filter( - (message) => message.from === memberName || message.to === memberName - ); - setPagedMessages(memberPageMessages); - setNextCursor(page.nextCursor); - setHasMore(page.hasMore); - } catch { - if (!cancelled) { - setPagedMessages([]); - setNextCursor(null); - setHasMore(false); - } - } finally { - if (!cancelled) setLoading(false); - } - })(); - - return () => { - cancelled = true; - }; - }, [teamName, memberName]); - const loadOlderMessages = useCallback(async () => { - if (!nextCursor || loading) return; - setLoading(true); - try { - const page = await api.teams.getMessagesPage(teamName, { - beforeTimestamp: nextCursor, - limit: MEMBER_MESSAGES_PAGE_SIZE, - }); - const memberPageMessages = page.messages.filter( - (message) => message.from === memberName || message.to === memberName - ); - setPagedMessages((prev) => mergeTeamMessages(prev, memberPageMessages)); - setNextCursor(page.nextCursor); - setHasMore(page.hasMore); - } catch { - // best-effort - } finally { - setLoading(false); + if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) { + return; } - }, [teamName, memberName, nextCursor, loading]); + await loadOlderTeamMessages(teamName); + }, [loadOlderTeamMessages, messagesState, teamName]); - const effectiveMessages = useMemo( - () => mergeTeamMessages(messages, pagedMessages), - [messages, pagedMessages] - ); + const loading = (messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false); + const hasMore = messagesState?.hasMore ?? false; const filteredMessages = useMemo( () => - filterTeamMessages(effectiveMessages, { + filterTeamMessages(messages, { timeWindow: null, filter: { from: new Set(), to: new Set(), showNoise: true }, searchQuery: '', }), - [effectiveMessages] + [messages] ); const activityEntries = useMemo(() => { diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 5638726c..791a8513 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -1,7 +1,6 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Sheet, type SheetRef } from 'react-modal-sheet'; -import { api } from '@renderer/api'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; @@ -9,7 +8,7 @@ import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMe import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useStore } from '@renderer/store'; -import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; +import { selectTeamMessages } from '@renderer/store/slices/teamSlice'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics'; @@ -70,8 +69,6 @@ interface MessagesPanelProps { members: ResolvedTeamMember[]; /** All team tasks. */ tasks: TeamTaskWithKanban[]; - /** All raw messages from team data. */ - messages: InboxMessage[]; /** Whether the team is alive. */ isTeamAlive?: boolean; /** Live lead activity status for the current team. */ @@ -109,7 +106,6 @@ export const MessagesPanel = memo(function MessagesPanel({ mountPoint, members, tasks, - messages, isTeamAlive, leadActivity, leadContextUpdatedAt, @@ -133,6 +129,9 @@ export const MessagesPanel = memo(function MessagesPanel({ lastSendMessageResult, teams, openTeamTab, + messages, + messagesState, + loadOlderTeamMessages, } = useStore( useShallow((s) => ({ sendTeamMessage: s.sendTeamMessage, @@ -142,79 +141,23 @@ export const MessagesPanel = memo(function MessagesPanel({ lastSendMessageResult: s.lastSendMessageResult, teams: s.teams, openTeamTab: s.openTeamTab, + messages: selectTeamMessages(s, teamName), + messagesState: teamName ? s.teamMessagesByName[teamName] : undefined, + loadOlderTeamMessages: s.loadOlderTeamMessages, })) ); - // ── Paginated message fetching ── - // Messages are now fetched via getMessagesPage API instead of coming - // from getTeamData. The `messages` prop is used as initial seed if non-empty. - const PAGE_SIZE = 50; - const [fetchedMessages, setFetchedMessages] = useState([]); - const [nextCursor, setNextCursor] = useState(null); - const [hasMore, setHasMore] = useState(false); - const [messagesLoading, setMessagesLoading] = useState(false); - const fetchIdRef = useRef(0); - - // Initial fetch on mount or team change - useEffect(() => { - const id = ++fetchIdRef.current; - setMessagesLoading(true); - void (async () => { - try { - const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE }); - if (fetchIdRef.current !== id) return; - setFetchedMessages(page.messages); - setNextCursor(page.nextCursor); - setHasMore(page.hasMore); - } catch { - // Fallback: use prop messages if API fails - if (fetchIdRef.current === id && messages.length > 0) { - setFetchedMessages(messages); - } - } finally { - if (fetchIdRef.current === id) setMessagesLoading(false); - } - })(); - }, [teamName]); // eslint-disable-line react-hooks/exhaustive-deps -- intentionally only on teamName change - - // Auto-refresh: poll for NEW messages only (prepend to head). - // Does NOT touch nextCursor/hasMore — those belong to the "Load older" flow. - useEffect(() => { - if (!isTeamAlive && leadActivity !== 'active') return; - const interval = setInterval(async () => { - try { - const page = await api.teams.getMessagesPage(teamName, { limit: PAGE_SIZE }); - setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages)); - } catch { - // best-effort - } - }, 5000); - return () => clearInterval(interval); - }, [teamName, isTeamAlive, leadActivity]); - const loadOlderMessages = useCallback(async () => { - if (!nextCursor || messagesLoading) return; - setMessagesLoading(true); - try { - const page = await api.teams.getMessagesPage(teamName, { - beforeTimestamp: nextCursor, - limit: PAGE_SIZE, - }); - setFetchedMessages((prev) => mergeTeamMessages(prev, page.messages)); - setNextCursor(page.nextCursor); - setHasMore(page.hasMore); - } catch { - // best-effort - } finally { - setMessagesLoading(false); + if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) { + return; } - }, [teamName, nextCursor, messagesLoading]); + await loadOlderTeamMessages(teamName); + }, [loadOlderTeamMessages, messagesState, teamName]); - // Use fetched messages, fall back to prop messages during initial load - const effectiveMessages = useMemo(() => { - if (fetchedMessages.length === 0) return messages; - return mergeTeamMessages(fetchedMessages, messages); - }, [fetchedMessages, messages]); + const messagesLoading = + (messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false); + const hasMore = messagesState?.hasMore ?? false; + const effectiveMessages = messages; const composerTextareaRef = useRef(null); const sidebarScrollRef = useRef(null); diff --git a/src/renderer/components/team/provisioningSteps.ts b/src/renderer/components/team/provisioningSteps.ts index bb5eb924..92d7df18 100644 --- a/src/renderer/components/team/provisioningSteps.ts +++ b/src/renderer/components/team/provisioningSteps.ts @@ -3,10 +3,14 @@ import { isLeadMember } from '@shared/utils/leadDetection'; import type { MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, - ResolvedTeamMember, TeamProvisioningProgress, } from '@shared/types'; +interface LaunchJoinMemberLike { + name: string; + removedAt?: number; +} + /** Display steps for the provisioning stepper (0-indexed). */ export const DISPLAY_STEPS = [ { key: 'starting', label: 'Starting' }, @@ -52,7 +56,7 @@ export function getLaunchJoinMilestonesFromMembers({ memberSpawnStatuses, memberSpawnSnapshot, }: { - members: readonly ResolvedTeamMember[]; + members: readonly LaunchJoinMemberLike[]; memberSpawnStatuses?: MemberSpawnStatusCollection; memberSpawnSnapshot?: Pick; }): LaunchJoinMilestones { diff --git a/src/renderer/components/team/useTeamProvisioningPresentation.ts b/src/renderer/components/team/useTeamProvisioningPresentation.ts index 3eac3f72..0cdf27ec 100644 --- a/src/renderer/components/team/useTeamProvisioningPresentation.ts +++ b/src/renderer/components/team/useTeamProvisioningPresentation.ts @@ -3,7 +3,7 @@ import { useMemo } from 'react'; import { useStore } from '@renderer/store'; import { getCurrentProvisioningProgressForTeam, - selectTeamDataForName, + selectTeamMemberSnapshotsForName, } from '@renderer/store/slices/teamSlice'; import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; import { useShallow } from 'zustand/react/shallow'; @@ -20,7 +20,7 @@ export function useTeamProvisioningPresentation(teamName: string): { useShallow((s) => ({ progress: getCurrentProvisioningProgressForTeam(s, teamName), cancelProvisioning: s.cancelProvisioning, - teamMembers: selectTeamDataForName(s, teamName)?.members ?? [], + teamMembers: selectTeamMemberSnapshotsForName(s, teamName), memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], })) diff --git a/src/renderer/hooks/useTaskSuggestions.ts b/src/renderer/hooks/useTaskSuggestions.ts index 0a6f3218..892942a1 100644 --- a/src/renderer/hooks/useTaskSuggestions.ts +++ b/src/renderer/hooks/useTaskSuggestions.ts @@ -1,6 +1,10 @@ import { useMemo } from 'react'; import { useStore } from '@renderer/store'; +import { + selectResolvedMembersForTeamName, + selectTeamDataForName, +} from '@renderer/store/slices/teamSlice'; import { createEncodedTaskReference } from '@renderer/utils/taskReferenceUtils'; import { getTaskDisplayId } from '@shared/utils/taskIdentity'; import { useShallow } from 'zustand/react/shallow'; @@ -57,11 +61,13 @@ function isVisibleTask(task: TeamTaskWithKanban | GlobalTask): boolean { } export function useTaskSuggestions(currentTeamName: string | null): UseTaskSuggestionsResult { - const { globalTasks, selectedTeamName, selectedTeamData, teamByName } = useStore( + const { globalTasks, currentTeamData, currentTeamMembers, teamByName } = useStore( useShallow((s) => ({ globalTasks: s.globalTasks, - selectedTeamName: s.selectedTeamName, - selectedTeamData: s.selectedTeamData, + currentTeamData: currentTeamName ? selectTeamDataForName(s, currentTeamName) : null, + currentTeamMembers: currentTeamName + ? selectResolvedMembersForTeamName(s, currentTeamName) + : [], teamByName: s.teamByName, })) ); @@ -73,14 +79,10 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge if (currentTeamName) { const currentTeamSummary = teamByName[currentTeamName]; const currentTeamDisplayName = currentTeamSummary?.displayName || currentTeamName; - const currentTeamMembers = - selectedTeamName === currentTeamName && selectedTeamData - ? selectedTeamData.members - : (currentTeamSummary?.members ?? []); const currentTeamTasks = - selectedTeamName === currentTeamName && selectedTeamData - ? selectedTeamData.tasks - : globalTasks.filter((task) => task.teamName === currentTeamName); + currentTeamData?.tasks ?? globalTasks.filter((task) => task.teamName === currentTeamName); + const currentTeamMemberColors = + currentTeamMembers.length > 0 ? currentTeamMembers : (currentTeamSummary?.members ?? []); for (const task of currentTeamTasks) { if (!isVisibleTask(task)) continue; @@ -91,7 +93,7 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge teamDisplayName: currentTeamDisplayName, teamColor: currentTeamSummary?.color, isCurrentTeamTask: true, - ownerColor: currentTeamMembers.find((member) => member.name === task.owner)?.color, + ownerColor: currentTeamMemberColors.find((member) => member.name === task.owner)?.color, }); } } @@ -123,7 +125,7 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge }); return tasks.map(buildTaskSuggestion); - }, [currentTeamName, globalTasks, selectedTeamData, selectedTeamName, teamByName]); + }, [currentTeamData, currentTeamMembers, currentTeamName, globalTasks, teamByName]); return { suggestions }; } diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 82c5d7e6..a2ffff64 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -35,7 +35,9 @@ import { createTabSlice } from './slices/tabSlice'; import { createTabUISlice } from './slices/tabUISlice'; import { createTeamSlice, + getActiveTeamPendingReplyWaits, getLastResolvedTeamDataRefreshAt, + hasActiveTeamPendingReplyWait, isTeamDataRefreshPending, selectTeamDataForName, } from './slices/teamSlice'; @@ -65,6 +67,7 @@ const TEAM_CHANGE_EVENT_BURST_WARN_COUNT = 8; const TEAM_CHANGE_EVENT_WARN_THROTTLE_MS = 2_000; const TEAM_VISIBLE_IDLE_WATCHDOG_POLL_MS = 10_000; const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000; +const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_000; const CURRENT_APP_VERSION = typeof __APP_VERSION__ === 'string' ? normalizeVersion(__APP_VERSION__) : '0.0.0'; const logger = createLogger('Store:index'); @@ -237,10 +240,12 @@ export function initializeNotificationListeners(): () => void { const teamLastRelevantActivityAt = new Map(); const teamLastIdleWatchdogRefreshAt = new Map(); let teamRefreshTimers = new Map>(); + let teamMessageRefreshTimers = new Map>(); let teamPresenceRefreshTimers = new Map>(); let memberSpawnRefreshTimers = new Map>(); let toolActivityTimers = new Map>(); let inProgressChangePresencePollInFlight = false; + let teamMessageFallbackPollInFlight = false; const inProgressChangePresenceCursorByTeam = new Map(); let teamListRefreshTimer: ReturnType | null = null; @@ -252,6 +257,23 @@ export function initializeNotificationListeners(): () => void { const TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS = 500; const TEAM_LIST_REFRESH_THROTTLE_MS = 2000; const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500; + const refreshTrackedTeamMessages = async (teamName: string): Promise => { + if (!teamName || !shouldRefreshTeamMessages(teamName)) { + return; + } + + const current = useStore.getState(); + try { + const headResult = await current.refreshTeamMessagesHead(teamName); + const latest = useStore.getState(); + const meta = latest.memberActivityMetaByTeam[teamName]; + if (headResult.feedChanged || !meta || meta.feedRevision !== headResult.feedRevision) { + await latest.refreshMemberActivityMeta(teamName); + } + } catch { + // Best-effort refresh for message-driven events and fallback polling only. + } + }; const scheduleMemberSpawnStatusesRefresh = (teamName: string | null | undefined): void => { if (!teamName || !isTeamVisibleInAnyPane(teamName)) { return; @@ -265,6 +287,19 @@ export function initializeNotificationListeners(): () => void { }, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS); memberSpawnRefreshTimers.set(teamName, timer); }; + const scheduleTrackedTeamMessageRefresh = (teamName: string | null | undefined): void => { + if (!teamName || !shouldRefreshTeamMessages(teamName)) { + return; + } + if (teamMessageRefreshTimers.has(teamName)) { + return; + } + const timer = setTimeout(() => { + teamMessageRefreshTimers.delete(teamName); + void refreshTrackedTeamMessages(teamName); + }, TEAM_REFRESH_THROTTLE_MS); + teamMessageRefreshTimers.set(teamName, timer); + }; const buildToolActivityTimerKey = ( teamName: string, memberName: string, @@ -587,6 +622,18 @@ export function initializeNotificationListeners(): () => void { return getVisibleTeamNamesInAnyPane().has(teamName); }; + const shouldRefreshTeamMessages = (teamName: string): boolean => { + return isTeamVisibleInAnyPane(teamName) || hasActiveTeamPendingReplyWait(teamName); + }; + + const getTrackedTeamMessageRefreshTeams = (): Set => { + const tracked = getVisibleTeamNamesInAnyPane(); + for (const teamName of getActiveTeamPendingReplyWaits()) { + tracked.add(teamName); + } + return tracked; + }; + const getTrackedChangePresenceTeams = (): Set => { const state = useStore.getState(); const tracked = new Set(); @@ -627,6 +674,26 @@ export function initializeNotificationListeners(): () => void { return activeTab.teamName; }; + const pollTrackedTeamMessageFallback = async (): Promise => { + if (teamMessageFallbackPollInFlight) { + return; + } + + const teamNames = getTrackedTeamMessageRefreshTeams(); + if (teamNames.size === 0) { + return; + } + + teamMessageFallbackPollInFlight = true; + try { + await Promise.allSettled( + Array.from(teamNames, (teamName) => refreshTrackedTeamMessages(teamName)) + ); + } finally { + teamMessageFallbackPollInFlight = false; + } + }; + const pollFocusedVisibleTeamIdleWatchdog = async (): Promise => { if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { return; @@ -863,11 +930,18 @@ export function initializeNotificationListeners(): () => void { cleanupFns.push(() => { clearInterval(teamIdleWatchdogTimer); }); + const teamMessageFallbackPollTimer = setInterval(() => { + void pollTrackedTeamMessageFallback(); + }, TEAM_MESSAGE_FALLBACK_POLL_MS); + cleanupFns.push(() => { + clearInterval(teamMessageFallbackPollTimer); + }); if (api.teams?.onTeamChange) { const cleanup = api.teams.onTeamChange((_event: unknown, event: TeamChangeEvent) => { - const visibleTeam = Boolean(event.teamName) && isTeamVisibleInAnyPane(event.teamName); - noteTeamChangeEventBurst(event.teamName, event.type, visibleTeam); + const messageRefreshRelevant = + Boolean(event.teamName) && shouldRefreshTeamMessages(event.teamName); + noteTeamChangeEventBurst(event.teamName, event.type, messageRefreshRelevant); const isIgnoredRuntimeRun = (() => { if (!event.runId) return false; @@ -924,24 +998,26 @@ export function initializeNotificationListeners(): () => void { }, }; - const cachedTeamData = prev.teamDataCacheByName[event.teamName]; - if (cachedTeamData) { + const baseTeamData = + prev.teamDataCacheByName[event.teamName] ?? + (prev.selectedTeamName === event.teamName ? prev.selectedTeamData : null); + const nextTeamData = + baseTeamData && baseTeamData.isAlive !== (nextActivity !== 'offline') + ? { + ...baseTeamData, + isAlive: nextActivity !== 'offline', + } + : baseTeamData; + + if (nextTeamData) { nextState.teamDataCacheByName = { ...prev.teamDataCacheByName, - [event.teamName]: { - ...cachedTeamData, - isAlive: nextActivity !== 'offline', - }, + [event.teamName]: nextTeamData, }; } - // Keep TeamDetailView in sync: it historically relied on selectedTeamData.isAlive, - // which isn't refreshed for lead-activity events. - if (prev.selectedTeamName === event.teamName && prev.selectedTeamData) { - nextState.selectedTeamData = { - ...prev.selectedTeamData, - isAlive: nextActivity !== 'offline', - }; + if (prev.selectedTeamName === event.teamName && nextTeamData) { + nextState.selectedTeamData = nextTeamData; } // Clear context data when lead goes offline @@ -1122,29 +1198,19 @@ export function initializeNotificationListeners(): () => void { return; } - if (event.type === 'inbox' || event.type === 'config' || event.type === 'process') { - scheduleMemberSpawnStatusesRefresh(event.teamName); + if (event.type === 'inbox') { + scheduleTrackedTeamMessageRefresh(event.teamName); + return; } - // Live lead-message events: only refresh the visible team detail, not team/task lists. - // This keeps the refresh lightweight and prevents one noisy team from starving another. + // Live lead-message events refresh only the tracked message feed surface + // (visible team or local pending-reply wait), not the structural snapshot. if (event.type === 'lead-message') { if (isStaleRuntimeEvent) { return; } seedCurrentRunIdIfMissing(); - if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) { - return; - } - if (teamRefreshTimers.has(event.teamName)) { - return; - } - const timer = setTimeout(() => { - teamRefreshTimers.delete(event.teamName); - const current = useStore.getState(); - void current.refreshTeamData(event.teamName, { withDedup: true }); - }, TEAM_REFRESH_THROTTLE_MS); - teamRefreshTimers.set(event.teamName, timer); + scheduleTrackedTeamMessageRefresh(event.teamName); return; } @@ -1205,6 +1271,8 @@ export function initializeNotificationListeners(): () => void { cleanup(); for (const t of teamRefreshTimers.values()) clearTimeout(t); teamRefreshTimers = new Map(); + for (const t of teamMessageRefreshTimers.values()) clearTimeout(t); + teamMessageRefreshTimers = new Map(); for (const t of teamPresenceRefreshTimers.values()) clearTimeout(t); teamPresenceRefreshTimers = new Map(); for (const t of memberSpawnRefreshTimers.values()) clearTimeout(t); diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index e1d10700..0e8a4475 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -7,7 +7,9 @@ import { type TaskChangeRequestOptions, } from '@renderer/utils/taskChangeRequest'; import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; +import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; +import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; import { createLogger } from '@shared/utils/logger'; @@ -33,21 +35,25 @@ import type { KanbanColumnId, LeadActivityState, LeadContextUsage, + MemberActivityMetaEntry, MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, PersistedTeamLaunchSummary, + ResolvedTeamMember, SendMessageRequest, SendMessageResult, TaskChangePresenceState, TaskComment, TeamCreateRequest, - TeamData, TeamLaunchRequest, + TeamMemberActivityMeta, + TeamMemberSnapshot, TeamProviderId, TeamProvisioningProgress, TeamSummary, TeamTask, TeamTaskStatus, + TeamViewSnapshot, ToolApprovalRequest, ToolApprovalSettings, UpdateKanbanPatch, @@ -69,9 +75,20 @@ const TEAM_REFRESH_BURST_WINDOW_MS = 4_000; const TEAM_REFRESH_BURST_WARN_COUNT = 5; const TEAM_REFRESH_WARN_THROTTLE_MS = 2_000; const MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS = 2_000; -const inFlightTeamDataRequests = new Map>(); +const inFlightTeamDataRequests = new Map>(); const inFlightRefreshTeamDataCalls = new Set(); const pendingFreshTeamDataRefreshes = new Set(); +const inFlightTeamMessagesHeadRequests = new Map>(); +const inFlightTeamMessagesOlderRequests = new Map>(); +const queuedTeamMessagesHeadRefreshesAfterOlder = new Map< + string, + Promise +>(); +const pendingFreshTeamMessagesHeadRefreshes = new Set(); +const inFlightTeamMemberActivityMetaRequests = new Map>(); +const pendingFreshTeamMemberActivityMetaRefreshes = new Set(); +const pendingTeamPendingReplyRefreshTimers = new Map>(); +const activeTeamPendingReplyWaits = new Set(); const lastResolvedTeamDataRefreshAtByTeam = new Map(); let inFlightGlobalTasksRefresh: Promise | null = null; let pendingFreshGlobalTasksRefresh = false; @@ -99,14 +116,37 @@ export function getLastResolvedTeamDataRefreshAt(teamName: string): number | und return lastResolvedTeamDataRefreshAtByTeam.get(teamName); } +export function hasActiveTeamPendingReplyWait(teamName: string): boolean { + return activeTeamPendingReplyWaits.has(teamName); +} + +export function getActiveTeamPendingReplyWaits(): Set { + return new Set(activeTeamPendingReplyWaits); +} + export function __resetTeamSliceModuleStateForTests(): void { inFlightTeamDataRequests.clear(); inFlightRefreshTeamDataCalls.clear(); pendingFreshTeamDataRefreshes.clear(); + inFlightTeamMessagesHeadRequests.clear(); + inFlightTeamMessagesOlderRequests.clear(); + queuedTeamMessagesHeadRefreshesAfterOlder.clear(); + pendingFreshTeamMessagesHeadRefreshes.clear(); + inFlightTeamMemberActivityMetaRequests.clear(); + pendingFreshTeamMemberActivityMetaRefreshes.clear(); + for (const timer of pendingTeamPendingReplyRefreshTimers.values()) { + clearTimeout(timer); + } + pendingTeamPendingReplyRefreshTimers.clear(); + activeTeamPendingReplyWaits.clear(); lastResolvedTeamDataRefreshAtByTeam.clear(); memberSpawnStatusesIpcBackoffUntilByTeam.clear(); teamRefreshBurstDiagnostics.clear(); memberSpawnUiEqualLastWarnAtByTeam.clear(); + resolvedMembersSelectorCache.clear(); + resolvedMemberSelectorCache.clear(); + mergedMessagesSelectorCache.clear(); + memberMessagesSelectorCache.clear(); } function nowIso(): string { @@ -117,6 +157,66 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +function isPlainObject(value: unknown): value is Record { + if (value == null || typeof value !== 'object') { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function structurallySharePlainValue(previous: T, next: T): T { + if (Object.is(previous, next)) { + return previous; + } + + if (Array.isArray(previous) && Array.isArray(next)) { + let changed = previous.length !== next.length; + const result = next.map((nextItem, index) => { + const sharedItem = structurallySharePlainValue(previous[index], nextItem); + if (!Object.is(sharedItem, previous[index])) { + changed = true; + } + return sharedItem; + }); + return changed ? (result as T) : previous; + } + + if (isPlainObject(previous) && isPlainObject(next)) { + const previousRecord = previous as Record; + const nextRecord = next as Record; + const previousKeys = Object.keys(previousRecord); + const nextKeys = Object.keys(nextRecord); + let changed = previousKeys.length !== nextKeys.length; + const result: Record = {}; + + for (const key of nextKeys) { + if (!Object.prototype.hasOwnProperty.call(previousRecord, key)) { + changed = true; + } + const sharedValue = structurallySharePlainValue(previousRecord[key], nextRecord[key]); + if (!Object.is(sharedValue, previousRecord[key])) { + changed = true; + } + result[key] = sharedValue; + } + + return changed ? (result as T) : previous; + } + + return next; +} + +function structurallyShareTeamSnapshot( + previous: TeamViewSnapshot | null | undefined, + next: TeamViewSnapshot +): TeamViewSnapshot { + if (!previous) { + return next; + } + return structurallySharePlainValue(previous, next); +} + const ACTIVE_PROVISIONING_STATES = new Set([ 'validating', 'spawning', @@ -148,7 +248,7 @@ function withTimeout(promise: Promise, ms: number, label: string): Promise }); } -function fetchTeamDataDeduped(teamName: string): Promise { +function fetchTeamDataDeduped(teamName: string): Promise { const existing = inFlightTeamDataRequests.get(teamName); if (existing) { return existing; @@ -168,7 +268,7 @@ function fetchTeamDataDeduped(teamName: string): Promise { return request; } -function fetchTeamDataFresh(teamName: string): Promise { +function fetchTeamDataFresh(teamName: string): Promise { return withTimeout( unwrapIpc('team:getData', () => api.teams.getData(teamName)), TEAM_GET_DATA_TIMEOUT_MS, @@ -176,19 +276,17 @@ function fetchTeamDataFresh(teamName: string): Promise { ); } -function summarizeTeamDataCounts(data: TeamData | null | undefined): { - messages: number; +function summarizeTeamDataCounts(data: TeamViewSnapshot | null | undefined): { tasks: number; members: number; activeMembers: number; processes: number; } { if (!data) { - return { messages: 0, tasks: 0, members: 0, activeMembers: 0, processes: 0 }; + return { tasks: 0, members: 0, activeMembers: 0, processes: 0 }; } return { - messages: data.messages.length, tasks: data.tasks.length, members: data.members.length, activeMembers: data.members.filter((member) => !member.removedAt).length, @@ -196,20 +294,11 @@ function summarizeTeamDataCounts(data: TeamData | null | undefined): { }; } -function estimateTeamPayloadWeight(data: TeamData): { - messageTextChars: number; - messageAttachments: number; +function estimateTeamPayloadWeight(data: TeamViewSnapshot): { taskComments: number; taskHistoryEvents: number; taskDescriptionChars: number; } { - let messageTextChars = 0; - let messageAttachments = 0; - for (const message of data.messages) { - messageTextChars += (message.text?.length ?? 0) + (message.summary?.length ?? 0); - messageAttachments += message.attachments?.length ?? 0; - } - let taskComments = 0; let taskHistoryEvents = 0; let taskDescriptionChars = 0; @@ -220,8 +309,6 @@ function estimateTeamPayloadWeight(data: TeamData): { } return { - messageTextChars, - messageAttachments, taskComments, taskHistoryEvents, taskDescriptionChars, @@ -266,8 +353,8 @@ function maybeLogTeamDataPerf(params: { setMs: number; postMs: number; totalMs: number; - previousData: TeamData | null | undefined; - nextData: TeamData; + previousData: TeamViewSnapshot | null | undefined; + nextData: TeamViewSnapshot; deduped: boolean; reusedInFlightRequest: boolean; burstCount?: number; @@ -288,8 +375,7 @@ function maybeLogTeamDataPerf(params: { const nextCounts = summarizeTeamDataCounts(nextData); const previousCounts = summarizeTeamDataCounts(previousData); - const largePayload = - nextCounts.messages >= TEAM_DATA_LARGE_MESSAGES || nextCounts.tasks >= TEAM_DATA_LARGE_TASKS; + const largePayload = nextCounts.tasks >= TEAM_DATA_LARGE_TASKS; const slow = ipcMs >= TEAM_DATA_IPC_WARN_MS || setMs >= TEAM_DATA_SET_WARN_MS || @@ -305,15 +391,13 @@ function maybeLogTeamDataPerf(params: { 1 )}ms post=${postMs.toFixed(1)}ms total=${totalMs.toFixed(1)}ms deduped=${deduped} reusedInFlight=${ reusedInFlightRequest ? 'yes' : 'no' - } burst=${burstCount ?? 1} counts=messages:${previousCounts.messages}->${nextCounts.messages},tasks:${ - previousCounts.tasks - }->${nextCounts.tasks},members:${previousCounts.members}->${nextCounts.members},activeMembers:${ + } burst=${burstCount ?? 1} counts=tasks:${previousCounts.tasks}->${nextCounts.tasks},members:${ + previousCounts.members + }->${nextCounts.members},activeMembers:${ previousCounts.activeMembers }->${nextCounts.activeMembers},processes:${previousCounts.processes}->${nextCounts.processes} payload=textChars:${ - payloadWeight.messageTextChars + payloadWeight.taskDescriptionChars - },attachments=${payloadWeight.messageAttachments},taskComments=${ - payloadWeight.taskComments - },historyEvents=${payloadWeight.taskHistoryEvents}` + payloadWeight.taskDescriptionChars + },taskComments=${payloadWeight.taskComments},historyEvents=${payloadWeight.taskHistoryEvents}` ); } @@ -431,32 +515,195 @@ function compareInboxMessagesByTimestamp(a: InboxMessage, b: InboxMessage): numb return aId.localeCompare(bId); } -function upsertLocalSentMessage(data: TeamData, message: InboxMessage): TeamData { - const nextMessages = [...data.messages]; +export interface TeamMessagesCacheEntry { + canonicalMessages: InboxMessage[]; + optimisticMessages: InboxMessage[]; + feedRevision: string | null; + nextCursor: string | null; + hasMore: boolean; + lastFetchedAt: number | null; + loadingHead: boolean; + loadingOlder: boolean; + headHydrated: boolean; +} + +export interface RefreshTeamMessagesHeadResult { + feedChanged: boolean; + headChanged: boolean; + feedRevision: string | null; +} + +const EMPTY_TEAM_MESSAGES_CACHE_ENTRY: TeamMessagesCacheEntry = { + canonicalMessages: [], + optimisticMessages: [], + feedRevision: null, + nextCursor: null, + hasMore: false, + lastFetchedAt: null, + loadingHead: false, + loadingOlder: false, + headHydrated: false, +}; + +function createEmptyTeamMessagesCacheEntry(): TeamMessagesCacheEntry { + return { + canonicalMessages: [], + optimisticMessages: [], + feedRevision: null, + nextCursor: null, + hasMore: false, + lastFetchedAt: null, + loadingHead: false, + loadingOlder: false, + headHydrated: false, + }; +} + +function getTeamMessagesCacheEntry( + state: Pick, + teamName: string +): TeamMessagesCacheEntry { + return state.teamMessagesByName[teamName] ?? EMPTY_TEAM_MESSAGES_CACHE_ENTRY; +} + +function upsertOptimisticTeamMessage( + entry: TeamMessagesCacheEntry, + message: InboxMessage +): TeamMessagesCacheEntry { + const nextOptimistic = [...entry.optimisticMessages]; const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; if (messageId.length > 0) { - const existingIndex = nextMessages.findIndex( + const existingIndex = nextOptimistic.findIndex( (candidate) => typeof candidate.messageId === 'string' && candidate.messageId.trim() === messageId ); if (existingIndex >= 0) { - nextMessages[existingIndex] = { - ...nextMessages[existingIndex], + nextOptimistic[existingIndex] = { + ...nextOptimistic[existingIndex], ...message, }; } else { - nextMessages.push(message); + nextOptimistic.push(message); } } else { - nextMessages.push(message); + nextOptimistic.push(message); } - nextMessages.sort(compareInboxMessagesByTimestamp); + nextOptimistic.sort(compareInboxMessagesByTimestamp); return { - ...data, - messages: nextMessages, + ...entry, + optimisticMessages: nextOptimistic, }; } +function areInboxMessageArraysEquivalent( + left: readonly InboxMessage[], + right: readonly InboxMessage[] +): boolean { + if (left === right) return true; + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + const leftItem = left[index]; + const rightItem = right[index]; + if ( + leftItem.messageId !== rightItem.messageId || + leftItem.timestamp !== rightItem.timestamp || + leftItem.from !== rightItem.from || + leftItem.to !== rightItem.to || + leftItem.text !== rightItem.text || + leftItem.summary !== rightItem.summary || + leftItem.read !== rightItem.read || + leftItem.relayOfMessageId !== rightItem.relayOfMessageId || + leftItem.source !== rightItem.source || + leftItem.leadSessionId !== rightItem.leadSessionId || + leftItem.messageKind !== rightItem.messageKind + ) { + return false; + } + } + return true; +} + +function pruneOptimisticMessages( + optimistic: readonly InboxMessage[], + canonical: readonly InboxMessage[] +): InboxMessage[] { + if (optimistic.length === 0) { + return []; + } + + const canonicalIds = new Set( + canonical + .map((message) => (typeof message.messageId === 'string' ? message.messageId.trim() : '')) + .filter((messageId) => messageId.length > 0) + ); + + return optimistic.filter((message) => { + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + return !messageId || !canonicalIds.has(messageId); + }); +} + +function clearPendingReplyRefreshTimer(teamName: string): void { + const existingTimer = pendingTeamPendingReplyRefreshTimers.get(teamName); + if (existingTimer == null) { + return; + } + clearTimeout(existingTimer); + pendingTeamPendingReplyRefreshTimers.delete(teamName); +} + +function setPendingReplyRefreshEnabled(teamName: string, enabled: boolean): void { + if (enabled) { + activeTeamPendingReplyWaits.add(teamName); + return; + } + activeTeamPendingReplyWaits.delete(teamName); +} + +function getCanonicalHeadSlice( + canonicalMessages: readonly InboxMessage[], + headLength: number +): readonly InboxMessage[] { + if (headLength <= 0) { + return []; + } + return canonicalMessages.slice(0, headLength); +} + +function extractRetainedCanonicalOlderTail( + canonicalMessages: readonly InboxMessage[], + freshHeadMessages: readonly InboxMessage[] +): InboxMessage[] | null { + if (canonicalMessages.length === 0) { + return []; + } + if (freshHeadMessages.length === 0) { + return null; + } + + const freshHeadKeys = new Set(freshHeadMessages.map((message) => toMessageKey(message))); + let hasMessagesOutsideFreshHead = false; + for (const message of canonicalMessages) { + if (!freshHeadKeys.has(toMessageKey(message))) { + hasMessagesOutsideFreshHead = true; + break; + } + } + if (!hasMessagesOutsideFreshHead) { + return []; + } + + const anchorKey = toMessageKey(freshHeadMessages[freshHeadMessages.length - 1]); + const anchorIndex = canonicalMessages.findIndex((message) => toMessageKey(message) === anchorKey); + if (anchorIndex < 0) { + return null; + } + + return canonicalMessages + .slice(anchorIndex + 1) + .filter((message) => !freshHeadKeys.has(toMessageKey(message))); +} + async function refreshTaskChangePresenceForUpdatedTask( getState: () => AppState, teamName: string, @@ -824,8 +1071,8 @@ function fireAllTasksCompletedNotification( function collectTaskChangeInvalidationState( teamName: string, - prevTasks: TeamData['tasks'], - nextTasks: TeamData['tasks'] + prevTasks: TeamViewSnapshot['tasks'], + nextTasks: TeamViewSnapshot['tasks'] ): { cacheKeys: string[]; taskIds: string[] } { const nextKeys = new Set( nextTasks.map((task) => @@ -853,9 +1100,9 @@ function collectTaskChangeInvalidationState( function preserveKnownTaskChangePresence( teamName: string, - prevTasks: TeamData['tasks'] | null | undefined, - nextTasks: TeamData['tasks'] -): TeamData['tasks'] { + prevTasks: TeamViewSnapshot['tasks'] | null | undefined, + nextTasks: TeamViewSnapshot['tasks'] +): TeamViewSnapshot['tasks'] { if (!Array.isArray(prevTasks) || prevTasks.length === 0 || nextTasks.length === 0) { return nextTasks; } @@ -928,10 +1175,127 @@ export interface TeamLaunchParams { limitContext?: boolean; } +const resolvedMembersSelectorCache = new Map< + string, + { + snapshotRef: TeamViewSnapshot['members']; + metaMembersRef: TeamMemberActivityMeta['members'] | undefined; + result: ResolvedTeamMember[]; + } +>(); +const resolvedMemberSelectorCache = new Map< + string, + { + snapshotMemberRef: TeamMemberSnapshot | undefined; + metaEntryRef: MemberActivityMetaEntry | undefined; + result: ResolvedTeamMember | null; + } +>(); +const mergedMessagesSelectorCache = new Map< + string, + { + canonicalRef: InboxMessage[]; + optimisticRef: InboxMessage[]; + result: InboxMessage[]; + } +>(); +const EMPTY_TEAM_MEMBER_SNAPSHOTS: TeamMemberSnapshot[] = []; +const EMPTY_TEAM_TASKS: TeamViewSnapshot['tasks'] = []; +const memberMessagesSelectorCache = new Map< + string, + { + messagesRef: InboxMessage[]; + result: InboxMessage[]; + } +>(); + +function resolveMemberStatus( + snapshot: TeamMemberSnapshot, + activity: MemberActivityMetaEntry | undefined +): ResolvedTeamMember['status'] { + if (activity?.latestAuthoredMessageSignalsTermination) { + return 'terminated'; + } + + if (!activity?.lastAuthoredMessageAt) { + return snapshot.currentTaskId ? 'active' : 'idle'; + } + + const ageMs = Date.now() - Date.parse(activity.lastAuthoredMessageAt); + if (Number.isNaN(ageMs)) { + return 'unknown'; + } + if (ageMs < 5 * 60 * 1000) { + return 'active'; + } + return 'idle'; +} + +function buildResolvedMembers( + snapshots: readonly TeamMemberSnapshot[], + meta: TeamMemberActivityMeta | undefined +): ResolvedTeamMember[] { + return snapshots.map((member) => buildResolvedMember(member, meta?.members[member.name])); +} + +function buildResolvedMember( + snapshot: TeamMemberSnapshot, + activity: MemberActivityMetaEntry | undefined +): ResolvedTeamMember { + return { + ...snapshot, + status: resolveMemberStatus(snapshot, activity), + messageCount: activity?.messageCountExact ?? 0, + lastActiveAt: activity?.lastAuthoredMessageAt ?? null, + }; +} + +function areMemberActivityMetaEntriesEqual( + left: MemberActivityMetaEntry | undefined, + right: MemberActivityMetaEntry +): boolean { + if (!left) { + return false; + } + return ( + left.memberName === right.memberName && + left.lastAuthoredMessageAt === right.lastAuthoredMessageAt && + left.messageCountExact === right.messageCountExact && + left.latestAuthoredMessageSignalsTermination === right.latestAuthoredMessageSignalsTermination + ); +} + +function structurallyShareMemberActivityFacts( + previous: Record | undefined, + next: Record +): Record { + if (!previous) { + return next; + } + + const nextKeys = Object.keys(next); + const previousKeys = Object.keys(previous); + let changed = nextKeys.length !== previousKeys.length; + const shared: Record = {}; + + for (const key of nextKeys) { + const nextEntry = next[key]; + const previousEntry = previous[key]; + if (!areMemberActivityMetaEntriesEqual(previousEntry, nextEntry)) { + changed = true; + shared[key] = nextEntry; + continue; + } + shared[key] = previousEntry; + } + + return changed ? shared : previous; +} + export function selectTeamDataForName( state: Pick, teamName: string | null | undefined -): TeamData | null { +): TeamViewSnapshot | null { if (!teamName) { return null; } @@ -943,7 +1307,7 @@ export function selectTeamDataForName( function migrateStableSlotAssignmentsForMembers( assignments: TeamGraphSlotAssignments | undefined, - members: readonly Pick[] + members: readonly Pick[] ): { assignments: TeamGraphSlotAssignments; changed: boolean } { const nextAssignments: TeamGraphSlotAssignments = { ...(assignments ?? {}) }; let changed = false; @@ -970,6 +1334,157 @@ function migrateStableSlotAssignmentsForMembers( return { assignments: nextAssignments, changed }; } +export function selectResolvedMembersForTeamName( + state: Pick< + TeamSlice, + 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' | 'memberActivityMetaByTeam' + >, + teamName: string | null | undefined +): ResolvedTeamMember[] { + const snapshot = selectTeamDataForName(state, teamName); + if (!snapshot || !teamName) { + return []; + } + + const meta = state.memberActivityMetaByTeam[teamName]; + const metaMembers = meta?.members; + const cached = resolvedMembersSelectorCache.get(teamName); + if (cached && cached.snapshotRef === snapshot.members && cached.metaMembersRef === metaMembers) { + return cached.result; + } + + const result = buildResolvedMembers(snapshot.members, meta); + resolvedMembersSelectorCache.set(teamName, { + snapshotRef: snapshot.members, + metaMembersRef: metaMembers, + result, + }); + return result; +} + +export function selectResolvedMemberForTeamName( + state: Pick< + TeamSlice, + 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData' | 'memberActivityMetaByTeam' + >, + teamName: string | null | undefined, + memberName: string | null | undefined +): ResolvedTeamMember | null { + const snapshot = selectTeamDataForName(state, teamName); + if (!snapshot || !teamName || !memberName) { + return null; + } + + const snapshotMember = snapshot.members.find((member) => member.name === memberName); + if (!snapshotMember) { + return null; + } + + const metaEntry = state.memberActivityMetaByTeam[teamName]?.members[memberName]; + const cacheKey = `${teamName}:${memberName}`; + const cached = resolvedMemberSelectorCache.get(cacheKey); + if (cached && cached.snapshotMemberRef === snapshotMember && cached.metaEntryRef === metaEntry) { + return cached.result; + } + + const result = buildResolvedMember(snapshotMember, metaEntry); + resolvedMemberSelectorCache.set(cacheKey, { + snapshotMemberRef: snapshotMember, + metaEntryRef: metaEntry, + result, + }); + return result; +} + +export function selectTeamMemberSnapshotsForName( + state: Pick, + teamName: string | null | undefined +): TeamViewSnapshot['members'] { + return selectTeamDataForName(state, teamName)?.members ?? EMPTY_TEAM_MEMBER_SNAPSHOTS; +} + +export function selectTeamTasksForName( + state: Pick, + teamName: string | null | undefined +): TeamViewSnapshot['tasks'] { + return selectTeamDataForName(state, teamName)?.tasks ?? EMPTY_TEAM_TASKS; +} + +export function selectTeamIsAliveForName( + state: Pick, + teamName: string | null | undefined +): boolean | undefined { + return selectTeamDataForName(state, teamName)?.isAlive; +} + +export function selectTeamMessages( + state: Pick, + teamName: string | null | undefined +): InboxMessage[] { + if (!teamName) { + return []; + } + + const entry = getTeamMessagesCacheEntry(state, teamName); + const cached = mergedMessagesSelectorCache.get(teamName); + if ( + cached && + cached.canonicalRef === entry.canonicalMessages && + cached.optimisticRef === entry.optimisticMessages + ) { + return cached.result; + } + + const result = mergeTeamMessages(entry.canonicalMessages, entry.optimisticMessages); + mergedMessagesSelectorCache.set(teamName, { + canonicalRef: entry.canonicalMessages, + optimisticRef: entry.optimisticMessages, + result, + }); + return result; +} + +export function selectMemberMessagesForTeamMember( + state: Pick, + teamName: string | null | undefined, + memberName: string | null | undefined +): InboxMessage[] { + if (!teamName || !memberName) { + return []; + } + + const messages = selectTeamMessages(state, teamName); + const cacheKey = `${teamName}:${memberName}`; + const cached = memberMessagesSelectorCache.get(cacheKey); + if (cached && cached.messagesRef === messages) { + return cached.result; + } + + const result = messages.filter( + (message) => message.from === memberName || message.to === memberName + ); + memberMessagesSelectorCache.set(cacheKey, { + messagesRef: messages, + result, + }); + return result; +} + +function isMemberActivityMetaStale( + state: Pick, + teamName: string +): boolean { + const meta = state.memberActivityMetaByTeam[teamName]; + const feedRevision = getTeamMessagesCacheEntry(state, teamName).feedRevision; + if (!meta) { + return true; + } + if (!feedRevision) { + return false; + } + return meta.feedRevision !== feedRevision; +} + function isVisibleInActiveTeamSurface( state: Pick, teamName: string | null | undefined @@ -1028,11 +1543,13 @@ export interface TeamSlice { req: { taskId: string; filePath?: string; requestOptions: TaskChangeRequestOptions } | null ) => void; selectedTeamName: string | null; - selectedTeamData: TeamData | null; + selectedTeamData: TeamViewSnapshot | null; /** Team-scoped detailed cache used by multi-pane views like agent graph. */ - teamDataCacheByName: Record; + teamDataCacheByName: Record; slotLayoutVersion: string; slotAssignmentsByTeam: Record; + teamMessagesByName: Record; + memberActivityMetaByTeam: Record; selectedTeamLoading: boolean; selectedTeamLoadNonce: number; selectedTeamError: string | null; @@ -1077,7 +1594,7 @@ export interface TeamSlice { clearKanbanFilter: () => void; ensureTeamGraphSlotAssignments: ( teamName: string, - members: readonly Pick[] + members: readonly Pick[] ) => void; setTeamGraphOwnerSlotAssignment: ( teamName: string, @@ -1109,6 +1626,10 @@ export interface TeamSlice { opts?: { skipProjectAutoSelect?: boolean; allowReloadWhileProvisioning?: boolean } ) => Promise; refreshTeamData: (teamName: string, opts?: RefreshTeamDataOptions) => Promise; + refreshTeamMessagesHead: (teamName: string) => Promise; + loadOlderTeamMessages: (teamName: string) => Promise; + refreshMemberActivityMeta: (teamName: string) => Promise; + syncTeamPendingReplyRefresh: (teamName: string, enabled: boolean, delayMs?: number) => void; sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise; crossTeamTargets: { teamName: string; @@ -1350,6 +1871,8 @@ export const createTeamSlice: StateCreator = (set, teamDataCacheByName: {}, slotLayoutVersion: GRAPH_STABLE_SLOT_LAYOUT_VERSION, slotAssignmentsByTeam: {}, + teamMessagesByName: {}, + memberActivityMetaByTeam: {}, selectedTeamLoading: false, selectedTeamLoadNonce: 0, selectedTeamError: null, @@ -2079,11 +2602,11 @@ export const createTeamSlice: StateCreator = (set, const requestNonce = get().selectedTeamLoadNonce + 1; const previousData = selectTeamDataForName(get(), teamName); - // Stale-while-revalidate: keep previous data visible while loading new team. - // Skeleton only shows on first load (when data is null). - // Data is atomically replaced when the new team's data arrives. + // Repoint selection synchronously to the new team's cached snapshot when available. + // Never keep the previous team's snapshot attached to a newly selected team. set({ selectedTeamName: teamName, + selectedTeamData: previousData, selectedTeamLoading: true, selectedTeamLoadNonce: requestNonce, selectedTeamError: null, @@ -2119,23 +2642,31 @@ export const createTeamSlice: StateCreator = (set, set({ teamByName: { ...prevByName, [teamName]: patched } }); } - const nextTeamData = previousData + const projectedTeamData = previousData ? { ...data, tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks), } : data; + const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData); const setStartedAt = performance.now(); - set((state) => ({ - selectedTeamName: teamName, - selectedTeamData: nextTeamData, - teamDataCacheByName: { - ...state.teamDataCacheByName, - [teamName]: nextTeamData, - }, - selectedTeamLoading: false, - selectedTeamError: null, - })); + set((state) => { + const nextCache = + state.teamDataCacheByName[teamName] === nextTeamData + ? state.teamDataCacheByName + : { + ...state.teamDataCacheByName, + [teamName]: nextTeamData, + }; + + return { + selectedTeamName: teamName, + selectedTeamData: nextTeamData, + teamDataCacheByName: nextCache, + selectedTeamLoading: false, + selectedTeamError: null, + }; + }); lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now()); const setMs = performance.now() - setStartedAt; const postStartedAt = performance.now(); @@ -2157,7 +2688,7 @@ export const createTeamSlice: StateCreator = (set, postMs, totalMs: performance.now() - startedAt, previousData, - nextData: data, + nextData: nextTeamData, deduped: true, reusedInFlightRequest: false, }); @@ -2174,6 +2705,11 @@ export const createTeamSlice: StateCreator = (set, } } + const messagesHeadResult = await get().refreshTeamMessagesHead(teamName); + if (messagesHeadResult.feedChanged || isMemberActivityMetaStale(get(), teamName)) { + await get().refreshMemberActivityMeta(teamName); + } + if (opts?.skipProjectAutoSelect) { return; } @@ -2275,25 +2811,44 @@ export const createTeamSlice: StateCreator = (set, ? await fetchTeamDataDeduped(teamName) : await fetchTeamDataFresh(teamName); const ipcMs = performance.now() - startedAt; - const nextTeamData = previousData + const projectedTeamData = previousData ? { ...data, tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks), } : data; + const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData); const setStartedAt = performance.now(); - set((state) => ({ - teamDataCacheByName: { - ...state.teamDataCacheByName, - [teamName]: nextTeamData, - }, - ...(state.selectedTeamName === teamName - ? { - selectedTeamData: nextTeamData, - selectedTeamError: null, - } - : {}), - })); + set((state) => { + const nextCache = + state.teamDataCacheByName[teamName] === nextTeamData + ? state.teamDataCacheByName + : { + ...state.teamDataCacheByName, + [teamName]: nextTeamData, + }; + + const selectedState = + state.selectedTeamName === teamName + ? { + selectedTeamData: nextTeamData, + selectedTeamError: null, + } + : {}; + + if ( + nextCache === state.teamDataCacheByName && + (state.selectedTeamName !== teamName || + (state.selectedTeamData === nextTeamData && state.selectedTeamError == null)) + ) { + return {}; + } + + return { + teamDataCacheByName: nextCache, + ...selectedState, + }; + }); lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now()); const setMs = performance.now() - setStartedAt; const postStartedAt = performance.now(); @@ -2315,7 +2870,7 @@ export const createTeamSlice: StateCreator = (set, postMs, totalMs: performance.now() - startedAt, previousData, - nextData: data, + nextData: nextTeamData, deduped: opts?.withDedup === true, reusedInFlightRequest, burstCount, @@ -2386,6 +2941,315 @@ export const createTeamSlice: StateCreator = (set, } }, + refreshTeamMessagesHead: async (teamName: string) => { + const existingRequest = inFlightTeamMessagesHeadRequests.get(teamName); + if (existingRequest) { + pendingFreshTeamMessagesHeadRefreshes.add(teamName); + return existingRequest; + } + const queuedAfterOlder = queuedTeamMessagesHeadRefreshesAfterOlder.get(teamName); + if (queuedAfterOlder) { + return queuedAfterOlder; + } + + const existingOlderRequest = inFlightTeamMessagesOlderRequests.get(teamName); + if (existingOlderRequest) { + let queuedRequest: Promise; + queuedRequest = existingOlderRequest + .then(() => { + if (queuedTeamMessagesHeadRefreshesAfterOlder.get(teamName) === queuedRequest) { + queuedTeamMessagesHeadRefreshesAfterOlder.delete(teamName); + } + return get().refreshTeamMessagesHead(teamName); + }) + .finally(() => { + if (queuedTeamMessagesHeadRefreshesAfterOlder.get(teamName) === queuedRequest) { + queuedTeamMessagesHeadRefreshesAfterOlder.delete(teamName); + } + }); + queuedTeamMessagesHeadRefreshesAfterOlder.set(teamName, queuedRequest); + return queuedRequest; + } + + const request = (async (): Promise => { + set((state) => ({ + teamMessagesByName: { + ...state.teamMessagesByName, + [teamName]: { + ...getTeamMessagesCacheEntry(state, teamName), + loadingHead: true, + }, + }, + })); + + try { + const page = await unwrapIpc('team:getMessagesPage', () => + api.teams.getMessagesPage(teamName, { limit: 50 }) + ); + + const previousEntry = getTeamMessagesCacheEntry(get(), teamName); + const feedChanged = + !previousEntry.headHydrated || previousEntry.feedRevision !== page.feedRevision; + const previousHeadSlice = getCanonicalHeadSlice( + previousEntry.canonicalMessages, + page.messages.length + ); + const headChanged = !areInboxMessageArraysEquivalent(previousHeadSlice, page.messages); + + set((state) => { + const current = getTeamMessagesCacheEntry(state, teamName); + const retainedOlderTail = extractRetainedCanonicalOlderTail( + current.canonicalMessages, + page.messages + ); + const preserveLoadedOlderTail = + Array.isArray(retainedOlderTail) && retainedOlderTail.length > 0; + const nextCanonical = headChanged + ? preserveLoadedOlderTail + ? mergeTeamMessages(retainedOlderTail, page.messages) + : page.messages + : current.canonicalMessages; + const nextOptimistic = pruneOptimisticMessages(current.optimisticMessages, nextCanonical); + const nextEntry: TeamMessagesCacheEntry = { + ...current, + canonicalMessages: nextCanonical, + optimisticMessages: nextOptimistic, + feedRevision: page.feedRevision, + nextCursor: preserveLoadedOlderTail ? current.nextCursor : page.nextCursor, + hasMore: preserveLoadedOlderTail ? current.hasMore : page.hasMore, + lastFetchedAt: Date.now(), + loadingHead: false, + headHydrated: true, + }; + return { + teamMessagesByName: { + ...state.teamMessagesByName, + [teamName]: nextEntry, + }, + }; + }); + + return { + feedChanged, + headChanged, + feedRevision: page.feedRevision, + }; + } catch (error) { + set((state) => ({ + teamMessagesByName: { + ...state.teamMessagesByName, + [teamName]: { + ...getTeamMessagesCacheEntry(state, teamName), + loadingHead: false, + }, + }, + })); + throw error; + } finally { + inFlightTeamMessagesHeadRequests.delete(teamName); + if (pendingFreshTeamMessagesHeadRefreshes.delete(teamName)) { + void get().refreshTeamMessagesHead(teamName); + } + } + })(); + + inFlightTeamMessagesHeadRequests.set(teamName, request); + return request; + }, + + loadOlderTeamMessages: async (teamName: string) => { + const existingRequest = inFlightTeamMessagesOlderRequests.get(teamName); + if (existingRequest) { + return existingRequest; + } + + const existingHeadRequest = inFlightTeamMessagesHeadRequests.get(teamName); + if (existingHeadRequest) { + await existingHeadRequest; + } + + let entry = getTeamMessagesCacheEntry(get(), teamName); + if (!entry.headHydrated) { + await get().refreshTeamMessagesHead(teamName); + entry = getTeamMessagesCacheEntry(get(), teamName); + } + + if (!entry.headHydrated || !entry.nextCursor || entry.loadingOlder || entry.loadingHead) { + return; + } + + const request = (async (): Promise => { + set((state) => ({ + teamMessagesByName: { + ...state.teamMessagesByName, + [teamName]: { + ...getTeamMessagesCacheEntry(state, teamName), + loadingOlder: true, + }, + }, + })); + + try { + const baseFeedRevision = entry.feedRevision; + const page = await unwrapIpc('team:getMessagesPage', () => + api.teams.getMessagesPage(teamName, { + cursor: entry.nextCursor, + limit: 50, + }) + ); + + const current = getTeamMessagesCacheEntry(get(), teamName); + if (current.feedRevision !== baseFeedRevision) { + set((state) => ({ + teamMessagesByName: { + ...state.teamMessagesByName, + [teamName]: { + ...getTeamMessagesCacheEntry(state, teamName), + loadingOlder: false, + }, + }, + })); + await get().refreshTeamMessagesHead(teamName); + return; + } + + if (current.feedRevision && current.feedRevision !== page.feedRevision) { + set((state) => ({ + teamMessagesByName: { + ...state.teamMessagesByName, + [teamName]: { + ...getTeamMessagesCacheEntry(state, teamName), + loadingOlder: false, + }, + }, + })); + await get().refreshTeamMessagesHead(teamName); + return; + } + + set((state) => { + const liveEntry = getTeamMessagesCacheEntry(state, teamName); + const mergedCanonical = mergeTeamMessages(liveEntry.canonicalMessages, page.messages); + return { + teamMessagesByName: { + ...state.teamMessagesByName, + [teamName]: { + ...liveEntry, + canonicalMessages: mergedCanonical, + nextCursor: page.nextCursor, + hasMore: page.hasMore, + feedRevision: page.feedRevision, + loadingOlder: false, + }, + }, + }; + }); + } catch { + set((state) => ({ + teamMessagesByName: { + ...state.teamMessagesByName, + [teamName]: { + ...getTeamMessagesCacheEntry(state, teamName), + loadingOlder: false, + }, + }, + })); + } finally { + inFlightTeamMessagesOlderRequests.delete(teamName); + } + })(); + + inFlightTeamMessagesOlderRequests.set(teamName, request); + return request; + }, + + refreshMemberActivityMeta: async (teamName: string) => { + const entry = getTeamMessagesCacheEntry(get(), teamName); + if (!entry.headHydrated) { + return; + } + + const existingRequest = inFlightTeamMemberActivityMetaRequests.get(teamName); + if (existingRequest) { + pendingFreshTeamMemberActivityMetaRefreshes.add(teamName); + return existingRequest; + } + + const request = (async (): Promise => { + try { + const meta = await unwrapIpc('team:getMemberActivityMeta', () => + api.teams.getMemberActivityMeta(teamName) + ); + + set((state) => { + const currentFeedRevision = getTeamMessagesCacheEntry(state, teamName).feedRevision; + if (currentFeedRevision && meta.feedRevision !== currentFeedRevision) { + return {}; + } + const existing = state.memberActivityMetaByTeam[teamName]; + if (existing && existing.feedRevision === meta.feedRevision) { + return {}; + } + const sharedMembers = structurallyShareMemberActivityFacts( + existing?.members, + meta.members + ); + const nextMeta = + existing && + existing.members === sharedMembers && + existing.feedRevision === meta.feedRevision && + existing.computedAt === meta.computedAt + ? existing + : { + ...meta, + members: sharedMembers, + }; + return { + memberActivityMetaByTeam: { + ...state.memberActivityMetaByTeam, + [teamName]: nextMeta, + }, + }; + }); + } finally { + inFlightTeamMemberActivityMetaRequests.delete(teamName); + if (pendingFreshTeamMemberActivityMetaRefreshes.delete(teamName)) { + void get().refreshMemberActivityMeta(teamName); + } + } + })(); + + inFlightTeamMemberActivityMetaRequests.set(teamName, request); + return request; + }, + + syncTeamPendingReplyRefresh: (teamName: string, enabled: boolean, delayMs = 10_000) => { + clearPendingReplyRefreshTimer(teamName); + setPendingReplyRefreshEnabled(teamName, enabled); + if (!enabled) { + return; + } + + const timer = setTimeout(() => { + if (pendingTeamPendingReplyRefreshTimers.get(teamName) !== timer) { + return; + } + pendingTeamPendingReplyRefreshTimers.delete(teamName); + void (async () => { + try { + const headResult = await get().refreshTeamMessagesHead(teamName); + if (headResult.feedChanged || isMemberActivityMetaStale(get(), teamName)) { + await get().refreshMemberActivityMeta(teamName); + } + } catch { + // Best-effort delayed refresh while waiting for replies. + } + })(); + }, delayMs); + + pendingTeamPendingReplyRefreshTimers.set(teamName, timer); + }, + updateKanban: async (teamName: string, taskId: string, patch: UpdateKanbanPatch) => { try { set({ reviewActionError: null }); @@ -2442,24 +3306,15 @@ export const createTeamSlice: StateCreator = (set, sendingMessage: false, sendMessageError: null, lastSendMessageResult: result, - ...(selectTeamDataForName(state, teamName) - ? { - teamDataCacheByName: { - ...state.teamDataCacheByName, - [teamName]: upsertLocalSentMessage( - selectTeamDataForName(state, teamName)!, - optimisticMessage - ), - }, - } - : {}), - ...(state.selectedTeamName === teamName && state.selectedTeamData - ? { - selectedTeamData: upsertLocalSentMessage(state.selectedTeamData, optimisticMessage), - } - : {}), + teamMessagesByName: { + ...state.teamMessagesByName, + [teamName]: upsertOptimisticTeamMessage( + getTeamMessagesCacheEntry(state, teamName), + optimisticMessage + ), + }, })); - await get().refreshTeamData(teamName); + await get().refreshTeamMessagesHead(teamName); } catch (error) { set({ sendingMessage: false, @@ -2493,7 +3348,7 @@ export const createTeamSlice: StateCreator = (set, deduplicated: result.deduplicated, }, }); - await get().refreshTeamData(request.fromTeam); + await get().refreshTeamMessagesHead(request.fromTeam); } catch (error) { set({ sendingMessage: false, @@ -2668,13 +3523,27 @@ export const createTeamSlice: StateCreator = (set, deleteTeam: async (teamName: string) => { await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName)); + clearPendingReplyRefreshTimer(teamName); + setPendingReplyRefreshEnabled(teamName, false); set((state) => { const nextCache = state.teamDataCacheByName[teamName] ? { ...state.teamDataCacheByName } : null; + const nextMessageCache = state.teamMessagesByName[teamName] + ? { ...state.teamMessagesByName } + : null; + const nextActivityMeta = state.memberActivityMetaByTeam[teamName] + ? { ...state.memberActivityMetaByTeam } + : null; if (nextCache) { delete nextCache[teamName]; } + if (nextMessageCache) { + delete nextMessageCache[teamName]; + } + if (nextActivityMeta) { + delete nextActivityMeta[teamName]; + } if (state.selectedTeamName === teamName) { return { selectedTeamName: null, @@ -2682,9 +3551,15 @@ export const createTeamSlice: StateCreator = (set, selectedTeamLoading: false, selectedTeamError: null, ...(nextCache ? { teamDataCacheByName: nextCache } : {}), + ...(nextMessageCache ? { teamMessagesByName: nextMessageCache } : {}), + ...(nextActivityMeta ? { memberActivityMetaByTeam: nextActivityMeta } : {}), }; } - return nextCache ? { teamDataCacheByName: nextCache } : {}; + return { + ...(nextCache ? { teamDataCacheByName: nextCache } : {}), + ...(nextMessageCache ? { teamMessagesByName: nextMessageCache } : {}), + ...(nextActivityMeta ? { memberActivityMetaByTeam: nextActivityMeta } : {}), + }; }); await get().fetchTeams(); await get().fetchAllTasks(); @@ -2692,15 +3567,32 @@ export const createTeamSlice: StateCreator = (set, restoreTeam: async (teamName: string) => { await unwrapIpc('team:restoreTeam', () => api.teams.restoreTeam(teamName)); + clearPendingReplyRefreshTimer(teamName); + setPendingReplyRefreshEnabled(teamName, false); set((state) => { - if (!state.teamDataCacheByName[teamName]) { + const hasSnapshot = Boolean(state.teamDataCacheByName[teamName]); + const hasMessages = Boolean(state.teamMessagesByName[teamName]); + const hasMeta = Boolean(state.memberActivityMetaByTeam[teamName]); + if (!hasSnapshot && !hasMessages && !hasMeta) { return {}; } - const nextCache = { ...state.teamDataCacheByName }; - delete nextCache[teamName]; - return { - teamDataCacheByName: nextCache, - }; + const nextState: Partial = {}; + if (hasSnapshot) { + const nextCache = { ...state.teamDataCacheByName }; + delete nextCache[teamName]; + nextState.teamDataCacheByName = nextCache; + } + if (hasMessages) { + const nextMessages = { ...state.teamMessagesByName }; + delete nextMessages[teamName]; + nextState.teamMessagesByName = nextMessages; + } + if (hasMeta) { + const nextMeta = { ...state.memberActivityMetaByTeam }; + delete nextMeta[teamName]; + nextState.memberActivityMetaByTeam = nextMeta; + } + return nextState; }); await get().fetchTeams(); await get().fetchAllTasks(); @@ -2708,19 +3600,34 @@ export const createTeamSlice: StateCreator = (set, permanentlyDeleteTeam: async (teamName: string) => { await unwrapIpc('team:permanentlyDeleteTeam', () => api.teams.permanentlyDeleteTeam(teamName)); + clearPendingReplyRefreshTimer(teamName); + setPendingReplyRefreshEnabled(teamName, false); const state = get(); const nextCache = { ...state.teamDataCacheByName }; + const nextMessages = { ...state.teamMessagesByName }; + const nextMeta = { ...state.memberActivityMetaByTeam }; delete nextCache[teamName]; + delete nextMessages[teamName]; + delete nextMeta[teamName]; if (state.selectedTeamName === teamName) { set({ selectedTeamName: null, selectedTeamData: null, selectedTeamError: null, teamDataCacheByName: nextCache, + teamMessagesByName: nextMessages, + memberActivityMetaByTeam: nextMeta, }); } else if (state.teamDataCacheByName[teamName]) { set({ teamDataCacheByName: nextCache, + teamMessagesByName: nextMessages, + memberActivityMetaByTeam: nextMeta, + }); + } else if (state.teamMessagesByName[teamName] || state.memberActivityMetaByTeam[teamName]) { + set({ + teamMessagesByName: nextMessages, + memberActivityMetaByTeam: nextMeta, }); } await get().fetchTeams(); diff --git a/src/renderer/utils/teamProvisioningPresentation.ts b/src/renderer/utils/teamProvisioningPresentation.ts index 2253c177..38b5cc32 100644 --- a/src/renderer/utils/teamProvisioningPresentation.ts +++ b/src/renderer/utils/teamProvisioningPresentation.ts @@ -8,7 +8,6 @@ import { import type { MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, - ResolvedTeamMember, TeamProvisioningProgress, } from '@shared/types'; @@ -17,6 +16,11 @@ type MemberSpawnStatusCollection = | Map | undefined; +interface ProvisioningMemberLike { + name: string; + removedAt?: number; +} + const ACTIVE_PROVISIONING_STATES = new Set([ 'validating', 'spawning', @@ -66,7 +70,7 @@ export function buildTeamProvisioningPresentation({ memberSpawnSnapshot, }: { progress: TeamProvisioningProgress | null | undefined; - members: readonly ResolvedTeamMember[]; + members: readonly ProvisioningMemberLike[]; memberSpawnStatuses?: MemberSpawnStatusCollection; memberSpawnSnapshot?: Pick; }): TeamProvisioningPresentation | null { diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index afc2aae7..3f42ec1c 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -53,6 +53,7 @@ import type { KanbanColumnId, LeadActivitySnapshot, LeadContextUsageSnapshot, + TeamMemberActivityMeta, MemberFullStats, MemberLogSummary, MemberSpawnStatusesSnapshot, @@ -71,10 +72,10 @@ import type { TeamCreateConfigRequest, TeamCreateRequest, TeamCreateResponse, - TeamData, TeamLaunchRequest, TeamLaunchResponse, TeamMessageNotificationData, + TeamViewSnapshot, TeamProvisioningPrepareResult, TeamProvisioningProgress, TeamSummary, @@ -425,7 +426,7 @@ export interface HttpServerAPI { export interface TeamsAPI { list: () => Promise; - getData: (teamName: string) => Promise; + getData: (teamName: string) => Promise; getTaskChangePresence: (teamName: string) => Promise>; setChangePresenceTracking: (teamName: string, enabled: boolean) => Promise; setToolActivityTracking: (teamName: string, enabled: boolean) => Promise; @@ -446,8 +447,9 @@ export interface TeamsAPI { sendMessage: (teamName: string, request: SendMessageRequest) => Promise; getMessagesPage: ( teamName: string, - options?: { beforeTimestamp?: string; limit?: number } + options?: { cursor?: string | null; limit?: number } ) => Promise; + getMemberActivityMeta: (teamName: string) => Promise; createTask: (teamName: string, request: CreateTaskRequest) => Promise; requestReview: (teamName: string, taskId: string) => Promise; updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index dcc8dfdf..41562918 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -602,6 +602,11 @@ export interface MessagesPage { /** Opaque cursor string for fetching older messages. Null when no more pages. */ nextCursor: string | null; hasMore: boolean; + /** + * Content-stable revision of the full normalized feed that produced this page. + * Changes only when the semantic message feed changes. + */ + feedRevision: string; } export type AgentActionMode = 'do' | 'ask' | 'delegate'; @@ -729,12 +734,44 @@ export interface TeamProcess { stoppedAt?: string; } -export interface TeamData { +export interface TeamMemberSnapshot { + name: string; + agentId?: string; + currentTaskId: string | null; + taskCount: number; + color?: string; + agentType?: string; + role?: string; + workflow?: string; + providerId?: TeamProviderId; + model?: string; + effort?: EffortLevel; + cwd?: string; + /** Set only when member's git branch differs from the lead's branch. */ + gitBranch?: string; + runtimeAdvisory?: MemberRuntimeAdvisory; + removedAt?: number; +} + +export interface MemberActivityMetaEntry { + memberName: string; + lastAuthoredMessageAt: string | null; + messageCountExact: number; + latestAuthoredMessageSignalsTermination: boolean; +} + +export interface TeamMemberActivityMeta { + teamName: string; + computedAt: string; + members: Record; + feedRevision: string; +} + +export interface TeamViewSnapshot { teamName: string; config: TeamConfig; tasks: TeamTaskWithKanban[]; - members: ResolvedTeamMember[]; - messages: InboxMessage[]; + members: TeamMemberSnapshot[]; kanbanState: KanbanState; processes: TeamProcess[]; warnings?: string[]; diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 24412b24..4a2ef3a7 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -12,7 +12,7 @@ import type { } from '@shared/types/team'; vi.mock('electron', () => ({ - app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp') }, + app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp'), isPackaged: false }, Notification: Object.assign(vi.fn(), { isSupported: vi.fn(() => false) }), BrowserWindow: { getAllWindows: vi.fn(() => []) }, })); @@ -34,6 +34,8 @@ const { mockTeamDataWorkerClient } = vi.hoisted(() => ({ mockTeamDataWorkerClient: { isAvailable: vi.fn(), getTeamData: vi.fn(), + getMessagesPage: vi.fn(), + getMemberActivityMeta: vi.fn(), findLogsForTask: vi.fn(), }, })); @@ -62,6 +64,8 @@ import { TEAM_CREATE_TASK, TEAM_DELETE_TEAM, TEAM_GET_DATA, + TEAM_GET_MEMBER_ACTIVITY_META, + TEAM_GET_MESSAGES_PAGE, TEAM_LAUNCH, TEAM_LIST, TEAM_PREPARE_PROVISIONING, @@ -135,13 +139,33 @@ describe('ipc teams handlers', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [] as InboxMessage[], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], })), + getMessageFeed: vi.fn(async () => ({ + teamName: 'my-team', + feedRevision: 'rev-1', + messages: [] as InboxMessage[], + })), + getMessagesPage: vi.fn(async () => ({ + messages: [] as InboxMessage[], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-1', + })), + getMemberActivityMeta: vi.fn(async () => ({ + teamName: 'my-team', + computedAt: '2026-03-12T10:00:00.000Z', + members: {}, + feedRevision: 'rev-1', + })), getTaskChangePresence: vi.fn(async () => ({ 'task-1': 'has_changes' })), reconcileTeamArtifacts: vi.fn(async () => undefined), setTaskChangePresenceTracking: vi.fn(() => undefined), + getTeamNotificationContext: vi.fn(async () => ({ + displayName: 'My Team', + projectPath: '/tmp/project', + })), deleteTeam: vi.fn(async () => undefined), getLeadMemberName: vi.fn(async () => 'team-lead'), getTeamDisplayName: vi.fn(async () => 'My Team'), @@ -231,6 +255,8 @@ describe('ipc teams handlers', () => { mockGetMembersMeta.mockResolvedValue([]); mockTeamDataWorkerClient.isAvailable.mockReturnValue(false); mockTeamDataWorkerClient.getTeamData.mockReset(); + mockTeamDataWorkerClient.getMessagesPage.mockReset(); + mockTeamDataWorkerClient.getMemberActivityMeta.mockReset(); mockTeamDataWorkerClient.findLogsForTask.mockReset(); initializeTeamHandlers( service as never, @@ -252,6 +278,8 @@ describe('ipc teams handlers', () => { it('registers all expected handlers', () => { expect(handlers.has(TEAM_LIST)).toBe(true); expect(handlers.has(TEAM_GET_DATA)).toBe(true); + expect(handlers.has(TEAM_GET_MESSAGES_PAGE)).toBe(true); + expect(handlers.has(TEAM_GET_MEMBER_ACTIVITY_META)).toBe(true); expect(handlers.has(TEAM_GET_TASK_CHANGE_PRESENCE)).toBe(true); expect(handlers.has(TEAM_SET_CHANGE_PRESENCE_TRACKING)).toBe(true); expect(handlers.has(TEAM_DELETE_TEAM)).toBe(true); @@ -580,7 +608,6 @@ describe('ipc teams handlers', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [] as InboxMessage[], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }); @@ -759,24 +786,7 @@ describe('ipc teams handlers', () => { }); }); - it('dedups live lead replies when lead_session already has same text', async () => { - service.getTeamData.mockResolvedValueOnce({ - teamName: 'my-team', - config: { name: 'My Team' }, - tasks: [], - members: [], - messages: [ - { - from: 'team-lead', - text: 'Hello there', - timestamp: '2026-02-23T10:00:00.000Z', - read: true, - source: 'lead_session' as const, - }, - ], - kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, - processes: [], - }); + it('keeps TEAM_GET_DATA structural and does not expose message transport', async () => { provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([ { from: 'team-lead', @@ -791,59 +801,159 @@ describe('ipc teams handlers', () => { const getDataHandler = handlers.get(TEAM_GET_DATA)!; const result = (await getDataHandler({} as never, 'my-team')) as { success: boolean; - data: { messages: { source?: string }[] }; + data: Record; }; expect(result.success).toBe(true); - const sources = result.data.messages.map((m) => m.source); - expect(sources.filter((s) => s === 'lead_process')).toHaveLength(0); - expect(sources.filter((s) => s === 'lead_session')).toHaveLength(1); + expect(result.data.teamName).toBe('my-team'); + expect(result.data).not.toHaveProperty('messages'); + expect(service.getMessageFeed).not.toHaveBeenCalled(); }); - it('merges early live messages before durable lead_session backfill exists', async () => { - // Simulate: team just became readable but lead_session JSONL hasn't been written yet. - // Only live in-memory messages exist from the provisioning process. - service.getTeamData.mockResolvedValueOnce({ - teamName: 'my-team', - config: { name: 'My Team' }, - tasks: [], - members: [], - messages: [], // No durable messages yet - kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, - processes: [], - }); - provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([ - { - from: 'team-lead', - text: 'Команда создана. Запускаю тиммейтов.', - timestamp: '2026-02-23T10:00:00.000Z', - read: true, - source: 'lead_process' as const, - messageId: 'lead-turn-run-1-1', - }, - { - from: 'team-lead', - text: 'All teammates online!', - timestamp: '2026-02-23T10:00:01.000Z', - read: true, - source: 'lead_process' as const, - messageId: 'lead-turn-run-1-2', - to: 'user', - }, - ]); + it('rejects TEAM_GET_DATA fallback in packaged runtime when worker is unavailable', async () => { + const electron = await import('electron'); + mockTeamDataWorkerClient.isAvailable.mockReturnValue(false); + (electron.app as { isPackaged: boolean }).isPackaged = true; + + const handler = handlers.get(TEAM_GET_DATA)!; + const result = (await handler({} as never, 'my-team')) as { success: boolean; error?: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain('TEAM_DATA_WORKER_UNAVAILABLE'); + expect(service.getTeamData).not.toHaveBeenCalled(); + vi.mocked(console.error).mockClear(); + + (electron.app as { isPackaged: boolean }).isPackaged = false; + }); + + it('uses the team-data worker for TEAM_GET_MESSAGES_PAGE when available', async () => { + mockTeamDataWorkerClient.isAvailable.mockReturnValue(true); + mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({ + messages: [ + { + from: 'team-lead', + text: 'Hello there', + timestamp: '2026-02-23T10:00:01.000Z', + read: true, + source: 'lead_session' as const, + messageId: 'msg-1', + }, + ], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-worker', + }); + + const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!; + const result = (await handler({} as never, 'my-team', { + limit: 50, + })) as { success: boolean; data: { feedRevision: string } }; - const getDataHandler = handlers.get(TEAM_GET_DATA)!; - const result = (await getDataHandler({} as never, 'my-team')) as { - success: boolean; - data: { messages: { source?: string; text: string }[] }; - }; expect(result.success).toBe(true); - // Both live messages should appear since there's no durable backfill yet - // Sorted by timestamp descending (newest first) - expect(result.data.messages).toHaveLength(2); - expect(result.data.messages[0].source).toBe('lead_process'); - expect(result.data.messages[0].text).toBe('All teammates online!'); - expect(result.data.messages[1].source).toBe('lead_process'); - expect(result.data.messages[1].text).toBe('Команда создана. Запускаю тиммейтов.'); + expect(result.data.feedRevision).toBe('rev-worker'); + expect(mockTeamDataWorkerClient.getMessagesPage).toHaveBeenCalledWith('my-team', { + cursor: undefined, + limit: 50, + }); + expect(service.getMessagesPage).not.toHaveBeenCalled(); + }); + + it('scans rate-limit notifications from message-page results without hydrating TEAM_GET_DATA feed', async () => { + mockTeamDataWorkerClient.isAvailable.mockReturnValue(true); + mockTeamDataWorkerClient.getMessagesPage.mockResolvedValueOnce({ + messages: [ + { + from: 'team-lead', + text: "You've hit your limit. Please wait a bit before retrying.", + timestamp: '2026-02-23T10:00:01.000Z', + read: true, + source: 'lead_session' as const, + messageId: 'msg-rate-limit-1', + }, + ], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-worker', + }); + + const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!; + const result = (await handler({} as never, 'my-team', { + limit: 50, + })) as { success: boolean; data: { feedRevision: string } }; + + expect(result.success).toBe(true); + expect(result.data.feedRevision).toBe('rev-worker'); + expect(mockAddTeamNotification).toHaveBeenCalledWith( + expect.objectContaining({ + teamEventType: 'rate_limit', + teamName: 'my-team', + teamDisplayName: 'My Team', + from: 'team-lead', + dedupeKey: 'rate-limit:my-team:msg-rate-limit-1', + }) + ); + expect(service.getMessageFeed).not.toHaveBeenCalled(); + }); + + it('rejects heavy TEAM_GET_MESSAGES_PAGE fallback in packaged runtime when worker is unavailable', async () => { + const electron = await import('electron'); + mockTeamDataWorkerClient.isAvailable.mockReturnValue(false); + (electron.app as { isPackaged: boolean }).isPackaged = true; + + const handler = handlers.get(TEAM_GET_MESSAGES_PAGE)!; + const result = (await handler({} as never, 'my-team', { + limit: 50, + })) as { success: boolean; error?: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain('TEAM_DATA_WORKER_UNAVAILABLE'); + expect(service.getMessagesPage).not.toHaveBeenCalled(); + vi.mocked(console.error).mockClear(); + + (electron.app as { isPackaged: boolean }).isPackaged = false; + }); + + it('uses the team-data worker for TEAM_GET_MEMBER_ACTIVITY_META when available', async () => { + mockTeamDataWorkerClient.isAvailable.mockReturnValue(true); + mockTeamDataWorkerClient.getMemberActivityMeta.mockResolvedValueOnce({ + teamName: 'my-team', + computedAt: '2026-03-12T10:00:00.000Z', + members: { + alice: { + memberName: 'alice', + lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z', + messageCountExact: 4, + latestAuthoredMessageSignalsTermination: false, + }, + }, + feedRevision: 'rev-worker', + }); + + const handler = handlers.get(TEAM_GET_MEMBER_ACTIVITY_META)!; + const result = (await handler({} as never, 'my-team')) as { + success: boolean; + data: { feedRevision: string }; + }; + + expect(result.success).toBe(true); + expect(result.data.feedRevision).toBe('rev-worker'); + expect(mockTeamDataWorkerClient.getMemberActivityMeta).toHaveBeenCalledWith('my-team'); + expect(service.getMemberActivityMeta).not.toHaveBeenCalled(); + }); + + it('rejects heavy TEAM_GET_MEMBER_ACTIVITY_META fallback in packaged runtime when worker is unavailable', async () => { + const electron = await import('electron'); + mockTeamDataWorkerClient.isAvailable.mockReturnValue(false); + (electron.app as { isPackaged: boolean }).isPackaged = true; + + const handler = handlers.get(TEAM_GET_MEMBER_ACTIVITY_META)!; + const result = (await handler({} as never, 'my-team')) as { success: boolean; error?: string }; + + expect(result.success).toBe(false); + expect(result.error).toContain('TEAM_DATA_WORKER_UNAVAILABLE'); + expect(service.getMemberActivityMeta).not.toHaveBeenCalled(); + vi.mocked(console.error).mockClear(); + + (electron.app as { isPackaged: boolean }).isPackaged = false; }); it('keeps TEAM_GET_DATA read-only and never triggers reconcile side effects', async () => { diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index a2e077bb..2d9d61b7 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -11,8 +11,9 @@ import { TeamDataService } from '../../../../src/main/services/team/TeamDataServ import type { InboxMessage, KanbanState, + ResolvedTeamMember, TeamConfig, - TeamData, + TeamProcess, TeamTask, TeamTaskWithKanban, } from '../../../../src/shared/types/team'; @@ -240,10 +241,9 @@ function createGetTeamDataHarness(options: { config: TeamConfig, metaMembers: TeamConfig['members'], inboxNames: string[], - tasks: TeamTaskWithKanban[], - messages: InboxMessage[] - ) => TeamData['members']; - listProcesses?: () => TeamData['processes']; + tasks: TeamTaskWithKanban[] + ) => ResolvedTeamMember[]; + listProcesses?: () => TeamProcess[]; getMemberAdvisories?: () => Promise>; } = {}) { const getConfig = vi.fn(async () => @@ -351,7 +351,7 @@ function createGetTeamDataHarness(options: { }; } -function buildResolvedMember(name: string): TeamData['members'][number] { +function buildResolvedMember(name: string): ResolvedTeamMember { return { name, status: 'unknown', @@ -628,6 +628,39 @@ describe('TeamDataService', () => { ); }); + it('returns lightweight notification context from config without hydrating team data', async () => { + const getConfig = vi.fn(async () => ({ + name: 'My Team', + projectPath: '/Users/dev/my-project', + members: [], + })); + + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig, + } as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + (() => ({ processes: { listProcesses: vi.fn(() => []) } })) as never + ); + + const result = await service.getTeamNotificationContext('my-team'); + + expect(result).toEqual({ + displayName: 'My Team', + projectPath: '/Users/dev/my-project', + }); + expect(getConfig).toHaveBeenCalledWith('my-team'); + }); + it('creates task with status pending when startImmediately is false', async () => { const createTaskMock = vi.fn((task) => ({ ...task, status: 'pending' })); const service = new TeamDataService( @@ -2437,8 +2470,8 @@ describe('TeamDataService', () => { } as never ); - const data = await service.getTeamData('my-team'); - const costResult = data.messages.find((message) => message.messageId === 'lead-thought-1'); + const feed = await service.getMessageFeed('my-team'); + const costResult = feed.messages.find((message) => message.messageId === 'lead-thought-1'); expect(costResult).toMatchObject({ messageKind: 'slash_command_result', @@ -2507,8 +2540,8 @@ describe('TeamDataService', () => { } as never ); - const data = await service.getTeamData('my-team'); - const result = data.messages.find((message) => message.messageId === 'passive-idle-dup-1'); + const feed = await service.getMessageFeed('my-team'); + const result = feed.messages.find((message) => message.messageId === 'passive-idle-dup-1'); expect(result).toBeDefined(); expect(result?.source).not.toBe('lead_process'); @@ -2582,8 +2615,8 @@ describe('TeamDataService', () => { sentMessages: [userReplyRow], }); - const data = await service.getTeamData('my-team'); - const linked = data.messages.find((message) => message.messageId === 'passive-user-summary-1'); + const feed = await service.getMessageFeed('my-team'); + const linked = feed.messages.find((message) => message.messageId === 'passive-user-summary-1'); expect(linked?.relayOfMessageId).toBe('user-reply-1'); expect(passiveSummaryRow.relayOfMessageId).toBeUndefined(); @@ -2618,8 +2651,8 @@ describe('TeamDataService', () => { ], }); - const data = await service.getTeamData('my-team'); - const linked = data.messages.find( + const feed = await service.getMessageFeed('my-team'); + const linked = feed.messages.find( (message) => message.messageId === 'passive-user-summary-contains-1' ); @@ -2655,8 +2688,8 @@ describe('TeamDataService', () => { ], }); - const data = await service.getTeamData('my-team'); - const linked = data.messages.find((message) => message.messageId === 'passive-user-summary-old-1'); + const feed = await service.getMessageFeed('my-team'); + const linked = feed.messages.find((message) => message.messageId === 'passive-user-summary-old-1'); expect(linked?.relayOfMessageId).toBeUndefined(); }); @@ -2690,8 +2723,8 @@ describe('TeamDataService', () => { ], }); - const data = await service.getTeamData('my-team'); - const linked = data.messages.find((message) => message.messageId === 'passive-bob-summary-1'); + const feed = await service.getMessageFeed('my-team'); + const linked = feed.messages.find((message) => message.messageId === 'passive-bob-summary-1'); expect(linked?.relayOfMessageId).toBeUndefined(); }); @@ -2725,8 +2758,8 @@ describe('TeamDataService', () => { ], }); - const data = await service.getTeamData('my-team'); - const linked = data.messages.find( + const feed = await service.getMessageFeed('my-team'); + const linked = feed.messages.find( (message) => message.messageId === 'passive-user-summary-sender-1' ); @@ -2772,8 +2805,8 @@ describe('TeamDataService', () => { ], }); - const data = await service.getTeamData('my-team'); - const linked = data.messages.find( + const feed = await service.getMessageFeed('my-team'); + const linked = feed.messages.find( (message) => message.messageId === 'passive-user-summary-ambiguous-1' ); @@ -3281,8 +3314,6 @@ describe('TeamDataService', () => { it('starts light reads immediately, bounds heavy reads, and keeps processes outside the parallel phase', async () => { const order: string[] = []; const tasksDeferred = createDeferred(); - const messagesDeferred = createDeferred(); - const leadTextsDeferred = createDeferred(); const harness = createGetTeamDataHarness({ getTasks: async () => { @@ -3293,10 +3324,6 @@ describe('TeamDataService', () => { order.push('inboxNames:start'); return []; }, - getMessages: async () => { - order.push('messages:start'); - return messagesDeferred.promise; - }, getMembers: async () => { order.push('meta:start'); return []; @@ -3305,10 +3332,6 @@ describe('TeamDataService', () => { order.push('kanban:start'); return { teamName: 'my-team', reviewers: [], tasks: {} }; }, - readMessages: async () => { - order.push('sent:start'); - return []; - }, resolveMembers: () => { order.push('resolveMembers'); return []; @@ -3330,39 +3353,21 @@ describe('TeamDataService', () => { }, }); - vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation( - async () => { - order.push('leadTexts:start'); - return leadTextsDeferred.promise; - } - ); - const pending = harness.service.getTeamData('my-team'); await flushMicrotasks(); expect(order).toEqual( expect.arrayContaining([ 'inboxNames:start', - 'sent:start', 'meta:start', 'kanban:start', 'tasks:start', - 'messages:start', ]) ); - expect(order).not.toContain('leadTexts:start'); expect(order).not.toContain('processes:start'); + expect(order).not.toContain('leadTexts:start'); tasksDeferred.resolve([]); - await flushMicrotasks(); - - expect(order).toContain('leadTexts:start'); - expect(order.indexOf('tasks:start')).toBeLessThan(order.indexOf('messages:start')); - expect(order.indexOf('messages:start')).toBeLessThan(order.indexOf('leadTexts:start')); - expect(order).not.toContain('processes:start'); - - messagesDeferred.resolve([]); - leadTextsDeferred.resolve([]); const data = await pending; @@ -3372,7 +3377,7 @@ describe('TeamDataService', () => { pid: 101, }), ]); - expect(order.indexOf('leadTexts:start')).toBeLessThan(order.indexOf('processes:start')); + expect(order).not.toContain('leadTexts:start'); expect(order.indexOf('resolveMembers')).toBeLessThan(order.indexOf('processes:start')); }); @@ -3417,47 +3422,64 @@ describe('TeamDataService', () => { ); }); + it('surfaces isAlive in the structural snapshot from live process state', async () => { + const aliveHarness = createGetTeamDataHarness({ + listProcesses: () => + [ + { + id: 'proc-1', + label: 'Lead', + pid: 101, + registeredAt: '2026-04-09T10:00:00.000Z', + }, + ] satisfies TeamProcess[], + }); + const offlineHarness = createGetTeamDataHarness({ + listProcesses: () => + [ + { + id: 'proc-1', + label: 'Lead', + pid: 101, + registeredAt: '2026-04-09T10:00:00.000Z', + stoppedAt: '2026-04-09T10:05:00.000Z', + }, + ] satisfies TeamProcess[], + }); + + const aliveData = await aliveHarness.service.getTeamData('my-team'); + const offlineData = await offlineHarness.service.getTeamData('my-team'); + + expect(aliveData.isAlive).toBe(true); + expect(offlineData.isAlive).toBe(false); + }); + it('keeps warning order deterministic even when read failures settle out of order', async () => { const tasksDeferred = createDeferred(); const inboxDeferred = createDeferred(); - const messagesDeferred = createDeferred(); - const leadTextsDeferred = createDeferred(); - const sentDeferred = createDeferred(); const metaDeferred = createDeferred(); const kanbanDeferred = createDeferred(); const harness = createGetTeamDataHarness({ getTasks: async () => tasksDeferred.promise, listInboxNames: async () => inboxDeferred.promise, - getMessages: async () => messagesDeferred.promise, getMembers: async () => metaDeferred.promise, getState: async () => kanbanDeferred.promise, - readMessages: async () => sentDeferred.promise, }); - vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation( - async () => leadTextsDeferred.promise - ); - const pending = harness.service.getTeamData('my-team'); await flushMicrotasks(); - sentDeferred.reject(new Error('sent failed')); kanbanDeferred.reject(new Error('kanban failed')); tasksDeferred.reject(new Error('tasks failed')); metaDeferred.reject(new Error('meta failed')); inboxDeferred.reject(new Error('inbox failed')); - leadTextsDeferred.reject(new Error('lead failed')); - messagesDeferred.reject(new Error('messages failed')); const data = await pending; expect(data.warnings).toEqual([ 'Tasks failed to load', 'Inboxes failed to load', - 'Messages failed to load', - 'Lead session texts failed to load', - 'Sent messages failed to load', 'Member metadata failed to load', 'Kanban state failed to load', ]); @@ -3501,9 +3523,9 @@ describe('TeamDataService', () => { }, ]); - const data = await harness.service.getTeamData('my-team'); + const feed = await harness.service.getMessageFeed('my-team'); - expect(data.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1', 'inbox-1']); + expect(feed.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1', 'inbox-1']); }); it('preserves assembled messages and resolver inputs when inbox messages fail', async () => { @@ -3552,11 +3574,10 @@ describe('TeamDataService', () => { ]); const data = await harness.service.getTeamData('my-team'); + const feed = await harness.service.getMessageFeed('my-team'); - expect(data.warnings).toEqual( - expect.arrayContaining(['Messages failed to load', 'Kanban state failed to load']) - ); - expect(data.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1']); + expect(data.warnings).toEqual(expect.arrayContaining(['Kanban state failed to load'])); + expect(feed.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1']); expect(resolveMembersSpy).toHaveBeenCalledWith( buildDefaultTeamConfig(), metaMembers, @@ -3566,10 +3587,6 @@ describe('TeamDataService', () => { id: 'task-1', subject: 'Investigate rollout', }), - ], - [ - expect.objectContaining({ messageId: 'sent-1' }), - expect.objectContaining({ messageId: 'lead-1' }), ] ); }); @@ -3608,16 +3625,11 @@ describe('TeamDataService', () => { it('degrades a queued heavy sync throw to warning and still completes the snapshot', async () => { const order: string[] = []; const tasksDeferred = createDeferred(); - const messagesDeferred = createDeferred(); const harness = createGetTeamDataHarness({ getTasks: async () => { order.push('tasks:start'); return tasksDeferred.promise; }, - getMessages: async () => { - order.push('messages:start'); - return messagesDeferred.promise; - }, listProcesses: () => { order.push('processes:start'); return []; @@ -3635,14 +3647,9 @@ describe('TeamDataService', () => { expect(order).not.toContain('leadTexts:start'); tasksDeferred.resolve([]); - await flushMicrotasks(); - - expect(order).toContain('leadTexts:start'); - - messagesDeferred.resolve([]); const data = await pending; - expect(data.warnings).toEqual(expect.arrayContaining(['Lead session texts failed to load'])); + expect(data.warnings ?? []).not.toContain('Lead session texts failed to load'); expect(order).toContain('processes:start'); }); @@ -3780,7 +3787,7 @@ describe('TeamDataService', () => { expect(page1.hasMore).toBe(true); const page2 = await service.getMessagesPage('my-team', { - beforeTimestamp: page1.nextCursor!, + cursor: page1.nextCursor!, limit: 10, }); // Should get the remaining 2 messages, not lose the one with same timestamp @@ -3813,5 +3820,40 @@ describe('TeamDataService', () => { const result = page.messages.find((m) => m.messageId === 'resp1'); expect(result?.messageKind).toBe('slash_command_result'); }); + + it('normalizes stable effective message ids before pagination and cursoring', async () => { + const msgs = [ + { + from: 'alice', + text: 'same-ts-a', + timestamp: '2026-01-01T00:00:02.000Z', + source: 'inbox' as const, + }, + { + from: 'bob', + text: 'same-ts-b', + timestamp: '2026-01-01T00:00:02.000Z', + source: 'inbox' as const, + }, + { + from: 'carol', + text: 'older', + timestamp: '2026-01-01T00:00:01.000Z', + source: 'inbox' as const, + }, + ]; + const service = createPaginationService(msgs); + + const page1 = await service.getMessagesPage('my-team', { limit: 1 }); + const page2 = await service.getMessagesPage('my-team', { + cursor: page1.nextCursor!, + limit: 10, + }); + + expect(page1.messages[0]?.messageId).toMatch(/^inbox-/); + expect(page1.nextCursor).toContain(page1.messages[0]!.messageId!); + expect(page2.messages.every((message) => Boolean(message.messageId))).toBe(true); + expect(new Set([...page1.messages, ...page2.messages].map((message) => message.messageId)).size).toBe(3); + }); }); }); diff --git a/test/main/services/team/TeamMemberResolver.test.ts b/test/main/services/team/TeamMemberResolver.test.ts index 1a0b855c..77f44ce7 100644 --- a/test/main/services/team/TeamMemberResolver.test.ts +++ b/test/main/services/team/TeamMemberResolver.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'; import { TeamMemberResolver } from '../../../../src/main/services/team/TeamMemberResolver'; import type { - InboxMessage, TeamConfig, TeamTask, TeamTaskWithKanban, @@ -24,13 +23,8 @@ describe('TeamMemberResolver', () => { { id: '1', subject: 'Visible task', status: 'pending', owner: 'alice' }, { id: '2', subject: 'Ghost task', status: 'pending', owner: 'stranger' }, ]; - const now = new Date().toISOString(); - const messages: InboxMessage[] = [ - { from: 'bob', text: 'ready', timestamp: now, read: false, color: 'green' }, - { from: 'user', text: 'system note', timestamp: now, read: false }, - ]; - const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks, messages); + const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks); const names = members.map((member) => member.name); expect(names).toHaveLength(3); @@ -62,9 +56,8 @@ describe('TeamMemberResolver', () => { ]; const inboxNames = ['user', 'alice']; const tasks: TeamTask[] = []; - const messages: InboxMessage[] = []; - const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks, messages); + const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks); const names = members.map((m) => m.name); expect(names).not.toContain('user'); @@ -81,9 +74,8 @@ describe('TeamMemberResolver', () => { const metaMembers: TeamConfig['members'] = [{ name: 'alice', agentType: 'general-purpose' }]; const inboxNames = ['alice', 'team-best.user', 'dream-team.team-lead']; const tasks: TeamTask[] = []; - const messages: InboxMessage[] = []; - const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks, messages); + const members = resolver.resolveMembers(config, metaMembers, inboxNames, tasks); const names = members.map((m) => m.name); expect(names).toContain('alice'); @@ -104,7 +96,7 @@ describe('TeamMemberResolver', () => { ]; const inboxNames = ['a3975f80d37fbcea1', 'alice', 'a68a8f6a643e59bfd']; - const members = resolver.resolveMembers(config, metaMembers, inboxNames, [], []); + const members = resolver.resolveMembers(config, metaMembers, inboxNames, []); const names = members.map((m) => m.name); expect(names).toContain('alice'); @@ -124,7 +116,7 @@ describe('TeamMemberResolver', () => { ], }; - const members = resolver.resolveMembers(config, [], ['ops.bot'], [], []); + const members = resolver.resolveMembers(config, [], ['ops.bot'], []); const names = members.map((m) => m.name); expect(names).toContain('ops.bot'); @@ -141,7 +133,6 @@ describe('TeamMemberResolver', () => { config, [], ['cross-team:team-alpha-super', 'cross-team-team-alpha-super', 'alice'], - [], [] ); const names = members.map((m) => m.name); @@ -163,7 +154,6 @@ describe('TeamMemberResolver', () => { config, [], ['cross_team_send', 'cross_team_list_targets', 'alice'], - [], [] ); const names = members.map((m) => m.name); @@ -185,7 +175,6 @@ describe('TeamMemberResolver', () => { config, [], ['cross_team::team-alpha-super', 'cross_team--team-alpha-super', 'alice'], - [], [] ); const names = members.map((m) => m.name); @@ -206,7 +195,7 @@ describe('TeamMemberResolver', () => { ], }; - const members = resolver.resolveMembers(config, [], ['ops.bot'], [], []); + const members = resolver.resolveMembers(config, [], ['ops.bot'], []); const names = members.map((m) => m.name); expect(names).toContain('Ops.Bot'); @@ -222,7 +211,7 @@ describe('TeamMemberResolver', () => { const tasks: TeamTaskWithKanban[] = [ { id: 't1', subject: 'Work', status: 'in_progress', owner: 'bob' }, ]; - const members = resolver.resolveMembers(config, [], [], tasks, []); + const members = resolver.resolveMembers(config, [], [], tasks); const bob = members.find((m) => m.name === 'bob'); expect(bob?.currentTaskId).toBe('t1'); }); @@ -243,7 +232,7 @@ describe('TeamMemberResolver', () => { kanbanColumn: 'approved', }, ]; - const members = resolver.resolveMembers(config, [], [], tasks, []); + const members = resolver.resolveMembers(config, [], [], tasks); const bob = members.find((m) => m.name === 'bob'); expect(bob?.currentTaskId).toBeNull(); }); @@ -264,7 +253,7 @@ describe('TeamMemberResolver', () => { // kanbanColumn not set — stale data scenario }, ]; - const members = resolver.resolveMembers(config, [], [], tasks, []); + const members = resolver.resolveMembers(config, [], [], tasks); const bob = members.find((m) => m.name === 'bob'); expect(bob?.currentTaskId).toBeNull(); }); @@ -281,7 +270,7 @@ describe('TeamMemberResolver', () => { // Teammates sometimes send messages to "lead" instead of "team-lead", // creating a separate inbox file that the resolver picks up. const inboxNames = ['team-lead', 'lead', 'alice']; - const members = resolver.resolveMembers(config, [], inboxNames, [], []); + const members = resolver.resolveMembers(config, [], inboxNames, []); const names = members.map((m) => m.name); expect(names).toContain('team-lead'); @@ -295,7 +284,7 @@ describe('TeamMemberResolver', () => { name: 'Team', members: [{ name: 'lead', agentType: 'team-lead', role: 'lead' }], }; - const members = resolver.resolveMembers(config, [], ['lead'], [], []); + const members = resolver.resolveMembers(config, [], ['lead'], []); const names = members.map((m) => m.name); expect(names).toContain('lead'); @@ -310,7 +299,7 @@ describe('TeamMemberResolver', () => { const tasks: TeamTaskWithKanban[] = [ { id: 't1', subject: 'Work', status: 'completed', owner: 'bob' }, ]; - const members = resolver.resolveMembers(config, [], [], tasks, []); + const members = resolver.resolveMembers(config, [], [], tasks); const bob = members.find((m) => m.name === 'bob'); expect(bob?.currentTaskId).toBeNull(); }); diff --git a/test/renderer/components/team/TeamProvisioningBanner.test.ts b/test/renderer/components/team/TeamProvisioningBanner.test.ts index 24e1ca81..d6ff7de6 100644 --- a/test/renderer/components/team/TeamProvisioningBanner.test.ts +++ b/test/renderer/components/team/TeamProvisioningBanner.test.ts @@ -39,6 +39,16 @@ vi.mock('@renderer/store/slices/teamSlice', () => ({ selectTeamDataForName: (_state: typeof storeState, teamName: string) => storeState.teamDataCacheByName[teamName] ?? (storeState.selectedTeamName === teamName ? storeState.selectedTeamData : null), + selectTeamMemberSnapshotsForName: (_state: typeof storeState, teamName: string) => + ( + storeState.teamDataCacheByName[teamName] ?? + (storeState.selectedTeamName === teamName ? storeState.selectedTeamData : null) + )?.members ?? [], + selectResolvedMembersForTeamName: (_state: typeof storeState, teamName: string) => + ( + storeState.teamDataCacheByName[teamName] ?? + (storeState.selectedTeamName === teamName ? storeState.selectedTeamData : null) + )?.members ?? [], })); vi.mock('zustand/react/shallow', () => ({ diff --git a/test/renderer/components/team/members/MemberHoverCard.test.ts b/test/renderer/components/team/members/MemberHoverCard.test.ts index 280c0b17..23e66673 100644 --- a/test/renderer/components/team/members/MemberHoverCard.test.ts +++ b/test/renderer/components/team/members/MemberHoverCard.test.ts @@ -61,6 +61,16 @@ vi.mock('@renderer/store', () => ({ vi.mock('@renderer/store/slices/teamSlice', () => ({ getCurrentProvisioningProgressForTeam: () => storeState.progress, + selectResolvedMemberForTeamName: (state: typeof storeState, teamName: string, memberName: string) => + (state.selectedTeamName === teamName ? state.selectedTeamData : null)?.members.find( + (candidate) => candidate.name === memberName + ) ?? null, + selectTeamMemberSnapshotsForName: (state: typeof storeState, teamName: string) => + (state.selectedTeamName === teamName ? state.selectedTeamData : null)?.members ?? [], + selectTeamTasksForName: (state: typeof storeState, teamName: string) => + (state.selectedTeamName === teamName ? state.selectedTeamData : null)?.tasks ?? [], + selectTeamIsAliveForName: (state: typeof storeState, teamName: string) => + (state.selectedTeamName === teamName ? state.selectedTeamData : null)?.isAlive, })); vi.mock('@renderer/hooks/useTheme', () => ({ diff --git a/test/renderer/components/team/members/MemberMessagesTab.test.ts b/test/renderer/components/team/members/MemberMessagesTab.test.ts index fad6eedb..871e86e7 100644 --- a/test/renderer/components/team/members/MemberMessagesTab.test.ts +++ b/test/renderer/components/team/members/MemberMessagesTab.test.ts @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MemberMessagesTab } from '@renderer/components/team/members/MemberMessagesTab'; +import { useStore } from '@renderer/store'; import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; @@ -48,11 +49,27 @@ describe('MemberMessagesTab', () => { nextCursor: null, hasMore: false, }); + useStore.setState({ + teamMessagesByName: { + 'demo-team': { + canonicalMessages: [], + optimisticMessages: [], + feedRevision: 'rev-empty', + nextCursor: null, + hasMore: false, + lastFetchedAt: null, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + } as never); }); afterEach(() => { document.body.innerHTML = ''; getMessagesPage.mockReset(); + useStore.setState({ teamMessagesByName: {} } as never); }); it('shows both messages and comments by default and filters them separately', async () => { @@ -110,10 +127,25 @@ describe('MemberMessagesTab', () => { document.body.appendChild(host); const root = createRoot(host); + useStore.setState({ + teamMessagesByName: { + 'demo-team': { + canonicalMessages: messages, + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: null, + hasMore: false, + lastFetchedAt: Date.now(), + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + } as never); + await act(async () => { root.render( React.createElement(MemberMessagesTab, { - messages, teamName: 'demo-team', memberName: 'jack', members, @@ -123,6 +155,8 @@ describe('MemberMessagesTab', () => { await Promise.resolve(); }); + expect(getMessagesPage).not.toHaveBeenCalled(); + const getRenderedKinds = () => Array.from(host.querySelectorAll('[data-testid="activity-item"]')).map((node) => node.getAttribute('data-kind') @@ -209,10 +243,35 @@ describe('MemberMessagesTab', () => { document.body.appendChild(host); const root = createRoot(host); + useStore.setState({ + teamMessagesByName: { + 'demo-team': { + canonicalMessages: [ + { + from: 'team-lead', + to: 'alice', + text: 'Message for another member', + summary: 'Message for another member', + timestamp: '2026-04-13T13:34:00.000Z', + read: false, + messageId: 'msg-other-member', + }, + ], + optimisticMessages: [], + feedRevision: 'rev-older', + nextCursor: 'older-cursor', + hasMore: true, + lastFetchedAt: Date.now(), + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + } as never); + await act(async () => { root.render( React.createElement(MemberMessagesTab, { - messages: [], teamName: 'demo-team', memberName: 'jack', members, @@ -222,6 +281,7 @@ describe('MemberMessagesTab', () => { await Promise.resolve(); }); + expect(getMessagesPage).not.toHaveBeenCalled(); expect(host.textContent).toContain('No activity with this member'); expect(host.textContent).not.toContain('Load older messages'); diff --git a/test/renderer/components/team/messages/MessagesPanel.test.ts b/test/renderer/components/team/messages/MessagesPanel.test.ts index 5de525d6..cd276c9f 100644 --- a/test/renderer/components/team/messages/MessagesPanel.test.ts +++ b/test/renderer/components/team/messages/MessagesPanel.test.ts @@ -12,6 +12,21 @@ const storeState = { lastSendMessageResult: null, teams: [], openTeamTab: vi.fn(), + loadOlderTeamMessages: vi.fn().mockResolvedValue(undefined), + teamMessagesByName: {} as Record< + string, + { + canonicalMessages: InboxMessage[]; + optimisticMessages: InboxMessage[]; + feedRevision: string | null; + nextCursor: string | null; + hasMore: boolean; + lastFetchedAt: number | null; + loadingHead: boolean; + loadingOlder: boolean; + headHydrated: boolean; + } + >, }; const readHookState = { @@ -146,6 +161,8 @@ describe('MessagesPanel idle summary invariants', () => { storeState.sendTeamMessage.mockClear(); storeState.sendCrossTeamMessage.mockClear(); storeState.openTeamTab.mockClear(); + storeState.loadOlderTeamMessages.mockClear(); + storeState.teamMessagesByName = {}; }); it('keeps read passive peer summaries in the activity timeline while unread badge only counts filtered unread messages', async () => { @@ -175,6 +192,17 @@ describe('MessagesPanel idle summary invariants', () => { ]; await act(async () => { + storeState.teamMessagesByName['atlas-hq'] = { + canonicalMessages: messages, + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: null, + hasMore: false, + lastFetchedAt: Date.now(), + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }; root.render( React.createElement(MessagesPanel, { teamName: 'atlas-hq', @@ -182,7 +210,6 @@ describe('MessagesPanel idle summary invariants', () => { onPositionChange: vi.fn(), members: [], tasks: [], - messages, timeWindow: null, teamSessionIds: new Set(), pendingRepliesByMember: {}, @@ -226,6 +253,17 @@ describe('MessagesPanel idle summary invariants', () => { ]; await act(async () => { + storeState.teamMessagesByName['atlas-hq'] = { + canonicalMessages: messages, + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: null, + hasMore: false, + lastFetchedAt: Date.now(), + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }; root.render( React.createElement(MessagesPanel, { teamName: 'atlas-hq', @@ -233,7 +271,6 @@ describe('MessagesPanel idle summary invariants', () => { onPositionChange: vi.fn(), members: [], tasks: [], - messages, timeWindow: null, teamSessionIds: new Set(), pendingRepliesByMember: { alice: pendingSentAtMs }, @@ -260,6 +297,17 @@ describe('MessagesPanel idle summary invariants', () => { const root = createRoot(host); await act(async () => { + storeState.teamMessagesByName['atlas-hq'] = { + canonicalMessages: [makeMessage()], + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: null, + hasMore: false, + lastFetchedAt: Date.now(), + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }; root.render( React.createElement(MessagesPanel, { teamName: 'atlas-hq', @@ -268,7 +316,6 @@ describe('MessagesPanel idle summary invariants', () => { onPositionChange: vi.fn(), members: [], tasks: [], - messages: [makeMessage()], timeWindow: null, teamSessionIds: new Set(), pendingRepliesByMember: {}, diff --git a/test/renderer/features/agent-graph/GraphActivityHud.test.ts b/test/renderer/features/agent-graph/GraphActivityHud.test.ts index b534a5e2..a51b7d3e 100644 --- a/test/renderer/features/agent-graph/GraphActivityHud.test.ts +++ b/test/renderer/features/agent-graph/GraphActivityHud.test.ts @@ -16,11 +16,10 @@ const teamState = { { name: 'jack', agentType: 'developer' }, ], tasks: [], - messages: [], }, teamDataCacheByName: new Map< string, - { members: Record[]; tasks: unknown[]; messages: unknown[] } + { members: Record[]; tasks: unknown[] } >([ [ 'demo-team', @@ -30,7 +29,6 @@ const teamState = { { name: 'jack', agentType: 'developer' }, ], tasks: [], - messages: [], }, ], ]), @@ -55,6 +53,12 @@ vi.mock('@renderer/store/slices/teamSlice', () => ({ selectTeamDataForName: (_state: typeof teamState, teamName: string) => teamState.teamDataCacheByName.get(teamName) ?? (teamState.selectedTeamName === teamName ? teamState.selectedTeamData : null), + selectResolvedMembersForTeamName: (_state: typeof teamState, teamName: string) => + ( + teamState.teamDataCacheByName.get(teamName) ?? + (teamState.selectedTeamName === teamName ? teamState.selectedTeamData : null) + )?.members ?? [], + selectTeamMessages: () => [], })); vi.mock('zustand/react/shallow', () => ({ diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 4c2ba66f..70b33eeb 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -1,16 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { TeamGraphAdapter } from '@features/agent-graph/renderer/adapters/TeamGraphAdapter'; +import { + TeamGraphAdapter, + type TeamGraphData, +} from '@features/agent-graph/renderer/adapters/TeamGraphAdapter'; -import type { InboxMessage, TeamData, TeamTaskWithKanban } from '@shared/types/team'; +import type { InboxMessage, TeamTaskWithKanban } from '@shared/types/team'; import type { GraphDataPort } from '@claude-teams/agent-graph'; function createBaseTeamData( - overrides?: Partial & { + overrides?: Partial & { tasks?: TeamTaskWithKanban[]; messages?: InboxMessage[]; } -): TeamData { +): TeamGraphData { + const { messages, ...restOverrides } = overrides ?? {}; return { teamName: 'my-team', config: { @@ -46,11 +50,11 @@ function createBaseTeamData( }, ], tasks: [], - messages: [], + messageFeed: messages ?? [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], isAlive: true, - ...overrides, + ...restOverrides, }; } diff --git a/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts b/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts index 6c97a6ff..8ae5e030 100644 --- a/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts +++ b/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts @@ -1,25 +1,20 @@ import { describe, expect, it } from 'vitest'; import { + type ActivityEntrySourceData, buildInlineActivityEntries, getGraphLeadMemberName, } from '@features/agent-graph/core/domain/buildInlineActivityEntries'; -import type { InboxMessage, TeamData, TeamTaskWithKanban } from '@shared/types/team'; +import type { InboxMessage, TeamTaskWithKanban } from '@shared/types/team'; function createBaseTeamData( - overrides?: Partial & { + overrides?: Partial & { tasks?: TeamTaskWithKanban[]; messages?: InboxMessage[]; } -): TeamData { +): ActivityEntrySourceData { return { - teamName: 'my-team', - config: { - name: 'My Team', - members: [{ name: 'team-lead' }, { name: 'alice' }, { name: 'bob' }], - projectPath: '/repo', - }, members: [ { name: 'team-lead', @@ -49,9 +44,6 @@ function createBaseTeamData( ], tasks: [], messages: [], - kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, - processes: [], - isAlive: true, ...overrides, }; } diff --git a/test/renderer/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts index 9d7fa351..8abe8f90 100644 --- a/test/renderer/store/teamChangeThrottle.test.ts +++ b/test/renderer/store/teamChangeThrottle.test.ts @@ -62,6 +62,7 @@ vi.mock('@renderer/api', () => ({ })); import { initializeNotificationListeners, useStore } from '../../../src/renderer/store'; +import { __resetTeamSliceModuleStateForTests } from '../../../src/renderer/store/slices/teamSlice'; import { api } from '@renderer/api'; describe('team change throttling', () => { @@ -69,17 +70,29 @@ describe('team change throttling', () => { beforeEach(async () => { vi.useFakeTimers(); + __resetTeamSliceModuleStateForTests(); const fetchTeams = vi.fn(async () => undefined); + const fetchMemberSpawnStatuses = vi.fn(async () => undefined); const refreshTeamData = vi.fn(async () => undefined); + const refreshTeamMessagesHead = vi.fn(async () => ({ + feedChanged: true, + headChanged: true, + feedRevision: 'rev-1', + })); + const refreshMemberActivityMeta = vi.fn(async () => undefined); const refreshTeamChangePresence = vi.fn(async () => undefined); useStore.setState({ fetchTeams, + fetchMemberSpawnStatuses, refreshTeamData, + refreshTeamMessagesHead, + refreshMemberActivityMeta, refreshTeamChangePresence, selectedTeamName: null, selectedTeamData: null, teamDataCacheByName: {}, + memberActivityMetaByTeam: {}, paneLayout: { focusedPaneId: 'p1', panes: [ @@ -103,6 +116,7 @@ describe('team change throttling', () => { afterEach(() => { cleanup?.(); cleanup = null; + __resetTeamSliceModuleStateForTests(); vi.mocked(console.warn).mockClear(); vi.useRealTimers(); }); @@ -149,10 +163,12 @@ describe('team change throttling', () => { expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2); }); - it('lead-message refreshes detail only, not team list or tasks', async () => { + it('lead-message refreshes message head only, not team list, tasks, or structural detail', async () => { const state = useStore.getState(); const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams'); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); + const refreshTeamMessagesHeadSpy = vi.spyOn(state, 'refreshTeamMessagesHead'); + const refreshMemberActivityMetaSpy = vi.spyOn(state, 'refreshMemberActivityMeta'); // Emit a lead-message event hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' }); @@ -161,9 +177,11 @@ describe('team change throttling', () => { await vi.advanceTimersByTimeAsync(2100); expect(fetchTeamsSpy).not.toHaveBeenCalled(); - // Should trigger refreshTeamData at 800ms - expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1); - expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); + expect(refreshTeamDataSpy).not.toHaveBeenCalled(); + expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledTimes(1); + expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('my-team'); + expect(refreshMemberActivityMetaSpy).toHaveBeenCalledTimes(1); + expect(refreshMemberActivityMetaSpy).toHaveBeenCalledWith('my-team'); }); it('lead-message refreshes visible graph tabs even when the team is not selected', async () => { @@ -174,7 +192,6 @@ describe('team change throttling', () => { config: { name: 'Other Team', members: [], projectPath: '/repo' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, @@ -192,11 +209,88 @@ describe('team change throttling', () => { } as never); const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData'); + const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead'); hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' }); await vi.advanceTimersByTimeAsync(800); - expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); + expect(refreshTeamDataSpy).not.toHaveBeenCalled(); + expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('my-team'); + }); + + it('lead-message refreshes hidden teams with an active pending-reply wait state', async () => { + useStore.getState().syncTeamPendingReplyRefresh('other-team', true, 60_000); + useStore.setState({ + paneLayout: { + focusedPaneId: 'p1', + panes: [ + { + id: 'p1', + widthFraction: 1, + tabs: [{ id: 't1', type: 'team', teamName: 'my-team', label: 'my-team' }], + activeTabId: 't1', + }, + ], + }, + } as never); + + const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData'); + const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead'); + const refreshMemberActivityMetaSpy = vi.spyOn(useStore.getState(), 'refreshMemberActivityMeta'); + + hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'other-team' }); + + await vi.advanceTimersByTimeAsync(800); + expect(refreshTeamDataSpy).not.toHaveBeenCalled(); + expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('other-team'); + expect(refreshMemberActivityMetaSpy).toHaveBeenCalledWith('other-team'); + }); + + it('lead-message does not refresh hidden inactive teams without pending replies', async () => { + useStore.setState({ + paneLayout: { + focusedPaneId: 'p1', + panes: [ + { + id: 'p1', + widthFraction: 1, + tabs: [{ id: 't1', type: 'team', teamName: 'my-team', label: 'my-team' }], + activeTabId: 't1', + }, + ], + }, + } as never); + + const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead'); + const refreshMemberActivityMetaSpy = vi.spyOn(useStore.getState(), 'refreshMemberActivityMeta'); + + hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'other-team' }); + + await vi.advanceTimersByTimeAsync(800); + expect(refreshTeamMessagesHeadSpy).not.toHaveBeenCalledWith('other-team'); + expect(refreshMemberActivityMetaSpy).not.toHaveBeenCalledWith('other-team'); + }); + + it('member-spawn refreshes spawn statuses without forcing structural refresh', async () => { + const fetchMemberSpawnStatusesSpy = vi.spyOn(useStore.getState(), 'fetchMemberSpawnStatuses'); + const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData'); + + hoisted.onTeamChangeCb?.({}, { type: 'member-spawn', teamName: 'my-team' }); + + await vi.advanceTimersByTimeAsync(500); + expect(fetchMemberSpawnStatusesSpy).toHaveBeenCalledWith('my-team'); + expect(refreshTeamDataSpy).not.toHaveBeenCalled(); + }); + + it('inbox/config/process do not refresh member spawn statuses by default', async () => { + const fetchMemberSpawnStatusesSpy = vi.spyOn(useStore.getState(), 'fetchMemberSpawnStatuses'); + + hoisted.onTeamChangeCb?.({}, { type: 'inbox', teamName: 'my-team' }); + hoisted.onTeamChangeCb?.({}, { type: 'config', teamName: 'my-team' }); + hoisted.onTeamChangeCb?.({}, { type: 'process', teamName: 'my-team' }); + + await vi.advanceTimersByTimeAsync(800); + expect(fetchMemberSpawnStatusesSpy).not.toHaveBeenCalled(); }); it('lead-message does not call fetchAllTasks', async () => { @@ -209,6 +303,17 @@ describe('team change throttling', () => { expect(fetchAllTasksSpy).not.toHaveBeenCalled(); }); + it('fallback polling refreshes hidden teams with an active pending-reply wait state', async () => { + useStore.getState().syncTeamPendingReplyRefresh('other-team', true, 60_000); + const refreshTeamMessagesHeadSpy = vi.spyOn(useStore.getState(), 'refreshTeamMessagesHead'); + const refreshMemberActivityMetaSpy = vi.spyOn(useStore.getState(), 'refreshMemberActivityMeta'); + + await vi.advanceTimersByTimeAsync(10_000); + + expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('other-team'); + expect(refreshMemberActivityMetaSpy).toHaveBeenCalledWith('other-team'); + }); + it('log-source-change refreshes only task change presence', async () => { useStore.setState({ selectedTeamName: 'my-team', @@ -217,7 +322,6 @@ describe('team change throttling', () => { config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -248,7 +352,6 @@ describe('team change throttling', () => { config: { name: 'Other Team', members: [], projectPath: '/repo' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, @@ -258,7 +361,6 @@ describe('team change throttling', () => { config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -318,7 +420,6 @@ describe('team change throttling', () => { }, ], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -354,7 +455,6 @@ describe('team change throttling', () => { config: { name: 'Other Team', members: [], projectPath: '/repo' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, @@ -387,7 +487,6 @@ describe('team change throttling', () => { }, ], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -448,6 +547,7 @@ describe('team change throttling', () => { const state = useStore.getState(); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); + const refreshTeamMessagesHeadSpy = vi.spyOn(state, 'refreshTeamMessagesHead'); // Fire rapid events for my-team (throttled) hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' }); @@ -459,9 +559,10 @@ describe('team change throttling', () => { await vi.advanceTimersByTimeAsync(800); // Both teams should get exactly 1 refresh each - expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2); - expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); - expect(refreshTeamDataSpy).toHaveBeenCalledWith('other-team', { withDedup: true }); + expect(refreshTeamDataSpy).not.toHaveBeenCalled(); + expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledTimes(2); + expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('my-team'); + expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledWith('other-team'); }); it('keeps auto change presence tracking disabled even after selected team data is hydrated', async () => { @@ -477,7 +578,6 @@ describe('team change throttling', () => { config: { name: 'My Team', members: [], projectPath: '/repo' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index d91c919f..5fe48ef9 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -4,12 +4,17 @@ import { create } from 'zustand'; import { __resetTeamSliceModuleStateForTests, createTeamSlice, + selectResolvedMemberForTeamName, getCurrentProvisioningProgressForTeam, + selectMemberMessagesForTeamMember, + selectResolvedMembersForTeamName, } from '../../../src/renderer/store/slices/teamSlice'; const hoisted = vi.hoisted(() => ({ list: vi.fn(), getData: vi.fn(), + getMessagesPage: vi.fn(), + getMemberActivityMeta: vi.fn(), createTeam: vi.fn(), getProvisioningStatus: vi.fn(), getMemberSpawnStatuses: vi.fn(), @@ -29,6 +34,8 @@ vi.mock('@renderer/api', () => ({ teams: { list: hoisted.list, getData: hoisted.getData, + getMessagesPage: hoisted.getMessagesPage, + getMemberActivityMeta: hoisted.getMemberActivityMeta, createTeam: hoisted.createTeam, getProvisioningStatus: hoisted.getProvisioningStatus, getMemberSpawnStatuses: hoisted.getMemberSpawnStatuses, @@ -87,6 +94,28 @@ function createSliceStore() { })); } +function createTeamSnapshot( + overrides: Record = {} +): { + teamName: string; + config: { name: string; members?: unknown[]; projectPath?: string }; + tasks: unknown[]; + members: unknown[]; + kanbanState: { teamName: string; reviewers: unknown[]; tasks: Record }; + processes: unknown[]; + isAlive?: boolean; +} { + return { + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + ...overrides, + }; +} + function createMemberSpawnStatus(overrides: Record = {}) { return { status: 'online', @@ -125,19 +154,33 @@ function createMemberSpawnSnapshot(overrides: Record = {}) { }; } +function createDeferredPromise() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + describe('teamSlice actions', () => { beforeEach(() => { vi.clearAllMocks(); __resetTeamSliceModuleStateForTests(); hoisted.list.mockResolvedValue([]); - hoisted.getData.mockResolvedValue({ - teamName: 'my-team', - config: { name: 'My Team' }, - tasks: [], - members: [], + hoisted.getData.mockResolvedValue(createTeamSnapshot()); + hoisted.getMessagesPage.mockResolvedValue({ messages: [], - kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, - processes: [], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-1', + }); + hoisted.getMemberActivityMeta.mockResolvedValue({ + teamName: 'my-team', + computedAt: '2026-03-12T10:00:00.000Z', + members: {}, + feedRevision: 'rev-1', }); hoisted.sendMessage.mockResolvedValue({ deliveredToInbox: true, messageId: 'm1' }); hoisted.requestReview.mockResolvedValue(undefined); @@ -332,6 +375,701 @@ describe('teamSlice actions', () => { expect(updateTabLabel).toHaveBeenCalledWith('graph-tab', 'Northstar Graph'); }); + it('clears stale selectedTeamData immediately when selecting an uncached team', async () => { + const store = createSliceStore(); + const nextTeamData = createDeferredPromise>(); + + store.setState({ + selectedTeamName: 'alpha-team', + selectedTeamData: createTeamSnapshot({ + teamName: 'alpha-team', + config: { name: 'Alpha Team' }, + }), + }); + + hoisted.getData.mockImplementationOnce(async () => nextTeamData.promise); + + const selectPromise = store.getState().selectTeam('beta-team'); + + expect(store.getState().selectedTeamName).toBe('beta-team'); + expect(store.getState().selectedTeamLoading).toBe(true); + expect(store.getState().selectedTeamData).toBeNull(); + + nextTeamData.resolve( + createTeamSnapshot({ + teamName: 'beta-team', + config: { name: 'Beta Team' }, + }) + ); + await selectPromise; + + expect(store.getState().selectedTeamData?.teamName).toBe('beta-team'); + }); + + it('repoints selectedTeamData to the cached snapshot immediately on team switch', async () => { + const store = createSliceStore(); + const nextTeamData = createDeferredPromise>(); + const cachedBeta = createTeamSnapshot({ + teamName: 'beta-team', + config: { name: 'Beta Team' }, + }); + + store.setState({ + selectedTeamName: 'alpha-team', + selectedTeamData: createTeamSnapshot({ + teamName: 'alpha-team', + config: { name: 'Alpha Team' }, + }), + teamDataCacheByName: { + 'beta-team': cachedBeta, + }, + }); + + hoisted.getData.mockImplementationOnce(async () => nextTeamData.promise); + + const selectPromise = store.getState().selectTeam('beta-team'); + + expect(store.getState().selectedTeamName).toBe('beta-team'); + expect(store.getState().selectedTeamData).toBe(cachedBeta); + + nextTeamData.resolve(cachedBeta); + await selectPromise; + + expect(store.getState().selectedTeamData).toBe(cachedBeta); + }); + + it('distinguishes historical feed changes from visible head changes in refreshTeamMessagesHead', async () => { + const store = createSliceStore(); + const existingMessages = [ + { + from: 'team-lead', + text: 'Stable head', + timestamp: '2026-03-20T08:00:00.000Z', + read: true, + source: 'lead_session', + messageId: 'msg-1', + }, + ]; + + store.setState({ + teamMessagesByName: { + 'my-team': { + canonicalMessages: existingMessages, + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: 'cursor-1', + hasMore: true, + lastFetchedAt: 123, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + }); + + hoisted.getMessagesPage.mockResolvedValueOnce({ + messages: existingMessages.map((message) => ({ ...message })), + nextCursor: 'cursor-1', + hasMore: true, + feedRevision: 'rev-2', + }); + + const result = await store.getState().refreshTeamMessagesHead('my-team'); + const nextEntry = store.getState().teamMessagesByName['my-team']; + + expect(result).toEqual({ + feedChanged: true, + headChanged: false, + feedRevision: 'rev-2', + }); + expect(nextEntry?.canonicalMessages).toBe(existingMessages); + expect(nextEntry?.feedRevision).toBe('rev-2'); + expect(nextEntry?.nextCursor).toBe('cursor-1'); + expect(nextEntry?.hasMore).toBe(true); + }); + + it('keeps loaded older tail when head refresh updates only the visible top slice', async () => { + const store = createSliceStore(); + const existingMessages = [ + { + from: 'team-lead', + text: 'Head 2', + timestamp: '2026-03-20T08:00:03.000Z', + read: true, + source: 'lead_session', + messageId: 'msg-4', + }, + { + from: 'alice', + text: 'Head 1', + timestamp: '2026-03-20T08:00:02.000Z', + read: true, + source: 'inbox', + messageId: 'msg-3', + }, + { + from: 'bob', + text: 'Older 1', + timestamp: '2026-03-20T08:00:01.000Z', + read: true, + source: 'inbox', + messageId: 'msg-2', + }, + { + from: 'carol', + text: 'Older 2', + timestamp: '2026-03-20T08:00:00.000Z', + read: true, + source: 'inbox', + messageId: 'msg-1', + }, + ]; + + store.setState({ + teamMessagesByName: { + 'my-team': { + canonicalMessages: existingMessages, + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: 'cursor-tail', + hasMore: true, + lastFetchedAt: 123, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + }); + + hoisted.getMessagesPage.mockResolvedValueOnce({ + messages: [ + { + from: 'team-lead', + text: 'Fresh head', + timestamp: '2026-03-20T08:00:04.000Z', + read: true, + source: 'lead_session', + messageId: 'msg-5', + }, + existingMessages[0], + existingMessages[1], + ], + nextCursor: 'cursor-head', + hasMore: true, + feedRevision: 'rev-2', + }); + + const result = await store.getState().refreshTeamMessagesHead('my-team'); + const nextEntry = store.getState().teamMessagesByName['my-team']; + + expect(result).toEqual({ + feedChanged: true, + headChanged: true, + feedRevision: 'rev-2', + }); + expect( + nextEntry?.canonicalMessages.map((message: { messageId?: string }) => message.messageId) + ).toEqual([ + 'msg-5', + 'msg-4', + 'msg-3', + 'msg-2', + 'msg-1', + ]); + expect(nextEntry?.nextCursor).toBe('cursor-tail'); + expect(nextEntry?.hasMore).toBe(true); + }); + + it('single-flights concurrent head refreshes and runs one fresh follow-up pass', async () => { + const store = createSliceStore(); + const firstRequest = createDeferredPromise<{ + messages: Array<{ + from: string; + text: string; + timestamp: string; + read: boolean; + source: string; + messageId: string; + }>; + nextCursor: string | null; + hasMore: boolean; + feedRevision: string; + }>(); + + store.setState({ + teamMessagesByName: { + 'my-team': { + canonicalMessages: [], + optimisticMessages: [], + feedRevision: null, + nextCursor: null, + hasMore: false, + lastFetchedAt: 0, + loadingHead: false, + loadingOlder: false, + headHydrated: false, + }, + }, + }); + + hoisted.getMessagesPage + .mockImplementationOnce(() => firstRequest.promise) + .mockResolvedValueOnce({ + messages: [ + { + from: 'team-lead', + text: 'Newest head', + timestamp: '2026-03-20T08:00:01.000Z', + read: true, + source: 'lead_session', + messageId: 'msg-2', + }, + ], + nextCursor: 'cursor-2', + hasMore: true, + feedRevision: 'rev-2', + }); + + const p1 = store.getState().refreshTeamMessagesHead('my-team'); + const p2 = store.getState().refreshTeamMessagesHead('my-team'); + + expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(1); + + firstRequest.resolve({ + messages: [ + { + from: 'team-lead', + text: 'Old head', + timestamp: '2026-03-20T08:00:00.000Z', + read: true, + source: 'lead_session', + messageId: 'msg-1', + }, + ], + nextCursor: 'cursor-1', + hasMore: true, + feedRevision: 'rev-1', + }); + + await p1; + await p2; + await Promise.resolve(); + await Promise.resolve(); + + expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(2); + expect(store.getState().teamMessagesByName['my-team']).toMatchObject({ + feedRevision: 'rev-2', + nextCursor: 'cursor-2', + hasMore: true, + loadingHead: false, + headHydrated: true, + }); + expect(store.getState().teamMessagesByName['my-team']?.canonicalMessages[0]?.messageId).toBe( + 'msg-2' + ); + }); + + it('serializes head refresh behind an in-flight older-page load', async () => { + const store = createSliceStore(); + const olderRequest = createDeferredPromise<{ + messages: Array<{ + from: string; + text: string; + timestamp: string; + read: boolean; + source: string; + messageId: string; + }>; + nextCursor: string | null; + hasMore: boolean; + feedRevision: string; + }>(); + + store.setState({ + teamMessagesByName: { + 'my-team': { + canonicalMessages: [ + { + from: 'team-lead', + text: 'Head 1', + timestamp: '2026-03-20T08:00:02.000Z', + read: true, + source: 'lead_session', + messageId: 'msg-3', + }, + { + from: 'alice', + text: 'Head 0', + timestamp: '2026-03-20T08:00:01.000Z', + read: true, + source: 'inbox', + messageId: 'msg-2', + }, + ], + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: 'cursor-older', + hasMore: true, + lastFetchedAt: 123, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + }); + + hoisted.getMessagesPage + .mockImplementationOnce(() => olderRequest.promise) + .mockResolvedValueOnce({ + messages: [ + { + from: 'team-lead', + text: 'Fresh head', + timestamp: '2026-03-20T08:00:03.000Z', + read: true, + source: 'lead_session', + messageId: 'msg-4', + }, + { + from: 'team-lead', + text: 'Head 1', + timestamp: '2026-03-20T08:00:02.000Z', + read: true, + source: 'lead_session', + messageId: 'msg-3', + }, + ], + nextCursor: 'cursor-head', + hasMore: true, + feedRevision: 'rev-2', + }); + + const olderPromise = store.getState().loadOlderTeamMessages('my-team'); + const headPromise = store.getState().refreshTeamMessagesHead('my-team'); + + expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(1); + expect(hoisted.getMessagesPage.mock.calls[0]).toEqual([ + 'my-team', + { cursor: 'cursor-older', limit: 50 }, + ]); + + olderRequest.resolve({ + messages: [ + { + from: 'bob', + text: 'Older tail', + timestamp: '2026-03-20T08:00:00.000Z', + read: true, + source: 'inbox', + messageId: 'msg-1', + }, + ], + nextCursor: null, + hasMore: false, + feedRevision: 'rev-1', + }); + + await olderPromise; + await headPromise; + + expect(hoisted.getMessagesPage).toHaveBeenCalledTimes(2); + expect(hoisted.getMessagesPage.mock.calls[1]).toEqual(['my-team', { limit: 50 }]); + expect( + store + .getState() + .teamMessagesByName['my-team']?.canonicalMessages.map( + (message: { messageId?: string }) => message.messageId + ) + ).toEqual(['msg-4', 'msg-3', 'msg-2', 'msg-1']); + }); + + it('schedules pending-reply refresh through store-owned timers', async () => { + vi.useFakeTimers(); + try { + const store = createSliceStore(); + const refreshTeamMessagesHeadSpy = vi + .spyOn(store.getState(), 'refreshTeamMessagesHead') + .mockResolvedValue({ + feedChanged: true, + headChanged: true, + feedRevision: 'rev-2', + }); + const refreshMemberActivityMetaSpy = vi + .spyOn(store.getState(), 'refreshMemberActivityMeta') + .mockResolvedValue(undefined); + + store.getState().syncTeamPendingReplyRefresh('my-team', true, 1_000); + + await vi.advanceTimersByTimeAsync(999); + expect(refreshTeamMessagesHeadSpy).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledTimes(1); + expect(refreshMemberActivityMetaSpy).toHaveBeenCalledTimes(1); + + store.getState().syncTeamPendingReplyRefresh('my-team', true, 1_000); + store.getState().syncTeamPendingReplyRefresh('my-team', false); + + await vi.advanceTimersByTimeAsync(1_000); + expect(refreshTeamMessagesHeadSpy).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('single-flights concurrent member activity refreshes and re-fetches after feed revision changes', async () => { + const store = createSliceStore(); + const firstRequest = createDeferredPromise<{ + teamName: string; + computedAt: string; + members: Record; + feedRevision: string; + }>(); + + store.setState({ + teamMessagesByName: { + 'my-team': { + canonicalMessages: [], + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: null, + hasMore: false, + lastFetchedAt: 0, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + memberActivityMetaByTeam: {}, + }); + + hoisted.getMemberActivityMeta + .mockImplementationOnce(() => firstRequest.promise) + .mockResolvedValueOnce({ + teamName: 'my-team', + computedAt: '2026-03-12T10:00:01.000Z', + members: { + alice: { + memberName: 'alice', + lastAuthoredMessageAt: '2026-03-12T10:00:01.000Z', + messageCountExact: 3, + latestAuthoredMessageSignalsTermination: false, + }, + }, + feedRevision: 'rev-2', + }); + + const p1 = store.getState().refreshMemberActivityMeta('my-team'); + + store.setState((state: any) => ({ + teamMessagesByName: { + ...state.teamMessagesByName, + 'my-team': { + ...state.teamMessagesByName['my-team'], + feedRevision: 'rev-2', + }, + }, + })); + + const p2 = store.getState().refreshMemberActivityMeta('my-team'); + + expect(hoisted.getMemberActivityMeta).toHaveBeenCalledTimes(1); + + firstRequest.resolve({ + teamName: 'my-team', + computedAt: '2026-03-12T10:00:00.000Z', + members: { + alice: { + memberName: 'alice', + lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z', + messageCountExact: 2, + latestAuthoredMessageSignalsTermination: false, + }, + }, + feedRevision: 'rev-1', + }); + + await p1; + await p2; + await Promise.resolve(); + await Promise.resolve(); + + expect(hoisted.getMemberActivityMeta).toHaveBeenCalledTimes(2); + expect(store.getState().memberActivityMetaByTeam['my-team']).toMatchObject({ + feedRevision: 'rev-2', + members: { + alice: { + messageCountExact: 3, + }, + }, + }); + }); + + it('reuses member activity facts and resolved member refs when only meta wrapper fields change', async () => { + const store = createSliceStore(); + const initialMetaMembers = { + alice: { + memberName: 'alice', + lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z', + messageCountExact: 2, + latestAuthoredMessageSignalsTermination: false, + }, + }; + + store.setState({ + selectedTeamName: 'my-team', + selectedTeamData: createTeamSnapshot({ + members: [ + { + name: 'alice', + currentTaskId: null, + taskCount: 0, + }, + ], + }), + teamDataCacheByName: { + 'my-team': createTeamSnapshot({ + members: [ + { + name: 'alice', + currentTaskId: null, + taskCount: 0, + }, + ], + }), + }, + teamMessagesByName: { + 'my-team': { + canonicalMessages: [], + optimisticMessages: [], + feedRevision: 'rev-2', + nextCursor: null, + hasMore: false, + lastFetchedAt: 0, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + memberActivityMetaByTeam: { + 'my-team': { + teamName: 'my-team', + computedAt: '2026-03-12T10:00:00.000Z', + members: initialMetaMembers, + feedRevision: 'rev-1', + }, + }, + }); + + const initialResolvedMembers = selectResolvedMembersForTeamName(store.getState(), 'my-team'); + + hoisted.getMemberActivityMeta.mockResolvedValueOnce({ + teamName: 'my-team', + computedAt: '2026-03-12T10:00:05.000Z', + members: { + alice: { + memberName: 'alice', + lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z', + messageCountExact: 2, + latestAuthoredMessageSignalsTermination: false, + }, + }, + feedRevision: 'rev-2', + }); + + await store.getState().refreshMemberActivityMeta('my-team'); + + const nextMeta = store.getState().memberActivityMetaByTeam['my-team']; + const nextResolvedMembers = selectResolvedMembersForTeamName(store.getState(), 'my-team'); + + expect(nextMeta?.feedRevision).toBe('rev-2'); + expect(nextMeta?.members).toBe(initialMetaMembers); + expect(nextResolvedMembers).toBe(initialResolvedMembers); + }); + + it('memoizes team-scoped member messages selectors over the merged message feed', () => { + const store = createSliceStore(); + store.setState({ + teamMessagesByName: { + 'my-team': { + canonicalMessages: [ + { + from: 'team-lead', + to: 'alice', + text: 'Ping Alice', + summary: 'Ping Alice', + timestamp: '2026-03-12T10:00:00.000Z', + read: false, + messageId: 'msg-1', + }, + { + from: 'team-lead', + to: 'bob', + text: 'Ping Bob', + summary: 'Ping Bob', + timestamp: '2026-03-12T10:00:01.000Z', + read: false, + messageId: 'msg-2', + }, + ], + optimisticMessages: [], + feedRevision: 'rev-1', + nextCursor: null, + hasMore: false, + lastFetchedAt: 0, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + }); + + const first = selectMemberMessagesForTeamMember(store.getState(), 'my-team', 'alice'); + const second = selectMemberMessagesForTeamMember(store.getState(), 'my-team', 'alice'); + + expect(first).toBe(second); + expect(first.map((message) => message.messageId)).toEqual(['msg-1']); + + store.setState({ + teamMessagesByName: { + 'my-team': { + canonicalMessages: [ + { + from: 'team-lead', + to: 'alice', + text: 'Ping Alice', + summary: 'Ping Alice', + timestamp: '2026-03-12T10:00:00.000Z', + read: false, + messageId: 'msg-1', + }, + { + from: 'alice', + to: 'team-lead', + text: 'Reply from Alice', + summary: 'Reply from Alice', + timestamp: '2026-03-12T10:00:02.000Z', + read: false, + messageId: 'msg-3', + }, + ], + optimisticMessages: [], + feedRevision: 'rev-2', + nextCursor: null, + hasMore: false, + lastFetchedAt: 1, + loadingHead: false, + loadingOlder: false, + headHydrated: true, + }, + }, + }); + + const third = selectMemberMessagesForTeamMember(store.getState(), 'my-team', 'alice'); + expect(third).not.toBe(first); + expect(third.map((message) => message.messageId)).toEqual(['msg-3', 'msg-1']); + }); + it('removes non-selected team cache entries on permanent delete', async () => { const store = createSliceStore(); store.setState({ @@ -341,7 +1079,6 @@ describe('teamSlice actions', () => { config: { name: 'Other Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, @@ -351,7 +1088,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -360,7 +1096,6 @@ describe('teamSlice actions', () => { config: { name: 'Other Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, @@ -383,7 +1118,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -393,7 +1127,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -417,7 +1150,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -441,7 +1173,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, }, selectedTeamError: null, @@ -466,7 +1197,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [{ from: 'lead', text: 'Hello', timestamp: '2026-01-01T00:00:00Z' }], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, }; store.setState({ @@ -484,6 +1214,162 @@ describe('teamSlice actions', () => { expect(store.getState().selectedTeamData).toEqual(existingData); }); + it('reuses the existing selectedTeamData ref on a semantic no-op refresh', async () => { + const store = createSliceStore(); + const existingData = createTeamSnapshot({ + tasks: [ + { + id: 'task-1', + subject: 'Stable task', + status: 'pending', + createdAt: '2026-03-20T08:00:00.000Z', + updatedAt: '2026-03-20T08:00:00.000Z', + }, + ], + members: [ + { + name: 'alice', + currentTaskId: 'task-1', + taskCount: 1, + }, + ], + }); + + store.setState({ + selectedTeamName: 'my-team', + selectedTeamData: existingData, + teamDataCacheByName: { + 'my-team': existingData, + }, + selectedTeamError: 'stale error', + }); + + hoisted.getData.mockResolvedValue({ + ...existingData, + tasks: existingData.tasks.map((task: any) => ({ ...task })), + members: existingData.members.map((member: any) => ({ ...member })), + kanbanState: { + ...existingData.kanbanState, + reviewers: [...existingData.kanbanState.reviewers], + tasks: { ...existingData.kanbanState.tasks }, + }, + processes: [...existingData.processes], + }); + + await store.getState().refreshTeamData('my-team'); + + expect(store.getState().selectedTeamData).toBe(existingData); + expect(store.getState().teamDataCacheByName['my-team']).toBe(existingData); + expect(store.getState().selectedTeamError).toBeNull(); + }); + + it('memoizes focused resolved member selection against unrelated member activity churn', () => { + const aliceSnapshot = { + name: 'alice', + currentTaskId: null, + taskCount: 0, + role: 'Reviewer', + }; + const bobSnapshot = { + name: 'bob', + currentTaskId: null, + taskCount: 0, + role: 'Builder', + }; + const baseState = { + selectedTeamName: 'my-team', + selectedTeamData: null, + teamDataCacheByName: { + 'my-team': createTeamSnapshot({ + members: [aliceSnapshot, bobSnapshot], + }), + }, + memberActivityMetaByTeam: { + 'my-team': { + teamName: 'my-team', + computedAt: '2026-03-12T10:00:00.000Z', + feedRevision: 'rev-1', + members: { + alice: { + memberName: 'alice', + lastAuthoredMessageAt: '2026-03-12T10:00:00.000Z', + messageCountExact: 3, + latestAuthoredMessageSignalsTermination: false, + }, + bob: { + memberName: 'bob', + lastAuthoredMessageAt: '2026-03-12T10:01:00.000Z', + messageCountExact: 1, + latestAuthoredMessageSignalsTermination: false, + }, + }, + }, + }, + }; + + const firstAlice = selectResolvedMemberForTeamName(baseState as never, 'my-team', 'alice'); + const nextState = { + ...baseState, + memberActivityMetaByTeam: { + 'my-team': { + ...baseState.memberActivityMetaByTeam['my-team'], + computedAt: '2026-03-12T10:02:00.000Z', + feedRevision: 'rev-2', + members: { + ...baseState.memberActivityMetaByTeam['my-team'].members, + bob: { + ...baseState.memberActivityMetaByTeam['my-team'].members.bob, + messageCountExact: 2, + }, + }, + }, + }, + }; + + const secondAlice = selectResolvedMemberForTeamName(nextState as never, 'my-team', 'alice'); + + expect(firstAlice).not.toBeNull(); + expect(secondAlice).toBe(firstAlice); + }); + + it('re-canonicalizes selectedTeamData into the cache on a no-op refresh', async () => { + const store = createSliceStore(); + const existingData = createTeamSnapshot({ + tasks: [ + { + id: 'task-1', + subject: 'Stable task', + status: 'pending', + createdAt: '2026-03-20T08:00:00.000Z', + updatedAt: '2026-03-20T08:00:00.000Z', + }, + ], + }); + + store.setState({ + selectedTeamName: 'my-team', + selectedTeamData: existingData, + teamDataCacheByName: {}, + }); + + hoisted.getData.mockResolvedValue({ + ...existingData, + tasks: existingData.tasks.map((task: any) => ({ ...task })), + members: existingData.members.map((member: any) => ({ ...member })), + kanbanState: { + ...existingData.kanbanState, + reviewers: [...existingData.kanbanState.reviewers], + tasks: { ...existingData.kanbanState.tasks }, + }, + processes: [...existingData.processes], + }); + + await store.getState().refreshTeamData('my-team'); + + expect(store.getState().teamDataCacheByName['my-team']).toBe(existingData); + expect(store.getState().selectedTeamData).toBe(existingData); + }); + it('clears non-selected cache on TEAM_DRAFT refresh failure', async () => { const store = createSliceStore(); store.setState({ @@ -493,7 +1379,6 @@ describe('teamSlice actions', () => { config: { name: 'Other Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, @@ -503,7 +1388,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -527,7 +1411,6 @@ describe('teamSlice actions', () => { config: { name: 'Other Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, @@ -537,7 +1420,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -561,7 +1443,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, }, selectedTeamError: 'Previous failure', @@ -584,7 +1465,6 @@ describe('teamSlice actions', () => { config: { name: 'My Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, }; store.setState({ @@ -664,7 +1544,6 @@ describe('teamSlice actions', () => { }, ], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -714,7 +1593,6 @@ describe('teamSlice actions', () => { }, ], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }); @@ -749,7 +1627,6 @@ describe('teamSlice actions', () => { }, ], members: [], - messages: [], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }, @@ -774,7 +1651,6 @@ describe('teamSlice actions', () => { }, ], members: [], - messages: [{ from: 'team-lead', text: 'Ping', timestamp: '2026-03-01T10:10:00.000Z' }], kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], }); @@ -812,7 +1688,6 @@ describe('teamSlice actions', () => { config: { name: 'Other Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, @@ -858,7 +1733,6 @@ describe('teamSlice actions', () => { config: { name: 'Other Team' }, tasks: [], members: [], - messages: [], kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} }, processes: [], }, diff --git a/test/renderer/utils/teamProvisioningPresentation.test.ts b/test/renderer/utils/teamProvisioningPresentation.test.ts index f24f8896..667c3774 100644 --- a/test/renderer/utils/teamProvisioningPresentation.test.ts +++ b/test/renderer/utils/teamProvisioningPresentation.test.ts @@ -20,12 +20,6 @@ describe('buildTeamProvisioningPresentation', () => { members: [ { name: 'team-lead', - agentType: 'team-lead', - status: 'active', - currentTaskId: null, - taskCount: 0, - lastActiveAt: null, - messageCount: 0, }, ], memberSpawnStatuses: {}, From 943722013306fef86abc0698bb76df6b0ad41a31 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 13:16:28 +0300 Subject: [PATCH 03/21] 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 d5afc4a0..f76bf13b 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, @@ -218,8 +218,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')); }); }); From 47eb461730403b668c21221b41c85c4020984ff0 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 19:45:31 +0300 Subject: [PATCH 04/21] fix(types): resolve post-merge regressions --- src/renderer/utils/teamProvisioningPresentation.ts | 6 ++++++ .../normalizeDashboardRecentProjectsPayload.test.ts | 2 +- .../features/agent-graph/buildInlineActivityEntries.test.ts | 5 ----- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/renderer/utils/teamProvisioningPresentation.ts b/src/renderer/utils/teamProvisioningPresentation.ts index 606249bc..3d45b7d8 100644 --- a/src/renderer/utils/teamProvisioningPresentation.ts +++ b/src/renderer/utils/teamProvisioningPresentation.ts @@ -19,6 +19,12 @@ type MemberSpawnStatusCollection = interface ProvisioningMemberLike { name: string; removedAt?: number; + agentType?: string; + status?: string; + currentTaskId?: string | null; + taskCount?: number; + lastActiveAt?: string | null; + messageCount?: number; } interface FailedSpawnDetail { diff --git a/test/features/recent-projects/contracts/normalizeDashboardRecentProjectsPayload.test.ts b/test/features/recent-projects/contracts/normalizeDashboardRecentProjectsPayload.test.ts index de8345a6..59d41c27 100644 --- a/test/features/recent-projects/contracts/normalizeDashboardRecentProjectsPayload.test.ts +++ b/test/features/recent-projects/contracts/normalizeDashboardRecentProjectsPayload.test.ts @@ -44,7 +44,7 @@ describe('normalizeDashboardRecentProjectsPayload', () => { normalizeDashboardRecentProjectsPayload({ degraded: false, projects: null, - } as unknown as { degraded: boolean; projects: null }) + } as unknown as Parameters[0]) ).toBeNull(); }); }); diff --git a/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts b/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts index c1dcaa4c..ce409e00 100644 --- a/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts +++ b/test/renderer/features/agent-graph/buildInlineActivityEntries.test.ts @@ -194,11 +194,6 @@ describe('buildInlineActivityEntries', () => { 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', From 57ba5b57b5b1d6340aad19f3a2ef875ea62ddff4 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 21:26:15 +0300 Subject: [PATCH 05/21] fix(agent-graph): harden pan and launch stepper visibility --- .../agent-graph/src/canvas/draw-agents.ts | 74 ++++++++++++++----- packages/agent-graph/src/ui/GraphControls.tsx | 19 +++-- packages/agent-graph/src/ui/GraphView.tsx | 62 +++++++++++----- .../renderer/ui/GraphProvisioningHud.tsx | 6 +- .../agent-graph/GraphProvisioningHud.test.ts | 19 +++-- 5 files changed, 127 insertions(+), 53 deletions(-) diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index 9a31800a..ed8db002 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -266,24 +266,49 @@ function drawLaunchStage( ctx.save(); switch (visualState) { case 'waiting': { - const ringR = r + 7 + Math.sin(time * 3.2) * 1.2; - const pulseAlpha = 0.2 + 0.14 * (0.5 + 0.5 * Math.sin(time * 3.2)); + const ringR = r + 8 + Math.sin(time * 3.2) * 1.4; + const pulseAlpha = 0.28 + 0.18 * (0.5 + 0.5 * Math.sin(time * 3.2)); + const dotOrbit = r + 11; ctx.beginPath(); ctx.arc(x, y, ringR, 0, Math.PI * 2); ctx.strokeStyle = hexWithAlpha('#d4d4d8', pulseAlpha); - ctx.lineWidth = 2.2; + ctx.lineWidth = 2.5; + ctx.setLineDash([4, 5]); ctx.stroke(); + ctx.setLineDash([]); + for (let index = 0; index < 3; index += 1) { + const angle = time * 1.2 + (Math.PI * 2 * index) / 3; + ctx.beginPath(); + ctx.arc(x + Math.cos(angle) * dotOrbit, y + Math.sin(angle) * dotOrbit, 1.7, 0, Math.PI * 2); + ctx.fillStyle = hexWithAlpha('#e4e4e7', 0.72); + ctx.fill(); + } break; } case 'spawning': { const ringR = r + 7; - const rotation = time * 2.4; + const rotation = time * 2.7; ctx.beginPath(); ctx.arc(x, y, ringR, rotation, rotation + Math.PI * 1.15); ctx.strokeStyle = hexWithAlpha('#f59e0b', 0.8); - ctx.lineWidth = 2.2; + ctx.lineWidth = 2.8; ctx.lineCap = 'round'; ctx.stroke(); + + ctx.beginPath(); + ctx.arc(x, y, ringR + 4, rotation + Math.PI, rotation + Math.PI + Math.PI * 0.4); + ctx.strokeStyle = hexWithAlpha('#fbbf24', 0.65); + ctx.lineWidth = 1.8; + ctx.lineCap = 'round'; + ctx.stroke(); + + const glow = ctx.createRadialGradient(x, y, r * 0.5, x, y, ringR + 12); + glow.addColorStop(0, hexWithAlpha('#f59e0b', 0.18)); + glow.addColorStop(1, hexWithAlpha('#f59e0b', 0)); + ctx.beginPath(); + ctx.arc(x, y, ringR + 12, 0, Math.PI * 2); + ctx.fillStyle = glow; + ctx.fill(); break; } case 'runtime_pending': { @@ -291,27 +316,37 @@ function drawLaunchStage( ctx.beginPath(); ctx.arc(x, y, ringR, 0, Math.PI * 2); ctx.strokeStyle = hexWithAlpha('#38bdf8', 0.48); - ctx.lineWidth = 1.75; + ctx.lineWidth = 1.9; + ctx.setLineDash([5, 4]); ctx.stroke(); + ctx.setLineDash([]); - const orbit = time * 1.6; - const dotR = 2.2; - const dotX = x + Math.cos(orbit) * ringR; - const dotY = y + Math.sin(orbit) * ringR; - ctx.beginPath(); - ctx.arc(dotX, dotY, dotR, 0, Math.PI * 2); - ctx.fillStyle = hexWithAlpha('#67e8f9', 0.9); - ctx.fill(); + const orbit = time * 1.8; + for (let index = 0; index < 2; index += 1) { + const angle = orbit + Math.PI * index; + const dotX = x + Math.cos(angle) * ringR; + const dotY = y + Math.sin(angle) * ringR; + ctx.beginPath(); + ctx.arc(dotX, dotY, 2.3, 0, Math.PI * 2); + ctx.fillStyle = hexWithAlpha(index === 0 ? '#67e8f9' : '#38bdf8', 0.92); + ctx.fill(); + } break; } case 'settling': { const ringR = r + 6; - const arc = 0.65 + 0.08 * Math.sin(time * 2.2); + const arc = 0.72 + 0.08 * Math.sin(time * 2.2); const rotation = time * 1.25; + ctx.beginPath(); + ctx.arc(x, y, ringR, 0, Math.PI * 2); + ctx.strokeStyle = hexWithAlpha('#22c55e', 0.18); + ctx.lineWidth = 1.4; + ctx.stroke(); + ctx.beginPath(); ctx.arc(x, y, ringR, rotation, rotation + Math.PI * arc); ctx.strokeStyle = hexWithAlpha('#22c55e', 0.62); - ctx.lineWidth = 2; + ctx.lineWidth = 2.2; ctx.lineCap = 'round'; ctx.stroke(); break; @@ -321,9 +356,14 @@ function drawLaunchStage( ctx.beginPath(); ctx.arc(x, y, ringR, Math.PI * 0.2, Math.PI * 1.15); ctx.strokeStyle = hexWithAlpha('#ef4444', 0.72); - ctx.lineWidth = 2.2; + ctx.lineWidth = 2.4; ctx.lineCap = 'round'; ctx.stroke(); + + ctx.beginPath(); + ctx.arc(x + ringR * 0.52, y - ringR * 0.5, 2.2, 0, Math.PI * 2); + ctx.fillStyle = hexWithAlpha('#f87171', 0.92); + ctx.fill(); break; } } diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index a3bc581d..801b4a8b 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -49,6 +49,7 @@ export interface GraphControlsProps { teamColor?: string; isAlive?: boolean; topToolbarContent?: React.ReactNode; + interactionLocked?: boolean; } const TOPBAR_BUTTON_SIZE = 25; @@ -69,6 +70,7 @@ export function GraphControls({ isSidebarVisible = true, teamColor, topToolbarContent, + interactionLocked = false, }: GraphControlsProps): React.JSX.Element { const [isSettingsOpen, setIsSettingsOpen] = useState(false); const settingsRef = useRef(null); @@ -104,6 +106,9 @@ export function GraphControls({ }, [isSettingsOpen]); const nameColor = teamColor ?? '#aaeeff'; + const chromeInteractivityClass = interactionLocked + ? 'pointer-events-none select-none' + : 'pointer-events-auto'; return ( <> @@ -111,7 +116,7 @@ export function GraphControls({
{onToggleSidebar ? (
{topToolbarContent ? ( -
+
{topToolbarContent}
) : null} @@ -175,7 +180,7 @@ export function GraphControls({
-
+
(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); + const [interactionLocked, setInteractionLocked] = useState(false); const [filters, setFilters] = useState({ showTasks: config?.showTasks ?? true, showProcesses: config?.showProcesses ?? true, @@ -136,6 +137,7 @@ export function GraphView({ color?: string | null; } | null>(null); const selectionLockRef = useRef<{ userSelect: string; webkitUserSelect: string } | null>(null); + const activePrimaryInteractionRef = useRef(false); // ─── Hooks ────────────────────────────────────────────────────────────── const simulation = useGraphSimulation(); @@ -280,6 +282,15 @@ export function GraphView({ selectionLockRef.current = null; }, []); + const setInteractionGuards = useCallback( + (active: boolean) => { + activePrimaryInteractionRef.current = active; + setInteractionLocked(active); + setInteractionSelectionDisabled(active); + }, + [setInteractionSelectionDisabled] + ); + const animate = useCallback(() => { if (!runningRef.current) return; @@ -413,6 +424,7 @@ export function GraphView({ dragPreviewRef.current = null; isPanningRef.current = false; edgeMouseDownRef.current = null; + setInteractionGuards(false); }, [interaction, isSurfaceActive, simulation]); const handleWheel = useCallback( @@ -432,11 +444,11 @@ export function GraphView({ if (e.button !== 0) return; // only left click e.preventDefault(); dragPreviewRef.current = null; - setInteractionSelectionDisabled(true); + setInteractionGuards(true); const canvas = canvasHandle.current?.getCanvas(); if (!canvas) { - setInteractionSelectionDisabled(false); + setInteractionGuards(false); return; } const rect = canvas.getBoundingClientRect(); @@ -482,13 +494,14 @@ export function GraphView({ getVisibleNodes, interaction, markUserInteracted, + setInteractionGuards, simulation.stateRef, ] ); const processActivePointerMove = useCallback( - (clientX: number, clientY: number, buttons: number) => { - if ((buttons & 1) === 0) { + (clientX: number, clientY: number) => { + if (!activePrimaryInteractionRef.current) { dragPreviewRef.current = null; return false; } @@ -545,7 +558,7 @@ export function GraphView({ if (isPanningRef.current) { camera.handlePanEnd(); isPanningRef.current = false; - setInteractionSelectionDisabled(false); + setInteractionGuards(false); dragPreviewRef.current = null; setSelectedNodeId(null); setSelectedEdgeId(null); @@ -556,7 +569,7 @@ export function GraphView({ const clickedId = interaction.handleMouseUp(); if (wasDragging && draggedNodeId) { - setInteractionSelectionDisabled(false); + setInteractionGuards(false); const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId); if (draggedNode?.kind === 'member' && draggedNode.x != null && draggedNode.y != null) { const nearest = simulation.resolveNearestOwnerSlot( @@ -585,7 +598,7 @@ export function GraphView({ return; } - setInteractionSelectionDisabled(false); + setInteractionGuards(false); if (clickedId) { setSelectedNodeId(clickedId); setSelectedEdgeId(null); @@ -624,12 +637,12 @@ export function GraphView({ } dragPreviewRef.current = null; }, - [camera, events, interaction, onOwnerSlotDrop, setInteractionSelectionDisabled, simulation] + [camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation] ); const handleMouseMove = useCallback( (e: React.MouseEvent) => { - if (processActivePointerMove(e.clientX, e.clientY, e.buttons)) { + if (processActivePointerMove(e.clientX, e.clientY)) { return; } @@ -678,11 +691,8 @@ export function GraphView({ useEffect(() => { const handleWindowMouseMove = (event: MouseEvent): void => { - if ((event.buttons & 1) === 0) { - setInteractionSelectionDisabled(false); - return; - } if ( + !activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.dragNodeId.current && !interaction.isDragging.current && @@ -691,30 +701,47 @@ export function GraphView({ return; } event.preventDefault(); - processActivePointerMove(event.clientX, event.clientY, event.buttons); + processActivePointerMove(event.clientX, event.clientY); }; const handleWindowMouseUp = (event: MouseEvent): void => { if ( + !activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.dragNodeId.current && !interaction.isDragging.current && !edgeMouseDownRef.current ) { - setInteractionSelectionDisabled(false); + setInteractionGuards(false); return; } completePointerInteraction(event.clientX, event.clientY); }; + const clearInteraction = (): void => { + if (!activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.isDragging.current) { + return; + } + interaction.handleMouseUp(); + camera.handlePanEnd(); + isPanningRef.current = false; + edgeMouseDownRef.current = null; + dragPreviewRef.current = null; + setInteractionGuards(false); + }; + window.addEventListener('mousemove', handleWindowMouseMove); window.addEventListener('mouseup', handleWindowMouseUp); + window.addEventListener('blur', clearInteraction); + window.addEventListener('dragstart', clearInteraction); return () => { window.removeEventListener('mousemove', handleWindowMouseMove); window.removeEventListener('mouseup', handleWindowMouseUp); - setInteractionSelectionDisabled(false); + window.removeEventListener('blur', clearInteraction); + window.removeEventListener('dragstart', clearInteraction); + setInteractionGuards(false); }; - }, [completePointerInteraction, interaction, processActivePointerMove, setInteractionSelectionDisabled]); + }, [camera, completePointerInteraction, interaction, processActivePointerMove, setInteractionGuards]); const handleDoubleClick = useCallback( (e: React.MouseEvent) => { @@ -911,6 +938,7 @@ export function GraphView({ teamColor={data.teamColor} isAlive={data.isAlive} topToolbarContent={renderTopToolbarContent?.()} + interactionLocked={interactionLocked} /> {renderHud ? ( diff --git a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx index 06dc290a..34574126 100644 --- a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx @@ -34,7 +34,7 @@ const HUD_STEPPER_STYLE: CSSProperties = { }; function shouldRenderLaunchHud(presentation: TeamProvisioningPresentation | null): boolean { - return presentation != null; + return presentation != null && (presentation.isActive || presentation.isFailed); } export interface GraphProvisioningHudProps { @@ -80,8 +80,10 @@ export const GraphProvisioningHud = ({ <> - {showArchived ? 'Hide archived' : 'Show archived'} + {effectiveShowArchived ? 'Hide archived' : 'Show archived'}
@@ -627,14 +645,25 @@ export const GlobalTaskList = ({ if (group.tasks.length === 0) return null; const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey); const groupColor = projectColor(group.projectLabel); + const visibleCount = getProjectGroupVisibleCount( + projectVisibleCountByKey[group.projectKey], + group.tasks.length + ); + const visibleTasks = group.tasks.slice(0, visibleCount); + const showMoreVisible = canProjectGroupShowMore(visibleCount, group.tasks.length); + const showLessVisible = canProjectGroupShowLess(visibleCount, group.tasks.length); let lastTeam: string | null = null; return (
{!isGroupCollapsed && - group.tasks.map((task) => { + visibleTasks.map((task) => { const showTeamHeader = task.teamName !== lastTeam; lastTeam = task.teamName; return ( @@ -691,6 +723,44 @@ export const GlobalTaskList = ({
); })} + {!isGroupCollapsed && (showMoreVisible || showLessVisible) && ( +
+ {showMoreVisible && ( + + )} + {showLessVisible && ( + + )} +
+ )}
); })} diff --git a/src/renderer/components/sidebar/projectGroupPagination.ts b/src/renderer/components/sidebar/projectGroupPagination.ts new file mode 100644 index 00000000..58d32732 --- /dev/null +++ b/src/renderer/components/sidebar/projectGroupPagination.ts @@ -0,0 +1,89 @@ +export const PROJECT_GROUP_PAGE_SIZE = 5; + +export interface ProjectGroupVisibilityDescriptor { + projectKey: string; + taskCount: number; +} + +export function getProjectGroupVisibleCount( + visibleCount: number | undefined, + taskCount: number +): number { + if (taskCount <= 0) { + return 0; + } + + const minimumVisibleCount = Math.min(PROJECT_GROUP_PAGE_SIZE, taskCount); + if (visibleCount == null || !Number.isFinite(visibleCount)) { + return minimumVisibleCount; + } + + const normalizedVisibleCount = Math.floor(visibleCount); + return Math.min(taskCount, Math.max(minimumVisibleCount, normalizedVisibleCount)); +} + +export function getNextProjectGroupVisibleCount( + visibleCount: number | undefined, + taskCount: number +): number { + const currentVisibleCount = getProjectGroupVisibleCount(visibleCount, taskCount); + if (currentVisibleCount >= taskCount) { + return currentVisibleCount; + } + return Math.min(taskCount, currentVisibleCount + PROJECT_GROUP_PAGE_SIZE); +} + +export function getPreviousProjectGroupVisibleCount( + visibleCount: number | undefined, + taskCount: number +): number { + const currentVisibleCount = getProjectGroupVisibleCount(visibleCount, taskCount); + const minimumVisibleCount = Math.min(PROJECT_GROUP_PAGE_SIZE, taskCount); + return Math.max(minimumVisibleCount, currentVisibleCount - PROJECT_GROUP_PAGE_SIZE); +} + +export function canProjectGroupShowMore( + visibleCount: number | undefined, + taskCount: number +): boolean { + return getProjectGroupVisibleCount(visibleCount, taskCount) < taskCount; +} + +export function canProjectGroupShowLess( + visibleCount: number | undefined, + taskCount: number +): boolean { + if (taskCount <= PROJECT_GROUP_PAGE_SIZE) { + return false; + } + return getProjectGroupVisibleCount(visibleCount, taskCount) > PROJECT_GROUP_PAGE_SIZE; +} + +export function syncProjectGroupVisibleCountByKey( + previousVisibleCountByKey: Record, + groups: readonly ProjectGroupVisibilityDescriptor[] +): Record { + let changed = false; + const nextVisibleCountByKey: Record = {}; + + for (const group of groups) { + const nextVisibleCount = getProjectGroupVisibleCount( + previousVisibleCountByKey[group.projectKey], + group.taskCount + ); + + if (nextVisibleCount > 0) { + nextVisibleCountByKey[group.projectKey] = nextVisibleCount; + } + + if (previousVisibleCountByKey[group.projectKey] !== nextVisibleCount) { + changed = true; + } + } + + if (Object.keys(previousVisibleCountByKey).length !== Object.keys(nextVisibleCountByKey).length) { + changed = true; + } + + return changed ? nextVisibleCountByKey : previousVisibleCountByKey; +} diff --git a/test/renderer/components/sidebar/GlobalTaskList.test.ts b/test/renderer/components/sidebar/GlobalTaskList.test.ts new file mode 100644 index 00000000..3dcacade --- /dev/null +++ b/test/renderer/components/sidebar/GlobalTaskList.test.ts @@ -0,0 +1,278 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { GlobalTask } from '../../../../src/shared/types'; + +interface StoreState { + globalTasks: GlobalTask[]; + globalTasksLoading: boolean; + globalTasksInitialized: boolean; + fetchAllTasks: ReturnType; + softDeleteTask: ReturnType; + projects: { path: string; name: string; sessions: unknown[]; totalSessions?: number }[]; + viewMode: 'flat' | 'grouped'; + repositoryGroups: { + id: string; + name: string; + totalSessions: number; + worktrees: { path: string }[]; + }[]; + teams: { teamName: string; displayName: string }[]; +} + +const storeState = {} as StoreState; +const toggleCollapsedGroup = vi.fn(); +const taskLocalState = { + isPinned: vi.fn(() => false), + isArchived: vi.fn(() => false), + getRenamedSubject: vi.fn(() => undefined), + togglePin: vi.fn(), + toggleArchive: vi.fn(), + renameTask: vi.fn(), +}; + +vi.mock('../../../../src/renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: T) => selector, +})); + +vi.mock('../../../../src/renderer/components/common/ConfirmDialog', () => ({ + confirm: vi.fn(() => Promise.resolve(true)), +})); + +vi.mock('../../../../src/renderer/hooks/useCollapsedGroups', () => ({ + useCollapsedGroups: () => ({ + isCollapsed: () => false, + toggle: toggleCollapsedGroup, + }), +})); + +vi.mock('../../../../src/renderer/hooks/useTaskLocalState', () => ({ + useTaskLocalState: () => taskLocalState, +})); + +vi.mock('../../../../src/renderer/components/team/activity/AnimatedHeightReveal', () => ({ + AnimatedHeightReveal: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), +})); + +vi.mock('../../../../src/renderer/components/sidebar/TaskContextMenu', () => ({ + TaskContextMenu: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), +})); + +vi.mock('../../../../src/renderer/components/sidebar/SidebarTaskItem', () => ({ + SidebarTaskItem: ({ task }: { task: GlobalTask }) => + React.createElement('div', { 'data-testid': 'sidebar-task-item' }, task.subject), +})); + +vi.mock('../../../../src/renderer/components/sidebar/TaskFiltersPopover', () => ({ + TaskFiltersPopover: () => null, +})); + +vi.mock('../../../../src/renderer/components/ui/popover', () => ({ + Popover: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children), + PopoverTrigger: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + PopoverContent: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), +})); + +vi.mock('../../../../src/renderer/components/ui/tooltip', () => ({ + Tooltip: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children), + TooltipTrigger: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + TooltipContent: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + Archive: Icon, + ArrowUpDown: Icon, + Check: Icon, + ChevronDown: Icon, + ChevronRight: Icon, + Folder: Icon, + ListTodo: Icon, + Pin: Icon, + Search: Icon, + X: Icon, + }; +}); + +import { GlobalTaskList } from '../../../../src/renderer/components/sidebar/GlobalTaskList'; + +function flushMicrotasks(): Promise { + return Promise.resolve(); +} + +function findButton(host: HTMLElement, label: string): HTMLButtonElement | null { + return Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label + ) ?? null; +} + +function visibleSubjects(host: HTMLElement): string[] { + return Array.from(host.querySelectorAll('[data-testid="sidebar-task-item"]')).map( + (node) => node.textContent ?? '' + ); +} + +function makeTask(index: number, overrides: Partial = {}): GlobalTask { + const timestamp = String(60 - index).padStart(2, '0'); + return { + id: `task-${index}`, + displayId: `task${index}`, + teamName: 'alpha-team', + teamDisplayName: 'Alpha Team', + subject: `Task ${index}`, + description: '', + status: 'in_progress', + owner: 'alice', + createdAt: `2026-04-18T10:${timestamp}:00.000Z`, + updatedAt: `2026-04-18T10:${timestamp}:00.000Z`, + reviewState: 'none', + reviewNotes: [], + blockedBy: [], + blocks: [], + comments: [], + attachments: [], + workIntervals: [], + kanbanColumnId: null, + projectPath: '/workspace/hookplex', + ...overrides, + } as GlobalTask; +} + +describe('GlobalTaskList project grouping', () => { + beforeEach(() => { + storeState.globalTasks = []; + storeState.globalTasksLoading = false; + storeState.globalTasksInitialized = true; + storeState.fetchAllTasks = vi.fn(() => Promise.resolve(undefined)); + storeState.softDeleteTask = vi.fn(() => Promise.resolve(undefined)); + storeState.projects = []; + storeState.viewMode = 'flat'; + storeState.repositoryGroups = []; + storeState.teams = [{ teamName: 'alpha-team', displayName: 'Alpha Team' }]; + toggleCollapsedGroup.mockReset(); + taskLocalState.isPinned.mockClear(); + taskLocalState.isArchived.mockClear(); + taskLocalState.getRenamedSubject.mockClear(); + taskLocalState.togglePin.mockClear(); + taskLocalState.toggleArchive.mockClear(); + taskLocalState.renameTask.mockClear(); + localStorage.clear(); + localStorage.setItem('sidebarTasksGrouping', 'project'); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('shows five tasks first, then expands and collapses with Show more and Show less', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.globalTasks = Array.from({ length: 6 }, (_, index) => makeTask(index + 1)); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(GlobalTaskList)); + await flushMicrotasks(); + }); + + expect(visibleSubjects(host)).toEqual(['Task 1', 'Task 2', 'Task 3', 'Task 4', 'Task 5']); + expect(findButton(host, 'Show more')).not.toBeNull(); + expect(findButton(host, 'Show less')).toBeNull(); + + await act(async () => { + findButton(host, 'Show more')?.click(); + await flushMicrotasks(); + }); + + expect(visibleSubjects(host)).toEqual(['Task 1', 'Task 2', 'Task 3', 'Task 4', 'Task 5', 'Task 6']); + expect(findButton(host, 'Show less')).not.toBeNull(); + + await act(async () => { + findButton(host, 'Show less')?.click(); + await flushMicrotasks(); + }); + + expect(visibleSubjects(host)).toEqual(['Task 1', 'Task 2', 'Task 3', 'Task 4', 'Task 5']); + expect(findButton(host, 'Show less')).toBeNull(); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('keeps the hard visible limit when new tasks arrive after expansion', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.globalTasks = Array.from({ length: 10 }, (_, index) => makeTask(index + 1)); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(GlobalTaskList)); + await flushMicrotasks(); + }); + + await act(async () => { + findButton(host, 'Show more')?.click(); + await flushMicrotasks(); + }); + + expect(visibleSubjects(host)).toHaveLength(10); + expect(findButton(host, 'Show less')).not.toBeNull(); + + storeState.globalTasks = [ + makeTask(0, { + id: 'task-new', + displayId: 'task-new', + subject: 'Task 0', + createdAt: '2026-04-18T11:00:00.000Z', + updatedAt: '2026-04-18T11:00:00.000Z', + }), + ...Array.from({ length: 10 }, (_, index) => makeTask(index + 1)), + ]; + + await act(async () => { + root.render(React.createElement(GlobalTaskList)); + await flushMicrotasks(); + }); + + expect(visibleSubjects(host)).toHaveLength(10); + expect(visibleSubjects(host)).toEqual([ + 'Task 0', + 'Task 1', + 'Task 2', + 'Task 3', + 'Task 4', + 'Task 5', + 'Task 6', + 'Task 7', + 'Task 8', + 'Task 9', + ]); + expect(visibleSubjects(host)).not.toContain('Task 10'); + expect(findButton(host, 'Show more')).not.toBeNull(); + expect(findButton(host, 'Show less')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); +}); diff --git a/test/renderer/components/sidebar/projectGroupPagination.test.ts b/test/renderer/components/sidebar/projectGroupPagination.test.ts new file mode 100644 index 00000000..b9651f04 --- /dev/null +++ b/test/renderer/components/sidebar/projectGroupPagination.test.ts @@ -0,0 +1,75 @@ +import { + PROJECT_GROUP_PAGE_SIZE, + canProjectGroupShowLess, + canProjectGroupShowMore, + getNextProjectGroupVisibleCount, + getPreviousProjectGroupVisibleCount, + getProjectGroupVisibleCount, + syncProjectGroupVisibleCountByKey, +} from '../../../../src/renderer/components/sidebar/projectGroupPagination'; +import { describe, expect, it } from 'vitest'; + +describe('projectGroupPagination', () => { + it('defaults to the first page and respects small groups', () => { + expect(getProjectGroupVisibleCount(undefined, 0)).toBe(0); + expect(getProjectGroupVisibleCount(undefined, 3)).toBe(3); + expect(getProjectGroupVisibleCount(undefined, 12)).toBe(PROJECT_GROUP_PAGE_SIZE); + }); + + it('expands in steps of five and clamps to the group size', () => { + let visibleCount = getProjectGroupVisibleCount(undefined, 17); + expect(visibleCount).toBe(5); + + visibleCount = getNextProjectGroupVisibleCount(visibleCount, 17); + expect(visibleCount).toBe(10); + + visibleCount = getNextProjectGroupVisibleCount(visibleCount, 17); + expect(visibleCount).toBe(15); + + visibleCount = getNextProjectGroupVisibleCount(visibleCount, 17); + expect(visibleCount).toBe(17); + + expect(canProjectGroupShowMore(visibleCount, 17)).toBe(false); + }); + + it('collapses in steps of five and never goes below the first page', () => { + expect(getPreviousProjectGroupVisibleCount(15, 17)).toBe(10); + expect(getPreviousProjectGroupVisibleCount(10, 17)).toBe(5); + expect(getPreviousProjectGroupVisibleCount(5, 17)).toBe(5); + + expect(canProjectGroupShowLess(5, 17)).toBe(false); + expect(canProjectGroupShowLess(10, 17)).toBe(true); + }); + + it('clamps existing counts when the group shrinks and removes missing groups', () => { + const previousVisibleCounts = { + active: 15, + compact: 7, + removed: 10, + }; + + expect( + syncProjectGroupVisibleCountByKey(previousVisibleCounts, [ + { projectKey: 'active', taskCount: 9 }, + { projectKey: 'compact', taskCount: 4 }, + ]) + ).toEqual({ + active: 9, + compact: 4, + }); + }); + + it('returns the same object when nothing changes', () => { + const previousVisibleCounts = { + active: 10, + compact: 4, + }; + + const nextVisibleCounts = syncProjectGroupVisibleCountByKey(previousVisibleCounts, [ + { projectKey: 'active', taskCount: 12 }, + { projectKey: 'compact', taskCount: 4 }, + ]); + + expect(nextVisibleCounts).toBe(previousVisibleCounts); + }); +}); From 82566162fc301a8bfdb1713e597dafd1df7e3782 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 18 Apr 2026 13:11:13 +0300 Subject: [PATCH 14/21] fix(team): restore task log fallback and block launch auto-assignment --- .../services/team/TeamProvisioningService.ts | 24 +- .../stream/BoardTaskLogStreamService.ts | 338 +++++++++++++++++- src/main/types/jsonl.ts | 1 + src/main/types/messages.ts | 4 + src/main/utils/jsonl.ts | 6 + .../team/task-log-stream-fallback-real.jsonl | 10 + .../BoardTaskLogStreamIntegration.test.ts | 268 +++++++++++++- .../TeamProvisioningServicePrompts.test.ts | 8 +- test/main/utils/jsonl.test.ts | 43 +++ 9 files changed, 693 insertions(+), 9 deletions(-) create mode 100644 test/fixtures/team/task-log-stream-fallback-real.jsonl diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 7204faa5..2721ddc1 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1906,6 +1906,7 @@ function buildDeterministicLaunchHydrationPrompt( const userPromptBlock = request.prompt?.trim() ? `\nOriginal user instructions to apply after reconnect is stable:\n${request.prompt.trim()}\n` : ''; + const hasOriginalUserPrompt = Boolean(request.prompt?.trim()); const taskBoardSnapshot = buildTaskBoardSnapshot(tasks); const persistentContext = buildPersistentLeadContext({ teamName: request.teamName, @@ -1919,13 +1920,21 @@ Do NOT call TeamCreate. Do NOT use Agent to spawn or restore teammates. Do NOT start implementation in this turn. Use this turn only to refresh context, review the current board snapshot, and confirm you are ready. -If the user instructions imply new substantial work that is not on the board yet, you MAY create or update board tasks for yourself, but do not begin executing them yet.` +${ + hasOriginalUserPrompt + ? 'Do NOT create or update any new task in this turn - wait for the next normal operating turn before translating those instructions into board work.' + : 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.' +}` : `This reconnect/bootstrap step has already been completed deterministically by the runtime. Do NOT call TeamCreate. Do NOT use Agent to spawn or restore teammates. Do NOT repeat the launch summary. -Use this turn only to refresh context, review the current board snapshot, and prepare the next delegation step. -If the user instructions imply new substantial work that is not on the board yet, you MAY create or update team-board tasks and assign owners now, but do NOT start implementation work in this turn. +Use this turn only to refresh context and review the current board snapshot. +${ + hasOriginalUserPrompt + ? 'Do NOT create or assign any new task in this turn - wait for the next normal operating turn before translating those instructions into board work.' + : 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.' +} Treat teammates whose bootstrap is still pending as not-yet-available for blocking assignments.`; return `${startLabel} [Deterministic reconnect | Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"] @@ -1952,6 +1961,7 @@ function buildGeminiPostLaunchHydrationPrompt( const userPromptBlock = run.request.prompt?.trim() ? `\nOriginal user instructions to apply now:\n${run.request.prompt.trim()}\n` : ''; + const hasOriginalUserPrompt = Boolean(run.request.prompt?.trim()); const taskBoardSnapshot = buildTaskBoardSnapshot(tasks); const teammateBootstrapSnapshot = members.length ? `Current teammate launch status:\n${members @@ -1980,8 +1990,12 @@ function buildGeminiPostLaunchHydrationPrompt( members, }); const nextStepInstruction = isSolo - ? 'From this point on, use the full operating rules below for all future turns. If the original user instructions describe substantial work that should be tracked, you MAY now create board tasks for yourself, but do not start implementation in this context-refresh turn.' - : 'From this point on, use the full team operating rules below for all future turns. If the original user instructions describe substantial work that should be tracked, you MAY now translate them into board tasks and prepare delegation, but do not start implementation work in this context-refresh turn. Do NOT assume bootstrap-pending or failed teammates are ready; only treat teammates with confirmed bootstrap as immediately available for blocking assignments.'; + ? hasOriginalUserPrompt + ? 'From this point on, use the full operating rules below for all future turns. Do NOT create or update any new task in this context-refresh turn - wait for the next normal operating turn before translating those instructions into board work.' + : 'From this point on, use the full operating rules below for all future turns. Do NOT create, assign, or delegate any new task in this context-refresh turn. If the board is empty, stay silent and wait for a fresh user instruction.' + : hasOriginalUserPrompt + ? 'From this point on, use the full team operating rules below for all future turns. Do NOT create or assign any new task in this context-refresh turn - wait for the next normal operating turn before translating those instructions into board work. Do NOT assume bootstrap-pending or failed teammates are ready; only treat teammates with confirmed bootstrap as immediately available for blocking assignments.' + : 'From this point on, use the full team operating rules below for all future turns. Do NOT create, assign, or delegate any new task in this context-refresh turn. If the board is empty, stay silent and wait for a fresh user instruction. Do NOT assume bootstrap-pending or failed teammates are ready; only treat teammates with confirmed bootstrap as immediately available for blocking assignments.'; return `Gemini launch phase 2 — operating context for team "${run.teamName}". diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts index 2a47c4fb..15157c8f 100644 --- a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -1,6 +1,9 @@ import { extractToolCalls, extractToolResults } from '@main/utils/toolExtraction'; +import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection'; +import { TeamTaskReader } from '../../TeamTaskReader'; import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource'; +import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator'; import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder'; import { BoardTaskExactLogDetailSelector } from '../exact/BoardTaskExactLogDetailSelector'; import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser'; @@ -16,12 +19,14 @@ import type { BoardTaskLogParticipant, BoardTaskLogSegment, BoardTaskLogStreamResponse, + TeamTask, } from '@shared/types'; interface StreamSlice { id: string; timestamp: string; filePath: string; + sortOrder?: number; participantKey: string; actor: BoardTaskLogActor; actionCategory?: BoardTaskActivityCategory; @@ -37,6 +42,17 @@ interface MergedMessageAccumulator { toolUseResults: ToolUseResultData[]; } +interface TimeWindow { + startMs: number; + endMs: number | null; +} + +const BOARD_MCP_TOOL_PREFIXES = ['mcp__agent-teams__', 'mcp__agent_teams__'] as const; +const INFERRED_WINDOW_GRACE_BEFORE_MS = 30_000; +const INFERRED_WINDOW_GRACE_AFTER_MS = 15_000; +const INFERRED_RECORD_RANGE_BEFORE_MS = 5 * 60_000; +const INFERRED_RECORD_RANGE_AFTER_MS = 60_000; + function emptyResponse(): BoardTaskLogStreamResponse { return { participants: [], @@ -49,6 +65,12 @@ function normalizeMemberName(value: string): string { return value.trim().toLowerCase(); } +function isBoardMcpToolName(toolName: string | undefined): boolean { + if (!toolName) return false; + const normalized = toolName.trim().toLowerCase(); + return BOARD_MCP_TOOL_PREFIXES.some((prefix) => normalized.startsWith(prefix)); +} + function toStreamActor(detail: BoardTaskExactLogDetailCandidate['actor']): BoardTaskLogActor { return { ...(detail.memberName ? { memberName: detail.memberName } : {}), @@ -691,15 +713,319 @@ function buildSegmentId(participantKey: string, slices: StreamSlice[]): string { return `${participantKey}:${first?.id ?? 'start'}:${last?.id ?? 'end'}`; } +function buildToolNameByUseId( + parsedMessagesByFile: Map +): Map { + const toolNameByUseId = new Map(); + + for (const messages of parsedMessagesByFile.values()) { + for (const message of messages) { + for (const toolCall of message.toolCalls) { + toolNameByUseId.set(toolCall.id, toolCall.name); + } + } + } + + return toolNameByUseId; +} + +function buildTaskTimeWindows(task: TeamTask, recordTimestamps: number[]): TimeWindow[] { + const windowsFromIntervals = (Array.isArray(task.workIntervals) ? task.workIntervals : []) + .map((interval) => { + const startedAt = Date.parse(interval.startedAt); + if (!Number.isFinite(startedAt)) { + return null; + } + const completedAt = + typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : Number.NaN; + return { + startMs: startedAt - INFERRED_WINDOW_GRACE_BEFORE_MS, + endMs: Number.isFinite(completedAt) ? completedAt + INFERRED_WINDOW_GRACE_AFTER_MS : null, + }; + }) + .filter((window): window is TimeWindow => window !== null); + + if (windowsFromIntervals.length > 0) { + return windowsFromIntervals; + } + + const createdAtMs = typeof task.createdAt === 'string' ? Date.parse(task.createdAt) : Number.NaN; + const updatedAtMs = typeof task.updatedAt === 'string' ? Date.parse(task.updatedAt) : Number.NaN; + if (Number.isFinite(createdAtMs) || Number.isFinite(updatedAtMs)) { + const startMs = Number.isFinite(createdAtMs) ? createdAtMs : updatedAtMs; + return [ + { + startMs: startMs - INFERRED_WINDOW_GRACE_BEFORE_MS, + endMs: Number.isFinite(updatedAtMs) ? updatedAtMs + INFERRED_WINDOW_GRACE_AFTER_MS : null, + }, + ]; + } + + const finiteRecordTimestamps = recordTimestamps.filter((timestamp) => Number.isFinite(timestamp)); + if (finiteRecordTimestamps.length === 0) { + return []; + } + + return [ + { + startMs: Math.min(...finiteRecordTimestamps) - INFERRED_RECORD_RANGE_BEFORE_MS, + endMs: Math.max(...finiteRecordTimestamps) + INFERRED_RECORD_RANGE_AFTER_MS, + }, + ]; +} + +function isWithinTimeWindows(timestamp: Date, windows: TimeWindow[]): boolean { + const messageTime = timestamp.getTime(); + if (!Number.isFinite(messageTime)) { + return false; + } + if (windows.length === 0) { + return true; + } + + const now = Date.now(); + return windows.some((window) => { + const endMs = window.endMs ?? now; + return messageTime >= window.startMs && messageTime <= endMs; + }); +} + +function collectExplicitMessageIds(records: { source: { messageUuid: string } }[]): Set { + return new Set(records.map((record) => record.source.messageUuid)); +} + +function collectExplicitToolUseIds( + records: { + source: { toolUseId?: string }; + action?: { toolUseId?: string }; + }[] +): Set { + const toolUseIds = new Set(); + + for (const record of records) { + const sourceToolUseId = record.source.toolUseId?.trim(); + if (sourceToolUseId) { + toolUseIds.add(sourceToolUseId); + } + + const actionToolUseId = record.action?.toolUseId?.trim(); + if (actionToolUseId) { + toolUseIds.add(actionToolUseId); + } + } + + return toolUseIds; +} + +function collectAllowedMemberNames( + task: TeamTask, + records: { actor: { memberName?: string } }[] +): Set { + const allowedNames = new Set(); + + if (typeof task.owner === 'string' && task.owner.trim().length > 0) { + allowedNames.add(normalizeMemberName(task.owner)); + } + + for (const record of records) { + if (typeof record.actor.memberName === 'string' && record.actor.memberName.trim().length > 0) { + allowedNames.add(normalizeMemberName(record.actor.memberName)); + } + } + + return allowedNames; +} + +function extractMessageToolUseIds(message: ParsedMessage): Set { + const toolUseIds = new Set(); + + for (const toolCall of message.toolCalls) { + if (typeof toolCall.id === 'string' && toolCall.id.trim().length > 0) { + toolUseIds.add(toolCall.id.trim()); + } + } + + for (const toolResult of message.toolResults) { + if (typeof toolResult.toolUseId === 'string' && toolResult.toolUseId.trim().length > 0) { + toolUseIds.add(toolResult.toolUseId.trim()); + } + } + + if (typeof message.sourceToolUseID === 'string' && message.sourceToolUseID.trim().length > 0) { + toolUseIds.add(message.sourceToolUseID.trim()); + } + + return toolUseIds; +} + +function messageHasNonBoardToolActivity( + message: ParsedMessage, + toolNameByUseId: Map +): boolean { + for (const toolCall of message.toolCalls) { + if (!isBoardMcpToolName(toolCall.name)) { + return true; + } + } + + for (const toolResult of message.toolResults) { + if (!isBoardMcpToolName(toolNameByUseId.get(toolResult.toolUseId))) { + return true; + } + } + + if (message.sourceToolUseID) { + const sourceToolName = toolNameByUseId.get(message.sourceToolUseID); + if (sourceToolName && !isBoardMcpToolName(sourceToolName)) { + return true; + } + } + + return false; +} + +function buildInferredActor(message: ParsedMessage, leadName: string): BoardTaskLogActor | null { + const sessionId = message.sessionId?.trim(); + if (!sessionId) { + return null; + } + + const memberName = + typeof message.agentName === 'string' && message.agentName.trim().length > 0 + ? message.agentName.trim() + : undefined; + + const isLead = + memberName != null && normalizeMemberName(memberName) === normalizeMemberName(leadName); + + return { + ...(memberName ? { memberName } : {}), + role: isLead ? 'lead' : memberName ? 'member' : message.isSidechain ? 'member' : 'unknown', + sessionId, + ...(message.agentId ? { agentId: message.agentId } : {}), + isSidechain: message.isSidechain, + }; +} + +function compareSlices(left: StreamSlice, right: StreamSlice): number { + const leftTs = Date.parse(left.timestamp); + const rightTs = Date.parse(right.timestamp); + if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) { + return leftTs - rightTs; + } + if (left.filePath !== right.filePath) { + return left.filePath.localeCompare(right.filePath); + } + if ((left.sortOrder ?? 0) !== (right.sortOrder ?? 0)) { + return (left.sortOrder ?? 0) - (right.sortOrder ?? 0); + } + return left.id.localeCompare(right.id); +} + export class BoardTaskLogStreamService { constructor( private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), private readonly summarySelector: BoardTaskExactLogSummarySelector = new BoardTaskExactLogSummarySelector(), private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(), private readonly detailSelector: BoardTaskExactLogDetailSelector = new BoardTaskExactLogDetailSelector(), - private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder() + private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder(), + private readonly taskReader: TeamTaskReader = new TeamTaskReader(), + private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator() ) {} + private async buildInferredExecutionSlices( + teamName: string, + taskId: string, + records: Awaited>, + parsedMessagesByFile: Map + ): Promise { + if (records.some((record) => record.linkKind === 'execution')) { + return []; + } + + const [activeTasks, deletedTasks, transcriptContext] = await Promise.all([ + this.taskReader.getTasks(teamName), + this.taskReader.getDeletedTasks(teamName), + this.transcriptSourceLocator.getContext(teamName), + ]); + + const task = [...activeTasks, ...deletedTasks].find((candidate) => candidate.id === taskId); + if (!task) { + return []; + } + + const transcriptFiles = transcriptContext?.transcriptFiles ?? []; + const missingFiles = transcriptFiles.filter((filePath) => !parsedMessagesByFile.has(filePath)); + let mergedParsedMessagesByFile = parsedMessagesByFile; + if (missingFiles.length > 0) { + const additionalParsedMessages = await this.strictParser.parseFiles(missingFiles); + mergedParsedMessagesByFile = new Map([ + ...parsedMessagesByFile.entries(), + ...additionalParsedMessages.entries(), + ]); + } + + const toolNameByUseId = buildToolNameByUseId(mergedParsedMessagesByFile); + const recordTimestamps = records.map((record) => Date.parse(record.timestamp)); + const taskTimeWindows = buildTaskTimeWindows(task, recordTimestamps); + if (taskTimeWindows.length === 0) { + return []; + } + + const explicitMessageIds = collectExplicitMessageIds(records); + const explicitToolUseIds = collectExplicitToolUseIds(records); + const allowedMemberNames = collectAllowedMemberNames(task, records); + const leadName = + transcriptContext?.config.members + ?.find((member) => isLeadMemberCheck(member)) + ?.name?.trim() || 'team-lead'; + + const inferredSlices: StreamSlice[] = []; + for (const [filePath, messages] of mergedParsedMessagesByFile.entries()) { + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + if (explicitMessageIds.has(message.uuid)) { + continue; + } + if (!isWithinTimeWindows(message.timestamp, taskTimeWindows)) { + continue; + } + + const actor = buildInferredActor(message, leadName); + if (!actor || !actor.memberName) { + continue; + } + + if ( + allowedMemberNames.size > 0 && + !allowedMemberNames.has(normalizeMemberName(actor.memberName)) + ) { + continue; + } + + const messageToolUseIds = extractMessageToolUseIds(message); + if ([...messageToolUseIds].some((toolUseId) => explicitToolUseIds.has(toolUseId))) { + continue; + } + if (!messageHasNonBoardToolActivity(message, toolNameByUseId)) { + continue; + } + + inferredSlices.push({ + id: `inferred:${filePath}:${message.uuid}`, + timestamp: message.timestamp.toISOString(), + filePath, + sortOrder: index, + participantKey: buildParticipantKey(actor), + actor, + filteredMessages: [message], + }); + } + } + + return inferredSlices.sort(compareSlices); + } + async getTaskLogStream(teamName: string, taskId: string): Promise { if (!isBoardTaskExactLogsReadEnabled()) { return emptyResponse(); @@ -762,6 +1088,7 @@ export class BoardTaskLogStreamService { id: detail.id, timestamp: detail.timestamp, filePath: detail.source.filePath, + sortOrder: detail.source.sourceOrder, participantKey: buildParticipantKey(actor), actor, actionCategory: candidate.actionCategory, @@ -773,7 +1100,14 @@ export class BoardTaskLogStreamService { return emptyResponse(); } - const deNoisedSlices = filterReadOnlySlices(slices); + const inferredExecutionSlices = await this.buildInferredExecutionSlices( + teamName, + taskId, + records, + parsedMessagesByFile + ); + const combinedSlices = [...slices, ...inferredExecutionSlices].sort(compareSlices); + const deNoisedSlices = filterReadOnlySlices(combinedSlices); const namedParticipantSlices = deNoisedSlices.filter((slice) => hasNamedParticipant(slice.actor) diff --git a/src/main/types/jsonl.ts b/src/main/types/jsonl.ts index 6435a707..dc8fb673 100644 --- a/src/main/types/jsonl.ts +++ b/src/main/types/jsonl.ts @@ -130,6 +130,7 @@ interface ConversationalEntry extends BaseEntry { sessionId: string; version: string; gitBranch: string; + agentName?: string; slug?: string; } diff --git a/src/main/types/messages.ts b/src/main/types/messages.ts index 1b496d9c..12d87a2d 100644 --- a/src/main/types/messages.ts +++ b/src/main/types/messages.ts @@ -80,10 +80,14 @@ export interface ParsedMessage { // Metadata /** Current working directory when message was created */ cwd?: string; + /** Root/session identifier from transcript */ + sessionId?: string; /** Git branch context */ gitBranch?: string; /** Agent ID for subagent messages */ agentId?: string; + /** Human-readable agent/member name from transcript */ + agentName?: string; /** Whether this is a sidechain message */ isSidechain: boolean; /** Whether this is a meta message */ diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index 3a345d5d..17b249e6 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -240,8 +240,10 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { let model: string | undefined; let requestId: string | undefined; let cwd: string | undefined; + let sessionId: string | undefined; let gitBranch: string | undefined; let agentId: string | undefined; + let agentName: string | undefined; let isSidechain = false; let isMeta = false; let userType: string | undefined; @@ -255,10 +257,12 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { if (isConversationalEntry(entry)) { // Common properties from ConversationalEntry base cwd = entry.cwd; + sessionId = entry.sessionId; gitBranch = entry.gitBranch; isSidechain = entry.isSidechain ?? false; userType = entry.userType; parentUuid = entry.parentUuid ?? null; + agentName = entry.agentName; // Type-specific properties if (entry.type === 'user') { @@ -298,8 +302,10 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { model, // Metadata cwd, + sessionId, gitBranch, agentId, + agentName, isSidechain, isMeta, userType, diff --git a/test/fixtures/team/task-log-stream-fallback-real.jsonl b/test/fixtures/team/task-log-stream-fallback-real.jsonl new file mode 100644 index 00000000..63a0bd55 --- /dev/null +++ b/test/fixtures/team/task-log-stream-fallback-real.jsonl @@ -0,0 +1,10 @@ +{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-start-real","timestamp":"2026-04-12T15:36:00.000Z","agentName":"tom","teamName":"beacon-desk-2","requestId":"req-start-real","message":{"id":"msg-a-start-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":5},"content":[{"type":"tool_use","id":"call-task-start-real","name":"mcp__agent-teams__task_start","input":{"teamName":"beacon-desk-2","taskId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"}}]}} +{"parentUuid":"a-start-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-start-real","timestamp":"2026-04-12T15:36:00.120Z","agentName":"tom","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-start-real","sourceToolUseID":"call-task-start-real","toolUseResult":{"toolUseId":"call-task-start-real","content":"{\"id\":\"c414cd52\"}"},"boardTaskLinks":[{"schemaVersion":1,"toolUseId":"call-task-start-real","task":{"ref":"c414cd52-470a-4b51-ae1e-e5250fff95d7","refKind":"canonical","canonicalId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"},"targetRole":"subject","linkKind":"lifecycle","taskArgumentSlot":"taskId","actorContext":{"relation":"idle"}}],"boardTaskToolActions":[{"schemaVersion":1,"toolUseId":"call-task-start-real","canonicalToolName":"task_start"}],"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-task-start-real","content":"ok"}]}} +{"parentUuid":"u-start-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-bash-real","timestamp":"2026-04-12T15:36:14.000Z","agentName":"tom","teamName":"beacon-desk-2","requestId":"req-bash-real","message":{"id":"msg-a-bash-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":12,"output_tokens":7},"content":[{"type":"tool_use","id":"call-bash-real","name":"Bash","input":{"command":"pnpm test --filter signal-ops","description":"Run targeted tests"}}]}} +{"parentUuid":"a-bash-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-bash-real","timestamp":"2026-04-12T15:36:14.250Z","agentName":"tom","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-bash-real","sourceToolUseID":"call-bash-real","toolUseResult":{"toolUseId":"call-bash-real","stdout":"tests ok","stderr":"","exitCode":0,"content":"tests ok"},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-bash-real","content":"tests ok"}]}} +{"parentUuid":"u-bash-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-comment-real","timestamp":"2026-04-12T15:36:30.000Z","agentName":"tom","teamName":"beacon-desk-2","requestId":"req-comment-real","message":{"id":"msg-a-comment-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":11,"output_tokens":6},"content":[{"type":"tool_use","id":"call-comment-real","name":"mcp__agent-teams__task_add_comment","input":{"teamName":"beacon-desk-2","taskId":"c414cd52-470a-4b51-ae1e-e5250fff95d7","text":"Diagnostics complete - passing targeted checks."}}]}} +{"parentUuid":"a-comment-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-comment-real","timestamp":"2026-04-12T15:36:30.150Z","agentName":"tom","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-comment-real","sourceToolUseID":"call-comment-real","toolUseResult":{"toolUseId":"call-comment-real","content":"{\"comment\":{\"id\":\"comment-real-1\",\"text\":\"Diagnostics complete - passing targeted checks.\"}}"},"boardTaskLinks":[{"schemaVersion":1,"toolUseId":"call-comment-real","task":{"ref":"c414cd52-470a-4b51-ae1e-e5250fff95d7","refKind":"canonical","canonicalId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"},"targetRole":"subject","linkKind":"board_action","taskArgumentSlot":"taskId","actorContext":{"relation":"same_task"}}],"boardTaskToolActions":[{"schemaVersion":1,"toolUseId":"call-comment-real","canonicalToolName":"task_add_comment","resultRefs":{"commentId":"comment-real-1"}}],"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-comment-real","content":"{\"comment\":{\"id\":\"comment-real-1\",\"text\":\"Diagnostics complete - passing targeted checks.\"}}"}]}} +{"parentUuid":"u-comment-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-alice-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-bash-alice-real","timestamp":"2026-04-12T15:36:35.000Z","agentName":"alice","teamName":"beacon-desk-2","requestId":"req-bash-alice-real","message":{"id":"msg-a-bash-alice-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":9,"output_tokens":4},"content":[{"type":"tool_use","id":"call-bash-alice-real","name":"Bash","input":{"command":"echo alien","description":"Unrelated command"}}]}} +{"parentUuid":"a-bash-alice-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-alice-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-bash-alice-real","timestamp":"2026-04-12T15:36:35.100Z","agentName":"alice","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-bash-alice-real","sourceToolUseID":"call-bash-alice-real","toolUseResult":{"toolUseId":"call-bash-alice-real","stdout":"alien","stderr":"","exitCode":0,"content":"alien"},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-bash-alice-real","content":"alien"}]}} +{"parentUuid":"u-bash-alice-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-complete-real","timestamp":"2026-04-12T15:36:45.000Z","agentName":"tom","teamName":"beacon-desk-2","requestId":"req-complete-real","message":{"id":"msg-a-complete-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":8,"output_tokens":4},"content":[{"type":"tool_use","id":"call-complete-real","name":"mcp__agent-teams__task_complete","input":{"teamName":"beacon-desk-2","taskId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"}}]}} +{"parentUuid":"a-complete-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-complete-real","timestamp":"2026-04-12T15:36:45.120Z","agentName":"tom","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-complete-real","sourceToolUseID":"call-complete-real","toolUseResult":{"toolUseId":"call-complete-real","content":"{\"id\":\"c414cd52\"}"},"boardTaskLinks":[{"schemaVersion":1,"toolUseId":"call-complete-real","task":{"ref":"c414cd52-470a-4b51-ae1e-e5250fff95d7","refKind":"canonical","canonicalId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"},"targetRole":"subject","linkKind":"lifecycle","taskArgumentSlot":"taskId","actorContext":{"relation":"same_task"}}],"boardTaskToolActions":[{"schemaVersion":1,"toolUseId":"call-complete-real","canonicalToolName":"task_complete"}],"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-complete-real","content":"ok"}]}} diff --git a/test/main/services/team/BoardTaskLogStreamIntegration.test.ts b/test/main/services/team/BoardTaskLogStreamIntegration.test.ts index cc05960e..40355e34 100644 --- a/test/main/services/team/BoardTaskLogStreamIntegration.test.ts +++ b/test/main/services/team/BoardTaskLogStreamIntegration.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import path from 'path'; @@ -13,6 +13,10 @@ import type { TeamTask } from '../../../../src/shared/types'; const TEAM_NAME = 'beacon-desk-2'; const TASK_ID = 'c414cd52-470a-4b51-ae1e-e5250fff95d7'; +const REAL_FIXTURE_PATH = path.resolve( + process.cwd(), + 'test/fixtures/team/task-log-stream-fallback-real.jsonl', +); function createTask(overrides: Partial = {}): TeamTask { return { @@ -377,4 +381,266 @@ describe('BoardTaskLogStreamService integration', () => { expect(response.segments).toHaveLength(1); expect(commentResult).toBeUndefined(); }); + + it('falls back to task time-window worker logs when explicit execution links are missing', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-inferred-')); + tempDirs.push(dir); + const transcriptPath = path.join(dir, 'session.jsonl'); + const task = createTask({ + owner: 'tom', + workIntervals: [ + { + startedAt: '2026-04-12T15:36:00.000Z', + completedAt: '2026-04-12T15:40:00.000Z', + }, + ], + }); + + const lines = [ + createAssistantEntry({ + uuid: 'a-start', + timestamp: '2026-04-12T15:36:00.000Z', + requestId: 'req-start', + content: [ + { + type: 'tool_use', + id: 'call-task-start', + name: 'mcp__agent-teams__task_start', + input: { + teamName: TEAM_NAME, + taskId: TASK_ID, + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-start', + timestamp: '2026-04-12T15:36:00.120Z', + sourceToolAssistantUUID: 'a-start', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-task-start', + content: 'ok', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-task-start', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'lifecycle', + taskArgumentSlot: 'taskId', + actorContext: { + relation: 'idle', + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'call-task-start', + canonicalToolName: 'task_start', + }, + ], + toolUseResult: { + toolUseId: 'call-task-start', + content: '{"id":"c414cd52"}', + }, + }), + createAssistantEntry({ + uuid: 'a-bash', + timestamp: '2026-04-12T15:36:14.000Z', + requestId: 'req-bash', + content: [ + { + type: 'tool_use', + id: 'call-bash', + name: 'Bash', + input: { + command: 'pnpm test', + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-bash', + timestamp: '2026-04-12T15:36:14.300Z', + sourceToolAssistantUUID: 'a-bash', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-bash', + content: 'tests ok', + }, + ], + toolUseResult: { + toolUseId: 'call-bash', + content: 'tests ok', + }, + }), + createAssistantEntry({ + uuid: 'a-complete', + timestamp: '2026-04-12T15:36:30.000Z', + requestId: 'req-complete', + content: [ + { + type: 'tool_use', + id: 'call-complete', + name: 'mcp__agent-teams__task_complete', + input: { + teamName: TEAM_NAME, + taskId: TASK_ID, + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-complete', + timestamp: '2026-04-12T15:36:30.150Z', + sourceToolAssistantUUID: 'a-complete', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-complete', + content: 'ok', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-complete', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'lifecycle', + taskArgumentSlot: 'taskId', + actorContext: { + relation: 'same_task', + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'call-complete', + canonicalToolName: 'task_complete', + }, + ], + toolUseResult: { + toolUseId: 'call-complete', + content: '{"id":"c414cd52"}', + }, + }), + ]; + + await writeFile( + transcriptPath, + `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, + 'utf8', + ); + + const recordSource = { + getTaskRecords: async () => buildRecordsFromTranscript(transcriptPath, task), + }; + const taskReader = { + getTasks: async () => [task], + getDeletedTasks: async () => [] as TeamTask[], + }; + const transcriptSourceLocator = { + getContext: async () => + ({ + transcriptFiles: [transcriptPath], + config: { + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + }) as never, + }; + + const service = new BoardTaskLogStreamService( + recordSource as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + taskReader as never, + transcriptSourceLocator as never, + ); + const response = await service.getTaskLogStream(TEAM_NAME, task.id); + const rawMessages = flattenRawMessages(response); + const toolNames = rawMessages.flatMap((message) => + message.toolCalls.map((toolCall) => toolCall.name), + ); + + expect(response.participants.map((participant) => participant.label)).toEqual(['tom']); + expect(response.defaultFilter).toBe('member:tom'); + expect(toolNames).toContain('Bash'); + expect(toolNames).toContain('mcp__agent-teams__task_complete'); + }); + + it('reads a real-format transcript fixture and surfaces fallback worker logs for the task owner only', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-real-fixture-')); + tempDirs.push(dir); + const transcriptPath = path.join(dir, 'session.jsonl'); + const fixtureText = await readFile(REAL_FIXTURE_PATH, 'utf8'); + await writeFile(transcriptPath, fixtureText, 'utf8'); + + const task = createTask({ + owner: 'tom', + workIntervals: [ + { + startedAt: '2026-04-12T15:36:00.000Z', + completedAt: '2026-04-12T15:40:00.000Z', + }, + ], + }); + + const recordSource = { + getTaskRecords: async () => buildRecordsFromTranscript(transcriptPath, task), + }; + const taskReader = { + getTasks: async () => [task], + getDeletedTasks: async () => [] as TeamTask[], + }; + const transcriptSourceLocator = { + getContext: async () => + ({ + transcriptFiles: [transcriptPath], + config: { + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + }) as never, + }; + + const service = new BoardTaskLogStreamService( + recordSource as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + taskReader as never, + transcriptSourceLocator as never, + ); + const response = await service.getTaskLogStream(TEAM_NAME, task.id); + const rawMessages = flattenRawMessages(response); + const bashCommands = rawMessages.flatMap((message) => + message.toolCalls + .filter((toolCall) => toolCall.name === 'Bash') + .map((toolCall) => String(toolCall.input.command ?? '')), + ); + + expect(response.participants.map((participant) => participant.label)).toEqual(['tom']); + expect(response.defaultFilter).toBe('member:tom'); + expect(bashCommands).toContain('pnpm test --filter signal-ops'); + expect(bashCommands).not.toContain('echo alien'); + expect(rawMessages.some((message) => message.uuid === 'u-bash-alice-real')).toBe(false); + }); }); diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index 7eef52f4..597b31bc 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -215,6 +215,9 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => expect(prompt).toContain('This reconnect/bootstrap step has already been completed deterministically by the runtime.'); expect(prompt).toContain('Do NOT start implementation in this turn.'); expect(prompt).toContain('Use this turn only to refresh context, review the current board snapshot, and confirm you are ready.'); + expect(prompt).toContain( + 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.' + ); expect(prompt).toContain( 'review_request already notifies the reviewer, so do NOT send a second manual SendMessage for the same review request' ); @@ -473,7 +476,10 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => const prompt = extractPromptFromBootstrapFile(); expect(prompt).toContain('This reconnect/bootstrap step has already been completed deterministically by the runtime.'); expect(prompt).toContain('Do NOT use Agent to spawn or restore teammates.'); - expect(prompt).toContain('Use this turn only to refresh context, review the current board snapshot, and prepare the next delegation step.'); + expect(prompt).toContain('Use this turn only to refresh context and review the current board snapshot.'); + expect(prompt).toContain( + 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.' + ); expect(prompt).toContain('DELEGATION-FIRST (behavior rule for ALL future turns):'); expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`); expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`); diff --git a/test/main/utils/jsonl.test.ts b/test/main/utils/jsonl.test.ts index 561de38d..3d458fdf 100644 --- a/test/main/utils/jsonl.test.ts +++ b/test/main/utils/jsonl.test.ts @@ -233,5 +233,48 @@ describe('jsonl', () => { expect(parsed?.uuid).toBe('bom-1'); }); + + it('preserves real transcript metadata needed by task-log fallback selection', () => { + const parsed = parseJsonlLine( + JSON.stringify({ + parentUuid: 'assistant-1', + isSidechain: false, + userType: 'external', + cwd: '/tmp/project', + sessionId: 'session-real-1', + version: '1.0.0', + gitBranch: 'main', + type: 'user', + uuid: 'user-real-1', + timestamp: '2026-04-12T15:36:14.250Z', + agentName: 'tom', + isMeta: true, + sourceToolAssistantUUID: 'assistant-1', + sourceToolUseID: 'call-bash-real', + toolUseResult: { + toolUseId: 'call-bash-real', + stdout: 'tests ok', + exitCode: 0, + }, + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-bash-real', + content: 'tests ok', + }, + ], + }, + }), + ); + + expect(parsed?.sessionId).toBe('session-real-1'); + expect(parsed?.agentName).toBe('tom'); + expect(parsed?.isMeta).toBe(true); + expect(parsed?.sourceToolAssistantUUID).toBe('assistant-1'); + expect(parsed?.sourceToolUseID).toBe('call-bash-real'); + expect(parsed?.toolResults[0]?.toolUseId).toBe('call-bash-real'); + }); }); }); From 9675f9b331a32696a2c66b6dc208512637cf68bc Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 18 Apr 2026 14:11:17 +0300 Subject: [PATCH 15/21] fix(recent-projects): show active projects during provisioning --- .../hooks/useRecentProjectsSection.ts | 78 +++++++++++-------- .../renderer/utils/activeProjectTeams.ts | 48 ++++++++++++ .../renderer/utils/activeProjectTeams.test.ts | 57 ++++++++++++++ 3 files changed, 152 insertions(+), 31 deletions(-) create mode 100644 src/features/recent-projects/renderer/utils/activeProjectTeams.ts create mode 100644 test/features/recent-projects/renderer/utils/activeProjectTeams.test.ts diff --git a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts index baa2f48b..1166dcf6 100644 --- a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts +++ b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts @@ -3,7 +3,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type DashboardRecentProject } from '@features/recent-projects/contracts'; import { api, isElectronMode } from '@renderer/api'; import { useStore } from '@renderer/store'; -import { buildTaskCountsByProject, normalizePath } from '@renderer/utils/pathNormalize'; +import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; +import { buildTaskCountsByProject } from '@renderer/utils/pathNormalize'; import { useShallow } from 'zustand/react/shallow'; import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter'; @@ -11,6 +12,7 @@ import { sortRecentProjectsByDisplayPriority, subscribeRecentProjectOpenHistory, } from '../utils/recentProjectOpenHistory'; +import { buildActiveTeamsByProject } from '../utils/activeProjectTeams'; import { getRecentProjectsClientSnapshot, loadRecentProjectsWithClientCache, @@ -62,16 +64,27 @@ export function useRecentProjectsSection( openProjectPath: (projectPath: string) => Promise; selectProjectFolder: () => Promise; } { - const { globalTasks, globalTasksInitialized, globalTasksLoading, fetchAllTasks, teams } = - useStore( - useShallow((state) => ({ - globalTasks: state.globalTasks, - globalTasksInitialized: state.globalTasksInitialized, - globalTasksLoading: state.globalTasksLoading, - fetchAllTasks: state.fetchAllTasks, - teams: state.teams, - })) - ); + const { + globalTasks, + globalTasksInitialized, + globalTasksLoading, + fetchAllTasks, + teams, + provisioningRuns, + currentProvisioningRunIdByTeam, + provisioningSnapshotByTeam, + } = useStore( + useShallow((state) => ({ + globalTasks: state.globalTasks, + globalTasksInitialized: state.globalTasksInitialized, + globalTasksLoading: state.globalTasksLoading, + fetchAllTasks: state.fetchAllTasks, + teams: state.teams, + provisioningRuns: state.provisioningRuns, + currentProvisioningRunIdByTeam: state.currentProvisioningRunIdByTeam, + provisioningSnapshotByTeam: state.provisioningSnapshotByTeam, + })) + ); const initialSnapshot = useMemo(() => getRecentProjectsClientSnapshot(), []); const { openRecentProject, openProjectPath, selectProjectFolder } = useOpenRecentProject(); const [recentProjects, setRecentProjects] = useState( @@ -92,6 +105,21 @@ export function useRecentProjectsSection( const recentProjectsRef = useRef( initialSnapshot?.payload.projects ?? [] ); + const provisioningState = useMemo( + () => ({ currentProvisioningRunIdByTeam, provisioningRuns }), + [currentProvisioningRunIdByTeam, provisioningRuns] + ); + const provisioningTeamNames = useMemo( + () => + Object.keys(currentProvisioningRunIdByTeam).filter((teamName) => + isTeamProvisioningActive(provisioningState, teamName) + ), + [currentProvisioningRunIdByTeam, provisioningState] + ); + const provisioningTeamNamesKey = useMemo( + () => [...provisioningTeamNames].sort().join('\u0000'), + [provisioningTeamNames] + ); useEffect(() => { recentProjectsRef.current = recentProjects; @@ -173,7 +201,7 @@ export function useRecentProjectsSection( return () => { cancelled = true; }; - }, [teams]); + }, [provisioningTeamNamesKey, teams]); useEffect(() => { if (!searchQuery.trim()) { @@ -189,25 +217,13 @@ export function useRecentProjectsSection( const taskCountsByProject = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]); const activeTeamsByProject = useMemo(() => { - const aliveSet = new Set(aliveTeams); - const teamsByProject = new Map(); - - for (const team of teams) { - if (!team.projectPath || !aliveSet.has(team.teamName)) { - continue; - } - - const key = normalizePath(team.projectPath); - const existing = teamsByProject.get(key); - if (existing) { - existing.push(team); - } else { - teamsByProject.set(key, [team]); - } - } - - return teamsByProject; - }, [aliveTeams, teams]); + return buildActiveTeamsByProject({ + teams, + aliveTeamNames: aliveTeams, + provisioningTeamNames, + provisioningSnapshotByTeam, + }); + }, [aliveTeams, provisioningSnapshotByTeam, provisioningTeamNames, teams]); const decoratedCards = useMemo( () => diff --git a/src/features/recent-projects/renderer/utils/activeProjectTeams.ts b/src/features/recent-projects/renderer/utils/activeProjectTeams.ts new file mode 100644 index 00000000..273d60f8 --- /dev/null +++ b/src/features/recent-projects/renderer/utils/activeProjectTeams.ts @@ -0,0 +1,48 @@ +import { normalizePath } from '@renderer/utils/pathNormalize'; + +import type { TeamSummary } from '@shared/types'; + +interface BuildActiveTeamsByProjectInput { + teams: TeamSummary[]; + aliveTeamNames: readonly string[]; + provisioningTeamNames: readonly string[]; + provisioningSnapshotByTeam: Record; +} + +export function buildActiveTeamsByProject({ + teams, + aliveTeamNames, + provisioningTeamNames, + provisioningSnapshotByTeam, +}: BuildActiveTeamsByProjectInput): Map { + const activeTeamNames = new Set([...aliveTeamNames, ...provisioningTeamNames]); + if (activeTeamNames.size === 0) { + return new Map(); + } + + const existingTeamNames = new Set(teams.map((team) => team.teamName)); + const syntheticProvisioningTeams = provisioningTeamNames + .filter((teamName) => !existingTeamNames.has(teamName)) + .map((teamName) => provisioningSnapshotByTeam[teamName]) + .filter((team): team is TeamSummary => Boolean(team)); + + const teamsByProject = new Map(); + const visibleTeams = + syntheticProvisioningTeams.length > 0 ? [...teams, ...syntheticProvisioningTeams] : teams; + + for (const team of visibleTeams) { + if (!team.projectPath || !activeTeamNames.has(team.teamName)) { + continue; + } + + const key = normalizePath(team.projectPath); + const existing = teamsByProject.get(key); + if (existing) { + existing.push(team); + } else { + teamsByProject.set(key, [team]); + } + } + + return teamsByProject; +} diff --git a/test/features/recent-projects/renderer/utils/activeProjectTeams.test.ts b/test/features/recent-projects/renderer/utils/activeProjectTeams.test.ts new file mode 100644 index 00000000..e2f2b44c --- /dev/null +++ b/test/features/recent-projects/renderer/utils/activeProjectTeams.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { buildActiveTeamsByProject } from '@features/recent-projects/renderer/utils/activeProjectTeams'; + +import type { TeamSummary } from '@shared/types'; + +function makeTeamSummary( + overrides: Partial & Pick +): TeamSummary { + return { + ...overrides, + description: overrides.description ?? '', + memberCount: overrides.memberCount ?? 0, + taskCount: overrides.taskCount ?? 0, + lastActivity: overrides.lastActivity ?? null, + teamName: overrides.teamName, + displayName: overrides.displayName, + }; +} + +describe('buildActiveTeamsByProject', () => { + it('treats provisioning-active existing teams as active before aliveList catches up', () => { + const lintai = makeTeamSummary({ + teamName: 'signal-ops-3', + displayName: 'signal-ops-3', + projectPath: '/Users/test/lintai', + }); + + const teamsByProject = buildActiveTeamsByProject({ + teams: [lintai], + aliveTeamNames: [], + provisioningTeamNames: ['signal-ops-3'], + provisioningSnapshotByTeam: {}, + }); + + expect(teamsByProject.get('/users/test/lintai')).toEqual([lintai]); + }); + + it('includes synthetic provisioning snapshots for teams not yet present in team summaries', () => { + const provisioningSnapshot = makeTeamSummary({ + teamName: 'northstar-team', + displayName: 'Northstar Team', + projectPath: '/Users/test/northstar', + }); + + const teamsByProject = buildActiveTeamsByProject({ + teams: [], + aliveTeamNames: [], + provisioningTeamNames: ['northstar-team'], + provisioningSnapshotByTeam: { + 'northstar-team': provisioningSnapshot, + }, + }); + + expect(teamsByProject.get('/users/test/northstar')).toEqual([provisioningSnapshot]); + }); +}); From fb3d1ceb27bb1b9c22b06097a0017f74b4b4d104 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 18 Apr 2026 18:32:04 +0300 Subject: [PATCH 16/21] fix(agent-graph): stabilize drag and pan interactions --- .../agent-graph/src/hooks/useGraphCamera.ts | 42 +- .../src/hooks/useGraphInteraction.ts | 23 +- .../src/hooks/useGraphSimulation.ts | 38 +- packages/agent-graph/src/ui/GraphView.tsx | 117 +++-- .../features/agent-graph/GraphView.test.ts | 400 ++++++++++++++++++ .../agent-graph/useGraphCamera.test.ts | 39 ++ .../agent-graph/useGraphInteraction.test.ts | 51 +++ .../useGraphSimulationIdentity.test.ts | 51 +++ 8 files changed, 692 insertions(+), 69 deletions(-) create mode 100644 test/renderer/features/agent-graph/GraphView.test.ts create mode 100644 test/renderer/features/agent-graph/useGraphInteraction.test.ts create mode 100644 test/renderer/features/agent-graph/useGraphSimulationIdentity.test.ts diff --git a/packages/agent-graph/src/hooks/useGraphCamera.ts b/packages/agent-graph/src/hooks/useGraphCamera.ts index 0a842b91..fbfa2b38 100644 --- a/packages/agent-graph/src/hooks/useGraphCamera.ts +++ b/packages/agent-graph/src/hooks/useGraphCamera.ts @@ -4,7 +4,7 @@ * All state in refs — no React re-renders. */ -import { useRef, useCallback } from 'react'; +import { useRef, useCallback, useMemo } from 'react'; import type { GraphNode } from '../ports/types'; import { CAMERA, ANIM, NODE, TASK_PILL } from '../constants/canvas-constants'; import type { WorldBounds } from '../layout/launchAnchor'; @@ -170,17 +170,31 @@ export function useGraphCamera(): UseGraphCameraResult { t.zoom = Math.max(CAMERA.minZoom, t.zoom / 1.2); }, []); - return { - transformRef, - screenToWorld, - worldToScreen, - handleWheel, - handlePanStart, - handlePanMove, - handlePanEnd, - zoomToFit, - zoomIn, - zoomOut, - updateInertia, - }; + return useMemo( + () => ({ + transformRef, + screenToWorld, + worldToScreen, + handleWheel, + handlePanStart, + handlePanMove, + handlePanEnd, + zoomToFit, + zoomIn, + zoomOut, + updateInertia, + }), + [ + screenToWorld, + worldToScreen, + handleWheel, + handlePanStart, + handlePanMove, + handlePanEnd, + zoomToFit, + zoomIn, + zoomOut, + updateInertia, + ] + ); } diff --git a/packages/agent-graph/src/hooks/useGraphInteraction.ts b/packages/agent-graph/src/hooks/useGraphInteraction.ts index 33862ef3..7fdbf13e 100644 --- a/packages/agent-graph/src/hooks/useGraphInteraction.ts +++ b/packages/agent-graph/src/hooks/useGraphInteraction.ts @@ -3,7 +3,7 @@ * Delegates hit testing to strategy pattern. */ -import { useRef, useCallback } from 'react'; +import { useRef, useCallback, useMemo } from 'react'; import type { GraphNode } from '../ports/types'; import { ANIM } from '../constants/canvas-constants'; import { findNodeAt } from '../canvas/hit-detection'; @@ -81,13 +81,16 @@ export function useGraphInteraction( return findNodeAt(wx, wy, nodes); }, []); - return { - hoveredNodeId, - dragNodeId, - isDragging, - handleMouseDown, - handleMouseMove, - handleMouseUp, - handleDoubleClick, - }; + return useMemo( + () => ({ + hoveredNodeId, + dragNodeId, + isDragging, + handleMouseDown, + handleMouseMove, + handleMouseUp, + handleDoubleClick, + }), + [handleDoubleClick, handleMouseDown, handleMouseMove, handleMouseUp] + ); } diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index cd4d62ad..db34adde 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { ANIM_SPEED, NODE } from '../constants/canvas-constants'; import { getStateColor } from '../constants/colors'; @@ -239,19 +239,29 @@ export function useGraphSimulation(): UseGraphSimulationResult { }; }, []); - return { - stateRef, - updateData, - tick, - setNodePosition, - clearNodePosition, - clearTransientOwnerPositions, - resolveNearestOwnerSlot, - getLaunchAnchorWorldPosition: (leadNodeId: string) => - launchAnchorPositionsRef.current.get(leadNodeId) ?? null, - getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null, - getExtraWorldBounds: () => extraWorldBoundsRef.current, - }; + return useMemo( + () => ({ + stateRef, + updateData, + tick, + setNodePosition, + clearNodePosition, + clearTransientOwnerPositions, + resolveNearestOwnerSlot, + getLaunchAnchorWorldPosition: (leadNodeId: string) => + launchAnchorPositionsRef.current.get(leadNodeId) ?? null, + getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null, + getExtraWorldBounds: () => extraWorldBoundsRef.current, + }), + [ + updateData, + tick, + setNodePosition, + clearNodePosition, + clearTransientOwnerPositions, + resolveNearestOwnerSlot, + ] + ); } function applySnapshotToNodes( diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index c9f1d744..4f26e365 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -32,7 +32,7 @@ import { findNodeAt, getEdgeMidpoint, } from '../canvas/hit-detection'; -import { ANIM_SPEED } from '../constants/canvas-constants'; +import { ANIM, ANIM_SPEED } from '../constants/canvas-constants'; import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor'; export interface GraphViewProps { @@ -148,13 +148,6 @@ export function GraphView({ // ─── Hooks ────────────────────────────────────────────────────────────── const simulation = useGraphSimulation(); const camera = useGraphCamera(); - - // Stable refs for RAF loop (avoid recreating animate on hook identity change) - const simulationRef = useRef(simulation); - simulationRef.current = simulation; - const cameraRef = useRef(camera); - cameraRef.current = camera; - const interaction = useGraphInteraction( useCallback( (nodeId: string, x: number, y: number) => { @@ -164,6 +157,20 @@ export function GraphView({ ) ); + // Stable refs for RAF loop (avoid recreating animate on hook identity change) + const simulationRef = useRef(simulation); + simulationRef.current = simulation; + const cameraRef = useRef(camera); + cameraRef.current = camera; + const interactionRef = useRef(interaction); + interactionRef.current = interaction; + const processActivePointerMoveRef = useRef<((clientX: number, clientY: number) => boolean) | null>( + null + ); + const completePointerInteractionRef = useRef<((clientX: number, clientY: number) => void) | null>( + null + ); + const getVisibleNodes = useCallback( (nodes: GraphNode[]): GraphNode[] => nodes.filter((node) => { @@ -433,16 +440,16 @@ export function GraphView({ }, []); useLayoutEffect(() => { - if (!isSurfaceActive) { + if (isSurfaceActive) { return; } - interaction.handleMouseUp(); - simulation.clearTransientOwnerPositions(); + interactionRef.current.handleMouseUp(); + simulationRef.current.clearTransientOwnerPositions(); dragPreviewRef.current = null; isPanningRef.current = false; edgeMouseDownRef.current = null; setInteractionGuards(false); - }, [interaction, isSurfaceActive, simulation]); + }, [isSurfaceActive, setInteractionGuards]); const handleWheel = useCallback( (e: WheelEvent) => { @@ -454,7 +461,13 @@ export function GraphView({ // ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─ const isPanningRef = useRef(false); - const edgeMouseDownRef = useRef<{ id: string; x: number; y: number } | null>(null); + const edgeMouseDownRef = useRef<{ + id: string; + worldX: number; + worldY: number; + clientX: number; + clientY: number; + } | null>(null); const handleMouseDown = useCallback( (e: React.MouseEvent) => { @@ -491,7 +504,13 @@ export function GraphView({ if (hitEdge) { markUserInteracted(); isPanningRef.current = false; - edgeMouseDownRef.current = { id: hitEdge, x: world.x, y: world.y }; + edgeMouseDownRef.current = { + id: hitEdge, + worldX: world.x, + worldY: world.y, + clientX: e.clientX, + clientY: e.clientY, + }; hoveredEdgeIdRef.current = hitEdge; } else { // Hit empty space → pan @@ -518,11 +537,6 @@ export function GraphView({ const processActivePointerMove = useCallback( (clientX: number, clientY: number) => { - if (!activePrimaryInteractionRef.current) { - dragPreviewRef.current = null; - return false; - } - if (isPanningRef.current) { if (typeof document !== 'undefined') { document.getSelection()?.removeAllRanges(); @@ -531,6 +545,36 @@ export function GraphView({ return true; } + const edgeMouseDown = edgeMouseDownRef.current; + if ( + edgeMouseDown && + !interaction.dragNodeId.current && + !interaction.isDragging.current + ) { + const dx = clientX - edgeMouseDown.clientX; + const dy = clientY - edgeMouseDown.clientY; + if (dx * dx + dy * dy > ANIM.dragThresholdPx * ANIM.dragThresholdPx) { + if (typeof document !== 'undefined') { + document.getSelection()?.removeAllRanges(); + } + hoveredEdgeIdRef.current = null; + edgeMouseDownRef.current = null; + isPanningRef.current = true; + camera.handlePanStart(edgeMouseDown.clientX, edgeMouseDown.clientY); + camera.handlePanMove(clientX, clientY); + return true; + } + } + + if ( + !activePrimaryInteractionRef.current && + !interaction.dragNodeId.current && + !interaction.isDragging.current + ) { + dragPreviewRef.current = null; + return false; + } + const canvas = canvasHandle.current?.getCanvas(); if (!canvas) { dragPreviewRef.current = null; @@ -627,8 +671,8 @@ export function GraphView({ if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) { const rect = canvas.getBoundingClientRect(); const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top); - const dx = world.x - edgeMouseDownRef.current.x; - const dy = world.y - edgeMouseDownRef.current.y; + const dx = world.x - edgeMouseDownRef.current.worldX; + const dy = world.y - edgeMouseDownRef.current.worldY; if (dx * dx + dy * dy <= 25) { clickedEdgeId = edgeMouseDownRef.current.id; } @@ -656,6 +700,8 @@ export function GraphView({ }, [camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation] ); + processActivePointerMoveRef.current = processActivePointerMove; + completePointerInteractionRef.current = completePointerInteraction; const handleMouseMove = useCallback( (e: React.MouseEvent) => { @@ -711,36 +757,40 @@ export function GraphView({ if ( !activePrimaryInteractionRef.current && !isPanningRef.current && - !interaction.dragNodeId.current && - !interaction.isDragging.current && + !interactionRef.current.dragNodeId.current && + !interactionRef.current.isDragging.current && !edgeMouseDownRef.current ) { return; } event.preventDefault(); - processActivePointerMove(event.clientX, event.clientY); + processActivePointerMoveRef.current?.(event.clientX, event.clientY); }; const handleWindowMouseUp = (event: MouseEvent): void => { if ( !activePrimaryInteractionRef.current && !isPanningRef.current && - !interaction.dragNodeId.current && - !interaction.isDragging.current && + !interactionRef.current.dragNodeId.current && + !interactionRef.current.isDragging.current && !edgeMouseDownRef.current ) { setInteractionGuards(false); return; } - completePointerInteraction(event.clientX, event.clientY); + completePointerInteractionRef.current?.(event.clientX, event.clientY); }; const clearInteraction = (): void => { - if (!activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.isDragging.current) { + if ( + !activePrimaryInteractionRef.current && + !isPanningRef.current && + !interactionRef.current.isDragging.current + ) { return; } - interaction.handleMouseUp(); - camera.handlePanEnd(); + interactionRef.current.handleMouseUp(); + cameraRef.current.handlePanEnd(); isPanningRef.current = false; edgeMouseDownRef.current = null; dragPreviewRef.current = null; @@ -756,9 +806,14 @@ export function GraphView({ window.removeEventListener('mouseup', handleWindowMouseUp); window.removeEventListener('blur', clearInteraction); window.removeEventListener('dragstart', clearInteraction); + }; + }, [setInteractionGuards]); + + useEffect(() => { + return () => { setInteractionGuards(false); }; - }, [camera, completePointerInteraction, interaction, processActivePointerMove, setInteractionGuards]); + }, [setInteractionGuards]); const handleDoubleClick = useCallback( (e: React.MouseEvent) => { diff --git a/test/renderer/features/agent-graph/GraphView.test.ts b/test/renderer/features/agent-graph/GraphView.test.ts new file mode 100644 index 00000000..e2f1908c --- /dev/null +++ b/test/renderer/features/agent-graph/GraphView.test.ts @@ -0,0 +1,400 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { GraphEdge, GraphNode } from '@claude-teams/agent-graph'; + +import { getEdgeMidpoint } from '../../../../packages/agent-graph/src/canvas/hit-detection'; + +const hoisted = vi.hoisted(() => ({ + handlePanStart: vi.fn(), + handlePanMove: vi.fn(), + handlePanEnd: vi.fn(), + zoomToFit: vi.fn(), + zoomIn: vi.fn(), + zoomOut: vi.fn(), + updateInertia: vi.fn(), + interaction: { + hoveredNodeId: { current: null as string | null }, + dragNodeId: { current: null as string | null }, + isDragging: { current: false }, + handleMouseDown: vi.fn(), + handleMouseMove: vi.fn(), + handleMouseUp: vi.fn(() => null), + handleDoubleClick: vi.fn(() => null), + }, + simulationState: { + nodes: [] as GraphNode[], + edges: [] as GraphEdge[], + particles: [], + effects: [], + time: 0, + }, + clearTransientOwnerPositions: vi.fn(), +})); + +vi.mock('../../../../packages/agent-graph/src/hooks/useGraphCamera', () => ({ + useGraphCamera: () => ({ + transformRef: { current: { x: 0, y: 0, zoom: 1 } }, + screenToWorld: (sx: number, sy: number) => ({ x: sx, y: sy }), + worldToScreen: (wx: number, wy: number) => ({ x: wx, y: wy }), + handleWheel: vi.fn(), + handlePanStart: hoisted.handlePanStart, + handlePanMove: hoisted.handlePanMove, + handlePanEnd: hoisted.handlePanEnd, + zoomToFit: hoisted.zoomToFit, + zoomIn: hoisted.zoomIn, + zoomOut: hoisted.zoomOut, + updateInertia: hoisted.updateInertia, + }), +})); + +vi.mock('../../../../packages/agent-graph/src/hooks/useGraphInteraction', () => ({ + useGraphInteraction: () => hoisted.interaction, +})); + +vi.mock('../../../../packages/agent-graph/src/hooks/useGraphSimulation', () => ({ + useGraphSimulation: () => ({ + stateRef: { current: hoisted.simulationState }, + updateData: vi.fn(), + tick: vi.fn(), + getExtraWorldBounds: vi.fn(() => []), + getLaunchAnchorWorldPosition: vi.fn(() => null), + getActivityWorldRect: vi.fn(() => null), + resolveNearestOwnerSlot: vi.fn(() => null), + clearNodePosition: vi.fn(), + clearTransientOwnerPositions: hoisted.clearTransientOwnerPositions, + setNodePosition: vi.fn(), + }), +})); + +vi.mock('../../../../packages/agent-graph/src/ui/GraphControls', () => ({ + GraphControls: () => null, +})); + +vi.mock('../../../../packages/agent-graph/src/ui/GraphOverlay', () => ({ + GraphOverlay: () => null, +})); + +vi.mock('../../../../packages/agent-graph/src/ui/GraphEdgeOverlay', () => ({ + GraphEdgeOverlay: () => null, +})); + +import { GraphView } from '../../../../packages/agent-graph/src/ui/GraphView'; + +describe('GraphView pan interactions', () => { + let container: HTMLDivElement; + let root: Root; + let originalGetBoundingClientRect: typeof HTMLCanvasElement.prototype.getBoundingClientRect; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + hoisted.interaction.hoveredNodeId.current = null; + hoisted.interaction.dragNodeId.current = null; + hoisted.interaction.isDragging.current = false; + hoisted.simulationState.nodes = []; + hoisted.simulationState.edges = []; + vi.stubGlobal( + 'ResizeObserver', + class { + observe(): void {} + disconnect(): void {} + } + ); + vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1)); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + originalGetBoundingClientRect = HTMLCanvasElement.prototype.getBoundingClientRect; + HTMLCanvasElement.prototype.getBoundingClientRect = function getBoundingClientRect(): DOMRect { + return DOMRect.fromRect({ x: 0, y: 0, width: 800, height: 600 }); + }; + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + HTMLCanvasElement.prototype.getBoundingClientRect = originalGetBoundingClientRect; + vi.unstubAllGlobals(); + }); + + it('starts panning when dragging from a hit-tested edge instead of getting stuck on edge selection', async () => { + const source: GraphNode = { + id: 'member:alice', + kind: 'member', + label: 'alice', + state: 'idle', + x: 0, + y: 0, + domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'alice' }, + }; + const target: GraphNode = { + id: 'task:1', + kind: 'task', + label: 'Task 1', + state: 'idle', + x: 160, + y: 90, + domainRef: { kind: 'task', teamName: 'demo-team', taskId: 'task:1' }, + }; + const edge: GraphEdge = { + id: 'edge:blocking', + source: source.id, + target: target.id, + type: 'blocking', + }; + hoisted.simulationState.nodes = [source, target]; + hoisted.simulationState.edges = [edge]; + + const midpoint = getEdgeMidpoint(edge, new Map([ + [source.id, source], + [target.id, target], + ])); + expect(midpoint).not.toBeNull(); + + await act(async () => { + root.render( + React.createElement(GraphView, { + data: { + teamName: 'demo-team', + nodes: [source, target], + edges: [edge], + particles: [], + }, + config: { animationEnabled: false }, + }) + ); + }); + + const canvas = container.querySelector('canvas'); + expect(canvas).not.toBeNull(); + + await act(async () => { + canvas!.dispatchEvent( + new MouseEvent('mousedown', { + bubbles: true, + button: 0, + clientX: midpoint!.x, + clientY: midpoint!.y, + }) + ); + canvas!.dispatchEvent( + new MouseEvent('mousemove', { + bubbles: true, + buttons: 1, + clientX: midpoint!.x + 24, + clientY: midpoint!.y + 4, + }) + ); + }); + + expect(hoisted.handlePanStart).toHaveBeenCalledWith(midpoint!.x, midpoint!.y); + expect(hoisted.handlePanMove).toHaveBeenCalledWith(midpoint!.x + 24, midpoint!.y + 4); + }); + + it('does not clear pan state on the rerender triggered by interaction lock', async () => { + const source: GraphNode = { + id: 'member:alice', + kind: 'member', + label: 'alice', + state: 'idle', + x: 0, + y: 0, + domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'alice' }, + }; + hoisted.simulationState.nodes = [source]; + hoisted.simulationState.edges = []; + + await act(async () => { + root.render( + React.createElement(GraphView, { + data: { + teamName: 'demo-team', + nodes: [source], + edges: [], + particles: [], + }, + config: { animationEnabled: false }, + }) + ); + }); + + const canvas = container.querySelector('canvas'); + expect(canvas).not.toBeNull(); + + await act(async () => { + canvas!.dispatchEvent( + new MouseEvent('mousedown', { + bubbles: true, + button: 0, + clientX: 320, + clientY: 220, + }) + ); + await Promise.resolve(); + }); + + await act(async () => { + canvas!.dispatchEvent( + new MouseEvent('mousemove', { + bubbles: true, + buttons: 1, + clientX: 352, + clientY: 248, + }) + ); + }); + + expect(hoisted.handlePanStart).toHaveBeenCalledWith(320, 220); + expect(hoisted.handlePanMove).toHaveBeenCalledWith(352, 248); + }); + + it('does not force-handleMouseUp when props rerender during an active member drag', async () => { + const source: GraphNode = { + id: 'member:demo-team:alice', + kind: 'member', + label: 'alice', + state: 'idle', + x: 80, + y: 80, + domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'alice' }, + }; + hoisted.simulationState.nodes = [source]; + hoisted.simulationState.edges = []; + hoisted.interaction.handleMouseDown.mockImplementation(() => { + hoisted.interaction.dragNodeId.current = source.id; + }); + hoisted.interaction.handleMouseMove.mockImplementation(() => { + hoisted.interaction.isDragging.current = true; + }); + + const firstEvents = {}; + const secondEvents = {}; + + await act(async () => { + root.render( + React.createElement(GraphView, { + data: { + teamName: 'demo-team', + nodes: [source], + edges: [], + particles: [], + }, + events: firstEvents, + config: { animationEnabled: false }, + }) + ); + }); + + const canvas = container.querySelector('canvas'); + expect(canvas).not.toBeNull(); + + await act(async () => { + canvas!.dispatchEvent( + new MouseEvent('mousedown', { + bubbles: true, + button: 0, + clientX: 80, + clientY: 80, + }) + ); + canvas!.dispatchEvent( + new MouseEvent('mousemove', { + bubbles: true, + buttons: 1, + clientX: 95, + clientY: 95, + }) + ); + }); + + expect(hoisted.interaction.isDragging.current).toBe(true); + + await act(async () => { + root.render( + React.createElement(GraphView, { + data: { + teamName: 'demo-team', + nodes: [source], + edges: [], + particles: [], + }, + events: secondEvents, + config: { animationEnabled: false }, + }) + ); + }); + + expect(hoisted.interaction.handleMouseUp).not.toHaveBeenCalled(); + + await act(async () => { + window.dispatchEvent( + new MouseEvent('mousemove', { + bubbles: true, + buttons: 1, + clientX: 112, + clientY: 112, + }) + ); + }); + + expect(hoisted.interaction.handleMouseMove).toHaveBeenCalled(); + expect(hoisted.interaction.isDragging.current).toBe(true); + }); + + it('clears drag state when the graph surface becomes inactive', async () => { + const source: GraphNode = { + id: 'member:demo-team:alice', + kind: 'member', + label: 'alice', + state: 'idle', + x: 80, + y: 80, + domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'alice' }, + }; + hoisted.simulationState.nodes = [source]; + hoisted.simulationState.edges = []; + hoisted.interaction.dragNodeId.current = source.id; + hoisted.interaction.isDragging.current = true; + + await act(async () => { + root.render( + React.createElement(GraphView, { + data: { + teamName: 'demo-team', + nodes: [source], + edges: [], + particles: [], + }, + config: { animationEnabled: false }, + isSurfaceActive: true, + }) + ); + }); + + expect(hoisted.interaction.handleMouseUp).not.toHaveBeenCalled(); + expect(hoisted.clearTransientOwnerPositions).not.toHaveBeenCalled(); + + await act(async () => { + root.render( + React.createElement(GraphView, { + data: { + teamName: 'demo-team', + nodes: [source], + edges: [], + particles: [], + }, + config: { animationEnabled: false }, + isSurfaceActive: false, + }) + ); + }); + + expect(hoisted.interaction.handleMouseUp).toHaveBeenCalledTimes(1); + expect(hoisted.clearTransientOwnerPositions).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/renderer/features/agent-graph/useGraphCamera.test.ts b/test/renderer/features/agent-graph/useGraphCamera.test.ts index b4b518d5..ca804fc3 100644 --- a/test/renderer/features/agent-graph/useGraphCamera.test.ts +++ b/test/renderer/features/agent-graph/useGraphCamera.test.ts @@ -7,15 +7,29 @@ import { useGraphCamera, type UseGraphCameraResult } from '../../../../packages/ import type { GraphNode } from '@claude-teams/agent-graph'; let capturedCamera: UseGraphCameraResult | null = null; +let firstCamera: UseGraphCameraResult | null = null; +let secondCamera: UseGraphCameraResult | null = null; function CameraHarness(): React.JSX.Element | null { capturedCamera = useGraphCamera(); return null; } +function CameraIdentityHarness({ pass }: { pass: number }): React.JSX.Element | null { + const camera = useGraphCamera(); + if (pass === 1) { + firstCamera = camera; + } else { + secondCamera = camera; + } + return null; +} + describe('useGraphCamera zoomToFit', () => { afterEach(() => { capturedCamera = null; + firstCamera = null; + secondCamera = null; document.body.innerHTML = ''; }); @@ -71,4 +85,29 @@ describe('useGraphCamera zoomToFit', () => { await Promise.resolve(); }); }); + + it('returns a referentially stable result across rerenders', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CameraIdentityHarness, { pass: 1 })); + await Promise.resolve(); + }); + + await act(async () => { + root.render(React.createElement(CameraIdentityHarness, { pass: 2 })); + await Promise.resolve(); + }); + + expect(firstCamera).toBeTruthy(); + expect(secondCamera).toBe(firstCamera); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/features/agent-graph/useGraphInteraction.test.ts b/test/renderer/features/agent-graph/useGraphInteraction.test.ts new file mode 100644 index 00000000..767ece9d --- /dev/null +++ b/test/renderer/features/agent-graph/useGraphInteraction.test.ts @@ -0,0 +1,51 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useGraphInteraction, type UseGraphInteractionResult } from '../../../../packages/agent-graph/src/hooks/useGraphInteraction'; + +let firstInteraction: UseGraphInteractionResult | null = null; +let secondInteraction: UseGraphInteractionResult | null = null; + +function InteractionHarness({ pass }: { pass: number }): React.JSX.Element | null { + const interaction = useGraphInteraction(); + if (pass === 1) { + firstInteraction = interaction; + } else { + secondInteraction = interaction; + } + return null; +} + +describe('useGraphInteraction', () => { + afterEach(() => { + firstInteraction = null; + secondInteraction = null; + document.body.innerHTML = ''; + }); + + it('returns a referentially stable result across rerenders', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(InteractionHarness, { pass: 1 })); + await Promise.resolve(); + }); + + await act(async () => { + root.render(React.createElement(InteractionHarness, { pass: 2 })); + await Promise.resolve(); + }); + + expect(firstInteraction).toBeTruthy(); + expect(secondInteraction).toBe(firstInteraction); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/features/agent-graph/useGraphSimulationIdentity.test.ts b/test/renderer/features/agent-graph/useGraphSimulationIdentity.test.ts new file mode 100644 index 00000000..4bbf58cc --- /dev/null +++ b/test/renderer/features/agent-graph/useGraphSimulationIdentity.test.ts @@ -0,0 +1,51 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useGraphSimulation, type UseGraphSimulationResult } from '../../../../packages/agent-graph/src/hooks/useGraphSimulation'; + +let firstSimulation: UseGraphSimulationResult | null = null; +let secondSimulation: UseGraphSimulationResult | null = null; + +function SimulationHarness({ pass }: { pass: number }): React.JSX.Element | null { + const simulation = useGraphSimulation(); + if (pass === 1) { + firstSimulation = simulation; + } else { + secondSimulation = simulation; + } + return null; +} + +describe('useGraphSimulation', () => { + afterEach(() => { + firstSimulation = null; + secondSimulation = null; + document.body.innerHTML = ''; + }); + + it('returns a referentially stable result across rerenders', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(SimulationHarness, { pass: 1 })); + await Promise.resolve(); + }); + + await act(async () => { + root.render(React.createElement(SimulationHarness, { pass: 2 })); + await Promise.resolve(); + }); + + expect(firstSimulation).toBeTruthy(); + expect(secondSimulation).toBe(firstSimulation); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); From 452948b260db4b3a73dd0cf49e33ff5dd2a47caa Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 18 Apr 2026 18:32:21 +0300 Subject: [PATCH 17/21] fix(team): refine bootstrap and provider diagnostics --- .../services/team/TeamProvisioningService.ts | 30 ++++++---- src/main/services/team/cliFlavor.ts | 14 +++++ .../ProvisioningProviderStatusList.tsx | 2 +- .../dialogs/providerPrepareDiagnostics.ts | 59 +++++++++++++++++-- .../TeamProvisioningServicePrepare.test.ts | 22 ++++++- .../TeamProvisioningServicePrompts.test.ts | 15 ++++- .../ProvisioningProviderStatusList.test.ts | 32 ++++++++++ .../providerPrepareDiagnostics.test.ts | 43 +++++++++++++- 8 files changed, 198 insertions(+), 19 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index c2aac6f0..fdd13b1d 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -85,6 +85,7 @@ import { buildActionModeProtocol } from './actionModeInstructions'; import { atomicWriteAsync } from './atomicWrite'; import { peekAutoResumeService } from './AutoResumeService'; import { ClaudeBinaryResolver } from './ClaudeBinaryResolver'; +import { getConfiguredCliCommandLabel } from './cliFlavor'; import { withFileLock } from './fileLock'; import { type ClassifiedMainProcessIdle, @@ -1345,6 +1346,8 @@ ${buildCanonicalSendMessageExample({ to: leadName, summary: 'short update', mess After member_briefing succeeds: - Do NOT send a "ready", "online", "status accepted", or other acknowledgement-only message just to confirm you started successfully. - If bootstrap succeeded and you have no task yet, stay silent and wait for task assignments. +- If bootstrap succeeded and you have no task, produce ZERO assistant text for that turn and end it immediately after the successful tool result. +- Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after bootstrap. - Only SendMessage the lead after bootstrap when there is a real blocker, a failed bootstrap, an explicit question, an urgent coordination need, or a completed task result to report. - Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence. - When you later receive work or reconnect after a restart, use task_briefing as your compact queue view. Use task_get when you need the full task context before starting a pending/needsFix task or when the in_progress briefing details are not enough. @@ -1415,6 +1418,8 @@ ${actionModeProtocol} After member_briefing succeeds: - Do NOT send a "ready", "online", "status accepted", or other acknowledgement-only message just to confirm you reconnected successfully. - If reconnect bootstrap succeeded and you have no immediate blocker or question, stay silent and continue with your queue. + - If reconnect bootstrap succeeded and you have no immediate blocker, question, or task, produce ZERO assistant text for that turn and end it immediately. + - Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after reconnect bootstrap. - Never send raw tool output, JSON, dict/object dumps, Python-style structs, or internal state payloads to the lead or the user. If you need to report bootstrap/task/tool status, rewrite it as one short natural-language sentence. - Use task_briefing as your compact queue view. - If task_briefing shows any in_progress task, resume/finish those first. Call task_get only if you need more context than task_briefing already gave you. @@ -12441,6 +12446,7 @@ export class TeamProvisioningService { providerId: TeamProviderId | undefined = 'anthropic' ): Promise<{ warning?: string }> { const resolvedProviderId = resolveTeamProviderId(providerId); + const cliCommandLabel = getConfiguredCliCommandLabel(); try { const versionProbe = await this.spawnProbe( claudePath, @@ -12452,9 +12458,9 @@ export class TeamProvisioningService { if (versionProbe.exitCode !== 0) { const errorText = buildCombinedLogs(versionProbe.stdout, versionProbe.stderr) || - `Claude CLI exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`; + `${cliCommandLabel} exited with code ${versionProbe.exitCode ?? 'unknown'} during warm-up`; return { - warning: `Claude CLI binary failed to start correctly. Details: ${errorText}`, + warning: `${cliCommandLabel} binary failed to start correctly. Details: ${errorText}`, }; } } catch (error) { @@ -12465,7 +12471,7 @@ export class TeamProvisioningService { }; } return { - warning: `Claude CLI binary failed to start. Details: ${message}`, + warning: `${cliCommandLabel} binary failed to start. Details: ${message}`, }; } @@ -12527,7 +12533,7 @@ export class TeamProvisioningService { } return { warning: - 'Preflight check for `claude -p` did not complete. ' + + `Preflight check for \`${cliCommandLabel} -p\` did not complete. ` + `Proceeding anyway. Details: ${message}`, }; } @@ -12548,13 +12554,15 @@ export class TeamProvisioningService { const hint = isAuthFailure ? resolvedProviderId === 'codex' ? 'Codex provider is not authenticated for `-p` mode. ' + - 'Run `claude-multimodel auth login --provider codex` and retry.' + + `Authenticate Codex in ${cliCommandLabel} and retry.` + (attempt > 1 ? ` (failed after ${attempt} attempts)` : '') - : 'Claude CLI `-p` mode is not authenticated. ' + - 'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' + + : `${cliCommandLabel} \`-p\` mode is not authenticated. ` + + (cliCommandLabel === 'claude' + ? 'Run `claude auth login` (or start `claude` and run `/login`) to authenticate. ' + : `Authenticate Anthropic in ${cliCommandLabel} and retry. `) + 'For automation/headless use, set ANTHROPIC_API_KEY.' + (attempt > 1 ? ` (failed after ${attempt} attempts)` : '') - : `Claude CLI preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`; + : `${cliCommandLabel} preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`; return { warning: hint }; } @@ -12595,7 +12603,7 @@ export class TeamProvisioningService { const targetCwd = cwd ?? process.cwd(); const probeResult = await this.getCachedOrProbeResult(targetCwd, 'anthropic'); if (!probeResult?.claudePath) { - throw new Error('Claude CLI not found'); + throw new Error(`${getConfiguredCliCommandLabel()} not found`); } const { env } = await this.buildProvisioningEnv(); const result = await this.spawnProbe( @@ -12608,7 +12616,7 @@ export class TeamProvisioningService { const output = (result.stdout + '\n' + result.stderr).trim(); if (!output) { throw new Error( - `claude --help returned empty output (exit code: ${String(result.exitCode)})` + `${getConfiguredCliCommandLabel()} --help returned empty output (exit code: ${String(result.exitCode)})` ); } this.helpOutputCache = output; @@ -12966,7 +12974,7 @@ export class TeamProvisioningService { const timeoutHandle = setTimeout(() => { settled = true; killProcessTree(child); - reject(new Error(`Timeout running: claude ${args.join(' ')}`)); + reject(new Error(`Timeout running: ${getConfiguredCliCommandLabel()} ${args.join(' ')}`)); }, timeoutMs); const maybeResolveEarly = (): void => { diff --git a/src/main/services/team/cliFlavor.ts b/src/main/services/team/cliFlavor.ts index 787bcec1..5936f527 100644 --- a/src/main/services/team/cliFlavor.ts +++ b/src/main/services/team/cliFlavor.ts @@ -41,3 +41,17 @@ export function getCliFlavorUiOptions(flavor: CliFlavor): CliFlavorUiOptions { }; } } + +export function getCliFlavorCommandLabel(flavor: CliFlavor): string { + switch (flavor) { + case 'agent_teams_orchestrator': + return 'orchestrator-cli'; + case 'claude': + default: + return 'claude'; + } +} + +export function getConfiguredCliCommandLabel(): string { + return getCliFlavorCommandLabel(getConfiguredCliFlavor()); +} diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx index 27bb8dfe..d4be245c 100644 --- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx +++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx @@ -135,7 +135,7 @@ function summarizeDetail( ) { return 'CLI binary could not be started'; } - if (lower.includes('preflight check for `claude -p` did not complete')) { + if (lower.includes('preflight check for `') && lower.includes('-p` did not complete')) { return 'CLI preflight did not complete'; } if (lower.includes('not authenticated') || lower.includes('not logged in')) { diff --git a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts index 21f67237..626c5476 100644 --- a/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts +++ b/src/renderer/components/team/dialogs/providerPrepareDiagnostics.ts @@ -232,6 +232,50 @@ function createRuntimeDetailLines(result: TeamProvisioningPrepareResult): string return [...(result.details ?? []), ...(result.warnings ?? [])]; } +function extractTimedOutPreflightProbeModelId(detail: string): string | null { + const trimmed = detail.trim(); + if (!trimmed) { + return null; + } + if ( + !trimmed.toLowerCase().includes('preflight check for `') || + !trimmed.toLowerCase().includes('-p` did not complete') + ) { + return null; + } + const match = /--model\s+([^\s]+)/i.exec(trimmed); + return match?.[1]?.trim() || null; +} + +function suppressSupersededRuntimeWarnings(params: { + runtimeDetailLines: string[]; + runtimeWarnings: string[]; + modelResultsById: Map; +}): { + runtimeDetailLines: string[]; + runtimeWarnings: string[]; +} { + const suppressedEntries = new Set(); + + for (const warning of params.runtimeWarnings) { + const probedModelId = extractTimedOutPreflightProbeModelId(warning); + if (!probedModelId) { + continue; + } + if (params.modelResultsById.get(probedModelId)?.status !== 'ready') { + continue; + } + suppressedEntries.add(warning); + } + + return { + runtimeDetailLines: params.runtimeDetailLines.filter( + (detail) => !suppressedEntries.has(detail) + ), + runtimeWarnings: params.runtimeWarnings.filter((warning) => !suppressedEntries.has(warning)), + }; +} + function resolveModelResultFromBatch( providerId: TeamProviderId, modelId: string, @@ -351,7 +395,7 @@ export async function runProviderPrepareDiagnostics({ const modelLines = new Map(); let completedCount = 0; let hasFailure = false; - let hasNotes = runtimeWarnings.length > 0; + let hasNotes = false; const modelWarnings: string[] = []; for (const modelId of orderedModelIds) { @@ -436,7 +480,14 @@ export async function runProviderPrepareDiagnostics({ } } - const dedupedWarnings = Array.from(new Set([...runtimeWarnings, ...modelWarnings])); + const filteredRuntime = suppressSupersededRuntimeWarnings({ + runtimeDetailLines, + runtimeWarnings, + modelResultsById, + }); + const dedupedWarnings = Array.from( + new Set([...filteredRuntime.runtimeWarnings, ...modelWarnings]) + ); const selectedModelResultsById = Object.fromEntries( orderedModelIds .map((modelId) => [modelId, modelResultsById.get(modelId)] as const) @@ -446,9 +497,9 @@ export async function runProviderPrepareDiagnostics({ ); return { - status: hasFailure ? 'failed' : hasNotes ? 'notes' : 'ready', + status: hasFailure ? 'failed' : hasNotes || dedupedWarnings.length > 0 ? 'notes' : 'ready', details: [ - ...runtimeDetailLines, + ...filteredRuntime.runtimeDetailLines, ...orderedModelIds.map((modelId) => modelLines.get(modelId) ?? ''), ], warnings: dedupedWarnings, diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 64e668fb..0addd0bb 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -401,7 +401,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => { }); vi.spyOn(svc as any, 'spawnProbe').mockRejectedValue( new Error( - 'Timeout running: claude -p Output only the single word PONG. --output-format text --model gpt-5.3-codex --max-turns 1 --no-session-persistence' + 'Timeout running: orchestrator-cli -p Output only the single word PONG. --output-format text --model gpt-5.3-codex --max-turns 1 --no-session-persistence' ) ); @@ -417,6 +417,26 @@ describe('TeamProvisioningService prepare/auth behavior', () => { ); }); + it('surfaces preflight timeouts with the orchestrator-cli label', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ + claudePath: '/fake/claude', + authSource: 'codex_runtime', + warning: + 'Preflight check for `orchestrator-cli -p` did not complete. Proceeding anyway. Details: Timeout running: orchestrator-cli -p Output only the single word PONG. --output-format text --model gpt-5.4-mini --max-turns 1 --no-session-persistence', + }); + + const result = await svc.prepareForProvisioning(tempRoot, { + forceFresh: true, + providerId: 'codex', + }); + + expect(result.ready).toBe(true); + expect(result.warnings).toContain( + 'Preflight check for `orchestrator-cli -p` did not complete. Proceeding anyway. Details: Timeout running: orchestrator-cli -p Output only the single word PONG. --output-format text --model gpt-5.4-mini --max-turns 1 --no-session-persistence' + ); + }); + it('maps ANTHROPIC_AUTH_TOKEN into ANTHROPIC_API_KEY for headless preflight', async () => { const svc = new TeamProvisioningService(); vi.mocked(resolveInteractiveShellEnv).mockResolvedValue({ diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index 597b31bc..d738d43c 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -365,6 +365,20 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => ); }); + it('add-member spawn prompt explicitly forbids no-task bootstrap chatter', () => { + const prompt = buildAddMemberSpawnMessage('my-team', 'My Team', 'team-lead', { + name: 'alice', + role: 'developer', + }); + + expect(prompt).toContain( + 'If bootstrap succeeded and you have no task, produce ZERO assistant text for that turn and end it immediately after the successful tool result.' + ); + expect(prompt).toContain( + 'Do NOT ask the user or the lead to send you a task ID, task description, or "next task" right after bootstrap.' + ); + }); + it('launchTeam hydration prompt includes task-comment handling guidance by default', async () => { const teamName = 'forward-live-team'; const teamDir = path.join(tempTeamsBase, teamName); @@ -502,7 +516,6 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => expect(prompt).toContain( 'Correct flow: finish implementation on #X -> task_complete #X -> review_request #X -> reviewer runs review_start #X -> reviewer runs review_approve or review_request_changes on #X.' ); - await svc.cancelProvisioning(runId); }); diff --git a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts index 7c69269d..931753f9 100644 --- a/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts +++ b/test/renderer/components/team/dialogs/ProvisioningProviderStatusList.test.ts @@ -128,4 +128,36 @@ describe('ProvisioningProviderStatusList', () => { await Promise.resolve(); }); }); + + it('normalizes generic preflight timeout notes without depending on a hardcoded CLI name', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ProvisioningProviderStatusList, { + checks: [ + { + providerId: 'codex', + status: 'notes', + backendSummary: 'Default adapter', + details: [ + 'Preflight check for `orchestrator-cli -p` did not complete. Proceeding anyway. Details: Timeout running: orchestrator-cli -p Output only the single word PONG. --output-format text --model gpt-5.4-mini --max-turns 1 --no-session-persistence', + ], + }, + ], + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Codex (Default adapter): CLI preflight did not complete'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts index af685938..65feb589 100644 --- a/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts +++ b/test/renderer/components/team/dialogs/providerPrepareDiagnostics.test.ts @@ -196,7 +196,7 @@ describe('runProviderPrepareDiagnostics', () => { ready: true, message: 'CLI is warmed up and ready to launch', warnings: [ - 'Selected model gpt-5.3-codex could not be verified. Timeout running: claude -p Output only the single word PONG. --output-format text --model gpt-5.3-codex --max-turns 1 --no-session-persistence', + 'Selected model gpt-5.3-codex could not be verified. Timeout running: orchestrator-cli -p Output only the single word PONG. --output-format text --model gpt-5.3-codex --max-turns 1 --no-session-persistence', ], }); }); @@ -372,4 +372,45 @@ describe('runProviderPrepareDiagnostics', () => { 'gpt-5.2-codex', ], undefined); }); + + it('suppresses a timed out runtime preflight note when that same model later verifies', async () => { + const prepareProvisioning = vi.fn< + ( + cwd?: string, + providerId?: 'anthropic' | 'codex' | 'gemini', + providerIds?: ('anthropic' | 'codex' | 'gemini')[], + selectedModels?: string[] + ) => Promise + >((_, __, ___, selectedModels) => { + if (!selectedModels || selectedModels.length === 0) { + return Promise.resolve({ + ready: true, + message: 'CLI is ready to launch (see notes)', + warnings: [ + 'Preflight check for `orchestrator-cli -p` did not complete. Proceeding anyway. Details: Timeout running: orchestrator-cli -p Output only the single word PONG. --output-format text --model gpt-5.4-mini --max-turns 1 --no-session-persistence', + ], + }); + } + + return Promise.resolve({ + ready: true, + message: 'CLI is warmed up and ready to launch', + details: [ + 'Selected model gpt-5.4-mini verified for launch.', + 'Selected model gpt-5.4 verified for launch.', + ], + }); + }); + + const result = await runProviderPrepareDiagnostics({ + cwd: '/tmp/project', + providerId: 'codex', + selectedModelIds: ['gpt-5.4-mini', 'gpt-5.4'], + prepareProvisioning, + }); + + expect(result.status).toBe('ready'); + expect(result.warnings).toEqual([]); + expect(result.details).toEqual(['5.4 Mini - verified', '5.4 - verified']); + }); }); From c25097a7815663882a38212b9cee73c24cc56f91 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 18 Apr 2026 18:32:40 +0300 Subject: [PATCH 18/21] fix(sidebar): soften unread task highlight --- .../components/sidebar/SidebarTaskItem.tsx | 4 +- .../sidebar/SidebarTaskItem.test.ts | 142 ++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 test/renderer/components/sidebar/SidebarTaskItem.test.ts diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 5267a413..688aa958 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -144,11 +144,13 @@ export const SidebarTaskItem = ({ ); const showTeamRow = showTeamName && !hideTeamName; + const unreadBackgroundClass = + unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : ''; return (
-
- {summaryContent} + + + +
+ {compactPreviewText} +
+
+ + {compactPreviewText} + +
+
+
+ ) : !isExpanded ? ( +
+
+ {isUnread ? ( + + ) : null} + {showChevron ? ( + + ) : null} + {crossTeamOrigin ? ( + + ) : null} + {senderBadge} + {!compactHeader && formattedRole && !isSlashCommandResult ? ( + + {formattedRole} + + ) : null} + {messageTypeBadge} + {leadSourceBadge} + {statusBadge} + {recipientBadge} +
+ + {timestamp} + + {onExpand && expandItemKey && ( + + )} +
+ + + +
+ {compactPreviewText} +
+
+ + {compactPreviewText} + +
+
) : ( <> diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 6943a68b..31295a3b 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -10,7 +10,12 @@ import { } from 'react'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@renderer/components/ui/tooltip'; import { CARD_BG, CARD_BG_ZEBRA, @@ -39,6 +44,7 @@ import { } from './AnimatedHeightReveal'; import { ThoughtBodyContent } from './ThoughtBodyContent'; +import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import type { InboxMessage, ToolCallMeta } from '@shared/types'; export interface LeadThoughtGroup { @@ -587,9 +593,11 @@ const LeadThoughtsGroupRowComponent = ({ // Try newest first (most relevant), then scan for any text for (const t of thoughts) { if (t.text && t.text.trim()) { - const plain = extractMarkdownPlainText(t.text); - const firstLine = plain.split('\n').find((l) => l.trim().length > 0) ?? ''; - return firstLine.trim(); + const plain = extractMarkdownPlainText(stripAgentBlocks(t.text)); + const normalized = plain.replace(/\n+/g, ' ').trim(); + if (normalized) { + return normalized; + } } } return null; @@ -830,13 +838,108 @@ const LeadThoughtsGroupRowComponent = ({
{compactPreviewText ? ( -
- {compactPreviewText} + + + +
+ {compactPreviewText} +
+
+ + {compactPreviewText} + +
+
+ ) : null} +
+ ) : !isBodyVisible ? ( +
+
+ {canToggleBodyVisibility && !compactHeader ? ( + + ) : null} + {!compactHeader ? ( +
+ + +
+ ) : null} + + + {thoughts.length} thoughts + +
+ + {timestampLabel} + + {onExpand && expandItemKey && ( + + )}
+
+ {compactPreviewText ? ( + + + +
+ {compactPreviewText} +
+
+ + {compactPreviewText} + +
+
) : null}
) : ( @@ -871,26 +974,7 @@ const LeadThoughtsGroupRowComponent = ({ {thoughts.length} thoughts - {!isBodyVisible && headerTextPreview ? ( - - - - {headerTextPreview} - - - {totalToolSummary ? ( - - - - ) : null} - - ) : totalToolSummary ? ( + {totalToolSummary ? ( diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx new file mode 100644 index 00000000..4867111d --- /dev/null +++ b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx @@ -0,0 +1,161 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@renderer/components/team/MemberBadge', () => ({ + MemberBadge: ({ name }: { name: string }) => React.createElement('span', null, name), +})); + +vi.mock('@renderer/components/team/UnreadCommentsBadge', () => ({ + UnreadCommentsBadge: () => null, +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + className, + onClick, + disabled, + 'aria-label': ariaLabel, + }: { + children: React.ReactNode; + className?: string; + onClick?: React.MouseEventHandler; + disabled?: boolean; + 'aria-label'?: string; + }) => + React.createElement( + 'button', + { className, onClick, disabled, 'aria-label': ariaLabel, type: 'button' }, + children + ), +})); + +vi.mock('@renderer/components/ui/popover', () => ({ + Popover: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + PopoverTrigger: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + PopoverContent: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), +})); + +vi.mock('@renderer/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + TooltipTrigger: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + TooltipContent: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), +})); + +vi.mock('@renderer/hooks/useTheme', () => ({ + useTheme: () => ({ isLight: false }), +})); + +vi.mock('@renderer/hooks/useUnreadCommentCount', () => ({ + useUnreadCommentCount: () => 0, +})); + +import { KanbanTaskCard } from './KanbanTaskCard'; + +import type { TeamTaskWithKanban } from '@shared/types/team'; + +const baseTask: TeamTaskWithKanban = { + id: 'task-1', + displayId: 'abcd1234', + subject: 'Implement safer onboarding flow', + owner: 'alice', + reviewer: '', + status: 'in_progress', + changePresence: 'unknown', + comments: [], + blockedBy: [], + blocks: [], + workIntervals: [], + historyEvents: [], + createdAt: '2026-04-18T10:00:00.000Z', + updatedAt: '2026-04-18T10:10:00.000Z', +} as unknown as TeamTaskWithKanban; + +const noop = (): void => undefined; + +describe('KanbanTaskCard change badge', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('does not render a No changes badge when changePresence is no_changes', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(KanbanTaskCard, { + task: { ...baseTask, changePresence: 'no_changes' }, + teamName: 'my-team', + columnId: 'in_progress', + hasReviewers: true, + compact: false, + taskMap: new Map(), + memberColorMap: new Map([['alice', 'blue']]), + onRequestReview: noop, + onApprove: noop, + onRequestChanges: noop, + onMoveBackToDone: noop, + onStartTask: noop, + onCompleteTask: noop, + onCancelTask: noop, + onViewChanges: noop, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).not.toContain('No changes'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('still renders the Changes action when changePresence is has_changes', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(KanbanTaskCard, { + task: { ...baseTask, changePresence: 'has_changes' }, + teamName: 'my-team', + columnId: 'in_progress', + hasReviewers: true, + compact: false, + taskMap: new Map(), + memberColorMap: new Map([['alice', 'blue']]), + onRequestReview: noop, + onApprove: noop, + onRequestChanges: noop, + onMoveBackToDone: noop, + onStartTask: noop, + onCompleteTask: noop, + onCancelTask: noop, + onViewChanges: noop, + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[aria-label="Changes"]')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 87933fb6..7c84488d 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -268,10 +268,6 @@ export const KanbanTaskCard = memo( onViewChanges!(task.id); }} /> - ) : canDisplay && task.changePresence === 'no_changes' ? ( - - No changes - ) : null} {onDeleteTask ? ( diff --git a/src/renderer/components/team/members/CurrentTaskIndicator.tsx b/src/renderer/components/team/members/CurrentTaskIndicator.tsx index e4df93f6..d168359a 100644 --- a/src/renderer/components/team/members/CurrentTaskIndicator.tsx +++ b/src/renderer/components/team/members/CurrentTaskIndicator.tsx @@ -6,7 +6,6 @@ import type { TeamTaskWithKanban } from '@shared/types'; interface CurrentTaskIndicatorProps { task: TeamTaskWithKanban; borderColor: string; - /** Max characters for the subject before truncating */ maxSubjectLength?: number; activityLabel?: string; onOpenTask?: () => void; @@ -19,21 +18,24 @@ interface CurrentTaskIndicatorProps { export const CurrentTaskIndicator = ({ task, borderColor, - maxSubjectLength = 36, + maxSubjectLength, activityLabel = 'working on', onOpenTask, }: CurrentTaskIndicatorProps): React.JSX.Element => { - const truncated = task.subject.length > maxSubjectLength; - const subjectText = truncated ? `${task.subject.slice(0, maxSubjectLength)}…` : task.subject; + const subjectText = + typeof maxSubjectLength === 'number' && + maxSubjectLength > 0 && + task.subject.length > maxSubjectLength + ? `${task.subject.slice(0, maxSubjectLength)}…` + : task.subject; return ( - <> +
{activityLabel} - +
); }; diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index bcf7ebc2..5ea2e0fe 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -1,6 +1,6 @@ import { Badge } from '@renderer/components/ui/badge'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; -import { getTeamColorSet, getThemedBadge, scaleColorAlpha } from '@renderer/constants/teamColors'; +import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { @@ -101,6 +101,7 @@ export const MemberCard = ({ const completed = taskCounts?.completed ?? 0; const totalTasks = pending + inProgress + completed; const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0; + const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); const activityTask = currentTask ?? reviewTask ?? null; const activityTitle = currentTask ? `Current task: #${deriveTaskDisplayId(currentTask.id)}` @@ -120,18 +121,14 @@ export const MemberCard = ({ !showStartingBadge && spawnStatus !== 'error' && (Boolean(activityTask) || !isAwaitingReply); - const cardTint = scaleColorAlpha(getThemedBadge(colors, isLight), 0.5); return (
- {member.name} +
+ {member.name} +
-
+
{displayMemberName(member.name)} @@ -210,20 +215,16 @@ export const MemberCard = ({ style={{ backgroundColor: 'var(--skeleton-base)' }} />
- ) : runtimeSummary ? ( -
- {runtimeSummary} + ) : runtimeSummary || roleLabel ? ( +
+ {runtimeSummary ? {runtimeSummary} : null} + {runtimeSummary && roleLabel ? ( + + ) : null} + {roleLabel ? {roleLabel} : null}
) : null}
- {(() => { - const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); - return roleLabel ? ( - - {roleLabel} - - ) : null; - })()} {showStartingBadge ? ( ({ React.createElement(React.Fragment, null, children), })); vi.mock('@renderer/components/ui/tooltip', () => ({ + TooltipProvider: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), Tooltip: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), TooltipTrigger: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), @@ -45,6 +47,186 @@ import { } from '@renderer/components/team/activity/ActivityItem'; import type { InboxMessage } from '@shared/types'; +describe('ActivityItem compact header preview', () => { + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('uses a two-line clamped preview in compact mode', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const summary = + 'Делегировал alice длинную задачу с заметно более длинным описанием, чтобы превью занимало больше одной строки в компактном режиме.'; + + const message: InboxMessage = { + from: 'team-lead', + text: summary, + summary, + timestamp: new Date('2026-04-18T16:30:00.000Z').toISOString(), + read: true, + source: 'lead_process', + }; + + await act(async () => { + root.render( + React.createElement(ActivityItem, { + message, + teamName: 'my-team', + compactHeader: true, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + collapseToggleKey: 'message-key', + }) + ); + await Promise.resolve(); + }); + + const preview = host.querySelector('.line-clamp-2'); + expect(preview).not.toBeNull(); + expect(preview?.textContent).toBe(summary); + expect(preview?.getAttribute('title')).toBeNull(); + expect(preview?.className).toContain('line-clamp-2'); + expect(preview?.className).toContain('w-full'); + expect(preview?.className).toContain('max-w-full'); + expect(preview?.className).not.toContain('min-h-8'); + expect(preview?.className).not.toContain('truncate'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('prefers full message text over a pre-truncated summary in compact mode', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const fullText = + 'Делегировал bob ещё один узкий шаг: собрать fix-batch с учётом landing P0 по render->generate и пройтись по оставшимся edge cases.'; + + const message: InboxMessage = { + from: 'team-lead', + text: fullText, + summary: 'Делегировал bob ещё один узкий шаг: собрать fix-batch с у...', + timestamp: new Date('2026-04-18T16:29:00.000Z').toISOString(), + read: true, + source: 'lead_process', + }; + + await act(async () => { + root.render( + React.createElement(ActivityItem, { + message, + teamName: 'my-team', + compactHeader: true, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + collapseToggleKey: 'message-key-full-text', + }) + ); + await Promise.resolve(); + }); + + const preview = host.querySelector('.line-clamp-2'); + expect(preview).not.toBeNull(); + expect(preview?.textContent).toBe(fullText); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('strips info_for_agent blocks from compact preview text', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + const visibleText = 'New task assigned to you: #3fd70e2 Собрать fix-batch'; + const message: InboxMessage = { + from: 'team-lead', + text: `${visibleText}\n\ninternal only\n`, + timestamp: new Date('2026-04-18T16:28:00.000Z').toISOString(), + read: true, + source: 'lead_process', + }; + + await act(async () => { + root.render( + React.createElement(ActivityItem, { + message, + teamName: 'my-team', + compactHeader: true, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + collapseToggleKey: 'message-key-strip-agent-block', + }) + ); + await Promise.resolve(); + }); + + const preview = host.querySelector('.line-clamp-2'); + expect(preview).not.toBeNull(); + expect(preview?.textContent).toBe(visibleText); + expect(preview?.textContent).not.toContain('info_for_agent'); + expect(preview?.textContent).not.toContain('internal only'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('uses a two-line preview in collapsed wide mode, not inline one-line summary', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const fullText = + 'Делегировал alice финальную общую сводку и remediation plan по всем findings команды.'; + + const message: InboxMessage = { + from: 'team-lead', + text: fullText, + timestamp: new Date('2026-04-18T16:30:00.000Z').toISOString(), + read: true, + source: 'lead_process', + }; + + await act(async () => { + root.render( + React.createElement(ActivityItem, { + message, + teamName: 'my-team', + compactHeader: false, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + collapseToggleKey: 'message-key-wide-collapsed', + }) + ); + await Promise.resolve(); + }); + + const preview = host.querySelector('.line-clamp-2'); + expect(preview).not.toBeNull(); + expect(preview?.textContent).toBe(fullText); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); + describe('ActivityItem slash command rendering', () => { afterEach(() => { document.body.innerHTML = ''; diff --git a/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts b/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts index cdb504de..1dd46a44 100644 --- a/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts +++ b/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts @@ -1,8 +1,36 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, vi } from 'vitest'; + +vi.mock('@renderer/components/team/MemberBadge', () => ({ + MemberBadge: ({ name }: { name: string }) => React.createElement('span', null, name), +})); +vi.mock('@renderer/components/ui/tooltip', () => ({ + TooltipProvider: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), + Tooltip: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), + TooltipTrigger: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + TooltipContent: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children), +})); +vi.mock('../../../../../src/renderer/components/team/activity/AnimatedHeightReveal', () => ({ + ENTRY_REVEAL_ANIMATION_MS: 220, + ENTRY_REVEAL_EASING: 'ease', + AnimatedHeightReveal: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), +})); +vi.mock('../../../../../src/renderer/components/team/activity/ThoughtBodyContent', () => ({ + ThoughtBodyContent: ({ thought }: { thought: { text: string } }) => + React.createElement('div', null, thought.text), +})); +vi.mock('@renderer/utils/memberHelpers', () => ({ + agentAvatarUrl: () => '/avatar.png', +})); import { groupTimelineItems, isLeadThought, + LeadThoughtsGroupRow, } from '../../../../../src/renderer/components/team/activity/LeadThoughtsGroup'; import type { InboxMessage } from '../../../../../src/shared/types'; @@ -19,6 +47,29 @@ function makeLeadSessionMsg(text: string, overrides?: Partial): In } describe('LeadThoughtsGroup', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.stubGlobal( + 'IntersectionObserver', + class { + observe() {} + disconnect() {} + } + ); + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + disconnect() {} + } + ); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + it('does not classify slash command results as lead thoughts', () => { const resultMessage: InboxMessage = { from: 'team-lead', @@ -118,4 +169,155 @@ describe('LeadThoughtsGroup', () => { } }); }); + + it('uses a two-line clamped preview in compact header mode', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const preview = + 'Это длинный preview текста для lead thoughts, который должен занимать до двух строк в compact header, а не одну.'; + + const thought = makeLeadSessionMsg(preview, { + messageId: 'thought-1', + leadSessionId: 'lead-session-1', + }); + + await act(async () => { + root.render( + React.createElement(LeadThoughtsGroupRow, { + group: { type: 'lead-thoughts', thoughts: [thought] }, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + compactHeader: true, + }) + ); + await Promise.resolve(); + }); + + const previewNode = host.querySelector('.line-clamp-2'); + expect(previewNode).not.toBeNull(); + expect(previewNode?.textContent).toBe(preview); + expect(previewNode?.getAttribute('title')).toBeNull(); + expect(previewNode?.className).toContain('line-clamp-2'); + expect(previewNode?.className).toContain('w-full'); + expect(previewNode?.className).toContain('max-w-full'); + expect(previewNode?.className).not.toContain('min-h-8'); + expect(previewNode?.className).not.toContain('truncate'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('uses the normalized full thought text instead of only the first line in compact header mode', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const firstLine = 'Собрать единый remediation plan.'; + const secondLine = 'Проверить remaining edge cases по graph и messages.'; + const preview = `${firstLine} ${secondLine}`; + + const thought = makeLeadSessionMsg(`${firstLine}\n${secondLine}`, { + messageId: 'thought-2', + leadSessionId: 'lead-session-2', + }); + + await act(async () => { + root.render( + React.createElement(LeadThoughtsGroupRow, { + group: { type: 'lead-thoughts', thoughts: [thought] }, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + compactHeader: true, + }) + ); + await Promise.resolve(); + }); + + const previewNode = host.querySelector('.line-clamp-2'); + expect(previewNode).not.toBeNull(); + expect(previewNode?.textContent).toBe(preview); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('strips info_for_agent blocks from compact thoughts preview', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const visibleText = 'Собрать единый remediation plan.'; + + const thought = makeLeadSessionMsg( + `${visibleText}\n\ninternal note\n`, + { + messageId: 'thought-3', + leadSessionId: 'lead-session-3', + } + ); + + await act(async () => { + root.render( + React.createElement(LeadThoughtsGroupRow, { + group: { type: 'lead-thoughts', thoughts: [thought] }, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + compactHeader: true, + }) + ); + await Promise.resolve(); + }); + + const previewNode = host.querySelector('.line-clamp-2'); + expect(previewNode).not.toBeNull(); + expect(previewNode?.textContent).toBe(visibleText); + expect(previewNode?.textContent).not.toContain('info_for_agent'); + expect(previewNode?.textContent).not.toContain('internal note'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('uses a two-line preview in collapsed wide mode for thought groups', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const preview = + 'Делегировал alice финальную общую сводку и remediation plan по всем findings команды.'; + + const thought = makeLeadSessionMsg(preview, { + messageId: 'thought-4', + leadSessionId: 'lead-session-4', + }); + + await act(async () => { + root.render( + React.createElement(LeadThoughtsGroupRow, { + group: { type: 'lead-thoughts', thoughts: [thought] }, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + compactHeader: false, + }) + ); + await Promise.resolve(); + }); + + const previewNode = host.querySelector('.line-clamp-2'); + expect(previewNode).not.toBeNull(); + expect(previewNode?.textContent).toBe(preview); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/members/CurrentTaskIndicator.test.ts b/test/renderer/components/team/members/CurrentTaskIndicator.test.ts new file mode 100644 index 00000000..c538792f --- /dev/null +++ b/test/renderer/components/team/members/CurrentTaskIndicator.test.ts @@ -0,0 +1,77 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { CurrentTaskIndicator } from '@renderer/components/team/members/CurrentTaskIndicator'; + +import type { TeamTaskWithKanban } from '@shared/types'; + +const task: TeamTaskWithKanban = { + id: 'task-1', + displayId: '9d1915a7', + subject: 'Полный аудит актуальности документации и связанных onboarding заметок', + status: 'in_progress', +} as unknown as TeamTaskWithKanban; + +describe('CurrentTaskIndicator', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('uses all available width for the task pill without early subject truncation', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(CurrentTaskIndicator, { + task, + borderColor: '#3b82f6', + }) + ); + await Promise.resolve(); + }); + + const wrapper = host.firstElementChild as HTMLElement | null; + const button = host.querySelector('button'); + + expect(wrapper?.className).toContain('flex-1'); + expect(button?.className).toContain('flex-1'); + expect(button?.className).toContain('text-left'); + expect(button?.textContent).toContain(task.subject); + expect(button?.style.border).toBe(''); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('still supports an explicit subject ceiling when a compact caller requests it', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(CurrentTaskIndicator, { + task, + borderColor: '#3b82f6', + maxSubjectLength: 12, + }) + ); + await Promise.resolve(); + }); + + const button = host.querySelector('button'); + expect(button?.textContent).toContain('Полный аудит…'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/components/team/members/MemberCard.test.ts b/test/renderer/components/team/members/MemberCard.test.ts index 6fa3cd14..e0058c00 100644 --- a/test/renderer/components/team/members/MemberCard.test.ts +++ b/test/renderer/components/team/members/MemberCard.test.ts @@ -240,4 +240,38 @@ describe('MemberCard starting-state visuals', () => { await Promise.resolve(); }); }); + + it('shows member color on the avatar ring instead of a colored card rail', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member, + memberColor: 'blue', + isTeamAlive: true, + isTeamProvisioning: false, + }) + ); + await Promise.resolve(); + }); + + const img = host.querySelector('img'); + const avatarRing = img?.parentElement; + const clickableCard = host.querySelector('[role="button"]') as HTMLElement | null; + + expect(avatarRing).not.toBeNull(); + expect(avatarRing?.style.borderColor).toBe('#3b82f6'); + expect(clickableCard?.style.borderLeft).toBe(''); + expect(clickableCard?.style.background).toBe(''); + expect(clickableCard?.className).not.toContain('px-'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); From 93a6ae74b06c7372cdb5d8abb0e0a95fb646e101 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 18 Apr 2026 21:57:59 +0300 Subject: [PATCH 20/21] refactor(activity): reuse markdown rendering in compact previews --- .../chat/viewers/MarkdownViewer.tsx | 388 ++++++++++++------ .../components/team/activity/ActivityItem.tsx | 48 ++- .../team/activity/LeadThoughtsGroup.tsx | 61 +-- .../team/activity/ThoughtBodyContent.tsx | 20 +- .../team/activity/activityMarkdown.ts | 36 ++ .../team/activity/ActivityItem.test.ts | 49 ++- .../team/activity/LeadThoughtsGroup.test.ts | 41 ++ 7 files changed, 470 insertions(+), 173 deletions(-) create mode 100644 src/renderer/components/team/activity/activityMarkdown.ts diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index a9b9454c..1e671096 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -71,10 +71,21 @@ interface MarkdownViewerProps { onTeamClick?: (teamName: string) => void; } +interface CompactMarkdownPreviewProps { + content: string; + className?: string; + /** Optional precomputed team color map to avoid subscribing to the full team list. */ + teamColorByName?: ReadonlyMap; + /** Optional team click handler to avoid subscribing to store in leaf renderers. */ + onTeamClick?: (teamName: string) => void; +} + const EMPTY_TEAMS: { teamName?: string; displayName?: string; color?: string }[] = []; const EMPTY_TEAM_COLOR_MAP = new Map(); const NOOP_TEAM_CLICK = (): void => undefined; +type ViewerMarkdownMode = 'default' | 'compact-preview'; + // ============================================================================= // Helpers // ============================================================================= @@ -322,53 +333,89 @@ function createViewerMarkdownComponents( isLight = false, teamColorByName: ReadonlyMap = new Map(), onTeamClick?: (teamName: string) => void, - copyCodeBlocks: boolean = false + copyCodeBlocks: boolean = false, + mode: ViewerMarkdownMode = 'default' ): Components { const hl = (children: React.ReactNode): React.ReactNode => searchCtx ? highlightSearchInChildren(children, searchCtx) : children; + const isCompactPreview = mode === 'compact-preview'; + + const renderCompactInline = ( + children: React.ReactNode, + className: string, + style: React.CSSProperties + ): React.ReactElement => ( + + {hl(children)}{' '} + + ); return { // Headings - h1: ({ children }) => ( -

- {hl(children)} -

- ), - h2: ({ children }) => ( -

- {hl(children)} -

- ), - h3: ({ children }) => ( -

- {hl(children)} -

- ), - h4: ({ children }) => ( -

- {hl(children)} -

- ), - h5: ({ children }) => ( -
- {hl(children)} -
- ), - h6: ({ children }) => ( -
- {hl(children)} -
- ), + h1: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) + ) : ( +

+ {hl(children)} +

+ ), + h2: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) + ) : ( +

+ {hl(children)} +

+ ), + h3: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) + ) : ( +

+ {hl(children)} +

+ ), + h4: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) + ) : ( +

+ {hl(children)} +

+ ), + h5: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-medium', { color: PROSE_HEADING }) + ) : ( +
+ {hl(children)} +
+ ), + h6: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-medium', { color: PROSE_HEADING }) + ) : ( +
+ {hl(children)} +
+ ), // Paragraphs - p: ({ children }) => ( -

- {hl(children)} -

- ), + p: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, '', { color: PROSE_BODY }) + ) : ( +

+ {hl(children)} +

+ ), // Links — inline element, no hl(); parent block element's hl() descends here // task:// links render with TaskTooltip + are clickable via ancestor onClickCapture @@ -570,6 +617,20 @@ function createViewerMarkdownComponents( // Code blocks — intercept mermaid diagrams at the pre level pre: ({ children, node }) => { + if (isCompactPreview) { + const compactText = extractTextFromReactNode(children).trim(); + return ( + + {compactText} + + ); + } // Check if this pre contains a mermaid code block const codeEl = node?.children?.[0]; if (codeEl && 'tagName' in codeEl && codeEl.tagName === 'code' && 'properties' in codeEl) { @@ -596,74 +657,107 @@ function createViewerMarkdownComponents( }, // Blockquotes - blockquote: ({ children }) => ( -
- {hl(children)} -
- ), + blockquote: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'italic', { color: PROSE_MUTED }) + ) : ( +
+ {hl(children)} +
+ ), // Lists - ul: ({ children }) => ( -
    - {children} -
- ), - ol: ({ children }) => ( -
    - {children} -
- ), - li: ({ children }) => ( -
  • - {hl(children)} -
  • - ), + ul: ({ children }) => + isCompactPreview ? ( + {children} + ) : ( +
      + {children} +
    + ), + ol: ({ children }) => + isCompactPreview ? ( + {children} + ) : ( +
      + {children} +
    + ), + li: ({ children }) => + isCompactPreview ? ( + + • {hl(children)}{' '} + + ) : ( +
  • + {hl(children)} +
  • + ), // Tables - table: ({ children }) => ( -
    - + isCompactPreview ? ( + {children} + ) : ( +
    +
    + {children} +
    +
    + ), + thead: ({ children }) => + isCompactPreview ? ( + {children} + ) : ( + {children} + ), + th: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) + ) : ( + - {children} - -
    - ), - thead: ({ children }) => ( - {children} - ), - th: ({ children }) => ( - - {hl(children)} - - ), - td: ({ children }) => ( - - {hl(children)} - - ), + {hl(children)} + + ), + td: ({ children }) => + isCompactPreview ? ( + renderCompactInline(children, '', { color: PROSE_BODY }) + ) : ( + + {hl(children)} + + ), // Horizontal rule - hr: () =>
    , + hr: () => + isCompactPreview ? ( + + · + + ) : ( +
    + ), }; } @@ -679,6 +773,78 @@ const LARGE_PREVIEW_CHARS = 30_000; // Component // ============================================================================= +function useResolvedViewerTeamContext( + providedTeamColorByName?: ReadonlyMap, + providedOnTeamClick?: (teamName: string) => void +): { + teamColorByName: ReadonlyMap; + onTeamClick?: (teamName: string) => void; +} { + const teams = useStore(useShallow((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams))); + const openTeamTab = useStore((s) => (providedOnTeamClick ? NOOP_TEAM_CLICK : s.openTeamTab)); + + const fallbackTeamColorByName = React.useMemo(() => { + const result = new Map(); + for (const team of teams) { + if (team.teamName) { + result.set(team.teamName, team.color ?? ''); + } + if (team.displayName) { + result.set(team.displayName, team.color ?? ''); + } + } + return result; + }, [teams]); + + return { + teamColorByName: providedTeamColorByName ?? fallbackTeamColorByName ?? EMPTY_TEAM_COLOR_MAP, + onTeamClick: providedOnTeamClick ?? openTeamTab, + }; +} + +export const CompactMarkdownPreview: React.FC = React.memo( + function CompactMarkdownPreview({ + content, + className = '', + teamColorByName: providedTeamColorByName, + onTeamClick: providedOnTeamClick, + }) { + const { isLight } = useTheme(); + const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext( + providedTeamColorByName, + providedOnTeamClick + ); + + const components = React.useMemo( + () => + createViewerMarkdownComponents( + null, + isLight, + teamColorByName, + onTeamClick, + false, + 'compact-preview' + ), + [isLight, onTeamClick, teamColorByName] + ); + + return ( +
    + + {content} + +
    + ); + } +); + export const MarkdownViewer: React.FC = ({ content, maxHeight = 'max-h-96', @@ -695,24 +861,10 @@ export const MarkdownViewer: React.FC = ({ const [showRaw, setShowRaw] = React.useState(false); const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); const { isLight } = useTheme(); - const teams = useStore(useShallow((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams))); - const openTeamTab = useStore((s) => (providedOnTeamClick ? NOOP_TEAM_CLICK : s.openTeamTab)); - - const fallbackTeamColorByName = React.useMemo(() => { - const result = new Map(); - for (const team of teams) { - if (team.teamName) { - result.set(team.teamName, team.color ?? ''); - } - if (team.displayName) { - result.set(team.displayName, team.color ?? ''); - } - } - return result; - }, [teams]); - const teamColorByName = - providedTeamColorByName ?? fallbackTeamColorByName ?? EMPTY_TEAM_COLOR_MAP; - const onTeamClick = providedOnTeamClick ?? openTeamTab; + const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext( + providedTeamColorByName, + providedOnTeamClick + ); const isTooLarge = content.length > MAX_MARKDOWN_CHARS; const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS; diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 86d75b5b..0ad6473d 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -1,6 +1,9 @@ import { Fragment, memo, useCallback, useMemo } from 'react'; -import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; +import { + CompactMarkdownPreview, + MarkdownViewer, +} from '@renderer/components/chat/viewers/MarkdownViewer'; import { CopyButton } from '@renderer/components/common/CopyButton'; import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; @@ -823,7 +826,7 @@ export const ActivityItem = memo( structured, ]); const summaryText = extractMarkdownPlainText(rawSummary); - const compactPreviewText = useMemo(() => { + const compactPreviewMarkdown = useMemo(() => { if (idleSemantic?.hasPeerSummary && idleSemantic.peerSummary) { return idleSemantic.peerSummary; } @@ -839,14 +842,15 @@ export const ActivityItem = memo( } if (crossTeamPreview) return crossTeamPreview; - const fullText = strippedText?.trim() ?? ''; - if (fullText) { - return extractMarkdownPlainText(fullText).replace(/\n+/g, ' ').trim(); + const formattedDisplayText = displayText?.trim() ?? ''; + if (formattedDisplayText) { + return formattedDisplayText; } return summaryText || rawSummary; }, [ crossTeamPreview, + displayText, idleSemantic, isSlashCommandMessage, isSlashCommandResult, @@ -856,6 +860,12 @@ export const ActivityItem = memo( slashCommandMeta, summaryText, ]); + const compactPreviewTooltipText = useMemo(() => { + const normalized = extractMarkdownPlainText(compactPreviewMarkdown) + .replace(/\n+/g, ' ') + .trim(); + return normalized || compactPreviewMarkdown; + }, [compactPreviewMarkdown]); const commentTaskRef = message.messageKind === 'task_comment_notification' ? (message.taskRefs?.[0] ?? null) : null; const commentTaskDisplayId = @@ -1218,11 +1228,13 @@ export const ActivityItem = memo( -
    - {compactPreviewText} +
    +
    - {compactPreviewText} + {compactPreviewTooltipText} @@ -1298,11 +1310,13 @@ export const ActivityItem = memo( -
    - {compactPreviewText} +
    +
    - {compactPreviewText} + {compactPreviewTooltipText} diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 31295a3b..0b8df55c 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -9,6 +9,7 @@ import { useState, } from 'react'; +import { CompactMarkdownPreview } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { Tooltip, @@ -42,6 +43,7 @@ import { ENTRY_REVEAL_ANIMATION_MS, ENTRY_REVEAL_EASING, } from './AnimatedHeightReveal'; +import { buildThoughtDisplayContent } from './activityMarkdown'; import { ThoughtBodyContent } from './ThoughtBodyContent'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; @@ -588,20 +590,30 @@ const LeadThoughtsGroupRowComponent = ({ return calls.length > 0 ? calls : undefined; }, [thoughts]); - // Extract text preview for header: use newest thought's text, fallback through group - const headerTextPreview = useMemo(() => { + // Reuse the same markdown preprocessing as the expanded thought body. + const compactPreviewMarkdown = useMemo(() => { // Try newest first (most relevant), then scan for any text for (const t of thoughts) { if (t.text && t.text.trim()) { - const plain = extractMarkdownPlainText(stripAgentBlocks(t.text)); - const normalized = plain.replace(/\n+/g, ' ').trim(); - if (normalized) { - return normalized; + const stripped = stripAgentBlocks(t.text).trim(); + if (stripped) { + return buildThoughtDisplayContent(t, memberColorMap, teamNames, { + preserveLineBreaks: false, + stripAgentOnlyBlocks: true, + }) + .replace(/\n+/g, ' ') + .trim(); } } } - return null; - }, [thoughts]); + return totalToolSummary; + }, [memberColorMap, teamNames, thoughts, totalToolSummary]); + const compactPreviewTooltipText = useMemo(() => { + const normalized = extractMarkdownPlainText(compactPreviewMarkdown ?? '') + .replace(/\n+/g, ' ') + .trim(); + return normalized || compactPreviewMarkdown; + }, [compactPreviewMarkdown]); // Detect if any thought in this group is an API error const hasApiError = useMemo(() => thoughts.some((t) => isApiErrorMessage(t.text)), [thoughts]); @@ -764,7 +776,6 @@ const LeadThoughtsGroupRowComponent = ({ ? formatTime(oldest.timestamp) : `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`; const useCompactCollapsedHeader = compactHeader && !isBodyVisible; - const compactPreviewText = headerTextPreview ?? totalToolSummary; return ( @@ -837,15 +848,17 @@ const LeadThoughtsGroupRowComponent = ({ )}
    - {compactPreviewText ? ( + {compactPreviewMarkdown ? ( -
    - {compactPreviewText} +
    +
    - {compactPreviewText} + {compactPreviewTooltipText} @@ -920,15 +933,17 @@ const LeadThoughtsGroupRowComponent = ({ )}
    - {compactPreviewText ? ( + {compactPreviewMarkdown ? ( -
    - {compactPreviewText} +
    +
    - {compactPreviewText} + {compactPreviewTooltipText} diff --git a/src/renderer/components/team/activity/ThoughtBodyContent.tsx b/src/renderer/components/team/activity/ThoughtBodyContent.tsx index d4b7c212..75985812 100644 --- a/src/renderer/components/team/activity/ThoughtBodyContent.tsx +++ b/src/renderer/components/team/activity/ThoughtBodyContent.tsx @@ -4,17 +4,16 @@ import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer import { CopyButton } from '@renderer/components/common/CopyButton'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { CARD_ICON_MUTED, CARD_TEXT_LIGHT } from '@renderer/constants/cssVariables'; -import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; import { areStringArraysEqual, areStringMapsEqual, areThoughtMessagesEquivalentForRender, } from '@renderer/utils/messageRenderEquality'; -import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; +import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; -import { stripTeammateMessageBlocks } from '@shared/utils/inboxNoise'; import { Reply } from 'lucide-react'; +import { buildThoughtDisplayContent } from './activityMarkdown'; import { formatTimeWithSec, ToolSummaryTooltipContent } from './LeadThoughtsGroup'; import type { InboxMessage } from '@shared/types'; @@ -42,17 +41,10 @@ export const ThoughtBodyContent = memo( onTeamClick, }: ThoughtBodyContentProps): JSX.Element { const displayContent = useMemo(() => { - // Strip leaked protocol XML ( blocks) before rendering - let text = stripTeammateMessageBlocks(thought.text).replace(/\n/g, ' \n'); - text = linkifyTaskIdsInMarkdown(text, thought.taskRefs); - if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) { - text = linkifyAllMentionsInMarkdown( - text, - (memberColorMap ?? new Map()) as Map, - teamNames - ); - } - return text; + return buildThoughtDisplayContent(thought, memberColorMap, teamNames, { + preserveLineBreaks: true, + stripAgentOnlyBlocks: true, + }); }, [thought.text, thought.taskRefs, memberColorMap, teamNames]); const handleTaskLinkClick = useCallback( diff --git a/src/renderer/components/team/activity/activityMarkdown.ts b/src/renderer/components/team/activity/activityMarkdown.ts new file mode 100644 index 00000000..02776c70 --- /dev/null +++ b/src/renderer/components/team/activity/activityMarkdown.ts @@ -0,0 +1,36 @@ +import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; +import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils'; +import { stripAgentBlocks } from '@shared/constants/agentBlocks'; +import { stripTeammateMessageBlocks } from '@shared/utils/inboxNoise'; + +import type { InboxMessage } from '@shared/types'; + +interface ThoughtDisplayContentOptions { + preserveLineBreaks?: boolean; + stripAgentOnlyBlocks?: boolean; +} + +export function buildThoughtDisplayContent( + thought: Pick, + memberColorMap?: ReadonlyMap, + teamNames: string[] = [], + options: ThoughtDisplayContentOptions = {} +): string { + const { preserveLineBreaks = true, stripAgentOnlyBlocks = false } = options; + let text = stripTeammateMessageBlocks(thought.text); + if (stripAgentOnlyBlocks) { + text = stripAgentBlocks(text); + } + if (preserveLineBreaks) { + text = text.replace(/\n/g, ' \n'); + } + text = linkifyTaskIdsInMarkdown(text, thought.taskRefs); + if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) { + text = linkifyAllMentionsInMarkdown( + text, + (memberColorMap ?? new Map()) as Map, + teamNames + ); + } + return text; +} diff --git a/test/renderer/components/team/activity/ActivityItem.test.ts b/test/renderer/components/team/activity/ActivityItem.test.ts index 5bce9077..64205bcc 100644 --- a/test/renderer/components/team/activity/ActivityItem.test.ts +++ b/test/renderer/components/team/activity/ActivityItem.test.ts @@ -7,6 +7,8 @@ vi.mock('@renderer/hooks/useTheme', () => ({ })); vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({ MarkdownViewer: ({ content }: { content: string }) => React.createElement('div', null, content), + CompactMarkdownPreview: ({ content, className }: { content: string; className?: string }) => + React.createElement('div', { className }, content), })); vi.mock('@renderer/components/common/CopyButton', () => ({ CopyButton: () => null, @@ -175,7 +177,9 @@ describe('ActivityItem compact header preview', () => { const preview = host.querySelector('.line-clamp-2'); expect(preview).not.toBeNull(); - expect(preview?.textContent).toBe(visibleText); + expect(preview?.textContent).toContain('**New task assigned to you:**'); + expect(preview?.textContent).toContain('[#3fd70e2](task://3fd70e2)'); + expect(preview?.textContent).toContain('Собрать fix-batch'); expect(preview?.textContent).not.toContain('info_for_agent'); expect(preview?.textContent).not.toContain('internal only'); @@ -185,6 +189,49 @@ describe('ActivityItem compact header preview', () => { }); }); + it('reuses markdown display content for compact preview formatting', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const markdownText = '**Важно** проверить `CurrentTaskIndicator` и #abc123'; + + const message: InboxMessage = { + from: 'team-lead', + text: markdownText, + timestamp: new Date('2026-04-18T16:31:00.000Z').toISOString(), + read: true, + source: 'lead_process', + taskRefs: [{ taskId: 'abc123', displayId: '#abc123', teamName: 'my-team' }], + }; + + await act(async () => { + root.render( + React.createElement(ActivityItem, { + message, + teamName: 'my-team', + compactHeader: true, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + collapseToggleKey: 'message-key-markdown-preview', + }) + ); + await Promise.resolve(); + }); + + const preview = host.querySelector('.line-clamp-2'); + expect(preview).not.toBeNull(); + expect(preview?.textContent).toContain('**Важно**'); + expect(preview?.textContent).toContain('task://abc123'); + expect(preview?.textContent).toContain('`CurrentTaskIndicator`'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('uses a two-line preview in collapsed wide mode, not inline one-line summary', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); diff --git a/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts b/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts index 1dd46a44..0960a38b 100644 --- a/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts +++ b/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts @@ -6,6 +6,10 @@ import { afterEach, beforeEach, vi } from 'vitest'; vi.mock('@renderer/components/team/MemberBadge', () => ({ MemberBadge: ({ name }: { name: string }) => React.createElement('span', null, name), })); +vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({ + CompactMarkdownPreview: ({ content, className }: { content: string; className?: string }) => + React.createElement('div', { className }, content), +})); vi.mock('@renderer/components/ui/tooltip', () => ({ TooltipProvider: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), Tooltip: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), @@ -320,4 +324,41 @@ describe('LeadThoughtsGroup', () => { await Promise.resolve(); }); }); + + it('reuses the expanded thought markdown preprocessing for compact preview', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const thought = makeLeadSessionMsg('**Важно** проверить #task123 и ping @alice', { + messageId: 'thought-4', + leadSessionId: 'lead-session-4', + taskRefs: [{ taskId: 'task123', displayId: '#task123', teamName: 'my-team' }], + }); + + await act(async () => { + root.render( + React.createElement(LeadThoughtsGroupRow, { + group: { type: 'lead-thoughts', thoughts: [thought] }, + collapseMode: 'managed', + isCollapsed: true, + canToggleCollapse: true, + compactHeader: true, + memberColorMap: new Map([['alice', 'blue']]), + teamNames: ['my-team'], + }) + ); + await Promise.resolve(); + }); + + const previewNode = host.querySelector('.line-clamp-2'); + expect(previewNode).not.toBeNull(); + expect(previewNode?.textContent).toContain('**Важно**'); + expect(previewNode?.textContent).toContain('[#task123](task://task123)'); + expect(previewNode?.textContent).toContain('mention://blue/alice'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); From 61556a5a7750128c7208f3896913d4a537f59516 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 18 Apr 2026 22:28:53 +0300 Subject: [PATCH 21/21] fix(ci): restore checks and lead model selection --- src/features/agent-graph/renderer/ui/GraphActivityCard.tsx | 2 +- src/features/agent-graph/renderer/ui/GraphActivityHud.tsx | 1 + .../renderer/hooks/useRecentProjectsSection.ts | 2 +- src/main/ipc/teams.ts | 6 +++--- src/renderer/components/team/TeamDetailView.tsx | 2 +- .../components/team/activity/LeadThoughtsGroup.tsx | 4 ++-- src/renderer/components/team/dialogs/CreateTeamDialog.tsx | 3 +-- src/renderer/components/team/dialogs/LaunchTeamDialog.tsx | 3 +-- .../components/team/kanban/KanbanTaskCard.test.tsx | 1 + .../components/team/members/MemberDetailDialog.tsx | 4 ++-- src/renderer/components/team/members/MemberList.tsx | 2 +- src/renderer/store/slices/teamSlice.ts | 4 ++-- src/renderer/utils/teamModelAvailability.ts | 2 +- src/renderer/utils/teamModelCatalog.ts | 2 +- src/shared/types/api.ts | 2 +- test/main/ipc/teams.test.ts | 1 + .../services/team/TeamProvisioningServicePrepare.test.ts | 7 ++++++- 17 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/features/agent-graph/renderer/ui/GraphActivityCard.tsx b/src/features/agent-graph/renderer/ui/GraphActivityCard.tsx index e75f5696..363cc711 100644 --- a/src/features/agent-graph/renderer/ui/GraphActivityCard.tsx +++ b/src/features/agent-graph/renderer/ui/GraphActivityCard.tsx @@ -1,7 +1,7 @@ import { ActivityItem } from '@renderer/components/team/activity/ActivityItem'; import { - resolveMessageRenderProps, type MessageContext, + resolveMessageRenderProps, } from '@renderer/components/team/activity/activityMessageContext'; import type { diff --git a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx index e2a5b82f..4e31fec3 100644 --- a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx @@ -13,6 +13,7 @@ import { type InlineActivityEntry, } from '../../core/domain/buildInlineActivityEntries'; import { useGraphActivityContext } from '../hooks/useGraphActivityContext'; + import { GraphActivityCard } from './GraphActivityCard'; import type { GraphNode } from '@claude-teams/agent-graph'; diff --git a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts index 1166dcf6..79ae9ce3 100644 --- a/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts +++ b/src/features/recent-projects/renderer/hooks/useRecentProjectsSection.ts @@ -8,11 +8,11 @@ import { buildTaskCountsByProject } from '@renderer/utils/pathNormalize'; import { useShallow } from 'zustand/react/shallow'; import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter'; +import { buildActiveTeamsByProject } from '../utils/activeProjectTeams'; import { sortRecentProjectsByDisplayPriority, subscribeRecentProjectOpenHistory, } from '../utils/recentProjectOpenHistory'; -import { buildActiveTeamsByProject } from '../utils/activeProjectTeams'; import { getRecentProjectsClientSnapshot, loadRecentProjectsWithClientCache, diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 8f8c1fde..ca2d6dbb 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -16,9 +16,9 @@ import { TEAM_DELETE_DRAFT, TEAM_DELETE_TASK_ATTACHMENT, TEAM_DELETE_TEAM, + TEAM_GET_AGENT_RUNTIME, TEAM_GET_ALL_TASKS, TEAM_GET_ATTACHMENTS, - TEAM_GET_AGENT_RUNTIME, TEAM_GET_CLAUDE_LOGS, TEAM_GET_DATA, TEAM_GET_DELETED_TASKS, @@ -60,8 +60,8 @@ import { TEAM_SEND_MESSAGE, TEAM_SET_CHANGE_PRESENCE_TRACKING, TEAM_SET_PROJECT_BRANCH_TRACKING, - TEAM_SET_TASK_LOG_STREAM_TRACKING, TEAM_SET_TASK_CLARIFICATION, + TEAM_SET_TASK_LOG_STREAM_TRACKING, TEAM_SET_TOOL_ACTIVITY_TRACKING, TEAM_SHOW_MESSAGE_NOTIFICATION, TEAM_SOFT_DELETE_TASK, @@ -167,7 +167,6 @@ import type { LeadContextUsageSnapshot, MemberFullStats, MemberLogSummary, - TeamAgentRuntimeSnapshot, MemberSpawnStatusesSnapshot, MessagesPage, SendMessageRequest, @@ -175,6 +174,7 @@ import type { TaskAttachmentMeta, TaskComment, TaskRef, + TeamAgentRuntimeSnapshot, TeamClaudeLogsQuery, TeamClaudeLogsResponse, TeamConfig, diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index bb4ae753..68d73349 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -122,10 +122,10 @@ import type { ContextInjection } from '@renderer/types/contextInjection'; import type { Session } from '@renderer/types/data'; import type { InlineChip } from '@renderer/types/inlineChip'; import type { - TeamAgentRuntimeEntry, MemberSpawnStatusEntry, ResolvedTeamMember, TaskRef, + TeamAgentRuntimeEntry, TeamTaskWithKanban, } from '@shared/types'; import type { EditorSelectionAction } from '@shared/types/editor'; diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 0b8df55c..9ee1adc1 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -32,21 +32,21 @@ import { areThoughtMessagesEquivalentForRender, } from '@renderer/utils/messageRenderEquality'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; +import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { isApiErrorMessage } from '@shared/utils/apiErrorDetector'; import { isThoughtProtocolNoise } from '@shared/utils/inboxNoise'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; import { ChevronDown, ChevronRight, ChevronUp, Maximize2 } from 'lucide-react'; +import { buildThoughtDisplayContent } from './activityMarkdown'; import { AnimatedHeightReveal, ENTRY_REVEAL_ANIMATION_MS, ENTRY_REVEAL_EASING, } from './AnimatedHeightReveal'; -import { buildThoughtDisplayContent } from './activityMarkdown'; import { ThoughtBodyContent } from './ThoughtBodyContent'; -import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import type { InboxMessage, ToolCallMeta } from '@shared/types'; export interface LeadThoughtGroup { diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index a510bd4c..f6216327 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -44,7 +44,6 @@ import { normalizePath } from '@renderer/utils/pathNormalize'; import { getTeamModelSelectionError, normalizeExplicitTeamModelForUi, - normalizeTeamModelForUi, } from '@renderer/utils/teamModelAvailability'; import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; @@ -400,7 +399,7 @@ export const CreateTeamDialog = ({ }, [advancedKey]); const setSelectedModel = (value: string): void => { - const normalizedValue = normalizeTeamModelForUi(selectedProviderId, value); + const normalizedValue = normalizeExplicitTeamModelForUi(selectedProviderId, value); setSelectedModelRaw(normalizedValue); localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, normalizedValue); }; diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index be3fe54d..7574de53 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -49,7 +49,6 @@ import { nameColorSet } from '@renderer/utils/projectColor'; import { getTeamModelSelectionError, normalizeExplicitTeamModelForUi, - normalizeTeamModelForUi, } from '@renderer/utils/teamModelAvailability'; import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog'; import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; @@ -462,7 +461,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen }; const setSelectedModel = (value: string): void => { - const normalizedValue = normalizeTeamModelForUi(selectedProviderId, value); + const normalizedValue = normalizeExplicitTeamModelForUi(selectedProviderId, value); setSelectedModelRaw(normalizedValue); localStorage.setItem(`team:lastSelectedModel:${selectedProviderId}`, normalizedValue); }; diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx index 4867111d..908c7c35 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx @@ -1,5 +1,6 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; + import { afterEach, describe, expect, it, vi } from 'vitest'; vi.mock('@renderer/components/team/MemberBadge', () => ({ diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index ac9b5147..8d699053 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -27,14 +27,14 @@ import { MemberMessagesTab } from './MemberMessagesTab'; import { MemberStatsTab } from './MemberStatsTab'; import { MemberTasksTab } from './MemberTasksTab'; +import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; import type { LeadActivityState, MemberSpawnStatusEntry, - TeamAgentRuntimeEntry, ResolvedTeamMember, + TeamAgentRuntimeEntry, TeamTaskWithKanban, } from '@shared/types'; -import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; interface MemberDetailDialogProps { open: boolean; diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 06ede4cf..ece0043c 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -10,9 +10,9 @@ import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; import type { LeadActivityState, - TeamAgentRuntimeEntry, MemberSpawnStatusEntry, ResolvedTeamMember, + TeamAgentRuntimeEntry, TeamTaskWithKanban, } from '@shared/types'; diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 200fa236..0b249482 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -38,8 +38,6 @@ import type { LeadActivityState, LeadContextUsage, MemberActivityMetaEntry, - TeamAgentRuntimeEntry, - TeamAgentRuntimeSnapshot, MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, PersistedTeamLaunchSummary, @@ -48,6 +46,8 @@ import type { SendMessageResult, TaskChangePresenceState, TaskComment, + TeamAgentRuntimeEntry, + TeamAgentRuntimeSnapshot, TeamCreateRequest, TeamLaunchRequest, TeamMemberActivityMeta, diff --git a/src/renderer/utils/teamModelAvailability.ts b/src/renderer/utils/teamModelAvailability.ts index cb64debe..585ec2d5 100644 --- a/src/renderer/utils/teamModelAvailability.ts +++ b/src/renderer/utils/teamModelAvailability.ts @@ -1,6 +1,5 @@ import { getProviderScopedTeamModelLabel, - isSupportedAnthropicTeamModel, getRuntimeAwareTeamModelUiDisabledReason, getTeamProviderLabel, getTeamProviderModelOptions, @@ -12,6 +11,7 @@ import { GPT_5_2_CODEX_UI_DISABLED_REASON, GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL, GPT_5_3_CODEX_SPARK_UI_DISABLED_REASON, + isSupportedAnthropicTeamModel, normalizeTeamModelForUi as normalizeCatalogTeamModelForUi, sortTeamProviderModels, TEAM_MODEL_UI_DISABLED_BADGE_LABEL, diff --git a/src/renderer/utils/teamModelCatalog.ts b/src/renderer/utils/teamModelCatalog.ts index f51c161a..4f593561 100644 --- a/src/renderer/utils/teamModelCatalog.ts +++ b/src/renderer/utils/teamModelCatalog.ts @@ -1,10 +1,10 @@ +import { parseModelString } from '@shared/utils/modelParser'; import { filterVisibleProviderRuntimeModels, GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, GPT_5_2_CODEX_UI_DISABLED_MODEL, GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL, } from '@shared/utils/providerModelVisibility'; -import { parseModelString } from '@shared/utils/modelParser'; import type { CliProviderId, CliProviderStatus, TeamProviderId } from '@shared/types'; diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index dc101003..0a9ca719 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -56,7 +56,6 @@ import type { LeadContextUsageSnapshot, MemberFullStats, MemberLogSummary, - TeamAgentRuntimeSnapshot, MemberSpawnStatusesSnapshot, MessagesPage, ProjectBranchChangeEvent, @@ -66,6 +65,7 @@ import type { TaskAttachmentMeta, TaskChangePresenceState, TaskComment, + TeamAgentRuntimeSnapshot, TeamChangeEvent, TeamClaudeLogsQuery, TeamClaudeLogsResponse, diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index ffa52d66..d7f68173 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -271,6 +271,7 @@ describe('ipc teams handlers', () => { undefined, undefined, undefined, + undefined, boardTaskActivityService as never, boardTaskActivityDetailService as never, boardTaskLogStreamService as never, diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 0addd0bb..dfefce73 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -167,7 +167,12 @@ describe('TeamProvisioningService prepare/auth behavior', () => { }); afterEach(() => { - fs.rmSync(tempRoot, { recursive: true, force: true }); + fs.rmSync(tempRoot, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 200, + }); }); it('does not create missing directories during prepareForProvisioning', async () => {