opencode-session-turns 0.1.12 → 0.1.14

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
@@ -22,8 +22,8 @@ click a turn to jump straight to that message.
22
22
  - 列出当前会话的每一轮用户提问(从会话消息接口分页拉取,`type=user`)。
23
23
  - 鼠标移到窗口右缘的细条上自动展开,移开自动收起;长历史跳转期间面板保持展开。
24
24
  - 点击任意一轮,**跳转到对应消息**——包括很久以前、尚未渲染的历史轮次。
25
- - 深历史需要逐页加载时,头部实时显示「已加载 N 页 / 耗时 Xs」,目标轮次琥珀色脉冲,
26
- 底部状态条同步提示;按 `Esc` 或点 × 可随时取消。
25
+ - 深历史需要逐页加载时,面板头部显示「加载历史中 · N 页 · Xs」,目标轮次带「加载中」
26
+ 徽标并琥珀色脉冲;按 `Esc` 或点 × 可随时取消。
27
27
  - **历史预热**:宿主自己加载历史每页之间有 2 秒硬冷却,深历史跳转因此要等好几秒。
28
28
  轮次条会在你打开面板时(悬停或 `/turns`)后台把历史预热完并还原视口,
29
29
  之后所有跳转都是瞬时的;也可以点面板上的「预热」手动触发。
@@ -1,5 +1,5 @@
1
- "use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const l=require("solid-js/web"),B=require("@opencode/plugin/gui"),a=require("solid-js");var ee=l.template("<div class=sr-status>"),ne=l.template('<span class="sr-title sr-progress">'),re=l.template("<div class=sr-rail><div class=sr-head><button type=button class=sr-warm title=预热历史:后台把会话历史加载完,之后跳转瞬时>预热</button><button type=button class=sr-close>×</button></div><div class=sr-list>"),se=l.template("<span class=sr-title>会话轮次"),oe=l.template("<div class=sr-note>"),ie=l.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 at="v16",ft="message-",Bt="session-turns:diag",gt="session-turns:open",lt="session-turns:warm",ae=40,le=200,ce=50,de=30,q=45e3,ue=12e3,pe=5e3,fe=9e3,ge=220,he=800;let Ht=0,ht="",mt="",bt="";const Jt=o=>{const r=String(o?.text??"").split(`
2
- `).map(g=>g.trim()).find(g=>g.length>0);if(r)return r;const u=o?.files?.length??0;return u>0?`[附件 ×${u}]`:"(空消息)"},Ot=o=>String(o).padStart(2,"0"),me=o=>{const r=new Date(o??0);return`${Ot(r.getHours())}:${Ot(r.getMinutes())}`},ct=o=>new Promise(r=>setTimeout(r,o)),wt=()=>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 u=getComputedStyle(r);if(/(auto|scroll)/.test(u.overflowY)&&r.scrollHeight>r.clientHeight+1)return r;r=r.parentElement}})(),k=o=>document.getElementById(`${ft}${o}`)??[...document.querySelectorAll("[data-message-id]")].find(r=>r.getAttribute("data-message-id")===o),dt=o=>{const r=wt(),u=[...document.querySelectorAll("[data-message-id]")],g=u[0]?.getAttribute("data-message-id")??"",E=bt?` · 目标 ${k(bt)?"在":"不在"} DOM`:"",_=g?` · 行ID ${g.slice(0,20)}`:"";let I="(空)";try{I=o.router.path()||"(空)"}catch{}return`轮次 ${Ht} · 容器 ${r?"OK":"缺失"} · DOM ${u.length} 行${E}${_} · 路由 ${I} · hash ${window.location.hash||"(空)"}${mt?` · ${mt}`:""}${ht?` · ${ht}`:""}`},be=o=>{const r=o.getBoundingClientRect().top;let u=r;for(const g of o.querySelectorAll(".sticky, [data-session-title]")){if(!(g instanceof HTMLElement)||getComputedStyle(g).position!=="sticky"&&!g.hasAttribute("data-session-title"))continue;const _=g.getBoundingClientRect();_.height!==0&&(_.top>r+4||(u=Math.max(u,_.bottom)))}return u},ut=o=>{try{const r=wt();if(!r||!o.isConnected)return;const u=o.querySelector('[data-slot="user-message-text"]')??o,g=be(r),E=u.getBoundingClientRect().top-(g+18);Math.abs(E)>2&&(r.scrollTop+=E),mt=`inset ${Math.round(g)} · delta ${Math.round(E)}`}catch{}},pt=o=>{ut(o),setTimeout(()=>ut(o),450),setTimeout(()=>ut(o),1e3)},Nt=o=>{let r="";try{r=o.router.path()||""}catch{}return r.startsWith("/")?r:""},we=o=>{const r=document.createElement("a");r.setAttribute("href",o),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()}},ve=`
1
+ "use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const o=require("solid-js/web"),q=require("@opencode/plugin/gui"),l=require("solid-js");var Le=o.template("<div class=sr-status>"),Xe=o.template('<span class="sr-title sr-progress">加载历史中'),Qe=o.template("<div class=sr-foot>"),et=o.template("<div class=sr-rail><div class=sr-head><button type=button class=sr-close>×</button></div><div class=sr-list>"),tt=o.template("<span class=sr-title>会话轮次"),rt=o.template("<div class=sr-note>"),nt=o.template("<span class=sr-badge>加载中"),st=o.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 De="v16",le="message-",Pe="session-turns:diag",ce="session-turns:open",ot=40,at=200,it=50,lt=30,ct=45e3,dt=2e4,ut=8e3,pt=12e3,ft=220,gt=800;let Be=0,de="",ue="",pe="";const je=a=>{const s=String(a?.text??"").split(`
2
+ `).map(p=>p.trim()).find(p=>p.length>0);if(s)return s;const d=a?.files?.length??0;return d>0?`[附件 ×${d}]`:"(空消息)"},Re=a=>String(a).padStart(2,"0"),ht=a=>{const s=new Date(a??0);return`${Re(s.getHours())}:${Re(s.getMinutes())}`},se=a=>new Promise(s=>setTimeout(s,a)),fe=()=>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}})(),y=a=>{const s=document.getElementById(`${le}${a}`);if(s)return s;const d=`[data-message-id="${a.replace(/"/g,'\\"')}"]`;for(const p of document.querySelectorAll(d))if(p.getAttribute("data-timeline-row")==="UserMessage"||p.querySelector('[data-slot="user-message-text"]'))return p},oe=a=>{const s=fe(),d=[...document.querySelectorAll("[data-message-id]")],p=d[0]?.getAttribute("data-message-id")??"",S=pe?` · 目标 ${y(pe)?"在":"不在"} DOM`:"",$=p?` · 行ID ${p.slice(0,20)}`:"";let E="(空)";try{E=a.router.path()||"(空)"}catch{}return`轮次 ${Be} · 容器 ${s?"OK":"缺失"} · DOM ${d.length} 行${S}${$} · 路由 ${E} · hash ${window.location.hash||"(空)"}${ue?` · ${ue}`:""}${de?` · ${de}`:""}`},mt=a=>{const s=a.getBoundingClientRect().top;let d=s;for(const p of a.querySelectorAll(".sticky, [data-session-title]")){if(!(p instanceof HTMLElement)||getComputedStyle(p).position!=="sticky"&&!p.hasAttribute("data-session-title"))continue;const $=p.getBoundingClientRect();$.height!==0&&($.top>s+4||(d=Math.max(d,$.bottom)))}return d},ae=a=>{try{const s=fe();if(!s||!a.isConnected)return;const d=a.querySelector('[data-slot="user-message-text"]')??a,p=mt(s),S=d.getBoundingClientRect().top-(p+18);Math.abs(S)>2&&(s.scrollTop+=S),ue=`inset ${Math.round(p)} · delta ${Math.round(S)}`}catch{}},ie=a=>{ae(a),setTimeout(()=>ae(a),450),setTimeout(()=>ae(a),1e3)},qe=a=>{let s="";try{s=a.router.path()||""}catch{}return s.startsWith("/")?s:""},bt=a=>{const s=document.createElement("a");s.setAttribute("href",a),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()}},wt=`
3
3
  .sr-rail {
4
4
  position: fixed;
5
5
  top: 50%;
@@ -60,21 +60,10 @@
60
60
  flex-shrink: 0;
61
61
  }
