Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { Navigate, Route, Routes, useLocation } from "react-router-dom";

import { AppWindow } from "./components/AppWindow";
import { ActivationPage } from "./pages/ActivationPage";
Expand All @@ -12,12 +12,21 @@ import { ProviderKeyPage } from "./pages/ProviderKeyPage";
import { ProvidersPage } from "./pages/ProvidersPage";
import { ReviewPage } from "./pages/ReviewPage";
import { I18nProvider, useI18n } from "./i18n";
import { TaskCenterProvider } from "./state/TaskCenterContext";
import { TaskCenterProvider, useTaskCenter } from "./state/TaskCenterContext";
import { ThemeProvider } from "./state/ThemeContext";
import { WizardProvider, useWizard } from "./state/WizardContext";

function SetupGuard({ stage, children }: { stage: "provider" | "model" | "review" | "activation"; children: React.ReactNode }) {
const { state } = useWizard();
const { tasks } = useTaskCenter();
const location = useLocation();
const activationTask = stage === "activation" && tasks.some((task) => {
const route = task.route.split("?", 1)[0];
return route === "/setup/activation";
});
// A task card can restore the activation page after another setup run reset
// the wizard draft. The task itself is the durable source of truth then.
if (stage === "activation" && activationTask && location.pathname === "/setup/activation") return children;
if (!state.selectedAgentIds.length) return <Navigate to="/setup/agents" replace />;
const providerHasKey = Boolean(state.status?.providers[state.provider]?.has_key);
if (stage === "model" && !providerHasKey) {
Expand Down
33 changes: 24 additions & 9 deletions frontend/src/components/AgentManageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Link } from "react-router-dom";

import { api, describeError } from "../backend/api";
import { sourceTranslate, type Translate, useI18n } from "../i18n";
import { taskKey, useTaskCenter, useTaskRoute } from "../state/TaskCenterContext";
import type { AgentCatalogItem, AgentStatus, ProfileSummary, StatusResponse } from "../types/api";
import { AgentIcon, agentTagline } from "./icons/agents";

Expand Down Expand Up @@ -110,9 +111,14 @@ export function AgentManageRow({
onChanged?: () => void | Promise<void>;
}) {
const { t } = useI18n();
const { startTask, finishTask, taskFor, isTaskRunning } = useTaskCenter();
const route = useTaskRoute();
const [launching, setLaunching] = useState(false);
const [updating, setUpdating] = useState(false);
const [localUpdating, setLocalUpdating] = useState(false);
const [failure, setFailure] = useState("");
const updateTaskID = taskKey("update", agentId);
const updateTask = taskFor(updateTaskID);
const updating = updateTask?.state === "running" || localUpdating;
const version = versionNote(status, t);
const target = targetSummary(status, providers, t);
const providerId = profile?.provider || status.provider || "";
Expand All @@ -123,7 +129,8 @@ export function AgentManageRow({
// No "not configured" state here: the Profile and Provider tokens already name
// whichever piece is absent, so a third word for the same condition only adds
// a term the user has to map back onto them.
const statusLabel = failure ? t("失败") : !status.installed ? t("未安装") : "";
const updateFailure = updateTask?.state === "failure" ? updateTask.message || t("失败") : "";
const statusLabel = failure || updateFailure ? t("失败") : !status.installed ? t("未安装") : "";

// installed is true only when the Agent's command resolved on the managed
// PATH, so it is already the precise "there is something to launch" signal.
Expand All @@ -141,15 +148,23 @@ export function AgentManageRow({
}
};
const update = async () => {
setUpdating(true);
if (!startTask({
id: updateTaskID,
kind: "update",
target: agentId,
title: t("更新 {name}", { name: catalog?.name || agentId }),
route,
})) return;
setLocalUpdating(true);
setFailure("");
try {
await api.updateAgent(agentId);
finishTask(updateTaskID, { kind: "success", message: t("更新完成") });
await onChanged?.();
} catch (error) {
setFailure(describeError(error, t("无法更新 Agent")).message);
finishTask(updateTaskID, { kind: "failure", message: describeError(error, t("无法更新 Agent")).message });
} finally {
setUpdating(false);
setLocalUpdating(false);
}
};

Expand All @@ -162,7 +177,7 @@ export function AgentManageRow({
</span>
<span className="agent-manage-identity-copy">
<strong>{catalog?.name || agentId}</strong>
{failure ? <small className="agent-manage-note is-error">{failure}</small> : null}
{failure || updateFailure ? <small className="agent-manage-note is-error">{failure || updateFailure}</small> : null}
</span>
</div>
{/* Right-aligned, in a fixed order, with the Profile and Provider slots
Expand All @@ -187,17 +202,17 @@ export function AgentManageRow({
{version.text}
</span>
) : null}
{statusLabel ? <span className={`agent-manage-state${failure ? " is-error" : ""}`}>{statusLabel}</span> : null}
{statusLabel ? <span className={`agent-manage-state${failure || updateFailure ? " is-error" : ""}`}>{statusLabel}</span> : null}
</div>
</div>
<div className="agent-manage-actions">
{/* Always in the row, not only when the Agent cannot launch. Configuring
an installed Agent was previously reachable only by opening <details>,
which made the common case the hidden one. */}
{npmAgents.has(agentId) ? (
<button className="button button-secondary" type="button" onClick={() => void update()} disabled={updating || launching} title={t("执行 npm update")}>
<button className="button button-secondary" type="button" onClick={() => void update()} disabled={updating || launching || isTaskRunning(taskKey("install", agentId))} title={t("执行 npm update")}>
<RefreshCw size={15} className={updating ? "spin" : ""} aria-hidden="true" />
{t("更新")}
{updating ? t("更新中") : t("更新")}
</button>
) : null}
<Link
Expand Down
40 changes: 27 additions & 13 deletions frontend/src/components/DesktopAppSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useState } from "react";

import { api, describeError } from "../backend/api";
import { useI18n } from "../i18n";
import { useTaskCenter } from "../state/TaskCenterContext";
import { taskKey, useTaskCenter, useTaskRoute } from "../state/TaskCenterContext";
import type { DesktopAgentStatus, ProfileSummary } from "../types/api";
import { DownloadProgress } from "./DownloadProgress";
import { AgentIcon } from "./icons/agents";
Expand All @@ -25,28 +25,40 @@ type Action = "install" | "open";

export function DesktopAppSection({ app: desktopApp, onChanged, onSetup, onConfigure, profile, providerName, model, showUninstalled = true, showHeading = true }: DesktopAppSectionProps) {
const { t } = useI18n();
const { running, outcomes, startTask, finishTask, clearOutcome } = useTaskCenter();
const { startTask, finishTask, taskFor } = useTaskCenter();
const route = useTaskRoute();
const [pending, setPending] = useState<Action | "">("");
const [localNotice, setLocalNotice] = useState("");
const [localFailure, setLocalFailure] = useState("");

if (!desktopApp?.supported || (!showUninstalled && !desktopApp.installed)) return null;

// A download outlives this component: the user can navigate away while it
// runs, which unmounts the row and drops any local state. The in-flight flag
// and the outcome therefore live in the Task Center provider above the router,
// and the outcome therefore live in the Task Center provider above route content,
// so both the bar and the final verdict survive the round trip.
const downloading = Boolean(running[desktopApp.id]);
const installTaskID = taskKey("install", desktopApp.id);
const installTask = taskFor(installTaskID);
const downloading = installTask?.state === "running";
const busy = Boolean(pending) || downloading;
const outcome = outcomes[desktopApp.id];
const notice = outcome?.kind === "success" ? outcome.message : "";
const failure = outcome?.kind === "failure" ? outcome.message : "";
const notice = localNotice || (installTask?.state === "success" ? installTask.message : "");
const failure = localFailure || (!localNotice && installTask?.state === "failure" ? installTask.message : "");

const run = async (action: Action) => {
const downloads = action === "install";
if (downloads && !startTask({
id: installTaskID,
kind: "install",
target: desktopApp.id,
title: t("安装 {name}", { name: desktopApp.name }),
route,
progressTarget: desktopApp.id,
})) return;
setPending(action);
// Opening the app is not a download, so it gets no shared bar; it still
// clears the previous verdict so a stale notice does not linger.
if (downloads) startTask(desktopApp.id);
else clearOutcome(desktopApp.id);
setLocalNotice("");
setLocalFailure("");
// Opening the app is not a download, so it gets no shared bar. Terminal
// install cards stay in the center until the user dismisses them.
try {
const result = action === "install" ? await api.installDesktopAgent(desktopApp.id) : null;
let message: string;
Expand All @@ -62,11 +74,13 @@ export function DesktopAppSection({ app: desktopApp, onChanged, onSetup, onConfi
}
// Recorded in the provider, so it still reaches the user if this row
// unmounted while the install was running.
finishTask(desktopApp.id, { kind: "success", message });
if (downloads) finishTask(installTaskID, { kind: "success", message });
else setLocalNotice(message);
await onChanged();
} catch (error) {
const message = describeError(error, t("{name} 操作失败", { name: desktopApp.name })).message;
finishTask(desktopApp.id, { kind: "failure", message });
if (downloads) finishTask(installTaskID, { kind: "failure", message });
else setLocalFailure(message);
} finally {
setPending("");
}
Expand Down
11 changes: 4 additions & 7 deletions frontend/src/components/NavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { Boxes, FolderCog, Gauge, Languages, Layers3 } from "lucide-react";
import { NavLink } from "react-router-dom";

import { type TranslationKey, useI18n } from "../i18n";
import { useWizard } from "../state/WizardContext";
import { TaskCenter } from "./TaskCenter";
import { ThemePicker } from "./ThemePicker";

Expand All @@ -17,7 +16,6 @@ const navItems: Array<{ to: string; label: TranslationKey | "Provider"; icon: ty

export function NavigationSidebar() {
const { locale, setLocale, t } = useI18n();
const { state } = useWizard();
return (
<aside className="navigation-sidebar">
<div className="brand-lockup">
Expand All @@ -40,8 +38,8 @@ export function NavigationSidebar() {
))}
</nav>

{/* First of the bottom group, so its margin-top: auto pushes appearance,
language and the task centre down together. */}
{/* First of the bottom group, so its margin-top: auto pushes appearance
and language down together. The task centre is viewport-docked. */}
<ThemePicker />

<label className="language-picker">
Expand All @@ -57,9 +55,8 @@ export function NavigationSidebar() {
</select>
</label>

{/* Last child, so the language picker's margin-top: auto pushes both to
the bottom of the sidebar as one group. */}
<TaskCenter logDir={state.status?.paths.logs || ""} />
{/* The task centre is fixed to the viewport's lower-left corner. */}
<TaskCenter />
</aside>
);
}
28 changes: 19 additions & 9 deletions frontend/src/components/RuntimePrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useMemo, useState } from "react";

import { api, describeError } from "../backend/api";
import { useI18n } from "../i18n";
import { useTaskCenter } from "../state/TaskCenterContext";
import { taskKey, useTaskCenter, useTaskRoute } from "../state/TaskCenterContext";
import type { AgentStatus, RuntimeStatus } from "../types/api";
import { DownloadProgress } from "./DownloadProgress";

Expand All @@ -23,7 +23,8 @@ interface RuntimePromptProps {
*/
export function RuntimePrompt({ runtimes, missingRuntime, selectedAgentIds, agents, onInstalled }: RuntimePromptProps) {
const { t } = useI18n();
const { running, outcomes, startTask, finishTask } = useTaskCenter();
const { startTask, finishTask, taskFor } = useTaskCenter();
const route = useTaskRoute();
const [pending, setPending] = useState("");

const required = useMemo(() => {
Expand All @@ -43,23 +44,32 @@ export function RuntimePrompt({ runtimes, missingRuntime, selectedAgentIds, agen
if (!required.length) return null;

// The prompt sits on a wizard step the user can leave mid-download, so the
// in-flight flag and the failure live in the provider above the router rather
// in-flight flag and the failure live in the provider above route content rather
// than in local state that unmounting would discard.
const downloading = required.find((runtime) => running[runtime.id])?.id ?? "";
const downloading = required.find((runtime) => taskFor(taskKey("download", runtime.id))?.state === "running")?.id ?? "";
const busy = Boolean(pending) || Boolean(downloading);
const failure = required
.map((runtime) => outcomes[runtime.id])
.find((outcome) => outcome?.kind === "failure")?.message ?? "";
.map((runtime) => taskFor(taskKey("download", runtime.id)))
.find((task) => task?.state === "failure")?.message ?? "";

const install = async (runtimeId: string) => {
const id = taskKey("download", runtimeId);
const runtime = required.find((item) => item.id === runtimeId);
if (!startTask({
id,
kind: "download",
target: runtimeId,
title: t("安装 {name} {version}", { name: runtime?.name || runtimeId, version: runtime?.lockedVersion || "" }),
route,
progressTarget: runtimeId,
})) return;
setPending(runtimeId);
startTask(runtimeId);
try {
await api.installRuntime(runtimeId);
finishTask(runtimeId, { kind: "success", message: "" });
finishTask(id, { kind: "success", message: t("安装完成") });
await onInstalled();
} catch (error) {
finishTask(runtimeId, { kind: "failure", message: describeError(error, t("运行时安装失败")).message });
finishTask(id, { kind: "failure", message: describeError(error, t("运行时安装失败")).message });
} finally {
setPending("");
}
Expand Down
30 changes: 20 additions & 10 deletions frontend/src/components/RuntimeSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useState } from "react";

import { api, describeError } from "../backend/api";
import { useI18n } from "../i18n";
import { useTaskCenter } from "../state/TaskCenterContext";
import { taskKey, useTaskCenter, useTaskRoute } from "../state/TaskCenterContext";
import type { RuntimeStatus } from "../types/api";
import { AdvancedSection } from "./AdvancedSection";
import { DownloadProgress } from "./DownloadProgress";
Expand Down Expand Up @@ -39,7 +39,8 @@ export function runtimeRoot(runtimes: RuntimeStatus[]): string {

export function RuntimeSection({ runtimes, onInstalled }: RuntimeSectionProps) {
const { t } = useI18n();
const { running, outcomes, startTask, finishTask } = useTaskCenter();
const { startTask, finishTask, taskFor } = useTaskCenter();
const route = useTaskRoute();
const [pending, setPending] = useState("");

const supported = runtimes.filter((runtime) => runtime.supported || runtime.installed);
Expand All @@ -50,23 +51,32 @@ export function RuntimeSection({ runtimes, onInstalled }: RuntimeSectionProps) {
const root = runtimeRoot(supported);

// A runtime download survives navigation away from this section, so the
// in-flight flag and the failure both live in the provider above the router.
// in-flight flag and the failure both live in the provider above route content.
// A local flag would reset on unmount and hide a download still running.
const downloading = supported.find((runtime) => running[runtime.id])?.id ?? "";
const downloading = supported.find((runtime) => taskFor(taskKey("download", runtime.id))?.state === "running")?.id ?? "";
const busy = Boolean(pending) || Boolean(downloading);
const failure = supported
.map((runtime) => outcomes[runtime.id])
.find((outcome) => outcome?.kind === "failure")?.message ?? "";
.map((runtime) => taskFor(taskKey("download", runtime.id)))
.find((task) => task?.state === "failure")?.message ?? "";

const install = async (runtimeId: string) => {
const id = taskKey("download", runtimeId);
const runtime = supported.find((item) => item.id === runtimeId);
if (!startTask({
id,
kind: "download",
target: runtimeId,
title: t("安装 {name} {version}", { name: runtime?.name || runtimeId, version: runtime?.lockedVersion || "" }),
route,
progressTarget: runtimeId,
})) return;
setPending(runtimeId);
startTask(runtimeId);
try {
await api.installRuntime(runtimeId);
finishTask(runtimeId, { kind: "success", message: "" });
finishTask(id, { kind: "success", message: t("安装完成") });
await onInstalled();
} catch (error) {
finishTask(runtimeId, { kind: "failure", message: describeError(error, t("运行时安装失败")).message });
finishTask(id, { kind: "failure", message: describeError(error, t("运行时安装失败")).message });
} finally {
setPending("");
}
Expand Down Expand Up @@ -111,7 +121,7 @@ export function RuntimeSection({ runtimes, onInstalled }: RuntimeSectionProps) {
className="button button-secondary"
type="button"
onClick={() => void install(runtime.id)}
disabled={busy}
disabled={busy || taskFor(taskKey("download", runtime.id))?.state === "running"}
>
{pending === runtime.id || downloading === runtime.id ? <RefreshCw size={15} className="spin" /> : <Download size={15} />}
{pending === runtime.id || downloading === runtime.id ? t("安装中") : t("安装")}
Expand Down
Loading
Loading