feat(run): Enter sends the prompt, and a busy lane attaches instead of failing

- The prompt box sends on Enter (Ctrl/Cmd+Enter still works); Shift+Enter
  inserts a newline, and IME composition (Vietnamese/CJK) never sends.
- Starting or resuming into a lane that still holds a live run answered a
  raw 409 ERUNLIVE; the console now attaches to that run with a notice.
  Checked by error code, since the server's message is prose.
- Active runs gains a two-click Kill for live dashboard-spawned rows, the
  way to free a lane when the previous session really should go.
This commit is contained in:
2026-09-23 13:23:20 +07:00
parent 1f5c04e765
commit 3f50e162e6
9 changed files with 205 additions and 19 deletions
+67 -10
View File
@@ -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ĩ <vinnt@smartgift.vn>
*/
@@ -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 (
<div data-testid="pane-empty" className="flex min-h-0 flex-1 flex-col gap-2 p-4">
@@ -438,6 +494,7 @@ export function LaneConsolePane({
externalSessions={externalSessions}
onResumeFromHistory={onResumeFromHistory}
onViewFromHistory={onViewFromHistory}
onKill={killRun}
onRefresh={refreshList}
/>
</header>
+51 -4
View File
@@ -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 <session>` in that cwd.
* fresh tmux-backed `claude --resume <session>` 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ĩ <vinnt@smartgift.vn>
*/
@@ -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")}
</button>
)}
{row.isLive && !row.external && (
<button
onClick={() => {
if (!confirmKill) {
setConfirmKill(true);
return;
}
setConfirmKill(false);
onKill();
}}
onBlur={() => setConfirmKill(false)}
title={
confirmKill
? t("runs.killConfirm", "Click again to kill this session — cannot be undone")
: t("runs.killLabel", "Kill")
}
className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[10.5px] font-medium transition-colors ${
confirmKill
? "border-status-danger/50 bg-status-danger/15 text-status-danger"
: "border-border bg-surface-2 hover:border-status-danger/40 hover:bg-status-danger/10 text-fg-secondary hover:text-status-danger"
}`}
>
<Square className="w-3 h-3" />
{confirmKill ? t("runs.killConfirmShort", "Sure?") : t("runs.killLabel", "Kill")}
</button>
)}
</span>
</div>
{row.promptPreview && (
+3 -1
View File
@@ -77,7 +77,9 @@ interface PromptEditorProps {
function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: PromptEditorProps) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
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();
}
@@ -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<typeof vi.fn>).mockResolvedValue({ items: [HISTORY_ITEM] });
(api.lanes.action as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue({
lanes: [{ ...LANE, run_id: "run-live" }],
});
(api.run.get as ReturnType<typeof vi.fn>).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(
<MemoryRouter>
<LaneConsolePane {...baseProps()} />
</MemoryRouter>
);
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(<LaneConsolePane {...baseProps()} laneId={null} showLaneSelector={true} />);
expect(screen.getByTestId("pane-empty")).toBeInTheDocument();
@@ -88,6 +88,7 @@ function renderSwitcher(overrides: Partial<React.ComponentProps<typeof ActiveRun
onAttach: vi.fn(),
onResumeFromHistory: vi.fn(),
onViewFromHistory: vi.fn(),
onKill: vi.fn(),
onRefresh: vi.fn(),
};
const utils = render(
@@ -127,6 +128,7 @@ function renderModal(
onAttach: vi.fn(),
onResume: vi.fn(),
onView: vi.fn(),
onKill: vi.fn(),
onClose: vi.fn(),
onRefresh: vi.fn(),
};
@@ -274,6 +276,19 @@ describe("RunsModal", () => {
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")));
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -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õ"
@@ -5578,7 +5578,7 @@ exports[`screen snapshots > Run 1`] = `
<div
class="mt-1 text-[10px] text-fg-muted"
>
Cmd+Enter / Ctrl+Enter to send
Enter to send · Shift+Enter for newline
· / for slash commands · @ for file references
</div>
</div>
@@ -9617,7 +9617,7 @@ exports[`screen snapshots > Settings 1`] = `
<p
class="text-[10px] text-status-warning/90 mt-1"
>
UI build v1.8.1 (rebuild client to match)
UI build v1.8.2 (rebuild client to match)
</p>
</div>
<div
+4
View File
@@ -444,8 +444,12 @@ An external session is deduped against a dashboard run with the same `session_id
External rows have **no Attach action**: the dashboard owns no tmux session for them, so there is no PTY to bridge. Their action is **Resume**, which does what resuming from history does — `POST /api/lanes/ensure` for the session's `cwd`, then `POST /api/lanes/:id/start` with `resumeSessionId` — spawning a *new* tmux-backed `claude --resume <session>` 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-<id>` 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`