opencode-session-turns 0.1.10 → 0.1.12
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 +4 -1
- package/dist/gui-experimental.cjs +25 -15
- package/package.json +1 -1
- package/src/gui.tsx +155 -18
package/README.md
CHANGED
|
@@ -24,7 +24,10 @@ click a turn to jump straight to that message.
|
|
|
24
24
|
- 点击任意一轮,**跳转到对应消息**——包括很久以前、尚未渲染的历史轮次。
|
|
25
25
|
- 深历史需要逐页加载时,头部实时显示「已加载 N 页 / 耗时 Xs」,目标轮次琥珀色脉冲,
|
|
26
26
|
底部状态条同步提示;按 `Esc` 或点 × 可随时取消。
|
|
27
|
-
-
|
|
27
|
+
- **历史预热**:宿主自己加载历史每页之间有 2 秒硬冷却,深历史跳转因此要等好几秒。
|
|
28
|
+
轮次条会在你打开面板时(悬停或 `/turns`)后台把历史预热完并还原视口,
|
|
29
|
+
之后所有跳转都是瞬时的;也可以点面板上的「预热」手动触发。
|
|
30
|
+
- 定位成功后精准描边闪烁**那一条用户气泡**(跟随气泡自身圆角),不再整行填色。
|
|
28
31
|
- 自动高亮当前视口所在的轮次。
|
|
29
32
|
- 斜杠命令 `/turns`、快捷键 `⌘⌥R`(Windows/Linux 为 `Ctrl+Alt+R`)展开轮次条。
|
|
30
33
|
|
|
@@ -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 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=`
|
|
3
3
|
.sr-rail {
|
|
4
4
|
position: fixed;
|
|
5
5
|
top: 50%;
|
|
@@ -60,6 +60,20 @@
|
|
|
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)); }
|
|
63
77
|
/* the scrollable list; the header stays fixed above it */
|
|
64
78
|
.sr-list {
|
|
65
79
|
flex: 1;
|
|
@@ -176,24 +190,20 @@
|
|
|
176
190
|
0%, 100% { opacity: .35; }
|
|
177
191
|
50% { opacity: 1; }
|
|
178
192
|
}
|
|
179
|
-
/* pulse a landed bubble:
|
|
180
|
-
|
|
193
|
+
/* pulse a landed bubble: an INSET ring, so it follows the bubble's own
|
|
194
|
+
border-radius and can never be clipped by an ancestor that clips overflow
|
|
195
|
+
(the timeline rows do) - an outline/box-shadow drawn outside the bubble
|
|
196
|
+
loses its top edge exactly where the row clips. */
|
|
181
197
|
.sr-flash-ring { animation: srRing 1.8s ease-out 1; }
|
|
182
198
|
@keyframes srRing {
|
|
183
199
|
0% {
|
|
184
|
-
|
|
185
|
-
outline-offset: 2px;
|
|
186
|
-
box-shadow: 0 0 24px rgba(255,170,60,.45);
|
|
200
|
+
box-shadow: inset 0 0 0 2px rgba(255,170,60,.95), inset 0 0 26px rgba(255,170,60,.30);
|
|
187
201
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
outline-offset: 2px;
|
|
191
|
-
box-shadow: 0 0 12px rgba(255,170,60,.15);
|
|
202
|
+
55% {
|
|
203
|
+
box-shadow: inset 0 0 0 2px rgba(255,170,60,.40), inset 0 0 14px rgba(255,170,60,.12);
|
|
192
204
|
}
|
|
193
205
|
100% {
|
|
194
|
-
|
|
195
|
-
outline-offset: 2px;
|
|
196
|
-
box-shadow: 0 0 0 rgba(255,170,60,0);
|
|
206
|
+
box-shadow: inset 0 0 0 2px rgba(255,170,60,0), inset 0 0 0 rgba(255,170,60,0);
|
|
197
207
|
}
|
|
198
208
|
}
|
|
199
209
|
.sr-note {
|
|
@@ -218,4 +228,4 @@
|
|
|
218
228
|
white-space: pre-wrap;
|
|
219
229
|
max-width: min(720px, 90vw);
|
|
220
230
|
}
|
|
221
|
-
`;function
|
|
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;
|
package/package.json
CHANGED
package/src/gui.tsx
CHANGED
|
@@ -6,10 +6,11 @@ import { Portal } from "solid-js/web"
|
|
|
6
6
|
// constants / helpers
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
|
|
9
|
-
const VERSION = "
|
|
9
|
+
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"
|
|
13
14
|
|
|
14
15
|
// paging: a full load walks every page; a light refresh pulls just the newest
|
|
15
16
|
// page (new messages always land at the head of a desc listing).
|
|
@@ -243,6 +244,20 @@ const CSS = `
|
|
|
243
244
|
flex-shrink: 0;
|
|
244
245
|
}
|
|
245
246
|
.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)); }
|
|
246
261
|
/* the scrollable list; the header stays fixed above it */
|
|
247
262
|
.sr-list {
|
|
248
263
|
flex: 1;
|
|
@@ -359,24 +374,20 @@ const CSS = `
|
|
|
359
374
|
0%, 100% { opacity: .35; }
|
|
360
375
|
50% { opacity: 1; }
|
|
361
376
|
}
|
|
362
|
-
/* pulse a landed bubble:
|
|
363
|
-
|
|
377
|
+
/* pulse a landed bubble: an INSET ring, so it follows the bubble's own
|
|
378
|
+
border-radius and can never be clipped by an ancestor that clips overflow
|
|
379
|
+
(the timeline rows do) - an outline/box-shadow drawn outside the bubble
|
|
380
|
+
loses its top edge exactly where the row clips. */
|
|
364
381
|
.sr-flash-ring { animation: srRing 1.8s ease-out 1; }
|
|
365
382
|
@keyframes srRing {
|
|
366
383
|
0% {
|
|
367
|
-
|
|
368
|
-
outline-offset: 2px;
|
|
369
|
-
box-shadow: 0 0 24px rgba(255,170,60,.45);
|
|
384
|
+
box-shadow: inset 0 0 0 2px rgba(255,170,60,.95), inset 0 0 26px rgba(255,170,60,.30);
|
|
370
385
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
outline-offset: 2px;
|
|
374
|
-
box-shadow: 0 0 12px rgba(255,170,60,.15);
|
|
386
|
+
55% {
|
|
387
|
+
box-shadow: inset 0 0 0 2px rgba(255,170,60,.40), inset 0 0 14px rgba(255,170,60,.12);
|
|
375
388
|
}
|
|
376
389
|
100% {
|
|
377
|
-
|
|
378
|
-
outline-offset: 2px;
|
|
379
|
-
box-shadow: 0 0 0 rgba(255,170,60,0);
|
|
390
|
+
box-shadow: inset 0 0 0 2px rgba(255,170,60,0), inset 0 0 0 rgba(255,170,60,0);
|
|
380
391
|
}
|
|
381
392
|
}
|
|
382
393
|
.sr-note {
|
|
@@ -707,12 +718,18 @@ function Rail(props) {
|
|
|
707
718
|
const onDiag = () => flash(`${VERSION} · ${diagnostics(ctx)}`)
|
|
708
719
|
// /turns toggles the panel; Esc / outside click / × also close it. No more
|
|
709
720
|
// forced auto-close after 4s.
|
|
710
|
-
const onOpen = () =>
|
|
721
|
+
const onOpen = () => {
|
|
722
|
+
setOpen((value) => !value)
|
|
723
|
+
maybePrewarm()
|
|
724
|
+
}
|
|
725
|
+
const onWarm = () => void prewarm()
|
|
711
726
|
window.addEventListener(DIAG_EVENT, onDiag)
|
|
712
727
|
window.addEventListener(OPEN_EVENT, onOpen)
|
|
728
|
+
window.addEventListener(WARM_EVENT, onWarm)
|
|
713
729
|
onCleanup(() => {
|
|
714
730
|
window.removeEventListener(DIAG_EVENT, onDiag)
|
|
715
731
|
window.removeEventListener(OPEN_EVENT, onOpen)
|
|
732
|
+
window.removeEventListener(WARM_EVENT, onWarm)
|
|
716
733
|
})
|
|
717
734
|
// One-shot confirmation that this window actually runs the rebuilt bundle.
|
|
718
735
|
flash(`会话轮次 ${VERSION} 已加载`)
|
|
@@ -838,6 +855,9 @@ function Rail(props) {
|
|
|
838
855
|
}
|
|
839
856
|
|
|
840
857
|
const jump = async (turn) => {
|
|
858
|
+
// A jump takes priority over a background prewarm.
|
|
859
|
+
if (warming()) cancelWarm()
|
|
860
|
+
|
|
841
861
|
// Clicking the same turn again while its jump is running must NOT restart
|
|
842
862
|
// the loop: every restart cancels the host's in-flight page load, which is
|
|
843
863
|
// what makes a deep jump advance only one page per click. Re-affirm the
|
|
@@ -974,6 +994,111 @@ function Rail(props) {
|
|
|
974
994
|
}
|
|
975
995
|
}
|
|
976
996
|
|
|
997
|
+
// ---- prewarm ------------------------------------------------------------
|
|
998
|
+
// The host loads history one page at a time with a hard 2s cooldown between
|
|
999
|
+
// pages (its own paging machine: AS=2e3), which is why a deep jump waits
|
|
1000
|
+
// several seconds. The plugin can pay that cost in advance: ask the host to
|
|
1001
|
+
// seek to the OLDEST turn - its seek machine then pages all the way back -
|
|
1002
|
+
// and restore the viewport afterwards. Once the store holds the whole
|
|
1003
|
+
// history every jump is instant.
|
|
1004
|
+
const [warming, setWarming] = createSignal(false)
|
|
1005
|
+
const [warmPages, setWarmPages] = createSignal(0)
|
|
1006
|
+
let warmToken = 0
|
|
1007
|
+
let warmedFor = ""
|
|
1008
|
+
|
|
1009
|
+
// The topmost visible row plus its offset, so the viewport can be restored.
|
|
1010
|
+
const viewAnchor = () => {
|
|
1011
|
+
const sc = scroller()
|
|
1012
|
+
if (!sc) return undefined
|
|
1013
|
+
const scRect = sc.getBoundingClientRect()
|
|
1014
|
+
for (const row of sc.querySelectorAll("[data-message-id]")) {
|
|
1015
|
+
const rect = row.getBoundingClientRect()
|
|
1016
|
+
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 }
|
|
1020
|
+
}
|
|
1021
|
+
return undefined
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const restoreView = (anchor) => {
|
|
1025
|
+
if (!anchor) return
|
|
1026
|
+
const sc = scroller()
|
|
1027
|
+
if (!sc) return
|
|
1028
|
+
const row = findRow(anchor.id)
|
|
1029
|
+
if (!row) return
|
|
1030
|
+
const scRect = sc.getBoundingClientRect()
|
|
1031
|
+
const delta = row.getBoundingClientRect().top - scRect.top - anchor.offset
|
|
1032
|
+
if (Math.abs(delta) > 2) sc.scrollTop += delta
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const cancelWarm = () => {
|
|
1036
|
+
warmToken++
|
|
1037
|
+
setWarming(false)
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Prewarm once per session, when the user shows interest in the rail (hover
|
|
1041
|
+
// or /turns). Long sessions only - short ones are already instant.
|
|
1042
|
+
const maybePrewarm = () => {
|
|
1043
|
+
const sid = sessionID()
|
|
1044
|
+
if (!sid || warming()) return
|
|
1045
|
+
if (warmedFor === sid) return
|
|
1046
|
+
if (turns().length < 15) return
|
|
1047
|
+
void prewarm()
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
const prewarm = async () => {
|
|
1051
|
+
if (warming()) return
|
|
1052
|
+
const path = routePath(ctx)
|
|
1053
|
+
const oldest = turns()[0]
|
|
1054
|
+
const sid = sessionID()
|
|
1055
|
+
if (!path || !oldest || !sid) return
|
|
1056
|
+
// 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
|
+
}
|
|
1061
|
+
const token = ++warmToken
|
|
1062
|
+
const anchor = viewAnchor()
|
|
1063
|
+
const sc = scroller()
|
|
1064
|
+
const wasAtBottom = sc ? sc.scrollTop + sc.clientHeight >= sc.scrollHeight - 32 : true
|
|
1065
|
+
setWarming(true)
|
|
1066
|
+
setWarmPages(0)
|
|
1067
|
+
flash("正在预热历史…", 120000)
|
|
1068
|
+
try {
|
|
1069
|
+
navTo(path, oldest.id)
|
|
1070
|
+
const deadline = Date.now() + 120000
|
|
1071
|
+
let pages = 0
|
|
1072
|
+
let lastSig = rowSignature()
|
|
1073
|
+
const keep = setInterval(() => restoreView(anchor), 150)
|
|
1074
|
+
try {
|
|
1075
|
+
while (Date.now() < deadline && token === warmToken) {
|
|
1076
|
+
if (findRow(oldest.id)) break
|
|
1077
|
+
await sleep(250)
|
|
1078
|
+
const sig = rowSignature()
|
|
1079
|
+
if (sig !== lastSig) {
|
|
1080
|
+
lastSig = sig
|
|
1081
|
+
pages++
|
|
1082
|
+
setWarmPages(pages)
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
} finally {
|
|
1086
|
+
clearInterval(keep)
|
|
1087
|
+
}
|
|
1088
|
+
if (token !== warmToken) return
|
|
1089
|
+
// The host scrolled to the oldest message; put the viewport back.
|
|
1090
|
+
restoreView(anchor)
|
|
1091
|
+
if (wasAtBottom) {
|
|
1092
|
+
const live = scroller()
|
|
1093
|
+
if (live) live.scrollTop = live.scrollHeight
|
|
1094
|
+
}
|
|
1095
|
+
warmedFor = sid
|
|
1096
|
+
flash(`历史已预热(${pages} 页),现在跳转是瞬时的`)
|
|
1097
|
+
} finally {
|
|
1098
|
+
if (token === warmToken) setWarming(false)
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
977
1102
|
// Keep the busy row visible in the panel list while the jump pages through
|
|
978
1103
|
// history, so the "which turn am I waiting for" answer stays on screen.
|
|
979
1104
|
createEffect(() => {
|
|
@@ -997,7 +1122,10 @@ function Rail(props) {
|
|
|
997
1122
|
class="sr-rail"
|
|
998
1123
|
data-open={open() ? "true" : "false"}
|
|
999
1124
|
data-jumping={jumping() ? "true" : "false"}
|
|
1000
|
-
onMouseEnter={() =>
|
|
1125
|
+
onMouseEnter={() => {
|
|
1126
|
+
setOpen(true)
|
|
1127
|
+
maybePrewarm()
|
|
1128
|
+
}}
|
|
1001
1129
|
// Keep the panel pinned while a long history jump runs; otherwise the
|
|
1002
1130
|
// mouse drifting off the rail hides exactly the progress the user is
|
|
1003
1131
|
// waiting for. Esc / × still close (or cancel) it.
|
|
@@ -1007,16 +1135,25 @@ function Rail(props) {
|
|
|
1007
1135
|
>
|
|
1008
1136
|
<div class="sr-head">
|
|
1009
1137
|
<Show
|
|
1010
|
-
when={jumping()}
|
|
1138
|
+
when={jumping() || warming()}
|
|
1011
1139
|
fallback={
|
|
1012
1140
|
<span class="sr-title">会话轮次{turns().length > 0 ? ` · ${turns().length}` : ""}</span>
|
|
1013
1141
|
}
|
|
1014
1142
|
>
|
|
1015
1143
|
<span class="sr-title sr-progress">
|
|
1016
|
-
|
|
1017
|
-
|
|
1144
|
+
{jumping()
|
|
1145
|
+
? `跳转中${jumpPages() > 0 ? ` · 已加载 ${jumpPages()} 页` : ""}${jumpElapsed() > 0 ? ` · ${jumpElapsed()}s` : ""}`
|
|
1146
|
+
: `预热中${warmPages() > 0 ? ` · ${warmPages()} 页` : ""}`}
|
|
1018
1147
|
</span>
|
|
1019
1148
|
</Show>
|
|
1149
|
+
<button
|
|
1150
|
+
type="button"
|
|
1151
|
+
class="sr-warm"
|
|
1152
|
+
title="预热历史:后台把会话历史加载完,之后跳转瞬时"
|
|
1153
|
+
onClick={() => window.dispatchEvent(new Event(WARM_EVENT))}
|
|
1154
|
+
>
|
|
1155
|
+
预热
|
|
1156
|
+
</button>
|
|
1020
1157
|
<button
|
|
1021
1158
|
type="button"
|
|
1022
1159
|
class="sr-close"
|