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
+7 -20
View File
@@ -81,8 +81,9 @@ import type {
} from "../lib/api";
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
import { toast } from "../lib/toast";
import { endRuns } from "../lib/stopSession";
import { ContextMenu, MenuItem, useContextMenu } from "../components/ContextMenu";
import { confirmDialog, confirmDiscard } from "../lib/confirm";
import { confirmDiscard } from "../lib/confirm";
import { keepIfSame } from "../lib/keepIfSame";
import { useWorkLanes } from "../hooks/useLanes";
import { LaneConsolePane } from "../components/run/LaneConsolePane";
@@ -423,27 +424,12 @@ function ConsoleArea({
else onNewSession?.();
};
// A pane header's X: stops that one session, as a one-pane tab's X does —
// an AI session may be mid-turn, so it asks first; a shell holds no agent.
const stopPane = async (slot: number) => {
// A pane header's X stops (and archives) that one session, its moon sleeps
// it — as a one-pane tab's buttons do (`endRuns` asks first for an AI pane).
const stopPane = async (slot: number, sleep = false) => {
const run = runOf(slot);
if (!run) return;
if (
run.tool !== "shell" &&
!(await confirmDialog({
title: t("tabs.stopTitle", { name: label(run) }),
message: t("tabs.stopBody"),
confirmLabel: t("tabs.stopConfirm"),
destructive: true,
}))
)
return;
try {
await api.run.kill(run.id);
ended(slot);
} catch (err) {
toast.error(err);
}
if ((await endRuns([run], { sleep, name: label(run), t })).length) ended(slot);
};
// A split menu's "New terminal" or AI tool (bare, as the tab bar's `+`
@@ -632,6 +618,7 @@ function ConsoleArea({
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
stateOf={stateOf}
onStop={(slot) => void stopPane(slot)}
onSleep={(slot) => void stopPane(slot, true)}
// Keyed by workspace too, so a kept pane coming back is the same one.
keyOf={allTabs ? undefined : (slot) => paneKey(selectedLaneId ?? 0, slot)}
kept={kept}
+18 -6
View File
@@ -766,11 +766,21 @@ describe("console tabs", () => {
expect(screen.getByTestId("console-tab-10")).toBeInTheDocument();
});
it("a tab's moon sleeps its session: stopped but not archived", async () => {
withRuns([live(0), live(1)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await userEvent.setup().click(screen.getByTestId("console-tab-sleep-0"));
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1", { archive: false });
await waitFor(() =>
expect(screen.getByTestId("console-tab-1")).toHaveAttribute("aria-selected", "true")
);
});
it("closing the active tab stops its session and opens the next one", async () => {
withRuns([live(0), live(1)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await userEvent.setup().click(screen.getByTestId("console-tab-close-0"));
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1");
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1", { archive: true });
await waitFor(() =>
expect(screen.getByTestId("console-tab-1")).toHaveAttribute("aria-selected", "true")
);
@@ -916,8 +926,8 @@ describe("split panes", () => {
withRuns([live(1)]);
await userEvent.setup().click(screen.getByTestId("console-tab-close-0-2"));
await settle();
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1");
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1", { archive: true });
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
expect(shownPanes()).toEqual(["pane-1"]);
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
});
@@ -932,7 +942,7 @@ describe("split panes", () => {
await userEvent.setup().click(screen.getByTestId("pane-stop-1"));
await settle();
expect(api.run.kill).toHaveBeenCalledTimes(1);
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-1");
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-1", { archive: true });
expect(shownPanes()).toEqual(["pane-0", "pane-2"]);
expect(screen.queryByTestId("console-tab-1")).toBeNull();
});
@@ -1017,9 +1027,11 @@ describe("split panes", () => {
await right(screen.getByTestId("console-tab-2"));
await user.click(
within(screen.getByTestId("console-tab-menu-2")).getByText("Close tab (stops its session)")
within(screen.getByTestId("console-tab-menu-2")).getByText(
"Close tab (stops and archives its session)"
)
);
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
});
it("a split menu starts a new terminal in the first free slot", async () => {