mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* AgentWorldThemeBridge — maps OpenHuman design tokens to tiny.place CSS variables.
|
||||
*
|
||||
* Injects a `<style>` block that bridges OpenHuman's Tailwind tokens (ocean
|
||||
* primary `#4A83DD`, sage/amber/coral semantics) to the CSS variables the
|
||||
* vendored tiny.place components expect. This avoids modifying the vendored
|
||||
* source: we theme-bridge at the boundary.
|
||||
*
|
||||
* For Wave 0 this is a minimal stub. Full token mapping will be expanded when
|
||||
* the vendored Explore UI is ported in Wave 1.
|
||||
*/
|
||||
|
||||
const THEME_BRIDGE_CSS = `
|
||||
:root {
|
||||
/* tiny.place primary → OpenHuman ocean */
|
||||
--tp-color-primary: #4A83DD;
|
||||
--tp-color-primary-hover: #3a73cd;
|
||||
/* tiny.place surface → OpenHuman dark background */
|
||||
--tp-color-surface: #111827;
|
||||
--tp-color-surface-secondary: #1f2937;
|
||||
/* tiny.place text */
|
||||
--tp-color-text-primary: #f9fafb;
|
||||
--tp-color-text-secondary: #9ca3af;
|
||||
/* tiny.place semantic */
|
||||
--tp-color-success: #10b981;
|
||||
--tp-color-warning: #f59e0b;
|
||||
--tp-color-error: #ef4444;
|
||||
}
|
||||
`;
|
||||
|
||||
export default function AgentWorldThemeBridge() {
|
||||
return <style>{THEME_BRIDGE_CSS}</style>;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
const GoogleIcon = ({ className = 'w-5 h-5' }: { className?: string }) => {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoogleIcon;
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* ConnectionBadge — small pill badge rendered on connection cards.
|
||||
*
|
||||
* Two kinds:
|
||||
* - "Messaging" — shown for iMessage, Telegram, WhatsApp channel cards
|
||||
* - "Composio" — shown for cards backed by the Composio toolkit (kind === 'composio')
|
||||
*/
|
||||
import { useT } from '../lib/i18n/I18nContext';
|
||||
|
||||
const MESSAGING_IDS = ['imessage', 'telegram', 'whatsapp'] as const;
|
||||
type MessagingId = (typeof MESSAGING_IDS)[number];
|
||||
|
||||
export function isMessagingId(id: string): id is MessagingId {
|
||||
return (MESSAGING_IDS as readonly string[]).includes(id);
|
||||
}
|
||||
|
||||
interface ConnectionBadgeProps {
|
||||
/** 'composio' | 'messaging' */
|
||||
kind: 'composio' | 'messaging';
|
||||
}
|
||||
|
||||
export default function ConnectionBadge({ kind }: ConnectionBadgeProps) {
|
||||
const { t } = useT();
|
||||
if (kind === 'composio') {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-violet-50 border border-violet-200 px-2 py-0.5 text-[10px] font-medium text-violet-700 leading-none">
|
||||
{t('app.connectionBadge.composio')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-sky-50 border border-sky-200 px-2 py-0.5 text-[10px] font-medium text-sky-700 leading-none">
|
||||
{t('app.connectionBadge.messaging')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useLottie } from 'lottie-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface LottieAnimationProps {
|
||||
src: string;
|
||||
className?: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
const LottieAnimation = ({
|
||||
src,
|
||||
className = '',
|
||||
height = 200,
|
||||
width = 200,
|
||||
}: LottieAnimationProps) => {
|
||||
const [animationData, setAnimationData] = useState<unknown>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(src)
|
||||
.then(response => response.json())
|
||||
.then(data => setAnimationData(data))
|
||||
.catch(error => console.error('Failed to load Lottie animation:', error));
|
||||
}, [src]);
|
||||
|
||||
const options = { animationData, loop: true, autoplay: true };
|
||||
|
||||
const { View } = useLottie(options, { height, width });
|
||||
|
||||
if (!animationData) {
|
||||
return <div className={className} style={{ height, width }} />;
|
||||
}
|
||||
|
||||
return <div className={className}>{View}</div>;
|
||||
};
|
||||
|
||||
export default LottieAnimation;
|
||||
@@ -1,107 +0,0 @@
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import type { RespondQueueItem } from '../../types/providerSurfaces';
|
||||
import { openUrl } from '../../utils/openUrl';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
interface RespondQueuePanelProps {
|
||||
items: RespondQueueItem[];
|
||||
count: number;
|
||||
status: 'idle' | 'loading' | 'succeeded' | 'failed';
|
||||
error: string | null;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const ts = new Date(iso).getTime();
|
||||
if (!Number.isFinite(ts)) return 'unknown time';
|
||||
const deltaMs = Date.now() - ts;
|
||||
if (deltaMs < 60_000) return 'just now';
|
||||
const mins = Math.floor(deltaMs / 60_000);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function queueTitle(item: RespondQueueItem): string {
|
||||
return item.title || item.senderName || item.eventKind || item.provider;
|
||||
}
|
||||
|
||||
export default function RespondQueuePanel({
|
||||
items,
|
||||
count,
|
||||
status,
|
||||
error,
|
||||
onRefresh,
|
||||
}: RespondQueuePanelProps) {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<aside className="flex w-80 flex-none flex-col border-l border-line bg-surface">
|
||||
<div className="flex flex-none items-center justify-between border-b border-line-subtle px-4 py-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-content">{t('accounts.respondQueue.title')}</h3>
|
||||
<p className="text-xs text-content-muted">
|
||||
{count} {t('accounts.respondQueue.pending')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
onClick={onRefresh}
|
||||
data-analytics-id="respond-queue-refresh">
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-3 py-3">
|
||||
{status === 'loading' && items.length === 0 ? (
|
||||
<p className="rounded-lg bg-surface-muted px-3 py-2 text-xs text-content-muted">
|
||||
{t('accounts.respondQueue.loading')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{status === 'failed' ? (
|
||||
<p className="rounded-lg bg-coral-50 px-3 py-2 text-xs text-coral-600">
|
||||
{error ?? t('accounts.respondQueue.loadFailed')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{items.length === 0 && status !== 'loading' ? (
|
||||
<p className="rounded-lg bg-surface-muted px-3 py-2 text-xs text-content-muted">
|
||||
{t('accounts.respondQueue.empty')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
{items.slice(0, 30).map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
data-analytics-id={`respond-queue-item-${item.provider}`}
|
||||
onClick={() => {
|
||||
if (item.deepLink) {
|
||||
void openUrl(item.deepLink);
|
||||
}
|
||||
}}
|
||||
className="w-full rounded-xl border border-line bg-surface px-3 py-2 text-left transition-colors hover:bg-surface-hover disabled:cursor-default"
|
||||
disabled={!item.deepLink}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="truncate text-xs font-medium text-content">{queueTitle(item)}</p>
|
||||
<span className="rounded-full bg-surface-subtle px-2 py-0.5 text-[10px] uppercase text-content-secondary">
|
||||
{item.provider}
|
||||
</span>
|
||||
</div>
|
||||
{item.snippet ? (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-content-secondary">{item.snippet}</p>
|
||||
) : null}
|
||||
<div className="mt-1 flex items-center justify-between text-[10px] text-content-muted">
|
||||
<span>{item.senderName ?? item.senderHandle ?? item.accountId}</span>
|
||||
<span>{relativeTime(item.timestamp)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { LimitPill } from '../../features/conversations/components/LimitPill';
|
||||
import { formatResetTime } from '../../features/conversations/utils/format';
|
||||
import { useUsageState } from '../../hooks/useUsageState';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
|
||||
/**
|
||||
* Self-contained cycle usage indicator for the composer toolbar.
|
||||
* Shows "CYCLE ——— 0%" pill with a hover tooltip showing spend/remaining.
|
||||
* Uses useUsageState() internally — no props needed.
|
||||
*/
|
||||
export default function CycleUsagePill() {
|
||||
const { t } = useT();
|
||||
const { usagePct, teamUsage, isLoading } = useUsageState();
|
||||
|
||||
if (!isLoading && !teamUsage) return null;
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
{teamUsage ? (
|
||||
<LimitPill label={t('chat.cycle')} usedPct={usagePct} />
|
||||
) : (
|
||||
<span className="text-[10px] text-content-faint animate-pulse">{t('common.loading')}</span>
|
||||
)}
|
||||
{teamUsage && (
|
||||
<div className="absolute bottom-full left-0 mb-2 hidden group-hover:block z-50">
|
||||
<div className="bg-stone-900 text-white text-[10px] rounded-lg px-3 py-2 shadow-lg whitespace-nowrap space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-content-faint">{t('chat.cycleSpent')}</span>
|
||||
<span>
|
||||
${(teamUsage.cycleSpentUsd ?? 0).toFixed(2)} / $
|
||||
{(teamUsage.cycleBudgetUsd ?? 0).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-content-faint">{t('chat.cycleRemaining')}</span>
|
||||
<span>
|
||||
${(teamUsage.remainingUsd ?? 0).toFixed(2)} {t('chat.left')}
|
||||
{teamUsage.cycleEndsAt && (
|
||||
<span className="text-content-faint ml-1">
|
||||
— {t('chat.resets')} {formatResetTime(teamUsage.cycleEndsAt)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { useUsageState } from '../../hooks/useUsageState';
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { useAppSelector } from '../../store/hooks';
|
||||
import { BILLING_DASHBOARD_URL } from '../../utils/links';
|
||||
import { openUrl } from '../../utils/openUrl';
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n < 1000) return String(n);
|
||||
if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}K`;
|
||||
return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
|
||||
interface PillSeverity {
|
||||
bg: string;
|
||||
text: string;
|
||||
ring: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function severityFromPct(pct: number): PillSeverity {
|
||||
if (pct >= 0.9) {
|
||||
return {
|
||||
bg: 'bg-coral-50 dark:bg-coral-500/15',
|
||||
text: 'text-coral-700 dark:text-coral-300',
|
||||
ring: 'ring-coral-200 dark:ring-coral-500/30',
|
||||
label: `${Math.round(pct * 100)}%`,
|
||||
};
|
||||
}
|
||||
if (pct >= 0.7) {
|
||||
return {
|
||||
bg: 'bg-amber-50 dark:bg-amber-500/15',
|
||||
text: 'text-amber-700 dark:text-amber-300',
|
||||
ring: 'ring-amber-200 dark:ring-amber-500/30',
|
||||
label: `${Math.round(pct * 100)}%`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
bg: 'bg-sage-50 dark:bg-sage-500/15',
|
||||
text: 'text-sage-700 dark:text-sage-300',
|
||||
ring: 'ring-sage-200 dark:ring-sage-500/30',
|
||||
label: `${Math.round(pct * 100)}%`,
|
||||
};
|
||||
}
|
||||
|
||||
const TokenUsagePill = () => {
|
||||
const { t } = useT();
|
||||
const sessionTokens = useAppSelector(state => state.chatRuntime.sessionTokenUsage);
|
||||
const { usagePct, isAtLimit, isNearLimit, currentTier, teamUsage } = useUsageState();
|
||||
|
||||
const totalTokens = sessionTokens.inputTokens + sessionTokens.outputTokens;
|
||||
const showSessionCounter = totalTokens > 0;
|
||||
|
||||
const planSeverity = severityFromPct(usagePct);
|
||||
const showPlanPill = teamUsage !== null;
|
||||
|
||||
const planTitle = (() => {
|
||||
if (isAtLimit) return t('token.usageLimitReached');
|
||||
if (isNearLimit) return t('token.approachingLimit');
|
||||
return `${currentTier.toLowerCase()} ${t('token.planClickForDetails')}`;
|
||||
})();
|
||||
|
||||
if (!showSessionCounter && !showPlanPill) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-[11px] leading-none">
|
||||
{showSessionCounter ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full bg-surface-subtle px-2 py-1 font-mono text-content-secondary ring-1 ring-stone-200/60 dark:ring-neutral-700"
|
||||
title={t('token.sessionTokens')
|
||||
.replace('{in}', sessionTokens.inputTokens.toLocaleString())
|
||||
.replace('{out}', sessionTokens.outputTokens.toLocaleString())
|
||||
.replace('{turns}', String(sessionTokens.turns))}>
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
{formatTokens(totalTokens)}
|
||||
</span>
|
||||
) : null}
|
||||
{showPlanPill ? (
|
||||
<button
|
||||
type="button"
|
||||
data-analytics-id="chat-token-plan-pill"
|
||||
onClick={() => {
|
||||
void openUrl(BILLING_DASHBOARD_URL);
|
||||
}}
|
||||
title={planTitle}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-1 font-medium ring-1 transition-colors ${planSeverity.bg} ${planSeverity.text} ${planSeverity.ring} hover:opacity-80`}>
|
||||
{isAtLimit ? t('token.limit') : planSeverity.label}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TokenUsagePill;
|
||||
@@ -6,7 +6,6 @@ import type { Attachment } from '../../../lib/attachments';
|
||||
import ChatComposer, { type ChatComposerProps } from '../ChatComposer';
|
||||
|
||||
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
|
||||
vi.mock('../CycleUsagePill', () => ({ default: () => <div data-testid="cycle-usage-pill" /> }));
|
||||
|
||||
function makeAttachment(overrides: Partial<Attachment> = {}): Attachment {
|
||||
const blob = new Blob([new Uint8Array(256)], { type: 'image/png' });
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import OAuthProviderButton from './OAuthProviderButton';
|
||||
import { oauthProviderConfigs } from './providerConfigs';
|
||||
|
||||
interface OAuthLoginSectionProps {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
showTelegram?: boolean;
|
||||
}
|
||||
|
||||
const OAuthLoginSection = ({ className = '', disabled = false }: OAuthLoginSectionProps) => {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
<div className="space-y-3">
|
||||
<h3 className="block w-full text-center text-sm opacity-30 mb-3 font-semibold tracking-wide">
|
||||
{t('oauth.login.continueWith')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{oauthProviderConfigs.map(provider => (
|
||||
<OAuthProviderButton
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
disabled={disabled}
|
||||
className="w-full min-w-0"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuthLoginSection;
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useT } from '../../../../lib/i18n/I18nContext';
|
||||
import type { CreditTransaction } from '../../../../services/api/creditsApi';
|
||||
import Button from '../../../ui/Button';
|
||||
|
||||
interface BillingHistoryTabProps {
|
||||
hasActive: boolean;
|
||||
onManageSubscription: () => void;
|
||||
transactionRows: CreditTransaction[];
|
||||
}
|
||||
|
||||
export default function BillingHistoryTab({
|
||||
hasActive,
|
||||
onManageSubscription,
|
||||
transactionRows,
|
||||
}: BillingHistoryTabProps) {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-col gap-2 rounded-2xl bg-surface p-4 border border-line">
|
||||
<h3 className="font-headline text-2xl font-bold tracking-tight text-stone-950 dark:text-content">
|
||||
{t('settings.billing.history.title')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-content-muted">{t('settings.billing.history.desc')}</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{hasActive && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={onManageSubscription}
|
||||
className="px-0 font-semibold text-primary-600 hover:bg-transparent hover:text-primary-700 dark:text-primary-300">
|
||||
{t('settings.billing.history.openPortal')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-[28px] bg-surface shadow-[0_24px_70px_rgba(15,23,42,0.06)] ring-1 ring-stone-950/5">
|
||||
{transactionRows.length > 0 ? (
|
||||
<div className="divide-y divide-line-subtle dark:divide-neutral-800">
|
||||
{transactionRows.map(transaction => {
|
||||
const isEarn = transaction.type === 'EARN';
|
||||
return (
|
||||
<div
|
||||
key={transaction.id}
|
||||
className="grid gap-3 px-5 py-4 text-sm sm:grid-cols-[1.3fr_0.8fr_0.7fr_0.8fr] sm:items-center">
|
||||
<div>
|
||||
<p className="font-semibold text-stone-950 dark:text-content">
|
||||
{transaction.action}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
{new Date(transaction.createdAt).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-content-muted">{transaction.type}</div>
|
||||
<div
|
||||
className={`font-semibold ${isEarn ? 'text-sage-600 dark:text-sage-300' : 'text-stone-950 dark:text-content'}`}>
|
||||
{isEarn ? '+' : '-'}${Math.abs(transaction.amountUsd).toFixed(2)}
|
||||
</div>
|
||||
<div className="sm:text-right">
|
||||
<span className="rounded-full bg-surface-subtle px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-content-muted">
|
||||
{t('settings.billing.history.posted')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-5 py-8 text-sm text-content-muted">
|
||||
{t('settings.billing.history.empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import type {
|
||||
AutoRechargeSettings,
|
||||
CreditBalance,
|
||||
SavedCard,
|
||||
} from '../../../../services/api/creditsApi';
|
||||
import AutoRechargeSection from './AutoRechargeSection';
|
||||
import PayAsYouGoCard from './PayAsYouGoCard';
|
||||
|
||||
interface BillingPaymentsTabProps {
|
||||
arAmount: number;
|
||||
arDirty: boolean;
|
||||
arError: string | null;
|
||||
arLoading: boolean;
|
||||
arSaving: boolean;
|
||||
arSettings: AutoRechargeSettings | null;
|
||||
arThreshold: number;
|
||||
arWeeklyLimit: number;
|
||||
cards: SavedCard[];
|
||||
cardsLoading: boolean;
|
||||
confirmDeleteId: string | null;
|
||||
creditBalance: CreditBalance | null;
|
||||
deletingCardId: string | null;
|
||||
isLoadingCredits: boolean;
|
||||
isToppingUp: boolean;
|
||||
onAddCard: () => void;
|
||||
onArSave: () => void;
|
||||
onArToggle: () => void;
|
||||
onDeleteCard: (paymentMethodId: string) => void;
|
||||
onSetDefault: (paymentMethodId: string) => void;
|
||||
onTopUp: (amountUsd: number) => void;
|
||||
setArAmount: (value: number) => void;
|
||||
setArThreshold: (value: number) => void;
|
||||
setArWeeklyLimit: (value: number) => void;
|
||||
setConfirmDeleteId: (value: string | null) => void;
|
||||
settingDefaultId: string | null;
|
||||
}
|
||||
|
||||
export default function BillingPaymentsTab({
|
||||
arAmount,
|
||||
arDirty,
|
||||
arError,
|
||||
arLoading,
|
||||
arSaving,
|
||||
arSettings,
|
||||
arThreshold,
|
||||
arWeeklyLimit,
|
||||
cards,
|
||||
cardsLoading,
|
||||
confirmDeleteId,
|
||||
creditBalance,
|
||||
deletingCardId,
|
||||
isLoadingCredits,
|
||||
isToppingUp,
|
||||
onAddCard,
|
||||
onArSave,
|
||||
onArToggle,
|
||||
onDeleteCard,
|
||||
onSetDefault,
|
||||
onTopUp,
|
||||
setArAmount,
|
||||
setArThreshold,
|
||||
setArWeeklyLimit,
|
||||
setConfirmDeleteId,
|
||||
settingDefaultId,
|
||||
}: BillingPaymentsTabProps) {
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-4">
|
||||
<PayAsYouGoCard
|
||||
creditBalance={creditBalance}
|
||||
isLoadingCredits={isLoadingCredits}
|
||||
isToppingUp={isToppingUp}
|
||||
onTopUp={onTopUp}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<AutoRechargeSection
|
||||
arSettings={arSettings}
|
||||
arLoading={arLoading}
|
||||
arError={arError}
|
||||
arSaving={arSaving}
|
||||
arThreshold={arThreshold}
|
||||
arAmount={arAmount}
|
||||
arWeeklyLimit={arWeeklyLimit}
|
||||
arDirty={arDirty}
|
||||
setArThreshold={setArThreshold}
|
||||
setArAmount={setArAmount}
|
||||
setArWeeklyLimit={setArWeeklyLimit}
|
||||
onArToggle={onArToggle}
|
||||
onArSave={onArSave}
|
||||
cards={cards}
|
||||
cardsLoading={cardsLoading}
|
||||
confirmDeleteId={confirmDeleteId}
|
||||
deletingCardId={deletingCardId}
|
||||
settingDefaultId={settingDefaultId}
|
||||
setConfirmDeleteId={setConfirmDeleteId}
|
||||
onSetDefault={onSetDefault}
|
||||
onDeleteCard={onDeleteCard}
|
||||
onAddCard={onAddCard}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { PlanTier } from '../../../../types/api';
|
||||
import SubscriptionPlans from './SubscriptionPlans';
|
||||
|
||||
interface BillingPlansTabProps {
|
||||
billingInterval: 'monthly' | 'annual';
|
||||
currentTier: PlanTier;
|
||||
isPurchasing: boolean;
|
||||
onUpgrade: (tier: PlanTier) => void;
|
||||
paymentConfirmed: boolean;
|
||||
paymentMethod: 'card' | 'crypto';
|
||||
purchasingTier: PlanTier | null;
|
||||
setBillingInterval: (value: 'monthly' | 'annual') => void;
|
||||
setPaymentMethod: (value: 'card' | 'crypto') => void;
|
||||
}
|
||||
|
||||
export default function BillingPlansTab({
|
||||
billingInterval,
|
||||
currentTier,
|
||||
isPurchasing,
|
||||
onUpgrade,
|
||||
paymentConfirmed,
|
||||
paymentMethod,
|
||||
purchasingTier,
|
||||
setBillingInterval,
|
||||
setPaymentMethod,
|
||||
}: BillingPlansTabProps) {
|
||||
return (
|
||||
<SubscriptionPlans
|
||||
currentTier={currentTier}
|
||||
billingInterval={billingInterval}
|
||||
setBillingInterval={setBillingInterval}
|
||||
paymentMethod={paymentMethod}
|
||||
setPaymentMethod={setPaymentMethod}
|
||||
isPurchasing={isPurchasing}
|
||||
purchasingTier={purchasingTier}
|
||||
paymentConfirmed={paymentConfirmed}
|
||||
onUpgrade={onUpgrade}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
/**
|
||||
* SkillResourceTree
|
||||
* -----------------
|
||||
*
|
||||
* Groups a flat list of skill resource paths by their top-level directory
|
||||
* (`scripts/`, `references/`, `assets/`) with a catch-all "Other" bucket so
|
||||
* anything unexpected still renders. Items are rendered as clickable rows in
|
||||
* JetBrains Mono for path clarity. Selected item uses primary-50 background.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
|
||||
const log = debug('skills:resource-tree');
|
||||
|
||||
interface Props {
|
||||
resources: string[];
|
||||
selectedPath: string | null;
|
||||
onSelect: (path: string) => void;
|
||||
}
|
||||
|
||||
interface ResourceGroup {
|
||||
key: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
const KNOWN_GROUPS: Array<{ prefix: string; key: string }> = [
|
||||
{ prefix: 'scripts/', key: 'scripts' },
|
||||
{ prefix: 'references/', key: 'references' },
|
||||
{ prefix: 'assets/', key: 'assets' },
|
||||
{ prefix: 'templates/', key: 'templates' },
|
||||
{ prefix: 'examples/', key: 'examples' },
|
||||
{ prefix: 'prompts/', key: 'prompts' },
|
||||
];
|
||||
|
||||
function groupResources(resources: string[]): ResourceGroup[] {
|
||||
const buckets = new Map<string, ResourceGroup>();
|
||||
for (const known of KNOWN_GROUPS) {
|
||||
buckets.set(known.key, { key: known.key, items: [] });
|
||||
}
|
||||
const other: ResourceGroup = { key: 'other', items: [] };
|
||||
|
||||
for (const resource of resources) {
|
||||
let matched = false;
|
||||
for (const known of KNOWN_GROUPS) {
|
||||
if (resource.startsWith(known.prefix)) {
|
||||
buckets.get(known.key)!.items.push(resource);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
other.items.push(resource);
|
||||
}
|
||||
}
|
||||
|
||||
for (const bucket of buckets.values()) {
|
||||
bucket.items.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
other.items.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const result: ResourceGroup[] = [];
|
||||
for (const known of KNOWN_GROUPS) {
|
||||
const bucket = buckets.get(known.key)!;
|
||||
if (bucket.items.length > 0) {
|
||||
result.push(bucket);
|
||||
}
|
||||
}
|
||||
if (other.items.length > 0) {
|
||||
result.push(other);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const GROUP_LABEL_KEYS: Record<string, string> = {
|
||||
scripts: 'skills.resource.tree.scripts',
|
||||
references: 'skills.resource.tree.references',
|
||||
assets: 'skills.resource.tree.assets',
|
||||
templates: 'skills.resource.tree.templates',
|
||||
examples: 'skills.resource.tree.examples',
|
||||
prompts: 'skills.resource.tree.prompts',
|
||||
other: 'skills.resource.tree.other',
|
||||
};
|
||||
|
||||
export default function SkillResourceTree({ resources, selectedPath, onSelect }: Props) {
|
||||
const { t } = useT();
|
||||
const groups = useMemo(() => groupResources(resources), [resources]);
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-content-faint italic">
|
||||
{t('skills.resource.tree.empty')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{groups.map(group => (
|
||||
<div
|
||||
key={group.key}
|
||||
className="rounded-xl border border-line bg-surface-muted overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-line bg-surface-muted px-3 py-1.5">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-wide text-content-secondary">
|
||||
{t(GROUP_LABEL_KEYS[group.key] ?? group.key)}
|
||||
</h4>
|
||||
<span className="text-[10px] text-content-faint font-mono">
|
||||
{group.items.length}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="divide-y divide-line-subtle dark:divide-neutral-800">
|
||||
{group.items.map(path => {
|
||||
const isSelected = selectedPath === path;
|
||||
return (
|
||||
<li key={path}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
log('click path=%s', path);
|
||||
onSelect(path);
|
||||
}}
|
||||
className={`w-full truncate px-3 py-2 text-left text-[11px] font-mono transition-colors focus:outline-none focus:ring-1 focus:ring-inset focus:ring-primary-500 ${
|
||||
isSelected
|
||||
? 'bg-primary-50 dark:bg-primary-500/15 text-primary-700 dark:text-primary-300'
|
||||
: 'text-content-secondary hover:bg-white dark:hover:bg-surface-muted/60'
|
||||
}`}
|
||||
title={path}>
|
||||
{path}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockGetBackendUrl = vi.fn();
|
||||
|
||||
vi.mock('../services/backendUrl', () => ({ getBackendUrl: () => mockGetBackendUrl() }));
|
||||
|
||||
describe('useBackendUrl', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mockGetBackendUrl.mockReset();
|
||||
});
|
||||
|
||||
it('returns the resolved core-derived backend URL after the async lookup settles', async () => {
|
||||
mockGetBackendUrl.mockResolvedValue('https://api.example.com');
|
||||
|
||||
const { useBackendUrl } = await import('./useBackendUrl');
|
||||
const { result } = renderHook(() => useBackendUrl());
|
||||
|
||||
expect(result.current).toBeNull();
|
||||
await waitFor(() => expect(result.current).toBe('https://api.example.com'));
|
||||
});
|
||||
|
||||
it('stays null when resolution fails so callers do not fall back to a hardcoded host', async () => {
|
||||
mockGetBackendUrl.mockRejectedValue(new Error('rpc unreachable'));
|
||||
|
||||
const { useBackendUrl } = await import('./useBackendUrl');
|
||||
const { result } = renderHook(() => useBackendUrl());
|
||||
|
||||
await waitFor(() => expect(mockGetBackendUrl).toHaveBeenCalled());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it('does not update state if the component unmounts before resolution', async () => {
|
||||
let resolveFn: ((value: string) => void) | undefined;
|
||||
mockGetBackendUrl.mockImplementation(
|
||||
() =>
|
||||
new Promise<string>(resolve => {
|
||||
resolveFn = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const { useBackendUrl } = await import('./useBackendUrl');
|
||||
const { result, unmount } = renderHook(() => useBackendUrl());
|
||||
|
||||
unmount();
|
||||
resolveFn?.('https://api.example.com');
|
||||
await Promise.resolve();
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getBackendUrl } from '../services/backendUrl';
|
||||
|
||||
/**
|
||||
* Resolves the runtime backend URL from the core sidecar (or web fallback)
|
||||
* for use inside React components. Returns `null` while the resolution is in
|
||||
* flight or if it fails. Components should treat `null` as "URL not yet
|
||||
* known" and render a placeholder rather than guessing a hardcoded host.
|
||||
*
|
||||
* The resolution is delegated to `services/backendUrl#getBackendUrl`, which
|
||||
* caches the value for the session — using this hook in many components is
|
||||
* cheap (one RPC for the whole app).
|
||||
*/
|
||||
export function useBackendUrl(): string | null {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getBackendUrl()
|
||||
.then(resolved => {
|
||||
if (!cancelled) setUrl(resolved);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setUrl(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return url;
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Thread, ThreadMessage } from '../types/thread';
|
||||
|
||||
const mockGetThreads = vi.fn();
|
||||
const mockGetThreadMessages = vi.fn();
|
||||
|
||||
vi.mock('../services/api/threadApi', () => ({
|
||||
threadApi: {
|
||||
getThreads: () => mockGetThreads(),
|
||||
getThreadMessages: (threadId: string) => mockGetThreadMessages(threadId),
|
||||
},
|
||||
}));
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const thread: Thread = {
|
||||
id: 'thread-1',
|
||||
title: 'Planning',
|
||||
chatId: null,
|
||||
isActive: true,
|
||||
messageCount: 1,
|
||||
lastMessageAt: '2026-05-06T00:00:00.000Z',
|
||||
createdAt: '2026-05-06T00:00:00.000Z',
|
||||
labels: [],
|
||||
};
|
||||
|
||||
const message: ThreadMessage = {
|
||||
id: 'message-1',
|
||||
content: 'hello',
|
||||
type: 'text',
|
||||
extraMetadata: {},
|
||||
sender: 'user',
|
||||
createdAt: '2026-05-06T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('useThreadQueries', () => {
|
||||
beforeEach(() => {
|
||||
mockGetThreads.mockReset();
|
||||
mockGetThreadMessages.mockReset();
|
||||
});
|
||||
|
||||
it('loads threads with loading and success state', async () => {
|
||||
mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 });
|
||||
const { useThreads } = await import('./useThreadQueries');
|
||||
|
||||
const { result } = renderHook(() => useThreads());
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.data?.threads).toEqual([thread]);
|
||||
expect(result.current.data?.count).toBe(1);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(mockGetThreads).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('surfaces RPC errors without throwing from render', async () => {
|
||||
mockGetThreads.mockRejectedValue(new Error('rpc failed'));
|
||||
const { useThreads } = await import('./useThreadQueries');
|
||||
|
||||
const { result } = renderHook(() => useThreads());
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.data).toBeNull();
|
||||
expect(result.current.error?.message).toBe('rpc failed');
|
||||
});
|
||||
|
||||
it('does not load messages when no thread id is available', async () => {
|
||||
const { useThreadMessages } = await import('./useThreadQueries');
|
||||
|
||||
const { result, rerender } = renderHook(({ threadId }) => useThreadMessages(threadId), {
|
||||
initialProps: { threadId: null as string | null },
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.data).toBeNull();
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(mockGetThreadMessages).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ threadId: ' ' });
|
||||
expect(mockGetThreadMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads and refetches thread messages', async () => {
|
||||
mockGetThreadMessages
|
||||
.mockResolvedValueOnce({ messages: [message], count: 1 })
|
||||
.mockResolvedValueOnce({
|
||||
messages: [{ ...message, id: 'message-2', content: 'updated' }],
|
||||
count: 1,
|
||||
});
|
||||
const { useThreadMessages } = await import('./useThreadQueries');
|
||||
|
||||
const { result } = renderHook(() => useThreadMessages('thread-1'));
|
||||
|
||||
await waitFor(() => expect(result.current.data?.messages[0].id).toBe('message-1'));
|
||||
await act(async () => {
|
||||
await result.current.refetch();
|
||||
});
|
||||
|
||||
expect(result.current.data?.messages[0].id).toBe('message-2');
|
||||
expect(mockGetThreadMessages).toHaveBeenNthCalledWith(1, 'thread-1');
|
||||
expect(mockGetThreadMessages).toHaveBeenNthCalledWith(2, 'thread-1');
|
||||
});
|
||||
|
||||
it('exposes refetching state while keeping previous data', async () => {
|
||||
const nextMessages = deferred<{ messages: ThreadMessage[]; count: number }>();
|
||||
mockGetThreadMessages
|
||||
.mockResolvedValueOnce({ messages: [message], count: 1 })
|
||||
.mockReturnValueOnce(nextMessages.promise);
|
||||
const { useThreadMessages } = await import('./useThreadQueries');
|
||||
|
||||
const { result } = renderHook(() => useThreadMessages('thread-1'));
|
||||
|
||||
await waitFor(() => expect(result.current.data?.messages[0].id).toBe('message-1'));
|
||||
|
||||
let refetchPromise: Promise<unknown>;
|
||||
act(() => {
|
||||
refetchPromise = result.current.refetch();
|
||||
});
|
||||
|
||||
expect(result.current.isRefetching).toBe(true);
|
||||
expect(result.current.data?.messages[0].id).toBe('message-1');
|
||||
|
||||
nextMessages.resolve({ messages: [{ ...message, id: 'message-2' }], count: 1 });
|
||||
await act(async () => {
|
||||
await refetchPromise;
|
||||
});
|
||||
|
||||
expect(result.current.isRefetching).toBe(false);
|
||||
expect(result.current.data?.messages[0].id).toBe('message-2');
|
||||
});
|
||||
|
||||
it('clears previous messages while loading a different thread id', async () => {
|
||||
const nextMessages = deferred<{ messages: ThreadMessage[]; count: number }>();
|
||||
mockGetThreadMessages
|
||||
.mockResolvedValueOnce({ messages: [message], count: 1 })
|
||||
.mockReturnValueOnce(nextMessages.promise);
|
||||
const { useThreadMessages } = await import('./useThreadQueries');
|
||||
|
||||
const { result, rerender } = renderHook(({ threadId }) => useThreadMessages(threadId), {
|
||||
initialProps: { threadId: 'thread-1' },
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.data?.messages[0].id).toBe('message-1'));
|
||||
|
||||
rerender({ threadId: 'thread-2' });
|
||||
|
||||
await waitFor(() => expect(result.current.data).toBeNull());
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.isRefetching).toBe(false);
|
||||
|
||||
nextMessages.resolve({ messages: [{ ...message, id: 'message-2' }], count: 1 });
|
||||
await waitFor(() => expect(result.current.data?.messages[0].id).toBe('message-2'));
|
||||
expect(mockGetThreadMessages).toHaveBeenNthCalledWith(1, 'thread-1');
|
||||
expect(mockGetThreadMessages).toHaveBeenNthCalledWith(2, 'thread-2');
|
||||
});
|
||||
|
||||
it('ignores stale message responses after switching thread ids', async () => {
|
||||
const firstMessages = deferred<{ messages: ThreadMessage[]; count: number }>();
|
||||
const secondMessages = deferred<{ messages: ThreadMessage[]; count: number }>();
|
||||
mockGetThreadMessages
|
||||
.mockReturnValueOnce(firstMessages.promise)
|
||||
.mockReturnValueOnce(secondMessages.promise);
|
||||
const { useThreadMessages } = await import('./useThreadQueries');
|
||||
|
||||
const { result, rerender } = renderHook(({ threadId }) => useThreadMessages(threadId), {
|
||||
initialProps: { threadId: 'thread-1' },
|
||||
});
|
||||
|
||||
rerender({ threadId: 'thread-2' });
|
||||
|
||||
secondMessages.resolve({ messages: [{ ...message, id: 'message-2' }], count: 1 });
|
||||
await waitFor(() => expect(result.current.data?.messages[0].id).toBe('message-2'));
|
||||
|
||||
firstMessages.resolve({ messages: [{ ...message, id: 'message-1' }], count: 1 });
|
||||
await act(async () => {
|
||||
await firstMessages.promise;
|
||||
});
|
||||
|
||||
expect(result.current.data?.messages[0].id).toBe('message-2');
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
import debug from 'debug';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { threadApi } from '../services/api/threadApi';
|
||||
import type { ThreadMessagesData, ThreadsListData } from '../types/thread';
|
||||
|
||||
const log = debug('hooks:threadQueries');
|
||||
|
||||
export interface ThreadQueryState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
isRefetching: boolean;
|
||||
refetch: () => Promise<T | undefined>;
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
function useThreadQuery<T>(
|
||||
queryName: string,
|
||||
load: () => Promise<T>,
|
||||
enabled = true,
|
||||
queryKey = queryName
|
||||
): ThreadQueryState<T> {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(enabled);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isRefetching, setIsRefetching] = useState(false);
|
||||
const requestIdRef = useRef(0);
|
||||
const dataRef = useRef<T | null>(null);
|
||||
const queryKeyRef = useRef(queryKey);
|
||||
|
||||
const execute = useCallback(
|
||||
async (reason: 'initial' | 'refetch'): Promise<T | undefined> => {
|
||||
if (!enabled) {
|
||||
log('%s skip disabled reason=%s', queryName, reason);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const requestId = requestIdRef.current + 1;
|
||||
requestIdRef.current = requestId;
|
||||
const hasData = dataRef.current !== null;
|
||||
log('%s start requestId=%d reason=%s hasData=%s', queryName, requestId, reason, hasData);
|
||||
|
||||
setError(null);
|
||||
if (hasData || reason === 'refetch') {
|
||||
setIsRefetching(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const nextData = await load();
|
||||
if (requestIdRef.current !== requestId) {
|
||||
log('%s ignore stale success requestId=%d', queryName, requestId);
|
||||
return nextData;
|
||||
}
|
||||
dataRef.current = nextData;
|
||||
setData(nextData);
|
||||
log('%s success requestId=%d', queryName, requestId);
|
||||
return nextData;
|
||||
} catch (caught) {
|
||||
const nextError = normalizeError(caught);
|
||||
if (requestIdRef.current !== requestId) {
|
||||
log('%s ignore stale error requestId=%d error=%o', queryName, requestId, nextError);
|
||||
return undefined;
|
||||
}
|
||||
setError(nextError);
|
||||
log('%s error requestId=%d error=%o', queryName, requestId, nextError);
|
||||
return undefined;
|
||||
} finally {
|
||||
if (requestIdRef.current === requestId) {
|
||||
setLoading(false);
|
||||
setIsRefetching(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[enabled, load, queryName]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (queryKeyRef.current !== queryKey) {
|
||||
requestIdRef.current += 1;
|
||||
queryKeyRef.current = queryKey;
|
||||
dataRef.current = null;
|
||||
setData(null);
|
||||
setError(null);
|
||||
setIsRefetching(false);
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
requestIdRef.current += 1;
|
||||
dataRef.current = null;
|
||||
setData(null);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
setIsRefetching(false);
|
||||
return;
|
||||
}
|
||||
void execute('initial');
|
||||
}, [enabled, execute, queryKey]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
requestIdRef.current += 1;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const refetch = useCallback(() => execute('refetch'), [execute]);
|
||||
|
||||
return { data, loading, error, isRefetching, refetch };
|
||||
}
|
||||
|
||||
export function useThreads(): ThreadQueryState<ThreadsListData> {
|
||||
const load = useCallback(() => threadApi.getThreads(), []);
|
||||
return useThreadQuery('threads.list', load);
|
||||
}
|
||||
|
||||
export function useThreadMessages(threadId?: string | null): ThreadQueryState<ThreadMessagesData> {
|
||||
const normalizedThreadId = threadId?.trim() || null;
|
||||
const load = useCallback(
|
||||
() => threadApi.getThreadMessages(normalizedThreadId ?? ''),
|
||||
[normalizedThreadId]
|
||||
);
|
||||
return useThreadQuery(
|
||||
'threads.messages',
|
||||
load,
|
||||
normalizedThreadId !== null,
|
||||
`threads.messages:${normalizedThreadId ?? 'disabled'}`
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// Source of truth: src/openhuman/agent/agents/integrations_agent/prompt.md
|
||||
// Keep in sync when the Rust-side prompt changes.
|
||||
export const SKILLS_AGENT_PROMPT = `# Skills Agent — Service Integration Specialist
|
||||
|
||||
You are the **Skills Agent**. You interact with connected external services primarily through **Composio** (a managed OAuth gateway for 1000+ apps like Gmail, Notion, GitHub, Slack).
|
||||
|
||||
## Available tool surfaces
|
||||
|
||||
1. **Composio tools** — a small meta-surface that discovers and executes Composio actions on the user's behalf:
|
||||
- \`composio_list_toolkits\` — what integrations the backend allows (e.g. \`gmail\`, \`notion\`).
|
||||
- \`composio_list_connections\` — which of those the user has already authorised.
|
||||
- \`composio_authorize\` — start an OAuth handoff for a toolkit; returns a \`connectUrl\`.
|
||||
- \`composio_list_tools\` — list available action schemas (optionally filtered by toolkit). Use the returned \`function.name\` slug as the \`tool\` argument to \`composio_execute\`.
|
||||
- \`composio_execute\` — run a Composio action with \`{ tool, arguments }\` (e.g. \`tool = "GMAIL_SEND_EMAIL"\`).
|
||||
## Typical Composio flow
|
||||
|
||||
1. Call \`composio_list_connections\` to see what the user already has connected.
|
||||
2. If the required toolkit is missing, call \`composio_authorize\` and return the \`connectUrl\` so the user can complete OAuth.
|
||||
3. Once connected, call \`composio_list_tools\` (optionally scoped to one or two toolkits) to discover the action slug and its JSON schema.
|
||||
4. Call \`composio_execute\` with the slug and argument object.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never fabricate action slugs.** Always pull them from \`composio_list_tools\` before calling \`composio_execute\`.
|
||||
- **Respect rate limits** — Composio and upstream providers both throttle. Back off on errors rather than retrying tightly.
|
||||
- **Handle OAuth expiry** — if an action fails with an auth error, surface the need to re-authorise rather than looping.
|
||||
- **Use memory context** — consult the injected memory context for details about the user's integrations and preferences.
|
||||
- **Be precise** — every tool expects a specific argument shape. Validate against the schema from \`composio_list_tools\` before calling.
|
||||
- **Report results** — state what action was taken and the outcome, including any cost reported by Composio.`;
|
||||
@@ -40,7 +40,6 @@ const Onboarding = () => {
|
||||
<Route path="custom/embeddings" element={<CustomEmbeddingsPage />} />
|
||||
<Route path="custom/activity" element={<CustomActivityPage />} />
|
||||
<Route path="custom/vault" element={<VaultSetupStep />} />
|
||||
{/* <Route path="custom/memory" element={<CustomMemoryPage />} /> */}
|
||||
<Route path="*" element={<Navigate to="welcome" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
|
||||
interface ConfigureLaterCalloutProps {
|
||||
/** Settings-route the user will be deep-linked to after onboarding ends. */
|
||||
settingsHref: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder rendered inside a CustomWizardStep when the user picks
|
||||
* "Configure" but we don't have inline controls for that domain yet.
|
||||
* Records the user's intent; final wiring lives in the matching Settings
|
||||
* panel, surfaced via `settingsHref` once onboarding completes.
|
||||
*/
|
||||
const ConfigureLaterCallout = ({ settingsHref }: ConfigureLaterCalloutProps) => {
|
||||
const { t } = useT();
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-content-secondary leading-relaxed">
|
||||
{t('onboarding.custom.configureLater')}
|
||||
</p>
|
||||
<p className="text-[11px] text-content-muted" data-testid="configure-later-hint">
|
||||
{t('onboarding.custom.openSettings')}:{' '}
|
||||
<code className="text-content-secondary">{settingsHref}</code>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigureLaterCallout;
|
||||
@@ -1,20 +0,0 @@
|
||||
import { trackEvent } from '../../../services/analytics';
|
||||
import { useOnboardingContext } from '../OnboardingContext';
|
||||
import ApiKeysStep from '../steps/ApiKeysStep';
|
||||
|
||||
const ApiKeysPage = () => {
|
||||
const { completeAndExit } = useOnboardingContext();
|
||||
|
||||
const finish = async (skipped: boolean) => {
|
||||
trackEvent('onboarding_step_complete', { step_name: 'api_keys', skipped });
|
||||
try {
|
||||
await completeAndExit();
|
||||
} catch (err) {
|
||||
console.error('[onboarding:api-keys-page] completeAndExit failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
return <ApiKeysStep onNext={() => void finish(false)} onSkip={() => void finish(true)} />;
|
||||
};
|
||||
|
||||
export default ApiKeysPage;
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import OnboardingNextButton from '../components/OnboardingNextButton';
|
||||
import { useOnboardingContext } from '../OnboardingContext';
|
||||
|
||||
/**
|
||||
* Final onboarding step: pick a single chat provider.
|
||||
*
|
||||
* TODO: replace this stub with the real provider picker (WhatsApp /
|
||||
* Telegram / Slack / iMessage / …). For now it just lets the user
|
||||
* complete onboarding with no provider selected so the routed-pages
|
||||
* scaffolding can ship on its own.
|
||||
*/
|
||||
const ChatProviderPage = () => {
|
||||
const { t } = useT();
|
||||
const { completeAndExit } = useOnboardingContext();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleFinish = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await completeAndExit();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not finish onboarding.');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="onboarding-chat-provider-step"
|
||||
className="rounded-2xl border border-line bg-surface p-8 shadow-soft animate-fade-up">
|
||||
<div className="text-center mb-5">
|
||||
<h1 className="text-xl font-bold mb-2 text-content">{t('onboarding.chatProvider')}</h1>
|
||||
<p className="text-content-muted text-sm leading-relaxed max-w-sm mx-auto">
|
||||
{t('onboarding.chatProviderDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-dashed border-line bg-surface-muted p-6 mb-5 text-center text-sm text-content-muted">
|
||||
{t('misc.beta')}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-coral-400 text-sm mb-3 text-center">{error}</p>}
|
||||
|
||||
<OnboardingNextButton
|
||||
onClick={handleFinish}
|
||||
loading={loading}
|
||||
loadingLabel={t('common.finish')}
|
||||
label={t('common.finish')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatProviderPage;
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import MemoryDataPanel from '../../../components/settings/panels/MemoryDataPanel';
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { trackEvent } from '../../../services/analytics';
|
||||
import { CUSTOM_WIZARD_ROUTES, CUSTOM_WIZARD_STEPS } from '../customWizardSteps';
|
||||
import { type CustomStepChoice, useOnboardingContext } from '../OnboardingContext';
|
||||
import CustomWizardStep from '../steps/CustomWizardStep';
|
||||
|
||||
const STEP_KEY = 'memory' as const;
|
||||
const STEP_INDEX = CUSTOM_WIZARD_STEPS.indexOf(STEP_KEY);
|
||||
|
||||
const CustomMemoryPage = () => {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
const { draft, setDraft, completeAndExit } = useOnboardingContext();
|
||||
|
||||
const [choice, setChoice] = useState<CustomStepChoice | null>(
|
||||
draft.customChoices?.[STEP_KEY] ?? null
|
||||
);
|
||||
|
||||
const persistChoice = (next: CustomStepChoice) => {
|
||||
setChoice(next);
|
||||
setDraft(prev => ({ ...prev, customChoices: { ...prev.customChoices, [STEP_KEY]: next } }));
|
||||
};
|
||||
|
||||
const handleFinish = async () => {
|
||||
trackEvent('onboarding_step_complete', {
|
||||
step_name: 'custom_memory',
|
||||
choice: choice ?? 'default',
|
||||
});
|
||||
try {
|
||||
await completeAndExit();
|
||||
} catch (err) {
|
||||
console.error('[onboarding:custom-memory] completeAndExit failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomWizardStep
|
||||
testId="onboarding-custom-memory-step"
|
||||
stepIndex={STEP_INDEX}
|
||||
stepCount={CUSTOM_WIZARD_STEPS.length}
|
||||
title={t('onboarding.custom.memory.title')}
|
||||
subtitle={t('onboarding.custom.memory.subtitle')}
|
||||
defaultDescription={t('onboarding.custom.memory.defaultDesc')}
|
||||
configureDescription={t('onboarding.custom.memory.configureDesc')}
|
||||
configureContent={<MemoryDataPanel embedded />}
|
||||
choice={choice}
|
||||
onChoiceChange={persistChoice}
|
||||
onBack={() => navigate(CUSTOM_WIZARD_ROUTES[CUSTOM_WIZARD_STEPS[STEP_INDEX - 1]])}
|
||||
onContinue={() => void handleFinish()}
|
||||
continueLabel={t('onboarding.custom.finish')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomMemoryPage;
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import Button from '../../../components/ui/Button';
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { useCoreState } from '../../../providers/CoreStateProvider';
|
||||
import { referralApi } from '../../../services/api/referralApi';
|
||||
|
||||
interface ReferralApplyStepProps {
|
||||
onNext: () => void;
|
||||
onBack?: () => void;
|
||||
/** Called after a successful apply so onboarding can skip showing this step when navigating back. */
|
||||
onApplied?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional step: attribute the signed-in user to a referrer via POST /referral/claim.
|
||||
* Only eligible if the user has not yet subscribed.
|
||||
*/
|
||||
const ReferralApplyStep = ({ onNext, onApplied }: ReferralApplyStepProps) => {
|
||||
const { t } = useT();
|
||||
const { refresh } = useCoreState();
|
||||
const [code, setCode] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleApply = async () => {
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await referralApi.claimReferral(trimmed);
|
||||
setSuccess(true);
|
||||
try {
|
||||
await refresh();
|
||||
} catch {
|
||||
console.warn('[onboarding] referral apply: refresh after apply failed');
|
||||
}
|
||||
onApplied?.();
|
||||
console.debug('[onboarding] referral code applied');
|
||||
setTimeout(() => onNext(), 1200);
|
||||
} catch (err: unknown) {
|
||||
let msg = 'Could not apply referral code. Please check and try again.';
|
||||
try {
|
||||
if (err && typeof err === 'object') {
|
||||
const obj = err as Record<string, unknown>;
|
||||
if (typeof obj.error === 'string' && obj.error.trim()) {
|
||||
// Try to parse JSON body embedded in the error string
|
||||
const jsonMatch = String(obj.error).match(/\{.*\}/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]) as Record<string, unknown>;
|
||||
if (typeof parsed.error === 'string' && parsed.error.trim()) {
|
||||
msg = parsed.error;
|
||||
}
|
||||
} else if (!obj.error.includes('{')) {
|
||||
msg = obj.error;
|
||||
}
|
||||
} else if (typeof obj.message === 'string' && obj.message.trim()) {
|
||||
msg = obj.message;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// keep default msg
|
||||
}
|
||||
setError(msg);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-line bg-surface p-8 shadow-soft animate-fade-up">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-xl font-bold mb-2 text-content">{t('onboarding.referral')}</h1>
|
||||
<p className="text-content-secondary text-sm">{t('onboarding.referralDesc')}</p>
|
||||
</div>
|
||||
|
||||
{success ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="w-12 h-12 bg-sage-50 dark:bg-sage-500/10 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<svg
|
||||
className="w-6 h-6 text-sage-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sage-600 dark:text-sage-300 font-medium text-sm">
|
||||
{t('common.success')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={e => setCode(e.target.value.toUpperCase())}
|
||||
onKeyDown={e => e.key === 'Enter' && void handleApply()}
|
||||
placeholder={t('rewards.referralCode')}
|
||||
className="w-full px-4 py-3 bg-surface-muted border border-line rounded-xl text-center font-mono text-lg tracking-widest text-content placeholder:text-stone-400 dark:placeholder:text-neutral-500 dark:text-content-faint placeholder:tracking-normal placeholder:font-sans placeholder:text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500/50 transition-all"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{error ? <p className="text-coral-500 text-xs mt-2 text-center">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" onClick={onNext} disabled={isLoading} className="flex-1">
|
||||
{t('onboarding.skip')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void handleApply()}
|
||||
disabled={isLoading || !code.trim()}
|
||||
className="flex-1">
|
||||
{isLoading ? t('common.loading') : t('common.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReferralApplyStep;
|
||||
@@ -1,88 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockCallCoreRpc = vi.fn();
|
||||
|
||||
vi.mock('../coreRpcClient', () => ({
|
||||
callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args),
|
||||
}));
|
||||
|
||||
describe('mcpSetupApi', () => {
|
||||
beforeEach(() => {
|
||||
mockCallCoreRpc.mockReset();
|
||||
});
|
||||
|
||||
it('search calls mcp_setup_search', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ servers: [], page: 1, total_pages: 1 });
|
||||
const { mcpSetupApi } = await import('./mcpSetupApi');
|
||||
await mcpSetupApi.search({ query: 'notion' });
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.mcp_setup_search',
|
||||
params: { query: 'notion' },
|
||||
});
|
||||
});
|
||||
|
||||
it('get unwraps the server detail', async () => {
|
||||
const server = { qualified_name: 'q', display_name: 'd', connections: [] };
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ server });
|
||||
const { mcpSetupApi } = await import('./mcpSetupApi');
|
||||
const result = await mcpSetupApi.get('q');
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.mcp_setup_get',
|
||||
params: { qualified_name: 'q' },
|
||||
});
|
||||
expect(result).toEqual(server);
|
||||
});
|
||||
|
||||
it('requestSecret returns an opaque ref', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ ref: 'secret://abc', key_name: 'NOTION_API_KEY' });
|
||||
const { mcpSetupApi } = await import('./mcpSetupApi');
|
||||
const result = await mcpSetupApi.requestSecret({
|
||||
key_name: 'NOTION_API_KEY',
|
||||
prompt: 'Paste your token',
|
||||
});
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.mcp_setup_request_secret',
|
||||
params: { key_name: 'NOTION_API_KEY', prompt: 'Paste your token' },
|
||||
});
|
||||
expect(result.ref).toBe('secret://abc');
|
||||
});
|
||||
|
||||
it('submitSecret forwards the ref + value', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ ref: 'secret://abc', fulfilled: true });
|
||||
const { mcpSetupApi } = await import('./mcpSetupApi');
|
||||
const result = await mcpSetupApi.submitSecret({ ref_id: 'secret://abc', value: 'tok' });
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.mcp_setup_submit_secret',
|
||||
params: { ref_id: 'secret://abc', value: 'tok' },
|
||||
});
|
||||
expect(result.fulfilled).toBe(true);
|
||||
});
|
||||
|
||||
it('testConnection returns ok + tools', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ ok: true, tools: [{ name: 'search' }] });
|
||||
const { mcpSetupApi } = await import('./mcpSetupApi');
|
||||
const result = await mcpSetupApi.testConnection({
|
||||
qualified_name: 'q',
|
||||
env_refs: { NOTION_API_KEY: 'secret://abc' },
|
||||
});
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.mcp_setup_test_connection',
|
||||
params: { qualified_name: 'q', env_refs: { NOTION_API_KEY: 'secret://abc' } },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('installAndConnect commits the install', async () => {
|
||||
mockCallCoreRpc.mockResolvedValueOnce({ server_id: 'srv-1', status: 'connected', tools: [] });
|
||||
const { mcpSetupApi } = await import('./mcpSetupApi');
|
||||
const result = await mcpSetupApi.installAndConnect({
|
||||
qualified_name: 'q',
|
||||
env_refs: { NOTION_API_KEY: 'secret://abc' },
|
||||
});
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.mcp_setup_install_and_connect',
|
||||
params: { qualified_name: 'q', env_refs: { NOTION_API_KEY: 'secret://abc' } },
|
||||
});
|
||||
expect(result.status).toBe('connected');
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* Typed RPC wrapper for the MCP **setup-agent** domain (`mcp_setup_*`).
|
||||
*
|
||||
* These RPCs power the agent-native "install MCP server with assistant" flow:
|
||||
* search → get → request_secret (out-of-band native prompt) → test_connection →
|
||||
* install_and_connect. Secret *values* never travel through here as plaintext
|
||||
* the agent sees — `request_secret` returns an opaque `secret://<hex>` ref that
|
||||
* the UI fulfils via `submitSecret`, and only refs are passed to
|
||||
* `testConnection` / `installAndConnect`.
|
||||
*
|
||||
* Centralises the `openhuman.mcp_setup_<function>` method-name strings so
|
||||
* components never spell them out directly (issue #3039 gap B4).
|
||||
*/
|
||||
import debug from 'debug';
|
||||
|
||||
import type {
|
||||
McpTool,
|
||||
SmitheryServer,
|
||||
SmitheryServerDetail,
|
||||
} from '../../components/channels/mcp/types';
|
||||
import { callCoreRpc } from '../coreRpcClient';
|
||||
|
||||
const log = debug('mcp-setup:api');
|
||||
|
||||
interface SetupSearchResult {
|
||||
servers: SmitheryServer[];
|
||||
page: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
interface SetupGetResult {
|
||||
server: SmitheryServerDetail;
|
||||
}
|
||||
|
||||
interface RequestSecretResult {
|
||||
ref: string;
|
||||
key_name: string;
|
||||
}
|
||||
|
||||
interface SubmitSecretResult {
|
||||
ref: string;
|
||||
fulfilled: boolean;
|
||||
}
|
||||
|
||||
interface TestConnectionResult {
|
||||
ok: boolean;
|
||||
tools?: McpTool[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface InstallAndConnectResult {
|
||||
server_id: string;
|
||||
status: 'connected' | 'installed_disconnected';
|
||||
tools?: McpTool[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const mcpSetupApi = {
|
||||
/** Search all enabled registries (official modelcontextprotocol.io primary, Smithery fallback). */
|
||||
search: async (params: {
|
||||
query?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}): Promise<SetupSearchResult> => {
|
||||
log('search params=%o', params);
|
||||
return callCoreRpc<SetupSearchResult>({ method: 'openhuman.mcp_setup_search', params });
|
||||
},
|
||||
|
||||
/** Fetch one server's detail with `required_env_keys` injected. */
|
||||
get: async (qualified_name: string): Promise<SmitheryServerDetail> => {
|
||||
log('get qualified_name=%s', qualified_name);
|
||||
const result = await callCoreRpc<SetupGetResult>({
|
||||
method: 'openhuman.mcp_setup_get',
|
||||
params: { qualified_name },
|
||||
});
|
||||
return result.server;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ask the core to request a secret from the user out-of-band. Returns an
|
||||
* opaque ref; the value is collected by the native SecretPromptDialog and
|
||||
* submitted via {@link submitSecret}.
|
||||
*/
|
||||
requestSecret: async (params: {
|
||||
key_name: string;
|
||||
prompt: string;
|
||||
}): Promise<RequestSecretResult> => {
|
||||
log('request_secret key_name=%s', params.key_name);
|
||||
return callCoreRpc<RequestSecretResult>({
|
||||
method: 'openhuman.mcp_setup_request_secret',
|
||||
params,
|
||||
});
|
||||
},
|
||||
|
||||
/** UI-side: fulfil a pending `request_secret` with the user-entered value. */
|
||||
submitSecret: async (params: { ref_id: string; value: string }): Promise<SubmitSecretResult> => {
|
||||
// Intentionally NOT logging the value.
|
||||
log('submit_secret ref_id=%s', params.ref_id);
|
||||
return callCoreRpc<SubmitSecretResult>({ method: 'openhuman.mcp_setup_submit_secret', params });
|
||||
},
|
||||
|
||||
/** Dry-run a candidate install (spawn, list tools, tear down — nothing persisted). */
|
||||
testConnection: async (params: {
|
||||
qualified_name: string;
|
||||
env_refs: Record<string, string>;
|
||||
}): Promise<TestConnectionResult> => {
|
||||
log('test_connection qualified_name=%s', params.qualified_name);
|
||||
const result = await callCoreRpc<TestConnectionResult>({
|
||||
method: 'openhuman.mcp_setup_test_connection',
|
||||
params,
|
||||
});
|
||||
log('test_connection ok=%s', result.ok);
|
||||
return result;
|
||||
},
|
||||
|
||||
/** Commit the install + secrets, then connect and return the tool list. */
|
||||
installAndConnect: async (params: {
|
||||
qualified_name: string;
|
||||
env_refs: Record<string, string>;
|
||||
}): Promise<InstallAndConnectResult> => {
|
||||
log('install_and_connect qualified_name=%s', params.qualified_name);
|
||||
const result = await callCoreRpc<InstallAndConnectResult>({
|
||||
method: 'openhuman.mcp_setup_install_and_connect',
|
||||
params,
|
||||
});
|
||||
log('install_and_connect status=%s', result.status);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
@@ -1,425 +0,0 @@
|
||||
/// <reference types="@testing-library/jest-dom/vitest" />
|
||||
/**
|
||||
* Tests for Google OAuth login via OAuthLoginSection and OAuthProviderButton.
|
||||
*
|
||||
* Coverage areas:
|
||||
* - Section renders all providers including Google
|
||||
* - Google button initiates OAuth in both Tauri (desktop) and web environments
|
||||
* - Loading / disabled state management during login
|
||||
* - Error handling when the backend URL lookup fails
|
||||
* - dev-mode URL construction (responseType=json query param)
|
||||
*/
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import OAuthLoginSection from '../src/components/oauth/OAuthLoginSection';
|
||||
import OAuthProviderButton from '../src/components/oauth/OAuthProviderButton';
|
||||
import { oauthProviderConfigs } from '../src/components/oauth/providerConfigs';
|
||||
import { renderWithProviders } from '../src/test/test-utils';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module mocks
|
||||
// vi.hoisted() ensures mock functions are available inside vi.mock() factories
|
||||
// (which are hoisted to the top of the file by Vitest).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
mockGetBackendUrl,
|
||||
mockOpenUrl,
|
||||
mockIsTauri,
|
||||
mockPrepareOAuthLoginLaunch,
|
||||
mockCheckBackendHealthy,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetBackendUrl: vi.fn(),
|
||||
mockOpenUrl: vi.fn(),
|
||||
mockIsTauri: vi.fn(),
|
||||
mockPrepareOAuthLoginLaunch: vi.fn(),
|
||||
// Default to a healthy backend so the pre-flight in OAuthProviderButton
|
||||
// (added for issue #1985) doesn't short-circuit the OAuth flow these
|
||||
// tests exercise.
|
||||
mockCheckBackendHealthy: vi.fn().mockImplementation(async () => {
|
||||
// Mirror the real checkBackendHealthy contract: never throw — convert a
|
||||
// getBackendUrl() rejection into the resolve-failure result so tests that
|
||||
// exercise that error path see the production banner instead of an
|
||||
// unhandled rejection in the click handler.
|
||||
try {
|
||||
const backendUrl = await mockGetBackendUrl();
|
||||
return { healthy: true, status: 200, latencyMs: 5, backendUrl };
|
||||
} catch {
|
||||
return { healthy: false, reason: 'resolve-failure', latencyMs: 0 };
|
||||
}
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../src/services/backendUrl', () => ({ getBackendUrl: mockGetBackendUrl }));
|
||||
vi.mock('../src/services/backendHealth', () => ({ checkBackendHealthy: mockCheckBackendHealthy }));
|
||||
|
||||
vi.mock('../src/utils/openUrl', () => ({ openUrl: mockOpenUrl }));
|
||||
|
||||
vi.mock('../src/utils/tauriCommands', async importOriginal => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, isTauri: mockIsTauri };
|
||||
});
|
||||
vi.mock('../src/utils/oauthAppVersionGate', async importOriginal => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, prepareOAuthLoginLaunch: mockPrepareOAuthLoginLaunch };
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrepareOAuthLoginLaunch.mockReset();
|
||||
mockPrepareOAuthLoginLaunch.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
// IS_DEV is set to `true` by the global setup mock of '../utils/config'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const googleConfig = oauthProviderConfigs.find(p => p.id === 'google')!;
|
||||
|
||||
const renderSection = (props: Partial<ComponentProps<typeof OAuthLoginSection>> = {}) =>
|
||||
renderWithProviders(<OAuthLoginSection {...props} />);
|
||||
|
||||
const renderGoogleButton = (props: Partial<ComponentProps<typeof OAuthProviderButton>> = {}) =>
|
||||
renderWithProviders(<OAuthProviderButton provider={googleConfig} {...props} />);
|
||||
|
||||
// act() with an async callback returns Promise<void>, making await valid.
|
||||
const clickButton = (btn: HTMLElement) =>
|
||||
act(async () => {
|
||||
fireEvent.click(btn);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OAuthLoginSection — rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('OAuthLoginSection', () => {
|
||||
it('renders the "Continue with" heading', () => {
|
||||
renderSection();
|
||||
expect(screen.getByText('Continue with')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a button for every configured OAuth provider', () => {
|
||||
renderSection();
|
||||
for (const provider of oauthProviderConfigs) {
|
||||
expect(
|
||||
screen.getByRole('button', { name: new RegExp(provider.name, 'i') })
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders a Google login button', () => {
|
||||
renderSection();
|
||||
expect(screen.getByRole('button', { name: /google/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders buttons in a 2-column grid', () => {
|
||||
const { container } = renderSection();
|
||||
const grid = container.querySelector('.grid.grid-cols-2');
|
||||
expect(grid).toBeInTheDocument();
|
||||
expect(grid!.children).toHaveLength(oauthProviderConfigs.length);
|
||||
});
|
||||
|
||||
it('applies extra className to the wrapper div', () => {
|
||||
const { container } = renderSection({ className: 'mt-8' });
|
||||
expect(container.firstChild).toHaveClass('mt-8');
|
||||
});
|
||||
|
||||
it('forwards disabled prop to every provider button', () => {
|
||||
renderSection({ disabled: true });
|
||||
for (const btn of screen.getAllByRole('button')) {
|
||||
expect(btn).toBeDisabled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Google button — initial render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('OAuthProviderButton (Google) — rendering', () => {
|
||||
beforeEach(() => {
|
||||
mockGetBackendUrl.mockResolvedValue('http://localhost:5005');
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('shows the Google label', () => {
|
||||
renderGoogleButton();
|
||||
expect(screen.getByText('Google')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is enabled by default', () => {
|
||||
renderGoogleButton();
|
||||
expect(screen.getByRole('button', { name: /google/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('is disabled when disabled prop is true', () => {
|
||||
renderGoogleButton({ disabled: true });
|
||||
expect(screen.getByRole('button', { name: /google/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders the Google SVG icon', () => {
|
||||
const { container } = renderGoogleButton();
|
||||
expect(container.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Google button — web environment OAuth flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('OAuthProviderButton (Google) — web OAuth flow', () => {
|
||||
const originalLocation = window.location;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetBackendUrl.mockResolvedValue('http://localhost:5005');
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
|
||||
// Replace window.location so we can assert href changes
|
||||
delete (window as unknown as Record<string, unknown>).location;
|
||||
(window as unknown as Record<string, unknown>).location = { href: '' };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(window as unknown as Record<string, unknown>).location = originalLocation;
|
||||
});
|
||||
|
||||
it('redirects to backend Google OAuth URL on click (web, IS_DEV=true)', async () => {
|
||||
renderGoogleButton();
|
||||
await clickButton(screen.getByRole('button', { name: /google/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect((window.location as unknown as { href: string }).href).toContain(
|
||||
'http://localhost:5005/auth/google/login?responseType=json'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call openUrl (Tauri) in web mode', async () => {
|
||||
renderGoogleButton();
|
||||
await clickButton(screen.getByRole('button', { name: /google/i }));
|
||||
|
||||
await waitFor(() => expect((window.location as unknown as { href: string }).href).not.toBe(''));
|
||||
expect(mockOpenUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls getBackendUrl exactly once per click', async () => {
|
||||
renderGoogleButton();
|
||||
await clickButton(screen.getByRole('button', { name: /google/i }));
|
||||
|
||||
await waitFor(() => expect((window.location as unknown as { href: string }).href).not.toBe(''));
|
||||
expect(mockGetBackendUrl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Google button — Tauri (desktop) OAuth flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('OAuthProviderButton (Google) — Tauri OAuth flow', () => {
|
||||
beforeEach(() => {
|
||||
mockGetBackendUrl.mockResolvedValue('https://api.example.com');
|
||||
mockIsTauri.mockReturnValue(true);
|
||||
mockOpenUrl.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('calls openUrl with the Google OAuth URL (Tauri, IS_DEV=true)', async () => {
|
||||
renderGoogleButton();
|
||||
await clickButton(screen.getByRole('button', { name: /google/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOpenUrl).toHaveBeenCalledWith(
|
||||
expect.stringContaining('https://api.example.com/auth/google/login?responseType=json')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not set window.location.href in Tauri mode', async () => {
|
||||
const originalHref = window.location.href;
|
||||
renderGoogleButton();
|
||||
await clickButton(screen.getByRole('button', { name: /google/i }));
|
||||
|
||||
await waitFor(() => expect(mockOpenUrl).toHaveBeenCalledTimes(1));
|
||||
expect(window.location.href).toBe(originalHref);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Google button — loading state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('OAuthProviderButton (Google) — loading state', () => {
|
||||
it('shows spinner and "Connecting..." text while login is in progress', async () => {
|
||||
let resolveBackendUrl!: (_v: string) => void;
|
||||
mockGetBackendUrl.mockReturnValue(
|
||||
new Promise<string>(res => {
|
||||
resolveBackendUrl = res;
|
||||
})
|
||||
);
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
|
||||
renderGoogleButton();
|
||||
const button = screen.getByRole('button', { name: /google/i });
|
||||
|
||||
await clickButton(button);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Connecting...')).toBeInTheDocument());
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
expect(button).toBeDisabled();
|
||||
|
||||
// Settle the promise so React doesn't warn about state updates after unmount
|
||||
await act(async () => {
|
||||
resolveBackendUrl('http://localhost:5005');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not respond to a second click while already loading', async () => {
|
||||
let resolveBackendUrl!: (_v: string) => void;
|
||||
mockGetBackendUrl.mockReturnValue(
|
||||
new Promise<string>(res => {
|
||||
resolveBackendUrl = res;
|
||||
})
|
||||
);
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
|
||||
renderGoogleButton();
|
||||
const button = screen.getByRole('button', { name: /google/i });
|
||||
|
||||
await clickButton(button);
|
||||
await waitFor(() => expect(screen.getByText('Connecting...')).toBeInTheDocument());
|
||||
|
||||
// Second click while loading — getBackendUrl must still be called only once
|
||||
fireEvent.click(button);
|
||||
expect(mockGetBackendUrl).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
resolveBackendUrl('http://localhost:5005');
|
||||
});
|
||||
});
|
||||
|
||||
it('remains in loading state after successful Tauri openUrl (awaits deep-link callback)', async () => {
|
||||
// By design: the app calls openUrl() to open the system browser and then waits
|
||||
// for the deep-link callback. setIsLoading(false) is only called on error, so
|
||||
// the button intentionally stays in "Connecting..." state.
|
||||
mockGetBackendUrl.mockResolvedValue('http://localhost:5005');
|
||||
mockIsTauri.mockReturnValue(true);
|
||||
mockOpenUrl.mockResolvedValue(undefined);
|
||||
|
||||
renderGoogleButton();
|
||||
await clickButton(screen.getByRole('button', { name: /google/i }));
|
||||
|
||||
await waitFor(() => expect(mockOpenUrl).toHaveBeenCalledTimes(1));
|
||||
expect(screen.getByText('Connecting...')).toBeInTheDocument();
|
||||
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Google button — error handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('OAuthProviderButton (Google) — error handling', () => {
|
||||
beforeEach(() => {
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('returns to enabled state after getBackendUrl throws', async () => {
|
||||
mockGetBackendUrl.mockRejectedValue(new Error('network error'));
|
||||
|
||||
renderGoogleButton();
|
||||
const button = screen.getByRole('button', { name: /google/i });
|
||||
await clickButton(button);
|
||||
|
||||
await waitFor(() => expect(button).toBeEnabled());
|
||||
expect(screen.getByText('Google')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not redirect when getBackendUrl throws (web mode)', async () => {
|
||||
const originalLocation = window.location;
|
||||
delete (window as unknown as Record<string, unknown>).location;
|
||||
(window as unknown as Record<string, unknown>).location = { href: '' };
|
||||
|
||||
mockGetBackendUrl.mockRejectedValue(new Error('network error'));
|
||||
|
||||
renderGoogleButton();
|
||||
await clickButton(screen.getByRole('button', { name: /google/i }));
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /google/i })).toBeEnabled());
|
||||
expect((window.location as unknown as { href: string }).href).toBe('');
|
||||
|
||||
(window as unknown as Record<string, unknown>).location = originalLocation;
|
||||
});
|
||||
|
||||
it('does not call openUrl when getBackendUrl throws in Tauri mode', async () => {
|
||||
mockIsTauri.mockReturnValue(true);
|
||||
mockGetBackendUrl.mockRejectedValue(new Error('network error'));
|
||||
|
||||
renderGoogleButton();
|
||||
await clickButton(screen.getByRole('button', { name: /google/i }));
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /google/i })).toBeEnabled());
|
||||
expect(mockOpenUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op when the button is disabled and clicked', async () => {
|
||||
renderGoogleButton({ disabled: true });
|
||||
await clickButton(screen.getByRole('button', { name: /google/i }));
|
||||
expect(mockGetBackendUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL construction — dev mode query params (IS_DEV=true via global setup mock)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('OAuthProviderButton (Google) — dev mode URL params', () => {
|
||||
// The global setup.ts mocks IS_DEV=true, so these assertions run in that context.
|
||||
|
||||
it('appends ?responseType=json to the Google OAuth URL in dev mode (Tauri)', async () => {
|
||||
mockGetBackendUrl.mockResolvedValue('https://api.example.com');
|
||||
mockIsTauri.mockReturnValue(true);
|
||||
mockOpenUrl.mockResolvedValue(undefined);
|
||||
|
||||
renderGoogleButton();
|
||||
await clickButton(screen.getByRole('button', { name: /google/i }));
|
||||
|
||||
await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled());
|
||||
const calledUrl: string = mockOpenUrl.mock.calls[0][0];
|
||||
expect(calledUrl).toContain('?responseType=json');
|
||||
expect(calledUrl).toContain('https://api.example.com/auth/google/login?responseType=json');
|
||||
});
|
||||
|
||||
it('appends ?responseType=json to the Google OAuth URL in dev mode (web)', async () => {
|
||||
const originalLocation = window.location;
|
||||
delete (window as unknown as Record<string, unknown>).location;
|
||||
(window as unknown as Record<string, unknown>).location = { href: '' };
|
||||
|
||||
mockGetBackendUrl.mockResolvedValue('https://api.example.com');
|
||||
mockIsTauri.mockReturnValue(false);
|
||||
|
||||
renderGoogleButton();
|
||||
await clickButton(screen.getByRole('button', { name: /google/i }));
|
||||
|
||||
await waitFor(() => expect((window.location as unknown as { href: string }).href).not.toBe(''));
|
||||
expect((window.location as unknown as { href: string }).href).toContain(
|
||||
'https://api.example.com/auth/google/login?responseType=json'
|
||||
);
|
||||
|
||||
(window as unknown as Record<string, unknown>).location = originalLocation;
|
||||
});
|
||||
|
||||
it('uses the /auth/google/login path (not another provider)', async () => {
|
||||
mockGetBackendUrl.mockResolvedValue('https://api.example.com');
|
||||
mockIsTauri.mockReturnValue(true);
|
||||
mockOpenUrl.mockResolvedValue(undefined);
|
||||
|
||||
renderGoogleButton();
|
||||
await clickButton(screen.getByRole('button', { name: /google/i }));
|
||||
|
||||
await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled());
|
||||
const calledUrl: string = mockOpenUrl.mock.calls[0][0];
|
||||
expect(calledUrl).toContain('/auth/google/login');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user