qwen-code/packages/web-shell/client/components/WebShellTranscript.tsx
samuelhsin 4ddbf227e8
feat(mcp): add MCP 2026 core and WebShell Apps host (#8992)
* feat(mcp): add 2026 protocol negotiation

* feat(mcp): render MCP Apps in WebShell

* fix(mcp): keep legacy tool discovery lenient

* fix(mcp): keep Apps HTML out of TUI and honor tool visibility

TUI and history compaction dumped mcp_app HTML as JSON, and discoverTools registered app-only tools for the model.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): stabilize AppBridge lifetime and close sandbox CSP gaps

Theme toggles and transcript reseeds were tearing down MCP Apps; the host CSP also allowed any loopback port and form posts bypassed connect-src.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): list under-declared modern MCP capabilities over the wire

v2 typed helpers return [] without a request when a capability is omitted.
Use them only when the server declared the capability, and keep Apps
unmounted in collapsed tool rows.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): keep Apps sandbox reachable and list past 64 pages

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): reject empty compacted html and keep MCP Apps expanded in multi-tool groups

Fixes R3-1 and R3-2 review comments:

R3-1: getMcpAppDisplay now rejects empty html strings (from compaction)
so session replay shows fallbackText instead of mounting an empty iframe.

R3-2: ToolGroup now checks for MCP apps across all tools (not just
singleTool), auto-expands when any tool has an MCP app, and keeps
MCP app rows expanded (summaryOnly=false, forceExpanded=true) even
when adjacent tool calls are merged into the group.

* feat(web-shell): fold thinking into the compact-mode tool summary (#9148)

Compact mode used to drop thinking messages entirely, so a running turn
gave no indication of the thinking step. Keep the thoughts and aggregate
them with the adjacent tools into one summary: a streaming thought reads
"Thinking…" with the running shimmer, and a completed thought settles into
a click-to-expand row in its original interleaved position. The translate
action is preserved on both the thinking block and the folded thought
rows, and the merged group gets a synthetic id so its expanded state never
leaks into non-compact mode.

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>

* fix(mcp): address app discovery and sandbox regressions

* fix(web-shell): keep MCP apps expanded in compact summaries

* Revert "feat(web-shell): fold thinking into the compact-mode tool summary (#9148)"

This reverts commit ab2eebc5d36f17a51ce94e423db5745dcbb273fe.

* fix(web-shell): render compacted MCP App fallback and teardown before unload

Compacted history keeps type:mcp_app with empty html; show fallbackText instead of a blank sandbox, and wait for ui/resource-teardown before unloading the iframe.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): bound the discover probe and raise the daemon bundle cap

Silent legacy stdio servers inherited the 10-minute request timeout for server/discover. Cap the probe at 5s so fallback fits the discovery window, and raise the browser bundle budget after the main merge overflowed CI by 47 bytes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): skip version-negotiation probe on remote transports

SDK v2 rejects HTTP server/discover timeouts without falling back to initialize, and the 5s probe consumed the entire remote discovery window.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web-shell): keep MCP App iframe src across deferred teardown

Deferred unload() was clearing src on the live iframe after a remount, so the new AppBridge never saw sandbox-proxy-ready.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): honor listing-level MCP App CSP and permissions

registerAppResource puts ui.csp/permissions on resources/list, and resources/read does not merge that metadata into content entries.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): reuse session client for list and emit app fallback text

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): keep mcp list and IPv6 sandbox CSP valid

Give qwen mcp list leftover handshake budget after the 5s discover probe, and stop emitting invalid [::1] CSP origins.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): keep modern list and short discovery budgets working

Drop the era-illegal ping after mcp list connect, shrink the stdio discover probe to the discovery window, and document that remotes stay on legacy initialize until the SDK can fall back.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): keep the 2026 slice free of review-only extras

Drop the global tools/list page cap, generated companion notices, and the review screenshot so this PR stays on stdio 2026 plus the WebShell Apps host.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): restore generated companion notices after the SDK v2 bump

CI regenerates NOTICES.txt from the lockfile; the file has to ship with the new MCP client dependencies.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): isolate the Apps proxy from WebShell storage

Drop allow-same-origin on the outer sandbox iframe so a default localhost daemon cannot read the WebShell session token.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(mcp): harden fallback and app sandbox

* fix(core): preserve large and app-only MCP catalogs

* fix(mcp): preserve legacy negotiation compatibility

* fix(mcp): default stdio negotiation to legacy

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: ytahdn <1294726970@qq.com>
Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
Co-authored-by: YungSen Hsin <yungsenhsin@U-G0HXNQM1-2052.local>
2026-08-23 18:34:30 +00:00

293 lines
9.5 KiB
TypeScript

