From 14ab00755f2a0d754261fcb265b858e1bbe86b50 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Mon, 27 Apr 2026 04:09:39 -0500 Subject: [PATCH] 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. --- ui/src/ui/app-render.ts | 1 + ui/src/ui/format.test.ts | 35 +++++++++- ui/src/ui/format.ts | 25 +++++++ ui/src/ui/views/sessions.test.ts | 109 +++++++++++++++++++++++++++++++ ui/src/ui/views/sessions.ts | 52 ++++++++++++--- 5 files changed, 213 insertions(+), 9 deletions(-) diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 0fee5fa84d4..3853f935c3a 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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, diff --git a/ui/src/ui/format.test.ts b/ui/src/ui/format.test.ts index 165ec7e72c1..3c8185d4dca 100644 --- a/ui/src/ui/format.test.ts +++ b/ui/src/ui/format.test.ts @@ -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(); + }); +}); diff --git a/ui/src/ui/format.ts b/ui/src/ui/format.ts index f982c0700b0..84f39822d12 100644 --- a/ui/src/ui/format.ts +++ b/ui/src/ui/format.ts @@ -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 }; +} diff --git a/ui/src/ui/views/sessions.test.ts b/ui/src/ui/views/sessions.test.ts index 760facc4a09..91f578309f3 100644 --- a/ui/src/ui/views/sessions.test.ts +++ b/ui/src/ui/views/sessions.test.ts @@ -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( diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts index e5a475370b6..08417bdf37d 100644 --- a/ui/src/ui/views/sessions.ts +++ b/ui/src/ui/views/sessions.ts @@ -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; 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, + 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, +): 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) {