opencode-session-turns 0.0.0-stage → 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/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,85 @@
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
+ An **OpenCode Desktop** plugin that adds a slim rail on the right edge of the
4
+ conversation, listing **every user turn** of the current session. Hover the rail
5
+ to reveal the turns; click one to jump straight to that message.
6
+
7
+ > Works on OpenCode Desktop 2.2.6. The rail is a GUI plugin, loaded from the
8
+ > prebuilt `./gui-experimental` bundle.
9
+
10
+ ```
11
+ ┌─ Session turns (12) ────────┐
12
+ │ 09:14 #1 Fix this error… │
13
+ │ 09:22 #2 What about async?│
14
+ │ 09:35 #3 Continue │
15
+ │ ... │
16
+ └─────────────────────────────┘
17
+ ```
18
+
19
+ ## Features
20
+
21
+ - Lists every user turn of the current session (paged from the session API).
22
+ - Hover the right-edge rail to expand; move away to collapse.
23
+ - Click a turn to jump to its message — including turns far back in history.
24
+ - Highlights the turn currently in view.
25
+ - `/turns` slash command and `⌘⌥R` (`Ctrl+Alt+R`) to reveal the rail.
26
+
27
+ ## Install
28
+
29
+ Add the package name to the `plugins` list in your `opencode.json`; OpenCode
30
+ installs it from npm on startup:
31
+
32
+ ```json
33
+ {
34
+ "plugins": ["opencode-session-turns"]
35
+ }
36
+ ```
37
+
38
+ Restart OpenCode Desktop (the GUI bundle is loaded at window startup).
39
+
40
+ ## Usage
41
+
42
+ - Move the mouse to the thin rail on the right edge of the conversation.
43
+ - Or press `⌘⌥R` / run `/turns` to reveal it.
44
+ - Click a turn to jump to that message.
45
+
46
+ ## How it works (and why it is not trivial)
47
+
48
+ OpenCode Desktop 2.2.6 has some sharp edges that a GUI plugin has to respect:
49
+
50
+ - The timeline is **virtualized**: only rows near the viewport are mounted, so
51
+ distant turns are not in the DOM.
52
+ - The desktop app uses a **memory history** for routing. `window.location`
53
+ stays at `oc://renderer/index.html` and is decoupled from the real route.
54
+ Writing `#message-<id>` to `window.location` (or firing `popstate`) makes the
55
+ host parse `/index.html` as a route and throw a fatal `Unrecognised route!`.
56
+ - The GUI SDK exposes **no** `scrollToMessage()` / `navigate()` API
57
+ (`ctx.router` is read-only).
58
+
59
+ So this plugin:
60
+
61
+ 1. Picks the real route from `ctx.router.path()` (a valid
62
+ `/server/…/session/…` path).
63
+ 2. Fakes a click on an `<a href="<route>#message-<id>">`. The host's own
64
+ document-level link interceptor turns that into a **memory-router navigate**,
65
+ which drives the host's cursor machine to page in history and seek to the
66
+ row — without touching `window.location`.
67
+ 3. The host only pages in about **one history page (~5 turns) per seek**, so the
68
+ plugin keeps toggling the hash to an already-loaded turn and back to the
69
+ target, pulling one page at a time until the target row mounts.
70
+
71
+ Rows that are already mounted are scrolled locally for an instant jump.
72
+
73
+ ## Build
74
+
75
+ ```sh
76
+ npm install
77
+ npm run build # -> dist/gui-experimental.cjs
78
+ ```
79
+
80
+ After changing the source, rebuild and reload the plugin in OpenCode Desktop
81
+ (Settings → Extensions → Plugins → Reload) or restart the app.
82
+
83
+ ## License
84
+
85
+ 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.0",
4
+ "description": "OpenCode Desktop plugin: a right-edge rail listing every user turn of the current session; click a turn 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
+ })