opencode-session-turns 0.1.1 → 0.1.2

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.
@@ -1,5 +1,5 @@
1
- "use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const s=require("solid-js/web"),h=require("@opencode/plugin/gui"),a=require("solid-js");var Z=s.template("<div class=sr-status>"),tt=s.template("<div class=sr-rail>"),et=s.template("<div class=sr-note>"),nt=s.template("<button type=button class=sr-turn><span class=sr-text> </span><span class=sr-dash>");const C="v10",B="message-",_="session-turns:diag",A="session-turns:open";let F=0,j="",I="";const rt=n=>{const e=String(n?.text??"").split(`
2
- `).map(d=>d.trim()).find(d=>d.length>0);if(e)return e;const o=n?.files?.length??0;return o>0?`[附件 ×${o}]`:"(空消息)"},N=n=>String(n).padStart(2,"0"),st=n=>{const e=new Date(n??0);return`${N(e.getHours())}:${N(e.getMinutes())}`},z=n=>new Promise(e=>setTimeout(e,n)),x=()=>document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport')??document.querySelector('[data-slot="session-timeline-scroll"] [data-scrollable]')??(()=>{let e=document.querySelector("[data-message-id]")?.parentElement;for(;e;){const o=getComputedStyle(e);if(/(auto|scroll)/.test(o.overflowY)&&e.scrollHeight>e.clientHeight+1)return e;e=e.parentElement}})(),y=n=>document.getElementById(`${B}${n}`)??[...document.querySelectorAll("[data-message-id]")].find(e=>e.getAttribute("data-message-id")===n),T=n=>{const e=x(),o=[...document.querySelectorAll("[data-message-id]")],d=o[0]?.getAttribute("data-message-id")??"",$=I?` · 目标 ${y(I)?"在":"不在"} DOM`:"",v=d?` · 行ID ${d.slice(0,20)}`:"";let w="(空)";try{w=n.router.path()||"(空)"}catch{}return`轮次 ${F} · 容器 ${e?"OK":"缺失"} · DOM ${o.length} 行${$}${v} · 路由 ${w} · hash ${window.location.hash||"(空)"}${j?` · ${j}`:""}`},V=(n,e)=>{e.scrollIntoView({block:"start"});const o=n.querySelector("[data-session-title]"),d=o instanceof HTMLElement?o.offsetHeight:0;d&&(n.scrollTop-=d+12)},ot=n=>{let e="";try{e=n.router.path()||""}catch{}return e.startsWith("/")?e:""},at=n=>{const e=document.createElement("a");e.setAttribute("href",n),e.setAttribute("replace",""),e.setAttribute("noscroll",""),e.style.display="none",document.body.appendChild(e);try{e.dispatchEvent(new MouseEvent("click",{bubbles:!0,cancelable:!0,button:0,view:window}))}finally{e.remove()}},it=`
1
+ "use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const l=require("solid-js/web"),_=require("@opencode/plugin/gui"),i=require("solid-js");var we=l.template("<div class=sr-status>"),xe=l.template('<div class=sr-rail><div class=sr-head><span class=sr-title>会话轮次</span><button type=button class=sr-close title="关闭 (Esc)">×'),ye=l.template("<div class=sr-note>"),ke=l.template("<button type=button class=sr-turn><span class=sr-text><span class=sr-num>#</span> </span><span class=sr-dash>");const H="v11",ae="message-",re="session-turns:diag",U="session-turns:open",$e=40,Ee=200,Se=50,_e=30,Te=15e3,Ce=6e3,Le=800;let le=0,G="",W="";const se=s=>{const r=String(s?.text??"").split(`
2
+ `).map(h=>h.trim()).find(h=>h.length>0);if(r)return r;const c=s?.files?.length??0;return c>0?`[附件 ×${c}]`:"(空消息)"},oe=s=>String(s).padStart(2,"0"),Me=s=>{const r=new Date(s??0);return`${oe(r.getHours())}:${oe(r.getMinutes())}`},ie=s=>new Promise(r=>setTimeout(r,s)),ce=()=>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 c=getComputedStyle(r);if(/(auto|scroll)/.test(c.overflowY)&&r.scrollHeight>r.clientHeight+1)return r;r=r.parentElement}})(),y=s=>document.getElementById(`${ae}${s}`)??[...document.querySelectorAll("[data-message-id]")].find(r=>r.getAttribute("data-message-id")===s),F=s=>{const r=ce(),c=[...document.querySelectorAll("[data-message-id]")],h=c[0]?.getAttribute("data-message-id")??"",P=W?` · 目标 ${y(W)?"在":"不在"} DOM`:"",T=h?` · 行ID ${h.slice(0,20)}`:"";let k="(空)";try{k=s.router.path()||"(空)"}catch{}return`轮次 ${le} · 容器 ${r?"OK":"缺失"} · DOM ${c.length} 行${P}${T} · 路由 ${k} · hash ${window.location.hash||"(空)"}${G?` · ${G}`:""}`},J=(s,r)=>{r.scrollIntoView({block:"start"});const c=s.querySelector("[data-session-title]"),h=c instanceof HTMLElement?c.offsetHeight:0;h&&(s.scrollTop-=h+12)},Ae=s=>{let r="";try{r=s.router.path()||""}catch{}return r.startsWith("/")?r:""},Ie=s=>{const r=document.createElement("a");r.setAttribute("href",s),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()}},Pe=`
3
3
  .sr-rail {
4
4
  position: fixed;
5
5
  top: 50%;
@@ -16,14 +16,52 @@
16
16
  overflow-y: auto;
17
17
  overflow-x: hidden;
18
18
  scrollbar-width: none;
19
+ /* keep wheel scrolling from chaining into the host timeline when the rail
20
+ reaches its own end */
21
+ overscroll-behavior: contain;
19
22
  z-index: 400;
20
23
  border-radius: 10px;
21
24
  color: var(--text-base, currentColor);
22
25
  background: transparent;
23
- transition: width .18s ease;
26
+ transition: width .18s ease, background .18s ease, box-shadow .18s ease;
24
27
  }
25
28
  .sr-rail::-webkit-scrollbar { display: none; }
