opencode-session-turns 0.1.10 → 0.1.11

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
@@ -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 a=require("solid-js/web"),q=require("@opencode/plugin/gui"),i=require("solid-js");var zt=a.template("<div class=sr-status>"),Ft=a.template('<span class="sr-title sr-progress">跳转中'),Ut=a.template("<div class=sr-rail><div class=sr-head><button type=button class=sr-close>×</button></div><div class=sr-list>"),Gt=a.template("<span class=sr-title>会话轮次"),Ht=a.template("<div class=sr-note>"),Wt=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 et="v15",ot="message-",Ct="session-turns:diag",at="session-turns:open",Vt=40,Kt=200,Yt=50,Zt=30,j=45e3,Xt=12e3,Qt=5e3,te=9e3,ee=220,ne=800;let Lt=0,it="",lt="",ct="";const At=s=>{const n=String(s?.text??"").split(`
2
- `).map(p=>p.trim()).find(p=>p.length>0);if(n)return n;const d=s?.files?.length??0;return d>0?`[附件 ×${d}]`:"(空消息)"},Tt=s=>String(s).padStart(2,"0"),re=s=>{const n=new Date(s??0);return`${Tt(n.getHours())}:${Tt(n.getMinutes())}`},Mt=s=>new Promise(n=>setTimeout(n,s)),dt=()=>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 d=getComputedStyle(n);if(/(auto|scroll)/.test(d.overflowY)&&n.scrollHeight>n.clientHeight+1)return n;n=n.parentElement}})(),T=s=>document.getElementById(`${ot}${s}`)??[...document.querySelectorAll("[data-message-id]")].find(n=>n.getAttribute("data-message-id")===s),nt=s=>{const n=dt(),d=[...document.querySelectorAll("[data-message-id]")],p=d[0]?.getAttribute("data-message-id")??"",$=ct?` · 目标 ${T(ct)?"在":"不在"} DOM`:"",S=p?` · 行ID ${p.slice(0,20)}`:"";let M="(空)";try{M=s.router.path()||"(空)"}catch{}return`轮次 ${Lt} · 容器 ${n?"OK":"缺失"} · DOM ${d.length} 行${$}${S} · 路由 ${M} · hash ${window.location.hash||"(空)"}${lt?` · ${lt}`:""}${it?` · ${it}`:""}`},se=s=>{const n=s.getBoundingClientRect().top;let d=n;for(const p of s.querySelectorAll(".sticky, [data-session-title]")){if(!(p instanceof HTMLElement)||getComputedStyle(p).position!=="sticky"&&!p.hasAttribute("data-session-title"))continue;const S=p.getBoundingClientRect();S.height!==0&&(S.top>n+4||(d=Math.max(d,S.bottom)))}return d},rt=s=>{try{const n=dt();if(!n||!s.isConnected)return;const d=s.querySelector('[data-slot="user-message-text"]')??s,p=se(n),$=d.getBoundingClientRect().top-(p+18);Math.abs($)>2&&(n.scrollTop+=$),lt=`inset ${Math.round(p)} · delta ${Math.round($)}`}catch{}},st=s=>{rt(s),setTimeout(()=>rt(s),450),setTimeout(()=>rt(s),1e3)},oe=s=>{let n="";try{n=s.router.path()||""}catch{}return n.startsWith("/")?n:""},ae=s=>{const n=document.createElement("a");n.setAttribute("href",s),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()}},ie=`
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: outline follows the bubble's own border-radius, so no
180
- shape is imposed and the message background stays untouched */
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
- outline: 2px solid rgba(255,170,60,.95);
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
- 60% {
189
- outline: 2px solid rgba(255,170,60,.35);
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
- outline: 2px solid rgba(255,170,60,0);
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 le(s){const n=q.usePlugin(),[d,p]=i.createSignal([]),[$,S]=i.createSignal(""),[M,A]=i.createSignal(!1),[It,Dt]=i.createSignal(""),[ut,pt]=i.createSignal(""),[w,R]=i.createSignal(!1),[G,L]=i.createSignal(0),[H,I]=i.createSignal(0),[de,J]=i.createSignal(0),[W,B]=i.createSignal(""),E=new Map;let V=0,O=!1,N=!1,K="",D;const _=()=>s.session()?.id,Y=()=>{const t=[...E.values()].sort((r,l)=>r.created-l.created),e=i.untrack(d);e.length===t.length&&e.every((r,l)=>r===t[l])||(p(t),Lt=t.length)},ft=t=>{const e=E.get(t.id);e&&e.created===t.time&&e.text===t.text||E.set(t.id,{id:t.id,created:t.time,text:t.text})},Pt=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)},Z=async(t,e)=>{const r=++V;O=!0;let l="";try{let o;const c=e==="full"?Vt:1,u=e==="full"?Kt:Yt;let m=e!=="full";for(let v=0;v<c;v++){let b;try{b=await Pt(t,u,o)}catch(h){l=String(h?.message??h);break}if(r!==V)return;m||(E.clear(),m=!0);let f=0;for(const h of b?.data??[])E.has(h.id)||(ft({id:h.id,time:h.time?.created??0,text:At(h)}),f++);Y();const g=b?.cursor?.next;if(!g||f===0)break;o=g}}finally{O=!1,K=t,it=l,Dt(E.size===0?l||"暂无用户消息":""),N&&(N=!1,ht())}},ht=()=>{D||(D=setTimeout(()=>{D=void 0;const t=i.untrack(_);t&&Z(t,"light")},300))},gt=t=>{if(!(!t||t!==i.untrack(_))){if(O){N=!0;return}ht()}},jt=t=>{const e=t?.type;if(e==="message.updated"){const r=t?.data??{},l=r.info;l?.role==="user"&&l?.id&&(ft({id:l.id,time:l.time?.created??0,text:At(l)}),Y()),gt(r.sessionID)}else if(e==="message.removed"){const r=t?.data??{};r.messageID&&E.delete(r.messageID)&&Y(),gt(r.sessionID)}else if(e==="location.shutdown"){const r=i.untrack(_);r&&K===r&&Z(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 u=await c.next();if(u.done)break;e=0,jt(u.value)}}catch{}try{await c.return?.()}catch{}if(t)break;e++,await Mt(Math.min(2e3*e,15e3))}})(),i.onCleanup(()=>{t=!0,clearTimeout(r),D&&clearTimeout(D)})}),i.createEffect(()=>{const t=_();t&&i.untrack(()=>{K!==t&&(V++,O=!1,N=!1,E.clear(),p([]),S(""),Q="",Z(t,"full"))})});let z;const F=()=>(z?.isConnected||(z=dt()),z),qt=()=>{const t=F();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 o=l.getAttribute("data-message-id");o&&S(o);break}};let X=!1;const mt=()=>{X||(X=!0,requestAnimationFrame(()=>{X=!1,qt()}))};i.onMount(()=>{const t=()=>mt();document.addEventListener("scroll",t,!0);const e=setInterval(mt,1500);i.onCleanup(()=>{document.removeEventListener("scroll",t,!0),clearInterval(e)})});let bt=0;const x=(t,e=2600)=>{const r=++bt;pt(t),setTimeout(()=>{r===bt&&pt("")},e)};i.onMount(()=>{const t=r=>{if(r.key==="Escape"){if(i.untrack(w)){kt(),r.stopPropagation();return}i.untrack(M)&&(A(!1),r.stopPropagation())}},e=r=>{if(!i.untrack(M))return;(r.composedPath?.()??[]).some(c=>c instanceof Element&&c.closest?.(".sr-rail"))||A(!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=()=>x(`${et} · ${nt(n)}`),e=()=>A(r=>!r);window.addEventListener(Ct,t),window.addEventListener(at,e),i.onCleanup(()=>{window.removeEventListener(Ct,t),window.removeEventListener(at,e)}),x(`会话轮次 ${et} 已加载`)});let Q="",C=0,xt=0,vt="";const tt=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{}},Rt=()=>{for(const t of d())if(T(t.id))return t},wt=(t,e)=>{ae(`${t}#${ot}${e}`),Q=e},yt=()=>{const e=F()?.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}`},Jt=(t,e,r,l)=>new Promise(o=>{let c=!1,u,m,v;const b=Date.now();let g=yt(),h=b,P=0;const y=U=>{if(!c){c=!0;try{u?.disconnect()}catch{}clearInterval(m),clearTimeout(v),o(U??null)}},k=U=>{if(e!==C)return y(null);const St=U?T(t):document.getElementById(`${ot}${t}`);if(St)return y(St);if(!U)return;const Et=yt(),_t=Date.now();if(Et!==g){g=Et,h=_t,P++,l?.(P);return}const Nt=P>0?Qt:te;_t-h>Nt&&y(null)},$t=T(t);if($t)return y($t);u=new MutationObserver(()=>k(!1)),u.observe(document.documentElement,{childList:!0,subtree:!0}),m=setInterval(()=>k(!0),120),v=setTimeout(()=>y(T(t)),Math.max(400,r))}),kt=()=>{C++,R(!1),L(0),I(0),J(0),B(""),x("已取消跳转")},Bt=async t=>{if(w()&&W()===t.id){x(`正在跳转 · 已加载 ${G()} 页 · ${H()}s`,j);return}const e=++C;R(!1),L(0),I(0),J(0),B(""),ct=t.id;const r=Date.now();if(t.id===vt&&r-xt<ne){const f=T(t.id),g=F();if(f&&g){st(f),tt(f);return}}xt=r,vt=t.id;const l=F(),o=T(t.id);if(o&&l){st(o),tt(o),x("已定位");return}const c=oe(n);if(!c){x(`无法读取当前路由,无法定位 · ${nt(n)}`);return}const u=_();R(!0),B(t.id),L(0),I(0),A(!0),x("正在定位…",j);const m=Date.now(),v=setInterval(()=>{I(Math.round((Date.now()-m)/1e3))},500);let b=0;try{const f=Date.now()+j;for(let g=0;g<Zt&&Date.now()<f;g++){if(e!==C||_()!==u)return;if(J(g+1),Q===t.id){const k=Rt()??d()[0];k&&(wt(c,k.id),await Mt(ee))}if(e!==C||_()!==u)return;wt(c,t.id),x(`跳转中 · 已加载 ${b} 页`,j);let h=0;const P=Math.min(Xt,f-Date.now()),y=await Jt(t.id,e,P,k=>{h=k,L(b+k),x(`跳转中 · 已加载 ${b+k} 页`,j)});if(b+=h,e!==C||_()!==u)return;if(y){st(y),tt(y),x("已定位");return}}e===C&&x(`未能定位 · ${nt(n)}`)}finally{clearInterval(v),e===C&&(R(!1),L(0),I(0),J(0),B(""))}};i.createEffect(()=>{w()&&W()&&requestAnimationFrame(()=>{try{document.querySelector('.sr-list .sr-turn[data-busy="true"]')?.scrollIntoView({block:"nearest"})}catch{}})});const Ot=[a.createComponent(i.Show,{get when(){return ut()},get children(){var t=zt();return a.insert(t,ut),t}}),(()=>{var t=Ut(),e=t.firstChild,r=e.firstChild,l=e.nextSibling;return t.addEventListener("mouseleave",()=>{w()||A(!1)}),t.addEventListener("mouseenter",()=>A(!0)),a.insert(e,a.createComponent(i.Show,{get when(){return w()},get fallback(){return(()=>{var o=Gt();return o.firstChild,a.insert(o,(()=>{var c=a.memo(()=>d().length>0);return()=>c()?` · ${d().length}`:""})(),null),o})()},get children(){var o=Ft();return o.firstChild,a.insert(o,(()=>{var c=a.memo(()=>G()>0);return()=>c()?` · 已加载 ${G()} 页`:""})(),null),a.insert(o,(()=>{var c=a.memo(()=>H()>0);return()=>c()?` · ${H()}s`:""})(),null),o}}),r),r.$$click=()=>{w()?kt():A(!1)},a.insert(l,a.createComponent(i.Show,{get when(){return d().length>0},get fallback(){return(()=>{var o=Ht();return a.insert(o,()=>It()||`… ${et}`),o})()},get children(){return a.createComponent(i.For,{get each(){return d()},children:(o,c)=>(()=>{var u=Wt(),m=u.firstChild,v=m.firstChild;v.firstChild;var b=v.nextSibling;return u.$$click=()=>{Bt(o)},a.insert(v,()=>c()+1,null),a.insert(b,()=>re(o.created)),a.insert(m,()=>o.text,null),a.effect(f=>{var g=$()===o.id?"true":"false",h=w()&&W()===o.id?"true":"false";return g!==f.e&&a.setAttribute(u,"data-active",f.e=g),h!==f.t&&a.setAttribute(u,"data-busy",f.t=h),f},{e:void 0,t:void 0}),u})()})}})),a.effect(o=>{var c=M()?"true":"false",u=w()?"true":"false",m=w()?"取消跳转 (Esc)":"关闭 (Esc)";return c!==o.e&&a.setAttribute(t,"data-open",o.e=c),u!==o.t&&a.setAttribute(t,"data-jumping",o.t=u),m!==o.a&&a.setAttribute(r,"title",o.a=m),o},{e:void 0,t:void 0,a:void 0}),t})()];return a.createComponent(a.Portal,{get mount(){return document.body},children:Ot})}const ce=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(s){s.add(q.Style,ie),s.add(q.Slot,{at:"window.bottom",render:()=>a.createComponent(i.Show,{get when(){return s.sessions.current()},get children(){return a.createComponent(le,{session:()=>s.sessions.current()})}})}),s.add(q.Command,()=>({id:"jump",title:s.t("command.jump"),description:s.t("command.jump.description"),group:s.t("command.jump"),section:"navigation",bind:"mod+alt+r",slash:{name:"turns"},run:()=>{window.dispatchEvent(new Event(at))}}))}});a.delegateEvents(["click"]);exports.default=ce;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-session-turns",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
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,10 +6,11 @@ import { Portal } from "solid-js/web"
6
6
  // constants / helpers
7
7
  // ---------------------------------------------------------------------------
8
8
 
9
- const VERSION = "v15"
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: outline follows the bubble's own border-radius, so no
363
- shape is imposed and the message background stays untouched */
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
- outline: 2px solid rgba(255,170,60,.95);
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
- 60% {
372
- outline: 2px solid rgba(255,170,60,.35);
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
- outline: 2px solid rgba(255,170,60,0);
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 = () => setOpen((value) => !value)
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={() => setOpen(true)}
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
- 跳转中{jumpPages() > 0 ? ` · 已加载 ${jumpPages()} 页` : ""}
1017
- {jumpElapsed() > 0 ? ` · ${jumpElapsed()}s` : ""}
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"