@modesto_ma/dsh-channels 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,13 +6,13 @@
6
6
  |---|---|---|
7
7
  | 会话本体(事件日志 + header) | 宿主 `dsh-session-persistence` | `$DSH_HOME/sessions/<workspace>/<sessionId>/session.jsonl.zstd` |
8
8
  | 插件自有状态 | 插件 → `ctx.storageDomain` | 域 `channels_routes`,JSON 后端为 `$DSH_HOME/storages/channels_routes.json` |
9
- | 工作区 | 磁盘目录 + 宿主 registry | `<baseDir>/p2p|groups/...`(`baseDir` 可在 Web 面板配置),注册表 `$DSH_HOME/storages/workspace.json` |
9
+ | 工作区 / 话题目录 | 磁盘目录 + 宿主 registry + Session 日志 | 工作区 `<baseDir>/p2p|groups/...`(`baseDir` 可在 Web 面板配置,注册表 `$DSH_HOME/storages/workspace.json`);每个话题的工作目录(`<工作区>/topics/<话题>` 或 Git worktree)由 Session 日志的 `working-directory/change` 事件恢复 |
10
10
 
11
11
  ## 一、已持久化的内容
12
12
 
13
- 1. **路由与过期墓碑**(`src/domain.ts` 域 `channels_routes`):`routes` 表以 `thread_id` 或降级键 `chat:p2p|group:<chatId>:<openId>` 保存活动 `RouteRecord`(sessionId / chatId / chatType / senderOpenId / workspacePath / cardMessageId / createdAt / lastActivityAt);`expired_routes` 以 thread id 保存 TTL 退休后的最小墓碑,跨重启拒绝旧话题误建 Session。`cardMessageId` 每 turn 更新,`lastActivityAt` 供路由退休使用。
13
+ 1. **路由与过期墓碑**(`src/domain.ts` 域 `channels_routes`):`routes` 表以 `thread_id` 或降级键 `chat:p2p|group:<chatId>:<openId>` 保存活动 `RouteRecord`(sessionId / chatId / chatType / senderOpenId / workspacePath / sessionDir? / worktreeBranch? / cardMessageId / createdAt / lastActivityAt);`workspacePath` 是用户工作区(Session header cwd),`sessionDir` 是该话题的实际工作目录(旧记录缺省 = `workspacePath`);`expired_routes` 以 thread id 保存 TTL 退休后的最小墓碑,跨重启拒绝旧话题误建 Session。`cardMessageId` 每 turn 更新,`lastActivityAt` 供路由退休使用。
14
14
  2. **会话本体与事件日志**:宿主代持。插件消费的 `assistant/chunk`、`tool/call`、`tool-workflow/run-*`、`subagent/descriptor` 等全部在持久化日志里,因此 `src/stats.ts` 的 token/缓存统计不单独存,`foldAll()` 重放日志,重启后继续累计。`meta.cwd`、`agentPreset` 随 header 落盘。
15
- 3. **工作区**:目录确定性生成、幂等 mkdir;宿主 `workspace.json` 持久化 path/title/sessionIds。
15
+ 3. **工作区与话题目录**:工作区目录确定性生成、幂等 mkdir,宿主 `workspace.json` 持久化 path/title/sessionIds;话题目录路径记录在路由的 `sessionDir`,实际工作目录同时由 Session 的 `working-directory/change` 事件持久化。
16
16
  4. **凭据**:密钥存宿主 `$DSH_HOME/.credentials.yaml`,插件只持引用名。
17
17
  5. **准入授权**(`src/domain.ts` 域 `channels_routes`,同样保持 `version: 1` 加法新增):`access_groups` 表以 `chat_id` 保存已授权群(群名/来源/授权人/时间);`access_users` 表以 `open_id` 保存全局用户白名单;`access_requests` 表以不透明 token 保存入群/用户授权申请(含 `messageId`,重启后旧授权卡仍可点击结算;过期状态在同一表落审计)。管理员 open_id 与准入模式属于配置层(profile + Web 覆盖),不在这三张表里。
18
18
 
@@ -70,7 +70,7 @@
70
70
  2. **段卡更新**(`streamer.ts`):续卡发送成功、message id 解析后经新钩子 `onSegmentDelivered(mainCardMessageId, index, messageId, mode)` 上报;服务端通过 `table.update()`(宿主原子 read-modify-write)合并进快照。
