diff --git a/packages/web/src/components/voice/VoiceControls.tsx b/packages/web/src/components/voice/VoiceControls.tsx
index c0e45c60..c31494fd 100644
--- a/packages/web/src/components/voice/VoiceControls.tsx
+++ b/packages/web/src/components/voice/VoiceControls.tsx
@@ -1,5 +1,7 @@
import React, { useState, useRef } from 'react';
+import { useNavigate } from 'react-router-dom';
import { useVoiceStore } from '../../stores/voiceStore';
+import { useChatStore } from '../../stores/chatStore';
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
import { getActiveRoom } from '../../hooks/useLiveKit';
import { wsSend } from '../../hooks/useWebSocket';
@@ -16,6 +18,10 @@ import { handleCameraAction } from '../../utils/voiceActions';
*/
export function VoiceControls() {
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
+ const currentVoiceSpaceId = useVoiceStore((s) => s.currentVoiceSpaceId);
+ const currentVoiceChannelName = useVoiceStore((s) => s.currentVoiceChannelName);
+ const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
+ const navigate = useNavigate();
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled);
@@ -42,7 +48,20 @@ export function VoiceControls() {
if (!currentVoiceChannelId && !activeDmCall) return null;
const channel = channels.find(c => c.id === currentVoiceChannelId);
- const channelName = channel?.name ?? (activeDmCall ? 'DM Call' : 'Voice Channel');
+ const channelName =
+ channel?.name ?? currentVoiceChannelName ?? (activeDmCall ? 'DM Call' : 'Voice Channel');
+
+ // Jump back to where the call is happening. DM calls live under @me; space
+ // calls under the space captured at join time.
+ const callSpaceId = activeDmCall ? '@me' : currentVoiceSpaceId;
+ const callChannelId = activeDmCall ? activeDmCall.dmChannelId : currentVoiceChannelId;
+ const canGoToCall = Boolean(callChannelId && callSpaceId && !connectionError);
+
+ const handleGoToCall = () => {
+ if (!canGoToCall || !callChannelId) return;
+ setCurrentChannel(callChannelId);
+ navigate(`/channels/${callSpaceId}/${callChannelId}`);
+ };
const handleScreenShare = async () => {
const room = getActiveRoom();
@@ -120,9 +139,19 @@ export function VoiceControls() {
{connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...'}
-
- {connectionError ? connectionError : channelName}
-
+ {canGoToCall ? (
+
+ ) : (
+
+ {connectionError ? connectionError : channelName}
+
+ )}
diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts
index 82b486db..6e54b7d1 100644
--- a/packages/web/src/stores/voiceStore.ts
+++ b/packages/web/src/stores/voiceStore.ts
@@ -17,6 +17,15 @@ export interface ScreenShareConfig {
interface VoiceState {
voiceUsers: Map; // channelId → userIds
currentVoiceChannelId: string | null;
+ /**
+ * Space and name of the channel the call is in, captured at join time.
+ * `channels` in spaceStore only holds the space the user is *viewing*, so
+ * once they navigate elsewhere the call's channel is no longer resolvable
+ * from it — these keep the voice panel able to name and link to the call.
+ * Transient: intentionally absent from `partialize`.
+ */
+ currentVoiceSpaceId: string | null;
+ currentVoiceChannelName: string | null;
isMuted: boolean;
isDeafened: boolean;
isCameraOn: boolean;
@@ -85,7 +94,7 @@ interface VoiceState {
setVoiceUsers: (channelId: string, userIds: string[]) => void;
addVoiceUser: (channelId: string, userId: string) => void;
removeVoiceUser: (channelId: string, userId: string) => void;
- setCurrentVoiceChannel: (channelId: string | null) => void;
+ setCurrentVoiceChannel: (channelId: string | null, spaceId?: string | null, channelName?: string | null) => void;
setParticipants: (participants: ParticipantInfo[]) => void;
setSpeakingParticipants: (ids: Set) => void;
setConnectionError: (error: string | null) => void;
@@ -158,6 +167,8 @@ export const useVoiceStore = create()(
(set, get) => ({
voiceUsers: new Map(),
currentVoiceChannelId: null,
+ currentVoiceSpaceId: null,
+ currentVoiceChannelName: null,
isMuted: false,
pttActive: false,
isDeafened: false,
@@ -349,8 +360,10 @@ export const useVoiceStore = create()(
});
},
- setCurrentVoiceChannel: (channelId) => set({
+ setCurrentVoiceChannel: (channelId, spaceId = null, channelName = null) => set({
currentVoiceChannelId: channelId,
+ currentVoiceSpaceId: channelId ? spaceId : null,
+ currentVoiceChannelName: channelId ? channelName : null,
activeDmCall: null // Clear active DM call when joining a server channel
}),
@@ -516,6 +529,8 @@ export const useVoiceStore = create()(
voiceUsers: new Map(),
voiceUserStates: new Map(),
currentVoiceChannelId: null,
+ currentVoiceSpaceId: null,
+ currentVoiceChannelName: null,
participants: [],
speakingParticipantIds: new Set(),
speakingUserIds: new Set(),
diff --git a/packages/web/src/utils/voice.ts b/packages/web/src/utils/voice.ts
index 65fd9892..b6bb5873 100644
--- a/packages/web/src/utils/voice.ts
+++ b/packages/web/src/utils/voice.ts
@@ -152,7 +152,15 @@ export function joinVoiceChannel(
if (myOldId) removeVoiceUser(currentVoiceChannelId, myOldId);
}
- setCurrentVoiceChannel(channelId);
+ // Capture the space and name now: a voice channel can only be joined from
+ // within its own space, but the user may navigate away afterwards — at which
+ // point spaceStore.channels no longer resolves this channel.
+ const spaceStore = useSpaceStore.getState();
+ setCurrentVoiceChannel(
+ channelId,
+ spaceStore.currentSpaceId ?? null,
+ spaceStore.channels.find((c) => c.id === channelId)?.name ?? null,
+ );
// Optimistic: immediately show self in new channel (using origin-aware ID)
const myNewId = getMyUserIdForOrigin(getChannelOrigin(channelId));
if (myNewId) addVoiceUser(channelId, myNewId);