opencode-session-turns 0.1.0 → 0.1.2

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 CHANGED
@@ -1,33 +1,33 @@
1
1
  # opencode-session-turns
2
2
 
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.
3
+ **OpenCode Desktop 插件:在会话右缘列出每一轮的「用户提问」,悬停展开,点击即刻跳转到那一条消息。**
6
4
 
7
- > Works on OpenCode Desktop 2.2.6. The rail is a GUI plugin, loaded from the
8
- > prebuilt `./gui-experimental` bundle.
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` 包形式加载)。
9
10
 
10
11
  ```
11
- ┌─ Session turns (12) ────────┐
12
- │ 09:14 #1 Fix this error… │
13
- │ 09:22 #2 What about async?│
14
- │ 09:35 #3 Continue │
12
+ ┌─ 会话轮次 (12) ─────────────┐
13
+ │ 09:14 #1 帮我看看这个报错… │
14
+ │ 09:22 #2 那改成异步呢? │
15
+ │ 09:35 #3 继续 │
15
16
  │ ... │
16
17
  └─────────────────────────────┘
17
18
  ```
18
19
 
19
- ## Features
20
+ ## 功能
20
21
 
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.
22
+ - 列出当前会话的每一轮用户提问(从会话消息接口分页拉取,`type=user`)。
23
+ - 鼠标移到窗口右缘的细条上自动展开,移开自动收起。
24
+ - 点击任意一轮,**跳转到对应消息**——包括很久以前、尚未渲染的历史轮次。
25
+ - 自动高亮当前视口所在的轮次。
26
+ - 斜杠命令 `/turns`、快捷键 `⌘⌥R`(Windows/Linux 为 `Ctrl+Alt+R`)展开轮次条。
26
27
 
27
- ## Install
28
+ ## 安装
28
29
 
29
- Add the package name to the `plugins` list in your `opencode.json`; OpenCode
30
- installs it from npm on startup:
30
+ 把包名加入 `opencode.json` 的 `plugins` 列表,OpenCode 启动时会自动从 npm 安装:
31
31
 
32
32
  ```json
33
33
  {
@@ -35,51 +35,46 @@ installs it from npm on startup:
35
35
  }
36
36
  ```
37
37
 
38
- Restart OpenCode Desktop (the GUI bundle is loaded at window startup).
38
+ 然后重启 OpenCode Desktop(GUI bundle 在窗口启动时加载)。
39
39
 
40
- ## Usage
40
+ ## 使用
41
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.
42
+ - 把鼠标移到会话右侧的细条上展开;
43
+ - 或按 `⌘⌥R` / 输入 `/turns` 展开;
44
+ - 点击某一轮即可跳到那条消息。
45
45
 
46
- ## How it works (and why it is not trivial)
46
+ ## 实现原理(为什么并不简单)
47
47
 
48
- OpenCode Desktop 2.2.6 has some sharp edges that a GUI plugin has to respect:
48
+ OpenCode Desktop 2.2.6 有几个必须绕开的陷阱:
49
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).
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` 只读)。
58
55
 
59
- So this plugin:
56
+ 因此本插件的做法是:
60
57
 
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.
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
+ 「已加载的最早一轮」和目标,逐页把历史翻出来,直到目标行挂载。
70
65
 
71
- Rows that are already mounted are scrolled locally for an instant jump.
66
+ 已挂载的行则直接用本地滚动,瞬时定位。
72
67
 
73
- ## Build
68
+ ## 构建
74
69
 
75
70
  ```sh
76
71
  npm install
77
72
  npm run build # -> dist/gui-experimental.cjs
78
73
  ```
79
74
 
80
- After changing the source, rebuild and reload the plugin in OpenCode Desktop
81
- (Settings → Extensions → Plugins → Reload) or restart the app.
75
+ 改完源码后重新构建,并在 OpenCode Desktop 里 Reload 插件
76
+ (Settings → Extensions → Plugins → Reload)或重启应用。
82
77
 
83
- ## License
78
+ ## 许可证
84
79
 
85
80
  MIT © meiyx7
