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]));
|
.sort((left, right) => left[0].localeCompare(right[0]));
|
||||||
return JSON.stringify([
|
return JSON.stringify([
|
||||||
input.teamName,
|
input.teamName,
|
||||||
input.memberNames.map(normalizeMemberName),
|
input.memberNames.map(normalizeMemberName).sort((left, right) => left.localeCompare(right)),
|
||||||
laneEntries,
|
laneEntries,
|
||||||
input.maxItemsPerMember,
|
input.maxItemsPerMember,
|
||||||
input.textLimit,
|
input.textLimit,
|
||||||
|
|
@ -143,7 +143,14 @@ export function useGraphMemberLogPreviews(input: {
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [input.memberNames]);
|
}, [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(
|
const [previewsByMember, setPreviewsByMember] = useState(
|
||||||
new Map<string, MemberLogPreviewMember>()
|
new Map<string, MemberLogPreviewMember>()
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -101,8 +101,13 @@ function itemIcon(item: MemberLogPreviewItem): React.JSX.Element {
|
||||||
return <MessageSquareText className={`${className} text-slate-300`} />;
|
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 (loading && !preview) return 'Loading logs';
|
||||||
|
if (error && !preview) return 'Logs unavailable';
|
||||||
if (preview?.warnings.some((warning) => warning.code === 'codex_member_wide_not_supported')) {
|
if (preview?.warnings.some((warning) => warning.code === 'codex_member_wide_not_supported')) {
|
||||||
return 'Unsupported provider';
|
return 'Unsupported provider';
|
||||||
}
|
}
|
||||||
|
|
@ -111,12 +116,45 @@ function resolveEmptyText(preview: MemberLogPreviewMember | undefined, loading:
|
||||||
|
|
||||||
function compactDisplayTitle(item: MemberLogPreviewItem): string {
|
function compactDisplayTitle(item: MemberLogPreviewItem): string {
|
||||||
const title = item.title.trim();
|
const title = item.title.trim();
|
||||||
|
if (title.toLowerCase() === 'tool result') {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
if (item.kind === 'tool_result' && title.toLowerCase().endsWith(' result')) {
|
if (item.kind === 'tool_result' && title.toLowerCase().endsWith(' result')) {
|
||||||
return title.slice(0, -' result'.length).trim() || title;
|
return title.slice(0, -' result'.length).trim() || title;
|
||||||
}
|
}
|
||||||
return 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 {
|
function setShellHidden(shell: HTMLDivElement): void {
|
||||||
shell.style.opacity = '0';
|
shell.style.opacity = '0';
|
||||||
shell.style.pointerEvents = 'none';
|
shell.style.pointerEvents = 'none';
|
||||||
|
|
@ -155,7 +193,7 @@ export const GraphMemberLogPreviewHud = ({
|
||||||
}),
|
}),
|
||||||
[nodes]
|
[nodes]
|
||||||
);
|
);
|
||||||
const { previewsByMember, loading } = useGraphMemberLogPreviews({
|
const { previewsByMember, loading, error } = useGraphMemberLogPreviews({
|
||||||
teamName,
|
teamName,
|
||||||
memberNames: visibleMemberNames,
|
memberNames: visibleMemberNames,
|
||||||
laneIdsByMember,
|
laneIdsByMember,
|
||||||
|
|
@ -367,10 +405,10 @@ export const GraphMemberLogPreviewHud = ({
|
||||||
(memberName: string, item: MemberLogPreviewItem) => {
|
(memberName: string, item: MemberLogPreviewItem) => {
|
||||||
const relativeTime = formatRelativeTime(item.timestamp);
|
const relativeTime = formatRelativeTime(item.timestamp);
|
||||||
const displayTitle = compactDisplayTitle(item);
|
const displayTitle = compactDisplayTitle(item);
|
||||||
const previewText = item.preview || item.sourceLabel || 'Log event';
|
const previewText = compactPreviewText(item, displayTitle);
|
||||||
const titleText = relativeTime
|
const titleText = relativeTime
|
||||||
? `${item.title} ${relativeTime} ${previewText}`
|
? `${displayTitle} ${relativeTime} ${previewText}`
|
||||||
: `${item.title} ${previewText}`;
|
: `${displayTitle} ${previewText}`;
|
||||||
const isHighlighted = highlightedItemIds.has(item.id);
|
const isHighlighted = highlightedItemIds.has(item.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -392,23 +430,15 @@ export const GraphMemberLogPreviewHud = ({
|
||||||
>
|
>
|
||||||
{itemIcon(item)}
|
{itemIcon(item)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span className="align-top text-[11px] font-medium leading-5 text-slate-200">
|
||||||
className="inline-flex h-5 items-center align-top"
|
{displayTitle}
|
||||||
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>
|
</span>
|
||||||
<span
|
{relativeTime ? (
|
||||||
className="ml-1 break-words align-top text-[10px] leading-5 text-slate-300/85"
|
<span className="ml-1 align-top text-[9px] font-normal leading-5 text-slate-500">
|
||||||
style={{ position: 'relative', top: '-3px' }}
|
{relativeTime}
|
||||||
>
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="ml-1 break-words align-top text-[10px] leading-5 text-slate-300/85">
|
||||||
{previewText}
|
{previewText}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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"
|
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)}
|
onClick={() => openLogs(memberName)}
|
||||||
>
|
>
|
||||||
{resolveEmptyText(preview, loading)}
|
{resolveEmptyText(preview, loading, error)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{preview && preview.overflowCount > 0 ? (
|
{preview && preview.overflowCount > 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,16 @@ function stableInputKey(input: {
|
||||||
textLimit: number;
|
textLimit: number;
|
||||||
forceRefresh?: boolean;
|
forceRefresh?: boolean;
|
||||||
}): string {
|
}): 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([
|
return JSON.stringify([
|
||||||
input.teamName,
|
input.teamName,
|
||||||
input.members.map((member) => [normalizeMemberName(member.memberName), member.laneId ?? '']),
|
memberKeys,
|
||||||
input.maxItems,
|
input.maxItems,
|
||||||
input.textLimit,
|
input.textLimit,
|
||||||
input.forceRefresh === true,
|
input.forceRefresh === true,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { GetMemberLogPreviewsUseCase } from '../GetMemberLogPreviewsUseCase';
|
||||||
import type {
|
import type {
|
||||||
MemberLogPreviewSource,
|
MemberLogPreviewSource,
|
||||||
MemberLogPreviewSourceInput,
|
MemberLogPreviewSourceInput,
|
||||||
|
MemberLogPreviewSourceResult,
|
||||||
} from '../../ports/MemberLogPreviewSource';
|
} from '../../ports/MemberLogPreviewSource';
|
||||||
|
|
||||||
function source(
|
function source(
|
||||||
|
|
@ -16,6 +17,38 @@ function source(
|
||||||
return { provider, loadPreview };
|
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', () => {
|
describe('GetMemberLogPreviewsUseCase', () => {
|
||||||
it('dedupes members, clamps options, and merges source coverage per member', async () => {
|
it('dedupes members, clamps options, and merges source coverage per member', async () => {
|
||||||
const loadPreview = vi.fn(async (input: MemberLogPreviewSourceInput) => ({
|
const loadPreview = vi.fn(async (input: MemberLogPreviewSourceInput) => ({
|
||||||
|
|
@ -95,4 +128,38 @@ describe('GetMemberLogPreviewsUseCase', () => {
|
||||||
expect(loadPreview).toHaveBeenCalledTimes(1);
|
expect(loadPreview).toHaveBeenCalledTimes(1);
|
||||||
expect(first.members[0]?.warnings[0]?.code).toBe('codex_member_wide_not_supported');
|
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');
|
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', () => {
|
it('formats common board and cross-team tool previews compactly', () => {
|
||||||
const result = extractMemberLogPreviewItems({
|
const result = extractMemberLogPreviewItems({
|
||||||
provider: 'opencode_runtime',
|
provider: 'opencode_runtime',
|
||||||
|
|
|
||||||
|
|
@ -465,7 +465,56 @@ function countArrayField(payload: Record<string, unknown>, keys: readonly string
|
||||||
return null;
|
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 {
|
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 taskCount = countArrayField(payload, ['tasks', 'items', 'actionable']);
|
||||||
const summary =
|
const summary =
|
||||||
stringField(payload, 'summary') ??
|
stringField(payload, 'summary') ??
|
||||||
|
|
@ -935,6 +984,10 @@ function previewUnknownValue(
|
||||||
if (textBlocks) {
|
if (textBlocks) {
|
||||||
return previewUnknownValue(textBlocks, limit, priorityKeys, toolContext);
|
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
|
const parts = value
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
.map((item) => previewUnknownValue(item, limit, priorityKeys, toolContext).preview)
|
.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 { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
|
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
|
||||||
|
|
@ -238,6 +238,7 @@ export function ExecutionLogStreamView<TStream extends ExecutionLogStreamLike>({
|
||||||
getSegmentMetaLabel,
|
getSegmentMetaLabel,
|
||||||
}: Readonly<ExecutionLogStreamViewProps<TStream>>): React.JSX.Element {
|
}: Readonly<ExecutionLogStreamViewProps<TStream>>): React.JSX.Element {
|
||||||
const [selectedParticipantKey, setSelectedParticipantKey] = useState<string>('all');
|
const [selectedParticipantKey, setSelectedParticipantKey] = useState<string>('all');
|
||||||
|
const appliedSelectionResetKeyRef = useRef<string | null>(null);
|
||||||
const participants = stream?.participants ?? [];
|
const participants = stream?.participants ?? [];
|
||||||
const memberColorMap = useMemo(() => buildMemberColorMap([...teamMembers]), [teamMembers]);
|
const memberColorMap = useMemo(() => buildMemberColorMap([...teamMembers]), [teamMembers]);
|
||||||
const participantVisuals = useMemo(
|
const participantVisuals = useMemo(
|
||||||
|
|
@ -248,8 +249,13 @@ export function ExecutionLogStreamView<TStream extends ExecutionLogStreamLike>({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!stream) {
|
if (!stream) {
|
||||||
setSelectedParticipantKey('all');
|
setSelectedParticipantKey('all');
|
||||||
|
appliedSelectionResetKeyRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (appliedSelectionResetKeyRef.current === selectionResetKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
appliedSelectionResetKeyRef.current = selectionResetKey;
|
||||||
setSelectedParticipantKey(stream.defaultFilter);
|
setSelectedParticipantKey(stream.defaultFilter);
|
||||||
}, [selectionResetKey, stream]);
|
}, [selectionResetKey, stream]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
} from '@features/codex-runtime-profile/renderer';
|
} from '@features/codex-runtime-profile/renderer';
|
||||||
import { RuntimeProviderManagementPanel } from '@features/runtime-provider-management/renderer';
|
import { RuntimeProviderManagementPanel } from '@features/runtime-provider-management/renderer';
|
||||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||||
|
import { api } from '@renderer/api';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ const {
|
||||||
error: null as string | null,
|
error: null as string | null,
|
||||||
startedAt: null as string | null,
|
startedAt: null as string | null,
|
||||||
authUrl: null as string | null,
|
authUrl: null as string | null,
|
||||||
},
|
} as CodexLoginStateDto,
|
||||||
},
|
},
|
||||||
loginStateListeners: new Set<() => void>(),
|
loginStateListeners: new Set<() => void>(),
|
||||||
loginSettledListeners: new Set<() => void>(),
|
loginSettledListeners: new Set<() => void>(),
|
||||||
|
|
@ -242,6 +242,7 @@ describe('createCodexAccountFeature', () => {
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
error: null,
|
error: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
|
authUrl: null,
|
||||||
};
|
};
|
||||||
loginStateListeners.clear();
|
loginStateListeners.clear();
|
||||||
loginSettledListeners.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 { GraphMemberLogPreviewHud } from '@features/agent-graph/renderer/ui/GraphMemberLogPreviewHud';
|
||||||
|
|
||||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
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',
|
'team-lead',
|
||||||
{
|
{
|
||||||
|
|
@ -329,4 +330,89 @@ describe('GraphMemberLogPreviewHud', () => {
|
||||||
root.unmount();
|
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 = ({
|
const HookProbe = ({
|
||||||
teamName,
|
teamName,
|
||||||
memberNames,
|
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 () => {
|
it('reloads visible members on log-source events with force refresh', async () => {
|
||||||
let teamChangeListener:
|
let teamChangeListener:
|
||||||
| ((event: unknown, data: { teamName: string; type: string }) => void)
|
| ((event: unknown, data: { teamName: string; type: string }) => void)
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,9 @@ describe('stable slot layout planner', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(snapshot).not.toBeNull();
|
expect(snapshot).not.toBeNull();
|
||||||
|
if (!snapshot) {
|
||||||
|
throw new Error('Expected stable slot layout snapshot');
|
||||||
|
}
|
||||||
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
|
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
|
||||||
|
|
||||||
for (const frame of snapshot.memberSlotFrames) {
|
for (const frame of snapshot.memberSlotFrames) {
|
||||||
|
|
@ -429,6 +432,9 @@ describe('stable slot layout planner', () => {
|
||||||
expect(snapshot).not.toBeNull();
|
expect(snapshot).not.toBeNull();
|
||||||
expect(frame).toBeDefined();
|
expect(frame).toBeDefined();
|
||||||
expect(footprint).toBeDefined();
|
expect(footprint).toBeDefined();
|
||||||
|
if (!snapshot || !frame || !footprint) {
|
||||||
|
throw new Error('Expected stable slot frame and footprint');
|
||||||
|
}
|
||||||
|
|
||||||
const legacyHorizontalExtent = snapshot.runtimeCentralExclusion.right;
|
const legacyHorizontalExtent = snapshot.runtimeCentralExclusion.right;
|
||||||
const legacyVerticalExtent = Math.abs(snapshot.runtimeCentralExclusion.top);
|
const legacyVerticalExtent = Math.abs(snapshot.runtimeCentralExclusion.top);
|
||||||
|
|
@ -990,6 +996,9 @@ describe('stable slot layout planner', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(snapshot).not.toBeNull();
|
expect(snapshot).not.toBeNull();
|
||||||
|
if (!snapshot) {
|
||||||
|
throw new Error('Expected stable slot layout snapshot');
|
||||||
|
}
|
||||||
const targetFrame = snapshot.memberSlotFrames[1];
|
const targetFrame = snapshot.memberSlotFrames[1];
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue