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.
This commit is contained in:
@@ -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 注册、角标、门控、过滤与排序、三语词条齐备)
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
│ · src/client/builtins/ 内置面板:文件 / 文件变动 / 子代理 / 侧边对话 / │
|
│ · src/client/builtins/ 内置面板:文件 / 文件变动 / 子代理 / 侧边对话 / │
|
||||||
│ 终端 / 浏览器 + 文件预览器 │
|
│ 终端 / 浏览器 + 文件预览器 │
|
||||||
│ · src/client/sidebar/ 自绘底部工作台与开合控件(原生侧边栏之外的部分) │
|
│ · src/client/sidebar/ 自绘底部工作台与开合控件(原生侧边栏之外的部分) │
|
||||||
│ · src/client/apes/ 信息层三 tab(本 fork 的增量) │
|
│ · src/client/apes/ 信息层五 tab(本 fork 的增量) │
|
||||||
│ · src/client/service.ts ctx.betterSidebar 服务(供其他插件注册) │
|
│ · src/client/service.ts ctx.betterSidebar 服务(供其他插件注册) │
|
||||||
└──────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|||||||
+13
-4
@@ -15,23 +15,32 @@
|
|||||||
| `terminal` | 终端 | 40 | 上游 | PTY 终端(懒加载 `client-terminal`;v0.24.1 线起由宿主内置终端承担) |
|
| `terminal` | 终端 | 40 | 上游 | PTY 终端(懒加载 `client-terminal`;v0.24.1 线起由宿主内置终端承担) |
|
||||||
| `browser` | 浏览器 | 50 | 上游 | 内嵌浏览器/HTML 预览宿主 |
|
| `browser` | 浏览器 | 50 | 上游 | 内嵌浏览器/HTML 预览宿主 |
|
||||||
| `diff` | (内部) | -1 | 上游 | 非用户可见,供内部打开 diff |
|
| `diff` | (内部) | -1 | 上游 | 非用户可见,供内部打开 diff |
|
||||||
| **`apes:agents`** | **智能体** | **60** | **本 fork** | 主代理 + 直系子代理拓扑 |
|
| **`apes:tasks`** | **任务** | **55** | **本 fork** | 会话列表(当前 / 运行中 / 子智能体标记,`showInactive` 开关;角标 = 运行中会话数) |
|
||||||
|
| **`apes:agents`** | **智能体** | **60** | **本 fork** | 主代理 + 直系子代理拓扑(角标 = 直系子代理数) |
|
||||||
| **`apes:apps`** | **应用** | **65** | **本 fork** | 当前会话所属工作区(宿主暂无「应用」feed) |
|
| **`apes:apps`** | **应用** | **65** | **本 fork** | 当前会话所属工作区(宿主暂无「应用」feed) |
|
||||||
|
| **`apes:messages`** | **消息** | **67** | **本 fork** | 消息流占位(宿主暂无消息流 feed) |
|
||||||
| **`apes:profile`** | **画像** | **70** | **本 fork** | 当前会话摘要(标题 / cwd / 运行态) |
|
| **`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 芯片带图标、可开合与分栏。
|
因此自动获得原生侧边栏的全部待遇:进入原生 guide、tab 芯片带图标、可开合与分栏。
|
||||||
|
|
||||||
| tab | 展示内容 | 数据来源 | 空态 |
|
| tab | 展示内容 | 数据来源 | 空态 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
| `apes:tasks` | 会话行;当前会话排首位,其次运行中、再次空闲;行上带「当前 / 运行中 / 子智能体」标记;点击切换会话 | `ctx.sessions.list` 快照(`byId` + `current`) | 「当前没有会话」提示 |
|
||||||
| `apes:agents` | 根(主代理)卡片 + 其直系子代理卡片;每行显示标签、`one-shot`/`continuable`、运行态圆点 | ① `ctx.sessions.list` 的 `byId`(durable 会话摘要);② 宿主的会话投影 `projectionsBySession[sessionId].values.subagentCatalog`(直系子代理清单) | 「当前会话没有子代理」提示 |
|
| `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:apps` | 当前会话的工作区(cwd) | `byId[scope.sessionId].cwd`,回退 `scope.cwd` | 明确写出「宿主今日无应用 feed」 |
|
||||||
|
| `apes:messages` | 仅标题与说明(同 `apes:apps` 的占位策略) | —— | 明确写出「宿主今日无消息流 feed」 |
|
||||||
| `apes:profile` | 会话标题、运行/空闲、cwd | `byId[scope.sessionId]` | 「无 cwd」提示 |
|
| `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` 读**,
|
**实现要点**:两个来源被归一化成同一种行形状 `{ id, label, mode }`,**活动态统一从 `byId` 读**,
|
||||||
于是「投影已就绪」与「仅有 durable 摘要」两种情况下渲染一致;这也是上游 0.1.6→0.1.7 会话 feed
|
于是「投影已就绪」与「仅有 durable 摘要」两种情况下渲染一致;这也是上游 0.1.6→0.1.7 会话 feed
|
||||||
迁移(`subagentsByParent` → `projectionsBySession`、`list.current` 删除)后的落地方式。
|
迁移(`subagentsByParent` → `projectionsBySession`、`list.current` 删除)后的落地方式。
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ src/
|
|||||||
native/ 原生右侧栏适配(tab 记录、tab 体、tab 芯片标题)
|
native/ 原生右侧栏适配(tab 记录、tab 体、tab 芯片标题)
|
||||||
builtins/ 内置面板:editor / git / subagent / sidechat / terminal / browser + 预览器
|
builtins/ 内置面板:editor / git / subagent / sidechat / terminal / browser + 预览器
|
||||||
sidebar/ 自绘底部工作台与开合控件
|
sidebar/ 自绘底部工作台与开合控件
|
||||||
apes/ 信息层三 tab(本 fork 增量:tabs.tsx / icons.tsx / locales.ts)
|
apes/ 信息层五 tab(本 fork 增量:tabs.tsx / icons.tsx / locales.ts)
|
||||||
changes/ diff/ 文件变动与 diff 视图
|
changes/ diff/ 文件变动与 diff 视图
|
||||||
chunks/ 懒加载 chunk:editor / locale / mermaid / terminal
|
chunks/ 懒加载 chunk:editor / locale / mermaid / terminal
|
||||||
locales*.ts i18n(`locales.ts` + 19 个语言覆盖文件)
|
locales*.ts i18n(`locales.ts` + 19 个语言覆盖文件)
|
||||||
|
|||||||
@@ -48,3 +48,23 @@ export function IconUserOutline16({ size = 16, className }: { size?: number; cla
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tasks glyph: a rounded card carrying a check mark. */
|
||||||
|
export function IconTasksOutline16({ size = 16, className }: { size?: number; className?: string }): ReactNode {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<rect x="1.5" y="1.5" width="13" height="13" rx="3.6" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
<path d="M4.7 8.2l2.2 2.2 4.4-4.8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Messages glyph: a speech bubble with a lower-left tail. */
|
||||||
|
export function IconMessagesOutline16({ size = 16, className }: { size?: number; className?: string }): ReactNode {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||||
|
<path d="M1.5 5.1c0-2 1.6-3.6 3.6-3.6h5.8c2 0 3.6 1.6 3.6 3.6v3.1c0 2-1.6 3.6-3.6 3.6H6.6L2.9 14.5V11.5h-.2c-.7-.6-1.2-1.6-1.2-2.6z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||||
|
<path d="M5.4 7.4h5.2M5.4 4.9h3.1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* apes:* information-layer tabs — the fork's own contribution on top of the
|
* apes:* information-layer tabs — the fork's own contribution on top of the
|
||||||
* upstream dsh-better-sidebar. Three tabs ship (agents / apps / profile);
|
* upstream dsh-better-sidebar. Five tabs ship (tasks / agents / messages /
|
||||||
* `apes:tasks` was removed (C6) because it overlaps the official DSH session
|
* apps / profile).
|
||||||
* tree in the native sidebar, and `messages` is deferred (no DSH
|
*
|
||||||
* message-stream API yet — tracked separately).
|
* 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
|
* Isolated under `src/client/apes/` so the upstream `builtins/` tree stays
|
||||||
* untouched: cherry-picking upstream releases only has to keep the single
|
* 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
|
// Re-exported so index.tsx can register the apes dictionaries + attach the
|
||||||
// locale service in one import (keeps the apes module self-contained).
|
// 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
|
* Register the apes:* tabs with the sidebar service. Returns a disposer that
|
||||||
|
|||||||
@@ -17,9 +17,19 @@ export const APES_LOCALE_NS = 'apes'
|
|||||||
/** The zh dictionary (source of truth). */
|
/** The zh dictionary (source of truth). */
|
||||||
export const apesZh = {
|
export const apesZh = {
|
||||||
// View titles
|
// View titles
|
||||||
|
apesTasksTitle: '任务',
|
||||||
|
apesMessagesTitle: '消息',
|
||||||
apesAgentsTitle: '智能体',
|
apesAgentsTitle: '智能体',
|
||||||
apesAppsTitle: '应用',
|
apesAppsTitle: '应用',
|
||||||
apesProfileTitle: '画像',
|
apesProfileTitle: '画像',
|
||||||
|
// Tasks view
|
||||||
|
apesTasksShowInactive: '显示空闲会话',
|
||||||
|
apesTasksEmpty: '当前没有会话',
|
||||||
|
apesTasksCurrent: '当前',
|
||||||
|
apesTasksRunning: '运行中',
|
||||||
|
apesTasksSubagent: '子智能体',
|
||||||
|
// Messages view
|
||||||
|
apesMessagesNoFeed: '当前 DSH 版本未提供消息流数据源',
|
||||||
// Agents view
|
// Agents view
|
||||||
apesAgentsEmpty: '当前会话没有子智能体',
|
apesAgentsEmpty: '当前会话没有子智能体',
|
||||||
apesAgentsRoot: '主智能体',
|
apesAgentsRoot: '主智能体',
|
||||||
@@ -40,9 +50,17 @@ export const apesZh = {
|
|||||||
|
|
||||||
/** The en dictionary (checked against zh — every zh key must appear here). */
|
/** The en dictionary (checked against zh — every zh key must appear here). */
|
||||||
export const apesEn: Record<keyof typeof apesZh, string> = {
|
export const apesEn: Record<keyof typeof apesZh, string> = {
|
||||||
|
apesTasksTitle: 'Tasks',
|
||||||
|
apesMessagesTitle: 'Messages',
|
||||||
apesAgentsTitle: 'Agents',
|
apesAgentsTitle: 'Agents',
|
||||||
apesAppsTitle: 'Apps',
|
apesAppsTitle: 'Apps',
|
||||||
apesProfileTitle: 'Profile',
|
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',
|
apesAgentsEmpty: 'No subagents in this session',
|
||||||
apesAgentsRoot: 'Main agent',
|
apesAgentsRoot: 'Main agent',
|
||||||
apesAgentRunning: 'running',
|
apesAgentRunning: 'running',
|
||||||
@@ -60,7 +78,39 @@ export const apesEn: Record<keyof typeof apesZh, string> = {
|
|||||||
|
|
||||||
export type ApesCopyKey = keyof typeof apesZh
|
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<keyof typeof apesZh, string> = {
|
||||||
|
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 {
|
export function attachApesLocale(service: { getSnapshot(): { active: string } } | undefined): void {
|
||||||
apesLocaleService = service
|
apesLocaleService = service
|
||||||
}
|
}
|
||||||
@@ -69,13 +119,14 @@ let apesLocaleService: { getSnapshot(): { active: string } } | undefined
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Translate an apes key. Resolution order: active-locale chain (zh dict when
|
* 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
|
* the DSH preference is zh*, ja dict when it is ja*, en dict otherwise) → key
|
||||||
* itself rather than `undefined`). Mirrors the upstream t() minus the
|
* fallback (shows the key itself rather than `undefined`). Mirrors the
|
||||||
* better-locale override (Phase 2).
|
* upstream t() minus the better-locale override (Phase 2).
|
||||||
*/
|
*/
|
||||||
export function t(key: ApesCopyKey, params?: Record<string, string | number>): string {
|
export function t(key: ApesCopyKey, params?: Record<string, string | number>): string {
|
||||||
const active = apesLocaleService?.getSnapshot().active ?? 'zh'
|
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]
|
let text: string | undefined = dict[key]
|
||||||
if (text === undefined) text = key
|
if (text === undefined) text = key
|
||||||
if (params !== undefined) {
|
if (params !== undefined) {
|
||||||
|
|||||||
+215
-25
@@ -1,17 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* apes:* tab views (D2 = data wiring). Each tab reads live data from the
|
* apes:* tab views (D2 = data wiring). Each tab reads live data from the
|
||||||
* sessions list feed (`ctx.sessions.list`) via `useSyncExternalStore` and
|
* 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
|
* live outside `builtins/`, so importing them does not couple the apes module
|
||||||
* to upstream UI and survives cherry-picks.
|
* to upstream UI and survives cherry-picks.
|
||||||
*
|
*
|
||||||
* Three information-layer tabs (C6: `apes:tasks` removed — it overlaps the
|
* Five information-layer tabs (C7 reversal: `apes:tasks` is back). The reason
|
||||||
* official DSH session tree in the native sidebar):
|
* the earlier C6 removal no longer holds: the native DSH session tree renders
|
||||||
* - apes:agents → the subagent topology rooted at the main agent of the
|
* ONE focused lineage, while this panel is a *flat, whole-workspace* list with
|
||||||
* current session (rootAncestor + per-parent catalogs).
|
* a running badge and an idle filter — complementary, not redundant.
|
||||||
* - apes:apps → no DSH host feed today: shows the workspace (cwd) of the
|
* - apes:tasks → every session (running / current / subagent marks) with
|
||||||
* current session, otherwise an honest "no feed" note.
|
* an idle filter; a running count badges the tab.
|
||||||
* - apes:profile → the current session's summary row (title, cwd, running).
|
* - 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
|
* Visual unity (C7): colors use the DSW design-token aliases
|
||||||
* (`--dsw-alias-*`) so the apes panels match the native DSH sidebar
|
* (`--dsw-alias-*`) so the apes panels match the native DSH sidebar
|
||||||
@@ -24,14 +34,19 @@
|
|||||||
* `subscribe(cb)` is uSES-safe.
|
* `subscribe(cb)` is uSES-safe.
|
||||||
* - `scope.sessionId` = the currently-open session.
|
* - `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 { 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 { TabComponentProps, TabDescriptor } from '../service.ts'
|
||||||
import type {
|
import type {
|
||||||
SidebarSessionList,
|
SidebarSessionList,
|
||||||
SidebarSessionSummary,
|
SidebarSessionSummary,
|
||||||
SidebarSubagentCatalog,
|
|
||||||
SidebarSubagentChildEntry,
|
SidebarSubagentChildEntry,
|
||||||
} from '../../context-types.ts'
|
} from '../../context-types.ts'
|
||||||
import { rootAncestor, isSideThreadSummary } from '../subagent-lineage.ts'
|
import { rootAncestor, isSideThreadSummary } from '../subagent-lineage.ts'
|
||||||
@@ -66,8 +81,16 @@ const S: Record<string, CSSProperties> = {
|
|||||||
cardLabel: { fontSize: 13, fontWeight: 500, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: DSW.labelPrimary },
|
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' },
|
cardSecondary: { fontSize: 11, color: DSW.labelSecondary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
||||||
badge: { fontSize: 11, color: DSW.labelTertiary, flexShrink: 0 },
|
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). */
|
/** A small colored status dot (self-contained, not the builtin StateDot). */
|
||||||
function Dot(props: { color: string }) {
|
function Dot(props: { color: string }) {
|
||||||
const style: CSSProperties = {
|
const style: CSSProperties = {
|
||||||
@@ -76,15 +99,91 @@ function Dot(props: { color: string }) {
|
|||||||
return <span aria-hidden style={style} />
|
return <span aria-hidden style={style} />
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Subscribe to the sessions list feed (uSES-safe). Returns the live snapshot. */
|
/** A tiny status pill (current / running / subagent marks). */
|
||||||
function useSessionList(ctx: TabComponentProps['ctx']): SidebarSessionList {
|
function Chip(props: { label: string; color?: string }) {
|
||||||
const sessions = ctx.sessions
|
return <span style={props.color === undefined ? S.chip : { ...S.chip, color: props.color, borderColor: props.color }}>{props.label}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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(
|
return useSyncExternalStore(
|
||||||
useMemo(() => (callback: () => void) => sessions.list.subscribe(callback), [sessions]),
|
subscribe,
|
||||||
useCallback(() => sessions.list.getSnapshot(), [sessions]),
|
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<string, Record<string, unknown>> }
|
||||||
|
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). */
|
/** One level of the subagent topology (rooted at the main agent). */
|
||||||
function agentChildren(
|
function agentChildren(
|
||||||
byId: Readonly<Record<string, SidebarSessionSummary>>,
|
byId: Readonly<Record<string, SidebarSessionSummary>>,
|
||||||
@@ -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 (
|
||||||
|
<div style={S.body}>
|
||||||
|
<h2 style={S.title}>{t('apesTasksTitle')}</h2>
|
||||||
|
{rows.length === 0
|
||||||
|
? <p style={S.hint}>{t('apesTasksEmpty')}</p>
|
||||||
|
: (
|
||||||
|
<div style={S.list}>
|
||||||
|
{rows.map(row => {
|
||||||
|
const running = row.running === true
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={row.id}
|
||||||
|
type="button"
|
||||||
|
aria-current={row.id === list.current ? 'true' : undefined}
|
||||||
|
title={row.displayTitle}
|
||||||
|
onClick={() => { open?.(row.id) }}
|
||||||
|
style={{ ...S.card, ...S.taskRow }}
|
||||||
|
>
|
||||||
|
<div style={S.cardRow}>
|
||||||
|
<Dot color={running ? DSW.success : DSW.labelTertiary} />
|
||||||
|
<span style={S.cardLabel}>{row.displayTitle}</span>
|
||||||
|
{row.id === list.current && <Chip label={t('apesTasksCurrent')} color={DSW.labelPrimary} />}
|
||||||
|
{running && <Chip label={t('apesTasksRunning')} color={DSW.success} />}
|
||||||
|
{row.origin === 'subagent' && <Chip label={t('apesTasksSubagent')} />}
|
||||||
|
</div>
|
||||||
|
{row.cwd !== undefined && (
|
||||||
|
<div style={S.cardRow}>
|
||||||
|
<span style={S.cardSecondary}>{row.cwd}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** apes:messages — DSH exposes no message-stream feed; honest placeholder. */
|
||||||
|
export function ApesMessagesView(_props: TabComponentProps) {
|
||||||
|
return (
|
||||||
|
<div style={S.body}>
|
||||||
|
<h2 style={S.title}>{t('apesMessagesTitle')}</h2>
|
||||||
|
<p style={S.hint}>{t('apesMessagesNoFeed')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** apes:agents — the subagent topology of the current session's tree. */
|
/** apes:agents — the subagent topology of the current session's tree. */
|
||||||
function ApesAgentsView(props: TabComponentProps) {
|
function ApesAgentsView(props: TabComponentProps) {
|
||||||
const list = useSessionList(props.ctx)
|
const list = useSessionList(props.ctx, props.visible)
|
||||||
const { byId, subagentsByParent } = list
|
const { byId, subagentsByParent } = list
|
||||||
const rootId = useMemo(() => rootAncestor(byId, props.scope.sessionId), [byId, props.scope.sessionId])
|
const rootId = useMemo(() => rootAncestor(byId, props.scope.sessionId), [byId, props.scope.sessionId])
|
||||||
const rootSummary = rootId === undefined ? undefined : byId[rootId]
|
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). */
|
/** apes:profile — the current session's identity (title, cwd, running state). */
|
||||||
export function ApesProfileView(props: TabComponentProps) {
|
export function ApesProfileView(props: TabComponentProps) {
|
||||||
const list = useSessionList(props.ctx)
|
const list = useSessionList(props.ctx, props.visible)
|
||||||
const current = list.current
|
const current = list.current
|
||||||
const summary = current === undefined ? undefined : list.byId[current]
|
const summary = current === undefined ? undefined : list.byId[current]
|
||||||
const cwd = summary?.cwd ?? props.scope.cwd
|
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). */
|
/** apes:apps — no DSH host feed today; shows the current workspace (cwd). */
|
||||||
function ApesAppsView(props: TabComponentProps) {
|
function ApesAppsView(props: TabComponentProps) {
|
||||||
const list = useSessionList(props.ctx)
|
const list = useSessionList(props.ctx, props.visible)
|
||||||
const current = list.current
|
const current = list.current
|
||||||
const summary = current === undefined ? undefined : list.byId[current]
|
const summary = current === undefined ? undefined : list.byId[current]
|
||||||
const cwd = summary?.cwd ?? props.scope.cwd
|
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
|
* The five apes:* tab descriptors (C7: `apes:tasks` restored — the native
|
||||||
* official DSH session tree). Order 901-903: agents/apps/profile. Each is
|
* session tree focuses one lineage, this list covers the whole workspace).
|
||||||
* already shaped as `TabDescriptor` (component + single-instance + icon
|
* Order 55-70 keeps them after the built-ins (editor 10 … browser 50) and in
|
||||||
* element + i18n title function) so `registerApesTabs` can hand them straight
|
* reading order: tasks / agents / apps / messages / profile. Each is already
|
||||||
* to `service.registerTab`. Information-layer tabs are single-instance.
|
* 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[] = [
|
export const apesTabs: TabDescriptor[] = [
|
||||||
{ id: 'apes:agents', title: () => t('apesAgentsTitle'), icon: (size: number) => <IconAgentOutline16 size={size} />, order: 60, single: true, component: ApesAgentsView },
|
{
|
||||||
|
id: TASKS_TAB_ID,
|
||||||
|
title: () => t('apesTasksTitle'),
|
||||||
|
icon: (size: number) => <IconTasksOutline16 size={size} />,
|
||||||
|
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) => <IconAgentOutline16 size={size} />,
|
||||||
|
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) => <IconAppsOutline16 size={size} />, order: 65, single: true, component: ApesAppsView },
|
{ id: 'apes:apps', title: () => t('apesAppsTitle'), icon: (size: number) => <IconAppsOutline16 size={size} />, order: 65, single: true, component: ApesAppsView },
|
||||||
|
{ id: 'apes:messages', title: () => t('apesMessagesTitle'), icon: (size: number) => <IconMessagesOutline16 size={size} />, order: 67, single: true, component: ApesMessagesView },
|
||||||
{ id: 'apes:profile', title: () => t('apesProfileTitle'), icon: (size: number) => <IconUserOutline16 size={size} />, order: 70, single: true, component: ApesProfileView },
|
{ id: 'apes:profile', title: () => t('apesProfileTitle'), icon: (size: number) => <IconUserOutline16 size={size} />, order: 70, single: true, component: ApesProfileView },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { allLeaves, createSidebarStore, isAgentTabId } from './state.ts'
|
|||||||
import { createBetterSidebarService, matchUrlTarget } from './service.ts'
|
import { createBetterSidebarService, matchUrlTarget } from './service.ts'
|
||||||
import { revalidateChunksOnReactivate, setChunkModuleSystem } from './chunk-loader.ts'
|
import { revalidateChunksOnReactivate, setChunkModuleSystem } from './chunk-loader.ts'
|
||||||
import { registerBuiltins } from './builtins/index.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 { Sidebar } from './Sidebar.tsx'
|
||||||
import { RenderBoundary } from './RenderBoundary.tsx'
|
import { RenderBoundary } from './RenderBoundary.tsx'
|
||||||
import { registerTurnTailInterception } from './intercept.tsx'
|
import { registerTurnTailInterception } from './intercept.tsx'
|
||||||
@@ -65,14 +65,16 @@ export function apply(ctx: Context): void {
|
|||||||
attachLocale(ctx.locale)
|
attachLocale(ctx.locale)
|
||||||
// The apes module has its own locale namespace (isolated from the upstream
|
// The apes module has its own locale namespace (isolated from the upstream
|
||||||
// betterSidebar dicts): attach it so apes t() tracks the DSH active-locale
|
// 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)
|
attachApesLocale(ctx.locale)
|
||||||
ctx.effect(() => {
|
ctx.effect(() => {
|
||||||
const offZh = ctx.locale.register(LOCALE_NS, 'zh', zh)
|
const offZh = ctx.locale.register(LOCALE_NS, 'zh', zh)
|
||||||
const offEn = ctx.locale.register(LOCALE_NS, 'en', en)
|
const offEn = ctx.locale.register(LOCALE_NS, 'en', en)
|
||||||
const offApesZh = ctx.locale.register(APES_LOCALE_NS, 'zh', apesZh as Record<string, string>)
|
const offApesZh = ctx.locale.register(APES_LOCALE_NS, 'zh', apesZh as Record<string, string>)
|
||||||
|
const offApesJa = ctx.locale.register(APES_LOCALE_NS, 'ja', apesJa as Record<string, string>)
|
||||||
const offApesEn = ctx.locale.register(APES_LOCALE_NS, 'en', apesEn as Record<string, string>)
|
const offApesEn = ctx.locale.register(APES_LOCALE_NS, 'en', apesEn as Record<string, string>)
|
||||||
return () => { offZh(); offEn(); offApesZh(); offApesEn() }
|
return () => { offZh(); offEn(); offApesZh(); offApesJa(); offApesEn() }
|
||||||
}, 'dsh-apes-sidebar: dictionaries')
|
}, 'dsh-apes-sidebar: dictionaries')
|
||||||
|
|
||||||
// Opt-in third-language support through @huanlin/dsh-plugin-better-locale.
|
// Opt-in third-language support through @huanlin/dsh-plugin-better-locale.
|
||||||
|
|||||||
+236
-23
@@ -1,25 +1,44 @@
|
|||||||
/**
|
/**
|
||||||
* apes:* tab registration tests (Phase 1/1b, C6: 3 tabs). The fork registers
|
* apes:* tab registration tests (Phase 1/1b → C7: 5 tabs). The fork registers
|
||||||
* its three information-layer tabs (agents / apps / profile) through the same
|
* its information-layer tabs (tasks / agents / apps / messages / profile)
|
||||||
* BetterSidebarService the upstream builtins use (dogfood). `apes:tasks` was
|
* through the same BetterSidebarService the upstream builtins use (dogfood).
|
||||||
* removed in C6 — it overlaps the official DSH session tree in the native
|
* `apes:tasks` is back (C7 reversal of C6): the native DSH session tree focuses
|
||||||
* sidebar. The spec pins the registration contract (id / single / order /
|
* ONE lineage, while this panel is a flat, whole-workspace list with a running
|
||||||
* component presence) and the isolated i18n contract (the apes namespace
|
* badge and an idle filter — the overlap is complementary.
|
||||||
* resolves zh vs en through the attached locale service, and every apesZh
|
*
|
||||||
* key has an apesEn entry).
|
* 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
|
* 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'
|
import { describe, expect, it } from 'vitest'
|
||||||
// Browser globals before the client graph loads (mirror of builtins.spec.ts).
|
// Browser globals before the client graph loads (mirror of builtins.spec.ts).
|
||||||
import './browser-globals.ts'
|
import './browser-globals.ts'
|
||||||
|
|
||||||
import type { Context } from '../src/context-types.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 { createBetterSidebarService } from '../src/client/service.ts'
|
||||||
|
import type { SessionScope, SidebarState, TabComponentProps } from '../src/client/service.ts'
|
||||||
import { createSidebarStore } from '../src/client/state.ts'
|
import { createSidebarStore } from '../src/client/state.ts'
|
||||||
import { registerApesTabs } from '../src/client/apes/index.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(): {
|
function setup(): {
|
||||||
service: ReturnType<typeof createBetterSidebarService>
|
service: ReturnType<typeof createBetterSidebarService>
|
||||||
@@ -32,13 +51,47 @@ function setup(): {
|
|||||||
return { service, store, dispose }
|
return { service, store, dispose }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A durable session row fixture. */
|
||||||
|
function row(id: string, over: Partial<SidebarSessionSummary> = {}): 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', () => {
|
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()
|
const { service, dispose } = setup()
|
||||||
try {
|
try {
|
||||||
expect(service.getTabs().map(t => t.id).sort()).toEqual(
|
expect(service.getTabs().map(t => t.id).sort()).toEqual(APES_TAB_IDS)
|
||||||
['apes:agents', 'apes:apps', 'apes:profile'],
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
dispose()
|
dispose()
|
||||||
}
|
}
|
||||||
@@ -47,35 +100,40 @@ describe('apes:* tab registrations', () => {
|
|||||||
it('every apes tab is single-instance, visible, and component-carrying', () => {
|
it('every apes tab is single-instance, visible, and component-carrying', () => {
|
||||||
const { service, dispose } = setup()
|
const { service, dispose } = setup()
|
||||||
try {
|
try {
|
||||||
for (const id of ['apes:agents', 'apes:apps', 'apes:profile']) {
|
for (const id of APES_TAB_IDS) {
|
||||||
const tab = service.getTab(id)
|
const tab = service.getTab(id)
|
||||||
expect(tab, `${id} registered`).toBeDefined()
|
expect(tab, `${id} registered`).toBeDefined()
|
||||||
expect(tab?.single, `${id} single`).toBe(true)
|
expect(tab?.single, `${id} single`).toBe(true)
|
||||||
expect(tab?.hidden, `${id} hidden`).not.toBe(true)
|
expect(tab?.hidden, `${id} hidden`).not.toBe(true)
|
||||||
expect(tab?.component, `${id} component`).toBeDefined()
|
expect(tab?.component, `${id} component`).toBeDefined()
|
||||||
expect(tab?.icon, `${id} icon`).toBeDefined()
|
expect(tab?.icon, `${id} icon`).toBeDefined()
|
||||||
|
expect(typeof tab?.title === 'function' ? tab.title() : tab?.title, `${id} title`).toBeTruthy()
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
dispose()
|
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()
|
const { service, dispose } = setup()
|
||||||
try {
|
try {
|
||||||
const orders = {
|
const orders = {
|
||||||
|
tasks: service.getTab(TASKS_TAB_ID)?.order,
|
||||||
agents: service.getTab('apes:agents')?.order,
|
agents: service.getTab('apes:agents')?.order,
|
||||||
apps: service.getTab('apes:apps')?.order,
|
apps: service.getTab('apes:apps')?.order,
|
||||||
|
messages: service.getTab('apes:messages')?.order,
|
||||||
profile: service.getTab('apes:profile')?.order,
|
profile: service.getTab('apes:profile')?.order,
|
||||||
}
|
}
|
||||||
// C8: order 40-50 places apes tabs immediately after the native builtins
|
// 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.
|
// (10-35), not in a far-away 900+ band — visual unity in the tab strip.
|
||||||
expect(orders.agents).toBeGreaterThanOrEqual(40)
|
for (const [id, order] of Object.entries(orders)) {
|
||||||
expect(orders.apps).toBeGreaterThanOrEqual(40)
|
expect(order, `${id} order`).toBeGreaterThanOrEqual(40)
|
||||||
expect(orders.profile).toBeGreaterThanOrEqual(40)
|
}
|
||||||
// Strictly ordered: agents < apps < profile.
|
// Strictly ordered: tasks < agents < apps < messages < profile.
|
||||||
|
expect(orders.tasks!).toBeLessThan(orders.agents!)
|
||||||
expect(orders.agents!).toBeLessThan(orders.apps!)
|
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 {
|
} finally {
|
||||||
dispose()
|
dispose()
|
||||||
}
|
}
|
||||||
@@ -85,14 +143,146 @@ describe('apes:* tab registrations', () => {
|
|||||||
const store = createSidebarStore()
|
const store = createSidebarStore()
|
||||||
const service = createBetterSidebarService(store)
|
const service = createBetterSidebarService(store)
|
||||||
const dispose = registerApesTabs({} as Context, service)
|
const dispose = registerApesTabs({} as Context, service)
|
||||||
expect(service.getTabs()).toHaveLength(3)
|
expect(service.getTabs()).toHaveLength(5)
|
||||||
dispose()
|
dispose()
|
||||||
expect(service.getTabs()).toHaveLength(0)
|
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', () => {
|
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[]
|
const zhKeys = Object.keys(apesZh) as ApesCopyKey[]
|
||||||
for (const key of zhKeys) {
|
for (const key of zhKeys) {
|
||||||
expect(apesEn[key], `en entry for ${key}`).toBeDefined()
|
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', () => {
|
it('resolves zh when the DSH preference is zh', () => {
|
||||||
attachApesLocale({ getSnapshot: () => ({ active: 'zh-CN' }) })
|
attachApesLocale({ getSnapshot: () => ({ active: 'zh-CN' }) })
|
||||||
expect(t('apesAgentsTitle')).toBe('智能体')
|
expect(t('apesAgentsTitle')).toBe('智能体')
|
||||||
@@ -108,11 +307,25 @@ describe('apes i18n contract', () => {
|
|||||||
expect(t('apesProfileTitle')).toBe('画像')
|
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', () => {
|
it('resolves en when the DSH preference is en', () => {
|
||||||
attachApesLocale({ getSnapshot: () => ({ active: 'en' }) })
|
attachApesLocale({ getSnapshot: () => ({ active: 'en' }) })
|
||||||
expect(t('apesAgentsTitle')).toBe('Agents')
|
expect(t('apesAgentsTitle')).toBe('Agents')
|
||||||
expect(t('apesAppsTitle')).toBe('Apps')
|
expect(t('apesAppsTitle')).toBe('Apps')
|
||||||
expect(t('apesProfileTitle')).toBe('Profile')
|
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', () => {
|
it('falls back to zh when no locale service is attached', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user