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 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 s=require("solid-js/web"),H=require("@opencode/plugin/gui"),a=require("solid-js");var qe=s.template("<div class=sr-status>"),tt=s.template('<span class="sr-title sr-progress">'),rt=s.template("<div class=sr-foot>"),nt=s.template("<div class=sr-rail><div class=sr-head><button type=button class=sr-close>×</button></div><div class=sr-list>"),st=s.template("<span class=sr-title>会话轮次"),ot=s.template("<div class=sr-note>"),at=s.template("<span class=sr-badge>"),it=s.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 Be="v19",ue="message-",Je="session-turns:diag",pe="session-turns:open",lt=40,ct=200,dt=50,ut=30,pt=45e3,ft=2e4,gt=8e3,ht=12e3,mt=220,bt=800;let He=0,fe="",ge="",he="";const Oe=l=>{const o=String(l?.text??"").split(`
2
- `).map(p=>p.trim()).find(p=>p.length>0);if(o)return o;const d=l?.files?.length??0;return d>0?`[附件 ×${d}]`:"(空消息)"},Ne=l=>String(l).padStart(2,"0"),wt=l=>{const o=new Date(l??0);return`${Ne(o.getHours())}:${Ne(o.getMinutes())}`},le=l=>new Promise(o=>setTimeout(o,l)),me=()=>document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport')??document.querySelector('[data-slot="session-timeline-scroll"] [data-scrollable]')??(()=>{let o=document.querySelector("[data-message-id]")?.parentElement;for(;o;){const d=getComputedStyle(o);if(/(auto|scroll)/.test(d.overflowY)&&o.scrollHeight>o.clientHeight+1)return o;o=o.parentElement}})(),k=l=>{const o=document.getElementById(`${ue}${l}`);if(o)return o;const d=`[data-message-id="${l.replace(/"/g,'\\"')}"]`;for(const p of document.querySelectorAll(d))if(p.getAttribute("data-timeline-row")==="UserMessage"||p.querySelector('[data-slot="user-message-text"]'))return p},ce=l=>{const o=me(),d=[...document.querySelectorAll("[data-message-id]")],p=d[0]?.getAttribute("data-message-id")??"",E=he?` · 目标 ${k(he)?"在":"不在"} DOM`:"",_=p?` · 行ID ${p.slice(0,20)}`:"";let C="(空)";try{C=l.router.path()||"(空)"}catch{}return`轮次 ${He} · 容器 ${o?"OK":"缺失"} · DOM ${d.length} 行${E}${_} · 路由 ${C} · hash ${window.location.hash||"(空)"}${ge?` · ${ge}`:""}${fe?` · ${fe}`:""}`},xt=l=>{const o=l.getBoundingClientRect().top;let d=o;for(const p of l.querySelectorAll(".sticky, [data-session-title]")){if(!(p instanceof HTMLElement)||getComputedStyle(p).position!=="sticky"&&!p.hasAttribute("data-session-title"))continue;const _=p.getBoundingClientRect();_.height!==0&&(_.top>o+4||(d=Math.max(d,_.bottom)))}return d},de=l=>{try{const o=me();if(!o||!l.isConnected)return;const d=l.querySelector('[data-slot="user-message-text"]')??l,p=xt(o),E=d.getBoundingClientRect().top-(p+18);Math.abs(E)>2&&(o.scrollTop+=E),ge=`inset ${Math.round(p)} · delta ${Math.round(E)}`}catch{}},te=l=>{de(l),setTimeout(()=>de(l),450),setTimeout(()=>de(l),1e3)},ze=l=>{let o="";try{o=l.router.path()||""}catch{}return o.startsWith("/")?o:""},vt=l=>{const o=document.createElement("a");o.setAttribute("href",l),o.setAttribute("replace",""),o.setAttribute("noscroll",""),o.style.display="none",document.body.appendChild(o);try{o.dispatchEvent(new MouseEvent("click",{bubbles:!0,cancelable:!0,button:0,view:window}))}finally{o.remove()}},yt=`
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.15",
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 = "v19"
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
- return `${pad(date.getHours())}:${pad(date.getMinutes())}`
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") warmedFor = ""
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(), 800)
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, with a
663
- // slow interval as a fallback for rows mounting without a scroll (history
664
- // paging, virtual list swaps).
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
- const fallback = setInterval(scheduleTrack, 1500)
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
- setOpen(false)
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; Esc / outside click / × also close it. No more
754
- // forced auto-close after 4s.
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, 120000, (pages) => setJumpPages(pages))
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
- const deadline = Date.now() + 120000
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
- warmedFor = sid
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(), 800)
1348
+ const timer = setTimeout(() => maybePrewarm(), 1200)
1155
1349
  onCleanup(() => clearTimeout(timer))
1156
1350
  })
1157
1351
 
1158
- // Keep the busy row visible in the panel list while the jump pages through
1159
- // history, so the "which turn am I waiting for" answer stays on screen.
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; otherwise the
1186
- // mouse drifting off the rail hides exactly the progress the user is
1187
- // waiting for. Esc / × still close (or cancel) it.
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">会话轮次{turns().length > 0 ? ` · ${turns().length}` : ""}</span>
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
- <button
1206
- type="button"
1207
- class="sr-close"
1208
- title={jumping() ? "取消跳转 (Esc)" : "关闭 (Esc)"}
1209
- onClick={() => {
1210
- if (jumping()) cancelJump()
1211
- else setOpen(false)
1212
- }}
1213
- >
1214
- ×
1215
- </button>
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
- <div class="sr-list">
1218
- <Show when={turns().length > 0} fallback={<div class="sr-note">{note() || `… ${VERSION}`}</div>}>
1219
- <For each={turns()}>
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">#{index() + 1}</span>
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
  })