From 66ee3953c946419afad0f71ea9b09fc480c342e0 Mon Sep 17 00:00:00 2001 From: figmar Date: Sun, 11 Oct 2026 21:18:08 +0800 Subject: [PATCH] feat(apes): revive the task/message tabs in the info layer C6 removed apes:tasks as a duplicate of the host session panel; in practice the two answer different questions (host panel = switch workspaces, plugin tab = who is running right now), so the tab is restored and the info layer goes back to five types. tasks: live session list (current / running / subagent markers) + showInactive toggle via pluginSettings['apes:tasks']; messages: placeholder that states the host has no message feed yet; both get badge counters, and the whole layer now honours the visible gate (auto-collapse) so hidden tabs stop waking on session events. zh/en/ja dictionaries in sync (AGENTS 5); wiki 02/03/07 reverted from three to five tabs; decision record in docs/plans/2026-10-11-apes-tab-revival.md. --- docs/plans/2026-10-11-apes-tab-revival.md | 40 ++++ docs/wiki/02-系统架构.md | 2 +- docs/wiki/03-功能说明.md | 17 +- docs/wiki/07-开发与测试.md | 2 +- src/client/apes/icons.tsx | 20 ++ src/client/apes/index.ts | 15 +- src/client/apes/locales.ts | 61 ++++- src/client/apes/tabs.tsx | 240 +++++++++++++++++--- src/client/index.tsx | 8 +- tests/apes-tabs.spec.ts | 259 ++++++++++++++++++++-- 10 files changed, 597 insertions(+), 67 deletions(-) create mode 100644 docs/plans/2026-10-11-apes-tab-revival.md diff --git a/docs/plans/2026-10-11-apes-tab-revival.md b/docs/plans/2026-10-11-apes-tab-revival.md new file mode 100644 index 0000000..ad8886c --- /dev/null +++ b/docs/plans/2026-10-11-apes-tab-revival.md @@ -0,0 +1,40 @@ +# apes 信息层 tab 复活(C6 → C7 反演)· 2026-10-11 + +## 为什么 + +C6 那次收紧把 `apes:tasks` 从信息层删掉了,理由是与宿主自带的会话面板「职责重叠」。 +实际用下来这个判断过于保守:**宿主的会话面板按工作区分组、服务于「换项目」,插件这个 tab +服务于「此刻谁在跑」**——它把当前会话、运行中会话、子智能体三种状态压进一屏,是切会话的入口, +不是另一份项目列表。删掉之后,「我现在有几个会话在跑」这件事在界面上就没有观测点了。 + +因此本轮按 C-2 的口径**照字面复活**:把 from-scratch 线(`apes-sidebar@2.0.0`)当年的五个 +tab 完整搬回 `dsh-apes-sidebar`,并顺手把它当年没做完的两件事补上(角标计数、隐藏门控)。 + +## 变成什么 + +| tab | order | 作用 | 相对 v2.0.0 | +| --- | --- | --- | --- | +| `apes:tasks` | 55 | 会话列表:当前 / 运行中 / 子智能体标记,点击切换会话 | 复活 | +| `apes:agents` | 60 | 主代理 + 直系子代理拓扑 | 复活(沿用 1.2.0 已有的拓扑实现) | +| `apes:apps` | 65 | 当前会话所属工作区 | 复活(宿主无应用 feed,占位) | +| `apes:messages` | 67 | 消息流占位 | 复活(宿主无消息流 feed) | +| `apes:profile` | 70 | 当前会话摘要(标题 / cwd / 运行态) | 复活 | + +新增的两条行为: + +- **角标**:`apes:tasks` 显示运行中会话数,`apes:agents` 显示直系子代理数;计数为 0 时不发角标。 +- **门控**:tab 不可见时不订阅会话 feed(不吃会话事件);`apes:tasks` 带 `showInactive` 开关, + 落在 `prefs.pluginSettings['apes:tasks']`,缺省显示全部。 + +## 与 C6 的关系(如何避免重复搬运) + +C6 删的是「与宿主会话面板重复的那部分」;本轮复活的是「宿主没有的那部分」。判断标准不是 +「有没有相似的 UI」,而是**这件事在宿主里有没有观测点**:会话的运行态、子智能体的直系拓扑, +宿主的两处面板都不给,插件给。据此定下取舍线——**宿主给了 feed 就不自造(apps / messages 明确 +写空态),宿主没给才自己接(tasks / agents)**。 + +## 相关 + +- 代码:`src/client/apes/tabs.tsx`、`locales.ts`(zh/en/ja)、`icons.tsx`、`src/client/index.tsx` +- 契约:`docs/wiki/03-功能说明.md` §一 总表 / §二 信息层五 tab(行为契约) +- 守护:`tests/apes-tabs.spec.ts`(五 tab 注册、角标、门控、过滤与排序、三语词条齐备) diff --git a/docs/wiki/02-系统架构.md b/docs/wiki/02-系统架构.md index 29fd976..976655f 100644 --- a/docs/wiki/02-系统架构.md +++ b/docs/wiki/02-系统架构.md @@ -29,7 +29,7 @@ │ · src/client/builtins/ 内置面板:文件 / 文件变动 / 子代理 / 侧边对话 / │ │ 终端 / 浏览器 + 文件预览器 │ │ · src/client/sidebar/ 自绘底部工作台与开合控件(原生侧边栏之外的部分) │ -│ · src/client/apes/ 信息层三 tab(本 fork 的增量) │ +│ · src/client/apes/ 信息层五 tab(本 fork 的增量) │ │ · src/client/service.ts ctx.betterSidebar 服务(供其他插件注册) │ └──────────────────────────────────────────────────────────────────────────┘ ``` diff --git a/docs/wiki/03-功能说明.md b/docs/wiki/03-功能说明.md index 7ce4ad3..7b74f00 100644 --- a/docs/wiki/03-功能说明.md +++ b/docs/wiki/03-功能说明.md @@ -15,23 +15,32 @@ | `terminal` | 终端 | 40 | 上游 | PTY 终端(懒加载 `client-terminal`;v0.24.1 线起由宿主内置终端承担) | | `browser` | 浏览器 | 50 | 上游 | 内嵌浏览器/HTML 预览宿主 | | `diff` | (内部) | -1 | 上游 | 非用户可见,供内部打开 diff | -| **`apes:agents`** | **智能体** | **60** | **本 fork** | 主代理 + 直系子代理拓扑 | +| **`apes:tasks`** | **任务** | **55** | **本 fork** | 会话列表(当前 / 运行中 / 子智能体标记,`showInactive` 开关;角标 = 运行中会话数) | +| **`apes:agents`** | **智能体** | **60** | **本 fork** | 主代理 + 直系子代理拓扑(角标 = 直系子代理数) | | **`apes:apps`** | **应用** | **65** | **本 fork** | 当前会话所属工作区(宿主暂无「应用」feed) | +| **`apes:messages`** | **消息** | **67** | **本 fork** | 消息流占位(宿主暂无消息流 feed) | | **`apes:profile`** | **画像** | **70** | **本 fork** | 当前会话摘要(标题 / cwd / 运行态) | -> order 10–50 是上游/宿主既定带宽,apos 信息层取 60/65/70 留出余量,避免与原生面板抢占位置。 +> order 10–50 是上游/宿主既定带宽,apes 信息层取 55/60/65/67/70 留出余量,避免与原生面板抢占位置。 -## 二、信息层三 tab(本 fork 的增量) +## 二、信息层五 tab(本 fork 的增量) -三个 tab 全部是**单实例**(`single: true`),且都按 DSH 标准 `TabDescriptor` 注册, +五个 tab 全部是**单实例**(`single: true`),且都按 DSH 标准 `TabDescriptor` 注册, 因此自动获得原生侧边栏的全部待遇:进入原生 guide、tab 芯片带图标、可开合与分栏。 | tab | 展示内容 | 数据来源 | 空态 | | --- | --- | --- | --- | +| `apes:tasks` | 会话行;当前会话排首位,其次运行中、再次空闲;行上带「当前 / 运行中 / 子智能体」标记;点击切换会话 | `ctx.sessions.list` 快照(`byId` + `current`) | 「当前没有会话」提示 | | `apes:agents` | 根(主代理)卡片 + 其直系子代理卡片;每行显示标签、`one-shot`/`continuable`、运行态圆点 | ① `ctx.sessions.list` 的 `byId`(durable 会话摘要);② 宿主的会话投影 `projectionsBySession[sessionId].values.subagentCatalog`(直系子代理清单) | 「当前会话没有子代理」提示 | | `apes:apps` | 当前会话的工作区(cwd) | `byId[scope.sessionId].cwd`,回退 `scope.cwd` | 明确写出「宿主今日无应用 feed」 | +| `apes:messages` | 仅标题与说明(同 `apes:apps` 的占位策略) | —— | 明确写出「宿主今日无消息流 feed」 | | `apes:profile` | 会话标题、运行/空闲、cwd | `byId[scope.sessionId]` | 「无 cwd」提示 | +**行为契约**:`apes:tasks` / `apes:agents` 声明 `badge`(运行中会话数 / 直系子代理数,为 0 时不渲染角标), +其余三个 tab 不发角标;`apes:tasks` 额外声明 `showInactive` 开关,读写落在 `prefs.pluginSettings['apes:tasks']` +(缺省显示全部,值非布尔时按缺省处理)。隐藏的 tab 不订阅会话 feed(`visible` 门控), +因此后台 tab 不会被会话事件唤醒。 + **实现要点**:两个来源被归一化成同一种行形状 `{ id, label, mode }`,**活动态统一从 `byId` 读**, 于是「投影已就绪」与「仅有 durable 摘要」两种情况下渲染一致;这也是上游 0.1.6→0.1.7 会话 feed 迁移(`subagentsByParent` → `projectionsBySession`、`list.current` 删除)后的落地方式。 diff --git a/docs/wiki/07-开发与测试.md b/docs/wiki/07-开发与测试.md index b8c7029..969ec6e 100644 --- a/docs/wiki/07-开发与测试.md +++ b/docs/wiki/07-开发与测试.md @@ -33,7 +33,7 @@ src/ native/ 原生右侧栏适配(tab 记录、tab 体、tab 芯片标题) builtins/ 内置面板:editor / git / subagent / sidechat / terminal / browser + 预览器 sidebar/ 自绘底部工作台与开合控件 - apes/ 信息层三 tab(本 fork 增量:tabs.tsx / icons.tsx / locales.ts) + apes/ 信息层五 tab(本 fork 增量:tabs.tsx / icons.tsx / locales.ts) changes/ diff/ 文件变动与 diff 视图 chunks/ 懒加载 chunk:editor / locale / mermaid / terminal locales*.ts i18n(`locales.ts` + 19 个语言覆盖文件) diff --git a/src/client/apes/icons.tsx b/src/client/apes/icons.tsx index 5b89d43..ede9c85 100644 --- a/src/client/apes/icons.tsx +++ b/src/client/apes/icons.tsx @@ -48,3 +48,23 @@ export function IconUserOutline16({ size = 16, className }: { size?: number; cla ) } + +/** Tasks glyph: a rounded card carrying a check mark. */ +export function IconTasksOutline16({ size = 16, className }: { size?: number; className?: string }): ReactNode { + return ( + + + + + ) +} + +/** Messages glyph: a speech bubble with a lower-left tail. */ +export function IconMessagesOutline16({ size = 16, className }: { size?: number; className?: string }): ReactNode { + return ( + + + + + ) +} diff --git a/src/client/apes/index.ts b/src/client/apes/index.ts index d79ab28..d740bcf 100644 --- a/src/client/apes/index.ts +++ b/src/client/apes/index.ts @@ -1,9 +1,14 @@ /** * apes:* information-layer tabs — the fork's own contribution on top of the - * upstream dsh-better-sidebar. Three tabs ship (agents / apps / profile); - * `apes:tasks` was removed (C6) because it overlaps the official DSH session - * tree in the native sidebar, and `messages` is deferred (no DSH - * message-stream API yet — tracked separately). + * upstream dsh-better-sidebar. Five tabs ship (tasks / agents / messages / + * apps / profile). + * + * History (C7): `apes:tasks` was removed under the old C6 decision (it looked + * redundant next to the official DSH session tree). The `v2-revive` line + * restores it — the native tree is a *navigator* (one focused lineage), this + * panel is a *list* (every session, with an idle filter and a running badge), + * so the two answer different questions. `messages` is still an honest + * placeholder: no DSH message-stream feed exists yet. * * Isolated under `src/client/apes/` so the upstream `builtins/` tree stays * untouched: cherry-picking upstream releases only has to keep the single @@ -20,7 +25,7 @@ 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' +export { APES_LOCALE_NS, apesZh, apesEn, apesJa, attachApesLocale } from './locales.ts' /** * Register the apes:* tabs with the sidebar service. Returns a disposer that diff --git a/src/client/apes/locales.ts b/src/client/apes/locales.ts index ba2f46d..e4fc5e0 100644 --- a/src/client/apes/locales.ts +++ b/src/client/apes/locales.ts @@ -17,9 +17,19 @@ export const APES_LOCALE_NS = 'apes' /** The zh dictionary (source of truth). */ export const apesZh = { // View titles + apesTasksTitle: '任务', + apesMessagesTitle: '消息', apesAgentsTitle: '智能体', apesAppsTitle: '应用', apesProfileTitle: '画像', + // Tasks view + apesTasksShowInactive: '显示空闲会话', + apesTasksEmpty: '当前没有会话', + apesTasksCurrent: '当前', + apesTasksRunning: '运行中', + apesTasksSubagent: '子智能体', + // Messages view + apesMessagesNoFeed: '当前 DSH 版本未提供消息流数据源', // Agents view apesAgentsEmpty: '当前会话没有子智能体', apesAgentsRoot: '主智能体', @@ -40,9 +50,17 @@ export const apesZh = { /** The en dictionary (checked against zh — every zh key must appear here). */ export const apesEn: Record = { + apesTasksTitle: 'Tasks', + apesMessagesTitle: 'Messages', apesAgentsTitle: 'Agents', apesAppsTitle: 'Apps', apesProfileTitle: 'Profile', + apesTasksShowInactive: 'Show idle sessions', + apesTasksEmpty: 'No sessions right now', + apesTasksCurrent: 'current', + apesTasksRunning: 'running', + apesTasksSubagent: 'subagent', + apesMessagesNoFeed: 'This DSH version exposes no message stream feed', apesAgentsEmpty: 'No subagents in this session', apesAgentsRoot: 'Main agent', apesAgentRunning: 'running', @@ -60,7 +78,39 @@ export const apesEn: Record = { export type ApesCopyKey = keyof typeof apesZh -/** Resolve the currently active DSH locale ('zh' | 'en' today). */ +/** + * The ja dictionary (AGENTS §5: a new zh key must land in ja in the same + * change, otherwise ja readers silently get the en text). Japanese is not a + * separate source of truth — it mirrors the en wording, never the zh one. + */ +export const apesJa: Record = { + apesTasksTitle: 'タスク', + apesMessagesTitle: 'メッセージ', + apesAgentsTitle: 'エージェント', + apesAppsTitle: 'アプリ', + apesProfileTitle: 'プロフィール', + apesTasksShowInactive: 'アイドル状態のセッションを表示', + apesTasksEmpty: 'セッションがありません', + apesTasksCurrent: '現在', + apesTasksRunning: '実行中', + apesTasksSubagent: 'サブエージェント', + apesMessagesNoFeed: 'この DSH バージョンはメッセージストリームの供給元を提供していません', + apesAgentsEmpty: 'このセッションにサブエージェントはいません', + apesAgentsRoot: 'メインエージェント', + apesAgentRunning: '実行中', + apesAgentInactive: 'アイドル', + apesAgentOneShot: '単発', + apesAgentContinuable: '継続可', + apesProfileCurrent: '現在のセッション', + apesProfileCwd: '作業ディレクトリ', + apesProfileNoCwd: '不明', + apesProfileRunning: '実行中', + apesProfileIdle: 'アイドル', + apesAppsNoFeed: 'この DSH バージョンはワークスペースアプリのデータ源を提供していません', + apesAppsWorkspace: 'ワークスペース', +} + +/** Resolve the currently active DSH locale ('zh' | 'ja' | 'en' today). */ export function attachApesLocale(service: { getSnapshot(): { active: string } } | undefined): void { apesLocaleService = service } @@ -69,13 +119,14 @@ 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). + * the DSH preference is zh*, ja dict when it is ja*, 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 { const active = apesLocaleService?.getSnapshot().active ?? 'zh' - const dict = active.toLowerCase().startsWith('zh') ? apesZh : apesEn + const lang = active.toLowerCase() + const dict = lang.startsWith('zh') ? apesZh : lang.startsWith('ja') ? apesJa : apesEn let text: string | undefined = dict[key] if (text === undefined) text = key if (params !== undefined) { diff --git a/src/client/apes/tabs.tsx b/src/client/apes/tabs.tsx index 4c10c80..d4f0a12 100644 --- a/src/client/apes/tabs.tsx +++ b/src/client/apes/tabs.tsx @@ -1,17 +1,27 @@ /** * 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 — these are framework-free and + * reuses the fork's PURE lineage helpers — these are framework-free and * live outside `builtins/`, so importing them does not couple the apes module * to upstream UI and survives cherry-picks. * - * Three information-layer tabs (C6: `apes:tasks` removed — it overlaps the - * official DSH session tree in the native sidebar): - * - apes:agents → the subagent topology rooted at the main agent of the - * current session (rootAncestor + per-parent catalogs). - * - apes:apps → no DSH host feed today: shows the workspace (cwd) of the - * current session, otherwise an honest "no feed" note. - * - apes:profile → the current session's summary row (title, cwd, running). + * Five information-layer tabs (C7 reversal: `apes:tasks` is back). The reason + * the earlier C6 removal no longer holds: the native DSH session tree renders + * ONE focused lineage, while this panel is a *flat, whole-workspace* list with + * a running badge and an idle filter — complementary, not redundant. + * - apes:tasks → every session (running / current / subagent marks) with + * an idle filter; a running count badges the tab. + * - apes:agents → the subagent topology rooted at the main agent of the + * current session (rootAncestor + per-parent catalogs). + * - apes:messages → honest placeholder: DSH exposes no message-stream feed. + * - apes:apps → no DSH host feed today: shows the workspace (cwd) of the + * current session, otherwise an honest "no feed" note. + * - apes:profile → the current session's summary row (title, cwd, running). + * + * Live-data discipline (C4): list-reading views subscribe only while the tab + * is on screen (`visible ? subscribe : NOOP_SUBSCRIBE`), so a collapsed panel + * stops re-rendering; tab-strip counts ride the cheap `badge` contract instead + * of a live subscription. * * Visual unity (C7): colors use the DSW design-token aliases * (`--dsw-alias-*`) so the apes panels match the native DSH sidebar @@ -24,14 +34,19 @@ * `subscribe(cb)` is uSES-safe. * - `scope.sessionId` = the currently-open session. */ -import { useCallback, useMemo, useState, type CSSProperties } from 'react' +import { useCallback, useMemo, type CSSProperties } from 'react' import { useSyncExternalStore } from 'react' -import { IconAgentOutline16, IconAppsOutline16, IconUserOutline16 } from './icons.tsx' +import { + IconAgentOutline16, + IconAppsOutline16, + IconMessagesOutline16, + IconTasksOutline16, + IconUserOutline16, +} from './icons.tsx' import type { TabComponentProps, TabDescriptor } from '../service.ts' import type { SidebarSessionList, SidebarSessionSummary, - SidebarSubagentCatalog, SidebarSubagentChildEntry, } from '../../context-types.ts' import { rootAncestor, isSideThreadSummary } from '../subagent-lineage.ts' @@ -66,8 +81,16 @@ const S: Record = { cardLabel: { fontSize: 13, fontWeight: 500, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: DSW.labelPrimary }, cardSecondary: { fontSize: 11, color: DSW.labelSecondary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, badge: { fontSize: 11, color: DSW.labelTertiary, flexShrink: 0 }, + chip: { fontSize: 10, lineHeight: '15px', padding: '0 6px', borderRadius: 999, border: `1px solid ${DSW.borderL2}`, color: DSW.labelSecondary, flexShrink: 0, whiteSpace: 'nowrap' }, + taskRow: { appearance: 'none', textAlign: 'left', width: '100%', cursor: 'pointer', font: 'inherit', color: 'inherit' }, } +/** apes:tasks descriptor id (the plugin-toggle blob lives under this key). */ +export const TASKS_TAB_ID = 'apes:tasks' + +/** The plugin-local switch persisted in `pluginSettings['apes:tasks']`. */ +export const TASKS_SHOW_INACTIVE_KEY = 'showInactive' + /** A small colored status dot (self-contained, not the builtin StateDot). */ function Dot(props: { color: string }) { const style: CSSProperties = { @@ -76,15 +99,91 @@ function Dot(props: { color: string }) { return } -/** Subscribe to the sessions list feed (uSES-safe). Returns the live snapshot. */ -function useSessionList(ctx: TabComponentProps['ctx']): SidebarSessionList { - const sessions = ctx.sessions +/** A tiny status pill (current / running / subagent marks). */ +function Chip(props: { label: string; color?: string }) { + return {props.label} +} + +/** Stable no-op subscription: `visible === false` pauses the feed. */ +export const NOOP_SUBSCRIBE = (): (() => void) => () => {} + +/** + * The subscription a view hands to uSES (C4 pause contract). Exported so the + * "hidden tab stops waking on session events" rule is unit-testable without a + * DOM: with `visible = false` the feed is never touched. + */ +export function feedSubscribe( + ctx: TabComponentProps['ctx'], + visible: boolean, +): (callback: () => void) => () => void { + if (!visible) return NOOP_SUBSCRIBE + return callback => ctx.sessions.list.subscribe(callback) +} + +/** + * Subscribe to the sessions list feed (uSES-safe). Returns the live snapshot. + * Pass `visible = false` to detach — a hidden tab keeps rendering from the + * last snapshot without waking on every session event. + */ +function useSessionList(ctx: TabComponentProps['ctx'], visible = true): SidebarSessionList { + const subscribe = useMemo(() => feedSubscribe(ctx, visible), [ctx, visible]) return useSyncExternalStore( - useMemo(() => (callback: () => void) => sessions.list.subscribe(callback), [sessions]), - useCallback(() => sessions.list.getSnapshot(), [sessions]), + subscribe, + useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]), ) } +/** + * The `showInactive` filter state. The declarative settings row writes it into + * the sidebar prefs document under `pluginSettings['apes:tasks']` (service + * contract, `SidebarSettingsDeclaration.pluginToggles`), which is NOT part of + * the public `SidebarPrefs` type — hence the narrow local cast. Default is + * true: the panel is a task list, so showing idle sessions is the useful + * default and the switch lets the reader trim it down. + */ +export function tasksShowInactive(store: TabComponentProps['store']): boolean { + try { + const prefs = store.getPrefs() as unknown as { pluginSettings?: Record> } + const blob = prefs.pluginSettings?.[TASKS_TAB_ID] + const value = blob?.[TASKS_SHOW_INACTIVE_KEY] + return typeof value === 'boolean' ? value : true + } catch { + return true + } +} + +/** + * apes:tasks rows: every non-side-chat session, running ones first (then the + * current one), idle ones only when `showInactive`. PURE — unit-tested. + */ +export function tasksRows(list: SidebarSessionList, showInactive: boolean): SidebarSessionSummary[] { + const rows = Object.values(list.byId).filter(s => !isSideThreadSummary(s) && (showInactive || s.running === true)) + const rank = (s: SidebarSessionSummary): number => (s.id === list.current ? 0 : s.running === true ? 1 : 2) + return rows.sort( + (a, b) => rank(a) - rank(b) || (a.displayTitle ?? a.id).localeCompare(b.displayTitle ?? b.id), + ) +} + +/** Running sessions (the tasks tab badge). Cheap: one pass, no allocation. */ +export function runningSessionCount(list: SidebarSessionList): number { + let count = 0 + for (const s of Object.values(list.byId)) { + if (s.running === true && !isSideThreadSummary(s)) count += 1 + } + return count +} + +/** Subagents in the given session's tree (the agents tab badge). */ +export function subagentCount(list: SidebarSessionList, sessionId: string): number { + const root = rootAncestor(list.byId, sessionId) + let count = 0 + for (const s of Object.values(list.byId)) { + if (s.origin !== 'subagent' || isSideThreadSummary(s)) continue + if (rootAncestor(list.byId, s.id) === root) count += 1 + } + return count +} + /** One level of the subagent topology (rooted at the main agent). */ function agentChildren( byId: Readonly>, @@ -95,9 +194,69 @@ function agentChildren( ) } +/** apes:tasks — the flat, whole-workspace session list. */ +export function ApesTasksView(props: TabComponentProps) { + const list = useSessionList(props.ctx, props.visible) + const store = props.store + const showInactive = useSyncExternalStore( + useMemo(() => (callback: () => void) => store.subscribe(callback), [store]), + useCallback(() => tasksShowInactive(store), [store]), + ) + const rows = useMemo(() => tasksRows(list, showInactive), [list, showInactive]) + const open = props.ctx.sessions.open + + return ( +
+

