opencode-session-turns 0.1.1 → 0.1.3

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
@@ -20,8 +20,11 @@ click a turn to jump straight to that message.
20
20
  ## 功能
21
21
 
22
22
  - 列出当前会话的每一轮用户提问(从会话消息接口分页拉取,`type=user`)。
23
- - 鼠标移到窗口右缘的细条上自动展开,移开自动收起。
23
+ - 鼠标移到窗口右缘的细条上自动展开,移开自动收起;长历史跳转期间面板保持展开。
24
24
  - 点击任意一轮,**跳转到对应消息**——包括很久以前、尚未渲染的历史轮次。
25
+ - 深历史需要逐页加载时,头部实时显示「跳转中 · 第 N 页」,目标轮次琥珀色脉冲,
26
+ 底部状态条同步提示;按 `Esc` 或点 × 可随时取消。
27
+ - 定位成功后精准描边闪烁**那一条用户气泡**(跟随气泡自身圆角 + 柔光),不再整行填色。
25
28
  - 自动高亮当前视口所在的轮次。
26
29
  - 斜杠命令 `/turns`、快捷键 `⌘⌥R`(Windows/Linux 为 `Ctrl+Alt+R`)展开轮次条。
27
30
 
@@ -62,6 +65,8 @@ OpenCode Desktop 2.2.6 有几个必须绕开的陷阱:
62
65
  `window.location`。
63
66
  3. 宿主每次 seek 只向前加载**约一页(~5 轮)**历史,所以插件会交替把 hash 指向
64
67
  「已加载的最早一轮」和目标,逐页把历史翻出来,直到目标行挂载。
68
+ 每一轮监听行集合指纹,宿主一旦翻入新一页就立即进入下一轮(不再固定等待),
69
+ 并在面板头部显示页码进度;总预算 45 秒,`Esc` 可取消。
65
70
 
66
71
  已挂载的行则直接用本地滚动,瞬时定位。
67
72
 
@@ -1,29 +1,85 @@
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 a=require("solid-js/web"),A=require("@opencode/plugin/gui"),i=require("solid-js");var Lt=a.template("<div class=sr-status>"),It=a.template('<span class="sr-title sr-progress">跳转中'),Mt=a.template("<div class=sr-rail><div class=sr-head><button type=button class=sr-close>×</button></div><div class=sr-list>"),Pt=a.template("<span class=sr-title>会话轮次"),Dt=a.template("<div class=sr-note>"),jt=a.template("<button type=button class=sr-turn><span class=sr-text><span class=sr-num>#</span><span class=sr-time></span></span><span class=sr-dash>");const G="v12",wt="message-",ht="session-turns:diag",Y="session-turns:open",qt=40,Rt=200,Ot=50,Jt=120,V=45e3,Nt=4e3,zt=300,Ft=800;let xt=0,Z="",X="";const gt=o=>{const n=String(o?.text??"").split(`
2
+ `).map(g=>g.trim()).find(g=>g.length>0);if(n)return n;const f=o?.files?.length??0;return f>0?`[附件 ×${f}]`:"(空消息)"},mt=o=>String(o).padStart(2,"0"),Ht=o=>{const n=new Date(o??0);return`${mt(n.getHours())}:${mt(n.getMinutes())}`},bt=o=>new Promise(n=>setTimeout(n,o)),vt=()=>document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport')??document.querySelector('[data-slot="session-timeline-scroll"] [data-scrollable]')??(()=>{let n=document.querySelector("[data-message-id]")?.parentElement;for(;n;){const f=getComputedStyle(n);if(/(auto|scroll)/.test(f.overflowY)&&n.scrollHeight>n.clientHeight+1)return n;n=n.parentElement}})(),$=o=>document.getElementById(`${wt}${o}`)??[...document.querySelectorAll("[data-message-id]")].find(n=>n.getAttribute("data-message-id")===o),W=o=>{const n=vt(),f=[...document.querySelectorAll("[data-message-id]")],g=f[0]?.getAttribute("data-message-id")??"",j=X?` · 目标 ${$(X)?"在":"不在"} DOM`:"",C=g?` · 行ID ${g.slice(0,20)}`:"";let E="(空)";try{E=o.router.path()||"(空)"}catch{}return`轮次 ${xt} · 容器 ${n?"OK":"缺失"} · DOM ${f.length} 行${j}${C} · 路由 ${E} · hash ${window.location.hash||"(空)"}${Z?` · ${Z}`:""}`},K=(o,n)=>{n.scrollIntoView({block:"start"});const f=o.querySelector("[data-session-title]"),g=f instanceof HTMLElement?f.offsetHeight:0;g&&(o.scrollTop-=g+12)},Ut=o=>{let n="";try{n=o.router.path()||""}catch{}return n.startsWith("/")?n:""},Bt=o=>{const n=document.createElement("a");n.setAttribute("href",o),n.setAttribute("replace",""),n.setAttribute("noscroll",""),n.style.display="none",document.body.appendChild(n);try{n.dispatchEvent(new MouseEvent("click",{bubbles:!0,cancelable:!0,button:0,view:window}))}finally{n.remove()}},Gt=`
3
3
  .sr-rail {
4
4
  position: fixed;
5
5
  top: 50%;
6
6
  transform: translateY(-50%);
7
- right: 10px;
8
- max-height: 60vh;
9
- width: 14px;
7
+ right: 14px;
8
+ max-height: 66vh;
9
+ width: 16px;
10
10
  display: flex;
11
11
  flex-direction: column;
12
- align-items: flex-end;
13
12
  justify-content: center;
14
- gap: 2px;
15
- padding: 8px 4px;
16
- overflow-y: auto;
17
- overflow-x: hidden;
18
- scrollbar-width: none;
13
+ padding: 6px 2px;
14
+ overflow: hidden;
19
15
  z-index: 400;
20
16
  border-radius: 10px;
17
+ border: 1px solid transparent;
21
18
  color: var(--text-base, currentColor);
22
19
  background: transparent;
23
- transition: width .18s ease;
20
+ transition: width .18s ease, background .18s ease, box-shadow .18s ease, border-color .18s ease;
21
+ }
22
+ .sr-rail[data-open="true"] {
23
+ width: 340px;
24
+ padding: 10px;
25
+ justify-content: flex-start;
26
+ background: var(--surface-base, rgba(24,24,26,.86));
27
+ backdrop-filter: blur(16px);
28
+ -webkit-backdrop-filter: blur(16px);
29
+ border-color: rgba(127,127,127,.25);
30
+ box-shadow: 0 8px 32px rgba(0,0,0,.28);
31
+ }
32
+ .sr-head {
33
+ display: none;
34
+ align-items: center;
35
+ justify-content: space-between;
36
+ gap: 8px;
37
+ padding: 0 2px 8px;
38
+ margin-bottom: 6px;
39
+ border-bottom: 1px solid rgba(127,127,127,.2);
40
+ flex-shrink: 0;
41
+ }
42
+ .sr-rail[data-open="true"] .sr-head { display: flex; }
43
+ .sr-title { font-size: 12px; opacity: .6; white-space: nowrap; }
44
+ .sr-title.sr-progress {
45
+ opacity: .95;
46
+ color: rgba(255,170,60,.95);
47
+ font-variant-numeric: tabular-nums;
48
+ }
49
+ .sr-close {
50
+ border: 0;
51
+ background: transparent;
52
+ color: inherit;
53
+ font: inherit;
54
+ font-size: 13px;
55
+ line-height: 1;
56
+ padding: 2px 7px;
57
+ border-radius: 4px;
58
+ cursor: pointer;
59
+ opacity: .5;
60
+ flex-shrink: 0;
61
+ }
62
+ .sr-close:hover { opacity: 1; background: var(--surface-base-hover, rgba(127,127,127,.14)); }
63
+ /* the scrollable list; the header stays fixed above it */
64
+ .sr-list {
65
+ flex: 1;
66
+ min-height: 0;
67
+ display: flex;
68
+ flex-direction: column;
69
+ justify-content: center;
70
+ gap: 2px;
71
+ overflow-y: auto;
72
+ overflow-x: hidden;
73
+ scrollbar-width: none;
74
+ }
75
+ .sr-rail[data-open="true"] .sr-list { justify-content: flex-start; gap: 1px; scrollbar-width: thin; }
76
+ .sr-list::-webkit-scrollbar { display: none; }
77
+ .sr-rail[data-open="true"] .sr-list::-webkit-scrollbar { display: block; width: 8px; }
78
+ .sr-list::-webkit-scrollbar-track { background: transparent; }
79
+ .sr-rail[data-open="true"] .sr-list::-webkit-scrollbar-thumb {
80
+ background: rgba(127,127,127,.35);
81
+ border-radius: 4px;
24
82
  }
