qwen-code/packages/web-shell/client/components/artifacts/SideTaskPanel.test.tsx
ytahdn 0a3098a279
feat(web-shell): add contextual task panels (#7929)
* feat(web-shell): add contextual task panels

* fix(web-shell): harden contextual task panels

* fix(web-shell): preserve side task titles

* fix(web-shell): address review feedback on context panels PR (#7929)

- Add POST /session/:id/side-task to telemetry route catalog (51 routes)
- Increase SDK browser bundle size limit to 184KB
- Fix duplicated data-testid="chat-pane" → "chat-pane-container" on container
- Gate sourceType behind session_source_metadata capability check
- Add removeSession cleanup after killSession in !res.writable path
- Add i18n key sideTask.renameFailed for error fallback
- Add unit tests for selectVisibleHistoryRecords invariant

* fix(cli): update telemetry-catalog route drift guard to 51 routes (#7929)

* fix(web-shell): address review feedback round 2 on context panels PR (#7929)

- Fix /fork sider discarding createSideTask() return value: show toast
  when side tasks are unavailable
- Fix layout feedback loop: availableWidth no longer depends on
  environmentPanelVisible since the CSS overlay does not change the
  chat pane DOM width
- Remove dead environmentPanelSuppressed state (never set to true)
- Restore setArtifactPanelOpen(false) in closeArtifactPanelTab when
  the last tab is closed
- Extract agentDisplayName(task) to a local variable to avoid triple
  invocation per render

* fix(web-shell): dedupe completed background agents in environment panel (#7929)

getEnvironmentAgentTasks correlated a transcript tool card with the live
/tasks snapshot only on toolUseId, the notification taskId, and a
<subagentType>-<callId> derived id. A completed background agent can lose
that linkage (its live task carries no usable toolUseId and its daemon id
is general-purpose-<internalId>), so the trailing loop appended the live
task as a second entry. Add a conservative content fallback (prompt, or
description+subagentType) mirroring the daemon's legacy resolver.

* feat(web-shell): support side tasks during active turns

* fix(web-shell): deduplicate completed subagents and gate sourceType on capability (#7929)

* fix(web-shell): restore background agent reconciliation and fix agent dedupe (#7929)

Restore the one-shot subagent reconciliation for inline background Agent tool
cards. Persisted notification records do not always retain a toolUseId, so the
SSE discrete-notification path alone can leave a card stuck in Running; the
documented fallback resolves pending cards through the subagent endpoint after
catch-up, reconnect, and terminal notifications.

Also stop the loose description content fallback in getEnvironmentAgentTasks
from claiming a live task that another transcript tool call already links
precisely (by toolUseId, message taskId, or derived id). Two agents sharing a
description previously collapsed into one: the fallback stole the linked task,
its owner re-matched the same task, and the orphan was dropped.

* fix(web-shell): address critical review feedback on context panels (#7929)

* fix(web-shell): reconcile side-task state across sessions and listings (#7929)

* fix(web-shell): preserve contextual panel fallbacks

---------

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Qwen Code Autofix <qwen-code-autofix@users.noreply.github.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
2026-07-30 13:45:30 +00:00

547 lines
15 KiB
TypeScript

// @vitest-environment jsdom
import { act, type ComponentProps, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest';
import { I18nProvider } from '../../i18n';
const {
connection,
providerProps,
latestChatPaneProps,
renameSession,
sendPrompt,
transcript,
} = vi.hoisted(() => ({
connection: {
status: 'idle',
sessionId: undefined as string | undefined,
displayName: undefined as string | undefined,
loadingTranscript: false,
catchingUp: false,
},
providerProps: {
current: undefined as Record<string, unknown> | undefined,
},
latestChatPaneProps: {
current: undefined as Record<string, unknown> | undefined,
},
renameSession: vi.fn().mockResolvedValue(undefined),
sendPrompt: vi.fn(
async (
_prompt: string,
options?: {
onAdmitted?: () => void;
},
) => {
options?.onAdmitted?.();
},
),
transcript: {
blocks: [] as Array<{ kind: string }>,
hasMore: false,
loading: false,
capacityReached: false,
paginationError: undefined as string | undefined,
},
}));
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
DaemonSessionProvider: (props: {
children: ReactNode;
[key: string]: unknown;
}) => {
providerProps.current = props;
return props.children;
},
useConnection: () => connection,
useActions: () => ({ renameSession, sendPrompt }),
useTranscriptBlocks: () => transcript.blocks,
useTranscriptHistory: () => transcript,
}));
vi.mock('../ChatPane', () => ({
ChatPane: (props: Record<string, unknown>) => {
latestChatPaneProps.current = props;
return <div data-testid="side-task-chat" />;
},
}));
const { SideTaskPanel } = await import('./SideTaskPanel');
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
let container: HTMLDivElement | null = null;
let root: Root | null = null;
function renderSideTask(
props: Partial<ComponentProps<typeof SideTaskPanel>> = {},
) {
root!.render(
<I18nProvider language="en">
<SideTaskPanel
tabId="side-task:side-session-1"
sessionId="side-session-1"
parentSessionId="parent-session"
workspaceCwd="/work/project"
title="Side task"
createSession={vi.fn()}
onCreated={vi.fn()}
onTitleChange={vi.fn()}
{...props}
/>
</I18nProvider>,
);
}
afterEach(() => {
act(() => root?.unmount());
container?.remove();
container = null;
root = null;
connection.status = 'idle';
connection.sessionId = undefined;
connection.displayName = undefined;
connection.loadingTranscript = false;
connection.catchingUp = false;
providerProps.current = undefined;
latestChatPaneProps.current = undefined;
transcript.blocks = [];
transcript.hasMore = false;
transcript.loading = false;
transcript.capacityReached = false;
transcript.paginationError = undefined;
renameSession.mockClear();
renameSession.mockResolvedValue(undefined);
sendPrompt.mockClear();
});
it('creates a side task and reports the new session id', async () => {
const onCreated = vi.fn();
const onTitleChange = vi.fn();
const createSession = vi.fn().mockResolvedValue({
sessionId: 'side-session-1',
displayName: 'Side task',
});
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root!.render(
<I18nProvider language="en">
<SideTaskPanel
tabId="side-task:draft:1"
parentSessionId="parent-session"
workspaceCwd="/work/project"
title="Side task"
createSession={createSession}
onCreated={onCreated}
onTitleChange={onTitleChange}
/>
</I18nProvider>,
);
await Promise.resolve();
await Promise.resolve();
});
expect(createSession).toHaveBeenCalledWith(
'side-task:draft:1',
'parent-session',
'Side task',
);
expect(onCreated).toHaveBeenCalledWith('side-task:draft:1', 'side-session-1');
expect(onTitleChange).toHaveBeenCalledWith('side-task:draft:1', 'Side task');
});
it('does not retry creation after a prop change until the user requests it', async () => {
const createSession = vi
.fn()
.mockRejectedValueOnce(new Error('create failed'))
.mockResolvedValue({
sessionId: 'side-session-1',
displayName: 'Renamed task',
});
const onCreated = vi.fn();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const renderDraft = (title: string) => (
<I18nProvider language="en">
<SideTaskPanel
tabId="side-task:draft:1"
parentSessionId="parent-session"
workspaceCwd="/work/project"
title={title}
createSession={createSession}
onCreated={onCreated}
onTitleChange={vi.fn()}
/>
</I18nProvider>
);
await act(async () => {
root!.render(renderDraft('Side task'));
await Promise.resolve();
await Promise.resolve();
});
expect(createSession).toHaveBeenCalledOnce();
await act(async () => {
root!.render(renderDraft('Renamed task'));
await Promise.resolve();
});
expect(createSession).toHaveBeenCalledOnce();
await act(async () => {
const retryButton = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent === 'Try again',
);
expect(retryButton).not.toBeUndefined();
retryButton?.click();
await Promise.resolve();
await Promise.resolve();
});
expect(createSession).toHaveBeenCalledTimes(2);
expect(createSession).toHaveBeenLastCalledWith(
'side-task:draft:1',
'parent-session',
'Renamed task',
);
expect(onCreated).toHaveBeenCalledWith('side-task:draft:1', 'side-session-1');
});
it('renders a restored side task as a full chat pane', () => {
connection.sessionId = 'side-session-1';
connection.displayName = 'Investigate flaky tests';
connection.status = 'connected';
transcript.blocks = [{ kind: 'user' }];
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const onTitleChange = vi.fn();
const onRightPanelOpen = vi.fn();
const onArtifactsChange = vi.fn();
act(() => {
root!.render(
<I18nProvider language="en">
<SideTaskPanel
tabId="side-task:side-session-1"
sessionId="side-session-1"
parentSessionId="parent-session"
workspaceCwd="/work/project"
title="Side task"
createSession={vi.fn()}
onCreated={vi.fn()}
onTitleChange={onTitleChange}
onRightPanelOpen={onRightPanelOpen}
onArtifactsChange={onArtifactsChange}
/>
</I18nProvider>,
);
});
expect(
container.querySelector('[data-testid="side-task-chat"]'),
).not.toBeNull();
expect(latestChatPaneProps.current).toMatchObject({
title: 'Investigate flaky tests',
workspaceCwd: '/work/project',
embedded: true,
onRightPanelOpen,
onPaneArtifactsChange: onArtifactsChange,
});
expect(
latestChatPaneProps.current?.['onFirstPromptAdmitted'],
).toBeUndefined();
expect(providerProps.current).toMatchObject({
sessionId: 'side-session-1',
workspaceCwd: '/work/project',
autoConnect: true,
});
});
it('names a restored empty side task from its first prompt', async () => {
connection.sessionId = 'side-session-1';
connection.displayName = 'Side task';
connection.status = 'connected';
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const onTitleChange = vi.fn();
act(() => {
renderSideTask({ onTitleChange });
});
await act(async () => {
(
latestChatPaneProps.current?.['onFirstPromptAdmitted'] as (
text: string,
) => void
)('Investigate restored task');
await Promise.resolve();
});
expect(renameSession).toHaveBeenCalledWith('Investigate restored task');
expect(onTitleChange).toHaveBeenCalledWith(
'side-task:side-session-1',
'Investigate restored task',
true,
);
});
it('truncates the first-prompt title by code point, not code unit', async () => {
connection.sessionId = 'side-session-1';
connection.displayName = 'Side task';
connection.status = 'connected';
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const onTitleChange = vi.fn();
act(() => {
renderSideTask({ onTitleChange });
});
const longPrompt = `${'a'.repeat(199)}\u{1F600} trailing`;
await act(async () => {
(
latestChatPaneProps.current?.['onFirstPromptAdmitted'] as (
text: string,
) => void
)(longPrompt);
await Promise.resolve();
});
const expected = `${'a'.repeat(199)}\u{1F600}`;
expect(renameSession).toHaveBeenCalledWith(expected);
expect(onTitleChange).toHaveBeenCalledWith(
'side-task:side-session-1',
expected,
true,
);
});
it('sends the /btw question as the first side-task prompt', async () => {
connection.sessionId = 'side-session-1';
connection.displayName = 'Side task';
connection.status = 'connected';
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const onTitleChange = vi.fn();
await act(async () => {
renderSideTask({
initialPrompt: 'Explain the current implementation',
onTitleChange,
});
await Promise.resolve();
});
expect(sendPrompt).toHaveBeenCalledWith(
'Explain the current implementation',
expect.objectContaining({ onAdmitted: expect.any(Function) }),
);
expect(renameSession).toHaveBeenCalledWith(
'Explain the current implementation',
);
expect(onTitleChange).toHaveBeenCalledWith(
'side-task:side-session-1',
'Explain the current implementation',
true,
);
});
it('does not rename a restored side task when older history exists', () => {
connection.sessionId = 'side-session-1';
connection.displayName = 'Existing task';
connection.status = 'connected';
transcript.hasMore = true;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => {
renderSideTask({ title: 'Existing task' });
});
expect(
latestChatPaneProps.current?.['onFirstPromptAdmitted'],
).toBeUndefined();
});
it.each([
['loading', { loading: true }],
['capacity reached', { capacityReached: true }],
['pagination failed', { paginationError: 'history unavailable' }],
])('does not rename when transcript history is incomplete: %s', (_, state) => {
connection.sessionId = 'side-session-1';
connection.status = 'connected';
Object.assign(transcript, state);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => {
renderSideTask();
});
expect(
latestChatPaneProps.current?.['onFirstPromptAdmitted'],
).toBeUndefined();
});
it('names a newly created side task from its first prompt', async () => {
connection.sessionId = 'side-session-1';
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const onTitleChange = vi.fn();
act(() => {
root!.render(
<I18nProvider language="en">
<SideTaskPanel
tabId="side-task:draft:1"
sessionId="side-session-1"
parentSessionId="parent-session"
workspaceCwd="/work/project"
title="Side task"
shouldNameFromFirstPrompt
createSession={vi.fn()}
onCreated={vi.fn()}
onTitleChange={onTitleChange}
/>
</I18nProvider>,
);
});
await act(async () => {
(
latestChatPaneProps.current?.['onFirstPromptAdmitted'] as (
text: string,
) => void
)('Investigate cache invalidation');
await Promise.resolve();
});
expect(onTitleChange).toHaveBeenCalledWith(
'side-task:draft:1',
'Investigate cache invalidation',
true,
);
expect(renameSession).toHaveBeenCalledWith('Investigate cache invalidation');
act(() => {
root!.render(null);
});
transcript.blocks = [{ kind: 'user' }];
act(() => {
root!.render(
<I18nProvider language="en">
<SideTaskPanel
tabId="side-task:draft:1"
sessionId="side-session-1"
parentSessionId="parent-session"
workspaceCwd="/work/project"
title="Investigate cache invalidation"
shouldNameFromFirstPrompt={false}
createSession={vi.fn()}
onCreated={vi.fn()}
onTitleChange={onTitleChange}
/>
</I18nProvider>,
);
});
expect(
latestChatPaneProps.current?.['onFirstPromptAdmitted'],
).toBeUndefined();
});
it('retries the first-prompt title before marking it complete', async () => {
connection.sessionId = 'side-session-1';
connection.status = 'connected';
renameSession
.mockRejectedValueOnce(new Error('temporary failure'))
.mockResolvedValueOnce(undefined);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const onTitleChange = vi.fn();
const onError = vi.fn();
act(() => {
renderSideTask({
tabId: 'side-task:draft:1',
shouldNameFromFirstPrompt: true,
onTitleChange,
onError,
});
});
await act(async () => {
(
latestChatPaneProps.current?.['onFirstPromptAdmitted'] as (
text: string,
) => void
)('Retry this title');
await Promise.resolve();
await Promise.resolve();
});
expect(renameSession).toHaveBeenCalledTimes(2);
expect(renameSession).toHaveBeenNthCalledWith(1, 'Retry this title');
expect(renameSession).toHaveBeenNthCalledWith(2, 'Retry this title');
expect(onTitleChange).toHaveBeenCalledWith(
'side-task:draft:1',
'Retry this title',
true,
);
expect(onError).not.toHaveBeenCalled();
});
it('bounds first-prompt title retries and reports the final failure', async () => {
connection.sessionId = 'side-session-1';
connection.status = 'connected';
const failure = new Error('persistent failure');
renameSession.mockRejectedValue(failure);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const onTitleChange = vi.fn();
const onError = vi.fn();
act(() => {
renderSideTask({
tabId: 'side-task:draft:1',
shouldNameFromFirstPrompt: true,
onTitleChange,
onError,
});
});
await act(async () => {
(
latestChatPaneProps.current?.['onFirstPromptAdmitted'] as (
text: string,
) => void
)('Keep this title');
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(renameSession).toHaveBeenCalledTimes(3);
expect(onTitleChange).not.toHaveBeenCalledWith(
'side-task:draft:1',
'Keep this title',
true,
);
expect(onError).toHaveBeenCalledWith(failure, 'Failed to name side task');
});