@@ -1,5 +1,5 @@
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=`
1
+ "use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const l=require("solid-js/web"),_=require("@opencode/plugin/gui"),i=require("solid-js");var we=l.template("<div class=sr-status>"),xe=l.template('<div class=sr-rail><div class=sr-head><span class=sr-title>会话轮次</span><button type=button class=sr-close title="关闭 (Esc)">×'),ye=l.template("<div class=sr-note>"),ke=l.template("<button type=button class=sr-turn><span class=sr-text><span class=sr-num>#</span> </span><span class=sr-dash>");const H="v11",ae="message-",re="session-turns:diag",U="session-turns:open",$e=40,Ee=200,Se=50,_e=30,Te=15e3,Ce=6e3,Le=800;let le=0,G="",W="";const se=s=>{const r=String(s?.text??"").split(`
2
+ `).map(h=>h.trim()).find(h=>h.length>0);if(r)return r;const c=s?.files?.length??0;return c>0?`[附件 ×${c}]`:"(空消息)"},oe=s=>String(s).padStart(2,"0"),Me=s=>{const r=new Date(s??0);return`${oe(r.getHours())}:${oe(r.getMinutes())}`},ie=s=>new Promise(r=>setTimeout(r,s)),ce=()=>document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport')??document.querySelector('[data-slot="session-timeline-scroll"] [data-scrollable]')??(()=>{let r=document.querySelector("[data-message-id]")?.parentElement;for(;r;){const c=getComputedStyle(r);if(/(auto|scroll)/.test(c.overflowY)&&r.scrollHeight>r.clientHeight+1)return r;r=r.parentElement}})(),y=s=>document.getElementById(`${ae}${s}`)??[...document.querySelectorAll("[data-message-id]")].find(r=>r.getAttribute("data-message-id")===s),F=s=>{const r=ce(),c=[...document.querySelectorAll("[data-message-id]")],h=c[0]?.getAttribute("data-message-id")??"",P=W?` · 目标 ${y(W)?"在":"不在"} DOM`:"",T=h?` · 行ID ${h.slice(0,20)}`:"";let k="(空)";try{k=s.router.path()||"(空)"}catch{}return`轮次 ${le} · 容器 ${r?"OK":"缺失"} · DOM ${c.length} 行${P}${T} · 路由 ${k} · hash ${window.location.hash||"(空)"}${G?` · ${G}`:""}`},J=(s,r)=>{r.scrollIntoView({block:"start"});const c=s.querySelector("[data-session-title]"),h=c instanceof HTMLElement?c.offsetHeight:0;h&&(s.scrollTop-=h+12)},Ae=s=>{let r="";try{r=s.router.path()||""}catch{}return r.startsWith("/")?r:""},Ie=s=>{const r=document.createElement("a");r.setAttribute("href",s),r.setAttribute("replace",""),r.setAttribute("noscroll",""),r.style.display="none",document.body.appendChild(r);try{r.dispatchEvent(new MouseEvent("click",{bubbles:!0,cancelable:!0,button:0,view:window}))}finally{r.remove()}},Pe=`
3
3
  .sr-rail {
4
4
  position: fixed;
5
5
  top: 50%;
@@ -16,14 +16,52 @@
16
16
  overflow-y: auto;
17
17
  overflow-x: hidden;
18
18
  scrollbar-width: none;
19
+ /* keep wheel scrolling from chaining into the host timeline when the rail
20
+ reaches its own end */
21
+ overscroll-behavior: contain;
19
22
  z-index: 400;
20
23
  border-radius: 10px;
21
24
  color: var(--text-base, currentColor);
22
25
  background: transparent;
23
- transition: width .18s ease;
26
+ transition: width .18s ease, background .18s ease, box-shadow .18s ease;
24
27
  }
25
28
  .sr-rail::-webkit-scrollbar { display: none; }
26
- .sr-rail[data-open="true"] { width: 360px; background: transparent; }
29
+ .sr-rail[data-open="true"] {
30
+ width: 360px;
31
+ padding: 10px 8px;
32
+ justify-content: flex-start;
33
+ background: var(--surface-base, rgba(125,125,125,.16));
34
+ backdrop-filter: blur(14px);
35
+ -webkit-backdrop-filter: blur(14px);
36
+ box-shadow: 0 6px 28px rgba(0,0,0,.22);
37
+ }
38
+ .sr-head {
39
+ display: none;
40
+ align-items: center;
41
+ justify-content: space-between;
42
+ gap: 8px;
43
+ padding: 0 2px 6px;
44
+ margin-bottom: 4px;
45
+ border-bottom: 1px solid rgba(127,127,127,.22);
46
+ flex-shrink: 0;
47
+ width: 100%;
48
+ }
49
+ .sr-rail[data-open="true"] .sr-head { display: flex; }
50
+ .sr-title { font-size: 11px; opacity: .55; white-space: nowrap; }
51
+ .sr-close {
52
+ border: 0;
53
+ background: transparent;
54
+ color: inherit;
55
+ font: inherit;
56
+ font-size: 13px;
57
+ line-height: 1;
58
+ padding: 2px 7px;
59
+ border-radius: 4px;
60
+ cursor: pointer;
61
+ opacity: .5;
62
+ flex-shrink: 0;
63
+ }
64
+ .sr-close:hover { opacity: 1; background: var(--surface-base-hover, rgba(127,127,127,.14)); }
27
65
  .sr-turn {
28
66
  display: flex;
29
67
  align-items: center;
@@ -39,6 +77,7 @@
39
77
  font: inherit;
40
78
  border-radius: 4px;
41
79
  }
80
+ .sr-turn:focus-visible, .sr-close:focus-visible { outline: 1px solid currentColor; outline-offset: -1px; }
42
81
  .sr-dash {
43
82
  display: block;
44
83
  width: 10px;
@@ -67,6 +106,7 @@
67
106
  background: transparent;
68
107
  }
69
108
  .sr-rail[data-open="true"] .sr-text { display: block; }
109
+ .sr-num { opacity: .75; margin-right: 5px; font-variant-numeric: tabular-nums; }
70
110
  .sr-turn:hover .sr-text {
71
111
  opacity: 1;
72
112
  background: var(--surface-base-hover, rgba(127,127,127,.14));
@@ -76,6 +116,19 @@
76
116
  font-weight: 600;
77
117
  background: var(--surface-base-hover, rgba(127,127,127,.14));
78
118
  }
