From 8d06ee81c25fe48e47aa1c0c2b241b1b043c9b41 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 7 May 2026 20:58:40 +0300 Subject: [PATCH] fix(team): stabilize launch previews and codex reconnect --- .../hooks/useGraphMemberLogPreviews.ts | 15 +- .../CodexLoginSessionManager.ts | 3 - .../memberLogPreviewExtractor.test.ts | 145 +++++++++++++++++- .../policies/memberLogPreviewExtractor.ts | 87 ++++++++++- .../services/team/TeamProvisioningService.ts | 7 +- .../components/dashboard/CliStatusBanner.tsx | 13 +- .../runtime/ProviderRuntimeSettingsDialog.tsx | 2 +- .../team/dialogs/CodexReconnectPrompt.tsx | 15 +- .../team/dialogs/CreateTeamDialog.tsx | 11 +- .../team/dialogs/LaunchTeamDialog.tsx | 11 +- .../team/TeamProvisioningService.test.ts | 50 ++++++ .../useGraphMemberLogPreviews.test.tsx | 72 +++++++++ 12 files changed, 382 insertions(+), 49 deletions(-) diff --git a/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts b/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts index 7eddba2d..8630f328 100644 --- a/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts +++ b/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts @@ -184,6 +184,7 @@ export function useGraphMemberLogPreviews(input: { const cacheRef = useRef(new Map()); const previewsByMemberRef = useRef(previewsByMember); const inFlightRef = useRef(new Map>>()); + const activeRequestKeyByMemberRef = useRef(new Map()); const reloadTimerRef = useRef | null>(null); const teamNameRef = useRef(input.teamName); @@ -196,6 +197,7 @@ export function useGraphMemberLogPreviews(input: { teamNameRef.current = input.teamName; cacheRef.current.clear(); inFlightRef.current.clear(); + activeRequestKeyByMemberRef.current.clear(); setPreviewsByMember(new Map()); } if (!enabled || memberNames.length === 0) { @@ -261,6 +263,9 @@ export function useGraphMemberLogPreviews(input: { forceRefresh: options?.forceRefresh, }); const requestTeamName = input.teamName; + for (const memberName of membersToRequest) { + activeRequestKeyByMemberRef.current.set(normalizeMemberName(memberName), requestKey); + } if (!options?.background && hasMissingPreview) { setLoading(true); @@ -310,7 +315,15 @@ export function useGraphMemberLogPreviews(input: { if (teamNameRef.current !== requestTeamName) { return; } - setPreviewsByMember((current) => mergeMemberPreviews(current, members.values())); + const currentMembers = Array.from(members.values()).filter((member) => { + return ( + activeRequestKeyByMemberRef.current.get(normalizeMemberName(member.memberName)) === + requestKey + ); + }); + if (currentMembers.length > 0) { + setPreviewsByMember((current) => mergeMemberPreviews(current, currentMembers)); + } setError(null); } catch (loadError) { if (teamNameRef.current !== requestTeamName) { diff --git a/src/features/codex-account/main/infrastructure/CodexLoginSessionManager.ts b/src/features/codex-account/main/infrastructure/CodexLoginSessionManager.ts index 81551872..a5284c6a 100644 --- a/src/features/codex-account/main/infrastructure/CodexLoginSessionManager.ts +++ b/src/features/codex-account/main/infrastructure/CodexLoginSessionManager.ts @@ -4,7 +4,6 @@ import { type CodexAppServerLoginAccountResponse, type CodexAppServerSession, } from '@main/services/infrastructure/codexAppServer'; -import { shell } from 'electron'; import type { CodexLoginStateDto } from '@features/codex-account/contracts'; import type { CodexAppServerSessionFactory } from '@main/services/infrastructure/codexAppServer'; @@ -139,8 +138,6 @@ export class CodexLoginSessionManager { startedAt: this.state.startedAt, authUrl: authUrl.toString(), }); - - await shell.openExternal(authUrl.toString()); } catch (error) { const wasAbandonedDuringStart = this.pendingStartToken !== startToken && diff --git a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts index e5c0ae5d..89479bca 100644 --- a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts +++ b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts @@ -537,8 +537,7 @@ Reply to this comment using MCP tool task_add_comment. expect(result.items[0]).toMatchObject({ kind: 'tool_result', title: 'Read task error', - preview: - "Tool 'task_get' execution failed: Task not found: 211e430b-0901-4c9e-9296-2b6e2059a08f", + preview: "Tool 'task_get' execution failed: Task not found: 211e430b", tone: 'error', }); }); @@ -638,6 +637,39 @@ Reply to this comment using MCP tool task_add_comment. expect(result.items).toHaveLength(1); }); + it('does not repeat generic comment titles in compact previews', () => { + const result = extractMemberLogPreviewItems({ + provider: 'claude_transcript', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'comment-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-comment', + content: JSON.stringify({ + comment: { + text: 'Focused checks passed.', + }, + }), + }, + ], + }), + ], + }); + + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Comment', + preview: 'Focused checks passed.', + }); + }); + it('distinguishes read-comment results from add-comment results', () => { const result = extractMemberLogPreviewItems({ provider: 'claude_transcript', @@ -1694,6 +1726,115 @@ Reply to this comment using MCP tool task_add_comment. }); }); + it('cleans tagged file tool output and shortens absolute paths for compact rows', () => { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'read-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-read', + name: 'Read', + input: { + file_path: '/Users/belief/dev/projects/demo/app/page.tsx', + }, + }, + ], + }), + message({ + uuid: 'read-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-read', + content: `/Users/belief/dev/projects/demo/app/page.tsx +file + +1: export default function Page() { +2: return null; +3: } + +(End of file - total 3 lines) +`, + }, + ], + }), + ], + }); + + expect(result.items).toHaveLength(1); + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Read result', + preview: 'demo/app/page.tsx - export default function Page() { return null; }', + }); + expect(result.items[0]?.preview).not.toContain('/Users/belief'); + expect(result.items[0]?.preview).not.toContain(''); + expect(result.items[0]?.preview).not.toContain('1:'); + }); + + it('cleans tagged directory tool output without repeating absolute paths', () => { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'read-call', + timestamp: '2026-04-01T10:00:00.000Z', + content: [ + { + type: 'tool_use', + id: 'tool-read', + name: 'Read', + input: { + file_path: '/Users/belief/dev/projects/demo', + }, + }, + ], + }), + message({ + uuid: 'read-result', + type: 'user', + role: 'user', + timestamp: '2026-04-01T10:01:00.000Z', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-read', + content: `/Users/belief/dev/projects/demo +directory + +app/ +package.json +README.md + +(3 entries) +`, + }, + ], + }), + ], + }); + + expect(result.items).toHaveLength(1); + expect(result.items[0]).toMatchObject({ + kind: 'tool_result', + title: 'Read result', + preview: 'demo - directory app/ package.json README.md', + }); + expect(result.items[0]?.preview).not.toContain('/Users/belief'); + expect(result.items[0]?.preview).not.toContain('(3 entries)'); + }); + it('does not label arbitrary message fields as sent messages', () => { const result = extractMemberLogPreviewItems({ provider: 'opencode_runtime', diff --git a/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts index 92c62611..f24c8a09 100644 --- a/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts +++ b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts @@ -191,8 +191,15 @@ function parseJsonLikeString(value: string): unknown { } } +function shortenLongIdsForPreview(value: string): string { + return value.replace( + /\b([0-9a-f]{8})-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, + '$1' + ); +} + function truncatePreview(value: string, limit: number): { preview: string; truncated: boolean } { - const compact = compactWhitespace(removeHiddenInstructionBlocks(value)); + const compact = shortenLongIdsForPreview(compactWhitespace(removeHiddenInstructionBlocks(value))); if (compact.length <= limit) { return { preview: compact, truncated: false }; } @@ -533,6 +540,30 @@ function formatShellResultContext(toolContext: ToolUseContext | undefined): stri return stringField(input, 'description') ?? stringField(input, 'command'); } +function shortenPathForPreview(value: string): string { + const compact = compactWhitespace(value); + if (!compact) { + return ''; + } + const normalized = compact.replace(/\\/g, '/'); + if (!normalized.startsWith('/') && normalized.length <= 56) { + return normalized; + } + const parts = normalized.split('/').filter(Boolean); + if (parts.length <= 3) { + return normalized.startsWith('/') ? parts.join('/') : normalized; + } + const projectsIndex = parts.lastIndexOf('projects'); + if (projectsIndex >= 0 && projectsIndex < parts.length - 1) { + const projectRelative = parts.slice(projectsIndex + 1); + if (projectRelative.length <= 4) { + return projectRelative.join('/'); + } + } + const tail = parts.slice(-3); + return tail[0] === 'projects' ? parts.slice(-2).join('/') : tail.join('/'); +} + function addContextToSuccessResultPreview( preview: ValuePreview, context: string | null, @@ -580,15 +611,16 @@ function formatFileToolResultContext(toolContext: ToolUseContext | undefined): s stringField(input, 'filePath') ?? stringField(input, 'path') ?? stringField(input, 'cwd'); + const compactPath = path ? shortenPathForPreview(path) : null; if (toolContext.canonicalName === 'grep') { const query = stringField(input, 'query') ?? stringField(input, 'pattern'); - if (query && path) return `${query} in ${path}`; - return query ?? path; + if (query && compactPath) return `${query} in ${compactPath}`; + return query ?? compactPath; } if (toolContext.canonicalName === 'glob') { const pattern = stringField(input, 'pattern') ?? stringField(input, 'glob'); - if (pattern && path) return `${pattern} in ${path}`; - return pattern ?? path; + if (pattern && compactPath) return `${pattern} in ${compactPath}`; + return pattern ?? compactPath; } if ( toolContext.canonicalName === 'read' || @@ -596,7 +628,7 @@ function formatFileToolResultContext(toolContext: ToolUseContext | undefined): s toolContext.canonicalName === 'edit' || toolContext.canonicalName === 'ls' ) { - return path; + return compactPath; } return null; } @@ -733,7 +765,7 @@ function formatTaskCommentPayload( if (author && taskRef) return `Comment by ${author} on ${taskRef}: ${commentText}`; if (author) return `Comment by ${author}: ${commentText}`; if (taskRef) return `Comment on ${taskRef}: ${commentText}`; - return `Comment: ${commentText}`; + return commentText; } function countArrayField(payload: Record, keys: readonly string[]): number | null { @@ -1485,6 +1517,34 @@ function formatPlainToolResultStatus( ); } +function taggedSection(value: string, tag: string): string | null { + const match = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i').exec(value); + return match?.[1]?.trim() || null; +} + +function stripTaggedFileLineNumbers(value: string): string { + return value + .replace(/\(End of file - total \d+ lines?\)/gi, ' ') + .replace(/\(\d+ entries\)/gi, ' ') + .split(/\r?\n/) + .map((line) => line.replace(/^\s*\d+:\s*/, '').trim()) + .filter(Boolean) + .join(' '); +} + +function formatTaggedFileToolResult(value: string): string | null { + const content = taggedSection(value, 'content') ?? taggedSection(value, 'entries'); + if (!content) { + return null; + } + const type = taggedSection(value, 'type')?.toLowerCase(); + const body = compactWhitespace(stripTaggedFileLineNumbers(content)); + if (!body) { + return null; + } + return type === 'directory' ? `directory ${body}` : body; +} + function formatPlainToolErrorText(value: string, limit: number): ValuePreview | null { const compact = compactWhitespace(removeHiddenInstructionBlocks(value)); if (!compact) { @@ -1649,6 +1709,10 @@ function previewUnknownValue( if (plainStatus) { return { ...truncatePreview(plainStatus.text, limit), title: plainStatus.title }; } + const taggedFileResult = formatTaggedFileToolResult(value); + if (taggedFileResult) { + return truncatePreview(taggedFileResult, limit); + } const parsed = parseJsonLikeString(value); if (parsed != null) { return previewUnknownValue(parsed, limit, priorityKeys, toolContext); @@ -1749,6 +1813,15 @@ function previewToolInputValue(toolName: string, value: unknown, limit: number): : 'Read cross-team outbox'; return truncatePreview(text, limit); } + const fileToolContext = formatFileToolResultContext({ + id: '', + name: toolName, + canonicalName: canonical, + input: value, + }); + if (fileToolContext) { + return truncatePreview(fileToolContext, limit); + } const payload = recordFromUnknown(value); if (payload) { const runtimeFormatted = formatRuntimePayload(payload, canonical, payload); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 79ca7d04..34b76553 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -16230,6 +16230,7 @@ export class TeamProvisioningService { run.child.stderr?.removeAllListeners('data'); run.child.removeAllListeners('error'); run.child.removeAllListeners('exit'); + run.child.removeAllListeners('close'); killTeamProcess(run.child); run.child = null; } @@ -16469,7 +16470,7 @@ export class TeamProvisioningService { this.cleanupRun(run); }); - child.once('exit', (code) => { + child.once('close', (code) => { void this.handleProcessExit(run, code); }); } @@ -17083,7 +17084,7 @@ export class TeamProvisioningService { this.cleanupRun(run); }); - child.once('exit', (code) => { + child.once('close', (code) => { void this.handleProcessExit(run, code); }); @@ -18381,7 +18382,7 @@ export class TeamProvisioningService { this.cleanupRun(run); }); - child.once('exit', (code) => { + child.once('close', (code) => { void this.handleProcessExit(run, code); }); diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 76e3d431..93e9d036 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -908,7 +908,7 @@ const InstalledBanner = ({ color: '#fbbf24', }} > - {codexLoginAuthUrl ? 'Open login' : 'Reconnect ChatGPT'} + {codexLoginAuthUrl ? 'Open login' : 'Generate link'} ) : null} @@ -1153,16 +1153,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => { const handleCodexDashboardLogin = useCallback(() => { void (async () => { - const success = await codexAccount.startChatgptLogin(); - if (success) { - await refreshCliStatusForCurrentMode({ - multimodelEnabled, - bootstrapCliStatus, - fetchCliStatus, - }); - } + await codexAccount.startChatgptLogin(); })(); - }, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]); + }, [codexAccount]); const recheckAuthState = useCallback(() => { setIsVerifyingAuth(true); diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index cd91af77..e8c72d7f 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -1439,7 +1439,7 @@ export const ProviderRuntimeSettingsDialog = ({ onClick={() => void handleCodexStartLogin()} > - {codexNeedsReconnect ? 'Reconnect ChatGPT' : 'Connect ChatGPT'} + {codexNeedsReconnect ? 'Generate link' : 'Connect ChatGPT'} )} diff --git a/src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx b/src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx index 4a5b502f..56842c96 100644 --- a/src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx +++ b/src/renderer/components/team/dialogs/CodexReconnectPrompt.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { CodexLoginLinkCopyButton } from '@renderer/components/runtime/CodexLoginLinkCopyButton'; +import { api } from '@renderer/api'; import { LogIn } from 'lucide-react'; import type { ProvisioningProviderCheck } from './ProvisioningProviderStatusList'; @@ -78,13 +79,19 @@ export const CodexReconnectPrompt = ({ >

- Codex found the local ChatGPT account, but this session is stale. Reconnect ChatGPT, then - finish login in the browser and retry this dialog. + Codex found the local ChatGPT account, but this session is stale. Sign in with ChatGPT, + then finish login in the browser and retry this dialog.

diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 373e1640..4c876893 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -713,16 +713,9 @@ export const CreateTeamDialog = ({ const handleCodexReconnect = useCallback(() => { void (async () => { - const success = await codexAccount.startChatgptLogin(); - if (success) { - await refreshCliStatusForCurrentMode({ - multimodelEnabled, - bootstrapCliStatus, - fetchCliStatus, - }); - } + await codexAccount.startChatgptLogin(); })(); - }, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]); + }, [codexAccount]); useEffect(() => { if (!open || !canCreate || !launchTeam) { diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 121cd604..e243557c 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -590,16 +590,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const handleCodexReconnect = React.useCallback(() => { void (async () => { - const success = await codexAccount.startChatgptLogin(); - if (success) { - await refreshCliStatusForCurrentMode({ - multimodelEnabled, - bootstrapCliStatus, - fetchCliStatus, - }); - } + await codexAccount.startChatgptLogin(); })(); - }, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]); + }, [codexAccount]); // Schedule store actions const createSchedule = useStore((s) => s.createSchedule); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index e9315099..653ddc23 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -12356,6 +12356,56 @@ describe('TeamProvisioningService', () => { await svc.cancelProvisioning(runId); }); + it('waits for child close before handling launch process exit so stream-json can drain', async () => { + allowConsoleLogs(); + const teamName = 'launch-close-drains-stdout-team'; + const leadSessionId = 'lead-session-close-drain'; + writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice']); + + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); + const child = createRunningChild(); + vi.mocked(spawnCli).mockReturnValue(child as any); + + const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, { + writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'), + removeConfigFile: vi.fn(async () => {}), + } as any); + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { ANTHROPIC_API_KEY: 'test' }, + authSource: 'anthropic_api_key', + })); + (svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({ + members: [{ name: 'alice' }], + source: 'members-meta', + warning: undefined, + })); + (svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {}); + (svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {}); + (svc as any).updateConfigProjectPath = vi.fn(async () => {}); + (svc as any).restorePrelaunchConfig = vi.fn(async () => {}); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).persistLaunchStateSnapshot = vi.fn(async () => {}); + (svc as any).startFilesystemMonitor = vi.fn(); + (svc as any).pathExists = vi.fn(async (targetPath: string) => + targetPath.endsWith(`${leadSessionId}.jsonl`) + ); + const handleProcessExit = vi + .spyOn(svc as any, 'handleProcessExit') + .mockResolvedValue(undefined); + + const { runId } = await svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {}); + + child.emit('exit', 0); + await Promise.resolve(); + expect(handleProcessExit).not.toHaveBeenCalled(); + + child.emit('close', 0); + await vi.waitFor(() => expect(handleProcessExit).toHaveBeenCalledTimes(1)); + expect(handleProcessExit.mock.calls[0]?.[1]).toBe(0); + + await svc.cancelProvisioning(runId); + }); + it('clears stale team-scoped transient state before starting a new launch run', async () => { allowConsoleLogs(); vi.useFakeTimers(); diff --git a/test/renderer/features/agent-graph/useGraphMemberLogPreviews.test.tsx b/test/renderer/features/agent-graph/useGraphMemberLogPreviews.test.tsx index f7008511..7d8b3735 100644 --- a/test/renderer/features/agent-graph/useGraphMemberLogPreviews.test.tsx +++ b/test/renderer/features/agent-graph/useGraphMemberLogPreviews.test.tsx @@ -337,6 +337,78 @@ describe('useGraphMemberLogPreviews', () => { }); }); + it('ignores stale responses when the same member receives a newer lane request', async () => { + const oldLaneLoad = createDeferred(); + const newLaneLoad = createDeferred(); + apiMock.memberLogStream.getMemberLogPreviews + .mockReturnValueOnce(oldLaneLoad.promise) + .mockReturnValueOnce(newLaneLoad.promise); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const states: ReturnType[] = []; + const onState = vi.fn((state: ReturnType) => { + states.push(state); + }); + const latestState = (): ReturnType | undefined => + states.at(-1); + + await act(async () => { + root.render( + + ); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); + + await act(async () => { + root.render( + + ); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2); + + await act(async () => { + newLaneLoad.resolve(response('alice', '2026-04-03T00:01:00.000Z')); + await Promise.resolve(); + }); + expect(latestState()?.previewsByMember.get('alice')?.items[0]?.id).toBe( + 'alice:2026-04-03T00:01:00.000Z' + ); + + await act(async () => { + oldLaneLoad.resolve(response('alice', '2026-04-03T00:00:00.000Z')); + await Promise.resolve(); + }); + expect(latestState()?.previewsByMember.get('alice')?.items[0]?.id).toBe( + 'alice:2026-04-03T00:01:00.000Z' + ); + + act(() => { + root.unmount(); + }); + }); + it('reloads visible members on log-source events with force refresh', async () => { let teamChangeListener: | ((event: unknown, data: { teamName: string; type: string }) => void)