dsh-better-workspace 0.39.1 → 0.40.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.
package/README.md CHANGED
@@ -55,7 +55,7 @@
55
55
  <table>
56
56
  <tr>
57
57
  <td align="center" width="58%"><img src="docs/screenshots/5-context-menu.png" alt="右键菜单"/></td>
58
- <td valign="top"><b>右键菜单</b><br/>操作都收进右键:工作区行——重命名 / 删除 / 分叉 / 归档 / 自定义外观 / <b>修改工作区默认预设</b>(v0.32.0 起,改写该工作区常驻新会话所用的 agent 预设,走官方同一通道;内置预设显示官方本地化名——标准模式 / PTC 模式 / 极简模式 / 创造模式,v0.33.0 起;工作区暂无待命新会话时,确认后会预备一个带所选预设的新会话作为该工作区默认);工作区分组行——重命名分组(同步更新组内所有工作区名称)/ 自定义外观;会话与会话分组行——重命名 / 分叉 / 归档 / 自定义外观;会话行另有<b>复制 Session ID</b>(v0.30.0 起,对齐 dsh 0.2.1-alpha.5 官方会话菜单第五项——写剪贴板走宿主原语,复制成功与否均有提示)。<br/><b>v0.38.0 起接入官方统一右键菜单</b>:会话/工作区行由宿主共享菜单渲染,官方条目与插件补充项(移动到分组 / 移出分组 / 自定义外观 / 修改默认预设,排在分隔线之后)同席,官方新增条目会自动出现;分组行仍用插件菜单。</td>
58
+ <td valign="top"><b>右键菜单</b><br/>操作都收进右键:工作区行——重命名 / 在文件夹显示 / 删除工作区 / <b>筛选会话</b>(归档显示四态,v0.40.0 起) / 自定义外观 / <b>修改工作区默认预设</b>(v0.32.0 起,改写该工作区常驻新会话所用的 agent 预设,走官方同一通道;内置预设显示官方本地化名——标准模式 / PTC 模式 / 极简模式 / 创造模式,v0.33.0 起;工作区暂无待命新会话时,确认后会预备一个带所选预设的新会话作为该工作区默认);工作区分组行——重命名分组(同步更新组内所有工作区名称)/ 自定义外观;会话与会话分组行——重命名 / 分叉 / 归档 / 自定义外观;会话行另有<b>复制 Session ID</b>(v0.30.0 起,对齐 dsh 0.2.1-alpha.5 官方会话菜单第五项——写剪贴板走宿主原语,复制成功与否均有提示)。<br/><b>v0.38.0 起接入官方统一右键菜单</b>:会话/工作区行由宿主共享菜单渲染,官方条目与插件补充项(移动到分组 / 移出分组 / 自定义外观 / 修改默认预设,排在分隔线之后)同席,官方新增条目会自动出现;分组行仍用插件菜单。<br/><b>v0.40.0 起工作区右键新增「筛选会话」子菜单</b>:四个显示状态——跟随全局(默认)/ 隐藏已归档 / 全部对话(显示已归档)/ 仅显示已归档;选择只改该工作区,不写全局筛选,其他工作区照旧。</td>
59
59
  </tr>
60
60
  </table>
61
61
 
@@ -72,8 +72,8 @@
72
72
  - **拖拽还原原版能力**:工作区行可拖拽——拖到另一工作区行上/下半即调整顺序(写回宿主 registry 序),拖到分组行中带则移动进该分组(改写名称前缀);**工作区分组行同样可拖拽(v0.36.0 起,三带悬停)**——拖到分组行时行分三个落区:上下 **30% 边带**=与该组**排序**(拖完立即重排、刷新后保留),**中带**=整组移入(组成员逐个改写名称前缀,与「重命名分组」同一语义;拖到自身/自己的子孙组上会被拒绝);**组行与工作区行同处一条混合序列(v0.36.5 起)**:一个层级里的行顺序只有一份(组行与裸工作区行交错),工作区可以拖到分组行的边带插到该组前面/后面,分组也可以拖到工作区行前面/后面——不再有「组永远排在裸工作区之前」的限制;拖到**另一层**的边带是**放置**(v0.36.6):工作区离开原组(或进入目标层)并插到落点,「插到第一个」画哪落哪(跨磁盘层不放行——磁盘层是文件系统事实);**拖入黑框统一(v0.36.7)**:工作区行拖到分组行中带 = 改名前缀进组(黑框反馈);组行拖到**它所属的磁盘层工作区行 / 直接父级分组行**中带 = 整组「放入」该容器、排到该层末尾(标题与磁盘位置都不动,同一套黑框视觉)——排序走边带、放入走中带,两类操作视觉上彻底分开;**容器防环(v0.36.9)**:磁盘层的容器工作区不能被拖进它自己的名称组或子工作区——那会把容器改名成自己的子孙(如「工作/用户目录」甚至「<id>/用户目录」),现在整行拒绝、不留任何落点反馈,排序与放入父容器(v0.36.7)不受影响;**平级组嵌套与提升(v0.36.10)**:组行拖到平级组行的**中带**=变成它的**子组**(成员标题落到新组之下,`工作/平行帮` → `工作/Fis管理后台/平行帮`,天然不撞名——旧的并入语义会在双方都有同名子工作区时报 name-conflict、整树不动);组行拖到**另一层工作区行的边带**(同磁盘、不在自己子树内)=**整组提升**到那行所在层并锚定在落点旁(「把平行帮拖到和用户目录、工作平级」);已打开的排序槽不再锁死判定——指针推回行**中带**即可翻成放入黑框,嵌套与排序随时互切;**静止松手兜底**:Chromium 把指针静止约一秒后的松手吞成 dragend 时,按最后悬停的落点补提交(Esc 取消不受影响),不再「松手没反应」;**撑开让位(v0.36.4)**:进入排序边带立刻把目标行撑开一行(工作区行与分组行同一套视觉),判定按**行未撑开时的原始位置**算——指针停在槽空出的新区里保持当前方向不闪烁;槽已打开的行只按原始中线二选一(上半=插在前,下半=插在后,**不会翻成「移入」**),三带里的中带 INTO 只在首次进入时决定,随时可改主意;会话行可拖拽——同组内拖到另一会话上/下即调整顺序,拖到**别组的会话行**会把名字前缀改写到那个组(拖到根层的行 = 移出分组),拖到会话子分组行即移动进该分组,拖到**自己的工作区行**或**树里任何组外的地方**(含空白区域)同样移出分组 —— 拖拽期间工作区行整行高亮、树底部有一条淡虚线提示这个出口;带分组前缀的会话行右键还多一项「移出分组」。显示顺序 = 宿主手动序(分组行 = 父容器下的持久化组序),拖拽结果立即生效。**磁盘层是文件系统事实,不随拖拽改变**:跨磁盘层不放行(边带不接管光标、不画落点线),同磁盘层内按落点锚定。拖拽期间树形保持不变(v0.36.4):单链合并行不再临时展开、归组成功也不自动展开目标组——指针下的行不会突然换人,展开状态只在点击时改变。
73
73
  - **不丢原生能力**:会话行(打开/重命名/分叉/归档;**双击标题**重命名,v0.34.0 起;**右键是唯一菜单入口**——行内「⋯」已退役,悬停只留快捷钮,工作区行同样只留新会话钮,v0.35.1 起)、**官方行样式逐项对齐**(v0.34.0 起:恒定 16px 状态槽——运行转圈不再挤动标题、悬停 800ms 弹官方 HoverCard(标题/路径/时间/状态,可复制)、超长标题悬停匀速滚动、活动链工作区文件夹图标着蓝、拖放指示用官方斜纹图案)、每工作区「新会话」(点 + 总是先展开该工作区,让新会话立刻可见)、当前会话高亮、官方状态灯(蓝色运行环 / 琥珀待交互 / 绿色完成提醒)、**状态呼吸灯**(折叠藏起的状态灯向外冒泡:工作区/分组行图标按状态色呼吸发光——琥珀待交互/绿完成,自定义过发光的标题一起呼吸;运行中只在会话行本层蓝色呼吸,不外渗;可在设置关闭)、**活跃定时任务角标**(有活动 Schedule 的会话显示闹钟图标;v0.23.0 起直接渲染官方 `sidebar.session.row.leading` 座位的占用者,与官方行同源)、未分组会话兜底、界面文案跟随 DSH 的语言设置(内置 21 种语言,含简繁中文)。
74
74
  - **新增项自动配色 + 图标(v0.25.0,默认关闭)**:设置卡里一个开关,打开后**新创建的会话**自动分到一个内置色、**新工作区**再额外分到一个图标,懒得逐个设置的人直接开箱即用。颜色来自**定死的 13 个槽位**,每个槽位配**两色**——浅色主题用深色系、深色主题用亮色系——按「洗牌袋 + 近期回避窗口」抽取:一轮之内绝不重复,跨轮也不会立刻撞上刚出过的 3 个,连续新建几项肉眼可辨。**可读性是真达标**:每个色在**对应主题**背景下的对比度都 **≥4.5**,即 WCAG **正文级 AA**(对比度是脚本真算的,测试逐色断言;浅色主题按比纯白更暗的悬停面、深色主题按最亮的面这种保守取值)。行里存的是**槽位 id(`auto:3`)而不是色值**,所以**切换深浅主题时颜色自动换成同色系的另一套**,既不用重新设置,也不会失配。**只影响打开开关之后新建的项**:你手动设置过外观的行(存的是 hex)永不被覆盖,关闭开关也不回收已分配的样式,同一项不会被二次随机。图标从既有图标候选集里取、按宿主实际导出过滤;**图标只给工作区**——会话行的那个位置是官方状态灯与日程角标,没有图标位。
