qwen-code/packages/web-shell/client/components/artifacts/SideTaskPanel.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

285 lines
7.5 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import {
DaemonSessionProvider,
useActions,
useConnection,
useTranscriptBlocks,
useTranscriptHistory,
} from '@qwen-code/webui/daemon-react-sdk';
import {
WEB_SHELL_HISTORY_PAGE_SIZE,
WEB_SHELL_MAX_TRANSCRIPT_BLOCKS,
} from '../../constants/sessions';
import type { TurnOutputOpenRequest } from './TurnOutputs';
import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon';
import type { DaemonWorkspaceActions } from '@qwen-code/webui/daemon-react-sdk';
import { useI18n } from '../../i18n';
import { ChatPane } from '../ChatPane';
import { Button } from '../ui/button';
import { Spinner } from '../ui/spinner';
interface SideTaskPanelProps {
tabId: string;
sessionId?: string;
parentSessionId: string;
workspaceCwd?: string;
title: string;
shouldNameFromFirstPrompt?: boolean;
initialPrompt?: string;
createSession: (
tabId: string,
parentSessionId: string,
title: string,
) => Promise<{ sessionId: string; displayName?: string }>;
onCreated: (tabId: string, sessionId: string) => void;
onTitleChange: (
tabId: string,
title: string,
fromFirstPrompt?: boolean,
) => void;
onRightPanelOpen?: (request: TurnOutputOpenRequest) => void;
onArtifactsChange?: (
sessionId: string,
artifacts: readonly DaemonSessionArtifact[],
workspaceActions: DaemonWorkspaceActions,
) => void;
onError?: (error: unknown, fallback: string) => void;
}
const FIRST_PROMPT_RENAME_ATTEMPTS = 3;
export function SideTaskPanel({
tabId,
sessionId,
parentSessionId,
workspaceCwd,
title,
shouldNameFromFirstPrompt,
initialPrompt,
createSession,
onCreated,
onTitleChange,
onRightPanelOpen,
onArtifactsChange,
onError,
}: SideTaskPanelProps) {
if (!sessionId) {
return (
<SideTaskCreation
tabId={tabId}
parentSessionId={parentSessionId}
title={title}
createSession={createSession}
onCreated={onCreated}
onTitleChange={onTitleChange}
onError={onError}
/>
);
}
return (
<DaemonSessionProvider
sessionId={sessionId}
workspaceCwd={workspaceCwd}
clientId={`side-task:${parentSessionId}:${tabId}`}
autoConnect
historyPageSize={WEB_SHELL_HISTORY_PAGE_SIZE}
subagentTranscriptMode="summary"
maxBlocks={WEB_SHELL_MAX_TRANSCRIPT_BLOCKS}
suppressOwnUserEcho
>
<SideTaskSession
tabId={tabId}
title={title}
shouldNameFromFirstPrompt={shouldNameFromFirstPrompt}
initialPrompt={initialPrompt}
workspaceCwd={workspaceCwd}
onTitleChange={onTitleChange}
onRightPanelOpen={onRightPanelOpen}
onArtifactsChange={onArtifactsChange}
onError={onError}
/>
</DaemonSessionProvider>
);
}
function SideTaskCreation({
tabId,
parentSessionId,
title,
createSession,
onCreated,
onTitleChange,
onError,
}: Pick<
SideTaskPanelProps,
| 'tabId'
| 'parentSessionId'
| 'title'
| 'createSession'
| 'onCreated'
| 'onTitleChange'
| 'onError'
>) {
const { t } = useI18n();
const creatingRef = useRef(false);
const didAttemptCreateRef = useRef(false);
const [creationError, setCreationError] = useState<unknown>();
const create = useCallback(async () => {
if (creatingRef.current) return;
creatingRef.current = true;
setCreationError(undefined);
try {
const created = await createSession(tabId, parentSessionId, title);
onCreated(tabId, created.sessionId);
if (created.displayName) onTitleChange(tabId, created.displayName);
} catch (error) {
setCreationError(error);
onError?.(error, t('sideTask.createFailed'));
} finally {
creatingRef.current = false;
}
}, [
createSession,
onCreated,
onError,
onTitleChange,
parentSessionId,
t,
tabId,
title,
]);
useEffect(() => {
if (didAttemptCreateRef.current) return;
didAttemptCreateRef.current = true;
void create();
}, [create]);
if (creationError) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
<span>{t('sideTask.createFailed')}</span>
<Button type="button" variant="outline" onClick={() => void create()}>
{t('common.retry')}
</Button>
</div>
);
}
return (
<div
className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground"
role="status"
>
<Spinner />
<span>{t('sideTask.creating')}</span>
</div>
);
}
function SideTaskSession({
tabId,
title,
shouldNameFromFirstPrompt,
initialPrompt,
workspaceCwd,
onTitleChange,
onRightPanelOpen,
onArtifactsChange,
onError,
}: Omit<
SideTaskPanelProps,
'sessionId' | 'parentSessionId' | 'createSession' | 'onCreated'
>) {
const { t } = useI18n();
const connection = useConnection();
const actions = useActions();
const blocks = useTranscriptBlocks();
const transcriptHistory = useTranscriptHistory();
const hasUserPrompt = blocks.some((block) => block.kind === 'user');
const restoredEmptySession =
connection.status === 'connected' &&
!connection.loadingTranscript &&
!connection.catchingUp &&
!transcriptHistory.loading &&
!transcriptHistory.hasMore &&
!transcriptHistory.capacityReached &&
!transcriptHistory.paginationError &&
!hasUserPrompt;
const canNameFromFirstPrompt =
!hasUserPrompt && (shouldNameFromFirstPrompt || restoredEmptySession);
useEffect(() => {
const displayName = connection.displayName?.trim();
if (displayName) onTitleChange(tabId, displayName);
}, [connection.displayName, onTitleChange, tabId]);
const nameFromFirstPrompt = useCallback(
(text: string) => {
const nextTitle = Array.from(text.trim()).slice(0, 200).join('');
if (!nextTitle) return;
onTitleChange(tabId, nextTitle);
void (async () => {
let lastError: unknown;
for (
let attempt = 0;
attempt < FIRST_PROMPT_RENAME_ATTEMPTS;
attempt++
) {
try {
await actions.renameSession(nextTitle);
onTitleChange(tabId, nextTitle, true);
return;
} catch (error) {
lastError = error;
}
}
onError?.(lastError, t('sideTask.renameFailed'));
})();
},
[actions, onError, onTitleChange, t, tabId],
);
const initialPromptSentRef = useRef(false);
useEffect(() => {
const prompt = initialPrompt?.trim();
if (!prompt || !restoredEmptySession || initialPromptSentRef.current)
return;
initialPromptSentRef.current = true;
actions
.sendPrompt(prompt, {
onAdmitted: () => nameFromFirstPrompt(prompt),
})
.catch((error: unknown) => {
initialPromptSentRef.current = false;
onError?.(error, t('sideTask.promptFailed'));
});
}, [
actions,
initialPrompt,
nameFromFirstPrompt,
onError,
restoredEmptySession,
t,
]);
if (!connection.sessionId) {
return (
<div className="flex h-full items-center justify-center">
<Spinner />
</div>
);
}
return (
<ChatPane
title={connection.displayName?.trim() || title}
workspaceCwd={workspaceCwd}
onError={onError}
embedded
onFirstPromptAdmitted={
canNameFromFirstPrompt ? nameFromFirstPrompt : undefined
}
onRightPanelOpen={onRightPanelOpen}
onPaneArtifactsChange={onArtifactsChange}
/>
);
}