mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user