Skip to content

fix(alerts): make the Slack channel picker usable at scale - #279

Open
JeremyFunk wants to merge 2 commits into
mainfrom
fix/slack-channel-search
Open

fix(alerts): make the Slack channel picker usable at scale#279
JeremyFunk wants to merge 2 commits into
mainfrom
fix/slack-channel-search

Conversation

@JeremyFunk

@JeremyFunk JeremyFunk commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Fixes three reported problems with the channel picker on the slack-bot alert destination.

1. Search was broken, now fuzzy + ranked

The Combobox root was never given the items prop, so Base UI did no filtering at all — typing narrowed nothing, and ComboboxEmpty (which keys off filteredItems) rendered its "No matching channels." line unconditionally. That's the "practically unusable" part.

New apps/web/src/components/alerts/slack-channel-search.ts ranks client-side:

  • tiers, far enough apart that a weaker match can't outrank a stronger one on length: exact > prefix > word-boundary prefix > substring > subsequence (depaler finds deploy-alerts)
  • leading # stripped on both sides, case-insensitive; within a tier, earlier index and shorter name win; ties break on bot membership then alphabetically
  • empty query = member channels first, then alphabetical
  • capped at 50 rendered rows with a "Showing the closest 50 of N channels" footer, so an absent channel reads as "keep typing"

The ranked ids go back to the Combobox as items with filter={null} (no double filtering), so the empty state and keyboard highlighting match what's rendered. No new dependency; pure function, unit-tested (10 cases).

2. Refresh button

A Refresh button next to the Channel label calls useAtomRefresh on the stable query atom and spins while in flight. The existing previousSuccess fallback keeps the list on screen, so refreshing never blanks a selection mid-pick. The "no channels returned" hint now points at it, and the popup's empty state distinguishes loading / nothing-loaded / nothing-matched.

3. Channel cap raised ~600 → ~20k

limit=200 × SLACK_MAX_CHANNEL_PAGES=3 becomes 1000/page × 20 pages. Slack's docs recommend ≤200 per page (large pages can time out), but the binding constraint here is the rate limit, not page latency: conversations.list is Tier 2 (~20 req/min), so a 10k-channel workspace needs 50 requests at 200/page (guaranteed 429s, multi-minute wait) versus 10 at 1000/page. The page count is now a runaway guard, not an intended limit — the walk runs to cursor exhaustion and logs a warning if it ever trips the cap instead of silently truncating.

429s are also handled: Slack answers them with an empty body (which previously decoded as an "unexpected payload" upstream error). We now honour Retry-After (3 attempts, clamped to 30s) and retry the same page.

Verification

  • bun typecheck — 18/18 green
  • apps/api full Vitest suite — 104 files passed, including the updated page-cap test and a new TestClock-driven 429-retry test
  • apps/web — 117/123 files pass; the 6 failures are pre-existing on main (verified by re-running them stashed): the two known jsdom-localStorage suites plus 4 alerts/model suites failing on an unrelated SchemaError: Missing key at ["environments"]

Not verified

No browser check — dev servers were off limits for this run, so the Combobox items + filter={null} wiring is verified by types and the pure-function tests only.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Review fixes

Follow-up commit addressing confirmed review findings:

  1. Selecting a private channel emptied the picker — Base UI writes the item's
    label into the input on selection; the selection check compared it against the
    bare channel name, so a private channel's (private) suffix made every pick
    look like a search for a non-existent channel. Selection detection now uses the
    same channelLabel() the popup renders.
  2. No bound on total request time — the walk could sleep for ~30 minutes
    (20 pages x 4 attempts x a 60s Retry-After) while Cloudflare's edge gave up at
    ~100s. Added a 45s overall wall-clock budget; on exhaustion it returns the pages
    collected so far and logs the truncation warning. SLACK_MAX_RETRY_AFTER_MS's
    doc comment now says what it actually bounds.
  3. Retry-After: 60 clamped to 30sconversations.list is Tier 2 with a 60s
    window, so the clamp guaranteed the retry re-429'd. Ceiling raised to 60s, now
    safe under the overall budget.
  4. Truncation banner lied on any narrowing queryvisible.length < channels.length was true whenever the query filtered, so 3 matches out of 60
    rendered "Showing the closest 50 of 60", including under "No matching channels."
    Ranking now runs one row past the cap so hitting the cap is distinguishable from
    "exactly 50 matched", and the copy no longer prints the workspace total as a
    match count.
  5. Non-2xx upstream failures unhandled — a Slack 5xx or a proxy HTML error page
    fell through to response.json and surfaced as "returned a non-JSON response".
    Non-2xx now fails with the status; 5xx is retried within the budget.
  6. Selected channel could be hidden from items — past the 50-row cap the
    Combobox couldn't render its own checkmark. The selection is now pulled into the
    unfiltered view (never into a filtered one, which would show a non-matching row).
  7. Non-matching query stranded the user — added the input's clear affordance
    (showClear) so Escape-and-reopen isn't a dead end.
  8. The 429 test could hang instead of failing — the fixed TestClock/microtask
    turn count was coupled to PGlite's macrotask behaviour, so a low guess blocked
    Fiber.join to the vitest timeout. Replaced with a condition-driven
    runInterleaved helper that fails loudly if the effect doesn't settle.

