From 0a1dcfeb7f75b83c1aa773886054ffc194f6f5cb Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Thu, 15 Oct 2026 11:51:26 +0700 Subject: [PATCH] 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 --- client/src/components/UpdateNotifier.tsx | 107 +++++++++-- .../__tests__/UpdateNotifier.test.tsx | 41 ++++- .../__tests__/WorkspaceTree.test.tsx | 12 +- client/src/components/lanes/SessionMenu.tsx | 38 ++-- client/src/components/run/ConsoleTabs.tsx | 61 ++++--- client/src/components/run/PaneGrid.tsx | 28 ++- .../settings/GitIdentitySettings.tsx | 122 +++++++------ .../__tests__/GitIdentitySettings.test.tsx | 30 +++- client/src/i18n/locales/en/lanes.json | 12 +- client/src/i18n/locales/en/settings.json | 7 +- client/src/i18n/locales/en/updates.json | 6 +- client/src/i18n/locales/vi/lanes.json | 12 +- client/src/i18n/locales/vi/settings.json | 7 +- client/src/i18n/locales/vi/updates.json | 6 +- client/src/lib/api.ts | 29 ++- client/src/lib/stopSession.ts | 57 ++++++ client/src/pages/Workspace.tsx | 27 +-- client/src/pages/__tests__/Workspace.test.tsx | 24 ++- docs/API.md | 9 +- docs/LANES.md | 5 +- docs/SERVER-MODE.md | 2 +- server/__tests__/git-clone.test.js | 99 +++++++++- server/__tests__/run.test.js | 28 +++ server/lib/git-identity.js | 169 ++++++++++++++---- server/openapi-extra/files.js | 31 +++- server/openapi-extra/run.js | 14 ++ server/routes/git-identity.js | 33 +++- server/routes/run.js | 22 +++ 28 files changed, 813 insertions(+), 225 deletions(-) create mode 100644 client/src/lib/stopSession.ts diff --git a/client/src/components/UpdateNotifier.tsx b/client/src/components/UpdateNotifier.tsx index 5a6de01c..416fa916 100644 --- a/client/src/components/UpdateNotifier.tsx +++ b/client/src/components/UpdateNotifier.tsx @@ -16,9 +16,13 @@ * — and the poll waits for the old server to actually go DOWN first, because it * keeps answering while the chain installs. New skills/slash commands still * need `/reload-plugins` in an already-open Claude Code session; nothing a - * background process can do for it. Escape, the backdrop and "Not now" close it - * for this page load only (never while applying); "Skip this version" is the - * one persistent, per-version dismiss. + * background process can do for it. Escape, the backdrop and × close it for + * this page load only (never while applying); "Skip this version" is the one + * persistent, per-version dismiss. After a failed update, "Auto fix" hands the + * error to an AI agent: it opens (or adopts) a workspace on the install folder + * and starts a Claude Code session there with the error as its prompt — the + * agent fixes whatever blocked the update (a dirty lockfile, a failed build…) + * and runs the update command; the dashboard itself still decides nothing. * * ## State sources * - Initial fetch via `api.updates.status()` on mount. @@ -81,7 +85,18 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { + Download, + X, + Copy, + Check, + RefreshCw, + Zap, + ExternalLink, + SkipForward, + Wrench, +} from "lucide-react"; import { api } from "../lib/api"; import { eventBus } from "../lib/eventBus"; import { copyText } from "../lib/clipboard"; @@ -89,6 +104,7 @@ import { toast } from "../lib/toast"; import { useDialog } from "../hooks/useDialog"; import type { UpdateStatusPayload, WSMessage } from "../lib/types"; import { usePermissions } from "./AuthGate"; +import { startInFreeTab } from "./lanes/startSession"; /** `localStorage` key storing the dismissed release tag (or upstream SHA). */ const DISMISS_KEY = "agent-monitor-update-dismissed-sha"; @@ -107,6 +123,32 @@ function isAutoApplicable(situation: UpdateStatusPayload["situation"]): boolean ); } +/** The prompt the Auto fix session starts with: what failed, where, and the + * limits that keep the agent from taking down the dashboard it runs under. */ +function autoFixPrompt(status: UpdateStatusPayload, error: string): string { + const target = status.latest_release || status.remote_ref || "the newest version"; + const plugin = status.situation === "plugin_managed"; + return [ + `The AgentHub dashboard's self-update to ${target} failed${status.local_version ? ` (running ${status.local_version})` : ""}.`, + plugin + ? "AgentHub is installed as a Claude Code plugin (the `agenthub` plugin from the `agenthub` marketplace)." + : `The install is the git checkout at ${status.repo_root}.`, + "", + "Error:", + "```", + error, + "```", + ...(status.manual_command ? ["", "Update command:", "```", status.manual_command, "```"] : []), + "", + "Find the cause, fix it, then finish the update" + + (plugin + ? " (`claude plugin marketplace update agenthub` then `claude plugin update agenthub@agenthub`)." + : " by running the update command."), + "Rules: never lose the user's work — before discarding any local change, save it (e.g. `git diff > ~/agenthub-update-backup.patch`) and say where; do not stop or restart the AgentHub server, and never kill tmux sessions or the tmux server (this session runs inside one).", + "When done, tell me what you changed; I will restart the dashboard from its update prompt.", + ].join("\n"); +} + /** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */ function isUpdatePayload(x: unknown): x is UpdateStatusPayload { return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x; @@ -130,7 +172,7 @@ export function UpdateNotifier() { const { t } = useTranslation("updates"); const [status, setStatus] = useState(null); const [dismissedSha, setDismissedSha] = useState(loadDismissedSha); - // Closed for THIS page load only (Escape, backdrop, ×, "Not now") — the + // Closed for THIS page load only (Escape, backdrop, ×) — the // persistent per-version dismiss is the explicit "Skip this version". const [closedKey, setClosedKey] = useState(null); const panelRef = useRef(null); @@ -139,6 +181,8 @@ export function UpdateNotifier() { const [checking, setChecking] = useState(false); const [applying, setApplying] = useState(false); const [restarting, setRestarting] = useState(false); + const [fixing, setFixing] = useState(false); + const navigate = useNavigate(); const syncFromPayload = useCallback((s: UpdateStatusPayload) => { setStatus(s); @@ -200,7 +244,7 @@ export function UpdateNotifier() { dismissedSha !== dismissKey && closedKey !== dismissKey ); - const busy = applying || restarting; + const busy = applying || restarting || fixing; const closeForNow = useCallback(() => { if (busy || !dismissKey) return; @@ -317,6 +361,29 @@ export function UpdateNotifier() { } }; + // Hand the failure to an AI agent: a workspace on the install folder (the + // home for a plugin install — never a lane inside the plugin cache) and a + // Claude Code session there with the error as its first prompt. + const autoFix = async () => { + if (busy || !status || !error) return; + setFixing(true); + try { + const plugin = status.situation === "plugin_managed"; + const { lane } = await api.lanes.ensure({ + ...(plugin || !status.repo_root ? {} : { cwd: status.repo_root, exact: true }), + title: t("autoFixWorkspace"), + tool: "claude", + }); + const runId = await startInFreeTab(lane.id, { prompt: autoFixPrompt(status, error) }); + if (dismissKey) setClosedKey(dismissKey); + navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`); + } catch (e) { + toast.error(e instanceof Error ? e.message : t("autoFixError")); + } finally { + setFixing(false); + } + }; + if (!show || !status) return null; const refLabel = status.remote_ref || "origin"; @@ -493,16 +560,9 @@ export function UpdateNotifier() { title={t("skipVersionHint")} className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed" > + {t("skipVersion")} - {status.manual_command ? ( + ) : null} {isAutoApplicable(status.situation) ? ( + {tab.runs.some(canSleep) && ( + + )} + {run && onSleep && canSleep(run) && ( + + )} {run && onStop && ( )} - {id.ssh ? ( + {keys.length ? ( <> -