dsh-url-router 0.3.0 → 0.3.1

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/AGENTS.md CHANGED
@@ -44,7 +44,7 @@
44
44
  - **设置链接按"前缀"落地** ✓:裸 → 分区 → 页签逐级 ✓;命名了页签的链接必须看到那个页签 ✓,只命名到分区的链接被该分区任意页签满足 ✓。
45
45
  - **`aria-current` 必须在 DOM 观察者的 `attributeFilter` 里** ✓:切换分区只改这一个属性 ✓,漏了它地址栏就永远停在第一次看到的分区(历史 bug ✓)。
46
46
  - **裸 `#/settings` 立即可落地**(`sameRoute` 的定向例外 ✓):分区是那个表面的细化、不是另一个表面 ✓;但**两个命名分区之间仍然算不同** ✓(否则分区链接就不驱动了 ✓)。
47
- - **轮次锚点是"位置",只做反映**:点击聊天里的一轮(`[data-chat-turn]` ✓)把轮号写进地址栏 ✓;**绝不 preventDefault** ✗、**绝不碰交互元素**(button/a/input/textarea/select/[role=button]/[contenteditable] ✓ —— 逐个 `closest`,不用逗号选择器 ✓,因为小 DOM 桩可能不支持 ✓)。驱动侧只开会话 + 一条说明 ✓(官方没有对外跳转接缝 ✗)。
47
+ - **轮次锚点是"位置",反映 + 可跳转**:点击聊天里的一轮(`[data-chat-turn]` ✓)把轮号写进地址栏 ✓;**绝不 preventDefault** ✗、**绝不碰交互元素**(button/a/input/textarea/select/[role=button]/[contenteditable] ✓ —— 逐个 `closest`,不用逗号选择器 ✓,因为小 DOM 桩可能不支持 ✓)。驱动侧**全链路已闭环** ✓:开会话 → 翻页回那一轮 → 滚到它(契约见下 ✓)。会话还没上屏时**只开会话、不 drive** ✗(没有座位可滚 = 静默无操作 ✗,实测过 ✗)。
48
48
  - **右栏根上有 `data-sidebar-right-session="<会话 id>"`** ✓(右栏是**按会话**的 ✓,实测与路由里的会话 id 一致 ✓);**页签只读 `[data-dockkit-strip-tabs]`** ✓ —— 空态那层也有 `data-dockkit-pane="pane1"` ✗,读"任意 pane-active"会把空态当页签 ✗(历史 bug ✓)。
49
49
  - **官方源码级契约(读 dsh-web-frontend / sidebar-right 得到)** ✓:页签是 `role="tab"` ✓,活动的是 `aria-selected="true"` ✓,身份在 **`data-dockkit-tab`** ✓ —— 而**该值就是 `pageAddress(kind)` = `sidebar://<kind>[/<uuid>]`** ✓(`multiple: true` 的 kind 才拼 uuid ✓),所以 **kind 直接含在 tabId 里** ✓(`kindOfTabId()` 解 ✓)。`data-dockkit-pane` 的值是 **paneId**(`pane1` ✗),而且**主区也有同一套属性** ✗ → 读必须**限定在 `[data-sidebar-right-session]` 根内** ✓(否则读成主区的 pane ✗)。
50
50
  - **控制器的两条路** ✓:`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` ✓。
