feat(apes): Phase 2 数据接入 — 4 个信息层 tab 接真实会话数据
CI / ci (push) Canceled after 0s
CI / ci-windows (push) Canceled after 0s
CI / plugin-mount (push) Canceled after 0s

- apes:tasks  接 jobsBySession + running sessions(live 任务列表:
  label/status/kind/耗时 + owner 归属,复用 collectTreeJobs/orderJobs)
- apes:agents 接 subagent 拓扑(rootAncestor + subagentsByParent 目录,
  parent→children activity running/inactive, 降级用 byId origin 过滤)
- apes:profile 接 byId[current](当前会话画像: title/cwd/running + agent 状态)
- apes:apps    展示 cwd/workspace 信息(无 DSH host feed, 保留说明)

数据源: ctx.sessions.list (useSyncExternalStore, uSES 安全订阅)
复用 pure helper: subagent-lineage.ts (rootAncestor/isSideThreadSummary)
  + subagent-jobs.ts (collectTreeJobs/orderJobs/isJobLive/jobDotState)
UI 独立内联样式, 不耦合 builtin CSS module / StateDot (保持 apes/ 隔离)

locales: 加 status/duration/empty/workspace 文案 (zh+en, 含 {param} 插值)
gate: 1250 passed | typecheck exit 0 | tsdown build complete
This commit is contained in:
figmar
2026-09-07 01:45:39 +08:00
parent a7bcdef21b
commit 427266a191
4 changed files with 353 additions and 115 deletions
+2 -2
View File
@@ -27,11 +27,11 @@ export { APES_LOCALE_NS, apesZh, apesEn, attachApesLocale } from './locales.ts'
* re-activation re-registers cleanly — same contract as registerBuiltins).
*/
export function registerApesTabs(
ctx: Context,
_ctx: Context,
service: BetterSidebarService,
): () => void {
const disposers: (() => void)[] = []
for (const tab of apesTabs(ctx)) {
for (const tab of apesTabs) {
disposers.push(service.registerTab(tab))
}
return () => {
+57 -9
View File
@@ -21,15 +21,42 @@ export const apesZh = {
apesAgents: '智能体',
apesApps: '应用',
apesProfile: '画像',
// Placeholder view copy
// View titles
apesTasksTitle: '任务',
apesTasksHint: 'Phase 1 占位 — 数据接入见 Phase 2',
apesAgentsTitle: '智能体',
apesAgentsHint: 'Phase 1 占位 — 数据接入见 Phase 2',
apesAppsTitle: '应用',
apesAppsHint: 'Phase 1 占位 — 数据接入见 Phase 2',
apesProfileTitle: '画像',
apesProfileHint: 'Phase 1 占位 — 数据接入见 Phase 2',
// Tasks view
apesTasksEmpty: '暂无后台任务',
apesTasksLiveCount: '{count} 个运行中',
apesTasksOwner: '来自 {owner}',
apesTaskKind: '{kind}',
// Job statuses
apesStatusRunning: '运行中',
apesStatusStopping: '停止中',
apesStatusCompleted: '已完成',
apesStatusKilled: '已终止',
apesStatusFailed: '失败',
// Duration (at most two adjacent units)
apesDurationHours: '{hours} 小时 {minutes} 分',
apesDurationMinutes: '{minutes} 分 {seconds} 秒',
apesDurationSeconds: '{seconds} 秒',
// Agents view
apesAgentsEmpty: '当前会话没有子智能体',
apesAgentsRoot: '主智能体',
apesAgentRunning: '运行中',
apesAgentInactive: '空闲',
apesAgentOneShot: '一次性',
apesAgentContinuable: '可续',
// Profile view
apesProfileCurrent: '当前会话',
apesProfileCwd: '工作目录',
apesProfileNoCwd: '未知',
apesProfileRunning: '运行中',
apesProfileIdle: '空闲',
// Apps view
apesAppsNoFeed: '当前 DSH 版本未提供工作区应用数据源',
apesAppsWorkspace: '工作区',
} as const
/** The en dictionary (checked against zh — every zh key must appear here). */
@@ -39,13 +66,34 @@ export const apesEn: Record<keyof typeof apesZh, string> = {
apesApps: 'Apps',
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',
apesAppsTitle: 'Apps',
apesAppsHint: 'Phase 1 placeholder — data wiring lands in Phase 2',
apesProfileTitle: 'Profile',
apesProfileHint: 'Phase 1 placeholder — data wiring lands in Phase 2',
apesTasksEmpty: 'No background jobs',
apesTasksLiveCount: '{count} running',
apesTasksOwner: 'from {owner}',
apesTaskKind: '{kind}',
apesStatusRunning: 'running',
apesStatusStopping: 'stopping',
apesStatusCompleted: 'completed',
apesStatusKilled: 'killed',
apesStatusFailed: 'failed',
apesDurationHours: '{hours}h {minutes}m',
apesDurationMinutes: '{minutes}m {seconds}s',
apesDurationSeconds: '{seconds}s',
apesAgentsEmpty: 'No subagents in this session',
apesAgentsRoot: 'Main agent',
apesAgentRunning: 'running',
apesAgentInactive: 'idle',
apesAgentOneShot: 'one-shot',
apesAgentContinuable: 'continuable',
apesProfileCurrent: 'Current session',
apesProfileCwd: 'Working directory',
apesProfileNoCwd: 'unknown',
apesProfileRunning: 'running',
apesProfileIdle: 'idle',
apesAppsNoFeed: 'This DSH version exposes no workspace-app feed',
apesAppsWorkspace: 'Workspace',
}
export type ApesCopyKey = keyof typeof apesZh
+290 -101
View File
@@ -1,118 +1,307 @@
/**
* apes:* tab descriptors + static placeholder views (Phase 1 / 1b).
* apes:* tab views (D2 = data wiring). Each tab reads live data from the
* sessions list feed (`ctx.sessions.list`) via `useSyncExternalStore` and
* reuses the fork's PURE lineage/job helpers (rootAncestor, treeSessionIds,
* collectTreeJobs, orderJobs, isJobLive, jobDotState) — these are framework-free
* and live outside `builtins/`, so importing them does not couple the apes
* module to upstream UI and survives cherry-picks. UI is inline-styled and
* self-contained (no CSS modules, no `@deepseek-ai/dsh-client-ui-primitives`)
* so the module stays isolated under `src/client/apes/`.
*
* Phase 1/1b ship the four information-layer 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.
* Data sources (structural mirror — runtime provided by the DSH host):
* - `ctx.sessions.list.getSnapshot()` → { current, byId, subagentsByParent?,
* jobsBySession? }; `subscribe(cb)` is uSES-safe.
* - `scope.sessionId` = the currently-open session.
*
* 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 four are `single: true` (one instance each — focusing an existing
* tab instead of stacking duplicates).
* apes:tasks → jobsBySession across the current tree (owner-labeled,
* live-first order) + a running-session count.
* apes:agents → the subagent topology rooted at the main agent of the
* current session (rootAncestor + per-parent catalogs).
* apes:profile → the current session's summary row (title, cwd, running).
* apes:apps → no DSH host feed today: shows the workspace (cwd) of the
* current session, otherwise an honest "no feed" note.
*/
import type { ComponentType } from 'react'
import {
FiList,
FiCpu,
FiGrid,
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'
import { useCallback, useMemo, useState, type CSSProperties } from 'react'
import { useSyncExternalStore } from 'react'
import { FiList, FiCpu, FiGrid, FiUser } from 'react-icons/fi'
import type { TabComponentProps, TabDescriptor } from '../service.ts'
import type {
SidebarSessionList,
SidebarSessionSummary,
SidebarJobView,
SidebarJobStatus,
SidebarSubagentCatalog,
SidebarSubagentChildEntry,
} from '../../context-types.ts'
import { rootAncestor, isSideThreadSummary } from '../subagent-lineage.ts'
import { collectTreeJobs, orderJobs, isJobLive, jobDotState } from '../subagent-jobs.ts'
import { t } 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} />
/** Map a job wire status to its apes i18n key. */
function statusKey(status: SidebarJobStatus): 'apesStatusRunning' | 'apesStatusStopping' | 'apesStatusCompleted' | 'apesStatusKilled' | 'apesStatusFailed' {
switch (status) {
case 'running': return 'apesStatusRunning'
case 'stopping': return 'apesStatusStopping'
case 'completed': return 'apesStatusCompleted'
case 'killed': return 'apesStatusKilled'
case 'failed': return 'apesStatusFailed'
}
}
/* ------------------------------------------------------------------ */
/* Placeholder views (static in Phase 1) */
/* ------------------------------------------------------------------ */
/** Dot color for a job status (reuses the fork's jobDotState mapping). */
function dotColor(state: 'ongoing' | 'warning' | 'done' | 'error'): string {
switch (state) {
case 'ongoing': return '#52c41a'
case 'warning': return '#faad14'
case 'done': return '#8c8c8c'
case 'error': return '#ff4d4f'
}
}
/** Elapsed ms of a job (running → now, settled → finishedAt). */
function jobElapsedMs(job: SidebarJobView, now: number): number {
const end = job.finishedAt ?? (isJobLive(job) ? now : job.startedAt)
return Math.max(0, end - job.startedAt)
}
/** Elapsed time in at most two adjacent units (apes-local, no builtin i18n). */
function duration(elapsedMs: number): string {
const total = Math.max(0, Math.floor(elapsedMs / 1_000))
const seconds = total % 60
const minutes = Math.floor(total / 60) % 60
const hours = Math.floor(total / 3_600)
if (hours > 0) return t('apesDurationHours', { hours, minutes })
if (minutes > 0) return t('apesDurationMinutes', { minutes, seconds })
return t('apesDurationSeconds', { seconds })
}
/** Shared inline styles (KISS: one object, no CSS module). */
const S: Record<string, CSSProperties> = {
body: { padding: 12, display: 'flex', flexDirection: 'column', gap: 12 },
title: { margin: 0, fontSize: 14, fontWeight: 600 },
hint: { margin: 0, fontSize: 12, color: '#8c8c8c' },
list: { display: 'flex', flexDirection: 'column', gap: 8 },
card: { border: '1px solid #303030', borderRadius: 8, padding: 10, display: 'flex', flexDirection: 'column', gap: 6 },
cardRow: { display: 'flex', alignItems: 'center', gap: 8 },
cardLabel: { fontSize: 13, fontWeight: 500, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
cardSecondary: { fontSize: 11, color: '#8c8c8c', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
badge: { fontSize: 11, color: '#8c8c8c', flexShrink: 0 },
}
/** A small colored status dot (self-contained, not the builtin StateDot). */
function Dot(props: { color: string }) {
const style: CSSProperties = {
width: 8, height: 8, borderRadius: '50%', background: props.color, flexShrink: 0, display: 'inline-block',
}
return <span aria-hidden style={style} />
}
/** Subscribe to the sessions list feed (uSES-safe). Returns the live snapshot. */
function useSessionList(ctx: TabComponentProps['ctx']): SidebarSessionList {
const sessions = ctx.sessions
return useSyncExternalStore(
useMemo(() => (callback: () => void) => sessions.list.subscribe(callback), [sessions]),
useCallback(() => sessions.list.getSnapshot(), [sessions]),
)
}
/** A 1-second ticker that only runs while `active` (for live durations). */
function useNow(active: boolean): number {
const [now, setNow] = useState(() => Date.now())
useMemo(() => {
if (!active) return
const id = window.setInterval(() => { setNow(Date.now()) }, 1_000)
return () => { window.clearInterval(id) }
}, [active])
return now
}
/** apes:tasks — the current tree's background jobs (live-first, owner-labeled). */
function ApesTasksView(props: TabComponentProps) {
const list = useSessionList(props.ctx)
const { byId, jobsBySession } = list
const rootId = useMemo(() => rootAncestor(byId, props.scope.sessionId), [byId, props.scope.sessionId])
const rows = useMemo(() => orderJobs(collectTreeJobs(byId, jobsBySession, rootId)), [byId, jobsBySession, rootId])
const liveCount = useMemo(() => rows.reduce((n, row) => n + (isJobLive(row.job) ? 1 : 0), 0), [rows])
const now = useNow(liveCount > 0)
/** 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 style={S.body}>
<h2 style={S.title}>{t('apesTasksTitle')}</h2>
{rows.length === 0
? <p style={S.hint}>{t('apesTasksEmpty')}</p>
: (
<>
<p style={S.hint}>{t('apesTasksLiveCount', { count: liveCount })}</p>
<div style={S.list}>
{rows.map(row => (
<div key={row.job.id} style={S.card}>
<div style={S.cardRow}>
<Dot color={dotColor(jobDotState(row.job.status))} />
<span style={S.cardLabel} title={row.job.label}>{row.job.label}</span>
<span style={S.badge}>{t('apesTaskKind', { kind: row.job.kind })}</span>
</div>
<div style={S.cardRow}>
<span style={S.cardSecondary}>{t('apesTasksOwner', { owner: row.ownerTitle })}</span>
<span style={S.badge}>{duration(jobElapsedMs(row.job, now))}</span>
<span style={S.badge}>{t(statusKey(row.job.status))}</span>
</div>
</div>
))}
</div>
</>
)}
</div>
)
}
const TasksView = (_props: TabComponentProps) => (
<Placeholder titleKey="apesTasksTitle" hintKey="apesTasksHint" />
)
const AgentsView = (_props: TabComponentProps) => (
<Placeholder titleKey="apesAgentsTitle" hintKey="apesAgentsHint" />
)
const AppsView = (_props: TabComponentProps) => (
<Placeholder titleKey="apesAppsTitle" hintKey="apesAppsHint" />
)
const ProfileView = (_props: TabComponentProps) => (
<Placeholder titleKey="apesProfileTitle" hintKey="apesProfileHint" />
)
/** One level of the subagent topology (rooted at the main agent). */
function agentChildren(
byId: Readonly<Record<string, SidebarSessionSummary>>,
parentSessionId: string,
): SidebarSessionSummary[] {
return Object.values(byId).filter(
s => s.origin === 'subagent' && s.parentId === parentSessionId && !isSideThreadSummary(s),
)
}
/* ------------------------------------------------------------------ */
/* Descriptors */
/* ------------------------------------------------------------------ */
/** apes:agents — the subagent topology of the current session's tree. */
function ApesAgentsView(props: TabComponentProps) {
const list = useSessionList(props.ctx)
const { byId, subagentsByParent } = list
const rootId = useMemo(() => rootAncestor(byId, props.scope.sessionId), [byId, props.scope.sessionId])
const rootSummary = rootId === undefined ? undefined : byId[rootId]
const rootCatalog = rootId === undefined ? undefined : subagentsByParent?.[rootId]
// The topology: the main agent + its direct subagent children (one level,
// with per-child activity). A lazy catalog (subagentsByParent) is preferred
// for the child list when present; otherwise fall back to durable byId rows.
// (`.filter` does not narrow a union — narrow explicitly to child entries.)
const children: SidebarSubagentChildEntry[] = (rootCatalog?.entries ?? [])
.filter((e): e is SidebarSubagentChildEntry => e.kind === 'child')
.concat(rootCatalog === undefined
? agentChildren(byId, rootId ?? '').map<SidebarSubagentChildEntry>(s => ({
kind: 'child', id: s.id, mode: 'continuable',
activity: s.running === true ? 'running' : 'inactive',
label: s.displayTitle, hasChildren: agentChildren(byId, s.id).length > 0,
}))
: [])
return (
<div style={S.body}>
<h2 style={S.title}>{t('apesAgentsTitle')}</h2>
{rootSummary === undefined && children.length === 0
? <p style={S.hint}>{t('apesAgentsEmpty')}</p>
: (
<div style={S.list}>
{/* Root (main agent) node */}
{rootSummary !== undefined && (
<div style={S.card}>
<div style={S.cardRow}>
<Dot color={rootSummary.running === true ? '#52c41a' : '#8c8c8c'} />
<span style={S.cardLabel} title={rootSummary.displayTitle}>{t('apesAgentsRoot')}: {rootSummary.displayTitle}</span>
</div>
<div style={S.cardRow}>
<span style={S.badge}>{rootSummary.running === true ? t('apesAgentRunning') : t('apesAgentInactive')}</span>
</div>
</div>
)}
{/* Direct subagent children */}
{children.map(entry => {
const summary = byId[entry.id]
const running = entry.activity === 'running'
const label = entry.label ?? summary?.displayTitle ?? entry.id
return (
<div key={entry.id} style={{ ...S.card, marginLeft: 14, borderLeft: '2px solid #303030' }}>
<div style={S.cardRow}>
<Dot color={running ? '#52c41a' : '#8c8c8c'} />
<span style={S.cardLabel} title={label}>{label}</span>
</div>
<div style={S.cardRow}>
<span style={S.cardSecondary}>
{entry.mode === 'one-shot' ? t('apesAgentOneShot') : t('apesAgentContinuable')}
{' · '}
{running ? t('apesAgentRunning') : t('apesAgentInactive')}
</span>
</div>
</div>
)
})}
</div>
)}
</div>
)
}
/** apes:profile — the current session's identity (title, cwd, running state). */
function ApesProfileView(props: TabComponentProps) {
const list = useSessionList(props.ctx)
const current = list.current
const summary = current === undefined ? undefined : list.byId[current]
const cwd = summary?.cwd ?? props.scope.cwd
return (
<div style={S.body}>
<h2 style={S.title}>{t('apesProfileTitle')}</h2>
{summary === undefined
? <p style={S.hint}>{t('apesProfileNoCwd')}</p>
: (
<div style={S.list}>
<div style={S.card}>
<div style={S.cardRow}>
<Dot color={summary.running === true ? '#52c41a' : '#8c8c8c'} />
<span style={S.cardLabel} title={summary.displayTitle}>{t('apesProfileCurrent')}</span>
</div>
<div style={S.cardRow}>
<span style={S.badge}>{summary.running === true ? t('apesProfileRunning') : t('apesProfileIdle')}</span>
</div>
</div>
<div style={S.card}>
<div style={S.cardRow}>
<span style={S.cardLabel}>{summary.displayTitle}</span>
</div>
<div style={S.cardRow}>
<span style={S.cardSecondary}>{t('apesProfileCwd')}: {cwd ?? t('apesProfileNoCwd')}</span>
</div>
</div>
</div>
)}
</div>
)
}
/** apes:apps — no DSH host feed today; shows the current workspace (cwd). */
function ApesAppsView(props: TabComponentProps) {
const list = useSessionList(props.ctx)
const current = list.current
const summary = current === undefined ? undefined : list.byId[current]
const cwd = summary?.cwd ?? props.scope.cwd
return (
<div style={S.body}>
<h2 style={S.title}>{t('apesAppsTitle')}</h2>
<p style={S.hint}>{t('apesAppsNoFeed')}</p>
<div style={S.card}>
<div style={S.cardRow}>
<span style={S.cardLabel}>{t('apesAppsWorkspace')}</span>
</div>
<div style={S.cardRow}>
<span style={S.cardSecondary}>{cwd ?? t('apesProfileNoCwd')}</span>
</div>
</div>
</div>
)
}
/**
* 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.
* The four apes:* tab descriptors (order 900-903: tasks/agents/apps/profile),
* already shaped as `TabDescriptor` (component + single-instance + icon
* element + i18n title function) so `registerApesTabs` can hand them straight
* to `service.registerTab`. Information-layer tabs are single-instance.
*/
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:apps',
title: () => t('apesApps'),
icon: iconOf(FiGrid),
order: 902,
single: true,
component: AppsView,
},
{
id: 'apes:profile',
title: () => t('apesProfile'),
icon: iconOf(FiUser),
order: 903,
single: true,
component: ProfileView,
},
]
}
export const apesTabs: TabDescriptor[] = [
{ id: 'apes:tasks', title: () => t('apesTasksTitle'), icon: <FiList size={14} />, order: 900, single: true, component: ApesTasksView },
{ id: 'apes:agents', title: () => t('apesAgentsTitle'), icon: <FiCpu size={14} />, order: 901, single: true, component: ApesAgentsView },
{ id: 'apes:apps', title: () => t('apesAppsTitle'), icon: <FiGrid size={14} />, order: 902, single: true, component: ApesAppsView },
{ id: 'apes:profile', title: () => t('apesProfileTitle'), icon: <FiUser size={14} />, order: 903, single: true, component: ApesProfileView },
]
+4 -3
View File
@@ -123,8 +123,9 @@ describe('apes i18n contract', () => {
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')
// The duration strings carry {seconds} params — assert the mechanism via
// a real key (the t() contract, not a synthetic one).
expect(t('apesDurationSeconds', { seconds: 5 })).toBe('5 秒')
expect(t('apesDurationMinutes', { minutes: 2, seconds: 7 })).toBe('2 分 7 秒')
})
})