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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,4 @@ distribution.cer

# Release note previews
CHANGELOG.preview.md
*.profraw
48 changes: 33 additions & 15 deletions app/src/components/skills/MeetingBotsCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';

import { type MascotFace, RiveMascot } from '../../features/human/Mascot';
import { useT } from '../../lib/i18n/I18nContext';
Expand Down Expand Up @@ -36,14 +36,39 @@ interface Props {
onToast?: (toast: Toast) => void;
}

interface MeetingBotsInlineProps extends Props {
hasSubmittedRef: RefObject<boolean>;
}

export default function MeetingBotsCard({ onToast }: Props) {
const { t } = useT();
const status = useAppSelector(selectBackendMeetStatus);
const showActive = status === 'active';

// `hasSubmittedRef` lives in this always-mounted parent so the success toast
// fires reliably. When a join succeeds, `status` flips to 'active' and this
// component swaps `MeetingBotsInline` → `ActiveMeetingView`, unmounting the
// inline form before any effect inside it could observe 'active' (#3611
// flattened these into a mutually-exclusive ternary). The inline form sets
// this ref on submit; we fire the success toast here. The error path stays in
// the inline form, which remains mounted during the 'error' state.
const hasSubmittedRef = useRef(false);
useEffect(() => {
if (!hasSubmittedRef.current) return;
if (status === 'active') {
hasSubmittedRef.current = false;
onToast?.({
type: 'success',
title: t('skills.meetingBots.joiningTitle'),
message: t('skills.meetingBots.joiningMessage'),
});
}
}, [status, onToast, t]);

return showActive ? (
<ActiveMeetingView onToast={onToast} />
) : (
<MeetingBotsInline onToast={onToast} />
<MeetingBotsInline onToast={onToast} hasSubmittedRef={hasSubmittedRef} />
);
}

Expand Down Expand Up @@ -173,7 +198,7 @@ function ActiveMeetingView({ onToast }: Props) {
);
}

function MeetingBotsInline({ onToast }: Props) {
function MeetingBotsInline({ onToast, hasSubmittedRef }: MeetingBotsInlineProps) {
const { t } = useT();
const dispatch = useAppDispatch();
const [meetUrl, setMeetUrl] = useState('');
Expand All @@ -188,7 +213,6 @@ function MeetingBotsInline({ onToast }: Props) {
const [error, setError] = useState<string | null>(null);
const meetStatus = useAppSelector(selectBackendMeetStatus);
const meetError = useAppSelector(selectBackendMeetError);
const hasSubmittedRef = useRef(false);
const [recentCalls, setRecentCalls] = useState<MeetCallRecord[] | null>(null);
const [recentError, setRecentError] = useState<string | null>(null);

Expand Down Expand Up @@ -218,26 +242,20 @@ function MeetingBotsInline({ onToast }: Props) {
? { primaryColor: customPrimaryColor, secondaryColor: customSecondaryColor }
: undefined;

// Success ('active') is handled by the parent MeetingBotsCard, which stays
// mounted across the inline→active view swap. The error path lives here
// because the inline form remains mounted during the 'error' state and needs
// to surface the failure inline (setError/setSubmitting) alongside the toast.
useEffect(() => {
if (!hasSubmittedRef.current) return;
if (meetStatus === 'active') {
hasSubmittedRef.current = false;
onToast?.({
type: 'success',
title: t('skills.meetingBots.joiningTitle'),
message: t('skills.meetingBots.joiningMessage'),
});
setMeetUrl('');
return;
}
if (meetStatus === 'error') {
hasSubmittedRef.current = false;
const message = meetError?.trim() || t('skills.meetingBots.failedToStart');
setError(message);
setSubmitting(false);
onToast?.({ type: 'error', title: t('skills.meetingBots.couldNotStartTitle'), message });
}
}, [meetStatus, meetError, onToast, t]);
}, [meetStatus, meetError, onToast, t, hasSubmittedRef]);

const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
Expand Down
179 changes: 0 additions & 179 deletions app/src/pages/__tests__/Accounts.faceToggle.test.tsx

This file was deleted.

88 changes: 2 additions & 86 deletions app/src/pages/__tests__/Conversations.render.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -542,31 +542,8 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
});
});

