+
+
-
-
-
+
);
}
- const updateTile = update.error !== undefined || state?.status === "failed" || state?.status === "available" ? (
-
- ) : null;
-
return (
-
- {/* Keep inline-size containment off the auto-fit grid to avoid collapsed columns in WebKit. */}
-
-
-
-
-
+
}
+ >
+ {agents.status === "loading" &&
Загрузка…}
+ {agents.status === "error" &&
Не удалось загрузить состояние агентов: {agents.message}}
+ {agents.status === "ready" &&
}
+
);
}
diff --git a/crates/wizard-gui/wizard-ui/src/AppSidebar.tsx b/crates/wizard-gui/wizard-ui/src/AppSidebar.tsx
new file mode 100644
index 0000000..96e5949
--- /dev/null
+++ b/crates/wizard-gui/wizard-ui/src/AppSidebar.tsx
@@ -0,0 +1,76 @@
+import type { ComponentType, ComponentPropsWithoutRef } from "react";
+import { useClose } from "@headlessui/react";
+import type { AgentId, AgentStates } from "./agents/agents_state";
+import type { useProxy } from "./agents/proxy";
+import { AppLogo } from "./components/AppLogo";
+import { ClaudeIcon, OpenAiIcon, OpenCodeIcon, PiIcon } from "./components/BrandIcons";
+import { Badge } from "./components/catalyst/badge";
+import { Sidebar, SidebarBody, SidebarFooter, SidebarHeader, SidebarHeading, SidebarItem, SidebarLabel, SidebarSection, SidebarSpacer } from "./components/catalyst/sidebar";
+import { OpenLogsItem } from "./logs/OpenLogsButton";
+import { type HubAccountControl, HubAccountMenu } from "./settings/HubAccount";
+import type { UpdateObservation } from "./updates/update";
+import { UpdateItem } from "./updates/UpdateItem";
+
+// Short routing state of an agent, if it is worth showing next to its name.
+function AgentBadge({ agents, id, proxy }: { agents: AgentStates; id: AgentId; proxy: ReturnType
}) {
+ const agent = agents[id];
+ if (agent.status === "error") return Ошибка;
+ if (agent.status === "not_found") return null;
+
+ const mode = agent.data.routing_mode;
+ const chatGptProxyStopped = agents.codex.status === "found" && agents.codex.data.desktop.status === "found"
+ && proxy.proxy?.chatgpt === false;
+ if (mode === "disabled") return null;
+ if (id === "codex" && chatGptProxyStopped) return Прокси;
+ return {mode === "sub" ? "Подписка" : "Hub"};
+}
+
+export function AppSidebar({ page, onSelect, agents, proxy, account, update }: {
+ page: AgentId;
+ onSelect: (page: AgentId) => void;
+ agents: AgentStates | undefined;
+ proxy: ReturnType;
+ account: HubAccountControl;
+ update: UpdateObservation;
+}) {
+ // Closes the sidebar dialog on narrow windows; does nothing on the desktop layout.
+ const close = useClose();
+
+ function item(id: AgentId, label: string, Icon: ComponentType>) {
+ return (
+ { onSelect(id); close(); }}>
+
+ {label}
+ {agents && }
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ Агенты
+ {item("claude_desktop", "Claude Desktop", ClaudeIcon)}
+ {item("claude", "Claude Code", ClaudeIcon)}
+ {item("codex", "Codex", OpenAiIcon)}
+ {item("opencode", "OpenCode", OpenCodeIcon)}
+ {item("pi", "Pi", PiIcon)}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/crates/wizard-gui/wizard-ui/src/agents/AgentList.tsx b/crates/wizard-gui/wizard-ui/src/agents/AgentList.tsx
deleted file mode 100644
index abe4400..0000000
--- a/crates/wizard-gui/wizard-ui/src/agents/AgentList.tsx
+++ /dev/null
@@ -1,376 +0,0 @@
-import { type ReactNode, useEffect, useState } from "react";
-import { type AgentChange, type AgentDetection, type AgentId, type AgentStates, type BackupAgent, type HubMode, type PreparedAgentChange, type RoutingMode, type TokenChoice, prepareAgentChange } from "./agents_state";
-import { PreparedPlanDialog } from "./PreparedPlanDialog";
-import { BackupDialog } from "./BackupDialog";
-import { ClaudeDesktopInstall, useClaudeDesktopInstall } from "./ClaudeDesktopInstall";
-import { type HubTokensState, tokenLabel, tokenPrefix, useHubTokens } from "../settings/hub_tokens";
-import { useHubAccount } from "../settings/hub_account";
-import { type Proxy, useProxy } from "./proxy";
-import { reportError } from "../logs/log";
-import { Button } from "../components/catalyst/button";
-import { Badge } from "../components/catalyst/badge";
-import { Heading } from "../components/catalyst/heading";
-import { Label } from "../components/catalyst/fieldset";
-import { Listbox, ListboxLabel, ListboxOption } from "../components/catalyst/listbox";
-import { Radio, RadioField, RadioGroup } from "../components/catalyst/radio";
-import { Text } from "../components/catalyst/text";
-import { ErrorText } from "../components/ErrorText";
-
-type ModeOption = { mode: M; label: string; detail?: string };
-
-function routingModes(subscription: string): ModeOption[] {
- return [
- { mode: "disabled", label: "Без CoreInfra" },
- { mode: "hub", label: "CoreInfra Hub" },
- { mode: "sub", label: "CoreInfra Hub", detail: ` — через вашу подписку ${subscription}` },
- ];
-}
-
-const hubModes: ModeOption[] = [
- { mode: "disabled", label: "Без CoreInfra" },
- { mode: "hub", label: "CoreInfra Hub" },
-];
-
-// Hub access shared by all tiles. Token values are secrets: never log them.
-type Hub = HubTokensState & { signedIn: boolean };
-
-// Names of Hub tokens created for agents, used for the default choice and the create option.
-// Keep in sync with `TokenAgent::token_name` in `crates/wizard-core/src/agents/mod.rs`.
-const createdTokenNames: Record = {
- codex: "agent-codex",
- claude: "agent-claude-code",
- claude_desktop: "agent-claude-desktop",
- opencode: "agent-opencode",
- pi: "agent-pi",
-};
-
-// `token: null` is the option that creates the agent's Hub token.
-type TokenOption = { key: string; label: string; token: string | null };
-
-// Everything the token menu shows. Choosing an option only changes the local draft: creating
-// happens on an explicit apply/check/download action in the backend.
-// Selected: the draft, else the agent's token, else the oldest Hub token with the created name,
-// else creating. Creating is offered only when the loaded list has no such token: an unknown list
-// must not suggest a duplicate. Once a created token is listed, it replaces a create draft
-// (not the agent's other token), so a retry after a failed preparation does not create another.
-function useTokenMenu(agent: AgentId, agentToken: string | null, hub: Hub) {
- const [draft, setDraft] = useState();
- const createdName = createdTokenNames[agent];
-
- const options: TokenOption[] = [];
- // The agent's own token comes first, even when Hub does not list it (another hub, signed out).
- if (agentToken !== null) {
- options.push({ key: agentToken, label: `${tokenLabel(agentToken, hub.tokens)} (Текущий)`, token: agentToken });
- }
- // Other Hub tokens, without the current one and tokens Wizard created for other agents;
- // this agent's created tokens come first.
- const otherNames = Object.values(createdTokenNames).filter((name) => name !== createdName);
- const visible = hub.tokens
- .filter((token) => token.token !== agentToken && !otherNames.includes(token.name))
- .sort((a, b) => Number(b.name === createdName) - Number(a.name === createdName));
- for (const token of visible) {
- options.push({ key: token.token, label: token.name || tokenPrefix(token.token), token: token.token });
- }
- const listed = (token: string | null | undefined): token is string =>
- options.some((option) => option.token !== null && option.token === token);
-
- const named = hub.tokens
- .filter((token) => token.name === createdName)
- .sort((a, b) => a.issued_at - b.issued_at)[0]?.token;
- const canCreate = hub.signedIn && hub.status === "ready" && named === undefined;
- if (canCreate) {
- options.splice(1, 0, { key: "create", label: `Создать ${createdName}`, token: null });
- }
-
- let selected: TokenChoice | undefined;
- if (draft?.kind === "create") {
- selected = named === undefined ? draft : { kind: "existing", token: named };
- } else {
- const token = [draft?.token, agentToken, named].find(listed);
- selected = token !== undefined ? { kind: "existing", token } : canCreate ? { kind: "create" } : undefined;
- }
- const ready = hub.signedIn && hub.status === "ready" && selected !== undefined
- && (selected.kind === "create" ? canCreate : listed(selected.token));
- const selectedOption = options.find((option) =>
- selected?.kind === "create" ? option.token === null : option.token === selected?.token);
-
- // An applied change replaces the agent token; show it instead of the old draft.
- useEffect(() => setDraft(undefined), [agentToken]);
-
- function choose(option: TokenOption) {
- setDraft(option.token === null ? { kind: "create" } : { kind: "existing", token: option.token });
- }
-
- return { options, selected, selectedOption, choose, ready };
-}
-
-function TokenSelect({ options, value, disabled, onChange }: {
- options: TokenOption[];
- value: TokenOption | undefined;
- disabled: boolean;
- onChange: (option: TokenOption) => void;
-}) {
- // The «Токен ·» prefix names the field on the closed menu only: options render in a portal
- // outside the button.
- return (
-
- {options.map((option) => (
-
-
- Токен ·
- {option.label}
-
-
- ))}
-
- );
-}
-
-// Mode and token of one agent. The saved mode wins until the user picks another one.
-function RoutingSelector({ agent, modes, mode, toChange, agentToken, token: tokenOverride, hub, badges, disabled, onPrepare }: {
- agent: AgentId;
- token?: ReturnType;
- modes: ModeOption[];
- mode: M;
- toChange: (mode: M) => AgentChange;
- agentToken: string | null;
- hub: Hub;
- badges?: ReactNode;
- disabled: boolean;
- onPrepare: (change: AgentChange, token: TokenChoice | null) => void;
-}) {
- const [draft, setDraft] = useState();
- const selected = draft ?? mode;
- const enabled = selected !== "disabled";
- const localToken = useTokenMenu(agent, agentToken, hub);
- const token = tokenOverride ?? localToken;
-
- useEffect(() => {
- if (draft === mode) setDraft(undefined);
- }, [draft, mode]);
-
- return (
- <>
-
-
- {modes.find((option) => option.mode === mode)?.label}
- {mode !== "disabled" && " · активно"}
- {mode !== "disabled" && agentToken !== null && ` · ${tokenLabel(agentToken, hub.tokens)}`}
-
- {badges}
-
- setDraft(value as M)}>
- {modes.map((option) => (
-
-
-
-
- ))}
-
- {(enabled || tokenOverride !== undefined) && (
-
-
- {hub.status === "error" && {hub.error}}
-
- )}
-
- >
- );
-}
-
-function Installation({ name, detection, children, actions, tokenSelect, alwaysShowChildren = false }: {
- name: string;
- detection: AgentDetection<{ path: string; version: string | null }>;
- children?: ReactNode;
- actions?: ReactNode;
- tokenSelect?: ReactNode;
- alwaysShowChildren?: boolean;
-}) {
- return (
-
-
-
- {name}
- {detection.status !== "error" && (
-
- {detection.status === "not_found" ? "Не найден"
- : detection.data.version ? `v${detection.data.version}` : "Версия неизвестна"}
-
- )}
-
- {actions &&
{actions}
}
-
- {detection.status !== "found" && tokenSelect}
- {(detection.status === "found" || alwaysShowChildren) && (
- <>
- {children && {children}
}
-{/*
-
- Сведения об установке
-
- Путь:
- {detection.data.path}
-
-
-
-*/}
- >
- )}
- {detection.status === "error" && (
- Не удалось обнаружить {name}: {detection.data}
- )}
-
- );
-}
-
-function InstallationStatus({ name, detection, children }: {
- name: string;
- detection?: AgentDetection<{ path: string; version: string | null }>;
- children?: ReactNode;
-}) {
- return (
-
-
- {name}
-
- {detection?.status === "found"
- ? detection.data.version ? `v${detection.data.version}` : "Версия неизвестна"
- : detection?.status === "not_found" ? "Не найден" : "Статус неизвестен"}
-
- {children}
-
- {detection?.status === "error" &&
{detection.data}}
-
- );
-}
-
-function BackupButton({ agent, disabled }: { agent: BackupAgent; disabled: boolean }) {
- const [open, setOpen] = useState(false);
-
- return (
- <>
-
- {open && setOpen(false)} />}
- >
- );
-}
-
-// State of the app's listener only; each tile decides what it means for its app.
-function ProxyStatus({ proxy: { proxy, error }, app }: { proxy: ReturnType; app: keyof Proxy }) {
- if (error !== undefined) return Прокси: состояние недоступно;
- if (proxy === undefined) return Прокси: загрузка…;
-
- const running = proxy[app];
- return (
-
- {running ? "Прокси работает" : "Прокси остановлен"}
-
- );
-}
-
-export function AgentList({ agents }: { agents: AgentStates }) {
- const [preparedChange, setPreparedChange] = useState();
- const [isPreparingPlan, setIsPreparingPlan] = useState(false);
- const [error, setError] = useState();
- const isChangeInProgress = isPreparingPlan || preparedChange !== undefined;
- const { account } = useHubAccount();
- const hub: Hub = { ...useHubTokens(), signedIn: account?.signed_in === true };
- const proxy = useProxy();
- const claudeDesktopToken = useTokenMenu("claude_desktop", agents.claude_desktop.status === "found" ? agents.claude_desktop.data.token : null, hub);
- const claudeInstall = useClaudeDesktopInstall();
- const codex = agents.codex;
- const chatGptProxyStopped = proxy.proxy?.chatgpt === false && codex.status === "found"
- && codex.data.desktop.status === "found" && codex.data.routing_mode !== "disabled";
-
- async function prepare(change: AgentChange, token: TokenChoice | null) {
- if (isChangeInProgress) return;
-
- setIsPreparingPlan(true);
- setError(undefined);
-
- try {
- setPreparedChange(await prepareAgentChange(change, token));
- } catch (cause: unknown) {
- const message = "Не удалось подготовить изменения";
- reportError(message, cause);
- setError(`${message}: ${cause instanceof Error ? cause.message : String(cause)}`);
- } finally {
- setIsPreparingPlan(false);
- }
- }
-
- return (
- <>
-
-
-
-
-
-
-
- {chatGptProxyStopped &&
Прокси для ChatGPT не запущен, нажмите применить}
-
-
-
- Настройка общая для Codex и ChatGPT.
- {agents.codex.status === "found" && (
-
- ({ CodexSetRouting: mode })} agentToken={agents.codex.data.token} hub={hub}
- disabled={isChangeInProgress} onPrepare={prepare} />
-
- )}
- {agents.codex.status === "error" && (
- Не удалось получить состояние Codex / ChatGPT: {agents.codex.data}
- )}
-
- }>
- {agents.claude.status === "found" && (
- ({ ClaudeSetRouting: mode })} agentToken={agents.claude.data.token} hub={hub}
- disabled={isChangeInProgress} onPrepare={prepare} />
- )}
-
-
-
- {!hub.signedIn && Войдите в CoreInfra Hub для выбора токена.}
- {hub.status === "error" && {hub.error}}
-
- }>
- {agents.claude_desktop.status === "found" && (
-