71
71
  3. **清除**(`streamer.ts` `finalize`):turn 结束即经 `onTurnFinalized(sessionId, turn, mainCardMessageId)` 删除快照。会话创建失败路径(卡片已落「会话创建失败」终态)同样主动清除。
72
72
  4. **修复**(`service.ts` `start()`,长连接建立后后台执行 `repairInflight()`):
73
- - 对每条快照:所有 `mode: 'card'` 的卡 patch 为终态中断卡(主卡带折叠面板,续卡带分段头,正文为「⚠️ 服务已重启,本次回复中断,请重新发送消息。」);移除 Typing、补 DONE(表情操作 best-effort)。
73
+ - 对每条快照:所有 `mode: 'card'` 的卡 patch 为终态中断卡(`buildInterruptionCard`:正文为「⚠️ 服务已重启,本次回复中断,请重新发送消息。」一条 markdown;主卡只有该条正文、**不带折叠面板**,续卡额外带 `📄 回复分段 n · 回复中断` 的 notation 分段头;不带 token 统计);移除 Typing、补 DONE(表情操作 best-effort)。
74
74
  - 成功后删除快照;失败则 `attempts + 1`,达到 2 次后删除(一次性修复 + 有限重试,防止对无法接受 patch 的介质无限重试)。
75
75
 
76
76
  ## 已知取舍(v1)
@@ -1,7 +1,7 @@
1
1
  # 重构计划与进度
2
2
 
3
3
  > 目标:让 dsh-channels **更条理**(分层清晰、单一职责)、**更优雅**(去重、统一抽象、单一事实来源)、**更可用**(模型/权限/图片/历史等能力补齐)。
4
- > 约束:对外行为与公开 API 保持不变;每个阶段结束跑 `tsc --noEmit` + 全量 `vitest`,保持 263 个测试全绿作为安全网。
4
+ > 约束:对外行为与公开 API 保持不变;每个阶段结束跑 `tsc --noEmit` + 全量 `vitest`,保持 576 个测试全绿作为安全网。
5
5
 
6
6
  ## 目标结构
7
7
 
@@ -30,10 +30,11 @@ src/
30
30
  | 4 | `session/manager.ts`:live agent 借用/冷恢复、preset 组合、`/renew` 事务、推理强度挂载 | ✅ |
31
31
  | 5 | `commands/registry.ts` + 注册表驱动分派:命令词表/准入/`/help` 单一事实来源;`/new` 别名 | ✅ |
32
32
  | 6 | `routing/inbound.ts`:入站去重、准入、路由决策树、建/续会话、首触引导从门面拆出 | ✅ |
33
+ | 7 | `cards/streaming.ts` 构成收敛与预算/切分收敛:渲染复用 `cards/primitives.ts` 的 `card/markdown/note/foldPanel`(`buildOnboardingCard` 拆到 `cards/onboarding.ts`);预算常量 4 → 3(删除 `CARD_PANEL_RESERVE_BYTES`);分段按整卡真实渲染测量、续卡可用满 28 KB;切分统一避让代码围栏与表格;卡片构成收敛为「面板 → 活动行 → 正文 → 分割线 + 两行结算页脚」三层(页脚第一行:模型/会话/推理时长,第二行:上下文占比/输入输出/缓存命中);新增 golden 结构测试 | ✅ |
33
34
 
34
- 行数变化:`src/service.ts` **1902 → 1381**;宿主类型 1 文件 → 5 模块;卡片 1 文件 → 4 模块 + 原语;新增共享交互基类、命令注册表、会话管理器、路由存储、重启修复、投递层。测试 240 → **274**。
35
+ 行数变化:`src/service.ts` **1902 → 1381**;宿主类型 1 文件 → 5 模块;卡片 1 文件 → 4 模块 + 原语;新增共享交互基类、命令注册表、会话管理器、路由存储、重启修复、投递层。测试 240 → **274**(阶段 1–6 结束时;当前 576 个测试)。
35
36
 
36
- ## 可用性增强(阶段 7,待做)
37
+ ## 可用性增强(已完成)
37
38
 
