dsh-find-in-session 0.1.4 → 0.1.5

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/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.5 — 2026-11-15
4
+
5
+ - Fix: the “located the event, but its text is not rendered yet” message could
6
+ stay on screen forever and ended up covering the composer. It was a
7
+ `position:fixed` overlay appended to `document.body`, and `showToast()` created
8
+ a fresh `<div>` per message while keeping one shared `setTimeout` id — a second
9
+ message inside the 2.2 s window cancelled the *first* element's dismissal, and
10
+ nothing else (closing the find bar, unloading the plugin) ever removed it. No
11
+ bookkeeping and no cap meant every failed jump could leave one more overlay
12
+ behind, all at `bottom:96px`, i.e. right on the message input box.
13
+ - The message is now a single status line inside the find panel
14
+ (`role="status"`, `aria-live="polite"`), directly above the result list: one
15
+ element, one timer, cleared by a new query, by a scope change, by `Esc`/close
16
+ and by plugin teardown. It cannot overlap the composer because it is not an
17
+ overlay at all.
18
+ - The hit whose jump failed also keeps a `未渲染` / `not rendered` marker in its
19
+ own row, so the reason survives the notice fading.
20
+ - Jump locating no longer claims “not rendered” for text that is on screen but
21
+ split across inline nodes (`code` / `strong` / links): the snippet is matched
22
+ against the squeezed (whitespace-free, case-folded) text of each event block
23
+ (`[data-chat-node-key]`), longest prefix first, then that block is scrolled to
24
+ and flashed. Blocks are cached and invalidated by the transcript mutation
25
+ observer, so paging does not re-walk the transcript on every poll.
26
+
3
27
  ## 0.1.4 — 2026-11-01
4
28
 
5
29
  - Replaced the single-glyph `当 / 全` scope button with a labelled segmented
package/README.md CHANGED
@@ -66,7 +66,7 @@ curl http://127.0.0.1:<port>/dsh-find-in-session/ping
66
66
  | `Ctrl+F`(macOS `Cmd+F`) | 打开/聚焦查找条(会话头部也有放大镜按钮) |
67
67
  | 输入关键词 | 即时高亮当前视图全部命中,下方异步出现「全部历史命中」列表 |
68
68
  | `Enter` / `Shift+Enter` | 下一处 / 上一处(无视图内命中时在全历史命中间跳) |
69
- | 点击历史命中 | 翻页加载到该事件、滚动定位并闪烁 |
69
+ | 点击历史命中 | 翻页加载到该事件、滚动定位并闪烁;这条命中在视图里确实没有正文时,该行标注「未渲染」,查找条里再出现一行提示(提示在面板内,不会浮到输入框上) |
70
70
  | `范围 [仅当前视图] [含已替换]` | `仅当前视图` = 只搜转写里显示的正文(`surface: current`,默认);`含已替换` = 连被改写、压缩掉的旧事件一起搜。两段各带匹配的 tooltip |
71
71
  | `Esc` | 关闭查找条并清除高亮 |
72
72
 
@@ -82,17 +82,18 @@ curl http://127.0.0.1:<port>/dsh-find-in-session/ping
82
82
 
83
83
  - **不改宿主、不加依赖**:全历史扫描走 `filterEvents()`,它是 provider-independent 的字面量扫描(`text` 子句大小写不敏感、空白弹性),所以**不需要**挂 `dsh-session-query-sqlite` 的 FTS、不需要建索引库,也不与官方 `dsh-experimental-session-search` 抢服务。
84
84
  - **高亮用 CSS Custom Highlight API**:不给文本节点套 `<mark>`,避免与 React 重渲染、流式增量、代码块复制打架;宿主不支持该 API 时退化为「只滚动不染色」。
