dsh-session-hover-preview 0.1.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/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # dsh-session-hover-preview
2
+
3
+ 本地 DeepSeek Harness Web 插件,为右侧当前会话历史增加 Codex 风格的消息导航条。
4
+
5
+ ## 功能
6
+
7
+ - 在右侧会话历史区域的左侧边缘、页面垂直居中位置显示消息导航条;定位和交互均不依赖左侧 Session 列表。
8
+ - 每条横线对应一条真实用户消息,默认等长;Host 会分页读取完整 Session 历史。
9
+ - Hover 或键盘聚焦某条横线时,该条最长,并按距离向上下两侧逐渐缩短。
10
+ - 点击横线会滚动到对应用户消息;尚未渲染的旧消息会先通过“加载更早”补齐。
11
+ - 切换右侧当前会话或历史更新时会取消旧请求并刷新横线。
12
+ - Markdown 只用于生成安全纯文本标签;触屏和窄屏自动禁用。
13
+ - 只读调用官方 `ApiProxy.sessions.history`,不会恢复或发布 Agent。
14
+
15
+ ## 本地安装
16
+
17
+ ```bash
18
+ cd ~/.dsh
19
+ dsh plugin --profile web add /Users/g/gits/for-nothing/dsh-session-hover-preview
20
+ ```
21
+
22
+ 安装后刷新现有的 `http://127.0.0.1:3080`。如果 `dsh web` 已经运行,Host 端新增路由通常需要重启该进程;单独重写 `lib/client.js` 时,只有配套构建 watcher 正在运行才会触发 client HMR。
23
+
24
+ ## 本地检查
25
+
26
+ ```bash
27
+ node --test test/index.test.js
28
+ node --check lib/index.js
29
+ node --check lib/client.js
30
+ ```
@@ -0,0 +1,4 @@
1
+ # Local DeepSeek Harness bundle: mount the host/client plugin as a Web roster row.
2
+ - insert:
3
+ - id: session-hover-preview
4
+ name: 'dsh-session-hover-preview'
package/lib/client.js ADDED
@@ -0,0 +1,268 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-session-hover-preview',
3
+ factory: (require) => {
4
+ const module = { exports: {} }
5
+ const exports = module.exports
6
+
7
+ const inject = ['sessions']
8
+ const REFRESH_DELAY = 220
9
+ const DEFAULT_LINE_WIDTH = 10
10
+ const MAX_LINE_WIDTH = 30
11
+ const MIN_LINE_WIDTH = 8
12
+ const WIDTH_STEP = 4
13
+ const MAX_LOAD_OLDER_PAGES = 100
14
+
15
+ const css = `
16
+ [data-dsh-session-history-rail]{position:fixed;z-index:110;display:flex;box-sizing:border-box;width:46px;max-height:min(60vh,480px);padding:8px 4px;flex-direction:column;align-items:flex-start;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:none;opacity:.62;transition:opacity .16s ease,transform .16s ease;transform:translateX(0)}
17
+ [data-dsh-session-history-rail]::-webkit-scrollbar{display:none}
18
+ [data-dsh-session-history-rail]:hover,[data-dsh-session-history-rail]:focus-within{opacity:.9}
19
+ [data-dsh-session-history-rail][data-hidden]{pointer-events:none;opacity:0;transform:translateX(-4px)}
20
+ [data-dsh-session-history-line]{position:relative;display:block;box-sizing:border-box;width:38px;height:9px;min-height:9px;padding:0;flex:0 0 9px;cursor:pointer;background:transparent;border:0}
21
+ [data-dsh-session-history-line]::before{position:absolute;top:3px;left:0;width:var(--dsh-history-line-width,10px);height:2px;content:"";background:var(--dsw-alias-label-tertiary,#8d9199);border-radius:1px;transition:width .13s ease,background .13s ease}
22
+ [data-dsh-session-history-line]:hover,[data-dsh-session-history-line]:focus-visible{outline:none}
23
+ [data-dsh-session-history-line]:hover::before,[data-dsh-session-history-line]:focus-visible::before{background:var(--dsw-alias-label-primary,#222)}
24
+ @media (prefers-reduced-motion:reduce){[data-dsh-session-history-rail],[data-dsh-session-history-line]::before{transition:none}}
25
+ @media (max-width:760px),(hover:none){[data-dsh-session-history-rail]{display:none!important}}
26
+ `
27
+
28
+ function currentSession(ctx) {
29
+ const state = ctx.sessions.list.getSnapshot()
30
+ const summary = state.current ? state.byId[state.current] : undefined
31
+ return summary && !summary.blank ? summary : undefined
32
+ }
33
+
34
+ function conversationViewport() {
35
+ let element = document.querySelector('[data-slot="conversation.view"]')?.parentElement
36
+ while (element) {
37
+ const rect = element.getBoundingClientRect()
38
+ if (['auto', 'scroll'].includes(getComputedStyle(element).overflowY) && rect.width > 0 && rect.height > 0) return element
39
+ element = element.parentElement
40
+ }
41
+ return undefined
42
+ }
43
+
44
+ async function loadMessages(sessionId, signal) {
45
+ const response = await fetch('/session-hover-preview', {
46
+ method: 'POST',
47
+ headers: { 'content-type': 'application/json' },
48
+ body: JSON.stringify({ sessionId }),
49
+ signal,
50
+ })
51
+ const body = await response.json()
52
+ if (!response.ok || !body.ok) throw new Error(body.error || 'history unavailable')
53
+ return Array.isArray(body.messages)
54
+ ? body.messages.filter(message => message && typeof message.id === 'string' && typeof message.text === 'string')
55
+ : []
56
+ }
57
+
58
+ function apply(ctx) {
59
+ ctx.effect(() => {
60
+ const style = document.createElement('style')
61
+ style.dataset.plugin = 'dsh-session-hover-preview'
62
+ style.textContent = css
63
+ document.head.appendChild(style)
64
+
65
+ const rail = document.createElement('nav')
66
+ rail.setAttribute('data-dsh-session-history-rail', '')
67
+ rail.setAttribute('aria-label', '当前会话消息导航')
68
+ rail.setAttribute('data-hidden', '')
69
+ document.body.appendChild(rail)
70
+
71
+ let request
72
+ let refreshTimer
73
+ let refreshToken = 0
74
+ let currentSessionId = currentSession(ctx)?.id
75
+ let observedViewport
76
+
77
+ const resetLines = () => {
78
+ for (const line of rail.children) line.style.setProperty('--dsh-history-line-width', `${DEFAULT_LINE_WIDTH}px`)
79
+ }
80
+
81
+ const focusLines = activeIndex => {
82
+ for (const [index, line] of [...rail.children].entries()) {
83
+ const width = Math.max(MIN_LINE_WIDTH, MAX_LINE_WIDTH - Math.abs(index - activeIndex) * WIDTH_STEP)
84
+ line.style.setProperty('--dsh-history-line-width', `${width}px`)
85
+ }
86
+ }
87
+
88
+ const placeRail = () => {
89
+ const summary = currentSession(ctx)
90
+ const viewport = conversationViewport()
91
+ const rect = viewport?.getBoundingClientRect()
92
+ if (!summary || rail.childElementCount === 0 || !rect || rect.width === 0 || rect.height === 0) {
93
+ rail.setAttribute('data-hidden', '')
94
+ return
95
+ }
96
+ const height = rail.offsetHeight
97
+ const left = Math.max(12, Math.min(rect.left + 12, window.innerWidth - rail.offsetWidth - 12))
98
+ const center = window.innerHeight / 2
99
+ const top = Math.max(rect.top + 12, Math.min(center - height / 2, rect.bottom - height - 12))
100
+ rail.style.left = `${left}px`
101
+ rail.style.top = `${top}px`
102
+ rail.removeAttribute('data-hidden')
103
+ }
104
+
105
+ const findMessageElement = messageId => [...document.querySelectorAll('[data-chat-flow-kind="user"][data-chat-anchor-key]')]
106
+ .find(element => element.getAttribute('data-chat-anchor-key')?.endsWith(`input-message${messageId}`))
107
+
108
+ const olderPageControl = () => {
109
+ const column = document.querySelector('[data-slot="conversation.view"] [data-chat-flow]')
110
+ const first = column?.firstElementChild
111
+ if (!column || !first || first.hasAttribute('data-chat-flow-kind')) return undefined
112
+ const button = [...first.children].find(child => child instanceof HTMLButtonElement)
113
+ return button ? { button, column } : undefined
114
+ }
115
+
116
+ const waitForOlderPage = (column, previousCount, messageId) => new Promise(resolve => {
117
+ let finished = false
118
+ const done = progressed => {
119
+ if (finished) return
120
+ finished = true
121
+ observer.disconnect()
122
+ clearTimeout(timeout)
123
+ resolve(progressed)
124
+ }
125
+ const observer = new MutationObserver(() => {
126
+ const count = column.querySelectorAll('[data-chat-flow-kind="user"]').length
127
+ if (findMessageElement(messageId) || count > previousCount || !olderPageControl()) done(true)
128
+ })
129
+ const timeout = setTimeout(() => done(false), 10000)
130
+ observer.observe(column, { childList: true, subtree: true })
131
+ })
132
+
133
+ const waitForStableLayout = async element => {
134
+ let previousTop
135
+ let stableFrames = 0
136
+ for (let frame = 0; frame < 30 && stableFrames < 2; frame += 1) {
137
+ await new Promise(resolve => requestAnimationFrame(resolve))
138
+ const top = element.getBoundingClientRect().top
139
+ stableFrames = previousTop !== undefined && Math.abs(top - previousTop) < .5 ? stableFrames + 1 : 0
140
+ previousTop = top
141
+ }
142
+ }
143
+
144
+ const scrollToMessage = async messageId => {
145
+ const sessionId = currentSession(ctx)?.id
146
+ if (!sessionId) return
147
+ let loadedOlder = false
148
+ for (let page = 0; page < MAX_LOAD_OLDER_PAGES; page += 1) {
149
+ const target = findMessageElement(messageId)
150
+ if (target) {
151
+ if (loadedOlder) await waitForStableLayout(target)
152
+ target.scrollIntoView({
153
+ behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
154
+ block: 'center',
155
+ })
156
+ return
157
+ }
158
+ if (currentSession(ctx)?.id !== sessionId) return
159
+ const control = olderPageControl()
160
+ if (!control) return
161
+ const previousCount = control.column.querySelectorAll('[data-chat-flow-kind="user"]').length
162
+ const loaded = waitForOlderPage(control.column, previousCount, messageId)
163
+ control.button.click()
164
+ if (!await loaded) return
165
+ loadedOlder = true
166
+ }
167
+ }
168
+
169
+ const renderMessages = messages => {
170
+ rail.replaceChildren()
171
+ for (const [index, message] of messages.entries()) {
172
+ const line = document.createElement('button')
173
+ line.type = 'button'
174
+ line.setAttribute('data-dsh-session-history-line', '')
175
+ line.setAttribute('data-message-id', message.id)
176
+ line.setAttribute('aria-label', `跳转到第 ${index + 1} 条用户消息:${message.text}`)
177
+ line.style.setProperty('--dsh-history-line-width', `${DEFAULT_LINE_WIDTH}px`)
178
+ line.addEventListener('pointerenter', () => focusLines(index))
179
+ line.addEventListener('focus', () => focusLines(index))
180
+ line.addEventListener('click', event => {
181
+ event.stopPropagation()
182
+ void scrollToMessage(message.id)
183
+ })
184
+ rail.appendChild(line)
185
+ }
186
+ placeRail()
187
+ }
188
+
189
+ const refresh = async () => {
190
+ refreshTimer = undefined
191
+ const summary = currentSession(ctx)
192
+ if (!summary || !conversationViewport()) {
193
+ request?.abort()
194
+ rail.replaceChildren()
195
+ rail.setAttribute('data-hidden', '')
196
+ return
197
+ }
198
+ request?.abort()
199
+ request = new AbortController()
200
+ const ownToken = ++refreshToken
201
+ try {
202
+ const messages = await loadMessages(summary.id, request.signal)
203
+ if (ownToken !== refreshToken || currentSession(ctx)?.id !== summary.id) return
204
+ renderMessages(messages)
205
+ } catch (error) {
206
+ if (error?.name === 'AbortError' || ownToken !== refreshToken) return
207
+ rail.replaceChildren()
208
+ rail.setAttribute('data-hidden', '')
209
+ }
210
+ }
211
+
212
+ const scheduleRefresh = (delay = REFRESH_DELAY) => {
213
+ clearTimeout(refreshTimer)
214
+ refreshTimer = setTimeout(() => { void refresh() }, delay)
215
+ }
216
+
217
+ const resizeObserver = new ResizeObserver(placeRail)
218
+ const observeViewport = () => {
219
+ const viewport = conversationViewport()
220
+ if (viewport === observedViewport) return
221
+ resizeObserver.disconnect()
222
+ observedViewport = viewport
223
+ if (viewport) {
224
+ resizeObserver.observe(viewport)
225
+ if (rail.childElementCount === 0) scheduleRefresh(0)
226
+ }
227
+ placeRail()
228
+ }
229
+
230
+ rail.addEventListener('pointerleave', resetLines)
231
+ rail.addEventListener('focusout', event => {
232
+ if (!(event.relatedTarget instanceof Node) || !rail.contains(event.relatedTarget)) resetLines()
233
+ })
234
+ window.addEventListener('resize', placeRail)
235
+ const mutationObserver = new MutationObserver(observeViewport)
236
+ mutationObserver.observe(document.body, { childList: true, subtree: true })
237
+ const unsubscribe = ctx.sessions.list.subscribe(() => {
238
+ const nextSessionId = currentSession(ctx)?.id
239
+ if (nextSessionId !== currentSessionId) {
240
+ currentSessionId = nextSessionId
241
+ request?.abort()
242
+ rail.replaceChildren()
243
+ rail.setAttribute('data-hidden', '')
244
+ scheduleRefresh(0)
245
+ } else scheduleRefresh()
246
+ })
247
+ observeViewport()
248
+ scheduleRefresh(0)
249
+
250
+ return () => {
251
+ clearTimeout(refreshTimer)
252
+ request?.abort()
253
+ unsubscribe()
254
+ mutationObserver.disconnect()
255
+ resizeObserver.disconnect()
256
+ rail.removeEventListener('pointerleave', resetLines)
257
+ window.removeEventListener('resize', placeRail)
258
+ style.remove()
259
+ rail.remove()
260
+ }
261
+ }, 'dsh-session-hover-preview: mount')
262
+ }
263
+
264
+ exports.inject = inject
265
+ exports.apply = apply
266
+ return module.exports
267
+ },
268
+ })
package/lib/index.js ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Host half for the session hover-preview plugin.
3
+ *
4
+ * It exposes one same-origin, read-only endpoint backed by the official
5
+ * ApiProxy history seam. Reading history does not resume or publish an Agent.
6
+ */
7
+ export const name = 'dsh-session-hover-preview'
8
+ export const inject = ['webServer', 'apiProxy']
9
+
10
+ const MAX_BODY_BYTES = 16 * 1024
11
+ const HISTORY_PAGE_MESSAGES = 50
12
+ const PREVIEW_CHARS = 360
13
+
14
+ function writeJson(res, status, body) {
15
+ res.writeHead(status, {
16
+ 'content-type': 'application/json; charset=utf-8',
17
+ 'cache-control': 'no-store',
18
+ })
19
+ res.end(JSON.stringify(body))
20
+ }
21
+
22
+ function isLoopback(hostname) {
23
+ if (hostname === 'localhost' || hostname === '[::1]') return true
24
+ const parts = hostname.split('.')
25
+ return parts.length === 4
26
+ && parts[0] === '127'
27
+ && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
28
+ }
29
+
30
+ function trustedRequest(req) {
31
+ const host = typeof req.headers.host === 'string' ? req.headers.host : undefined
32
+ if (!host) return false
33
+ let hostUrl
34
+ try { hostUrl = new URL(`http://${host}`) } catch { return false }
35
+ if (!isLoopback(hostUrl.hostname)) return false
36
+ if (req.headers['sec-fetch-site'] === 'cross-site') return false
37
+ const origin = typeof req.headers.origin === 'string' ? req.headers.origin : undefined
38
+ if (!origin) return true
39
+ try { return new URL(origin).host === hostUrl.host } catch { return false }
40
+ }
41
+
42
+ async function readBody(req) {
43
+ const chunks = []
44
+ let size = 0
45
+ for await (const chunk of req) {
46
+ const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk)
47
+ size += bytes.length
48
+ if (size > MAX_BODY_BYTES) throw new Error('request body too large')
49
+ chunks.push(bytes)
50
+ }
51
+ if (chunks.length === 0) return {}
52
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
53
+ }
54
+
55
+ function normalizedText(value) {
56
+ return value
57
+ .replace(/```[\s\S]*?```/g, match => match.replace(/^```[^\n]*\n?|```$/g, ' '))
58
+ .replace(/`([^`]+)`/g, '$1')
59
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
60
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
61
+ .replace(/^\s{0,3}(?:#{1,6}|>|[-*+] |\d+[.)] )\s*/gm, '')
62
+ .replace(/[*_~]/g, '')
63
+ .replace(/\s+/g, ' ')
64
+ .trim()
65
+ }
66
+
67
+ function textFromBlocks(blocks) {
68
+ if (!Array.isArray(blocks)) return ''
69
+ return normalizedText(blocks
70
+ .filter(block => block && block.type === 'text' && typeof block.text === 'string')
71
+ .map(block => block.text)
72
+ .join('\n'))
73
+ }
74
+
75
+ function clamp(value) {
76
+ const chars = Array.from(value)
77
+ return chars.length <= PREVIEW_CHARS ? value : `${chars.slice(0, PREVIEW_CHARS).join('')}…`
78
+ }
79
+
80
+ function previewFrom(events) {
81
+ const turns = []
82
+ let current
83
+ for (const entry of events) {
84
+ const event = entry?.event
85
+ if (!event || typeof event !== 'object') continue
86
+ if (event.type === 'user/message' && event.data?.source?.kind === 'user') {
87
+ const text = textFromBlocks(event.data.content)
88
+ if (!text) continue
89
+ current = { user: text, assistant: '' }
90
+ turns.push(current)
91
+ continue
92
+ }
93
+ if (event.type === 'assistant/message' && current) {
94
+ const text = textFromBlocks(event.data?.message?.content)
95
+ if (text) current.assistant = text
96
+ }
97
+ }
98
+ const complete = turns.findLast(turn => turn.user && turn.assistant)
99
+ const latest = turns.at(-1)
100
+ const selected = complete ?? latest ?? { user: '', assistant: '' }
101
+ return { user: clamp(selected.user), assistant: clamp(selected.assistant) }
102
+ }
103
+
104
+ function userMessagesFrom(events) {
105
+ return events.flatMap(entry => {
106
+ const event = entry?.event
107
+ if (event?.type !== 'user/message' || event.data?.source?.kind !== 'user' || typeof event.data.id !== 'string') return []
108
+ const text = textFromBlocks(event.data.content)
109
+ return text ? [{ id: event.data.id, text: clamp(text) }] : []
110
+ })
111
+ }
112
+
113
+ async function readAllHistory(apiProxy, sessionId) {
114
+ const pages = []
115
+ let beforeSeq
116
+ while (true) {
117
+ const response = await apiProxy.sessions.history({
118
+ rpcId: crypto.randomUUID(),
119
+ payload: {
120
+ sessionId,
121
+ maxMessages: HISTORY_PAGE_MESSAGES,
122
+ ...(beforeSeq === undefined ? {} : { beforeSeq }),
123
+ },
124
+ })
125
+ if (!response.result.ok) return response.result
126
+ const { events, hasMore } = response.result.value
127
+ pages.unshift(events)
128
+ if (!hasMore) return { ok: true, value: pages.flat() }
129
+ const nextBeforeSeq = events[0]?.event?.seq
130
+ if (typeof nextBeforeSeq !== 'number' || nextBeforeSeq === beforeSeq) {
131
+ return {
132
+ ok: false,
133
+ error: { code: 'history-pagination-invalid', message: 'history pagination did not advance' },
134
+ }
135
+ }
136
+ beforeSeq = nextBeforeSeq
137
+ }
138
+ }
139
+
140
+ export function apply(ctx) {
141
+ ctx.effect(() => ctx.webServer.register({
142
+ kind: 'exact',
143
+ path: '/session-hover-preview',
144
+ handler: async (req, res) => {
145
+ if (!trustedRequest(req)) {
146
+ writeJson(res, 403, { ok: false, error: 'forbidden' })
147
+ return
148
+ }
149
+ if (req.method !== 'POST') {
150
+ writeJson(res, 405, { ok: false, error: 'method not allowed' })
151
+ return
152
+ }
153
+ try {
154
+ const payload = await readBody(req)
155
+ if (typeof payload.sessionId !== 'string' || payload.sessionId.length === 0) {
156
+ writeJson(res, 400, { ok: false, error: 'sessionId is required' })
157
+ return
158
+ }
159
+ const history = await readAllHistory(ctx.apiProxy, payload.sessionId)
160
+ if (!history.ok) {
161
+ writeJson(res, history.error.code === 'session-not-found' ? 404 : 500, {
162
+ ok: false,
163
+ error: history.error.message,
164
+ })
165
+ return
166
+ }
167
+ writeJson(res, 200, {
168
+ ok: true,
169
+ preview: previewFrom(history.value),
170
+ messages: userMessagesFrom(history.value),
171
+ })
172
+ } catch (error) {
173
+ const status = error instanceof SyntaxError
174
+ ? 400
175
+ : error instanceof Error && error.message === 'request body too large'
176
+ ? 413
177
+ : 500
178
+ writeJson(res, status, { ok: false, error: error instanceof Error ? error.message : String(error) })
179
+ }
180
+ },
181
+ }), 'dsh-session-hover-preview: route')
182
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "dsh-session-hover-preview",
3
+ "version": "0.1.0",
4
+ "description": "Codex-style user-message navigation for the current DeepSeek Harness conversation history",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "files": [
8
+ "lib",
9
+ "cordis.patch.yml",
10
+ "README.md"
11
+ ],
12
+ "publishConfig": {
13
+ "access": "public",
14
+ "registry": "https://registry.npmjs.org/"
15
+ },
16
+ "exports": {
17
+ ".": "./lib/index.js",
18
+ "./client": "./lib/client.js",
19
+ "./cordis.patch.yml": "./cordis.patch.yml",
20
+ "./package.json": "./package.json"
21
+ },
22
+ "dsh": {
23
+ "bundle": {
24
+ "patch": "./cordis.patch.yml"
25
+ },
26
+ "client": {
27
+ "inject": [
28
+ "@deepseek-ai/dsh-client-runtime",
29
+ "@deepseek-ai/dsh-client-ui-workspace"
30
+ ],
31
+ "platform": "web"
32
+ }
33
+ },
34
+ "peerDependencies": {
35
+ "@deepseek-ai/cordis": "^4.0.1",
36
+ "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
37
+ "@deepseek-ai/dsh-client-ui-workspace": "^0.1.1-rc.2",
38
+ "@deepseek-ai/dsh-host-apiproxy": "^0.1.1-rc.2",
39
+ "@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2"
40
+ }
41
+ }