38
39
  | 功能 | 状态 | 备注 |
39
40
  |---|---|---|
@@ -45,12 +46,12 @@ src/
45
46
  | 问答卡自由文本回答 | ✅ | 无选项问题直接开放答题;阻塞期间普通文本回复即答案(`custom`) |
46
47
  | `/model` 查看与切换 | ✅ | `session/request-policy.ts` 统一 provider/model/effort;`ctx.llm.listModels` 校验;经 `command/run` 持久化 |
47
48
  | 附件输入 | ✅ | `feishu/inbound-attachments.ts`:图片/文件流式落盘到工作区 `attachments/`、合并转发记录转存 `.txt`;不触发模型,Agent 用工具按需读取;`acceptAttachments` 可配 |
48
- | 工作区清理 | ✅ | `cleanup.ts` 纯策略 + 设置面板卡片:`workspaceRetention`(off/7/30/90/180 天)+ 手动/每日清理,删除插件工作区并把路由退休为墓碑,Session 日志保留 |
49
+ | 目录清理 | ✅ | `cleanup.ts` 纯策略 + 设置面板卡片:`workspaceRetention`(off/7/30/90/180 天)+ 手动/每日清理,删除过期话题目录/旧版话题工作区并把路由退休为墓碑,用户工作区与 Session 日志保留 |
49
50
 
50
51
  ## 验证基线
51
52
 
52
53
  ```bash
53
54
  cd /Users/moka/dev/project/dsh_ext/dsh_channels
54
55
  node_modules/.bin/tsc --noEmit
55
- node_modules/.bin/vitest run # 17 files, 287 tests
56
+ node_modules/.bin/vitest run # 25 files, 576 tests
56
57
  ```
