opencode-session-turns 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 meiyx7
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,80 @@
1
- # Temporary Holding Version
1
+ # opencode-session-turns
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **OpenCode Desktop 插件:在会话右缘列出每一轮的「用户提问」,悬停展开,点击即刻跳转到那一条消息。**
4
+
5
+ An OpenCode Desktop plugin that adds a slim rail on the right edge of the
6
+ conversation, listing every user turn of the current session. Hover to expand;
7
+ click a turn to jump straight to that message.
8
+
9
+ 适用 OpenCode Desktop 2.2.6(以预构建的 `./gui-experimental` 包形式加载)。
10
+
11
+ ```
12
+ ┌─ 会话轮次 (12) ─────────────┐
13
+ │ 09:14 #1 帮我看看这个报错… │
14
+ │ 09:22 #2 那改成异步呢? │
15
+ │ 09:35 #3 继续 │
16
+ │ ... │
17
+ └─────────────────────────────┘
18
+ ```
19
+
20
+ ## 功能
21
+
22
+ - 列出当前会话的每一轮用户提问(从会话消息接口分页拉取,`type=user`)。
23
+ - 鼠标移到窗口右缘的细条上自动展开,移开自动收起。
24
+ - 点击任意一轮,**跳转到对应消息**——包括很久以前、尚未渲染的历史轮次。
25
+ - 自动高亮当前视口所在的轮次。
26
+ - 斜杠命令 `/turns`、快捷键 `⌘⌥R`(Windows/Linux 为 `Ctrl+Alt+R`)展开轮次条。
27
+
28
+ ## 安装
29
+
30
+ 把包名加入 `opencode.json` 的 `plugins` 列表,OpenCode 启动时会自动从 npm 安装:
31
+
32
+ ```json
33
+ {
34
+ "plugins": ["opencode-session-turns"]
35
+ }
36
+ ```
37
+
38
+ 然后重启 OpenCode Desktop(GUI bundle 在窗口启动时加载)。
39
+
40
+ ## 使用
41
+
42
+ - 把鼠标移到会话右侧的细条上展开;
43
+ - 或按 `⌘⌥R` / 输入 `/turns` 展开;
44
+ - 点击某一轮即可跳到那条消息。
45
+
46
+ ## 实现原理(为什么并不简单)
47
+
48
+ OpenCode Desktop 2.2.6 有几个必须绕开的陷阱:
49
+
50
+ - 时间线是**虚拟渲染**的:只有视口附近的行动态挂载,远端轮次不在 DOM 里。
51
+ - 桌面端使用**内存路由**:`window.location` 始终停在 `oc://renderer/index.html`,
52
+ 与真实路由解耦。直接往 `window.location` 写 `#message-<id>` 或派发 `popstate`,
53
+ 会让宿主把 `/index.html` 当成路由解析并抛出致命的 `Unrecognised route!`(会崩)。
54
+ - GUI SDK **没有** `scrollToMessage()` / `navigate()` 这类公开 API(`ctx.router` 只读)。
55
+
56
+ 因此本插件的做法是:
57
+
58
+ 1. 用 `ctx.router.path()` 取到真实路由(合法的 `/server/…/session/…` 路径);
59
+ 2. 伪造一个 `<a href="<路由>#message-<id>">` 并派发冒泡 click。宿主在 `document`
60
+ 上注册的链接拦截器(Solid Router 的 `create` 钩子)会把它交给**内存 router 的
61
+ navigate()**,从而驱动宿主自己的游标状态机加载历史并定位到该行——全程不碰
62
+ `window.location`。
63
+ 3. 宿主每次 seek 只向前加载**约一页(~5 轮)**历史,所以插件会交替把 hash 指向
64
+ 「已加载的最早一轮」和目标,逐页把历史翻出来,直到目标行挂载。
65
+
66
+ 已挂载的行则直接用本地滚动,瞬时定位。
67
+
68
+ ## 构建
69
+
70
+ ```sh
71
+ npm install
72
+ npm run build # -> dist/gui-experimental.cjs
73
+ ```
74
+
75
+ 改完源码后重新构建,并在 OpenCode Desktop 里 Reload 插件
76
+ (Settings → Extensions → Plugins → Reload)或重启应用。
77
+
78
+ ## 许可证
79
+
80
+ MIT © meiyx7
@@ -0,0 +1,95 @@
1
+ "use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const s=require("solid-js/web"),h=require("@opencode/plugin/gui"),a=require("solid-js");var Z=s.template("<div class=sr-status>"),tt=s.template("<div class=sr-rail>"),et=s.template("<div class=sr-note>"),nt=s.template("<button type=button class=sr-turn><span class=sr-text> </span><span class=sr-dash>");const C="v10",B="message-",_="session-turns:diag",A="session-turns:open";let F=0,j="",I="";const rt=n=>{const e=String(n?.text??"").split(`
2
+ `).map(d=>d.trim()).find(d=>d.length>0);if(e)return e;const o=n?.files?.length??0;return o>0?`[附件 ×${o}]`:"(空消息)"},N=n=>String(n).padStart(2,"0"),st=n=>{const e=new Date(n??0);return`${N(e.getHours())}:${N(e.getMinutes())}`},z=n=>new Promise(e=>setTimeout(e,n)),x=()=>document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport')??document.querySelector('[data-slot="session-timeline-scroll"] [data-scrollable]')??(()=>{let e=document.querySelector("[data-message-id]")?.parentElement;for(;e;){const o=getComputedStyle(e);if(/(auto|scroll)/.test(o.overflowY)&&e.scrollHeight>e.clientHeight+1)return e;e=e.parentElement}})(),y=n=>document.getElementById(`${B}${n}`)??[...document.querySelectorAll("[data-message-id]")].find(e=>e.getAttribute("data-message-id")===n),T=n=>{const e=x(),o=[...document.querySelectorAll("[data-message-id]")],d=o[0]?.getAttribute("data-message-id")??"",$=I?` · 目标 ${y(I)?"在":"不在"} DOM`:"",v=d?` · 行ID ${d.slice(0,20)}`:"";let w="(空)";try{w=n.router.path()||"(空)"}catch{}return`轮次 ${F} · 容器 ${e?"OK":"缺失"} · DOM ${o.length} 行${$}${v} · 路由 ${w} · hash ${window.location.hash||"(空)"}${j?` · ${j}`:""}`},V=(n,e)=>{e.scrollIntoView({block:"start"});const o=n.querySelector("[data-session-title]"),d=o instanceof HTMLElement?o.offsetHeight:0;d&&(n.scrollTop-=d+12)},ot=n=>{let e="";try{e=n.router.path()||""}catch{}return e.startsWith("/")?e:""},at=n=>{const e=document.createElement("a");e.setAttribute("href",n),e.setAttribute("replace",""),e.setAttribute("noscroll",""),e.style.display="none",document.body.appendChild(e);try{e.dispatchEvent(new MouseEvent("click",{bubbles:!0,cancelable:!0,button:0,view:window}))}finally{e.remove()}},it=`
3
+ .sr-rail {
4
+ position: fixed;
5
+ top: 50%;
6
+ transform: translateY(-50%);
7
+ right: 10px;
8
+ max-height: 60vh;
9
+ width: 14px;
10
+ display: flex;
11
+ flex-direction: column;
12
+ align-items: flex-end;
13
+ justify-content: center;
14
+ gap: 2px;
15
+ padding: 8px 4px;
16
+ overflow-y: auto;
17
+ overflow-x: hidden;
18
+ scrollbar-width: none;
19
+ z-index: 400;
20
+ border-radius: 10px;
21
+ color: var(--text-base, currentColor);
22
+ background: transparent;
23
+ transition: width .18s ease;
24
+ }
25
+ .sr-rail::-webkit-scrollbar { display: none; }
26
+ .sr-rail[data-open="true"] { width: 360px; background: transparent; }
27
+ .sr-turn {
28
+ display: flex;
29
+ align-items: center;
30
+ justify-content: flex-end;
31
+ width: 100%;
32
+ min-height: 14px;
33
+ flex-shrink: 0;
34
+ cursor: pointer;
35
+ background: transparent;
36
+ border: 0;
37
+ padding: 0;
38
+ color: inherit;
39
+ font: inherit;
40
+ border-radius: 4px;
41
+ }
42
+ .sr-dash {
43
+ display: block;
44
+ width: 10px;
45
+ height: 2px;
46
+ border-radius: 1px;
47
+ background: currentColor;
48
+ opacity: .28;
49
+ flex-shrink: 0;
50
+ transition: opacity .12s ease, width .12s ease;
51
+ }
52
+ .sr-turn:hover .sr-dash,
53
+ .sr-turn[data-active="true"] .sr-dash { opacity: .95; width: 18px; }
54
+ .sr-text {
55
+ display: none;
56
+ margin-right: 10px;
57
+ padding: 1px 6px;
58
+ border-radius: 4px;
59
+ font-size: 12px;
60
+ line-height: 1.5;
61
+ text-align: right;
62
+ white-space: nowrap;
63
+ overflow: hidden;
64
+ text-overflow: ellipsis;
65
+ max-width: 300px;
66
+ opacity: .45;
67
+ background: transparent;
68
+ }
69
+ .sr-rail[data-open="true"] .sr-text { display: block; }
70
+ .sr-turn:hover .sr-text {
71
+ opacity: 1;
72
+ background: var(--surface-base-hover, rgba(127,127,127,.14));
73
+ }
74
+ .sr-turn[data-active="true"] .sr-text {
75
+ opacity: 1;
76
+ font-weight: 600;
77
+ background: var(--surface-base-hover, rgba(127,127,127,.14));
78
+ }
79
+ .sr-status {
80
+ position: fixed;
81
+ right: 30px;
82
+ bottom: 150px;
83
+ z-index: 401;
84
+ font-size: 11px;
85
+ padding: 3px 8px;
86
+ border-radius: 6px;
87
+ background: var(--background-strong, rgba(0,0,0,.75));
88
+ color: var(--text-base, #fff);
89
+ opacity: .9;
90
+ pointer-events: none;
91
+ white-space: pre-wrap;
92
+ max-width: min(720px, 90vw);
93
+ }
94
+ .sr-note { font-size: 11px; opacity: .4; padding: 2px; }
95
+ `;function lt(n){const e=h.usePlugin(),[o,d]=a.createSignal([]),[$,v]=a.createSignal(""),[w,b]=a.createSignal(!1),[J,Y]=a.createSignal(""),[q,L]=a.createSignal("");let S=!1,M="",k,E="";const R=async t=>{if(!t||S)return;S=!0;const r=new Map;let l,i="";try{for(let u=0;u<40;u++){const g=l?{sessionID:t,type:"user",limit:200,cursor:l}:{sessionID:t,type:"user",limit:200,order:"desc"};let f;try{f=await e.client.message.list(g)}catch(p){i=String(p?.message??p);break}const U=f?.data??[];let D=0;for(const p of U)r.has(p.id)||(r.set(p.id,{id:p.id,created:p.time?.created??0,text:rt(p)}),D++);const H=f?.cursor?.next;if(!H||D===0)break;l=H}}finally{S=!1}const c=[...r.values()].sort((u,g)=>u.created-g.created);d(c),F=c.length,j=i,Y(c.length===0?i||"暂无用户消息":""),M=t},O=()=>n.session()?.id;a.createEffect(()=>{const t=O();t&&a.untrack(()=>{M!==t&&(d([]),v(""),E="",R(t))})});const G=setInterval(()=>{const t=O();t&&R(t)},5e3);a.onCleanup(()=>clearInterval(G));const K=setInterval(()=>{const t=x();if(!t)return;const r=[...t.querySelectorAll("[data-message-id]")];if(r.length===0)return;const l=t.getBoundingClientRect().top+40;for(const i of r)if(i.getBoundingClientRect().bottom>=l){const c=i.getAttribute("data-message-id");c&&v(c);break}},400);a.onCleanup(()=>clearInterval(K));const m=t=>{L(t),setTimeout(()=>L(r=>r===t?"":r),3e3)};a.onMount(()=>{const t=()=>m(`${C} · ${T(e)}`),r=()=>{b(!0),clearTimeout(k),k=setTimeout(()=>b(!1),4e3)};window.addEventListener(_,t),window.addEventListener(A,r),a.onCleanup(()=>{window.removeEventListener(_,t),window.removeEventListener(A,r),clearTimeout(k)}),m(`会话轮次 ${C} 已加载`)});const W=()=>{for(const t of o())if(y(t.id))return t},P=(t,r)=>{at(`${t}#${B}${r}`),E=r},X=async t=>{I=t.id;const r=x(),l=y(t.id);if(l&&r){V(r,l),m("已定位");return}const i=ot(e);if(!i){m(`无法读取当前路由,无法定位 · ${T(e)}`);return}for(let c=0;c<30;c++){if(m(c===0?"定位中…":`加载历史… ${c}`),E===t.id){const u=W()??o()[0];u&&(P(i,u.id),await z(120))}P(i,t.id);for(let u=0;u<6;u++){await z(80);const g=y(t.id);if(!g)continue;const f=x()??r;f&&V(f,g),m("已定位");return}}m(`未能定位 · ${T(e)}`)},Q=[s.createComponent(a.Show,{get when(){return q()},get children(){var t=Z();return s.insert(t,q),t}}),(()=>{var t=tt();return t.addEventListener("mouseleave",()=>b(!1)),t.addEventListener("mouseenter",()=>b(!0)),s.insert(t,s.createComponent(a.Show,{get when(){return o().length>0},get fallback(){return(()=>{var r=et();return s.insert(r,()=>J()||`… ${C}`),r})()},get children(){return s.createComponent(a.For,{get each(){return o()},children:r=>(()=>{var l=nt(),i=l.firstChild,c=i.firstChild;return l.$$click=()=>{X(r)},s.insert(i,()=>st(r.created),c),s.insert(i,()=>r.text,null),s.effect(()=>s.setAttribute(l,"data-active",$()===r.id?"true":"false")),l})()})}})),s.effect(()=>s.setAttribute(t,"data-open",w()?"true":"false")),t})()];return s.createComponent(s.Portal,{get mount(){return document.body},children:Q})}const ct=h.Plugin.define({i18n:{en:{"command.jump":"Session turns: show / jump","command.jump.description":"Reveal the turn rail; click a turn to jump to that message"}},setup(n){n.add(h.Style,it),n.add(h.Slot,{at:"window.bottom",render:()=>s.createComponent(a.Show,{get when(){return n.sessions.current()},get children(){return s.createComponent(lt,{session:()=>n.sessions.current()})}})}),n.add(h.Command,()=>({id:"jump",title:n.t("command.jump"),description:n.t("command.jump.description"),group:n.t("command.jump"),section:"navigation",bind:"mod+alt+r",slash:{name:"turns"},run:()=>{window.dispatchEvent(new Event(A)),window.dispatchEvent(new Event(_))}}))}});s.delegateEvents(["click"]);exports.default=ct;
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "opencode-session-turns",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "OpenCode Desktop 插件:在会话右缘列出每一轮用户提问,点击即可跳转到对应消息。A right-edge rail of user turns; click one to jump to that message.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "meiyx7",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/meiyx7/opencode-session-turns.git"
11
+ },
12
+ "homepage": "https://github.com/meiyx7/opencode-session-turns#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/meiyx7/opencode-session-turns/issues"
15
+ },
16
+ "keywords": [
17
+ "opencode",
18
+ "opencode-plugin",
19
+ "session",
20
+ "turns",
21
+ "timeline",
22
+ "navigation",
23
+ "jump",
24
+ "message"
25
+ ],
26
+ "exports": {
27
+ "./gui-experimental": "./dist/gui-experimental.cjs",
28
+ "./package.json": "./package.json"
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "src",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "engines": {
37
+ "node": ">=20"
38
+ },
39
+ "scripts": {
40
+ "build": "vite build",
41
+ "dev": "vite build --watch",
42
+ "prepublishOnly": "npm run build"
43
+ },
44
+ "devDependencies": {
45
+ "@opencode/plugin": "^2.2.6",
46
+ "solid-js": "^1.9.0",
47
+ "vite": "^7.0.0",
48
+ "vite-plugin-solid": "^2.11.0"
49
+ }
50
+ }
package/src/gui.tsx ADDED
@@ -0,0 +1,493 @@
1
+ import { Command, Plugin, Slot, Style, usePlugin } from "@opencode/plugin/gui"
2
+ import { For, Show, createEffect, createSignal, onCleanup, onMount, untrack } from "solid-js"
3
+ import { Portal } from "solid-js/web"
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // constants / helpers
7
+ // ---------------------------------------------------------------------------
8
+
9
+ const VERSION = "v10"
10
+ const HASH_PREFIX = "message-"
11
+ const DIAG_EVENT = "session-turns:diag"
12
+ const OPEN_EVENT = "session-turns:open"
13
+
14
+ // Filled by Rail.load()/jump() so the command palette can report diagnostics.
15
+ let lastTurnCount = 0
16
+ let lastNote = ""
17
+ let lastJumpID = ""
18
+
19
+ const firstLine = (message) => {
20
+ const line = String(message?.text ?? "")
21
+ .split("\n")
22
+ .map((item) => item.trim())
23
+ .find((item) => item.length > 0)
24
+ if (line) return line
25
+ const files = message?.files?.length ?? 0
26
+ if (files > 0) return `[附件 ×${files}]`
27
+ return "(空消息)"
28
+ }
29
+
30
+ const pad = (value) => String(value).padStart(2, "0")
31
+
32
+ const formatTime = (created) => {
33
+ const date = new Date(created ?? 0)
34
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}`
35
+ }
36
+
37
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
38
+
39
+ // The app's own code reads this exact selector. The data-slot sits on the
40
+ // outer .scroll-view (overflow: hidden); only .scroll-view__viewport scrolls.
41
+ const findScroller = () =>
42
+ document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport') ??
43
+ document.querySelector('[data-slot="session-timeline-scroll"] [data-scrollable]') ??
44
+ (() => {
45
+ const row = document.querySelector("[data-message-id]")
46
+ let node = row?.parentElement
47
+ while (node) {
48
+ const style = getComputedStyle(node)
49
+ if (/(auto|scroll)/.test(style.overflowY) && node.scrollHeight > node.clientHeight + 1) return node
50
+ node = node.parentElement
51
+ }
52
+ return undefined
53
+ })()
54
+
55
+ // The app sets id="message-<id>" on every timeline row. Prefer that over the
56
+ // data attribute: Solid Router's own scrollToHash() uses getElementById too.
57
+ const findRow = (messageID) =>
58
+ document.getElementById(`${HASH_PREFIX}${messageID}`) ??
59
+ [...document.querySelectorAll("[data-message-id]")].find(
60
+ (node) => node.getAttribute("data-message-id") === messageID,
61
+ )
62
+
63
+ const diagnostics = (ctx) => {
64
+ const scroller = findScroller()
65
+ const nodes = [...document.querySelectorAll("[data-message-id]")]
66
+ const sample = nodes[0]?.getAttribute("data-message-id") ?? ""
67
+ const target = lastJumpID ? ` · 目标 ${findRow(lastJumpID) ? "在" : "不在"} DOM` : ""
68
+ const id = sample ? ` · 行ID ${sample.slice(0, 20)}` : ""
69
+ let route = "(空)"
70
+ try {
71
+ route = ctx.router.path() || "(空)"
72
+ } catch {}
73
+ return `轮次 ${lastTurnCount} · 容器 ${scroller ? "OK" : "缺失"} · DOM ${nodes.length} 行${target}${id} · 路由 ${route} · hash ${window.location.hash || "(空)"}${lastNote ? ` · ${lastNote}` : ""}`
74
+ }
75
+
76
+ const scrollToRow = (scroller, row) => {
77
+ // Native scrolling reaches the real scroll ancestor without depending on
78
+ // the viewport selector; then compensate for the sticky session title.
79
+ row.scrollIntoView({ block: "start" })
80
+ const title = scroller.querySelector("[data-session-title]")
81
+ const inset = title instanceof HTMLElement ? title.offsetHeight : 0
82
+ if (inset) scroller.scrollTop -= inset + 12
83
+ }
84
+
85
+ // The desktop app (2.2.6) routes through a memory history, so window.location is
86
+ // stuck at the asset path `oc://renderer/index.html` and is decoupled from the
87
+ // real route. Writing `#message-<id>` + firing popstate made the host parse
88
+ // `/index.html` as a route and throw a fatal "Unrecognised route!".
89
+ //
90
+ // The one safe path into the host router is its document-level link interceptor
91
+ // (Solid Router's `create` hook): a bubbling click on an <a> whose href is an
92
+ // internal route makes it call the memory router's navigate(). We build an <a>
93
+ // from the *real* route (ctx.router.path(), a valid /server/…/session/… path)
94
+ // plus `#message-<id>` and click it. The route parses fine, the location hash
95
+ // changes, and the host's own cursor machine pages in the history and seeks to
96
+ // the row. window.location is never touched, so nothing crashes.
97
+ const routePath = (ctx) => {
98
+ let path = ""
99
+ try {
100
+ path = ctx.router.path() || ""
101
+ } catch {}
102
+ return path.startsWith("/") ? path : ""
103
+ }
104
+
105
+ // Clicks an internal route link; the host's link interceptor turns it into
106
+ // memory-router navigate(). `href` is a path, optionally with a #message-<id>.
107
+ const clickRoute = (href) => {
108
+ const anchor = document.createElement("a")
109
+ anchor.setAttribute("href", href)
110
+ // Match the host's own hash jumps: replace instead of push, and skip the
111
+ // router's window.scrollTo(0,0) while the row is still unmounted.
112
+ anchor.setAttribute("replace", "")
113
+ anchor.setAttribute("noscroll", "")
114
+ anchor.style.display = "none"
115
+ document.body.appendChild(anchor)
116
+ try {
117
+ anchor.dispatchEvent(
118
+ new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, view: window }),
119
+ )
120
+ } finally {
121
+ anchor.remove()
122
+ }
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // styles
127
+ // ---------------------------------------------------------------------------
128
+
129
+ const CSS = `
130
+ .sr-rail {
131
+ position: fixed;
132
+ top: 50%;
133
+ transform: translateY(-50%);
134
+ right: 10px;
135
+ max-height: 60vh;
136
+ width: 14px;
137
+ display: flex;
138
+ flex-direction: column;
139
+ align-items: flex-end;
140
+ justify-content: center;
141
+ gap: 2px;
142
+ padding: 8px 4px;
143
+ overflow-y: auto;
144
+ overflow-x: hidden;
145
+ scrollbar-width: none;
146
+ z-index: 400;
147
+ border-radius: 10px;
148
+ color: var(--text-base, currentColor);
149
+ background: transparent;
150
+ transition: width .18s ease;
151
+ }
152
+ .sr-rail::-webkit-scrollbar { display: none; }
153
+ .sr-rail[data-open="true"] { width: 360px; background: transparent; }
154
+ .sr-turn {
155
+ display: flex;
156
+ align-items: center;
157
+ justify-content: flex-end;
158
+ width: 100%;
159
+ min-height: 14px;
160
+ flex-shrink: 0;
161
+ cursor: pointer;
162
+ background: transparent;
163
+ border: 0;
164
+ padding: 0;
165
+ color: inherit;
166
+ font: inherit;
167
+ border-radius: 4px;
168
+ }
169
+ .sr-dash {
170
+ display: block;
171
+ width: 10px;
172
+ height: 2px;
173
+ border-radius: 1px;
174
+ background: currentColor;
175
+ opacity: .28;
176
+ flex-shrink: 0;
177
+ transition: opacity .12s ease, width .12s ease;
178
+ }
179
+ .sr-turn:hover .sr-dash,
180
+ .sr-turn[data-active="true"] .sr-dash { opacity: .95; width: 18px; }
181
+ .sr-text {
182
+ display: none;
183
+ margin-right: 10px;
184
+ padding: 1px 6px;
185
+ border-radius: 4px;
186
+ font-size: 12px;
187
+ line-height: 1.5;
188
+ text-align: right;
189
+ white-space: nowrap;
190
+ overflow: hidden;
191
+ text-overflow: ellipsis;
192
+ max-width: 300px;
193
+ opacity: .45;
194
+ background: transparent;
195
+ }
196
+ .sr-rail[data-open="true"] .sr-text { display: block; }
197
+ .sr-turn:hover .sr-text {
198
+ opacity: 1;
199
+ background: var(--surface-base-hover, rgba(127,127,127,.14));
200
+ }
201
+ .sr-turn[data-active="true"] .sr-text {
202
+ opacity: 1;
203
+ font-weight: 600;
204
+ background: var(--surface-base-hover, rgba(127,127,127,.14));
205
+ }
206
+ .sr-status {
207
+ position: fixed;
208
+ right: 30px;
209
+ bottom: 150px;
210
+ z-index: 401;
211
+ font-size: 11px;
212
+ padding: 3px 8px;
213
+ border-radius: 6px;
214
+ background: var(--background-strong, rgba(0,0,0,.75));
215
+ color: var(--text-base, #fff);
216
+ opacity: .9;
217
+ pointer-events: none;
218
+ white-space: pre-wrap;
219
+ max-width: min(720px, 90vw);
220
+ }
221
+ .sr-note { font-size: 11px; opacity: .4; padding: 2px; }
222
+ `
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // rail
226
+ // ---------------------------------------------------------------------------
227
+
228
+ function Rail(props) {
229
+ const ctx = usePlugin()
230
+ const [turns, setTurns] = createSignal([])
231
+ const [active, setActive] = createSignal("")
232
+ const [open, setOpen] = createSignal(false)
233
+ const [note, setNote] = createSignal("")
234
+ const [status, setStatus] = createSignal("")
235
+
236
+ let loading = false
237
+ let loadedFor = ""
238
+ let closeTimer
239
+ // The memory router's hash is invisible to us (window.location is stale), so
240
+ // track the last id we navigated to in order to force a hash change each round.
241
+ let lastNavID = ""
242
+
243
+ const load = async (sessionID) => {
244
+ if (!sessionID || loading) return
245
+ loading = true
246
+ const collected = new Map()
247
+ let cursor
248
+ let error = ""
249
+ try {
250
+ for (let page = 0; page < 40; page++) {
251
+ // The server rejects `cursor` together with `order`
252
+ // (InvalidCursorError: "Cursor cannot be combined with order").
253
+ // The cursor itself already encodes the original order/direction,
254
+ // so only the first page declares order: "desc".
255
+ const params = cursor
256
+ ? { sessionID, type: "user", limit: 200, cursor }
257
+ : { sessionID, type: "user", limit: 200, order: "desc" }
258
+
259
+ let response
260
+ try {
261
+ response = await ctx.client.message.list(params)
262
+ } catch (cause) {
263
+ error = String(cause?.message ?? cause)
264
+ break
265
+ }
266
+
267
+ const data = response?.data ?? []
268
+ let fresh = 0
269
+ for (const message of data) {
270
+ if (collected.has(message.id)) continue
271
+ collected.set(message.id, {
272
+ id: message.id,
273
+ created: message.time?.created ?? 0,
274
+ text: firstLine(message),
275
+ })
276
+ fresh++
277
+ }
278
+ const next = response?.cursor?.next
279
+ if (!next || fresh === 0) break
280
+ cursor = next
281
+ }
282
+ } finally {
283
+ loading = false
284
+ }
285
+
286
+ // Publish whatever was collected even if a later page failed — a paging
287
+ // error must never blank out the whole rail again.
288
+ const list = [...collected.values()].sort((a, b) => a.created - b.created)
289
+ setTurns(list)
290
+ lastTurnCount = list.length
291
+ lastNote = error
292
+ setNote(list.length === 0 ? error || "暂无用户消息" : "")
293
+ loadedFor = sessionID
294
+ }
295
+
296
+ const sessionID = () => props.session()?.id
297
+
298
+ createEffect(() => {
299
+ const id = sessionID()
300
+ if (!id) return
301
+ untrack(() => {
302
+ if (loadedFor !== id) {
303
+ setTurns([])
304
+ setActive("")
305
+ lastNavID = ""
306
+ void load(id)
307
+ }
308
+ })
309
+ })
310
+
311
+ const poll = setInterval(() => {
312
+ const id = sessionID()
313
+ if (id) void load(id)
314
+ }, 5000)
315
+ onCleanup(() => clearInterval(poll))
316
+
317
+ const trackActive = () => {
318
+ const scroller = findScroller()
319
+ if (!scroller) return
320
+ const rows = [...scroller.querySelectorAll("[data-message-id]")]
321
+ if (rows.length === 0) return
322
+ const top = scroller.getBoundingClientRect().top + 40
323
+ for (const row of rows) {
324
+ if (row.getBoundingClientRect().bottom >= top) {
325
+ const id = row.getAttribute("data-message-id")
326
+ if (id) setActive(id)
327
+ break
328
+ }
329
+ }
330
+ }
331
+ const track = setInterval(trackActive, 400)
332
+ onCleanup(() => clearInterval(track))
333
+
334
+ const flash = (text) => {
335
+ setStatus(text)
336
+ setTimeout(() => setStatus((current) => (current === text ? "" : current)), 3000)
337
+ }
338
+
339
+ onMount(() => {
340
+ const onDiag = () => flash(`${VERSION} · ${diagnostics(ctx)}`)
341
+ const onOpen = () => {
342
+ setOpen(true)
343
+ clearTimeout(closeTimer)
344
+ closeTimer = setTimeout(() => setOpen(false), 4000)
345
+ }
346
+ window.addEventListener(DIAG_EVENT, onDiag)
347
+ window.addEventListener(OPEN_EVENT, onOpen)
348
+ onCleanup(() => {
349
+ window.removeEventListener(DIAG_EVENT, onDiag)
350
+ window.removeEventListener(OPEN_EVENT, onOpen)
351
+ clearTimeout(closeTimer)
352
+ })
353
+ // One-shot confirmation that this window actually runs the rebuilt bundle.
354
+ flash(`会话轮次 ${VERSION} 已加载`)
355
+ })
356
+
357
+ // Oldest turn that is currently mounted (i.e. already paged in), used as a
358
+ // safe "different id" to toggle the router hash so it re-runs its seek.
359
+ const oldestLoadedTurn = () => {
360
+ for (const t of turns()) if (findRow(t.id)) return t
361
+ return undefined
362
+ }
363
+
364
+ const navTo = (path, id) => {
365
+ clickRoute(`${path}#${HASH_PREFIX}${id}`)
366
+ lastNavID = id
367
+ }
368
+
369
+ const jump = async (turn) => {
370
+ lastJumpID = turn.id
371
+ const scroller = findScroller()
372
+ const row = findRow(turn.id)
373
+
374
+ // Fast path: the row is mounted, scroll locally.
375
+ if (row && scroller) {
376
+ scrollToRow(scroller, row)
377
+ flash("已定位")
378
+ return
379
+ }
380
+
381
+ const path = routePath(ctx)
382
+ if (!path) {
383
+ flash(`无法读取当前路由,无法定位 · ${diagnostics(ctx)}`)
384
+ return
385
+ }
386
+
387
+ // The host only pages in ~one history page (~5 turns) per seek and then goes
388
+ // idle. Drive it: each round toggles the hash to an already-loaded turn and
389
+ // back to the target, so the cursor machine runs another seek/page.
390
+ for (let round = 0; round < 30; round++) {
391
+ flash(round === 0 ? "定位中…" : `加载历史… ${round}`)
392
+
393
+ if (lastNavID === turn.id) {
394
+ const other = oldestLoadedTurn() ?? turns()[0]
395
+ if (other) {
396
+ navTo(path, other.id)
397
+ await sleep(120)
398
+ }
399
+ }
400
+ navTo(path, turn.id)
401
+
402
+ for (let k = 0; k < 6; k++) {
403
+ await sleep(80)
404
+ const landed = findRow(turn.id)
405
+ if (!landed) continue
406
+ const live = findScroller() ?? scroller
407
+ if (live) scrollToRow(live, landed)
408
+ flash("已定位")
409
+ return
410
+ }
411
+ }
412
+
413
+ flash(`未能定位 · ${diagnostics(ctx)}`)
414
+ }
415
+
416
+ const rail = (
417
+ <>
418
+ <Show when={status()}>
419
+ <div class="sr-status">{status()}</div>
420
+ </Show>
421
+ <div
422
+ class="sr-rail"
423
+ data-open={open() ? "true" : "false"}
424
+ onMouseEnter={() => setOpen(true)}
425
+ onMouseLeave={() => setOpen(false)}
426
+ >
427
+ <Show when={turns().length > 0} fallback={<div class="sr-note">{note() || `… ${VERSION}`}</div>}>
428
+ <For each={turns()}>
429
+ {(turn) => (
430
+ <button
431
+ type="button"
432
+ class="sr-turn"
433
+ data-active={active() === turn.id ? "true" : "false"}
434
+ onClick={() => void jump(turn)}
435
+ >
436
+ <span class="sr-text">
437
+ {formatTime(turn.created)} {turn.text}
438
+ </span>
439
+ <span class="sr-dash" />
440
+ </button>
441
+ )}
442
+ </For>
443
+ </Show>
444
+ </div>
445
+ </>
446
+ )
447
+
448
+ // Portal to <body>: window.bottom is a bottom strip with its own stacking
449
+ // context, which can clip or re-anchor position:fixed children.
450
+ return <Portal mount={document.body}>{rail}</Portal>
451
+ }
452
+
453
+ // ---------------------------------------------------------------------------
454
+ // plugin
455
+ // ---------------------------------------------------------------------------
456
+
457
+ export default Plugin.define({
458
+ i18n: {
459
+ en: {
460
+ "command.jump": "Session turns: show / jump",
461
+ "command.jump.description": "Reveal the turn rail; click a turn to jump to that message",
462
+ },
463
+ },
464
+ setup(ctx) {
465
+ ctx.add(Style, CSS)
466
+
467
+ // NOTE: never clear an existing #message-<id> hash here. The desktop app
468
+ // uses it as a deep link and its cursor machine owns that state.
469
+
470
+ ctx.add(Slot, {
471
+ at: "window.bottom",
472
+ render: () => (
473
+ <Show when={ctx.sessions.current()}>
474
+ <Rail session={() => ctx.sessions.current()} />
475
+ </Show>
476
+ ),
477
+ })
478
+
479
+ ctx.add(Command, () => ({
480
+ id: "jump",
481
+ title: ctx.t("command.jump"),
482
+ description: ctx.t("command.jump.description"),
483
+ group: ctx.t("command.jump"),
484
+ section: "navigation",
485
+ bind: "mod+alt+r",
486
+ slash: { name: "turns" },
487
+ run: () => {
488
+ window.dispatchEvent(new Event(OPEN_EVENT))
489
+ window.dispatchEvent(new Event(DIAG_EVENT))
490
+ },
491
+ }))
492
+ },
493
+ })