Skip to content
Closed
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
9 changes: 9 additions & 0 deletions mycelium-frontend/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->

# This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.

This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.

<!-- END:nextjs-agent-rules -->
1 change: 1 addition & 0 deletions mycelium-frontend/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
5 changes: 4 additions & 1 deletion mycelium-frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Metadata } from "next";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { CurrentUserProvider } from "@/components/current-user";
import { NotificationSettingsProvider } from "@/components/notification-settings";

export const metadata: Metadata = {
title: "mycelium",
Expand All @@ -23,7 +24,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
</head>
<body className="min-h-screen bg-bg text-text antialiased">
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
<CurrentUserProvider>{children}</CurrentUserProvider>
<CurrentUserProvider>
<NotificationSettingsProvider>{children}</NotificationSettingsProvider>
</CurrentUserProvider>
</ThemeProvider>
</body>
</html>
Expand Down
4 changes: 3 additions & 1 deletion mycelium-frontend/src/app/room/[name]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { RoomInspector, type Tab } from "@/components/room-inspector";
import { RoomTour } from "@/components/room-tour";
import { GlobalStatusItems, StatusButton } from "@/components/status-items";
import { ActingAsPicker } from "@/components/acting-as-picker";
import { NotificationBell } from "@/components/notification-bell";
import { useRoomStatus } from "@/lib/use-status";

interface Room {
Expand Down Expand Up @@ -125,7 +126,8 @@ export default function RoomPage() {
{room?.mas_id && (
<span className="font-mono text-micro text-faint truncate" title="MAS id">{room.mas_id}</span>
)}
<div className="ml-auto flex-shrink-0">
<div className="ml-auto flex flex-shrink-0 items-center gap-1">
<NotificationBell />
<ActingAsPicker />
</div>
</header>
Expand Down
175 changes: 175 additions & 0 deletions mycelium-frontend/src/components/event-stream.audio-ping.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Mycelium Contributors

import { act } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FakeEventSource } from "@/test/fake-event-source";
import { renderWithProviders } from "@/test/render-with-providers";

vi.mock("@/lib/api", () => ({
getSSEUrl: (room: string) => `/api/rooms/${room}/messages/stream`,
fetchMessages: vi.fn().mockResolvedValue({ messages: [] }),
fetchRoomAgents: vi.fn().mockResolvedValue([]),
fetchPendingInvites: vi.fn().mockResolvedValue([]),
respondToInvite: vi.fn(),
logFetchError: () => () => undefined,
}));

vi.mock("@/lib/audio-ping", () => ({
playPing: vi.fn(),
primeAudio: vi.fn(),
PING_SOUNDS: ["chime", "ping", "tone"],
}));

import { EventStream } from "@/components/event-stream";
import { playPing } from "@/lib/audio-ping";

const CREATED = "2026-08-04T10:00:00.000000+00:00";

function broadcast(text: string, sender = "alice", recipient: string | null = null) {
return {
message_type: recipient ? "direct" : "broadcast",
sender_handle: sender,
recipient_handle: recipient,
created_at: CREATED,
content: text,
};
}

function consensus() {
return {
message_type: "coordination_consensus",
sender_handle: "system",
created_at: CREATED,
content: JSON.stringify({ plan: "agreed", assignments: { a: "b" } }),
};
}

function setHidden(hidden: boolean) {
Object.defineProperty(document, "hidden", { value: hidden, configurable: true });
}

function setNotifySettings(overrides: Record<string, unknown>) {
window.localStorage.setItem(
"mycelium.notify",
JSON.stringify({ enabled: true, scope: "all", sound: "chime", volume: 0.5, ...overrides }),
);
}

describe("<EventStream /> audio ping", () => {
beforeEach(() => {
FakeEventSource.reset();
vi.stubGlobal("EventSource", FakeEventSource);
vi.mocked(playPing).mockClear();
window.localStorage.clear();
setHidden(false);
});

it("never pings while the tab is visible", async () => {
setNotifySettings({});
renderWithProviders(<EventStream roomName="sprint" />);
await act(async () => {});
const es = FakeEventSource.latest();

await act(async () => {
es.open();
es.emit(broadcast("hello"));
});

expect(playPing).not.toHaveBeenCalled();
});

it("does not ping when the toggle is off, even while hidden", async () => {
setNotifySettings({ enabled: false });
renderWithProviders(<EventStream roomName="sprint" />);
await act(async () => {});
const es = FakeEventSource.latest();
setHidden(true);

await act(async () => {
es.open();
es.emit(broadcast("hello"));
});

expect(playPing).not.toHaveBeenCalled();
});

it("pings on a broadcast while hidden with scope 'all'", async () => {
setNotifySettings({ scope: "all", sound: "chime", volume: 0.5 });
renderWithProviders(<EventStream roomName="sprint" />);
await act(async () => {});
const es = FakeEventSource.latest();
setHidden(true);

await act(async () => {
es.open();
es.emit(broadcast("hello everyone"));
});

expect(playPing).toHaveBeenCalledWith("chime", 0.5);
});

it("scope 'needs-me' ignores chat that doesn't mention me", async () => {
window.localStorage.setItem("mycelium.principal", "julia");
setNotifySettings({ scope: "needs-me" });
renderWithProviders(<EventStream roomName="sprint" />);
await act(async () => {});
const es = FakeEventSource.latest();
setHidden(true);

await act(async () => {
es.open();
es.emit(broadcast("just chatting about the plan"));
});

expect(playPing).not.toHaveBeenCalled();
});

it("scope 'needs-me' pings on an @-mention of my acting-as handle", async () => {
window.localStorage.setItem("mycelium.principal", "julia");
setNotifySettings({ scope: "needs-me", sound: "ping", volume: 0.8 });
renderWithProviders(<EventStream roomName="sprint" />);
await act(async () => {});
const es = FakeEventSource.latest();
setHidden(true);

await act(async () => {
es.open();
es.emit(broadcast("@julia can you take a look?"));
});

expect(playPing).toHaveBeenCalledWith("ping", 0.8);
});

it("scope 'needs-me' always pings on consensus", async () => {
setNotifySettings({ scope: "needs-me", sound: "tone" });
renderWithProviders(<EventStream roomName="sprint" />);
await act(async () => {});
const es = FakeEventSource.latest();
setHidden(true);

await act(async () => {
es.open();
es.emit(consensus());
});

expect(playPing).toHaveBeenCalledWith("tone", 0.5);
});

it("debounces a burst of activity into a single ping", async () => {
setNotifySettings({ scope: "all" });
renderWithProviders(<EventStream roomName="sprint" />);
await act(async () => {});
const es = FakeEventSource.latest();
setHidden(true);

await act(async () => {
es.open();
es.emit(broadcast("one"));
es.emit(broadcast("two"));
es.emit(broadcast("three"));
});

expect(playPing).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
// Copyright 2026 Mycelium Contributors

import { act } from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import { screen, fireEvent } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FakeEventSource } from "@/test/fake-event-source";
import { renderWithProviders } from "@/test/render-with-providers";

vi.mock("@/lib/api", () => ({
getSSEUrl: (room: string) => `/api/rooms/${room}/messages/stream`,
Expand Down Expand Up @@ -45,7 +46,7 @@ describe("<EventStream /> consent prompt", () => {
});

it("surfaces a consent_request bus event as an accept/decline prompt", async () => {
render(<EventStream roomName="sprint" />);
renderWithProviders(<EventStream roomName="sprint" />);
// Let the initial fetchPendingInvites effect settle so its empty result
// can't overwrite the invite the bus event adds below.
await act(async () => {});
Expand All @@ -62,7 +63,7 @@ describe("<EventStream /> consent prompt", () => {
});

it("accept wiring calls the invites endpoint with the accept decision", async () => {
render(<EventStream roomName="sprint" />);
renderWithProviders(<EventStream roomName="sprint" />);
// Let the initial fetchPendingInvites effect settle so its empty result
// can't overwrite the invite the bus event adds below.
await act(async () => {});
Expand All @@ -78,7 +79,7 @@ describe("<EventStream /> consent prompt", () => {
});

it("decline wiring calls the invites endpoint with the decline decision", async () => {
render(<EventStream roomName="sprint" />);
renderWithProviders(<EventStream roomName="sprint" />);
// Let the initial fetchPendingInvites effect settle so its empty result
// can't overwrite the invite the bus event adds below.
await act(async () => {});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
// Copyright 2026 Mycelium Contributors

import { act } from "react";
import { render, screen } from "@testing-library/react";
import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FakeEventSource } from "@/test/fake-event-source";
import { renderWithProviders } from "@/test/render-with-providers";

vi.mock("@/lib/api", () => ({
getSSEUrl: (room: string) => `/api/rooms/${room}/messages/stream`,
Expand Down Expand Up @@ -43,7 +44,7 @@ describe("<EventStream /> live message rendering", () => {
});

it("renders an l9_exchange streamed over SSE as a chat message", async () => {
render(<EventStream roomName="sprint" />);
renderWithProviders(<EventStream roomName="sprint" />);
await act(async () => {});
const es = FakeEventSource.latest();

Expand All @@ -59,7 +60,7 @@ describe("<EventStream /> live message rendering", () => {

it("warns loudly on an unhandled message_type instead of dropping it silently", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
render(<EventStream roomName="sprint" />);
renderWithProviders(<EventStream roomName="sprint" />);
await act(async () => {});
const es = FakeEventSource.latest();

Expand Down
62 changes: 62 additions & 0 deletions mycelium-frontend/src/components/event-stream.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import { RoomSlimView } from "@/components/room-slim";
import { EmptyState } from "@/components/empty-state";
import { initials } from "@/components/ui/monogram";
import { MessagesSquare } from "lucide-react";
import { useCurrentUser } from "@/components/current-user";
import { useNotificationSettings, type PingScope } from "@/components/notification-settings";
import { playPing } from "@/lib/audio-ping";

interface Event {
id: string;
Expand Down Expand Up @@ -245,6 +248,39 @@ function renderWithMentions(text: string): React.ReactNode {
);
}

// Minimum gap between audio pings, so a burst of messages (a negotiation
// round, several agent replies) plays one tone instead of a machine-gun.
const PING_COOLDOWN_MS = 4000;

function mentionedHandles(content: string): string[] {
return (content.match(MENTION_RE) ?? []).map((m) => m.slice(1).toLowerCase());
}

/** "needs me": consensus always qualifies (a room-wide outcome); chat only
* qualifies when it's addressed to me, addressed to an agent I own, or
* @-mentions either. Never true for my own outgoing messages. */
function needsMe(event: Event, principal: string, agentOwners: Map<string, string>): boolean {
if (event.type === "coordination_consensus") return true;
if (!CHAT_TYPES.has(event.type) || !principal || event.sender === principal) return false;
if (event.recipient === principal) return true;
if (event.recipient && agentOwners.get(event.recipient) === principal) return true;
return mentionedHandles(event.content).some(
(h) => h === principal || agentOwners.get(h) === principal,
);
}

/** Whether `event` should ping under the given scope. "all" covers anything
* that shows up in the channel view; "needs-me" narrows to `needsMe`. */
function isPingable(
event: Event,
scope: PingScope,
principal: string,
agentOwners: Map<string, string>,
): boolean {
if (scope === "needs-me") return needsMe(event, principal, agentOwners);
return event.sender !== principal && CHANNEL_VIEW_TYPES.has(event.type);
}

export type View = "channel" | "negotiate" | "plan" | "l9" | "slim";
export type NegotiationPhase = "idle" | "negotiating" | "converged" | "rejected";

Expand Down Expand Up @@ -279,6 +315,16 @@ export function EventStream({ roomName, onMemoryChanged, onConnectionChange, onN
const [invites, setInvites] = useState<PendingInvite[]>([]);
const scrollRef = useRef<HTMLDivElement>(null);

// The audio ping (issue #514) needs the latest principal/settings/agentOwners
// inside the SSE handler below, which is set up once per `roomName` and would
// otherwise close over a stale render. A ref updated every render sidesteps
// re-subscribing the EventSource just to keep pinging fresh.
const { principal } = useCurrentUser();
const { settings: pingSettings } = useNotificationSettings();
const lastPingAtRef = useRef(0);
const pingCtxRef = useRef({ principal, agentOwners, pingSettings });
pingCtxRef.current = { principal, agentOwners, pingSettings };

// Know which senders are registered agents (to badge their replies) and whom
// each belongs to (to attribute them inline). Self-fetched (mirrors the chat
// box) so the page doesn't have to thread it; owner is resolved at render time
Expand Down Expand Up @@ -358,6 +404,22 @@ export function EventStream({ roomName, onMemoryChanged, onConnectionChange, onN
if (event.type === "coordination_join" || event.type === "coordination_leave") {
onMemoryChanged?.();
}
// Audio ping (issue #514): only while the tab is actually hidden —
// never interrupt someone looking right at the feed — and only for
// events the current scope considers relevant. Rate-limited so a
// burst of activity plays one tone, not one per message.
const { principal: pingPrincipal, agentOwners: pingOwners, pingSettings: liveSettings } =
pingCtxRef.current;
if (liveSettings.enabled && document.hidden) {
const now = Date.now();
if (
now - lastPingAtRef.current > PING_COOLDOWN_MS &&
isPingable(event, liveSettings.scope, pingPrincipal, pingOwners)
) {
lastPingAtRef.current = now;
playPing(liveSettings.sound, liveSettings.volume);
}
}
} catch {}
};
es.onerror = () => {
Expand Down
Loading