From 5a8eca814d5a26e8e2e4e20ac3c98c3e63368328 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 2 Jun 2026 12:11:19 +0000 Subject: [PATCH] fix: terminal left shift and instance naming scheme - Debounce terminal ResizeObserver (100ms) and only send resize when cols/rows actually change - Send initial resize on WebSocket connect/reconnect to prevent PTY default 80x24 shift - Replace random hex instance names with sequential project-tool-NNN naming - Add _sanitize_name() and _generate_instance_name() helpers for readable Docker names --- apps/api/src/api/tool_instances.py | 41 ++++++++++++++++++++++++++-- apps/web/src/components/terminal.tsx | 27 ++++++++++++++++-- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/apps/api/src/api/tool_instances.py b/apps/api/src/api/tool_instances.py index 3b09f45..ae08753 100644 --- a/apps/api/src/api/tool_instances.py +++ b/apps/api/src/api/tool_instances.py @@ -2,6 +2,7 @@ import logging import os +import re import uuid from datetime import datetime @@ -206,6 +207,42 @@ async def _get_owned_project( return project +def _sanitize_name(name: str) -> str: + """Sanitize a string for use in Docker/container names.""" + sanitized = re.sub(r"[^a-z0-9-]", "-", name.lower()) + sanitized = re.sub(r"-+", "-", sanitized) + return sanitized.strip("-") + + +async def _generate_instance_name( + session: AsyncSession, + project_name: str, + tool_type_name: str, +) -> str: + """Generate a unique instance name: project-tool-NUM. + + Args: + session: Database session. + project_name: Name of the project. + tool_type_name: Name of the tool type. + + Returns: + A unique instance name with a sequential 3-digit number. + """ + base = f"{_sanitize_name(project_name)}-{_sanitize_name(tool_type_name)}" + base = base.strip("-") or "instance" + result = await session.execute( + select(ToolInstance.name).where(ToolInstance.name.like(f"{base}-%")) + ) + names = result.scalars().all() + max_num = 0 + for name in names: + parts = name.rsplit("-", 1) + if len(parts) == 2 and parts[0] == base and parts[1].isdigit(): + max_num = max(max_num, int(parts[1])) + return f"{base}-{max_num + 1:03d}" + + @router.post( "/{project_id}/repositories/{repo_id}/instances", summary="Create tool instance", @@ -278,8 +315,8 @@ async def create_instance( ) try: - # Generate unique name - instance_name = f"{tool_type.name}-{repo.name}-{uuid.uuid4().hex[:8]}" + # Generate unique name: project-tool-NUM + instance_name = await _generate_instance_name(session, _project.name, tool_type.name) instance_display = data.display_name or f"{tool_type.display_name} - {repo.name}" # Create instance directory diff --git a/apps/web/src/components/terminal.tsx b/apps/web/src/components/terminal.tsx index eabfb96..d9510d0 100644 --- a/apps/web/src/components/terminal.tsx +++ b/apps/web/src/components/terminal.tsx @@ -177,15 +177,28 @@ export const TerminalComponent: React.FC = ({ }); // Resize observer for container-level resize detection + let resizeTimeout: ReturnType | null = null; const resizeObserver = new ResizeObserver(() => { - fitAddon.fit(); - const { cols, rows } = term; - sendResize(cols, rows); + if (resizeTimeout) { + clearTimeout(resizeTimeout); + } + resizeTimeout = setTimeout(() => { + resizeTimeout = null; + const prevCols = term.cols; + const prevRows = term.rows; + fitAddon.fit(); + if (term.cols !== prevCols || term.rows !== prevRows) { + sendResize(term.cols, term.rows); + } + }, 100); }); resizeObserver.observe(terminalRef.current); resizeObserverRef.current = resizeObserver; return () => { + if (resizeTimeout) { + clearTimeout(resizeTimeout); + } disposable.dispose(); resizeObserver.disconnect(); term.dispose(); @@ -195,6 +208,14 @@ export const TerminalComponent: React.FC = ({ }; }, [instanceId, isDarkMode, sendInput, sendResize]); + // Send initial terminal size once connected (and on reconnect) + useEffect(() => { + if (state.status === "connected" && xtermRef.current) { + const { cols, rows } = xtermRef.current; + sendResize(cols, rows); + } + }, [state.status, sendResize]); + return (