Merge branch 'feat/session-branch-selection' into dev
Resolved conflicts: - Moved branch selection UI from inline sessions.tsx to CreateSessionForm component - Integrated branch dropdown and new branch creation into CreateSessionForm - Removed duplicate branch state management from sessions.tsx All branch selection tests pass (7/7).
This commit is contained in:
@@ -21,7 +21,6 @@ export interface ToolType {
|
||||
build_context: Record<string, string> | null;
|
||||
readiness_probe: ReadinessProbe | null;
|
||||
required_variables: string[];
|
||||
is_builtin: boolean;
|
||||
created_by_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Link, NavLink, Outlet } from "react-router-dom";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
|
||||
|
||||
import { getUserSessions } from "../api/sessions";
|
||||
import type { Session } from "../api/sessions";
|
||||
import { useTheme } from "../hooks/use-theme";
|
||||
import { useAuth } from "../state/auth";
|
||||
import { useSessions } from "../state/sessions";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
import { Icon } from "./icon";
|
||||
import type { IconName } from "../utils/icons";
|
||||
|
||||
@@ -39,6 +40,11 @@ export const AppShell = () => {
|
||||
useTheme();
|
||||
const { user, logout } = useAuth();
|
||||
const { sessions, setAllSessions } = useSessions();
|
||||
const location = useLocation();
|
||||
const isMobile = useMobileViewport();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
const isMobileTerminal = isMobile && location.pathname.includes("/instances/") && location.pathname.includes("/terminal");
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
@@ -58,6 +64,19 @@ export const AppShell = () => {
|
||||
return () => clearInterval(interval);
|
||||
}, [loadSessions]);
|
||||
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => {
|
||||
setMobileMenuOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
if (isMobileTerminal) {
|
||||
return (
|
||||
<div className="shell mobile-terminal-shell">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="shell-header">
|
||||
@@ -82,7 +101,17 @@ export const AppShell = () => {
|
||||
</header>
|
||||
|
||||
<div className="shell-body">
|
||||
<aside className="shell-nav" aria-label="Primary navigation">
|
||||
<aside className={`shell-nav ${mobileMenuOpen ? "mobile-open" : ""}`} aria-label="Primary navigation">
|
||||
{isMobile && (
|
||||
<button
|
||||
className="mobile-menu-close"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<Icon name="close" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const activeCount = sessions.filter((s) => s.status === "running").length;
|
||||
return (
|
||||
@@ -112,6 +141,13 @@ export const AppShell = () => {
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{isMobile && mobileMenuOpen && (
|
||||
<div
|
||||
className="mobile-menu-overlay"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<main className="shell-content">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Icon } from "./icon";
|
||||
import { createInstance, startInstance, type ToolInstance } from "../api/sessions";
|
||||
import type { Project } from "../types";
|
||||
import { listRepositoryBranches, type GitRepository, type Branch } from "../api/git_repositories";
|
||||
import type { ToolType } from "../api/tool_types";
|
||||
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||
|
||||
interface CreateSessionFormProps {
|
||||
projects: Project[];
|
||||
repositories: GitRepository[];
|
||||
toolTypes: ToolType[];
|
||||
fixedProjectId?: string;
|
||||
fixedRepoId?: string;
|
||||
projectName?: string;
|
||||
repoName?: string;
|
||||
showCloneMode?: boolean;
|
||||
showFixedFields?: boolean;
|
||||
onProjectChange?: (projectId: string) => void;
|
||||
onSuccess?: (instance: ToolInstance) => void;
|
||||
onCancel?: () => void;
|
||||
submitLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CreateSessionForm = ({
|
||||
projects,
|
||||
repositories,
|
||||
toolTypes,
|
||||
fixedProjectId,
|
||||
fixedRepoId,
|
||||
projectName,
|
||||
repoName,
|
||||
showCloneMode = true,
|
||||
showFixedFields = true,
|
||||
onProjectChange,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
submitLabel = "Create Session",
|
||||
className = "",
|
||||
}: CreateSessionFormProps) => {
|
||||
const [selectedProject, setSelectedProject] = useState(fixedProjectId || "");
|
||||
const [selectedRepo, setSelectedRepo] = useState(fixedRepoId || "");
|
||||
const [selectedToolType, setSelectedToolType] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount");
|
||||
const [branch, setBranch] = useState("main");
|
||||
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||
|
||||
const [branches, setBranches] = useState<Branch[]>([]);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [isCreatingNewBranch, setIsCreatingNewBranch] = useState(false);
|
||||
const [newBranchName, setNewBranchName] = useState("");
|
||||
const [baseBranch, setBaseBranch] = useState("");
|
||||
|
||||
const [status, setStatus] = useState<"idle" | "creating" | "error">("idle");
|
||||
const [progress, setProgress] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load SSH keys when clone mode is shown
|
||||
useEffect(() => {
|
||||
if (!showCloneMode) return;
|
||||
const loadKeys = async () => {
|
||||
try {
|
||||
const keys = await listSSHKeys();
|
||||
setSshKeys(keys);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void loadKeys();
|
||||
}, [showCloneMode]);
|
||||
|
||||
// Load branches when selected repo changes
|
||||
useEffect(() => {
|
||||
if (!selectedRepo || !showCloneMode) {
|
||||
setBranches([]);
|
||||
return;
|
||||
}
|
||||
const loadBranches = async () => {
|
||||
setIsLoadingBranches(true);
|
||||
try {
|
||||
const branchList = await listRepositoryBranches(selectedRepo);
|
||||
setBranches(branchList);
|
||||
const defaultBranch = branchList.find((b) => b.is_default);
|
||||
if (defaultBranch) {
|
||||
setBranch(defaultBranch.name);
|
||||
setBaseBranch(defaultBranch.name);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
};
|
||||
void loadBranches();
|
||||
}, [selectedRepo, showCloneMode]);
|
||||
|
||||
// Filter repositories by selected project
|
||||
const availableRepos = selectedProject
|
||||
? repositories.filter((r) => r.project_id === selectedProject)
|
||||
: [];
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
const projectId = fixedProjectId || selectedProject;
|
||||
const repoId = fixedRepoId || selectedRepo;
|
||||
|
||||
if (!projectId || !repoId || !selectedToolType) {
|
||||
setError("Project, repository, and tool type are required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (showCloneMode && cloneMode === "clone") {
|
||||
const repo = repositories.find((r) => r.id === repoId);
|
||||
if (!repo?.ssh_key_id) {
|
||||
setError("Repository must have an SSH key assigned for clone mode");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setStatus("creating");
|
||||
setProgress("Creating instance...");
|
||||
|
||||
try {
|
||||
const instance = await createInstance(
|
||||
projectId,
|
||||
repoId,
|
||||
selectedToolType,
|
||||
displayName || undefined,
|
||||
showCloneMode ? cloneMode : undefined,
|
||||
showCloneMode && cloneMode === "clone"
|
||||
? isCreatingNewBranch
|
||||
? baseBranch
|
||||
: branch
|
||||
: undefined,
|
||||
showCloneMode && cloneMode === "clone" && isCreatingNewBranch
|
||||
? newBranchName
|
||||
: undefined
|
||||
);
|
||||
|
||||
setProgress("Starting container...");
|
||||
await startInstance(projectId, repoId, instance.id);
|
||||
|
||||
// Reset form
|
||||
if (!fixedProjectId) setSelectedProject("");
|
||||
if (!fixedRepoId) setSelectedRepo("");
|
||||
setSelectedToolType("");
|
||||
setDisplayName("");
|
||||
setCloneMode("mount");
|
||||
setBranch("main");
|
||||
setIsCreatingNewBranch(false);
|
||||
setNewBranchName("");
|
||||
setBaseBranch("");
|
||||
setBranches([]);
|
||||
setStatus("idle");
|
||||
|
||||
onSuccess?.(instance);
|
||||
} catch {
|
||||
setStatus("error");
|
||||
setError("Failed to create session");
|
||||
setProgress("");
|
||||
}
|
||||
};
|
||||
|
||||
const isSubmitting = status === "creating";
|
||||
|
||||
return (
|
||||
<div className={`create-session-form-wrapper ${className}`}>
|
||||
{isSubmitting && (
|
||||
<div className="loading-overlay">
|
||||
<div className="loading-content">
|
||||
<Icon name="loading" size="lg" />
|
||||
<p>{progress || "Creating session..."}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="stack create-session-form">
|
||||
<div className="form-row">
|
||||
{fixedProjectId && showFixedFields ? (
|
||||
<label className="form-field">
|
||||
Project
|
||||
<input
|
||||
type="text"
|
||||
value={projectName || projects.find((p) => p.id === fixedProjectId)?.name || ""}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<label className="form-field">
|
||||
Project
|
||||
<select
|
||||
value={selectedProject}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSelectedProject(value);
|
||||
setSelectedRepo("");
|
||||
onProjectChange?.(value);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<option value="">Select project...</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{fixedRepoId && showFixedFields ? (
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<input
|
||||
type="text"
|
||||
value={repoName || repositories.find((r) => r.id === fixedRepoId)?.name || ""}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<select
|
||||
value={selectedRepo}
|
||||
onChange={(e) => setSelectedRepo(e.target.value)}
|
||||
disabled={!selectedProject || isSubmitting}
|
||||
>
|
||||
<option value="">Select repository...</option>
|
||||
{availableRepos.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="form-field">
|
||||
Tool Type
|
||||
<select
|
||||
value={selectedToolType}
|
||||
onChange={(e) => setSelectedToolType(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<option value="">Select tool...</option>
|
||||
{toolTypes.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{showCloneMode && (
|
||||
<div className="form-row">
|
||||
<label className="form-field">
|
||||
Repository Access
|
||||
<div className="radio-group">
|
||||
<label className="radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="cloneMode"
|
||||
value="mount"
|
||||
checked={cloneMode === "mount"}
|
||||
onChange={(e) => setCloneMode(e.target.value as "mount" | "clone")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
Mount (live sync)
|
||||
</label>
|
||||
<label className="radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="cloneMode"
|
||||
value="clone"
|
||||
checked={cloneMode === "clone"}
|
||||
onChange={(e) => setCloneMode(e.target.value as "mount" | "clone")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
Clone fresh copy
|
||||
</label>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{cloneMode === "clone" && (
|
||||
<>
|
||||
<label className="form-field">
|
||||
Branch
|
||||
{isLoadingBranches ? (
|
||||
<span className="muted">Loading branches...</span>
|
||||
) : (
|
||||
<select
|
||||
value={isCreatingNewBranch ? "__new__" : branch}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "__new__") {
|
||||
setIsCreatingNewBranch(true);
|
||||
setNewBranchName("");
|
||||
} else {
|
||||
setIsCreatingNewBranch(false);
|
||||
setBranch(value);
|
||||
setBaseBranch(value);
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b.name} value={b.name}>
|
||||
{b.name} {b.is_default ? "(default)" : ""}
|
||||
</option>
|
||||
))}
|
||||
<option value="__new__">Create new branch...</option>
|
||||
</select>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{isCreatingNewBranch && (
|
||||
<>
|
||||
<label className="form-field">
|
||||
New Branch Name
|
||||
<input
|
||||
type="text"
|
||||
value={newBranchName}
|
||||
onChange={(e) => setNewBranchName(e.target.value)}
|
||||
placeholder="feature/my-new-branch"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Base Branch
|
||||
<select
|
||||
value={baseBranch}
|
||||
onChange={(e) => setBaseBranch(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b.name} value={b.name}>
|
||||
{b.name} {b.is_default ? "(default)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedRepo && (
|
||||
<div className="form-field ssh-key-info">
|
||||
{(() => {
|
||||
const repo = repositories.find((r) => r.id === selectedRepo);
|
||||
if (!repo) return null;
|
||||
if (repo.ssh_key_id) {
|
||||
const key = sshKeys.find((k) => k.id === repo.ssh_key_id);
|
||||
return (
|
||||
<span className="success-text">
|
||||
SSH key: {key?.name || "Assigned"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="warning-text">
|
||||
No SSH key assigned to this repository. Clone mode requires an SSH key.
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="form-field">
|
||||
Display Name (optional)
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="My Development Environment"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
|
||||
<div className="form-actions">
|
||||
{onCancel && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="primary-button"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="add" size="sm" />
|
||||
{submitLabel}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,6 @@ import { Icon } from "./icon";
|
||||
import type { ToolInstance } from "../api/sessions";
|
||||
import {
|
||||
checkInstanceHealth,
|
||||
createInstance,
|
||||
deleteInstance,
|
||||
listInstances,
|
||||
recreateInstanceTunnel,
|
||||
@@ -13,22 +12,23 @@ import {
|
||||
stopInstance,
|
||||
} from "../api/sessions";
|
||||
import type { ToolType } from "../api/tool_types";
|
||||
import { CreateSessionForm } from "./create-session-form";
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||
|
||||
interface InstanceListProps {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
projectName?: string;
|
||||
repoName?: string;
|
||||
toolTypes: ToolType[];
|
||||
}
|
||||
|
||||
export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps) => {
|
||||
export const InstanceList = ({ projectId, repoId, projectName, repoName, toolTypes }: InstanceListProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [instances, setInstances] = useState<ToolInstance[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [selectedToolType, setSelectedToolType] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Stop confirmation
|
||||
@@ -83,18 +83,9 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
||||
return () => clearInterval(interval);
|
||||
}, [instances, projectId, repoId]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!selectedToolType) return;
|
||||
setError(null);
|
||||
try {
|
||||
await createInstance(projectId, repoId, selectedToolType, displayName || undefined);
|
||||
setShowCreate(false);
|
||||
setSelectedToolType("");
|
||||
setDisplayName("");
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to create instance");
|
||||
}
|
||||
const handleCreateSuccess = async () => {
|
||||
setShowCreate(false);
|
||||
await loadInstances();
|
||||
};
|
||||
|
||||
const handleStart = async (instanceId: string) => {
|
||||
@@ -309,48 +300,18 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h2>Launch Tool</h2>
|
||||
<div className="stack">
|
||||
<label className="form-field">
|
||||
Tool Type
|
||||
<select
|
||||
value={selectedToolType}
|
||||
onChange={(e) => setSelectedToolType(e.target.value)}
|
||||
>
|
||||
<option value="">Select a tool...</option>
|
||||
{toolTypes.map((tool) => (
|
||||
<option key={tool.id} value={tool.id}>
|
||||
{tool.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Display Name (optional)
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="My Development Environment"
|
||||
/>
|
||||
</label>
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => setShowCreate(false)}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={!selectedToolType}
|
||||
type="button"
|
||||
>
|
||||
Launch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<CreateSessionForm
|
||||
projects={[]}
|
||||
repositories={[]}
|
||||
toolTypes={toolTypes}
|
||||
fixedProjectId={projectId}
|
||||
fixedRepoId={repoId}
|
||||
projectName={projectName}
|
||||
repoName={repoName}
|
||||
onSuccess={handleCreateSuccess}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
submitLabel="Launch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from "react";
|
||||
import { Icon } from "./icon";
|
||||
|
||||
interface MobileTerminalHeaderProps {
|
||||
instanceName?: string;
|
||||
onBack?: () => void;
|
||||
onMenuToggle?: () => void;
|
||||
onClose?: () => void;
|
||||
isVisible: boolean;
|
||||
connectionStatus?: "connecting" | "connected" | "disconnected" | "error";
|
||||
}
|
||||
|
||||
export const MobileTerminalHeader: React.FC<MobileTerminalHeaderProps> = ({
|
||||
instanceName,
|
||||
onBack,
|
||||
onMenuToggle,
|
||||
onClose,
|
||||
isVisible,
|
||||
connectionStatus = "connecting",
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`mobile-terminal-header ${isVisible ? "visible" : "hidden"}`}
|
||||
>
|
||||
<div className="mobile-terminal-header-left">
|
||||
{onBack && (
|
||||
<button
|
||||
className="mobile-terminal-header-button"
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<Icon name="arrow-left" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
{onMenuToggle && (
|
||||
<button
|
||||
className="mobile-terminal-header-button"
|
||||
onClick={onMenuToggle}
|
||||
type="button"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<Icon name="menu" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mobile-terminal-header-center">
|
||||
<span className="mobile-terminal-header-title">
|
||||
{instanceName || "Terminal"}
|
||||
</span>
|
||||
<span
|
||||
className={`mobile-terminal-header-status ${connectionStatus}`}
|
||||
aria-label={`Connection status: ${connectionStatus}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mobile-terminal-header-right">
|
||||
{onClose && (
|
||||
<button
|
||||
className="mobile-terminal-header-button"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
aria-label="Close terminal"
|
||||
>
|
||||
<Icon name="close" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { TerminalComponent } from "./terminal";
|
||||
import { MobileTerminalHeader } from "./mobile-terminal-header";
|
||||
import { SpecialKeysStrip } from "./special-keys-strip";
|
||||
import { SpecialKeysPanel } from "./special-keys-panel";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard";
|
||||
import { useAutoHide } from "../hooks/use-auto-hide";
|
||||
|
||||
interface MobileTerminalWrapperProps {
|
||||
instanceId: string;
|
||||
instanceName?: string;
|
||||
onClose?: () => void;
|
||||
onBack?: () => void;
|
||||
onMenuToggle?: () => void;
|
||||
}
|
||||
|
||||
export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
|
||||
instanceId,
|
||||
instanceName,
|
||||
onClose,
|
||||
onBack,
|
||||
onMenuToggle,
|
||||
}) => {
|
||||
const isMobile = useMobileViewport();
|
||||
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
|
||||
useVirtualKeyboard();
|
||||
const [showPanel, setShowPanel] = useState(false);
|
||||
const [terminalRef, setTerminalRef] = useState<{
|
||||
sendData: (data: string) => void;
|
||||
connectionStatus: "connecting" | "connected" | "disconnected" | "error";
|
||||
focusInput: () => void;
|
||||
} | null>(null);
|
||||
|
||||
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
|
||||
const keysAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
|
||||
|
||||
const handleTerminalTap = useCallback(() => {
|
||||
headerAutoHide.toggle();
|
||||
keysAutoHide.toggle();
|
||||
}, [headerAutoHide, keysAutoHide]);
|
||||
|
||||
const handleTerminalReady = useCallback(
|
||||
(sendData: (data: string) => void, connectionStatus: "connecting" | "connected" | "disconnected" | "error", focusInput: () => void) => {
|
||||
setTerminalRef({ sendData, connectionStatus, focusInput });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSendKey = useCallback(
|
||||
(data: string) => {
|
||||
terminalRef?.sendData(data);
|
||||
},
|
||||
[terminalRef]
|
||||
);
|
||||
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<TerminalComponent
|
||||
instanceId={instanceId}
|
||||
onClose={onClose}
|
||||
isMobile={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mobile-terminal-wrapper">
|
||||
<MobileTerminalHeader
|
||||
instanceName={instanceName}
|
||||
onBack={onBack}
|
||||
onMenuToggle={onMenuToggle}
|
||||
onClose={onClose}
|
||||
isVisible={headerAutoHide.isVisible}
|
||||
connectionStatus={terminalRef?.connectionStatus}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="mobile-terminal-content"
|
||||
style={{
|
||||
paddingBottom: isKeyboardOpen ? keyboardHeight : 0,
|
||||
}}
|
||||
onClick={handleTerminalTap}
|
||||
>
|
||||
<TerminalComponent
|
||||
instanceId={instanceId}
|
||||
onClose={onClose}
|
||||
isMobile={true}
|
||||
onTerminalReady={handleTerminalReady}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SpecialKeysStrip
|
||||
onSend={handleSendKey}
|
||||
isVisible={keysAutoHide.isVisible && !showPanel}
|
||||
onMoreClick={() => setShowPanel(true)}
|
||||
onKeepFocus={() => terminalRef?.focusInput()}
|
||||
/>
|
||||
|
||||
<SpecialKeysPanel
|
||||
onSend={handleSendKey}
|
||||
isOpen={showPanel}
|
||||
onClose={() => setShowPanel(false)}
|
||||
onKeepFocus={() => terminalRef?.focusInput()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from "react";
|
||||
import { useSpecialKeys, type SpecialKey } from "../hooks/use-special-keys";
|
||||
|
||||
interface SpecialKeysPanelProps {
|
||||
onSend: (data: string) => void;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onKeepFocus?: () => void;
|
||||
}
|
||||
|
||||
const EXPANDED_KEYS: { key: SpecialKey; label: string }[] = [
|
||||
{ key: "home", label: "Home" },
|
||||
{ key: "end", label: "End" },
|
||||
{ key: "pageup", label: "PgUp" },
|
||||
{ key: "pagedown", label: "PgDn" },
|
||||
{ key: "ctrlc", label: "Ctrl+C" },
|
||||
{ key: "ctrld", label: "Ctrl+D" },
|
||||
{ key: "ctrlz", label: "Ctrl+Z" },
|
||||
];
|
||||
|
||||
const F_KEYS: { key: SpecialKey; label: string }[] = [
|
||||
{ key: "f1", label: "F1" },
|
||||
{ key: "f2", label: "F2" },
|
||||
{ key: "f3", label: "F3" },
|
||||
{ key: "f4", label: "F4" },
|
||||
{ key: "f5", label: "F5" },
|
||||
{ key: "f6", label: "F6" },
|
||||
{ key: "f7", label: "F7" },
|
||||
{ key: "f8", label: "F8" },
|
||||
{ key: "f9", label: "F9" },
|
||||
{ key: "f10", label: "F10" },
|
||||
{ key: "f11", label: "F11" },
|
||||
{ key: "f12", label: "F12" },
|
||||
];
|
||||
|
||||
export const SpecialKeysPanel: React.FC<SpecialKeysPanelProps> = ({
|
||||
onSend,
|
||||
isOpen,
|
||||
onClose,
|
||||
onKeepFocus,
|
||||
}) => {
|
||||
const { sendKey } = useSpecialKeys({ onSend });
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => {
|
||||
e.preventDefault();
|
||||
sendKey(key);
|
||||
onClose();
|
||||
onKeepFocus?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="special-keys-panel-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="special-keys-panel"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="special-keys-panel-section">
|
||||
{EXPANDED_KEYS.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
className="special-key-button"
|
||||
onPointerDown={(e) => handlePointerDown(e, key)}
|
||||
type="button"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="special-keys-panel-divider" />
|
||||
<div className="special-keys-panel-section">
|
||||
{F_KEYS.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
className="special-key-button"
|
||||
onPointerDown={(e) => handlePointerDown(e, key)}
|
||||
type="button"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from "react";
|
||||
import { useSpecialKeys, type SpecialKey } from "../hooks/use-special-keys";
|
||||
|
||||
interface SpecialKeysStripProps {
|
||||
onSend: (data: string) => void;
|
||||
isVisible: boolean;
|
||||
onMoreClick?: () => void;
|
||||
onKeepFocus?: () => void;
|
||||
}
|
||||
|
||||
const PRIMARY_KEYS: { key: SpecialKey; label: string }[] = [
|
||||
{ key: "escape", label: "Esc" },
|
||||
{ key: "tab", label: "Tab" },
|
||||
{ key: "ctrl", label: "Ctrl" },
|
||||
{ key: "alt", label: "Alt" },
|
||||
{ key: "up", label: "↑" },
|
||||
{ key: "down", label: "↓" },
|
||||
{ key: "left", label: "←" },
|
||||
{ key: "right", label: "→" },
|
||||
];
|
||||
|
||||
export const SpecialKeysStrip: React.FC<SpecialKeysStripProps> = ({
|
||||
onSend,
|
||||
isVisible,
|
||||
onMoreClick,
|
||||
onKeepFocus,
|
||||
}) => {
|
||||
const { sendKey } = useSpecialKeys({ onSend });
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => {
|
||||
e.preventDefault();
|
||||
sendKey(key);
|
||||
onKeepFocus?.();
|
||||
};
|
||||
|
||||
const handleMorePointerDown = (e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
onMoreClick?.();
|
||||
onKeepFocus?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`special-keys-strip ${isVisible ? "visible" : "hidden"}`}>
|
||||
{PRIMARY_KEYS.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
className="special-key-button"
|
||||
onPointerDown={(e) => handlePointerDown(e, key)}
|
||||
type="button"
|
||||
aria-label={`Send ${label}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{onMoreClick && (
|
||||
<button
|
||||
className="special-key-button special-key-more"
|
||||
onPointerDown={handleMorePointerDown}
|
||||
type="button"
|
||||
aria-label="More special keys"
|
||||
>
|
||||
More
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { Terminal } from "xterm";
|
||||
import { FitAddon } from "xterm-addon-fit";
|
||||
import { WebLinksAddon } from "xterm-addon-web-links";
|
||||
@@ -7,23 +7,120 @@ import "xterm/css/xterm.css";
|
||||
interface TerminalProps {
|
||||
instanceId: string;
|
||||
onClose?: () => void;
|
||||
isMobile?: boolean;
|
||||
onTerminalReady?: (
|
||||
sendData: (data: string) => void,
|
||||
connectionStatus: "connecting" | "connected" | "disconnected" | "error",
|
||||
focusInput: () => void
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose }) => {
|
||||
const FONT_SIZE_KEY = "terminal-font-size";
|
||||
const MIN_FONT_SIZE = 16;
|
||||
const MAX_FONT_SIZE = 24;
|
||||
const RECONNECT_ATTEMPTS = 3;
|
||||
const RECONNECT_DELAY_BASE = 1000;
|
||||
|
||||
export const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
instanceId,
|
||||
onClose,
|
||||
isMobile = false,
|
||||
onTerminalReady,
|
||||
}) => {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const hiddenInputRef = useRef<HTMLInputElement>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const [status, setStatus] = useState<"connecting" | "connected" | "disconnected" | "error">(
|
||||
"connecting",
|
||||
);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const onTerminalReadyRef = useRef(onTerminalReady);
|
||||
onTerminalReadyRef.current = onTerminalReady;
|
||||
const [status, setStatus] = useState<
|
||||
"connecting" | "connected" | "disconnected" | "error"
|
||||
>("connecting");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fontSize, setFontSize] = useState(() => {
|
||||
if (typeof window === "undefined") return isMobile ? 16 : 14;
|
||||
const stored = localStorage.getItem(FONT_SIZE_KEY);
|
||||
return stored ? parseInt(stored, 10) : isMobile ? 16 : 14;
|
||||
});
|
||||
|
||||
const calculateFontSize = useCallback(() => {
|
||||
if (!isMobile) return fontSize;
|
||||
const vw = window.innerWidth;
|
||||
const calculated = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, vw / 25));
|
||||
return Math.round(calculated);
|
||||
}, [isMobile, fontSize]);
|
||||
|
||||
const connectWebSocket = useCallback(() => {
|
||||
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setStatus("connected");
|
||||
setError(null);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (!termRef.current) return;
|
||||
|
||||
if (event.data instanceof Blob) {
|
||||
event.data.arrayBuffer().then((buffer) => {
|
||||
const data = new Uint8Array(buffer);
|
||||
termRef.current?.write(data);
|
||||
});
|
||||
} else if (typeof event.data === "string") {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "status" && msg.status === "connected") {
|
||||
setStatus("connected");
|
||||
}
|
||||
} catch {
|
||||
termRef.current?.write(event.data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
setStatus("disconnected");
|
||||
if (event.code !== 1000) {
|
||||
setError(`Connection closed (code: ${event.code})`);
|
||||
|
||||
// Attempt reconnection
|
||||
if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) {
|
||||
reconnectAttemptsRef.current++;
|
||||
const delay = RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttemptsRef.current - 1);
|
||||
setTimeout(() => {
|
||||
if (document.visibilityState !== "hidden") {
|
||||
connectWebSocket();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setStatus("error");
|
||||
setError("WebSocket error");
|
||||
};
|
||||
|
||||
return ws;
|
||||
}, [instanceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminalRef.current) return;
|
||||
|
||||
// Initialize terminal
|
||||
const currentFontSize = calculateFontSize();
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontSize: currentFontSize,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: "#1e1e1e",
|
||||
@@ -49,57 +146,18 @@ export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose
|
||||
},
|
||||
});
|
||||
|
||||
termRef.current = term;
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
fitAddonRef.current = fitAddon;
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
|
||||
term.open(terminalRef.current);
|
||||
fitAddon.fit();
|
||||
|
||||
// Build WebSocket URL
|
||||
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
|
||||
|
||||
// Connect WebSocket
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setStatus("connected");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (event.data instanceof Blob) {
|
||||
event.data.arrayBuffer().then((buffer) => {
|
||||
const data = new Uint8Array(buffer);
|
||||
term.write(data);
|
||||
});
|
||||
} else if (typeof event.data === "string") {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "status" && msg.status === "connected") {
|
||||
setStatus("connected");
|
||||
}
|
||||
} catch {
|
||||
term.write(event.data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
setStatus("disconnected");
|
||||
if (event.code !== 1000) {
|
||||
setError(`Connection closed (code: ${event.code})`);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setStatus("error");
|
||||
setError("WebSocket error");
|
||||
};
|
||||
const ws = connectWebSocket();
|
||||
|
||||
// Handle terminal input
|
||||
term.onData((data) => {
|
||||
@@ -108,19 +166,23 @@ export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose
|
||||
}
|
||||
});
|
||||
|
||||
// Handle resize
|
||||
// Handle resize with debounce
|
||||
let resizeTimeout: ReturnType<typeof setTimeout>;
|
||||
const handleResize = () => {
|
||||
fitAddon.fit();
|
||||
const { cols, rows } = term;
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
cols,
|
||||
rows,
|
||||
}),
|
||||
);
|
||||
}
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
const { cols, rows } = term;
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
cols,
|
||||
rows,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, 250);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
@@ -128,31 +190,192 @@ export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose
|
||||
// Initial resize
|
||||
setTimeout(handleResize, 100);
|
||||
|
||||
// Notify parent about terminal readiness
|
||||
if (onTerminalReadyRef.current) {
|
||||
const sendData = (data: string) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
};
|
||||
const focusInput = () => {
|
||||
hiddenInputRef.current?.focus();
|
||||
};
|
||||
onTerminalReadyRef.current(sendData, status, focusInput);
|
||||
}
|
||||
|
||||
// Visibility API for reconnection
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible" && ws.readyState !== WebSocket.OPEN) {
|
||||
reconnectAttemptsRef.current = 0;
|
||||
connectWebSocket();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
ws.close();
|
||||
term.dispose();
|
||||
};
|
||||
}, [instanceId]);
|
||||
}, [instanceId, connectWebSocket, calculateFontSize]);
|
||||
|
||||
// Update parent about status changes
|
||||
useEffect(() => {
|
||||
if (onTerminalReady && termRef.current) {
|
||||
const sendData = (data: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(data);
|
||||
}
|
||||
};
|
||||
const focusInput = () => {
|
||||
hiddenInputRef.current?.focus();
|
||||
};
|
||||
onTerminalReady(sendData, status, focusInput);
|
||||
}
|
||||
}, [status, onTerminalReady]);
|
||||
|
||||
const handleFontSizeChange = (delta: number) => {
|
||||
const newSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, fontSize + delta));
|
||||
setFontSize(newSize);
|
||||
localStorage.setItem(FONT_SIZE_KEY, newSize.toString());
|
||||
if (termRef.current) {
|
||||
termRef.current.options.fontSize = newSize;
|
||||
fitAddonRef.current?.fit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!termRef.current) return;
|
||||
const selection = termRef.current.getSelection();
|
||||
if (selection) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(selection);
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = selection;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(text);
|
||||
}
|
||||
} catch {
|
||||
// Clipboard API not available
|
||||
}
|
||||
};
|
||||
|
||||
// Focus hidden input on mobile to keep keyboard open
|
||||
const handleTerminalClick = () => {
|
||||
if (isMobile && hiddenInputRef.current) {
|
||||
hiddenInputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="terminal-wrapper">
|
||||
<div className={`terminal-wrapper ${isMobile ? "mobile" : ""}`}>
|
||||
<div className="terminal-header">
|
||||
<div className="terminal-status">
|
||||
<span
|
||||
className={`status-dot ${status}`}
|
||||
aria-label={`Terminal status: ${status}`}
|
||||
/>
|
||||
<span className="status-text">{status}</span>
|
||||
<div className="terminal-header-left">
|
||||
<div className="terminal-status">
|
||||
<span
|
||||
className={`status-dot ${status}`}
|
||||
aria-label={`Terminal status: ${status}`}
|
||||
/>
|
||||
<span className="status-text">
|
||||
{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>
|
||||
<div className="terminal-header-right">
|
||||
{isMobile && (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{onClose && (
|
||||
<button className="terminal-close" onClick={onClose} type="button">
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{onClose && (
|
||||
<button className="terminal-close" onClick={onClose} type="button">
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className="terminal-error">{error}</div>}
|
||||
<div ref={terminalRef} className="terminal-container" />
|
||||
{error && (
|
||||
<div className="terminal-error">
|
||||
{error}
|
||||
{status === "error" && (
|
||||
<button
|
||||
className="terminal-reconnect"
|
||||
onClick={() => {
|
||||
reconnectAttemptsRef.current = 0;
|
||||
connectWebSocket();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="terminal-container"
|
||||
onClick={handleTerminalClick}
|
||||
/>
|
||||
{isMobile && (
|
||||
<input
|
||||
ref={hiddenInputRef}
|
||||
type="text"
|
||||
className="terminal-hidden-input"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
|
||||
interface AutoHideOptions {
|
||||
timeout?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useAutoHide(options: AutoHideOptions = {}) {
|
||||
const { timeout = 3000, enabled = true } = options;
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastInteractionRef = useRef(Date.now());
|
||||
|
||||
const show = useCallback(() => {
|
||||
if (!enabled) return;
|
||||
setIsVisible(true);
|
||||
lastInteractionRef.current = Date.now();
|
||||
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
}, timeout);
|
||||
}, [enabled, timeout]);
|
||||
|
||||
const hide = useCallback(() => {
|
||||
if (!enabled) return;
|
||||
setIsVisible(false);
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (!enabled) return;
|
||||
if (isVisible) {
|
||||
hide();
|
||||
} else {
|
||||
show();
|
||||
}
|
||||
}, [enabled, isVisible, show, hide]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setIsVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the timer initially
|
||||
show();
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [enabled, show]);
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
show,
|
||||
hide,
|
||||
toggle,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useMobileViewport() {
|
||||
const [isMobile, setIsMobile] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.innerWidth < MOBILE_BREAKPOINT;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
export type SpecialKey =
|
||||
| "escape"
|
||||
| "tab"
|
||||
| "ctrl"
|
||||
| "alt"
|
||||
| "up"
|
||||
| "down"
|
||||
| "left"
|
||||
| "right"
|
||||
| "home"
|
||||
| "end"
|
||||
| "pageup"
|
||||
| "pagedown"
|
||||
| "ctrlc"
|
||||
| "ctrld"
|
||||
| "ctrlz"
|
||||
| "f1"
|
||||
| "f2"
|
||||
| "f3"
|
||||
| "f4"
|
||||
| "f5"
|
||||
| "f6"
|
||||
| "f7"
|
||||
| "f8"
|
||||
| "f9"
|
||||
| "f10"
|
||||
| "f11"
|
||||
| "f12";
|
||||
|
||||
const KEY_SEQUENCES: Record<SpecialKey, string> = {
|
||||
escape: "\x1B",
|
||||
tab: "\t",
|
||||
ctrl: "",
|
||||
alt: "",
|
||||
up: "\x1B[A",
|
||||
down: "\x1B[B",
|
||||
right: "\x1B[C",
|
||||
left: "\x1B[D",
|
||||
home: "\x1B[H",
|
||||
end: "\x1B[F",
|
||||
pageup: "\x1B[5~",
|
||||
pagedown: "\x1B[6~",
|
||||
ctrlc: "\x03",
|
||||
ctrld: "\x04",
|
||||
ctrlz: "\x1A",
|
||||
f1: "\x1BOP",
|
||||
f2: "\x1BOQ",
|
||||
f3: "\x1BOR",
|
||||
f4: "\x1BOS",
|
||||
f5: "\x1B[15~",
|
||||
f6: "\x1B[17~",
|
||||
f7: "\x1B[18~",
|
||||
f8: "\x1B[19~",
|
||||
f9: "\x1B[20~",
|
||||
f10: "\x1B[21~",
|
||||
f11: "\x1B[23~",
|
||||
f12: "\x1B[24~",
|
||||
};
|
||||
|
||||
interface UseSpecialKeysOptions {
|
||||
onSend: (data: string) => void;
|
||||
}
|
||||
|
||||
export function useSpecialKeys({ onSend }: UseSpecialKeysOptions) {
|
||||
const sendKey = useCallback(
|
||||
(key: SpecialKey) => {
|
||||
const sequence = KEY_SEQUENCES[key];
|
||||
if (sequence) {
|
||||
onSend(sequence);
|
||||
}
|
||||
},
|
||||
[onSend]
|
||||
);
|
||||
|
||||
return { sendKey };
|
||||
}
|
||||
|
||||
export { KEY_SEQUENCES };
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
interface VirtualKeyboardState {
|
||||
isOpen: boolean;
|
||||
height: number;
|
||||
viewportHeight: number;
|
||||
}
|
||||
|
||||
export function useVirtualKeyboard() {
|
||||
const [state, setState] = useState<VirtualKeyboardState>({
|
||||
isOpen: false,
|
||||
height: 0,
|
||||
viewportHeight: typeof window !== "undefined" ? window.innerHeight : 0,
|
||||
});
|
||||
|
||||
const updateKeyboardState = useCallback(() => {
|
||||
const visualViewport = window.visualViewport;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
if (visualViewport) {
|
||||
const viewportHeight = visualViewport.height;
|
||||
const keyboardHeight = windowHeight - viewportHeight;
|
||||
const isOpen = keyboardHeight > 100; // Threshold to avoid false positives
|
||||
|
||||
setState({
|
||||
isOpen,
|
||||
height: keyboardHeight,
|
||||
viewportHeight,
|
||||
});
|
||||
} else {
|
||||
// Fallback: compare window height to a stored reference
|
||||
// This is less reliable but works on older browsers
|
||||
const currentHeight = windowHeight;
|
||||
const isOpen = currentHeight < state.viewportHeight - 100;
|
||||
|
||||
setState((prev) => ({
|
||||
isOpen,
|
||||
height: isOpen ? prev.viewportHeight - currentHeight : 0,
|
||||
viewportHeight: isOpen ? prev.viewportHeight : currentHeight,
|
||||
}));
|
||||
}
|
||||
}, [state.viewportHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
const visualViewport = window.visualViewport;
|
||||
|
||||
if (visualViewport) {
|
||||
visualViewport.addEventListener("resize", updateKeyboardState);
|
||||
visualViewport.addEventListener("scroll", updateKeyboardState);
|
||||
} else {
|
||||
window.addEventListener("resize", updateKeyboardState);
|
||||
}
|
||||
|
||||
// Initial check
|
||||
updateKeyboardState();
|
||||
|
||||
return () => {
|
||||
if (visualViewport) {
|
||||
visualViewport.removeEventListener("resize", updateKeyboardState);
|
||||
visualViewport.removeEventListener("scroll", updateKeyboardState);
|
||||
} else {
|
||||
window.removeEventListener("resize", updateKeyboardState);
|
||||
}
|
||||
};
|
||||
}, [updateKeyboardState]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -2,13 +2,14 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
||||
import { createInstance, getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
|
||||
import { getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel, checkInstanceHealth, type Session as SessionApi, type InstanceHealth } from "../api/sessions";
|
||||
import { listProjects } from "../api/projects";
|
||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { updateUserConfig } from "../api/settings";
|
||||
import type { Project } from "../types";
|
||||
import { Icon } from "../components/icon";
|
||||
import { CreateSessionForm } from "../components/create-session-form";
|
||||
|
||||
type HomeStatus = "loading" | "ready" | "error";
|
||||
|
||||
@@ -29,10 +30,6 @@ export const HomePage = () => {
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState("");
|
||||
const [selectedRepo, setSelectedRepo] = useState("");
|
||||
const [selectedToolType, setSelectedToolType] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle");
|
||||
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
@@ -133,24 +130,10 @@ export const HomePage = () => {
|
||||
[safeSessions]
|
||||
);
|
||||
|
||||
const handleCreate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selectedProject || !selectedRepo || !selectedToolType) return;
|
||||
|
||||
setSaveState("saving");
|
||||
try {
|
||||
const instance = await createInstance(selectedProject, selectedRepo, selectedToolType, displayName || undefined);
|
||||
await startInstance(selectedProject, selectedRepo, instance.id);
|
||||
await updateUserConfig({ last_session_id: instance.id });
|
||||
setDisplayName("");
|
||||
setSelectedProject("");
|
||||
setSelectedRepo("");
|
||||
setSelectedToolType("");
|
||||
setSaveState("idle");
|
||||
await loadHome();
|
||||
} catch {
|
||||
setSaveState("error");
|
||||
}
|
||||
const handleCreateSuccess = async (instance: { id: string }) => {
|
||||
await updateUserConfig({ last_session_id: instance.id });
|
||||
setSelectedProject("");
|
||||
await loadHome();
|
||||
};
|
||||
|
||||
const handleOpen = (session: SessionView) => {
|
||||
@@ -358,41 +341,13 @@ export const HomePage = () => {
|
||||
<h2>Start a session</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form className="stack create-session-form" onSubmit={handleCreate}>
|
||||
<div className="form-row">
|
||||
<label className="form-field">
|
||||
Project
|
||||
<select value={selectedProject} onChange={(event) => { setSelectedProject(event.target.value); setSelectedRepo(""); }}>
|
||||
<option value="">Select project...</option>
|
||||
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<select value={selectedRepo} onChange={(event) => setSelectedRepo(event.target.value)} disabled={!selectedProject}>
|
||||
<option value="">Select repository...</option>
|
||||
{repositories.map((repo) => <option key={repo.id} value={repo.id}>{repo.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Tool type
|
||||
<select value={selectedToolType} onChange={(event) => setSelectedToolType(event.target.value)}>
|
||||
<option value="">Select tool...</option>
|
||||
{toolTypes.map((tool) => <option key={tool.id} value={tool.id}>{tool.display_name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="form-field">
|
||||
Display name
|
||||
<input type="text" value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="My Development Environment" />
|
||||
</label>
|
||||
<div className="form-actions">
|
||||
<button className="primary-button" type="submit" disabled={saveState === "saving"}>
|
||||
{saveState === "saving" ? <><Icon name="loading" size="sm" /> Creating...</> : <><Icon name="add" size="sm" /> Create Session</>}
|
||||
</button>
|
||||
{saveState === "error" && <span className="error-text">Failed to create session</span>}
|
||||
</div>
|
||||
</form>
|
||||
<CreateSessionForm
|
||||
projects={projects}
|
||||
repositories={repositories}
|
||||
toolTypes={toolTypes}
|
||||
onProjectChange={(projectId) => setSelectedProject(projectId)}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{recentSessions.length > 0 && (
|
||||
|
||||
@@ -254,6 +254,8 @@ export const RepoWorkspace = () => {
|
||||
<InstanceList
|
||||
projectId={projectId!}
|
||||
repoId={selectedRepoId}
|
||||
projectName={project?.name}
|
||||
repoName={repositories.find((r) => r.id === selectedRepoId)?.name}
|
||||
toolTypes={toolTypes}
|
||||
/>
|
||||
)}
|
||||
|
||||
+45
-313
@@ -3,24 +3,21 @@ import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { listProjects } from "../api/projects";
|
||||
import type { Project } from "../types";
|
||||
import { listRepositories, listRepositoryBranches, type GitRepository, type Branch } from "../api/git_repositories";
|
||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||
import {
|
||||
getUserSessions,
|
||||
type Session,
|
||||
deleteInstance,
|
||||
stopInstance,
|
||||
startInstance,
|
||||
checkInstanceHealth,
|
||||
recreateInstanceTunnel,
|
||||
} from "../api/sessions";
|
||||
import { listToolTypes, type ToolType } from "../api/tool_types";
|
||||
import { createInstance } from "../api/sessions";
|
||||
import { getUserConfig, updateUserConfig } from "../api/settings";
|
||||
import { listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
||||
import { Icon } from "../components/icon";
|
||||
import { CreateSessionForm } from "../components/create-session-form";
|
||||
|
||||
type SessionsStatus = "loading" | "ready" | "error";
|
||||
type CreateStatus = "idle" | "creating" | "error";
|
||||
|
||||
export const SessionsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -31,23 +28,7 @@ export const SessionsPage = () => {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
|
||||
const [selectedProject, setSelectedProject] = useState<string>("");
|
||||
const [selectedRepo, setSelectedRepo] = useState<string>("");
|
||||
const [selectedToolType, setSelectedToolType] = useState<string>("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [createStatus, setCreateStatus] = useState<CreateStatus>("idle");
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount");
|
||||
const [branch, setBranch] = useState("main");
|
||||
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||
|
||||
const [branches, setBranches] = useState<Branch[]>([]);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [isCreatingNewBranch, setIsCreatingNewBranch] = useState(false);
|
||||
const [newBranchName, setNewBranchName] = useState("");
|
||||
const [baseBranch, setBaseBranch] = useState("");
|
||||
|
||||
const [dirtyDeleteSession, setDirtyDeleteSession] = useState<Session | null>(null);
|
||||
const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState<string[]>([]);
|
||||
@@ -66,6 +47,8 @@ export const SessionsPage = () => {
|
||||
}>>({});
|
||||
const [recreatingId, setRecreatingId] = useState<string | null>(null);
|
||||
const [expandedProbeId, setExpandedProbeId] = useState<string | null>(null);
|
||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||
const [loadingAction, setLoadingAction] = useState<string>("");
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
@@ -110,44 +93,7 @@ export const SessionsPage = () => {
|
||||
void loadToolTypes();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSshKeys = async () => {
|
||||
try {
|
||||
const data = await listSSHKeys();
|
||||
setSshKeys(data);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void loadSshKeys();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadBranches = async () => {
|
||||
if (!selectedRepo || !selectedProject || cloneMode !== "clone") {
|
||||
setBranches([]);
|
||||
setIsCreatingNewBranch(false);
|
||||
setNewBranchName("");
|
||||
setBaseBranch("");
|
||||
return;
|
||||
}
|
||||
setIsLoadingBranches(true);
|
||||
try {
|
||||
const data = await listRepositoryBranches(selectedProject, selectedRepo);
|
||||
setBranches(data.branches);
|
||||
const defaultBranch = data.default_branch;
|
||||
setBaseBranch(defaultBranch);
|
||||
if (!branch || !data.branches.find((b) => b.name === branch)) {
|
||||
setBranch(defaultBranch);
|
||||
}
|
||||
} catch {
|
||||
setBranches([]);
|
||||
} finally {
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
};
|
||||
void loadBranches();
|
||||
}, [selectedRepo, selectedProject, cloneMode]);
|
||||
|
||||
// Poll health every 30 seconds for active instances
|
||||
useEffect(() => {
|
||||
@@ -221,72 +167,30 @@ export const SessionsPage = () => {
|
||||
[sessions, lastSessionId]
|
||||
);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCreateError(null);
|
||||
|
||||
if (!selectedProject || !selectedRepo || !selectedToolType) {
|
||||
setCreateError("Project, repository, and tool type are required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (cloneMode === "clone") {
|
||||
const repo = repositories.find((r) => r.id === selectedRepo);
|
||||
if (!repo?.ssh_key_id) {
|
||||
setCreateError("Repository must have an SSH key assigned for clone mode");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setCreateStatus("creating");
|
||||
try {
|
||||
const instance = await createInstance(
|
||||
selectedProject,
|
||||
selectedRepo,
|
||||
selectedToolType,
|
||||
displayName || undefined,
|
||||
cloneMode,
|
||||
isCreatingNewBranch ? baseBranch : branch,
|
||||
isCreatingNewBranch ? newBranchName : undefined
|
||||
);
|
||||
|
||||
// Auto-start the instance
|
||||
await startInstance(selectedProject, selectedRepo, instance.id);
|
||||
|
||||
await updateUserConfig({ last_session_id: instance.id });
|
||||
setCreateStatus("idle");
|
||||
setSelectedProject("");
|
||||
setSelectedRepo("");
|
||||
setSelectedToolType("");
|
||||
setDisplayName("");
|
||||
setCloneMode("mount");
|
||||
setBranch("main");
|
||||
setIsCreatingNewBranch(false);
|
||||
setNewBranchName("");
|
||||
setBaseBranch("");
|
||||
setBranches([]);
|
||||
await loadSessions();
|
||||
} catch (error) {
|
||||
setCreateStatus("error");
|
||||
const axiosError = error as { response?: { data?: { detail?: string } } };
|
||||
const message = axiosError.response?.data?.detail;
|
||||
setCreateError(
|
||||
typeof message === "string" ? message : "Failed to create session"
|
||||
);
|
||||
}
|
||||
const handleCreateSuccess = async (instance: { id: string }) => {
|
||||
await updateUserConfig({ last_session_id: instance.id });
|
||||
setSelectedProject("");
|
||||
await loadSessions();
|
||||
};
|
||||
|
||||
const handleStop = async (sessionId: string, projectId: string, repoId: string) => {
|
||||
setLoadingSessionId(sessionId);
|
||||
setLoadingAction("Stopping...");
|
||||
try {
|
||||
await stopInstance(projectId, repoId, sessionId);
|
||||
setStopConfirmId(null);
|
||||
await loadSessions();
|
||||
} catch {
|
||||
setStopConfirmId(null);
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (sessionId: string, projectId: string, repoId: string, force = false) => {
|
||||
setLoadingSessionId(sessionId);
|
||||
setLoadingAction("Deleting...");
|
||||
try {
|
||||
await deleteInstance(projectId, repoId, sessionId, force);
|
||||
setDeleteConfirmId(null);
|
||||
@@ -306,11 +210,15 @@ export const SessionsPage = () => {
|
||||
}
|
||||
}
|
||||
setDeleteConfirmId(null);
|
||||
} finally {
|
||||
setLoadingSessionId(null);
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecreateTunnel = async (session: Session) => {
|
||||
setRecreatingId(session.id);
|
||||
setLoadingSessionId(session.id);
|
||||
setLoadingAction("Recreating tunnel...");
|
||||
try {
|
||||
await recreateInstanceTunnel(
|
||||
session.project_id,
|
||||
@@ -322,7 +230,8 @@ export const SessionsPage = () => {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setRecreatingId(null);
|
||||
setLoadingSessionId(null);
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -407,7 +316,15 @@ export const SessionsPage = () => {
|
||||
)}
|
||||
|
||||
{/* Active Sessions */}
|
||||
<div className="active-sessions-section">
|
||||
<div className={`active-sessions-section ${loadingSessionId ? "dimmed" : ""}`}>
|
||||
{loadingSessionId && (
|
||||
<div className="loading-overlay">
|
||||
<div className="loading-content">
|
||||
<Icon name="loading" size="lg" />
|
||||
<p>{loadingAction}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<h2>
|
||||
Active Sessions
|
||||
{activeSessions.length > 0 && (
|
||||
@@ -445,17 +362,18 @@ export const SessionsPage = () => {
|
||||
{tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "error_response" && (
|
||||
<span className="status-badge warning">app error ({tunnelHealth[session.id].tunnel_status_code})</span>
|
||||
)}
|
||||
{tunnelHealth[session.id]?.last_probe_output && (
|
||||
{tunnelHealth[session.id]?.probe_status && tunnelHealth[session.id]?.probe_status !== "not_applicable" && (
|
||||
<div className="probe-output-section">
|
||||
<button
|
||||
className="probe-toggle"
|
||||
className={`probe-toggle probe-${tunnelHealth[session.id].probe_status}`}
|
||||
onClick={() => setExpandedProbeId(expandedProbeId === session.id ? null : session.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="info" size="sm" />
|
||||
{expandedProbeId === session.id ? "Hide probe output" : "Show probe output"}
|
||||
Probe: {tunnelHealth[session.id].probe_status}
|
||||
{expandedProbeId === session.id ? " (hide)" : " (show)"}
|
||||
</button>
|
||||
{expandedProbeId === session.id && (
|
||||
{expandedProbeId === session.id && tunnelHealth[session.id]?.last_probe_output && (
|
||||
<pre className="probe-output">
|
||||
{tunnelHealth[session.id].last_probe_output}
|
||||
</pre>
|
||||
@@ -642,201 +560,15 @@ export const SessionsPage = () => {
|
||||
{/* Create Session */}
|
||||
<div className="create-session-section">
|
||||
<h2>Create New Session</h2>
|
||||
<form onSubmit={handleCreate} className="card stack create-session-form">
|
||||
<div className="form-row">
|
||||
<label className="form-field">
|
||||
Project
|
||||
<select
|
||||
value={selectedProject}
|
||||
onChange={(e) => {
|
||||
setSelectedProject(e.target.value);
|
||||
setSelectedRepo("");
|
||||
}}
|
||||
>
|
||||
<option value="">Select project...</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<select
|
||||
value={selectedRepo}
|
||||
onChange={(e) => setSelectedRepo(e.target.value)}
|
||||
disabled={!selectedProject}
|
||||
>
|
||||
<option value="">Select repository...</option>
|
||||
{repositories.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
Tool Type
|
||||
<select
|
||||
value={selectedToolType}
|
||||
onChange={(e) => setSelectedToolType(e.target.value)}
|
||||
>
|
||||
<option value="">Select tool...</option>
|
||||
{toolTypes.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-field">
|
||||
Repository Access
|
||||
<div className="radio-group">
|
||||
<label className="radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="cloneMode"
|
||||
value="mount"
|
||||
checked={cloneMode === "mount"}
|
||||
onChange={(e) => setCloneMode(e.target.value as "mount" | "clone")}
|
||||
/>
|
||||
Mount (live sync)
|
||||
</label>
|
||||
<label className="radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="cloneMode"
|
||||
value="clone"
|
||||
checked={cloneMode === "clone"}
|
||||
onChange={(e) => setCloneMode(e.target.value as "mount" | "clone")}
|
||||
/>
|
||||
Clone fresh copy
|
||||
</label>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{cloneMode === "clone" && (
|
||||
<>
|
||||
<label className="form-field">
|
||||
Branch
|
||||
{isLoadingBranches ? (
|
||||
<span className="muted">Loading branches...</span>
|
||||
) : (
|
||||
<select
|
||||
value={isCreatingNewBranch ? "__new__" : branch}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "__new__") {
|
||||
setIsCreatingNewBranch(true);
|
||||
setNewBranchName("");
|
||||
} else {
|
||||
setIsCreatingNewBranch(false);
|
||||
setBranch(value);
|
||||
setBaseBranch(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b.name} value={b.name}>
|
||||
{b.name} {b.is_default ? "(default)" : ""}
|
||||
</option>
|
||||
))}
|
||||
<option value="__new__">Create new branch...</option>
|
||||
</select>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{isCreatingNewBranch && (
|
||||
<>
|
||||
<label className="form-field">
|
||||
New Branch Name
|
||||
<input
|
||||
type="text"
|
||||
value={newBranchName}
|
||||
onChange={(e) => setNewBranchName(e.target.value)}
|
||||
placeholder="feature/my-new-branch"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Base Branch
|
||||
<select
|
||||
value={baseBranch}
|
||||
onChange={(e) => setBaseBranch(e.target.value)}
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b.name} value={b.name}>
|
||||
{b.name} {b.is_default ? "(default)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedRepo && (
|
||||
<div className="form-field ssh-key-info">
|
||||
{(() => {
|
||||
const repo = repositories.find((r) => r.id === selectedRepo);
|
||||
if (!repo) return null;
|
||||
if (repo.ssh_key_id) {
|
||||
const key = sshKeys.find((k) => k.id === repo.ssh_key_id);
|
||||
return (
|
||||
<span className="success-text">
|
||||
SSH key: {key?.name || "Assigned"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="warning-text">
|
||||
No SSH key assigned to this repository. Clone mode requires an SSH key.
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="form-field">
|
||||
Display Name (optional)
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="My Development Environment"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{createError && <p className="error-text">{createError}</p>}
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
className="primary-button"
|
||||
type="submit"
|
||||
disabled={createStatus === "creating"}
|
||||
>
|
||||
{createStatus === "creating" ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="add" size="sm" />
|
||||
Create Session
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<CreateSessionForm
|
||||
projects={projects}
|
||||
repositories={repositories}
|
||||
toolTypes={toolTypes}
|
||||
onProjectChange={(projectId) => {
|
||||
setSelectedProject(projectId);
|
||||
}}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dirty Delete Confirmation Modal */}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { TerminalComponent } from "../components/terminal";
|
||||
import { Icon } from "../components/icon";
|
||||
import { MobileTerminalWrapper } from "../components/mobile-terminal-wrapper";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
|
||||
export const TerminalPage: React.FC = () => {
|
||||
const { instanceId } = useParams<{ instanceId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useMobileViewport();
|
||||
|
||||
if (!instanceId) {
|
||||
return (
|
||||
@@ -16,6 +18,16 @@ export const TerminalPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileTerminalWrapper
|
||||
instanceId={instanceId}
|
||||
onBack={() => navigate(-1)}
|
||||
onClose={() => navigate(-1)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="terminal-page">
|
||||
<div className="terminal-page-header">
|
||||
@@ -24,7 +36,6 @@ export const TerminalPage: React.FC = () => {
|
||||
onClick={() => navigate(-1)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="arrow-left" size="sm" />
|
||||
Back
|
||||
</button>
|
||||
<h1>Terminal</h1>
|
||||
@@ -32,6 +43,7 @@ export const TerminalPage: React.FC = () => {
|
||||
<TerminalComponent
|
||||
instanceId={instanceId}
|
||||
onClose={() => navigate(-1)}
|
||||
isMobile={false}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -2652,9 +2652,15 @@ a.nav-item,
|
||||
}
|
||||
|
||||
.active-sessions-section {
|
||||
position: relative;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.active-sessions-section.dimmed {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.active-sessions-section h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2717,6 +2723,62 @@ a.nav-item,
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
/* Loading overlay for sessions */
|
||||
.sessions-grid {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sessions-grid.dimmed {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
background: rgba(255, 254, 249, 0.7);
|
||||
border-radius: var(--space-2);
|
||||
}
|
||||
|
||||
.loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-6);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--space-2);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.loading-content .icon {
|
||||
animation: spin 1s linear infinite;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.loading-content p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.recent-sessions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2763,9 +2825,15 @@ a.nav-item,
|
||||
}
|
||||
|
||||
.create-session-section {
|
||||
position: relative;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.create-session-section.dimmed {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.create-session-form {
|
||||
max-width: 600px;
|
||||
}
|
||||
@@ -2805,3 +2873,371 @@ a.nav-item,
|
||||
background: var(--danger-light, #fee2e2);
|
||||
color: var(--danger, #dc2626);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Mobile Terminal Styles
|
||||
============================================ */
|
||||
|
||||
.mobile-terminal-shell {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mobile-terminal-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #1e1e1e;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Mobile Terminal Header */
|
||||
.mobile-terminal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: #2d2d2d;
|
||||
border-bottom: 1px solid #3e3e3e;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.mobile-terminal-header.hidden {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mobile-terminal-header.visible {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mobile-terminal-header-left,
|
||||
.mobile-terminal-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.mobile-terminal-header-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-terminal-header-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #d4d4d4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mobile-terminal-header-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid #3e3e3e;
|
||||
border-radius: 6px;
|
||||
color: #d4d4d4;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.mobile-terminal-header-button:hover {
|
||||
background: #3e3e3e;
|
||||
}
|
||||
|
||||
.mobile-terminal-header-status {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-terminal-header-status.connecting {
|
||||
background: #f5f543;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.mobile-terminal-header-status.connected {
|
||||
background: #0dbc79;
|
||||
}
|
||||
|
||||
.mobile-terminal-header-status.disconnected,
|
||||
.mobile-terminal-header-status.error {
|
||||
background: #cd3131;
|
||||
}
|
||||
|
||||
/* Mobile Terminal Content */
|
||||
.mobile-terminal-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Special Keys Strip */
|
||||
.special-keys-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: #2d2d2d;
|
||||
border-top: 1px solid #3e3e3e;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.special-keys-strip.hidden {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.special-keys-strip.visible {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.special-keys-strip::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.special-key-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
padding: 0 var(--space-2);
|
||||
background: #3e3e3e;
|
||||
border: 1px solid #4e4e4e;
|
||||
border-radius: 6px;
|
||||
color: #d4d4d4;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s ease, transform 0.1s ease;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.special-key-button:active {
|
||||
background: #4e4e4e;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.special-key-more {
|
||||
background: #2472c8;
|
||||
border-color: #2472c8;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.special-key-more:active {
|
||||
background: #1e5fa8;
|
||||
}
|
||||
|
||||
/* Special Keys Panel */
|
||||
.special-keys-panel-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.special-keys-panel {
|
||||
background: #2d2d2d;
|
||||
border-top: 1px solid #3e3e3e;
|
||||
border-radius: 12px 12px 0 0;
|
||||
padding: var(--space-4);
|
||||
width: 100%;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
animation: slideUp 0.2s ease;
|
||||
}
|
||||
|
||||
.special-keys-panel-section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.special-keys-panel-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.special-keys-panel-divider {
|
||||
height: 1px;
|
||||
background: #3e3e3e;
|
||||
margin: var(--space-3) 0;
|
||||
}
|
||||
|
||||
/* Terminal Component Updates */
|
||||
.terminal-wrapper.mobile {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.terminal-wrapper.mobile .terminal-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.terminal-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.terminal-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.terminal-header-button {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: transparent;
|
||||
border: 1px solid #666;
|
||||
border-radius: 4px;
|
||||
color: #d4d4d4;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.terminal-header-button:hover {
|
||||
background: #3e3e3e;
|
||||
}
|
||||
|
||||
.terminal-reconnect {
|
||||
margin-left: var(--space-2);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: #2472c8;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.terminal-hidden-input {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Disable zoom on mobile terminal */
|
||||
@media (max-width: 767px) {
|
||||
.mobile-terminal-wrapper {
|
||||
touch-action: none;
|
||||
-webkit-text-size-adjust: none;
|
||||
}
|
||||
|
||||
.mobile-terminal-wrapper * {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
touch-action: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Menu Overlay */
|
||||
.mobile-menu-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.mobile-menu-close {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* AppShell mobile menu */
|
||||
@media (max-width: 767px) {
|
||||
.shell-nav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 260px;
|
||||
background: var(--bg);
|
||||
z-index: 100;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
padding-top: var(--space-8);
|
||||
}
|
||||
|
||||
.shell-nav.mobile-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user