fix(team): preserve log stream participant filter

This commit is contained in:
777genius 2026-05-07 17:52:03 +03:00
parent 2e05941cba
commit b9f82f8162
12 changed files with 453 additions and 28 deletions

View file

@ -32,7 +32,7 @@ function buildRequestKey(input: {
.sort((left, right) => left[0].localeCompare(right[0]));
return JSON.stringify([
input.teamName,
input.memberNames.map(normalizeMemberName),
input.memberNames.map(normalizeMemberName).sort((left, right) => left.localeCompare(right)),
laneEntries,
input.maxItemsPerMember,
input.textLimit,
@ -143,7 +143,14 @@ export function useGraphMemberLogPreviews(input: {
}
return result;
}, [input.memberNames]);
const memberKey = useMemo(() => memberNames.map(normalizeMemberName).join('|'), [memberNames]);
const memberKey = useMemo(
() =>
memberNames
.map(normalizeMemberName)
.sort((left, right) => left.localeCompare(right))
.join('|'),
[memberNames]
);
const [previewsByMember, setPreviewsByMember] = useState(
new Map<string, MemberLogPreviewMember>()
);

View file

@ -101,8 +101,13 @@ function itemIcon(item: MemberLogPreviewItem): React.JSX.Element {
return <MessageSquareText className={`${className} text-slate-300`} />;
}
function resolveEmptyText(preview: MemberLogPreviewMember | undefined, loading: boolean): string {
function resolveEmptyText(
preview: MemberLogPreviewMember | undefined,
loading: boolean,
error: string | null
): string {
if (loading && !preview) return 'Loading logs';
if (error && !preview) return 'Logs unavailable';
if (preview?.warnings.some((warning) => warning.code === 'codex_member_wide_not_supported')) {
return 'Unsupported provider';
}
@ -111,12 +116,45 @@ function resolveEmptyText(preview: MemberLogPreviewMember | undefined, loading:
function compactDisplayTitle(item: MemberLogPreviewItem): string {
const title = item.title.trim();
if (title.toLowerCase() === 'tool result') {
return title;
}
if (item.kind === 'tool_result' && title.toLowerCase().endsWith(' result')) {
return title.slice(0, -' result'.length).trim() || title;
}
return title;
}
function trimRepeatedTitlePrefix(preview: string, title: string): string {
const normalizedPreview = preview.toLowerCase();
const normalizedTitle = title.toLowerCase();
if (normalizedPreview.startsWith(`${normalizedTitle} - `)) {
return preview.slice(title.length + 3).trim();
}
if (normalizedPreview.startsWith(`${normalizedTitle}: `)) {
return preview.slice(title.length + 2).trim();
}
if (normalizedPreview.startsWith(`${normalizedTitle} `)) {
return preview.slice(title.length + 1).trim();
}
return preview;
}
function compactPreviewText(item: MemberLogPreviewItem, displayTitle: string): string {
const preview = item.preview?.trim();
if (preview) {
const compact = trimRepeatedTitlePrefix(preview, displayTitle);
return compact || preview;
}
if (item.kind === 'tool_result') {
return item.tone === 'error' ? 'No error output' : 'No output';
}
if (item.kind === 'tool_use') {
return 'No input';
}
return item.sourceLabel || 'Log event';
}
function setShellHidden(shell: HTMLDivElement): void {
shell.style.opacity = '0';
shell.style.pointerEvents = 'none';
@ -155,7 +193,7 @@ export const GraphMemberLogPreviewHud = ({
}),
[nodes]
);
const { previewsByMember, loading } = useGraphMemberLogPreviews({
const { previewsByMember, loading, error } = useGraphMemberLogPreviews({
teamName,
memberNames: visibleMemberNames,
laneIdsByMember,
@ -367,10 +405,10 @@ export const GraphMemberLogPreviewHud = ({
(memberName: string, item: MemberLogPreviewItem) => {
const relativeTime = formatRelativeTime(item.timestamp);
const displayTitle = compactDisplayTitle(item);
const previewText = item.preview || item.sourceLabel || 'Log event';
const previewText = compactPreviewText(item, displayTitle);
const titleText = relativeTime
? `${item.title} ${relativeTime} ${previewText}`
: `${item.title} ${previewText}`;
? `${displayTitle} ${relativeTime} ${previewText}`
: `${displayTitle} ${previewText}`;
const isHighlighted = highlightedItemIds.has(item.id);
return (
@ -392,23 +430,15 @@ export const GraphMemberLogPreviewHud = ({
>
{itemIcon(item)}
</span>
<span
className="inline-flex h-5 items-center align-top"
style={{ position: 'relative', top: '-3px' }}
>
<span className="text-[11px] font-medium leading-none text-slate-200">
{displayTitle}
</span>
{relativeTime ? (
<span className="ml-1 text-[9px] font-normal leading-none text-slate-500">
{relativeTime}
</span>
) : null}
<span className="align-top text-[11px] font-medium leading-5 text-slate-200">
{displayTitle}
</span>
<span
className="ml-1 break-words align-top text-[10px] leading-5 text-slate-300/85"
style={{ position: 'relative', top: '-3px' }}
>
{relativeTime ? (
<span className="ml-1 align-top text-[9px] font-normal leading-5 text-slate-500">
{relativeTime}
</span>
) : null}
<span className="ml-1 break-words align-top text-[10px] leading-5 text-slate-300/85">
{previewText}
</span>
</button>
@ -467,7 +497,7 @@ export const GraphMemberLogPreviewHud = ({
className="flex h-14 min-h-14 items-center rounded-md border border-dashed border-white/10 bg-[rgba(8,14,28,0.28)] px-3 text-left text-[11px] text-slate-400/60"
onClick={() => openLogs(memberName)}
>
{resolveEmptyText(preview, loading)}
{resolveEmptyText(preview, loading, error)}
</button>
)}
{preview && preview.overflowCount > 0 ? (

View file

@ -74,9 +74,16 @@ function stableInputKey(input: {
textLimit: number;
forceRefresh?: boolean;
}): string {
const memberKeys = input.members
.map((member) => [normalizeMemberName(member.memberName), member.laneId ?? ''] as const)
.sort((left, right) => {
const byName = left[0].localeCompare(right[0]);
if (byName !== 0) return byName;
return left[1].localeCompare(right[1]);
});
return JSON.stringify([
input.teamName,
input.members.map((member) => [normalizeMemberName(member.memberName), member.laneId ?? '']),
memberKeys,
input.maxItems,
input.textLimit,
input.forceRefresh === true,

View file

@ -5,6 +5,7 @@ import { GetMemberLogPreviewsUseCase } from '../GetMemberLogPreviewsUseCase';
import type {
MemberLogPreviewSource,
MemberLogPreviewSourceInput,
MemberLogPreviewSourceResult,
} from '../../ports/MemberLogPreviewSource';
function source(
@ -16,6 +17,38 @@ function source(
return { provider, loadPreview };
}
function createDeferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
} {
let resolve!: (value: T) => void;
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve;
});
return { promise, resolve };
}
function textResult(memberName: string): MemberLogPreviewSourceResult {
return {
provider: 'claude_transcript',
status: 'included',
items: [
{
id: `item:${memberName}`,
kind: 'text',
provider: 'claude_transcript',
timestamp: '2026-04-01T12:00:00.000Z',
title: 'Assistant',
preview: memberName,
tone: 'neutral',
},
],
warnings: [],
truncated: false,
overflowCount: 0,
};
}
describe('GetMemberLogPreviewsUseCase', () => {
it('dedupes members, clamps options, and merges source coverage per member', async () => {
const loadPreview = vi.fn(async (input: MemberLogPreviewSourceInput) => ({
@ -95,4 +128,38 @@ describe('GetMemberLogPreviewsUseCase', () => {
expect(loadPreview).toHaveBeenCalledTimes(1);
expect(first.members[0]?.warnings[0]?.code).toBe('codex_member_wide_not_supported');
});
it('dedupes in-flight batch requests for the same member set in different order', async () => {
const pendingByMember = new Map<
string,
ReturnType<typeof createDeferred<MemberLogPreviewSourceResult>>
>();
const loadPreview = vi.fn((input: MemberLogPreviewSourceInput) => {
const deferred = createDeferred<MemberLogPreviewSourceResult>();
pendingByMember.set(input.memberName, deferred);
return deferred.promise;
});
const useCase = new GetMemberLogPreviewsUseCase({
sources: [source('claude_transcript', loadPreview)],
clock: { now: () => Date.parse('2026-04-01T12:01:00.000Z') },
logger: { warn: vi.fn(), error: vi.fn() },
});
const firstPromise = useCase.execute({
teamName: 'alpha-team',
memberNames: ['alice', 'bob'],
});
const secondPromise = useCase.execute({
teamName: 'alpha-team',
memberNames: ['bob', 'alice'],
});
expect(loadPreview).toHaveBeenCalledTimes(2);
pendingByMember.get('alice')?.resolve(textResult('alice'));
pendingByMember.get('bob')?.resolve(textResult('bob'));
const [first, second] = await Promise.all([firstPromise, secondPromise]);
expect(second).toBe(first);
expect(first.members.map((member) => member.memberName)).toEqual(['alice', 'bob']);
});
});

View file

@ -571,6 +571,80 @@ Reply to this comment using MCP tool task_add_comment.
expect(JSON.stringify(result.items)).not.toContain('agent_teams_task_get_response');
});
it('formats direct task list arrays without leaking raw array fields', () => {
const result = extractMemberLogPreviewItems({
provider: 'claude_transcript',
maxItems: 3,
textLimit: 220,
messages: [
message({
uuid: 'list-call',
timestamp: '2026-04-01T10:00:00.000Z',
content: [
{
type: 'tool_use',
id: 'tool-list',
name: 'mcp__agent-teams__task_list',
input: { teamName: 'demo' },
},
],
}),
message({
uuid: 'list-result',
type: 'user',
role: 'user',
timestamp: '2026-04-01T10:01:00.000Z',
content: [
{
type: 'tool_result',
tool_use_id: 'tool-list',
content: JSON.stringify([
{
id: '4499fbe5-1fee-42a5-8584-851fbfc4adcd',
displayId: '4499fbe5',
subject: 'Fix contact form route',
status: 'todo',
owner: 'bob',
},
{
id: '0276a054-1111-4222-8333-444444444444',
displayId: '0276a054',
title: 'High-confidence bug triage',
status: 'in_progress',
owner: 'alice',
},
{
id: '8a9e766b-1111-4222-8333-444444444444',
displayId: '8a9e766b',
title: 'Follow-up split',
status: 'done',
owner: 'tom',
},
{
id: '898a6a3e-1111-4222-8333-444444444444',
displayId: '898a6a3e',
title: 'Regression research',
status: 'done',
owner: 'team-lead',
},
]),
},
],
}),
],
});
expect(result.items[0]).toMatchObject({
kind: 'tool_result',
title: 'Task list',
preview:
'4 tasks - #4499fbe5: Fix contact form route, status todo, owner bob; #0276a054: High-confidence bug triage, status in_progress, owner alice; #8a9e766b: Follow-up split, status done, owner tom; +1 more',
});
expect(result.items).toHaveLength(1);
expect(result.items[0]?.preview).not.toContain('displayId');
expect(result.items[0]?.preview).not.toContain('[{');
});
it('formats common board and cross-team tool previews compactly', () => {
const result = extractMemberLogPreviewItems({
provider: 'opencode_runtime',

View file

@ -465,7 +465,56 @@ function countArrayField(payload: Record<string, unknown>, keys: readonly string
return null;
}
function formatTaskCollectionItem(task: Record<string, unknown>): string | null {
const taskRef = taskRefFromPayload(task);
const taskSummary = shortTaskSummary(task);
if (taskRef && taskSummary) return `${taskRef}: ${taskSummary}`;
if (taskRef) return taskRef;
return taskSummary;
}
function formatTaskCollectionArrayPayload(
items: readonly unknown[],
canonicalToolNameValue: string | null
): KnownPayloadPreview | null {
const canonical = canonicalToolNameValue ?? '';
if (canonical !== 'task_list' && canonical !== 'task_briefing') {
return null;
}
const tasks = items
.map((item) => asRecord(item))
.filter((item): item is Record<string, unknown> => Boolean(item));
if (tasks.length === 0) {
return {
title: canonical === 'task_briefing' ? 'Task briefing' : 'Task list',
text: '0 tasks',
};
}
const taskSummaries = tasks.slice(0, 3).map(formatTaskCollectionItem).filter(Boolean);
const remainingTaskCount = Math.max(0, tasks.length - taskSummaries.length);
const moreText = remainingTaskCount > 0 ? `; +${remainingTaskCount} more` : '';
const countText = `${tasks.length} ${tasks.length === 1 ? 'task' : 'tasks'}`;
return {
title: canonical === 'task_briefing' ? 'Task briefing' : 'Task list',
text:
taskSummaries.length > 0
? `${countText} - ${taskSummaries.join('; ')}${moreText}`
: countText,
};
}
function formatTaskCollectionPayload(payload: Record<string, unknown>): KnownPayloadPreview | null {
for (const key of ['tasks', 'items', 'actionable'] as const) {
const value = payload[key];
if (!Array.isArray(value)) continue;
const collection = formatTaskCollectionArrayPayload(value, 'task_list');
if (collection) {
return collection;
}
}
const taskCount = countArrayField(payload, ['tasks', 'items', 'actionable']);
const summary =
stringField(payload, 'summary') ??
@ -935,6 +984,10 @@ function previewUnknownValue(
if (textBlocks) {
return previewUnknownValue(textBlocks, limit, priorityKeys, toolContext);
}
const knownCollection = formatTaskCollectionArrayPayload(value, toolContext?.canonicalName);
if (knownCollection) {
return { ...truncatePreview(knownCollection.text, limit), title: knownCollection.title };
}
const parts = value
.slice(0, 3)
.map((item) => previewUnknownValue(item, limit, priorityKeys, toolContext).preview)

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
@ -238,6 +238,7 @@ export function ExecutionLogStreamView<TStream extends ExecutionLogStreamLike>({
getSegmentMetaLabel,
}: Readonly<ExecutionLogStreamViewProps<TStream>>): React.JSX.Element {
const [selectedParticipantKey, setSelectedParticipantKey] = useState<string>('all');
const appliedSelectionResetKeyRef = useRef<string | null>(null);
const participants = stream?.participants ?? [];
const memberColorMap = useMemo(() => buildMemberColorMap([...teamMembers]), [teamMembers]);
const participantVisuals = useMemo(
@ -248,8 +249,13 @@ export function ExecutionLogStreamView<TStream extends ExecutionLogStreamLike>({
useEffect(() => {
if (!stream) {
setSelectedParticipantKey('all');
appliedSelectionResetKeyRef.current = null;
return;
}
if (appliedSelectionResetKeyRef.current === selectionResetKey) {
return;
}
appliedSelectionResetKeyRef.current = selectionResetKey;
setSelectedParticipantKey(stream.defaultFilter);
}, [selectionResetKey, stream]);

View file

@ -21,6 +21,7 @@ import {
} from '@features/codex-runtime-profile/renderer';
import { RuntimeProviderManagementPanel } from '@features/runtime-provider-management/renderer';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,

View file

@ -41,7 +41,7 @@ const {
error: null as string | null,
startedAt: null as string | null,
authUrl: null as string | null,
},
} as CodexLoginStateDto,
},
loginStateListeners: new Set<() => void>(),
loginSettledListeners: new Set<() => void>(),
@ -242,6 +242,7 @@ describe('createCodexAccountFeature', () => {
status: 'idle',
error: null,
startedAt: null,
authUrl: null,
};
loginStateListeners.clear();
loginSettledListeners.clear();

View file

@ -5,8 +5,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GraphMemberLogPreviewHud } from '@features/agent-graph/renderer/ui/GraphMemberLogPreviewHud';
import type { GraphNode } from '@claude-teams/agent-graph';
import type { MemberLogPreviewMember } from '@features/member-log-stream/contracts/dto';
const basePreviewsByMember = new Map([
const basePreviewsByMember = new Map<string, MemberLogPreviewMember>([
[
'team-lead',
{
@ -329,4 +330,89 @@ describe('GraphMemberLogPreviewHud', () => {
root.unmount();
});
});
it('keeps compact event text readable without repeating the title prefix', async () => {
mockedPreviewsByMember = new Map<string, MemberLogPreviewMember>([
[
'alice',
{
memberName: 'alice',
items: [
{
id: 'message-sent-preview',
kind: 'tool_result',
provider: 'claude_transcript',
timestamp: '2026-04-03T00:01:00.000Z',
title: 'Message sent',
preview: 'Message sent to team-lead - #abc done',
tone: 'success',
},
{
id: 'generic-tool-result-preview',
kind: 'tool_result',
provider: 'claude_transcript',
timestamp: '2026-04-03T00:00:50.000Z',
title: 'Tool result',
preview: 'stored',
tone: 'success',
},
],
coverage: [{ provider: 'claude_transcript', status: 'included' }],
warnings: [],
truncated: false,
overflowCount: 0,
generatedAt: '2026-04-03T00:01:00.000Z',
},
],
]);
const node: GraphNode = {
id: 'member:alpha-team:alice',
kind: 'member',
label: 'alice',
state: 'active',
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' },
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
<GraphMemberLogPreviewHud
teamName="alpha-team"
nodes={[node]}
getLogWorldRect={() => ({
left: 40,
top: 80,
right: 300,
bottom: 372,
width: 260,
height: 292,
})}
getCameraZoom={() => 1}
worldToScreen={(x, y) => ({ x, y })}
getViewportSize={() => ({ width: 1200, height: 800 })}
focusNodeIds={null}
/>
);
await Promise.resolve();
});
const messageRow = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('#abc done')
);
expect(messageRow?.textContent).toContain('Message sent');
expect(messageRow?.textContent).toContain('to team-lead - #abc done');
expect(messageRow?.textContent).not.toContain('Message sentMessage sent');
expect(messageRow?.textContent).not.toContain('Message sent now Message sent');
const genericResultRow = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('stored')
);
expect(genericResultRow?.textContent).toContain('Tool result');
act(() => {
root.unmount();
});
});
});

View file

@ -57,6 +57,31 @@ function response(memberName: string, generatedAt: string): MemberLogPreviewResp
};
}
function batchResponse(memberNames: string[], generatedAt: string): MemberLogPreviewResponse {
return {
generatedAt,
members: memberNames.map((memberName) => ({
memberName,
items: [
{
id: `${memberName}:${generatedAt}`,
kind: 'text',
provider: 'claude_transcript',
timestamp: generatedAt,
title: 'Assistant',
preview: memberName,
tone: 'neutral',
},
],
coverage: [{ provider: 'claude_transcript', status: 'included' }],
warnings: [],
truncated: false,
overflowCount: 0,
generatedAt,
})),
};
}
const HookProbe = ({
teamName,
memberNames,
@ -250,6 +275,65 @@ describe('useGraphMemberLogPreviews', () => {
});
});
it('does not duplicate preview requests when the same visible members are reordered', async () => {
const firstLoad = createDeferred<MemberLogPreviewResponse>();
apiMock.memberLogStream.getMemberLogPreviews.mockReturnValueOnce(firstLoad.promise);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
<HookProbe teamName="alpha-team" memberNames={['alice', 'bob']} onState={() => undefined} />
);
await Promise.resolve();
});
await act(async () => {
vi.advanceTimersByTime(700);
await Promise.resolve();
});
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith(
'alpha-team',
['alice', 'bob'],
expect.any(Object)
);
await act(async () => {
root.render(
<HookProbe teamName="alpha-team" memberNames={['bob', 'alice']} onState={() => undefined} />
);
await Promise.resolve();
});
await act(async () => {
vi.advanceTimersByTime(700);
await Promise.resolve();
});
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
await act(async () => {
firstLoad.resolve(batchResponse(['alice', 'bob'], '2026-04-03T00:00:00.000Z'));
await Promise.resolve();
});
await act(async () => {
root.render(
<HookProbe teamName="alpha-team" memberNames={['alice', 'bob']} onState={() => undefined} />
);
await Promise.resolve();
});
await act(async () => {
vi.advanceTimersByTime(700);
await Promise.resolve();
});
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
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)

View file

@ -316,6 +316,9 @@ describe('stable slot layout planner', () => {
});
expect(snapshot).not.toBeNull();
if (!snapshot) {
throw new Error('Expected stable slot layout snapshot');
}
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
for (const frame of snapshot.memberSlotFrames) {
@ -429,6 +432,9 @@ describe('stable slot layout planner', () => {
expect(snapshot).not.toBeNull();
expect(frame).toBeDefined();
expect(footprint).toBeDefined();
if (!snapshot || !frame || !footprint) {
throw new Error('Expected stable slot frame and footprint');
}
const legacyHorizontalExtent = snapshot.runtimeCentralExclusion.right;
const legacyVerticalExtent = Math.abs(snapshot.runtimeCentralExclusion.top);
@ -990,6 +996,9 @@ describe('stable slot layout planner', () => {
});
expect(snapshot).not.toBeNull();
if (!snapshot) {
throw new Error('Expected stable slot layout snapshot');
}
const targetFrame = snapshot.memberSlotFrames[1];
expect(