85
- - **定位靠文本而不是事件键**:DSH 的 Chat 没有公开「按事件跳转」的 API,但 DOM 上有 `[data-chat-flow]` / `[data-chat-node-key]`。插件用命中片段里最长的词在 DOM 里二次匹配,配合 `loadThrough(seq)` 翻页;找不到时再点转写自己的「加载更早」,最多 14 轮。
85
+ - **定位靠文本而不是事件键**:DSH 的 Chat 没有公开「按事件跳转」的 API,但 DOM 上有 `[data-chat-flow]` / `[data-chat-node-key]`。插件先在单个文本节点里匹配命中片段里最长的词,再把片段的**去空白**形式和每个事件块(`[data-chat-node-key]`)比对一次——转写是 markdown 渲染,同一句话会被行内 `code` / `strong` / 链接切成多个文本节点,只按单节点匹配会把屏幕上的文字误判成「未渲染」。块文本按 DOM 变更缓存,配合 `loadThrough(seq)` 翻页;找不到时再点转写自己的「加载更早」,最多 14 轮。
86
+ - **提示不浮在页面上**:定位失败只写面板里的一行状态栏(`role="status"`,6 秒自清,换词/换范围/`Esc`/卸载都会清掉),并在该条命中行留一个「未渲染」小标。0.1.4 及以前是往 `document.body` 上挂 `position:fixed` 的浮层——每个提示新建一个节点、共用一个定时器,2.2 秒内的第二条会把第一条的消失定时器清掉,于是第一条永久停在 `bottom:96px`,正好压住消息输入框。
86
87
  - **只在本机可用**:宿主路由与其它 DSH 本机路由一致,做回环 socket + 回环 Host 头 + 同源三道校验,隧道/局域网客户端一律 403。
87
88
  - **条栏里不放多字文本**:查找条只承载输入框、计数、`↑`/`↓`/`✕` 这类定尺寸控件;「范围」放在条下方独立一行,靠 flex-wrap 换行而不是挤压。
88
89
 
89
90
  ## 验证记录
90
91
 
91
- 在本机 `0.2.1-rc.5` 上实测(2026-11-01):
92
+ 在本机 `0.2.1-rc.5` 上实测(2026-11-01;2026-11-15 的提示浮层修复重测了 `version=0.1.5` 与单元测试数):
92
93
 
93
94
  | 检查 | 结果 |
94
95
  |---|---|
95
- | 桌面端真机 | `activated=true`、`version=0.1.4`、`highlight=true`、`opens=2 via=button` |
96
+ | 桌面端真机 | `activated=true`、`version=0.1.5`、`highlight=true`、`opens=2 via=button` |
96
97
  | 宿主路由存活 | `GET /dsh-find-in-session/ping` → `{"ok":true,"ready":true}` |
97
98
  | 真实会话检索 | 本会话搜 `findInPage` → `total=8`,命中带 `seq / type / surface / snippet`(`surface` 能区分 `current` 与 `log-only`) |
98
99
  | 浏览器半被收录 | 另起 `dsh web`(临时挂载后已卸载):`client.registered=true`、`client.bootListed=true` |
@@ -102,7 +103,7 @@ curl http://127.0.0.1:<port>/dsh-find-in-session/ping
102
103
  | 高亮与逐条跳转 | 注入 2 处关键词的转写:计数 `1/2`、`CSS.highlights` 里 2 个 Range、上下切换 `1/2 -> 2/2 -> 1/2` 回绕 |
103
104
  | 全历史命中 + 翻页定位 | 打开真实会话(转写 2,282 字符)搜 `chrome`:视图内 `1/9`、宿主回报 19 处;点第 1 条 → 转写涨到 8,927 字符(`loadThrough` 生效)、滚动 `0 -> 5847`;点最后一条 → 该行 `data-active=true` |
104
105
  | 面板布局 | 504px 视口下:面板 442×106,两段按钮 `75×21` / `64×21`,`scrollWidth == clientWidth`,面板无横向溢出 |
