opencode-session-turns 0.1.5 → 0.1.7

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,7 +22,7 @@ click a turn to jump straight to that message.
22
22
  - 列出当前会话的每一轮用户提问(从会话消息接口分页拉取,`type=user`)。
23
23
  - 鼠标移到窗口右缘的细条上自动展开,移开自动收起;长历史跳转期间面板保持展开。
24
24
  - 点击任意一轮,**跳转到对应消息**——包括很久以前、尚未渲染的历史轮次。
25
- - 深历史需要逐页加载时,头部实时显示「跳转中 · 第 N 页」,目标轮次琥珀色脉冲,
25
+ - 深历史需要逐页加载时,头部实时显示「已加载 N 页 / 耗时 Xs」,目标轮次琥珀色脉冲,
26
26
  底部状态条同步提示;按 `Esc` 或点 × 可随时取消。
27
27
  - 定位成功后精准描边闪烁**那一条用户气泡**(跟随气泡自身圆角 + 柔光),不再整行填色。
28
28
  - 自动高亮当前视口所在的轮次。
@@ -63,10 +63,13 @@ OpenCode Desktop 2.2.6 有几个必须绕开的陷阱:
63
63
  上注册的链接拦截器(Solid Router 的 `create` 钩子)会把它交给**内存 router 的
64
64
  navigate()**,从而驱动宿主自己的游标状态机加载历史并定位到该行——全程不碰
65
65
  `window.location`。
66
- 3. 宿主每次 seek 只向前加载**约一页(~5 轮)**历史,所以插件会交替把 hash 指向
67
- 「已加载的最早一轮」和目标,逐页把历史翻出来,直到目标行挂载。
68
- 每一轮监听行集合指纹,宿主一旦翻入新一页就立即进入下一轮(不再固定等待),
69
- 并在面板头部显示页码进度;总预算 45 秒,`Esc` 可取消。
66
+ 3. 宿主每次 seek 只向前加载**约一页(~5 轮)**历史。宿主在 `hash.changed` 后会进入
67
+ `pending(目标)` 状态并**异步**加载缺失的历史(内部最多重试 12 次)。因此插件
68
+ **只设置一次 hash 然后耐心等待**:任何额外的 hash 切换都会把宿主的 `pending`
69
+ 状态重置、打断正在飞行的历史请求,表现为"每次点击只前进一页"。
70
+ 只有当宿主连续 5 秒(首屏 9 秒)毫无变化、判定这一轮已经结束时,插件才把
71
+ hash 弹到一个已挂载的轮次再切回来,开启新的一轮;面板头部实时显示
72
+ 「已加载 N 页 / 耗时 Xs」,总预算 45 秒,`Esc` 可取消。
70
73
 
71
74
  已挂载的行则直接用本地滚动,瞬时定位。
72
75
 
@@ -1,5 +1,5 @@
1
- "use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const o=require("solid-js/web"),L=require("@opencode/plugin/gui"),i=require("solid-js");var Lt=o.template("<div class=sr-status>"),It=o.template('<span class="sr-title sr-progress">跳转中'),Mt=o.template("<div class=sr-rail><div class=sr-head><button type=button class=sr-close>×</button></div><div class=sr-list>"),Pt=o.template("<span class=sr-title>会话轮次"),jt=o.template("<div class=sr-note>"),Dt=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 G="v12",Y="message-",gt="session-turns:diag",Z="session-turns:open",qt=40,Rt=200,Ot=50,Jt=120,V=45e3,Nt=4e3,zt=300,Bt=800;let xt=0,X="",Q="";const mt=a=>{const n=String(a?.text??"").split(`
2
- `).map(b=>b.trim()).find(b=>b.length>0);if(n)return n;const u=a?.files?.length??0;return u>0?`[附件 ×${u}]`:"(空消息)"},bt=a=>String(a).padStart(2,"0"),Ft=a=>{const n=new Date(a??0);return`${bt(n.getHours())}:${bt(n.getMinutes())}`},wt=a=>new Promise(n=>setTimeout(n,a)),vt=()=>document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport')??document.querySelector('[data-slot="session-timeline-scroll"] [data-scrollable]')??(()=>{let n=document.querySelector("[data-message-id]")?.parentElement;for(;n;){const u=getComputedStyle(n);if(/(auto|scroll)/.test(u.overflowY)&&n.scrollHeight>n.clientHeight+1)return n;n=n.parentElement}})(),E=a=>document.getElementById(`${Y}${a}`)??[...document.querySelectorAll("[data-message-id]")].find(n=>n.getAttribute("data-message-id")===a),W=a=>{const n=vt(),u=[...document.querySelectorAll("[data-message-id]")],b=u[0]?.getAttribute("data-message-id")??"",O=Q?` · 目标 ${E(Q)?"在":"不在"} DOM`:"",I=b?` · 行ID ${b.slice(0,20)}`:"";let _="(空)";try{_=a.router.path()||"(空)"}catch{}return`轮次 ${xt} · 容器 ${n?"OK":"缺失"} · DOM ${u.length} 行${O}${I} · 路由 ${_} · hash ${window.location.hash||"(空)"}${X?` · ${X}`:""}`},K=(a,n)=>{n.scrollIntoView({block:"start"});const u=a.querySelector("[data-session-title]"),b=u instanceof HTMLElement?u.offsetHeight:0;b&&(a.scrollTop-=b+12)},Ht=a=>{let n="";try{n=a.router.path()||""}catch{}return n.startsWith("/")?n:""},Ut=a=>{const n=document.createElement("a");n.setAttribute("href",a),n.setAttribute("replace",""),n.setAttribute("noscroll",""),n.style.display="none",document.body.appendChild(n);try{n.dispatchEvent(new MouseEvent("click",{bubbles:!0,cancelable:!0,button:0,view:window}))}finally{n.remove()}},Gt=`
1
+ "use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const a=require("solid-js/web"),R=require("@opencode/plugin/gui"),i=require("solid-js");var Ot=a.template("<div class=sr-status>"),Nt=a.template('<span class="sr-title sr-progress">跳转中'),Ft=a.template("<div class=sr-rail><div class=sr-head><button type=button class=sr-close>×</button></div><div class=sr-list>"),Ht=a.template("<span class=sr-title>会话轮次"),Ut=a.template("<div class=sr-note>"),zt=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 tt="v13",nt="message-",Et="session-turns:diag",st="session-turns:open",Gt=40,Wt=200,Vt=50,Kt=30,j=45e3,Yt=12e3,Zt=5e3,Xt=9e3,Qt=220,te=800;let At=0,ot="",at="";const _t=o=>{const r=String(o?.text??"").split(`
2
+ `).map(g=>g.trim()).find(g=>g.length>0);if(r)return r;const d=o?.files?.length??0;return d>0?`[附件 ×${d}]`:"(空消息)"},Ct=o=>String(o).padStart(2,"0"),ee=o=>{const r=new Date(o??0);return`${Ct(r.getHours())}:${Ct(r.getMinutes())}`},Tt=o=>new Promise(r=>setTimeout(r,o)),it=()=>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 d=getComputedStyle(r);if(/(auto|scroll)/.test(d.overflowY)&&r.scrollHeight>r.clientHeight+1)return r;r=r.parentElement}})(),T=o=>document.getElementById(`${nt}${o}`)??[...document.querySelectorAll("[data-message-id]")].find(r=>r.getAttribute("data-message-id")===o),et=o=>{const r=it(),d=[...document.querySelectorAll("[data-message-id]")],g=d[0]?.getAttribute("data-message-id")??"",S=at?` · 目标 ${T(at)?"在":"不在"} DOM`:"",A=g?` · 行ID ${g.slice(0,20)}`:"";let y="(空)";try{y=o.router.path()||"(空)"}catch{}return`轮次 ${At} · 容器 ${r?"OK":"缺失"} · DOM ${d.length} 行${S}${A} · 路由 ${y} · hash ${window.location.hash||"(空)"}${ot?` · ${ot}`:""}`},rt=(o,r)=>{const d=o.querySelector("[data-session-title]"),g=o.getBoundingClientRect(),S=d instanceof HTMLElement?d.getBoundingClientRect().bottom:g.top,y=r.getBoundingClientRect().top-(S+24);Math.abs(y)>2&&(o.scrollTop+=y)},re=o=>{setTimeout(()=>{if(!o.isConnected)return;const r=it();if(!r)return;const d=r.querySelector("[data-session-title]"),g=d instanceof HTMLElement?d.getBoundingClientRect().bottom:r.getBoundingClientRect().top,S=o.getBoundingClientRect().top;S<g+4&&(r.scrollTop+=S-(g+24))},300)},ne=o=>{let r="";try{r=o.router.path()||""}catch{}return r.startsWith("/")?r:""},se=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()}},oe=`
3
3
  .sr-rail {
4
4
  position: fixed;
5
5
  top: 50%;
@@ -215,4 +215,4 @@
215
215
  white-space: pre-wrap;
216
216
  max-width: min(720px, 90vw);
217
217
  }
