メインコンテンツへスキップ
T

dsh-recent

ttmouse/dsh-recent

Adds a 最近 (Recent) section below the workspace list in the DSH web sidebar that flattens the newest sessions of every workspace into one timeline, reusing the session rows and hover cards the shell already renders, paging in earlier sessions as you scroll, and folding the workspace list itself to the five folders with the newest history. Requires DSH ^0.2.0-rc.1.

インストール

dsh plugin --profile web add github:ttmouse/dsh-recent

README

dsh-recent

English | 简体中文

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 装:

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 时才出现。折叠钮按渲染顺序插在最后一个保留组之后,落选的分组留在原位只是被隐藏,所以展开时顺序不变。
  • 折叠是临时状态:没有任何 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

関連プラグイン