@@ -0,0 +1,78 @@
1
+ # 工作区粒度与会话隔离设计
2
+
3
+ > 状态:已确认并实现(v1)。目标:工作区数量随「人」增长而不是随「话题」增长,同时话题之间不互相踩文件。
4
+
5
+ ## 1. 问题
6
+
7
+ 旧模型(`一个话题 = 一个 DSH 工作区`)把两个独立维度绑在了一起:
8
+
9
+ - **项目维度**:`ctx.workspaceRegistry` 是持久化项目列表,GUI 侧边栏一个分区一项,随话题无上限增长;
10
+ - **隔离维度**:模型工具产生的文件必须按话题隔开。
11
+
12
+ DSH 本身允许这两个维度解耦,见下。
13
+
14
+ ## 2. DSH 的三个关键事实
15
+
16
+ 1. **工作区归属只认不可变的 `SessionHeader.cwd`**:`Workspace.attachSession()` 校验 header.cwd 与工作区路径(realpath 后)完全相等;GUI 分组、重启 bootstrap 同理。
17
+ 2. **实际工作目录可变**:`ctx.workingDirectory.set(agent, dir)` 写 `working-directory/change` 事件、持久化、冷恢复后还原;`bash`、`fs`、`terminal` 工具都从 `workingDirectory` 取 cwd。因此会话可以「归属 A 工作区、实际在 B 目录干活」。DSH 官方 Git worktree 也是靠这个机制保留原项目归属。
18
+ 3. **沙箱写边界 = 不可变 header.cwd**,不随工作目录切换。因此本设计提供的是**行为层隔离**(工具默认在话题目录内工作、互不踩文件),不是防恶意的硬沙箱;硬边界需要 header.cwd 直接指向话题目录,代价是会话失去工作区归属(见 §6)。
19
+
20
+ ## 3. 目录布局
21
+
22
+ ```
23
+ <baseDir>/
24
+ p2p/<open_id>/ # 私聊用户工作区(注册 workspace,header.cwd)
25
+ topics/<topic-token>/ # 话题目录(会话实际工作目录)
26
+ attachments/ # 该话题的入站附件
27
+ groups/<chat_id>/<open_id>/ # 群聊中某成员的工作区(注册 workspace)
28
+ topics/<topic-token>/
29
+ attachments/
30
+ topics/... # 旧版「话题=工作区」目录,原样保留,不自动迁移
31
+ _staging/<chatType>/<chatId>/<openId>/ # 话题建立前的附件暂存
32
+ ```
33
+
34
+ - 私聊一个用户一个工作区;群聊**一个群 × 一个用户**一个工作区(不能做成「用户全局一个」:否则同一个人在不同群之间会串文件,A 群会话可能把 B 群的文件内容贴给 A 群成员)。
35
+ - `<baseDir>/p2p/...`、`<baseDir>/groups/...` 与旧版「用户 × 聊天」共享工作区路径一致,升级后旧会话仍归属同一个项目。
36
+ - 话题 token:真实话题用 `thread_id`;降级 chat-scoped 会话用其 session id。目录名经过安全字符校验。
37
+
38
+ ## 4. 隔离档位(`topicIsolation`)
39
+
40
+ | 值 | 行为 |
41
+ |---|---|
42
+ | `auto`(默认) | 工作区含 `.git`(即用户把工作区做成了 Git 仓库)时,每个话题用 DSH worktree(分支 `feishu-<token>`,checkout 在 `.agents/worktrees/`);否则用普通话题子目录 |
43
+ | `subdir` | 始终普通话题子目录,零 Git 依赖 |
44
+ | `worktree` | 始终尝试 worktree;服务缺失 / 非 Git 仓库 / 权限不足时告警并回退子目录 |
45
+
46
+ - 子目录模式:`mkdir <workspace>/topics/<token>` 后 `workingDirectory.set` 切过去。文档/数据类工作区零成本、不复制文件。
47
+ - worktree 模式:`ctx.worktrees.create(agent, { name: 'feishu-<token>' })`,返回的 checkout 即会话目录;分支与工作树由 Git 管理,适合「多个话题并发改同一个代码仓库」。
48
+ - 两个模式都保留会话在用户工作区下的 GUI 归属,因为 header.cwd 仍是用户工作区。
49
+ - 降级:宿主没有 `workingDirectory` / `worktrees` 服务、或 worktree 创建失败时,回退为「会话直接以话题目录为 cwd」(此时不再 attach 到工作区,会话在 GUI 中可能显示为未分组;文件隔离仍然成立)。
50
+
51
+ ## 5. 路由记录
52
+
53
+ `channels_routes` 的 v1 记录保持可读,新增可选字段:
54
+
55
+ - `sessionDir`:会话实际工作目录(话题目录 / worktree);缺省表示旧记录——直接用 `workspacePath` 作为会话目录。
56
+ - `worktreeBranch`:worktree 模式下的话题分支,供清理与恢复使用。
57
+
58
+ `workspacePath` 语义变化为「用户工作区(会话 header cwd / 沙箱根)」。
59
+
60
+ ## 6. 取舍与边界
61
+
62
+ - **隔离强度**:话题子目录/worktree 只保证「工具默认在话题目录工作」。同一用户工作区内的绝对路径访问仍然可行;沙箱写边界仍是用户工作区。
63
+ - **硬隔离**(不采用):把 header.cwd 指向话题目录,会话在 GUI 中不再归属工作区。只有当话题来源不可信、必须防互相读取时才值得。
64
+ - **附件**:仍落在当前话题目录的 `attachments/`;话题建立前收到的附件先暂存到 `_staging/`,首个 Session 创建时迁入。
65
+ - **`/renew`**:同一话题内新建 Session,复用同一话题目录(worktree 也复用)。
66
+ - **清理**:保留期只删除过期的话题目录 / 旧版话题工作区,**永不删除用户工作区**;活跃路由先退休为墓碑(旧话题消息收到「话题已过期」提示),Session 日志不删除。worktree 模式下只删除 checkout 目录,Git 分支与 worktree 管理记录按 DSH 语义保留(`git worktree prune` / `branch -D` 可回收)。
67
+ - **迁移**:不做自动迁移。旧版 `topics/...` 工作区继续服务旧路由,可由清理卡片按保留期回收;旧版「用户 × 聊天」共享工作区即新的用户工作区,直接复用。
68
+
69
+ ## 7. 实现位置
70
+
71
+ | 文件 | 职责 |
72
+ |---|---|
73
+ | `src/workspaces.ts` | 用户工作区 / 话题目录 / 标题 / 暂存目录解析 |
74
+ | `src/session/isolation.ts` | 隔离档位判定、工作目录进入、worktree 创建与降级恢复 |
75
+ | `src/routing/inbound.ts` | 建会话与续聊时的目录编排、附件迁移、路由写入 |
76
+ | `src/session/manager.ts` | 会话创建/恢复/`/renew` 的目录挂载与附件 guidance |
77
+ | `src/cleanup.ts` + `src/service.ts` | 按话题目录/旧版工作区回收的保留期策略与面板接线 |
78
+ | `src/domain.ts` | 路由记录 v1 兼容扩展(`sessionDir` / `worktreeBranch`) |
package/lib/client.js CHANGED
@@ -28,7 +28,7 @@ window.__ModuleLoader__.load({
28
28
  const RECONNECT_ROUTE = "/dsh-channels/reconnect";
29
29
  /** 模型/推理档位/预设选择器的宿主目录(provider → model → effort 级联刷新)。 */
30
30
  const CATALOG_ROUTE = "/dsh-channels/catalog";
31
- /** 手动执行一次过期工作区清理(POST,无请求体)。 */
31
+ /** 手动执行一次过期目录清理(POST,无请求体)。 */
32
32
  const CLEANUP_ROUTE = "/dsh-channels/cleanup";
33
33
  /** 群白名单增删(POST/DELETE,body `{ chatId, name? }` / `{ chatId }`)。 */
34
34
  const ACCESS_GROUPS_ROUTE = "/dsh-channels/access/groups";
@@ -158,7 +158,7 @@ window.__ModuleLoader__.load({
158
158
  connectionTabHelp: "连接状态与飞书应用凭据。凭据不完整时不会连接飞书。",
159
159
  sessionsTabHelp: "私聊、群聊触发方式与入站内容;「需重连」项在下次连接时读取。",
160
160
  modelTabHelp: "飞书会话使用的模型服务商、模型、推理档位与 Agent 预设;下拉选项来自宿主机实时目录,保存后对新会话生效。",
161
- workspaceTabHelp: "每个飞书话题一个独立工作区(模型/工具产生文件的落点);只影响之后新建的话题。",
161
+ workspaceTabHelp: "每个用户一个工作区(模型/工具产生文件的落点),每个话题在其中拥有独立工作目录;只影响之后新建的话题。",
162
162
  accessTabHelp: "准入采用 fail-closed:管理员不受白名单限制,非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。",
163
163
  advancedTabHelp: "流式刷新等低层参数、Web 覆盖文件与只读信息。",
164
164
  save: "保存更改",
@@ -204,11 +204,12 @@ window.__ModuleLoader__.load({
204
204
  advancedCard: "只读与高级信息",
205
205
  advancedHelp: "这些值来自 profile 配置,不能在面板里编辑。",
206
206
  readOnlyCard: "部署级参数",
207
- cleanupCard: "工作区清理",
208
- cleanupCardHelp: "删除超过保留期且不再活跃的飞书工作区目录;只会删除本插件在「工作区根目录」下创建的目录,DSH 会话日志不删除。删除不可恢复:相关旧话题会失效并提示已过期,工作区列表记录同步移除。",
207
+ cleanupCard: "目录清理",
208
+ cleanupCardHelp: "删除超过保留期且不再活跃的飞书话题目录(以及旧版本按话题建立的工作区目录);用户工作区本身、DSH 会话日志都不会删除。删除不可恢复:相关旧话题会失效并提示已过期。",
209
209
  cleanupRetention: "保留期",
210
210
  cleanupAutoLabel: "自动清理",
211
- cleanupCandidates: "可清理工作区",
211
+ cleanupCandidates: "可清理目录",
212
+ cleanupWorkspaces: "插件工作区",
212
213
  cleanupCandidateBytes: "预计释放空间",
213
214
  cleanupRetentionOff: "关闭(不删除)",
214
215
  cleanupAutoOn: "已开启(启动时 + 每天一次)",
@@ -216,17 +217,18 @@ window.__ModuleLoader__.load({
216
217
  cleanupAutoInactive: "未执行(保留期为关闭)",
217
218
  cleanupNow: "立即清理",
218
219
  cleanupRunning: "清理中…",
219
- cleanupConfirm: "确定清理过期工作区吗?该操作不可恢复,会删除这些工作区内的全部文件(包括 attachments/ 附件)。",
220
- cleanupConfirmHint: "删除前会再次确认;保留期为「关闭」或没有过期工作区时按钮不可用。",
221
- cleanupDisabledOff: "保留策略为「关闭」:请先在上方「过期工作区保留期」中选择保留天数。",
222
- cleanupDisabledEmpty: "没有超过保留期的过期工作区。",
220
+ cleanupConfirm: "确定清理过期目录吗?该操作不可恢复,会删除这些目录内的全部文件(包括 attachments/ 附件)。",
221
+ cleanupConfirmHint: "删除前会再次确认;保留期为「关闭」或没有过期目录时按钮不可用。",
222
+ cleanupDisabledOff: "保留策略为「关闭」:请先在上方「过期目录保留期」中选择保留天数。",
223
+ cleanupDisabledEmpty: "没有超过保留期的过期目录。",
223
224
  cleanupPreviewFailed: "无法读取清理预览",
224
225
  cleanupDone: "清理完成",
225
226
  cleanupFailed: "清理失败",
226
227
  cleanupDeleted: "已删除工作区",
228
+ cleanupDeletedDirs: "话题目录",
227
229
  cleanupRetired: "已清理话题路由",
228
230
  cleanupFreed: "释放空间",
229
- cleanupFailures: "以下工作区未能删除:",
231
+ cleanupFailures: "以下目录未能删除:",
230
232
  accessCard: "访问与授权",
231
233
  accessCardHelp: "准入采用 fail-closed:管理员不受白名单限制(私聊、任意群聊都可直接对话);非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。管理员与准入模式在上方「准入与授权」中编辑;私聊机器人发送 /userid 获取自己的 open_id。",
232
234
  accessModeOpenWarning: "当前准入模式为开放(open):应用可用范围内的任何人都能开启对话。该模式不安全,仅建议调试时使用。",
@@ -1870,7 +1872,7 @@ window.__ModuleLoader__.load({
1870
1872
  //#endregion
1871
1873
  //#region src/client/panel/cleanup-card.tsx
1872
1874
  /**
1873
- * 工作区清理卡:展示清理预览(保留期 / 自动清理 / 候选数量与空间),
1875
+ * 目录清理卡:展示清理预览(保留期 / 自动清理 / 候选数量与空间),
1874
1876
  * 执行前二次确认,结果与部分失败逐条展示。`cleanup.error` 非空时
1875
1877
  * (例如未连接、无法扫描工作区)按钮天然禁用并显示原因。
1876
1878
  *
@@ -1891,7 +1893,11 @@ window.__ModuleLoader__.load({
1891
1893
  },
1892
1894
  {
1893
1895
  label: PANEL_COPY.cleanupCandidates,
1894
- value: `${cleanup.candidates} / ${cleanup.workspaces} 个`
1896
+ value: `${cleanup.candidates} 个`
1897
+ },
1898
+ {
1899
+ label: PANEL_COPY.cleanupWorkspaces,
1900
+ value: `${cleanup.workspaces} 个`
1895
1901
  },
1896
1902
  {
1897
1903
  label: PANEL_COPY.cleanupCandidateBytes,
@@ -1943,6 +1949,10 @@ window.__ModuleLoader__.load({
1943
1949
  " ",
1944
1950
  result.deletedWorkspaces,
1945
1951
  " 个,",
1952
+ PANEL_COPY.cleanupDeletedDirs,
1953
+ " ",
1954
+ result.deletedSessionDirs,
1955
+ " 个,",
1946
1956
  PANEL_COPY.cleanupRetired,
1947
1957
  " ",
1948
1958
  result.retiredRoutes,
@@ -1982,7 +1992,7 @@ window.__ModuleLoader__.load({
1982
1992
  //#endregion
1983
1993
  //#region src/client/panel/workspace-tab.tsx
1984
1994
  /**
1985
- * 「工作区」tab:工作区根目录 / 过期保留策略 / 自动清理字段 + 工作区清理卡。
1995
+ * 「工作区」tab:工作区根目录 / 过期保留策略 / 自动清理字段 + 目录清理卡。
1986
1996
  * 表单保存栏在字段卡正下方,清理卡(独立动作,不走 draft)在其后。
1987
1997
  *
1988
1998
  * @module dsh-channels/client/panel/workspace-tab