Also fixed a slack-channel-search.test.ts case that claimed to compare two prefix
matches but actually compared an exact match against a prefix. New coverage: the
time budget, the 5xx retry/report paths, selection-echo resolution, and the
truncation flag.

The slack-bot destination's channel picker had three problems: the list was
fetched once and went stale, search didn't work, and the backend truncated
large workspaces at ~600 channels.

Search: the Combobox was never given the root `items` prop, so Base UI did no
filtering at all — every channel stayed visible while typing, and
`ComboboxEmpty` (which keys off `filteredItems`) rendered unconditionally. We
now rank client-side with a small scoring function (exact > prefix >
word-boundary > substring > subsequence; `#` stripped, case-insensitive,
member channels as the tie-break), cap the rendered rows at 50 with a
"showing the closest 50 of N" footer, and hand the ranked ids back as `items`
with `filter={null}` so Base UI's empty state and keyboard highlighting agree
with what's on screen.

Refresh: a Refresh button next to the Channel label re-fetches via
`useAtomRefresh` on the stable query atom, spinning while in flight; the
previousSuccess fallback keeps the list rendered so a refresh can't blank a
selection mid-pick.

Pagination: 200/page × 3 pages became 1000/page (Slack's documented max) × 20
pages, i.e. up to ~20k channels. The binding constraint is the Tier 2 rate
limit (~20 req/min), not page latency, so the larger page is the cheaper
trade; 429s are now waited out per `Retry-After` (3 attempts, clamped to 30s)
instead of surfacing as an "unexpected payload" error, and hitting the page
cap logs a warning rather than silently truncating.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@pullfrog

pullfrog Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Your Pullfrog Router balance is exhausted.

You have a payment method on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer.

Top up balance → · Enable auto-reload →

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

🍁 Maple PR preview

The preview is ready:

Service Preview URL
Web app-pr-279.maple.dev
API api-pr-279.maple.dev
Chat chat-pr-279.maple.dev
Electric Sync sync-pr-279.maple.dev

Deployed from 1837a30 · View workflow run

Review follow-ups on the channel picker.

Frontend: selecting a *private* channel emptied the picker. Base UI writes the
item's label into the input on selection, and the "is this a selection or a
search" check compared that against the bare channel name — but a private
channel's label carries a " (private)" suffix, so the check missed, the label
stayed in `searchQuery`, and ranking found nothing. Selection detection now uses
the same `channelLabel()` the popup renders (plus the plain `#name` / `name`
forms). The truncation banner also lied: `visible.length < channels.length` is
true for *any* narrowing, so filtering 60 channels to 3 claimed "showing the
closest 50 of 60" — and it rendered under "No matching channels." too. We now
rank one row past the cap so hitting the cap is distinguishable from "exactly 50
matched", and the message no longer prints the workspace total as a match count.
The selected channel is pulled into the unfiltered view so the Combobox can
render its own checkmark past the cap, and the input gets a clear button so a
non-matching query isn't a dead end after Escape.

Backend: nothing bounded the total request time. `SLACK_MAX_RETRY_AFTER_MS`
bounds one sleep; 20 pages x 4 attempts x a 60s Retry-After is over half an hour,
while Cloudflare's edge gives up at ~100s — the Worker kept sleeping and
re-fetching for a client that was long gone. The walk now runs under a 45s
wall-clock budget and returns the pages it collected (a partial picker beats a
failed one), logging the existing truncation warning. With that in place the
Retry-After clamp rises to 60s: `conversations.list` is Tier 2 with a 60s window,
so clamping to 30s guaranteed the retry re-429'd and burned an attempt.
Non-2xx responses are now handled explicitly — a Slack 5xx or a proxy's HTML
error page fell through to `response.json` and surfaced as the unhelpful
"returned a non-JSON response"; 5xx is retried within the budget.

Tests: the 429 test drove the TestClock a fixed number of turns, so its failure
mode was `Fiber.join` blocking to the vitest timeout rather than an assertion —
it is now condition-driven via a shared `runInterleaved` helper. Adds coverage
for the budget, the 5xx paths, selection echo, and the truncation flag, and fixes
a search test that claimed to compare two prefix matches but compared an exact
match against a prefix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@pullfrog

pullfrog Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Your Pullfrog Router balance is exhausted.

You have a payment method on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer.

Top up balance → · Enable auto-reload →

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant