Skip to content
Merged
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
34 changes: 34 additions & 0 deletions app/src/components/meetings/MeetDefaultsDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
SettingsSelect,
SettingsStatusLine,
SettingsSwitch,
SettingsTextField,
} from '../settings/controls';

const log = debug('meetings:defaults-drawer');
Expand Down Expand Up @@ -80,6 +81,9 @@ export function MeetDefaultsDrawer({ open, onClose }: MeetDefaultsDrawerProps) {
const [autoSummarize, setAutoSummarize] = useState<MeetAutoSummarizePolicy>('ask');
const [listenOnly, setListenOnly] = useState(true);
const [ingestTranscripts, setIngestTranscripts] = useState(false);
// The user's meeting display name — reused as the bot's reply anchor on every
// join. Persisted on blur (a text field must not save per keystroke).
const [replyDisplayName, setReplyDisplayName] = useState('');

// Per-platform overrides: key → MeetAutoJoinPolicy | undefined (undefined = use default)
const [platformPolicies, setPlatformPolicies] = useState<Record<string, PlatformPolicy>>({});
Expand Down Expand Up @@ -111,6 +115,7 @@ export function MeetDefaultsDrawer({ open, onClose }: MeetDefaultsDrawerProps) {
setAutoSummarize(s.auto_summarize_policy);
setListenOnly(s.listen_only_default);
setIngestTranscripts(s.ingest_backend_transcripts);
setReplyDisplayName(s.reply_display_name ?? '');
// Build per-platform state: stored as "ask_each_time"|"always"|"never", display as that or "default"
const pp: Record<string, PlatformPolicy> = {};
const stored = s.platform_auto_join_policies ?? {};
Expand Down Expand Up @@ -189,6 +194,14 @@ export function MeetDefaultsDrawer({ open, onClose }: MeetDefaultsDrawerProps) {
void persist('listen_only_default', { listen_only_default: next }, () => setListenOnly(prev));
};

// Persist the display name on blur (not per keystroke). Trim before saving so
// the anchor match is clean; skip the write when nothing changed.
const handleReplyDisplayNameBlur = () => {
const trimmed = replyDisplayName.trim();
if (trimmed !== replyDisplayName) setReplyDisplayName(trimmed);
void persist('reply_display_name', { reply_display_name: trimmed });
};

const handleIngestChange = (next: boolean) => {
const prev = ingestTranscripts;
setIngestTranscripts(next);
Expand Down Expand Up @@ -291,6 +304,27 @@ export function MeetDefaultsDrawer({ open, onClose }: MeetDefaultsDrawerProps) {
/>
</SettingsSection>

{/* Reply anchor: the user's display name, reused on every join so
the bot knows who to reply to (otherwise it stays listen-only). */}
<SettingsSection>
<SettingsRow
stacked
htmlFor="drawer-input-reply-display-name"
label={t('skills.meetingBots.replyName.label')}
description={t('skills.meetingBots.replyName.description')}
control={
<SettingsTextField
id="drawer-input-reply-display-name"
value={replyDisplayName}
onChange={e => setReplyDisplayName(e.target.value)}
onBlur={handleReplyDisplayNameBlur}
placeholder={t('skills.meetingBots.replyName.placeholder')}
aria-label={t('skills.meetingBots.replyName.label')}
/>
}
/>
</SettingsSection>

{/* Global auto-join */}
<SettingsSection title={t('skills.meetingBots.defaults.globalPolicy')}>
<SettingsRow
Expand Down
17 changes: 15 additions & 2 deletions app/src/components/meetings/MeetingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useT } from '../../lib/i18n/I18nContext';
import { selectBackendMeetStatus } from '../../store/backendMeetSlice';
import { useAppSelector } from '../../store/hooks';
import { isTauri, openhumanGetMeetSettings } from '../../utils/tauriCommands';
import RecallCalendarCard from '../recallCalendar/RecallCalendarCard';
import BetaBanner from '../ui/BetaBanner';
import { ActiveMeetingBanner } from './ActiveMeetingBanner';
import HistorySection from './HistorySection';
Expand All @@ -36,6 +37,9 @@ export default function MeetingsPage({ onToast }: MeetingsPageProps) {
const [drawerOpen, setDrawerOpen] = useState(false);
// watchCalendar: null = unknown (don't show hint), false = off (show hint when there are meetings)
const [watchCalendar, setWatchCalendar] = useState<boolean | null>(null);
// The saved meeting display name — passed to UpcomingTable so "Join now" uses
// it as the reply anchor (and joins in reply mode instead of listen-only).
const [replyDisplayName, setReplyDisplayName] = useState('');
// Show the live banner while joining or in an active meeting. All other
// states ('idle', 'ended', 'error') render the composer so the user can
// submit a new join or see the inline error from a failed attempt.
Expand Down Expand Up @@ -69,6 +73,7 @@ export default function MeetingsPage({ onToast }: MeetingsPageProps) {
if (!cancelled) {
log('[page] watch_calendar=%s', resp.result.watch_calendar);
setWatchCalendar(resp.result.watch_calendar ?? false);
setReplyDisplayName(resp.result.reply_display_name ?? '');
}
})
.catch(err => {
Expand Down Expand Up @@ -115,7 +120,12 @@ export default function MeetingsPage({ onToast }: MeetingsPageProps) {
<MeetComposer onToast={onToast} hasSubmittedRef={hasSubmittedRef} />
)}

<UpcomingTable watchCalendar={watchCalendar} />
{/* Recall Calendar connect tile — meeting-specific, so it lives here
rather than on the OAuth/Connections page. Self-hides when the backend
has the integration disabled. */}
<RecallCalendarCard />

<UpcomingTable watchCalendar={watchCalendar} replyDisplayName={replyDisplayName} />

<HistorySection />

Expand All @@ -126,7 +136,10 @@ export default function MeetingsPage({ onToast }: MeetingsPageProps) {
// Re-fetch watch_calendar after drawer closes so the hint updates.
if (!isTauri()) return;
openhumanGetMeetSettings()
.then(resp => setWatchCalendar(resp.result.watch_calendar ?? false))
.then(resp => {
setWatchCalendar(resp.result.watch_calendar ?? false);
setReplyDisplayName(resp.result.reply_display_name ?? '');
})
.catch(() => {
/* leave unchanged */
});
Expand Down
60 changes: 55 additions & 5 deletions app/src/components/meetings/UpcomingTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
setEventPolicy,
type UpcomingMeeting,
} from '../../services/meetCallService';
import { selectBackendMeetStatus, selectBackendMeetUrl } from '../../store/backendMeetSlice';
import { useAppSelector } from '../../store/hooks';
import {
selectCustomPrimaryColor,
Expand Down Expand Up @@ -164,9 +165,17 @@ interface MeetingRowProps {
onJoinPolicyChange: (v: JoinPolicy) => void;
onJoin: (m: UpcomingMeeting) => void;
joining: boolean;
joined: boolean;
}

function MeetingRow({ meeting, joinPolicy, onJoinPolicyChange, onJoin, joining }: MeetingRowProps) {
function MeetingRow({
meeting,
joinPolicy,
onJoinPolicyChange,
onJoin,
joining,
joined,
}: MeetingRowProps) {
const { t } = useT();
const imminent = isImminent(meeting.start_time_ms);
const { relative, absolute } = formatWhen(meeting.start_time_ms, t);
Expand Down Expand Up @@ -262,7 +271,15 @@ function MeetingRow({ meeting, joinPolicy, onJoinPolicyChange, onJoin, joining }

{/* ACTION */}
<td className="py-2 px-3 whitespace-nowrap">
{imminent ? (
{joined ? (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-emerald-400">
<span
className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"
aria-hidden="true"
/>
{t('skills.meetingBots.liveBadge')}
</span>
) : imminent ? (
<Button
variant="primary"
size="xs"
Expand Down Expand Up @@ -314,12 +331,19 @@ export interface UpcomingTableProps {
* `true` = on — no hint needed.
*/
watchCalendar?: boolean | null;
/**
* The user's saved meeting display name (from meet settings). Used as the
* bot's reply anchor when joining via "Join now" — when set, the bot joins in
* reply mode instead of listen-only so it can respond to the user.
*/
replyDisplayName?: string;
}

export function UpcomingTable({
lookaheadMinutes,
limit,
watchCalendar = null,
replyDisplayName = '',
}: UpcomingTableProps) {
const { t } = useT();
const { meetings, loading, error, refresh } = useUpcomingMeetings(lookaheadMinutes, limit);
Expand All @@ -336,6 +360,17 @@ export function UpcomingTable({
const customPrimaryColor = useAppSelector(selectCustomPrimaryColor);
const customSecondaryColor = useAppSelector(selectCustomSecondaryColor);

// Live in-call state — lets a row detect that its meeting is already joined
// and suppress the "Join now" button. correlationId is a fresh per-join UUID
// (#4338), so the backendMeet slice's meetingId never equals
// calendar_event_id — match the joined meet_url instead.
const backendMeetStatus = useAppSelector(selectBackendMeetStatus);
const backendMeetUrl = useAppSelector(selectBackendMeetUrl);
const isMeetingJoined = (m: UpcomingMeeting): boolean => {
if (backendMeetStatus !== 'active' && backendMeetStatus !== 'joining') return false;
return Boolean(backendMeetUrl && m.meet_url && backendMeetUrl === m.meet_url);
};

// Resolve bot join params the same way MeetComposer does.
const mascotId = selectedMascotId ?? (mascotColor === 'custom' ? undefined : mascotColor);
const riveColors =
Expand All @@ -346,6 +381,17 @@ export function UpcomingTable({
const handleJoin = async (meeting: UpcomingMeeting) => {
if (!meeting.meet_url) return;
const platform = meeting.platform ?? inferPlatformFromUrl(meeting.meet_url) ?? undefined;
// Reply anchor: the display name the user saved on the Meetings page. When
// present the bot joins in reply mode (not listen-only) so it can respond to
// the user; without it we keep the safe listen-only default (the bot has no
// one to reply to and would otherwise talk to everyone).
const anchor = replyDisplayName.trim();
// Reply mode gates the bot behind a wake phrase so it only reacts when
// addressed ("Hey Alex, …"), never to every caption from the anchor —
// mirroring MeetComposer. The bot joins as `agentName`, so the phrase must
// match it. Listen-only joins (no anchor) send no wake phrase.
const agentName = personaDisplayName.trim() || 'Tiny';
const wakePhrase = anchor ? `Hey ${agentName}` : undefined;
// Mint a fresh correlation id per join. It becomes the call record's
// `request_id` (recent-calls list key + per-call detail filename), so it
// MUST be unique per join — reusing the deterministic `calendar_event_id`
Expand All @@ -355,20 +401,23 @@ export function UpcomingTable({
// setJoiningId), mirroring the background auto-join in calendar.rs.
const correlationId = crypto.randomUUID();
log(
'[upcoming] joining %s platform=%s correlationId=%s',
'[upcoming] joining %s platform=%s reply_mode=%s correlationId=%s',
meeting.calendar_event_id,
platform,
Boolean(anchor),
correlationId
);
setJoiningId(meeting.calendar_event_id);
try {
await joinMeetViaBackendBot({
meetUrl: meeting.meet_url,
platform: platform as MeetingPlatform | undefined,
agentName: personaDisplayName || undefined,
agentName,
systemPrompt: personaDescription || undefined,
mascotId: mascotId || undefined,
listenOnly: true,
respondToParticipant: anchor || undefined,
wakePhrase,
listenOnly: !anchor,
Comment thread
YellowSnnowmann marked this conversation as resolved.
correlationId,
riveColors,
});
Expand Down Expand Up @@ -564,6 +613,7 @@ export function UpcomingTable({
onJoinPolicyChange={v => handleJoinPolicyChange(m.calendar_event_id, v)}
onJoin={handleJoin}
joining={joiningId === m.calendar_event_id}
joined={isMeetingJoined(m)}
/>
);
}),
Expand Down
28 changes: 28 additions & 0 deletions app/src/components/meetings/__tests__/MeetDefaultsDrawer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -284,4 +284,32 @@ describe('MeetDefaultsDrawer', () => {
expect(autoJoinSelect).toHaveValue('never');
});
});

// ── reply_display_name text field ──────────────────────────────────────────

it('loads the saved reply_display_name into the input', async () => {
getMock.mockResolvedValueOnce({
result: { ...DEFAULT_SETTINGS.result, reply_display_name: 'Saved Name' },
});
renderWithProviders(<MeetDefaultsDrawer open onClose={vi.fn()} />);
const input = await screen.findByRole('textbox', { name: /your name in meetings/i });
expect(input).toHaveValue('Saved Name');
});

it('persists the trimmed reply_display_name on blur', async () => {
getMock.mockResolvedValueOnce({ ...DEFAULT_SETTINGS });
renderWithProviders(<MeetDefaultsDrawer open onClose={vi.fn()} />);
const input = await screen.findByRole('textbox', { name: /your name in meetings/i });

fireEvent.change(input, { target: { value: ' Alex Kim ' } });
fireEvent.blur(input);

await waitFor(() =>
expect(updateMock).toHaveBeenCalledWith(
expect.objectContaining({ reply_display_name: 'Alex Kim' })
)
);
// The input reflects the trimmed value after blur.
expect(input).toHaveValue('Alex Kim');
});
});
98 changes: 98 additions & 0 deletions app/src/components/meetings/__tests__/MeetingsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { renderWithProviders } from '../../../test/test-utils';
import MeetingsPage from '../MeetingsPage';

// ---------------------------------------------------------------------------
// Mock the Tauri bridge so the page believes it's running in Tauri and we
// control the meet-settings payload it loads on mount / after the drawer closes.
// ---------------------------------------------------------------------------

const getMeetSettingsMock = vi.fn();

vi.mock('../../../utils/tauriCommands', () => ({
isTauri: () => true,
openhumanGetMeetSettings: (...args: unknown[]) => getMeetSettingsMock(...args),
}));

// ---------------------------------------------------------------------------
// Stub the heavy children so the test stays focused on MeetingsPage's own
// settings-loading + drawer-close re-fetch wiring.
// ---------------------------------------------------------------------------

// UpcomingTable is the consumer we assert on — surface the replyDisplayName prop.
vi.mock('../UpcomingTable', () => ({
UpcomingTable: ({ replyDisplayName }: { replyDisplayName: string }) => (
<div data-testid="upcoming-table" data-reply-name={replyDisplayName} />
),
}));

// The remaining children carry no behavior relevant here — trivial stubs.
vi.mock('../../recallCalendar/RecallCalendarCard', () => ({
default: () => <div data-testid="recall-calendar-card-stub" />,
}));
vi.mock('../MeetComposer', () => ({
MeetComposer: () => <div data-testid="meet-composer-stub" />,
}));
vi.mock('../ActiveMeetingBanner', () => ({
ActiveMeetingBanner: () => <div data-testid="active-meeting-banner-stub" />,
}));
vi.mock('../HistorySection', () => ({ default: () => <div data-testid="history-section-stub" /> }));

// Lightweight drawer stub: renders a Close button (which calls onClose) only
// while open, so the drawer-close re-fetch path in MeetingsPage can be driven
// without pulling in the real drawer's own settings load.
vi.mock('../MeetDefaultsDrawer', () => ({
MeetDefaultsDrawer: ({ open, onClose }: { open: boolean; onClose: () => void }) =>
open ? (
<button type="button" data-testid="drawer-close-stub" onClick={onClose}>
close-drawer
</button>
) : null,
}));

function mockSettings(reply_display_name: string, watch_calendar = true) {
getMeetSettingsMock.mockResolvedValue({ result: { watch_calendar, reply_display_name } });
}

describe('MeetingsPage', () => {
beforeEach(() => {
getMeetSettingsMock.mockReset();
mockSettings('Alex Kim');
});

afterEach(() => cleanup());

it('loads reply_display_name and passes it to UpcomingTable', async () => {
renderWithProviders(<MeetingsPage />);

await waitFor(() =>
expect(screen.getByTestId('upcoming-table')).toHaveAttribute('data-reply-name', 'Alex Kim')
);
expect(getMeetSettingsMock).toHaveBeenCalledOnce();
});

it('refreshes settings after the meeting-defaults drawer closes', async () => {
renderWithProviders(<MeetingsPage />);

// On-mount fetch settles first.
await waitFor(() =>
expect(screen.getByTestId('upcoming-table')).toHaveAttribute('data-reply-name', 'Alex Kim')
);
expect(getMeetSettingsMock).toHaveBeenCalledOnce();

// The gear button opens the drawer (aria-label from i18n → "Meeting defaults").
fireEvent.click(screen.getByRole('button', { name: /meeting defaults/i }));

// A subsequent load returns an updated display name so we can prove the
// re-fetch actually re-threads the value into UpcomingTable.
mockSettings('Sam Rivers');
fireEvent.click(screen.getByTestId('drawer-close-stub'));

await waitFor(() => expect(getMeetSettingsMock).toHaveBeenCalledTimes(2));
await waitFor(() =>
expect(screen.getByTestId('upcoming-table')).toHaveAttribute('data-reply-name', 'Sam Rivers')
);
});
});
Loading
Loading