25
- .sr-rail::-webkit-scrollbar { display: none; }
26
- .sr-rail[data-open="true"] { width: 360px; background: transparent; }
27
83
  .sr-turn {
28
84
  display: flex;
29
85
  align-items: center;
@@ -39,6 +95,16 @@
39
95
  font: inherit;
40
96
  border-radius: 4px;
41
97
  }
98
+ /* expanded: dash flips to a left-side indicator, content reads left-to-right */
99
+ .sr-rail[data-open="true"] .sr-turn {
100
+ flex-direction: row-reverse;
101
+ justify-content: flex-start;
102
+ gap: 9px;
103
+ padding: 5px 8px;
104
+ min-height: 0;
105
+ border-radius: 6px;
106
+ }
107
+ .sr-turn:focus-visible, .sr-close:focus-visible { outline: 1px solid currentColor; outline-offset: -1px; }
42
108
  .sr-dash {
43
109
  display: block;
44
110
  width: 10px;
@@ -47,10 +113,13 @@
47
113
  background: currentColor;
48
114
  opacity: .28;
49
115
  flex-shrink: 0;
50
- transition: opacity .12s ease, width .12s ease;
116
+ transition: opacity .12s ease, width .12s ease, height .12s ease;
51
117
  }
52
118
  .sr-turn:hover .sr-dash,
53
119
  .sr-turn[data-active="true"] .sr-dash { opacity: .95; width: 18px; }
120
+ .sr-rail[data-open="true"] .sr-dash { width: 3px; height: 16px; }
121
+ .sr-rail[data-open="true"] .sr-turn:hover .sr-dash,
122
+ .sr-rail[data-open="true"] .sr-turn[data-active="true"] .sr-dash { width: 3px; height: 22px; }
54
123
  .sr-text {
55
124
  display: none;
56
125
  margin-right: 10px;
@@ -66,16 +135,71 @@
66
135
  opacity: .45;
67
136
  background: transparent;
68
137
  }
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));
138
+ .sr-rail[data-open="true"] .sr-text {
139
+ display: -webkit-box;
140
+ -webkit-line-clamp: 2;
141
+ -webkit-box-orient: vertical;
142
+ overflow: hidden;
143
+ flex: 1;
144
+ min-width: 0;
145
+ margin: 0;
146
+ padding: 0;
147
+ text-align: left;
148
+ white-space: normal;
149
+ line-height: 1.45;
150
+ max-width: none;
151
+ opacity: .8;
152
+ }
153
+ .sr-num, .sr-time {
154
+ opacity: .55;
155
+ margin-right: 6px;
156
+ font-variant-numeric: tabular-nums;
73
157
  }
