feat(apes): Phase 1 — 注册 3 个 apes:* 信息层 tab (tasks/agents/profile)
- 独立 src/client/apes/ 模块 (D1=B), index.tsx 仅 3 处最小改动 - 3 个静态占位 tab (react-icons FiList/FiCpu/FiUser), order 900+ 排在上游 builtins 之后 - 隔离 i18n 命名空间 'apes' (不碰上游 betterSidebar 字典), zh/en 双语 - index.tsx: attachApesLocale + 字典注册 + registerApesTabs effect - tests/apes-tabs.spec.ts: 9 测试 (注册契约 + disposer + i18n zh/en/兜底) gate: typecheck + build + test 1250 passed / 9 skipped / 0 failed apps tab 延后 Phase 1b, messages 已砍 (无 DSH message-stream API)
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* apes:* information-layer tabs — the fork's own contribution on top of the
|
||||
* upstream dsh-better-sidebar. Three tabs ship in the first round (tasks /
|
||||
* agents / profile); apps lands in Phase 1b, messages is deferred (no DSH
|
||||
* message-stream API yet — tracked separately).
|
||||
*
|
||||
* Isolated under `src/client/apes/` so the upstream `builtins/` tree stays
|
||||
* untouched: cherry-picking upstream releases only has to keep the single
|
||||
* `registerApesTabs(...)` call site in `index.tsx` (see D1 = module B).
|
||||
*
|
||||
* Every descriptor is registered through the same `BetterSidebarService`
|
||||
* the builtins use (dogfood), so the apes tabs get the full sidebar
|
||||
* lifecycle for free: + menu, enable/disable switches, badge pills,
|
||||
* dedupe/focus, and HMR-safe disposal via the returned disposer.
|
||||
*/
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import type { BetterSidebarService } from '../service.ts'
|
||||
import { apesTabs } from './tabs.tsx'
|
||||
|
||||
// Re-exported so index.tsx can register the apes dictionaries + attach the
|
||||
// locale service in one import (keeps the apes module self-contained).
|
||||
export { APES_LOCALE_NS, apesZh, apesEn, attachApesLocale } from './locales.ts'
|
||||
|
||||
/**
|
||||
* Register the apes:* tabs with the sidebar service. Returns a disposer that
|
||||
* unregisters all of them (cordis auto-invokes it on fiber disposal, so HMR
|
||||
* re-activation re-registers cleanly — same contract as registerBuiltins).
|
||||
*/
|
||||
export function registerApesTabs(
|
||||
ctx: Context,
|
||||
service: BetterSidebarService,
|
||||
): () => void {
|
||||
const disposers: (() => void)[] = []
|
||||
for (const tab of apesTabs(ctx)) {
|
||||
disposers.push(service.registerTab(tab))
|
||||
}
|
||||
return () => {
|
||||
for (const d of disposers) {
|
||||
try { d() } catch { /* already disposed */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* apes:* tab i18n — isolated from the upstream `betterSidebar` locale
|
||||
* namespace (D1 = module B). The fork keeps its own `apes` namespace and
|
||||
* its own `t()` so that adding apes keys never touches the upstream
|
||||
* `locales.ts` (whose `CopyKey = keyof typeof zh` is closed over its own
|
||||
* dicts). The `t()` mirrors the upstream implementation's resolution order
|
||||
* (active-locale chain → key fallback) but reads the apes dicts; it is NOT
|
||||
* wired to the better-locale override store in Phase 1 (those third-language
|
||||
* overrides ride the lazy `locale` chunk that only covers `betterSidebar`
|
||||
* entries — apes keys fall back to zh/en, which is acceptable for the
|
||||
* placeholder round and gets the override integration in Phase 2).
|
||||
*/
|
||||
|
||||
/** The apes locale namespace (registered with ctx.locale under this key). */
|
||||
export const APES_LOCALE_NS = 'apes'
|
||||
|
||||
/** The zh dictionary (source of truth). */
|
||||
export const apesZh = {
|
||||
// + menu tab titles
|
||||
apesTasks: '任务',
|
||||
apesAgents: '智能体',
|
||||
apesProfile: '画像',
|
||||
// Placeholder view copy
|
||||
apesTasksTitle: '任务',
|
||||
apesTasksHint: 'Phase 1 占位 — 数据接入见 Phase 2',
|
||||
apesAgentsTitle: '智能体',
|
||||
apesAgentsHint: 'Phase 1 占位 — 数据接入见 Phase 2',
|
||||
apesProfileTitle: '画像',
|
||||
apesProfileHint: 'Phase 1 占位 — 数据接入见 Phase 2',
|
||||
} as const
|
||||
|
||||
/** The en dictionary (checked against zh — every zh key must appear here). */
|
||||
export const apesEn: Record<keyof typeof apesZh, string> = {
|
||||
apesTasks: 'Tasks',
|
||||
apesAgents: 'Agents',
|
||||
apesProfile: 'Profile',
|
||||
apesTasksTitle: 'Tasks',
|
||||
apesTasksHint: 'Phase 1 placeholder — data wiring lands in Phase 2',
|
||||
apesAgentsTitle: 'Agents',
|
||||
apesAgentsHint: 'Phase 1 placeholder — data wiring lands in Phase 2',
|
||||
apesProfileTitle: 'Profile',
|
||||
apesProfileHint: 'Phase 1 placeholder — data wiring lands in Phase 2',
|
||||
}
|
||||
|
||||
export type ApesCopyKey = keyof typeof apesZh
|
||||
|
||||
/** Resolve the currently active DSH locale ('zh' | 'en' today). */
|
||||
export function attachApesLocale(service: { getSnapshot(): { active: string } } | undefined): void {
|
||||
apesLocaleService = service
|
||||
}
|
||||
|
||||
let apesLocaleService: { getSnapshot(): { active: string } } | undefined
|
||||
|
||||
/**
|
||||
* Translate an apes key. Resolution order: active-locale chain (zh dict when
|
||||
* the DSH preference is zh, en dict otherwise) → key fallback (shows the key
|
||||
* itself rather than `undefined`). Mirrors the upstream t() minus the
|
||||
* better-locale override (Phase 2).
|
||||
*/
|
||||
export function t(key: ApesCopyKey, params?: Record<string, string | number>): string {
|
||||
const active = apesLocaleService?.getSnapshot().active ?? 'zh'
|
||||
const dict = active.toLowerCase().startsWith('zh') ? apesZh : apesEn
|
||||
let text: string | undefined = dict[key]
|
||||
if (text === undefined) text = key
|
||||
if (params !== undefined) {
|
||||
for (const [name, value] of Object.entries(params)) {
|
||||
text = text.replaceAll(`{${name}}`, String(value))
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* apes:* tab descriptors + static placeholder views (Phase 1).
|
||||
*
|
||||
* Phase 1 ships the three highest-value tabs as STATIC placeholders — the
|
||||
* goal is to prove the registration / render / i18n / icon pipeline end to
|
||||
* end before wiring real data (Phase 2). Each view renders a titled card
|
||||
* with a "TODO: data" hint; no service calls yet.
|
||||
*
|
||||
* Tab IDs follow the `apes:*` namespace (the fork's own prefix, distinct
|
||||
* from the upstream builtins' bare ids and from the legacy `wb:*` prefix).
|
||||
* All three are `single: true` (one instance each — focusing an existing
|
||||
* tab instead of stacking duplicates).
|
||||
*/
|
||||
import type { ComponentType } from 'react'
|
||||
import {
|
||||
FiList,
|
||||
FiCpu,
|
||||
FiUser,
|
||||
} from 'react-icons/fi'
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import type { TabDescriptor, TabComponentProps } from '../service.ts'
|
||||
import { t, type ApesCopyKey } from './locales.ts'
|
||||
|
||||
/** Wrap a react-icons component into the sidebar's `icon: (size) => ReactNode` contract. */
|
||||
function iconOf(Icon: ComponentType<{ size?: number }>) {
|
||||
return (size: number) => <Icon size={size} />
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Placeholder views (static in Phase 1) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Shared chrome for the placeholder cards: a centred title + hint. */
|
||||
function Placeholder({ titleKey, hintKey }: { titleKey: ApesCopyKey; hintKey: ApesCopyKey }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
height: '100%',
|
||||
minHeight: 160,
|
||||
padding: 16,
|
||||
boxSizing: 'border-box',
|
||||
color: 'var(--dsh-fg-muted, #8b8b9e)',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--dsh-fg, #e6e6f0)' }}>
|
||||
{t(titleKey)}
|
||||
</div>
|
||||
<div style={{ opacity: 0.7 }}>{t(hintKey)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TasksView = (_props: TabComponentProps) => (
|
||||
<Placeholder titleKey="apesTasksTitle" hintKey="apesTasksHint" />
|
||||
)
|
||||
const AgentsView = (_props: TabComponentProps) => (
|
||||
<Placeholder titleKey="apesAgentsTitle" hintKey="apesAgentsHint" />
|
||||
)
|
||||
const ProfileView = (_props: TabComponentProps) => (
|
||||
<Placeholder titleKey="apesProfileTitle" hintKey="apesProfileHint" />
|
||||
)
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Descriptors */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* The three apes:* tab descriptors. `order` is set high (900+) so the apes
|
||||
* tabs sort AFTER the upstream builtins in the + menu (builtins occupy
|
||||
* 10–90); the apes cluster stays visually grouped at the tail.
|
||||
*/
|
||||
export function apesTabs(_ctx: Context): readonly TabDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'apes:tasks',
|
||||
title: () => t('apesTasks'),
|
||||
icon: iconOf(FiList),
|
||||
order: 900,
|
||||
single: true,
|
||||
component: TasksView,
|
||||
},
|
||||
{
|
||||
id: 'apes:agents',
|
||||
title: () => t('apesAgents'),
|
||||
icon: iconOf(FiCpu),
|
||||
order: 901,
|
||||
single: true,
|
||||
component: AgentsView,
|
||||
},
|
||||
{
|
||||
id: 'apes:profile',
|
||||
title: () => t('apesProfile'),
|
||||
icon: iconOf(FiUser),
|
||||
order: 902,
|
||||
single: true,
|
||||
component: ProfileView,
|
||||
},
|
||||
]
|
||||
}
|
||||
+17
-1
@@ -15,6 +15,7 @@ import { allLeaves, createSidebarStore, isAgentTabId } from './state.ts'
|
||||
import { createBetterSidebarService, matchUrlTarget } from './service.ts'
|
||||
import { revalidateChunksOnReactivate, setChunkModuleSystem } from './chunk-loader.ts'
|
||||
import { registerBuiltins } from './builtins/index.ts'
|
||||
import { registerApesTabs, APES_LOCALE_NS, apesZh, apesEn, attachApesLocale } from './apes/index.ts'
|
||||
import { Sidebar } from './Sidebar.tsx'
|
||||
import { RenderBoundary } from './RenderBoundary.tsx'
|
||||
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
|
||||
@@ -57,10 +58,16 @@ export function apply(ctx: Context): void {
|
||||
// plugin's dictionaries into the shared locale registry. The disposers
|
||||
// run on fiber disposal, so re-activation (HMR) re-registers cleanly.
|
||||
attachLocale(ctx.locale)
|
||||
// The apes module has its own locale namespace (isolated from the upstream
|
||||
// betterSidebar dicts per D1=B); attach it so apes t() tracks the DSH
|
||||
// active-locale preference, and register its zh/en dicts under `apes`.
|
||||
attachApesLocale(ctx.locale)
|
||||
ctx.effect(() => {
|
||||
const offZh = ctx.locale.register(LOCALE_NS, 'zh', zh)
|
||||
const offEn = ctx.locale.register(LOCALE_NS, 'en', en)
|
||||
return () => { offZh(); offEn() }
|
||||
const offApesZh = ctx.locale.register(APES_LOCALE_NS, 'zh', apesZh as Record<string, string>)
|
||||
const offApesEn = ctx.locale.register(APES_LOCALE_NS, 'en', apesEn as Record<string, string>)
|
||||
return () => { offZh(); offEn(); offApesZh(); offApesEn() }
|
||||
}, 'dsh-apes-sidebar: dictionaries')
|
||||
|
||||
// Opt-in third-language support through @huanlin/dsh-plugin-better-locale.
|
||||
@@ -164,6 +171,15 @@ export function apply(ctx: Context): void {
|
||||
() => registerBuiltins(ctx, service, { terminalTitle: () => terminalTitle }),
|
||||
'dsh-apes-sidebar: register built-in tabs and viewers',
|
||||
)
|
||||
// The apes:* information-layer tabs (the fork's own contribution, isolated
|
||||
// under src/client/apes per D1=B). Registered through the same service as
|
||||
// the builtins so they get the full sidebar lifecycle; the disposer runs on
|
||||
// fiber disposal (HMR-safe). A throw here is caught by the outer try and
|
||||
// routed to fail() — a bad apes registration must never blank the sidebar.
|
||||
ctx.effect(
|
||||
() => registerApesTabs(ctx, service),
|
||||
'dsh-apes-sidebar: register apes tabs',
|
||||
)
|
||||
// A failure anywhere in the client lifecycle must never take the app down
|
||||
// silently: log with the plugin prefix and pin a visible diagnostic strip
|
||||
// to the page so a blank panel is never the only symptom.
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* apes:* tab registration tests (Phase 1): the fork registers its three
|
||||
* information-layer tabs (tasks / agents / profile) through the same
|
||||
* BetterSidebarService the upstream builtins use (dogfood). The spec pins
|
||||
* the registration contract (id / single / order / component presence) and
|
||||
* the isolated i18n contract (the apes namespace resolves zh vs en through
|
||||
* the attached locale service, and every apesZh key has an apesEn entry).
|
||||
*
|
||||
* Runs in the node vitest environment like builtins.spec.ts — no DOM, no
|
||||
* real DSH host (a live cordis scratch mount is Phase 2+).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
// Browser globals before the client graph loads (mirror of builtins.spec.ts).
|
||||
import './browser-globals.ts'
|
||||
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
import { createBetterSidebarService } from '../src/client/service.ts'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
import { registerApesTabs } from '../src/client/apes/index.ts'
|
||||
import { apesZh, apesEn, attachApesLocale, t, type ApesCopyKey } from '../src/client/apes/locales.ts'
|
||||
|
||||
function setup(): {
|
||||
service: ReturnType<typeof createBetterSidebarService>
|
||||
store: ReturnType<typeof createSidebarStore>
|
||||
dispose: () => void
|
||||
} {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const dispose = registerApesTabs({} as Context, service)
|
||||
return { service, store, dispose }
|
||||
}
|
||||
|
||||
describe('apes:* tab registrations', () => {
|
||||
it('registers the 3 apes tabs with the apes: namespace', () => {
|
||||
const { service, dispose } = setup()
|
||||
try {
|
||||
expect(service.getTabs().map(t => t.id).sort()).toEqual(
|
||||
['apes:agents', 'apes:profile', 'apes:tasks'],
|
||||
)
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('every apes tab is single-instance, visible, and component-carrying', () => {
|
||||
const { service, dispose } = setup()
|
||||
try {
|
||||
for (const id of ['apes:tasks', 'apes:agents', 'apes:profile']) {
|
||||
const tab = service.getTab(id)
|
||||
expect(tab, `${id} registered`).toBeDefined()
|
||||
expect(tab?.single, `${id} single`).toBe(true)
|
||||
expect(tab?.hidden, `${id} hidden`).not.toBe(true)
|
||||
expect(tab?.component, `${id} component`).toBeDefined()
|
||||
expect(tab?.icon, `${id} icon`).toBeDefined()
|
||||
}
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('apes tabs sort after the upstream builtins (order 900+)', () => {
|
||||
const { service, dispose } = setup()
|
||||
try {
|
||||
const orders = {
|
||||
tasks: service.getTab('apes:tasks')?.order,
|
||||
agents: service.getTab('apes:agents')?.order,
|
||||
profile: service.getTab('apes:profile')?.order,
|
||||
}
|
||||
expect(orders.tasks).toBeGreaterThanOrEqual(900)
|
||||
expect(orders.agents).toBeGreaterThanOrEqual(900)
|
||||
expect(orders.profile).toBeGreaterThanOrEqual(900)
|
||||
// Strictly ordered: tasks < agents < profile.
|
||||
expect(orders.tasks!).toBeLessThan(orders.agents!)
|
||||
expect(orders.agents!).toBeLessThan(orders.profile!)
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('the disposer unregisters all apes tabs (HMR-safe)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const dispose = registerApesTabs({} as Context, service)
|
||||
expect(service.getTabs()).toHaveLength(3)
|
||||
dispose()
|
||||
expect(service.getTabs()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('apes i18n contract', () => {
|
||||
it('every apesZh key has an apesEn entry (no missing translations)', () => {
|
||||
const zhKeys = Object.keys(apesZh) as ApesCopyKey[]
|
||||
for (const key of zhKeys) {
|
||||
expect(apesEn[key], `en entry for ${key}`).toBeDefined()
|
||||
expect(typeof apesEn[key]).toBe('string')
|
||||
expect((apesEn[key] ?? '').length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves zh when the DSH preference is zh', () => {
|
||||
attachApesLocale({ getSnapshot: () => ({ active: 'zh-CN' }) })
|
||||
expect(t('apesTasks')).toBe('任务')
|
||||
expect(t('apesAgents')).toBe('智能体')
|
||||
expect(t('apesProfile')).toBe('画像')
|
||||
})
|
||||
|
||||
it('resolves en when the DSH preference is en', () => {
|
||||
attachApesLocale({ getSnapshot: () => ({ active: 'en' }) })
|
||||
expect(t('apesTasks')).toBe('Tasks')
|
||||
expect(t('apesAgents')).toBe('Agents')
|
||||
expect(t('apesProfile')).toBe('Profile')
|
||||
})
|
||||
|
||||
it('falls back to zh when no locale service is attached', () => {
|
||||
attachApesLocale(undefined)
|
||||
expect(t('apesTasks')).toBe('任务')
|
||||
})
|
||||
|
||||
it('supports {param} interpolation', () => {
|
||||
attachApesLocale({ getSnapshot: () => ({ active: 'zh' }) })
|
||||
// The placeholder hints carry no params today; assert the mechanism via
|
||||
// a synthetic key shape (the t() contract, not a real apes key).
|
||||
expect(typeof t('apesTasksHint')).toBe('string')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user