chore: v2.8.12 — clone queue and progress, session defaults, chat agents, drops as @ mentions

Whole tree from parallel sessions, plus the version bump:
- Clone: a queue with live progress (Add workspace and extra folders),
  clones run under nice/ionice so the machine stays responsive.
- Settings → AI tools → Session defaults: a new session's default
  permission mode / bypass per account.
- Chat lists the background subagents that have not reported back yet.
- A file pasted or dropped into a claude pane goes in as an @ mention.
- Changes panel: say why amend/undo is refused on a pushed commit.
- openapi.yaml regenerated (Artifacts, Host, resumeById).
This commit is contained in:
2026-10-19 17:19:41 +07:00
parent 1a6b550e89
commit 49f87c4da3
61 changed files with 2824 additions and 114 deletions
+30 -1
View File
@@ -9,7 +9,8 @@
* 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). There is no AI tool picker: the lane is created with the
* 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
* 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
@@ -30,6 +31,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 type { CwdSuggestion } from "../../lib/api";
import type { Lane, LaneWorktree } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
@@ -116,6 +118,9 @@ export function AddLaneModal({
// server asked for one — a login for the URL's https host.
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<string | null>(null);
const [login, setLogin] = useState<{
host: string;
username: string;
@@ -159,6 +164,7 @@ export function AddLaneModal({
setMode("repo");
setCloneUrl("");
setCloneName("");
setClonePartial(false);
setLogin(null);
setSshRefused(false);
setSourceRepo("");
@@ -236,13 +242,17 @@ export function AddLaneModal({
const url = cloneUrl.trim();
if (!url) return;
setSshRefused(false);
const progressToken = newCloneToken();
setCloneToken(progressToken);
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: {
@@ -274,6 +284,8 @@ export function AddLaneModal({
setError(err instanceof Error ? err.message : String(err));
}
setBusy(false);
} finally {
setCloneToken(null);
}
return;
}
@@ -547,6 +559,23 @@ export function AddLaneModal({
spellCheck={false}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<label
className="mt-2 flex items-center gap-1.5 text-[11px] text-fg-secondary"
title={t("clone.partialHint")}
>
<input
type="checkbox"
data-testid="add-lane-clone-partial"
checked={clonePartial}
onChange={(e) => setClonePartial(e.target.checked)}
/>
{t("clone.partial")}
</label>
{cloneToken && (
<div className="mt-2">
<CloneProgress token={cloneToken} />
</div>
)}
</div>
)}
@@ -0,0 +1,93 @@
/**
* @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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type CloneProgressView } from "../../lib/api";
const POLL_MS = 1000;
/** 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 }) {
const { t } = useTranslation("lanes");
const [view, setView] = useState<CloneProgressView | null>(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") {
return (
<p data-testid="clone-progress" className="text-[11px] text-status-warning">
{t("clone.queued", {
position: view.position ?? 1,
waiting: view.waiting,
running: view.running,
limit: view.limit,
})}
</p>
);
}
const phase = view.phase
? view.phase === "other"
? view.label || ""
: t(`clone.phase.${view.phase}`)
: t("clone.connecting");
const percent = view.percent ?? 0;
return (
<div data-testid="clone-progress" className="space-y-1">
<p className="flex justify-between gap-2 text-[11px] text-fg-secondary">
<span className="truncate">
{phase}
{view.phase ? ` ${percent}%` : ""}
</span>
{view.detail && (
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{view.detail}</span>
)}
</p>
{view.phase && (
<div
className="h-1 overflow-hidden rounded bg-surface-2"
role="progressbar"
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
aria-label={phase}
>
<div className="h-full bg-accent-solid transition-all" style={{ width: `${percent}%` }} />
</div>
)}
</div>
);
}
@@ -10,7 +10,8 @@
* sent to Settings → Git first — and an HTTPS host with no saved login offers
* the login row up front (optional: a public repo needs none). The identity is
* read again when the window regains focus, so a key made in another tab
* counts at once.
* counts at once. The running clone shows its queue place, then git's
* progress (`CloneProgress`); **Partial clone** sends `partial: true`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -18,6 +19,7 @@ import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Loader2, X } from "lucide-react";
import { api, type GitIdentity, type LaneSourcesView } from "../../lib/api";
import { CloneProgress, newCloneToken } from "./CloneProgress";
type Row = {
url: string;
@@ -25,6 +27,8 @@ type Row = {
note?: string;
/** The note points at Settings → Git (no key, or a key the host refused). */
ssh?: boolean;
/** While busy: the progress token CloneProgress polls. */
token?: string;
};
type Login = { host: string; username: string; token: string; remember: boolean };
@@ -71,6 +75,7 @@ export function CloneSourcesForm({
const [rows, setRows] = useState<Row[]>([]);
const [busy, setBusy] = useState(false);
const [login, setLogin] = useState<Login | null>(null);
const [partial, setPartial] = useState(false);
const [identity, setIdentity] = useState<GitIdentity | null>(null);
// An unknown identity (an older server, a failed read) checks nothing: the
@@ -116,10 +121,13 @@ export function CloneSourcesForm({
for (const row of next) {
if (row.state === "failed") continue;
row.state = "busy";
row.token = newCloneToken();
show();
try {
const view = await api.lanes.cloneFolder(laneId, {
url: row.url,
progressToken: row.token,
...(partial ? { partial: true } : {}),
...(login?.username.trim() && login.token
? {
credentials: {
@@ -151,6 +159,7 @@ export function CloneSourcesForm({
row.note = e.message || String(err);
}
}
row.token = undefined;
show();
}
if (asked) setLogin(asked);
@@ -191,6 +200,18 @@ export function CloneSourcesForm({
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<p className="text-[11px] text-fg-muted">{t("home.cloneHint", { path: parent })}</p>
<label
className="flex items-center gap-1.5 text-[11px] text-fg-secondary"
title={t("clone.partialHint")}
>
<input
type="checkbox"
data-testid="workspace-clone-partial"
checked={partial}
onChange={(e) => setPartial(e.target.checked)}
/>
{t("clone.partial")}
</label>
{sshHosts.length > 0 && (
<p
role="alert"
@@ -280,6 +301,11 @@ export function CloneSourcesForm({
{r.url}
</span>
</span>
{r.state === "busy" && r.token && (
<div className="ml-[18px] mt-0.5">
<CloneProgress token={r.token} />
</div>
)}
{r.note && (
<span className="ml-[18px] block text-status-danger">
{r.note}
@@ -1728,7 +1728,12 @@ export function LaneChangesPanel({
{commits?.[0]?.sha === commitCtx.menu.item.sha && (
<MenuItem
danger
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
// Pushed: the server refuses it (EPUSHED), so say why here
// instead of failing after the confirm.
disabled={
busy || opOpen || headPushed || commitCtx.menu.item.parents.length !== 1
}
title={headPushed ? t("changes.undoCommitPushed") : undefined}
testId="lane-changes-menu-undo-commit"
onSelect={pick(
commitCtx.close,
@@ -94,7 +94,7 @@ export function SessionReplay({
const tool = session?.tool || "claude";
const resumable = byId(tool)?.resumeById ?? RESUMABLE.has(tool);
const livePane = liveRunFor(lane, liveRuns, sessionId);
const text = promptWithImages(prompt, images);
const text = promptWithImages(prompt, images, tool === "claude");
const send = async () => {
if (!text || busy || uploading || livePane || !resumable) return;
+28 -16
View File
@@ -11,7 +11,7 @@
* shell tab at once, like the tab bar's New terminal) and one Model chip (`ModelMenu`: the tool's own list or the
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
* a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it
* a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
* **Show more** past the first page, and the sidebar row's Rename/Archive in
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
@@ -20,7 +20,6 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Checkbox } from "../Checkbox";
import { isSubmitEnter } from "../../lib/keys";
import { confirmDialog } from "../../lib/confirm";
import { useEffect, useRef, useState } from "react";
@@ -34,6 +33,7 @@ import {
MoreHorizontal,
ArrowUp,
SquareTerminal,
Zap,
} from "lucide-react";
import {
api,
@@ -76,7 +76,7 @@ export {
} from "./startSession";
import { startInFreeTab } from "./startSession";
import { WorkspaceContext } from "./WorkspaceContext";
import { readDefaultMode } from "../../lib/sessionDefaults";
import { readCodexDefaults, readDefaultMode } from "../../lib/sessionDefaults";
/** Recents' page size; Show more adds another. */
const RECENT_PAGE = 30;
@@ -165,12 +165,14 @@ function HomeBody({
// Claude and Codex always take a model and an effort; a catalog tool a
// model only when its file names the flag, an effort never.
const hasModel = claude || codex || !!entry?.modelFlag;
// The account's own default (first-run setup) — never bypass.
// The account's own defaults (first-run setup, Settings → AI tools →
// Session defaults); a pick here is for this session only.
const [mode, setMode] = useState<PermissionMode>(readDefaultMode);
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
const [approval, setApproval] = useState<CodexApproval>("on-request");
// Per session, never remembered.
const [sandbox, setSandbox] = useState<CodexSandbox>(() => readCodexDefaults().sandbox);
const [approval, setApproval] = useState<CodexApproval>(() => readCodexDefaults().approval);
// Off for every tool but Codex, which starts from the account's default.
const [bypass, setBypass] = useState(false);
useEffect(() => setBypass(codex && readCodexDefaults().bypass), [codex]);
const canBypass = !claude && !!entry?.bypass;
const [effort, setEffort] = useState<EffortLevel>("");
const hasEffort = claude || codex;
@@ -254,7 +256,7 @@ function HomeBody({
}
};
const text = promptWithImages(prompt, images);
const text = promptWithImages(prompt, images, claude);
const start = async () => {
// Enter reaches here past the disabled button: the same guards again.
// A ref, not only the state: two Enters in one tick both read `starting` false.
@@ -607,15 +609,25 @@ function HomeBody({
</div>
)}
{canBypass && (
<span className="px-2">
<Checkbox
tone="danger"
checked={bypass}
onChange={setBypass}
label={t("run:fields.bypass")}
labelClassName="text-xs text-status-danger"
// A chip like the selects beside it: quiet when off, red when on.
<button
type="button"
aria-pressed={bypass}
data-testid="workspace-home-bypass"
title={t(codex ? "run:fields.yoloHint" : "run:fields.bypass")}
onClick={() => setBypass(!bypass)}
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-status-danger/60 ${
bypass
? "bg-status-danger/10 font-medium text-status-danger ring-1 ring-inset ring-status-danger/30 hover:bg-status-danger/20"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<Zap
className={`h-3.5 w-3.5 ${bypass ? "fill-current" : "text-fg-muted"}`}
aria-hidden
/>
</span>
{t(codex ? "run:fields.yolo" : "run:fields.bypassShort")}
</button>
)}
</div>
{danger && (
@@ -786,9 +786,25 @@ describe("AddLaneModal — Clone", () => {
)
);
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].credentials).toBeUndefined();
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].partial).toBeUndefined();
await waitFor(() => expect(onAdded).toHaveBeenCalled());
});
it("sends partial only when the box is ticked", async () => {
vi.mocked(api.lanes.clone).mockResolvedValue({
lane: laneFixture({ id: 7 }),
created: true,
path: "/home/an/code/big",
});
const user = userEvent.setup();
await fillClone(user, "https://git.example.com/o/big.git");
await user.click(screen.getByTestId("add-lane-clone-partial"));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ partial: true }))
);
});
it("asks for a login on ECREDENTIALS and sends it on the retry", async () => {
vi.mocked(api.lanes.clone)
.mockRejectedValueOnce(
@@ -0,0 +1,79 @@
/**
* @file Tests for CloneProgress: a queued clone shows its place in the
* server's clone queue, a running one git's phase, percent and speed with a
* bar, and nothing shows before the server knows the token. Also that
* `newCloneToken` makes a token the server accepts.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import "../../../i18n";
vi.mock("../../../lib/api", () => ({
api: { lanes: { cloneProgress: vi.fn() } },
}));
import { api, type CloneProgressView } from "../../../lib/api";
import { CloneProgress, newCloneToken } from "../CloneProgress";
const base: CloneProgressView = {
state: "running",
position: null,
running: 2,
waiting: 1,
limit: 2,
phase: null,
label: null,
percent: null,
detail: null,
};
describe("CloneProgress", () => {
// Braces: a function returned from beforeEach is run as its teardown.
beforeEach(() => {
vi.mocked(api.lanes.cloneProgress).mockReset();
});
it("shows the clone's place in the queue while it waits", async () => {
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
...base,
state: "queued",
position: 1,
});
render(<CloneProgress token="t" />);
expect(await screen.findByTestId("clone-progress")).toHaveTextContent(
"Waiting in the clone queue — #1 of 1 (2/2 clones running)"
);
});
it("shows git's phase, percent and speed with a bar while it runs", async () => {
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
...base,
phase: "receiving",
label: "Receiving objects",
percent: 45,
detail: "450/1000, 120.50 MiB | 5.20 MiB/s",
});
render(<CloneProgress token="t" />);
const line = await screen.findByTestId("clone-progress");
expect(line).toHaveTextContent("Downloading 45%");
expect(line).toHaveTextContent("120.50 MiB | 5.20 MiB/s");
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
});
it("shows nothing while the server does not know the token yet", async () => {
vi.mocked(api.lanes.cloneProgress).mockImplementation(() =>
Promise.reject(Object.assign(new Error("no such clone"), { code: "ENOTFOUND" }))
);
render(<CloneProgress token="t" />);
await new Promise((r) => setTimeout(r, 20));
expect(screen.queryByTestId("clone-progress")).toBeNull();
});
it("makes tokens the server accepts", () => {
const a = newCloneToken();
expect(a).toMatch(/^[A-Za-z0-9_-]{16,64}$/);
expect(newCloneToken()).not.toBe(a);
});
});
@@ -3,7 +3,8 @@
* an extra folder, a host that wants a login opens the login row, and Retry
* re-runs only the failed URLs with that login. An SSH link while the
* account has no key is never sent — the user is pointed at Settings → Git —
* and an HTTPS host with no saved login is flagged before Clone.
* and an HTTPS host with no saved login is flagged before Clone. The
* partial-clone box adds `partial: true` to every clone.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -59,12 +60,29 @@ describe("CloneSourcesForm", () => {
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
progressToken: expect.any(String),
url: "https://private.test/b.git",
credentials: { username: "dev", password: "tok", remember: true },
});
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
});
it("asks for partial clones when the box is ticked", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={vi.fn()} />);
await user.type(screen.getByTestId("workspace-clone-input"), "https://h/big.git");
await user.click(screen.getByTestId("workspace-clone-partial"));
await user.click(screen.getByTestId("workspace-clone-run"));
await waitFor(() =>
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
progressToken: expect.any(String),
url: "https://h/big.git",
partial: true,
})
);
});
it("tells an SSH link from an HTTPS one", () => {
expect(cloneTarget("git@github.com:o/r.git")).toEqual({ kind: "ssh", host: "github.com" });
expect(cloneTarget("ssh://git@h.test:2222/o/r")).toEqual({ kind: "ssh", host: "h.test" });
@@ -92,7 +110,10 @@ describe("CloneSourcesForm", () => {
await user.click(screen.getByTestId("workspace-clone-run"));
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(1));
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, { url: "https://h.test/a.git" });
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
url: "https://h.test/a.git",
progressToken: expect.any(String),
});
expect(await screen.findByText(/has no SSH key yet/)).toBeInTheDocument();
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git");
});
@@ -1062,6 +1062,20 @@ describe("LaneChangesPanel", () => {
);
});
it("does not offer Undo for a top commit its upstream already holds", async () => {
withParent();
vi.mocked(api.lanes.git).mockResolvedValue(
facts({ hasRemote: true, upstream: "origin/feat/thing", ahead: 0, behind: 0 })
);
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "History" }));
const undo = within(await commitMenu(user)).getByTestId("lane-changes-menu-undo-commit");
await waitFor(() => expect(undo).toBeDisabled());
expect(undo).toHaveAttribute("title", expect.stringMatching(/already on the remote/));
});
it("cherry-picks only from another branch's history", async () => {
withParent();
vi.mocked(api.lanes.git).mockResolvedValue(facts());
@@ -245,7 +245,9 @@ describe("WorkspaceHome", () => {
// As the Workspace page: `&run=` replaces the home with the session view.
function HomeUntilRun() {
const [params] = useSearchParams();
return params.get("run") ? null : <WorkspaceHome lane={lane()} onMenuAction={() => undefined} />;
return params.get("run") ? null : (
<WorkspaceHome lane={lane()} onMenuAction={() => undefined} />
);
}
const user = userEvent.setup();
render(