diff --git a/mycelium-frontend/AGENTS.md b/mycelium-frontend/AGENTS.md new file mode 100644 index 00000000..643577df --- /dev/null +++ b/mycelium-frontend/AGENTS.md @@ -0,0 +1,9 @@ + + +# 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. + + diff --git a/mycelium-frontend/CLAUDE.md b/mycelium-frontend/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/mycelium-frontend/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/mycelium-frontend/src/app/layout.tsx b/mycelium-frontend/src/app/layout.tsx index 4daef2d1..549d52a6 100644 --- a/mycelium-frontend/src/app/layout.tsx +++ b/mycelium-frontend/src/app/layout.tsx @@ -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", @@ -23,7 +24,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - {children} + + {children} + diff --git a/mycelium-frontend/src/app/room/[name]/page.tsx b/mycelium-frontend/src/app/room/[name]/page.tsx index 7e0fa860..37c874ab 100644 --- a/mycelium-frontend/src/app/room/[name]/page.tsx +++ b/mycelium-frontend/src/app/room/[name]/page.tsx @@ -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 { @@ -125,7 +126,8 @@ export default function RoomPage() { {room?.mas_id && ( {room.mas_id} )} -
+
+
diff --git a/mycelium-frontend/src/components/event-stream.audio-ping.test.tsx b/mycelium-frontend/src/components/event-stream.audio-ping.test.tsx new file mode 100644 index 00000000..ce378837 --- /dev/null +++ b/mycelium-frontend/src/components/event-stream.audio-ping.test.tsx @@ -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) { + window.localStorage.setItem( + "mycelium.notify", + JSON.stringify({ enabled: true, scope: "all", sound: "chime", volume: 0.5, ...overrides }), + ); +} + +describe(" 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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); + }); +}); diff --git a/mycelium-frontend/src/components/event-stream.consent.test.tsx b/mycelium-frontend/src/components/event-stream.consent.test.tsx index 46461dea..26a8b559 100644 --- a/mycelium-frontend/src/components/event-stream.consent.test.tsx +++ b/mycelium-frontend/src/components/event-stream.consent.test.tsx @@ -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`, @@ -45,7 +46,7 @@ describe(" consent prompt", () => { }); it("surfaces a consent_request bus event as an accept/decline prompt", async () => { - render(); + renderWithProviders(); // Let the initial fetchPendingInvites effect settle so its empty result // can't overwrite the invite the bus event adds below. await act(async () => {}); @@ -62,7 +63,7 @@ describe(" consent prompt", () => { }); it("accept wiring calls the invites endpoint with the accept decision", async () => { - render(); + renderWithProviders(); // Let the initial fetchPendingInvites effect settle so its empty result // can't overwrite the invite the bus event adds below. await act(async () => {}); @@ -78,7 +79,7 @@ describe(" consent prompt", () => { }); it("decline wiring calls the invites endpoint with the decline decision", async () => { - render(); + renderWithProviders(); // Let the initial fetchPendingInvites effect settle so its empty result // can't overwrite the invite the bus event adds below. await act(async () => {}); diff --git a/mycelium-frontend/src/components/event-stream.render.test.tsx b/mycelium-frontend/src/components/event-stream.render.test.tsx index d80a7383..39736c5a 100644 --- a/mycelium-frontend/src/components/event-stream.render.test.tsx +++ b/mycelium-frontend/src/components/event-stream.render.test.tsx @@ -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`, @@ -43,7 +44,7 @@ describe(" live message rendering", () => { }); it("renders an l9_exchange streamed over SSE as a chat message", async () => { - render(); + renderWithProviders(); await act(async () => {}); const es = FakeEventSource.latest(); @@ -59,7 +60,7 @@ describe(" 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(); + renderWithProviders(); await act(async () => {}); const es = FakeEventSource.latest(); diff --git a/mycelium-frontend/src/components/event-stream.tsx b/mycelium-frontend/src/components/event-stream.tsx index 8b92540a..aa0d4186 100644 --- a/mycelium-frontend/src/components/event-stream.tsx +++ b/mycelium-frontend/src/components/event-stream.tsx @@ -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; @@ -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): 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, +): 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"; @@ -279,6 +315,16 @@ export function EventStream({ roomName, onMemoryChanged, onConnectionChange, onN const [invites, setInvites] = useState([]); const scrollRef = useRef(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 @@ -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 = () => { diff --git a/mycelium-frontend/src/components/notification-bell.tsx b/mycelium-frontend/src/components/notification-bell.tsx new file mode 100644 index 00000000..9dd0f111 --- /dev/null +++ b/mycelium-frontend/src/components/notification-bell.tsx @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Mycelium Contributors + +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Bell, BellOff } from "lucide-react"; +import { useNotificationSettings, type PingScope } from "@/components/notification-settings"; +import { PING_SOUNDS, playPing, primeAudio, type PingSound } from "@/lib/audio-ping"; +import { Checkbox } from "@/components/ui/checkbox"; + +const SCOPE_OPTIONS: { value: PingScope; label: string; hint: string }[] = [ + { value: "needs-me", label: "Needs me", hint: "mentions, my agents, consensus" }, + { value: "all", label: "Everything", hint: "any room activity" }, +]; + +const SOUND_LABELS: Record = { + chime: "Chime", + ping: "Ping", + tone: "Tone", +}; + +/** Room-header control for the audio ping (issue #514): a short tone that + * plays on relevant room activity while the tab is hidden/unfocused. Never + * pings while the tab is visible — see the play trigger in event-stream.tsx. */ +export function NotificationBell() { + const { settings, update } = useNotificationSettings(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + const Icon = settings.enabled ? Bell : BellOff; + + return ( +
+ + + {open && ( +
+ +

+ A short tone when the tab is hidden and something happens. +

+ +
+
+ Scope +
+ {SCOPE_OPTIONS.map((opt) => ( + + ))} +
+
+ +
+ Sound +
+ {PING_SOUNDS.map((s) => ( + + ))} +
+
+ + +
+
+ )} +
+ ); +} diff --git a/mycelium-frontend/src/components/notification-settings.tsx b/mycelium-frontend/src/components/notification-settings.tsx new file mode 100644 index 00000000..a6dd344e --- /dev/null +++ b/mycelium-frontend/src/components/notification-settings.tsx @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Mycelium Contributors + +"use client"; + +import { + createContext, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { primeAudio, type PingSound } from "@/lib/audio-ping"; + +/** + * The audio-ping preferences: whether a hidden tab pings on room activity, how + * broad a scope pings ("everything" vs. only what needs the acting-as + * principal's attention), and the tone/volume. Locally persisted, same as the + * acting-as identity it scopes against — there's no server-side account to + * hang preferences off yet. + */ +const STORAGE_KEY = "mycelium.notify"; + +export type PingScope = "all" | "needs-me"; + +export interface NotificationSettings { + enabled: boolean; + scope: PingScope; + sound: PingSound; + volume: number; +} + +const DEFAULTS: NotificationSettings = { + enabled: false, + scope: "needs-me", + sound: "chime", + volume: 0.5, +}; + +function isPingSound(v: unknown): v is PingSound { + return v === "chime" || v === "ping" || v === "tone"; +} + +function load(): NotificationSettings { + if (typeof window === "undefined") return DEFAULTS; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return DEFAULTS; + const parsed = JSON.parse(raw) as Partial; + return { + enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : DEFAULTS.enabled, + scope: parsed.scope === "all" ? "all" : DEFAULTS.scope, + sound: isPingSound(parsed.sound) ? parsed.sound : DEFAULTS.sound, + volume: typeof parsed.volume === "number" ? Math.min(1, Math.max(0, parsed.volume)) : DEFAULTS.volume, + }; + } catch { + return DEFAULTS; + } +} + +interface NotificationSettingsContext { + settings: NotificationSettings; + update: (patch: Partial) => void; +} + +const Ctx = createContext(null); + +export function NotificationSettingsProvider({ children }: { children: ReactNode }) { + const [settings, setSettings] = useState(DEFAULTS); + + useEffect(() => { + setSettings(load()); + }, []); + + const value = useMemo( + () => ({ + settings, + update: (patch) => { + setSettings((prev) => { + const next = { ...prev, ...patch }; + // Turning pinging on is the user gesture browsers require before + // audio can play — unlock the AudioContext right here, not on the + // first (possibly tab-hidden, gesture-less) ping. + if (patch.enabled) primeAudio(); + if (typeof window !== "undefined") { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } + return next; + }); + }, + }), + [settings], + ); + + return {children}; +} + +export function useNotificationSettings(): NotificationSettingsContext { + const ctx = useContext(Ctx); + if (!ctx) { + throw new Error("useNotificationSettings must be used within a NotificationSettingsProvider"); + } + return ctx; +} diff --git a/mycelium-frontend/src/lib/audio-ping.ts b/mycelium-frontend/src/lib/audio-ping.ts new file mode 100644 index 00000000..5f456248 --- /dev/null +++ b/mycelium-frontend/src/lib/audio-ping.ts @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Mycelium Contributors + +"use client"; + +/** + * Synthesized notification tones (Web Audio oscillators, no audio assets to + * ship/host). A module-level `AudioContext` is reused across pings and across + * room switches; browsers require it to be created/resumed from a user + * gesture, so callers should invoke `primeAudio()` from the click handler + * that turns pinging on. + */ + +export type PingSound = "chime" | "ping" | "tone"; + +interface SoundSpec { + /** Frequencies (Hz) played in sequence, one short note each. */ + freqs: number[]; + /** Seconds per note. */ + noteDuration: number; +} + +const SOUNDS: Record = { + chime: { freqs: [880, 1318.51], noteDuration: 0.16 }, // A5 -> E6 lift + ping: { freqs: [1046.5], noteDuration: 0.14 }, // C6 single blip + tone: { freqs: [523.25, 659.25, 783.99], noteDuration: 0.11 }, // C5-E5-G5 arpeggio +}; + +export const PING_SOUNDS: PingSound[] = ["chime", "ping", "tone"]; + +let ctx: AudioContext | null = null; + +function getContext(): AudioContext | null { + if (typeof window === "undefined") return null; + const Ctor = + window.AudioContext || + (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!Ctor) return null; + if (!ctx) ctx = new Ctor(); + if (ctx.state === "suspended") ctx.resume().catch(() => {}); + return ctx; +} + +/** Unlocks audio playback under the browser's user-gesture requirement. Call + * this synchronously from the click that enables pinging. */ +export function primeAudio(): void { + getContext(); +} + +/** Play a short notification tone at the given volume (0-1). Fails silently + * if Web Audio isn't available (SSR, unsupported browser, gesture missing). */ +export function playPing(sound: PingSound, volume: number): void { + const audioCtx = getContext(); + if (!audioCtx) return; + const spec = SOUNDS[sound] ?? SOUNDS.chime; + const clampedVolume = Math.min(1, Math.max(0, volume)); + const now = audioCtx.currentTime; + spec.freqs.forEach((freq, i) => { + const start = now + i * spec.noteDuration; + const osc = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + osc.type = "sine"; + osc.frequency.setValueAtTime(freq, start); + // Quick attack, exponential decay — a soft blip rather than a hard click. + gain.gain.setValueAtTime(0.0001, start); + gain.gain.linearRampToValueAtTime(clampedVolume, start + 0.01); + gain.gain.exponentialRampToValueAtTime(0.0001, start + spec.noteDuration); + osc.connect(gain); + gain.connect(audioCtx.destination); + osc.start(start); + osc.stop(start + spec.noteDuration + 0.02); + }); +} diff --git a/mycelium-frontend/src/test/render-with-providers.tsx b/mycelium-frontend/src/test/render-with-providers.tsx new file mode 100644 index 00000000..57858310 --- /dev/null +++ b/mycelium-frontend/src/test/render-with-providers.tsx @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Mycelium Contributors + +import type { ReactElement } from "react"; +import { render, type RenderResult } from "@testing-library/react"; +import { CurrentUserProvider } from "@/components/current-user"; +import { NotificationSettingsProvider } from "@/components/notification-settings"; + +/** Wraps `ui` with the app's local-storage-backed context providers + * (acting-as identity, notification settings) that most page-level + * components assume are mounted above them. */ +export function renderWithProviders(ui: ReactElement): RenderResult { + return render( + + {ui} + , + ); +}