From 3e8e8f3ce23c175909f1a890e97d7a129c306f5c Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:26:13 +0200 Subject: [PATCH 1/3] fix(ci): auto-merge points at a redirect again, and now something checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repos moved to the `bitbaum` organisation, so `catomean/dotfiles` — itself the fix for the `maonakamoto` rename two days ago — became a redirect in turn. Every auto-merge run since has failed, so nothing merges and nothing deploys. Same invisible failure as before. GitHub redirects a renamed owner for the REST API and for git remotes, so `gh api repos/catomean/dotfiles` answers happily and `git push` works; the Actions resolver is the one consumer that does not follow the redirect. It dies before any step exists — "This run likely failed because of a workflow file issue", no readable log — while pull requests stay green, clean and mergeable. The red run is on main, under a workflow nobody opens. Twice is where the rule says stop fixing instances. check-workflow-refs asks the REST API what each referenced repo is really called and fails when that disagrees with what the workflow says. That disagreement IS the bug: REST resolves the redirect, Actions does not. A static allowlist cannot do this — after a rename the workflow and the allowlist would hold the same stale name and agree with each other. It runs in CI rather than in `npm run verify`, because verify is the offline SSOT bundle and this needs the API to answer; without a token it skips rather than passing quietly. Proven by mutation: restoring `catomean/dotfiles` fails the check with the canonical name to use. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5 --- .github/workflows/auto-merge.yml | 4 +- .github/workflows/ci.yml | 17 +++++ CLAUDE.md | 2 +- scripts/ci/check-workflow-refs.mjs | 108 +++++++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 scripts/ci/check-workflow-refs.mjs diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml index f5b5bbc98..f4c6194b2 100644 --- a/.github/workflows/auto-merge.yml +++ b/.github/workflows/auto-merge.yml @@ -1,7 +1,7 @@ # Auto-merge — nobody is in the merge loop. # # Green, ready PRs merge themselves and deploy themselves. The policy lives in -# ONE place for the whole fleet — catomean/dotfiles, +# ONE place for the whole fleet — bitbaum/dotfiles, # scripts/ci/auto-merge-sweep.sh — and this file only says "run it, with these # settings". # @@ -42,7 +42,7 @@ permissions: jobs: sweep: - uses: catomean/dotfiles/.github/workflows/auto-merge-sweep.yml@master + uses: bitbaum/dotfiles/.github/workflows/auto-merge-sweep.yml@master with: base_branch: main ci_workflow: ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2b4c5369..b0f8584f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,23 @@ jobs: - name: Install dependencies run: npm ci + - name: Every external `uses:` names a canonical owner, not a redirect + # Twice in two days an owner rename stopped every merge and deploy in + # this repo: `maonakamoto` → `catomean` (2026-08-26), then the move to + # the `bitbaum` org (2026-08-28). REST and git follow a rename redirect, + # so every normal way of checking says the reference is fine; the + # Actions resolver does not follow it and dies before any step exists, + # with no readable log. PRs stay green and clean the whole time — the + # red run is on main, under a workflow nobody opens. + # + # Deliberately NOT part of `npm run verify`: verify is the local/CI SSOT + # bundle and must stay offline and deterministic. This one needs the API + # to answer what a repo is really called, so it lives here and no-ops + # without a token. + env: + GITHUB_TOKEN: ${{ github.token }} + run: node scripts/ci/check-workflow-refs.mjs + - name: Verify (docs + type-check + routes + lint + unit — same as local) # SSOT for "green": the pre-build gate is defined ONCE as the `verify` # npm script and called verbatim here, so `npm run verify` locally and CI diff --git a/CLAUDE.md b/CLAUDE.md index e4a91c423..1d3e0ed45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ push branch → open PR → CI green → auto-merge.yml squash-merges it `.github/workflows/auto-merge.yml` calls the fleet's canonical sweep — the policy no longer lives in this repo. It is defined once in -`maonakamoto/dotfiles`, `scripts/ci/auto-merge-sweep.sh`, and a fix made to a +`bitbaum/dotfiles`, `scripts/ci/auto-merge-sweep.sh`, and a fix made to a local copy here would reach nobody. The sweep merges **one** PR per sweep, and only when: it is not a draft, carries no hold label, every check has finished green, GitHub calls it cleanly mergeable, and main's own CI is currently green. One car per sweep is deliberate — a PR's diff --git a/scripts/ci/check-workflow-refs.mjs b/scripts/ci/check-workflow-refs.mjs new file mode 100644 index 000000000..1e2df7821 --- /dev/null +++ b/scripts/ci/check-workflow-refs.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +/** + * Every `uses:` that points at another repository must name that repository's + * CANONICAL owner — not a name that merely redirects to it. + * + * This exists because the same outage happened twice in two days: + * + * 2026-08-26 the account `maonakamoto` was renamed to `catomean` + * 2026-08-28 the repos moved to the organisation `bitbaum` + * + * Both times every merge and every deploy in this repo stopped, and both times + * nothing looked wrong. GitHub redirects a renamed owner for the REST API and + * for git remotes — `gh api repos//dotfiles` still answers, `git push` to + * the old remote still works — so every way a human normally checks says the + * reference is fine. The Actions resolver is the one consumer that does NOT + * follow the redirect. It fails before any step exists, with "This run likely + * failed because of a workflow file issue" and no readable log. + * + * The signal is the dangerous shape: pull requests stay GREEN, mergeable and + * clean. The red run is on main, under a workflow nobody opens. Work simply + * stops shipping. + * + * THE CHECK: ask the REST API what each referenced repo is really called. That + * is precisely the discrepancy — REST resolves the redirect and reports the + * canonical `full_name`, Actions does not resolve it at all. If those two + * disagree, the workflow is already broken, whether or not it has run yet. + * + * A static allowlist could not do this: after a rename the workflow file and + * the allowlist would both hold the same stale name and agree with each other. + * Only asking GitHub what the repo is called today can tell. + * + * Runs in CI, where GITHUB_TOKEN is present. Skips (exit 0) without a token so + * a local `npm run verify` does not depend on the network — CI is where this + * has to hold. + */ + +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const WORKFLOW_DIR = '.github/workflows'; +const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + +if (!token) { + console.log('[check-workflow-refs] no GITHUB_TOKEN — skipping (this gate runs in CI)'); + process.exit(0); +} + +/** + * `uses: owner/repo/path@ref` and `uses: owner/repo@ref`. + * Local (`./.github/...`) and container (`docker://`) references have no owner + * to be wrong about, so they are not matched. + */ +const USES = /^\s*uses:\s*([A-Za-z0-9][\w.-]*)\/([\w.-]+)(?:\/[^@\s]+)?@/gm; + +const refs = new Map(); // "owner/repo" -> Set of workflow files + +for (const file of readdirSync(WORKFLOW_DIR).filter(f => /\.ya?ml$/.test(f))) { + const text = readFileSync(join(WORKFLOW_DIR, file), 'utf8'); + for (const [, owner, repo] of text.matchAll(USES)) { + const key = `${owner}/${repo}`; + refs.set(key, (refs.get(key) ?? new Set()).add(file)); + } +} + +const problems = []; +let checked = 0; + +for (const [ref, files] of refs) { + const res = await fetch(`https://api.github.com/repos/${ref}`, { + headers: { + authorization: `Bearer ${token}`, + accept: 'application/vnd.github+json', + 'user-agent': 'orangecat-check-workflow-refs', + }, + }); + + if (res.status === 404) { + problems.push(`${ref} does not exist (referenced by ${[...files].join(', ')})`); + continue; + } + + if (!res.ok) { + // Rate limiting or an outage is "could not look", never "looks fine". + console.error(`[check-workflow-refs] could not resolve ${ref}: HTTP ${res.status}`); + process.exit(2); + } + + const { full_name: canonical } = await res.json(); + checked += 1; + + if (canonical.toLowerCase() !== ref.toLowerCase()) { + problems.push( + `${ref} is a REDIRECT to ${canonical} — REST and git follow it, the Actions ` + + `resolver does NOT, so ${[...files].join(', ')} will fail to load with no ` + + `usable log. Change the reference to ${canonical}.` + ); + } +} + +if (problems.length > 0) { + console.error('[check-workflow-refs] FAIL'); + for (const p of problems) { + console.error(` ${p}`); + } + process.exit(1); +} + +console.log(`[check-workflow-refs] OK — ${checked} external workflow reference(s), all canonical.`); From a9a03ed345c02359bea3cbb2af7a1a857dabf299 Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:39:07 +0200 Subject: [PATCH 2/3] fix(ci): secret scanning went off when the repo joined an org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gitleaks-action is free for personal accounts only. The move to the bitbaum org made it refuse to run — "License key is required" — so the security job went red on every PR and blocked the whole merge queue, while scanning nothing. The gitleaks CLI is MIT and has no such restriction, so this runs the scanner directly and keeps the coverage the action gave. The script guards the trap that makes a security gate worthless: gitleaks exits 0 on an EMPTY commit range, so a force-push, a missing base ref or a shallow clone reports success having examined nothing — not "no secrets" but "no look", wearing a green tick. The range is computed first and an empty one fails hard. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012dpTLxh5GJWeWTF1UEvcD5 --- .github/workflows/ci.yml | 10 +-- .../unit/domain/mention-autocomplete.test.ts | 48 ++++++++++++- scripts/ci/secret-scan.sh | 69 +++++++++++++++++++ .../mentions/MentionSuggestions.tsx | 24 ++++--- src/domain/mentions/rank.ts | 44 ++++++++++-- 5 files changed, 178 insertions(+), 17 deletions(-) create mode 100644 scripts/ci/secret-scan.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0f8584f5..28637ab7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -290,7 +290,11 @@ jobs: fetch-depth: 0 - name: Secret scan (gitleaks) - # Free for public repos; no license needed. Fails the job on any finding. + # Runs the MIT-licensed gitleaks CLI directly. gitleaks-ACTION is free + # for personal accounts only: when these repos moved into the `bitbaum` + # organisation it began refusing to run ("License key is required") and + # this job went red on every PR — blocking the merge queue while + # scanning nothing at all. The CLI has no such restriction. # # Skipped on workflow_dispatch, and only there. On push and pull_request # the action scans the incoming commits; a dispatched run has no such @@ -307,9 +311,7 @@ jobs: # triggers nothing), so without this every automated merge left main red # and CD, which chains off green CI, never deployed. if: github.event_name != 'workflow_dispatch' - uses: gitleaks/gitleaks-action@v3 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/ci/secret-scan.sh - name: Setup Node.js uses: actions/setup-node@v7 diff --git a/__tests__/unit/domain/mention-autocomplete.test.ts b/__tests__/unit/domain/mention-autocomplete.test.ts index 2eee6f636..073a951bb 100644 --- a/__tests__/unit/domain/mention-autocomplete.test.ts +++ b/__tests__/unit/domain/mention-autocomplete.test.ts @@ -125,8 +125,54 @@ describe('rankMentionSuggestions', () => { }); it('falls back to the handle when a profile has no display name', () => { - const items = rankMentionSuggestions('', [{ id: 'x', username: 'solo' }], null); + const items = rankMentionSuggestions('so', [{ id: 'x', username: 'solo' }], null); expect(items[0].name).toBe('solo'); + expect(items[0].isAnonymous).toBe(true); + }); + + // Most accounts on OrangeCat have no display name — 14 of the first 20 on + // 2026-08-28 — because handles stopped being minted from email local parts + // and NULL is the honest result. So this is the menu's common case, not an + // edge one. + describe('nameless profiles', () => { + const nameless = [ + { id: 'a', username: 'user_d58c7dccec41' }, + { id: 'b', username: 'user_09bf1419e7e7' }, + ]; + + it('offers none of them on a bare @, where they are unrecognisable hex', () => { + expect(rankMentionSuggestions('', nameless, null)).toHaveLength(0); + }); + + it('still offers the Cat on a bare @ — it is the row worth discovering', () => { + const items = rankMentionSuggestions('', nameless, cat); + expect(items).toHaveLength(1); + expect(items[0].isCat).toBe(true); + }); + + it('offers one the moment its handle is typed', () => { + const items = rankMentionSuggestions('user_d58', nameless, null); + expect(items[0].username).toBe('user_d58c7dccec41'); + }); + + it('sinks below a named person who matches just as well', () => { + const items = rankMentionSuggestions( + 'u', + [{ id: 'a', username: 'user_d58c7dccec41' }, { id: 'b', username: 'ursula', name: 'Ursula' }], + null + ); + expect(items[0].username).toBe('ursula'); + }); + + it('still wins on an exact handle match against a named person', () => { + // Demotion must not override "this is plainly the row being asked for". + const items = rankMentionSuggestions( + 'user_d58c7dccec41', + [{ id: 'b', username: 'ursula', name: 'user_d58c7dccec41 fan' }, { id: 'a', username: 'user_d58c7dccec41' }], + null + ); + expect(items[0].username).toBe('user_d58c7dccec41'); + }); }); it('honours the limit', () => { diff --git a/scripts/ci/secret-scan.sh b/scripts/ci/secret-scan.sh new file mode 100644 index 000000000..63157b77b --- /dev/null +++ b/scripts/ci/secret-scan.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# Scan the commits this run is actually about for leaked secrets. +# +# WHY NOT gitleaks-action: it is free for personal accounts only. When these +# repos moved into the `bitbaum` organisation the action began refusing to run +# — "[bitbaum] is an organization. License key is required." — and the job went +# red on every PR, blocking the whole merge queue while scanning nothing. The +# gitleaks CLI is MIT and has no such restriction, so this runs the scanner +# directly and keeps the coverage the action used to give. +# +# THE TRAP THIS GUARDS: `gitleaks` exits 0 on an EMPTY commit range. A range +# that comes out empty — a force-push, a missing base ref, a shallow clone — +# therefore reports success having examined nothing, which is the worst possible +# outcome for a security gate: not "no secrets", but "no look", wearing a green +# tick. So the range is computed first and an empty one is a hard failure. +# +# Deliberately scans a RANGE, not all of history. There are ~173 pre-existing +# findings in 2025-era commits that only a history rewrite can clear; scanning +# everything would make this permanently red and teach everyone to ignore it. +# Coverage is unaffected — every commit passes through a PR run before it can +# merge. +set -euo pipefail + +GITLEAKS_VERSION="${GITLEAKS_VERSION:-8.30.1}" + +case "${GITHUB_EVENT_NAME:-}" in + pull_request) + base="$(jq -r '.pull_request.base.sha' "$GITHUB_EVENT_PATH")" + head="$(jq -r '.pull_request.head.sha' "$GITHUB_EVENT_PATH")" + ;; + push) + base="$(jq -r '.before' "$GITHUB_EVENT_PATH")" + head="${GITHUB_SHA}" + # A new branch (and a first push) reports an all-zero "before". There is no + # range to compute, so scan just the tip commit rather than inventing one. + if [ -z "$base" ] || [ "$base" = "null" ] || [ "$base" = "0000000000000000000000000000000000000000" ]; then + base="${head}~1" + fi + ;; + *) + echo "secret-scan: unsupported event '${GITHUB_EVENT_NAME:-none}' — refusing to guess a range" >&2 + exit 2 + ;; +esac + +if ! git cat-file -e "${base}^{commit}" 2>/dev/null; then + echo "secret-scan: base commit ${base} is not in this clone (needs fetch-depth: 0)" >&2 + exit 2 +fi + +count="$(git rev-list --count "${base}..${head}")" +echo "secret-scan: ${count} commit(s) in ${base:0:8}..${head:0:8}" + +if [ "$count" -eq 0 ]; then + # Never let "nothing to scan" read as "nothing found". + echo "secret-scan: EMPTY range — gitleaks would exit 0 having scanned nothing" >&2 + exit 2 +fi + +curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | tar -xz -C /tmp gitleaks +chmod +x /tmp/gitleaks + +/tmp/gitleaks git . \ + --log-opts="${base}..${head}" \ + --redact \ + --verbose \ + --exit-code 1 diff --git a/src/components/mentions/MentionSuggestions.tsx b/src/components/mentions/MentionSuggestions.tsx index fde8beb42..a68945464 100644 --- a/src/components/mentions/MentionSuggestions.tsx +++ b/src/components/mentions/MentionSuggestions.tsx @@ -84,7 +84,13 @@ export default function MentionSuggestions({ - {item.name} + + {/* A nameless profile's `name` IS its handle, so printing both + lines gave two identical rows of hex — the most common shape + in the menu, since most accounts have no display name. Show + the handle once, in the position the eye reads first. */} + {item.isAnonymous ? `@${item.username}` : item.name} + {item.isCat && ( // `text-on-accent` rather than `text-white`: white on this // orange is 3.10:1 and fails AA (see tailwind.config.ts). @@ -93,13 +99,15 @@ export default function MentionSuggestions({ )} - {/* The handle is always shown, including for the Cat: the point of - the menu is that you learn `@cat` exists and can type it next - time without opening anything. */} - - @{item.username} - {item.isCat && ' · ask about this thread'} - + {/* For a named profile the handle is always shown, including for the + Cat: the point of the menu is that you learn `@cat` exists and + can type it next time without opening anything. */} + {!item.isAnonymous && ( + + @{item.username} + {item.isCat && ' · ask about this thread'} + + )} ))} diff --git a/src/domain/mentions/rank.ts b/src/domain/mentions/rank.ts index e87c6af44..fd7809c53 100644 --- a/src/domain/mentions/rank.ts +++ b/src/domain/mentions/rank.ts @@ -39,6 +39,17 @@ export interface MentionSuggestion { avatarUrl: string | null; /** Renders the Cat differently, and explains why it is at the top. */ isCat: boolean; + /** + * This profile has no display name, so `name` above is just the handle again. + * + * Worth a flag rather than leaving the renderer to compare the two strings: + * it is the difference between a row you can recognise and a row you cannot, + * and both the order and the markup depend on it. Measured on production + * 2026-08-28: 14 of the first 20 profiles, because the handle-retirement + * fix correctly stopped inventing display names out of email local parts — + * NULL is honest, and it means most accounts genuinely have no name yet. + */ + isAnonymous: boolean; } /** How many rows the menu shows. Enough to choose from, few enough to scan. */ @@ -63,12 +74,14 @@ function toSuggestion(profile: MentionCandidateProfile): MentionSuggestion | nul if (!username) { return null; } + const name = profile.name?.trim(); return { id: profile.id, username, - name: profile.name?.trim() || username, + name: name || username, avatarUrl: profile.avatar_url ?? null, isCat: isCatHandle(username), + isAnonymous: !name, }; } @@ -80,6 +93,12 @@ function toSuggestion(profile: MentionCandidateProfile): MentionSuggestion | nul * and handle beats display name. Ties keep the order the query returned rather * than being re-sorted alphabetically, because that order is already * newest-first and stable. + * + * A nameless profile sinks below every named one, UNLESS the query matches its + * handle — in which case it is plainly the row being asked for and leads. The + * asymmetry is the point: `user_d58c7dccec41` is unrecognisable, so offering it + * to someone who has typed nothing about it is noise, while offering it to + * someone typing `user_d58` is precisely right. */ function score(suggestion: MentionSuggestion, query: string): number { const q = normalizeUsername(query); @@ -87,7 +106,6 @@ function score(suggestion: MentionSuggestion, query: string): number { return 0; } const handle = normalizeUsername(suggestion.username); - const name = suggestion.name.toLowerCase(); if (handle === q) { return -3; @@ -95,7 +113,9 @@ function score(suggestion: MentionSuggestion, query: string): number { if (handle.startsWith(q)) { return -2; } - if (name.startsWith(query.toLowerCase())) { + // Only for a real name. For a nameless profile `name` is the handle again, so + // this would silently re-run the check above and promote a row nobody can read. + if (!suggestion.isAnonymous && suggestion.name.toLowerCase().startsWith(query.toLowerCase())) { return -1; } return 0; @@ -125,8 +145,24 @@ export function rankMentionSuggestions( // The Cat is placed deliberately, so drop it from the general pool rather // than letting the people search list it a second time further down. .filter(s => !s.isCat) + // On a bare `@` there is nothing to match on, so a nameless profile is a + // row of hex offered to someone who cannot possibly be looking for it — + // and with most accounts unnamed it is what the menu opened with. You + // cannot be searching for a name that does not exist; type any of the + // handle and they come back immediately via the prefix rules above. + .filter(s => query.length > 0 || !s.isAnonymous) .map((s, index) => ({ s, index, rank: score(s, query) })) - .sort((a, b) => a.rank - b.rank || a.index - b.index) + // Match quality first, then recognisability, then the order the query + // returned. The middle term matters because most accounts have no name: + // `@u` matches both `ursula` and `user_d58c7dccec41` as handle prefixes, + // equally well by any measure the score can see, and only one of them is a + // row a human can act on. + .sort( + (a, b) => + a.rank - b.rank || + Number(a.s.isAnonymous) - Number(b.s.isAnonymous) || + a.index - b.index + ) .map(entry => entry.s); const deduped: MentionSuggestion[] = []; From 80cba1c3ddb8d23c9c90776cf7b61a55fbed0033 Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:44:32 +0200 Subject: [PATCH 3/3] chore: move the mention-menu changes to their own branch They rode in on a branch switch and were swept up by `git add -A`. Unrelated to the CI fixes; they belong with the Cat handle work. Co-Authored-By: Claude Opus 5 --- .../unit/domain/mention-autocomplete.test.ts | 48 +------------------ .../mentions/MentionSuggestions.tsx | 24 ++++------ src/domain/mentions/rank.ts | 44 ++--------------- 3 files changed, 13 insertions(+), 103 deletions(-) diff --git a/__tests__/unit/domain/mention-autocomplete.test.ts b/__tests__/unit/domain/mention-autocomplete.test.ts index 073a951bb..2eee6f636 100644 --- a/__tests__/unit/domain/mention-autocomplete.test.ts +++ b/__tests__/unit/domain/mention-autocomplete.test.ts @@ -125,54 +125,8 @@ describe('rankMentionSuggestions', () => { }); it('falls back to the handle when a profile has no display name', () => { - const items = rankMentionSuggestions('so', [{ id: 'x', username: 'solo' }], null); + const items = rankMentionSuggestions('', [{ id: 'x', username: 'solo' }], null); expect(items[0].name).toBe('solo'); - expect(items[0].isAnonymous).toBe(true); - }); - - // Most accounts on OrangeCat have no display name — 14 of the first 20 on - // 2026-08-28 — because handles stopped being minted from email local parts - // and NULL is the honest result. So this is the menu's common case, not an - // edge one. - describe('nameless profiles', () => { - const nameless = [ - { id: 'a', username: 'user_d58c7dccec41' }, - { id: 'b', username: 'user_09bf1419e7e7' }, - ]; - - it('offers none of them on a bare @, where they are unrecognisable hex', () => { - expect(rankMentionSuggestions('', nameless, null)).toHaveLength(0); - }); - - it('still offers the Cat on a bare @ — it is the row worth discovering', () => { - const items = rankMentionSuggestions('', nameless, cat); - expect(items).toHaveLength(1); - expect(items[0].isCat).toBe(true); - }); - - it('offers one the moment its handle is typed', () => { - const items = rankMentionSuggestions('user_d58', nameless, null); - expect(items[0].username).toBe('user_d58c7dccec41'); - }); - - it('sinks below a named person who matches just as well', () => { - const items = rankMentionSuggestions( - 'u', - [{ id: 'a', username: 'user_d58c7dccec41' }, { id: 'b', username: 'ursula', name: 'Ursula' }], - null - ); - expect(items[0].username).toBe('ursula'); - }); - - it('still wins on an exact handle match against a named person', () => { - // Demotion must not override "this is plainly the row being asked for". - const items = rankMentionSuggestions( - 'user_d58c7dccec41', - [{ id: 'b', username: 'ursula', name: 'user_d58c7dccec41 fan' }, { id: 'a', username: 'user_d58c7dccec41' }], - null - ); - expect(items[0].username).toBe('user_d58c7dccec41'); - }); }); it('honours the limit', () => { diff --git a/src/components/mentions/MentionSuggestions.tsx b/src/components/mentions/MentionSuggestions.tsx index a68945464..fde8beb42 100644 --- a/src/components/mentions/MentionSuggestions.tsx +++ b/src/components/mentions/MentionSuggestions.tsx @@ -84,13 +84,7 @@ export default function MentionSuggestions({ - - {/* A nameless profile's `name` IS its handle, so printing both - lines gave two identical rows of hex — the most common shape - in the menu, since most accounts have no display name. Show - the handle once, in the position the eye reads first. */} - {item.isAnonymous ? `@${item.username}` : item.name} - + {item.name} {item.isCat && ( // `text-on-accent` rather than `text-white`: white on this // orange is 3.10:1 and fails AA (see tailwind.config.ts). @@ -99,15 +93,13 @@ export default function MentionSuggestions({ )} - {/* For a named profile the handle is always shown, including for the - Cat: the point of the menu is that you learn `@cat` exists and - can type it next time without opening anything. */} - {!item.isAnonymous && ( - - @{item.username} - {item.isCat && ' · ask about this thread'} - - )} + {/* The handle is always shown, including for the Cat: the point of + the menu is that you learn `@cat` exists and can type it next + time without opening anything. */} + + @{item.username} + {item.isCat && ' · ask about this thread'} + ))} diff --git a/src/domain/mentions/rank.ts b/src/domain/mentions/rank.ts index fd7809c53..e87c6af44 100644 --- a/src/domain/mentions/rank.ts +++ b/src/domain/mentions/rank.ts @@ -39,17 +39,6 @@ export interface MentionSuggestion { avatarUrl: string | null; /** Renders the Cat differently, and explains why it is at the top. */ isCat: boolean; - /** - * This profile has no display name, so `name` above is just the handle again. - * - * Worth a flag rather than leaving the renderer to compare the two strings: - * it is the difference between a row you can recognise and a row you cannot, - * and both the order and the markup depend on it. Measured on production - * 2026-08-28: 14 of the first 20 profiles, because the handle-retirement - * fix correctly stopped inventing display names out of email local parts — - * NULL is honest, and it means most accounts genuinely have no name yet. - */ - isAnonymous: boolean; } /** How many rows the menu shows. Enough to choose from, few enough to scan. */ @@ -74,14 +63,12 @@ function toSuggestion(profile: MentionCandidateProfile): MentionSuggestion | nul if (!username) { return null; } - const name = profile.name?.trim(); return { id: profile.id, username, - name: name || username, + name: profile.name?.trim() || username, avatarUrl: profile.avatar_url ?? null, isCat: isCatHandle(username), - isAnonymous: !name, }; } @@ -93,12 +80,6 @@ function toSuggestion(profile: MentionCandidateProfile): MentionSuggestion | nul * and handle beats display name. Ties keep the order the query returned rather * than being re-sorted alphabetically, because that order is already * newest-first and stable. - * - * A nameless profile sinks below every named one, UNLESS the query matches its - * handle — in which case it is plainly the row being asked for and leads. The - * asymmetry is the point: `user_d58c7dccec41` is unrecognisable, so offering it - * to someone who has typed nothing about it is noise, while offering it to - * someone typing `user_d58` is precisely right. */ function score(suggestion: MentionSuggestion, query: string): number { const q = normalizeUsername(query); @@ -106,6 +87,7 @@ function score(suggestion: MentionSuggestion, query: string): number { return 0; } const handle = normalizeUsername(suggestion.username); + const name = suggestion.name.toLowerCase(); if (handle === q) { return -3; @@ -113,9 +95,7 @@ function score(suggestion: MentionSuggestion, query: string): number { if (handle.startsWith(q)) { return -2; } - // Only for a real name. For a nameless profile `name` is the handle again, so - // this would silently re-run the check above and promote a row nobody can read. - if (!suggestion.isAnonymous && suggestion.name.toLowerCase().startsWith(query.toLowerCase())) { + if (name.startsWith(query.toLowerCase())) { return -1; } return 0; @@ -145,24 +125,8 @@ export function rankMentionSuggestions( // The Cat is placed deliberately, so drop it from the general pool rather // than letting the people search list it a second time further down. .filter(s => !s.isCat) - // On a bare `@` there is nothing to match on, so a nameless profile is a - // row of hex offered to someone who cannot possibly be looking for it — - // and with most accounts unnamed it is what the menu opened with. You - // cannot be searching for a name that does not exist; type any of the - // handle and they come back immediately via the prefix rules above. - .filter(s => query.length > 0 || !s.isAnonymous) .map((s, index) => ({ s, index, rank: score(s, query) })) - // Match quality first, then recognisability, then the order the query - // returned. The middle term matters because most accounts have no name: - // `@u` matches both `ursula` and `user_d58c7dccec41` as handle prefixes, - // equally well by any measure the score can see, and only one of them is a - // row a human can act on. - .sort( - (a, b) => - a.rank - b.rank || - Number(a.s.isAnonymous) - Number(b.s.isAnonymous) || - a.index - b.index - ) + .sort((a, b) => a.rank - b.rank || a.index - b.index) .map(entry => entry.s); const deduped: MentionSuggestion[] = [];