@bojackduy/opencode-telescope 0.1.14 → 0.1.15

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@bojackduy/opencode-telescope",
4
- "version": "0.1.14",
4
+ "version": "0.1.15",
5
5
  "description": "Fuzzy search across all OpenCode conversations — grep session and chat history, find code snippets, and jump to any chat instantly",
6
6
  "type": "module",
7
7
  "license": "MIT",
package/search.ts CHANGED
@@ -11,6 +11,7 @@ export type SearchResult = {
11
11
  sessionTitle: string
12
12
  directory: string
13
13
  role: "user" | "assistant"
14
+ partType: "text" | "reasoning" | "tool" | "patch"
14
15
  timeCreated: number
15
16
  snippet: string
16
17
  matchStart: number
@@ -67,10 +68,15 @@ type Row = {
67
68
  session_title: string | null
68
69
  directory: string
69
70
  role: SearchRole
71
+ part_type?: SearchResult["partType"]
70
72
  time_created: number
71
73
  text: string
72
74
  }
73
75
 
76
+ type SourceRow = Omit<Row, "text"> & {
77
+ data: string
78
+ }
79
+
74
80
  type ConversationRow = {
75
81
  id: string
76
82
  message_id: string
@@ -318,6 +324,7 @@ export function rowToSearchResult(row: Row, query: string): SearchResult | undef
318
324
  sessionTitle: row.session_title || "Untitled session",
319
325
  directory: row.directory,
320
326
  role: row.role,
327
+ partType: row.part_type ?? "text",
321
328
  timeCreated: row.time_created,
322
329
  snippet: makeSnippet(text, query),
323
330
  matchStart: match.start,
@@ -337,7 +344,7 @@ export function rowToSearchResult(row: Row, query: string): SearchResult | undef
337
344
 
338
345
  export function extractSearchText(data: string) {
339
346
  try {
340
- return extractFromValue(JSON.parse(data)).replace(/\s+/g, " ").trim()
347
+ return searchablePartText(JSON.parse(data)).replace(/\s+/g, " ").trim()
341
348
  } catch {
342
349
  return data.replace(/\s+/g, " ").trim()
343
350
  }
@@ -569,7 +576,7 @@ function indexedTextRows(db: Database, dbPath: string, limit: number, query: str
569
576
  debug.time("query:fts:exec")
570
577
  const rows = index.query<Row, (string | number)[]>(`
571
578
  SELECT id, message_id, session_id, session_title, directory, role,
572
- CAST(time_created AS INTEGER) AS time_created, text
579
+ part_type, CAST(time_created AS INTEGER) AS time_created, text
573
580
  FROM document_fts
574
581
  WHERE ${conditions.join(" AND ")}
575
582
  ORDER BY bm25(document_fts), CAST(time_created AS INTEGER) DESC
@@ -586,7 +593,7 @@ function indexedTextRows(db: Database, dbPath: string, limit: number, query: str
586
593
  function visibleTextRows(db: Database, limit: number, query?: string, directory?: string, offset?: number, role?: SearchRole) {
587
594
  const offsetClause = offset ? "OFFSET ?" : ""
588
595
  const conditions: string[] = [
589
- "json_extract(p.data, '$.type') = 'text'",
596
+ "json_extract(p.data, '$.type') IN ('text', 'reasoning', 'tool', 'patch')",
590
597
  role ? "json_extract(m.data, '$.role') = ?" : "json_extract(m.data, '$.role') IN ('user', 'assistant')",
591
598
  ]
592
599
  const params: (string | number)[] = []
@@ -600,7 +607,7 @@ function visibleTextRows(db: Database, limit: number, query?: string, directory?
600
607
 
601
608
  const tokens = query ? query.trim().split(/\s+/).filter(Boolean) : []
602
609
  for (const token of tokens) {
603
- conditions.push("json_extract(p.data, '$.text') LIKE ?")
610
+ conditions.push("p.data LIKE ?")
604
611
  params.push(`%${token}%`)
605
612
  }
606
613
 
@@ -610,8 +617,9 @@ function visibleTextRows(db: Database, limit: number, query?: string, directory?
610
617
  const sql = `
611
618
  SELECT p.id, p.message_id, p.session_id, s.title AS session_title, s.directory,
612
619
  json_extract(m.data, '$.role') AS role,
620
+ json_extract(p.data, '$.type') AS part_type,
613
621
  p.time_created,
614
- json_extract(p.data, '$.text') AS text
622
+ p.data
615
623
  FROM part p
616
624
  JOIN message m ON m.id = p.message_id
617
625
  JOIN session s ON s.id = p.session_id
@@ -620,9 +628,9 @@ function visibleTextRows(db: Database, limit: number, query?: string, directory?
620
628
  LIMIT ? ${offsetClause}
621
629
  `
622
630
  debug.time("query:sql:exec")
623
- const rows = db.query<Row, (string | number)[]>(sql).all(...params as any[])
631
+ const rows = db.query<SourceRow, (string | number)[]>(sql).all(...params as any[])
624
632
  debug.timeEnd("query:sql:exec")
625
- return rows
633
+ return rows.flatMap(sourceRowToSearchRow)
626
634
  }
627
635
 
628
636
  function ensureSearchIndex(source: Database, sourcePath: string) {
@@ -638,12 +646,15 @@ function ensureSearchIndex(source: Database, sourcePath: string) {
638
646
  const currentDataVersion = getMeta(_indexDb, "source_data_version")
639
647
  const currentMtimeMs = getMeta(_indexDb, "source_mtime_ms")
640
648
  const currentPath = getMeta(_indexDb, "source_path")
641
- if (currentPath !== sourcePath || currentDataVersion !== String(state.dataVersion) || currentMtimeMs !== String(state.mtimeMs)) {
649
+ const currentIndexVersion = getMeta(_indexDb, "index_version")
650
+ if (currentPath !== sourcePath || currentDataVersion !== String(state.dataVersion) || currentMtimeMs !== String(state.mtimeMs) || currentIndexVersion !== SEARCH_INDEX_VERSION) {
642
651
  rebuildSearchIndex(source, _indexDb, sourcePath, state)
643
652
  }
644
653
  return _indexDb
645
654
  }
646
655
 
656
+ const SEARCH_INDEX_VERSION = "3"
657
+
647
658
  function searchIndexPath(sourcePath: string) {
648
659
  const parsed = path.parse(sourcePath)
649
660
  return path.join(parsed.dir, `${parsed.name}-search.db`)
@@ -662,11 +673,31 @@ function migrateSearchIndex(db: Database) {
662
673
  session_title,
663
674
  directory UNINDEXED,
664
675
  role UNINDEXED,
676
+ part_type UNINDEXED,
665
677
  time_created UNINDEXED,
666
678
  text,
667
679
  tokenize='unicode61'
668
680
  );
669
681
  `)
682
+
683
+ const hasPartType = db.query<{ name: string }, []>("PRAGMA table_info(document_fts)").all().some((column) => column.name === "part_type")
684
+ if (!hasPartType) {
685
+ db.exec("DROP TABLE document_fts")
686
+ db.exec(`
687
+ CREATE VIRTUAL TABLE document_fts USING fts5(
688
+ id UNINDEXED,
689
+ message_id UNINDEXED,
690
+ session_id UNINDEXED,
691
+ session_title,
692
+ directory UNINDEXED,
693
+ role UNINDEXED,
694
+ part_type UNINDEXED,
695
+ time_created UNINDEXED,
696
+ text,
697
+ tokenize='unicode61'
698
+ );
699
+ `)
700
+ }
670
701
  }
671
702
 
672
703
  function sourceState(db: Database, sourcePath: string) {
@@ -677,27 +708,27 @@ function sourceState(db: Database, sourcePath: string) {
677
708
 
678
709
  function rebuildSearchIndex(source: Database, index: Database, sourcePath: string, state: { dataVersion: number; mtimeMs: number }) {
679
710
  debug.time("fts:rebuild")
680
- const rows = source.query<Row, []>(`
711
+ const rows = source.query<SourceRow, []>(`
681
712
  SELECT p.id, p.message_id, p.session_id, s.title AS session_title, s.directory,
682
- json_extract(m.data, '$.role') AS role,
683
- p.time_created,
684
- json_extract(p.data, '$.text') AS text
713
+ json_extract(m.data, '$.role') AS role,
714
+ json_extract(p.data, '$.type') AS part_type,
715
+ p.time_created,
716
+ p.data
685
717
  FROM part p
686
718
  JOIN message m ON m.id = p.message_id
687
719
  JOIN session s ON s.id = p.session_id
688
- WHERE json_extract(p.data, '$.type') = 'text'
720
+ WHERE json_extract(p.data, '$.type') IN ('text', 'reasoning', 'tool', 'patch')
689
721
  AND json_extract(m.data, '$.role') IN ('user', 'assistant')
690
- AND json_extract(p.data, '$.text') IS NOT NULL
691
722
  ORDER BY p.time_created DESC
692
723
  `).all()
693
- const insert = index.query<Row, [string, string, string, string, string, string, number, string]>(`
694
- INSERT INTO document_fts(id, message_id, session_id, session_title, directory, role, time_created, text)
695
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
724
+ const insert = index.query<Row, [string, string, string, string, string, string, SearchResult["partType"], number, string]>(`
725
+ INSERT INTO document_fts(id, message_id, session_id, session_title, directory, role, part_type, time_created, text)
726
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
696
727
  `)
697
728
  index.exec("BEGIN IMMEDIATE")
698
729
  try {
699
730
  index.exec("DELETE FROM document_fts")
700
- for (const row of rows) {
731
+ for (const row of rows.flatMap(sourceRowToSearchRow)) {
701
732
  insert.run(
702
733
  row.id,
703
734
  row.message_id,
@@ -705,6 +736,7 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
705
736
  row.session_title ?? "Untitled session",
706
737
  row.directory,
707
738
  row.role,
739
+ row.part_type ?? "text",
708
740
  row.time_created,
709
741
  row.text,
710
742
  )
@@ -712,6 +744,7 @@ function rebuildSearchIndex(source: Database, index: Database, sourcePath: strin
712
744
  setMeta(index, "source_path", sourcePath)
713
745
  setMeta(index, "source_data_version", String(state.dataVersion))
714
746
  setMeta(index, "source_mtime_ms", String(state.mtimeMs))
747
+ setMeta(index, "index_version", SEARCH_INDEX_VERSION)
715
748
  index.exec("COMMIT")
716
749
  } catch (err) {
717
750
  index.exec("ROLLBACK")
@@ -783,6 +816,29 @@ function extractFromValue(value: unknown): string {
783
816
  .join("\n")
784
817
  }
785
818
 
819
+ function sourceRowToSearchRow(row: SourceRow): Row[] {
820
+ const text = extractSearchText(row.data)
821
+ if (!text) return []
822
+ return [{ ...row, text }]
823
+ }
824
+
825
+ function searchablePartText(value: unknown): string {
826
+ if (!value || typeof value !== "object" || Array.isArray(value)) return extractFromValue(value)
827
+ const record = value as Record<string, unknown>
828
+ const type = record.type
829
+
830
+ if (type === "text" || type === "reasoning") return typeof record.text === "string" ? record.text : extractFromValue(record)
831
+ if (type === "patch") return extractFromValue(record.files)
832
+
833
+ if (type === "tool") {
834
+ const state = record.state && typeof record.state === "object" && !Array.isArray(record.state) ? record.state as Record<string, unknown> : undefined
835
+ if (record.tool !== "apply_patch") return ""
836
+ return extractFromValue(state?.input)
837
+ }
838
+
839
+ return extractFromValue(record)
840
+ }
841
+
786
842
  function truncate(value: string, length: number) {
787
843
  if (value.length <= length) return value
788
844
  return `${value.slice(0, length - 3)}...`
package/telescope.tsx CHANGED
@@ -46,14 +46,14 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
46
46
  const [loadingPreviewMore, setLoadingPreviewMore] = createSignal(false)
47
47
  const [prefetchingPreviewBefore, setPrefetchingPreviewBefore] = createSignal(false)
48
48
  const [prefetchingPreviewAfter, setPrefetchingPreviewAfter] = createSignal(false)
49
- const MIN_SEARCH_BATCH_SIZE = 25
50
- const MIN_RECENT_BATCH_SIZE = 15
49
+ const [previewEdgeLoadingReady, setPreviewEdgeLoadingReady] = createSignal(false)
50
+ const RESULT_BATCH_SIZE = 50
51
+ const RESULT_PREFETCH_AHEAD_ROWS = 25
51
52
  const RESULT_OVERSCAN_MULTIPLIER = 2
52
- const RESULT_BATCH_VIEWPORTS = 4
53
- const RESULT_PREFETCH_VIEWPORTS = 10
54
53
  const RESULT_CACHE_BEHIND_VIEWPORTS = 6
55
- const INITIAL_PREVIEW_BEFORE = 20
56
- const INITIAL_PREVIEW_AFTER = 30
54
+ const INITIAL_PREVIEW_BEFORE = 6
55
+ const INITIAL_PREVIEW_AFTER = 12
56
+ const INITIAL_PREVIEW_DELAY_MS = 75
57
57
  const PREVIEW_PAGE_SIZE = 20
58
58
  const PREVIEW_PREFETCH_VIEWPORTS = 0.5
59
59
  let input: InputRenderable | undefined
@@ -91,18 +91,19 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
91
91
  let resultPreviousTimer: ReturnType<typeof setTimeout> | undefined
92
92
  let previewBeforeTimer: ReturnType<typeof setTimeout> | undefined
93
93
  let previewAfterTimer: ReturnType<typeof setTimeout> | undefined
94
- let pendingPreviewBefore: { previousContentHeight: number; preserveScroll: boolean; visibleLoad: boolean } | undefined
95
- let pendingPreviewAfterVisible = false
94
+ let pendingPreviewBefore: { itemId: string; previousContentHeight: number; preserveScroll: boolean; visibleLoad: boolean } | undefined
95
+ let pendingPreviewAfter: { itemId: string; visibleLoad: boolean } | undefined
96
96
  const cancelPreviewPrefetch = () => {
97
97
  if (previewBeforeTimer) clearTimeout(previewBeforeTimer)
98
98
  if (previewAfterTimer) clearTimeout(previewAfterTimer)
99
99
  previewBeforeTimer = undefined
100
100
  previewAfterTimer = undefined
101
101
  pendingPreviewBefore = undefined
102
- pendingPreviewAfterVisible = false
102
+ pendingPreviewAfter = undefined
103
103
  setPrefetchingPreviewBefore(false)
104
104
  setPrefetchingPreviewAfter(false)
105
105
  setLoadingPreviewMore(false)
106
+ setPreviewEdgeLoadingReady(false)
106
107
  }
107
108
  onCleanup(() => {
108
109
  if (resultPrefetchTimer) clearTimeout(resultPrefetchTimer)
@@ -115,9 +116,9 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
115
116
  const viewportHeight = resultScroll?.height || Math.max(8, height() - 7)
116
117
  return Math.max(5, Math.floor(viewportHeight / resultRowHeight()))
117
118
  }
118
- const searchBatchSize = () => Math.max(MIN_SEARCH_BATCH_SIZE, visibleResultRows() * RESULT_BATCH_VIEWPORTS)
119
- const recentBatchSize = () => Math.max(MIN_RECENT_BATCH_SIZE, visibleResultRows() * RESULT_BATCH_VIEWPORTS)
120
- const resultPrefetchThreshold = () => visibleResultRows() * RESULT_PREFETCH_VIEWPORTS
119
+ const searchBatchSize = () => RESULT_BATCH_SIZE
120
+ const recentBatchSize = () => RESULT_BATCH_SIZE
121
+ const resultPrefetchThreshold = () => RESULT_PREFETCH_AHEAD_ROWS
121
122
  const resultPrefetchState = () => {
122
123
  const cachedEnd = resultBaseOffset() + results().length
123
124
  const rowsAhead = Math.max(0, cachedEnd - selected() - 1)
@@ -437,14 +438,20 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
437
438
  return lastChild ? lastChild.y + lastChild.height : 0
438
439
  }
439
440
 
440
- const loadPreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
441
+ const resetPreviewScroll = () => {
442
+ if (!previewScroll || previewScroll.y === 0) return
443
+ previewScroll.scrollBy(-previewScroll.y)
444
+ }
445
+
446
+ const loadPreviewBefore = (itemId: string, previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
441
447
  const item = selectedResult()
442
448
  const first = previewParts()[0]
443
- if (!item || !first || loadingPreviewMore() || prefetchingPreviewBefore()) return
449
+ if (!item || item.id !== itemId || !first || loadingPreviewMore() || prefetchingPreviewBefore()) return
444
450
  visibleLoad ? setLoadingPreviewMore(true) : setPrefetchingPreviewBefore(true)
445
451
  debug.time("preview:load-before")
446
452
  try {
447
453
  const page = loadConversationBefore(item, { id: first.id, timeCreated: first.timeCreated }, { limit: PREVIEW_PAGE_SIZE, dbPath: dbPath() })
454
+ if (selectedResult()?.id !== itemId) return
448
455
  debug.log("preview:load-before", {
449
456
  item: item.id,
450
457
  added: page.parts.length,
@@ -458,6 +465,7 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
458
465
  setPreviewParts((prev) => [...page.parts, ...prev])
459
466
  if (preserveScroll) {
460
467
  setTimeout(() => {
468
+ if (selectedResult()?.id !== itemId) return
461
469
  const delta = previewContentHeight() - previousContentHeight
462
470
  if (delta > 0) previewScroll?.scrollBy(delta)
463
471
  }, 1)
@@ -472,14 +480,15 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
472
480
  }
473
481
  }
474
482
 
475
- const loadPreviewAfter = (visibleLoad = false) => {
483
+ const loadPreviewAfter = (itemId: string, visibleLoad = false) => {
476
484
  const item = selectedResult()
477
485
  const last = previewParts().at(-1)
478
- if (!item || !last || loadingPreviewMore() || prefetchingPreviewAfter()) return
486
+ if (!item || item.id !== itemId || !last || loadingPreviewMore() || prefetchingPreviewAfter()) return
479
487
  visibleLoad ? setLoadingPreviewMore(true) : setPrefetchingPreviewAfter(true)
480
488
  debug.time("preview:load-after")
481
489
  try {
482
490
  const page = loadConversationAfter(item, { id: last.id, timeCreated: last.timeCreated }, { limit: PREVIEW_PAGE_SIZE, dbPath: dbPath() })
491
+ if (selectedResult()?.id !== itemId) return
483
492
  debug.log("preview:load-after", {
484
493
  item: item.id,
485
494
  added: page.parts.length,
@@ -499,7 +508,8 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
499
508
  }
500
509
 
501
510
  const schedulePreviewBefore = (previousContentHeight = previewContentHeight(), preserveScroll = true, visibleLoad = false) => {
502
- if (!hasMorePreviewBefore() || loadingPreviewMore() || prefetchingPreviewBefore()) return
511
+ const item = selectedResult()
512
+ if (!item || !hasMorePreviewBefore() || loadingPreviewMore() || prefetchingPreviewBefore()) return
503
513
  if (previewBeforeTimer) {
504
514
  if (pendingPreviewBefore) {
505
515
  pendingPreviewBefore.preserveScroll = pendingPreviewBefore.preserveScroll && preserveScroll
@@ -507,26 +517,27 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
507
517
  }
508
518
  return
509
519
  }
510
- pendingPreviewBefore = { previousContentHeight, preserveScroll, visibleLoad }
511
- debug.log("preview:prefetch-before-scheduled", { preserveScroll, visibleLoad })
520
+ pendingPreviewBefore = { itemId: item.id, previousContentHeight, preserveScroll, visibleLoad }
521
+ debug.log("preview:prefetch-before-scheduled", { item: item.id, preserveScroll, visibleLoad })
512
522
  previewBeforeTimer = setTimeout(() => {
513
523
  const pending = pendingPreviewBefore
514
524
  previewBeforeTimer = undefined
515
525
  pendingPreviewBefore = undefined
516
- if (pending) loadPreviewBefore(pending.previousContentHeight, pending.preserveScroll, pending.visibleLoad)
526
+ if (pending && selectedResult()?.id === pending.itemId) loadPreviewBefore(pending.itemId, pending.previousContentHeight, pending.preserveScroll, pending.visibleLoad)
517
527
  }, 1)
518
528
  }
519
529
 
520
530
  const schedulePreviewAfter = (visibleLoad = false) => {
521
- if (!hasMorePreviewAfter() || loadingPreviewMore() || prefetchingPreviewAfter()) return
522
- pendingPreviewAfterVisible = pendingPreviewAfterVisible || visibleLoad
531
+ const item = selectedResult()
532
+ if (!item || !hasMorePreviewAfter() || loadingPreviewMore() || prefetchingPreviewAfter()) return
533
+ pendingPreviewAfter = { itemId: item.id, visibleLoad: (pendingPreviewAfter?.visibleLoad || visibleLoad) }
523
534
  if (previewAfterTimer) return
524
- debug.log("preview:prefetch-after-scheduled", { visibleLoad })
535
+ debug.log("preview:prefetch-after-scheduled", { item: item.id, visibleLoad })
525
536
  previewAfterTimer = setTimeout(() => {
526
- const pendingVisibleLoad = pendingPreviewAfterVisible
537
+ const pending = pendingPreviewAfter
527
538
  previewAfterTimer = undefined
528
- pendingPreviewAfterVisible = false
529
- loadPreviewAfter(pendingVisibleLoad)
539
+ pendingPreviewAfter = undefined
540
+ if (pending && selectedResult()?.id === pending.itemId) loadPreviewAfter(pending.itemId, pending.visibleLoad)
530
541
  }, 1)
531
542
  }
532
543
 
@@ -543,33 +554,62 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
543
554
  if (item.id === lastPreviewItemId) return
544
555
  lastPreviewItemId = item.id
545
556
  cancelPreviewPrefetch()
557
+ resetPreviewScroll()
558
+ solidBatch(() => {
559
+ setPreviewParts([])
560
+ setHasMorePreviewBefore(false)
561
+ setHasMorePreviewAfter(false)
562
+ setPreviewEdgeLoadingReady(false)
563
+ })
546
564
  debug.log("preview:new-item", item.sessionTitle?.slice(0, 40) ?? item.id.slice(-8))
547
565
  const db = dbPath()
548
- debug.time("preview:load")
549
- try {
550
- const page = loadConversationAround(item, { before: INITIAL_PREVIEW_BEFORE, after: INITIAL_PREVIEW_AFTER, dbPath: db })
551
- debug.log("preview:init", {
552
- item: item.id,
553
- session: item.sessionID,
554
- parts: page.parts.length,
555
- hasMoreBefore: page.hasMoreBefore,
556
- hasMoreAfter: page.hasMoreAfter,
557
- first: page.parts[0]?.id,
558
- last: page.parts.at(-1)?.id,
559
- })
560
- setPreviewParts(page.parts)
561
- setHasMorePreviewBefore(page.hasMoreBefore)
562
- setHasMorePreviewAfter(page.hasMoreAfter)
563
- } catch {}
566
+ const itemId = item.id
567
+ debug.log("preview:schedule", { item: item.id, delayMs: INITIAL_PREVIEW_DELAY_MS, before: INITIAL_PREVIEW_BEFORE, after: INITIAL_PREVIEW_AFTER })
564
568
  debug.timeEnd("nav:total")
565
- debug.timeEnd("preview:load")
569
+ const timer = setTimeout(() => {
570
+ if (selectedResult()?.id !== itemId) {
571
+ debug.log("preview:load:cancelled", { item: itemId })
572
+ return
573
+ }
574
+ debug.time("preview:load")
575
+ try {
576
+ const page = loadConversationAround(item, { before: INITIAL_PREVIEW_BEFORE, after: INITIAL_PREVIEW_AFTER, dbPath: db })
577
+ if (selectedResult()?.id !== itemId) {
578
+ debug.log("preview:load:cancelled", { item: itemId, afterLoad: true })
579
+ return
580
+ }
581
+ debug.log("preview:init", {
582
+ item: item.id,
583
+ session: item.sessionID,
584
+ parts: page.parts.length,
585
+ hasMoreBefore: page.hasMoreBefore,
586
+ hasMoreAfter: page.hasMoreAfter,
587
+ first: page.parts[0]?.id,
588
+ last: page.parts.at(-1)?.id,
589
+ })
590
+ solidBatch(() => {
591
+ setPreviewParts(page.parts)
592
+ setHasMorePreviewBefore(page.hasMoreBefore)
593
+ setHasMorePreviewAfter(page.hasMoreAfter)
594
+ })
595
+ } catch {
596
+ } finally {
597
+ debug.timeEnd("preview:load")
598
+ }
599
+ }, INITIAL_PREVIEW_DELAY_MS)
600
+ onCleanup(() => {
601
+ clearTimeout(timer)
602
+ if (selectedResult()?.id !== itemId) {
603
+ debug.log("preview:load:cancelled", { item: itemId, cleanup: true })
604
+ }
605
+ })
566
606
  })
567
607
 
568
608
  createEffect(() => {
569
609
  const item = selectedResult()
570
610
  if (!item) return
571
611
  const interval = setInterval(() => {
572
- if (loadingPreviewMore() || prefetchingPreviewBefore() || prefetchingPreviewAfter()) return
612
+ if (!previewEdgeLoadingReady() || loadingPreviewMore() || prefetchingPreviewBefore() || prefetchingPreviewAfter()) return
573
613
  const scroll = previewScroll
574
614
  const children = scroll?.getChildren()
575
615
  if (!scroll || !children || children.length === 0) return
@@ -610,8 +650,19 @@ export const Telescope = (props: { api: TuiPluginApi; config: TelescopeConfig; o
610
650
  if (!item) return
611
651
  if (item.id === scrolledItem) return
612
652
  scrolledItem = item.id
613
- const timer = setTimeout(() => scrollPreviewToTarget(previewScroll, messageTargetID(item)), 1)
614
- onCleanup(() => clearTimeout(timer))
653
+ const itemId = item.id
654
+ let readyTimer: ReturnType<typeof setTimeout> | undefined
655
+ const timer = setTimeout(() => {
656
+ if (selectedResult()?.id !== itemId) return
657
+ scrollPreviewToTarget(previewScroll, messageTargetID(item))
658
+ readyTimer = setTimeout(() => {
659
+ if (selectedResult()?.id === itemId) setPreviewEdgeLoadingReady(true)
660
+ }, 100)
661
+ }, 1)
662
+ onCleanup(() => {
663
+ clearTimeout(timer)
664
+ if (readyTimer) clearTimeout(readyTimer)
665
+ })
615
666
  })
616
667
 
617
668
  const open = () => {
@@ -6,6 +6,8 @@ export function previewScrollAmount(scroll: ScrollBoxRenderable | undefined) {
6
6
  }
7
7
 
8
8
  export function messageTargetID(item: SearchResult) {
9
+ if (item.partType === "tool") return `tool-inline-${item.messageID}-${item.id}`
10
+ if (item.partType === "reasoning") return `text-${item.messageID}-${item.id}`
9
11
  if (item.role === "assistant") return `text-${item.messageID}-${item.id}`
10
12
  return item.messageID
11
13
  }