opencode-session-turns 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/dist/gui-experimental.cjs +147 -24
- package/package.json +1 -1
- package/src/gui.tsx +617 -127
package/README.md
CHANGED
|
@@ -20,8 +20,11 @@ click a turn to jump straight to that message.
|
|
|
20
20
|
## 功能
|
|
21
21
|
|
|
22
22
|
- 列出当前会话的每一轮用户提问(从会话消息接口分页拉取,`type=user`)。
|
|
23
|
-
-
|
|
23
|
+
- 鼠标移到窗口右缘的细条上自动展开,移开自动收起;长历史跳转期间面板保持展开。
|
|
24
24
|
- 点击任意一轮,**跳转到对应消息**——包括很久以前、尚未渲染的历史轮次。
|
|
25
|
+
- 深历史需要逐页加载时,头部实时显示「跳转中 · 第 N 页」,目标轮次琥珀色脉冲,
|
|
26
|
+
底部状态条同步提示;按 `Esc` 或点 × 可随时取消。
|
|
27
|
+
- 定位成功后精准描边闪烁**那一条用户气泡**(跟随气泡自身圆角 + 柔光),不再整行填色。
|
|
25
28
|
- 自动高亮当前视口所在的轮次。
|
|
26
29
|
- 斜杠命令 `/turns`、快捷键 `⌘⌥R`(Windows/Linux 为 `Ctrl+Alt+R`)展开轮次条。
|
|
27
30
|
|
|
@@ -62,6 +65,8 @@ OpenCode Desktop 2.2.6 有几个必须绕开的陷阱:
|
|
|
62
65
|
`window.location`。
|
|
63
66
|
3. 宿主每次 seek 只向前加载**约一页(~5 轮)**历史,所以插件会交替把 hash 指向
|
|
64
67
|
「已加载的最早一轮」和目标,逐页把历史翻出来,直到目标行挂载。
|
|
68
|
+
每一轮监听行集合指纹,宿主一旦翻入新一页就立即进入下一轮(不再固定等待),
|
|
69
|
+
并在面板头部显示页码进度;总预算 45 秒,`Esc` 可取消。
|
|
65
70
|
|
|
66
71
|
已挂载的行则直接用本地滚动,瞬时定位。
|
|
67
72
|
|
|
@@ -1,29 +1,85 @@
|
|
|
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 a=require("solid-js/web"),A=require("@opencode/plugin/gui"),i=require("solid-js");var Lt=a.template("<div class=sr-status>"),It=a.template('<span class="sr-title sr-progress">跳转中'),Mt=a.template("<div class=sr-rail><div class=sr-head><button type=button class=sr-close>×</button></div><div class=sr-list>"),Pt=a.template("<span class=sr-title>会话轮次"),Dt=a.template("<div class=sr-note>"),jt=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 G="v12",wt="message-",ht="session-turns:diag",Y="session-turns:open",qt=40,Rt=200,Ot=50,Jt=120,V=45e3,Nt=4e3,zt=300,Ft=800;let xt=0,Z="",X="";const gt=o=>{const n=String(o?.text??"").split(`
|
|
2
|
+
`).map(g=>g.trim()).find(g=>g.length>0);if(n)return n;const f=o?.files?.length??0;return f>0?`[附件 ×${f}]`:"(空消息)"},mt=o=>String(o).padStart(2,"0"),Ht=o=>{const n=new Date(o??0);return`${mt(n.getHours())}:${mt(n.getMinutes())}`},bt=o=>new Promise(n=>setTimeout(n,o)),vt=()=>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 f=getComputedStyle(n);if(/(auto|scroll)/.test(f.overflowY)&&n.scrollHeight>n.clientHeight+1)return n;n=n.parentElement}})(),$=o=>document.getElementById(`${wt}${o}`)??[...document.querySelectorAll("[data-message-id]")].find(n=>n.getAttribute("data-message-id")===o),W=o=>{const n=vt(),f=[...document.querySelectorAll("[data-message-id]")],g=f[0]?.getAttribute("data-message-id")??"",j=X?` · 目标 ${$(X)?"在":"不在"} DOM`:"",C=g?` · 行ID ${g.slice(0,20)}`:"";let E="(空)";try{E=o.router.path()||"(空)"}catch{}return`轮次 ${xt} · 容器 ${n?"OK":"缺失"} · DOM ${f.length} 行${j}${C} · 路由 ${E} · hash ${window.location.hash||"(空)"}${Z?` · ${Z}`:""}`},K=(o,n)=>{n.scrollIntoView({block:"start"});const f=o.querySelector("[data-session-title]"),g=f instanceof HTMLElement?f.offsetHeight:0;g&&(o.scrollTop-=g+12)},Ut=o=>{let n="";try{n=o.router.path()||""}catch{}return n.startsWith("/")?n:""},Bt=o=>{const n=document.createElement("a");n.setAttribute("href",o),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()}},Gt=`
|
|
3
3
|
.sr-rail {
|
|
4
4
|
position: fixed;
|
|
5
5
|
top: 50%;
|
|
6
6
|
transform: translateY(-50%);
|
|
7
|
-
right:
|
|
8
|
-
max-height:
|
|
9
|
-
width:
|
|
7
|
+
right: 14px;
|
|
8
|
+
max-height: 66vh;
|
|
9
|
+
width: 16px;
|
|
10
10
|
display: flex;
|
|
11
11
|
flex-direction: column;
|
|
12
|
-
align-items: flex-end;
|
|
13
12
|
justify-content: center;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
overflow-y: auto;
|
|
17
|
-
overflow-x: hidden;
|
|
18
|
-
scrollbar-width: none;
|
|
13
|
+
padding: 6px 2px;
|
|
14
|
+
overflow: hidden;
|
|
19
15
|
z-index: 400;
|
|
20
16
|
border-radius: 10px;
|
|
17
|
+
border: 1px solid transparent;
|
|
21
18
|
color: var(--text-base, currentColor);
|
|
22
19
|
background: transparent;
|
|
23
|
-
transition: width .18s ease;
|
|
20
|
+
transition: width .18s ease, background .18s ease, box-shadow .18s ease, border-color .18s ease;
|
|
21
|
+
}
|
|
22
|
+
.sr-rail[data-open="true"] {
|
|
23
|
+
width: 340px;
|
|
24
|
+
padding: 10px;
|
|
25
|
+
justify-content: flex-start;
|
|
26
|
+
background: var(--surface-base, rgba(24,24,26,.86));
|
|
27
|
+
backdrop-filter: blur(16px);
|
|
28
|
+
-webkit-backdrop-filter: blur(16px);
|
|
29
|
+
border-color: rgba(127,127,127,.25);
|
|
30
|
+
box-shadow: 0 8px 32px rgba(0,0,0,.28);
|
|
31
|
+
}
|
|
32
|
+
.sr-head {
|
|
33
|
+
display: none;
|
|
34
|
+
align-items: center;
|
|
35
|
+
justify-content: space-between;
|
|
36
|
+
gap: 8px;
|
|
37
|
+
padding: 0 2px 8px;
|
|
38
|
+
margin-bottom: 6px;
|
|
39
|
+
border-bottom: 1px solid rgba(127,127,127,.2);
|
|
40
|
+
flex-shrink: 0;
|
|
41
|
+
}
|
|
42
|
+
.sr-rail[data-open="true"] .sr-head { display: flex; }
|
|
43
|
+
.sr-title { font-size: 12px; opacity: .6; white-space: nowrap; }
|
|
44
|
+
.sr-title.sr-progress {
|
|
45
|
+
opacity: .95;
|
|
46
|
+
color: rgba(255,170,60,.95);
|
|
47
|
+
font-variant-numeric: tabular-nums;
|
|
48
|
+
}
|
|
49
|
+
.sr-close {
|
|
50
|
+
border: 0;
|
|
51
|
+
background: transparent;
|
|
52
|
+
color: inherit;
|
|
53
|
+
font: inherit;
|
|
54
|
+
font-size: 13px;
|
|
55
|
+
line-height: 1;
|
|
56
|
+
padding: 2px 7px;
|
|
57
|
+
border-radius: 4px;
|
|
58
|
+
cursor: pointer;
|
|
59
|
+
opacity: .5;
|
|
60
|
+
flex-shrink: 0;
|
|
61
|
+
}
|
|
62
|
+
.sr-close:hover { opacity: 1; background: var(--surface-base-hover, rgba(127,127,127,.14)); }
|
|
63
|
+
/* the scrollable list; the header stays fixed above it */
|
|
64
|
+
.sr-list {
|
|
65
|
+
flex: 1;
|
|
66
|
+
min-height: 0;
|
|
67
|
+
display: flex;
|
|
68
|
+
flex-direction: column;
|
|
69
|
+
justify-content: center;
|
|
70
|
+
gap: 2px;
|
|
71
|
+
overflow-y: auto;
|
|
72
|
+
overflow-x: hidden;
|
|
73
|
+
scrollbar-width: none;
|
|
74
|
+
}
|
|
75
|
+
.sr-rail[data-open="true"] .sr-list { justify-content: flex-start; gap: 1px; scrollbar-width: thin; }
|
|
76
|
+
.sr-list::-webkit-scrollbar { display: none; }
|
|
77
|
+
.sr-rail[data-open="true"] .sr-list::-webkit-scrollbar { display: block; width: 8px; }
|
|
78
|
+
.sr-list::-webkit-scrollbar-track { background: transparent; }
|
|
79
|
+
.sr-rail[data-open="true"] .sr-list::-webkit-scrollbar-thumb {
|
|
80
|
+
background: rgba(127,127,127,.35);
|
|
81
|
+
border-radius: 4px;
|
|
24
82
|
}
|
|
25
|
-
.sr-rail::-webkit-scrollbar { display: none; }
|
|
26
|
-
.sr-rail[data-open="true"] { width: 360px; background: transparent; }
|
|
27
83
|
.sr-turn {
|
|
28
84
|
display: flex;
|
|
29
85
|
align-items: center;
|
|
@@ -39,6 +95,16 @@
|
|
|
39
95
|
font: inherit;
|
|
40
96
|
border-radius: 4px;
|
|
41
97
|
}
|
|
98
|
+
/* expanded: dash flips to a left-side indicator, content reads left-to-right */
|
|
99
|
+
.sr-rail[data-open="true"] .sr-turn {
|
|
100
|
+
flex-direction: row-reverse;
|
|
101
|
+
justify-content: flex-start;
|
|
102
|
+
gap: 9px;
|
|
103
|
+
padding: 5px 8px;
|
|
104
|
+
min-height: 0;
|
|
105
|
+
border-radius: 6px;
|
|
106
|
+
}
|
|
107
|
+
.sr-turn:focus-visible, .sr-close:focus-visible { outline: 1px solid currentColor; outline-offset: -1px; }
|
|
42
108
|
.sr-dash {
|
|
43
109
|
display: block;
|
|
44
110
|
width: 10px;
|
|
@@ -47,10 +113,13 @@
|
|
|
47
113
|
background: currentColor;
|
|
48
114
|
opacity: .28;
|
|
49
115
|
flex-shrink: 0;
|
|
50
|
-
transition: opacity .12s ease, width .12s ease;
|
|
116
|
+
transition: opacity .12s ease, width .12s ease, height .12s ease;
|
|
51
117
|
}
|
|
52
118
|
.sr-turn:hover .sr-dash,
|
|
53
119
|
.sr-turn[data-active="true"] .sr-dash { opacity: .95; width: 18px; }
|
|
120
|
+
.sr-rail[data-open="true"] .sr-dash { width: 3px; height: 16px; }
|
|
121
|
+
.sr-rail[data-open="true"] .sr-turn:hover .sr-dash,
|
|
122
|
+
.sr-rail[data-open="true"] .sr-turn[data-active="true"] .sr-dash { width: 3px; height: 22px; }
|
|
54
123
|
.sr-text {
|
|
55
124
|
display: none;
|
|
56
125
|
margin-right: 10px;
|
|
@@ -66,16 +135,71 @@
|
|
|
66
135
|
opacity: .45;
|
|
67
136
|
background: transparent;
|
|
68
137
|
}
|
|
69
|
-
.sr-rail[data-open="true"] .sr-text {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
138
|
+
.sr-rail[data-open="true"] .sr-text {
|
|
139
|
+
display: -webkit-box;
|
|
140
|
+
-webkit-line-clamp: 2;
|
|
141
|
+
-webkit-box-orient: vertical;
|
|
142
|
+
overflow: hidden;
|
|
143
|
+
flex: 1;
|
|
144
|
+
min-width: 0;
|
|
145
|
+
margin: 0;
|
|
146
|
+
padding: 0;
|
|
147
|
+
text-align: left;
|
|
148
|
+
white-space: normal;
|
|
149
|
+
line-height: 1.45;
|
|
150
|
+
max-width: none;
|
|
151
|
+
opacity: .8;
|
|
152
|
+
}
|
|
153
|
+
.sr-num, .sr-time {
|
|
154
|
+
opacity: .55;
|
|
155
|
+
margin-right: 6px;
|
|
156
|
+
font-variant-numeric: tabular-nums;
|
|
73
157
|
}
|
|
74
|
-
.sr-turn
|
|
158
|
+
.sr-turn:hover { background: var(--surface-base-hover, rgba(127,127,127,.14)); }
|
|
159
|
+
.sr-rail[data-open="true"] .sr-turn:hover .sr-text,
|
|
160
|
+
.sr-rail[data-open="true"] .sr-turn[data-active="true"] .sr-text { opacity: 1; }
|
|
161
|
+
.sr-turn[data-active="true"] { font-weight: 500; }
|
|
162
|
+
/* the turn currently being jumped to */
|
|
163
|
+
.sr-turn[data-busy="true"] { background: rgba(255,170,60,.12); }
|
|
164
|
+
.sr-turn[data-busy="true"] .sr-dash {
|
|
75
165
|
opacity: 1;
|
|
76
|
-
|
|
77
|
-
|
|
166
|
+
width: 3px;
|
|
167
|
+
height: 22px;
|
|
168
|
+
background: rgba(255,170,60,.95);
|
|
169
|
+
animation: srBusy 1s ease-in-out infinite;
|
|
170
|
+
}
|
|
171
|
+
.sr-turn[data-busy="false"] .sr-dash { animation: none; }
|
|
172
|
+
@keyframes srBusy {
|
|
173
|
+
0%, 100% { opacity: .35; }
|
|
174
|
+
50% { opacity: 1; }
|
|
175
|
+
}
|
|
176
|
+
/* pulse a landed bubble: outline follows the bubble's own border-radius, so no
|
|
177
|
+
shape is imposed and the message background stays untouched */
|
|
178
|
+
.sr-flash-ring { animation: srRing 1.8s ease-out 1; }
|
|
179
|
+
@keyframes srRing {
|
|
180
|
+
0% {
|
|
181
|
+
outline: 2px solid rgba(255,170,60,.95);
|
|
182
|
+
outline-offset: 2px;
|
|
183
|
+
box-shadow: 0 0 24px rgba(255,170,60,.45);
|
|
184
|
+
}
|
|
185
|
+
60% {
|
|
186
|
+
outline: 2px solid rgba(255,170,60,.35);
|
|
187
|
+
outline-offset: 2px;
|
|
188
|
+
box-shadow: 0 0 12px rgba(255,170,60,.15);
|
|
189
|
+
}
|
|
190
|
+
100% {
|
|
191
|
+
outline: 2px solid rgba(255,170,60,0);
|
|
192
|
+
outline-offset: 2px;
|
|
193
|
+
box-shadow: 0 0 0 rgba(255,170,60,0);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
.sr-note {
|
|
197
|
+
display: none;
|
|
198
|
+
font-size: 11px;
|
|
199
|
+
opacity: .4;
|
|
200
|
+
padding: 2px;
|
|
78
201
|
}
|
|
202
|
+
.sr-rail[data-open="true"] .sr-note { display: block; }
|
|
79
203
|
.sr-status {
|
|
80
204
|
position: fixed;
|
|
81
205
|
right: 30px;
|
|
@@ -91,5 +215,4 @@
|
|
|
91
215
|
white-space: pre-wrap;
|
|
92
216
|
max-width: min(720px, 90vw);
|
|
93
217
|
}
|
|
94
|
-
.sr-
|
|
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;
|
|
218
|
+
`;function Vt(o){const n=A.usePlugin(),[f,g]=i.createSignal([]),[j,C]=i.createSignal(""),[E,S]=i.createSignal(!1),[yt,kt]=i.createSignal(""),[Q,tt]=i.createSignal(""),[x,q]=i.createSignal(!1),[et,L]=i.createSignal(0),[rt,R]=i.createSignal(""),v=new Map;let O=0,I=!1,M=!1,J="",_;const P=()=>o.session()?.id,N=()=>{const t=[...v.values()].sort((r,l)=>r.created-l.created),e=i.untrack(f);e.length===t.length&&e.every((r,l)=>r===t[l])||(g(t),xt=t.length)},nt=t=>{const e=v.get(t.id);e&&e.created===t.time&&e.text===t.text||v.set(t.id,{id:t.id,created:t.time,text:t.text})},St=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=++O;I=!0;let l="";try{let s;const c=e==="full"?qt:1,d=e==="full"?Rt:Ot;let p=e!=="full";for(let b=0;b<c;b++){let m;try{m=await St(t,d,s)}catch(h){l=String(h?.message??h);break}if(r!==O)return;p||(v.clear(),p=!0);let u=0;for(const h of m?.data??[])v.has(h.id)||(nt({id:h.id,time:h.time?.created??0,text:gt(h)}),u++);N();const k=m?.cursor?.next;if(!k||u===0)break;s=k}}finally{I=!1,J=t,Z=l,kt(v.size===0?l||"暂无用户消息":""),M&&(M=!1,st())}},st=()=>{_||(_=setTimeout(()=>{_=void 0;const t=i.untrack(P);t&&z(t,"light")},300))},at=t=>{if(!(!t||t!==i.untrack(P))){if(I){M=!0;return}st()}},$t=t=>{const e=t?.type;if(e==="message.updated"){const r=t?.data??{},l=r.info;l?.role==="user"&&l?.id&&(nt({id:l.id,time:l.time?.created??0,text:gt(l)}),N()),at(r.sessionID)}else if(e==="message.removed"){const r=t?.data??{};r.messageID&&v.delete(r.messageID)&&N(),at(r.sessionID)}else if(e==="location.shutdown"){const r=i.untrack(P);r&&J===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 d=await c.next();if(d.done)break;e=0,$t(d.value)}}catch{}try{await c.return?.()}catch{}if(t)break;e++,await bt(Math.min(2e3*e,15e3))}})(),i.onCleanup(()=>{t=!0,clearTimeout(r),_&&clearTimeout(_)})}),i.createEffect(()=>{const t=P();t&&i.untrack(()=>{J!==t&&(O++,I=!1,M=!1,v.clear(),g([]),C(""),H="",z(t,"full"))})});let D;const T=()=>(D?.isConnected||(D=vt()),D),Et=()=>{const t=T();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 s=l.getAttribute("data-message-id");s&&C(s);break}};let F=!1;const ot=()=>{F||(F=!0,requestAnimationFrame(()=>{F=!1,Et()}))};i.onMount(()=>{const t=()=>ot();document.addEventListener("scroll",t,!0);const e=setInterval(ot,1500);i.onCleanup(()=>{document.removeEventListener("scroll",t,!0),clearInterval(e)})});let it=0;const w=(t,e=2600)=>{const r=++it;tt(t),setTimeout(()=>{r===it&&tt("")},e)};i.onMount(()=>{const t=r=>{if(r.key==="Escape"){if(i.untrack(x)){pt(),r.stopPropagation();return}i.untrack(E)&&(S(!1),r.stopPropagation())}},e=r=>{if(!i.untrack(E))return;(r.composedPath?.()??[]).some(c=>c instanceof Element&&c.closest?.(".sr-rail"))||S(!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=()=>w(`${G} · ${W(n)}`),e=()=>S(r=>!r);window.addEventListener(ht,t),window.addEventListener(Y,e),i.onCleanup(()=>{window.removeEventListener(ht,t),window.removeEventListener(Y,e)}),w(`会话轮次 ${G} 已加载`)});let H="",y=0,lt=0,ct="";const U=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{}},_t=()=>{for(const t of f())if($(t.id))return t},dt=(t,e)=>{Bt(`${t}#${wt}${e}`),H=e},ut=()=>{const e=T()?.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}`},Tt=(t,e,r)=>new Promise(l=>{let s=!1,c,d,p;const b=Date.now(),m=ut(),u=B=>{if(!s){s=!0;try{c?.disconnect()}catch{}clearInterval(d),clearTimeout(p),l(B??null)}},k=B=>{if(e!==y)return u(null);const ft=$(t);if(ft)return u(ft);B&&Date.now()-b>zt&&ut()!==m&&u(null)},h=$(t);if(h)return u(h);c=new MutationObserver(()=>k(!1)),c.observe(document.documentElement,{childList:!0,subtree:!0}),d=setInterval(()=>k(!0),120),p=setTimeout(()=>u($(t)),Math.max(400,r))}),pt=()=>{y++,q(!1),L(0),R(""),w("已取消跳转")},At=async t=>{const e=++y;X=t.id;const r=Date.now();if(t.id===ct&&r-lt<Ft){const d=$(t.id),p=T();if(d&&p){K(p,d),U(d);return}}lt=r,ct=t.id;const l=T(),s=$(t.id);if(s&&l){K(l,s),U(s),w("已定位");return}const c=Ut(n);if(!c){w(`无法读取当前路由,无法定位 · ${W(n)}`);return}q(!0),R(t.id),L(0),S(!0),w("正在定位…",V);try{const d=Date.now()+V;for(let p=0;p<Jt&&Date.now()<d;p++){if(e!==y)return;if(L(p+1),w(`跳转中 · 第 ${p+1} 页`,V),H===t.id){const u=_t()??f()[0];u&&(dt(c,u.id),await bt(120))}if(e!==y)return;dt(c,t.id);const b=Math.min(Nt,d-Date.now()),m=await Tt(t.id,e,b);if(e!==y)return;if(m){const u=T()??l;u&&K(u,m),U(m),w("已定位");return}}e===y&&w(`未能定位 · ${W(n)}`)}finally{e===y&&(q(!1),L(0),R(""))}};i.createEffect(()=>{x()&&rt()&&requestAnimationFrame(()=>{try{document.querySelector('.sr-list .sr-turn[data-busy="true"]')?.scrollIntoView({block:"nearest"})}catch{}})});const Ct=[a.createComponent(i.Show,{get when(){return Q()},get children(){var t=Lt();return a.insert(t,Q),t}}),(()=>{var t=Mt(),e=t.firstChild,r=e.firstChild,l=e.nextSibling;return t.addEventListener("mouseleave",()=>{x()||S(!1)}),t.addEventListener("mouseenter",()=>S(!0)),a.insert(e,a.createComponent(i.Show,{get when(){return x()},get fallback(){return(()=>{var s=Pt();return s.firstChild,a.insert(s,(()=>{var c=a.memo(()=>f().length>0);return()=>c()?` · ${f().length}`:""})(),null),s})()},get children(){var s=It();return s.firstChild,a.insert(s,(()=>{var c=a.memo(()=>et()>0);return()=>c()?` · 第 ${et()} 页`:""})(),null),s}}),r),r.$$click=()=>{x()?pt():S(!1)},a.insert(l,a.createComponent(i.Show,{get when(){return f().length>0},get fallback(){return(()=>{var s=Dt();return a.insert(s,()=>yt()||`… ${G}`),s})()},get children(){return a.createComponent(i.For,{get each(){return f()},children:(s,c)=>(()=>{var d=jt(),p=d.firstChild,b=p.firstChild;b.firstChild;var m=b.nextSibling;return d.$$click=()=>{At(s)},a.insert(b,()=>c()+1,null),a.insert(m,()=>Ht(s.created)),a.insert(p,()=>s.text,null),a.effect(u=>{var k=j()===s.id?"true":"false",h=x()&&rt()===s.id?"true":"false";return k!==u.e&&a.setAttribute(d,"data-active",u.e=k),h!==u.t&&a.setAttribute(d,"data-busy",u.t=h),u},{e:void 0,t:void 0}),d})()})}})),a.effect(s=>{var c=E()?"true":"false",d=x()?"true":"false",p=x()?"取消跳转 (Esc)":"关闭 (Esc)";return c!==s.e&&a.setAttribute(t,"data-open",s.e=c),d!==s.t&&a.setAttribute(t,"data-jumping",s.t=d),p!==s.a&&a.setAttribute(r,"title",s.a=p),s},{e:void 0,t:void 0,a:void 0}),t})()];return a.createComponent(a.Portal,{get mount(){return document.body},children:Ct})}const Wt=A.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(A.Style,Gt),o.add(A.Slot,{at:"window.bottom",render:()=>a.createComponent(i.Show,{get when(){return o.sessions.current()},get children(){return a.createComponent(Vt,{session:()=>o.sessions.current()})}})}),o.add(A.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(Y))}}))}});a.delegateEvents(["click"]);exports.default=Wt;
|
package/package.json
CHANGED
package/src/gui.tsx
CHANGED
|
@@ -6,11 +6,26 @@ import { Portal } from "solid-js/web"
|
|
|
6
6
|
// constants / helpers
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
|
|
9
|
-
const VERSION = "
|
|
9
|
+
const VERSION = "v12"
|
|
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. One round pages in roughly one host history page (~5
|
|
21
|
+
// turns); a round ends as soon as the host appends rows, so the loop is paced
|
|
22
|
+
// by real progress instead of a fixed sleep.
|
|
23
|
+
const JUMP_ROUNDS = 120
|
|
24
|
+
const JUMP_TOTAL_MS = 45000
|
|
25
|
+
const JUMP_ROW_TIMEOUT_MS = 4000
|
|
26
|
+
const JUMP_SETTLE_MS = 300
|
|
27
|
+
const SAME_TARGET_WINDOW_MS = 800
|
|
28
|
+
|
|
14
29
|
// Filled by Rail.load()/jump() so the command palette can report diagnostics.
|
|
15
30
|
let lastTurnCount = 0
|
|
16
31
|
let lastNote = ""
|
|
@@ -131,26 +146,82 @@ const CSS = `
|
|
|
131
146
|
position: fixed;
|
|
132
147
|
top: 50%;
|
|
133
148
|
transform: translateY(-50%);
|
|
134
|
-
right:
|
|
135
|
-
max-height:
|
|
136
|
-
width:
|
|
149
|
+
right: 14px;
|
|
150
|
+
max-height: 66vh;
|
|
151
|
+
width: 16px;
|
|
137
152
|
display: flex;
|
|
138
153
|
flex-direction: column;
|
|
139
|
-
align-items: flex-end;
|
|
140
154
|
justify-content: center;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
overflow-y: auto;
|
|
144
|
-
overflow-x: hidden;
|
|
145
|
-
scrollbar-width: none;
|
|
155
|
+
padding: 6px 2px;
|
|
156
|
+
overflow: hidden;
|
|
146
157
|
z-index: 400;
|
|
147
158
|
border-radius: 10px;
|
|
159
|
+
border: 1px solid transparent;
|
|
148
160
|
color: var(--text-base, currentColor);
|
|
149
161
|
background: transparent;
|
|
150
|
-
transition: width .18s ease;
|
|
162
|
+
transition: width .18s ease, background .18s ease, box-shadow .18s ease, border-color .18s ease;
|
|
163
|
+
}
|
|
164
|
+
.sr-rail[data-open="true"] {
|
|
165
|
+
width: 340px;
|
|
166
|
+
padding: 10px;
|
|
167
|
+
justify-content: flex-start;
|
|
168
|
+
background: var(--surface-base, rgba(24,24,26,.86));
|
|
169
|
+
backdrop-filter: blur(16px);
|
|
170
|
+
-webkit-backdrop-filter: blur(16px);
|
|
171
|
+
border-color: rgba(127,127,127,.25);
|
|
172
|
+
box-shadow: 0 8px 32px rgba(0,0,0,.28);
|
|
173
|
+
}
|
|
174
|
+
.sr-head {
|
|
175
|
+
display: none;
|
|
176
|
+
align-items: center;
|
|
177
|
+
justify-content: space-between;
|
|
178
|
+
gap: 8px;
|
|
179
|
+
padding: 0 2px 8px;
|
|
180
|
+
margin-bottom: 6px;
|
|
181
|
+
border-bottom: 1px solid rgba(127,127,127,.2);
|
|
182
|
+
flex-shrink: 0;
|
|
183
|
+
}
|
|
184
|
+
.sr-rail[data-open="true"] .sr-head { display: flex; }
|
|
185
|
+
.sr-title { font-size: 12px; opacity: .6; white-space: nowrap; }
|
|
186
|
+
.sr-title.sr-progress {
|
|
187
|
+
opacity: .95;
|
|
188
|
+
color: rgba(255,170,60,.95);
|
|
189
|
+
font-variant-numeric: tabular-nums;
|
|
190
|
+
}
|
|
191
|
+
.sr-close {
|
|
192
|
+
border: 0;
|
|
193
|
+
background: transparent;
|
|
194
|
+
color: inherit;
|
|
195
|
+
font: inherit;
|
|
196
|
+
font-size: 13px;
|
|
197
|
+
line-height: 1;
|
|
198
|
+
padding: 2px 7px;
|
|
199
|
+
border-radius: 4px;
|
|
200
|
+
cursor: pointer;
|
|
201
|
+
opacity: .5;
|
|
202
|
+
flex-shrink: 0;
|
|
203
|
+
}
|
|
204
|
+
.sr-close:hover { opacity: 1; background: var(--surface-base-hover, rgba(127,127,127,.14)); }
|
|
205
|
+
/* the scrollable list; the header stays fixed above it */
|
|
206
|
+
.sr-list {
|
|
207
|
+
flex: 1;
|
|
208
|
+
min-height: 0;
|
|
209
|
+
display: flex;
|
|
210
|
+
flex-direction: column;
|
|
211
|
+
justify-content: center;
|
|
212
|
+
gap: 2px;
|
|
213
|
+
overflow-y: auto;
|
|
214
|
+
overflow-x: hidden;
|
|
215
|
+
scrollbar-width: none;
|
|
216
|
+
}
|
|
217
|
+
.sr-rail[data-open="true"] .sr-list { justify-content: flex-start; gap: 1px; scrollbar-width: thin; }
|
|
218
|
+
.sr-list::-webkit-scrollbar { display: none; }
|
|
219
|
+
.sr-rail[data-open="true"] .sr-list::-webkit-scrollbar { display: block; width: 8px; }
|
|
220
|
+
.sr-list::-webkit-scrollbar-track { background: transparent; }
|
|
221
|
+
.sr-rail[data-open="true"] .sr-list::-webkit-scrollbar-thumb {
|
|
222
|
+
background: rgba(127,127,127,.35);
|
|
223
|
+
border-radius: 4px;
|
|
151
224
|
}
|
|
152
|
-
.sr-rail::-webkit-scrollbar { display: none; }
|
|
153
|
-
.sr-rail[data-open="true"] { width: 360px; background: transparent; }
|
|
154
225
|
.sr-turn {
|
|
155
226
|
display: flex;
|
|
156
227
|
align-items: center;
|
|
@@ -166,6 +237,16 @@ const CSS = `
|
|
|
166
237
|
font: inherit;
|
|
167
238
|
border-radius: 4px;
|
|
168
239
|
}
|
|
240
|
+
/* expanded: dash flips to a left-side indicator, content reads left-to-right */
|
|
241
|
+
.sr-rail[data-open="true"] .sr-turn {
|
|
242
|
+
flex-direction: row-reverse;
|
|
243
|
+
justify-content: flex-start;
|
|
244
|
+
gap: 9px;
|
|
245
|
+
padding: 5px 8px;
|
|
246
|
+
min-height: 0;
|
|
247
|
+
border-radius: 6px;
|
|
248
|
+
}
|
|
249
|
+
.sr-turn:focus-visible, .sr-close:focus-visible { outline: 1px solid currentColor; outline-offset: -1px; }
|
|
169
250
|
.sr-dash {
|
|
170
251
|
display: block;
|
|
171
252
|
width: 10px;
|
|
@@ -174,10 +255,13 @@ const CSS = `
|
|
|
174
255
|
background: currentColor;
|
|
175
256
|
opacity: .28;
|
|
176
257
|
flex-shrink: 0;
|
|
177
|
-
transition: opacity .12s ease, width .12s ease;
|
|
258
|
+
transition: opacity .12s ease, width .12s ease, height .12s ease;
|
|
178
259
|
}
|
|
179
260
|
.sr-turn:hover .sr-dash,
|
|
180
261
|
.sr-turn[data-active="true"] .sr-dash { opacity: .95; width: 18px; }
|
|
262
|
+
.sr-rail[data-open="true"] .sr-dash { width: 3px; height: 16px; }
|
|
263
|
+
.sr-rail[data-open="true"] .sr-turn:hover .sr-dash,
|
|
264
|
+
.sr-rail[data-open="true"] .sr-turn[data-active="true"] .sr-dash { width: 3px; height: 22px; }
|
|
181
265
|
.sr-text {
|
|
182
266
|
display: none;
|
|
183
267
|
margin-right: 10px;
|
|
@@ -193,16 +277,71 @@ const CSS = `
|
|
|
193
277
|
opacity: .45;
|
|
194
278
|
background: transparent;
|
|
195
279
|
}
|
|
196
|
-
.sr-rail[data-open="true"] .sr-text {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
280
|
+
.sr-rail[data-open="true"] .sr-text {
|
|
281
|
+
display: -webkit-box;
|
|
282
|
+
-webkit-line-clamp: 2;
|
|
283
|
+
-webkit-box-orient: vertical;
|
|
284
|
+
overflow: hidden;
|
|
285
|
+
flex: 1;
|
|
286
|
+
min-width: 0;
|
|
287
|
+
margin: 0;
|
|
288
|
+
padding: 0;
|
|
289
|
+
text-align: left;
|
|
290
|
+
white-space: normal;
|
|
291
|
+
line-height: 1.45;
|
|
292
|
+
max-width: none;
|
|
293
|
+
opacity: .8;
|
|
294
|
+
}
|
|
295
|
+
.sr-num, .sr-time {
|
|
296
|
+
opacity: .55;
|
|
297
|
+
margin-right: 6px;
|
|
298
|
+
font-variant-numeric: tabular-nums;
|
|
200
299
|
}
|
|
201
|
-
.sr-turn
|
|
300
|
+
.sr-turn:hover { background: var(--surface-base-hover, rgba(127,127,127,.14)); }
|
|
301
|
+
.sr-rail[data-open="true"] .sr-turn:hover .sr-text,
|
|
302
|
+
.sr-rail[data-open="true"] .sr-turn[data-active="true"] .sr-text { opacity: 1; }
|
|
303
|
+
.sr-turn[data-active="true"] { font-weight: 500; }
|
|
304
|
+
/* the turn currently being jumped to */
|
|
305
|
+
.sr-turn[data-busy="true"] { background: rgba(255,170,60,.12); }
|
|
306
|
+
.sr-turn[data-busy="true"] .sr-dash {
|
|
202
307
|
opacity: 1;
|
|
203
|
-
|
|
204
|
-
|
|
308
|
+
width: 3px;
|
|
309
|
+
height: 22px;
|
|
310
|
+
background: rgba(255,170,60,.95);
|
|
311
|
+
animation: srBusy 1s ease-in-out infinite;
|
|
312
|
+
}
|
|
313
|
+
.sr-turn[data-busy="false"] .sr-dash { animation: none; }
|
|
314
|
+
@keyframes srBusy {
|
|
315
|
+
0%, 100% { opacity: .35; }
|
|
316
|
+
50% { opacity: 1; }
|
|
205
317
|
}
|
|
318
|
+
/* pulse a landed bubble: outline follows the bubble's own border-radius, so no
|
|
319
|
+
shape is imposed and the message background stays untouched */
|
|
320
|
+
.sr-flash-ring { animation: srRing 1.8s ease-out 1; }
|
|
321
|
+
@keyframes srRing {
|
|
322
|
+
0% {
|
|
323
|
+
outline: 2px solid rgba(255,170,60,.95);
|
|
324
|
+
outline-offset: 2px;
|
|
325
|
+
box-shadow: 0 0 24px rgba(255,170,60,.45);
|
|
326
|
+
}
|
|
327
|
+
60% {
|
|
328
|
+
outline: 2px solid rgba(255,170,60,.35);
|
|
329
|
+
outline-offset: 2px;
|
|
330
|
+
box-shadow: 0 0 12px rgba(255,170,60,.15);
|
|
331
|
+
}
|
|
332
|
+
100% {
|
|
333
|
+
outline: 2px solid rgba(255,170,60,0);
|
|
334
|
+
outline-offset: 2px;
|
|
335
|
+
box-shadow: 0 0 0 rgba(255,170,60,0);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
.sr-note {
|
|
339
|
+
display: none;
|
|
340
|
+
font-size: 11px;
|
|
341
|
+
opacity: .4;
|
|
342
|
+
padding: 2px;
|
|
343
|
+
}
|
|
344
|
+
.sr-rail[data-open="true"] .sr-note { display: block; }
|
|
206
345
|
.sr-status {
|
|
207
346
|
position: fixed;
|
|
208
347
|
right: 30px;
|
|
@@ -218,7 +357,6 @@ const CSS = `
|
|
|
218
357
|
white-space: pre-wrap;
|
|
219
358
|
max-width: min(720px, 90vw);
|
|
220
359
|
}
|
|
221
|
-
.sr-note { font-size: 11px; opacity: .4; padding: 2px; }
|
|
222
360
|
`
|
|
223
361
|
|
|
224
362
|
// ---------------------------------------------------------------------------
|
|
@@ -232,94 +370,220 @@ function Rail(props) {
|
|
|
232
370
|
const [open, setOpen] = createSignal(false)
|
|
233
371
|
const [note, setNote] = createSignal("")
|
|
234
372
|
const [status, setStatus] = createSignal("")
|
|
235
|
-
|
|
236
|
-
|
|
373
|
+
// Jump progress. While `jumping` is true the panel stays pinned open and the
|
|
374
|
+
// header reports the page being loaded; `busyID` marks the target row.
|
|
375
|
+
const [jumping, setJumping] = createSignal(false)
|
|
376
|
+
const [jumpPage, setJumpPage] = createSignal(0)
|
|
377
|
+
const [busyID, setBusyID] = createSignal("")
|
|
378
|
+
|
|
379
|
+
// ---- turn cache ---------------------------------------------------------
|
|
380
|
+
// Object identity is kept stable across refreshes so Solid's <For> reuses
|
|
381
|
+
// existing DOM instead of rebuilding the whole list on every update.
|
|
382
|
+
const cache = new Map()
|
|
383
|
+
let loadToken = 0
|
|
384
|
+
let loadingActive = false
|
|
385
|
+
let pendingBump = false
|
|
237
386
|
let loadedFor = ""
|
|
238
|
-
let
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
let lastNavID = ""
|
|
387
|
+
let lightTimer
|
|
388
|
+
|
|
389
|
+
const sessionID = () => props.session()?.id
|
|
242
390
|
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
391
|
+
const publish = () => {
|
|
392
|
+
const list = [...cache.values()].sort((a, b) => a.created - b.created)
|
|
393
|
+
const prev = untrack(turns)
|
|
394
|
+
// Skip the signal write when nothing changed; <For> diffs by reference.
|
|
395
|
+
if (prev.length === list.length && prev.every((item, i) => item === list[i])) return
|
|
396
|
+
setTurns(list)
|
|
397
|
+
lastTurnCount = list.length
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const upsert = (item) => {
|
|
401
|
+
const existing = cache.get(item.id)
|
|
402
|
+
if (existing && existing.created === item.time && existing.text === item.text) return
|
|
403
|
+
cache.set(item.id, { id: item.id, created: item.time, text: item.text })
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const fetchPage = async (id, limit, cursor) => {
|
|
407
|
+
// The server rejects `cursor` together with `order`
|
|
408
|
+
// (InvalidCursorError: "Cursor cannot be combined with order").
|
|
409
|
+
// The cursor itself already encodes the original order/direction,
|
|
410
|
+
// so only the first page declares order: "desc".
|
|
411
|
+
const params = cursor
|
|
412
|
+
? { sessionID: id, type: "user", limit, cursor }
|
|
413
|
+
: { sessionID: id, type: "user", limit, order: "desc" }
|
|
414
|
+
return await ctx.client.message.list(params)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const load = async (id, mode) => {
|
|
418
|
+
const token = ++loadToken
|
|
419
|
+
loadingActive = true
|
|
248
420
|
let error = ""
|
|
249
421
|
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
|
-
|
|
422
|
+
let cursor
|
|
423
|
+
const pages = mode === "full" ? FULL_PAGE_LIMIT : 1
|
|
424
|
+
const limit = mode === "full" ? FULL_PAGE_SIZE : LIGHT_PAGE_SIZE
|
|
425
|
+
let cleared = mode !== "full"
|
|
426
|
+
for (let page = 0; page < pages; page++) {
|
|
259
427
|
let response
|
|
260
428
|
try {
|
|
261
|
-
response = await
|
|
429
|
+
response = await fetchPage(id, limit, cursor)
|
|
262
430
|
} catch (cause) {
|
|
263
431
|
error = String(cause?.message ?? cause)
|
|
264
432
|
break
|
|
265
433
|
}
|
|
266
|
-
|
|
267
|
-
|
|
434
|
+
// Superseded by a newer load (or a session switch): drop this page
|
|
435
|
+
// instead of polluting the new session's cache with stale ids.
|
|
436
|
+
if (token !== loadToken) return
|
|
437
|
+
// Full mode clears only after the first page succeeded, so a server
|
|
438
|
+
// error never blanks out the rail (v10's hard-won lesson).
|
|
439
|
+
if (!cleared) {
|
|
440
|
+
cache.clear()
|
|
441
|
+
cleared = true
|
|
442
|
+
}
|
|
268
443
|
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
|
-
})
|
|
444
|
+
for (const message of response?.data ?? []) {
|
|
445
|
+
if (cache.has(message.id)) continue
|
|
446
|
+
upsert({ id: message.id, time: message.time?.created ?? 0, text: firstLine(message) })
|
|
276
447
|
fresh++
|
|
277
448
|
}
|
|
449
|
+
// Publish page by page: long sessions appear progressively instead of
|
|
450
|
+
// popping in all at once after every page has been fetched.
|
|
451
|
+
publish()
|
|
278
452
|
const next = response?.cursor?.next
|
|
279
453
|
if (!next || fresh === 0) break
|
|
280
454
|
cursor = next
|
|
281
455
|
}
|
|
282
456
|
} finally {
|
|
283
|
-
|
|
457
|
+
loadingActive = false
|
|
458
|
+
loadedFor = id
|
|
459
|
+
lastNote = error
|
|
460
|
+
setNote(cache.size === 0 ? error || "暂无用户消息" : "")
|
|
461
|
+
// A message arrived while a full load was running: catch up now.
|
|
462
|
+
if (pendingBump) {
|
|
463
|
+
pendingBump = false
|
|
464
|
+
scheduleLight()
|
|
465
|
+
}
|
|
284
466
|
}
|
|
467
|
+
}
|
|
285
468
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
469
|
+
// ---- event-driven refresh (replaces the 5s full-list polling) -----------
|
|
470
|
+
// The SDK shares one SSE connection with the host; consuming it here is a
|
|
471
|
+
// second iterator over the same stream, not a second connection. Events are
|
|
472
|
+
// debounced into a single-page light refresh, so streaming bursts cost
|
|
473
|
+
// nothing beyond a few cheap `type` comparisons per event.
|
|
474
|
+
const scheduleLight = () => {
|
|
475
|
+
if (lightTimer) return
|
|
476
|
+
lightTimer = setTimeout(() => {
|
|
477
|
+
lightTimer = undefined
|
|
478
|
+
const id = untrack(sessionID)
|
|
479
|
+
if (id) void load(id, "light")
|
|
480
|
+
}, 300)
|
|
294
481
|
}
|
|
295
482
|
|
|
296
|
-
const
|
|
483
|
+
const bump = (eventSessionID) => {
|
|
484
|
+
// Only the session currently shown matters; ignore everything else.
|
|
485
|
+
if (!eventSessionID || eventSessionID !== untrack(sessionID)) return
|
|
486
|
+
if (loadingActive) {
|
|
487
|
+
pendingBump = true
|
|
488
|
+
return
|
|
489
|
+
}
|
|
490
|
+
scheduleLight()
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const handleEvent = (event) => {
|
|
494
|
+
const type = event?.type
|
|
495
|
+
if (type === "message.updated") {
|
|
496
|
+
const data = event?.data ?? {}
|
|
497
|
+
const info = data.info
|
|
498
|
+
// Fast path: the payload carries the message itself, so a new user turn
|
|
499
|
+
// shows up instantly; the debounced light refresh reconciles the rest.
|
|
500
|
+
if (info?.role === "user" && info?.id) {
|
|
501
|
+
upsert({ id: info.id, time: info.time?.created ?? 0, text: firstLine(info) })
|
|
502
|
+
publish()
|
|
503
|
+
}
|
|
504
|
+
bump(data.sessionID)
|
|
505
|
+
} else if (type === "message.removed") {
|
|
506
|
+
const data = event?.data ?? {}
|
|
507
|
+
if (data.messageID && cache.delete(data.messageID)) publish()
|
|
508
|
+
bump(data.sessionID)
|
|
509
|
+
} else if (type === "location.shutdown") {
|
|
510
|
+
// Server rebuilt: do one full reload of the current session.
|
|
511
|
+
const id = untrack(sessionID)
|
|
512
|
+
if (id && loadedFor === id) void load(id, "full")
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
onMount(() => {
|
|
517
|
+
let stopped = false
|
|
518
|
+
let attempt = 0
|
|
519
|
+
let retryTimer
|
|
520
|
+
const run = async () => {
|
|
521
|
+
while (!stopped) {
|
|
522
|
+
const stream = ctx.client.event.subscribe()
|
|
523
|
+
const iterator = stream[Symbol.asyncIterator]()
|
|
524
|
+
try {
|
|
525
|
+
while (!stopped) {
|
|
526
|
+
const next = await iterator.next()
|
|
527
|
+
if (next.done) break
|
|
528
|
+
attempt = 0
|
|
529
|
+
handleEvent(next.value)
|
|
530
|
+
}
|
|
531
|
+
} catch {}
|
|
532
|
+
// iterator.return() cancels the SSE reader (the SDK's generator
|
|
533
|
+
// finally-block); errors from it are irrelevant at this point.
|
|
534
|
+
try {
|
|
535
|
+
await iterator.return?.()
|
|
536
|
+
} catch {}
|
|
537
|
+
if (stopped) break
|
|
538
|
+
attempt++
|
|
539
|
+
await sleep(Math.min(2000 * attempt, 15000))
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
void run()
|
|
543
|
+
onCleanup(() => {
|
|
544
|
+
stopped = true
|
|
545
|
+
clearTimeout(retryTimer)
|
|
546
|
+
if (lightTimer) clearTimeout(lightTimer)
|
|
547
|
+
})
|
|
548
|
+
})
|
|
297
549
|
|
|
298
550
|
createEffect(() => {
|
|
299
551
|
const id = sessionID()
|
|
300
552
|
if (!id) return
|
|
301
553
|
untrack(() => {
|
|
302
554
|
if (loadedFor !== id) {
|
|
555
|
+
// Invalidate anything in flight for the previous session first, so its
|
|
556
|
+
// finally-block can't write `loadedFor`/`note` for the wrong session.
|
|
557
|
+
loadToken++
|
|
558
|
+
loadingActive = false
|
|
559
|
+
pendingBump = false
|
|
560
|
+
cache.clear()
|
|
303
561
|
setTurns([])
|
|
304
562
|
setActive("")
|
|
305
563
|
lastNavID = ""
|
|
306
|
-
void load(id)
|
|
564
|
+
void load(id, "full")
|
|
307
565
|
}
|
|
308
566
|
})
|
|
309
567
|
})
|
|
310
568
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
569
|
+
// ---- active-turn tracking ----------------------------------------------
|
|
570
|
+
// Driven by scroll events (captured at document level so it also sees the
|
|
571
|
+
// real scroller without querying first) plus one rAF-throttled pass, with a
|
|
572
|
+
// slow interval as a fallback for rows mounting without a scroll (history
|
|
573
|
+
// paging, virtual list swaps).
|
|
574
|
+
let scrollerCache
|
|
575
|
+
const scroller = () => {
|
|
576
|
+
if (scrollerCache?.isConnected) return scrollerCache
|
|
577
|
+
scrollerCache = findScroller()
|
|
578
|
+
return scrollerCache
|
|
579
|
+
}
|
|
316
580
|
|
|
317
581
|
const trackActive = () => {
|
|
318
|
-
const
|
|
319
|
-
if (!
|
|
320
|
-
const rows =
|
|
582
|
+
const sc = scroller()
|
|
583
|
+
if (!sc) return
|
|
584
|
+
const rows = sc.querySelectorAll("[data-message-id]")
|
|
321
585
|
if (rows.length === 0) return
|
|
322
|
-
const top =
|
|
586
|
+
const top = sc.getBoundingClientRect().top + 40
|
|
323
587
|
for (const row of rows) {
|
|
324
588
|
if (row.getBoundingClientRect().bottom >= top) {
|
|
325
589
|
const id = row.getAttribute("data-message-id")
|
|
@@ -328,32 +592,112 @@ function Rail(props) {
|
|
|
328
592
|
}
|
|
329
593
|
}
|
|
330
594
|
}
|
|
331
|
-
const track = setInterval(trackActive, 400)
|
|
332
|
-
onCleanup(() => clearInterval(track))
|
|
333
595
|
|
|
334
|
-
|
|
596
|
+
let rafPending = false
|
|
597
|
+
const scheduleTrack = () => {
|
|
598
|
+
if (rafPending) return
|
|
599
|
+
rafPending = true
|
|
600
|
+
requestAnimationFrame(() => {
|
|
601
|
+
rafPending = false
|
|
602
|
+
trackActive()
|
|
603
|
+
})
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
onMount(() => {
|
|
607
|
+
const onScroll = () => scheduleTrack()
|
|
608
|
+
document.addEventListener("scroll", onScroll, true)
|
|
609
|
+
const fallback = setInterval(scheduleTrack, 1500)
|
|
610
|
+
onCleanup(() => {
|
|
611
|
+
document.removeEventListener("scroll", onScroll, true)
|
|
612
|
+
clearInterval(fallback)
|
|
613
|
+
})
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
// ---- status flash -------------------------------------------------------
|
|
617
|
+
// A sequence guard so a slow timeout from an earlier flash can never clear a
|
|
618
|
+
// newer message.
|
|
619
|
+
let flashSeq = 0
|
|
620
|
+
const flash = (text, ms = 2600) => {
|
|
621
|
+
const seq = ++flashSeq
|
|
335
622
|
setStatus(text)
|
|
336
|
-
setTimeout(() =>
|
|
623
|
+
setTimeout(() => {
|
|
624
|
+
if (seq === flashSeq) setStatus("")
|
|
625
|
+
}, ms)
|
|
337
626
|
}
|
|
338
627
|
|
|
628
|
+
// ---- open / close interactions ------------------------------------------
|
|
339
629
|
onMount(() => {
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
630
|
+
const onKey = (event) => {
|
|
631
|
+
if (event.key !== "Escape") return
|
|
632
|
+
// During a long jump Esc cancels the jump first and keeps the panel open,
|
|
633
|
+
// so the progress display doesn't vanish mid-flight.
|
|
634
|
+
if (untrack(jumping)) {
|
|
635
|
+
cancelJump()
|
|
636
|
+
event.stopPropagation()
|
|
637
|
+
return
|
|
638
|
+
}
|
|
639
|
+
if (untrack(open)) {
|
|
640
|
+
setOpen(false)
|
|
641
|
+
// Only swallow Esc while the rail is actually open, so the host keeps
|
|
642
|
+
// its own Escape handling the rest of the time.
|
|
643
|
+
event.stopPropagation()
|
|
644
|
+
}
|
|
345
645
|
}
|
|
646
|
+
const onPointerDown = (event) => {
|
|
647
|
+
if (!untrack(open)) return
|
|
648
|
+
const path = event.composedPath?.() ?? []
|
|
649
|
+
const inside = path.some((node) => node instanceof Element && node.closest?.(".sr-rail"))
|
|
650
|
+
if (!inside) setOpen(false)
|
|
651
|
+
}
|
|
652
|
+
window.addEventListener("keydown", onKey, true)
|
|
653
|
+
document.addEventListener("pointerdown", onPointerDown, true)
|
|
654
|
+
onCleanup(() => {
|
|
655
|
+
window.removeEventListener("keydown", onKey, true)
|
|
656
|
+
document.removeEventListener("pointerdown", onPointerDown, true)
|
|
657
|
+
})
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
onMount(() => {
|
|
661
|
+
const onDiag = () => flash(`${VERSION} · ${diagnostics(ctx)}`)
|
|
662
|
+
// /turns toggles the panel; Esc / outside click / × also close it. No more
|
|
663
|
+
// forced auto-close after 4s.
|
|
664
|
+
const onOpen = () => setOpen((value) => !value)
|
|
346
665
|
window.addEventListener(DIAG_EVENT, onDiag)
|
|
347
666
|
window.addEventListener(OPEN_EVENT, onOpen)
|
|
348
667
|
onCleanup(() => {
|
|
349
668
|
window.removeEventListener(DIAG_EVENT, onDiag)
|
|
350
669
|
window.removeEventListener(OPEN_EVENT, onOpen)
|
|
351
|
-
clearTimeout(closeTimer)
|
|
352
670
|
})
|
|
353
671
|
// One-shot confirmation that this window actually runs the rebuilt bundle.
|
|
354
672
|
flash(`会话轮次 ${VERSION} 已加载`)
|
|
355
673
|
})
|
|
356
674
|
|
|
675
|
+
// ---- jump ----------------------------------------------------------------
|
|
676
|
+
// The memory router's hash is invisible to us (window.location is stale), so
|
|
677
|
+
// track the last id we navigated to in order to force a hash change each round.
|
|
678
|
+
let lastNavID = ""
|
|
679
|
+
let jumpToken = 0
|
|
680
|
+
let lastJumpAt = 0
|
|
681
|
+
let lastJumpTurnID = ""
|
|
682
|
+
|
|
683
|
+
// Brief highlight on the message bubble. The timeline row spans the whole
|
|
684
|
+
// width, so tinting it looks like a stray orange band. The visible bubble is
|
|
685
|
+
// `[data-slot="user-message-text"]` (background + 10px radius); its parent
|
|
686
|
+
// `user-message-body` is only a layout column. An outline + glow hugs the
|
|
687
|
+
// bubble's own shape without altering its background.
|
|
688
|
+
const pulse = (row) => {
|
|
689
|
+
try {
|
|
690
|
+
const target =
|
|
691
|
+
row.querySelector('[data-slot="user-message-text"]') ??
|
|
692
|
+
row.querySelector('[data-slot="user-message-body"]') ??
|
|
693
|
+
row
|
|
694
|
+
target.classList.remove("sr-flash-ring")
|
|
695
|
+
void target.offsetWidth // restart the animation when re-pulsing the same node
|
|
696
|
+
target.classList.add("sr-flash-ring")
|
|
697
|
+
setTimeout(() => target.classList.remove("sr-flash-ring"), 1900)
|
|
698
|
+
} catch {}
|
|
699
|
+
}
|
|
700
|
+
|
|
357
701
|
// Oldest turn that is currently mounted (i.e. already paged in), used as a
|
|
358
702
|
// safe "different id" to toggle the router hash so it re-runs its seek.
|
|
359
703
|
const oldestLoadedTurn = () => {
|
|
@@ -366,14 +710,90 @@ function Rail(props) {
|
|
|
366
710
|
lastNavID = id
|
|
367
711
|
}
|
|
368
712
|
|
|
713
|
+
// Cheap fingerprint of the mounted timeline rows. Paging in older history
|
|
714
|
+
// prepends rows, so a changed fingerprint means the host did real work and
|
|
715
|
+
// the next hash toggle can be sent immediately.
|
|
716
|
+
const rowSignature = () => {
|
|
717
|
+
const sc = scroller()
|
|
718
|
+
const rows = sc?.querySelectorAll("[data-message-id]") ?? []
|
|
719
|
+
if (rows.length === 0) return "0"
|
|
720
|
+
const first = rows[0].getAttribute("data-message-id") ?? ""
|
|
721
|
+
const last = rows[rows.length - 1].getAttribute("data-message-id") ?? ""
|
|
722
|
+
return `${rows.length}:${first}:${last}`
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Resolves when the row enters the DOM (MutationObserver, with a cheap poll
|
|
726
|
+
// as a belt-and-braces fallback), or null on timeout / supersession / when
|
|
727
|
+
// the host has paged in another slice (the caller then drives the next seek).
|
|
728
|
+
const waitForRow = (messageID, token, timeout) =>
|
|
729
|
+
new Promise((resolve) => {
|
|
730
|
+
let done = false
|
|
731
|
+
let observer
|
|
732
|
+
let poll
|
|
733
|
+
let timer
|
|
734
|
+
const started = Date.now()
|
|
735
|
+
const baseline = rowSignature()
|
|
736
|
+
const finish = (row) => {
|
|
737
|
+
if (done) return
|
|
738
|
+
done = true
|
|
739
|
+
try {
|
|
740
|
+
observer?.disconnect()
|
|
741
|
+
} catch {}
|
|
742
|
+
clearInterval(poll)
|
|
743
|
+
clearTimeout(timer)
|
|
744
|
+
resolve(row ?? null)
|
|
745
|
+
}
|
|
746
|
+
const check = (full) => {
|
|
747
|
+
if (token !== jumpToken) return finish(null)
|
|
748
|
+
const row = findRow(messageID)
|
|
749
|
+
if (row) return finish(row)
|
|
750
|
+
// Let the click settle before trusting a DOM change as "a page arrived".
|
|
751
|
+
// Only the poll evaluates the row fingerprint: streaming output fires
|
|
752
|
+
// the observer constantly and a full query on every mutation is wasted.
|
|
753
|
+
if (full && Date.now() - started > JUMP_SETTLE_MS && rowSignature() !== baseline) finish(null)
|
|
754
|
+
}
|
|
755
|
+
const existing = findRow(messageID)
|
|
756
|
+
if (existing) return finish(existing)
|
|
757
|
+
observer = new MutationObserver(() => check(false))
|
|
758
|
+
observer.observe(document.documentElement, { childList: true, subtree: true })
|
|
759
|
+
poll = setInterval(() => check(true), 120)
|
|
760
|
+
timer = setTimeout(() => finish(findRow(messageID)), Math.max(400, timeout))
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
const cancelJump = () => {
|
|
764
|
+
jumpToken++
|
|
765
|
+
setJumping(false)
|
|
766
|
+
setJumpPage(0)
|
|
767
|
+
setBusyID("")
|
|
768
|
+
flash("已取消跳转")
|
|
769
|
+
}
|
|
770
|
+
|
|
369
771
|
const jump = async (turn) => {
|
|
772
|
+
const token = ++jumpToken
|
|
370
773
|
lastJumpID = turn.id
|
|
371
|
-
const
|
|
774
|
+
const now = Date.now()
|
|
775
|
+
|
|
776
|
+
// Same target clicked again shortly after: treat as idempotent re-scroll
|
|
777
|
+
// instead of restarting the paging loop.
|
|
778
|
+
if (turn.id === lastJumpTurnID && now - lastJumpAt < SAME_TARGET_WINDOW_MS) {
|
|
779
|
+
const row = findRow(turn.id)
|
|
780
|
+
const sc = scroller()
|
|
781
|
+
if (row && sc) {
|
|
782
|
+
scrollToRow(sc, row)
|
|
783
|
+
pulse(row)
|
|
784
|
+
return
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
lastJumpAt = now
|
|
788
|
+
lastJumpTurnID = turn.id
|
|
789
|
+
|
|
790
|
+
const sc = scroller()
|
|
372
791
|
const row = findRow(turn.id)
|
|
373
792
|
|
|
374
793
|
// Fast path: the row is mounted, scroll locally.
|
|
375
|
-
if (row &&
|
|
376
|
-
scrollToRow(
|
|
794
|
+
if (row && sc) {
|
|
795
|
+
scrollToRow(sc, row)
|
|
796
|
+
pulse(row)
|
|
377
797
|
flash("已定位")
|
|
378
798
|
return
|
|
379
799
|
}
|
|
@@ -384,35 +804,72 @@ function Rail(props) {
|
|
|
384
804
|
return
|
|
385
805
|
}
|
|
386
806
|
|
|
387
|
-
//
|
|
388
|
-
// idle. Drive it: each round toggles the hash to an
|
|
389
|
-
// back to the target, so the cursor machine runs
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
807
|
+
// Deep history: the host only pages in ~one history page (~5 turns) per
|
|
808
|
+
// seek and then goes idle. Drive it: each round toggles the hash to an
|
|
809
|
+
// already-loaded turn and back to the target, so the cursor machine runs
|
|
810
|
+
// another seek/page. A round ends as soon as rows appear instead of always
|
|
811
|
+
// sleeping 6s. A newer click bumps jumpToken, which cancels this loop at
|
|
812
|
+
// every await; Esc cancels it too.
|
|
813
|
+
setJumping(true)
|
|
814
|
+
setBusyID(turn.id)
|
|
815
|
+
setJumpPage(0)
|
|
816
|
+
// Pin the panel open even if the pointer has left it meanwhile.
|
|
817
|
+
setOpen(true)
|
|
818
|
+
flash("正在定位…", JUMP_TOTAL_MS)
|
|
819
|
+
try {
|
|
820
|
+
const deadline = Date.now() + JUMP_TOTAL_MS
|
|
821
|
+
for (let round = 0; round < JUMP_ROUNDS && Date.now() < deadline; round++) {
|
|
822
|
+
if (token !== jumpToken) return
|
|
823
|
+
setJumpPage(round + 1)
|
|
824
|
+
flash(`跳转中 · 第 ${round + 1} 页`, JUMP_TOTAL_MS)
|
|
825
|
+
|
|
826
|
+
if (lastNavID === turn.id) {
|
|
827
|
+
const other = oldestLoadedTurn() ?? turns()[0]
|
|
828
|
+
if (other) {
|
|
829
|
+
navTo(path, other.id)
|
|
830
|
+
await sleep(120)
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
if (token !== jumpToken) return
|
|
834
|
+
navTo(path, turn.id)
|
|
835
|
+
|
|
836
|
+
const remaining = Math.min(JUMP_ROW_TIMEOUT_MS, deadline - Date.now())
|
|
837
|
+
const landed = await waitForRow(turn.id, token, remaining)
|
|
838
|
+
if (token !== jumpToken) return
|
|
839
|
+
if (landed) {
|
|
840
|
+
const live = scroller() ?? sc
|
|
841
|
+
if (live) scrollToRow(live, landed)
|
|
842
|
+
pulse(landed)
|
|
843
|
+
flash("已定位")
|
|
844
|
+
return
|
|
398
845
|
}
|
|
399
846
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
flash("已定位")
|
|
409
|
-
return
|
|
847
|
+
|
|
848
|
+
if (token === jumpToken) flash(`未能定位 · ${diagnostics(ctx)}`)
|
|
849
|
+
} finally {
|
|
850
|
+
// A newer jump owns the signals when the token moved on.
|
|
851
|
+
if (token === jumpToken) {
|
|
852
|
+
setJumping(false)
|
|
853
|
+
setJumpPage(0)
|
|
854
|
+
setBusyID("")
|
|
410
855
|
}
|
|
411
856
|
}
|
|
412
|
-
|
|
413
|
-
flash(`未能定位 · ${diagnostics(ctx)}`)
|
|
414
857
|
}
|
|
415
858
|
|
|
859
|
+
// Keep the busy row visible in the panel list while the jump pages through
|
|
860
|
+
// history, so the "which turn am I waiting for" answer stays on screen.
|
|
861
|
+
createEffect(() => {
|
|
862
|
+
if (!jumping()) return
|
|
863
|
+
if (!busyID()) return
|
|
864
|
+
requestAnimationFrame(() => {
|
|
865
|
+
try {
|
|
866
|
+
document
|
|
867
|
+
.querySelector('.sr-list .sr-turn[data-busy="true"]')
|
|
868
|
+
?.scrollIntoView({ block: "nearest" })
|
|
869
|
+
} catch {}
|
|
870
|
+
})
|
|
871
|
+
})
|
|
872
|
+
|
|
416
873
|
const rail = (
|
|
417
874
|
<>
|
|
418
875
|
<Show when={status()}>
|
|
@@ -421,26 +878,60 @@ function Rail(props) {
|
|
|
421
878
|
<div
|
|
422
879
|
class="sr-rail"
|
|
423
880
|
data-open={open() ? "true" : "false"}
|
|
881
|
+
data-jumping={jumping() ? "true" : "false"}
|
|
424
882
|
onMouseEnter={() => setOpen(true)}
|
|
425
|
-
|
|
883
|
+
// Keep the panel pinned while a long history jump runs; otherwise the
|
|
884
|
+
// mouse drifting off the rail hides exactly the progress the user is
|
|
885
|
+
// waiting for. Esc / × still close (or cancel) it.
|
|
886
|
+
onMouseLeave={() => {
|
|
887
|
+
if (!jumping()) setOpen(false)
|
|
888
|
+
}}
|
|
426
889
|
>
|
|
427
|
-
<
|
|
428
|
-
<
|
|
429
|
-
{(
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
>
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
)}
|
|
442
|
-
|
|
443
|
-
|
|
890
|
+
<div class="sr-head">
|
|
891
|
+
<Show
|
|
892
|
+
when={jumping()}
|
|
893
|
+
fallback={
|
|
894
|
+
<span class="sr-title">会话轮次{turns().length > 0 ? ` · ${turns().length}` : ""}</span>
|
|
895
|
+
}
|
|
896
|
+
>
|
|
897
|
+
<span class="sr-title sr-progress">
|
|
898
|
+
跳转中{jumpPage() > 0 ? ` · 第 ${jumpPage()} 页` : ""}
|
|
899
|
+
</span>
|
|
900
|
+
</Show>
|
|
901
|
+
<button
|
|
902
|
+
type="button"
|
|
903
|
+
class="sr-close"
|
|
904
|
+
title={jumping() ? "取消跳转 (Esc)" : "关闭 (Esc)"}
|
|
905
|
+
onClick={() => {
|
|
906
|
+
if (jumping()) cancelJump()
|
|
907
|
+
else setOpen(false)
|
|
908
|
+
}}
|
|
909
|
+
>
|
|
910
|
+
×
|
|
911
|
+
</button>
|
|
912
|
+
</div>
|
|
913
|
+
<div class="sr-list">
|
|
914
|
+
<Show when={turns().length > 0} fallback={<div class="sr-note">{note() || `… ${VERSION}`}</div>}>
|
|
915
|
+
<For each={turns()}>
|
|
916
|
+
{(turn, index) => (
|
|
917
|
+
<button
|
|
918
|
+
type="button"
|
|
919
|
+
class="sr-turn"
|
|
920
|
+
data-active={active() === turn.id ? "true" : "false"}
|
|
921
|
+
data-busy={jumping() && busyID() === turn.id ? "true" : "false"}
|
|
922
|
+
onClick={() => void jump(turn)}
|
|
923
|
+
>
|
|
924
|
+
<span class="sr-text">
|
|
925
|
+
<span class="sr-num">#{index() + 1}</span>
|
|
926
|
+
<span class="sr-time">{formatTime(turn.created)}</span>
|
|
927
|
+
{turn.text}
|
|
928
|
+
</span>
|
|
929
|
+
<span class="sr-dash" />
|
|
930
|
+
</button>
|
|
931
|
+
)}
|
|
932
|
+
</For>
|
|
933
|
+
</Show>
|
|
934
|
+
</div>
|
|
444
935
|
</div>
|
|
445
936
|
</>
|
|
446
937
|
)
|
|
@@ -486,7 +977,6 @@ export default Plugin.define({
|
|
|
486
977
|
slash: { name: "turns" },
|
|
487
978
|
run: () => {
|
|
488
979
|
window.dispatchEvent(new Event(OPEN_EVENT))
|
|
489
|
-
window.dispatchEvent(new Event(DIAG_EVENT))
|
|
490
980
|
},
|
|
491
981
|
}))
|
|
492
982
|
},
|