+ );
+}
diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx
index 31e0f668..d8ba3bdd 100644
--- a/client/src/components/Layout.tsx
+++ b/client/src/components/Layout.tsx
@@ -51,6 +51,7 @@
* ## Internal dependencies
* - `./Sidebar`
* - `./UpdateNotifier`
+ * - `./BackgroundJobsDock` (background clones and uploads, app-wide)
* - `./lanes/InterruptedSessionsPrompt` (the "resume interrupted sessions?" dialog)
* - `./Sagi/SagiMount`
*
@@ -82,6 +83,7 @@ import { Menu } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
import { UpdateNotifier } from "./UpdateNotifier";
+import { BackgroundJobsDock } from "./BackgroundJobsDock";
import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
import { SagiMount } from "./Sagi/SagiMount";
import { StatusBar } from "./StatusBar";
@@ -182,6 +184,7 @@ export function Layout({ wsConnected }: LayoutProps) {
{t("skipToContent")}
+
{onboarding.loaded && !onboarding.onboardedAt && (
diff --git a/client/src/components/__tests__/BackgroundJobsDock.test.tsx b/client/src/components/__tests__/BackgroundJobsDock.test.tsx
new file mode 100644
index 00000000..ebf41fad
--- /dev/null
+++ b/client/src/components/__tests__/BackgroundJobsDock.test.tsx
@@ -0,0 +1,290 @@
+/**
+ * @file Tests for the background jobs store (`lib/cloneJobs.ts`,
+ * `lib/uploadJobs.ts`) and its dock: a clone started in the background is
+ * announced once when the store sees it finish (toast + window event); a
+ * fresh mount (F5) shows what the server kept — an interrupted clone with
+ * Retry, a login failure with an inline login form that retries with it —
+ * without toasting it again; × dismisses; Stop (click twice) cancels a
+ * running clone, which then reads as stopped and can be retried; an older
+ * server that cannot stop says so. An upload shows with Stop, and one a
+ * reload cut short is shown as interrupted until dismissed. Several chips
+ * collapse to one pill.
+ * @author Nguyễn Ngọc Trí Vĩ
+ */
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { MemoryRouter } from "react-router-dom";
+import "../../i18n";
+
+vi.mock("../../lib/api", () => ({
+ api: {
+ lanes: {
+ clone: vi.fn(),
+ cloneFolder: vi.fn(),
+ cloneJobs: vi.fn(),
+ retryCloneJob: vi.fn(),
+ dismissCloneJob: vi.fn(),
+ cancelCloneJob: vi.fn(),
+ },
+ },
+}));
+vi.mock("../../lib/toast", () => ({
+ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
+}));
+
+import { api, type CloneJob } from "../../lib/api";
+import { toast } from "../../lib/toast";
+import {
+ CLONE_JOB_EVENT,
+ refreshCloneJobs,
+ resetCloneJobsForTests,
+ startClone,
+} from "../../lib/cloneJobs";
+import { beginUpload, reloadUploadsForTests } from "../../lib/uploadJobs";
+import { BackgroundJobsDock } from "../BackgroundJobsDock";
+
+const base: CloneJob = {
+ id: "j1",
+ kind: "workspace",
+ state: "running",
+ url: "https://git.example/o/repo.git",
+ name: "repo",
+ parent: "/home/an",
+ target: "/home/an/repo",
+ laneId: null,
+ position: null,
+ phase: "receiving",
+ label: null,
+ percent: 40,
+ detail: null,
+ error: null,
+ result: null,
+ createdAt: "",
+ finishedAt: null,
+ request: { url: "https://git.example/o/repo.git", parent: "/home/an", name: "repo" },
+};
+const job = (over: Partial = {}): CloneJob => ({ ...base, ...over });
+
+const renderDock = () =>
+ render(
+
+
+
+ );
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ resetCloneJobsForTests();
+ try {
+ localStorage.clear();
+ } catch {
+ /* none */
+ }
+ reloadUploadsForTests();
+ vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [] });
+ vi.mocked(api.lanes.dismissCloneJob).mockResolvedValue({ ok: true });
+});
+
+describe("cloneJobs store", () => {
+ it("announces a background clone once, when it sees it finish", async () => {
+ const onDone = vi.fn();
+ const events: CloneJob[] = [];
+ const listen = (e: Event) => events.push((e as CustomEvent).detail);
+ window.addEventListener(CLONE_JOB_EVENT, listen);
+ vi.mocked(api.lanes.clone).mockResolvedValue({ job: job({ state: "queued", position: 1 }) });
+ await startClone({ kind: "workspace", body: { url: base.url, parent: "/home/an" } }, { onDone });
+ expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ background: true }));
+ expect(toast.success).not.toHaveBeenCalled();
+
+ const done = job({ state: "done", result: { laneId: 7, path: "/home/an/repo" } });
+ vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [done] });
+ await refreshCloneJobs();
+ await refreshCloneJobs();
+ expect(toast.success).toHaveBeenCalledTimes(1);
+ expect(vi.mocked(toast.success).mock.calls[0]![0]).toMatch(/Cloned repo into \/home\/an\/repo/);
+ expect(events).toEqual([done]);
+ expect(onDone).toHaveBeenCalledWith(done, undefined);
+ window.removeEventListener(CLONE_JOB_EVENT, listen);
+ });
+});
+
+describe("BackgroundJobsDock", () => {
+ it("shows a running clone's progress after a fresh mount, without a toast", async () => {
+ vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
+ renderDock();
+ const chip = await screen.findByTestId("clone-job");
+ expect(chip).toHaveTextContent("Cloning repo");
+ expect(within(chip).getByRole("progressbar")).toHaveAttribute("aria-valuenow", "40");
+ expect(toast.success).not.toHaveBeenCalled();
+ expect(toast.error).not.toHaveBeenCalled();
+ });
+
+ it("shows an interrupted clone after F5 and retries it", async () => {
+ const user = userEvent.setup();
+ vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
+ jobs: [
+ job({
+ state: "interrupted",
+ error: { code: "EINTERRUPTED", message: "server restarted" },
+ }),
+ ],
+ });
+ vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({
+ job: job({ id: "j2", state: "queued", position: 1 }),
+ });
+ renderDock();
+ const chip = await screen.findByTestId("clone-job");
+ expect(chip).toHaveTextContent("AgentHub restarted while this clone was running.");
+ expect(toast.error).not.toHaveBeenCalled();
+ await user.click(within(chip).getByRole("button", { name: "Retry" }));
+ expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", undefined);
+ await waitFor(() =>
+ expect(screen.getByTestId("clone-job")).toHaveTextContent("Waiting in the clone queue — #1")
+ );
+ });
+
+ it("asks for a login on ECREDENTIALS and retries with it", async () => {
+ const user = userEvent.setup();
+ vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
+ jobs: [
+ job({
+ state: "failed",
+ error: {
+ code: "ECREDENTIALS",
+ message: "login",
+ auth: { protocol: "https", host: "git.example" },
+ },
+ }),
+ ],
+ });
+ vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({ job: job({ id: "j2" }) });
+ renderDock();
+ const form = await screen.findByTestId("clone-job-login");
+ expect(screen.getByTestId("clone-job")).toHaveTextContent("git.example needs a login");
+ await user.type(within(form).getByLabelText("Username"), "ann");
+ await user.type(within(form).getByLabelText("Password or access token"), "tok");
+ await user.click(within(form).getByRole("button", { name: "Retry with this login" }));
+ expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", {
+ username: "ann",
+ password: "tok",
+ remember: true,
+ });
+ await waitFor(() =>
+ expect(screen.getByTestId("clone-job")).toHaveAttribute("data-state", "running")
+ );
+ });
+
+ it("dismisses a finished clone", async () => {
+ const user = userEvent.setup();
+ vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
+ jobs: [job({ state: "done", result: { laneId: 7, path: "/home/an/repo" } })],
+ });
+ renderDock();
+ const chip = await screen.findByTestId("clone-job");
+ expect(within(chip).getByRole("button", { name: "Open workspace" })).toBeInTheDocument();
+ await user.click(within(chip).getByTestId("clone-job-dismiss"));
+ expect(api.lanes.dismissCloneJob).toHaveBeenCalledWith("j1");
+ expect(screen.queryByTestId("clone-job")).toBeNull();
+ });
+
+ it("stops a running clone on a second click, then lets it be retried", async () => {
+ const user = userEvent.setup();
+ vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
+ vi.mocked(api.lanes.cancelCloneJob).mockResolvedValue({
+ job: job({ state: "cancelled", error: { code: "ECANCELLED", message: "cancelled" } }),
+ });
+ vi.mocked(api.lanes.retryCloneJob).mockResolvedValue({ job: job({ id: "j2" }) });
+ renderDock();
+ const stop = await screen.findByTestId("clone-job-stop");
+ await user.click(stop);
+ expect(api.lanes.cancelCloneJob).not.toHaveBeenCalled();
+ expect(screen.getByTestId("clone-job")).toHaveTextContent("Click again to stop");
+ await user.click(screen.getByTestId("clone-job-stop"));
+ expect(api.lanes.cancelCloneJob).toHaveBeenCalledWith("j1");
+ const chip = await screen.findByText("You stopped this clone.");
+ expect(chip).toBeInTheDocument();
+ expect(toast.info).toHaveBeenCalledWith("Stopped cloning repo.");
+ expect(toast.error).not.toHaveBeenCalled();
+ await user.click(screen.getByRole("button", { name: "Retry" }));
+ expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", undefined);
+ await waitFor(() =>
+ expect(screen.getByTestId("clone-job")).toHaveAttribute("data-state", "running")
+ );
+ });
+
+ it("says so when the server cannot stop a clone yet", async () => {
+ const user = userEvent.setup();
+ vi.mocked(api.lanes.cloneJobs).mockResolvedValue({ jobs: [job()] });
+ vi.mocked(api.lanes.cancelCloneJob).mockRejectedValue(
+ Object.assign(new Error("HTTP 404"), { status: 404 })
+ );
+ renderDock();
+ await user.click(await screen.findByTestId("clone-job-stop"));
+ await user.click(screen.getByTestId("clone-job-stop"));
+ await waitFor(() =>
+ expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/cannot stop a clone yet/))
+ );
+ });
+
+ it("shows an upload with Stop, which aborts it", async () => {
+ renderDock();
+ let up!: ReturnType;
+ act(() => {
+ up = beginUpload({ folder: "one/src", total: 3, bytes: 300 });
+ up.update({ name: "a.txt", done: 1, sent: 150 });
+ });
+ const chip = await screen.findByTestId("fx-progress");
+ expect(chip).toHaveTextContent("Uploading 2/3");
+ expect(chip).toHaveTextContent("one/src · a.txt");
+ fireEvent.click(within(chip).getByTestId("upload-job-stop"));
+ expect(up.signal.aborted).toBe(false);
+ fireEvent.click(within(chip).getByTestId("upload-job-stop"));
+ expect(up.signal.aborted).toBe(true);
+ act(() => up.end());
+ expect(screen.queryByTestId("fx-progress")).toBeNull();
+ });
+
+ it("shows an upload a reload cut short until it is dismissed", async () => {
+ localStorage.setItem(
+ "agenthub.uploads.inflight",
+ JSON.stringify([
+ {
+ id: "up-old",
+ folder: "one/src",
+ name: "b.txt",
+ done: 2,
+ total: 5,
+ sent: 10,
+ bytes: 50,
+ state: "running",
+ beat: Date.now() - 60_000,
+ },
+ ])
+ );
+ act(() => reloadUploadsForTests());
+ renderDock();
+ const chip = await screen.findByTestId("upload-job");
+ expect(chip).toHaveTextContent(
+ "The upload of 5 file(s) to one/src was interrupted by a page reload — 2 sent."
+ );
+ fireEvent.click(within(chip).getByRole("button", { name: "Dismiss" }));
+ expect(screen.queryByTestId("upload-job")).toBeNull();
+ expect(localStorage.getItem("agenthub.uploads.inflight")).toBeNull();
+ });
+
+ it("collapses several chips to one pill", async () => {
+ const user = userEvent.setup();
+ vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
+ jobs: [job(), job({ id: "j2", name: "other" })],
+ });
+ renderDock();
+ await waitFor(() => expect(screen.getAllByTestId("clone-job")).toHaveLength(2));
+ await user.click(screen.getByTestId("jobs-dock-collapse"));
+ expect(screen.queryByTestId("clone-job")).toBeNull();
+ expect(screen.getByTestId("jobs-dock-pill")).toHaveTextContent("2 background tasks");
+ await user.click(screen.getByTestId("jobs-dock-pill"));
+ expect(screen.getAllByTestId("clone-job")).toHaveLength(2);
+ });
+});
diff --git a/client/src/components/lanes/AddLaneModal.tsx b/client/src/components/lanes/AddLaneModal.tsx
index 30e3b3d4..bc847e65 100644
--- a/client/src/components/lanes/AddLaneModal.tsx
+++ b/client/src/components/lanes/AddLaneModal.tsx
@@ -9,8 +9,11 @@
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
* https/ssh URL cloned into a folder of the user's, with their OWN SSH key or
* HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
- * Settings → Git; **Partial clone** makes it blobless, and `CloneProgress`
- * shows its queue place, then git's progress). There is no AI tool picker: the lane is created with the
+ * Settings → Git; **Partial clone** makes it blobless). The clone is a
+ * background job (`lib/cloneJobs.ts`): the modal closes once the server
+ * accepted it, the app-wide dock shows its queue place, git's progress and
+ * outcome, and the new workspace is selected when it ends while the user is
+ * still on that page. There is no AI tool picker: the lane is created with the
* default AI tool (Settings → AI Tools), and each new session picks its own
* AI on the workspace home. Either
* mode's path field can be filled by typing, by the CwdAutocomplete
@@ -31,7 +34,7 @@ import { useDialog } from "../../hooks/useDialog";
import { CwdAutocomplete } from "../run/RunSetup";
import { PathPickButton } from "../PathPickButton";
import { api } from "../../lib/api";
-import { CloneProgress, newCloneToken } from "./CloneProgress";
+import { startClone } from "../../lib/cloneJobs";
import type { CwdSuggestion } from "../../lib/api";
import type { Lane, LaneWorktree } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
@@ -119,8 +122,6 @@ export function AddLaneModal({
const [cloneUrl, setCloneUrl] = useState("");
const [cloneName, setCloneName] = useState("");
const [clonePartial, setClonePartial] = useState(false);
- /** The running clone's progress token (CloneProgress polls it). */
- const [cloneToken, setCloneToken] = useState(null);
const [login, setLogin] = useState<{
host: string;
username: string;
@@ -242,28 +243,46 @@ export function AddLaneModal({
const url = cloneUrl.trim();
if (!url) return;
setSshRefused(false);
- const progressToken = newCloneToken();
- setCloneToken(progressToken);
+ // The new workspace is selected when the clone ends only while the user
+ // is still on the page they started it from; elsewhere the dock's
+ // "Open workspace" does it.
+ const from = window.location.pathname;
try {
- const result = await api.lanes.clone({
- progressToken,
- url,
- parent: repo,
- name: cloneName.trim() || undefined,
- title: name || undefined,
- tool,
- ...(clonePartial ? { partial: true } : {}),
- ...(login?.username.trim() && login.token
- ? {
- credentials: {
- username: login.username.trim(),
- password: login.token,
- remember: login.remember,
- },
- }
- : {}),
- });
- onAdded(result.lane);
+ await startClone(
+ {
+ kind: "workspace",
+ body: {
+ url,
+ parent: repo,
+ name: cloneName.trim() || undefined,
+ title: name || undefined,
+ tool,
+ ...(clonePartial ? { partial: true } : {}),
+ ...(login?.username.trim() && login.token
+ ? {
+ credentials: {
+ username: login.username.trim(),
+ password: login.token,
+ remember: login.remember,
+ },
+ }
+ : {}),
+ },
+ },
+ {
+ onDone: (job, lane) => {
+ if (window.location.pathname !== from) return;
+ if (lane) return onAdded(lane);
+ const id = job.result?.laneId;
+ if (id == null) return;
+ api.lanes
+ .get(id)
+ .then((r) => onAdded(r.lane))
+ .catch(() => undefined);
+ },
+ }
+ );
+ // Accepted: the dock carries the clone from here.
reset();
onClose();
} catch (err) {
@@ -284,8 +303,6 @@ export function AddLaneModal({
setError(err instanceof Error ? err.message : String(err));
}
setBusy(false);
- } finally {
- setCloneToken(null);
}
return;
}
@@ -571,11 +588,7 @@ export function AddLaneModal({
/>
{t("clone.partial")}
- {cloneToken && (
-
-
-
- )}
+
{t("cloneJobs.backgroundHint")}
)}
@@ -856,7 +869,7 @@ export function AddLaneModal({
{done
? t("addLaneSetupClose")
: busy && mode === "clone"
- ? t("clone.cloning")
+ ? t("cloneJobs.starting")
: t("add")}
diff --git a/client/src/components/lanes/CloneHereDialog.tsx b/client/src/components/lanes/CloneHereDialog.tsx
index e5e56989..11da489a 100644
--- a/client/src/components/lanes/CloneHereDialog.tsx
+++ b/client/src/components/lanes/CloneHereDialog.tsx
@@ -5,13 +5,13 @@
* `POST /api/lanes/clone` as Add workspace → Clone — the server holds the URL
* to its allowlist and the folder to the user's own, and clones as the
* folder's account with that account's own SSH key / HTTPS login — and opens
- * a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
- * once, optionally remembered), `ESSHAUTH` points to Settings → Git.
- * A clone runs in the background from the moment it starts: the dialog steps
- * aside for a small progress chip (`CloneProgress`; click to reopen), the
- * request keeps going, and its outcome arrives as a toast — a failure is kept
- * for the next open, and one asking for a login reopens the dialog with its
- * login row.
+ * a workspace on the new folder. The clone is a server-side background job
+ * (`lib/cloneJobs.ts`): the dialog closes as soon as the server accepted it
+ * and the app-wide dock (`BackgroundJobsDock`) carries its progress and
+ * outcome, across pages, F5 and server restarts. A refusal before the clone
+ * starts (a bad URL, a folder that is not the user's, a 409 `ECREDENTIALS` /
+ * `ESSHAUTH`) stays in the form: a login row (sent once, optionally
+ * remembered), or a pointer to Settings → Git.
* @author Nguyễn Ngọc Trí Vĩ
*/
@@ -19,12 +19,9 @@ import { useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { FolderGit2 } from "lucide-react";
-import { api } from "../../lib/api";
-import type { Lane } from "../../lib/types";
import { useDialog } from "../../hooks/useDialog";
import { cloneDirName } from "./AddLaneModal";
-import { CloneProgress, newCloneToken } from "./CloneProgress";
-import { toast } from "../../lib/toast";
+import { startClone } from "../../lib/cloneJobs";
type Login = { host: string; username: string; token: string; remember: boolean };
@@ -32,16 +29,11 @@ export function CloneHereDialog({
open,
parent,
onClose,
- onCloned,
- onReopen,
}: {
open: boolean;
/** The absolute folder the clone goes into. */
parent: string;
onClose: () => void;
- onCloned: (lane: Lane, path: string) => void;
- /** Show the dialog again from the background chip. */
- onReopen: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const panelRef = useRef(null);
@@ -52,23 +44,10 @@ export function CloneHereDialog({
const [sshRefused, setSshRefused] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
- const [token, setToken] = useState(null);
- /** Where the running clone goes (the open folder may change meanwhile). */
- const [target, setTarget] = useState("");
- // Read by the request's own continuation and by the reset below.
- const openRef = useRef(open);
- openRef.current = open;
- const busyRef = useRef(false);
- /** A clone failed while hidden: the next open shows that, not a blank form. */
- const keepRef = useRef(false);
useDialog(open, panelRef, onClose);
useEffect(() => {
- if (!open || busyRef.current) return;
- if (keepRef.current) {
- keepRef.current = false;
- return;
- }
+ if (!open) return;
setUrl("");
setName("");
setLogin(null);
@@ -77,70 +56,42 @@ export function CloneHereDialog({
setError(null);
}, [open, parent]);
- const folder = name.trim() || cloneDirName(url);
- const into = busy ? target : `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
+ if (!open) return null;
- if (!open) {
- if (!busy || !token) return null;
- return createPortal(
- ,
- document.body
- );
- }
+ const folder = name.trim() || cloneDirName(url);
+ const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
const submit = async () => {
const u = url.trim();
if (!u || busy) return;
- const progressToken = newCloneToken();
- busyRef.current = true;
setBusy(true);
- setToken(progressToken);
- setTarget(into);
setError(null);
setSshRefused(false);
- // Runs in the background by default: the dialog steps aside for the
- // progress chip, and comes back only if the clone needs a login.
- onClose();
try {
- const result = await api.lanes.clone({
- url: u,
- parent,
- name: name.trim() || undefined,
- progressToken,
- ...(login?.username.trim() && login.token
- ? {
- credentials: {
- username: login.username.trim(),
- password: login.token,
- remember: login.remember,
- },
- }
- : {}),
+ await startClone({
+ kind: "workspace",
+ body: {
+ url: u,
+ parent,
+ name: name.trim() || undefined,
+ ...(login?.username.trim() && login.token
+ ? {
+ credentials: {
+ username: login.username.trim(),
+ password: login.token,
+ remember: login.remember,
+ },
+ }
+ : {}),
+ },
});
- busyRef.current = false;
- setBusy(false);
- setToken(null);
- onCloned(result.lane, result.path);
+ // Accepted: the dock takes it from here.
+ onClose();
} catch (err) {
const e = err as { code?: string; auth?: { host?: string }; message?: string };
const host = e.auth?.host || u;
- let message: string;
if (e.code === "ECREDENTIALS") {
- message = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
+ setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
setLogin((l) => ({
host,
username: l?.username ?? "",
@@ -149,20 +100,11 @@ export function CloneHereDialog({
}));
} else if (e.code === "ESSHAUTH") {
setSshRefused(true);
- message = t("clone.sshRefused", { host });
+ setError(t("clone.sshRefused", { host }));
} else {
- message = err instanceof Error ? err.message : String(err);
+ setError(err instanceof Error ? err.message : String(err));
}
- setError(message);
- busyRef.current = false;
setBusy(false);
- setToken(null);
- if (!openRef.current) {
- keepRef.current = true;
- toast.error(message);
- // A login to type: show the form again with its login row.
- if (e.code === "ECREDENTIALS") onReopen();
- }
}
};
@@ -264,7 +206,6 @@ export function CloneHereDialog({
)}
- {busy && token && }
{error && (
{error}{" "}
@@ -275,9 +216,10 @@ export function CloneHereDialog({
)}
)}
+
{t("cloneJobs.backgroundHint")}
diff --git a/client/src/components/lanes/CloneProgress.tsx b/client/src/components/lanes/CloneProgress.tsx
index e7cc594a..1ea008ad 100644
--- a/client/src/components/lanes/CloneProgress.tsx
+++ b/client/src/components/lanes/CloneProgress.tsx
@@ -2,59 +2,35 @@
* @file CloneProgress — the live line under a running clone: its place in the
* server's clone queue while it waits (clones run a few at a time machine-wide,
* `server/lib/clone-queue.js`), then git's own phase, percent, size and speed
- * with a bar. It polls `GET /api/lanes/clone/progress/:token` every second for
- * the token the clone's POST carried (`newCloneToken`); a 404 — the POST not
- * yet arrived, or an older server — just shows nothing yet.
+ * with a bar. It renders the progress fields of a background clone job
+ * (`lib/cloneJobs.ts`, polled by the store) — it fetches nothing itself.
* @author Nguyễn Ngọc Trí Vĩ
*/
-import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
-import { api, type CloneProgressView } from "../../lib/api";
+import type { CloneJob } from "../../lib/api";
-const POLL_MS = 1000;
+export type CloneProgressFields = Pick<
+ CloneJob,
+ "state" | "position" | "phase" | "label" | "percent" | "detail"
+> & { running?: number; waiting?: number; limit?: number };
-/** A fresh, unguessable progress token (works on plain http, unlike randomUUID). */
-export function newCloneToken(): string {
- const bytes = new Uint8Array(16);
- crypto.getRandomValues(bytes);
- return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
-}
-
-export function CloneProgress({ token }: { token: string }) {
+export function CloneProgress({ view }: { view: CloneProgressFields }) {
const { t } = useTranslation("lanes");
- const [view, setView] = useState(null);
- useEffect(() => {
- let alive = true;
- setView(null);
- // Never throws: progress is a courtesy, the clone's own answer decides.
- const poll = () =>
- Promise.resolve()
- .then(() => api.lanes.cloneProgress(token))
- .then((v) => {
- if (alive) setView(v);
- })
- .catch(() => {});
- void poll();
- const timer = setInterval(() => void poll(), POLL_MS);
- return () => {
- alive = false;
- clearInterval(timer);
- };
- }, [token]);
-
- if (!view || view.state === "done" || view.state === "failed") return null;
+ if (view.state !== "queued" && view.state !== "running") return null;
if (view.state === "queued") {
return (