From 63811a9aa1fc96b0b6a15ec3437fe946c94955b6 Mon Sep 17 00:00:00 2001
From: Collin Pendleton
Date: Wed, 7 Oct 2026 15:16:19 -0500
Subject: [PATCH] Explain autopilot picks in the UI (MAD-760)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
People forgive picks they understand. Each DJ pick now carries a short
reason and the music knowledge it came from, shown on now playing, up
next and the big screen.
- AutopilotInfo gains Summary and Source, written when autopilot picks:
"Because Sam's Radiohead → Portishead (similar 0.82)", "A top song by
Radiohead, an artist the room loves", "Bridging Sam's Radiohead and
Jo's Massive Attack", deep cuts, songs like a member's, throwbacks.
Source names the knowledge sources ("Last.fm, Deezer").
- The API's AutopilotPick has them as reason and source.
- The web app shows the reason in place of "Like {seed}" when there is
one: in up next, under the title in the now playing sheet (with its
source), and on /tv, both for the song playing and in up next.
- ADR 0012 notes it.
Co-Authored-By: Claude Opus 5.5
---
api/openapi.yaml | 11 ++
docs/adr/0012-smart-dj.md | 2 +-
server/internal/autopilot/autopilot.go | 4 +-
server/internal/autopilot/dj_test.go | 5 +
server/internal/autopilot/explain.go | 100 ++++++++++++++++++
server/internal/autopilot/explain_test.go | 75 +++++++++++++
server/internal/httpapi/api.gen.go | 9 ++
server/internal/httpapi/realtime.go | 5 +-
server/internal/queue/queue.go | 5 +
web/src/api/schema.gen.ts | 10 ++
web/src/components/room/autopilot-badge.tsx | 13 ++-
.../components/shell/now-playing-sheet.tsx | 3 +-
web/src/components/tv/tv-stage.tsx | 10 +-
web/src/lib/autopilot.test.ts | 10 +-
web/src/lib/autopilot.ts | 11 +-
15 files changed, 265 insertions(+), 8 deletions(-)
create mode 100644 server/internal/autopilot/explain.go
create mode 100644 server/internal/autopilot/explain_test.go
diff --git a/api/openapi.yaml b/api/openapi.yaml
index 9ec2a5b..00326c6 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -4555,6 +4555,17 @@ components:
type: string
seedArtist:
type: string
+ reason:
+ type: string
+ description: |
+ Why the DJ chose it, in a few words: "Because Sam's Radiohead →
+ Portishead (similar 0.82)", "A top song by Radiohead, an artist
+ the room loves", "Bridging Sam's Radiohead and Jo's Massive
+ Attack". Absent for a song a service recommended, or one picked
+ at random; say "Like {seedTitle}" then.
+ source:
+ type: string
+ description: The music knowledge the reason came from, for a credit, e.g. "Last.fm, Deezer".
Palette:
type: object
diff --git a/docs/adr/0012-smart-dj.md b/docs/adr/0012-smart-dj.md
index d5cfeb7..3482e6a 100644
--- a/docs/adr/0012-smart-dj.md
+++ b/docs/adr/0012-smart-dj.md
@@ -251,7 +251,7 @@ If the graph has no sources, knows nothing near the room's taste, or none of its
- The similar-artists dataset name is ListenBrainz's own and may change. If it does, ListenBrainz stops contributing similar artists until the constant is updated, and the other sources carry on.
- An artist's name can belong to more than one artist. Deezer takes the one with the most fans, and MusicBrainz the best search result.
- The first fill after a cold start knows only the artists it fetched in time. The warmer catches up from the songs being queued.
-- `AutopilotInfo.reason` is stored with each song; it isn't in the API until MAD-760.
+- `AutopilotInfo.reason` is stored with each song. The API carries a short summary of it and its sources (`AutopilotPick.reason` and `source`, MAD-760), shown on now playing, up next and the big screen. The summary names members as they were called when the song was picked.
- A room's long-term taste is the room's: it's deleted with the room. It names members by ID, and keeps an artist only while its likes or skips are above 0.005, about 15 nights of silence for one play.
- Suggestions (ADR 0010) come from the DJ too (MAD-759): the asker's taste for "your vibe", the room's for "group vibe", found only on the asker's links. Without a graph they fall back on the services' recommendations, turning artists away by the DJ's rule (`suggest.TurnedAway` reads `dj.Profile.Avoid`), so a skip fades there too (MAD-762).
- Tempo is often unknown: Deezer has a BPM for some songs and 0 for many, and few libraries tag it. Set flow treats 0 as unknown, so a room with little known about its songs plays as it did before; it flows better as the cache warms.
diff --git a/server/internal/autopilot/autopilot.go b/server/internal/autopilot/autopilot.go
index 1a287e3..241eb03 100644
--- a/server/internal/autopilot/autopilot.go
+++ b/server/internal/autopilot/autopilot.go
@@ -539,9 +539,11 @@ func (f *fill) fromGraph(ctx context.Context, users []string) []pick {
if len(users) > 0 {
fallback = users[0]
}
+ name := f.s.names(ctx)
var out []pick
for _, p := range picks {
- info := queue.AutopilotInfo{Reason: &queue.AutopilotReason{
+ summary, source := explain(p, name)
+ info := queue.AutopilotInfo{Summary: summary, Source: source, Reason: &queue.AutopilotReason{
Kind: p.Why.Kind, Via: p.Why.Via, Similarity: p.Why.Similarity, Popularity: p.Why.Popularity,
Novelty: p.Why.Novelty, Prior: p.Why.Prior, Score: p.Why.Score, DeepCut: p.Why.DeepCut, Sources: p.Why.Sources,
}}
diff --git a/server/internal/autopilot/dj_test.go b/server/internal/autopilot/dj_test.go
index d53d652..15761c8 100644
--- a/server/internal/autopilot/dj_test.go
+++ b/server/internal/autopilot/dj_test.go
@@ -5,6 +5,7 @@ package autopilot_test
import (
"context"
"fmt"
+ "strings"
"testing"
"github.com/madeofpendletonwool/syncphony/server/internal/musicgraph"
@@ -78,6 +79,10 @@ func TestDJPicksFromTheGraph(t *testing.T) {
if r == nil || r.Kind != "similar-artist" || r.Via != "The Test Patterns" || r.Popularity != 1 || r.Score <= 0 {
t.Fatalf("reason = %+v", r)
}
+ // It's explained in words (MAD-760).
+ if !strings.HasPrefix(info.Summary, "Because alice's The Test Patterns → Null Island (similar") || info.Source != "test" {
+ t.Errorf("summary = %q, source %q", info.Summary, info.Source)
+ }
// It planned what might follow, and the room's energy curve is on.
if r.Flow == nil || len(r.Flow.Ahead) == 0 || r.Flow.Target == nil {
t.Errorf("flow = %+v: want a plan ahead, and the curve's target", r.Flow)
diff --git a/server/internal/autopilot/explain.go b/server/internal/autopilot/explain.go
new file mode 100644
index 0000000..b49d822
--- /dev/null
+++ b/server/internal/autopilot/explain.go
@@ -0,0 +1,100 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package autopilot
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/madeofpendletonwool/syncphony/server/internal/dj"
+)
+
+// Explaining the DJ's picks (MAD-760): people forgive picks they
+// understand. Each pick gets a short reason, in words the room can read on
+// now playing, up next and the big screen, and the knowledge sources it
+// came from.
+
+// sourceNames are how music knowledge sources are shown.
+var sourceNames = map[string]string{
+ "lastfm": "Last.fm",
+ "listenbrainz": "ListenBrainz",
+ "deezer": "Deezer",
+ "musicbrainz": "MusicBrainz",
+}
+
+// explain returns why the DJ chose p, as a short reason, and where the
+// knowledge came from ("" if it didn't say). name returns a member's
+// display name.
+func explain(p dj.Pick, name func(userID string) string) (reason, source string) {
+ w := p.Why
+ artist := ""
+ if len(p.Track.Artists) > 0 {
+ artist = p.Track.Artists[0].Name
+ }
+ // whose is a member's artist: "Sam's Radiohead", or the room's.
+ whose := func(userID, a string) string {
+ if n := name(userID); n != "" {
+ return n + "'s " + a
+ }
+ return "the room's " + a
+ }
+ // The member's song it leads from, if any, names the artist it's
+ // like: the via artist, or for a member's turn, theirs.
+ seedUser, via := "", w.Via
+ if p.Seed.ID != "" {
+ seedUser = p.Seed.AddedBy
+ if t := dj.TrackOf(p.Seed); len(t.Artists) > 0 {
+ via = t.Artists[0].Name
+ }
+ }
+ switch {
+ case w.Bridge != nil:
+ b := w.Bridge
+ reason = fmt.Sprintf("Bridging %s and %s", whose(b.Users[0], b.Artists[0]), whose(b.Users[1], b.Artists[1]))
+ case w.Kind == dj.KindThrowback:
+ reason = fmt.Sprintf("A throwback to %s, a favorite from past nights", w.Via)
+ case w.Kind == dj.KindArtist && w.DeepCut:
+ reason = fmt.Sprintf("A deep cut by %s, an artist the room loves", artist)
+ case w.Kind == dj.KindArtist:
+ reason = fmt.Sprintf("A top song by %s, an artist the room loves", artist)
+ case w.Kind == dj.KindSimilarSong && p.Seed.ID != "":
+ reason = fmt.Sprintf("Like %s", whose(seedUser, dj.TrackOf(p.Seed).Title))
+ case w.Kind == dj.KindSimilarSong:
+ reason = fmt.Sprintf("Like a song by %s", w.Via)
+ case w.Kind == dj.KindTwoSteps:
+ reason = fmt.Sprintf("Further out from %s → %s", whose(seedUser, via), artist)
+ default:
+ reason = fmt.Sprintf("Because %s → %s (similar %.2f)", whose(seedUser, via), artist, w.Similarity)
+ }
+ var names []string
+ for _, s := range w.Sources {
+ if n, ok := sourceNames[s]; ok {
+ names = append(names, n)
+ } else {
+ names = append(names, s)
+ }
+ }
+ return reason, strings.Join(names, ", ")
+}
+
+// names returns a member's display name, looked up once, or "" if they're
+// gone.
+func (s *Service) names(ctx context.Context) func(userID string) string {
+ seen := map[string]string{}
+ return func(id string) string {
+ if id == "" {
+ return ""
+ }
+ if n, ok := seen[id]; ok {
+ return n
+ }
+ u, err := s.db.GetUser(ctx, id)
+ if err == nil {
+ seen[id] = u.DisplayName
+ } else {
+ seen[id] = ""
+ }
+ return seen[id]
+ }
+}
diff --git a/server/internal/autopilot/explain_test.go b/server/internal/autopilot/explain_test.go
new file mode 100644
index 0000000..750c0ba
--- /dev/null
+++ b/server/internal/autopilot/explain_test.go
@@ -0,0 +1,75 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package autopilot
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/madeofpendletonwool/syncphony/server/internal/dj"
+ "github.com/madeofpendletonwool/syncphony/server/internal/provider"
+ "github.com/madeofpendletonwool/syncphony/server/internal/store"
+)
+
+func TestExplain(t *testing.T) {
+ names := map[string]string{"sam": "Sam", "jo": "Jo"}
+ name := func(id string) string { return names[id] }
+ song := func(artist, title string) provider.Track {
+ return provider.Track{Title: title, Artists: []provider.ArtistCredit{{Name: artist}}}
+ }
+ seed := func(user, artist, title string) store.QueueItem {
+ meta, _ := json.Marshal(song(artist, title))
+ return store.QueueItem{ID: "i1", AddedBy: user, Metadata: string(meta)}
+ }
+ cases := []struct {
+ name string
+ pick dj.Pick
+ want, source string
+ }{
+ {
+ "similar artist",
+ dj.Pick{Track: song("Portishead", "Roads"), Seed: seed("sam", "Radiohead", "Creep"), Why: dj.Why{
+ Kind: dj.KindSimilarArtist, Via: "Radiohead", Similarity: 0.8234, Sources: []string{"lastfm", "deezer"},
+ }},
+ "Because Sam's Radiohead → Portishead (similar 0.82)", "Last.fm, Deezer",
+ },
+ {
+ "autopilot's own taste",
+ dj.Pick{Track: song("Portishead", "Roads"), Why: dj.Why{Kind: dj.KindSimilarArtist, Via: "Radiohead", Similarity: 0.5}},
+ "Because the room's Radiohead → Portishead (similar 0.50)", "",
+ },
+ {
+ "top song",
+ dj.Pick{Track: song("Radiohead", "Creep"), Why: dj.Why{Kind: dj.KindArtist, Via: "Radiohead"}},
+ "A top song by Radiohead, an artist the room loves", "",
+ },
+ {
+ "deep cut",
+ dj.Pick{Track: song("Radiohead", "Nude"), Why: dj.Why{Kind: dj.KindArtist, DeepCut: true}},
+ "A deep cut by Radiohead, an artist the room loves", "",
+ },
+ {
+ "bridge",
+ dj.Pick{Track: song("Portishead", "Roads"), Why: dj.Why{Kind: dj.KindSimilarArtist, Bridge: &dj.Bridge{
+ Users: [2]string{"sam", "jo"}, Artists: [2]string{"Radiohead", "Massive Attack"},
+ }}},
+ "Bridging Sam's Radiohead and Jo's Massive Attack", "",
+ },
+ {
+ "similar song",
+ dj.Pick{Track: song("Muse", "Unintended"), Seed: seed("jo", "Radiohead", "Creep"), Why: dj.Why{Kind: dj.KindSimilarSong, Sources: []string{"lastfm"}}},
+ "Like Jo's Creep", "Last.fm",
+ },
+ {
+ "throwback",
+ dj.Pick{Track: song("Blur", "Song 2"), Why: dj.Why{Kind: dj.KindThrowback, Via: "Blur"}},
+ "A throwback to Blur, a favorite from past nights", "",
+ },
+ }
+ for _, c := range cases {
+ got, source := explain(c.pick, name)
+ if got != c.want || source != c.source {
+ t.Errorf("%s: explained %q (%q), want %q (%q)", c.name, got, source, c.want, c.source)
+ }
+ }
+}
diff --git a/server/internal/httpapi/api.gen.go b/server/internal/httpapi/api.gen.go
index 1843947..e4e090c 100644
--- a/server/internal/httpapi/api.gen.go
+++ b/server/internal/httpapi/api.gen.go
@@ -839,11 +839,20 @@ type ArtistResult struct {
// Label it "Autopilot", not as anyone's. It plays after every
// member's song, takes nobody's turn, and anyone may remove it.
type AutopilotPick struct {
+ // Reason Why the DJ chose it, in a few words: "Because Sam's Radiohead →
+ // Portishead (similar 0.82)", "A top song by Radiohead, an artist
+ // the room loves", "Bridging Sam's Radiohead and Jo's Massive
+ // Attack". Absent for a song a service recommended, or one picked
+ // at random; say "Like {seedTitle}" then.
+ Reason *string `json:"reason,omitempty"`
SeedArtist *string `json:"seedArtist,omitempty"`
// SeedItemId The item whose song it's like. Absent for a song picked at random.
SeedItemId *string `json:"seedItemId,omitempty"`
SeedTitle *string `json:"seedTitle,omitempty"`
+
+ // Source The music knowledge the reason came from, for a credit, e.g. "Last.fm, Deezer".
+ Source *string `json:"source,omitempty"`
}
// Backup defines model for Backup.
diff --git a/server/internal/httpapi/realtime.go b/server/internal/httpapi/realtime.go
index e98b7e7..5daeec4 100644
--- a/server/internal/httpapi/realtime.go
+++ b/server/internal/httpapi/realtime.go
@@ -383,7 +383,10 @@ func toQueueItem(it store.QueueItem) QueueItem {
out.Via = &PlaysVia{Provider: it.ViaProvider.String, LinkId: it.ViaLinkID.String, TrackId: it.ViaTrackID.String}
}
if info, ok := queue.ParseAutopilot(it); ok {
- out.Autopilot = &AutopilotPick{SeedItemId: nonEmpty(info.SeedItemID), SeedTitle: nonEmpty(info.SeedTitle), SeedArtist: nonEmpty(info.SeedArtist)}
+ out.Autopilot = &AutopilotPick{
+ SeedItemId: nonEmpty(info.SeedItemID), SeedTitle: nonEmpty(info.SeedTitle), SeedArtist: nonEmpty(info.SeedArtist),
+ Reason: nonEmpty(info.Summary), Source: nonEmpty(info.Source),
+ }
}
if it.Palette.Valid {
var p palette.Palette
diff --git a/server/internal/queue/queue.go b/server/internal/queue/queue.go
index ed98329..49c3114 100644
--- a/server/internal/queue/queue.go
+++ b/server/internal/queue/queue.go
@@ -296,6 +296,11 @@ type AutopilotInfo struct {
// Reason is the DJ's reasoning (ADR 0012). It's nil for a song a
// service recommended, or one picked at random.
Reason *AutopilotReason `json:"reason,omitempty"`
+ // Summary is the DJ's reasoning in a few words, for people (MAD-760):
+ // "Because Sam's Radiohead → Portishead (similar 0.82)". Source is the
+ // music knowledge it came from: "Last.fm, Deezer".
+ Summary string `json:"summary,omitempty"`
+ Source string `json:"source,omitempty"`
}
// AutopilotReason is why the DJ chose a song: how it relates to the room
diff --git a/web/src/api/schema.gen.ts b/web/src/api/schema.gen.ts
index c33d23f..a2f0ee2 100644
--- a/web/src/api/schema.gen.ts
+++ b/web/src/api/schema.gen.ts
@@ -3076,6 +3076,16 @@ export interface components {
seedItemId?: string;
seedTitle?: string;
seedArtist?: string;
+ /**
+ * @description Why the DJ chose it, in a few words: "Because Sam's Radiohead →
+ * Portishead (similar 0.82)", "A top song by Radiohead, an artist
+ * the room loves", "Bridging Sam's Radiohead and Jo's Massive
+ * Attack". Absent for a song a service recommended, or one picked
+ * at random; say "Like {seedTitle}" then.
+ */
+ reason?: string;
+ /** @description The music knowledge the reason came from, for a credit, e.g. "Last.fm, Deezer". */
+ source?: string;
};
/**
* @description The colors of a song's artwork, worked out once on the server so
diff --git a/web/src/components/room/autopilot-badge.tsx b/web/src/components/room/autopilot-badge.tsx
index 72a6251..48597cc 100644
--- a/web/src/components/room/autopilot-badge.tsx
+++ b/web/src/components/room/autopilot-badge.tsx
@@ -4,7 +4,7 @@ import { api } from '@/api/client'
import { errorMessage, unwrap } from '@/api/errors'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
-import { autopilotReason, type AutopilotPick } from '@/lib/autopilot'
+import { autopilotReason, autopilotSource, type AutopilotPick } from '@/lib/autopilot'
import { tap } from '@/lib/haptics'
import type { QueueItem } from '@/lib/playback'
import { queueQuery } from '@/lib/room'
@@ -21,6 +21,17 @@ export function AutopilotBadge({ pick, className }: { pick: AutopilotPick; class
)
}
+/** Why autopilot chose a song, and where the DJ's knowledge came from. */
+export function AutopilotWhy({ pick, className }: { pick: AutopilotPick; className?: string }) {
+ const source = autopilotSource(pick)
+ return (
+
+ {autopilotReason(pick)}
+ {source && · {source}}
+
+ )
+}
+
/** Autopilot's stand-in for a user avatar, in queue rows. */
export function AutopilotMark({ className }: { className?: string }) {
return (
diff --git a/web/src/components/shell/now-playing-sheet.tsx b/web/src/components/shell/now-playing-sheet.tsx
index 5cc90cc..2838231 100644
--- a/web/src/components/shell/now-playing-sheet.tsx
+++ b/web/src/components/shell/now-playing-sheet.tsx
@@ -4,7 +4,7 @@ import { Dialog } from 'radix-ui'
import { useRef, useState } from 'react'
import { Artwork } from '@/components/artwork'
import { LyricsView } from '@/components/lyrics/lyrics-view'
-import { AutopilotBadge } from '@/components/room/autopilot-badge'
+import { AutopilotBadge, AutopilotWhy } from '@/components/room/autopilot-badge'
import { SourceTag } from '@/components/service-tag'
import { AlbumLink, ArtistLinks } from '@/components/track-credits'
import { Badge } from '@/components/ui/badge'
@@ -136,6 +136,7 @@ export function NowPlayingSheet({ open, onOpenChange }: { open: boolean; onOpenC
{np.autopilot && }
+ {np.autopilot && }
diff --git a/web/src/components/tv/tv-stage.tsx b/web/src/components/tv/tv-stage.tsx
index ef3e50e..fc57bd0 100644
--- a/web/src/components/tv/tv-stage.tsx
+++ b/web/src/components/tv/tv-stage.tsx
@@ -10,7 +10,7 @@ import { AlbumBackdrop } from '@/components/shell/album-backdrop'
import { UserAvatar } from '@/components/user-avatar'
import { useAlbumPalette } from '@/hooks/use-album-palette'
import { usePosition } from '@/hooks/use-position'
-import { autopilotReason } from '@/lib/autopilot'
+import { autopilotReason, autopilotSource } from '@/lib/autopilot'
import { guestPassQuery } from '@/lib/guests'
import { laneStyle } from '@/lib/lane'
import { linerCards, linerNotesQuery } from '@/lib/liner-notes'
@@ -148,6 +148,9 @@ function NowPlayingColumn({ np }: { np: NowPlaying }) {
Autopilot
· {autopilotReason(np.autopilot)}
+ {autopilotSource(np.autopilot) && (
+ · {autopilotSource(np.autopilot)}
+ )}
)}
@@ -308,6 +311,11 @@ function UpNext({
{item.autopilot ? 'Autopilot' : (u?.displayName ?? 'Someone')}
+ {item.autopilot && (
+
+ {autopilotReason(item.autopilot)}
+
+ )}
)
diff --git a/web/src/lib/autopilot.test.ts b/web/src/lib/autopilot.test.ts
index 9d38d42..38635bc 100644
--- a/web/src/lib/autopilot.test.ts
+++ b/web/src/lib/autopilot.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { autopilotReason, isMine } from './autopilot'
+import { autopilotReason, autopilotSource, isMine } from './autopilot'
describe('isMine', () => {
it("counts your songs, not autopilot's", () => {
@@ -14,4 +14,12 @@ describe('autopilotReason', () => {
expect(autopilotReason({ seedItemId: 'i1', seedTitle: 'Heroes', seedArtist: 'David Bowie' })).toBe('Like Heroes')
expect(autopilotReason({})).toBe('To keep the music going')
})
+
+ it("prefers the DJ's reason, and credits its source", () => {
+ const pick = { seedTitle: 'Creep', reason: "Because Sam's Radiohead → Portishead (similar 0.82)", source: 'Last.fm, Deezer' }
+ expect(autopilotReason(pick)).toBe("Because Sam's Radiohead → Portishead (similar 0.82)")
+ expect(autopilotSource(pick)).toBe('via Last.fm, Deezer')
+ expect(autopilotSource({ seedTitle: 'Creep', source: 'Last.fm' })).toBeUndefined()
+ expect(autopilotSource({ reason: 'A top song by Radiohead, an artist the room loves' })).toBeUndefined()
+ })
})
diff --git a/web/src/lib/autopilot.ts b/web/src/lib/autopilot.ts
index f57c606..92281d3 100644
--- a/web/src/lib/autopilot.ts
+++ b/web/src/lib/autopilot.ts
@@ -12,8 +12,17 @@ export function isMine(item: Item, userId: string) {
return item.addedBy === userId && !item.autopilot
}
-/** Why autopilot chose a song, for a byline: "Like Heroes". */
+/**
+ * Why autopilot chose a song, for a byline: the DJ's reason ("Because
+ * Sam's Radiohead → Portishead (similar 0.82)"), else "Like Heroes".
+ */
export function autopilotReason(pick: AutopilotPick) {
+ if (pick.reason) return pick.reason
if (!pick.seedTitle) return 'To keep the music going'
return `Like ${pick.seedTitle}`
}
+
+/** Where the DJ's reason came from, for a credit: "via Last.fm, Deezer". */
+export function autopilotSource(pick: AutopilotPick) {
+ return pick.reason && pick.source ? `via ${pick.source}` : undefined
+}