119
+ /* brief highlight of the row a jump landed on */
120
+ .sr-flash-target { animation: srPulse 1.5s ease-out 1; }
121
+ @keyframes srPulse {
122
+ 0%, 30% { background: rgba(255,170,60,.4); }
123
+ 100% { background: transparent; }
124
+ }
125
+ .sr-note {
126
+ display: none;
127
+ font-size: 11px;
128
+ opacity: .4;
129
+ padding: 2px;
130
+ }
131
+ .sr-rail[data-open="true"] .sr-note { display: block; }
79
132
  .sr-status {
80
133
  position: fixed;
81
134
  right: 30px;
@@ -91,5 +144,4 @@
91
144
  white-space: pre-wrap;
92
145
  max-width: min(720px, 90vw);
93
146
  }
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;
147
+ `;function De(s){const r=_.usePlugin(),[c,h]=i.createSignal([]),[P,T]=i.createSignal(""),[k,$]=i.createSignal(!1),[de,ue]=i.createSignal(""),[B,V]=i.createSignal(""),b=new Map;let D=0,C=!1,L=!1,j="",S;const M=()=>s.session()?.id,O=()=>{const e=[...b.values()].sort((n,o)=>n.created-o.created),t=i.untrack(c);t.length===e.length&&t.every((n,o)=>n===e[o])||(h(e),le=e.length)},K=e=>{const t=b.get(e.id);t&&t.created===e.time&&t.text===e.text||b.set(e.id,{id:e.id,created:e.time,text:e.text})},pe=async(e,t,n)=>{const o=n?{sessionID:e,type:"user",limit:t,cursor:n}:{sessionID:e,type:"user",limit:t,order:"desc"};return await r.client.message.list(o)},q=async(e,t)=>{const n=++D;C=!0;let o="";try{let a;const u=t==="full"?$e:1,f=t==="full"?Ee:Se;let d=t!=="full";for(let p=0;p<u;p++){let m;try{m=await pe(e,f,a)}catch(x){o=String(x?.message??x);break}if(n!==D)return;d||(b.clear(),d=!0);let g=0;for(const x of m?.data??[])b.has(x.id)||(K({id:x.id,time:x.time?.created??0,text:se(x)}),g++);O();const w=m?.cursor?.next;if(!w||g===0)break;a=w}}finally{C=!1,j=e,G=o,ue(b.size===0?o||"暂无用户消息":""),L&&(L=!1,Y())}},Y=()=>{S||(S=setTimeout(()=>{S=void 0;const e=i.untrack(M);e&&q(e,"light")},300))},Z=e=>{if(!(!e||e!==i.untrack(M))){if(C){L=!0;return}Y()}},fe=e=>{const t=e?.type;if(t==="message.updated"){const n=e?.data??{},o=n.info;o?.role==="user"&&o?.id&&(K({id:o.id,time:o.time?.created??0,text:se(o)}),O()),Z(n.sessionID)}else if(t==="message.removed"){const n=e?.data??{};n.messageID&&b.delete(n.messageID)&&O(),Z(n.sessionID)}else if(t==="location.shutdown"){const n=i.untrack(M);n&&j===n&&q(n,"full")}};i.onMount(()=>{let e=!1,t=0,n;(async()=>{for(;!e;){const u=r.client.event.subscribe()[Symbol.asyncIterator]();try{for(;!e;){const f=await u.next();if(f.done)break;t=0,fe(f.value)}}catch{}try{await u.return?.()}catch{}if(e)break;t++,await ie(Math.min(2e3*t,15e3))}})(),i.onCleanup(()=>{e=!0,clearTimeout(n),S&&clearTimeout(S)})}),i.createEffect(()=>{const e=M();e&&i.untrack(()=>{j!==e&&(D++,C=!1,L=!1,b.clear(),h([]),T(""),N="",q(e,"full"))})});let A;const I=()=>(A?.isConnected||(A=ce()),A),he=()=>{const e=I();if(!e)return;const t=e.querySelectorAll("[data-message-id]");if(t.length===0)return;const n=e.getBoundingClientRect().top+40;for(const o of t)if(o.getBoundingClientRect().bottom>=n){const a=o.getAttribute("data-message-id");a&&T(a);break}};let R=!1;const X=()=>{R||(R=!0,requestAnimationFrame(()=>{R=!1,he()}))};i.onMount(()=>{const e=()=>X();document.addEventListener("scroll",e,!0);const t=setInterval(X,1500);i.onCleanup(()=>{document.removeEventListener("scroll",e,!0),clearInterval(t)})});let Q=0;const v=(e,t=2600)=>{const n=++Q;V(e),setTimeout(()=>{n===Q&&V("")},t)};i.onMount(()=>{const e=n=>{n.key==="Escape"&&i.untrack(k)&&($(!1),n.stopPropagation())},t=n=>{if(!i.untrack(k))return;(n.composedPath?.()??[]).some(u=>u instanceof Element&&u.closest?.(".sr-rail"))||$(!1)};window.addEventListener("keydown",e,!0),document.addEventListener("pointerdown",t,!0),i.onCleanup(()=>{window.removeEventListener("keydown",e,!0),document.removeEventListener("pointerdown",t,!0)})}),i.onMount(()=>{const e=()=>v(`${H} · ${F(r)}`),t=()=>$(n=>!n);window.addEventListener(re,e),window.addEventListener(U,t),i.onCleanup(()=>{window.removeEventListener(re,e),window.removeEventListener(U,t)}),v(`会话轮次 ${H} 已加载`)});let N="",E=0,ee=0,te="";const z=e=>{try{e.classList.remove("sr-flash-target"),e.offsetWidth,e.classList.add("sr-flash-target"),setTimeout(()=>e.classList.remove("sr-flash-target"),1600)}catch{}},me=()=>{for(const e of c())if(y(e.id))return e},ne=(e,t)=>{Ie(`${e}#${ae}${t}`),N=t},ge=(e,t,n)=>new Promise(o=>{let a=!1,u,f,d;const p=w=>{if(!a){a=!0;try{u?.disconnect()}catch{}clearInterval(f),clearTimeout(d),o(w??null)}},m=()=>{if(t!==E)return p(null);const w=y(e);w&&p(w)},g=y(e);if(g)return p(g);u=new MutationObserver(m),u.observe(document.documentElement,{childList:!0,subtree:!0}),f=setInterval(m,120),d=setTimeout(()=>p(y(e)),Math.max(400,n))}),be=async e=>{const t=++E;W=e.id;const n=Date.now();if(e.id===te&&n-ee<Le){const d=y(e.id),p=I();if(d&&p){J(p,d),z(d);return}}ee=n,te=e.id;const o=I(),a=y(e.id);if(a&&o){J(o,a),z(a),v("已定位");return}const u=Ae(r);if(!u){v(`无法读取当前路由,无法定位 · ${F(r)}`);return}const f=Date.now()+Te;for(let d=0;d<_e&&Date.now()<f;d++){if(t!==E)return;if(v(d===0?"定位中…":`加载历史… ${d}`),N===e.id){const g=me()??c()[0];g&&(ne(u,g.id),await ie(120))}if(t!==E)return;ne(u,e.id);const p=Math.min(Ce,f-Date.now()),m=await ge(e.id,t,p);if(t!==E)return;if(m){const g=I()??o;g&&J(g,m),z(m),v("已定位");return}}t===E&&v(`未能定位 · ${F(r)}`)},ve=[l.createComponent(i.Show,{get when(){return B()},get children(){var e=we();return l.insert(e,B),e}}),(()=>{var e=xe(),t=e.firstChild,n=t.firstChild;n.firstChild;var o=n.nextSibling;return e.addEventListener("mouseleave",()=>$(!1)),e.addEventListener("mouseenter",()=>$(!0)),l.insert(n,(()=>{var a=l.memo(()=>c().length>0);return()=>a()?` · ${c().length}`:""})(),null),o.$$click=()=>$(!1),l.insert(e,l.createComponent(i.Show,{get when(){return c().length>0},get fallback(){return(()=>{var a=ye();return l.insert(a,()=>de()||`… ${H}`),a})()},get children(){return l.createComponent(i.For,{get each(){return c()},children:(a,u)=>(()=>{var f=ke(),d=f.firstChild,p=d.firstChild;p.firstChild;var m=p.nextSibling;return f.$$click=()=>{be(a)},l.insert(p,()=>u()+1,null),l.insert(d,()=>Me(a.created),m),l.insert(d,()=>a.text,null),l.effect(()=>l.setAttribute(f,"data-active",P()===a.id?"true":"false")),f})()})}}),null),l.effect(()=>l.setAttribute(e,"data-open",k()?"true":"false")),e})()];return l.createComponent(l.Portal,{get mount(){return document.body},children:ve})}const je=_.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(s){s.add(_.Style,Pe),s.add(_.Slot,{at:"window.bottom",render:()=>l.createComponent(i.Show,{get when(){return s.sessions.current()},get children(){return l.createComponent(De,{session:()=>s.sessions.current()})}})}),s.add(_.Command,()=>({id:"jump",title:s.t("command.jump"),description:s.t("command.jump.description"),group:s.t("command.jump"),section:"navigation",bind:"mod+alt+r",slash:{name:"turns"},run:()=>{window.dispatchEvent(new Event(U))}}))}});l.delegateEvents(["click"]);exports.default=je;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-session-turns",
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.",
3
+ "version": "0.1.2",
4
+ "description": "OpenCode Desktop 插件:在会话右缘列出每一轮用户提问,点击即可跳转到对应消息。A right-edge rail of user turns; click one to jump to that message.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "meiyx7",
package/src/gui.tsx CHANGED
@@ -6,11 +6,23 @@ import { Portal } from "solid-js/web"
6
6
  // constants / helpers
