# AGENTS.md — dsh-url-router 本仓是一个独立的 DeepSeek Harness Web GUI 插件仓(**只有浏览器半区**)。 ## 形态约束 - **手写 JS,直接进 `lib/`**:没有构建步骤,`lib/index.js`(宿主半区,空操作)与 `lib/client.js`(浏览器半区,交付产物)既是源也是产物。 - **`lib/client.js` 必须守住模块加载器合同**:`window.__ModuleLoader__.load({ id: 'dsh-url-router', factory })`,工厂返回 `{ apply, inject }`;`inject` 是服务名 `['sessions','workspaces','uiWorkspace','slots']`,包 `dsh.client.inject` 声明的是四个官方客户端模块 id(含 `@deepseek-ai/dsh-client-ui-layout`)。`ctx.layout` 走 `ctx.get('layout')` **可选读取**(组合里可能没有布局包)。 - **零依赖**:没有 dependencies/devDependencies,也没有遥测与网络请求。 - **测试用 `node --test`**:`npm test`。测试**直接加载交付产物**(`node:vm` 里假 loader + 假 window + 手动时钟 + 假 panel store/目录),不复制实现、不 mock 模块图;行为变化必须带测试。 ## 路由契约(改之前先读) - **本插件是地址栏的唯一写者**。任何其它写 fragment 的插件都不得同时启用(`dsh-session-url` 就是这样一个插件)。 - **段位**:`#/session/`(会话,`uiWorkspace.openSession`)、`#/panel/`(主面板,`ctx.layout.selectPanel`)。输入侧接受复数与百分号编码,规范化成单数形式;**不认识的段位一律不碰**,只留一条诊断。 - **轮次归属必须可证明** ✓:助手行自带轮次 ✓;工具行只有调用 id ✗ → 靠 `[data-turn-start]`(值 = 轮号 ✓)反推 ✓,**但只有"行之前和之后各有一个已渲染边界"时才写** ✓(`turn//row/…` ✓)。虚拟化下边界常常只渲染一侧 ✗ → 那就**不写轮次** ✓(绝不猜 ✗)。假 DOM 的 `compareDocumentPosition` 用"根的后代序号"实现 ✓(不要用 `ownerDocument` ✗,假件没有 ✓)。 - **行级路线**:`#/session//row/<类>/<作用域>/` ✓(身份 = `data-trajectory-row-key` ✓,值是 `%00` 分隔的三元组 ✓ → 拆成路径段 ✓);家系视图的 `data-dshts-turn`(轮号 ✓)按 `turn/` 写 ✓。**点击处理器必须"行优先"** ✓:轨迹行本身就是按钮 ✓,而处理器开头的 `closest([data-chat-turn])` 会直接把非轮次元素挡掉 ✓(历史 bug ✓)。反映**纯被动** ✓(不 preventDefault ✓)。驱动 = 尽力 `scrollIntoView` ✓(虚拟化未渲染的行**不假装** ✓,返回 false ✓)。canonical 里位置的优先级 **行 > 视图 > 轮次** ✓。 - **会话视图路线**:`#/session//view/`(有工作区时跟在会话 id 之后 ✓)。**`uiConversation` 服务在根插件里读不到** ✗(实测 `ctx.get('uiConversation')` = undefined ✓,它是会话作用域内的 ✓)—— **视图 id 从槽位读** ✓:`ctx.slots.entries('conversation.view')` 的 `options.id` ✓,按 `options.order` 排序 ✓(chat=0 / trajectory=10 / surface=20 / plus2=21 ✓)。头部页签容器是 **`[data-conversation-tabs]`** ✓、页签是 `button[role="tab"][aria-selected]` ✓(**没有 id 写在 DOM 里** ✗)→ **位置↔注册顺序** 对齐出 id ✓。默认视图(第一个 ✓)不写进链接 ✓。 - **视图驱动必须排在工作区/会话分支之前** ✓,否则那两条会先 `return` 把它吞掉 ✓(历史 bug ✓);且**不能被 `source === 'hash'` 挡住** ✓(粘贴视图链接就是要切过去 ✓)。 - **工作区路线**:`#/workspace/<工作区名>/session/<会话id>`(侧栏形状 ✓)。工作区行是 `data-row-key="workspace:"` ✓、**行文本就是工作区名** ✓(无 `data-workspace-*` ✗,实测 ✓);会话所属工作区 = 树里**它之前最近的那个 workspace 行** ✓(`workspaceNameOf()` ✓,行锚点与反映共用 ✓)。名字是**装饰** ✓:`sameRoute` 对 workspace 路线只比**会话 id** ✓(名字过期也能落地 ✓);读不到工作区行就退回 `#/session/` ✓(不猜 ✓)。 - **行锚点的 href 也要带工作区** ✓(右键"复制链接"直接给出带归属的链接 ✓)。 - **作用域由视图决定,不靠猜**:主面板是应用级界面(列的是本机插件,不是某个对话),所以它的链接**不带**会话 id;只有会话级视图才写会话。 - **一个 boot 的权威窗口(8 秒)**:链接在窗口内压过两边各自的 localStorage 恢复,窗口过后视图重新拥有地址栏。 - **绝不抛错**:官方 `selectPanel` 对未注册的面板 id 是**抛异常**的,所有宿主面调用都必须被容纳(`attempt`/`guard`),失败只留一条诊断并回落到屏幕上真实的视图。 - 只用 `replaceState`;路径与查询串原样保留。 ## 行链接契约(改之前先读) - **整行即链接**:`[data-row-key^="session:"]` 的会话行会被挂上**覆盖整行的透明真锚点**(`data-dsh-part="url-router-link"`,作为行首子元素,`href="#/session/"`、`target="_blank"`)。右键=浏览器原生链接菜单,中键/修饰键=原生新标签,**左键单击只 `preventDefault` 后调官方导航**(原地切换、不新增历史记录)。 - **样式表两条规则**,其中 `:is(button,[role="button"],a,…):not(.dsh-url-router-link)` 的 `:not()` 是**止血点**:去掉它,这条规则会匹配锚点自身(锚点就是 ``),把它压成 0×0 的 relative 元素,右键就再也命中不到(历史事故,别删)。 - 锚点 href 必须用本插件自己的编码函数 `routeHash('session', id)`,不要另写一套。 - **找不到 DOM / 行属性不匹配一律静默 no-op**,绝不改官方行、绝不抛错;锚点点击里的导航也要 `try/catch`(官方导航可能抛)。 - 行选择器或属性一旦改动,必须同步改 `test/client.test.js` 里的假 DOM 用例(假 DOM 已包含 `fakeDom`/`openWithDom`)。 - **面板内部页是多段的**:`#/panel/plugins/<包>/<组件>`(每段单独 `encodeURIComponent`,所以原生的 `/` 一定是层级分隔)。钩子分**两组**,不要以为一套通吃:**已安装组** `li[data-plugin-package]` → `[data-plugin-detail]` → `li[data-plugin-row=":"]` → `[data-plugin-row-detail="<包>#"]`;**官方组** `li[data-plugin-item=""]` → `[data-plugin-item-detail=""]`(实测:官方项页面**没有** `data-plugin-detail`)。驱动一个段位时先找包行、再找官方行。**钩子有两种拼写**(`#` 与 `:`),解析统一走 `splitRowHook()`,别自己 `split`。 - **任务看板走它自己的契约**(由用户单仓 `dsh-task-board` 发布):`#/panel/task-board/task/<任务id>` 与 `#/panel/task-board/new`。反映读 `data-dsh-taskboard-open-task` / `data-dsh-taskboard-new-task`;驱动派发同名窗口事件;**表单优先于任务详情**。 - **观察者的 `attributeFilter` 必须列全所有已发布的页面钩子**:只列 `data-plugin-detail` 曾让任务看板的重试通知饿死(历史 bug,别删名单项)。 - **未落地的链接要有定时重试**(`LINK_RETRY_MS` = 500ms):只靠 DOM 通知不够,启动后界面会安静下来,一次被吞掉的重试就永远不来了。 - **切换主面板前先 `beginNavigation()`**(`ctx.layout` 的配套动作,可选面,缺失即跳过)。 - **设置面板是覆盖层**:`#/settings` ✓(裸段位 ✓,`ROUTE_PATTERN` 支持 `#/` ✓)与 `#/settings/<分区>` ✓。反映读 `[data-dsh-surface="settings"]` ✓;驱动点 `[data-slot="sidebar.settings"]` 的入口 ✓(兜底按可访问名 ✓)。 - **设置分区的身份 = 显示名**(实测无稳定 id ✗:导航项只有 `aria-current="true"` ✓,无 `id`/`data-*`/`aria-controls` ✗,右侧内容区无 `data-*` ✗)。反映:在覆盖层里找 `aria-current="true"` **且 `closest('nav') !== null`** 的按钮 ✓,取它的标签文字 ✓;驱动:按标签(先精确、再忽略大小写)点 ✓。**换语言/改文案会让旧分区链接失效** ✗(重新点一次即写出新链接 ✓)。 - **设置分区里的页签同理,再深一层** ✓:`#/settings/<分区>/<页签>`。契约是标准 ARIA ✓:`[role="tab"][aria-selected="true"]` ✓(实测 `dsh-usage` 的用量/个人套餐/Token 银行就是 `role="tab"` ✓),身份同样是**标签文字** ✓;驱动=先点分区、再点标签匹配的页签 ✓(同一次调用里做完 ✓,因为分区一点开,页签就在同一个覆盖层里 ✓)。 - **`aria-selected` 也必须观察** ✓(与 `aria-current` 同理:切换页签只改它 ✓)。 - **设置链接按"前缀"落地** ✓:裸 → 分区 → 页签逐级 ✓;命名了页签的链接必须看到那个页签 ✓,只命名到分区的链接被该分区任意页签满足 ✓。 - **`aria-current` 必须在 DOM 观察者的 `attributeFilter` 里** ✓:切换分区只改这一个属性 ✓,漏了它地址栏就永远停在第一次看到的分区(历史 bug ✓)。 - **裸 `#/settings` 立即可落地**(`sameRoute` 的定向例外 ✓):分区是那个表面的细化、不是另一个表面 ✓;但**两个命名分区之间仍然算不同** ✓(否则分区链接就不驱动了 ✓)。 - **轮次锚点是"位置",反映 + 可跳转**:点击聊天里的一轮(`[data-chat-turn]` ✓)把轮号写进地址栏 ✓;**绝不 preventDefault** ✗、**绝不碰交互元素**(button/a/input/textarea/select/[role=button]/[contenteditable] ✓ —— 逐个 `closest`,不用逗号选择器 ✓,因为小 DOM 桩可能不支持 ✓)。驱动侧**全链路已闭环** ✓:开会话 → 翻页回那一轮 → 滚到它(契约见下 ✓)。会话还没上屏时**只开会话、不 drive** ✗(没有座位可滚 = 静默无操作 ✗,实测过 ✗)。 - **右栏根上有 `data-sidebar-right-session="<会话 id>"`** ✓(右栏是**按会话**的 ✓,实测与路由里的会话 id 一致 ✓);**页签只读 `[data-dockkit-strip-tabs]`** ✓ —— 空态那层也有 `data-dockkit-pane="pane1"` ✗,读"任意 pane-active"会把空态当页签 ✗(历史 bug ✓)。 - **官方源码级契约(读 dsh-web-frontend / sidebar-right 得到)** ✓:页签是 `role="tab"` ✓,活动的是 `aria-selected="true"` ✓,身份在 **`data-dockkit-tab`** ✓ —— 而**该值就是 `pageAddress(kind)` = `sidebar://[/]`** ✓(`multiple: true` 的 kind 才拼 uuid ✓),所以 **kind 直接含在 tabId 里** ✓(`kindOfTabId()` 解 ✓)。`data-dockkit-pane` 的值是 **paneId**(`pane1` ✗),而且**主区也有同一套属性** ✗ → 读必须**限定在 `[data-sidebar-right-session]` 根内** ✓(否则读成主区的 pane ✗)。 - **控制器的两条路** ✓:`openTab(kind)` 要求**已挂载**(`require()` 抛 `no session surface is mounted` ✗);**`openTabIn(sessionId, kind, options)`** 才是显式带会话的公开路径 ✓(**参数顺序是 session 优先** ✗ 传反就成了"查一个叫 files 的会话" ✗ 历史 bug ✓)。`actionsFor(sessionId)` = `adopted.get(sessionId)?.store.actions` ✓,而 **adopt 发生在右栏被呈现时** ✓(`layout.openRightbar(track, fullscreen)` ✓,track = `session` | `panel` ✓)→ 所以驱动**先呈现右栏** ✓ 再 `openTabIn` ✓。 - **`isExpanded()`** = `mountedSurface().layout.expanded` ✓;`toggleExpanded()` 内部同样走 `require()` ✗(未挂载会抛 ✓,要 try ✗)。 - **旧守卫(已删)**:两条 pane 分支原来都有 `source !== 'hash'` ✗ → **粘贴 pane 链接不驱动** ✗(视图那次修了、pane 漏了 ✓)。 - **驱动三级链(旧记录)**:`sidebarRight.openTab(kind, { sessionId })` → `openTabIn(kind, sessionId)` → **guide 动作**(空态卡片背后那一步 ✓,`sidebarRightTabs.guideEntries` ✓)。**真机全部无效** ✗(零异常零诊断 ✓)→ 与 `uiConversation` 同一结论:**根插件拿到的是根控制器** ✗,会话内的那一个够不着 ✓ → 需要**会话作用域接缝** ✓。 - **右栏有两个服务,别混** ✓(实测两个面):**`sidebarRightTabs` = 注册表** ✓(`kinds` / `ids` / `entries` / `register` / `active` / `candidates` / `guide` ✓);**`sidebarRight` = 控制器** ✓(`openTab(kind, options)` / `openTabIn` / `close` / `closeIn` / `toggleExpanded` / `isExpanded` / `active` / `split` / `focus` / `screen` ✓)。读活动页签:控制器 `active` 优先 ✓(可能是值或方法 ✓)→ 注册表 `active` ✓ → 再 DOM 兜底 ✓。开页签:**必须走控制器** ✓(调注册表**静默失败** ✗,历史 bug ✓)。**真机仍未打开** ✗(9 个 kind 全试、零异常 ✓)→ 疑似**根控制器 vs 会话内控制器**(与 `uiConversation` 同类 ✓),需要会话作用域接缝 ✓。`paneKinds()` 已备好(供体检/预校验 ✓),暂未接进认领路径 ✓。 - **旧记录(仍在)**:DOM 兜底必须**只认真正属于右栏**的标记 ✓(`data-dockkit-pane-active` 是主区共用的 ✗,历史踩过 ✓)。该服务已加进 `inject` ✓ 与包声明的官方模块 id ✓。 - **链接落地即交权**:视图一旦等于链接描述的界面,链接立刻失去权威(只留"还没落地"的窗口期)。否则用户在面板里继续点进去时,地址栏会被拽回链接那一层(真机踩过:点组件行后又被改回包详情)。 - **面板行同样是链接**:`data-slot="sidebar.panellist"` 所在的那个 `