qwen-code/packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx
tanzhenxin 2a21963026
feat(web-shell): display nested sub-agents as a tree in the tasks panel (#6239)
Carry nested-agent lineage (parentAgentId, parentName, depth) through the
daemon tasks snapshot as optional fields and render the web-shell tasks
panel as a tree: children group under their parent with a ↳ marker and
clamped indentation, agents whose parent left the roster are promoted to
root with a "from <parent>" annotation, and the detail view gains a
nesting line. The [blocking] tag and the two-step stop confirmation now
apply only to provably user-blocking chains, mirroring the TUI's
agent-forest semantics from #6191.
2026-07-03 10:01:07 +00:00

214 lines
7.3 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type {
DaemonSessionAgentTaskStatus,
DaemonSessionTasksStatus,
} from '@qwen-code/sdk/daemon';
import { I18nProvider } from '../../i18n';
// The panel only needs getTasks/cancelTask from the daemon SDK; mock the
// hook so the unit test doesn't pull the whole connection graph. Hoisted
// so tests can assert on / reprogram the mocks across renders.
const { getTasksMock, cancelTaskMock } = vi.hoisted(() => ({
getTasksMock: vi.fn(),
cancelTaskMock: vi.fn(),
}));
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
useActions: () => ({
getTasks: getTasksMock,
cancelTask: cancelTaskMock,
}),
}));
const { TasksStatusMessage } = await import('./TasksStatusMessage');
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
afterEach(() => {
for (const { root, container } of mounted) {
act(() => root.unmount());
container.remove();
}
mounted.length = 0;
getTasksMock.mockReset();
cancelTaskMock.mockReset();
});
function agentTask(
id: string,
overrides: Partial<DaemonSessionAgentTaskStatus> = {},
): DaemonSessionAgentTaskStatus {
return {
kind: 'agent',
id,
label: `label-${id}`,
description: `desc-${id}`,
status: 'running',
startTime: 1_000,
runtimeMs: 5_000,
isBackgrounded: true,
subagentType: 'general-purpose',
...overrides,
};
}
function renderPanel(tasks: DaemonSessionAgentTaskStatus[]): HTMLElement {
const snapshot: DaemonSessionTasksStatus = {
v: 1,
sessionId: 'session-1',
now: 10_000,
tasks,
};
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
act(() => {
root.render(
<I18nProvider language="en">
<TasksStatusMessage message={{ snapshot }} manageActiveEvent={false} />
</I18nProvider>,
);
});
return container;
}
describe('TasksStatusMessage nested-agent tree', () => {
it('groups a child directly beneath its parent across the sort order', () => {
// Active sort alone renders newest-first: child(3000), other(2000),
// parent(1000). The tree post-pass must pull the child up under its
// parent without disturbing the other root's earned position.
const container = renderPanel([
agentTask('parent', { startTime: 1_000 }),
agentTask('other', { startTime: 2_000 }),
agentTask('child', {
startTime: 3_000,
parentAgentId: 'parent',
parentName: 'general-purpose',
depth: 1,
}),
]);
const text = container.textContent ?? '';
const posOther = text.indexOf('label-other');
const posParent = text.indexOf('label-parent');
const posChild = text.indexOf('label-child');
expect(posOther).toBeGreaterThanOrEqual(0);
expect(posParent).toBeGreaterThan(posOther);
expect(posChild).toBeGreaterThan(posParent);
});
it('marks nested rows with the ↳ marker and indents by visible depth', () => {
const container = renderPanel([
agentTask('parent'),
agentTask('child', { parentAgentId: 'parent', depth: 1 }),
]);
expect(container.textContent).toContain('↳');
const indented = container.querySelector(
'span[style*="padding-left"]',
) as HTMLElement | null;
expect(indented).not.toBeNull();
expect(indented!.style.paddingLeft).toBe('16px');
expect(indented!.textContent).toContain('label-child');
});
it('annotates an orphaned row with its departed parent instead of indenting', () => {
const container = renderPanel([
agentTask('orphan', {
parentAgentId: 'gone',
parentName: 'editor',
depth: 2,
}),
]);
const text = container.textContent ?? '';
expect(text).toContain('↳');
expect(text).toContain('from editor');
expect(container.querySelector('span[style*="padding-left"]')).toBeNull();
});
it('cancels a foreground child of a background parent on the first press', async () => {
// The two-step confirm exists to warn "cancelling ends your turn".
// A foreground child awaited by a background parent unblocks that
// parent, not the user — first press must cancel immediately, same
// as the TUI dialog's chain-aware gate.
getTasksMock.mockResolvedValue({ tasks: [] });
cancelTaskMock.mockResolvedValue({ cancelled: true });
renderPanel([
agentTask('bg-parent', { isBackgrounded: true, startTime: 2_000 }),
agentTask('fg-child', {
isBackgrounded: false,
parentAgentId: 'bg-parent',
depth: 1,
startTime: 1_000,
}),
]);
// The global keydown listener attaches after a 50 ms guard delay.
await act(async () => {
await new Promise((r) => setTimeout(r, 80));
});
const press = (key: string) =>
act(async () => {
window.dispatchEvent(new KeyboardEvent('keydown', { key }));
// Each state change re-arms the delayed listener (50 ms guard);
// wait it out so the next press isn't swallowed mid-re-attach.
await new Promise((r) => setTimeout(r, 80));
});
await press('ArrowDown'); // select the child (row 2)
await press('x');
expect(cancelTaskMock).toHaveBeenCalledTimes(1);
expect(cancelTaskMock).toHaveBeenCalledWith('fg-child', 'agent');
});
it('requires a second press to cancel a user-blocking agent', async () => {
getTasksMock.mockResolvedValue({ tasks: [] });
cancelTaskMock.mockResolvedValue({ cancelled: true });
renderPanel([
agentTask('fg-root', { isBackgrounded: false, startTime: 2_000 }),
agentTask('fg-child', {
isBackgrounded: false,
parentAgentId: 'fg-root',
depth: 1,
startTime: 1_000,
}),
]);
await act(async () => {
await new Promise((r) => setTimeout(r, 80));
});
const press = (key: string) =>
act(async () => {
window.dispatchEvent(new KeyboardEvent('keydown', { key }));
// Each state change re-arms the delayed listener (50 ms guard);
// wait it out so the next press isn't swallowed mid-re-attach.
await new Promise((r) => setTimeout(r, 80));
});
await press('x'); // fully-foreground chain → arms the confirm instead
expect(cancelTaskMock).not.toHaveBeenCalled();
await press('x'); // second press confirms
expect(cancelTaskMock).toHaveBeenCalledTimes(1);
expect(cancelTaskMock).toHaveBeenCalledWith('fg-root', 'agent');
});
it('tags [blocking] only on a fully-foreground chain', () => {
const container = renderPanel([
agentTask('bg-parent', { isBackgrounded: true }),
agentTask('fg-child', {
isBackgrounded: false,
parentAgentId: 'bg-parent',
depth: 1,
}),
agentTask('fg-root', { isBackgrounded: false }),
]);
const text = container.textContent ?? '';
// fg-root's whole chain (itself) is foreground → tagged.
expect(text).toContain('[blocking] label-fg-root');
// fg-child is awaited by a background parent → blocks that parent,
// not the user; must NOT be tagged.
expect(text).not.toContain('[blocking] label-fg-child');
expect(text).not.toContain('[blocking] label-bg-parent');
});
});