218
- `;function Vt(a){const n=L.usePlugin(),[u,b]=i.createSignal([]),[O,I]=i.createSignal(""),[_,$]=i.createSignal(!1),[yt,kt]=i.createSignal(""),[tt,et]=i.createSignal(""),[v,M]=i.createSignal(!1),[rt,T]=i.createSignal(0),[nt,P]=i.createSignal(""),y=new Map;let J=0,j=!1,D=!1,N="",A;const k=()=>a.session()?.id,z=()=>{const t=[...y.values()].sort((r,l)=>r.created-l.created),e=i.untrack(u);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 n.client.message.list(l)},B=async(t,e)=>{const r=++J;j=!0;let l="";try{let s;const c=e==="full"?qt:1,d=e==="full"?Rt:Ot;let p=e!=="full";for(let h=0;h<c;h++){let x;try{x=await St(t,d,s)}catch(m){l=String(m?.message??m);break}if(r!==J)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:mt(m)}),f++);z();const g=x?.cursor?.next;if(!g||f===0)break;s=g}}finally{j=!1,N=t,X=l,kt(y.size===0?l||"暂无用户消息":""),D&&(D=!1,ot())}},ot=()=>{A||(A=setTimeout(()=>{A=void 0;const t=i.untrack(k);t&&B(t,"light")},300))},at=t=>{if(!(!t||t!==i.untrack(k))){if(j){D=!0;return}ot()}},$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:mt(l)}),z()),at(r.sessionID)}else if(e==="message.removed"){const r=t?.data??{};r.messageID&&y.delete(r.messageID)&&z(),at(r.sessionID)}else if(e==="location.shutdown"){const r=i.untrack(k);r&&N===r&&B(r,"full")}};i.onMount(()=>{let t=!1,e=0,r;(async()=>{for(;!t;){const c=n.client.event.subscribe()[Symbol.asyncIterator]();try{for(;!t;){const d=await c.next();if(d.done)break;e=0,$t(d.value)}}catch{}try{await c.return?.()}catch{}if(t)break;e++,await wt(Math.min(2e3*e,15e3))}})(),i.onCleanup(()=>{t=!0,clearTimeout(r),A&&clearTimeout(A)})}),i.createEffect(()=>{const t=k();t&&i.untrack(()=>{N!==t&&(J++,j=!1,D=!1,y.clear(),b([]),I(""),H="",B(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 s=l.getAttribute("data-message-id");s&&I(s);break}};let F=!1;const it=()=>{F||(F=!0,requestAnimationFrame(()=>{F=!1,Et()}))};i.onMount(()=>{const t=()=>it();document.addEventListener("scroll",t,!0);const e=setInterval(it,1500);i.onCleanup(()=>{document.removeEventListener("scroll",t,!0),clearInterval(e)})});let lt=0;const w=(t,e=2600)=>{const r=++lt;et(t),setTimeout(()=>{r===lt&&et("")},e)};i.onMount(()=>{const t=r=>{if(r.key==="Escape"){if(i.untrack(v)){ft(),r.stopPropagation();return}i.untrack(_)&&($(!1),r.stopPropagation())}},e=r=>{if(!i.untrack(_))return;(r.composedPath?.()??[]).some(c=>c instanceof Element&&c.closest?.(".sr-rail"))||$(!1)};window.addEventListener("keydown",t,!0),document.addEventListener("pointerdown",e,!0),i.onCleanup(()=>{window.removeEventListener("keydown",t,!0),document.removeEventListener("pointerdown",e,!0)})}),i.onMount(()=>{const t=()=>w(`${G} · ${W(n)}`),e=()=>$(r=>!r);window.addEventListener(gt,t),window.addEventListener(Z,e),i.onCleanup(()=>{window.removeEventListener(gt,t),window.removeEventListener(Z,e)}),w(`会话轮次 ${G} 已加载`)});let H="",S=0,ct=0,dt="";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 u())if(E(t.id))return t},ut=(t,e)=>{Ut(`${t}#${Y}${e}`),H=e},pt=()=>{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 s=!1,c,d,p;const h=Date.now(),x=pt(),f=R=>{if(!s){s=!0;try{c?.disconnect()}catch{}clearInterval(d),clearTimeout(p),l(R??null)}},g=R=>{if(e!==S)return f(null);const ht=R?E(t):document.getElementById(`${Y}${t}`);if(ht)return f(ht);R&&Date.now()-h>zt&&pt()!==x&&f(null)},m=E(t);if(m)return f(m);c=new MutationObserver(()=>g(!1)),c.observe(document.documentElement,{childList:!0,subtree:!0}),d=setInterval(()=>g(!0),120),p=setTimeout(()=>f(E(t)),Math.max(400,r))}),ft=()=>{S++,M(!1),T(0),P(""),w("已取消跳转")},At=async t=>{const e=++S;M(!1),T(0),P(""),Q=t.id;const r=Date.now();if(t.id===dt&&r-ct<Bt){const p=E(t.id),h=C();if(p&&h){K(h,p),U(p);return}}ct=r,dt=t.id;const l=C(),s=E(t.id);if(s&&l){K(l,s),U(s),w("已定位");return}const c=Ht(n);if(!c){w(`无法读取当前路由,无法定位 · ${W(n)}`);return}const d=k();M(!0),P(t.id),T(0),$(!0),w("正在定位…",V);try{const p=Date.now()+V;for(let h=0;h<Jt&&Date.now()<p;h++){if(e!==S||k()!==d)return;if(T(h+1),w(`跳转中 · 第 ${h+1} 页`,V),H===t.id){const g=_t()??u()[0];g&&(ut(c,g.id),await wt(120))}if(e!==S||k()!==d)return;ut(c,t.id);const x=Math.min(Nt,p-Date.now()),f=await Tt(t.id,e,x);if(e!==S||k()!==d)return;if(f){const g=C()??l;g&&K(g,f),U(f),w("已定位");return}}e===S&&w(`未能定位 · ${W(n)}`)}finally{e===S&&(M(!1),T(0),P(""))}};i.createEffect(()=>{v()&&nt()&&requestAnimationFrame(()=>{try{document.querySelector('.sr-list .sr-turn[data-busy="true"]')?.scrollIntoView({block:"nearest"})}catch{}})});const Ct=[o.createComponent(i.Show,{get when(){return tt()},get children(){var t=Lt();return o.insert(t,tt),t}}),(()=>{var t=Mt(),e=t.firstChild,r=e.firstChild,l=e.nextSibling;return t.addEventListener("mouseleave",()=>{v()||$(!1)}),t.addEventListener("mouseenter",()=>$(!0)),o.insert(e,o.createComponent(i.Show,{get when(){return v()},get fallback(){return(()=>{var s=Pt();return s.firstChild,o.insert(s,(()=>{var c=o.memo(()=>u().length>0);return()=>c()?` · ${u().length}`:""})(),null),s})()},get children(){var s=It();return s.firstChild,o.insert(s,(()=>{var c=o.memo(()=>rt()>0);return()=>c()?` · 第 ${rt()} 页`:""})(),null),s}}),r),r.$$click=()=>{v()?ft():$(!1)},o.insert(l,o.createComponent(i.Show,{get when(){return u().length>0},get fallback(){return(()=>{var s=jt();return o.insert(s,()=>yt()||`… ${G}`),s})()},get children(){return o.createComponent(i.For,{get each(){return u()},children:(s,c)=>(()=>{var d=Dt(),p=d.firstChild,h=p.firstChild;h.firstChild;var x=h.nextSibling;return d.$$click=()=>{At(s)},o.insert(h,()=>c()+1,null),o.insert(x,()=>Ft(s.created)),o.insert(p,()=>s.text,null),o.effect(f=>{var g=O()===s.id?"true":"false",m=v()&&nt()===s.id?"true":"false";return g!==f.e&&o.setAttribute(d,"data-active",f.e=g),m!==f.t&&o.setAttribute(d,"data-busy",f.t=m),f},{e:void 0,t:void 0}),d})()})}})),o.effect(s=>{var c=_()?"true":"false",d=v()?"true":"false",p=v()?"取消跳转 (Esc)":"关闭 (Esc)";return c!==s.e&&o.setAttribute(t,"data-open",s.e=c),d!==s.t&&o.setAttribute(t,"data-jumping",s.t=d),p!==s.a&&o.setAttribute(r,"title",s.a=p),s},{e:void 0,t:void 0,a:void 0}),t})()];return o.createComponent(o.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(a){a.add(L.Style,Gt),a.add(L.Slot,{at:"window.bottom",render:()=>o.createComponent(i.Show,{get when(){return a.sessions.current()},get children(){return o.createComponent(Vt,{session:()=>a.sessions.current()})}})}),a.add(L.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(Z))}}))}});o.delegateEvents(["click"]);exports.default=Wt;
218
+ `;function ae(o){const r=R.usePlugin(),[d,g]=i.createSignal([]),[S,A]=i.createSignal(""),[y,C]=i.createSignal(!1),[Lt,Mt]=i.createSignal(""),[lt,ct]=i.createSignal(""),[k,q]=i.createSignal(!1),[B,L]=i.createSignal(0),[z,M]=i.createSignal(0),[le,J]=i.createSignal(0),[G,O]=i.createSignal(""),$=new Map;let W=0,N=!1,F=!1,V="",I;const E=()=>o.session()?.id,K=()=>{const t=[...$.values()].sort((n,l)=>n.created-l.created),e=i.untrack(d);e.length===t.length&&e.every((n,l)=>n===t[l])||(g(t),At=t.length)},dt=t=>{const e=$.get(t.id);e&&e.created===t.time&&e.text===t.text||$.set(t.id,{id:t.id,created:t.time,text:t.text})},It=async(t,e,n)=>{const l=n?{sessionID:t,type:"user",limit:e,cursor:n}:{sessionID:t,type:"user",limit:e,order:"desc"};return await r.client.message.list(l)},Y=async(t,e)=>{const n=++W;N=!0;let l="";try{let s;const c=e==="full"?Gt:1,u=e==="full"?Wt:Vt;let m=e!=="full";for(let v=0;v<c;v++){let h;try{h=await It(t,u,s)}catch(p){l=String(p?.message??p);break}if(n!==W)return;m||($.clear(),m=!0);let f=0;for(const p of h?.data??[])$.has(p.id)||(dt({id:p.id,time:p.time?.created??0,text:_t(p)}),f++);K();const x=h?.cursor?.next;if(!x||f===0)break;s=x}}finally{N=!1,V=t,ot=l,Mt($.size===0?l||"暂无用户消息":""),F&&(F=!1,ut())}},ut=()=>{I||(I=setTimeout(()=>{I=void 0;const t=i.untrack(E);t&&Y(t,"light")},300))},pt=t=>{if(!(!t||t!==i.untrack(E))){if(N){F=!0;return}ut()}},Dt=t=>{const e=t?.type;if(e==="message.updated"){const n=t?.data??{},l=n.info;l?.role==="user"&&l?.id&&(dt({id:l.id,time:l.time?.created??0,text:_t(l)}),K()),pt(n.sessionID)}else if(e==="message.removed"){const n=t?.data??{};n.messageID&&$.delete(n.messageID)&&K(),pt(n.sessionID)}else if(e==="location.shutdown"){const n=i.untrack(E);n&&V===n&&Y(n,"full")}};i.onMount(()=>{let t=!1,e=0,n;(async()=>{for(;!t;){const c=r.client.event.subscribe()[Symbol.asyncIterator]();try{for(;!t;){const u=await c.next();if(u.done)break;e=0,Dt(u.value)}}catch{}try{await c.return?.()}catch{}if(t)break;e++,await Tt(Math.min(2e3*e,15e3))}})(),i.onCleanup(()=>{t=!0,clearTimeout(n),I&&clearTimeout(I)})}),i.createEffect(()=>{const t=E();t&&i.untrack(()=>{V!==t&&(W++,N=!1,F=!1,$.clear(),g([]),A(""),X="",Y(t,"full"))})});let H;const D=()=>(H?.isConnected||(H=it()),H),Pt=()=>{const t=D();if(!t)return;const e=t.querySelectorAll("[data-message-id]");if(e.length===0)return;const n=t.getBoundingClientRect().top+40;for(const l of e)if(l.getBoundingClientRect().bottom>=n){const s=l.getAttribute("data-message-id");s&&A(s);break}};let Z=!1;const ft=()=>{Z||(Z=!0,requestAnimationFrame(()=>{Z=!1,Pt()}))};i.onMount(()=>{const t=()=>ft();document.addEventListener("scroll",t,!0);const e=setInterval(ft,1500);i.onCleanup(()=>{document.removeEventListener("scroll",t,!0),clearInterval(e)})});let gt=0;const w=(t,e=2600)=>{const n=++gt;ct(t),setTimeout(()=>{n===gt&&ct("")},e)};i.onMount(()=>{const t=n=>{if(n.key==="Escape"){if(i.untrack(k)){vt(),n.stopPropagation();return}i.untrack(y)&&(C(!1),n.stopPropagation())}},e=n=>{if(!i.untrack(y))return;(n.composedPath?.()??[]).some(c=>c instanceof Element&&c.closest?.(".sr-rail"))||C(!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(`${tt} · ${et(r)}`),e=()=>C(n=>!n);window.addEventListener(Et,t),window.addEventListener(st,e),i.onCleanup(()=>{window.removeEventListener(Et,t),window.removeEventListener(st,e)}),w(`会话轮次 ${tt} 已加载`)});let X="",_=0,ht=0,mt="";const Q=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{}},jt=()=>{for(const t of d())if(T(t.id))return t},bt=(t,e)=>{se(`${t}#${nt}${e}`),X=e},wt=()=>{const e=D()?.querySelectorAll("[data-message-id]")??[];if(e.length===0)return"0";const n=e[0].getAttribute("data-message-id")??"",l=e[e.length-1].getAttribute("data-message-id")??"";return`${e.length}:${n}:${l}`},Rt=(t,e,n,l)=>new Promise(s=>{let c=!1,u,m,v;const h=Date.now();let x=wt(),p=h,b=0;const P=U=>{if(!c){c=!0;try{u?.disconnect()}catch{}clearInterval(m),clearTimeout(v),s(U??null)}},xt=U=>{if(e!==_)return P(null);const kt=U?T(t):document.getElementById(`${nt}${t}`);if(kt)return P(kt);if(!U)return;const St=wt(),$t=Date.now();if(St!==x){x=St,p=$t,b++,l?.(b);return}const Jt=b>0?Zt:Xt;$t-p>Jt&&P(null)},yt=T(t);if(yt)return P(yt);u=new MutationObserver(()=>xt(!1)),u.observe(document.documentElement,{childList:!0,subtree:!0}),m=setInterval(()=>xt(!0),120),v=setTimeout(()=>P(T(t)),Math.max(400,n))}),vt=()=>{_++,q(!1),L(0),M(0),J(0),O(""),w("已取消跳转")},qt=async t=>{if(k()&&G()===t.id){w(`正在跳转 · 已加载 ${B()} 页 · ${z()}s`,j);return}const e=++_;q(!1),L(0),M(0),J(0),O(""),at=t.id;const n=Date.now();if(t.id===mt&&n-ht<te){const h=T(t.id),f=D();if(h&&f){rt(f,h),Q(h);return}}ht=n,mt=t.id;const l=D(),s=T(t.id);if(s&&l){rt(l,s),Q(s),w("已定位");return}const c=ne(r);if(!c){w(`无法读取当前路由,无法定位 · ${et(r)}`);return}const u=E();q(!0),O(t.id),L(0),M(0),C(!0),w("正在定位…",j);const m=Date.now(),v=setInterval(()=>{M(Math.round((Date.now()-m)/1e3))},500);try{const h=Date.now()+j;for(let f=0;f<Kt&&Date.now()<h;f++){if(e!==_||E()!==u)return;if(J(f+1),X===t.id){const b=jt()??d()[0];b&&(bt(c,b.id),await Tt(Qt))}if(e!==_||E()!==u)return;bt(c,t.id),w(`跳转中 · 已加载 ${B()} 页`,j);const x=Math.min(Yt,h-Date.now()),p=await Rt(t.id,e,x,b=>{L(b),w(`跳转中 · 已加载 ${b} 页`,j)});if(e!==_||E()!==u)return;if(p){const b=D()??l;b&&rt(b,p),Q(p),w("已定位"),re(p);return}}e===_&&w(`未能定位 · ${et(r)}`)}finally{clearInterval(v),e===_&&(q(!1),L(0),M(0),J(0),O(""))}};i.createEffect(()=>{k()&&G()&&requestAnimationFrame(()=>{try{document.querySelector('.sr-list .sr-turn[data-busy="true"]')?.scrollIntoView({block:"nearest"})}catch{}})});const Bt=[a.createComponent(i.Show,{get when(){return lt()},get children(){var t=Ot();return a.insert(t,lt),t}}),(()=>{var t=Ft(),e=t.firstChild,n=e.firstChild,l=e.nextSibling;return t.addEventListener("mouseleave",()=>{k()||C(!1)}),t.addEventListener("mouseenter",()=>C(!0)),a.insert(e,a.createComponent(i.Show,{get when(){return k()},get fallback(){return(()=>{var s=Ht();return s.firstChild,a.insert(s,(()=>{var c=a.memo(()=>d().length>0);return()=>c()?` · ${d().length}`:""})(),null),s})()},get children(){var s=Nt();return s.firstChild,a.insert(s,(()=>{var c=a.memo(()=>B()>0);return()=>c()?` · 已加载 ${B()} 页`:""})(),null),a.insert(s,(()=>{var c=a.memo(()=>z()>0);return()=>c()?` · ${z()}s`:""})(),null),s}}),n),n.$$click=()=>{k()?vt():C(!1)},a.insert(l,a.createComponent(i.Show,{get when(){return d().length>0},get fallback(){return(()=>{var s=Ut();return a.insert(s,()=>Lt()||`… ${tt}`),s})()},get children(){return a.createComponent(i.For,{get each(){return d()},children:(s,c)=>(()=>{var u=zt(),m=u.firstChild,v=m.firstChild;v.firstChild;var h=v.nextSibling;return u.$$click=()=>{qt(s)},a.insert(v,()=>c()+1,null),a.insert(h,()=>ee(s.created)),a.insert(m,()=>s.text,null),a.effect(f=>{var x=S()===s.id?"true":"false",p=k()&&G()===s.id?"true":"false";return x!==f.e&&a.setAttribute(u,"data-active",f.e=x),p!==f.t&&a.setAttribute(u,"data-busy",f.t=p),f},{e:void 0,t:void 0}),u})()})}})),a.effect(s=>{var c=y()?"true":"false",u=k()?"true":"false",m=k()?"取消跳转 (Esc)":"关闭 (Esc)";return c!==s.e&&a.setAttribute(t,"data-open",s.e=c),u!==s.t&&a.setAttribute(t,"data-jumping",s.t=u),m!==s.a&&a.setAttribute(n,"title",s.a=m),s},{e:void 0,t:void 0,a:void 0}),t})()];return a.createComponent(a.Portal,{get mount(){return document.body},children:Bt})}const ie=R.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(R.Style,oe),o.add(R.Slot,{at:"window.bottom",render:()=>a.createComponent(i.Show,{get when(){return o.sessions.current()},get children(){return a.createComponent(ae,{session:()=>o.sessions.current()})}})}),o.add(R.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(st))}}))}});a.delegateEvents(["click"]);exports.default=ie;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-session-turns",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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 = "v12"
9
+ const VERSION = "v13"
10
10
  const HASH_PREFIX = "message-"