26
- .sr-rail[data-open="true"] { width: 360px; background: transparent; }
29
+ .sr-rail[data-open="true"] {
30
+ width: 360px;
31
+ padding: 10px 8px;
32
+ justify-content: flex-start;
33
+ background: var(--surface-base, rgba(125,125,125,.16));
34
+ backdrop-filter: blur(14px);
35
+ -webkit-backdrop-filter: blur(14px);
36
+ box-shadow: 0 6px 28px rgba(0,0,0,.22);
37
+ }
38
+ .sr-head {
39
+ display: none;
40
+ align-items: center;
41
+ justify-content: space-between;
42
+ gap: 8px;
43
+ padding: 0 2px 6px;
44
+ margin-bottom: 4px;
45
+ border-bottom: 1px solid rgba(127,127,127,.22);
46
+ flex-shrink: 0;
47
+ width: 100%;
48
+ }
49
+ .sr-rail[data-open="true"] .sr-head { display: flex; }
50
+ .sr-title { font-size: 11px; opacity: .55; white-space: nowrap; }
51
+ .sr-close {
52
+ border: 0;
53
+ background: transparent;
54
+ color: inherit;
55
+ font: inherit;
56
+ font-size: 13px;
57
+ line-height: 1;
58
+ padding: 2px 7px;
59
+ border-radius: 4px;
60
+ cursor: pointer;
61
+ opacity: .5;
62
+ flex-shrink: 0;
63
+ }
64
+ .sr-close:hover { opacity: 1; background: var(--surface-base-hover, rgba(127,127,127,.14)); }
27
65
  .sr-turn {
28
66
  display: flex;
29
67
  align-items: center;
@@ -39,6 +77,7 @@
39
77
  font: inherit;
40
78
  border-radius: 4px;
41
79
  }
80
+ .sr-turn:focus-visible, .sr-close:focus-visible { outline: 1px solid currentColor; outline-offset: -1px; }
42
81
  .sr-dash {
43
82
  display: block;
44
83
  width: 10px;
@@ -67,6 +106,7 @@
67
106
  background: transparent;
68
107
  }
69
108
  .sr-rail[data-open="true"] .sr-text { display: block; }
109
+ .sr-num { opacity: .75; margin-right: 5px; font-variant-numeric: tabular-nums; }
70
110
  .sr-turn:hover .sr-text {
71
111
  opacity: 1;
72
112
  background: var(--surface-base-hover, rgba(127,127,127,.14));
@@ -76,6 +116,19 @@
76
116
  font-weight: 600;
77
117
  background: var(--surface-base-hover, rgba(127,127,127,.14));
78
118
  }
119
+ /* brief highlight of the row a jump landed on */
120
+ .sr-flash-target { animation: srPulse 1.5s ease-out 1; }
121
+ @keyframes srPulse {
122
+ 0%, 30% { background: rgba(255,170,60,.4); }
123
+ 100% { background: transparent; }
124
+ }
125
+ .sr-note {
126
+ display: none;
127
+ font-size: 11px;
128
+ opacity: .4;
129
+ padding: 2px;
130
+ }
131
+ .sr-rail[data-open="true"] .sr-note { display: block; }
79
132
  .sr-status {
80
133
  position: fixed;
81
134
  right: 30px;
@@ -91,5 +144,4 @@
91
144
  white-space: pre-wrap;
92
145
  max-width: min(720px, 90vw);
93
146
  }
