diff --git a/src/apps/billboard/tests/integrated/test_buds.py b/src/apps/billboard/tests/integrated/test_buds.py
index 257c057..fa2c3e1 100644
--- a/src/apps/billboard/tests/integrated/test_buds.py
+++ b/src/apps/billboard/tests/integrated/test_buds.py
@@ -142,6 +142,19 @@ class AddBudViewTest(TestCase):
response = self.client.get(reverse("billboard:add_bud"))
self.assertEqual(response.status_code, 405)
+ def test_add_returns_at_handle_and_title_for_tooltip_row(self):
+ """The async-appended My Buds row mirrors _my_buds_item.html, so the
+ payload must carry the bud's at_handle + active_title_display to fill
+ the data-tt-* attrs — without them the new row's tooltip renders empty
+ (the entries above it, server-rendered, have them). Regression
+ 2026-05-29."""
+ alice = User.objects.create(email="alice@test.io", username="alice")
+ body = self.client.post(
+ reverse("billboard:add_bud"), data={"recipient": "alice"},
+ ).json()
+ self.assertEqual(body["bud"]["at_handle"], "@alice")
+ self.assertEqual(body["bud"]["title"], alice.active_title_display)
+
def test_add_resolves_username_too_not_just_email(self):
"""Phase 2: recipient field accepts usernames as well as emails."""
alice = User.objects.create(email="alice@test.io", username="alice")
diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py
index a908c0f..df86c91 100644
--- a/src/apps/billboard/views.py
+++ b/src/apps/billboard/views.py
@@ -700,11 +700,16 @@ def add_bud(request):
already_present = candidate in request.user.buds.all()
if not already_present:
request.user.buds.add(candidate)
+ from apps.lyric.templatetags.lyric_extras import at_handle
display = candidate.username or candidate.email
bud = {
- "id": str(candidate.id),
- "username": display,
- "email": candidate.email,
+ "id": str(candidate.id),
+ "username": display,
+ "email": candidate.email,
+ # at_handle + title feed the async row's data-tt-* attrs so its
+ # tooltip matches the server-rendered rows (regression 2026-05-29).
+ "at_handle": at_handle(candidate),
+ "title": candidate.active_title_display,
}
recipient_display = display
recipient_user_id = str(candidate.id)
diff --git a/src/functional_tests/test_bill_my_buds.py b/src/functional_tests/test_bill_my_buds.py
index ac7f06a..feadc33 100644
--- a/src/functional_tests/test_bill_my_buds.py
+++ b/src/functional_tests/test_bill_my_buds.py
@@ -64,6 +64,47 @@ class MyBudsPageTest(FunctionalTest):
[],
))
+ def test_async_added_entry_matches_server_row_and_populates_tooltip(self):
+ """Regression 2026-05-29: the async-appended row used to be a stripped
+ `@handle`-only `
` — no anchor, no ` the `, no data-tt-*
+ attrs — so its tooltip rendered empty (vs. the server-rendered rows
+ above it). The appended row must mirror _my_buds_item.html exactly."""
+ self.browser.get(self.live_server_url + "/billboard/my-buds/")
+ self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn")).click()
+ recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
+ recipient.send_keys("alice@test.io")
+ self.browser.find_element(
+ By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm"
+ ).click()
+
+ sel = f".bud-entry[data-bud-id='{self.alice.id}']"
+ row = self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, sel))
+ # Carries both classes (styling + tooltip-lock both key on these).
+ cls = (row.get_attribute("class") or "").split()
+ self.assertIn("applet-list-entry", cls)
+ self.assertIn("bud-entry", cls)
+ # data-tt-* attrs the portal reads on row-lock.
+ self.assertEqual(row.get_attribute("data-tt-title"), "@alice")
+ self.assertEqual(row.get_attribute("data-tt-description"), "Earthman")
+ self.assertEqual(row.get_attribute("data-tt-email"), "alice@test.io")
+ # Anchor routes into the bud landing page; trailing ` the `.
+ anchor = row.find_element(By.CSS_SELECTOR, ".bud-name a")
+ self.assertIn(f"/billboard/buds/{self.alice.id}/", anchor.get_attribute("href"))
+ self.assertIn("the Earthman", row.find_element(
+ By.CSS_SELECTOR, ".bud-row-title"
+ ).text)
+ # Click the row (NOT the anchor) → portal locks + populates non-empty.
+ row.find_element(By.CSS_SELECTOR, ".bud-row-title").click()
+ portal = self.wait_for(lambda: self.browser.find_element(
+ By.CSS_SELECTOR, "#id_tooltip_portal.active"
+ ))
+ self.assertEqual(
+ portal.find_element(By.CSS_SELECTOR, ".tt-title").text, "@alice"
+ )
+ self.assertEqual(
+ portal.find_element(By.CSS_SELECTOR, ".tt-description").text, "Earthman"
+ )
+
def test_no_autocomplete_suggestions_on_my_buds_page(self):
"""The bud-autocomplete pool is request.user.buds — surfacing buds
you've already added on the page where you ADD new buds is just
diff --git a/src/templates/apps/billboard/_partials/_bud_add_panel.html b/src/templates/apps/billboard/_partials/_bud_add_panel.html
index d05fc8e..6bf8888 100644
--- a/src/templates/apps/billboard/_partials/_bud_add_panel.html
+++ b/src/templates/apps/billboard/_partials/_bud_add_panel.html
@@ -19,18 +19,41 @@
var empty = list.querySelector('.applet-list-entry--empty');
if (empty) empty.remove();
+ // Mirror _my_buds_item.html EXACTLY so the async row behaves like a
+ // server-rendered one: both classes (styling + tooltip-lock key on
+ // them), the data-tt-* attrs the portal reads on row-lock, the
+ // bud-page anchor, and the trailing ` the `. Earlier this
+ // built a bare `@handle` → empty tooltip + dead row.
+ // `at_handle` comes from the server (the client can't replicate the
+ // truncate_email fallback for username-less buds); fall back to a
+ // naive `@`-prefix only if the payload predates that field.
+ var handle = bud.at_handle || (function () {
+ var d = bud.username || '';
+ return d.indexOf('@') >= 0 ? d : '@' + d;
+ }());
+ var title = bud.title || '';
+
var li = document.createElement('li');
- li.className = 'bud-entry';
+ li.className = 'applet-list-entry bud-entry';
li.dataset.budId = bud.id;
+ li.dataset.ttTitle = handle;
+ li.dataset.ttDescription = title;
+ li.dataset.ttEmail = bud.email || '';
+ li.dataset.ttShoptalk = ''; // fresh bud — no BudshipNote yet
+
var name = document.createElement('span');
name.className = 'bud-name';
- // Mirror the `at_handle` template filter: prefix `@` on plain
- // usernames; leave the value untouched if it already contains
- // `@` (the server falls back to email when username is unset,
- // and emails don't take the prefix).
- var display = bud.username || '';
- name.textContent = display.indexOf('@') >= 0 ? display : '@' + display;
+ var a = document.createElement('a');
+ a.href = '/billboard/buds/' + bud.id + '/';
+ a.textContent = handle;
+ name.appendChild(a);
li.appendChild(name);
+
+ var rowTitle = document.createElement('span');
+ rowTitle.className = 'bud-row-title';
+ rowTitle.textContent = ' the ' + title;
+ li.appendChild(rowTitle);
+
var buffer = list.querySelector('.bud-entry-buffer');
if (buffer) list.insertBefore(li, buffer);
else list.appendChild(li);