7
7
  // ---------------------------------------------------------------------------
8
8
 
9
- const VERSION = "v10"
9
+ const VERSION = "v11"
10
10
  const HASH_PREFIX = "message-"
11
11
  const DIAG_EVENT = "session-turns:diag"
12
12
  const OPEN_EVENT = "session-turns:open"
13
13
 
14
+ // paging: a full load walks every page; a light refresh pulls just the newest
15
+ // page (new messages always land at the head of a desc listing).
16
+ const FULL_PAGE_LIMIT = 40
17
+ const FULL_PAGE_SIZE = 200
18
+ const LIGHT_PAGE_SIZE = 50
19
+
20
+ // jump loop budgets
21
+ const JUMP_ROUNDS = 30
22
+ const JUMP_TOTAL_MS = 15000
23
+ const JUMP_ROW_TIMEOUT_MS = 6000
24
+ const SAME_TARGET_WINDOW_MS = 800
25
+
14
26
  // Filled by Rail.load()/jump() so the command palette can report diagnostics.
15
27
  let lastTurnCount = 0
16
28
  let lastNote = ""
@@ -143,14 +155,52 @@ const CSS = `
143
155
  overflow-y: auto;
144
156
  overflow-x: hidden;
145
157
  scrollbar-width: none;
158
+ /* keep wheel scrolling from chaining into the host timeline when the rail
159
+ reaches its own end */
160
+ overscroll-behavior: contain;
146
161
  z-index: 400;
147
162
  border-radius: 10px;
148
163
  color: var(--text-base, currentColor);
149
164
  background: transparent;
150
- transition: width .18s ease;
165
+ transition: width .18s ease, background .18s ease, box-shadow .18s ease;
151
166
  }
152
167
  .sr-rail::-webkit-scrollbar { display: none; }
153
- .sr-rail[data-open="true"] { width: 360px; background: transparent; }
168
+ .sr-rail[data-open="true"] {
169
+ width: 360px;
170
+ padding: 10px 8px;
171
+ justify-content: flex-start;
172
+ background: var(--surface-base, rgba(125,125,125,.16));
173
+ backdrop-filter: blur(14px);
174
+ -webkit-backdrop-filter: blur(14px);
175
+ box-shadow: 0 6px 28px rgba(0,0,0,.22);
176
+ }
177
+ .sr-head {
178
+ display: none;
179
+ align-items: center;
180
+ justify-content: space-between;
181
+ gap: 8px;
182
+ padding: 0 2px 6px;
183
+ margin-bottom: 4px;
184
+ border-bottom: 1px solid rgba(127,127,127,.22);
185
+ flex-shrink: 0;
186
+ width: 100%;
187
+ }
188
+ .sr-rail[data-open="true"] .sr-head { display: flex; }
189
+ .sr-title { font-size: 11px; opacity: .55; white-space: nowrap; }
190
+ .sr-close {
191
+ border: 0;
192
+ background: transparent;
193
+ color: inherit;
194
+ font: inherit;
195
+ font-size: 13px;
196
+ line-height: 1;
197
+ padding: 2px 7px;
198
+ border-radius: 4px;
199
+ cursor: pointer;
200
+ opacity: .5;
201
+ flex-shrink: 0;
202
+ }
203
+ .sr-close:hover { opacity: 1; background: var(--surface-base-hover, rgba(127,127,127,.14)); }
154
204
  .sr-turn {
155
205
  display: flex;
156
206
  align-items: center;
@@ -166,6 +216,7 @@ const CSS = `
166
216
  font: inherit;
