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
+1
View File
@@ -1303,6 +1303,7 @@ async def start_instance(
home_dir = "/root"
if tool_type and tool_type.definition_type == "manifest" and tool_type.manifest_id:
from src.models.tool_definition_manifest import ToolDefinitionManifest
manifest_def = await session.get(ToolDefinitionManifest, tool_type.manifest_id)
if manifest_def:
home_dir = get_manifest_home_dir(dict(manifest_def.manifest))
@@ -12,7 +12,9 @@ class TestExpandContainerPath:
def test_tilde_slash_expands(self) -> None:
"""~/foo should expand to home_dir/foo."""
assert expand_container_path("~/workspace", "/home/user") == "/home/user/workspace"
assert (
expand_container_path("~/workspace", "/home/user") == "/home/user/workspace"
)
def test_tilde_alone_expands(self) -> None:
"""~ should expand to home_dir."""
@@ -20,7 +22,10 @@ class TestExpandContainerPath:
def test_dollar_home_slash_expands(self) -> None:
"""$HOME/foo should expand to home_dir/foo."""
assert expand_container_path("$HOME/workspace", "/home/user") == "/home/user/workspace"
assert (
expand_container_path("$HOME/workspace", "/home/user")
== "/home/user/workspace"
)
def test_dollar_home_alone_expands(self) -> None:
"""$HOME should expand to home_dir."""
@@ -78,7 +83,9 @@ class TestResolveGitMountMappingsExpansion:
"""Mapping with ~/repo target expands to home dir."""
(tmp_path / "src").mkdir()
mappings = [{"source_path": "src", "target_path": "~/repo"}]
result = _resolve_git_mount_mappings(str(tmp_path), mappings, None, "/home/user")
result = _resolve_git_mount_mappings(
str(tmp_path), mappings, None, "/home/user"
)
assert len(result) == 1
assert result[0]["target"] == "/home/user/repo"
@@ -86,7 +93,9 @@ class TestResolveGitMountMappingsExpansion:
"""Mapping with $HOME/repo target expands to home dir."""
(tmp_path / "src").mkdir()
mappings = [{"source_path": "src", "target_path": "$HOME/repo"}]
result = _resolve_git_mount_mappings(str(tmp_path), mappings, None, "/home/user")
result = _resolve_git_mount_mappings(
str(tmp_path), mappings, None, "/home/user"
)
assert len(result) == 1
assert result[0]["target"] == "/home/user/repo"
@@ -94,6 +103,8 @@ class TestResolveGitMountMappingsExpansion:
"""Absolute target paths are not modified."""
(tmp_path / "src").mkdir()
mappings = [{"source_path": "src", "target_path": "/app/src"}]
result = _resolve_git_mount_mappings(str(tmp_path), mappings, None, "/home/user")
result = _resolve_git_mount_mappings(
str(tmp_path), mappings, None, "/home/user"
)
assert len(result) == 1
assert result[0]["target"] == "/app/src"
@@ -77,7 +77,9 @@ def mock_session(fake_user_id, fake_project_id, fake_repo_id, fake_tool_type_id)
session.get.side_effect = _get
session.add = MagicMock(side_effect=_add)
session.execute.return_value = MagicMock(scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))))
session.execute.return_value = MagicMock(
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[])))
)
return session
@@ -440,7 +442,11 @@ class TestStartInstanceLegacyFallback:
mock_get_container_id.return_value = "abc123"
mock_get_container_name.return_value = "test-container"
mock_connect_network.return_value = True
mock_wait_container.return_value = {"success": True, "status": "running", "waited_seconds": 0.5}
mock_wait_container.return_value = {
"success": True,
"status": "running",
"waited_seconds": 0.5,
}
instance = ToolInstance(
id=fake_instance_id,
@@ -534,7 +540,11 @@ class TestStartInstanceLegacyFallback:
mock_get_container_id.return_value = "abc123"
mock_get_container_name.return_value = "test-container"
mock_connect_network.return_value = True
mock_wait_container.return_value = {"success": True, "status": "running", "waited_seconds": 0.5}
mock_wait_container.return_value = {
"success": True,
"status": "running",
"waited_seconds": 0.5,
}
instance = ToolInstance(
id=fake_instance_id,
@@ -627,7 +637,11 @@ class TestStartInstanceLegacyFallback:
mock_get_container_id.return_value = "abc123"
mock_get_container_name.return_value = "test-container"
mock_connect_network.return_value = True
mock_wait_container.return_value = {"success": True, "status": "running", "waited_seconds": 0.5}
mock_wait_container.return_value = {
"success": True,
"status": "running",
"waited_seconds": 0.5,
}
instance = ToolInstance(
id=fake_instance_id,
@@ -730,7 +744,11 @@ class TestStartInstanceManifestBranch:
mock_get_container_id.return_value = "abc123"
mock_get_container_name.return_value = "test-container"
mock_connect_network.return_value = True
mock_wait_container.return_value = {"success": True, "status": "running", "waited_seconds": 0.5}
mock_wait_container.return_value = {
"success": True,
"status": "running",
"waited_seconds": 0.5,
}
mock_prepare_manifest.return_value = (
"headquarter/test:latest",
"services:\n app:\n image: test",
+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 */
@@ -0,0 +1,7 @@
name: terminal-fullscreen-unified-header
status: in-progress
phase: apply
type: bugfix
description: Fix terminal fullscreen mode where session switcher overlays terminal controls. Combine both elements into a single unified header visible at all times.
created_at: 2026-05-28
updated_at: 2026-05-28