diff --git a/apps/desktop/e2e/quote-companion.spec.ts b/apps/desktop/e2e/quote-companion.spec.ts index a364ceb012..234dfc097f 100644 --- a/apps/desktop/e2e/quote-companion.spec.ts +++ b/apps/desktop/e2e/quote-companion.spec.ts @@ -45,7 +45,7 @@ test('quote companion removes one staged quote, forks, answers, and cleans up on // Quiet composer stages quotes as drawer Tokens (Astryx Token + remove). const quoteTokens = panel.locator('.maka-composer-context-drawer .astryx-token'); await expect(quoteTokens).toHaveCount(2); - await quoteTokens.first().getByRole('button', { name: /^Remove / }).click(); + await quoteTokens.first().getByRole('button', { name: /^移除/ }).click(); await expect(quoteTokens).toHaveCount(1); // Full text authority is the companion panel list, not truncated token labels. diff --git a/packages/ui/src/__tests__/chat-view-empty-state.test.tsx b/packages/ui/src/__tests__/chat-view-empty-state.test.tsx index 1b24e1ea84..479dbd5763 100644 --- a/packages/ui/src/__tests__/chat-view-empty-state.test.tsx +++ b/packages/ui/src/__tests__/chat-view-empty-state.test.tsx @@ -174,3 +174,28 @@ describe('ChatView sent inline references', () => { assert.equal(markup.match(/astryx-badge/g)?.length, 2); }); }); + +describe('ChatView #642 streaming fallback', () => { + // The fallback renders outside TurnView, so it must localize the message + // aria-label itself — a bare ChatMessage resolves Astryx's shipped + // "Message from {sender}" and leaks English into a Chinese a11y tree. + it('localizes the fallback assistant message aria-label', () => { + // No liveTurn: a live turn projects into `turns` and takes the localized + // TurnView path instead. The fallback needs streaming with zero turns — + // wait indicators alone — which is exactly the #642 replay window. + const markup = renderToStaticMarkup( + + undefined} + /> + , + ); + + assert.match(markup, /data-live-streaming="true"/); + assert.match(markup, /aria-label="Maka 的回答"/); + assert.doesNotMatch(markup, /Message from/); + }); +}); diff --git a/packages/ui/src/__tests__/markdown-body.test.ts b/packages/ui/src/__tests__/markdown-body.test.ts index 19a045f61f..415965b9b1 100644 --- a/packages/ui/src/__tests__/markdown-body.test.ts +++ b/packages/ui/src/__tests__/markdown-body.test.ts @@ -1,4 +1,5 @@ import { strict as assert } from 'node:assert'; +import { createRequire } from 'node:module'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { it } from 'node:test'; @@ -219,15 +220,94 @@ it('localizes Astryx Markdown accessibility copy in Chinese', () => { assert.doesNotMatch(markup, />Checkbox { +// A dead-config guard used to sit here, banning override keys for Astryx +// surfaces Maka supposedly never rendered. Both of its entries rotted the +// same way: `chat` stopped being true at #1795 (ChatLayout took over the +// transcript, and the guard then blocked the fix for the English +// scroll-to-bottom pill), and `lightbox` was never true — chat-turn.tsx +// reaches Lightbox through useLightbox, which a JSX-tag scan misses. A ban +// list keyed to "what we render today" goes stale silently, so it is gone; +// the tests below pin the surfaces we know are live instead. +function assertChineseAstryxOverrides(keys: readonly string[]) { const messages = astryxMessageOverrides('zh')?.zh ?? {}; - for (const key of Object.keys(messages)) { + for (const key of keys) { + // Assert presence first: this reads the override map directly (no catalog + // resolution), so a deleted entry yields undefined → '' — which holds no + // Latin letters and would satisfy the translation check on its own. (At + // runtime the same missing entry falls back to Astryx's shipped en + // catalog, i.e. English in the UI.) + const value = messages[key]; + assert.ok(value, `missing override: ${key}`); assert.doesNotMatch( - key, - /^@astryx\.(?:lightbox|chat)/, - `dead Astryx locale override: ${key}`, + value.replace(/\{[^}]*\}/g, ''), + /[A-Za-z]/, + `untranslated: ${key}`, ); } +} + +it('localizes the Astryx chat chrome adopted in #1795', () => { + const messages = astryxMessageOverrides('zh')?.zh ?? {}; + assert.equal(messages['@astryx.chatLayout.newMessages'], '跳到最新消息'); + assert.equal(messages['@astryx.chatLayoutScrollButton.scrollToBottom'], '滚动到底部'); + assertChineseAstryxOverrides(['@astryx.chatToolCalls.error', '@astryx.chat.status.sent']); +}); + +// Whole-map sweep: every override must target a key Astryx actually ships, +// carry the same ICU arguments as the en default (a renamed placeholder +// throws at format time), and hold no Latin outside {…} segments. The pinned +// tests above cover specific regressions; this keeps the other ~70 entries +// honest without naming them one by one. +it('every zh override is a real Astryx key, translated, with matching ICU args', () => { + const require = createRequire(import.meta.url); + const catalog: Record = require( + '@astryxdesign/core/locales/en.json', + ); + // Top-level ICU argument names only: inside `{count, plural, one {result}}` + // the `{result}` is branch text, not an argument — a naive regex would + // report it and flag every zh string that drops an inapplicable plural. + const icuArgs = (message: string) => { + const args = new Set(); + let depth = 0; + for (let i = 0; i < message.length; i++) { + if (message[i] === '{') { + if (depth === 0) { + const m = /^\{\s*([a-zA-Z0-9_]+)/.exec(message.slice(i)); + if (m?.[1]) args.add(m[1]); + } + depth++; + } else if (message[i] === '}') { + depth = Math.max(0, depth - 1); + } + } + return args; + }; + const messages = astryxMessageOverrides('zh')?.zh ?? {}; + assert.ok(Object.keys(messages).length > 0); + for (const [key, value] of Object.entries(messages)) { + const shipped = catalog[key]; + assert.ok(shipped, `override targets a key Astryx does not ship: ${key}`); + assert.ok(value, `empty override: ${key}`); + assert.doesNotMatch( + value.replace(/\{[^}]*\}/g, ''), + /[A-Za-z]/, + `untranslated: ${key} = ${value}`, + ); + assert.deepEqual( + icuArgs(value), + icuArgs(shipped.defaultMessage), + `ICU argument mismatch for ${key}: zh "${value}" vs en "${shipped.defaultMessage}"`, + ); + } +}); + +it('localizes the Lightbox reached via useLightbox in chat-turn', () => { + assertChineseAstryxOverrides([ + '@astryx.lightbox.mediaViewer', + '@astryx.lightbox.close', + '@astryx.lightbox.previous', + '@astryx.lightbox.next', + ]); }); it('uses the localized Astryx code block and syntax tokenizer', () => { diff --git a/packages/ui/src/astryx-copy.ts b/packages/ui/src/astryx-copy.ts new file mode 100644 index 0000000000..fd0e4f1626 --- /dev/null +++ b/packages/ui/src/astryx-copy.ts @@ -0,0 +1,153 @@ +/** + * Chinese copy for Astryx's own message catalog, which ships no `zh`: without + * an override every `@astryx.*` string falls back to the shipped `en` catalog + * silently. Grouped by the component that renders it so a slice adopting a new + * Astryx surface can see at a glance whether its strings are already covered. + * + * Deliberately NOT exported from the package barrel (`index.ts`): the only + * consumer is `astryxMessageOverrides` in `astryx-i18n.tsx`, and per the + * README's off-barrel convention a symbol earns barrel export only with a + * cross-package consumer. Strings that Maka's own components also render live + * in `shared-ui-copy.ts` instead and are referenced from the override map, so + * shared wording keeps one home. + * + * `zh` only: `astryxMessageOverrides` returns `undefined` for `en`, which + * resolves Astryx's shipped defaults — an `en` mirror here would be dead + * config drifting against upstream. + */ +export interface AstryxCopy { + banner: { collapse: string; expand: string }; + breadcrumbs: { label: string }; + calendar: { + dayInRange: string; + dayRangeEnd: string; + dayRangeStart: string; + dayRangeStartAndEnd: string; + daySelected: string; + nextMonth: string; + previousMonth: string; + rangeCompleteAnnounce: string; + rangeStartAnnounce: string; + }; + chat: { + composerPlaceholder: string; + composerDrawerLabel: string; + composerInputLabel: string; + messageAriaLabel: string; + pastedTextExpand: string; + statusDelivered: string; + statusFailed: string; + statusRead: string; + statusSending: string; + statusSent: string; + drawerCollapse: string; + drawerExpand: string; + newMessages: string; + scrollToBottom: string; + toolCallsError: string; + toolCallsGroupLabel: string; + triggerSuggestions: string; + }; + commandPalette: { + emptyBootstrap: string; + emptySearch: string; + inputPlaceholder: string; + label: string; + noResultsFor: string; + resultCount: string; + }; + dateTime: { + closeCalendar: string; + openCalendar: string; + dialogLabel: string; + datePlaceholder: string; + timePlaceholder: string; + timeSuffix: string; + }; + inputStatus: { error: string; success: string; warning: string }; + lightbox: { mediaViewer: string; previous: string; next: string }; + menus: { dropdown: string; more: string }; + multiSelector: { clearAll: string; selectAll: string }; + /** Selector and MultiSelector render the same two search affordances. */ + search: { options: string; placeholder: string }; + sideNav: { + label: string; + resizeSidebar: string; + collapseSidebar: string; + expandSidebar: string; + itemCollapse: string; + itemExpand: string; + }; + tabList: { label: string }; + table: { label: string }; + thumbnail: { fallbackName: string; open: string; remove: string }; + token: { remove: string }; +} + +export const ASTRYX_COPY_ZH: AstryxCopy = { + banner: { collapse: '收起', expand: '展开' }, + breadcrumbs: { label: '面包屑导航' }, + calendar: { + dayInRange: '{date},在所选范围内', + dayRangeEnd: '{date},范围结束', + dayRangeStart: '{date},范围开始', + dayRangeStartAndEnd: '{date},范围开始与结束', + daySelected: '{date},已选择', + nextMonth: '下个月', + previousMonth: '上个月', + rangeCompleteAnnounce: '已选择范围:{start} 至 {end}。', + rangeStartAnnounce: '开始日期 {date}。请选择结束日期。', + }, + chat: { + composerPlaceholder: '输入消息…', + composerDrawerLabel: '附加内容', + composerInputLabel: '消息输入框', + messageAriaLabel: '消息:{status}', + pastedTextExpand: '展开', + statusDelivered: '已送达', + statusFailed: '发送失败', + statusRead: '已读', + statusSending: '发送中', + statusSent: '已发送', + drawerCollapse: '收起{label}', + drawerExpand: '展开{label}', + newMessages: '跳到最新消息', + scrollToBottom: '滚动到底部', + toolCallsError: '错误:{message}', + toolCallsGroupLabel: '{count} 次工具调用', + triggerSuggestions: '建议', + }, + commandPalette: { + emptyBootstrap: '输入以搜索', + emptySearch: '无结果', + inputPlaceholder: '搜索…', + label: '命令面板', + noResultsFor: '没有与「{query}」匹配的结果', + resultCount: '{count, number} 条结果', + }, + dateTime: { + closeCalendar: '关闭日历', + openCalendar: '打开日历', + dialogLabel: '选择日期', + datePlaceholder: '选择日期', + timePlaceholder: '选择时间', + timeSuffix: '{label}时间', + }, + inputStatus: { error: '错误详情', success: '成功详情', warning: '警告详情' }, + lightbox: { mediaViewer: '媒体查看器', previous: '上一张', next: '下一张' }, + menus: { dropdown: '菜单', more: '更多选项' }, + multiSelector: { clearAll: '清除全部{label}', selectAll: '全选' }, + search: { options: '搜索选项', placeholder: '搜索…' }, + sideNav: { + label: '侧边导航', + resizeSidebar: '调整侧边栏宽度', + collapseSidebar: '收起侧边栏', + expandSidebar: '展开侧边栏', + itemCollapse: '收起{label}', + itemExpand: '展开{label}', + }, + tabList: { label: '标签页' }, + table: { label: '表格' }, + thumbnail: { fallbackName: '缩略图', open: '打开{accessibleName}', remove: '移除{accessibleName}' }, + token: { remove: '移除{label}' }, +}; diff --git a/packages/ui/src/astryx-i18n.tsx b/packages/ui/src/astryx-i18n.tsx index 6a073d85ba..34c4611e78 100644 --- a/packages/ui/src/astryx-i18n.tsx +++ b/packages/ui/src/astryx-i18n.tsx @@ -2,6 +2,7 @@ import { useMemo, type ReactNode } from 'react'; import { InternationalizationProvider } from '@astryxdesign/core/i18n'; import type { Overrides } from '@astryxdesign/core/i18n'; import { getSharedUiCopy } from './shared-ui-copy.js'; +import { ASTRYX_COPY_ZH } from './astryx-copy.js'; import { useUiLocale } from './locale-context.js'; import type { UiLocale } from './locale-helpers.js'; @@ -56,6 +57,9 @@ export function astryxMessageOverrides(locale: UiLocale): Overrides | undefined if (locale === 'en') return undefined; const shared = getSharedUiCopy(locale); const form = shared.formControls; + // `locale` is narrowed to 'zh' past the early return; the catalogue lives + // off-barrel in astryx-copy.ts because nothing outside this map consumes it. + const astryx = ASTRYX_COPY_ZH; return { [locale]: { '@astryx.codeBlock.copyCode': shared.markdown.copyCode, @@ -75,6 +79,97 @@ export function astryxMessageOverrides(locale: UiLocale): Overrides | undefined '@astryx.selector.placeholder': form.selectPlaceholder, '@astryx.selector.clearLabel': form.clear, '@astryx.numberInput.clearLabel': form.clear, + + // Chat — the transcript, composer and scroll affordances Astryx owns + // since #1795 moved the chat surfaces onto ChatLayout. + '@astryx.chat.composer.placeholder': astryx.chat.composerPlaceholder, + '@astryx.chat.composerDrawer.label': astryx.chat.composerDrawerLabel, + '@astryx.chat.composerInput.label': astryx.chat.composerInputLabel, + '@astryx.chat.messageAriaLabel': astryx.chat.messageAriaLabel, + '@astryx.chat.pastedText.expand': astryx.chat.pastedTextExpand, + '@astryx.chat.status.delivered': astryx.chat.statusDelivered, + '@astryx.chat.status.failed': astryx.chat.statusFailed, + '@astryx.chat.status.read': astryx.chat.statusRead, + '@astryx.chat.status.sending': astryx.chat.statusSending, + '@astryx.chat.status.sent': astryx.chat.statusSent, + '@astryx.chatComposerDrawer.collapse': astryx.chat.drawerCollapse, + '@astryx.chatComposerDrawer.expand': astryx.chat.drawerExpand, + '@astryx.chatLayout.newMessages': astryx.chat.newMessages, + '@astryx.chatLayoutScrollButton.scrollToBottom': astryx.chat.scrollToBottom, + '@astryx.chatToolCalls.error': astryx.chat.toolCallsError, + '@astryx.chatToolCalls.groupLabel': astryx.chat.toolCallsGroupLabel, + '@astryx.chatTriggerMenu.suggestions': astryx.chat.triggerSuggestions, + + // Command palette — `list.label` stays a call-site override because each + // palette names its own result list. + '@astryx.commandPalette.emptyBootstrap': astryx.commandPalette.emptyBootstrap, + '@astryx.commandPalette.emptySearch': astryx.commandPalette.emptySearch, + '@astryx.commandPalette.input.placeholder': astryx.commandPalette.inputPlaceholder, + '@astryx.commandPalette.label': astryx.commandPalette.label, + '@astryx.commandPalette.loading': shared.primitives.loading, + '@astryx.commandPalette.noResultsFor': astryx.commandPalette.noResultsFor, + '@astryx.commandPalette.resultCount': astryx.commandPalette.resultCount, + + // DateTimeInput and the Calendar it opens. + '@astryx.dateInput.clear': form.clear, + '@astryx.dateInput.closeCalendar': astryx.dateTime.closeCalendar, + '@astryx.dateInput.openCalendar': astryx.dateTime.openCalendar, + '@astryx.dateInput.toggleCalendarClose': astryx.dateTime.closeCalendar, + '@astryx.dateTimeInput.dialogLabel': astryx.dateTime.dialogLabel, + '@astryx.dateTimeInput.placeholder': astryx.dateTime.datePlaceholder, + '@astryx.dateTimeInput.timePlaceholder': astryx.dateTime.timePlaceholder, + '@astryx.dateTimeInput.timeSuffix': astryx.dateTime.timeSuffix, + '@astryx.calendar.dayInRange': astryx.calendar.dayInRange, + '@astryx.calendar.dayRangeEnd': astryx.calendar.dayRangeEnd, + '@astryx.calendar.dayRangeStart': astryx.calendar.dayRangeStart, + '@astryx.calendar.dayRangeStartAndEnd': astryx.calendar.dayRangeStartAndEnd, + '@astryx.calendar.daySelected': astryx.calendar.daySelected, + '@astryx.calendar.nextMonth': astryx.calendar.nextMonth, + '@astryx.calendar.previousMonth': astryx.calendar.previousMonth, + '@astryx.calendar.rangeCompleteAnnounce': astryx.calendar.rangeCompleteAnnounce, + '@astryx.calendar.rangeStartAnnounce': astryx.calendar.rangeStartAnnounce, + + // Menus, selectors and inputs. + '@astryx.dropdownMenu.label': astryx.menus.dropdown, + '@astryx.moreMenu.label': astryx.menus.more, + '@astryx.selector.searchOptions': astryx.search.options, + '@astryx.selector.searchPlaceholder': astryx.search.placeholder, + '@astryx.multiSelector.searchOptions': astryx.search.options, + '@astryx.multiSelector.searchPlaceholder': astryx.search.placeholder, + '@astryx.multiSelector.selectPlaceholder': form.selectPlaceholder, + '@astryx.multiSelector.clearAll': astryx.multiSelector.clearAll, + '@astryx.multiSelector.selectAll': astryx.multiSelector.selectAll, + '@astryx.textInput.clearLabel': form.clear, + '@astryx.input.statusButton.error': astryx.inputStatus.error, + '@astryx.input.statusButton.success': astryx.inputStatus.success, + '@astryx.input.statusButton.warning': astryx.inputStatus.warning, + + // Lightbox — reached through useLightbox in chat-turn.tsx (image + // preview), not a element; JSX-tag scans miss it. + '@astryx.lightbox.mediaViewer': astryx.lightbox.mediaViewer, + '@astryx.lightbox.close': shared.primitives.close, + '@astryx.lightbox.previous': astryx.lightbox.previous, + '@astryx.lightbox.next': astryx.lightbox.next, + + // Shell chrome: side nav, tabs, banners, breadcrumbs, resize handles. + '@astryx.banner.collapse': astryx.banner.collapse, + '@astryx.banner.expand': astryx.banner.expand, + '@astryx.banner.dismiss': shared.primitives.close, + '@astryx.breadcrumbs.label': astryx.breadcrumbs.label, + '@astryx.sideNav.label': astryx.sideNav.label, + '@astryx.sideNav.resizeSidebar': astryx.sideNav.resizeSidebar, + '@astryx.sideNavCollapseButton.collapseSidebar': astryx.sideNav.collapseSidebar, + '@astryx.sideNavCollapseButton.expandSidebar': astryx.sideNav.expandSidebar, + '@astryx.sideNavItem.collapse': astryx.sideNav.itemCollapse, + '@astryx.sideNavItem.expand': astryx.sideNav.itemExpand, + '@astryx.tabList.label': astryx.tabList.label, + + // Table (usage settings) and the chat transcript's attachment chrome. + '@astryx.table.label': astryx.table.label, + '@astryx.thumbnail.fallbackName': astryx.thumbnail.fallbackName, + '@astryx.thumbnail.open': astryx.thumbnail.open, + '@astryx.thumbnail.remove': astryx.thumbnail.remove, + '@astryx.token.remove': astryx.token.remove, }, }; } diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 00e5c2a54d..51a0875a52 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -43,7 +43,7 @@ import { getConversationCopy } from './conversation-copy.js'; import { AstryxLocaleProvider } from './astryx-i18n.js'; import { InlineReferenceText } from './inline-reference.js'; -function LocalizedChatMessage({ +export function LocalizedChatMessage({ accessibleLabel, ...props }: Omit, 'aria-label'> & { diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 33e71ae164..d79a48b1d0 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -16,7 +16,7 @@ import type { StoredMessage, } from '@maka/core'; import { isDeepResearchSession } from '@maka/core'; -import { Button, ButtonGroup, ChatMessage, ChatMessageList, EmptyState } from '@astryxdesign/core'; +import { Button, ButtonGroup, ChatMessageList, EmptyState } from '@astryxdesign/core'; import { useChatLayoutContext } from '@astryxdesign/core/Chat'; import { useLayer } from '@astryxdesign/core/Layer'; import { materializeChat } from './materialize.js'; @@ -25,6 +25,7 @@ import type { LiveTurnProjection } from './live-turn-projection.js'; import { ModelContinuingIndicator, ModelProviderRetryIndicator, + LocalizedChatMessage, ModelProcessingIndicator, TurnView, type ReadAttachmentBytes, @@ -214,7 +215,8 @@ export function ChatView(props: { */ onAskAboutSelection?(input: { text: string; turnId?: string }): void; }) { - const copy = getConversationCopy(useUiLocale()).chat; + const conversationCopy = getConversationCopy(useUiLocale()); + const copy = conversationCopy.chat; // chat survives for the empty-state path; the main message log is driven by // `turns` (per @kenji UI-04 turn-grouping projection). const drainingMessageIdsKey = JSON.stringify( @@ -536,7 +538,11 @@ export function ChatView(props: { `tailTurnId` is undefined), so the answer never double-renders. */} {streamingActive && !tailTurnId && (
- +
{props.liveTurn?.providerRetry ? ( @@ -548,7 +554,7 @@ export function ChatView(props: { )}
)} {conversationItems