File size: 4,021 Bytes
8c1b9fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
"use client";
import { useEffect, useState } from "react";
import { Check, FileText, Layers } from "lucide-react";
import { corpusDocuments, type CorpusDocument } from "@/lib/api";
import { displaySource } from "@/lib/format";

/**
 * NotebookLM-style source scoping: check/uncheck indexed documents to restrict
 * the next query's retrieval set. Reports the selected doc_ids to the parent
 * (null when everything is selected → search the whole corpus).
 */
export function SourceScope({
  refreshKey,
  onChange,
}: {
  refreshKey?: number;
  onChange: (docIds: string[] | null) => void;
}) {
  const [docs, setDocs] = useState<CorpusDocument[]>([]);
  const [included, setIncluded] = useState<Set<string>>(new Set());

  useEffect(() => {
    let alive = true;
    corpusDocuments()
      .then((d) => {
        if (!alive) return;
        setDocs(d);
        setIncluded(new Set(d.map((x) => x.doc_id))); // default: all in scope
      })
      .catch(() => {});
    return () => {
      alive = false;
    };
  }, [refreshKey]);

  // Report scope up: all-selected → null (whole corpus), else the id list.
  const emit = (next: Set<string>) => {
    onChange(next.size === docs.length ? null : Array.from(next));
  };

  const toggle = (id: string) => {
    setIncluded((prev) => {
      const next = new Set(prev);
      next.has(id) ? next.delete(id) : next.add(id);
      emit(next);
      return next;
    });
  };
  const setAll = (all: boolean) => {
    const next = all ? new Set(docs.map((d) => d.doc_id)) : new Set<string>();
    setIncluded(next);
    emit(next);
  };

  if (docs.length === 0) return null;
  const n = included.size;
  const scoped = n !== docs.length;

  return (
    <div className="card-inset">
      <div className="mb-2 flex items-center justify-between">
        <span className="inline-flex items-center gap-1.5 text-xs font-semibold text-fg2">
          <Layers className="h-3.5 w-3.5 text-fg3" aria-hidden />
          Scope retrieval
          <span className={`font-mono ${scoped ? "text-brand" : "text-fg3"}`}>
            {n}/{docs.length}
          </span>
        </span>
        <span className="flex gap-1.5 text-[11px]">
          <button type="button" onClick={() => setAll(true)} className="text-fg3 transition hover:text-fg">
            All
          </button>
          <span className="text-fg3">·</span>
          <button type="button" onClick={() => setAll(false)} className="text-fg3 transition hover:text-fg">
            None
          </button>
        </span>
      </div>
      <ul className="max-h-40 space-y-0.5 overflow-y-auto scroll-thin">
        {docs.map((d) => {
          const on = included.has(d.doc_id);
          return (
            <li key={d.doc_id}>
              <button
                type="button"
                role="checkbox"
                aria-checked={on}
                onClick={() => toggle(d.doc_id)}
                className="flex w-full items-center gap-2 rounded-md px-1.5 py-1 text-left text-xs transition hover:bg-panel2"
              >
                <span
                  className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
                    on ? "border-brand bg-brand/20 text-brand" : "border-edge text-transparent"
                  }`}
                >
                  <Check className="h-3 w-3" aria-hidden />
                </span>
                <FileText className="h-3.5 w-3.5 shrink-0 text-fg3" aria-hidden />
                <span className={`flex-1 truncate ${on ? "text-fg2" : "text-fg3"}`} title={displaySource(d.title || d.source)}>
                  {displaySource(d.title || d.source)}
                </span>
                <span className="shrink-0 font-mono text-[10px] text-fg3">{d.chunks}</span>
              </button>
            </li>
          );
        })}
      </ul>
      {scoped && (
        <p className="mt-1.5 text-[10px] text-fg3">
          Next queries search {n} of {docs.length} documents.
        </p>
      )}
    </div>
  );
}