mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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">·</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">·</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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user