fix(team): preserve log stream participant filter
This commit is contained in:
parent
2e05941cba
commit
b9f82f8162
12 changed files with 453 additions and 28 deletions
|
|
@ -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>()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue