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:
2026-05-28 13:35:45 +02:00
parent 0b35ae3bf0
commit 62d1bdc462
26 changed files with 4589 additions and 775 deletions
+54 -54
View File
@@ -1,87 +1,87 @@
import { apiClient } from "./client";
export interface TerminalSession {
id: string;
name: string;
status: string;
has_websockets: boolean;
created_at: string;
last_activity_at: string | null;
id: string;
name: string;
status: string;
has_websockets: boolean;
created_at: string;
last_activity_at: string | null;
}
export interface TerminalSessionListResponse {
sessions: TerminalSession[];
sessions: TerminalSession[];
}
export interface TerminalSessionCreateRequest {
name?: string;
name?: string;
}
export interface TerminalSessionCreateResponse {
id: string;
name: string;
status: string;
created_at: string;
id: string;
name: string;
status: string;
created_at: string;
}
export async function listTerminalSessions(
projectId: string,
repoId: string,
instanceId: string
projectId: string,
repoId: string,
instanceId: string,
): Promise<TerminalSession[]> {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions`
);
return response.data.sessions;
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions`,
);
return response.data.sessions;
}
export async function createTerminalSession(
projectId: string,
repoId: string,
instanceId: string,
name?: string
projectId: string,
repoId: string,
instanceId: string,
name?: string,
): Promise<TerminalSessionCreateResponse> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions`,
{ name }
);
return response.data;
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions`,
{ name },
);
return response.data;
}
export async function closeTerminalSession(
projectId: string,
repoId: string,
instanceId: string,
sessionId: string
projectId: string,
repoId: string,
instanceId: string,
sessionId: string,
): Promise<{ status: string; session_id: string }> {
const response = await apiClient.delete(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions/${sessionId}`
);
return response.data;
const response = await apiClient.delete(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions/${sessionId}`,
);
return response.data;
}
export async function resetTerminalSession(
projectId: string,
repoId: string,
instanceId: string,
sessionId: string
projectId: string,
repoId: string,
instanceId: string,
sessionId: string,
): Promise<{ id: string; name: string; status: string }> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions/${sessionId}/reset`
);
return response.data;
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions/${sessionId}/reset`,
);
return response.data;
}
export async function renameTerminalSession(
projectId: string,
repoId: string,
instanceId: string,
sessionId: string,
name: string
projectId: string,
repoId: string,
instanceId: string,
sessionId: string,
name: string,
): Promise<{ id: string; name: string }> {
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions/${sessionId}/rename`,
{ name }
);
return response.data;
const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/terminal/sessions/${sessionId}/rename`,
{ name },
);
return response.data;
}
@@ -0,0 +1,161 @@
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
TerminalSessionTabs,
type TerminalSessionInfo,
} from "./terminal-session-tabs";
const mockSessions: TerminalSessionInfo[] = [
{ id: "s1", name: "Session 1", status: "connected" },
{ id: "s2", name: "Session 2", status: "connecting" },
{ id: "s3", name: "Session 3", status: "disconnected" },
];
afterEach(() => {
cleanup();
});
describe("TerminalSessionTabs", () => {
it("renders all tabs", () => {
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
expect(screen.getByText("Session 1")).toBeInTheDocument();
expect(screen.getByText("Session 2")).toBeInTheDocument();
expect(screen.getByText("Session 3")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /new session/i })).toBeInTheDocument();
});
it("clicking a tab calls onSelect", () => {
const onSelect = vi.fn();
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={onSelect}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
fireEvent.click(screen.getAllByText("Session 2")[0]);
expect(onSelect).toHaveBeenCalledWith("s2");
});
it("close button calls onClose after confirmation", () => {
const onClose = vi.fn();
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={onClose}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
const closeButton = screen.getByLabelText("Close session Session 1");
// First click shows confirm
fireEvent.click(closeButton);
expect(screen.getByText("Close?")).toBeInTheDocument();
// Click confirm text
fireEvent.click(screen.getByText("Close?"));
expect(onClose).toHaveBeenCalledWith("s1");
});
it("double-click enables rename and Enter commits", () => {
const onRename = vi.fn();
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={onRename}
/>
);
fireEvent.doubleClick(screen.getAllByText("Session 1")[0]);
const input = screen.getByLabelText("Rename session");
expect(input).toBeInTheDocument();
fireEvent.change(input, { target: { value: "Renamed" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onRename).toHaveBeenCalledWith("s1", "Renamed");
});
it("double-click enables rename and Escape cancels", () => {
const onRename = vi.fn();
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={onRename}
/>
);
fireEvent.doubleClick(screen.getAllByText("Session 1")[0]);
const input = screen.getByLabelText("Rename session");
fireEvent.change(input, { target: { value: "Renamed" } });
fireEvent.keyDown(input, { key: "Escape" });
expect(onRename).not.toHaveBeenCalled();
expect(screen.getByText("Session 1")).toBeInTheDocument();
});
it("plus button is disabled at 5 sessions", () => {
const fiveSessions: TerminalSessionInfo[] = Array.from({ length: 5 }, (_, i) => ({
id: `s${i + 1}`,
name: `Session ${i + 1}`,
status: "connected",
}));
render(
<TerminalSessionTabs
sessions={fiveSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
const newButton = screen.getByRole("button", { name: /new session/i });
expect(newButton).toBeDisabled();
});
it("status dot reflects connection state", () => {
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
const tabs = screen.getAllByRole("tab");
expect(tabs).toHaveLength(3);
expect(tabs[0].querySelector(".connected")).toBeInTheDocument();
expect(tabs[1].querySelector(".connecting")).toBeInTheDocument();
expect(tabs[2].querySelector(".disconnected")).toBeInTheDocument();
});
});
@@ -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>
);
};
File diff suppressed because it is too large Load Diff
+141 -128
View File
@@ -1,145 +1,158 @@
import { useCallback, useEffect, useState } from "react";
import {
listTerminalSessions,
createTerminalSession,
closeTerminalSession,
resetTerminalSession,
renameTerminalSession,
type TerminalSession,
listTerminalSessions,
createTerminalSession,
closeTerminalSession,
resetTerminalSession,
renameTerminalSession,
type TerminalSession,
} from "../api/terminal";
export interface UseTerminalSessionsResult {
sessions: TerminalSession[];
activeSessionId: string | null;
setActiveSessionId: (id: string) => void;
createSession: (name?: string) => Promise<TerminalSession | null>;
closeSession: (sessionId: string) => Promise<void>;
renameSession: (sessionId: string, name: string) => Promise<void>;
resetSession: (sessionId: string) => Promise<void>;
loading: boolean;
error: string | null;
sessions: TerminalSession[];
activeSessionId: string | null;
setActiveSessionId: (id: string) => void;
createSession: (name?: string) => Promise<TerminalSession | null>;
closeSession: (sessionId: string) => Promise<void>;
renameSession: (sessionId: string, name: string) => Promise<void>;
resetSession: (sessionId: string) => Promise<void>;
loading: boolean;
error: string | null;
}
export function useTerminalSessions(
projectId: string,
repoId: string,
instanceId: string
projectId: string,
repoId: string,
instanceId: string,
): UseTerminalSessionsResult {
const [sessions, setSessions] = useState<TerminalSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sessions, setSessions] = useState<TerminalSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadSessions = useCallback(async () => {
setLoading(true);
setError(null);
try {
const sess = await listTerminalSessions(projectId, repoId, instanceId);
setSessions(sess);
if (sess.length > 0 && !activeSessionId) {
setActiveSessionId(sess[0].id);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load sessions");
} finally {
setLoading(false);
}
}, [projectId, repoId, instanceId, activeSessionId]);
const loadSessions = useCallback(async () => {
setLoading(true);
setError(null);
try {
const sess = await listTerminalSessions(projectId, repoId, instanceId);
setSessions(sess);
if (sess.length > 0 && !activeSessionId) {
setActiveSessionId(sess[0].id);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load sessions");
} finally {
setLoading(false);
}
}, [projectId, repoId, instanceId, activeSessionId]);
const createSession = useCallback(
async (name?: string) => {
setError(null);
try {
const newSession = await createTerminalSession(
projectId,
repoId,
instanceId,
name
);
const session: TerminalSession = {
id: newSession.id,
name: newSession.name,
status: newSession.status,
has_websockets: false,
created_at: newSession.created_at,
last_activity_at: null,
};
setSessions((prev) => [...prev, session]);
setActiveSessionId(session.id);
return session;
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to create session";
setError(msg);
return null;
}
},
[projectId, repoId, instanceId]
);
const createSession = useCallback(
async (name?: string) => {
setError(null);
try {
const newSession = await createTerminalSession(
projectId,
repoId,
instanceId,
name,
);
const session: TerminalSession = {
id: newSession.id,
name: newSession.name,
status: newSession.status,
has_websockets: false,
created_at: newSession.created_at,
last_activity_at: null,
};
setSessions((prev) => [...prev, session]);
setActiveSessionId(session.id);
return session;
} catch (err) {
const msg =
err instanceof Error ? err.message : "Failed to create session";
setError(msg);
return null;
}
},
[projectId, repoId, instanceId],
);
const closeSession = useCallback(
async (sessionId: string) => {
setError(null);
try {
await closeTerminalSession(projectId, repoId, instanceId, sessionId);
setSessions((prev) => {
const filtered = prev.filter((s) => s.id !== sessionId);
if (activeSessionId === sessionId && filtered.length > 0) {
setActiveSessionId(filtered[0].id);
} else if (filtered.length === 0) {
setActiveSessionId(null);
}
return filtered;
});
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to close session");
}
},
[projectId, repoId, instanceId, activeSessionId]
);
const closeSession = useCallback(
async (sessionId: string) => {
setError(null);
try {
await closeTerminalSession(projectId, repoId, instanceId, sessionId);
setSessions((prev) => {
const filtered = prev.filter((s) => s.id !== sessionId);
if (activeSessionId === sessionId && filtered.length > 0) {
setActiveSessionId(filtered[0].id);
} else if (filtered.length === 0) {
setActiveSessionId(null);
}
return filtered;
});
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to close session",
);
}
},
[projectId, repoId, instanceId, activeSessionId],
);
const renameSession = useCallback(
async (sessionId: string, name: string) => {
setError(null);
try {
await renameTerminalSession(projectId, repoId, instanceId, sessionId, name);
setSessions((prev) =>
prev.map((s) => (s.id === sessionId ? { ...s, name } : s))
);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to rename session");
}
},
[projectId, repoId, instanceId]
);
const renameSession = useCallback(
async (sessionId: string, name: string) => {
setError(null);
try {
await renameTerminalSession(
projectId,
repoId,
instanceId,
sessionId,
name,
);
setSessions((prev) =>
prev.map((s) => (s.id === sessionId ? { ...s, name } : s)),
);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to rename session",
);
}
},
[projectId, repoId, instanceId],
);
const resetSession = useCallback(
async (sessionId: string) => {
setError(null);
try {
await resetTerminalSession(projectId, repoId, instanceId, sessionId);
// Refetch to get updated session info
await loadSessions();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to reset session");
}
},
[projectId, repoId, instanceId, loadSessions]
);
const resetSession = useCallback(
async (sessionId: string) => {
setError(null);
try {
await resetTerminalSession(projectId, repoId, instanceId, sessionId);
// Refetch to get updated session info
await loadSessions();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to reset session",
);
}
},
[projectId, repoId, instanceId, loadSessions],
);
// Initial load
useEffect(() => {
void loadSessions();
}, [loadSessions]);
// Initial load
useEffect(() => {
void loadSessions();
}, [loadSessions]);
return {
sessions,
activeSessionId,
setActiveSessionId,
createSession,
closeSession,
renameSession,
resetSession,
loading,
error,
};
return {
sessions,
activeSessionId,
setActiveSessionId,
createSession,
closeSession,
renameSession,
resetSession,
loading,
error,
};
}
+302 -42
View File
@@ -1,50 +1,310 @@
import React from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { TerminalComponent } from "../components/terminal";
import { MobileTerminalWrapper } from "../components/mobile-terminal-wrapper";
import { TerminalComponent, type TerminalRef } from "../components/terminal";
import {
TerminalSessionTabs,
type TerminalSessionInfo,
} from "../components/terminal-session-tabs";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { useTerminalSessions } from "../hooks/use-terminal-sessions";
import type { TerminalSession } from "../api/terminal";
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
sessions.map((s) => ({
id: s.id,
name: s.name,
status: s.status as TerminalSessionInfo["status"],
}));
export const TerminalPage: React.FC = () => {
const { instanceId } = useParams<{ instanceId: string }>();
const navigate = useNavigate();
const isMobile = useMobileViewport();
const { projectId, repoId, instanceId } = useParams<{
projectId: string;
repoId: string;
instanceId: string;
}>();
const navigate = useNavigate();
const isMobile = useMobileViewport();
const [isFullscreen, setIsFullscreen] = useState(false);
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
if (!instanceId) {
return (
<section className="stack">
<h1>Terminal</h1>
<p className="muted">No instance ID provided.</p>
</section>
);
}
const {
sessions,
activeSessionId,
setActiveSessionId,
createSession,
closeSession,
renameSession,
resetSession,
loading,
error,
} = useTerminalSessions(projectId ?? "", repoId ?? "", instanceId ?? "");
if (isMobile) {
return (
<MobileTerminalWrapper
instanceId={instanceId}
onBack={() => navigate(-1)}
onClose={() => navigate(-1)}
/>
);
}
// Auto-create default session if none exist
useEffect(() => {
if (!loading && sessions.length === 0 && !error && instanceId) {
void createSession("Session 1");
}
}, [loading, sessions.length, error, instanceId, createSession]);
return (
<section className="terminal-page">
<div className="terminal-page-header">
<button
className="secondary-button"
onClick={() => navigate(-1)}
type="button"
>
Back
</button>
<h1>Terminal</h1>
</div>
<TerminalComponent
instanceId={instanceId}
onClose={() => navigate(-1)}
isMobile={false}
/>
</section>
);
// Ensure refs map is kept in sync with sessions
useEffect(() => {
for (const session of sessions) {
if (!terminalRefs.current[session.id]) {
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
}
}
// Clean up refs for closed sessions
const currentIds = new Set(sessions.map((s) => s.id));
for (const id of Object.keys(terminalRefs.current)) {
if (!currentIds.has(id)) {
delete terminalRefs.current[id];
}
}
}, [sessions]);
// Fit active terminal when switching tabs
useEffect(() => {
if (activeSessionId && terminalRefs.current[activeSessionId]) {
const ref = terminalRefs.current[activeSessionId];
// Small delay to allow display:block to apply
const timer = setTimeout(() => {
ref.current?.fit();
}, 50);
return () => clearTimeout(timer);
}
}, [activeSessionId]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
if (!isAltShift) return;
switch (e.key.toLowerCase()) {
case "n":
e.preventDefault();
if (sessions.length < 5) {
void createSession(`Session ${sessions.length + 1}`);
}
break;
case "w":
e.preventDefault();
if (
activeSessionId &&
window.confirm("Close this terminal session?")
) {
void closeSession(activeSessionId);
}
break;
case "arrowleft":
e.preventDefault();
if (activeSessionId) {
const idx = sessions.findIndex((s) => s.id === activeSessionId);
if (idx > 0) {
setActiveSessionId(sessions[idx - 1].id);
}
}
break;
case "arrowright":
e.preventDefault();
if (activeSessionId) {
const idx = sessions.findIndex((s) => s.id === activeSessionId);
if (idx < sessions.length - 1) {
setActiveSessionId(sessions[idx + 1].id);
}
}
break;
case "r":
e.preventDefault();
if (activeSessionId) {
void resetSession(activeSessionId);
}
break;
case "f":
e.preventDefault();
setIsFullscreen((prev) => !prev);
break;
default:
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
sessions,
activeSessionId,
createSession,
closeSession,
resetSession,
setActiveSessionId,
]);
// Exit fullscreen on Escape
useEffect(() => {
if (!isFullscreen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsFullscreen(false);
}
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [isFullscreen]);
const handleSelect = useCallback(
(sessionId: string) => {
setActiveSessionId(sessionId);
},
[setActiveSessionId],
);
const handleClose = useCallback(
async (sessionId: string) => {
await closeSession(sessionId);
},
[closeSession],
);
const handleCreate = useCallback(() => {
void createSession(`Session ${sessions.length + 1}`);
}, [createSession, sessions.length]);
const handleRename = useCallback(
(sessionId: string, newName: string) => {
void renameSession(sessionId, newName);
},
[renameSession],
);
if (!instanceId) {
return (
<section className="stack">
<h1>Terminal</h1>
<p className="muted">No instance ID provided.</p>
</section>
);
}
const sessionInfos = SESSIONS_TO_INFO(sessions);
if (isMobile) {
return (
<section className={`terminal-page mobile ${isFullscreen ? "fullscreen" : ""}`}>
{!isFullscreen && (
<div className="terminal-page-header mobile-header">
<button className="secondary-button" onClick={() => navigate(-1)} type="button">
Back
</button>
<h1>Terminal</h1>
<button
className="secondary-button"
onClick={() => setIsFullscreen((p) => !p)}
type="button"
>
{isFullscreen ? "Exit" : "Fullscreen"}
</button>
</div>
)}
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId ?? ""}
onSelect={handleSelect}
onClose={handleClose}
onCreate={handleCreate}
onRename={handleRename}
isMobile={true}
/>
<div className="terminal-page-content">
{error && <div className="terminal-error-banner">{error}</div>}
{sessions.map((session) => (
<div
key={session.id}
className={`terminal-instance ${session.id === activeSessionId ? "active" : ""}`}
style={{
display: session.id === activeSessionId ? "flex" : "none",
}}
>
<TerminalComponent
ref={terminalRefs.current[session.id]}
instanceId={instanceId}
sessionId={session.id}
onClose={() => handleClose(session.id)}
isMobile={true}
/>
</div>
))}
{sessions.length === 0 && !loading && (
<div className="terminal-empty-state">
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
</div>
)}
</div>
</section>
);
}
return (
<section
className={`terminal-page ${isFullscreen ? "fullscreen" : ""}`}
>
{!isFullscreen && (
<div className="terminal-page-header">
<button
className="secondary-button"
onClick={() => navigate(-1)}
type="button"
>
Back
</button>
<h1>Terminal</h1>
<button
className="secondary-button"
onClick={() => setIsFullscreen((p) => !p)}
type="button"
title="Toggle fullscreen (Alt+Shift+F)"
>
{isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
</button>
</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.map((session) => (
<div
key={session.id}
className={`terminal-instance ${session.id === activeSessionId ? "active" : ""}`}
style={{
display:
session.id === activeSessionId ? "flex" : "none",
}}
>
<TerminalComponent
ref={terminalRefs.current[session.id]}
instanceId={instanceId}
sessionId={session.id}
onClose={() => handleClose(session.id)}
isMobile={false}
/>
</div>
))}
{sessions.length === 0 && !loading && (
<div className="terminal-empty-state">
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
</div>
)}
</div>
</section>
);
};
+248
View File
@@ -2663,6 +2663,254 @@ a.nav-item,
}
}
/* ============================================
Terminal Session Tabs
============================================ */
.terminal-session-tabs {
display: flex;
flex-shrink: 0;
background: #2d2d2d;
border-bottom: 1px solid #3e3e3e;
overflow: hidden;
}
.terminal-session-tabs.mobile {
background: #1e1e1e;
}
.terminal-session-tabs-scroll {
display: flex;
gap: 2px;
padding: var(--space-1) var(--space-2);
overflow-x: auto;
scrollbar-width: thin;
scrollbar-color: #555 transparent;
flex: 1;
min-width: 0;
}
.terminal-session-tabs-scroll::-webkit-scrollbar {
height: 4px;
}
.terminal-session-tabs-scroll::-webkit-scrollbar-thumb {
background: #555;
border-radius: 2px;
}
.terminal-session-tab {
display: flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: #888;
cursor: pointer;
font-size: 0.8125rem;
white-space: nowrap;
transition: background 0.15s, color 0.15s, border-color 0.15s;
user-select: none;
min-width: 0;
}
.terminal-session-tab:hover {
background: #3e3e3e;
color: #ccc;
}
.terminal-session-tab.active {
background: #1e1e1e;
color: #d4d4d4;
border-color: #3e3e3e;
}
.terminal-session-tab-status {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.terminal-session-tab-status.connecting {
background: #f5f543;
animation: pulse 1.5s infinite;
}
.terminal-session-tab-status.connected {
background: #0dbc79;
}
.terminal-session-tab-status.disconnected,
.terminal-session-tab-status.error {
background: #cd3131;
}
.terminal-session-tab-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.terminal-session-tab-input {
background: #1e1e1e;
border: 1px solid var(--brand);
border-radius: 4px;
color: #d4d4d4;
font-size: 0.8125rem;
padding: 1px 4px;
width: 100px;
outline: none;
}
.terminal-session-tab-close {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
margin: 0;
background: transparent;
border: none;
color: #888;
cursor: pointer;
font-size: 0.875rem;
line-height: 1;
border-radius: 3px;
opacity: 0;
transition: opacity 0.15s, background 0.15s;
}
.terminal-session-tab:hover .terminal-session-tab-close {
opacity: 1;
}
.terminal-session-tab-close:hover {
background: #cd3131;
color: white;
}
.terminal-session-tab-confirm {
font-size: 0.6875rem;
color: #cd3131;
padding: 1px 4px;
border-radius: 3px;
background: rgba(205, 49, 49, 0.15);
white-space: nowrap;
}
.terminal-session-tab.new-session {
font-weight: 600;
font-size: 1rem;
line-height: 1;
padding: var(--space-1) var(--space-2);
color: #888;
}
.terminal-session-tab.new-session:hover {
background: #3e3e3e;
color: #d4d4d4;
}
.terminal-session-tab.new-session:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Terminal page with multi-session */
.terminal-page-content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
background: #1e1e1e;
}
.terminal-instance {
flex: 1;
min-height: 0;
display: none;
}
.terminal-instance.active {
display: flex;
}
.terminal-empty-state {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: #888;
font-size: 0.875rem;
}
.terminal-error-banner {
padding: var(--space-2) var(--space-4);
background: var(--danger-light);
color: var(--danger);
font-size: 0.875rem;
border-bottom: 1px solid var(--danger-light);
}
/* Fullscreen mode */
.terminal-page.fullscreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
padding: 0;
gap: 0;
background: #1e1e1e;
}
.terminal-page.fullscreen .terminal-page-content {
border: none;
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;
}
.terminal-page.fullscreen .terminal-session-tabs:hover {
opacity: 1;
}
/* Mobile fullscreen */
@media (max-width: 767px) {
.terminal-page.fullscreen {
padding: 0;
}
.terminal-page.mobile .terminal-page-header {
padding: var(--space-2);
gap: var(--space-2);
}
.terminal-page.mobile .terminal-page-header h1 {
font-size: 1rem;
}
.terminal-session-tab-name {
max-width: 80px;
}
}
/* ============================================
Sessions Page Styles
============================================ */