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.
- package/dist/gui-experimental.cjs +58 -6
- package/package.json +1 -1
- package/src/gui.tsx +386 -75
|
@@ -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"),_=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"] {
|
|
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
|
-
.
|
|
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
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 = "
|
|
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"] {
|
|
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
|
-
|
|
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
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
319
|
-
if (!
|
|
320
|
-
const rows =
|
|
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 =
|
|
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
|
-
|
|
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(() =>
|
|
544
|
+
setTimeout(() => {
|
|
545
|
+
if (seq === flashSeq) setStatus("")
|
|
546
|
+
}, ms)
|
|
337
547
|
}
|
|
338
548
|
|
|
549
|
+
// ---- open / close interactions ------------------------------------------
|
|
339
550
|
onMount(() => {
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
|
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 &&
|
|
376
|
-
scrollToRow(
|
|
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
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
const live =
|
|
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
|
},
|