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:
@@ -7,7 +7,8 @@ import type {
|
|||||||
import {
|
import {
|
||||||
IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot,
|
IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot,
|
||||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
} 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 type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||||
import css from './SubagentCatalogAction.module.css'
|
import css from './SubagentCatalogAction.module.css'
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ export interface SubagentCatalogInjected {
|
|||||||
|
|
||||||
/** Full props for the session-header catalog action. */
|
/** Full props for the session-header catalog action. */
|
||||||
export type SubagentCatalogActionProps =
|
export type SubagentCatalogActionProps =
|
||||||
PropsRuntime<'conversation.session.header.actions'> & SubagentCatalogInjected
|
PropsRuntime<'conversation.session.header.actions'> & SubagentCatalogInjected & PropsLocale<typeof NS>
|
||||||
|
|
||||||
interface CatalogRowsProps {
|
interface CatalogRowsProps {
|
||||||
parentSessionId: SessionId
|
parentSessionId: SessionId
|
||||||
@@ -39,11 +40,14 @@ interface CatalogRowsProps {
|
|||||||
closeCatalog: () => void
|
closeCatalog: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function diagnosticReason(entry: Extract<CatalogEntry, { kind: 'diagnostic' }>): string {
|
function diagnosticReason(
|
||||||
|
entry: Extract<CatalogEntry, { kind: 'diagnostic' }>,
|
||||||
|
t: TranslateNS<typeof NS>,
|
||||||
|
): string {
|
||||||
switch (entry.reason) {
|
switch (entry.reason) {
|
||||||
case 'corrupt': return '会话记录损坏'
|
case 'corrupt': return t('diagnostic.corrupt')
|
||||||
case 'unsupported': return '子代理记录版本不受支持'
|
case 'unsupported': return t('diagnostic.unsupported')
|
||||||
case 'unavailable': return '会话记录暂不可用'
|
case 'unavailable': return t('diagnostic.unavailable')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,18 +58,22 @@ function treeItems(root: HTMLDivElement | null): HTMLElement[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Compact trailing activity time for a catalog row. */
|
/** 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<typeof NS>,
|
||||||
|
): string | undefined {
|
||||||
if (updatedAt === undefined) return undefined
|
if (updatedAt === undefined) return undefined
|
||||||
const minute = 60_000
|
const minute = 60_000
|
||||||
const hour = 60 * minute
|
const hour = 60 * minute
|
||||||
const day = 24 * hour
|
const day = 24 * hour
|
||||||
const diff = Math.max(0, now - updatedAt)
|
const diff = Math.max(0, now - updatedAt)
|
||||||
if (diff < minute) return '刚刚'
|
if (diff < minute) return t('time.justNow')
|
||||||
if (diff < hour) return `${Math.floor(diff / minute)}分钟`
|
if (diff < hour) return t('time.minutes', { n: Math.floor(diff / minute) })
|
||||||
if (diff < day) return `${Math.floor(diff / hour)}小时`
|
if (diff < day) return t('time.hours', { n: Math.floor(diff / hour) })
|
||||||
if (diff < 30 * day) return `${Math.floor(diff / day)}天`
|
if (diff < 30 * day) return t('time.days', { n: Math.floor(diff / day) })
|
||||||
if (diff < 365 * day) return `${Math.floor(diff / (30 * day))}个月`
|
if (diff < 365 * day) return t('time.months', { n: Math.floor(diff / (30 * day)) })
|
||||||
return `${Math.floor(diff / (365 * day))}年`
|
return t('time.years', { n: Math.floor(diff / (365 * day)) })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Aggregate the complete subagent-only descendant subtree from flat summaries. */
|
/** Aggregate the complete subagent-only descendant subtree from flat summaries. */
|
||||||
@@ -98,28 +106,30 @@ function CatalogLoadingRows({
|
|||||||
parentSessionId,
|
parentSessionId,
|
||||||
summaries,
|
summaries,
|
||||||
level,
|
level,
|
||||||
|
t,
|
||||||
}: {
|
}: {
|
||||||
parentSessionId: SessionId
|
parentSessionId: SessionId
|
||||||
summaries: Readonly<Record<SessionId, SessionSummary>>
|
summaries: Readonly<Record<SessionId, SessionSummary>>
|
||||||
level: number
|
level: number
|
||||||
|
t: TranslateNS<typeof NS>
|
||||||
}) {
|
}) {
|
||||||
const children = Object.values(summaries).filter(summary => (
|
const children = Object.values(summaries).filter(summary => (
|
||||||
summary.origin === 'subagent' && summary.parentId === parentSessionId
|
summary.origin === 'subagent' && summary.parentId === parentSessionId
|
||||||
))
|
))
|
||||||
if (children.length === 0) return <div className={css.notice}>正在加载子代理…</div>
|
if (children.length === 0) return <div className={css.notice}>{t('loading.label')}</div>
|
||||||
return children.map(summary => (
|
return children.map(summary => (
|
||||||
<div key={summary.id} className={css.node}>
|
<div key={summary.id} className={css.node}>
|
||||||
<div
|
<div
|
||||||
role="treeitem"
|
role="treeitem"
|
||||||
aria-disabled="true"
|
aria-disabled="true"
|
||||||
aria-level={level}
|
aria-level={level}
|
||||||
aria-label="正在加载子代理"
|
aria-label={t('loading.aria')}
|
||||||
className={`${css.row} ${css.disabled} ${css.loadingRow}`}
|
className={`${css.row} ${css.disabled} ${css.loadingRow}`}
|
||||||
>
|
>
|
||||||
<span className={css.disclosureSpace} />
|
<span className={css.disclosureSpace} />
|
||||||
<StateDot state={summary.running ? 'ongoing' : 'done'} />
|
<StateDot state={summary.running ? 'ongoing' : 'done'} />
|
||||||
<span className={css.content}>
|
<span className={css.content}>
|
||||||
<span className={css.label}>正在加载子代理…</span>
|
<span className={css.label}>{t('loading.label')}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,8 +139,8 @@ function CatalogLoadingRows({
|
|||||||
/** Render one catalog level and recurse only through explicitly expanded rows. */
|
/** Render one catalog level and recurse only through explicitly expanded rows. */
|
||||||
function CatalogRows({
|
function CatalogRows({
|
||||||
parentSessionId, catalog, catalogs, summaries, expanded, level, now,
|
parentSessionId, catalog, catalogs, summaries, expanded, level, now,
|
||||||
openChild, refresh, toggleBranch, closeCatalog,
|
openChild, refresh, toggleBranch, closeCatalog, t,
|
||||||
}: CatalogRowsProps) {
|
}: CatalogRowsProps & { t: TranslateNS<typeof NS> }) {
|
||||||
const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0
|
const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -139,24 +149,25 @@ function CatalogRows({
|
|||||||
parentSessionId={parentSessionId}
|
parentSessionId={parentSessionId}
|
||||||
summaries={summaries}
|
summaries={summaries}
|
||||||
level={level}
|
level={level}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{catalog.state === 'error' && (
|
{catalog.state === 'error' && (
|
||||||
<div className={css.error}>
|
<div className={css.error}>
|
||||||
<span>{catalog.error?.message ?? '无法加载子代理'}</span>
|
<span>{catalog.error?.message ?? t('load.error')}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={css.refresh}
|
className={css.refresh}
|
||||||
onClick={() => { refresh(parentSessionId) }}
|
onClick={() => { refresh(parentSessionId) }}
|
||||||
>
|
>
|
||||||
<IconRefreshOutline14 />
|
<IconRefreshOutline14 />
|
||||||
重试
|
{t('retry')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{catalog.entries.map((entry) => {
|
{catalog.entries.map((entry) => {
|
||||||
if (entry.kind === 'diagnostic') {
|
if (entry.kind === 'diagnostic') {
|
||||||
const reason = diagnosticReason(entry)
|
const reason = diagnosticReason(entry, t)
|
||||||
return (
|
return (
|
||||||
<div key={entry.id} className={css.node}>
|
<div key={entry.id} className={css.node}>
|
||||||
<div
|
<div
|
||||||
@@ -185,12 +196,12 @@ function CatalogRows({
|
|||||||
|| (childCatalog.state === 'loading' && childCatalog.entries.length === 0)
|
|| (childCatalog.state === 'loading' && childCatalog.entries.length === 0)
|
||||||
const summary = summaries[entry.id]
|
const summary = summaries[entry.id]
|
||||||
const label = entry.label ?? entry.id
|
const label = entry.label ?? entry.id
|
||||||
const mode = entry.mode === 'one-shot' ? '一次性' : '可继续'
|
const mode = entry.mode === 'one-shot' ? t('mode.oneShot') : t('mode.continuable')
|
||||||
const activity = entry.activity === 'running' ? '正在运行' : '当前未运行'
|
const activity = entry.activity === 'running' ? t('activity.running') : t('activity.inactive')
|
||||||
const secondary = [summary?.title, mode, activity]
|
const secondary = [summary?.title, mode, activity]
|
||||||
.filter(value => value !== undefined)
|
.filter(value => value !== undefined)
|
||||||
.join(' · ')
|
.join(' · ')
|
||||||
const time = relativeTime(summary?.updatedAt, now)
|
const time = relativeTime(summary?.updatedAt, now, t)
|
||||||
|
|
||||||
const open = (): void => {
|
const open = (): void => {
|
||||||
openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode })
|
openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode })
|
||||||
@@ -235,7 +246,7 @@ function CatalogRows({
|
|||||||
type="button"
|
type="button"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className={`${css.disclosure} ${isExpanded ? css.disclosureOpen : ''}`}
|
className={`${css.disclosure} ${isExpanded ? css.disclosureOpen : ''}`}
|
||||||
aria-label={`${isExpanded ? '收起' : '展开'} ${label} 的下级子代理`}
|
aria-label={t(isExpanded ? 'branch.collapse' : 'branch.expand', { label })}
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
>
|
>
|
||||||
<IconChevronRightOutline14 />
|
<IconChevronRightOutline14 />
|
||||||
@@ -262,6 +273,7 @@ function CatalogRows({
|
|||||||
parentSessionId={entry.id}
|
parentSessionId={entry.id}
|
||||||
summaries={summaries}
|
summaries={summaries}
|
||||||
level={level + 1}
|
level={level + 1}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
@@ -277,6 +289,7 @@ function CatalogRows({
|
|||||||
refresh={refresh}
|
refresh={refresh}
|
||||||
toggleBranch={toggleBranch}
|
toggleBranch={toggleBranch}
|
||||||
closeCatalog={closeCatalog}
|
closeCatalog={closeCatalog}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -294,7 +307,7 @@ function CatalogRows({
|
|||||||
* @returns The action only after a non-empty catalog arrives.
|
* @returns The action only after a non-empty catalog arrives.
|
||||||
*/
|
*/
|
||||||
export function SubagentCatalogAction({
|
export function SubagentCatalogAction({
|
||||||
sessionId, useSessions, openChild, refresh, setCatalogOpen,
|
sessionId, useSessions, openChild, refresh, setCatalogOpen, t,
|
||||||
}: SubagentCatalogActionProps) {
|
}: SubagentCatalogActionProps) {
|
||||||
const catalogs = useSessions(state => state.subagentsByParent)
|
const catalogs = useSessions(state => state.subagentsByParent)
|
||||||
const summaries = useSessions(state => state.byId)
|
const summaries = useSessions(state => state.byId)
|
||||||
@@ -419,7 +432,7 @@ export function SubagentCatalogAction({
|
|||||||
className={css.trigger}
|
className={css.trigger}
|
||||||
aria-haspopup="tree"
|
aria-haspopup="tree"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-label={`${descendantCount} 个子代理${descendants.running ? ',正在运行' : ''}`}
|
aria-label={t(descendants.running ? 'count.running' : 'count.total', { count: descendantCount })}
|
||||||
onClick={() => { changeOpen(!open) }}
|
onClick={() => { changeOpen(!open) }}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key !== 'ArrowDown') return
|
if (event.key !== 'ArrowDown') return
|
||||||
@@ -431,11 +444,11 @@ export function SubagentCatalogAction({
|
|||||||
<span className={css.activitySlot}>
|
<span className={css.activitySlot}>
|
||||||
{descendants.running && <StateDot state="ongoing" />}
|
{descendants.running && <StateDot state="ongoing" />}
|
||||||
</span>
|
</span>
|
||||||
<span className={css.count}>{descendantCount} 个子代理</span>
|
<span className={css.count}>{t('count.total', { count: descendantCount })}</span>
|
||||||
<IconChevronDownOutline14 className={open ? css.triggerOpen : undefined} />
|
<IconChevronDownOutline14 className={open ? css.triggerOpen : undefined} />
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className={css.menu} role="tree" aria-label="子代理会话">
|
<div className={css.menu} role="tree" aria-label={t('tree.aria')}>
|
||||||
<CatalogRows
|
<CatalogRows
|
||||||
parentSessionId={sessionId}
|
parentSessionId={sessionId}
|
||||||
catalog={catalog}
|
catalog={catalog}
|
||||||
@@ -448,6 +461,7 @@ export function SubagentCatalogAction({
|
|||||||
refresh={refresh}
|
refresh={refresh}
|
||||||
toggleBranch={toggleBranch}
|
toggleBranch={toggleBranch}
|
||||||
closeCatalog={() => { changeOpen(false) }}
|
closeCatalog={() => { changeOpen(false) }}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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'
|
import css from './SubagentReadOnlyComposer.module.css'
|
||||||
|
|
||||||
/** Why a catalog-addressed conversation cannot accept human input. */
|
/** 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. */
|
/** Full chain props after the read-only subagent selector accepts the owner currency. */
|
||||||
export type SubagentReadOnlyComposerProps =
|
export type SubagentReadOnlyComposerProps =
|
||||||
PropsRuntime<'conversation.composer'> & { matched: SubagentReadOnlyMatch }
|
PropsRuntime<'conversation.composer'> & { matched: SubagentReadOnlyMatch } & PropsLocale<typeof NS>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Explain why the normal composer is unavailable for an addressed child.
|
* Explain why the normal composer is unavailable for an addressed child.
|
||||||
@@ -16,16 +17,14 @@ export type SubagentReadOnlyComposerProps =
|
|||||||
* @returns A read-only composer replacement.
|
* @returns A read-only composer replacement.
|
||||||
*/
|
*/
|
||||||
export function SubagentReadOnlyComposer({
|
export function SubagentReadOnlyComposer({
|
||||||
matched,
|
matched, t,
|
||||||
}: Pick<SubagentReadOnlyComposerProps, 'matched'>) {
|
}: Pick<SubagentReadOnlyComposerProps, 'matched' | 't'>) {
|
||||||
const oneShot = matched.reason === 'one-shot'
|
const oneShot = matched.reason === 'one-shot'
|
||||||
return (
|
return (
|
||||||
<div className={css.frame} role="status">
|
<div className={css.frame} role="status">
|
||||||
<strong>{oneShot ? '一次性子代理记录' : '此子代理暂时只读'}</strong>
|
<strong>{t(oneShot ? 'readonly.oneShot.title' : 'readonly.title')}</strong>
|
||||||
<span>
|
<span>
|
||||||
{oneShot
|
{t(oneShot ? 'readonly.oneShot.body' : 'readonly.body')}
|
||||||
? '一次性任务不支持后续消息,可在这里查看完整执行记录。'
|
|
||||||
: '父会话当前不在线,重新打开父会话后即可继续发送消息。'}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ import { SubagentCatalogAction, type SubagentCatalogInjected } from './SubagentC
|
|||||||
import {
|
import {
|
||||||
SubagentReadOnlyComposer, type SubagentReadOnlyMatch,
|
SubagentReadOnlyComposer, type SubagentReadOnlyMatch,
|
||||||
} from './SubagentReadOnlyComposer.tsx'
|
} 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 {
|
export type {
|
||||||
SubagentCatalogActionProps, SubagentCatalogInjected,
|
SubagentCatalogActionProps, SubagentCatalogInjected,
|
||||||
@@ -27,7 +36,7 @@ export type {
|
|||||||
} from './SubagentReadOnlyComposer.tsx'
|
} from './SubagentReadOnlyComposer.tsx'
|
||||||
|
|
||||||
/** Required services for references, conversation slots, and session navigation. */
|
/** 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. */
|
/** Claim the composer for one-shot history or an unavailable continuation owner. */
|
||||||
function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null {
|
function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null {
|
||||||
@@ -42,6 +51,7 @@ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatc
|
|||||||
* @param ctx - client root context.
|
* @param ctx - client root context.
|
||||||
*/
|
*/
|
||||||
export function apply(ctx: ClientContext): void {
|
export function apply(ctx: ClientContext): void {
|
||||||
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-subagent: dictionaries')
|
||||||
const sessions = ctx.sessions
|
const sessions = ctx.sessions
|
||||||
// Child labels live on the session list (parentId lineage + displayTitle),
|
// Child labels live on the session list (parentId lineage + displayTitle),
|
||||||
// not the conversation snapshot — the list store is the zero-RPC candidate feed.
|
// 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',
|
name: 'conversation.session.header.actions',
|
||||||
id: 'subagent-catalog',
|
id: 'subagent-catalog',
|
||||||
order: 10,
|
order: 10,
|
||||||
|
locale: NS,
|
||||||
inject: catalogActions,
|
inject: catalogActions,
|
||||||
}, SubagentCatalogAction),
|
}, SubagentCatalogAction),
|
||||||
'ui-subagent: lazy descendant catalog action',
|
'ui-subagent: lazy descendant catalog action',
|
||||||
@@ -106,6 +117,7 @@ export function apply(ctx: ClientContext): void {
|
|||||||
() => ctx.slots.register({
|
() => ctx.slots.register({
|
||||||
name: 'conversation.composer',
|
name: 'conversation.composer',
|
||||||
priority: -10,
|
priority: -10,
|
||||||
|
locale: NS,
|
||||||
select: selectReadOnlySubagent,
|
select: selectReadOnlySubagent,
|
||||||
}, SubagentReadOnlyComposer),
|
}, SubagentReadOnlyComposer),
|
||||||
'ui-subagent: read-only addressed composer',
|
'ui-subagent: read-only addressed composer',
|
||||||
|
|||||||
67
packages/client/ui-subagent/src/client/locales.ts
Normal file
67
packages/client/ui-subagent/src/client/locales.ts
Normal file
@@ -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<SubagentKey, string> = {
|
||||||
|
'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
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||||
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
|
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||||
import type { ClientSessionContext, SlashSource } 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 {
|
import {
|
||||||
SubagentCatalogAction, type SubagentCatalogInjected,
|
SubagentCatalogAction, type SubagentCatalogInjected,
|
||||||
} from '../src/client/SubagentCatalogAction.tsx'
|
} 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('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
|
||||||
ctx.provide('sessions', face)
|
ctx.provide('sessions', face)
|
||||||
await provideSlotFaces(ctx)
|
await provideSlotFaces(ctx)
|
||||||
|
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
|
||||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||||
return { source: captured!, face, ctx }
|
return { source: captured!, face, ctx }
|
||||||
}
|
}
|
||||||
@@ -111,7 +113,7 @@ const req = (query: string) =>
|
|||||||
|
|
||||||
describe('apply', () => {
|
describe('apply', () => {
|
||||||
it('declares the services it binds', () => {
|
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 () => {
|
it('registers the "@" subagent source; disposal frees the name (HMR safety)', async () => {
|
||||||
@@ -119,6 +121,7 @@ describe('apply', () => {
|
|||||||
await ctx.plugin(SlashService).await()
|
await ctx.plugin(SlashService).await()
|
||||||
ctx.provide('sessions', sessionsWith(FAMILY))
|
ctx.provide('sessions', sessionsWith(FAMILY))
|
||||||
await provideSlotFaces(ctx)
|
await provideSlotFaces(ctx)
|
||||||
|
await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await()
|
||||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||||
await fiber.await()
|
await fiber.await()
|
||||||
const slash = ctx.get('slash') as SlashService
|
const slash = ctx.get('slash') as SlashService
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import type {
|
|||||||
import {
|
import {
|
||||||
SubagentCatalogAction, type SubagentCatalogActionProps,
|
SubagentCatalogAction, type SubagentCatalogActionProps,
|
||||||
} from '../src/client/SubagentCatalogAction.tsx'
|
} 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(() => {
|
afterEach(() => {
|
||||||
cleanup()
|
cleanup()
|
||||||
@@ -63,12 +66,22 @@ function props(
|
|||||||
function useSessions<T>(select: (snapshot: SessionListState) => T): T {
|
function useSessions<T>(select: (snapshot: SessionListState) => T): T {
|
||||||
return select(state)
|
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 {
|
return {
|
||||||
sessionId: PARENT,
|
sessionId: PARENT,
|
||||||
useSessions,
|
useSessions,
|
||||||
openChild: vi.fn(),
|
openChild: vi.fn(),
|
||||||
refresh: vi.fn(),
|
refresh: vi.fn(),
|
||||||
setCatalogOpen: vi.fn(),
|
setCatalogOpen: vi.fn(),
|
||||||
|
t,
|
||||||
} as unknown as SubagentCatalogActionProps
|
} as unknown as SubagentCatalogActionProps
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,13 +466,16 @@ describe('SubagentCatalogAction', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('SubagentReadOnlyComposer', () => {
|
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', () => {
|
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('父会话当前不在线')
|
expect(screen.getByRole('status').textContent).toContain('父会话当前不在线')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('explains that one-shot histories never accept follow-ups', () => {
|
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('一次性任务不支持后续消息')
|
expect(screen.getByRole('status').textContent).toContain('一次性任务不支持后续消息')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user