// Covers lines 1455-1483: quota pill loading state
it('renders "Loading…" quota pill when isLoadingBudget=true', async () => {
mockUseUsageState.mockReturnValue({
teamUsage: null,
currentPlan: null,
currentTier: 'FREE' as const,
isFreeTier: true,
usagePct: 0.0,
isNearLimit: false,
isAtLimit: false,
isBudgetExhausted: false,
shouldShowBudgetCompletedMessage: false,
isLoading: true,
refresh: vi.fn(),
});

await act(async () => {
await renderConversations({ thread: emptyThreadState });
});

expect(screen.getByText('Loading…')).toBeInTheDocument();
});

// Covers lines 1417-1439: budget banner + lines 1455-1516: LimitPill + tooltip
it('renders budget-limit banner and limit pills when teamUsage is present', async () => {
// Covers lines 1417-1439: budget-exceeded banner
it('renders budget-limit banner when teamUsage is present', async () => {
// cycleBudgetUsd: 0 → renders "Your included budget is complete" branch
const teamUsage = { cycleBudgetUsd: 0, remainingUsd: 0, cycleSpentUsd: 0, cycleEndsAt: null };

Expand All @@ -591,9 +568,6 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => {
// Budget-exceeded banner (lines 1417-1439) — cycleBudgetUsd=0 gives "included budget" message
expect(screen.getByText(/Your included budget is complete/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Use OpenRouter free models/i })).toBeInTheDocument();

// LimitPill renders with the cycle label
expect(screen.getByText('Cycle')).toBeInTheDocument();
});

// Covers line 247: if (cancelled) return — the non-cancelled path through loadThreads callback
Expand Down Expand Up @@ -1573,64 +1547,6 @@ describe('Conversations — worker thread back-to-parent navigation (#1624)', ()
// cycleBudgetUsd=0 → false branch of cycleBudgetUsd > 0 ternary → budgetComplete key
expect(screen.getByText(/Your included budget is complete/i)).toBeInTheDocument();
});

// Covers line 1910: cycleEndsAt truthy branch inside cycle-pill tooltip
it('renders reset time in cycle-pill tooltip when cycleEndsAt is set', async () => {
const teamUsage = {
cycleBudgetUsd: 10,
remainingUsd: 5,
cycleSpentUsd: 5,
cycleEndsAt: '2026-06-01T00:00:00.000Z',
};

mockUseUsageState.mockReturnValue({
teamUsage,
currentPlan: null,
currentTier: 'PRO' as const,
isFreeTier: false,
usagePct: 0.5,
isNearLimit: false,
isAtLimit: false,
isBudgetExhausted: false,
shouldShowBudgetCompletedMessage: false,
isLoading: false,
refresh: vi.fn(),
});

await act(async () => {
await renderConversations({ thread: emptyThreadState });
});

// Tooltip is hidden via CSS but present in DOM; cycleEndsAt truthy → reset span renders
expect(screen.getByText('Cycle')).toBeInTheDocument();
// The tooltip resets span contains "resets" text (covers line 1910 conditional)
const resetSpans = document.querySelectorAll('[class*="text-stone-400"]');
expect(resetSpans.length).toBeGreaterThan(0);
});

// Covers lines 1903-1904: loading animation span when isLoading=true, teamUsage=null
it('renders loading pulse span in cycle-pill area when isLoading=true', async () => {
mockUseUsageState.mockReturnValue({
teamUsage: null,
currentPlan: null,
currentTier: 'FREE' as const,
isFreeTier: true,
usagePct: 0,
isNearLimit: false,
isAtLimit: false,
isBudgetExhausted: false,
shouldShowBudgetCompletedMessage: false,
isLoading: true,
refresh: vi.fn(),
});

await act(async () => {
await renderConversations({ thread: emptyThreadState });
});

// Loading span with animate-pulse is present when teamUsage=null and loading
expect(screen.getByText('Loading…')).toBeInTheDocument();
});
});

describe('Conversations — thread title editing', () => {
Expand Down
Loading
Loading