74
- .sr-turn[data-active="true"] .sr-text {
158
+ .sr-turn:hover { background: var(--surface-base-hover, rgba(127,127,127,.14)); }
159
+ .sr-rail[data-open="true"] .sr-turn:hover .sr-text,
160
+ .sr-rail[data-open="true"] .sr-turn[data-active="true"] .sr-text { opacity: 1; }
161
+ .sr-turn[data-active="true"] { font-weight: 500; }
162
+ /* the turn currently being jumped to */
163
+ .sr-turn[data-busy="true"] { background: rgba(255,170,60,.12); }
164
+ .sr-turn[data-busy="true"] .sr-dash {
75
165
  opacity: 1;
76
- font-weight: 600;
77
- background: var(--surface-base-hover, rgba(127,127,127,.14));
166
+ width: 3px;
167
+ height: 22px;
168
+ background: rgba(255,170,60,.95);
169
+ animation: srBusy 1s ease-in-out infinite;
170
+ }
171
+ .sr-turn[data-busy="false"] .sr-dash { animation: none; }
172
+ @keyframes srBusy {
173
+ 0%, 100% { opacity: .35; }
174
+ 50% { opacity: 1; }
175
+ }
176
+ /* pulse a landed bubble: outline follows the bubble's own border-radius, so no
177
+ shape is imposed and the message background stays untouched */
178
+ .sr-flash-ring { animation: srRing 1.8s ease-out 1; }
179
+ @keyframes srRing {
180
+ 0% {
181
+ outline: 2px solid rgba(255,170,60,.95);
182
+ outline-offset: 2px;
183
+ box-shadow: 0 0 24px rgba(255,170,60,.45);
184
+ }
185
+ 60% {
186
+ outline: 2px solid rgba(255,170,60,.35);
187
+ outline-offset: 2px;
188
+ box-shadow: 0 0 12px rgba(255,170,60,.15);
189
+ }
190
+ 100% {
191
+ outline: 2px solid rgba(255,170,60,0);
192
+ outline-offset: 2px;
193
+ box-shadow: 0 0 0 rgba(255,170,60,0);
194
+ }
195
+ }
196
+ .sr-note {
197
+ display: none;
198
+ font-size: 11px;
199
+ opacity: .4;
200
+ padding: 2px;
78
201
  }
202
+ .sr-rail[data-open="true"] .sr-note { display: block; }
79
203
  .sr-status {
80
204
  position: fixed;
81
205
  right: 30px;
@@ -91,5 +215,4 @@
91
215
  white-space: pre-wrap;
92
216
  max-width: min(720px, 90vw);
93
217
  }
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;
218
+ `;function Vt(o){const n=A.usePlugin(),[f,g]=i.createSignal([]),[j,C]=i.createSignal(""),[E,S]=i.createSignal(!1),[yt,kt]=i.createSignal(""),[Q,tt]=i.createSignal(""),[x,q]=i.createSignal(!1),[et,L]=i.createSignal(0),[rt,R]=i.createSignal(""),v=new Map;let O=0,I=!1,M=!1,J="",_;const P=()=>o.session()?.id,N=()=>{const t=[...v.values()].sort((r,l)=>r.created-l.created),e=i.untrack(f);e.length===t.length&&e.every((r,l)=>r===t[l])||(g(t),xt=t.length)},nt=t=>{const e=v.get(t.id);e&&e.created===t.time&&e.text===t.text||v.set(t.id,{id:t.id,created:t.time,text:t.text})},St=async(t,e,r)=>{const l=r?{sessionID:t,type:"user",limit:e,cursor:r}:{sessionID:t,type:"user",limit:e,order:"desc"};return await n.client.message.list(l)},z=async(t,e)=>{const r=++O;I=!0;let l="";try{let s;const c=e==="full"?qt:1,d=e==="full"?Rt:Ot;let p=e!=="full";for(let b=0;b<c;b++){let m;try{m=await St(t,d,s)}catch(h){l=String(h?.message??h);break}if(r!==O)return;p||(v.clear(),p=!0);let u=0;for(const h of m?.data??[])v.has(h.id)||(nt({id:h.id,time:h.time?.created??0,text:gt(h)}),u++);N();const k=m?.cursor?.next;if(!k||u===0)break;s=k}}finally{I=!1,J=t,Z=l,kt(v.size===0?l||"暂无用户消息":""),M&&(M=!1,st())}},st=()=>{_||(_=setTimeout(()=>{_=void 0;const t=i.untrack(P);t&&z(t,"light")},300))},at=t=>{if(!(!t||t!==i.untrack(P))){if(I){M=!0;return}st()}},$t=t=>{const e=t?.type;if(e==="message.updated"){const r=t?.data??{},l=r.info;l?.role==="user"&&l?.id&&(nt({id:l.id,time:l.time?.created??0,text:gt(l)}),N()),at(r.sessionID)}else if(e==="message.removed"){const r=t?.data??{};r.messageID&&v.delete(r.messageID)&&N(),at(r.sessionID)}else if(e==="location.shutdown"){const r=i.untrack(P);r&&J===r&&z(r,"full")}};i.onMount(()=>{let t=!1,e=0,r;(async()=>{for(;!t;){const c=n.client.event.subscribe()[Symbol.asyncIterator]();try{for(;!t;){const d=await c.next();if(d.done)break;e=0,$t(d.value)}}catch{}try{await c.return?.()}catch{}if(t)break;e++,await bt(Math.min(2e3*e,15e3))}})(),i.onCleanup(()=>{t=!0,clearTimeout(r),_&&clearTimeout(_)})}),i.createEffect(()=>{const t=P();t&&i.untrack(()=>{J!==t&&(O++,I=!1,M=!1,v.clear(),g([]),C(""),H="",z(t,"full"))})});let D;const T=()=>(D?.isConnected||(D=vt()),D),Et=()=>{const t=T();if(!t)return;const e=t.querySelectorAll("[data-message-id]");if(e.length===0)return;const r=t.getBoundingClientRect().top+40;for(const l of e)if(l.getBoundingClientRect().bottom>=r){const s=l.getAttribute("data-message-id");s&&C(s);break}};let F=!1;const ot=()=>{F||(F=!0,requestAnimationFrame(()=>{F=!1,Et()}))};i.onMount(()=>{const t=()=>ot();document.addEventListener("scroll",t,!0);const e=setInterval(ot,1500);i.onCleanup(()=>{document.removeEventListener("scroll",t,!0),clearInterval(e)})});let it=0;const w=(t,e=2600)=>{const r=++it;tt(t),setTimeout(()=>{r===it&&tt("")},e)};i.onMount(()=>{const t=r=>{if(r.key==="Escape"){if(i.untrack(x)){pt(),r.stopPropagation();return}i.untrack(E)&&(S(!1),r.stopPropagation())}},e=r=>{if(!i.untrack(E))return;(r.composedPath?.()??[]).some(c=>c instanceof Element&&c.closest?.(".sr-rail"))||S(!1)};window.addEventListener("keydown",t,!0),document.addEventListener("pointerdown",e,!0),i.onCleanup(()=>{window.removeEventListener("keydown",t,!0),document.removeEventListener("pointerdown",e,!0)})}),i.onMount(()=>{const t=()=>w(`${G} · ${W(n)}`),e=()=>S(r=>!r);window.addEventListener(ht,t),window.addEventListener(Y,e),i.onCleanup(()=>{window.removeEventListener(ht,t),window.removeEventListener(Y,e)}),w(`会话轮次 ${G} 已加载`)});let H="",y=0,lt=0,ct="";const U=t=>{try{const e=t.querySelector('[data-slot="user-message-text"]')??t.querySelector('[data-slot="user-message-body"]')??t;e.classList.remove("sr-flash-ring"),e.offsetWidth,e.classList.add("sr-flash-ring"),setTimeout(()=>e.classList.remove("sr-flash-ring"),1900)}catch{}},_t=()=>{for(const t of f())if($(t.id))return t},dt=(t,e)=>{Bt(`${t}#${wt}${e}`),H=e},ut=()=>{const e=T()?.querySelectorAll("[data-message-id]")??[];if(e.length===0)return"0";const r=e[0].getAttribute("data-message-id")??"",l=e[e.length-1].getAttribute("data-message-id")??"";return`${e.length}:${r}:${l}`},Tt=(t,e,r)=>new Promise(l=>{let s=!1,c,d,p;const b=Date.now(),m=ut(),u=B=>{if(!s){s=!0;try{c?.disconnect()}catch{}clearInterval(d),clearTimeout(p),l(B??null)}},k=B=>{if(e!==y)return u(null);const ft=$(t);if(ft)return u(ft);B&&Date.now()-b>zt&&ut()!==m&&u(null)},h=$(t);if(h)return u(h);c=new MutationObserver(()=>k(!1)),c.observe(document.documentElement,{childList:!0,subtree:!0}),d=setInterval(()=>k(!0),120),p=setTimeout(()=>u($(t)),Math.max(400,r))}),pt=()=>{y++,q(!1),L(0),R(""),w("已取消跳转")},At=async t=>{const e=++y;X=t.id;const r=Date.now();if(t.id===ct&&r-lt<Ft){const d=$(t.id),p=T();if(d&&p){K(p,d),U(d);return}}lt=r,ct=t.id;const l=T(),s=$(t.id);if(s&&l){K(l,s),U(s),w("已定位");return}const c=Ut(n);if(!c){w(`无法读取当前路由,无法定位 · ${W(n)}`);return}q(!0),R(t.id),L(0),S(!0),w("正在定位…",V);try{const d=Date.now()+V;for(let p=0;p<Jt&&Date.now()<d;p++){if(e!==y)return;if(L(p+1),w(`跳转中 · 第 ${p+1} 页`,V),H===t.id){const u=_t()??f()[0];u&&(dt(c,u.id),await bt(120))}if(e!==y)return;dt(c,t.id);const b=Math.min(Nt,d-Date.now()),m=await Tt(t.id,e,b);if(e!==y)return;if(m){const u=T()??l;u&&K(u,m),U(m),w("已定位");return}}e===y&&w(`未能定位 · ${W(n)}`)}finally{e===y&&(q(!1),L(0),R(""))}};i.createEffect(()=>{x()&&rt()&&requestAnimationFrame(()=>{try{document.querySelector('.sr-list .sr-turn[data-busy="true"]')?.scrollIntoView({block:"nearest"})}catch{}})});const Ct=[a.createComponent(i.Show,{get when(){return Q()},get children(){var t=Lt();return a.insert(t,Q),t}}),(()=>{var t=Mt(),e=t.firstChild,r=e.firstChild,l=e.nextSibling;return t.addEventListener("mouseleave",()=>{x()||S(!1)}),t.addEventListener("mouseenter",()=>S(!0)),a.insert(e,a.createComponent(i.Show,{get when(){return x()},get fallback(){return(()=>{var s=Pt();return s.firstChild,a.insert(s,(()=>{var c=a.memo(()=>f().length>0);return()=>c()?` · ${f().length}`:""})(),null),s})()},get children(){var s=It();return s.firstChild,a.insert(s,(()=>{var c=a.memo(()=>et()>0);return()=>c()?` · 第 ${et()} 页`:""})(),null),s}}),r),r.$$click=()=>{x()?pt():S(!1)},a.insert(l,a.createComponent(i.Show,{get when(){return f().length>0},get fallback(){return(()=>{var s=Dt();return a.insert(s,()=>yt()||`… ${G}`),s})()},get children(){return a.createComponent(i.For,{get each(){return f()},children:(s,c)=>(()=>{var d=jt(),p=d.firstChild,b=p.firstChild;b.firstChild;var m=b.nextSibling;return d.$$click=()=>{At(s)},a.insert(b,()=>c()+1,null),a.insert(m,()=>Ht(s.created)),a.insert(p,()=>s.text,null),a.effect(u=>{var k=j()===s.id?"true":"false",h=x()&&rt()===s.id?"true":"false";return k!==u.e&&a.setAttribute(d,"data-active",u.e=k),h!==u.t&&a.setAttribute(d,"data-busy",u.t=h),u},{e:void 0,t:void 0}),d})()})}})),a.effect(s=>{var c=E()?"true":"false",d=x()?"true":"false",p=x()?"取消跳转 (Esc)":"关闭 (Esc)";return c!==s.e&&a.setAttribute(t,"data-open",s.e=c),d!==s.t&&a.setAttribute(t,"data-jumping",s.t=d),p!==s.a&&a.setAttribute(r,"title",s.a=p),s},{e:void 0,t:void 0,a:void 0}),t})()];return a.createComponent(a.Portal,{get mount(){return document.body},children:Ct})}const Wt=A.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(o){o.add(A.Style,Gt),o.add(A.Slot,{at:"window.bottom",render:()=>a.createComponent(i.Show,{get when(){return o.sessions.current()},get children(){return a.createComponent(Vt,{session:()=>o.sessions.current()})}})}),o.add(A.Command,()=>({id:"jump",title:o.t("command.jump"),description:o.t("command.jump.description"),group:o.t("command.jump"),section:"navigation",bind:"mod+alt+r",slash:{name:"turns"},run:()=>{window.dispatchEvent(new Event(Y))}}))}});a.delegateEvents(["click"]);exports.default=Wt;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-session-turns",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
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",
package/src/gui.tsx CHANGED
@@ -6,11 +6,26 @@ import { Portal } from "solid-js/web"
6
6
  // constants / helpers
7
7
  // ---------------------------------------------------------------------------
8
8
 
9
- const VERSION = "v10"
9
+ const VERSION = "v12"
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. One round pages in roughly one host history page (~5
21
+ // turns); a round ends as soon as the host appends rows, so the loop is paced
22
+ // by real progress instead of a fixed sleep.
23
+ const JUMP_ROUNDS = 120
24
+ const JUMP_TOTAL_MS = 45000
25
+ const JUMP_ROW_TIMEOUT_MS = 4000
26
+ const JUMP_SETTLE_MS = 300
27
+ const SAME_TARGET_WINDOW_MS = 800
28
+
14
29
  // Filled by Rail.load()/jump() so the command palette can report diagnostics.
15
30
  let lastTurnCount = 0
16
31
  let lastNote = ""
@@ -131,26 +146,82 @@ const CSS = `
131
146
  position: fixed;
132
147
  top: 50%;
133
148
  transform: translateY(-50%);
134
- right: 10px;
135
- max-height: 60vh;
136
- width: 14px;
149
+ right: 14px;
150
+ max-height: 66vh;
151
+ width: 16px;
137
152
  display: flex;
138
153
  flex-direction: column;
139
- align-items: flex-end;
140
154
  justify-content: center;
141
- gap: 2px;
142
- padding: 8px 4px;
143
- overflow-y: auto;
144
- overflow-x: hidden;
145
- scrollbar-width: none;
155
+ padding: 6px 2px;
156
+ overflow: hidden;
146
157
  z-index: 400;
147
158
  border-radius: 10px;
159
+ border: 1px solid transparent;
148
160
  color: var(--text-base, currentColor);
149
161
  background: transparent;
150
- transition: width .18s ease;
162
+ transition: width .18s ease, background .18s ease, box-shadow .18s ease, border-color .18s ease;
163
+ }
164
+ .sr-rail[data-open="true"] {
165
+ width: 340px;
166
+ padding: 10px;
167
+ justify-content: flex-start;
168
+ background: var(--surface-base, rgba(24,24,26,.86));
169
+ backdrop-filter: blur(16px);
170
+ -webkit-backdrop-filter: blur(16px);
171
+ border-color: rgba(127,127,127,.25);
172
+ box-shadow: 0 8px 32px rgba(0,0,0,.28);
173
+ }
174
+ .sr-head {
175
+ display: none;
176
+ align-items: center;
177
+ justify-content: space-between;
178
+ gap: 8px;
179
+ padding: 0 2px 8px;
180
+ margin-bottom: 6px;
181
+ border-bottom: 1px solid rgba(127,127,127,.2);
182
+ flex-shrink: 0;
183
+ }
184
+ .sr-rail[data-open="true"] .sr-head { display: flex; }
185
+ .sr-title { font-size: 12px; opacity: .6; white-space: nowrap; }
186
+ .sr-title.sr-progress {
187
+ opacity: .95;
188
+ color: rgba(255,170,60,.95);
189
+ font-variant-numeric: tabular-nums;
190
+ }
191
+ .sr-close {
192
+ border: 0;
193
+ background: transparent;
194
+ color: inherit;
195
+ font: inherit;
196
+ font-size: 13px;
197
+ line-height: 1;
198
+ padding: 2px 7px;
199
+ border-radius: 4px;
200
+ cursor: pointer;
201
+ opacity: .5;
202
+ flex-shrink: 0;
203
+ }
204
+ .sr-close:hover { opacity: 1; background: var(--surface-base-hover, rgba(127,127,127,.14)); }
205
+ /* the scrollable list; the header stays fixed above it */
206
+ .sr-list {
207
+ flex: 1;
208
+ min-height: 0;
209
+ display: flex;
210
+ flex-direction: column;
211
+ justify-content: center;
212
+ gap: 2px;
213
+ overflow-y: auto;
214
+ overflow-x: hidden;
215
+ scrollbar-width: none;
216
+ }
217
+ .sr-rail[data-open="true"] .sr-list { justify-content: flex-start; gap: 1px; scrollbar-width: thin; }
218
+ .sr-list::-webkit-scrollbar { display: none; }
219
+ .sr-rail[data-open="true"] .sr-list::-webkit-scrollbar { display: block; width: 8px; }
220
+ .sr-list::-webkit-scrollbar-track { background: transparent; }
221
+ .sr-rail[data-open="true"] .sr-list::-webkit-scrollbar-thumb {
222
+ background: rgba(127,127,127,.35);
223
+ border-radius: 4px;
151
224
  }
152
- .sr-rail::-webkit-scrollbar { display: none; }
153
- .sr-rail[data-open="true"] { width: 360px; background: transparent; }
154
225
  .sr-turn {
155
226
  display: flex;
156
227
  align-items: center;
@@ -166,6 +237,16 @@ const CSS = `
166
237
  font: inherit;
167
238
  border-radius: 4px;
168
239
  }
240
+ /* expanded: dash flips to a left-side indicator, content reads left-to-right */
241
+ .sr-rail[data-open="true"] .sr-turn {
242
+ flex-direction: row-reverse;
243
+ justify-content: flex-start;
244
+ gap: 9px;
245
+ padding: 5px 8px;
246
+ min-height: 0;
247
+ border-radius: 6px;
248
+ }
249
+ .sr-turn:focus-visible, .sr-close:focus-visible { outline: 1px solid currentColor; outline-offset: -1px; }
169
250
  .sr-dash {
170
251
  display: block;
171
252
  width: 10px;
@@ -174,10 +255,13 @@ const CSS = `
174
255
  background: currentColor;
175
256
  opacity: .28;
176
257
  flex-shrink: 0;
177
- transition: opacity .12s ease, width .12s ease;
258
+ transition: opacity .12s ease, width .12s ease, height .12s ease;
178
259
  }
179
260
  .sr-turn:hover .sr-dash,
180
261
  .sr-turn[data-active="true"] .sr-dash { opacity: .95; width: 18px; }
262
+ .sr-rail[data-open="true"] .sr-dash { width: 3px; height: 16px; }
263
+ .sr-rail[data-open="true"] .sr-turn:hover .sr-dash,
264
+ .sr-rail[data-open="true"] .sr-turn[data-active="true"] .sr-dash { width: 3px; height: 22px; }
181
265
  .sr-text {
182
266
  display: none;
183
267
  margin-right: 10px;
@@ -193,16 +277,71 @@ const CSS = `
193
277
  opacity: .45;
194
278
  background: transparent;
195
279
  }
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));
280
+ .sr-rail[data-open="true"] .sr-text {
281
+ display: -webkit-box;
282
+ -webkit-line-clamp: 2;
283
+ -webkit-box-orient: vertical;
284
+ overflow: hidden;
285
+ flex: 1;
286
+ min-width: 0;
287
+ margin: 0;
288
+ padding: 0;
289
+ text-align: left;
290
+ white-space: normal;
291
+ line-height: 1.45;
292
+ max-width: none;
293
+ opacity: .8;
294
+ }
295
+ .sr-num, .sr-time {
296
+ opacity: .55;
297
+ margin-right: 6px;
298
+ font-variant-numeric: tabular-nums;
200
299
  }