{t('apesTasksTitle')}

+ {rows.length === 0 + ?

{t('apesTasksEmpty')}

+ : ( +
+ {rows.map(row => { + const running = row.running === true + return ( + + ) + })} +
+ )} +
+ ) +} + +/** apes:messages — DSH exposes no message-stream feed; honest placeholder. */ +export function ApesMessagesView(_props: TabComponentProps) { + return ( +
+

{t('apesMessagesTitle')}

+

{t('apesMessagesNoFeed')}

+
+ ) +} + /** apes:agents — the subagent topology of the current session's tree. */ function ApesAgentsView(props: TabComponentProps) { - const list = useSessionList(props.ctx) + const list = useSessionList(props.ctx, props.visible) const { byId, subagentsByParent } = list const rootId = useMemo(() => rootAncestor(byId, props.scope.sessionId), [byId, props.scope.sessionId]) const rootSummary = rootId === undefined ? undefined : byId[rootId] @@ -165,7 +324,7 @@ function ApesAgentsView(props: TabComponentProps) { /** apes:profile — the current session's identity (title, cwd, running state). */ export function ApesProfileView(props: TabComponentProps) { - const list = useSessionList(props.ctx) + const list = useSessionList(props.ctx, props.visible) const current = list.current const summary = current === undefined ? undefined : list.byId[current] const cwd = summary?.cwd ?? props.scope.cwd @@ -202,7 +361,7 @@ export function ApesProfileView(props: TabComponentProps) { /** apes:apps — no DSH host feed today; shows the current workspace (cwd). */ function ApesAppsView(props: TabComponentProps) { - const list = useSessionList(props.ctx) + const list = useSessionList(props.ctx, props.visible) const current = list.current const summary = current === undefined ? undefined : list.byId[current] const cwd = summary?.cwd ?? props.scope.cwd @@ -224,14 +383,45 @@ function ApesAppsView(props: TabComponentProps) { } /** - * The three apes:* tab descriptors (C6: `apes:tasks` removed — overlaps the - * official DSH session tree). Order 901-903: agents/apps/profile. Each is - * 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. + * The five apes:* tab descriptors (C7: `apes:tasks` restored — the native + * session tree focuses one lineage, this list covers the whole workspace). + * Order 55-70 keeps them after the built-ins (editor 10 … browser 50) and in + * reading order: tasks / agents / apps / messages / profile. Each is 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 const apesTabs: TabDescriptor[] = [ - { id: 'apes:agents', title: () => t('apesAgentsTitle'), icon: (size: number) => , order: 60, single: true, component: ApesAgentsView }, + { + id: TASKS_TAB_ID, + title: () => t('apesTasksTitle'), + icon: (size: number) => , + order: 55, + single: true, + component: ApesTasksView, + badge: ctx => { + const count = runningSessionCount(ctx.sessions.list.getSnapshot()) + return count > 0 ? count : null + }, + settings: { + pluginToggles: [ + { key: TASKS_SHOW_INACTIVE_KEY, title: () => t('apesTasksShowInactive'), type: 'switch' }, + ], + }, + }, + { + id: 'apes:agents', + title: () => t('apesAgentsTitle'), + icon: (size: number) => , + order: 60, + single: true, + component: ApesAgentsView, + badge: (ctx, scope) => { + const count = subagentCount(ctx.sessions.list.getSnapshot(), scope.sessionId) + return count > 0 ? count : null + }, + }, { id: 'apes:apps', title: () => t('apesAppsTitle'), icon: (size: number) => , order: 65, single: true, component: ApesAppsView }, + { id: 'apes:messages', title: () => t('apesMessagesTitle'), icon: (size: number) => , order: 67, single: true, component: ApesMessagesView }, { id: 'apes:profile', title: () => t('apesProfileTitle'), icon: (size: number) => , order: 70, single: true, component: ApesProfileView }, ] diff --git a/src/client/index.tsx b/src/client/index.tsx index 8cf08ed..6a5c8fb 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -15,7 +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 { registerApesTabs, APES_LOCALE_NS, apesZh, apesEn, apesJa, attachApesLocale } from './apes/index.ts' import { Sidebar } from './Sidebar.tsx' import { RenderBoundary } from './RenderBoundary.tsx' import { registerTurnTailInterception } from './intercept.tsx' @@ -65,14 +65,16 @@ export function apply(ctx: Context): void { attachLocale(ctx.locale) // The apes module has its own locale namespace (isolated from the upstream // betterSidebar dicts): attach it so apes t() tracks the DSH active-locale - // preference, and register its zh/en dicts under `apes`. + // preference, and register its zh/ja/en dicts under `apes` (AGENTS §5: a ja + // dict must ship with every new zh key, else ja readers fall back to en). attachApesLocale(ctx.locale) ctx.effect(() => { const offZh = ctx.locale.register(LOCALE_NS, 'zh', zh) const offEn = ctx.locale.register(LOCALE_NS, 'en', en) const offApesZh = ctx.locale.register(APES_LOCALE_NS, 'zh', apesZh as Record) + const offApesJa = ctx.locale.register(APES_LOCALE_NS, 'ja', apesJa as Record) const offApesEn = ctx.locale.register(APES_LOCALE_NS, 'en', apesEn as Record) - return () => { offZh(); offEn(); offApesZh(); offApesEn() } + return () => { offZh(); offEn(); offApesZh(); offApesJa(); offApesEn() } }, 'dsh-apes-sidebar: dictionaries') // Opt-in third-language support through @huanlin/dsh-plugin-better-locale. diff --git a/tests/apes-tabs.spec.ts b/tests/apes-tabs.spec.ts index 547cc5a..b1c32cd 100644 --- a/tests/apes-tabs.spec.ts +++ b/tests/apes-tabs.spec.ts @@ -1,25 +1,44 @@ /** - * apes:* tab registration tests (Phase 1/1b, C6: 3 tabs). The fork registers - * its three information-layer tabs (agents / apps / profile) through the same - * BetterSidebarService the upstream builtins use (dogfood). `apes:tasks` was - * removed in C6 — it overlaps the official DSH session tree in the native - * sidebar. 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). + * apes:* tab registration tests (Phase 1/1b → C7: 5 tabs). The fork registers + * its information-layer tabs (tasks / agents / apps / messages / profile) + * through the same BetterSidebarService the upstream builtins use (dogfood). + * `apes:tasks` is back (C7 reversal of C6): the native DSH session tree focuses + * ONE lineage, while this panel is a flat, whole-workspace list with a running + * badge and an idle filter — the overlap is complementary. + * + * The spec pins: the registration contract (id / single / order / component / + * icon), the two Phase-2 increments (running-count + subagent-count `badge` + * functions, and the C4 `visible` pause on the sessions-list feed), the + * `showInactive` plugin-toggle channel, the PURE row helpers, and the isolated + * i18n contract (zh / ja / en parity + active-locale resolution). * * Runs in the node vitest environment like builtins.spec.ts — no DOM, no - * real DSH host (a live cordis scratch mount is Phase 2+). + * real DSH host (a live cordis scratch mount is covered by the mount e2e). */ 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 type { SidebarSessionList, SidebarSessionSummary } from '../src/context-types.ts' import { createBetterSidebarService } from '../src/client/service.ts' +import type { SessionScope, SidebarState, TabComponentProps } 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' +import { apesZh, apesEn, apesJa, attachApesLocale, t, type ApesCopyKey } from '../src/client/apes/locales.ts' +import { + NOOP_SUBSCRIBE, + TASKS_SHOW_INACTIVE_KEY, + TASKS_TAB_ID, + apesTabs, + feedSubscribe, + runningSessionCount, + subagentCount, + tasksRows, + tasksShowInactive, +} from '../src/client/apes/tabs.tsx' + +const APES_TAB_IDS = ['apes:agents', 'apes:apps', 'apes:messages', 'apes:profile', 'apes:tasks'] function setup(): { service: ReturnType @@ -32,13 +51,47 @@ function setup(): { return { service, store, dispose } } +/** A durable session row fixture. */ +function row(id: string, over: Partial = {}): SidebarSessionSummary { + return { id, displayTitle: id, ...over } +} + +/** A session-list snapshot fixture. */ +function snap(current: string | undefined, rows: readonly SidebarSessionSummary[]): SidebarSessionList { + return { current, byId: Object.fromEntries(rows.map(r => [r.id, r])) } +} + +/** A minimal ctx carrying only the sessions-list feed the apes views read. */ +function fakeCtx(list: SidebarSessionList, onSubscribe?: () => void): Context { + return { + sessions: { + list: { + getSnapshot: () => list, + subscribe: () => { + onSubscribe?.() + return () => {} + }, + }, + }, + } as unknown as Context +} + +/** Invoke a descriptor's badge with a fixture snapshot. */ +function badgeOf(id: string, list: SidebarSessionList, sessionId = 'main'): string | number | null | undefined { + const descriptor = apesTabs.find(d => d.id === id) + expect(descriptor, `${id} descriptor`).toBeDefined() + return descriptor?.badge?.( + fakeCtx(list), + { sessionId } as SessionScope, + {} as SidebarState, + ) +} + describe('apes:* tab registrations', () => { - it('registers the 3 apes tabs with the apes: namespace', () => { + it('registers the 5 apes tabs with the apes: namespace', () => { const { service, dispose } = setup() try { - expect(service.getTabs().map(t => t.id).sort()).toEqual( - ['apes:agents', 'apes:apps', 'apes:profile'], - ) + expect(service.getTabs().map(t => t.id).sort()).toEqual(APES_TAB_IDS) } finally { dispose() } @@ -47,35 +100,40 @@ describe('apes:* tab registrations', () => { it('every apes tab is single-instance, visible, and component-carrying', () => { const { service, dispose } = setup() try { - for (const id of ['apes:agents', 'apes:apps', 'apes:profile']) { + for (const id of APES_TAB_IDS) { 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() + expect(typeof tab?.title === 'function' ? tab.title() : tab?.title, `${id} title`).toBeTruthy() } } finally { dispose() } }) - it('apes tabs sort after the upstream builtins (order 40+)', () => { + it('apes tabs sort after the upstream builtins (order 40+) in reading order', () => { const { service, dispose } = setup() try { const orders = { + tasks: service.getTab(TASKS_TAB_ID)?.order, agents: service.getTab('apes:agents')?.order, apps: service.getTab('apes:apps')?.order, + messages: service.getTab('apes:messages')?.order, profile: service.getTab('apes:profile')?.order, } // C8: order 40-50 places apes tabs immediately after the native builtins // (10-35), not in a far-away 900+ band — visual unity in the tab strip. - expect(orders.agents).toBeGreaterThanOrEqual(40) - expect(orders.apps).toBeGreaterThanOrEqual(40) - expect(orders.profile).toBeGreaterThanOrEqual(40) - // Strictly ordered: agents < apps < profile. + for (const [id, order] of Object.entries(orders)) { + expect(order, `${id} order`).toBeGreaterThanOrEqual(40) + } + // Strictly ordered: tasks < agents < apps < messages < profile. + expect(orders.tasks!).toBeLessThan(orders.agents!) expect(orders.agents!).toBeLessThan(orders.apps!) - expect(orders.apps!).toBeLessThan(orders.profile!) + expect(orders.apps!).toBeLessThan(orders.messages!) + expect(orders.messages!).toBeLessThan(orders.profile!) } finally { dispose() } @@ -85,14 +143,146 @@ describe('apes:* tab registrations', () => { const store = createSidebarStore() const service = createBetterSidebarService(store) const dispose = registerApesTabs({} as Context, service) - expect(service.getTabs()).toHaveLength(3) + expect(service.getTabs()).toHaveLength(5) dispose() expect(service.getTabs()).toHaveLength(0) }) }) +describe('apes:tasks plugin toggles', () => { + it('declares the showInactive switch under the tasks descriptor', () => { + const descriptor = apesTabs.find(d => d.id === TASKS_TAB_ID) + const toggles = descriptor?.settings?.pluginToggles ?? [] + expect(toggles).toHaveLength(1) + const toggle = toggles[0] + expect(toggle?.key).toBe(TASKS_SHOW_INACTIVE_KEY) + expect(toggle?.key).toBe('showInactive') + expect(toggle?.type).toBe('switch') + expect(typeof toggle?.title === 'function' ? toggle.title() : toggle?.title).toBeTruthy() + }) + + it('defaults to showing every session when no pref is stored', () => { + const store = { getPrefs: () => ({}) } as unknown as TabComponentProps['store'] + expect(tasksShowInactive(store)).toBe(true) + }) + + it('reads the boolean from pluginSettings[apes:tasks]', () => { + const store = { + getPrefs: () => ({ pluginSettings: { [TASKS_TAB_ID]: { [TASKS_SHOW_INACTIVE_KEY]: false } } }), + } as unknown as TabComponentProps['store'] + expect(tasksShowInactive(store)).toBe(false) + }) + + it('ignores non-boolean values and failing stores (fail soft)', () => { + const odd = { + getPrefs: () => ({ pluginSettings: { [TASKS_TAB_ID]: { [TASKS_SHOW_INACTIVE_KEY]: 'nope' } } }), + } as unknown as TabComponentProps['store'] + expect(tasksShowInactive(odd)).toBe(true) + const broken = { getPrefs: () => { throw new Error('no prefs') } } as unknown as TabComponentProps['store'] + expect(tasksShowInactive(broken)).toBe(true) + }) +}) + +describe('apes:tasks row selection (PURE)', () => { + it('ranks current first, then running, then idle', () => { + const list = snap('idle-b', [ + row('idle-a'), + row('run-2', { running: true }), + row('idle-b'), + row('run-1', { running: true }), + ]) + expect(tasksRows(list, true).map(r => r.id)).toEqual(['idle-b', 'run-1', 'run-2', 'idle-a']) + }) + + it('hides idle sessions when showInactive is false', () => { + const list = snap('main', [row('main'), row('work', { running: true }), row('sleeping')]) + // C-2 fidelity: v2.0.0 filters on `running` alone, so an idle *current* + // session is dropped too — the toggle means "idle sessions disappear", + // not "always keep the one I am looking at". + expect(tasksRows(list, false).map(r => r.id)).toEqual(['work']) + expect(tasksRows(list, true).map(r => r.id)).toEqual(['main', 'work', 'sleeping']) + }) + + it('never lists side-chat threads (they are not topology)', () => { + const list = snap('main', [row('main'), row('side', { origin: 'subagent', displayTitle: 'Side: main' })]) + expect(tasksRows(list, true).map(r => r.id)).toEqual(['main']) + }) + + it('counts running sessions for the badge', () => { + const list = snap('main', [ + row('main', { running: true }), + row('child', { running: true, origin: 'subagent', parentId: 'main' }), + row('idle'), + row('side', { origin: 'subagent', displayTitle: 'Side: main' }), + ]) + expect(runningSessionCount(list)).toBe(2) + }) +}) + +describe('apes:agents badge + topology count', () => { + it('counts the subagents of the current session tree only', () => { + const list = snap('main', [ + row('main'), + row('c1', { origin: 'subagent', parentId: 'main' }), + row('c2', { origin: 'subagent', parentId: 'c1' }), + row('other'), + row('o1', { origin: 'subagent', parentId: 'other' }), + ]) + expect(subagentCount(list, 'main')).toBe(2) + expect(subagentCount(list, 'c1')).toBe(2) + expect(subagentCount(list, 'other')).toBe(1) + expect(subagentCount(list, 'unknown')).toBe(0) + }) + + it('excludes side-chat threads from the count', () => { + const list = snap('main', [ + row('main'), + row('side', { origin: 'subagent', parentId: 'main', displayTitle: 'Side: main' }), + row('c1', { origin: 'subagent', parentId: 'main' }), + ]) + expect(subagentCount(list, 'main')).toBe(1) + }) +}) + +describe('apes badge contract', () => { + it('badges the tasks tab with the running count, null when idle', () => { + expect(badgeOf(TASKS_TAB_ID, snap('main', [row('a', { running: true }), row('b')]))).toBe(1) + expect(badgeOf(TASKS_TAB_ID, snap('main', [row('a'), row('b')]))).toBeNull() + }) + + it('badges the agents tab with the subagent count, null when childless', () => { + expect(badgeOf('apes:agents', snap('main', [row('main'), row('c1', { origin: 'subagent', parentId: 'main' })]), 'main')).toBe(1) + expect(badgeOf('apes:agents', snap('main', [row('main')]), 'main')).toBeNull() + }) + + it('never badges the placeholder/static tabs', () => { + for (const id of ['apes:apps', 'apes:messages', 'apes:profile']) { + expect(apesTabs.find(d => d.id === id)?.badge, `${id} badge`).toBeUndefined() + } + }) +}) + +describe('apes C4 visible pause', () => { + it('does not touch the sessions feed while the tab is hidden', () => { + let subscribed = 0 + const ctx = fakeCtx(snap('main', [row('main')]), () => { subscribed += 1 }) + feedSubscribe(ctx, false) + expect(subscribed).toBe(0) + const stop = feedSubscribe(ctx, true) (() => {}) + expect(subscribed).toBe(1) + expect(typeof stop).toBe('function') + stop() + }) + + it('exposes a stable no-op subscription', () => { + const stop = NOOP_SUBSCRIBE() + expect(typeof stop).toBe('function') + expect(() => stop()).not.toThrow() + }) +}) + describe('apes i18n contract', () => { - it('every apesZh key has an apesEn entry (no missing translations)', () => { + it('every apesZh key has an en entry (no missing translations)', () => { const zhKeys = Object.keys(apesZh) as ApesCopyKey[] for (const key of zhKeys) { expect(apesEn[key], `en entry for ${key}`).toBeDefined() @@ -101,6 +291,15 @@ describe('apes i18n contract', () => { } }) + it('every apesZh key has a ja entry (AGENTS §5)', () => { + const zhKeys = Object.keys(apesZh) as ApesCopyKey[] + for (const key of zhKeys) { + expect(apesJa[key], `ja entry for ${key}`).toBeDefined() + expect(typeof apesJa[key]).toBe('string') + expect((apesJa[key] ?? '').length).toBeGreaterThan(0) + } + }) + it('resolves zh when the DSH preference is zh', () => { attachApesLocale({ getSnapshot: () => ({ active: 'zh-CN' }) }) expect(t('apesAgentsTitle')).toBe('智能体') @@ -108,11 +307,25 @@ describe('apes i18n contract', () => { expect(t('apesProfileTitle')).toBe('画像') }) + it('resolves the new tasks/messages titles in zh', () => { + attachApesLocale({ getSnapshot: () => ({ active: 'zh' }) }) + expect(t('apesTasksTitle')).toBe('任务') + expect(t('apesMessagesTitle')).toBe('消息') + }) + it('resolves en when the DSH preference is en', () => { attachApesLocale({ getSnapshot: () => ({ active: 'en' }) }) expect(t('apesAgentsTitle')).toBe('Agents') expect(t('apesAppsTitle')).toBe('Apps') expect(t('apesProfileTitle')).toBe('Profile') + expect(t('apesTasksTitle')).toBe('Tasks') + }) + + it('resolves ja when the DSH preference is ja', () => { + attachApesLocale({ getSnapshot: () => ({ active: 'ja-JP' }) }) + expect(t('apesAgentsTitle')).toBe('エージェント') + expect(t('apesTasksTitle')).toBe('タスク') + expect(t('apesMessagesTitle')).toBe('メッセージ') }) it('falls back to zh when no locale service is attached', () => {