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/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
+ })