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 +21 -0
- package/README.md +110 -2
- package/dist/gui-experimental.cjs +318 -0
- package/package.json +49 -4
- package/src/gui.tsx +1674 -0
package/src/gui.tsx
ADDED
|
@@ -0,0 +1,1674 @@
|
|
|
1
|
+
import { Command, Plugin, Slot, Style, usePlugin } from "@opencode/plugin/gui"
|
|
2
|
+
import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount, untrack } from "solid-js"
|
|
3
|
+
import { Portal } from "solid-js/web"
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// constants / helpers
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
const VERSION = "v22"
|
|
10
|
+
const HASH_PREFIX = "message-"
|
|
11
|
+
const DIAG_EVENT = "session-turns:diag"
|
|
12
|
+
const OPEN_EVENT = "session-turns:open"
|
|
13
|
+
const PIN_EVENT = "session-turns:pin"
|
|
14
|
+
|
|
15
|
+
// paging: a full load walks every page; a light refresh pulls just the newest
|
|
16
|
+
// page (new messages always land at the head of a desc listing).
|
|
17
|
+
const FULL_PAGE_LIMIT = 40
|
|
18
|
+
const FULL_PAGE_SIZE = 200
|
|
19
|
+
const LIGHT_PAGE_SIZE = 50
|
|
20
|
+
|
|
21
|
+
// Jump budgets.
|
|
22
|
+
//
|
|
23
|
+
// The host loads missing history asynchronously and retries a missing seek up
|
|
24
|
+
// to 12 times on its own. The plugin therefore does NOT poll the hash: it
|
|
25
|
+
// sets it once and waits. JUMP_CHAIN_MS is how long a single chain may run,
|
|
26
|
+
// JUMP_IDLE_MS how long the host may stay completely idle before the plugin
|
|
27
|
+
// assumes the chain died and starts a new one. Efficiency-first: the host
|
|
28
|
+
// pages with a 2s cooldown, so 6s of total silence means the seek was
|
|
29
|
+
// dropped (its machine starts inactive and ignores early seeks) - bounce
|
|
30
|
+
// and re-engage instead of waiting longer.
|
|
31
|
+
const JUMP_ROUNDS = 30
|
|
32
|
+
const JUMP_TOTAL_MS = 45000
|
|
33
|
+
const JUMP_CHAIN_MS = 20000
|
|
34
|
+
const JUMP_IDLE_MS = 6000
|
|
35
|
+
const JUMP_FIRST_IDLE_MS = 6000
|
|
36
|
+
const JUMP_TOGGLE_DWELL_MS = 220
|
|
37
|
+
const SAME_TARGET_WINDOW_MS = 800
|
|
38
|
+
|
|
39
|
+
// Filled by Rail.load()/jump() so the command palette can report diagnostics.
|
|
40
|
+
let lastTurnCount = 0
|
|
41
|
+
let lastNote = ""
|
|
42
|
+
let lastAlign = ""
|
|
43
|
+
let lastJumpID = ""
|
|
44
|
+
|
|
45
|
+
const firstLine = (message) => {
|
|
46
|
+
const line = String(message?.text ?? "")
|
|
47
|
+
.split("\n")
|
|
48
|
+
.map((item) => item.trim())
|
|
49
|
+
.find((item) => item.length > 0)
|
|
50
|
+
if (line) return line
|
|
51
|
+
const files = message?.files?.length ?? 0
|
|
52
|
+
if (files > 0) return `[附件 ×${files}]`
|
|
53
|
+
return "(空消息)"
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const pad = (value) => String(value).padStart(2, "0")
|
|
57
|
+
|
|
58
|
+
const formatTime = (created) => {
|
|
59
|
+
const date = new Date(created ?? 0)
|
|
60
|
+
const now = new Date()
|
|
61
|
+
const sameDay =
|
|
62
|
+
date.getFullYear() === now.getFullYear() &&
|
|
63
|
+
date.getMonth() === now.getMonth() &&
|
|
64
|
+
date.getDate() === now.getDate()
|
|
65
|
+
if (sameDay) return `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
66
|
+
const yesterday = new Date(now)
|
|
67
|
+
yesterday.setDate(now.getDate() - 1)
|
|
68
|
+
const wasYesterday =
|
|
69
|
+
date.getFullYear() === yesterday.getFullYear() &&
|
|
70
|
+
date.getMonth() === yesterday.getMonth() &&
|
|
71
|
+
date.getDate() === yesterday.getDate()
|
|
72
|
+
if (wasYesterday) return `昨天 ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
73
|
+
// Older turns need a date: a bare HH:mm is ambiguous in multi-day sessions.
|
|
74
|
+
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
78
|
+
|
|
79
|
+
// The app's own code reads this exact selector. The data-slot sits on the
|
|
80
|
+
// outer .scroll-view (overflow: hidden); only .scroll-view__viewport scrolls.
|
|
81
|
+
const findScroller = () =>
|
|
82
|
+
document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport') ??
|
|
83
|
+
document.querySelector('[data-slot="session-timeline-scroll"] [data-scrollable]') ??
|
|
84
|
+
(() => {
|
|
85
|
+
const row = document.querySelector("[data-message-id]")
|
|
86
|
+
let node = row?.parentElement
|
|
87
|
+
while (node) {
|
|
88
|
+
const style = getComputedStyle(node)
|
|
89
|
+
if (/(auto|scroll)/.test(style.overflowY) && node.scrollHeight > node.clientHeight + 1) return node
|
|
90
|
+
node = node.parentElement
|
|
91
|
+
}
|
|
92
|
+
return undefined
|
|
93
|
+
})()
|
|
94
|
+
|
|
95
|
+
// The app sets id="message-<id>" on every timeline row. Prefer that over the
|
|
96
|
+
// data attribute: Solid Router's own scrollToHash() uses getElementById too.
|
|
97
|
+
//
|
|
98
|
+
// IMPORTANT: `data-message-id` is set on assistant rows as well (they carry
|
|
99
|
+
// the id of the user message they belong to), so a naive lookup can return an
|
|
100
|
+
// assistant row and we would scroll to the reply instead of the question.
|
|
101
|
+
// Only the user message row counts as "found".
|
|
102
|
+
const findRow = (messageID) => {
|
|
103
|
+
const anchored = document.getElementById(`${HASH_PREFIX}${messageID}`)
|
|
104
|
+
if (anchored) return anchored
|
|
105
|
+
const selector = `[data-message-id="${messageID.replace(/"/g, '\\"')}"]`
|
|
106
|
+
for (const node of document.querySelectorAll(selector)) {
|
|
107
|
+
if (node.getAttribute("data-timeline-row") === "UserMessage") return node
|
|
108
|
+
if (node.querySelector('[data-slot="user-message-text"]')) return node
|
|
109
|
+
}
|
|
110
|
+
return undefined
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const diagnostics = (ctx) => {
|
|
114
|
+
const scroller = findScroller()
|
|
115
|
+
const nodes = [...document.querySelectorAll("[data-message-id]")]
|
|
116
|
+
const sample = nodes[0]?.getAttribute("data-message-id") ?? ""
|
|
117
|
+
const target = lastJumpID ? ` · 目标 ${findRow(lastJumpID) ? "在" : "不在"} DOM` : ""
|
|
118
|
+
const id = sample ? ` · 行ID ${sample.slice(0, 20)}` : ""
|
|
119
|
+
let route = "(空)"
|
|
120
|
+
try {
|
|
121
|
+
route = ctx.router.path() || "(空)"
|
|
122
|
+
} catch {}
|
|
123
|
+
return `轮次 ${lastTurnCount} · 容器 ${scroller ? "OK" : "缺失"} · DOM ${nodes.length} 行${target}${id} · 路由 ${route} · hash ${window.location.hash || "(空)"}${lastAlign ? ` · ${lastAlign}` : ""}${lastNote ? ` · ${lastNote}` : ""}`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The timeline's top is covered by the host's sticky header: the session title
|
|
127
|
+
// (z-30) plus a 16px gradient fade strip that sits right below it. The host
|
|
128
|
+
// parks a sought row flush against that band, which is why the bubble's top
|
|
129
|
+
// edge (and the highlight ring) used to be cut off.
|
|
130
|
+
//
|
|
131
|
+
// Instead of guessing a fixed offset, measure the band: every sticky element
|
|
132
|
+
// pinned to the top of the scrollport contributes its bottom edge.
|
|
133
|
+
const topInset = (scroller) => {
|
|
134
|
+
const scTop = scroller.getBoundingClientRect().top
|
|
135
|
+
let bottom = scTop
|
|
136
|
+
for (const el of scroller.querySelectorAll(".sticky, [data-session-title]")) {
|
|
137
|
+
if (!(el instanceof HTMLElement)) continue
|
|
138
|
+
const style = getComputedStyle(el)
|
|
139
|
+
if (style.position !== "sticky" && !el.hasAttribute("data-session-title")) continue
|
|
140
|
+
const rect = el.getBoundingClientRect()
|
|
141
|
+
if (rect.height === 0) continue
|
|
142
|
+
if (rect.top > scTop + 4) continue // not pinned to the top
|
|
143
|
+
bottom = Math.max(bottom, rect.bottom)
|
|
144
|
+
}
|
|
145
|
+
return bottom
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Places the message bubble (not the row) clear of the header band.
|
|
149
|
+
const alignBubble = (row) => {
|
|
150
|
+
try {
|
|
151
|
+
const sc = findScroller()
|
|
152
|
+
if (!sc || !row.isConnected) return
|
|
153
|
+
const bubble = row.querySelector('[data-slot="user-message-text"]') ?? row
|
|
154
|
+
const inset = topInset(sc)
|
|
155
|
+
const delta = bubble.getBoundingClientRect().top - (inset + 18)
|
|
156
|
+
if (Math.abs(delta) > 2) sc.scrollTop += delta
|
|
157
|
+
lastAlign = `inset ${Math.round(inset)} · delta ${Math.round(delta)}`
|
|
158
|
+
} catch {}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The host's own seek may finish after ours (it can even scroll smoothly),
|
|
162
|
+
// re-aligning the row flush under the header. Re-align a couple of times.
|
|
163
|
+
const alignAfterLand = (row) => {
|
|
164
|
+
alignBubble(row)
|
|
165
|
+
setTimeout(() => alignBubble(row), 450)
|
|
166
|
+
setTimeout(() => alignBubble(row), 1000)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The desktop app (2.2.6) routes through a memory history, so window.location is
|
|
170
|
+
// stuck at the asset path `oc://renderer/index.html` and is decoupled from the
|
|
171
|
+
// real route. Writing `#message-<id>` + firing popstate made the host parse
|
|
172
|
+
// `/index.html` as a route and throw a fatal "Unrecognised route!".
|
|
173
|
+
//
|
|
174
|
+
// The one safe path into the host router is its document-level link interceptor
|
|
175
|
+
// (Solid Router's `create` hook): a bubbling click on an <a> whose href is an
|
|
176
|
+
// internal route makes it call the memory router's navigate(). We build an <a>
|
|
177
|
+
// from the *real* route (ctx.router.path(), a valid /server/…/session/… path)
|
|
178
|
+
// plus `#message-<id>` and click it. The route parses fine, the location hash
|
|
179
|
+
// changes, and the host's own cursor machine pages in the history and seeks to
|
|
180
|
+
// the row. window.location is never touched, so nothing crashes.
|
|
181
|
+
const routePath = (ctx) => {
|
|
182
|
+
let path = ""
|
|
183
|
+
try {
|
|
184
|
+
path = ctx.router.path() || ""
|
|
185
|
+
} catch {}
|
|
186
|
+
return path.startsWith("/") ? path : ""
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Clicks an internal route link; the host's link interceptor turns it into
|
|
190
|
+
// memory-router navigate(). `href` is a path, optionally with a #message-<id>.
|
|
191
|
+
const clickRoute = (href) => {
|
|
192
|
+
const anchor = document.createElement("a")
|
|
193
|
+
anchor.setAttribute("href", href)
|
|
194
|
+
// Match the host's own hash jumps: replace instead of push, and skip the
|
|
195
|
+
// router's window.scrollTo(0,0) while the row is still unmounted.
|
|
196
|
+
anchor.setAttribute("replace", "")
|
|
197
|
+
anchor.setAttribute("noscroll", "")
|
|
198
|
+
anchor.style.display = "none"
|
|
199
|
+
document.body.appendChild(anchor)
|
|
200
|
+
try {
|
|
201
|
+
anchor.dispatchEvent(
|
|
202
|
+
new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, view: window }),
|
|
203
|
+
)
|
|
204
|
+
} finally {
|
|
205
|
+
anchor.remove()
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// styles
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
const CSS = `
|
|
214
|
+
.sr-rail {
|
|
215
|
+
position: fixed;
|
|
216
|
+
top: 50%;
|
|
217
|
+
transform: translateY(-50%);
|
|
218
|
+
right: 14px;
|
|
219
|
+
max-height: 66vh;
|
|
220
|
+
width: 16px;
|
|
221
|
+
display: flex;
|
|
222
|
+
flex-direction: column;
|
|
223
|
+
justify-content: center;
|
|
224
|
+
padding: 6px 2px;
|
|
225
|
+
overflow: hidden;
|
|
226
|
+
z-index: 400;
|
|
227
|
+
border-radius: 10px;
|
|
228
|
+
border: 1px solid transparent;
|
|
229
|
+
color: var(--text-base, currentColor);
|
|
230
|
+
background: transparent;
|
|
231
|
+
transition: width .18s ease, background .18s ease, box-shadow .18s ease, border-color .18s ease;
|
|
232
|
+
}
|
|
233
|
+
.sr-rail[data-open="true"] {
|
|
234
|
+
width: 340px;
|
|
235
|
+
padding: 10px;
|
|
236
|
+
justify-content: flex-start;
|
|
237
|
+
background: var(--surface-base, rgba(24,24,26,.86));
|
|
238
|
+
backdrop-filter: blur(16px);
|
|
239
|
+
-webkit-backdrop-filter: blur(16px);
|
|
240
|
+
border-color: rgba(127,127,127,.25);
|
|
241
|
+
box-shadow: 0 8px 32px rgba(0,0,0,.28);
|
|
242
|
+
}
|
|
243
|
+
.sr-head {
|
|
244
|
+
display: none;
|
|
245
|
+
align-items: center;
|
|
246
|
+
justify-content: space-between;
|
|
247
|
+
gap: 8px;
|
|
248
|
+
padding: 0 2px 8px;
|
|
249
|
+
margin-bottom: 6px;
|
|
250
|
+
border-bottom: 1px solid rgba(127,127,127,.2);
|
|
251
|
+
flex-shrink: 0;
|
|
252
|
+
}
|
|
253
|
+
.sr-rail[data-open="true"] .sr-head { display: flex; }
|
|
254
|
+
.sr-title { font-size: 12px; opacity: .6; white-space: nowrap; }
|
|
255
|
+
.sr-title.sr-progress {
|
|
256
|
+
opacity: .95;
|
|
257
|
+
color: rgba(255,170,60,.95);
|
|
258
|
+
font-variant-numeric: tabular-nums;
|
|
259
|
+
}
|
|
260
|
+
.sr-head-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
|
|
261
|
+
.sr-iconbtn {
|
|
262
|
+
display: inline-flex;
|
|
263
|
+
align-items: center;
|
|
264
|
+
justify-content: center;
|
|
265
|
+
width: 22px;
|
|
266
|
+
height: 22px;
|
|
267
|
+
padding: 0;
|
|
268
|
+
border: 0;
|
|
269
|
+
background: transparent;
|
|
270
|
+
color: inherit;
|
|
271
|
+
cursor: pointer;
|
|
272
|
+
border-radius: 4px;
|
|
273
|
+
opacity: .5;
|
|
274
|
+
}
|
|
275
|
+
.sr-iconbtn svg { width: 13px; height: 13px; }
|
|
276
|
+
.sr-iconbtn:hover { opacity: 1; background: var(--surface-base-hover, rgba(127,127,127,.14)); }
|
|
277
|
+
.sr-iconbtn[data-on="true"] { opacity: .95; color: rgba(255,170,60,.95); }
|
|
278
|
+
.sr-iconbtn:focus-visible { outline: 1px solid currentColor; outline-offset: -1px; }
|
|
279
|
+
/* search box */
|
|
280
|
+
.sr-search {
|
|
281
|
+
position: relative;
|
|
282
|
+
flex-shrink: 0;
|
|
283
|
+
margin: 0 0 6px;
|
|
284
|
+
}
|
|
285
|
+
.sr-search-input {
|
|
286
|
+
width: 100%;
|
|
287
|
+
box-sizing: border-box;
|
|
288
|
+
height: 26px;
|
|
289
|
+
padding: 0 24px 0 8px;
|
|
290
|
+
border: 1px solid rgba(127,127,127,.25);
|
|
291
|
+
border-radius: 6px;
|
|
292
|
+
background: color-mix(in srgb, currentColor 6%, transparent);
|
|
293
|
+
color: inherit;
|
|
294
|
+
font: inherit;
|
|
295
|
+
font-size: 12px;
|
|
296
|
+
outline: none;
|
|
297
|
+
transition: border-color .12s ease, box-shadow .12s ease;
|
|
298
|
+
}
|
|
299
|
+
.sr-search-input::placeholder { color: currentColor; opacity: .4; }
|
|
300
|
+
.sr-search-input:focus {
|
|
301
|
+
border-color: rgba(255,170,60,.6);
|
|
302
|
+
box-shadow: 0 0 0 2px rgba(255,170,60,.14);
|
|
303
|
+
}
|
|
304
|
+
.sr-search-clear {
|
|
305
|
+
position: absolute;
|
|
306
|
+
right: 4px;
|
|
307
|
+
top: 50%;
|
|
308
|
+
transform: translateY(-50%);
|
|
309
|
+
display: inline-flex;
|
|
310
|
+
align-items: center;
|
|
311
|
+
justify-content: center;
|
|
312
|
+
width: 16px;
|
|
313
|
+
height: 16px;
|
|
314
|
+
padding: 0;
|
|
315
|
+
border: 0;
|
|
316
|
+
border-radius: 50%;
|
|
317
|
+
background: transparent;
|
|
318
|
+
color: inherit;
|
|
319
|
+
font-size: 12px;
|
|
320
|
+
line-height: 1;
|
|
321
|
+
cursor: pointer;
|
|
322
|
+
opacity: .5;
|
|
323
|
+
}
|
|
324
|
+
.sr-search-clear:hover { opacity: 1; background: var(--surface-base-hover, rgba(127,127,127,.14)); }
|
|
325
|
+
/* keyboard cursor (↑/↓) */
|
|
326
|
+
.sr-turn[data-cursor="true"] { background: var(--surface-base-hover, rgba(127,127,127,.14)); }
|
|
327
|
+
.sr-close {
|
|
328
|
+
border: 0;
|
|
329
|
+
background: transparent;
|
|
330
|
+
color: inherit;
|
|
331
|
+
font: inherit;
|
|
332
|
+
font-size: 13px;
|
|
333
|
+
line-height: 1;
|
|
334
|
+
padding: 2px 7px;
|
|
335
|
+
border-radius: 4px;
|
|
336
|
+
cursor: pointer;
|
|
337
|
+
opacity: .5;
|
|
338
|
+
flex-shrink: 0;
|
|
339
|
+
}
|
|
340
|
+
.sr-close:hover { opacity: 1; background: var(--surface-base-hover, rgba(127,127,127,.14)); }
|
|
341
|
+
/* the scrollable list; the header stays fixed above it.
|
|
342
|
+
The collapsed rail must NOT be a scroll container: centering the content
|
|
343
|
+
inside a scroller overflows in both directions, so the browser clamps the
|
|
344
|
+
scroll offset and the list snaps to the top/bottom when the panel opens. */
|
|
345
|
+
.sr-list {
|
|
346
|
+
flex: 1;
|
|
347
|
+
min-height: 0;
|
|
348
|
+
display: flex;
|
|
349
|
+
flex-direction: column;
|
|
350
|
+
justify-content: center;
|
|
351
|
+
gap: 2px;
|
|
352
|
+
overflow: hidden;
|
|
353
|
+
scrollbar-width: none;
|
|
354
|
+
}
|
|
355
|
+
.sr-rail[data-open="true"] .sr-list {
|
|
356
|
+
justify-content: flex-start;
|
|
357
|
+
gap: 1px;
|
|
358
|
+
overflow-y: auto;
|
|
359
|
+
scrollbar-width: thin;
|
|
360
|
+
}
|
|
361
|
+
.sr-list::-webkit-scrollbar { display: none; }
|
|
362
|
+
.sr-rail[data-open="true"] .sr-list::-webkit-scrollbar { display: block; width: 8px; }
|
|
363
|
+
.sr-list::-webkit-scrollbar-track { background: transparent; }
|
|
364
|
+
.sr-rail[data-open="true"] .sr-list::-webkit-scrollbar-thumb {
|
|
365
|
+
background: rgba(127,127,127,.35);
|
|
366
|
+
border-radius: 4px;
|
|
367
|
+
}
|
|
368
|
+
.sr-turn {
|
|
369
|
+
display: flex;
|
|
370
|
+
align-items: center;
|
|
371
|
+
justify-content: flex-end;
|
|
372
|
+
width: 100%;
|
|
373
|
+
min-height: 14px;
|
|
374
|
+
flex-shrink: 0;
|
|
375
|
+
cursor: pointer;
|
|
376
|
+
background: transparent;
|
|
377
|
+
border: 0;
|
|
378
|
+
padding: 0;
|
|
379
|
+
color: inherit;
|
|
380
|
+
font: inherit;
|
|
381
|
+
border-radius: 4px;
|
|
382
|
+
}
|
|
383
|
+
/* expanded: dash flips to a left-side indicator, content reads left-to-right */
|
|
384
|
+
.sr-rail[data-open="true"] .sr-turn {
|
|
385
|
+
flex-direction: row-reverse;
|
|
386
|
+
justify-content: flex-start;
|
|
387
|
+
gap: 9px;
|
|
388
|
+
padding: 5px 8px;
|
|
389
|
+
min-height: 0;
|
|
390
|
+
border-radius: 6px;
|
|
391
|
+
}
|
|
392
|
+
.sr-turn:focus-visible, .sr-close:focus-visible { outline: 1px solid currentColor; outline-offset: -1px; }
|
|
393
|
+
.sr-dash {
|
|
394
|
+
display: block;
|
|
395
|
+
width: 10px;
|
|
396
|
+
height: 2px;
|
|
397
|
+
border-radius: 1px;
|
|
398
|
+
background: currentColor;
|
|
399
|
+
opacity: .28;
|
|
400
|
+
flex-shrink: 0;
|
|
401
|
+
transition: opacity .12s ease, width .12s ease, height .12s ease;
|
|
402
|
+
}
|
|
403
|
+
.sr-turn:hover .sr-dash,
|
|
404
|
+
.sr-turn[data-active="true"] .sr-dash { opacity: .95; width: 18px; }
|
|
405
|
+
.sr-rail[data-open="true"] .sr-dash { width: 3px; height: 16px; }
|
|
406
|
+
.sr-rail[data-open="true"] .sr-turn:hover .sr-dash,
|
|
407
|
+
.sr-rail[data-open="true"] .sr-turn[data-active="true"] .sr-dash { width: 3px; height: 22px; }
|
|
408
|
+
.sr-text {
|
|
409
|
+
display: none;
|
|
410
|
+
margin-right: 10px;
|
|
411
|
+
padding: 1px 6px;
|
|
412
|
+
border-radius: 4px;
|
|
413
|
+
font-size: 12px;
|
|
414
|
+
line-height: 1.5;
|
|
415
|
+
text-align: right;
|
|
416
|
+
white-space: nowrap;
|
|
417
|
+
overflow: hidden;
|
|
418
|
+
text-overflow: ellipsis;
|
|
419
|
+
max-width: 300px;
|
|
420
|
+
opacity: .45;
|
|
421
|
+
background: transparent;
|
|
422
|
+
}
|
|
423
|
+
.sr-rail[data-open="true"] .sr-text {
|
|
424
|
+
display: -webkit-box;
|
|
425
|
+
-webkit-line-clamp: 2;
|
|
426
|
+
-webkit-box-orient: vertical;
|
|
427
|
+
overflow: hidden;
|
|
428
|
+
flex: 1;
|
|
429
|
+
min-width: 0;
|
|
430
|
+
margin: 0;
|
|
431
|
+
padding: 0;
|
|
432
|
+
text-align: left;
|
|
433
|
+
white-space: normal;
|
|
434
|
+
line-height: 1.45;
|
|
435
|
+
max-width: none;
|
|
436
|
+
opacity: .8;
|
|
437
|
+
}
|
|
438
|
+
.sr-num, .sr-time {
|
|
439
|
+
opacity: .55;
|
|
440
|
+
margin-right: 6px;
|
|
441
|
+
font-variant-numeric: tabular-nums;
|
|
442
|
+
}
|
|
443
|
+
.sr-turn:hover { background: var(--surface-base-hover, rgba(127,127,127,.14)); }
|
|
444
|
+
.sr-rail[data-open="true"] .sr-turn:hover .sr-text,
|
|
445
|
+
.sr-rail[data-open="true"] .sr-turn[data-active="true"] .sr-text { opacity: 1; }
|
|
446
|
+
.sr-turn[data-active="true"] { font-weight: 500; }
|
|
447
|
+
/* the turn currently being jumped to */
|
|
448
|
+
.sr-turn[data-busy="true"] { background: rgba(255,170,60,.12); }
|
|
449
|
+
.sr-turn[data-busy="true"] .sr-dash {
|
|
450
|
+
opacity: 1;
|
|
451
|
+
width: 3px;
|
|
452
|
+
height: 22px;
|
|
453
|
+
background: rgba(255,170,60,.95);
|
|
454
|
+
animation: srBusy 1s ease-in-out infinite;
|
|
455
|
+
}
|
|
456
|
+
.sr-turn[data-busy="false"] .sr-dash { animation: none; }
|
|
457
|
+
@keyframes srBusy {
|
|
458
|
+
0%, 100% { opacity: .35; }
|
|
459
|
+
50% { opacity: 1; }
|
|
460
|
+
}
|
|
461
|
+
/* pulse a landed bubble: an INSET ring, so it follows the bubble's own
|
|
462
|
+
border-radius and can never be clipped by an ancestor that clips overflow
|
|
463
|
+
(the timeline rows do) - an outline/box-shadow drawn outside the bubble
|
|
464
|
+
loses its top edge exactly where the row clips. */
|
|
465
|
+
.sr-flash-ring { animation: srRing 1.8s ease-out 1; }
|
|
466
|
+
@keyframes srRing {
|
|
467
|
+
0% {
|
|
468
|
+
box-shadow: inset 0 0 0 2px rgba(255,170,60,.95), inset 0 0 26px rgba(255,170,60,.30);
|
|
469
|
+
}
|
|
470
|
+
55% {
|
|
471
|
+
box-shadow: inset 0 0 0 2px rgba(255,170,60,.40), inset 0 0 14px rgba(255,170,60,.12);
|
|
472
|
+
}
|
|
473
|
+
100% {
|
|
474
|
+
box-shadow: inset 0 0 0 2px rgba(255,170,60,0), inset 0 0 0 rgba(255,170,60,0);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
.sr-badge {
|
|
478
|
+
font-size: 10px;
|
|
479
|
+
line-height: 1.4;
|
|
480
|
+
padding: 0 5px;
|
|
481
|
+
border-radius: 4px;
|
|
482
|
+
color: rgba(255,170,60,.95);
|
|
483
|
+
background: rgba(255,170,60,.14);
|
|
484
|
+
border: 1px solid rgba(255,170,60,.35);
|
|
485
|
+
flex-shrink: 0;
|
|
486
|
+
white-space: nowrap;
|
|
487
|
+
}
|
|
488
|
+
.sr-note {
|
|
489
|
+
display: none;
|
|
490
|
+
font-size: 11px;
|
|
491
|
+
opacity: .4;
|
|
492
|
+
padding: 2px;
|
|
493
|
+
}
|
|
494
|
+
.sr-rail[data-open="true"] .sr-note { display: block; }
|
|
495
|
+
.sr-foot {
|
|
496
|
+
flex-shrink: 0;
|
|
497
|
+
font-size: 11px;
|
|
498
|
+
line-height: 1.5;
|
|
499
|
+
color: rgba(255,170,60,.92);
|
|
500
|
+
padding: 6px 2px 0;
|
|
501
|
+
margin-top: 6px;
|
|
502
|
+
border-top: 1px solid rgba(127,127,127,.2);
|
|
503
|
+
white-space: pre-wrap;
|
|
504
|
+
max-height: 64px;
|
|
505
|
+
overflow: hidden;
|
|
506
|
+
}
|
|
507
|
+
.sr-status {
|
|
508
|
+
position: fixed;
|
|
509
|
+
right: 42px;
|
|
510
|
+
top: 50%;
|
|
511
|
+
transform: translateY(-50%);
|
|
512
|
+
z-index: 401;
|
|
513
|
+
font-size: 11px;
|
|
514
|
+
line-height: 1.5;
|
|
515
|
+
padding: 4px 9px;
|
|
516
|
+
border-radius: 6px;
|
|
517
|
+
background: var(--background-strong, rgba(0,0,0,.82));
|
|
518
|
+
color: var(--text-base, #fff);
|
|
519
|
+
border: 1px solid rgba(127,127,127,.25);
|
|
520
|
+
white-space: pre-wrap;
|
|
521
|
+
max-width: 260px;
|
|
522
|
+
}
|
|
523
|
+
/* Reduced motion: keep the signals (colors, widths) but drop the animation. */
|
|
524
|
+
@media (prefers-reduced-motion: reduce) {
|
|
525
|
+
.sr-rail, .sr-dash, .sr-text { transition: none !important; }
|
|
526
|
+
.sr-turn[data-busy="true"] .sr-dash { animation: none !important; opacity: 1 !important; }
|
|
527
|
+
.sr-flash-ring { animation: none !important; box-shadow: inset 0 0 0 2px rgba(255,170,60,.5) !important; }
|
|
528
|
+
}
|
|
529
|
+
`
|
|
530
|
+
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
// rail
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
|
|
535
|
+
function Rail(props) {
|
|
536
|
+
const ctx = usePlugin()
|
|
537
|
+
const [turns, setTurns] = createSignal([])
|
|
538
|
+
const [active, setActive] = createSignal("")
|
|
539
|
+
const [open, setOpen] = createSignal(false)
|
|
540
|
+
const [note, setNote] = createSignal("")
|
|
541
|
+
const [status, setStatus] = createSignal("")
|
|
542
|
+
// Search box over the turn list; empty means show everything.
|
|
543
|
+
const [query, setQuery] = createSignal("")
|
|
544
|
+
// Pinned panels stay open when the pointer leaves (toggle: ⌘⌥P / /turns pin).
|
|
545
|
+
const [pinned, setPinned] = createSignal(false)
|
|
546
|
+
// Jump progress. While `jumping` is true the panel stays pinned open and the
|
|
547
|
+
// header reports what the host is doing; `busyID` marks the target row.
|
|
548
|
+
const [jumping, setJumping] = createSignal(false)
|
|
549
|
+
const [jumpPages, setJumpPages] = createSignal(0)
|
|
550
|
+
const [jumpElapsed, setJumpElapsed] = createSignal(0)
|
|
551
|
+
const [jumpChain, setJumpChain] = createSignal(0)
|
|
552
|
+
const [busyID, setBusyID] = createSignal("")
|
|
553
|
+
// True while a click is waiting for the background prewarm to page its target
|
|
554
|
+
// in (header reads "预热中 · 稍等一下" instead of "加载历史中").
|
|
555
|
+
const [waitWarm, setWaitWarm] = createSignal(false)
|
|
556
|
+
|
|
557
|
+
// ---- turn cache ---------------------------------------------------------
|
|
558
|
+
// Object identity is kept stable across refreshes so Solid's <For> reuses
|
|
559
|
+
// existing DOM instead of rebuilding the whole list on every update.
|
|
560
|
+
const cache = new Map()
|
|
561
|
+
let loadToken = 0
|
|
562
|
+
let loadingActive = false
|
|
563
|
+
let pendingBump = false
|
|
564
|
+
let loadedFor = ""
|
|
565
|
+
let lightTimer
|
|
566
|
+
|
|
567
|
+
const sessionID = () => props.session()?.id
|
|
568
|
+
|
|
569
|
+
const publish = () => {
|
|
570
|
+
const list = [...cache.values()].sort((a, b) => a.created - b.created)
|
|
571
|
+
const prev = untrack(turns)
|
|
572
|
+
// Skip the signal write when nothing changed; <For> diffs by reference.
|
|
573
|
+
if (prev.length === list.length && prev.every((item, i) => item === list[i])) return
|
|
574
|
+
setTurns(list)
|
|
575
|
+
lastTurnCount = list.length
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const upsert = (item) => {
|
|
579
|
+
const existing = cache.get(item.id)
|
|
580
|
+
if (existing && existing.created === item.time && existing.text === item.text) return
|
|
581
|
+
cache.set(item.id, { id: item.id, created: item.time, text: item.text })
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const fetchPage = async (id, limit, cursor) => {
|
|
585
|
+
// The server rejects `cursor` together with `order`
|
|
586
|
+
// (InvalidCursorError: "Cursor cannot be combined with order").
|
|
587
|
+
// The cursor itself already encodes the original order/direction,
|
|
588
|
+
// so only the first page declares order: "desc".
|
|
589
|
+
const params = cursor
|
|
590
|
+
? { sessionID: id, type: "user", limit, cursor }
|
|
591
|
+
: { sessionID: id, type: "user", limit, order: "desc" }
|
|
592
|
+
return await ctx.client.message.list(params)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const load = async (id, mode) => {
|
|
596
|
+
const token = ++loadToken
|
|
597
|
+
loadingActive = true
|
|
598
|
+
// A full (re)load means the host store may be cold again (session open or
|
|
599
|
+
// server rebuild): invalidate the prewarm marker so it runs once more.
|
|
600
|
+
if (mode === "full") {
|
|
601
|
+
warmedFor = ""
|
|
602
|
+
warmBlockedFor = ""
|
|
603
|
+
}
|
|
604
|
+
let error = ""
|
|
605
|
+
try {
|
|
606
|
+
let cursor
|
|
607
|
+
const pages = mode === "full" ? FULL_PAGE_LIMIT : 1
|
|
608
|
+
const limit = mode === "full" ? FULL_PAGE_SIZE : LIGHT_PAGE_SIZE
|
|
609
|
+
let cleared = mode !== "full"
|
|
610
|
+
for (let page = 0; page < pages; page++) {
|
|
611
|
+
let response
|
|
612
|
+
try {
|
|
613
|
+
response = await fetchPage(id, limit, cursor)
|
|
614
|
+
} catch (cause) {
|
|
615
|
+
error = String(cause?.message ?? cause)
|
|
616
|
+
break
|
|
617
|
+
}
|
|
618
|
+
// Superseded by a newer load (or a session switch): drop this page
|
|
619
|
+
// instead of polluting the new session's cache with stale ids.
|
|
620
|
+
if (token !== loadToken) return
|
|
621
|
+
// Full mode clears only after the first page succeeded, so a server
|
|
622
|
+
// error never blanks out the rail (v10's hard-won lesson).
|
|
623
|
+
if (!cleared) {
|
|
624
|
+
cache.clear()
|
|
625
|
+
cleared = true
|
|
626
|
+
}
|
|
627
|
+
let fresh = 0
|
|
628
|
+
for (const message of response?.data ?? []) {
|
|
629
|
+
if (cache.has(message.id)) continue
|
|
630
|
+
upsert({ id: message.id, time: message.time?.created ?? 0, text: firstLine(message) })
|
|
631
|
+
fresh++
|
|
632
|
+
}
|
|
633
|
+
// Publish page by page: long sessions appear progressively instead of
|
|
634
|
+
// popping in all at once after every page has been fetched.
|
|
635
|
+
publish()
|
|
636
|
+
const next = response?.cursor?.next
|
|
637
|
+
if (!next || fresh === 0) break
|
|
638
|
+
cursor = next
|
|
639
|
+
}
|
|
640
|
+
} finally {
|
|
641
|
+
loadingActive = false
|
|
642
|
+
loadedFor = id
|
|
643
|
+
lastNote = error
|
|
644
|
+
setNote(cache.size === 0 ? error || "暂无用户消息" : "")
|
|
645
|
+
// A message arrived while a full load was running: catch up now.
|
|
646
|
+
if (pendingBump) {
|
|
647
|
+
pendingBump = false
|
|
648
|
+
scheduleLight()
|
|
649
|
+
}
|
|
650
|
+
// Prewarm EVERY session as soon as its full turn list has loaded - the
|
|
651
|
+
// host's seek machine drops seeks issued before the timeline is ready,
|
|
652
|
+
// and an 8-turn session used to hit exactly that window on first click
|
|
653
|
+
// (12s of dead air). Short sessions cost at most one extra host page.
|
|
654
|
+
if (mode === "full" && !error && id === untrack(sessionID) && cache.size >= 1) {
|
|
655
|
+
setTimeout(() => maybePrewarm(), 400)
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ---- event-driven refresh (replaces the 5s full-list polling) -----------
|
|
661
|
+
// The SDK shares one SSE connection with the host; consuming it here is a
|
|
662
|
+
// second iterator over the same stream, not a second connection. Events are
|
|
663
|
+
// debounced into a single-page light refresh, so streaming bursts cost
|
|
664
|
+
// nothing beyond a few cheap `type` comparisons per event.
|
|
665
|
+
const scheduleLight = () => {
|
|
666
|
+
if (lightTimer) return
|
|
667
|
+
lightTimer = setTimeout(() => {
|
|
668
|
+
lightTimer = undefined
|
|
669
|
+
const id = untrack(sessionID)
|
|
670
|
+
if (id) void load(id, "light")
|
|
671
|
+
}, 300)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const bump = (eventSessionID) => {
|
|
675
|
+
// Only the session currently shown matters; ignore everything else.
|
|
676
|
+
if (!eventSessionID || eventSessionID !== untrack(sessionID)) return
|
|
677
|
+
if (loadingActive) {
|
|
678
|
+
pendingBump = true
|
|
679
|
+
return
|
|
680
|
+
}
|
|
681
|
+
scheduleLight()
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const handleEvent = (event) => {
|
|
685
|
+
const type = event?.type
|
|
686
|
+
if (type === "message.updated") {
|
|
687
|
+
const data = event?.data ?? {}
|
|
688
|
+
const info = data.info
|
|
689
|
+
// Fast path: the payload carries the message itself, so a new user turn
|
|
690
|
+
// shows up instantly; the debounced light refresh reconciles the rest.
|
|
691
|
+
if (info?.role === "user" && info?.id) {
|
|
692
|
+
upsert({ id: info.id, time: info.time?.created ?? 0, text: firstLine(info) })
|
|
693
|
+
publish()
|
|
694
|
+
}
|
|
695
|
+
bump(data.sessionID)
|
|
696
|
+
} else if (type === "message.removed") {
|
|
697
|
+
const data = event?.data ?? {}
|
|
698
|
+
if (data.messageID && cache.delete(data.messageID)) publish()
|
|
699
|
+
bump(data.sessionID)
|
|
700
|
+
} else if (type === "location.shutdown") {
|
|
701
|
+
// Server rebuilt: do one full reload of the current session.
|
|
702
|
+
const id = untrack(sessionID)
|
|
703
|
+
if (id && loadedFor === id) void load(id, "full")
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
onMount(() => {
|
|
708
|
+
let stopped = false
|
|
709
|
+
let attempt = 0
|
|
710
|
+
let retryTimer
|
|
711
|
+
const run = async () => {
|
|
712
|
+
while (!stopped) {
|
|
713
|
+
const stream = ctx.client.event.subscribe()
|
|
714
|
+
const iterator = stream[Symbol.asyncIterator]()
|
|
715
|
+
try {
|
|
716
|
+
while (!stopped) {
|
|
717
|
+
const next = await iterator.next()
|
|
718
|
+
if (next.done) break
|
|
719
|
+
attempt = 0
|
|
720
|
+
handleEvent(next.value)
|
|
721
|
+
}
|
|
722
|
+
} catch {}
|
|
723
|
+
// iterator.return() cancels the SSE reader (the SDK's generator
|
|
724
|
+
// finally-block); errors from it are irrelevant at this point.
|
|
725
|
+
try {
|
|
726
|
+
await iterator.return?.()
|
|
727
|
+
} catch {}
|
|
728
|
+
if (stopped) break
|
|
729
|
+
attempt++
|
|
730
|
+
await sleep(Math.min(2000 * attempt, 15000))
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
void run()
|
|
734
|
+
onCleanup(() => {
|
|
735
|
+
stopped = true
|
|
736
|
+
clearTimeout(retryTimer)
|
|
737
|
+
if (lightTimer) clearTimeout(lightTimer)
|
|
738
|
+
})
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
createEffect(() => {
|
|
742
|
+
const id = sessionID()
|
|
743
|
+
if (!id) return
|
|
744
|
+
untrack(() => {
|
|
745
|
+
if (loadedFor !== id) {
|
|
746
|
+
// Invalidate anything in flight for the previous session first, so its
|
|
747
|
+
// finally-block can't write `loadedFor`/`note` for the wrong session.
|
|
748
|
+
loadToken++
|
|
749
|
+
loadingActive = false
|
|
750
|
+
pendingBump = false
|
|
751
|
+
cache.clear()
|
|
752
|
+
setTurns([])
|
|
753
|
+
setActive("")
|
|
754
|
+
lastNavID = ""
|
|
755
|
+
void load(id, "full")
|
|
756
|
+
}
|
|
757
|
+
})
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
// ---- active-turn tracking ----------------------------------------------
|
|
761
|
+
// Driven by scroll events (captured at document level so it also sees the
|
|
762
|
+
// real scroller without querying first) plus one rAF-throttled pass. An
|
|
763
|
+
// IntersectionObserver on the timeline rows replaces the old 1.5s interval:
|
|
764
|
+
// a row leaving the sentinel line at the top of the viewport means the turn
|
|
765
|
+
// under it became active, with zero work when nothing moves (and none at
|
|
766
|
+
// all in a background window, where rAF is throttled to zero).
|
|
767
|
+
let scrollerCache
|
|
768
|
+
const scroller = () => {
|
|
769
|
+
if (scrollerCache?.isConnected) return scrollerCache
|
|
770
|
+
scrollerCache = findScroller()
|
|
771
|
+
return scrollerCache
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Rows already wired to the observer, keyed by element so history paging
|
|
775
|
+
// (rows mounting/unmounting) never re-observes the same node.
|
|
776
|
+
const observed = new WeakSet()
|
|
777
|
+
let rowObserver
|
|
778
|
+
const ensureObserver = () => {
|
|
779
|
+
if (rowObserver) return rowObserver
|
|
780
|
+
rowObserver = new IntersectionObserver(
|
|
781
|
+
(entries) => {
|
|
782
|
+
// Any intersection change along the sentinel line can shift which turn
|
|
783
|
+
// is topmost; recompute from the DOM (rAF-throttled, cheap).
|
|
784
|
+
if (entries.length === 0) return
|
|
785
|
+
scheduleTrack()
|
|
786
|
+
},
|
|
787
|
+
{ rootMargin: "-40px 0px 0px 0px", threshold: 0 },
|
|
788
|
+
)
|
|
789
|
+
return rowObserver
|
|
790
|
+
}
|
|
791
|
+
// Wire up rows that appeared since the last pass. Called from trackActive,
|
|
792
|
+
// so it only runs while the document is actually scrolling (rAF-gated).
|
|
793
|
+
const observeRows = () => {
|
|
794
|
+
const observer = ensureObserver()
|
|
795
|
+
for (const row of document.querySelectorAll("[data-message-id]")) {
|
|
796
|
+
if (!observed.has(row)) {
|
|
797
|
+
observed.add(row)
|
|
798
|
+
observer.observe(row)
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const trackActive = () => {
|
|
804
|
+
const sc = scroller()
|
|
805
|
+
if (!sc) return
|
|
806
|
+
const rows = sc.querySelectorAll("[data-message-id]")
|
|
807
|
+
if (rows.length === 0) return
|
|
808
|
+
const top = sc.getBoundingClientRect().top + 40
|
|
809
|
+
for (const row of rows) {
|
|
810
|
+
if (row.getBoundingClientRect().bottom >= top) {
|
|
811
|
+
const id = row.getAttribute("data-message-id")
|
|
812
|
+
if (id && id !== active()) setActive(id)
|
|
813
|
+
break
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
let rafPending = false
|
|
819
|
+
const scheduleTrack = () => {
|
|
820
|
+
if (rafPending) return
|
|
821
|
+
rafPending = true
|
|
822
|
+
requestAnimationFrame(() => {
|
|
823
|
+
rafPending = false
|
|
824
|
+
trackActive()
|
|
825
|
+
observeRows()
|
|
826
|
+
})
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
onMount(() => {
|
|
830
|
+
const onScroll = () => scheduleTrack()
|
|
831
|
+
document.addEventListener("scroll", onScroll, true)
|
|
832
|
+
// One slow liveness pass only while the tab is visible: catches rows that
|
|
833
|
+
// mount without a scroll event and reconnects after DOM swaps, without
|
|
834
|
+
// burning cycles in background windows (rAF never fires there, and the
|
|
835
|
+
// interval self-pauses via document.hidden).
|
|
836
|
+
const fallback = setInterval(() => {
|
|
837
|
+
if (document.hidden) return
|
|
838
|
+
scheduleTrack()
|
|
839
|
+
}, 4000)
|
|
840
|
+
onCleanup(() => {
|
|
841
|
+
document.removeEventListener("scroll", onScroll, true)
|
|
842
|
+
clearInterval(fallback)
|
|
843
|
+
rowObserver?.disconnect()
|
|
844
|
+
})
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
// ---- status flash -------------------------------------------------------
|
|
848
|
+
// A sequence guard so a slow timeout from an earlier flash can never clear a
|
|
849
|
+
// newer message.
|
|
850
|
+
let flashSeq = 0
|
|
851
|
+
const flash = (text, ms = 2600) => {
|
|
852
|
+
const seq = ++flashSeq
|
|
853
|
+
setStatus(text)
|
|
854
|
+
setTimeout(() => {
|
|
855
|
+
if (seq === flashSeq) setStatus("")
|
|
856
|
+
}, ms)
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// ---- open / close interactions ------------------------------------------
|
|
860
|
+
onMount(() => {
|
|
861
|
+
const onKey = (event) => {
|
|
862
|
+
if (event.key !== "Escape") return
|
|
863
|
+
// During a long jump Esc cancels the jump first and keeps the panel open,
|
|
864
|
+
// so the progress display doesn't vanish mid-flight.
|
|
865
|
+
if (untrack(jumping)) {
|
|
866
|
+
cancelJump()
|
|
867
|
+
event.stopPropagation()
|
|
868
|
+
return
|
|
869
|
+
}
|
|
870
|
+
if (untrack(open)) {
|
|
871
|
+
// Unpin first when pinned; a second Esc then closes the panel.
|
|
872
|
+
if (untrack(pinned)) {
|
|
873
|
+
setPinned(false)
|
|
874
|
+
} else {
|
|
875
|
+
setOpen(false)
|
|
876
|
+
}
|
|
877
|
+
// Only swallow Esc while the rail is actually open, so the host keeps
|
|
878
|
+
// its own Escape handling the rest of the time.
|
|
879
|
+
event.stopPropagation()
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
const onPointerDown = (event) => {
|
|
883
|
+
if (!untrack(open)) return
|
|
884
|
+
if (untrack(pinned)) return
|
|
885
|
+
const path = event.composedPath?.() ?? []
|
|
886
|
+
const inside = path.some((node) => node instanceof Element && node.closest?.(".sr-rail"))
|
|
887
|
+
if (!inside) setOpen(false)
|
|
888
|
+
}
|
|
889
|
+
window.addEventListener("keydown", onKey, true)
|
|
890
|
+
document.addEventListener("pointerdown", onPointerDown, true)
|
|
891
|
+
onCleanup(() => {
|
|
892
|
+
window.removeEventListener("keydown", onKey, true)
|
|
893
|
+
document.removeEventListener("pointerdown", onPointerDown, true)
|
|
894
|
+
})
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
onMount(() => {
|
|
898
|
+
const onDiag = () => flash(`${VERSION} · ${diagnostics(ctx)}`)
|
|
899
|
+
// /turns toggles the panel; ⌘⌥P / the pin button toggles "pinned" (stay
|
|
900
|
+
// open when the pointer leaves). Esc / outside click / × still close an
|
|
901
|
+
// unpinned panel.
|
|
902
|
+
const onOpen = () => {
|
|
903
|
+
setOpen((value) => !value)
|
|
904
|
+
maybePrewarm()
|
|
905
|
+
}
|
|
906
|
+
const onPin = () => {
|
|
907
|
+
setPinned((value) => !value)
|
|
908
|
+
if (!untrack(open)) {
|
|
909
|
+
setOpen(true)
|
|
910
|
+
maybePrewarm()
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
window.addEventListener(DIAG_EVENT, onDiag)
|
|
914
|
+
window.addEventListener(OPEN_EVENT, onOpen)
|
|
915
|
+
window.addEventListener(PIN_EVENT, onPin)
|
|
916
|
+
onCleanup(() => {
|
|
917
|
+
window.removeEventListener(DIAG_EVENT, onDiag)
|
|
918
|
+
window.removeEventListener(OPEN_EVENT, onOpen)
|
|
919
|
+
window.removeEventListener(PIN_EVENT, onPin)
|
|
920
|
+
})
|
|
921
|
+
// No startup toast: the plugin should be invisible when everything works.
|
|
922
|
+
})
|
|
923
|
+
|
|
924
|
+
// ---- jump ----------------------------------------------------------------
|
|
925
|
+
// The memory router's hash is invisible to us (window.location is stale), so
|
|
926
|
+
// track the last id we navigated to in order to force a hash change each round.
|
|
927
|
+
let lastNavID = ""
|
|
928
|
+
let jumpToken = 0
|
|
929
|
+
let lastJumpAt = 0
|
|
930
|
+
let lastJumpTurnID = ""
|
|
931
|
+
|
|
932
|
+
// Brief highlight on the message bubble. The timeline row spans the whole
|
|
933
|
+
// width, so tinting it looks like a stray orange band. The visible bubble is
|
|
934
|
+
// `[data-slot="user-message-text"]` (background + 10px radius); its parent
|
|
935
|
+
// `user-message-body` is only a layout column. An outline + glow hugs the
|
|
936
|
+
// bubble's own shape without altering its background.
|
|
937
|
+
const pulse = (row) => {
|
|
938
|
+
try {
|
|
939
|
+
const target =
|
|
940
|
+
row.querySelector('[data-slot="user-message-text"]') ??
|
|
941
|
+
row.querySelector('[data-slot="user-message-body"]') ??
|
|
942
|
+
row
|
|
943
|
+
target.classList.remove("sr-flash-ring")
|
|
944
|
+
void target.offsetWidth // restart the animation when re-pulsing the same node
|
|
945
|
+
target.classList.add("sr-flash-ring")
|
|
946
|
+
setTimeout(() => target.classList.remove("sr-flash-ring"), 1900)
|
|
947
|
+
} catch {}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// Oldest turn that is currently mounted (i.e. already paged in), used as a
|
|
951
|
+
// safe "different id" to toggle the router hash so it re-runs its seek.
|
|
952
|
+
const oldestLoadedTurn = () => {
|
|
953
|
+
for (const t of turns()) if (findRow(t.id)) return t
|
|
954
|
+
return undefined
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const navTo = (path, id) => {
|
|
958
|
+
clickRoute(`${path}#${HASH_PREFIX}${id}`)
|
|
959
|
+
lastNavID = id
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// Cheap fingerprint of the mounted timeline rows. Paging in older history
|
|
963
|
+
// prepends rows, so a changed fingerprint means the host did real work and
|
|
964
|
+
// the next hash toggle can be sent immediately.
|
|
965
|
+
const rowSignature = () => {
|
|
966
|
+
const sc = scroller()
|
|
967
|
+
const rows = sc?.querySelectorAll("[data-message-id]") ?? []
|
|
968
|
+
if (rows.length === 0) return "0"
|
|
969
|
+
const first = rows[0].getAttribute("data-message-id") ?? ""
|
|
970
|
+
const last = rows[rows.length - 1].getAttribute("data-message-id") ?? ""
|
|
971
|
+
return `${rows.length}:${first}:${last}`
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Waits until the target row is mounted.
|
|
975
|
+
//
|
|
976
|
+
// Resolves with the row on success, or null when the host stopped making
|
|
977
|
+
// progress (its own seek chain ran out) or the timeout hit. Crucially it
|
|
978
|
+
// does NOT resolve just because rows appeared: the host may still be
|
|
979
|
+
// fetching the rest of the history for this target, and interrupting it
|
|
980
|
+
// (by toggling the hash) is what made deep jumps crawl one page per click.
|
|
981
|
+
//
|
|
982
|
+
// onKick covers the cold-start case: the host's seek machine starts
|
|
983
|
+
// inactive and DROPS a seek issued before the timeline is ready (worst on
|
|
984
|
+
// short sessions, which never used to prewarm). When nothing at all has
|
|
985
|
+
// happened for KICK_AFTER_MS, it is called once to bounce the hash and
|
|
986
|
+
// re-engage the host; then the normal idle limits apply.
|
|
987
|
+
const waitForRow = (messageID, token, timeout, onPage, onKick) =>
|
|
988
|
+
new Promise((resolve) => {
|
|
989
|
+
let done = false
|
|
990
|
+
let observer
|
|
991
|
+
let poll
|
|
992
|
+
let timer
|
|
993
|
+
const started = Date.now()
|
|
994
|
+
const baseline = rowSignature()
|
|
995
|
+
let lastSig = baseline
|
|
996
|
+
let lastChange = started
|
|
997
|
+
let pages = 0
|
|
998
|
+
let kicked = false
|
|
999
|
+
const KICK_AFTER_MS = 4000
|
|
1000
|
+
const finish = (row) => {
|
|
1001
|
+
if (done) return
|
|
1002
|
+
done = true
|
|
1003
|
+
try {
|
|
1004
|
+
observer?.disconnect()
|
|
1005
|
+
} catch {}
|
|
1006
|
+
clearInterval(poll)
|
|
1007
|
+
clearTimeout(timer)
|
|
1008
|
+
resolve(row ?? null)
|
|
1009
|
+
}
|
|
1010
|
+
const check = (full) => {
|
|
1011
|
+
if (token !== jumpToken) return finish(null)
|
|
1012
|
+
// Observer callbacks use the cheap id lookup; only the poll pays for the
|
|
1013
|
+
// data-attribute fallback (a full scan) and the row fingerprint.
|
|
1014
|
+
const row = full ? findRow(messageID) : document.getElementById(`${HASH_PREFIX}${messageID}`)
|
|
1015
|
+
if (row) return finish(row)
|
|
1016
|
+
if (!full) return
|
|
1017
|
+
const sig = rowSignature()
|
|
1018
|
+
const now = Date.now()
|
|
1019
|
+
if (sig !== lastSig) {
|
|
1020
|
+
lastSig = sig
|
|
1021
|
+
lastChange = now
|
|
1022
|
+
pages++
|
|
1023
|
+
onPage?.(pages)
|
|
1024
|
+
return
|
|
1025
|
+
}
|
|
1026
|
+
// Nothing has changed for a while: the host has no more history in
|
|
1027
|
+
// flight for this target. Before the first page lands we are even more
|
|
1028
|
+
// patient - the initial fetch can take seconds, and bouncing the hash
|
|
1029
|
+
// then would abort it.
|
|
1030
|
+
const idleLimit = pages > 0 ? JUMP_IDLE_MS : JUMP_FIRST_IDLE_MS
|
|
1031
|
+
// Cold start, nothing has EVER changed: a dropped seek. Kick the host
|
|
1032
|
+
// once instead of burning the whole first-idle budget on dead air.
|
|
1033
|
+
if (!kicked && onKick && baseline === "0" && now - lastChange > KICK_AFTER_MS) {
|
|
1034
|
+
kicked = true
|
|
1035
|
+
lastChange = now
|
|
1036
|
+
onKick()
|
|
1037
|
+
return
|
|
1038
|
+
}
|
|
1039
|
+
if (now - lastChange > idleLimit) finish(null)
|
|
1040
|
+
}
|
|
1041
|
+
const existing = findRow(messageID)
|
|
1042
|
+
if (existing) return finish(existing)
|
|
1043
|
+
observer = new MutationObserver(() => check(false))
|
|
1044
|
+
observer.observe(document.documentElement, { childList: true, subtree: true })
|
|
1045
|
+
poll = setInterval(() => check(true), 120)
|
|
1046
|
+
timer = setTimeout(() => finish(findRow(messageID)), Math.max(400, timeout))
|
|
1047
|
+
})
|
|
1048
|
+
|
|
1049
|
+
const cancelJump = () => {
|
|
1050
|
+
jumpToken++
|
|
1051
|
+
setJumping(false)
|
|
1052
|
+
setJumpPages(0)
|
|
1053
|
+
setJumpElapsed(0)
|
|
1054
|
+
setJumpChain(0)
|
|
1055
|
+
setBusyID("")
|
|
1056
|
+
setWaitWarm(false)
|
|
1057
|
+
flash("已取消跳转")
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
const jump = async (turn) => {
|
|
1061
|
+
// Clicking the same turn again while its jump is running must NOT restart
|
|
1062
|
+
// the loop: every restart cancels the host's in-flight page load, which is
|
|
1063
|
+
// what makes a deep jump advance only one page per click. The header
|
|
1064
|
+
// already shows the progress, so just leave it running.
|
|
1065
|
+
if (jumping() && busyID() === turn.id) return
|
|
1066
|
+
|
|
1067
|
+
const path = routePath(ctx)
|
|
1068
|
+
const sc = scroller()
|
|
1069
|
+
const mounted = findRow(turn.id)
|
|
1070
|
+
|
|
1071
|
+
// Fast path: the row is already mounted (e.g. the prewarm reached it), so
|
|
1072
|
+
// the jump is instant. If a prewarm is still running, re-target the host's
|
|
1073
|
+
// pending oldest-seek to this turn first - otherwise the host would scroll
|
|
1074
|
+
// to the oldest message when that seek completes and yank the viewport.
|
|
1075
|
+
if (mounted && sc) {
|
|
1076
|
+
if (warming()) {
|
|
1077
|
+
cancelWarm()
|
|
1078
|
+
if (path) navTo(path, turn.id)
|
|
1079
|
+
}
|
|
1080
|
+
alignAfterLand(mounted)
|
|
1081
|
+
pulse(mounted)
|
|
1082
|
+
return
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const now = Date.now()
|
|
1086
|
+
// Same target clicked again shortly after: treat as idempotent re-scroll
|
|
1087
|
+
// instead of restarting the paging loop.
|
|
1088
|
+
if (turn.id === lastJumpTurnID && now - lastJumpAt < SAME_TARGET_WINDOW_MS) {
|
|
1089
|
+
const row = findRow(turn.id)
|
|
1090
|
+
if (row && sc) {
|
|
1091
|
+
alignAfterLand(row)
|
|
1092
|
+
pulse(row)
|
|
1093
|
+
return
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
lastJumpAt = now
|
|
1097
|
+
lastJumpTurnID = turn.id
|
|
1098
|
+
lastJumpID = turn.id
|
|
1099
|
+
|
|
1100
|
+
if (!path) {
|
|
1101
|
+
flash(`无法读取当前路由,无法定位 · ${diagnostics(ctx)}`)
|
|
1102
|
+
return
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
const token = ++jumpToken
|
|
1106
|
+
const sessionAtJump = sessionID()
|
|
1107
|
+
setJumping(true)
|
|
1108
|
+
setBusyID(turn.id)
|
|
1109
|
+
setJumpPages(0)
|
|
1110
|
+
setJumpElapsed(0)
|
|
1111
|
+
setJumpChain(0)
|
|
1112
|
+
setWaitWarm(warming())
|
|
1113
|
+
// Pin the panel open even if the pointer has left it meanwhile.
|
|
1114
|
+
setOpen(true)
|
|
1115
|
+
const startedAt = Date.now()
|
|
1116
|
+
const elapsedTimer = setInterval(() => {
|
|
1117
|
+
setJumpElapsed(Math.round((Date.now() - startedAt) / 1000))
|
|
1118
|
+
}, 500)
|
|
1119
|
+
// Pages loaded so far, accumulated across chains (a new chain restarts
|
|
1120
|
+
// the per-chain counter, the headline must not go backwards).
|
|
1121
|
+
let chainBase = 0
|
|
1122
|
+
try {
|
|
1123
|
+
// A prewarm is already paging the history (it seeks the oldest turn, so
|
|
1124
|
+
// it passes through this one on the way). Don't fight it with a competing
|
|
1125
|
+
// seek: just wait for the row to mount, then land. The header reads
|
|
1126
|
+
// "预热中 · 稍等一下" while we wait.
|
|
1127
|
+
if (warming()) {
|
|
1128
|
+
// No cold-start kick here: the running prewarm owns the hash and has
|
|
1129
|
+
// its own kick loop; competing toggles would fight it.
|
|
1130
|
+
const landed = await waitForRow(turn.id, token, 20000, (pages) => setJumpPages(pages))
|
|
1131
|
+
if (token !== jumpToken || sessionID() !== sessionAtJump) return
|
|
1132
|
+
if (landed) {
|
|
1133
|
+
cancelWarm()
|
|
1134
|
+
navTo(path, turn.id)
|
|
1135
|
+
alignAfterLand(landed)
|
|
1136
|
+
pulse(landed)
|
|
1137
|
+
return
|
|
1138
|
+
}
|
|
1139
|
+
// The prewarm ran out without mounting the row: fall back to driving
|
|
1140
|
+
// the host's seek ourselves below.
|
|
1141
|
+
if (warming()) cancelWarm()
|
|
1142
|
+
setWaitWarm(false)
|
|
1143
|
+
if (token !== jumpToken || sessionID() !== sessionAtJump) return
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// Deep history, no prewarm. The host's own seek machine does the heavy
|
|
1147
|
+
// lifting: hash.changed -> applying -> pending(target) -> target.synced
|
|
1148
|
+
// -> seek, loading the missing history asynchronously. Every extra hash
|
|
1149
|
+
// change RESETS that pending state and aborts the fetch in flight, which
|
|
1150
|
+
// is why hammering the hash made a deep jump advance one page per click.
|
|
1151
|
+
// So: set the hash once, then be patient; only bounce it through a
|
|
1152
|
+
// mounted turn when the host has been idle for JUMP_IDLE_MS.
|
|
1153
|
+
const deadline = Date.now() + JUMP_TOTAL_MS
|
|
1154
|
+
for (let chain = 0; chain < JUMP_ROUNDS && Date.now() < deadline; chain++) {
|
|
1155
|
+
// Abandon the loop if the user switched sessions mid-jump: toggling the
|
|
1156
|
+
// old route hash would navigate back to the previous session.
|
|
1157
|
+
if (token !== jumpToken || sessionID() !== sessionAtJump) return
|
|
1158
|
+
setJumpChain(chain + 1)
|
|
1159
|
+
|
|
1160
|
+
if (lastNavID === turn.id) {
|
|
1161
|
+
// The hash already points at the target and the host ignores a
|
|
1162
|
+
// repeated hash value, so bounce through a mounted turn to force a
|
|
1163
|
+
// fresh seek. This is the fallback path, not the normal one.
|
|
1164
|
+
const other = oldestLoadedTurn() ?? turns()[0]
|
|
1165
|
+
if (other) {
|
|
1166
|
+
navTo(path, other.id)
|
|
1167
|
+
// Let the host commit the intermediate hash before seeking back;
|
|
1168
|
+
// too fast and both clicks collapse into the same target hash.
|
|
1169
|
+
await sleep(JUMP_TOGGLE_DWELL_MS)
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
if (token !== jumpToken || sessionID() !== sessionAtJump) return
|
|
1173
|
+
navTo(path, turn.id)
|
|
1174
|
+
|
|
1175
|
+
let chainPages = 0
|
|
1176
|
+
const remaining = Math.min(JUMP_CHAIN_MS, deadline - Date.now())
|
|
1177
|
+
const landed = await waitForRow(
|
|
1178
|
+
turn.id,
|
|
1179
|
+
token,
|
|
1180
|
+
remaining,
|
|
1181
|
+
(pages) => {
|
|
1182
|
+
chainPages = pages
|
|
1183
|
+
// Progress is shown in the panel header, not in a toast.
|
|
1184
|
+
setJumpPages(chainBase + pages)
|
|
1185
|
+
},
|
|
1186
|
+
// Cold start: the host dropped our seek (its machine activates only
|
|
1187
|
+
// after the timeline is ready). Bounce the hash through a mounted
|
|
1188
|
+
// turn to re-engage it now instead of waiting out the idle budget.
|
|
1189
|
+
() => {
|
|
1190
|
+
const other = oldestLoadedTurn() ?? turns()[0]
|
|
1191
|
+
if (other && other.id !== turn.id) {
|
|
1192
|
+
navTo(path, other.id)
|
|
1193
|
+
}
|
|
1194
|
+
},
|
|
1195
|
+
)
|
|
1196
|
+
chainBase += chainPages
|
|
1197
|
+
if (token !== jumpToken || sessionID() !== sessionAtJump) return
|
|
1198
|
+
if (landed) {
|
|
1199
|
+
alignAfterLand(landed)
|
|
1200
|
+
pulse(landed)
|
|
1201
|
+
return
|
|
1202
|
+
}
|
|
1203
|
+
// The host went idle without landing: it exhausted this chain. The
|
|
1204
|
+
// next iteration bounces the hash to start a new one.
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
if (token === jumpToken) flash(`未能定位 · ${diagnostics(ctx)}`)
|
|
1208
|
+
} finally {
|
|
1209
|
+
clearInterval(elapsedTimer)
|
|
1210
|
+
// A newer jump owns the signals when the token moved on.
|
|
1211
|
+
if (token === jumpToken) {
|
|
1212
|
+
setJumping(false)
|
|
1213
|
+
setJumpPages(0)
|
|
1214
|
+
setJumpElapsed(0)
|
|
1215
|
+
setJumpChain(0)
|
|
1216
|
+
setBusyID("")
|
|
1217
|
+
setWaitWarm(false)
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// ---- prewarm ------------------------------------------------------------
|
|
1223
|
+
// The host loads history one page at a time with a hard 2s cooldown between
|
|
1224
|
+
// pages (its own paging machine: AS=2e3), which is why a deep jump waits
|
|
1225
|
+
// several seconds. The plugin can pay that cost in advance: ask the host to
|
|
1226
|
+
// seek to the OLDEST turn - its seek machine then pages all the way back -
|
|
1227
|
+
// and restore the viewport afterwards. Once the store holds the whole
|
|
1228
|
+
// history every jump is instant.
|
|
1229
|
+
const [warming, setWarming] = createSignal(false)
|
|
1230
|
+
let warmToken = 0
|
|
1231
|
+
let warmedFor = ""
|
|
1232
|
+
// Set when a prewarm gave up without reaching the oldest turn (the host never
|
|
1233
|
+
// engaged). The auto-resume effect must not retry it in a loop; an explicit
|
|
1234
|
+
// hover or a fresh full load still may.
|
|
1235
|
+
let warmBlockedFor = ""
|
|
1236
|
+
|
|
1237
|
+
// The topmost visible row plus its offset, so the viewport can be restored.
|
|
1238
|
+
const viewAnchor = () => {
|
|
1239
|
+
const sc = scroller()
|
|
1240
|
+
if (!sc) return undefined
|
|
1241
|
+
const scRect = sc.getBoundingClientRect()
|
|
1242
|
+
for (const row of sc.querySelectorAll("[data-message-id]")) {
|
|
1243
|
+
const rect = row.getBoundingClientRect()
|
|
1244
|
+
if (rect.bottom <= scRect.top + 1) continue
|
|
1245
|
+
const mid = row.getAttribute("data-message-id")
|
|
1246
|
+
if (!mid) continue
|
|
1247
|
+
return { el: row, id: mid, offset: rect.top - scRect.top }
|
|
1248
|
+
}
|
|
1249
|
+
return undefined
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
const restoreView = (anchor) => {
|
|
1253
|
+
if (!anchor) return
|
|
1254
|
+
const sc = scroller()
|
|
1255
|
+
if (!sc) return
|
|
1256
|
+
// Prefer the element itself: an assistant row can carry the same message
|
|
1257
|
+
// id, so looking it up by id might land on a different element.
|
|
1258
|
+
const row = anchor.el?.isConnected ? anchor.el : findRow(anchor.id)
|
|
1259
|
+
if (!row) return
|
|
1260
|
+
const scRect = sc.getBoundingClientRect()
|
|
1261
|
+
const delta = row.getBoundingClientRect().top - scRect.top - anchor.offset
|
|
1262
|
+
if (Math.abs(delta) > 2) sc.scrollTop += delta
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
const cancelWarm = () => {
|
|
1266
|
+
warmToken++
|
|
1267
|
+
setWarming(false)
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// Prewarm once per session, right after the turn list loads (or on hover /
|
|
1271
|
+
// /turns). Every session gets it: the warm cost for a short session is one
|
|
1272
|
+
// cheap host page, and it guarantees the first click lands instantly.
|
|
1273
|
+
const maybePrewarm = () => {
|
|
1274
|
+
const sid = sessionID()
|
|
1275
|
+
if (!sid || warming()) return
|
|
1276
|
+
if (warmedFor === sid) return
|
|
1277
|
+
if (turns().length < 1) return
|
|
1278
|
+
void prewarm()
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
const prewarm = async () => {
|
|
1282
|
+
if (warming()) return
|
|
1283
|
+
const path = routePath(ctx)
|
|
1284
|
+
const oldest = turns()[0]
|
|
1285
|
+
const sid = sessionID()
|
|
1286
|
+
if (!path || !oldest || !sid) return
|
|
1287
|
+
// Already warm: the oldest turn is mounted, so the store has the history.
|
|
1288
|
+
if (findRow(oldest.id)) {
|
|
1289
|
+
warmedFor = sid
|
|
1290
|
+
return
|
|
1291
|
+
}
|
|
1292
|
+
if (warmedFor === sid) return
|
|
1293
|
+
const token = ++warmToken
|
|
1294
|
+
const anchor = viewAnchor()
|
|
1295
|
+
const sc = scroller()
|
|
1296
|
+
const wasAtBottom = sc ? sc.scrollTop + sc.clientHeight >= sc.scrollHeight - 32 : true
|
|
1297
|
+
setWarming(true)
|
|
1298
|
+
try {
|
|
1299
|
+
navTo(path, oldest.id)
|
|
1300
|
+
// The host's seek machine starts inactive and DROPS a seek issued before
|
|
1301
|
+
// the timeline is ready - exactly the situation on session open. So don't
|
|
1302
|
+
// wait blindly for the oldest row: watch for real paging progress, and if
|
|
1303
|
+
// the host goes silent, bounce the hash to re-engage it once it is ready.
|
|
1304
|
+
// If it never engages, bail fast (without marking warm) so clicks fall
|
|
1305
|
+
// back to a direct jump instead of all showing "预热中 · 稍等一下" for 2min.
|
|
1306
|
+
const deadline = Date.now() + 30000
|
|
1307
|
+
let lastSig = rowSignature()
|
|
1308
|
+
let lastChange = Date.now()
|
|
1309
|
+
let kicks = 0
|
|
1310
|
+
const keep = setInterval(() => restoreView(anchor), 150)
|
|
1311
|
+
try {
|
|
1312
|
+
while (Date.now() < deadline && token === warmToken) {
|
|
1313
|
+
if (findRow(oldest.id)) break
|
|
1314
|
+
await sleep(250)
|
|
1315
|
+
if (token !== warmToken) break
|
|
1316
|
+
const now = Date.now()
|
|
1317
|
+
const sig = rowSignature()
|
|
1318
|
+
if (sig !== lastSig) {
|
|
1319
|
+
// Real progress: the host is paging. Keep waiting, reset the kick budget.
|
|
1320
|
+
lastSig = sig
|
|
1321
|
+
lastChange = now
|
|
1322
|
+
kicks = 0
|
|
1323
|
+
} else if (now - lastChange > 3000) {
|
|
1324
|
+
if (kicks >= 2) break // host never engaged: give up, stay un-warm
|
|
1325
|
+
// Re-engage: a repeated identical hash is ignored by the router, so
|
|
1326
|
+
// bounce through a mounted turn first, then back to the oldest.
|
|
1327
|
+
const other = oldestLoadedTurn()
|
|
1328
|
+
if (other && other.id !== oldest.id) {
|
|
1329
|
+
navTo(path, other.id)
|
|
1330
|
+
await sleep(JUMP_TOGGLE_DWELL_MS)
|
|
1331
|
+
if (token !== warmToken) break
|
|
1332
|
+
}
|
|
1333
|
+
navTo(path, oldest.id)
|
|
1334
|
+
lastChange = Date.now()
|
|
1335
|
+
kicks++
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
} finally {
|
|
1339
|
+
clearInterval(keep)
|
|
1340
|
+
}
|
|
1341
|
+
if (token !== warmToken) return
|
|
1342
|
+
const done = !!findRow(oldest.id)
|
|
1343
|
+
// The host scrolled to the oldest message; put the viewport back.
|
|
1344
|
+
restoreView(anchor)
|
|
1345
|
+
if (wasAtBottom) {
|
|
1346
|
+
const live = scroller()
|
|
1347
|
+
if (live) live.scrollTop = live.scrollHeight
|
|
1348
|
+
}
|
|
1349
|
+
if (done) {
|
|
1350
|
+
warmedFor = sid
|
|
1351
|
+
} else {
|
|
1352
|
+
// Never claim the history is ready when the oldest turn never mounted.
|
|
1353
|
+
warmBlockedFor = sid
|
|
1354
|
+
}
|
|
1355
|
+
// Silent on success: prewarming is an implementation detail.
|
|
1356
|
+
} finally {
|
|
1357
|
+
if (token === warmToken) setWarming(false)
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// Session switch: abort any jump/prewarm in flight - they target the old
|
|
1362
|
+
// route, and toggling its hash would navigate back to the previous session.
|
|
1363
|
+
let guardSid = untrack(sessionID)
|
|
1364
|
+
createEffect(() => {
|
|
1365
|
+
const sid = sessionID()
|
|
1366
|
+
if (sid === guardSid) return
|
|
1367
|
+
guardSid = sid
|
|
1368
|
+
cancelWarm()
|
|
1369
|
+
jumpToken++
|
|
1370
|
+
setJumping(false)
|
|
1371
|
+
setJumpPages(0)
|
|
1372
|
+
setJumpElapsed(0)
|
|
1373
|
+
setJumpChain(0)
|
|
1374
|
+
setBusyID("")
|
|
1375
|
+
setWaitWarm(false)
|
|
1376
|
+
})
|
|
1377
|
+
|
|
1378
|
+
// Resume the prewarm after a jump interrupted it, so the remaining older
|
|
1379
|
+
// history still gets warmed for the next click. The initial start happens in
|
|
1380
|
+
// load()'s finally (once the full turn list is in); this only fires when the
|
|
1381
|
+
// warming signal flips back to false while the session is not fully warm.
|
|
1382
|
+
// A prewarm that gave up (warmBlockedFor) is NOT retried here, otherwise a
|
|
1383
|
+
// host that never engages would loop forever and keep every click on "稍等".
|
|
1384
|
+
createEffect(() => {
|
|
1385
|
+
const sid = sessionID()
|
|
1386
|
+
const count = turns().length
|
|
1387
|
+
const hot = warming()
|
|
1388
|
+
if (!sid || count < 1 || hot || warmedFor === sid || warmBlockedFor === sid) return
|
|
1389
|
+
if (loadingActive || loadedFor !== sid) return
|
|
1390
|
+
const timer = setTimeout(() => maybePrewarm(), 400)
|
|
1391
|
+
onCleanup(() => clearTimeout(timer))
|
|
1392
|
+
})
|
|
1393
|
+
|
|
1394
|
+
// ---- search / keyboard navigation --------------------------------------
|
|
1395
|
+
// Filtered view of the turns; identity-stable so <For> keeps DOM nodes.
|
|
1396
|
+
const visibleTurns = createMemo(() => {
|
|
1397
|
+
const list = turns()
|
|
1398
|
+
const needle = query().trim().toLowerCase()
|
|
1399
|
+
if (!needle) return list
|
|
1400
|
+
return list.filter((turn) => turn.text.toLowerCase().includes(needle))
|
|
1401
|
+
})
|
|
1402
|
+
|
|
1403
|
+
// The row the ↑/↓ keys currently point at (index within visibleTurns).
|
|
1404
|
+
const [cursorIndex, setCursorIndex] = createSignal(-1)
|
|
1405
|
+
const clearCursor = () => setCursorIndex(-1)
|
|
1406
|
+
|
|
1407
|
+
const moveCursor = (delta) => {
|
|
1408
|
+
const list = visibleTurns()
|
|
1409
|
+
if (list.length === 0) return
|
|
1410
|
+
let index = cursorIndex()
|
|
1411
|
+
if (index < 0) {
|
|
1412
|
+
// Start from the active turn when the cursor is fresh, so ↓ walks to the
|
|
1413
|
+
// next turn relative to where the viewport already is.
|
|
1414
|
+
const activeID = active()
|
|
1415
|
+
index = list.findIndex((turn) => turn.id === activeID)
|
|
1416
|
+
if (index < 0) index = delta > 0 ? -1 : 0
|
|
1417
|
+
}
|
|
1418
|
+
index = Math.min(list.length - 1, Math.max(0, index + delta))
|
|
1419
|
+
setCursorIndex(index)
|
|
1420
|
+
requestAnimationFrame(() => {
|
|
1421
|
+
document
|
|
1422
|
+
.querySelector(`.sr-list .sr-turn[data-index="${index}"]`)
|
|
1423
|
+
?.scrollIntoView({ block: "nearest" })
|
|
1424
|
+
})
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
const activateCursor = () => {
|
|
1428
|
+
const turn = visibleTurns()[cursorIndex()]
|
|
1429
|
+
if (!turn) return
|
|
1430
|
+
void jump(turn)
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// The search input, for focus round-trips.
|
|
1434
|
+
let searchInput: HTMLInputElement | undefined
|
|
1435
|
+
const listKeyDown = (event) => {
|
|
1436
|
+
if (event.key === "ArrowDown") {
|
|
1437
|
+
event.preventDefault()
|
|
1438
|
+
event.stopPropagation()
|
|
1439
|
+
moveCursor(1)
|
|
1440
|
+
} else if (event.key === "ArrowUp") {
|
|
1441
|
+
event.preventDefault()
|
|
1442
|
+
event.stopPropagation()
|
|
1443
|
+
moveCursor(-1)
|
|
1444
|
+
} else if (event.key === "Enter" && cursorIndex() >= 0) {
|
|
1445
|
+
event.preventDefault()
|
|
1446
|
+
event.stopPropagation()
|
|
1447
|
+
activateCursor()
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// Keep the highlighted row visible while a jump pages through history.
|
|
1452
|
+
createEffect(() => {
|
|
1453
|
+
if (!jumping()) return
|
|
1454
|
+
if (!busyID()) return
|
|
1455
|
+
requestAnimationFrame(() => {
|
|
1456
|
+
try {
|
|
1457
|
+
document
|
|
1458
|
+
.querySelector('.sr-list .sr-turn[data-busy="true"]')
|
|
1459
|
+
?.scrollIntoView({ block: "nearest" })
|
|
1460
|
+
} catch {}
|
|
1461
|
+
})
|
|
1462
|
+
})
|
|
1463
|
+
|
|
1464
|
+
const rail = (
|
|
1465
|
+
<>
|
|
1466
|
+
<Show when={status()}>
|
|
1467
|
+
<div class="sr-status">{status()}</div>
|
|
1468
|
+
</Show>
|
|
1469
|
+
<div
|
|
1470
|
+
class="sr-rail"
|
|
1471
|
+
data-open={open() ? "true" : "false"}
|
|
1472
|
+
data-jumping={jumping() ? "true" : "false"}
|
|
1473
|
+
data-pinned={pinned() ? "true" : "false"}
|
|
1474
|
+
role={open() ? "dialog" : undefined}
|
|
1475
|
+
aria-label={open() ? "会话轮次" : undefined}
|
|
1476
|
+
onMouseEnter={() => {
|
|
1477
|
+
setOpen(true)
|
|
1478
|
+
maybePrewarm()
|
|
1479
|
+
}}
|
|
1480
|
+
// Keep the panel pinned while a long history jump runs or when the
|
|
1481
|
+
// user pinned it; otherwise the mouse drifting off the rail hides
|
|
1482
|
+
// exactly the progress the user is waiting for. Esc / × still close
|
|
1483
|
+
// (or cancel) it.
|
|
1484
|
+
onMouseLeave={() => {
|
|
1485
|
+
if (!jumping() && !pinned()) setOpen(false)
|
|
1486
|
+
}}
|
|
1487
|
+
>
|
|
1488
|
+
<div class="sr-head">
|
|
1489
|
+
<Show
|
|
1490
|
+
when={jumping()}
|
|
1491
|
+
fallback={
|
|
1492
|
+
<span class="sr-title">
|
|
1493
|
+
会话轮次
|
|
1494
|
+
{turns().length > 0 ? ` · ${turns().length}` : ""}
|
|
1495
|
+
<Show when={query().trim()}> · 匹配 {visibleTurns().length}</Show>
|
|
1496
|
+
</span>
|
|
1497
|
+
}
|
|
1498
|
+
>
|
|
1499
|
+
<span class="sr-title sr-progress">
|
|
1500
|
+
{waitWarm() ? "预热中 · 稍等一下" : "加载历史中"}
|
|
1501
|
+
{jumpPages() > 0 ? ` · ${jumpPages()} 页` : ""}
|
|
1502
|
+
{jumpElapsed() > 0 ? ` · ${jumpElapsed()}s` : ""}
|
|
1503
|
+
</span>
|
|
1504
|
+
</Show>
|
|
1505
|
+
<span class="sr-head-actions">
|
|
1506
|
+
<button
|
|
1507
|
+
type="button"
|
|
1508
|
+
class="sr-iconbtn"
|
|
1509
|
+
data-on={pinned() ? "true" : "false"}
|
|
1510
|
+
title={pinned() ? "取消固定 (⌘⌥P)" : "固定面板 (⌘⌥P)"}
|
|
1511
|
+
aria-label={pinned() ? "取消固定面板" : "固定面板"}
|
|
1512
|
+
aria-pressed={pinned() ? "true" : "false"}
|
|
1513
|
+
onClick={() => setPinned(!pinned())}
|
|
1514
|
+
>
|
|
1515
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
1516
|
+
<Show
|
|
1517
|
+
when={pinned()}
|
|
1518
|
+
fallback={
|
|
1519
|
+
<>
|
|
1520
|
+
<path d="M9.5 2.5l4 4-2.6.9-1.4 3.5-4.4-4.4L8.6 5.1z" />
|
|
1521
|
+
<path d="M5.1 6.5L2.5 9.1l4.4 4.4 2.6-2.6" />
|
|
1522
|
+
</>
|
|
1523
|
+
}
|
|
1524
|
+
>
|
|
1525
|
+
<path d="M6 3l7 7-3.5.5L7 13 3 9l2.5-2.5z" />
|
|
1526
|
+
<path d="M4 12l-1.5 1.5" />
|
|
1527
|
+
</Show>
|
|
1528
|
+
</svg>
|
|
1529
|
+
</button>
|
|
1530
|
+
<button
|
|
1531
|
+
type="button"
|
|
1532
|
+
class="sr-close"
|
|
1533
|
+
title={jumping() ? "取消跳转 (Esc)" : "关闭 (Esc)"}
|
|
1534
|
+
onClick={() => {
|
|
1535
|
+
if (jumping()) cancelJump()
|
|
1536
|
+
else setOpen(false)
|
|
1537
|
+
}}
|
|
1538
|
+
>
|
|
1539
|
+
×
|
|
1540
|
+
</button>
|
|
1541
|
+
</span>
|
|
1542
|
+
</div>
|
|
1543
|
+
<Show when={turns().length >= 8}>
|
|
1544
|
+
<div class="sr-search">
|
|
1545
|
+
<input
|
|
1546
|
+
ref={searchInput}
|
|
1547
|
+
type="text"
|
|
1548
|
+
class="sr-search-input"
|
|
1549
|
+
placeholder="搜索轮次…"
|
|
1550
|
+
value={query()}
|
|
1551
|
+
onInput={(event) => {
|
|
1552
|
+
setQuery(event.currentTarget.value)
|
|
1553
|
+
clearCursor()
|
|
1554
|
+
}}
|
|
1555
|
+
onKeyDown={listKeyDown}
|
|
1556
|
+
/>
|
|
1557
|
+
<Show when={query()}>
|
|
1558
|
+
<button
|
|
1559
|
+
type="button"
|
|
1560
|
+
class="sr-search-clear"
|
|
1561
|
+
aria-label="清除搜索"
|
|
1562
|
+
onClick={() => {
|
|
1563
|
+
setQuery("")
|
|
1564
|
+
clearCursor()
|
|
1565
|
+
searchInput?.focus()
|
|
1566
|
+
}}
|
|
1567
|
+
>
|
|
1568
|
+
×
|
|
1569
|
+
</button>
|
|
1570
|
+
</Show>
|
|
1571
|
+
</div>
|
|
1572
|
+
</Show>
|
|
1573
|
+
<div
|
|
1574
|
+
class="sr-list"
|
|
1575
|
+
onKeyDown={listKeyDown}
|
|
1576
|
+
tabIndex={-1}
|
|
1577
|
+
>
|
|
1578
|
+
<Show when={visibleTurns().length > 0} fallback={<div class="sr-note">{note() || `… ${VERSION}`}</div>}>
|
|
1579
|
+
<For each={visibleTurns()}>
|
|
1580
|
+
{(turn, index) => (
|
|
1581
|
+
<button
|
|
1582
|
+
type="button"
|
|
1583
|
+
class="sr-turn"
|
|
1584
|
+
data-active={active() === turn.id ? "true" : "false"}
|
|
1585
|
+
data-cursor={cursorIndex() === index() ? "true" : "false"}
|
|
1586
|
+
data-busy={jumping() && busyID() === turn.id ? "true" : "false"}
|
|
1587
|
+
data-index={index()}
|
|
1588
|
+
onClick={() => void jump(turn)}
|
|
1589
|
+
>
|
|
1590
|
+
<span class="sr-text">
|
|
1591
|
+
<span class="sr-num">#{turns().indexOf(turn) + 1}</span>
|
|
1592
|
+
<span class="sr-time">{formatTime(turn.created)}</span>
|
|
1593
|
+
{turn.text}
|
|
1594
|
+
</span>
|
|
1595
|
+
<Show when={jumping() && busyID() === turn.id}>
|
|
1596
|
+
<span class="sr-badge">{waitWarm() ? "稍等" : "加载中"}</span>
|
|
1597
|
+
</Show>
|
|
1598
|
+
<span class="sr-dash" />
|
|
1599
|
+
</button>
|
|
1600
|
+
)}
|
|
1601
|
+
</For>
|
|
1602
|
+
</Show>
|
|
1603
|
+
</div>
|
|
1604
|
+
{/* Messages that need attention (errors, cancel confirmations) live in
|
|
1605
|
+
the panel footer while it is open, and next to the collapsed rail
|
|
1606
|
+
when it is closed - the old bottom-right toast was easy to miss. */}
|
|
1607
|
+
<Show when={open() && status()}>
|
|
1608
|
+
<div class="sr-foot">{status()}</div>
|
|
1609
|
+
</Show>
|
|
1610
|
+
</div>
|
|
1611
|
+
<Show when={!open() && status()}>
|
|
1612
|
+
<div class="sr-status">{status()}</div>
|
|
1613
|
+
</Show>
|
|
1614
|
+
</>
|
|
1615
|
+
)
|
|
1616
|
+
|
|
1617
|
+
// Portal to <body>: window.bottom is a bottom strip with its own stacking
|
|
1618
|
+
// context, which can clip or re-anchor position:fixed children.
|
|
1619
|
+
return <Portal mount={document.body}>{rail}</Portal>
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
// ---------------------------------------------------------------------------
|
|
1623
|
+
// plugin
|
|
1624
|
+
// ---------------------------------------------------------------------------
|
|
1625
|
+
|
|
1626
|
+
export default Plugin.define({
|
|
1627
|
+
i18n: {
|
|
1628
|
+
en: {
|
|
1629
|
+
"command.jump": "Session turns: show / jump",
|
|
1630
|
+
"command.jump.description": "Reveal the turn rail; click a turn to jump to that message",
|
|
1631
|
+
"command.pin": "Session turns: pin panel",
|
|
1632
|
+
"command.pin.description": "Toggle pinning the turn rail open (⌘⌥P)",
|
|
1633
|
+
},
|
|
1634
|
+
},
|
|
1635
|
+
setup(ctx) {
|
|
1636
|
+
ctx.add(Style, CSS)
|
|
1637
|
+
|
|
1638
|
+
// NOTE: never clear an existing #message-<id> hash here. The desktop app
|
|
1639
|
+
// uses it as a deep link and its cursor machine owns that state.
|
|
1640
|
+
|
|
1641
|
+
ctx.add(Slot, {
|
|
1642
|
+
at: "window.bottom",
|
|
1643
|
+
render: () => (
|
|
1644
|
+
<Show when={ctx.sessions.current()}>
|
|
1645
|
+
<Rail session={() => ctx.sessions.current()} />
|
|
1646
|
+
</Show>
|
|
1647
|
+
),
|
|
1648
|
+
})
|
|
1649
|
+
|
|
1650
|
+
ctx.add(Command, () => ({
|
|
1651
|
+
id: "jump",
|
|
1652
|
+
title: ctx.t("command.jump"),
|
|
1653
|
+
description: ctx.t("command.jump.description"),
|
|
1654
|
+
section: "navigation",
|
|
1655
|
+
bind: "mod+alt+r",
|
|
1656
|
+
slash: { name: "turns" },
|
|
1657
|
+
run: () => {
|
|
1658
|
+
window.dispatchEvent(new Event(OPEN_EVENT))
|
|
1659
|
+
},
|
|
1660
|
+
}))
|
|
1661
|
+
|
|
1662
|
+
ctx.add(Command, () => ({
|
|
1663
|
+
id: "pin",
|
|
1664
|
+
title: ctx.t("command.pin"),
|
|
1665
|
+
description: ctx.t("command.pin.description"),
|
|
1666
|
+
section: "navigation",
|
|
1667
|
+
bind: "mod+alt+p",
|
|
1668
|
+
slash: { name: "turns-pin" },
|
|
1669
|
+
run: () => {
|
|
1670
|
+
window.dispatchEvent(new Event(PIN_EVENT))
|
|
1671
|
+
},
|
|
1672
|
+
}))
|
|
1673
|
+
},
|
|
1674
|
+
})
|