201
- .sr-turn[data-active="true"] .sr-text {
300
+ .sr-turn:hover { background: var(--surface-base-hover, rgba(127,127,127,.14)); }
301
+ .sr-rail[data-open="true"] .sr-turn:hover .sr-text,
302
+ .sr-rail[data-open="true"] .sr-turn[data-active="true"] .sr-text { opacity: 1; }
303
+ .sr-turn[data-active="true"] { font-weight: 500; }
304
+ /* the turn currently being jumped to */
305
+ .sr-turn[data-busy="true"] { background: rgba(255,170,60,.12); }
306
+ .sr-turn[data-busy="true"] .sr-dash {
202
307
  opacity: 1;
203
- font-weight: 600;
204
- background: var(--surface-base-hover, rgba(127,127,127,.14));
308
+ width: 3px;
309
+ height: 22px;
310
+ background: rgba(255,170,60,.95);
311
+ animation: srBusy 1s ease-in-out infinite;
312
+ }
313
+ .sr-turn[data-busy="false"] .sr-dash { animation: none; }
314
+ @keyframes srBusy {
315
+ 0%, 100% { opacity: .35; }
316
+ 50% { opacity: 1; }
205
317
  }
318
+ /* pulse a landed bubble: outline follows the bubble's own border-radius, so no
319
+ shape is imposed and the message background stays untouched */
320
+ .sr-flash-ring { animation: srRing 1.8s ease-out 1; }
321
+ @keyframes srRing {
322
+ 0% {
323
+ outline: 2px solid rgba(255,170,60,.95);
324
+ outline-offset: 2px;
325
+ box-shadow: 0 0 24px rgba(255,170,60,.45);
326
+ }
327
+ 60% {
328
+ outline: 2px solid rgba(255,170,60,.35);
329
+ outline-offset: 2px;
330
+ box-shadow: 0 0 12px rgba(255,170,60,.15);
331
+ }
332
+ 100% {
333
+ outline: 2px solid rgba(255,170,60,0);
334
+ outline-offset: 2px;
335
+ box-shadow: 0 0 0 rgba(255,170,60,0);
336
+ }
337
+ }
338
+ .sr-note {
339
+ display: none;
340
+ font-size: 11px;
341
+ opacity: .4;
342
+ padding: 2px;
343
+ }
344
+ .sr-rail[data-open="true"] .sr-note { display: block; }
206
345
  .sr-status {
207
346
  position: fixed;
208
347
  right: 30px;
@@ -218,7 +357,6 @@ const CSS = `
218
357
  white-space: pre-wrap;
219
358
  max-width: min(720px, 90vw);
220
359
  }
221
- .sr-note { font-size: 11px; opacity: .4; padding: 2px; }
222
360
  `
223
361
 
224
362
  // ---------------------------------------------------------------------------
@@ -232,94 +370,220 @@ function Rail(props) {
232
370
  const [open, setOpen] = createSignal(false)
233
371
  const [note, setNote] = createSignal("")
234
372
  const [status, setStatus] = createSignal("")
235
-
236
- let loading = false
373
+ // Jump progress. While `jumping` is true the panel stays pinned open and the
374
+ // header reports the page being loaded; `busyID` marks the target row.
375
+ const [jumping, setJumping] = createSignal(false)
376
+ const [jumpPage, setJumpPage] = createSignal(0)
377
+ const [busyID, setBusyID] = createSignal("")
378
+
379
+ // ---- turn cache ---------------------------------------------------------
380
+ // Object identity is kept stable across refreshes so Solid's <For> reuses
381
+ // existing DOM instead of rebuilding the whole list on every update.
382
+ const cache = new Map()
383
+ let loadToken = 0
384
+ let loadingActive = false
385
+ let pendingBump = false
237
386
  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 = ""
387
+ let lightTimer
388
+
389
+ const sessionID = () => props.session()?.id
242
390
 
243
- const load = async (sessionID) => {
244
- if (!sessionID || loading) return
245
- loading = true
246
- const collected = new Map()
247
- let cursor
391
+ const publish = () => {
392
+ const list = [...cache.values()].sort((a, b) => a.created - b.created)
393
+ const prev = untrack(turns)
394
+ // Skip the signal write when nothing changed; <For> diffs by reference.
395
+ if (prev.length === list.length && prev.every((item, i) => item === list[i])) return
396
+ setTurns(list)
397
+ lastTurnCount = list.length
398
+ }
399
+
400
+ const upsert = (item) => {
401
+ const existing = cache.get(item.id)
402
+ if (existing && existing.created === item.time && existing.text === item.text) return
403
+ cache.set(item.id, { id: item.id, created: item.time, text: item.text })
404
+ }
405
+
406
+ const fetchPage = async (id, limit, cursor) => {
407
+ // The server rejects `cursor` together with `order`
408
+ // (InvalidCursorError: "Cursor cannot be combined with order").
409
+ // The cursor itself already encodes the original order/direction,
410
+ // so only the first page declares order: "desc".
411
+ const params = cursor
412
+ ? { sessionID: id, type: "user", limit, cursor }
413
+ : { sessionID: id, type: "user", limit, order: "desc" }
414
+ return await ctx.client.message.list(params)
415
+ }
416
+
417
+ const load = async (id, mode) => {
418
+ const token = ++loadToken
419
+ loadingActive = true
248
420
  let error = ""
249
421
  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
-
422
+ let cursor
423
+ const pages = mode === "full" ? FULL_PAGE_LIMIT : 1
424
+ const limit = mode === "full" ? FULL_PAGE_SIZE : LIGHT_PAGE_SIZE
425
+ let cleared = mode !== "full"
426
+ for (let page = 0; page < pages; page++) {
259
427
  let response
260
428
  try {
261
- response = await ctx.client.message.list(params)
429
+ response = await fetchPage(id, limit, cursor)
262
430
  } catch (cause) {
263
431
  error = String(cause?.message ?? cause)
264
432
  break
265
433
  }
266
-
267
- const data = response?.data ?? []
434
+ // Superseded by a newer load (or a session switch): drop this page
435
+ // instead of polluting the new session's cache with stale ids.
436
+ if (token !== loadToken) return
437
+ // Full mode clears only after the first page succeeded, so a server
438
+ // error never blanks out the rail (v10's hard-won lesson).
439
+ if (!cleared) {
440
+ cache.clear()
441
+ cleared = true
442
+ }
268
443
  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
- })
444
+ for (const message of response?.data ?? []) {
445
+ if (cache.has(message.id)) continue
446
+ upsert({ id: message.id, time: message.time?.created ?? 0, text: firstLine(message) })
276
447
  fresh++
277
448
  }
449
+ // Publish page by page: long sessions appear progressively instead of
450
+ // popping in all at once after every page has been fetched.
451
+ publish()
278
452
  const next = response?.cursor?.next
279
453
  if (!next || fresh === 0) break
280
454
  cursor = next
281
455
  }
282
456
  } finally {
283
- loading = false
457
+ loadingActive = false
458
+ loadedFor = id
459
+ lastNote = error
460
+ setNote(cache.size === 0 ? error || "暂无用户消息" : "")
461
+ // A message arrived while a full load was running: catch up now.
462
+ if (pendingBump) {
463
+ pendingBump = false
464
+ scheduleLight()
465
+ }
284
466
  }
467
+ }
285
468
 
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
469
+ // ---- event-driven refresh (replaces the 5s full-list polling) -----------
470
+ // The SDK shares one SSE connection with the host; consuming it here is a
471
+ // second iterator over the same stream, not a second connection. Events are
472
+ // debounced into a single-page light refresh, so streaming bursts cost
473
+ // nothing beyond a few cheap `type` comparisons per event.
474
+ const scheduleLight = () => {
475
+ if (lightTimer) return
476
+ lightTimer = setTimeout(() => {
477
+ lightTimer = undefined
478
+ const id = untrack(sessionID)
479
+ if (id) void load(id, "light")
480
+ }, 300)
294
481
  }
295
482
 
296
- const sessionID = () => props.session()?.id
483
+ const bump = (eventSessionID) => {
484
+ // Only the session currently shown matters; ignore everything else.
485
+ if (!eventSessionID || eventSessionID !== untrack(sessionID)) return
486
+ if (loadingActive) {
487
+ pendingBump = true
488
+ return
489
+ }
490
+ scheduleLight()
491
+ }
492
+
493
+ const handleEvent = (event) => {
494
+ const type = event?.type
495
+ if (type === "message.updated") {
496
+ const data = event?.data ?? {}
497
+ const info = data.info
498
+ // Fast path: the payload carries the message itself, so a new user turn
499
+ // shows up instantly; the debounced light refresh reconciles the rest.
500
+ if (info?.role === "user" && info?.id) {
501
+ upsert({ id: info.id, time: info.time?.created ?? 0, text: firstLine(info) })
502
+ publish()
503
+ }
504
+ bump(data.sessionID)
505
+ } else if (type === "message.removed") {
506
+ const data = event?.data ?? {}
507
+ if (data.messageID && cache.delete(data.messageID)) publish()
508
+ bump(data.sessionID)
509
+ } else if (type === "location.shutdown") {
510
+ // Server rebuilt: do one full reload of the current session.
511
+ const id = untrack(sessionID)
512
+ if (id && loadedFor === id) void load(id, "full")
513
+ }
514
+ }
515
+
516
+ onMount(() => {
517
+ let stopped = false
518
+ let attempt = 0
519
+ let retryTimer
520
+ const run = async () => {
521
+ while (!stopped) {
522
+ const stream = ctx.client.event.subscribe()
523
+ const iterator = stream[Symbol.asyncIterator]()
524
+ try {
525
+ while (!stopped) {
526
+ const next = await iterator.next()
527
+ if (next.done) break
528
+ attempt = 0
529
+ handleEvent(next.value)
530
+ }
531
+ } catch {}
532
+ // iterator.return() cancels the SSE reader (the SDK's generator
533
+ // finally-block); errors from it are irrelevant at this point.
534
+ try {
535
+ await iterator.return?.()
536
+ } catch {}
537
+ if (stopped) break
538
+ attempt++
539
+ await sleep(Math.min(2000 * attempt, 15000))
540
+ }
541
+ }
542
+ void run()
543
+ onCleanup(() => {
544
+ stopped = true
545
+ clearTimeout(retryTimer)
546
+ if (lightTimer) clearTimeout(lightTimer)
547
+ })
548
+ })
297
549
 
298
550
  createEffect(() => {
299
551
  const id = sessionID()
300
552
  if (!id) return
301
553
  untrack(() => {
302
554
  if (loadedFor !== id) {
555
+ // Invalidate anything in flight for the previous session first, so its
556
+ // finally-block can't write `loadedFor`/`note` for the wrong session.
557
+ loadToken++
558
+ loadingActive = false
559
+ pendingBump = false
560
+ cache.clear()
303
561
  setTurns([])
304
562
  setActive("")
305
563
  lastNavID = ""
306
- void load(id)
564
+ void load(id, "full")
307
565
  }
308
566
  })
309
567
  })
310
568
 
311
- const poll = setInterval(() => {
312
- const id = sessionID()
313
- if (id) void load(id)
314
- }, 5000)
315
- onCleanup(() => clearInterval(poll))
569
+ // ---- active-turn tracking ----------------------------------------------
570
+ // Driven by scroll events (captured at document level so it also sees the
571
+ // real scroller without querying first) plus one rAF-throttled pass, with a
572
+ // slow interval as a fallback for rows mounting without a scroll (history
573
+ // paging, virtual list swaps).
574
+ let scrollerCache
575
+ const scroller = () => {
576
+ if (scrollerCache?.isConnected) return scrollerCache
577
+ scrollerCache = findScroller()
578
+ return scrollerCache
579
+ }
316
580
 
317
581
  const trackActive = () => {
318
- const scroller = findScroller()
319
- if (!scroller) return
320
- const rows = [...scroller.querySelectorAll("[data-message-id]")]
582
+ const sc = scroller()
583
+ if (!sc) return
584
+ const rows = sc.querySelectorAll("[data-message-id]")
321
585
  if (rows.length === 0) return
322
- const top = scroller.getBoundingClientRect().top + 40
586
+ const top = sc.getBoundingClientRect().top + 40
323
587
  for (const row of rows) {
324
588
  if (row.getBoundingClientRect().bottom >= top) {
325
589
  const id = row.getAttribute("data-message-id")
@@ -328,32 +592,112 @@ function Rail(props) {
328
592
  }
329
593
  }
330
594
  }
331
- const track = setInterval(trackActive, 400)
332
- onCleanup(() => clearInterval(track))
333
595
 
334
- const flash = (text) => {
596
+ let rafPending = false
597
+ const scheduleTrack = () => {
598
+ if (rafPending) return
599
+ rafPending = true
600
+ requestAnimationFrame(() => {
601
+ rafPending = false
602
+ trackActive()
603
+ })
604
+ }
605
+
606
+ onMount(() => {
607
+ const onScroll = () => scheduleTrack()
608
+ document.addEventListener("scroll", onScroll, true)
609
+ const fallback = setInterval(scheduleTrack, 1500)
610
+ onCleanup(() => {
611
+ document.removeEventListener("scroll", onScroll, true)
612
+ clearInterval(fallback)
613
+ })
614
+ })
615
+
616
+ // ---- status flash -------------------------------------------------------
617
+ // A sequence guard so a slow timeout from an earlier flash can never clear a
618
+ // newer message.
619
+ let flashSeq = 0
620
+ const flash = (text, ms = 2600) => {
621
+ const seq = ++flashSeq
335
622
  setStatus(text)
336
- setTimeout(() => setStatus((current) => (current === text ? "" : current)), 3000)
623
+ setTimeout(() => {
624
+ if (seq === flashSeq) setStatus("")
625
+ }, ms)
337
626
  }
338
627
 
628
+ // ---- open / close interactions ------------------------------------------
339
629
  onMount(() => {
340
- const onDiag = () => flash(`${VERSION} · ${diagnostics(ctx)}`)
341
- const onOpen = () => {
342
- setOpen(true)
343
- clearTimeout(closeTimer)
344
- closeTimer = setTimeout(() => setOpen(false), 4000)
630
+ const onKey = (event) => {
631
+ if (event.key !== "Escape") return
632
+ // During a long jump Esc cancels the jump first and keeps the panel open,
633
+ // so the progress display doesn't vanish mid-flight.
634
+ if (untrack(jumping)) {
635
+ cancelJump()
636
+ event.stopPropagation()
637
+ return
638
+ }
639
+ if (untrack(open)) {
640
+ setOpen(false)
641
+ // Only swallow Esc while the rail is actually open, so the host keeps
642
+ // its own Escape handling the rest of the time.
643
+ event.stopPropagation()
644
+ }
345
645
  }
646
+ const onPointerDown = (event) => {
647
+ if (!untrack(open)) return
648
+ const path = event.composedPath?.() ?? []
649
+ const inside = path.some((node) => node instanceof Element && node.closest?.(".sr-rail"))
650
+ if (!inside) setOpen(false)
651
+ }
652
+ window.addEventListener("keydown", onKey, true)
653
+ document.addEventListener("pointerdown", onPointerDown, true)
654
+ onCleanup(() => {
655
+ window.removeEventListener("keydown", onKey, true)
656
+ document.removeEventListener("pointerdown", onPointerDown, true)
657
+ })
658
+ })
659
+
660
+ onMount(() => {
661
+ const onDiag = () => flash(`${VERSION} · ${diagnostics(ctx)}`)
662
+ // /turns toggles the panel; Esc / outside click / × also close it. No more
663
+ // forced auto-close after 4s.
664
+ const onOpen = () => setOpen((value) => !value)
346
665
  window.addEventListener(DIAG_EVENT, onDiag)
347
666
  window.addEventListener(OPEN_EVENT, onOpen)
348
667
  onCleanup(() => {
349
668
  window.removeEventListener(DIAG_EVENT, onDiag)
350
669
  window.removeEventListener(OPEN_EVENT, onOpen)
351
- clearTimeout(closeTimer)
352
670
  })
353
671
  // One-shot confirmation that this window actually runs the rebuilt bundle.
354
672
  flash(`会话轮次 ${VERSION} 已加载`)
355
673
  })
356
674
 
675
+ // ---- jump ----------------------------------------------------------------
676
+ // The memory router's hash is invisible to us (window.location is stale), so
677
+ // track the last id we navigated to in order to force a hash change each round.
678
+ let lastNavID = ""
679
+ let jumpToken = 0
680
+ let lastJumpAt = 0
681
+ let lastJumpTurnID = ""
682
+
683
+ // Brief highlight on the message bubble. The timeline row spans the whole
684
+ // width, so tinting it looks like a stray orange band. The visible bubble is
685
+ // `[data-slot="user-message-text"]` (background + 10px radius); its parent
686
+ // `user-message-body` is only a layout column. An outline + glow hugs the
687
+ // bubble's own shape without altering its background.
688
+ const pulse = (row) => {
689
+ try {
690
+ const target =
691
+ row.querySelector('[data-slot="user-message-text"]') ??
692
+ row.querySelector('[data-slot="user-message-body"]') ??
693
+ row
694
+ target.classList.remove("sr-flash-ring")
695
+ void target.offsetWidth // restart the animation when re-pulsing the same node
696
+ target.classList.add("sr-flash-ring")
697
+ setTimeout(() => target.classList.remove("sr-flash-ring"), 1900)
698
+ } catch {}
699
+ }
700
+
357
701
  // Oldest turn that is currently mounted (i.e. already paged in), used as a
358
702
  // safe "different id" to toggle the router hash so it re-runs its seek.
359
703
  const oldestLoadedTurn = () => {
@@ -366,14 +710,90 @@ function Rail(props) {
366
710
  lastNavID = id
367
711
  }
368
712
 
713
+ // Cheap fingerprint of the mounted timeline rows. Paging in older history
714
+ // prepends rows, so a changed fingerprint means the host did real work and
715
+ // the next hash toggle can be sent immediately.
716
+ const rowSignature = () => {
717
+ const sc = scroller()
718
+ const rows = sc?.querySelectorAll("[data-message-id]") ?? []
719
+ if (rows.length === 0) return "0"
720
+ const first = rows[0].getAttribute("data-message-id") ?? ""
721
+ const last = rows[rows.length - 1].getAttribute("data-message-id") ?? ""
722
+ return `${rows.length}:${first}:${last}`
723
+ }
724
+
725
+ // Resolves when the row enters the DOM (MutationObserver, with a cheap poll
726
+ // as a belt-and-braces fallback), or null on timeout / supersession / when
727
+ // the host has paged in another slice (the caller then drives the next seek).
728
+ const waitForRow = (messageID, token, timeout) =>
729
+ new Promise((resolve) => {
730
+ let done = false
731
+ let observer
732
+ let poll
733
+ let timer
734
+ const started = Date.now()
735
+ const baseline = rowSignature()
736
+ const finish = (row) => {
737
+ if (done) return
738
+ done = true
739
+ try {
740
+ observer?.disconnect()
741
+ } catch {}
742
+ clearInterval(poll)
743
+ clearTimeout(timer)
744
+ resolve(row ?? null)
745
+ }
746
+ const check = (full) => {
747
+ if (token !== jumpToken) return finish(null)
748
+ const row = findRow(messageID)
749
+ if (row) return finish(row)
750
+ // Let the click settle before trusting a DOM change as "a page arrived".
751
+ // Only the poll evaluates the row fingerprint: streaming output fires
752
+ // the observer constantly and a full query on every mutation is wasted.
753
+ if (full && Date.now() - started > JUMP_SETTLE_MS && rowSignature() !== baseline) finish(null)
754
+ }
755
+ const existing = findRow(messageID)
756
+ if (existing) return finish(existing)
757
+ observer = new MutationObserver(() => check(false))
758
+ observer.observe(document.documentElement, { childList: true, subtree: true })
759
+ poll = setInterval(() => check(true), 120)
760
+ timer = setTimeout(() => finish(findRow(messageID)), Math.max(400, timeout))
761
+ })
762
+
763
+ const cancelJump = () => {
764
+ jumpToken++
765
+ setJumping(false)
766
+ setJumpPage(0)
767
+ setBusyID("")
768
+ flash("已取消跳转")
769
+ }
770
+
369
771
  const jump = async (turn) => {
772
+ const token = ++jumpToken
370
773
  lastJumpID = turn.id
371
- const scroller = findScroller()
774
+ const now = Date.now()
775
+
776
+ // Same target clicked again shortly after: treat as idempotent re-scroll
777
+ // instead of restarting the paging loop.
778
+ if (turn.id === lastJumpTurnID && now - lastJumpAt < SAME_TARGET_WINDOW_MS) {
779
+ const row = findRow(turn.id)
780
+ const sc = scroller()
781
+ if (row && sc) {
782
+ scrollToRow(sc, row)
783
+ pulse(row)
784
+ return
785
+ }
786
+ }
787
+ lastJumpAt = now
788
+ lastJumpTurnID = turn.id
789
+
790
+ const sc = scroller()
372
791
  const row = findRow(turn.id)
373
792
 
374
793
  // Fast path: the row is mounted, scroll locally.
375
- if (row && scroller) {
376
- scrollToRow(scroller, row)
794
+ if (row && sc) {
795
+ scrollToRow(sc, row)
796
+ pulse(row)
377
797
  flash("已定位")
378
798
  return
379
799
  }
@@ -384,35 +804,72 @@ function Rail(props) {
384
804
  return
385
805
  }
386
806
 
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)
807
+ // Deep history: the host only pages in ~one history page (~5 turns) per
808
+ // seek and then goes idle. Drive it: each round toggles the hash to an
809
+ // already-loaded turn and back to the target, so the cursor machine runs
810
+ // another seek/page. A round ends as soon as rows appear instead of always
811
+ // sleeping 6s. A newer click bumps jumpToken, which cancels this loop at
812
+ // every await; Esc cancels it too.
813
+ setJumping(true)
814
+ setBusyID(turn.id)
815
+ setJumpPage(0)
816
+ // Pin the panel open even if the pointer has left it meanwhile.
817
+ setOpen(true)
818
+ flash("正在定位…", JUMP_TOTAL_MS)
819
+ try {
820
+ const deadline = Date.now() + JUMP_TOTAL_MS
821
+ for (let round = 0; round < JUMP_ROUNDS && Date.now() < deadline; round++) {
822
+ if (token !== jumpToken) return
823
+ setJumpPage(round + 1)
824
+ flash(`跳转中 · 第 ${round + 1} 页`, JUMP_TOTAL_MS)
825
+
826
+ if (lastNavID === turn.id) {
827
+ const other = oldestLoadedTurn() ?? turns()[0]
828
+ if (other) {
829
+ navTo(path, other.id)
830
+ await sleep(120)
831
+ }
832
+ }
833
+ if (token !== jumpToken) return
834
+ navTo(path, turn.id)
835
+
836
+ const remaining = Math.min(JUMP_ROW_TIMEOUT_MS, deadline - Date.now())
837
+ const landed = await waitForRow(turn.id, token, remaining)
838
+ if (token !== jumpToken) return
839
+ if (landed) {
840
+ const live = scroller() ?? sc
841
+ if (live) scrollToRow(live, landed)
842
+ pulse(landed)
843
+ flash("已定位")
844
+ return
398
845
  }
399
846
  }
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
847
+
848
+ if (token === jumpToken) flash(`未能定位 · ${diagnostics(ctx)}`)
849
+ } finally {
850
+ // A newer jump owns the signals when the token moved on.
851
+ if (token === jumpToken) {
852
+ setJumping(false)
853
+ setJumpPage(0)
854
+ setBusyID("")
410
855
  }
411
856
  }
412
-
413
- flash(`未能定位 · ${diagnostics(ctx)}`)
414
857
  }
415
858
 
859
+ // Keep the busy row visible in the panel list while the jump pages through
860
+ // history, so the "which turn am I waiting for" answer stays on screen.
861
+ createEffect(() => {
862
+ if (!jumping()) return
863
+ if (!busyID()) return
864
+ requestAnimationFrame(() => {
865
+ try {
866
+ document
867
+ .querySelector('.sr-list .sr-turn[data-busy="true"]')
868
+ ?.scrollIntoView({ block: "nearest" })
869
+ } catch {}
870
+ })
871
+ })
872
+
416
873
  const rail = (
417
874
  <>
418
875
  <Show when={status()}>
@@ -421,26 +878,60 @@ function Rail(props) {
421
878
  <div
422
879
  class="sr-rail"
423
880
  data-open={open() ? "true" : "false"}
881
+ data-jumping={jumping() ? "true" : "false"}
424
882
  onMouseEnter={() => setOpen(true)}
425
- onMouseLeave={() => setOpen(false)}
883
+ // Keep the panel pinned while a long history jump runs; otherwise the
884
+ // mouse drifting off the rail hides exactly the progress the user is
885
+ // waiting for. Esc / × still close (or cancel) it.
886
+ onMouseLeave={() => {
887
+ if (!jumping()) setOpen(false)
888
+ }}
426
889
  >
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>
890
+ <div class="sr-head">
891
+ <Show
892
+ when={jumping()}
893
+ fallback={
894
+ <span class="sr-title">会话轮次{turns().length > 0 ? ` · ${turns().length}` : ""}</span>
895
+ }
896
+ >
897
+ <span class="sr-title sr-progress">
898
+ 跳转中{jumpPage() > 0 ? ` · 第 ${jumpPage()} 页` : ""}
899
+ </span>
900
+ </Show>
901
+ <button
902
+ type="button"
903
+ class="sr-close"
904
+ title={jumping() ? "取消跳转 (Esc)" : "关闭 (Esc)"}
905
+ onClick={() => {
906
+ if (jumping()) cancelJump()
907
+ else setOpen(false)
908
+ }}
909
+ >
910
+ ×
911
+ </button>
912
+ </div>
913
+ <div class="sr-list">
914
+ <Show when={turns().length > 0} fallback={<div class="sr-note">{note() || `… ${VERSION}`}</div>}>
915
+ <For each={turns()}>
916
+ {(turn, index) => (
917
+ <button
918
+ type="button"
919
+ class="sr-turn"
920
+ data-active={active() === turn.id ? "true" : "false"}
921
+ data-busy={jumping() && busyID() === turn.id ? "true" : "false"}
922
+ onClick={() => void jump(turn)}
923
+ >
924
+ <span class="sr-text">
925
+ <span class="sr-num">#{index() + 1}</span>
926
+ <span class="sr-time">{formatTime(turn.created)}</span>
927
+ {turn.text}
928
+ </span>
929
+ <span class="sr-dash" />
930
+ </button>
931
+ )}
932
+ </For>
933
+ </Show>
934
+ </div>
444
935
  </div>
445
936
  </>
446
937
  )
@@ -486,7 +977,6 @@ export default Plugin.define({
486
977
  slash: { name: "turns" },
487
978
  run: () => {
488
979
  window.dispatchEvent(new Event(OPEN_EVENT))
489
- window.dispatchEvent(new Event(DIAG_EVENT))
490
980
  },
491
981
  }))
492
982
  },