75
- - **官方新语义与快捷键都接上(v0.23.0,对齐 dsh 0.1.7-rc.2)**:① 归档筛选是官方新版三态直选(**隐藏已归档** / **全部对话(显示已归档)** / **仅显示已归档**);选「仅显示已归档」时**没有归档会话的工作区整行隐藏**,空列表显示「暂无已归档会话」+「查看其他会话」一键回退;② 官方快捷键 **⌥⌘K 搜索 / ⌥⌘O 添加工作区 / ⌥⌘N 新会话**(桌面端为 ⌘K / ⌘O / ⌘N)在本插件的自绘侧栏里**真的生效**——搜索键打开并聚焦本插件的搜索框、添加键走同一个官方目录流(原生宿主弹系统选择器,browse 宿主直接弹出官方应用内目录浏览器),按钮文案还会带上宿主当前的键位;③ 首次使用的工作区显示为本地化的「默认工作区」,不再露出磁盘目录名。
76
- - **URL/斜杠标题不拆层**:宿主自动生成的会话标题落地时若含 `/`(如复述了 URL),整串自动包上 `“”` 原样显示,绝不解析成层级——只对本次启动后**新建的会话**生效,并等命名稳定(约 20 秒,让原生 AI 命名先落地)才处理;存量会话与你手动命名的 `/` 分组标题永不被改动。引号内(含手动添加)的 `/` 永不切分,孤立引号只当普通字符;无引号但含 URL 的旧标题也有平铺兜底。**v0.37.2 起再加一道路径保护(issue #17)**:标题里引用绝对路径(`/Users/...`、`C:/...` 这类含至少两个 `/` 的路径 token)时,整条标题作为一个会话行显示,绝不拆成伪目录——存量会话与首条消息回显的标题同样生效,`web/前端` 这类正常的名称分组不受影响。**v0.37.3 起路径标题不再被 20 秒稳定期改成带引号的用户标题**:显示层已经平铺,原逻辑反而会把回显路径钉死并取消尚未落地的 AI 命名;现在直接放过,等 AI 总结名替换。会话分组仍默认开启,不需要的话可在视图菜单关掉。
75
+ - **官方新语义与快捷键都接上(v0.23.0,对齐 dsh 0.1.7-rc.2)**:① 归档筛选是官方新版三态直选(**隐藏已归档** / **全部对话(显示已归档)** / **仅显示已归档**);选「仅显示已归档」时**没有归档会话的工作区整行隐藏**,空列表显示「暂无已归档会话」+「查看其他会话」一键回退;② 官方快捷键 **⌥⌘K 搜索 / ⌥⌘O 添加工作区 / ⌥⌘N 新会话**(桌面端为 ⌘K / ⌘O / ⌘N)在本插件的自绘侧栏里**真的生效**——搜索键打开并聚焦本插件的搜索框、添加键走同一个官方目录流(原生宿主弹系统选择器,browse 宿主直接弹出官方应用内目录浏览器),按钮文案还会带上宿主当前的键位;③ 首次使用的工作区显示为本地化的「默认工作区」,不再露出磁盘目录名。④ **v0.40.0 起工作区右键「筛选会话」可单独覆盖这三态**(跟随全局 / 隐藏已归档 / 全部对话 / 仅显示已归档):默认跟随全局,覆盖只影响该工作区,全局筛选和其他工作区都不动;「仅显示已归档」下没有归档会话的工作区若自己选了该态,仍保留空行,方便从同一个右键菜单改回。
76
+ - **自动标题永不拆层,用户分组才拆(v0.39.2,issue #19)**:宿主为每个标题记录了来源(`session/title` 的 `source.kind`)。本插件把来源经摘要投影 `betterWorkspace/titleSource` 带到侧栏:**`fallback` / `provider` 的自动标题整条平铺,标题里有多少 `/` 都不建组**(不再自动包 `“”`,也不等 20 秒改名);只有你**显式重命名、拖入分组或改写分组前缀**(来源变为 `user`)之后,`/` 才作为层级。这样新会话一生成就是一行,不会因为回显首条消息或路径被拆成伪目录。来源缺失时(旧冷缓存、投影未注册)仍保留显示层兜底:引用绝对路径或 `~` / `./` / `../` 路径的标题整条平铺;成对引号保护其中的 `/`;没有斜杠的引号段只是普通文字。旧会话不会丢,也不需要迁移数据。会话分组仍默认开启,不需要的话可在视图菜单关掉。
77
77
  - **新会话页的工作区选择器 = 侧栏那棵树(v0.22.0)**:会话空态页「工作区」按钮的弹层原本是官方的一层平铺标题列表(连磁盘目录嵌套都没有);本插件接管该座位后,弹层绘制与侧栏**同一棵**树——磁盘目录嵌套、「/」名称分组三态、单链合并全部读同一个视图 store,所以侧栏的视图选项一改,**这边立刻跟着变**;当前工作区带勾选标记,分组行可开合(选择器内本地记忆,默认全展开),标题按侧栏的层级叶子显示。底部「添加工作区」仍由**官方目录拾取占用者**承载(原生选择器 / 官方应用内浏览器),不是仿制品。
78
78
 
79
79
  <table>
package/README_EN.md CHANGED
@@ -55,7 +55,7 @@ Session titles nest on `/` too (e.g. `test1/plugin maintenance`). Session groups
55
55
  <table>
56
56
  <tr>
57
57
  <td align="center" width="58%"><img src="docs/screenshots/5-context-menu.png" alt="Context menu"/></td>
58
- <td valign="top"><b>Context menu everywhere</b><br/>Actions live in the right-click menu: workspace rows — rename / delete / fork / archive / customize; folder rows — rename group (updates all descendant workspaces) / customize; session &amp; session-group rows — rename / fork / archive / customize; session rows also get <b>Copy Session ID</b> (v0.30.0, mirroring dsh 0.2.1-alpha.5's fifth official menu entry — the clipboard write rides the host primitive, with a notice either way).<br/><b>Since v0.38.0 the shared host context menu owns session/workspace rows</b>: official entries and this plugin's extras (move to group / move out / customize / default preset, after a separator) share one popup, and future official entries appear automatically; group rows keep the plugin menu.</td>
58
+ <td valign="top"><b>Context menu everywhere</b><br/>Actions live in the right-click menu: workspace rows — rename / show in folder / delete workspace / <b>filter sessions</b> (archived visibility, since v0.40.0) / customize; folder rows — rename group (updates all descendant workspaces) / customize; session &amp; session-group rows — rename / fork / archive / customize; session rows also get <b>Copy Session ID</b> (v0.30.0, mirroring dsh 0.2.1-alpha.5's fifth official menu entry — the clipboard write rides the host primitive, with a notice either way).<br/><b>Since v0.38.0 the shared host context menu owns session/workspace rows</b>: official entries and this plugin's extras (move to group / move out / customize / default preset, after a separator) share one popup, and future official entries appear automatically; group rows keep the plugin menu.<br/><b>Since v0.40.0 workspace rows add a “Filter sessions” submenu</b>: Follow global (default) / Hide archived / All conversations (show archived) / Archived only. The pick changes only that workspace, never the global filter, and every other workspace stays as it is.</td>
59
59
  </tr>
60
60
  </table>
61
61
 
@@ -72,8 +72,8 @@ Session titles nest on `/` too (e.g. `test1/plugin maintenance`). Session groups
72
72
  - **Native drag restored**: drag a workspace row above/below another to reorder (writes back the host registry order) or onto a folder row to move it into that group (rewrites the title prefix); drag session rows to reorder inside their group, onto a row of ANOTHER group to move into that group (onto a root-level row to leave the group), onto a session-group row to move into it, or onto their OWN workspace row / anywhere outside a group (empty space included) to leave it — the workspace row highlights and the tree shows a faint dashed hint while such a drag is in flight; a grouped session row also offers "Move out of group" in its context menu. Display order = host manual order, drag results are visible immediately. **The disk layer is a filesystem fact and never changes through dragging**: a cross-level drop only re-groups the name prefix (the workspace lands at the end of its own disk level), same-level drops take the anchor order. While dragging a workspace, merged single-chain rows (e.g. work/A) temporarily re-expand into folder rows — every level of the path becomes a drop target, and chains merge back when the drag ends.
73
73
  - **Native capabilities kept**: open/rename/fork/archive sessions, per-workspace new-session (the + button always expands the workspace first so the new session is immediately visible), current-session highlight, official status dots (blue running ring / amber pending / green done), **status breathing lights** (dots hidden by collapse bubble outward: workspace/folder rows breathe on their icon in the status color — amber pending / green done, custom-glow labels breathe along; running breathes blue only on the session row itself and never relays; off-able in settings), **active-schedule badge** (alarm clock icon for sessions with active Schedule records; since v0.23.0 it renders the occupant of the shipped `sidebar.session.row.leading` seat, so it is the same mark the official row would show), ungrouped-session fallback, UI copy in 21 languages following the DSH language preference (Simplified and Traditional Chinese included).
74
74
  - **Auto color + icon for new items (v0.25.0, off by default)**: one switch in the plugin settings card. Turn it on and every newly created session gets a built-in color while every new workspace additionally gets an icon — for anyone who would rather not style rows one by one. Colors come from **13 fixed slots**, each shipping **two colors** — a darker one for the light theme, a lighter one for the dark theme — drawn with a shuffle bag plus a recent-draw window: nothing repeats inside a cycle, a refill never starts with the last three colors, and items added in a row look clearly different. **The readability claim is real**: every entry keeps contrast **≥ 4.5** on its own theme's background, i.e. WCAG **normal-text AA** (computed by script and asserted per color in the test suite, measured against conservative surfaces: a hovered row darker than pure white in the light theme, the brightest surface in the dark theme). A row stores the **slot id (`auto:3`), not a hex**, so **switching between light and dark repaints it with the matching sibling color** — nothing to reconfigure and no mismatch. **Only items created after you turn it on are touched**: rows you styled by hand (stored as hex) are never overwritten, turning it off does not undo assignments, and an item is never randomized twice. Icons come from the existing icon candidate set, filtered by what the host actually exports — and **workspaces only**: the session row's cell belongs to the official status dot and schedule mark, so it has no icon slot.
75
- - **Official semantics and shortcuts wired up (v0.23.0, aligned with dsh 0.1.7-rc.2)**: ① the archived filter is the shipped three-way pick (**Hide archived** / **All conversations (show archived)** / **Archived only**); under "Archived only" a workspace without archived sessions disappears entirely, and an empty list offers "No archived sessions yet" + **View other sessions** as the way back. ② The shipped shortcuts **⌥⌘K search / ⌥⌘O add workspace / ⌥⌘N new session** (⌘K / ⌘O / ⌘N on desktop) really work in this plugin's own sidebar — the search key opens and focuses this sidebar's filter field, the add key runs the very same official directory flow (native chooser on a native host, the in-app pointer on a browse host), and the buttons show the host's current keybindings. ③ A first-use workspace reads as the localized "Default workspace" instead of the on-disk directory name.
76
- - **Slash-bearing titles never nest from URLs**: when a host-generated session title lands containing `/` (e.g. echoing a pasted URL), it is auto-wrapped in `“”` and shown verbatim — only for sessions **created after this launch**, and only once the name stays stable for ~20s (letting the native AI naming land first); pre-existing sessions and your manually named `/` groupings are never touched. Quotes (manual ones too) never split on `/`, and a lone quote is just an ordinary character; unquoted URL tails still fall back flat. **Since v0.37.2 there is also a path guard (issue #17)**: a title quoting an absolute path (`/Users/...`, `C:/...` — any token with at least two `/`s) is shown as ONE session row and never shredded into fake folders, including pre-existing sessions and auto-titles echoing the first message; normal name groups like `web/frontend` are unaffected. **Since v0.37.3 a path-shaped title is no longer rewritten into a quoted user title after the 20s window**: the display guard already flattens it, while that rename pinned the echoed title and cancelled an LLM name that had not landed yet — the effect now leaves it to the provider. Session grouping stays on by default; turn it off in the view menu if you don't want it.
75
+ - **Official semantics and shortcuts wired up (v0.23.0, aligned with dsh 0.1.7-rc.2)**: ① the archived filter is the shipped three-way pick (**Hide archived** / **All conversations (show archived)** / **Archived only**); under "Archived only" a workspace without archived sessions disappears entirely, and an empty list offers "No archived sessions yet" + **View other sessions** as the way back. ② The shipped shortcuts **⌥⌘K search / ⌥⌘O add workspace / ⌥⌘N new session** (⌘K / ⌘O / ⌘N on desktop) really work in this plugin's own sidebar — the search key opens and focuses this sidebar's filter field, the add key runs the very same official directory flow (native chooser on a native host, the in-app pointer on a browse host), and the buttons show the host's current keybindings. ③ A first-use workspace reads as the localized "Default workspace" instead of the on-disk directory name. ④ **Since v0.40.0 a workspace's right-click “Filter sessions” submenu can override those three states per workspace** (Follow global / Hide archived / All conversations / Archived only): the default follows the global filter, the override touches only that workspace (neither the global pick nor other workspaces move), and a workspace that chose “Archived only” while holding no archived sessions keeps its empty row so the same menu can switch it back.
76
+ - **Automatic titles never nest; only your explicit grouping does (v0.39.2, issue #19)**: the host records where each title came from (`source.kind` on `session/title`). This plugin carries that provenance to the sidebar through the `betterWorkspace/titleSource` summary projection: a **`fallback` / `provider` auto-title is always ONE row, no matter how many `/` it contains** (no synthetic `“”` wrap and no 20-second rename). A `/` becomes a hierarchy only after you **rename the session, drag it into a group, or rewrite its group prefix** — the source then becomes `user`. A fresh session therefore stays a single row from birth, even when its echoed first message or a path used to shred it into fake folders. When the projection is missing (old cold cache, registration unavailable) the display keeps its text fallback: titles quoting an absolute path or a `~` / `./` / `../` path stay flat; paired quotes protect a `/` inside them; a quoted span with no slash is ordinary text. No session data is migrated or lost. Session grouping stays on by default; turn it off in the view menu if you don't want it.
77
77
  - **The New Session workspace picker IS the sidebar tree (v0.22.0)**: the empty-state "Workspace" chip used to open the shipped one-level list of flat titles (no disk nesting at all). This plugin shadows that seat (lowest priority renders) and draws the **same** tree the sidebar draws — disk nesting, the tri-state "/" name layer, chain compression — all from the one view store, so changing a view option on the sidebar moves this popover immediately. The current workspace carries a check, folder rows fold (picker-local, fully expanded by default), and the pinned "Add workspace" action still renders the **shipped directory-picking occupant** (native chooser / official in-app browser) instead of a copy.
78
78
 
79
79
  ## Install
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-better-workspace",
3
- "version": "0.39.1",
3
+ "version": "0.40.0",
4
4
  "main": "./src/index.js",
5
5
  "description": "侧边栏工作区树:按官方磁盘目录嵌套(官方 workspace-tree 语义),可选按名称中的 / 分组(默认关闭,设置里开启);新会话页的工作区选择器同树。Workspace tree for the DSH sidebar workspace list, nested by official disk folders with optional \"/\"-name grouping; the New Session workspace picker shows the same tree.",
6
6
  "engines": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-better-workspace",
3
- "version": "0.39.1",
3
+ "version": "0.40.0",
4
4
  "description": "侧边栏工作区树:按官方磁盘目录嵌套(官方 workspace-tree 语义),可选按名称中的 / 分组(默认关闭,设置里开启);新会话页的工作区选择器同树(同一份视图选项即时同步);添加工作区走官方目录流。Workspace tree for the DSH sidebar: disk-path nesting (official workspace-tree semantics), with optional \"/\"-name grouping (off by default), and the same tree in the New Session workspace picker; add-workspace rides the official directory flow.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -42,6 +42,7 @@
42
42
  "src/client.js",
43
43
  "src/client.locales.js",
44
44
  "src/client.styles.js",
45
+ "src/title-source.js",
45
46
  "cordis.patch.yml",
46
47
  "dsh.plugin.json",
47
48
  "README.md",
@@ -66,7 +67,8 @@
66
67
  "peerDependencies": {
67
68
  "@deepseek-ai/cordis": "^4.0.1",
68
69
  "@deepseek-ai/dsh": ">=0.2.2-rc.1",
69
- "@deepseek-ai/schemastery": "^3.19.0"
70
+ "@deepseek-ai/schemastery": "^3.19.0",
71
+ "zod": "^4.4.3"
70
72
  },
71
73
  "peerDependenciesMeta": {
72
74
  "@deepseek-ai/dsh": {
@@ -79,6 +81,7 @@
79
81
  },
80
82
  "icon": "./icon.svg",
81
83
  "devDependencies": {
82
- "@deepseek-ai/schemastery": "^3.19.0"
84
+ "@deepseek-ai/schemastery": "^3.19.0",
85
+ "zod": "^4.4.3"
83
86
  }
84
87
  }
package/src/client.js CHANGED
@@ -47,14 +47,28 @@ window.__ModuleLoader__.load({
47
47
  const i = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'))
48
48
  return i === -1 ? s : s.slice(i + 1)
49
49
  }
50
+
51
+ // Host-recorded title provenance (src/title-source.js). A fresh session's
52
+ // fallback/provider title never becomes a hierarchy; only a user rename or
53
+ // an explicit grouping action may form groups. Undefined means the host
54
+ // projection is not there (old cold cache), so the display falls back to
55
+ // the text heuristics below.
56
+ const TITLE_SOURCE_KEY = 'betterWorkspace/titleSource'
57
+ const titleSourceOf = (summary) => {
58
+ const values = summary && summary.projectionValues
59
+ return values && typeof values === 'object' ? values[TITLE_SOURCE_KEY] : undefined
60
+ }
61
+ const groupsAllowedFor = (summary) => {
62
+ const source = titleSourceOf(summary)
63
+ return source === undefined || source === 'user'
64
+ }
50
65
  /**
51
66
  * Plain-text splitting with the URL tail kept opaque: a bare "/" split
52
67
  * shreds URLs ("https:" → empty → host → path...), so from the first
53
68
  * "://" onward the tail is ONE leaf; text before the marker splits
54
69
  * normally at the last "/" before it. This is the FALLBACK guard —
55
- * freshly generated titles get their whole "/"-bearing title wrapped in
56
- * quotes by the quote-on-land effect (see BetterBrowser), which is the
57
- * primary mechanism.
70
+ * whether a session title is allowed to form groups at all is decided by
71
+ * its host-recorded provenance (see TITLE_SOURCE_KEY), not by the text.
58
72
  */
59
73
  const splitPlainSegs = (text) => {
60
74
  const s = String(text || '')
@@ -79,12 +93,18 @@ window.__ModuleLoader__.load({
79
93
  const titleQuotesPath = (text) => {
80
94
  // A path inside a PAIRED quote span is already an opaque leaf (the
81
95
  // quote parser below keeps it verbatim), so only the unquoted remainder
82
- // can be mistaken for a name hierarchy.
96
+ // can be mistaken for a name hierarchy. "~/.dsh/..." and "./..." are
97
+ // paths even when they carry only one slash; drive-letter paths are
98
+ // paths by construction. A bare "https://" is not matched (it does not
99
+ // start with a slash).
83
100
  const s = String(text || '').replace(/"[^"]*"|“[^”]*”/g, ' ')
84
- const re = /(?:^|\s)(?:[A-Za-z]:)?\/\S+/g
101
+ const re = /(?:^|\s)(?:(?:[A-Za-z]:)?\/\S+|(?:~|\.{1,2})\/\S+)/g
85
102
  let m
86
103
  while ((m = re.exec(s)) !== null) {
87
- if ((m[0].match(/\//g) || []).length >= 2) return true
104
+ const token = m[0].trim()
105
+ if (/^[A-Za-z]:[\\/]/.test(token)) return true
106
+ if (/^(?:~|\.{1,2})\//.test(token)) return true
107
+ if ((token.match(/\//g) || []).length >= 2) return true
88
108
  }
89
109
  return false
90
110
  }
@@ -100,8 +120,9 @@ window.__ModuleLoader__.load({
100
120
  * Grouping is a pure projection, so previously shredded titles re-flow
101
121
  * on reload.
102
122
  */
103
- const splitTitleSegs = (title) => {
123
+ const splitTitleSegs = (title, allowGroups = true) => {
104
124
  const s = String(title || '')
125
+ if (allowGroups === false) return s.trim() === '' ? [] : [s.trim()]
105
126
  if (titleQuotesPath(s)) return [s.trim()]
106
127
  if (!/["“]/.test(s)) return splitPlainSegs(s)
107
128
  const out = []
@@ -117,11 +138,19 @@ window.__ModuleLoader__.load({
117
138
  i += 1
118
139
  continue
119
140
  }
120
- for (const seg of splitPlainSegs(plain)) out.push(seg)
121
- plain = ''
122
141
  const end = j + 1
123
142
  const quoted = s.slice(i, end).trim()
124
- if (quoted !== '') out.push(quoted)
143
+ // Quotes protect a slash INSIDE a leaf; they do not manufacture a
144
+ // group out of ordinary punctuation. "受阻" in a sentence stays
145
+ // part of the surrounding leaf (issue #19), so the pending plain
146
+ // text is only flushed when the quoted span itself carries a slash.
147
+ if (quoted !== '' && quoted.includes('/')) {
148
+ for (const seg of splitPlainSegs(plain)) out.push(seg)
149
+ plain = ''
150
+ out.push(quoted)
151
+ } else {
152
+ plain += quoted
153
+ }
125
154
  i = end
126
155
  continue
127
156
  }
@@ -645,7 +674,7 @@ window.__ModuleLoader__.load({
645
674
  }
646
675
  for (const segs of explicitSegsOf(explicitSess, groupScope)) ensure(segs)
647
676
  for (const row of rows || []) {
648
- const segs = splitTitleSegs(row.title)
677
+ const segs = splitTitleSegs(row.title, row.allowGroups !== false)
649
678
  const folderPath = segs.slice(0, -1).join('/')
650
679
  const leaf = segs.length > 0 ? segs[segs.length - 1] : row.title
651
680
  ensure(segs.slice(0, -1)).sessions.push({ ...row, leaf })
@@ -1080,7 +1109,7 @@ window.__ModuleLoader__.load({
1080
1109
  // LITERAL, not a spread of DEFAULT_APPEARANCE: init() is eval-harness
1081
1110
  // friendly (no cross-section reference), and the smoke suite asserts the
1082
1111
  // two stay in lockstep field by field.
1083
- init: () => ({ expanded: {}, sessionsExpanded: {}, sessionGroups: {}, sessionOrder: {}, prefs: { compactChains: true, sessionLimit: 0, appearance: { color: '', glow: 0, weight: 0, shadow: false, stroke: false, strokeWidth: 1, strokeColor: '#808080' } }, styling: {}, groupBy: 'workspace-tree', orderBy: 'manual', archivedFilter: 'default', workspaceGroupMode: 'disk', sessionTitleSlash: true, workspaceTitleSlash: false, ungroupedOpen: true, folders: { ws: {}, sess: {} }, folderOrder: {} }),
1112
+ init: () => ({ expanded: {}, sessionsExpanded: {}, sessionGroups: {}, sessionOrder: {}, prefs: { compactChains: true, sessionLimit: 0, appearance: { color: '', glow: 0, weight: 0, shadow: false, stroke: false, strokeWidth: 1, strokeColor: '#808080' } }, styling: {}, groupBy: 'workspace-tree', orderBy: 'manual', archivedFilter: 'default', workspaceArchivedFilters: {}, workspaceGroupMode: 'disk', sessionTitleSlash: true, workspaceTitleSlash: false, ungroupedOpen: true, folders: { ws: {}, sess: {} }, folderOrder: {} }),
1084
1113
  // NOTE: hydration REPLACES the state with the persisted whole value —
1085
1114
  // init defaults never merge. Every action must tolerate a missing key
1086
1115
  // (states persisted by older plugin versions lack sessionGroups), and
@@ -1108,6 +1137,15 @@ window.__ModuleLoader__.load({
1108
1137
  setGroupBy: (d, value) => { d.groupBy = value },
1109
1138
  setOrderBy: (d, value) => { d.orderBy = value },
1110
1139
  setArchivedFilter: (d, value) => { d.archivedFilter = value === 'show' || value === 'only' ? value : 'default' },
1140
+ // Per-workspace override of the archived visibility filter (v0.40.0).
1141
+ // Absent means "follow the global archivedFilter"; a stored value is
1142
+ // one of the same three literals, so sessionsOf needs no translation
1143
+ // layer. null deletes the override and restores the follow state.
1144
+ setWorkspaceArchivedFilter: (d, workspaceId, value) => {
1145
+ if (!d.workspaceArchivedFilters || typeof d.workspaceArchivedFilters !== 'object') d.workspaceArchivedFilters = {}
1146
+ if (value === 'default' || value === 'show' || value === 'only') d.workspaceArchivedFilters[workspaceId] = value
1147
+ else delete d.workspaceArchivedFilters[workspaceId]
1148
+ },
1111
1149
  setSessionTitleSlash: (d, value) => { d.sessionTitleSlash = value !== false },
1112
1150
  setWorkspaceTitleSlash: (d, value) => { d.workspaceTitleSlash = value !== false },
1113
1151
  // v0.21.0 tri-state grouping (issue #7). The legacy boolean rides
@@ -3695,6 +3733,19 @@ window.__ModuleLoader__.load({
3695
3733
  // Archived-row visibility (v0.20.0, official ArchivedFilter mirror);
3696
3734
  // persisted snapshots from older plugin versions lack the key.
3697
3735
  const archivedFilter = useStore ? (useStore(s => s.archivedFilter) || 'default') : 'default'
3736
+ // Per-workspace archived-visibility overrides (v0.40.0): hydrated
3737
+ // snapshots predate the key, so every read takes the empty-object
3738
+ // fallback and an unknown value means "follow the global filter".
3739
+ const workspaceArchivedFilters = useStore ? (useStore(s => s.workspaceArchivedFilters) || {}) : {}
3740
+ const workspaceArchivedFilterOf = (workspaceId) => {
3741
+ if (!workspaceArchivedFilters || typeof workspaceArchivedFilters !== 'object') return null
3742
+ const value = workspaceArchivedFilters[workspaceId]
3743
+ return value === 'default' || value === 'show' || value === 'only' ? value : null
3744
+ }
3745
+ const effectiveArchivedFilterOf = (workspace) => {
3746
+ const override = workspace ? workspaceArchivedFilterOf(workspace.workspaceId) : null
3747
+ return override || archivedFilter
3748
+ }
3698
3749
  const archivedSet = React.useMemo(() => new Set(archivedSessionIds), [archivedSessionIds])
3699
3750
  const pinnedSet = React.useMemo(() => new Set(pinnedSessionIds), [pinnedSessionIds])
3700
3751
  const subCounts = React.useMemo(
@@ -4009,42 +4060,13 @@ window.__ModuleLoader__.load({
4009
4060
  // across a group row showed the thin insert line and never the empty
4010
4061
  // slot — the user report "还是一条蓝线,没拨开让我插入". The edge bands
4011
4062
  // are 30% wide and geometrically judged, so they can answer at once.
4012
- // Quote-on-land eligibility sets (mount-scoped):
4013
- // - blankSeen: ids observed BLANK in any snapshot. A blank row is a
4014
- // freshly created New Session, so ONLY these may ever receive an
4015
- // automatic quote. A titled row missing from blankSeen — rows that
4016
- // stream into the store after mount, fork children (born titled),
4017
- // rows hidden by load-time filtering — predates this mount or was
4018
- // named deliberately, and is NEVER touched. (0.9.1 keyed "fresh" off
4019
- // the first snapshot instead, so late-arriving OLD sessions were
4020
- // misquoted; blank is the only trustworthy birth mark.)
4021
- // - humanTouched: ids renamed through THIS component's user actions
4022
- // (rename dialog, drag into a group, group prefix rewrite). A
4023
- // user-written "/" is a deliberate grouping; a user-written quote
4024
- // pair is the verbatim escape. The host pins user titles (a later
4025
- // automatic name is superseded), so the mark is final.
4026
- // - autoStable: id → { title, since } for freshly landed automatic
4027
- // titles waiting out the stabilization window below.
4028
- const blankSeenRef = React.useRef(null)
4029
- const humanTouchedRef = React.useRef(null)
4030
- const autoStableRef = React.useRef(null)
4031
- const stableTimerRef = React.useRef(null)
4032
- const [stableTick, setStableTick] = React.useState(0)
4033
- // The wire cannot tell the deterministic fallback title (first user
4034
- // message echo, "/"-prone) from the async LLM name that replaces it
4035
- // seconds later — SessionSummary carries no source field. So a landed
4036
- // "/"-bearing title is quoted only after it survives 20s unchanged;
4037
- // any rename resets the window, and the common slash-free LLM name
4038
- // simply releases the session untouched. Only the "new session
4039
- // auto-title stayed slashy" case gets wrapped.
4040
- const TITLE_STABLE_MS = 20000
4041
-
4042
- // User-driven renames funnel through here: mark first so the
4043
- // quote-on-land effect never second-guesses a deliberate title.
4063
+ // Title provenance is host-recorded: the summary projection above paints
4064
+ // source.kind ('fallback' | 'provider' | 'user') onto every list row, so
4065
+ // a user rename needs no local bookkeeping — the next snapshot marks it
4066
+ // 'user' and only then does a "/" title form groups. No synthetic user
4067
+ // title and no 20s stabilization window: rewriting the host title would
4068
+ // pin an automatic name before its LLM replacement lands.
4044
4069
  const renameByUser = (sessionId, title) => {
4045
- if (humanTouchedRef.current === null) humanTouchedRef.current = new Set()
4046
- humanTouchedRef.current.add(String(sessionId))
4047
- if (autoStableRef.current) autoStableRef.current.delete(String(sessionId))
4048
4070
  // A manual rename is authoritative: forget any grafted display so
4049
4071
  // the fresh wire title stands verbatim (no prefix re-applied).
4050
4072
  if (titleCacheRef && typeof titleCacheRef.forgetDisplay === 'function') titleCacheRef.forgetDisplay(String(sessionId))
@@ -4096,66 +4118,6 @@ window.__ModuleLoader__.load({
4096
4118
  return () => clearTimeout(timer)
4097
4119
  }, [copyNotice ? copyNotice.seq : null])
4098
4120
 
4099
- React.useEffect(() => {
4100
- if (!list || !list.byId || typeof renameSession !== 'function') return
4101
- if (blankSeenRef.current === null) blankSeenRef.current = new Set()
4102
- if (humanTouchedRef.current === null) humanTouchedRef.current = new Set()
4103
- if (autoStableRef.current === null) autoStableRef.current = new Map()
4104
- const blankSeen = blankSeenRef.current
4105
- const touched = humanTouchedRef.current
4106
- const stable = autoStableRef.current
4107
- const now = Date.now()
4108
- const fixes = []
4109
- let deadline = Infinity
4110
- for (const id of Object.keys(list.byId)) {
4111
- const summary = list.byId[id]
4112
- if (!summary) continue
4113
- if (summary.blank) { blankSeen.add(id); stable.delete(id); continue }
4114
- if (!blankSeen.has(id) || touched.has(id)) { stable.delete(id); continue }
4115
- const text = String(summary.title || '')
4116
- if (text.trim() === '' || text.includes('“') || text.includes('"') || !text.includes('/')) {
4117
- stable.delete(id) // quoted already, or the LLM name arrived slash-free: done
4118
- continue
4119
- }
4120
- // v0.37.3: a path-shaped title is already ONE row at render time
4121
- // (titleQuotesPath). Renaming it here would append a USER title and
4122
- // supersede an asynchronous LLM name that has not landed yet, so the
4123
- // fallback would stay pinned as a raw path. Leave it to the host.
4124
- if (titleQuotesPath(text)) {
4125
- stable.delete(id)
4126
- continue
4127
- }
4128
- const prev = stable.get(id)
4129
- if (prev && prev.title === text) {
4130
- if (now - prev.since >= TITLE_STABLE_MS) {
4131
- stable.delete(id)
4132
- fixes.push([id, '“' + text + '”'])
4133
- } else {
4134
- deadline = Math.min(deadline, prev.since + TITLE_STABLE_MS)
4135
- }
4136
- continue
4137
- }
4138
- stable.set(id, { title: text, since: now }) // new landing or fallback→LLM rename: reset
4139
- deadline = Math.min(deadline, now + TITLE_STABLE_MS)
4140
- }
4141
- for (const id of Array.from(stable.keys())) {
4142
- if (!list.byId[id]) stable.delete(id) // session vanished (archived/deleted): drop the wait
4143
- }
4144
- if (stableTimerRef.current !== null) { clearTimeout(stableTimerRef.current); stableTimerRef.current = null }
4145
- if (deadline !== Infinity) {
4146
- stableTimerRef.current = setTimeout(() => {
4147
- stableTimerRef.current = null
4148
- setStableTick(t => t + 1) // re-evaluate through the effect, never rename from a stale closure
4149
- }, Math.max(1, deadline - now))
4150
- }
4151
- if (fixes.length === 0) return
4152
- Promise.resolve()
4153
- .then(async () => { for (const [id, next] of fixes) await renameSession(id, next) })
4154
- .catch(() => { /* display-layer fallback keeps the row flat; a later snapshot re-evaluates */ })
4155
- }, [list, stableTick])
4156
- React.useEffect(() => () => {
4157
- if (stableTimerRef.current !== null) clearTimeout(stableTimerRef.current)
4158
- }, [])
4159
4121
  const normalizedQuery = query.trim().toLowerCase()
4160
4122
  const now = Date.now()
4161
4123
 
@@ -4224,6 +4186,7 @@ window.__ModuleLoader__.load({
4224
4186
  pending: visiblePendingKind(st && st.pendingInteraction && st.pendingInteraction.kind),
4225
4187
  pinned: pinnedSet.has(id) && !archivedSet.has(id),
4226
4188
  archived: archivedSet.has(id),
4189
+ allowGroups: groupsAllowedFor(summary),
4227
4190
  })
4228
4191
  }
4229
4192
  ungrouped.sort((a, b) => b.updatedAt - a.updatedAt)
@@ -4284,9 +4247,10 @@ window.__ModuleLoader__.load({
4284
4247
 
4285
4248
  const sessionsOf = (workspace) => {
4286
4249
  const rows = []
4250
+ const filter = effectiveArchivedFilterOf(workspace)
4287
4251
  for (const id of workspace.sessionIds || []) {
4288
4252
  const summary = list && list.byId ? list.byId[id] : undefined
4289
- if (!sessionVisible(summary, currentId, archivedSet, archivedFilter)) continue
4253
+ if (!sessionVisible(summary, currentId, archivedSet, filter)) continue
4290
4254
  const st = statusOf(id)
4291
4255
  rows.push({
4292
4256
  id,
@@ -4300,6 +4264,7 @@ window.__ModuleLoader__.load({
4300
4264
  pending: visiblePendingKind(st && st.pendingInteraction && st.pendingInteraction.kind),
4301
4265
  pinned: pinnedSet.has(id) && !archivedSet.has(id),
4302
4266
  archived: archivedSet.has(id),
4267
+ allowGroups: groupsAllowedFor(summary),
4303
4268
  })
4304
4269
  }
4305
4270
  // Browser-local reorder fallback: used only when the injected
@@ -4576,16 +4541,27 @@ window.__ModuleLoader__.load({
4576
4541
  * by these groups, so a drop that only reorders the flat list would leave
4577
4542
  * the row exactly where it was.
4578
4543
  */
4579
- const titleMovedToGroup = (title, anchorTitle) => {
4580
- const leaf = leafOf(title)
4581
- const group = groupPrefixOf(anchorTitle)
4582
- if (!group) return leaf
4583
- // A path-shaped leaf stays ONE opaque row (titleQuotesPath), so an
4584
- // unwrapped prefix would never become a visible group. Quote the leaf
4585
- // — the same convention quote-on-land uses — and the prefix splits
4586
- // while the path stays verbatim inside the quotes.
4587
- const safe = titleQuotesPath(leaf) ? '“' + leaf + '”' : leaf
4588
- return group + '/' + safe
4544
+ // A path-shaped leaf stays ONE opaque row (titleQuotesPath), so an
4545
+ // unwrapped prefix would never become a visible group. Quote it and the
4546
+ // prefix splits while the path stays verbatim inside the quotes. A leaf
4547
+ // from an auto-titled session is opaque for the same reason: its "\/" is
4548
+ // prose, not a group, and the drag itself is the explicit grouping act.
4549
+ const leafForGroup = (leaf, opaque) => {
4550
+ const s = String(leaf || '')
4551
+ if (!s.includes('/')) return s
4552
+ if (/^“[^”]*”$/.test(s) || /^"[^"]*"$/.test(s)) return s
4553
+ return (opaque || titleQuotesPath(s)) ? '“' + s + '”' : s
4554
+ }
4555
+ const titleMovedToGroup = (title, anchorTitle, opts = {}) => {
4556
+ const sourceOpaque = opts.sourceOpaque === true
4557
+ const anchorOpaque = opts.anchorOpaque === true
4558
+ const leaf = sourceOpaque ? String(title || '') : leafOf(title)
4559
+ const group = anchorOpaque ? '' : groupPrefixOf(anchorTitle)
4560
+ // Returning to the root is still a manual rename in the host, which
4561
+ // turns the title's source into 'user'. Quote an opaque leaf so its
4562
+ // prose "/" cannot start forming groups after that rename.
4563
+ if (!group) return leafForGroup(leaf, sourceOpaque)
4564
+ return group + '/' + leafForGroup(leaf, sourceOpaque)
4589
4565
  }
4590
4566
  /**
4591
4567
  * Every existing group path, deduplicated (datalist source). `kind`
@@ -4607,7 +4583,12 @@ window.__ModuleLoader__.load({
4607
4583
  }
4608
4584
  for (const workspace of items || []) {
4609
4585
  if (kind !== 'session') addPrefixes(workspace.title)
4610
- if (kind !== 'workspace') for (const session of sessionsOf(workspace)) addPrefixes(session.title)
4586
+ if (kind !== 'workspace') for (const session of sessionsOf(workspace)) {
4587
+ // An auto-titled session's "/" is prose: it forms no group and must
4588
+ // not be suggested as one (issue #19).
4589
+ if (session.allowGroups === false) continue
4590
+ addPrefixes(session.title)
4591
+ }
4611
4592
  }
4612
4593
  return [...paths].sort()
4613
4594
  }
@@ -4731,7 +4712,7 @@ window.__ModuleLoader__.load({
4731
4712
  const id = text.slice(5)
4732
4713
  for (const workspace of items || []) {
4733
4714
  const row = sessionsOf(workspace).find((s) => s.id === id)
4734
- if (row) return { kind: 'session', id: row.id, title: row.title, workspaceId: workspace.workspaceId }
4715
+ if (row) return { kind: 'session', id: row.id, title: row.title, workspaceId: workspace.workspaceId, allowGroups: row.allowGroups !== false }
4735
4716
  }
4736
4717
  return null
4737
4718
  }
@@ -4747,13 +4728,15 @@ window.__ModuleLoader__.load({
4747
4728
  * (folder / sgroup) rewrite the prefix of every member, exactly like
4748
4729
  * the existing rename-group dialogs; single kinds just retitle the
4749
4730
  * entry. All paths are user-driven renames, so sessions go through
4750
- * renameByUser (humanTouched: never auto-quoted afterwards).
4731
+ * renameByUser, so the next snapshot records source "user" and the new
4732
+ * prefix becomes a real group.
4751
4733
  */
4752
4734
  const submitGroupMove = (target, rawGroup) => {
4753
4735
  if (!target) { setDialog(null); return }
4754
4736
  const group = normPath(String(rawGroup || ''))
4755
- const leaf = leafOf(target.title)
4756
- const next = group ? group + '/' + leaf : leaf
4737
+ const leaf = target.allowGroups === false ? String(target.title || '') : leafOf(target.title)
4738
+ const safe = target.kind === 'session' ? leafForGroup(leaf, target.allowGroups === false) : leaf
4739
+ const next = group ? group + '/' + safe : safe
4757
4740
  if (next === '' || next === target.title) { setDialog(null); return }
4758
4741
  if (target.kind === 'workspace') {
4759
4742
  Promise.resolve()
@@ -4988,7 +4971,7 @@ window.__ModuleLoader__.load({
4988
4971
  // then shows its plain "no drop" cursor, which tells the truth).
4989
4972
  if (dragMatches('session')) {
4990
4973
  if (!sessionSlash || drag.source.workspaceId !== workspace.workspaceId) return
4991
- if (leafOf(drag.source.title) === drag.source.title) return
4974
+ if (drag.source.allowGroups === false || leafOf(drag.source.title) === drag.source.title) return
4992
4975
  event.preventDefault()
4993
4976
  event.stopPropagation()
4994
4977
  try { event.dataTransfer.dropEffect = 'move' } catch { }
@@ -5081,7 +5064,7 @@ window.__ModuleLoader__.load({
5081
5064
  onDrop: (event) => {
5082
5065
  if (dragMatches('session')) {
5083
5066
  if (!sessionSlash || drag.source.workspaceId !== workspace.workspaceId) return
5084
- if (leafOf(drag.source.title) === drag.source.title) return
5067
+ if (drag.source.allowGroups === false || leafOf(drag.source.title) === drag.source.title) return
5085
5068
  event.preventDefault()
5086
5069
  event.stopPropagation()
5087
5070
  commitSessionMoveOut(workspace.workspaceId)
@@ -5791,7 +5774,7 @@ window.__ModuleLoader__.load({
5791
5774
  event.dataTransfer.effectAllowed = 'move'
5792
5775
  event.dataTransfer.setData('text/plain', session.id)
5793
5776
  } catch { }
5794
- setDrag({ kind: 'session', source: { sessionId: session.id, workspaceId, title: session.title, leaf: session.leaf || session.title }, over: null })
5777
+ setDrag({ kind: 'session', source: { sessionId: session.id, workspaceId, title: session.title, leaf: session.leaf || session.title, allowGroups: session.allowGroups !== false }, over: null })
5795
5778
  },
5796
5779
  onDragEnd: () => setDrag(null),
5797
5780
  onDragOver: (event) => {
@@ -5849,7 +5832,7 @@ window.__ModuleLoader__.load({
5849
5832
  // pure reorder; with the name layer off every group is '' — which is
5850
5833
  // the same thing.
5851
5834
  const target = flat.find(s => s.id === targetSessionId)
5852
- const nextTitle = sessionSlash && target ? titleMovedToGroup(source.title, target.title) : null
5835
+ const nextTitle = sessionSlash && target ? titleMovedToGroup(source.title, target.title, { sourceOpaque: source.allowGroups === false, anchorOpaque: target.allowGroups === false }) : null
5853
5836
  // Official pinned-block rule: a pinned row reorders only among pinned
5854
5837
  // rows. A cross-block drop that would be a pure reorder is ignored;
5855
5838
  // group moves keep working (the pin fronts the row in its new group).
@@ -5882,7 +5865,9 @@ window.__ModuleLoader__.load({
5882
5865
  const source = drag.source
5883
5866
  setDrag(null)
5884
5867
  if (!source || source.workspaceId !== workspaceId) return
5885
- const leaf = leafOf(source.title)
5868
+ // An auto-titled row is already at the root: its "/" never formed a
5869
+ // group, so there is nothing to leave.
5870
+ const leaf = source.allowGroups === false ? source.title : leafOf(source.title)
5886
5871
  if (leaf === source.title) return
5887
5872
  Promise.resolve()
5888
5873
  .then(() => renameByUser(source.sessionId, leaf))
@@ -5904,7 +5889,8 @@ window.__ModuleLoader__.load({
5904
5889
  const commitSessionMoveInto = (workspaceId, groupPath) => {
5905
5890
  const source = drag.source
5906
5891
  setDrag(null)
5907
- const newTitle = groupPath !== '' ? groupPath + '/' + source.leaf : source.leaf
5892
+ const leaf = leafForGroup(source.leaf, source.allowGroups === false)
5893
+ const newTitle = groupPath !== '' ? groupPath + '/' + leaf : leaf
5908
5894
  if (newTitle === source.title) return
5909
5895
  Promise.resolve()
5910
5896
  .then(() => renameByUser(source.sessionId, newTitle))
@@ -6128,8 +6114,17 @@ window.__ModuleLoader__.load({
6128
6114
  * with, so this can never disagree with the session list below the row.
6129
6115
  */
6130
6116
  const archivedOnly = archivedFilter === 'only'
6131
- const workspaceVisible = (workspace) => !archivedOnly
6132
- || !!(workspace && Array.isArray(workspace.sessionIds) && sessionsOf(workspace).length > 0)
6117
+ const workspaceVisible = (workspace) => {
6118
+ if (!workspace) return false
6119
+ if (effectiveArchivedFilterOf(workspace) !== 'only') return true
6120
+ if (Array.isArray(workspace.sessionIds) && sessionsOf(workspace).length > 0) return true
6121
+ // Keep an EMPTY workspace that carries its own "only archived"
6122
+ // override: its row is the sole surface holding the menu that can
6123
+ // switch it back to "follow global". Hiding it would trap the user
6124
+ // in an empty view with no visible way out (the global empty-state
6125
+ // action edits the global filter, not this workspace's override).
6126
+ return workspaceArchivedFilterOf(workspace.workspaceId) === 'only'
6127
+ }
6133
6128
  const countVisibleAt = (level) => {
6134
6129
  let total = 0
6135
6130
  for (const folder of level.folders) {
@@ -6392,7 +6387,7 @@ window.__ModuleLoader__.load({
6392
6387
  const groupItem = { id: 'move-group', label: t('menu.moveToGroup'), icon: icon('IconFolderOpenOutline16', 16) }
6393
6388
  const ungroupItem = { id: 'move-out', label: t('menu.moveOutGroup'), icon: icon('IconRightUpOutline16', 16) }
6394
6389
  const sgroupItems = sessionSlash ? [groupItem] : []
6395
- const sessionUngroupItems = sessionSlash && payload && groupPrefixOf(String(payload.title || '')) !== '' ? [ungroupItem] : []
6390
+ const sessionUngroupItems = sessionSlash && payload && payload.allowGroups !== false && groupPrefixOf(String(payload.title || '')) !== '' ? [ungroupItem] : []
6396
6391
  const official = (key, fallback) => {
6397
6392
  const value = typeof ot === 'function' ? ot(key) : null
6398
6393
  return typeof value === 'string' && value !== '' && value !== key ? value : fallback
@@ -6435,6 +6430,10 @@ window.__ModuleLoader__.load({
6435
6430
  // One group-LEAVING implementation for the local menu and the official
6436
6431
  // contribution: retitle to the leaf and let the user-touch wrapper pin it.
6437
6432
  const moveSessionOut = (payload) => {
6433
+ const summary = list && list.byId ? list.byId[payload.id] : undefined
6434
+ // Only a user-authored title has groups to leave; an automatic title's
6435
+ // "/" is prose (issue #19).
6436
+ if (payload.allowGroups === false || (summary && groupsAllowedFor(summary) === false)) return
6438
6437
  const title = String(payload.title || '')
6439
6438
  const leaf = leafOf(title)
6440
6439
  if (leaf !== title) Promise.resolve().then(() => renameByUser(payload.id, leaf)).catch(fail)
@@ -6520,6 +6519,39 @@ window.__ModuleLoader__.load({
6520
6519
  current: projection && typeof projection.agentPreset === 'string' ? projection.agentPreset : undefined,
6521
6520
  })
6522
6521
  }
6522
+ // Per-workspace archived visibility (v0.40.0). The four entries share
6523
+ // one id set across the primitives submenu, the flat no-Menu fallback
6524
+ // and the official shared menu, so dispatch is identical everywhere.
6525
+ // A leading check marks the stored value; `follow` is the absent key.
6526
+ const archiveFilterOptions = (workspaceId) => {
6527
+ const selected = workspaceArchivedFilterOf(workspaceId)
6528
+ const option = (value, id, label) => ({
6529
+ value,
6530
+ id,
6531
+ label: (selected === value ? '✓ ' : '') + label,
6532
+ })
6533
+ return [
6534
+ option(null, 'bw-archive-follow', t('menu.archiveFollow')),
6535
+ option('default', 'bw-archive-hide', t('menu.archiveHide')),
6536
+ option('show', 'bw-archive-show', t('menu.archiveShow')),
6537
+ option('only', 'bw-archive-only', t('menu.archiveOnly')),
6538
+ ]
6539
+ }
6540
+ const archiveFilterEntries = (workspaceId) => {
6541
+ const options = archiveFilterOptions(workspaceId)
6542
+ // The plugin's no-primitives fallback menu is a flat button list, so
6543
+ // a submenu would be unreachable there. Flatten into the parent menu
6544
+ // instead; ids stay the same, dispatch stays shared.
6545
+ if (typeof ui.Menu !== 'function') {
6546
+ return options.map((option) => ({ id: option.id, label: option.label }))
6547
+ }
6548
+ return [{
6549
+ id: 'bw-archive-filter',
6550
+ label: t('menu.archiveFilter'),
6551
+ icon: icon('IconArchiveOutlineRegular', 16),
6552
+ submenu: options.map((option) => ({ id: option.id, label: option.label })),
6553
+ }]
6554
+ }
6523
6555
  const menuEntries = () => {
6524
6556
  if (ctx === null) return []
6525
6557
  const customizeEntry = { id: 'customize', label: t('custom.title'), icon: icon('IconPersonalizationOutline16', 16) }
@@ -6553,6 +6585,7 @@ window.__ModuleLoader__.load({
6553
6585
  { id: 'delete', label: ot ? ot('delete.workspace') : t('menu.delete'), icon: icon('IconTrashOutline16', 16), danger: true },
6554
6586
  { type: 'separator', id: 'bw-sep' },
6555
6587
  ...wsGroupItems,
6588
+ ...archiveFilterEntries(ctx.payload.workspaceId),
6556
6589
  customizeEntry,
6557
6590
  // v0.32.0: the workspace default preset. Rides the official
6558
6591
  // agent-preset roster + blank-composition channels (the hero
@@ -6587,14 +6620,25 @@ window.__ModuleLoader__.load({
6587
6620
  }
6588
6621
  const { kind, payload } = current
6589
6622
  setCtx(null)
6623
+ const archiveValue = id === 'bw-archive-follow' ? null
6624
+ : id === 'bw-archive-hide' ? 'default'
6625
+ : id === 'bw-archive-show' ? 'show'
6626
+ : id === 'bw-archive-only' ? 'only'
6627
+ : undefined
6628
+ if (kind === 'workspace' && archiveValue !== undefined) {
6629
+ if (actions && typeof actions.setWorkspaceArchivedFilter === 'function') {
6630
+ actions.setWorkspaceArchivedFilter(payload.workspaceId, archiveValue)
6631
+ }
6632
+ return
6633
+ }
6590
6634
  if (id === 'move-group') {
6591
6635
  // Lock the clicked row as the dialog target; the group name starts
6592
6636
  // at its CURRENT prefix so "retype to move elsewhere" is one edit.
6593
6637
  const target = kind === 'folder' ? { kind: 'folder', path: payload.path, title: payload.path }
6594
6638
  : kind === 'workspace' ? { kind: 'workspace', id: payload.workspaceId, title: payload.title || payload.leaf }
6595
- : kind === 'session' ? { kind: 'session', id: payload.id, title: payload.title }
6639
+ : kind === 'session' ? { kind: 'session', id: payload.id, title: payload.title, allowGroups: payload.allowGroups !== false }
6596
6640
  : { kind: 'sgroup', path: payload.path, title: payload.path, workspaceId: payload.workspaceId }
6597
- setDialog({ kind: 'group-move', target, initial: groupPrefixOf(target.title), groupPaths: collectGroupPaths(target.kind) })
6641
+ setDialog({ kind: 'group-move', target, initial: target.allowGroups === false ? '' : groupPrefixOf(target.title), groupPaths: collectGroupPaths(target.kind) })
6598
6642
  }
6599
6643
  else if (kind === 'folder' && id === 'rename-folder') setDialog({ kind: 'folder-rename', path: payload.path })
6600
6644
  else if (kind === 'folder' && id === 'delete-folder') setDialog({ kind: 'folder-delete', path: payload.path, scope: payload.scope, name: payload.name })
@@ -6618,7 +6662,19 @@ window.__ModuleLoader__.load({
6618
6662
  slashes: { session: sessionSlash, workspace: workspaceSlash },
6619
6663
  hasPreset: () => !!presetFace,
6620
6664
  groupPrefix: (title) => groupPrefixOf(title),
6665
+ // The official session target carries only displayTitle, so provenance
6666
+ // is looked up by id here — the same list snapshot the rows render from.
6667
+ allowGroups: (sessionId) => {
6668
+ const summary = list && list.byId ? list.byId[sessionId] : undefined
6669
+ return !summary || groupsAllowedFor(summary)
6670
+ },
6621
6671
  label: (key) => t(key),
6672
+ archiveOptions: (workspaceId) => archiveFilterOptions(workspaceId),
6673
+ setArchiveFilter: (workspaceId, value) => {
6674
+ if (actions && typeof actions.setWorkspaceArchivedFilter === 'function') {
6675
+ actions.setWorkspaceArchivedFilter(workspaceId, value)
6676
+ }
6677
+ },
6622
6678
  customize: (kind, payload) => {
6623
6679
  const name = kind === 'workspace' ? (payload.title || payload.leaf) : payload.title
6624
6680
  setCustomize({ kind, entryKey: keyOf(kind, payload), name })
@@ -6628,10 +6684,13 @@ window.__ModuleLoader__.load({
6628
6684
  const target = kind === 'workspace'
6629
6685
  ? { kind: 'workspace', id: payload.workspaceId, title }
6630
6686
  : { kind: 'session', id: payload.id, title }
6687
+ const summary = kind === 'session' && list && list.byId ? list.byId[payload.id] : undefined
6688
+ const allowGroups = !summary || groupsAllowedFor(summary)
6689
+ if (kind === 'session') target.allowGroups = allowGroups
6631
6690
  setDialog({
6632
6691
  kind: 'group-move',
6633
6692
  target,
6634
- initial: groupPrefixOf(target.title),
6693
+ initial: allowGroups ? groupPrefixOf(target.title) : '',
6635
6694
  groupPaths: collectGroupPaths(kind),
6636
6695
  })
6637
6696
  },
@@ -6899,7 +6958,7 @@ window.__ModuleLoader__.load({
6899
6958
  // gap), so a drop outside every group always means the same thing:
6900
6959
  // leave the group. Row handlers stopPropagation, so this only fires
6901
6960
  // where no row claimed the drop.
6902
- const sessionCouldLeaveGroup = dragMatches('session') && sessionSlash && drag.source && leafOf(drag.source.title) !== drag.source.title
6961
+ const sessionCouldLeaveGroup = dragMatches('session') && sessionSlash && drag.source && drag.source.allowGroups !== false && leafOf(drag.source.title) !== drag.source.title
6903
6962
  const rootDropEvents = {
6904
6963
  onDragOver: (event) => {
6905
6964
  if (!sessionCouldLeaveGroup) return
@@ -7273,7 +7332,8 @@ window.__ModuleLoader__.load({
7273
7332
  run: () => bridge.moveGroup('session', { id: target.sessionId, title }),
7274
7333
  })
7275
7334
  }
7276
- if (bridge.groupPrefix && bridge.groupPrefix(title) !== '') {
7335
+ const allowGroups = !bridge.allowGroups || bridge.allowGroups(target.sessionId)
7336
+ if (allowGroups && bridge.groupPrefix && bridge.groupPrefix(title) !== '') {
7277
7337
  actions.push({
7278
7338
  id: 'bw-move-out',
7279
7339
  label: bridge.label('menu.moveOutGroup'),
@@ -7305,6 +7365,23 @@ window.__ModuleLoader__.load({
7305
7365
  run: () => bridge.moveGroup('workspace', payload),
7306
7366
  })
7307
7367
  }
7368
+ // A submenu, not four top-level rows: the official shared menu
7369
+ // only sees this plugin's contribution, so the workspace verb
7370
+ // block stays short while the four archive modes are one level
7371
+ // deeper. The check mark lives in the label because a shared
7372
+ // contextMenus action has no selectedIds channel.
7373
+ const archive = bridge.archiveOptions && bridge.archiveOptions(target.workspaceId)
7374
+ if (archive && archive.length > 0) {
7375
+ actions.push({
7376
+ id: 'bw-archive-filter',
7377
+ label: bridge.label('menu.archiveFilter'),
7378
+ children: archive.map((option) => ({
7379
+ id: option.id,
7380
+ label: option.label,
7381
+ run: () => bridge.setArchiveFilter(target.workspaceId, option.value),
7382
+ })),
7383
+ })
7384
+ }
7308
7385
  actions.push({
7309
7386
  id: 'bw-customize',
7310
7387
  label: bridge.label('custom.title'),
@@ -64,6 +64,11 @@ window.__ModuleLoader__.load({
64
64
  'menu.moveOutGroup': '移出分组',
65
65
  'menu.sessionId': '复制 Session ID',
66
66
  'menu.workspacePreset': '修改工作区默认预设…',
67
+ 'menu.archiveFilter': '筛选会话',
68
+ 'menu.archiveFollow': '跟随全局',
69
+ 'menu.archiveHide': '隐藏已归档',
70
+ 'menu.archiveShow': '全部对话(显示已归档)',
71
+ 'menu.archiveOnly': '仅显示已归档',
67
72
  'wsPreset.title': '修改工作区默认预设',
68
73
  'wsPreset.current': '当前默认预设',
69
74
  'wsPreset.currentUnknown': '当前预设未知',
@@ -227,6 +232,11 @@ window.__ModuleLoader__.load({
227
232
  'menu.moveOutGroup': 'Move out of group',
228
233
  'menu.sessionId': 'Copy Session ID',
229
234
  'menu.workspacePreset': 'Workspace default preset…',
235
+ 'menu.archiveFilter': 'Filter sessions',
236
+ 'menu.archiveFollow': 'Follow global',
237
+ 'menu.archiveHide': 'Hide archived',
238
+ 'menu.archiveShow': 'All conversations (show archived)',
239
+ 'menu.archiveOnly': 'Archived only',
230
240
  'wsPreset.title': 'Change workspace default preset',
231
241
  'wsPreset.current': 'Current default preset',
232
242
  'wsPreset.currentUnknown': 'Current preset unknown',
@@ -398,6 +408,11 @@ window.__ModuleLoader__.load({
398
408
  'menu.moveOutGroup': 'إخراج من المجموعة',
399
409
  'menu.sessionId': 'نسخ معرّف الجلسة',
400
410
  'menu.workspacePreset': 'الإعداد المسبق الافتراضي لمساحة العمل…',
411
+ 'menu.archiveFilter': 'تصفية الجلسات',
412
+ 'menu.archiveFollow': 'اتباع العام',
413
+ 'menu.archiveHide': 'إخفاء المؤرشفة',
414
+ 'menu.archiveShow': 'كل المحادثات (إظهار المؤرشفة)',
415
+ 'menu.archiveOnly': 'المؤرشفة فقط',
401
416
  'wsPreset.title': 'تغيير الإعداد المسبق الافتراضي لمساحة العمل',
402
417
  'wsPreset.current': 'الإعداد المسبق الافتراضي الحالي',
403
418
  'wsPreset.currentUnknown': 'الإعداد المسبق الحالي غير معروف',
@@ -561,6 +576,11 @@ window.__ModuleLoader__.load({
561
576
  'menu.moveOutGroup': 'Aus der Gruppe entfernen',
562
577
  'menu.sessionId': 'Session-ID kopieren',
563
578
  'menu.workspacePreset': 'Workspace-Standardpreset…',
579
+ 'menu.archiveFilter': 'Sitzungen filtern',
580
+ 'menu.archiveFollow': 'Global folgen',
581
+ 'menu.archiveHide': 'Archivierte ausblenden',
582
+ 'menu.archiveShow': 'Alle Unterhaltungen (archivierte anzeigen)',
583
+ 'menu.archiveOnly': 'Nur archivierte',
564
584
  'wsPreset.title': 'Workspace-Standardpreset ändern',
565
585
  'wsPreset.current': 'Aktueller Standardpreset',
566
586
  'wsPreset.currentUnknown': 'Aktueller Preset unbekannt',
@@ -724,6 +744,11 @@ window.__ModuleLoader__.load({
724
744
  'menu.moveOutGroup': 'Retirer du groupe',
725
745
  'menu.sessionId': 'Copier l’ID de session',
726
746
  'menu.workspacePreset': 'Préréglage par défaut du workspace…',
747
+ 'menu.archiveFilter': 'Filtrer les sessions',
748
+ 'menu.archiveFollow': 'Suivre le global',
749
+ 'menu.archiveHide': 'Masquer les archivées',
750
+ 'menu.archiveShow': 'Toutes les conversations (afficher les archivées)',
751
+ 'menu.archiveOnly': 'Archivées uniquement',
727
752
  'wsPreset.title': 'Modifier le préréglage par défaut du workspace',
728
753
  'wsPreset.current': 'Préréglage par défaut actuel',
729
754
  'wsPreset.currentUnknown': 'Préréglage actuel inconnu',
@@ -887,6 +912,11 @@ window.__ModuleLoader__.load({
887
912
  'menu.moveOutGroup': 'समूह से बाहर निकालें',
888
913
  'menu.sessionId': 'सेशन आईडी कॉपी करें',
889
914
  'menu.workspacePreset': 'वर्कस्पेस डिफ़ॉल्ट प्रीसेट…',
915
+ 'menu.archiveFilter': 'सत्र फ़िल्टर करें',
916
+ 'menu.archiveFollow': 'ग्लोबल का पालन करें',
917
+ 'menu.archiveHide': 'संग्रहीत छिपाएँ',
918
+ 'menu.archiveShow': 'सभी बातचीत (संग्रहीत दिखाएँ)',
919
+ 'menu.archiveOnly': 'केवल संग्रहीत',
890
920
  'wsPreset.title': 'वर्कस्पेस डिफ़ॉल्ट प्रीसेट बदलें',
891
921
  'wsPreset.current': 'वर्तमान डिफ़ॉल्ट प्रीसेट',
892
922
  'wsPreset.currentUnknown': 'वर्तमान प्रीसेट अज्ञात',
@@ -1050,6 +1080,11 @@ window.__ModuleLoader__.load({
1050
1080
  'menu.moveOutGroup': 'Keluarkan dari grup',
1051
1081
  'menu.sessionId': 'Salin ID Sesi',
1052
1082
  'menu.workspacePreset': 'Preset bawaan ruang kerja…',
1083
+ 'menu.archiveFilter': 'Filter sesi',
1084
+ 'menu.archiveFollow': 'Ikuti global',
1085
+ 'menu.archiveHide': 'Sembunyikan arsip',
1086
+ 'menu.archiveShow': 'Semua percakapan (tampilkan arsip)',
1087
+ 'menu.archiveOnly': 'Hanya arsip',
1053
1088
  'wsPreset.title': 'Ubah preset bawaan ruang kerja',
1054
1089
  'wsPreset.current': 'Preset bawaan saat ini',
1055
1090
  'wsPreset.currentUnknown': 'Preset saat ini tidak diketahui',
@@ -1213,6 +1248,11 @@ window.__ModuleLoader__.load({
1213
1248
  'menu.moveOutGroup': 'Rimuovi dal gruppo',
1214
1249
  'menu.sessionId': 'Copia ID sessione',
1215
1250
  'menu.workspacePreset': 'Preset predefinito dello workspace…',
1251
+ 'menu.archiveFilter': 'Filtra sessioni',
1252
+ 'menu.archiveFollow': 'Segui globale',
1253
+ 'menu.archiveHide': 'Nascondi archiviate',
1254
+ 'menu.archiveShow': 'Tutte le conversazioni (mostra archiviate)',
1255
+ 'menu.archiveOnly': 'Solo archiviate',
1216
1256
  'wsPreset.title': 'Modifica il preset predefinito dello workspace',
1217
1257
  'wsPreset.current': 'Preset predefinito attuale',
1218
1258
  'wsPreset.currentUnknown': 'Preset attuale sconosciuto',
@@ -1376,6 +1416,11 @@ window.__ModuleLoader__.load({
1376
1416
  'menu.moveOutGroup': 'グループから出す',
1377
1417
  'menu.sessionId': 'セッション ID をコピー',
1378
1418
  'menu.workspacePreset': 'ワークスペース既定プリセット…',
1419
+ 'menu.archiveFilter': 'セッションを絞り込む',
1420
+ 'menu.archiveFollow': 'グローバルに従う',
1421
+ 'menu.archiveHide': 'アーカイブを非表示',
1422
+ 'menu.archiveShow': 'すべての会話(アーカイブを表示)',
1423
+ 'menu.archiveOnly': 'アーカイブのみ',
1379
1424
  'wsPreset.title': 'ワークスペース既定プリセットを変更',
1380
1425
  'wsPreset.current': '現在の既定プリセット',
1381
1426
  'wsPreset.currentUnknown': '現在のプリセットは不明',
@@ -1539,6 +1584,11 @@ window.__ModuleLoader__.load({
1539
1584
  'menu.moveOutGroup': '그룹에서 제거',
1540
1585
  'menu.sessionId': '세션 ID 복사',
1541
1586
  'menu.workspacePreset': '작업 공간 기본 프리셋…',
1587
+ 'menu.archiveFilter': '세션 필터',
1588
+ 'menu.archiveFollow': '전역 설정 따르기',
1589
+ 'menu.archiveHide': '보관된 항목 숨기기',
1590
+ 'menu.archiveShow': '모든 대화(보관된 항목 표시)',
1591
+ 'menu.archiveOnly': '보관된 항목만',
1542
1592
  'wsPreset.title': '작업 공간 기본 프리셋 변경',
1543
1593
  'wsPreset.current': '현재 기본 프리셋',
1544
1594
  'wsPreset.currentUnknown': '현재 프리셋을 알 수 없음',
@@ -1702,6 +1752,11 @@ window.__ModuleLoader__.load({
1702
1752
  'menu.moveOutGroup': 'Uit groep halen',
1703
1753
  'menu.sessionId': 'Sessie-ID kopiëren',
1704
1754
  'menu.workspacePreset': 'Standaardpreset van workspace…',
1755
+ 'menu.archiveFilter': 'Sessies filteren',
1756
+ 'menu.archiveFollow': 'Globaal volgen',
1757
+ 'menu.archiveHide': 'Gearchiveerde verbergen',
1758
+ 'menu.archiveShow': 'Alle gesprekken (gearchiveerde tonen)',
1759
+ 'menu.archiveOnly': 'Alleen gearchiveerd',
1705
1760
  'wsPreset.title': 'Standaardpreset van workspace wijzigen',
1706
1761
  'wsPreset.current': 'Huidige standaardpreset',
1707
1762
  'wsPreset.currentUnknown': 'Huidige preset onbekend',
@@ -1865,6 +1920,11 @@ window.__ModuleLoader__.load({
1865
1920
  'menu.moveOutGroup': 'Przenieś poza grupę',
1866
1921
  'menu.sessionId': 'Kopiuj ID sesji',
1867
1922
  'menu.workspacePreset': 'Domyślny preset obszaru roboczego…',
1923
+ 'menu.archiveFilter': 'Filtruj sesje',
1924
+ 'menu.archiveFollow': 'Zgodnie z globalnymi',
1925
+ 'menu.archiveHide': 'Ukryj zarchiwizowane',
1926
+ 'menu.archiveShow': 'Wszystkie rozmowy (pokaż zarchiwizowane)',
1927
+ 'menu.archiveOnly': 'Tylko zarchiwizowane',
1868
1928
  'wsPreset.title': 'Zmień domyślny preset obszaru roboczego',
1869
1929
  'wsPreset.current': 'Aktualny domyślny preset',
1870
1930
  'wsPreset.currentUnknown': 'Aktualny preset nieznany',
@@ -2028,6 +2088,11 @@ window.__ModuleLoader__.load({
2028
2088
  'menu.moveOutGroup': 'Remover do grupo',
2029
2089
  'menu.sessionId': 'Copiar ID da sessão',
2030
2090
  'menu.workspacePreset': 'Preset padrão do workspace…',
2091
+ 'menu.archiveFilter': 'Filtrar sessões',
2092
+ 'menu.archiveFollow': 'Seguir o global',
2093
+ 'menu.archiveHide': 'Ocultar arquivadas',
2094
+ 'menu.archiveShow': 'Todas as conversas (mostrar arquivadas)',
2095
+ 'menu.archiveOnly': 'Somente arquivadas',
2031
2096
  'wsPreset.title': 'Alterar o preset padrão do workspace',
2032
2097
  'wsPreset.current': 'Preset padrão atual',
2033
2098
  'wsPreset.currentUnknown': 'Preset atual desconhecido',
@@ -2191,6 +2256,11 @@ window.__ModuleLoader__.load({
2191
2256
  'menu.moveOutGroup': 'Убрать из группы',
2192
2257
  'menu.sessionId': 'Копировать ID сессии',
2193
2258
  'menu.workspacePreset': 'Пресет воркспейса по умолчанию…',
2259
+ 'menu.archiveFilter': 'Фильтровать сеансы',
2260
+ 'menu.archiveFollow': 'Как в общем',
2261
+ 'menu.archiveHide': 'Скрыть архивные',
2262
+ 'menu.archiveShow': 'Все разговоры (показать архивные)',
2263
+ 'menu.archiveOnly': 'Только архивные',
2194
2264
  'wsPreset.title': 'Изменить пресет воркспейса по умолчанию',
2195
2265
  'wsPreset.current': 'Текущий пресет по умолчанию',
2196
2266
  'wsPreset.currentUnknown': 'Текущий пресет неизвестен',
@@ -2354,6 +2424,11 @@ window.__ModuleLoader__.load({
2354
2424
  'menu.moveOutGroup': 'Flytta ut ur gruppen',
2355
2425
  'menu.sessionId': 'Kopiera sessions-ID',
2356
2426
  'menu.workspacePreset': 'Standardförinställning för arbetsyta…',
2427
+ 'menu.archiveFilter': 'Filtrera sessioner',
2428
+ 'menu.archiveFollow': 'Följ globalt',
2429
+ 'menu.archiveHide': 'Dölj arkiverade',
2430
+ 'menu.archiveShow': 'Alla konversationer (visa arkiverade)',
2431
+ 'menu.archiveOnly': 'Endast arkiverade',
2357
2432
  'wsPreset.title': 'Ändra standardförinställning för arbetsyta',
2358
2433
  'wsPreset.current': 'Aktuell standardförinställning',
2359
2434
  'wsPreset.currentUnknown': 'Aktuell förinställning okänd',
@@ -2517,6 +2592,11 @@ window.__ModuleLoader__.load({
2517
2592
  'menu.moveOutGroup': 'ย้ายออกจากกลุ่ม',
2518
2593
  'menu.sessionId': 'คัดลอก ID เซสชัน',
2519
2594
  'menu.workspacePreset': 'พรีเซ็ตเริ่มต้นของพื้นที่ทำงาน…',
2595
+ 'menu.archiveFilter': 'กรองเซสชัน',
2596
+ 'menu.archiveFollow': 'ตามการตั้งค่าทั่วไป',
2597
+ 'menu.archiveHide': 'ซ่อนที่เก็บถาวร',
2598
+ 'menu.archiveShow': 'บทสนทนาทั้งหมด (แสดงที่เก็บถาวร)',
2599
+ 'menu.archiveOnly': 'เฉพาะที่เก็บถาวร',
2520
2600
  'wsPreset.title': 'เปลี่ยนพรีเซ็ตเริ่มต้นของพื้นที่ทำงาน',
2521
2601
  'wsPreset.current': 'พรีเซ็ตเริ่มต้นปัจจุบัน',
2522
2602
  'wsPreset.currentUnknown': 'ไม่ทราบพรีเซ็ตปัจจุบัน',
@@ -2680,6 +2760,11 @@ window.__ModuleLoader__.load({
2680
2760
  'menu.moveOutGroup': 'Gruptan çıkar',
2681
2761
  'menu.sessionId': 'Oturum kimliğini kopyala',
2682
2762
  'menu.workspacePreset': 'Çalışma alanı varsayılan ön ayarı…',
2763
+ 'menu.archiveFilter': 'Oturumları filtrele',
2764
+ 'menu.archiveFollow': 'Geneli takip et',
2765
+ 'menu.archiveHide': 'Arşivlenenleri gizle',
2766
+ 'menu.archiveShow': 'Tüm konuşmalar (arşivlenenleri göster)',
2767
+ 'menu.archiveOnly': 'Yalnızca arşivlenenler',
2683
2768
  'wsPreset.title': 'Çalışma alanı varsayılan ön ayarını değiştir',
2684
2769
  'wsPreset.current': 'Geçerli varsayılan ön ayar',
2685
2770
  'wsPreset.currentUnknown': 'Geçerli ön ayar bilinmiyor',
@@ -2843,6 +2928,11 @@ window.__ModuleLoader__.load({
2843
2928
  'menu.moveOutGroup': 'Đưa ra khỏi nhóm',
2844
2929
  'menu.sessionId': 'Sao chép ID phiên',
2845
2930
  'menu.workspacePreset': 'Preset mặc định của không gian làm việc…',
2931
+ 'menu.archiveFilter': 'Lọc phiên',
2932
+ 'menu.archiveFollow': 'Theo toàn cục',
2933
+ 'menu.archiveHide': 'Ẩn đã lưu trữ',
2934
+ 'menu.archiveShow': 'Tất cả cuộc trò chuyện (hiện đã lưu trữ)',
2935
+ 'menu.archiveOnly': 'Chỉ đã lưu trữ',
2846
2936
  'wsPreset.title': 'Thay đổi preset mặc định của không gian làm việc',
2847
2937
  'wsPreset.current': 'Preset mặc định hiện tại',
2848
2938
  'wsPreset.currentUnknown': 'Không rõ preset hiện tại',
@@ -3006,6 +3096,11 @@ window.__ModuleLoader__.load({
3006
3096
  'menu.moveOutGroup': '移出分組',
3007
3097
  'menu.sessionId': '複製 Session ID',
3008
3098
  'menu.workspacePreset': '修改工作區預設…',
3099
+ 'menu.archiveFilter': '篩選會話',
3100
+ 'menu.archiveFollow': '跟隨全域',
3101
+ 'menu.archiveHide': '隱藏已封存',
3102
+ 'menu.archiveShow': '全部對話(顯示已封存)',
3103
+ 'menu.archiveOnly': '僅顯示已封存',
3009
3104
  'wsPreset.title': '修改工作區預設',
3010
3105
  'wsPreset.current': '目前預設',
3011
3106
  'wsPreset.currentUnknown': '目前預設未知',
@@ -3169,6 +3264,11 @@ window.__ModuleLoader__.load({
3169
3264
  'menu.moveOutGroup': '移出分組',
3170
3265
  'menu.sessionId': '複製 Session ID',
3171
3266
  'menu.workspacePreset': '修改工作區預設…',
3267
+ 'menu.archiveFilter': '篩選會話',
3268
+ 'menu.archiveFollow': '跟隨全域',
3269
+ 'menu.archiveHide': '隱藏已封存',
3270
+ 'menu.archiveShow': '全部對話(顯示已封存)',
3271
+ 'menu.archiveOnly': '僅顯示已封存',
3172
3272
  'wsPreset.title': '修改工作區預設',
3173
3273
  'wsPreset.current': '目前預設',
3174
3274
  'wsPreset.currentUnknown': '目前預設未知',
@@ -3332,6 +3432,11 @@ window.__ModuleLoader__.load({
3332
3432
  'menu.moveOutGroup': '移出分組',
3333
3433
  'menu.sessionId': '複製 Session ID',
3334
3434
  'menu.workspacePreset': '修改工作區預設…',
3435
+ 'menu.archiveFilter': '篩選工作階段',
3436
+ 'menu.archiveFollow': '跟隨全域',
3437
+ 'menu.archiveHide': '隱藏已封存',
3438
+ 'menu.archiveShow': '全部對話(顯示已封存)',
3439
+ 'menu.archiveOnly': '僅顯示已封存',
3335
3440
  'wsPreset.title': '修改工作區預設',
3336
3441
  'wsPreset.current': '目前預設',
3337
3442
  'wsPreset.currentUnknown': '目前預設未知',
package/src/index.js CHANGED
@@ -6,6 +6,8 @@
6
6
  * dsh client store). This host half exists so the cordis plugin row has a
7
7
  * valid Node entry and so Config metadata is available to the Loader.
8
8
  */
9
+ import { registerTitleSource } from './title-source.js'
10
+
9
11
  export const name = 'dsh-better-workspace'
10
12
 
11
13
  /**
@@ -48,9 +50,13 @@ export const Config = Schema === null
48
50
  appearance: Schema.dict(Schema.any()).default({}).volatile(),
49
51
  })
50
52
 
51
- export function apply(ctx) {
53
+ export const inject = ['sessionProjections']
54
+
55
+ export async function apply(ctx) {
52
56
  const log = ctx && ctx.logger && typeof ctx.logger.info === 'function'
53
57
  ? (msg) => ctx.logger.info(msg)
54
58
  : (msg) => console.log(msg)
55
- log('[dsh-better-workspace] host half loaded; UI runs in the browser (client half)')
59
+ const registered = await registerTitleSource(ctx, log)
60
+ log('[dsh-better-workspace] host half loaded; UI runs in the browser (client half)'
61
+ + (registered ? '; session title provenance projection registered' : '; title provenance unavailable'))
56
62
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Title provenance: the host knows WHY a session title exists — the
3
+ * `session/title` event carries `source.kind` (`fallback` | `provider` |
4
+ * `user`), and the official title projection deliberately exposes only the
5
+ * plain string. Without the source, the sidebar can only guess from the text
6
+ * whether a `/` was a deliberate grouping or part of an auto-generated
7
+ * sentence (issue #19). This optional summary projection carries the kind to
8
+ * the list rows so the client can trust intent instead of guessing it.
9
+ *
10
+ * It is a separate module so the fold can be unit-tested without booting the
11
+ * plugin row. Registration is best-effort: when zod is unavailable (a profile
12
+ * that did not hoist the host's dependency), the client keeps its display-layer
13
+ * fallback and the plugin still mounts.
14
+ */
15
+ export const TITLE_SOURCE_KEY = 'betterWorkspace/titleSource'
16
+
17
+ /**
18
+ * Build the projection definition. `z` is zod's namespace, injected so the
19
+ * fold can be tested with a real schema without a top-level import.
20
+ * @param z - zod namespace (v4).
21
+ * @returns a summary projection definition for `sessionProjections.register`.
22
+ */
23
+ export function titleSourceProjection(z) {
24
+ const schema = z.string().nullable()
25
+ return {
26
+ key: TITLE_SOURCE_KEY,
27
+ stateVersion: 1,
28
+ stateSchema: schema,
29
+ init: () => null,
30
+ apply: (state, event) => {
31
+ if (!event || event.type !== 'session/title') return state
32
+ const kind = event.data && event.data.source && event.data.source.kind
33
+ return typeof kind === 'string' && kind !== '' ? kind : state
34
+ },
35
+ // A summary unit by default: the list row needs this value. Eager
36
+ // providers must stay cheap; this fold only reads session/title events.
37
+ wire: { viewSchema: schema, view: (state) => state },
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Register the projection on the plugin context. Never throws: a missing zod
43
+ * or a registration conflict degrades to the client's text fallback.
44
+ * @param ctx - the plugin host context carrying `sessionProjections`.
45
+ * @param log - diagnostic sink.
46
+ * @returns whether the projection was registered.
47
+ */
48
+ export async function registerTitleSource(ctx, log = () => {}) {
49
+ let z
50
+ try {
51
+ ({ z } = await import('zod'))
52
+ } catch (error) {
53
+ log('[dsh-better-workspace] zod is not resolvable; session title provenance falls back to display heuristics: '
54
+ + (error && error.message || String(error)))
55
+ return false
56
+ }
57
+ try {
58
+ await ctx.sessionProjections.register(titleSourceProjection(z))
59
+ return true
60
+ } catch (error) {
61
+ log('[dsh-better-workspace] title provenance projection was not registered; '
62
+ + 'session titles fall back to display heuristics: ' + (error && error.message || String(error)))
63
+ return false
64
+ }
65
+ }