opencode-session-turns 0.0.0-stage → 0.1.0
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 +21 -0
- package/README.md +84 -2
- package/dist/gui-experimental.cjs +95 -0
- package/package.json +48 -4
- package/src/gui.tsx +493 -0
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,85 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-session-turns
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An **OpenCode Desktop** plugin that adds a slim rail on the right edge of the
|
|
4
|
+
conversation, listing **every user turn** of the current session. Hover the rail
|
|
5
|
+
to reveal the turns; click one to jump straight to that message.
|
|
6
|
+
|
|
7
|
+
> Works on OpenCode Desktop 2.2.6. The rail is a GUI plugin, loaded from the
|
|
8
|
+
> prebuilt `./gui-experimental` bundle.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
┌─ Session turns (12) ────────┐
|
|
12
|
+
│ 09:14 #1 Fix this error… │
|
|
13
|
+
│ 09:22 #2 What about async?│
|
|
14
|
+
│ 09:35 #3 Continue │
|
|
15
|
+
│ ... │
|
|
16
|
+
└─────────────────────────────┘
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- Lists every user turn of the current session (paged from the session API).
|
|
22
|
+
- Hover the right-edge rail to expand; move away to collapse.
|
|
23
|
+
- Click a turn to jump to its message — including turns far back in history.
|
|
24
|
+
- Highlights the turn currently in view.
|
|
25
|
+
- `/turns` slash command and `⌘⌥R` (`Ctrl+Alt+R`) to reveal the rail.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
Add the package name to the `plugins` list in your `opencode.json`; OpenCode
|
|
30
|
+
installs it from npm on startup:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"plugins": ["opencode-session-turns"]
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Restart OpenCode Desktop (the GUI bundle is loaded at window startup).
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
- Move the mouse to the thin rail on the right edge of the conversation.
|
|
43
|
+
- Or press `⌘⌥R` / run `/turns` to reveal it.
|
|
44
|
+
- Click a turn to jump to that message.
|
|
45
|
+
|
|
46
|
+
## How it works (and why it is not trivial)
|
|
47
|
+
|
|
48
|
+
OpenCode Desktop 2.2.6 has some sharp edges that a GUI plugin has to respect:
|
|
49
|
+
|
|
50
|
+
- The timeline is **virtualized**: only rows near the viewport are mounted, so
|
|
51
|
+
distant turns are not in the DOM.
|
|
52
|
+
- The desktop app uses a **memory history** for routing. `window.location`
|
|
53
|
+
stays at `oc://renderer/index.html` and is decoupled from the real route.
|
|
54
|
+
Writing `#message-<id>` to `window.location` (or firing `popstate`) makes the
|
|
55
|
+
host parse `/index.html` as a route and throw a fatal `Unrecognised route!`.
|
|
56
|
+
- The GUI SDK exposes **no** `scrollToMessage()` / `navigate()` API
|
|
57
|
+
(`ctx.router` is read-only).
|
|
58
|
+
|
|
59
|
+
So this plugin:
|
|
60
|
+
|
|
61
|
+
1. Picks the real route from `ctx.router.path()` (a valid
|
|
62
|
+
`/server/…/session/…` path).
|
|
63
|
+
2. Fakes a click on an `<a href="<route>#message-<id>">`. The host's own
|
|
64
|
+
document-level link interceptor turns that into a **memory-router navigate**,
|
|
65
|
+
which drives the host's cursor machine to page in history and seek to the
|
|
66
|
+
row — without touching `window.location`.
|
|
67
|
+
3. The host only pages in about **one history page (~5 turns) per seek**, so the
|
|
68
|
+
plugin keeps toggling the hash to an already-loaded turn and back to the
|
|
69
|
+
target, pulling one page at a time until the target row mounts.
|
|
70
|
+
|
|
71
|
+
Rows that are already mounted are scrolled locally for an instant jump.
|
|
72
|
+
|
|
73
|
+
## Build
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
npm install
|
|
77
|
+
npm run build # -> dist/gui-experimental.cjs
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
After changing the source, rebuild and reload the plugin in OpenCode Desktop
|
|
81
|
+
(Settings → Extensions → Plugins → Reload) or restart the app.
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT © meiyx7
|
|
@@ -0,0 +1,95 @@
|
|
|
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 Z=s.template("<div class=sr-status>"),tt=s.template("<div class=sr-rail>"),et=s.template("<div class=sr-note>"),nt=s.template("<button type=button class=sr-turn><span class=sr-text> </span><span class=sr-dash>");const C="v10",B="message-",_="session-turns:diag",A="session-turns:open";let F=0,j="",I="";const rt=n=>{const e=String(n?.text??"").split(`
|
|
2
|
+
`).map(d=>d.trim()).find(d=>d.length>0);if(e)return e;const o=n?.files?.length??0;return o>0?`[附件 ×${o}]`:"(空消息)"},N=n=>String(n).padStart(2,"0"),st=n=>{const e=new Date(n??0);return`${N(e.getHours())}:${N(e.getMinutes())}`},z=n=>new Promise(e=>setTimeout(e,n)),x=()=>document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport')??document.querySelector('[data-slot="session-timeline-scroll"] [data-scrollable]')??(()=>{let e=document.querySelector("[data-message-id]")?.parentElement;for(;e;){const o=getComputedStyle(e);if(/(auto|scroll)/.test(o.overflowY)&&e.scrollHeight>e.clientHeight+1)return e;e=e.parentElement}})(),y=n=>document.getElementById(`${B}${n}`)??[...document.querySelectorAll("[data-message-id]")].find(e=>e.getAttribute("data-message-id")===n),T=n=>{const e=x(),o=[...document.querySelectorAll("[data-message-id]")],d=o[0]?.getAttribute("data-message-id")??"",$=I?` · 目标 ${y(I)?"在":"不在"} DOM`:"",v=d?` · 行ID ${d.slice(0,20)}`:"";let w="(空)";try{w=n.router.path()||"(空)"}catch{}return`轮次 ${F} · 容器 ${e?"OK":"缺失"} · DOM ${o.length} 行${$}${v} · 路由 ${w} · hash ${window.location.hash||"(空)"}${j?` · ${j}`:""}`},V=(n,e)=>{e.scrollIntoView({block:"start"});const o=n.querySelector("[data-session-title]"),d=o instanceof HTMLElement?o.offsetHeight:0;d&&(n.scrollTop-=d+12)},ot=n=>{let e="";try{e=n.router.path()||""}catch{}return e.startsWith("/")?e:""},at=n=>{const e=document.createElement("a");e.setAttribute("href",n),e.setAttribute("replace",""),e.setAttribute("noscroll",""),e.style.display="none",document.body.appendChild(e);try{e.dispatchEvent(new MouseEvent("click",{bubbles:!0,cancelable:!0,button:0,view:window}))}finally{e.remove()}},it=`
|
|
3
|
+
.sr-rail {
|
|
4
|
+
position: fixed;
|
|
5
|
+
top: 50%;
|
|
6
|
+
transform: translateY(-50%);
|
|
7
|
+
right: 10px;
|
|
8
|
+
max-height: 60vh;
|
|
9
|
+
width: 14px;
|
|
10
|
+
display: flex;
|
|
11
|
+
flex-direction: column;
|
|
12
|
+
align-items: flex-end;
|
|
13
|
+
justify-content: center;
|
|
14
|
+
gap: 2px;
|
|
15
|
+
padding: 8px 4px;
|
|
16
|
+
overflow-y: auto;
|
|
17
|
+
overflow-x: hidden;
|
|
18
|
+
scrollbar-width: none;
|
|
19
|
+
z-index: 400;
|
|
20
|
+
border-radius: 10px;
|
|
21
|
+
color: var(--text-base, currentColor);
|
|
22
|
+
background: transparent;
|
|
23
|
+
transition: width .18s ease;
|
|
24
|
+
}
|
|
25
|
+
.sr-rail::-webkit-scrollbar { display: none; }
|
|
26
|
+
.sr-rail[data-open="true"] { width: 360px; background: transparent; }
|
|
27
|
+
.sr-turn {
|
|
28
|
+
display: flex;
|
|
29
|
+
align-items: center;
|
|
30
|
+
justify-content: flex-end;
|
|
31
|
+
width: 100%;
|
|
32
|
+
min-height: 14px;
|
|
33
|
+
flex-shrink: 0;
|
|
34
|
+
cursor: pointer;
|
|
35
|
+
background: transparent;
|
|
36
|
+
border: 0;
|
|
37
|
+
padding: 0;
|
|
38
|
+
color: inherit;
|
|
39
|
+
font: inherit;
|
|
40
|
+
border-radius: 4px;
|
|
41
|
+
}
|
|
42
|
+
.sr-dash {
|
|
43
|
+
display: block;
|
|
44
|
+
width: 10px;
|
|
45
|
+
height: 2px;
|
|
46
|
+
border-radius: 1px;
|
|
47
|
+
background: currentColor;
|
|
48
|
+
opacity: .28;
|
|
49
|
+
flex-shrink: 0;
|
|
50
|
+
transition: opacity .12s ease, width .12s ease;
|
|
51
|
+
}
|
|
52
|
+
.sr-turn:hover .sr-dash,
|
|
53
|
+
.sr-turn[data-active="true"] .sr-dash { opacity: .95; width: 18px; }
|
|
54
|
+
.sr-text {
|
|
55
|
+
display: none;
|
|
56
|
+
margin-right: 10px;
|
|
57
|
+
padding: 1px 6px;
|
|
58
|
+
border-radius: 4px;
|
|
59
|
+
font-size: 12px;
|
|
60
|
+
line-height: 1.5;
|
|
61
|
+
text-align: right;
|
|
62
|
+
white-space: nowrap;
|
|
63
|
+
overflow: hidden;
|
|
64
|
+
text-overflow: ellipsis;
|
|
65
|
+
max-width: 300px;
|
|
66
|
+
opacity: .45;
|
|
67
|
+
background: transparent;
|
|
68
|
+
}
|
|
69
|
+
.sr-rail[data-open="true"] .sr-text { display: block; }
|
|
70
|
+
.sr-turn:hover .sr-text {
|
|
71
|
+
opacity: 1;
|
|
72
|
+
background: var(--surface-base-hover, rgba(127,127,127,.14));
|
|
73
|
+
}
|
|
74
|
+
.sr-turn[data-active="true"] .sr-text {
|
|
75
|
+
opacity: 1;
|
|
76
|
+
font-weight: 600;
|
|
77
|
+
background: var(--surface-base-hover, rgba(127,127,127,.14));
|
|
78
|
+
}
|
|
79
|
+
.sr-status {
|
|
80
|
+
position: fixed;
|
|
81
|
+
right: 30px;
|
|
82
|
+
bottom: 150px;
|
|
83
|
+
z-index: 401;
|
|
84
|
+
font-size: 11px;
|
|
85
|
+
padding: 3px 8px;
|
|
86
|
+
border-radius: 6px;
|
|
87
|
+
background: var(--background-strong, rgba(0,0,0,.75));
|
|
88
|
+
color: var(--text-base, #fff);
|
|
89
|
+
opacity: .9;
|
|
90
|
+
pointer-events: none;
|
|
91
|
+
white-space: pre-wrap;
|
|
92
|
+
max-width: min(720px, 90vw);
|
|
93
|
+
}
|
|
94
|
+
.sr-note { font-size: 11px; opacity: .4; padding: 2px; }
|
|
95
|
+
`;function lt(n){const e=h.usePlugin(),[o,d]=a.createSignal([]),[$,v]=a.createSignal(""),[w,b]=a.createSignal(!1),[J,Y]=a.createSignal(""),[q,L]=a.createSignal("");let S=!1,M="",k,E="";const R=async t=>{if(!t||S)return;S=!0;const r=new Map;let l,i="";try{for(let u=0;u<40;u++){const g=l?{sessionID:t,type:"user",limit:200,cursor:l}:{sessionID:t,type:"user",limit:200,order:"desc"};let f;try{f=await e.client.message.list(g)}catch(p){i=String(p?.message??p);break}const U=f?.data??[];let D=0;for(const p of U)r.has(p.id)||(r.set(p.id,{id:p.id,created:p.time?.created??0,text:rt(p)}),D++);const H=f?.cursor?.next;if(!H||D===0)break;l=H}}finally{S=!1}const c=[...r.values()].sort((u,g)=>u.created-g.created);d(c),F=c.length,j=i,Y(c.length===0?i||"暂无用户消息":""),M=t},O=()=>n.session()?.id;a.createEffect(()=>{const t=O();t&&a.untrack(()=>{M!==t&&(d([]),v(""),E="",R(t))})});const G=setInterval(()=>{const t=O();t&&R(t)},5e3);a.onCleanup(()=>clearInterval(G));const K=setInterval(()=>{const t=x();if(!t)return;const r=[...t.querySelectorAll("[data-message-id]")];if(r.length===0)return;const l=t.getBoundingClientRect().top+40;for(const i of r)if(i.getBoundingClientRect().bottom>=l){const c=i.getAttribute("data-message-id");c&&v(c);break}},400);a.onCleanup(()=>clearInterval(K));const m=t=>{L(t),setTimeout(()=>L(r=>r===t?"":r),3e3)};a.onMount(()=>{const t=()=>m(`${C} · ${T(e)}`),r=()=>{b(!0),clearTimeout(k),k=setTimeout(()=>b(!1),4e3)};window.addEventListener(_,t),window.addEventListener(A,r),a.onCleanup(()=>{window.removeEventListener(_,t),window.removeEventListener(A,r),clearTimeout(k)}),m(`会话轮次 ${C} 已加载`)});const W=()=>{for(const t of o())if(y(t.id))return t},P=(t,r)=>{at(`${t}#${B}${r}`),E=r},X=async t=>{I=t.id;const r=x(),l=y(t.id);if(l&&r){V(r,l),m("已定位");return}const i=ot(e);if(!i){m(`无法读取当前路由,无法定位 · ${T(e)}`);return}for(let c=0;c<30;c++){if(m(c===0?"定位中…":`加载历史… ${c}`),E===t.id){const u=W()??o()[0];u&&(P(i,u.id),await z(120))}P(i,t.id);for(let u=0;u<6;u++){await z(80);const g=y(t.id);if(!g)continue;const f=x()??r;f&&V(f,g),m("已定位");return}}m(`未能定位 · ${T(e)}`)},Q=[s.createComponent(a.Show,{get when(){return q()},get children(){var t=Z();return s.insert(t,q),t}}),(()=>{var t=tt();return t.addEventListener("mouseleave",()=>b(!1)),t.addEventListener("mouseenter",()=>b(!0)),s.insert(t,s.createComponent(a.Show,{get when(){return o().length>0},get fallback(){return(()=>{var r=et();return s.insert(r,()=>J()||`… ${C}`),r})()},get children(){return s.createComponent(a.For,{get each(){return o()},children:r=>(()=>{var l=nt(),i=l.firstChild,c=i.firstChild;return l.$$click=()=>{X(r)},s.insert(i,()=>st(r.created),c),s.insert(i,()=>r.text,null),s.effect(()=>s.setAttribute(l,"data-active",$()===r.id?"true":"false")),l})()})}})),s.effect(()=>s.setAttribute(t,"data-open",w()?"true":"false")),t})()];return s.createComponent(s.Portal,{get mount(){return document.body},children:Q})}const ct=h.Plugin.define({i18n:{en:{"command.jump":"Session turns: show / jump","command.jump.description":"Reveal the turn rail; click a turn to jump to that message"}},setup(n){n.add(h.Style,it),n.add(h.Slot,{at:"window.bottom",render:()=>s.createComponent(a.Show,{get when(){return n.sessions.current()},get children(){return s.createComponent(lt,{session:()=>n.sessions.current()})}})}),n.add(h.Command,()=>({id:"jump",title:n.t("command.jump"),description:n.t("command.jump.description"),group:n.t("command.jump"),section:"navigation",bind:"mod+alt+r",slash:{name:"turns"},run:()=>{window.dispatchEvent(new Event(A)),window.dispatchEvent(new Event(_))}}))}});s.delegateEvents(["click"]);exports.default=ct;
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-session-turns",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode Desktop plugin: a right-edge rail listing every user turn of the current session; click a turn to jump to that message.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "meiyx7",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/meiyx7/opencode-session-turns.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/meiyx7/opencode-session-turns#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/meiyx7/opencode-session-turns/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"opencode",
|
|
18
|
+
"opencode-plugin",
|
|
19
|
+
"session",
|
|
20
|
+
"turns",
|
|
21
|
+
"timeline",
|
|
22
|
+
"navigation",
|
|
23
|
+
"jump",
|
|
24
|
+
"message"
|
|
25
|
+
],
|
|
26
|
+
"exports": {
|
|
27
|
+
"./gui-experimental": "./dist/gui-experimental.cjs",
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"src",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=20"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "vite build",
|
|
41
|
+
"dev": "vite build --watch",
|
|
42
|
+
"prepublishOnly": "npm run build"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@opencode/plugin": "^2.2.6",
|
|
46
|
+
"solid-js": "^1.9.0",
|
|
47
|
+
"vite": "^7.0.0",
|
|
48
|
+
"vite-plugin-solid": "^2.11.0"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/gui.tsx
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { Command, Plugin, Slot, Style, usePlugin } from "@opencode/plugin/gui"
|
|
2
|
+
import { For, Show, createEffect, createSignal, onCleanup, onMount, untrack } from "solid-js"
|
|
3
|
+
import { Portal } from "solid-js/web"
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// constants / helpers
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
const VERSION = "v10"
|
|
10
|
+
const HASH_PREFIX = "message-"
|
|
11
|
+
const DIAG_EVENT = "session-turns:diag"
|
|
12
|
+
const OPEN_EVENT = "session-turns:open"
|
|
13
|
+
|
|
14
|
+
// Filled by Rail.load()/jump() so the command palette can report diagnostics.
|
|
15
|
+
let lastTurnCount = 0
|
|
16
|
+
let lastNote = ""
|
|
17
|
+
let lastJumpID = ""
|
|
18
|
+
|
|
19
|
+
const firstLine = (message) => {
|
|
20
|
+
const line = String(message?.text ?? "")
|
|
21
|
+
.split("\n")
|
|
22
|
+
.map((item) => item.trim())
|
|
23
|
+
.find((item) => item.length > 0)
|
|
24
|
+
if (line) return line
|
|
25
|
+
const files = message?.files?.length ?? 0
|
|
26
|
+
if (files > 0) return `[附件 ×${files}]`
|
|
27
|
+
return "(空消息)"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const pad = (value) => String(value).padStart(2, "0")
|
|
31
|
+
|
|
32
|
+
const formatTime = (created) => {
|
|
33
|
+
const date = new Date(created ?? 0)
|
|
34
|
+
return `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
38
|
+
|
|
39
|
+
// The app's own code reads this exact selector. The data-slot sits on the
|
|
40
|
+
// outer .scroll-view (overflow: hidden); only .scroll-view__viewport scrolls.
|
|
41
|
+
const findScroller = () =>
|
|
42
|
+
document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport') ??
|
|
43
|
+
document.querySelector('[data-slot="session-timeline-scroll"] [data-scrollable]') ??
|
|
44
|
+
(() => {
|
|
45
|
+
const row = document.querySelector("[data-message-id]")
|
|
46
|
+
let node = row?.parentElement
|
|
47
|
+
while (node) {
|
|
48
|
+
const style = getComputedStyle(node)
|
|
49
|
+
if (/(auto|scroll)/.test(style.overflowY) && node.scrollHeight > node.clientHeight + 1) return node
|
|
50
|
+
node = node.parentElement
|
|
51
|
+
}
|
|
52
|
+
return undefined
|
|
53
|
+
})()
|
|
54
|
+
|
|
55
|
+
// The app sets id="message-<id>" on every timeline row. Prefer that over the
|
|
56
|
+
// data attribute: Solid Router's own scrollToHash() uses getElementById too.
|
|
57
|
+
const findRow = (messageID) =>
|
|
58
|
+
document.getElementById(`${HASH_PREFIX}${messageID}`) ??
|
|
59
|
+
[...document.querySelectorAll("[data-message-id]")].find(
|
|
60
|
+
(node) => node.getAttribute("data-message-id") === messageID,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const diagnostics = (ctx) => {
|
|
64
|
+
const scroller = findScroller()
|
|
65
|
+
const nodes = [...document.querySelectorAll("[data-message-id]")]
|
|
66
|
+
const sample = nodes[0]?.getAttribute("data-message-id") ?? ""
|
|
67
|
+
const target = lastJumpID ? ` · 目标 ${findRow(lastJumpID) ? "在" : "不在"} DOM` : ""
|
|
68
|
+
const id = sample ? ` · 行ID ${sample.slice(0, 20)}` : ""
|
|
69
|
+
let route = "(空)"
|
|
70
|
+
try {
|
|
71
|
+
route = ctx.router.path() || "(空)"
|
|
72
|
+
} catch {}
|
|
73
|
+
return `轮次 ${lastTurnCount} · 容器 ${scroller ? "OK" : "缺失"} · DOM ${nodes.length} 行${target}${id} · 路由 ${route} · hash ${window.location.hash || "(空)"}${lastNote ? ` · ${lastNote}` : ""}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const scrollToRow = (scroller, row) => {
|
|
77
|
+
// Native scrolling reaches the real scroll ancestor without depending on
|
|
78
|
+
// the viewport selector; then compensate for the sticky session title.
|
|
79
|
+
row.scrollIntoView({ block: "start" })
|
|
80
|
+
const title = scroller.querySelector("[data-session-title]")
|
|
81
|
+
const inset = title instanceof HTMLElement ? title.offsetHeight : 0
|
|
82
|
+
if (inset) scroller.scrollTop -= inset + 12
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The desktop app (2.2.6) routes through a memory history, so window.location is
|
|
86
|
+
// stuck at the asset path `oc://renderer/index.html` and is decoupled from the
|
|
87
|
+
// real route. Writing `#message-<id>` + firing popstate made the host parse
|
|
88
|
+
// `/index.html` as a route and throw a fatal "Unrecognised route!".
|
|
89
|
+
//
|
|
90
|
+
// The one safe path into the host router is its document-level link interceptor
|
|
91
|
+
// (Solid Router's `create` hook): a bubbling click on an <a> whose href is an
|
|
92
|
+
// internal route makes it call the memory router's navigate(). We build an <a>
|
|
93
|
+
// from the *real* route (ctx.router.path(), a valid /server/…/session/… path)
|
|
94
|
+
// plus `#message-<id>` and click it. The route parses fine, the location hash
|
|
95
|
+
// changes, and the host's own cursor machine pages in the history and seeks to
|
|
96
|
+
// the row. window.location is never touched, so nothing crashes.
|
|
97
|
+
const routePath = (ctx) => {
|
|
98
|
+
let path = ""
|
|
99
|
+
try {
|
|
100
|
+
path = ctx.router.path() || ""
|
|
101
|
+
} catch {}
|
|
102
|
+
return path.startsWith("/") ? path : ""
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Clicks an internal route link; the host's link interceptor turns it into
|
|
106
|
+
// memory-router navigate(). `href` is a path, optionally with a #message-<id>.
|
|
107
|
+
const clickRoute = (href) => {
|
|
108
|
+
const anchor = document.createElement("a")
|
|
109
|
+
anchor.setAttribute("href", href)
|
|
110
|
+
// Match the host's own hash jumps: replace instead of push, and skip the
|
|
111
|
+
// router's window.scrollTo(0,0) while the row is still unmounted.
|
|
112
|
+
anchor.setAttribute("replace", "")
|
|
113
|
+
anchor.setAttribute("noscroll", "")
|
|
114
|
+
anchor.style.display = "none"
|
|
115
|
+
document.body.appendChild(anchor)
|
|
116
|
+
try {
|
|
117
|
+
anchor.dispatchEvent(
|
|
118
|
+
new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, view: window }),
|
|
119
|
+
)
|
|
120
|
+
} finally {
|
|
121
|
+
anchor.remove()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// styles
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
const CSS = `
|
|
130
|
+
.sr-rail {
|
|
131
|
+
position: fixed;
|
|
132
|
+
top: 50%;
|
|
133
|
+
transform: translateY(-50%);
|
|
134
|
+
right: 10px;
|
|
135
|
+
max-height: 60vh;
|
|
136
|
+
width: 14px;
|
|
137
|
+
display: flex;
|
|
138
|
+
flex-direction: column;
|
|
139
|
+
align-items: flex-end;
|
|
140
|
+
justify-content: center;
|
|
141
|
+
gap: 2px;
|
|
142
|
+
padding: 8px 4px;
|
|
143
|
+
overflow-y: auto;
|
|
144
|
+
overflow-x: hidden;
|
|
145
|
+
scrollbar-width: none;
|
|
146
|
+
z-index: 400;
|
|
147
|
+
border-radius: 10px;
|
|
148
|
+
color: var(--text-base, currentColor);
|
|
149
|
+
background: transparent;
|
|
150
|
+
transition: width .18s ease;
|
|
151
|
+
}
|
|
152
|
+
.sr-rail::-webkit-scrollbar { display: none; }
|
|
153
|
+
.sr-rail[data-open="true"] { width: 360px; background: transparent; }
|
|
154
|
+
.sr-turn {
|
|
155
|
+
display: flex;
|
|
156
|
+
align-items: center;
|
|
157
|
+
justify-content: flex-end;
|
|
158
|
+
width: 100%;
|
|
159
|
+
min-height: 14px;
|
|
160
|
+
flex-shrink: 0;
|
|
161
|
+
cursor: pointer;
|
|
162
|
+
background: transparent;
|
|
163
|
+
border: 0;
|
|
164
|
+
padding: 0;
|
|
165
|
+
color: inherit;
|
|
166
|
+
font: inherit;
|
|
167
|
+
border-radius: 4px;
|
|
168
|
+
}
|
|
169
|
+
.sr-dash {
|
|
170
|
+
display: block;
|
|
171
|
+
width: 10px;
|
|
172
|
+
height: 2px;
|
|
173
|
+
border-radius: 1px;
|
|
174
|
+
background: currentColor;
|
|
175
|
+
opacity: .28;
|
|
176
|
+
flex-shrink: 0;
|
|
177
|
+
transition: opacity .12s ease, width .12s ease;
|
|
178
|
+
}
|
|
179
|
+
.sr-turn:hover .sr-dash,
|
|
180
|
+
.sr-turn[data-active="true"] .sr-dash { opacity: .95; width: 18px; }
|
|
181
|
+
.sr-text {
|
|
182
|
+
display: none;
|
|
183
|
+
margin-right: 10px;
|
|
184
|
+
padding: 1px 6px;
|
|
185
|
+
border-radius: 4px;
|
|
186
|
+
font-size: 12px;
|
|
187
|
+
line-height: 1.5;
|
|
188
|
+
text-align: right;
|
|
189
|
+
white-space: nowrap;
|
|
190
|
+
overflow: hidden;
|
|
191
|
+
text-overflow: ellipsis;
|
|
192
|
+
max-width: 300px;
|
|
193
|
+
opacity: .45;
|
|
194
|
+
background: transparent;
|
|
195
|
+
}
|
|
196
|
+
.sr-rail[data-open="true"] .sr-text { display: block; }
|
|
197
|
+
.sr-turn:hover .sr-text {
|
|
198
|
+
opacity: 1;
|
|
199
|
+
background: var(--surface-base-hover, rgba(127,127,127,.14));
|
|
200
|
+
}
|
|
201
|
+
.sr-turn[data-active="true"] .sr-text {
|
|
202
|
+
opacity: 1;
|
|
203
|
+
font-weight: 600;
|
|
204
|
+
background: var(--surface-base-hover, rgba(127,127,127,.14));
|
|
205
|
+
}
|
|
206
|
+
.sr-status {
|
|
207
|
+
position: fixed;
|
|
208
|
+
right: 30px;
|
|
209
|
+
bottom: 150px;
|
|
210
|
+
z-index: 401;
|
|
211
|
+
font-size: 11px;
|
|
212
|
+
padding: 3px 8px;
|
|
213
|
+
border-radius: 6px;
|
|
214
|
+
background: var(--background-strong, rgba(0,0,0,.75));
|
|
215
|
+
color: var(--text-base, #fff);
|
|
216
|
+
opacity: .9;
|
|
217
|
+
pointer-events: none;
|
|
218
|
+
white-space: pre-wrap;
|
|
219
|
+
max-width: min(720px, 90vw);
|
|
220
|
+
}
|
|
221
|
+
.sr-note { font-size: 11px; opacity: .4; padding: 2px; }
|
|
222
|
+
`
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// rail
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
function Rail(props) {
|
|
229
|
+
const ctx = usePlugin()
|
|
230
|
+
const [turns, setTurns] = createSignal([])
|
|
231
|
+
const [active, setActive] = createSignal("")
|
|
232
|
+
const [open, setOpen] = createSignal(false)
|
|
233
|
+
const [note, setNote] = createSignal("")
|
|
234
|
+
const [status, setStatus] = createSignal("")
|
|
235
|
+
|
|
236
|
+
let loading = false
|
|
237
|
+
let loadedFor = ""
|
|
238
|
+
let closeTimer
|
|
239
|
+
// The memory router's hash is invisible to us (window.location is stale), so
|
|
240
|
+
// track the last id we navigated to in order to force a hash change each round.
|
|
241
|
+
let lastNavID = ""
|
|
242
|
+
|
|
243
|
+
const load = async (sessionID) => {
|
|
244
|
+
if (!sessionID || loading) return
|
|
245
|
+
loading = true
|
|
246
|
+
const collected = new Map()
|
|
247
|
+
let cursor
|
|
248
|
+
let error = ""
|
|
249
|
+
try {
|
|
250
|
+
for (let page = 0; page < 40; page++) {
|
|
251
|
+
// The server rejects `cursor` together with `order`
|
|
252
|
+
// (InvalidCursorError: "Cursor cannot be combined with order").
|
|
253
|
+
// The cursor itself already encodes the original order/direction,
|
|
254
|
+
// so only the first page declares order: "desc".
|
|
255
|
+
const params = cursor
|
|
256
|
+
? { sessionID, type: "user", limit: 200, cursor }
|
|
257
|
+
: { sessionID, type: "user", limit: 200, order: "desc" }
|
|
258
|
+
|
|
259
|
+
let response
|
|
260
|
+
try {
|
|
261
|
+
response = await ctx.client.message.list(params)
|
|
262
|
+
} catch (cause) {
|
|
263
|
+
error = String(cause?.message ?? cause)
|
|
264
|
+
break
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const data = response?.data ?? []
|
|
268
|
+
let fresh = 0
|
|
269
|
+
for (const message of data) {
|
|
270
|
+
if (collected.has(message.id)) continue
|
|
271
|
+
collected.set(message.id, {
|
|
272
|
+
id: message.id,
|
|
273
|
+
created: message.time?.created ?? 0,
|
|
274
|
+
text: firstLine(message),
|
|
275
|
+
})
|
|
276
|
+
fresh++
|
|
277
|
+
}
|
|
278
|
+
const next = response?.cursor?.next
|
|
279
|
+
if (!next || fresh === 0) break
|
|
280
|
+
cursor = next
|
|
281
|
+
}
|
|
282
|
+
} finally {
|
|
283
|
+
loading = false
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Publish whatever was collected even if a later page failed — a paging
|
|
287
|
+
// error must never blank out the whole rail again.
|
|
288
|
+
const list = [...collected.values()].sort((a, b) => a.created - b.created)
|
|
289
|
+
setTurns(list)
|
|
290
|
+
lastTurnCount = list.length
|
|
291
|
+
lastNote = error
|
|
292
|
+
setNote(list.length === 0 ? error || "暂无用户消息" : "")
|
|
293
|
+
loadedFor = sessionID
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const sessionID = () => props.session()?.id
|
|
297
|
+
|
|
298
|
+
createEffect(() => {
|
|
299
|
+
const id = sessionID()
|
|
300
|
+
if (!id) return
|
|
301
|
+
untrack(() => {
|
|
302
|
+
if (loadedFor !== id) {
|
|
303
|
+
setTurns([])
|
|
304
|
+
setActive("")
|
|
305
|
+
lastNavID = ""
|
|
306
|
+
void load(id)
|
|
307
|
+
}
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
const poll = setInterval(() => {
|
|
312
|
+
const id = sessionID()
|
|
313
|
+
if (id) void load(id)
|
|
314
|
+
}, 5000)
|
|
315
|
+
onCleanup(() => clearInterval(poll))
|
|
316
|
+
|
|
317
|
+
const trackActive = () => {
|
|
318
|
+
const scroller = findScroller()
|
|
319
|
+
if (!scroller) return
|
|
320
|
+
const rows = [...scroller.querySelectorAll("[data-message-id]")]
|
|
321
|
+
if (rows.length === 0) return
|
|
322
|
+
const top = scroller.getBoundingClientRect().top + 40
|
|
323
|
+
for (const row of rows) {
|
|
324
|
+
if (row.getBoundingClientRect().bottom >= top) {
|
|
325
|
+
const id = row.getAttribute("data-message-id")
|
|
326
|
+
if (id) setActive(id)
|
|
327
|
+
break
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const track = setInterval(trackActive, 400)
|
|
332
|
+
onCleanup(() => clearInterval(track))
|
|
333
|
+
|
|
334
|
+
const flash = (text) => {
|
|
335
|
+
setStatus(text)
|
|
336
|
+
setTimeout(() => setStatus((current) => (current === text ? "" : current)), 3000)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
onMount(() => {
|
|
340
|
+
const onDiag = () => flash(`${VERSION} · ${diagnostics(ctx)}`)
|
|
341
|
+
const onOpen = () => {
|
|
342
|
+
setOpen(true)
|
|
343
|
+
clearTimeout(closeTimer)
|
|
344
|
+
closeTimer = setTimeout(() => setOpen(false), 4000)
|
|
345
|
+
}
|
|
346
|
+
window.addEventListener(DIAG_EVENT, onDiag)
|
|
347
|
+
window.addEventListener(OPEN_EVENT, onOpen)
|
|
348
|
+
onCleanup(() => {
|
|
349
|
+
window.removeEventListener(DIAG_EVENT, onDiag)
|
|
350
|
+
window.removeEventListener(OPEN_EVENT, onOpen)
|
|
351
|
+
clearTimeout(closeTimer)
|
|
352
|
+
})
|
|
353
|
+
// One-shot confirmation that this window actually runs the rebuilt bundle.
|
|
354
|
+
flash(`会话轮次 ${VERSION} 已加载`)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
// Oldest turn that is currently mounted (i.e. already paged in), used as a
|
|
358
|
+
// safe "different id" to toggle the router hash so it re-runs its seek.
|
|
359
|
+
const oldestLoadedTurn = () => {
|
|
360
|
+
for (const t of turns()) if (findRow(t.id)) return t
|
|
361
|
+
return undefined
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const navTo = (path, id) => {
|
|
365
|
+
clickRoute(`${path}#${HASH_PREFIX}${id}`)
|
|
366
|
+
lastNavID = id
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const jump = async (turn) => {
|
|
370
|
+
lastJumpID = turn.id
|
|
371
|
+
const scroller = findScroller()
|
|
372
|
+
const row = findRow(turn.id)
|
|
373
|
+
|
|
374
|
+
// Fast path: the row is mounted, scroll locally.
|
|
375
|
+
if (row && scroller) {
|
|
376
|
+
scrollToRow(scroller, row)
|
|
377
|
+
flash("已定位")
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const path = routePath(ctx)
|
|
382
|
+
if (!path) {
|
|
383
|
+
flash(`无法读取当前路由,无法定位 · ${diagnostics(ctx)}`)
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// The host only pages in ~one history page (~5 turns) per seek and then goes
|
|
388
|
+
// idle. Drive it: each round toggles the hash to an already-loaded turn and
|
|
389
|
+
// back to the target, so the cursor machine runs another seek/page.
|
|
390
|
+
for (let round = 0; round < 30; round++) {
|
|
391
|
+
flash(round === 0 ? "定位中…" : `加载历史… ${round}`)
|
|
392
|
+
|
|
393
|
+
if (lastNavID === turn.id) {
|
|
394
|
+
const other = oldestLoadedTurn() ?? turns()[0]
|
|
395
|
+
if (other) {
|
|
396
|
+
navTo(path, other.id)
|
|
397
|
+
await sleep(120)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
navTo(path, turn.id)
|
|
401
|
+
|
|
402
|
+
for (let k = 0; k < 6; k++) {
|
|
403
|
+
await sleep(80)
|
|
404
|
+
const landed = findRow(turn.id)
|
|
405
|
+
if (!landed) continue
|
|
406
|
+
const live = findScroller() ?? scroller
|
|
407
|
+
if (live) scrollToRow(live, landed)
|
|
408
|
+
flash("已定位")
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
flash(`未能定位 · ${diagnostics(ctx)}`)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const rail = (
|
|
417
|
+
<>
|
|
418
|
+
<Show when={status()}>
|
|
419
|
+
<div class="sr-status">{status()}</div>
|
|
420
|
+
</Show>
|
|
421
|
+
<div
|
|
422
|
+
class="sr-rail"
|
|
423
|
+
data-open={open() ? "true" : "false"}
|
|
424
|
+
onMouseEnter={() => setOpen(true)}
|
|
425
|
+
onMouseLeave={() => setOpen(false)}
|
|
426
|
+
>
|
|
427
|
+
<Show when={turns().length > 0} fallback={<div class="sr-note">{note() || `… ${VERSION}`}</div>}>
|
|
428
|
+
<For each={turns()}>
|
|
429
|
+
{(turn) => (
|
|
430
|
+
<button
|
|
431
|
+
type="button"
|
|
432
|
+
class="sr-turn"
|
|
433
|
+
data-active={active() === turn.id ? "true" : "false"}
|
|
434
|
+
onClick={() => void jump(turn)}
|
|
435
|
+
>
|
|
436
|
+
<span class="sr-text">
|
|
437
|
+
{formatTime(turn.created)} {turn.text}
|
|
438
|
+
</span>
|
|
439
|
+
<span class="sr-dash" />
|
|
440
|
+
</button>
|
|
441
|
+
)}
|
|
442
|
+
</For>
|
|
443
|
+
</Show>
|
|
444
|
+
</div>
|
|
445
|
+
</>
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
// Portal to <body>: window.bottom is a bottom strip with its own stacking
|
|
449
|
+
// context, which can clip or re-anchor position:fixed children.
|
|
450
|
+
return <Portal mount={document.body}>{rail}</Portal>
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
// plugin
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
export default Plugin.define({
|
|
458
|
+
i18n: {
|
|
459
|
+
en: {
|
|
460
|
+
"command.jump": "Session turns: show / jump",
|
|
461
|
+
"command.jump.description": "Reveal the turn rail; click a turn to jump to that message",
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
setup(ctx) {
|
|
465
|
+
ctx.add(Style, CSS)
|
|
466
|
+
|
|
467
|
+
// NOTE: never clear an existing #message-<id> hash here. The desktop app
|
|
468
|
+
// uses it as a deep link and its cursor machine owns that state.
|
|
469
|
+
|
|
470
|
+
ctx.add(Slot, {
|
|
471
|
+
at: "window.bottom",
|
|
472
|
+
render: () => (
|
|
473
|
+
<Show when={ctx.sessions.current()}>
|
|
474
|
+
<Rail session={() => ctx.sessions.current()} />
|
|
475
|
+
</Show>
|
|
476
|
+
),
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
ctx.add(Command, () => ({
|
|
480
|
+
id: "jump",
|
|
481
|
+
title: ctx.t("command.jump"),
|
|
482
|
+
description: ctx.t("command.jump.description"),
|
|
483
|
+
group: ctx.t("command.jump"),
|
|
484
|
+
section: "navigation",
|
|
485
|
+
bind: "mod+alt+r",
|
|
486
|
+
slash: { name: "turns" },
|
|
487
|
+
run: () => {
|
|
488
|
+
window.dispatchEvent(new Event(OPEN_EVENT))
|
|
489
|
+
window.dispatchEvent(new Event(DIAG_EVENT))
|
|
490
|
+
},
|
|
491
|
+
}))
|
|
492
|
+
},
|
|
493
|
+
})
|