diff --git a/README.md b/README.md index f90d0989..94ab9335 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ https://github.com/user-attachments/assets/35e27989-726d-4059-8662-bae610e46b42 ## Installation No prerequisites - the app can detect supported runtimes/providers and guide setup from the UI. +If you want the freshest version, clone the repo and run it from the `dev` branch. diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 5e02090e..f05a0e5a 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -99,18 +99,19 @@ import * as path from 'path'; import { ConfigManager } from '../services/infrastructure/ConfigManager'; import { NotificationManager } from '../services/infrastructure/NotificationManager'; import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver'; -import { - getAutoResumeService, - initializeAutoResumeService, -} from '../services/team/AutoResumeService'; import { buildActionModeAgentBlock, isAgentActionMode, } from '../services/team/actionModeInstructions'; +import { + getAutoResumeService, + initializeAutoResumeService, +} from '../services/team/AutoResumeService'; import { buildReplaceMembersDiff, buildReplaceMembersSummaryMessage, } from '../services/team/memberUpdateNotifications'; +import { mergeLiveLeadProcessMessages } from '../services/team/mergeLiveLeadProcessMessages'; import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore'; import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore'; import { TeamMetaStore } from '../services/team/TeamMetaStore'; @@ -156,11 +157,9 @@ import type { IpcResult, KanbanColumnId, LeadActivitySnapshot, - LeadContextUsage, LeadContextUsageSnapshot, MemberFullStats, MemberLogSummary, - MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, MessagesPage, SendMessageRequest, @@ -823,82 +822,23 @@ async function handleGetData( 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)); + let merged = mergeLiveLeadProcessMessages(data.messages, live); + if (data.messages.length >= 50) { + try { + const newestPage = await teamDataService.getMessagesPage(tn, { + limit: 50, + liveMessages: live, + }); + merged = newestPage.messages; + } catch (error) { + logger.warn( + `[teams:getData] failed to rebuild newest merged messages for ${tn}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } } - 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, isAlive, currentLeadSessionId); checkApiErrorMessages(merged, tn, displayName, projectPath); return { success: true, data: { ...data, isAlive, messages: merged } }; @@ -1789,7 +1729,10 @@ async function handleGetMessagesPage( return wrapTeamHandler('getMessagesPage', async () => { const service = getTeamDataService(); - return service.getMessagesPage(vTeam.value!, { beforeTimestamp, limit }); + const liveMessages = beforeTimestamp + ? undefined + : getTeamProvisioningService().getLiveLeadProcessMessages(vTeam.value!); + return service.getMessagesPage(vTeam.value!, { beforeTimestamp, limit, liveMessages }); }); } diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 307cfa67..f1d8a119 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -1,12 +1,5 @@ import { yieldToEventLoop } from '@main/utils/asyncYield'; -import { - encodePath, - extractBaseDir, - getClaudeBasePath, - getProjectsBasePath, - getTasksBasePath, - getTeamsBasePath, -} from '@main/utils/pathDecoder'; +import { getClaudeBasePath, getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder'; import { killProcessByPid } from '@main/utils/processKill'; import { AGENT_BLOCK_CLOSE, @@ -16,7 +9,7 @@ import { } from '@shared/constants/agentBlocks'; import { getMemberColorByName } from '@shared/constants/memberColors'; import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics'; -import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; +import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState'; import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands'; @@ -39,6 +32,10 @@ import { } from './cache/LeadSessionParseCache'; import { atomicWriteAsync } from './atomicWrite'; import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor'; +import { + getLiveLeadProcessMessageKey, + mergeLiveLeadProcessMessages, +} from './mergeLiveLeadProcessMessages'; import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils'; import { TeamConfigReader } from './TeamConfigReader'; import { TeamInboxReader } from './TeamInboxReader'; @@ -52,6 +49,7 @@ import { TeamSentMessagesStore } from './TeamSentMessagesStore'; import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal'; import { TeamTaskReader } from './TeamTaskReader'; import { TeamTaskWriter } from './TeamTaskWriter'; +import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver'; import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes'; import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository'; @@ -182,7 +180,10 @@ export class TeamDataService { private readonly taskCommentNotificationJournal: TeamTaskCommentNotificationJournal = new TeamTaskCommentNotificationJournal(), private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(), private memberRuntimeAdvisoryService: TeamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(), - private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache() + private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache(), + private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver( + configReader + ) ) {} private getController(teamName: string): AgentTeamsController { @@ -769,7 +770,7 @@ export class TeamDataService { label: 'leadTexts', createFallback: () => [], warningText: 'Lead session texts failed to load', - load: () => this.extractLeadSessionTexts(config), + load: () => this.extractLeadSessionTexts(teamName, config), }) ); @@ -1113,7 +1114,7 @@ export class TeamDataService { */ async getMessagesPage( teamName: string, - options: { beforeTimestamp?: string; limit: number } + options: { beforeTimestamp?: string; limit: number; liveMessages?: InboxMessage[] } ): Promise { const config = await this.configReader.getConfig(teamName); if (!config) { @@ -1125,7 +1126,7 @@ export class TeamDataService { const [inboxMessages, leadTexts, sentMessages] = await Promise.all([ this.inboxReader.getMessages(teamName).catch(() => [] as InboxMessage[]), - this.extractLeadSessionTexts(config).catch(() => [] as InboxMessage[]), + this.extractLeadSessionTexts(teamName, config).catch(() => [] as InboxMessage[]), this.sentMessagesStore.readMessages(teamName).catch(() => [] as InboxMessage[]), ]); @@ -1190,6 +1191,11 @@ export class TeamDataService { return (a.messageId ?? '').localeCompare(b.messageId ?? ''); }); + const newestDurableMessages = messages; + const durableMessageIndexByKey = new Map( + newestDurableMessages.map((message, index) => [getLiveLeadProcessMessageKey(message), index]) + ); + // Apply cursor filter. Cursor format: "timestamp|messageId" (compound) // to handle multiple messages sharing the same timestamp. if (options.beforeTimestamp) { @@ -1212,7 +1218,54 @@ export class TeamDataService { const nextCursor = hasMore && lastMsg ? `${lastMsg.timestamp}|${lastMsg.messageId ?? ''}` : null; - return { messages: page, nextCursor, hasMore }; + if (options.beforeTimestamp || !options.liveMessages?.length) { + return { messages: page, nextCursor, hasMore }; + } + + // Merge live lead thoughts against the full durable newest-page history so we do not + // re-introduce persisted thoughts that have simply paged off the first durable page. + const displayMessages = mergeLiveLeadProcessMessages( + newestDurableMessages, + options.liveMessages + ).slice(0, options.limit); + + if (displayMessages.length === 0) { + return { messages: displayMessages, nextCursor: null, hasMore: false }; + } + + let lastDurableDisplayed: InboxMessage | null = null; + for (let index = displayMessages.length - 1; index >= 0; index -= 1) { + const candidate = displayMessages[index]; + if (durableMessageIndexByKey.has(getLiveLeadProcessMessageKey(candidate))) { + lastDurableDisplayed = candidate; + break; + } + } + + if (!lastDurableDisplayed) { + const boundary = displayMessages[displayMessages.length - 1]; + return { + messages: displayMessages, + nextCursor: + newestDurableMessages.length > 0 + ? `${boundary.timestamp}|${boundary.messageId ?? ''}` + : null, + hasMore: newestDurableMessages.length > 0, + }; + } + + const durableIndex = + durableMessageIndexByKey.get(getLiveLeadProcessMessageKey(lastDurableDisplayed)) ?? + Number.POSITIVE_INFINITY; + const durableHasMore = durableIndex < newestDurableMessages.length - 1; + + return { + messages: displayMessages, + nextCursor: durableHasMore + ? `${lastDurableDisplayed.timestamp}|${lastDurableDisplayed.messageId ?? ''}` + : null, + hasMore: durableHasMore, + }; } /** @@ -2614,37 +2667,20 @@ export class TeamDataService { } } - private getLeadProjectDirCandidates(projectPath: string): string[] { - const projectId = encodePath(projectPath); - const baseDir = extractBaseDir(projectId); - const candidateDirs = [ - path.join(getProjectsBasePath(), baseDir), - // Claude Code encodes underscores as hyphens in project directory names; - // our encodePath only handles slashes. Try the underscore-to-hyphen variant. - ...(baseDir.includes('_') - ? [path.join(getProjectsBasePath(), baseDir.replace(/_/g, '-'))] - : []), - ]; - - return [...new Set(candidateDirs)]; - } - - private async getLeadSessionJsonlPaths(projectPath: string): Promise> { + private async getLeadSessionJsonlPaths(projectDir: string): Promise> { const jsonlPaths = new Map(); - for (const dirPath of this.getLeadProjectDirCandidates(projectPath)) { - let entries: fs.Dirent[]; - try { - entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); - } catch { - continue; - } + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(projectDir, { withFileTypes: true }); + } catch { + return jsonlPaths; + } - for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue; - const sessionId = entry.name.slice(0, -'.jsonl'.length).trim(); - if (!sessionId || jsonlPaths.has(sessionId)) continue; - jsonlPaths.set(sessionId, path.join(dirPath, entry.name)); - } + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue; + const sessionId = entry.name.slice(0, -'.jsonl'.length).trim(); + if (!sessionId || jsonlPaths.has(sessionId)) continue; + jsonlPaths.set(sessionId, path.join(projectDir, entry.name)); } return jsonlPaths; @@ -2890,17 +2926,23 @@ export class TeamDataService { } } - private async extractLeadSessionTexts(config: TeamConfig): Promise { - if (!config.projectPath) { + private async extractLeadSessionTexts( + teamName: string, + config: TeamConfig + ): Promise { + const transcriptContext = await this.projectResolver.getContext(teamName); + if (!transcriptContext) { return []; } - - const leadName = config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead'; - const sessionIds = this.getRecentLeadSessionIds(config); + const leadName = + transcriptContext.config.members?.find((m) => isLeadMember(m))?.name ?? 'team-lead'; + const sessionIds = Array.from( + new Set([...this.getRecentLeadSessionIds(config), ...transcriptContext.sessionIds]) + ); if (sessionIds.length === 0) { return []; } - const availableJsonlPaths = await this.getLeadSessionJsonlPaths(config.projectPath); + const availableJsonlPaths = await this.getLeadSessionJsonlPaths(transcriptContext.projectDir); if (availableJsonlPaths.size === 0) { return []; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 98c336b3..7204faa5 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -3109,17 +3109,14 @@ export class TeamProvisioningService { } getLiveLeadProcessMessages(teamName: string): InboxMessage[] { - const list = this.liveLeadProcessMessages.get(teamName) ?? []; const runId = this.getTrackedRunId(teamName); - const sessionId = runId ? this.runs.get(runId)?.detectedSessionId : null; - if (sessionId) { - for (const message of list) { - if (!message.leadSessionId && message.source === 'lead_process') { - message.leadSessionId = sessionId; - } - } - } - return [...list]; + const detectedSessionId = runId ? (this.runs.get(runId)?.detectedSessionId ?? null) : null; + + return (this.liveLeadProcessMessages.get(teamName) ?? []).map((message) => + !message.leadSessionId && detectedSessionId + ? { ...message, leadSessionId: detectedSessionId } + : { ...message } + ); } private pruneLiveLeadMessagesForCleanedRun(run: ProvisioningRun): void { diff --git a/src/main/services/team/TeamTranscriptProjectResolver.ts b/src/main/services/team/TeamTranscriptProjectResolver.ts index 4099ce1d..5bbe4edd 100644 --- a/src/main/services/team/TeamTranscriptProjectResolver.ts +++ b/src/main/services/team/TeamTranscriptProjectResolver.ts @@ -1,4 +1,12 @@ -import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder'; +import { extractCwd } from '@main/utils/jsonl'; +import { + encodePath, + extractBaseDir, + getProjectsBasePath, + getTeamsBasePath, +} from '@main/utils/pathDecoder'; +import { atomicWriteAsync } from '@main/utils/atomicWrite'; +import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { createReadStream, type Dirent } from 'fs'; import * as fs from 'fs/promises'; @@ -15,6 +23,33 @@ const SESSION_DISCOVERY_CACHE_TTL = 30_000; const TEAM_AFFINITY_SCAN_LINES = 40; const ROOT_DISCOVERY_CONCURRENCY = 12; +type ProjectEvidenceSource = + | 'projectPath' + | 'projectPathHistory' + | 'leadCwd' + | 'memberCwd' + | 'projectsScan'; + +interface ProjectPathCandidate { + projectPath: string; + source: Exclude; +} + +interface ProjectDirCandidate { + projectPath: string; + projectDir: string; + projectId: string; + source: ProjectEvidenceSource; +} + +interface SessionProjectMatch extends ProjectDirCandidate { + matchedSessionId: string; +} + +type ScannedSessionProjectMatch = Omit & { + projectPath?: string; +}; + function trimTrailingSlashes(value: string): string { let end = value.length; while (end > 0) { @@ -32,6 +67,17 @@ function isSessionDirectoryName(name: string): boolean { return name !== 'memory' && !name.startsWith('.'); } +function normalizeProjectPathCandidate(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + return trimTrailingSlashes(trimmed); +} + function extractTextContent(entry: Record): string | null { if (typeof entry.content === 'string') { return entry.content; @@ -71,6 +117,9 @@ function lineMentionsTeam(text: string, teamName: string): boolean { return false; } return ( + normalizedText.includes(`team name: ${normalizedTeam}`) || + normalizedText.includes(`team name "${normalizedTeam}"`) || + normalizedText.includes(`team name '${normalizedTeam}'`) || normalizedText.includes(`on team "${normalizedTeam}"`) || normalizedText.includes(`on team '${normalizedTeam}'`) || normalizedText.includes(`team "${normalizedTeam}"`) || @@ -79,6 +128,28 @@ function lineMentionsTeam(text: string, teamName: string): boolean { ); } +function entryContainsNestedTeamName(value: unknown, teamName: string, depth: number = 0): boolean { + if (!value || depth > 8 || typeof value !== 'object') { + return false; + } + + if (Array.isArray(value)) { + return value.some((item) => entryContainsNestedTeamName(item, teamName, depth + 1)); + } + + const entry = value as Record; + if (typeof entry.teamName === 'string' && entry.teamName.trim().toLowerCase() === teamName) { + return true; + } + + return Object.entries(entry).some(([key, nested]) => { + if (key === 'teamName') { + return false; + } + return entryContainsNestedTeamName(nested, teamName, depth + 1); + }); +} + function collectKnownSessionIds(config: TeamConfig): string[] { const knownSessionIds = new Set(); const push = (value: unknown): void => { @@ -93,7 +164,8 @@ function collectKnownSessionIds(config: TeamConfig): string[] { push(config.leadSessionId); if (Array.isArray(config.sessionHistory)) { - for (const sessionId of config.sessionHistory) { + for (let index = config.sessionHistory.length - 1; index >= 0; index -= 1) { + const sessionId = config.sessionHistory[index]; push(sessionId); } } @@ -130,13 +202,39 @@ export class TeamTranscriptProjectResolver { } const config = await this.configReader.getConfig(teamName); - if (!config?.projectPath) { + if (!config) { return null; } - const { projectDir, projectId } = await this.resolveProjectDirectory(config); - const sessionIds = await this.discoverSessionIds(teamName, projectDir, config); - const value = { projectDir, projectId, config, sessionIds }; + const resolution = await this.resolveProjectDirectory(teamName, config); + if (!resolution) { + return null; + } + + const resolvedConfig = + resolution.effectiveProjectPath && + trimTrailingSlashes(resolution.effectiveProjectPath) !== + trimTrailingSlashes(config.projectPath ?? '') + ? { + ...config, + projectPath: resolution.effectiveProjectPath, + projectPathHistory: this.buildRepairedProjectPathHistory( + config, + resolution.effectiveProjectPath + ), + } + : config; + const sessionIds = await this.discoverSessionIds( + teamName, + resolution.projectDir, + resolvedConfig + ); + const value = { + projectDir: resolution.projectDir, + projectId: resolution.projectId, + config: resolvedConfig, + sessionIds, + }; this.contextCache.set(teamName, { value, expiresAt: Date.now() + SESSION_DISCOVERY_CACHE_TTL, @@ -145,47 +243,391 @@ export class TeamTranscriptProjectResolver { } private async resolveProjectDirectory( + teamName: string, config: TeamConfig - ): Promise<{ projectDir: string; projectId: string }> { - const normalizedProjectPath = trimTrailingSlashes(config.projectPath ?? ''); - let projectId = encodePath(normalizedProjectPath); - let projectDir = path.join(getProjectsBasePath(), extractBaseDir(projectId)); + ): Promise<{ projectDir: string; projectId: string; effectiveProjectPath?: string } | null> { + const sessionIds = collectKnownSessionIds(config); + const pathCandidates = this.collectProjectPathCandidates(config); + const currentCandidate = pathCandidates[0] ?? null; + if (sessionIds.length === 0) { + return this.buildFallbackResolution(teamName, pathCandidates); + } - 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 }; - } + const rankBySessionId = new Map(sessionIds.map((sessionId, index) => [sessionId, index])); + const getMatchRank = (match: { matchedSessionId: string } | null): number => + match + ? (rankBySessionId.get(match.matchedSessionId) ?? Number.POSITIVE_INFINITY) + : Number.POSITIVE_INFINITY; - 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 toResolution = ( + match: Pick & { projectPath?: string } + ): { projectDir: string; projectId: string; effectiveProjectPath?: string } => ({ + projectDir: match.projectDir, + projectId: match.projectId, + ...(match.projectPath ? { effectiveProjectPath: match.projectPath } : {}), + }); + + let currentMatch: SessionProjectMatch | null = null; + if (currentCandidate) { + const resolvedCurrentMatch = await this.findMatchInProjectPathCandidate( + currentCandidate, + sessionIds + ); + if (resolvedCurrentMatch && getMatchRank(resolvedCurrentMatch) === 0) { + return toResolution(resolvedCurrentMatch); + } + if (resolvedCurrentMatch) { + currentMatch = resolvedCurrentMatch; } } - return { projectDir, projectId }; + const configuredMatches = + pathCandidates.length > 1 + ? await this.findMatchesInProjectPathCandidates(pathCandidates.slice(1), sessionIds) + : []; + const scannedMatches = await this.findMatchesByScanningProjects(sessionIds); + + const candidateMatchesByProjectDir = new Map< + string, + SessionProjectMatch | ScannedSessionProjectMatch + >(); + for (const match of configuredMatches) { + if (match.projectDir === currentMatch?.projectDir) { + continue; + } + candidateMatchesByProjectDir.set(match.projectDir, match); + } + for (const match of scannedMatches) { + if (match.projectDir === currentMatch?.projectDir) { + continue; + } + if (!candidateMatchesByProjectDir.has(match.projectDir)) { + candidateMatchesByProjectDir.set(match.projectDir, match); + } + } + + const alternateMatches = [...candidateMatchesByProjectDir.values()]; + const bestAlternateRank = alternateMatches.reduce( + (best, match) => Math.min(best, getMatchRank(match)), + Number.POSITIVE_INFINITY + ); + const currentRank = getMatchRank(currentMatch); + + if (currentMatch && currentRank <= bestAlternateRank) { + return toResolution(currentMatch); + } + + if (bestAlternateRank !== Number.POSITIVE_INFINITY) { + const bestAlternates = alternateMatches.filter( + (match) => getMatchRank(match) === bestAlternateRank + ); + if (bestAlternates.length === 1) { + const winner = bestAlternates[0]; + if (winner.projectPath) { + await this.persistResolvedProjectPath(teamName, config, winner.projectPath); + } + return toResolution(winner); + } + logger.warn( + `[${teamName}] Transcript project resolution ambiguous across exact-session candidates; keeping current path` + ); + return currentMatch + ? toResolution(currentMatch) + : this.buildFallbackResolution(teamName, pathCandidates); + } + + if (currentMatch) { + return toResolution(currentMatch); + } + + return this.buildFallbackResolution(teamName, pathCandidates); + } + + private async buildFallbackResolution( + teamName: string, + candidates: readonly ProjectPathCandidate[] + ): Promise<{ projectDir: string; projectId: string; effectiveProjectPath?: string } | null> { + let firstResolution: { + projectDir: string; + projectId: string; + effectiveProjectPath?: string; + } | null = null; + let firstExistingResolution: { + projectDir: string; + projectId: string; + effectiveProjectPath?: string; + } | null = null; + + for (const candidate of candidates) { + for (const dirCandidate of this.buildProjectDirCandidates(candidate.projectPath)) { + const resolution = { + projectDir: dirCandidate.projectDir, + projectId: dirCandidate.projectId, + effectiveProjectPath: candidate.projectPath, + }; + if (!firstResolution) { + firstResolution = resolution; + } + if (!(await this.projectDirExists(dirCandidate.projectDir))) { + continue; + } + if (!firstExistingResolution) { + firstExistingResolution = resolution; + } + const teamRootSessionIds = await this.listTeamRootSessionIds( + dirCandidate.projectDir, + teamName + ); + if (teamRootSessionIds.length > 0) { + return resolution; + } + } + } + + return firstExistingResolution ?? firstResolution; + } + + private collectProjectPathCandidates(config: TeamConfig): ProjectPathCandidate[] { + const candidates: ProjectPathCandidate[] = []; + const seen = new Set(); + const push = (value: unknown, source: Exclude): void => { + const normalized = normalizeProjectPathCandidate(value); + if (!normalized || seen.has(normalized)) { + return; + } + seen.add(normalized); + candidates.push({ projectPath: normalized, source }); + }; + + push(config.projectPath, 'projectPath'); + + if (Array.isArray(config.projectPathHistory)) { + for (let index = config.projectPathHistory.length - 1; index >= 0; index -= 1) { + push(config.projectPathHistory[index], 'projectPathHistory'); + } + } + + const leadCwd = (config.members ?? []).find((member) => isLeadMember(member))?.cwd; + push(leadCwd, 'leadCwd'); + + const distinctMemberCwds = Array.from( + new Set( + (config.members ?? []) + .map((member) => normalizeProjectPathCandidate(member.cwd)) + .filter((cwd): cwd is string => Boolean(cwd)) + ) + ); + if (distinctMemberCwds.length === 1) { + push(distinctMemberCwds[0], 'memberCwd'); + } + + return candidates; + } + + private buildProjectDirCandidates(projectPath: string): ProjectDirCandidate[] { + const normalizedProjectPath = trimTrailingSlashes(projectPath); + const projectId = extractBaseDir(encodePath(normalizedProjectPath)); + const baseCandidates = [ + { projectDir: path.join(getProjectsBasePath(), projectId), projectId }, + ...(projectId.includes('_') + ? [ + { + projectDir: path.join(getProjectsBasePath(), projectId.replace(/_/g, '-')), + projectId: projectId.replace(/_/g, '-'), + }, + ] + : []), + ]; + + const seen = new Set(); + return baseCandidates + .filter((candidate) => { + if (seen.has(candidate.projectDir)) { + return false; + } + seen.add(candidate.projectDir); + return true; + }) + .map((candidate) => ({ + projectPath: normalizedProjectPath, + projectDir: candidate.projectDir, + projectId: candidate.projectId, + source: 'projectPath' as const, + })); + } + + private async findMatchInProjectPathCandidate( + candidate: ProjectPathCandidate, + sessionIds: string[] + ): Promise { + const rankBySessionId = new Map(sessionIds.map((sessionId, index) => [sessionId, index])); + let bestMatch: SessionProjectMatch | null = null; + + for (const projectCandidate of this.buildProjectDirCandidates(candidate.projectPath)) { + const matchedSessionId = await this.findMatchingSessionId( + projectCandidate.projectDir, + sessionIds + ); + if (!matchedSessionId) { + continue; + } + const match = { + ...projectCandidate, + source: candidate.source, + matchedSessionId, + }; + const matchRank = rankBySessionId.get(match.matchedSessionId) ?? Number.POSITIVE_INFINITY; + const bestRank = bestMatch + ? (rankBySessionId.get(bestMatch.matchedSessionId) ?? Number.POSITIVE_INFINITY) + : Number.POSITIVE_INFINITY; + if (!bestMatch || matchRank < bestRank) { + bestMatch = match; + } + if (matchRank === 0) { + break; + } + } + return bestMatch; + } + + private async findMatchesInProjectPathCandidates( + candidates: ProjectPathCandidate[], + sessionIds: string[] + ): Promise { + const matches: SessionProjectMatch[] = []; + const seenProjectDirs = new Set(); + for (const candidate of candidates) { + const match = await this.findMatchInProjectPathCandidate(candidate, sessionIds); + if (!match || seenProjectDirs.has(match.projectDir)) { + continue; + } + seenProjectDirs.add(match.projectDir); + matches.push(match); + } + return matches; + } + + private async findMatchingSessionId( + projectDir: string, + sessionIds: string[] + ): Promise { + for (const sessionId of sessionIds) { + try { + const stat = await fs.stat(path.join(projectDir, `${sessionId}.jsonl`)); + if (stat.isFile()) { + return sessionId; + } + } catch { + // continue + } + } + return null; + } + + private async findMatchesByScanningProjects( + sessionIds: string[] + ): Promise { + let projectEntries: Dirent[]; + try { + projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true }); + } catch { + return []; + } + + const directories = projectEntries.filter((entry) => entry.isDirectory()); + const matches: ScannedSessionProjectMatch[] = []; + let nextIndex = 0; + + const worker = async (): Promise => { + while (nextIndex < directories.length) { + const index = nextIndex++; + const entry = directories[index]; + const projectDir = path.join(getProjectsBasePath(), entry.name); + const matchedSessionId = await this.findMatchingSessionId(projectDir, sessionIds); + if (!matchedSessionId) { + continue; + } + const jsonlPath = path.join(projectDir, `${matchedSessionId}.jsonl`); + const cwd = await extractCwd(jsonlPath); + matches.push({ + projectPath: cwd ?? undefined, + projectDir, + projectId: entry.name, + source: 'projectsScan', + matchedSessionId, + }); + } + }; + + await Promise.all( + Array.from({ length: Math.min(ROOT_DISCOVERY_CONCURRENCY, directories.length) }, () => + worker() + ) + ); + + const deduped = new Map(); + for (const match of matches) { + if (!deduped.has(match.projectDir)) { + deduped.set(match.projectDir, match); + } + } + return [...deduped.values()]; + } + + private async persistResolvedProjectPath( + teamName: string, + config: TeamConfig, + nextProjectPath: string + ): Promise { + const normalizedNextPath = normalizeProjectPathCandidate(nextProjectPath); + if (!normalizedNextPath) { + return; + } + + const currentProjectPath = normalizeProjectPathCandidate(config.projectPath); + if (currentProjectPath === normalizedNextPath) { + return; + } + + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + try { + const raw = await fs.readFile(configPath, 'utf8'); + const parsed = JSON.parse(raw) as Record; + const rawProjectPath = + normalizeProjectPathCandidate(parsed.projectPath) ?? currentProjectPath ?? null; + + parsed.projectPath = normalizedNextPath; + + const history: string[] = []; + const seen = new Set(); + const pushHistory = (value: unknown): void => { + const normalized = normalizeProjectPathCandidate(value); + if (!normalized || normalized === normalizedNextPath || seen.has(normalized)) { + return; + } + seen.add(normalized); + history.push(normalized); + }; + + if (Array.isArray(parsed.projectPathHistory)) { + for (const value of parsed.projectPathHistory) { + pushHistory(value); + } + } + pushHistory(rawProjectPath); + + parsed.projectPathHistory = history.slice(-500); + await atomicWriteAsync(configPath, JSON.stringify(parsed, null, 2)); + logger.info( + `[${teamName}] Repaired transcript projectPath via exact session match: ${normalizedNextPath}` + ); + } catch (error) { + logger.warn( + `[${teamName}] Failed to persist repaired transcript projectPath: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } } private async discoverSessionIds( @@ -199,9 +641,58 @@ export class TeamTranscriptProjectResolver { this.listSessionDirIds(projectDir), ]); - return Array.from(new Set([...knownSessionIds, ...teamRootSessionIds, ...sessionDirIds])).sort( - (left, right) => left.localeCompare(right) - ); + const orderedSessionIds: string[] = []; + const seen = new Set(); + const push = (sessionId: string): void => { + if (seen.has(sessionId)) { + return; + } + seen.add(sessionId); + orderedSessionIds.push(sessionId); + }; + + for (const sessionId of knownSessionIds) { + push(sessionId); + } + for (const sessionId of [...teamRootSessionIds, ...sessionDirIds].sort((left, right) => + left.localeCompare(right) + )) { + push(sessionId); + } + + return orderedSessionIds; + } + + private buildRepairedProjectPathHistory(config: TeamConfig, nextProjectPath: string): string[] { + const normalizedNextPath = normalizeProjectPathCandidate(nextProjectPath); + const history: string[] = []; + const seen = new Set(); + const pushHistory = (value: unknown): void => { + const normalized = normalizeProjectPathCandidate(value); + if (!normalized || normalized === normalizedNextPath || seen.has(normalized)) { + return; + } + seen.add(normalized); + history.push(normalized); + }; + + if (Array.isArray(config.projectPathHistory)) { + for (const value of config.projectPathHistory) { + pushHistory(value); + } + } + pushHistory(config.projectPath); + + return history.slice(-500); + } + + private async projectDirExists(projectDir: string): Promise { + try { + const stat = await fs.stat(projectDir); + return stat.isDirectory(); + } catch { + return false; + } } private async listSessionDirIds(projectDir: string): Promise { @@ -272,6 +763,9 @@ export class TeamTranscriptProjectResolver { if (directTeamName === normalizedTeam) { return true; } + if (entryContainsNestedTeamName(entry, normalizedTeam)) { + return true; + } const textContent = extractTextContent(entry); if (textContent && lineMentionsTeam(textContent, normalizedTeam)) { diff --git a/src/main/services/team/mergeLiveLeadProcessMessages.ts b/src/main/services/team/mergeLiveLeadProcessMessages.ts new file mode 100644 index 00000000..5b613caf --- /dev/null +++ b/src/main/services/team/mergeLiveLeadProcessMessages.ts @@ -0,0 +1,73 @@ +import type { InboxMessage } from '@shared/types'; + +export function getLiveLeadProcessMessageKey(message: { + messageId?: string; + timestamp: string; + from: string; + text: string; +}): string { + if (typeof message.messageId === 'string' && message.messageId.trim().length > 0) { + return message.messageId; + } + return `${message.timestamp}\0${message.from}\0${(message.text ?? '').slice(0, 80)}`; +} + +export function mergeLiveLeadProcessMessages( + durableMessages: InboxMessage[], + liveMessages: InboxMessage[] +): InboxMessage[] { + if (liveMessages.length === 0) { + return durableMessages; + } + + 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)}`; + + const existingTextFingerprints = new Set(); + for (const msg of durableMessages) { + if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue; + if (!isLeadThoughtLike(msg)) continue; + existingTextFingerprints.add(getLeadThoughtFingerprint(msg)); + } + + const leadProcessTextFingerprints = new Set(); + const contentSeen = new Map(); + const merged: InboxMessage[] = []; + const seen = new Set(); + + for (const msg of [...durableMessages, ...liveMessages]) { + if (msg.source === 'lead_process' && !msg.to) { + const fp = getLeadThoughtFingerprint(msg); + if (existingTextFingerprints.has(fp) || leadProcessTextFingerprints.has(fp)) { + continue; + } + leadProcessTextFingerprints.add(fp); + } + + 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; + } + contentSeen.set(contentFp, msgMs); + } + + const key = getLiveLeadProcessMessageKey(msg); + if (seen.has(key)) { + continue; + } + seen.add(key); + merged.push(msg); + } + + merged.sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp)); + return merged; +} diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 007c9e2b..45a3d2f7 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -7,6 +7,7 @@ import type { BoardTaskExactLogDetailResult, BoardTaskExactLogSummariesResponse, InboxMessage, + MessagesPage, TeamCreateRequest, TeamProvisioningProgress, } from '@shared/types/team'; @@ -80,6 +81,7 @@ import { TEAM_GET_TASK_EXACT_LOG_SUMMARIES, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, + TEAM_GET_MESSAGES_PAGE, TEAM_START_TASK, TEAM_UPDATE_CONFIG, TEAM_UPDATE_KANBAN, @@ -140,6 +142,11 @@ describe('ipc teams handlers', () => { kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, processes: [], })), + getMessagesPage: vi.fn(async (..._args: unknown[]): Promise => ({ + messages: [] as InboxMessage[], + nextCursor: null, + hasMore: false, + })), getTaskChangePresence: vi.fn(async () => ({ 'task-1': 'has_changes' })), reconcileTeamArtifacts: vi.fn(async () => undefined), setTaskChangePresenceTracking: vi.fn(() => undefined), @@ -1279,6 +1286,197 @@ describe('ipc teams handlers', () => { } }); + it('rebuilds capped newest messages through getMessagesPage so live duplicates do not leak back in', async () => { + service.getTeamData.mockResolvedValueOnce({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + messages: Array.from({ length: 50 }, (_, index) => ({ + from: 'alice', + text: `filler-${index}`, + timestamp: `2026-02-23T10:${String(index).padStart(2, '0')}:00.000Z`, + read: true, + source: 'inbox' as const, + messageId: `durable-${index}`, + })), + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }); + service.getMessagesPage.mockResolvedValueOnce({ + messages: [ + { + from: 'alice', + text: 'filler-0', + timestamp: '2026-02-23T10:00:00.000Z', + read: true, + source: 'inbox' as const, + messageId: 'durable-0', + }, + ], + nextCursor: null, + hasMore: false, + }); + provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([ + { + from: 'team-lead', + text: 'Already persisted thought', + timestamp: '2026-02-23T11:00:00.000Z', + read: true, + source: 'lead_process' as const, + messageId: 'live-dup', + leadSessionId: 'lead-1', + }, + ]); + + const getDataHandler = handlers.get(TEAM_GET_DATA)!; + const result = (await getDataHandler({} as never, 'my-team')) as { + success: boolean; + data: { messages: InboxMessage[] }; + }; + + expect(result.success).toBe(true); + expect(service.getMessagesPage).toHaveBeenCalledWith('my-team', { + limit: 50, + liveMessages: expect.arrayContaining([ + expect.objectContaining({ + messageId: 'live-dup', + source: 'lead_process', + }), + ]), + }); + expect(result.data.messages.map((message) => message.messageId)).toEqual(['durable-0']); + }); + + it('overlays live lead_process messages onto the newest messages page', async () => { + service.getMessagesPage.mockImplementationOnce(async (...args: unknown[]) => { + const { liveMessages = [] } = (args[1] ?? {}) as { liveMessages?: InboxMessage[] }; + return { + messages: [ + { + from: 'user', + text: 'Ping', + timestamp: '2026-02-23T10:00:00.000Z', + read: true, + source: 'user_sent' as const, + messageId: 'durable-1', + }, + ...liveMessages, + ].sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp)), + nextCursor: '2026-02-23T10:00:00.000Z|durable-1', + hasMore: true, + }; + }); + provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([ + { + from: 'team-lead', + text: 'Команда поднята, приступаю к раздаче задач.', + timestamp: '2026-02-23T10:00:01.000Z', + read: true, + source: 'lead_process' as const, + messageId: 'live-1', + }, + ]); + + const result = (await handlers.get(TEAM_GET_MESSAGES_PAGE)!({} as never, 'my-team', { + limit: 20, + })) as { + success: boolean; + data: { messages: InboxMessage[]; nextCursor: string | null; hasMore: boolean }; + }; + + expect(result.success).toBe(true); + expect(result.data.messages).toHaveLength(2); + expect(result.data.messages[0]?.source).toBe('lead_process'); + expect(result.data.messages[0]?.text).toBe('Команда поднята, приступаю к раздаче задач.'); + expect(result.data.nextCursor).toBe('2026-02-23T10:00:00.000Z|durable-1'); + expect(result.data.hasMore).toBe(true); + expect(service.getMessagesPage).toHaveBeenCalledWith('my-team', { + limit: 20, + beforeTimestamp: undefined, + liveMessages: expect.arrayContaining([ + expect.objectContaining({ + source: 'lead_process', + messageId: 'live-1', + }), + ]), + }); + }); + + it('dedups live lead thoughts on the newest messages page when durable lead_session already exists', async () => { + service.getMessagesPage.mockImplementationOnce(async (...args: unknown[]) => { + const { liveMessages = [] } = (args[1] ?? {}) as { liveMessages?: InboxMessage[] }; + expect(liveMessages).toHaveLength(1); + return { + messages: [ + { + from: 'team-lead', + text: 'Hello there', + timestamp: '2026-02-23T10:00:00.000Z', + read: true, + source: 'lead_session' as const, + leadSessionId: 'lead-1', + messageId: 'durable-1', + }, + ], + nextCursor: null, + hasMore: false, + }; + }); + provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([ + { + from: 'team-lead', + text: 'Hello there', + timestamp: '2026-02-23T10:00:01.000Z', + read: true, + source: 'lead_process' as const, + leadSessionId: 'lead-1', + messageId: 'live-1', + }, + ]); + + const result = (await handlers.get(TEAM_GET_MESSAGES_PAGE)!({} as never, 'my-team', { + limit: 20, + })) as { + success: boolean; + data: { messages: InboxMessage[] }; + }; + + expect(result.success).toBe(true); + expect(result.data.messages).toHaveLength(1); + expect(result.data.messages[0]?.source).toBe('lead_session'); + }); + + it('does not overlay live lead_process messages onto older paginated pages', async () => { + service.getMessagesPage.mockResolvedValueOnce({ + messages: [ + { + from: 'user', + text: 'Older durable message', + timestamp: '2026-02-23T09:59:00.000Z', + read: true, + source: 'user_sent' as const, + messageId: 'durable-older-1', + }, + ], + nextCursor: null, + hasMore: false, + }); + + const result = (await handlers.get(TEAM_GET_MESSAGES_PAGE)!({} as never, 'my-team', { + limit: 20, + beforeTimestamp: '2026-02-23T10:00:00.000Z|cursor', + })) as { + success: boolean; + data: { messages: InboxMessage[] }; + }; + + expect(result.success).toBe(true); + expect(provisioningService.getLiveLeadProcessMessages).not.toHaveBeenCalled(); + expect(result.data.messages).toHaveLength(1); + expect(result.data.messages[0]?.messageId).toBe('durable-older-1'); + }); + it('keeps TEAM_GET_DATA read-only and never triggers reconcile side effects', async () => { const getDataHandler = handlers.get(TEAM_GET_DATA)!; const result = (await getDataHandler({} as never, 'my-team')) as { diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index a2e077bb..daa5f693 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -1,10 +1,12 @@ +import * as nodeFs from 'fs'; import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; +import { encodePath, setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; +import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader'; import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils'; import { TeamDataService } from '../../../../src/main/services/team/TeamDataService'; @@ -81,6 +83,102 @@ async function createTempJsonlInNamedDir( return jsonlPath; } +async function createResolverBackedLeadFixture(options?: { + teamName?: string; + staleProjectPath?: string; + actualProjectPath?: string; + leadSessionId?: string; + sessionHistory?: string[]; + sessionFileId?: string; +}): Promise<{ + claudeRoot: string; + teamName: string; + configPath: string; + staleProjectPath: string; + actualProjectPath: string; + actualProjectDir: string; +}> { + const teamName = options?.teamName ?? 'my-team'; + const staleProjectPath = options?.staleProjectPath ?? '/Users/test/hookplex'; + const actualProjectPath = options?.actualProjectPath ?? '/Users/test/plugin-kit-ai'; + const leadSessionId = options?.leadSessionId ?? 'lead-1'; + const sessionFileId = options?.sessionFileId ?? leadSessionId; + const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-data-resolver-backed-')); + tempPaths.push(claudeRoot); + setClaudeBasePathOverride(claudeRoot); + + await fs.mkdir(path.join(claudeRoot, 'teams', teamName), { recursive: true }); + await fs.mkdir(path.join(claudeRoot, 'projects', encodePath(staleProjectPath)), { + recursive: true, + }); + + const configPath = path.join(claudeRoot, 'teams', teamName, 'config.json'); + await fs.writeFile( + configPath, + JSON.stringify( + { + name: 'My Team', + projectPath: staleProjectPath, + ...(leadSessionId ? { leadSessionId } : {}), + ...(options?.sessionHistory ? { sessionHistory: options.sessionHistory } : {}), + members: [{ name: 'team-lead', agentType: 'team-lead', cwd: actualProjectPath }], + }, + null, + 2 + ), + 'utf8' + ); + + const actualProjectDir = path.join(claudeRoot, 'projects', encodePath(actualProjectPath)); + await fs.mkdir(actualProjectDir, { recursive: true }); + await fs.writeFile( + path.join(actualProjectDir, `${sessionFileId}.jsonl`), + `${JSON.stringify({ + teamName, + type: 'assistant', + timestamp: '2026-04-18T10:00:00.000Z', + cwd: actualProjectPath, + message: { + role: 'assistant', + content: [ + { + type: 'text', + text: 'This is a sufficiently long lead thought recovered through the transcript resolver.', + }, + ], + }, + })}\n`, + 'utf8' + ); + + return { + claudeRoot, + teamName, + configPath, + staleProjectPath, + actualProjectPath, + actualProjectDir, + }; +} + +function createResolverBackedService(): TeamDataService { + return new TeamDataService( + new TeamConfigReader(), + { getTasks: vi.fn(async () => []) } as never, + { + listInboxNames: vi.fn(async () => []), + getMessages: vi.fn(async () => []), + } as never, + {} as never, + {} as never, + { resolveMembers: vi.fn(() => []) } as never, + { getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })) } as never, + {} as never, + { getMembers: vi.fn(async () => []) } as never, + { readMessages: vi.fn(async () => []) } as never + ); +} + function createLeadSessionCachingService(): TeamDataService { return new TeamDataService( { @@ -3260,6 +3358,70 @@ describe('TeamDataService', () => { expect(secondSpy).toHaveBeenCalledTimes(1); }); + it('loads durable lead_session messages through the transcript resolver when projectPath is stale', async () => { + const fixture = await createResolverBackedLeadFixture(); + const service = createResolverBackedService(); + + const data = await service.getTeamData(fixture.teamName); + const persistedConfig = JSON.parse(await fs.readFile(fixture.configPath, 'utf8')) as TeamConfig; + + expect( + data.messages.find( + (message) => + message.source === 'lead_session' && + message.text.includes('recovered through the transcript resolver') + ) + ).toBeTruthy(); + expect(persistedConfig.projectPath).toBe(fixture.actualProjectPath); + }); + + it('still returns lead_session messages when projectPath repair persistence fails', async () => { + const fixture = await createResolverBackedLeadFixture(); + const originalWriteFile = nodeFs.promises.writeFile.bind(nodeFs.promises); + const teamTmpPrefix = path.join(fixture.claudeRoot, 'teams', fixture.teamName, '.tmp.'); + + vi.spyOn(nodeFs.promises, 'writeFile').mockImplementation( + async (...args: Parameters) => { + const [targetPath] = args; + if (typeof targetPath === 'string' && targetPath.startsWith(teamTmpPrefix)) { + throw new Error('simulated atomic write failure'); + } + return originalWriteFile(...args); + } + ); + + const service = createResolverBackedService(); + const page = await service.getMessagesPage(fixture.teamName, { limit: 10 }); + const persistedConfig = JSON.parse(await fs.readFile(fixture.configPath, 'utf8')) as TeamConfig; + + expect( + page.messages.find( + (message) => + message.source === 'lead_session' && + message.text.includes('recovered through the transcript resolver') + ) + ).toBeTruthy(); + expect(persistedConfig.projectPath).toBe(fixture.staleProjectPath); + }); + + it('uses resolver-discovered session ids when config has no leadSessionId or sessionHistory', async () => { + const fixture = await createResolverBackedLeadFixture({ + leadSessionId: undefined, + sessionFileId: 'lead-discovered', + }); + const service = createResolverBackedService(); + + const page = await service.getMessagesPage(fixture.teamName, { limit: 10 }); + + expect( + page.messages.find( + (message) => + message.source === 'lead_session' && + message.text.includes('recovered through the transcript resolver') + ) + ).toBeTruthy(); + }); + it('fails fast when config is missing before any read-phase step starts', async () => { const harness = createGetTeamDataHarness({ config: null, @@ -3813,5 +3975,89 @@ describe('TeamDataService', () => { const result = page.messages.find((m) => m.messageId === 'resp1'); expect(result?.messageKind).toBe('slash_command_result'); }); + + it('dedups newest-page live overlay against durable lead thoughts that already paged off the first page', async () => { + const fillerMessages = Array.from({ length: 55 }, (_, index) => ({ + from: 'alice', + text: `filler-${index}`, + timestamp: `2026-01-01T00:00:${String(10 + index).padStart(2, '0')}.000Z`, + messageId: `filler-${index}`, + source: 'inbox' as const, + })); + const durableThought = { + from: 'team-lead', + text: 'Hello there', + timestamp: '2026-01-01T00:00:01.000Z', + messageId: 'durable-thought', + source: 'lead_session' as const, + leadSessionId: 'lead-1', + }; + const service = createPaginationService([...fillerMessages, durableThought]); + + const page = await service.getMessagesPage('my-team', { + limit: 50, + liveMessages: [ + { + from: 'team-lead', + text: 'Hello there', + timestamp: '2026-01-01T00:01:30.000Z', + read: true, + source: 'lead_process', + messageId: 'live-thought', + leadSessionId: 'lead-1', + }, + ], + }); + + expect(page.messages).toHaveLength(50); + expect(page.messages.some((message) => message.messageId === 'live-thought')).toBe(false); + expect(page.messages.some((message) => message.messageId === 'durable-thought')).toBe(false); + }); + + it('does not skip durable rows when live overlay fills the newest page', async () => { + const msgs = [ + { + from: 'alice', + text: 'durable-newest', + timestamp: '2026-01-01T00:00:02.000Z', + messageId: 'durable-2', + source: 'inbox' as const, + }, + { + from: 'alice', + text: 'durable-older', + timestamp: '2026-01-01T00:00:01.000Z', + messageId: 'durable-1', + source: 'inbox' as const, + }, + ]; + const service = createPaginationService(msgs); + + const page1 = await service.getMessagesPage('my-team', { + limit: 1, + liveMessages: [ + { + from: 'team-lead', + text: 'live-thought', + timestamp: '2026-01-01T00:00:03.000Z', + read: true, + source: 'lead_process', + messageId: 'live-1', + leadSessionId: 'lead-1', + }, + ], + }); + + expect(page1.messages.map((message) => message.messageId)).toEqual(['live-1']); + expect(page1.hasMore).toBe(true); + expect(page1.nextCursor).toBe('2026-01-01T00:00:03.000Z|live-1'); + + const page2 = await service.getMessagesPage('my-team', { + limit: 10, + beforeTimestamp: page1.nextCursor!, + }); + + expect(page2.messages.map((message) => message.messageId)).toEqual(['durable-2', 'durable-1']); + }); }); }); diff --git a/test/main/services/team/TeamTranscriptProjectResolver.test.ts b/test/main/services/team/TeamTranscriptProjectResolver.test.ts new file mode 100644 index 00000000..e914942e --- /dev/null +++ b/test/main/services/team/TeamTranscriptProjectResolver.test.ts @@ -0,0 +1,460 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { TeamTranscriptProjectResolver } from '../../../../src/main/services/team/TeamTranscriptProjectResolver'; +import { encodePath, setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; + +import type { TeamConfig } from '../../../../src/shared/types/team'; + +describe('TeamTranscriptProjectResolver', () => { + let tmpDir: string | null = null; + + afterEach(async () => { + setClaudeBasePathOverride(null); + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + tmpDir = null; + } + }); + + async function setupClaudeRoot(): Promise { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-transcript-project-resolver-')); + setClaudeBasePathOverride(tmpDir); + await fs.mkdir(path.join(tmpDir, 'teams'), { recursive: true }); + await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true }); + return tmpDir; + } + + async function writeTeamConfig(teamName: string, config: TeamConfig): Promise { + const teamDir = path.join(tmpDir!, 'teams', teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile(path.join(teamDir, 'config.json'), JSON.stringify(config, null, 2), 'utf8'); + } + + async function readTeamConfig(teamName: string): Promise { + const raw = await fs.readFile(path.join(tmpDir!, 'teams', teamName, 'config.json'), 'utf8'); + return JSON.parse(raw) as TeamConfig; + } + + async function createSessionFile( + projectPath: string, + sessionId: string, + cwd: string = projectPath + ): Promise<{ projectDir: string; jsonlPath: string }> { + const projectDir = path.join(tmpDir!, 'projects', encodePath(projectPath)); + await fs.mkdir(projectDir, { recursive: true }); + const jsonlPath = path.join(projectDir, `${sessionId}.jsonl`); + await fs.writeFile( + jsonlPath, + `${JSON.stringify({ + type: 'assistant', + timestamp: '2026-04-18T10:00:00.000Z', + cwd, + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Resolver probe output' }], + }, + })}\n`, + 'utf8' + ); + return { projectDir, jsonlPath }; + } + + async function createSessionFileInProjectDir( + projectDirName: string, + sessionId: string, + cwd: string + ): Promise<{ projectDir: string; jsonlPath: string }> { + const projectDir = path.join(tmpDir!, 'projects', projectDirName); + await fs.mkdir(projectDir, { recursive: true }); + const jsonlPath = path.join(projectDir, `${sessionId}.jsonl`); + await fs.writeFile( + jsonlPath, + `${JSON.stringify({ + type: 'assistant', + timestamp: '2026-04-18T10:00:00.000Z', + cwd, + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Resolver probe output' }], + }, + })}\n`, + 'utf8' + ); + return { projectDir, jsonlPath }; + } + + async function createTeamAwareSessionFile( + projectPath: string, + sessionId: string, + teamName: string, + mode: 'text' | 'nested' + ): Promise<{ projectDir: string; jsonlPath: string }> { + const projectDir = path.join(tmpDir!, 'projects', encodePath(projectPath)); + await fs.mkdir(projectDir, { recursive: true }); + const jsonlPath = path.join(projectDir, `${sessionId}.jsonl`); + const lines = + mode === 'text' + ? [ + { + type: 'user', + timestamp: '2026-04-18T10:00:00.000Z', + cwd: projectPath, + message: { + role: 'user', + content: [ + { + type: 'text', + text: `Current durable team context:\n- Team name: ${teamName}\n- You are the live team lead "team-lead"`, + }, + ], + }, + }, + ] + : [ + { + type: 'assistant', + timestamp: '2026-04-18T10:00:00.000Z', + cwd: projectPath, + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'call_probe', + name: 'mcp__agent-teams__task_create_from_message', + input: { + teamName, + subject: 'Probe task', + }, + }, + ], + }, + }, + ]; + + await fs.writeFile(jsonlPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf8'); + return { projectDir, jsonlPath }; + } + + it('repairs stale projectPath when exact leadSessionId exists only in the renamed project', async () => { + await setupClaudeRoot(); + + const teamName = 'my-team'; + const staleProjectPath = '/Users/test/hookplex'; + const repairedProjectPath = '/Users/test/plugin-kit-ai'; + const leadSessionId = 'lead-1'; + const staleProjectDir = path.join(tmpDir!, 'projects', encodePath(staleProjectPath)); + await fs.mkdir(staleProjectDir, { recursive: true }); + const repaired = await createSessionFile(repairedProjectPath, leadSessionId); + + await writeTeamConfig(teamName, { + name: 'My Team', + projectPath: staleProjectPath, + leadSessionId, + members: [{ name: 'team-lead', agentType: 'team-lead', cwd: repairedProjectPath }], + }); + + const resolver = new TeamTranscriptProjectResolver(); + const context = await resolver.getContext(teamName); + const persisted = await readTeamConfig(teamName); + + expect(context).not.toBeNull(); + expect(context?.projectDir).toBe(repaired.projectDir); + expect(context?.config.projectPath).toBe(repairedProjectPath); + expect(persisted.projectPath).toBe(repairedProjectPath); + expect(persisted.projectPathHistory).toEqual(expect.arrayContaining([staleProjectPath])); + }); + + it('keeps the current projectPath when it already contains the exact session', async () => { + await setupClaudeRoot(); + + const teamName = 'my-team'; + const currentProjectPath = '/Users/test/hookplex'; + const alternateProjectPath = '/Users/test/plugin-kit-ai'; + const leadSessionId = 'lead-1'; + const current = await createSessionFile(currentProjectPath, leadSessionId); + await createSessionFile(alternateProjectPath, leadSessionId); + + await writeTeamConfig(teamName, { + name: 'My Team', + projectPath: currentProjectPath, + projectPathHistory: [alternateProjectPath], + leadSessionId, + members: [{ name: 'team-lead', agentType: 'team-lead', cwd: alternateProjectPath }], + }); + + const resolver = new TeamTranscriptProjectResolver(); + const context = await resolver.getContext(teamName); + const persisted = await readTeamConfig(teamName); + + expect(context?.projectDir).toBe(current.projectDir); + expect(context?.config.projectPath).toBe(currentProjectPath); + expect(persisted.projectPath).toBe(currentProjectPath); + expect(persisted.projectPathHistory).toEqual([alternateProjectPath]); + }); + + it('falls back to exact sessionHistory ids when leadSessionId file is missing', async () => { + await setupClaudeRoot(); + + const teamName = 'my-team'; + const staleProjectPath = '/Users/test/hookplex'; + const repairedProjectPath = '/Users/test/plugin-kit-ai'; + const historicalSessionId = 'lead-old'; + await fs.mkdir(path.join(tmpDir!, 'projects', encodePath(staleProjectPath)), { recursive: true }); + const repaired = await createSessionFile(repairedProjectPath, historicalSessionId); + + await writeTeamConfig(teamName, { + name: 'My Team', + projectPath: staleProjectPath, + leadSessionId: 'lead-missing', + sessionHistory: [historicalSessionId], + members: [{ name: 'team-lead', agentType: 'team-lead', cwd: repairedProjectPath }], + }); + + const resolver = new TeamTranscriptProjectResolver(); + const context = await resolver.getContext(teamName); + const persisted = await readTeamConfig(teamName); + + expect(context?.projectDir).toBe(repaired.projectDir); + expect(context?.config.projectPath).toBe(repairedProjectPath); + expect(persisted.projectPath).toBe(repairedProjectPath); + }); + + it('prefers the newest sessionHistory match when leadSessionId is missing', async () => { + await setupClaudeRoot(); + + const teamName = 'my-team'; + const staleProjectPath = '/Users/test/hookplex'; + const repairedProjectPath = '/Users/test/plugin-kit-ai'; + const olderSessionId = 'lead-old'; + const newerSessionId = 'lead-new'; + await createSessionFile(staleProjectPath, olderSessionId); + const repaired = await createSessionFile(repairedProjectPath, newerSessionId); + + await writeTeamConfig(teamName, { + name: 'My Team', + projectPath: staleProjectPath, + leadSessionId: 'lead-missing', + sessionHistory: [olderSessionId, newerSessionId], + members: [{ name: 'team-lead', agentType: 'team-lead', cwd: repairedProjectPath }], + }); + + const resolver = new TeamTranscriptProjectResolver(); + const context = await resolver.getContext(teamName); + + expect(context?.projectDir).toBe(repaired.projectDir); + expect(context?.config.projectPath).toBe(repairedProjectPath); + }); + + it('does not let an old sessionHistory match block repair when the current leadSessionId exists elsewhere', async () => { + await setupClaudeRoot(); + + const teamName = 'my-team'; + const staleProjectPath = '/Users/test/hookplex'; + const repairedProjectPath = '/Users/test/plugin-kit-ai'; + const leadSessionId = 'lead-current'; + const historicalSessionId = 'lead-old'; + await createSessionFile(staleProjectPath, historicalSessionId); + const repaired = await createSessionFile(repairedProjectPath, leadSessionId); + + await writeTeamConfig(teamName, { + name: 'My Team', + projectPath: staleProjectPath, + leadSessionId, + sessionHistory: [historicalSessionId], + members: [{ name: 'team-lead', agentType: 'team-lead', cwd: repairedProjectPath }], + }); + + const resolver = new TeamTranscriptProjectResolver(); + const context = await resolver.getContext(teamName); + const persisted = await readTeamConfig(teamName); + + expect(context?.projectDir).toBe(repaired.projectDir); + expect(context?.config.projectPath).toBe(repairedProjectPath); + expect(persisted.projectPath).toBe(repairedProjectPath); + }); + + it('picks the best exact session match across dir variants for the same projectPath', async () => { + await setupClaudeRoot(); + + const teamName = 'my-team'; + const projectPath = '/Users/test/plugin_kit_ai'; + const staleSessionId = 'lead-old'; + const currentSessionId = 'lead-current'; + await createSessionFile(projectPath, staleSessionId); + const repaired = await createSessionFileInProjectDir( + encodePath(projectPath).replace(/_/g, '-'), + currentSessionId, + projectPath + ); + + await writeTeamConfig(teamName, { + name: 'My Team', + projectPath, + leadSessionId: currentSessionId, + sessionHistory: [staleSessionId], + members: [{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath }], + }); + + const resolver = new TeamTranscriptProjectResolver(); + const context = await resolver.getContext(teamName); + + expect(context?.projectDir).toBe(repaired.projectDir); + }); + + it('does not self-heal when an alternate configured match is not unique across projects scan', async () => { + await setupClaudeRoot(); + + const teamName = 'my-team'; + const staleProjectPath = '/Users/test/hookplex'; + const configuredProjectPath = '/Users/test/plugin-kit-ai'; + const duplicateProjectPath = '/Users/test/plugin-kit-ai-copy'; + const leadSessionId = 'lead-1'; + const staleProjectDir = path.join(tmpDir!, 'projects', encodePath(staleProjectPath)); + await fs.mkdir(staleProjectDir, { recursive: true }); + await createSessionFile(configuredProjectPath, leadSessionId); + await createSessionFile(duplicateProjectPath, leadSessionId); + + await writeTeamConfig(teamName, { + name: 'My Team', + projectPath: staleProjectPath, + projectPathHistory: [configuredProjectPath], + leadSessionId, + members: [{ name: 'team-lead', agentType: 'team-lead', cwd: configuredProjectPath }], + }); + + const resolver = new TeamTranscriptProjectResolver(); + const warnSpy = vi.mocked(console.warn); + const context = await resolver.getContext(teamName); + const persisted = await readTeamConfig(teamName); + + expect(context?.projectDir).toBe(staleProjectDir); + expect(context?.config.projectPath).toBe(staleProjectPath); + expect(persisted.projectPath).toBe(staleProjectPath); + expect(warnSpy.mock.calls).toEqual( + expect.arrayContaining([ + expect.arrayContaining([ + expect.stringContaining('Transcript project resolution ambiguous across exact-session candidates'), + ]), + ]) + ); + warnSpy.mockClear(); + }); + + it('does not self-heal when full scan finds multiple equally valid session matches', async () => { + await setupClaudeRoot(); + + const teamName = 'my-team'; + const staleProjectPath = '/Users/test/hookplex'; + const leadSessionId = 'lead-1'; + const staleProjectDir = path.join(tmpDir!, 'projects', encodePath(staleProjectPath)); + await fs.mkdir(staleProjectDir, { recursive: true }); + await createSessionFile('/Users/test/plugin-kit-ai', leadSessionId); + await createSessionFile('/Users/test/plugin-kit-ai-copy', leadSessionId); + + await writeTeamConfig(teamName, { + name: 'My Team', + projectPath: staleProjectPath, + leadSessionId, + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }); + + const resolver = new TeamTranscriptProjectResolver(); + const warnSpy = vi.mocked(console.warn); + const context = await resolver.getContext(teamName); + const persisted = await readTeamConfig(teamName); + + expect(context?.projectDir).toBe(staleProjectDir); + expect(context?.config.projectPath).toBe(staleProjectPath); + expect(persisted.projectPath).toBe(staleProjectPath); + expect(warnSpy.mock.calls).toEqual( + expect.arrayContaining([ + expect.arrayContaining([ + expect.stringContaining('Transcript project resolution ambiguous across exact-session candidates'), + ]), + ]) + ); + warnSpy.mockClear(); + }); + + it('falls back to an existing alternate dir candidate when no session ids are known yet', async () => { + await setupClaudeRoot(); + + const teamName = 'my-team'; + const projectPath = '/Users/test/plugin_kit_ai'; + const alternateDir = encodePath(projectPath).replace(/_/g, '-'); + const fallback = await createSessionFileInProjectDir(alternateDir, 'lead-1', projectPath); + + await writeTeamConfig(teamName, { + name: 'My Team', + projectPath, + members: [{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath }], + }); + + const resolver = new TeamTranscriptProjectResolver(); + const context = await resolver.getContext(teamName); + + expect(context?.projectDir).toBe(fallback.projectDir); + expect(context?.config.projectPath).toBe(projectPath); + }); + + it('prefers a later candidate when the transcript text explicitly names the team and the stale project dir still exists', async () => { + await setupClaudeRoot(); + + const teamName = 'vector-room-55555551'; + const staleProjectPath = '/Users/test/hookplex'; + const repairedProjectPath = '/Users/test/plugin-kit-ai'; + const staleProjectDir = path.join(tmpDir!, 'projects', encodePath(staleProjectPath)); + await fs.mkdir(staleProjectDir, { recursive: true }); + const repaired = await createTeamAwareSessionFile( + repairedProjectPath, + 'lead-1', + teamName, + 'text' + ); + + await writeTeamConfig(teamName, { + name: 'My Team', + projectPath: staleProjectPath, + members: [{ name: 'team-lead', agentType: 'team-lead', cwd: repairedProjectPath }], + }); + + const resolver = new TeamTranscriptProjectResolver(); + const context = await resolver.getContext(teamName); + + expect(context?.projectDir).toBe(repaired.projectDir); + expect(context?.config.projectPath).toBe(repairedProjectPath); + }); + + it('recognizes nested tool input teamName during no-session fallback', async () => { + await setupClaudeRoot(); + + const teamName = 'vector-room-55555551'; + const staleProjectPath = '/Users/test/hookplex'; + const repairedProjectPath = '/Users/test/plugin-kit-ai'; + const staleProjectDir = path.join(tmpDir!, 'projects', encodePath(staleProjectPath)); + await fs.mkdir(staleProjectDir, { recursive: true }); + const repaired = await createTeamAwareSessionFile( + repairedProjectPath, + 'lead-1', + teamName, + 'nested' + ); + + await writeTeamConfig(teamName, { + name: 'My Team', + projectPath: staleProjectPath, + members: [{ name: 'team-lead', agentType: 'team-lead', cwd: repairedProjectPath }], + }); + + const resolver = new TeamTranscriptProjectResolver(); + const context = await resolver.getContext(teamName); + + expect(context?.projectDir).toBe(repaired.projectDir); + expect(context?.config.projectPath).toBe(repairedProjectPath); + }); +}); diff --git a/test/renderer/components/team/messages/MessagesPanel.test.ts b/test/renderer/components/team/messages/MessagesPanel.test.ts index 5de525d6..c75c4b35 100644 --- a/test/renderer/components/team/messages/MessagesPanel.test.ts +++ b/test/renderer/components/team/messages/MessagesPanel.test.ts @@ -251,6 +251,55 @@ describe('MessagesPanel idle summary invariants', () => { }); }); + it('clears pending replies when a real member reply arrives after the pending timestamp', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onPendingReplyChange = vi.fn(); + + const pendingSentAtMs = Date.parse('2026-04-08T12:00:00.000Z'); + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'lead-reply', + from: 'alice', + read: true, + source: 'lead_process', + timestamp: '2026-04-08T12:01:00.000Z', + text: 'Starting now.', + }), + ]; + + await act(async () => { + root.render( + React.createElement(MessagesPanel, { + teamName: 'atlas-hq', + position: 'sidebar', + onPositionChange: vi.fn(), + members: [], + tasks: [], + messages, + timeWindow: null, + teamSessionIds: new Set(), + pendingRepliesByMember: { alice: pendingSentAtMs }, + onPendingReplyChange, + }) + ); + await Promise.resolve(); + }); + + expect(onPendingReplyChange.mock.calls.length).toBeGreaterThan(0); + const updater = onPendingReplyChange.mock.calls.at(-1)?.[0] as + | ((current: Record) => Record) + | undefined; + expect(updater?.({ alice: pendingSentAtMs })).toEqual({}); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('renders the bottom-sheet composer before the status block so input stays pinned near the header', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div');