From fe7c6808e7430d185498178e07e58e378d2e4c7d Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 19 Aug 2026 12:57:25 -0400 Subject: [PATCH] fix(desktop): hide archived channels from #/Tab autocomplete (#6156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary Desktop's `#`/Tab channel autocomplete offered archived channels as if they were valid destinations. Stale channels competed with active ones in the picker, including a confusing uppercase `STREAM` entry — the badge of an archived stream channel leaking into the suggestion list. After this change, autocomplete suggests only active, non-DM channels. Links to archived channels in old messages still resolve normally. ## Demo Before/after, real archive flow: ![Demo — archived channel disappears from # autocomplete](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6156/demo-archive-autocomplete.gif) ## What changed `useChannelLinks.ts` builds the autocomplete suggestion list from the raw relay channel list and previously excluded only DMs, so archived channels slipped through. The fix filters `archivedAt === null` while generating suggestions only — the shared navigation list stays unfiltered, so resolving historical links to archived channels is unchanged. - `desktop/src/features/messages/lib/useChannelLinks.ts` — suggestion selection centralized in `selectChannelSuggestions`; excludes archived channels and DMs; keeps the existing eight-result limit. - `desktop/src/features/messages/lib/useChannelLinks.test.mjs` — new tests: archived stream/forum excluded, active ones included, DMs excluded, mixed lists. ## Validation At exact head `5a963b8407bd48f40d992e026d0a841d2445647f`: `pnpm check`, `pnpm typecheck`, and the full Desktop unit suite (4,960 passed). Smoke E2E run compared against exact base `f956e6fe`: the only failures are two pre-existing failures that reproduce on base and four documented pass-on-retry flakes — none touches the autocomplete surface. Signed-off-by: loganj Co-authored-by: Claude Code --- .../messages/lib/useChannelLinks.test.mjs | 121 ++++++++++++++++++ .../features/messages/lib/useChannelLinks.ts | 48 +++++-- 2 files changed, 155 insertions(+), 14 deletions(-) create mode 100644 desktop/src/features/messages/lib/useChannelLinks.test.mjs diff --git a/desktop/src/features/messages/lib/useChannelLinks.test.mjs b/desktop/src/features/messages/lib/useChannelLinks.test.mjs new file mode 100644 index 0000000000..e859c7a884 --- /dev/null +++ b/desktop/src/features/messages/lib/useChannelLinks.test.mjs @@ -0,0 +1,121 @@ +/** + * Unit tests for selectChannelSuggestions — the #/Tab channel-autocomplete + * suggestion list. + * + * Archived channels must stay resolvable in historical links elsewhere in the + * app, but must not be offered as new `#channel` suggestions (they were + * leaking through, badged STREAM/FORUM, for archived branch channels). + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { selectChannelSuggestions } from "./useChannelLinks.ts"; + +function channel(overrides = {}) { + return { + id: "chan-1", + name: "general", + channelType: "stream", + archivedAt: null, + ...overrides, + }; +} + +test("excludes an archived stream channel from suggestions", () => { + const channels = [ + channel({ + id: "s1", + name: "shipped-feature", + channelType: "stream", + archivedAt: "2026-01-01T00:00:00Z", + }), + ]; + + assert.deepEqual(selectChannelSuggestions(channels, "shipped"), []); +}); + +test("excludes an archived forum channel from suggestions", () => { + const channels = [ + channel({ + id: "f1", + name: "old-rfc", + channelType: "forum", + archivedAt: "2026-01-01T00:00:00Z", + }), + ]; + + assert.deepEqual(selectChannelSuggestions(channels, "old"), []); +}); + +test("includes an active stream channel matching the query", () => { + const channels = [ + channel({ + id: "s2", + name: "engineering", + channelType: "stream", + archivedAt: null, + }), + ]; + + assert.deepEqual(selectChannelSuggestions(channels, "eng"), [ + { id: "s2", name: "engineering", channelType: "stream" }, + ]); +}); + +test("includes an active forum channel matching the query", () => { + const channels = [ + channel({ + id: "f2", + name: "design-rfcs", + channelType: "forum", + archivedAt: null, + }), + ]; + + assert.deepEqual(selectChannelSuggestions(channels, "design"), [ + { id: "f2", name: "design-rfcs", channelType: "forum" }, + ]); +}); + +test("excludes DM channels regardless of archive state", () => { + const channels = [ + channel({ id: "d1", name: "alice", channelType: "dm", archivedAt: null }), + ]; + + assert.deepEqual(selectChannelSuggestions(channels, "alice"), []); +}); + +test("mixed list keeps only active, non-DM matches", () => { + const channels = [ + channel({ + id: "s3", + name: "random", + channelType: "stream", + archivedAt: null, + }), + channel({ + id: "s4", + name: "random-old", + channelType: "stream", + archivedAt: "2026-01-01T00:00:00Z", + }), + channel({ + id: "f3", + name: "random-forum", + channelType: "forum", + archivedAt: null, + }), + channel({ + id: "d2", + name: "random-dm", + channelType: "dm", + archivedAt: null, + }), + ]; + + assert.deepEqual( + selectChannelSuggestions(channels, "random").map((s) => s.id), + ["s3", "f3"], + ); +}); diff --git a/desktop/src/features/messages/lib/useChannelLinks.ts b/desktop/src/features/messages/lib/useChannelLinks.ts index 5ffc1b8c2e..c9e573b3fc 100644 --- a/desktop/src/features/messages/lib/useChannelLinks.ts +++ b/desktop/src/features/messages/lib/useChannelLinks.ts @@ -2,6 +2,7 @@ import * as React from "react"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; +import type { Channel } from "@/shared/api/types"; import type { AutocompleteEdit } from "./useRichTextEditor"; export type ChannelSuggestion = { @@ -12,6 +13,37 @@ export type ChannelSuggestion = { const CHANNEL_QUERY_DEBOUNCE_MS = 120; +/** + * Archived channels must stay resolvable in historical links (rendered from + * the unfiltered ChannelNavigationContext list), but are dead ends for new + * `#channel` references — exclude them here, at generation time, rather than + * from the shared channel list. + */ +function isChannelSuggestable( + channel: Pick, +): boolean { + return channel.channelType !== "dm" && channel.archivedAt === null; +} + +/** Exported for unit testing. */ +export function selectChannelSuggestions( + channels: Channel[], + query: string, +): ChannelSuggestion[] { + const lowerQuery = query.toLowerCase(); + return channels + .filter( + (ch) => + isChannelSuggestable(ch) && ch.name.toLowerCase().includes(lowerQuery), + ) + .slice(0, 8) + .map((ch) => ({ + id: ch.id, + name: ch.name, + channelType: ch.channelType as "stream" | "forum", + })); +} + export function useChannelLinks() { const { channels } = useChannelNavigation(); @@ -27,7 +59,7 @@ export function useChannelLinks() { /** Channel names (original casing) for overlay highlighting. */ const knownChannelNames = React.useMemo( - () => channels.filter((ch) => ch.channelType !== "dm").map((ch) => ch.name), + () => channels.filter(isChannelSuggestable).map((ch) => ch.name), [channels], ); @@ -56,19 +88,7 @@ export function useChannelLinks() { if (channelQuery === null) { return []; } - - const lowerQuery = channelQuery.toLowerCase(); - return channels - .filter( - (ch) => - ch.channelType !== "dm" && ch.name.toLowerCase().includes(lowerQuery), - ) - .slice(0, 8) - .map((ch) => ({ - id: ch.id, - name: ch.name, - channelType: ch.channelType as "stream" | "forum", - })); + return selectChannelSuggestions(channels, channelQuery); }, [channels, channelQuery]); const isChannelOpen = channelQuery !== null && channelSuggestions.length > 0;