diff --git a/client/src/components/run/LaneConsolePane.tsx b/client/src/components/run/LaneConsolePane.tsx index 8b517377..8cae7645 100644 --- a/client/src/components/run/LaneConsolePane.tsx +++ b/client/src/components/run/LaneConsolePane.tsx @@ -15,6 +15,15 @@ * swaps the whole pane over to the new lane — its cwd, its tool, its history, * and its live tmux session — instead of leaving the previous lane's terminal * on screen under a new lane's header. + * + * A lane's pane is one tmux session, so starting or resuming into a lane + * already holding a live run answers 409 ERUNLIVE rather than overwriting a + * prompt in flight — `onStartFromSetup` and `onResumeFromHistory` both catch + * that and attach to the still-live session instead of failing outright + * (with a notice explaining what happened), since that session is still + * reachable and the user did not ask to kill it. `killRun` is the other half: + * the Active runs list's Kill button (`RunHistory.tsx`), for when the user + * DOES want that previous session gone so a fresh start/resume can proceed. * @author Nguyễn Ngọc Trí Vĩ */ @@ -246,12 +255,16 @@ export function LaneConsolePane({ resumeLast: args.resumeLast, }); } catch (laneErr: unknown) { - const msg = laneErr instanceof Error ? laneErr.message : String(laneErr); - if (msg.includes("409") || msg.includes("ERUNLIVE")) { + // A code-only check: the server's message is human prose ("lane + // already has a live run"), never the literal strings "409" or + // "ERUNLIVE" — matching against `.message` here is dead code that + // always falls through to the raw error below. + if ((laneErr as { code?: string })?.code === "ERUNLIVE") { const fresh = await api.lanes.list().catch(() => null); const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId); if (updatedLane?.run_id) { await attachToRun(updatedLane.run_id); + setError(t("errors.attachedToLiveInstead")); return; } } @@ -307,13 +320,34 @@ export function LaneConsolePane({ onLaneCreated(ensureResult.lane); } - const laneStartResult = await api.lanes.action(targetLaneId, "start", { - prompt: "", - model: item.model || undefined, - permissionMode: item.permission_mode || undefined, - effort: item.effort || undefined, - resumeSessionId: item.session_id, - }); + let laneStartResult; + try { + laneStartResult = await api.lanes.action(targetLaneId, "start", { + prompt: "", + model: item.model || undefined, + permissionMode: item.permission_mode || undefined, + effort: item.effort || undefined, + resumeSessionId: item.session_id, + }); + } catch (laneErr: unknown) { + // The lane's own pane is still busy with a PREVIOUS live session — + // that session is still active and reachable, so land on it + // instead of failing outright. Killing it (Active runs → Kill) is + // what actually frees the lane for this resume. Checked by `.code` + // — the server's `.message` is prose, never the literal string + // "ERUNLIVE" or "409". + if ((laneErr as { code?: string })?.code === "ERUNLIVE") { + const fresh = await api.lanes.list().catch(() => null); + const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId); + if (updatedLane?.run_id) { + onLaneIdChange(targetLaneId); + await attachToRun(updatedLane.run_id); + setError(t("errors.attachedToLiveInstead")); + return; + } + } + throw laneErr; + } if (!laneStartResult.lane?.run_id) { throw new Error("No run_id returned from lane start"); @@ -343,7 +377,7 @@ export function LaneConsolePane({ setBusy(null); } }, - [busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange] + [busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange, attachToRun] ); const onViewFromHistory = useCallback( @@ -360,6 +394,28 @@ export function LaneConsolePane({ setError(null); }, []); + // Frees a lane blocked by a live run nobody wants anymore (see Active + // runs' Kill button) — the lane's own `run_id` self-heals to null the next + // time it's read (routes/lanes.js's healRunId), so no lane-side call here. + const killRun = useCallback( + async (id: string) => { + setBusy("kill"); + setError(null); + try { + await api.run.kill(id); + if (handle?.id === id) newRun(); + refreshList(); + } catch (err) { + setError( + t("errors.killFailed", { message: err instanceof Error ? err.message : "unknown" }) + ); + } finally { + setBusy(null); + } + }, + [handle, newRun, refreshList, t] + ); + if (laneId === null && showLaneSelector) { return (
@@ -438,6 +494,7 @@ export function LaneConsolePane({ externalSessions={externalSessions} onResumeFromHistory={onResumeFromHistory} onViewFromHistory={onViewFromHistory} + onKill={killRun} onRefresh={refreshList} /> diff --git a/client/src/components/run/RunHistory.tsx b/client/src/components/run/RunHistory.tsx index 91b8c38d..f9fc623f 100644 --- a/client/src/components/run/RunHistory.tsx +++ b/client/src/components/run/RunHistory.tsx @@ -10,18 +10,23 @@ * - `RunsModal` — the merged list itself: live in-memory handles from * `activeRuns` deduped against persistent `runHistory`, newest first, with * status / mode chip filters, a free-text search, and the per-row Attach / - * Resume / View actions. + * Resume / View / Kill actions. * * `externalSessions` (active Claude Code sessions this dashboard did NOT spawn — * e.g. `claude` started by hand in a terminal tab) are merged in as live rows so * "Active runs" counts everything actually running. They carry no tmux session * the dashboard can attach to, so their only action is Resume, which spawns a - * fresh tmux-backed `claude --resume ` in that cwd. + * fresh tmux-backed `claude --resume ` in that cwd — Kill is withheld + * from them for the same reason. * * Props only: no API call of its own. The page passes `activeRuns`, * `runHistory` and `externalSessions` in and gets attach / resume / view / - * refresh back out through callbacks; the 2 s refresh ticker the modal runs - * just calls `onRefresh`. + * kill / refresh back out through callbacks; the 2 s refresh ticker the modal + * runs just calls `onRefresh`. Kill (two clicks, same confirm convention as + * discarding a file in the Changes panel) is what actually frees a lane whose + * pane is still busy with a previous session — `LaneConsolePane` needs it + * because starting or resuming into a lane already holding a live run answers + * 409 ERUNLIVE rather than overwriting a prompt in flight. * * @author Nguyễn Ngọc Trí Vĩ */ @@ -39,6 +44,7 @@ import { Search, RotateCcw, Eye, + Square, } from "lucide-react"; import type { DashboardRunHistoryItem, RunListResponse, RunStatus } from "../../lib/api"; import type { Session } from "../../lib/types"; @@ -96,6 +102,7 @@ export function ActiveRunsSwitcher({ externalSessions = [], onResumeFromHistory, onViewFromHistory, + onKill, onRefresh, }: { activeRuns: RunListResponse | null; @@ -107,6 +114,10 @@ export function ActiveRunsSwitcher({ externalSessions?: Session[]; onResumeFromHistory: (item: DashboardRunHistoryItem) => void; onViewFromHistory: (item: DashboardRunHistoryItem) => void; + /** Kills the row's tmux session (DELETE /api/run/:id). Live, dashboard- + * spawned rows only — an external session has no tmux session here to + * kill. */ + onKill: (id: string) => void; onRefresh: () => void; }) { const { t } = useTranslation("run"); @@ -258,6 +269,7 @@ export function ActiveRunsSwitcher({ setOpen(false); onViewFromHistory(item); }} + onKill={onKill} runHistory={historyItems} onClose={() => setOpen(false)} onRefresh={onRefresh} @@ -273,6 +285,7 @@ export function RunsModal({ onAttach, onResume, onView, + onKill, runHistory, onClose, onRefresh, @@ -282,6 +295,7 @@ export function RunsModal({ onAttach: (id: string) => void; onResume: (item: DashboardRunHistoryItem) => void; onView: (item: DashboardRunHistoryItem) => void; + onKill: (id: string) => void; runHistory: DashboardRunHistoryItem[]; onClose: () => void; onRefresh: () => void; @@ -446,6 +460,7 @@ export function RunsModal({ const h = historyById.get(r.id); if (h) onView(h); }} + onKill={() => onKill(r.id)} /> ); }) @@ -512,14 +527,20 @@ function UnifiedRunRowView({ onAttach, onResume, onView, + onKill, }: { row: UnifiedRunRow; isCurrent: boolean; onAttach: () => void; onResume: () => void; onView: () => void; + onKill: () => void; }) { const { t } = useTranslation("run"); + // Two clicks before the tmux session actually dies — same convention as + // discarding a file's changes in the Changes panel, for the one action + // here that cannot be undone. + const [confirmKill, setConfirmKill] = useState(false); const startedDate = new Date(row.startedAt); // 0 is "unknown", not the epoch — a run whose persisted row is missing has no // start time, and printing "Jan 1, 08:00 AM" for it reads as real data. @@ -593,6 +614,32 @@ function UnifiedRunRowView({ {t("runs.viewLabel", "View")} )} + {row.isLive && !row.external && ( + + )}
{row.promptPreview && ( diff --git a/client/src/components/run/RunSetup.tsx b/client/src/components/run/RunSetup.tsx index 2a3340f4..2f77d885 100644 --- a/client/src/components/run/RunSetup.tsx +++ b/client/src/components/run/RunSetup.tsx @@ -77,7 +77,9 @@ interface PromptEditorProps { function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: PromptEditorProps) { const handleKeyDown = (e: React.KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { + // Enter sends (Ctrl/Cmd+Enter still does); Shift+Enter inserts a newline. + // isComposing guards IME input (Vietnamese/CJK), where Enter commits the candidate. + if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); onSubmit(); } diff --git a/client/src/components/run/__tests__/LaneConsolePane.test.tsx b/client/src/components/run/__tests__/LaneConsolePane.test.tsx index 6bd314fd..d412205b 100644 --- a/client/src/components/run/__tests__/LaneConsolePane.test.tsx +++ b/client/src/components/run/__tests__/LaneConsolePane.test.tsx @@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; import { LaneConsolePane } from "../LaneConsolePane"; import { api } from "../../../lib/api"; import type { Lane } from "../../../lib/types"; @@ -266,6 +267,64 @@ describe("LaneConsolePane", () => { await waitFor(() => expect(prompt.value).toBe("add a button")); }); + it("attaches to the lane's still-live session instead of failing when Resume hits ERUNLIVE", async () => { + const HISTORY_ITEM = { + id: "run-old", + session_id: "sess-old", + cwd: "/workspace/a", + model: null, + permission_mode: null, + effort: null, + resume_session_id: null, + prompt_preview: "earlier prompt", + status: "abandoned" as const, + exit_code: null, + started_at: new Date(1000).toISOString(), + ended_at: new Date(2000).toISOString(), + isLive: false, + tool: "claude" as const, + }; + (api.run.history as ReturnType).mockResolvedValue({ items: [HISTORY_ITEM] }); + (api.lanes.action as ReturnType).mockRejectedValue( + // Matches the real shape `request()` throws: a plain, human-readable + // `.message` plus a `.code` a caller actually branches on. + Object.assign(new Error("lane already has a live run"), { code: "ERUNLIVE", status: 409 }) + ); + (api.lanes.list as ReturnType).mockResolvedValue({ + lanes: [{ ...LANE, run_id: "run-live" }], + }); + (api.run.get as ReturnType).mockResolvedValue({ + id: "run-live", + laneId: 1, + status: "running", + cwd: "/workspace/a", + model: null, + permissionMode: null, + effort: null, + resumeSessionId: null, + sessionId: null, + startedAt: null, + promptPreview: null, + }); + + render( + + + + ); + + fireEvent.click(await screen.findByRole("button", { name: /active runs/i })); + const resumeButtons = await screen.findAllByRole("button", { + name: "Resume existing session", + }); + fireEvent.click(resumeButtons[0]!); + + await waitFor(() => + expect(screen.getByTestId("terminal-view")).toHaveAttribute("data-run-id", "run-live") + ); + expect(screen.getByText(/attached to it instead/i)).toBeInTheDocument(); + }); + it("renders an empty placeholder with selector when laneId is null but showLaneSelector is true (split-view)", () => { render(); expect(screen.getByTestId("pane-empty")).toBeInTheDocument(); diff --git a/client/src/components/run/__tests__/RunHistory.test.tsx b/client/src/components/run/__tests__/RunHistory.test.tsx index 5a0bfdee..01e5963f 100644 --- a/client/src/components/run/__tests__/RunHistory.test.tsx +++ b/client/src/components/run/__tests__/RunHistory.test.tsx @@ -88,6 +88,7 @@ function renderSwitcher(overrides: Partial { expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID); }); + it("needs two clicks to kill a live row, and never offers Kill for an external one", () => { + const { spies, unmount } = renderModal([row(LIVE_ID, { isLive: true, status: "running" })]); + const killButton = screen.getByText(i18n.t("run:runs.killLabel", "Kill")); + fireEvent.click(killButton); + expect(spies.onKill).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText(i18n.t("run:runs.killConfirmShort", "Sure?"))); + expect(spies.onKill).toHaveBeenCalledWith(LIVE_ID); + unmount(); + + renderModal([row(LIVE_ID, { isLive: true, status: "running", external: true })]); + expect(screen.queryByText(i18n.t("run:runs.killLabel", "Kill"))).toBeNull(); + }); + it("fires resume with the history item behind a finished row", () => { const { spies } = renderModal([row(PAST_ID)]); fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption"))); diff --git a/client/src/i18n/locales/en/run.json b/client/src/i18n/locales/en/run.json index 0c86810d..644e62fb 100644 --- a/client/src/i18n/locales/en/run.json +++ b/client/src/i18n/locales/en/run.json @@ -34,6 +34,7 @@ }, "errors": { "attachFailed": "Couldn't attach to run: {{message}}", + "attachedToLiveInstead": "This lane's previous session is still running, so nothing new could start — attached to it instead. Kill it from Active runs to start fresh.", "cwdRequired": "Working directory required to start a run", "killFailed": "Couldn't stop: {{message}}", "laneCreateFailed": "Failed to create lane: {{message}}", @@ -97,7 +98,7 @@ "headlessExplain": "Headless mode is best for scripted tasks where you know exactly what you want. The session can't ask follow-up questions and will hang on permission prompts unless you stay in acceptEdits.", "permissionWarning": "bypassPermissions allows ALL tool invocations without confirming. Only use for trusted prompts.", "sandboxWarning": "Codex will run commands with no sandbox.", - "shortcut": "Cmd+Enter / Ctrl+Enter to send" + "shortcut": "Enter to send · Shift+Enter for newline" }, "limitations": { "dismiss": "Got it" diff --git a/client/src/i18n/locales/vi/run.json b/client/src/i18n/locales/vi/run.json index 4784bee9..e3a57d6d 100644 --- a/client/src/i18n/locales/vi/run.json +++ b/client/src/i18n/locales/vi/run.json @@ -33,6 +33,7 @@ }, "errors": { "attachFailed": "Không gắn được vào run: {{message}}", + "attachedToLiveInstead": "Phiên trước của làn đường này vẫn đang chạy nên không thể bắt đầu phiên mới — đã gắn vào phiên đang chạy thay vào đó. Bấm Kill trong Active runs để bắt đầu lại từ đầu.", "cwdRequired": "Cần thư mục làm việc để bắt đầu run", "killFailed": "Không dừng được: {{message}}", "laneCreateFailed": "Không thể tạo làn đường: {{message}}", @@ -96,7 +97,7 @@ "headlessExplain": "Headless phù hợp cho tác vụ tự động. Phiên không thể hỏi lại, sẽ treo ở prompt xin quyền nếu bạn không giữ acceptEdits.", "permissionWarning": "bypassPermissions cho phép gọi mọi tool không cần xác nhận. Chỉ dùng cho prompt bạn tin tưởng.", "sandboxWarning": "Codex sẽ chạy lệnh mà không có sandbox.", - "shortcut": "Cmd+Enter / Ctrl+Enter để gửi" + "shortcut": "Enter để gửi · Shift+Enter xuống dòng" }, "limitations": { "dismiss": "Đã rõ" diff --git a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap index 36231bc5..6cf693ed 100644 --- a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap +++ b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap @@ -5578,7 +5578,7 @@ exports[`screen snapshots > Run 1`] = `
- Cmd+Enter / Ctrl+Enter to send + Enter to send · Shift+Enter for newline · / for slash commands · @ for file references
@@ -9617,7 +9617,7 @@ exports[`screen snapshots > Settings 1`] = `

- UI build v1.8.1 (rebuild client to match) + UI build v1.8.2 (rebuild client to match)

` in that folder. The original terminal keeps running; resuming gives you a second Claude Code process on the same transcript, not a view of the first one. +Live, dashboard-spawned rows (not external — there is no tmux session to kill for those) also carry a **Kill** button, `DELETE /api/run/:id` behind a two-click confirm. This is how you free a lane stuck on a previous live session when you actually want it gone, rather than attached to. + **Start and Resume are create-or-reuse, and never silently swallow the request.** When the lane's `agenthub-lane-` tmux session does not exist, it is created with the full argv. When it exists but its pane is sitting at a **shell prompt** (a `agenthub lanes shell` you opened, or a `claude` that has since exited), the argv is typed into that pane — so `--resume` really runs, and an initial prompt really lands, in the session you are already looking at. Only when the pane is running something (a live `claude`, an editor, a build) is the request adopted as-is: attaching shows you what is running rather than typing over it. Before this, an existing session was always adopted silently, so the first Resume after a `agenthub lanes shell` answered `200` while doing nothing at all. +**A lane whose `run_id` still points at a live run refuses a second Start/Resume outright** — `POST /api/lanes/:id/start` answers `409 ERUNLIVE` rather than typing a new prompt or `--resume` over a session that is still in flight. The console does not surface that raw error: `LaneConsolePane` catches it, re-reads the lane, and attaches to the run it is already holding instead — with a notice explaining that the previous session is still live, so nothing new started. Killing that session (Active runs list → **Kill**, two clicks, the same confirm convention as discarding a file in the Changes panel) is what actually frees the lane for a fresh Start or a different Resume; the kill goes straight through `DELETE /api/run/:id`, and the lane's own `run_id`/`status` self-heal to idle the next time anything reads the lane (`healRunId` in `routes/lanes.js`), so no separate lane-side call is needed. + The UI operates on a working directory (`cwd`), not a lane id. Starting a run in a `cwd` that no lane owns calls `POST /api/lanes/ensure` first, to idempotently find or adopt a lane for that path; a `cwd` an existing lane already owns is matched from the loaded lane list without a round trip. Either way the run is then started through `POST /api/lanes/:id/start` rather than directly through `POST /api/run`. ### Finding or adopting a lane: `POST /api/lanes/ensure`