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.
This commit is contained in:
Tianyi Cui
2026-08-02 12:37:54 +08:00
parent 3114947324
commit 902b46b86b
6 changed files with 153 additions and 42 deletions

View File

@@ -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

View File

@@ -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<T>(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, unknown>): 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(<SubagentReadOnlyComposer matched={{ reason: 'parent-unavailable' }} />)
render(<SubagentReadOnlyComposer matched={{ reason: 'parent-unavailable' }} t={t} />)
expect(screen.getByRole('status').textContent).toContain('父会话当前不在线')
})
it('explains that one-shot histories never accept follow-ups', () => {
render(<SubagentReadOnlyComposer matched={{ reason: 'one-shot' }} />)
render(<SubagentReadOnlyComposer matched={{ reason: 'one-shot' }} t={t} />)
expect(screen.getByRole('status').textContent).toContain('一次性任务不支持后续消息')
})
})