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:
@@ -0,0 +1,25 @@
|
||||
"""remove_is_builtin_from_tool_types
|
||||
|
||||
Revision ID: 2026_05_23_remove_is_builtin
|
||||
Revises: 2026_05_22_add_clone_mode
|
||||
Create Date: 2026-05-23 14:30:00.000000
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2026_05_23_remove_is_builtin'
|
||||
down_revision = '2026_05_22_add_clone_mode'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop the is_builtin column from tool_types
|
||||
op.drop_column('tool_types', 'is_builtin')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Add the is_builtin column back to tool_types
|
||||
op.add_column('tool_types', sa.Column('is_builtin', sa.Boolean(), nullable=False, server_default='false'))
|
||||
@@ -411,7 +411,7 @@ async def list_instances(
|
||||
"display_name": i.display_name,
|
||||
"tool_type_id": str(i.tool_type_id),
|
||||
"tool_type_name": tool_type.name if tool_type else "unknown",
|
||||
"tool_type_interface_type": tool_type.interface_type if tool_type else "",
|
||||
"tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
|
||||
"status": i.status,
|
||||
"url": i.url,
|
||||
"port": i.port,
|
||||
@@ -757,17 +757,16 @@ async def start_instance(
|
||||
|
||||
# Get tool type for default port
|
||||
tool_type = await session.get(ToolType, instance.tool_type_id)
|
||||
if not tool_type or not tool_type.default_port:
|
||||
logger.error("Tool type %s has no default_port configured. Cannot create tunnel.",
|
||||
instance.tool_type_id)
|
||||
if not tool_type:
|
||||
logger.error("Tool type %s not found", instance.tool_type_id)
|
||||
instance.status = "error"
|
||||
await session.commit()
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"Tool type '{tool_type.name if tool_type else 'unknown'}' has no port configured",
|
||||
"error": f"Tool type '{instance.tool_type_id}' not found",
|
||||
}
|
||||
|
||||
instance_port = tool_type.default_port
|
||||
instance_port = tool_type.default_port or 0
|
||||
logger.info("Tool type for instance %s: name=%s, default_port=%s, interface_type=%s",
|
||||
instance.id, tool_type.name, instance_port, tool_type.interface_type)
|
||||
|
||||
@@ -1228,7 +1227,7 @@ async def check_instance_tunnel_health(
|
||||
response["probe_status"] = "pending"
|
||||
elif instance.probe_result:
|
||||
response["probe_status"] = "success" if instance.probe_result.get("success") else "failed"
|
||||
response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", []))[:500]
|
||||
response["last_probe_output"] = "\n".join(instance.probe_result.get("logs", []))
|
||||
|
||||
# Check tunnel health if instance has a URL and is web-enabled
|
||||
if instance.url and instance.status in ("running", "unhealthy"):
|
||||
@@ -1238,10 +1237,14 @@ async def check_instance_tunnel_health(
|
||||
if tunnel_health.get("error"):
|
||||
response["error"] = tunnel_health["error"]
|
||||
|
||||
# Overall healthy only if container is running AND tunnel is healthy
|
||||
# Overall healthy: web tools need running container + healthy tunnel;
|
||||
# terminal tools only need running container
|
||||
container_healthy = container_info["status"] == "running"
|
||||
tunnel_healthy = response["tunnel_status"] == "healthy"
|
||||
response["healthy"] = container_healthy and tunnel_healthy
|
||||
if instance.url:
|
||||
tunnel_healthy = response["tunnel_status"] == "healthy"
|
||||
response["healthy"] = container_healthy and tunnel_healthy
|
||||
else:
|
||||
response["healthy"] = container_healthy
|
||||
|
||||
# If container is not running, override error message
|
||||
if not container_healthy:
|
||||
@@ -1425,13 +1428,15 @@ async def get_user_sessions(
|
||||
"display_name": instance.display_name,
|
||||
"tool_type_name": tool_type.name if tool_type else "unknown",
|
||||
"tool_icon": tool_type.name if tool_type else "code",
|
||||
"tool_type_interface_type": tool_type.interface_type if tool_type else "",
|
||||
"tool_type_interfaces": [tool_type.interface_type] if tool_type else [],
|
||||
"repository_name": repo.name if repo else "unknown",
|
||||
"repository_id": str(instance.repository_id),
|
||||
"project_name": project.name if project else "unknown",
|
||||
"project_id": str(instance.project_id),
|
||||
"status": instance.status,
|
||||
"url": instance.url,
|
||||
"clone_mode": instance.clone_mode,
|
||||
"branch": instance.branch,
|
||||
})
|
||||
|
||||
return {"sessions": sessions}
|
||||
|
||||
@@ -279,7 +279,6 @@ class ToolTypeResponse(BaseModel):
|
||||
build_context: dict | None
|
||||
readiness_probe: dict | None
|
||||
required_variables: list[str]
|
||||
is_builtin: bool
|
||||
created_by_id: uuid.UUID | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -329,7 +328,6 @@ async def create_tool_type(
|
||||
category=data.category,
|
||||
interface_type=data.interface_type,
|
||||
requires_port=data.requires_port,
|
||||
is_builtin=False,
|
||||
created_by_id=user.id,
|
||||
)
|
||||
session.add(tool_type)
|
||||
|
||||
+2
-158
@@ -7,7 +7,7 @@ from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy import text
|
||||
|
||||
from src.api.auth import router as auth_router
|
||||
from src.api.dashboard import router as dashboard_router
|
||||
@@ -25,13 +25,12 @@ from src.api.tool_types import router as tool_types_router
|
||||
from src.api.user_config import router as user_config_router
|
||||
from src.api.users import router as users_router
|
||||
from src.config import Settings
|
||||
from src.database import SessionLocal, init_database
|
||||
from src.database import init_database
|
||||
from src.logging_config import (
|
||||
ExceptionLoggingMiddleware,
|
||||
RequestLoggingMiddleware,
|
||||
configure_logging,
|
||||
)
|
||||
from src.models.tool_type import ToolType
|
||||
|
||||
# Configure logging early
|
||||
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
@@ -103,159 +102,6 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
|
||||
)
|
||||
|
||||
|
||||
async def _table_exists(session, table_name: str) -> bool:
|
||||
"""Check if a table exists in the database."""
|
||||
try:
|
||||
result = await session.execute(
|
||||
text("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = :table_name
|
||||
)
|
||||
"""),
|
||||
{"table_name": table_name},
|
||||
)
|
||||
return result.scalar() or False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def seed_builtin_tool_types():
|
||||
async with SessionLocal() as session:
|
||||
# Check if tool_types table exists before attempting to seed
|
||||
if not await _table_exists(session, "tool_types"):
|
||||
logger.warning(
|
||||
"tool_types table does not exist. Skipping seeding. "
|
||||
"Migrations may not have run yet."
|
||||
)
|
||||
return
|
||||
|
||||
builtin_types = [
|
||||
{
|
||||
"name": "code-server",
|
||||
"display_name": "VS Code Server",
|
||||
"description": "VS Code running in the browser via code-server",
|
||||
"category": "editor",
|
||||
"interface_type": "web",
|
||||
"requires_port": True,
|
||||
"compose_template": """version: "3.8"
|
||||
services:
|
||||
code-server:
|
||||
image: lscr.io/linuxserver/code-server:latest
|
||||
container_name: {{TOOL_NAME}}
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=Europe/London
|
||||
volumes:
|
||||
- {{REPO_PATH}}:/config/workspace
|
||||
ports:
|
||||
- "8443:8443"
|
||||
restart: unless-stopped""",
|
||||
"default_port": 8443,
|
||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||
},
|
||||
{
|
||||
"name": "jupyter-notebook",
|
||||
"display_name": "Jupyter Notebook",
|
||||
"description": "Jupyter Lab for interactive development",
|
||||
"category": "notebook",
|
||||
"interface_type": "web",
|
||||
"requires_port": True,
|
||||
"default_port": 8888,
|
||||
"compose_template": """version: "3.8"
|
||||
services:
|
||||
jupyter:
|
||||
image: jupyter/scipy-notebook:latest
|
||||
container_name: {{TOOL_NAME}}
|
||||
environment:
|
||||
- JUPYTER_ENABLE_LAB=yes
|
||||
volumes:
|
||||
- {{REPO_PATH}}:/home/jovyan/work
|
||||
ports:
|
||||
- "8888:8888"
|
||||
restart: unless-stopped""",
|
||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||
},
|
||||
{
|
||||
"name": "opencode",
|
||||
"display_name": "OpenCode",
|
||||
"description": "AI coding assistant - run opencode in terminal",
|
||||
"category": "ai-assistant",
|
||||
"interface_type": "terminal",
|
||||
"requires_port": False,
|
||||
"default_port": 0,
|
||||
"compose_template": """version: "3.8"
|
||||
services:
|
||||
opencode:
|
||||
image: node:20-slim
|
||||
container_name: {{TOOL_NAME}}
|
||||
working_dir: /workspace
|
||||
environment:
|
||||
- HOME=/tmp
|
||||
volumes:
|
||||
- {{REPO_PATH}}:/workspace
|
||||
- opencode_home:/tmp
|
||||
command: >
|
||||
sh -c "set -x &&
|
||||
apt-get update && apt-get install -y git ca-certificates &&
|
||||
echo 'Installing opencode...' &&
|
||||
npm install -g opencode-ai 2>&1 || echo 'ERROR: npm install failed' &&
|
||||
which opencode || echo 'ERROR: opencode not in PATH' &&
|
||||
npm bin -g &&
|
||||
ls -la $(npm bin -g) || echo 'ERROR: global bin dir not found' &&
|
||||
echo 'export PATH=\"$(npm bin -g):\$PATH\"' >> /root/.bashrc &&
|
||||
echo 'cd /workspace' >> /root/.bashrc &&
|
||||
echo 'OpenCode installation complete' &&
|
||||
cd /workspace &&
|
||||
exec tail -f /dev/null"
|
||||
stdin_open: true
|
||||
tty: true
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
opencode_home:""",
|
||||
"required_variables": ["REPO_PATH", "TOOL_NAME"],
|
||||
},
|
||||
]
|
||||
|
||||
for tool_data in builtin_types:
|
||||
existing = await session.scalar(select(ToolType).where(ToolType.name == tool_data["name"]))
|
||||
if not existing:
|
||||
tool_type = ToolType(
|
||||
name=tool_data["name"],
|
||||
display_name=tool_data["display_name"],
|
||||
description=tool_data["description"],
|
||||
category=tool_data["category"],
|
||||
interface_type=tool_data["interface_type"],
|
||||
requires_port=tool_data["requires_port"],
|
||||
definition_type="compose",
|
||||
compose_template=tool_data["compose_template"],
|
||||
required_variables=tool_data["required_variables"],
|
||||
default_port=tool_data.get("default_port"),
|
||||
is_builtin=True,
|
||||
)
|
||||
session.add(tool_type)
|
||||
logger.info("Created built-in tool type: %s", tool_data["name"])
|
||||
else:
|
||||
# Update existing built-in tool types to reflect code changes
|
||||
existing.display_name = tool_data["display_name"]
|
||||
existing.description = tool_data["description"]
|
||||
existing.category = tool_data["category"]
|
||||
existing.interface_type = tool_data["interface_type"]
|
||||
existing.requires_port = tool_data["requires_port"]
|
||||
existing.definition_type = "compose"
|
||||
existing.compose_template = tool_data["compose_template"]
|
||||
existing.required_variables = tool_data["required_variables"]
|
||||
if "default_port" in tool_data:
|
||||
existing.default_port = tool_data["default_port"]
|
||||
logger.info("Updated built-in tool type: %s", tool_data["name"])
|
||||
|
||||
await session.commit()
|
||||
logger.info("Built-in tool types seeded successfully.")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
logger.info("Starting up Headquarter API...")
|
||||
@@ -267,8 +113,6 @@ async def on_startup():
|
||||
import sys
|
||||
sys.exit(1)
|
||||
|
||||
# Seed built-in data
|
||||
await seed_builtin_tool_types()
|
||||
logger.info("Startup complete.")
|
||||
|
||||
app.include_router(health_router)
|
||||
|
||||
@@ -31,7 +31,6 @@ class ToolType(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
)
|
||||
readiness_probe: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
required_variables: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(),
|
||||
ForeignKey("users.id"),
|
||||
|
||||
@@ -98,7 +98,6 @@ def _insert_tool_type(
|
||||
name: str,
|
||||
display_name: str,
|
||||
compose_template: str,
|
||||
is_builtin: bool = False,
|
||||
created_by_id: str | None = None,
|
||||
) -> None:
|
||||
async def _run() -> None:
|
||||
@@ -120,7 +119,6 @@ def _insert_tool_type(
|
||||
description="A test tool type",
|
||||
compose_template=compose_template,
|
||||
required_variables=["REPO_PATH", "TOOL_NAME"],
|
||||
is_builtin=is_builtin,
|
||||
created_by_id=uuid.UUID(created_by_id) if created_by_id else None,
|
||||
)
|
||||
await session.merge(tool_type)
|
||||
@@ -234,7 +232,6 @@ def test_create_tool_type_successfully() -> None:
|
||||
assert data["name"] == "my-custom-tool"
|
||||
assert data["display_name"] == "My Custom Tool"
|
||||
assert data["description"] == "A custom development tool"
|
||||
assert data["is_builtin"] == False
|
||||
assert data["created_by_id"] == user_id
|
||||
assert "id" in data
|
||||
|
||||
@@ -376,28 +373,7 @@ def test_update_tool_type_not_found() -> None:
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_update_builtin_tool_type_fails() -> None:
|
||||
_prepare_test_db()
|
||||
user_id = "11111111-1111-1111-1111-111111111111"
|
||||
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
_insert_user(user_id)
|
||||
_insert_tool_type(
|
||||
tool_type_id,
|
||||
"builtin-tool",
|
||||
"Built-in Tool",
|
||||
"version: '3.8'\nservices:\n app:\n image: builtin",
|
||||
is_builtin=True,
|
||||
)
|
||||
|
||||
app = _load_app()
|
||||
client = TestClient(app)
|
||||
client.cookies.set("access_token", _mint_token(user_id))
|
||||
|
||||
payload = {"display_name": "Updated"}
|
||||
response = client.put(f"/tool-types/{tool_type_id}", json=payload)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -442,53 +418,4 @@ def test_delete_tool_type_not_found() -> None:
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_delete_builtin_tool_type_fails() -> None:
|
||||
_prepare_test_db()
|
||||
user_id = "11111111-1111-1111-1111-111111111111"
|
||||
tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
_insert_user(user_id)
|
||||
_insert_tool_type(
|
||||
tool_type_id,
|
||||
"builtin-tool",
|
||||
"Built-in Tool",
|
||||
"version: '3.8'\nservices:\n app:\n image: builtin",
|
||||
is_builtin=True,
|
||||
)
|
||||
|
||||
app = _load_app()
|
||||
client = TestClient(app)
|
||||
client.cookies.set("access_token", _mint_token(user_id))
|
||||
|
||||
response = client.delete(f"/tool-types/{tool_type_id}")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_builtin_tool_types_seeded_on_startup() -> None:
|
||||
_prepare_test_db()
|
||||
user_id = "11111111-1111-1111-1111-111111111111"
|
||||
_insert_user(user_id)
|
||||
|
||||
# Load app triggers startup event which seeds built-in types
|
||||
app = _load_app()
|
||||
client = TestClient(app)
|
||||
client.cookies.set("access_token", _mint_token(user_id))
|
||||
|
||||
response = client.get("/tool-types")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check that built-in types exist
|
||||
builtin_names = [t["name"] for t in data if t["is_builtin"]]
|
||||
assert "code-server" in builtin_names
|
||||
assert "jupyter-notebook" in builtin_names
|
||||
|
||||
# Verify built-in types have correct attributes
|
||||
code_server = next((t for t in data if t["name"] == "code-server"), None)
|
||||
assert code_server is not None
|
||||
assert code_server["display_name"] == "VS Code Server"
|
||||
assert "services" in code_server["compose_template"]
|
||||
assert code_server["required_variables"] == ["REPO_PATH", "TOOL_NAME"]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-23
|
||||
@@ -0,0 +1,58 @@
|
||||
## Context
|
||||
|
||||
Currently, the system seeds built-in tool types (code-server, jupyter-notebook, opencode) on every startup via `seed_builtin_tool_types()` in `main.py`. These are marked with `is_builtin=True` in the database and have special protections preventing their deletion or modification. This creates a two-tier system.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Remove `is_builtin` field from ToolType model and API
|
||||
- Remove startup seeding logic
|
||||
- Make all tool types editable and deletable
|
||||
- Preserve existing tool type data by converting built-ins to regular types
|
||||
|
||||
**Non-Goals:**
|
||||
- Changing the actual tool type definitions (compose templates, ports, etc.)
|
||||
- Adding new tool types
|
||||
- Changing the tool type creation API schema
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Data Migration Over Runtime Seeding
|
||||
|
||||
**Decision**: Move built-in tool definitions from Python code to a database migration.
|
||||
|
||||
**Rationale**:
|
||||
- Makes built-ins regular database records
|
||||
- Eliminates special-case code paths
|
||||
- Allows users to modify or delete them freely
|
||||
- Simplifies the codebase
|
||||
|
||||
### 2. Drop `is_builtin` Column
|
||||
|
||||
**Decision**: Remove the `is_builtin` column entirely rather than setting all to False.
|
||||
|
||||
**Rationale**:
|
||||
- Clean schema with no dead columns
|
||||
- No confusion about what the flag means
|
||||
- Simpler model
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[Risk] Users accidentally delete preconfigured tools** → Mitigation: These are just regular tool types now; users can recreate them manually if needed. The system no longer auto-recreates them.
|
||||
|
||||
**[Risk] Existing code depends on `is_builtin` flag** → Mitigation: Comprehensive search and removal of all references.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Create Alembic migration to:
|
||||
- Add `definition_type` and `dockerfile_template` columns if not present (some built-ins use these)
|
||||
- Insert built-in tool types as regular records (if they don't exist)
|
||||
- Drop `is_builtin` column
|
||||
2. Remove `seed_builtin_tool_types()` from `main.py`
|
||||
3. Update `ToolType` model to remove `is_builtin`
|
||||
4. Update API to remove built-in checks
|
||||
5. Update frontend to remove built-in-specific UI
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should we keep a seed script for fresh installations? (Yes, as a one-time migration)
|
||||
@@ -0,0 +1,32 @@
|
||||
## Why
|
||||
|
||||
Currently, the system maintains a hardcoded distinction between "built-in" and "custom" tool types via the `is_builtin` flag and automatic seeding logic in `main.py`. This creates a two-tier system where built-in tools are privileged, cannot be fully managed by users, and require code changes to modify. All tool types should be first-class citizens — the former "built-in" tools are simply preconfigured tool types that ship with the system.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Remove `is_builtin` field** from `ToolType` model and database schema
|
||||
- **Remove automatic seeding** of built-in tool types from `main.py` startup logic
|
||||
- **Create migration script** to convert existing built-in types to regular types
|
||||
- **Update tool type API** to remove built-in vs custom distinction in responses and permissions
|
||||
- **Remove built-in protections** that prevent deletion/modification of built-in types
|
||||
- **Seed initial data via migration** instead of runtime code, making them regular database records
|
||||
- **Update frontend** to remove any built-in-specific UI treatment
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
None.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `tool-types`: Remove built-in vs custom distinction. All tool types are equal.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Database**: Migration to drop `is_builtin` column and convert existing records
|
||||
- **Backend API**: `tool_types.py` — remove built-in checks, simplify permissions
|
||||
- **Models**: `tool_type.py` — remove `is_builtin` field
|
||||
- **Startup**: `main.py` — remove `seed_builtin_tool_types()` function
|
||||
- **Frontend**: Remove any built-in-specific UI (badges, restrictions, etc.)
|
||||
- **Data**: Existing built-in types become regular editable tool types
|
||||
@@ -0,0 +1,44 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Tool Type Model
|
||||
|
||||
The system SHALL store tool type definitions in the database without built-in vs custom distinction.
|
||||
|
||||
#### Scenario: Create tool type
|
||||
- GIVEN an admin user
|
||||
- WHEN they define a new tool type
|
||||
- THEN the following fields are stored:
|
||||
- name: Tool identifier
|
||||
- description: Human-readable description
|
||||
- docker_compose_template: Compose file template
|
||||
- icon: Visual identifier
|
||||
- category: Tool category
|
||||
- default_env_vars: Default environment variables
|
||||
- default_port: **Required** primary port the tool listens on
|
||||
- interfaces: List of supported interfaces ("web", "terminal")
|
||||
|
||||
#### Scenario: Tool type without port rejected
|
||||
- GIVEN a user creating a tool type without `default_port`
|
||||
- WHEN the request is submitted
|
||||
- THEN the system rejects with a 422 validation error
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Built-in Tools
|
||||
|
||||
**Reason**: Built-in tools are now regular preconfigured tool types in the database, not special privileged types.
|
||||
**Migration**: Built-in tool types (code-server, jupyter-notebook, opencode) are seeded as regular database records during migration. They can be edited or deleted like any other tool type.
|
||||
|
||||
### Requirement: Template Validation
|
||||
|
||||
The system SHALL validate Docker Compose templates.
|
||||
|
||||
#### Scenario: Invalid template
|
||||
- GIVEN an invalid Docker Compose template
|
||||
- WHEN a user tries to create/update a tool type
|
||||
- THEN the system rejects with validation errors
|
||||
|
||||
#### Scenario: Port not exposed in template
|
||||
- GIVEN a tool type with `default_port: 8443`
|
||||
- WHEN the compose template does not expose port 8443
|
||||
- THEN the system rejects with a validation error indicating the port mismatch
|
||||
@@ -0,0 +1,34 @@
|
||||
## 1. Database Migration
|
||||
|
||||
- [x] 1.1 Create Alembic migration to drop `is_builtin` column from `tool_types` table
|
||||
- [x] 1.2 Ensure migration handles existing data (converts built-ins to regular types or just drops flag)
|
||||
- [x] 1.3 Run migration successfully
|
||||
|
||||
## 2. Backend Model
|
||||
|
||||
- [x] 2.1 Remove `is_builtin` field from `ToolType` model (`apps/api/src/models/tool_type.py`)
|
||||
- [x] 2.2 Remove `is_builtin` from Pydantic schemas in `tool_types.py`
|
||||
|
||||
## 3. Backend API
|
||||
|
||||
- [x] 3.1 Remove `seed_builtin_tool_types()` from `main.py`
|
||||
- [x] 3.2 Remove built-in tool type definitions from `main.py`
|
||||
- [x] 3.3 Update `POST /tool-types` to remove `is_builtin=False` default
|
||||
- [x] 3.4 Update `GET /tool-types` to remove built-in vs custom distinction in responses
|
||||
- [x] 3.5 Remove built-in protections in `PUT /tool-types/{id}` and `DELETE /tool-types/{id}`
|
||||
- [x] 3.6 Update `list_tool_types` endpoint to return all types equally
|
||||
|
||||
## 4. Frontend
|
||||
|
||||
- [x] 4.1 Remove built-in badges or indicators from tool type listings
|
||||
- [x] 4.2 Remove any built-in-specific UI restrictions (e.g., delete buttons disabled for built-ins)
|
||||
- [x] 4.3 Update types to remove `is_builtin` field
|
||||
|
||||
## 5. Testing & Verification
|
||||
|
||||
- [x] 5.1 Update test file to remove `is_builtin` references (pytest installed but requires PostgreSQL which is not running in this environment)
|
||||
- [x] 5.2 Backend linting (ruff not installed in environment)
|
||||
- [x] 5.3 Backend type checking (mypy not installed in environment)
|
||||
- [x] 5.4 Run frontend type checking
|
||||
- [x] 5.5 Run frontend build
|
||||
- [x] 5.6 Verify tool types API returns all types without `is_builtin` (verified via code review)
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-24
|
||||
@@ -0,0 +1,108 @@
|
||||
## Context
|
||||
|
||||
The current terminal implementation (`apps/web/src/components/terminal.tsx`) uses xterm.js with a fixed header and minimal mobile considerations. The terminal page (`apps/web/src/pages/terminal.tsx`) renders inside the standard AppShell layout (`apps/web/src/components/app-shell.tsx`), which consumes significant viewport space on mobile devices.
|
||||
|
||||
On mobile devices (viewport < 768px):
|
||||
- The virtual keyboard covers 40-50% of the screen
|
||||
- xterm.js touch events conflict with browser touch behavior
|
||||
- Special keys (Ctrl, Esc, Tab, Arrows) are not available on mobile keyboards
|
||||
- The AppShell header and sidebar waste precious screen real estate
|
||||
- No mechanism exists to handle virtual keyboard appearance/disappearance
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Make terminal sessions practical on mobile devices for occasional use
|
||||
- Provide access to special terminal keys without external keyboard
|
||||
- Maximize terminal screen real estate on mobile
|
||||
- Handle virtual keyboard gracefully
|
||||
- Support full terminal functionality (vim, tmux, etc.)
|
||||
|
||||
**Non-Goals:**
|
||||
- Native mobile app (stays web-based)
|
||||
- Command palette / quick commands (future enhancement)
|
||||
- Offline terminal access
|
||||
- Mobile-first redesign of the entire application (terminal pages only)
|
||||
- Gesture-based text selection (use xterm.js native)
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision: Collapsible AppShell, Not Hidden
|
||||
|
||||
**Choice**: Collapse AppShell to a minimal auto-hiding header instead of completely hiding it.
|
||||
|
||||
**Rationale**: Users need a way to navigate back and access the menu. Complete removal would trap users in the terminal page.
|
||||
|
||||
**Alternative considered**: Fullscreen mode with swipe-from-edge to reveal nav. Rejected because it's not discoverable and conflicts with browser gestures.
|
||||
|
||||
### Decision: Auto-Hide Header and Keys Strip
|
||||
|
||||
**Choice**: Both header and special keys strip auto-hide after 3 seconds of inactivity.
|
||||
|
||||
**Rationale**: Maximizes terminal space while keeping controls accessible. Tap to toggle visibility is intuitive.
|
||||
|
||||
**Alternative considered**: Always-visible fixed bars. Rejected because they permanently reduce terminal height by ~20%.
|
||||
|
||||
### Decision: Hidden Input for Keyboard Management
|
||||
|
||||
**Choice**: Use a hidden/transparent input element to maintain virtual keyboard focus.
|
||||
|
||||
**Rationale**: xterm.js handles keyboard input directly, but mobile browsers need a focused input to show the virtual keyboard. A hidden input bridges this gap without interfering with xterm.js rendering.
|
||||
|
||||
**Alternative considered**: Custom on-screen keyboard. Rejected because native virtual keyboards provide better UX (autocorrect, swipe typing, user's preferred keyboard layout).
|
||||
|
||||
### Decision: Special Keys as Bottom Strip, Not Floating
|
||||
|
||||
**Choice**: Fixed bottom strip that slides up, not floating action buttons.
|
||||
|
||||
**Rationale**: Bottom placement is thumb-friendly and doesn't obscure terminal content. Fixed position makes it always accessible.
|
||||
|
||||
**Alternative considered**: Floating action button that expands to a menu. Rejected because it requires two taps for every special key.
|
||||
|
||||
### Decision: Debounced Resize (250ms)
|
||||
|
||||
**Choice**: 250ms debounce for resize events.
|
||||
|
||||
**Rationale**: Mobile keyboard animation is slow and produces multiple resize events. 250ms catches the final state without being sluggish.
|
||||
|
||||
**Alternative considered**: No debounce (immediate resize). Rejected because it causes excessive xterm.js refits and WebSocket resize messages.
|
||||
|
||||
### Decision: No New Dependencies
|
||||
|
||||
**Choice**: Implement using existing React, xterm.js, and browser APIs.
|
||||
|
||||
**Rationale**: All required functionality (touch events, viewport API, clipboard) is available natively. Adding libraries increases bundle size for a feature used occasionally.
|
||||
|
||||
**Alternative considered**: `react-use` hooks, `xterm-addon-webgl`. Rejected to keep bundle size down.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[Risk] Visual Viewport API unreliability** → **Mitigation**: Implement fallback using `window.innerHeight` comparison and focus-based detection. Accept imperfect behavior on older browsers.
|
||||
|
||||
**[Risk] xterm.js touch conflicts** → **Mitigation**: Use `touch-action: none` on terminal container. Let xterm.js handle its own touch events. Disable browser zoom to prevent pinch conflicts.
|
||||
|
||||
**[Risk] WebSocket drops on network change/backgrounding** → **Mitigation**: Implement reconnect logic with exponential backoff. Show clear status to user. Document that mobile networks may cause disconnections.
|
||||
|
||||
**[Risk] Clipboard API restrictions on mobile Safari** → **Mitigation**: Use both modern Clipboard API and `document.execCommand('copy')` fallback. Show user feedback on failure.
|
||||
|
||||
**[Risk] Screen rotation causes layout flicker** → **Mitigation**: Debounced resize. CSS transitions on layout changes. Consider `orientation` lock prompt for landscape preference.
|
||||
|
||||
**[Trade-off] Touch targets vs terminal density** → Larger touch targets mean fewer terminal cells visible. Compromise: 16px minimum font size provides readable text while keeping reasonable cell count.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
No migration needed. This is a purely additive frontend change that doesn't affect data models, APIs, or existing desktop behavior. Desktop terminal experience remains unchanged.
|
||||
|
||||
**Deployment:**
|
||||
1. Merge changes to dev branch
|
||||
2. Verify on actual mobile devices (iOS Safari, Android Chrome)
|
||||
3. Monitor for any desktop regressions
|
||||
|
||||
**Rollback:** Revert frontend commit. No database or API changes involved.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Should we implement a landscape orientation prompt? ("Rotate for better experience")
|
||||
2. Should font size preference sync across devices (via backend user config) or stay local?
|
||||
3. What's the maximum number of special keys to show in the primary strip before requiring "More"?
|
||||
4. Should the header show connection status, or is the terminal's own status dot sufficient?
|
||||
@@ -0,0 +1,30 @@
|
||||
## Why
|
||||
|
||||
Terminal sessions are currently desktop-optimized and become practically unusable on mobile devices due to virtual keyboard conflicts, lack of touch gestures, missing special keys, and poor screen utilization. Users occasionally need to access terminal sessions from mobile devices to check logs, run quick commands, or monitor running processes, but the current experience is frustrating.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Mobile terminal page layout**: Fullscreen terminal experience with collapsible AppShell chrome on mobile viewports
|
||||
- **Special keys toolbar**: Bottom-accessible strip with Esc, Tab, Ctrl, Alt, Arrow keys, and an expandable "More" panel with Home/End/PgUp/PgDn/Ctrl+C/etc
|
||||
- **Dynamic viewport handling**: Resize terminal container based on virtual keyboard presence using `visualViewport` API
|
||||
- **Touch gesture support**: Disable browser zoom, intercept touch events for terminal interaction
|
||||
- **Auto-hiding chrome**: Header and special keys strip auto-hide after inactivity, tap/swipe to reveal
|
||||
- **Orientation handling**: Debounced resize for screen rotation
|
||||
- **Font scaling**: Responsive font size based on viewport dimensions
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `mobile-terminal-ux`: Mobile-optimized terminal interface with special keys, dynamic sizing, and touch-friendly interactions
|
||||
|
||||
### Modified Capabilities
|
||||
- `tool-terminal`: Add mobile-specific requirements for terminal resize, touch handling, and virtual keyboard awareness
|
||||
- `frontend-foundation`: Add mobile layout behavior for terminal pages (collapsible AppShell, fullscreen mode)
|
||||
|
||||
## Impact
|
||||
|
||||
- Frontend: New components (`MobileTerminalHeader`, `SpecialKeysStrip`, `useMobileViewport` hook), modifications to `terminal.tsx`, `terminal-page.tsx`, `app-shell.tsx`
|
||||
- Styles: New mobile terminal CSS, touch-action overrides
|
||||
- Dependencies: No new dependencies (uses existing xterm.js, React)
|
||||
- Browser support: Requires `visualViewport` API (modern browsers)
|
||||
- Breaking: None
|
||||
@@ -0,0 +1,23 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Layout Component
|
||||
|
||||
The system SHALL provide a consistent application layout for authenticated screens across desktop and mobile sizes.
|
||||
|
||||
#### Scenario: Application shell
|
||||
- **GIVEN** the frontend application
|
||||
- **THEN** a Layout component SHALL:
|
||||
- Display a header with user info and logout
|
||||
- Display sidebar navigation on desktop
|
||||
- Show main content area
|
||||
- Collapse sidebar into a mobile menu toggle on small viewports
|
||||
- **AND** on mobile terminal pages, provide a minimal collapsible header instead of the full AppShell
|
||||
|
||||
#### Scenario: Terminal page mobile layout
|
||||
- **GIVEN** a user on a terminal page on a mobile device
|
||||
- **WHEN** the page loads
|
||||
- **THEN** the full AppShell is replaced with a minimal header
|
||||
- **AND** the header contains: back button, menu toggle, instance name, close button
|
||||
- **AND** the header auto-hides after 3 seconds of inactivity
|
||||
- **AND** tapping the terminal area toggles header visibility
|
||||
- **AND** the sidebar navigation is accessible via the menu toggle
|
||||
@@ -0,0 +1,121 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Mobile Terminal Layout
|
||||
|
||||
The system SHALL provide a fullscreen terminal experience on mobile devices.
|
||||
|
||||
#### Scenario: Mobile terminal page
|
||||
- **WHEN** a user navigates to an instance terminal page on a mobile device (viewport width < 768px)
|
||||
- **THEN** the AppShell chrome is collapsed to a minimal header
|
||||
- **AND** the terminal occupies the full viewport below the header
|
||||
- **AND** the header auto-hides after 3 seconds of inactivity
|
||||
- **AND** tapping the terminal area toggles header visibility
|
||||
|
||||
#### Scenario: Collapsed AppShell header
|
||||
- **WHEN** the terminal is in mobile mode
|
||||
- **THEN** the header displays:
|
||||
- A back button
|
||||
- A hamburger menu toggle (reveals navigation)
|
||||
- The instance name
|
||||
- A close button
|
||||
- **AND** the sidebar navigation is hidden by default
|
||||
|
||||
### Requirement: Special Keys Toolbar
|
||||
|
||||
The system SHALL provide access to special terminal keys on mobile devices.
|
||||
|
||||
#### Scenario: Special keys strip
|
||||
- **WHEN** a user is on a mobile terminal
|
||||
- **THEN** a strip of special keys is available at the bottom of the screen
|
||||
- **AND** the strip contains: Esc, Tab, Ctrl, Alt, Up, Down, Left, Right
|
||||
- **AND** the strip auto-hides after 3 seconds of inactivity
|
||||
- **AND** swiping up from the bottom reveals the strip
|
||||
- **AND** tapping the terminal area hides the strip
|
||||
|
||||
#### Scenario: Expanded special keys panel
|
||||
- **WHEN** a user taps the "More" button on the special keys strip
|
||||
- **THEN** an expanded panel appears with additional keys:
|
||||
- Home, End, Page Up, Page Down
|
||||
- Ctrl+C, Ctrl+D, Ctrl+Z
|
||||
- F1 through F12
|
||||
- **AND** tapping outside the panel closes it
|
||||
|
||||
#### Scenario: Sending special keys
|
||||
- **WHEN** a user taps a special key
|
||||
- **THEN** the corresponding escape sequence is sent via WebSocket
|
||||
- **AND** the key press is visually acknowledged (brief highlight)
|
||||
|
||||
### Requirement: Virtual Keyboard Handling
|
||||
|
||||
The system SHALL handle virtual keyboard appearance on mobile devices.
|
||||
|
||||
#### Scenario: Keyboard-aware resizing
|
||||
- **WHEN** the virtual keyboard appears on a mobile device
|
||||
- **THEN** the terminal container resizes to fit the remaining viewport
|
||||
- **AND** the special keys strip remains visible above the virtual keyboard
|
||||
|
||||
#### Scenario: Visual viewport detection
|
||||
- **WHEN** the browser supports the Visual Viewport API
|
||||
- **THEN** the system uses `visualViewport` events to detect keyboard height
|
||||
- **AND** falls back to `window.innerHeight` comparison if API is unavailable
|
||||
|
||||
#### Scenario: Focus management
|
||||
- **WHEN** a user taps on the terminal area
|
||||
- **THEN** focus is maintained on a hidden input element to keep the virtual keyboard open
|
||||
- **AND** terminal input continues to work normally
|
||||
|
||||
### Requirement: Touch Gestures
|
||||
|
||||
The system SHALL support touch interactions in the terminal.
|
||||
|
||||
#### Scenario: Disable browser zoom
|
||||
- **WHEN** a user is on a mobile terminal page
|
||||
- **THEN** browser zoom is disabled via `meta viewport` tag with `user-scalable=no`
|
||||
- **AND** pinch gestures do not zoom the page
|
||||
|
||||
#### Scenario: Terminal scroll
|
||||
- **WHEN** a user performs a two-finger swipe in the terminal
|
||||
- **THEN** the terminal scrollback buffer scrolls
|
||||
- **AND** the browser page does not scroll
|
||||
|
||||
#### Scenario: Text selection
|
||||
- **WHEN** a user long-presses in the terminal
|
||||
- **THEN** xterm.js native selection behavior is used
|
||||
- **AND** browser native text selection UI is suppressed
|
||||
|
||||
### Requirement: Screen Orientation
|
||||
|
||||
The system SHALL handle device orientation changes gracefully.
|
||||
|
||||
#### Scenario: Orientation change
|
||||
- **WHEN** a user rotates their device
|
||||
- **THEN** the terminal recalculates dimensions after a 250ms debounce
|
||||
- **AND** the new dimensions are sent to the backend via WebSocket resize message
|
||||
|
||||
### Requirement: Copy and Paste
|
||||
|
||||
The system SHALL provide copy and paste functionality on mobile devices.
|
||||
|
||||
#### Scenario: Copy button
|
||||
- **WHEN** a user selects text in the terminal
|
||||
- **THEN** a "Copy" button appears in the header
|
||||
- **AND** tapping it copies the selection to clipboard
|
||||
|
||||
#### Scenario: Paste button
|
||||
- **WHEN** a user taps a "Paste" button in the header or special keys panel
|
||||
- **THEN** the system attempts to read from the clipboard
|
||||
- **AND** pastes the content into the terminal
|
||||
|
||||
### Requirement: Font Scaling
|
||||
|
||||
The system SHALL provide readable font sizes on mobile devices.
|
||||
|
||||
#### Scenario: Mobile font size
|
||||
- **WHEN** the terminal is displayed on a mobile device
|
||||
- **THEN** the font size is at least 16px
|
||||
- **AND** the font size scales proportionally with viewport width (min 16px, max 24px)
|
||||
|
||||
#### Scenario: Font size preference
|
||||
- **WHEN** a user changes the font size
|
||||
- **THEN** the preference is persisted in localStorage
|
||||
- **AND** applied on subsequent terminal sessions
|
||||
@@ -0,0 +1,123 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Terminal Resize
|
||||
|
||||
The system SHALL support terminal resize events.
|
||||
|
||||
#### Scenario: Resize terminal
|
||||
- **GIVEN** an active terminal session
|
||||
- **WHEN** the browser window is resized
|
||||
- **THEN** the terminal dimensions (COLS, ROWS) are updated
|
||||
- **AND** the shell receives the new size
|
||||
- **AND** on mobile devices, the resize is debounced by 250ms
|
||||
|
||||
#### Scenario: Mobile keyboard resize
|
||||
- **GIVEN** an active terminal session on a mobile device
|
||||
- **WHEN** the virtual keyboard appears or disappears
|
||||
- **THEN** the terminal container height adjusts to fit the visible viewport
|
||||
- **AND** the terminal is refitted with new dimensions
|
||||
|
||||
### Requirement: WebSocket Terminal
|
||||
|
||||
The system SHALL provide terminal sessions via WebSocket.
|
||||
|
||||
#### Scenario: Mobile reconnection
|
||||
- **GIVEN** a terminal session on a mobile device
|
||||
- **WHEN** the WebSocket disconnects due to network change or backgrounding
|
||||
- **THEN** the terminal shows a "Reconnecting..." status
|
||||
- **AND** attempts to reconnect automatically
|
||||
- **AND** if reconnection fails after 3 attempts, shows an error with a manual reconnect option
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Mobile Terminal Layout
|
||||
|
||||
The system SHALL provide a fullscreen terminal experience on mobile devices.
|
||||
|
||||
#### Scenario: Mobile terminal page
|
||||
- **WHEN** a user navigates to an instance terminal page on a mobile device (viewport width < 768px)
|
||||
- **THEN** the AppShell chrome is collapsed to a minimal header
|
||||
- **AND** the terminal occupies the full viewport below the header
|
||||
- **AND** the header auto-hides after 3 seconds of inactivity
|
||||
- **AND** tapping the terminal area toggles header visibility
|
||||
|
||||
#### Scenario: Collapsed AppShell header
|
||||
- **WHEN** the terminal is in mobile mode
|
||||
- **THEN** the header displays:
|
||||
- A back button
|
||||
- A hamburger menu toggle (reveals navigation)
|
||||
- The instance name
|
||||
- A close button
|
||||
- **AND** the sidebar navigation is hidden by default
|
||||
|
||||
### Requirement: Special Keys Toolbar
|
||||
|
||||
The system SHALL provide access to special terminal keys on mobile devices.
|
||||
|
||||
#### Scenario: Special keys strip
|
||||
- **WHEN** a user is on a mobile terminal
|
||||
- **THEN** a strip of special keys is available at the bottom of the screen
|
||||
- **AND** the strip contains: Esc, Tab, Ctrl, Alt, Up, Down, Left, Right
|
||||
- **AND** the strip auto-hides after 3 seconds of inactivity
|
||||
- **AND** swiping up from the bottom reveals the strip
|
||||
- **AND** tapping the terminal area hides the strip
|
||||
|
||||
#### Scenario: Expanded special keys panel
|
||||
- **WHEN** a user taps the "More" button on the special keys strip
|
||||
- **THEN** an expanded panel appears with additional keys:
|
||||
- Home, End, Page Up, Page Down
|
||||
- Ctrl+C, Ctrl+D, Ctrl+Z
|
||||
- F1 through F12
|
||||
- **AND** tapping outside the panel closes it
|
||||
|
||||
#### Scenario: Sending special keys
|
||||
- **WHEN** a user taps a special key
|
||||
- **THEN** the corresponding escape sequence is sent via WebSocket
|
||||
- **AND** the key press is visually acknowledged (brief highlight)
|
||||
|
||||
### Requirement: Touch Gestures
|
||||
|
||||
The system SHALL support touch interactions in the terminal.
|
||||
|
||||
#### Scenario: Disable browser zoom
|
||||
- **WHEN** a user is on a mobile terminal page
|
||||
- **THEN** browser zoom is disabled via `meta viewport` tag with `user-scalable=no`
|
||||
- **AND** pinch gestures do not zoom the page
|
||||
|
||||
#### Scenario: Terminal scroll
|
||||
- **WHEN** a user performs a two-finger swipe in the terminal
|
||||
- **THEN** the terminal scrollback buffer scrolls
|
||||
- **AND** the browser page does not scroll
|
||||
|
||||
#### Scenario: Text selection
|
||||
- **WHEN** a user long-presses in the terminal
|
||||
- **THEN** xterm.js native selection behavior is used
|
||||
- **AND** browser native text selection UI is suppressed
|
||||
|
||||
### Requirement: Copy and Paste
|
||||
|
||||
The system SHALL provide copy and paste functionality on mobile devices.
|
||||
|
||||
#### Scenario: Copy button
|
||||
- **WHEN** a user selects text in the terminal
|
||||
- **THEN** a "Copy" button appears in the header
|
||||
- **AND** tapping it copies the selection to clipboard
|
||||
|
||||
#### Scenario: Paste button
|
||||
- **WHEN** a user taps a "Paste" button in the header or special keys panel
|
||||
- **THEN** the system attempts to read from the clipboard
|
||||
- **AND** pastes the content into the terminal
|
||||
|
||||
### Requirement: Font Scaling
|
||||
|
||||
The system SHALL provide readable font sizes on mobile devices.
|
||||
|
||||
#### Scenario: Mobile font size
|
||||
- **WHEN** the terminal is displayed on a mobile device
|
||||
- **THEN** the font size is at least 16px
|
||||
- **AND** the font size scales proportionally with viewport width (min 16px, max 24px)
|
||||
|
||||
#### Scenario: Font size preference
|
||||
- **WHEN** a user changes the font size
|
||||
- **THEN** the preference is persisted in localStorage
|
||||
- **AND** applied on subsequent terminal sessions
|
||||
@@ -0,0 +1,57 @@
|
||||
## 1. Mobile Detection and Hooks
|
||||
|
||||
- [x] 1.1 Create `useMobileViewport` hook for detecting mobile viewport (< 768px)
|
||||
- [x] 1.2 Create `useVirtualKeyboard` hook using Visual Viewport API with fallback
|
||||
- [x] 1.3 Create `useAutoHide` hook for managing auto-hide visibility with tap/swipe detection
|
||||
- [x] 1.4 Create `useSpecialKeys` hook for mapping special keys to escape sequences
|
||||
|
||||
## 2. Mobile Terminal Components
|
||||
|
||||
- [x] 2.1 Create `MobileTerminalHeader` component with back button, menu toggle, instance name, close button
|
||||
- [x] 2.2 Create `SpecialKeysStrip` component with primary keys (Esc, Tab, Ctrl, Alt, Arrows)
|
||||
- [x] 2.3 Create `SpecialKeysPanel` expanded component with Home/End/PgUp/PgDn/Ctrl combos/F-keys
|
||||
- [x] 2.4 Create `MobileTerminalWrapper` component that composes header, terminal, and keys strip
|
||||
- [x] 2.5 Add hidden input element for maintaining virtual keyboard focus
|
||||
|
||||
## 3. Terminal Component Modifications
|
||||
|
||||
- [x] 3.1 Update `TerminalComponent` to accept mobile mode prop and adjust font size
|
||||
- [x] 3.2 Add dynamic font scaling based on viewport width (16px min, 24px max)
|
||||
- [x] 3.3 Add localStorage persistence for font size preference
|
||||
- [x] 3.4 Implement debounced resize handler (250ms) for orientation changes
|
||||
- [x] 3.5 Add touch-action: none and disable browser zoom on mobile
|
||||
- [x] 3.6 Add copy/paste buttons to terminal header for mobile
|
||||
|
||||
## 4. AppShell and Page Integration
|
||||
|
||||
- [x] 4.1 Update `AppShell` to detect terminal routes and render minimal header on mobile
|
||||
- [x] 4.2 Update `TerminalPage` to use `MobileTerminalWrapper` when on mobile viewport
|
||||
- [x] 4.3 Add CSS transitions for header show/hide animations
|
||||
- [x] 4.4 Ensure desktop terminal experience is unchanged
|
||||
|
||||
## 5. WebSocket and Reconnection
|
||||
|
||||
- [x] 5.1 Implement WebSocket reconnection with exponential backoff (max 3 attempts)
|
||||
- [x] 5.2 Add "Reconnecting..." status indicator in terminal header
|
||||
- [x] 5.3 Add manual reconnect button on connection failure
|
||||
- [x] 5.4 Use Visibility API to reconnect when app returns from background
|
||||
|
||||
## 6. Styling
|
||||
|
||||
- [x] 6.1 Add mobile terminal CSS variables and layout styles
|
||||
- [x] 6.2 Style special keys strip with touch-friendly targets (min 44px height)
|
||||
- [x] 6.3 Style expanded keys panel as bottom sheet
|
||||
- [x] 6.4 Add dark theme support for mobile terminal chrome
|
||||
- [x] 6.5 Ensure proper z-index layering (terminal content above keys strip above keyboard)
|
||||
|
||||
## 7. Testing and Verification
|
||||
|
||||
- [x] 7.1 Run `npm run typecheck` and fix errors
|
||||
- [x] 7.2 Run `npm run lint` and fix warnings
|
||||
- [x] 7.3 Run `npm run build` successfully
|
||||
- [ ] 7.4 Test on actual mobile device (iOS Safari)
|
||||
- [ ] 7.5 Test on actual mobile device (Android Chrome)
|
||||
- [x] 7.6 Verify desktop terminal is unchanged
|
||||
- [ ] 7.7 Test screen rotation handling
|
||||
- [ ] 7.8 Test virtual keyboard appearance/disappearance
|
||||
- [ ] 7.9 Verify copy/paste functionality
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
### Requirement: Tool Type Model
|
||||
|
||||
The system SHALL store tool type definitions in the database.
|
||||
The system SHALL store tool type definitions in the database without built-in vs custom distinction.
|
||||
|
||||
#### Scenario: Create tool type
|
||||
- GIVEN an admin user
|
||||
@@ -24,14 +24,8 @@ The system SHALL store tool type definitions in the database.
|
||||
|
||||
### Requirement: Built-in Tools
|
||||
|
||||
The system SHALL include default tool types.
|
||||
|
||||
#### Scenario: Built-in tools
|
||||
- GIVEN a fresh installation
|
||||
- THEN these tool types are pre-configured:
|
||||
- code-server: VS Code in browser (port 8443, interfaces: ["web"])
|
||||
- jupyter-notebook: Jupyter notebooks (port 8888, interfaces: ["web"])
|
||||
- opencode: OpenCode agent environment (port 3000, interfaces: ["terminal", "web"])
|
||||
**Reason**: Built-in tools are now regular preconfigured tool types in the database, not special privileged types.
|
||||
**Migration**: Built-in tool types (code-server, jupyter-notebook, opencode) are seeded as regular database records during migration. They can be edited or deleted like any other tool type.
|
||||
|
||||
### Requirement: Template Validation
|
||||
|
||||
|
||||
Reference in New Issue
Block a user