94
- .sr-note { font-size: 11px; opacity: .4; padding: 2px; }
95
- `;function lt(n){const e=h.usePlugin(),[o,d]=a.createSignal([]),[$,v]=a.createSignal(""),[w,b]=a.createSignal(!1),[J,Y]=a.createSignal(""),[q,L]=a.createSignal("");let S=!1,M="",k,E="";const R=async t=>{if(!t||S)return;S=!0;const r=new Map;let l,i="";try{for(let u=0;u<40;u++){const g=l?{sessionID:t,type:"user",limit:200,cursor:l}:{sessionID:t,type:"user",limit:200,order:"desc"};let f;try{f=await e.client.message.list(g)}catch(p){i=String(p?.message??p);break}const U=f?.data??[];let D=0;for(const p of U)r.has(p.id)||(r.set(p.id,{id:p.id,created:p.time?.created??0,text:rt(p)}),D++);const H=f?.cursor?.next;if(!H||D===0)break;l=H}}finally{S=!1}const c=[...r.values()].sort((u,g)=>u.created-g.created);d(c),F=c.length,j=i,Y(c.length===0?i||"暂无用户消息":""),M=t},O=()=>n.session()?.id;a.createEffect(()=>{const t=O();t&&a.untrack(()=>{M!==t&&(d([]),v(""),E="",R(t))})});const G=setInterval(()=>{const t=O();t&&R(t)},5e3);a.onCleanup(()=>clearInterval(G));const K=setInterval(()=>{const t=x();if(!t)return;const r=[...t.querySelectorAll("[data-message-id]")];if(r.length===0)return;const l=t.getBoundingClientRect().top+40;for(const i of r)if(i.getBoundingClientRect().bottom>=l){const c=i.getAttribute("data-message-id");c&&v(c);break}},400);a.onCleanup(()=>clearInterval(K));const m=t=>{L(t),setTimeout(()=>L(r=>r===t?"":r),3e3)};a.onMount(()=>{const t=()=>m(`${C} · ${T(e)}`),r=()=>{b(!0),clearTimeout(k),k=setTimeout(()=>b(!1),4e3)};window.addEventListener(_,t),window.addEventListener(A,r),a.onCleanup(()=>{window.removeEventListener(_,t),window.removeEventListener(A,r),clearTimeout(k)}),m(`会话轮次 ${C} 已加载`)});const W=()=>{for(const t of o())if(y(t.id))return t},P=(t,r)=>{at(`${t}#${B}${r}`),E=r},X=async t=>{I=t.id;const r=x(),l=y(t.id);if(l&&r){V(r,l),m("已定位");return}const i=ot(e);if(!i){m(`无法读取当前路由,无法定位 · ${T(e)}`);return}for(let c=0;c<30;c++){if(m(c===0?"定位中…":`加载历史… ${c}`),E===t.id){const u=W()??o()[0];u&&(P(i,u.id),await z(120))}P(i,t.id);for(let u=0;u<6;u++){await z(80);const g=y(t.id);if(!g)continue;const f=x()??r;f&&V(f,g),m("已定位");return}}m(`未能定位 · ${T(e)}`)},Q=[s.createComponent(a.Show,{get when(){return q()},get children(){var t=Z();return s.insert(t,q),t}}),(()=>{var t=tt();return t.addEventListener("mouseleave",()=>b(!1)),t.addEventListener("mouseenter",()=>b(!0)),s.insert(t,s.createComponent(a.Show,{get when(){return o().length>0},get fallback(){return(()=>{var r=et();return s.insert(r,()=>J()||`… ${C}`),r})()},get children(){return s.createComponent(a.For,{get each(){return o()},children:r=>(()=>{var l=nt(),i=l.firstChild,c=i.firstChild;return l.$$click=()=>{X(r)},s.insert(i,()=>st(r.created),c),s.insert(i,()=>r.text,null),s.effect(()=>s.setAttribute(l,"data-active",$()===r.id?"true":"false")),l})()})}})),s.effect(()=>s.setAttribute(t,"data-open",w()?"true":"false")),t})()];return s.createComponent(s.Portal,{get mount(){return document.body},children:Q})}const ct=h.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(n){n.add(h.Style,it),n.add(h.Slot,{at:"window.bottom",render:()=>s.createComponent(a.Show,{get when(){return n.sessions.current()},get children(){return s.createComponent(lt,{session:()=>n.sessions.current()})}})}),n.add(h.Command,()=>({id:"jump",title:n.t("command.jump"),description:n.t("command.jump.description"),group:n.t("command.jump"),section:"navigation",bind:"mod+alt+r",slash:{name:"turns"},run:()=>{window.dispatchEvent(new Event(A)),window.dispatchEvent(new Event(_))}}))}});s.delegateEvents(["click"]);exports.default=ct;
147
+ `;function De(s){const r=_.usePlugin(),[c,h]=i.createSignal([]),[P,T]=i.createSignal(""),[k,$]=i.createSignal(!1),[de,ue]=i.createSignal(""),[B,V]=i.createSignal(""),b=new Map;let D=0,C=!1,L=!1,j="",S;const M=()=>s.session()?.id,O=()=>{const e=[...b.values()].sort((n,o)=>n.created-o.created),t=i.untrack(c);t.length===e.length&&t.every((n,o)=>n===e[o])||(h(e),le=e.length)},K=e=>{const t=b.get(e.id);t&&t.created===e.time&&t.text===e.text||b.set(e.id,{id:e.id,created:e.time,text:e.text})},pe=async(e,t,n)=>{const o=n?{sessionID:e,type:"user",limit:t,cursor:n}:{sessionID:e,type:"user",limit:t,order:"desc"};return await r.client.message.list(o)},q=async(e,t)=>{const n=++D;C=!0;let o="";try{let a;const u=t==="full"?$e:1,f=t==="full"?Ee:Se;let d=t!=="full";for(let p=0;p<u;p++){let m;try{m=await pe(e,f,a)}catch(x){o=String(x?.message??x);break}if(n!==D)return;d||(b.clear(),d=!0);let g=0;for(const x of m?.data??[])b.has(x.id)||(K({id:x.id,time:x.time?.created??0,text:se(x)}),g++);O();const w=m?.cursor?.next;if(!w||g===0)break;a=w}}finally{C=!1,j=e,G=o,ue(b.size===0?o||"暂无用户消息":""),L&&(L=!1,Y())}},Y=()=>{S||(S=setTimeout(()=>{S=void 0;const e=i.untrack(M);e&&q(e,"light")},300))},Z=e=>{if(!(!e||e!==i.untrack(M))){if(C){L=!0;return}Y()}},fe=e=>{const t=e?.type;if(t==="message.updated"){const n=e?.data??{},o=n.info;o?.role==="user"&&o?.id&&(K({id:o.id,time:o.time?.created??0,text:se(o)}),O()),Z(n.sessionID)}else if(t==="message.removed"){const n=e?.data??{};n.messageID&&b.delete(n.messageID)&&O(),Z(n.sessionID)}else if(t==="location.shutdown"){const n=i.untrack(M);n&&j===n&&q(n,"full")}};i.onMount(()=>{let e=!1,t=0,n;(async()=>{for(;!e;){const u=r.client.event.subscribe()[Symbol.asyncIterator]();try{for(;!e;){const f=await u.next();if(f.done)break;t=0,fe(f.value)}}catch{}try{await u.return?.()}catch{}if(e)break;t++,await ie(Math.min(2e3*t,15e3))}})(),i.onCleanup(()=>{e=!0,clearTimeout(n),S&&clearTimeout(S)})}),i.createEffect(()=>{const e=M();e&&i.untrack(()=>{j!==e&&(D++,C=!1,L=!1,b.clear(),h([]),T(""),N="",q(e,"full"))})});let A;const I=()=>(A?.isConnected||(A=ce()),A),he=()=>{const e=I();if(!e)return;const t=e.querySelectorAll("[data-message-id]");if(t.length===0)return;const n=e.getBoundingClientRect().top+40;for(const o of t)if(o.getBoundingClientRect().bottom>=n){const a=o.getAttribute("data-message-id");a&&T(a);break}};let R=!1;const X=()=>{R||(R=!0,requestAnimationFrame(()=>{R=!1,he()}))};i.onMount(()=>{const e=()=>X();document.addEventListener("scroll",e,!0);const t=setInterval(X,1500);i.onCleanup(()=>{document.removeEventListener("scroll",e,!0),clearInterval(t)})});let Q=0;const v=(e,t=2600)=>{const n=++Q;V(e),setTimeout(()=>{n===Q&&V("")},t)};i.onMount(()=>{const e=n=>{n.key==="Escape"&&i.untrack(k)&&($(!1),n.stopPropagation())},t=n=>{if(!i.untrack(k))return;(n.composedPath?.()??[]).some(u=>u instanceof Element&&u.closest?.(".sr-rail"))||$(!1)};window.addEventListener("keydown",e,!0),document.addEventListener("pointerdown",t,!0),i.onCleanup(()=>{window.removeEventListener("keydown",e,!0),document.removeEventListener("pointerdown",t,!0)})}),i.onMount(()=>{const e=()=>v(`${H} · ${F(r)}`),t=()=>$(n=>!n);window.addEventListener(re,e),window.addEventListener(U,t),i.onCleanup(()=>{window.removeEventListener(re,e),window.removeEventListener(U,t)}),v(`会话轮次 ${H} 已加载`)});let N="",E=0,ee=0,te="";const z=e=>{try{e.classList.remove("sr-flash-target"),e.offsetWidth,e.classList.add("sr-flash-target"),setTimeout(()=>e.classList.remove("sr-flash-target"),1600)}catch{}},me=()=>{for(const e of c())if(y(e.id))return e},ne=(e,t)=>{Ie(`${e}#${ae}${t}`),N=t},ge=(e,t,n)=>new Promise(o=>{let a=!1,u,f,d;const p=w=>{if(!a){a=!0;try{u?.disconnect()}catch{}clearInterval(f),clearTimeout(d),o(w??null)}},m=()=>{if(t!==E)return p(null);const w=y(e);w&&p(w)},g=y(e);if(g)return p(g);u=new MutationObserver(m),u.observe(document.documentElement,{childList:!0,subtree:!0}),f=setInterval(m,120),d=setTimeout(()=>p(y(e)),Math.max(400,n))}),be=async e=>{const t=++E;W=e.id;const n=Date.now();if(e.id===te&&n-ee<Le){const d=y(e.id),p=I();if(d&&p){J(p,d),z(d);return}}ee=n,te=e.id;const o=I(),a=y(e.id);if(a&&o){J(o,a),z(a),v("已定位");return}const u=Ae(r);if(!u){v(`无法读取当前路由,无法定位 · ${F(r)}`);return}const f=Date.now()+Te;for(let d=0;d<_e&&Date.now()<f;d++){if(t!==E)return;if(v(d===0?"定位中…":`加载历史… ${d}`),N===e.id){const g=me()??c()[0];g&&(ne(u,g.id),await ie(120))}if(t!==E)return;ne(u,e.id);const p=Math.min(Ce,f-Date.now()),m=await ge(e.id,t,p);if(t!==E)return;if(m){const g=I()??o;g&&J(g,m),z(m),v("已定位");return}}t===E&&v(`未能定位 · ${F(r)}`)},ve=[l.createComponent(i.Show,{get when(){return B()},get children(){var e=we();return l.insert(e,B),e}}),(()=>{var e=xe(),t=e.firstChild,n=t.firstChild;n.firstChild;var o=n.nextSibling;return e.addEventListener("mouseleave",()=>$(!1)),e.addEventListener("mouseenter",()=>$(!0)),l.insert(n,(()=>{var a=l.memo(()=>c().length>0);return()=>a()?` · ${c().length}`:""})(),null),o.$$click=()=>$(!1),l.insert(e,l.createComponent(i.Show,{get when(){return c().length>0},get fallback(){return(()=>{var a=ye();return l.insert(a,()=>de()||`… ${H}`),a})()},get children(){return l.createComponent(i.For,{get each(){return c()},children:(a,u)=>(()=>{var f=ke(),d=f.firstChild,p=d.firstChild;p.firstChild;var m=p.nextSibling;return f.$$click=()=>{be(a)},l.insert(p,()=>u()+1,null),l.insert(d,()=>Me(a.created),m),l.insert(d,()=>a.text,null),l.effect(()=>l.setAttribute(f,"data-active",P()===a.id?"true":"false")),f})()})}}),null),l.effect(()=>l.setAttribute(e,"data-open",k()?"true":"false")),e})()];return l.createComponent(l.Portal,{get mount(){return document.body},children:ve})}const je=_.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(_.Style,Pe),s.add(_.Slot,{at:"window.bottom",render:()=>l.createComponent(i.Show,{get when(){return s.sessions.current()},get children(){return l.createComponent(De,{session:()=>s.sessions.current()})}})}),s.add(_.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(U))}}))}});l.delegateEvents(["click"]);exports.default=je;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-session-turns",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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,11 +6,23 @@ import { Portal } from "solid-js/web"
6
6
  // constants / helpers
