fix(jobs): a failed clone's chip is readable and clickable

A failed or stopped clone also raised a toast on the same corner, a layer
above the dock, so it covered the chip's x / Retry / Stop and repeated the
same long message; the chip now says it alone, and every toast stacks above
the dock (--jobs-dock-h). A URL with no space to break at (user@host:/PATH)
wraps inside the chip and inside a toast instead of running off the screen.
Open Settings -> Git is a router link; a plain <a href> reloaded the app.
This commit is contained in:
2026-11-02 11:26:48 +07:00
parent 1f18f151d9
commit 2b26fe1efa
4 changed files with 66 additions and 19 deletions
+28 -7
View File
@@ -13,9 +13,9 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState, type ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import { ChevronDown, FolderGit2, Square, Upload, X } from "lucide-react";
import type { CloneJob } from "../lib/api";
import {
@@ -156,15 +156,16 @@ function CloneJobChip({ job }: { job: CloneJob }) {
<>
<p
role={job.state === "cancelled" ? undefined : "alert"}
className={`text-[11px] ${
className={`text-[11px] [overflow-wrap:anywhere] ${
job.state === "cancelled" ? "text-fg-secondary" : "text-status-danger"
}`}
>
{cloneJobError(job)}{" "}
{code === "ESSHAUTH" && (
<a href="/settings/git#git-identity" className="underline">
// A router link: a plain <a href> reloaded the whole app.
<Link to="/settings/git#git-identity" className="underline">
{t("clone.sshSettings")}
</a>
</Link>
)}
</p>
{code === "ECREDENTIALS" && job.state === "failed" ? (
@@ -226,7 +227,7 @@ function CloneJobChip({ job }: { job: CloneJob }) {
)
)}
{error && (
<p role="alert" className="text-[11px] text-status-danger">
<p role="alert" className="text-[11px] text-status-danger [overflow-wrap:anywhere]">
{error}
</p>
)}
@@ -244,7 +245,7 @@ function UploadChip({ job }: { job: UploadJob }) {
<div className={chipClass} data-testid="upload-job" data-state="interrupted">
<div className="flex items-start gap-2">
<Upload className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
<p className="min-w-0 flex-1 text-[11px] text-fg-secondary">
<p className="min-w-0 flex-1 text-[11px] text-fg-secondary [overflow-wrap:anywhere]">
{t("uploads.interrupted", {
total: job.total,
folder: job.folder,
@@ -305,15 +306,35 @@ export function BackgroundJobsDock() {
const jobs = useCloneJobs();
const uploads = useUploadJobs();
const [collapsed, setCollapsed] = useState(false);
const dockRef = useRef<HTMLDivElement>(null);
useEffect(() => ensureCloneJobs(), []);
const count = jobs.length + uploads.length;
// Toasts stack ABOVE the dock (Toaster reads --jobs-dock-h): they used to
// sit on the same corner and cover a chip's × / Retry / Stop.
useEffect(() => {
const root = document.documentElement;
const el = dockRef.current;
if (!count || !el) {
root.style.removeProperty("--jobs-dock-h");
return;
}
const set = () => root.style.setProperty("--jobs-dock-h", `${el.offsetHeight + 8}px`);
set();
const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(set);
ro?.observe(el);
return () => {
ro?.disconnect();
root.style.removeProperty("--jobs-dock-h");
};
}, [count, collapsed]);
if (!count) return null;
const running =
jobs.filter(isActiveJob).length + uploads.filter((u) => u.state !== "interrupted").length;
return (
<div
ref={dockRef}
role="region"
aria-label={t("jobs.dock")}
data-testid="jobs-dock"
+6 -2
View File
@@ -25,7 +25,9 @@ function Item({ item }: { item: ToastItem }) {
className={`pointer-events-auto rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${TONE[item.kind]}`}
>
<Icon className="w-4 h-4 flex-shrink-0 mt-0.5" aria-hidden />
<span className="text-xs leading-relaxed flex-1 break-words">{item.message}</span>
<span className="text-xs leading-relaxed flex-1 min-w-0 break-words [overflow-wrap:anywhere]">
{item.message}
</span>
<button
type="button"
onClick={() => dismissToast(item.id)}
@@ -44,8 +46,10 @@ export function Toaster() {
useEffect(() => subscribeToasts(setItems), []);
const errors = items.filter((i) => i.kind === "error");
const rest = items.filter((i) => i.kind !== "error");
// Above the background-jobs dock (BackgroundJobsDock sets --jobs-dock-h
// to its height), so a toast never covers a chip's buttons.
return (
<div className="pointer-events-none fixed bottom-10 right-4 z-[70] flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-2">
<div className="pointer-events-none fixed bottom-[calc(2.5rem+var(--jobs-dock-h,0px))] right-4 z-[70] flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-2">
<div role="status" aria-live="polite" className="flex flex-col gap-2">
{rest.map((i) => (
<Item key={i.id} item={i} />
@@ -94,7 +94,10 @@ describe("cloneJobs store", () => {
const listen = (e: Event) => events.push((e as CustomEvent<CloneJob>).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 });
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();
@@ -205,7 +208,8 @@ describe("BackgroundJobsDock", () => {
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.");
// The chip says it; no toast repeats it on top of the chip.
expect(toast.info).not.toHaveBeenCalled();
expect(toast.error).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", undefined);
@@ -287,4 +291,27 @@ describe("BackgroundJobsDock", () => {
await user.click(screen.getByTestId("jobs-dock-pill"));
expect(screen.getAllByTestId("clone-job")).toHaveLength(2);
});
it("wraps a long SSH error, links to Settings in-app and lifts toasts above itself", async () => {
const long = "mdpartners@mdpartners.git.backlog.jp:/PETITGIFT_CONSOLE4/console4-specs.git";
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
jobs: [
job({
state: "failed",
url: long,
error: { code: "ESSHAUTH", message: "the SSH login was refused" },
}),
],
});
renderDock();
const alert = await screen.findByRole("alert");
// A URL with no space to break at must still wrap inside the chip.
expect(alert.className).toContain("[overflow-wrap:anywhere]");
// A router link, not a full page reload.
const link = screen.getByRole("link", { name: /Settings/ });
expect(link).toHaveAttribute("href", "/settings/git#git-identity");
await waitFor(() =>
expect(document.documentElement.style.getPropertyValue("--jobs-dock-h")).not.toBe("")
);
});
});
+3 -8
View File
@@ -33,10 +33,7 @@ export const isActiveJob = (j: Pick<CloneJob, "state">) =>
type OnDone = (job: CloneJob, lane?: Lane) => void;
type Creds = { username: string; password: string; remember?: boolean };
type WorkspaceBody = Omit<Parameters<typeof api.lanes.clone>[0], "background" | "progressToken">;
type SourceBody = Omit<
Parameters<typeof api.lanes.cloneFolder>[1],
"background" | "progressToken"
>;
type SourceBody = Omit<Parameters<typeof api.lanes.cloneFolder>[1], "background" | "progressToken">;
let jobs: CloneJob[] = [];
/** The last state seen per job, to tell a transition from a repeat. */
@@ -79,6 +76,8 @@ export function cloneJobError(job: CloneJob): string {
function announce(job: CloneJob, lane?: Lane) {
const t = i18n.t.bind(i18n);
const path = job.result?.path || job.target;
// A failure or a stop is told by its chip alone (reason + Retry): a toast
// repeated the same long message on top of the chip, covering its buttons.
if (job.state === "done") {
toast.success(
job.kind === "workspace"
@@ -89,10 +88,6 @@ function announce(job: CloneJob, lane?: Lane) {
})
: t("lanes:cloneJobs.doneSource", { name: job.name, path })
);
} else if (job.state === "cancelled") {
toast.info(t("lanes:cloneJobs.cancelledToast", { name: job.name }));
} else {
toast.error(t("lanes:cloneJobs.failedToast", { name: job.name, message: cloneJobError(job) }));
}
window.dispatchEvent(new CustomEvent<CloneJob>(CLONE_JOB_EVENT, { detail: job }));
if (job.state === "done") {