mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 14:38:23 -10:00
* 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 in705a3b799The 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 of705a3b799(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]>
355 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|