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:
+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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user