mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-11 09:46:05 +00:00
* 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>
547 lines
15 KiB
TypeScript
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');
|
|
});
|