feat(apes): Phase 2 数据接入 — 4 个信息层 tab 接真实会话数据
- 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:
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
]
|
||||
|
||||
@@ -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 秒')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user