Files
buzz/desktop/src/app/useHuddlePresentation.ts
klopez4212 ce3cf3cd25 Polish Huddle voice controls (#4694)
## Summary

- add a visible Stop control for interrupting agent speech
- make push-to-talk available by default while preserving manual mute
controls
- refine agent management, muted audio states, drawer layering, and
return navigation
- suppress duplicate notification sounds for Huddle messages

## Why

Huddles could trap users behind long agent speech, hide useful agent
controls, and leave temporary Huddle state visible after the call. The
drawer also regressed when the terminal substrate began painting behind
the rounded app surface.

## Validation

- `just desktop-ci`
- focused Huddle Playwright coverage for the drawer, speech
interruption, agent picker, and leave navigation

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-04 13:07:50 -07:00

466 lines
15 KiB
TypeScript

import { useQueryClient } from "@tanstack/react-query";
import { useLocation } from "@tanstack/react-router";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import * as React from "react";
import {
loadHuddleBackingChannelIds,
rememberHuddleBackingChannelId,
} from "@/app/huddleBackingChannelStorage";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { channelsQueryKey } from "@/features/channels/hooks";
import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow";
import {
channelMessagesKey,
channelWindowKey,
} from "@/features/messages/lib/messageQueryKeys";
type HuddleTranscriptRouteState = {
phase:
| "idle"
| "creating"
| "connecting"
| "connected"
| "active"
| "leaving";
parent_channel_id: string | null;
ephemeral_channel_id: string | null;
huddle_thread_event_id: string | null;
};
export function useHuddlePresentation() {
const huddleRoomChannelId = huddleWindowChannelId();
const isHuddleRoom = huddleRoomChannelId !== null;
const [isHuddleDrawerOpen, setIsHuddleDrawerOpen] = React.useState(false);
const [isHuddleCompanionOpen, setIsHuddleCompanionOpen] =
React.useState(false);
const [isHuddleStartPending, setIsHuddleStartPending] = React.useState(false);
const [revealedHuddleChannelIds, setRevealedHuddleChannelIds] =
React.useState<ReadonlySet<string>>(() => new Set());
const [huddleBackingChannelIds, setHuddleBackingChannelIds] = React.useState<
ReadonlySet<string>
>(loadHuddleBackingChannelIds);
const activeHuddleChannelIdRef = React.useRef<string | null>(null);
const huddleCompanionChannelIdRef = React.useRef<string | null>(null);
const huddleCompanionDismissedChannelIdRef = React.useRef<string | null>(
null,
);
const huddleCompanionOpenPromiseRef = React.useRef<Promise<void> | null>(
null,
);
const activeHuddleParentChannelIdRef = React.useRef<string | null>(null);
const [huddleTranscriptRoute, setHuddleTranscriptRoute] =
React.useState<HuddleTranscriptRouteState | null>(null);
const location = useLocation();
const queryClient = useQueryClient();
const { goChannel } = useAppNavigation();
React.useEffect(() => {
if (!isHuddleRoom) return;
let cancelled = false;
let unlisten: (() => void) | null = null;
const syncRoute = (state: HuddleTranscriptRouteState) => {
if (!cancelled) setHuddleTranscriptRoute(state);
};
void invoke<HuddleTranscriptRouteState>("get_huddle_state")
.then(syncRoute)
.catch((error) => {
console.error("Failed to resolve huddle transcript route:", error);
if (!cancelled) {
setHuddleTranscriptRoute({
ephemeral_channel_id: huddleRoomChannelId,
huddle_thread_event_id: null,
parent_channel_id: null,
phase: "active",
});
}
});
void listen<HuddleTranscriptRouteState>("huddle-state-changed", (event) =>
syncRoute(event.payload),
).then((cleanup) => {
if (cancelled) cleanup();
else unlisten = cleanup;
});
return () => {
cancelled = true;
unlisten?.();
};
}, [huddleRoomChannelId, isHuddleRoom]);
const huddleRouteResolved = huddleTranscriptRoute !== null;
const huddleRouteEphemeralChannelId =
huddleTranscriptRoute?.ephemeral_channel_id ?? null;
const huddleRouteIsActive = huddleTranscriptRoute?.phase === "active";
const huddleRouteDestinationChannelId =
huddleRouteEphemeralChannelId ?? huddleRoomChannelId;
const huddleRouteMatchesLocation = Boolean(
huddleRouteDestinationChannelId &&
location.pathname === `/channels/${huddleRouteDestinationChannelId}`,
);
const isHuddleRoomStarting =
isHuddleRoom &&
(!huddleRouteResolved ||
!huddleRouteIsActive ||
!huddleRouteMatchesLocation);
React.useEffect(() => {
if (!huddleRoomChannelId || !huddleRouteResolved || !huddleRouteIsActive) {
return;
}
let cancelled = false;
const channelId = huddleRouteEphemeralChannelId ?? huddleRoomChannelId;
void Promise.all([
queryClient.invalidateQueries({ queryKey: channelsQueryKey }),
queryClient.invalidateQueries({
queryKey: channelMessagesKey(channelId),
}),
queryClient.invalidateQueries({ queryKey: channelWindowKey(channelId) }),
]).then(() => {
if (!cancelled) void goChannel(channelId, { replace: true });
});
return () => {
cancelled = true;
};
}, [
goChannel,
huddleRoomChannelId,
huddleRouteEphemeralChannelId,
huddleRouteIsActive,
huddleRouteResolved,
queryClient,
]);
const handleHuddleStartPendingChange = React.useCallback(
(pending: boolean) => {
setIsHuddleStartPending(pending);
if (pending) setIsHuddleDrawerOpen(false);
},
[],
);
const handleHuddleVisibilityChange = React.useCallback(
(visible: boolean) => {
setIsHuddleDrawerOpen(
visible && !isHuddleStartPending && !isHuddleCompanionOpen,
);
},
[isHuddleCompanionOpen, isHuddleStartPending],
);
const hideHuddleChannel = React.useCallback(
(ephemeralChannelId: string | null | undefined) => {
if (!ephemeralChannelId) return;
setRevealedHuddleChannelIds((current) => {
if (!current.has(ephemeralChannelId)) return current;
const next = new Set(current);
next.delete(ephemeralChannelId);
return next;
});
},
[],
);
const trackHuddleBackingChannel = React.useCallback(
(ephemeralChannelId: string) => {
rememberHuddleBackingChannelId(ephemeralChannelId);
setHuddleBackingChannelIds((current) => {
if (current.has(ephemeralChannelId)) return current;
const next = new Set(current);
next.add(ephemeralChannelId);
return next;
});
},
[],
);
const revealHuddleChannel = React.useCallback(
(ephemeralChannelId: string) => {
setRevealedHuddleChannelIds((current) => {
if (current.has(ephemeralChannelId)) return current;
const next = new Set(current);
next.add(ephemeralChannelId);
return next;
});
},
[],
);
const returnMainWindowToHuddleParent = React.useCallback(
(state: HuddleTranscriptRouteState) => {
const ephemeralChannelId = state.ephemeral_channel_id;
const parentChannelId = state.parent_channel_id;
if (parentChannelId) {
activeHuddleParentChannelIdRef.current = parentChannelId;
}
if (
ephemeralChannelId &&
parentChannelId &&
location.pathname === `/channels/${ephemeralChannelId}`
) {
void goChannel(parentChannelId, { replace: true });
}
},
[goChannel, location.pathname],
);
const returnToHuddleParentAfterEnd = React.useCallback(
(ephemeralChannelId: string | null, parentChannelId: string | null) => {
if (
ephemeralChannelId &&
parentChannelId &&
location.pathname === `/channels/${ephemeralChannelId}`
) {
void goChannel(parentChannelId, { replace: true });
}
},
[goChannel, location.pathname],
);
const handleHuddleCompanionOpen = React.useCallback(() => {
const ephemeralChannelId = activeHuddleChannelIdRef.current;
huddleCompanionDismissedChannelIdRef.current = null;
hideHuddleChannel(ephemeralChannelId);
setIsHuddleDrawerOpen(false);
setIsHuddleCompanionOpen(true);
const parentChannelId = activeHuddleParentChannelIdRef.current;
if (
ephemeralChannelId &&
parentChannelId &&
location.pathname === `/channels/${ephemeralChannelId}`
) {
void goChannel(parentChannelId, { replace: true });
return;
}
void invoke<HuddleTranscriptRouteState>("get_huddle_state")
.then(returnMainWindowToHuddleParent)
.catch((error) => {
console.error("Failed to restore the huddle parent channel:", error);
});
}, [
goChannel,
hideHuddleChannel,
location.pathname,
returnMainWindowToHuddleParent,
]);
const openHuddleCompanion = React.useCallback(
(ephemeralChannelId: string) => {
activeHuddleChannelIdRef.current = ephemeralChannelId;
trackHuddleBackingChannel(ephemeralChannelId);
if (huddleCompanionDismissedChannelIdRef.current === ephemeralChannelId) {
return Promise.resolve();
}
huddleCompanionDismissedChannelIdRef.current = null;
hideHuddleChannel(ephemeralChannelId);
setIsHuddleDrawerOpen(false);
setIsHuddleCompanionOpen(true);
if (
huddleCompanionChannelIdRef.current === ephemeralChannelId &&
huddleCompanionOpenPromiseRef.current
) {
return huddleCompanionOpenPromiseRef.current;
}
huddleCompanionChannelIdRef.current = ephemeralChannelId;
const openPromise = invoke<void>("open_huddle_window").catch((error) => {
if (huddleCompanionChannelIdRef.current === ephemeralChannelId) {
huddleCompanionChannelIdRef.current = null;
huddleCompanionOpenPromiseRef.current = null;
setIsHuddleCompanionOpen(false);
}
throw error;
});
huddleCompanionOpenPromiseRef.current = openPromise;
return openPromise;
},
[hideHuddleChannel, trackHuddleBackingChannel],
);
const handleHuddleStarted = React.useCallback(
async (ephemeralChannelId: string) => {
try {
await openHuddleCompanion(ephemeralChannelId);
} catch (error) {
revealHuddleChannel(ephemeralChannelId);
throw error;
}
},
[openHuddleCompanion, revealHuddleChannel],
);
const viewHuddleChannel = React.useCallback(
(ephemeralChannelId: string) => {
revealHuddleChannel(ephemeralChannelId);
void queryClient.invalidateQueries({ queryKey: channelsQueryKey });
void queryClient.invalidateQueries({
queryKey: channelMessagesKey(ephemeralChannelId),
});
void queryClient.invalidateQueries({
queryKey: channelWindowKey(ephemeralChannelId),
});
void goChannel(ephemeralChannelId);
},
[goChannel, queryClient, revealHuddleChannel],
);
const showHuddleInMainApp = React.useCallback(
(ephemeralChannelId: string) => {
activeHuddleChannelIdRef.current = ephemeralChannelId;
trackHuddleBackingChannel(ephemeralChannelId);
viewHuddleChannel(ephemeralChannelId);
},
[trackHuddleBackingChannel, viewHuddleChannel],
);
const handleSidebarChannelSelect = React.useCallback(
(channelId: string) => {
if (
isHuddleDrawerOpen &&
channelId === activeHuddleChannelIdRef.current
) {
showHuddleInMainApp(channelId);
return;
}
void goChannel(channelId);
},
[goChannel, isHuddleDrawerOpen, showHuddleInMainApp],
);
const handleHuddleEnded = React.useCallback(
(ephemeralChannelId: string | null) => {
const endedChannelId =
ephemeralChannelId ?? activeHuddleChannelIdRef.current;
returnToHuddleParentAfterEnd(
endedChannelId,
activeHuddleParentChannelIdRef.current,
);
hideHuddleChannel(endedChannelId);
activeHuddleChannelIdRef.current = null;
activeHuddleParentChannelIdRef.current = null;
huddleCompanionChannelIdRef.current = null;
huddleCompanionDismissedChannelIdRef.current = null;
huddleCompanionOpenPromiseRef.current = null;
setIsHuddleCompanionOpen(false);
void queryClient.invalidateQueries({ queryKey: channelsQueryKey });
},
[hideHuddleChannel, queryClient, returnToHuddleParentAfterEnd],
);
React.useEffect(() => {
if (isHuddleRoom) return;
let cancelled = false;
let unlisten: (() => void) | null = null;
void listen("huddle-companion-returned", () => {
if (cancelled) return;
huddleCompanionDismissedChannelIdRef.current =
activeHuddleChannelIdRef.current;
huddleCompanionChannelIdRef.current = null;
huddleCompanionOpenPromiseRef.current = null;
setIsHuddleCompanionOpen(false);
setIsHuddleDrawerOpen(true);
void invoke<HuddleTranscriptRouteState>("get_huddle_state")
.then((state) => {
if (!state.ephemeral_channel_id) return;
if (state.parent_channel_id) {
activeHuddleParentChannelIdRef.current = state.parent_channel_id;
}
showHuddleInMainApp(state.ephemeral_channel_id);
})
.catch((error) => {
console.error("Failed to open huddle in the main app:", error);
});
}).then((cleanup) => {
if (cancelled) cleanup();
else unlisten = cleanup;
});
return () => {
cancelled = true;
unlisten?.();
};
}, [isHuddleRoom, showHuddleInMainApp]);
React.useEffect(() => {
let cancelled = false;
let unlisten: (() => void) | null = null;
void invoke<HuddleTranscriptRouteState>("get_huddle_state")
.then((state) => {
if (cancelled || !state.ephemeral_channel_id) return;
activeHuddleChannelIdRef.current = state.ephemeral_channel_id;
trackHuddleBackingChannel(state.ephemeral_channel_id);
if (state.parent_channel_id) {
activeHuddleParentChannelIdRef.current = state.parent_channel_id;
}
})
.catch(() => {
/* lifecycle events remain authoritative */
});
listen<HuddleTranscriptRouteState>("huddle-state-changed", (event) => {
if (cancelled) return;
if (event.payload.ephemeral_channel_id) {
activeHuddleChannelIdRef.current = event.payload.ephemeral_channel_id;
trackHuddleBackingChannel(event.payload.ephemeral_channel_id);
}
if (event.payload.parent_channel_id) {
activeHuddleParentChannelIdRef.current =
event.payload.parent_channel_id;
}
if (
!isHuddleRoom &&
event.payload.phase === "creating" &&
event.payload.ephemeral_channel_id
) {
void openHuddleCompanion(event.payload.ephemeral_channel_id).catch(
(error) => {
console.error("Failed to open starting huddle window:", error);
},
);
}
if (event.payload.phase === "idle") {
const endedChannelId = activeHuddleChannelIdRef.current;
const parentChannelId = activeHuddleParentChannelIdRef.current;
returnToHuddleParentAfterEnd(endedChannelId, parentChannelId);
hideHuddleChannel(endedChannelId);
activeHuddleChannelIdRef.current = null;
activeHuddleParentChannelIdRef.current = null;
huddleCompanionChannelIdRef.current = null;
huddleCompanionDismissedChannelIdRef.current = null;
huddleCompanionOpenPromiseRef.current = null;
setIsHuddleDrawerOpen(false);
setIsHuddleCompanionOpen(false);
setIsHuddleStartPending(false);
void queryClient.invalidateQueries({ queryKey: channelsQueryKey });
}
}).then((cleanup) => {
if (cancelled) cleanup();
else unlisten = cleanup;
});
return () => {
cancelled = true;
unlisten?.();
};
}, [
hideHuddleChannel,
isHuddleRoom,
openHuddleCompanion,
queryClient,
returnToHuddleParentAfterEnd,
trackHuddleBackingChannel,
]);
return {
handleHuddleCompanionOpen,
handleHuddleEnded,
handleHuddleStartPendingChange,
handleHuddleStarted,
handleHuddleVisibilityChange,
handleSidebarChannelSelect,
huddleBackingChannelIds,
revealedHuddleChannelIds,
isHuddleCompanionOpen,
isHuddleDrawerOpen,
isHuddleRoom,
isHuddleRoomStarting,
isHuddleStartPending,
showHuddleInMainApp,
viewHuddleChannel,
};
}