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:
2026-10-05 13:22:12 +07:00
parent 6715fbd971
commit efa417488a
43 changed files with 2252 additions and 507 deletions
+178 -37
View File
@@ -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>