From ae42cac61ed29b287eeba6e5af6b1d8e464d59ea Mon Sep 17 00:00:00 2001 From: Fusion Date: Fri, 22 May 2026 23:55:51 +0200 Subject: [PATCH 01/14] fix: terminal tools always showing as unhealthy For terminal-only tools (no URL), only check container status for overall health instead of requiring tunnel health. Terminal tools do not have tunnels, so tunnel_status stays as 'not_applicable' which was failing the healthy check. --- apps/api/src/api/tool_instances.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 859c87f..568e5fe 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -1217,10 +1217,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: From aebcf25bf47615bdb6198291798a91afc8ce5fff Mon Sep 17 00:00:00 2001 From: Fusion Date: Sat, 23 May 2026 07:29:56 +0200 Subject: [PATCH 02/14] fix: OpenCode instances fail with 'no port configured' error For terminal-only tools like OpenCode, default_port is 0 which is falsy in Python. The code incorrectly treated port 0 as 'not configured' and marked the instance as error. Now we only check if tool_type exists, and default to port 0. Terminal tools skip tunnel creation anyway. --- apps/api/src/api/tool_instances.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 568e5fe..9b0d93c 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -736,17 +736,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) From 507b71c5866e6c3be8a678d29ea44e3c90fa2b26 Mon Sep 17 00:00:00 2001 From: Fusion Date: Sat, 23 May 2026 07:36:46 +0200 Subject: [PATCH 03/14] fix: React crash when opening terminal sessions The backend was returning 'tool_type_interface_type' (string) but the frontend expected 'tool_type_interfaces' (array). This caused undefined.includes() crash when clicking Open on terminal sessions. Changed both list_instances and get_user_sessions to return tool_type_interfaces as an array. Also added clone_mode and branch to get_user_sessions response. --- apps/api/src/api/tool_instances.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 9b0d93c..21845e1 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -390,7 +390,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, @@ -1407,13 +1407,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} From 953ea057563c39a22e8bef7f26e226efc51697ce Mon Sep 17 00:00:00 2001 From: Fusion Date: Sat, 23 May 2026 07:43:51 +0200 Subject: [PATCH 04/14] fix: show all probe attempts including successful ones Remove 500-character truncation on probe output so users can see all attempts including the final successful one. Add probe status indicator (passed/failed/pending) that's always visible when probe data exists. --- apps/api/src/api/tool_instances.py | 2 +- apps/web/src/pages/sessions.tsx | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 21845e1..1bf61e8 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -1206,7 +1206,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"): diff --git a/apps/web/src/pages/sessions.tsx b/apps/web/src/pages/sessions.tsx index bf74bad..0d7b9c2 100644 --- a/apps/web/src/pages/sessions.tsx +++ b/apps/web/src/pages/sessions.tsx @@ -407,17 +407,18 @@ export const SessionsPage = () => { {tunnelHealth[session.id] && tunnelHealth[session.id].tunnel_status === "error_response" && ( app error ({tunnelHealth[session.id].tunnel_status_code}) )} - {tunnelHealth[session.id]?.last_probe_output && ( + {tunnelHealth[session.id]?.probe_status && tunnelHealth[session.id]?.probe_status !== "not_applicable" && (
- {expandedProbeId === session.id && ( + {expandedProbeId === session.id && tunnelHealth[session.id]?.last_probe_output && (
                               {tunnelHealth[session.id].last_probe_output}
                             
From d1be2e4951877b721c00381b5dbd6bf0cbb2b807 Mon Sep 17 00:00:00 2001 From: Fusion Date: Sat, 23 May 2026 07:54:52 +0200 Subject: [PATCH 05/14] feat: add loading indicators for long-running operations Add loading overlay to sessions list during create, stop, delete, and recreate tunnel operations. Show progress messages like 'Creating instance...' and 'Starting container...' during creation. Dim the sessions grid while operations are in progress to prevent user confusion and accidental duplicate actions. --- apps/web/src/pages/sessions.tsx | 41 ++++++++++++++++++++++-- apps/web/src/styles.css | 56 +++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/apps/web/src/pages/sessions.tsx b/apps/web/src/pages/sessions.tsx index 0d7b9c2..9a4573b 100644 --- a/apps/web/src/pages/sessions.tsx +++ b/apps/web/src/pages/sessions.tsx @@ -38,6 +38,7 @@ export const SessionsPage = () => { const [displayName, setDisplayName] = useState(""); const [createStatus, setCreateStatus] = useState("idle"); const [createError, setCreateError] = useState(null); + const [createProgress, setCreateProgress] = useState(""); const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount"); const [branch, setBranch] = useState("main"); @@ -60,6 +61,8 @@ export const SessionsPage = () => { }>>({}); const [recreatingId, setRecreatingId] = useState(null); const [expandedProbeId, setExpandedProbeId] = useState(null); + const [loadingSessionId, setLoadingSessionId] = useState(null); + const [loadingAction, setLoadingAction] = useState(""); const loadSessions = useCallback(async () => { setStatus("loading"); @@ -206,6 +209,7 @@ export const SessionsPage = () => { } setCreateStatus("creating"); + setCreateProgress("Creating instance..."); try { const instance = await createInstance( selectedProject, @@ -216,11 +220,13 @@ export const SessionsPage = () => { cloneMode === "clone" ? branch : undefined ); + setCreateProgress("Starting container..."); // Auto-start the instance await startInstance(selectedProject, selectedRepo, instance.id); await updateUserConfig({ last_session_id: instance.id }); setCreateStatus("idle"); + setCreateProgress(""); setSelectedProject(""); setSelectedRepo(""); setSelectedToolType(""); @@ -230,6 +236,7 @@ export const SessionsPage = () => { await loadSessions(); } catch (error) { setCreateStatus("error"); + setCreateProgress(""); const axiosError = error as { response?: { data?: { detail?: string } } }; const message = axiosError.response?.data?.detail; setCreateError( @@ -239,16 +246,23 @@ export const SessionsPage = () => { }; 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); @@ -268,11 +282,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, @@ -284,7 +302,8 @@ export const SessionsPage = () => { } catch { // ignore } finally { - setRecreatingId(null); + setLoadingSessionId(null); + setLoadingAction(""); } }; @@ -379,7 +398,23 @@ export const SessionsPage = () => { {activeSessions.length === 0 ? (

No active sessions

) : ( -
+
+ {createStatus === "creating" && ( +
+
+ +

{createProgress || "Creating session..."}

+
+
+ )} + {loadingSessionId && ( +
+
+ +

{loadingAction || "Processing..."}

+
+
+ )} {activeSessions.map((session) => (
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index d7221cf..6131118 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -2717,6 +2717,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; From 8c5e1b931e819c52f4c9ef92b881790a660c5fd9 Mon Sep 17 00:00:00 2001 From: Fusion Date: Sat, 23 May 2026 08:04:48 +0200 Subject: [PATCH 06/14] fix: move creation loading indicator outside active sessions grid The loading overlay for instance creation was inside the active sessions grid, which doesn't render when there are no active sessions. Moved the overlay to the parent container so it's always visible during creation regardless of existing sessions. --- apps/web/src/pages/sessions.tsx | 36 ++++++++++++++++----------------- apps/web/src/styles.css | 6 ++++++ 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/apps/web/src/pages/sessions.tsx b/apps/web/src/pages/sessions.tsx index 9a4573b..d7f94f7 100644 --- a/apps/web/src/pages/sessions.tsx +++ b/apps/web/src/pages/sessions.tsx @@ -388,7 +388,23 @@ export const SessionsPage = () => { )} {/* Active Sessions */} -
+
+ {createStatus === "creating" && ( +
+
+ +

{createProgress || "Creating session..."}

+
+
+ )} + {loadingSessionId && ( +
+
+ +

{loadingAction || "Processing..."}

+
+
+ )}

Active Sessions {activeSessions.length > 0 && ( @@ -398,23 +414,7 @@ export const SessionsPage = () => { {activeSessions.length === 0 ? (

No active sessions

) : ( -
- {createStatus === "creating" && ( -
-
- -

{createProgress || "Creating session..."}

-
-
- )} - {loadingSessionId && ( -
-
- -

{loadingAction || "Processing..."}

-
-
- )} +
{activeSessions.map((session) => (
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 6131118..9f93cb0 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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; From 18646e3d1bd4ee0c45f1a393d700c3b1a95c85c7 Mon Sep 17 00:00:00 2001 From: Fusion Date: Sat, 23 May 2026 08:08:24 +0200 Subject: [PATCH 07/14] fix: move creation loading indicator to create session form Move the loading overlay from the active sessions section to the create session section so it dims the form itself during creation, providing better visual feedback to the user. --- apps/web/src/pages/sessions.tsx | 28 ++++++++++------------------ apps/web/src/styles.css | 6 ++++++ 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/apps/web/src/pages/sessions.tsx b/apps/web/src/pages/sessions.tsx index d7f94f7..d4723bd 100644 --- a/apps/web/src/pages/sessions.tsx +++ b/apps/web/src/pages/sessions.tsx @@ -388,23 +388,7 @@ export const SessionsPage = () => { )} {/* Active Sessions */} -
- {createStatus === "creating" && ( -
-
- -

{createProgress || "Creating session..."}

-
-
- )} - {loadingSessionId && ( -
-
- -

{loadingAction || "Processing..."}

-
-
- )} +

Active Sessions {activeSessions.length > 0 && ( @@ -638,7 +622,15 @@ export const SessionsPage = () => { )} {/* Create Session */} -
+
+ {createStatus === "creating" && ( +
+
+ +

{createProgress || "Creating session..."}

+
+
+ )}

Create New Session

diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 9f93cb0..57157b0 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -2825,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; } From cd4eba98033cea0cf24f77f46d053b50694b18e1 Mon Sep 17 00:00:00 2001 From: Fusion Date: Sat, 23 May 2026 08:12:15 +0200 Subject: [PATCH 08/14] fix: restore loading overlay for delete/stop operations on active sessions --- apps/web/src/pages/sessions.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/web/src/pages/sessions.tsx b/apps/web/src/pages/sessions.tsx index d4723bd..225f1e8 100644 --- a/apps/web/src/pages/sessions.tsx +++ b/apps/web/src/pages/sessions.tsx @@ -388,7 +388,15 @@ export const SessionsPage = () => { )} {/* Active Sessions */} -
+
+ {loadingSessionId && ( +
+
+ +

{loadingAction}

+
+
+ )}

Active Sessions {activeSessions.length > 0 && ( From 2e9ca52cdbd9687c2facb22736bc5ebd01586ab7 Mon Sep 17 00:00:00 2001 From: Fusion Date: Sat, 23 May 2026 16:26:35 +0200 Subject: [PATCH 09/14] refactor: unify session creation form into CreateSessionForm component --- .../src/components/create-session-form.tsx | 283 ++++++++++++++++++ apps/web/src/components/instance-list.tsx | 71 +---- apps/web/src/pages/dashboard.tsx | 71 +---- apps/web/src/pages/sessions.tsx | 251 ++-------------- 4 files changed, 327 insertions(+), 349 deletions(-) create mode 100644 apps/web/src/components/create-session-form.tsx diff --git a/apps/web/src/components/create-session-form.tsx b/apps/web/src/components/create-session-form.tsx new file mode 100644 index 0000000..8f20af0 --- /dev/null +++ b/apps/web/src/components/create-session-form.tsx @@ -0,0 +1,283 @@ +import { useState, useEffect } from "react"; +import { Icon } from "./icon"; +import { createInstance, startInstance, type ToolInstance } from "../api/sessions"; +import type { Project } from "../types"; +import type { GitRepository } 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; + showCloneMode?: boolean; + onProjectChange?: (projectId: string) => void; + onSuccess?: (instance: ToolInstance) => void; + onCancel?: () => void; + submitLabel?: string; + className?: string; +} + +export const CreateSessionForm = ({ + projects, + repositories, + toolTypes, + fixedProjectId, + fixedRepoId, + showCloneMode = false, + 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([]); + + const [status, setStatus] = useState<"idle" | "creating" | "error">("idle"); + const [progress, setProgress] = useState(""); + const [error, setError] = useState(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]); + + // 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" ? branch : undefined + ); + + setProgress("Starting container..."); + await startInstance(projectId, repoId, instance.id); + + // Reset form + if (!fixedProjectId) setSelectedProject(""); + if (!fixedRepoId) setSelectedRepo(""); + setSelectedToolType(""); + setDisplayName(""); + setCloneMode("mount"); + setBranch("main"); + setStatus("idle"); + + onSuccess?.(instance); + } catch { + setStatus("error"); + setError("Failed to create session"); + setProgress(""); + } + }; + + const isSubmitting = status === "creating"; + + return ( +
+ {isSubmitting && ( +
+
+ +

{progress || "Creating session..."}

+
+
+ )} + + +
+ {!fixedProjectId && ( + + )} + + {!fixedRepoId && ( + + )} + + +
+ + {showCloneMode && ( +
+ + + {cloneMode === "clone" && ( + + )} +
+ )} + + + + {error &&

{error}

} + +
+ {onCancel && ( + + )} + +
+ +
+ ); +}; diff --git a/apps/web/src/components/instance-list.tsx b/apps/web/src/components/instance-list.tsx index d9ec05a..7cba02b 100644 --- a/apps/web/src/components/instance-list.tsx +++ b/apps/web/src/components/instance-list.tsx @@ -4,7 +4,6 @@ import { Icon } from "./icon"; import type { ToolInstance } from "../api/sessions"; import { checkInstanceHealth, - createInstance, deleteInstance, listInstances, recreateInstanceTunnel, @@ -13,6 +12,7 @@ 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"; @@ -27,8 +27,6 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps const [instances, setInstances] = useState([]); const [loading, setLoading] = useState(false); const [showCreate, setShowCreate] = useState(false); - const [selectedToolType, setSelectedToolType] = useState(""); - const [displayName, setDisplayName] = useState(""); const [error, setError] = useState(null); // Stop confirmation @@ -83,18 +81,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 +298,16 @@ export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps

Launch Tool

-
- - -
- - -
-
+ setShowCreate(false)} + submitLabel="Launch" + />
)} diff --git a/apps/web/src/pages/dashboard.tsx b/apps/web/src/pages/dashboard.tsx index eea233a..77b46a1 100644 --- a/apps/web/src/pages/dashboard.tsx +++ b/apps/web/src/pages/dashboard.tsx @@ -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([]); const [toolTypes, setToolTypes] = useState([]); 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(null); const [stopConfirmId, setStopConfirmId] = useState(null); const [deleteConfirmId, setDeleteConfirmId] = useState(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 = () => {

Start a session

-
-
- - - -
- -
- - {saveState === "error" && Failed to create session} -
-
+ setSelectedProject(projectId)} + onSuccess={handleCreateSuccess} + /> {recentSessions.length > 0 && ( diff --git a/apps/web/src/pages/sessions.tsx b/apps/web/src/pages/sessions.tsx index 225f1e8..2ba2e54 100644 --- a/apps/web/src/pages/sessions.tsx +++ b/apps/web/src/pages/sessions.tsx @@ -14,13 +14,11 @@ import { 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,18 +29,7 @@ export const SessionsPage = () => { const [projects, setProjects] = useState([]); const [repositories, setRepositories] = useState([]); const [toolTypes, setToolTypes] = useState([]); - const [selectedProject, setSelectedProject] = useState(""); - const [selectedRepo, setSelectedRepo] = useState(""); - const [selectedToolType, setSelectedToolType] = useState(""); - const [displayName, setDisplayName] = useState(""); - const [createStatus, setCreateStatus] = useState("idle"); - const [createError, setCreateError] = useState(null); - const [createProgress, setCreateProgress] = useState(""); - - const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount"); - const [branch, setBranch] = useState("main"); - const [sshKeys, setSshKeys] = useState([]); const [dirtyDeleteSession, setDirtyDeleteSession] = useState(null); const [dirtyDeleteFiles, setDirtyDeleteFiles] = useState([]); @@ -107,17 +94,7 @@ export const SessionsPage = () => { void loadToolTypes(); }, []); - useEffect(() => { - const loadSshKeys = async () => { - try { - const data = await listSSHKeys(); - setSshKeys(data); - } catch { - // ignore - } - }; - void loadSshKeys(); - }, []); + // Poll health every 30 seconds for active instances useEffect(() => { @@ -191,58 +168,10 @@ 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"); - setCreateProgress("Creating instance..."); - try { - const instance = await createInstance( - selectedProject, - selectedRepo, - selectedToolType, - displayName || undefined, - cloneMode, - cloneMode === "clone" ? branch : undefined - ); - - setCreateProgress("Starting container..."); - // Auto-start the instance - await startInstance(selectedProject, selectedRepo, instance.id); - - await updateUserConfig({ last_session_id: instance.id }); - setCreateStatus("idle"); - setCreateProgress(""); - setSelectedProject(""); - setSelectedRepo(""); - setSelectedToolType(""); - setDisplayName(""); - setCloneMode("mount"); - setBranch("main"); - await loadSessions(); - } catch (error) { - setCreateStatus("error"); - setCreateProgress(""); - 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) => { @@ -630,164 +559,18 @@ export const SessionsPage = () => { )} {/* Create Session */} -
- {createStatus === "creating" && ( -
-
- -

{createProgress || "Creating session..."}

-
-
- )} +

Create New Session

-
-
- - - - - -
- -
- - - {cloneMode === "clone" && ( - <> - - - {selectedRepo && ( -
- {(() => { - 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 ( - - SSH key: {key?.name || "Assigned"} - - ); - } - return ( - - No SSH key assigned to this repository. Clone mode requires an SSH key. - - ); - })()} -
- )} - - )} -
- - - - {createError &&

{createError}

} - -
- -
-
+ { + setSelectedProject(projectId); + }} + onSuccess={handleCreateSuccess} + />
{/* Dirty Delete Confirmation Modal */} From 01adc9a00fe2b50bc2f7839f746aeb2d605385f1 Mon Sep 17 00:00:00 2001 From: Fusion Date: Sat, 23 May 2026 19:53:12 +0200 Subject: [PATCH 10/14] refactor: unify create session forms - show clone mode everywhere and display fixed fields as read-only --- .../src/components/create-session-form.tsx | 32 +++++++++++++++++-- apps/web/src/components/instance-list.tsx | 6 +++- apps/web/src/pages/repo-workspace.tsx | 2 ++ apps/web/src/pages/sessions.tsx | 1 - 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/create-session-form.tsx b/apps/web/src/components/create-session-form.tsx index 8f20af0..392f1b0 100644 --- a/apps/web/src/components/create-session-form.tsx +++ b/apps/web/src/components/create-session-form.tsx @@ -12,7 +12,10 @@ interface CreateSessionFormProps { toolTypes: ToolType[]; fixedProjectId?: string; fixedRepoId?: string; + projectName?: string; + repoName?: string; showCloneMode?: boolean; + showFixedFields?: boolean; onProjectChange?: (projectId: string) => void; onSuccess?: (instance: ToolInstance) => void; onCancel?: () => void; @@ -26,7 +29,10 @@ export const CreateSessionForm = ({ toolTypes, fixedProjectId, fixedRepoId, - showCloneMode = false, + projectName, + repoName, + showCloneMode = true, + showFixedFields = true, onProjectChange, onSuccess, onCancel, @@ -132,7 +138,17 @@ export const CreateSessionForm = ({
- {!fixedProjectId && ( + {fixedProjectId && showFixedFields ? ( + + ) : ( + ) : (
); }; diff --git a/apps/web/src/hooks/use-auto-hide.ts b/apps/web/src/hooks/use-auto-hide.ts new file mode 100644 index 0000000..9ec31ce --- /dev/null +++ b/apps/web/src/hooks/use-auto-hide.ts @@ -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 | 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, + }; +} diff --git a/apps/web/src/hooks/use-mobile-viewport.ts b/apps/web/src/hooks/use-mobile-viewport.ts new file mode 100644 index 0000000..5335b60 --- /dev/null +++ b/apps/web/src/hooks/use-mobile-viewport.ts @@ -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; +} diff --git a/apps/web/src/hooks/use-special-keys.ts b/apps/web/src/hooks/use-special-keys.ts new file mode 100644 index 0000000..c0527c4 --- /dev/null +++ b/apps/web/src/hooks/use-special-keys.ts @@ -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 = { + 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 }; diff --git a/apps/web/src/hooks/use-virtual-keyboard.ts b/apps/web/src/hooks/use-virtual-keyboard.ts new file mode 100644 index 0000000..0d8cca0 --- /dev/null +++ b/apps/web/src/hooks/use-virtual-keyboard.ts @@ -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({ + 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; +} diff --git a/apps/web/src/pages/terminal.tsx b/apps/web/src/pages/terminal.tsx index 65686d5..e5801df 100644 --- a/apps/web/src/pages/terminal.tsx +++ b/apps/web/src/pages/terminal.tsx @@ -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 ( + navigate(-1)} + onClose={() => navigate(-1)} + /> + ); + } + return (
@@ -24,7 +36,6 @@ export const TerminalPage: React.FC = () => { onClick={() => navigate(-1)} type="button" > - Back

Terminal

@@ -32,6 +43,7 @@ export const TerminalPage: React.FC = () => { navigate(-1)} + isMobile={false} />
); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 57157b0..2bcf502 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -2873,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); + } +} diff --git a/openspec/changes/mobile-terminal-ux/.openspec.yaml b/openspec/changes/mobile-terminal-ux/.openspec.yaml new file mode 100644 index 0000000..6894814 --- /dev/null +++ b/openspec/changes/mobile-terminal-ux/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-24 diff --git a/openspec/changes/mobile-terminal-ux/design.md b/openspec/changes/mobile-terminal-ux/design.md new file mode 100644 index 0000000..ea6b0d1 --- /dev/null +++ b/openspec/changes/mobile-terminal-ux/design.md @@ -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? diff --git a/openspec/changes/mobile-terminal-ux/proposal.md b/openspec/changes/mobile-terminal-ux/proposal.md new file mode 100644 index 0000000..497c2d6 --- /dev/null +++ b/openspec/changes/mobile-terminal-ux/proposal.md @@ -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 diff --git a/openspec/changes/mobile-terminal-ux/specs/frontend-foundation/spec.md b/openspec/changes/mobile-terminal-ux/specs/frontend-foundation/spec.md new file mode 100644 index 0000000..4df005d --- /dev/null +++ b/openspec/changes/mobile-terminal-ux/specs/frontend-foundation/spec.md @@ -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 diff --git a/openspec/changes/mobile-terminal-ux/specs/mobile-terminal-ux/spec.md b/openspec/changes/mobile-terminal-ux/specs/mobile-terminal-ux/spec.md new file mode 100644 index 0000000..b4b7823 --- /dev/null +++ b/openspec/changes/mobile-terminal-ux/specs/mobile-terminal-ux/spec.md @@ -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 diff --git a/openspec/changes/mobile-terminal-ux/specs/tool-terminal/spec.md b/openspec/changes/mobile-terminal-ux/specs/tool-terminal/spec.md new file mode 100644 index 0000000..92736e6 --- /dev/null +++ b/openspec/changes/mobile-terminal-ux/specs/tool-terminal/spec.md @@ -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 diff --git a/openspec/changes/mobile-terminal-ux/tasks.md b/openspec/changes/mobile-terminal-ux/tasks.md new file mode 100644 index 0000000..e02c647 --- /dev/null +++ b/openspec/changes/mobile-terminal-ux/tasks.md @@ -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 From aea1ff95f66f4cca50a47394dd970cc8be4d3b9c Mon Sep 17 00:00:00 2001 From: Fusion Date: Sun, 24 May 2026 11:35:32 +0200 Subject: [PATCH 13/14] fix: prevent infinite terminal re-initialization loop - Remove status from TerminalComponent useEffect dependencies to prevent recreation on WebSocket status changes - Use ref for onTerminalReady callback to avoid parent re-renders triggering terminal recreation - Wrap MobileTerminalWrapper onTerminalReady with useCallback for stable reference --- apps/web/src/components/mobile-terminal-wrapper.tsx | 11 ++++++++--- apps/web/src/components/terminal.tsx | 8 +++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/mobile-terminal-wrapper.tsx b/apps/web/src/components/mobile-terminal-wrapper.tsx index a6b827b..94479af 100644 --- a/apps/web/src/components/mobile-terminal-wrapper.tsx +++ b/apps/web/src/components/mobile-terminal-wrapper.tsx @@ -39,6 +39,13 @@ export const MobileTerminalWrapper: React.FC = ({ keysAutoHide.toggle(); }, [headerAutoHide, keysAutoHide]); + const handleTerminalReady = useCallback( + (sendData: (data: string) => void, connectionStatus: "connecting" | "connected" | "disconnected" | "error") => { + setTerminalRef({ sendData, connectionStatus }); + }, + [] + ); + const handleSendKey = useCallback( (data: string) => { terminalRef?.sendData(data); @@ -78,9 +85,7 @@ export const MobileTerminalWrapper: React.FC = ({ instanceId={instanceId} onClose={onClose} isMobile={true} - onTerminalReady={(sendData, connectionStatus) => - setTerminalRef({ sendData, connectionStatus }) - } + onTerminalReady={handleTerminalReady} />
diff --git a/apps/web/src/components/terminal.tsx b/apps/web/src/components/terminal.tsx index bd178cc..1e0d62c 100644 --- a/apps/web/src/components/terminal.tsx +++ b/apps/web/src/components/terminal.tsx @@ -32,6 +32,8 @@ export const TerminalComponent: React.FC = ({ const termRef = useRef(null); const fitAddonRef = useRef(null); const reconnectAttemptsRef = useRef(0); + const onTerminalReadyRef = useRef(onTerminalReady); + onTerminalReadyRef.current = onTerminalReady; const [status, setStatus] = useState< "connecting" | "connected" | "disconnected" | "error" >("connecting"); @@ -188,13 +190,13 @@ export const TerminalComponent: React.FC = ({ setTimeout(handleResize, 100); // Notify parent about terminal readiness - if (onTerminalReady) { + if (onTerminalReadyRef.current) { const sendData = (data: string) => { if (ws.readyState === WebSocket.OPEN) { ws.send(data); } }; - onTerminalReady(sendData, status); + onTerminalReadyRef.current(sendData, status); } // Visibility API for reconnection @@ -213,7 +215,7 @@ export const TerminalComponent: React.FC = ({ ws.close(); term.dispose(); }; - }, [instanceId, connectWebSocket, onTerminalReady, status, calculateFontSize]); + }, [instanceId, connectWebSocket, calculateFontSize]); // Update parent about status changes useEffect(() => { From 7e0df57f8c71c6db627093596e4d4834a317dd05 Mon Sep 17 00:00:00 2001 From: Fusion Date: Sun, 24 May 2026 11:57:55 +0200 Subject: [PATCH 14/14] fix: keep virtual keyboard open when tapping special keys - Use onPointerDown with preventDefault() instead of onClick - Add onKeepFocus callback to SpecialKeysStrip and SpecialKeysPanel - Expose focusInput via onTerminalReady in TerminalComponent - MobileTerminalWrapper passes focus callback to keep keyboard open --- .../components/mobile-terminal-wrapper.tsx | 7 +++++-- .../web/src/components/special-keys-panel.tsx | 19 +++++++++++-------- .../web/src/components/special-keys-strip.tsx | 18 ++++++++++++++++-- apps/web/src/components/terminal.tsx | 13 ++++++++++--- 4 files changed, 42 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/mobile-terminal-wrapper.tsx b/apps/web/src/components/mobile-terminal-wrapper.tsx index 94479af..3e0e7d3 100644 --- a/apps/web/src/components/mobile-terminal-wrapper.tsx +++ b/apps/web/src/components/mobile-terminal-wrapper.tsx @@ -29,6 +29,7 @@ export const MobileTerminalWrapper: React.FC = ({ const [terminalRef, setTerminalRef] = useState<{ sendData: (data: string) => void; connectionStatus: "connecting" | "connected" | "disconnected" | "error"; + focusInput: () => void; } | null>(null); const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile }); @@ -40,8 +41,8 @@ export const MobileTerminalWrapper: React.FC = ({ }, [headerAutoHide, keysAutoHide]); const handleTerminalReady = useCallback( - (sendData: (data: string) => void, connectionStatus: "connecting" | "connected" | "disconnected" | "error") => { - setTerminalRef({ sendData, connectionStatus }); + (sendData: (data: string) => void, connectionStatus: "connecting" | "connected" | "disconnected" | "error", focusInput: () => void) => { + setTerminalRef({ sendData, connectionStatus, focusInput }); }, [] ); @@ -93,12 +94,14 @@ export const MobileTerminalWrapper: React.FC = ({ onSend={handleSendKey} isVisible={keysAutoHide.isVisible && !showPanel} onMoreClick={() => setShowPanel(true)} + onKeepFocus={() => terminalRef?.focusInput()} /> setShowPanel(false)} + onKeepFocus={() => terminalRef?.focusInput()} />
); diff --git a/apps/web/src/components/special-keys-panel.tsx b/apps/web/src/components/special-keys-panel.tsx index 9e52492..0136f54 100644 --- a/apps/web/src/components/special-keys-panel.tsx +++ b/apps/web/src/components/special-keys-panel.tsx @@ -5,6 +5,7 @@ interface SpecialKeysPanelProps { onSend: (data: string) => void; isOpen: boolean; onClose: () => void; + onKeepFocus?: () => void; } const EXPANDED_KEYS: { key: SpecialKey; label: string }[] = [ @@ -36,11 +37,19 @@ export const SpecialKeysPanel: React.FC = ({ 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 (
= ({