feat: Stop archives a session, Sleep keeps it; SSH keys per type (#82); update Auto fix

- Stop (a tab's or pane's X, Close tab) now archives the session it held
  (DELETE /api/run/:id?archive=1); the new Sleep (moon left of the X, and in
  the tab, pane and session right-click menus) stops the pane but keeps the
  session listed to resume later. Shared endRuns in client/src/lib/stopSession.ts.
- Git identity (#82): an account keeps an ED25519 AND an RSA key (AWS
  CodeCommit takes only RSA), every ssh git offers both, and a member's
  ~/.gitconfig gets core.sshCommand so a git typed in a terminal uses them.
- Update dialog: Auto fix starts a Claude Code session on the install
  folder with the failed update's error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 11:51:26 +07:00
parent 58be04a4e7
commit 0a1dcfeb7f
28 changed files with 813 additions and 225 deletions
@@ -4,9 +4,11 @@
* commit author (name + email in the account's global git config — prefilled
* from Settings → Account while unset, since a member's commits fail without
* one), the SSH
* key a clone (Add workspace → Clone) and the pushes from that clone use —
* make one, copy its public half to add to GitHub/GitLab, replace or delete
* it — and, for a user with their own Linux account (S3), their own HTTPS
* keys a clone (Add workspace → Clone) and the pushes from that clone use —
* an ED25519 and/or an RSA one (AWS CodeCommit takes only RSA) — make one, copy its public half to add to GitHub/GitLab, replace or delete
* it, and whether git typed in the account's own terminal uses it too (a
* switch over `core.sshCommand` in its `~/.gitconfig`, issue #82) — and, for
* a user with their own Linux account (S3), their own HTTPS
* logins. The host account's HTTPS logins are the shared Git credentials
* table above it, so this lists them only for a member. The private key and
* every token are write-only: the server never returns them. A host identity
@@ -21,6 +23,7 @@ import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { toast } from "../../lib/toast";
import { useAuth } from "../AuthGate";
import { Switch } from "../Switch";
const INPUT =
"w-full rounded border border-border-light bg-surface-0 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
@@ -65,23 +68,24 @@ export function GitIdentitySettings() {
};
// ed25519 suits GitHub/GitLab/Gitea; AWS IAM (CodeCommit) accepts only RSA.
// An account may hold one of each; an older server answers `ssh` only.
const keys = id ? (id.sshKeys ?? (id.ssh ? [id.ssh] : [])) : [];
const terminal = id?.sshTerminal ?? id?.ssh?.terminal ?? null;
const [keyType, setKeyType] = useState<"ed25519" | "rsa">("ed25519");
const typeTaken = keys.some((k) => (k.type ?? "ed25519") === keyType);
const generate = async () => {
if (
id?.ssh &&
typeTaken &&
!(await confirmDialog({
title: t("gitIdentity.replaceConfirm"),
confirmLabel: t("gitIdentity.replace"),
}))
)
return;
await run(
() => api.gitIdentity.generateKey(Boolean(id?.ssh), keyType),
t("gitIdentity.keyMade")
);
await run(() => api.gitIdentity.generateKey(typeTaken, keyType), t("gitIdentity.keyMade"));
};
const removeKey = async () => {
const removeKey = async (type?: "ed25519" | "rsa") => {
if (
!(await confirmDialog({
title: t("gitIdentity.removeKeyConfirm"),
@@ -89,7 +93,7 @@ export function GitIdentitySettings() {
}))
)
return;
await run(() => api.gitIdentity.removeKey());
await run(() => api.gitIdentity.removeKey(type));
};
const saveLogin = async () => {
@@ -151,7 +155,7 @@ export function GitIdentitySettings() {
return (
<div className="space-y-4">
<p className="max-w-2xl text-xs text-fg-muted">
<p className="text-xs text-fg-muted">
{member ? t("gitIdentity.descriptionMember") : t("gitIdentity.descriptionHost")}
</p>
@@ -162,7 +166,7 @@ export function GitIdentitySettings() {
)}
<form
className="max-w-2xl space-y-2 rounded-lg border border-border p-3"
className="space-y-2 rounded-lg border border-border p-3"
onSubmit={(e) => {
e.preventDefault();
void saveAuthor();
@@ -202,7 +206,7 @@ export function GitIdentitySettings() {
)}
</form>
<div className="max-w-2xl space-y-2 rounded-lg border border-border p-3">
<div className="space-y-2 rounded-lg border border-border p-3">
<div className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
@@ -222,46 +226,64 @@ export function GitIdentitySettings() {
)}
{id.writable && (
<button type="button" className={BTN} disabled={busy} onClick={() => void generate()}>
{id.ssh ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{id.ssh ? t("gitIdentity.replace") : t("gitIdentity.generate")}
{typeTaken ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{typeTaken ? t("gitIdentity.replace") : t("gitIdentity.generate")}
</button>
)}
</div>
{id.ssh ? (
{keys.length ? (
<>
<textarea
readOnly
aria-label={t("gitIdentity.publicKey")}
value={id.ssh.publicKey}
rows={3}
onFocus={(e) => e.currentTarget.select()}
className={`${INPUT} resize-none break-all font-mono text-[11px]`}
/>
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
{id.ssh.type === "rsa" ? "RSA 4096 · " : id.ssh.type ? "ED25519 · " : ""}
{id.ssh.fingerprint}
</span>
<button
type="button"
className={BTN}
onClick={() => void copyText(id.ssh?.publicKey ?? "")}
>
<Copy className="h-3.5 w-3.5" />
{t("gitIdentity.copy")}
</button>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
onClick={() => void removeKey()}
>
<Trash2 className="h-3.5 w-3.5" />
{t("gitIdentity.remove")}
</button>
)}
</div>
{keys.map((k) => (
<div key={k.type ?? "ed25519"} className="space-y-1.5">
<textarea
readOnly
aria-label={`${t("gitIdentity.publicKey")} ${k.type === "rsa" ? "RSA" : "ED25519"}`}
value={k.publicKey}
rows={k.type === "rsa" ? 4 : 2}
onFocus={(e) => e.currentTarget.select()}
className={`${INPUT} resize-none break-all font-mono text-[11px]`}
/>
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
{k.type === "rsa" ? "RSA 4096 · " : "ED25519 · "}
{k.fingerprint}
</span>
<button type="button" className={BTN} onClick={() => void copyText(k.publicKey)}>
<Copy className="h-3.5 w-3.5" />
{t("gitIdentity.copy")}
</button>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
onClick={() => void removeKey(k.type)}
>
<Trash2 className="h-3.5 w-3.5" />
{t("gitIdentity.remove")}
</button>
)}
</div>
</div>
))}
{terminal && (
<div className="flex items-start gap-2 rounded border border-border p-2">
<div className="min-w-0 flex-1">
<div className="text-[11px] text-fg-primary">{t("gitIdentity.terminal")}</div>
<div className="text-[10px] text-fg-muted">
{terminal === "foreign"
? t("gitIdentity.terminalForeign")
: t("gitIdentity.terminalHint")}
</div>
</div>
<Switch
label={t("gitIdentity.terminal")}
checked={terminal === "on"}
disabled={busy || !id.writable || terminal === "foreign"}
onChange={(on) => void run(() => api.gitIdentity.setTerminalSsh(on))}
/>
</div>
)}
<p className="text-[10px] text-fg-muted">{t("gitIdentity.addKeyHint")}</p>
</>
) : (
@@ -270,7 +292,7 @@ export function GitIdentitySettings() {
</div>
{member && (
<div className="max-w-2xl space-y-2 rounded-lg border border-border p-3">
<div className="space-y-2 rounded-lg border border-border p-3">
<div className="flex items-center gap-2">
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
{t("gitIdentity.httpsTitle")}
@@ -19,6 +19,7 @@ vi.mock("../../../lib/api", () => ({
get: vi.fn(),
generateKey: vi.fn(),
removeKey: vi.fn(),
setTerminalSsh: vi.fn(),
saveHttps: vi.fn(),
saveAuthor: vi.fn(),
removeHttps: vi.fn(),
@@ -51,7 +52,7 @@ describe("GitIdentitySettings", () => {
await userEvent.click(await screen.findByRole("button", { name: "Generate SSH key" }));
expect(confirmDialog).not.toHaveBeenCalled();
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519");
expect(await screen.findByLabelText("Public key")).toHaveValue(KEY.publicKey);
expect(await screen.findByLabelText(/^Public key/)).toHaveValue(KEY.publicKey);
});
it("copies the public key and asks before replacing it", async () => {
@@ -97,6 +98,31 @@ describe("GitIdentitySettings", () => {
expect(await screen.findByText(/^RSA 4096 · /)).toBeInTheDocument();
});
it("adds an RSA key beside the ED25519 one and deletes each on its own", async () => {
const RSA = {
type: "rsa" as const,
publicKey: "ssh-rsa AAAAB3 an@agenthub",
fingerprint: "SHA256:r",
};
const ED = { ...KEY, type: "ed25519" as const };
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ ssh: ED, sshKeys: [ED] }));
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
view({ ssh: ED, sshKeys: [ED, RSA], sshTerminal: "on" })
);
vi.mocked(api.gitIdentity.removeKey).mockResolvedValue(view({ ssh: ED, sshKeys: [ED] }));
render(<GitIdentitySettings />);
await userEvent.selectOptions(await screen.findByLabelText("Key type"), "rsa");
// No RSA key yet: a plain Generate, no replace prompt, the ED25519 key stays.
await userEvent.click(screen.getByRole("button", { name: "Generate SSH key" }));
expect(confirmDialog).not.toHaveBeenCalled();
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa");
expect(await screen.findByLabelText("Public key RSA")).toHaveValue(RSA.publicKey);
expect(screen.getByLabelText("Public key ED25519")).toHaveValue(ED.publicKey);
expect(screen.getByRole("switch", { name: /terminal/ })).toBeChecked();
await userEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
await waitFor(() => expect(api.gitIdentity.removeKey).toHaveBeenCalledWith("rsa"));
});
it("warns while the commit author is unset and saves it", async () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ author: { name: null, email: null } }));
vi.mocked(api.gitIdentity.saveAuthor).mockResolvedValue(view());
@@ -118,7 +144,7 @@ describe("GitIdentitySettings", () => {
view({ account: "host", writable: false, ssh: KEY })
);
render(<GitIdentitySettings />);
expect(await screen.findByLabelText("Public key")).toBeInTheDocument();
expect(await screen.findByLabelText(/^Public key/)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Replace key" })).toBeNull();
expect(screen.queryByRole("button", { name: "Add login" })).toBeNull();
});