import '../styles/globals.css';
import {
useLayoutEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type ReactElement,
} from 'react';
import type { DaemonTranscriptBlock } from '@qwen-code/sdk/daemon';
import {
CompactModeContext,
TodoDetailContext,
TodoTimelineContext,
} from '../App';
import {
WebShellCustomizationProvider,
type AssistantTurnFooterRenderer,
type ComposerTagRenderer,
type MarkdownTableMode,
type ToolHeaderExtraRenderer,
type UserMessageContentParser,
type UserMessageContentRenderer,
type WebShellComposerTagIconMap,
type WebShellMarkdownCustomization,
} from '../customization';
import { ErrorBoundary } from './ErrorBoundary';
import { MessageList } from './MessageList';
import { RootErrorFallback } from './RootErrorFallback';
import {
getTranslator,
I18nProvider,
normalizeLanguage,
type WebShellLanguage,
} from '../i18n';
import { transcriptBlocksToLocalizedMessages } from '../hooks/useMessages';
import { WebShellPortalRootContext } from '../portalRoot';
import { computeTodoDetails, computeTodoTimeline } from '../utils/todos';
import {
ThemeProvider,
WebShellThemeId,
type WebShellTheme,
} from '../themeContext';
import { TranscriptRenderModeProvider } from '../transcriptRenderMode';
import styles from '../App.module.css';
import { McpAppHostContext } from '../mcpAppHostContext';
const DEFAULT_CHAT_MAX_WIDTH = 1000;
const CHAT_SHELL_HORIZONTAL_PADDING = 40;
export interface WebShellTranscriptProps {
blocks: readonly DaemonTranscriptBlock[];
theme?: WebShellTheme;
language?: 'en' | 'zh-CN' | 'zh' | 'zh-cn';
className?: string;
style?: CSSProperties;
chatMaxWidth?: number;
workspaceCwd?: string;
compactThinking?: boolean;
collapseCompletedTurns?: boolean;
markdownTableMode?: MarkdownTableMode;
virtualScrollThreshold?: number;
markdown?: WebShellMarkdownCustomization;
composerTagIcons?: WebShellComposerTagIconMap;
renderToolHeaderExtra?: ToolHeaderExtraRenderer;
parseUserMessageContent?: UserMessageContentParser;
renderUserMessageContent?: UserMessageContentRenderer;
renderComposerTag?: ComposerTagRenderer;
renderComposerTagTooltip?: ComposerTagRenderer;
renderAssistantTurnFooter?: AssistantTurnFooterRenderer;
mcpAppBaseUrl?: string;
}
function resolveLanguage(
language: WebShellTranscriptProps['language'],
): WebShellLanguage {
if (language !== undefined) return normalizeLanguage(language);
if (typeof window === 'undefined') return 'en';
const params = new URLSearchParams(window.location.search);
return normalizeLanguage(
params.get('language') ?? params.get('lang') ?? navigator.language,
);
}
function getChatWidthStyle(chatMaxWidth: number | undefined): CSSProperties {
const width =
typeof chatMaxWidth === 'number' &&
Number.isFinite(chatMaxWidth) &&
chatMaxWidth > 0
? chatMaxWidth
: DEFAULT_CHAT_MAX_WIDTH;
const contentWidth = `${width}px`;
const shellWidth = `calc(${contentWidth} + ${CHAT_SHELL_HORIZONTAL_PADDING}px)`;
return {
'--chat-regular-content-width': contentWidth,
'--chat-regular-shell-width': shellWidth,
'--chat-content-width': contentWidth,
'--chat-shell-width': shellWidth,
} as CSSProperties;
}
function WebShellTranscriptContent({
blocks,
theme = WebShellThemeId.Dark,
language,
className,
style,
chatMaxWidth,
workspaceCwd = '',
compactThinking = false,
collapseCompletedTurns = true,
markdownTableMode = 'basic',
virtualScrollThreshold,
markdown,
composerTagIcons,
renderToolHeaderExtra,
parseUserMessageContent,
renderUserMessageContent,
renderComposerTag,
renderComposerTagTooltip,
renderAssistantTurnFooter,
mcpAppBaseUrl,
}: WebShellTranscriptProps): ReactElement {
const resolvedLanguage = resolveLanguage(language);
const t = useMemo(() => getTranslator(resolvedLanguage), [resolvedLanguage]);
const messages = useMemo(
() => transcriptBlocksToLocalizedMessages(blocks, t),
[blocks, t],
);
const todoDetails = useMemo(() => computeTodoDetails(messages), [messages]);
const todoTimeline = useMemo(() => computeTodoTimeline(messages), [messages]);
const customization = useMemo(
() => ({
composerTagIcons,
renderToolHeaderExtra,
parseUserMessageContent,
renderUserMessageContent,
renderComposerTag,
renderComposerTagTooltip,
renderAssistantTurnFooter,
compactThinking,
collapseCompletedTurns,
markdownTableMode,
markdown,
}),
[
collapseCompletedTurns,
compactThinking,
composerTagIcons,
markdown,
markdownTableMode,
parseUserMessageContent,
renderAssistantTurnFooter,
renderComposerTag,
renderComposerTagTooltip,
renderToolHeaderExtra,
renderUserMessageContent,
],
);
const rootRef = useRef<HTMLDivElement>(null);
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
const portalVariableNamesRef = useRef<Set<string>>(new Set());
const rootClassName = [
styles.app,
styles.appChat,
theme === WebShellThemeId.Light ? styles.themeLight : styles.themeDark,
theme === WebShellThemeId.Dark ? 'dark' : undefined,
className,
]
.filter(Boolean)
.join(' ');
const rootStyle = useMemo(
() => ({ ...style, ...getChatWidthStyle(chatMaxWidth) }),
[chatMaxWidth, style],
);
useLayoutEffect(() => {
if (typeof document === 'undefined') return;
const root = document.createElement('div');
root.dataset.webShellPortalRoot = '';
root.dataset.webShellShadcn = '';
document.body.appendChild(root);
setPortalRoot(root);
return () => {
root.remove();
setPortalRoot(null);
};
}, []);
useLayoutEffect(() => {
const root = rootRef.current;
if (!root || !portalRoot) return;
let frameId: number | null = null;
const syncVariables = () => {
frameId = null;
const computedStyle = getComputedStyle(root);
const nextNames = new Set<string>();
portalRoot.dataset.webShellShadcn = '';
portalRoot.classList.toggle('dark', theme === WebShellThemeId.Dark);
portalRoot.lang = resolvedLanguage;
for (let index = 0; index < computedStyle.length; index += 1) {
const name = computedStyle[index];
if (!name.startsWith('--')) continue;
nextNames.add(name);
portalRoot.style.setProperty(
name,
computedStyle.getPropertyValue(name),
);
}
for (const name of portalVariableNamesRef.current) {
if (!nextNames.has(name)) portalRoot.style.removeProperty(name);
}
portalVariableNamesRef.current = nextNames;
};
const scheduleSync = () => {
if (frameId === null) frameId = requestAnimationFrame(syncVariables);
};
syncVariables();
const observer = new MutationObserver(scheduleSync);
let element: HTMLElement | null = root;
while (element) {
observer.observe(element, {
attributes: true,
attributeFilter: ['class', 'style', 'data-theme', 'lang'],
});
element = element.parentElement;
}
window.addEventListener('resize', scheduleSync);
return () => {
observer.disconnect();
window.removeEventListener('resize', scheduleSync);
if (frameId !== null) cancelAnimationFrame(frameId);
};
}, [portalRoot, resolvedLanguage, rootClassName, rootStyle, theme]);
return (
<ThemeProvider value={theme}>
<I18nProvider language={resolvedLanguage}>
<McpAppHostContext.Provider value={mcpAppBaseUrl}>
<WebShellPortalRootContext.Provider value={portalRoot}>
<TranscriptRenderModeProvider value="readonly">
<WebShellCustomizationProvider value={customization}>
<TodoTimelineContext.Provider value={todoTimeline}>
<TodoDetailContext.Provider value={todoDetails}>
<CompactModeContext.Provider value={false}>
<div
ref={rootRef}
className={rootClassName}
style={rootStyle}
data-web-shell-root
data-web-shell-shadcn
lang={resolvedLanguage}
>
<div
className={`${styles.content} ${styles.contentHasMessages}`}
>
<MessageList
messages={messages}
pendingApproval={null}
isResponding={false}
workspaceCwd={workspaceCwd}
virtualScrollThreshold={virtualScrollThreshold}
/>
</div>
</div>
</CompactModeContext.Provider>
</TodoDetailContext.Provider>
</TodoTimelineContext.Provider>
</WebShellCustomizationProvider>
</TranscriptRenderModeProvider>
</WebShellPortalRootContext.Provider>
</McpAppHostContext.Provider>
</I18nProvider>
</ThemeProvider>
);
}
export function WebShellTranscript(
props: WebShellTranscriptProps,
): ReactElement {
const language = resolveLanguage(props.language);
return (
<ErrorBoundary
label="web-shell-transcript-root"
resetKeys={[props.blocks, language]}
fallback={(error, reset) => (
<RootErrorFallback error={error} onRetry={reset} language={language} />
)}
>
<WebShellTranscriptContent {...props} />
</ErrorBoundary>
);
}