fix(team): stabilize graph preview validation

This commit is contained in:
777genius 2026-05-07 18:07:00 +03:00
parent b9f82f8162
commit 30a6e36976
12 changed files with 224 additions and 13 deletions

View file

@ -33,6 +33,7 @@ export interface GraphConfigPort {
// ─── Filters (show/hide node kinds) ──────────────────────────────────── // ─── Filters (show/hide node kinds) ────────────────────────────────────
showActivity?: boolean; showActivity?: boolean;
showLogs?: boolean;
showTasks?: boolean; showTasks?: boolean;
showProcesses?: boolean; showProcesses?: boolean;
showCompletedTasks?: boolean; showCompletedTasks?: boolean;

View file

@ -9,6 +9,7 @@ import {
Activity, Activity,
Columns3, Columns3,
Expand, Expand,
FileText,
Settings2, Settings2,
Eye, Eye,
EyeOff, EyeOff,
@ -29,6 +30,7 @@ import type { GraphLayoutMode } from '../ports/types';
export interface GraphFilterState { export interface GraphFilterState {
showActivity: boolean; showActivity: boolean;
showLogs: boolean;
showTasks: boolean; showTasks: boolean;
showProcesses: boolean; showProcesses: boolean;
showEdges: boolean; showEdges: boolean;
@ -269,6 +271,13 @@ export function GraphControls({
label="Activity" label="Activity"
block block
/> />
<ToolbarToggle
active={filters.showLogs}
onClick={() => toggle('showLogs')}
icon={<FileText size={13} />}
label="Logs"
block
/>
<ToolbarToggle <ToolbarToggle
active={filters.showTasks} active={filters.showTasks}
onClick={() => toggle('showTasks')} onClick={() => toggle('showTasks')}

View file

@ -124,6 +124,7 @@ export function GraphView({
const [interactionLocked, setInteractionLocked] = useState(false); const [interactionLocked, setInteractionLocked] = useState(false);
const [filters, setFilters] = useState<GraphFilterState>({ const [filters, setFilters] = useState<GraphFilterState>({
showActivity: config?.showActivity ?? true, showActivity: config?.showActivity ?? true,
showLogs: config?.showLogs ?? config?.showActivity ?? true,
showTasks: config?.showTasks ?? true, showTasks: config?.showTasks ?? true,
showProcesses: config?.showProcesses ?? true, showProcesses: config?.showProcesses ?? true,
showEdges: true, showEdges: true,
@ -138,10 +139,10 @@ export function GraphView({
? { ? {
...data.layout, ...data.layout,
showActivity: filters.showActivity, showActivity: filters.showActivity,
showLogs: filters.showActivity, showLogs: filters.showLogs,
} }
: data.layout, : data.layout,
[data.layout, filters.showActivity] [data.layout, filters.showActivity, filters.showLogs]
); );
// Ref mirror of selectedNodeId — read by RAF loop to avoid recreating animate on selection change // Ref mirror of selectedNodeId — read by RAF loop to avoid recreating animate on selection change

View file

@ -416,7 +416,7 @@ export const GraphMemberLogPreviewHud = ({
key={item.id} key={item.id}
type="button" type="button"
className={[ className={[
'block h-14 min-h-14 w-full min-w-0 overflow-hidden rounded-md border px-2.5 py-1.5 text-left text-slate-400 transition-[border-color,background-color,box-shadow] duration-500 hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]', 'block h-16 min-h-16 w-full min-w-0 overflow-hidden rounded-md border px-2.5 py-1.5 text-left text-slate-400 transition-[border-color,background-color,box-shadow] duration-500 hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]',
isHighlighted isHighlighted
? 'border-sky-300/70 bg-[rgba(14,34,62,0.74)] shadow-[0_0_0_1px_rgba(125,211,252,0.30),0_0_18px_rgba(56,189,248,0.22)]' ? 'border-sky-300/70 bg-[rgba(14,34,62,0.74)] shadow-[0_0_0_1px_rgba(125,211,252,0.30),0_0_18px_rgba(56,189,248,0.22)]'
: 'border-white/10 bg-[rgba(8,14,28,0.52)]', : 'border-white/10 bg-[rgba(8,14,28,0.52)]',
@ -430,15 +430,15 @@ export const GraphMemberLogPreviewHud = ({
> >
{itemIcon(item)} {itemIcon(item)}
</span> </span>
<span className="align-top text-[11px] font-medium leading-5 text-slate-200"> <span className="align-top text-[11px] font-medium leading-4 text-slate-200">
{displayTitle} {displayTitle}
</span> </span>
{relativeTime ? ( {relativeTime ? (
<span className="ml-1 align-top text-[9px] font-normal leading-5 text-slate-500"> <span className="ml-1 align-top text-[9px] font-normal leading-4 text-slate-500">
{relativeTime} {relativeTime}
</span> </span>
) : null} ) : null}
<span className="ml-1 break-words align-top text-[10px] leading-5 text-slate-300/85"> <span className="ml-1 break-words align-top text-[10px] leading-4 text-slate-300/85">
{previewText} {previewText}
</span> </span>
</button> </button>
@ -494,7 +494,7 @@ export const GraphMemberLogPreviewHud = ({
) : ( ) : (
<button <button
type="button" type="button"
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-16 min-h-16 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, error)} {resolveEmptyText(preview, loading, error)}

View file

@ -203,7 +203,7 @@ export const TeamGraphOverlay = ({
worldToScreen={extraHudProps.worldToScreen} worldToScreen={extraHudProps.worldToScreen}
getViewportSize={getViewportSize} getViewportSize={getViewportSize}
focusNodeIds={focusNodeIds} focusNodeIds={focusNodeIds}
enabled={filters?.showActivity ?? true} enabled={filters?.showLogs ?? true}
onOpenMemberProfile={onOpenMemberProfile} onOpenMemberProfile={onOpenMemberProfile}
/> />
</> </>

View file

@ -224,7 +224,7 @@ export const TeamGraphTab = ({
worldToScreen={extraHudProps.worldToScreen} worldToScreen={extraHudProps.worldToScreen}
getViewportSize={getViewportSize} getViewportSize={getViewportSize}
focusNodeIds={focusNodeIds} focusNodeIds={focusNodeIds}
enabled={isActive && (filters?.showActivity ?? true)} enabled={isActive && (filters?.showLogs ?? true)}
onOpenMemberProfile={dispatchOpenProfile} onOpenMemberProfile={dispatchOpenProfile}
/> />
</> </>

View file

@ -645,6 +645,62 @@ Reply to this comment using MCP tool task_add_comment.
expect(result.items[0]?.preview).not.toContain('[{'); expect(result.items[0]?.preview).not.toContain('[{');
}); });
it('formats sourceToolUseID result wrappers with text-block content arrays', () => {
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: 'source-result',
type: 'user',
role: 'user',
timestamp: '2026-04-01T10:01:00.000Z',
content: '',
sourceToolUseID: 'tool-list',
toolUseResult: {
toolUseId: 'tool-list',
content: [
{
type: 'text',
text: JSON.stringify([
{
id: '4499fbe5-1fee-42a5-8584-851fbfc4adcd',
displayId: '4499fbe5',
subject: 'Fix contact form route',
status: 'todo',
owner: 'bob',
},
]),
},
],
isError: false,
},
}),
],
});
expect(result.items[0]).toMatchObject({
kind: 'tool_result',
title: 'Task list',
preview: '1 task - #4499fbe5: Fix contact form route, status todo, owner bob',
});
expect(result.items[0]?.preview).not.toContain('toolUseId');
expect(result.items[0]?.preview).not.toContain('content');
});
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',

View file

@ -240,8 +240,10 @@ function recordFromUnknownWithWrapper(
return null; return null;
} }
if (typeof record.content === 'string') { const nestedContent =
const nested = recordFromUnknownWithWrapper(record.content); typeof record.content === 'string' ? record.content : textFromTextContentBlocks(record.content);
if (nestedContent) {
const nested = recordFromUnknownWithWrapper(nestedContent);
if (nested) { if (nested) {
return nested; return nested;
} }
@ -984,7 +986,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); const knownCollection = formatTaskCollectionArrayPayload(
value,
toolContext?.canonicalName ?? null
);
if (knownCollection) { if (knownCollection) {
return { ...truncatePreview(knownCollection.text, limit), title: knownCollection.title }; return { ...truncatePreview(knownCollection.text, limit), title: knownCollection.title };
} }
@ -1000,6 +1005,13 @@ function previewUnknownValue(
if (known) { if (known) {
return { ...truncatePreview(known.text, limit), title: known.title }; return { ...truncatePreview(known.text, limit), title: known.title };
} }
for (const key of ['content', 'message', 'result'] as const) {
if (!(key in record)) continue;
const nested = previewUnknownValue(record[key], limit, priorityKeys, toolContext);
if (nested.preview.trim().length > 0) {
return nested;
}
}
const priority = findPriorityValue(record, priorityKeys); const priority = findPriorityValue(record, priorityKeys);
if (priority) { if (priority) {
return truncatePreview(priority, limit); return truncatePreview(priority, limit);

View file

@ -37,6 +37,7 @@ describe('GraphControls', () => {
React.createElement(GraphControls, { React.createElement(GraphControls, {
filters: { filters: {
showActivity: true, showActivity: true,
showLogs: true,
showTasks: true, showTasks: true,
showProcesses: true, showProcesses: true,
showEdges: true, showEdges: true,
@ -90,6 +91,7 @@ describe('GraphControls', () => {
React.createElement(GraphControls, { React.createElement(GraphControls, {
filters: { filters: {
showActivity: true, showActivity: true,
showLogs: true,
showTasks: true, showTasks: true,
showProcesses: true, showProcesses: true,
showEdges: true, showEdges: true,
@ -126,6 +128,7 @@ describe('GraphControls', () => {
React.createElement(GraphControls, { React.createElement(GraphControls, {
filters: { filters: {
showActivity: true, showActivity: true,
showLogs: true,
showTasks: true, showTasks: true,
showProcesses: true, showProcesses: true,
showEdges: true, showEdges: true,
@ -161,6 +164,67 @@ describe('GraphControls', () => {
expect(onFiltersChange).toHaveBeenCalledWith({ expect(onFiltersChange).toHaveBeenCalledWith({
showActivity: false, showActivity: false,
showLogs: true,
showTasks: true,
showProcesses: true,
showEdges: true,
paused: false,
});
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('toggles log preview visibility from graph settings independently of activity', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onFiltersChange = vi.fn();
await act(async () => {
root.render(
React.createElement(GraphControls, {
filters: {
showActivity: true,
showLogs: true,
showTasks: true,
showProcesses: true,
showEdges: true,
paused: false,
},
onFiltersChange,
onZoomIn: vi.fn(),
onZoomOut: vi.fn(),
onZoomToFit: vi.fn(),
teamName: 'demo-team',
})
);
await Promise.resolve();
});
const settingsButton = host.querySelector('button[aria-label="Graph settings"]');
expect(settingsButton).not.toBeNull();
await act(async () => {
settingsButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
const logsButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Logs')
);
expect(logsButton).not.toBeUndefined();
await act(async () => {
logsButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(onFiltersChange).toHaveBeenCalledWith({
showActivity: true,
showLogs: false,
showTasks: true, showTasks: true,
showProcesses: true, showProcesses: true,
showEdges: true, showEdges: true,
@ -184,6 +248,7 @@ describe('GraphControls', () => {
React.createElement(GraphControls, { React.createElement(GraphControls, {
filters: { filters: {
showActivity: true, showActivity: true,
showLogs: true,
showTasks: true, showTasks: true,
showProcesses: true, showProcesses: true,
showEdges: true, showEdges: true,

View file

@ -164,6 +164,8 @@ describe('GraphMemberLogPreviewHud', () => {
expect(row).not.toBeUndefined(); expect(row).not.toBeUndefined();
expect(row?.querySelector('.float-left')).not.toBeNull(); expect(row?.querySelector('.float-left')).not.toBeNull();
expect(row?.querySelector('.line-clamp-3')).toBeNull(); expect(row?.querySelector('.line-clamp-3')).toBeNull();
expect(row?.className).toContain('h-16');
expect(row?.querySelector('span.text-slate-200')?.className).toContain('leading-4');
expect(row?.textContent).toContain('pnpm test'); expect(row?.textContent).toContain('pnpm test');
const errorRow = Array.from(host.querySelectorAll('button')).find((button) => const errorRow = Array.from(host.querySelectorAll('button')).find((button) =>

View file

@ -513,7 +513,7 @@ describe('GraphView pan interactions', () => {
expect(onOwnerSlotDrop).not.toHaveBeenCalled(); expect(onOwnerSlotDrop).not.toHaveBeenCalled();
}); });
it('passes activity filter state to renderHud and updates it through graph controls', async () => { it('passes activity and log filter state to renderHud and updates it through graph controls', async () => {
const renderHud = vi.fn(() => null); const renderHud = vi.fn(() => null);
await act(async () => { await act(async () => {
@ -539,6 +539,7 @@ describe('GraphView pan interactions', () => {
expect.objectContaining({ expect.objectContaining({
filters: expect.objectContaining({ filters: expect.objectContaining({
showActivity: false, showActivity: false,
showLogs: false,
}), }),
}) })
); );
@ -546,6 +547,7 @@ describe('GraphView pan interactions', () => {
const controlsProps = hoisted.graphControlsProps as { const controlsProps = hoisted.graphControlsProps as {
filters: { filters: {
showActivity: boolean; showActivity: boolean;
showLogs: boolean;
showTasks: boolean; showTasks: boolean;
showProcesses: boolean; showProcesses: boolean;
showEdges: boolean; showEdges: boolean;
@ -553,6 +555,7 @@ describe('GraphView pan interactions', () => {
}; };
onFiltersChange: (filters: { onFiltersChange: (filters: {
showActivity: boolean; showActivity: boolean;
showLogs: boolean;
showTasks: boolean; showTasks: boolean;
showProcesses: boolean; showProcesses: boolean;
showEdges: boolean; showEdges: boolean;
@ -573,6 +576,25 @@ describe('GraphView pan interactions', () => {
expect.objectContaining({ expect.objectContaining({
filters: expect.objectContaining({ filters: expect.objectContaining({
showActivity: true, showActivity: true,
showLogs: false,
}),
})
);
await act(async () => {
controlsProps?.onFiltersChange({
...controlsProps!.filters,
showActivity: true,
showLogs: true,
});
await Promise.resolve();
});
expect(renderHud).toHaveBeenLastCalledWith(
expect.objectContaining({
filters: expect.objectContaining({
showActivity: true,
showLogs: true,
}), }),
}) })
); );

View file

@ -408,6 +408,49 @@ describe('stable slot layout planner', () => {
expect(frame?.kanbanBandRect.left).toBe(frame?.boardBandRect.left); expect(frame?.kanbanBandRect.left).toBe(frame?.boardBandRect.left);
}); });
it('keeps the reserved log column when logs are shown and activity is hidden', () => {
const teamName = 'team-logs-without-activity-slot';
const lead = createLead(teamName);
const alice = createMember(teamName, 'agent-alice', 'alice');
const layout: GraphLayoutPort = {
version: 'stable-slots-v1',
showActivity: false,
showLogs: true,
ownerOrder: [alice.id],
slotAssignments: {
[alice.id]: { ringIndex: 0, sectorIndex: 1 },
},
};
const [footprint] = computeOwnerFootprints([lead, alice], layout);
const snapshot = buildStableSlotLayoutSnapshot({
teamName,
nodes: [lead, alice],
layout,
});
const frame = snapshot?.memberSlotFrames[0];
expect(footprint).toBeDefined();
expect(footprint?.activityColumnWidth).toBe(0);
expect(footprint?.activityColumnHeight).toBe(0);
expect(footprint?.logColumnWidth).toBe(260);
expect(footprint?.logColumnHeight).toBe(
ACTIVITY_LANE.headerHeight +
ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight +
ACTIVITY_LANE.overflowHeight
);
expect(snapshot).not.toBeNull();
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
expect(frame?.activityColumnRect.width).toBe(0);
expect(frame?.activityColumnRect.height).toBe(0);
expect(frame?.logColumnRect.width).toBe(260);
expect(frame?.logColumnRect.height).toBe(
ACTIVITY_LANE.headerHeight +
ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight +
ACTIVITY_LANE.overflowHeight
);
});
it('keeps diagonal ring-zero sectors closer than the legacy coarse central box radius', () => { it('keeps diagonal ring-zero sectors closer than the legacy coarse central box radius', () => {
const teamName = 'team-directional-radius'; const teamName = 'team-directional-radius';
const lead = createLead(teamName); const lead = createLead(teamName);