opencode-session-turns 0.1.2 → 0.1.4

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,53 +1,51 @@
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=`
1
+ "use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const a=require("solid-js/web"),L=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 s=String(o?.text??"").split(`
2
+ `).map(b=>b.trim()).find(b=>b.length>0);if(s)return s;const d=o?.files?.length??0;return d>0?`[附件 ×${d}]`:"(空消息)"},mt=o=>String(o).padStart(2,"0"),Ht=o=>{const s=new Date(o??0);return`${mt(s.getHours())}:${mt(s.getMinutes())}`},bt=o=>new Promise(s=>setTimeout(s,o)),vt=()=>document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport')??document.querySelector('[data-slot="session-timeline-scroll"] [data-scrollable]')??(()=>{let s=document.querySelector("[data-message-id]")?.parentElement;for(;s;){const d=getComputedStyle(s);if(/(auto|scroll)/.test(d.overflowY)&&s.scrollHeight>s.clientHeight+1)return s;s=s.parentElement}})(),$=o=>document.getElementById(`${wt}${o}`)??[...document.querySelectorAll("[data-message-id]")].find(s=>s.getAttribute("data-message-id")===o),W=o=>{const s=vt(),d=[...document.querySelectorAll("[data-message-id]")],b=d[0]?.getAttribute("data-message-id")??"",R=X?` · 目标 ${$(X)?"在":"不在"} DOM`:"",I=b?` · 行ID ${b.slice(0,20)}`:"";let E="(空)";try{E=o.router.path()||"(空)"}catch{}return`轮次 ${xt} · 容器 ${s?"OK":"缺失"} · DOM ${d.length} 行${R}${I} · 路由 ${E} · hash ${window.location.hash||"(空)"}${Z?` · ${Z}`:""}`},K=(o,s)=>{s.scrollIntoView({block:"start"});const d=o.querySelector("[data-session-title]"),b=d instanceof HTMLElement?d.offsetHeight:0;b&&(o.scrollTop-=b+12)},Ut=o=>{let s="";try{s=o.router.path()||""}catch{}return s.startsWith("/")?s:""},Bt=o=>{const s=document.createElement("a");s.setAttribute("href",o),s.setAttribute("replace",""),s.setAttribute("noscroll",""),s.style.display="none",document.body.appendChild(s);try{s.dispatchEvent(new MouseEvent("click",{bubbles:!0,cancelable:!0,button:0,view:window}))}finally{s.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;
19
- /* keep wheel scrolling from chaining into the host timeline when the rail
20
- reaches its own end */
21
- overscroll-behavior: contain;
13
+ padding: 6px 2px;
14
+ overflow: hidden;
22
15
  z-index: 400;
23
16
  border-radius: 10px;
17
+ border: 1px solid transparent;
24
18
  color: var(--text-base, currentColor);
25
19
  background: transparent;
26
- transition: width .18s ease, background .18s ease, box-shadow .18s ease;
20
+ transition: width .18s ease, background .18s ease, box-shadow .18s ease, border-color .18s ease;
27
21
  }
28
- .sr-rail::-webkit-scrollbar { display: none; }
29
22
  .sr-rail[data-open="true"] {
30
- width: 360px;
31
- padding: 10px 8px;
23
+ width: 340px;
24
+ padding: 10px;
32
25
  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);
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);
37
31
  }
38
32
  .sr-head {
39
33
  display: none;
40
34
  align-items: center;
41
35
  justify-content: space-between;
42
36
  gap: 8px;
43
- padding: 0 2px 6px;
44
- margin-bottom: 4px;
45
- border-bottom: 1px solid rgba(127,127,127,.22);
37
+ padding: 0 2px 8px;
38
+ margin-bottom: 6px;
39
+ border-bottom: 1px solid rgba(127,127,127,.2);
46
40
  flex-shrink: 0;
47
- width: 100%;
48
41
  }
49
42
  .sr-rail[data-open="true"] .sr-head { display: flex; }
50
- .sr-title { font-size: 11px; opacity: .55; white-space: nowrap; }
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
+ }
51
49
  .sr-close {
52
50
  border: 0;
53
51
  background: transparent;
@@ -62,6 +60,26 @@
62
60
  flex-shrink: 0;
63
61
  }
64
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;
82
+ }
65
83
  .sr-turn {
66
84
  display: flex;
67
85
  align-items: center;
@@ -77,6 +95,15 @@
77
95
  font: inherit;
78
96
  border-radius: 4px;
79
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
+ }
80
107
  .sr-turn:focus-visible, .sr-close:focus-visible { outline: 1px solid currentColor; outline-offset: -1px; }
81
108
  .sr-dash {
82
109
  display: block;
@@ -86,10 +113,13 @@
86
113
  background: currentColor;
87
114
  opacity: .28;
88
115
  flex-shrink: 0;
89
- transition: opacity .12s ease, width .12s ease;
116
+ transition: opacity .12s ease, width .12s ease, height .12s ease;
90
117
  }
91
118
  .sr-turn:hover .sr-dash,
92
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; }
93
123
  .sr-text {
94
124
  display: none;
95
125
  margin-right: 10px;
@@ -105,22 +135,63 @@
105
135
  opacity: .45;
106
136
  background: transparent;
107
137
  }
108
- .sr-rail[data-open="true"] .sr-text { display: block; }
109
- .sr-num { opacity: .75; margin-right: 5px; font-variant-numeric: tabular-nums; }
110
- .sr-turn:hover .sr-text {
111
- opacity: 1;
112
- 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;
113
157
  }
114
- .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 {
115
165
  opacity: 1;
116
- font-weight: 600;
117
- 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; }
118
175
  }
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; }
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
+ }
124
195
  }
125
196
  .sr-note {
126
197
  display: none;
@@ -144,4 +215,4 @@
144
215
  white-space: pre-wrap;
145
216
  max-width: min(720px, 90vw);
146
217
  }
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;
218
+ `;function Vt(o){const s=L.usePlugin(),[d,b]=i.createSignal([]),[R,I]=i.createSignal(""),[E,S]=i.createSignal(!1),[yt,kt]=i.createSignal(""),[Q,tt]=i.createSignal(""),[v,M]=i.createSignal(!1),[et,T]=i.createSignal(0),[rt,P]=i.createSignal(""),y=new Map;let O=0,D=!1,j=!1,J="",A;const _=()=>o.session()?.id,N=()=>{const t=[...y.values()].sort((r,l)=>r.created-l.created),e=i.untrack(d);e.length===t.length&&e.every((r,l)=>r===t[l])||(b(t),xt=t.length)},st=t=>{const e=y.get(t.id);e&&e.created===t.time&&e.text===t.text||y.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 s.client.message.list(l)},z=async(t,e)=>{const r=++O;D=!0;let l="";try{let n;const c=e==="full"?qt:1,u=e==="full"?Rt:Ot;let p=e!=="full";for(let h=0;h<c;h++){let x;try{x=await St(t,u,n)}catch(m){l=String(m?.message??m);break}if(r!==O)return;p||(y.clear(),p=!0);let f=0;for(const m of x?.data??[])y.has(m.id)||(st({id:m.id,time:m.time?.created??0,text:gt(m)}),f++);N();const g=x?.cursor?.next;if(!g||f===0)break;n=g}}finally{D=!1,J=t,Z=l,kt(y.size===0?l||"暂无用户消息":""),j&&(j=!1,nt())}},nt=()=>{A||(A=setTimeout(()=>{A=void 0;const t=i.untrack(_);t&&z(t,"light")},300))},at=t=>{if(!(!t||t!==i.untrack(_))){if(D){j=!0;return}nt()}},$t=t=>{const e=t?.type;if(e==="message.updated"){const r=t?.data??{},l=r.info;l?.role==="user"&&l?.id&&(st({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&&y.delete(r.messageID)&&N(),at(r.sessionID)}else if(e==="location.shutdown"){const r=i.untrack(_);r&&J===r&&z(r,"full")}};i.onMount(()=>{let t=!1,e=0,r;(async()=>{for(;!t;){const c=s.client.event.subscribe()[Symbol.asyncIterator]();try{for(;!t;){const u=await c.next();if(u.done)break;e=0,$t(u.value)}}catch{}try{await c.return?.()}catch{}if(t)break;e++,await bt(Math.min(2e3*e,15e3))}})(),i.onCleanup(()=>{t=!0,clearTimeout(r),A&&clearTimeout(A)})}),i.createEffect(()=>{const t=_();t&&i.untrack(()=>{J!==t&&(O++,D=!1,j=!1,y.clear(),b([]),I(""),H="",z(t,"full"))})});let q;const C=()=>(q?.isConnected||(q=vt()),q),Et=()=>{const t=C();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 n=l.getAttribute("data-message-id");n&&I(n);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(v)){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(s)}`),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="",k=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 d())if($(t.id))return t},dt=(t,e)=>{Bt(`${t}#${wt}${e}`),H=e},ut=()=>{const e=C()?.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 n=!1,c,u,p;const h=Date.now(),x=ut(),f=B=>{if(!n){n=!0;try{c?.disconnect()}catch{}clearInterval(u),clearTimeout(p),l(B??null)}},g=B=>{if(e!==k)return f(null);const ft=$(t);if(ft)return f(ft);B&&Date.now()-h>zt&&ut()!==x&&f(null)},m=$(t);if(m)return f(m);c=new MutationObserver(()=>g(!1)),c.observe(document.documentElement,{childList:!0,subtree:!0}),u=setInterval(()=>g(!0),120),p=setTimeout(()=>f($(t)),Math.max(400,r))}),pt=()=>{k++,M(!1),T(0),P(""),w("已取消跳转")},At=async t=>{const e=++k;M(!1),T(0),P(""),X=t.id;const r=Date.now();if(t.id===ct&&r-lt<Ft){const p=$(t.id),h=C();if(p&&h){K(h,p),U(p);return}}lt=r,ct=t.id;const l=C(),n=$(t.id);if(n&&l){K(l,n),U(n),w("已定位");return}const c=Ut(s);if(!c){w(`无法读取当前路由,无法定位 · ${W(s)}`);return}const u=_();M(!0),P(t.id),T(0),S(!0),w("正在定位…",V);try{const p=Date.now()+V;for(let h=0;h<Jt&&Date.now()<p;h++){if(e!==k||_()!==u)return;if(T(h+1),w(`跳转中 · 第 ${h+1} 页`,V),H===t.id){const g=_t()??d()[0];g&&(dt(c,g.id),await bt(120))}if(e!==k)return;dt(c,t.id);const x=Math.min(Nt,p-Date.now()),f=await Tt(t.id,e,x);if(e!==k)return;if(f){const g=C()??l;g&&K(g,f),U(f),w("已定位");return}}e===k&&w(`未能定位 · ${W(s)}`)}finally{e===k&&(M(!1),T(0),P(""))}};i.createEffect(()=>{v()&&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",()=>{v()||S(!1)}),t.addEventListener("mouseenter",()=>S(!0)),a.insert(e,a.createComponent(i.Show,{get when(){return v()},get fallback(){return(()=>{var n=Pt();return n.firstChild,a.insert(n,(()=>{var c=a.memo(()=>d().length>0);return()=>c()?` · ${d().length}`:""})(),null),n})()},get children(){var n=It();return n.firstChild,a.insert(n,(()=>{var c=a.memo(()=>et()>0);return()=>c()?` · 第 ${et()} 页`:""})(),null),n}}),r),r.$$click=()=>{v()?pt():S(!1)},a.insert(l,a.createComponent(i.Show,{get when(){return d().length>0},get fallback(){return(()=>{var n=Dt();return a.insert(n,()=>yt()||`… ${G}`),n})()},get children(){return a.createComponent(i.For,{get each(){return d()},children:(n,c)=>(()=>{var u=jt(),p=u.firstChild,h=p.firstChild;h.firstChild;var x=h.nextSibling;return u.$$click=()=>{At(n)},a.insert(h,()=>c()+1,null),a.insert(x,()=>Ht(n.created)),a.insert(p,()=>n.text,null),a.effect(f=>{var g=R()===n.id?"true":"false",m=v()&&rt()===n.id?"true":"false";return g!==f.e&&a.setAttribute(u,"data-active",f.e=g),m!==f.t&&a.setAttribute(u,"data-busy",f.t=m),f},{e:void 0,t:void 0}),u})()})}})),a.effect(n=>{var c=E()?"true":"false",u=v()?"true":"false",p=v()?"取消跳转 (Esc)":"关闭 (Esc)";return c!==n.e&&a.setAttribute(t,"data-open",n.e=c),u!==n.t&&a.setAttribute(t,"data-jumping",n.t=u),p!==n.a&&a.setAttribute(r,"title",n.a=p),n},{e:void 0,t:void 0,a:void 0}),t})()];return a.createComponent(a.Portal,{get mount(){return document.body},children:Ct})}const Wt=L.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(L.Style,Gt),o.add(L.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(L.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.2",
3
+ "version": "0.1.4",
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,7 +6,7 @@ import { Portal } from "solid-js/web"
6
6
  // constants / helpers
7
7
  // ---------------------------------------------------------------------------
8
8
 
9
- const VERSION = "v11"
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"
@@ -17,10 +17,13 @@ const FULL_PAGE_LIMIT = 40
17
17
  const FULL_PAGE_SIZE = 200
18
18
  const LIGHT_PAGE_SIZE = 50
19
19
 
20
- // jump loop budgets
21
- const JUMP_ROUNDS = 30
22
- const JUMP_TOTAL_MS = 15000
23
- const JUMP_ROW_TIMEOUT_MS = 6000
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
24
27
  const SAME_TARGET_WINDOW_MS = 800
25
28
 
26
29
  // Filled by Rail.load()/jump() so the command palette can report diagnostics.
@@ -143,50 +146,48 @@ const CSS = `
143
146
  position: fixed;
144
147
  top: 50%;
145
148
  transform: translateY(-50%);
146
- right: 10px;
147
- max-height: 60vh;
148
- width: 14px;
149
+ right: 14px;
150
+ max-height: 66vh;
151
+ width: 16px;
149
152
  display: flex;
150
153
  flex-direction: column;
151
- align-items: flex-end;
152
154
  justify-content: center;
153
- gap: 2px;
154
- padding: 8px 4px;
155
- overflow-y: auto;
156
- overflow-x: hidden;
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;
155
+ padding: 6px 2px;
156
+ overflow: hidden;
161
157
  z-index: 400;
162
158
  border-radius: 10px;
159
+ border: 1px solid transparent;
163
160
  color: var(--text-base, currentColor);
164
161
  background: transparent;
165
- transition: width .18s ease, background .18s ease, box-shadow .18s ease;
162
+ transition: width .18s ease, background .18s ease, box-shadow .18s ease, border-color .18s ease;
166
163
  }
167
- .sr-rail::-webkit-scrollbar { display: none; }
168
164
  .sr-rail[data-open="true"] {
169
- width: 360px;
170
- padding: 10px 8px;
165
+ width: 340px;
166
+ padding: 10px;
171
167
  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);
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);
176
173
  }
177
174
  .sr-head {
178
175
  display: none;
179
176
  align-items: center;
180
177
  justify-content: space-between;
181
178
  gap: 8px;
182
- padding: 0 2px 6px;
183
- margin-bottom: 4px;
184
- border-bottom: 1px solid rgba(127,127,127,.22);
179
+ padding: 0 2px 8px;
180
+ margin-bottom: 6px;
181
+ border-bottom: 1px solid rgba(127,127,127,.2);
185
182
  flex-shrink: 0;
186
- width: 100%;
187
183
  }
188
184
  .sr-rail[data-open="true"] .sr-head { display: flex; }
189
- .sr-title { font-size: 11px; opacity: .55; white-space: nowrap; }
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
+ }
190
191
  .sr-close {
191
192
  border: 0;
192
193
  background: transparent;
@@ -201,6 +202,26 @@ const CSS = `
201
202
  flex-shrink: 0;
202
203
  }
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;
224
+ }
204
225
  .sr-turn {
205
226
  display: flex;
206
227
  align-items: center;
@@ -216,6 +237,15 @@ const CSS = `
216
237
  font: inherit;
217
238
  border-radius: 4px;
218
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
+ }
219
249
  .sr-turn:focus-visible, .sr-close:focus-visible { outline: 1px solid currentColor; outline-offset: -1px; }
220
250
  .sr-dash {
221
251
  display: block;
@@ -225,10 +255,13 @@ const CSS = `
225
255
  background: currentColor;
226
256
  opacity: .28;
227
257
  flex-shrink: 0;
228
- transition: opacity .12s ease, width .12s ease;
258
+ transition: opacity .12s ease, width .12s ease, height .12s ease;
229
259
  }
230
260
  .sr-turn:hover .sr-dash,
231
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; }
232
265
  .sr-text {
233
266
  display: none;
234
267
  margin-right: 10px;
@@ -244,22 +277,63 @@ const CSS = `
244
277
  opacity: .45;
245
278
  background: transparent;
246
279
  }
247
- .sr-rail[data-open="true"] .sr-text { display: block; }
248
- .sr-num { opacity: .75; margin-right: 5px; font-variant-numeric: tabular-nums; }
249
- .sr-turn:hover .sr-text {
250
- opacity: 1;
251
- 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;
252
299
  }
253
- .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 {
254
307
  opacity: 1;
255
- font-weight: 600;
256
- 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; }
257
317
  }
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; }
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
+ }
263
337
  }
264
338
  .sr-note {
265
339
  display: none;
@@ -296,6 +370,11 @@ function Rail(props) {
296
370
  const [open, setOpen] = createSignal(false)
297
371
  const [note, setNote] = createSignal("")
298
372
  const [status, setStatus] = createSignal("")
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("")
299
378
 
300
379
  // ---- turn cache ---------------------------------------------------------
301
380
  // Object identity is kept stable across refreshes so Solid's <For> reuses
@@ -550,6 +629,13 @@ function Rail(props) {
550
629
  onMount(() => {
551
630
  const onKey = (event) => {
552
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
+ }
553
639
  if (untrack(open)) {
554
640
  setOpen(false)
555
641
  // Only swallow Esc while the rail is actually open, so the host keeps
@@ -594,14 +680,21 @@ function Rail(props) {
594
680
  let lastJumpAt = 0
595
681
  let lastJumpTurnID = ""
596
682
 
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.
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.
599
688
  const pulse = (row) => {
600
689
  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)
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)
605
698
  } catch {}
606
699
  }
607
700
 
@@ -617,14 +710,29 @@ function Rail(props) {
617
710
  lastNavID = id
618
711
  }
619
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
+
620
725
  // Resolves when the row enters the DOM (MutationObserver, with a cheap poll
621
- // as a belt-and-braces fallback), or null on timeout / supersession.
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).
622
728
  const waitForRow = (messageID, token, timeout) =>
623
729
  new Promise((resolve) => {
624
730
  let done = false
625
731
  let observer
626
732
  let poll
627
733
  let timer
734
+ const started = Date.now()
735
+ const baseline = rowSignature()
628
736
  const finish = (row) => {
629
737
  if (done) return
630
738
  done = true
@@ -635,21 +743,39 @@ function Rail(props) {
635
743
  clearTimeout(timer)
636
744
  resolve(row ?? null)
637
745
  }
638
- const check = () => {
746
+ const check = (full) => {
639
747
  if (token !== jumpToken) return finish(null)
640
748
  const row = findRow(messageID)
641
- if (row) finish(row)
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)
642
754
  }
643
755
  const existing = findRow(messageID)
644
756
  if (existing) return finish(existing)
645
- observer = new MutationObserver(check)
757
+ observer = new MutationObserver(() => check(false))
646
758
  observer.observe(document.documentElement, { childList: true, subtree: true })
647
- poll = setInterval(check, 120)
759
+ poll = setInterval(() => check(true), 120)
648
760
  timer = setTimeout(() => finish(findRow(messageID)), Math.max(400, timeout))
649
761
  })
650
762
 
763
+ const cancelJump = () => {
764
+ jumpToken++
765
+ setJumping(false)
766
+ setJumpPage(0)
767
+ setBusyID("")
768
+ flash("已取消跳转")
769
+ }
770
+
651
771
  const jump = async (turn) => {
652
772
  const token = ++jumpToken
773
+ // A new click always owns the progress UI. This also clears a previous
774
+ // jump's state when the new target takes the mounted fast path, where the
775
+ // superseded loop's finally no longer touches these signals.
776
+ setJumping(false)
777
+ setJumpPage(0)
778
+ setBusyID("")
653
779
  lastJumpID = turn.id
654
780
  const now = Date.now()
655
781
 
@@ -684,40 +810,75 @@ function Rail(props) {
684
810
  return
685
811
  }
686
812
 
687
- // The host only pages in ~one history page (~5 turns) per seek and then goes
688
- // idle. Drive it: each round toggles the hash to an already-loaded turn and
689
- // back to the target, so the cursor machine runs another seek/page.
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
694
- flash(round === 0 ? "定位中…" : `加载历史… ${round}`)
695
-
696
- if (lastNavID === turn.id) {
697
- const other = oldestLoadedTurn() ?? turns()[0]
698
- if (other) {
699
- navTo(path, other.id)
700
- await sleep(120)
813
+ // Deep history: the host only pages in ~one history page (~5 turns) per
814
+ // seek and then goes idle. Drive it: each round toggles the hash to an
815
+ // already-loaded turn and back to the target, so the cursor machine runs
816
+ // another seek/page. A round ends as soon as rows appear instead of always
817
+ // sleeping 6s. A newer click bumps jumpToken, which cancels this loop at
818
+ // every await; Esc cancels it too.
819
+ const sessionAtJump = sessionID()
820
+ setJumping(true)
821
+ setBusyID(turn.id)
822
+ setJumpPage(0)
823
+ // Pin the panel open even if the pointer has left it meanwhile.
824
+ setOpen(true)
825
+ flash("正在定位…", JUMP_TOTAL_MS)
826
+ try {
827
+ const deadline = Date.now() + JUMP_TOTAL_MS
828
+ for (let round = 0; round < JUMP_ROUNDS && Date.now() < deadline; round++) {
829
+ // Abandon the loop if the user switched sessions mid-jump: toggling the
830
+ // old route hash would navigate back to the previous session.
831
+ if (token !== jumpToken || sessionID() !== sessionAtJump) return
832
+ setJumpPage(round + 1)
833
+ flash(`跳转中 · 第 ${round + 1} 页`, JUMP_TOTAL_MS)
834
+
835
+ if (lastNavID === turn.id) {
836
+ const other = oldestLoadedTurn() ?? turns()[0]
837
+ if (other) {
838
+ navTo(path, other.id)
839
+ await sleep(120)
840
+ }
841
+ }
842
+ if (token !== jumpToken) return
843
+ navTo(path, turn.id)
844
+
845
+ const remaining = Math.min(JUMP_ROW_TIMEOUT_MS, deadline - Date.now())
846
+ const landed = await waitForRow(turn.id, token, remaining)
847
+ if (token !== jumpToken) return
848
+ if (landed) {
849
+ const live = scroller() ?? sc
850
+ if (live) scrollToRow(live, landed)
851
+ pulse(landed)
852
+ flash("已定位")
853
+ return
701
854
  }
702
855
  }
703
- if (token !== jumpToken) return
704
- navTo(path, turn.id)
705
-
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
711
- if (live) scrollToRow(live, landed)
712
- pulse(landed)
713
- flash("已定位")
714
- return
856
+
857
+ if (token === jumpToken) flash(`未能定位 · ${diagnostics(ctx)}`)
858
+ } finally {
859
+ // A newer jump owns the signals when the token moved on.
860
+ if (token === jumpToken) {
861
+ setJumping(false)
862
+ setJumpPage(0)
863
+ setBusyID("")
715
864
  }
716
865
  }
717
-
718
- if (token === jumpToken) flash(`未能定位 · ${diagnostics(ctx)}`)
719
866
  }
720
867
 
868
+ // Keep the busy row visible in the panel list while the jump pages through
869
+ // history, so the "which turn am I waiting for" answer stays on screen.
870
+ createEffect(() => {
871
+ if (!jumping()) return
872
+ if (!busyID()) return
873
+ requestAnimationFrame(() => {
874
+ try {
875
+ document
876
+ .querySelector('.sr-list .sr-turn[data-busy="true"]')
877
+ ?.scrollIntoView({ block: "nearest" })
878
+ } catch {}
879
+ })
880
+ })
881
+
721
882
  const rail = (
722
883
  <>
723
884
  <Show when={status()}>
@@ -726,33 +887,60 @@ function Rail(props) {
726
887
  <div
727
888
  class="sr-rail"
728
889
  data-open={open() ? "true" : "false"}
890
+ data-jumping={jumping() ? "true" : "false"}
729
891
  onMouseEnter={() => setOpen(true)}
730
- onMouseLeave={() => setOpen(false)}
892
+ // Keep the panel pinned while a long history jump runs; otherwise the
893
+ // mouse drifting off the rail hides exactly the progress the user is
894
+ // waiting for. Esc / × still close (or cancel) it.
895
+ onMouseLeave={() => {
896
+ if (!jumping()) setOpen(false)
897
+ }}
731
898
  >
732
899
  <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)}>
900
+ <Show
901
+ when={jumping()}
902
+ fallback={
903
+ <span class="sr-title">会话轮次{turns().length > 0 ? ` · ${turns().length}` : ""}</span>
904
+ }
905
+ >
906
+ <span class="sr-title sr-progress">
907
+ 跳转中{jumpPage() > 0 ? ` · 第 ${jumpPage()} 页` : ""}
908
+ </span>
909
+ </Show>
910
+ <button
911
+ type="button"
912
+ class="sr-close"
913
+ title={jumping() ? "取消跳转 (Esc)" : "关闭 (Esc)"}
914
+ onClick={() => {
915
+ if (jumping()) cancelJump()
916
+ else setOpen(false)
917
+ }}
918
+ >
735
919
  ×
736
920
  </button>
737
921
  </div>
738
- <Show when={turns().length > 0} fallback={<div class="sr-note">{note() || `… ${VERSION}`}</div>}>
739
- <For each={turns()}>
740
- {(turn, index) => (
741
- <button
742
- type="button"
743
- class="sr-turn"
744
- data-active={active() === turn.id ? "true" : "false"}
745
- onClick={() => void jump(turn)}
746
- >
747
- <span class="sr-text">
748
- <span class="sr-num">#{index() + 1}</span>
749
- {formatTime(turn.created)} {turn.text}
750
- </span>
751
- <span class="sr-dash" />
752
- </button>
753
- )}
754
- </For>
755
- </Show>
922
+ <div class="sr-list">
923
+ <Show when={turns().length > 0} fallback={<div class="sr-note">{note() || `… ${VERSION}`}</div>}>
924
+ <For each={turns()}>
925
+ {(turn, index) => (
926
+ <button
927
+ type="button"
928
+ class="sr-turn"
929
+ data-active={active() === turn.id ? "true" : "false"}
930
+ data-busy={jumping() && busyID() === turn.id ? "true" : "false"}
931
+ onClick={() => void jump(turn)}
932
+ >
933
+ <span class="sr-text">
934
+ <span class="sr-num">#{index() + 1}</span>
935
+ <span class="sr-time">{formatTime(turn.created)}</span>
936
+ {turn.text}
937
+ </span>
938
+ <span class="sr-dash" />
939
+ </button>
940
+ )}
941
+ </For>
942
+ </Show>
943
+ </div>
756
944
  </div>
757
945
  </>
758
946
  )