From 902b46b86bc2402df644474b4dffec7ccd5cac62 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:37:54 +0800 Subject: [PATCH] feat(web): localize the subagent catalog and read-only composer copy The catalog action (diagnostics, relative times, loading/error/retry, mode and activity labels, branch toggles, descendant counts, tree aria) and the read-only composer were hardcoded to Simplified Chinese, so an English-locale session rendered mixed-language UI. Register a `subagent` locale namespace (zh source of truth + en dictionary), declare it on both slot registrations, thread the locale `t` seat through the components, and mount the locale service in the plugin specs. The UI spec's zh assertions now run against the real dictionary through a `t` stub that interpolates `{name}` params exactly like the locale service. --- .../src/client/SubagentCatalogAction.tsx | 72 +++++++++++-------- .../src/client/SubagentReadOnlyComposer.tsx | 15 ++-- .../client/ui-subagent/src/client/index.ts | 14 +++- .../client/ui-subagent/src/client/locales.ts | 67 +++++++++++++++++ .../ui-subagent/tests/browser-plugin.spec.ts | 5 +- .../tests/conversation-ui.spec.tsx | 22 +++++- 6 files changed, 153 insertions(+), 42 deletions(-) create mode 100644 packages/client/ui-subagent/src/client/locales.ts diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index c23c82d77b..359827780f 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -7,7 +7,8 @@ import type { import { IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import { NS } from './locales.ts' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import css from './SubagentCatalogAction.module.css' @@ -23,7 +24,7 @@ export interface SubagentCatalogInjected { /** Full props for the session-header catalog action. */ export type SubagentCatalogActionProps = - PropsRuntime<'conversation.session.header.actions'> & SubagentCatalogInjected + PropsRuntime<'conversation.session.header.actions'> & SubagentCatalogInjected & PropsLocale interface CatalogRowsProps { parentSessionId: SessionId @@ -39,11 +40,14 @@ interface CatalogRowsProps { closeCatalog: () => void } -function diagnosticReason(entry: Extract): string { +function diagnosticReason( + entry: Extract, + t: TranslateNS, +): string { switch (entry.reason) { - case 'corrupt': return '会话记录损坏' - case 'unsupported': return '子代理记录版本不受支持' - case 'unavailable': return '会话记录暂不可用' + case 'corrupt': return t('diagnostic.corrupt') + case 'unsupported': return t('diagnostic.unsupported') + case 'unavailable': return t('diagnostic.unavailable') } } @@ -54,18 +58,22 @@ function treeItems(root: HTMLDivElement | null): HTMLElement[] { } /** Compact trailing activity time for a catalog row. */ -function relativeTime(updatedAt: number | undefined, now: number): string | undefined { +function relativeTime( + updatedAt: number | undefined, + now: number, + t: TranslateNS, +): string | undefined { if (updatedAt === undefined) return undefined const minute = 60_000 const hour = 60 * minute const day = 24 * hour const diff = Math.max(0, now - updatedAt) - if (diff < minute) return '刚刚' - if (diff < hour) return `${Math.floor(diff / minute)}分钟` - if (diff < day) return `${Math.floor(diff / hour)}小时` - if (diff < 30 * day) return `${Math.floor(diff / day)}天` - if (diff < 365 * day) return `${Math.floor(diff / (30 * day))}个月` - return `${Math.floor(diff / (365 * day))}年` + if (diff < minute) return t('time.justNow') + if (diff < hour) return t('time.minutes', { n: Math.floor(diff / minute) }) + if (diff < day) return t('time.hours', { n: Math.floor(diff / hour) }) + if (diff < 30 * day) return t('time.days', { n: Math.floor(diff / day) }) + if (diff < 365 * day) return t('time.months', { n: Math.floor(diff / (30 * day)) }) + return t('time.years', { n: Math.floor(diff / (365 * day)) }) } /** Aggregate the complete subagent-only descendant subtree from flat summaries. */ @@ -98,28 +106,30 @@ function CatalogLoadingRows({ parentSessionId, summaries, level, + t, }: { parentSessionId: SessionId summaries: Readonly> level: number + t: TranslateNS }) { const children = Object.values(summaries).filter(summary => ( summary.origin === 'subagent' && summary.parentId === parentSessionId )) - if (children.length === 0) return
正在加载子代理…
+ if (children.length === 0) return
{t('loading.label')}
return children.map(summary => (
- 正在加载子代理… + {t('loading.label')}
@@ -129,8 +139,8 @@ function CatalogLoadingRows({ /** Render one catalog level and recurse only through explicitly expanded rows. */ function CatalogRows({ parentSessionId, catalog, catalogs, summaries, expanded, level, now, - openChild, refresh, toggleBranch, closeCatalog, -}: CatalogRowsProps) { + openChild, refresh, toggleBranch, closeCatalog, t, +}: CatalogRowsProps & { t: TranslateNS }) { const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0 return ( <> @@ -139,24 +149,25 @@ function CatalogRows({ parentSessionId={parentSessionId} summaries={summaries} level={level} + t={t} /> )} {catalog.state === 'error' && (
- {catalog.error?.message ?? '无法加载子代理'} + {catalog.error?.message ?? t('load.error')}
)} {catalog.entries.map((entry) => { if (entry.kind === 'diagnostic') { - const reason = diagnosticReason(entry) + const reason = diagnosticReason(entry, t) return (
value !== undefined) .join(' · ') - const time = relativeTime(summary?.updatedAt, now) + const time = relativeTime(summary?.updatedAt, now, t) const open = (): void => { openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode }) @@ -235,7 +246,7 @@ function CatalogRows({ type="button" tabIndex={-1} className={`${css.disclosure} ${isExpanded ? css.disclosureOpen : ''}`} - aria-label={`${isExpanded ? '收起' : '展开'} ${label} 的下级子代理`} + aria-label={t(isExpanded ? 'branch.collapse' : 'branch.expand', { label })} onClick={toggle} > @@ -262,6 +273,7 @@ function CatalogRows({ parentSessionId={entry.id} summaries={summaries} level={level + 1} + t={t} /> ) : ( @@ -277,6 +289,7 @@ function CatalogRows({ refresh={refresh} toggleBranch={toggleBranch} closeCatalog={closeCatalog} + t={t} /> )}
@@ -294,7 +307,7 @@ function CatalogRows({ * @returns The action only after a non-empty catalog arrives. */ export function SubagentCatalogAction({ - sessionId, useSessions, openChild, refresh, setCatalogOpen, + sessionId, useSessions, openChild, refresh, setCatalogOpen, t, }: SubagentCatalogActionProps) { const catalogs = useSessions(state => state.subagentsByParent) const summaries = useSessions(state => state.byId) @@ -419,7 +432,7 @@ export function SubagentCatalogAction({ className={css.trigger} aria-haspopup="tree" aria-expanded={open} - aria-label={`${descendantCount} 个子代理${descendants.running ? ',正在运行' : ''}`} + aria-label={t(descendants.running ? 'count.running' : 'count.total', { count: descendantCount })} onClick={() => { changeOpen(!open) }} onKeyDown={(event) => { if (event.key !== 'ArrowDown') return @@ -431,11 +444,11 @@ export function SubagentCatalogAction({ {descendants.running && } - {descendantCount} 个子代理 + {t('count.total', { count: descendantCount })} {open && ( -
+
{ changeOpen(false) }} + t={t} />
)} diff --git a/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx b/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx index 0cc2699bd8..158e8cb77b 100644 --- a/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx +++ b/packages/client/ui-subagent/src/client/SubagentReadOnlyComposer.tsx @@ -1,4 +1,5 @@ -import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { NS } from './locales.ts' import css from './SubagentReadOnlyComposer.module.css' /** Why a catalog-addressed conversation cannot accept human input. */ @@ -8,7 +9,7 @@ export interface SubagentReadOnlyMatch { /** Full chain props after the read-only subagent selector accepts the owner currency. */ export type SubagentReadOnlyComposerProps = - PropsRuntime<'conversation.composer'> & { matched: SubagentReadOnlyMatch } + PropsRuntime<'conversation.composer'> & { matched: SubagentReadOnlyMatch } & PropsLocale /** * Explain why the normal composer is unavailable for an addressed child. @@ -16,16 +17,14 @@ export type SubagentReadOnlyComposerProps = * @returns A read-only composer replacement. */ export function SubagentReadOnlyComposer({ - matched, -}: Pick) { + matched, t, +}: Pick) { const oneShot = matched.reason === 'one-shot' return (
- {oneShot ? '一次性子代理记录' : '此子代理暂时只读'} + {t(oneShot ? 'readonly.oneShot.title' : 'readonly.title')} - {oneShot - ? '一次性任务不支持后续消息,可在这里查看完整执行记录。' - : '父会话当前不在线,重新打开父会话后即可继续发送消息。'} + {t(oneShot ? 'readonly.oneShot.body' : 'readonly.body')}
) diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 239ffd4335..31579dc258 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -18,6 +18,15 @@ import { SubagentCatalogAction, type SubagentCatalogInjected } from './SubagentC import { SubagentReadOnlyComposer, type SubagentReadOnlyMatch, } from './SubagentReadOnlyComposer.tsx' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { en, NS, zh, type SubagentKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Subagent catalog and read-only composer copy. */ + 'subagent': SubagentKey + } +} export type { SubagentCatalogActionProps, SubagentCatalogInjected, @@ -27,7 +36,7 @@ export type { } from './SubagentReadOnlyComposer.tsx' /** Required services for references, conversation slots, and session navigation. */ -export const inject = ['slash', 'sessions', 'conversation', 'slots'] +export const inject = ['slash', 'sessions', 'conversation', 'slots', 'locale'] /** Claim the composer for one-shot history or an unavailable continuation owner. */ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null { @@ -42,6 +51,7 @@ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatc * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-subagent: dictionaries') const sessions = ctx.sessions // Child labels live on the session list (parentId lineage + displayTitle), // not the conversation snapshot — the list store is the zero-RPC candidate feed. @@ -98,6 +108,7 @@ export function apply(ctx: ClientContext): void { name: 'conversation.session.header.actions', id: 'subagent-catalog', order: 10, + locale: NS, inject: catalogActions, }, SubagentCatalogAction), 'ui-subagent: lazy descendant catalog action', @@ -106,6 +117,7 @@ export function apply(ctx: ClientContext): void { () => ctx.slots.register({ name: 'conversation.composer', priority: -10, + locale: NS, select: selectReadOnlySubagent, }, SubagentReadOnlyComposer), 'ui-subagent: read-only addressed composer', diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts new file mode 100644 index 0000000000..2ecf1be4f5 --- /dev/null +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -0,0 +1,67 @@ +/** `subagent` namespace dictionaries. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'subagent' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'diagnostic.corrupt': '会话记录损坏', + 'diagnostic.unsupported': '子代理记录版本不受支持', + 'diagnostic.unavailable': '会话记录暂不可用', + 'time.justNow': '刚刚', + 'time.minutes': '{n}分钟', + 'time.hours': '{n}小时', + 'time.days': '{n}天', + 'time.months': '{n}个月', + 'time.years': '{n}年', + 'loading.label': '正在加载子代理…', + 'loading.aria': '正在加载子代理', + 'load.error': '无法加载子代理', + 'retry': '重试', + 'mode.oneShot': '一次性', + 'mode.continuable': '可继续', + 'activity.running': '正在运行', + 'activity.inactive': '当前未运行', + 'branch.collapse': '收起 {label} 的下级子代理', + 'branch.expand': '展开 {label} 的下级子代理', + 'count.total': '{count} 个子代理', + 'count.running': '{count} 个子代理,正在运行', + 'tree.aria': '子代理会话', + 'readonly.oneShot.title': '一次性子代理记录', + 'readonly.title': '此子代理暂时只读', + 'readonly.oneShot.body': '一次性任务不支持后续消息,可在这里查看完整执行记录。', + 'readonly.body': '父会话当前不在线,重新打开父会话后即可继续发送消息。', +} as const + +/** English dictionary, key-identical to the Chinese source of truth. */ +export const en: Record = { + 'diagnostic.corrupt': 'corrupted session record', + 'diagnostic.unsupported': 'unsupported subagent record version', + 'diagnostic.unavailable': 'session record temporarily unavailable', + 'time.justNow': 'just now', + 'time.minutes': '{n}m', + 'time.hours': '{n}h', + 'time.days': '{n}d', + 'time.months': '{n}mo', + 'time.years': '{n}y', + 'loading.label': 'Loading subagents…', + 'loading.aria': 'Loading subagents', + 'load.error': 'Unable to load subagents', + 'retry': 'Retry', + 'mode.oneShot': 'one-shot', + 'mode.continuable': 'continuable', + 'activity.running': 'running', + 'activity.inactive': 'not running', + 'branch.collapse': 'Collapse {label} descendants', + 'branch.expand': 'Expand {label} descendants', + 'count.total': '{count} subagents', + 'count.running': '{count} subagents running', + 'tree.aria': 'Subagent sessions', + 'readonly.oneShot.title': 'One-shot subagent record', + 'readonly.title': 'This subagent is read-only for now', + 'readonly.oneShot.body': 'One-shot tasks do not accept follow-ups; review the full execution record here.', + 'readonly.body': 'The parent session is offline; reopen it to continue sending messages.', +} + +/** Key domain of the `subagent` namespace (zh is the source of truth). */ +export type SubagentKey = keyof typeof zh diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts index 0db157a5a7..d2332cb3af 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.spec.ts @@ -18,6 +18,7 @@ import { import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' +import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' import { SubagentCatalogAction, type SubagentCatalogInjected, } from '../src/client/SubagentCatalogAction.tsx' @@ -85,6 +86,7 @@ async function fullBench(sessions: SessionSummary[]) { ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } }) ctx.provide('sessions', face) await provideSlotFaces(ctx) + await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() await ctx.plugin({ inject: [...inject], apply }).await() return { source: captured!, face, ctx } } @@ -111,7 +113,7 @@ const req = (query: string) => describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['slash', 'sessions', 'conversation', 'slots']) + expect(inject).toEqual(['slash', 'sessions', 'conversation', 'slots', 'locale']) }) it('registers the "@" subagent source; disposal frees the name (HMR safety)', async () => { @@ -119,6 +121,7 @@ describe('apply', () => { await ctx.plugin(SlashService).await() ctx.provide('sessions', sessionsWith(FAMILY)) await provideSlotFaces(ctx) + await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() const slash = ctx.get('slash') as SlashService diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index 6eb9f881c0..2e90893244 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -7,7 +7,10 @@ import type { import { SubagentCatalogAction, type SubagentCatalogActionProps, } from '../src/client/SubagentCatalogAction.tsx' -import { SubagentReadOnlyComposer } from '../src/client/SubagentReadOnlyComposer.tsx' +import { + SubagentReadOnlyComposer, type SubagentReadOnlyComposerProps, +} from '../src/client/SubagentReadOnlyComposer.tsx' +import { zh, type SubagentKey } from '../src/client/locales.ts' afterEach(() => { cleanup() @@ -63,12 +66,22 @@ function props( function useSessions(select: (snapshot: SessionListState) => T): T { return select(state) } + // The zh dictionary is the source of truth for this spec's assertions: + // the stub interpolates `{name}` params like the locale service does. + const t = ((key: SubagentKey, params?: Record): string => { + let text = zh[key] + for (const [name, value] of Object.entries(params ?? {})) { + text = text.replaceAll(`{${name}}`, String(value)) + } + return text + }) as SubagentCatalogActionProps['t'] return { sessionId: PARENT, useSessions, openChild: vi.fn(), refresh: vi.fn(), setCatalogOpen: vi.fn(), + t, } as unknown as SubagentCatalogActionProps } @@ -453,13 +466,16 @@ describe('SubagentCatalogAction', () => { }) describe('SubagentReadOnlyComposer', () => { + // The zh dictionary is the source of truth for this spec's assertions. + const t = ((key: SubagentKey): string => zh[key]) as SubagentReadOnlyComposerProps['t'] + it('explains the exact missing-parent recovery path', () => { - render() + render() expect(screen.getByRole('status').textContent).toContain('父会话当前不在线') }) it('explains that one-shot histories never accept follow-ups', () => { - render() + render() expect(screen.getByRole('status').textContent).toContain('一次性任务不支持后续消息') }) })