105
- | 单元测试 | `node --test --test-isolation=none "test/*.test.js"` → 21 pass |
106
+ | 单元测试 | `node --test --test-isolation=none "test/*.test.js"` → 24 pass |
106
107
 
107
108
  浏览器侧验证可复现:起一个临时 `dsh web`(把本插件临时挂到某个 profile),用无头 Chrome 打开它,再跑:
108
109
 
@@ -130,11 +131,11 @@ pwsh -ExecutionPolicy Bypass -File .\scripts\ui-probe\run-jump-check.ps1 -Pag
130
131
  ## 测试
131
132
 
132
133
  ```powershell
133
- node --test --test-isolation=none "test/*.test.js" # 21 pass(DSH 沙箱内必须带 --test-isolation=none)
134
+ node --test --test-isolation=none "test/*.test.js" # 24 pass(DSH 沙箱内必须带 --test-isolation=none)
134
135
  node --test "test/*.test.js" # 普通环境
135
136
  ```
136
137
 
137
- 零依赖:覆盖字面量编译、片段窗口、上限与 surface 过滤,宿主路由的守卫/校验/错误码/正常返回,以及浏览器半在 stub DOM 下的 factory → exports → apply → 两处 slot 注册 + 激活探针。
138
+ 零依赖:覆盖字面量编译、片段窗口、上限与 surface 过滤,宿主路由的守卫/校验/错误码/正常返回,浏览器半在 stub DOM 下的 factory → exports → apply → 两处 slot 注册 + 激活探针,以及 `test/client-jump.test.js` 里用假 DOM 走完整条「Ctrl+F → 输入 → 全历史命中 → 点击」路径:定位失败只留面板内的一行状态(不再往 `document.body` 挂浮层)、该命中行带「未渲染」标、换词/`Esc`/卸载都会清掉,且跨行内节点的片段能被定位到事件块。
138
139
 
139
140
  ## 文件
140
141
 