11
11
  const DIAG_EVENT = "session-turns:diag"
12
12
  const OPEN_EVENT = "session-turns:open"
@@ -17,13 +17,19 @@ 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. 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
20
+ // Jump budgets.
21
+ //
22
+ // The host loads missing history asynchronously and retries a missing seek up
23
+ // to 12 times on its own. The plugin therefore does NOT poll the hash: it
24
+ // sets it once and waits. JUMP_CHAIN_MS is how long a single chain may run,
25
+ // JUMP_IDLE_MS how long the host may stay completely idle before the plugin
26
+ // assumes the chain died and starts a new one.
27
+ const JUMP_ROUNDS = 30
24
28
  const JUMP_TOTAL_MS = 45000
25
- const JUMP_ROW_TIMEOUT_MS = 4000
26
- const JUMP_SETTLE_MS = 300
29
+ const JUMP_CHAIN_MS = 12000
30
+ const JUMP_IDLE_MS = 5000
31
+ const JUMP_FIRST_IDLE_MS = 9000
32
+ const JUMP_TOGGLE_DWELL_MS = 220
27
33
  const SAME_TARGET_WINDOW_MS = 800
28
34
 
29
35
  // Filled by Rail.load()/jump() so the command palette can report diagnostics.
@@ -89,12 +95,33 @@ const diagnostics = (ctx) => {
89
95
  }
