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