feat(ui): hide dev-only features in production, clean up homepage (#3157)

This commit is contained in:
Steven Enamakel
2026-06-01 08:49:08 -07:00
committed by GitHub
parent 7ca56362dc
commit 46812db44b
6 changed files with 81 additions and 163 deletions
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import type { ToastNotification } from '../../types/intelligence';
import { IS_DEV } from '../../utils/config';
import PillTabBar from '../PillTabBar';
import ConnectionPathTab from './ConnectionPathTab';
import DiagramViewerTab from './DiagramViewerTab';
@@ -39,17 +40,18 @@ export default function MemorySection({ onToast }: MemorySectionProps) {
const { t } = useT();
const [activeSubTab, setActiveSubTab] = useState<MemorySubTab>('memoryTree');
const subTabs: { id: MemorySubTab; label: string }[] = [
const allSubTabs: { id: MemorySubTab; label: string; devOnly?: boolean }[] = [
{ id: 'memoryTree', label: t('memory.tab.memoryTree') },
{ id: 'diagram', label: t('memory.tab.diagram') },
{ id: 'centrality', label: t('memory.tab.centrality') },
{ id: 'cohesion', label: t('memory.tab.cohesion') },
{ id: 'associations', label: t('memory.tab.associations') },
{ id: 'freshness', label: t('memory.tab.freshness') },
{ id: 'timeline', label: t('memory.tab.timeline') },
{ id: 'paths', label: t('memory.tab.path') },
{ id: 'namespaces', label: t('memory.tab.namespaces') },
{ id: 'diagram', label: t('memory.tab.diagram'), devOnly: true },
{ id: 'centrality', label: t('memory.tab.centrality'), devOnly: true },
{ id: 'cohesion', label: t('memory.tab.cohesion'), devOnly: true },
{ id: 'associations', label: t('memory.tab.associations'), devOnly: true },
{ id: 'freshness', label: t('memory.tab.freshness'), devOnly: true },
{ id: 'timeline', label: t('memory.tab.timeline'), devOnly: true },
{ id: 'paths', label: t('memory.tab.path'), devOnly: true },
{ id: 'namespaces', label: t('memory.tab.namespaces'), devOnly: true },
];
const subTabs = allSubTabs.filter(tab => !tab.devOnly || IS_DEV);
return (
<div className="space-y-4">
+15 -12
View File
@@ -10,6 +10,7 @@ import {
selectCustomSecondaryColor,
selectMascotColor,
} from '../../store/mascotSlice';
import { IS_DEV } from '../../utils/config';
import { CustomGifMascot, getMascotPalette, hexToArgbInt, RiveMascot } from './Mascot';
import { useHumanMascot } from './useHumanMascot';
@@ -72,18 +73,20 @@ const HumanPage = () => {
{t('voice.pushToTalk')}
</label>
{/* "Send OpenHuman to a meeting" — opens the Flow A modal which spawns
an off-screen CEF webview pointed at the Meet URL with the mascot
canvas as the outbound camera and synthesized speech as the
outbound mic. The user's OS mic is never wired to the meeting. */}
<button
type="button"
onClick={() => setJoinMeetingOpen(true)}
data-testid="human-join-meeting-pill"
className="absolute top-4 left-44 z-10 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-primary-500 text-white text-xs font-medium shadow-soft hover:bg-primary-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-300">
<span aria-hidden="true">📞</span>
{t('skills.meetingBots.modalTitle')}
</button>
{/* "Send OpenHuman to a meeting" — dev-only; opens the Flow A modal
which spawns an off-screen CEF webview pointed at the Meet URL with
the mascot canvas as the outbound camera and synthesized speech as
the outbound mic. The user's OS mic is never wired to the meeting. */}
{IS_DEV && (
<button
type="button"
onClick={() => setJoinMeetingOpen(true)}
data-testid="human-join-meeting-pill"
className="absolute top-4 left-44 z-10 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-primary-500 text-white text-xs font-medium shadow-soft hover:bg-primary-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-300">
<span aria-hidden="true">📞</span>
{t('skills.meetingBots.modalTitle')}
</button>
)}
{joinMeetingOpen && <MeetingBotsModal onClose={() => setJoinMeetingOpen(false)} />}
+1 -73
View File
@@ -1,15 +1,12 @@
import createDebug from 'debug';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import ConnectionIndicator from '../components/ConnectionIndicator';
import {
DiscordBanner,
EarlyBirdyBanner,
PromotionalCreditsBanner,
UsageLimitBanner,
} from '../components/home/HomeBanners';
import { dismissBanner, shouldShowBanner } from '../components/upsell/upsellDismissState';
import { useUsageState } from '../hooks/useUsageState';
import { useUser } from '../hooks/useUser';
import { useT } from '../lib/i18n/I18nContext';
@@ -18,9 +15,6 @@ import { selectBlockingState } from '../store/connectivitySelectors';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { resolveTheme, setThemeMode, type ThemeMode } from '../store/themeSlice';
import { APP_VERSION } from '../utils/config';
import { openhumanCronList } from '../utils/tauriCommands';
const homeLog = createDebug('app:home');
export function resolveHomeUserName(user: unknown): string {
if (!user || typeof user !== 'object') return 'User';
@@ -57,16 +51,6 @@ const Home = () => {
user?.subscription?.plan === 'FREE' || !user?.subscription?.hasActiveSubscription;
const showPromoBanner = isFreeTier && promoCredits > 0.01;
// Early birdy banner: once dismissed it stays gone (cooldown longer than any realistic session).
const [showEarlyBirdy, setShowEarlyBirdy] = useState(() =>
shouldShowBanner('home-earlybirdy', Number.MAX_SAFE_INTEGER)
);
const handleDismissEarlyBirdy = () => {
dismissBanner('home-earlybirdy');
setShowEarlyBirdy(false);
};
const welcomeVariants = useMemo(
() => [`Welcome, ${userName} 👋`, `Let's cook, ${userName} 🧑‍🍳.`, `Time to Zone In 🧘🏻`],
[userName]
@@ -81,21 +65,6 @@ const Home = () => {
const [isRestartingCore, setIsRestartingCore] = useState(false);
const [restartError, setRestartError] = useState<string | null>(null);
// Routine count for the card on home.
const [activeRoutineCount, setActiveRoutineCount] = useState<number | null>(null);
const loadRoutineCount = useCallback(async () => {
try {
const response = await openhumanCronList();
const activeCount = response.result.filter(j => j.enabled).length;
setActiveRoutineCount(activeCount);
} catch (err) {
homeLog('failed to load routine count', err);
}
}, []);
useEffect(() => {
void loadRoutineCount();
}, [loadRoutineCount]);
const dispatch = useAppDispatch();
const themeMode = useAppSelector(state => state.theme.mode) as ThemeMode;
const resolvedTheme = resolveTheme(themeMode);
@@ -276,47 +245,6 @@ const Home = () => {
</button>
</div>
{/* Routines card */}
{activeRoutineCount !== null && (
<button
onClick={() => navigate('/routines')}
className="mt-3 w-full bg-white dark:bg-neutral-900 rounded-2xl shadow-soft border border-stone-200 dark:border-neutral-800 p-4 flex items-center gap-3 text-left hover:border-primary-300 dark:hover:border-primary-500/40 transition-colors animate-fade-up">
<div className="w-9 h-9 rounded-full bg-primary-50 dark:bg-primary-500/10 flex items-center justify-center flex-shrink-0">
<svg
className="w-4.5 h-4.5 text-primary-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('home.routinesCard')}
</div>
<div className="text-xs text-stone-500 dark:text-neutral-400">
{activeRoutineCount > 0
? t('home.routinesActive').replace('{count}', String(activeRoutineCount))
: t('routines.emptyHint')}
</div>
</div>
<svg
className="w-4 h-4 text-stone-400 dark:text-neutral-500 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
)}
{showEarlyBirdy && <EarlyBirdyBanner onDismiss={handleDismissEarlyBirdy} />}
<DiscordBanner />
{/* Next steps — compact directory of where to go next */}
+26 -13
View File
@@ -17,6 +17,7 @@ import type {
ConfirmationModal as ConfirmationModalType,
ToastNotification,
} from '../types/intelligence';
import { IS_DEV } from '../utils/config';
import AgentWorkflows from './AgentWorkflows';
type IntelligenceTab = 'memory' | 'subconscious' | 'tasks' | 'workflows' | 'council';
@@ -24,7 +25,7 @@ type IntelligenceTab = 'memory' | 'subconscious' | 'tasks' | 'workflows' | 'coun
export default function Intelligence() {
const { t } = useT();
const [activeTab, setActiveTab] = useState<IntelligenceTab>('tasks');
const [activeTab, setActiveTab] = useState<IntelligenceTab>('memory');
// The legacy header pills (system-status + Ingesting/Queued chips) were
// sourced from `useConsciousItems` + `useMemoryIngestionStatus`. They are
@@ -87,18 +88,30 @@ export default function Intelligence() {
}
}, [socketConnected, socketManager]);
const tabs: { id: IntelligenceTab; label: string; description?: string; comingSoon?: boolean }[] =
[
{ id: 'tasks', label: t('memory.tab.tasks'), description: t('memory.tab.tasksDescription') },
{ id: 'memory', label: t('memory.tab.memory') },
{ id: 'subconscious', label: t('memory.tab.subconscious') },
{
id: 'workflows',
label: t('memory.tab.workflows'),
description: t('memory.tab.workflowsDescription'),
},
{ id: 'council', label: t('memory.tab.council') },
];
const allTabs: {
id: IntelligenceTab;
label: string;
description?: string;
comingSoon?: boolean;
devOnly?: boolean;
}[] = [
{
id: 'tasks',
label: t('memory.tab.tasks'),
description: t('memory.tab.tasksDescription'),
devOnly: true,
},
{ id: 'memory', label: t('memory.tab.memory') },
{ id: 'subconscious', label: t('memory.tab.subconscious') },
{
id: 'workflows',
label: t('memory.tab.workflows'),
description: t('memory.tab.workflowsDescription'),
devOnly: true,
},
{ id: 'council', label: t('memory.tab.council'), devOnly: true },
];
const tabs = allTabs.filter(tab => !tab.devOnly || IS_DEV);
const activeTabDef = tabs.find(tab => tab.id === activeTab);
return (
+23 -11
View File
@@ -355,7 +355,8 @@ export default function Skills() {
const initialTab: ConnectionsTab = (() => {
const params = new URLSearchParams(location.search);
const t = params.get('tab');
if (t === 'runners' || t === 'composio' || t === 'channels' || t === 'mcp') return t;
if (t === 'runners') return IS_DEV ? 'runners' : 'composio';
if (t === 'composio' || t === 'channels' || t === 'mcp') return t;
return 'composio';
})();
const [activeTab, setActiveTab] = useState<ConnectionsTab>(initialTab);
@@ -659,7 +660,9 @@ export default function Skills() {
for (const { meta } of composioGridEntries) {
cats.add(meta.category);
}
return SKILL_CATEGORY_ORDER.filter(c => c !== 'Channels' && cats.has(c));
return SKILL_CATEGORY_ORDER.filter(
c => c !== 'Channels' && cats.has(c) && (IS_DEV || c !== 'Other')
);
}, [allItems, composioGridEntries]);
const filteredItems = useMemo(() => {
@@ -690,7 +693,7 @@ export default function Skills() {
return items.length > 0 ? { category: 'Channels' as SkillCategory, items } : undefined;
}, [allItems]);
const otherGroups = useMemo(
() => groupedItems.filter(g => g.category !== 'Channels'),
() => groupedItems.filter(g => g.category !== 'Channels' && (IS_DEV || g.category !== 'Other')),
[groupedItems]
);
@@ -941,12 +944,12 @@ export default function Skills() {
{ value: 'composio', label: t('skills.tabs.composio') },
{ value: 'channels', label: t('skills.tabs.channels') },
{ value: 'mcp', label: t('skills.tabs.mcp') },
{ value: 'runners', label: t('skills.tabs.runners') },
...(IS_DEV ? [{ value: 'runners' as const, label: t('skills.tabs.runners') }] : []),
]}
/>
{
<>
{activeTab === 'runners' && (
{IS_DEV && activeTab === 'runners' && (
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-soft animate-fade-up">
{/* The Runners sub-tab IS the scheduled-skills dashboard:
header + [+ Create a Skill] + [▷ Run a Skill] CTAs
@@ -1102,12 +1105,21 @@ export default function Skills() {
{t('channels.mcp.description')}
</p>
</div>
{/* The real browse/install/manage surface (issue #3039).
McpServersTab manages its own height via h-full, so it
needs a sized container to fill. */}
<div className="h-[72vh] min-h-[480px]">
<McpServersTab />
</div>
{IS_DEV ? (
<div className="h-[72vh] min-h-[480px]">
<McpServersTab />
</div>
) : (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="text-3xl mb-3">🔌</div>
<p className="text-sm font-medium text-stone-700 dark:text-neutral-300">
{t('misc.comingSoon')}
</p>
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
{t('channels.mcp.description')}
</p>
</div>
)}
</div>
)}
</>
+5 -45
View File
@@ -61,15 +61,6 @@ vi.mock('../../services/coreProcessControl', () => ({
restartCoreProcess: () => restartCoreProcessMock(),
}));
const mockShouldShowBanner = vi.fn<() => boolean>(() => true);
const mockDismissBanner = vi.fn<(id: string) => void>();
vi.mock('../../components/upsell/upsellDismissState', () => ({
shouldShowBanner: (...args: Parameters<typeof mockShouldShowBanner>) =>
mockShouldShowBanner(...args),
dismissBanner: (...args: Parameters<typeof mockDismissBanner>) => mockDismissBanner(...args),
}));
describe('resolveHomeUserName', () => {
it('uses camelCase name fields when present', () => {
expect(resolveHomeUserName({ firstName: 'Ada', lastName: 'Lovelace' })).toBe('Ada Lovelace');
@@ -111,7 +102,7 @@ describe('resolveHomeUserName', () => {
describe('Home page — handleRestartCore and blocking state rendering', () => {
it('shows "Restart Core" button when blocking=core-unreachable (lines 194, 200)', async () => {
useAppSelectorMock.mockReturnValue('core-unreachable');
mockShouldShowBanner.mockReturnValue(false);
const { default: Home } = await import('../Home');
render(<Home />);
@@ -120,7 +111,7 @@ describe('Home page — handleRestartCore and blocking state rendering', () => {
it('does NOT show "Restart Core" button when blocking=ok (line 194)', async () => {
useAppSelectorMock.mockReturnValue('ok');
mockShouldShowBanner.mockReturnValue(false);
const { default: Home } = await import('../Home');
render(<Home />);
@@ -129,7 +120,7 @@ describe('Home page — handleRestartCore and blocking state rendering', () => {
it('handleRestartCore calls restartCoreProcess and resets state on success (lines 78-81, 85)', async () => {
useAppSelectorMock.mockReturnValue('core-unreachable');
mockShouldShowBanner.mockReturnValue(false);
restartCoreProcessMock.mockResolvedValueOnce(undefined);
const { default: Home } = await import('../Home');
@@ -150,7 +141,7 @@ describe('Home page — handleRestartCore and blocking state rendering', () => {
it('handleRestartCore shows error message when restartCoreProcess throws (lines 78-83, 202)', async () => {
useAppSelectorMock.mockReturnValue('core-unreachable');
mockShouldShowBanner.mockReturnValue(false);
restartCoreProcessMock.mockRejectedValueOnce(new Error('sidecar not found'));
const { default: Home } = await import('../Home');
@@ -164,7 +155,7 @@ describe('Home page — handleRestartCore and blocking state rendering', () => {
it('handleRestartCore shows string error when restartCoreProcess throws a non-Error (lines 83)', async () => {
useAppSelectorMock.mockReturnValue('core-unreachable');
mockShouldShowBanner.mockReturnValue(false);
restartCoreProcessMock.mockRejectedValueOnce('raw string error');
const { default: Home } = await import('../Home');
@@ -177,40 +168,11 @@ describe('Home page — handleRestartCore and blocking state rendering', () => {
});
});
describe('Home page — EarlyBirdy banner integration', () => {
it('shows the EarlyBirdy banner when shouldShowBanner returns true', async () => {
mockShouldShowBanner.mockReturnValue(true);
const { default: Home } = await import('../Home');
render(<Home />);
expect(screen.getByText('The first 1,000 users get 60% off.')).toBeInTheDocument();
});
it('hides the EarlyBirdy banner when shouldShowBanner returns false', async () => {
mockShouldShowBanner.mockReturnValue(false);
const { default: Home } = await import('../Home');
render(<Home />);
expect(screen.queryByText('The first 1,000 users get 60% off.')).not.toBeInTheDocument();
});
it('dismisses the EarlyBirdy banner and calls dismissBanner when the X button is clicked', async () => {
mockShouldShowBanner.mockReturnValue(true);
const { default: Home } = await import('../Home');
render(<Home />);
const dismissBtn = screen.getByRole('button', { name: /dismiss early bird banner/i });
fireEvent.click(dismissBtn);
expect(mockDismissBanner).toHaveBeenCalledWith('home-earlybirdy');
expect(screen.queryByText('The first 1,000 users get 60% off.')).not.toBeInTheDocument();
});
});
describe('Home page — theme toggle', () => {
it('renders "Switch to dark mode" in light mode and dispatches setThemeMode("dark") on click', async () => {
themeModeProbe.current = 'light';
const dispatch = vi.fn();
useAppDispatchMock.mockReturnValue(dispatch);
mockShouldShowBanner.mockReturnValue(false);
const { default: Home } = await import('../Home');
render(<Home />);
@@ -228,7 +190,6 @@ describe('Home page — theme toggle', () => {
themeModeProbe.current = 'dark';
const dispatch = vi.fn();
useAppDispatchMock.mockReturnValue(dispatch);
mockShouldShowBanner.mockReturnValue(false);
const { default: Home } = await import('../Home');
render(<Home />);
@@ -248,7 +209,6 @@ describe('Home page — budget completed banner', () => {
// Covers line 151: UsageLimitBanner render when shouldShowBudgetCompletedMessage=true
it('renders UsageLimitBanner when shouldShowBudgetCompletedMessage=true', async () => {
mockUseUsageState.mockReturnValueOnce({ shouldShowBudgetCompletedMessage: true });
mockShouldShowBanner.mockReturnValue(false);
const { default: Home } = await import('../Home');
render(<Home />);