167
217
  border-radius: 4px;
168
218
  }
219
+ .sr-turn:focus-visible, .sr-close:focus-visible { outline: 1px solid currentColor; outline-offset: -1px; }
169
220
  .sr-dash {
170
221
  display: block;
171
222
  width: 10px;
@@ -194,6 +245,7 @@ const CSS = `
194
245
  background: transparent;
195
246
  }
196
247
  .sr-rail[data-open="true"] .sr-text { display: block; }
248
+ .sr-num { opacity: .75; margin-right: 5px; font-variant-numeric: tabular-nums; }
197
249
  .sr-turn:hover .sr-text {
198
250
  opacity: 1;
199
251
  background: var(--surface-base-hover, rgba(127,127,127,.14));
@@ -203,6 +255,19 @@ const CSS = `
203
255
  font-weight: 600;
204
256
  background: var(--surface-base-hover, rgba(127,127,127,.14));
205
257
  }
258
+ /* brief highlight of the row a jump landed on */
259
+ .sr-flash-target { animation: srPulse 1.5s ease-out 1; }
260
+ @keyframes srPulse {
261
+ 0%, 30% { background: rgba(255,170,60,.4); }
262
+ 100% { background: transparent; }
263
+ }
264
+ .sr-note {
265
+ display: none;
266
+ font-size: 11px;
267
+ opacity: .4;
268
+ padding: 2px;
269
+ }
270
+ .sr-rail[data-open="true"] .sr-note { display: block; }
206
271
  .sr-status {
207
272
  position: fixed;
208
273
  right: 30px;
@@ -218,7 +283,6 @@ const CSS = `
218
283
  white-space: pre-wrap;
219
284
  max-width: min(720px, 90vw);
220
285
  }
221
- .sr-note { font-size: 11px; opacity: .4; padding: 2px; }
222
286
  `
223
287
 
224
288
  // ---------------------------------------------------------------------------
@@ -233,93 +297,214 @@ function Rail(props) {
233
297
  const [note, setNote] = createSignal("")
234
298
  const [status, setStatus] = createSignal("")
235
299
 
236
- let loading = false
300
+ // ---- turn cache ---------------------------------------------------------
301
+ // Object identity is kept stable across refreshes so Solid's <For> reuses
302
+ // existing DOM instead of rebuilding the whole list on every update.
303
+ const cache = new Map()
304
+ let loadToken = 0
305
+ let loadingActive = false
306
+ let pendingBump = false
237
307
  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 = ""
308
+ let lightTimer
242
309
 
243
- const load = async (sessionID) => {
244
- if (!sessionID || loading) return
245
- loading = true
246
- const collected = new Map()
247
- let cursor
310
+ const sessionID = () => props.session()?.id
311
+
312
+ const publish = () => {
313
+ const list = [...cache.values()].sort((a, b) => a.created - b.created)
314
+ const prev = untrack(turns)
315
+ // Skip the signal write when nothing changed; <For> diffs by reference.
316
+ if (prev.length === list.length && prev.every((item, i) => item === list[i])) return
317
+ setTurns(list)
318
+ lastTurnCount = list.length
319
+ }
320
+
321
+ const upsert = (item) => {
322
+ const existing = cache.get(item.id)
323
+ if (existing && existing.created === item.time && existing.text === item.text) return
324
+ cache.set(item.id, { id: item.id, created: item.time, text: item.text })
325
+ }
326
+
327
+ const fetchPage = async (id, limit, cursor) => {
328
+ // The server rejects `cursor` together with `order`
329
+ // (InvalidCursorError: "Cursor cannot be combined with order").
330
+ // The cursor itself already encodes the original order/direction,
331
+ // so only the first page declares order: "desc".
332
+ const params = cursor
333
+ ? { sessionID: id, type: "user", limit, cursor }
334
+ : { sessionID: id, type: "user", limit, order: "desc" }
335
+ return await ctx.client.message.list(params)
336
+ }
337
+
338
+ const load = async (id, mode) => {
339
+ const token = ++loadToken
340
+ loadingActive = true
248
341
  let error = ""
249
342
  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
-
343
+ let cursor
344
+ const pages = mode === "full" ? FULL_PAGE_LIMIT : 1
345
+ const limit = mode === "full" ? FULL_PAGE_SIZE : LIGHT_PAGE_SIZE
346
+ let cleared = mode !== "full"
347
+ for (let page = 0; page < pages; page++) {
259
348
  let response
260
349
  try {
261
- response = await ctx.client.message.list(params)
350
+ response = await fetchPage(id, limit, cursor)
262
351
  } catch (cause) {
263
352
  error = String(cause?.message ?? cause)
264
353
  break
265
354
  }
266
-
267
- const data = response?.data ?? []
355
+ // Superseded by a newer load (or a session switch): drop this page
356
+ // instead of polluting the new session's cache with stale ids.
357
+ if (token !== loadToken) return
358
+ // Full mode clears only after the first page succeeded, so a server
359
+ // error never blanks out the rail (v10's hard-won lesson).
360
+ if (!cleared) {
361
+ cache.clear()
362
+ cleared = true
363
+ }
268
364
  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
- })
365
+ for (const message of response?.data ?? []) {
366
+ if (cache.has(message.id)) continue
367
+ upsert({ id: message.id, time: message.time?.created ?? 0, text: firstLine(message) })
276
368
  fresh++
277
369
  }
370
+ // Publish page by page: long sessions appear progressively instead of
371
+ // popping in all at once after every page has been fetched.
372
+ publish()
278
373
  const next = response?.cursor?.next
279
374
  if (!next || fresh === 0) break
280
375
  cursor = next
281
376
  }
282
377
  } finally {
283
- loading = false
378
+ loadingActive = false
379
+ loadedFor = id
380
+ lastNote = error
381
+ setNote(cache.size === 0 ? error || "暂无用户消息" : "")
382
+ // A message arrived while a full load was running: catch up now.
383
+ if (pendingBump) {
384
+ pendingBump = false
385
+ scheduleLight()
386
+ }
284
387
  }
388
+ }
285
389
 
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
390
+ // ---- event-driven refresh (replaces the 5s full-list polling) -----------
391
+ // The SDK shares one SSE connection with the host; consuming it here is a
392
+ // second iterator over the same stream, not a second connection. Events are
393
+ // debounced into a single-page light refresh, so streaming bursts cost
394
+ // nothing beyond a few cheap `type` comparisons per event.
395
+ const scheduleLight = () => {
396
+ if (lightTimer) return
397
+ lightTimer = setTimeout(() => {
398
+ lightTimer = undefined
399
+ const id = untrack(sessionID)
400
+ if (id) void load(id, "light")
401
+ }, 300)
294
402
  }