7
7
  // ---------------------------------------------------------------------------
8
8
 
9
- const VERSION = "v10"
9
+ const VERSION = "v11"
10
10
  const HASH_PREFIX = "message-"
11
11
  const DIAG_EVENT = "session-turns:diag"
12
12
  const OPEN_EVENT = "session-turns:open"
13
13
 
14
+ // paging: a full load walks every page; a light refresh pulls just the newest
15
+ // page (new messages always land at the head of a desc listing).
16
+ const FULL_PAGE_LIMIT = 40
17
+ const FULL_PAGE_SIZE = 200
18
+ const LIGHT_PAGE_SIZE = 50
19
+
20
+ // jump loop budgets
21
+ const JUMP_ROUNDS = 30
22
+ const JUMP_TOTAL_MS = 15000
23
+ const JUMP_ROW_TIMEOUT_MS = 6000
24
+ const SAME_TARGET_WINDOW_MS = 800
25
+
14
26
  // Filled by Rail.load()/jump() so the command palette can report diagnostics.
15
27
  let lastTurnCount = 0
16
28
  let lastNote = ""
@@ -143,14 +155,52 @@ const CSS = `
143
155
  overflow-y: auto;
144
156
  overflow-x: hidden;
145
157
  scrollbar-width: none;
158
+ /* keep wheel scrolling from chaining into the host timeline when the rail
159
+ reaches its own end */
160
+ overscroll-behavior: contain;
146
161
  z-index: 400;
147
162
  border-radius: 10px;
148
163
  color: var(--text-base, currentColor);
149
164
  background: transparent;
150
- transition: width .18s ease;
165
+ transition: width .18s ease, background .18s ease, box-shadow .18s ease;
151
166
  }
152
167
  .sr-rail::-webkit-scrollbar { display: none; }
153
- .sr-rail[data-open="true"] { width: 360px; background: transparent; }
168
+ .sr-rail[data-open="true"] {
169
+ width: 360px;
170
+ padding: 10px 8px;
171
+ justify-content: flex-start;
172
+ background: var(--surface-base, rgba(125,125,125,.16));
173
+ backdrop-filter: blur(14px);
174
+ -webkit-backdrop-filter: blur(14px);
175
+ box-shadow: 0 6px 28px rgba(0,0,0,.22);
176
+ }
177
+ .sr-head {
178
+ display: none;
179
+ align-items: center;
180
+ justify-content: space-between;
181
+ gap: 8px;
182
+ padding: 0 2px 6px;
183
+ margin-bottom: 4px;
184
+ border-bottom: 1px solid rgba(127,127,127,.22);
185
+ flex-shrink: 0;
186
+ width: 100%;
187
+ }
188
+ .sr-rail[data-open="true"] .sr-head { display: flex; }
189
+ .sr-title { font-size: 11px; opacity: .55; white-space: nowrap; }
190
+ .sr-close {
191
+ border: 0;
192
+ background: transparent;
193
+ color: inherit;
194
+ font: inherit;
195
+ font-size: 13px;
196
+ line-height: 1;
197
+ padding: 2px 7px;
198
+ border-radius: 4px;
199
+ cursor: pointer;
200
+ opacity: .5;
201
+ flex-shrink: 0;
202
+ }
203
+ .sr-close:hover { opacity: 1; background: var(--surface-base-hover, rgba(127,127,127,.14)); }
154
204
  .sr-turn {
155
205
  display: flex;
156
206
  align-items: center;
@@ -166,6 +216,7 @@ const CSS = `
166
216
  font: inherit;
