fix(desktop): header UX polish and member list improvements (#447)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-05-01 18:51:30 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent d84f8c605c
commit 05ba08412c
10 changed files with 94 additions and 217 deletions
@@ -5,16 +5,18 @@ export function getChannelDescription(channel: Channel | null): string {
return "Connect to the relay to browse channels and read messages.";
}
return (
[
channel.archivedAt ? "Archived." : null,
!channel.isMember ? "Read-only until you join this open channel." : null,
channel.topic,
channel.description,
channel.purpose,
null,
]
.filter((value) => value && value.trim().length > 0)
.join(" ") || "Channel details and activity."
const prefixes = [
channel.archivedAt ? "Archived." : null,
!channel.isMember ? "Read-only until you join this open channel." : null,
].filter((value) => value && value.trim().length > 0);
// Show only the first non-empty field to avoid duplication when
// topic, description, and purpose contain overlapping text.
const detail = [channel.topic, channel.description, channel.purpose].find(
(value) => value && value.trim().length > 0,
);
const parts = [...prefixes, detail ?? null].filter(Boolean);
return parts.length > 0 ? parts.join(" ") : "Channel details and activity.";
}
@@ -7,20 +7,11 @@ import {
useAcpProvidersQuery,
useBackendProvidersQuery,
useManagedAgentsQuery,
usePersonasQuery,
useRelayAgentsQuery,
} from "@/features/agents/hooks";
import { pickBotName } from "@/features/agents/lib/pickBotName";
import {
useBotRecents,
pickQuickBotPersonas,
} from "@/features/agents/lib/useBotRecents";
import { getActivePersonas } from "@/features/agents/lib/catalog";
import { useChannelMembersQuery } from "@/features/channels/hooks";
import { QuickBotBar } from "@/features/channels/ui/QuickBotBar";
import { useQuickBotDrop } from "@/features/channels/ui/useQuickBotDrop";
import { CreateWorkflowDialog } from "@/features/workflows/ui/CreateWorkflowDialog";
import type { AgentPersona, Channel } from "@/shared/api/types";
import type { Channel } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import { AddChannelBotDialog } from "./AddChannelBotDialog";
@@ -69,58 +60,6 @@ export function ChannelMembersBar({
members.find(
(member) => normalizePubkey(member.pubkey) === normalizedCurrentPubkey,
) ?? null;
const personasQuery2 = usePersonasQuery();
const allPersonas = React.useMemo(
() => getActivePersonas(personasQuery2.data ?? []),
[personasQuery2.data],
);
const { recentIds, pushRecent } = useBotRecents();
const quickDrop = useQuickBotDrop(channel.id);
// Track in-flight instance names so rapid clicks don't produce duplicates.
// Cleared when the members query refetches with the new member.
const inflightNamesRef = React.useRef<Record<string, string[]>>({});
// Resolve the 3 personas to show in the quick bar.
const quickPersonas = React.useMemo(() => {
if (allPersonas.length === 0) return [];
const resolved = pickQuickBotPersonas(allPersonas, recentIds);
// Reset in-flight names when members list updates (the new bot appeared).
inflightNamesRef.current = {};
// Build the set of names already used in this channel
const usedNames = new Set(
members.map((m) => m.displayName ?? "").filter((n) => n.length > 0),
);
// Compute instance names from persona name pools
return resolved.map((persona) => {
// Include in-flight names to avoid duplicates on rapid clicks
const inflight = inflightNamesRef.current[persona.id] ?? [];
const combinedUsed = new Set(usedNames);
for (const n of inflight) combinedUsed.add(n);
const instanceName = pickBotName(persona.namePool ?? [], combinedUsed);
return { persona, instanceName };
});
}, [allPersonas, recentIds, members]);
const addBot = quickDrop.addBot;
const handleQuickAdd = React.useCallback(
async (persona: AgentPersona, instanceName: string) => {
// Optimistically track the chosen name to avoid duplicates on rapid clicks.
inflightNamesRef.current[persona.id] = [
...(inflightNamesRef.current[persona.id] ?? []),
instanceName,
];
pushRecent(persona.id);
await addBot(persona, instanceName);
},
[pushRecent, addBot],
);
const canManageMembers =
selfMember?.role === "owner" || selfMember?.role === "admin";
const canAddAgents =
@@ -151,29 +90,20 @@ export function ChannelMembersBar({
return (
<React.Fragment>
<div className="flex items-center gap-2">
<div className="group/quick flex items-center">
{canAddAgents ? (
<QuickBotBar
personas={quickPersonas}
pending={quickDrop.pending}
onAdd={handleQuickAdd}
/>
) : null}
<Button
aria-label="Add agent"
className="h-9 w-9 rounded-full"
data-testid="channel-add-bot-trigger"
disabled={!canAddAgents}
onClick={() => {
setIsAddBotOpen(true);
}}
size="icon"
type="button"
variant="outline"
>
<Plus className="h-4 w-4" />
</Button>
</div>
<Button
aria-label="Add agent"
className="h-9 w-9 rounded-full"
data-testid="channel-add-bot-trigger"
disabled={!canAddAgents}
onClick={() => {
setIsAddBotOpen(true);
}}
size="icon"
type="button"
variant="outline"
>
<Plus className="h-4 w-4" />
</Button>
<Button
aria-label="Create workflow"
@@ -13,8 +13,8 @@ import {
isManagedAgentActive,
} from "@/features/agents/lib/managedAgentControlActions";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { getPresenceLabel } from "@/features/presence/lib/presence";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
import { truncatePubkey } from "@/features/profile/lib/identity";
import type {
ChannelMember,
ManagedAgent,
@@ -102,40 +102,43 @@ export function MembersSidebarMemberCard({
data-testid={`sidebar-member-${member.pubkey}`}
>
<div className="flex min-w-0 items-center gap-3">
<ProfileAvatar
avatarUrl={profileAvatarUrl ?? null}
className="h-9 w-9 rounded-full text-[11px] shadow-none"
iconClassName="h-4 w-4"
label={memberLabel}
/>
<div className="min-w-0 space-y-0.5">
<p className="truncate text-sm font-medium leading-5">
{memberLabel}
</p>
<div
className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground"
data-testid={`sidebar-member-presence-${member.pubkey}`}
>
{presenceStatus ? (
<>
<PresenceDot className="h-2 w-2" status={presenceStatus} />
<span>{getPresenceLabel(presenceStatus)}</span>
<span aria-hidden="true">&middot;</span>
</>
) : null}
<span>{roleLabel}</span>
<div className="relative shrink-0">
<ProfileAvatar
avatarUrl={profileAvatarUrl ?? null}
className="h-9 w-9 rounded-full text-[11px] shadow-none"
iconClassName="h-4 w-4"
label={memberLabel}
/>
{presenceStatus ? (
<span
className="absolute -bottom-0.5 -right-0.5 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-background"
data-testid={`sidebar-member-presence-${member.pubkey}`}
>
<PresenceDot className="h-2 w-2" status={presenceStatus} />
</span>
) : null}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<p className="truncate text-sm font-medium leading-5">
{memberLabel}
</p>
<Badge className="shrink-0" variant="secondary">
{roleLabel}
</Badge>
{managedAgent ? (
<>
<span aria-hidden="true">&middot;</span>
<Badge
data-testid={`sidebar-managed-agent-status-${member.pubkey}`}
variant="secondary"
>
{formatManagedAgentStatus(managedAgent)}
</Badge>
</>
<Badge
className="shrink-0"
data-testid={`sidebar-managed-agent-status-${member.pubkey}`}
variant="secondary"
>
{formatManagedAgentStatus(managedAgent)}
</Badge>
) : null}
</div>
<p className="truncate font-mono text-[10px] text-muted-foreground/50">
{truncatePubkey(member.pubkey)}
</p>
</div>
</div>
{hasActions ? (
@@ -1,5 +1,6 @@
import * as React from "react";
import { truncatePubkey } from "@/features/profile/lib/identity";
import { Badge } from "@/shared/ui/badge";
import { cn } from "@/shared/lib/cn";
import { UserAvatar } from "@/shared/ui/UserAvatar";
@@ -80,6 +81,9 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
) : suggestion.role ? (
<Badge variant="secondary">{suggestion.role}</Badge>
) : null}
<span className="ml-auto shrink-0 font-mono text-[10px] text-muted-foreground/50">
{truncatePubkey(suggestion.pubkey)}
</span>
</button>
))}
</div>
@@ -14,7 +14,7 @@ import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
import { formatRelativeTime } from "@/features/forum/lib/time";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { useAgentSession } from "@/shared/context/AgentSessionContext";
import { Markdown } from "@/shared/ui/markdown";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { BotIdenticon } from "@/features/messages/ui/BotIdenticon";
@@ -61,10 +61,9 @@ export function UserProfilePopover({
botIdenticonValue,
}: UserProfilePopoverProps) {
const [open, setOpen] = React.useState(false);
const [showAllNotes, setShowAllNotes] = React.useState(false);
const profileQuery = useUserProfileQuery(open ? pubkey : undefined);
const notesQuery = useUserNotesQuery(open ? pubkey : undefined, {
limit: showAllNotes ? 20 : 3,
limit: 1,
});
const relayAgentsQuery = useRelayAgentsQuery({
enabled: open && role === "bot",
@@ -86,7 +85,7 @@ export function UserProfilePopover({
managedAgent?.backend.type === "local" &&
Boolean(onOpenAgentSession);
const profile = profileQuery.data;
const notes = notesQuery.data?.notes ?? [];
const latestNote = (notesQuery.data?.notes ?? [])[0] ?? null;
const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()];
return (
@@ -128,11 +127,30 @@ export function UserProfilePopover({
{profile.nip05Handle}
</p>
) : null}
{profile?.displayName ? (
<p className="truncate font-mono text-[10px] text-muted-foreground/50">
{truncatePubkey(pubkey)}
</p>
) : null}
</div>
{presenceStatus ? <PresenceBadge status={presenceStatus} /> : null}
</div>
{!notesQuery.isLoading && !notesQuery.isError && latestNote ? (
<div
className="rounded-lg bg-muted/30 px-3 py-2"
data-testid="user-profile-latest-note"
>
<p className="line-clamp-2 text-xs text-foreground">
{latestNote.content}
</p>
<p className="mt-1 text-[10px] text-muted-foreground/70">
{formatRelativeTime(latestNote.createdAt)}
</p>
</div>
) : null}
{role === "bot" && (managedAgent || relayAgent) ? (
<div className="flex flex-wrap gap-1.5">
{managedAgent?.agentCommand ? (
@@ -170,61 +188,6 @@ export function UserProfilePopover({
</button>
) : null}
<p className="truncate font-mono text-[10px] text-muted-foreground/60">
{truncatePubkey(pubkey)}
</p>
{notesQuery.isLoading ? (
<div
className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground"
data-testid="user-profile-notes-loading"
>
Loading recent notes
</div>
) : null}
{!notesQuery.isLoading && notes.length > 0 ? (
<div
className="border-t border-border/60 pt-3"
data-testid="user-profile-notes"
>
<div className="mb-2 flex items-center justify-between">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Recent Notes
</p>
{notes.length >= 3 ? (
<button
className="text-[11px] text-primary hover:underline"
onClick={() => setShowAllNotes(!showAllNotes)}
type="button"
>
{showAllNotes ? "Show less" : "View all"}
</button>
) : null}
</div>
<div
className={`space-y-2 ${showAllNotes ? "max-h-64 overflow-y-auto" : ""}`}
>
{notes.map((note) => (
<article
className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2"
data-testid="user-profile-note"
key={note.id}
>
<p className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground/80">
{formatRelativeTime(note.createdAt)}
</p>
<Markdown
className="max-w-none text-xs text-foreground"
content={note.content}
tight
/>
</article>
))}
</div>
</div>
) : null}
{notesQuery.isError ? (
<p className="text-xs text-muted-foreground">
Recent notes are unavailable right now.
@@ -52,7 +52,7 @@ export function WorkspaceSwitcher({
data-testid="workspace-switcher"
type="button"
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-primary/15 text-xs leading-none">
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-xs leading-none">
🌱
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
-20
View File
@@ -325,26 +325,6 @@ test("built-in deselection failures show up in Persona Catalog", async ({
).toBeVisible();
});
test("channel quick add falls back to added personas when defaults are absent", async ({
page,
}) => {
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByTestId("open-persona-catalog").click();
await page
.getByTestId("persona-catalog-card-target-builtin:reviewer")
.click();
await page.getByTestId("persona-catalog-dialog-done").click();
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await page.getByTestId("channel-add-bot-trigger").hover();
await expect(
page.getByRole("button", { name: "Add Reviewer" }),
).toBeVisible();
});
test("personas referenced by teams cannot be deleted", async ({ page }) => {
await gotoApp(page);
+2 -2
View File
@@ -265,10 +265,10 @@ test("shows presence in sidebar, DM header, and member list", async ({
await openMembersSidebar(page, "general");
await expect(
page.getByTestId(`sidebar-member-presence-${TEST_IDENTITIES.alice.pubkey}`),
).toContainText("Online");
).toBeVisible();
await expect(
page.getByTestId(`sidebar-member-presence-${TEST_IDENTITIES.bob.pubkey}`),
).toContainText("Away");
).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("members-sidebar")).not.toBeVisible();
});
+1 -6
View File
@@ -492,15 +492,10 @@ test("manage sheet updates channel details and context through the relay", async
await page.getByTestId(`channel-${renamedChannel}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(renamedChannel);
// channelDescription deduplicates by showing only the first non-empty field
await expect(page.getByTestId("chat-description")).toContainText(
updatedTopic,
);
await expect(page.getByTestId("chat-description")).toContainText(
updatedDescription,
);
await expect(page.getByTestId("chat-description")).toContainText(
updatedPurpose,
);
await openChannelManagement(page);
await expect(page.getByTestId("channel-management-name")).toHaveValue(
+1 -1
View File
@@ -142,7 +142,7 @@ test("clicking author name opens user profile popover", async ({ page }) => {
const popover = page.locator("[data-radix-popper-content-wrapper]");
await expect(popover).toBeVisible();
await expect(popover).toContainText("deadbeef");
await expect(page.getByTestId("user-profile-notes")).toContainText(
await expect(page.getByTestId("user-profile-latest-note")).toContainText(
"Shipped the new desktop sidebar polish today.",
);
});