feat(workspace): no double resume, server-picked tabs, terminal a11y
- A resume of a session a live pane already holds is refused (409 ESESSIONLIVE with error.run_id) and the client opens that pane, pasting any typed message there; catalog tools refuse resumeSessionId (EUNSUPPORTED) instead of silently starting fresh. - POST /api/lanes/:id/start takes slot: "auto": the server picks the free tab under the lane lock (components/lanes/startSession.ts on the client, kept out of WorkspaceHome so the sidebar does not pull react-markdown into the main bundle). - Unsaved whole-file edits are guarded on every exit (Back, another file, tab switch, close, lane/session change, beforeunload). - Closing an AI tab asks first; hiding a pane has its own icon. Tabs and pane headers show the session name, a state dot, rename by double-click. - Terminal: Ctrl+M toggles Tab-moves-focus (no keyboard trap), Cmd+F / Ctrl+Shift+F find bar, clickable links (noopener), a write-only OSC 52 clipboard, a Reconnecting overlay, debounced resizes, optional screen reader mode. Alt+1-4 / Alt+\ / Alt+W focus, split and hide panes. - Changes panel: draggable splitter, wrapping git bar, history paging, disarming discard/undo/abort, inline diff comments pasted only into a visible pane; drag MIME guard on sidebar rows; IME-safe Enter; Show more in the sidebar and Recents; idle dots readable with sr-only text. - Adds @xterm/addon-clipboard, @xterm/addon-search, @xterm/addon-web-links.
This commit is contained in:
Generated
+39
@@ -11,7 +11,10 @@
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"d3": "^7.9.0",
|
||||
"i18next": "^26.0.8",
|
||||
@@ -2136,6 +2139,18 @@
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-clipboard": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.1.0.tgz",
|
||||
"integrity": "sha512-zdoM7p53T5sv/HbRTyp4hY0kKmEQ3MZvAvEtiXqNIHc/JdpqwByCtsTaQF5DX2n4hYdXRPO4P/eOS0QEhX1nPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-base64": "^3.7.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||
@@ -2145,6 +2160,24 @@
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-search": {
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz",
|
||||
"integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-web-links": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
|
||||
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
@@ -3711,6 +3744,12 @@
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/js-base64": {
|
||||
"version": "3.9.4",
|
||||
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.9.4.tgz",
|
||||
"integrity": "sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"d3": "^7.9.0",
|
||||
"i18next": "^26.0.8",
|
||||
|
||||
@@ -168,11 +168,17 @@ export function WorkspaceTree() {
|
||||
}
|
||||
};
|
||||
|
||||
const onMenuAction = (lane: Lane, action: WorkspaceMenuAction) => {
|
||||
const onMenuAction = (lane: Lane, action: WorkspaceMenuAction, runId?: string) => {
|
||||
if (action === "rename") setRenamingId(lane.id);
|
||||
else if (action === "new-here") openAdd(lane.source_repo || lane.cwd);
|
||||
else if (action === "reset" || action === "remove") setDestructive({ lane, action });
|
||||
else if (action === "started") select(lane.id);
|
||||
// Start opens the session it started, as the home's menu does.
|
||||
else if (action === "started")
|
||||
navigate(
|
||||
runId
|
||||
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`
|
||||
: `/workspace?lane=${lane.id}`
|
||||
);
|
||||
};
|
||||
|
||||
const confirmDestructive = async (body?: { expect?: unknown; force?: boolean }) => {
|
||||
@@ -192,7 +198,8 @@ export function WorkspaceTree() {
|
||||
}
|
||||
};
|
||||
|
||||
const headBtn = "rounded p-1 text-fg-muted hover:bg-surface-3 hover:text-fg-primary";
|
||||
// ≥24px targets (WCAG 2.5.8); each carries an aria-label.
|
||||
const headBtn = "btn-icon";
|
||||
return (
|
||||
<section data-testid="workspace-tree" className="mt-3 border-t border-border pt-3">
|
||||
<div className="mb-1 flex items-center gap-1 px-2">
|
||||
@@ -205,7 +212,7 @@ export function WorkspaceTree() {
|
||||
onChange={(e) => writeProjectTreePrefs({ tool: e.target.value || null })}
|
||||
aria-label={t("tree.toolFilter")}
|
||||
title={t("tree.toolFilter")}
|
||||
className="max-w-[6.5rem] rounded border border-border bg-surface-1 px-1 py-0.5 text-[11px] text-fg-secondary"
|
||||
className="select max-w-[6.5rem] !rounded !py-0.5 !pl-1 !pr-5 !text-[11px] text-fg-secondary"
|
||||
>
|
||||
<option value="">{t("tree.allTools")}</option>
|
||||
{tools.map((id) => (
|
||||
@@ -307,7 +314,7 @@ export function WorkspaceTree() {
|
||||
x={menu.x}
|
||||
y={menu.y}
|
||||
onClose={() => setMenu(null)}
|
||||
onAction={(a) => onMenuAction(menu.lane, a)}
|
||||
onAction={(a, runId) => onMenuAction(menu.lane, a, runId)}
|
||||
/>
|
||||
)}
|
||||
{destructive && (
|
||||
|
||||
@@ -44,6 +44,11 @@ const session = (id: string, name: string) =>
|
||||
|
||||
let lanes: Lane[] = [];
|
||||
|
||||
// Archiving a live session asks through the themed confirm (App's host): yes.
|
||||
vi.mock("../../lib/confirm", () => ({
|
||||
confirmDialog: vi.fn(async () => true),
|
||||
confirmDiscard: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
@@ -65,6 +70,10 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
sessions: [session("s-live", "Live one"), session("s-old", "Old one")],
|
||||
})),
|
||||
patch: vi.fn(async () => ({ session: {} })),
|
||||
// A tab's session with no row yet (a bare tab): 404.
|
||||
get: vi.fn(async () => {
|
||||
throw Object.assign(new Error("not found"), { status: 404 });
|
||||
}),
|
||||
},
|
||||
run: {
|
||||
list: vi.fn(async () => ({
|
||||
@@ -483,6 +492,6 @@ describe("workspaceDot", () => {
|
||||
expect(workspaceDot(lane(1, "/a", { status: "error" }))).toBe("bg-status-danger");
|
||||
expect(workspaceDot(lane(1, "/a", { status: "running" }))).toContain("animate-pulse-slow");
|
||||
expect(workspaceDot(lane(1, "/a", { liveness: "active" }))).toBe("bg-status-success");
|
||||
expect(workspaceDot(lane(1, "/a"))).toBe("bg-surface-4");
|
||||
expect(workspaceDot(lane(1, "/a"))).toBe("border border-fg-muted");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ function Seg({
|
||||
aria-pressed={active}
|
||||
className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||
active
|
||||
? "bg-accent text-white shadow-sm"
|
||||
? "bg-accent-solid text-white shadow-sm"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
@@ -567,7 +567,7 @@ export function AddLaneModal({
|
||||
id="add-lane-base"
|
||||
value={base}
|
||||
onChange={(e) => setBase(e.target.value)}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 text-xs text-fg-primary focus:border-accent focus:outline-none"
|
||||
className="select w-full !py-1.5 !text-xs"
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
@@ -606,7 +606,7 @@ export function AddLaneModal({
|
||||
id="add-lane-existing"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary focus:border-accent focus:outline-none"
|
||||
className="select w-full !py-1.5 font-mono !text-xs"
|
||||
>
|
||||
{branches.map((b) => {
|
||||
const busyAt = busyBranches.get(b);
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen, FolderGit2, ArrowUp } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
|
||||
export function FolderBrowseModal({
|
||||
open,
|
||||
@@ -45,14 +46,10 @@ export function FolderBrowseModal({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
// The shared dialog contract: focus in, Tab trapped, Escape closes (only
|
||||
// this one when it sits over the Add-workspace modal), focus restored.
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
useDialog(open, panel, onClose);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -63,6 +60,7 @@ export function FolderBrowseModal({
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={panel}
|
||||
className="relative flex max-h-[70vh] w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
@@ -99,7 +97,7 @@ export function FolderBrowseModal({
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
|
||||
>
|
||||
{entry.isGitRepo ? (
|
||||
<FolderGit2 className="h-3.5 w-3.5 text-blue-700 dark:text-blue-400" />
|
||||
<FolderGit2 className="h-3.5 w-3.5 text-accent" />
|
||||
) : (
|
||||
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
|
||||
)}
|
||||
|
||||
@@ -100,7 +100,8 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
||||
|
||||
export const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
||||
active: "bg-status-success",
|
||||
idle: "bg-surface-4",
|
||||
// A ring: a surface-4 fill vanished on the light themes.
|
||||
idle: "border border-fg-muted",
|
||||
dead: "bg-status-danger",
|
||||
};
|
||||
|
||||
@@ -128,7 +129,6 @@ export default function LaneCard({
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [destructiveAction, setDestructiveAction] = useState<"reset" | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [menuAt, setMenuAt] = useState<MenuPoint | null>(null);
|
||||
const [runtimeBump, setRuntimeBump] = useState(0);
|
||||
const [bootLine, setBootLine] = useState<string | null>(null);
|
||||
@@ -203,9 +203,13 @@ export default function LaneCard({
|
||||
<div key={name} className="flex items-center gap-1.5 truncate">
|
||||
<span
|
||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
|
||||
info.listening ? "bg-status-success" : "bg-surface-4"
|
||||
info.listening ? "bg-status-success" : "border border-fg-muted"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="sr-only">
|
||||
{info.listening ? t("runtime.listening") : t("runtime.notListening")}
|
||||
</span>
|
||||
<span className="text-fg-secondary">{name}</span>
|
||||
<span className="text-fg-muted">:{info.port ?? "—"}</span>
|
||||
{info.port !== null && info.port !== info.expected && (
|
||||
@@ -265,7 +269,7 @@ export default function LaneCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectLane?.(w.id)}
|
||||
className="truncate text-left text-blue-700 dark:text-blue-400 hover:underline"
|
||||
className="truncate text-left text-accent hover:underline"
|
||||
title={w.cwd}
|
||||
>
|
||||
#{w.id} {w.title || w.cwd} · {w.status}
|
||||
@@ -283,42 +287,23 @@ export default function LaneCard({
|
||||
applies to managed lanes and is rare enough to stay behind ⋯. */}
|
||||
{lane.kind === "managed" && (
|
||||
<div className="mt-auto flex items-center justify-end gap-1 border-t border-border pt-2 text-xs">
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-more"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen((v) => !v);
|
||||
}}
|
||||
className="rounded px-2 py-1 text-fg-muted hover:bg-surface-2 hover:text-fg-secondary"
|
||||
title={t("moreActions")}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 z-10 mt-1 min-w-40 overflow-hidden rounded-md border border-border-light bg-surface-0 py-1 shadow-lg shadow-black/40"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="lane-action-reset"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen(false);
|
||||
setDestructiveAction("reset");
|
||||
}}
|
||||
className="block w-full px-3 py-1.5 text-left text-status-warning hover:bg-status-warning/10"
|
||||
>
|
||||
{t("action.reset")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* The shared menu shell: outside click, Escape and focus. */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-more"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={!!menuAt}
|
||||
aria-label={t("moreActions")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
setMenuAt(menuAt ? null : { x: r.right - 160, y: r.bottom + 4 });
|
||||
}}
|
||||
className="rounded px-2 py-1 text-fg-muted hover:bg-surface-2 hover:text-fg-secondary"
|
||||
title={t("moreActions")}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -327,6 +312,7 @@ export default function LaneCard({
|
||||
<ContextMenu x={menuAt.x} y={menuAt.y} onClose={() => setMenuAt(null)}>
|
||||
<MenuItem
|
||||
danger
|
||||
testId="lane-action-reset"
|
||||
onSelect={() => {
|
||||
setMenuAt(null);
|
||||
setDestructiveAction("reset");
|
||||
|
||||
@@ -32,7 +32,15 @@
|
||||
*
|
||||
* A diff line's number opens a comment box (LaneDiffView `onComment`); the
|
||||
* comment is pasted, as `<path>:<line> — <text>`, into the workspace's pane
|
||||
* focused last (`lib/panePaste.ts`) and sent only when the user presses Enter.
|
||||
* focused last (`lib/panePaste.ts`) and sent only when the user presses Enter;
|
||||
* a toast says which tab took it, and a console not on screen takes nothing.
|
||||
*
|
||||
* An unsaved whole-file edit is never dropped silently: Back, another file,
|
||||
* Cancel and a quick-open pick all ask once (`confirmDiscard`), closing or
|
||||
* reloading the browser tab asks the browser's own question, and
|
||||
* `onDirtyChange` tells the Workspace, which asks before closing the panel,
|
||||
* switching its tab, or moving it to another workspace or session. Two-click
|
||||
* actions (discard, a hunk's Undo, Abort merge) disarm after 3 s (`useArmed`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -61,6 +69,12 @@ import {
|
||||
} from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { pasteToLane } from "../../lib/panePaste";
|
||||
import { confirmDiscard as askDiscard } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { useArmed } from "../../hooks/useArmed";
|
||||
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
|
||||
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
|
||||
import { LaneConflictView } from "./LaneConflictView";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
@@ -428,14 +442,21 @@ function ChangeRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** History's page size, and the most the server reads (`listCommits`). */
|
||||
const HISTORY_PAGE = 50;
|
||||
const HISTORY_MAX = 200;
|
||||
|
||||
export function LaneChangesPanel({
|
||||
laneId,
|
||||
session,
|
||||
onNewLane,
|
||||
full = false,
|
||||
tab = "changes",
|
||||
onDirtyChange,
|
||||
}: {
|
||||
laneId: number;
|
||||
/** The whole-file editor holds unsaved text (true) or not (false). */
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
/** Show and act on that session's own worktree (`claude --worktree`)
|
||||
* instead of the workspace's folder. The Workspace keys the panel on it,
|
||||
* so a different session is a fresh panel. */
|
||||
@@ -457,7 +478,17 @@ export function LaneChangesPanel({
|
||||
// Every git read and write below goes to the same tree as those facts.
|
||||
const target = gitTarget(laneId, session);
|
||||
// A diff line's comment goes into this workspace's pane, as a paste.
|
||||
const comment = useCallback((text: string) => pasteToLane(laneId, text), [laneId]);
|
||||
const comment = useCallback(
|
||||
(text: string) => {
|
||||
const runId = pasteToLane(laneId, text);
|
||||
if (runId) {
|
||||
const slot = Number(/-([1-3])$/.exec(runId)?.[1] ?? 0);
|
||||
toast.success(t("changes.commentPasted", { tab: slot + 1 }));
|
||||
}
|
||||
return runId !== null;
|
||||
},
|
||||
[laneId, t]
|
||||
);
|
||||
// Bumped by Refresh and by a git bar action: a pull or a switch moves
|
||||
// history and the tree too, not just the diff `load` re-reads.
|
||||
const [reread, setReread] = useState(0);
|
||||
@@ -523,12 +554,21 @@ export function LaneChangesPanel({
|
||||
}, [text]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmDiscard, setConfirmDiscard] = useState<string | null>(null);
|
||||
const [armedAbort, setArmedAbort] = useState(false);
|
||||
// Two-click arming that disarms by itself after 3 s: the armed path, the
|
||||
// armed hunk (`${path}\n${header}\n${block}`) and the armed merge abort.
|
||||
const discardArm = useArmed();
|
||||
const confirmDiscard = discardArm.armed;
|
||||
const setConfirmDiscard = (path: string | null) =>
|
||||
path ? discardArm.arm(path) : discardArm.disarm();
|
||||
const abortArm = useArmed();
|
||||
const armedAbort = abortArm.armed !== null;
|
||||
const setArmedAbort = (on: boolean) => (on ? abortArm.arm("abort") : abortArm.disarm());
|
||||
// A conflicted file opens in the conflict view; true = the plain editor.
|
||||
const [rawEdit, setRawEdit] = useState(false);
|
||||
// The hunk whose Undo was clicked once: `${path}\n${header}`.
|
||||
const [armedHunk, setArmedHunk] = useState<string | null>(null);
|
||||
// The hunk whose Undo was clicked once: `${path}\n${header}\n${block}`.
|
||||
const hunkArm = useArmed();
|
||||
const armedHunk = hunkArm.armed;
|
||||
const setArmedHunk = (key: string | null) => (key ? hunkArm.arm(key) : hunkArm.disarm());
|
||||
// Files folded shut in review mode. A change-set is read file by file, and
|
||||
// a file already read is in the way of the next one — so the set is what is
|
||||
// COLLAPSED, and a file arriving later is open by default.
|
||||
@@ -603,13 +643,19 @@ export function LaneChangesPanel({
|
||||
|
||||
// History and the file tree are their own reads, each fetched when its mode
|
||||
// is entered rather than alongside the diff nobody is looking at.
|
||||
// History reads HISTORY_PAGE commits, and Load more asks for the next page
|
||||
// (up to the server's 200).
|
||||
const [historyLimit, setHistoryLimit] = useState(HISTORY_PAGE);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
useEffect(() => {
|
||||
if (mode !== "history") return;
|
||||
setHistoryLoading(true);
|
||||
void api.lanes
|
||||
.gitLog(target, { limit: 50 })
|
||||
.gitLog(target, { limit: historyLimit })
|
||||
.then(forLane(laneId, (r) => setCommits(r.commits)))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
}, [mode, laneId, reread]);
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => laneNow.current === laneId && setHistoryLoading(false));
|
||||
}, [mode, laneId, reread, historyLimit]);
|
||||
|
||||
// Re-run when a lane switch empties the cache too: the Files tab stays in
|
||||
// `browse` across the switch, so the mode alone never changes.
|
||||
@@ -739,8 +785,14 @@ export function LaneChangesPanel({
|
||||
pendingScroll.current = null;
|
||||
}, [laneId]);
|
||||
// After the lane reset above: each tab opens on its own mode.
|
||||
// Leaving the open file this way (the Workspace asked first when it was
|
||||
// edited) drops it whole: a draft left behind would keep `dirty` true.
|
||||
useEffect(() => {
|
||||
if (!full) setMode(tab === "files" ? "browse" : "overview");
|
||||
if (full) return;
|
||||
setMode(tab === "files" ? "browse" : "overview");
|
||||
setOpenFile(null);
|
||||
setWhole(false);
|
||||
setDraft(null);
|
||||
}, [tab, laneId, full]);
|
||||
const filesTab = tab === "files" && !full;
|
||||
|
||||
@@ -756,7 +808,11 @@ export function LaneChangesPanel({
|
||||
pendingScroll.current = null;
|
||||
}, [mode, data]);
|
||||
|
||||
const show = (path: string) => {
|
||||
// Every way out of an open, edited file asks once; nothing else does.
|
||||
const dirtyRef = useRef(false);
|
||||
const confirmDrop = async () => !dirtyRef.current || (await askDiscard());
|
||||
|
||||
const showNow = (path: string) => {
|
||||
// Opening a file FROM a file (the review list is gone by then) must not
|
||||
// overwrite the list position that is still waiting to be restored.
|
||||
if (mode !== "file") {
|
||||
@@ -773,13 +829,21 @@ export function LaneChangesPanel({
|
||||
setMode("file");
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
const show = (path: string) => {
|
||||
void confirmDrop().then((ok) => ok && showNow(path));
|
||||
};
|
||||
|
||||
const goBackNow = () => {
|
||||
const back = returnTo.current;
|
||||
pendingScroll.current = back?.scroll ?? 0;
|
||||
returnTo.current = null;
|
||||
setMode(back?.mode ?? (range === "commit" ? "review" : "overview"));
|
||||
setOpenFile(null);
|
||||
setWhole(false);
|
||||
setDraft(null);
|
||||
};
|
||||
const goBack = () => {
|
||||
void confirmDrop().then((ok) => ok && goBackNow());
|
||||
};
|
||||
|
||||
// Save the editor over exactly the bytes it opened (`sha`): a file the agent
|
||||
@@ -860,7 +924,7 @@ export function LaneChangesPanel({
|
||||
setRange("commit");
|
||||
setMode("review");
|
||||
};
|
||||
const copy = (text: string) => void navigator.clipboard.writeText(text);
|
||||
const copy = (text: string) => void copyText(text);
|
||||
// A menu item: close its menu, then act.
|
||||
const pick = (close: () => void, fn: () => void) => () => {
|
||||
close();
|
||||
@@ -952,7 +1016,7 @@ export function LaneChangesPanel({
|
||||
testId="lane-changes-tree-menu"
|
||||
>
|
||||
{!treeCtx.menu.item.dir && (
|
||||
<MenuItem onSelect={pick(treeCtx.close, () => openWhole(treeCtx.menu!.item.path))}>
|
||||
<MenuItem onSelect={pick(treeCtx.close, () => void openWhole(treeCtx.menu!.item.path))}>
|
||||
{t("changes.openFile", { path: treeCtx.menu.item.name })}
|
||||
</MenuItem>
|
||||
)}
|
||||
@@ -1090,6 +1154,14 @@ export function LaneChangesPanel({
|
||||
});
|
||||
|
||||
const dirty = draft !== null && draft !== text?.lines.join("\n");
|
||||
dirtyRef.current = dirty;
|
||||
useUnsavedGuard(dirty);
|
||||
const dirtyChangeRef = useRef(onDirtyChange);
|
||||
dirtyChangeRef.current = onDirtyChange;
|
||||
useEffect(() => dirtyChangeRef.current?.(dirty), [dirty]);
|
||||
// Unmounted with an edit open (the panel closed or moved on, after the
|
||||
// Workspace asked): nothing is dirty any more.
|
||||
useEffect(() => () => dirtyChangeRef.current?.(false), []);
|
||||
|
||||
const files = data?.available ? data.files : [];
|
||||
const allCollapsed = files.length > 0 && files.every((f) => collapsed.has(f.path));
|
||||
@@ -1110,9 +1182,9 @@ export function LaneChangesPanel({
|
||||
|
||||
// A tree or quick-open pick opens the whole file — and, with an edit open,
|
||||
// would drop it, so a dirty draft asks first.
|
||||
const openWhole = (p: string) => {
|
||||
if (dirty && !window.confirm(t("changes.dropEdits"))) return;
|
||||
show(p);
|
||||
const openWhole = async (p: string) => {
|
||||
if (!(await confirmDrop())) return;
|
||||
showNow(p);
|
||||
setWhole(true);
|
||||
};
|
||||
|
||||
@@ -1156,7 +1228,7 @@ export function LaneChangesPanel({
|
||||
<li key={p}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openWhole(p)}
|
||||
onClick={() => void openWhole(p)}
|
||||
className="w-full truncate rounded px-1 py-0.5 text-left font-mono text-[11px] text-fg-primary hover:bg-surface-2"
|
||||
>
|
||||
{p}
|
||||
@@ -1175,7 +1247,7 @@ export function LaneChangesPanel({
|
||||
children={treeChildren}
|
||||
expanded={expandedDirs}
|
||||
onToggleDir={toggleDir}
|
||||
onOpenFile={openWhole}
|
||||
onOpenFile={(p) => void openWhole(p)}
|
||||
onMenu={treeCtx.open}
|
||||
active={full && mode === "file" ? openFile : null}
|
||||
/>
|
||||
@@ -1235,7 +1307,7 @@ export function LaneChangesPanel({
|
||||
onClick={() => setMode(m)}
|
||||
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
|
||||
mode === m
|
||||
? "bg-accent text-white"
|
||||
? "bg-accent-solid text-white"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
@@ -1437,9 +1509,25 @@ export function LaneChangesPanel({
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{commits === null && (
|
||||
<li className="text-[11px] text-fg-muted">{t("changes.loading")}</li>
|
||||
)}
|
||||
{commits && commits.length === 0 && (
|
||||
<li className="text-[11px] text-fg-muted">{t("changes.noCommits")}</li>
|
||||
)}
|
||||
{commits && commits.length >= historyLimit && historyLimit < HISTORY_MAX && (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-history-more"
|
||||
disabled={historyLoading}
|
||||
onClick={() => setHistoryLimit((n) => Math.min(n + HISTORY_PAGE, HISTORY_MAX))}
|
||||
className="mt-1 w-full rounded px-1 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:opacity-40"
|
||||
>
|
||||
{historyLoading ? t("changes.loading") : t("changes.loadMore")}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
) : mode === "browse" && !full ? (
|
||||
browsePane
|
||||
@@ -1469,7 +1557,7 @@ export function LaneChangesPanel({
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && message.trim() && !busy) void commitStaged();
|
||||
if (isSubmitEnter(e) && message.trim() && !busy) void commitStaged();
|
||||
}}
|
||||
placeholder={t("changes.commitPlaceholder")}
|
||||
aria-label={t("changes.commitPlaceholder")}
|
||||
@@ -1670,7 +1758,7 @@ export function LaneChangesPanel({
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setDraft(null)}
|
||||
onClick={() => void confirmDrop().then((ok) => ok && setDraft(null))}
|
||||
className="shrink-0 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-40"
|
||||
>
|
||||
{t("changes.cancelEdit")}
|
||||
|
||||
@@ -18,12 +18,16 @@
|
||||
* so every windowed row is pinned to `ROW_PX` and the range is pure arithmetic
|
||||
* (`visibleRange`); the content's min-width comes from the longest line, so
|
||||
* the horizontal extent does not jump as rows come and go.
|
||||
* A line's comment box floats just under the clicked line number (fixed,
|
||||
* re-placed as the diff scrolls), not under the whole diff.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -265,7 +269,7 @@ export function analyseHunk(hunk: LaneDiffHunk): Analysed {
|
||||
/** Set by a LaneDiffView given `onComment`: a click on a NEW line's number
|
||||
* opens the comment box for that line. */
|
||||
const CommentLine = createContext<{
|
||||
open: (line: number) => void;
|
||||
open: (line: number, anchor?: HTMLElement) => void;
|
||||
title: (line: number) => string;
|
||||
} | null>(null);
|
||||
|
||||
@@ -276,7 +280,7 @@ function Gutter({ n, commentable = false }: { n: number | null; commentable?: bo
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`diff-comment-line-${n}`}
|
||||
onClick={() => comment.open(n)}
|
||||
onClick={(e) => comment.open(n, e.currentTarget)}
|
||||
title={comment.title(n)}
|
||||
className="w-10 shrink-0 select-none px-1 text-right text-fg-muted/60 hover:bg-accent/20 hover:text-accent"
|
||||
>
|
||||
@@ -741,18 +745,34 @@ export function LaneDiffView({
|
||||
const [commentAt, setCommentAt] = useState<number | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [undelivered, setUndelivered] = useState(false);
|
||||
// The clicked line number: the box floats just under it, where the eye is,
|
||||
// instead of under the whole (possibly 2000-line) diff.
|
||||
const anchorEl = useRef<HTMLElement | null>(null);
|
||||
const [anchor, setAnchor] = useState<{ top: number; left: number } | null>(null);
|
||||
const place = useCallback(() => {
|
||||
const r = anchorEl.current?.isConnected ? anchorEl.current.getBoundingClientRect() : null;
|
||||
if (r) setAnchor({ top: r.bottom + 2, left: r.left });
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (commentAt == null) return;
|
||||
window.addEventListener("resize", place);
|
||||
return () => window.removeEventListener("resize", place);
|
||||
}, [commentAt, place]);
|
||||
const openComment = useMemo(
|
||||
() =>
|
||||
onComment
|
||||
? {
|
||||
open: (line: number) => {
|
||||
open: (line: number, el?: HTMLElement) => {
|
||||
anchorEl.current = el ?? null;
|
||||
setAnchor(null);
|
||||
place();
|
||||
setCommentAt(line);
|
||||
setUndelivered(false);
|
||||
},
|
||||
title: (line: number) => t("changes.commentLine", { n: line }),
|
||||
}
|
||||
: null,
|
||||
[onComment, t]
|
||||
[onComment, t, place]
|
||||
);
|
||||
const sendComment = () => {
|
||||
const text = draft.trim();
|
||||
@@ -812,7 +832,10 @@ export function LaneDiffView({
|
||||
ref={root}
|
||||
data-testid="lane-diff-view"
|
||||
data-layout={layout}
|
||||
onScroll={(e) => win.onScroll(e.currentTarget)}
|
||||
onScroll={(e) => {
|
||||
win.onScroll(e.currentTarget);
|
||||
if (commentAt != null) place();
|
||||
}}
|
||||
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
|
||||
layout === "split"
|
||||
? boxHeight(fill, splitWindowed)
|
||||
@@ -855,7 +878,19 @@ export function LaneDiffView({
|
||||
{commentAt != null && (
|
||||
<form
|
||||
data-testid="diff-comment-box"
|
||||
className="flex flex-col gap-1 border-t border-border bg-surface-2 p-2"
|
||||
style={
|
||||
anchor
|
||||
? {
|
||||
position: "fixed",
|
||||
top: Math.min(anchor.top, window.innerHeight - 180),
|
||||
left: anchor.left,
|
||||
width: "min(28rem, calc(100vw - 2rem))",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`z-30 flex flex-col gap-1 border-border bg-surface-2 p-2 ${
|
||||
anchor ? "rounded-lg border shadow-lg" : "border-t"
|
||||
}`}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
sendComment();
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt
|
||||
* in, so a remote wanting a login opens a form here and the SAME action is
|
||||
* resent with the typed credential — the token is never rendered back.
|
||||
* Fetch, pull, push and publish say so in a toast when they succeed; a merge
|
||||
* asks through the themed confirm; the rows wrap in a narrow panel.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -37,6 +39,9 @@ import {
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { api, gitTarget, type GitAuthNeeded } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
||||
|
||||
type Action =
|
||||
@@ -170,6 +175,8 @@ export function LaneGitBar({
|
||||
setError(t("gitBar.mergeConflicts", { count: res.conflicts.length }));
|
||||
setLogin(null);
|
||||
setPassword("");
|
||||
if (action === "fetch" || action === "pull" || action === "push" || action === "publish")
|
||||
toast.success(t(`gitBar.done.${action}`));
|
||||
// The branch list this bar is showing is stale the moment a branch is
|
||||
// created, deleted or switched to, so it is re-read rather than patched.
|
||||
if (action === "publish") {
|
||||
@@ -227,9 +234,14 @@ export function LaneGitBar({
|
||||
await refreshBranches();
|
||||
};
|
||||
|
||||
const merge = (branch: string) => {
|
||||
if (!window.confirm(t("gitBar.mergeConfirm", { branch, current: git.branch }))) return;
|
||||
void run("merge", { branch });
|
||||
const merge = async (branch: string) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("gitBar.merge"),
|
||||
message: t("gitBar.mergeConfirm", { branch, current: git.branch }),
|
||||
confirmLabel: t("gitBar.merge"),
|
||||
destructive: false,
|
||||
});
|
||||
if (ok) void run("merge", { branch });
|
||||
};
|
||||
|
||||
const heldBy = new Map(
|
||||
@@ -238,7 +250,7 @@ export function LaneGitBar({
|
||||
|
||||
return (
|
||||
<div data-testid="lane-git-bar" className="space-y-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{lead}
|
||||
{/* Ahead/behind first: it is the fact that decides which of the
|
||||
buttons beside it the user actually wants. */}
|
||||
@@ -285,7 +297,7 @@ export function LaneGitBar({
|
||||
busy={busy === "merge"}
|
||||
disabled={!!busy || dirty > 0}
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeUpstreamHint")}
|
||||
onClick={() => merge(git.upstream as string)}
|
||||
onClick={() => void merge(git.upstream as string)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -362,13 +374,13 @@ export function LaneGitBar({
|
||||
</div>
|
||||
|
||||
{switching && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<select
|
||||
aria-label={t("gitBar.switchLabel")}
|
||||
value={picked}
|
||||
disabled={!!busy}
|
||||
onChange={(e) => setPicked(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary focus:border-accent focus:outline-none disabled:opacity-40"
|
||||
className="select min-w-[8rem] flex-1 !rounded !py-0.5 !pl-2 !pr-6 font-mono !text-[11px]"
|
||||
>
|
||||
<option value="">{t("gitBar.switchLabel")}</option>
|
||||
{(branches || []).map((b) => {
|
||||
@@ -396,7 +408,7 @@ export function LaneGitBar({
|
||||
busy={busy === "merge"}
|
||||
disabled={!picked || picked === git.branch || dirty > 0 || !!busy}
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeHint")}
|
||||
onClick={() => merge(picked)}
|
||||
onClick={() => void merge(picked)}
|
||||
/>
|
||||
{onNewLane && (
|
||||
<Btn
|
||||
@@ -436,7 +448,7 @@ export function LaneGitBar({
|
||||
value={newBranch}
|
||||
onChange={(e) => setNewBranch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newBranch.trim())
|
||||
if (isSubmitEnter(e) && newBranch.trim())
|
||||
void run("branch-create", { branch: newBranch.trim() });
|
||||
}}
|
||||
placeholder={t("gitBar.branchNewPlaceholder")}
|
||||
@@ -462,7 +474,7 @@ export function LaneGitBar({
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && url.trim()) void run("publish", { url: url.trim() });
|
||||
if (isSubmitEnter(e) && url.trim()) void run("publish", { url: url.trim() });
|
||||
}}
|
||||
// The server decides which URLs are acceptable (https without a
|
||||
// token, ssh, user@host:path); this only names the two usual ones.
|
||||
|
||||
@@ -267,7 +267,13 @@ export function LaneGitHeader({
|
||||
// keyboard or a touchscreen has.
|
||||
onFocus={openFiles}
|
||||
onBlur={closeFiles}
|
||||
onClick={() => setFilesOpen((v) => !v)}
|
||||
// Click only OPENS: hover (or a tap's focus) has already opened it,
|
||||
// and a toggle here shut it again on the same gesture. Leaving,
|
||||
// blurring or Escape closes it.
|
||||
onClick={openFiles}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setFilesOpen(false);
|
||||
}}
|
||||
className="shrink-0 cursor-help rounded-full border border-status-warning/40 bg-status-warning/10 px-2 py-px text-[10.5px] text-status-warning"
|
||||
>
|
||||
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { LaneNode } from "../../lib/types";
|
||||
|
||||
const STATE_CLASS: Record<LaneNode["state"], string> = {
|
||||
done: "border-status-success/60 text-status-success bg-status-success/10",
|
||||
current: "border-accent bg-accent text-white ring-2 ring-accent/30 shadow-sm",
|
||||
current: "border-accent bg-accent-solid text-white ring-2 ring-accent/30 shadow-sm",
|
||||
"passed-no-evidence": "border-status-warning/60 text-status-warning bg-status-warning/10",
|
||||
failed: "border-status-danger/60 text-status-danger bg-status-danger/10",
|
||||
pending: "border-border-light text-fg-muted bg-transparent",
|
||||
|
||||
@@ -6,16 +6,18 @@
|
||||
* opens its conversation — never a resume), open a live one in a split,
|
||||
* rename / archive / unarchive when the list offers them, copy its id, open
|
||||
* its analysis page, and close its live tab (`DELETE /api/run/:id`, what the
|
||||
* tab's X does).
|
||||
* tab's X does — after the same confirm for an AI tab, which may be mid-turn).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api, type RunHandle } from "../../lib/api";
|
||||
import type { Session } from "../../lib/types";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "../ContextMenu";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
export function SessionMenu({
|
||||
laneId,
|
||||
@@ -39,7 +41,6 @@ export function SessionMenu({
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const runUrl = run && `/workspace?lane=${laneId}&run=${encodeURIComponent(run.id)}`;
|
||||
|
||||
const pick = (fn: () => unknown) => () => {
|
||||
@@ -48,12 +49,21 @@ export function SessionMenu({
|
||||
};
|
||||
const closeTab = async () => {
|
||||
if (!run) return;
|
||||
setError(null);
|
||||
onClose();
|
||||
if (
|
||||
run.tool !== "shell" &&
|
||||
!(await confirmDialog({
|
||||
title: t("tabs.stopTitle", { name: session?.name || t("tree.newSession") }),
|
||||
message: t("tabs.stopBody"),
|
||||
confirmLabel: t("tabs.stopConfirm"),
|
||||
destructive: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api.run.kill(run.id);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -79,7 +89,7 @@ export function SessionMenu({
|
||||
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
|
||||
{t("sessionMenu.details")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(() => void navigator.clipboard.writeText(session.id))}>
|
||||
<MenuItem onSelect={pick(() => void copyText(session.id))}>
|
||||
{t("sessionMenu.copyId")}
|
||||
</MenuItem>
|
||||
</>
|
||||
@@ -92,11 +102,6 @@ export function SessionMenu({
|
||||
{t("tabs.close")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="px-2 py-1 text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,22 +2,52 @@
|
||||
* @file SessionReplay — an ENDED session opened in its workspace
|
||||
* (`/workspace?lane=<id>&session=<sessionId>`), the way Claude Desktop opens
|
||||
* an old one: its conversation, read-only (`ConversationView`), and a prompt
|
||||
* box under it (a pasted image rides along as a thumbnail). Opening it resumes nothing; only SENDING a message does —
|
||||
* `POST /api/lanes/:id/start` with `resumeSessionId` and that prompt, in the
|
||||
* first free pane slot, after which `onResumed` opens the pane (`&run=`). The
|
||||
* session's tool and folder come from its row (`GET /api/sessions/:id`), so a
|
||||
* resume runs on the tool the session ran on (the server enforces that too).
|
||||
* box under it (a pasted image rides along as a thumbnail). Opening it resumes
|
||||
* nothing; only SENDING a message does — `POST /api/lanes/:id/start` with
|
||||
* `resumeSessionId`, that prompt and `slot: "auto"` (the server picks the free
|
||||
* tab), after which `onResumed` opens the pane (`&run=`). The panes are re-read
|
||||
* first, and a session some tab already holds opens THAT tab instead of a
|
||||
* second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed
|
||||
* message is pasted there (never sent: Enter is the user's), with a toast; while
|
||||
* it is live the composer is off. Only Claude and Codex can resume a session by id —
|
||||
* any other tool's session offers a new session from the workspace home
|
||||
* instead. The header carries the transcript page's Resume/Fork
|
||||
* (`SessionResumeButton`, behind its warning). The session's tool and folder
|
||||
* come from its row (`GET /api/sessions/:id`), so a resume runs on the tool the
|
||||
* session ran on (the server enforces that too).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ArrowUp, SquareTerminal } from "lucide-react";
|
||||
import { api, type RunHandle } from "../../lib/api";
|
||||
import type { Lane, Session } from "../../lib/types";
|
||||
import { ConversationView } from "../conversation/ConversationView";
|
||||
import { PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
|
||||
import { freeSlot, paneRunId } from "./WorkspaceHome";
|
||||
import { startInFreeTabDetailed } from "./startSession";
|
||||
import { pasteToRun } from "../../lib/panePaste";
|
||||
import { toast } from "../../lib/toast";
|
||||
import i18n from "../../i18n";
|
||||
import { liveRunFor } from "./WorkspaceSessionList";
|
||||
import { SessionResumeButton } from "./SessionResumeButton";
|
||||
|
||||
/** A message for a session that a tab already holds goes into THAT tab as a
|
||||
* paste, once its terminal is on screen (it mounts after the navigation);
|
||||
* a toast says where it went — or, failing that, carries the text. */
|
||||
function pasteWhenShown(runId: string, text: string) {
|
||||
let tries = 0;
|
||||
const tick = () => {
|
||||
if (pasteToRun(runId, text)) toast.success(i18n.t("lanes:replay.pasted"));
|
||||
else if (++tries < 30) window.setTimeout(tick, 100);
|
||||
else toast.error(i18n.t("lanes:replay.notPasted", { text }));
|
||||
};
|
||||
window.setTimeout(tick, 0);
|
||||
}
|
||||
|
||||
/** The tools whose launcher resumes a session BY ID (server/lib/pty-run.js). */
|
||||
const RESUMABLE = new Set(["claude", "codex"]);
|
||||
|
||||
export function SessionReplay({
|
||||
lane,
|
||||
@@ -27,15 +57,18 @@ export function SessionReplay({
|
||||
}: {
|
||||
lane: Lane;
|
||||
sessionId: string;
|
||||
/** Every live dashboard pane, to pick a free slot. */
|
||||
/** Every live dashboard pane: a session one of them holds opens that pane. */
|
||||
liveRuns: RunHandle[];
|
||||
/** Opens the pane the resume started in. */
|
||||
onResumed: (runId: string) => void;
|
||||
/** Opens the pane the resume started in — of `laneId` when it is another
|
||||
* (nested) workspace's pane that holds the session. */
|
||||
onResumed: (runId: string, laneId?: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -46,7 +79,7 @@ export function SessionReplay({
|
||||
setImages([]);
|
||||
setError(null);
|
||||
api.sessions
|
||||
.get(sessionId)
|
||||
.get(sessionId, { events: false })
|
||||
.then((r) => live && setSession(r.session))
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
@@ -54,26 +87,32 @@ export function SessionReplay({
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
const tool = session?.tool || "claude";
|
||||
const resumable = RESUMABLE.has(tool);
|
||||
const livePane = liveRunFor(lane, liveRuns, sessionId);
|
||||
const text = promptWithImages(prompt, images);
|
||||
|
||||
const send = async () => {
|
||||
const text = promptWithImages(prompt, images);
|
||||
if (!text || busy) return;
|
||||
const slot = freeSlot(lane.id, liveRuns);
|
||||
if (slot === null) {
|
||||
setError(t("home.allBusy"));
|
||||
return;
|
||||
}
|
||||
if (!text || busy || uploading || livePane || !resumable) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await api.lanes.action(lane.id, "start", {
|
||||
slot,
|
||||
resumeSessionId: sessionId,
|
||||
prompt: text,
|
||||
});
|
||||
// Re-read: a tab that took this session since the list was read is
|
||||
// opened, never resumed a second time.
|
||||
const fresh = await api.run
|
||||
.list()
|
||||
.then((r) => r.items ?? [])
|
||||
.catch(() => liveRuns);
|
||||
const holder = liveRunFor(lane, fresh, sessionId);
|
||||
const r = holder
|
||||
? { runId: holder.id, laneId: lane.id, held: true }
|
||||
: await startInFreeTabDetailed(lane.id, { resumeSessionId: sessionId, prompt: text });
|
||||
setPrompt("");
|
||||
images.forEach((i) => URL.revokeObjectURL(i.url));
|
||||
setImages([]);
|
||||
onResumed(r.run_id ?? paneRunId(lane.id, slot));
|
||||
onResumed(r.runId, r.laneId);
|
||||
// Held already: the prompt reached nothing yet — paste it there.
|
||||
if (r.held) pasteWhenShown(r.runId, text);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -83,42 +122,79 @@ export function SessionReplay({
|
||||
|
||||
return (
|
||||
<div data-testid="session-replay" className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
{session && (
|
||||
<div className="flex justify-end">
|
||||
<SessionResumeButton session={session} />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ConversationView sessionId={sessionId} />
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border p-2 transition-colors focus-within:border-accent/50">
|
||||
<PromptEditor
|
||||
bare
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
value={prompt}
|
||||
onChange={setPrompt}
|
||||
onSubmit={() => void send()}
|
||||
placeholder={t("replay.placeholder")}
|
||||
rows={2}
|
||||
slash={(session?.tool ?? "claude") === "claude"}
|
||||
fileCwd={session?.cwd || lane.cwd}
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="text-[11px] text-fg-muted">{t("replay.hint")}</span>
|
||||
{livePane ? (
|
||||
<div
|
||||
data-testid="session-replay-live"
|
||||
className="flex 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
|
||||
type="button"
|
||||
data-testid="session-replay-send"
|
||||
onClick={() => void send()}
|
||||
disabled={busy || (!prompt.trim() && images.length === 0)}
|
||||
aria-label={t("replay.send")}
|
||||
title={t("replay.send")}
|
||||
className="ml-auto flex h-8 w-8 items-center justify-center rounded-full bg-accent text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
onClick={() => onResumed(livePane.id)}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
<SquareTerminal className="h-4 w-4" />
|
||||
{t("resume.openPane")}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="mt-1 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : session && !resumable ? (
|
||||
<div
|
||||
data-testid="session-replay-no-resume"
|
||||
className="flex 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
|
||||
type="button"
|
||||
onClick={() => navigate(`/workspace?lane=${lane.id}`)}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
{t("replay.newSession")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-border p-2 transition-colors focus-within:border-accent/50">
|
||||
<PromptEditor
|
||||
bare
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
onBusyChange={setUploading}
|
||||
value={prompt}
|
||||
onChange={setPrompt}
|
||||
onSubmit={() => void send()}
|
||||
placeholder={t("replay.placeholder")}
|
||||
rows={2}
|
||||
slash={tool === "claude"}
|
||||
fileCwd={session?.cwd || lane.cwd}
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="text-[11px] text-fg-muted">{t("replay.hint")}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="session-replay-send"
|
||||
onClick={() => void send()}
|
||||
disabled={busy || uploading || !text}
|
||||
aria-label={t("replay.send")}
|
||||
title={t("replay.send")}
|
||||
className="ml-auto flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="mt-1 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
* (`forkSession: true` → `claude --resume <id> --fork-session`), leaving the
|
||||
* original untouched, and is offered even while the original has a live pane. A
|
||||
* session that still has a live pane offers that pane instead. A resume
|
||||
* starts in the workspace's first free pane slot (`POST /api/lanes/:id/start
|
||||
* {slot, resumeSessionId}`, panes re-read first) and opens it — offered only
|
||||
* starts in the workspace's first free pane slot, picked by the server
|
||||
* (`POST /api/lanes/:id/start {slot: "auto", resumeSessionId}`, panes re-read
|
||||
* first) and opens it — offered only
|
||||
* for a session whose folder IS the workspace's, because the pane starts in
|
||||
* the workspace's folder and Claude finds a session by the folder it ran in.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -22,7 +23,7 @@ import { api, type RunHandle } from "../../lib/api";
|
||||
import type { Lane, Session } from "../../lib/types";
|
||||
import { useLanes } from "../../hooks/useLanes";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { freeSlot, paneRunId } from "./WorkspaceHome";
|
||||
import { startInFreeTabDetailed } from "./startSession";
|
||||
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
|
||||
|
||||
/** The workspace of this tool whose folder holds `cwd` most deeply — the
|
||||
@@ -81,8 +82,8 @@ export function SessionResumeButton({
|
||||
(same(session.cwd, lane.cwd) ||
|
||||
sessionWorktreeName(lane, { cwd: session.cwd } as Session) !== null);
|
||||
if (!run && !resumable) return null;
|
||||
const open = (runId: string) =>
|
||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||||
const open = (runId: string, laneId = lane.id) =>
|
||||
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
||||
|
||||
const resume = async (fork: boolean) => {
|
||||
// Busy first: the Confirm button is disabled only through it, and the
|
||||
@@ -99,17 +100,12 @@ export function SessionResumeButton({
|
||||
setRuns(fresh);
|
||||
const live = liveRunFor(lane, fresh, session.id);
|
||||
if (live && !fork) return open(live.id);
|
||||
const slot = freeSlot(lane.id, fresh);
|
||||
if (slot === null) {
|
||||
setError(t("home.allBusy"));
|
||||
return;
|
||||
}
|
||||
const r = await api.lanes.action(lane.id, "start", {
|
||||
slot,
|
||||
// A pane of another (nested) workspace may hold it: open it there.
|
||||
const r = await startInFreeTabDetailed(lane.id, {
|
||||
resumeSessionId: session.id,
|
||||
...(fork ? { forkSession: true } : {}),
|
||||
});
|
||||
open(r.run_id ?? paneRunId(lane.id, slot));
|
||||
open(r.runId, r.laneId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
* router's models, plus Effort); under it the quieter choices — AI Router
|
||||
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
|
||||
* a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it
|
||||
* owns and has not archived (`?lane=&archived=0`, with each one's state dot),
|
||||
* then the archived ones folded with Unarchive; a live session opens its pane,
|
||||
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
|
||||
* **Show more** past the first page, and the sidebar row's Rename/Archive in
|
||||
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
|
||||
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
|
||||
* Folders (`GET/PUT/POST/DELETE /api/lanes/:id/sources…`, see
|
||||
* server/lib/lane-sources.js). Folder suggestions are the repo's untracked
|
||||
@@ -20,6 +21,10 @@
|
||||
*/
|
||||
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -64,22 +69,19 @@ import {
|
||||
liveRunFor,
|
||||
SessionFold,
|
||||
sessionDot,
|
||||
sessionDotLabel,
|
||||
sessionWorktreeName,
|
||||
useSessionPage,
|
||||
} from "./WorkspaceSessionList";
|
||||
|
||||
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
|
||||
export function paneRunId(laneId: number, slot: number): string {
|
||||
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
|
||||
}
|
||||
|
||||
/** The first pane slot with no live run, or null when all four are busy. */
|
||||
export function freeSlot(laneId: number, runs: RunHandle[]): number | null {
|
||||
const busy = new Set(
|
||||
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
|
||||
);
|
||||
return [0, 1, 2, 3].find((s) => !busy.has(s)) ?? null;
|
||||
}
|
||||
export {
|
||||
freeSlot,
|
||||
laneOfRun,
|
||||
paneRunId,
|
||||
startInFreeTab,
|
||||
startInFreeTabDetailed,
|
||||
} from "./startSession";
|
||||
import { startInFreeTab } from "./startSession";
|
||||
|
||||
function fmtBytes(n: number | null): string {
|
||||
if (!n) return "";
|
||||
@@ -88,6 +90,9 @@ function fmtBytes(n: number | null): string {
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** Recents' page size; Show more adds another. */
|
||||
const RECENT_PAGE = 30;
|
||||
|
||||
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
||||
|
||||
type HomeProps = Parameters<typeof HomeBody>[0];
|
||||
@@ -105,8 +110,9 @@ function HomeBody({
|
||||
onRenamed,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** Row-menu items the page carries out (its modals); rename is done here. */
|
||||
onMenuAction: (action: WorkspaceMenuAction) => void;
|
||||
/** Row-menu items the page carries out (its modals); rename is done here.
|
||||
* `started` comes with the run it opened. */
|
||||
onMenuAction: (action: WorkspaceMenuAction, runId?: string) => void;
|
||||
/** Called after a rename, so the page re-reads the lane list. */
|
||||
onRenamed?: () => void;
|
||||
}) {
|
||||
@@ -158,9 +164,17 @@ function HomeBody({
|
||||
// its own (`claude --worktree`), made and cleaned up by Claude Code.
|
||||
const [inWorktree, setInWorktree] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const startingRef = useRef(false);
|
||||
// An image still uploading: its path is not in the prompt yet.
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
|
||||
const [sessions, setSessions] = useState<Session[] | null>(null);
|
||||
const [sessionTotal, setSessionTotal] = useState(0);
|
||||
const [recentLimit, setRecentLimit] = useState(RECENT_PAGE);
|
||||
// A Recents row being renamed (its session id) and the draft.
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [nameDraft, setNameDraft] = useState("");
|
||||
const [view, setView] = useState<LaneSourcesView | null>(null);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
|
||||
@@ -172,6 +186,8 @@ function HomeBody({
|
||||
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
||||
const [title, setTitle] = useState<string | null>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const memoryPanel = useRef<HTMLDivElement>(null);
|
||||
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
|
||||
const recentCtx = useContextMenu<Session>();
|
||||
const sourceCtx = useContextMenu<LaneSource>();
|
||||
|
||||
@@ -186,13 +202,17 @@ function HomeBody({
|
||||
.then((r) => live && setLiveRuns(r.items))
|
||||
.catch(() => undefined);
|
||||
api.sessions
|
||||
.list({ lane: lane.id, archived: false, limit: 30 })
|
||||
.then((r) => live && setSessions(r.sessions))
|
||||
.list({ lane: lane.id, archived: false, limit: recentLimit })
|
||||
.then((r) => {
|
||||
if (!live) return;
|
||||
setSessions(r.sessions);
|
||||
setSessionTotal(r.total ?? r.sessions.length);
|
||||
})
|
||||
.catch(() => live && setSessions([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [lane.id, runKey, version]);
|
||||
}, [lane.id, runKey, version, recentLimit]);
|
||||
const archived = useSessionPage(
|
||||
{ lane: lane.id, archived: true, limit: 30 },
|
||||
`${runKey}:${version}`
|
||||
@@ -269,18 +289,17 @@ function HomeBody({
|
||||
}
|
||||
};
|
||||
|
||||
const text = promptWithImages(prompt, images);
|
||||
const start = async () => {
|
||||
const slot = freeSlot(lane.id, liveRuns);
|
||||
if (slot === null) {
|
||||
setError(t("home.allBusy"));
|
||||
return;
|
||||
}
|
||||
// Enter reaches here past the disabled button: the same guards again.
|
||||
// A ref, not only the state: two Enters in one tick both read `starting` false.
|
||||
if (startingRef.current || uploading || !text) return;
|
||||
startingRef.current = true;
|
||||
setStarting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await api.lanes.action(lane.id, "start", {
|
||||
slot,
|
||||
prompt: promptWithImages(prompt, images),
|
||||
const runId = await startInFreeTab(lane.id, {
|
||||
prompt: text,
|
||||
// Only a different tool is named: the workspace's own keeps starting
|
||||
// even after it was disabled in Settings.
|
||||
...(tool !== lane.tool ? { tool } : {}),
|
||||
@@ -295,15 +314,61 @@ function HomeBody({
|
||||
setPrompt("");
|
||||
images.forEach((i) => URL.revokeObjectURL(i.url));
|
||||
setImages([]);
|
||||
const runId = r.run_id ?? paneRunId(lane.id, slot);
|
||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||||
} catch (err) {
|
||||
setError(message(err));
|
||||
} finally {
|
||||
startingRef.current = false;
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// The sidebar row's Rename and Archive, for a Recents row.
|
||||
const patchSession = async (s: Session, body: { name?: string; archived?: boolean }) => {
|
||||
try {
|
||||
await api.sessions.patch(s.id, body);
|
||||
setVersion((v) => v + 1);
|
||||
} catch (err) {
|
||||
setError(message(err));
|
||||
}
|
||||
};
|
||||
const archiveSession = async (s: Session) => {
|
||||
const live = !!liveRunFor(lane, liveRuns, s.id);
|
||||
const wt = sessionWorktreeName(lane, s);
|
||||
const ok = await confirmDialog(
|
||||
wt && !live
|
||||
? {
|
||||
title: t("tree.archiveWorktreeTitle"),
|
||||
message: t("tree.archiveWorktreeBody", { name: wt }),
|
||||
confirmLabel: t("tree.archiveWorktreeConfirm"),
|
||||
}
|
||||
: live
|
||||
? {
|
||||
title: t("tree.archiveLiveTitle"),
|
||||
message: t("tree.archiveLiveBody"),
|
||||
confirmLabel: t("sessionMenu.archive"),
|
||||
destructive: false,
|
||||
}
|
||||
: {
|
||||
title: t("tree.archiveSession"),
|
||||
confirmLabel: t("sessionMenu.archive"),
|
||||
destructive: false,
|
||||
}
|
||||
);
|
||||
if (!ok) return;
|
||||
if (wt && !live) {
|
||||
try {
|
||||
await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => {
|
||||
if ((err as { code?: string }).code !== "ENOWORKTREE") throw err;
|
||||
});
|
||||
} catch (err) {
|
||||
setError(message(err));
|
||||
return;
|
||||
}
|
||||
}
|
||||
await patchSession(s, { archived: true });
|
||||
};
|
||||
|
||||
const openSession = (s: Session) => {
|
||||
const r = liveRunFor(lane, liveRuns, s.id);
|
||||
navigate(
|
||||
@@ -364,7 +429,7 @@ function HomeBody({
|
||||
onBlur={() => setTitle(null)}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Escape") setTitle(null);
|
||||
if (e.key !== "Enter") return;
|
||||
if (!isSubmitEnter(e)) return;
|
||||
const next = title.trim();
|
||||
setTitle(null);
|
||||
if (next === (lane.title || "")) return;
|
||||
@@ -421,6 +486,7 @@ function HomeBody({
|
||||
fileCwd={lane.cwd}
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
onBusyChange={setUploading}
|
||||
bare
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
@@ -459,10 +525,10 @@ function HomeBody({
|
||||
type="button"
|
||||
data-testid="workspace-home-start"
|
||||
onClick={() => void start()}
|
||||
disabled={starting}
|
||||
disabled={starting || uploading || !text}
|
||||
aria-label={t("home.start")}
|
||||
title={t("home.start")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -582,6 +648,29 @@ function HomeBody({
|
||||
<ul data-testid="workspace-recents" className="mt-2 divide-y divide-border/60">
|
||||
{(sessions ?? []).map((s) => {
|
||||
const live = !!liveRunFor(lane, liveRuns, s.id);
|
||||
if (renamingId === s.id) {
|
||||
return (
|
||||
<li key={s.id} className="py-1.5">
|
||||
<input
|
||||
data-testid={`workspace-recent-rename-${s.id}`}
|
||||
aria-label={t("tree.renameSession")}
|
||||
autoFocus
|
||||
value={nameDraft}
|
||||
maxLength={200}
|
||||
onChange={(e) => setNameDraft(e.target.value)}
|
||||
onBlur={() => setRenamingId(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setRenamingId(null);
|
||||
if (!isSubmitEnter(e)) return;
|
||||
setRenamingId(null);
|
||||
const name = nameDraft.trim();
|
||||
if (name && name !== s.name) void patchSession(s, { name });
|
||||
}}
|
||||
className="w-full rounded border border-accent bg-surface-1 px-2 py-1 text-sm text-fg-primary"
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li key={s.id}>
|
||||
<button
|
||||
@@ -592,7 +681,11 @@ function HomeBody({
|
||||
onContextMenu={(e) => recentCtx.open(e, s)}
|
||||
className="flex w-full items-center gap-3 py-2.5 text-left hover:bg-surface-3"
|
||||
>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${sessionDot(s, live)}`} />
|
||||
<span
|
||||
aria-hidden
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${sessionDot(s, live)}`}
|
||||
/>
|
||||
<span className="sr-only">{t(`tree.state.${sessionDotLabel(s, live)}`)}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-fg-primary">
|
||||
{s.name || s.id.slice(0, 8)}
|
||||
</span>
|
||||
@@ -618,6 +711,16 @@ function HomeBody({
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{sessions !== null && sessionTotal > sessions.length && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="workspace-recents-more"
|
||||
onClick={() => setRecentLimit((n) => n + RECENT_PAGE)}
|
||||
className="mt-1 text-xs text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
{t("tree.showMore", { count: Math.min(sessionTotal - sessions.length, RECENT_PAGE) })}
|
||||
</button>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<SessionFold
|
||||
testId={`workspace-archived-${lane.id}`}
|
||||
@@ -632,7 +735,11 @@ function HomeBody({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside data-testid="workspace-context" className="card w-80 shrink-0 self-start p-0">
|
||||
<aside
|
||||
data-testid="workspace-context"
|
||||
aria-label={t("home.contextPanel")}
|
||||
className="card w-80 shrink-0 self-start p-0"
|
||||
>
|
||||
{view && !view.supported && (
|
||||
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
|
||||
{t("home.unsupported", { tool: aiTools.label(lane.tool) })}
|
||||
@@ -887,11 +994,11 @@ function HomeBody({
|
||||
onClick={() => setMemoryOpen(false)}
|
||||
>
|
||||
<div
|
||||
ref={memoryPanel}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("home.memory")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.key === "Escape" && setMemoryOpen(false)}
|
||||
className="flex max-h-[85vh] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
||||
>
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between">
|
||||
@@ -919,9 +1026,30 @@ function HomeBody({
|
||||
key={f.path}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`workspace-memory-tab-${i}`}
|
||||
aria-controls="workspace-memory-panel"
|
||||
tabIndex={i === memoryTab ? 0 : -1}
|
||||
title={f.path}
|
||||
aria-selected={i === memoryTab}
|
||||
onClick={() => setMemoryTab(i)}
|
||||
// APG tabs: arrows move (and select), one tab stop.
|
||||
onKeyDown={(e) => {
|
||||
const n = memory.length;
|
||||
const to =
|
||||
e.key === "ArrowRight"
|
||||
? (i + 1) % n
|
||||
: e.key === "ArrowLeft"
|
||||
? (i - 1 + n) % n
|
||||
: e.key === "Home"
|
||||
? 0
|
||||
: e.key === "End"
|
||||
? n - 1
|
||||
: null;
|
||||
if (to === null) return;
|
||||
e.preventDefault();
|
||||
setMemoryTab(to);
|
||||
document.getElementById(`workspace-memory-tab-${to}`)?.focus();
|
||||
}}
|
||||
className={`flex-shrink-0 whitespace-nowrap rounded-lg border px-3 py-1.5 font-mono text-xs transition-colors ${
|
||||
i === memoryTab
|
||||
? "border-accent/30 bg-accent/15 text-accent"
|
||||
@@ -933,7 +1061,12 @@ function HomeBody({
|
||||
))}
|
||||
</div>
|
||||
{memory[memoryTab] && (
|
||||
<div role="tabpanel" className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div
|
||||
role="tabpanel"
|
||||
id="workspace-memory-panel"
|
||||
aria-labelledby={`workspace-memory-tab-${memoryTab}`}
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
<MarkdownPreview text={memory[memoryTab].content} />
|
||||
</div>
|
||||
)}
|
||||
@@ -949,6 +1082,12 @@ function HomeBody({
|
||||
session={recentCtx.menu.item}
|
||||
run={liveRunFor(lane, liveRuns, recentCtx.menu.item.id)}
|
||||
onClose={recentCtx.close}
|
||||
onRename={() => {
|
||||
const s = recentCtx.menu!.item;
|
||||
setNameDraft(s.name || "");
|
||||
setRenamingId(s.id);
|
||||
}}
|
||||
onArchive={() => void archiveSession(recentCtx.menu!.item)}
|
||||
/>
|
||||
)}
|
||||
{sourceCtx.menu && (
|
||||
@@ -960,7 +1099,7 @@ function HomeBody({
|
||||
>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
void navigator.clipboard.writeText(sourceCtx.menu!.item.path);
|
||||
void copyText(sourceCtx.menu!.item.path);
|
||||
sourceCtx.close();
|
||||
}}
|
||||
>
|
||||
@@ -985,7 +1124,9 @@ function HomeBody({
|
||||
x={menuAt.x}
|
||||
y={menuAt.y}
|
||||
onClose={() => setMenuAt(null)}
|
||||
onAction={(a) => (a === "rename" ? setTitle(lane.title || "") : onMenuAction(a))}
|
||||
onAction={(a, runId) =>
|
||||
a === "rename" ? setTitle(lane.title || "") : onMenuAction(a, runId)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* session there) and `⋯` (the row menu, `WorkspaceRowMenu.tsx`, also on
|
||||
* right-click). Dragging it, or Alt+↑/↓, reorders the list — a viewer
|
||||
* preference kept in localStorage (`laneOrderStorage.ts`), never written to
|
||||
* the lane row.
|
||||
* the lane row. Only a workspace row's own drag (`LANE_DRAG_TYPE`) is taken —
|
||||
* a file, a tab or a text dragged over is not a reorder. F2 renames too; the
|
||||
* dot's meaning is screen-reader text, and an idle dot is a ring.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -14,6 +16,12 @@ import { useState, type MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from "lucide-react";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
|
||||
/** What a workspace row's drag carries: its index in the list. */
|
||||
export const LANE_DRAG_TYPE = "application/x-agenthub-lane";
|
||||
const isLaneDrag = (e: { dataTransfer: DataTransfer | null }) =>
|
||||
!!e.dataTransfer?.types?.includes(LANE_DRAG_TYPE);
|
||||
|
||||
/** The row's dot: the lane's liveness, plus a running pulse and needs-you. */
|
||||
export function workspaceDot(lane: Lane): string {
|
||||
@@ -21,7 +29,17 @@ export function workspaceDot(lane: Lane): string {
|
||||
if (lane.liveness === "dead" || lane.status === "error") return "bg-status-danger";
|
||||
if (lane.status === "running") return "bg-status-success animate-pulse-slow";
|
||||
if (lane.liveness === "active") return "bg-status-success";
|
||||
return "bg-surface-4";
|
||||
// A ring, not a fill: an idle surface-4 dot vanished on the light themes.
|
||||
return "border border-fg-muted";
|
||||
}
|
||||
|
||||
/** What the dot means (`row.state.*`), for a screen reader. */
|
||||
export function workspaceDotLabel(lane: Lane): string {
|
||||
if (lane.needs_action) return "needsYou";
|
||||
if (lane.liveness === "dead" || lane.status === "error") return "error";
|
||||
if (lane.status === "running") return "running";
|
||||
if (lane.liveness === "active") return "active";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
export function workspaceName(lane: Lane): string {
|
||||
@@ -78,19 +96,23 @@ export function WorkspaceRow({
|
||||
draggable={!renaming}
|
||||
onDragStart={(e) => {
|
||||
// Firefox starts no drag at all without a setData call.
|
||||
e.dataTransfer.setData("text/plain", String(index));
|
||||
e.dataTransfer.setData(LANE_DRAG_TYPE, String(index));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!isLaneDrag(e)) return;
|
||||
e.preventDefault();
|
||||
setDropTarget(true);
|
||||
}}
|
||||
onDragLeave={() => setDropTarget(false)}
|
||||
onDragEnd={() => setDropTarget(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDropTarget(false);
|
||||
const from = Number(e.dataTransfer.getData("text/plain"));
|
||||
if (!isLaneDrag(e)) return;
|
||||
e.preventDefault();
|
||||
const raw = e.dataTransfer.getData(LANE_DRAG_TYPE);
|
||||
// Number("") is 0: an empty payload must not move the first row.
|
||||
const from = raw === "" ? NaN : Number(raw);
|
||||
if (Number.isInteger(from)) onReorder(from, index);
|
||||
}}
|
||||
onContextMenu={openMenu}
|
||||
@@ -125,7 +147,7 @@ export function WorkspaceRow({
|
||||
// Blur CANCELS — Enter is the only save, so one edit fires one PATCH.
|
||||
onBlur={() => onRenameEnd(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onRenameEnd(draft.trim());
|
||||
if (isSubmitEnter(e)) onRenameEnd(draft.trim());
|
||||
if (e.key === "Escape") onRenameEnd(null);
|
||||
}}
|
||||
className="my-1 min-w-0 flex-1 rounded border border-accent bg-surface-1 px-1.5 py-0.5 text-[13px] text-fg-primary"
|
||||
@@ -135,12 +157,19 @@ export function WorkspaceRow({
|
||||
type="button"
|
||||
data-testid={`workspace-open-${lane.id}`}
|
||||
aria-current={selected ? "true" : undefined}
|
||||
onClick={onSelect}
|
||||
// A double-click's second click renames instead of opening again.
|
||||
onClick={(e) => e.detail < 2 && onSelect()}
|
||||
onDoubleClick={() => {
|
||||
setDraft(lane.title || "");
|
||||
onRenameStart();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "F2") {
|
||||
e.preventDefault();
|
||||
setDraft(lane.title || "");
|
||||
onRenameStart();
|
||||
return;
|
||||
}
|
||||
if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
|
||||
e.preventDefault();
|
||||
onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1));
|
||||
@@ -148,7 +177,8 @@ export function WorkspaceRow({
|
||||
title={`${lane.cwd}\n${t("row.hint")}`}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left"
|
||||
>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
|
||||
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
|
||||
<span className="sr-only">{t(`row.state.${workspaceDotLabel(lane)}`)}</span>
|
||||
<span className="truncate text-[13px] text-fg-primary">{name}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* @file WorkspaceRowMenu — the sidebar workspace row's context menu, opened at
|
||||
* the pointer (right-click) or under the row's `⋯` button. Every item is an
|
||||
* existing call: Start/Stop (`POST /api/lanes/:id/start` slot 0 / `stop`),
|
||||
* existing call: Start (`POST /api/lanes/:id/start`, the free tab the server
|
||||
* picks — then the session opens, `onAction("started", runId)`) / Stop
|
||||
* (`stop`, every tab of the workspace — counted from `GET /api/run` when the
|
||||
* menu opens, and confirmed: "Stop N sessions?"),
|
||||
* Rename (the row's inline field), New workspace here (the Add modal prefilled),
|
||||
* Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
|
||||
* managed lane. The server's guards are the real ones; the menu only hides
|
||||
@@ -10,11 +13,15 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { startInFreeTab } from "./startSession";
|
||||
|
||||
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove" | "started";
|
||||
|
||||
@@ -29,37 +36,70 @@ export function WorkspaceRowMenu({
|
||||
x: number;
|
||||
y: number;
|
||||
onClose: () => void;
|
||||
/** Items the tree itself carries out (rename field, modals, selection). */
|
||||
onAction: (action: WorkspaceMenuAction) => void;
|
||||
/** Items the tree itself carries out (rename field, modals, selection);
|
||||
* `started` comes with the run it opened. */
|
||||
onAction: (action: WorkspaceMenuAction, runId?: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Every live tab of the workspace — slot 0 alone undercounts a split.
|
||||
const [liveCount, setLiveCount] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api.run
|
||||
.list()
|
||||
.then(
|
||||
(r) =>
|
||||
alive &&
|
||||
setLiveCount(
|
||||
(r.items ?? []).filter((x) => x.laneId === lane.id && x.status === "running").length
|
||||
)
|
||||
)
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [lane.id]);
|
||||
const live = liveCount === null ? !!lane.run_id || lane.status === "running" : liveCount > 0;
|
||||
|
||||
const run = async (fn: () => Promise<unknown>, then?: WorkspaceMenuAction) => {
|
||||
setError(null);
|
||||
const stop = async () => {
|
||||
onClose();
|
||||
const n = liveCount ?? 1;
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("menu.stopTitle", { count: n }),
|
||||
message: t("menu.stopBody"),
|
||||
confirmLabel: t("menu.stop"),
|
||||
destructive: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await fn();
|
||||
if (then) onAction(then);
|
||||
onClose();
|
||||
await api.lanes.action(lane.id, "stop");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
const start = async () => {
|
||||
onClose();
|
||||
try {
|
||||
onAction("started", await startInFreeTab(lane.id, { prompt: "" }));
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
const live = !!lane.run_id || lane.status === "running";
|
||||
return (
|
||||
<ContextMenu x={x} y={y} onClose={onClose} testId={`workspace-row-menu-${lane.id}`}>
|
||||
<MenuItem
|
||||
onSelect={() =>
|
||||
live
|
||||
? void run(() => api.lanes.action(lane.id, "stop"))
|
||||
: void run(() => api.lanes.action(lane.id, "start", { slot: 0, prompt: "" }), "started")
|
||||
}
|
||||
>
|
||||
<MenuItem onSelect={() => void (live ? stop() : start())}>
|
||||
{live ? t("menu.stop") : t("menu.start")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>
|
||||
<MenuItem onSelect={() => onAction("new-here")}>{t("menu.newHere")}</MenuItem>
|
||||
<MenuItem onSelect={() => void run(() => navigator.clipboard.writeText(lane.cwd))}>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
onClose();
|
||||
void copyText(lane.cwd);
|
||||
}}
|
||||
>
|
||||
{t("menu.copyPath")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
@@ -71,11 +111,6 @@ export function WorkspaceRowMenu({
|
||||
<MenuItem danger onSelect={() => onAction("remove")}>
|
||||
{t("menu.remove")}
|
||||
</MenuItem>
|
||||
{error && (
|
||||
<p role="alert" className="px-2 py-1 text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,10 +17,16 @@
|
||||
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
|
||||
* `SessionReplay`): its conversation with a prompt box, resumed only when a
|
||||
* message is SENT, because a resume re-reads the whole transcript into context.
|
||||
* The list shows `SESSION_LIMIT` rows and a **Show N more** under them. A
|
||||
* live tab whose session was archived is not brought back as a New session,
|
||||
* and archiving a live one asks first. F2 renames the focused row too; each
|
||||
* dot carries its meaning as screen-reader text.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { Archive, GitBranch } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
@@ -57,7 +63,7 @@ export function sessionWorktreeName(lane: Lane, s: Session): string | null {
|
||||
}
|
||||
|
||||
/** A session row's dot: waiting for the user, failed, held by a live pane, or
|
||||
* at rest. */
|
||||
* at rest (a ring, which reads on every theme). */
|
||||
export function sessionDot(session: Session, live: boolean): string {
|
||||
if (isSessionAwaitingInput(session)) return "bg-status-warning";
|
||||
if (session.status === "error") return "bg-status-danger";
|
||||
@@ -65,6 +71,13 @@ export function sessionDot(session: Session, live: boolean): string {
|
||||
return "border border-fg-muted";
|
||||
}
|
||||
|
||||
/** What the dot means, for a screen reader (`tree.state.*`). */
|
||||
export function sessionDotLabel(session: Session, live: boolean): string {
|
||||
if (isSessionAwaitingInput(session)) return "awaiting";
|
||||
if (session.status === "error") return "error";
|
||||
return live ? "live" : "idle";
|
||||
}
|
||||
|
||||
export type SessionPage = { sessions: Session[]; total: number };
|
||||
|
||||
/** One page of `GET /api/sessions`, re-read whenever `refreshKey` changes. */
|
||||
@@ -147,7 +160,13 @@ export function SessionFold({
|
||||
onContextMenu={laneId === undefined ? undefined : (e) => ctx.open(e, s)}
|
||||
>
|
||||
<Link
|
||||
to={`/sessions/${s.id}`}
|
||||
// An archived session opens in its workspace like any other
|
||||
// (its replay), not on the analysis page.
|
||||
to={
|
||||
laneId === undefined
|
||||
? `/sessions/${s.id}`
|
||||
: `/workspace?lane=${laneId}&session=${encodeURIComponent(s.id)}`
|
||||
}
|
||||
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}`}
|
||||
className="min-w-0 flex-1 truncate rounded-md py-1 text-[12px] text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
@@ -225,6 +244,8 @@ export function WorkspaceSessionList({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const ctx = useContextMenu<RunHandle>();
|
||||
const sessionCtx = useContextMenu<Session>();
|
||||
// Rows shown; Show more adds a page.
|
||||
const [limit, setLimit] = useState(SESSION_LIMIT);
|
||||
|
||||
// This workspace's live AI tabs. A tab started bare (no prompt) has its
|
||||
// session id from the start, but no session row until a hook creates one.
|
||||
@@ -237,8 +258,9 @@ export function WorkspaceSessionList({
|
||||
const refreshKey = `${lane.session_id}:${lane.status}:${lane.needs_action}:${version}:${laneRuns
|
||||
.map((r) => `${r.id}=${r.sessionId ?? ""}`)
|
||||
.join(",")}`;
|
||||
const own = useSessionPage({ lane: lane.id, archived: false, limit: SESSION_LIMIT }, refreshKey);
|
||||
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
|
||||
const sessions = own?.sessions ?? (own === null ? null : []);
|
||||
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const shown = (sessions ?? []).filter(
|
||||
@@ -248,10 +270,41 @@ export function WorkspaceSessionList({
|
||||
// A live tab whose session has no row yet: shown as a New session at the
|
||||
// top, like Claude Desktop, until its first hook brings the real row. Not
|
||||
// during a search — it has no name to match.
|
||||
// A tab whose session HAS a row the list does not show is archived (or
|
||||
// past the page): looked up once, so archiving a live session never brings
|
||||
// it back as a New session.
|
||||
const orphanIds =
|
||||
sessions === null
|
||||
? ""
|
||||
: laneRuns
|
||||
.filter((r) => r.sessionId && !sessions.some((s) => s.id === r.sessionId))
|
||||
.map((r) => r.sessionId)
|
||||
.join(",");
|
||||
const [hasRow, setHasRow] = useState<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
if (!orphanIds) return;
|
||||
let live = true;
|
||||
void Promise.all(
|
||||
orphanIds.split(",").map((id) =>
|
||||
api.sessions
|
||||
// The row only: a session's events are its whole history.
|
||||
.get(id, { events: false })
|
||||
.then(() => id)
|
||||
.catch(() => null)
|
||||
)
|
||||
).then((found) => live && setHasRow(new Set(found.filter((x): x is string => !!x))));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [orphanIds]);
|
||||
const fresh =
|
||||
sessions === null || q
|
||||
? []
|
||||
: laneRuns.filter((r) => !r.sessionId || !sessions.some((s) => s.id === r.sessionId));
|
||||
: laneRuns.filter(
|
||||
(r) =>
|
||||
!r.sessionId ||
|
||||
(!sessions.some((s) => s.id === r.sessionId) && !hasRow.has(r.sessionId))
|
||||
);
|
||||
const count = sessions === null ? null : shown.length;
|
||||
useEffect(() => {
|
||||
if (count !== null) onMatches?.(count);
|
||||
@@ -274,10 +327,21 @@ export function WorkspaceSessionList({
|
||||
setDraft(s.name || "");
|
||||
setRenaming(s.id);
|
||||
};
|
||||
const archive = (s: Session) =>
|
||||
sessionWorktreeName(lane, s) && !runFor(s)
|
||||
? setArchiving(s)
|
||||
: void patch(s, { archived: true });
|
||||
const archive = async (s: Session) => {
|
||||
if (sessionWorktreeName(lane, s) && !runFor(s)) return setArchiving(s);
|
||||
// A live one keeps running in its tab; archiving only hides its row.
|
||||
if (
|
||||
runFor(s) &&
|
||||
!(await confirmDialog({
|
||||
title: t("tree.archiveLiveTitle"),
|
||||
message: t("tree.archiveLiveBody"),
|
||||
confirmLabel: t("sessionMenu.archive"),
|
||||
destructive: false,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await patch(s, { archived: true });
|
||||
};
|
||||
const archiveWithWorktree = async (s: Session) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
@@ -347,7 +411,8 @@ export function WorkspaceSessionList({
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-status-success" />
|
||||
<span aria-hidden className="h-1.5 w-1.5 shrink-0 rounded-full bg-status-success" />
|
||||
<span className="sr-only">{t("tree.state.live")}</span>
|
||||
<span className="truncate italic">{t("tree.newSession")}</span>
|
||||
<AiToolIcon
|
||||
tool={{ id: r.tool, label: tools.label(r.tool) }}
|
||||
@@ -373,7 +438,7 @@ export function WorkspaceSessionList({
|
||||
onBlur={() => setRenaming(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setRenaming(null);
|
||||
if (e.key !== "Enter") return;
|
||||
if (!isSubmitEnter(e)) return;
|
||||
setRenaming(null);
|
||||
const name = draft.trim();
|
||||
if (name && name !== s.name) void patch(s, { name });
|
||||
@@ -389,7 +454,10 @@ export function WorkspaceSessionList({
|
||||
type="button"
|
||||
data-testid={`workspace-session-${s.id}`}
|
||||
data-live={run ? "true" : undefined}
|
||||
onClick={(e) =>
|
||||
onClick={(e) => {
|
||||
// A double-click's second click renames; it must not
|
||||
// navigate (a second history entry) on top of the first.
|
||||
if (e.detail > 1) return;
|
||||
navigate(
|
||||
run
|
||||
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`
|
||||
@@ -397,9 +465,15 @@ export function WorkspaceSessionList({
|
||||
// Ctrl/Cmd+click a live one: open it in a split beside the
|
||||
// focused pane (Workspace reads this state, not the URL).
|
||||
run && (e.ctrlKey || e.metaKey) ? { state: { split: true } } : undefined
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
onDoubleClick={() => startRename(s)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "F2") {
|
||||
e.preventDefault();
|
||||
startRename(s);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => sessionCtx.open(e, s)}
|
||||
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
|
||||
sessionWorktreeName(lane, s)
|
||||
@@ -414,8 +488,10 @@ export function WorkspaceSessionList({
|
||||
>
|
||||
<span
|
||||
data-testid={`workspace-session-dot-${s.id}`}
|
||||
aria-hidden
|
||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${sessionDot(s, !!run)}`}
|
||||
/>
|
||||
<span className="sr-only">{t(`tree.state.${sessionDotLabel(s, !!run)}`)}</span>
|
||||
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
|
||||
{sessionWorktreeName(lane, s) && (
|
||||
<GitBranch
|
||||
@@ -432,10 +508,12 @@ export function WorkspaceSessionList({
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-session-archive-${s.id}`}
|
||||
onClick={() => archive(s)}
|
||||
onClick={() => void archive(s)}
|
||||
title={t("tree.archiveSession")}
|
||||
aria-label={t("tree.archiveSession")}
|
||||
className="absolute right-1 top-1/2 hidden -translate-y-1/2 rounded p-0.5 text-fg-muted hover:bg-surface-4 hover:text-fg-primary focus:block group-hover:block"
|
||||
// Invisible, not `hidden`: a keyboard user must be able to
|
||||
// Tab onto it, and it shows when focused.
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -443,6 +521,16 @@ export function WorkspaceSessionList({
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{more > 0 && !q && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-sessions-more-${lane.id}`}
|
||||
onClick={() => setLimit((n) => n + SESSION_LIMIT)}
|
||||
className="w-full py-1 pl-8 pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
{t("tree.showMore", { count: Math.min(more, SESSION_LIMIT) })}
|
||||
</button>
|
||||
)}
|
||||
{failure}
|
||||
{confirmArchive}
|
||||
{ctx.menu && (
|
||||
@@ -464,7 +552,7 @@ export function WorkspaceSessionList({
|
||||
run={runFor(sessionCtx.menu.item)}
|
||||
onClose={sessionCtx.close}
|
||||
onRename={() => startRename(sessionCtx.menu!.item)}
|
||||
onArchive={() => archive(sessionCtx.menu!.item)}
|
||||
onArchive={() => void archive(sessionCtx.menu!.item)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -15,9 +15,15 @@ import { render, screen, waitFor, act, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { LaneChangesPanel } from "../LaneChangesPanel";
|
||||
import { confirmDiscard } from "../../../lib/confirm";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { LaneChanges } from "../../../lib/types";
|
||||
|
||||
// The themed confirm needs its host (App); answer it here.
|
||||
vi.mock("../../../lib/confirm", () => ({
|
||||
confirmDiscard: vi.fn(async () => true),
|
||||
confirmDialog: vi.fn(async () => true),
|
||||
}));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
||||
api: {
|
||||
@@ -134,7 +140,13 @@ beforeEach(() => {
|
||||
|
||||
/** The Workspace right panel's Files/Changes activity-bar buttons, around the
|
||||
* panel — the tab is the Workspace's state, passed in as `tab`. */
|
||||
function Tabbed({ laneId }: { laneId: number }) {
|
||||
function Tabbed({
|
||||
laneId,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
laneId: number;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<"files" | "changes">("changes");
|
||||
return (
|
||||
<>
|
||||
@@ -144,7 +156,7 @@ function Tabbed({ laneId }: { laneId: number }) {
|
||||
<button type="button" onClick={() => setTab("changes")}>
|
||||
Changes
|
||||
</button>
|
||||
<LaneChangesPanel laneId={laneId} tab={tab} />
|
||||
<LaneChangesPanel laneId={laneId} tab={tab} onDirtyChange={onDirtyChange} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -404,7 +416,12 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
||||
await user.type(editor, "x");
|
||||
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
|
||||
// Cancel drops the edit and goes back to reading.
|
||||
// Cancel asks before dropping an edit: "keep" keeps it…
|
||||
vi.mocked(confirmDiscard).mockClear().mockResolvedValueOnce(false);
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(confirmDiscard).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 1;x");
|
||||
// …and "discard" drops it and goes back to reading.
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
@@ -739,6 +756,33 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
});
|
||||
|
||||
// The Workspace asked before switching tab; the edit must then be GONE, or
|
||||
// `dirty` stays true and keeps the panel pinned and the unload prompt on.
|
||||
it("drops an edited file when the tab switches away from it", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "src/main.ts",
|
||||
rev: "work",
|
||||
binary: false,
|
||||
truncated: false,
|
||||
lines: ["const whole = 1;"],
|
||||
sha: "b".repeat(40),
|
||||
});
|
||||
const dirty = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<Tabbed laneId={7} onDirtyChange={dirty} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
||||
await user.click(await screen.findByText("src/main.ts"));
|
||||
await user.click(await screen.findByTestId("lane-file-view"));
|
||||
await user.type(await screen.findByTestId("lane-file-editor"), "x");
|
||||
expect(dirty).toHaveBeenLastCalledWith(true);
|
||||
await user.click(screen.getByRole("button", { name: "Changes" }));
|
||||
await waitFor(() => expect(dirty).toHaveBeenLastCalledWith(false));
|
||||
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
||||
});
|
||||
|
||||
it("says why a file cannot be edited instead of offering a dead Edit button", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
|
||||
@@ -16,6 +16,8 @@ import { LaneGitBar } from "../LaneGitBar";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { LaneGitFacts } from "../../../lib/types";
|
||||
|
||||
// A merge asks through the themed confirm (its host lives in App): yes.
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
||||
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
|
||||
@@ -371,7 +373,6 @@ describe("git login (issue #45)", () => {
|
||||
|
||||
describe("merge (issue #45)", () => {
|
||||
it("merges the picked branch into the current one after a confirm", async () => {
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
@@ -380,11 +381,9 @@ describe("merge (issue #45)", () => {
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" })
|
||||
);
|
||||
confirm.mockRestore();
|
||||
});
|
||||
|
||||
it("offers to merge the upstream when the branch has diverged", async () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: "Merge origin/feat/thing" }));
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
* @file SessionReplay.test.tsx
|
||||
* @description An ended session opened in its workspace: its conversation and
|
||||
* a prompt box; opening it starts nothing, and only a SENT message resumes it
|
||||
* (`resumeSessionId` + the prompt, in the first free pane slot), after which
|
||||
* the pane opens.
|
||||
* (`resumeSessionId` + the prompt, in the free tab the server picks), after
|
||||
* which the pane opens; a session a tab already holds opens that tab, and a
|
||||
* tool that cannot resume by id offers a new session instead.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
import type { RunHandle } from "../../../lib/api";
|
||||
|
||||
@@ -33,45 +35,112 @@ vi.mock("../../run/RunSetup", () => ({
|
||||
<textarea aria-label={placeholder} value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
),
|
||||
}));
|
||||
vi.mock("../WorkspaceHome", () => ({
|
||||
freeSlot: (_id: number, runs: RunHandle[]) => (runs.length ? 1 : 0),
|
||||
paneRunId: (id: number, slot: number) =>
|
||||
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
|
||||
vi.mock("../startSession", () => ({
|
||||
startInFreeTabDetailed: vi.fn(async () => ({
|
||||
runId: "agenthub-lane-1-1",
|
||||
laneId: 1,
|
||||
held: false,
|
||||
})),
|
||||
}));
|
||||
vi.mock("../../../lib/panePaste", () => ({ pasteToRun: vi.fn(() => true) }));
|
||||
vi.mock("../SessionResumeButton", () => ({ SessionResumeButton: () => null }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
sessions: {
|
||||
get: vi.fn(async () => ({ session: { id: "s-old", tool: "claude", cwd: "/r" } })),
|
||||
},
|
||||
lanes: { action: vi.fn(async () => ({ run_id: "agenthub-lane-1-1" })) },
|
||||
run: { list: vi.fn(async () => ({ items: [] })) },
|
||||
},
|
||||
}));
|
||||
|
||||
import { api } from "../../../lib/api";
|
||||
import { startInFreeTabDetailed as startInFreeTab } from "../startSession";
|
||||
import { SessionReplay } from "../SessionReplay";
|
||||
import { pasteToRun } from "../../../lib/panePaste";
|
||||
|
||||
const lane = { id: 1, cwd: "/r", tool: "claude" } as unknown as Lane;
|
||||
const busy = [{ id: "agenthub-lane-1", laneId: 1, slot: 0, status: "running" }] as RunHandle[];
|
||||
const holding = (sessionId: string) =>
|
||||
[{ id: "agenthub-lane-1-2", laneId: 1, slot: 2, status: "running", sessionId }] as RunHandle[];
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const renderIn = (ui: React.ReactElement) => render(<MemoryRouter>{ui}</MemoryRouter>);
|
||||
|
||||
describe("SessionReplay", () => {
|
||||
it("shows the conversation and resumes nothing until a message is sent", async () => {
|
||||
const onResumed = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<SessionReplay lane={lane} sessionId="s-old" liveRuns={busy} onResumed={onResumed} />);
|
||||
renderIn(<SessionReplay lane={lane} sessionId="s-old" liveRuns={busy} onResumed={onResumed} />);
|
||||
expect(screen.getByTestId("conversation")).toHaveTextContent("s-old");
|
||||
await waitFor(() => expect(api.sessions.get).toHaveBeenCalledWith("s-old"));
|
||||
// The row only — never the session's whole event history.
|
||||
await waitFor(() => expect(api.sessions.get).toHaveBeenCalledWith("s-old", { events: false }));
|
||||
expect(screen.getByTestId("session-replay-send")).toBeDisabled();
|
||||
expect(api.lanes.action).not.toHaveBeenCalled();
|
||||
expect(startInFreeTab).not.toHaveBeenCalled();
|
||||
|
||||
await user.type(screen.getByLabelText("Message to continue this session…"), "carry on");
|
||||
await user.click(screen.getByTestId("session-replay-send"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
|
||||
slot: 1,
|
||||
resumeSessionId: "s-old",
|
||||
prompt: "carry on",
|
||||
// The server picks the free tab (`slot: "auto"`, inside startInFreeTab).
|
||||
await waitFor(() =>
|
||||
expect(startInFreeTab).toHaveBeenCalledWith(1, {
|
||||
resumeSessionId: "s-old",
|
||||
prompt: "carry on",
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(onResumed).toHaveBeenCalledWith("agenthub-lane-1-1", 1));
|
||||
});
|
||||
|
||||
it("opens the tab that already holds the session instead of resuming it again", async () => {
|
||||
const onResumed = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderIn(
|
||||
<SessionReplay
|
||||
lane={lane}
|
||||
sessionId="s-old"
|
||||
liveRuns={holding("s-old")}
|
||||
onResumed={onResumed}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId("session-replay-send")).toBeNull();
|
||||
await user.click(await screen.findByRole("button", { name: "Open pane" }));
|
||||
expect(onResumed).toHaveBeenCalledWith("agenthub-lane-1-2");
|
||||
expect(startInFreeTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-reads the panes on send: a tab that took the session meanwhile is opened", async () => {
|
||||
vi.mocked(api.run.list).mockResolvedValueOnce({ items: holding("s-old") } as never);
|
||||
const onResumed = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderIn(<SessionReplay lane={lane} sessionId="s-old" liveRuns={[]} onResumed={onResumed} />);
|
||||
await user.type(screen.getByLabelText("Message to continue this session…"), "go");
|
||||
await user.click(screen.getByTestId("session-replay-send"));
|
||||
await waitFor(() => expect(onResumed).toHaveBeenCalledWith("agenthub-lane-1-2", 1));
|
||||
expect(startInFreeTab).not.toHaveBeenCalled();
|
||||
// The typed message goes into that tab once it is on screen.
|
||||
await waitFor(() => expect(pasteToRun).toHaveBeenCalledWith("agenthub-lane-1-2", "go"));
|
||||
});
|
||||
|
||||
it("opens the pane the server says holds it (even another workspace's) and pastes there", async () => {
|
||||
vi.mocked(startInFreeTab).mockResolvedValueOnce({
|
||||
runId: "agenthub-lane-9-1",
|
||||
laneId: 9,
|
||||
held: true,
|
||||
});
|
||||
await waitFor(() => expect(onResumed).toHaveBeenCalledWith("agenthub-lane-1-1"));
|
||||
const onResumed = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderIn(<SessionReplay lane={lane} sessionId="s-old" liveRuns={[]} onResumed={onResumed} />);
|
||||
await user.type(screen.getByLabelText("Message to continue this session…"), "hi");
|
||||
await user.click(screen.getByTestId("session-replay-send"));
|
||||
await waitFor(() => expect(onResumed).toHaveBeenCalledWith("agenthub-lane-9-1", 9));
|
||||
await waitFor(() => expect(pasteToRun).toHaveBeenCalledWith("agenthub-lane-9-1", "hi"));
|
||||
});
|
||||
|
||||
it("offers a new session, not a resume, for a tool that cannot resume by id", async () => {
|
||||
vi.mocked(api.sessions.get).mockResolvedValueOnce({
|
||||
session: { id: "s-old", tool: "gemini", cwd: "/r" },
|
||||
} as never);
|
||||
renderIn(<SessionReplay lane={lane} sessionId="s-old" liveRuns={[]} onResumed={vi.fn()} />);
|
||||
expect(await screen.findByTestId("session-replay-no-resume")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("session-replay-send")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* @description The transcript page's explicit Resume and Fork: it shows only for a
|
||||
* Claude session inside a workspace, a live session offers its pane instead,
|
||||
* and a resume asks first (it re-reads the whole transcript), then starts in
|
||||
* the first free pane slot and opens that pane — or refuses when all four are
|
||||
* busy; a Fork asks too and starts a new session id beside a live original.
|
||||
* the free pane slot the server picks (`slot: "auto"`) and opens that pane —
|
||||
* or says so when all four are busy; a Fork asks too and starts a new session id beside a live original.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -81,7 +81,7 @@ describe("SessionResumeButton", () => {
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("whole transcript");
|
||||
await user.click(screen.getByRole("button", { name: "Resume session" }));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
|
||||
slot: 1,
|
||||
slot: "auto",
|
||||
resumeSessionId: "s-1",
|
||||
});
|
||||
await waitFor(() =>
|
||||
@@ -97,7 +97,7 @@ describe("SessionResumeButton", () => {
|
||||
await user.click(await screen.findByRole("button", { name: "Resume" }));
|
||||
await user.click(screen.getByRole("button", { name: "Resume session" }));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(4, "start", {
|
||||
slot: 0,
|
||||
slot: "auto",
|
||||
resumeSessionId: "s-2",
|
||||
});
|
||||
});
|
||||
@@ -135,7 +135,7 @@ describe("SessionResumeButton", () => {
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("NEW session");
|
||||
await user.click(screen.getByRole("button", { name: "Fork session" }));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
|
||||
slot: 1,
|
||||
slot: "auto",
|
||||
resumeSessionId: "s-1",
|
||||
forkSession: true,
|
||||
});
|
||||
@@ -147,7 +147,7 @@ describe("SessionResumeButton", () => {
|
||||
await user.click(await screen.findByRole("button", { name: "Resume" }));
|
||||
await user.click(screen.getByRole("button", { name: "Resume session" }));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
|
||||
slot: 1,
|
||||
slot: "auto",
|
||||
resumeSessionId: "s-wt",
|
||||
});
|
||||
});
|
||||
@@ -178,7 +178,12 @@ describe("SessionResumeButton", () => {
|
||||
(slot) => ({ id: `r${slot}`, laneId: 3, slot, status: "running" }) as RunHandle
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Resume session" }));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", { slot: 2, resumeSessionId: "s-1" });
|
||||
// The server picks the free tab under its lock; the client only re-reads
|
||||
// to open a pane that took this session meanwhile.
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(3, "start", {
|
||||
slot: "auto",
|
||||
resumeSessionId: "s-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("starts once on a double click while the panes are being re-read", async () => {
|
||||
@@ -216,16 +221,31 @@ describe("SessionResumeButton", () => {
|
||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=3&run=agenthub-lane-3-2");
|
||||
});
|
||||
|
||||
it("refuses when all four panes are busy", async () => {
|
||||
runs = [0, 1, 2, 3].map(
|
||||
(slot) => ({ id: `r${slot}`, laneId: 3, slot, status: "running" }) as RunHandle
|
||||
it("says so when the server finds all four panes busy", async () => {
|
||||
vi.mocked(api.lanes.action).mockRejectedValueOnce(
|
||||
Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" })
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
|
||||
await user.click(await screen.findByRole("button", { name: "Resume" }));
|
||||
await user.click(screen.getByRole("button", { name: "Resume session" }));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("All four panes");
|
||||
expect(api.lanes.action).not.toHaveBeenCalled();
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("All four panes");
|
||||
});
|
||||
|
||||
it("opens the pane the server names when the session is already live there", async () => {
|
||||
vi.mocked(api.lanes.action).mockRejectedValueOnce(
|
||||
// A nested workspace's pane holds it: opened in THAT workspace.
|
||||
Object.assign(new Error("open"), { code: "ESESSIONLIVE", runId: "agenthub-lane-4-3" })
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderButton({ id: "s-1", cwd: "/r", tool: "claude" });
|
||||
await user.click(await screen.findByRole("button", { name: "Resume" }));
|
||||
await user.click(screen.getByRole("button", { name: "Resume session" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("where").textContent).toBe(
|
||||
"/workspace?lane=4&run=agenthub-lane-4-3"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("shows nothing outside a workspace or for a non-Claude session", async () => {
|
||||
|
||||
@@ -24,13 +24,24 @@ vi.mock("../../run/RunSetup", () => ({
|
||||
PromptEditor: ({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
onSubmit: () => void;
|
||||
placeholder: string;
|
||||
}) => (
|
||||
<textarea aria-label={placeholder} value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
<textarea
|
||||
aria-label={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
ModelMenu: ({
|
||||
value,
|
||||
@@ -199,7 +210,7 @@ describe("WorkspaceHome", () => {
|
||||
await user.type(screen.getByLabelText("How can I help you today?"), "fix the login");
|
||||
await user.click(screen.getByTestId("workspace-home-start"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
|
||||
slot: 1,
|
||||
slot: "auto",
|
||||
prompt: "fix the login",
|
||||
permissionMode: "auto",
|
||||
});
|
||||
@@ -248,7 +259,7 @@ describe("WorkspaceHome", () => {
|
||||
await user.type(screen.getByLabelText("How can I help you today?"), "port it");
|
||||
await user.click(screen.getByTestId("workspace-home-start"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
|
||||
slot: 1,
|
||||
slot: "auto",
|
||||
prompt: "port it",
|
||||
tool: "codex",
|
||||
sandbox: "workspace-write",
|
||||
@@ -300,23 +311,40 @@ describe("WorkspaceHome", () => {
|
||||
await user.type(screen.getByLabelText("How can I help you today?"), "try it");
|
||||
await user.click(screen.getByTestId("workspace-home-start"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
|
||||
slot: 1,
|
||||
slot: "auto",
|
||||
prompt: "try it",
|
||||
permissionMode: "auto",
|
||||
worktree: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to start when all four panes are busy", async () => {
|
||||
runs = [0, 1, 2, 3].map(
|
||||
(slot) => ({ id: `r${slot}`, laneId: 1, slot, status: "running" }) as RunHandle
|
||||
it("says so when the server finds all four panes busy", async () => {
|
||||
vi.mocked(api.lanes.action).mockRejectedValueOnce(
|
||||
Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" })
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-old");
|
||||
await user.type(screen.getByLabelText("How can I help you today?"), "go");
|
||||
await user.click(screen.getByTestId("workspace-home-start"));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("All four panes");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("All four panes");
|
||||
});
|
||||
|
||||
it("starts nothing on an empty prompt, and only once on a double Enter", async () => {
|
||||
let release: (v: { run_id: string }) => void = () => undefined;
|
||||
vi.mocked(api.lanes.action).mockImplementationOnce(
|
||||
() => new Promise((r) => (release = r)) as never
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-old");
|
||||
const box = screen.getByLabelText("How can I help you today?");
|
||||
expect(screen.getByTestId("workspace-home-start")).toBeDisabled();
|
||||
await user.type(box, "{Enter}");
|
||||
expect(api.lanes.action).not.toHaveBeenCalled();
|
||||
await user.type(box, "go{Enter}{Enter}");
|
||||
release({ run_id: "agenthub-lane-1-1" });
|
||||
await waitFor(() => expect(api.lanes.action).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("folds the archived sessions with Unarchive", async () => {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @file startSession.ts — starting a session in a workspace's first free tab
|
||||
* (`POST /api/lanes/:id/start` with `slot: "auto"`) and the small pane-slot
|
||||
* helpers around it. Kept out of `WorkspaceHome.tsx` so the sidebar's row menu
|
||||
* can start a session without pulling the whole home (and react-markdown) into
|
||||
* the main bundle.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import i18n from "../../i18n";
|
||||
import { api, type RunHandle } from "../../lib/api";
|
||||
|
||||
/** The tmux session a pane slot of a lane owns (server/lib/pty-run.js). */
|
||||
export function paneRunId(laneId: number, slot: number): string {
|
||||
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a session in the workspace's first free tab. The SERVER picks the slot
|
||||
* (`slot: "auto"`, under its lane lock), because any list the client holds may
|
||||
* already be stale. Resolves to the run id it opened — or, when the session to
|
||||
* resume is already open in a tab (`ESESSIONLIVE`), to that tab, so a second
|
||||
* `--resume` of one session never starts.
|
||||
*/
|
||||
export async function startInFreeTab(
|
||||
laneId: number,
|
||||
body: Record<string, unknown>
|
||||
): Promise<string> {
|
||||
return (await startInFreeTabDetailed(laneId, body)).runId;
|
||||
}
|
||||
|
||||
/** The workspace a pane belongs to, from its tmux name `agenthub-lane-<id>[-slot]`. */
|
||||
export function laneOfRun(runId: string): number | null {
|
||||
const m = /^agenthub-lane-(\d+)(?:-[1-3])?$/.exec(runId);
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
/** `startInFreeTab`, saying also whether the run was STARTED or an existing
|
||||
* pane already held the session (`held` — nothing was sent to it), and the
|
||||
* workspace that pane belongs to (a nested folder's workspace may hold it). */
|
||||
export async function startInFreeTabDetailed(
|
||||
laneId: number,
|
||||
body: Record<string, unknown>
|
||||
): Promise<{ runId: string; laneId: number; held: boolean }> {
|
||||
try {
|
||||
const r = await api.lanes.action(laneId, "start", { ...body, slot: "auto" });
|
||||
return { runId: r.run_id ?? paneRunId(laneId, 0), laneId, held: false };
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; runId?: string };
|
||||
if (e.code === "ESESSIONLIVE" && e.runId)
|
||||
return { runId: e.runId, laneId: laneOfRun(e.runId) ?? laneId, held: true };
|
||||
if (e.code === "ERUNLIVE") throw new Error(i18n.t("lanes:home.allBusy"));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** The first pane slot with no live run, or null when all four are busy. */
|
||||
export function freeSlot(laneId: number, runs: RunHandle[]): number | null {
|
||||
const busy = new Set(
|
||||
runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0)
|
||||
);
|
||||
return [0, 1, 2, 3].find((s) => !busy.has(s)) ?? null;
|
||||
}
|
||||
@@ -2,8 +2,14 @@
|
||||
* @file ConsoleTabs.tsx
|
||||
* @description The Workspace session view's tab bar: one tab per live tmux
|
||||
* slot of the workspace (0–3, `GET /api/run`), the active one being the URL's
|
||||
* `?run=`. A tab's X stops its session (`DELETE /api/run/:id`). The `+` menu
|
||||
* opens a new tab in the first free slot — a plain shell
|
||||
* `?run=`. A tab is named by its session (`sessions.name`, double-click or
|
||||
* Rename to change it — `PATCH /api/sessions/:id`), else the prompt it started
|
||||
* on, else its tool and number, and carries a state dot (waiting for you /
|
||||
* error / running, with screen-reader text). A tab's X stops its session
|
||||
* (`DELETE /api/run/:id`) — after a confirm for an AI tab, since it may be
|
||||
* mid-turn; a shell closes at once. Arrow 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, router or
|
||||
* worktree is still the workspace home's prompt box) — plus a link to
|
||||
@@ -16,21 +22,74 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { House, Plus, Settings2, SquareTerminal, X } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { RunHandle } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { freeSlot } from "../lanes/WorkspaceHome";
|
||||
import { freeSlot, startInFreeTab } from "../lanes/startSession";
|
||||
import { RunIcon, startPaneDrag } from "./PaneGrid";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
|
||||
type AiToolsState = ReturnType<typeof useAiTools>;
|
||||
|
||||
/** A tab's (and a pane header's) name: its session's own, else the first
|
||||
* line of the prompt it started on, else its tool and tab number. */
|
||||
export function runName(
|
||||
r: RunHandle,
|
||||
session: Session | undefined,
|
||||
toolLabel: string,
|
||||
terminal: string
|
||||
): string {
|
||||
if (r.tool === "shell") return terminal;
|
||||
const prompt = r.promptPreview?.trim().split("\n")[0];
|
||||
return session?.name || prompt || `${toolLabel} #${(r.slot ?? 0) + 1}`;
|
||||
}
|
||||
|
||||
export type RunState = "awaiting" | "error" | "running";
|
||||
/** What a live AI tab's dot says: waiting for you, failed, or at work. */
|
||||
export function runState(session: Session | undefined): RunState {
|
||||
if (session && isSessionAwaitingInput(session)) return "awaiting";
|
||||
if (session?.status === "error") return "error";
|
||||
return "running";
|
||||
}
|
||||
|
||||
/** The state dot, with its meaning for a screen reader (colour alone says nothing). */
|
||||
export function RunDot({ state }: { state: RunState }) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const colour =
|
||||
state === "awaiting"
|
||||
? "bg-status-warning"
|
||||
: state === "error"
|
||||
? "bg-status-danger"
|
||||
: "bg-status-success";
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
data-state={state}
|
||||
title={t(`tabs.state.${state}`)}
|
||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${colour}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="sr-only">{t(`tabs.state.${state}`)}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The tools a new tab or split offers: the workspace's own whatever its
|
||||
* state, then every installed, enabled one. */
|
||||
export function newTabTools(lane: Lane, aiTools: AiToolsState): string[] {
|
||||
@@ -63,10 +122,16 @@ export function ConsoleTabs({
|
||||
onClosed,
|
||||
onHome,
|
||||
actions,
|
||||
sessions,
|
||||
onRenamed,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** The workspace's live runs, in slot order. */
|
||||
tabs: RunHandle[];
|
||||
/** The sessions the tabs hold, by id: their names and states. */
|
||||
sessions?: Map<string, Session>;
|
||||
/** A tab's session was renamed: re-read the sessions. */
|
||||
onRenamed?: () => void;
|
||||
/** The focused pane's run. */
|
||||
activeRunId: string | null;
|
||||
/** Runs the other panes show. */
|
||||
@@ -84,7 +149,10 @@ export function ConsoleTabs({
|
||||
const navigate = useNavigate();
|
||||
const aiTools = useAiTools();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// The tab being renamed (its run id) and the draft name.
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const tabCtx = useContextMenu<RunHandle>();
|
||||
|
||||
@@ -103,31 +171,82 @@ export function ConsoleTabs({
|
||||
}, [menuOpen]);
|
||||
|
||||
const slot = freeSlot(lane.id, tabs);
|
||||
const label = (r: RunHandle) => (r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool));
|
||||
const sessionOf = (r: RunHandle) => (r.sessionId ? sessions?.get(r.sessionId) : undefined);
|
||||
const toolLabel = (r: RunHandle) =>
|
||||
r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool);
|
||||
const label = (r: RunHandle) => runName(r, sessionOf(r), toolLabel(r), t("tabs.terminal"));
|
||||
const toolIds = newTabTools(lane, aiTools);
|
||||
|
||||
const open = async (body: Record<string, unknown>, split = false) => {
|
||||
setMenuOpen(false);
|
||||
if (slot === null) return;
|
||||
setError(null);
|
||||
try {
|
||||
const r = await api.lanes.action(lane.id, "start", { slot, ...body });
|
||||
if (r.run_id) onOpen(r.run_id, split);
|
||||
onOpen(await startInFreeTab(lane.id, body), split);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const close = async (runId: string) => {
|
||||
setError(null);
|
||||
// An AI tab may be mid-turn: stopping it asks first. A shell holds no agent.
|
||||
const close = async (r: RunHandle) => {
|
||||
if (
|
||||
r.tool !== "shell" &&
|
||||
!(await confirmDialog({
|
||||
title: t("tabs.stopTitle", { name: label(r) }),
|
||||
message: t("tabs.stopBody"),
|
||||
confirmLabel: t("tabs.stopConfirm"),
|
||||
destructive: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api.run.kill(runId);
|
||||
onClosed(runId);
|
||||
await api.run.kill(r.id);
|
||||
onClosed(r.id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const startRename = (r: RunHandle) => {
|
||||
if (!r.sessionId || r.tool === "shell") return;
|
||||
setDraft(sessionOf(r)?.name ?? "");
|
||||
setRenaming(r.id);
|
||||
};
|
||||
const saveRename = async (r: RunHandle) => {
|
||||
setRenaming(null);
|
||||
const name = draft.trim();
|
||||
if (!r.sessionId || !name || name === sessionOf(r)?.name) return;
|
||||
try {
|
||||
await api.sessions.patch(r.sessionId, { name });
|
||||
onRenamed?.();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
// APG tabs: one tab stop; arrows, Home and End move between the tabs.
|
||||
const onTabKey = (e: ReactKeyboardEvent, i: number) => {
|
||||
const last = tabs.length - 1;
|
||||
const to =
|
||||
e.key === "ArrowRight"
|
||||
? i === last
|
||||
? 0
|
||||
: i + 1
|
||||
: e.key === "ArrowLeft"
|
||||
? i === 0
|
||||
? last
|
||||
: i - 1
|
||||
: e.key === "Home"
|
||||
? 0
|
||||
: e.key === "End"
|
||||
? last
|
||||
: null;
|
||||
if (to === null || !tabs[to]) return;
|
||||
e.preventDefault();
|
||||
tabRefs.current.get(tabs[to].id)?.focus();
|
||||
};
|
||||
const tabStop = tabs.some((r) => r.id === activeRunId) ? activeRunId : tabs[0]?.id;
|
||||
|
||||
// A tab-menu item: close the menu, then act on the tab it was opened on.
|
||||
const pickTab = (fn: (r: RunHandle) => unknown) => () => {
|
||||
const r = tabCtx.menu?.item;
|
||||
@@ -153,9 +272,32 @@ export function ConsoleTabs({
|
||||
</button>
|
||||
)}
|
||||
<div role="tablist" aria-label={t("tabs.label")} className="flex min-w-0 items-stretch">
|
||||
{tabs.map((r) => {
|
||||
{tabs.map((r, i) => {
|
||||
const active = r.id === activeRunId;
|
||||
const shown = !active && shownRunIds.includes(r.id);
|
||||
if (renaming === r.id) {
|
||||
return (
|
||||
<div key={r.id} className="flex items-center px-2">
|
||||
<input
|
||||
data-testid={`console-tab-rename-${r.slot ?? 0}`}
|
||||
aria-label={t("tree.renameSession")}
|
||||
autoFocus
|
||||
value={draft}
|
||||
maxLength={200}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
// Blur cancels, Enter saves — as a sidebar session row.
|
||||
onBlur={() => setRenaming(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setRenaming(null);
|
||||
if (!isSubmitEnter(e)) return;
|
||||
e.preventDefault();
|
||||
void saveRename(r);
|
||||
}}
|
||||
className="w-40 rounded border border-accent bg-surface-1 px-1.5 py-0.5 text-sm text-fg-primary"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
@@ -182,18 +324,34 @@ export function ConsoleTabs({
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
tabIndex={r.id === tabStop ? 0 : -1}
|
||||
ref={(el) => {
|
||||
if (el) tabRefs.current.set(r.id, el);
|
||||
else tabRefs.current.delete(r.id);
|
||||
}}
|
||||
onKeyDown={(e) => onTabKey(e, i)}
|
||||
data-testid={`console-tab-${r.slot ?? 0}`}
|
||||
onClick={(e) => onOpen(r.id, e.ctrlKey || e.metaKey)}
|
||||
title={[r.model, r.promptPreview, t("tabs.splitHint")].filter(Boolean).join("\n")}
|
||||
onDoubleClick={() => startRename(r)}
|
||||
title={[
|
||||
toolLabel(r),
|
||||
r.model,
|
||||
r.promptPreview,
|
||||
t("tabs.splitHint"),
|
||||
r.sessionId ? t("tabs.renameHint") : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")}
|
||||
className="flex h-full min-w-0 items-center gap-2 pl-3 pr-1.5 text-sm"
|
||||
>
|
||||
<RunIcon run={r} label={label(r)} />
|
||||
<RunIcon run={r} label={toolLabel(r)} />
|
||||
<span className="truncate">{label(r)}</span>
|
||||
{r.tool !== "shell" && <RunDot state={runState(sessionOf(r))} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`console-tab-close-${r.slot ?? 0}`}
|
||||
onClick={() => void close(r.id)}
|
||||
onClick={() => void close(r)}
|
||||
aria-label={t("tabs.close")}
|
||||
title={t("tabs.close")}
|
||||
className={`mr-2 shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-status-danger ${
|
||||
@@ -262,11 +420,6 @@ export function ConsoleTabs({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<span role="alert" className="ml-2 min-w-0 self-center truncate text-xs text-status-danger">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
{actions && <div className="ml-auto flex items-center pr-2">{actions}</div>}
|
||||
{tabCtx.menu && (
|
||||
<ContextMenu
|
||||
@@ -279,20 +432,19 @@ export function ConsoleTabs({
|
||||
<MenuItem onSelect={pickTab((r) => onOpen(r.id, true))}>
|
||||
{t("sessionMenu.openSplit")}
|
||||
</MenuItem>
|
||||
{tabCtx.menu.item.sessionId && (
|
||||
{tabCtx.menu.item.sessionId && tabCtx.menu.item.tool !== "shell" && (
|
||||
<>
|
||||
<MenuItem onSelect={pickTab(startRename)}>{t("menu.rename")}</MenuItem>
|
||||
<MenuItem onSelect={pickTab((r) => navigate(`/sessions/${r.sessionId}`))}>
|
||||
{t("sessionMenu.details")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={pickTab((r) => void navigator.clipboard.writeText(r.sessionId ?? ""))}
|
||||
>
|
||||
<MenuItem onSelect={pickTab((r) => void copyText(r.sessionId ?? ""))}>
|
||||
{t("sessionMenu.copyId")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
<MenuItem danger onSelect={pickTab((r) => void close(r.id))}>
|
||||
<MenuItem danger onSelect={pickTab((r) => void close(r))}>
|
||||
{t("tabs.close")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
|
||||
@@ -102,7 +102,12 @@ export function LaneConsolePane({
|
||||
autoAttachedForLane.current = laneId;
|
||||
api.run
|
||||
.get(attachRequest.runId)
|
||||
.then((h) => live && setHandle(h))
|
||||
.then((h) => {
|
||||
if (!live) return;
|
||||
setHandle(h);
|
||||
// A later attach that worked clears an earlier one's failure.
|
||||
setError(null);
|
||||
})
|
||||
.catch(
|
||||
(err: unknown) =>
|
||||
live &&
|
||||
@@ -136,7 +141,7 @@ export function LaneConsolePane({
|
||||
<div data-testid="console-body" className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
{/* Not probed yet (a tool the page has not probed, or a shell) is not "missing". */}
|
||||
{binaryStatus?.[paneTool] && !binaryStatus[paneTool].found && (
|
||||
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
|
||||
<div className="mx-3 mt-2 rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span>
|
||||
{t("binary.missing", {
|
||||
@@ -148,7 +153,7 @@ export function LaneConsolePane({
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
|
||||
<div className="mx-3 mt-2 rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 break-all">{error}</span>
|
||||
</div>
|
||||
@@ -157,7 +162,7 @@ export function LaneConsolePane({
|
||||
{!handle ? (
|
||||
<div
|
||||
data-testid="pane-no-run"
|
||||
className="flex flex-1 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border p-6 text-center text-sm text-fg-muted"
|
||||
className="m-3 flex flex-1 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border p-6 text-center text-sm text-fg-muted"
|
||||
>
|
||||
<p>{t("emptyPane")}</p>
|
||||
{onNewSession && (
|
||||
|
||||
@@ -8,16 +8,28 @@
|
||||
* remount it and drop what it drew. With more than one pane each gets a slim
|
||||
* header: drag it to move the pane (drop on another pane's edge to split
|
||||
* there, on its middle to swap), pick another live session for it, split it
|
||||
* right or down, or close the pane (its session keeps running as a tab).
|
||||
* right or down, or HIDE the pane (its session keeps running as a tab — so
|
||||
* its icon is not the tab's stop X). A header names the pane's session and
|
||||
* shows its state dot, as its tab does.
|
||||
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). A
|
||||
* right-click on a header offers the swap, a split with a new terminal and
|
||||
* the close as one menu. Dividers resize by pointer or by arrow keys.
|
||||
* the close as one menu. Dividers resize by pointer or by arrow keys; a drag
|
||||
* moves only the line and commits the ratio on release, so the layout is
|
||||
* saved and the terminals refit once, not on every pointer move.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronDown, Columns2, GripVertical, Rows2, SquareTerminal, X } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Columns2,
|
||||
GripVertical,
|
||||
Minimize2,
|
||||
Rows2,
|
||||
SquareTerminal,
|
||||
} from "lucide-react";
|
||||
import type { RunHandle } from "../../lib/api";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import {
|
||||
@@ -37,6 +49,7 @@ import {
|
||||
swapInLayout,
|
||||
} from "../../lib/paneLayout";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { RunDot, type RunState } from "./ConsoleTabs";
|
||||
|
||||
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
|
||||
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
|
||||
@@ -283,8 +296,11 @@ export function PaneGrid({
|
||||
tools,
|
||||
toolLabel,
|
||||
onNewSplit,
|
||||
stateOf,
|
||||
}: {
|
||||
layout: PaneLayout;
|
||||
/** A live AI run's state for its header dot (none for a shell). */
|
||||
stateOf?: (r: RunHandle) => RunState | null;
|
||||
/** Every slot to keep mounted; those not in the layout are hidden. */
|
||||
mounted: number[];
|
||||
/** The workspace's live runs (its tabs). */
|
||||
@@ -433,6 +449,7 @@ export function PaneGrid({
|
||||
<GripVertical className="h-3.5 w-3.5 shrink-0 opacity-60" aria-hidden />
|
||||
{run && <RunIcon run={run} label={label(run)} />}
|
||||
<span className="min-w-0 truncate">{run ? label(run) : t("panes.empty")}</span>
|
||||
{run && stateOf?.(run) && <RunDot state={stateOf(run)!} />}
|
||||
<PaneMenu
|
||||
testId={`pane-pick-${slot}`}
|
||||
label={t("panes.pick")}
|
||||
@@ -482,14 +499,14 @@ export function PaneGrid({
|
||||
onClick={() => onChange((l) => closeInLayout(l, slot))}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
<Minimize2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-h-0 flex-1 flex-col pb-1 pl-3 pr-1 pt-1.5">
|
||||
{renderPane(slot)}
|
||||
</div>
|
||||
{/* No padding here: the terminal paints its own gutter in its
|
||||
palette, so a dark pane never sits in a light-page frame. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col">{renderPane(slot)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -550,23 +567,31 @@ export function PaneGrid({
|
||||
);
|
||||
}
|
||||
|
||||
/** A split's divider: a 1px line with a wider grab area. */
|
||||
function Divider({
|
||||
/** A split's divider: a 1px line with a wider grab area. Also the Changes
|
||||
* panel's splitter (Workspace.tsx), over the whole row (`box` = 0,0,1,1). */
|
||||
export function Divider({
|
||||
gridRef,
|
||||
dir,
|
||||
at,
|
||||
box,
|
||||
onRatio,
|
||||
label,
|
||||
}: {
|
||||
gridRef: React.RefObject<HTMLDivElement>;
|
||||
dir: SplitDir;
|
||||
at: number;
|
||||
box: Rect;
|
||||
onRatio: (ratio: number) => void;
|
||||
/** Accessible name; the panes' own by default. */
|
||||
label?: string;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [dragging, setDragging] = useState(false);
|
||||
// The ratio under the pointer while dragging: only this line moves until
|
||||
// release, when the layout takes it once.
|
||||
const [live, setLive] = useState<number | null>(null);
|
||||
const dragging = live !== null;
|
||||
const row = dir === "row";
|
||||
const pos = live ?? at;
|
||||
const ratioAt = (e: React.PointerEvent) => {
|
||||
const g = gridRef.current?.getBoundingClientRect();
|
||||
if (!g || !g.width || !g.height) return null;
|
||||
@@ -575,17 +600,22 @@ function Divider({
|
||||
: ((e.clientY - g.top) / g.height - box.y) / box.h;
|
||||
};
|
||||
const style = row
|
||||
? { left: `${(box.x + box.w * at) * 100}%`, top: `${box.y * 100}%`, height: `${box.h * 100}%` }
|
||||
: { top: `${(box.y + box.h * at) * 100}%`, left: `${box.x * 100}%`, width: `${box.w * 100}%` };
|
||||
? { left: `${(box.x + box.w * pos) * 100}%`, top: `${box.y * 100}%`, height: `${box.h * 100}%` }
|
||||
: { top: `${(box.y + box.h * pos) * 100}%`, left: `${box.x * 100}%`, width: `${box.w * 100}%` };
|
||||
const clamp = (r: number) => Math.min(0.9, Math.max(0.1, r));
|
||||
const finish = () => {
|
||||
if (live !== null && live !== at) onRatio(live);
|
||||
setLive(null);
|
||||
};
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
aria-orientation={row ? "vertical" : "horizontal"}
|
||||
aria-valuenow={Math.round(at * 100)}
|
||||
aria-valuenow={Math.round(pos * 100)}
|
||||
aria-valuemin={10}
|
||||
aria-valuemax={90}
|
||||
aria-label={t("panes.resize")}
|
||||
aria-label={label ?? t("panes.resize")}
|
||||
className={`group absolute z-10 flex justify-center outline-none ${
|
||||
row
|
||||
? "w-2 -translate-x-1/2 cursor-col-resize"
|
||||
@@ -595,15 +625,15 @@ function Divider({
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
setDragging(true);
|
||||
setLive(at);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (!dragging) return;
|
||||
const r = ratioAt(e);
|
||||
if (r !== null) onRatio(r);
|
||||
if (r !== null) setLive(clamp(r));
|
||||
}}
|
||||
onPointerUp={() => setDragging(false)}
|
||||
onPointerCancel={() => setDragging(false)}
|
||||
onPointerUp={finish}
|
||||
onPointerCancel={() => setLive(null)}
|
||||
onKeyDown={(e) => {
|
||||
const back = row ? "ArrowLeft" : "ArrowUp";
|
||||
const fwd = row ? "ArrowRight" : "ArrowDown";
|
||||
|
||||
@@ -43,6 +43,7 @@ import type { CwdSuggestion, CcMdItem } from "../../lib/api";
|
||||
import type { LaneTool } from "../../lib/types";
|
||||
import { Select } from "../Select";
|
||||
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
@@ -131,6 +132,9 @@ interface PromptEditorProps {
|
||||
onImagesChange: Dispatch<SetStateAction<PromptImage[]>>;
|
||||
/** Borderless, for a composer box that draws its own frame. */
|
||||
bare?: boolean;
|
||||
/** An image upload started (true) or settled (false): the caller holds its
|
||||
* send button meanwhile, or the prompt would go out without the image. */
|
||||
onBusyChange?: (busy: boolean) => void;
|
||||
}
|
||||
|
||||
/** An attached image: its path on the server's machine and a local preview. */
|
||||
@@ -153,6 +157,7 @@ export function PromptEditor({
|
||||
images,
|
||||
onImagesChange,
|
||||
bare,
|
||||
onBusyChange,
|
||||
}: PromptEditorProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
@@ -265,6 +270,10 @@ export function PromptEditor({
|
||||
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
|
||||
const [viewing, setViewing] = useState<PromptImage | null>(null);
|
||||
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
|
||||
const uploading = upload === "uploading";
|
||||
const busyRef = useRef(onBusyChange);
|
||||
busyRef.current = onBusyChange;
|
||||
useEffect(() => busyRef.current?.(uploading), [uploading]);
|
||||
const attachImages = (list: FileList | null | undefined) => {
|
||||
const picked = Array.from(list ?? []).filter((f) => f.type.startsWith("image/"));
|
||||
if (picked.length === 0) return false;
|
||||
@@ -320,10 +329,11 @@ export function PromptEditor({
|
||||
return;
|
||||
}
|
||||
// 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) {
|
||||
// isSubmitEnter guards IME input (Vietnamese/CJK), where Enter commits the
|
||||
// candidate. An image still uploading holds the send.
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
if (!uploading) onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -878,7 +888,7 @@ export function ModelMenu({
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
if (!isSubmitEnter(e)) return;
|
||||
e.preventDefault();
|
||||
setCustom(false);
|
||||
pick(e.currentTarget.value.trim());
|
||||
|
||||
@@ -16,13 +16,29 @@
|
||||
* and how `claude` attaches an image. The pane also registers with
|
||||
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
||||
* pasted into whichever of the workspace's panes was focused last.
|
||||
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
|
||||
* mode on Shift+Tab), so Ctrl+M toggles "Tab moves focus" — VS Code's escape
|
||||
* from its terminal — announced by a hint the textarea is described by;
|
||||
* Cmd+F (macOS) / Ctrl+Shift+F opens a find bar (`@xterm/addon-search`). A screen-reader mode
|
||||
* (`agenthub.terminal.screenReader` = "1" in localStorage) turns on xterm's.
|
||||
* URLs open in a new tab (`@xterm/addon-web-links`); a selection tmux copies
|
||||
* (OSC 52 — tmux's default `set-clipboard external` passes its own
|
||||
* copy-mode selections on; a CLI's OSC 52 inside the pane is not) lands in the browser
|
||||
* clipboard (`@xterm/addon-clipboard`, write-only: no program in the pane may
|
||||
* READ the user's clipboard). While the socket is down a "Reconnecting…"
|
||||
* overlay says keystrokes are not reaching the pane; resizes reach tmux at
|
||||
* most every 150 ms.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { useEffect, useId, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { CanvasAddon } from "@xterm/addon-canvas";
|
||||
import { ClipboardAddon, type IClipboardProvider } from "@xterm/addon-clipboard";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { ArrowDown, ArrowUp, X } from "lucide-react";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
@@ -30,6 +46,37 @@ import { api } from "../../lib/api";
|
||||
import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||
|
||||
type DropState = "hint" | "uploading" | "failed" | "imagesOnly";
|
||||
|
||||
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
||||
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
||||
/** localStorage flag: the "Shift+drag selects text" hint was dismissed. */
|
||||
const SELECT_HINT_KEY = "agenthub.terminal.selectHintSeen";
|
||||
const readFlag = (key: string) => {
|
||||
try {
|
||||
return window.localStorage.getItem(key) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** OSC 52 from the pane may WRITE the clipboard (a tmux selection, a CLI's
|
||||
* copy) but never read it: a read would hand the user's clipboard to
|
||||
* whatever runs in the pane. */
|
||||
const clipboardProvider: IClipboardProvider = {
|
||||
readText: () => "",
|
||||
// tmux names no selection (`52;;<b64>`), a CLI usually `c`: both are the
|
||||
// clipboard. An empty write (a "clear") is ignored.
|
||||
writeText: (_selection, text) => {
|
||||
// Silent and best effort: no toast per copy, and on a non-secure origin
|
||||
// (no Clipboard API, no user gesture for a fallback) it simply does nothing.
|
||||
if (text) void navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||
},
|
||||
};
|
||||
// The addon's typings declare one argument, its code takes (base64, provider).
|
||||
const ClipboardWithProvider = ClipboardAddon as unknown as new (
|
||||
base64: undefined,
|
||||
provider: IClipboardProvider
|
||||
) => ClipboardAddon;
|
||||
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
|
||||
|
||||
interface TerminalViewProps {
|
||||
@@ -62,7 +109,19 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
paletteRef.current = palette;
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const { t } = useTranslation("run");
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
const [drop, setDrop] = useState<DropState | null>(null);
|
||||
// The socket dropped after it had been up: keystrokes are not reaching tmux.
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
// Ctrl+M: Tab and Shift+Tab move focus out instead of reaching the pane.
|
||||
const [tabMoves, setTabMoves] = useState(false);
|
||||
const tabMovesRef = useRef(false);
|
||||
const [find, setFind] = useState<string | null>(null);
|
||||
const searchRef = useRef<SearchAddon | null>(null);
|
||||
const findInput = useRef<HTMLInputElement>(null);
|
||||
const [selectHint, setSelectHint] = useState(() => !readFlag(SELECT_HINT_KEY));
|
||||
const hintId = useId();
|
||||
// dragenter/dragleave fire for every child crossed, so count them: the hint
|
||||
// only goes away when the pointer leaves the terminal itself.
|
||||
const dragDepth = useRef(0);
|
||||
@@ -121,11 +180,22 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
cursorBlink: true,
|
||||
fontFamily: '"JetBrains Mono", monospace',
|
||||
theme: paletteRef.current,
|
||||
screenReaderMode: readFlag(SCREEN_READER_KEY),
|
||||
});
|
||||
termRef.current = term;
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
const search = new SearchAddon();
|
||||
term.loadAddon(search);
|
||||
searchRef.current = search;
|
||||
term.loadAddon(new ClipboardWithProvider(undefined, clipboardProvider));
|
||||
term.loadAddon(
|
||||
new WebLinksAddon((_e, uri) => {
|
||||
window.open(uri, "_blank", "noopener,noreferrer");
|
||||
})
|
||||
);
|
||||
if (containerRef.current) term.open(containerRef.current);
|
||||
term.textarea?.setAttribute("aria-describedby", hintId);
|
||||
|
||||
// Draw each cell at a fixed position instead of letting the browser lay the
|
||||
// row out as text. xterm's default DOM renderer aligns a row with a single
|
||||
@@ -171,8 +241,33 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
// which is why `shift+tab to cycle` (permission mode in claude) looked dead
|
||||
// here while working in a real terminal. Returning true keeps xterm's own
|
||||
// handling; the preventDefault only stops focus from escaping.
|
||||
// Ctrl+M toggles "Tab moves focus" (VS Code's way out of its terminal);
|
||||
// in that mode Tab and Shift+Tab are not the pane's, so the browser moves
|
||||
// focus. Cmd+F / Ctrl+Shift+F open the find bar. Returning false keeps xterm from
|
||||
// handling — and from cancelling — the key.
|
||||
term.attachCustomKeyEventHandler((ev) => {
|
||||
if (ev.type === "keydown" && ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
||||
if (ev.type !== "keydown") return true;
|
||||
const key = ev.key.toLowerCase();
|
||||
if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "m") {
|
||||
ev.preventDefault();
|
||||
tabMovesRef.current = !tabMovesRef.current;
|
||||
setTabMoves(tabMovesRef.current);
|
||||
return false;
|
||||
}
|
||||
// Find: Cmd+F on macOS, Ctrl+Shift+F elsewhere — never plain Ctrl+F,
|
||||
// which is readline's forward-char (and bound by many TUIs).
|
||||
const findChord =
|
||||
!ev.altKey &&
|
||||
key === "f" &&
|
||||
((ev.metaKey && !ev.ctrlKey && !ev.shiftKey) || (ev.ctrlKey && ev.shiftKey && !ev.metaKey));
|
||||
if (findChord) {
|
||||
ev.preventDefault();
|
||||
setFind((f) => f ?? "");
|
||||
requestAnimationFrame(() => findInput.current?.focus());
|
||||
return false;
|
||||
}
|
||||
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
||||
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -192,11 +287,22 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
* (`display:none` leaves the container 0×0), and fitting to that would
|
||||
* resize the real tmux pane down to nothing.
|
||||
*/
|
||||
const sendSize = () => {
|
||||
const sendSizeNow = () => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
if (term.cols < 2 || term.rows < 2) return;
|
||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||
};
|
||||
// A divider drag or a window resize fires a resize per frame; tmux and the
|
||||
// program in the pane redraw on each, so the LAST size is sent once things
|
||||
// settle (trailing, 150 ms).
|
||||
let sizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const sendSize = () => {
|
||||
if (sizeTimer) clearTimeout(sizeTimer);
|
||||
sizeTimer = setTimeout(() => {
|
||||
sizeTimer = null;
|
||||
sendSizeNow();
|
||||
}, 150);
|
||||
};
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let ended = false; // server said the pane exited — nothing left to attach to
|
||||
let disposed = false;
|
||||
@@ -219,7 +325,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
sock.binaryType = "arraybuffer";
|
||||
|
||||
sock.onopen = () => {
|
||||
sendSize();
|
||||
setReconnecting(false);
|
||||
sendSizeNow();
|
||||
};
|
||||
sock.onmessage = (event) => {
|
||||
const isArrayBuffer = Object.prototype.toString.call(event.data) === "[object ArrayBuffer]";
|
||||
@@ -237,7 +344,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.type === "exit") {
|
||||
ended = true;
|
||||
term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`);
|
||||
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
|
||||
onExitRef.current?.();
|
||||
}
|
||||
return;
|
||||
@@ -251,7 +358,9 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
sock.onclose = () => {
|
||||
if (disposed || ended) return;
|
||||
// tmux keeps the session alive across a server restart, so re-attaching
|
||||
// repaints the pane in full — no output is lost by retrying.
|
||||
// repaints the pane in full — no output is lost by retrying. Input is
|
||||
// not: the overlay says so rather than letting keystrokes vanish.
|
||||
setReconnecting(true);
|
||||
retryTimer = setTimeout(connect, 1500);
|
||||
};
|
||||
};
|
||||
@@ -270,7 +379,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
// (a second tab, another lane pane), and tmux's `window-size latest` sizes
|
||||
// the window to whichever client was last active — resizing this pane out
|
||||
// from under a terminal that is never told. Coming back to it says so.
|
||||
const reassert = () => sendSize();
|
||||
const reassert = () => sendSizeNow();
|
||||
term.textarea?.addEventListener("focus", reassert);
|
||||
|
||||
const refit = () => {
|
||||
@@ -296,6 +405,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
if (sizeTimer) clearTimeout(sizeTimer);
|
||||
searchRef.current = null;
|
||||
resizeObserver.disconnect();
|
||||
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
|
||||
term.textarea?.removeEventListener("focus", reassert);
|
||||
@@ -315,16 +426,46 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
// Other parts of the page (a diff-line comment) paste into this pane.
|
||||
useEffect(
|
||||
() =>
|
||||
registerPane(runId, (text) => {
|
||||
termRef.current?.paste(text);
|
||||
termRef.current?.focus();
|
||||
}),
|
||||
registerPane(
|
||||
runId,
|
||||
(text) => {
|
||||
termRef.current?.paste(text);
|
||||
termRef.current?.focus();
|
||||
},
|
||||
// On screen: a console hidden with CSS measures 0×0.
|
||||
() => !!containerRef.current?.offsetWidth
|
||||
),
|
||||
[runId]
|
||||
);
|
||||
|
||||
const runFind = (backwards = false) => {
|
||||
const q = find ?? "";
|
||||
if (!q) return;
|
||||
if (backwards) searchRef.current?.findPrevious(q);
|
||||
else searchRef.current?.findNext(q);
|
||||
};
|
||||
const closeFind = () => {
|
||||
setFind(null);
|
||||
searchRef.current?.clearDecorations();
|
||||
termRef.current?.focus();
|
||||
};
|
||||
const dismissSelectHint = () => {
|
||||
setSelectHint(false);
|
||||
try {
|
||||
window.localStorage.setItem(SELECT_HINT_KEY, "1");
|
||||
} catch {
|
||||
/* shown again next time */
|
||||
}
|
||||
};
|
||||
const chip =
|
||||
"absolute z-10 rounded-md border border-white/15 bg-black/70 px-2 py-1 text-[11px] text-white/90";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-full w-full"
|
||||
// The gutter around the terminal is the terminal's own colour, so a
|
||||
// dark pane never sits in a light-page frame.
|
||||
className="relative h-full w-full pb-1 pl-3 pr-1 pt-1.5"
|
||||
style={{ backgroundColor: palette.background }}
|
||||
onDragEnter={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
@@ -344,12 +485,94 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
onDrop={(e) => void onDrop(e)}
|
||||
onFocus={() => notePaneFocus(runId)}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={{ backgroundColor: palette.background }}
|
||||
data-testid="terminal-view"
|
||||
/>
|
||||
<div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />
|
||||
<p id={hintId} className="sr-only">
|
||||
{t("terminal.keyboardHint")}
|
||||
</p>
|
||||
{tabMoves && (
|
||||
<span data-testid="terminal-tab-moves" role="status" className={`${chip} bottom-2 right-3`}>
|
||||
{t("terminal.tabMoves")}
|
||||
</span>
|
||||
)}
|
||||
{selectHint && !reconnecting && (
|
||||
<span className={`${chip} bottom-2 left-3 flex items-center gap-2`}>
|
||||
{t("terminal.selectHint")}
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismissSelectHint}
|
||||
aria-label={t("terminal.dismissHint")}
|
||||
title={t("terminal.dismissHint")}
|
||||
className="rounded p-0.5 hover:bg-white/15"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{find !== null && (
|
||||
<div
|
||||
data-testid="terminal-find"
|
||||
role="search"
|
||||
className={`${chip} right-3 top-2 flex items-center gap-1 !p-1`}
|
||||
>
|
||||
<input
|
||||
ref={findInput}
|
||||
value={find}
|
||||
aria-label={t("terminal.find")}
|
||||
placeholder={t("terminal.find")}
|
||||
onChange={(e) => {
|
||||
setFind(e.target.value);
|
||||
if (e.target.value)
|
||||
searchRef.current?.findNext(e.target.value, { incremental: true });
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
closeFind();
|
||||
} else if (e.key === "Enter" && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
runFind(e.shiftKey);
|
||||
}
|
||||
}}
|
||||
className="w-44 rounded bg-white/10 px-1.5 py-0.5 text-white placeholder:text-white/50 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runFind(true)}
|
||||
aria-label={t("terminal.findPrev")}
|
||||
title={t("terminal.findPrev")}
|
||||
className="rounded p-0.5 hover:bg-white/15"
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runFind()}
|
||||
aria-label={t("terminal.findNext")}
|
||||
title={t("terminal.findNext")}
|
||||
className="rounded p-0.5 hover:bg-white/15"
|
||||
>
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeFind}
|
||||
aria-label={t("terminal.findClose")}
|
||||
title={t("terminal.findClose")}
|
||||
className="rounded p-0.5 hover:bg-white/15"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{reconnecting && (
|
||||
<div
|
||||
data-testid="terminal-reconnecting"
|
||||
role="status"
|
||||
className="absolute inset-0 z-20 flex items-center justify-center bg-black/60 text-sm text-white"
|
||||
>
|
||||
{t("terminal.reconnecting")}
|
||||
</div>
|
||||
)}
|
||||
{drop && (
|
||||
// pointer-events-none: the drop must land on the terminal, not on this.
|
||||
<div
|
||||
|
||||
@@ -234,6 +234,60 @@ describe("TerminalView", () => {
|
||||
expect(preventPlainTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// WCAG 2.1.2: the terminal keeps Tab, so Ctrl+M must let the keyboard out.
|
||||
it("lets Ctrl+M toggle Tab into moving focus out of the terminal", () => {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||
const ctrlM = () => new KeyboardEvent("keydown", { key: "m", ctrlKey: true });
|
||||
// Not sent to the pane.
|
||||
act(() => {
|
||||
expect(handler(ctrlM())).toBe(false);
|
||||
});
|
||||
expect(screen.getByTestId("terminal-tab-moves")).toBeInTheDocument();
|
||||
// false = xterm neither sends nor cancels it: the browser moves focus.
|
||||
const tab = new KeyboardEvent("keydown", { key: "Tab" });
|
||||
const prevent = vi.spyOn(tab, "preventDefault");
|
||||
expect(handler(tab)).toBe(false);
|
||||
expect(prevent).not.toHaveBeenCalled();
|
||||
expect(handler(new KeyboardEvent("keydown", { key: "Tab", shiftKey: true }))).toBe(false);
|
||||
act(() => {
|
||||
handler(ctrlM());
|
||||
});
|
||||
expect(screen.queryByTestId("terminal-tab-moves")).toBeNull();
|
||||
expect(handler(new KeyboardEvent("keydown", { key: "Tab" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("opens find on Ctrl+Shift+F or Cmd+F, never on plain Ctrl+F (readline's forward-char)", () => {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||
expect(handler(new KeyboardEvent("keydown", { key: "f", ctrlKey: true }))).toBe(true);
|
||||
expect(screen.queryByTestId("terminal-find")).toBeNull();
|
||||
act(() => {
|
||||
expect(
|
||||
handler(new KeyboardEvent("keydown", { key: "F", ctrlKey: true, shiftKey: true }))
|
||||
).toBe(false);
|
||||
});
|
||||
expect(screen.getByTestId("terminal-find")).toBeInTheDocument();
|
||||
cleanup();
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const mac = keyHandlerMock.mock.calls[keyHandlerMock.mock.calls.length - 1]![0]! as (
|
||||
e: KeyboardEvent
|
||||
) => boolean;
|
||||
act(() => {
|
||||
expect(mac(new KeyboardEvent("keydown", { key: "f", metaKey: true }))).toBe(false);
|
||||
});
|
||||
expect(screen.getByTestId("terminal-find")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says it is reconnecting while the socket is down, and clears it on reopen", () => {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
act(() => ws.onopen?.());
|
||||
expect(screen.queryByTestId("terminal-reconnecting")).toBeNull();
|
||||
act(() => ws.onclose?.());
|
||||
expect(screen.getByTestId("terminal-reconnecting")).toHaveTextContent("Reconnecting");
|
||||
});
|
||||
|
||||
it("takes the keyboard as soon as it mounts", () => {
|
||||
// The terminal IS the page here — without this the first thing typed after
|
||||
// opening a lane went nowhere until the user clicked the pane.
|
||||
@@ -275,7 +329,7 @@ describe("TerminalView", () => {
|
||||
expect(ws.sent).toContain(JSON.stringify({ type: "resize", cols: 131, rows: 28 }));
|
||||
});
|
||||
|
||||
it("reports a size change xterm made on its own, with no container resize", () => {
|
||||
it("reports a size change xterm made on its own, with no container resize", async () => {
|
||||
// Webfont load and page zoom both move cols without resizing the element
|
||||
// ResizeObserver watches, which is why onResize is the hook.
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
@@ -284,7 +338,12 @@ describe("TerminalView", () => {
|
||||
ws.sent.length = 0;
|
||||
termState.cols = 129;
|
||||
onResizeHandlers.forEach((fn) => fn());
|
||||
expect(ws.sent).toEqual([JSON.stringify({ type: "resize", cols: 129, rows: 28 })]);
|
||||
// A burst (a divider drag) reaches tmux once, with the last size.
|
||||
termState.cols = 127;
|
||||
onResizeHandlers.forEach((fn) => fn());
|
||||
expect(ws.sent).toEqual([]);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(ws.sent).toEqual([JSON.stringify({ type: "resize", cols: 127, rows: 28 })]);
|
||||
});
|
||||
|
||||
it("never reports a collapsed console's zero size", () => {
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
"changes.commentOn": "Comment on {{at}} — pasted into the pane you used last; nothing is sent until you press Enter there",
|
||||
"changes.commentSend": "Paste into pane",
|
||||
"changes.commentCancel": "Cancel",
|
||||
"changes.commentNoPane": "No pane of this workspace is open — open its session first",
|
||||
"changes.commentNoPane": "No terminal of this workspace is on screen — open its session (or narrow the Changes panel) first",
|
||||
"changes.layout.split": "Side-by-side",
|
||||
"changes.layout.unified": "Unified",
|
||||
"changes.loading": "Reading the working copy…",
|
||||
@@ -390,7 +390,7 @@
|
||||
"panes.split.bottom": "Split down",
|
||||
"panes.full": "4 panes is the most a workspace can show",
|
||||
"panes.newTerminal": "New terminal",
|
||||
"panes.close": "Close pane (its session keeps running as a tab)",
|
||||
"panes.close": "Hide pane (its session keeps running as a tab)",
|
||||
"panes.resize": "Resize panes",
|
||||
"tabs.home": "Workspace home",
|
||||
"sessionMenu.open": "Open",
|
||||
@@ -401,9 +401,47 @@
|
||||
"panes.show": "Show {{name}} here",
|
||||
"panes.splitNew.right": "Split right with a new terminal",
|
||||
"panes.splitNew.bottom": "Split down with a new terminal",
|
||||
"panes.closeShort": "Close pane",
|
||||
"panes.closeShort": "Hide pane",
|
||||
"changes.copyPath": "Copy relative path",
|
||||
"changes.viewCommit": "View commit",
|
||||
"changes.copySha": "Copy commit SHA",
|
||||
"changes.copySubject": "Copy commit message"
|
||||
"changes.copySubject": "Copy commit message",
|
||||
"replay.live": "This session is open in a tab — continue it there.",
|
||||
"replay.noResume": "{{tool}} cannot resume a session by id, so this conversation is read-only here.",
|
||||
"replay.newSession": "Start a new session",
|
||||
"tabs.state.awaiting": "Waiting for you",
|
||||
"tabs.state.error": "Error",
|
||||
"tabs.state.running": "Running",
|
||||
"tabs.renameHint": "Double-click to rename",
|
||||
"tabs.stopTitle": "Stop “{{name}}”?",
|
||||
"tabs.stopBody": "Closing the tab stops its session, even mid-turn. It can be resumed later from Recents.",
|
||||
"tabs.stopConfirm": "Stop session",
|
||||
"changes.loadMore": "Load more commits",
|
||||
"changes.commentPasted": "Pasted into tab {{tab}} — press Enter there to send it",
|
||||
"changes.splitter": "Resize the changes panel",
|
||||
"tree.archiveLiveTitle": "Archive a live session?",
|
||||
"tree.archiveLiveBody": "Its tab keeps running; archiving only hides it from this list. Unarchive it from the archived fold.",
|
||||
"tree.state.awaiting": "waiting for you",
|
||||
"tree.state.error": "error",
|
||||
"tree.state.live": "live",
|
||||
"tree.state.idle": "ended",
|
||||
"tree.showMore_one": "Show {{count}} more",
|
||||
"tree.showMore_other": "Show {{count}} more",
|
||||
"home.contextPanel": "Workspace context",
|
||||
"row.state.needsYou": "needs you",
|
||||
"row.state.error": "error",
|
||||
"row.state.running": "running",
|
||||
"row.state.active": "active",
|
||||
"row.state.idle": "idle",
|
||||
"menu.stopTitle_one": "Stop the running session?",
|
||||
"menu.stopTitle_other": "Stop {{count}} running sessions?",
|
||||
"menu.stopBody": "Every tab of this workspace stops, even mid-turn. Its sessions can be resumed later from Recents.",
|
||||
"gitBar.done.fetch": "Fetched",
|
||||
"gitBar.done.pull": "Pulled",
|
||||
"gitBar.done.push": "Pushed",
|
||||
"gitBar.done.publish": "Published",
|
||||
"runtime.listening": "listening",
|
||||
"runtime.notListening": "not listening",
|
||||
"replay.pasted": "That session was already open in a tab — your message is pasted there; press Enter to send it.",
|
||||
"replay.notPasted": "That session is open in a tab, but your message could not be pasted there: {{text}}"
|
||||
}
|
||||
|
||||
@@ -148,5 +148,17 @@
|
||||
"imagesOnly": "Only images can be dropped here",
|
||||
"remove": "Remove image",
|
||||
"view": "View image"
|
||||
},
|
||||
"terminal": {
|
||||
"ended": "[session ended, exit code {{code}}]",
|
||||
"reconnecting": "Reconnecting… keystrokes are not reaching the session until the terminal is back.",
|
||||
"keyboardHint": "Terminal. Press Ctrl+M to toggle whether Tab moves focus out of the terminal; Ctrl+Shift+F (Cmd+F on macOS) finds text.",
|
||||
"tabMoves": "Tab moves focus — Ctrl+M to give Tab back to the terminal",
|
||||
"selectHint": "Shift+drag to select text in the browser; a plain drag selects in tmux and copies on release.",
|
||||
"dismissHint": "Dismiss",
|
||||
"find": "Find in terminal",
|
||||
"findPrev": "Previous match",
|
||||
"findNext": "Next match",
|
||||
"findClose": "Close find"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
"changes.commentOn": "Comment {{at}} — dán vào pane bạn dùng gần nhất; chưa gửi gì cho tới khi bạn nhấn Enter ở đó",
|
||||
"changes.commentSend": "Dán vào pane",
|
||||
"changes.commentCancel": "Huỷ",
|
||||
"changes.commentNoPane": "Workspace này chưa mở pane nào — hãy mở session trước",
|
||||
"changes.commentNoPane": "Chưa có terminal nào của workspace này đang hiện — hãy mở session (hoặc thu hẹp khung Changes) trước",
|
||||
"changes.layout.split": "Hai cột",
|
||||
"changes.layout.unified": "Một cột",
|
||||
"changes.loading": "Đang đọc working copy…",
|
||||
@@ -390,7 +390,7 @@
|
||||
"panes.split.bottom": "Tách xuống dưới",
|
||||
"panes.full": "Một workspace hiện tối đa 4 pane",
|
||||
"panes.newTerminal": "Terminal mới",
|
||||
"panes.close": "Đóng pane (session vẫn chạy dưới dạng tab)",
|
||||
"panes.close": "Ẩn pane (session vẫn chạy dưới dạng tab)",
|
||||
"panes.resize": "Đổi kích thước pane",
|
||||
"tabs.home": "Trang chủ workspace",
|
||||
"sessionMenu.open": "Mở",
|
||||
@@ -401,9 +401,47 @@
|
||||
"panes.show": "Hiện {{name}} ở đây",
|
||||
"panes.splitNew.right": "Chia sang phải với terminal mới",
|
||||
"panes.splitNew.bottom": "Chia xuống dưới với terminal mới",
|
||||
"panes.closeShort": "Đóng pane",
|
||||
"panes.closeShort": "Ẩn pane",
|
||||
"changes.copyPath": "Sao chép đường dẫn tương đối",
|
||||
"changes.viewCommit": "Xem commit",
|
||||
"changes.copySha": "Sao chép SHA của commit",
|
||||
"changes.copySubject": "Sao chép commit message"
|
||||
"changes.copySubject": "Sao chép commit message",
|
||||
"replay.live": "Session này đang mở trong một tab — hãy tiếp tục ở đó.",
|
||||
"replay.noResume": "{{tool}} không thể tiếp tục (resume) một session theo id, nên hội thoại này chỉ để đọc.",
|
||||
"replay.newSession": "Bắt đầu session mới",
|
||||
"tabs.state.awaiting": "Đang chờ bạn",
|
||||
"tabs.state.error": "Lỗi",
|
||||
"tabs.state.running": "Đang chạy",
|
||||
"tabs.renameHint": "Nhấp đúp để đổi tên",
|
||||
"tabs.stopTitle": "Dừng “{{name}}”?",
|
||||
"tabs.stopBody": "Đóng tab sẽ dừng phiên của nó, kể cả khi đang chạy dở. Bạn có thể tiếp tục (resume) sau từ mục Gần đây.",
|
||||
"tabs.stopConfirm": "Dừng phiên",
|
||||
"changes.loadMore": "Tải thêm commit",
|
||||
"changes.commentPasted": "Đã dán vào tab {{tab}} — nhấn Enter ở đó để gửi",
|
||||
"changes.splitter": "Đổi độ rộng khung thay đổi",
|
||||
"tree.archiveLiveTitle": "Lưu trữ một session đang chạy?",
|
||||
"tree.archiveLiveBody": "Tab của nó vẫn chạy; lưu trữ chỉ ẩn nó khỏi danh sách này. Bỏ lưu trữ trong mục đã lưu trữ.",
|
||||
"tree.state.awaiting": "đang chờ bạn",
|
||||
"tree.state.error": "lỗi",
|
||||
"tree.state.live": "đang chạy",
|
||||
"tree.state.idle": "đã kết thúc",
|
||||
"tree.showMore_one": "Hiện thêm {{count}}",
|
||||
"tree.showMore_other": "Hiện thêm {{count}}",
|
||||
"home.contextPanel": "Ngữ cảnh workspace",
|
||||
"row.state.needsYou": "cần bạn",
|
||||
"row.state.error": "lỗi",
|
||||
"row.state.running": "đang chạy",
|
||||
"row.state.active": "đang hoạt động",
|
||||
"row.state.idle": "nghỉ",
|
||||
"menu.stopTitle_one": "Dừng phiên đang chạy?",
|
||||
"menu.stopTitle_other": "Dừng {{count}} phiên đang chạy?",
|
||||
"menu.stopBody": "Mọi tab của workspace này sẽ dừng, kể cả khi đang chạy dở. Có thể tiếp tục các phiên sau từ mục Gần đây.",
|
||||
"gitBar.done.fetch": "Đã fetch",
|
||||
"gitBar.done.pull": "Đã pull",
|
||||
"gitBar.done.push": "Đã push",
|
||||
"gitBar.done.publish": "Đã publish",
|
||||
"runtime.listening": "đang lắng nghe",
|
||||
"runtime.notListening": "không lắng nghe",
|
||||
"replay.pasted": "Session đó đã mở trong một tab — tin nhắn đã được dán vào đó; nhấn Enter để gửi.",
|
||||
"replay.notPasted": "Session đó đang mở trong một tab nhưng không dán được tin nhắn vào đó: {{text}}"
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"approvalOnRequest": "Khi cần",
|
||||
"cwd": "Thư mục làm việc",
|
||||
"cwdGroups": {
|
||||
"dashboard": "Dashboard server",
|
||||
"dashboard": "Máy chủ dashboard",
|
||||
"home": "Home",
|
||||
"repo": "Repo nguồn",
|
||||
"recent": "Gần đây - Claude Code đã dùng"
|
||||
@@ -79,7 +79,7 @@
|
||||
"permissionBypass": "bypassPermissions (NGUY HIỂM - cho phép tất cả mà không hỏi)",
|
||||
"bypass": "Bỏ qua xin quyền cho lần chạy này",
|
||||
"permissionDefault": "default (prompt tương tác - headless sẽ treo)",
|
||||
"permissionMode": "Permission mode",
|
||||
"permissionMode": "Chế độ quyền",
|
||||
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
|
||||
"prompt": "Prompt",
|
||||
"promptPlaceholder": "Hỏi Claude bất cứ điều gì…",
|
||||
@@ -92,7 +92,7 @@
|
||||
"footer": {
|
||||
"cost": "Chi phí",
|
||||
"duration": "Thời lượng",
|
||||
"exitCode": "Exit code",
|
||||
"exitCode": "Mã thoát",
|
||||
"sessionId": "Session ID",
|
||||
"turns": "Lượt"
|
||||
},
|
||||
@@ -134,7 +134,7 @@
|
||||
"session": "Session"
|
||||
},
|
||||
"tokens": {
|
||||
"cacheRead": "Cache hit",
|
||||
"cacheRead": "Trúng cache",
|
||||
"cost": "Chi phí",
|
||||
"input": "Input",
|
||||
"label": "Context",
|
||||
@@ -147,5 +147,17 @@
|
||||
"imagesOnly": "Chỉ thả được ảnh vào đây",
|
||||
"remove": "Bỏ ảnh",
|
||||
"view": "Xem ảnh"
|
||||
},
|
||||
"terminal": {
|
||||
"ended": "[phiên đã kết thúc, mã thoát {{code}}]",
|
||||
"reconnecting": "Đang kết nối lại… phím gõ chưa tới được phiên cho tới khi terminal kết nối lại.",
|
||||
"keyboardHint": "Terminal. Nhấn Ctrl+M để bật/tắt chế độ Tab chuyển focus ra khỏi terminal; Ctrl+Shift+F (Cmd+F trên macOS) để tìm chữ.",
|
||||
"tabMoves": "Tab chuyển focus — Ctrl+M để trả Tab lại cho terminal",
|
||||
"selectHint": "Shift+kéo để chọn chữ trong trình duyệt; kéo thường sẽ chọn trong tmux và sao chép khi thả chuột.",
|
||||
"dismissHint": "Bỏ qua",
|
||||
"find": "Tìm trong terminal",
|
||||
"findPrev": "Kết quả trước",
|
||||
"findNext": "Kết quả sau",
|
||||
"findClose": "Đóng tìm kiếm"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file panePaste.test.ts
|
||||
* @description Pins where a pasted diff comment lands: the workspace's pane
|
||||
* focused last, else any of its panes, never another workspace's (lane 1 is
|
||||
* not lane 12), and nowhere once the pane unmounted.
|
||||
* not lane 12), nowhere once the pane unmounted, and never into a pane that
|
||||
* is not on screen (a hidden console behind a full-width Changes panel).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -16,7 +17,7 @@ describe("panePaste", () => {
|
||||
const off12 = reg("agenthub-lane-12");
|
||||
const off0 = reg("agenthub-lane-1");
|
||||
const off2 = reg("agenthub-lane-1-2");
|
||||
expect(pasteToLane(1, "a")).toBe(true);
|
||||
expect(pasteToLane(1, "a")).toBe("agenthub-lane-1");
|
||||
expect(got["agenthub-lane-1"]).toEqual(["a"]);
|
||||
notePaneFocus("agenthub-lane-1-2");
|
||||
pasteToLane(1, "b");
|
||||
@@ -27,7 +28,24 @@ describe("panePaste", () => {
|
||||
expect(got["agenthub-lane-12"]).toBeUndefined();
|
||||
off0();
|
||||
off2();
|
||||
expect(pasteToLane(1, "d")).toBe(false);
|
||||
expect(pasteToLane(1, "d")).toBeNull();
|
||||
off12();
|
||||
});
|
||||
|
||||
it("never pastes into a pane that is not visible", () => {
|
||||
const got: string[] = [];
|
||||
let shown = false;
|
||||
const off = registerPane(
|
||||
"agenthub-lane-5",
|
||||
(t) => got.push(t),
|
||||
() => shown
|
||||
);
|
||||
notePaneFocus("agenthub-lane-5");
|
||||
expect(pasteToLane(5, "x")).toBeNull();
|
||||
expect(got).toEqual([]);
|
||||
shown = true;
|
||||
expect(pasteToLane(5, "y")).toBe("agenthub-lane-5");
|
||||
expect(got).toEqual(["y"]);
|
||||
off();
|
||||
});
|
||||
});
|
||||
|
||||
+34
-14
@@ -4,18 +4,30 @@
|
||||
* mounted TerminalView registers its run id; the text goes to the pane of that
|
||||
* workspace the user focused last, else any of its panes. It is a paste —
|
||||
* bracketed, so `claude` takes it as one block in its composer — and never an
|
||||
* Enter: the user reads it and sends it.
|
||||
* Enter: the user reads it and sends it. Only a pane on SCREEN takes it (one
|
||||
* hidden behind a full-width Changes panel or a replay would swallow it
|
||||
* unseen); the caller says where it went, or that it went nowhere.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const panes = new Map<string, (text: string) => void>();
|
||||
interface Pane {
|
||||
paste: (text: string) => void;
|
||||
visible: () => boolean;
|
||||
}
|
||||
const panes = new Map<string, Pane>();
|
||||
let lastFocused: string | null = null;
|
||||
|
||||
/** Registers a mounted terminal; returns its unregister. */
|
||||
export function registerPane(runId: string, paste: (text: string) => void): () => void {
|
||||
panes.set(runId, paste);
|
||||
/** Registers a mounted terminal (`visible`: is it on screen now); returns its
|
||||
* unregister. */
|
||||
export function registerPane(
|
||||
runId: string,
|
||||
paste: (text: string) => void,
|
||||
visible: () => boolean = () => true
|
||||
): () => void {
|
||||
const entry = { paste, visible };
|
||||
panes.set(runId, entry);
|
||||
return () => {
|
||||
if (panes.get(runId) === paste) panes.delete(runId);
|
||||
if (panes.get(runId) === entry) panes.delete(runId);
|
||||
if (lastFocused === runId) lastFocused = null;
|
||||
};
|
||||
}
|
||||
@@ -30,13 +42,21 @@ function ofLane(runId: string, laneId: number): boolean {
|
||||
return runId === base || runId.startsWith(`${base}-`);
|
||||
}
|
||||
|
||||
/** Pastes `text` into one of the workspace's mounted panes; false when none is. */
|
||||
export function pasteToLane(laneId: number, text: string): boolean {
|
||||
const id =
|
||||
lastFocused && panes.has(lastFocused) && ofLane(lastFocused, laneId)
|
||||
? lastFocused
|
||||
: [...panes.keys()].find((k) => ofLane(k, laneId));
|
||||
if (!id) return false;
|
||||
panes.get(id)!(text);
|
||||
/** Pastes `text` into the pane of `runId` when it is mounted and on screen;
|
||||
* false otherwise (not mounted yet, or hidden). */
|
||||
export function pasteToRun(runId: string, text: string): boolean {
|
||||
const p = panes.get(runId);
|
||||
if (!p?.visible()) return false;
|
||||
p.paste(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Pastes `text` into one of the workspace's VISIBLE panes — the one focused
|
||||
* last when it is — and returns its run id; null when none is on screen. */
|
||||
export function pasteToLane(laneId: number, text: string): string | null {
|
||||
const ok = (id: string) => ofLane(id, laneId) && !!panes.get(id)?.visible();
|
||||
const id = lastFocused && ok(lastFocused) ? lastFocused : [...panes.keys()].find(ok);
|
||||
if (!id) return null;
|
||||
panes.get(id)!.paste(text);
|
||||
return id;
|
||||
}
|
||||
|
||||
+286
-84
@@ -10,6 +10,14 @@
|
||||
* Files · Changes · Sessions. Runs are tied to lanes: starting a run
|
||||
* posts to POST /api/lanes/:id/start with tool-appropriate fields. When a cwd
|
||||
* isn't owned by any lane, calls POST /api/lanes/ensure first.
|
||||
* Tabs and pane headers name each live session (`runName`) and show its state;
|
||||
* a tab click replaces the history entry (Back leaves the session view, not
|
||||
* the last tab). Keyboard, outside a terminal: Alt+1–4 focus a pane, Alt+\\
|
||||
* splits the focused one right with a new terminal, Alt+W hides it. The
|
||||
* Changes panel resizes by its button's presets or by dragging (or arrow keys
|
||||
* on) its splitter; an unsaved edit in it is asked about before the panel
|
||||
* closes, switches tab, or moves to another workspace or session (it stays on
|
||||
* the old one until saved or discarded). Errors are toasts.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -57,15 +65,30 @@ import {
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { CwdSuggestion, RunHandle, RunListResponse } from "../lib/api";
|
||||
import type { Lane, LaneFeature, ProofFeature } from "../lib/types";
|
||||
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
|
||||
import { toast } from "../lib/toast";
|
||||
import { confirmDiscard } from "../lib/confirm";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||
import { ConsoleTabs, newTabBody, newTabTools } from "../components/run/ConsoleTabs";
|
||||
import { type LayoutUpdate, PaneGrid, SplitMenus, usePaneLayout } from "../components/run/PaneGrid";
|
||||
import {
|
||||
ConsoleTabs,
|
||||
newTabBody,
|
||||
newTabTools,
|
||||
runName,
|
||||
runState,
|
||||
} from "../components/run/ConsoleTabs";
|
||||
import {
|
||||
Divider,
|
||||
type LayoutUpdate,
|
||||
PaneGrid,
|
||||
SplitMenus,
|
||||
usePaneLayout,
|
||||
} from "../components/run/PaneGrid";
|
||||
import {
|
||||
type Edge,
|
||||
type PaneLayout,
|
||||
MAX_PANES,
|
||||
closeInLayout,
|
||||
leaves,
|
||||
moveInLayout,
|
||||
@@ -74,7 +97,8 @@ import {
|
||||
pruneLayout,
|
||||
} from "../lib/paneLayout";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { freeSlot } from "../components/lanes/WorkspaceHome";
|
||||
import { freeSlot, startInFreeTab } from "../components/lanes/startSession";
|
||||
import { sessionWorktreeName } from "../components/lanes/WorkspaceSessionList";
|
||||
import { LIVE_RUNS_EVENT } from "../components/WorkspaceTree";
|
||||
import PipelineMap from "../components/lanes/PipelineMap";
|
||||
import LaneCard, { since } from "../components/lanes/LaneCard";
|
||||
@@ -105,8 +129,10 @@ type PanelTab = (typeof PANEL_TABS)[number]["id"];
|
||||
* 30/70 → 50/50 → full → closed, rather than a width button beside a
|
||||
* collapse button that both live in the panel's tightest corner. */
|
||||
const CHANGES_SIZES = ["compact", "half", "full"] as const;
|
||||
type ChangesSize = (typeof CHANGES_SIZES)[number];
|
||||
const CHANGES_WIDTH: Record<ChangesSize, string> = {
|
||||
type ChangesPreset = (typeof CHANGES_SIZES)[number];
|
||||
/** A preset, or a width (panel fraction 0.15–0.85) the splitter was dragged to. */
|
||||
type ChangesSize = ChangesPreset | number;
|
||||
const CHANGES_WIDTH: Record<ChangesPreset, string> = {
|
||||
compact: "w-[30%]",
|
||||
half: "w-1/2",
|
||||
// The console beside it is HIDDEN, not unmounted: a live run's terminal
|
||||
@@ -115,11 +141,20 @@ const CHANGES_WIDTH: Record<ChangesSize, string> = {
|
||||
};
|
||||
/** The icon the one resize control shows AT each size: what the next click
|
||||
* gives you — an even split, the full width, then closed. */
|
||||
const CHANGES_NEXT_ICON: Record<ChangesSize, typeof Columns2> = {
|
||||
const CHANGES_NEXT_ICON: Record<ChangesPreset, typeof Columns2> = {
|
||||
compact: Columns2,
|
||||
half: Maximize2,
|
||||
full: PanelRightClose,
|
||||
};
|
||||
/** The panel's share of the row, for the splitter. */
|
||||
const changesRatio = (s: ChangesSize) =>
|
||||
typeof s === "number" ? s : s === "compact" ? 0.3 : s === "half" ? 0.5 : 1;
|
||||
/** The preset a size counts as for the button: under ~45% is narrow. */
|
||||
const changesStep = (s: ChangesSize): ChangesPreset =>
|
||||
s === "full" ? "full" : changesRatio(s) < 0.45 ? "compact" : "half";
|
||||
const clampChanges = (r: number) => Math.min(0.85, Math.max(0.15, r));
|
||||
/** The panel's own minimum (`min-w-[18rem]`), in px. */
|
||||
const CHANGES_MIN_PX = 288;
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -161,8 +196,12 @@ function ConsoleArea({
|
||||
attachRequest: { runId: string; nonce: string; split?: boolean; focusOnly?: boolean } | null;
|
||||
/** Back to the workspace home, where a new session is started. */
|
||||
onNewSession?: () => void;
|
||||
/** Opens a run (`?run=`); `split` splits, `focusOnly` only follows the focus. */
|
||||
onOpenRun: (runId: string, opts?: { split?: boolean; focusOnly?: boolean }) => void;
|
||||
/** Opens a run (`?run=`); `split` splits, `focusOnly` only follows the focus,
|
||||
* `replace` adds no history entry (a tab click). */
|
||||
onOpenRun: (
|
||||
runId: string,
|
||||
opts?: { split?: boolean; focusOnly?: boolean; replace?: boolean }
|
||||
) => void;
|
||||
/** A tab was opened or closed: re-read the run list now. */
|
||||
onRunsChanged: () => void;
|
||||
}) {
|
||||
@@ -195,8 +234,26 @@ function ConsoleArea({
|
||||
const exited = useRef(new Set<string>());
|
||||
const laneRef = useRef(selectedLaneId);
|
||||
laneRef.current = selectedLaneId;
|
||||
const [splitError, setSplitError] = useState<string | null>(null);
|
||||
const label = (r: RunHandle) => (r.tool === "shell" ? t("tabs.terminal") : aiTools.label(r.tool));
|
||||
// The sessions the tabs hold — their names and states — re-read when a tab
|
||||
// opens or learns its session, when the lane's status moves, or on a rename.
|
||||
const [tabSessions, setTabSessions] = useState<Map<string, Session>>(new Map());
|
||||
const [sessionsVersion, setSessionsVersion] = useState(0);
|
||||
const tabSessionIds = tabs.map((r) => r.sessionId ?? "").join(",");
|
||||
useEffect(() => {
|
||||
if (selectedLaneId === null || !tabSessionIds.replace(/,/g, "")) return;
|
||||
let live = true;
|
||||
api.sessions
|
||||
.list({ lane: selectedLaneId, limit: 50 })
|
||||
.then((r) => live && setTabSessions(new Map(r.sessions.map((x) => [x.id, x]))))
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [selectedLaneId, tabSessionIds, lane?.status, lane?.needs_action, sessionsVersion]);
|
||||
const sessionOf = (r: RunHandle) => (r.sessionId ? tabSessions.get(r.sessionId) : undefined);
|
||||
const label = (r: RunHandle) =>
|
||||
runName(r, sessionOf(r), aiTools.label(r.tool), t("tabs.terminal"));
|
||||
const stateOf = (r: RunHandle) => (r.tool === "shell" ? null : runState(sessionOf(r)));
|
||||
const runOf = (slot: number | null) => tabs.find((r) => (r.slot ?? 0) === slot);
|
||||
const runKey = (r: RunHandle) => `${r.id}@${r.startedAt}`;
|
||||
|
||||
@@ -293,17 +350,16 @@ function ConsoleArea({
|
||||
const newSplit = async (target: number, edge: Edge, tool?: string) => {
|
||||
if (!lane || free === null) return;
|
||||
const laneId = lane.id;
|
||||
const slot = free;
|
||||
setSplitError(null);
|
||||
try {
|
||||
const body = tool ? newTabBody(tool, lane, aiTools) : { shell: true };
|
||||
const r = await api.lanes.action(laneId, "start", { slot, ...body });
|
||||
// The server picks the slot: the list `free` came from may be stale.
|
||||
const slot = slotOf(await startInFreeTab(laneId, body));
|
||||
onRunsChanged();
|
||||
if (!r.run_id || laneRef.current !== laneId) return;
|
||||
if (laneRef.current !== laneId) return;
|
||||
pending.current.set(slot, activeRuns);
|
||||
change((l) => moveInLayout(l.root ? l : view, slot, target, edge));
|
||||
} catch (err) {
|
||||
setSplitError(err instanceof Error ? err.message : String(err));
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -316,8 +372,43 @@ function ConsoleArea({
|
||||
const mounted = [...new Set([...shown, ...tabs.map((r) => r.slot ?? 0)])].sort();
|
||||
const focusSlot = view.focus;
|
||||
|
||||
// Alt+1–4 focus a pane, Alt+\ splits the focused one right with a new
|
||||
// terminal, Alt+W hides it. Session view only, and never from inside a
|
||||
// terminal or a field, whose keys are their own (Ctrl+M lets Tab out of a
|
||||
// terminal first). Read through a ref: one listener, always current.
|
||||
const onKeys = useRef<(e: KeyboardEvent) => void>(() => undefined);
|
||||
const areaRef = useRef<HTMLDivElement>(null);
|
||||
onKeys.current = (e) => {
|
||||
if (!attachRequest || !e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
// Not under a dialog, and not while the console is hidden (the Changes
|
||||
// panel at full width, a replay, the home) — CSS-hidden, so by class.
|
||||
if (document.querySelector('[role="dialog"]')) return;
|
||||
if (!areaRef.current || areaRef.current.closest(".hidden")) return;
|
||||
const el = e.target as HTMLElement | null;
|
||||
if (el?.closest?.(".xterm")) return;
|
||||
if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName))) return;
|
||||
const digit = /^Digit([1-4])$/.exec(e.code);
|
||||
if (digit) {
|
||||
const s = shown[Number(digit[1]) - 1];
|
||||
if (s === undefined) return;
|
||||
e.preventDefault();
|
||||
change((l) => (l.root && l.focus !== s ? { ...l, focus: s } : l));
|
||||
} else if (e.code === "Backslash" && focusSlot !== null) {
|
||||
e.preventDefault();
|
||||
if (free !== null && shown.length < MAX_PANES) void newSplit(focusSlot, "right");
|
||||
} else if (e.code === "KeyW" && focusSlot !== null && shown.length > 1) {
|
||||
e.preventDefault();
|
||||
change((l) => closeInLayout(l, focusSlot));
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
const on = (e: KeyboardEvent) => onKeys.current(e);
|
||||
document.addEventListener("keydown", on);
|
||||
return () => document.removeEventListener("keydown", on);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div ref={areaRef} className="flex min-h-0 flex-1 flex-col">
|
||||
{lane && (
|
||||
<ConsoleTabs
|
||||
lane={lane}
|
||||
@@ -326,8 +417,12 @@ function ConsoleArea({
|
||||
shownRunIds={shown.map((s) => runOf(s)?.id).filter((id): id is string => !!id)}
|
||||
onOpen={(runId, split) => {
|
||||
onRunsChanged();
|
||||
onOpenRun(runId, { split });
|
||||
// A tab click is not a new history entry: Back leaves the
|
||||
// session view instead of walking back through every tab.
|
||||
onOpenRun(runId, { split, replace: true });
|
||||
}}
|
||||
sessions={tabSessions}
|
||||
onRenamed={() => setSessionsVersion((v) => v + 1)}
|
||||
onClosed={(runId) => ended(slotOf(runId))}
|
||||
onHome={onNewSession}
|
||||
// While one pane is shown, its split menus (a split pane has its own).
|
||||
@@ -351,11 +446,6 @@ function ConsoleArea({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{splitError && (
|
||||
<p role="alert" className="px-3 pt-2 text-xs text-status-danger">
|
||||
{splitError}
|
||||
</p>
|
||||
)}
|
||||
<PaneGrid
|
||||
layout={view}
|
||||
mounted={mounted}
|
||||
@@ -366,6 +456,7 @@ function ConsoleArea({
|
||||
tools={splitTools}
|
||||
toolLabel={aiTools.label}
|
||||
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
|
||||
stateOf={stateOf}
|
||||
renderPane={(slot) => (
|
||||
<LaneConsolePane
|
||||
lanes={lanes}
|
||||
@@ -433,7 +524,10 @@ export function Workspace() {
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
// A run belongs to its lane: another lane opens on its home.
|
||||
if (next.get("lane") !== String(id)) next.delete("run");
|
||||
if (next.get("lane") !== String(id)) {
|
||||
next.delete("run");
|
||||
next.delete("session");
|
||||
}
|
||||
if (id === null) next.delete("lane");
|
||||
else next.set("lane", String(id));
|
||||
return next;
|
||||
@@ -454,7 +548,10 @@ export function Workspace() {
|
||||
}
|
||||
if (paramId === null) setSelectedLaneId(selectedLaneId);
|
||||
}, [lanesLoaded, selectedLaneId, paramId, setSelectedLaneId]);
|
||||
const [laneActionError, setLaneActionError] = useState<string | null>(null);
|
||||
// A lane action's failure, as a toast (it stays until dismissed).
|
||||
const setLaneActionError = (message: string | null) => {
|
||||
if (message) toast.error(tLanes("actionError", { message }));
|
||||
};
|
||||
const [destructive, setDestructive] = useState<{
|
||||
lane: Lane;
|
||||
action: "reset" | "remove";
|
||||
@@ -516,32 +613,119 @@ export function Workspace() {
|
||||
/* private mode / disabled storage: the panel just forgets between visits */
|
||||
}
|
||||
}, [changesOpen]);
|
||||
// A preset name, or the fraction a splitter drag left (stored as a number).
|
||||
const [changesSize, setChangesSize] = useState<ChangesSize>(() => {
|
||||
try {
|
||||
const saved = window.localStorage.getItem(CHANGES_SIZE_KEY) as ChangesSize | null;
|
||||
return saved && CHANGES_SIZES.includes(saved) ? saved : "compact";
|
||||
const saved = window.localStorage.getItem(CHANGES_SIZE_KEY);
|
||||
if (saved && (CHANGES_SIZES as readonly string[]).includes(saved))
|
||||
return saved as ChangesPreset;
|
||||
const n = Number(saved);
|
||||
return saved && Number.isFinite(n) ? clampChanges(n) : "compact";
|
||||
} catch {
|
||||
return "compact";
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(CHANGES_SIZE_KEY, changesSize);
|
||||
window.localStorage.setItem(CHANGES_SIZE_KEY, String(changesSize));
|
||||
} catch {
|
||||
/* same as above: a forgotten width, not an error */
|
||||
}
|
||||
}, [changesSize]);
|
||||
const changesFull = changesSize === "full";
|
||||
const changesPreset = changesStep(changesSize);
|
||||
const nextChangesSize =
|
||||
CHANGES_SIZES[(CHANGES_SIZES.indexOf(changesSize) + 1) % CHANGES_SIZES.length]!;
|
||||
const changesNextLabel =
|
||||
changesSize === CHANGES_SIZES[CHANGES_SIZES.length - 1]
|
||||
? tLanes("changes.collapse")
|
||||
: tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) });
|
||||
CHANGES_SIZES[(CHANGES_SIZES.indexOf(changesPreset) + 1) % CHANGES_SIZES.length]!;
|
||||
const changesNextLabel = changesFull
|
||||
? tLanes("changes.collapse")
|
||||
: tLanes("changes.resize", { size: tLanes(`changes.size.${nextChangesSize}`) });
|
||||
const changesRowRef = useRef<HTMLDivElement>(null);
|
||||
// The row's width: the panel never gets narrower than its 18rem minimum,
|
||||
// so the splitter is drawn — and dragged — no further than that edge.
|
||||
const [rowWidth, setRowWidth] = useState(0);
|
||||
useEffect(() => {
|
||||
const el = changesRowRef.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(() => setRowWidth(el.clientWidth));
|
||||
ro.observe(el);
|
||||
setRowWidth(el.clientWidth);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
const minChanges = rowWidth ? Math.min(0.85, CHANGES_MIN_PX / rowWidth) : 0.15;
|
||||
|
||||
// The session the opened run holds, when that run works in a worktree of
|
||||
// its own (`claude --worktree`): the Changes panel shows that tree.
|
||||
// its own (`claude --worktree`): the Changes panel shows that tree. A
|
||||
// replayed session (`?session=`) shows its own worktree the same way.
|
||||
const openedRun = runParam ? activeRuns?.items.find((r) => r.id === runParam) : undefined;
|
||||
const gitSession = openedRun?.worktree && openedRun.sessionId ? openedRun.sessionId : undefined;
|
||||
// Its worktree is checked once (`GET …/git?session=`): an ended session's
|
||||
// worktree Claude already removed answers ENOWORKTREE, and the panel then
|
||||
// shows the workspace's own folder instead of an error.
|
||||
const [replayed, setReplayed] = useState<{ id: string; worktree: boolean } | null>(null);
|
||||
const replayLane = lanes.find((l) => l.id === selectedLaneId);
|
||||
useEffect(() => {
|
||||
setReplayed(null);
|
||||
if (!sessionParam || !replayLane) return;
|
||||
let live = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const { session } = await api.sessions.get(sessionParam, { events: false });
|
||||
if (!sessionWorktreeName(replayLane, session)) {
|
||||
if (live) setReplayed({ id: sessionParam, worktree: false });
|
||||
return;
|
||||
}
|
||||
const ok = await api.lanes
|
||||
.git({ lane: replayLane.id, session: sessionParam })
|
||||
.then(() => true)
|
||||
.catch((err: { code?: string }) => err?.code !== "ENOWORKTREE");
|
||||
if (live) setReplayed({ id: sessionParam, worktree: ok });
|
||||
} catch {
|
||||
/* no row: the workspace's folder */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- the lane by id
|
||||
}, [sessionParam, replayLane?.id]);
|
||||
const gitSession =
|
||||
openedRun?.worktree && openedRun.sessionId
|
||||
? openedRun.sessionId
|
||||
: sessionParam && replayed?.id === sessionParam && replayed.worktree
|
||||
? sessionParam
|
||||
: undefined;
|
||||
|
||||
// An unsaved whole-file edit in the Changes panel. While it is open the
|
||||
// panel stays on the tree it was opened in — moving it (another workspace,
|
||||
// session or worktree) would remount it and drop the edit — until the user
|
||||
// says to discard it, or saves.
|
||||
const [changesDirty, setChangesDirty] = useState(false);
|
||||
const wantPanel =
|
||||
selectedLaneId === null ? null : { laneId: selectedLaneId, session: gitSession };
|
||||
const wantPanelKey = wantPanel ? `${wantPanel.laneId}:${wantPanel.session ?? ""}` : "";
|
||||
const [panelAt, setPanelAt] = useState(wantPanel);
|
||||
const panelKey = panelAt ? `${panelAt.laneId}:${panelAt.session ?? ""}` : "";
|
||||
const asking = useRef(false);
|
||||
useEffect(() => {
|
||||
if (wantPanelKey === panelKey) return;
|
||||
if (!changesDirty) {
|
||||
setPanelAt(wantPanel);
|
||||
return;
|
||||
}
|
||||
if (asking.current) return;
|
||||
asking.current = true;
|
||||
void confirmDiscard().then((ok) => {
|
||||
asking.current = false;
|
||||
if (!ok) return;
|
||||
setChangesDirty(false);
|
||||
setPanelAt(wantPanel);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the keys
|
||||
}, [wantPanelKey, panelKey, changesDirty]);
|
||||
/** Runs `fn` once an unsaved edit (if any) may be dropped. */
|
||||
const afterDrop = (fn: () => void) => {
|
||||
if (!changesDirty) return fn();
|
||||
void confirmDiscard().then((ok) => ok && fn());
|
||||
};
|
||||
|
||||
// Probe the CLI of every tool a lane uses — fresh on the server, so a CLI
|
||||
// installed since the page loaded clears its missing-binary banner. Claude is
|
||||
@@ -697,8 +881,7 @@ export function Workspace() {
|
||||
// editor: no page padding, no cards, dividers between its regions.
|
||||
const sessionView = !!currentLane && !home;
|
||||
const viewportLocked =
|
||||
sessionView ||
|
||||
(!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesSize === "full")));
|
||||
sessionView || (!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesFull)));
|
||||
|
||||
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
|
||||
setLaneActionError(null);
|
||||
@@ -807,7 +990,7 @@ export function Workspace() {
|
||||
<WorkspaceHome
|
||||
lane={currentLane}
|
||||
onRenamed={() => void refreshLanes()}
|
||||
onMenuAction={(action) => {
|
||||
onMenuAction={(action, runId) => {
|
||||
if (action === "new-here") {
|
||||
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
|
||||
setAddLaneOpen(true);
|
||||
@@ -816,17 +999,17 @@ export function Workspace() {
|
||||
} else if (action === "started") {
|
||||
setSearchParams({
|
||||
lane: String(currentLane.id),
|
||||
run: `agenthub-lane-${currentLane.id}`,
|
||||
run: runId ?? `agenthub-lane-${currentLane.id}`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className={`flex min-h-0 flex-1${home ? " hidden" : ""}`}>
|
||||
<div ref={changesRowRef} className={`relative flex min-h-0 flex-1${home ? " hidden" : ""}`}>
|
||||
<section
|
||||
data-testid="lane-detail"
|
||||
className={`flex min-h-0 min-w-0 flex-1 flex-col${
|
||||
currentLane && changesOpen && changesSize === "full" ? " hidden" : ""
|
||||
currentLane && changesOpen && changesFull ? " hidden" : ""
|
||||
}`}
|
||||
>
|
||||
{/* No header row: the console's tab bar is the header. Only a
|
||||
@@ -840,7 +1023,7 @@ export function Workspace() {
|
||||
<select
|
||||
data-testid="pipeline-picker"
|
||||
aria-label={tLanes("pipelinePicker.label")}
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs text-fg-secondary disabled:opacity-60"
|
||||
className="select !w-auto !rounded !py-0.5 !pl-2 !pr-6 !text-xs text-fg-secondary"
|
||||
value={currentLane.pipeline}
|
||||
disabled={!!viewedFeature}
|
||||
title={
|
||||
@@ -873,7 +1056,7 @@ export function Workspace() {
|
||||
{features.length > 0 && (
|
||||
<select
|
||||
data-testid="feature-picker"
|
||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
|
||||
className="select !w-auto !rounded !py-0.5 !pl-2 !pr-6 !text-xs"
|
||||
value={viewedFeatureSlug ?? ""}
|
||||
onChange={(e) => setViewedFeatureSlug(e.target.value || null)}
|
||||
>
|
||||
@@ -966,8 +1149,8 @@ export function Workspace() {
|
||||
lane={currentLane}
|
||||
sessionId={sessionParam}
|
||||
liveRuns={activeRuns?.items ?? []}
|
||||
onResumed={(runId) =>
|
||||
setSearchParams({ lane: String(currentLane.id), run: runId })
|
||||
onResumed={(runId, laneId) =>
|
||||
setSearchParams({ lane: String(laneId ?? currentLane.id), run: runId })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -992,8 +1175,9 @@ export function Workspace() {
|
||||
setSearchParams(
|
||||
{ lane: String(currentLane.id), run: runId },
|
||||
{
|
||||
// Following the focus is not a new history entry.
|
||||
replace: opts?.focusOnly,
|
||||
// Following the focus, or a tab click, is not a new
|
||||
// history entry.
|
||||
replace: opts?.focusOnly || opts?.replace,
|
||||
state: opts?.split
|
||||
? { split: true }
|
||||
: opts?.focusOnly
|
||||
@@ -1008,14 +1192,29 @@ export function Workspace() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{currentLane && changesOpen && !changesFull && (
|
||||
// Drag (or arrow keys) to any width; the button keeps its presets.
|
||||
<Divider
|
||||
gridRef={changesRowRef}
|
||||
dir="row"
|
||||
at={1 - Math.max(changesRatio(changesSize), minChanges)}
|
||||
box={{ x: 0, y: 0, w: 1, h: 1 }}
|
||||
label={tLanes("changes.splitter")}
|
||||
onRatio={(r) => setChangesSize(clampChanges(Math.max(1 - r, minChanges)))}
|
||||
/>
|
||||
)}
|
||||
{currentLane &&
|
||||
(changesOpen ? (
|
||||
<aside
|
||||
id="lane-changes-panel"
|
||||
data-testid="lane-changes-card"
|
||||
className={`flex shrink-0 flex-col bg-surface-1 ${CHANGES_WIDTH[changesSize]}${
|
||||
changesSize === "full" ? "" : " border-l border-border"
|
||||
}`}
|
||||
aria-label={tLanes("changes.title")}
|
||||
style={
|
||||
typeof changesSize === "number" ? { width: `${changesSize * 100}%` } : undefined
|
||||
}
|
||||
className={`flex shrink-0 flex-col bg-surface-1 ${
|
||||
typeof changesSize === "number" ? "" : CHANGES_WIDTH[changesSize]
|
||||
}${changesFull ? "" : " min-w-[18rem] border-l border-border"}`}
|
||||
>
|
||||
{/* Activity bar: Files · Changes · Sessions, then the resize
|
||||
control — the same height as the console's tab bar, so the
|
||||
@@ -1030,7 +1229,12 @@ export function Workspace() {
|
||||
type="button"
|
||||
data-testid={`panel-tab-${id}`}
|
||||
aria-pressed={panelTab === id}
|
||||
onClick={() => setPanelTab(id)}
|
||||
// Narrow, a tab switch leaves the open file (and its
|
||||
// edit); at full width the editor stays put.
|
||||
onClick={() =>
|
||||
panelTab !== id &&
|
||||
(changesFull ? setPanelTab(id) : afterDrop(() => setPanelTab(id)))
|
||||
}
|
||||
title={tLanes(`panel.${id}`)}
|
||||
aria-label={tLanes(`panel.${id}`)}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${
|
||||
@@ -1062,10 +1266,13 @@ export function Workspace() {
|
||||
data-testid="lane-changes-resize"
|
||||
onClick={() => {
|
||||
// Past the widest step the next state is "gone", which
|
||||
// is what the rail button reopens at `compact`.
|
||||
if (changesSize === CHANGES_SIZES[CHANGES_SIZES.length - 1]) {
|
||||
setChangesOpen(false);
|
||||
setChangesSize("compact");
|
||||
// is what the rail button reopens at `compact` — and
|
||||
// what drops an unsaved edit, so that asks first.
|
||||
if (changesFull) {
|
||||
afterDrop(() => {
|
||||
setChangesOpen(false);
|
||||
setChangesSize("compact");
|
||||
});
|
||||
} else {
|
||||
setChangesSize(nextChangesSize);
|
||||
}
|
||||
@@ -1082,31 +1289,35 @@ export function Workspace() {
|
||||
{/* Icon-only, and it draws the width the click LANDS on,
|
||||
not the current one — the label the tooltip carries. */}
|
||||
{(() => {
|
||||
const Next = CHANGES_NEXT_ICON[changesSize];
|
||||
const Next = CHANGES_NEXT_ICON[changesPreset];
|
||||
return <Next className="h-4 w-4" />;
|
||||
})()}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
||||
<LaneChangesPanel
|
||||
// A new tree is a new panel: no read for the previous one
|
||||
// can land in it.
|
||||
key={`${currentLane.id}:${gitSession ?? ""}`}
|
||||
laneId={currentLane.id}
|
||||
session={gitSession}
|
||||
tab={panelTab}
|
||||
full={changesSize === "full"}
|
||||
// A sibling worktree forks from the lane's SOURCE repo when
|
||||
// it has one (this lane is itself a worktree of it); an
|
||||
// adopted lane is its own repo.
|
||||
onNewLane={(branch) => {
|
||||
setAddLanePrefill({
|
||||
sourceRepo: currentLane.source_repo || currentLane.cwd,
|
||||
branch,
|
||||
});
|
||||
setAddLaneOpen(true);
|
||||
}}
|
||||
/>
|
||||
{panelAt && (
|
||||
<LaneChangesPanel
|
||||
// A new tree is a new panel: no read for the previous one
|
||||
// can land in it. `panelAt` holds still while an edit is
|
||||
// unsaved (see above).
|
||||
key={panelKey}
|
||||
laneId={panelAt.laneId}
|
||||
session={panelAt.session}
|
||||
tab={panelTab}
|
||||
full={changesFull}
|
||||
onDirtyChange={setChangesDirty}
|
||||
// A sibling worktree forks from the lane's SOURCE repo when
|
||||
// it has one (this lane is itself a worktree of it); an
|
||||
// adopted lane is its own repo.
|
||||
onNewLane={(branch) => {
|
||||
setAddLanePrefill({
|
||||
sourceRepo: currentLane.source_repo || currentLane.cwd,
|
||||
branch,
|
||||
});
|
||||
setAddLaneOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
) : (
|
||||
@@ -1167,15 +1378,6 @@ export function Workspace() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{laneActionError && (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded border border-status-danger bg-status-danger/40 px-3 py-2 text-sm text-status-danger"
|
||||
>
|
||||
{tLanes("actionError", { message: laneActionError })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ let nextRunId = 1;
|
||||
// tests are about the session view it hides, so it is a stub here.
|
||||
vi.mock("../../components/lanes/WorkspaceHome", () => ({
|
||||
WorkspaceHome: () => <div data-testid="workspace-home" />,
|
||||
}));
|
||||
vi.mock("../../components/lanes/startSession", () => ({
|
||||
paneRunId: (id: number, slot: number) =>
|
||||
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
|
||||
freeSlot: (laneId: number, runs: Array<{ laneId: number; slot?: number; status: string }>) => {
|
||||
@@ -63,6 +65,25 @@ vi.mock("../../components/lanes/WorkspaceHome", () => ({
|
||||
);
|
||||
return [0, 1, 2, 3].find((s) => !busy.has(s)) ?? null;
|
||||
},
|
||||
// The server picks the slot (`slot: "auto"`); stand in for it with the first
|
||||
// one the mocked run list leaves free.
|
||||
startInFreeTab: async (laneId: number, body: Record<string, unknown>) => {
|
||||
const { api } = await import("../../lib/api");
|
||||
const { items } = await api.run.list();
|
||||
const busy = new Set(
|
||||
(items as Array<{ laneId: number; slot?: number; status: string }>)
|
||||
.filter((r) => r.laneId === laneId && r.status === "running")
|
||||
.map((r) => r.slot ?? 0)
|
||||
);
|
||||
const slot = [0, 1, 2, 3].find((x) => !busy.has(x)) ?? 0;
|
||||
await api.lanes.action(laneId, "start", { ...body, slot: "auto" });
|
||||
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
|
||||
},
|
||||
}));
|
||||
// Closing an AI tab asks first (the themed confirm needs App's host): yes.
|
||||
vi.mock("../../lib/confirm", () => ({
|
||||
confirmDialog: vi.fn(async () => true),
|
||||
confirmDiscard: vi.fn(async () => true),
|
||||
}));
|
||||
// An ended session's replay (its own suite): a stub that names its session.
|
||||
vi.mock("../../components/lanes/SessionReplay", () => ({
|
||||
@@ -243,6 +264,9 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
},
|
||||
sessions: {
|
||||
list: vi.fn().mockResolvedValue({ sessions: [], total: 0, limit: 50, offset: 0 }),
|
||||
get: vi.fn().mockImplementation(async (id: string) => ({
|
||||
session: { id, cwd: "/workspace/a", tool: "claude" },
|
||||
})),
|
||||
transcript: vi.fn().mockResolvedValue({ messages: [] }),
|
||||
},
|
||||
},
|
||||
@@ -594,7 +618,7 @@ describe("console tabs", () => {
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("console-tab-new"));
|
||||
await user.click(within(screen.getByTestId("console-tab-menu")).getByText("New terminal"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 1, shell: true });
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", shell: true });
|
||||
});
|
||||
|
||||
it("disables + once all four slots are taken", async () => {
|
||||
@@ -782,12 +806,15 @@ describe("split panes", () => {
|
||||
|
||||
await right(screen.getByTestId("pane-header-1"));
|
||||
await user.click(screen.getByTestId("pane-menu-split-bottom"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 3, shell: true });
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", shell: true });
|
||||
// The new terminal (the slot the server picked, 3) opened under pane 1.
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1", "pane-3"]);
|
||||
|
||||
await right(screen.getByTestId("pane-header-1"));
|
||||
await user.click(within(screen.getByTestId("pane-menu-1")).getByText("Close pane"));
|
||||
await user.click(within(screen.getByTestId("pane-menu-1")).getByText("Hide pane"));
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0"]);
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-3"]);
|
||||
expect(api.run.kill).not.toHaveBeenCalled();
|
||||
|
||||
await right(screen.getByTestId("console-tab-2"));
|
||||
@@ -804,7 +831,7 @@ describe("split panes", () => {
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("pane-split-bottom-1"));
|
||||
await user.click(screen.getByTestId("pane-split-new-bottom-1"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 2, shell: true });
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", shell: true });
|
||||
});
|
||||
|
||||
it("a split menu starts an AI tool, bare, in the first free slot", async () => {
|
||||
@@ -815,7 +842,7 @@ 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: 2, prompt: "" });
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", prompt: "" });
|
||||
});
|
||||
|
||||
it("with one pane, the split menus sit beside Tools and split it", async () => {
|
||||
|
||||
@@ -1926,3 +1926,66 @@ describe("a session's own worktree (claude --worktree)", () => {
|
||||
assert.equal(r.body.error.code, "ENOWORKTREE");
|
||||
});
|
||||
});
|
||||
|
||||
// The server, not a stale client list, picks the tab and refuses a second pane
|
||||
// on a session one already holds.
|
||||
describe("start: slot auto and a session already live", () => {
|
||||
const tmux = require("../lib/tmux");
|
||||
let id;
|
||||
let cwd;
|
||||
const live = new Set();
|
||||
before(async () => {
|
||||
cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-auto-"));
|
||||
id = (await request("POST", "/api/lanes", { cwd })).body.lane.id;
|
||||
tmux.__setExecImpl((args) => {
|
||||
const name = String(args[args.indexOf("-t") + 1] || "").replace(/^=|:$/g, "");
|
||||
if (args[0] === "has-session" && !live.has(name)) throw new Error("no such session");
|
||||
if (args[0] === "new-session") live.add(args[args.indexOf("-s") + 1]);
|
||||
if (args[0] === "list-sessions") return [...live].join("\n");
|
||||
return "";
|
||||
});
|
||||
});
|
||||
after(() => {
|
||||
tmux.__reset();
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("takes the first free slot for slot: auto", async () => {
|
||||
live.add(`agenthub-lane-${id}`);
|
||||
live.add(`agenthub-lane-${id}-1`);
|
||||
const r = await request("POST", `/api/lanes/${id}/start`, { slot: "auto", shell: true });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.run_id, `agenthub-lane-${id}-2`);
|
||||
});
|
||||
|
||||
it("answers ERUNLIVE when every slot is busy", async () => {
|
||||
live.add(`agenthub-lane-${id}-3`);
|
||||
const r = await request("POST", `/api/lanes/${id}/start`, { slot: "auto" });
|
||||
assert.equal(r.status, 409);
|
||||
assert.equal(r.body.error.code, "ERUNLIVE");
|
||||
live.delete(`agenthub-lane-${id}-3`);
|
||||
live.delete(`agenthub-lane-${id}-2`);
|
||||
});
|
||||
|
||||
it("refuses to resume a session a live pane already holds, naming that pane", async () => {
|
||||
const first = await request("POST", `/api/lanes/${id}/start`, {
|
||||
slot: "auto",
|
||||
resumeSessionId: "live-sess-0001",
|
||||
});
|
||||
assert.equal(first.status, 200);
|
||||
const again = await request("POST", `/api/lanes/${id}/start`, {
|
||||
slot: "auto",
|
||||
resumeSessionId: "live-sess-0001",
|
||||
});
|
||||
assert.equal(again.status, 409);
|
||||
assert.equal(again.body.error.code, "ESESSIONLIVE");
|
||||
assert.equal(again.body.error.run_id, first.body.run_id);
|
||||
// A fork makes a new session, so it is not a duplicate.
|
||||
const fork = await request("POST", `/api/lanes/${id}/start`, {
|
||||
slot: "auto",
|
||||
resumeSessionId: "live-sess-0001",
|
||||
forkSession: true,
|
||||
});
|
||||
assert.equal(fork.status, 200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -596,6 +596,21 @@ describe("pty-run", () => {
|
||||
assert.deepEqual(paneArgv(last), [at("agy"), "--continue", "--dangerously-skip-permissions"]);
|
||||
});
|
||||
|
||||
// A catalog tool has no "resume THIS session" argv; starting it fresh under
|
||||
// a resume request would read as a resume that silently lost the history.
|
||||
it("refuses resumeSessionId for a catalog tool instead of starting fresh", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
pty.spawnRun({
|
||||
laneId: 15,
|
||||
cwd: "/tmp/repo",
|
||||
tool: "antigravity",
|
||||
resumeSessionId: "abc12345",
|
||||
}),
|
||||
{ code: "EUNSUPPORTED" }
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a flag-shaped model id for any tool, before tmux is asked", () => {
|
||||
for (const tool of ["claude", "codex", "antigravity"]) {
|
||||
assert.throws(
|
||||
|
||||
@@ -319,6 +319,11 @@ function spawnRun(args) {
|
||||
) {
|
||||
throw makeErr("EBADSESSION", "resumeSessionId is not a valid session id");
|
||||
}
|
||||
// Only the hand-written launchers can resume a NAMED session; a catalog
|
||||
// tool would silently start fresh under a "resume".
|
||||
if (resumeSessionId && !entry.launcher) {
|
||||
throw makeErr("EUNSUPPORTED", `${tool} cannot resume a session by id`);
|
||||
}
|
||||
if (worktree === true && entry.launcher !== "claude") {
|
||||
throw makeErr("EUNSUPPORTED", `${tool} cannot start a session in its own worktree`);
|
||||
}
|
||||
|
||||
+48
-9
@@ -1779,20 +1779,37 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
switch (action) {
|
||||
case "start": {
|
||||
const slot = body.slot === undefined ? 0 : body.slot;
|
||||
// `"auto"`: the first free tab, picked under the lane lock below — a
|
||||
// client's own pick comes from a list that may already be stale.
|
||||
const auto = body.slot === "auto";
|
||||
let slot = body.slot === undefined || auto ? 0 : body.slot;
|
||||
if (!Number.isInteger(slot) || slot < 0 || slot > runs.MAX_PANE_SLOT) {
|
||||
return res.status(400).json({
|
||||
error: { code: "EBADSLOT", message: `slot must be an integer 0–${runs.MAX_PANE_SLOT}` },
|
||||
});
|
||||
}
|
||||
const isLive = (laneId, s) =>
|
||||
runs.getRun(runs.runIdForLane(laneId, s))?.status === "running";
|
||||
// Under the lock: the free slot, or null when every tab is busy.
|
||||
const pickSlot = (laneId) => {
|
||||
if (!auto) return slot;
|
||||
for (let s = 0; s <= runs.MAX_PANE_SLOT; s++) if (!isLive(laneId, s)) return s;
|
||||
return null;
|
||||
};
|
||||
const allBusy = () =>
|
||||
res.status(409).json({
|
||||
error: { code: "ERUNLIVE", message: "every tab of this workspace is busy" },
|
||||
});
|
||||
// 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 };
|
||||
const picked = pickSlot(current.id);
|
||||
if (picked === null) return { allBusy: true };
|
||||
slot = picked;
|
||||
if (isLive(current.id, slot)) return { conflict: true };
|
||||
startedRunId = runs.spawnShell({ laneId: current.id, slot, cwd: current.cwd }).id;
|
||||
return {};
|
||||
});
|
||||
@@ -1801,12 +1818,11 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
.status(404)
|
||||
.json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
}
|
||||
if (shellOutcome.allBusy) return allBusy();
|
||||
if (shellOutcome.conflict) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({
|
||||
error: { code: "ERUNLIVE", message: "that tab already has a live session" },
|
||||
});
|
||||
return res.status(409).json({
|
||||
error: { code: "ERUNLIVE", message: "that tab already has a live session" },
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1866,6 +1882,19 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
// waited for the lock — the lock makes that race visible instead of
|
||||
// spawning a run for a lane that no longer exists.
|
||||
if (!current) return { missing: true };
|
||||
// A session holds ONE pane: a second `--resume` of it would run two
|
||||
// agents writing one transcript. Its pane is named instead. A fork
|
||||
// makes a new session, so it is no duplicate.
|
||||
const resumeTarget = body.resumeSessionId || (body.resume ? current.session_id : null);
|
||||
if (resumeTarget && body.forkSession !== true) {
|
||||
const holder = runs
|
||||
.listRuns()
|
||||
.find((r) => r.status === "running" && r.sessionId === resumeTarget);
|
||||
if (holder) return { sessionLive: holder.id };
|
||||
}
|
||||
const picked = pickSlot(current.id);
|
||||
if (picked === null) return { allBusy: true };
|
||||
slot = picked;
|
||||
const liveId = slot ? runs.runIdForLane(current.id, slot) : current.run_id;
|
||||
const live = liveId ? runs.getRun(liveId) : null;
|
||||
if (live && live.status === "running") {
|
||||
@@ -1907,10 +1936,20 @@ router.post("/:id/:action", sameOriginGuard, async (req, res) => {
|
||||
.status(404)
|
||||
.json({ error: { code: "ENOLANE", message: "workspace not found" } });
|
||||
}
|
||||
if (outcome.sessionLive) {
|
||||
return res.status(409).json({
|
||||
error: {
|
||||
code: "ESESSIONLIVE",
|
||||
message: "that session is already open in a tab",
|
||||
run_id: outcome.sessionLive,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (outcome.allBusy) return allBusy();
|
||||
if (outcome.conflict) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: { code: "ERUNLIVE", message: "workspace already has a live run" } });
|
||||
.json({ error: { code: "ERUNLIVE", message: "that tab already has a live session" } });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user