90
96
 
91
97
  const scrollToRow = (scroller, row) => {
92
- // Native scrolling reaches the real scroll ancestor without depending on
93
- // the viewport selector; then compensate for the sticky session title.
94
- row.scrollIntoView({ block: "start" })
98
+ // Align by rects instead of scrollIntoView: the host puts the row top flush
99
+ // against the sticky session title, but a 16px gradient fade sits right below
100
+ // it and would cover the top of the bubble (and our outline). Leave 24px so
101
+ // the bubble clears both the title and the fade.
95
102
  const title = scroller.querySelector("[data-session-title]")
96
- const inset = title instanceof HTMLElement ? title.offsetHeight : 0
97
- if (inset) scroller.scrollTop -= inset + 12
103
+ const scRect = scroller.getBoundingClientRect()
104
+ const titleBottom =
105
+ title instanceof HTMLElement ? title.getBoundingClientRect().bottom : scRect.top
106
+ const rowTop = row.getBoundingClientRect().top
107
+ const delta = rowTop - (titleBottom + 24)
108
+ if (Math.abs(delta) > 2) scroller.scrollTop += delta
109
+ }
110
+
111
+ // The host's own hash seek may finish after ours and re-align the row flush
112
+ // under the sticky title (its 16px fade then covers the bubble's top edge).
113
+ // Repair only that case: if the user already scrolled elsewhere, leave it be.
114
+ const repairScroll = (row) => {
115
+ setTimeout(() => {
116
+ if (!row.isConnected) return
117
+ const sc = findScroller()
118
+ if (!sc) return
119
+ const title = sc.querySelector("[data-session-title]")
120
+ const titleBottom =
121
+ title instanceof HTMLElement ? title.getBoundingClientRect().bottom : sc.getBoundingClientRect().top
122
+ const rowTop = row.getBoundingClientRect().top
123
+ if (rowTop < titleBottom + 4) sc.scrollTop += rowTop - (titleBottom + 24)
124
+ }, 300)
98
125
  }
