diff --git a/src/client/apes/index.ts b/src/client/apes/index.ts index bcdb055..f4b3414 100644 --- a/src/client/apes/index.ts +++ b/src/client/apes/index.ts @@ -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 () => { diff --git a/src/client/apes/locales.ts b/src/client/apes/locales.ts index e487e9b..794f2af 100644 --- a/src/client/apes/locales.ts +++ b/src/client/apes/locales.ts @@ -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 = { 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 diff --git a/src/client/apes/tabs.tsx b/src/client/apes/tabs.tsx index f190bd1..5d1af07 100644 --- a/src/client/apes/tabs.tsx +++ b/src/client/apes/tabs.tsx @@ -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) => +/** 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 = { + 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 +} + +/** 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 ( -
-
- {t(titleKey)} -
-
{t(hintKey)}
+
+

{t('apesTasksTitle')}

+ {rows.length === 0 + ?

{t('apesTasksEmpty')}

+ : ( + <> +

{t('apesTasksLiveCount', { count: liveCount })}

+
+ {rows.map(row => ( +
+
+ + {row.job.label} + {t('apesTaskKind', { kind: row.job.kind })} +
+
+ {t('apesTasksOwner', { owner: row.ownerTitle })} + {duration(jobElapsedMs(row.job, now))} + {t(statusKey(row.job.status))} +
+
+ ))} +
+ + )}
) } -const TasksView = (_props: TabComponentProps) => ( - -) -const AgentsView = (_props: TabComponentProps) => ( - -) -const AppsView = (_props: TabComponentProps) => ( - -) -const ProfileView = (_props: TabComponentProps) => ( - -) +/** One level of the subagent topology (rooted at the main agent). */ +function agentChildren( + byId: Readonly>, + 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(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 ( +
+

{t('apesAgentsTitle')}

+ {rootSummary === undefined && children.length === 0 + ?

{t('apesAgentsEmpty')}

+ : ( +
+ {/* Root (main agent) node */} + {rootSummary !== undefined && ( +
+
+ + {t('apesAgentsRoot')}: {rootSummary.displayTitle} +
+
+ {rootSummary.running === true ? t('apesAgentRunning') : t('apesAgentInactive')} +
+
+ )} + {/* 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 ( +
+
+ + {label} +
+
+ + {entry.mode === 'one-shot' ? t('apesAgentOneShot') : t('apesAgentContinuable')} + {' · '} + {running ? t('apesAgentRunning') : t('apesAgentInactive')} + +
+
+ ) + })} +
+ )} +
+ ) +} + +/** 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 ( +
+

{t('apesProfileTitle')}

+ {summary === undefined + ?

{t('apesProfileNoCwd')}

+ : ( +
+
+
+ + {t('apesProfileCurrent')} +
+
+ {summary.running === true ? t('apesProfileRunning') : t('apesProfileIdle')} +
+
+
+
+ {summary.displayTitle} +
+
+ {t('apesProfileCwd')}: {cwd ?? t('apesProfileNoCwd')} +
+
+
+ )} +
+ ) +} + +/** 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 ( +
+

{t('apesAppsTitle')}

+

{t('apesAppsNoFeed')}

+
+
+ {t('apesAppsWorkspace')} +
+
+ {cwd ?? t('apesProfileNoCwd')} +
+
+
+ ) +} /** - * 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: , order: 900, single: true, component: ApesTasksView }, + { id: 'apes:agents', title: () => t('apesAgentsTitle'), icon: , order: 901, single: true, component: ApesAgentsView }, + { id: 'apes:apps', title: () => t('apesAppsTitle'), icon: , order: 902, single: true, component: ApesAppsView }, + { id: 'apes:profile', title: () => t('apesProfileTitle'), icon: , order: 903, single: true, component: ApesProfileView }, +] diff --git a/tests/apes-tabs.spec.ts b/tests/apes-tabs.spec.ts index 826a198..208b1dc 100644 --- a/tests/apes-tabs.spec.ts +++ b/tests/apes-tabs.spec.ts @@ -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 秒') }) })