diff --git a/studio/src/components/TweetsTab.jsx b/studio/src/components/TweetsTab.jsx
index 0e4fc4e..3b10cbc 100644
--- a/studio/src/components/TweetsTab.jsx
+++ b/studio/src/components/TweetsTab.jsx
@@ -2,6 +2,7 @@ import { useState, useMemo } from "react";
import { Heart, Repeat2, MessageSquare, Eye, Wand2, X } from "lucide-react";
import { Chip, SentimentChip } from "./primitives.jsx";
import { TONE_DEFS, TONE_COLOR } from "../lib/lexicons.js";
+import { MAX_VISIBLE, capNotice } from "../lib/display.js";
/* ============================================================
* TWEETS TAB
@@ -14,7 +15,7 @@ export function TweetsTab({ tweets, selection, onJumpToCaptions }) {
const [sortKey, setSortKey] = useState("engagement");
const [onlySelected, setOnlySelected] = useState(false);
- const filtered = useMemo(() => {
+ const matched = useMemo(() => {
const needle = q.trim().toLowerCase();
return tweets
.filter(
@@ -34,10 +35,12 @@ export function TweetsTab({ tweets, selection, onJumpToCaptions }) {
if (sortKey === "date")
return (b.date || "").localeCompare(a.date || "");
return 0;
- })
- .slice(0, 500);
+ });
}, [tweets, q, sentFilter, toneFilter, sortKey, onlySelected, selection]);
+ const filtered = useMemo(() => matched.slice(0, MAX_VISIBLE), [matched]);
+ const notice = capNotice(matched.length);
+
const visibleIds = useMemo(() => filtered.map((t) => t.uid), [filtered]);
const allVisibleSelected =
visibleIds.length > 0 && visibleIds.every((id) => selection.has(id));
@@ -102,10 +105,14 @@ export function TweetsTab({ tweets, selection, onJumpToCaptions }) {
Selected only
- {filtered.length} of {tweets.length}
+ {matched.length} of {tweets.length}
+ {notice && (
+
{notice}
+ )}
+
{selection.count} selected
diff --git a/studio/src/lib/display.js b/studio/src/lib/display.js
new file mode 100644
index 0000000..be48272
--- /dev/null
+++ b/studio/src/lib/display.js
@@ -0,0 +1,13 @@
+/* ============================================================
+ * DISPLAY HELPERS
+ * ============================================================ */
+
+// The Tweets tab renders at most this many rows for performance. The cap was
+// previously applied silently; capNotice surfaces it so users don't think
+// matches are missing.
+export const MAX_VISIBLE = 500;
+
+export function capNotice(matchCount, cap = MAX_VISIBLE) {
+ if (matchCount <= cap) return null;
+ return `Showing the top ${cap} of ${matchCount.toLocaleString()} matches`;
+}
diff --git a/studio/src/lib/display.test.js b/studio/src/lib/display.test.js
new file mode 100644
index 0000000..973e404
--- /dev/null
+++ b/studio/src/lib/display.test.js
@@ -0,0 +1,24 @@
+import { describe, it, expect } from "vitest";
+import { capNotice, MAX_VISIBLE } from "./display.js";
+
+describe("capNotice", () => {
+ it("returns null when the match count is at or below the cap", () => {
+ expect(capNotice(0, 500)).toBeNull();
+ expect(capNotice(500, 500)).toBeNull();
+ });
+
+ it("returns a notice when matches exceed the cap", () => {
+ expect(capNotice(501, 500)).toBe("Showing the top 500 of 501 matches");
+ });
+
+ it("formats large match counts with thousands separators", () => {
+ expect(capNotice(12345, 500)).toBe("Showing the top 500 of 12,345 matches");
+ });
+
+ it("defaults to MAX_VISIBLE when no cap is passed", () => {
+ expect(capNotice(MAX_VISIBLE)).toBeNull();
+ expect(capNotice(MAX_VISIBLE + 1)).toBe(
+ `Showing the top ${MAX_VISIBLE} of ${(MAX_VISIBLE + 1).toLocaleString()} matches`,
+ );
+ });
+});