feat(nexus): mount phase 14 providers + drain voice queue in assistant

main.tsx adds VoiceProvider and CommandPaletteProvider to the provider
stack, placed above BrowserRouter so both the route tree and the
document-level Cmd+K listener see a stable context.

PersonalAssistant gains a single-shot effect that, on mount, drains
VoiceContext.queue via drainQueue() and feeds each transcript through
the existing handleSend pipeline. This implements spec section 5.5
(voice captured on non-Assistant routes streams through on arrival).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-11 13:23:30 +00:00
parent 2a950dedd0
commit 9b772aa1bd
2 changed files with 45 additions and 23 deletions

View file

@ -14,6 +14,8 @@ import { SidebarProvider } from "./context/SidebarContext";
import { DialogProvider } from "./context/DialogContext"; import { DialogProvider } from "./context/DialogContext";
import { ToastProvider } from "./context/ToastContext"; import { ToastProvider } from "./context/ToastContext";
import { ThemeProvider } from "./context/ThemeContext"; import { ThemeProvider } from "./context/ThemeContext";
import { VoiceProvider } from "./context/VoiceContext";
import { CommandPaletteProvider } from "./context/CommandPaletteContext";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { initPluginBridge } from "./plugins/bridge-init"; import { initPluginBridge } from "./plugins/bridge-init";
import { PluginLauncherProvider } from "./plugins/launchers"; import { PluginLauncherProvider } from "./plugins/launchers";
@ -41,29 +43,33 @@ createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ThemeProvider> <ThemeProvider>
<BrowserRouter> <VoiceProvider>
<CompanyProvider> <CommandPaletteProvider>
<ToastProvider> <BrowserRouter>
<LiveUpdatesProvider> <CompanyProvider>
<TooltipProvider> <ToastProvider>
<BreadcrumbProvider> <LiveUpdatesProvider>
<SidebarProvider> <TooltipProvider>
<PanelProvider> <BreadcrumbProvider>
<ChatPanelProvider> <SidebarProvider>
<PluginLauncherProvider> <PanelProvider>
<DialogProvider> <ChatPanelProvider>
<App /> <PluginLauncherProvider>
</DialogProvider> <DialogProvider>
</PluginLauncherProvider> <App />
</ChatPanelProvider> </DialogProvider>
</PanelProvider> </PluginLauncherProvider>
</SidebarProvider> </ChatPanelProvider>
</BreadcrumbProvider> </PanelProvider>
</TooltipProvider> </SidebarProvider>
</LiveUpdatesProvider> </BreadcrumbProvider>
</ToastProvider> </TooltipProvider>
</CompanyProvider> </LiveUpdatesProvider>
</BrowserRouter> </ToastProvider>
</CompanyProvider>
</BrowserRouter>
</CommandPaletteProvider>
</VoiceProvider>
</ThemeProvider> </ThemeProvider>
</QueryClientProvider> </QueryClientProvider>
</StrictMode> </StrictMode>

View file

@ -43,6 +43,7 @@ export function PersonalAssistant() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { pushToast } = useToast(); const { pushToast } = useToast();
const { activeConversationId, setActiveConversationId } = useChatPanel(); const { activeConversationId, setActiveConversationId } = useChatPanel();
const voice = useVoice();
const companyId = selectedCompany?.id ?? null; const companyId = selectedCompany?.id ?? null;
const companyPrefix = useMemo( const companyPrefix = useMemo(
@ -195,6 +196,21 @@ export function PersonalAssistant() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [promote.state.kind]); }, [promote.state.kind]);
// Phase 14 — drain any voice transcripts captured from non-Assistant
// routes into the chat pipeline. Runs once per mount; if the queue is
// empty the effect is a no-op.
const voiceDrainedRef = useRef(false);
useEffect(() => {
if (voiceDrainedRef.current) return;
if (!companyId) return;
if (voice.queue.length === 0) return;
voiceDrainedRef.current = true;
const drained = voice.drainQueue();
for (const text of drained) {
handleSend(text);
}
}, [companyId, voice, handleSend]);
const handlePromote = useCallback(() => { const handlePromote = useCallback(() => {
if (!selectedConvId || !canPromote) return; if (!selectedConvId || !canPromote) return;
promote.startPrompting(); promote.startPrompting();