nlm-memory 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/nlm.js +221 -32
- package/dist/cli/nlm.js.map +1 -1
- package/dist/core/adapters/cursor.d.ts +45 -0
- package/dist/core/adapters/cursor.js +397 -0
- package/dist/core/adapters/cursor.js.map +1 -0
- package/dist/core/adapters/from-source.js +10 -0
- package/dist/core/adapters/from-source.js.map +1 -1
- package/dist/core/adapters/windsurf.d.ts +44 -0
- package/dist/core/adapters/windsurf.js +299 -0
- package/dist/core/adapters/windsurf.js.map +1 -0
- package/dist/core/hook/claude-settings.d.ts +12 -5
- package/dist/core/hook/claude-settings.js +21 -6
- package/dist/core/hook/claude-settings.js.map +1 -1
- package/dist/core/sources/source-registry.d.ts +1 -1
- package/dist/core/sources/source-registry.js +18 -0
- package/dist/core/sources/source-registry.js.map +1 -1
- package/dist/core/storage/sqlite-session-store.d.ts +2 -0
- package/dist/core/storage/sqlite-session-store.js +38 -2
- package/dist/core/storage/sqlite-session-store.js.map +1 -1
- package/dist/hook/hook-auth.d.ts +13 -0
- package/dist/hook/hook-auth.js +19 -0
- package/dist/hook/hook-auth.js.map +1 -0
- package/dist/hook/prompt-recall-hook.js +7 -1
- package/dist/hook/prompt-recall-hook.js.map +1 -1
- package/dist/hook/session-start-hook.js +4 -1
- package/dist/hook/session-start-hook.js.map +1 -1
- package/dist/hook/stop-hook.js +4 -1
- package/dist/hook/stop-hook.js.map +1 -1
- package/dist/http/app.d.ts +2 -0
- package/dist/http/app.js +74 -0
- package/dist/http/app.js.map +1 -1
- package/dist/install/claude-code.js +1 -1
- package/dist/install/claude-code.js.map +1 -1
- package/dist/install/cursor.d.ts +25 -0
- package/dist/install/cursor.js +43 -0
- package/dist/install/cursor.js.map +1 -0
- package/dist/install/nlm-dir-perms.d.ts +19 -0
- package/dist/install/nlm-dir-perms.js +43 -0
- package/dist/install/nlm-dir-perms.js.map +1 -0
- package/dist/install/ollama.d.ts +18 -1
- package/dist/install/ollama.js +62 -7
- package/dist/install/ollama.js.map +1 -1
- package/dist/install/setup.d.ts +4 -0
- package/dist/install/setup.js +141 -18
- package/dist/install/setup.js.map +1 -1
- package/dist/install/windsurf.d.ts +25 -0
- package/dist/install/windsurf.js +43 -0
- package/dist/install/windsurf.js.map +1 -0
- package/dist/shared/types.d.ts +4 -0
- package/dist/ui/assets/{index-BA6IpU8g.css → index-C8cpwbYJ.css} +1 -1
- package/dist/ui/assets/index-CB50QnL-.js +69 -0
- package/dist/ui/index.html +2 -2
- package/logs/CHANGELOG/CHANGELOG-2026.md +186 -0
- package/logs/CHANGELOG/CHANGELOG.md +107 -235
- package/migrations/014_sources_cursor.sql +30 -0
- package/migrations/015_sources_windsurf.sql +30 -0
- package/package.json +1 -1
- package/plugin/scripts/prompt-recall-hook.mjs +55 -4
- package/plugin/scripts/stop-hook.mjs +57 -6
- package/src/cli/nlm.ts +224 -31
- package/src/core/adapters/cursor.ts +486 -0
- package/src/core/adapters/from-source.ts +10 -0
- package/src/core/adapters/windsurf.ts +386 -0
- package/src/core/hook/claude-settings.ts +30 -9
- package/src/core/sources/source-registry.ts +19 -1
- package/src/core/storage/sqlite-session-store.ts +46 -1
- package/src/hook/hook-auth.ts +18 -0
- package/src/hook/prompt-recall-hook.ts +7 -1
- package/src/hook/session-start-hook.ts +4 -1
- package/src/hook/stop-hook.ts +4 -1
- package/src/http/app.ts +78 -0
- package/src/install/claude-code.ts +1 -1
- package/src/install/cursor.ts +68 -0
- package/src/install/nlm-dir-perms.ts +55 -0
- package/src/install/ollama.ts +80 -7
- package/src/install/setup.ts +138 -17
- package/src/install/windsurf.ts +68 -0
- package/src/shared/types.ts +4 -0
- package/src/ui/components/SessionDrawer.tsx +97 -34
- package/src/ui/pages/River.tsx +90 -44
- package/src/ui/pages/Search.tsx +357 -64
- package/src/ui/pages/Thread.tsx +267 -56
- package/src/ui/styles.css +129 -5
- package/tests/integration/getbyids-sqlite.test.ts +40 -0
- package/tests/integration/hook-claude-settings.test.ts +14 -1
- package/tests/integration/mcp.test.ts +12 -0
- package/tests/integration/source-registry.test.ts +5 -3
- package/tests/unit/core/adapters/cursor.test.ts +485 -0
- package/tests/unit/core/adapters/windsurf.test.ts +416 -0
- package/dist/ui/assets/index-B_qIVV0k.js +0 -69
package/src/ui/pages/Search.tsx
CHANGED
|
@@ -1,93 +1,386 @@
|
|
|
1
|
-
import { useMemo, useState } from "react";
|
|
2
|
-
import {
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { useSearchParams } from "react-router-dom";
|
|
3
3
|
import { useDataset, relativeAge } from "../lib/dataset.js";
|
|
4
|
+
import { SessionDrawer } from "../components/SessionDrawer.js";
|
|
5
|
+
|
|
6
|
+
type MatchedField = "label" | "entity" | "decision" | "open" | "summary";
|
|
7
|
+
type SortMode = "relevance" | "recent";
|
|
8
|
+
|
|
9
|
+
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const;
|
|
10
|
+
|
|
11
|
+
function score(
|
|
12
|
+
s: { label: string; summary: string; decisions: string[]; open: string[]; entities: string[] },
|
|
13
|
+
tokens: string[],
|
|
14
|
+
phrase: string,
|
|
15
|
+
): { score: number; matchedField: MatchedField } {
|
|
16
|
+
const label = s.label.toLowerCase();
|
|
17
|
+
const summary = s.summary.toLowerCase();
|
|
18
|
+
const decisionsJoined = s.decisions.join(" ").toLowerCase();
|
|
19
|
+
const openJoined = s.open.join(" ").toLowerCase();
|
|
20
|
+
|
|
21
|
+
const fieldScores: Record<MatchedField, number> = {
|
|
22
|
+
label: 0,
|
|
23
|
+
entity: 0,
|
|
24
|
+
decision: 0,
|
|
25
|
+
open: 0,
|
|
26
|
+
summary: 0,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
for (const t of tokens) {
|
|
30
|
+
if (label.includes(t)) fieldScores.label += 3;
|
|
31
|
+
for (const e of s.entities) {
|
|
32
|
+
const el = e.toLowerCase();
|
|
33
|
+
if (el === t) { fieldScores.entity += 4; break; }
|
|
34
|
+
else if (el.includes(t)) { fieldScores.entity += 2; break; }
|
|
35
|
+
}
|
|
36
|
+
if (decisionsJoined.includes(t)) fieldScores.decision += 2;
|
|
37
|
+
if (openJoined.includes(t)) fieldScores.open += 2;
|
|
38
|
+
if (summary.includes(t)) fieldScores.summary += 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (phrase && (label.includes(phrase) || decisionsJoined.includes(phrase) || openJoined.includes(phrase))) {
|
|
42
|
+
const topField = (Object.keys(fieldScores) as MatchedField[]).reduce((a, b) =>
|
|
43
|
+
fieldScores[a] >= fieldScores[b] ? a : b
|
|
44
|
+
);
|
|
45
|
+
fieldScores[topField] += 5;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const total = Object.values(fieldScores).reduce((a, b) => a + b, 0);
|
|
49
|
+
|
|
50
|
+
const priority: MatchedField[] = ["label", "entity", "decision", "open", "summary"];
|
|
51
|
+
let matchedField: MatchedField = "summary";
|
|
52
|
+
let best = -1;
|
|
53
|
+
for (const f of priority) {
|
|
54
|
+
if (fieldScores[f] > best) { best = fieldScores[f]; matchedField = f; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { score: total, matchedField };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildSnippet(text: string, tokens: string[], radius = 60): string {
|
|
61
|
+
const lower = text.toLowerCase();
|
|
62
|
+
let best = -1;
|
|
63
|
+
for (const t of tokens) {
|
|
64
|
+
const i = lower.indexOf(t);
|
|
65
|
+
if (i !== -1 && (best === -1 || i < best)) best = i;
|
|
66
|
+
}
|
|
67
|
+
if (best === -1) return text.slice(0, radius * 2);
|
|
68
|
+
const start = Math.max(0, best - radius);
|
|
69
|
+
const end = Math.min(text.length, best + radius);
|
|
70
|
+
return (start > 0 ? "…" : "") + text.slice(start, end) + (end < text.length ? "…" : "");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function highlightTokens(text: string, tokens: string[]): string {
|
|
74
|
+
let escaped = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
75
|
+
for (const t of tokens) {
|
|
76
|
+
escaped = escaped.replace(
|
|
77
|
+
new RegExp(t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"),
|
|
78
|
+
(m) => `<mark>${m}</mark>`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return escaped;
|
|
82
|
+
}
|
|
4
83
|
|
|
5
84
|
export function SearchPage() {
|
|
6
85
|
const { data, loading, error } = useDataset();
|
|
7
86
|
const [params, setParams] = useSearchParams();
|
|
8
|
-
const q = params.get("q") ?? "";
|
|
9
|
-
const entity = params.get("entity") ?? "";
|
|
10
87
|
|
|
88
|
+
const q = params.get("q") ?? "";
|
|
11
89
|
const [input, setInput] = useState(q);
|
|
12
90
|
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
91
|
+
const [entityFilter, setEntityFilter] = useState(params.get("entity") ?? "");
|
|
92
|
+
const [runtimeFilter, setRuntimeFilter] = useState<string>("all");
|
|
93
|
+
const [statusFilter, setStatusFilter] = useState<string>("all");
|
|
94
|
+
const [sortMode, setSortMode] = useState<SortMode>("recent");
|
|
95
|
+
|
|
96
|
+
const [pageSize, setPageSize] = useState<number>(25);
|
|
97
|
+
const [page, setPage] = useState(0);
|
|
98
|
+
|
|
99
|
+
const [drawerSid, setDrawerSid] = useState<string | null>(params.get("session"));
|
|
100
|
+
|
|
101
|
+
const openSession = (id: string) => {
|
|
102
|
+
const next = new URLSearchParams(params);
|
|
103
|
+
next.set("session", id);
|
|
104
|
+
setParams(next);
|
|
105
|
+
setDrawerSid(id);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const closeSession = () => {
|
|
109
|
+
const next = new URLSearchParams(params);
|
|
110
|
+
next.delete("session");
|
|
111
|
+
setParams(next);
|
|
112
|
+
setDrawerSid(null);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const t = setTimeout(() => {
|
|
117
|
+
const next = new URLSearchParams(params);
|
|
118
|
+
if (input) next.set("q", input); else next.delete("q");
|
|
119
|
+
setParams(next);
|
|
120
|
+
}, 200);
|
|
121
|
+
return () => clearTimeout(t);
|
|
122
|
+
}, [input]);
|
|
123
|
+
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
const next = new URLSearchParams(params);
|
|
126
|
+
if (entityFilter) next.set("entity", entityFilter); else next.delete("entity");
|
|
127
|
+
setParams(next);
|
|
128
|
+
}, [entityFilter]);
|
|
129
|
+
|
|
130
|
+
const tokens = useMemo(() => q.toLowerCase().split(/\s+/).filter(Boolean), [q]);
|
|
131
|
+
const phrase = tokens.join(" ");
|
|
17
132
|
|
|
18
133
|
const results = useMemo(() => {
|
|
19
134
|
if (!data) return [];
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
135
|
+
|
|
136
|
+
const filtered = data.sessions.filter((s) => {
|
|
137
|
+
if (entityFilter && !s.entities.includes(entityFilter)) return false;
|
|
138
|
+
if (runtimeFilter !== "all" && s.runtime !== runtimeFilter) return false;
|
|
139
|
+
if (statusFilter !== "all" && s.status !== statusFilter) return false;
|
|
140
|
+
return true;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (tokens.length === 0) {
|
|
144
|
+
const sorted = [...filtered].sort((a, b) => (b.started_at ?? "").localeCompare(a.started_at ?? ""));
|
|
145
|
+
return sorted.map((session) => ({ session, score: 0, matchedField: "summary" as MatchedField }));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const scored = filtered
|
|
149
|
+
.map((s) => {
|
|
150
|
+
const { score: sc, matchedField } = score(s, tokens, phrase);
|
|
151
|
+
return { session: s, score: sc, matchedField };
|
|
152
|
+
})
|
|
153
|
+
.filter((x) => x.score > 0);
|
|
154
|
+
|
|
155
|
+
if (sortMode === "recent") {
|
|
156
|
+
scored.sort((a, b) => (b.session.started_at ?? "").localeCompare(a.session.started_at ?? ""));
|
|
157
|
+
} else {
|
|
158
|
+
scored.sort((a, b) => b.score - a.score);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return scored;
|
|
162
|
+
}, [data, tokens, phrase, entityFilter, runtimeFilter, statusFilter, sortMode]);
|
|
163
|
+
|
|
164
|
+
useEffect(() => { setPage(0); }, [results, runtimeFilter, statusFilter, entityFilter]);
|
|
165
|
+
|
|
166
|
+
const pageCount = Math.max(1, Math.ceil(results.length / pageSize));
|
|
167
|
+
const currentPage = Math.min(page, pageCount - 1);
|
|
168
|
+
const start = currentPage * pageSize;
|
|
169
|
+
const slice = results.slice(start, start + pageSize);
|
|
170
|
+
|
|
171
|
+
const availableRuntimes = useMemo(() => {
|
|
172
|
+
if (!data) return [];
|
|
173
|
+
const counts = new Map<string, number>();
|
|
174
|
+
for (const s of data.sessions) counts.set(s.runtime, (counts.get(s.runtime) ?? 0) + 1);
|
|
175
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([r]) => r);
|
|
176
|
+
}, [data]);
|
|
177
|
+
|
|
178
|
+
const topEntities = useMemo(() => {
|
|
179
|
+
const counts = new Map<string, number>();
|
|
180
|
+
for (const { session: s } of results) {
|
|
181
|
+
for (const e of s.entities) counts.set(e, (counts.get(e) ?? 0) + 1);
|
|
182
|
+
}
|
|
183
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([e]) => e);
|
|
184
|
+
}, [results]);
|
|
185
|
+
|
|
186
|
+
const anyFilterActive = q !== "" || entityFilter !== "" || runtimeFilter !== "all" || statusFilter !== "all";
|
|
187
|
+
|
|
188
|
+
const clearAllFilters = () => {
|
|
189
|
+
setInput("");
|
|
190
|
+
setEntityFilter("");
|
|
191
|
+
setRuntimeFilter("all");
|
|
192
|
+
setStatusFilter("all");
|
|
193
|
+
setSortMode("recent");
|
|
194
|
+
const next = new URLSearchParams();
|
|
195
|
+
if (drawerSid) next.set("session", drawerSid);
|
|
37
196
|
setParams(next);
|
|
38
197
|
};
|
|
39
198
|
|
|
199
|
+
const idx = slice.findIndex((r) => r.session.id === drawerSid);
|
|
200
|
+
const prevId = idx < slice.length - 1 ? slice[idx + 1]!.session.id : null;
|
|
201
|
+
const nextId = idx > 0 ? slice[idx - 1]!.session.id : null;
|
|
202
|
+
|
|
40
203
|
return (
|
|
41
204
|
<div className="page-pad">
|
|
42
|
-
<
|
|
43
|
-
<
|
|
44
|
-
className="search-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
205
|
+
<div className="search-header">
|
|
206
|
+
<form onSubmit={(e) => e.preventDefault()} className="search-bar">
|
|
207
|
+
<div className="search-wrap" style={{ flex: 1 }}>
|
|
208
|
+
<input
|
|
209
|
+
className="search-input search-big"
|
|
210
|
+
placeholder="search sessions, decisions, open questions…"
|
|
211
|
+
value={input}
|
|
212
|
+
onChange={(e) => setInput(e.target.value)}
|
|
213
|
+
autoFocus
|
|
214
|
+
/>
|
|
215
|
+
{input.length > 0 && (
|
|
216
|
+
<button type="button" className="search-clear" onClick={() => setInput("")} aria-label="Clear search">
|
|
217
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
218
|
+
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
|
219
|
+
</svg>
|
|
220
|
+
</button>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
223
|
+
</form>
|
|
224
|
+
|
|
225
|
+
<div className="thread-filters">
|
|
226
|
+
{availableRuntimes.length > 1 && (
|
|
227
|
+
<div className="filter-group" role="group" aria-label="Agent filter">
|
|
228
|
+
<button type="button" className={`chip${runtimeFilter === "all" ? " active" : ""}`} onClick={() => setRuntimeFilter("all")}>all</button>
|
|
229
|
+
{availableRuntimes.map((r) => {
|
|
230
|
+
const count = results.filter((x) => x.session.runtime === r).length;
|
|
231
|
+
return (
|
|
232
|
+
<button key={r} type="button" className={`chip${runtimeFilter === r ? " active" : ""}`} onClick={() => setRuntimeFilter(r)}>
|
|
233
|
+
{r} · {count}
|
|
234
|
+
</button>
|
|
235
|
+
);
|
|
236
|
+
})}
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
|
|
240
|
+
<div className="filter-group" role="group" aria-label="Status filter">
|
|
241
|
+
{(["all", "active", "idle", "closed", "superseded"] as const).map((s) => {
|
|
242
|
+
const count = s === "all" ? results.length : results.filter((x) => x.session.status === s).length;
|
|
243
|
+
return (
|
|
244
|
+
<button
|
|
245
|
+
key={s}
|
|
246
|
+
type="button"
|
|
247
|
+
className={`chip${statusFilter === s ? " active" : ""}`}
|
|
248
|
+
data-status={s === "all" ? undefined : s}
|
|
249
|
+
onClick={() => setStatusFilter(s)}
|
|
250
|
+
>{s} · {count}</button>
|
|
251
|
+
);
|
|
252
|
+
})}
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
{q !== "" && (
|
|
256
|
+
<div className="filter-group" role="group" aria-label="Sort order">
|
|
257
|
+
{(["relevance", "recent"] as const).map((m) => (
|
|
258
|
+
<button key={m} type="button" className={`chip${sortMode === m ? " active" : ""}`} onClick={() => setSortMode(m)}>{m}</button>
|
|
259
|
+
))}
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
|
|
263
|
+
{topEntities.length > 0 && (
|
|
264
|
+
<div className="filter-group" role="group" aria-label="Entity filter" style={{ flexWrap: "wrap" }}>
|
|
265
|
+
<button type="button" className={`chip${entityFilter === "" ? " active" : ""}`} onClick={() => setEntityFilter("")}>all entities</button>
|
|
266
|
+
{topEntities.slice(0, 12).map((e) => (
|
|
267
|
+
<button key={e} type="button" className={`chip${entityFilter === e ? " active" : ""}`} onClick={() => setEntityFilter(entityFilter === e ? "" : e)}>{e}</button>
|
|
268
|
+
))}
|
|
269
|
+
{topEntities.length > 12 && (
|
|
270
|
+
<select
|
|
271
|
+
className="form-input form-input-inline"
|
|
272
|
+
value={entityFilter}
|
|
273
|
+
onChange={(e) => setEntityFilter(e.target.value)}
|
|
274
|
+
>
|
|
275
|
+
<option value="">more entities…</option>
|
|
276
|
+
{topEntities.slice(12).map((e) => (
|
|
277
|
+
<option key={e} value={e}>{e}</option>
|
|
278
|
+
))}
|
|
279
|
+
</select>
|
|
280
|
+
)}
|
|
281
|
+
</div>
|
|
282
|
+
)}
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
57
285
|
|
|
58
286
|
{loading && !data && <div className="muted">Loading…</div>}
|
|
59
287
|
{error && <div className="muted error">{error}</div>}
|
|
60
288
|
|
|
289
|
+
{!anyFilterActive && <p className="muted small search-hint">Showing recent sessions. Type to search across labels, decisions, open questions, and summaries.</p>}
|
|
290
|
+
|
|
61
291
|
<div className="muted small search-meta">
|
|
62
292
|
{results.length} result{results.length === 1 ? "" : "s"}
|
|
293
|
+
{anyFilterActive && (
|
|
294
|
+
<button type="button" className="link-button" style={{ marginLeft: 8 }} onClick={clearAllFilters}>
|
|
295
|
+
clear filters
|
|
296
|
+
</button>
|
|
297
|
+
)}
|
|
63
298
|
</div>
|
|
64
299
|
|
|
65
|
-
<ul className="session-list
|
|
66
|
-
{
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
300
|
+
<ul className="session-list">
|
|
301
|
+
{slice.map(({ session: s, matchedField }) => {
|
|
302
|
+
let snippetText = s.summary;
|
|
303
|
+
if (tokens.length > 0) {
|
|
304
|
+
if (matchedField === "decision") {
|
|
305
|
+
snippetText = s.decisions.find((d) => tokens.some((t) => d.toLowerCase().includes(t))) ?? s.summary;
|
|
306
|
+
} else if (matchedField === "open") {
|
|
307
|
+
snippetText = s.open.find((o) => tokens.some((t) => o.toLowerCase().includes(t))) ?? s.summary;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return (
|
|
312
|
+
<li
|
|
313
|
+
key={s.id}
|
|
314
|
+
className={`session-row session-row-detail clickable${drawerSid === s.id ? " is-active" : ""}`}
|
|
315
|
+
onClick={() => openSession(s.id)}
|
|
316
|
+
>
|
|
317
|
+
<span className={`chip-inline status-${s.status}`}>{s.status}</span>
|
|
318
|
+
<div className="session-row-main">
|
|
319
|
+
<span className="session-label">{s.label}</span>
|
|
320
|
+
<span className="session-meta">
|
|
321
|
+
{s.entities.slice(0, 4).map((e) => (
|
|
322
|
+
<span key={e} className="chip-inline" style={{ marginRight: 4 }}>{e}</span>
|
|
323
|
+
))}
|
|
324
|
+
</span>
|
|
325
|
+
{tokens.length > 0 && (
|
|
326
|
+
<>
|
|
327
|
+
<span className="live-tag" data-kind={matchedField}>{matchedField}</span>
|
|
328
|
+
<div
|
|
329
|
+
className="match-snippet"
|
|
330
|
+
dangerouslySetInnerHTML={{ __html: highlightTokens(buildSnippet(snippetText, tokens), tokens) }}
|
|
331
|
+
/>
|
|
332
|
+
</>
|
|
333
|
+
)}
|
|
334
|
+
</div>
|
|
335
|
+
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 4 }}>
|
|
336
|
+
<span className="muted small mono">{relativeAge(s.started_at)}</span>
|
|
337
|
+
<span className="chip-inline">{s.runtime}</span>
|
|
338
|
+
</div>
|
|
339
|
+
</li>
|
|
340
|
+
);
|
|
341
|
+
})}
|
|
342
|
+
{slice.length === 0 && data && (
|
|
343
|
+
<li className="muted empty-row">
|
|
344
|
+
{results.length === 0 ? "No sessions match the current filters." : "No sessions on this page."}
|
|
73
345
|
</li>
|
|
74
|
-
)
|
|
346
|
+
)}
|
|
75
347
|
</ul>
|
|
348
|
+
|
|
349
|
+
{results.length > 0 && (
|
|
350
|
+
<div className="pagination">
|
|
351
|
+
<div className="page-size">
|
|
352
|
+
<label className="form-label">Per page</label>
|
|
353
|
+
<select
|
|
354
|
+
className="form-input form-input-inline"
|
|
355
|
+
value={pageSize}
|
|
356
|
+
onChange={(e) => setPageSize(Number.parseInt(e.target.value, 10))}
|
|
357
|
+
>
|
|
358
|
+
{PAGE_SIZE_OPTIONS.map((n) => <option key={n} value={n}>{n}</option>)}
|
|
359
|
+
</select>
|
|
360
|
+
</div>
|
|
361
|
+
<span className="header-spacer" />
|
|
362
|
+
<span className="muted small">
|
|
363
|
+
{start + 1}–{Math.min(start + pageSize, results.length)} of {results.length}
|
|
364
|
+
</span>
|
|
365
|
+
<div className="page-nav">
|
|
366
|
+
<button type="button" className="chip" disabled={currentPage === 0} onClick={() => setPage(0)}>« first</button>
|
|
367
|
+
<button type="button" className="chip" disabled={currentPage === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>‹ prev</button>
|
|
368
|
+
<span className="page-indicator mono">{currentPage + 1} / {pageCount}</span>
|
|
369
|
+
<button type="button" className="chip" disabled={currentPage >= pageCount - 1} onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}>next ›</button>
|
|
370
|
+
<button type="button" className="chip" disabled={currentPage >= pageCount - 1} onClick={() => setPage(pageCount - 1)}>last »</button>
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
)}
|
|
374
|
+
|
|
375
|
+
{drawerSid && (
|
|
376
|
+
<SessionDrawer
|
|
377
|
+
sessionId={drawerSid}
|
|
378
|
+
onClose={closeSession}
|
|
379
|
+
onNavigate={openSession}
|
|
380
|
+
prevSessionId={prevId}
|
|
381
|
+
nextSessionId={nextId}
|
|
382
|
+
/>
|
|
383
|
+
)}
|
|
76
384
|
</div>
|
|
77
385
|
);
|
|
78
386
|
}
|
|
79
|
-
|
|
80
|
-
function score(s: { label: string; summary: string; decisions: string[]; open: string[]; entities: string[] }, tokens: string[]): number {
|
|
81
|
-
let total = 0;
|
|
82
|
-
const label = s.label.toLowerCase();
|
|
83
|
-
const summary = s.summary.toLowerCase();
|
|
84
|
-
const decisions = s.decisions.join(" ").toLowerCase();
|
|
85
|
-
const open = s.open.join(" ").toLowerCase();
|
|
86
|
-
for (const t of tokens) {
|
|
87
|
-
if (label.includes(t)) total += 3;
|
|
88
|
-
if (decisions.includes(t)) total += 2;
|
|
89
|
-
if (open.includes(t)) total += 2;
|
|
90
|
-
if (summary.includes(t)) total += 1;
|
|
91
|
-
}
|
|
92
|
-
return total;
|
|
93
|
-
}
|