diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx
index 46388acf..c8e7a907 100644
--- a/frontend/src/components/Channels.tsx
+++ b/frontend/src/components/Channels.tsx
@@ -18,7 +18,8 @@ import { useChannelTagActions } from "../lib/useChannelTagActions";
import { useChannelPriority } from "../lib/useChannelPriority";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import ColumnSortControl from "./ColumnSortControl";
-import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
+import FilterChipGroup from "./FilterChipGroup";
+import TagFilterChips, { type FilterChip } from "./TagFilterChips";
import ActionMenu, { type ActionMenuItem } from "./ActionMenu";
import ChannelStatsPopover from "./ChannelStatsPopover";
import Pager from "./Pager";
@@ -229,6 +230,22 @@ export default function Channels() {
const noTagOn = tagFilter.includes(NO_TAG_SENTINEL);
const toggleNoTagFilter = () =>
setFilters({ ...filters, tags: noTagOn ? [] : [NO_TAG_SENTINEL] });
+ // The tag-filter chips (user tags + the "No tag" sentinel), fed to TagFilterChips which folds any
+ // overflow into a "+N" pill so the row stays one line and the trailing "…" actions can't wrap.
+ const tagFilterChips: FilterChip[] = [
+ ...userTags.map((tg) => ({
+ key: `t${tg.id}`,
+ label: tg.name,
+ active: tagFilter.includes(String(tg.id)),
+ onToggle: () => toggleTagFilter(tg.id),
+ })),
+ {
+ key: NO_TAG_SENTINEL,
+ label: t("channels.tags.noTag"),
+ active: noTagOn,
+ onToggle: toggleNoTagFilter,
+ },
+ ];
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
useEffect(() => {
if (focusChannelName) setSearch(focusChannelName);
@@ -433,48 +450,31 @@ export default function Channels() {
{tabs}
{view === "subscribed" && (
- {/* Your tags (left) share one row with the two catalog-wide actions (right); the channel-name
- search lives in the shared top SearchBar, tag create/delete in the manager. */}
-
-
- {/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
- "Manage tags" button; the tooltip explains what tags are + that it's clickable. */}
-
-
-
- {userTags.map((tg) => (
-
- ))}
+ {/* One non-wrapping row: the TAGS editor trigger (left) + the tag-filter chip lane (folds
+ overflow into "+N") + Clear, then the catalog "…" actions pinned right. It never wraps, so the
+ "…" menu always stays on the first line. Channel-name search lives in the shared top SearchBar. */}
+
+ {/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
+ "Manage tags" button; the tooltip explains what tags are + that it's clickable. */}
+
- {tagFilter.length > 0 && (
-
- )}
-
)}
diff --git a/frontend/src/components/TagFilterChips.tsx b/frontend/src/components/TagFilterChips.tsx
new file mode 100644
index 00000000..e2efd945
--- /dev/null
+++ b/frontend/src/components/TagFilterChips.tsx
@@ -0,0 +1,128 @@
+import { useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { fitChipsWithPill } from "../lib/chipOverflow";
+import { useChipOverflowFit } from "../lib/useChipOverflowFit";
+import { filterChipClass } from "./FilterChipGroup";
+import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover";
+
+// A single-line row of toggle filter chips that collapses its overflow into a trailing "+N" pill whose
+// popover holds the rest (still toggleable) — the same compression the feed's applied-filter row uses
+// (ActiveFilterChips), sharing the same measure core (useChipOverflowFit + fitChipsWithPill). Used by
+// the channel manager's tag-filter row so it never wraps to a second line and the trailing "…" actions
+// stay pinned on the first line. Any fixed leading (the TAGS editor trigger) / trailing (Clear, the "…"
+// menu) chrome lives OUTSIDE this lane in the caller, so only the chips themselves fold into "+N".
+
+export interface FilterChip {
+ key: string;
+ label: string;
+ active: boolean;
+ onToggle: () => void;
+}
+
+function Chip({ chip, extra }: { chip: FilterChip; extra?: string }) {
+ return (
+
+ );
+}
+
+export default function TagFilterChips({
+ chips,
+ className,
+}: {
+ chips: FilterChip[];
+ className?: string;
+}) {
+ const { t } = useTranslation();
+ const rowRef = useRef(null);
+ const chipRefs = useRef>([]);
+ const pillRef = useRef(null);
+ // Re-fit when the set, the labels, OR the pressed state change (the pressed paint can change a chip's
+ // width via the `.pill` recipe, so an aria-pressed flip must re-measure too).
+ const key = chips.map((c) => `${c.key}:${c.label}:${c.active ? 1 : 0}`).join("|");
+
+ const visible = useChipOverflowFit({
+ rowRef,
+ chipsRef: chipRefs,
+ pillRef,
+ deps: key,
+ initial: chips.length,
+ decide: (g) =>
+ fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
+ });
+
+ const hidden = chips.length - visible;
+ const [open, setOpen] = useState(false);
+ const pop = useAnchoredPopover({
+ open,
+ onOpenChange: setOpen,
+ mode: "menu",
+ placement: "bottom-end",
+ });
+ // If nothing overflows anymore, the "+N" trigger unmounts — force the popover closed so it can't
+ // linger against a gone reference.
+ useEffect(() => {
+ if (hidden === 0 && open) setOpen(false);
+ }, [hidden, open]);
+
+ const moreLabel = t("channels.tags.moreCount", { count: hidden });
+
+ return (
+
+ {/* Hidden measurer: all chips + a sample "+N" pill at natural widths, so the shared fit core can
+ read each chip's right edge, the rem gap and the pill width off real boxes. */}
+