fix(lotus): explain a failed voice connection and offer Try again

"Failed to connect to Livekit server (Reason: ServerUnreachable)" left a
user with nothing to try; they retried for a long time and gave up.

- Title "Couldn't connect to voice". The message depends on the reason:
  unreachable/timeout-style errors explain that voice needs its own live
  connection, which VPNs, antivirus web shields and some networks block,
  even when chat works, and say what to try; NotAllowed says the call may
  be full or access changed.
- The raw reason, status and message move under Technical details.
- LivekitConnectionError and PeerConnectionTimeoutError now get a
  Try again button (the existing reconnect path), shown as primary: the
  Lotus theme renders `secondary` as dark-on-dark.

Verified in the Lotus client against a local LiveKit with the signal
socket and validate request blocked: the dialog appears after ~20 s;
unblocking and pressing Try again joins with the mic published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-23 20:51:43 -04:00
co-authored by Claude Opus 5.5
parent e9723a21b5
commit 3e69a18a39
5 changed files with 214 additions and 75 deletions
+24 -7
View File
@@ -365,17 +365,18 @@ describe("LiveKit ConnectionError variants", () => {
expectedReason: "InternalError",
},
])(
"should display LiveKit $name error correctly",
"should explain the LiveKit $name error and offer a retry",
async ({ error, expectedReason }) => {
const TestComponent = (): ReactNode => {
throw new LivekitConnectionError(error);
};
const recoveryActionHandler = vi.fn(async () => Promise.resolve());
const { asFragment } = render(
<BrowserRouter>
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
recoveryActionHandler={recoveryActionHandler}
widget={null}
>
<TestComponent />
@@ -383,12 +384,28 @@ describe("LiveKit ConnectionError variants", () => {
</BrowserRouter>,
);
// Check title
await screen.findByText("Failed to connect to Livekit server");
await screen.findByText("Couldn’t connect to voice");
// Check that reason is displayed in the description
expect(screen.getByText(/Reason:/i)).toBeInTheDocument();
expect(screen.getByText(expectedReason)).toBeInTheDocument();
// [lotus] Plain-language guidance instead of "(Reason: X)".
expect(
screen.getByText(
expectedReason === "NotAllowed"
? /The voice server didn’t let you in/
: /Your device couldn’t reach the voice server/,
),
).toBeInTheDocument();
// The raw reason is still there, under Technical details.
expect(screen.getByText("Technical details")).toBeInTheDocument();
expect(
screen.getByText(new RegExp(`Reason: ${expectedReason}`), {
selector: "pre",
}),
).toBeInTheDocument();
// Try again re-enters the call.
await userEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(recoveryActionHandler).toHaveBeenCalledWith("reconnect");
expect(asFragment()).toMatchSnapshot();
},