62
62
  .sr-close:hover { opacity: 1; background: var(--surface-base-hover, rgba(127,127,127,.14)); }
63
- .sr-warm {
64
- border: 1px solid rgba(127,127,127,.28);
65
- background: transparent;
66
- color: inherit;
67
- font: inherit;
68
- font-size: 11px;
69
- line-height: 1;
70
- padding: 3px 7px;
71
- border-radius: 5px;
72
- cursor: pointer;
73
- opacity: .6;
74
- flex-shrink: 0;
75
- }
76
- .sr-warm:hover { opacity: 1; background: var(--surface-base-hover, rgba(127,127,127,.14)); }
77
- /* the scrollable list; the header stays fixed above it */
63
+ /* the scrollable list; the header stays fixed above it.
64
+ The collapsed rail must NOT be a scroll container: centering the content
65
+ inside a scroller overflows in both directions, so the browser clamps the
66
+ scroll offset and the list snaps to the top/bottom when the panel opens. */
78
67
  .sr-list {
79
68
  flex: 1;
80
69
  min-height: 0;
@@ -82,11 +71,15 @@
82
71
  flex-direction: column;
83
72
  justify-content: center;
84
73
  gap: 2px;
85
- overflow-y: auto;
86
- overflow-x: hidden;
74
+ overflow: hidden;
87
75
  scrollbar-width: none;
88
76
  }
89
- .sr-rail[data-open="true"] .sr-list { justify-content: flex-start; gap: 1px; scrollbar-width: thin; }
77
+ .sr-rail[data-open="true"] .sr-list {
78
+ justify-content: flex-start;
79
+ gap: 1px;
80
+ overflow-y: auto;
81
+ scrollbar-width: thin;
82
+ }
90
83
  .sr-list::-webkit-scrollbar { display: none; }
91
84
  .sr-rail[data-open="true"] .sr-list::-webkit-scrollbar { display: block; width: 8px; }
92
85
  .sr-list::-webkit-scrollbar-track { background: transparent; }
@@ -108,9 +101,6 @@
108
101
  color: inherit;
109
102
  font: inherit;
110
103
  border-radius: 4px;
111
- /* long sessions: skip layout/paint for rows outside the panel viewport */
112
- content-visibility: auto;
113
- contain-intrinsic-size: 0 26px;
114
104
  }
115
105
  /* expanded: dash flips to a left-side indicator, content reads left-to-right */
116
106
  .sr-rail[data-open="true"] .sr-turn {
@@ -206,6 +196,17 @@
206
196
  box-shadow: inset 0 0 0 2px rgba(255,170,60,0), inset 0 0 0 rgba(255,170,60,0);
207
197
  }
208
198
  }
199
+ .sr-badge {
200
+ font-size: 10px;
201
+ line-height: 1.4;
202
+ padding: 0 5px;
203
+ border-radius: 4px;
204
+ color: rgba(255,170,60,.95);
205
+ background: rgba(255,170,60,.14);
206
+ border: 1px solid rgba(255,170,60,.35);
207
+ flex-shrink: 0;
208
+ white-space: nowrap;
209
+ }
209
210
  .sr-note {
210
211
  display: none;
211
212
  font-size: 11px;
@@ -213,19 +214,32 @@
213
214
  padding: 2px;
214
215
  }
215
216
  .sr-rail[data-open="true"] .sr-note { display: block; }
