qwen-code/packages/web-shell/client/components/WebShellTranscript.tsx
ytahdn 1e921ce323
fix(web-shell): isolate component styles from host CSS (#7466)
Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
2026-07-22 02:21:17 +00:00

288 lines
9.2 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';
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;
}
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,
}: 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}>
<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>
</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>
);
}