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
11 changes: 3 additions & 8 deletions desktop/src-tauri/src/huddle/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
3 changes: 3 additions & 0 deletions desktop/src/features/channels/ui/ChannelMembersBar.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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"}
Expand Down
5 changes: 3 additions & 2 deletions desktop/src/features/huddle/HuddleContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 2 additions & 3 deletions desktop/src/features/huddle/components/HuddleAttachment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
3 changes: 3 additions & 0 deletions desktop/src/features/huddle/components/HuddleIndicator.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
46 changes: 46 additions & 0 deletions desktop/src/features/huddle/lib/huddleError.test.mjs
Original file line number Diff line number Diff line change
@@ -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.",
);
});
37 changes: 37 additions & 0 deletions desktop/src/features/huddle/lib/huddleError.ts
Original file line number Diff line number Diff line change
@@ -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.";
}
5 changes: 2 additions & 3 deletions desktop/src/features/messages/ui/WaveMessageAttachment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"));
}
},
[
Expand Down
5 changes: 2 additions & 3 deletions desktop/src/features/profile/ui/UserProfilePopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
58 changes: 57 additions & 1 deletion desktop/tests/e2e/channels.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();
Expand Down
Loading