From 36fd998f66a589d158fe58d72a12a2a6b0ca76c1 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Thu, 14 May 2026 17:21:46 -0500 Subject: [PATCH] fix(ui): align overview session labels --- CHANGELOG.md | 1 + ui/src/ui/app-render.helpers.ts | 4 +- ui/src/ui/chat/session-controls.ts | 122 +----------------------- ui/src/ui/session-display.ts | 122 ++++++++++++++++++++++++ ui/src/ui/views/overview-cards.ts | 3 +- ui/src/ui/views/overview.render.test.ts | 46 +++++++++ 6 files changed, 173 insertions(+), 125 deletions(-) create mode 100644 ui/src/ui/session-display.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ac82516d46dc..a15d5dca15cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai - Channels/Weixin: bump the bundled `@tencent-weixin/openclaw-weixin` external entry to `2.4.3` (from `2.4.1`) so onboarding and `openclaw channels add` install the current Tencent Weixin (personal WeChat) plugin release. (#81730) Thanks @scotthuang. - CLI: lazy-load model, plugin, and device runtime helpers and keep channel option help on generated startup metadata or generic fallback text so parent/help output renders without importing those runtime paths. - CLI: route `plugins list --json` through the parsed command fast path and cover it in response budgets so plugin JSON inventory avoids full CLI registration work. +- Control UI/Overview: render recent session rows through the shared session display resolver so label/displayName priority, key-equivalent labels, and channel fallbacks stay consistent with the chat selector. (#50696) Thanks @Maple778 and @BunsDev. - Gateway/network: keep OpenClaw-installed undici dispatchers on HTTP/1.1 and treat destroyed HTTP/2 session errors as recoverable network teardown, preventing `ERR_HTTP2_INVALID_SESSION` from crashing active gateway turns. Fixes #81627. (#81838) Thanks @joshavant. - Memory/daily-files: widen the daily-memory file matcher used by Dreaming, rem-backfill, rem-harness, the doctor sweep, and short-term promotion so `memory/YYYY-MM-DD-.md` files written by the bundled session-memory hook (and any future slugged variants) are discovered alongside the date-only `memory/YYYY-MM-DD.md` shape. Date extraction still uses the leading `YYYY-MM-DD` capture group, so per-day ingestion/promotion semantics are unchanged for existing date-only files; slugged files now flow through the same paths instead of being silently skipped. Fixes #69536. Thanks @jack-stormentswe. - macOS/Gateway: fail managed LaunchAgent stop and restart when the configured gateway port remains busy after cleanup instead of reporting success while a listener survives. Fixes #73132. Thanks @BunsDev. diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 5606a1716213..be55ed345e0e 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -10,10 +10,7 @@ import { syncUrlWithSessionKey } from "./app-settings.ts"; import type { AppViewState } from "./app-view-state.ts"; import { reconcileChatRunLifecycle } from "./chat/run-lifecycle.ts"; import { - isCronSessionKey, - parseSessionKey, renderChatSessionSelect as renderChatSessionSelectBase, - resolveSessionDisplayName, resolveSessionOptionGroups, } from "./chat/session-controls.ts"; import { refreshSlashCommands } from "./chat/slash-commands.ts"; @@ -22,6 +19,7 @@ import { ChatState, loadChatHistory } from "./controllers/chat.ts"; import { createSessionAndRefresh, loadSessions } from "./controllers/sessions.ts"; import { icons } from "./icons.ts"; import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts"; +import { isCronSessionKey, parseSessionKey, resolveSessionDisplayName } from "./session-display.ts"; import { normalizeAgentId, parseAgentSessionKey, diff --git a/ui/src/ui/chat/session-controls.ts b/ui/src/ui/chat/session-controls.ts index f0df9a8b1ede..4de6190d04e6 100644 --- a/ui/src/ui/chat/session-controls.ts +++ b/ui/src/ui/chat/session-controls.ts @@ -11,6 +11,7 @@ import { import { refreshVisibleToolsEffectiveForCurrentSession } from "../controllers/agents.ts"; import { loadSessions } from "../controllers/sessions.ts"; import { pushUniqueTrimmedSelectOption } from "../select-options.ts"; +import { isCronSessionKey, resolveSessionDisplayName } from "../session-display.ts"; import { buildAgentMainSessionKey, isSubagentSessionKey, @@ -439,127 +440,6 @@ async function switchChatThinkingLevel(state: AppViewState, nextThinkingLevel: s } } -/* Channel display labels. */ -const CHANNEL_LABELS: Record = { - imessage: "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 { - const normalized = normalizeLowercaseStringOrEmpty(key); - - // 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 (normalized.startsWith("cron:") || key.includes(":cron:")) { - return { prefix: "Cron:", fallbackName: "Cron Job:" }; - } - - // Direct chat: agent:::direct:. - 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:::group:. - 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, for example "imessage: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], -): string { - const label = normalizeOptionalString(row?.label) ?? ""; - const displayName = normalizeOptionalString(row?.displayName) ?? ""; - const { prefix, fallbackName } = parseSessionKey(key); - - const applyTypedPrefix = (name: string): string => { - if (!prefix) { - return name; - } - const prefixPattern = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}\\s*`, "i"); - return prefixPattern.test(name) ? name : `${prefix} ${name}`; - }; - - if (label && label !== key) { - return applyTypedPrefix(label); - } - if (displayName && displayName !== key) { - return applyTypedPrefix(displayName); - } - return fallbackName; -} - -export function isCronSessionKey(key: string): boolean { - const normalized = normalizeLowercaseStringOrEmpty(key); - if (!normalized) { - return false; - } - if (normalized.startsWith("cron:")) { - return true; - } - if (!normalized.startsWith("agent:")) { - return false; - } - const parts = normalized.split(":").filter(Boolean); - if (parts.length < 3) { - return false; - } - const rest = parts.slice(2).join(":"); - return rest.startsWith("cron:"); -} - type SessionOptionEntry = { key: string; label: string; diff --git a/ui/src/ui/session-display.ts b/ui/src/ui/session-display.ts new file mode 100644 index 000000000000..ee8730eaeb73 --- /dev/null +++ b/ui/src/ui/session-display.ts @@ -0,0 +1,122 @@ +import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "./string-coerce.ts"; +import type { SessionsListResult } from "./types.ts"; + +const CHANNEL_LABELS: Record = { + imessage: "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 { + const normalized = normalizeLowercaseStringOrEmpty(key); + + // 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 (normalized.startsWith("cron:") || key.includes(":cron:")) { + return { prefix: "Cron:", fallbackName: "Cron Job:" }; + } + + // Direct chat: agent:::direct:. + 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:::group:. + 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, for example "imessage: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], +): string { + const label = normalizeOptionalString(row?.label) ?? ""; + const displayName = normalizeOptionalString(row?.displayName) ?? ""; + const { prefix, fallbackName } = parseSessionKey(key); + + const applyTypedPrefix = (name: string): string => { + if (!prefix) { + return name; + } + const prefixPattern = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}\\s*`, "i"); + return prefixPattern.test(name) ? name : `${prefix} ${name}`; + }; + + if (label && label !== key) { + return applyTypedPrefix(label); + } + if (displayName && displayName !== key) { + return applyTypedPrefix(displayName); + } + return fallbackName; +} + +export function isCronSessionKey(key: string): boolean { + const normalized = normalizeLowercaseStringOrEmpty(key); + if (!normalized) { + return false; + } + if (normalized.startsWith("cron:")) { + return true; + } + if (!normalized.startsWith("agent:")) { + return false; + } + const parts = normalized.split(":").filter(Boolean); + if (parts.length < 3) { + return false; + } + const rest = parts.slice(2).join(":"); + return rest.startsWith("cron:"); +} diff --git a/ui/src/ui/views/overview-cards.ts b/ui/src/ui/views/overview-cards.ts index 2b731685a3b9..aa0ab835ba86 100644 --- a/ui/src/ui/views/overview-cards.ts +++ b/ui/src/ui/views/overview-cards.ts @@ -4,6 +4,7 @@ import { t } from "../../i18n/index.ts"; import { formatCost, formatTokens, formatRelativeTimestamp } from "../format.ts"; import { isMonitoredAuthProvider } from "../model-auth-helpers.ts"; import { formatNextRun } from "../presenter.ts"; +import { resolveSessionDisplayName } from "../session-display.ts"; import type { SessionsUsageResult, SessionsListResult, @@ -244,7 +245,7 @@ export function renderOverviewCards(props: OverviewCardsProps) { (s) => html`
  • ${blurDigits(s.displayName || s.label || s.key)}${blurDigits(resolveSessionDisplayName(s.key, s))} ${s.model ?? ""} { "openclaw devices list", ]); }); + + it("renders recent session names through the shared display resolver", async () => { + const container = document.createElement("div"); + const props = createOverviewProps({ + sessionsResult: { + ts: 0, + path: "", + count: 3, + defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: null }, + sessions: [ + { + key: "discord:123:456", + kind: "direct", + label: " ", + displayName: "Ops Room", + model: "gpt-5", + updatedAt: null, + }, + { + key: "telegram:123:456", + kind: "direct", + label: "telegram:123:456", + model: "gpt-5", + updatedAt: null, + }, + { + key: "agent:main:main", + kind: "direct", + label: "Main Project", + displayName: "agent:main:main", + model: "gpt-5", + updatedAt: null, + }, + ], + }, + }); + + render(renderOverview(props), container); + await Promise.resolve(); + + const recentNames = [...container.querySelectorAll(".ov-recent__key")].map( + (node) => node.textContent?.trim() ?? "", + ); + expect(recentNames).toEqual(["Ops Room", "Telegram Session", "Main Project"]); + expect(recentNames).not.toContain("telegram:123:456"); + }); });