feat(workspace): session view console as tabs, with New terminal
The 1/2/4 split view becomes a tab bar (ConsoleTabs.tsx): one tab per live tmux slot (0-3) of the workspace, the active one being ?run=, every live tab's pane kept mounted and hidden with CSS. The + menu opens a new tab in the first free slot: New terminal (a plain shell - shell: true on POST /api/lanes/:id/start, spawnShell, tool 'shell', no run_id/status change), any installed enabled AI tool started bare, or Settings -> Agents. A tab's x stops its session. splitViewStorage, &pane= and the sidebar's Ctrl/Cmd+click are gone.
This commit is contained in:
@@ -401,6 +401,42 @@ describe("lane start input", () => {
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// A Terminal tab: the user's own shell in the workspace folder, no AI tool —
|
||||
// so it is no agent at work and leaves the lane's run_id/status alone.
|
||||
it("opens a plain shell for `shell: true`, without marking the lane running", async () => {
|
||||
const tmux = require("../lib/tmux");
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-shell-"));
|
||||
const id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
|
||||
let created = null;
|
||||
try {
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session" && !created) throw new Error("no such session");
|
||||
if (args[0] === "new-session") created = args;
|
||||
return "";
|
||||
});
|
||||
const r = await request("POST", `/api/lanes/${id}/start`, {
|
||||
slot: 1,
|
||||
shell: true,
|
||||
prompt: "ignored",
|
||||
tool: "codex",
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.run_id, `agenthub-lane-${id}-1`);
|
||||
assert.equal(created[created.indexOf("-s") + 1], `agenthub-lane-${id}-1`);
|
||||
assert.equal(created[created.indexOf("-c") + 1], cwd);
|
||||
// Nothing after `--`: tmux runs the user's own default shell.
|
||||
assert.equal(created[created.length - 1], "--");
|
||||
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
|
||||
assert.equal(lane.run_id, null);
|
||||
assert.notEqual(lane.status, "running");
|
||||
const run = (await request("GET", `/api/run/agenthub-lane-${id}-1`)).body;
|
||||
assert.equal(run.tool, "shell");
|
||||
} finally {
|
||||
tmux.__reset();
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("hook → lane binding", () => {
|
||||
|
||||
+48
-3
@@ -22,9 +22,10 @@
|
||||
* Spawns `claude`, `codex` or `agy` (Antigravity) in the tmux pane based on
|
||||
* the tool parameter.
|
||||
*
|
||||
* A lane can hold up to four sessions, one per Workspace split pane: pane 0 is
|
||||
* `agenthub-lane-<laneId>` (the lane's own `run_id`), panes 1–3 are
|
||||
* `agenthub-lane-<laneId>-<slot>`.
|
||||
* A lane can hold up to four sessions, one per Workspace tab: slot 0 is
|
||||
* `agenthub-lane-<laneId>` (the lane's own `run_id`), slots 1–3 are
|
||||
* `agenthub-lane-<laneId>-<slot>`. A slot holds an AI tool (`spawnRun`) or a
|
||||
* plain shell (`spawnShell`, a Terminal tab).
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -498,6 +499,49 @@ function spawnRun(args) {
|
||||
return getRun(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* A Workspace "Terminal" tab: the user's own default shell (tmux's, run with
|
||||
* nothing after `--`) in a lane's pane slot. No AI tool, no prompt, nothing
|
||||
* from the request but the slot — the cwd is the lane's. Recorded as tool
|
||||
* `shell` so the tab can say what it is. The caller has already made sure the
|
||||
* slot holds no live session.
|
||||
*/
|
||||
function spawnShell({ laneId, slot = 0, cwd }) {
|
||||
if (!Number.isInteger(laneId)) throw makeErr("EBADLANE", "laneId must be an integer");
|
||||
if (!Number.isInteger(slot) || slot < 0 || slot > MAX_PANE_SLOT) {
|
||||
throw makeErr("EBADSLOT", `slot must be an integer 0–${MAX_PANE_SLOT}`);
|
||||
}
|
||||
if (!tmux.isTmuxAvailable()) throw makeErr("ETMUXMISSING", "tmux is not installed");
|
||||
const id = runIdForLane(laneId, slot);
|
||||
const env = {
|
||||
AGENTHUB_LANE_ID: String(laneId),
|
||||
AGENTHUB_RUN_ID: id,
|
||||
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
|
||||
...desktopEnv(),
|
||||
};
|
||||
tmux.newSession({ name: id, cwd, argv: [], env });
|
||||
dashboardRuns?.recordRun({
|
||||
id,
|
||||
sessionId: null,
|
||||
mode: "interactive",
|
||||
cwd,
|
||||
tool: "shell",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
worktree: false,
|
||||
router: null,
|
||||
prompt: "",
|
||||
status: "running",
|
||||
startedAt: Date.now(),
|
||||
endedAt: null,
|
||||
exitCode: null,
|
||||
laneId,
|
||||
});
|
||||
return getRun(id);
|
||||
}
|
||||
|
||||
// Only a dashboard lane pane is a run. Without this, `DELETE /api/run/main`
|
||||
// would kill the user's own tmux session named `main`.
|
||||
function killRun(id) {
|
||||
@@ -544,6 +588,7 @@ function listRuns() {
|
||||
|
||||
module.exports = {
|
||||
spawnRun,
|
||||
spawnShell,
|
||||
killRun,
|
||||
getRun,
|
||||
listRuns,
|
||||
|
||||
@@ -1785,6 +1785,31 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
error: { code: "EBADSLOT", message: `slot must be an integer 0–${runs.MAX_PANE_SLOT}` },
|
||||
});
|
||||
}
|
||||
// A Terminal tab: a plain shell in the slot. No agent is at work in
|
||||
// it, so the lane's run_id/status stay as they are.
|
||||
if (body.shell === true) {
|
||||
const shellOutcome = await withLaneLock(lane.id, async () => {
|
||||
const current = lanesLib.getLane(lane.id);
|
||||
if (!current) return { missing: true };
|
||||
const id = runs.runIdForLane(current.id, slot);
|
||||
if (runs.getRun(id)?.status === "running") return { conflict: true };
|
||||
startedRunId = runs.spawnShell({ laneId: current.id, slot, cwd: current.cwd }).id;
|
||||
return {};
|
||||
});
|
||||
if (shellOutcome.missing) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
}
|
||||
if (shellOutcome.conflict) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({
|
||||
error: { code: "ERUNLIVE", message: "that tab already has a live session" },
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
// A session that ran in a worktree of its own resumes (or forks) THERE:
|
||||
// Claude finds a session by the folder it ran in. Resolved from the
|
||||
// session row, before the lock; anything else starts as it always did.
|
||||
|
||||
Reference in New Issue
Block a user