opencode-session-turns 0.1.15 → 0.1.17
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 +16 -1
- package/dist/gui-experimental.cjs +76 -3
- package/package.json +2 -2
- package/src/gui.tsx +374 -41
package/README.md
CHANGED
|
@@ -21,6 +21,8 @@ click a turn to jump straight to that message.
|
|
|
21
21
|
|
|
22
22
|
- 列出当前会话的每一轮用户提问(从会话消息接口分页拉取,`type=user`)。
|
|
23
23
|
- 鼠标移到窗口右缘的细条上自动展开,移开自动收起;长历史跳转期间面板保持展开。
|
|
24
|
+
- **搜索轮次**:≥ 8 轮时面板顶部出现搜索框,按关键字即时过滤(匹配数显示在标题里);`↑`/`↓` 在结果间移动光标,`Enter` 跳转,无鼠标也能导航。
|
|
25
|
+
- **面板固定**:标题栏图钉按钮或 `⌘⌥P`(斜杠命令 `/turns-pin`)把面板钉住,鼠标移开不收起;`Esc` 先解固定、再关面板。
|
|
24
26
|
- 点击任意一轮,**跳转到对应消息**——包括很久以前、尚未渲染的历史轮次。
|
|
25
27
|
- 深历史需要逐页加载时,面板头部显示进度,目标轮次带徽标并琥珀色脉冲;
|
|
26
28
|
按 `Esc` 或点 × 可随时取消。
|
|
@@ -30,7 +32,9 @@ click a turn to jump straight to that message.
|
|
|
30
32
|
- **点击与预热的配合**:点到的轮次已预热 → 直接瞬时跳转;还没预热到 → 头部显示
|
|
31
33
|
「预热中 · 稍等一下」,等预热翻到该轮后自动落地(不打断预热)。
|
|
32
34
|
- 定位成功后精准描边闪烁**那一条用户气泡**(跟随气泡自身圆角),不再整行填色。
|
|
33
|
-
-
|
|
35
|
+
- 自动高亮当前视口所在的轮次(IntersectionObserver 驱动,后台窗口零开销)。
|
|
36
|
+
- 跨天会话的时间标注会带上「昨天」或日期,不再只有 `HH:mm` 造成歧义。
|
|
37
|
+
- 尊重系统的「减弱动态效果」(`prefers-reduced-motion`):保留状态色彩,去掉动画。
|
|
34
38
|
- 斜杠命令 `/turns`、快捷键 `⌘⌥R`(Windows/Linux 为 `Ctrl+Alt+R`)展开轮次条。
|
|
35
39
|
|
|
36
40
|
## 安装
|
|
@@ -88,6 +92,17 @@ npm run build # -> dist/gui-experimental.cjs
|
|
|
88
92
|
改完源码后重新构建,并在 OpenCode Desktop 里 Reload 插件
|
|
89
93
|
(Settings → Extensions → Plugins → Reload)或重启应用。
|
|
90
94
|
|
|
95
|
+
## 发布
|
|
96
|
+
|
|
97
|
+
版本发布只通过 GitHub Actions(`.github/workflows/publish.yml`);本地 `npm publish` 已被 `prepublishOnly` 守卫禁止:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
npm version patch # 或 minor / major:自动改版本、提交并打 v* tag
|
|
101
|
+
git push --follow-tags origin main
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
CI 会校验 tag 与 `package.json` 版本一致,随后构建并发布到 npm。
|
|
105
|
+
|
|
91
106
|
## 许可证
|
|
92
107
|
|
|
93
108
|
MIT © meiyx7
|
|
@@ -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 r=require("solid-js/web"),F=require("@opencode/plugin/gui"),a=require("solid-js");var tt=r.template("<div class=sr-status>"),xt=r.template('<span class="sr-title sr-progress">'),yt=r.template('<svg><path d="M6 3l7 7-3.5.5L7 13 3 9l2.5-2.5z"></svg>',!1,!0,!1),kt=r.template('<svg><path d="M4 12l-1.5 1.5"></svg>',!1,!0,!1),$t=r.template("<button type=button class=sr-search-clear aria-label=清除搜索>×"),St=r.template("<div class=sr-search><input type=text class=sr-search-input placeholder=搜索轮次…>"),Ct=r.template("<div class=sr-foot>"),Et=r.template('<div class=sr-rail><div class=sr-head><span class=sr-head-actions><button type=button class=sr-iconbtn><svg viewBox="0 0 16 16"fill=none stroke=currentColor stroke-width=1.5 stroke-linecap=round stroke-linejoin=round aria-hidden=true></svg></button><button type=button class=sr-close>×</button></span></div><div class=sr-list tabindex=-1>'),_t=r.template("<span class=sr-title>会话轮次"),At=r.template('<svg><path d="M9.5 2.5l4 4-2.6.9-1.4 3.5-4.4-4.4L8.6 5.1z"></svg>',!1,!0,!1),Mt=r.template('<svg><path d="M5.1 6.5L2.5 9.1l4.4 4.4 2.6-2.6"></svg>',!1,!0,!1),Tt=r.template("<div class=sr-note>"),Dt=r.template("<span class=sr-badge>"),It=r.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 rt="v21",Se="message-",nt="session-turns:diag",Ce="session-turns:open",Ee="session-turns:pin",Lt=40,Pt=200,jt=50,qt=30,Rt=45e3,Bt=2e4,Ot=8e3,zt=12e3,st=220,Ft=800;let at=0,_e="",Ae="",Me="";const ot=l=>{const s=String(l?.text??"").split(`
|
|
2
|
+
`).map(m=>m.trim()).find(m=>m.length>0);if(s)return s;const c=l?.files?.length??0;return c>0?`[附件 ×${c}]`:"(空消息)"},P=l=>String(l).padStart(2,"0"),Jt=l=>{const s=new Date(l??0),c=new Date;if(s.getFullYear()===c.getFullYear()&&s.getMonth()===c.getMonth()&&s.getDate()===c.getDate())return`${P(s.getHours())}:${P(s.getMinutes())}`;const $=new Date(c);return $.setDate(c.getDate()-1),s.getFullYear()===$.getFullYear()&&s.getMonth()===$.getMonth()&&s.getDate()===$.getDate()?`昨天 ${P(s.getHours())}:${P(s.getMinutes())}`:`${P(s.getMonth()+1)}-${P(s.getDate())} ${P(s.getHours())}:${P(s.getMinutes())}`},de=l=>new Promise(s=>setTimeout(s,l)),Te=()=>document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport')??document.querySelector('[data-slot="session-timeline-scroll"] [data-scrollable]')??(()=>{let s=document.querySelector("[data-message-id]")?.parentElement;for(;s;){const c=getComputedStyle(s);if(/(auto|scroll)/.test(c.overflowY)&&s.scrollHeight>s.clientHeight+1)return s;s=s.parentElement}})(),E=l=>{const s=document.getElementById(`${Se}${l}`);if(s)return s;const c=`[data-message-id="${l.replace(/"/g,'\\"')}"]`;for(const m of document.querySelectorAll(c))if(m.getAttribute("data-timeline-row")==="UserMessage"||m.querySelector('[data-slot="user-message-text"]'))return m},ke=l=>{const s=Te(),c=[...document.querySelectorAll("[data-message-id]")],m=c[0]?.getAttribute("data-message-id")??"",$=Me?` · 目标 ${E(Me)?"在":"不在"} DOM`:"",T=m?` · 行ID ${m.slice(0,20)}`:"";let S="(空)";try{S=l.router.path()||"(空)"}catch{}return`轮次 ${at} · 容器 ${s?"OK":"缺失"} · DOM ${c.length} 行${$}${T} · 路由 ${S} · hash ${window.location.hash||"(空)"}${Ae?` · ${Ae}`:""}${_e?` · ${_e}`:""}`},Nt=l=>{const s=l.getBoundingClientRect().top;let c=s;for(const m of l.querySelectorAll(".sticky, [data-session-title]")){if(!(m instanceof HTMLElement)||getComputedStyle(m).position!=="sticky"&&!m.hasAttribute("data-session-title"))continue;const T=m.getBoundingClientRect();T.height!==0&&(T.top>s+4||(c=Math.max(c,T.bottom)))}return c},$e=l=>{try{const s=Te();if(!s||!l.isConnected)return;const c=l.querySelector('[data-slot="user-message-text"]')??l,m=Nt(s),$=c.getBoundingClientRect().top-(m+18);Math.abs($)>2&&(s.scrollTop+=$),Ae=`inset ${Math.round(m)} · delta ${Math.round($)}`}catch{}},ue=l=>{$e(l),setTimeout(()=>$e(l),450),setTimeout(()=>$e(l),1e3)},it=l=>{let s="";try{s=l.router.path()||""}catch{}return s.startsWith("/")?s:""},Ht=l=>{const s=document.createElement("a");s.setAttribute("href",l),s.setAttribute("replace",""),s.setAttribute("noscroll",""),s.style.display="none",document.body.appendChild(s);try{s.dispatchEvent(new MouseEvent("click",{bubbles:!0,cancelable:!0,button:0,view:window}))}finally{s.remove()}},Ut=`
|
|
3
3
|
.sr-rail {
|
|
4
4
|
position: fixed;
|
|
5
5
|
top: 50%;
|
|
@@ -46,6 +46,73 @@
|
|
|
46
46
|
color: rgba(255,170,60,.95);
|
|
47
47
|
font-variant-numeric: tabular-nums;
|
|
48
48
|
}
|
|
49
|
+
.sr-head-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
|
|
50
|
+
.sr-iconbtn {
|
|
51
|
+
display: inline-flex;
|
|
52
|
+
align-items: center;
|
|
53
|
+
justify-content: center;
|
|
54
|
+
width: 22px;
|
|
55
|
+
height: 22px;
|
|
56
|
+
padding: 0;
|
|
57
|
+
border: 0;
|
|
58
|
+
background: transparent;
|
|
59
|
+
color: inherit;
|
|
60
|
+
cursor: pointer;
|
|
61
|
+
border-radius: 4px;
|
|
62
|
+
opacity: .5;
|
|
63
|
+
}
|
|
64
|
+
.sr-iconbtn svg { width: 13px; height: 13px; }
|
|
65
|
+
.sr-iconbtn:hover { opacity: 1; background: var(--surface-base-hover, rgba(127,127,127,.14)); }
|
|
66
|
+
.sr-iconbtn[data-on="true"] { opacity: .95; color: rgba(255,170,60,.95); }
|
|
67
|
+
.sr-iconbtn:focus-visible { outline: 1px solid currentColor; outline-offset: -1px; }
|
|
68
|
+
/* search box */
|
|
69
|
+
.sr-search {
|
|
70
|
+
position: relative;
|
|
71
|
+
flex-shrink: 0;
|
|
72
|
+
margin: 0 0 6px;
|
|
73
|
+
}
|
|
74
|
+
.sr-search-input {
|
|
75
|
+
width: 100%;
|
|
76
|
+
box-sizing: border-box;
|
|
77
|
+
height: 26px;
|
|
78
|
+
padding: 0 24px 0 8px;
|
|
79
|
+
border: 1px solid rgba(127,127,127,.25);
|
|
80
|
+
border-radius: 6px;
|
|
81
|
+
background: color-mix(in srgb, currentColor 6%, transparent);
|
|
82
|
+
color: inherit;
|
|
83
|
+
font: inherit;
|
|
84
|
+
font-size: 12px;
|
|
85
|
+
outline: none;
|
|
86
|
+
transition: border-color .12s ease, box-shadow .12s ease;
|
|
87
|
+
}
|
|
88
|
+
.sr-search-input::placeholder { color: currentColor; opacity: .4; }
|
|
89
|
+
.sr-search-input:focus {
|
|
90
|
+
border-color: rgba(255,170,60,.6);
|
|
91
|
+
box-shadow: 0 0 0 2px rgba(255,170,60,.14);
|
|
92
|
+
}
|
|
93
|
+
.sr-search-clear {
|
|
94
|
+
position: absolute;
|
|
95
|
+
right: 4px;
|
|
96
|
+
top: 50%;
|
|
97
|
+
transform: translateY(-50%);
|
|
98
|
+
display: inline-flex;
|
|
99
|
+
align-items: center;
|
|
100
|
+
justify-content: center;
|
|
101
|
+
width: 16px;
|
|
102
|
+
height: 16px;
|
|
103
|
+
padding: 0;
|
|
104
|
+
border: 0;
|
|
105
|
+
border-radius: 50%;
|
|
106
|
+
background: transparent;
|
|
107
|
+
color: inherit;
|
|
108
|
+
font-size: 12px;
|
|
109
|
+
line-height: 1;
|
|
110
|
+
cursor: pointer;
|
|
111
|
+
opacity: .5;
|
|
112
|
+
}
|
|
113
|
+
.sr-search-clear:hover { opacity: 1; background: var(--surface-base-hover, rgba(127,127,127,.14)); }
|
|
114
|
+
/* keyboard cursor (↑/↓) */
|
|
115
|
+
.sr-turn[data-cursor="true"] { background: var(--surface-base-hover, rgba(127,127,127,.14)); }
|
|
49
116
|
.sr-close {
|
|
50
117
|
border: 0;
|
|
51
118
|
background: transparent;
|
|
@@ -242,4 +309,10 @@
|
|
|
242
309
|
white-space: pre-wrap;
|
|
243
310
|
max-width: 260px;
|
|
244
311
|
}
|
|
245
|
-
`;function kt(l){const o=H.usePlugin(),[d,p]=a.createSignal([]),[E,_]=a.createSignal(""),[C,M]=a.createSignal(!1),[Fe,Ue]=a.createSignal(""),[D,be]=a.createSignal(""),[y,F]=a.createSignal(!1),[we,P]=a.createSignal(0),[xe,j]=a.createSignal(0),[$t,R]=a.createSignal(0),[U,W]=a.createSignal(""),[ve,q]=a.createSignal(!1),S=new Map;let re=0,B=!1,G=!1,V="",J;const h=()=>l.session()?.id,ne=()=>{const e=[...S.values()].sort((r,i)=>r.created-i.created),t=a.untrack(d);t.length===e.length&&t.every((r,i)=>r===e[i])||(p(e),He=e.length)},ye=e=>{const t=S.get(e.id);t&&t.created===e.time&&t.text===e.text||S.set(e.id,{id:e.id,created:e.time,text:e.text})},We=async(e,t,r)=>{const i=r?{sessionID:e,type:"user",limit:t,cursor:r}:{sessionID:e,type:"user",limit:t,order:"desc"};return await o.client.message.list(i)},se=async(e,t)=>{const r=++re;B=!0,t==="full"&&(z="");let i="";try{let n;const c=t==="full"?lt:1,u=t==="full"?ct:dt;let m=t!=="full";for(let w=0;w<c;w++){let b;try{b=await We(e,u,n)}catch(g){i=String(g?.message??g);break}if(r!==re)return;m||(S.clear(),m=!0);let x=0;for(const g of b?.data??[])S.has(g.id)||(ye({id:g.id,time:g.time?.created??0,text:Oe(g)}),x++);ne();const f=b?.cursor?.next;if(!f||x===0)break;n=f}}finally{B=!1,V=e,fe=i,Ue(S.size===0?i||"暂无用户消息":""),G&&(G=!1,ke()),t==="full"&&!i&&e===a.untrack(h)&&S.size>=15&&setTimeout(()=>Q(),800)}},ke=()=>{J||(J=setTimeout(()=>{J=void 0;const e=a.untrack(h);e&&se(e,"light")},300))},Se=e=>{if(!(!e||e!==a.untrack(h))){if(B){G=!0;return}ke()}},Ge=e=>{const t=e?.type;if(t==="message.updated"){const r=e?.data??{},i=r.info;i?.role==="user"&&i?.id&&(ye({id:i.id,time:i.time?.created??0,text:Oe(i)}),ne()),Se(r.sessionID)}else if(t==="message.removed"){const r=e?.data??{};r.messageID&&S.delete(r.messageID)&&ne(),Se(r.sessionID)}else if(t==="location.shutdown"){const r=a.untrack(h);r&&V===r&&se(r,"full")}};a.onMount(()=>{let e=!1,t=0,r;(async()=>{for(;!e;){const c=o.client.event.subscribe()[Symbol.asyncIterator]();try{for(;!e;){const u=await c.next();if(u.done)break;t=0,Ge(u.value)}}catch{}try{await c.return?.()}catch{}if(e)break;t++,await le(Math.min(2e3*t,15e3))}})(),a.onCleanup(()=>{e=!0,clearTimeout(r),J&&clearTimeout(J)})}),a.createEffect(()=>{const e=h();e&&a.untrack(()=>{V!==e&&(re++,B=!1,G=!1,S.clear(),p([]),_(""),ae="",se(e,"full"))})});let Y;const I=()=>(Y?.isConnected||(Y=me()),Y),Ve=()=>{const e=I();if(!e)return;const t=e.querySelectorAll("[data-message-id]");if(t.length===0)return;const r=e.getBoundingClientRect().top+40;for(const i of t)if(i.getBoundingClientRect().bottom>=r){const n=i.getAttribute("data-message-id");n&&_(n);break}};let oe=!1;const $e=()=>{oe||(oe=!0,requestAnimationFrame(()=>{oe=!1,Ve()}))};a.onMount(()=>{const e=()=>$e();document.addEventListener("scroll",e,!0);const t=setInterval($e,1500);a.onCleanup(()=>{document.removeEventListener("scroll",e,!0),clearInterval(t)})});let Ee=0;const K=(e,t=2600)=>{const r=++Ee;be(e),setTimeout(()=>{r===Ee&&be("")},t)};a.onMount(()=>{const e=r=>{if(r.key==="Escape"){if(a.untrack(y)){Me(),r.stopPropagation();return}a.untrack(C)&&(M(!1),r.stopPropagation())}},t=r=>{if(!a.untrack(C))return;(r.composedPath?.()??[]).some(c=>c instanceof Element&&c.closest?.(".sr-rail"))||M(!1)};window.addEventListener("keydown",e,!0),document.addEventListener("pointerdown",t,!0),a.onCleanup(()=>{window.removeEventListener("keydown",e,!0),document.removeEventListener("pointerdown",t,!0)})}),a.onMount(()=>{const e=()=>K(`${Be} · ${ce(o)}`),t=()=>{M(r=>!r),Q()};window.addEventListener(Je,e),window.addEventListener(pe,t),a.onCleanup(()=>{window.removeEventListener(Je,e),window.removeEventListener(pe,t)})});let ae="",v=0,_e=0,Ce="";const Z=e=>{try{const t=e.querySelector('[data-slot="user-message-text"]')??e.querySelector('[data-slot="user-message-body"]')??e;t.classList.remove("sr-flash-ring"),t.offsetWidth,t.classList.add("sr-flash-ring"),setTimeout(()=>t.classList.remove("sr-flash-ring"),1900)}catch{}},Ye=()=>{for(const e of d())if(k(e.id))return e},O=(e,t)=>{vt(`${e}#${ue}${t}`),ae=t},Te=()=>{const t=I()?.querySelectorAll("[data-message-id]")??[];if(t.length===0)return"0";const r=t[0].getAttribute("data-message-id")??"",i=t[t.length-1].getAttribute("data-message-id")??"";return`${t.length}:${r}:${i}`},Ae=(e,t,r,i)=>new Promise(n=>{let c=!1,u,m,w;const b=Date.now();let f=Te(),g=b,T=0;const $=ee=>{if(!c){c=!0;try{u?.disconnect()}catch{}clearInterval(m),clearTimeout(w),n(ee??null)}},A=ee=>{if(t!==v)return $(null);const Pe=ee?k(e):document.getElementById(`${ue}${e}`);if(Pe)return $(Pe);if(!ee)return;const je=Te(),Re=Date.now();if(je!==f){f=je,g=Re,T++,i?.(T);return}const et=T>0?gt:ht;Re-g>et&&$(null)},De=k(e);if(De)return $(De);u=new MutationObserver(()=>A(!1)),u.observe(document.documentElement,{childList:!0,subtree:!0}),m=setInterval(()=>A(!0),120),w=setTimeout(()=>$(k(e)),Math.max(400,r))}),Me=()=>{v++,F(!1),P(0),j(0),R(0),W(""),q(!1),K("已取消跳转")},Ke=async e=>{if(y()&&U()===e.id)return;const t=ze(o),r=I(),i=k(e.id);if(i&&r){L()&&(X(),t&&O(t,e.id)),te(i),Z(i);return}const n=Date.now();if(e.id===Ce&&n-_e<bt){const x=k(e.id);if(x&&r){te(x),Z(x);return}}if(_e=n,Ce=e.id,he=e.id,!t){K(`无法读取当前路由,无法定位 · ${ce(o)}`);return}const c=++v,u=h();F(!0),W(e.id),P(0),j(0),R(0),q(L()),M(!0);const m=Date.now(),w=setInterval(()=>{j(Math.round((Date.now()-m)/1e3))},500);let b=0;try{if(L()){const f=await Ae(e.id,c,12e4,g=>P(g));if(c!==v||h()!==u)return;if(f){X(),O(t,e.id),te(f),Z(f);return}if(L()&&X(),q(!1),c!==v||h()!==u)return}const x=Date.now()+pt;for(let f=0;f<ut&&Date.now()<x;f++){if(c!==v||h()!==u)return;if(R(f+1),ae===e.id){const A=Ye()??d()[0];A&&(O(t,A.id),await le(mt))}if(c!==v||h()!==u)return;O(t,e.id);let g=0;const T=Math.min(ft,x-Date.now()),$=await Ae(e.id,c,T,A=>{g=A,P(b+A)});if(b+=g,c!==v||h()!==u)return;if($){te($),Z($);return}}c===v&&K(`未能定位 · ${ce(o)}`)}finally{clearInterval(w),c===v&&(F(!1),P(0),j(0),R(0),W(""),q(!1))}},[L,ie]=a.createSignal(!1);let N=0,z="";const Ze=()=>{const e=I();if(!e)return;const t=e.getBoundingClientRect();for(const r of e.querySelectorAll("[data-message-id]")){const i=r.getBoundingClientRect();if(i.bottom<=t.top+1)continue;const n=r.getAttribute("data-message-id");if(n)return{el:r,id:n,offset:i.top-t.top}}},Ie=e=>{if(!e)return;const t=I();if(!t)return;const r=e.el?.isConnected?e.el:k(e.id);if(!r)return;const i=t.getBoundingClientRect(),n=r.getBoundingClientRect().top-i.top-e.offset;Math.abs(n)>2&&(t.scrollTop+=n)},X=()=>{N++,ie(!1)},Q=()=>{const e=h();!e||L()||z!==e&&(d().length<15||Xe())},Xe=async()=>{if(L())return;const e=ze(o),t=d()[0],r=h();if(!e||!t||!r||z===r&&k(t.id))return;const i=++N,n=Ze(),c=I(),u=c?c.scrollTop+c.clientHeight>=c.scrollHeight-32:!0;ie(!0);try{O(e,t.id);const m=Date.now()+12e4,w=setInterval(()=>Ie(n),150);try{for(;Date.now()<m&&i===N&&!k(t.id);)await le(250)}finally{clearInterval(w)}if(i!==N)return;if(Ie(n),u){const b=I();b&&(b.scrollTop=b.scrollHeight)}z=r}finally{i===N&&ie(!1)}};let Le=a.untrack(h);a.createEffect(()=>{const e=h();e!==Le&&(Le=e,X(),v++,F(!1),P(0),j(0),R(0),W(""),q(!1))}),a.createEffect(()=>{const e=h(),t=d().length,r=L();if(!e||t<15||r||z===e||B||V!==e)return;const i=setTimeout(()=>Q(),800);a.onCleanup(()=>clearTimeout(i))}),a.createEffect(()=>{y()&&U()&&requestAnimationFrame(()=>{try{document.querySelector('.sr-list .sr-turn[data-busy="true"]')?.scrollIntoView({block:"nearest"})}catch{}})});const Qe=[s.createComponent(a.Show,{get when(){return D()},get children(){var e=qe();return s.insert(e,D),e}}),(()=>{var e=nt(),t=e.firstChild,r=t.firstChild,i=t.nextSibling;return e.addEventListener("mouseleave",()=>{y()||M(!1)}),e.addEventListener("mouseenter",()=>{M(!0),Q()}),s.insert(t,s.createComponent(a.Show,{get when(){return y()},get fallback(){return(()=>{var n=st();return n.firstChild,s.insert(n,(()=>{var c=s.memo(()=>d().length>0);return()=>c()?` · ${d().length}`:""})(),null),n})()},get children(){var n=tt();return s.insert(n,()=>ve()?"预热中 · 稍等一下":"加载历史中",null),s.insert(n,(()=>{var c=s.memo(()=>we()>0);return()=>c()?` · ${we()} 页`:""})(),null),s.insert(n,(()=>{var c=s.memo(()=>xe()>0);return()=>c()?` · ${xe()}s`:""})(),null),n}}),r),r.$$click=()=>{y()?Me():M(!1)},s.insert(i,s.createComponent(a.Show,{get when(){return d().length>0},get fallback(){return(()=>{var n=ot();return s.insert(n,()=>Fe()||`… ${Be}`),n})()},get children(){return s.createComponent(a.For,{get each(){return d()},children:(n,c)=>(()=>{var u=it(),m=u.firstChild,w=m.firstChild;w.firstChild;var b=w.nextSibling,x=m.nextSibling;return u.$$click=()=>{Ke(n)},s.insert(w,()=>c()+1,null),s.insert(b,()=>wt(n.created)),s.insert(m,()=>n.text,null),s.insert(u,s.createComponent(a.Show,{get when(){return s.memo(()=>!!y())()&&U()===n.id},get children(){var f=at();return s.insert(f,()=>ve()?"稍等":"加载中"),f}}),x),s.effect(f=>{var g=E()===n.id?"true":"false",T=y()&&U()===n.id?"true":"false";return g!==f.e&&s.setAttribute(u,"data-active",f.e=g),T!==f.t&&s.setAttribute(u,"data-busy",f.t=T),f},{e:void 0,t:void 0}),u})()})}})),s.insert(e,s.createComponent(a.Show,{get when(){return s.memo(()=>!!C())()&&D()},get children(){var n=rt();return s.insert(n,D),n}}),null),s.effect(n=>{var c=C()?"true":"false",u=y()?"true":"false",m=y()?"取消跳转 (Esc)":"关闭 (Esc)";return c!==n.e&&s.setAttribute(e,"data-open",n.e=c),u!==n.t&&s.setAttribute(e,"data-jumping",n.t=u),m!==n.a&&s.setAttribute(r,"title",n.a=m),n},{e:void 0,t:void 0,a:void 0}),e})(),s.createComponent(a.Show,{get when(){return s.memo(()=>!C())()&&D()},get children(){var e=qe();return s.insert(e,D),e}})];return s.createComponent(s.Portal,{get mount(){return document.body},children:Qe})}const St=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(l){l.add(H.Style,yt),l.add(H.Slot,{at:"window.bottom",render:()=>s.createComponent(a.Show,{get when(){return l.sessions.current()},get children(){return s.createComponent(kt,{session:()=>l.sessions.current()})}})}),l.add(H.Command,()=>({id:"jump",title:l.t("command.jump"),description:l.t("command.jump.description"),group:l.t("command.jump"),section:"navigation",bind:"mod+alt+r",slash:{name:"turns"},run:()=>{window.dispatchEvent(new Event(pe))}}))}});s.delegateEvents(["click"]);exports.default=St;
|
|
312
|
+
/* Reduced motion: keep the signals (colors, widths) but drop the animation. */
|
|
313
|
+
@media (prefers-reduced-motion: reduce) {
|
|
314
|
+
.sr-rail, .sr-dash, .sr-text { transition: none !important; }
|
|
315
|
+
.sr-turn[data-busy="true"] .sr-dash { animation: none !important; opacity: 1 !important; }
|
|
316
|
+
.sr-flash-ring { animation: none !important; box-shadow: inset 0 0 0 2px rgba(255,170,60,.5) !important; }
|
|
317
|
+
}
|
|
318
|
+
`;function Wt(l){const s=F.usePlugin(),[c,m]=a.createSignal([]),[$,T]=a.createSignal(""),[S,I]=a.createSignal(!1),[lt,ct]=a.createSignal(""),[O,De]=a.createSignal(""),[Q,Ie]=a.createSignal(""),[_,pe]=a.createSignal(!1),[A,X]=a.createSignal(!1),[Le,z]=a.createSignal(0),[Pe,J]=a.createSignal(0),[Gt,N]=a.createSignal(0),[ee,te]=a.createSignal(""),[je,H]=a.createSignal(!1),D=new Map;let fe=0,U=!1,re=!1,ne="",W;const k=()=>l.session()?.id,ge=()=>{const e=[...D.values()].sort((n,i)=>n.created-i.created),t=a.untrack(c);t.length===e.length&&t.every((n,i)=>n===e[i])||(m(e),at=e.length)},qe=e=>{const t=D.get(e.id);t&&t.created===e.time&&t.text===e.text||D.set(e.id,{id:e.id,created:e.time,text:e.text})},dt=async(e,t,n)=>{const i=n?{sessionID:e,type:"user",limit:t,cursor:n}:{sessionID:e,type:"user",limit:t,order:"desc"};return await s.client.message.list(i)},he=async(e,t)=>{const n=++fe;U=!0,t==="full"&&(G="",xe="");let i="";try{let g;const d=t==="full"?Lt:1,v=t==="full"?Pt:jt;let o=t!=="full";for(let u=0;u<d;u++){let f;try{f=await dt(e,v,g)}catch(w){i=String(w?.message??w);break}if(n!==fe)return;o||(D.clear(),o=!0);let h=0;for(const w of f?.data??[])D.has(w.id)||(qe({id:w.id,time:w.time?.created??0,text:ot(w)}),h++);ge();const b=f?.cursor?.next;if(!b||h===0)break;g=b}}finally{U=!1,ne=e,_e=i,ct(D.size===0?i||"暂无用户消息":""),re&&(re=!1,Re()),t==="full"&&!i&&e===a.untrack(k)&&D.size>=15&&setTimeout(()=>V(),1200)}},Re=()=>{W||(W=setTimeout(()=>{W=void 0;const e=a.untrack(k);e&&he(e,"light")},300))},Be=e=>{if(!(!e||e!==a.untrack(k))){if(U){re=!0;return}Re()}},ut=e=>{const t=e?.type;if(t==="message.updated"){const n=e?.data??{},i=n.info;i?.role==="user"&&i?.id&&(qe({id:i.id,time:i.time?.created??0,text:ot(i)}),ge()),Be(n.sessionID)}else if(t==="message.removed"){const n=e?.data??{};n.messageID&&D.delete(n.messageID)&&ge(),Be(n.sessionID)}else if(t==="location.shutdown"){const n=a.untrack(k);n&&ne===n&&he(n,"full")}};a.onMount(()=>{let e=!1,t=0,n;(async()=>{for(;!e;){const d=s.client.event.subscribe()[Symbol.asyncIterator]();try{for(;!e;){const v=await d.next();if(v.done)break;t=0,ut(v.value)}}catch{}try{await d.return?.()}catch{}if(e)break;t++,await de(Math.min(2e3*t,15e3))}})(),a.onCleanup(()=>{e=!0,clearTimeout(n),W&&clearTimeout(W)})}),a.createEffect(()=>{const e=k();e&&a.untrack(()=>{ne!==e&&(fe++,U=!1,re=!1,D.clear(),m([]),T(""),we="",he(e,"full"))})});let se;const j=()=>(se?.isConnected||(se=Te()),se),Oe=new WeakSet;let Y;const pt=()=>Y||(Y=new IntersectionObserver(e=>{e.length!==0&&be()},{rootMargin:"-40px 0px 0px 0px",threshold:0}),Y),ft=()=>{const e=pt();for(const t of document.querySelectorAll("[data-message-id]"))Oe.has(t)||(Oe.add(t),e.observe(t))},gt=()=>{const e=j();if(!e)return;const t=e.querySelectorAll("[data-message-id]");if(t.length===0)return;const n=e.getBoundingClientRect().top+40;for(const i of t)if(i.getBoundingClientRect().bottom>=n){const g=i.getAttribute("data-message-id");g&&g!==$()&&T(g);break}};let me=!1;const be=()=>{me||(me=!0,requestAnimationFrame(()=>{me=!1,gt(),ft()}))};a.onMount(()=>{const e=()=>be();document.addEventListener("scroll",e,!0);const t=setInterval(()=>{document.hidden||be()},4e3);a.onCleanup(()=>{document.removeEventListener("scroll",e,!0),clearInterval(t),Y?.disconnect()})});let ze=0;const oe=(e,t=2600)=>{const n=++ze;De(e),setTimeout(()=>{n===ze&&De("")},t)};a.onMount(()=>{const e=n=>{if(n.key==="Escape"){if(a.untrack(A)){Ue(),n.stopPropagation();return}a.untrack(S)&&(a.untrack(_)?pe(!1):I(!1),n.stopPropagation())}},t=n=>{if(!a.untrack(S)||a.untrack(_))return;(n.composedPath?.()??[]).some(d=>d instanceof Element&&d.closest?.(".sr-rail"))||I(!1)};window.addEventListener("keydown",e,!0),document.addEventListener("pointerdown",t,!0),a.onCleanup(()=>{window.removeEventListener("keydown",e,!0),document.removeEventListener("pointerdown",t,!0)})}),a.onMount(()=>{const e=()=>oe(`${rt} · ${ke(s)}`),t=()=>{I(i=>!i),V()},n=()=>{pe(i=>!i),a.untrack(S)||(I(!0),V())};window.addEventListener(nt,e),window.addEventListener(Ce,t),window.addEventListener(Ee,n),a.onCleanup(()=>{window.removeEventListener(nt,e),window.removeEventListener(Ce,t),window.removeEventListener(Ee,n)})});let we="",C=0,Fe=0,Je="";const ie=e=>{try{const t=e.querySelector('[data-slot="user-message-text"]')??e.querySelector('[data-slot="user-message-body"]')??e;t.classList.remove("sr-flash-ring"),t.offsetWidth,t.classList.add("sr-flash-ring"),setTimeout(()=>t.classList.remove("sr-flash-ring"),1900)}catch{}},Ne=()=>{for(const e of c())if(E(e.id))return e},q=(e,t)=>{Ht(`${e}#${Se}${t}`),we=t},ae=()=>{const t=j()?.querySelectorAll("[data-message-id]")??[];if(t.length===0)return"0";const n=t[0].getAttribute("data-message-id")??"",i=t[t.length-1].getAttribute("data-message-id")??"";return`${t.length}:${n}:${i}`},He=(e,t,n,i)=>new Promise(g=>{let d=!1,v,o,u;const f=Date.now();let b=ae(),w=f,y=0;const p=M=>{if(!d){d=!0;try{v?.disconnect()}catch{}clearInterval(o),clearTimeout(u),g(M??null)}},x=M=>{if(t!==C)return p(null);const Z=M?E(e):document.getElementById(`${Se}${e}`);if(Z)return p(Z);if(!M)return;const Xe=ae(),et=Date.now();if(Xe!==b){b=Xe,w=et,y++,i?.(y);return}const vt=y>0?Ot:zt;et-w>vt&&p(null)},L=E(e);if(L)return p(L);v=new MutationObserver(()=>x(!1)),v.observe(document.documentElement,{childList:!0,subtree:!0}),o=setInterval(()=>x(!0),120),u=setTimeout(()=>p(E(e)),Math.max(400,n))}),Ue=()=>{C++,X(!1),z(0),J(0),N(0),te(""),H(!1),oe("已取消跳转")},We=async e=>{if(A()&&ee()===e.id)return;const t=it(s),n=j(),i=E(e.id);if(i&&n){R()&&(le(),t&&q(t,e.id)),ue(i),ie(i);return}const g=Date.now();if(e.id===Je&&g-Fe<Ft){const h=E(e.id);if(h&&n){ue(h),ie(h);return}}if(Fe=g,Je=e.id,Me=e.id,!t){oe(`无法读取当前路由,无法定位 · ${ke(s)}`);return}const d=++C,v=k();X(!0),te(e.id),z(0),J(0),N(0),H(R()),I(!0);const o=Date.now(),u=setInterval(()=>{J(Math.round((Date.now()-o)/1e3))},500);let f=0;try{if(R()){const b=await He(e.id,d,2e4,w=>z(w));if(d!==C||k()!==v)return;if(b){le(),q(t,e.id),ue(b),ie(b);return}if(R()&&le(),H(!1),d!==C||k()!==v)return}const h=Date.now()+Rt;for(let b=0;b<qt&&Date.now()<h;b++){if(d!==C||k()!==v)return;if(N(b+1),we===e.id){const x=Ne()??c()[0];x&&(q(t,x.id),await de(st))}if(d!==C||k()!==v)return;q(t,e.id);let w=0;const y=Math.min(Bt,h-Date.now()),p=await He(e.id,d,y,x=>{w=x,z(f+x)});if(f+=w,d!==C||k()!==v)return;if(p){ue(p),ie(p);return}}d===C&&oe(`未能定位 · ${ke(s)}`)}finally{clearInterval(u),d===C&&(X(!1),z(0),J(0),N(0),te(""),H(!1))}},[R,ve]=a.createSignal(!1);let B=0,G="",xe="";const ht=()=>{const e=j();if(!e)return;const t=e.getBoundingClientRect();for(const n of e.querySelectorAll("[data-message-id]")){const i=n.getBoundingClientRect();if(i.bottom<=t.top+1)continue;const g=n.getAttribute("data-message-id");if(g)return{el:n,id:g,offset:i.top-t.top}}},Ye=e=>{if(!e)return;const t=j();if(!t)return;const n=e.el?.isConnected?e.el:E(e.id);if(!n)return;const i=t.getBoundingClientRect(),g=n.getBoundingClientRect().top-i.top-e.offset;Math.abs(g)>2&&(t.scrollTop+=g)},le=()=>{B++,ve(!1)},V=()=>{const e=k();!e||R()||G!==e&&(c().length<15||mt())},mt=async()=>{if(R())return;const e=it(s),t=c()[0],n=k();if(!e||!t||!n||G===n&&E(t.id))return;const i=++B,g=ht(),d=j(),v=d?d.scrollTop+d.clientHeight>=d.scrollHeight-32:!0;ve(!0);try{q(e,t.id);const o=Date.now()+6e4;let u=ae(),f=Date.now(),h=0;const b=setInterval(()=>Ye(g),150);try{for(;Date.now()<o&&i===B&&!(E(t.id)||(await de(250),i!==B));){const y=Date.now(),p=ae();if(p!==u)u=p,f=y,h=0;else if(y-f>5e3){if(h>=2)break;const x=Ne();if(x&&x.id!==t.id&&(q(e,x.id),await de(st),i!==B))break;q(e,t.id),f=Date.now(),h++}}}finally{clearInterval(b)}if(i!==B)return;const w=!!E(t.id);if(Ye(g),v){const y=j();y&&(y.scrollTop=y.scrollHeight)}w?G=n:xe=n}finally{i===B&&ve(!1)}};let Ge=a.untrack(k);a.createEffect(()=>{const e=k();e!==Ge&&(Ge=e,le(),C++,X(!1),z(0),J(0),N(0),te(""),H(!1))}),a.createEffect(()=>{const e=k(),t=c().length,n=R();if(!e||t<15||n||G===e||xe===e||U||ne!==e)return;const i=setTimeout(()=>V(),1200);a.onCleanup(()=>clearTimeout(i))});const K=a.createMemo(()=>{const e=c(),t=Q().trim().toLowerCase();return t?e.filter(n=>n.text.toLowerCase().includes(t)):e}),[ce,Ve]=a.createSignal(-1),Ke=()=>Ve(-1),Ze=e=>{const t=K();if(t.length===0)return;let n=ce();if(n<0){const i=$();n=t.findIndex(g=>g.id===i),n<0&&(n=e>0?-1:0)}n=Math.min(t.length-1,Math.max(0,n+e)),Ve(n),requestAnimationFrame(()=>{document.querySelector(`.sr-list .sr-turn[data-index="${n}"]`)?.scrollIntoView({block:"nearest"})})},bt=()=>{const e=K()[ce()];e&&We(e)};let ye;const Qe=e=>{e.key==="ArrowDown"?(e.preventDefault(),e.stopPropagation(),Ze(1)):e.key==="ArrowUp"?(e.preventDefault(),e.stopPropagation(),Ze(-1)):e.key==="Enter"&&ce()>=0&&(e.preventDefault(),e.stopPropagation(),bt())};a.createEffect(()=>{A()&&ee()&&requestAnimationFrame(()=>{try{document.querySelector('.sr-list .sr-turn[data-busy="true"]')?.scrollIntoView({block:"nearest"})}catch{}})});const wt=[r.createComponent(a.Show,{get when(){return O()},get children(){var e=tt();return r.insert(e,O),e}}),(()=>{var e=Et(),t=e.firstChild,n=t.firstChild,i=n.firstChild,g=i.firstChild,d=i.nextSibling,v=t.nextSibling;return e.addEventListener("mouseleave",()=>{!A()&&!_()&&I(!1)}),e.addEventListener("mouseenter",()=>{I(!0),V()}),r.insert(t,r.createComponent(a.Show,{get when(){return A()},get fallback(){return(()=>{var o=_t();return o.firstChild,r.insert(o,(()=>{var u=r.memo(()=>c().length>0);return()=>u()?` · ${c().length}`:""})(),null),r.insert(o,r.createComponent(a.Show,{get when(){return Q().trim()},get children(){return[" · 匹配 ",r.memo(()=>K().length)]}}),null),o})()},get children(){var o=xt();return r.insert(o,()=>je()?"预热中 · 稍等一下":"加载历史中",null),r.insert(o,(()=>{var u=r.memo(()=>Le()>0);return()=>u()?` · ${Le()} 页`:""})(),null),r.insert(o,(()=>{var u=r.memo(()=>Pe()>0);return()=>u()?` · ${Pe()}s`:""})(),null),o}}),n),i.$$click=()=>pe(!_()),r.insert(g,r.createComponent(a.Show,{get when(){return _()},get fallback(){return[At(),Mt()]},get children(){return[yt(),kt()]}})),d.$$click=()=>{A()?Ue():I(!1)},r.insert(e,r.createComponent(a.Show,{get when(){return c().length>=8},get children(){var o=St(),u=o.firstChild;u.$$keydown=Qe,u.$$input=h=>{Ie(h.currentTarget.value),Ke()};var f=ye;return typeof f=="function"?r.use(f,u):ye=u,r.insert(o,r.createComponent(a.Show,{get when(){return Q()},get children(){var h=$t();return h.$$click=()=>{Ie(""),Ke(),ye?.focus()},h}}),null),r.effect(()=>u.value=Q()),o}}),v),v.$$keydown=Qe,r.insert(v,r.createComponent(a.Show,{get when(){return K().length>0},get fallback(){return(()=>{var o=Tt();return r.insert(o,()=>lt()||`… ${rt}`),o})()},get children(){return r.createComponent(a.For,{get each(){return K()},children:(o,u)=>(()=>{var f=It(),h=f.firstChild,b=h.firstChild;b.firstChild;var w=b.nextSibling,y=h.nextSibling;return f.$$click=()=>{We(o)},r.insert(b,()=>c().indexOf(o)+1,null),r.insert(w,()=>Jt(o.created)),r.insert(h,()=>o.text,null),r.insert(f,r.createComponent(a.Show,{get when(){return r.memo(()=>!!A())()&&ee()===o.id},get children(){var p=Dt();return r.insert(p,()=>je()?"稍等":"加载中"),p}}),y),r.effect(p=>{var x=$()===o.id?"true":"false",L=ce()===u()?"true":"false",M=A()&&ee()===o.id?"true":"false",Z=u();return x!==p.e&&r.setAttribute(f,"data-active",p.e=x),L!==p.t&&r.setAttribute(f,"data-cursor",p.t=L),M!==p.a&&r.setAttribute(f,"data-busy",p.a=M),Z!==p.o&&r.setAttribute(f,"data-index",p.o=Z),p},{e:void 0,t:void 0,a:void 0,o:void 0}),f})()})}})),r.insert(e,r.createComponent(a.Show,{get when(){return r.memo(()=>!!S())()&&O()},get children(){var o=Ct();return r.insert(o,O),o}}),null),r.effect(o=>{var u=S()?"true":"false",f=A()?"true":"false",h=_()?"true":"false",b=S()?"dialog":void 0,w=S()?"会话轮次":void 0,y=_()?"true":"false",p=_()?"取消固定 (⌘⌥P)":"固定面板 (⌘⌥P)",x=_()?"取消固定面板":"固定面板",L=_()?"true":"false",M=A()?"取消跳转 (Esc)":"关闭 (Esc)";return u!==o.e&&r.setAttribute(e,"data-open",o.e=u),f!==o.t&&r.setAttribute(e,"data-jumping",o.t=f),h!==o.a&&r.setAttribute(e,"data-pinned",o.a=h),b!==o.o&&r.setAttribute(e,"role",o.o=b),w!==o.i&&r.setAttribute(e,"aria-label",o.i=w),y!==o.n&&r.setAttribute(i,"data-on",o.n=y),p!==o.s&&r.setAttribute(i,"title",o.s=p),x!==o.h&&r.setAttribute(i,"aria-label",o.h=x),L!==o.r&&r.setAttribute(i,"aria-pressed",o.r=L),M!==o.d&&r.setAttribute(d,"title",o.d=M),o},{e:void 0,t:void 0,a:void 0,o:void 0,i:void 0,n:void 0,s:void 0,h:void 0,r:void 0,d:void 0}),e})(),r.createComponent(a.Show,{get when(){return r.memo(()=>!S())()&&O()},get children(){var e=tt();return r.insert(e,O),e}})];return r.createComponent(r.Portal,{get mount(){return document.body},children:wt})}const Yt=F.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","command.pin":"Session turns: pin panel","command.pin.description":"Toggle pinning the turn rail open (⌘⌥P)"}},setup(l){l.add(F.Style,Ut),l.add(F.Slot,{at:"window.bottom",render:()=>r.createComponent(a.Show,{get when(){return l.sessions.current()},get children(){return r.createComponent(Wt,{session:()=>l.sessions.current()})}})}),l.add(F.Command,()=>({id:"jump",title:l.t("command.jump"),description:l.t("command.jump.description"),section:"navigation",bind:"mod+alt+r",slash:{name:"turns"},run:()=>{window.dispatchEvent(new Event(Ce))}})),l.add(F.Command,()=>({id:"pin",title:l.t("command.pin"),description:l.t("command.pin.description"),section:"navigation",bind:"mod+alt+p",slash:{name:"turns-pin"},run:()=>{window.dispatchEvent(new Event(Ee))}}))}});r.delegateEvents(["click","input","keydown"]);exports.default=Yt;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-session-turns",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
4
4
|
"description": "OpenCode Desktop 插件:在会话右缘列出每一轮用户提问,点击即可跳转到对应消息。A right-edge rail of user turns; click one to jump to that message.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"scripts": {
|
|
40
40
|
"build": "vite build",
|
|
41
41
|
"dev": "vite build --watch",
|
|
42
|
-
"prepublishOnly": "npm run build"
|
|
42
|
+
"prepublishOnly": "node scripts/ensure-ci-publish.mjs && npm run build"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@opencode/plugin": "^2.2.6",
|
package/src/gui.tsx
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { Command, Plugin, Slot, Style, usePlugin } from "@opencode/plugin/gui"
|
|
2
|
-
import { For, Show, createEffect, createSignal, onCleanup, onMount, untrack } from "solid-js"
|
|
2
|
+
import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount, untrack } from "solid-js"
|
|
3
3
|
import { Portal } from "solid-js/web"
|
|
4
4
|
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
6
6
|
// constants / helpers
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
|
|
9
|
-
const VERSION = "
|
|
9
|
+
const VERSION = "v21"
|
|
10
10
|
const HASH_PREFIX = "message-"
|
|
11
11
|
const DIAG_EVENT = "session-turns:diag"
|
|
12
12
|
const OPEN_EVENT = "session-turns:open"
|
|
13
|
+
const PIN_EVENT = "session-turns:pin"
|
|
13
14
|
|
|
14
15
|
// paging: a full load walks every page; a light refresh pulls just the newest
|
|
15
16
|
// page (new messages always land at the head of a desc listing).
|
|
@@ -53,7 +54,21 @@ const pad = (value) => String(value).padStart(2, "0")
|
|
|
53
54
|
|
|
54
55
|
const formatTime = (created) => {
|
|
55
56
|
const date = new Date(created ?? 0)
|
|
56
|
-
|
|
57
|
+
const now = new Date()
|
|
58
|
+
const sameDay =
|
|
59
|
+
date.getFullYear() === now.getFullYear() &&
|
|
60
|
+
date.getMonth() === now.getMonth() &&
|
|
61
|
+
date.getDate() === now.getDate()
|
|
62
|
+
if (sameDay) return `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
63
|
+
const yesterday = new Date(now)
|
|
64
|
+
yesterday.setDate(now.getDate() - 1)
|
|
65
|
+
const wasYesterday =
|
|
66
|
+
date.getFullYear() === yesterday.getFullYear() &&
|
|
67
|
+
date.getMonth() === yesterday.getMonth() &&
|
|
68
|
+
date.getDate() === yesterday.getDate()
|
|
69
|
+
if (wasYesterday) return `昨天 ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
70
|
+
// Older turns need a date: a bare HH:mm is ambiguous in multi-day sessions.
|
|
71
|
+
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
57
72
|
}
|
|
58
73
|
|
|
59
74
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
@@ -239,6 +254,73 @@ const CSS = `
|
|
|
239
254
|
color: rgba(255,170,60,.95);
|
|
240
255
|
font-variant-numeric: tabular-nums;
|
|
241
256
|
}
|
|
257
|
+
.sr-head-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
|
|
258
|
+
.sr-iconbtn {
|
|
259
|
+
display: inline-flex;
|
|
260
|
+
align-items: center;
|
|
261
|
+
justify-content: center;
|
|
262
|
+
width: 22px;
|
|
263
|
+
height: 22px;
|
|
264
|
+
padding: 0;
|
|
265
|
+
border: 0;
|
|
266
|
+
background: transparent;
|
|
267
|
+
color: inherit;
|
|
268
|
+
cursor: pointer;
|
|
269
|
+
border-radius: 4px;
|
|
270
|
+
opacity: .5;
|
|
271
|
+
}
|
|
272
|
+
.sr-iconbtn svg { width: 13px; height: 13px; }
|
|
273
|
+
.sr-iconbtn:hover { opacity: 1; background: var(--surface-base-hover, rgba(127,127,127,.14)); }
|
|
274
|
+
.sr-iconbtn[data-on="true"] { opacity: .95; color: rgba(255,170,60,.95); }
|
|
275
|
+
.sr-iconbtn:focus-visible { outline: 1px solid currentColor; outline-offset: -1px; }
|
|
276
|
+
/* search box */
|
|
277
|
+
.sr-search {
|
|
278
|
+
position: relative;
|
|
279
|
+
flex-shrink: 0;
|
|
280
|
+
margin: 0 0 6px;
|
|
281
|
+
}
|
|
282
|
+
.sr-search-input {
|
|
283
|
+
width: 100%;
|
|
284
|
+
box-sizing: border-box;
|
|
285
|
+
height: 26px;
|
|
286
|
+
padding: 0 24px 0 8px;
|
|
287
|
+
border: 1px solid rgba(127,127,127,.25);
|
|
288
|
+
border-radius: 6px;
|
|
289
|
+
background: color-mix(in srgb, currentColor 6%, transparent);
|
|
290
|
+
color: inherit;
|
|
291
|
+
font: inherit;
|
|
292
|
+
font-size: 12px;
|
|
293
|
+
outline: none;
|
|
294
|
+
transition: border-color .12s ease, box-shadow .12s ease;
|
|
295
|
+
}
|
|
296
|
+
.sr-search-input::placeholder { color: currentColor; opacity: .4; }
|
|
297
|
+
.sr-search-input:focus {
|
|
298
|
+
border-color: rgba(255,170,60,.6);
|
|
299
|
+
box-shadow: 0 0 0 2px rgba(255,170,60,.14);
|
|
300
|
+
}
|
|
301
|
+
.sr-search-clear {
|
|
302
|
+
position: absolute;
|
|
303
|
+
right: 4px;
|
|
304
|
+
top: 50%;
|
|
305
|
+
transform: translateY(-50%);
|
|
306
|
+
display: inline-flex;
|
|
307
|
+
align-items: center;
|
|
308
|
+
justify-content: center;
|
|
309
|
+
width: 16px;
|
|
310
|
+
height: 16px;
|
|
311
|
+
padding: 0;
|
|
312
|
+
border: 0;
|
|
313
|
+
border-radius: 50%;
|
|
314
|
+
background: transparent;
|
|
315
|
+
color: inherit;
|
|
316
|
+
font-size: 12px;
|
|
317
|
+
line-height: 1;
|
|
318
|
+
cursor: pointer;
|
|
319
|
+
opacity: .5;
|
|
320
|
+
}
|
|
321
|
+
.sr-search-clear:hover { opacity: 1; background: var(--surface-base-hover, rgba(127,127,127,.14)); }
|
|
322
|
+
/* keyboard cursor (↑/↓) */
|
|
323
|
+
.sr-turn[data-cursor="true"] { background: var(--surface-base-hover, rgba(127,127,127,.14)); }
|
|
242
324
|
.sr-close {
|
|
243
325
|
border: 0;
|
|
244
326
|
background: transparent;
|
|
@@ -435,6 +517,12 @@ const CSS = `
|
|
|
435
517
|
white-space: pre-wrap;
|
|
436
518
|
max-width: 260px;
|
|
437
519
|
}
|
|
520
|
+
/* Reduced motion: keep the signals (colors, widths) but drop the animation. */
|
|
521
|
+
@media (prefers-reduced-motion: reduce) {
|
|
522
|
+
.sr-rail, .sr-dash, .sr-text { transition: none !important; }
|
|
523
|
+
.sr-turn[data-busy="true"] .sr-dash { animation: none !important; opacity: 1 !important; }
|
|
524
|
+
.sr-flash-ring { animation: none !important; box-shadow: inset 0 0 0 2px rgba(255,170,60,.5) !important; }
|
|
525
|
+
}
|
|
438
526
|
`
|
|
439
527
|
|
|
440
528
|
// ---------------------------------------------------------------------------
|
|
@@ -448,6 +536,10 @@ function Rail(props) {
|
|
|
448
536
|
const [open, setOpen] = createSignal(false)
|
|
449
537
|
const [note, setNote] = createSignal("")
|
|
450
538
|
const [status, setStatus] = createSignal("")
|
|
539
|
+
// Search box over the turn list; empty means show everything.
|
|
540
|
+
const [query, setQuery] = createSignal("")
|
|
541
|
+
// Pinned panels stay open when the pointer leaves (toggle: ⌘⌥P / /turns pin).
|
|
542
|
+
const [pinned, setPinned] = createSignal(false)
|
|
451
543
|
// Jump progress. While `jumping` is true the panel stays pinned open and the
|
|
452
544
|
// header reports what the host is doing; `busyID` marks the target row.
|
|
453
545
|
const [jumping, setJumping] = createSignal(false)
|
|
@@ -502,7 +594,10 @@ function Rail(props) {
|
|
|
502
594
|
loadingActive = true
|
|
503
595
|
// A full (re)load means the host store may be cold again (session open or
|
|
504
596
|
// server rebuild): invalidate the prewarm marker so it runs once more.
|
|
505
|
-
if (mode === "full")
|
|
597
|
+
if (mode === "full") {
|
|
598
|
+
warmedFor = ""
|
|
599
|
+
warmBlockedFor = ""
|
|
600
|
+
}
|
|
506
601
|
let error = ""
|
|
507
602
|
try {
|
|
508
603
|
let cursor
|
|
@@ -552,7 +647,7 @@ function Rail(props) {
|
|
|
552
647
|
// Prewarm long sessions as soon as their full turn list has loaded, so
|
|
553
648
|
// the history is warm before the first click (not only on rail hover).
|
|
554
649
|
if (mode === "full" && !error && id === untrack(sessionID) && cache.size >= 15) {
|
|
555
|
-
setTimeout(() => maybePrewarm(),
|
|
650
|
+
setTimeout(() => maybePrewarm(), 1200)
|
|
556
651
|
}
|
|
557
652
|
}
|
|
558
653
|
}
|
|
@@ -659,9 +754,11 @@ function Rail(props) {
|
|
|
659
754
|
|
|
660
755
|
// ---- active-turn tracking ----------------------------------------------
|
|
661
756
|
// Driven by scroll events (captured at document level so it also sees the
|
|
662
|
-
// real scroller without querying first) plus one rAF-throttled pass
|
|
663
|
-
//
|
|
664
|
-
//
|
|
757
|
+
// real scroller without querying first) plus one rAF-throttled pass. An
|
|
758
|
+
// IntersectionObserver on the timeline rows replaces the old 1.5s interval:
|
|
759
|
+
// a row leaving the sentinel line at the top of the viewport means the turn
|
|
760
|
+
// under it became active, with zero work when nothing moves (and none at
|
|
761
|
+
// all in a background window, where rAF is throttled to zero).
|
|
665
762
|
let scrollerCache
|
|
666
763
|
const scroller = () => {
|
|
667
764
|
if (scrollerCache?.isConnected) return scrollerCache
|
|
@@ -669,6 +766,35 @@ function Rail(props) {
|
|
|
669
766
|
return scrollerCache
|
|
670
767
|
}
|
|
671
768
|
|
|
769
|
+
// Rows already wired to the observer, keyed by element so history paging
|
|
770
|
+
// (rows mounting/unmounting) never re-observes the same node.
|
|
771
|
+
const observed = new WeakSet()
|
|
772
|
+
let rowObserver
|
|
773
|
+
const ensureObserver = () => {
|
|
774
|
+
if (rowObserver) return rowObserver
|
|
775
|
+
rowObserver = new IntersectionObserver(
|
|
776
|
+
(entries) => {
|
|
777
|
+
// Any intersection change along the sentinel line can shift which turn
|
|
778
|
+
// is topmost; recompute from the DOM (rAF-throttled, cheap).
|
|
779
|
+
if (entries.length === 0) return
|
|
780
|
+
scheduleTrack()
|
|
781
|
+
},
|
|
782
|
+
{ rootMargin: "-40px 0px 0px 0px", threshold: 0 },
|
|
783
|
+
)
|
|
784
|
+
return rowObserver
|
|
785
|
+
}
|
|
786
|
+
// Wire up rows that appeared since the last pass. Called from trackActive,
|
|
787
|
+
// so it only runs while the document is actually scrolling (rAF-gated).
|
|
788
|
+
const observeRows = () => {
|
|
789
|
+
const observer = ensureObserver()
|
|
790
|
+
for (const row of document.querySelectorAll("[data-message-id]")) {
|
|
791
|
+
if (!observed.has(row)) {
|
|
792
|
+
observed.add(row)
|
|
793
|
+
observer.observe(row)
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
672
798
|
const trackActive = () => {
|
|
673
799
|
const sc = scroller()
|
|
674
800
|
if (!sc) return
|
|
@@ -678,7 +804,7 @@ function Rail(props) {
|
|
|
678
804
|
for (const row of rows) {
|
|
679
805
|
if (row.getBoundingClientRect().bottom >= top) {
|
|
680
806
|
const id = row.getAttribute("data-message-id")
|
|
681
|
-
if (id) setActive(id)
|
|
807
|
+
if (id && id !== active()) setActive(id)
|
|
682
808
|
break
|
|
683
809
|
}
|
|
684
810
|
}
|
|
@@ -691,16 +817,25 @@ function Rail(props) {
|
|
|
691
817
|
requestAnimationFrame(() => {
|
|
692
818
|
rafPending = false
|
|
693
819
|
trackActive()
|
|
820
|
+
observeRows()
|
|
694
821
|
})
|
|
695
822
|
}
|
|
696
823
|
|
|
697
824
|
onMount(() => {
|
|
698
825
|
const onScroll = () => scheduleTrack()
|
|
699
826
|
document.addEventListener("scroll", onScroll, true)
|
|
700
|
-
|
|
827
|
+
// One slow liveness pass only while the tab is visible: catches rows that
|
|
828
|
+
// mount without a scroll event and reconnects after DOM swaps, without
|
|
829
|
+
// burning cycles in background windows (rAF never fires there, and the
|
|
830
|
+
// interval self-pauses via document.hidden).
|
|
831
|
+
const fallback = setInterval(() => {
|
|
832
|
+
if (document.hidden) return
|
|
833
|
+
scheduleTrack()
|
|
834
|
+
}, 4000)
|
|
701
835
|
onCleanup(() => {
|
|
702
836
|
document.removeEventListener("scroll", onScroll, true)
|
|
703
837
|
clearInterval(fallback)
|
|
838
|
+
rowObserver?.disconnect()
|
|
704
839
|
})
|
|
705
840
|
})
|
|
706
841
|
|
|
@@ -728,7 +863,12 @@ function Rail(props) {
|
|
|
728
863
|
return
|
|
729
864
|
}
|
|
730
865
|
if (untrack(open)) {
|
|
731
|
-
|
|
866
|
+
// Unpin first when pinned; a second Esc then closes the panel.
|
|
867
|
+
if (untrack(pinned)) {
|
|
868
|
+
setPinned(false)
|
|
869
|
+
} else {
|
|
870
|
+
setOpen(false)
|
|
871
|
+
}
|
|
732
872
|
// Only swallow Esc while the rail is actually open, so the host keeps
|
|
733
873
|
// its own Escape handling the rest of the time.
|
|
734
874
|
event.stopPropagation()
|
|
@@ -736,6 +876,7 @@ function Rail(props) {
|
|
|
736
876
|
}
|
|
737
877
|
const onPointerDown = (event) => {
|
|
738
878
|
if (!untrack(open)) return
|
|
879
|
+
if (untrack(pinned)) return
|
|
739
880
|
const path = event.composedPath?.() ?? []
|
|
740
881
|
const inside = path.some((node) => node instanceof Element && node.closest?.(".sr-rail"))
|
|
741
882
|
if (!inside) setOpen(false)
|
|
@@ -750,17 +891,27 @@ function Rail(props) {
|
|
|
750
891
|
|
|
751
892
|
onMount(() => {
|
|
752
893
|
const onDiag = () => flash(`${VERSION} · ${diagnostics(ctx)}`)
|
|
753
|
-
// /turns toggles the panel;
|
|
754
|
-
//
|
|
894
|
+
// /turns toggles the panel; ⌘⌥P / the pin button toggles "pinned" (stay
|
|
895
|
+
// open when the pointer leaves). Esc / outside click / × still close an
|
|
896
|
+
// unpinned panel.
|
|
755
897
|
const onOpen = () => {
|
|
756
898
|
setOpen((value) => !value)
|
|
757
899
|
maybePrewarm()
|
|
758
900
|
}
|
|
901
|
+
const onPin = () => {
|
|
902
|
+
setPinned((value) => !value)
|
|
903
|
+
if (!untrack(open)) {
|
|
904
|
+
setOpen(true)
|
|
905
|
+
maybePrewarm()
|
|
906
|
+
}
|
|
907
|
+
}
|
|
759
908
|
window.addEventListener(DIAG_EVENT, onDiag)
|
|
760
909
|
window.addEventListener(OPEN_EVENT, onOpen)
|
|
910
|
+
window.addEventListener(PIN_EVENT, onPin)
|
|
761
911
|
onCleanup(() => {
|
|
762
912
|
window.removeEventListener(DIAG_EVENT, onDiag)
|
|
763
913
|
window.removeEventListener(OPEN_EVENT, onOpen)
|
|
914
|
+
window.removeEventListener(PIN_EVENT, onPin)
|
|
764
915
|
})
|
|
765
916
|
// No startup toast: the plugin should be invisible when everything works.
|
|
766
917
|
})
|
|
@@ -953,7 +1104,7 @@ function Rail(props) {
|
|
|
953
1104
|
// seek: just wait for the row to mount, then land. The header reads
|
|
954
1105
|
// "预热中 · 稍等一下" while we wait.
|
|
955
1106
|
if (warming()) {
|
|
956
|
-
const landed = await waitForRow(turn.id, token,
|
|
1107
|
+
const landed = await waitForRow(turn.id, token, 20000, (pages) => setJumpPages(pages))
|
|
957
1108
|
if (token !== jumpToken || sessionID() !== sessionAtJump) return
|
|
958
1109
|
if (landed) {
|
|
959
1110
|
cancelWarm()
|
|
@@ -1041,6 +1192,10 @@ function Rail(props) {
|
|
|
1041
1192
|
const [warming, setWarming] = createSignal(false)
|
|
1042
1193
|
let warmToken = 0
|
|
1043
1194
|
let warmedFor = ""
|
|
1195
|
+
// Set when a prewarm gave up without reaching the oldest turn (the host never
|
|
1196
|
+
// engaged). The auto-resume effect must not retry it in a loop; an explicit
|
|
1197
|
+
// hover or a fresh full load still may.
|
|
1198
|
+
let warmBlockedFor = ""
|
|
1044
1199
|
|
|
1045
1200
|
// The topmost visible row plus its offset, so the viewport can be restored.
|
|
1046
1201
|
const viewAnchor = () => {
|
|
@@ -1100,24 +1255,61 @@ function Rail(props) {
|
|
|
1100
1255
|
setWarming(true)
|
|
1101
1256
|
try {
|
|
1102
1257
|
navTo(path, oldest.id)
|
|
1103
|
-
|
|
1258
|
+
// The host's seek machine starts inactive and DROPS a seek issued before
|
|
1259
|
+
// the timeline is ready - exactly the situation on session open. So don't
|
|
1260
|
+
// wait blindly for the oldest row: watch for real paging progress, and if
|
|
1261
|
+
// the host goes silent, bounce the hash to re-engage it once it is ready.
|
|
1262
|
+
// If it never engages, bail fast (without marking warm) so clicks fall
|
|
1263
|
+
// back to a direct jump instead of all showing "预热中 · 稍等一下" for 2min.
|
|
1264
|
+
const deadline = Date.now() + 60000
|
|
1265
|
+
let lastSig = rowSignature()
|
|
1266
|
+
let lastChange = Date.now()
|
|
1267
|
+
let kicks = 0
|
|
1104
1268
|
const keep = setInterval(() => restoreView(anchor), 150)
|
|
1105
1269
|
try {
|
|
1106
1270
|
while (Date.now() < deadline && token === warmToken) {
|
|
1107
1271
|
if (findRow(oldest.id)) break
|
|
1108
1272
|
await sleep(250)
|
|
1273
|
+
if (token !== warmToken) break
|
|
1274
|
+
const now = Date.now()
|
|
1275
|
+
const sig = rowSignature()
|
|
1276
|
+
if (sig !== lastSig) {
|
|
1277
|
+
// Real progress: the host is paging. Keep waiting, reset the kick budget.
|
|
1278
|
+
lastSig = sig
|
|
1279
|
+
lastChange = now
|
|
1280
|
+
kicks = 0
|
|
1281
|
+
} else if (now - lastChange > 5000) {
|
|
1282
|
+
if (kicks >= 2) break // host never engaged: give up, stay un-warm
|
|
1283
|
+
// Re-engage: a repeated identical hash is ignored by the router, so
|
|
1284
|
+
// bounce through a mounted turn first, then back to the oldest.
|
|
1285
|
+
const other = oldestLoadedTurn()
|
|
1286
|
+
if (other && other.id !== oldest.id) {
|
|
1287
|
+
navTo(path, other.id)
|
|
1288
|
+
await sleep(JUMP_TOGGLE_DWELL_MS)
|
|
1289
|
+
if (token !== warmToken) break
|
|
1290
|
+
}
|
|
1291
|
+
navTo(path, oldest.id)
|
|
1292
|
+
lastChange = Date.now()
|
|
1293
|
+
kicks++
|
|
1294
|
+
}
|
|
1109
1295
|
}
|
|
1110
1296
|
} finally {
|
|
1111
1297
|
clearInterval(keep)
|
|
1112
1298
|
}
|
|
1113
1299
|
if (token !== warmToken) return
|
|
1300
|
+
const done = !!findRow(oldest.id)
|
|
1114
1301
|
// The host scrolled to the oldest message; put the viewport back.
|
|
1115
1302
|
restoreView(anchor)
|
|
1116
1303
|
if (wasAtBottom) {
|
|
1117
1304
|
const live = scroller()
|
|
1118
1305
|
if (live) live.scrollTop = live.scrollHeight
|
|
1119
1306
|
}
|
|
1120
|
-
|
|
1307
|
+
if (done) {
|
|
1308
|
+
warmedFor = sid
|
|
1309
|
+
} else {
|
|
1310
|
+
// Never claim the history is ready when the oldest turn never mounted.
|
|
1311
|
+
warmBlockedFor = sid
|
|
1312
|
+
}
|
|
1121
1313
|
// Silent on success: prewarming is an implementation detail.
|
|
1122
1314
|
} finally {
|
|
1123
1315
|
if (token === warmToken) setWarming(false)
|
|
@@ -1145,18 +1337,76 @@ function Rail(props) {
|
|
|
1145
1337
|
// history still gets warmed for the next click. The initial start happens in
|
|
1146
1338
|
// load()'s finally (once the full turn list is in); this only fires when the
|
|
1147
1339
|
// warming signal flips back to false while the session is not fully warm.
|
|
1340
|
+
// A prewarm that gave up (warmBlockedFor) is NOT retried here, otherwise a
|
|
1341
|
+
// host that never engages would loop forever and keep every click on "稍等".
|
|
1148
1342
|
createEffect(() => {
|
|
1149
1343
|
const sid = sessionID()
|
|
1150
1344
|
const count = turns().length
|
|
1151
1345
|
const hot = warming()
|
|
1152
|
-
if (!sid || count < 15 || hot || warmedFor === sid) return
|
|
1346
|
+
if (!sid || count < 15 || hot || warmedFor === sid || warmBlockedFor === sid) return
|
|
1153
1347
|
if (loadingActive || loadedFor !== sid) return
|
|
1154
|
-
const timer = setTimeout(() => maybePrewarm(),
|
|
1348
|
+
const timer = setTimeout(() => maybePrewarm(), 1200)
|
|
1155
1349
|
onCleanup(() => clearTimeout(timer))
|
|
1156
1350
|
})
|
|
1157
1351
|
|
|
1158
|
-
//
|
|
1159
|
-
//
|
|
1352
|
+
// ---- search / keyboard navigation --------------------------------------
|
|
1353
|
+
// Filtered view of the turns; identity-stable so <For> keeps DOM nodes.
|
|
1354
|
+
const visibleTurns = createMemo(() => {
|
|
1355
|
+
const list = turns()
|
|
1356
|
+
const needle = query().trim().toLowerCase()
|
|
1357
|
+
if (!needle) return list
|
|
1358
|
+
return list.filter((turn) => turn.text.toLowerCase().includes(needle))
|
|
1359
|
+
})
|
|
1360
|
+
|
|
1361
|
+
// The row the ↑/↓ keys currently point at (index within visibleTurns).
|
|
1362
|
+
const [cursorIndex, setCursorIndex] = createSignal(-1)
|
|
1363
|
+
const clearCursor = () => setCursorIndex(-1)
|
|
1364
|
+
|
|
1365
|
+
const moveCursor = (delta) => {
|
|
1366
|
+
const list = visibleTurns()
|
|
1367
|
+
if (list.length === 0) return
|
|
1368
|
+
let index = cursorIndex()
|
|
1369
|
+
if (index < 0) {
|
|
1370
|
+
// Start from the active turn when the cursor is fresh, so ↓ walks to the
|
|
1371
|
+
// next turn relative to where the viewport already is.
|
|
1372
|
+
const activeID = active()
|
|
1373
|
+
index = list.findIndex((turn) => turn.id === activeID)
|
|
1374
|
+
if (index < 0) index = delta > 0 ? -1 : 0
|
|
1375
|
+
}
|
|
1376
|
+
index = Math.min(list.length - 1, Math.max(0, index + delta))
|
|
1377
|
+
setCursorIndex(index)
|
|
1378
|
+
requestAnimationFrame(() => {
|
|
1379
|
+
document
|
|
1380
|
+
.querySelector(`.sr-list .sr-turn[data-index="${index}"]`)
|
|
1381
|
+
?.scrollIntoView({ block: "nearest" })
|
|
1382
|
+
})
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
const activateCursor = () => {
|
|
1386
|
+
const turn = visibleTurns()[cursorIndex()]
|
|
1387
|
+
if (!turn) return
|
|
1388
|
+
void jump(turn)
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// The search input, for focus round-trips.
|
|
1392
|
+
let searchInput: HTMLInputElement | undefined
|
|
1393
|
+
const listKeyDown = (event) => {
|
|
1394
|
+
if (event.key === "ArrowDown") {
|
|
1395
|
+
event.preventDefault()
|
|
1396
|
+
event.stopPropagation()
|
|
1397
|
+
moveCursor(1)
|
|
1398
|
+
} else if (event.key === "ArrowUp") {
|
|
1399
|
+
event.preventDefault()
|
|
1400
|
+
event.stopPropagation()
|
|
1401
|
+
moveCursor(-1)
|
|
1402
|
+
} else if (event.key === "Enter" && cursorIndex() >= 0) {
|
|
1403
|
+
event.preventDefault()
|
|
1404
|
+
event.stopPropagation()
|
|
1405
|
+
activateCursor()
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// Keep the highlighted row visible while a jump pages through history.
|
|
1160
1410
|
createEffect(() => {
|
|
1161
1411
|
if (!jumping()) return
|
|
1162
1412
|
if (!busyID()) return
|
|
@@ -1178,22 +1428,30 @@ function Rail(props) {
|
|
|
1178
1428
|
class="sr-rail"
|
|
1179
1429
|
data-open={open() ? "true" : "false"}
|
|
1180
1430
|
data-jumping={jumping() ? "true" : "false"}
|
|
1431
|
+
data-pinned={pinned() ? "true" : "false"}
|
|
1432
|
+
role={open() ? "dialog" : undefined}
|
|
1433
|
+
aria-label={open() ? "会话轮次" : undefined}
|
|
1181
1434
|
onMouseEnter={() => {
|
|
1182
1435
|
setOpen(true)
|
|
1183
1436
|
maybePrewarm()
|
|
1184
1437
|
}}
|
|
1185
|
-
// Keep the panel pinned while a long history jump runs
|
|
1186
|
-
// mouse drifting off the rail hides
|
|
1187
|
-
// waiting for. Esc / × still close
|
|
1438
|
+
// Keep the panel pinned while a long history jump runs or when the
|
|
1439
|
+
// user pinned it; otherwise the mouse drifting off the rail hides
|
|
1440
|
+
// exactly the progress the user is waiting for. Esc / × still close
|
|
1441
|
+
// (or cancel) it.
|
|
1188
1442
|
onMouseLeave={() => {
|
|
1189
|
-
if (!jumping()) setOpen(false)
|
|
1443
|
+
if (!jumping() && !pinned()) setOpen(false)
|
|
1190
1444
|
}}
|
|
1191
1445
|
>
|
|
1192
1446
|
<div class="sr-head">
|
|
1193
1447
|
<Show
|
|
1194
1448
|
when={jumping()}
|
|
1195
1449
|
fallback={
|
|
1196
|
-
<span class="sr-title"
|
|
1450
|
+
<span class="sr-title">
|
|
1451
|
+
会话轮次
|
|
1452
|
+
{turns().length > 0 ? ` · ${turns().length}` : ""}
|
|
1453
|
+
<Show when={query().trim()}> · 匹配 {visibleTurns().length}</Show>
|
|
1454
|
+
</span>
|
|
1197
1455
|
}
|
|
1198
1456
|
>
|
|
1199
1457
|
<span class="sr-title sr-progress">
|
|
@@ -1202,31 +1460,93 @@ function Rail(props) {
|
|
|
1202
1460
|
{jumpElapsed() > 0 ? ` · ${jumpElapsed()}s` : ""}
|
|
1203
1461
|
</span>
|
|
1204
1462
|
</Show>
|
|
1205
|
-
<
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1463
|
+
<span class="sr-head-actions">
|
|
1464
|
+
<button
|
|
1465
|
+
type="button"
|
|
1466
|
+
class="sr-iconbtn"
|
|
1467
|
+
data-on={pinned() ? "true" : "false"}
|
|
1468
|
+
title={pinned() ? "取消固定 (⌘⌥P)" : "固定面板 (⌘⌥P)"}
|
|
1469
|
+
aria-label={pinned() ? "取消固定面板" : "固定面板"}
|
|
1470
|
+
aria-pressed={pinned() ? "true" : "false"}
|
|
1471
|
+
onClick={() => setPinned(!pinned())}
|
|
1472
|
+
>
|
|
1473
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
1474
|
+
<Show
|
|
1475
|
+
when={pinned()}
|
|
1476
|
+
fallback={
|
|
1477
|
+
<>
|
|
1478
|
+
<path d="M9.5 2.5l4 4-2.6.9-1.4 3.5-4.4-4.4L8.6 5.1z" />
|
|
1479
|
+
<path d="M5.1 6.5L2.5 9.1l4.4 4.4 2.6-2.6" />
|
|
1480
|
+
</>
|
|
1481
|
+
}
|
|
1482
|
+
>
|
|
1483
|
+
<path d="M6 3l7 7-3.5.5L7 13 3 9l2.5-2.5z" />
|
|
1484
|
+
<path d="M4 12l-1.5 1.5" />
|
|
1485
|
+
</Show>
|
|
1486
|
+
</svg>
|
|
1487
|
+
</button>
|
|
1488
|
+
<button
|
|
1489
|
+
type="button"
|
|
1490
|
+
class="sr-close"
|
|
1491
|
+
title={jumping() ? "取消跳转 (Esc)" : "关闭 (Esc)"}
|
|
1492
|
+
onClick={() => {
|
|
1493
|
+
if (jumping()) cancelJump()
|
|
1494
|
+
else setOpen(false)
|
|
1495
|
+
}}
|
|
1496
|
+
>
|
|
1497
|
+
×
|
|
1498
|
+
</button>
|
|
1499
|
+
</span>
|
|
1216
1500
|
</div>
|
|
1217
|
-
<
|
|
1218
|
-
<
|
|
1219
|
-
<
|
|
1501
|
+
<Show when={turns().length >= 8}>
|
|
1502
|
+
<div class="sr-search">
|
|
1503
|
+
<input
|
|
1504
|
+
ref={searchInput}
|
|
1505
|
+
type="text"
|
|
1506
|
+
class="sr-search-input"
|
|
1507
|
+
placeholder="搜索轮次…"
|
|
1508
|
+
value={query()}
|
|
1509
|
+
onInput={(event) => {
|
|
1510
|
+
setQuery(event.currentTarget.value)
|
|
1511
|
+
clearCursor()
|
|
1512
|
+
}}
|
|
1513
|
+
onKeyDown={listKeyDown}
|
|
1514
|
+
/>
|
|
1515
|
+
<Show when={query()}>
|
|
1516
|
+
<button
|
|
1517
|
+
type="button"
|
|
1518
|
+
class="sr-search-clear"
|
|
1519
|
+
aria-label="清除搜索"
|
|
1520
|
+
onClick={() => {
|
|
1521
|
+
setQuery("")
|
|
1522
|
+
clearCursor()
|
|
1523
|
+
searchInput?.focus()
|
|
1524
|
+
}}
|
|
1525
|
+
>
|
|
1526
|
+
×
|
|
1527
|
+
</button>
|
|
1528
|
+
</Show>
|
|
1529
|
+
</div>
|
|
1530
|
+
</Show>
|
|
1531
|
+
<div
|
|
1532
|
+
class="sr-list"
|
|
1533
|
+
onKeyDown={listKeyDown}
|
|
1534
|
+
tabIndex={-1}
|
|
1535
|
+
>
|
|
1536
|
+
<Show when={visibleTurns().length > 0} fallback={<div class="sr-note">{note() || `… ${VERSION}`}</div>}>
|
|
1537
|
+
<For each={visibleTurns()}>
|
|
1220
1538
|
{(turn, index) => (
|
|
1221
1539
|
<button
|
|
1222
1540
|
type="button"
|
|
1223
1541
|
class="sr-turn"
|
|
1224
1542
|
data-active={active() === turn.id ? "true" : "false"}
|
|
1543
|
+
data-cursor={cursorIndex() === index() ? "true" : "false"}
|
|
1225
1544
|
data-busy={jumping() && busyID() === turn.id ? "true" : "false"}
|
|
1545
|
+
data-index={index()}
|
|
1226
1546
|
onClick={() => void jump(turn)}
|
|
1227
1547
|
>
|
|
1228
1548
|
<span class="sr-text">
|
|
1229
|
-
<span class="sr-num">#{
|
|
1549
|
+
<span class="sr-num">#{turns().indexOf(turn) + 1}</span>
|
|
1230
1550
|
<span class="sr-time">{formatTime(turn.created)}</span>
|
|
1231
1551
|
{turn.text}
|
|
1232
1552
|
</span>
|
|
@@ -1266,6 +1586,8 @@ export default Plugin.define({
|
|
|
1266
1586
|
en: {
|
|
1267
1587
|
"command.jump": "Session turns: show / jump",
|
|
1268
1588
|
"command.jump.description": "Reveal the turn rail; click a turn to jump to that message",
|
|
1589
|
+
"command.pin": "Session turns: pin panel",
|
|
1590
|
+
"command.pin.description": "Toggle pinning the turn rail open (⌘⌥P)",
|
|
1269
1591
|
},
|
|
1270
1592
|
},
|
|
1271
1593
|
setup(ctx) {
|
|
@@ -1287,7 +1609,6 @@ export default Plugin.define({
|
|
|
1287
1609
|
id: "jump",
|
|
1288
1610
|
title: ctx.t("command.jump"),
|
|
1289
1611
|
description: ctx.t("command.jump.description"),
|
|
1290
|
-
group: ctx.t("command.jump"),
|
|
1291
1612
|
section: "navigation",
|
|
1292
1613
|
bind: "mod+alt+r",
|
|
1293
1614
|
slash: { name: "turns" },
|
|
@@ -1295,5 +1616,17 @@ export default Plugin.define({
|
|
|
1295
1616
|
window.dispatchEvent(new Event(OPEN_EVENT))
|
|
1296
1617
|
},
|
|
1297
1618
|
}))
|
|
1619
|
+
|
|
1620
|
+
ctx.add(Command, () => ({
|
|
1621
|
+
id: "pin",
|
|
1622
|
+
title: ctx.t("command.pin"),
|
|
1623
|
+
description: ctx.t("command.pin.description"),
|
|
1624
|
+
section: "navigation",
|
|
1625
|
+
bind: "mod+alt+p",
|
|
1626
|
+
slash: { name: "turns-pin" },
|
|
1627
|
+
run: () => {
|
|
1628
|
+
window.dispatchEvent(new Event(PIN_EVENT))
|
|
1629
|
+
},
|
|
1630
|
+
}))
|
|
1298
1631
|
},
|
|
1299
1632
|
})
|