Compare commits

..

13 Commits

Author SHA1 Message Date
nntrivi2001 1294ce1faa chore: v2.8.58 — Preview tab in the session panel 2026-11-02 15:06:43 +07:00
nntrivi2001 5d62003fe5 feat(workspace): Preview tab in the session panel; local links open there
The session panel's header becomes two tabs, Changes and Preview. Preview
shows a web page by address (Reload, Open in a browser tab, the workspace's
ports as chips, last address per workspace) in an iframe on the page's own
origin: http(s) only, no userinfo, never the dashboard's origin, no top
navigation. One rule for every link (lib/openInPreview.ts): a chat or
terminal link to localhost/127.0.0.1/0.0.0.0/[::1] opens in Preview while a
session panel answers and the dashboard is on loopback; anything else, or a
Ctrl/Cmd click, opens a browser tab as before.
2026-11-02 15:06:04 +07:00
nntrivi2001 12d53afaeb fix(changes): long branch name truncates; armed Discard matches the row icons
The branch name was shrink-0, so a long one ran over the head sha and the
action icons. It now truncates after the commit subject (full name in the
tooltip). The armed per-file Discard is the trash icon plus a small red
word on a faint tint, sized like its neighbours, instead of a bordered chip.
2026-11-02 14:44:23 +07:00
nntrivi2001 4263b3eb26 chore: v2.8.57 — chat column widens to 64rem 2026-11-02 14:42:01 +07:00
nntrivi2001 d58f13e1ce fix(chat): conversation column widens to 64rem instead of 48rem
A wide pane left the chat squeezed into a 768px column with empty space
either side, so a wide table was cut off. The message list, the prompt
box and the session replay now share max-w-5xl.
2026-11-02 14:41:56 +07:00
nntrivi2001 81b3cbda65 chore: v2.8.56 — new tab / split pane starts in the Session defaults 2026-11-02 13:34:36 +07:00
nntrivi2001 0a99eed369 fix(workspace): a new tab or split pane starts in the account's Session defaults
newTabBody sent only prompt/tool/model, so a Claude tab opened from the tab
bar + or a split menu always started in auto mode even with Bypass
permissions set as the default; a Codex one ignored its sandbox, approval
and YOLO defaults. It now sends the same defaults the workspace home's
prompt box starts from.
2026-11-02 13:34:10 +07:00
nntrivi2001 1ec9f2e592 chore: v2.8.55 — chat + menu no longer covered by the controls under the prompt box 2026-11-02 13:30:33 +07:00
nntrivi2001 a401a318c5 fix(chat): + menu paints above the controls row under the prompt box
Opened downward, the panel had no z-index, so the AI Proxy / Permission /
Local selects (positioned, later in the DOM) drew over its first rows.
2026-11-02 13:29:08 +07:00
nntrivi2001 5caad0df2d chore: v2.8.54 — chat Stop shows while Claude thinks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:29:05 +07:00
nntrivi2001 01e66ba122 fix(chat): Stop shows while Claude thinks, not only while its footer says "esc to interrupt"
The chat's live line said "Thinking… 21s" while the box offered Send, so the
turn could not be stopped. Stop shows only when a hook marked main "working"
or the pane's busy line matched, and Claude Code 2.1.3xx writes
"esc to interrupt" only in its footer, which a narrow pane, a tip or a notice
replaces. Claude's busy line now also matches its spinner (verb… (41s · …)),
which the line a finished turn leaves ("Cooked for 1m 2s") does not; and the
chat re-reads "working" on transcript_changed, so a hook that reached another
dashboard on the same database no longer leaves Stop hidden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:29:05 +07:00
nntrivi2001 923c1635b4 chore: v2.8.53 — chat + menu opens downward when there is no room above
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:25:38 +07:00
nntrivi2001 c37bc5205b fix(chat): + menu opens downward when the prompt box sits too near the top
On the workspace home the box is high on the screen, so the menu and its
submenus (Project files) opened upward and were clipped off the top.
Measure the room above/below (viewport narrowed by clipping ancestors) at
open and drop the menu below the button, submenus top-aligned, when it fits better.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:25:05 +07:00
24 changed files with 602 additions and 40 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: v2.8.52 (2026-11-02) — git: shelves, branches & tags, history graph + multi cherry-pick, merge editor, rebase/reset/force-with-lease (47 actions)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
_Last synced: v2.8.58 (2026-11-02) — the session panel gains a Preview tab; local links open there._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -65,6 +65,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
- **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history. Billing has one more owner: a session NO workspace owns belongs to the user of the Linux account it ran as (`sessions.account` — the hook's peer, else its folder; `server/lib/session-account.js`, the host's account → the admin marked `users.host_account`, else the only admin), so a script's `claude -p` counts in Today, the Users page and spend quotas — a workspace's ownership always wins over it.
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
- **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed.
- **A web page is previewed by URL in the session panel's Preview tab, never on the dashboard's origin, and every link follows ONE rule.** `client/src/components/lanes/UrlPreview.tsx` shows an `<iframe src>` whose sandbox has no `allow-top-navigation*`; `normalizePreviewUrl` (`client/src/lib/openInPreview.ts`) takes http(s) only, no userinfo, and refuses the dashboard's own origin (with `allow-same-origin` that frame would BE the dashboard). A chat link, bare URL or terminal link to `localhost`/`127.0.0.1`/`0.0.0.0`/`[::1]` opens there only while a session panel answers the event and the dashboard itself is on loopback; anything else — and any Ctrl/Cmd click — opens a browser tab. Do not add a second preview surface or a per-place button, and do not proxy a page through the dashboard's origin.
- **A chat artifact runs in its own sandbox, never on the dashboard's origin.** The artifact panel (`client/src/components/run/ChatFilePreview.tsx`, `client/src/lib/artifactView.ts`) runs model-written HTML/Mermaid/React in an `<iframe srcdoc sandbox="allow-scripts …">` and an SVG in `sandbox=""` — never add `allow-same-origin` (the script would read the dashboard's cookies and call its API as the user), never render one with `dangerouslySetInnerHTML`, and keep its CDN libraries pinned to exact versions. An HTML FILE opened from the Changes panel, a Chat file preview or the File Explorer runs in the same sandbox (`client/src/components/HtmlFrame.tsx`, shown in a modal — `HtmlViewerModal.tsx`, Chat's `FilePreview modal`); its relative CSS/scripts/images are fetched by the DASHBOARD through the existing download routes and inlined (`client/src/lib/htmlInline.ts`) — never add a route that serves a folder to the frame without the login cookie. Versions are rebuilt from Write/Edit inputs (kept up to 256 KB by `server/lib/transcript-message.js`); no copy is stored, and Save to Artifacts uses the Hub's own `pinFile`/upload routes.
- **A `cwd` can back at most one lane per tool, not one lane overall.** Uniqueness is the composite `idx_lanes_cwd_tool` (`cwd`, `tool`), not `cwd` alone — Repo/Adopt mode (`POST /api/lanes/ensure`) lets the same directory carry a `claude` lane and a `codex` lane side by side, shown as two separate cards. `resolveLaneByCwd(cwd, tool)` takes an optional `tool` filter for exactly this; hook ingestion (`touchLaneFromHook`) pins it to the hook's own tool — `claude`, `codex` for a hook posted with `tool: "codex"`, or `antigravity` for one posted with `tool: "antigravity"` (the Codex and Antigravity hook rules above) — so a hook event never touches the other tool's lane sharing the path, and stage detection (`resolveLaneForWork` + `detect`) runs for `claude` only. Worktree mode is unaffected — it already allocates a unique directory per lane.
@@ -63,6 +63,7 @@
import React from "react";
import { CodeBlock } from "./CodeBlock";
import { onLinkClick } from "../../lib/openInPreview";
type Block =
| { kind: "code"; lang: string; code: string }
@@ -305,7 +306,13 @@ const LABEL_CLASS = (word: string) =>
function UrlLink({ url }: { url: string }) {
return (
<a href={url} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => onLinkClick(e, url)}
className={LINK_CLASS}
>
{url}
</a>
);
@@ -466,6 +473,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => onLinkClick(e, href)}
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
>
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
@@ -865,7 +865,7 @@ function MessageListView({
className={
compact
? "space-y-3 py-1 pr-2"
: "mx-auto flex w-full max-w-3xl flex-col gap-3 px-4 py-6"
: "mx-auto flex w-full max-w-5xl flex-col gap-3 px-4 py-6"
}
>
{items.map((item) => {
@@ -489,8 +489,9 @@ function FileActions({
title={t("changes.discardConfirm")}
disabled={busy}
onClick={() => onAct("discard", f.path, true)}
className={`${HUNK_CHIP} border-status-danger bg-status-danger/20 text-status-danger`}
className="flex shrink-0 items-center gap-1 rounded bg-status-danger/10 px-1 py-0.5 text-[11px] leading-3 text-status-danger hover:bg-status-danger/20 disabled:opacity-40"
>
<Trash2 className="h-3 w-3" />
{t("changes.discardShortConfirm")}
</button>
) : (
@@ -373,12 +373,17 @@ export function LaneGitHeader({
className="flex min-w-0 flex-1 items-baseline gap-1.5 font-mono text-[11px]"
title={`${git.head} ${git.subject}`}
>
<span className="flex shrink-0 items-center gap-1 self-center font-semibold text-fg-primary">
<GitBranch className="h-3 w-3 text-fg-muted" />
{git.branch}
{/* A long branch name truncates too (after the subject has gone),
or it overflowed onto the head and the action icons. */}
<span
className="flex min-w-[5rem] items-center gap-1 self-center font-semibold text-fg-primary"
title={git.branch}
>
<GitBranch className="h-3 w-3 shrink-0 text-fg-muted" />
<span className="truncate">{git.branch}</span>
</span>
<span className="shrink-0 text-fg-muted">{git.head}</span>
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
<span className="min-w-0 shrink-[999] truncate text-fg-muted">{git.subject}</span>
</div>
</>
);
@@ -145,7 +145,7 @@ export function SessionReplay({
{livePane ? (
<div
data-testid="session-replay-live"
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
className="mx-auto flex w-full max-w-5xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
>
<span className="min-w-0 flex-1">{t("replay.live")}</span>
<button
@@ -160,7 +160,7 @@ export function SessionReplay({
) : session && !resumable ? (
<div
data-testid="session-replay-no-resume"
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
className="mx-auto flex w-full max-w-5xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
>
<span className="min-w-0 flex-1">{t("replay.noResume", { tool })}</span>
<button
@@ -172,7 +172,7 @@ export function SessionReplay({
</button>
</div>
) : (
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
<div className="mx-auto w-full max-w-5xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
<PromptEditor
bare
images={images}
+159
View File
@@ -0,0 +1,159 @@
/**
* @file UrlPreview.tsx
* @description The session panel's Preview tab: a web page (a lane's dev
* server, mostly) shown beside the console. One bar — address, Reload, Open
* in a browser tab — and, before anything is open, the workspace's own ports
* as one-click chips. The page runs in an `<iframe src>` on ITS OWN origin:
* `normalizePreviewUrl` refuses the dashboard's origin and anything but
* http(s), and the sandbox leaves out top navigation so a page cannot move
* the dashboard away. A site that forbids framing (most login pages) shows
* blank; that is the browser's rule, so the way out is the Open button. The
* last address is remembered per workspace in localStorage (a viewer
* convenience, never the server's).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ExternalLink, Globe, RotateCw } from "lucide-react";
import { normalizePreviewUrl } from "../../lib/openInPreview";
import { isSubmitEnter } from "../../lib/keys";
import { toast } from "../../lib/toast";
const KEY = (laneId: number) => `agenthub.preview.url.${laneId}`;
function readSaved(laneId: number): string {
try {
return window.localStorage.getItem(KEY(laneId)) ?? "";
} catch {
return "";
}
}
/** The frame's sandbox: everything a web app needs, minus moving the
* dashboard (`allow-top-navigation*`). `allow-same-origin` is the PAGE's
* own origin — never the dashboard's, which `normalizePreviewUrl` refuses. */
const FRAME_SANDBOX =
"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads";
export function UrlPreview({
laneId,
ports,
request,
}: {
laneId: number;
/** The lane's bound ports, by name — the chips of the empty state. */
ports: Record<string, number>;
/** A link asked for this page (`openInPreview`); a new nonce re-opens it. */
request: { url: string; nonce: number } | null;
}) {
const { t } = useTranslation("lanes");
const [url, setUrl] = useState(() => readSaved(laneId));
const [draft, setDraft] = useState(url);
const [reload, setReload] = useState(0);
// Another workspace: its own last page.
useEffect(() => {
const saved = readSaved(laneId);
setUrl(saved);
setDraft(saved);
}, [laneId]);
useEffect(() => {
if (request) go(request.url);
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the nonce
}, [request?.nonce]);
function go(raw: string) {
const next = normalizePreviewUrl(raw);
if (!next) {
toast.error(t("preview.badUrl"));
return;
}
setUrl(next);
setDraft(next);
setReload((n) => n + 1);
try {
window.localStorage.setItem(KEY(laneId), next);
} catch {
/* disabled storage: the tab just forgets between visits */
}
}
const portList = Object.entries(ports ?? {}).filter(([, p]) => Number.isInteger(p) && p > 0);
const btn =
"flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40 disabled:hover:bg-transparent";
return (
<div className="flex min-h-0 flex-1 flex-col" data-testid="url-preview">
<div className="flex shrink-0 items-center gap-1 border-b border-border px-2 py-1.5">
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
go(draft);
}
}}
placeholder="localhost:3000"
aria-label={t("preview.address")}
spellCheck={false}
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<button
type="button"
className={btn}
disabled={!url}
onClick={() => setReload((n) => n + 1)}
title={t("preview.reload")}
aria-label={t("preview.reload")}
>
<RotateCw className="h-3.5 w-3.5" />
</button>
<button
type="button"
className={btn}
disabled={!url}
onClick={() => url && window.open(url, "_blank", "noopener,noreferrer")}
title={t("preview.openTab")}
aria-label={t("preview.openTab")}
>
<ExternalLink className="h-3.5 w-3.5" />
</button>
</div>
{url ? (
<iframe
key={`${url}#${reload}`}
src={url}
title={t("preview.title")}
sandbox={FRAME_SANDBOX}
referrerPolicy="no-referrer"
className="min-h-0 w-full flex-1 border-0 bg-white"
data-testid="url-preview-frame"
/>
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<Globe className="h-6 w-6 text-fg-muted" />
<p className="text-xs text-fg-secondary">{t("preview.empty")}</p>
{portList.length > 0 && (
<div className="flex flex-wrap justify-center gap-1.5">
{portList.map(([name, port]) => (
<button
key={name}
type="button"
onClick={() => go(`localhost:${port}`)}
title={name}
className="rounded-full border border-border px-2.5 py-0.5 font-mono text-[11px] text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary"
>
localhost:{port}
</button>
))}
</div>
)}
<p className="text-[11px] text-fg-muted">{t("preview.loginHint")}</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,47 @@
/**
* @file UrlPreview.test.tsx
* @description The Preview tab: port chips before a page is open, the
* address bar opening a page in a sandboxed frame without top navigation,
* a link's request opening its page, and the dashboard's own origin refused.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { beforeEach, describe, expect, it } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import "../../../i18n";
import { UrlPreview } from "../UrlPreview";
beforeEach(() => window.localStorage.clear());
describe("UrlPreview", () => {
it("offers the workspace's ports, and a chip opens that page", () => {
render(<UrlPreview laneId={1} ports={{ web: 3001 }} request={null} />);
fireEvent.click(screen.getByText("localhost:3001"));
const frame = screen.getByTestId("url-preview-frame");
expect(frame.getAttribute("src")).toBe("http://localhost:3001/");
expect(frame.getAttribute("sandbox")).not.toMatch(/top-navigation/);
expect(window.localStorage.getItem("agenthub.preview.url.1")).toBe("http://localhost:3001/");
});
it("opens what the address bar names, and refuses the dashboard itself", () => {
render(<UrlPreview laneId={2} ports={{}} request={null} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: window.location.origin + "/settings" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.queryByTestId("url-preview-frame")).toBeNull();
fireEvent.change(input, { target: { value: "127.0.0.1:8080/app" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByTestId("url-preview-frame").getAttribute("src")).toBe(
"http://127.0.0.1:8080/app"
);
});
it("opens a link's request", () => {
render(
<UrlPreview laneId={3} ports={{}} request={{ url: "http://localhost:5173/", nonce: 1 }} />
);
expect(screen.getByTestId("url-preview-frame").getAttribute("src")).toBe(
"http://localhost:5173/"
);
});
});
+46 -9
View File
@@ -13,7 +13,8 @@
* puts `/<name>` (`/<plugin>:<name>`) at the front of the message — the same
* text the `/` autocomplete inserts, so the CLI runs it as typed. Both lists
* are read when the menu first opens (`GET /api/cc-config/skills|plugins`),
* and Manage opens the Claude config page on that tab.
* and Manage opens the Claude config page on that tab. The menu opens
* downward when the box sits too near the top to fit it above.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -77,7 +78,36 @@ function loadLists(cwd: string): Promise<Lists> {
}
const PANEL =
"absolute w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
"absolute z-40 w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
/** Room the menu and its tallest submenu (Project files: search + list) need. */
const MENU_ROOM = 380;
/** The visible band the button sits in: the viewport, narrowed by any
* scrolling/clipping ancestor (the page body scrolls inside the layout). */
function clipBand(el: HTMLElement): { top: number; bottom: number } {
let top = 0;
let bottom = window.innerHeight;
for (let p = el.parentElement; p; p = p.parentElement) {
const o = getComputedStyle(p);
if (/(auto|scroll|hidden|clip)/.test(o.overflowY + o.overflow)) {
const r = p.getBoundingClientRect();
top = Math.max(top, r.top);
bottom = Math.min(bottom, r.bottom);
}
}
return { top, bottom };
}
/** Open downward when there is not room above but more below. */
function opensDown(el: HTMLElement | null): boolean {
if (!el) return false;
const r = el.getBoundingClientRect();
const band = clipBand(el);
const above = r.top - band.top;
const below = band.bottom - r.bottom;
return above < MENU_ROOM && below > above;
}
const ROW =
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
@@ -195,6 +225,7 @@ export function ChatAddMenu({
const { t } = useTranslation("run");
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [down, setDown] = useState(false);
const [sub, setSub] = useState<Sub | null>(null);
const [context, setContext] = useState<Attachment[] | null>(null);
const [recent, setRecent] = useState<Attachment[]>([]);
@@ -286,6 +317,8 @@ export function ChatAddMenu({
/>
))
);
// A submenu lines up with the menu's edge nearest the button.
const edge = down ? "top-0" : "bottom-0";
const shownPlugin = lists?.plugins.find((p) => p.name === plugin);
return (
@@ -296,13 +329,17 @@ export function ChatAddMenu({
aria-label={t("chat.add")}
title={t("chat.add")}
aria-expanded={open}
onClick={() => (open ? close() : setOpen(true))}
onClick={() => {
if (open) return close();
setDown(opensDown(ref.current));
setOpen(true);
}}
className="flex h-8 w-8 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
<Plus className="h-4 w-4" />
</button>
{open && (
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} bottom-full left-0 mb-2`}>
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} left-0 ${down ? "top-full mt-2" : "bottom-full mb-2"}`}>
<Row
icon={<Paperclip className="h-4 w-4" />}
label={t("chat.upload")}
@@ -376,13 +413,13 @@ export function ChatAddMenu({
)}
{sub === "project" && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1 w-72 pt-2`}>
<div role="menu" className={`${PANEL} ${edge} left-full ml-1 w-72 pt-2`}>
<ProjectFiles cwd={cwd} onPick={(rel) => attach(projectAttachment(rel))} />
</div>
)}
{(sub === "context" || sub === "recent") && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">
{(sub === "context" ? context : recent)?.length ? (
(sub === "context" ? context! : recent).map((it) => (
@@ -405,7 +442,7 @@ export function ChatAddMenu({
)}
{sub === "skills" && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">{entries(lists?.skills ?? [], "")}</div>
<div className="my-1 border-t border-border" />
<Row
@@ -417,7 +454,7 @@ export function ChatAddMenu({
)}
{sub === "plugins" && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">
{lists && lists.plugins.length === 0 && (
<p className="px-3 py-1.5 text-xs text-fg-muted">{t("chat.none")}</p>
@@ -443,7 +480,7 @@ export function ChatAddMenu({
onClick={() => manage("plugins")}
/>
{shownPlugin && (
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
<div className="max-h-72 overflow-y-auto">
{entries(
shownPlugin.items,
+13 -5
View File
@@ -16,8 +16,9 @@
* keys, Home and End move between tabs (one tab stop, APG). Errors are
* toasts. The `+` menu opens a new tab in the free slot the server picks
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
* or an installed, enabled AI tool started bare on its default model, no
* prompt (a prompt, proxy or worktree is still the workspace home's prompt
* or an installed, enabled AI tool started bare on its default model and
* the account's Session defaults (permission mode, Codex sandbox/approval/
* bypass), no prompt (a prompt, proxy or worktree is still the workspace home's prompt
* box) — plus a link to Settings → Agents. Ten sessions is the server's limit
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
* workspace's home) at its left end, `actions` (the single pane's split menus)
@@ -72,6 +73,7 @@ import {
type GroupsUpdate,
} from "./PaneGrid";
import { insertionIndex } from "../../lib/laneOrderStorage";
import { readCodexDefaults, readDefaultMode } from "../../lib/sessionDefaults";
import {
type PaneGroups,
MAX_PANES,
@@ -140,15 +142,21 @@ export function newTabTools(lane: Lane, aiTools: AiToolsState): string[] {
];
}
/** The start body of a bare tool on its default model, no prompt. Only a
* different tool is named, as the home does: the workspace's own keeps
* starting even once disabled. */
/** The start body of a bare tool on its default model, no prompt, in the
* account's Session defaults (Claude's permission mode, Codex's sandbox,
* approval and bypass) — the same ones the workspace home's prompt box starts
* from. Only a different tool is named, as the home does: the workspace's own
* keeps starting even once disabled. */
export function newTabBody(id: string, lane: Lane, aiTools: AiToolsState) {
const model = aiTools.byId(id)?.defaultModel;
const codex = id === "codex" ? readCodexDefaults() : null;
return {
prompt: "",
...(id !== lane.tool ? { tool: id } : {}),
...(model ? { model } : {}),
...(id === "claude" ? { permissionMode: readDefaultMode() } : {}),
...(codex ? { sandbox: codex.sandbox, approval: codex.approval } : {}),
...(codex?.bypass && aiTools.byId(id)?.bypass ? { bypass: true } : {}),
};
}
+5 -2
View File
@@ -689,8 +689,11 @@ export const PaneChat = memo(function PaneChat({
// another dashboard on the same database, or the socket dropped) would
// otherwise leave Stop up after the turn ended.
intervalMs: working || waiting ? 5000 : undefined,
// The transcript growing re-reads it too: a hook that reached another
// dashboard on the same database sends this one no agent_updated, and
// without a poll Stop never showed for a turn the chat calls Thinking….
match: (msg) =>
msg.type === "agent_updated" &&
(msg.type === "agent_updated" || msg.type === "transcript_changed") &&
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
});
@@ -855,7 +858,7 @@ export const PaneChat = memo(function PaneChat({
)}
</div>
<div className="mx-4">
<div className="mx-auto w-full max-w-3xl">
<div className="mx-auto w-full max-w-5xl">
{menu && (
<PaneMenuCard
// A new question starts with its field closed.
+5 -1
View File
@@ -90,6 +90,7 @@ import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nat
import { usePermissions } from "../AuthGate";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
import { openInPreview } from "../../lib/openInPreview";
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocalPaste";
@@ -462,7 +463,10 @@ export const TerminalView = memo(function TerminalView({
searchRef.current = search;
term.loadAddon(new ClipboardWithProvider(undefined, clipboardProvider));
term.loadAddon(
new WebLinksAddon((_e, uri) => {
new WebLinksAddon((e, uri) => {
// A local page opens in the session panel's Preview (one rule
// for every link, `lib/openInPreview.ts`); Ctrl/Cmd keeps a tab.
if (!e.ctrlKey && !e.metaKey && !e.shiftKey && openInPreview(uri)) return;
window.open(uri, "_blank", "noopener,noreferrer");
})
);
+8
View File
@@ -385,6 +385,14 @@
"menu.remove": "Delete workspace…",
"panel.changes": "Changes",
"panel.analyst": "Analyst",
"panel.preview": "Preview",
"preview.title": "Page preview",
"preview.address": "Page address",
"preview.reload": "Reload",
"preview.openTab": "Open in a browser tab",
"preview.badUrl": "Only an http(s) page other than the dashboard can be previewed",
"preview.empty": "Type an address, or click a localhost link in the chat or terminal.",
"preview.loginHint": "A blank page? The site may forbid embedding or need a login — open it in a browser tab.",
"replay.placeholder": "Message to continue this session…",
"replay.hint": "Sending resumes the session in a free pane.",
"replay.send": "Send",
+8
View File
@@ -385,6 +385,14 @@
"menu.remove": "Xóa workspace…",
"panel.changes": "Thay đổi",
"panel.analyst": "Analyst",
"panel.preview": "Xem trang",
"preview.title": "Xem trước trang web",
"preview.address": "Địa chỉ trang",
"preview.reload": "Tải lại",
"preview.openTab": "Mở trong tab trình duyệt",
"preview.badUrl": "Chỉ xem được trang http(s) khác với dashboard",
"preview.empty": "Nhập địa chỉ, hoặc bấm một link localhost trong chat hay terminal.",
"preview.loginHint": "Trang trắng? Trang có thể chặn nhúng hoặc cần đăng nhập — hãy mở trong tab trình duyệt.",
"replay.placeholder": "Nhắn để tiếp tục session này…",
"replay.hint": "Gửi tin sẽ resume session trong một pane trống.",
"replay.send": "Gửi",
@@ -0,0 +1,84 @@
/**
* @file openInPreview.test.ts
* @description The one link rule: which URLs the Preview takes (http(s),
* never the dashboard's origin, `0.0.0.0` read as `localhost`), that a local
* link goes to the session panel only when one answers, and that a
* Ctrl/Cmd click keeps the browser's own tab.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import {
OPEN_IN_PREVIEW_EVENT,
isLoopbackUrl,
normalizePreviewUrl,
onLinkClick,
openInPreview,
type OpenInPreview,
} from "../openInPreview";
const SELF = "http://localhost:4820";
describe("normalizePreviewUrl", () => {
it("adds http:// to a bare host:port and reads 0.0.0.0 as localhost", () => {
expect(normalizePreviewUrl("localhost:3000", SELF)).toBe("http://localhost:3000/");
expect(normalizePreviewUrl("http://0.0.0.0:5173/a", SELF)).toBe("http://localhost:5173/a");
});
it("refuses the dashboard's own origin, other schemes and credentials", () => {
expect(normalizePreviewUrl("http://localhost:4820/settings", SELF)).toBeNull();
expect(normalizePreviewUrl("javascript:alert(1)", SELF)).toBeNull();
expect(normalizePreviewUrl("file:///etc/passwd", SELF)).toBeNull();
expect(normalizePreviewUrl("http://u:p@localhost:3000", SELF)).toBeNull();
expect(normalizePreviewUrl(" ", SELF)).toBeNull();
});
it("accepts an external https page", () => {
expect(normalizePreviewUrl("https://example.com/x", SELF)).toBe("https://example.com/x");
});
});
describe("isLoopbackUrl", () => {
it("is true only for this machine's hosts", () => {
expect(isLoopbackUrl("http://localhost:3000")).toBe(true);
expect(isLoopbackUrl("http://127.0.0.1:8080/x")).toBe(true);
expect(isLoopbackUrl("http://[::1]:3000")).toBe(true);
expect(isLoopbackUrl("https://github.com")).toBe(false);
expect(isLoopbackUrl("not a url")).toBe(false);
});
});
describe("openInPreview", () => {
let off: (() => void) | null = null;
afterEach(() => off?.());
const listen = (take: boolean) => {
const seen: string[] = [];
const fn = (e: Event) => {
const d = (e as CustomEvent<OpenInPreview>).detail;
seen.push(d.url);
if (take) d.handled = true;
};
window.addEventListener(OPEN_IN_PREVIEW_EVENT, fn);
off = () => window.removeEventListener(OPEN_IN_PREVIEW_EVENT, fn);
return seen;
};
it("hands a local page to a listening panel", () => {
const seen = listen(true);
expect(openInPreview("http://localhost:3000/login")).toBe(true);
expect(seen).toEqual(["http://localhost:3000/login"]);
});
it("falls back when no panel takes it, and never sends an external page", () => {
const seen = listen(false);
expect(openInPreview("http://localhost:3000")).toBe(false);
expect(openInPreview("https://github.com/login")).toBe(false);
expect(seen).toEqual(["http://localhost:3000/"]);
});
it("leaves a Ctrl/Cmd click to the browser", () => {
listen(true);
const preventDefault = vi.fn();
const base = { ctrlKey: false, metaKey: false, shiftKey: false, button: 0, preventDefault };
onLinkClick({ ...base, ctrlKey: true }, "http://localhost:3000");
expect(preventDefault).not.toHaveBeenCalled();
onLinkClick(base, "http://localhost:3000");
expect(preventDefault).toHaveBeenCalledTimes(1);
});
});
@@ -432,6 +432,14 @@ describe("readRunBusy", () => {
let rows = ["✻ Thinking… (4s · ↓ 120 tokens · esc to interrupt)"];
const off = registerPane("agenthub-lane-8", () => {}, () => true, undefined, undefined, () => rows);
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
// Claude's spinner with the footer's hint gone (a narrow pane, a tip).
rows = ["✶ Skedaddling… (41s · ↓ 2.8k tokens · thought for 2s)", " ⏵⏵ auto mode on"];
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
rows = ["✻ Thinking… (1m 3s)"];
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
// The line a finished turn leaves is not.
rows = ["✻ Cooked for 1m 2s", "❯ "];
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false);
// Claude's permission footer is not a turn at work.
rows = ["Esc to cancel · Tab to amend"];
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false);
+83
View File
@@ -0,0 +1,83 @@
/**
* @file openInPreview.ts — the ONE rule every link in the dashboard follows
* (a chat's markdown link or bare URL, a terminal's link): a page served by
* THIS machine (`localhost`, `127.0.0.1`, `0.0.0.0`, `[::1]`) opens in the
* session view's Preview tab, everything else in a browser tab as before.
* The request travels as a window event (like `openInChanges.ts`); the
* Workspace page answers it only while it shows a session panel, so with no
* listener — another page, or a remote dashboard where "localhost" is the
* viewer's own machine — the link falls back to a browser tab. Also holds the
* URL checks the Preview tab itself uses.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
export const OPEN_IN_PREVIEW_EVENT = "agenthub:open-in-preview";
/** The event's detail. `handled` is set by the listener, read by the sender. */
export type OpenInPreview = { url: string; handled: boolean };
const LOOPBACK = new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]", "::1"]);
/** A URL the Preview may show: http(s) only, `0.0.0.0` read as `localhost`
* (a dev server prints it, a browser cannot open it), never the dashboard's
* own origin (the frame would then BE the dashboard, cookies and API
* included). A bare `localhost:3000` gets `http://`. Null when refused. */
export function normalizePreviewUrl(raw: string, self = window.location.origin): string | null {
let text = raw.trim();
if (!text) return null;
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) text = `http://${text}`;
let u: URL;
try {
u = new URL(text);
} catch {
return null;
}
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
if (u.username || u.password) return null;
if (u.hostname === "0.0.0.0") u.hostname = "localhost";
if (u.origin === self) return null;
return u.toString();
}
/** True for a page served by the machine the BROWSER runs on. */
export function isLoopbackUrl(url: string): boolean {
try {
const u = new URL(url);
return (u.protocol === "http:" || u.protocol === "https:") && LOOPBACK.has(u.hostname);
} catch {
return false;
}
}
/** The dashboard itself is opened from this machine: only then does a
* `localhost` link name the same machine the agents run on. */
export function dashboardIsLocal(hostname = window.location.hostname): boolean {
return LOOPBACK.has(hostname);
}
/** Asks the open session panel to show `url`. False when nothing took it
* (not a local page, a remote dashboard, no session panel). */
export function openInPreview(url: string): boolean {
if (!isLoopbackUrl(url) || !dashboardIsLocal()) return false;
const target = normalizePreviewUrl(url);
if (!target) return false;
const detail: OpenInPreview = { url: target, handled: false };
window.dispatchEvent(new CustomEvent(OPEN_IN_PREVIEW_EVENT, { detail }));
return detail.handled;
}
/** A link click: Preview when the rule says so, else a browser tab. A
* Ctrl/Cmd/Shift/middle click always keeps the browser's own behaviour. */
export function onLinkClick(
e: {
ctrlKey: boolean;
metaKey: boolean;
shiftKey: boolean;
button: number;
preventDefault: () => void;
},
url: string
): void {
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button !== 0) return;
if (openInPreview(url)) e.preventDefault();
}
+8 -3
View File
@@ -368,10 +368,15 @@ function typeRow(options: PaneMenuOption[]): number {
/** The status line a tool's TUI shows only while a turn runs, read off its
* binary's own strings: Claude's and Codex's `esc to interrupt`, agy's
* `Thinking… (3s · esc to cancel)` — in parentheses after an ellipsis, so
* Claude's permission footer (`Esc to cancel · Tab to amend`) is not it. A
* tool missing here (omp, Cursor) has no line we know. */
* Claude's permission footer (`Esc to cancel · Tab to amend`) is not it.
* Claude ≥ 2.1.3xx keeps `esc to interrupt` only in its footer, which a
* narrow pane, a tip or a notice replaces, so its spinner line counts too:
* a verb, an ellipsis and the turn's time in parentheses
* (`✶ Skedaddling… (41s · ↓ 2.8k tokens)`) — the line a finished turn leaves
* (`✻ Cooked for 1m 2s`) has neither. A tool missing here (omp, Cursor) has
* no line we know. */
export const BUSY_LINE: Readonly<Record<string, RegExp>> = {
claude: /\besc to interrupt\b/i,
claude: /\besc to interrupt\b|…\s*\(\s*\d+[smh]\b/i,
codex: /\besc to interrupt\b/i,
antigravity: /…\s*\([^)]*\besc to cancel\)/i,
};
+75 -4
View File
@@ -141,6 +141,8 @@ import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
import { FileExplorer } from "../components/lanes/FileExplorer";
import { WorkspaceContext } from "../components/lanes/WorkspaceContext";
import { OPEN_IN_CHANGES_EVENT, type OpenInChanges } from "../lib/openInChanges";
import { OPEN_IN_PREVIEW_EVENT, type OpenInPreview } from "../lib/openInPreview";
import { UrlPreview } from "../components/lanes/UrlPreview";
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
import { SessionReplay } from "../components/lanes/SessionReplay";
@@ -148,6 +150,8 @@ import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
/** localStorage key for the Changes panel's open/closed state. */
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
/** The session panel's tab — Changes or Preview. A viewer preference. */
const PANEL_TAB_KEY = "agenthub.workspace.panelTab";
/** localStorage key for how wide the Changes panel is. */
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
/** localStorage key for the last width the user dragged the panel to — what
@@ -799,6 +803,25 @@ export function Workspace() {
return false;
}
});
// The panel's two tabs: Changes (git + files) and Preview (a web page).
// Both stay mounted once shown (CSS), so an unsaved edit or a loaded page
// survives a switch.
const [panelTab, setPanelTab] = useState<"changes" | "preview">(() => {
try {
return window.localStorage.getItem(PANEL_TAB_KEY) === "preview" ? "preview" : "changes";
} catch {
return "changes";
}
});
const [previewShown, setPreviewShown] = useState(panelTab === "preview");
useEffect(() => {
if (panelTab === "preview") setPreviewShown(true);
try {
window.localStorage.setItem(PANEL_TAB_KEY, panelTab);
} catch {
/* disabled storage: the tab just forgets between visits */
}
}, [panelTab]);
useEffect(() => {
try {
window.localStorage.setItem(CHANGES_OPEN_KEY, changesOpen ? "1" : "0");
@@ -1050,6 +1073,22 @@ export function Workspace() {
window.addEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
return () => window.removeEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
}, []);
// A link to a local page asks for it (`openInPreview`): taken only while a
// session panel is on screen, so elsewhere the link opens a browser tab.
const [previewRequest, setPreviewRequest] = useState<{ url: string; nonce: number } | null>(null);
const previewHostRef = useRef(false);
useEffect(() => {
const onOpen = (e: Event) => {
const detail = (e as CustomEvent<OpenInPreview>).detail;
if (!previewHostRef.current) return;
detail.handled = true;
setChangesOpen(true);
setPanelTab("preview");
setPreviewRequest((r) => ({ url: detail.url, nonce: (r?.nonce ?? 0) + 1 }));
};
window.addEventListener(OPEN_IN_PREVIEW_EVENT, onOpen);
return () => window.removeEventListener(OPEN_IN_PREVIEW_EVENT, onOpen);
}, []);
// Asked once: a panel reopened or moved to another tree must not replay it.
useEffect(() => {
if (!changesOpen) setChangesRequest(null);
@@ -1230,6 +1269,7 @@ export function Workspace() {
// The session view (console or replay) fills the page edge to edge, like an
// editor: no page padding, no cards, dividers between its regions.
const sessionView = !!currentLane && !home;
previewHostRef.current = sessionView;
const viewportLocked =
sessionView || (!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesFull)));
@@ -1567,9 +1607,25 @@ export function Workspace() {
className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2"
role="toolbar"
>
<span className="px-1 text-xs font-medium text-fg-secondary">
{tLanes("panel.changes")}
</span>
<div role="tablist" className="flex items-center gap-0.5">
{(["changes", "preview"] as const).map((tab) => (
<button
key={tab}
type="button"
role="tab"
data-testid={`panel-tab-${tab}`}
aria-selected={panelTab === tab}
onClick={() => setPanelTab(tab)}
className={`rounded-md px-2 py-1 text-xs font-medium transition-colors ${
panelTab === tab
? "bg-surface-4 text-fg-primary"
: "text-fg-muted hover:text-fg-primary"
}`}
>
{tLanes(tab === "changes" ? "panel.changes" : "panel.preview")}
</button>
))}
</div>
{/* The opened session's analysis page — a page of its own. */}
<button
type="button"
@@ -1652,7 +1708,22 @@ export function Workspace() {
<PanelRightClose className="h-4 w-4" />
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
{previewShown && (
<div
className={panelTab === "preview" ? "flex min-h-0 flex-1 flex-col" : "hidden"}
>
<UrlPreview
laneId={currentLane.id}
ports={currentLane.ports ?? {}}
request={previewRequest}
/>
</div>
)}
<div
className={
panelTab === "changes" ? "flex min-h-0 flex-1 flex-col px-3 pt-2" : "hidden"
}
>
{panelAt &&
[
...(showRoot
+22 -1
View File
@@ -1053,7 +1053,28 @@ describe("split panes", () => {
const user = userEvent.setup();
await user.click(screen.getByTestId("pane-split-right-1"));
await user.click(screen.getByTestId("pane-split-new-right-1-claude"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", prompt: "" });
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: "auto",
prompt: "",
permissionMode: "auto",
});
});
it("a new AI pane starts in the account's Session defaults (bypass included)", async () => {
localStorage.setItem("agenthub.permissionMode", "bypassPermissions");
lanesToReturn = lanesToReturn.map((l) => ({ ...l, tool: "claude" }));
withRuns([live(0), live(1)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await ctrlClick(screen.getByTestId("console-tab-1"));
const user = userEvent.setup();
await user.click(screen.getByTestId("pane-split-right-1"));
await user.click(screen.getByTestId("pane-split-new-right-1-claude"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
slot: "auto",
prompt: "",
permissionMode: "bypassPermissions",
});
localStorage.removeItem("agenthub.permissionMode");
});
it("with one pane, the split menus sit beside Tools and split it", async () => {
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.52 (rebuild client to match)
UI build v2.8.58 (rebuild client to match)
</span>
</p>
</div>
+1
View File
@@ -416,6 +416,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`) over the workspace's live tmux sessions — at most ten, its slots 0–9: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–9 are `agenthub-lane-<id>-<slot>`. Every tab is a **combo**: a group of one to four split panes, each session in exactly one tab (`PaneGroups` in `client/src/lib/paneLayout.ts`). A one-pane tab shows its AI tool's icon and name (or **Terminal**); a combo tab shows every pane's icon and name, and the most urgent state dot. The active tab is the one on screen and its focused pane is the URL's `?run=`, so the Changes panel and Analyst follow it. A click shows a tab; **Ctrl/Cmd+click** on a one-pane tab or a `+` item brings it into the tab on screen, in the same order as a join below (a full tab — four panes — opens it as a tab of its own instead), and a one-pane tab dragged onto a pane joins that tab there. Any tab drags along the bar to reorder it; a pane's header dragged onto the bar takes that pane out into a tab of its own at that place. Dropped on the MIDDLE of another tab (it highlights), a tab — or a pane's header — joins that tab instead, in a fixed order: the second pane goes to the right (left | right), the third under the right pane, the fourth under the left pane (2×2) — the new pane always splits the BIGGEST one by the tree's shape (dragged dividers ignored, a tie goes to the later pane), so a three-pane tab of any shape fills its one missing quarter; a drop that would pass four panes reorders instead (`joinGroup`). A pane drag (a pane's header, or a one-pane tab) held over another tab for 0.6 s opens that tab (`SPRING_MS`), so the pane can be dropped on any edge of that tab's panes instead of the fixed join order; on a bar wider than the screen, dragging near either end scrolls it. Every live session stays mounted and those of other tabs are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops every session in it (`DELETE /api/run/<runId>`, after a confirm when one is an AI tool); with no tab left it moves to the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With ten sessions open **+** is disabled. EVERY tab is bound to the sidebar's selection — unless Settings → Appearance → **Tabs from every workspace** is on (`agenthub.workspace.allTabs`, `client/src/lib/workspaceTabs.ts`, synced to the account): then the bar holds every workspace's live sessions, each tab named `<workspace> · <session>`, a combo may mix workspaces, and focusing a pane selects its workspace (`?lane=` follows `?run=`, so the Changes panel and the sidebar follow too). Those tabs are stored once, under `all` in `agenthub.workspace.paneLayout`, their leaves `paneKey`s (lane id × 10 + slot); a split's **New terminal** starts in the target pane's own workspace. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.) **Settings** opened on a workspace — the sidebar's Settings, the `+` menu's Agent settings, any Settings link inside it — is one more tab at the bar's end (`?settings=<section>` beside `lane` and `run`, `client/src/lib/settingsLink.ts`): the Settings pages (`SettingsHub` with a `section`), shown instead of the panes, which stay mounted behind them, kept until the tab's X and reopened on the section it was on. Off a workspace a Settings link still opens the `/settings` page.
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): one binary tree of splits PER TAB, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes in a tab, a session never shown twice. **Ctrl/Cmd+click** a one-pane tab, a `+` item or a sidebar session row brings it into the tab on screen at the 2×2 grid's next place — the same order as a tab join, whichever pane is focused (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two (a session from another tab takes the pane's place and that one moves to a tab of its own) —; while the drag lasts the moved pane is dimmed, the drag shows a small chip with its name, and the drop preview says what will happen (*Place on the left*, *Swap places*…, `dropAction`), its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, its **⤡** takes the pane out of this tab into a tab of its own (its session keeps running), and its **×** (also in the header's right-click menu) stops just that pane's session — the same `DELETE /api/run/<runId>` and AI-tool confirm as a tab's **×** — without taking it out first. A session dropped or swapped in from another tab leaves that tab. Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The tabs and their layouts are a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`, `{groups, active}`; a stored pre-tab single layout reads as one tab), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
- **Preview** — the session view's right panel has two tabs, **Changes** and **Preview** (`client/src/components/lanes/UrlPreview.tsx`). Preview shows a web page by address — before one is open, the workspace's bound ports are offered as `localhost:<port>` chips — with Reload and **Open in a browser tab**; the last address is remembered per workspace in this browser (`agenthub.preview.url.<id>`). Every link follows one rule (`client/src/lib/openInPreview.ts`): a chat link, bare URL or terminal link to `localhost`/`127.0.0.1`/`0.0.0.0`/`[::1]` opens in Preview while a session view is open and the dashboard itself is opened on this machine; any other link, and any Ctrl/Cmd click, opens a browser tab. The page runs on its own origin (the dashboard's own is refused) with no top navigation. A site that forbids embedding — most login pages — shows blank; open it in a browser tab.
- **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner.
Run history is per workspace, queryable via `GET /api/run/history?laneId=<n>`.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.52",
"version": "2.8.58",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.52",
"version": "2.8.58",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.52",
"version": "2.8.58",
"bin": {
"agenthub": "bin/agenthub.js"
},