feat: multi-session terminal frontend UI + tests (PR 3)

- Add TerminalSessionTabs component with status dots, rename, close, max-5 limit
- Add 7 component tests for tab rendering, selection, close, rename
- TerminalComponent: sessionId prop, forwardRef with fit() method
- TerminalPage: multi-session orchestration, tab switching, auto-create default
- Fullscreen mode: Alt+Shift+F toggle, auto-hide tabs, Esc exit
- Keyboard shortcuts: Alt+Shift+N/W/ArrowLeft/ArrowRight/R
- Add CSS for tabs, fullscreen, mobile responsive
- Update useTerminalSessions hook for session CRUD
- terminal_manager.py: lookup by internal session_id fallback

Quality gates: tsc --noEmit clean, vitest (7/7 new tests passed), pytest (182 passed)
This commit is contained in:
2026-05-28 13:35:45 +02:00
parent 0b35ae3bf0
commit 62d1bdc462
26 changed files with 4589 additions and 775 deletions
@@ -0,0 +1,167 @@
import React, { useState, useRef, useCallback } from "react";
export interface TerminalSessionInfo {
id: string;
name: string;
status: "connecting" | "connected" | "disconnected" | "error" | "resetting";
}
export interface TerminalSessionTabsProps {
sessions: TerminalSessionInfo[];
activeSessionId: string;
onSelect: (sessionId: string) => void;
onClose: (sessionId: string) => void;
onCreate: () => void;
onRename: (sessionId: string, newName: string) => void;
isMobile?: boolean;
}
export const TerminalSessionTabs: React.FC<TerminalSessionTabsProps> = ({
sessions,
activeSessionId,
onSelect,
onClose,
onCreate,
onRename,
isMobile = false,
}) => {
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [confirmCloseId, setConfirmCloseId] = useState<string | null>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const handleDoubleClick = useCallback((session: TerminalSessionInfo) => {
setRenamingId(session.id);
setRenameValue(session.name);
requestAnimationFrame(() => {
renameInputRef.current?.focus();
renameInputRef.current?.select();
});
}, []);
const commitRename = useCallback(() => {
if (renamingId && renameValue.trim()) {
onRename(renamingId, renameValue.trim());
}
setRenamingId(null);
setRenameValue("");
}, [renamingId, renameValue, onRename]);
const cancelRename = useCallback(() => {
setRenamingId(null);
setRenameValue("");
}, []);
const handleRenameKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
commitRename();
} else if (e.key === "Escape") {
cancelRename();
}
},
[commitRename, cancelRename],
);
const handleCloseClick = useCallback(
(e: React.MouseEvent, sessionId: string) => {
e.stopPropagation();
if (confirmCloseId === sessionId) {
setConfirmCloseId(null);
onClose(sessionId);
} else {
setConfirmCloseId(sessionId);
// Auto-dismiss confirm after 3s
setTimeout(() => {
setConfirmCloseId((prev) => (prev === sessionId ? null : prev));
}, 3000);
}
},
[confirmCloseId, onClose],
);
const isMaxSessions = sessions.length >= 5;
return (
<div
className={`terminal-session-tabs ${isMobile ? "mobile" : ""}`}
role="tablist"
aria-label="Terminal sessions"
>
<div className="terminal-session-tabs-scroll" ref={scrollRef}>
{sessions.map((session) => {
const isActive = session.id === activeSessionId;
const isRenaming = renamingId === session.id;
const isConfirmingClose = confirmCloseId === session.id;
return (
<div
key={session.id}
className={`terminal-session-tab ${isActive ? "active" : ""}`}
role="tab"
aria-selected={isActive}
onClick={() => onSelect(session.id)}
onDoubleClick={() => handleDoubleClick(session)}
title={isRenaming ? "" : `${session.name} (${session.status})`}
>
<span
className={`terminal-session-tab-status ${session.status}`}
aria-hidden="true"
/>
{isRenaming ? (
<input
ref={renameInputRef}
className="terminal-session-tab-input"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleRenameKeyDown}
onBlur={commitRename}
onClick={(e) => e.stopPropagation()}
aria-label="Rename session"
/>
) : (
<span className="terminal-session-tab-name">
{session.name}
</span>
)}
{isConfirmingClose ? (
<button
className="terminal-session-tab-confirm"
onClick={(e) => {
e.stopPropagation();
setConfirmCloseId(null);
onClose(session.id);
}}
type="button"
>
Close?
</button>
) : (
<button
className="terminal-session-tab-close"
onClick={(e) => handleCloseClick(e, session.id)}
type="button"
aria-label={`Close session ${session.name}`}
tabIndex={-1}
>
×
</button>
)}
</div>
);
})}
<button
className="terminal-session-tab new-session"
onClick={onCreate}
disabled={isMaxSessions}
type="button"
aria-label="New session"
title={isMaxSessions ? "Maximum 5 sessions reached" : "New session"}
>
+
</button>
</div>
</div>
);
};