Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/presence.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# Shared observed community presence

Profiles show Active, Away, or Offline with text, while avatar badges use
solid status fills. Pending, failed, stale, or unavailable evidence renders no
status; it is not relabeled Offline. People's message and thread bylines omit presence badges and custom status symbols.
Profiles show presence through the avatar badge plus compact Active, Away, or
Offline text beside the name, without a separate status row in Info. Both cues
remain across profile tabs and use the same observed value. Pending, failed,
stale, or unavailable evidence renders no status; it is not relabeled Offline.
People's message and thread bylines omit presence badges and custom status symbols.
One-to-one DM avatars and profiles retain them. Agent message avatars retain presence
badges and demand presence when mounted. Their online badge morphs into a centered
three-dot pill while the existing agent activity feed reports working or typing;
Expand Down
57 changes: 42 additions & 15 deletions src/bundled/profiles/ProfilePanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,11 @@ it("updates a mounted profile from the shared name view without replacing its id
/>,
);
expect(await screen.findByRole("heading", { name })).toBeTruthy();
expect(screen.queryByRole("img", { name: /^Presence:/ })).toBeNull();
expect(screen.getByText("Active", { exact: true })).toBeVisible();
expect(
screen.getAllByRole("img", { name: "Presence: Active" }),
screen.getAllByRole("img", { name: `${name} avatar, online` }),
).toHaveLength(1);
expect(
screen.queryByRole("img", { name: `${name} avatar, online` }),
).toBeNull();
expect(document.querySelector(".buzz-avatar-status")).toHaveAttribute(
"data-status",
"online",
Expand All @@ -85,18 +84,33 @@ it("updates a mounted profile from the shared name view without replacing its id
screen.getByRole("img", { name: `${name} avatar, online` }),
).toBeTruthy();
expect(screen.queryByRole("img", { name: "Presence: Active" })).toBeNull();
expect(screen.getByText("Active", { exact: true })).toBeVisible();
await user.click(screen.getByRole("tab", { name: "Info" }));
expect(screen.queryByRole("img", { name: /^Presence:/ })).toBeNull();
expect(screen.getByText("Active", { exact: true })).toBeVisible();
expect(
screen.getAllByRole("img", { name: "Presence: Active" }),
screen.getAllByRole("img", { name: `${name} avatar, online` }),
).toHaveLength(1);
expect(
screen.queryByRole("img", { name: `${name} avatar, online` }),
).toBeNull();
act(() => {
name = "Edited name";
notify();
});
expect(screen.getByRole("heading", { name })).toBeTruthy();
for (const [status, label] of [
["away", "Away"],
["offline", "Offline"],
] as const) {
presenceStatus.mockReturnValue(status);
act(() => {
name = `${label} name`;
notify();
});
expect(screen.getByText(label, { exact: true })).toBeVisible();
expect(
screen.getByRole("img", { name: `${name} avatar, ${status}` }),
).toBeTruthy();
expect(screen.queryByText("Active", { exact: true })).toBeNull();
}
presenceStatus.mockReturnValue("unknown");
act(() => {
name = "Unknown status name";
Expand All @@ -107,6 +121,7 @@ it("updates a mounted profile from the shared name view without replacing its id
expect(document.querySelector(".buzz-avatar-status")).not.toHaveAttribute(
"data-status",
);
expect(screen.queryByText(/^(Active|Away|Offline)$/)).toBeNull();
expect(owner.session.profiles.snapshot().size).toBe(0);
} finally {
vi.useRealTimers();
Expand Down Expand Up @@ -465,7 +480,7 @@ it.each(["ambiguous", "unmatched"])(
},
);

it("exposes thinking in Profile Info while its identity artwork is decorative", async () => {
it("exposes thinking through the named profile avatar across tabs", async () => {
const agent = keypair();
const owner = createRelaySession({
viewer: key,
Expand Down Expand Up @@ -513,10 +528,9 @@ it("exposes thinking in Profile Info while its identity artwork is decorative",
).toBeTruthy();
const details = screen.getByRole("region", { name: "Profile details" });
expect(details).not.toHaveAccessibleDescription();
expect(document.querySelector(".agent-motion-avatar")).toHaveAttribute(
"aria-hidden",
"true",
);
expect(
screen.getByRole("img", { name: "Thinking agent avatar, available" }),
).toBeVisible();
act(() => {
activity = {
...activity,
Expand All @@ -532,13 +546,26 @@ it("exposes thinking in Profile Info while its identity artwork is decorative",
};
for (const listener of listeners) listener();
});
expect(details).toHaveAccessibleDescription("Agent is thinking");
expect(screen.getByRole("img", { name: "Presence: Active" })).toBeTruthy();
expect(details).not.toHaveAccessibleDescription();
expect(
screen.getByRole("img", { name: "Thinking agent avatar, thinking" }),
).toBeVisible();
expect(screen.getByText("Active", { exact: true })).toBeVisible();
await userEvent
.setup()
.click(screen.getByRole("tab", { name: "Channels" }));
expect(
screen.getByRole("img", { name: "Thinking agent avatar, thinking" }),
).toBeVisible();
expect(screen.getByText("Active", { exact: true })).toBeVisible();
act(() => {
activity = { ...activity, turns: [] };
for (const listener of listeners) listener();
});
expect(details).not.toHaveAccessibleDescription();
expect(
screen.getByRole("img", { name: "Thinking agent avatar, available" }),
).toBeVisible();
} finally {
cleanup();
owner.dispose();
Expand Down
37 changes: 14 additions & 23 deletions src/bundled/profiles/ProfilePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,10 @@ import { ProfileRuntime, useRuntimeAgents } from "./ProfileRuntime";
import type { Navigation } from "../../features/navigation/controller";
import { ProfileChannels } from "./ProfileChannels";
import { useChannelIdentityNames } from "../../features/identity-names/react";
import {
PresenceIndicator,
usePresenceStatus,
} from "../../features/presence/react";
import { usePresenceStatus } from "../../features/presence/react";
import {
type ReactNode,
useEffect,
useId,
useMemo,
useRef,
useState,
Expand Down Expand Up @@ -197,9 +193,6 @@ function ProfileDetails({
const knownAgent = agentPubkeys.has(pubkey);
// Navigation carries appearance, not the evidence used by private controls.
const displayAgent = knownAgent || agentHint;
const thinkingId = useId();
const describeThinking =
knownAgent && tab === "info" && presence !== "unknown";
const ownership = useAgentOwnerEvidence(
session,
knownAgent ? pubkey : undefined,
Expand Down Expand Up @@ -335,7 +328,6 @@ function ProfileDetails({
ref={region}
data-buzz-ui=""
aria-label="Profile details"
aria-describedby={describeThinking ? thinkingId : undefined}
tabIndex={-1}
className={styles.root}
>
Expand All @@ -358,22 +350,26 @@ function ProfileDetails({
session={session}
agentPubkey={pubkey}
channelId={context?.channelId}
thinkingDescriptionId={
describeThinking ? thinkingId : undefined
}
src={picture}
alt={
tab === "info" && presence !== "unknown"
? ""
: `${name} avatar`
}
alt={`${name} avatar`}
fallback={name}
size="fill"
shape={displayAgent ? "squircle" : "circle"}
statusBadge={presence === "unknown" ? undefined : presence}
/>
</div>
<h2 className="text-heading">{name}</h2>
<div className="min-w-0">
<h2 className="text-heading">{name}</h2>
{presence !== "unknown" && (
<p className="text-body-sm text-secondary">
{
{ online: "Active", away: "Away", offline: "Offline" }[
presence
]
}
</p>
)}
</div>
</div>
<Tabs
value={selectedTab}
Expand All @@ -394,11 +390,6 @@ function ProfileDetails({
<div className={styles.tabContent}>
{selected === "info" ? (
<>
<PresenceIndicator
presence={session.presence}
pubkey={pubkey}
profile
/>
Comment on lines -397 to -401

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep a non-color presence cue when removing the separate row

This removes the profile’s only visible text/symbol distinction between presence states. The remaining Avatar renders the same dot geometry for online and away; components.css:359–385 changes only its fill/outline colors, and Avatar.tsx:73–86 puts the status in an aria-label while hiding the dot from accessibility. There is no visible status label or disclosure on this avatar. A sighted user who cannot distinguish the status colors can no longer tell whether this person is online or away; an accessible name alone does not supply that visual information. This regresses the non-color cue previously provided by PresenceIndicator and conflicts with the WCAG 2.1 AA target (block/buzz/VISION.md, accessibility requirement; WCAG 1.4.1).

Preserve the intended row removal, but expose the status without relying on color—for example, compact visible status text beside the avatar using the existing presence value. Keep the single subscription and unknown-without-status behavior, and update the existing component coverage for the replacement cue. The shared design’s solid badge colors can remain unchanged.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth considering ^^

<UserStatusDisplay session={session} userId={pubkey} />
{canMessage && (
<div>
Expand Down
31 changes: 19 additions & 12 deletions tests/browser/presence.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ test("profile snapshot and same-socket renewal coexist with real chat while opti
.click();
const profile = page.getByRole("region", { name: "Profile details" });
await expect(profile).toBeVisible();
await expect(profile.getByRole("img", { name: /^Presence:/ })).toHaveCount(
0,
await expect(
profile.getByRole("img", { name: "Alice Fixture avatar", exact: true }),
).toBeVisible();
await expect(profile.locator(".buzz-avatar-status")).not.toHaveAttribute(
"data-status",
);
// Narrowing the timeline for the profile can demand an older page. This
// fixture deliberately holds those pages; retire that foreground work first.
Expand Down Expand Up @@ -64,17 +67,19 @@ test("profile snapshot and same-socket renewal coexist with real chat while opti
expect(
app.report.presenceSnapshots.some((snapshot) => snapshot.pending),
).toBe(true);
await expect(profile.getByText(/^(Active|Away|Offline)$/)).toHaveCount(0);
app.relay.releasePresence();
await expect(profile.getByText("Active", { exact: true })).toBeVisible();
await expect(
profile.getByRole("img", { name: "Presence: Active" }),
profile.getByRole("img", { name: "Alice Fixture avatar, online" }),
).toBeVisible();
await expect(profile.locator(".buzz-avatar-status")).toHaveAttribute(
"data-status",
"online",
);
await expect(
profile.getByRole("img", { name: "Alice Fixture avatar, online" }),
).toHaveCount(0);
await expect(profile.getByRole("img", { name: /^Presence:/ })).toHaveCount(
0,
);
// Startup may skip busy setup. Keep real time: advancing only browser time
// would expire its SSE heartbeat without advancing the broker's keepalive.
await expect
Expand Down Expand Up @@ -259,11 +264,13 @@ test.describe("human message bylines omit presence", () => {
const profile = page.getByRole("region", { name: "Profile details" });
// Human bylines do not demand presence; opening the profile starts that read.
await expect(
profile.getByRole("img", { name: "Presence: Active" }),
profile.getByRole("img", { name: "Alice Fixture avatar, online" }),
).toBeVisible();
await expect(profile.getByText("Active", { exact: true })).toBeVisible();
app.presence("away");
await expect(profile.getByText("Away", { exact: true })).toBeVisible();
await expect(
profile.getByRole("img", { name: "Presence: Away" }),
profile.getByRole("img", { name: "Alice Fixture avatar, away" }),
).toBeVisible();
expect(
app.report.presenceSnapshots.some((snapshot) =>
Expand All @@ -274,9 +281,9 @@ test.describe("human message bylines omit presence", () => {
"data-status",
"away",
);
await expect(
profile.getByRole("img", { name: "Alice Fixture avatar, online" }),
).toHaveCount(0);
await expect(profile.getByRole("img", { name: /^Presence:/ })).toHaveCount(
0,
);
expect(
app.relay.requests.some(({ filters }) =>
filters.some(
Expand Down Expand Up @@ -397,7 +404,7 @@ test("presence becomes usable during held HTTP work and unfinished subscription
await expect(
page
.getByRole("region", { name: "Profile details" })
.getByRole("img", { name: "Presence: Active" })
.getByRole("img", { name: "Alice Fixture avatar, online" })
.first(),
).toBeVisible();
expect(app.report.unreadHolds.some((hold) => hold.pending)).toBe(true);
Expand Down
Loading