dsh-url-router 0.1.2 → 0.2.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/AGENTS.md CHANGED
@@ -13,6 +13,8 @@
13
13
 
14
14
  - **本插件是地址栏的唯一写者**。任何其它写 fragment 的插件都不得同时启用(`dsh-session-url` 就是这样一个插件)。
15
15
  - **段位**:`#/session/<id>`(会话,`uiWorkspace.openSession`)、`#/panel/<panelId>`(主面板,`ctx.layout.selectPanel`)。输入侧接受复数与百分号编码,规范化成单数形式;**不认识的段位一律不碰**,只留一条诊断。
16
+ - **工作区路线**:`#/workspace/<工作区名>/session/<会话id>`(侧栏形状 ✓)。工作区行是 `data-row-key="workspace:<uuid>"` ✓、**行文本就是工作区名** ✓(无 `data-workspace-*` ✗,实测 ✓);会话所属工作区 = 树里**它之前最近的那个 workspace 行** ✓(`workspaceNameOf()` ✓,行锚点与反映共用 ✓)。名字是**装饰** ✓:`sameRoute` 对 workspace 路线只比**会话 id** ✓(名字过期也能落地 ✓);读不到工作区行就退回 `#/session/<id>` ✓(不猜 ✓)。
17
+ - **行锚点的 href 也要带工作区** ✓(右键"复制链接"直接给出带归属的链接 ✓)。
16
18
  - **作用域由视图决定,不靠猜**:主面板是应用级界面(列的是本机插件,不是某个对话),所以它的链接**不带**会话 id;只有会话级视图才写会话。
17
19
  - **一个 boot 的权威窗口(8 秒)**:链接在窗口内压过两边各自的 localStorage 恢复,窗口过后视图重新拥有地址栏。
18
20
  - **绝不抛错**:官方 `selectPanel` 对未注册的面板 id 是**抛异常**的,所有宿主面调用都必须被容纳(`attempt`/`guard`),失败只留一条诊断并回落到屏幕上真实的视图。
@@ -31,6 +33,15 @@
31
33
  - **观察者的 `attributeFilter` 必须列全所有已发布的页面钩子**:只列 `data-plugin-detail` 曾让任务看板的重试通知饿死(历史 bug,别删名单项)。
32
34
  - **未落地的链接要有定时重试**(`LINK_RETRY_MS` = 500ms):只靠 DOM 通知不够,启动后界面会安静下来,一次被吞掉的重试就永远不来了。
33
35
  - **切换主面板前先 `beginNavigation()`**(`ctx.layout` 的配套动作,可选面,缺失即跳过)。
36
+ - **设置面板是覆盖层**:`#/settings` ✓(裸段位 ✓,`ROUTE_PATTERN` 支持 `#/<kind>` ✓)与 `#/settings/<分区>` ✓。反映读 `[data-dsh-surface="settings"]` ✓;驱动点 `[data-slot="sidebar.settings"]` 的入口 ✓(兜底按可访问名 ✓)。
37
+ - **设置分区的身份 = 显示名**(实测无稳定 id ✗:导航项只有 `aria-current="true"` ✓,无 `id`/`data-*`/`aria-controls` ✗,右侧内容区无 `data-*` ✗)。反映:在覆盖层里找 `aria-current="true"` **且 `closest('nav') !== null`** 的按钮 ✓,取它的标签文字 ✓;驱动:按标签(先精确、再忽略大小写)点 ✓。**换语言/改文案会让旧分区链接失效** ✗(重新点一次即写出新链接 ✓)。
38
+ - **设置分区里的页签同理,再深一层** ✓:`#/settings/<分区>/<页签>`。契约是标准 ARIA ✓:`[role="tab"][aria-selected="true"]` ✓(实测 `dsh-usage` 的用量/个人套餐/Token 银行就是 `role="tab"` ✓),身份同样是**标签文字** ✓;驱动=先点分区、再点标签匹配的页签 ✓(同一次调用里做完 ✓,因为分区一点开,页签就在同一个覆盖层里 ✓)。
39
+ - **`aria-selected` 也必须观察** ✓(与 `aria-current` 同理:切换页签只改它 ✓)。
40
+ - **设置链接按"前缀"落地** ✓:裸 → 分区 → 页签逐级 ✓;命名了页签的链接必须看到那个页签 ✓,只命名到分区的链接被该分区任意页签满足 ✓。
41
+ - **`aria-current` 必须在 DOM 观察者的 `attributeFilter` 里** ✓:切换分区只改这一个属性 ✓,漏了它地址栏就永远停在第一次看到的分区(历史 bug ✓)。
42
+ - **裸 `#/settings` 立即可落地**(`sameRoute` 的定向例外 ✓):分区是那个表面的细化、不是另一个表面 ✓;但**两个命名分区之间仍然算不同** ✓(否则分区链接就不驱动了 ✓)。
43
+ - **轮次锚点是"位置",只做反映**:点击聊天里的一轮(`[data-chat-turn]` ✓)把轮号写进地址栏 ✓;**绝不 preventDefault** ✗、**绝不碰交互元素**(button/a/input/textarea/select/[role=button]/[contenteditable] ✓ —— 逐个 `closest`,不用逗号选择器 ✓,因为小 DOM 桩可能不支持 ✓)。驱动侧只开会话 + 一条说明 ✓(官方没有对外跳转接缝 ✗)。
44
+ - **右栏 pane 走 `sidebarRightTabs` 服务**:`activeTabId` 读 ✓、`openTab(kind)` 驱动 ✓;DOM 兜底必须**只认真正属于右栏**的标记 ✓(`data-dockkit-pane-active` 是主区共用的 ✗,历史踩过 ✓)。该服务已加进 `inject` ✓ 与包声明的官方模块 id ✓。
34
45
  - **链接落地即交权**:视图一旦等于链接描述的界面,链接立刻失去权威(只留"还没落地"的窗口期)。否则用户在面板里继续点进去时,地址栏会被拽回链接那一层(真机踩过:点组件行后又被改回包详情)。
