Offer the merge when two people turn out to share a name

Over-clustering is the normal state of a freshly indexed library — FR-CULL-10
says so — which means one person arrives as several groups and the user names
each of them the same thing. Until now that produced several people called Anna
and no way to join them: `identity::merge` existed, `faces::merge_people`
existed with its redirect tombstone, and `merge-into(int)` sat in identity.slint
declared, never emitted and never wired. The screen had a split button and no
merge.

So a rename that collides now offers one. Type a name another person already
carries and a strip appears under the field: "Someone else is already called
Anna (14 faces). Merge them?"

Offered, not performed. `people.uuid` is the identity and the name is not — the
schema comment on that column is explicit that two devices naming the same
cluster independently is the case it was built for — so two people sharing a
name is legal, and folding them together on a keystroke would be the screen
making an identity decision on the user's behalf. That is the thing this screen
spends a whole button avoiding.

The rename always lands first, and declining leaves it exactly as typed. There
is nothing to undo because nothing was done.

Details that are not arbitrary:

The comparison is trimmed and case-insensitive. "anna" on a phone keyboard and
"Anna" on a desktop are one intention, and an offer that appeared only when the
capitalisation matched would read as a bug.

An empty name collides with nothing. Every unnamed cluster renders as "Unnamed
(n faces)"; if that counted as a collision the offer would appear on every
cluster in a fresh library, and accepting it would fold the library into one
person.

The newly-named person folds into the one that already held the name, not the
reverse. The older person is the one other devices have seen and the one whose
confirmations are more likely to be real. Selection follows the merge, because
landing on an empty screen after a successful action reads as a failure.

The offer is retired when the person changes, and when a refresh finds its
target gone — merged from the other side of a sync, or deleted. An offer left
standing would fold whoever happens to be selected now.

A merged-away person is not a namesake: `faces::people` already excludes
redirects, so the offer does not reappear the instant it is accepted.

Four tests over the collision rules, and the existing 467 still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 13:33:36 +02:00
co-authored by Claude Opus 5
parent 8155f5276e
commit 725f7bf77f
4 changed files with 253 additions and 1 deletions
+9
View File
@@ -564,11 +564,16 @@ export component AppWindow inherits Window {
in property <string> identity-coverage;
in property <bool> identity-coverage-complete: false;
in property <int> identity-picked: 0;
/// The namesake merge the Identity screen is offering. See IdentityScreen.
in property <string> identity-merge-offer-name;
in property <int> identity-merge-offer-faces: 0;
callback identity-open();
callback identity-close();
callback identity-person-picked(int);
callback identity-rename(string);
callback identity-merge-accept();
callback identity-merge-decline();
callback identity-confirm-face(int);
callback identity-reject-face(int);
callback identity-toggle-pick(int);
@@ -1370,9 +1375,13 @@ in property <bool> panel-visible: true;
back-label: root.identity-back-label;
coverage: root.identity-coverage;
coverage-complete: root.identity-coverage-complete;
merge-offer-name: root.identity-merge-offer-name;
merge-offer-faces: root.identity-merge-offer-faces;
person-picked(id) => { root.identity-person-picked(id); }
rename(name) => { root.identity-rename(name); }
merge-accept() => { root.identity-merge-accept(); }
merge-decline() => { root.identity-merge-decline(); }
confirm-face(id) => { root.identity-confirm-face(id); }
reject-face(id) => { root.identity-reject-face(id); }
toggle-pick(id) => { root.identity-toggle-pick(id); }
+49 -1
View File
@@ -181,7 +181,18 @@ export component IdentityScreen inherits Rectangle {
callback toggle-pick(int);
callback confirm-all();
callback split-picked();
callback merge-into(int);
/// A merge the screen is offering, because the name just typed is already
/// someone else's. Empty when there is nothing to offer.
///
/// Offered rather than performed: `people.uuid` is the identity and the
/// name is not, so two people sharing one is legal and folding them
/// together silently would be the screen making an identity decision on
/// the user's behalf — the thing FR-CULL-10 spends the split button to
/// avoid.
in property <string> merge-offer-name;
in property <int> merge-offer-faces: 0;
callback merge-accept();
callback merge-decline();
callback recluster();
callback index-faces();
callback stop-indexing();
@@ -352,6 +363,43 @@ export component IdentityScreen inherits Rectangle {
}
}
// The namesake offer. Sits directly under the name field that
// caused it, because it is a question about what was just typed
// and anywhere else it would read as a status line.
if root.merge-offer-name != "": Rectangle {
height: 44px;
border-radius: Theme.radius;
background: Theme.surface-raised;
HorizontalLayout {
padding-left: Theme.gap;
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
Text {
text: "Someone else is already called " + root.merge-offer-name
+ " (" + root.merge-offer-faces + " faces). Merge them?";
color: Theme.ink-dim;
font-size: Theme.text-sm;
vertical-alignment: center;
wrap: word-wrap;
}
Rectangle { }
// Decline first and merge second, so the destructive-
// feeling half is not the one under a thumb reaching for
// the edge of the strip.
Button {
text: "Keep separate";
clicked => { root.merge-decline(); }
}
Button {
text: "Merge";
primary: true;
clicked => { root.merge-accept(); }
}
}
}
if root.model-missing: Rectangle {
height: 40px;
border-radius: Theme.radius;