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 +11 -0
- package/README.md +5 -1
- package/lib/client.js +348 -11
- package/package.json +3 -2
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
|
-
-
|
|
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
|
-
/**
|
|
68
|
-
const
|
|
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
|
-
|
|
112
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|