35
46
  - **面板行同样是链接**:`data-slot="sidebar.panellist"` 所在的那个 `<button>`(官方侧栏渲染的面板行)会挂上 `href="#/panel/<id>"` 的锚点。**行里没有 id**,id 只能从注册表取:`ctx.slots.entries('sidebar.panellist')` 的 `options.id` / `options.order` / `options.label`;label 可能是**函数**(按语言解析),所以优先按可访问名匹配、数量一致时按 order 对齐,匹配不上一律不碰。
36
47
  - **`slots` 必须留在 `inject` 里**:cordis 下读未声明的服务会抛错,而插件激活期的抛错会让整个插件 `failed`(真机事故:面板链接那次就是这么挂的——假 ctx 无条件给服务,单测抓不到,只有真机能抓)。其余可选面(如 `ctx.layout`)一律 `try/catch` 读取。
package/README.md CHANGED
@@ -54,13 +54,17 @@ dsh plugin --profile web add "link:/path/to/dsh-url-router"
54
54
  - **只动 fragment**:路径与查询串从不改写(启动 `?token=…` 能活下来)。
55
55
  - **唯一写者**:本插件拥有 fragment,请**不要**和别的写 fragment 的插件同时启用(例如 `dsh-session-url`),两者会互相覆盖。
56
56
  - **只用 `replaceState`**:浏览面板不污染后退栈。
57
+ - **工作区是"它住在哪",所以写在链接最前面** ✓:`#/workspace/<工作区名>/session/<会话id>`(侧栏本身就是这个形状 ✓)。工作区名是**给眼睛看的** ✓ —— 决定打开哪个会话的永远是会话 id ✓,所以**名字过期/写错也照样打开对的会话** ✓(地址栏随后自己纠正成真实工作区 ✓)。读不到工作区行(没有侧栏 / 树里没有)就**不猜** ✓,退回 `#/session/<id>` ✓。
57
58
  - **作用域由视图决定,不靠猜**:主面板是应用级界面(列的是本机插件,不是某个对话),所以它的链接不带会话 id。
58
59
  - **链接落地即交权**:视图一旦变成链接描述的那个界面,链接立刻失去权威——这样你在面板里继续点进去时,地址栏不会被拽回上一层。
59
60
  - **绝不抛错**:官方 `selectPanel` 对未注册的面板 id 是抛异常的;所有宿主面调用都被容纳,失败只留一条诊断并回落到屏幕上真实的视图。
60
61
 
61
62
  ### 已知限制
62
63
 
63
- - **设置页分区暂未纳入**:设置包没有"打开某个分区"的接缝(它们是 keyed list slot),只能等它自己发布类似契约。
64
+ - **轮次链接是"位置"不是"界面"**:点某一轮,地址栏记下 `#/session/<id>/turn/<n>` ✓;但**粘贴它只会打开那个对话,不会滚到那一轮** ✗ —— 官方把"跳到某轮"(`scrollToTurn` / `JUMP_PAGE_OPTIONS`)留在 chat 包**内部**,没有对外接缝 ✗,逐页翻也不可靠 ✗(实测「Load earlier」按钮在两个会话上都没加载出更早的轮次 ✗)。要真跳转,需要官方开一个服务或槽位 ✓。
65
+ - **右栏 pane 已接入但未真机验证**:`#/panel/<面板>/pane/<页签 kind>` ✓(读 `sidebarRightTabs` 的 `activeTabId` ✓,驱动用它的 `openTab` ✓)。**注意**:本机组合里右栏没有任何页签注册 ✗,所以只跑过单测 ✓;页签 kind 由注册方决定 ✓(例如官方 `-files` / `-terminal` / `-browser` / `-documentpreview` 包注册的那些 ✓)。
66
+ - **设置面板已纳入**:`#/settings` ✓、**`#/settings/<分区>`** ✓、**`#/settings/<分区>/<页签>`** ✓(页签靠标准 `role="tab"` + `aria-selected` ✓,分区内任何页签都吃这一套 ✓)。分区**没有稳定 id** ✗(实测:导航项上只有 `aria-current="true"` ✓,父链是 `nav` ✓,右侧内容区一个 `data-*` 都没有 ✗;官方内部的 `openSection(id)` 也不对外 ✗)→ 所以**分区的身份就是它的显示名** ✓:反映读「`aria-current="true"` 且属于 `nav`」的那个按钮的**标签文字** ✓,驱动按标签点 ✓。含义:**换语言或官方改文案后,旧的分区链接会失效** ✗(但你重新点一次,地址栏就会写出新链接 ✓ —— 自洽、不过期 ✓)。裸 `#/settings` 只要面板开着就算落地 ✓,随后地址栏自动升级为具体分区 ✓。
67
+ - **设置页分区暂未纳入(旧描述)**:设置包没有"打开某个分区"的接缝(它们是 keyed list slot),只能等它自己发布类似契约。
64
68
  - **右栏 pane 与会话内轮次/消息锚点暂未纳入**。
