From 80068382216e72fda9bcea9ce21f458c9253a1bb Mon Sep 17 00:00:00 2001 From: damienrj Date: Thu, 23 Jul 2026 10:59:40 -0700 Subject: [PATCH 1/2] Improve unavailable huddle audio errors --- .../channels/ui/ChannelMembersBar.tsx | 3 ++ desktop/src/features/huddle/HuddleContext.tsx | 5 +- .../huddle/components/HuddleAttachment.tsx | 5 +- .../huddle/components/HuddleIndicator.tsx | 3 ++ .../features/huddle/lib/huddleError.test.mjs | 46 +++++++++++++++++++ .../src/features/huddle/lib/huddleError.ts | 37 +++++++++++++++ .../messages/ui/WaveMessageAttachment.tsx | 5 +- .../profile/ui/UserProfilePopover.tsx | 5 +- 8 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 desktop/src/features/huddle/lib/huddleError.test.mjs create mode 100644 desktop/src/features/huddle/lib/huddleError.ts diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index 8e030ef215..a8f42f3e0f 100644 --- a/desktop/src/features/channels/ui/ChannelMembersBar.tsx +++ b/desktop/src/features/channels/ui/ChannelMembersBar.tsx @@ -1,8 +1,10 @@ import { EllipsisVertical, Settings2, Users } from "lucide-react"; import * as React from "react"; +import { toast } from "sonner"; import { useQueryClient } from "@tanstack/react-query"; import { useHuddle } from "@/features/huddle"; import { HuddleIndicator } from "@/features/huddle/components/HuddleIndicator"; +import { formatHuddleActionError } from "@/features/huddle/lib/huddleError"; import { buildHuddleChannelName } from "@/features/huddle/lib/huddleChannelName"; import { useAvailableAcpRuntimes, @@ -127,6 +129,7 @@ export function ChannelMembersBar({ void queryClient.invalidateQueries({ queryKey: ["channels"] }); } catch (e) { console.error("Failed to start huddle:", e); + toast.error(formatHuddleActionError(e, "start")); } }} renderMode={variant === "compact" ? "menu-item" : "button"} diff --git a/desktop/src/features/huddle/HuddleContext.tsx b/desktop/src/features/huddle/HuddleContext.tsx index 3ea25ad1a3..804eb4bc0e 100644 --- a/desktop/src/features/huddle/HuddleContext.tsx +++ b/desktop/src/features/huddle/HuddleContext.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import { setupAudioWorklet, type AudioWorkletHandle } from "./lib/audioWorklet"; import { useAudioDevices } from "./lib/useAudioDevices"; +import { formatHuddleActionError } from "./lib/huddleError"; import { useTtsSubscription } from "./lib/useTtsSubscription"; /** @@ -457,7 +458,7 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) { const w = workletRef.current; workletRef.current = null; await cleanupFailedStart(w, true); - setHuddleError(msg); + setHuddleError(formatHuddleActionError(e, "start")); console.error("Failed to start huddle:", e); throw e; } finally { @@ -503,7 +504,7 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) { const w = workletRef.current; workletRef.current = null; await cleanupFailedStart(w, false); - setHuddleError(msg); + setHuddleError(formatHuddleActionError(e, "join")); console.error("Failed to join huddle:", e); throw e; } finally { diff --git a/desktop/src/features/huddle/components/HuddleAttachment.tsx b/desktop/src/features/huddle/components/HuddleAttachment.tsx index 80e446f798..db64e20bd7 100644 --- a/desktop/src/features/huddle/components/HuddleAttachment.tsx +++ b/desktop/src/features/huddle/components/HuddleAttachment.tsx @@ -24,6 +24,7 @@ import { } from "@/shared/ui/attachment"; import { useHuddle } from "../HuddleContext"; import { isHuddleStartStale } from "../lib/huddleCardState"; +import { formatHuddleActionError } from "../lib/huddleError"; type HuddleAttachmentProps = { channelId: string | null; @@ -221,9 +222,7 @@ export function HuddleAttachment({ await joinHuddle(channelId, ephemeralChannelId); void queryClient.invalidateQueries({ queryKey: ["channels"] }); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to join huddle"; - toast.error(message); + toast.error(formatHuddleActionError(error, "join")); } finally { setIsJoining(false); } diff --git a/desktop/src/features/huddle/components/HuddleIndicator.tsx b/desktop/src/features/huddle/components/HuddleIndicator.tsx index ad5baea581..19b504cf28 100644 --- a/desktop/src/features/huddle/components/HuddleIndicator.tsx +++ b/desktop/src/features/huddle/components/HuddleIndicator.tsx @@ -1,6 +1,7 @@ import { listen } from "@tauri-apps/api/event"; import { Headphones } from "lucide-react"; import * as React from "react"; +import { toast } from "sonner"; import { useQueryClient } from "@tanstack/react-query"; import { relayClient } from "@/shared/api/relayClient"; @@ -10,6 +11,7 @@ import { Button } from "@/shared/ui/button"; import { DropdownMenuItem } from "@/shared/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { useHuddle } from "../HuddleContext"; +import { formatHuddleActionError } from "../lib/huddleError"; /** Huddle lifecycle event kinds */ const KIND_HUDDLE_STARTED = 48100; @@ -263,6 +265,7 @@ export function HuddleIndicator({ void queryClient.invalidateQueries({ queryKey: ["channels"] }); } catch (e) { console.error("Failed to join huddle:", e); + toast.error(formatHuddleActionError(e, "join")); } finally { setIsJoining(false); } diff --git a/desktop/src/features/huddle/lib/huddleError.test.mjs b/desktop/src/features/huddle/lib/huddleError.test.mjs new file mode 100644 index 0000000000..ec61b83467 --- /dev/null +++ b/desktop/src/features/huddle/lib/huddleError.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { formatHuddleActionError } from "./huddleError.ts"; + +const AUDIO_UNAVAILABLE_MESSAGE = + "Huddle audio isn’t available on this server. Ask an administrator to turn it on."; + +test("maps the relay deployment rejection to actionable copy", () => { + assert.equal( + formatHuddleActionError( + "audio relay auth error: huddle audio unavailable in this deployment", + "join", + ), + AUDIO_UNAVAILABLE_MESSAGE, + ); +}); + +test("recognizes the relay error code when present", () => { + assert.equal( + formatHuddleActionError("huddle_audio_unavailable", "start"), + AUDIO_UNAVAILABLE_MESSAGE, + ); +}); + +test("preserves other string and Error messages", () => { + assert.equal( + formatHuddleActionError("Microphone unavailable", "join"), + "Microphone unavailable", + ); + assert.equal( + formatHuddleActionError(new Error("Connection timed out"), "start"), + "Connection timed out", + ); +}); + +test("uses action-specific fallback copy for unknown errors", () => { + assert.equal( + formatHuddleActionError({ reason: "unknown" }, "join"), + "Couldn’t join the huddle.", + ); + assert.equal( + formatHuddleActionError(null, "start"), + "Couldn’t start the huddle.", + ); +}); diff --git a/desktop/src/features/huddle/lib/huddleError.ts b/desktop/src/features/huddle/lib/huddleError.ts new file mode 100644 index 0000000000..7ee47b2133 --- /dev/null +++ b/desktop/src/features/huddle/lib/huddleError.ts @@ -0,0 +1,37 @@ +export type HuddleAction = "join" | "start"; + +const HUDDLE_AUDIO_UNAVAILABLE_MESSAGE = + "Huddle audio isn’t available on this server. Ask an administrator to turn it on."; + +function rawErrorMessage(error: unknown): string | null { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + return null; +} + +export function formatHuddleActionError( + error: unknown, + action: HuddleAction, +): string { + const message = rawErrorMessage(error)?.trim(); + const normalized = message?.toLowerCase(); + + if ( + normalized?.includes("huddle_audio_unavailable") || + normalized?.includes("huddle audio unavailable in this deployment") + ) { + return HUDDLE_AUDIO_UNAVAILABLE_MESSAGE; + } + + if (message) { + return message; + } + + return action === "join" + ? "Couldn’t join the huddle." + : "Couldn’t start the huddle."; +} diff --git a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx index 0012d0b140..f782326295 100644 --- a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx +++ b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx @@ -4,6 +4,7 @@ import { toast } from "sonner"; import { channelsQueryKey } from "@/features/channels/hooks"; import { useHuddle } from "@/features/huddle"; +import { formatHuddleActionError } from "@/features/huddle/lib/huddleError"; import { Attachment, AttachmentAction, @@ -45,9 +46,7 @@ export function WaveMessageAttachment({ await startHuddle(channelId, [...huddleMemberPubkeys]); await queryClient.invalidateQueries({ queryKey: channelsQueryKey }); } catch (error) { - toast.error( - error instanceof Error ? error.message : "Failed to start huddle.", - ); + toast.error(formatHuddleActionError(error, "start")); } }, [ diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index 9161bac302..f2739088a6 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -5,6 +5,7 @@ import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useHuddle } from "@/features/huddle"; +import { formatHuddleActionError } from "@/features/huddle/lib/huddleError"; import { channelsQueryKey, useChannelsQuery, @@ -374,9 +375,7 @@ export function UserProfilePopover({ setOpen(false); } } catch (error) { - toast.error( - error instanceof Error ? error.message : "Failed to start huddle.", - ); + toast.error(formatHuddleActionError(error, "start")); } finally { if (isMountedRef.current) { setPendingAction(null); From 4a1b3ee1d1cce6ad38cc5b9fb66a21060528885b Mon Sep 17 00:00:00 2001 From: damienrj Date: Thu, 23 Jul 2026 11:53:22 -0700 Subject: [PATCH 2/2] End huddles after audio setup rollback --- desktop/src-tauri/src/huddle/mod.rs | 11 ++---- desktop/tests/e2e/channels.spec.ts | 58 ++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs index 8a8f1ec9ff..a815bf2d06 100644 --- a/desktop/src-tauri/src/huddle/mod.rs +++ b/desktop/src-tauri/src/huddle/mod.rs @@ -289,14 +289,9 @@ pub async fn start_huddle( // Audio relay failure is fatal — no point in a huddle without audio. if let Err(e) = post_connect_setup(&state, &ephemeral_channel_id).await { // Rollback: audio relay failed after state was committed. - // Archive the ephemeral channel and reset state. - if let Ok(archive_builder) = events::build_archive(ephemeral_uuid) { - if let Err(ae) = submit_event(archive_builder, &state).await { - eprintln!( - "buzz-desktop: rollback archive of {ephemeral_channel_id} failed: {ae}" - ); - } - } + // Publish the terminal lifecycle event before archiving so + // other clients do not reconstruct a phantom active huddle. + emit_end_and_archive(&parent_channel_id, &ephemeral_channel_id, &state).await; if let Ok(mut hs) = state.huddle_state.lock() { hs.reset_preserving_generation(); } diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 7da20334f3..7e8323feeb 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -1,6 +1,10 @@ import { expect, test } from "@playwright/test"; -import { KIND_TYPING_INDICATOR } from "../../src/shared/constants/kinds"; +import { + KIND_HUDDLE_ENDED, + KIND_HUDDLE_STARTED, + KIND_TYPING_INDICATOR, +} from "../../src/shared/constants/kinds"; import { TEST_IDENTITIES, installMockBridge, @@ -2794,6 +2798,58 @@ test("channel header omits the add agent action", async ({ page }) => { await expect(page.getByTestId("channel-management-trigger")).toBeVisible(); }); +test("huddle rollback end event clears the active header action", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await waitForMockLiveSubscription(page, "random", KIND_HUDDLE_STARTED); + + const ephemeralChannelId = "10000000-0000-4000-8000-000000000001"; + const createdAt = Math.floor(Date.now() / 1000); + + await page.evaluate( + ({ createdAt, ephemeralChannelId, kind }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "random", + content: JSON.stringify({ + ephemeral_channel_id: ephemeralChannelId, + }), + createdAt, + id: "1".repeat(64), + kind, + }); + }, + { createdAt, ephemeralChannelId, kind: KIND_HUDDLE_STARTED }, + ); + + await expect( + page.getByRole("button", { + name: "Join active huddle (1 participant)", + }), + ).toBeVisible(); + + await page.evaluate( + ({ createdAt, ephemeralChannelId, kind }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "random", + content: JSON.stringify({ + ephemeral_channel_id: ephemeralChannelId, + }), + createdAt, + id: "2".repeat(64), + kind, + }); + }, + { createdAt, ephemeralChannelId, kind: KIND_HUDDLE_ENDED }, + ); + + await expect( + page.getByTestId("channel-start-huddle-trigger"), + ).toHaveAttribute("aria-label", "Start huddle"); +}); + test("channel header actions show tooltips", async ({ page }) => { await page.goto("/"); await page.getByTestId("channel-random").click();