-
+
- {storedChannelName ? (
-
- Currently #{storedChannelName}
-
- ) : null}
+
+ {storedChannelName ? (
+
+ Currently #{storedChannelName}
+
+ ) : null}
+ {/* The list is fetched once per dialog and cached by reactivity key, so
+ a channel created in Slack a minute ago isn't in it. Re-fetch in
+ place: `refreshChannels` pokes the same atom, and the previous
+ success keeps rendering, so the list never blanks mid-pick. */}
+
+
setChannelQuery(value)}
itemToStringLabel={(value: string) => label(value)}
onValueChange={(value) => {
if (value == null) return
@@ -754,12 +817,24 @@ function SlackBotFields({
// The stored channel is shown as a value above; the placeholder stays
// the actual prompt so the field never looks pre-filled.
placeholder={channelsLoading ? "Loading channels…" : "Search channels…"}
+ // Single-select never clears the input on close, so a query that
+ // matched nothing would survive an Escape and reopen as an empty
+ // popup with no way out. The clear button is the way out.
+ showClear
className="w-full"
/>
- No matching channels.
+ {/* "Nothing matched" and "nothing loaded yet" are different problems
+ with different next steps — don't collapse them into one line. */}
+
+ {channelsLoading
+ ? "Loading channels…"
+ : channels.length === 0
+ ? "No channels loaded yet."
+ : "No matching channels."}
+
- {channels.map((channel) => (
+ {visibleChannels.map((channel) => (
#{channel.name}
@@ -777,6 +852,20 @@ function SlackBotFields({
))}
+ {/* Big workspaces run to thousands of channels; rendering them all is
+ both slow and useless. Say that the list is a top-N so an absent
+ channel reads as "keep typing", not "we don't have it".
+ `truncated` means matches were actually dropped, so this can never
+ render over an empty list — and while a query is active we don't
+ know the true match count, only that it exceeded the cap, so don't
+ print the workspace total as if it were one. */}
+ {truncated ? (
+
+ {searchQuery.trim().length > 0
+ ? `Showing the closest ${CHANNEL_RESULT_LIMIT} matches — keep typing to narrow.`
+ : `Showing ${CHANNEL_RESULT_LIMIT} of ${channels.length} channels — type to narrow.`}
+
+ ) : null}
{channelsFailed ? (
@@ -788,7 +877,8 @@ function SlackBotFields({
) : channels.length === 0 && !channelsLoading ? (
- No channels returned. Make sure the Maple bot has been added to at least one channel.
+ No channels returned. Make sure the Maple bot has been added to at least one channel,
+ then hit Refresh.
) : null}
diff --git a/apps/web/src/components/alerts/slack-channel-search.test.ts b/apps/web/src/components/alerts/slack-channel-search.test.ts
new file mode 100644
index 00000000..21db27e1
--- /dev/null
+++ b/apps/web/src/components/alerts/slack-channel-search.test.ts
@@ -0,0 +1,171 @@
+import { describe, expect, it } from "vitest"
+import {
+ CHANNEL_RESULT_LIMIT,
+ channelLabel,
+ channelPickerView,
+ rankChannels,
+ resolveSearchQuery,
+ scoreChannelName,
+} from "./slack-channel-search"
+
+const channel = (name: string, is_member = true) => ({ name, is_member })
+const names = (list: ReadonlyArray<{ name: string }>) => list.map((c) => c.name)
+
+describe("scoreChannelName", () => {
+ it("orders exact > prefix > word-boundary > substring > subsequence", () => {
+ const scores = [
+ scoreChannelName("alerts", "alerts"),
+ scoreChannelName("alerts-prod", "alerts"),
+ scoreChannelName("deploy-alerts", "alerts"),
+ scoreChannelName("stalerts", "alerts"),
+ scoreChannelName("a-long-eventful-run-tests", "alerts"),
+ ] as Array
+ expect(scores.every((s) => s !== null)).toBe(true)
+ for (let i = 1; i < scores.length; i++) expect(scores[i]!).toBeLessThan(scores[i - 1]!)
+ })
+
+ it("ignores a typed # and is case-insensitive", () => {
+ expect(scoreChannelName("General", "#gen")).toBe(scoreChannelName("general", "GEN"))
+ expect(scoreChannelName("general", "#general")).toBe(scoreChannelName("general", "general"))
+ })
+
+ it("returns null when the characters aren't all present in order", () => {
+ expect(scoreChannelName("deploy-alerts", "zzz")).toBeNull()
+ // Right characters, wrong order — subsequence is ordered on purpose.
+ expect(scoreChannelName("alerts", "strela")).toBeNull()
+ })
+
+ it("scores an empty query as a neutral match rather than no match", () => {
+ expect(scoreChannelName("anything", "")).toBe(0)
+ expect(scoreChannelName("anything", " ")).toBe(0)
+ })
+
+ it("prefers the shorter name among prefix matches", () => {
+ // Both are prefix matches and neither is exact — otherwise this would be
+ // comparing tiers (exact > prefix) and prove nothing about length.
+ expect(scoreChannelName("dep-a", "dep")).toBeGreaterThan(
+ scoreChannelName("dep-a-very-long", "dep")!,
+ )
+ })
+})
+
+describe("rankChannels", () => {
+ it("puts a fuzzy typo's intended channel first", () => {
+ const channels = [
+ channel("random"),
+ channel("design-eng"),
+ channel("deploy-alerts"),
+ channel("deploys"),
+ ]
+ expect(names(rankChannels(channels, "depaler"))[0]).toBe("deploy-alerts")
+ })
+
+ it("shows member channels first, then alphabetical, when the query is empty", () => {
+ const channels = [channel("zulu"), channel("beta", false), channel("alpha", false), channel("mike")]
+ expect(names(rankChannels(channels, ""))).toEqual(["mike", "zulu", "alpha", "beta"])
+ })
+
+ it("caps the result count", () => {
+ const channels = Array.from({ length: 500 }, (_, i) => channel(`chan-${i}`))
+ expect(rankChannels(channels, "").length).toBe(CHANNEL_RESULT_LIMIT)
+ expect(rankChannels(channels, "chan").length).toBe(CHANNEL_RESULT_LIMIT)
+ expect(rankChannels(channels, "chan", 5).length).toBe(5)
+ })
+
+ it("drops non-matching channels entirely", () => {
+ const channels = [channel("alerts"), channel("random"), channel("deploys")]
+ expect(names(rankChannels(channels, "alert"))).toEqual(["alerts"])
+ expect(rankChannels(channels, "qqqq")).toEqual([])
+ })
+
+ it("ranks a private/non-member channel by relevance, not membership, when searching", () => {
+ // Membership is only the tie-breaker — a much better name match wins.
+ const channels = [channel("alerts-archive"), channel("alerts", false)]
+ expect(names(rankChannels(channels, "alerts"))[0]).toBe("alerts")
+ })
+})
+
+const pickable = (id: string, name: string, is_private = false, is_member = true) => ({
+ id,
+ name,
+ is_private,
+ is_member,
+})
+
+describe("resolveSearchQuery", () => {
+ const publicChannel = pickable("C1", "alerts")
+ const privateChannel = pickable("C2", "alerts", true)
+
+ it("treats the rendered label of the selected channel as 'not a search'", () => {
+ expect(resolveSearchQuery(channelLabel(publicChannel), publicChannel)).toBe("")
+ // The regression: a private channel's label carries " (private)", so a
+ // bare-name compare left the query set and emptied the picker.
+ expect(channelLabel(privateChannel)).toBe("#alerts (private)")
+ expect(resolveSearchQuery(channelLabel(privateChannel), privateChannel)).toBe("")
+ })
+
+ it("also accepts the plain #name / name forms of the selection", () => {
+ expect(resolveSearchQuery("#alerts", privateChannel)).toBe("")
+ expect(resolveSearchQuery("alerts", privateChannel)).toBe("")
+ expect(resolveSearchQuery(" #Alerts (PRIVATE) ", privateChannel)).toBe("")
+ })
+
+ it("leaves real typing alone", () => {
+ expect(resolveSearchQuery("dep", publicChannel)).toBe("dep")
+ expect(resolveSearchQuery("alerts-2", publicChannel)).toBe("alerts-2")
+ expect(resolveSearchQuery("alerts", undefined)).toBe("alerts")
+ })
+})
+
+describe("channelPickerView", () => {
+ const many = (count: number) => Array.from({ length: count }, (_, i) => pickable(`C${i}`, `chan-${i}`))
+
+ it("does not claim truncation when a query merely narrows the list", () => {
+ const channels = [...many(60)]
+ // 60 channels, 3 matches — the old `visible.length < channels.length` check
+ // reported "showing the closest 50 of 60".
+ const view = channelPickerView(channels, "chan-1", null, 50)
+ expect(view.truncated).toBe(false)
+ expect(view.visible.length).toBeLessThan(50)
+ })
+
+ it("does not claim truncation when the match count exactly equals the cap", () => {
+ const view = channelPickerView(many(50), "", null, 50)
+ expect(view.visible.length).toBe(50)
+ expect(view.truncated).toBe(false)
+ })
+
+ it("claims truncation only once matches were actually dropped", () => {
+ const view = channelPickerView(many(51), "", null, 50)
+ expect(view.visible.length).toBe(50)
+ expect(view.truncated).toBe(true)
+ })
+
+ it("never reports truncation with nothing to show", () => {
+ const view = channelPickerView(many(500), "zzqqzz", null, CHANNEL_RESULT_LIMIT)
+ expect(view.visible).toEqual([])
+ expect(view.truncated).toBe(false)
+ })
+
+ it("pulls the selected channel into the unfiltered view even when it's past the cap", () => {
+ const channels = many(500)
+ const selected = channels.at(-1)!
+ const view = channelPickerView(channels, "", selected.id, 50)
+ expect(view.visible.length).toBe(50)
+ expect(view.visible[0]).toBe(selected)
+ expect(view.truncated).toBe(true)
+ })
+
+ it("leaves a filtered view honest — the selection is not forced into search results", () => {
+ const channels = [...many(60), pickable("C-sel", "totally-unrelated")]
+ const view = channelPickerView(channels, "chan-1", "C-sel", 50)
+ expect(view.visible.some((c) => c.id === "C-sel")).toBe(false)
+ })
+
+ it("doesn't duplicate a selection that already ranks", () => {
+ const channels = many(10)
+ const view = channelPickerView(channels, "", "C3", 50)
+ expect(view.visible.filter((c) => c.id === "C3").length).toBe(1)
+ expect(view.visible.length).toBe(10)
+ })
+})
diff --git a/apps/web/src/components/alerts/slack-channel-search.ts b/apps/web/src/components/alerts/slack-channel-search.ts
new file mode 100644
index 00000000..ea4fcc52
--- /dev/null
+++ b/apps/web/src/components/alerts/slack-channel-search.ts
@@ -0,0 +1,180 @@
+/**
+ * Ranking for the Slack channel picker.
+ *
+ * The picker holds every channel a workspace has (tens of thousands is a real
+ * shape), so search has to be both forgiving — people type `depaler` for
+ * `deploy-alerts` — and cheap enough to run on every keystroke over the whole
+ * list. Scoring is a single pass per channel with no allocation beyond the
+ * lowercased name, and the caller renders only the top {@link CHANNEL_RESULT_LIMIT}.
+ */
+
+/** The shape this module needs; the wire type `V2SlackChannel` is a superset. */
+export interface RankableChannel {
+ readonly name: string
+ readonly is_member: boolean
+}
+
+/** The extra fields the picker-state helpers need on top of {@link RankableChannel}. */
+export interface PickableChannel extends RankableChannel {
+ readonly id: string
+ readonly is_private: boolean
+}
+
+/** How many rows the picker renders. Beyond this the list is noise, not choice. */
+export const CHANNEL_RESULT_LIMIT = 50
+
+/**
+ * Slack channel names are already lowercase, but the list also carries display
+ * names from older workspaces, and users type `#` out of habit. Normalise both
+ * sides the same way so neither can miss a match.
+ */
+const normalize = (value: string): string => value.trim().toLowerCase().replace(/^#+/, "")
+
+/** Characters that start a new "word" inside a channel name. */
+const isBoundary = (char: string): boolean => char === "-" || char === "_" || char === "." || char === " "
+
+/**
+ * Subsequence match: every query char appears in order. Returns a penalty
+ * proportional to how scattered the match is (0 = contiguous), or `null` when
+ * the chars don't all appear.
+ */
+const subsequencePenalty = (name: string, query: string): number | null => {
+ let nameIndex = 0
+ let gaps = 0
+ let firstIndex = -1
+ for (const char of query) {
+ const found = name.indexOf(char, nameIndex)
+ if (found === -1) return null
+ if (firstIndex === -1) firstIndex = found
+ else gaps += found - nameIndex
+ nameIndex = found + 1
+ }
+ return firstIndex + gaps
+}
+
+/**
+ * Score a channel name against a query. Higher is better; `null` means no match.
+ *
+ * The tiers are deliberately far apart so a weaker match can never outrank a
+ * stronger one on length alone: exact > prefix > word-boundary prefix >
+ * substring > subsequence. Within a tier, earlier and shorter wins.
+ */
+export const scoreChannelName = (name: string, query: string): number | null => {
+ const haystack = normalize(name)
+ const needle = normalize(query)
+ if (needle.length === 0) return 0
+ if (haystack === needle) return 10_000
+ if (haystack.startsWith(needle)) return 9_000 - Math.min(haystack.length, 500)
+
+ const index = haystack.indexOf(needle)
+ if (index !== -1) {
+ // `deploy-alerts` should surface for `alerts` ahead of `stale-alerting`:
+ // a match that starts a word beats one that lands mid-word.
+ const atWordStart = isBoundary(haystack[index - 1] ?? "")
+ const base = atWordStart ? 8_000 : 7_000
+ return base - Math.min(index, 400) - Math.min(haystack.length, 500) / 100
+ }
+
+ const penalty = subsequencePenalty(haystack, needle)
+ if (penalty === null) return null
+ return 6_000 - Math.min(penalty, 500) - Math.min(haystack.length, 500) / 100
+}
+
+/**
+ * Filter + rank + cap the channel list for a query.
+ *
+ * With an empty query this is the "just opened the picker" view: channels the
+ * bot is already in first (those are the ones that will actually work without
+ * an `/invite`), then alphabetical. Ties at equal score resolve the same way,
+ * so ranking never reshuffles arbitrarily between keystrokes.
+ */
+export const rankChannels = (
+ channels: ReadonlyArray,
+ query: string,
+ limit: number = CHANNEL_RESULT_LIMIT,
+): Array => {
+ const scored: Array<{ channel: T; score: number }> = []
+ for (const channel of channels) {
+ const score = scoreChannelName(channel.name, query)
+ if (score !== null) scored.push({ channel, score })
+ }
+ scored.sort((a, b) => {
+ if (b.score !== a.score) return b.score - a.score
+ if (a.channel.is_member !== b.channel.is_member) return a.channel.is_member ? -1 : 1
+ return a.channel.name.localeCompare(b.channel.name)
+ })
+ return scored.slice(0, Math.max(limit, 0)).map((entry) => entry.channel)
+}
+
+/**
+ * The exact string the Combobox shows for a channel — in the popup row, and,
+ * once a channel is picked, written by Base UI into the input itself.
+ *
+ * Selection detection ({@link resolveSearchQuery}) *must* compare against this
+ * and not against the bare name: a private channel's label carries a
+ * ` (private)` suffix, and comparing against `name` alone made every private
+ * pick look like a search for a channel that doesn't exist.
+ */
+export const channelLabel = (channel: Pick): string =>
+ channel.is_private ? `#${channel.name} (private)` : `#${channel.name}`
+
+/**
+ * Base UI writes the selected item's label into the input, which fires
+ * `onInputValueChange` — indistinguishable from typing unless we recognise the
+ * label. Anything the input could plausibly be holding *because of* a selection
+ * counts: the rendered label, and the plain `#name` / `name` forms so a stored
+ * value or a manual retype of the name doesn't wipe the list either.
+ */
+const selectionEchoes = (channel: Pick): ReadonlyArray => [
+ channelLabel(channel),
+ `#${channel.name}`,
+ channel.name,
+]
+
+/**
+ * The query to actually search on: empty when the input merely echoes the
+ * current selection, otherwise what the user typed.
+ */
+export const resolveSearchQuery = (
+ input: string,
+ selected: Pick | undefined,
+): string => {
+ if (selected === undefined) return input
+ const typed = input.trim().toLowerCase()
+ return selectionEchoes(selected).some((echo) => echo.toLowerCase() === typed) ? "" : input
+}
+
+export interface ChannelPickerView {
+ /** The rows to render, and the `items` handed to Base UI. */
+ readonly visible: ReadonlyArray
+ /** True only when matches were actually dropped by the cap. */
+ readonly truncated: boolean
+}
+
+/**
+ * What the popup should show for a query.
+ *
+ * Two things the naive `visible.length < channels.length` version got wrong:
+ * ranking one row past the cap is the only way to tell "we dropped matches"
+ * apart from "exactly {@link CHANNEL_RESULT_LIMIT} channels matched" (any
+ * narrowing query made the old check true, so filtering 60 channels to 3
+ * claimed 50 were shown); and with the cap in play the selected channel is
+ * often outside the top N, which leaves the Combobox unable to render its own
+ * checkmark. On the unfiltered view we pull the selection in explicitly —
+ * never on a filtered one, where showing a non-matching row would be a lie.
+ */
+export const channelPickerView = (
+ channels: ReadonlyArray,
+ searchQuery: string,
+ selectedId: string | null,
+ limit: number = CHANNEL_RESULT_LIMIT,
+): ChannelPickerView => {
+ const ranked = rankChannels(channels, searchQuery, limit + 1)
+ const truncated = ranked.length > limit
+ const visible = truncated ? ranked.slice(0, limit) : ranked
+ if (searchQuery.trim().length > 0 || selectedId === null || limit < 1) return { visible, truncated }
+ if (visible.some((channel) => channel.id === selectedId)) return { visible, truncated }
+ const selected = channels.find((channel) => channel.id === selectedId)
+ if (selected === undefined) return { visible, truncated }
+ return { visible: [selected, ...visible.slice(0, limit - 1)], truncated }
+}