65
69
  - **技能中心 / SSH 的内部页**:需要它们各自发布"当前实体 id + 按 id 打开"的契约(任务看板就是这么做的)。
66
70
  - 链接**同名不跨机**:会话 id 只在同一台机器、同一个 `DSH_HOME` 下有意义。
package/lib/client.js CHANGED
@@ -63,9 +63,15 @@ window.__ModuleLoader__.load({
63
63
  // another router's route may legitimately be pasted in.
64
64
  // ---------------------------------------------------------------------
65
65
  /** Route kind aliases accepted on input. */
66
- const ROUTE_ALIASES = { session: 'session', sessions: 'session', panel: 'panel', panels: 'panel' }
67
- /** Canonical fragment shape. */
68
- const ROUTE_PATTERN = /^#\/?([a-z][a-z0-9-]*)\/(.+)$/
66
+ const ROUTE_ALIASES = { session: 'session', sessions: 'session', panel: 'panel', panels: 'panel', settings: 'settings', workspace: 'workspace', workspaces: 'workspace' }
67
+ /** Attribute the settings overlay puts on its root element. */
68
+ const SETTINGS_SURFACE_ATTRIBUTE = 'data-dsh-surface'
69
+ /** Value of that attribute for the settings overlay. */
70
+ const SETTINGS_SURFACE_VALUE = 'settings'
71
+ /** Slot the settings entry is rendered into (its button opens the overlay). */
72
+ const SETTINGS_TRIGGER_SLOT = 'sidebar.settings'
73
+ /** Canonical fragment shape, plus the bare `#/<kind>` form a modal surface uses. */
74
+ const ROUTE_PATTERN = /^#\/?([a-z][a-z0-9-]*)(?:\/(.+))?$/
69
75
  /** How often an unsatisfied link is retried while it is authoritative. */
70
76
  const LINK_RETRY_MS = 500
71
77
  /** How many times an inner page may be requested while its link is authoritative. */
@@ -82,11 +88,47 @@ window.__ModuleLoader__.load({
82
88
  const TASK_BOARD_NEW_ATTRIBUTE = 'data-dsh-taskboard-new-task'
83
89
  /** Window event that asks the task board for its new-task form. */
84
90
  const TASK_BOARD_NEW_EVENT = 'dsh-taskboard-new-task'
91
+ /** Sidebar row key prefix for a workspace (the value is its id; the row text is its name). */
92
+ const WORKSPACE_ROW_PREFIX = 'workspace:'
93
+ /** Inner segment that marks the conversation inside a workspace route. */
94
+ const WORKSPACE_SESSION_SEGMENT = 'session'
95
+ /** Attribute the chat view puts on every rendered turn node (the turn number). */
96
+ const TURN_ATTRIBUTE = 'data-chat-turn'
97
+ /** Inner segment that names the turn a conversation is showing. */
98
+ const TURN_SEGMENT = 'turn'
99
+ /** Inner segment that names the right-hand pane in front. */
100
+ const PANE_SEGMENT = 'pane'
101
+ /** Service the right-hand pane registers, whose snapshot names the active tab. */
102
+ const SIDEBAR_RIGHT_SERVICE = 'sidebarRightTabs'
85
103
  /** Panel id of the official plugin manager, whose list rows and detail pages are routed. */
86
104
  const PLUGINS_PANEL_ID = 'plugins'
87
105
  /** Log prefix for every diagnostic. */
88
106
  const TAG = '[url-router]'
89
107
 
108
+ /**
109
+ * The workspace a sidebar row belongs to: the nearest workspace row before it, read from the
110
+ * row text. Returns undefined when the tree says nothing, so no caller has to guess.
111
+ * @param {object} row - the conversation row.
112
+ * @param {object} document - the document the tree lives in.
113
+ * @returns {string|undefined} the workspace display name.
114
+ */
115
+ function workspaceNameOf(row, document) {
116
+ const rows = document?.querySelectorAll?.('[data-row-key]')
117
+ if (rows === undefined || rows === null) return undefined
118
+ let name
119
+ for (const candidate of rows) {
120
+ const key = typeof candidate.getAttribute === 'function' ? candidate.getAttribute('data-row-key') : null
121
+ if (typeof key !== 'string') continue
122
+ if (key.startsWith(WORKSPACE_ROW_PREFIX)) {
123
+ name = (candidate.textContent ?? '').trim()
124
+ continue
125
+ }
126
+ if (candidate !== row) continue
127
+ return name === undefined || name === '' ? undefined : name
128
+ }
129
+ return undefined
130
+ }
131
+
90
132
  /**
91
133
  * Read a route out of a location fragment.
92
134
  * @param {string} hash - the raw `window.location.hash`, including the leading `#`.
@@ -103,13 +145,37 @@ window.__ModuleLoader__.load({
103
145
  function parseRoute(hash) {
104
146
  const match = ROUTE_PATTERN.exec(hash)
105
147
  if (match === null) return undefined
148
+ const kind = ROUTE_ALIASES[match[1]]
149
+ if (kind === undefined) return { unknown: match[1] }
150
+ // A modal surface needs no identity: `#/settings` is the whole route.
151
+ if (match[2] === undefined) return kind === 'settings' ? { kind, id: '' } : undefined
152
+ // A conversation can be named inside its workspace — the shape the sidebar itself shows,
153
+ // and the only way a link says *where* a conversation lives.
154
+ if (kind === 'workspace') {
155
+ const parts = match[2].replace(/\/+$/, '').split('/')
156
+ const name = decodePart(parts.shift() ?? '').trim()
157
+ if (name === '') return { unknown: `workspace/${match[2]}` }
158
+ const rest = parts.map(decodePart).filter(segment => segment.trim() !== '')
159
+ if (rest.length < 2 || rest[0] !== WORKSPACE_SESSION_SEGMENT) return { unknown: `workspace/${match[2]}` }
160
+ const sessionId = rest[1].trim()
161
+ if (sessionId === '') return { unknown: `workspace/${match[2]}` }
162
+ return { kind: 'workspace', id: name, inner: innerPath([WORKSPACE_SESSION_SEGMENT, sessionId]) }
163
+ }
106
164
  const segments = match[2].replace(/\/+$/, '').split('/')
107
165
  const head = segments.shift() ?? ''
108
- if (head === '') return undefined
166
+ if (head === '') return kind === 'settings' ? { kind, id: '' } : undefined
109
167
  const id = decodePart(head).trim()
110
- if (id === '') return undefined
111
- const kind = ROUTE_ALIASES[match[1]]
112
- if (kind === undefined) return { unknown: match[1] }
168
+ if (id === '') return kind === 'settings' ? { kind, id: '' } : undefined
169
+ // A settings section has no stable id in the DOM (measured: the nav item carries only
170
+ // aria-current), so its identity is its *label* — the string the user actually sees and
171
+ // clicks. Round-tripping the label keeps the link self-consistent; a locale change
172
+ // renames the sections and the address bar simply writes the new label next time.
173
+ if (kind === 'settings') {
174
+ // A section may hold tabs of its own (measured: `role="tab"` + `aria-selected`), so
175
+ // the rest of the path names the tab — again by its label.
176
+ const rest = segments.map(decodePart).filter(segment => segment.trim() !== '')
177
+ return rest.length === 0 ? { kind, id } : { kind, id, inner: rest }
178
+ }
113
179
  // A panel may have an inner page (a plugin's detail view, for instance): the
114
180
  // rest of the path names it, per panel.
115
181
  const inner = segments.map(decodePart).filter(segment => segment.trim() !== '')
@@ -123,6 +189,18 @@ window.__ModuleLoader__.load({
123
189
  * @returns {string} the `#/<kind>/<encoded id>` fragment.
124
190
  */
125
191
  function routeHash(kind, id, inner) {
192
+ // A modal surface has no identity: `#/settings`, not `#/settings/`.
193
+ if (kind === 'settings' && (id === undefined || id === '')) return '#/settings'
194
+ if (kind === 'workspace') {
195
+ const rest = innerPath(inner)
196
+ const tail = rest.length === 0 ? '' : `/${rest.map(encodeURIComponent).join('/')}`
197
+ return `#/workspace/${encodeURIComponent(id)}${tail}`
198
+ }
199
+ if (kind === 'settings') {
200
+ const head = `#/settings/${encodeURIComponent(id)}`
201
+ const rest = innerPath(inner)
202
+ return rest.length === 0 ? head : `${head}/${rest.map(encodeURIComponent).join('/')}`
203
+ }
126
204
  const base = `#/${kind}/${encodeURIComponent(id)}`
127
205
  if (inner === undefined || inner === '') return base
128
206
  const segments = innerPath(inner)
@@ -228,6 +306,8 @@ window.__ModuleLoader__.load({
228
306
  let claimed = false
229
307
  /** The package segment of the deepest panel page the address bar carried. */
230
308
  let panelScope
309
+ /** The turn the user last clicked inside a conversation, with its session. */
310
+ let turnAnchor
231
311
  /** How many times the linked panel was selected (bounded, see below). */
232
312
  let panelAttempts = 0
233
313
  /** How many times a panel link's inner page was requested (bounded, see below). */
@@ -255,9 +335,20 @@ window.__ModuleLoader__.load({
255
335
  * conversation behind it, because the panel is what fills the screen.
256
336
  */
257
337
  const canonical = () => {
338
+ // The settings overlay covers the main column, so while it is up it is what is on
339
+ // screen; when it closes the ordinary surface takes the address bar back.
340
+ if (window.document?.querySelector?.(`[${SETTINGS_SURFACE_ATTRIBUTE}="${SETTINGS_SURFACE_VALUE}"]`) != null) {
341
+ const section = host.activeSettingsSection() ?? ''
342
+ const tab = section === '' ? undefined : host.activeSettingsTab()
343
+ return tab === undefined ? { kind: 'settings', id: section } : { kind: 'settings', id: section, inner: [tab] }
344
+ }
258
345
  const panelId = host.activePanelId()
259
346
  if (panelId !== undefined && panelId !== null) {
260
347
  const id = String(panelId)
348
+ // A right-hand pane in front is part of what is on screen, so it is named first:
349
+ // it is true whether or not the panel itself reports an inner page.
350
+ const paneInPanel = host.activePaneKind()
351
+ if (paneInPanel !== undefined) return { kind: 'panel', id, inner: innerPath([PANE_SEGMENT, paneInPanel]) }
261
352
  const page = host.panelPage(id, panelScope)
262
353
  if (page === undefined) return { kind: 'panel', id, inner: undefined }
263
354
  const segments = innerPath(page)
@@ -267,10 +358,48 @@ window.__ModuleLoader__.load({
267
358
  return { kind: 'panel', id, inner: segments }
268
359
  }
269
360
  const session = host.mainViewSessionId()
270
- if (session !== undefined && session !== null) return { kind: 'session', id: String(session) }
361
+ if (session !== undefined && session !== null) {
362
+ const id = String(session)
363
+ // The workspace is part of *where* the conversation lives, so it leads the route.
364
+ // Reading it from the sidebar keeps this honest: no sidebar (or no matching row)
365
+ // means the plain conversation route, never a guess.
366
+ const workspaceName = host.workspaceNameOf(id)
367
+ if (workspaceName !== undefined) {
368
+ return { kind: 'workspace', id: workspaceName, inner: innerPath([WORKSPACE_SESSION_SEGMENT, id]) }
369
+ }
370
+ const pane = host.activePaneKind()
371
+ if (pane !== undefined) return { kind: 'session', id, inner: innerPath([PANE_SEGMENT, pane]) }
372
+ // The turn the user last pointed at is a position inside this conversation;
373
+ // it is only named while the same conversation is the one on screen.
374
+ if (turnAnchor !== undefined && turnAnchor.session === id) {
375
+ return { kind: 'session', id, inner: innerPath([TURN_SEGMENT, turnAnchor.turn]) }
376
+ }
377
+ return { kind: 'session', id }
378
+ }
271
379
  return undefined
272
380
  }
273
- const sameRoute = (left, right) => routeKey(left) === routeKey(right)
381
+ // Two routes are the same surface when they compare equal — plus one refinement: a bare
382
+ // `#/settings` names the overlay, and whichever section it happens to show is that same
383
+ // surface, so the link lands at once instead of sitting there until its window expires.
384
+ // A *named* section against a different named section is a real difference, so a section
385
+ // link still drives the overlay to the section it asks for.
386
+ const sameRoute = (left, right) => {
387
+ if (routeKey(left) === routeKey(right)) return true
388
+ // A workspace route is about the conversation it names; the workspace name is what the
389
+ // link *shows*, so a stale name must not keep the link from landing.
390
+ if (left?.kind === 'workspace' || right?.kind === 'workspace') {
391
+ if (left?.kind !== right?.kind) return false
392
+ return innerPath(left.inner)[1] === innerPath(right.inner)[1]
393
+ }
394
+ if (left?.kind !== 'settings' || right?.kind !== 'settings') return false
395
+ if (left.id === '' || right.id === '') return true
396
+ // One level deeper: the same section is satisfied while a link names no tab, but a
397
+ // link that names a tab has to see that tab.
398
+ if (left.id !== right.id) return false
399
+ const leftTab = innerPath(left.inner)[0] ?? ''
400
+ const rightTab = innerPath(right.inner)[0] ?? ''
401
+ return leftTab === '' || rightTab === ''
402
+ }
274
403
  /** Write the fragment for the current view, leaving path and query untouched. */
275
404
  const reflect = route => {
276
405
  const { pathname, search, hash } = host.readLocation()
@@ -313,7 +442,51 @@ window.__ModuleLoader__.load({
313
442
  reflect(canonical())
314
443
  return
315
444
  }
445
+ if (linked.kind === 'workspace') {
446
+ // The workspace is where the conversation lives, not a separate surface: the link
447
+ // is satisfied by that conversation being on screen, whatever the name says.
448
+ const target = innerPath(linked.inner)[1]
449
+ const shown = host.mainViewSessionId()
450
+ if (shown !== target && source !== 'hash' && panelAttempts < INNER_ATTEMPT_LIMIT) {
451
+ panelAttempts += 1
452
+ host.openSession(target)
453
+ }
454
+ armRetry()
455
+ return
456
+ }
457
+ if (linked.kind === 'settings') {
458
+ if (source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
459
+ innerAttempts += 1
460
+ host.openSettings(linked.id, innerPath(linked.inner)[0])
461
+ }
462
+ armRetry()
463
+ return
464
+ }
465
+ if (linked.kind === 'session' && linked.inner !== undefined && innerPath(linked.inner)[0] === PANE_SEGMENT) {
466
+ if (source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
467
+ innerAttempts += 1
468
+ host.openPane(innerPath(linked.inner)[1])
469
+ }
470
+ armRetry()
471
+ return
472
+ }
473
+ if (linked.kind === 'session' && linked.inner !== undefined && innerPath(linked.inner)[0] === TURN_SEGMENT) {
474
+ // A turn link is a position, not a surface: the conversation is opened, and the
475
+ // view is told once that this build does not scroll to the turn (see README).
476
+ // Record the position so the canonical form keeps it, then fall through to the
477
+ // ordinary conversation branch: a turn link opens the conversation, and saying so
478
+ // in the console would only be noise.
479
+ turnAnchor = { session: linked.id, turn: innerPath(linked.inner)[1] }
480
+ }
316
481
  if (linked.kind === 'panel') {
482
+ if (linked.inner !== undefined && innerPath(linked.inner)[0] === PANE_SEGMENT) {
483
+ if (source !== 'hash' && innerAttempts < INNER_ATTEMPT_LIMIT) {
484
+ innerAttempts += 1
485
+ host.openPane(innerPath(linked.inner)[1])
486
+ }
487
+ armRetry()
488
+ return
489
+ }
317
490
  // Panels are selected by id and report back through the panel store; an id
318
491
  // nobody registered never becomes active, so the claim is retried when the
319
492
  // panel store moves (a late-loading plugin), not on every catalog tick.
@@ -434,8 +607,42 @@ window.__ModuleLoader__.load({
434
607
  const offDom = host.onDomChange(() => safeReconcile('dom'))
435
608
  const offHashChange = host.onHashChange(() => safeReadLink())
436
609
  safeReadLink()
610
+ // Pointing at a turn records a position, never a navigation: the listener is passive
611
+ // and it refuses to act on any interactive element, so clicks keep working exactly as
612
+ // the chat expects. Clicking a turn is how the address bar learns "which turn".
613
+ const onDocumentClick = event => {
614
+ if (disposed || event.button !== 0 || event.defaultPrevented) return
615
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
616
+ const target = event.target
617
+ if (typeof target?.closest !== 'function') return
618
+ const node = target.closest(`[${TURN_ATTRIBUTE}]`)
619
+ if (node === null) return
620
+ // One selector at a time: a comma list is the kind of thing a small DOM stub (and a
621
+ // future minimal engine) may not implement, and the answer must be the same.
622
+ for (const interactive of ['button', 'a', 'input', 'textarea', 'select', '[role="button"]', '[contenteditable="true"]']) {
623
+ if (target.closest(interactive) !== null) return
624
+ }
625
+ const raw = node.getAttribute?.(TURN_ATTRIBUTE)
626
+ const turn = typeof raw === 'string' ? raw.trim() : ''
627
+ if (turn === '') return
628
+ const id = host.mainViewSessionId()
629
+ if (id === undefined) return
630
+ turnAnchor = { session: id, turn }
631
+ safeReconcile('turn')
632
+ }
633
+ try {
634
+ window.document?.addEventListener?.('click', onDocumentClick, true)
635
+ } catch {
636
+ // A composition without the DOM listener is still a working router.
637
+ }
638
+
437
639
  return () => {
438
640
  disposed = true
641
+ try {
642
+ window.document?.removeEventListener?.('click', onDocumentClick, true)
643
+ } catch {
644
+ // Nothing to undo when the listener was never attached.
645
+ }
439
646
  dropTimer()
440
647
  dropRetry()
441
648
  offPanel()
@@ -630,7 +837,13 @@ window.__ModuleLoader__.load({
630
837
  function enhanceRow(document, row, openSession) {
631
838
  const id = sessionIdOfRow(row)
632
839
  if (id === undefined) return false
633
- return mountAnchor(document, row, routeHash('session', id), () => openSession(id))
840
+ // The workspace the row sits under leads the link, so a copied link says where the
841
+ // conversation lives. No workspace row in the tree means no guess: the plain route.
842
+ const workspaceName = workspaceNameOf(row, document)
843
+ const href = workspaceName === undefined
844
+ ? routeHash('session', id)
845
+ : routeHash('workspace', workspaceName, innerPath([WORKSPACE_SESSION_SEGMENT, id]))
846
+ return mountAnchor(document, row, href, () => openSession(id))
634
847
  }
635
848
 
636
849
  /**
@@ -761,7 +974,7 @@ window.__ModuleLoader__.load({
761
974
  // browser wiring: probe the official faces, then drive the machine.
762
975
  // ---------------------------------------------------------------------
763
976
  /** Services this plugin needs before apply runs; `layout` is optional and read through `ctx.get`. */
764
- const inject = ['sessions', 'workspaces', 'uiWorkspace', 'slots']
977
+ const inject = ['sessions', 'workspaces', 'uiWorkspace', 'slots', 'sidebarRightTabs']
765
978
 
766
979
  /**
767
980
  * Probe the faces the router needs.
@@ -796,6 +1009,9 @@ window.__ModuleLoader__.load({
796
1009
  const panels = typeof panelInfo?.getSnapshot === 'function' && typeof panelInfo?.subscribe === 'function' ? panelInfo : undefined
797
1010
  const selectPanel = typeof layout?.selectPanel === 'function' ? layout.selectPanel.bind(layout) : undefined
798
1011
  const beginNavigation = typeof layout?.beginNavigation === 'function' ? layout.beginNavigation.bind(layout) : undefined
1012
+ const openRightbar = typeof layout?.openRightbar === 'function' ? layout.openRightbar.bind(layout) : undefined
1013
+ const sidebarRightTabs = typeof scope.get === 'function' ? scope.get('sidebarRightTabs') : undefined
1014
+ const sidebarRight = typeof scope.get === 'function' ? scope.get('sidebarRight') : undefined
799
1015
  return {
800
1016
  faces: {
801
1017
  catalog: list,
@@ -803,6 +1019,9 @@ window.__ModuleLoader__.load({
803
1019
  openSession: id => openSession.call(navigation, id),
804
1020
  selectPanel: selectPanel === undefined ? undefined : id => selectPanel(id),
805
1021
  beginNavigation: beginNavigation === undefined ? undefined : () => beginNavigation(),
1022
+ openRightbar: openRightbar === undefined ? undefined : () => openRightbar(),
1023
+ sidebarRightTabs,
1024
+ sidebarRight,
806
1025
  panels,
807
1026
  slots,
808
1027
  },
@@ -832,6 +1051,24 @@ window.__ModuleLoader__.load({
832
1051
  }
833
1052
  return {
834
1053
  mainViewSessionId: () => attempt(() => mainViewSessionId(faces.catalog.getSnapshot().byId), undefined),
1054
+ // The sidebar lists workspaces and their sessions in one tree, in order, so the
1055
+ // workspace a conversation belongs to is the nearest workspace row before it.
1056
+ workspaceNameOf: sessionId => {
1057
+ const rows = window.document?.querySelectorAll?.('[data-row-key]')
1058
+ if (rows === undefined || rows === null) return undefined
1059
+ let name
1060
+ for (const row of rows) {
1061
+ const key = typeof row.getAttribute === 'function' ? row.getAttribute('data-row-key') : null
1062
+ if (typeof key !== 'string') continue
1063
+ if (key.startsWith(WORKSPACE_ROW_PREFIX)) {
1064
+ name = (row.textContent ?? '').trim()
1065
+ continue
1066
+ }
1067
+ if (key !== `${ROW_KEY_PREFIX}${sessionId}`) continue
1068
+ return name === undefined || name === '' ? undefined : name
1069
+ }
1070
+ return undefined
1071
+ },
835
1072
  activePanelId: () => (faces.panels === undefined ? undefined : attempt(() => faces.panels.getSnapshot()?.activePanelId, undefined)),
836
1073
  // The panel that owns inner pages advertises them with a data attribute; the
837
1074
  // router only reads it, never the panel's internals.
@@ -914,6 +1151,100 @@ window.__ModuleLoader__.load({
914
1151
  openSession: id => {
915
1152
  attempt(() => faces.openSession(id))
916
1153
  },
1154
+ // The right-hand pane names the tab in front. The service is the source of truth;
1155
+ // the DOM marker is a fallback for a composition where the service is absent.
1156
+ activePaneKind: () => {
1157
+ const face = faces.sidebarRightTabs
1158
+ if (face !== undefined) {
1159
+ const snapshot = attempt(() => (typeof face.getSnapshot === 'function' ? face.getSnapshot() : undefined))
1160
+ const active = snapshot === undefined ? undefined : snapshot.activeTabId ?? snapshot.activeTab ?? snapshot.active
1161
+ if (typeof active === 'string' && active !== '') return active
1162
+ }
1163
+ // DOM fallback, deliberately narrow: the active-pane marker is shared with the
1164
+ // main dockkit, so it only counts when it really belongs to the right bar.
1165
+ const active = window.document?.querySelector?.('[data-dockkit-pane-active]')
1166
+ if (active === null || active === undefined) return undefined
1167
+ const own = typeof active.getAttribute === 'function' ? active.getAttribute('data-sidebar-right-tab') : null
1168
+ if (typeof own === 'string' && own !== '') return own
1169
+ const inside = typeof active.querySelector === 'function' ? active.querySelector('[data-sidebar-right-tab]') : null
1170
+ const inner = typeof inside?.getAttribute === 'function' ? inside.getAttribute('data-sidebar-right-tab') : null
1171
+ return typeof inner === 'string' && inner !== '' ? inner : undefined
1172
+ },
1173
+ // The section the overlay shows: the nav item the platform marks aria-current.
1174
+ // One selector at a time (a comma list is beyond a small DOM stub) and the `nav`
1175
+ // ancestor check keeps unrelated aria-current controls out of it.
1176
+ activeSettingsSection: () => {
1177
+ const overlay = window.document?.querySelector?.(`[${SETTINGS_SURFACE_ATTRIBUTE}="${SETTINGS_SURFACE_VALUE}"]`)
1178
+ if (overlay === null || overlay === undefined || typeof overlay.querySelectorAll !== 'function') return undefined
1179
+ for (const candidate of overlay.querySelectorAll('button')) {
1180
+ if (typeof candidate.getAttribute !== 'function') continue
1181
+ if (candidate.getAttribute('aria-current') !== 'true') continue
1182
+ if (typeof candidate.closest === 'function' && candidate.closest('nav') === null) continue
1183
+ const label = (candidate.textContent ?? '').trim()
1184
+ if (label !== '') return label
1185
+ }
1186
+ return undefined
1187
+ },
1188
+ // The tab a settings section is showing: the same ARIA contract one level deeper.
1189
+ activeSettingsTab: () => {
1190
+ const overlay = window.document?.querySelector?.(`[${SETTINGS_SURFACE_ATTRIBUTE}="${SETTINGS_SURFACE_VALUE}"]`)
1191
+ if (overlay === null || overlay === undefined || typeof overlay.querySelectorAll !== 'function') return undefined
1192
+ for (const candidate of overlay.querySelectorAll('[role="tab"]')) {
1193
+ if (typeof candidate.getAttribute !== 'function') continue
1194
+ if (candidate.getAttribute('aria-selected') !== 'true') continue
1195
+ const label = (candidate.textContent ?? '').trim()
1196
+ if (label !== '') return label
1197
+ }
1198
+ return undefined
1199
+ },
1200
+ openSettings: (section, tab) => {
1201
+ if (typeof section === 'string' && section !== '') {
1202
+ // A section is named by the label the user reads, so the request is a label match.
1203
+ const overlay = window.document?.querySelector?.(`[${SETTINGS_SURFACE_ATTRIBUTE}="${SETTINGS_SURFACE_VALUE}"]`)
1204
+ const scope = overlay ?? window.document
1205
+ if (typeof scope?.querySelectorAll === 'function') {
1206
+ const candidates = [...scope.querySelectorAll('button')].filter(candidate => {
1207
+ if (typeof candidate.closest === 'function' && candidate.closest('nav') === null) return false
1208
+ return (candidate.textContent ?? '').trim() !== ''
1209
+ })
1210
+ const wanted = section.trim().toLowerCase()
1211
+ const match = candidates.find(candidate => (candidate.textContent ?? '').trim().toLowerCase() === wanted)
1212
+ if (match !== undefined && typeof match.click === 'function') {
1213
+ attempt(() => match.click())
1214
+ // The section may own tabs: a named one is clicked once the section is up.
1215
+ if (typeof tab === 'string' && tab !== '') {
1216
+ const wantedTab = tab.trim().toLowerCase()
1217
+ const tabs = [...scope.querySelectorAll('[role="tab"]')]
1218
+ const matchTab = tabs.find(candidate => (candidate.textContent ?? '').trim().toLowerCase() === wantedTab)
1219
+ if (matchTab !== undefined && typeof matchTab.click === 'function') attempt(() => matchTab.click())
1220
+ }
1221
+ return
1222
+ }
1223
+ }
1224
+ }
1225
+ const slot = window.document?.querySelector?.(`[data-slot="${SETTINGS_TRIGGER_SLOT}"]`)
1226
+ const button =
1227
+ (typeof slot?.querySelector === 'function' ? slot.querySelector('button') : null) ??
1228
+ (typeof slot?.closest === 'function' ? slot.closest('button') : null)
1229
+ if (typeof button?.click === 'function') {
1230
+ attempt(() => button.click())
1231
+ return
1232
+ }
1233
+ // Fallback for a composition without that slot: the labelled entry.
1234
+ const labelled = typeof window.document?.querySelectorAll === 'function'
1235
+ ? [...window.document.querySelectorAll('button')].find(candidate => /^(settings|设置)$/i.test((candidate.textContent ?? '').trim()))
1236
+ : undefined
1237
+ if (typeof labelled?.click === 'function') attempt(() => labelled.click())
1238
+ },
1239
+ openPane: kind => {
1240
+ if (typeof kind !== 'string' || kind === '') return
1241
+ const face = faces.sidebarRightTabs ?? faces.sidebarRight
1242
+ if (face !== undefined && typeof face.openTab === 'function') {
1243
+ attempt(() => face.openTab(kind))
1244
+ return
1245
+ }
1246
+ attempt(() => faces.openRightbar?.())
1247
+ },
917
1248
  beginNavigation: () => {
918
1249
  if (faces.beginNavigation === undefined) return
919
1250
  attempt(() => faces.beginNavigation())
@@ -953,6 +1284,12 @@ window.__ModuleLoader__.load({
953
1284
  'data-plugin-row-detail',
954
1285
  TASK_BOARD_OPEN_ATTRIBUTE,
955
1286
  TASK_BOARD_NEW_ATTRIBUTE,
1287
+ // Switching a settings section only moves this marker, so it must be
1288
+ // watched or the address bar keeps the section it saw first.
1289
+ 'aria-current',
1290
+ // Switching a settings *tab* moves this one, for the same reason.
1291
+ 'aria-selected',
1292
+ SETTINGS_SURFACE_ATTRIBUTE,
956
1293
  ],
957
1294
  })
958
1295
  return () => observer.disconnect()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-url-router",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "DSH Web GUI 地址栏路由插件:每个界面都有自己的链接(会话 / 主面板 / 插件页与组件页 / 任务看板),整行即真链接,只写 fragment。",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,7 +29,8 @@
29
29
  "@deepseek-ai/dsh-api-session-controller",
30
30
  "@deepseek-ai/dsh-api-workspace-controller",
31
31
  "@deepseek-ai/dsh-client-ui-workspace",
32
- "@deepseek-ai/dsh-client-ui-layout"
32
+ "@deepseek-ai/dsh-client-ui-layout",
33
+ "@deepseek-ai/dsh-client-ui-sidebar-right"
33
34
  ]
34
35
  },
35
36
  "compatibility": {