dsh-recent
English | 简体中文
DSH Web GUI 插件:在左侧边栏「工作区」列表下面加一个 最近 区块,把各个项目里最新的会话打散成一条时间线。上面按文件夹找项目,下面直接点最近干的活。

它解决什么
Codex 的侧边栏有两个视角:项目(文件夹)和最近(时间线)。DSH 的侧边栏只有项目视角——想回到刚才那个会话,得先想起它在哪个工作区里、再展开那个文件夹。
这个插件补上第二个视角:所有工作区的会话按更新时间排成一条平铺列表,行的长相、悬停行为和浮层都与上面工作区列表里的会话行逐元素一致——状态点、标题、距今时间(等待你响应时换成「待审批 / 计划待审 / 待回答」)、置顶标记,悬停时时间与标记让位给该行的操作(⋯ 菜单 / 归档 / 置顶);浮层是同款会话卡片(完整标题、距今时间、运行状态),并且多一行「文件夹图标 + 项目名」——平铺列表跨项目,这一行是唯一能看出会话属于哪个项目的地方。点击即打开会话。列表不折叠:滚到已渲染部分的末尾就继续加载更早的会话——整栏一条滚动条,从工作区树一路滚进历史。
特性
- 平铺时间线:跨工作区取全部有历史的会话,不限条数,不受项目折叠状态影响。
- 滚动加载更早的:默认先渲染一页(20 条),滚到已渲染部分的末尾自动再取一页,直到把历史铺完;没有折叠钮,也没有内层滚动条——区块就在工作区列表自己的滚动区里,整栏一起滚。
- 行就是工作区树的行:16px 状态格 + 标题 + 距今时间(10px 三级色),已置顶的会话带置顶标记;尺寸、间距、圆角、悬停与当前高亮全部取自外壳自己的会话行(32px /
0 8px/--dsw-radius-md/--dsw-alias-interactive-bg-hover)。 - 悬停即换出操作:指针停在行上(或该行菜单打开)时,时间与置顶标记让位给行操作——⋯ 菜单(置顶 / 分叉 / 归档)、归档、置顶,与工作区树的会话行同一套图标与间距(16px 按钮 / 10px 间隙)。
- 悬停浮层:外壳自己的会话卡片,元素与工作区树的会话卡片一致——完整标题、距今时间、运行状态行(进行中 / 等待审批 / 计划待审 / 等待回答),点卡片可复制标题;另加一行「文件夹图标 + 项目名」(落在外壳卡片给扩展内容留的位置上,状态行仍是最后一行),这样两个同名会话也能看出各自属于哪个项目。
- 归档仍问一句:会话还在干活时宿主会拒绝归档,此时弹出与外壳同款措辞的「停止并归档此会话?」,确认后先停后归档。
- 一致性:可见性规则与工作区树完全一致——归档会话不显示、子代理会话归其父会话目录、空白占位会话不显示(它没有历史)。
- 状态可见:等待你响应(审批/提问/计划确认)标黄点,运行中标动态点,当前会话整行高亮。
- 工作区折叠:工作区列表默认只展示 5 个文件夹,其余折在一行与外壳同款的折叠钮后面(「展开其余 N 个工作区」),每点一次多展开 10 个,铺完时文案变「收起」,再点回到前 5 个;最近区块不折叠。留下的是最近有记录的那几个:每个分组按各自最新会话的时间参与比较,未分组那一组和普通工作区一样排排坐,谁都不默认占位——只有旧聊天记录的文件夹,甚至一条记录都没有的文件夹,默认就不会出现在列表里。展开是临时状态,收起侧栏再展开回到折叠默认。
- 每个项目的会话折叠:每个项目下面默认只展示最新的 5 条对话——运行中、等待响应、空闲的都占名额。外壳自己只把空闲会话算进配额(运行中的会话永远多显示),所以同时开着好几个会话的项目会露出超过 5 条;这一层把窗口收紧到 5 条,动态会话也算数。折叠钮就是外壳自己那一行的位置与样式,文案同款(「展开其余 N 个会话」/「收起」),每点一次再展开 10 条,一次一页地长到铺完;铺完时文案变「收起」,再点回到前 5 条。正在读的那条会话永不隐藏;项目折叠再打开回到前 5 条(与外壳重置自己配额的行为一致)。
- 原生外观:使用
--dsw-*语义 token 与外壳自带的StateDot、细箭头图标,跟随明暗主题与品牌主题;折叠钮的尺寸、缩进、颜色与外壳自己的「展开其余 N 个会话」完全一致。 - 悬停浮层是外壳自己的原语:直接用
@deepseek-ai/dsh-client-ui-primitives的HoverCard(工作区树会话卡片同一组件、同一份 244px 深色卡面),浮层内文字沿用它的固定浅色值,明暗主题下都不反色。 - 不改外壳源码:最近区块注册到侧边栏既有的
sidebar.footer.action列表槽位;行操作走外壳公开的uiWorkspace服务;工作区列表的折叠、以及每个项目会话列表的折叠,在已发布的外壳里都没有对应槽位或服务,因此都由本插件在 DOM 层补上(见设计说明),不修改、也不需要重新构建 DSH 前端。
安装
在跑 GUI 的 profile 目录里(通常是 ~/.dsh/profiles/web),从 GitHub 装:
dsh plugin --profile web add github:ttmouse/dsh-recent
改本插件源码时用本地克隆(link: 会跟随你的改动):
dsh plugin --profile web add link:/path/to/dsh-recent
该命令会把依赖写进 profile 的 package.json,并自动把 dsh-recent 追加到 dsh.profile.bundles(也可以手工做这两步):
{
"dependencies": { "dsh-recent": "link:/path/to/dsh-recent" },
"dsh": { "profile": { "bundles": ["…", "dsh-recent"] } }
}
然后重启 dsh web 并刷新页面——bundle 列表在启动时读取,热重载只覆盖已加载插件的源码变更:
# 停掉当前的 dsh web,再重新启动
dsh web
开发
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 时才出现。折叠钮按渲染顺序插在最后一个保留组之后,落选的分组留在原位只是被隐藏,所以展开时顺序不变。窗口每点一次加 10 个(FOLD_STEP),加满就换成「收起」,再点回到前 5 个——和下面每个项目的会话折叠同一个步长。 - 折叠是临时状态:没有任何 store,窗口住在折叠层里(默认 5 个,每点一次 +10);侧栏收起再展开(
wide由 false 变 true)时工作区列表回到只显示 5 个、最近区块回到第一页,和 Codex 一致。 - 每个项目的会话折叠怎么做的:外壳的配额在 React 自己的 state 里(
sessionLimits),插件改不了,所以这层同样从外面补:把外壳那一行折叠钮隐藏,在它的位置插一行本插件自己的折叠钮(同样的 owner 属性写法,样式复用工作区折叠行),并按自己的窗口把超出的会话行置为display: none。窗口是每个项目一份的临时状态(默认 5,每次 +10),项目收起再打开就丢掉——外壳也在那一刻把自己的配额重置回 5。只有窗口要的行数超过外壳已经渲染的行数时,这一层才去点外壳那一行折叠钮:点几次是按外壳自己的页大小(5)算出来的(ceil((要的行数 − 已渲染) / 5)),不在两次点击之间读 DOM——React 可能延后一个任务才提交新行,盯着 DOM 会多点好几下。外壳还没渲染的那部分行数直接从它自己的文案里读(「展开其余 N 个会话」里的 N),所以本插件这一行的计数始终是「已渲染但被窗口藏起来的 + 外壳还没渲染的」,与外壳当前停在哪一页无关;读不到数字时按 0 计(宁可少报)。正在读的那条会话的行不隐藏:外壳在树里为它做过 reveal(把配额放开到全部),插件把它藏起来会让那次滚动落在看不见的行上。 - 区块就在列表的滚动区里:区块被 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>"与data-row-key="overflow:<工作区 key>"两个行键、分组标题行上的aria-expanded(判断项目是否打开)、以及外壳折叠钮文案里的那个数字(读它藏了多少行)。外壳若改掉其中任何一处,这一层只会退化而不会报错:读不到数字时计数偏小,读不到项目状态时窗口不再重置,行不会丢,最多回到外壳原本的显示。这一层还会以程序方式点外壳自己的折叠钮来请它多渲染几行,所以外壳若改掉自己那一行的行为(页大小、点开后是收起还是再展开),请行数的次数会跟着不准——但「显示多少条」始终由本插件的窗口决定。 - 一次一页:默认渲染最近 20 条,之后每次滚动追加 20 条;会话很多时要一路滚到底才能看到最老的,没有页码或跳转入口。
- 不做搜索、不能重命名:搜索仍在工作区树里;重命名也留在树里——外壳的重命名对话框由
ui-workspace的内部 store 驱动,插件拿不到入口,所以最近行的 ⋯ 菜单比外壳的会话菜单少一项「重命名」。要找很久以前的会话,搜索仍然比滚到底快。 - 标题为空时会显示会话 id:标题由宿主从日志投影;尚无标题的旧会话,行标题就是
displayTitle的回退值(通常是项目目录名或会话 id)。
No comments yet. Be the first to write one.