99
126
 
100
127
  // The desktop app (2.2.6) routes through a memory history, so window.location is
@@ -371,9 +398,11 @@ function Rail(props) {
371
398
  const [note, setNote] = createSignal("")
372
399
  const [status, setStatus] = createSignal("")
373
400
  // 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.
401
+ // header reports what the host is doing; `busyID` marks the target row.
375
402
  const [jumping, setJumping] = createSignal(false)
376
- const [jumpPage, setJumpPage] = createSignal(0)
403
+ const [jumpPages, setJumpPages] = createSignal(0)
404
+ const [jumpElapsed, setJumpElapsed] = createSignal(0)
405
+ const [jumpChain, setJumpChain] = createSignal(0)
377
406
  const [busyID, setBusyID] = createSignal("")
378
407
 
379
408
  // ---- turn cache ---------------------------------------------------------
@@ -722,10 +751,14 @@ function Rail(props) {
722
751
  return `${rows.length}:${first}:${last}`
723
752
  }
724
753
 
725
- // Resolves when the row enters the DOM (MutationObserver, with a cheap poll
726
- // as a belt-and-braces fallback), or null on timeout / supersession / when
727
- // the host has paged in another slice (the caller then drives the next seek).
728
- const waitForRow = (messageID, token, timeout) =>
754
+ // Waits until the target row is mounted.
755
+ //
756
+ // Resolves with the row on success, or null when the host stopped making
757
+ // progress (its own seek chain ran out) or the timeout hit. Crucially it
758
+ // does NOT resolve just because rows appeared: the host may still be
759
+ // fetching the rest of the history for this target, and interrupting it
760
+ // (by toggling the hash) is what made deep jumps crawl one page per click.
761
+ const waitForRow = (messageID, token, timeout, onPage) =>
729
762
  new Promise((resolve) => {
730
763
  let done = false
731
764
  let observer
@@ -733,6 +766,9 @@ function Rail(props) {
733
766
  let timer
734
767
  const started = Date.now()
735
768
  const baseline = rowSignature()
769
+ let lastSig = baseline
770
+ let lastChange = started
771
+ let pages = 0
736
772
  const finish = (row) => {
737
773
  if (done) return
738
774
  done = true
@@ -749,8 +785,22 @@ function Rail(props) {
749
785
  // data-attribute fallback (a full scan) and the row fingerprint.
750
786
  const row = full ? findRow(messageID) : document.getElementById(`${HASH_PREFIX}${messageID}`)
751
787
  if (row) return finish(row)
752
- // Let the click settle before trusting a DOM change as "a page arrived".
753
- if (full && Date.now() - started > JUMP_SETTLE_MS && rowSignature() !== baseline) finish(null)
788
+ if (!full) return
789
+ const sig = rowSignature()
790
+ const now = Date.now()
791
+ if (sig !== lastSig) {
792
+ lastSig = sig
793
+ lastChange = now
794
+ pages++
795
+ onPage?.(pages)
796
+ return
797
+ }
798
+ // Nothing has changed for a while: the host has no more history in
799
+ // flight for this target. Before the first page lands we are even more
800
+ // patient - the initial fetch can take seconds, and bouncing the hash
801
+ // then would abort it.
802
+ const idleLimit = pages > 0 ? JUMP_IDLE_MS : JUMP_FIRST_IDLE_MS
803
+ if (now - lastChange > idleLimit) finish(null)
754
804
  }
755
805
  const existing = findRow(messageID)
756
806
  if (existing) return finish(existing)
@@ -763,18 +813,31 @@ function Rail(props) {
763
813
  const cancelJump = () => {
764
814
  jumpToken++
765
815
  setJumping(false)
766
- setJumpPage(0)
816
+ setJumpPages(0)
817
+ setJumpElapsed(0)
818
+ setJumpChain(0)
767
819
  setBusyID("")
768
820
  flash("已取消跳转")
769
821
  }
770
822
 
771
823
  const jump = async (turn) => {
824
+ // Clicking the same turn again while its jump is running must NOT restart
825
+ // the loop: every restart cancels the host's in-flight page load, which is
826
+ // what makes a deep jump advance only one page per click. Re-affirm the
827
+ // progress instead and let the running loop finish.
828
+ if (jumping() && busyID() === turn.id) {
829
+ flash(`正在跳转 · 已加载 ${jumpPages()} 页 · ${jumpElapsed()}s`, JUMP_TOTAL_MS)
830
+ return
831
+ }
832
+
772
833
  const token = ++jumpToken
773
834
  // A new click always owns the progress UI. This also clears a previous
774
835
  // jump's state when the new target takes the mounted fast path, where the
775
836
  // superseded loop's finally no longer touches these signals.
776
837
  setJumping(false)
777
- setJumpPage(0)
838
+ setJumpPages(0)
839
+ setJumpElapsed(0)
840
+ setJumpChain(0)
778
841
  setBusyID("")
779
842
  lastJumpID = turn.id
780
843
  const now = Date.now()
@@ -810,56 +873,81 @@ function Rail(props) {
810
873
  return
811
874
  }
812
875
 
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.
876
+ // Deep history.
877
+ //
878
+ // The host's own seek machine does the heavy lifting: hash.changed ->
879
+ // applying -> pending(target) -> target.synced -> seek, and it loads the
880
+ // missing history asynchronously (up to 12 internal retries). Every extra
881
+ // hash change we send RESETS that pending state and aborts the history
882
+ // fetch in flight, which is why hammering the hash made a deep jump
883
+ // advance a single page at a time.
884
+ //
885
+ // So: set the hash once, then be patient. Only when the host has been
886
+ // completely idle for JUMP_IDLE_MS do we bounce the hash through a
887
+ // mounted turn to kick off a fresh chain.
819
888
  const sessionAtJump = sessionID()
820
889
  setJumping(true)
821
890
  setBusyID(turn.id)
822
- setJumpPage(0)
891
+ setJumpPages(0)
892
+ setJumpElapsed(0)
823
893
  // Pin the panel open even if the pointer has left it meanwhile.
824
894
  setOpen(true)
825
895
  flash("正在定位…", JUMP_TOTAL_MS)
896
+ const startedAt = Date.now()
897
+ const elapsedTimer = setInterval(() => {
898
+ setJumpElapsed(Math.round((Date.now() - startedAt) / 1000))
899
+ }, 500)
826
900
  try {
827
901
  const deadline = Date.now() + JUMP_TOTAL_MS
828
- for (let round = 0; round < JUMP_ROUNDS && Date.now() < deadline; round++) {
902
+ for (let chain = 0; chain < JUMP_ROUNDS && Date.now() < deadline; chain++) {
829
903
  // Abandon the loop if the user switched sessions mid-jump: toggling the
830
904
  // old route hash would navigate back to the previous session.
831
905
  if (token !== jumpToken || sessionID() !== sessionAtJump) return
832
- setJumpPage(round + 1)
833
- flash(`跳转中 · 第 ${round + 1} 页`, JUMP_TOTAL_MS)
906
+ setJumpChain(chain + 1)
834
907
 
835
908
  if (lastNavID === turn.id) {
909
+ // The hash already points at the target and the host ignores a
910
+ // repeated hash value, so bounce through a mounted turn to force a
911
+ // fresh seek. This is the fallback path, not the normal one.
836
912
  const other = oldestLoadedTurn() ?? turns()[0]
837
913
  if (other) {
838
914
  navTo(path, other.id)
839
- await sleep(120)
915
+ // Let the host commit the intermediate hash before seeking back;
916
+ // too fast and both clicks collapse into the same target hash.
917
+ await sleep(JUMP_TOGGLE_DWELL_MS)
840
918
  }
841
919
  }
842
920
  if (token !== jumpToken || sessionID() !== sessionAtJump) return
843
921
  navTo(path, turn.id)
922
+ flash(`跳转中 · 已加载 ${jumpPages()} 页`, JUMP_TOTAL_MS)
844
923
 
845
- const remaining = Math.min(JUMP_ROW_TIMEOUT_MS, deadline - Date.now())
846
- const landed = await waitForRow(turn.id, token, remaining)
924
+ const remaining = Math.min(JUMP_CHAIN_MS, deadline - Date.now())
925
+ const landed = await waitForRow(turn.id, token, remaining, (pages) => {
926
+ setJumpPages(pages)
927
+ flash(`跳转中 · 已加载 ${pages} 页`, JUMP_TOTAL_MS)
928
+ })
847
929
  if (token !== jumpToken || sessionID() !== sessionAtJump) return
848
930
  if (landed) {
849
931
  const live = scroller() ?? sc
850
932
  if (live) scrollToRow(live, landed)
851
933
  pulse(landed)
852
934
  flash("已定位")
935
+ repairScroll(landed)
853
936
  return
854
937
  }
938
+ // The host went idle without landing: it exhausted this chain. The
939
+ // next iteration bounces the hash to start a new one.
855
940
  }
856
941
 
857
942
  if (token === jumpToken) flash(`未能定位 · ${diagnostics(ctx)}`)
858
943
  } finally {
944
+ clearInterval(elapsedTimer)
859
945
  // A newer jump owns the signals when the token moved on.
860
946
  if (token === jumpToken) {
861
947
  setJumping(false)
862
- setJumpPage(0)
948
+ setJumpPages(0)
949
+ setJumpElapsed(0)
950
+ setJumpChain(0)
863
951
  setBusyID("")
864
952
  }
865
953
  }
@@ -904,7 +992,8 @@ function Rail(props) {
904
992
  }
905
993
  >
906
994
  <span class="sr-title sr-progress">
907
- 跳转中{jumpPage() > 0 ? ` · 第 ${jumpPage()} 页` : ""}
995
+ 跳转中{jumpPages() > 0 ? ` · 已加载 ${jumpPages()} 页` : ""}
996
+ {jumpElapsed() > 0 ? ` · ${jumpElapsed()}s` : ""}
908
997
  </span>
909
998
  </Show>
910
999
  <button