62d1bdc462
- 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)
168 lines
4.4 KiB
TypeScript
168 lines
4.4 KiB
TypeScript
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>
|
||
);
|
||
};
|