295
403
 
296
- const sessionID = () => props.session()?.id
404
+ const bump = (eventSessionID) => {
405
+ // Only the session currently shown matters; ignore everything else.
406
+ if (!eventSessionID || eventSessionID !== untrack(sessionID)) return
407
+ if (loadingActive) {
408
+ pendingBump = true
409
+ return
410
+ }
411
+ scheduleLight()
412
+ }
413
+
414
+ const handleEvent = (event) => {
415
+ const type = event?.type
416
+ if (type === "message.updated") {
417
+ const data = event?.data ?? {}
418
+ const info = data.info
419
+ // Fast path: the payload carries the message itself, so a new user turn
420
+ // shows up instantly; the debounced light refresh reconciles the rest.
421
+ if (info?.role === "user" && info?.id) {
422
+ upsert({ id: info.id, time: info.time?.created ?? 0, text: firstLine(info) })
423
+ publish()
424
+ }
425
+ bump(data.sessionID)
426
+ } else if (type === "message.removed") {
427
+ const data = event?.data ?? {}
428
+ if (data.messageID && cache.delete(data.messageID)) publish()
429
+ bump(data.sessionID)
430
+ } else if (type === "location.shutdown") {
431
+ // Server rebuilt: do one full reload of the current session.
432
+ const id = untrack(sessionID)
433
+ if (id && loadedFor === id) void load(id, "full")
434
+ }
435
+ }
436
+
437
+ onMount(() => {
438
+ let stopped = false
439
+ let attempt = 0
440
+ let retryTimer
441
+ const run = async () => {
442
+ while (!stopped) {
443
+ const stream = ctx.client.event.subscribe()
444
+ const iterator = stream[Symbol.asyncIterator]()
445
+ try {
446
+ while (!stopped) {
447
+ const next = await iterator.next()
448
+ if (next.done) break
449
+ attempt = 0
450
+ handleEvent(next.value)
451
+ }
452
+ } catch {}
453
+ // iterator.return() cancels the SSE reader (the SDK's generator
454
+ // finally-block); errors from it are irrelevant at this point.
455
+ try {
456
+ await iterator.return?.()
457
+ } catch {}
458
+ if (stopped) break
459
+ attempt++
460
+ await sleep(Math.min(2000 * attempt, 15000))
461
+ }
462
+ }
463
+ void run()
464
+ onCleanup(() => {
465
+ stopped = true
466
+ clearTimeout(retryTimer)
467
+ if (lightTimer) clearTimeout(lightTimer)
468
+ })
469
+ })
297
470
 
298
471
  createEffect(() => {
299
472
  const id = sessionID()
300
473
  if (!id) return
301
474
  untrack(() => {
302
475
  if (loadedFor !== id) {
476
+ // Invalidate anything in flight for the previous session first, so its
477
+ // finally-block can't write `loadedFor`/`note` for the wrong session.
478
+ loadToken++
479
+ loadingActive = false
480
+ pendingBump = false
481
+ cache.clear()
303
482
  setTurns([])
304
483
  setActive("")
305
484
  lastNavID = ""
306
- void load(id)
485
+ void load(id, "full")
307
486
  }
308
487
  })
309
488
  })
310
489
 
311
- const poll = setInterval(() => {
312
- const id = sessionID()
313
- if (id) void load(id)
314
- }, 5000)
315
- onCleanup(() => clearInterval(poll))
490
+ // ---- active-turn tracking ----------------------------------------------
491
+ // Driven by scroll events (captured at document level so it also sees the
492
+ // real scroller without querying first) plus one rAF-throttled pass, with a
493
+ // slow interval as a fallback for rows mounting without a scroll (history
494
+ // paging, virtual list swaps).
495
+ let scrollerCache
496
+ const scroller = () => {
497
+ if (scrollerCache?.isConnected) return scrollerCache
498
+ scrollerCache = findScroller()
499
+ return scrollerCache
500
+ }
316
501
 
