opencode-session-turns 0.1.14 → 0.1.16
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 +6 -4
- package/dist/gui-experimental.cjs +3 -3
- package/package.json +1 -1
- package/src/gui.tsx +146 -46
package/README.md
CHANGED
|
@@ -22,11 +22,13 @@ click a turn to jump straight to that message.
|
|
|
22
22
|
- 列出当前会话的每一轮用户提问(从会话消息接口分页拉取,`type=user`)。
|
|
23
23
|
- 鼠标移到窗口右缘的细条上自动展开,移开自动收起;长历史跳转期间面板保持展开。
|
|
24
24
|
- 点击任意一轮,**跳转到对应消息**——包括很久以前、尚未渲染的历史轮次。
|
|
25
|
-
-
|
|
26
|
-
|
|
25
|
+
- 深历史需要逐页加载时,面板头部显示进度,目标轮次带徽标并琥珀色脉冲;
|
|
26
|
+
按 `Esc` 或点 × 可随时取消。
|
|
27
27
|
- **历史预热**:宿主自己加载历史每页之间有 2 秒硬冷却,深历史跳转因此要等好几秒。
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
轮次条会在**打开会话时**(轮次列表加载完、≥15 轮)就后台把历史预热完并还原视口,
|
|
29
|
+
悬停面板或 `/turns` 也会触发。预热完成后所有跳转都是瞬时的。
|
|
30
|
+
- **点击与预热的配合**:点到的轮次已预热 → 直接瞬时跳转;还没预热到 → 头部显示
|
|
31
|
+
「预热中 · 稍等一下」,等预热翻到该轮后自动落地(不打断预热)。
|
|
30
32
|
- 定位成功后精准描边闪烁**那一条用户气泡**(跟随气泡自身圆角),不再整行填色。
|
|
31
33
|
- 自动高亮当前视口所在的轮次。
|
|
32
34
|
- 斜杠命令 `/turns`、快捷键 `⌘⌥R`(Windows/Linux 为 `Ctrl+Alt+R`)展开轮次条。
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
"use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const
|
|
2
|
-
`).map(
|
|
1
|
+
"use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const s=require("solid-js/web"),F=require("@opencode/plugin/gui"),a=require("solid-js");var Je=s.template("<div class=sr-status>"),nt=s.template('<span class="sr-title sr-progress">'),st=s.template("<div class=sr-foot>"),ot=s.template("<div class=sr-rail><div class=sr-head><button type=button class=sr-close>×</button></div><div class=sr-list>"),it=s.template("<span class=sr-title>会话轮次"),at=s.template("<div class=sr-note>"),lt=s.template("<span class=sr-badge>"),ct=s.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 Oe="v20",fe="message-",Ne="session-turns:diag",ge="session-turns:open",dt=40,ut=200,pt=50,ft=30,gt=45e3,ht=2e4,mt=8e3,bt=12e3,ze=220,wt=800;let We=0,he="",me="",be="";const Fe=l=>{const o=String(l?.text??"").split(`
|
|
2
|
+
`).map(f=>f.trim()).find(f=>f.length>0);if(o)return o;const d=l?.files?.length??0;return d>0?`[附件 ×${d}]`:"(空消息)"},He=l=>String(l).padStart(2,"0"),xt=l=>{const o=new Date(l??0);return`${He(o.getHours())}:${He(o.getMinutes())}`},re=l=>new Promise(o=>setTimeout(o,l)),we=()=>document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport')??document.querySelector('[data-slot="session-timeline-scroll"] [data-scrollable]')??(()=>{let o=document.querySelector("[data-message-id]")?.parentElement;for(;o;){const d=getComputedStyle(o);if(/(auto|scroll)/.test(d.overflowY)&&o.scrollHeight>o.clientHeight+1)return o;o=o.parentElement}})(),$=l=>{const o=document.getElementById(`${fe}${l}`);if(o)return o;const d=`[data-message-id="${l.replace(/"/g,'\\"')}"]`;for(const f of document.querySelectorAll(d))if(f.getAttribute("data-timeline-row")==="UserMessage"||f.querySelector('[data-slot="user-message-text"]'))return f},ue=l=>{const o=we(),d=[...document.querySelectorAll("[data-message-id]")],f=d[0]?.getAttribute("data-message-id")??"",C=be?` · 目标 ${$(be)?"在":"不在"} DOM`:"",T=f?` · 行ID ${f.slice(0,20)}`:"";let A="(空)";try{A=l.router.path()||"(空)"}catch{}return`轮次 ${We} · 容器 ${o?"OK":"缺失"} · DOM ${d.length} 行${C}${T} · 路由 ${A} · hash ${window.location.hash||"(空)"}${me?` · ${me}`:""}${he?` · ${he}`:""}`},vt=l=>{const o=l.getBoundingClientRect().top;let d=o;for(const f of l.querySelectorAll(".sticky, [data-session-title]")){if(!(f instanceof HTMLElement)||getComputedStyle(f).position!=="sticky"&&!f.hasAttribute("data-session-title"))continue;const T=f.getBoundingClientRect();T.height!==0&&(T.top>o+4||(d=Math.max(d,T.bottom)))}return d},pe=l=>{try{const o=we();if(!o||!l.isConnected)return;const d=l.querySelector('[data-slot="user-message-text"]')??l,f=vt(o),C=d.getBoundingClientRect().top-(f+18);Math.abs(C)>2&&(o.scrollTop+=C),me=`inset ${Math.round(f)} · delta ${Math.round(C)}`}catch{}},ne=l=>{pe(l),setTimeout(()=>pe(l),450),setTimeout(()=>pe(l),1e3)},Ue=l=>{let o="";try{o=l.router.path()||""}catch{}return o.startsWith("/")?o:""},yt=l=>{const o=document.createElement("a");o.setAttribute("href",l),o.setAttribute("replace",""),o.setAttribute("noscroll",""),o.style.display="none",document.body.appendChild(o);try{o.dispatchEvent(new MouseEvent("click",{bubbles:!0,cancelable:!0,button:0,view:window}))}finally{o.remove()}},kt=`
|
|
3
3
|
.sr-rail {
|
|
4
4
|
position: fixed;
|
|
5
5
|
top: 50%;
|
|
@@ -242,4 +242,4 @@
|
|
|
242
242
|
white-space: pre-wrap;
|
|
243
243
|
max-width: 260px;
|
|
244
244
|
}
|
|
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;
|
|
245
|
+
`;function St(l){const o=F.usePlugin(),[d,f]=a.createSignal([]),[C,T]=a.createSignal(""),[A,M]=a.createSignal(!1),[Ge,Ve]=a.createSignal(""),[j,xe]=a.createSignal(""),[E,H]=a.createSignal(!1),[ve,R]=a.createSignal(0),[ye,q]=a.createSignal(0),[Et,B]=a.createSignal(0),[U,W]=a.createSignal(""),[ke,J]=a.createSignal(!1),_=new Map;let se=0,O=!1,G=!1,V="",N;const h=()=>l.session()?.id,oe=()=>{const e=[..._.values()].sort((r,i)=>r.created-i.created),t=a.untrack(d);t.length===e.length&&t.every((r,i)=>r===e[i])||(f(e),We=e.length)},Se=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})},Ye=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 o.client.message.list(i)},ie=async(e,t)=>{const r=++se;O=!0,t==="full"&&(z="",de="");let i="";try{let n;const c=t==="full"?dt:1,u=t==="full"?ut:pt;let m=t!=="full";for(let x=0;x<c;x++){let v;try{v=await Ye(e,u,n)}catch(g){i=String(g?.message??g);break}if(r!==se)return;m||(_.clear(),m=!0);let b=0;for(const g of v?.data??[])_.has(g.id)||(Se({id:g.id,time:g.time?.created??0,text:Fe(g)}),b++);oe();const p=v?.cursor?.next;if(!p||b===0)break;n=p}}finally{O=!1,V=e,he=i,Ve(_.size===0?i||"暂无用户消息":""),G&&(G=!1,$e()),t==="full"&&!i&&e===a.untrack(h)&&_.size>=15&&setTimeout(()=>ee(),1200)}},$e=()=>{N||(N=setTimeout(()=>{N=void 0;const e=a.untrack(h);e&&ie(e,"light")},300))},Ee=e=>{if(!(!e||e!==a.untrack(h))){if(O){G=!0;return}$e()}},Ke=e=>{const t=e?.type;if(t==="message.updated"){const r=e?.data??{},i=r.info;i?.role==="user"&&i?.id&&(Se({id:i.id,time:i.time?.created??0,text:Fe(i)}),oe()),Ee(r.sessionID)}else if(t==="message.removed"){const r=e?.data??{};r.messageID&&_.delete(r.messageID)&&oe(),Ee(r.sessionID)}else if(t==="location.shutdown"){const r=a.untrack(h);r&&V===r&&ie(r,"full")}};a.onMount(()=>{let e=!1,t=0,r;(async()=>{for(;!e;){const c=o.client.event.subscribe()[Symbol.asyncIterator]();try{for(;!e;){const u=await c.next();if(u.done)break;t=0,Ke(u.value)}}catch{}try{await c.return?.()}catch{}if(e)break;t++,await re(Math.min(2e3*t,15e3))}})(),a.onCleanup(()=>{e=!0,clearTimeout(r),N&&clearTimeout(N)})}),a.createEffect(()=>{const e=h();e&&a.untrack(()=>{V!==e&&(se++,O=!1,G=!1,_.clear(),f([]),T(""),le="",ie(e,"full"))})});let Y;const I=()=>(Y?.isConnected||(Y=we()),Y),Ze=()=>{const e=I();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&&T(n);break}};let ae=!1;const _e=()=>{ae||(ae=!0,requestAnimationFrame(()=>{ae=!1,Ze()}))};a.onMount(()=>{const e=()=>_e();document.addEventListener("scroll",e,!0);const t=setInterval(_e,1500);a.onCleanup(()=>{document.removeEventListener("scroll",e,!0),clearInterval(t)})});let Ce=0;const K=(e,t=2600)=>{const r=++Ce;xe(e),setTimeout(()=>{r===Ce&&xe("")},t)};a.onMount(()=>{const e=r=>{if(r.key==="Escape"){if(a.untrack(E)){Le(),r.stopPropagation();return}a.untrack(A)&&(M(!1),r.stopPropagation())}},t=r=>{if(!a.untrack(A))return;(r.composedPath?.()??[]).some(c=>c instanceof Element&&c.closest?.(".sr-rail"))||M(!1)};window.addEventListener("keydown",e,!0),document.addEventListener("pointerdown",t,!0),a.onCleanup(()=>{window.removeEventListener("keydown",e,!0),document.removeEventListener("pointerdown",t,!0)})}),a.onMount(()=>{const e=()=>K(`${Oe} · ${ue(o)}`),t=()=>{M(r=>!r),ee()};window.addEventListener(Ne,e),window.addEventListener(ge,t),a.onCleanup(()=>{window.removeEventListener(Ne,e),window.removeEventListener(ge,t)})});let le="",S=0,Te=0,Ae="";const Z=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{}},Me=()=>{for(const e of d())if($(e.id))return e},L=(e,t)=>{yt(`${e}#${fe}${t}`),le=t},X=()=>{const t=I()?.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}`},Ie=(e,t,r,i)=>new Promise(n=>{let c=!1,u,m,x;const v=Date.now();let p=X(),g=v,w=0;const y=te=>{if(!c){c=!0;try{u?.disconnect()}catch{}clearInterval(m),clearTimeout(x),n(te??null)}},k=te=>{if(t!==S)return y(null);const Re=te?$(e):document.getElementById(`${fe}${e}`);if(Re)return y(Re);if(!te)return;const qe=X(),Be=Date.now();if(qe!==p){p=qe,g=Be,w++,i?.(w);return}const rt=w>0?mt:bt;Be-g>rt&&y(null)},je=$(e);if(je)return y(je);u=new MutationObserver(()=>k(!1)),u.observe(document.documentElement,{childList:!0,subtree:!0}),m=setInterval(()=>k(!0),120),x=setTimeout(()=>y($(e)),Math.max(400,r))}),Le=()=>{S++,H(!1),R(0),q(0),B(0),W(""),J(!1),K("已取消跳转")},Xe=async e=>{if(E()&&U()===e.id)return;const t=Ue(o),r=I(),i=$(e.id);if(i&&r){D()&&(Q(),t&&L(t,e.id)),ne(i),Z(i);return}const n=Date.now();if(e.id===Ae&&n-Te<wt){const b=$(e.id);if(b&&r){ne(b),Z(b);return}}if(Te=n,Ae=e.id,be=e.id,!t){K(`无法读取当前路由,无法定位 · ${ue(o)}`);return}const c=++S,u=h();H(!0),W(e.id),R(0),q(0),B(0),J(D()),M(!0);const m=Date.now(),x=setInterval(()=>{q(Math.round((Date.now()-m)/1e3))},500);let v=0;try{if(D()){const p=await Ie(e.id,c,2e4,g=>R(g));if(c!==S||h()!==u)return;if(p){Q(),L(t,e.id),ne(p),Z(p);return}if(D()&&Q(),J(!1),c!==S||h()!==u)return}const b=Date.now()+gt;for(let p=0;p<ft&&Date.now()<b;p++){if(c!==S||h()!==u)return;if(B(p+1),le===e.id){const k=Me()??d()[0];k&&(L(t,k.id),await re(ze))}if(c!==S||h()!==u)return;L(t,e.id);let g=0;const w=Math.min(ht,b-Date.now()),y=await Ie(e.id,c,w,k=>{g=k,R(v+k)});if(v+=g,c!==S||h()!==u)return;if(y){ne(y),Z(y);return}}c===S&&K(`未能定位 · ${ue(o)}`)}finally{clearInterval(x),c===S&&(H(!1),R(0),q(0),B(0),W(""),J(!1))}},[D,ce]=a.createSignal(!1);let P=0,z="",de="";const Qe=()=>{const e=I();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}}},De=e=>{if(!e)return;const t=I();if(!t)return;const r=e.el?.isConnected?e.el:$(e.id);if(!r)return;const i=t.getBoundingClientRect(),n=r.getBoundingClientRect().top-i.top-e.offset;Math.abs(n)>2&&(t.scrollTop+=n)},Q=()=>{P++,ce(!1)},ee=()=>{const e=h();!e||D()||z!==e&&(d().length<15||et())},et=async()=>{if(D())return;const e=Ue(o),t=d()[0],r=h();if(!e||!t||!r||z===r&&$(t.id))return;const i=++P,n=Qe(),c=I(),u=c?c.scrollTop+c.clientHeight>=c.scrollHeight-32:!0;ce(!0);try{L(e,t.id);const m=Date.now()+6e4;let x=X(),v=Date.now(),b=0;const p=setInterval(()=>De(n),150);try{for(;Date.now()<m&&i===P&&!($(t.id)||(await re(250),i!==P));){const w=Date.now(),y=X();if(y!==x)x=y,v=w,b=0;else if(w-v>5e3){if(b>=2)break;const k=Me();if(k&&k.id!==t.id&&(L(e,k.id),await re(ze),i!==P))break;L(e,t.id),v=Date.now(),b++}}}finally{clearInterval(p)}if(i!==P)return;const g=!!$(t.id);if(De(n),u){const w=I();w&&(w.scrollTop=w.scrollHeight)}g?z=r:de=r}finally{i===P&&ce(!1)}};let Pe=a.untrack(h);a.createEffect(()=>{const e=h();e!==Pe&&(Pe=e,Q(),S++,H(!1),R(0),q(0),B(0),W(""),J(!1))}),a.createEffect(()=>{const e=h(),t=d().length,r=D();if(!e||t<15||r||z===e||de===e||O||V!==e)return;const i=setTimeout(()=>ee(),1200);a.onCleanup(()=>clearTimeout(i))}),a.createEffect(()=>{E()&&U()&&requestAnimationFrame(()=>{try{document.querySelector('.sr-list .sr-turn[data-busy="true"]')?.scrollIntoView({block:"nearest"})}catch{}})});const tt=[s.createComponent(a.Show,{get when(){return j()},get children(){var e=Je();return s.insert(e,j),e}}),(()=>{var e=ot(),t=e.firstChild,r=t.firstChild,i=t.nextSibling;return e.addEventListener("mouseleave",()=>{E()||M(!1)}),e.addEventListener("mouseenter",()=>{M(!0),ee()}),s.insert(t,s.createComponent(a.Show,{get when(){return E()},get fallback(){return(()=>{var n=it();return n.firstChild,s.insert(n,(()=>{var c=s.memo(()=>d().length>0);return()=>c()?` · ${d().length}`:""})(),null),n})()},get children(){var n=nt();return s.insert(n,()=>ke()?"预热中 · 稍等一下":"加载历史中",null),s.insert(n,(()=>{var c=s.memo(()=>ve()>0);return()=>c()?` · ${ve()} 页`:""})(),null),s.insert(n,(()=>{var c=s.memo(()=>ye()>0);return()=>c()?` · ${ye()}s`:""})(),null),n}}),r),r.$$click=()=>{E()?Le():M(!1)},s.insert(i,s.createComponent(a.Show,{get when(){return d().length>0},get fallback(){return(()=>{var n=at();return s.insert(n,()=>Ge()||`… ${Oe}`),n})()},get children(){return s.createComponent(a.For,{get each(){return d()},children:(n,c)=>(()=>{var u=ct(),m=u.firstChild,x=m.firstChild;x.firstChild;var v=x.nextSibling,b=m.nextSibling;return u.$$click=()=>{Xe(n)},s.insert(x,()=>c()+1,null),s.insert(v,()=>xt(n.created)),s.insert(m,()=>n.text,null),s.insert(u,s.createComponent(a.Show,{get when(){return s.memo(()=>!!E())()&&U()===n.id},get children(){var p=lt();return s.insert(p,()=>ke()?"稍等":"加载中"),p}}),b),s.effect(p=>{var g=C()===n.id?"true":"false",w=E()&&U()===n.id?"true":"false";return g!==p.e&&s.setAttribute(u,"data-active",p.e=g),w!==p.t&&s.setAttribute(u,"data-busy",p.t=w),p},{e:void 0,t:void 0}),u})()})}})),s.insert(e,s.createComponent(a.Show,{get when(){return s.memo(()=>!!A())()&&j()},get children(){var n=st();return s.insert(n,j),n}}),null),s.effect(n=>{var c=A()?"true":"false",u=E()?"true":"false",m=E()?"取消跳转 (Esc)":"关闭 (Esc)";return c!==n.e&&s.setAttribute(e,"data-open",n.e=c),u!==n.t&&s.setAttribute(e,"data-jumping",n.t=u),m!==n.a&&s.setAttribute(r,"title",n.a=m),n},{e:void 0,t:void 0,a:void 0}),e})(),s.createComponent(a.Show,{get when(){return s.memo(()=>!A())()&&j()},get children(){var e=Je();return s.insert(e,j),e}})];return s.createComponent(s.Portal,{get mount(){return document.body},children:tt})}const $t=F.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(l){l.add(F.Style,kt),l.add(F.Slot,{at:"window.bottom",render:()=>s.createComponent(a.Show,{get when(){return l.sessions.current()},get children(){return s.createComponent(St,{session:()=>l.sessions.current()})}})}),l.add(F.Command,()=>({id:"jump",title:l.t("command.jump"),description:l.t("command.jump.description"),group:l.t("command.jump"),section:"navigation",bind:"mod+alt+r",slash:{name:"turns"},run:()=>{window.dispatchEvent(new Event(ge))}}))}});s.delegateEvents(["click"]);exports.default=$t;
|
package/package.json
CHANGED
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 = "
|
|
9
|
+
const VERSION = "v20"
|
|
10
10
|
const HASH_PREFIX = "message-"
|
|
11
11
|
const DIAG_EVENT = "session-turns:diag"
|
|
12
12
|
const OPEN_EVENT = "session-turns:open"
|
|
@@ -455,6 +455,9 @@ function Rail(props) {
|
|
|
455
455
|
const [jumpElapsed, setJumpElapsed] = createSignal(0)
|
|
456
456
|
const [jumpChain, setJumpChain] = createSignal(0)
|
|
457
457
|
const [busyID, setBusyID] = createSignal("")
|
|
458
|
+
// True while a click is waiting for the background prewarm to page its target
|
|
459
|
+
// in (header reads "预热中 · 稍等一下" instead of "加载历史中").
|
|
460
|
+
const [waitWarm, setWaitWarm] = createSignal(false)
|
|
458
461
|
|
|
459
462
|
// ---- turn cache ---------------------------------------------------------
|
|
460
463
|
// Object identity is kept stable across refreshes so Solid's <For> reuses
|
|
@@ -497,6 +500,12 @@ function Rail(props) {
|
|
|
497
500
|
const load = async (id, mode) => {
|
|
498
501
|
const token = ++loadToken
|
|
499
502
|
loadingActive = true
|
|
503
|
+
// A full (re)load means the host store may be cold again (session open or
|
|
504
|
+
// server rebuild): invalidate the prewarm marker so it runs once more.
|
|
505
|
+
if (mode === "full") {
|
|
506
|
+
warmedFor = ""
|
|
507
|
+
warmBlockedFor = ""
|
|
508
|
+
}
|
|
500
509
|
let error = ""
|
|
501
510
|
try {
|
|
502
511
|
let cursor
|
|
@@ -543,6 +552,11 @@ function Rail(props) {
|
|
|
543
552
|
pendingBump = false
|
|
544
553
|
scheduleLight()
|
|
545
554
|
}
|
|
555
|
+
// Prewarm long sessions as soon as their full turn list has loaded, so
|
|
556
|
+
// the history is warm before the first click (not only on rail hover).
|
|
557
|
+
if (mode === "full" && !error && id === untrack(sessionID) && cache.size >= 15) {
|
|
558
|
+
setTimeout(() => maybePrewarm(), 1200)
|
|
559
|
+
}
|
|
546
560
|
}
|
|
547
561
|
}
|
|
548
562
|
|
|
@@ -870,36 +884,40 @@ function Rail(props) {
|
|
|
870
884
|
setJumpElapsed(0)
|
|
871
885
|
setJumpChain(0)
|
|
872
886
|
setBusyID("")
|
|
887
|
+
setWaitWarm(false)
|
|
873
888
|
flash("已取消跳转")
|
|
874
889
|
}
|
|
875
890
|
|
|
876
891
|
const jump = async (turn) => {
|
|
877
|
-
// A jump takes priority over a background prewarm.
|
|
878
|
-
if (warming()) cancelWarm()
|
|
879
|
-
|
|
880
892
|
// Clicking the same turn again while its jump is running must NOT restart
|
|
881
893
|
// the loop: every restart cancels the host's in-flight page load, which is
|
|
882
894
|
// what makes a deep jump advance only one page per click. The header
|
|
883
895
|
// already shows the progress, so just leave it running.
|
|
884
896
|
if (jumping() && busyID() === turn.id) return
|
|
885
897
|
|
|
886
|
-
const
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
898
|
+
const path = routePath(ctx)
|
|
899
|
+
const sc = scroller()
|
|
900
|
+
const mounted = findRow(turn.id)
|
|
901
|
+
|
|
902
|
+
// Fast path: the row is already mounted (e.g. the prewarm reached it), so
|
|
903
|
+
// the jump is instant. If a prewarm is still running, re-target the host's
|
|
904
|
+
// pending oldest-seek to this turn first - otherwise the host would scroll
|
|
905
|
+
// to the oldest message when that seek completes and yank the viewport.
|
|
906
|
+
if (mounted && sc) {
|
|
907
|
+
if (warming()) {
|
|
908
|
+
cancelWarm()
|
|
909
|
+
if (path) navTo(path, turn.id)
|
|
910
|
+
}
|
|
911
|
+
alignAfterLand(mounted)
|
|
912
|
+
pulse(mounted)
|
|
913
|
+
return
|
|
914
|
+
}
|
|
897
915
|
|
|
916
|
+
const now = Date.now()
|
|
898
917
|
// Same target clicked again shortly after: treat as idempotent re-scroll
|
|
899
918
|
// instead of restarting the paging loop.
|
|
900
919
|
if (turn.id === lastJumpTurnID && now - lastJumpAt < SAME_TARGET_WINDOW_MS) {
|
|
901
920
|
const row = findRow(turn.id)
|
|
902
|
-
const sc = scroller()
|
|
903
921
|
if (row && sc) {
|
|
904
922
|
alignAfterLand(row)
|
|
905
923
|
pulse(row)
|
|
@@ -908,45 +926,23 @@ function Rail(props) {
|
|
|
908
926
|
}
|
|
909
927
|
lastJumpAt = now
|
|
910
928
|
lastJumpTurnID = turn.id
|
|
929
|
+
lastJumpID = turn.id
|
|
911
930
|
|
|
912
|
-
const sc = scroller()
|
|
913
|
-
const row = findRow(turn.id)
|
|
914
|
-
|
|
915
|
-
// Fast path: the row is mounted, scroll locally. No toast: the bubble
|
|
916
|
-
// ring is the feedback.
|
|
917
|
-
if (row && sc) {
|
|
918
|
-
alignAfterLand(row)
|
|
919
|
-
pulse(row)
|
|
920
|
-
return
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
const path = routePath(ctx)
|
|
924
931
|
if (!path) {
|
|
925
932
|
flash(`无法读取当前路由,无法定位 · ${diagnostics(ctx)}`)
|
|
926
933
|
return
|
|
927
934
|
}
|
|
928
935
|
|
|
929
|
-
|
|
930
|
-
//
|
|
931
|
-
// The host's own seek machine does the heavy lifting: hash.changed ->
|
|
932
|
-
// applying -> pending(target) -> target.synced -> seek, and it loads the
|
|
933
|
-
// missing history asynchronously (up to 12 internal retries). Every extra
|
|
934
|
-
// hash change we send RESETS that pending state and aborts the history
|
|
935
|
-
// fetch in flight, which is why hammering the hash made a deep jump
|
|
936
|
-
// advance a single page at a time.
|
|
937
|
-
//
|
|
938
|
-
// So: set the hash once, then be patient. Only when the host has been
|
|
939
|
-
// completely idle for JUMP_IDLE_MS do we bounce the hash through a
|
|
940
|
-
// mounted turn to kick off a fresh chain.
|
|
936
|
+
const token = ++jumpToken
|
|
941
937
|
const sessionAtJump = sessionID()
|
|
942
938
|
setJumping(true)
|
|
943
939
|
setBusyID(turn.id)
|
|
944
940
|
setJumpPages(0)
|
|
945
941
|
setJumpElapsed(0)
|
|
942
|
+
setJumpChain(0)
|
|
943
|
+
setWaitWarm(warming())
|
|
946
944
|
// Pin the panel open even if the pointer has left it meanwhile.
|
|
947
945
|
setOpen(true)
|
|
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.
|
|
950
946
|
const startedAt = Date.now()
|
|
951
947
|
const elapsedTimer = setInterval(() => {
|
|
952
948
|
setJumpElapsed(Math.round((Date.now() - startedAt) / 1000))
|
|
@@ -955,6 +951,34 @@ function Rail(props) {
|
|
|
955
951
|
// the per-chain counter, the headline must not go backwards).
|
|
956
952
|
let chainBase = 0
|
|
957
953
|
try {
|
|
954
|
+
// A prewarm is already paging the history (it seeks the oldest turn, so
|
|
955
|
+
// it passes through this one on the way). Don't fight it with a competing
|
|
956
|
+
// seek: just wait for the row to mount, then land. The header reads
|
|
957
|
+
// "预热中 · 稍等一下" while we wait.
|
|
958
|
+
if (warming()) {
|
|
959
|
+
const landed = await waitForRow(turn.id, token, 20000, (pages) => setJumpPages(pages))
|
|
960
|
+
if (token !== jumpToken || sessionID() !== sessionAtJump) return
|
|
961
|
+
if (landed) {
|
|
962
|
+
cancelWarm()
|
|
963
|
+
navTo(path, turn.id)
|
|
964
|
+
alignAfterLand(landed)
|
|
965
|
+
pulse(landed)
|
|
966
|
+
return
|
|
967
|
+
}
|
|
968
|
+
// The prewarm ran out without mounting the row: fall back to driving
|
|
969
|
+
// the host's seek ourselves below.
|
|
970
|
+
if (warming()) cancelWarm()
|
|
971
|
+
setWaitWarm(false)
|
|
972
|
+
if (token !== jumpToken || sessionID() !== sessionAtJump) return
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// Deep history, no prewarm. The host's own seek machine does the heavy
|
|
976
|
+
// lifting: hash.changed -> applying -> pending(target) -> target.synced
|
|
977
|
+
// -> seek, loading the missing history asynchronously. Every extra hash
|
|
978
|
+
// change RESETS that pending state and aborts the fetch in flight, which
|
|
979
|
+
// is why hammering the hash made a deep jump advance one page per click.
|
|
980
|
+
// So: set the hash once, then be patient; only bounce it through a
|
|
981
|
+
// mounted turn when the host has been idle for JUMP_IDLE_MS.
|
|
958
982
|
const deadline = Date.now() + JUMP_TOTAL_MS
|
|
959
983
|
for (let chain = 0; chain < JUMP_ROUNDS && Date.now() < deadline; chain++) {
|
|
960
984
|
// Abandon the loop if the user switched sessions mid-jump: toggling the
|
|
@@ -1005,6 +1029,7 @@ function Rail(props) {
|
|
|
1005
1029
|
setJumpElapsed(0)
|
|
1006
1030
|
setJumpChain(0)
|
|
1007
1031
|
setBusyID("")
|
|
1032
|
+
setWaitWarm(false)
|
|
1008
1033
|
}
|
|
1009
1034
|
}
|
|
1010
1035
|
}
|
|
@@ -1019,6 +1044,10 @@ function Rail(props) {
|
|
|
1019
1044
|
const [warming, setWarming] = createSignal(false)
|
|
1020
1045
|
let warmToken = 0
|
|
1021
1046
|
let warmedFor = ""
|
|
1047
|
+
// Set when a prewarm gave up without reaching the oldest turn (the host never
|
|
1048
|
+
// engaged). The auto-resume effect must not retry it in a loop; an explicit
|
|
1049
|
+
// hover or a fresh full load still may.
|
|
1050
|
+
let warmBlockedFor = ""
|
|
1022
1051
|
|
|
1023
1052
|
// The topmost visible row plus its offset, so the viewport can be restored.
|
|
1024
1053
|
const viewAnchor = () => {
|
|
@@ -1078,30 +1107,100 @@ function Rail(props) {
|
|
|
1078
1107
|
setWarming(true)
|
|
1079
1108
|
try {
|
|
1080
1109
|
navTo(path, oldest.id)
|
|
1081
|
-
|
|
1110
|
+
// The host's seek machine starts inactive and DROPS a seek issued before
|
|
1111
|
+
// the timeline is ready - exactly the situation on session open. So don't
|
|
1112
|
+
// wait blindly for the oldest row: watch for real paging progress, and if
|
|
1113
|
+
// the host goes silent, bounce the hash to re-engage it once it is ready.
|
|
1114
|
+
// If it never engages, bail fast (without marking warm) so clicks fall
|
|
1115
|
+
// back to a direct jump instead of all showing "预热中 · 稍等一下" for 2min.
|
|
1116
|
+
const deadline = Date.now() + 60000
|
|
1117
|
+
let lastSig = rowSignature()
|
|
1118
|
+
let lastChange = Date.now()
|
|
1119
|
+
let kicks = 0
|
|
1082
1120
|
const keep = setInterval(() => restoreView(anchor), 150)
|
|
1083
1121
|
try {
|
|
1084
1122
|
while (Date.now() < deadline && token === warmToken) {
|
|
1085
1123
|
if (findRow(oldest.id)) break
|
|
1086
1124
|
await sleep(250)
|
|
1125
|
+
if (token !== warmToken) break
|
|
1126
|
+
const now = Date.now()
|
|
1127
|
+
const sig = rowSignature()
|
|
1128
|
+
if (sig !== lastSig) {
|
|
1129
|
+
// Real progress: the host is paging. Keep waiting, reset the kick budget.
|
|
1130
|
+
lastSig = sig
|
|
1131
|
+
lastChange = now
|
|
1132
|
+
kicks = 0
|
|
1133
|
+
} else if (now - lastChange > 5000) {
|
|
1134
|
+
if (kicks >= 2) break // host never engaged: give up, stay un-warm
|
|
1135
|
+
// Re-engage: a repeated identical hash is ignored by the router, so
|
|
1136
|
+
// bounce through a mounted turn first, then back to the oldest.
|
|
1137
|
+
const other = oldestLoadedTurn()
|
|
1138
|
+
if (other && other.id !== oldest.id) {
|
|
1139
|
+
navTo(path, other.id)
|
|
1140
|
+
await sleep(JUMP_TOGGLE_DWELL_MS)
|
|
1141
|
+
if (token !== warmToken) break
|
|
1142
|
+
}
|
|
1143
|
+
navTo(path, oldest.id)
|
|
1144
|
+
lastChange = Date.now()
|
|
1145
|
+
kicks++
|
|
1146
|
+
}
|
|
1087
1147
|
}
|
|
1088
1148
|
} finally {
|
|
1089
1149
|
clearInterval(keep)
|
|
1090
1150
|
}
|
|
1091
1151
|
if (token !== warmToken) return
|
|
1152
|
+
const done = !!findRow(oldest.id)
|
|
1092
1153
|
// The host scrolled to the oldest message; put the viewport back.
|
|
1093
1154
|
restoreView(anchor)
|
|
1094
1155
|
if (wasAtBottom) {
|
|
1095
1156
|
const live = scroller()
|
|
1096
1157
|
if (live) live.scrollTop = live.scrollHeight
|
|
1097
1158
|
}
|
|
1098
|
-
|
|
1159
|
+
if (done) {
|
|
1160
|
+
warmedFor = sid
|
|
1161
|
+
} else {
|
|
1162
|
+
// Never claim the history is ready when the oldest turn never mounted.
|
|
1163
|
+
warmBlockedFor = sid
|
|
1164
|
+
}
|
|
1099
1165
|
// Silent on success: prewarming is an implementation detail.
|
|
1100
1166
|
} finally {
|
|
1101
1167
|
if (token === warmToken) setWarming(false)
|
|
1102
1168
|
}
|
|
1103
1169
|
}
|
|
1104
1170
|
|
|
1171
|
+
// Session switch: abort any jump/prewarm in flight - they target the old
|
|
1172
|
+
// route, and toggling its hash would navigate back to the previous session.
|
|
1173
|
+
let guardSid = untrack(sessionID)
|
|
1174
|
+
createEffect(() => {
|
|
1175
|
+
const sid = sessionID()
|
|
1176
|
+
if (sid === guardSid) return
|
|
1177
|
+
guardSid = sid
|
|
1178
|
+
cancelWarm()
|
|
1179
|
+
jumpToken++
|
|
1180
|
+
setJumping(false)
|
|
1181
|
+
setJumpPages(0)
|
|
1182
|
+
setJumpElapsed(0)
|
|
1183
|
+
setJumpChain(0)
|
|
1184
|
+
setBusyID("")
|
|
1185
|
+
setWaitWarm(false)
|
|
1186
|
+
})
|
|
1187
|
+
|
|
1188
|
+
// Resume the prewarm after a jump interrupted it, so the remaining older
|
|
1189
|
+
// history still gets warmed for the next click. The initial start happens in
|
|
1190
|
+
// load()'s finally (once the full turn list is in); this only fires when the
|
|
1191
|
+
// warming signal flips back to false while the session is not fully warm.
|
|
1192
|
+
// A prewarm that gave up (warmBlockedFor) is NOT retried here, otherwise a
|
|
1193
|
+
// host that never engages would loop forever and keep every click on "稍等".
|
|
1194
|
+
createEffect(() => {
|
|
1195
|
+
const sid = sessionID()
|
|
1196
|
+
const count = turns().length
|
|
1197
|
+
const hot = warming()
|
|
1198
|
+
if (!sid || count < 15 || hot || warmedFor === sid || warmBlockedFor === sid) return
|
|
1199
|
+
if (loadingActive || loadedFor !== sid) return
|
|
1200
|
+
const timer = setTimeout(() => maybePrewarm(), 1200)
|
|
1201
|
+
onCleanup(() => clearTimeout(timer))
|
|
1202
|
+
})
|
|
1203
|
+
|
|
1105
1204
|
// Keep the busy row visible in the panel list while the jump pages through
|
|
1106
1205
|
// history, so the "which turn am I waiting for" answer stays on screen.
|
|
1107
1206
|
createEffect(() => {
|
|
@@ -1144,7 +1243,8 @@ function Rail(props) {
|
|
|
1144
1243
|
}
|
|
1145
1244
|
>
|
|
1146
1245
|
<span class="sr-title sr-progress">
|
|
1147
|
-
|
|
1246
|
+
{waitWarm() ? "预热中 · 稍等一下" : "加载历史中"}
|
|
1247
|
+
{jumpPages() > 0 ? ` · ${jumpPages()} 页` : ""}
|
|
1148
1248
|
{jumpElapsed() > 0 ? ` · ${jumpElapsed()}s` : ""}
|
|
1149
1249
|
</span>
|
|
1150
1250
|
</Show>
|
|
@@ -1177,7 +1277,7 @@ function Rail(props) {
|
|
|
1177
1277
|
{turn.text}
|
|
1178
1278
|
</span>
|
|
1179
1279
|
<Show when={jumping() && busyID() === turn.id}>
|
|
1180
|
-
<span class="sr-badge"
|
|
1280
|
+
<span class="sr-badge">{waitWarm() ? "稍等" : "加载中"}</span>
|
|
1181
1281
|
</Show>
|
|
1182
1282
|
<span class="sr-dash" />
|
|
1183
1283
|
</button>
|