feat(ui): display agent identities in session list

Display friendly agent identity labels in the Control UI Sessions key column when identity data is available, keep raw-key fallback behavior, and allow filtering by agent identity name.

This is the maintainer-owned replacement for #54212 by @dingtao416. Thanks @dingtao416 for the original feature idea and implementation direction.

Includes follow-up fixes from maintainer review automation: normalized key-cell classes, own-property identity lookup, and friendly-label tooltips.

Validation:
- pnpm test ui/src/ui/format.test.ts ui/src/ui/views/sessions.test.ts
- pnpm check:changed

Closes #54163.
Supersedes #54212.
This commit is contained in:
Val Alexander
2026-04-27 04:09:39 -05:00
committed by GitHub
parent 9f450dcf06
commit 14ab00755f
5 changed files with 213 additions and 9 deletions

View File

@@ -1672,6 +1672,7 @@ export function renderApp(state: AppViewState) {
includeUnknown: state.sessionsIncludeUnknown,
basePath: state.basePath,
searchQuery: state.sessionsSearchQuery,
agentIdentityById: state.agentIdentityById,
sortColumn: state.sessionsSortColumn,
sortDir: state.sessionsSortDir,
page: state.sessionsPage,

View File

@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { formatRelativeTimestamp, formatUnknownText, stripThinkingTags } from "./format.ts";
import {
formatRelativeTimestamp,
formatUnknownText,
parseSessionKeyParts,
stripThinkingTags,
} from "./format.ts";
describe("formatAgo", () => {
it("returns 'in <1m' for timestamps less than 60s in the future", () => {
@@ -115,3 +120,31 @@ describe("formatUnknownText", () => {
expect(formatUnknownText(Symbol("agent"))).toBe("Symbol(agent)");
});
});
describe("parseSessionKeyParts", () => {
it("parses a standard agent session key", () => {
expect(parseSessionKeyParts("agent:data-expert:dingtalk:cidzg6sF43NZMy52Rnk8EN")).toEqual({
agentId: "data-expert",
channel: "dingtalk",
accountId: "cidzg6sF43NZMy52Rnk8EN",
});
});
it("parses account ids containing separators", () => {
expect(parseSessionKeyParts("agent:main:telegram:user:12345:extra")).toEqual({
agentId: "main",
channel: "telegram",
accountId: "user:12345:extra",
});
});
it("returns null for non-agent or malformed keys", () => {
expect(parseSessionKeyParts("global:default")).toBeNull();
expect(parseSessionKeyParts("direct:some-key")).toBeNull();
expect(parseSessionKeyParts("")).toBeNull();
expect(parseSessionKeyParts("agent:")).toBeNull();
expect(parseSessionKeyParts("agent:main")).toBeNull();
expect(parseSessionKeyParts("agent:main:")).toBeNull();
expect(parseSessionKeyParts("agent:main:telegram")).toBeNull();
});
});

View File

@@ -110,3 +110,28 @@ export function formatTokens(tokens: number | null | undefined, fallback = "0"):
const m = tokens / 1_000_000;
return m < 10 ? `${m.toFixed(1)}M` : `${Math.round(m)}M`;
}
export function parseSessionKeyParts(
key: string,
): { agentId: string; channel: string; accountId: string } | null {
if (!key.startsWith("agent:")) {
return null;
}
const rest = key.slice("agent:".length);
const firstColon = rest.indexOf(":");
if (firstColon < 1) {
return null;
}
const agentId = rest.slice(0, firstColon);
const afterAgent = rest.slice(firstColon + 1);
const secondColon = afterAgent.indexOf(":");
if (secondColon < 1) {
return null;
}
const channel = afterAgent.slice(0, secondColon);
const accountId = afterAgent.slice(secondColon + 1);
if (!accountId) {
return null;
}
return { agentId, channel, accountId };
}

View File

@@ -36,6 +36,7 @@ function buildProps(result: SessionsListResult): SessionsProps {
includeUnknown: false,
basePath: "",
searchQuery: "",
agentIdentityById: {},
sortColumn: "updated",
sortDir: "desc",
page: 0,
@@ -169,6 +170,114 @@ describe("sessions view", () => {
expect(onPatch).toHaveBeenCalledWith("agent:main:main", { thinkingLevel: "low" });
});
it("shows agent identity name and emoji for matching session keys", async () => {
const container = document.createElement("div");
render(
renderSessions({
...buildProps(
buildResult({
key: "agent:data-expert:dingtalk:cidzg6sF43NZMy52Rnk8EN",
kind: "direct",
updatedAt: Date.now(),
}),
),
agentIdentityById: {
"data-expert": {
agentId: "data-expert",
name: "Data Expert",
avatar: "",
emoji: "📊",
},
},
}),
container,
);
await Promise.resolve();
const keyCell = container.querySelector(".session-key-cell");
expect(keyCell?.textContent).toContain("📊 Data Expert (dingtalk)");
expect(keyCell?.getAttribute("title")).toBe("📊 Data Expert (dingtalk)");
});
it("keeps raw keys when identity data is unavailable", async () => {
const container = document.createElement("div");
render(
renderSessions(
buildProps(
buildResult({
key: "agent:unknown-agent:telegram:abc123",
kind: "direct",
updatedAt: Date.now(),
}),
),
),
container,
);
await Promise.resolve();
const keyCell = container.querySelector(".session-key-cell");
expect(keyCell?.textContent).toContain("agent:unknown-agent:telegram:abc123");
expect(keyCell?.getAttribute("title")).toBe("agent:unknown-agent:telegram:abc123");
});
it("keeps raw keys for inherited identity object properties", async () => {
const container = document.createElement("div");
render(
renderSessions(
buildProps(
buildResult({
key: "agent:constructor:telegram:abc123",
kind: "direct",
updatedAt: Date.now(),
}),
),
),
container,
);
await Promise.resolve();
const text = container.querySelector(".session-key-cell")?.textContent ?? "";
expect(text).toContain("agent:constructor:telegram:abc123");
expect(text).not.toContain("Object (telegram)");
});
it("filters rows by agent identity name", async () => {
const container = document.createElement("div");
render(
renderSessions({
...buildProps(
buildMultiResult([
{
key: "agent:data-expert:dingtalk:cidzg6sF43NZMy52Rnk8EN",
kind: "direct",
updatedAt: 20,
},
{
key: "agent:code-agent:telegram:abc123",
kind: "direct",
updatedAt: 10,
},
]),
),
searchQuery: "data expert",
agentIdentityById: {
"data-expert": {
agentId: "data-expert",
name: "Data Expert",
avatar: "",
},
},
}),
container,
);
await Promise.resolve();
expect(container.querySelector(".session-key-cell")?.textContent).toContain(
"Data Expert (dingtalk)",
);
expect(container.textContent).not.toContain("code-agent");
});
it("keeps session selects stable and deselects only the current page", async () => {
const container = document.createElement("div");
render(

View File

@@ -1,12 +1,13 @@
import { html, nothing } from "lit";
import { t } from "../../i18n/index.ts";
import { formatRelativeTimestamp } from "../format.ts";
import { formatRelativeTimestamp, parseSessionKeyParts } from "../format.ts";
import { icons } from "../icons.ts";
import { pathForTab } from "../navigation.ts";
import { formatSessionTokens } from "../presenter.ts";
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts";
import { normalizeThinkLevel } from "../thinking.ts";
import type {
AgentIdentityResult,
GatewaySessionRow,
GatewayThinkingLevelOption,
SessionCompactionCheckpoint,
@@ -23,6 +24,7 @@ export type SessionsProps = {
includeUnknown: boolean;
basePath: string;
searchQuery: string;
agentIdentityById: Record<string, AgentIdentityResult>;
sortColumn: "key" | "kind" | "updated" | "tokens";
sortDir: "asc" | "desc";
page: number;
@@ -80,6 +82,15 @@ const FAST_LEVELS = [
const REASONING_LEVELS = ["", "off", "on", "stream"] as const;
const PAGE_SIZES = [10, 25, 50, 100] as const;
function getAgentIdentity(
agentIdentityById: Record<string, AgentIdentityResult>,
agentId: string,
): AgentIdentityResult | null {
return Object.prototype.hasOwnProperty.call(agentIdentityById, agentId)
? (agentIdentityById[agentId] ?? null)
: null;
}
function normalizeThinkingOptionValue(raw: string): string {
return normalizeThinkLevel(raw) ?? normalizeLowercaseStringOrEmpty(raw);
}
@@ -133,7 +144,11 @@ function resolveThinkLevelPatchValue(value: string): string | null {
return value;
}
function filterRows(rows: GatewaySessionRow[], query: string): GatewaySessionRow[] {
function filterRows(
rows: GatewaySessionRow[],
query: string,
agentIdentityById: Record<string, AgentIdentityResult>,
): GatewaySessionRow[] {
const q = normalizeLowercaseStringOrEmpty(query);
if (!q) {
return rows;
@@ -143,7 +158,14 @@ function filterRows(rows: GatewaySessionRow[], query: string): GatewaySessionRow
const label = normalizeLowercaseStringOrEmpty(row.label);
const kind = normalizeLowercaseStringOrEmpty(row.kind);
const displayName = normalizeLowercaseStringOrEmpty(row.displayName);
return key.includes(q) || label.includes(q) || kind.includes(q) || displayName.includes(q);
if (key.includes(q) || label.includes(q) || kind.includes(q) || displayName.includes(q)) {
return true;
}
const keyParts = parseSessionKeyParts(row.key);
const identityName = keyParts
? normalizeLowercaseStringOrEmpty(getAgentIdentity(agentIdentityById, keyParts.agentId)?.name)
: "";
return identityName.includes(q);
});
}
@@ -216,7 +238,7 @@ function formatCheckpointDelta(checkpoint: SessionCompactionCheckpoint): string
export function renderSessions(props: SessionsProps) {
const rawRows = props.result?.sessions ?? [];
const filtered = filterRows(rawRows, props.searchQuery);
const filtered = filterRows(rawRows, props.searchQuery, props.agentIdentityById);
const sorted = sortRows(filtered, props.sortColumn, props.sortDir);
const totalRows = sorted.length;
const totalPages = Math.max(1, Math.ceil(totalRows / props.pageSize));
@@ -328,7 +350,7 @@ export function renderSessions(props: SessionsProps) {
<div class="data-table-search">
<input
type="text"
placeholder="Filter by key, label, kind…"
placeholder="Filter by key, agent, label, kind…"
.value=${props.searchQuery}
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
/>
@@ -460,6 +482,17 @@ function renderRows(row: GatewaySessionRow, props: SessionsProps) {
const showDisplayName = Boolean(
displayName && displayName !== row.key && displayName !== trimmedLabel,
);
const keyParts = parseSessionKeyParts(row.key);
const agentIdentity = keyParts
? getAgentIdentity(props.agentIdentityById, keyParts.agentId)
: null;
const identityEmoji = normalizeOptionalString(agentIdentity?.emoji) ?? "";
const identityName = normalizeOptionalString(agentIdentity?.name) ?? "";
const friendlyKeyLabel =
identityName && keyParts
? `${identityEmoji ? `${identityEmoji} ` : ""}${identityName} (${keyParts.channel})`
: null;
const keyCellTitle = friendlyKeyLabel ?? row.key;
const canLink = row.kind !== "global";
const chatUrl = canLink
? `${pathForTab("chat", props.basePath)}?session=${encodeURIComponent(row.key)}`
@@ -484,7 +517,10 @@ function renderRows(row: GatewaySessionRow, props: SessionsProps) {
/>
</td>
<td class="data-table-key-col">
<div class="mono session-key-cell">
<div
class=${friendlyKeyLabel ? "session-key-cell" : "mono session-key-cell"}
title=${keyCellTitle}
>
${canLink
? html`<a
href=${chatUrl}
@@ -505,9 +541,9 @@ function renderRows(row: GatewaySessionRow, props: SessionsProps) {
props.onNavigateToChat(row.key);
}
}}
>${row.key}</a
>${friendlyKeyLabel ?? row.key}</a
>`
: row.key}
: (friendlyKeyLabel ?? row.key)}
${showDisplayName
? html`<span class="muted session-key-display-name">${displayName}</span>`
: nothing}