Bundle
dsh-recent
DSH web GUI plugin: a Codex-style 最近 (Recent) section in the left sidebar that flattens the newest sessions of every workspace into one jump list, directly below the workspace tree.
- Source
- ttmouse
- License
- MIT
- Updated
- Updated 8 days ago
Readme
# dsh-recent
[English](README.en.md) | 简体中文
DSH Web GUI 插件:在左侧边栏「工作区」列表下面加一个 **最近** 区块,把各个项目里最新的会话打散成一条时间线。上面按文件夹找项目,下面直接点最近干的活。

## 它解决什么
Codex 的侧边栏有两个视角:项目(文件夹)和最近(时间线)。DSH 的侧边栏只有项目视角——想回到刚才那个会话,得先想起它在哪个工作区里、再展开那个文件夹。
这个插件补上第二个视角:所有工作区的会话按更新时间排成一条平铺列表,行的长相、悬停行为和浮层都与上面工作区列表里的会话行逐元素一致——状态点、标题、距今时间(等待你响应时换成「待审批 / 计划待审 / 待回答」)、置顶标记,悬停时时间与标记让位给该行的操作(⋯ 菜单 / 归档 / 置顶);浮层是同款会话卡片(完整标题、距今时间、运行状态),并且多一行「文件夹图标 + 项目名」——平铺列表跨项目,这一行是唯一能看出会话属于哪个项目的地方。点击即打开会话。列表不折叠:滚到已渲染部分的末尾就继续加载更早的会话——整栏一条滚动条,从工作区树一路滚进历史。
## 特性
- **平铺时间线**:跨工作区取全部有历史的会话,不限条数,不受项目折叠状态影响。
- **滚动加载更早的**:默认先渲染一页(20 条),滚到已渲染部分的末尾自动再取一页,直到把历史铺完;没有折叠钮,也没有内层滚动条——区块就在工作区列表自己的滚动区里,整栏一起滚。
- **行就是工作区树的行**:16px 状态格 + 标题 + 距今时间(10px 三级色),已置顶的会话带置顶标记;尺寸、间距、圆角、悬停与当前高亮全部取自外壳自己的会话行(32px / `0 8px` / `--dsw-radius-md` / `--dsw-alias-interactive-bg-hover`)。
- **悬停即换出操作**:指针停在行上(或该行菜单打开)时,时间与置顶标记让位给行操作——⋯ 菜单(置顶 / 分叉 / 归档)、归档、置顶,与工作区树的会话行同一套图标与间距(16px 按钮 / 10px 间隙)。
- **悬停不位移**:指针只改变「显示什么」(填充色 + 尾格内容),不改变「在哪里」——行的位置与宽度、标题起点、尾格的位置与宽度,在静止态、悬停态、菜单打开态三态下逐值相同(尾格固定 74px 宽,三个占用者互斥挂载)。行上没有任何 `transition`,所以连过渡动画也没有:指针进入和离开都是一步到位,鼠标底下不会有东西滑动或跳一下。
- **悬停高亮内缩一圈**:填充色画在一层单独的高亮层上,左右各比行两端缩进 4px,所以指针所在的那一行看起来是一张四周留空的卡片,而不是顶到两端的色条。缩进的只有这一层、不是行本身——行是它自己文字的布局盒,缩盒子会把标题一起推着走,而且这是布局,去掉过渡也消不掉那次跳动。把间距画进那一层,才能「有间距」和「不位移」同时成立。
- **悬停浮层**:外壳自己的会话卡片,元素与工作区树的会话卡片一致——完整标题、距今时间、运行状态行(进行中 / 等待审批 / 计划待审 / 等待回答),点卡片可复制标题;另加一行「文件夹图标 + 项目名」(落在外壳卡片给扩展内容留的位置上,状态行仍是最后一行),这样两个同名会话也能看出各自属于哪个项目。
- **归档仍问一句**:会话还在干活时宿主会拒绝归档,此时弹出与外壳同款措辞的「停止并归档此会话?」,确认后先停后归档。
- **一致性**:可见性规则与工作区树完全一致——归档会话不显示、子代理会话归其父会话目录、空白占位会话不显示(它没有历史)。
- **状态可见**:等待你响应(审批/提问/计划确认)标黄点,运行中标动态点,当前会话整行高亮。
- **工作区折叠**:工作区列表默认只展示 5 个文件夹,其余折在一行与外壳同款的折叠钮后面(「展开其余 N 个工作区」);最近区块不折叠。留下的是**最近有记录**的那几个:每个分组按各自最新会话的时间参与比较,未分组那一组和普通工作区一样排排坐,谁都不默认占位——只有旧聊天记录的文件夹,甚至一条记录都没有的文件夹,默认就不会出现在列表里。展开是临时状态,收起侧栏再展开回到折叠默认。
- **每个项目只留最近 5 个会话**:项目下面的会话行同样裁到最新 5 个,**不看状态**——按会话自己的更新时间排,运行中、等待你响应、空白占位都不额外占名额(外壳自带的配额会豁免它们,这一层不会;比第 5 名更旧的运行中会话照样折起来)。多出来的折在一行里:外壳已经渲染「展开其余 N 个会话」时就用它,没有时插件补一行同款,点一下整组铺开。
- **原生外观**:使用 `--dsw-*` 语义 token 与外壳自带的 `StateDot`、细箭头图标,跟随明暗主题与品牌主题;折叠钮的尺寸、缩进、颜色与外壳自己的「展开其余 N 个会话」完全一致。
- **悬停浮层是外壳自己的原语**:直接用 `@deepseek-ai/dsh-client-ui-primitives` 的 `HoverCard`(工作区树会话卡片同一组件、同一份 244px 深色卡面),浮层内文字沿用它的固定浅色值,明暗主题下都不反色。
- **不改外壳源码**:最近区块注册到侧边栏既有的 `sidebar.footer.action` 列表槽位;行操作走外壳公开的 `uiWorkspace` 服务;工作区列表的折叠在已发布的外壳里没有对应槽位或服务,因此由本插件在 DOM 层补上(见设计说明),不修改、也不需要重新构建 DSH 前端。
## 安装
在跑 GUI 的 profile 目录里(通常是 `~/.dsh/profiles/web`),从 GitHub 装:
```sh
dsh plugin --profile web add github:ttmouse/dsh-recent
```
改本插件源码时用本地克隆(`link:` 会跟随你的改动):
```sh
dsh plugin --profile web add link:/path/to/dsh-recent
```
该命令会把依赖写进 profile 的 `package.json`,并自动把 `dsh-recent` 追加到 `dsh.profile.bundles`(也可以手工做这两步):
```json
{
"dependencies": { "dsh-recent": "link:/path/to/dsh-recent" },
"dsh": { "profile": { "bundles": ["…", "dsh-recent"] } }
}
```
然后重启 `dsh web` 并刷新页面——bundle 列表在启动时读取,热重载只覆盖已加载插件的源码变更:
```sh
# 停掉当前的 dsh web,再重新启动
dsh web
```
## 开发
```sh
pnpm install
pnpm run typecheck # tsc --noEmit(src + tests)
pnpm test # 单元测试;构建过后还会校验 lib/client.js 的加载器契约
pnpm run build # lib/index.js + lib/invariant.js + lib/client.js + lib/types
```
改完源码必须重新 `pnpm run build`:宿主服务的是 `lib/client.js`,不是源码。
## 设计说明
- **槽位选择**:`sidebar.footer.action` 是 `@deepseek-ai/dsh-client-ui-sidebar` 长期声明的 list 槽位,渲染位置正是工作区树与「设置」之间——需求要的位置。它同时被收窄到 56px 图标栏(`wide: false`)时,本区块渲染为 `null`:一条列表在导轨里没有意义。
- **数据来源**:会话与工作区数据走框架标准套件(注册组件自带的 `useSessions`/`useSessionStatus`/`useWorkspaces` 选择器钩子),会话操作走注册时注入的 `RecentActions` 面(`src/client/sessionActions.ts`,绑定 `uiWorkspace`),不在组件里订阅任何外部源。
- **打开会话**:走 `uiWorkspace.openSession`(它同时清掉中间列选中的面板)。`uiWorkspace` 是注册所需的 cordis 服务,侧边栏外壳本身就依赖它。
- **时间分档**:直接用外壳 `@deepseek-ai/dsh-client-ui-primitives` 导出的 `relativeTime`(刚刚 / N分钟 / N小时 / N天 / N个月 / N年),每 30 秒刷新一次,所以同一会话在两个界面上读到的年龄一定一致。
- **行与浮层照抄外壳**:行的每个元素都按外壳 `ui-workspace` 的会话行实现——16×20 状态格、14/20 标题、10/16 三级色时间、置顶标记、悬停时换出的三个 16px 图标按钮,高亮色 `--dsw-alias-interactive-bg-hover` 同时用于悬停行与当前会话;浮层沿用外壳会话卡片的元素与取值(标题 / 距今时间 / 状态行),只多一行项目名——卡片本身给扩展内容留了位置(外壳的 `sidebar.session.row.hover` 座位),所以这一行插在时间之后、状态行之前,状态行仍是卡片的最后一行。项目名取工作区标题,会话不属于任何工作区时退回目录名、再退回「未分组」。卡片可点击复制标题,与外壳会话卡片同一交互。
- **行操作走公开服务**:归档、置顶、分叉都通过注册时注入的 `uiWorkspace`(`archiveSession` / `pinSession` / `forkSession`)执行。外壳自己的行操作槽位(`sidebar.workspaces.session.row.action`、`sidebar.workspaces.session.menu.item`)按「声明即独占」规则已被 `ui-workspace` 声明,插件不能再声明或渲染它们,所以这里复刻的是外观与行为,而不是槽位本身。宿主以「会话仍在运行」拒绝归档时(`WorkspaceArchiveError` + `workspace/session-active`)弹出停止并归档确认;其余失败只记日志——插件没有自己的通知面。
- **两处刻意的差异**:浮层保留 0ms 即时出现(此前你要求「不该有任何延迟」,旧卡片靠 stale 标记立刻消失),外壳自己的会话行是 800ms;标题在悬停时保留省略号,不跟随外壳的硬切 + 爬行滚动——浮层已经即时给出完整标题。
- **工作区折叠怎么做的**:外壳把每个工作区渲染成一个 `_groupSection`,但既不提供「少显示几个」的能力,也没有对应槽位、store 或配置项,所以插件从外面补:把落选的分组置为 `display: none`,在最后一个保留的组后面插一行折叠钮。React 每次重渲染都会重写这份列表,因此用一个挂在 `document.body` 上的 MutationObserver 自愈,并用「先比对再写」保证自己的写入不会触发自己;观察回调先按记录过滤(只处理落在侧栏列内的变更),对话流式输出不会走到 `apply()`。列表容器的定位靠「第一个已渲染的 `_groupSection` 的父节点」,所以单列表/搜索渲染(没有 group 包装)天然不受影响。
- **折叠按「最近用过的」裁,而不是按列表顺序裁**:每个分组的新鲜度取它自己最新的会话时间(会话目录来自框架标准套件,分组归属照抄外壳:注册工作区认领自己的会话,剩下的都算未分组),保留最新的 5 个。分组的身份从 DOM 上读——外壳给每个分组的标题行写了 `data-row-key="workspace:<工作区 id>"`(未分组那一组的 key 是空串),这是分组唯一稳定的地址(类名是 hash,结构归 React)。没有历史记录的分组排在所有有记录的分组之后,所以默认折叠时它只会在名额有剩时才出现;列表本身没超过 5 个时不做任何裁剪。未分组那一组因此不再特殊:它默认不展示,只有在自己的会话足够新、挤进前 5 时才出现。折叠钮按渲染顺序插在最后一个保留组之后,落选的分组留在原位只是被隐藏,所以展开时顺序不变。
- **折叠是临时状态**:没有任何 store,展开状态住在组件里;侧栏收起再展开(`wide` 由 false 变 true)时工作区列表回到只显示 5 个、最近区块回到第一页,和 Codex 一致。
- **会话折叠怎么做的**:外壳并不把会话行渲染成分组的直接子节点,而是每行都包在自己的 hover 卡片槽位里(`position:relative; display:block` 的 span,槽位自己还带 2px 间距)。所以折叠层按 `data-row-key="session:<id>"` 在分组里找行,并且隐藏/还原的是**槽位**本身——只隐藏行会留下槽位的间距;自己那行折叠钮插在最后一个保留行的槽位之后(与外壳自己的溢出钮同层、同度量,`margin-top` 也照它归零)。每组只留一个展开入口:外壳已经渲染「展开其余 N 个会话」时不再补第二个,而且点击它同时展开两层——插件在 document 上监听那个 `data-row-key`,顺手把自己的折叠状态也放开。排序用会话目录里的更新时间,目录里没有记录的行排最后。
- **区块就在列表的滚动区里**:区块被 portal 进外壳那个滚动容器(工作区树自己的 `overflow-y: auto` 列表),成为这一栏的最后一段内容——所以整栏只有一条滚动条,滚过工作区树就自然滚到「最近」,再往下就是更早的会话。区块因此不占底部固定高度、也不做内部滚动,行多高就多高。
- **滚动加载怎么做的**:列表末尾放一个 1px 的哨兵元素,用 `IntersectionObserver`(root 就是那个滚动容器,底部提前 320px 触发)盯着它;哨兵进入视野就把渲染窗口再加一页。追加后哨兵被推出触发带,观察器自然安静下来,等下一次滚动——所以是一页一页地长,而不是一次铺完。区块折起(表头箭头)时不渲染哨兵,也就不会偷偷加载。
- **不再需要改外壳的 flex**:早先区块挂在这个滚动容器外面,工作区不多时外壳的 `flex: 1` 会把列表撑到栏底、把区块顶到看不见的地方,所以折叠层要把列表的 `flex: 1` 换成 `flex: 0 1 auto`。区块搬进滚动容器后这个问题不存在了(区块是列表内容的一部分,不会落在栏底之外),这层覆盖随之删掉,外壳的布局不再被本插件改动。
## 已知限制
- **只在宽栏显示**:56px 导轨状态不渲染本区块(需要在导轨上占一格的入口,应改用 `sidebar.panellist` 走面板形态)。
- **工作区折叠依赖外壳的 DOM 结构**:靠 `_groupSection` / `_footArea` 两个 CSS Module 后缀和「第一个 group 的父节点」定位,分组的身份则靠标题行上的 `data-row-key="workspace:<id>"`;外壳若改名或改结构,折叠会静默失效(退化成外壳原本的完整列表),不会报错也不会破坏列表——identity 读不到的分组一律排到最后,等于按列表顺序裁。折叠按分组计数(未分组那一组也算一个),不按会话数。区块所在的滚动容器也走这条定位:外壳若改成不给列表自己的 `overflow-y`,区块会跟着那次解析落到别的元素上。
- **折叠只裁不排序**:新鲜度只决定谁留下,不改变顺序——工作区的顺序沿用列表自身顺序(新建在前 + 手动排序),插件不按「最近活跃」重排;要重排需要动外壳的列表顺序,属于上游改动。
- **会话折叠同样依赖外壳的 DOM 结构**:行靠 `data-row-key="session:<id>"` 定位、靠 `_groupSection` 划归属(嵌套分组不串行);外壳若去掉行上的 key 或改掉槽位结构,会话折叠会静默退回外壳自己的配额(5 个空闲会话 + 运行中豁免),不会报错。顺带一提,外壳自己那行的数字只看它自己没渲染的部分,点开时会比它承诺的多露出插件折叠的那几条。
- **一次一页**:默认渲染最近 20 条,之后每次滚动追加 20 条;会话很多时要一路滚到底才能看到最老的,没有页码或跳转入口。
- **不做搜索、不能重命名**:搜索仍在工作区树里;重命名也留在树里——外壳的重命名对话框由 `ui-workspace` 的内部 store 驱动,插件拿不到入口,所以最近行的 ⋯ 菜单比外壳的会话菜单少一项「重命名」。要找很久以前的会话,搜索仍然比滚到底快。
- **标题为空时会显示会话 id**:标题由宿主从日志投影;尚无标题的旧会话,行标题就是 `displayTitle` 的回退值(通常是项目目录名或会话 id)。
## 许可证
[MIT](LICENSE)
Install
dsh plugin --profile web add github:ttmouse/dsh-recent
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-recent from the hub
- This package builds from source on install. pnpm will ask you to allow its build script — that is permission to run the package’s code on your machine, outside the agent sandbox. Only allow sources you trust.
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.