nlm-memory 0.4.1 → 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 +68 -10
- 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 +86 -10
- 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/Thread.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useMemo, useState } from "react";
|
|
2
2
|
import { Link, useSearchParams } from "react-router-dom";
|
|
3
3
|
import { useDataset, relativeAge } from "../lib/dataset.js";
|
|
4
|
-
import type { DatasetSession } from "../lib/dataset.js";
|
|
4
|
+
import type { DatasetSession, Dataset } from "../lib/dataset.js";
|
|
5
5
|
import { SessionDrawer } from "../components/SessionDrawer.js";
|
|
6
6
|
import { PromoteOpenButton } from "../components/PromoteOpenButton.js";
|
|
7
7
|
import { SessionListSkeleton, Skeleton } from "../components/Skeleton.js";
|
|
@@ -14,6 +14,8 @@ export function ThreadPage() {
|
|
|
14
14
|
const drawerSid = params.get("session");
|
|
15
15
|
|
|
16
16
|
const [sort, setSort] = useState<ThreadSort>(() => readViewSettings().threadSort);
|
|
17
|
+
const [decisionsExpanded, setDecisionsExpanded] = useState(false);
|
|
18
|
+
const [openExpanded, setOpenExpanded] = useState(false);
|
|
17
19
|
|
|
18
20
|
const thread = useMemo(() => {
|
|
19
21
|
if (!data || !entity) return [];
|
|
@@ -54,23 +56,7 @@ export function ThreadPage() {
|
|
|
54
56
|
if (!data) return null;
|
|
55
57
|
|
|
56
58
|
if (!entity) {
|
|
57
|
-
return
|
|
58
|
-
<div className="page-pad">
|
|
59
|
-
<h2 className="page-title">Thread</h2>
|
|
60
|
-
<p className="muted">Pick an entity to view its reasoning history.</p>
|
|
61
|
-
<ul className="entity-grid">
|
|
62
|
-
{data.entities.slice(0, 48).map((e) => (
|
|
63
|
-
<li key={e.canonical}>
|
|
64
|
-
<Link to={`/thread?entity=${encodeURIComponent(e.canonical)}`} className="card card-lift entity-card">
|
|
65
|
-
<span className="dot" style={{ background: data.entity_colors[e.canonical] ?? "#666" }} />
|
|
66
|
-
<span className="entity-name">{e.canonical}</span>
|
|
67
|
-
<span className="muted small">{e.session_count}</span>
|
|
68
|
-
</Link>
|
|
69
|
-
</li>
|
|
70
|
-
))}
|
|
71
|
-
</ul>
|
|
72
|
-
</div>
|
|
73
|
-
);
|
|
59
|
+
return <EntityPicker data={data} />;
|
|
74
60
|
}
|
|
75
61
|
|
|
76
62
|
const decisions = thread.flatMap((s) => s.decisions.map((d) => ({ d, sid: s.id, when: s.started_at })));
|
|
@@ -85,31 +71,41 @@ export function ThreadPage() {
|
|
|
85
71
|
<h2 className="page-title">{entity}</h2>
|
|
86
72
|
<span className="muted">{thread.length} session{thread.length === 1 ? "" : "s"}</span>
|
|
87
73
|
<span className="header-spacer" />
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
74
|
+
<div className="filter-group" role="group" aria-label="Sort order">
|
|
75
|
+
{(["recent", "oldest"] as const).map((s) => (
|
|
76
|
+
<button key={s} type="button" className={`chip${sort === s ? " active" : ""}`} onClick={() => setSort(s)}>
|
|
77
|
+
{s === "recent" ? "recent first" : "oldest first"}
|
|
78
|
+
</button>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
92
81
|
</div>
|
|
93
82
|
|
|
94
83
|
<div className="thread-grid">
|
|
95
84
|
<section className="card">
|
|
96
85
|
<header className="card-head"><h3>Decisions</h3><span className="muted small">{decisions.length}</span></header>
|
|
97
86
|
<ul className="marker-list">
|
|
98
|
-
{decisions.slice(0, 30).map((d, i) => (
|
|
87
|
+
{(decisionsExpanded ? decisions : decisions.slice(0, 30)).map((d, i) => (
|
|
99
88
|
<li key={i} className="marker-row">
|
|
100
89
|
<span className="live-tag" data-kind="decision">decision</span>
|
|
101
90
|
<span className="marker-text">{d.d}</span>
|
|
102
91
|
<button type="button" className="link-button" onClick={() => openSession(d.sid)}>{relativeAge(d.when)}</button>
|
|
103
92
|
</li>
|
|
104
93
|
))}
|
|
105
|
-
{decisions.length === 0 && <li className="muted
|
|
94
|
+
{decisions.length === 0 && <li className="muted empty-row">No decisions captured yet.</li>}
|
|
95
|
+
{decisions.length > 30 && (
|
|
96
|
+
<li style={{ padding: "8px 14px" }}>
|
|
97
|
+
<button type="button" className="link-button" onClick={() => setDecisionsExpanded((v) => !v)}>
|
|
98
|
+
{decisionsExpanded ? "Show less" : `Showing 30 of ${decisions.length} — show all`}
|
|
99
|
+
</button>
|
|
100
|
+
</li>
|
|
101
|
+
)}
|
|
106
102
|
</ul>
|
|
107
103
|
</section>
|
|
108
104
|
|
|
109
105
|
<section className="card">
|
|
110
106
|
<header className="card-head"><h3>Open questions</h3><span className="muted small">{open.length}</span></header>
|
|
111
107
|
<ul className="marker-list">
|
|
112
|
-
{open.slice(0, 30).map((o, i) => (
|
|
108
|
+
{(openExpanded ? open : open.slice(0, 30)).map((o, i) => (
|
|
113
109
|
<li key={`${o.id}-${i}`} className="marker-row marker-row-promotable">
|
|
114
110
|
<span className="live-tag" data-kind="open">open</span>
|
|
115
111
|
<span className="marker-text">{o.q}</span>
|
|
@@ -119,16 +115,35 @@ export function ThreadPage() {
|
|
|
119
115
|
</div>
|
|
120
116
|
</li>
|
|
121
117
|
))}
|
|
122
|
-
{open.length === 0 && <li className="muted
|
|
118
|
+
{open.length === 0 && <li className="muted empty-row">No open questions captured yet.</li>}
|
|
119
|
+
{open.length > 30 && (
|
|
120
|
+
<li style={{ padding: "8px 14px" }}>
|
|
121
|
+
<button type="button" className="link-button" onClick={() => setOpenExpanded((v) => !v)}>
|
|
122
|
+
{openExpanded ? "Show less" : `Showing 30 of ${open.length} — show all`}
|
|
123
|
+
</button>
|
|
124
|
+
</li>
|
|
125
|
+
)}
|
|
123
126
|
</ul>
|
|
124
127
|
</section>
|
|
125
128
|
</div>
|
|
126
129
|
|
|
127
|
-
<ThreadSessionList thread={thread} onOpenSession={openSession} />
|
|
130
|
+
<ThreadSessionList thread={thread} entity={entity} onOpenSession={openSession} />
|
|
128
131
|
|
|
129
|
-
{drawerSid && (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
+
{drawerSid && (() => {
|
|
133
|
+
const idx = thread.findIndex((s) => s.id === drawerSid);
|
|
134
|
+
const prevId = idx < thread.length - 1 ? thread[idx + 1]!.id : null;
|
|
135
|
+
const nextId = idx > 0 ? thread[idx - 1]!.id : null;
|
|
136
|
+
return (
|
|
137
|
+
<SessionDrawer
|
|
138
|
+
sessionId={drawerSid}
|
|
139
|
+
onClose={closeSession}
|
|
140
|
+
onNavigate={openSession}
|
|
141
|
+
prevSessionId={prevId}
|
|
142
|
+
nextSessionId={nextId}
|
|
143
|
+
entityColor={entityColor}
|
|
144
|
+
/>
|
|
145
|
+
);
|
|
146
|
+
})()}
|
|
132
147
|
</div>
|
|
133
148
|
);
|
|
134
149
|
}
|
|
@@ -139,20 +154,30 @@ const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const;
|
|
|
139
154
|
|
|
140
155
|
function ThreadSessionList({
|
|
141
156
|
thread,
|
|
157
|
+
entity,
|
|
142
158
|
onOpenSession,
|
|
143
159
|
}: {
|
|
144
160
|
thread: DatasetSession[];
|
|
161
|
+
entity: string;
|
|
145
162
|
onOpenSession: (id: string) => void;
|
|
146
163
|
}) {
|
|
164
|
+
const [runtimeFilter, setRuntimeFilter] = useState<string>("all");
|
|
147
165
|
const [query, setQuery] = useState("");
|
|
148
166
|
const [status, setStatus] = useState<StatusFilter>("all");
|
|
149
167
|
const [markers, setMarkers] = useState<MarkerFilter>("all");
|
|
150
168
|
const [pageSize, setPageSize] = useState<number>(25);
|
|
151
169
|
const [page, setPage] = useState(0);
|
|
152
170
|
|
|
171
|
+
const threadRuntimes = useMemo(() => {
|
|
172
|
+
const counts = new Map<string, number>();
|
|
173
|
+
for (const s of thread) counts.set(s.runtime, (counts.get(s.runtime) ?? 0) + 1);
|
|
174
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([r]) => r);
|
|
175
|
+
}, [thread]);
|
|
176
|
+
|
|
153
177
|
const filtered = useMemo(() => {
|
|
154
178
|
const q = query.toLowerCase().trim();
|
|
155
179
|
return thread.filter((s) => {
|
|
180
|
+
if (runtimeFilter !== "all" && s.runtime !== runtimeFilter) return false;
|
|
156
181
|
if (status !== "all" && s.status !== status) return false;
|
|
157
182
|
if (markers === "decisions" && s.decisions.length === 0) return false;
|
|
158
183
|
if (markers === "open" && s.open_questions.length === 0) return false;
|
|
@@ -164,10 +189,13 @@ function ThreadSessionList({
|
|
|
164
189
|
s.open.some((o) => o.toLowerCase().includes(q))
|
|
165
190
|
);
|
|
166
191
|
});
|
|
167
|
-
}, [thread, query, status, markers]);
|
|
192
|
+
}, [thread, query, status, markers, runtimeFilter]);
|
|
168
193
|
|
|
169
194
|
// Reset page when filter inputs change
|
|
170
|
-
useEffect(() => { setPage(0); }, [query, status, markers, pageSize]);
|
|
195
|
+
useEffect(() => { setPage(0); }, [query, status, markers, pageSize, runtimeFilter]);
|
|
196
|
+
|
|
197
|
+
// Reset runtime filter when entity changes
|
|
198
|
+
useEffect(() => { setRuntimeFilter("all"); }, [entity]);
|
|
171
199
|
|
|
172
200
|
const pageCount = Math.max(1, Math.ceil(filtered.length / pageSize));
|
|
173
201
|
const currentPage = Math.min(page, pageCount - 1);
|
|
@@ -178,36 +206,72 @@ function ThreadSessionList({
|
|
|
178
206
|
<>
|
|
179
207
|
<div className="thread-sessions-head">
|
|
180
208
|
<h3 className="section-title thread-sessions-title">Sessions</h3>
|
|
181
|
-
<
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
209
|
+
<div className="search-wrap">
|
|
210
|
+
<input
|
|
211
|
+
className="search-input"
|
|
212
|
+
placeholder="search label, summary, decisions, open…"
|
|
213
|
+
value={query}
|
|
214
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
215
|
+
onKeyDown={(e) => { if (e.key === "Escape") setQuery(""); }}
|
|
216
|
+
/>
|
|
217
|
+
{query && (
|
|
218
|
+
<button type="button" className="search-clear" onClick={() => setQuery("")} aria-label="Clear search">
|
|
219
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
220
|
+
</button>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
187
223
|
</div>
|
|
188
224
|
|
|
189
225
|
<div className="thread-filters">
|
|
190
|
-
|
|
191
|
-
|
|
226
|
+
{threadRuntimes.length > 1 && (
|
|
227
|
+
<div className="filter-group" role="group" aria-label="Agent filter">
|
|
192
228
|
<button
|
|
193
|
-
key={s}
|
|
194
229
|
type="button"
|
|
195
|
-
className={`chip${
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
230
|
+
className={`chip${runtimeFilter === "all" ? " active" : ""}`}
|
|
231
|
+
onClick={() => setRuntimeFilter("all")}
|
|
232
|
+
>all</button>
|
|
233
|
+
{threadRuntimes.map((r) => {
|
|
234
|
+
const count = thread.filter((s) => s.runtime === r).length;
|
|
235
|
+
return (
|
|
236
|
+
<button
|
|
237
|
+
key={r}
|
|
238
|
+
type="button"
|
|
239
|
+
className={`chip${runtimeFilter === r ? " active" : ""}`}
|
|
240
|
+
onClick={() => setRuntimeFilter(r)}
|
|
241
|
+
>{r} · {count}</button>
|
|
242
|
+
);
|
|
243
|
+
})}
|
|
244
|
+
</div>
|
|
245
|
+
)}
|
|
246
|
+
<div className="filter-group" role="group" aria-label="Status filter">
|
|
247
|
+
{(["all", "active", "idle", "closed", "superseded"] as const).map((s) => {
|
|
248
|
+
const count = s === "all" ? thread.length : thread.filter((x) => x.status === s).length;
|
|
249
|
+
return (
|
|
250
|
+
<button
|
|
251
|
+
key={s}
|
|
252
|
+
type="button"
|
|
253
|
+
className={`chip${status === s ? " active" : ""}`}
|
|
254
|
+
data-status={s === "all" ? undefined : s}
|
|
255
|
+
onClick={() => setStatus(s)}
|
|
256
|
+
>{s} · {count}</button>
|
|
257
|
+
);
|
|
258
|
+
})}
|
|
200
259
|
</div>
|
|
201
260
|
<div className="filter-group" role="group" aria-label="Marker filter">
|
|
202
|
-
{(["all", "decisions", "open"] as const).map((m) =>
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
261
|
+
{(["all", "decisions", "open"] as const).map((m) => {
|
|
262
|
+
const count = m === "decisions"
|
|
263
|
+
? thread.filter((s) => s.decisions.length > 0).length
|
|
264
|
+
: thread.filter((s) => s.open_questions.length > 0).length;
|
|
265
|
+
return (
|
|
266
|
+
<button
|
|
267
|
+
key={m}
|
|
268
|
+
type="button"
|
|
269
|
+
className={`chip${markers === m ? " active" : ""}`}
|
|
270
|
+
data-marker={m === "all" ? undefined : m}
|
|
271
|
+
onClick={() => setMarkers(m)}
|
|
272
|
+
>{m === "all" ? "all" : `${m} · ${count}`}</button>
|
|
273
|
+
);
|
|
274
|
+
})}
|
|
211
275
|
</div>
|
|
212
276
|
<span className="header-spacer" />
|
|
213
277
|
<span className="muted small">{filtered.length} match{filtered.length === 1 ? "" : "es"}</span>
|
|
@@ -225,7 +289,7 @@ function ThreadSessionList({
|
|
|
225
289
|
</li>
|
|
226
290
|
))}
|
|
227
291
|
{slice.length === 0 && (
|
|
228
|
-
<li className="muted
|
|
292
|
+
<li className="muted empty-row">
|
|
229
293
|
{thread.length === 0 ? "No sessions yet." : "No sessions match the current filters."}
|
|
230
294
|
</li>
|
|
231
295
|
)}
|
|
@@ -260,3 +324,150 @@ function ThreadSessionList({
|
|
|
260
324
|
);
|
|
261
325
|
}
|
|
262
326
|
|
|
327
|
+
type EntitySort = "most-active" | "least-active" | "a-z" | "z-a";
|
|
328
|
+
const ENTITY_PAGE_SIZE_OPTIONS = [24, 48, 96] as const;
|
|
329
|
+
|
|
330
|
+
function EntityPicker({ data }: { data: Dataset }) {
|
|
331
|
+
const [runtimeFilter, setRuntimeFilter] = useState<string>("all");
|
|
332
|
+
const [entitySearch, setEntitySearch] = useState("");
|
|
333
|
+
const [sort, setSort] = useState<EntitySort>("most-active");
|
|
334
|
+
const [pageSize, setPageSize] = useState<number>(48);
|
|
335
|
+
const [page, setPage] = useState(0);
|
|
336
|
+
|
|
337
|
+
const entityRuntimeMap = useMemo(() => {
|
|
338
|
+
const m = new Map<string, Set<string>>();
|
|
339
|
+
for (const s of data.sessions) {
|
|
340
|
+
for (const e of s.entities) {
|
|
341
|
+
let set = m.get(e);
|
|
342
|
+
if (!set) { set = new Set(); m.set(e, set); }
|
|
343
|
+
set.add(s.runtime);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return m;
|
|
347
|
+
}, [data.sessions]);
|
|
348
|
+
|
|
349
|
+
const sortedRuntimes = useMemo(() => {
|
|
350
|
+
return [...data.runtimes].sort((a, b) => b.sessions_total - a.sessions_total);
|
|
351
|
+
}, [data.runtimes]);
|
|
352
|
+
|
|
353
|
+
const filtered = useMemo(() => {
|
|
354
|
+
const q = entitySearch.toLowerCase().trim();
|
|
355
|
+
const matches = q
|
|
356
|
+
? data.entities.filter((e) => e.canonical.toLowerCase().includes(q))
|
|
357
|
+
: [...data.entities];
|
|
358
|
+
const result = runtimeFilter === "all"
|
|
359
|
+
? matches
|
|
360
|
+
: matches.filter((e) => entityRuntimeMap.get(e.canonical)?.has(runtimeFilter) ?? false);
|
|
361
|
+
result.sort((a, b) => {
|
|
362
|
+
if (sort === "most-active") return b.session_count - a.session_count;
|
|
363
|
+
if (sort === "least-active") return a.session_count - b.session_count;
|
|
364
|
+
if (sort === "a-z") return a.canonical.localeCompare(b.canonical);
|
|
365
|
+
return b.canonical.localeCompare(a.canonical);
|
|
366
|
+
});
|
|
367
|
+
return result;
|
|
368
|
+
}, [data.entities, entitySearch, sort, runtimeFilter, entityRuntimeMap]);
|
|
369
|
+
|
|
370
|
+
useEffect(() => { setPage(0); }, [entitySearch, sort, pageSize, runtimeFilter]);
|
|
371
|
+
|
|
372
|
+
const pageCount = Math.max(1, Math.ceil(filtered.length / pageSize));
|
|
373
|
+
const currentPage = Math.min(page, pageCount - 1);
|
|
374
|
+
const start = currentPage * pageSize;
|
|
375
|
+
const slice = filtered.slice(start, start + pageSize);
|
|
376
|
+
|
|
377
|
+
return (
|
|
378
|
+
<div className="page-pad">
|
|
379
|
+
<h2 className="page-title">Thread</h2>
|
|
380
|
+
<p className="muted">Pick an entity to view its reasoning history.</p>
|
|
381
|
+
<div className="thread-sessions-head" style={{ marginTop: 16 }}>
|
|
382
|
+
<div className="search-wrap" style={{ maxWidth: 320 }}>
|
|
383
|
+
<input
|
|
384
|
+
className="search-input"
|
|
385
|
+
placeholder="search entities…"
|
|
386
|
+
value={entitySearch}
|
|
387
|
+
onChange={(e) => setEntitySearch(e.target.value)}
|
|
388
|
+
onKeyDown={(e) => { if (e.key === "Escape") setEntitySearch(""); }}
|
|
389
|
+
/>
|
|
390
|
+
{entitySearch && (
|
|
391
|
+
<button type="button" className="search-clear" onClick={() => setEntitySearch("")} aria-label="Clear search">
|
|
392
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
393
|
+
</button>
|
|
394
|
+
)}
|
|
395
|
+
</div>
|
|
396
|
+
<span className="header-spacer" />
|
|
397
|
+
<span className="muted small">
|
|
398
|
+
{entitySearch
|
|
399
|
+
? `${filtered.length} of ${data.entities.length} entities`
|
|
400
|
+
: `${data.entities.length} entities`}
|
|
401
|
+
</span>
|
|
402
|
+
</div>
|
|
403
|
+
<div className="thread-filters">
|
|
404
|
+
<div className="filter-group" role="group" aria-label="Agent filter">
|
|
405
|
+
<button
|
|
406
|
+
type="button"
|
|
407
|
+
className={`chip${runtimeFilter === "all" ? " active" : ""}`}
|
|
408
|
+
onClick={() => setRuntimeFilter("all")}
|
|
409
|
+
>all</button>
|
|
410
|
+
{sortedRuntimes.map((r) => (
|
|
411
|
+
<button
|
|
412
|
+
key={r.name}
|
|
413
|
+
type="button"
|
|
414
|
+
className={`chip${runtimeFilter === r.name ? " active" : ""}`}
|
|
415
|
+
onClick={() => setRuntimeFilter(r.name)}
|
|
416
|
+
>{r.name} · {r.sessions_total}</button>
|
|
417
|
+
))}
|
|
418
|
+
</div>
|
|
419
|
+
<div className="filter-group" role="group" aria-label="Sort order">
|
|
420
|
+
{(["most-active", "least-active", "a-z", "z-a"] as const).map((s) => (
|
|
421
|
+
<button
|
|
422
|
+
key={s}
|
|
423
|
+
type="button"
|
|
424
|
+
className={`chip${sort === s ? " active" : ""}`}
|
|
425
|
+
onClick={() => setSort(s)}
|
|
426
|
+
>
|
|
427
|
+
{s === "most-active" ? "most active" : s === "least-active" ? "least active" : s}
|
|
428
|
+
</button>
|
|
429
|
+
))}
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
<ul className="entity-grid">
|
|
433
|
+
{slice.map((e) => (
|
|
434
|
+
<li key={e.canonical}>
|
|
435
|
+
<Link to={`/thread?entity=${encodeURIComponent(e.canonical)}`} className="card card-lift entity-card">
|
|
436
|
+
<span className="dot" style={{ background: data.entity_colors[e.canonical] ?? "#666" }} />
|
|
437
|
+
<span className="entity-name">{e.canonical}</span>
|
|
438
|
+
<span className="muted small">{e.session_count}</span>
|
|
439
|
+
</Link>
|
|
440
|
+
</li>
|
|
441
|
+
))}
|
|
442
|
+
{slice.length === 0 && (
|
|
443
|
+
<li style={{ gridColumn: "1 / -1" }} className="muted empty-row">No entities match.</li>
|
|
444
|
+
)}
|
|
445
|
+
</ul>
|
|
446
|
+
{filtered.length > pageSize && (
|
|
447
|
+
<div className="pagination">
|
|
448
|
+
<div className="page-size">
|
|
449
|
+
<label className="form-label">Per page</label>
|
|
450
|
+
<select
|
|
451
|
+
className="form-input form-input-inline"
|
|
452
|
+
value={pageSize}
|
|
453
|
+
onChange={(e) => setPageSize(Number.parseInt(e.target.value, 10))}
|
|
454
|
+
>
|
|
455
|
+
{ENTITY_PAGE_SIZE_OPTIONS.map((n) => <option key={n} value={n}>{n}</option>)}
|
|
456
|
+
</select>
|
|
457
|
+
</div>
|
|
458
|
+
<span className="header-spacer" />
|
|
459
|
+
<span className="muted small">
|
|
460
|
+
{start + 1}–{Math.min(start + pageSize, filtered.length)} of {filtered.length}
|
|
461
|
+
</span>
|
|
462
|
+
<div className="page-nav">
|
|
463
|
+
<button type="button" className="chip" disabled={currentPage === 0} onClick={() => setPage(0)}>« first</button>
|
|
464
|
+
<button type="button" className="chip" disabled={currentPage === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}>‹ prev</button>
|
|
465
|
+
<span className="page-indicator mono">{currentPage + 1} / {pageCount}</span>
|
|
466
|
+
<button type="button" className="chip" disabled={currentPage >= pageCount - 1} onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}>next ›</button>
|
|
467
|
+
<button type="button" className="chip" disabled={currentPage >= pageCount - 1} onClick={() => setPage(pageCount - 1)}>last »</button>
|
|
468
|
+
</div>
|
|
469
|
+
</div>
|
|
470
|
+
)}
|
|
471
|
+
</div>
|
|
472
|
+
);
|
|
473
|
+
}
|
package/src/ui/styles.css
CHANGED
|
@@ -1314,7 +1314,7 @@ a.session-label:hover { color: var(--accent); }
|
|
|
1314
1314
|
--river-label-w: 200px;
|
|
1315
1315
|
--river-row-gap: 3px;
|
|
1316
1316
|
--river-cell-gap: 2px;
|
|
1317
|
-
--river-cell-h:
|
|
1317
|
+
--river-cell-h: 20px;
|
|
1318
1318
|
padding: 12px;
|
|
1319
1319
|
width: 100%;
|
|
1320
1320
|
display: flex;
|
|
@@ -1461,7 +1461,7 @@ a.session-label:hover { color: var(--accent); }
|
|
|
1461
1461
|
}
|
|
1462
1462
|
|
|
1463
1463
|
.river-cell {
|
|
1464
|
-
transition:
|
|
1464
|
+
transition: outline-color var(--ease);
|
|
1465
1465
|
outline: 1px solid transparent;
|
|
1466
1466
|
outline-offset: 1px;
|
|
1467
1467
|
position: relative;
|
|
@@ -1469,7 +1469,6 @@ a.session-label:hover { color: var(--accent); }
|
|
|
1469
1469
|
}
|
|
1470
1470
|
|
|
1471
1471
|
.river-cell:hover {
|
|
1472
|
-
transform: scale(1.3);
|
|
1473
1472
|
outline-color: rgba(255, 255, 255, 0.7);
|
|
1474
1473
|
z-index: 1;
|
|
1475
1474
|
}
|
|
@@ -1626,6 +1625,55 @@ a.session-label:hover { color: var(--accent); }
|
|
|
1626
1625
|
gap: 6px;
|
|
1627
1626
|
}
|
|
1628
1627
|
|
|
1628
|
+
/* ── Supersedence banners (SessionDrawer) ───────────────────────────────── */
|
|
1629
|
+
.supersedence-banner {
|
|
1630
|
+
display: flex;
|
|
1631
|
+
align-items: baseline;
|
|
1632
|
+
flex-wrap: wrap;
|
|
1633
|
+
gap: 6px;
|
|
1634
|
+
padding: 8px 12px;
|
|
1635
|
+
border-radius: var(--r-sm);
|
|
1636
|
+
margin-bottom: 10px;
|
|
1637
|
+
font-size: var(--text-sm);
|
|
1638
|
+
}
|
|
1639
|
+
.supersedence-banner--superseded {
|
|
1640
|
+
background: rgba(255, 107, 53, 0.08);
|
|
1641
|
+
border: 1px solid rgba(255, 107, 53, 0.25);
|
|
1642
|
+
color: var(--danger);
|
|
1643
|
+
}
|
|
1644
|
+
.supersedence-banner--supersedes {
|
|
1645
|
+
background: rgba(168, 168, 168, 0.06);
|
|
1646
|
+
border: 1px solid var(--border-2);
|
|
1647
|
+
color: var(--text-2);
|
|
1648
|
+
}
|
|
1649
|
+
.supersedence-label {
|
|
1650
|
+
font-weight: 500;
|
|
1651
|
+
white-space: nowrap;
|
|
1652
|
+
}
|
|
1653
|
+
.supersedence-ids {
|
|
1654
|
+
display: flex;
|
|
1655
|
+
flex-wrap: wrap;
|
|
1656
|
+
gap: 4px;
|
|
1657
|
+
}
|
|
1658
|
+
.supersedence-id {
|
|
1659
|
+
color: inherit;
|
|
1660
|
+
opacity: 0.8;
|
|
1661
|
+
}
|
|
1662
|
+
.supersedence-link {
|
|
1663
|
+
background: none;
|
|
1664
|
+
border: none;
|
|
1665
|
+
padding: 0;
|
|
1666
|
+
cursor: pointer;
|
|
1667
|
+
font-family: var(--font-mono);
|
|
1668
|
+
font-size: var(--text-xs);
|
|
1669
|
+
color: inherit;
|
|
1670
|
+
text-decoration: underline;
|
|
1671
|
+
text-underline-offset: 2px;
|
|
1672
|
+
}
|
|
1673
|
+
.supersedence-link:hover {
|
|
1674
|
+
opacity: 0.75;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1629
1677
|
/* ── Card head with stacked filters ──────────────────────────────────── */
|
|
1630
1678
|
.card-head.card-head-stack {
|
|
1631
1679
|
flex-direction: column;
|
|
@@ -1678,8 +1726,6 @@ a.session-label:hover { color: var(--accent); }
|
|
|
1678
1726
|
|
|
1679
1727
|
.thread-filters .filter-group { flex-wrap: wrap; }
|
|
1680
1728
|
|
|
1681
|
-
.empty-row { padding: 16px 14px; }
|
|
1682
|
-
|
|
1683
1729
|
.pagination {
|
|
1684
1730
|
display: flex;
|
|
1685
1731
|
align-items: center;
|
|
@@ -1764,3 +1810,81 @@ a.session-label:hover { color: var(--accent); }
|
|
|
1764
1810
|
.pagination.pagination-compact .page-indicator { min-width: 40px; padding: 0 4px; font-size: var(--text-xs); }
|
|
1765
1811
|
.pagination.pagination-compact .chip { padding: 1px 6px; font-size: var(--text-xs); }
|
|
1766
1812
|
.pagination.pagination-compact .form-label { font-size: 9px; }
|
|
1813
|
+
|
|
1814
|
+
/* ── River date label month boundary ───────────────────────────────────── */
|
|
1815
|
+
.river-date-cell--month-start {
|
|
1816
|
+
border-left: 1px solid var(--border-2);
|
|
1817
|
+
padding-left: 3px;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
/* ── River legend ────────────────────────────────────────────────────────── */
|
|
1821
|
+
.river-legend { display: flex; align-items: center; gap: 3px; }
|
|
1822
|
+
.river-legend-cell { width: 12px; height: 12px; border-radius: 2px; background: var(--surface-2); }
|
|
1823
|
+
.river-legend-cell.tier-1 { background: rgba(232,255,110,0.20); }
|
|
1824
|
+
.river-legend-cell.tier-2 { background: rgba(232,255,110,0.40); }
|
|
1825
|
+
.river-legend-cell.tier-3 { background: rgba(232,255,110,0.65); }
|
|
1826
|
+
.river-legend-cell.tier-4 { background: var(--accent); }
|
|
1827
|
+
|
|
1828
|
+
/* ── Empty row ───────────────────────────────────────────────────────────── */
|
|
1829
|
+
.empty-row { color: var(--text-2); padding: 16px 0; font-size: var(--text-sm); }
|
|
1830
|
+
|
|
1831
|
+
/* ── Session meta 2-line wrap ───────────────────────────────────────────── */
|
|
1832
|
+
.session-meta {
|
|
1833
|
+
display: -webkit-box;
|
|
1834
|
+
-webkit-line-clamp: 2;
|
|
1835
|
+
line-clamp: 2;
|
|
1836
|
+
-webkit-box-orient: vertical;
|
|
1837
|
+
overflow: hidden;
|
|
1838
|
+
white-space: normal;
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
/* ── Search clear button ─────────────────────────────────────────────────── */
|
|
1842
|
+
.search-wrap { position: relative; display: flex; align-items: center; }
|
|
1843
|
+
.search-wrap .search-input { padding-right: 28px; width: 100%; }
|
|
1844
|
+
.search-clear { position: absolute; right: 6px; background: none; border: none; color: var(--text-3); cursor: pointer; display: flex; align-items: center; padding: 0; }
|
|
1845
|
+
.search-clear:hover { color: var(--text-1); }
|
|
1846
|
+
|
|
1847
|
+
/* ── Drawer nav buttons ──────────────────────────────────────────────────── */
|
|
1848
|
+
.drawer-nav {
|
|
1849
|
+
display: flex;
|
|
1850
|
+
gap: 4px;
|
|
1851
|
+
align-items: center;
|
|
1852
|
+
flex-shrink: 0;
|
|
1853
|
+
}
|
|
1854
|
+
.drawer-nav-btn {
|
|
1855
|
+
background: none;
|
|
1856
|
+
border: 1px solid var(--border-2);
|
|
1857
|
+
color: var(--text-3);
|
|
1858
|
+
cursor: pointer;
|
|
1859
|
+
display: flex;
|
|
1860
|
+
align-items: center;
|
|
1861
|
+
justify-content: center;
|
|
1862
|
+
padding: 3px;
|
|
1863
|
+
border-radius: var(--r-sm);
|
|
1864
|
+
transition: color var(--ease), border-color var(--ease), background var(--ease);
|
|
1865
|
+
}
|
|
1866
|
+
.drawer-nav-btn:hover:not(:disabled) { color: var(--text-1); border-color: var(--border-3); background: var(--surface-2); }
|
|
1867
|
+
.drawer-nav-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
|
1868
|
+
|
|
1869
|
+
/* ── Dot pulse animation ─────────────────────────────────────────────────── */
|
|
1870
|
+
.dot-pulse {
|
|
1871
|
+
animation: dot-pulse 2s ease-in-out infinite;
|
|
1872
|
+
}
|
|
1873
|
+
@keyframes dot-pulse {
|
|
1874
|
+
0%, 100% { opacity: 1; transform: scale(1); }
|
|
1875
|
+
50% { opacity: 0.6; transform: scale(1.35); }
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
/* ── Search page additions ───────────────────────────────────────────────── */
|
|
1879
|
+
.search-hint { margin: 8px 0 12px; }
|
|
1880
|
+
|
|
1881
|
+
.search-header { position: sticky; top: 0; z-index: 10; background: var(--surface-0); padding-bottom: 12px; border-bottom: 1px solid var(--border-1); margin-bottom: 16px; }
|
|
1882
|
+
|
|
1883
|
+
.session-row.is-active { background: var(--surface-2); border-left: 2px solid var(--accent); padding-left: 12px; }
|
|
1884
|
+
|
|
1885
|
+
.live-tag[data-kind="summary"] { background: var(--surface-2); color: var(--text-2); }
|
|
1886
|
+
.live-tag[data-kind="label"] { background: var(--surface-2); color: var(--text-1); }
|
|
1887
|
+
.live-tag[data-kind="entity"] { background: var(--accent-glow); border-color: rgba(232,255,110,0.25); color: var(--accent); }
|
|
1888
|
+
|
|
1889
|
+
.match-snippet { color: var(--text-2); font-size: var(--text-xs); line-height: var(--lh-base); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-top: 4px; }
|
|
1890
|
+
.match-snippet mark { background: rgba(232,255,110,0.15); color: var(--accent); padding: 0 2px; border-radius: 2px; }
|
|
@@ -58,3 +58,43 @@ describe("SqliteSessionStore.getByIds", () => {
|
|
|
58
58
|
expect(s1?.body).toBe("");
|
|
59
59
|
});
|
|
60
60
|
});
|
|
61
|
+
|
|
62
|
+
describe("SqliteSessionStore.getById — supersedence edges", () => {
|
|
63
|
+
let tmp: string;
|
|
64
|
+
let store: SqliteSessionStore;
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
tmp = mkdtempSync(join(tmpdir(), "nlm-getbyid-edges-"));
|
|
68
|
+
store = new SqliteSessionStore({
|
|
69
|
+
dbPath: join(tmp, "canonical.sqlite"),
|
|
70
|
+
migrationsDir: MIGRATIONS_DIR,
|
|
71
|
+
});
|
|
72
|
+
store.insertSessionForTest(makeSession({ id: "old", label: "old session" }));
|
|
73
|
+
store.insertSessionForTest(makeSession({ id: "new", label: "new session" }));
|
|
74
|
+
store.insertEdgeForTest("new", "old", "supersedes");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
store.close();
|
|
79
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("superseding session reports what it supersedes", async () => {
|
|
83
|
+
const s = await store.getById("new");
|
|
84
|
+
expect(s?.supersedes).toEqual(["old"]);
|
|
85
|
+
expect(s?.supersededBy).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("superseded session reports what superseded it", async () => {
|
|
89
|
+
const s = await store.getById("old");
|
|
90
|
+
expect(s?.supersededBy).toBe("new");
|
|
91
|
+
expect(s?.supersedes).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("session with no edges has null supersededBy and empty supersedes", async () => {
|
|
95
|
+
store.insertSessionForTest(makeSession({ id: "unrelated", label: "standalone" }));
|
|
96
|
+
const s = await store.getById("unrelated");
|
|
97
|
+
expect(s?.supersededBy).toBeNull();
|
|
98
|
+
expect(s?.supersedes).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
});
|