Files
qwen-code/packages/web-shell/client/components/artifacts/SideTaskPanel.tsx
T
Shaojin WenandQwen-Coder 61aef512e7 feat(web-shell): offer a details side task from pending AskUser questions (#16458)
* feat(web-shell): offer a details side task from pending AskUser questions

While an AskUserQuestion is pending, the Web Shell hides the composer and
the btw shortcut ignores the pending-approval state, so a user who does not
understand the options can only answer blindly or ignore the question.

Add an "Ask for details" action to the pending panel. It opens a side task
whose prefilled prompt snapshots the question and options and asks for a
verified explanation — background, per-option meaning, differences, impact
and risks, and a recommendation — delivered as a self-contained single-file
HTML in controlled ASD-STE100-style sentences under .qwen/tmp/, previewable
in the right panel. The side task inherits the parent context but never
answers the question or touches the main session; the question stays
pending until the user answers or ignores it.

The action is main-view-only and gated on the session_side_task capability,
matching /btw side. No daemon, protocol, or settings changes.

* fix(web-shell): one details side task per question, reachable on narrow panels

Post-publication audit of the AskUser details action found three issues:

- Repeat clicks provisioned duplicate side tasks for the same pending
  question, each burning a model turn. Give the details tab a stable id
  derived from the parent session and the permission request id and upsert
  it, reusing the artifact panel's open-or-focus pattern; /btw side keeps
  its unique per-invocation draft id.
- The question snapshot dropped multiSelect, so the explanation prompt did
  not say options can combine. Append a [multiSelect] marker (strict true).
- On a 320px two-question panel the actions row overflowed to the left and
  pushed the details button out of the panel (left: -103px in a real-browser
  probe; scrollWidth misses left overflow on a flex-end row). Let the row
  wrap and pin a narrow-viewport regression test asserting every action
  button stays inside the panel and stays clickable.

Each fix carries a red/green witness: the repeat-click App test, the
multiSelect unit test, and a flex-wrap mutation of the narrow visuals test.

* fix(web-shell): keep the details tab id within the daemon clientId budget

The dedup fix derived the details tab id from the parent session id AND the
request id. SideTaskPanel derives the pane's daemon clientId as
side-task:<parentSessionId>:<tabId>, which with two uuids reached 138 chars —
over the daemon's MAX_CLIENT_ID_LENGTH (128). The child session load then
400s, the pane never connects, and the prefilled prompt is never sent.
Caught by re-running the real-daemon scenario on the post-fix head; mocked
unit tests could not see it.

Derive the stable id from the permission request id alone (a uuid; tabs are
already bucketed per parent session), bringing the clientId to 101 chars.
Add a SideTaskPanel regression test asserting the derived clientId stays
within 128 for uuid-length inputs; the old id shape fails it with 138.

* fix(web-shell): retain the details question-to-child association across tab close

Review found that deduplicating only against open tabs loses the existing
child when the details tab closes: clicking "Ask for details" again for the
same pending request provisioned a second child, and reopening the child
from the side-task catalog first did not deduplicate either (its tab id is
side-task:<sessionId>, not the details-request id).

Persist the pending-request-to-child association per parent session in
localStorage, written when the details child is created. On click, focus
whichever open tab shows the known child (details tab or catalog tab);
with no tab open, reopen one attached to the existing child session — no
draft, no initial prompt, so nothing is provisioned or sent again. One
entry per parent supersedes the resolved question's association, keeping
the store bounded; the association also survives reloads.

Regression tests cover both reported paths (close + re-click keeps exactly
one createSideTaskSession call and reopens with the child's sessionId and
no initialPrompt; catalog reopen + click focuses the single existing tab).
Red witness: dropping the association lookup fails the close + re-click
test with two creation calls.

* fix(web-shell): close the details association lifecycle gaps

Re-review of the association persistence found three lifecycle gaps, all
fixed here with regression tests and mutation witnesses:

- A child exists before its explanation prompt is admitted. Reopening a
  closed details tab now retains the prompt on the tab; the pane's existing
  empty-session guard delivers it exactly once (only while the child has no
  user prompt) and never re-sends it.
- Archiving a details child left its persisted association pointing at an
  unloadable session (the daemon's load route 409s session_archived).
  archiveSideTasks now invalidates associations pointing at archived
  children, so the next click provisions a fresh child.
- Closing the draft tab before creation resolved skipped the association
  write, orphaning the child and provisioning a duplicate on the next click.
  The parent identity is captured when creation starts, and the write no
  longer depends on the tab still existing.

Also pin two invariants the deep-verification report found unasserted:
the derived clientId budget with uuid-length inputs through the real tab-id
derivation (App test), and the formatter's array guard against non-array
objects (formatter test).

* fix(web-shell): supply the unsent details prompt to a catalog-reopened tab

Co-authored-by: Qwen-Coder <[email protected]>

* fix(web-shell): treat media-only user prompts as interaction in the side-task guard

Co-authored-by: Qwen-Coder <[email protected]>

* fix(web-shell): validate details child archive state at the read site and address review suggestions

Co-authored-by: Qwen-Coder <[email protected]>

* fix(web-shell): replace stale details tab on fresh draft and re-check pending after the liveness query

Co-authored-by: Qwen-Coder <[email protected]>

* fix(web-shell): replace a details tab bound to a different child than the validated binding

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-11-07 19:16:53 +08:00

438 lines
12 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import {
DaemonSessionProvider,
useActions,
useConnection,
useTranscriptBlocks,
useTranscriptHistory,
useWorkspace,
} from '@qwen-code/web-shell/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 { useI18n } from '../../i18n';
import {
isModelSetupCommand,
isModelCommandSnapshotReady,
resolveModelManagement,
type WebShellModelManagementOptions,
} from '../../modelManagement';
import type {
WebShellUnavailableModel,
WebShellUnavailableModelTooltipMount,
} from '../../unavailableModels';
import { ChatPane } from '../ChatPane';
import { Button } from '../ui/button';
import { Spinner } from '../ui/spinner';
import { useSessionCatalogController } from '../../session-catalog/session-catalog-hooks';
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[],
) => void;
onError?: (error: unknown, fallback: string) => void;
sessionWorkflowEnabled?: boolean;
modelManagement?: WebShellModelManagementOptions;
unavailableModels?: readonly WebShellUnavailableModel[];
mountUnavailableModelTooltip?: WebShellUnavailableModelTooltipMount;
onImageIngestionNotice?: (tone: 'warning' | 'error', message: string) => void;
/** Policy refused the stored initial prompt; the parent drops it from the tab. */
onInitialPromptRefused?: (tabId: string) => void;
}
const FIRST_PROMPT_RENAME_ATTEMPTS = 3;
export function SideTaskPanel({
tabId,
sessionId,
parentSessionId,
workspaceCwd,
title,
shouldNameFromFirstPrompt,
initialPrompt,
createSession,
onCreated,
onTitleChange,
onRightPanelOpen,
onArtifactsChange,
onError,
sessionWorkflowEnabled,
modelManagement,
unavailableModels,
mountUnavailableModelTooltip,
onImageIngestionNotice,
onInitialPromptRefused,
}: 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}
sessionWorkflowEnabled={sessionWorkflowEnabled}
modelManagement={modelManagement}
unavailableModels={unavailableModels}
mountUnavailableModelTooltip={mountUnavailableModelTooltip}
onImageIngestionNotice={onImageIngestionNotice}
onInitialPromptRefused={onInitialPromptRefused}
/>
</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 mountedRef = useRef(true);
const [creationError, setCreationError] = useState<unknown>();
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
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 (mountedRef.current && created.displayName) {
onTitleChange(tabId, created.displayName);
}
} catch (error) {
if (!mountedRef.current) return;
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,
sessionWorkflowEnabled,
modelManagement,
unavailableModels,
mountUnavailableModelTooltip,
onImageIngestionNotice,
onInitialPromptRefused,
}: Omit<
SideTaskPanelProps,
'sessionId' | 'parentSessionId' | 'createSession' | 'onCreated'
>) {
const { t } = useI18n();
const connection = useConnection();
const actions = useActions();
const workspace = useWorkspace();
const sessionCatalogController = useSessionCatalogController(
workspace.client,
);
const blocks = useTranscriptBlocks();
const transcriptHistory = useTranscriptHistory();
const catalogOwnerCwd =
connection.workspaceCwd &&
workspaceCwd &&
connection.workspaceCwd !== workspaceCwd
? undefined
: (connection.workspaceCwd ?? workspaceCwd);
const hasTextualUserPrompt = blocks.some(
(block) =>
block.kind === 'user' &&
(typeof block.text === 'string'
? block.text.trim().length > 0
: !block.images?.length),
);
// Media-only prompts (image, file, or resource link) arrive with empty text
// (see userBlockBoundaryKey in DaemonSessionProvider). They leave the
// session unnamed, but they are still user interaction: a retained
// auto-prompt must not fire on top.
const hasUserInteraction = blocks.some((block) => {
if (block.kind !== 'user') return false;
if (
block.images?.length ||
block.files?.length ||
block.resourceLinks?.length
)
return true;
return typeof block.text !== 'string' || block.text.trim().length > 0;
});
const transcriptSettled =
connection.status === 'connected' &&
!connection.loadingTranscript &&
!connection.catchingUp &&
!transcriptHistory.loading &&
!transcriptHistory.hasMore &&
!transcriptHistory.capacityReached &&
!transcriptHistory.paginationError;
const restoredEmptySession = transcriptSettled && !hasUserInteraction;
const canNameFromFirstPrompt =
!hasTextualUserPrompt &&
(shouldNameFromFirstPrompt || (transcriptSettled && !hasTextualUserPrompt));
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);
if (connection.sessionId && catalogOwnerCwd) {
sessionCatalogController.renamed(
catalogOwnerCwd,
connection.sessionId,
nextTitle,
);
}
onTitleChange(tabId, nextTitle, true);
return;
} catch (error) {
lastError = error;
}
}
if (catalogOwnerCwd) {
sessionCatalogController.invalidateWorkspace(catalogOwnerCwd);
}
onError?.(lastError, t('sideTask.renameFailed'));
})();
},
[
actions,
catalogOwnerCwd,
connection.sessionId,
onError,
onTitleChange,
sessionCatalogController,
t,
tabId,
],
);
const initialPromptSentRef = useRef(false);
const waitingForCommandSnapshot = Boolean(
initialPrompt?.trim() &&
restoredEmptySession &&
!initialPromptSentRef.current &&
!resolveModelManagement(modelManagement).allowAdd &&
!isModelCommandSnapshotReady(connection.commands) &&
isModelSetupCommand(initialPrompt),
);
const commandWaitNoticeRef = useRef(onImageIngestionNotice);
commandWaitNoticeRef.current = onImageIngestionNotice;
useEffect(() => {
if (!waitingForCommandSnapshot) return;
const timer = setTimeout(() => {
commandWaitNoticeRef.current?.(
'warning',
t('sideTask.commandsLoadingTimedOut'),
);
}, 5_000);
return () => clearTimeout(timer);
}, [waitingForCommandSnapshot, initialPrompt, connection.sessionId, t]);
useEffect(() => {
const prompt = initialPrompt?.trim();
if (
!prompt ||
!restoredEmptySession ||
initialPromptSentRef.current ||
waitingForCommandSnapshot
)
return;
initialPromptSentRef.current = true;
if (
!resolveModelManagement(modelManagement).allowAdd &&
isModelSetupCommand(prompt, connection.commands)
) {
onImageIngestionNotice?.('warning', t('settings.models.addDisabled'));
onInitialPromptRefused?.(tabId);
return;
}
actions
.sendPrompt(prompt, {
onAdmitted: () => {
if (connection.sessionId && catalogOwnerCwd) {
sessionCatalogController.promptAdmitted(
catalogOwnerCwd,
connection.sessionId,
);
}
nameFromFirstPrompt(prompt);
},
})
.catch((error: unknown) => {
initialPromptSentRef.current = false;
onError?.(error, t('sideTask.promptFailed'));
});
}, [
actions,
catalogOwnerCwd,
connection.commands,
connection.sessionId,
initialPrompt,
modelManagement,
onImageIngestionNotice,
onInitialPromptRefused,
nameFromFirstPrompt,
onError,
restoredEmptySession,
waitingForCommandSnapshot,
sessionCatalogController,
t,
tabId,
]);
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}
onImageIngestionNotice={onImageIngestionNotice}
embedded
onFirstPromptAdmitted={
canNameFromFirstPrompt ? nameFromFirstPrompt : undefined
}
onRightPanelOpen={onRightPanelOpen}
onPaneArtifactsChange={onArtifactsChange}
sessionWorkflowEnabled={sessionWorkflowEnabled}
modelManagement={modelManagement}
unavailableModels={unavailableModels}
mountUnavailableModelTooltip={mountUnavailableModelTooltip}
/>
);
}