package/lib/client.js CHANGED
@@ -37,7 +37,7 @@ window.__ModuleLoader__.load({
37
37
  const NS = 'session-search'
38
38
  const SEARCH_ROUTE = '/dsh-find-in-session/search'
39
39
  const HELLO_ROUTE = '/dsh-find-in-session/client-hello'
40
- const VERSION = '0.1.4'
40
+ const VERSION = '0.1.5'
41
41
  const HIT_HIGHLIGHT = 'dsh-ss-hit'
42
42
  const ACTIVE_HIGHLIGHT = 'dsh-ss-active'
43
43
  const STYLE_ID = 'dsh-find-in-session-style'
@@ -45,6 +45,16 @@ window.__ModuleLoader__.load({
45
45
  const HOST_DEBOUNCE_MS = 320
46
46
  const PAGE_ROUNDS = 14
47
47
  const PAGE_WAIT_MS = 380
48
+ // Feedback for a jump that could not be completed is one reused status line
49
+ // inside the panel: it is disposed with the panel, so it can neither stack
50
+ // up nor float over the composer the way a body-level toast can.
51
+ const NOTICE_MS = 6000
52
+ // A Host snippet is one run of text, but the rendered transcript splits it
53
+ // at every inline code / strong / link, so a single-text-node match misses
54
+ // text that is plainly on screen. These prefix lengths are tried against
55
+ // the event block instead of one node.
56
+ const BLOCK_PROBES = [160, 48, 24]
57
+ const MIN_BLOCK_PROBE = 12
48
58
  // The transcript renders the current model surface; replaced (shadowed) and
49
59
  // log-only events are not displayed, so they are noise for a find bar and
50
60
  // only join the scan when the user asks for them.
@@ -68,6 +78,9 @@ window.__ModuleLoader__.load({
68
78
  activeSeq: null,
69
79
  busy: false,
70
80
  includeHidden: false,
81
+ // seqs whose jump found nothing on screen; the row keeps a marker until
82
+ // the query or the scope changes
83
+ unlocated: new Set(),
71
84
  }
72
85
 
73
86
  const messageOf = (error) => String(error && error.message ? error.message : error)
@@ -236,6 +249,74 @@ window.__ModuleLoader__.load({
236
249
  return rangeOf(node, 0, node.nodeValue.length)
237
250
  }
238
251
 
252
+ // --------------------------------------------------------- block locating
253
+ // A Host snippet is one continuous run of text, but the transcript renders
254
+ // markdown: inline `code`, **strong** and links split the same sentence into
255
+ // several text nodes, and the rendered whitespace never lines up exactly.
256
+ // Comparing the squeezed (whitespace-free, case-folded) text of an event
257
+ // block still finds the event, which is all a jump needs.
258
+ let blockCache = null
259
+
260
+ function markTranscriptDirty() {
261
+ blockCache = null
262
+ }
263
+
264
+ function squeeze(text) {
265
+ return String(text === undefined || text === null ? '' : text)
266
+ .replace(/\s+/gu, '')
267
+ .toLowerCase()
268
+ }
269
+
270
+ /** Event blocks of the rendered transcript, each with its squeezed text. */
271
+ function transcriptBlocks() {
272
+ if (blockCache !== null) return blockCache
273
+ const blocks = []
274
+ try {
275
+ const root = chatRoot()
276
+ if (root !== null) {
277
+ const keyed = typeof root.querySelectorAll === 'function' ? root.querySelectorAll('[data-chat-node-key]') : null
278
+ const elements = keyed !== null && keyed.length > 0 ? Array.from(keyed) : Array.from(root.children || [])
279
+ for (const element of elements) {
280
+ const text = squeeze(element.textContent)
281
+ if (text !== '') blocks.push({ element, text })
282
+ }
283
+ }
284
+ } catch {
285
+ // An unexpected DOM shape may cost the fallback locator, never the jump.
286
+ }
287
+ blockCache = blocks
288
+ return blocks
289
+ }
290
+
291
+ function blockProbes(wanted) {
292
+ const probes = []
293
+ for (const length of BLOCK_PROBES) {
294
+ const probe = wanted.slice(0, Math.min(length, wanted.length))
295
+ if (probe.length < MIN_BLOCK_PROBE || probes.indexOf(probe) !== -1) continue
296
+ probes.push(probe)
297
+ }
298
+ return probes
299
+ }
300
+
301
+ /**
302
+ * The event block that renders a snippet the single-node pass missed.
303
+ * Longest prefix first: a shorter probe is only trusted when no block holds
304
+ * a longer one, so a click lands on the right event, not the first similar.
305
+ */
306
+ function locateEventBlock(needle) {
307
+ const wanted = squeeze(needle)
308
+ if (wanted.length < MIN_BLOCK_PROBE) return null
309
+ const probes = blockProbes(wanted)
310
+ if (probes.length === 0) return null
311
+ const blocks = transcriptBlocks()
312
+ for (const probe of probes) {
313
+ for (const block of blocks) {
314
+ if (block.text.indexOf(probe) !== -1) return block.element
315
+ }
316
+ }
317
+ return null
318
+ }
319
+
239
320
  // --------------------------------------------------------------- find bar
240
321
  function installStyleOnce() {
241
322
  if (document.getElementById(STYLE_ID) !== null) return
@@ -291,6 +372,20 @@ window.__ModuleLoader__.load({
291
372
  .dsh-ss-seg > button[data-active="true"] { background: rgba(77, 107, 254, 0.18); opacity: 1; font-weight: 600; }
292
373
  .dsh-ss-host { border-top: 1px solid var(--dsw-alias-border-l1, rgba(128, 128, 128, 0.22)); max-height: 46vh; overflow: auto; display: none; }
293
374
  .dsh-ss-host[data-show="true"] { display: block; }
375
+ /* Status line: in the panel's own flow, above the result list. It cannot
376
+ overlap the composer, and it cannot outlive the panel. */
377
+ .dsh-ss-notice {
378
+ display: none; margin: 0 12px 8px; padding: 6px 10px; border-radius: 8px;
379
+ font-size: 12px; line-height: 1.5; word-break: break-word;
380
+ background: rgba(255, 138, 0, 0.16);
381
+ border: 1px solid rgba(255, 138, 0, 0.4);
382
+ }
383
+ .dsh-ss-notice[data-show="true"] { display: block; }
384
+ .dsh-ss-row-unlocated {
385
+ flex: 0 0 auto; margin-left: auto; padding: 0 6px; border-radius: 999px;
386
+ background: rgba(255, 138, 0, 0.18); font-size: 10px; letter-spacing: 0.02em;
387
+ }
388
+ .dsh-ss-row[data-unlocated="true"] .dsh-ss-row-text { opacity: 0.72; }
294
389
  .dsh-ss-host-head {
295
390
  padding: 7px 12px; font-size: 11px; letter-spacing: 0.03em; opacity: 0.62;
296
391
  display: flex; align-items: baseline; gap: 8px;
@@ -385,6 +480,9 @@ window.__ModuleLoader__.load({
385
480
  const choose = (includeHidden) => {
386
481
  if (runtime.includeHidden === includeHidden) return
387
482
  runtime.includeHidden = includeHidden
483
+ clearNotice()
484
+ runtime.unlocated.clear()
485
+ markTranscriptDirty()
388
486
  render()
389
487
  if (runtime.query.trim() !== '') scheduleHostSearch(runtime.query)
390
488
  }
@@ -393,14 +491,20 @@ window.__ModuleLoader__.load({
393
491
  seg.append(segCurrent, segAll)
394
492
  scopeRow.append(scopeLabel, seg)
395
493
 
494
+ const notice = document.createElement('div')
495
+ notice.className = 'dsh-ss-notice'
496
+ notice.dataset.show = 'false'
497
+ notice.setAttribute('role', 'status')
498
+ notice.setAttribute('aria-live', 'polite')
499
+
396
500
  const host = document.createElement('div')
397
501
  host.className = 'dsh-ss-host'
398
502
  host.dataset.show = 'false'
399
503
 
400
- panel.append(bar, scopeRow, host)
504
+ panel.append(bar, scopeRow, notice, host)
401
505
  document.body.appendChild(panel)
402
506
 
403
- const ui = { panel, bar, input, count, prev, next, close, host, segCurrent, segAll }
507
+ const ui = { panel, bar, input, count, prev, next, close, notice, host, segCurrent, segAll }
404
508
  runtime.ui = ui
405
509
 
406
510
  input.addEventListener('input', () => {
@@ -485,6 +589,8 @@ window.__ModuleLoader__.load({
485
589
  row.type = 'button'
486
590
  row.className = 'dsh-ss-row'
487
591
  row.dataset.active = runtime.activeSeq === hit.seq ? 'true' : 'false'
592
+ const unlocated = runtime.unlocated.has(hit.seq)
593
+ if (unlocated) row.dataset.unlocated = 'true'
488
594
 
489
595
  const meta = document.createElement('div')
490
596
  meta.className = 'dsh-ss-row-meta'
@@ -495,6 +601,14 @@ window.__ModuleLoader__.load({
495
601
  const time = document.createElement('span')
496
602
  time.textContent = stamp
497
603
  meta.append(kind, time)
604
+ if (unlocated) {
605
+ // The row keeps saying why the jump did nothing, long after the one-line
606
+ // notice above the list has faded.
607
+ const marker = document.createElement('span')
608
+ marker.className = 'dsh-ss-row-unlocated'
609
+ marker.textContent = runtime.t('row.unlocated')
610
+ meta.append(marker)
611
+ }
498
612
 
499
613
  const text = document.createElement('div')
500
614
  text.className = 'dsh-ss-row-text'
@@ -535,6 +649,9 @@ window.__ModuleLoader__.load({
535
649
  runtime.sessionId = next
536
650
  runtime.host = { status: 'idle', hits: [], total: 0, truncated: false, error: null, token: runtime.host.token + 1 }
537
651
  runtime.activeSeq = null
652
+ clearNotice()
653
+ runtime.unlocated.clear()
654
+ markTranscriptDirty()
538
655
  if (runtime.open && runtime.query.trim() !== '') scheduleHostSearch(runtime.query)
539
656
  render()
540
657
  }
@@ -571,6 +688,10 @@ window.__ModuleLoader__.load({
571
688
 
572
689
  function setQuery(value) {
573
690
  runtime.query = typeof value === 'string' ? value : ''
691
+ // A new query invalidates the status line and every "unrendered" marker.
692
+ clearNotice()
693
+ runtime.unlocated.clear()
694
+ markTranscriptDirty()
574
695
  const matches = scanDom(runtime.query)
575
696
  runtime.matches = matches
576
697
  runtime.index = matches.length > 0 ? 0 : -1
@@ -653,23 +774,67 @@ window.__ModuleLoader__.load({
653
774
  render()
654
775
  }
655
776
 
777
+ /**
778
+ * Resolve one Host hit to something on screen: the exact range of its text
779
+ * when the transcript keeps it in one text node, otherwise the event block
780
+ * that renders it. Only a hit with neither is reported as unrendered.
781
+ */
782
+ function locateHit(needle) {
783
+ const range = locateNeedle(needle)
784
+ if (range !== null) return { range }
785
+ const block = locateEventBlock(needle)
786
+ if (block !== null) return { block }
787
+ return null
788
+ }
789
+
790
+ function revealTarget(target) {
791
+ if (target.range !== undefined && target.range !== null) {
792
+ scrollToRange(target.range)
793
+ flashRange(target.range)
794
+ return
795
+ }
796
+ if (target.block !== undefined && target.block !== null) {
797
+ scrollElement(target.block)
798
+ flashElement(target.block)
799
+ }
800
+ }
801
+
802
+ function scrollElement(element) {
803
+ if (element === null || element === undefined) return false
804
+ try {
805
+ element.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' })
806
+ } catch {
807
+ try {
808
+ element.scrollIntoView()
809
+ } catch {
810
+ return false
811
+ }
812
+ }
813
+ return true
814
+ }
815
+
656
816
  async function revealHostHit(hit) {
657
817
  if (runtime.busy) return
658
818
  runtime.busy = true
659
819
  runtime.activeSeq = hit.seq
820
+ clearNotice()
660
821
  render()
661
822
  try {
662
823
  const needle = needleOf(hit.snippet)
663
- if (needle !== '') {
664
- const found = locateNeedle(needle)
665
- if (found !== null) {
666
- scrollToRange(found)
667
- flashRange(found)
668
- return
669
- }
670
- const paged = await pageThrough(hit.seq, needle)
671
- if (!paged) showToast(runtime.t('toast.notRendered'))
824
+ if (needle === '') return
825
+ const target = locateHit(needle)
826
+ if (target !== null) {
827
+ revealTarget(target)
828
+ runtime.unlocated.delete(hit.seq)
829
+ return
672
830
  }
831
+ const paged = await pageThrough(hit.seq, needle)
832
+ if (paged) {
833
+ runtime.unlocated.delete(hit.seq)
834
+ return
835
+ }
836
+ runtime.unlocated.add(hit.seq)
837
+ showNotice(runtime.t('notice.notRendered'))
673
838
  } finally {
674
839
  runtime.busy = false
675
840
  render()
@@ -688,6 +853,7 @@ window.__ModuleLoader__.load({
688
853
 
689
854
  async function pageThrough(seq, needle) {
690
855
  await loadThrough(seq)
856
+ markTranscriptDirty()
691
857
  if (await waitForNeedle(needle, 900)) return true
692
858
  for (let round = 0; round < PAGE_ROUNDS; round += 1) {
693
859
  if (!clickLoadEarlier()) return false
@@ -699,10 +865,9 @@ window.__ModuleLoader__.load({
699
865
  async function waitForNeedle(needle, timeout) {
700
866
  const deadline = Date.now() + timeout
701
867
  for (;;) {
702
- const found = locateNeedle(needle)
703
- if (found !== null) {
704
- scrollToRange(found)
705
- flashRange(found)
868
+ const target = locateHit(needle)
869
+ if (target !== null) {
870
+ revealTarget(target)
706
871
  return true
707
872
  }
708
873
  if (Date.now() >= deadline) return false
@@ -721,11 +886,13 @@ window.__ModuleLoader__.load({
721
886
  if (session && typeof session.loadThrough === 'function') {
722
887
  await session.loadThrough(seq)
723
888
  await sleep(120)
889
+ markTranscriptDirty()
724
890
  return true
725
891
  }
726
892
  if (session && typeof session.loadOlder === 'function') {
727
893
  await session.loadOlder()
728
894
  await sleep(120)
895
+ markTranscriptDirty()
729
896
  return true
730
897
  }
731
898
  } catch {
@@ -750,14 +917,18 @@ window.__ModuleLoader__.load({
750
917
  return false
751
918
  }
752
919
 
753
- function flashRange(range) {
754
- const element = elementOfRange(range)
920
+ function flashElement(element) {
755
921
  if (element === null || element === undefined) return
922
+ if (element.classList === undefined || element.classList === null) return
756
923
  element.classList.add('dsh-ss-flash')
757
924
  setTimeout(() => element.classList.remove('dsh-ss-flash'), 1200)
758
925
  }
759
926
 
760
- let toastTimer = null
927
+ function flashRange(range) {
928
+ flashElement(elementOfRange(range))
929
+ }
930
+
931
+ let noticeTimer = null
761
932
  let observer = null
762
933
  let observerTimer = null
763
934
 
@@ -769,6 +940,9 @@ window.__ModuleLoader__.load({
769
940
  function startObserver() {
770
941
  if (observer !== null || typeof MutationObserver !== 'function') return
771
942
  observer = new MutationObserver((records) => {
943
+ // Any transcript mutation invalidates the cached block text, even when
944
+ // the panel is closed and no rescan of the query is needed.
945
+ markTranscriptDirty()
772
946
  if (!runtime.open || runtime.query.trim() === '') return
773
947
  for (const record of records) {
774
948
  const target = record.target
@@ -798,18 +972,42 @@ window.__ModuleLoader__.load({
798
972
  observer = null
799
973
  }
800
974
  }
801
- function showToast(text) {
802
- const toast = document.createElement('div')
803
- toast.textContent = text
804
- toast.style.cssText =
805
- 'position:fixed;left:50%;bottom:96px;transform:translateX(-50%);z-index:2147483001;padding:8px 14px;border-radius:8px;font-size:12px;background:rgba(24,24,28,.9);color:#fff;box-shadow:0 8px 24px rgba(0,0,0,.28);pointer-events:none;'
806
- document.body.appendChild(toast)
807
- if (toastTimer !== null) clearTimeout(toastTimer)
808
- toastTimer = setTimeout(() => toast.remove(), 2200)
975
+
976
+ /**
977
+ * Status line for the find panel. One element, one timer: a second message
978
+ * replaces the first instead of orphaning it (the old toast kept a single
979
+ * module-level timeout for elements it created per call, so two messages in
980
+ * the same 2.2s window left the first one on screen forever), the line lives
981
+ * inside the panel, and it clears itself, on close, and on teardown.
982
+ */
983
+ function showNotice(text) {
984
+ const ui = runtime.ui
985
+ if (ui === null) return
986
+ ui.notice.textContent = text
987
+ ui.notice.dataset.show = text === '' ? 'false' : 'true'
988
+ if (noticeTimer !== null) clearTimeout(noticeTimer)
989
+ noticeTimer = null
990
+ if (text === '') return
991
+ noticeTimer = setTimeout(() => {
992
+ noticeTimer = null
993
+ clearNotice()
994
+ }, NOTICE_MS)
995
+ }
996
+
997
+ function clearNotice() {
998
+ if (noticeTimer !== null) {
999
+ clearTimeout(noticeTimer)
1000
+ noticeTimer = null
1001
+ }
1002
+ const ui = runtime.ui
1003
+ if (ui === null) return
1004
+ ui.notice.textContent = ''
1005
+ ui.notice.dataset.show = 'false'
809
1006
  }
810
1007
 
811
1008
  function openPanel(via) {
812
1009
  const wasOpen = runtime.open
1010
+ markTranscriptDirty()
813
1011
  const ui = buildUi()
814
1012
  runtime.open = true
815
1013
  if (ui.input.value !== runtime.query) ui.input.value = runtime.query
@@ -827,6 +1025,7 @@ window.__ModuleLoader__.load({
827
1025
 
828
1026
  function closePanel() {
829
1027
  runtime.open = false
1028
+ clearNotice()
830
1029
  if (runtime.hostTimer !== null) {
831
1030
  clearTimeout(runtime.hostTimer)
832
1031
  runtime.hostTimer = null
@@ -978,7 +1177,8 @@ window.__ModuleLoader__.load({
978
1177
  'role.tool': '\u5de5\u5177',
979
1178
  'role.event': '\u4e8b\u4ef6',
980
1179
  'button.title': '\u5728\u4f1a\u8bdd\u4e2d\u67e5\u627e (Ctrl+F)',
981
- 'toast.notRendered': '\u5df2\u5b9a\u4f4d\u5230\u8be5\u4e8b\u4ef6\uff0c\u4f46\u6b63\u6587\u8fd8\u672a\u6e32\u67d3\u5230\u89c6\u56fe\u91cc',
1180
+ 'notice.notRendered': '\u5df2\u5b9a\u4f4d\u5230\u8be5\u4e8b\u4ef6\uff0c\u4f46\u6b63\u6587\u8fd8\u672a\u6e32\u67d3\u5230\u89c6\u56fe\u91cc',
1181
+ 'row.unlocated': '\u672a\u6e32\u67d3',
982
1182
  },
983
1183
  en: {
984
1184
  'panel.title': 'Find in conversation',
@@ -1002,7 +1202,8 @@ window.__ModuleLoader__.load({
1002
1202
  'role.tool': 'Tool',
1003
1203
  'role.event': 'Event',
1004
1204
  'button.title': 'Find in conversation (Ctrl+F)',
1005
- 'toast.notRendered': 'Located the event, but its text is not rendered yet',
1205
+ 'notice.notRendered': 'Located the event, but its text is not rendered yet',
1206
+ 'row.unlocated': 'not rendered',
1006
1207
  },
1007
1208
  }),
1008
1209
  'session-search: locale dictionaries',
@@ -1021,10 +1222,12 @@ window.__ModuleLoader__.load({
1021
1222
  document.removeEventListener('keydown', onKeyDown, true)
1022
1223
  window.removeEventListener('resize', onResize)
1023
1224
  stopObserver()
1225
+ clearNotice()
1024
1226
  if (runtime.ui !== null) {
1025
1227
  runtime.ui.panel.remove()
1026
1228
  runtime.ui = null
1027
1229
  }
1230
+ markTranscriptDirty()
1028
1231
  clearHighlights()
1029
1232
  },
1030
1233
  'session-search: teardown',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-find-in-session",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "DeepSeek Harness plugin: search inside a conversation — Ctrl+F find bar over the rendered transcript plus full-history search through ctx.sessionQuery.filterEvents, with jump-to-message paging of older history.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",