commit 483487407374033ae6194ef85d336ec4dea8ad64
parent 9a3f4bb88574528cd569fc2147e754f0ce86d9ef
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 20:16:35 -0400
common: a hub result names its archive; "N of M archives answered." above the results
ResultGroup.source (hub mode only, from siteTitleOf) is shown before the channel
on the card (data-testid result-source); the accent stripe stays. When an
archive in scope failed, one line above the results says how many answered,
names the ones that did not, and offers Retry (data-testid hub-scope-status).
Single-site: no federation, no source key, nothing rendered.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 70 insertions(+), 0 deletions(-)
diff --git a/common/components/SearchResults.tsx b/common/components/SearchResults.tsx
@@ -190,6 +190,8 @@ export default function SearchResults() {
</div>
</div>
+ <FederationStatus />
+
{view !== "chart" && resultGroups.length > 0 && (
<div
data-testid="selection-toolbar"
@@ -318,6 +320,47 @@ export default function SearchResults() {
);
}
+// Hub mode only: when an archive in scope failed to answer, one plain line
+// above the results says how many did, names the ones that did not, and offers
+// a Retry for them. The results from the archives that answered render as
+// usual underneath — one member failing never empties the page. Nothing in
+// single-site mode (no `federation`), and nothing while every archive is fine
+// or still loading (the scope chips show loading).
+function FederationStatus() {
+ const { federation } = useSearchData();
+ if (!federation) return null;
+ const scoped = federation.sites.filter((s) => s.status !== "off");
+ const failed = scoped.filter((s) => s.status === "failed");
+ if (failed.length === 0) return null;
+ const answered = scoped.filter((s) => s.status === "ready").length;
+ const names = failed.map((s) => s.siteTitle);
+ const list =
+ names.length === 1
+ ? names[0]
+ : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
+ return (
+ <p
+ data-testid="hub-scope-status"
+ className="mb-2 flex flex-wrap items-baseline gap-x-2 text-sm text-muted-foreground"
+ >
+ <span>
+ {answered} of {scoped.length} archives answered. {list}{" "}
+ did not, so{" "}
+ {names.length === 1 ? "its" : "their"} videos are not in these results.
+ </span>
+ <button
+ type="button"
+ onClick={() => {
+ for (const s of failed) federation.retry(s.origin);
+ }}
+ className="text-brand transition-colors hover:underline"
+ >
+ Retry
+ </button>
+ </p>
+ );
+}
+
// ─── Result rendering ────────────────────────────────────────────────────────
type ResultRow =
| { kind: "card"; cardIndex: number; group: ResultGroup }
@@ -633,6 +676,15 @@ const ResultCard = memo(function ResultCard({
</>
)}
<span className="basis-full sm:basis-auto text-xs text-muted-foreground shrink-0">
+ {/* Hub mode: the archive this came from, in text. */}
+ {group.source && (
+ <>
+ <span data-testid="result-source" className="text-foreground/80">
+ {group.source}
+ </span>
+ {" · "}
+ </>
+ )}
{group.channel && `${group.channel} · `}
{group.date}
{group.hits.length > 0 &&
diff --git a/common/components/SearchSessionContext.tsx b/common/components/SearchSessionContext.tsx
@@ -158,6 +158,9 @@ export type ResultGroup = {
// Provenance accent of the source origin (hub mode only); undefined
// single-site, so no marker renders.
accent?: string;
+ // The source archive's title (hub mode only), named on the card beside the
+ // channel so provenance is not carried by colour alone. Absent single-site.
+ source?: string;
// Set on rows from the social-post corpus. A post is not a video: it has no
// timeline to seek, no livestream/age state and no VOD expiry, so the result
// card renders a distinct variant rather than a degraded video card.
@@ -486,6 +489,7 @@ function useSearchSessionState() {
groups: manifestGroups,
channelKeyOf,
accentOf,
+ siteTitleOf,
} = useSearchData();
const { summaries, loadedPages, pageCount, summariesReady } =
summariesState;
@@ -873,6 +877,16 @@ function useSearchSessionState() {
// those slugs that survived the query tree. For each, attach the per-leaf
// hits collected by searchEval. Hits are already sorted by start time
// within each video.
+ // `{ source }` for a hub result, `{}` single-site — so a single-site group
+ // carries no `source` key at all.
+ const sourceOf = useCallback(
+ (slug: string): { source?: string } => {
+ const title = siteTitleOf?.(splitId(slug).origin);
+ return title ? { source: title } : {};
+ },
+ [siteTitleOf],
+ );
+
const resultGroups = useMemo<ResultGroup[]>(() => {
if (!transcripts) return [];
if (!hasActiveQuery) {
@@ -897,6 +911,7 @@ function useSearchSessionState() {
? { curatedTags: t.curatedTags }
: {}),
accent: accentOf(splitId(t.slug).origin),
+ ...sourceOf(t.slug),
});
}
return out;
@@ -924,6 +939,7 @@ function useSearchSessionState() {
? { curatedTags: t.curatedTags }
: {}),
accent: accentOf(splitId(t.slug).origin),
+ ...sourceOf(t.slug),
});
}
// Matched POST slugs. They are not in `transcripts` (a disjoint namespace),
@@ -958,6 +974,7 @@ function useSearchSessionState() {
uploadDate: post.uploadDate,
hits: hitsBySlug.get(slug) ?? [],
accent: accentOf(splitId(slug).origin),
+ ...sourceOf(slug),
post,
});
}
@@ -973,6 +990,7 @@ function useSearchSessionState() {
treeProgress,
passesFilter,
accentOf,
+ sourceOf,
committedDateFrom,
committedDateTo,
committedStates,