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:
@@ -100,7 +100,8 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
||||
|
||||
export const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
||||
active: "bg-status-success",
|
||||
idle: "bg-surface-4",
|
||||
// A ring: a surface-4 fill vanished on the light themes.
|
||||
idle: "border border-fg-muted",
|
||||
dead: "bg-status-danger",
|
||||
};
|
||||
|
||||
@@ -128,7 +129,6 @@ export default function LaneCard({
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [destructiveAction, setDestructiveAction] = useState<"reset" | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [menuAt, setMenuAt] = useState<MenuPoint | null>(null);
|
||||
const [runtimeBump, setRuntimeBump] = useState(0);
|
||||
const [bootLine, setBootLine] = useState<string | null>(null);
|
||||
@@ -203,9 +203,13 @@ export default function LaneCard({
|
||||
<div key={name} className="flex items-center gap-1.5 truncate">
|
||||
<span
|
||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
|
||||
info.listening ? "bg-status-success" : "bg-surface-4"
|
||||
info.listening ? "bg-status-success" : "border border-fg-muted"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="sr-only">
|
||||
{info.listening ? t("runtime.listening") : t("runtime.notListening")}
|
||||
</span>
|
||||
<span className="text-fg-secondary">{name}</span>
|
||||
<span className="text-fg-muted">:{info.port ?? "—"}</span>
|
||||
{info.port !== null && info.port !== info.expected && (
|
||||
@@ -265,7 +269,7 @@ export default function LaneCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectLane?.(w.id)}
|
||||
className="truncate text-left text-blue-700 dark:text-blue-400 hover:underline"
|
||||
className="truncate text-left text-accent hover:underline"
|
||||
title={w.cwd}
|
||||
>
|
||||
#{w.id} {w.title || w.cwd} · {w.status}
|
||||
@@ -283,42 +287,23 @@ export default function LaneCard({
|
||||
applies to managed lanes and is rare enough to stay behind ⋯. */}
|
||||
{lane.kind === "managed" && (
|
||||
<div className="mt-auto flex items-center justify-end gap-1 border-t border-border pt-2 text-xs">
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-more"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen((v) => !v);
|
||||
}}
|
||||
className="rounded px-2 py-1 text-fg-muted hover:bg-surface-2 hover:text-fg-secondary"
|
||||
title={t("moreActions")}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 z-10 mt-1 min-w-40 overflow-hidden rounded-md border border-border-light bg-surface-0 py-1 shadow-lg shadow-black/40"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="lane-action-reset"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen(false);
|
||||
setDestructiveAction("reset");
|
||||
}}
|
||||
className="block w-full px-3 py-1.5 text-left text-status-warning hover:bg-status-warning/10"
|
||||
>
|
||||
{t("action.reset")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* The shared menu shell: outside click, Escape and focus. */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-more"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={!!menuAt}
|
||||
aria-label={t("moreActions")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
setMenuAt(menuAt ? null : { x: r.right - 160, y: r.bottom + 4 });
|
||||
}}
|
||||
className="rounded px-2 py-1 text-fg-muted hover:bg-surface-2 hover:text-fg-secondary"
|
||||
title={t("moreActions")}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -327,6 +312,7 @@ export default function LaneCard({
|
||||
<ContextMenu x={menuAt.x} y={menuAt.y} onClose={() => setMenuAt(null)}>
|
||||
<MenuItem
|
||||
danger
|
||||
testId="lane-action-reset"
|
||||
onSelect={() => {
|
||||
setMenuAt(null);
|
||||
setDestructiveAction("reset");
|
||||
|
||||
Reference in New Issue
Block a user