fix(ui): resolve raw user IDs to display names across web UI

Extract shared formatUserLabel util and wire useContactResolver into
15 pages that previously showed raw numeric IDs (e.g. 386246614)
instead of human-readable names.

- Add formatUserLabel with display hierarchy: name > @username > #id
- Fix MultiUserPicker badges (covers all allow/deny lists)
- Fix permissions, tenant users, overview cards, traces, nodes
- Remove redundant raw ID spans from permissions and pending messages
- Delete duplicate formatScopeLabel/parseUserDisplay local functions
This commit is contained in:
viettranx committed 2026-04-02 23:30:14 +07:00
1 parent ade6c7ba33
commit 1f85c73a39
15 files changed
+105 -60

No files matched your search

@@ -1,6 +1,8 @@
import { useState } from "react";
import { X } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useContactResolver } from "@/hooks/use-contact-resolver";
import { formatUserLabel } from "@/lib/format-user-label";
import { UserPickerCombobox } from "./user-picker-combobox";
interface MultiUserPickerProps {
@@ -34,6 +36,7 @@ export function MultiUserPicker({
portalContainer,
}: MultiUserPickerProps) {
const [inputValue, setInputValue] = useState("");
const { resolve } = useContactResolver(value);
const handleCommit = (val: string) => {
const trimmed = val.trim();
@@ -59,7 +62,7 @@ export function MultiUserPicker({
<div className="flex flex-wrap gap-1.5">
{value.map((id) => (
<Badge key={id} variant="secondary" className="gap-1 pr-1">
{id}
{formatUserLabel(id, resolve)}
<button
type="button"
onClick={() => onChange(value.filter((v) => v !== id))}
+31
View File
@@ -0,0 +1,31 @@
type ContactLike = { display_name?: string; username?: string } | null;
type Resolver = (id: string) => ContactLike;
/**
* Format a user/sender ID into a human-readable label.
* Display hierarchy: display_name > @username > formatted ID fallback.
*/
export function formatUserLabel(userId: string, resolve?: Resolver): string {
if (!userId) return "";
// Try contact resolver first
if (resolve) {
const contact = resolve(userId);
if (contact?.display_name) return contact.display_name;
if (contact?.username) return `@${contact.username}`;
}
// Special cases
if (userId === "system") return "System";
if (userId.startsWith("group:")) {
const parts = userId.split(":");
if (parts.length >= 3) {
const channel = parts[1]!.charAt(0).toUpperCase() + parts[1]!.slice(1);
return `${channel} ${parts.slice(2).join(":")}`;
}
}
// Fallback: prefix numeric IDs with #, string IDs with @
if (/^\d+$/.test(userId)) return `#${userId}`;
return userId;
}
@@ -9,6 +9,8 @@ import {
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { useConfigPermissions, type ConfigPermission } from "../hooks/use-config-permissions";
import { UserPickerCombobox } from "@/components/shared/user-picker-combobox";
import { useContactResolver } from "@/hooks/use-contact-resolver";
import { formatUserLabel } from "@/lib/format-user-label";
import { useWs } from "@/hooks/use-ws";
import { Methods } from "@/api/protocol";
import type { DeliveryTarget } from "../hooks/use-agent-heartbeat";
@@ -121,6 +123,13 @@ export function AgentPermissionsTab({ agentId }: AgentPermissionsTabProps) {
[permissions],
);
// Resolve user IDs to display names
const allPermUserIds = useMemo(
() => [...new Set(permissions.map((p) => p.userId))],
[permissions],
);
const { resolve } = useContactResolver(allPermUserIds);
// Group file_writer by scope
const fileWritersByScope = useMemo(() => {
const map = new Map<string, ConfigPermission[]>();
@@ -228,7 +237,7 @@ export function AgentPermissionsTab({ agentId }: AgentPermissionsTabProps) {
<span className="text-xs font-medium text-muted-foreground">{scopeLabels.get(scopeKey) ?? scopeKey}</span>
</div>
{writers.map((p) => {
const displayName = p.metadata?.displayName || p.userId;
const label = p.metadata?.displayName || formatUserLabel(p.userId, resolve);
const username = p.metadata?.username ? ` @${p.metadata.username}` : "";
return (
<div key={p.id} className="flex items-center justify-between gap-2 px-3 py-2 pl-7">
@@ -239,11 +248,10 @@ export function AgentPermissionsTab({ agentId }: AgentPermissionsTabProps) {
>
{p.permission}
</Badge>
<span className="font-medium truncate">{displayName}</span>
<span className="font-medium truncate">{label}</span>
{username && (
<span className="text-[11px] text-muted-foreground shrink-0">{username}</span>
)}
<span className="text-[11px] text-muted-foreground shrink-0 font-mono">({p.userId})</span>
</div>
<Button
variant="ghost"
@@ -278,7 +286,7 @@ export function AgentPermissionsTab({ agentId }: AgentPermissionsTabProps) {
>
{p.permission}
</Badge>
<span className="font-medium truncate">{p.userId}</span>
<span className="font-medium truncate">{formatUserLabel(p.userId, resolve)}</span>
<span className="text-[11px] text-muted-foreground shrink-0">{p.configType}</span>
<span className="text-[11px] text-muted-foreground shrink-0">@ {scopeLabels.get(p.scope) ?? p.scope}</span>
</div>
@@ -7,6 +7,8 @@ import { Shield, X, AlertTriangle, Plus, Brain } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { WorkspaceSharingConfig } from "@/types/agent";
import { UserPickerCombobox } from "@/components/shared/user-picker-combobox";
import { useContactResolver } from "@/hooks/use-contact-resolver";
import { formatUserLabel } from "@/lib/format-user-label";
import { InfoLabel } from "./config-section";
const MAX_SHARED_USERS = 100;
@@ -22,6 +24,7 @@ export function WorkspaceSharingSection({ value, onChange }: WorkspaceSharingSec
const [contactSearch, setContactSearch] = useState("");
const existingUsers = value.shared_users ?? [];
const { resolve } = useContactResolver(existingUsers);
const addUser = (userId: string) => {
const trimmed = userId.trim();
@@ -118,7 +121,7 @@ export function WorkspaceSharingSection({ value, onChange }: WorkspaceSharingSec
<div className="flex flex-wrap gap-1.5">
{existingUsers.map((u, i) => (
<Badge key={i} variant="secondary" className="gap-1 pr-1">
<span className="max-w-48 truncate">{u}</span>
<span className="max-w-48 truncate">{formatUserLabel(u, resolve)}</span>
<button
type="button"
onClick={() => removeUser(i)}
@@ -3,6 +3,7 @@ import { Radio, User } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { formatRelativeTime } from "@/lib/format";
import { formatUserLabel } from "@/lib/format-user-label";
import type { TeamEventEntry } from "@/stores/use-team-event-store";
import { useAgentResolver } from "./use-agent-resolver";
import { getCategoryConfig } from "./event-categories";
@@ -129,7 +130,7 @@ function EventMetadataFooter({ payload }: { payload: unknown }) {
{userId && (
<span className="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5">
<User className="h-3 w-3" />
{userId}
{formatUserLabel(userId)}
</span>
)}
{chatId && (
@@ -1,4 +1,5 @@
import { useState, useEffect } from "react";
import { formatUserLabel } from "@/lib/format-user-label";
import { useTranslation } from "react-i18next";
import {
Dialog,
@@ -163,7 +164,7 @@ export function MemoryCreateDialog({ open, onOpenChange, agentId: parentAgentId,
<option value="">{t("createDialog.selectGroupUser")}</option>
{knownUserIds.map((uid) => (
<option key={uid} value={uid}>
{formatScopeLabel(uid)}
{formatUserLabel(uid)}
</option>
))}
</select>
@@ -227,13 +228,3 @@ export function MemoryCreateDialog({ open, onOpenChange, agentId: parentAgentId,
);
}
function formatScopeLabel(userId: string): string {
if (userId.startsWith("group:")) {
const parts = userId.split(":");
if (parts.length >= 3) {
const channel = parts[1]!.charAt(0).toUpperCase() + parts[1]!.slice(1);
return `${channel} ${parts.slice(2).join(":")}`;
}
}
return userId;
}
+3 -20
View File
@@ -11,6 +11,7 @@ import { Pagination } from "@/components/shared/pagination";
import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { useAgents } from "@/pages/agents/hooks/use-agents";
import { useContactResolver } from "@/hooks/use-contact-resolver";
import { formatUserLabel } from "@/lib/format-user-label";
import { useMemoryDocuments } from "./hooks/use-memory";
import { MemoryDocumentDialog } from "./memory-document-dialog";
import { MemoryCreateDialog } from "./memory-create-dialog";
@@ -167,7 +168,7 @@ export function MemoryPage() {
<option value="">{t("filters.allScope")}</option>
{userIds.map((uid) => (
<option key={uid} value={uid}>
{formatScopeLabel(uid, resolveContact)}
{formatUserLabel(uid, resolveContact)}
</option>
))}
</select>
@@ -236,7 +237,7 @@ export function MemoryPage() {
{doc.user_id ? t("scopeLabel.personal") : t("scopeLabel.global")}
</Badge>
{doc.user_id && (
<span className="ml-1 text-xs text-muted-foreground">{formatScopeLabel(doc.user_id, resolveContact)}</span>
<span className="ml-1 text-xs text-muted-foreground">{formatUserLabel(doc.user_id, resolveContact)}</span>
)}
</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
@@ -311,21 +312,3 @@ export function MemoryPage() {
);
}
/** Format user_id into a readable scope label, preferring contact title if available. */
function formatScopeLabel(userId: string, resolveContact?: (id: string) => { display_name?: string; username?: string } | null): string {
// Try contact resolver first
if (resolveContact) {
const contact = resolveContact(userId);
if (contact?.display_name) return contact.display_name;
if (contact?.username) return `@${contact.username}`;
}
// Fallback: format group IDs nicely
if (userId.startsWith("group:")) {
const parts = userId.split(":");
if (parts.length >= 3) {
const channel = parts[1]!.charAt(0).toUpperCase() + parts[1]!.slice(1);
return `${channel} ${parts.slice(2).join(":")}`;
}
}
return userId;
}
+12 -4
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useMemo } from "react";
import { Link as LinkIcon, RefreshCw, Check, X, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -15,11 +15,19 @@ import {
} from "./hooks/use-nodes";
import { useMinLoading } from "@/hooks/use-min-loading";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import { useContactResolver } from "@/hooks/use-contact-resolver";
import { formatUserLabel } from "@/lib/format-user-label";
export function NodesPage() {
const { t } = useTranslation("nodes");
const { pendingPairings, pairedDevices, loading, refresh, approvePairing, denyPairing, revokePairing } = useNodes();
const spinning = useMinLoading(loading);
const senderIds = useMemo(() => [
...pendingPairings.map((p) => p.sender_id),
...pairedDevices.map((d) => d.sender_id),
...pairedDevices.map((d) => d.paired_by).filter(Boolean),
].filter(Boolean) as string[], [pendingPairings, pairedDevices]);
const { resolve } = useContactResolver(senderIds);
const isEmpty = pendingPairings.length === 0 && pairedDevices.length === 0;
const showSkeleton = useDeferredLoading(loading && isEmpty);
const [revokeTarget, setRevokeTarget] = useState<PairedDevice | null>(null);
@@ -64,7 +72,7 @@ export function NodesPage() {
<span className="font-mono text-sm font-medium">{p.code}</span>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t("sender")}{p.sender_id}
{t("sender")}{formatUserLabel(p.sender_id, resolve)}
{p.chat_id && ` | ${t("chat")}${p.chat_id}`}
{" | "}
{formatRelativeTime(new Date(p.created_at))}
@@ -116,11 +124,11 @@ export function NodesPage() {
<td className="px-4 py-3">
<Badge variant="outline">{d.channel}</Badge>
</td>
<td className="px-4 py-3 font-medium">{d.sender_id}</td>
<td className="px-4 py-3 font-medium">{formatUserLabel(d.sender_id, resolve)}</td>
<td className="px-4 py-3 text-muted-foreground">
{formatDate(new Date(d.paired_at))}
</td>
<td className="px-4 py-3 text-muted-foreground">{d.paired_by}</td>
<td className="px-4 py-3 text-muted-foreground">{d.paired_by ? formatUserLabel(d.paired_by, resolve) : "--"}</td>
<td className="px-4 py-3 text-right">
<Button
variant="ghost"
@@ -1,9 +1,12 @@
import { useMemo } from "react";
import { Users } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { ClientInfo } from "./types";
import { formatClientTime } from "./hooks/use-live-uptime";
import { useContactResolver } from "@/hooks/use-contact-resolver";
import { formatUserLabel } from "@/lib/format-user-label";
export function ConnectedClientsCard({
clients,
@@ -13,6 +16,8 @@ export function ConnectedClientsCard({
currentId?: string;
}) {
const { t } = useTranslation("overview");
const userIds = useMemo(() => clients.map((c) => c.userId).filter(Boolean) as string[], [clients]);
const { resolve } = useContactResolver(userIds);
return (
<Card>
<CardHeader className="pb-3">
@@ -61,7 +66,7 @@ export function ConnectedClientsCard({
)}
</td>
<td className="py-2 px-3 font-mono text-xs">
{c.userId || "--"}
{c.userId ? formatUserLabel(c.userId, resolve) : "--"}
</td>
<td className="py-2 px-3">
<Badge
@@ -1,7 +1,10 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { StatusBadge } from "@/components/shared/status-badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { QuotaUsage, QuotaUsageResult } from "./types";
import { useContactResolver } from "@/hooks/use-contact-resolver";
import { formatUserLabel } from "@/lib/format-user-label";
function QuotaBar({ used, limit }: { used: number; limit: number }) {
const { t } = useTranslation("overview");
@@ -40,6 +43,8 @@ function QuotaCell({ usage }: { usage: QuotaUsage }) {
export function QuotaUsageCard({ quota }: { quota: QuotaUsageResult }) {
const { t } = useTranslation("overview");
const userIds = useMemo(() => quota.entries.map((e) => e.userId).filter(Boolean) as string[], [quota.entries]);
const { resolve } = useContactResolver(userIds);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-3">
@@ -64,7 +69,7 @@ export function QuotaUsageCard({ quota }: { quota: QuotaUsageResult }) {
className="border-b last:border-0"
>
<td className="py-3 pr-4">
<span className="font-mono text-xs">{entry.userId}</span>
<span className="font-mono text-xs">{formatUserLabel(entry.userId, resolve)}</span>
</td>
<td className="py-3 px-4">
<QuotaCell usage={entry.hour} />
@@ -1,3 +1,4 @@
import { useMemo } from "react";
import { ArrowRight } from "lucide-react";
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
@@ -9,6 +10,8 @@ import {
formatTokens,
formatDuration,
} from "@/lib/format";
import { useContactResolver } from "@/hooks/use-contact-resolver";
import { formatUserLabel } from "@/lib/format-user-label";
interface Trace {
id: string;
@@ -24,6 +27,8 @@ interface Trace {
export function RecentRequestsCard({ traces }: { traces: Trace[] }) {
const { t } = useTranslation("overview");
const userIds = useMemo(() => traces.map((tr) => tr.user_id).filter(Boolean) as string[], [traces]);
const { resolve } = useContactResolver(userIds);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-3">
@@ -68,7 +73,7 @@ export function RecentRequestsCard({ traces }: { traces: Trace[] }) {
{t.name || "--"}
</td>
<td className="py-2.5 px-4 font-mono text-xs">
{t.user_id || "--"}
{t.user_id ? formatUserLabel(t.user_id, resolve) : "--"}
</td>
<td className="py-2.5 px-4">{t.channel || "--"}</td>
<td className="py-2.5 px-4 text-right tabular-nums">
@@ -59,7 +59,6 @@ export function MessageListDialog({
>
<div className="mb-1.5 flex items-center gap-2">
<span className="font-medium">{msg.sender}</span>
<span className="text-xs text-muted-foreground">({msg.sender_id})</span>
{msg.is_summary && (
<Badge variant="warning" className="text-xs">{t("dialog.summary")}</Badge>
)}
@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button";
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
import { useTranslation } from "react-i18next";
import { formatDate } from "@/lib/format";
import { formatUserLabel } from "@/lib/format-user-label";
import { CollapsibleSection } from "./task-detail-content";
import type { TeamTaskComment } from "@/types/team";
@@ -46,7 +47,7 @@ export function TaskDetailComments({ comments, onAddComment }: TaskDetailComment
{comments.length > 0 && (
<div className="space-y-0">
{comments.map((c) => {
const name = c.agent_key || (c.user_id ? "User" : "Unknown");
const name = c.agent_key || (c.user_id ? formatUserLabel(c.user_id) : "Unknown");
const initial = (name[0] ?? "?").toUpperCase();
return (
<div key={c.id} className="flex gap-3 py-3 border-b border-border/50 last:border-0">
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useMemo } from "react";
import { useParams, useNavigate } from "react-router";
import { useTranslation } from "react-i18next";
import { ArrowLeft, Plus, RefreshCw, Users, Trash2, Calendar, Hash, Shield } from "lucide-react";
@@ -25,6 +25,8 @@ import { PageHeader } from "@/components/shared/page-header";
import { EmptyState } from "@/components/shared/empty-state";
import { TableSkeleton } from "@/components/shared/loading-skeleton";
import { UserPickerCombobox } from "@/components/shared/user-picker-combobox";
import { useContactResolver } from "@/hooks/use-contact-resolver";
import { formatUserLabel } from "@/lib/format-user-label";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import { useMinLoading } from "@/hooks/use-min-loading";
import { useTenantDetail } from "./hooks/use-tenant-detail";
@@ -57,6 +59,10 @@ export function TenantDetailPage() {
const spinning = useMinLoading(usersRefreshing);
const showSkeleton = useDeferredLoading(usersLoading && users.length === 0);
// Resolve user IDs to display names via contacts
const userIds = useMemo(() => users.map((u) => u.user_id), [users]);
const { resolve } = useContactResolver(userIds);
const [addOpen, setAddOpen] = useState(false);
const [userId, setUserId] = useState("");
const [role, setRole] = useState("member");
@@ -147,10 +153,10 @@ export function TenantDetailPage() {
<div key={u.user_id} className="flex items-center justify-between rounded-lg border px-4 py-3 hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium uppercase">
{u.user_id.charAt(0)}
{(u.display_name || formatUserLabel(u.user_id, resolve)).charAt(0)}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{u.user_id}</p>
<p className="text-sm font-medium truncate">{u.display_name || formatUserLabel(u.user_id, resolve)}</p>
<p className="text-xs text-muted-foreground">{new Date(u.created_at).toLocaleDateString()}</p>
</div>
</div>
+6 -10
View File
@@ -15,6 +15,8 @@ import { EmptyState } from "@/components/shared/empty-state";
import { Pagination } from "@/components/shared/pagination";
import { TableSkeleton } from "@/components/shared/loading-skeleton";
import { formatDate, formatDuration, formatTokens, computeDurationMs } from "@/lib/format";
import { formatUserLabel } from "@/lib/format-user-label";
import { useContactResolver } from "@/hooks/use-contact-resolver";
import { useTraces, type TraceData } from "./hooks/use-traces";
import { TraceDetailDialog } from "./trace-detail-dialog";
import { useMinLoading } from "@/hooks/use-min-loading";
@@ -39,15 +41,6 @@ function parseSourceType(sessionKey: string): { type: string; topic?: string } {
return { type: "unknown" };
}
/** Parse user_id into a human-readable display label */
function parseUserDisplay(userId: string): string {
if (!userId) return "";
if (userId === "system") return "System";
if (userId.startsWith("group:")) return "Group";
if (/^\d+$/.test(userId)) return `#${userId}`;
return `@${userId}`;
}
const SOURCE_ICONS: Record<string, typeof Bot> = {
cron: Clock,
team: Network,
@@ -85,6 +78,9 @@ export function TracesPage() {
offset: (page - 1) * pageSize,
});
const traceUserIds = useMemo(() => traces.map((tr) => tr.user_id).filter(Boolean) as string[], [traces]);
const { resolve } = useContactResolver(traceUserIds);
const spinning = useMinLoading(fetching);
const showSkeleton = useDeferredLoading(loading && traces.length === 0);
@@ -186,7 +182,7 @@ export function TracesPage() {
<tbody>
{traces.map((trace: TraceData) => {
const source = parseSourceType(trace.session_key);
const userLabel = parseUserDisplay(trace.user_id);
const userLabel = formatUserLabel(trace.user_id, resolve);
const agentName = trace.agent_id ? agentMap.get(trace.agent_id) : undefined;
const SourceIcon = SOURCE_ICONS[source.type] || Bot;