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