import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
type DesktopStatus = "loading" | "ready" | "error";
type CheckStatus = "pass" | "warn" | "fail";
type DesktopCheck = {
id: string;
label: string;
status: CheckStatus;
detail: string;
};
type DesktopDependency = {
name: string;
description: string;
website?: string;
status: CheckStatus;
path?: string;
version?: string;
verify_command: string;
install_commands: string[];
detail: string;
};
type GitCommitBinding = {
github_login: string;
commit_name: string;
commit_email: string;
};
type DesktopProfile = {
identity: string;
handle: string;
display_name: string;
rho_home: string;
source: string;
has_local_private_keys: boolean;
signing_key_path?: string;
encryption_key_path?: string;
proof_verified: boolean;
proof_url?: string;
git_commit_binding?: GitCommitBinding;
};
type ProfileSelection = {
identity?: string;
rho_home?: string;
};
type CommandResult = {
ok: boolean;
code?: number;
stdout: string;
stderr: string;
};
type GitHubAccount = {
login: string;
host: string;
active: boolean;
state: string;
git_protocol: string;
token_source: string;
matching_identity: string;
has_rho_profile: boolean;
has_local_private_keys: boolean;
signing_key_path?: string;
encryption_key_path?: string;
proof_verified: boolean;
proof_url?: string;
git_commit_binding?: GitCommitBinding;
};
type OnboardingState = {
rho_home: string;
gh_accounts: GitHubAccount[];
local_profiles: DesktopProfile[];
};
type DesktopProject = {
id: string;
name: string;
path: string;
exists: boolean;
is_git: boolean;
is_rho: boolean;
rho_project_id?: string;
owner?: string;
role: string;
current_branch?: string;
remote_url?: string;
dirty: boolean;
upstream?: string;
ahead: number;
behind: number;
status_summary: string;
warning?: string;
};
type FileEntry = {
path: string;
kind: string;
status?: string;
};
type MemberInfo = {
identity: string;
handle: string;
role: string;
};
type ParticipantInfo = {
handle: string;
identity?: string;
display_name?: string;
signing_fingerprint?: string;
encryption_fingerprint?: string;
proof_url?: string;
};
type RepoFileSummary = {
path: string;
title: string;
summary: string;
};
type ToolInfo = {
id: string;
action_type?: string;
owner?: string;
approval_required: boolean;
path: string;
};
type MessageInfo = {
path: string;
inbox: string;
thread: string;
kind: string;
encrypted: boolean;
signed: boolean;
summary: string;
};
type PendingAction = {
id: string;
kind: string;
actor?: string;
summary: string;
path: string;
};
type GitStatusEntry = {
path: string;
status: string;
};
type RepoDetail = {
project: DesktopProject;
files: FileEntry[];
members: MemberInfo[];
participants: ParticipantInfo[];
policy_files: RepoFileSummary[];
tools: ToolInfo[];
messages: MessageInfo[];
pending_actions: PendingAction[];
status_entries: GitStatusEntry[];
};
type NavIconName =
| "setup"
| "projects"
| "messages"
| "activity"
| "settings"
| "refresh"
| "bug"
| "sun"
| "moon";
function NavIcon({ name }: { name: NavIconName }) {
const common = {
width: 18,
height: 18,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true
};
if (name === "setup") {
return (
<svg {...common}>
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<path d="m9 12 2 2 4-5" />
</svg>
);
}
if (name === "projects") {
return (
<svg {...common}>
<path d="M3 7a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
</svg>
);
}
if (name === "messages") {
return (
<svg {...common}>
<path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4Z" />
</svg>
);
}
if (name === "activity") {
return (
<svg {...common}>
<path d="M3 12h4l3 8 4-16 3 8h4" />
</svg>
);
}
if (name === "sun") {
return (
<svg {...common}>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</svg>
);
}
if (name === "moon") {
return (
<svg {...common}>
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" />
</svg>
);
}
if (name === "bug") {
return (
<svg {...common}>
<path d="M8 6a4 4 0 0 1 8 0" />
<rect x="8" y="6" width="8" height="12" rx="4" />
<path d="M3 11h3M18 11h3M3 17h4M17 17h4M12 6v14" />
</svg>
);
}
if (name === "settings") {
return (
<svg {...common}>
<path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z" />
<path d="M19.4 15a1.8 1.8 0 0 0 .36 1.98l.04.04a2 2 0 1 1-2.83 2.83l-.04-.04A1.8 1.8 0 0 0 15 19.4a1.8 1.8 0 0 0-1 .6 1.8 1.8 0 0 0-.4 1.1V21a2 2 0 1 1-4 0v-.08A1.8 1.8 0 0 0 8.6 19.4a1.8 1.8 0 0 0-1.98.36l-.04.04a2 2 0 1 1-2.83-2.83l.04-.04A1.8 1.8 0 0 0 4.6 15a1.8 1.8 0 0 0-.6-1 1.8 1.8 0 0 0-1.1-.4H3a2 2 0 1 1 0-4h.08A1.8 1.8 0 0 0 4.6 8.6a1.8 1.8 0 0 0-.36-1.98l-.04-.04a2 2 0 1 1 2.83-2.83l.04.04A1.8 1.8 0 0 0 9 4.6a1.8 1.8 0 0 0 1-.6 1.8 1.8 0 0 0 .4-1.1V3a2 2 0 1 1 4 0v.08A1.8 1.8 0 0 0 15.4 4.6a1.8 1.8 0 0 0 1.98-.36l.04-.04a2 2 0 1 1 2.83 2.83l-.04.04A1.8 1.8 0 0 0 19.4 9a1.8 1.8 0 0 0 .6 1 1.8 1.8 0 0 0 1.1.4H21a2 2 0 1 1 0 4h-.08A1.8 1.8 0 0 0 19.4 15Z" />
</svg>
);
}
return (
<svg {...common}>
<path d="M21 12a9 9 0 0 1-9 9 8.7 8.7 0 0 1-6.4-2.8" />
<path d="M3 12a9 9 0 0 1 15.1-6.6" />
<path d="M18 2v4h-4" />
<path d="M6 22v-4h4" />
</svg>
);
}
function ClipboardIcon({ copied }: { copied: boolean }) {
if (copied) {
return (
<svg
width={15}
height={15}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden={true}
>
<path d="m5 13 4 4L19 7" />
</svg>
);
}
return (
<svg
width={15}
height={15}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden={true}
>
<rect x="9" y="9" width="11" height="11" rx="2" />
<path d="M5 15V5a2 2 0 0 1 2-2h10" />
</svg>
);
}
function RhoLogo({ size = 26 }: { size?: number }) {
return (
<svg
viewBox="0 0 1024 1024"
width={size}
height={size}
fill="none"
stroke="currentColor"
strokeWidth={80}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden={true}
>
<path d="M 510 930 V 245 A 195 195 0 1 1 510 575" />
<path d="M 388 575 H 510" />
<path d="M 437 852 H 583" />
</svg>
);
}
function FolderIcon() {
return (
<svg
width={16}
height={16}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden={true}
>
<path d="M3 7a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
</svg>
);
}
// Logins whose avatar failed to load — kept so we don't retry a broken image or
// flicker back to it on re-mount. Successful images are served from the webview's
// HTTP cache by URL, so an <img> (CORS-free, unlike fetch) won't reload.
const avatarFailed = new Set<string>();
function Avatar({ login, size = 40 }: { login: string; size?: number }) {
const [failed, setFailed] = useState(() => avatarFailed.has(login));
if (failed || !login) {
return (
<span
className="avatar avatar-fallback"
style={{ width: size, height: size, fontSize: Math.round(size * 0.4) }}
>
{(login.slice(0, 1) || "?").toUpperCase()}
</span>
);
}
return (
<img
className="avatar"
src={`https://github.com/${encodeURIComponent(login)}.png?size=${Math.round(size * 2)}`}
width={size}
height={size}
alt=""
onError={() => {
avatarFailed.add(login);
setFailed(true);
}}
/>
);
}
function AppRail({
active,
onReportBug,
onOpenSettings,
onOpenProfiles,
onOpenProjects,
onToggleTheme,
theme,
currentLogin
}: {
active: "setup" | "projects" | "messages" | "settings";
onReportBug: () => void;
onOpenSettings: () => void;
onOpenProfiles: () => void;
onOpenProjects: () => void;
onToggleTheme: () => void;
theme: "light" | "dark";
currentLogin?: string;
}) {
return (
<aside className="desktop-sidebar" aria-label="Rho navigation">
<div className="sidebar-brand" title="Rho">
<RhoLogo size={24} />
</div>
<button
type="button"
className="sidebar-icon"
data-active={active === "projects"}
onClick={onOpenProjects}
title="Projects"
>
<NavIcon name="projects" />
</button>
<button
type="button"
className="sidebar-icon"
data-active={active === "messages"}
disabled
title="Messages"
>
<NavIcon name="messages" />
</button>
<button
type="button"
className="sidebar-icon sidebar-bottom"
onClick={onToggleTheme}
title={theme === "dark" ? "Dark mode (switch to light)" : "Light mode (switch to dark)"}
>
<NavIcon name={theme === "dark" ? "moon" : "sun"} />
</button>
<button type="button" className="sidebar-icon" onClick={onReportBug} title="Report a bug">
<NavIcon name="bug" />
</button>
{currentLogin ? (
<button
type="button"
className="sidebar-icon sidebar-profile"
onClick={onOpenProfiles}
title={`Profile: ${currentLogin} — switch`}
>
<Avatar login={currentLogin} size={30} />
</button>
) : null}
<button
type="button"
className="sidebar-icon"
data-active={active === "settings"}
onClick={onOpenSettings}
title="Settings"
>
<NavIcon name="settings" />
</button>
</aside>
);
}
export default function App() {
const [status, setStatus] = useState<DesktopStatus>("loading");
const [version, setVersion] = useState<string>("");
const [error, setError] = useState<string>("");
const [profiles, setProfiles] = useState<DesktopProfile[]>([]);
const [dependencies, setDependencies] = useState<DesktopDependency[]>([]);
const [selectedDependencyName, setSelectedDependencyName] = useState<string>("");
const [dependenciesError, setDependenciesError] = useState<string>("");
const [isCheckingDependencies, setIsCheckingDependencies] = useState<boolean>(false);
const [selectedIdentity, setSelectedIdentity] = useState<string>("");
const [checks, setChecks] = useState<DesktopCheck[]>([]);
const [isChecking, setIsChecking] = useState<boolean>(false);
const [actionResult, setActionResult] = useState<string>("");
const [isSwitchingGh, setIsSwitchingGh] = useState<boolean>(false);
const [isCheckingEnv, setIsCheckingEnv] = useState<boolean>(false);
const [projects, setProjects] = useState<DesktopProject[]>([]);
const [projectsError, setProjectsError] = useState<string>("");
const [isLoadingProjects, setIsLoadingProjects] = useState<boolean>(false);
const [isAddingProject, setIsAddingProject] = useState<boolean>(false);
const [isCloningProject, setIsCloningProject] = useState<boolean>(false);
const [isSettingUpRhoRepo, setIsSettingUpRhoRepo] = useState<boolean>(false);
const [localProjectPath, setLocalProjectPath] = useState<string>("");
const [cloneUrl, setCloneUrl] = useState<string>("");
const [cloneDestination, setCloneDestination] = useState<string>("");
const [setupRepoPath, setSetupRepoPath] = useState<string>("");
const [setupRepoId, setSetupRepoId] = useState<string>("");
const [setupRemoteUrl, setSetupRemoteUrl] = useState<string>("");
const [setupProtectOwnerInbox, setSetupProtectOwnerInbox] = useState<boolean>(true);
const [selectedProjectPath, setSelectedProjectPath] = useState<string>("");
const [repoDetail, setRepoDetail] = useState<RepoDetail | null>(null);
const [repoDetailError, setRepoDetailError] = useState<string>("");
const [isLoadingRepoDetail, setIsLoadingRepoDetail] = useState<boolean>(false);
const [onboarding, setOnboarding] = useState<OnboardingState | null>(null);
const [onboardingError, setOnboardingError] = useState<string>("");
const [isLoadingOnboarding, setIsLoadingOnboarding] = useState<boolean>(false);
const [isCreatingProfile, setIsCreatingProfile] = useState<string>("");
const [isVerifyingProfile, setIsVerifyingProfile] = useState<string>("");
const [isBindingCommitAuthor, setIsBindingCommitAuthor] = useState<string>("");
const [manualHandle, setManualHandle] = useState<string>("");
const [repoActionTargetHandle, setRepoActionTargetHandle] = useState<string>("");
const [repoActionRequestId, setRepoActionRequestId] = useState<string>("");
const [isRunningRepoAction, setIsRunningRepoAction] = useState<string>("");
const [commitMessage, setCommitMessage] = useState<string>("");
const [isCommittingRepo, setIsCommittingRepo] = useState<boolean>(false);
const [messageRecipient, setMessageRecipient] = useState<string>("");
const [messageThread, setMessageThread] = useState<string>("");
const [messageText, setMessageText] = useState<string>("");
const [isSendingMessage, setIsSendingMessage] = useState<boolean>(false);
const [branchName, setBranchName] = useState<string>("");
const [isRunningGitAction, setIsRunningGitAction] = useState<string>("");
const [onboardingStep, setOnboardingStep] = useState<"tools" | "accounts" | "profile">("tools");
const [toolsSkipped, setToolsSkipped] = useState<boolean>(false);
const [isOpeningTerminal, setIsOpeningTerminal] = useState<boolean>(false);
const [copiedField, setCopiedField] = useState<string>("");
const [showProfileSwitcher, setShowProfileSwitcher] = useState<boolean>(false);
const [view, setView] = useState<"onboarding" | "projects" | "settings">("onboarding");
const [hiddenProfiles, setHiddenProfiles] = useState<string[]>(() => {
try {
const raw = window.localStorage.getItem("rho.desktop.hiddenProfiles");
return raw ? (JSON.parse(raw) as string[]) : [];
} catch {
return [];
}
});
const [theme, setTheme] = useState<"light" | "dark">(() => {
const saved = window.localStorage.getItem("rho.desktop.theme");
if (saved === "light" || saved === "dark") {
return saved;
}
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
});
const [proofIssue, setProofIssue] = useState<{
handle: string;
kind: "missing" | "invalid";
} | null>(null);
useEffect(() => {
const queryIdentity = new URLSearchParams(window.location.search).get("identity") ?? "";
void Promise.all([
invoke<string>("app_version"),
invoke<DesktopProfile[]>("desktop_profiles"),
invoke<OnboardingState>("desktop_onboarding"),
invoke<DesktopDependency[]>("desktop_dependencies"),
invoke<string | null>("launch_identity").catch(() => null)
])
.then(([value, discoveredProfiles, onboardingState, dependencyState, launchedIdentity]) => {
setVersion(value);
setProfiles(discoveredProfiles);
setOnboarding(onboardingState);
setDependencies(dependencyState);
setSelectedDependencyName(dependencyState[0]?.name || "");
setSelectedIdentity(
queryIdentity || launchedIdentity || discoveredProfiles[0]?.identity || ""
);
// Land on Projects if a profile already exists; otherwise start onboarding.
setView(discoveredProfiles.length > 0 ? "projects" : "onboarding");
setStatus("ready");
})
.catch((cause: unknown) => {
setError(cause instanceof Error ? cause.message : String(cause));
setStatus("error");
});
}, []);
useEffect(() => {
if (status !== "ready") {
return;
}
void runChecks(selectedIdentity);
void loadProjects(selectedIdentity);
setSelectedProjectPath("");
setRepoDetail(null);
}, [selectedIdentity, status]);
const selectedProfile = profiles.find((profile) => profile.identity === selectedIdentity);
const selectedHandle = selectedProfile?.handle || githubHandle(selectedIdentity);
const selectedAccount = onboarding?.gh_accounts.find((account) => account.login === selectedHandle);
const prerequisiteIds = ["rho_cli", "git", "gh", "gh_auth"];
const prerequisiteChecks = checks.filter((check) => prerequisiteIds.includes(check.id));
const profileChecks = checks.filter((check) => !prerequisiteIds.includes(check.id));
const prerequisitesReady =
prerequisiteChecks.length >= prerequisiteIds.length &&
prerequisiteChecks.every((check) => check.status === "pass");
const selectedProfileReady = selectedAccount
? isAccountReady(selectedAccount)
: Boolean(
selectedProfile?.has_local_private_keys &&
selectedProfile.proof_verified &&
selectedProfile.git_commit_binding
);
const finalReadinessReady =
prerequisitesReady && selectedProfileReady && checks.every((check) => check.status === "pass");
const visibleDependencies = dependencies.filter((dependency) => dependency.name !== "rho");
const allDependenciesReady =
visibleDependencies.length > 0 &&
visibleDependencies.every((dependency) => dependency.status === "pass");
const selectedDependency =
visibleDependencies.find((dependency) => dependency.name === selectedDependencyName) ||
visibleDependencies[0];
const shouldShowOnboarding = view === "onboarding";
const hasUsableProfiles = profiles.length > 0;
const didAutoSkipTools = useRef(false);
const prevOnboardingStep = useRef(onboardingStep);
// Latest values for the once-registered focus listener, so it never has to
// re-register (re-registering churns Tauri's event map and races its cleanup).
const focusCtx = useRef({
onboardingStep,
selectedAccount,
isVerifyingProfile,
verify: verifyProfileForHandle
});
focusCtx.current = {
onboardingStep,
selectedAccount,
isVerifyingProfile,
verify: verifyProfileForHandle
};
// Re-check the GitHub proof against the live profile every time the user opens
// the Prepare step, so the "verified" badge reflects reality.
useEffect(() => {
const enteringProfile =
onboardingStep === "profile" && prevOnboardingStep.current !== "profile";
prevOnboardingStep.current = onboardingStep;
if (enteringProfile && selectedAccount?.has_rho_profile && !isVerifyingProfile) {
void verifyProfileForHandle(selectedAccount.login);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onboardingStep, selectedAccount?.login, selectedAccount?.has_rho_profile]);
// Re-check on app-window focus (e.g. after editing GitHub in the browser and
// switching back). Registered ONCE; reads current state via focusCtx ref.
useEffect(() => {
let unlisten: (() => void) | undefined;
let cancelled = false;
void getCurrentWindow()
.onFocusChanged(({ payload: focused }) => {
if (!focused) {
return;
}
const ctx = focusCtx.current;
if (
ctx.onboardingStep === "profile" &&
ctx.selectedAccount?.has_rho_profile &&
!ctx.isVerifyingProfile
) {
void ctx.verify(ctx.selectedAccount.login);
}
})
.then((fn) => {
if (cancelled) {
fn();
} else {
unlisten = fn;
}
});
return () => {
cancelled = true;
unlisten?.();
};
}, []);
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
window.localStorage.setItem("rho.desktop.theme", theme);
}, [theme]);
useEffect(() => {
if (
status === "ready" &&
allDependenciesReady &&
!didAutoSkipTools.current &&
onboardingStep === "tools"
) {
didAutoSkipTools.current = true;
setOnboardingStep("accounts");
}
}, [status, allDependenciesReady, onboardingStep]);
function selection(identity = selectedIdentity): ProfileSelection | undefined {
const profile = profiles.find((candidate) => candidate.identity === identity);
if (!profile && !identity) {
return undefined;
}
return {
identity: identity || profile?.identity,
rho_home: profile?.rho_home
};
}
function githubHandle(identity?: string): string {
return (
identity
?.replace("rho://id/github/", "")
.replace("github/", "")
.trim() || ""
);
}
function isAccountReady(account?: GitHubAccount): boolean {
return Boolean(
account?.has_rho_profile &&
account.has_local_private_keys &&
account.proof_verified &&
account.git_commit_binding
);
}
function dependencyTitle(name: string): string {
const titles: Record<string, string> = { gh: "GitHub CLI", git: "Git" };
return titles[name] || name;
}
function statusLabel(statusValue: CheckStatus): string {
if (statusValue === "pass") {
return "Ready";
}
if (statusValue === "warn") {
return "Needs attention";
}
return "Blocked";
}
function accountChecklist(account: GitHubAccount) {
return [
{
label: "Rho profile",
ok: account.has_rho_profile,
detail: account.has_rho_profile ? account.matching_identity : "Not created"
},
{
label: "Private keys",
ok: account.has_local_private_keys,
detail: account.has_local_private_keys ? "Signing and encryption keys found" : "Keys missing"
},
{
label: "GitHub proof",
ok: account.proof_verified,
detail: account.proof_verified ? "Verified" : account.proof_url || "Not verified"
},
{
label: "Commit author",
ok: Boolean(account.git_commit_binding),
detail: account.git_commit_binding
? `${account.git_commit_binding.commit_name} <${account.git_commit_binding.commit_email}>`
: "Not bound"
}
];
}
function dismissOnboarding() {
setView("projects");
}
function finishOnboarding() {
const ready = Boolean(selectedAccount?.has_rho_profile && selectedAccount?.proof_verified);
if (!ready) {
const proceed = window.confirm(
"Your profile setup isn't complete yet (GitHub proof not verified). Continue anyway?"
);
if (!proceed) {
return;
}
}
dismissOnboarding();
}
function resetOnboarding() {
setView("onboarding");
}
function chooseAccount(account: GitHubAccount) {
const profile = profileForHandle(account.login);
setSelectedIdentity(profile?.identity || account.matching_identity);
setProofIssue(null);
}
async function runChecks(identity = selectedIdentity) {
setIsChecking(true);
try {
const result = await invoke<DesktopCheck[]>("desktop_checks", {
profile: selection(identity)
});
setChecks(result);
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
setStatus("error");
} finally {
setIsChecking(false);
}
}
async function refreshProfilesAndOnboarding(preferredHandle?: string) {
setIsLoadingOnboarding(true);
setOnboardingError("");
try {
const [discoveredProfiles, onboardingState] = await Promise.all([
invoke<DesktopProfile[]>("desktop_profiles"),
invoke<OnboardingState>("desktop_onboarding")
]);
setProfiles(discoveredProfiles);
setOnboarding(onboardingState);
if (preferredHandle) {
const profile = discoveredProfiles.find((candidate) => candidate.handle === preferredHandle);
if (profile) {
setSelectedIdentity(profile.identity);
}
} else if (!selectedIdentity && discoveredProfiles[0]) {
setSelectedIdentity(discoveredProfiles[0].identity);
}
} catch (cause) {
setOnboardingError(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsLoadingOnboarding(false);
}
}
async function refreshDependencies() {
setIsCheckingDependencies(true);
setDependenciesError("");
try {
const dependencyState = await invoke<DesktopDependency[]>("desktop_dependencies");
setDependencies(dependencyState);
setSelectedDependencyName((current) => current || dependencyState[0]?.name || "");
} catch (cause) {
setDependenciesError(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsCheckingDependencies(false);
}
}
async function openProfileWindow() {
await invoke("open_profile_window", {
profile: selection()
});
}
async function openProfileWindowForHandle(handle: string) {
const profile = profiles.find((candidate) => candidate.handle === handle);
const profileSelection: ProfileSelection = {
identity: profile?.identity || `rho://id/github/${handle}`,
rho_home: profile?.rho_home || onboarding?.rho_home
};
await invoke("open_profile_window", {
profile: profileSelection
});
}
async function switchGhProfile() {
setIsSwitchingGh(true);
try {
const result = await invoke<CommandResult>("switch_github_profile", {
profile: selection()
});
setActionResult(formatCommandResult("rho gh switch", result));
await runChecks();
} catch (cause) {
setActionResult(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsSwitchingGh(false);
}
}
async function switchGhHandle(handle: string) {
setIsSwitchingGh(true);
try {
const result = await invoke<CommandResult>("switch_github_profile", {
profile: {
identity: `rho://id/github/${handle}`,
rho_home: onboarding?.rho_home || selectedProfile?.rho_home
}
});
setActionResult(formatCommandResult(`rho gh switch ${handle}`, result));
await runChecks();
await refreshProfilesAndOnboarding(handle);
} catch (cause) {
setActionResult(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsSwitchingGh(false);
}
}
async function createProfileForHandle(handle: string) {
const cleanHandle = handle.trim();
if (!cleanHandle) {
setOnboardingError("Enter a GitHub handle first.");
return;
}
setIsCreatingProfile(cleanHandle);
setOnboardingError("");
try {
const result = await invoke<CommandResult>("create_github_profile", {
input: {
handle: cleanHandle,
displayName: cleanHandle,
rhoHome: onboarding?.rho_home
}
});
setActionResult(formatCommandResult(`rho id init ${cleanHandle}`, result));
setManualHandle("");
await refreshProfilesAndOnboarding(cleanHandle);
} catch (cause) {
setOnboardingError(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsCreatingProfile("");
}
}
async function createMissingDetectedProfiles() {
const missing = onboarding?.gh_accounts.filter((account) => !account.has_rho_profile) ?? [];
if (missing.length === 0) {
setOnboardingError("");
setActionResult("All detected GitHub accounts already have Rho profiles.");
return;
}
setOnboardingError("");
const results: string[] = [];
for (const account of missing) {
setIsCreatingProfile(account.login);
try {
const result = await invoke<CommandResult>("create_github_profile", {
input: {
handle: account.login,
displayName: account.login,
rhoHome: onboarding?.rho_home
}
});
results.push(formatCommandResult(`rho id init ${account.login}`, result));
} catch (cause) {
results.push(`${account.login}: ${cause instanceof Error ? cause.message : String(cause)}`);
}
}
setIsCreatingProfile("");
setActionResult(results.join("\n\n"));
await refreshProfilesAndOnboarding();
}
async function verifyProfileForHandle(handle: string) {
console.log(`[verify] start handle=${handle} rhoHome=${onboarding?.rho_home ?? "default"}`);
setIsVerifyingProfile(handle);
setOnboardingError("");
try {
const result = await invoke<CommandResult>("verify_github_profile", {
handle,
rhoHome: onboarding?.rho_home
});
setActionResult(formatCommandResult(`rho id verify-github ${handle}`, result));
if (result.ok) {
console.log(`[verify] ${handle} -> VERIFIED`, result);
setProofIssue(null);
} else {
const text = `${result.stderr}\n${result.stdout}`.toLowerCase();
const kind =
text.includes("invalid claim") || text.includes("does not match this key")
? "invalid"
: "missing";
console.warn(`[verify] ${handle} -> ${kind.toUpperCase()}`, result);
setProofIssue({ handle, kind });
}
await refreshProfilesAndOnboarding(handle);
} catch (cause) {
console.error(`[verify] ${handle} -> ERROR`, cause);
setOnboardingError(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsVerifyingProfile("");
}
}
async function bindCommitAuthorForHandle(handle: string) {
setIsBindingCommitAuthor(handle);
setOnboardingError("");
try {
const result = await invoke<CommandResult>("bind_github_commit_profile", {
handle,
rhoHome: onboarding?.rho_home
});
setActionResult(formatCommandResult(`bind commit author ${handle}`, result));
await runChecks();
await refreshProfilesAndOnboarding(handle);
} catch (cause) {
setOnboardingError(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsBindingCommitAuthor("");
}
}
async function showProfileForHandle(handle: string) {
setOnboardingError("");
try {
const result = await invoke<CommandResult>("show_github_profile", {
handle,
rhoHome: onboarding?.rho_home
});
setActionResult(formatCommandResult(`rho id show ${handle}`, result));
} catch (cause) {
setOnboardingError(cause instanceof Error ? cause.message : String(cause));
}
}
async function checkProfileEnv() {
setIsCheckingEnv(true);
try {
const result = await invoke<CommandResult>("profile_rho_env", {
profile: selection()
});
setActionResult(formatCommandResult("rho env status", result));
} catch (cause) {
setActionResult(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsCheckingEnv(false);
}
}
async function loadProjects(identity = selectedIdentity) {
setIsLoadingProjects(true);
setProjectsError("");
try {
const result = await invoke<DesktopProject[]>("desktop_projects", {
profile: selection(identity)
});
setProjects(result);
if (selectedProjectPath && !result.some((project) => project.path === selectedProjectPath)) {
setSelectedProjectPath("");
setRepoDetail(null);
}
} catch (cause) {
setProjectsError(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsLoadingProjects(false);
}
}
async function addLocalProject() {
if (!localProjectPath.trim()) {
setProjectsError("Enter a local repository path first.");
return;
}
setIsAddingProject(true);
setProjectsError("");
try {
const project = await invoke<DesktopProject>("add_local_project", {
path: localProjectPath,
profile: selection()
});
setActionResult(`Added ${project.name}`);
setLocalProjectPath("");
await loadProjects();
await selectProject(project.path);
} catch (cause) {
setProjectsError(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsAddingProject(false);
}
}
async function cloneProject() {
if (!cloneUrl.trim()) {
setProjectsError("Enter a GitHub repository URL first.");
return;
}
setIsCloningProject(true);
setProjectsError("");
try {
const project = await invoke<DesktopProject>("clone_github_project", {
url: cloneUrl,
destination: cloneDestination || undefined,
profile: selection()
});
setActionResult(`Cloned ${project.name}`);
setCloneUrl("");
setCloneDestination("");
await loadProjects();
await selectProject(project.path);
} catch (cause) {
setProjectsError(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsCloningProject(false);
}
}
async function setupRhoRepo() {
if (!setupRepoPath.trim()) {
setProjectsError("Enter a repository folder path first.");
return;
}
if (!selectedIdentity) {
setProjectsError("Select a Rho profile before setting up a repo.");
return;
}
setIsSettingUpRhoRepo(true);
setProjectsError("");
try {
const result = await invoke<CommandResult>("setup_rho_repo", {
input: {
path: setupRepoPath,
repoId: setupRepoId || undefined,
remoteUrl: setupRemoteUrl || undefined,
profile: selection(),
protectOwnerInbox: setupProtectOwnerInbox
}
});
setActionResult(formatCommandResult("setup rho repo", result));
setSetupRepoPath("");
setSetupRepoId("");
setSetupRemoteUrl("");
await loadProjects();
} catch (cause) {
setProjectsError(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsSettingUpRhoRepo(false);
}
}
async function selectProject(path: string) {
setSelectedProjectPath(path);
setRepoDetailError("");
setIsLoadingRepoDetail(true);
try {
const detail = await invoke<RepoDetail>("repo_detail", {
path,
profile: selection()
});
setRepoDetail(detail);
} catch (cause) {
setRepoDetail(null);
setRepoDetailError(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsLoadingRepoDetail(false);
}
}
async function openExternalUrl(url: string) {
try {
await invoke("open_external_url", { url });
} catch (cause) {
setActionResult(cause instanceof Error ? cause.message : String(cause));
}
}
async function copyValue(value: string, field: string) {
try {
await navigator.clipboard.writeText(value);
setCopiedField(field);
window.setTimeout(() => setCopiedField((current) => (current === field ? "" : current)), 1500);
} catch (cause) {
setActionResult(cause instanceof Error ? cause.message : String(cause));
}
}
function switchToProfile(identity: string) {
// Full reload bound to the chosen profile (via ?identity=) so there is no
// leftover in-memory state from the previous profile.
const url = new URL(window.location.href);
url.searchParams.set("identity", identity);
window.location.replace(url.toString());
}
function reonboard() {
setShowProfileSwitcher(false);
setView("onboarding");
// Skip the tool check when the dependencies are already satisfied.
setOnboardingStep(allDependenciesReady ? "accounts" : "tools");
}
function toggleTheme() {
setTheme((current) => (current === "dark" ? "light" : "dark"));
}
function removeProfile(identity: string) {
if (
!window.confirm(
"Remove this profile from Rho Desktop? Your keys and identity files are kept on disk — this only hides it here."
)
) {
return;
}
setHiddenProfiles((current) => {
const next = current.includes(identity) ? current : [...current, identity];
window.localStorage.setItem("rho.desktop.hiddenProfiles", JSON.stringify(next));
return next;
});
}
function openProjects() {
setShowProfileSwitcher(false);
setView("projects");
}
function openSettings() {
setShowProfileSwitcher(false);
setView("settings");
}
async function openProfileProcess(identity: string) {
try {
await invoke("open_profile_process", { identity });
} catch (cause) {
setActionResult(cause instanceof Error ? cause.message : String(cause));
}
}
function isAccountSetUp(account: GitHubAccount): boolean {
return Boolean(
account.has_rho_profile && account.has_local_private_keys && account.proof_verified
);
}
async function captureScreenshotToClipboard(): Promise<boolean> {
try {
const { default: html2canvas } = await import("html2canvas-pro");
const canvas = await html2canvas(document.body, {
backgroundColor: null,
logging: false,
scale: Math.min(window.devicePixelRatio || 1, 2)
});
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob((value) => resolve(value), "image/png")
);
if (!blob || typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) {
return false;
}
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
return true;
} catch {
return false;
}
}
async function reportBug() {
const captured = await captureScreenshotToClipboard();
const recent = (actionResult || error || "none").slice(0, 800);
const body = [
"## What happened?",
"",
"## Steps to reproduce",
"1. ",
"",
"---",
captured
? "<!-- A screenshot was copied to your clipboard — paste it here with Cmd/Ctrl+V -->"
: "<!-- Tip: attach a screenshot here -->",
"",
"### Context",
`- Rho Desktop: ${version || "unknown"}`,
`- Screen: ${onboardingStep}`,
`- Profile: ${selectedIdentity || "none"}`,
`- Platform: ${navigator.userAgent}`,
"",
"### Last output",
"```",
recent,
"```"
].join("\n");
const url =
"https://github.com/madhavajay/rho/issues/new" +
`?title=${encodeURIComponent("[Desktop] ")}` +
`&body=${encodeURIComponent(body)}`;
setActionResult(
captured
? "Screenshot copied to clipboard — paste it into the issue with Cmd/Ctrl+V."
: "Opening bug report (screenshot capture unavailable)."
);
void openExternalUrl(url);
}
async function signInWithGitHub() {
setIsOpeningTerminal(true);
setOnboardingError("");
try {
await invoke("open_terminal_command", { command: "gh auth login" });
} catch (cause) {
setOnboardingError(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsOpeningTerminal(false);
}
}
async function openProjectFolder(path: string) {
try {
const result = await invoke<CommandResult>("open_project_folder", { path });
if (!result.ok) {
setActionResult(formatCommandResult("open folder", result));
}
} catch (cause) {
setActionResult(cause instanceof Error ? cause.message : String(cause));
}
}
async function runRepoAction(
action: string,
overrides: { target?: string; requestId?: string } = {}
) {
if (!repoDetail) {
return;
}
setIsRunningRepoAction(action);
try {
const result = await invoke<CommandResult>("run_repo_cli_action", {
input: {
path: repoDetail.project.path,
profile: selection(),
action,
target: (overrides.target ?? repoActionTargetHandle) || undefined,
requestId: (overrides.requestId ?? repoActionRequestId) || undefined
}
});
setActionResult(formatCommandResult(action.replace(/_/g, " "), result));
await loadProjects();
await selectProject(repoDetail.project.path);
} catch (cause) {
setActionResult(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsRunningRepoAction("");
}
}
async function commitRepoChanges() {
if (!repoDetail) {
return;
}
if (!commitMessage.trim()) {
setActionResult("Enter a commit message first.");
return;
}
setIsCommittingRepo(true);
try {
const result = await invoke<CommandResult>("commit_repo_changes", {
input: {
path: repoDetail.project.path,
profile: selection(),
message: commitMessage
}
});
setActionResult(formatCommandResult("rho commit", result));
setCommitMessage("");
await loadProjects();
await selectProject(repoDetail.project.path);
} catch (cause) {
setActionResult(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsCommittingRepo(false);
}
}
async function sendProjectMessage() {
if (!repoDetail) {
return;
}
if (!messageRecipient.trim()) {
setActionResult("Enter a recipient GitHub handle first.");
return;
}
if (!messageText.trim()) {
setActionResult("Enter a message first.");
return;
}
setIsSendingMessage(true);
try {
const result = await invoke<CommandResult>("send_project_message", {
input: {
path: repoDetail.project.path,
profile: selection(),
to: messageRecipient,
thread: messageThread || undefined,
text: messageText,
messageType: "chat"
}
});
setActionResult(formatCommandResult("send message", result));
setMessageThread("");
setMessageText("");
await loadProjects();
await selectProject(repoDetail.project.path);
} catch (cause) {
setActionResult(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsSendingMessage(false);
}
}
async function runGitAction(action: string, branch?: string) {
if (!repoDetail) {
return;
}
setIsRunningGitAction(action);
try {
const result = await invoke<CommandResult>("run_git_repo_action", {
input: {
path: repoDetail.project.path,
profile: selection(),
action,
branch: (branch ?? branchName) || undefined
}
});
setActionResult(formatCommandResult(action.replace(/_/g, " "), result));
await loadProjects();
await selectProject(repoDetail.project.path);
} catch (cause) {
setActionResult(cause instanceof Error ? cause.message : String(cause));
} finally {
setIsRunningGitAction("");
}
}
function formatCommandResult(label: string, result: CommandResult): string {
const body = [result.stdout, result.stderr].filter(Boolean).join("\n");
const statusText = result.ok ? "ok" : `exit ${result.code ?? "unknown"}`;
return `${label}: ${statusText}${body ? `\n${body}` : ""}`;
}
function shortPath(path: string): string {
return path.length > 70 ? `...${path.slice(-67)}` : path;
}
function profileForHandle(handle: string): DesktopProfile | undefined {
return profiles.find((profile) => profile.handle === handle);
}
function requestIdFromPath(path: string): string {
return path.split("/").find((part) => part.startsWith("req-")) || "";
}
function keysFolder(account: GitHubAccount): string | undefined {
const file = account.signing_key_path || account.encryption_key_path;
if (!file) {
return undefined;
}
const idx = Math.max(file.lastIndexOf("/"), file.lastIndexOf("\\"));
return idx > 0 ? file.slice(0, idx) : undefined;
}
const settingsContent = (
<>
<header className="window-titlebar">
<div>
<h1>Settings</h1>
<p>Rho Desktop {version}</p>
</div>
</header>
<div className="settings-body">
<div className="empty-state">
<h2>No settings yet</h2>
<p>App settings will live here. For now, manage profiles from the avatar in the sidebar.</p>
</div>
</div>
</>
);
const projectsContent = (
<>
<header className="window-titlebar">
<div>
<h1>Projects</h1>
<p>Repositories tracked for this profile.</p>
</div>
</header>
<div className="settings-body">
<div className="empty-state">
<h2>No projects yet</h2>
<p>Project management is coming soon.</p>
</div>
</div>
</>
);
const profileSwitcherOverlay = showProfileSwitcher ? (
<div className="modal-backdrop" onClick={() => setShowProfileSwitcher(false)}>
<div className="modal-panel" onClick={(event) => event.stopPropagation()}>
<div className="modal-head">
<h2>Profiles</h2>
<button type="button" onClick={() => setShowProfileSwitcher(false)}>
Close
</button>
</div>
<div className="switcher-list">
{profiles
.filter((profile) => !hiddenProfiles.includes(profile.identity))
.map((profile) => (
<div
key={profile.identity}
className="switcher-row"
data-current={profile.identity === selectedIdentity}
>
<Avatar login={profile.handle} size={38} />
<div className="switcher-info">
<strong>{profile.handle}</strong>
<span>{profile.identity}</span>
</div>
{profile.identity === selectedIdentity ? (
<span className="switcher-current">current</span>
) : null}
<div className="switcher-actions">
<button
type="button"
onClick={() => switchToProfile(profile.identity)}
disabled={profile.identity === selectedIdentity}
>
Switch
</button>
<button type="button" onClick={() => void openProfileProcess(profile.identity)}>
New Window
</button>
<button
type="button"
onClick={() => removeProfile(profile.identity)}
disabled={profile.identity === selectedIdentity}
>
Remove
</button>
</div>
</div>
))}
{profiles.filter((profile) => !hiddenProfiles.includes(profile.identity)).length === 0 ? (
<p className="guide-muted">No local profiles yet.</p>
) : null}
</div>
<div className="modal-foot">
<button type="button" onClick={reonboard}>
Set up another profile
</button>
</div>
</div>
</div>
) : null;
return (
<main className="app-shell desktop-wallpaper">
{profileSwitcherOverlay}
<section className="desktop-window">
<AppRail
active={shouldShowOnboarding ? "setup" : view}
onReportBug={reportBug}
onOpenSettings={openSettings}
onOpenProfiles={() => setShowProfileSwitcher(true)}
onOpenProjects={openProjects}
onToggleTheme={toggleTheme}
theme={theme}
currentLogin={selectedHandle}
/>
<section className="onboarding-workspace">
{status !== "ready" ? (
<header className="window-titlebar">
<div>
<h1>Rho Desktop</h1>
<p>{status === "error" ? error : "Loading desktop state."}</p>
</div>
</header>
) : !shouldShowOnboarding ? (
view === "settings" ? (
settingsContent
) : (
projectsContent
)
) : (
<>
<header className="window-titlebar">
<div>
<h1>
{onboardingStep === "tools"
? "Set up Rho Desktop"
: onboardingStep === "accounts"
? "Choose a GitHub account"
: "Prepare your profile"}
</h1>
<p>
{onboardingStep === "tools"
? "Step 1: check required command line tools before creating or opening a profile."
: onboardingStep === "accounts"
? "Step 2: pick a detected GitHub account, or sign in with another."
: "Step 3: finish setting up the selected account."}
</p>
</div>
</header>
{onboardingStep === "tools" ? (
<div className="dependency-screen">
{dependenciesError ? <p className="error">{dependenciesError}</p> : null}
<section className="dependency-panel">
<div className="dependency-list" aria-label="Dependencies">
{visibleDependencies.map((dependency) => (
<button
key={dependency.name}
type="button"
className="dependency-item"
data-active={selectedDependency?.name === dependency.name}
data-state={dependency.status}
onClick={() => setSelectedDependencyName(dependency.name)}
>
<span className="dependency-dot" aria-hidden="true" />
<span className="dependency-item-name">{dependencyTitle(dependency.name)}</span>
<span className="dependency-item-status">{statusLabel(dependency.status)}</span>
</button>
))}
{visibleDependencies.length === 0 ? (
<div className="empty-state">
<h2>No dependencies loaded</h2>
<p>Click Check Again to reload the dependency manifest.</p>
</div>
) : null}
</div>
<div className="dependency-detail">
{selectedDependency ? (
<>
<div className="dependency-detail-heading">
<div>
<h2>{dependencyTitle(selectedDependency.name)}</h2>
<p>{selectedDependency.description}</p>
</div>
<span data-state={selectedDependency.status}>
{statusLabel(selectedDependency.status)}
</span>
</div>
<dl className="dependency-facts">
<div>
<dt>Path</dt>
<dd>{selectedDependency.path || "Not found"}</dd>
</div>
<div>
<dt>Version</dt>
<dd>{selectedDependency.version || selectedDependency.detail}</dd>
</div>
{selectedDependency.website ? (
<div>
<dt>Website</dt>
<dd>
<a
href={selectedDependency.website}
className="dependency-link"
onClick={(event) => {
event.preventDefault();
void openExternalUrl(selectedDependency.website!);
}}
>
{selectedDependency.website}
</a>
</dd>
</div>
) : null}
</dl>
<div className="install-command-list">
<h2>Install Options</h2>
{selectedDependency.install_commands.map((command) => (
<code key={command}>{command}</code>
))}
{selectedDependency.install_commands.length === 0 ? (
<p>No install command is configured for this platform.</p>
) : null}
</div>
</>
) : (
<div className="empty-state">
<h2>Select a dependency</h2>
<p>Dependency details will appear here.</p>
</div>
)}
</div>
</section>
<div className="stage-actions split-actions">
<button
type="button"
onClick={() => void refreshDependencies()}
disabled={isCheckingDependencies}
>
{isCheckingDependencies ? "Checking" : "Check Again"}
</button>
{!allDependenciesReady ? (
<button
type="button"
onClick={() => {
setToolsSkipped(true);
setOnboardingStep("accounts");
}}
>
Skip
</button>
) : null}
<button
type="button"
className="primary-action"
disabled={!allDependenciesReady}
onClick={() => {
setToolsSkipped(false);
setOnboardingStep("accounts");
}}
>
Continue
</button>
</div>
</div>
) : onboardingStep === "accounts" ? (
<div className="dependency-screen">
<div className="accounts-screen">
{toolsSkipped ? (
<p className="error">
You skipped the tool check. If account actions fail, go back and confirm Git and
the GitHub CLI are installed.
</p>
) : null}
{onboardingError ? <p className="error">{onboardingError}</p> : null}
{onboarding && onboarding.gh_accounts.length > 0 ? (
(() => {
const setUp = onboarding.gh_accounts.filter(isAccountSetUp);
const available = onboarding.gh_accounts.filter(
(account) => !isAccountSetUp(account)
);
return (
<>
{setUp.length > 0 ? (
<>
<div className="accounts-section-label">Already set up</div>
<div className="account-pick-list">
{setUp.map((account) => (
<div
key={`${account.host}-${account.login}`}
className="account-pick account-pick-static"
data-state="ready"
>
<Avatar login={account.login} size={40} />
<span className="account-pick-info">
<strong>{account.login}</strong>
<span>Rho profile ready{account.active ? " · active in gh" : ""}</span>
</span>
<span className="account-pick-status">ready</span>
</div>
))}
</div>
<div className="accounts-divider" />
</>
) : null}
<div className="accounts-section-label">Choose a GitHub account</div>
{available.length > 0 ? (
<div className="account-pick-list">
{available.map((account) => (
<button
key={`${account.host}-${account.login}`}
type="button"
className="account-pick"
data-state={isAccountReady(account) ? "ready" : "setup"}
onClick={() => {
chooseAccount(account);
setOnboardingStep("profile");
}}
>
<Avatar login={account.login} size={40} />
<span className="account-pick-info">
<strong>{account.login}</strong>
<span>
{account.has_rho_profile
? "Rho profile ready"
: "No Rho profile yet"}
{account.active ? " · active in gh" : ""}
</span>
</span>
<span className="account-pick-status">
{isAccountReady(account) ? "ready" : "needs setup"}
</span>
<span className="account-pick-chevron" aria-hidden="true">
›
</span>
</button>
))}
</div>
) : (
<div className="accounts-empty-available">
<p>All detected accounts are already set up.</p>
<p>To add another, run:</p>
<div className="copy-field">
<code>gh auth login</code>
<button
type="button"
className="copy-btn"
title="Copy command"
onClick={() => void copyValue("gh auth login", "gh-login")}
>
<ClipboardIcon copied={copiedField === "gh-login"} />
</button>
</div>
<p>
Then come back here and press Refresh once you have authed a new
account.
</p>
</div>
)}
<button
type="button"
className="account-add"
onClick={() => void signInWithGitHub()}
disabled={isOpeningTerminal}
>
{isOpeningTerminal ? "Opening Terminal…" : "Run: gh auth login"}
</button>
</>
);
})()
) : (
<div className="empty-state">
<h2>No GitHub accounts found</h2>
<p>
Sign in with the GitHub CLI to create or log in to an account, then refresh.
</p>
<div className="empty-actions">
<button
type="button"
className="primary-action"
onClick={() => void signInWithGitHub()}
disabled={isOpeningTerminal}
>
{isOpeningTerminal ? "Opening Terminal…" : "Sign in with GitHub"}
</button>
</div>
</div>
)}
</div>
<div className="stage-actions split-actions">
{!allDependenciesReady ? (
<button
type="button"
style={{ marginRight: "auto" }}
onClick={() => setOnboardingStep("tools")}
>
Back
</button>
) : null}
<button
type="button"
onClick={() => void refreshProfilesAndOnboarding()}
disabled={isLoadingOnboarding}
>
{isLoadingOnboarding ? "Refreshing" : "Refresh"}
</button>
</div>
</div>
) : (
<div className="dependency-screen">
<div className="accounts-screen">
{onboardingError ? <p className="error">{onboardingError}</p> : null}
{selectedAccount ? (
<div className="profile-prepare">
<div className="profile-prepare-head">
<Avatar login={selectedAccount.login} size={44} />
<div className="profile-prepare-id">
<strong>{selectedAccount.login}</strong>
<span>{selectedAccount.matching_identity}</span>
</div>
</div>
{!selectedAccount.has_rho_profile ? (
<div className="guide-callout">
<p>Create your Rho profile to generate keys and your GitHub proof.</p>
<button
type="button"
className="primary-action"
onClick={() => void createProfileForHandle(selectedAccount.login)}
disabled={isCreatingProfile === selectedAccount.login}
>
{isCreatingProfile === selectedAccount.login
? "Creating…"
: "Create Rho Profile"}
</button>
</div>
) : null}
<ol className="guide-steps">
<li className="guide-step" data-ok={true}>
<span className="guide-step-num">✓</span>
<div className="guide-step-body">
<h3>Your Rho identity</h3>
<p>This is globally unique, derived from your GitHub handle.</p>
<div className="copy-field">
<code>{selectedAccount.matching_identity}</code>
<button
type="button"
className="copy-btn"
title="Copy identity"
onClick={() =>
void copyValue(selectedAccount.matching_identity, "identity")
}
>
<ClipboardIcon copied={copiedField === "identity"} />
</button>
</div>
</div>
</li>
<li className="guide-step" data-ok={selectedAccount.has_local_private_keys}>
<span className="guide-step-num">
{selectedAccount.has_local_private_keys ? "✓" : "2"}
</span>
<div className="guide-step-body">
<h3>Private keys</h3>
<p>
We generated your signing (Ed25519) and encryption (X25519) keys. They
stay on this machine and are never shared.
</p>
{keysFolder(selectedAccount) ? (
<button
type="button"
className="guide-folder"
onClick={() => void openProjectFolder(keysFolder(selectedAccount)!)}
>
<FolderIcon />
<span>{keysFolder(selectedAccount)}</span>
</button>
) : (
<p className="guide-muted">
Keys appear here once your Rho profile is created.
</p>
)}
</div>
</li>
<li
className="guide-step"
data-ok={selectedAccount.proof_verified}
data-missing={
!selectedAccount.proof_verified &&
proofIssue?.handle === selectedAccount.login
}
>
<span className="guide-step-num">
{selectedAccount.proof_verified
? "✓"
: !selectedAccount.proof_verified &&
proofIssue?.handle === selectedAccount.login
? "✕"
: "3"}
</span>
<div className="guide-step-body">
<h3>
GitHub proof
{selectedAccount.proof_verified ? (
<span className="guide-ok-pill">verified</span>
) : proofIssue?.handle === selectedAccount.login ? (
<span className="guide-missing-pill">
{proofIssue.kind === "invalid" ? "invalid" : "missing"}
</span>
) : null}
</h3>
<p>
To prove you hold this private key, publish this proof on your public
GitHub profile — paste it into your bio, website, or social links.
Anywhere on the page works. Then click Re-verify.
</p>
{selectedAccount.proof_url ? (
<div className="copy-field">
<code>{selectedAccount.proof_url}</code>
<button
type="button"
className="copy-btn"
title="Copy proof"
onClick={() => void copyValue(selectedAccount.proof_url!, "proof")}
>
<ClipboardIcon copied={copiedField === "proof"} />
</button>
</div>
) : (
<p className="guide-muted">
Your proof appears once your Rho profile is created.
</p>
)}
<div className="guide-actions">
<button
type="button"
onClick={() =>
void openExternalUrl(`https://github.com/${selectedAccount.login}`)
}
>
Open GitHub Profile
</button>
<button
type="button"
className="primary-action verify-button"
data-loading={isVerifyingProfile === selectedAccount.login}
onClick={() => void verifyProfileForHandle(selectedAccount.login)}
disabled={
!selectedAccount.has_rho_profile ||
isVerifyingProfile === selectedAccount.login
}
>
{isVerifyingProfile === selectedAccount.login ? (
<span className="btn-spinner" aria-label="Checking" />
) : (
"Re-verify"
)}
</button>
</div>
</div>
</li>
</ol>
</div>
) : (
<div className="empty-state">
<h2>No account selected</h2>
<p>Go back and pick a GitHub account.</p>
</div>
)}
</div>
<div className="stage-actions split-actions">
<button
type="button"
style={{ marginRight: "auto" }}
onClick={() => setOnboardingStep("accounts")}
>
Back
</button>
<button type="button" className="primary-action" onClick={finishOnboarding}>
Finish
</button>
</div>
</div>
)}
</>
)}
</section>
</section>
</main>
);
}