Files
qwen-code/packages/web-shell/client/components/WebShellTranscript.tsx
T
4ekuct25andСергей Панченко 68f181b885 feat(web-shell): add Russian locale for the Goal card and Goals dialog (#13685)
* feat(web-shell): add Russian locale for the Goal card and Goals dialog

The web-shell i18n shipped only en and zh-CN and collapsed every other
browser locale (e.g. ru-RU) to en in normalizeLanguage, so a Russian
user saw the Goal card and the Goals dialog in English around a
Russian objective.

Add a partial ru catalog for the goal.*/goals.* keys with Russian
pluralisation (ход/хода/ходов, токен/токена/токенов,
активная цель/активные цели/активных целей). getTranslator already
falls back per key to EN (messages[key] ?? EN[key] ?? key), and the
partial catalog relies on that for every other key — the behaviour this
locale has been running with as an installer patch.

- WEB_SHELL_LANGUAGES / LANGUAGE_LABELS: add 'ru' / 'Русский [ru]' to
  the settings picker
- normalizeLanguage: accept ru / ru-ru / russian / русский
- languageSettingToWebShellLanguage: accept the same spellings for the
  daemon's general.language setting
- WebShellProps.language / WebShellTranscriptProps.language: accept the
  'ru' and 'ru-ru' raw values
- surfaces with their own WebShellLanguage-keyed copy tables
  (StandaloneAuth, RootErrorFallback, conversation search, desktop
  relay) reuse the EN copy for ru, same partial-catalog policy
- re-measure the transcript entry size budget for the catalog
  (+4,649 to 1,325,015; ceiling raised to 1,328,000)

Part of #13391

* fix(web-shell): goals.dropped count and ru_* language normalization

goals.dropped now renders the session count with the correct Russian
plural form (it previously dropped the ${count} interpolation and
showed a bare phrase). The ru language normalizers match the ru prefix
instead of a fixed spelling list, so region-qualified values
(ru-RU, ru-UA, ru-KZ, …) and the underscore form resolve to the
partial ru catalog instead of falling through to en. Add count and
plural pins for goals.dropped and region-form pins for both
normalizers.

* chore: empty commit to retrigger CI and review

* feat(core): Goal verifier verdict inherits the session output language

The verifier's 'Last check:' reason is user-visible prose (#13391 part 2), so it should honor the session output-language preference. It was the only goal-related side query still passing skipOutputLanguagePreference: true; removing the flag makes runSideQuery append the preference. The decision enum stays schema-bound - only the reason prose localizes. Also re-measures the merged web-shell transcript entry (1,334,649 chars) and sets its size ceiling to 1_337_000 with the matching budget narrative.

* fix(web-shell): drop stray conflict marker left by the main merge

* fix(web-shell): accept /language ui ru and derive language listings from WEB_SHELL_LANGUAGES

* fix(web-shell): render goals.dropped with the count and the dialog's session term

* fix(web-shell): make the desktop relay copy table total over WebShellLanguage

* fix(vscode-ide-companion): let the web shell resolve its own UI language

* docs(web-shell): document ru in the language prop unions

* test(web-shell): pin tokensOfBudget pluralising on the budget

* revert(core): move the Goal verifier language change to companion PR #15806

The output-language change for the Goal verifier verdict is split out to
its own PR (QwenLM/qwen-code#15806) so the core change gets dedicated
review under the small-core-change rule. This PR is now web-shell only.

* test(web-shell): pin the /language error and usage copy to the shipped set

* fix(web-shell): restore the DesktopRelayControl header truncated in 705a3b799

The staged hunk for the relay copy-table change cut off the top of the
file: the license header, all imports and the DesktopRelayPhase/
DesktopRelayBlocker types were missing from the committed file, leaving
it uncompilable (tsc: 63 errors). Restore the header verbatim from the
parent commit (c5ff652b5), re-indent the extracted EN_COPY table, and
fold the ternary-free useCallback call to the single line prettier wants.
The semantic change of 705a3b799 (en table extracted to EN_COPY,
explicit ru: EN_COPY entry, ternary-free COPY[language][key] lookup)
is preserved unchanged.

* style(web-shell): prettier 3.6.1 formatting for the /language gate and README language row

---------

Co-authored-by: Сергей Панченко <[email protected]>
2026-10-29 12:49:34 +00:00

355 lines
12 KiB
TypeScript

import { getSourceEntries } from './sources/sourceEntries';
import '../styles/globals.css';
import 'katex/dist/katex.min.css';
import {
useLayoutEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type ReactElement,
} from 'react';
import type {
DaemonTranscriptBlock,
SessionSource,
DaemonSessionAttachmentReference,
} from '@qwen-code/sdk/daemon';
import { CompactModeContext, TodoContextsProvider } from '../WebShellContexts';
import {
WebShellCustomizationProvider,
type AssistantTurnFooterRenderer,
type WebShellArtifactCustomization,
type ComposerTagRenderer,
type MarkdownTableMode,
type ToolHeaderExtraRenderer,
type UserMessageContentParser,
type UserMessageContentRenderer,
type WebShellComposerTagIconMap,
type WebShellMarkdownCustomization,
type WebShellSource,
type WebShellSourceReference,
type WebShellSourceIconResolver,
} 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 '../adapters/localizedMessages';
import { WebShellPortalRootContext } from '../portalRoot';
import { computeTodoDetails, computeTodoTimeline } from '../utils/todos';
import {
ThemeProvider,
WebShellThemeId,
type WebShellTheme,
} from '../themeContext';
import {
TranscriptDocumentExpandedProvider,
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[];
renderMode?: 'readonly' | 'document';
theme?: WebShellTheme;
language?: 'en' | 'zh-CN' | 'zh' | 'zh-cn' | 'ru' | 'ru-ru';
className?: string;
style?: CSSProperties;
chatMaxWidth?: number;
workspaceCwd?: string;
compactThinking?: boolean;
documentExpanded?: boolean;
collapseCompletedTurns?: boolean;
markdownTableMode?: MarkdownTableMode;
virtualScrollThreshold?: number;
markdown?: WebShellMarkdownCustomization;
sourceSessionId?: string;
sources?: readonly SessionSource[];
sourceAttachments?: readonly DaemonSessionAttachmentReference[];
sourceReferences?: readonly WebShellSourceReference[];
getAssistantSourcesIcon?: WebShellSourceIconResolver;
onSourceOpen?: (source: WebShellSource) => void;
composerTagIcons?: WebShellComposerTagIconMap;
renderToolHeaderExtra?: ToolHeaderExtraRenderer;
artifact?: WebShellArtifactCustomization;
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,
renderMode = 'readonly',
theme = WebShellThemeId.Dark,
language,
className,
style,
chatMaxWidth,
workspaceCwd = '',
compactThinking = false,
documentExpanded = true,
collapseCompletedTurns,
markdownTableMode = 'basic',
virtualScrollThreshold,
markdown,
sourceSessionId,
sources,
sourceAttachments,
sourceReferences,
getAssistantSourcesIcon,
onSourceOpen,
composerTagIcons,
artifact,
renderToolHeaderExtra,
parseUserMessageContent,
renderUserMessageContent,
renderComposerTag,
renderComposerTagTooltip,
renderAssistantTurnFooter,
mcpAppBaseUrl,
}: WebShellTranscriptProps): ReactElement {
const documentMode = renderMode === 'document';
const effectiveCollapseCompletedTurns =
!documentMode && (collapseCompletedTurns ?? true);
const effectiveMarkdownTableMode = documentMode ? 'basic' : markdownTableMode;
const resolvedLanguage = resolveLanguage(language);
const t = useMemo(() => getTranslator(resolvedLanguage), [resolvedLanguage]);
const messages = useMemo(
() => transcriptBlocksToLocalizedMessages(blocks, t, documentMode),
[blocks, documentMode, t],
);
const sourceEntries = useMemo(
() => getSourceEntries(sources ?? [], sourceAttachments ?? []),
[sources, sourceAttachments],
);
const todoDetails = useMemo(() => computeTodoDetails(messages), [messages]);
const todoTimeline = useMemo(() => computeTodoTimeline(messages), [messages]);
const customization = useMemo(
() => ({
artifact,
composerTagIcons,
renderToolHeaderExtra,
parseUserMessageContent,
renderUserMessageContent,
renderComposerTag,
renderComposerTagTooltip,
renderAssistantTurnFooter,
compactThinking,
collapseCompletedTurns: effectiveCollapseCompletedTurns,
markdownTableMode: effectiveMarkdownTableMode,
markdown,
sourceReferences,
getAssistantSourcesIcon,
}),
[
artifact,
effectiveCollapseCompletedTurns,
compactThinking,
composerTagIcons,
markdown,
sourceReferences,
getAssistantSourcesIcon,
effectiveMarkdownTableMode,
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={renderMode}>
<TranscriptDocumentExpandedProvider value={documentExpanded}>
<WebShellCustomizationProvider value={customization}>
<TodoContextsProvider
timeline={todoTimeline}
details={todoDetails}
>
{/* Embedded read-only transcript API (not the main chat):
keep tool groups unmerged so hosts see the full record,
independent of the main app's always-on compact view. */}
<CompactModeContext.Provider value={false}>
<div
ref={rootRef}
className={rootClassName}
style={rootStyle}
data-web-shell-root
data-web-shell-shadcn
data-transcript-render-mode={renderMode}
data-document-expanded={
documentMode ? String(documentExpanded) : undefined
}
lang={resolvedLanguage}
>
<div
className={`${styles.content} ${styles.contentHasMessages}`}
>
<MessageList
messages={messages}
sourceSessionId={sourceSessionId}
sourceEntries={
documentMode ? undefined : sourceEntries
}
onSourceOpen={onSourceOpen}
pendingApproval={null}
isResponding={false}
workspaceCwd={workspaceCwd}
virtualScrollThreshold={
documentMode
? Number.MAX_SAFE_INTEGER
: virtualScrollThreshold
}
hideSessionTimeline={documentMode}
/>
</div>
</div>
</CompactModeContext.Provider>
</TodoContextsProvider>
</WebShellCustomizationProvider>
</TranscriptDocumentExpandedProvider>
</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>
);
}