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 +11 -2
- package/README.md +3 -3
- package/lib/client.js +226 -72
- package/package.json +3 -2
package/AGENTS.md
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
- **设置链接按"前缀"落地** ✓:裸 → 分区 → 页签逐级 ✓;命名了页签的链接必须看到那个页签 ✓,只命名到分区的链接被该分区任意页签满足 ✓。
|
|
45
45
|
- **`aria-current` 必须在 DOM 观察者的 `attributeFilter` 里** ✓:切换分区只改这一个属性 ✓,漏了它地址栏就永远停在第一次看到的分区(历史 bug ✓)。
|
|
46
46
|
- **裸 `#/settings` 立即可落地**(`sameRoute` 的定向例外 ✓):分区是那个表面的细化、不是另一个表面 ✓;但**两个命名分区之间仍然算不同** ✓(否则分区链接就不驱动了 ✓)。
|
|
47
|
-
- **轮次锚点是"位置"
|
|
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
|
-
-
|
|
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
|
-
- **轮次链接是"位置"
|
|
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>`
|
|
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:
|
|
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.
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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)
|
|
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
|
-
|
|
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() >=
|
|
729
|
-
const hash = routeHash(
|
|
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(
|
|
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 =
|
|
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.
|
|
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 =
|
|
754
|
-
const wantedSession =
|
|
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 (
|
|
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(
|
|
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 (
|
|
850
|
+
if (link.kind === 'settings') {
|
|
782
851
|
if (source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
|
|
783
852
|
innerAttempts += 1
|
|
784
|
-
host.openSettings(
|
|
853
|
+
host.openSettings(link.id, innerPath(link.inner)[0])
|
|
785
854
|
}
|
|
786
855
|
armRetry()
|
|
787
856
|
return
|
|
788
857
|
}
|
|
789
|
-
if (
|
|
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(
|
|
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,
|
|
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 (
|
|
805
|
-
// A turn link is a position,
|
|
806
|
-
// view
|
|
807
|
-
//
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
|
|
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 (
|
|
813
|
-
if (
|
|
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(
|
|
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 ===
|
|
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(
|
|
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 (
|
|
942
|
+
if (link.inner !== undefined && routeKey(current) !== routeKey(link) && source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
|
|
849
943
|
innerAttempts += 1
|
|
850
|
-
host.openPanelPage(
|
|
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(
|
|
858
|
-
host.warn(`${TAG} ${routeHash(
|
|
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(
|
|
957
|
+
if ((!claimed || moved) && host.isOpenable(link.id)) {
|
|
864
958
|
claimed = true
|
|
865
|
-
host.openSession(
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|