167
217
  border-radius: 4px;
168
218
  }
219
+ .sr-turn:focus-visible, .sr-close:focus-visible { outline: 1px solid currentColor; outline-offset: -1px; }
169
220
  .sr-dash {
170
221
  display: block;
171
222
  width: 10px;
@@ -194,6 +245,7 @@ const CSS = `
194
245
  background: transparent;
195
246
  }
196
247
  .sr-rail[data-open="true"] .sr-text { display: block; }
248
+ .sr-num { opacity: .75; margin-right: 5px; font-variant-numeric: tabular-nums; }
197
249
  .sr-turn:hover .sr-text {
198
250
  opacity: 1;
199
251
  background: var(--surface-base-hover, rgba(127,127,127,.14));
@@ -203,6 +255,19 @@ const CSS = `
203
255
  font-weight: 600;
204
256
  background: var(--surface-base-hover, rgba(127,127,127,.14));
205
257
  }
258
+ /* brief highlight of the row a jump landed on */
259
+ .sr-flash-target { animation: srPulse 1.5s ease-out 1; }
260
+ @keyframes srPulse {
261
+ 0%, 30% { background: rgba(255,170,60,.4); }
262
+ 100% { background: transparent; }
263
+ }
264
+ .sr-note {
265
+ display: none;
266
+ font-size: 11px;
267
+ opacity: .4;
268
+ padding: 2px;
269
+ }
270
+ .sr-rail[data-open="true"] .sr-note { display: block; }
206
271
  .sr-status {
207
272
  position: fixed;
208
273
  right: 30px;
@@ -218,7 +283,6 @@ const CSS = `
218
283
  white-space: pre-wrap;
219
284
  max-width: min(720px, 90vw);
220
285
  }
221
- .sr-note { font-size: 11px; opacity: .4; padding: 2px; }
222
286
  `
223
287
 
224
288
  // ---------------------------------------------------------------------------
@@ -233,93 +297,214 @@ function Rail(props) {
233
297
  const [note, setNote] = createSignal("")
234
298
  const [status, setStatus] = createSignal("")
235
299
 
236
- let loading = false
300
+ // ---- turn cache ---------------------------------------------------------
301
+ // Object identity is kept stable across refreshes so Solid's <For> reuses
302
+ // existing DOM instead of rebuilding the whole list on every update.
303
+ const cache = new Map()
304
+ let loadToken = 0
305
+ let loadingActive = false
306
+ let pendingBump = false
237
307
  let loadedFor = ""
238
- let closeTimer
239
- // The memory router's hash is invisible to us (window.location is stale), so
240
- // track the last id we navigated to in order to force a hash change each round.
241
- let lastNavID = ""
308
+ let lightTimer
242
309
 
243
- const load = async (sessionID) => {
244
- if (!sessionID || loading) return
245
- loading = true
246
- const collected = new Map()
247
- let cursor
310
+ const sessionID = () => props.session()?.id
311
+
312
+ const publish = () => {
313
+ const list = [...cache.values()].sort((a, b) => a.created - b.created)
314
+ const prev = untrack(turns)
315
+ // Skip the signal write when nothing changed; <For> diffs by reference.
316
+ if (prev.length === list.length && prev.every((item, i) => item === list[i])) return
317
+ setTurns(list)
318
+ lastTurnCount = list.length
319
+ }
320
+
321
+ const upsert = (item) => {
322
+ const existing = cache.get(item.id)
323
+ if (existing && existing.created === item.time && existing.text === item.text) return
324
+ cache.set(item.id, { id: item.id, created: item.time, text: item.text })
325
+ }
326
+
327
+ const fetchPage = async (id, limit, cursor) => {
328
+ // The server rejects `cursor` together with `order`
329
+ // (InvalidCursorError: "Cursor cannot be combined with order").
330
+ // The cursor itself already encodes the original order/direction,
331
+ // so only the first page declares order: "desc".
332
+ const params = cursor
333
+ ? { sessionID: id, type: "user", limit, cursor }
334
+ : { sessionID: id, type: "user", limit, order: "desc" }
335
+ return await ctx.client.message.list(params)
336
+ }
337
+
338
+ const load = async (id, mode) => {
339
+ const token = ++loadToken
340
+ loadingActive = true
248
341
  let error = ""
249
342
  try {
250
- for (let page = 0; page < 40; page++) {
251
- // The server rejects `cursor` together with `order`
252
- // (InvalidCursorError: "Cursor cannot be combined with order").
253
- // The cursor itself already encodes the original order/direction,
254
- // so only the first page declares order: "desc".
255
- const params = cursor
256
- ? { sessionID, type: "user", limit: 200, cursor }
257
- : { sessionID, type: "user", limit: 200, order: "desc" }
258
-
343
+ let cursor
344
+ const pages = mode === "full" ? FULL_PAGE_LIMIT : 1
345
+ const limit = mode === "full" ? FULL_PAGE_SIZE : LIGHT_PAGE_SIZE
346
+ let cleared = mode !== "full"
347
+ for (let page = 0; page < pages; page++) {
259
348
  let response
260
349
  try {
261
- response = await ctx.client.message.list(params)
350
+ response = await fetchPage(id, limit, cursor)
262
351
  } catch (cause) {
263
352
  error = String(cause?.message ?? cause)
264
353
  break
265
354
  }
266
-
267
- const data = response?.data ?? []
355
+ // Superseded by a newer load (or a session switch): drop this page
356
+ // instead of polluting the new session's cache with stale ids.
357
+ if (token !== loadToken) return
358
+ // Full mode clears only after the first page succeeded, so a server
359
+ // error never blanks out the rail (v10's hard-won lesson).
360
+ if (!cleared) {
361
+ cache.clear()
362
+ cleared = true
363
+ }
268
364
  let fresh = 0
269
- for (const message of data) {
270
- if (collected.has(message.id)) continue
271
- collected.set(message.id, {
272
- id: message.id,
273
- created: message.time?.created ?? 0,
274
- text: firstLine(message),
275
- })
365
+ for (const message of response?.data ?? []) {
366
+ if (cache.has(message.id)) continue
367
+ upsert({ id: message.id, time: message.time?.created ?? 0, text: firstLine(message) })
276
368
  fresh++
277
369
  }
370
+ // Publish page by page: long sessions appear progressively instead of
371
+ // popping in all at once after every page has been fetched.
372
+ publish()
278
373
  const next = response?.cursor?.next
279
374
  if (!next || fresh === 0) break
280
375
  cursor = next
281
376
  }
282
377
  } finally {
283
- loading = false
378
+ loadingActive = false
379
+ loadedFor = id
380
+ lastNote = error
381
+ setNote(cache.size === 0 ? error || "暂无用户消息" : "")
382
+ // A message arrived while a full load was running: catch up now.
383
+ if (pendingBump) {
384
+ pendingBump = false
385
+ scheduleLight()
386
+ }
284
387
  }
388
+ }
285
389
 
286
- // Publish whatever was collected even if a later page failed — a paging
287
- // error must never blank out the whole rail again.
288
- const list = [...collected.values()].sort((a, b) => a.created - b.created)
289
- setTurns(list)
290
- lastTurnCount = list.length
291
- lastNote = error
292
- setNote(list.length === 0 ? error || "暂无用户消息" : "")
293
- loadedFor = sessionID
390
+ // ---- event-driven refresh (replaces the 5s full-list polling) -----------
391
+ // The SDK shares one SSE connection with the host; consuming it here is a
392
+ // second iterator over the same stream, not a second connection. Events are
393
+ // debounced into a single-page light refresh, so streaming bursts cost
394
+ // nothing beyond a few cheap `type` comparisons per event.
395
+ const scheduleLight = () => {
396
+ if (lightTimer) return
397
+ lightTimer = setTimeout(() => {
398
+ lightTimer = undefined
399
+ const id = untrack(sessionID)
400
+ if (id) void load(id, "light")
401
+ }, 300)
294
402
  }
295
403
 
296
- const sessionID = () => props.session()?.id
404
+ const bump = (eventSessionID) => {
405
+ // Only the session currently shown matters; ignore everything else.
406
+ if (!eventSessionID || eventSessionID !== untrack(sessionID)) return
407
+ if (loadingActive) {
408
+ pendingBump = true
409
+ return
410
+ }
411
+ scheduleLight()
412
+ }
413
+
414
+ const handleEvent = (event) => {
415
+ const type = event?.type
416
+ if (type === "message.updated") {
417
+ const data = event?.data ?? {}
418
+ const info = data.info
419
+ // Fast path: the payload carries the message itself, so a new user turn
420
+ // shows up instantly; the debounced light refresh reconciles the rest.
421
+ if (info?.role === "user" && info?.id) {
422
+ upsert({ id: info.id, time: info.time?.created ?? 0, text: firstLine(info) })
423
+ publish()
424
+ }
425
+ bump(data.sessionID)
426
+ } else if (type === "message.removed") {
427
+ const data = event?.data ?? {}
428
+ if (data.messageID && cache.delete(data.messageID)) publish()
429
+ bump(data.sessionID)
430
+ } else if (type === "location.shutdown") {
431
+ // Server rebuilt: do one full reload of the current session.
432
+ const id = untrack(sessionID)
433
+ if (id && loadedFor === id) void load(id, "full")
434
+ }
435
+ }
436
+
437
+ onMount(() => {
438
+ let stopped = false
439
+ let attempt = 0
440
+ let retryTimer
441
+ const run = async () => {
442
+ while (!stopped) {
443
+ const stream = ctx.client.event.subscribe()
444
+ const iterator = stream[Symbol.asyncIterator]()
445
+ try {
446
+ while (!stopped) {
447
+ const next = await iterator.next()
448
+ if (next.done) break
449
+ attempt = 0
450
+ handleEvent(next.value)
451
+ }
452
+ } catch {}
453
+ // iterator.return() cancels the SSE reader (the SDK's generator
454
+ // finally-block); errors from it are irrelevant at this point.
455
+ try {
456
+ await iterator.return?.()
457
+ } catch {}
458
+ if (stopped) break
459
+ attempt++
460
+ await sleep(Math.min(2000 * attempt, 15000))
461
+ }
462
+ }
463
+ void run()
464
+ onCleanup(() => {
465
+ stopped = true
466
+ clearTimeout(retryTimer)
467
+ if (lightTimer) clearTimeout(lightTimer)
468
+ })
469
+ })
297
470
 
298
471
  createEffect(() => {
299
472
  const id = sessionID()
300
473
  if (!id) return
301
474
  untrack(() => {
302
475
  if (loadedFor !== id) {
476
+ // Invalidate anything in flight for the previous session first, so its
477
+ // finally-block can't write `loadedFor`/`note` for the wrong session.
478
+ loadToken++
479
+ loadingActive = false
480
+ pendingBump = false
481
+ cache.clear()
303
482
  setTurns([])
304
483
  setActive("")
305
484
  lastNavID = ""
306
- void load(id)
485
+ void load(id, "full")
307
486
  }
308
487
  })
309
488
  })
310
489
 
311
- const poll = setInterval(() => {
312
- const id = sessionID()
313
- if (id) void load(id)
314
- }, 5000)
315
- onCleanup(() => clearInterval(poll))
490
+ // ---- active-turn tracking ----------------------------------------------
491
+ // Driven by scroll events (captured at document level so it also sees the
492
+ // real scroller without querying first) plus one rAF-throttled pass, with a
493
+ // slow interval as a fallback for rows mounting without a scroll (history
494
+ // paging, virtual list swaps).
495
+ let scrollerCache
496
+ const scroller = () => {
497
+ if (scrollerCache?.isConnected) return scrollerCache
498
+ scrollerCache = findScroller()
499
+ return scrollerCache
500
+ }
316
501
 
317
502
  const trackActive = () => {
318
- const scroller = findScroller()
319
- if (!scroller) return
320
- const rows = [...scroller.querySelectorAll("[data-message-id]")]
503
+ const sc = scroller()
504
+ if (!sc) return
505
+ const rows = sc.querySelectorAll("[data-message-id]")
321
506
  if (rows.length === 0) return
322
- const top = scroller.getBoundingClientRect().top + 40
507
+ const top = sc.getBoundingClientRect().top + 40
323
508
  for (const row of rows) {
324
509
  if (row.getBoundingClientRect().bottom >= top) {
325
510
  const id = row.getAttribute("data-message-id")
@@ -328,32 +513,98 @@ function Rail(props) {
328
513
  }
329
514
  }
330
515
  }
331
- const track = setInterval(trackActive, 400)
332
- onCleanup(() => clearInterval(track))
333
516
 
334
- const flash = (text) => {
517
+ let rafPending = false
518
+ const scheduleTrack = () => {
519
+ if (rafPending) return
520
+ rafPending = true
521
+ requestAnimationFrame(() => {
522
+ rafPending = false
523
+ trackActive()
524
+ })
525
+ }
526
+
527
+ onMount(() => {
528
+ const onScroll = () => scheduleTrack()
529
+ document.addEventListener("scroll", onScroll, true)
530
+ const fallback = setInterval(scheduleTrack, 1500)
531
+ onCleanup(() => {
532
+ document.removeEventListener("scroll", onScroll, true)
533
+ clearInterval(fallback)
534
+ })
535
+ })
536
+
537
+ // ---- status flash -------------------------------------------------------
538
+ // A sequence guard so a slow timeout from an earlier flash can never clear a
539
+ // newer message.
540
+ let flashSeq = 0
541
+ const flash = (text, ms = 2600) => {
542
+ const seq = ++flashSeq
335
543
  setStatus(text)
336
- setTimeout(() => setStatus((current) => (current === text ? "" : current)), 3000)
544
+ setTimeout(() => {
545
+ if (seq === flashSeq) setStatus("")
546
+ }, ms)
337
547
  }
338
548
 
549
+ // ---- open / close interactions ------------------------------------------
339
550
  onMount(() => {
340
- const onDiag = () => flash(`${VERSION} · ${diagnostics(ctx)}`)
341
- const onOpen = () => {
342
- setOpen(true)
343
- clearTimeout(closeTimer)
344
- closeTimer = setTimeout(() => setOpen(false), 4000)
551
+ const onKey = (event) => {
552
+ if (event.key !== "Escape") return
553
+ if (untrack(open)) {
554
+ setOpen(false)
555
+ // Only swallow Esc while the rail is actually open, so the host keeps
556
+ // its own Escape handling the rest of the time.
557
+ event.stopPropagation()
558
+ }
345
559
  }
560
+ const onPointerDown = (event) => {
561
+ if (!untrack(open)) return
562
+ const path = event.composedPath?.() ?? []
563
+ const inside = path.some((node) => node instanceof Element && node.closest?.(".sr-rail"))
564
+ if (!inside) setOpen(false)
565
+ }
566
+ window.addEventListener("keydown", onKey, true)
567
+ document.addEventListener("pointerdown", onPointerDown, true)
568
+ onCleanup(() => {
569
+ window.removeEventListener("keydown", onKey, true)
570
+ document.removeEventListener("pointerdown", onPointerDown, true)
571
+ })
572
+ })
573
+
574
+ onMount(() => {
575
+ const onDiag = () => flash(`${VERSION} · ${diagnostics(ctx)}`)
576
+ // /turns toggles the panel; Esc / outside click / × also close it. No more
577
+ // forced auto-close after 4s.
578
+ const onOpen = () => setOpen((value) => !value)
346
579
  window.addEventListener(DIAG_EVENT, onDiag)
347
580
  window.addEventListener(OPEN_EVENT, onOpen)
348
581
  onCleanup(() => {
349
582
  window.removeEventListener(DIAG_EVENT, onDiag)
350
583
  window.removeEventListener(OPEN_EVENT, onOpen)
351
- clearTimeout(closeTimer)
352
584
  })
353
585
  // One-shot confirmation that this window actually runs the rebuilt bundle.
354
586
  flash(`会话轮次 ${VERSION} 已加载`)
355
587
  })
356
588
 
589
+ // ---- jump ----------------------------------------------------------------
590
+ // The memory router's hash is invisible to us (window.location is stale), so
591
+ // track the last id we navigated to in order to force a hash change each round.
592
+ let lastNavID = ""
593
+ let jumpToken = 0
594
+ let lastJumpAt = 0
595
+ let lastJumpTurnID = ""
596
+
597
+ // Brief highlight on the row a jump landed on. The row may be unmounted by
598
+ // the virtual list right after; the timer then just no-ops.
599
+ const pulse = (row) => {
600
+ try {
601
+ row.classList.remove("sr-flash-target")
602
+ void row.offsetWidth // restart the animation when re-pulsing the same row
603
+ row.classList.add("sr-flash-target")
604
+ setTimeout(() => row.classList.remove("sr-flash-target"), 1600)
605
+ } catch {}
606
+ }
607
+
357
608
  // Oldest turn that is currently mounted (i.e. already paged in), used as a
358
609
  // safe "different id" to toggle the router hash so it re-runs its seek.
359
610
  const oldestLoadedTurn = () => {
@@ -366,14 +617,63 @@ function Rail(props) {
366
617
  lastNavID = id
367
618
  }
368
619
 
620
+ // Resolves when the row enters the DOM (MutationObserver, with a cheap poll
621
+ // as a belt-and-braces fallback), or null on timeout / supersession.
622
+ const waitForRow = (messageID, token, timeout) =>
623
+ new Promise((resolve) => {
624
+ let done = false
625
+ let observer
626
+ let poll
627
+ let timer
628
+ const finish = (row) => {
629
+ if (done) return
630
+ done = true
631
+ try {
632
+ observer?.disconnect()
633
+ } catch {}
634
+ clearInterval(poll)
635
+ clearTimeout(timer)
636
+ resolve(row ?? null)
637
+ }
638
+ const check = () => {
639
+ if (token !== jumpToken) return finish(null)
640
+ const row = findRow(messageID)
641
+ if (row) finish(row)
642
+ }
643
+ const existing = findRow(messageID)
644
+ if (existing) return finish(existing)
645
+ observer = new MutationObserver(check)
646
+ observer.observe(document.documentElement, { childList: true, subtree: true })
647
+ poll = setInterval(check, 120)
648
+ timer = setTimeout(() => finish(findRow(messageID)), Math.max(400, timeout))
649
+ })
650
+
369
651
  const jump = async (turn) => {
652
+ const token = ++jumpToken
370
653
  lastJumpID = turn.id
371
- const scroller = findScroller()
654
+ const now = Date.now()
655
+
656
+ // Same target clicked again shortly after: treat as idempotent re-scroll
657
+ // instead of restarting the paging loop.
658
+ if (turn.id === lastJumpTurnID && now - lastJumpAt < SAME_TARGET_WINDOW_MS) {
659
+ const row = findRow(turn.id)
660
+ const sc = scroller()
661
+ if (row && sc) {
662
+ scrollToRow(sc, row)
663
+ pulse(row)
664
+ return
665
+ }
666
+ }
667
+ lastJumpAt = now
668
+ lastJumpTurnID = turn.id
669
+
670
+ const sc = scroller()
372
671
  const row = findRow(turn.id)
373
672
 
374
673
  // Fast path: the row is mounted, scroll locally.
375
- if (row && scroller) {
376
- scrollToRow(scroller, row)
674
+ if (row && sc) {
675
+ scrollToRow(sc, row)
676
+ pulse(row)
377
677
  flash("已定位")
378
678
  return
379
679
  }
@@ -387,7 +687,10 @@ function Rail(props) {
387
687
  // The host only pages in ~one history page (~5 turns) per seek and then goes
388
688
  // idle. Drive it: each round toggles the hash to an already-loaded turn and
389
689
  // back to the target, so the cursor machine runs another seek/page.
390
- for (let round = 0; round < 30; round++) {
690
+ // A newer click bumps jumpToken, which cancels this loop at every await.
691
+ const deadline = Date.now() + JUMP_TOTAL_MS
692
+ for (let round = 0; round < JUMP_ROUNDS && Date.now() < deadline; round++) {
693
+ if (token !== jumpToken) return
391
694
  flash(round === 0 ? "定位中…" : `加载历史… ${round}`)
392
695
 
393
696
  if (lastNavID === turn.id) {
@@ -397,20 +700,22 @@ function Rail(props) {
397
700
  await sleep(120)
398
701
  }
399
702
  }
703
+ if (token !== jumpToken) return
400
704
  navTo(path, turn.id)
401
705
 
402
- for (let k = 0; k < 6; k++) {
403
- await sleep(80)
404
- const landed = findRow(turn.id)
405
- if (!landed) continue
406
- const live = findScroller() ?? scroller
706
+ const remaining = Math.min(JUMP_ROW_TIMEOUT_MS, deadline - Date.now())
707
+ const landed = await waitForRow(turn.id, token, remaining)
708
+ if (token !== jumpToken) return
709
+ if (landed) {
710
+ const live = scroller() ?? sc
407
711
  if (live) scrollToRow(live, landed)
712
+ pulse(landed)
408
713
  flash("已定位")
409
714
  return
410
715
  }
411
716
  }
412
717
 
413
- flash(`未能定位 · ${diagnostics(ctx)}`)
718
+ if (token === jumpToken) flash(`未能定位 · ${diagnostics(ctx)}`)
414
719
  }
415
720
 
416
721
  const rail = (
@@ -424,9 +729,15 @@ function Rail(props) {
424
729
  onMouseEnter={() => setOpen(true)}
425
730
  onMouseLeave={() => setOpen(false)}
426
731
  >
732
+ <div class="sr-head">
733
+ <span class="sr-title">会话轮次{turns().length > 0 ? ` · ${turns().length}` : ""}</span>
734
+ <button type="button" class="sr-close" title="关闭 (Esc)" onClick={() => setOpen(false)}>
735
+ ×
736
+ </button>
737
+ </div>
427
738
  <Show when={turns().length > 0} fallback={<div class="sr-note">{note() || `… ${VERSION}`}</div>}>
428
739
  <For each={turns()}>
429
- {(turn) => (
740
+ {(turn, index) => (
430
741
  <button
431
742
  type="button"
432
743
  class="sr-turn"
@@ -434,6 +745,7 @@ function Rail(props) {
434
745
  onClick={() => void jump(turn)}
435
746
  >
436
747
  <span class="sr-text">
748
+ <span class="sr-num">#{index() + 1}</span>
437
749
  {formatTime(turn.created)} {turn.text}
438
750
  </span>
439
751
  <span class="sr-dash" />
@@ -486,7 +798,6 @@ export default Plugin.define({
486
798
  slash: { name: "turns" },
487
799
  run: () => {
488
800
  window.dispatchEvent(new Event(OPEN_EVENT))
489
- window.dispatchEvent(new Event(DIAG_EVENT))
490
801
  },
491
802
  }))
492
803
  },