Files
openhuman/src/pages/Skills.tsx
T
Steven EnamakelandGitHub 249aedfc04 chore: remov unwanted/mobile references and update skills submodule (#45)
* refactor: remove mobile platform support and update documentation

- Updated CONTRIBUTING.md and SECURITY.md to reflect the removal of Android and iOS support, focusing on desktop platforms only.
- Modified AGENTS.md and MEMORY.md to clarify the platform capabilities of OpenHuman as a desktop application.
- Adjusted various Rust files to eliminate references to mobile platforms, including socket management and platform detection logic.
- Removed mobile-specific code from SkillsGrid and Skills components, ensuring the application is tailored for desktop usage.
- Deleted unused iOS and Android assets and configurations from the Tauri project structure.

* chore: remove zeroclaw mentions and mobile support paths
2026-03-27 14:38:48 -07:00

320 lines
12 KiB
TypeScript

import { invoke } from '@tauri-apps/api/core';
import { useEffect, useMemo, useState } from 'react';
import {
DefaultIcon,
SKILL_ICONS,
SkillActionButton,
type SkillListEntry,
STATUS_DISPLAY,
STATUS_PRIORITY,
} from '../components/skills/shared';
import SkillSetupModal from '../components/skills/SkillSetupModal';
import { deriveConnectionStatus, useSkillConnectionStatus } from '../lib/skills/hooks';
import { skillManager } from '../lib/skills/manager';
import type { SkillConnectionStatus, SkillHostConnectionState } from '../lib/skills/types';
import { useAppSelector } from '../store/hooks';
import { IS_DEV } from '../utils/config';
import { deriveSkillSyncUiState } from './skillsSyncUi';
/** Format large numbers: 1200 → "1.2K", 1200000 → "1.2M" */
function formatNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}
/** Status dot color for skill connection status */
function statusDotClass(status: SkillConnectionStatus): string {
switch (status) {
case 'connected':
return 'bg-sage-400';
case 'connecting':
return 'bg-amber-400 animate-pulse';
case 'error':
return 'bg-coral-400';
default:
return 'bg-stone-600';
}
}
// ─── Skill Card (used in the skills list) ───────────────────────────────────
interface SkillCardProps {
skill: SkillListEntry;
onSetup: () => void;
}
function SkillCard({ skill, onSetup }: SkillCardProps) {
const connectionStatus = useSkillConnectionStatus(skill.id);
const statusDisplay = STATUS_DISPLAY[connectionStatus] || STATUS_DISPLAY.offline;
const skillState = useAppSelector(state => state.skills.skillStates[skill.id]) as
| (SkillHostConnectionState & Record<string, unknown>)
| undefined;
const [manualSyncing, setManualSyncing] = useState(false);
const syncUi = useMemo(
() => deriveSkillSyncUiState(skill.id, skillState),
[skill.id, skillState]
);
const isSyncing = manualSyncing || syncUi.isSyncing;
const handleSync = async (e: React.MouseEvent) => {
e.stopPropagation();
setManualSyncing(true);
try {
await skillManager.triggerSync(skill.id);
} catch (err) {
console.error(`Sync failed for ${skill.id}:`, err);
} finally {
setManualSyncing(false);
}
};
// Build subtitle from skill state (sync time, chat/message counts)
const subtitleParts: string[] = [];
if (skillState) {
const chatCount = skillState.chat_count as number | undefined;
const msgCount = skillState.message_count as number | undefined;
const lastSync = skillState.last_sync as string | undefined;
if (chatCount != null) subtitleParts.push(`${formatNumber(chatCount)} chats`);
if (msgCount != null) subtitleParts.push(`${formatNumber(msgCount)} msgs`);
if (lastSync) subtitleParts.push(`Synced ${lastSync}`);
}
return (
<div className="flex items-center gap-3 p-3 rounded-xl bg-white/[0.03] border border-white/[0.06] hover:bg-white/[0.06] transition-colors">
{/* Icon */}
<div className="w-8 h-8 flex items-center justify-center text-white opacity-70 flex-shrink-0">
{skill.icon || <DefaultIcon />}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-white truncate">{skill.name}</span>
<div
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${statusDotClass(connectionStatus)}`}
/>
<span className={`text-xs flex-shrink-0 ${statusDisplay.color}`}>
{statusDisplay.text}
</span>
</div>
{subtitleParts.length > 0 && (
<p className="text-[11px] text-stone-500 truncate mt-0.5">{subtitleParts.join(' · ')}</p>
)}
{isSyncing && (
<div className="mt-1.5">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-stone-800">
{syncUi.progressPercent != null ? (
<div
className="h-full rounded-full bg-primary-400 transition-all duration-300"
style={{ width: `${syncUi.progressPercent}%` }}
/>
) : (
<div className="h-full w-1/2 rounded-full bg-primary-400/80 animate-pulse" />
)}
</div>
{syncUi.progressMessage && (
<p className="text-[11px] text-primary-300 truncate mt-1">{syncUi.progressMessage}</p>
)}
{syncUi.metricsText && (
<p className="text-[11px] text-stone-500 truncate mt-0.5">{syncUi.metricsText}</p>
)}
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-1 flex-shrink-0">
{connectionStatus === 'connected' && (
<>
{/* Sync */}
<button
onClick={isSyncing ? undefined : handleSync}
disabled={isSyncing}
className="w-7 h-7 flex items-center justify-center rounded-lg text-stone-400 hover:text-white hover:bg-white/10 transition-colors disabled:opacity-40"
title="Sync">
<svg
className={`w-3.5 h-3.5 ${isSyncing ? 'animate-spin' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
{/* Settings */}
<button
onClick={e => {
e.stopPropagation();
onSetup();
}}
className="w-7 h-7 flex items-center justify-center rounded-lg text-stone-400 hover:text-white hover:bg-white/10 transition-colors"
title="Settings">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
</>
)}
<SkillActionButton
skill={skill}
connectionStatus={connectionStatus}
onOpenModal={onSetup}
/>
</div>
</div>
);
}
// ─── Main Skills Page ───────────────────────────────────────────────────────
export default function Skills() {
// Skills state
const [skillsList, setSkillsList] = useState<SkillListEntry[]>([]);
const [skillsLoading, setSkillsLoading] = useState(true);
const skillsState = useAppSelector(state => state.skills.skills);
const skillStates = useAppSelector(state => state.skills.skillStates);
// Modal state
const [setupModalOpen, setSetupModalOpen] = useState(false);
const [activeSkillId, setActiveSkillId] = useState<string | null>(null);
const [activeSkillName, setActiveSkillName] = useState('');
const [activeSkillDescription, setActiveSkillDescription] = useState('');
const [activeSkillHasSetup, setActiveSkillHasSetup] = useState(false);
// Load skills
useEffect(() => {
const loadSkills = async () => {
try {
const manifests = await invoke<Array<Record<string, unknown>>>('runtime_discover_skills');
const ALLOWED_SKILLS = new Set(['gmail', 'notion']);
const validManifests = manifests.filter(m => {
const id = m.id as string;
if (id.includes('_')) return false;
return ALLOWED_SKILLS.has(id);
});
const processed: SkillListEntry[] = validManifests
.map(m => {
const setup = m.setup as Record<string, unknown> | undefined;
return {
id: m.id as string,
name:
(m.name as string) ||
(m.id as string).charAt(0).toUpperCase() + (m.id as string).slice(1),
description: (m.description as string) || '',
icon: SKILL_ICONS[m.id as string],
ignoreInProduction: (m.ignoreInProduction as boolean) ?? false,
hasSetup: !!(setup && setup.required),
};
})
.filter(s => IS_DEV || !s.ignoreInProduction);
setSkillsList(processed);
} catch {
// Skills unavailable
} finally {
setSkillsLoading(false);
}
};
loadSkills();
}, []);
// Sort skills by connection status
const sortedSkillsList = useMemo(() => {
return [...skillsList]
.sort((a, b) => {
const skillA = skillsState[a.id];
const skillB = skillsState[b.id];
const stateA = skillStates[a.id];
const stateB = skillStates[b.id];
const statusA = deriveConnectionStatus(skillA?.status, skillA?.setupComplete, stateA);
const statusB = deriveConnectionStatus(skillB?.status, skillB?.setupComplete, stateB);
const priorityA = STATUS_PRIORITY[statusA] ?? 999;
const priorityB = STATUS_PRIORITY[statusB] ?? 999;
if (priorityA === priorityB) return a.name.localeCompare(b.name);
return priorityA - priorityB;
})
.filter(s => IS_DEV || !s.ignoreInProduction);
}, [skillsList, skillsState, skillStates]);
const openSkillSetup = (skill: SkillListEntry) => {
setActiveSkillId(skill.id);
setActiveSkillName(skill.name);
setActiveSkillDescription(skill.description);
setActiveSkillHasSetup(skill.hasSetup);
setSetupModalOpen(true);
};
return (
<div className="min-h-full relative">
<div className="relative z-10 min-h-full flex flex-col">
<div className="flex-1 p-6">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-bold text-white">Skills</h1>
</div>
{/* Active Skills */}
<div className="animate-fade-up" style={{ animationDelay: '100ms' }}>
<div className="mb-3">
<h2 className="text-sm font-semibold text-white opacity-80">Active Skills</h2>
</div>
{skillsLoading ? (
<div className="glass rounded-2xl p-6 text-center">
<p className="text-sm text-stone-500">Loading skills...</p>
</div>
) : sortedSkillsList.length === 0 ? (
<div className="glass rounded-2xl p-6 text-center">
<p className="text-sm text-stone-500">No skills discovered</p>
</div>
) : (
<div className="space-y-2">
{sortedSkillsList.map(skill => (
<SkillCard key={skill.id} skill={skill} onSetup={() => openSkillSetup(skill)} />
))}
</div>
)}
</div>
</div>
</div>
</div>
{/* Setup modal */}
{setupModalOpen && activeSkillId && (
<SkillSetupModal
skillId={activeSkillId}
skillName={activeSkillName}
skillDescription={activeSkillDescription}
hasSetup={activeSkillHasSetup}
onClose={() => {
setSetupModalOpen(false);
setActiveSkillId(null);
}}
/>
)}
</div>
);
}