217
+ .sr-foot {
218
+ flex-shrink: 0;
219
+ font-size: 11px;
220
+ line-height: 1.5;
221
+ color: rgba(255,170,60,.92);
222
+ padding: 6px 2px 0;
223
+ margin-top: 6px;
224
+ border-top: 1px solid rgba(127,127,127,.2);
225
+ white-space: pre-wrap;
226
+ max-height: 64px;
227
+ overflow: hidden;
228
+ }
216
229
  .sr-status {
217
230
  position: fixed;
218
- right: 30px;
219
- bottom: 150px;
231
+ right: 42px;
232
+ top: 50%;
233
+ transform: translateY(-50%);
220
234
  z-index: 401;
221
235
  font-size: 11px;
222
- padding: 3px 8px;
236
+ line-height: 1.5;
237
+ padding: 4px 9px;
223
238
  border-radius: 6px;
224
- background: var(--background-strong, rgba(0,0,0,.75));
239
+ background: var(--background-strong, rgba(0,0,0,.82));
225
240
  color: var(--text-base, #fff);
226
- opacity: .9;
227
- pointer-events: none;
241
+ border: 1px solid rgba(127,127,127,.25);
228
242
  white-space: pre-wrap;
229
- max-width: min(720px, 90vw);
243
+ max-width: 260px;
230
244
  }
231
- `;function xe(o){const r=B.usePlugin(),[u,g]=a.createSignal([]),[E,_]=a.createSignal(""),[I,L]=a.createSignal(!1),[zt,Ft]=a.createSignal(""),[vt,xt]=a.createSignal(""),[x,J]=a.createSignal(!1),[V,P]=a.createSignal(0),[K,D]=a.createSignal(0),[ke,O]=a.createSignal(0),[Y,N]=a.createSignal(""),A=new Map;let Z=0,H=!1,z=!1,X="",j;const y=()=>o.session()?.id,Q=()=>{const t=[...A.values()].sort((n,i)=>n.created-i.created),e=a.untrack(u);e.length===t.length&&e.every((n,i)=>n===t[i])||(g(t),Ht=t.length)},yt=t=>{const e=A.get(t.id);e&&e.created===t.time&&e.text===t.text||A.set(t.id,{id:t.id,created:t.time,text:t.text})},Wt=async(t,e,n)=>{const i=n?{sessionID:t,type:"user",limit:e,cursor:n}:{sessionID:t,type:"user",limit:e,order:"desc"};return await r.client.message.list(i)},tt=async(t,e)=>{const n=++Z;H=!0;let i="";try{let c;const s=e==="full"?ae:1,p=e==="full"?le:ce;let f=e!=="full";for(let h=0;h<s;h++){let m;try{m=await Wt(t,p,c)}catch(b){i=String(b?.message??b);break}if(n!==Z)return;f||(A.clear(),f=!0);let v=0;for(const b of m?.data??[])A.has(b.id)||(yt({id:b.id,time:b.time?.created??0,text:Jt(b)}),v++);Q();const d=m?.cursor?.next;if(!d||v===0)break;c=d}}finally{H=!1,X=t,ht=i,Ft(A.size===0?i||"暂无用户消息":""),z&&(z=!1,kt())}},kt=()=>{j||(j=setTimeout(()=>{j=void 0;const t=a.untrack(y);t&&tt(t,"light")},300))},$t=t=>{if(!(!t||t!==a.untrack(y))){if(H){z=!0;return}kt()}},Ut=t=>{const e=t?.type;if(e==="message.updated"){const n=t?.data??{},i=n.info;i?.role==="user"&&i?.id&&(yt({id:i.id,time:i.time?.created??0,text:Jt(i)}),Q()),$t(n.sessionID)}else if(e==="message.removed"){const n=t?.data??{};n.messageID&&A.delete(n.messageID)&&Q(),$t(n.sessionID)}else if(e==="location.shutdown"){const n=a.untrack(y);n&&X===n&&tt(n,"full")}};a.onMount(()=>{let t=!1,e=0,n;(async()=>{for(;!t;){const s=r.client.event.subscribe()[Symbol.asyncIterator]();try{for(;!t;){const p=await s.next();if(p.done)break;e=0,Ut(p.value)}}catch{}try{await s.return?.()}catch{}if(t)break;e++,await ct(Math.min(2e3*e,15e3))}})(),a.onCleanup(()=>{t=!0,clearTimeout(n),j&&clearTimeout(j)})}),a.createEffect(()=>{const t=y();t&&a.untrack(()=>{X!==t&&(Z++,H=!1,z=!1,A.clear(),g([]),_(""),nt="",tt(t,"full"))})});let F;const C=()=>(F?.isConnected||(F=wt()),F),Gt=()=>{const t=C();if(!t)return;const e=t.querySelectorAll("[data-message-id]");if(e.length===0)return;const n=t.getBoundingClientRect().top+40;for(const i of e)if(i.getBoundingClientRect().bottom>=n){const c=i.getAttribute("data-message-id");c&&_(c);break}};let et=!1;const St=()=>{et||(et=!0,requestAnimationFrame(()=>{et=!1,Gt()}))};a.onMount(()=>{const t=()=>St();document.addEventListener("scroll",t,!0);const e=setInterval(St,1500);a.onCleanup(()=>{document.removeEventListener("scroll",t,!0),clearInterval(e)})});let Et=0;const w=(t,e=2600)=>{const n=++Et;xt(t),setTimeout(()=>{n===Et&&xt("")},e)};a.onMount(()=>{const t=n=>{if(n.key==="Escape"){if(a.untrack(x)){Ct(),n.stopPropagation();return}a.untrack(I)&&(L(!1),n.stopPropagation())}},e=n=>{if(!a.untrack(I))return;(n.composedPath?.()??[]).some(s=>s instanceof Element&&s.closest?.(".sr-rail"))||L(!1)};window.addEventListener("keydown",t,!0),document.addEventListener("pointerdown",e,!0),a.onCleanup(()=>{window.removeEventListener("keydown",t,!0),document.removeEventListener("pointerdown",e,!0)})}),a.onMount(()=>{const t=()=>w(`${at} · ${dt(r)}`),e=()=>{L(i=>!i),It()},n=()=>{Pt()};window.addEventListener(Bt,t),window.addEventListener(gt,e),window.addEventListener(lt,n),a.onCleanup(()=>{window.removeEventListener(Bt,t),window.removeEventListener(gt,e),window.removeEventListener(lt,n)}),w(`会话轮次 ${at} 已加载`)});let nt="",T=0,_t=0,At="";const rt=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{}},Vt=()=>{for(const t of u())if(k(t.id))return t},st=(t,e)=>{we(`${t}#${ft}${e}`),nt=e},W=()=>{const e=C()?.querySelectorAll("[data-message-id]")??[];if(e.length===0)return"0";const n=e[0].getAttribute("data-message-id")??"",i=e[e.length-1].getAttribute("data-message-id")??"";return`${e.length}:${n}:${i}`},Kt=(t,e,n,i)=>new Promise(c=>{let s=!1,p,f,h;const m=Date.now();let d=W(),b=m,M=0;const $=G=>{if(!s){s=!0;try{p?.disconnect()}catch{}clearInterval(f),clearTimeout(h),c(G??null)}},S=G=>{if(e!==T)return $(null);const jt=G?k(t):document.getElementById(`${ft}${t}`);if(jt)return $(jt);if(!G)return;const Rt=W(),qt=Date.now();if(Rt!==d){d=Rt,b=qt,M++,i?.(M);return}const te=M>0?pe:fe;qt-b>te&&$(null)},Dt=k(t);if(Dt)return $(Dt);p=new MutationObserver(()=>S(!1)),p.observe(document.documentElement,{childList:!0,subtree:!0}),f=setInterval(()=>S(!0),120),h=setTimeout(()=>$(k(t)),Math.max(400,n))}),Ct=()=>{T++,J(!1),P(0),D(0),O(0),N(""),w("已取消跳转")},Yt=async t=>{if(U()&&Xt(),x()&&Y()===t.id){w(`正在跳转 · 已加载 ${V()} 页 · ${K()}s`,q);return}const e=++T;J(!1),P(0),D(0),O(0),N(""),bt=t.id;const n=Date.now();if(t.id===At&&n-_t<he){const v=k(t.id),d=C();if(v&&d){pt(v),rt(v);return}}_t=n,At=t.id;const i=C(),c=k(t.id);if(c&&i){pt(c),rt(c),w("已定位");return}const s=Nt(r);if(!s){w(`无法读取当前路由,无法定位 · ${dt(r)}`);return}const p=y();J(!0),N(t.id),P(0),D(0),L(!0),w("正在定位…",q);const f=Date.now(),h=setInterval(()=>{D(Math.round((Date.now()-f)/1e3))},500);let m=0;try{const v=Date.now()+q;for(let d=0;d<de&&Date.now()<v;d++){if(e!==T||y()!==p)return;if(O(d+1),nt===t.id){const S=Vt()??u()[0];S&&(st(s,S.id),await ct(ge))}if(e!==T||y()!==p)return;st(s,t.id),w(`跳转中 · 已加载 ${m} 页`,q);let b=0;const M=Math.min(ue,v-Date.now()),$=await Kt(t.id,e,M,S=>{b=S,P(m+S),w(`跳转中 · 已加载 ${m+S} 页`,q)});if(m+=b,e!==T||y()!==p)return;if($){pt($),rt($),w("已定位");return}}e===T&&w(`未能定位 · ${dt(r)}`)}finally{clearInterval(h),e===T&&(J(!1),P(0),D(0),O(0),N(""))}},[U,ot]=a.createSignal(!1),[Tt,Mt]=a.createSignal(0);let R=0,it="";const Zt=()=>{const t=C();if(!t)return;const e=t.getBoundingClientRect();for(const n of t.querySelectorAll("[data-message-id]")){const i=n.getBoundingClientRect();if(i.bottom<=e.top+1)continue;const c=n.getAttribute("data-message-id");if(c)return{id:c,offset:i.top-e.top}}},Lt=t=>{if(!t)return;const e=C();if(!e)return;const n=k(t.id);if(!n)return;const i=e.getBoundingClientRect(),c=n.getBoundingClientRect().top-i.top-t.offset;Math.abs(c)>2&&(e.scrollTop+=c)},Xt=()=>{R++,ot(!1)},It=()=>{const t=y();!t||U()||it!==t&&(u().length<15||Pt())},Pt=async()=>{if(U())return;const t=Nt(r),e=u()[0],n=y();if(!t||!e||!n)return;if(it===n&&k(e.id)){w("历史已就绪");return}const i=++R,c=Zt(),s=C(),p=s?s.scrollTop+s.clientHeight>=s.scrollHeight-32:!0;ot(!0),Mt(0),w("正在预热历史…",12e4);try{st(t,e.id);const f=Date.now()+12e4;let h=0,m=W();const v=setInterval(()=>Lt(c),150);try{for(;Date.now()<f&&i===R&&!k(e.id);){await ct(250);const d=W();d!==m&&(m=d,h++,Mt(h))}}finally{clearInterval(v)}if(i!==R)return;if(Lt(c),p){const d=C();d&&(d.scrollTop=d.scrollHeight)}it=n,w(`历史已预热(${h} 页),现在跳转是瞬时的`)}finally{i===R&&ot(!1)}};a.createEffect(()=>{x()&&Y()&&requestAnimationFrame(()=>{try{document.querySelector('.sr-list .sr-turn[data-busy="true"]')?.scrollIntoView({block:"nearest"})}catch{}})});const Qt=[l.createComponent(a.Show,{get when(){return vt()},get children(){var t=ee();return l.insert(t,vt),t}}),(()=>{var t=re(),e=t.firstChild,n=e.firstChild,i=n.nextSibling,c=e.nextSibling;return t.addEventListener("mouseleave",()=>{x()||L(!1)}),t.addEventListener("mouseenter",()=>{L(!0),It()}),l.insert(e,l.createComponent(a.Show,{get when(){return x()||U()},get fallback(){return(()=>{var s=se();return s.firstChild,l.insert(s,(()=>{var p=l.memo(()=>u().length>0);return()=>p()?` · ${u().length}`:""})(),null),s})()},get children(){var s=ne();return l.insert(s,(()=>{var p=l.memo(()=>!!x());return()=>p()?`跳转中${V()>0?` · 已加载 ${V()} 页`:""}${K()>0?` · ${K()}s`:""}`:`预热中${Tt()>0?` · ${Tt()} 页`:""}`})()),s}}),n),n.$$click=()=>window.dispatchEvent(new Event(lt)),i.$$click=()=>{x()?Ct():L(!1)},l.insert(c,l.createComponent(a.Show,{get when(){return u().length>0},get fallback(){return(()=>{var s=oe();return l.insert(s,()=>zt()||`… ${at}`),s})()},get children(){return l.createComponent(a.For,{get each(){return u()},children:(s,p)=>(()=>{var f=ie(),h=f.firstChild,m=h.firstChild;m.firstChild;var v=m.nextSibling;return f.$$click=()=>{Yt(s)},l.insert(m,()=>p()+1,null),l.insert(v,()=>me(s.created)),l.insert(h,()=>s.text,null),l.effect(d=>{var b=E()===s.id?"true":"false",M=x()&&Y()===s.id?"true":"false";return b!==d.e&&l.setAttribute(f,"data-active",d.e=b),M!==d.t&&l.setAttribute(f,"data-busy",d.t=M),d},{e:void 0,t:void 0}),f})()})}})),l.effect(s=>{var p=I()?"true":"false",f=x()?"true":"false",h=x()?"取消跳转 (Esc)":"关闭 (Esc)";return p!==s.e&&l.setAttribute(t,"data-open",s.e=p),f!==s.t&&l.setAttribute(t,"data-jumping",s.t=f),h!==s.a&&l.setAttribute(i,"title",s.a=h),s},{e:void 0,t:void 0,a:void 0}),t})()];return l.createComponent(l.Portal,{get mount(){return document.body},children:Qt})}const ye=B.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(B.Style,ve),o.add(B.Slot,{at:"window.bottom",render:()=>l.createComponent(a.Show,{get when(){return o.sessions.current()},get children(){return l.createComponent(xe,{session:()=>o.sessions.current()})}})}),o.add(B.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(gt))}}))}});l.delegateEvents(["click"]);exports.default=ye;
245
+ `;function xt(a){const s=q.usePlugin(),[d,p]=l.createSignal([]),[S,$]=l.createSignal(""),[E,I]=l.createSignal(!1),[Je,Oe]=l.createSignal(""),[L,ge]=l.createSignal(""),[x,B]=l.createSignal(!1),[he,D]=l.createSignal(0),[me,P]=l.createSignal(0),[yt,J]=l.createSignal(0),[O,N]=l.createSignal(""),_=new Map;let W=0,H=!1,z=!1,V="",j;const v=()=>a.session()?.id,Y=()=>{const e=[..._.values()].sort((r,i)=>r.created-i.created),t=l.untrack(d);t.length===e.length&&t.every((r,i)=>r===e[i])||(p(e),Be=e.length)},be=e=>{const t=_.get(e.id);t&&t.created===e.time&&t.text===e.text||_.set(e.id,{id:e.id,created:e.time,text:e.text})},Ne=async(e,t,r)=>{const i=r?{sessionID:e,type:"user",limit:t,cursor:r}:{sessionID:e,type:"user",limit:t,order:"desc"};return await s.client.message.list(i)},K=async(e,t)=>{const r=++W;H=!0;let i="";try{let n;const c=t==="full"?ot:1,u=t==="full"?at:it;let g=t!=="full";for(let b=0;b<c;b++){let m;try{m=await Ne(e,u,n)}catch(h){i=String(h?.message??h);break}if(r!==W)return;g||(_.clear(),g=!0);let w=0;for(const h of m?.data??[])_.has(h.id)||(be({id:h.id,time:h.time?.created??0,text:je(h)}),w++);Y();const f=m?.cursor?.next;if(!f||w===0)break;n=f}}finally{H=!1,V=e,de=i,Oe(_.size===0?i||"暂无用户消息":""),z&&(z=!1,we())}},we=()=>{j||(j=setTimeout(()=>{j=void 0;const e=l.untrack(v);e&&K(e,"light")},300))},xe=e=>{if(!(!e||e!==l.untrack(v))){if(H){z=!0;return}we()}},He=e=>{const t=e?.type;if(t==="message.updated"){const r=e?.data??{},i=r.info;i?.role==="user"&&i?.id&&(be({id:i.id,time:i.time?.created??0,text:je(i)}),Y()),xe(r.sessionID)}else if(t==="message.removed"){const r=e?.data??{};r.messageID&&_.delete(r.messageID)&&Y(),xe(r.sessionID)}else if(t==="location.shutdown"){const r=l.untrack(v);r&&V===r&&K(r,"full")}};l.onMount(()=>{let e=!1,t=0,r;(async()=>{for(;!e;){const c=s.client.event.subscribe()[Symbol.asyncIterator]();try{for(;!e;){const u=await c.next();if(u.done)break;t=0,He(u.value)}}catch{}try{await c.return?.()}catch{}if(e)break;t++,await se(Math.min(2e3*t,15e3))}})(),l.onCleanup(()=>{e=!0,clearTimeout(r),j&&clearTimeout(j)})}),l.createEffect(()=>{const e=v();e&&l.untrack(()=>{V!==e&&(W++,H=!1,z=!1,_.clear(),p([]),$(""),X="",K(e,"full"))})});let F;const C=()=>(F?.isConnected||(F=fe()),F),ze=()=>{const e=C();if(!e)return;const t=e.querySelectorAll("[data-message-id]");if(t.length===0)return;const r=e.getBoundingClientRect().top+40;for(const i of t)if(i.getBoundingClientRect().bottom>=r){const n=i.getAttribute("data-message-id");n&&$(n);break}};let Z=!1;const ve=()=>{Z||(Z=!0,requestAnimationFrame(()=>{Z=!1,ze()}))};l.onMount(()=>{const e=()=>ve();document.addEventListener("scroll",e,!0);const t=setInterval(ve,1500);l.onCleanup(()=>{document.removeEventListener("scroll",e,!0),clearInterval(t)})});let ye=0;const U=(e,t=2600)=>{const r=++ye;ge(e),setTimeout(()=>{r===ye&&ge("")},t)};l.onMount(()=>{const e=r=>{if(r.key==="Escape"){if(l.untrack(x)){Ee(),r.stopPropagation();return}l.untrack(E)&&(I(!1),r.stopPropagation())}},t=r=>{if(!l.untrack(E))return;(r.composedPath?.()??[]).some(c=>c instanceof Element&&c.closest?.(".sr-rail"))||I(!1)};window.addEventListener("keydown",e,!0),document.addEventListener("pointerdown",t,!0),l.onCleanup(()=>{window.removeEventListener("keydown",e,!0),document.removeEventListener("pointerdown",t,!0)})}),l.onMount(()=>{const e=()=>U(`${De} · ${oe(s)}`),t=()=>{I(r=>!r),Ce()};window.addEventListener(Pe,e),window.addEventListener(ce,t),l.onCleanup(()=>{window.removeEventListener(Pe,e),window.removeEventListener(ce,t)})});let X="",T=0,ke=0,Se="";const Q=e=>{try{const t=e.querySelector('[data-slot="user-message-text"]')??e.querySelector('[data-slot="user-message-body"]')??e;t.classList.remove("sr-flash-ring"),t.offsetWidth,t.classList.add("sr-flash-ring"),setTimeout(()=>t.classList.remove("sr-flash-ring"),1900)}catch{}},Fe=()=>{for(const e of d())if(y(e.id))return e},ee=(e,t)=>{bt(`${e}#${le}${t}`),X=t},$e=()=>{const t=C()?.querySelectorAll("[data-message-id]")??[];if(t.length===0)return"0";const r=t[0].getAttribute("data-message-id")??"",i=t[t.length-1].getAttribute("data-message-id")??"";return`${t.length}:${r}:${i}`},Ue=(e,t,r,i)=>new Promise(n=>{let c=!1,u,g,b;const m=Date.now();let f=$e(),h=m,A=0;const k=G=>{if(!c){c=!0;try{u?.disconnect()}catch{}clearInterval(g),clearTimeout(b),n(G??null)}},M=G=>{if(t!==T)return k(null);const Ae=G?y(e):document.getElementById(`${le}${e}`);if(Ae)return k(Ae);if(!G)return;const Me=$e(),Ie=Date.now();if(Me!==f){f=Me,h=Ie,A++,i?.(A);return}const Ze=A>0?ut:pt;Ie-h>Ze&&k(null)},Te=y(e);if(Te)return k(Te);u=new MutationObserver(()=>M(!1)),u.observe(document.documentElement,{childList:!0,subtree:!0}),g=setInterval(()=>M(!0),120),b=setTimeout(()=>k(y(e)),Math.max(400,r))}),Ee=()=>{T++,B(!1),D(0),P(0),J(0),N(""),U("已取消跳转")},Ge=async e=>{if(te()&&Ve(),x()&&O()===e.id)return;const t=++T;B(!1),D(0),P(0),J(0),N(""),pe=e.id;const r=Date.now();if(e.id===Se&&r-ke<gt){const w=y(e.id),f=C();if(w&&f){ie(w),Q(w);return}}ke=r,Se=e.id;const i=C(),n=y(e.id);if(n&&i){ie(n),Q(n);return}const c=qe(s);if(!c){U(`无法读取当前路由,无法定位 · ${oe(s)}`);return}const u=v();B(!0),N(e.id),D(0),P(0),I(!0);const g=Date.now(),b=setInterval(()=>{P(Math.round((Date.now()-g)/1e3))},500);let m=0;try{const w=Date.now()+ct;for(let f=0;f<lt&&Date.now()<w;f++){if(t!==T||v()!==u)return;if(J(f+1),X===e.id){const M=Fe()??d()[0];M&&(ee(c,M.id),await se(ft))}if(t!==T||v()!==u)return;ee(c,e.id);let h=0;const A=Math.min(dt,w-Date.now()),k=await Ue(e.id,t,A,M=>{h=M,D(m+M)});if(m+=h,t!==T||v()!==u)return;if(k){ie(k),Q(k);return}}t===T&&U(`未能定位 · ${oe(s)}`)}finally{clearInterval(b),t===T&&(B(!1),D(0),P(0),J(0),N(""))}},[te,re]=l.createSignal(!1);let R=0,ne="";const We=()=>{const e=C();if(!e)return;const t=e.getBoundingClientRect();for(const r of e.querySelectorAll("[data-message-id]")){const i=r.getBoundingClientRect();if(i.bottom<=t.top+1)continue;const n=r.getAttribute("data-message-id");if(n)return{el:r,id:n,offset:i.top-t.top}}},_e=e=>{if(!e)return;const t=C();if(!t)return;const r=e.el?.isConnected?e.el:y(e.id);if(!r)return;const i=t.getBoundingClientRect(),n=r.getBoundingClientRect().top-i.top-e.offset;Math.abs(n)>2&&(t.scrollTop+=n)},Ve=()=>{R++,re(!1)},Ce=()=>{const e=v();!e||te()||ne!==e&&(d().length<15||Ye())},Ye=async()=>{if(te())return;const e=qe(s),t=d()[0],r=v();if(!e||!t||!r||ne===r&&y(t.id))return;const i=++R,n=We(),c=C(),u=c?c.scrollTop+c.clientHeight>=c.scrollHeight-32:!0;re(!0);try{ee(e,t.id);const g=Date.now()+12e4,b=setInterval(()=>_e(n),150);try{for(;Date.now()<g&&i===R&&!y(t.id);)await se(250)}finally{clearInterval(b)}if(i!==R)return;if(_e(n),u){const m=C();m&&(m.scrollTop=m.scrollHeight)}ne=r}finally{i===R&&re(!1)}};l.createEffect(()=>{x()&&O()&&requestAnimationFrame(()=>{try{document.querySelector('.sr-list .sr-turn[data-busy="true"]')?.scrollIntoView({block:"nearest"})}catch{}})});const Ke=[o.createComponent(l.Show,{get when(){return L()},get children(){var e=Le();return o.insert(e,L),e}}),(()=>{var e=et(),t=e.firstChild,r=t.firstChild,i=t.nextSibling;return e.addEventListener("mouseleave",()=>{x()||I(!1)}),e.addEventListener("mouseenter",()=>{I(!0),Ce()}),o.insert(t,o.createComponent(l.Show,{get when(){return x()},get fallback(){return(()=>{var n=tt();return n.firstChild,o.insert(n,(()=>{var c=o.memo(()=>d().length>0);return()=>c()?` · ${d().length}`:""})(),null),n})()},get children(){var n=Xe();return n.firstChild,o.insert(n,(()=>{var c=o.memo(()=>he()>0);return()=>c()?` · ${he()} 页`:""})(),null),o.insert(n,(()=>{var c=o.memo(()=>me()>0);return()=>c()?` · ${me()}s`:""})(),null),n}}),r),r.$$click=()=>{x()?Ee():I(!1)},o.insert(i,o.createComponent(l.Show,{get when(){return d().length>0},get fallback(){return(()=>{var n=rt();return o.insert(n,()=>Je()||`… ${De}`),n})()},get children(){return o.createComponent(l.For,{get each(){return d()},children:(n,c)=>(()=>{var u=st(),g=u.firstChild,b=g.firstChild;b.firstChild;var m=b.nextSibling,w=g.nextSibling;return u.$$click=()=>{Ge(n)},o.insert(b,()=>c()+1,null),o.insert(m,()=>ht(n.created)),o.insert(g,()=>n.text,null),o.insert(u,o.createComponent(l.Show,{get when(){return o.memo(()=>!!x())()&&O()===n.id},get children(){return nt()}}),w),o.effect(f=>{var h=S()===n.id?"true":"false",A=x()&&O()===n.id?"true":"false";return h!==f.e&&o.setAttribute(u,"data-active",f.e=h),A!==f.t&&o.setAttribute(u,"data-busy",f.t=A),f},{e:void 0,t:void 0}),u})()})}})),o.insert(e,o.createComponent(l.Show,{get when(){return o.memo(()=>!!E())()&&L()},get children(){var n=Qe();return o.insert(n,L),n}}),null),o.effect(n=>{var c=E()?"true":"false",u=x()?"true":"false",g=x()?"取消跳转 (Esc)":"关闭 (Esc)";return c!==n.e&&o.setAttribute(e,"data-open",n.e=c),u!==n.t&&o.setAttribute(e,"data-jumping",n.t=u),g!==n.a&&o.setAttribute(r,"title",n.a=g),n},{e:void 0,t:void 0,a:void 0}),e})(),o.createComponent(l.Show,{get when(){return o.memo(()=>!E())()&&L()},get children(){var e=Le();return o.insert(e,L),e}})];return o.createComponent(o.Portal,{get mount(){return document.body},children:Ke})}const vt=q.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(a){a.add(q.Style,wt),a.add(q.Slot,{at:"window.bottom",render:()=>o.createComponent(l.Show,{get when(){return a.sessions.current()},get children(){return o.createComponent(xt,{session:()=>a.sessions.current()})}})}),a.add(q.Command,()=>({id:"jump",title:a.t("command.jump"),description:a.t("command.jump.description"),group:a.t("command.jump"),section:"navigation",bind:"mod+alt+r",slash:{name:"turns"},run:()=>{window.dispatchEvent(new Event(ce))}}))}});o.delegateEvents(["click"]);exports.default=vt;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-session-turns",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
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
@@ -10,7 +10,6 @@ const VERSION = "v16"
10
10
  const HASH_PREFIX = "message-"
11
11
  const DIAG_EVENT = "session-turns:diag"
12
12
  const OPEN_EVENT = "session-turns:open"
13
- const WARM_EVENT = "session-turns:warm"
14
13
 
15
14
  // paging: a full load walks every page; a light refresh pulls just the newest
16
15
  // page (new messages always land at the head of a desc listing).
@@ -27,9 +26,9 @@ const LIGHT_PAGE_SIZE = 50
27
26
  // assumes the chain died and starts a new one.
28
27
  const JUMP_ROUNDS = 30
29
28
  const JUMP_TOTAL_MS = 45000
30
- const JUMP_CHAIN_MS = 12000
31
- const JUMP_IDLE_MS = 5000
32
- const JUMP_FIRST_IDLE_MS = 9000
29
+ const JUMP_CHAIN_MS = 20000
30
+ const JUMP_IDLE_MS = 8000
31
+ const JUMP_FIRST_IDLE_MS = 12000
33
32
  const JUMP_TOGGLE_DWELL_MS = 220
34
33
  const SAME_TARGET_WINDOW_MS = 800
35
34
 
@@ -77,11 +76,21 @@ const findScroller = () =>
77
76
 
78
77
  // The app sets id="message-<id>" on every timeline row. Prefer that over the
79
78
  // data attribute: Solid Router's own scrollToHash() uses getElementById too.
80
- const findRow = (messageID) =>
81
- document.getElementById(`${HASH_PREFIX}${messageID}`) ??
82
- [...document.querySelectorAll("[data-message-id]")].find(
83
- (node) => node.getAttribute("data-message-id") === messageID,
84
- )
79
+ //
80
+ // IMPORTANT: `data-message-id` is set on assistant rows as well (they carry
81
+ // the id of the user message they belong to), so a naive lookup can return an
82
+ // assistant row and we would scroll to the reply instead of the question.
83
+ // Only the user message row counts as "found".
84
+ const findRow = (messageID) => {
85
+ const anchored = document.getElementById(`${HASH_PREFIX}${messageID}`)
86
+ if (anchored) return anchored
87
+ const selector = `[data-message-id="${messageID.replace(/"/g, '\\"')}"]`
88
+ for (const node of document.querySelectorAll(selector)) {
89
+ if (node.getAttribute("data-timeline-row") === "UserMessage") return node
90
+ if (node.querySelector('[data-slot="user-message-text"]')) return node
91
+ }
92
+ return undefined
93
+ }
85
94
 
86
95
  const diagnostics = (ctx) => {
87
96
  const scroller = findScroller()
@@ -244,21 +253,10 @@ const CSS = `
244
253
  flex-shrink: 0;
245
254
  }
246
255
  .sr-close:hover { opacity: 1; background: var(--surface-base-hover, rgba(127,127,127,.14)); }
247
- .sr-warm {
248
- border: 1px solid rgba(127,127,127,.28);
249
- background: transparent;
250
- color: inherit;
251
- font: inherit;
252
- font-size: 11px;
253
- line-height: 1;
254
- padding: 3px 7px;
255
- border-radius: 5px;
256
- cursor: pointer;
257
- opacity: .6;
258
- flex-shrink: 0;
259
- }
260
- .sr-warm:hover { opacity: 1; background: var(--surface-base-hover, rgba(127,127,127,.14)); }
261
- /* the scrollable list; the header stays fixed above it */
256
+ /* the scrollable list; the header stays fixed above it.
257
+ The collapsed rail must NOT be a scroll container: centering the content
258
+ inside a scroller overflows in both directions, so the browser clamps the
259
+ scroll offset and the list snaps to the top/bottom when the panel opens. */
262
260
  .sr-list {
263
261
  flex: 1;
264
262
  min-height: 0;
@@ -266,11 +264,15 @@ const CSS = `
266
264
  flex-direction: column;
267
265
  justify-content: center;
268
266
  gap: 2px;
269
- overflow-y: auto;
270
- overflow-x: hidden;
267
+ overflow: hidden;
271
268
  scrollbar-width: none;
272
269
  }
273
- .sr-rail[data-open="true"] .sr-list { justify-content: flex-start; gap: 1px; scrollbar-width: thin; }
270
+ .sr-rail[data-open="true"] .sr-list {
271
+ justify-content: flex-start;
272
+ gap: 1px;
273
+ overflow-y: auto;
274
+ scrollbar-width: thin;
275
+ }
274
276
  .sr-list::-webkit-scrollbar { display: none; }
275
277
  .sr-rail[data-open="true"] .sr-list::-webkit-scrollbar { display: block; width: 8px; }
276
278
  .sr-list::-webkit-scrollbar-track { background: transparent; }
@@ -292,9 +294,6 @@ const CSS = `
292
294
  color: inherit;
293
295
  font: inherit;
294
296
  border-radius: 4px;
295
- /* long sessions: skip layout/paint for rows outside the panel viewport */
296
- content-visibility: auto;
297
- contain-intrinsic-size: 0 26px;
298
297
  }
299
298
  /* expanded: dash flips to a left-side indicator, content reads left-to-right */
300
299
  .sr-rail[data-open="true"] .sr-turn {
@@ -390,6 +389,17 @@ const CSS = `
390
389
  box-shadow: inset 0 0 0 2px rgba(255,170,60,0), inset 0 0 0 rgba(255,170,60,0);
391
390
  }
