把上一轮验证过的数据层搬到工作图基座上,图/树两种模式共用:
- 新增 src/process-activity.ts:宿主 ui-chat 的 process group 算法移植
(activity() 分类表逐条照抄、liveToolDetail 键优先级与 160 字截断、
counts 计数排序与并列规则、preparing 语义、按 user/reply 边界切范围)。
按本轮设计裁剪掉逐阶段列表(阶段改由徽标 + 颜色表达)。新增一条
偏差:最新范围为空(子代理刚回复完)时回退到上一个「做过事」的范围,
否则已结算卡片的小条只会显示「已完成分析」。
- chatT:从宿主 chat 命名空间读 message.stepProcess.* 措辞(方法调用 +
try/catch 兜底,key 未命中即回退),4 个回归用例钉住「不许解绑 bind」。
- subagents.live 载荷换新:SidebarChildLiveView { running?, text?, summary?,
lastEventTime? },报告树内每个子节点(含已结算)与拓扑根;删除
expand/stages 机制;childActivity → childLive(「不在 map 里 = 未运行」
的读法随旧协议一起作废)。
- tasks-model 消费新形状;LiveLine 与节点详情浮窗改显示合并活动措辞。
- 删除 src/subagent-activity.ts 与 tests/subagent-activity.spec.ts。
验证:typecheck / lint 通过;pnpm test 1252 passed / 9 skipped。
(cherry picked from commit 5e5284f214846b0c366048acbee459f3a7a79456)
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
/**
|
|
* Locale-following spec: the sidebar copy follows the DSH locale service
|
|
* (`ctx.locale`, provided by @deepseek-ai/dsh-client-locale) when attached
|
|
* through `attachLocale`, and falls back to the browser language otherwise.
|
|
* Covers attach/detach, live switching, dictionary parity, and placeholder
|
|
* interpolation.
|
|
*/
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { LOCALE_NS, attachBetterLocale, attachLocale, chatT, en, isZh, relativeTime, t, zh } from '../src/client/locales.ts'
|
|
import { localeDicts } from '../src/client/chunks/locale.tsx'
|
|
|
|
/** Minimal structural fake of the DSH LocaleService face the sidebar uses. */
|
|
class FakeLocale {
|
|
active: string = 'en'
|
|
getSnapshot(): { active: string } {
|
|
return { active: this.active }
|
|
}
|
|
subscribe(_fn: () => void): () => void {
|
|
return () => {}
|
|
}
|
|
register(_ns: string, _locale: string, _dict: Record<string, string>): () => void {
|
|
return () => {}
|
|
}
|
|
switchTo(id: string): void {
|
|
this.active = id
|
|
}
|
|
}
|
|
|
|
/** Point the browser-language fallback at a specific language (undefined = none). */
|
|
function stubNavigatorLanguage(lang: string | undefined): void {
|
|
Object.defineProperty(globalThis, 'navigator', {
|
|
value: lang === undefined ? undefined : { language: lang },
|
|
configurable: true,
|
|
})
|
|
}
|
|
|
|
afterEach(() => {
|
|
attachLocale(undefined)
|
|
attachBetterLocale(undefined)
|
|
stubNavigatorLanguage(undefined)
|
|
})
|
|
|
|
describe('locales (DSH i18n following)', () => {
|
|
it('falls back to the browser language without an attached service', () => {
|
|
stubNavigatorLanguage('en-US')
|
|
expect(t('explorer')).toBe('Explorer')
|
|
expect(isZh()).toBe(false)
|
|
|
|
stubNavigatorLanguage('zh-CN')
|
|
expect(t('explorer')).toBe('资源管理器')
|
|
expect(isZh()).toBe(true)
|
|
})
|
|
|
|
it('defaults to English when no locale service and no browser language are available', () => {
|
|
expect(t('explorer')).toBe('Explorer')
|
|
expect(isZh()).toBe(false)
|
|
})
|
|
|
|
it('follows the attached locale service instead of the browser language', () => {
|
|
stubNavigatorLanguage('en-US')
|
|
const locale = new FakeLocale()
|
|
attachLocale(locale)
|
|
|
|
locale.switchTo('zh')
|
|
expect(t('changes')).toBe('文件变动')
|
|
expect(isZh()).toBe(true)
|
|
|
|
// Live switch: the service's active locale wins even though the
|
|
// browser still asks for en-US.
|
|
locale.switchTo('en')
|
|
expect(t('changes')).toBe('Changes')
|
|
expect(isZh()).toBe(false)
|
|
})
|
|
|
|
it('detaches back to the browser-language fallback', () => {
|
|
const locale = new FakeLocale()
|
|
locale.switchTo('zh')
|
|
attachLocale(locale)
|
|
expect(t('terminal')).toBe('终端')
|
|
|
|
stubNavigatorLanguage('en-US')
|
|
attachLocale(undefined)
|
|
expect(t('terminal')).toBe('Terminal')
|
|
})
|
|
|
|
it('interpolates {name} placeholders in the active locale', () => {
|
|
const locale = new FakeLocale()
|
|
attachLocale(locale)
|
|
locale.switchTo('zh')
|
|
expect(t('timeMinutesAgo', { n: 5 })).toBe('5 分钟前')
|
|
locale.switchTo('en')
|
|
expect(t('timeMinutesAgo', { n: 5 })).toBe('5 min ago')
|
|
})
|
|
|
|
it('drives the relative-time chain through the active locale', () => {
|
|
const locale = new FakeLocale()
|
|
attachLocale(locale)
|
|
locale.switchTo('zh')
|
|
expect(relativeTime(new Date().toISOString())).toBe('刚刚')
|
|
locale.switchTo('en')
|
|
expect(relativeTime(new Date().toISOString())).toBe('just now')
|
|
})
|
|
|
|
it('registers a namespace distinct from DSH ui-sidebar\'s own \'sidebar\'', () => {
|
|
expect(LOCALE_NS).toBe('betterSidebar')
|
|
})
|
|
|
|
it('keeps the zh and en dictionaries key-set-equal', () => {
|
|
expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort())
|
|
})
|
|
|
|
it('keeps every shipped third-language dictionary key-set-equal to zh', () => {
|
|
// The third-language dicts live in the lazy locale chunk typed as plain
|
|
// records (the chunk boundary has no key-set type tie to zh). This test
|
|
// catches missing/extra keys introduced when a zh key is added without
|
|
// updating every language — a missing key silently falls back to en.
|
|
for (const [lang, dict] of Object.entries(localeDicts)) {
|
|
expect(Object.keys(dict).sort(), lang).toEqual(Object.keys(zh).sort())
|
|
}
|
|
expect(Object.keys(localeDicts), 'every shipped third language rides the locale chunk').toContain('ja')
|
|
})
|
|
})
|
|
|
|
/**
|
|
* A locale service whose `bind` reads instance state, exactly like DSH's own
|
|
* (`LocaleRuntime.bind` touches `this.bound`). A detached call — the natural
|
|
* refactor `const bind = service.bind; bind('chat')` — throws here, which is
|
|
* what a real host did before the mount lane caught it.
|
|
*/
|
|
class FakeLocaleWithBind {
|
|
active = 'zh'
|
|
private readonly dicts: Record<string, Record<string, string>> = {
|
|
chat: { 'message.stepProcess.done.read': '已读取文件' },
|
|
}
|
|
getSnapshot(): { active: string } {
|
|
return { active: this.active }
|
|
}
|
|
bind(ns: string): (key: string, params?: Record<string, string | number>) => string {
|
|
// The `this` read that makes a detached call blow up.
|
|
const dict: Record<string, string> = this.dicts[ns] ?? {}
|
|
return (key) => dict[key] ?? key
|
|
}
|
|
}
|
|
|
|
describe('locales (host chat namespace bridge)', () => {
|
|
it('reads the host namespace through a METHOD call on the attached service', () => {
|
|
attachLocale(new FakeLocaleWithBind())
|
|
expect(chatT('message.stepProcess.done.read')).toBe('已读取文件')
|
|
})
|
|
|
|
it('answers undefined for a namespace or key the host does not serve', () => {
|
|
attachLocale(new FakeLocaleWithBind())
|
|
// An unregistered host namespace resolves to the key itself: the caller
|
|
// must see "unavailable", never the raw `message.…` key.
|
|
expect(chatT('message.stepProcess.read')).toBeUndefined()
|
|
})
|
|
|
|
it('answers undefined when the attached face has no bind at all', () => {
|
|
attachLocale({ getSnapshot: () => ({ active: 'en' }) })
|
|
expect(chatT('message.stepProcess.done.read')).toBeUndefined()
|
|
attachLocale(undefined)
|
|
expect(chatT('message.stepProcess.done.read')).toBeUndefined()
|
|
})
|
|
|
|
it('never lets a throwing host seam break a render', () => {
|
|
attachLocale({
|
|
getSnapshot: () => ({ active: 'en' }),
|
|
bind: () => { throw new TypeError("Cannot read properties of undefined (reading 'bound')") },
|
|
})
|
|
expect(chatT('message.stepProcess.done.read')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
/** Minimal fake of the better-locale override store (the subset t()/isZh() read). */
|
|
class FakeBetterLocale {
|
|
active: string | undefined = undefined
|
|
private readonly dict: Record<string, Record<string, Record<string, string>>>
|
|
constructor(dict: Record<string, Record<string, Record<string, string>>>) {
|
|
this.dict = dict
|
|
}
|
|
getOverride(dshActive: string, ns: string, key: string): string | undefined {
|
|
if (this.active === undefined) return undefined
|
|
if (dshActive !== 'en') return undefined
|
|
return this.dict[ns]?.[this.active]?.[key]
|
|
}
|
|
isOverrideActive(dshActive: string): boolean {
|
|
return this.active !== undefined && dshActive === 'en'
|
|
}
|
|
subscribe(_listener: () => void): () => void {
|
|
return () => {}
|
|
}
|
|
}
|
|
|
|
describe('locales (better-locale override)', () => {
|
|
it('returns the override text when an override is active and the key exists', () => {
|
|
const locale = new FakeLocale()
|
|
locale.switchTo('en')
|
|
attachLocale(locale)
|
|
|
|
const betterLocale = new FakeBetterLocale({
|
|
[LOCALE_NS]: { ja: { explorer: 'エクスプローラー' } },
|
|
})
|
|
betterLocale.active = 'ja'
|
|
attachBetterLocale(betterLocale)
|
|
|
|
expect(t('explorer')).toBe('エクスプローラー')
|
|
expect(isZh()).toBe(false)
|
|
})
|
|
|
|
it('falls back to the zh/en chain when override is active but the key is missing', () => {
|
|
const locale = new FakeLocale()
|
|
locale.switchTo('en')
|
|
attachLocale(locale)
|
|
|
|
const betterLocale = new FakeBetterLocale({
|
|
[LOCALE_NS]: { ja: { explorer: 'エクスプローラー' } },
|
|
})
|
|
betterLocale.active = 'ja'
|
|
attachBetterLocale(betterLocale)
|
|
|
|
// 'changes' has no ja entry in this fake; should fall back to the en text.
|
|
expect(t('changes')).toBe('Changes')
|
|
})
|
|
|
|
it('falls back to the zh/en chain when no override is active', () => {
|
|
const locale = new FakeLocale()
|
|
locale.switchTo('zh')
|
|
attachLocale(locale)
|
|
|
|
const betterLocale = new FakeBetterLocale({
|
|
[LOCALE_NS]: { ja: { explorer: 'エクスプローラー' } },
|
|
})
|
|
// active is undefined — no override.
|
|
attachBetterLocale(betterLocale)
|
|
|
|
expect(t('explorer')).toBe('资源管理器')
|
|
expect(isZh()).toBe(true)
|
|
})
|
|
|
|
it('override is inert while DSH is on zh (override borrows the en slot)', () => {
|
|
const locale = new FakeLocale()
|
|
locale.switchTo('zh')
|
|
attachLocale(locale)
|
|
|
|
const betterLocale = new FakeBetterLocale({
|
|
[LOCALE_NS]: { ja: { explorer: 'エクスプローラー' } },
|
|
})
|
|
betterLocale.active = 'ja'
|
|
attachBetterLocale(betterLocale)
|
|
|
|
// Override is set to 'ja' but DSH is on zh — override is inert,
|
|
// native zh text wins. The user must switch DSH to en to see ja.
|
|
expect(t('explorer')).toBe('资源管理器')
|
|
expect(isZh()).toBe(true)
|
|
})
|
|
|
|
it('returns to the zh/en chain after detaching the override store', () => {
|
|
const locale = new FakeLocale()
|
|
locale.switchTo('en')
|
|
attachLocale(locale)
|
|
|
|
const betterLocale = new FakeBetterLocale({
|
|
[LOCALE_NS]: { ja: { explorer: 'エクスプローラー' } },
|
|
})
|
|
betterLocale.active = 'ja'
|
|
attachBetterLocale(betterLocale)
|
|
expect(t('explorer')).toBe('エクスプローラー')
|
|
|
|
attachBetterLocale(undefined)
|
|
expect(t('explorer')).toBe('Explorer')
|
|
expect(isZh()).toBe(false)
|
|
})
|
|
|
|
it('interpolates {name} placeholders in the override text', () => {
|
|
const locale = new FakeLocale()
|
|
locale.switchTo('en')
|
|
attachLocale(locale)
|
|
|
|
const betterLocale = new FakeBetterLocale({
|
|
[LOCALE_NS]: { ja: { timeMinutesAgo: '{n} 分前' } },
|
|
})
|
|
betterLocale.active = 'ja'
|
|
attachBetterLocale(betterLocale)
|
|
|
|
expect(t('timeMinutesAgo', { n: 5 })).toBe('5 分前')
|
|
})
|
|
|
|
it('ships a ja dictionary whose every key resolves to a non-empty string', () => {
|
|
// Smoke-check the shipped ja dict: every key in zh must have a non-empty
|
|
// ja entry. This catches copy-paste mistakes where a key was added to zh
|
|
// but its ja translation was left blank or missing.
|
|
const locale = new FakeLocale()
|
|
locale.switchTo('en')
|
|
attachLocale(locale)
|
|
|
|
const betterLocale = new FakeBetterLocale({ [LOCALE_NS]: { ja: localeDicts.ja! } })
|
|
betterLocale.active = 'ja'
|
|
attachBetterLocale(betterLocale)
|
|
|
|
for (const key of Object.keys(zh) as (keyof typeof zh)[]) {
|
|
const text = t(key)
|
|
expect(text, `ja translation for "${key}"`).toBeTruthy()
|
|
expect(text, `ja translation for "${key}"`).not.toBe(key)
|
|
}
|
|
})
|
|
})
|