fix(desktop): exclude archived channels from #/Tab composer autocomplete

Composer #-mention suggestions read the raw ChannelNavigationContext
list, which includes archived channels (e.g. merged branch channels)
alongside active ones — so an archived stream channel could surface in
the dropdown badged STREAM. Historical #channel links must still
resolve archived channels, so the fix is scoped to suggestion
generation in useChannelLinks (shared by both the Stream and Forum
composers) rather than filtering the shared channel list.

Signed-off-by: loganj <loganj@squareup.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
loganj
2026-08-16 16:26:51 +00:00
co-authored by Claude Code
parent f956e6fe06
commit 5a963b8407
2 changed files with 155 additions and 14 deletions
@@ -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"],
);
});
@@ -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<Channel, "channelType" | "archivedAt">,
): 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<string[]>(
() => 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;