mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
fix(ui): show session labels in selector and standardize session key prefixes
- Display session labels in the session selector - Cap selector width to 300px - Standardize key prefixes and fallback names for subagent and cron job sessions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -333,7 +333,7 @@
|
||||
|
||||
.chat-controls__session {
|
||||
min-width: 140px;
|
||||
max-width: 420px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.chat-controls__thinking {
|
||||
@@ -400,7 +400,7 @@
|
||||
.chat-controls__session select {
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
max-width: 420px;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SessionsListResult } from "./types.ts";
|
||||
import { resolveSessionDisplayName } from "./app-render.helpers.ts";
|
||||
import { parseSessionKey, resolveSessionDisplayName } from "./app-render.helpers.ts";
|
||||
|
||||
type SessionRow = SessionsListResult["sessions"][number];
|
||||
|
||||
@@ -8,72 +8,238 @@ function row(overrides: Partial<SessionRow> & { key: string }): SessionRow {
|
||||
return { kind: "direct", updatedAt: 0, ...overrides };
|
||||
}
|
||||
|
||||
describe("resolveSessionDisplayName", () => {
|
||||
it("returns key when no row is provided", () => {
|
||||
expect(resolveSessionDisplayName("agent:main:main")).toBe("agent:main:main");
|
||||
/* ================================================================
|
||||
* parseSessionKey – low-level key → type / fallback mapping
|
||||
* ================================================================ */
|
||||
|
||||
describe("parseSessionKey", () => {
|
||||
it("identifies main session (bare 'main')", () => {
|
||||
expect(parseSessionKey("main")).toEqual({ prefix: "", fallbackName: "Main Session" });
|
||||
});
|
||||
|
||||
it("returns key when row has no label or displayName", () => {
|
||||
expect(resolveSessionDisplayName("agent:main:main", row({ key: "agent:main:main" }))).toBe(
|
||||
"agent:main:main",
|
||||
it("identifies main session (agent:main:main)", () => {
|
||||
expect(parseSessionKey("agent:main:main")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "Main Session",
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies subagent sessions", () => {
|
||||
expect(parseSessionKey("agent:main:subagent:18abfefe-1fa6-43cb-8ba8-ebdc9b43e253")).toEqual({
|
||||
prefix: "Subagent:",
|
||||
fallbackName: "Subagent:",
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies cron sessions", () => {
|
||||
expect(parseSessionKey("agent:main:cron:daily-briefing-uuid")).toEqual({
|
||||
prefix: "Cron:",
|
||||
fallbackName: "Cron Job:",
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies direct chat with known channel", () => {
|
||||
expect(parseSessionKey("agent:main:bluebubbles:direct:+19257864429")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "iMessage · +19257864429",
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies direct chat with telegram", () => {
|
||||
expect(parseSessionKey("agent:main:telegram:direct:user123")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "Telegram · user123",
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies group chat with known channel", () => {
|
||||
expect(parseSessionKey("agent:main:discord:group:guild-chan")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "Discord Group",
|
||||
});
|
||||
});
|
||||
|
||||
it("capitalises unknown channels in direct/group patterns", () => {
|
||||
expect(parseSessionKey("agent:main:mychannel:direct:user1")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "Mychannel · user1",
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies channel-prefixed legacy keys", () => {
|
||||
expect(parseSessionKey("bluebubbles:g-agent-main-bluebubbles-direct-+19257864429")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "iMessage Session",
|
||||
});
|
||||
expect(parseSessionKey("discord:123:456")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "Discord Session",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles bare channel name as key", () => {
|
||||
expect(parseSessionKey("telegram")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "Telegram Session",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns raw key for unknown patterns", () => {
|
||||
expect(parseSessionKey("something-unknown")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "something-unknown",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* ================================================================
|
||||
* resolveSessionDisplayName – full resolution with row data
|
||||
* ================================================================ */
|
||||
|
||||
describe("resolveSessionDisplayName", () => {
|
||||
// ── Key-only fallbacks (no row) ──────────────────
|
||||
|
||||
it("returns 'Main Session' for agent:main:main key", () => {
|
||||
expect(resolveSessionDisplayName("agent:main:main")).toBe("Main Session");
|
||||
});
|
||||
|
||||
it("returns 'Main Session' for bare 'main' key", () => {
|
||||
expect(resolveSessionDisplayName("main")).toBe("Main Session");
|
||||
});
|
||||
|
||||
it("returns 'Subagent:' for subagent key without row", () => {
|
||||
expect(resolveSessionDisplayName("agent:main:subagent:abc-123")).toBe("Subagent:");
|
||||
});
|
||||
|
||||
it("returns 'Cron Job:' for cron key without row", () => {
|
||||
expect(resolveSessionDisplayName("agent:main:cron:abc-123")).toBe("Cron Job:");
|
||||
});
|
||||
|
||||
it("parses direct chat key with channel", () => {
|
||||
expect(resolveSessionDisplayName("agent:main:bluebubbles:direct:+19257864429")).toBe(
|
||||
"iMessage · +19257864429",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns key when displayName matches key", () => {
|
||||
it("parses channel-prefixed legacy key", () => {
|
||||
expect(resolveSessionDisplayName("discord:123:456")).toBe("Discord Session");
|
||||
});
|
||||
|
||||
it("returns raw key for unknown patterns", () => {
|
||||
expect(resolveSessionDisplayName("something-custom")).toBe("something-custom");
|
||||
});
|
||||
|
||||
// ── With row data (label / displayName) ──────────
|
||||
|
||||
it("returns parsed fallback when row has no label or displayName", () => {
|
||||
expect(resolveSessionDisplayName("agent:main:main", row({ key: "agent:main:main" }))).toBe(
|
||||
"Main Session",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns parsed fallback when displayName matches key", () => {
|
||||
expect(resolveSessionDisplayName("mykey", row({ key: "mykey", displayName: "mykey" }))).toBe(
|
||||
"mykey",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns key when label matches key", () => {
|
||||
it("returns parsed fallback when label matches key", () => {
|
||||
expect(resolveSessionDisplayName("mykey", row({ key: "mykey", label: "mykey" }))).toBe("mykey");
|
||||
});
|
||||
|
||||
it("uses displayName prominently when available", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"discord:123:456",
|
||||
row({ key: "discord:123:456", displayName: "My Chat" }),
|
||||
),
|
||||
).toBe("My Chat (discord:123:456)");
|
||||
});
|
||||
|
||||
it("falls back to label when displayName is absent", () => {
|
||||
it("uses label alone when available", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"discord:123:456",
|
||||
row({ key: "discord:123:456", label: "General" }),
|
||||
),
|
||||
).toBe("General (discord:123:456)");
|
||||
).toBe("General");
|
||||
});
|
||||
|
||||
it("prefers displayName over label when both are present", () => {
|
||||
it("falls back to displayName when label is absent", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"discord:123:456",
|
||||
row({ key: "discord:123:456", displayName: "My Chat" }),
|
||||
),
|
||||
).toBe("My Chat");
|
||||
});
|
||||
|
||||
it("prefers label over displayName when both are present", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"discord:123:456",
|
||||
row({ key: "discord:123:456", displayName: "My Chat", label: "General" }),
|
||||
),
|
||||
).toBe("My Chat (discord:123:456)");
|
||||
).toBe("General");
|
||||
});
|
||||
|
||||
it("ignores whitespace-only displayName", () => {
|
||||
it("ignores whitespace-only label and falls back to displayName", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"discord:123:456",
|
||||
row({ key: "discord:123:456", displayName: " ", label: "General" }),
|
||||
row({ key: "discord:123:456", displayName: "My Chat", label: " " }),
|
||||
),
|
||||
).toBe("General (discord:123:456)");
|
||||
).toBe("My Chat");
|
||||
});
|
||||
|
||||
it("ignores whitespace-only label", () => {
|
||||
it("uses parsed fallback when whitespace-only label and no displayName", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName("discord:123:456", row({ key: "discord:123:456", label: " " })),
|
||||
).toBe("discord:123:456");
|
||||
).toBe("Discord Session");
|
||||
});
|
||||
|
||||
it("trims displayName and label", () => {
|
||||
it("trims label and displayName", () => {
|
||||
expect(resolveSessionDisplayName("k", row({ key: "k", label: " General " }))).toBe("General");
|
||||
expect(resolveSessionDisplayName("k", row({ key: "k", displayName: " My Chat " }))).toBe(
|
||||
"My Chat (k)",
|
||||
"My Chat",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Type prefixes applied to labels / displayNames ──
|
||||
|
||||
it("prefixes subagent label with Subagent:", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"agent:main:subagent:abc-123",
|
||||
row({ key: "agent:main:subagent:abc-123", label: "maintainer-v2" }),
|
||||
),
|
||||
).toBe("Subagent: maintainer-v2");
|
||||
});
|
||||
|
||||
it("prefixes subagent displayName with Subagent:", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"agent:main:subagent:abc-123",
|
||||
row({ key: "agent:main:subagent:abc-123", displayName: "Task Runner" }),
|
||||
),
|
||||
).toBe("Subagent: Task Runner");
|
||||
});
|
||||
|
||||
it("prefixes cron label with Cron:", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"agent:main:cron:abc-123",
|
||||
row({ key: "agent:main:cron:abc-123", label: "daily-briefing" }),
|
||||
),
|
||||
).toBe("Cron: daily-briefing");
|
||||
});
|
||||
|
||||
it("prefixes cron displayName with Cron:", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"agent:main:cron:abc-123",
|
||||
row({ key: "agent:main:cron:abc-123", displayName: "Nightly Sync" }),
|
||||
),
|
||||
).toBe("Cron: Nightly Sync");
|
||||
});
|
||||
|
||||
it("does not prefix non-typed sessions with labels", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"agent:main:bluebubbles:direct:+19257864429",
|
||||
row({ key: "agent:main:bluebubbles:direct:+19257864429", label: "Tyler" }),
|
||||
),
|
||||
).toBe("Tyler");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,7 +159,7 @@ export function renderChatControls(state: AppViewState) {
|
||||
sessionOptions,
|
||||
(entry) => entry.key,
|
||||
(entry) =>
|
||||
html`<option value=${entry.key}>
|
||||
html`<option value=${entry.key} title=${entry.key}>
|
||||
${entry.displayName ?? entry.key}
|
||||
</option>`,
|
||||
)}
|
||||
@@ -256,19 +256,96 @@ function resolveMainSessionKey(
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── Channel display labels ────────────────────────────── */
|
||||
const CHANNEL_LABELS: Record<string, string> = {
|
||||
bluebubbles: "iMessage",
|
||||
telegram: "Telegram",
|
||||
discord: "Discord",
|
||||
signal: "Signal",
|
||||
slack: "Slack",
|
||||
whatsapp: "WhatsApp",
|
||||
matrix: "Matrix",
|
||||
email: "Email",
|
||||
sms: "SMS",
|
||||
};
|
||||
|
||||
const KNOWN_CHANNEL_KEYS = Object.keys(CHANNEL_LABELS);
|
||||
|
||||
/** Parsed type / context extracted from a session key. */
|
||||
export type SessionKeyInfo = {
|
||||
/** Prefix for typed sessions (Subagent:/Cron:). Empty for others. */
|
||||
prefix: string;
|
||||
/** Human-readable fallback when no label / displayName is available. */
|
||||
fallbackName: string;
|
||||
};
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a session key to extract type information and a human-readable
|
||||
* fallback display name. Exported for testing.
|
||||
*/
|
||||
export function parseSessionKey(key: string): SessionKeyInfo {
|
||||
// ── Main session ─────────────────────────────────
|
||||
if (key === "main" || key === "agent:main:main") {
|
||||
return { prefix: "", fallbackName: "Main Session" };
|
||||
}
|
||||
|
||||
// ── Subagent ─────────────────────────────────────
|
||||
if (key.includes(":subagent:")) {
|
||||
return { prefix: "Subagent:", fallbackName: "Subagent:" };
|
||||
}
|
||||
|
||||
// ── Cron job ─────────────────────────────────────
|
||||
if (key.includes(":cron:")) {
|
||||
return { prefix: "Cron:", fallbackName: "Cron Job:" };
|
||||
}
|
||||
|
||||
// ── Direct chat (agent:<x>:<channel>:direct:<id>) ──
|
||||
const directMatch = key.match(/^agent:[^:]+:([^:]+):direct:(.+)$/);
|
||||
if (directMatch) {
|
||||
const channel = directMatch[1];
|
||||
const identifier = directMatch[2];
|
||||
const channelLabel = CHANNEL_LABELS[channel] ?? capitalize(channel);
|
||||
return { prefix: "", fallbackName: `${channelLabel} · ${identifier}` };
|
||||
}
|
||||
|
||||
// ── Group chat (agent:<x>:<channel>:group:<id>) ────
|
||||
const groupMatch = key.match(/^agent:[^:]+:([^:]+):group:(.+)$/);
|
||||
if (groupMatch) {
|
||||
const channel = groupMatch[1];
|
||||
const channelLabel = CHANNEL_LABELS[channel] ?? capitalize(channel);
|
||||
return { prefix: "", fallbackName: `${channelLabel} Group` };
|
||||
}
|
||||
|
||||
// ── Channel-prefixed legacy keys (e.g. "bluebubbles:g-…") ──
|
||||
for (const ch of KNOWN_CHANNEL_KEYS) {
|
||||
if (key === ch || key.startsWith(`${ch}:`)) {
|
||||
return { prefix: "", fallbackName: `${CHANNEL_LABELS[ch]} Session` };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Unknown — return key as-is ───────────────────
|
||||
return { prefix: "", fallbackName: key };
|
||||
}
|
||||
|
||||
export function resolveSessionDisplayName(
|
||||
key: string,
|
||||
row?: SessionsListResult["sessions"][number],
|
||||
) {
|
||||
const displayName = row?.displayName?.trim() || "";
|
||||
): string {
|
||||
const label = row?.label?.trim() || "";
|
||||
if (displayName && displayName !== key) {
|
||||
return `${displayName} (${key})`;
|
||||
}
|
||||
const displayName = row?.displayName?.trim() || "";
|
||||
const { prefix, fallbackName } = parseSessionKey(key);
|
||||
|
||||
if (label && label !== key) {
|
||||
return `${label} (${key})`;
|
||||
return prefix ? `${prefix} ${label}` : label;
|
||||
}
|
||||
return key;
|
||||
if (displayName && displayName !== key) {
|
||||
return prefix ? `${prefix} ${displayName}` : displayName;
|
||||
}
|
||||
return fallbackName;
|
||||
}
|
||||
|
||||
function resolveSessionOptions(
|
||||
|
||||
Reference in New Issue
Block a user