mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
b7cc6811b7
commit
b8e7f27ff2
+13
-7
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user