@@ -68,7 +68,16 @@
68
68
  - **路由体检(#7)**:一个 `window.__dshUrlRouter.report()` ✓(无 UI 依赖 ✓,绝不抛错 ✓),列出:当前 canonical 路由 ✓、正在落地的链接与重试次数 ✓、每一层界面**读到多少个钩子** ✓(会话行 / 工作区行 / 视图 tab 与 id 列表 / 轮次 / 轨迹行 / 轮次边界 / 插件行 / 官方项 / 设置覆盖层与分区 / 任务看板 / 右栏 pane ✓)、已发出的告警 ✓、以及被"不碰"的段位 ✓。
69
69
  - **对外接缝(#8)**:`ctx.urlRouter`(服务)或同名窗口事件 ✓,让第三方把"当前界面"写成链接 ✓,并由本插件**统一裁决** fragment 所有权 ✓ —— 这同时也是 #4 的真正解法 ✓。
70
70
  - **面板内层页面(#5)**:技能中心 / SSH 需要各自发契约 ✓(`data-*` + 接受同名窗口事件 ✓);任务看板是现成先例 ✓,可作为 PR 往上游推 ✓(需要各仓 owner 同意 ✓)。
71
- - **跳转到某一轮(官方)**:已发讨论 <https://github.com/deepseek-ai/deepseek-harness/discussions/9339> ✓(请求一个 `scrollToTurn(turn)` 服务或 `dsh-chat-scroll-to-turn` 窗口事件 ✓;退路是驱动 rail 刻度点击 ✗ 虚拟化下不可靠 ✓)。
71
+ - **跳转到某一轮:已用官方公开契约打通,无需新接缝** ✓。链路:
72
+ `faces.sessions.retainScope(sessionId, SRC)` → `reference.binding.session` →
73
+ `session.projections.faceOf('turnOutline').getSnapshot()`(条目 `{turn, seq, prompt, response}` ✓ → 取目标轮的 `seq` ✓)→
74
+ `session.loadThrough(seq)` → `reference.release()` → 再 `scrollIntoView` ✓。
75
+ - **`sessions` 必须在 `inject` 里声明** ✗ 否则拿不到 ✗(与 `uiConversation` 同一规矩 ✓);**`probeFaces` 必须把它读进 faces** ✗(曾漏读 → 真机 `retainScope` 恒 undefined → 白烧六轮 ✗)。
76
+ - **根插件拿不到 `sessions.binding()`** ✗(实测 undefined ✓):会话作用域的控制器只能靠 `retainScope` 物化 ✓。
77
+ - **座位/视口判据用官方同款选择器** ✓:`[data-chat-turn]:not([hidden])` ✓,并自己排除 `closest('[hidden]')` ✓ —— **虚拟列表会把未载入的轮次也渲染进 DOM** ✗(只查"存在"会假报就位 ✗,实测 ✗)。
78
+ - **`sameRoute` 对 turn 的落地判据 = 目标真在视口** ✓(`routeKey` 比法会让 canonical 一带上 turn 就判等 → 链接永不驱动 ✗,死锁实测 ✓)。
79
+ - **drive 会重入 reconcile** ✓,重入可能已 `dropLink()` → **守门后立刻 `const link = linked`,体内一律用快照** ✗(否则 `reading 'inner'` ✓,实测 ✗)。
80
+ - `loadThrough` 是**公开 slot 契约** ✓(`session-controller` 1885 注释 "page backwards until the window covers seq" ✓)。官方 `uiChat.scrollToTurn(sessionId, turn)` 那条请求(讨论 #9339 ✓)**已降级为锦上添花** ✓(不阻塞 ✓)。
72
81
 
73
82
  ## 已实现:#4 外部 fragment 共存 / #7 路由体检(2026-10-11)
74
83
 
package/README.md CHANGED
@@ -64,11 +64,11 @@ dsh plugin --profile web add "link:/path/to/dsh-url-router"
64
64
 
65
65
  ### 已知限制
66
66
 
67
- - **轮次链接是"位置"不是"界面"**:点某一轮,地址栏记下 `#/session/<id>/turn/<n>` ✓;但**粘贴它只会打开那个对话,不会滚到那一轮** ✗ —— 官方把"跳到某轮"(`scrollToTurn` / `JUMP_PAGE_OPTIONS`)留在 chat 包**内部**,没有对外接缝 ✗,逐页翻也不可靠 ✗(实测「Load earlier」按钮在两个会话上都没加载出更早的轮次 ✗)。要真跳转,需要官方开一个服务或槽位 ✓。
67
+ - **轮次链接是"位置",而且现在真能跳** ✓:点某一轮,地址栏记下 `#/session/<id>/turn/<n>` ✓;**粘贴它会打开那个对话、把历史翻页回那一轮、再滚到它** ✓(真机实测:贴 `/turn/118` → 窗口变成 118→121、目标在视口 true、最顶可见正是 118 ✓)。用的是**官方已有公开契约** ✓:`sessions.retainScope(id, source)` 拿到会话控制器 ✓ → `session.projections.faceOf('turnOutline')` 读到那一轮的 `seq` ✓ → `session.loadThrough(seq)` 翻页回去 ✓(完事 `reference.release()` ✓)→ 再按官方视口同款选择器 `[data-chat-turn]:not([hidden])` 滚到它 ✓。座位高度/相交都读不到时**不算到位** ✓,宁可继续重试也不假报 ✗。
68
68
  - **右栏 pane 已闭环** ✓:`#/session/<id>/pane/<kind>` 与 `#/panel/<面板>/pane/<kind>` ✓(真机验证:贴链接开出对应页签 ✓、地址栏跟着切换 ✓)。页签种类由右栏自己注册 ✓(实测 7 种:`guide` / `subagentchat` / `changes-review` / `plan` / `files` / `text` / `terminal` ✓)。
69
69
  - **设置面板已纳入**:`#/settings` ✓、**`#/settings/<分区>`** ✓、**`#/settings/<分区>/<页签>`** ✓(页签靠标准 `role="tab"` + `aria-selected` ✓,分区内任何页签都吃这一套 ✓)。分区**没有稳定 id** ✗(实测:导航项上只有 `aria-current="true"` ✓,父链是 `nav` ✓,右侧内容区一个 `data-*` 都没有 ✗;官方内部的 `openSection(id)` 也不对外 ✗)→ 所以**分区的身份就是它的显示名** ✓:反映读「`aria-current="true"` 且属于 `nav`」的那个按钮的**标签文字** ✓,驱动按标签点 ✓。含义:**换语言或官方改文案后,旧的分区链接会失效** ✗(但你重新点一次,地址栏就会写出新链接 ✓ —— 自洽、不过期 ✓)。裸 `#/settings` 只要面板开着就算落地 ✓,随后地址栏自动升级为具体分区 ✓。
70
70
  - **设置页分区暂未纳入(旧描述)**:设置包没有"打开某个分区"的接缝(它们是 keyed list slot),只能等它自己发布类似契约。
71
- - **会话内的轮次与行锚点已纳入** ✓:`#/session/<id>/turn/<n>` ✓(只做反映 ✓)、`#/session/<id>/row/<类>/<作用域>/<id>` ✓(反映 + 尽力滚动 ✓,含可证明时的轮次归属 ✓)。**跳转仍然做不到** ✗(官方把 `scrollToTurn` 留在包内部 ✓,已向官方提 <https://github.com/deepseek-ai/deepseek-harness/discussions/9339> ✓)。
71
+ - **会话内的轮次与行锚点已纳入** ✓:`#/session/<id>/turn/<n>` ✓(**反映 + 翻页 + 滚动 ✓ 全链路已闭环**)、`#/session/<id>/row/<类>/<作用域>/<id>` ✓(反映 + 尽力滚动 ✓,含可证明时的轮次归属 ✓)。轮次跳转用的是官方公开 API ✓,**没有等待任何新接缝** ✓(`uiChat.scrollToTurn` 那条请求已降级为锦上添花 ✓,讨论见 <https://github.com/deepseek-ai/deepseek-harness/discussions/9339> ✓)。
72
72
  - **技能中心 / SSH 的内部页**:需要它们各自发布"当前实体 id + 按 id 打开"的契约(任务看板就是这么做的)。
73
73
  - **和别的 fragment 写者互斥**(例如写 `#msg=` 的"跳到消息"插件):一个 URL 只有一个 fragment ✓,两个写者只能轮流拥有 ✗。本插件**不假装合并** ✓ —— 它认出外来 fragment ✓、**绝不覆盖** ✓,并明确说"只能启用一个写者" ✓(而不是互相静默擦掉对方的链接 ✗)。
74
74
  - 链接**同名不跨机**:会话 id 只在同一台机器、同一个 `DSH_HOME` 下有意义。
@@ -123,4 +123,4 @@ Conversation rows and both plugin-list groups carry a transparent full-row ancho
123
123
 
124
124
  ### Limits
125
125
 
126
- Still open: jumping to a named turn (the official chat keeps `scrollToTurn` inside its own package — asked for a seam in discussion #9339), the right-hand panes (wired, but the right bar starts empty in this composition so they are not verified end to end yet), and Skill Center / SSH inner pages, which need their own "publish the current id + accept an open request" contract the way the task board did. Session links are meaningful on the same machine and `DSH_HOME` only.
126
+ Still open: Skill Center / SSH inner pages, which need their own "publish the current id + accept an open request" contract the way the task board did. Session links are meaningful on the same machine and `DSH_HOME` only.
package/lib/client.js CHANGED
@@ -123,7 +123,7 @@ window.__ModuleLoader__.load({
123
123
  * The router's own version, for the report a human files. A test keeps it equal to the
124
124
  * package version, so a release cannot forget to bump it.
125
125
  */
126
- const ROUTER_VERSION = '0.3.0'
126
+ const ROUTER_VERSION = '0.3.1'
127
127
 
128
128
  const TAG = '[url-router]'
129
129
 
@@ -133,24 +133,22 @@ window.__ModuleLoader__.load({
133
133
  * @param {object} faces - the probed host faces.
134
134
  * @returns {string[]|undefined} the ids, or undefined when the registry is not readable.
135
135
  */
136
+ /**
137
+ * The conversation view ids the registry publishes, in registration order.
138
+ *
139
+ * `uiConversation` is a **root** client service (measured: `new UiConversation(ctx)` with
140
+ * `super(ctx, "uiConversation")`), and `views` is a public field on it — so the ids come
141
+ * straight from the registry instead of being aligned to the slot by position.
142
+ * @param {object} faces - the probed host faces.
143
+ * @returns {string[]|undefined} the ids, or undefined when the registry is not readable.
144
+ */
136
145
  function conversationViewIdsOf(faces) {
137
- // The conversation's own `uiConversation` registry is scoped to the conversation and is
138
- // not visible from a root plugin (measured: undefined), but the view tabs are contributed
139
- // through the `conversation.view` slot, and that *is* readable — with an id and an order.
140
- const entries = attemptValue(() => faces.slots?.entries?.('conversation.view'))
141
- if (!Array.isArray(entries)) return undefined
142
- const rows = []
143
- for (const entry of entries) {
144
- if (entry === null || typeof entry !== 'object') continue
145
- const options = entry.options ?? entry
146
- const id = options.id ?? entry.id
147
- if (typeof id !== 'string' || id === '') continue
148
- const order = typeof options.order === 'number' ? options.order : 0
149
- rows.push({ id, order })
150
- }
151
- if (rows.length === 0) return undefined
152
- rows.sort((left, right) => left.order - right.order)
153
- return rows.map(row => row.id)
146
+ const rows = attemptValue(() => faces.uiConversation?.views?.entries?.())
147
+ if (!Array.isArray(rows)) return undefined
148
+ const ids = rows
149
+ .map(row => (row === null || typeof row !== 'object' ? undefined : row.target ?? row.id))
150
+ .filter(id => typeof id === 'string' && id !== '')
151
+ return ids.length === 0 ? undefined : ids
154
152
  }
155
153
 
156
154
  /**
@@ -203,6 +201,37 @@ window.__ModuleLoader__.load({
203
201
  * @param {object} faces - the probed host faces.
204
202
  * @returns {{kind: string, label: string|undefined, multiple: boolean}[]|undefined} the rows.
205
203
  */
204
+ /**
205
+ * The seq of a turn's start event, from the `turnOutline` projection.
206
+ *
207
+ * The projection is a public read on the session controller (the core reads it the same
208
+ * way: `session.projections.faceOf("turnOutline").getSnapshot()`), and each entry carries
209
+ * the `seq` of that turn's `turn/start` — which is exactly what `loadThrough` pages back
210
+ * to. Reading it is what makes a turn link work for a turn that is not loaded yet.
211
+ * @param {object} faces - the probed host faces.
212
+ * @param {string|undefined} sessionId - the session the turn belongs to.
213
+ * @param {string} turn - the turn number.
214
+ * @returns {number|undefined} the seq, when the projection names that turn.
215
+ */
216
+ function turnSeqOf(faces, sessionId, turn) {
217
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined
218
+ // Reached by retaining the session's scope: `sessions.binding(id)` answers undefined
219
+ // for a session nobody materialised yet (measured), while `retainScope(id, source)`
220
+ // materialises it and returns a reference whose `binding` is the live one. Released here
221
+ // because only the projection is read.
222
+ const reference = attemptValue(() => faces.sessions?.retainScope?.(sessionId, 'url-router'))
223
+ const outline = attemptValue(() => session?.projections?.faceOf?.('turnOutline')?.getSnapshot?.())
224
+ attempt(() => reference?.release?.())
225
+ if (!Array.isArray(outline)) return undefined
226
+ const wanted = String(turn)
227
+ for (const entry of outline) {
228
+ if (entry === null || typeof entry !== 'object') continue
229
+ if (String(entry.turn) !== wanted) continue
230
+ return typeof entry.seq === 'number' ? entry.seq : undefined
231
+ }
232
+ return undefined
233
+ }
234
+
206
235
  function paneRegistryOf(faces) {
207
236
  const rows = attemptValue(() => faces.sidebarRightTabs?.entries?.())
208
237
  if (!Array.isArray(rows)) return undefined
@@ -369,6 +398,11 @@ window.__ModuleLoader__.load({
369
398
  if (sessionId === '') return { unknown: `workspace/${match[2]}` }
370
399
  // The view a conversation is showing is part of the conversation, so it may follow.
371
400
  const tail = rest.slice(2)
401
+ // `turn` is a position like `row` and `pane`, and it composes with `row`
402
+ // (`turn/<n>/row/…`): without this branch a workspace-shaped turn link lands in
403
+ // `unknown` and is never claimed at all.
404
+ const turnAt = tail.indexOf(TURN_SEGMENT)
405
+ if (turnAt >= 0 && tail.length > turnAt + 1 && tail[turnAt + 1].trim() !== '') return { kind: 'workspace', id: name, inner: innerPath([WORKSPACE_SESSION_SEGMENT, sessionId, ...tail]) }
372
406
  if (tail.length === 0) return { kind: 'workspace', id: name, inner: innerPath([WORKSPACE_SESSION_SEGMENT, sessionId]) }
373
407
  if (tail.length < 2 || tail[1].trim() === '') return { unknown: `workspace/${match[2]}` }
374
408
  if (tail[0] === VIEW_SEGMENT && tail.length === 2) return { kind: 'workspace', id: name, inner: innerPath([WORKSPACE_SESSION_SEGMENT, sessionId, VIEW_SEGMENT, tail[1]]) }
@@ -527,6 +561,7 @@ window.__ModuleLoader__.load({
527
561
  let rowAnchor
528
562
  /** The pane kind this claim already opened (one drive per claim, see the pane branch). */
529
563
  let paneOpenedFor
564
+ /** A position the previous step asked to bring into view, driven on the next reconcile. */
530
565
  let turnAnchor
531
566
  /** How many times the linked panel was selected (bounded, see below). */
532
567
  let panelAttempts = 0
@@ -589,13 +624,19 @@ window.__ModuleLoader__.load({
589
624
  // A pointed-at row is the most specific position there is, then the view, then the
590
625
  // turn: they are all "where inside this conversation", so one of them is written.
591
626
  const view = host.activeConversationView()
592
- const position = rowAnchor !== undefined && rowAnchor.session === id
593
- ? rowAnchor.turn === undefined || rowAnchor.turn === ''
594
- ? [ROW_SEGMENT, ...rowAnchor.parts]
595
- : [TURN_SEGMENT, rowAnchor.turn, ROW_SEGMENT, ...rowAnchor.parts]
596
- : view !== undefined && view.index !== 0
597
- ? [VIEW_SEGMENT, view.id]
598
- : []
627
+ // A pointed-at row is a position, then the view. A *turn* deliberately is not: while a
628
+ // turn link is in flight the address bar belongs to it, and once it lands the link hands
629
+ // the address bar back — naming the turn here as well is what made a link look "landed"
630
+ // before it had ever driven (a real bug, live-measured).
631
+ // A pointed-at row is a position, then the view, then the turn the user pointed at —
632
+ // but never while a turn link is in flight: that link owns the address bar until it lands,
633
+ // and naming the turn here too is what made it look "landed" before it ever drove.
634
+ const anchoredTurn = linked === undefined && turnAnchor !== undefined && turnAnchor.session === id && turnAnchor.turn !== undefined && turnAnchor.turn !== '' ? [TURN_SEGMENT, turnAnchor.turn] : []
635
+ const position = [
636
+ ...(rowAnchor !== undefined && rowAnchor.session === id ? (rowAnchor.turn === undefined || rowAnchor.turn === '' ? [ROW_SEGMENT, ...rowAnchor.parts] : [TURN_SEGMENT, rowAnchor.turn, ROW_SEGMENT, ...rowAnchor.parts]) : []),
637
+ ...(anchoredTurn.length > 0 && rowAnchor !== undefined && rowAnchor.session === id ? [] : anchoredTurn),
638
+ ...(view !== undefined && view.index !== 0 ? [VIEW_SEGMENT, view.id] : []),
639
+ ]
599
640
  // The right-hand pane is its own layer, not a position inside the conversation:
600
641
  // it is named next to whatever the main column shows, never instead of it — so it
601
642
  // is read *before* the workspace branch, or the workspace would swallow it.
@@ -609,9 +650,6 @@ window.__ModuleLoader__.load({
609
650
  if (tail.length > 0) return { kind: 'session', id, inner: innerPath([...position, ...tail]) }
610
651
  // The turn the user last pointed at is a position inside this conversation;
611
652
  // it is only named while the same conversation is the one on screen.
612
- if (turnAnchor !== undefined && turnAnchor.session === id) {
613
- return { kind: 'session', id, inner: innerPath([TURN_SEGMENT, turnAnchor.turn]) }
614
- }
615
653
  return { kind: 'session', id }
616
654
  }
617
655
  return undefined
@@ -644,6 +682,9 @@ window.__ModuleLoader__.load({
644
682
  return at < 0 ? undefined : parts[at + 1]
645
683
  }
646
684
  const sameRoute = (current, linked) => {
685
+ // A drive re-enters this machine, and re-entry can find the link already dropped: a link
686
+ // that is no longer in flight is never "the same route" as anything.
687
+ if (linked === undefined || linked === null) return false
647
688
  if (routeKey(current) === routeKey(linked)) return true
648
689
  const currentSession = sessionIdOfRoute(current)
649
690
  const linkedSession = sessionIdOfRoute(linked)
@@ -655,7 +696,27 @@ window.__ModuleLoader__.load({
655
696
  // route that names the same conversation is satisfied by it being on screen —
656
697
  // the workspace name is decoration, not a condition.
657
698
  const parts = innerPath(linked.inner)
658
- if (parts.includes(TURN_SEGMENT) || parts.includes(ROW_SEGMENT)) return routeKey(current) === routeKey(linked)
699
+ if (parts.includes(TURN_SEGMENT)) {
700
+ // A turn link lands when that turn is really in view. Comparing routes here called it
701
+ // landed while the seat was still outside the viewport (live-measured): the address bar
702
+ // is what the *link* already guarantees, so the route comparison proved nothing.
703
+ // Unknown geometry counts as "not in view" — the safe side: it keeps the drive alive
704
+ // rather than dropping a link that has not moved anything yet.
705
+ const turnWanted = parts[parts.indexOf(TURN_SEGMENT) + 1]
706
+ const seat = attemptValue(() => document?.querySelector?.(`[${TURN_ATTRIBUTE}="${turnWanted}"]`))
707
+ if (seat === null || seat === undefined) return false
708
+ // The official viewport reads exactly this: a seat that is hidden, or inside anything
709
+ // hidden, is not on screen — and the virtual list does render such rows (measured: the
710
+ // seat for the wanted turn existed while the turn was plainly not loaded).
711
+ if (seat.hidden === true) return false
712
+ if (attemptValue(() => seat.closest?.('[hidden]')) !== null && attemptValue(() => seat.closest?.('[hidden]')) !== undefined) return false
713
+ const rect = attemptValue(() => seat.getBoundingClientRect?.())
714
+ if (rect === null || rect === undefined) return false
715
+ const viewHeight = attemptValue(() => window.innerHeight)
716
+ if (typeof viewHeight !== 'number' || !(viewHeight > 0)) return false
717
+ return rect.height > 0 && rect.bottom > 0 && rect.top < viewHeight
718
+ }
719
+ if (parts.includes(ROW_SEGMENT)) return routeKey(current) === routeKey(linked)
659
720
  // A pane is its own layer, named next to the conversation: the route *shape* (with or
660
721
  // without the workspace) is decoration, exactly like the workspace name — what has to
661
722
  // match is the pane kind itself.
@@ -701,6 +762,7 @@ window.__ModuleLoader__.load({
701
762
  */
702
763
  const reconcile = (source = 'boot') => {
703
764
  if (disposed) return
765
+
704
766
  const current = canonical()
705
767
  const currentKey = routeKey(current)
706
768
  const moved = currentKey !== seen
@@ -712,11 +774,18 @@ window.__ModuleLoader__.load({
712
774
  if (paneTabs > 0 && host.activePaneKind() === undefined) {
713
775
  notePaneShape(message => host.warn(message), 'pane-shape', `the right bar shows ${paneTabs} tab(s) but this build cannot read the active one; the first tab is: ${activePaneTabOf.observed ?? 'no attributes read'}`)
714
776
  }
777
+ // A position the previous step asked for: it is driven here, one step later, so the step
778
+ // that claimed it never drives while it is still running.
715
779
  if (linked === undefined) {
716
780
  reflect(current)
717
781
  return
718
782
  }
719
- if (sameRoute(current, linked)) {
783
+ // Snapshot the link for the rest of this step. Driving a position re-enters this machine
784
+ // (and re-entry can drop the link), so every read below must use the snapshot: reading the
785
+ // live binding again is how a drive turned into "Cannot read properties of undefined".
786
+ const link = linked
787
+
788
+ if (sameRoute(current, link)) {
720
789
  dropRetry()
721
790
  // The link has landed: it stops being authoritative right here, so the user
722
791
  // keeps owning the address bar afterwards (walking deeper inside a panel
@@ -725,8 +794,8 @@ window.__ModuleLoader__.load({
725
794
  reflect(current)
726
795
  return
727
796
  }
728
- if (host.now() >= linked.deadline) {
729
- const hash = routeHash(linked.kind, linked.id, linked.inner)
797
+ if (host.now() >= link.deadline) {
798
+ const hash = routeHash(link.kind, link.id, link.inner)
730
799
  host.warn(`${TAG} ${hash} did not become the shown view within ${LINK_ENFORCE_WINDOW_MS} ms; the link was ignored`)
731
800
  dropLink()
732
801
  reflect(canonical())
@@ -734,10 +803,10 @@ window.__ModuleLoader__.load({
734
803
  }
735
804
  // A conversation link may name which view it wants, on either route shape; the view is
736
805
  // driven before the plain conversation/workspace branches, or they would swallow it.
737
- const rowParts = innerPath(linked.inner)
806
+ const rowParts = innerPath(link.inner)
738
807
  const rowAt = rowParts.indexOf(ROW_SEGMENT)
739
808
  const wantedTurn = rowParts.indexOf(TURN_SEGMENT)
740
- const rowSession = linked.kind === 'workspace' ? rowParts[1] : linked.kind === 'session' ? linked.id : undefined
809
+ const rowSession = link.kind === 'workspace' ? rowParts[1] : link.kind === 'session' ? link.id : undefined
741
810
  if (rowSession !== undefined && rowAt >= 0 && rowParts.length > rowAt + 2) {
742
811
  const shown = host.mainViewSessionId()
743
812
  if (shown !== rowSession && panelAttempts < INNER_ATTEMPT_LIMIT) {
@@ -745,13 +814,13 @@ window.__ModuleLoader__.load({
745
814
  host.openSession(rowSession)
746
815
  } else if (shown === rowSession && innerAttempts < INNER_ATTEMPT_LIMIT) {
747
816
  innerAttempts += 1
748
- if (!host.revealRow(rowParts.slice(rowAt)) && wantedTurn >= 0) host.revealRow(rowParts.slice(wantedTurn, wantedTurn + 2))
817
+ if (!host.revealPosition(rowParts.slice(rowAt)) && wantedTurn >= 0) host.revealPosition(rowParts.slice(wantedTurn, wantedTurn + 2), rowSession)
749
818
  }
750
819
  armRetry()
751
820
  return
752
821
  }
753
- const wantedView = linked.kind === 'session' || linked.kind === 'workspace' ? viewIdOfRoute(linked) : undefined
754
- const wantedSession = linked.kind === 'workspace' ? innerPath(linked.inner)[1] : linked.kind === 'session' ? linked.id : undefined
822
+ const wantedView = link.kind === 'session' || link.kind === 'workspace' ? viewIdOfRoute(link) : undefined
823
+ const wantedSession = link.kind === 'workspace' ? innerPath(link.inner)[1] : link.kind === 'session' ? link.id : undefined
755
824
  if (wantedView !== undefined && wantedSession !== undefined) {
756
825
  // Unlike the boot restore, a named view is driven whatever brought the route in —
757
826
  // a pasted view link exists precisely to switch the view, so it must not be skipped.
@@ -766,10 +835,10 @@ window.__ModuleLoader__.load({
766
835
  armRetry()
767
836
  return
768
837
  }
769
- if (linked.kind === 'workspace') {
838
+ if (link.kind === 'workspace') {
770
839
  // The workspace is where the conversation lives, not a separate surface: the link
771
840
  // is satisfied by that conversation being on screen, whatever the name says.
772
- const target = innerPath(linked.inner)[1]
841
+ const target = innerPath(link.inner)[1]
773
842
  const shown = host.mainViewSessionId()
774
843
  if (shown !== target && source !== 'hash' && panelAttempts < INNER_ATTEMPT_LIMIT) {
775
844
  panelAttempts += 1
@@ -778,42 +847,67 @@ window.__ModuleLoader__.load({
778
847
  armRetry()
779
848
  return
780
849
  }
781
- if (linked.kind === 'settings') {
850
+ if (link.kind === 'settings') {
782
851
  if (source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
783
852
  innerAttempts += 1
784
- host.openSettings(linked.id, innerPath(linked.inner)[0])
853
+ host.openSettings(link.id, innerPath(link.inner)[0])
785
854
  }
786
855
  armRetry()
787
856
  return
788
857
  }
789
- if (linked.kind === 'session' && linked.inner !== undefined && innerPath(linked.inner)[0] === PANE_SEGMENT) {
858
+ if (link.kind === 'session' && link.inner !== undefined && innerPath(link.inner)[0] === PANE_SEGMENT) {
790
859
  // A pasted pane link is exactly what this drive is for, so the source is not a filter
791
860
  // here (the same lesson as the view branch); the attempt bound still limits it.
792
- const wantedPane = innerPath(linked.inner)[1]
861
+ const wantedPane = innerPath(link.inner)[1]
793
862
  // A page type that may be opened more than once (measured: `multiple: true`, the
794
863
  // terminal) makes *every* attempt open another tab, so one claim drives once and the
795
864
  // retry then only waits for the bar to report the tab it opened.
796
865
  if (innerAttempts < INNER_ATTEMPT_LIMIT && paneOpenedFor !== wantedPane) {
797
866
  innerAttempts += 1
798
867
  paneOpenedFor = wantedPane
799
- host.openPane(wantedPane, linked.kind === 'panel' ? 'panel' : 'session', message => host.warn(message))
868
+ host.openPane(wantedPane, link.kind === 'panel' ? 'panel' : 'session', message => host.warn(message))
800
869
  }
801
870
  armRetry()
802
871
  return
803
872
  }
804
- if (linked.kind === 'session' && linked.inner !== undefined && innerPath(linked.inner)[0] === TURN_SEGMENT) {
805
- // A turn link is a position, not a surface: the conversation is opened, and the
806
- // view is told once that this build does not scroll to the turn (see README).
807
- // Record the position so the canonical form keeps it, then fall through to the
808
- // ordinary conversation branch: a turn link opens the conversation, and saying so
809
- // in the console would only be noise.
810
- turnAnchor = { session: linked.id, turn: innerPath(linked.inner)[1] }
873
+ if (link.kind === 'session' && link.inner !== undefined && innerPath(link.inner)[0] === TURN_SEGMENT) {
874
+ // A turn link is a position, and a drivable one: the conversation is opened and the turn
875
+ // is brought into view in the same step, because the link lands as soon as the address
876
+ // bar names it. A turn that is not loaded yet is paged back through its seq.
877
+ //
878
+ // The position is recorded only *after* the drive: recording it first would make the
879
+ // canonical form equal the link already, so a link that has not moved anything would
880
+ // look "landed" and never be driven (a real bug, live-measured).
881
+ const wantedTurn = innerPath(link.inner)[1]
882
+ const wantedSession = link.id
883
+ // Drive first, record second. Recording first would make the canonical form equal the
884
+ // link on the *next* reconcile — before the drive ever ran, so the link would look
885
+ // "landed" and the position would never be brought into view (live-measured). Driving
886
+ // first means the work is done by the time the form can match.
887
+ if (innerAttempts < INNER_ATTEMPT_LIMIT) {
888
+ innerAttempts += 1
889
+ // The conversation has to be on screen before anything inside it can be brought into
890
+ // view: driving a turn against a conversation that is not shown is a silent no-op, which
891
+ // is what a cold-opened turn link used to do. So navigate first, and drive only once the
892
+ // conversation is really the main view.
893
+ // Bring the conversation up *and* drive the position, the way a row link does: the drive is
894
+ // idempotent on a conversation that is not shown yet (no seats, nothing to scroll to, and
895
+ // the paging path is a no-op too), and waiting for the main-view counter to catch up is
896
+ // what left a cold-opened turn link doing nothing at all.
897
+ host.openSession(wantedSession)
898
+ host.revealPosition(innerPath([TURN_SEGMENT, wantedTurn]), wantedSession)
899
+ // A row may follow the turn (`turn/<n>/row/…`): reveal it after the turn.
900
+ const rowAt = innerPath(link.inner).indexOf(ROW_SEGMENT)
901
+ if (rowAt >= 0) host.revealPosition(innerPath(link.inner).slice(rowAt), wantedSession)
902
+ }
903
+ turnAnchor = { session: wantedSession, turn: wantedTurn }
904
+ return
811
905
  }
812
- if (linked.kind === 'panel') {
813
- if (linked.inner !== undefined && innerPath(linked.inner)[0] === PANE_SEGMENT) {
906
+ if (link.kind === 'panel') {
907
+ if (link.inner !== undefined && innerPath(link.inner)[0] === PANE_SEGMENT) {
814
908
  if (innerAttempts < INNER_ATTEMPT_LIMIT) {
815
909
  innerAttempts += 1
816
- host.openPane(innerPath(linked.inner)[1], linked.kind === 'panel' ? 'panel' : 'session', message => host.warn(message))
910
+ host.openPane(innerPath(link.inner)[1], link.kind === 'panel' ? 'panel' : 'session', message => host.warn(message))
817
911
  }
818
912
  armRetry()
819
913
  return
@@ -823,7 +917,7 @@ window.__ModuleLoader__.load({
823
917
  // panel store moves (a late-loading plugin), not on every catalog tick.
824
918
  // `current` may be undefined when nothing is on screen yet; reading through it
825
919
  // unconditionally would abort the whole step inside the guard.
826
- const panelShown = current !== undefined && current.kind === 'panel' && current.id === linked.id
920
+ const panelShown = current !== undefined && current.kind === 'panel' && current.id === link.id
827
921
  if (!panelShown) {
828
922
  // Selecting a panel can be refused or swallowed while the shell is still
829
923
  // settling, so the request is retried (on later notifications and on a
@@ -834,7 +928,7 @@ window.__ModuleLoader__.load({
834
928
  // in flight first (the layout exposes exactly that pair), otherwise the
835
929
  // selection can be swallowed by the boot restore.
836
930
  host.beginNavigation()
837
- host.selectPanel(linked.id)
931
+ host.selectPanel(link.id)
838
932
  }
839
933
  armRetry()
840
934
  return
@@ -845,24 +939,24 @@ window.__ModuleLoader__.load({
845
939
  // once that panel has rendered: the request is retried on every later
846
940
  // notification (not on the hash read itself, to avoid a double request),
847
941
  // with a bound so an unopenable page cannot turn into a click loop.
848
- if (linked.inner !== undefined && routeKey(current) !== routeKey(linked) && source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
942
+ if (link.inner !== undefined && routeKey(current) !== routeKey(link) && source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
849
943
  innerAttempts += 1
850
- host.openPanelPage(linked.id, linked.inner)
944
+ host.openPanelPage(link.id, link.inner)
851
945
  }
852
946
  armRetry()
853
947
  return
854
948
  }
855
949
  // A session link: refuse immediately when the conversation cannot be opened at
856
950
  // all, otherwise hand the navigation over once and wait for the view to land.
857
- if (host.isArchived(linked.id)) {
858
- host.warn(`${TAG} ${routeHash(linked.kind, linked.id)} is archived; the link was ignored`)
951
+ if (host.isArchived(link.id)) {
952
+ host.warn(`${TAG} ${routeHash(link.kind, link.id)} is archived; the link was ignored`)
859
953
  dropLink()
860
954
  reflect(canonical())
861
955
  return
862
956
  }
863
- if ((!claimed || moved) && host.isOpenable(linked.id)) {
957
+ if ((!claimed || moved) && host.isOpenable(link.id)) {
864
958
  claimed = true
865
- host.openSession(linked.id)
959
+ host.openSession(link.id)
866
960
  }
867
961
  }
868
962
  /** Adopt the surface the address bar names, if it names one. */
@@ -1384,7 +1478,7 @@ window.__ModuleLoader__.load({
1384
1478
  // browser wiring: probe the official faces, then drive the machine.
1385
1479
  // ---------------------------------------------------------------------
1386
1480
  /** Services this plugin needs before apply runs; `layout` is optional and read through `ctx.get`. */
1387
- const inject = ['sessions', 'workspaces', 'uiWorkspace', 'slots', 'sidebarRightTabs']
1481
+ const inject = ['sessions', 'workspaces', 'uiWorkspace', 'slots', 'sidebarRightTabs', 'uiConversation']
1388
1482
 
1389
1483
  /**
1390
1484
  * Probe the faces the router needs.
@@ -1397,6 +1491,11 @@ window.__ModuleLoader__.load({
1397
1491
  */
1398
1492
  function probeFaces(scope) {
1399
1493
  const list = scope.sessions?.list
1494
+ // The session controller itself (not just its catalog): the turn drive needs
1495
+ // `retainScope(id, source)` to reach a session that is not loaded yet. It lives in `inject`
1496
+ // for the same reason `uiConversation` does — cordis only hands a service to a plugin that
1497
+ // declared it — and reading it here is what makes the paging path reachable at all.
1498
+ const sessions = scope.sessions
1400
1499
  const registry = scope.workspaces?.list
1401
1500
  const navigation = scope.uiWorkspace
1402
1501
  if (typeof list !== 'object' || list === null) return null
@@ -1422,6 +1521,9 @@ window.__ModuleLoader__.load({
1422
1521
  const openRightbar = typeof layout?.openRightbar === 'function' ? layout.openRightbar.bind(layout) : undefined
1423
1522
  const sidebarRightTabs = typeof scope.get === 'function' ? scope.get('sidebarRightTabs') : undefined
1424
1523
  const sidebarRight = typeof scope.get === 'function' ? scope.get('sidebarRight') : undefined
1524
+ // `uiConversation` is a root service, but cordis only hands it to a plugin that
1525
+ // declared it in `inject` — declaring it is what makes the view registry readable.
1526
+ const uiConversation = typeof scope.get === 'function' ? scope.get('uiConversation') : undefined
1425
1527
  return {
1426
1528
  faces: {
1427
1529
  catalog: list,
@@ -1432,6 +1534,8 @@ window.__ModuleLoader__.load({
1432
1534
  openRightbar: openRightbar === undefined ? undefined : track => openRightbar(track),
1433
1535
  sidebarRightTabs,
1434
1536
  sidebarRight,
1537
+ uiConversation,
1538
+ sessions,
1435
1539
  panels,
1436
1540
  slots,
1437
1541
  },
@@ -1459,7 +1563,7 @@ window.__ModuleLoader__.load({
1459
1563
  const ids = attempt(() => faces.archivedSessionIds(), [])
1460
1564
  return Array.isArray(ids) ? ids : []
1461
1565
  }
1462
- return {
1566
+ const host = {
1463
1567
  mainViewSessionId: () => attempt(() => mainViewSessionId(faces.catalog.getSnapshot().byId), undefined),
1464
1568
  // The sidebar lists workspaces and their sessions in one tree, in order, so the
1465
1569
  // workspace a conversation belongs to is the nearest workspace row before it.
@@ -1600,7 +1704,19 @@ window.__ModuleLoader__.load({
1600
1704
  return before
1601
1705
  },
1602
1706
  // Best effort: scroll to the named row when it is rendered.
1603
- revealRow: parts => {
1707
+ /**
1708
+ * Bring the named position into view.
1709
+ *
1710
+ * A trajectory row is scrolled to when it is rendered. A *turn* needs history paged
1711
+ * back first, and that part is a public slot contract rather than a guess:
1712
+ * `session.loadThrough(seq)` pages backwards until the window covers the seq of that
1713
+ * turn's start event (measured in the core, and the same path the chat view uses). The
1714
+ * seq comes from the `turnOutline` projection, which is readable through the session
1715
+ * controller's projections face.
1716
+ * @param {Array} parts - the inner path segments.
1717
+ * @returns {boolean} whether the position was brought into view.
1718
+ */
1719
+ revealPosition: (parts, wantedSession) => {
1604
1720
  const wanted = innerPath(parts)
1605
1721
  const document = window.document
1606
1722
  if (wanted[0] === ROW_SEGMENT && wanted.length >= 3) {
@@ -1612,14 +1728,40 @@ window.__ModuleLoader__.load({
1612
1728
  }
1613
1729
  return false
1614
1730
  }
1615
- if (wanted[0] === TURN_SEGMENT) {
1616
- for (const candidate of document?.querySelectorAll?.(`[${TRAJECTORY_TURN_ATTRIBUTE}]`) ?? []) {
1617
- if (candidate.getAttribute?.(TRAJECTORY_TURN_ATTRIBUTE) !== wanted[1]) continue
1618
- attempt(() => candidate.scrollIntoView?.({ block: 'center' }))
1619
- return true
1620
- }
1731
+ if (wanted[0] !== TURN_SEGMENT) return false
1732
+ const turn = wanted[1]
1733
+ // 1) Already rendered: the chat seats carry `data-chat-turn` (what the official
1734
+ // ChatNodeSeat prints), so scroll to it and be done.
1735
+ // Official viewport selector: a seat that is hidden, or inside anything hidden, is not on
1736
+ // screen — and the virtual list renders such seats for turns that are plainly not loaded.
1737
+ for (const candidate of document?.querySelectorAll?.(`[${TURN_ATTRIBUTE}]:not([hidden])`) ?? []) {
1738
+ if (candidate.getAttribute?.(TURN_ATTRIBUTE) !== turn) continue
1739
+ attempt(() => candidate.scrollIntoView?.({ block: 'center' }))
1740
+ return true
1741
+ }
1742
+ // 2) Not loaded yet: page history back through that turn's seq, then its seat renders.
1743
+ //
1744
+ // The session controller is reached by *retaining* its scope: `sessions.binding(id)`
1745
+ // answers undefined for a session nobody materialised yet (measured), while
1746
+ // `retainScope(id, source)` materialises it and returns a reference whose `binding`
1747
+ // is the live one. The reference is released as soon as the page-back is asked for, so
1748
+ // retaining costs nothing when the turn turns out to be unloadable.
1749
+ // The session the position belongs to is passed in by the caller, because the turn that
1750
+ // drove it may not be the main view *yet* (a link that just opened it): reading it back
1751
+ // from the catalog here is how this step used to look up "no session".
1752
+ const reference = attemptValue(() => faces.sessions?.retainScope?.(wantedSession, 'url-router'))
1753
+ const session = attemptValue(() => reference?.binding?.session)
1754
+ const loadThrough = attemptValue(() => (session === undefined || session === null ? undefined : session.loadThrough))
1755
+ const outline = attemptValue(() => session?.projections?.faceOf?.('turnOutline')?.getSnapshot?.())
1756
+ const seq = Array.isArray(outline) ? outline.find(entry => entry && String(entry.turn) === String(turn))?.seq : undefined
1757
+ const usable = typeof loadThrough === 'function' && typeof reference?.release === 'function'
1758
+ if (typeof seq !== 'number' || !usable) {
1759
+ attemptValue(() => reference?.release?.())
1760
+ return false
1621
1761
  }
1622
- return false
1762
+ attemptValue(() => loadThrough(seq))
1763
+ attemptValue(() => reference.release())
1764
+ return true
1623
1765
  },
1624
1766
  // Which conversation view is showing. The registry is the source of truth for ids
1625
1767
  // (`ctx.uiConversation.views`), the header renders its tabs in registration order,
@@ -1904,6 +2046,7 @@ window.__ModuleLoader__.load({
1904
2046
  }, () => {}),
1905
2047
  warn: message => console.warn(message),
1906
2048
  }
2049
+ return host
1907
2050
  }
1908
2051
 
1909
2052
  /**
@@ -1923,6 +2066,17 @@ window.__ModuleLoader__.load({
1923
2066
  console.warn(`${TAG} the layout service exposes no panel store; only conversation routes are routed`)
1924
2067
  }
1925
2068
  return startUrlRouting(browserHost(probed.faces))
2069
+ // The session-scoped seat. A conversation's jump loader (`session.loadThrough(seq)`) lives
2070
+ // on the session controller, and a root plugin only reaches a session by *contributing* to
2071
+ // a session-scoped slot — the same seat the official chat and the family views use
2072
+ // (`inject: (sessionId) => …`). The entry itself draws nothing: it exists so the callback
2073
+ // runs with the session in hand. The entry itself is inert (it renders nothing and adds
2074
+ // nothing to the tail), and it rides a `list` slot so it can never become the chosen view.
2075
+ // (A session-scoped seat was tried here: contributing to a session-scoped slot is how a
2076
+ // root plugin reaches a session controller. It did not fire — measured live — and the
2077
+ // session-scoped slots that *would* fire (`conversation.view`) always add a visible tab,
2078
+ // so the seat was withdrawn. `sessionFor` stays as the seam for when a seam exists.)
2079
+ probed.faces.sessionFor = () => undefined
1926
2080
  }, 'url-router: fragment routing')
1927
2081
  // Row links: every conversation row is exposed as a real link to its route, so
1928
2082
  // the browser's own link menu (open in new tab / copy link) works on it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-url-router",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "DSH Web GUI 地址栏路由插件:每个界面都有自己的链接(会话 / 主面板 / 插件页与组件页 / 任务看板),整行即真链接,只写 fragment。",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,7 +30,8 @@
30
30
  "@deepseek-ai/dsh-api-workspace-controller",
31
31
  "@deepseek-ai/dsh-client-ui-workspace",
32
32
  "@deepseek-ai/dsh-client-ui-layout",
33
- "@deepseek-ai/dsh-client-ui-sidebar-right"
33
+ "@deepseek-ai/dsh-client-ui-sidebar-right",
34
+ "@deepseek-ai/dsh-client-ui-conversation"
34
35
  ]
35
36
  },
36
37
  "compatibility": {