Skip to content
Closed
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
6 changes: 3 additions & 3 deletions app/src/components/skills/MeetingBotsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ interface ModalProps {
onToast?: (toast: Toast) => void;
}

function MeetingBotsModal({ onClose, onToast }: ModalProps) {
export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
const { t } = useT();
const [platform, setPlatform] = useState<MascotMeetPlatform>('gmeet');
const [meetUrl, setMeetUrl] = useState('');
Expand Down Expand Up @@ -282,10 +282,10 @@ function MeetingBotsModal({ onClose, onToast }: ModalProps) {
disabled={submitting || isComingSoon || !meetUrl.trim()}
className="rounded-xl bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 disabled:cursor-not-allowed disabled:bg-stone-200 dark:disabled:bg-neutral-700 disabled:text-stone-400 dark:disabled:text-neutral-500">
{isComingSoon
? `${selected.label} ${t('skills.meetingBots.comingSoon')}`
? t('skills.meetingBots.comingSoon').replace('{label}', selected.label)
: submitting
? t('skills.meetingBots.starting')
: `${t('skills.meetingBots.sendTo')} ${selected.label}`}
: t('skills.meetingBots.sendTo').replace('{label}', selected.label)}
</button>
</div>
</form>
Expand Down
12 changes: 11 additions & 1 deletion app/src/components/skills/__tests__/MeetingBotsCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import MeetingBotsCard from '../MeetingBotsCard';
import MeetingBotsCard, { MeetingBotsModal } from '../MeetingBotsCard';

const joinMock = vi.fn();

Expand Down Expand Up @@ -126,4 +126,14 @@ describe('MeetingBotsCard', () => {
const submit = screen.getByRole('button', { name: /coming soon/i });
expect(submit).toBeDisabled();
});

// MeetingBotsModal is exported standalone so HumanPage (and any future
// surface) can open the same join flow without the banner chrome.
it('exposes MeetingBotsModal as a standalone export usable without the banner', () => {
const onClose = vi.fn();
render(<MeetingBotsModal onClose={onClose} />);
expect(screen.getByRole('dialog')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
24 changes: 24 additions & 0 deletions app/src/features/human/HumanPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ vi.mock('./Mascot', () => ({

vi.mock('./useHumanMascot', () => ({ useHumanMascot: () => ({ face: 'idle', visemes: [] }) }));

// Stub the meeting-bots modal — the real component imports apiClient and a
// network-aware service singleton. The HumanPage tests only care that the pill
// toggles the modal open, not the modal's submit machinery (that lives in
// MeetingBotsCard.test.tsx).
vi.mock('../../components/skills/MeetingBotsCard', () => ({
default: () => null,
MeetingBotsModal: ({ onClose }: { onClose: () => void }) => (
<div role="dialog" data-testid="meeting-bots-modal-stub">
<button type="button" onClick={onClose}>
Close
</button>
</div>
),
}));

const SPEAK_REPLIES_KEY = 'human.speakReplies';

function buildMinimalStore() {
Expand Down Expand Up @@ -102,6 +117,15 @@ describe('HumanPage — speak-replies localStorage persistence', () => {
expect(checkbox).toBeChecked();
});

it('opens the meeting-bots modal when the join-meeting pill is clicked', async () => {
renderHumanPage();
expect(screen.queryByTestId('meeting-bots-modal-stub')).not.toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByTestId('human-join-meeting-pill'));
});
expect(screen.getByTestId('meeting-bots-modal-stub')).toBeInTheDocument();
});

it('renders sub-mascots for the selected thread subagent timeline', () => {
const store = buildMinimalStore();
store.dispatch(setSelectedThread('thread-subagents'));
Expand Down
16 changes: 16 additions & 0 deletions app/src/features/human/HumanPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';

import { MeetingBotsModal } from '../../components/skills/MeetingBotsCard';
import { useT } from '../../lib/i18n/I18nContext';
import Conversations from '../../pages/Conversations';
import type { ToolTimelineEntry } from '../../store/chatRuntimeSlice';
Expand All @@ -21,6 +22,7 @@ const HumanPage = () => {
const raw = window.localStorage.getItem(SPEAK_REPLIES_KEY);
return raw === null ? true : raw === '1';
});
const [joinMeetingOpen, setJoinMeetingOpen] = useState(false);

useEffect(() => {
window.localStorage.setItem(SPEAK_REPLIES_KEY, speakReplies ? '1' : '0');
Expand Down Expand Up @@ -65,6 +67,20 @@ const HumanPage = () => {
{t('voice.pushToTalk')}
</label>

{/* "Send OpenHuman to a meeting" — opens the same backend-bot modal as
the Skills tab so the mascot joins as a separate participant (its
own audio identity, no echo against the user's mic). */}
<button
type="button"
onClick={() => setJoinMeetingOpen(true)}
data-testid="human-join-meeting-pill"
className="absolute top-4 left-44 z-10 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-primary-500 text-white text-xs font-medium shadow-soft hover:bg-primary-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-300">
<span aria-hidden="true">📞</span>
{t('skills.meetingBots.modalTitle')}
</button>

{joinMeetingOpen && <MeetingBotsModal onClose={() => setJoinMeetingOpen(false)} />}

{/* Chat sidebar — vertically centered above the BottomTabBar (~80px). */}
<div className="absolute right-4 top-0 bottom-20 z-10 flex items-center">
<aside className="w-[420px] h-[min(720px,calc(100vh-160px))] rounded-2xl border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-soft flex flex-col overflow-hidden">
Expand Down
4 changes: 2 additions & 2 deletions app/src/pages/Skills.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { ToastContainer } from '../components/intelligence/Toast';
import AutocompleteSetupModal from '../components/skills/AutocompleteSetupModal';
import CreateSkillModal from '../components/skills/CreateSkillModal';
import InstallSkillDialog from '../components/skills/InstallSkillDialog';
// import MeetingBotsCard from '../components/skills/MeetingBotsCard';
import MeetingBotsCard from '../components/skills/MeetingBotsCard';
import ScreenIntelligenceSetupModal from '../components/skills/ScreenIntelligenceSetupModal';
import UnifiedSkillCard from '../components/skills/SkillCard';
import { SKILL_CATEGORY_ORDER, type SkillCategory } from '../components/skills/skillCategories';
Expand Down Expand Up @@ -838,7 +838,7 @@ export default function Skills() {
</div>
)}

{/* <MeetingBotsCard onToast={addToast} /> */}
<MeetingBotsCard onToast={addToast} />

<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 shadow-soft animate-fade-up">
<div className="px-1 pb-3 pt-1">
Expand Down
2 changes: 1 addition & 1 deletion app/src/services/meetCallService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export interface MascotJoinMeetingResult {
* without leaking the underlying paid-plan rule.
*/
export const SERVER_OVERLOADED_MESSAGE =
'OpenHuman is under heavy load right now. Please try again in a few minutes.';
'Mascot streaming capacity is exhausted. Please try again later.';

export interface MascotJoinMeetingError {
/** User-safe error text. Falls back to a generic message. */
Expand Down
Loading