diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 4be64c6..87d932a 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -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)) diff --git a/apps/api/tests/unit/test_home_path_expansion.py b/apps/api/tests/unit/test_home_path_expansion.py index a8ac4b6..9c09e39 100644 --- a/apps/api/tests/unit/test_home_path_expansion.py +++ b/apps/api/tests/unit/test_home_path_expansion.py @@ -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" diff --git a/apps/api/tests/unit/test_tool_instances_legacy.py b/apps/api/tests/unit/test_tool_instances_legacy.py index c662193..2e22e0e 100644 --- a/apps/api/tests/unit/test_tool_instances_legacy.py +++ b/apps/api/tests/unit/test_tool_instances_legacy.py @@ -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", diff --git a/apps/web/src/components/terminal.tsx b/apps/web/src/components/terminal.tsx index db427e1..5454b99 100644 --- a/apps/web/src/components/terminal.tsx +++ b/apps/web/src/components/terminal.tsx @@ -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( sessionId, onClose, isMobile = false, + showControls = true, activeModifier, onModifierChange, onTerminalReady, @@ -471,6 +474,11 @@ export const TerminalComponent = React.forwardRef( 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( return (
-
-
-
- - - {status === "resetting" - ? "Resetting..." - : reconnectAttemptsRef.current > 0 && status !== "connected" - ? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...` - : status} - + {showControls && ( +
+
+
+ + + {status === "resetting" + ? "Resetting..." + : reconnectAttemptsRef.current > 0 && status !== "connected" + ? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...` + : status} + +
+ {isMobile && ( + <> + + + + )}
- {isMobile && ( - <> - - - - )} -
-
- - - - {onClose && ( +
- )} + + + {onClose && ( + + )} +
-
+ )} {showResetConfirm && (
diff --git a/apps/web/src/pages/terminal.tsx b/apps/web/src/pages/terminal.tsx index 74ea37d..72e6e3d 100644 --- a/apps/web/src/pages/terminal.tsx +++ b/apps/web/src/pages/terminal.tsx @@ -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>>({}); const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile }); + // Track terminal status and callbacks for unified fullscreen header + const [terminalStatuses, setTerminalStatuses] = useState>({}); + 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 (
@@ -240,6 +270,7 @@ export const TerminalPage: React.FC = () => { sessionId={session.id} onClose={() => handleClose(session.id)} isMobile={true} + onTerminalReady={handleTerminalReady} />
))} @@ -275,15 +306,98 @@ export const TerminalPage: React.FC = () => {
)} - + {isFullscreen ? ( +
+
+ +
+
+ + + + + +
+ {showResetConfirm && ( +
+
+

+ Reset terminal? This will kill the current shell session and + start fresh. +

+
+ + +
+
+
+ )} +
+ ) : ( + + )}
{error &&
{error}
} {sessions @@ -296,6 +410,8 @@ export const TerminalPage: React.FC = () => { sessionId={session.id} onClose={() => handleClose(session.id)} isMobile={false} + showControls={!isFullscreen} + onTerminalReady={handleTerminalReady} />
))} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index dfedd03..22ce470 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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 */ diff --git a/openspec/changes/terminal-fullscreen-unified-header/.openspec.yaml b/openspec/changes/terminal-fullscreen-unified-header/.openspec.yaml new file mode 100644 index 0000000..314748b --- /dev/null +++ b/openspec/changes/terminal-fullscreen-unified-header/.openspec.yaml @@ -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