fix: unified fullscreen terminal header

- Add showControls prop to TerminalComponent to optionally hide internal header
- Add reset() method to TerminalRef for external reset control
- TerminalPage now renders a unified fullscreen header bar combining:
  - Session tabs (TerminalSessionTabs)
  - Terminal controls (status dot, A-, A+, Reset, Exit Fullscreen)
- Unified header is always visible in fullscreen (no hover-to-reveal)
- TerminalComponent internal header hidden when in fullscreen mode
- Remove old CSS that hid session tabs with opacity:0 until hover

Quality gates: pytest 188 passed, frontend typecheck clean

Fixes: terminal-fullscreen-unified-header
This commit is contained in:
Alex Blank
2026-05-29 10:34:31 +02:00
parent 0fdbef578f
commit 8926152fca
7 changed files with 297 additions and 95 deletions
+76 -66
View File
@@ -20,6 +20,7 @@ export interface TerminalProps {
sessionId?: string;
onClose?: () => void;
isMobile?: boolean;
showControls?: boolean;
activeModifier?: ModifierKey | null;
onModifierChange?: (modifier: ModifierKey | null) => void;
onTerminalReady?: (
@@ -38,6 +39,7 @@ export interface TerminalProps {
export interface TerminalRef {
fit: () => void;
focus: () => void;
reset: () => void;
}
const FONT_SIZE_KEY = "terminal-font-size";
@@ -53,6 +55,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
sessionId,
onClose,
isMobile = false,
showControls = true,
activeModifier,
onModifierChange,
onTerminalReady,
@@ -471,6 +474,11 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
focus: () => {
termRef.current?.focus();
},
reset: () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "reset" }));
}
},
}));
// Update parent about status changes
@@ -561,78 +569,80 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
return (
<div className={`terminal-wrapper ${isMobile ? "mobile" : ""}`}>
<div className="terminal-header">
<div className="terminal-header-left">
<div className="terminal-status">
<span
className={`status-dot ${status}`}
aria-label={`Terminal status: ${status}`}
/>
<span className="status-text">
{status === "resetting"
? "Resetting..."
: reconnectAttemptsRef.current > 0 && status !== "connected"
? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...`
: status}
</span>
{showControls && (
<div className="terminal-header">
<div className="terminal-header-left">
<div className="terminal-status">
<span
className={`status-dot ${status}`}
aria-label={`Terminal status: ${status}`}
/>
<span className="status-text">
{status === "resetting"
? "Resetting..."
: reconnectAttemptsRef.current > 0 && status !== "connected"
? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...`
: status}
</span>
</div>
{isMobile && (
<>
<button
className="terminal-header-button"
onClick={handleCopy}
type="button"
aria-label="Copy selection"
>
Copy
</button>
<button
className="terminal-header-button"
onClick={handlePaste}
type="button"
aria-label="Paste from clipboard"
>
Paste
</button>
</>
)}
</div>
{isMobile && (
<>
<button
className="terminal-header-button"
onClick={handleCopy}
type="button"
aria-label="Copy selection"
>
Copy
</button>
<button
className="terminal-header-button"
onClick={handlePaste}
type="button"
aria-label="Paste from clipboard"
>
Paste
</button>
</>
)}
</div>
<div className="terminal-header-right">
<button
className="terminal-header-button"
onClick={() => handleFontSizeChange(-1)}
type="button"
aria-label="Decrease font size"
>
A-
</button>
<button
className="terminal-header-button"
onClick={() => handleFontSizeChange(1)}
type="button"
aria-label="Increase font size"
>
A+
</button>
<button
className="terminal-header-button"
onClick={() => setShowResetConfirm(true)}
type="button"
aria-label="Reset terminal"
>
Reset
</button>
{onClose && (
<div className="terminal-header-right">
<button
className="terminal-close"
onClick={onClose}
className="terminal-header-button"
onClick={() => handleFontSizeChange(-1)}
type="button"
aria-label="Decrease font size"
>
Close
A-
</button>
)}
<button
className="terminal-header-button"
onClick={() => handleFontSizeChange(1)}
type="button"
aria-label="Increase font size"
>
A+
</button>
<button
className="terminal-header-button"
onClick={() => setShowResetConfirm(true)}
type="button"
aria-label="Reset terminal"
>
Reset
</button>
{onClose && (
<button
className="terminal-close"
onClick={onClose}
type="button"
>
Close
</button>
)}
</div>
</div>
</div>
)}
{showResetConfirm && (
<div className="terminal-reset-confirm">
<div className="terminal-reset-confirm-content">
+125 -9
View File
@@ -17,6 +17,8 @@ const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
status: s.status as TerminalSessionInfo["status"],
}));
type TerminalStatus = "connecting" | "connected" | "disconnected" | "error" | "resetting";
export const TerminalPage: React.FC = () => {
const { instanceId } = useParams<{
instanceId: string;
@@ -27,6 +29,11 @@ export const TerminalPage: React.FC = () => {
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
// Track terminal status and callbacks for unified fullscreen header
const [terminalStatuses, setTerminalStatuses] = useState<Record<string, TerminalStatus>>({});
const changeFontSizeRef = useRef<((delta: number) => void) | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const {
sessions,
activeSessionId,
@@ -178,6 +185,29 @@ export const TerminalPage: React.FC = () => {
[renameSession],
);
const handleTerminalReady = useCallback(
(
_sendData: (data: string) => void,
status: TerminalStatus,
_focusInput: () => void,
changeFontSize: (delta: number) => void,
) => {
setTerminalStatuses((prev) => ({ ...prev, [activeSessionId ?? "default"]: status }));
changeFontSizeRef.current = changeFontSize;
},
[activeSessionId],
);
const handleFontSizeChange = useCallback((delta: number) => {
changeFontSizeRef.current?.(delta);
}, []);
const handleReset = useCallback(() => {
if (activeSessionId && terminalRefs.current[activeSessionId]) {
terminalRefs.current[activeSessionId].current?.reset();
}
}, [activeSessionId]);
if (!instanceId) {
return (
<section className="stack">
@@ -240,6 +270,7 @@ export const TerminalPage: React.FC = () => {
sessionId={session.id}
onClose={() => handleClose(session.id)}
isMobile={true}
onTerminalReady={handleTerminalReady}
/>
</div>
))}
@@ -275,15 +306,98 @@ export const TerminalPage: React.FC = () => {
</button>
</div>
)}
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId ?? ""}
onSelect={handleSelect}
onClose={handleClose}
onCreate={handleCreate}
onRename={handleRename}
isMobile={false}
/>
{isFullscreen ? (
<div className="terminal-fullscreen-header">
<div className="terminal-fullscreen-header-tabs">
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId ?? ""}
onSelect={handleSelect}
onClose={handleClose}
onCreate={handleCreate}
onRename={handleRename}
isMobile={false}
/>
</div>
<div className="terminal-fullscreen-header-controls">
<span
className={`terminal-fullscreen-status status-dot ${terminalStatuses[activeSessionId ?? "default"] ?? "connecting"}`}
aria-label={`Terminal status: ${terminalStatuses[activeSessionId ?? "default"] ?? "connecting"}`}
/>
<button
className="terminal-header-button"
onClick={() => handleFontSizeChange(-1)}
type="button"
aria-label="Decrease font size"
>
A-
</button>
<button
className="terminal-header-button"
onClick={() => handleFontSizeChange(1)}
type="button"
aria-label="Increase font size"
>
A+
</button>
<button
className="terminal-header-button"
onClick={() => setShowResetConfirm(true)}
type="button"
aria-label="Reset terminal"
>
Reset
</button>
<button
className="terminal-close"
onClick={() => setIsFullscreen(false)}
type="button"
title="Exit fullscreen (Esc)"
>
Exit
</button>
</div>
{showResetConfirm && (
<div className="terminal-reset-confirm">
<div className="terminal-reset-confirm-content">
<p>
Reset terminal? This will kill the current shell session and
start fresh.
</p>
<div className="terminal-reset-confirm-buttons">
<button
className="terminal-reset-confirm-button cancel"
onClick={() => setShowResetConfirm(false)}
type="button"
>
Cancel
</button>
<button
className="terminal-reset-confirm-button confirm"
onClick={() => {
setShowResetConfirm(false);
handleReset();
}}
type="button"
>
Reset
</button>
</div>
</div>
</div>
)}
</div>
) : (
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId ?? ""}
onSelect={handleSelect}
onClose={handleClose}
onCreate={handleCreate}
onRename={handleRename}
isMobile={false}
/>
)}
<div className="terminal-page-content">
{error && <div className="terminal-error-banner">{error}</div>}
{sessions
@@ -296,6 +410,8 @@ export const TerminalPage: React.FC = () => {
sessionId={session.id}
onClose={() => handleClose(session.id)}
isMobile={false}
showControls={!isFullscreen}
onTerminalReady={handleTerminalReady}
/>
</div>
))}
+49 -10
View File
@@ -2890,18 +2890,57 @@ a.nav-item,
border-radius: 0;
}
.terminal-page.fullscreen .terminal-session-tabs {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
opacity: 0;
transition: opacity 0.3s;
/* Unified fullscreen header: session tabs + terminal controls */
.terminal-fullscreen-header {
display: flex;
align-items: center;
justify-content: space-between;
background: #2d2d2d;
border-bottom: 1px solid #3e3e3e;
flex-shrink: 0;
min-height: 0;
}
.terminal-page.fullscreen .terminal-session-tabs:hover {
opacity: 1;
.terminal-fullscreen-header-tabs {
flex: 1;
min-width: 0;
overflow: hidden;
}
.terminal-fullscreen-header-tabs .terminal-session-tabs {
background: transparent;
border-bottom: none;
}
.terminal-fullscreen-header-controls {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-3);
flex-shrink: 0;
border-left: 1px solid #3e3e3e;
}
.terminal-fullscreen-status {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
flex-shrink: 0;
}
.terminal-fullscreen-status.connecting {
background: #f5f543;
animation: pulse 1.5s infinite;
}
.terminal-fullscreen-status.connected {
background: #0dbc79;
}
.terminal-fullscreen-status.disconnected,
.terminal-fullscreen-status.error {
background: #cd3131;
}
/* Mobile auto-hide header and tabs */