392
391
  }
392
+ .sr-badge {
393
+ font-size: 10px;
394
+ line-height: 1.4;
395
+ padding: 0 5px;
396
+ border-radius: 4px;
397
+ color: rgba(255,170,60,.95);
398
+ background: rgba(255,170,60,.14);
399
+ border: 1px solid rgba(255,170,60,.35);
400
+ flex-shrink: 0;
401
+ white-space: nowrap;
402
+ }
393
403
  .sr-note {
394
404
  display: none;
395
405
  font-size: 11px;
@@ -397,20 +407,33 @@ const CSS = `
397
407
  padding: 2px;
398
408
  }
399
409
  .sr-rail[data-open="true"] .sr-note { display: block; }
410
+ .sr-foot {
411
+ flex-shrink: 0;
412
+ font-size: 11px;
413
+ line-height: 1.5;
414
+ color: rgba(255,170,60,.92);
415
+ padding: 6px 2px 0;
416
+ margin-top: 6px;
417
+ border-top: 1px solid rgba(127,127,127,.2);
418
+ white-space: pre-wrap;
419
+ max-height: 64px;
420
+ overflow: hidden;
421
+ }
400
422
  .sr-status {
401
423
  position: fixed;
402
- right: 30px;
403
- bottom: 150px;
424
+ right: 42px;
425
+ top: 50%;
426
+ transform: translateY(-50%);
404
427
  z-index: 401;
405
428
  font-size: 11px;
406
- padding: 3px 8px;
429
+ line-height: 1.5;
430
+ padding: 4px 9px;
407
431
  border-radius: 6px;
408
- background: var(--background-strong, rgba(0,0,0,.75));
432
+ background: var(--background-strong, rgba(0,0,0,.82));
409
433
  color: var(--text-base, #fff);
410
- opacity: .9;
411
- pointer-events: none;
434
+ border: 1px solid rgba(127,127,127,.25);
412
435
  white-space: pre-wrap;
413
- max-width: min(720px, 90vw);
436
+ max-width: 260px;
414
437
  }
415
438
  `
416
439
 
@@ -722,17 +745,13 @@ function Rail(props) {
722
745
  setOpen((value) => !value)
723
746
  maybePrewarm()
724
747
  }
725
- const onWarm = () => void prewarm()
726
748
  window.addEventListener(DIAG_EVENT, onDiag)
727
749
  window.addEventListener(OPEN_EVENT, onOpen)
728
- window.addEventListener(WARM_EVENT, onWarm)
729
750
  onCleanup(() => {
730
751
  window.removeEventListener(DIAG_EVENT, onDiag)
731
752
  window.removeEventListener(OPEN_EVENT, onOpen)
732
- window.removeEventListener(WARM_EVENT, onWarm)
733
753
  })
734
- // One-shot confirmation that this window actually runs the rebuilt bundle.
735
- flash(`会话轮次 ${VERSION} 已加载`)
754
+ // No startup toast: the plugin should be invisible when everything works.
736
755
  })
