From f7b334df9cb8baa6aa3daf13f3b60524a83d6ac9 Mon Sep 17 00:00:00 2001 From: Weigang Geng <3356786+gengwg@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:50:27 -0700 Subject: [PATCH 1/2] Keep the table inside its card in worst-first view Worst-first puts the heaviest sessions on screen together and pushed the Updated column off the right edge; widths had only been checked against the recency view, where every row is a one-turn cron job. Durations over an hour read 66h 26m rather than 3985m 54s: narrower, and legible, which the old format was not. The title column goes from 240px to 170px, having been sized for prompt text back when titles were prompt text; a shared fleet's titles are session ids. Measured on the real fleet: 1042px table in a 1042px card, Updated fully visible, two prose titles out of two hundred clipping with tooltips intact. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NZDHk4yVYP4jMZkTUMQ1Lw --- BUILDLOG.md | 16 ++++++++++++++++ package-lock.json | 2 +- packages/server/package.json | 2 +- packages/web/src/App.tsx | 10 ++++++++-- packages/web/src/styles.css | 4 ++-- 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/BUILDLOG.md b/BUILDLOG.md index 1bd85d7..98c8b30 100644 --- a/BUILDLOG.md +++ b/BUILDLOG.md @@ -574,3 +574,19 @@ keeps its width and the branch gives way first if anything has to. Checked against the real shared fleet at the browser's own width: nothing clipped, and the table still fits with no horizontal scroll, which was the constraint that made this column narrow in the first place. + +## 2026-09-07 - Worst-first pushed the table off its own edge + +Sorting by problem score put the heaviest sessions on screen at once, and the +Updated column went off the right edge of the card. The widths had only ever +been checked against the recency view, where every row is a one-turn cron job. + +Two fixes, both of which the fleet needed anyway. Durations over an hour now +read `66h 26m` rather than `3985m 54s`, which is narrower and, more to the +point, legible - nobody can read 3985 minutes. And the title column, sized at +240px back when titles were prompt text, is 170px now that a shared fleet's +titles are session ids of about eighteen monospace characters. + +Measured on the real shared fleet in worst-first: the table is 1042px in a +1042px card, Updated fully visible, and two titles out of two hundred clip - +both local sessions with real prose, which keep their tooltip. diff --git a/package-lock.json b/package-lock.json index aac810b..e00d560 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3533,7 +3533,7 @@ }, "packages/server": { "name": "@gengwg/agentlens", - "version": "0.12.2", + "version": "0.12.3", "license": "MIT", "dependencies": { "@hono/node-server": "^1.14.0", diff --git a/packages/server/package.json b/packages/server/package.json index 9c07ba8..5bdfc33 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -24,7 +24,7 @@ "tsx": "^4.20.0", "typescript": "^5.9.0" }, - "version": "0.12.2", + "version": "0.12.3", "description": "Local observability for coding-agent harnesses: fleet view and trace viewer for Claude Code, OpenCode, dsh, Cursor Agent, Antigravity, TrueForge and more.", "license": "MIT", "repository": { diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index b5b2957..45e9287 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -17,8 +17,14 @@ const fmtTokens = (n: number | null | undefined) => // more honestly than $0.00 does. const fmtCost = (n: number | null | undefined) => !n || n < 0.005 ? null : n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`; -const fmtDur = (s: number | null | undefined) => - s == null ? "-" : s >= 60 ? `${Math.floor(s / 60)}m ${Math.round(s % 60)}s` : `${Math.round(s)}s`; +// A session that ran for 3985m says nothing; 66h 26m does, and it is narrower, +// which matters because worst-first puts the longest sessions on screen at once. +const fmtDur = (s: number | null | undefined) => { + if (s == null) return "-"; + if (s < 60) return `${Math.round(s)}s`; + if (s < 3600) return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`; + return `${Math.floor(s / 3600)}h ${Math.round((s % 3600) / 60)}m`; +}; const fmtAge = (iso: string | null | undefined) => { if (!iso) return null; const diff = (Date.now() - new Date(iso).getTime()) / 1000; diff --git a/packages/web/src/styles.css b/packages/web/src/styles.css index 2b3fcc6..32aafc0 100644 --- a/packages/web/src/styles.css +++ b/packages/web/src/styles.css @@ -64,8 +64,8 @@ td { padding: 9px 8px; border-bottom: 1px solid var(--border); white-space: nowr .est { color: var(--dim); } .branch { color: var(--dim); } td .title.dim { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 11px; } -td .title { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -td .agent { display: flex; gap: 6px; max-width: 300px; } +td .title { max-width: 170px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +td .agent { display: flex; gap: 6px; max-width: 240px; } td .agent .repo { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } td .agent .branch { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 0 1 auto; min-width: 0; } tbody tr.clickable { cursor: pointer; } From 6bba104d30005ae3066bc26277725e375a07c286 Mon Sep 17 00:00:00 2001 From: Weigang Geng <3356786+gengwg@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:54:28 -0700 Subject: [PATCH 2/2] Mask titles and turn errors, and fix four review findings Masking covered events.raw and nothing else. A session title is the first eighty characters of the first prompt and a turn error is model or tool text, and both went to their own columns in clear. OpenCode showed it worst: the same error string masked through putEvent and plaintext through setTurnState, in one function. maskText now guards both columns at every writer - titles from six adapters, errors from closeTurn, failTurn and OpenCode's state update. agent_name is left alone on purpose: it is a directory basename, and masking it would only mangle repository names. Also: the problem score divided two integers, so 14 tool calls over 3 turns scored 4 rather than 4.67; "show more" kept offering more past the server's 2000-row cap, reachable at 3,179 sessions; a port in use crashed with a stack trace instead of naming the port; and the README still said the shared server was not built yet. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NZDHk4yVYP4jMZkTUMQ1Lw --- BUILDLOG.md | 29 +++++++++++++++++ README.md | 8 +++-- package-lock.json | 2 +- packages/server/package.json | 2 +- packages/server/src/db.ts | 16 +++++++--- packages/server/src/index.ts | 10 +++++- packages/server/src/mcp.ts | 9 ++++++ packages/server/src/redact-secrets.ts | 5 +++ packages/server/src/sources/claude-code.ts | 5 +-- packages/server/src/sources/emit.ts | 5 +-- packages/server/src/sources/opencode.ts | 3 +- packages/server/test/redact-secrets.test.ts | 35 +++++++++++++++++++++ packages/web/src/App.tsx | 22 +++++++++---- 13 files changed, 130 insertions(+), 21 deletions(-) diff --git a/BUILDLOG.md b/BUILDLOG.md index 98c8b30..bc6e240 100644 --- a/BUILDLOG.md +++ b/BUILDLOG.md @@ -590,3 +590,32 @@ titles are session ids of about eighteen monospace characters. Measured on the real shared fleet in worst-first: the table is 1042px in a 1042px card, Updated fully visible, and two titles out of two hundred clip - both local sessions with real prose, which keep their tooltip. + +## 2026-09-07 - An outside review, and where it was right + +An external review of the tree. Its headline finding was correct and is the +kind of gap worth having someone else find: masking covered `events.raw` and +nothing else. A session title is the first eighty characters of the first +prompt, and a turn error is model or tool text, and both were written to their +own columns in clear. The sharpest illustration was OpenCode, where the same +error string went through `putEvent` masked and into `turns.error` in the +clear, in the same function. + +Masking now covers both columns, through `maskText` at every writer: session +titles from six adapters, turn errors from `closeTurn`, `failTurn` and +OpenCode's own state update. `agent_name` is left alone deliberately - it is a +directory basename, and masking it would only mangle repository names. + +Also fixed from the same review: the problem score divided two integers, so +fourteen tool calls over three turns scored as four rather than 4.67; the fleet +table's "show more" kept offering more past the server's 2000-row cap, which +3,179 sessions on the shared server made reachable; a port already in use +crashed with a stack trace instead of naming the port; and the README still +claimed the shared server was "not built yet" several sections after describing +how to run one. + +Two findings did not survive checking. The claim that the `seq` backfill scans +the whole table on startup is wrong - `EXPLAIN QUERY PLAN` shows +`SEARCH events USING INDEX idx_events_seq`, because the index is created before +the backfill runs. And the uncommitted layout change it flagged was committed +before the review was read. diff --git a/README.md b/README.md index 3964fa5..38563e5 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,9 @@ Antigravity CLI records no token usage, and only conversations with a row in them, group under the source name. The Cursor IDE's own chats (not the CLI) are not read. Other variables: `AGENTLENS_DB` (`agentlens.db`), `PORT` (`8788`), `MCP_PORT` -(`8791`), `AGENTLENS_HOST` (`127.0.0.1`, see Shared server below). +(`8791`), `AGENTLENS_HOST` (`127.0.0.1`, see Shared server below), +`AGENTLENS_CORS_ORIGIN` (`http://localhost:5173`, only needed if you serve the +dashboard from somewhere other than the Vite dev server). A session records the git branch it started on, read from `.git/HEAD` in the harness's working directory - no subprocess, and it works when git is absent. @@ -291,8 +293,8 @@ infra traces. TrueForge emits no telemetry itself, so the exporter belongs here. The store and UI are harness-neutral. Adapters exist for TrueForge, Claude Code, OpenCode, dsh, Codex CLI, Gemini CLI, Roo Code, and Cline; each new harness is one file under `packages/server/src/sources/`, or a client of the ingest API. -A shared company server (per-machine shippers pushing to one AgentLens) is the -next step and is not built yet. +A shared company server, with per-machine shippers pushing metadata to one +AgentLens, is built: see "Shared server" above. ## Optional: investigator agent diff --git a/package-lock.json b/package-lock.json index e00d560..18c8e62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3533,7 +3533,7 @@ }, "packages/server": { "name": "@gengwg/agentlens", - "version": "0.12.3", + "version": "0.12.4", "license": "MIT", "dependencies": { "@hono/node-server": "^1.14.0", diff --git a/packages/server/package.json b/packages/server/package.json index 5bdfc33..b085827 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -24,7 +24,7 @@ "tsx": "^4.20.0", "typescript": "^5.9.0" }, - "version": "0.12.3", + "version": "0.12.4", "description": "Local observability for coding-agent harnesses: fleet view and trace viewer for Claude Code, OpenCode, dsh, Cursor Agent, Antigravity, TrueForge and more.", "license": "MIT", "repository": { diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index e529b16..e06cdf7 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -1,6 +1,6 @@ import Database from "better-sqlite3"; import { branchOf } from "./git-branch.js"; -import { redactEventRaw } from "./redact-secrets.js"; +import { maskText, redactEventRaw } from "./redact-secrets.js"; export const db = new Database(process.env.AGENTLENS_DB ?? "agentlens.db"); db.pragma("journal_mode = WAL"); @@ -102,7 +102,9 @@ export const upsertSession = { // A shipped session carries the sender's branch; there is no working // directory to read on the receiving machine. branch?: string | null; - }) => upsertSessionStmt.run({ ...s, cwd: undefined, branch: s.branch ?? branchOf(s.cwd) }), + // A title is the first 80 characters of the first prompt, so it can carry a + // secret exactly as a prompt can. + }) => upsertSessionStmt.run({ ...s, cwd: undefined, title: maskText(s.title), branch: s.branch ?? branchOf(s.cwd) }), }; export const sessionSource = db.prepare(`SELECT source FROM sessions WHERE id = ?`); @@ -145,12 +147,18 @@ export function sweepStaleTurns() { return sweepStmt.run().changes; } -export const upsertTurn = db.prepare(` +const upsertTurnStmt = db.prepare(` INSERT INTO turns (id, session_id, created_at, completed_at, status, error, ingested, pending_actions) VALUES (@id, @session_id, @created_at, @completed_at, @status, @error, @ingested, @pending_actions) ON CONFLICT(id) DO UPDATE SET completed_at=@completed_at, status=@status, error=@error, ingested=@ingested, pending_actions=@pending_actions `); +// A turn's error is model or tool text and can quote a key just as an event can. +export const upsertTurn = { + run: (t: Record & { error: string | null }) => + upsertTurnStmt.run({ ...t, error: maskText(t.error) }), +}; + const insertEventStmt = db.prepare(` INSERT OR IGNORE INTO events (id, session_id, turn_id, thread_id, type, created_at, raw, seq) VALUES (@id, @session_id, @turn_id, @thread_id, @type, @created_at, @raw, ${NEXT_SEQ}) @@ -214,7 +222,7 @@ const PROBLEM_SCORE = ` + MIN(COALESCE((SELECT MAX(gap) FROM ( SELECT strftime('%s', e.created_at) - LAG(strftime('%s', e.created_at)) OVER (ORDER BY e.created_at) gap FROM events e WHERE e.session_id = s.id AND e.created_at IS NOT NULL)), 0) / 60, 30) - + MIN(MAX((SELECT COUNT(*) FROM events e WHERE e.session_id = s.id AND e.type = 'tool.response') + + MIN(MAX((SELECT COUNT(*) FROM events e WHERE e.session_id = s.id AND e.type = 'tool.response') * 1.0 / MAX((SELECT COUNT(*) FROM turns t WHERE t.session_id = s.id), 1) - 10, 0), 30)`; export type SessionQuery = { diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index ee4c755..f94475d 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -45,9 +45,17 @@ function serveMain() { const host = process.env.AGENTLENS_HOST ?? "127.0.0.1"; if (host !== "127.0.0.1" && host !== "localhost") console.log(`warning: listening on ${host} with no authentication`); - serve({ fetch: app.fetch, port, hostname: host }, () => + const server = serve({ fetch: app.fetch, port, hostname: host }, () => console.log(`agentlens on http://${host === "0.0.0.0" ? "localhost" : host}:${port}`), ); + server.on("error", (err: NodeJS.ErrnoException) => { + console.error( + err.code === "EADDRINUSE" + ? `port ${port} is already in use; set PORT to something else` + : `server: ${err.message}`, + ); + process.exit(1); + }); } // `agentlens ship ...` is a client of another AgentLens, not a server. diff --git a/packages/server/src/mcp.ts b/packages/server/src/mcp.ts index e0faa4d..deb1df5 100644 --- a/packages/server/src/mcp.ts +++ b/packages/server/src/mcp.ts @@ -141,6 +141,15 @@ export function startMcpServer(port = 8791) { }); // Loopback only: the MCP tools have no auth, so binding to all interfaces // would expose session traces and the report-write tool to the LAN. + server.on("error", (err: NodeJS.ErrnoException) => { + // Usually a second AgentLens on the same machine. Say which port, and stop. + console.error( + err.code === "EADDRINUSE" + ? `mcp port ${port} is already in use; set MCP_PORT to something else` + : `mcp: ${err.message}`, + ); + process.exit(1); + }); server.listen(port, "127.0.0.1", () => console.log(`agentlens mcp on http://localhost:${port}/mcp`), ); diff --git a/packages/server/src/redact-secrets.ts b/packages/server/src/redact-secrets.ts index fc6d73e..9fb8398 100644 --- a/packages/server/src/redact-secrets.ts +++ b/packages/server/src/redact-secrets.ts @@ -60,6 +60,11 @@ export function redactSecrets(text: string): string { // with no quotes or backslashes, so masking the serialized form cannot break // the JSON. Doing it here rather than per field covers prompts, model output, // tool arguments and tool results in one pass. +// For the text columns outside events.raw: session titles and turn errors. +export function maskText(text: T): T { + return (text && redactionEnabled() ? (redactSecrets(text) as T) : text); +} + export function redactEventRaw(raw: string): string { return redactionEnabled() ? redactSecrets(raw) : raw; } diff --git a/packages/server/src/sources/claude-code.ts b/packages/server/src/sources/claude-code.ts index bc23306..98efba3 100644 --- a/packages/server/src/sources/claude-code.ts +++ b/packages/server/src/sources/claude-code.ts @@ -4,6 +4,7 @@ import { basename, join } from "node:path"; import { db, getCursor, insertEvent, setCursor, turnAt, upsertSession, upsertTurn } from "../db.js"; import { textOf, usageOf } from "./emit.js"; import type { Source } from "./types.js"; +import { maskText } from "../redact-secrets.js"; // Claude Code writes ~/.claude/projects//.jsonl, one JSON // record per line, append-only. Subagent transcripts live next to it under @@ -120,7 +121,7 @@ export function ingestRecords(ctx: Ctx, records: any[], state: FileState) { if (r.type === "ai-title" || r.type === "custom-title") { state.title = r.aiTitle ?? r.customTitle ?? state.title; - if (state.title && sessionExists.get(ctx.sessionId)) setTitle.run(state.title, ctx.sessionId); + if (state.title && sessionExists.get(ctx.sessionId)) setTitle.run(maskText(state.title), ctx.sessionId); continue; } @@ -202,7 +203,7 @@ export function ingestRecords(ctx: Ctx, records: any[], state: FileState) { }); state.usage_due = false; } - if (r.isApiErrorMessage && !ctx.threadId) failTurn.run(content.slice(0, 500), turnId); + if (r.isApiErrorMessage && !ctx.threadId) failTurn.run(maskText(content.slice(0, 500)), turnId); event(ctx, turnId, eid(ctx, r.uuid), "model.message", at, raw); continue; } diff --git a/packages/server/src/sources/emit.ts b/packages/server/src/sources/emit.ts index eac3306..c9ca47c 100644 --- a/packages/server/src/sources/emit.ts +++ b/packages/server/src/sources/emit.ts @@ -1,5 +1,6 @@ import { userInfo } from "node:os"; import { db, upsertEvent, upsertSession } from "../db.js"; +import { maskText } from "../redact-secrets.js"; // Small write helpers shared by adapters that produce the normalized // vocabulary directly (dsh, codex, gemini, roo, the ingest API). @@ -33,7 +34,7 @@ export function ensureSession(s: { branch?: string | null; }) { if (sessionExists.get(s.id)) { - if (s.title) setTitle.run(s.title, s.id); + if (s.title) setTitle.run(maskText(s.title), s.id); // A branch handed over by a shipper belongs to that session and fills a // gap on a row that arrived before branches were sent. A branch derived // from a local working directory is not backfilled here: on an old session @@ -57,7 +58,7 @@ export function ensureSession(s: { export const touch = (sessionId: string, at: string) => touchStmt.run(at, sessionId, at); export const openTurn = (id: string, sessionId: string, at: string) => openTurnStmt.run(id, sessionId, at); export const closeTurn = (id: string, status: string, at: string | null, error: string | null = null) => - closeTurnStmt.run(status, at, error, id); + closeTurnStmt.run(status, at, maskText(error), id); // Harnesses without a terminal marker: a new prompt ends whatever was running. export const closeOpenTurns = (sessionId: string, before: string) => closeOpenTurnsStmt.run(sessionId, before); diff --git a/packages/server/src/sources/opencode.ts b/packages/server/src/sources/opencode.ts index 51c7445..85a0ed7 100644 --- a/packages/server/src/sources/opencode.ts +++ b/packages/server/src/sources/opencode.ts @@ -4,6 +4,7 @@ import { basename } from "node:path"; import { db, getCursor, insertEvent, setCursor, turnAt, upsertEvent, upsertSession } from "../db.js"; import { usageOf } from "./emit.js"; import type { Source } from "./types.js"; +import { maskText } from "../redact-secrets.js"; // OpenCode keeps its state in SQLite (session / message / part tables, JSON in // `data`). Rows are updated in place while a step runs, so events are upserted @@ -161,7 +162,7 @@ export function createOpenCode(src: Database): Source { } if (status === "running") return; const completed = iso(Math.max(...replies.map((r) => r.time?.completed ?? r.time?.created ?? 0))); - setTurnState.run(status, completed, message, `oc:${userMsgId}`); + setTurnState.run(status, completed, maskText(message), `oc:${userMsgId}`); const cost = replies.reduce((n, r) => n + (r.cost ?? 0), 0); insertEvent.run({ id: `oc:${userMsgId}:done`, diff --git a/packages/server/test/redact-secrets.test.ts b/packages/server/test/redact-secrets.test.ts index c0746e1..b06dff3 100644 --- a/packages/server/test/redact-secrets.test.ts +++ b/packages/server/test/redact-secrets.test.ts @@ -4,6 +4,7 @@ import { test } from "node:test"; import { db, insertEvent, seedSession, sessionTrace, upsertEvent } from "./fixtures.ts"; const { redactSecrets } = await import("../src/redact-secrets.ts"); +const { upsertSession, upsertTurn } = await import("../src/db.ts"); // Fake credentials are assembled at runtime rather than written out. They are // invented, but they match real formats by design, and a literal in the source @@ -100,3 +101,37 @@ test("a private key block is masked whole", () => { const out = redactSecrets(`here it is:\n${pem}\nthat was it`); assert.equal(out, "here it is:\n[REDACTED:private-key]\nthat was it"); }); + +test("a secret in a session title is masked", () => { + // A first prompt that opens with a key becomes the session title. + upsertSession.run({ + id: "r-title2", + agent_name: "repo", + title: `use ${fake.githubPat} to fetch it`, + created_at: "2026-09-01T00:00:00Z", + updated_at: "2026-09-01T00:00:00Z", + created_by: "test", + source: "claude-code", + }); + const t = db.prepare(`SELECT title FROM sessions WHERE id = ?`).get("r-title2") as { title: string }; + assert.ok(!t.title.includes(fake.githubPat), "the token is not stored in the title"); + assert.ok(t.title.includes("[REDACTED:github-pat]") && t.title.includes("to fetch it")); +}); + +test("a turn error carrying a key is masked", () => { + seedSession("r-err"); + upsertTurn.run({ + id: "r-err-t1", + session_id: "r-err", + created_at: "2026-09-01T00:00:00Z", + completed_at: "2026-09-01T00:01:00Z", + status: "error", + error: `auth failed for ${fake.openaiProject}`, + ingested: 1, + pending_actions: 0, + }); + const row = db.prepare(`SELECT error FROM turns WHERE id = ?`).get("r-err-t1") as { error: string }; + assert.ok(!row.error.includes(fake.openaiProject)); + assert.ok(row.error.includes("[REDACTED:openai-project-key]")); + assert.ok(row.error.startsWith("auth failed for"), "the rest of the message survives"); +}); diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 45e9287..880bb12 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from "react"; import { api, type FleetTotals, type Report, type SessionSummary, type SourceStatus, type Trace, type TraceEvent } from "./api"; const PAGE = 200; +// The server clamps a page to this, so asking for more silently does nothing. +const MAX_ROWS = 2000; const fmtTokens = (n: number | null | undefined) => n == null @@ -199,9 +201,10 @@ export function App() { q={q} setQ={setQ} matched={matched} + atCap={limit >= MAX_ROWS} sort={sort} setSort={setSort} - onMore={() => setLimit((n) => n + PAGE)} + onMore={() => setLimit((n) => Math.min(n + PAGE, MAX_ROWS))} /> @@ -250,6 +253,7 @@ function SessionTable({ q, setQ, matched, + atCap, sort, setSort, onMore, @@ -259,6 +263,7 @@ function SessionTable({ q: string; setQ: (q: string) => void; matched: number; + atCap: boolean; sort: "recent" | "score"; setSort: (s: "recent" | "score") => void; onMore: () => void; @@ -378,11 +383,16 @@ function SessionTable({ )} - {matched > rows.length && ( - - )} + {matched > rows.length && + (atCap ? ( +
+ showing the first {rows.length} of {matched}; narrow the filter to see the rest +
+ ) : ( + + ))} ); }