317
502
  const trackActive = () => {
318
- const scroller = findScroller()
319
- if (!scroller) return
320
- const rows = [...scroller.querySelectorAll("[data-message-id]")]
503
+ const sc = scroller()
504
+ if (!sc) return
505
+ const rows = sc.querySelectorAll("[data-message-id]")
321
506
  if (rows.length === 0) return
322
- const top = scroller.getBoundingClientRect().top + 40
507
+ const top = sc.getBoundingClientRect().top + 40
323
508
  for (const row of rows) {
324
509
  if (row.getBoundingClientRect().bottom >= top) {
325
510
  const id = row.getAttribute("data-message-id")
@@ -328,32 +513,98 @@ function Rail(props) {
328
513
  }
329
514
  }
330
515
  }
331
- const track = setInterval(trackActive, 400)
332
- onCleanup(() => clearInterval(track))
333
516
 
334
- const flash = (text) => {
517
+ let rafPending = false
518
+ const scheduleTrack = () => {
519
+ if (rafPending) return
520
+ rafPending = true
521
+ requestAnimationFrame(() => {
522
+ rafPending = false
523
+ trackActive()
524
+ })
525
+ }
526
+
527
+ onMount(() => {
528
+ const onScroll = () => scheduleTrack()
529
+ document.addEventListener("scroll", onScroll, true)
530
+ const fallback = setInterval(scheduleTrack, 1500)
531
+ onCleanup(() => {
532
+ document.removeEventListener("scroll", onScroll, true)
533
+ clearInterval(fallback)
534
+ })
535
+ })
536
+
537
+ // ---- status flash -------------------------------------------------------
538
+ // A sequence guard so a slow timeout from an earlier flash can never clear a
539
+ // newer message.
540
+ let flashSeq = 0
541
+ const flash = (text, ms = 2600) => {
542
+ const seq = ++flashSeq
335
543
  setStatus(text)
336
- setTimeout(() => setStatus((current) => (current === text ? "" : current)), 3000)
544
+ setTimeout(() => {
545
+ if (seq === flashSeq) setStatus("")
546
+ }, ms)
337
547
  }
338
548
 
549
+ // ---- open / close interactions ------------------------------------------
339
550
  onMount(() => {
340
- const onDiag = () => flash(`${VERSION} · ${diagnostics(ctx)}`)
341
- const onOpen = () => {
342
- setOpen(true)
343
- clearTimeout(closeTimer)
344
- closeTimer = setTimeout(() => setOpen(false), 4000)
551
+ const onKey = (event) => {
552
+ if (event.key !== "Escape") return
553
+ if (untrack(open)) {
554
+ setOpen(false)
555
+ // Only swallow Esc while the rail is actually open, so the host keeps
556
+ // its own Escape handling the rest of the time.
557
+ event.stopPropagation()
558
+ }
345
559
  }
560
+ const onPointerDown = (event) => {
561
+ if (!untrack(open)) return
562
+ const path = event.composedPath?.() ?? []
563
+ const inside = path.some((node) => node instanceof Element && node.closest?.(".sr-rail"))
564
+ if (!inside) setOpen(false)
565
+ }
566
+ window.addEventListener("keydown", onKey, true)
567
+ document.addEventListener("pointerdown", onPointerDown, true)
568
+ onCleanup(() => {
569
+ window.removeEventListener("keydown", onKey, true)
570
+ document.removeEventListener("pointerdown", onPointerDown, true)
571
+ })
572
+ })
573
+
574
+ onMount(() => {
575
+ const onDiag = () => flash(`${VERSION} · ${diagnostics(ctx)}`)
576
+ // /turns toggles the panel; Esc / outside click / × also close it. No more
577
+ // forced auto-close after 4s.
578
+ const onOpen = () => setOpen((value) => !value)
346
579
  window.addEventListener(DIAG_EVENT, onDiag)
347
580
  window.addEventListener(OPEN_EVENT, onOpen)
348
581
  onCleanup(() => {
349
582
  window.removeEventListener(DIAG_EVENT, onDiag)
350
583
  window.removeEventListener(OPEN_EVENT, onOpen)
351
- clearTimeout(closeTimer)
352
584
  })
353
585
  // One-shot confirmation that this window actually runs the rebuilt bundle.
354
586
  flash(`会话轮次 ${VERSION} 已加载`)
355
587
  })
356
588
 
589
+ // ---- jump ----------------------------------------------------------------
590
+ // The memory router's hash is invisible to us (window.location is stale), so
591
+ // track the last id we navigated to in order to force a hash change each round.
592
+ let lastNavID = ""
593
+ let jumpToken = 0
594
+ let lastJumpAt = 0
595
+ let lastJumpTurnID = ""
596
+
597
+ // Brief highlight on the row a jump landed on. The row may be unmounted by
598
+ // the virtual list right after; the timer then just no-ops.
599
+ const pulse = (row) => {
600
+ try {
601
+ row.classList.remove("sr-flash-target")
602
+ void row.offsetWidth // restart the animation when re-pulsing the same row
603
+ row.classList.add("sr-flash-target")
604
+ setTimeout(() => row.classList.remove("sr-flash-target"), 1600)
605
+ } catch {}
606
+ }
607
+
357
608
  // Oldest turn that is currently mounted (i.e. already paged in), used as a
358
609
  // safe "different id" to toggle the router hash so it re-runs its seek.
359
610
  const oldestLoadedTurn = () => {
@@ -366,14 +617,63 @@ function Rail(props) {
366
617
  lastNavID = id
367
618
  }
368
619
 
620
+ // Resolves when the row enters the DOM (MutationObserver, with a cheap poll
621
+ // as a belt-and-braces fallback), or null on timeout / supersession.
622
+ const waitForRow = (messageID, token, timeout) =>
623
+ new Promise((resolve) => {
624
+ let done = false
625
+ let observer
626
+ let poll
627
+ let timer
628
+ const finish = (row) => {
629
+ if (done) return
630
+ done = true
631
+ try {
632
+ observer?.disconnect()
633
+ } catch {}
634
+ clearInterval(poll)
635
+ clearTimeout(timer)
636
+ resolve(row ?? null)
637
+ }
638
+ const check = () => {
639
+ if (token !== jumpToken) return finish(null)
640
+ const row = findRow(messageID)
641
+ if (row) finish(row)
642
+ }
643
+ const existing = findRow(messageID)
644
+ if (existing) return finish(existing)
645
+ observer = new MutationObserver(check)
646
+ observer.observe(document.documentElement, { childList: true, subtree: true })
647
+ poll = setInterval(check, 120)
648
+ timer = setTimeout(() => finish(findRow(messageID)), Math.max(400, timeout))
649
+ })
650
+
369
651
  const jump = async (turn) => {
652
+ const token = ++jumpToken
370
653
  lastJumpID = turn.id
371
- const scroller = findScroller()
654
+ const now = Date.now()
655
+
656
+ // Same target clicked again shortly after: treat as idempotent re-scroll
657
+ // instead of restarting the paging loop.
658
+ if (turn.id === lastJumpTurnID && now - lastJumpAt < SAME_TARGET_WINDOW_MS) {
659
+ const row = findRow(turn.id)
660
+ const sc = scroller()
661
+ if (row && sc) {
662
+ scrollToRow(sc, row)
663
+ pulse(row)
664
+ return
665
+ }
666
+ }
667
+ lastJumpAt = now
668
+ lastJumpTurnID = turn.id
669
+
670
+ const sc = scroller()
372
671
  const row = findRow(turn.id)
373
672
 
374
673
  // Fast path: the row is mounted, scroll locally.
375
- if (row && scroller) {
376
- scrollToRow(scroller, row)
674
+ if (row && sc) {
675
+ scrollToRow(sc, row)
676
+ pulse(row)
377
677
  flash("已定位")
378
678
  return
379
679
  }
@@ -387,7 +687,10 @@ function Rail(props) {
387
687
  // The host only pages in ~one history page (~5 turns) per seek and then goes
388
688
  // idle. Drive it: each round toggles the hash to an already-loaded turn and
389
689
  // back to the target, so the cursor machine runs another seek/page.
390
- for (let round = 0; round < 30; round++) {
690
+ // A newer click bumps jumpToken, which cancels this loop at every await.
691
+ const deadline = Date.now() + JUMP_TOTAL_MS
692
+ for (let round = 0; round < JUMP_ROUNDS && Date.now() < deadline; round++) {
693
+ if (token !== jumpToken) return
391
694
  flash(round === 0 ? "定位中…" : `加载历史… ${round}`)
392
695
 
393
696
  if (lastNavID === turn.id) {
@@ -397,20 +700,22 @@ function Rail(props) {
397
700
  await sleep(120)
398
701
  }
399
702
  }
703
+ if (token !== jumpToken) return
400
704
  navTo(path, turn.id)
401
705
 
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
706
+ const remaining = Math.min(JUMP_ROW_TIMEOUT_MS, deadline - Date.now())
707
+ const landed = await waitForRow(turn.id, token, remaining)
708
+ if (token !== jumpToken) return
709
+ if (landed) {
710
+ const live = scroller() ?? sc
407
711
  if (live) scrollToRow(live, landed)
712
+ pulse(landed)
408
713
  flash("已定位")
409
714
  return
410
715
  }
411
716
  }
412
717
 
413
- flash(`未能定位 · ${diagnostics(ctx)}`)
718
+ if (token === jumpToken) flash(`未能定位 · ${diagnostics(ctx)}`)
414
719
  }
415
720
 
416
721
  const rail = (
@@ -424,9 +729,15 @@ function Rail(props) {
424
729
  onMouseEnter={() => setOpen(true)}
425
730
  onMouseLeave={() => setOpen(false)}
426
731
  >
732
+ <div class="sr-head">
733
+ <span class="sr-title">会话轮次{turns().length > 0 ? ` · ${turns().length}` : ""}</span>
734
+ <button type="button" class="sr-close" title="关闭 (Esc)" onClick={() => setOpen(false)}>
735
+ ×
736
+ </button>
737
+ </div>
427
738
  <Show when={turns().length > 0} fallback={<div class="sr-note">{note() || `… ${VERSION}`}</div>}>
428
739
  <For each={turns()}>
429
- {(turn) => (
740
+ {(turn, index) => (
430
741
  <button
431
742
  type="button"
432
743
  class="sr-turn"
@@ -434,6 +745,7 @@ function Rail(props) {
434
745
  onClick={() => void jump(turn)}
435
746
  >
436
747
  <span class="sr-text">
748
+ <span class="sr-num">#{index() + 1}</span>
437
749
  {formatTime(turn.created)} {turn.text}
438
750
  </span>
439
751
  <span class="sr-dash" />
@@ -486,7 +798,6 @@ export default Plugin.define({
486
798
  slash: { name: "turns" },
487
799
  run: () => {
488
800
  window.dispatchEvent(new Event(OPEN_EVENT))
489
- window.dispatchEvent(new Event(DIAG_EVENT))
490
801
  },
491
802
  }))
492
803
  },