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:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user