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>
|
||||
|
||||
Reference in New Issue
Block a user