opencode-timeline-gui 0.0.0-stage → 0.2.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 meiyx7
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,111 @@
1
- # Temporary Holding Version
1
+ # opencode-timeline-gui
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **OpenCode Desktop 插件:在会话右缘列出每一轮的「用户提问」,悬停展开,点击即刻跳转到那一条消息。**
4
+
5
+ An OpenCode Desktop plugin that adds a slim rail on the right edge of the
6
+ conversation, listing every user turn of the current session. Hover to expand;
7
+ click a turn to jump straight to that message.
8
+
9
+ > 本包原名 `opencode-session-turns`,自 0.2.0 起更名为 `opencode-timeline-gui`。
10
+ > 已安装旧包的话,把配置里的包名换成新名字即可,功能不变。
11
+
12
+ 适用 OpenCode Desktop 2.2.6(以预构建的 `./gui-experimental` 包形式加载)。
13
+
14
+ ```
15
+ ┌─ 会话轮次 (12) ─────────────┐
16
+ │ 09:14 #1 帮我看看这个报错… │
17
+ │ 09:22 #2 那改成异步呢? │
18
+ │ 09:35 #3 继续 │
19
+ │ ... │
20
+ └─────────────────────────────┘
21
+ ```
22
+
23
+ ## 功能
24
+
25
+ - 列出当前会话的每一轮用户提问(从会话消息接口分页拉取,`type=user`)。
26
+ - 鼠标移到窗口右缘的细条上自动展开,移开自动收起;长历史跳转期间面板保持展开。
27
+ - **搜索轮次**:≥ 8 轮时面板顶部出现搜索框,按关键字即时过滤(匹配数显示在标题里);`↑`/`↓` 在结果间移动光标,`Enter` 跳转,无鼠标也能导航。
28
+ - **面板固定**:标题栏图钉按钮或 `⌘⌥P`(斜杠命令 `/turns-pin`)把面板钉住,鼠标移开不收起;`Esc` 先解固定、再关面板。
29
+ - 点击任意一轮,**跳转到对应消息**——包括很久以前、尚未渲染的历史轮次。
30
+ - 深历史需要逐页加载时,面板头部显示进度,目标轮次带徽标并琥珀色脉冲;
31
+ 按 `Esc` 或点 × 可随时取消。
32
+ - **历史预热**:宿主自己加载历史每页之间有 2 秒硬冷却,深历史跳转因此要等好几秒。
33
+ 轮次条会在**打开会话时**(轮次列表加载完、≥15 轮)就后台把历史预热完并还原视口,
34
+ 悬停面板或 `/turns` 也会触发。预热完成后所有跳转都是瞬时的。
35
+ - **点击与预热的配合**:点到的轮次已预热 → 直接瞬时跳转;还没预热到 → 头部显示
36
+ 「预热中 · 稍等一下」,等预热翻到该轮后自动落地(不打断预热)。
37
+ - 定位成功后精准描边闪烁**那一条用户气泡**(跟随气泡自身圆角),不再整行填色。
38
+ - 自动高亮当前视口所在的轮次(IntersectionObserver 驱动,后台窗口零开销)。
39
+ - 跨天会话的时间标注会带上「昨天」或日期,不再只有 `HH:mm` 造成歧义。
40
+ - 尊重系统的「减弱动态效果」(`prefers-reduced-motion`):保留状态色彩,去掉动画。
41
+ - 斜杠命令 `/turns`、快捷键 `⌘⌥R`(Windows/Linux 为 `Ctrl+Alt+R`)展开轮次条。
42
+
43
+ ## 安装
44
+
45
+ 把包名加入 `opencode.json` 的 `plugins` 列表,OpenCode 启动时会自动从 npm 安装:
46
+
47
+ ```json
48
+ {
49
+ "plugins": ["opencode-timeline-gui"]
50
+ }
51
+ ```
52
+
53
+ 然后重启 OpenCode Desktop(GUI bundle 在窗口启动时加载)。
54
+
55
+ ## 使用
56
+
57
+ - 把鼠标移到会话右侧的细条上展开;
58
+ - 或按 `⌘⌥R` / 输入 `/turns` 展开;
59
+ - 点击某一轮即可跳到那条消息。
60
+
61
+ ## 实现原理(为什么并不简单)
62
+
63
+ OpenCode Desktop 2.2.6 有几个必须绕开的陷阱:
64
+
65
+ - 时间线是**虚拟渲染**的:只有视口附近的行动态挂载,远端轮次不在 DOM 里。
66
+ - 桌面端使用**内存路由**:`window.location` 始终停在 `oc://renderer/index.html`,
67
+ 与真实路由解耦。直接往 `window.location` 写 `#message-<id>` 或派发 `popstate`,
68
+ 会让宿主把 `/index.html` 当成路由解析并抛出致命的 `Unrecognised route!`(会崩)。
69
+ - GUI SDK **没有** `scrollToMessage()` / `navigate()` 这类公开 API(`ctx.router` 只读)。
70
+
71
+ 因此本插件的做法是:
72
+
73
+ 1. 用 `ctx.router.path()` 取到真实路由(合法的 `/server/…/session/…` 路径);
74
+ 2. 伪造一个 `<a href="<路由>#message-<id>">` 并派发冒泡 click。宿主在 `document`
75
+ 上注册的链接拦截器(Solid Router 的 `create` 钩子)会把它交给**内存 router 的
76
+ navigate()**,从而驱动宿主自己的游标状态机加载历史并定位到该行——全程不碰
77
+ `window.location`。
78
+ 3. 宿主每次 seek 只向前加载**约一页(~5 轮)**历史。宿主在 `hash.changed` 后会进入
79
+ `pending(目标)` 状态并**异步**加载缺失的历史(内部最多重试 12 次)。因此插件
80
+ **只设置一次 hash 然后耐心等待**:任何额外的 hash 切换都会把宿主的 `pending`
81
+ 状态重置、打断正在飞行的历史请求,表现为"每次点击只前进一页"。
82
+ 只有当宿主连续 5 秒(首屏 9 秒)毫无变化、判定这一轮已经结束时,插件才把
83
+ hash 弹到一个已挂载的轮次再切回来,开启新的一轮;面板头部实时显示
84
+ 「已加载 N 页 / 耗时 Xs」,总预算 45 秒,`Esc` 可取消。
85
+
86
+ 已挂载的行则直接用本地滚动,瞬时定位。
87
+
88
+ ## 构建
89
+
90
+ ```sh
91
+ npm install
92
+ npm run build # -> dist/gui-experimental.cjs
93
+ ```
94
+
95
+ 改完源码后重新构建,并在 OpenCode Desktop 里 Reload 插件
96
+ (Settings → Extensions → Plugins → Reload)或重启应用。
97
+
98
+ ## 发布
99
+
100
+ 版本发布只通过 GitHub Actions(`.github/workflows/publish.yml`);本地 `npm publish` 已被 `prepublishOnly` 守卫禁止:
101
+
102
+ ```sh
103
+ npm version patch # 或 minor / major:自动改版本、提交并打 v* tag
104
+ git push --follow-tags origin main
105
+ ```
106
+
107
+ CI 会校验 tag 与 `package.json` 版本一致,随后构建并发布到 npm。
108
+
109
+ ## 许可证
110
+
111
+ MIT © meiyx7
@@ -0,0 +1,318 @@
1
+ "use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const r=require("solid-js/web"),J=require("@opencode/plugin/gui"),a=require("solid-js");var st=r.template("<div class=sr-status>"),$t=r.template('<span class="sr-title sr-progress">'),St=r.template('<svg><path d="M6 3l7 7-3.5.5L7 13 3 9l2.5-2.5z"></svg>',!1,!0,!1),Ct=r.template('<svg><path d="M4 12l-1.5 1.5"></svg>',!1,!0,!1),Et=r.template("<button type=button class=sr-search-clear aria-label=清除搜索>×"),_t=r.template("<div class=sr-search><input type=text class=sr-search-input placeholder=搜索轮次…>"),At=r.template("<div class=sr-foot>"),Mt=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>'),Tt=r.template("<span class=sr-title>会话轮次"),Dt=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),It=r.template('<svg><path d="M5.1 6.5L2.5 9.1l4.4 4.4 2.6-2.6"></svg>',!1,!0,!1),Lt=r.template("<div class=sr-note>"),Pt=r.template("<span class=sr-badge>"),jt=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 ot="v22",_e="message-",it="session-turns:diag",Ae="session-turns:open",Me="session-turns:pin",Rt=40,qt=200,Bt=50,Ot=30,Ft=45e3,zt=2e4,Jt=6e3,Nt=6e3,at=220,Ht=800;let dt=0,Te="",De="",Ie="";const lt=l=>{const o=String(l?.text??"").split(`
2
+ `).map(m=>m.trim()).find(m=>m.length>0);if(o)return o;const c=l?.files?.length??0;return c>0?`[附件 ×${c}]`:"(空消息)"},P=l=>String(l).padStart(2,"0"),Ut=l=>{const o=new Date(l??0),c=new Date;if(o.getFullYear()===c.getFullYear()&&o.getMonth()===c.getMonth()&&o.getDate()===c.getDate())return`${P(o.getHours())}:${P(o.getMinutes())}`;const $=new Date(c);return $.setDate(c.getDate()-1),o.getFullYear()===$.getFullYear()&&o.getMonth()===$.getMonth()&&o.getDate()===$.getDate()?`昨天 ${P(o.getHours())}:${P(o.getMinutes())}`:`${P(o.getMonth()+1)}-${P(o.getDate())} ${P(o.getHours())}:${P(o.getMinutes())}`},pe=l=>new Promise(o=>setTimeout(o,l)),Le=()=>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 c=getComputedStyle(o);if(/(auto|scroll)/.test(c.overflowY)&&o.scrollHeight>o.clientHeight+1)return o;o=o.parentElement}})(),_=l=>{const o=document.getElementById(`${_e}${l}`);if(o)return o;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},Ce=l=>{const o=Le(),c=[...document.querySelectorAll("[data-message-id]")],m=c[0]?.getAttribute("data-message-id")??"",$=Ie?` · 目标 ${_(Ie)?"在":"不在"} DOM`:"",T=m?` · 行ID ${m.slice(0,20)}`:"";let S="(空)";try{S=l.router.path()||"(空)"}catch{}return`轮次 ${dt} · 容器 ${o?"OK":"缺失"} · DOM ${c.length} 行${$}${T} · 路由 ${S} · hash ${window.location.hash||"(空)"}${De?` · ${De}`:""}${Te?` · ${Te}`:""}`},Wt=l=>{const o=l.getBoundingClientRect().top;let c=o;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>o+4||(c=Math.max(c,T.bottom)))}return c},Ee=l=>{try{const o=Le();if(!o||!l.isConnected)return;const c=l.querySelector('[data-slot="user-message-text"]')??l,m=Wt(o),$=c.getBoundingClientRect().top-(m+18);Math.abs($)>2&&(o.scrollTop+=$),De=`inset ${Math.round(m)} · delta ${Math.round($)}`}catch{}},fe=l=>{Ee(l),setTimeout(()=>Ee(l),450),setTimeout(()=>Ee(l),1e3)},ct=l=>{let o="";try{o=l.router.path()||""}catch{}return o.startsWith("/")?o:""},Yt=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()}},Gt=`
3
+ .sr-rail {
4
+ position: fixed;
5
+ top: 50%;
6
+ transform: translateY(-50%);
7
+ right: 14px;
8
+ max-height: 66vh;
9
+ width: 16px;
10
+ display: flex;
11
+ flex-direction: column;
12
+ justify-content: center;
13
+ padding: 6px 2px;
14
+ overflow: hidden;
15
+ z-index: 400;
16
+ border-radius: 10px;
17
+ border: 1px solid transparent;
18
+ color: var(--text-base, currentColor);
19
+ background: transparent;
20
+ transition: width .18s ease, background .18s ease, box-shadow .18s ease, border-color .18s ease;
21
+ }
22
+ .sr-rail[data-open="true"] {
23
+ width: 340px;
24
+ padding: 10px;
25
+ justify-content: flex-start;
26
+ background: var(--surface-base, rgba(24,24,26,.86));
27
+ backdrop-filter: blur(16px);
28
+ -webkit-backdrop-filter: blur(16px);
29
+ border-color: rgba(127,127,127,.25);
30
+ box-shadow: 0 8px 32px rgba(0,0,0,.28);
31
+ }
32
+ .sr-head {
33
+ display: none;
34
+ align-items: center;
35
+ justify-content: space-between;
36
+ gap: 8px;
37
+ padding: 0 2px 8px;
38
+ margin-bottom: 6px;
39
+ border-bottom: 1px solid rgba(127,127,127,.2);
40
+ flex-shrink: 0;
41
+ }
42
+ .sr-rail[data-open="true"] .sr-head { display: flex; }
43
+ .sr-title { font-size: 12px; opacity: .6; white-space: nowrap; }
44
+ .sr-title.sr-progress {
45
+ opacity: .95;
46
+ color: rgba(255,170,60,.95);
47
+ font-variant-numeric: tabular-nums;
48
+ }
49
+ .sr-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)); }
116
+ .sr-close {
117
+ border: 0;
118
+ background: transparent;
119
+ color: inherit;
120
+ font: inherit;
121
+ font-size: 13px;
122
+ line-height: 1;
123
+ padding: 2px 7px;
124
+ border-radius: 4px;
125
+ cursor: pointer;
126
+ opacity: .5;
127
+ flex-shrink: 0;
128
+ }
129
+ .sr-close:hover { opacity: 1; background: var(--surface-base-hover, rgba(127,127,127,.14)); }
130
+ /* the scrollable list; the header stays fixed above it.
131
+ The collapsed rail must NOT be a scroll container: centering the content
132
+ inside a scroller overflows in both directions, so the browser clamps the
133
+ scroll offset and the list snaps to the top/bottom when the panel opens. */
134
+ .sr-list {
135
+ flex: 1;
136
+ min-height: 0;
137
+ display: flex;
138
+ flex-direction: column;
139
+ justify-content: center;
140
+ gap: 2px;
141
+ overflow: hidden;
142
+ scrollbar-width: none;
143
+ }
144
+ .sr-rail[data-open="true"] .sr-list {
145
+ justify-content: flex-start;
146
+ gap: 1px;
147
+ overflow-y: auto;
148
+ scrollbar-width: thin;
149
+ }
150
+ .sr-list::-webkit-scrollbar { display: none; }
151
+ .sr-rail[data-open="true"] .sr-list::-webkit-scrollbar { display: block; width: 8px; }
152
+ .sr-list::-webkit-scrollbar-track { background: transparent; }
153
+ .sr-rail[data-open="true"] .sr-list::-webkit-scrollbar-thumb {
154
+ background: rgba(127,127,127,.35);
155
+ border-radius: 4px;
156
+ }
157
+ .sr-turn {
158
+ display: flex;
159
+ align-items: center;
160
+ justify-content: flex-end;
161
+ width: 100%;
162
+ min-height: 14px;
163
+ flex-shrink: 0;
164
+ cursor: pointer;
165
+ background: transparent;
166
+ border: 0;
167
+ padding: 0;
168
+ color: inherit;
169
+ font: inherit;
170
+ border-radius: 4px;
171
+ }
172
+ /* expanded: dash flips to a left-side indicator, content reads left-to-right */
173
+ .sr-rail[data-open="true"] .sr-turn {
174
+ flex-direction: row-reverse;
175
+ justify-content: flex-start;
176
+ gap: 9px;
177
+ padding: 5px 8px;
178
+ min-height: 0;
179
+ border-radius: 6px;
180
+ }
181
+ .sr-turn:focus-visible, .sr-close:focus-visible { outline: 1px solid currentColor; outline-offset: -1px; }
182
+ .sr-dash {
183
+ display: block;
184
+ width: 10px;
185
+ height: 2px;
186
+ border-radius: 1px;
187
+ background: currentColor;
188
+ opacity: .28;
189
+ flex-shrink: 0;
190
+ transition: opacity .12s ease, width .12s ease, height .12s ease;
191
+ }
192
+ .sr-turn:hover .sr-dash,
193
+ .sr-turn[data-active="true"] .sr-dash { opacity: .95; width: 18px; }
194
+ .sr-rail[data-open="true"] .sr-dash { width: 3px; height: 16px; }
195
+ .sr-rail[data-open="true"] .sr-turn:hover .sr-dash,
196
+ .sr-rail[data-open="true"] .sr-turn[data-active="true"] .sr-dash { width: 3px; height: 22px; }
197
+ .sr-text {
198
+ display: none;
199
+ margin-right: 10px;
200
+ padding: 1px 6px;
201
+ border-radius: 4px;
202
+ font-size: 12px;
203
+ line-height: 1.5;
204
+ text-align: right;
205
+ white-space: nowrap;
206
+ overflow: hidden;
207
+ text-overflow: ellipsis;
208
+ max-width: 300px;
209
+ opacity: .45;
210
+ background: transparent;
211
+ }
212
+ .sr-rail[data-open="true"] .sr-text {
213
+ display: -webkit-box;
214
+ -webkit-line-clamp: 2;
215
+ -webkit-box-orient: vertical;
216
+ overflow: hidden;
217
+ flex: 1;
218
+ min-width: 0;
219
+ margin: 0;
220
+ padding: 0;
221
+ text-align: left;
222
+ white-space: normal;
223
+ line-height: 1.45;
224
+ max-width: none;
225
+ opacity: .8;
226
+ }
227
+ .sr-num, .sr-time {
228
+ opacity: .55;
229
+ margin-right: 6px;
230
+ font-variant-numeric: tabular-nums;
231
+ }
232
+ .sr-turn:hover { background: var(--surface-base-hover, rgba(127,127,127,.14)); }
233
+ .sr-rail[data-open="true"] .sr-turn:hover .sr-text,
234
+ .sr-rail[data-open="true"] .sr-turn[data-active="true"] .sr-text { opacity: 1; }
235
+ .sr-turn[data-active="true"] { font-weight: 500; }
236
+ /* the turn currently being jumped to */
237
+ .sr-turn[data-busy="true"] { background: rgba(255,170,60,.12); }
238
+ .sr-turn[data-busy="true"] .sr-dash {
239
+ opacity: 1;
240
+ width: 3px;
241
+ height: 22px;
242
+ background: rgba(255,170,60,.95);
243
+ animation: srBusy 1s ease-in-out infinite;
244
+ }
245
+ .sr-turn[data-busy="false"] .sr-dash { animation: none; }
246
+ @keyframes srBusy {
247
+ 0%, 100% { opacity: .35; }
248
+ 50% { opacity: 1; }
249
+ }
250
+ /* pulse a landed bubble: an INSET ring, so it follows the bubble's own
251
+ border-radius and can never be clipped by an ancestor that clips overflow
252
+ (the timeline rows do) - an outline/box-shadow drawn outside the bubble
253
+ loses its top edge exactly where the row clips. */
254
+ .sr-flash-ring { animation: srRing 1.8s ease-out 1; }
255
+ @keyframes srRing {
256
+ 0% {
257
+ box-shadow: inset 0 0 0 2px rgba(255,170,60,.95), inset 0 0 26px rgba(255,170,60,.30);
258
+ }
259
+ 55% {
260
+ box-shadow: inset 0 0 0 2px rgba(255,170,60,.40), inset 0 0 14px rgba(255,170,60,.12);
261
+ }
262
+ 100% {
263
+ box-shadow: inset 0 0 0 2px rgba(255,170,60,0), inset 0 0 0 rgba(255,170,60,0);
264
+ }
265
+ }
266
+ .sr-badge {
267
+ font-size: 10px;
268
+ line-height: 1.4;
269
+ padding: 0 5px;
270
+ border-radius: 4px;
271
+ color: rgba(255,170,60,.95);
272
+ background: rgba(255,170,60,.14);
273
+ border: 1px solid rgba(255,170,60,.35);
274
+ flex-shrink: 0;
275
+ white-space: nowrap;
276
+ }
277
+ .sr-note {
278
+ display: none;
279
+ font-size: 11px;
280
+ opacity: .4;
281
+ padding: 2px;
282
+ }
283
+ .sr-rail[data-open="true"] .sr-note { display: block; }
284
+ .sr-foot {
285
+ flex-shrink: 0;
286
+ font-size: 11px;
287
+ line-height: 1.5;
288
+ color: rgba(255,170,60,.92);
289
+ padding: 6px 2px 0;
290
+ margin-top: 6px;
291
+ border-top: 1px solid rgba(127,127,127,.2);
292
+ white-space: pre-wrap;
293
+ max-height: 64px;
294
+ overflow: hidden;
295
+ }
296
+ .sr-status {
297
+ position: fixed;
298
+ right: 42px;
299
+ top: 50%;
300
+ transform: translateY(-50%);
301
+ z-index: 401;
302
+ font-size: 11px;
303
+ line-height: 1.5;
304
+ padding: 4px 9px;
305
+ border-radius: 6px;
306
+ background: var(--background-strong, rgba(0,0,0,.82));
307
+ color: var(--text-base, #fff);
308
+ border: 1px solid rgba(127,127,127,.25);
309
+ white-space: pre-wrap;
310
+ max-width: 260px;
311
+ }
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 Vt(l){const o=J.usePlugin(),[c,m]=a.createSignal([]),[$,T]=a.createSignal(""),[S,I]=a.createSignal(!1),[ut,pt]=a.createSignal(""),[O,Pe]=a.createSignal(""),[Q,je]=a.createSignal(""),[A,he]=a.createSignal(!1),[M,X]=a.createSignal(!1),[Re,F]=a.createSignal(0),[qe,N]=a.createSignal(0),[Zt,H]=a.createSignal(0),[ee,te]=a.createSignal(""),[Be,U]=a.createSignal(!1),D=new Map;let ge=0,W=!1,re=!1,ne="",Y;const k=()=>l.session()?.id,me=()=>{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),dt=e.length)},Oe=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})},ft=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 o.client.message.list(i)},be=async(e,t)=>{const n=++ge;W=!0,t==="full"&&(z="",$e="");let i="";try{let p;const f=t==="full"?Rt:1,x=t==="full"?qt:Bt;let s=t!=="full";for(let d=0;d<f;d++){let u;try{u=await ft(e,x,p)}catch(w){i=String(w?.message??w);break}if(n!==ge)return;s||(D.clear(),s=!0);let h=0;for(const w of u?.data??[])D.has(w.id)||(Oe({id:w.id,time:w.time?.created??0,text:lt(w)}),h++);me();const b=u?.cursor?.next;if(!b||h===0)break;p=b}}finally{W=!1,ne=e,Te=i,pt(D.size===0?i||"暂无用户消息":""),re&&(re=!1,Fe()),t==="full"&&!i&&e===a.untrack(k)&&D.size>=1&&setTimeout(()=>V(),400)}},Fe=()=>{Y||(Y=setTimeout(()=>{Y=void 0;const e=a.untrack(k);e&&be(e,"light")},300))},ze=e=>{if(!(!e||e!==a.untrack(k))){if(W){re=!0;return}Fe()}},ht=e=>{const t=e?.type;if(t==="message.updated"){const n=e?.data??{},i=n.info;i?.role==="user"&&i?.id&&(Oe({id:i.id,time:i.time?.created??0,text:lt(i)}),me()),ze(n.sessionID)}else if(t==="message.removed"){const n=e?.data??{};n.messageID&&D.delete(n.messageID)&&me(),ze(n.sessionID)}else if(t==="location.shutdown"){const n=a.untrack(k);n&&ne===n&&be(n,"full")}};a.onMount(()=>{let e=!1,t=0,n;(async()=>{for(;!e;){const f=o.client.event.subscribe()[Symbol.asyncIterator]();try{for(;!e;){const x=await f.next();if(x.done)break;t=0,ht(x.value)}}catch{}try{await f.return?.()}catch{}if(e)break;t++,await pe(Math.min(2e3*t,15e3))}})(),a.onCleanup(()=>{e=!0,clearTimeout(n),Y&&clearTimeout(Y)})}),a.createEffect(()=>{const e=k();e&&a.untrack(()=>{ne!==e&&(ge++,W=!1,re=!1,D.clear(),m([]),T(""),xe="",be(e,"full"))})});let se;const j=()=>(se?.isConnected||(se=Le()),se),Je=new WeakSet;let G;const gt=()=>G||(G=new IntersectionObserver(e=>{e.length!==0&&ve()},{rootMargin:"-40px 0px 0px 0px",threshold:0}),G),mt=()=>{const e=gt();for(const t of document.querySelectorAll("[data-message-id]"))Je.has(t)||(Je.add(t),e.observe(t))},bt=()=>{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 p=i.getAttribute("data-message-id");p&&p!==$()&&T(p);break}};let we=!1;const ve=()=>{we||(we=!0,requestAnimationFrame(()=>{we=!1,bt(),mt()}))};a.onMount(()=>{const e=()=>ve();document.addEventListener("scroll",e,!0);const t=setInterval(()=>{document.hidden||ve()},4e3);a.onCleanup(()=>{document.removeEventListener("scroll",e,!0),clearInterval(t),G?.disconnect()})});let Ne=0;const oe=(e,t=2600)=>{const n=++Ne;Pe(e),setTimeout(()=>{n===Ne&&Pe("")},t)};a.onMount(()=>{const e=n=>{if(n.key==="Escape"){if(a.untrack(M)){Ye(),n.stopPropagation();return}a.untrack(S)&&(a.untrack(A)?he(!1):I(!1),n.stopPropagation())}},t=n=>{if(!a.untrack(S)||a.untrack(A))return;(n.composedPath?.()??[]).some(f=>f instanceof Element&&f.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(`${ot} · ${Ce(o)}`),t=()=>{I(i=>!i),V()},n=()=>{he(i=>!i),a.untrack(S)||(I(!0),V())};window.addEventListener(it,e),window.addEventListener(Ae,t),window.addEventListener(Me,n),a.onCleanup(()=>{window.removeEventListener(it,e),window.removeEventListener(Ae,t),window.removeEventListener(Me,n)})});let xe="",C=0,He=0,Ue="";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{}},ye=()=>{for(const e of c())if(_(e.id))return e},L=(e,t)=>{Yt(`${e}#${_e}${t}`),xe=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}`},We=(e,t,n,i,p)=>new Promise(f=>{let x=!1,s,d,u;const h=Date.now(),b=ae();let w=b,y=h,g=0,v=!1;const B=4e3,E=de=>{if(!x){x=!0;try{s?.disconnect()}catch{}clearInterval(d),clearTimeout(u),f(de??null)}},Z=de=>{if(t!==C)return E(null);const rt=de?_(e):document.getElementById(`${_e}${e}`);if(rt)return E(rt);if(!de)return;const nt=ae(),ue=Date.now();if(nt!==w){w=nt,y=ue,g++,i?.(g);return}const kt=g>0?Jt:Nt;if(!v&&p&&b==="0"&&ue-y>B){v=!0,y=ue,p();return}ue-y>kt&&E(null)},tt=_(e);if(tt)return E(tt);s=new MutationObserver(()=>Z(!1)),s.observe(document.documentElement,{childList:!0,subtree:!0}),d=setInterval(()=>Z(!0),120),u=setTimeout(()=>E(_(e)),Math.max(400,n))}),Ye=()=>{C++,X(!1),F(0),N(0),H(0),te(""),U(!1),oe("已取消跳转")},Ge=async e=>{if(M()&&ee()===e.id)return;const t=ct(o),n=j(),i=_(e.id);if(i&&n){R()&&(le(),t&&L(t,e.id)),fe(i),ie(i);return}const p=Date.now();if(e.id===Ue&&p-He<Ht){const h=_(e.id);if(h&&n){fe(h),ie(h);return}}if(He=p,Ue=e.id,Ie=e.id,!t){oe(`无法读取当前路由,无法定位 · ${Ce(o)}`);return}const f=++C,x=k();X(!0),te(e.id),F(0),N(0),H(0),U(R()),I(!0);const s=Date.now(),d=setInterval(()=>{N(Math.round((Date.now()-s)/1e3))},500);let u=0;try{if(R()){const b=await We(e.id,f,2e4,w=>F(w));if(f!==C||k()!==x)return;if(b){le(),L(t,e.id),fe(b),ie(b);return}if(R()&&le(),U(!1),f!==C||k()!==x)return}const h=Date.now()+Ft;for(let b=0;b<Ot&&Date.now()<h;b++){if(f!==C||k()!==x)return;if(H(b+1),xe===e.id){const v=ye()??c()[0];v&&(L(t,v.id),await pe(at))}if(f!==C||k()!==x)return;L(t,e.id);let w=0;const y=Math.min(zt,h-Date.now()),g=await We(e.id,f,y,v=>{w=v,F(u+v)},()=>{const v=ye()??c()[0];v&&v.id!==e.id&&L(t,v.id)});if(u+=w,f!==C||k()!==x)return;if(g){fe(g),ie(g);return}}f===C&&oe(`未能定位 · ${Ce(o)}`)}finally{clearInterval(d),f===C&&(X(!1),F(0),N(0),H(0),te(""),U(!1))}},[R,ke]=a.createSignal(!1);let q=0,z="",$e="";const wt=()=>{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 p=n.getAttribute("data-message-id");if(p)return{el:n,id:p,offset:i.top-t.top}}},Ve=e=>{if(!e)return;const t=j();if(!t)return;const n=e.el?.isConnected?e.el:_(e.id);if(!n)return;const i=t.getBoundingClientRect(),p=n.getBoundingClientRect().top-i.top-e.offset;Math.abs(p)>2&&(t.scrollTop+=p)},le=()=>{q++,ke(!1)},V=()=>{const e=k();!e||R()||z!==e&&(c().length<1||vt())},vt=async()=>{if(R())return;const e=ct(o),t=c()[0],n=k();if(!e||!t||!n)return;if(_(t.id)){z=n;return}if(z===n)return;const i=++q,p=wt(),f=j(),x=f?f.scrollTop+f.clientHeight>=f.scrollHeight-32:!0;ke(!0);try{L(e,t.id);const s=Date.now()+3e4;let d=ae(),u=Date.now(),h=0;const b=setInterval(()=>Ve(p),150);try{for(;Date.now()<s&&i===q&&!(_(t.id)||(await pe(250),i!==q));){const y=Date.now(),g=ae();if(g!==d)d=g,u=y,h=0;else if(y-u>3e3){if(h>=2)break;const v=ye();if(v&&v.id!==t.id&&(L(e,v.id),await pe(at),i!==q))break;L(e,t.id),u=Date.now(),h++}}}finally{clearInterval(b)}if(i!==q)return;const w=!!_(t.id);if(Ve(p),x){const y=j();y&&(y.scrollTop=y.scrollHeight)}w?z=n:$e=n}finally{i===q&&ke(!1)}};let Ke=a.untrack(k);a.createEffect(()=>{const e=k();e!==Ke&&(Ke=e,le(),C++,X(!1),F(0),N(0),H(0),te(""),U(!1))}),a.createEffect(()=>{const e=k(),t=c().length,n=R();if(!e||t<1||n||z===e||$e===e||W||ne!==e)return;const i=setTimeout(()=>V(),400);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,Ze]=a.createSignal(-1),Qe=()=>Ze(-1),Xe=e=>{const t=K();if(t.length===0)return;let n=ce();if(n<0){const i=$();n=t.findIndex(p=>p.id===i),n<0&&(n=e>0?-1:0)}n=Math.min(t.length-1,Math.max(0,n+e)),Ze(n),requestAnimationFrame(()=>{document.querySelector(`.sr-list .sr-turn[data-index="${n}"]`)?.scrollIntoView({block:"nearest"})})},xt=()=>{const e=K()[ce()];e&&Ge(e)};let Se;const et=e=>{e.key==="ArrowDown"?(e.preventDefault(),e.stopPropagation(),Xe(1)):e.key==="ArrowUp"?(e.preventDefault(),e.stopPropagation(),Xe(-1)):e.key==="Enter"&&ce()>=0&&(e.preventDefault(),e.stopPropagation(),xt())};a.createEffect(()=>{M()&&ee()&&requestAnimationFrame(()=>{try{document.querySelector('.sr-list .sr-turn[data-busy="true"]')?.scrollIntoView({block:"nearest"})}catch{}})});const yt=[r.createComponent(a.Show,{get when(){return O()},get children(){var e=st();return r.insert(e,O),e}}),(()=>{var e=Mt(),t=e.firstChild,n=t.firstChild,i=n.firstChild,p=i.firstChild,f=i.nextSibling,x=t.nextSibling;return e.addEventListener("mouseleave",()=>{!M()&&!A()&&I(!1)}),e.addEventListener("mouseenter",()=>{I(!0),V()}),r.insert(t,r.createComponent(a.Show,{get when(){return M()},get fallback(){return(()=>{var s=Tt();return s.firstChild,r.insert(s,(()=>{var d=r.memo(()=>c().length>0);return()=>d()?` · ${c().length}`:""})(),null),r.insert(s,r.createComponent(a.Show,{get when(){return Q().trim()},get children(){return[" · 匹配 ",r.memo(()=>K().length)]}}),null),s})()},get children(){var s=$t();return r.insert(s,()=>Be()?"预热中 · 稍等一下":"加载历史中",null),r.insert(s,(()=>{var d=r.memo(()=>Re()>0);return()=>d()?` · ${Re()} 页`:""})(),null),r.insert(s,(()=>{var d=r.memo(()=>qe()>0);return()=>d()?` · ${qe()}s`:""})(),null),s}}),n),i.$$click=()=>he(!A()),r.insert(p,r.createComponent(a.Show,{get when(){return A()},get fallback(){return[Dt(),It()]},get children(){return[St(),Ct()]}})),f.$$click=()=>{M()?Ye():I(!1)},r.insert(e,r.createComponent(a.Show,{get when(){return c().length>=8},get children(){var s=_t(),d=s.firstChild;d.$$keydown=et,d.$$input=h=>{je(h.currentTarget.value),Qe()};var u=Se;return typeof u=="function"?r.use(u,d):Se=d,r.insert(s,r.createComponent(a.Show,{get when(){return Q()},get children(){var h=Et();return h.$$click=()=>{je(""),Qe(),Se?.focus()},h}}),null),r.effect(()=>d.value=Q()),s}}),x),x.$$keydown=et,r.insert(x,r.createComponent(a.Show,{get when(){return K().length>0},get fallback(){return(()=>{var s=Lt();return r.insert(s,()=>ut()||`… ${ot}`),s})()},get children(){return r.createComponent(a.For,{get each(){return K()},children:(s,d)=>(()=>{var u=jt(),h=u.firstChild,b=h.firstChild;b.firstChild;var w=b.nextSibling,y=h.nextSibling;return u.$$click=()=>{Ge(s)},r.insert(b,()=>c().indexOf(s)+1,null),r.insert(w,()=>Ut(s.created)),r.insert(h,()=>s.text,null),r.insert(u,r.createComponent(a.Show,{get when(){return r.memo(()=>!!M())()&&ee()===s.id},get children(){var g=Pt();return r.insert(g,()=>Be()?"稍等":"加载中"),g}}),y),r.effect(g=>{var v=$()===s.id?"true":"false",B=ce()===d()?"true":"false",E=M()&&ee()===s.id?"true":"false",Z=d();return v!==g.e&&r.setAttribute(u,"data-active",g.e=v),B!==g.t&&r.setAttribute(u,"data-cursor",g.t=B),E!==g.a&&r.setAttribute(u,"data-busy",g.a=E),Z!==g.o&&r.setAttribute(u,"data-index",g.o=Z),g},{e:void 0,t:void 0,a:void 0,o:void 0}),u})()})}})),r.insert(e,r.createComponent(a.Show,{get when(){return r.memo(()=>!!S())()&&O()},get children(){var s=At();return r.insert(s,O),s}}),null),r.effect(s=>{var d=S()?"true":"false",u=M()?"true":"false",h=A()?"true":"false",b=S()?"dialog":void 0,w=S()?"会话轮次":void 0,y=A()?"true":"false",g=A()?"取消固定 (⌘⌥P)":"固定面板 (⌘⌥P)",v=A()?"取消固定面板":"固定面板",B=A()?"true":"false",E=M()?"取消跳转 (Esc)":"关闭 (Esc)";return d!==s.e&&r.setAttribute(e,"data-open",s.e=d),u!==s.t&&r.setAttribute(e,"data-jumping",s.t=u),h!==s.a&&r.setAttribute(e,"data-pinned",s.a=h),b!==s.o&&r.setAttribute(e,"role",s.o=b),w!==s.i&&r.setAttribute(e,"aria-label",s.i=w),y!==s.n&&r.setAttribute(i,"data-on",s.n=y),g!==s.s&&r.setAttribute(i,"title",s.s=g),v!==s.h&&r.setAttribute(i,"aria-label",s.h=v),B!==s.r&&r.setAttribute(i,"aria-pressed",s.r=B),E!==s.d&&r.setAttribute(f,"title",s.d=E),s},{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=st();return r.insert(e,O),e}})];return r.createComponent(r.Portal,{get mount(){return document.body},children:yt})}const Kt=J.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(J.Style,Gt),l.add(J.Slot,{at:"window.bottom",render:()=>r.createComponent(a.Show,{get when(){return l.sessions.current()},get children(){return r.createComponent(Vt,{session:()=>l.sessions.current()})}})}),l.add(J.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(Ae))}})),l.add(J.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(Me))}}))}});r.delegateEvents(["click","input","keydown"]);exports.default=Kt;
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "opencode-timeline-gui",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
4
+ "description": "OpenCode Desktop 插件:在会话右缘列出每一轮用户提问,点击即可跳转到对应消息。A right-edge rail of user turns; click one to jump to that message. (formerly opencode-session-turns)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "meiyx7",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/meiyx7/opencode-timeline-gui.git"
11
+ },
12
+ "homepage": "https://github.com/meiyx7/opencode-timeline-gui#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/meiyx7/opencode-timeline-gui/issues"
15
+ },
16
+ "keywords": [
17
+ "opencode",
18
+ "opencode-plugin",
19
+ "timeline",
20
+ "gui",
21
+ "session",
22
+ "turns",
23
+ "navigation",
24
+ "jump",
25
+ "message"
26
+ ],
27
+ "exports": {
28
+ "./gui-experimental": "./dist/gui-experimental.cjs",
29
+ "./package.json": "./package.json"
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "src",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "engines": {
38
+ "node": ">=20"
39
+ },
40
+ "scripts": {
41
+ "build": "vite build",
42
+ "dev": "vite build --watch",
43
+ "prepublishOnly": "node scripts/ensure-ci-publish.mjs && npm run build"
44
+ },
45
+ "devDependencies": {
46
+ "@opencode/plugin": "^2.2.6",
47
+ "solid-js": "^1.9.0",
48
+ "vite": "^7.0.0",
49
+ "vite-plugin-solid": "^2.11.0"
50
+ }
51
+ }