feat(ui): let a link's slug settle a shared short-id prefix (#114486)

* feat(ui): let a link's slug settle a shared short-id prefix

Two sessions can share the first block of their uuid, which sent an otherwise
exact link to the disambiguation view even though the slug in that link already
said which session was meant. Capture the slug on short references and use it to
break the tie.

It can only narrow: a slug matching none or several of the tied sessions leaves
the chooser alone, and a reference that already resolves uniquely is untouched.
Generated links stay at their normal length instead of growing a full uuid.

* fix(ui): never settle a slug tie from an incomplete search

A truncated candidate set is an unfinished search, not a tie: an unexamined page
could hold the same short-id prefix under the same slug. Settling there would be
the guess the bounded search exists to avoid, so keep the chooser.
This commit is contained in:
Peter Steinberger
2026-07-27 06:08:51 -04:00
committed by GitHub
parent b7cc6811b7
commit b8e7f27ff2
5 changed files with 123 additions and 15 deletions
+13 -7
View File
@@ -77,10 +77,15 @@ The following parts are stable URL contracts:
- The key UUID short id in short-id URLs.
- The arity and short-versus-literal parsing rules above.
In short-id form, the agent segment and slug are explicitly decorative. They may
change without notice and are not used to identify or validate the session.
After resolution, the Control UI replaces the address bar with the current
agent id and current display-name slug without adding a browser-history entry.
In short-id form, the agent segment is decorative and the slug is almost
decorative. Neither identifies the session on its own, and both may change
without notice. The one exception is a tie: if the short id matches more than
one session and exactly one of them still carries the slug in the link, that
session is used, so a generated link keeps working even when two ids happen to
share a prefix. A slug that matches none or several of the tied sessions is
ignored and the disambiguation view is shown. After resolution, the Control UI
replaces the address bar with the current agent id and current display-name slug
without adding a browser-history entry.
In literal-key form, the agent segment is authoritative because it is part of
the reconstructed session key. The remaining literal segments are authoritative
@@ -95,9 +100,10 @@ the slug, the UI shows the same disambiguation view used for short-id ties
instead of guessing. Exact short-id and literal-key references always win over
slug matching.
If one short id matches more than one session, the UI does not guess. It shows a
small disambiguation view with the matching display names, agents, and longer id
prefixes. Use a longer prefix to make the URL unique. Resolution examines at
If one short id matches more than one session and the slug does not settle it,
the UI does not guess. It shows a small disambiguation view with the matching
display names, agents, and longer id prefixes. Use a longer prefix to make the
URL unique. Resolution examines at
most five pages of search results; if more remain, the view says that the search
was incomplete instead of guessing.
+13 -3
View File
@@ -5,6 +5,12 @@ export type ControlUiSessionPathTarget =
kind: "short";
agentId: string;
shortId: string;
/**
* Display-name slug that preceded the id, when the reference carried one. The id
* stays authoritative; this only breaks a tie between sessions whose ids share the
* given prefix, so a short link keeps resolving to one session.
*/
slugHint?: string;
}
| {
namespace: "chat" | "dashboard";
@@ -126,9 +132,13 @@ export function parseControlUiSessionPath(
return { namespace, kind: "literal", agentId, sessionKey };
}
const shortId = segment.match(SHORT_SESSION_REF_RE)?.[1]?.toLowerCase();
return shortId
? { namespace, kind: "short", agentId, shortId }
: { namespace, kind: "literal", agentId, sessionKey, slugCandidate: segment };
if (!shortId) {
return { namespace, kind: "literal", agentId, sessionKey, slugCandidate: segment };
}
const slugHint = segment.slice(0, segment.length - shortId.length).replace(/-+$/u, "");
return slugHint
? { namespace, kind: "short", agentId, shortId, slugHint }
: { namespace, kind: "short", agentId, shortId };
}
return null;
}
+3
View File
@@ -540,11 +540,14 @@ describe("routeIdFromPath", () => {
agentId: "main",
shortId: "12345678",
});
// The slug is captured so it can settle a tie between ids sharing this prefix, but it
// never changes the parsed id: a wrong agent and a wrong slug both stay decorative.
expect(sessionRefFromPath("/chat/wrong/wrong-slug-1234567890ab")).toEqual({
namespace: "chat",
kind: "short",
agentId: "wrong",
shortId: "1234567890ab",
slugHint: "wrong-slug",
});
expect(sessionRefFromPath("/chat/main/telegram/12345")).toEqual({
namespace: "chat",
+30 -5
View File
@@ -192,6 +192,28 @@ function sessionReferenceMatches(
return result.sessions.filter((row) => sessionKeyUuid(row.key)?.startsWith(prefix) === true);
}
// Two sessions can share a short id's prefix, which would send an otherwise exact link to
// the disambiguation view. When the link also carries a display-name slug, that slug says
// which one was meant, so it settles the tie and keeps generated links durable at their
// normal length. It can only narrow: a hint that matches nothing (a stale or hand-edited
// name) leaves the original candidates for the chooser rather than dropping the session.
//
// A truncated set is not a tie, it is an unfinished search. Another page could hold the
// same prefix under the same slug, so settling here would be the guess the bounded search
// exists to avoid.
function narrowBySlugHint(
resolution: SessionReferenceResolution,
slugHint: string | undefined,
): SessionReferenceResolution {
if (resolution.kind !== "ambiguous" || resolution.truncated || !slugHint) {
return resolution;
}
const matched = resolution.sessions.filter(
(row) => controlUiSessionSlug(row.displayName) === slugHint,
);
return matched.length === 1 && matched[0] ? { kind: "unique", session: matched[0] } : resolution;
}
function incompleteSessionReferenceResolution(
search: SessionReferenceSearch,
sessions: GatewaySessionRow[],
@@ -685,12 +707,15 @@ export async function loadChatRoute(
...(canonicalLocationReady ? { canonicalLocationReady } : {}),
};
}
const resolution = requireSessionReferenceResolution(
await querySessionReference(
context,
{ kind: "short", value: target.shortId, agentId: target.agentId },
signal,
const resolution = narrowBySlugHint(
requireSessionReferenceResolution(
await querySessionReference(
context,
{ kind: "short", value: target.shortId, agentId: target.agentId },
signal,
),
),
target.slugHint,
);
if (resolution.kind === "not-found") {
return notFound({ routeId: face });
@@ -390,6 +390,70 @@ describe("gateway-backed session route resolution", () => {
]);
});
it("settles a shared short-id prefix with the slug the link carries", async () => {
const rows = [
row({ key: "agent:roboclaw:thread:12345678-0aaa-4000-8000-000000000001" }),
row({
key: "agent:roboclaw:thread:12345678-0bbb-4000-8000-000000000002",
displayName: "Deploy monitor",
}),
];
const { context } = contextFor(() => result(rows));
const loaded = await loadChatRoute(
context,
{ pathname: "/chat/roboclaw/deploy-monitor-12345678", search: "", hash: "" },
"chat",
new AbortController().signal,
);
// Both ids start with 12345678; the slug says which one, so the short link still
// resolves instead of bouncing to the chooser.
expect(loaded).toMatchObject({ kind: "session", sessionKey: rows[1]?.key });
});
it("keeps the chooser when the slug matches neither or both tied sessions", async () => {
const rows = [
row({ key: "agent:roboclaw:thread:12345678-0aaa-4000-8000-000000000001" }),
row({ key: "agent:roboclaw:thread:12345678-0bbb-4000-8000-000000000002" }),
];
const { context } = contextFor(() => result(rows));
for (const pathname of [
// Stale slug: the session was renamed since the link was made.
"/chat/roboclaw/an-old-name-12345678",
// Both tied sessions share the slug, so it cannot decide.
"/chat/roboclaw/default-mode-with-rare-surprises-12345678",
]) {
const loaded = await loadChatRoute(
context,
{ pathname, search: "", hash: "" },
"chat",
new AbortController().signal,
);
expect(loaded).toMatchObject({ kind: "ambiguous", shortId: "12345678" });
}
});
it("does not settle a slug tie while the bounded search is incomplete", async () => {
// Only one loaded row carries the slug, but pagination stopped early: an unexamined
// page could hold the same prefix under the same name, so the chooser has to stand.
const storedRow = row({
key: "agent:roboclaw:thread:12345678-0aaa-4000-8000-000000000001",
displayName: "Deploy monitor",
});
const { context } = contextFor(({ offset = 0 }) =>
result(offset === 0 ? [storedRow] : [], { hasMore: true, nextOffset: offset + 20, offset }),
);
const loaded = await loadChatRoute(
context,
{ pathname: "/chat/roboclaw/deploy-monitor-12345678", search: "", hash: "" },
"chat",
new AbortController().signal,
);
expect(loaded).toMatchObject({ kind: "ambiguous", shortId: "12345678", truncated: true });
});
it("prefers an exact literal key over slug matches", async () => {
const literal = row({
key: "agent:roboclaw:default-mode-with-rare-surprises",