737
756
 
738
757
  // ---- jump ----------------------------------------------------------------
@@ -860,12 +879,9 @@ function Rail(props) {
860
879
 
861
880
  // Clicking the same turn again while its jump is running must NOT restart
862
881
  // the loop: every restart cancels the host's in-flight page load, which is
863
- // what makes a deep jump advance only one page per click. Re-affirm the
864
- // progress instead and let the running loop finish.
865
- if (jumping() && busyID() === turn.id) {
866
- flash(`正在跳转 · 已加载 ${jumpPages()} 页 · ${jumpElapsed()}s`, JUMP_TOTAL_MS)
867
- return
868
- }
882
+ // what makes a deep jump advance only one page per click. The header
883
+ // already shows the progress, so just leave it running.
884
+ if (jumping() && busyID() === turn.id) return
869
885
 
870
886
  const token = ++jumpToken
871
887
  // A new click always owns the progress UI. This also clears a previous
@@ -896,11 +912,11 @@ function Rail(props) {
896
912
  const sc = scroller()
897
913
  const row = findRow(turn.id)
898
914
 
899
- // Fast path: the row is mounted, scroll locally.
915
+ // Fast path: the row is mounted, scroll locally. No toast: the bubble
916
+ // ring is the feedback.
900
917
  if (row && sc) {
901
918
  alignAfterLand(row)
902
919
  pulse(row)
903
- flash("已定位")
904
920
  return
905
921
  }
906
922
 
@@ -929,7 +945,8 @@ function Rail(props) {
929
945
  setJumpElapsed(0)
930
946
  // Pin the panel open even if the pointer has left it meanwhile.
931
947
  setOpen(true)
932
- flash("正在定位…", JUMP_TOTAL_MS)
948
+ // The loading state is shown in the panel header and on the target row -
949
+ // no toast needed, the toast would land outside the user's focus.
933
950
  const startedAt = Date.now()
934
951
  const elapsedTimer = setInterval(() => {
935
952
  setJumpElapsed(Math.round((Date.now() - startedAt) / 1000))
@@ -959,21 +976,19 @@ function Rail(props) {
959
976
  }
960
977
  if (token !== jumpToken || sessionID() !== sessionAtJump) return
961
978
  navTo(path, turn.id)
962
- flash(`跳转中 · 已加载 ${chainBase} 页`, JUMP_TOTAL_MS)
963
979
 
964
980
  let chainPages = 0
965
981
  const remaining = Math.min(JUMP_CHAIN_MS, deadline - Date.now())
966
982
  const landed = await waitForRow(turn.id, token, remaining, (pages) => {
967
983
  chainPages = pages
984
+ // Progress is shown in the panel header, not in a toast.
968
985
  setJumpPages(chainBase + pages)
969
- flash(`跳转中 · 已加载 ${chainBase + pages} 页`, JUMP_TOTAL_MS)
970
986
  })
971
987
  chainBase += chainPages
972
988
  if (token !== jumpToken || sessionID() !== sessionAtJump) return
973
989
  if (landed) {
974
990
  alignAfterLand(landed)
975
991
  pulse(landed)
976
- flash("已定位")
977
992
  return
978
993
  }
979
994
  // The host went idle without landing: it exhausted this chain. The
@@ -1002,7 +1017,6 @@ function Rail(props) {
1002
1017
  // and restore the viewport afterwards. Once the store holds the whole
1003
1018
  // history every jump is instant.
1004
1019
  const [warming, setWarming] = createSignal(false)
1005
- const [warmPages, setWarmPages] = createSignal(0)
1006
1020
  let warmToken = 0
1007
1021
  let warmedFor = ""
1008
1022
 
@@ -1014,9 +1028,9 @@ function Rail(props) {
1014
1028
  for (const row of sc.querySelectorAll("[data-message-id]")) {
1015
1029
  const rect = row.getBoundingClientRect()
1016
1030
  if (rect.bottom <= scRect.top + 1) continue
1017
- const id = row.getAttribute("data-message-id")
1018
- if (!id) continue
1019
- return { id, offset: rect.top - scRect.top }
1031
+ const mid = row.getAttribute("data-message-id")
1032
+ if (!mid) continue
1033
+ return { el: row, id: mid, offset: rect.top - scRect.top }
1020
1034
  }
1021
1035
  return undefined
1022
1036
  }
@@ -1025,7 +1039,9 @@ function Rail(props) {
1025
1039
  if (!anchor) return
1026
1040
  const sc = scroller()
1027
1041
  if (!sc) return
1028
- const row = findRow(anchor.id)
1042
+ // Prefer the element itself: an assistant row can carry the same message
1043
+ // id, so looking it up by id might land on a different element.
1044
+ const row = anchor.el?.isConnected ? anchor.el : findRow(anchor.id)
1029
1045
  if (!row) return
1030
1046
  const scRect = sc.getBoundingClientRect()
1031
1047
  const delta = row.getBoundingClientRect().top - scRect.top - anchor.offset
@@ -1054,33 +1070,20 @@ function Rail(props) {
1054
1070
  const sid = sessionID()
1055
1071
  if (!path || !oldest || !sid) return
1056
1072
  // Already warm: the oldest turn is mounted, so the store has the history.
1057
- if (warmedFor === sid && findRow(oldest.id)) {
1058
- flash("历史已就绪")
1059
- return
1060
- }
1073
+ if (warmedFor === sid && findRow(oldest.id)) return
1061
1074
  const token = ++warmToken
1062
1075
  const anchor = viewAnchor()
1063
1076
  const sc = scroller()
1064
1077
  const wasAtBottom = sc ? sc.scrollTop + sc.clientHeight >= sc.scrollHeight - 32 : true
1065
1078
  setWarming(true)
1066
- setWarmPages(0)
1067
- flash("正在预热历史…", 120000)
1068
1079
  try {
1069
1080
  navTo(path, oldest.id)
1070
1081
  const deadline = Date.now() + 120000
1071
- let pages = 0
1072
- let lastSig = rowSignature()
1073
1082
  const keep = setInterval(() => restoreView(anchor), 150)
1074
1083
  try {
1075
1084
  while (Date.now() < deadline && token === warmToken) {
1076
1085
  if (findRow(oldest.id)) break
1077
1086
  await sleep(250)
1078
- const sig = rowSignature()
1079
- if (sig !== lastSig) {
1080
- lastSig = sig
1081
- pages++
1082
- setWarmPages(pages)
1083
- }
1084
1087
  }
1085
1088
  } finally {
1086
1089
  clearInterval(keep)
@@ -1093,7 +1096,7 @@ function Rail(props) {
1093
1096
  if (live) live.scrollTop = live.scrollHeight
1094
1097
  }
1095
1098
  warmedFor = sid
1096
- flash(`历史已预热(${pages} 页),现在跳转是瞬时的`)
1099
+ // Silent on success: prewarming is an implementation detail.
1097
1100
  } finally {
1098
1101
  if (token === warmToken) setWarming(false)
1099
1102
  }
@@ -1135,25 +1138,16 @@ function Rail(props) {
1135
1138
  >
1136
1139
  <div class="sr-head">
1137
1140
  <Show
1138
- when={jumping() || warming()}
1141
+ when={jumping()}
1139
1142
  fallback={
1140
1143
  <span class="sr-title">会话轮次{turns().length > 0 ? ` · ${turns().length}` : ""}</span>
1141
1144
  }
1142
1145
  >
1143
1146
  <span class="sr-title sr-progress">
1144
- {jumping()
1145
- ? `跳转中${jumpPages() > 0 ? ` · 已加载 ${jumpPages()} 页` : ""}${jumpElapsed() > 0 ? ` · ${jumpElapsed()}s` : ""}`
1146
- : `预热中${warmPages() > 0 ? ` · ${warmPages()} 页` : ""}`}
1147
+ 加载历史中{jumpPages() > 0 ? ` · ${jumpPages()} 页` : ""}
1148
+ {jumpElapsed() > 0 ? ` · ${jumpElapsed()}s` : ""}
1147
1149
  </span>
1148
1150
  </Show>
1149
- <button
1150
- type="button"
1151
- class="sr-warm"
1152
- title="预热历史:后台把会话历史加载完,之后跳转瞬时"
1153
- onClick={() => window.dispatchEvent(new Event(WARM_EVENT))}
1154
- >
1155
- 预热
1156
- </button>
1157
1151
  <button
1158
1152
  type="button"
1159
1153
  class="sr-close"
@@ -1182,13 +1176,25 @@ function Rail(props) {
1182
1176
  <span class="sr-time">{formatTime(turn.created)}</span>
1183
1177
  {turn.text}
1184
1178
  </span>
1179
+ <Show when={jumping() && busyID() === turn.id}>
1180
+ <span class="sr-badge">加载中</span>
1181
+ </Show>
1185
1182
  <span class="sr-dash" />
1186
1183
  </button>
1187
1184
  )}
1188
1185
  </For>
1189
1186
  </Show>
1190
1187
  </div>
1188
+ {/* Messages that need attention (errors, cancel confirmations) live in
1189
+ the panel footer while it is open, and next to the collapsed rail
1190
+ when it is closed - the old bottom-right toast was easy to miss. */}
1191
+ <Show when={open() && status()}>
1192
+ <div class="sr-foot">{status()}</div>
1193
+ </Show>
1191
1194
  </div>
1195
+ <Show when={!open() && status()}>
1196
+ <div class="sr-status">{status()}</div>
1197
+ </Show>
1192
1198
  </>
1193
1199
  )
1194
1200