Compare commits

...

31 Commits

Author SHA1 Message Date
Alex Blank c051929f8c fix: use host bind mount for repos so tool instances can access workspace files
Replace named Docker volume (repo_data) with bind mount (/data/repos) in both
development and production compose files. The named volume trapped repo files
inside the API container; tool instances started via Docker socket on the host
could not see them, causing /workspace to mount as an empty directory.

Also fix 6 pre-existing test failures in test_tool_instances_legacy.py caused
by get_container_id/get_container_name moving to docker.py and new helpers
(_ensure_web_bind_address, _ensure_container_name_in_compose) being added.

- docker-compose.yml: repo_data:/data/repos -> /data/repos:/data/repos
- docker-compose.traefik.yml: same change + remove repo_data volume decl
- tests: update patch targets and add missing mock parameters

Quality gates: pytest test_tool_instances_legacy.py (10 passed)
2026-06-02 12:47:14 +02:00
Alex Blank 4814ec2363 fix: mobile terminal scroll in both normal mode and tmux
- Dual-mode touch scroll:
  - Normal mode: scroll .xterm-viewport directly when scrollHeight > clientHeight
  - Alternate screen (tmux/vim): send SGR 1006 mouse-wheel protocol data
    using cursor position so tmux knows which pane to scroll
- Add touch-action: none to .terminal-container to prevent browser gestures
- Lock both html and body overflow when terminal page is open on mobile
- Remove synthetic WheelEvent approach (xterm.js SmoothScrollableElement
  doesn't reliably handle synthetic events)
2026-05-29 20:35:28 +02:00
Alex Blank 98b9d612fa fix: container-level capture touch with direct viewport.scrollTop manipulation
- Attach capture-phase touch listeners to .terminal-container (parent of xterm)
- On vertical swipe: e.preventDefault() blocks page scroll, then directly
  adjust .xterm-viewport.scrollTop by the swipe delta
- This bypasses term.scrollLines() API and directly manipulates the DOM
  element that xterm.js watches via its internal scroll handler
- Remove all CSS touch-action overrides — container handles it in JS
2026-05-29 20:23:19 +02:00
Alex Blank 874873541d fix: xterm.js mobile touch scrolling via viewport CSS and stopPropagation
- Add full mobile viewport CSS: overflow-y scroll, -webkit-overflow-scrolling
  touch, overscroll-behavior-y contain, translate3d hardware accel,
  scroll-behavior smooth, touch-action pan-y
- After term.open(), find .xterm-viewport and add passive touch listeners
  that call stopPropagation() (not preventDefault) — this lets the browser
  handle native touch scrolling while preventing xterm.js internal handlers
  from interfering
- Based on xterm.js known issue #5489 and SCROLLING_FIX.md approach
2026-05-29 20:16:12 +02:00
Alex Blank ef9ac76f06 fix: remove all touch interception, let browser scroll xterm viewport natively
- xterm.js has zero touch event handlers (verified: only 1 'touch' ref in
  entire library), so it wasn't intercepting anything
- Our touch-action: none + preventDefault() combo was blocking the browser
  from scrolling the .xterm-viewport natively
- Removed all custom touch event handlers from terminal.tsx
- Removed touch-action: none from .terminal-container
- Added touch-action: pan-y to .xterm-viewport so browser allows vertical pan
- Body scroll lock (terminal-page-open) prevents page from scrolling
2026-05-29 20:08:58 +02:00
Alex Blank ca9db195de fix: document-level capture touch listeners for mobile terminal scroll
- Attach touch listeners to document with capture:true instead of container
- Check if touch target is inside terminal container before handling
- This runs before xterm.js internal handlers, giving us full control
- Add touch-action: none to terminal container to prevent browser gestures
- Lower threshold to 3px, 20px per line for responsive scrolling
2026-05-29 20:03:11 +02:00
Alex Blank c1e16f2163 fix: lock body scroll and re-add programmatic terminal touch scroll
- Add body.terminal-page-open { overflow: hidden } to prevent page scroll
- TerminalPage adds/removes 'terminal-page-open' class on body when mounted
- Re-add capture-phase touch listeners in terminal.tsx with low 3px threshold
- Call e.preventDefault() immediately when vertical gesture is detected,
  before browser compositor commits to page scroll
- Remove CSS touch-action overrides on xterm viewport (now handled in JS)
- Scroll forwarded via term.scrollLines() with 24px per line sensitivity
2026-05-29 19:57:48 +02:00
Alex Blank 61d32fa00f fix: enable native touch scrolling on xterm.js viewport for mobile
- Remove all custom touch event interception code from terminal.tsx
- After term.open(), find the internal .xterm-viewport element and set
  touchAction=pan-y and overscrollBehavior=contain via inline styles
- Add CSS targeting .xterm-viewport on mobile with touch-action: pan-y,
  -webkit-overflow-scrolling: touch, and overflow-y: auto
- Let the browser handle vertical touch panning natively instead of
  trying to intercept and manually forward events
2026-05-29 19:41:23 +02:00
Alex Blank cddb3f8ccf fix: mobile terminal scroll via capture-phase touch listeners
- Attach touch listeners to container wrapper in CAPTURE phase so they run
  before xterm.js internals stop propagation
- Add e.stopPropagation() in touchmove after handling scroll to prevent
  xterm.js from conflicting with our scroll
- Add wheel event fallback for mobile browsers that synthesize wheel from touch
- Remove touch-action: none CSS which was blocking native xterm viewport scroll
2026-05-29 19:35:42 +02:00
Alex Blank 87a938fe58 fix: mobile terminal touch scrolling direction and target
- Attach touch listeners to term.element (xterm root) instead of wrapper
- Fix scroll direction: swipe up now scrolls up (shows older buffer)
- Remove RAF indirection; scroll applied synchronously in touchmove
- Accumulate delta between events for smoother scrolling
- Lower threshold to 6px and px-per-line to 16 for better responsiveness
- Add touch-action: none to terminal container on mobile
2026-05-29 19:29:29 +02:00
Alex Blank 9157694412 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-29 19:21:35 +02:00
Alex Blank aa34314175 feat: touch swipe scrolling in terminal on mobile
- Intercepts touch events on the terminal container when isMobile=true
- Detects vertical swipe gestures (dominant over horizontal movement)
- Translates swipe distance to xterm.js scrollLines() calls
- Uses requestAnimationFrame for smooth scroll updates
- Threshold of 10px before scroll kicks in; 30px per line
- Touch listeners cleaned up on component unmount
2026-05-29 19:20:52 +02:00
Developer c7fc386d0f Merge branch 'fix/code-server-bind-addr-port' into dev 2026-05-29 16:50:00 +00:00
Developer 6bd814e346 fix(cloudflared): use --bind-addr with port for code-server bind fix
Root cause: _ensure_web_bind_address injected --host 0.0.0.0 for code-server,
which only sets the bind host, not the port. code-server then listens on its
default port (8080) instead of the tool type's default_port (8443). Cloudflared
connects to port 8443 and gets connection refused, resulting in a 502.

Changes:
- _ensure_web_bind_address now accepts default_port and builds
  --bind-addr 0.0.0.0:{port} for code-server
- Same fix for jupyter-notebook with explicit --port flag
- Existing broken --host commands are now detected and replaced
- New migration fixes tool_types templates and instance compose files on disk
- Test fixture updated to use correct --bind-addr 0.0.0.0:8443
2026-05-29 16:49:52 +00:00
alex aa25852091 fix: predictable container names for tunnel connectivity
- Inject explicit container_name into compose files at start/restart time
  via _ensure_container_name_in_compose() to prevent Docker Compose from
  generating UUID-based auto names that break backend network resolution.
- Use instance.name.lower() directly instead of get_container_name() lookups
  which were unreliable with auto-generated names.
- Apply compose sanitization, bind-address fix, and container-name injection
  on restart_instance as well so restarts pick up template fixes.
- Add --force-recreate to docker compose up to ensure container_name changes
  take effect immediately.
- Fix notification lifecycle tests to match current behavior (success severity,
  health_changed event for ownership test).

Quality gates: ruff clean, pytest (7 notification lifecycle tests passed)
2026-05-29 17:51:34 +02:00
Alex Blank c2740cd282 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-29 17:40:57 +02:00
Developer 23875bb3cc Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-29 15:40:36 +00:00
Developer ee1eab8408 Merge branch 'feat/agents-english-rule' into dev 2026-05-29 15:40:16 +00:00
Developer 2254ba7496 docs: add english language rule to AGENTS.md
- Require all agent output, comments, commits, docs, and artifacts to be in English unless explicitly requested otherwise
2026-05-29 15:40:11 +00:00
Alex Blank 4866ad08b1 feat: request screen wake lock while terminal page is open
- Uses navigator.wakeLock.request('screen') to keep device awake
- Re-acquires wake lock when tab becomes visible again
- Releases wake lock on component unmount
- Silently ignored on unsupported browsers or if denied
2026-05-29 17:39:57 +02:00
Alex Blank 97ebc19313 fix: restore special keys bar on mobile terminal
- Add SpecialKeysStrip and SpecialKeysPanel to mobile terminal page
- Store sendData and focusInput refs via onTerminalReady callback
- Pass activeModifier/onModifierChange to TerminalComponent on mobile
- Add virtual keyboard padding to prevent keyboard from covering terminal
- Special keys bar sits at bottom of viewport, panel opens as overlay
2026-05-29 17:36:08 +02:00
Alex Blank 946ac6f66a fix: remove mobile terminal pull handle, tap terminal to toggle overlay 2026-05-29 17:29:14 +02:00
Alex Blank 90ddee14c2 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-29 17:22:07 +02:00
Alex Blank f17f8ae8c8 Merge branch 'fix/mobile-terminal-overlay' into dev 2026-05-29 17:20:22 +02:00
Alex Blank d713bfc5f9 fix: mobile terminal overlay status bar with auto-hide
- Replace inline header+tabs layout with position:absolute overlay
- Overlay contains: back button, session name, status dot, A-/A+ font size, exit
- Session tabs live inside the overlay below the toolbar
- Auto-hides after 3s; clicking terminal content hides it immediately
- Pull handle at top edge appears when overlay is hidden to restore it
- Terminal content always fills full viewport; overlay never resizes container
- Pass showControls=false to TerminalComponent on mobile to avoid double headers
2026-05-29 17:20:15 +02:00
alex 27fe8c24ec merge: keep fixed migration with correct compose_path column 2026-05-29 17:18:17 +02:00
alex eef1e4e8c6 fix(cloudflared): remove command override for LSIO images
Problem: linuxserver/code-server already binds to 0.0.0.0 by default.
Adding any command: override (--bind-addr or --host) breaks the LSIO
s6 init system with 'not found' errors.

Changes:
- _ensure_web_bind_address(): Skip LSIO images entirely (no command
  override needed). If an existing override is found, remove it.
- New migration 2026_05_29_remove_lsio_command_override: Removes
  --bind-addr and --host command overrides from both DB templates
  and existing instance compose files on disk for LSIO images.
- Fixed migration to use correct column name (compose_path) and
  check information_schema for column existence defensively.

Quality gates: ruff clean
2026-05-29 17:17:12 +02:00
alex a7a5905874 fix(cloudflared): remove command override for LSIO images
Problem: linuxserver/code-server already binds to 0.0.0.0 by default.
Adding any command: override (--bind-addr or --host) breaks the LSIO
s6 init system with 'not found' errors.

Changes:
- _ensure_web_bind_address(): Skip LSIO images entirely (no command
  override needed). If an existing override is found, remove it.
- New migration 2026_05_29_remove_lsio_command_override: Removes
  --bind-addr and --host command overrides from both DB templates
  and existing instance compose files on disk for LSIO images.

Quality gates: ruff clean
2026-05-29 17:09:43 +02:00
alex 021537de56 fix(cloudflared): replace broken --bind-addr at runtime + new migration
Problem: The first migration already ran on the user's server with
--bind-addr (broken). Alembic won't re-run the fixed migration.

Changes:
- _ensure_web_bind_address(): Now detects existing --bind-addr commands
  and replaces them with --host 0.0.0.0 instead of skipping
- New migration 2026_05_29_fix_code_server_bind_addr: Finds code-server
  tool types with --bind-addr in compose_template and replaces with
  --host 0.0.0.0

Quality gates: pytest 42 passed (2 pre-existing unrelated failures)
2026-05-29 17:02:26 +02:00
Alex Blank fdfd75790d Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-29 16:56:14 +02:00
Alex Blank 3d1f8d9cf7 fix: reorder notification DELETE routes so bulk clear matches first
FastAPI matches routes in declaration order. The DELETE /notifications
endpoint (bulk clear) was registered AFTER DELETE /notifications/{id},
so the path parameter route intercepted all requests to the bulk route,
causing a 422 UUID validation error instead of hitting clear_all.

Moved clear_all_notifications above dismiss_notification in the router.
Added regression test to verify route order.

Quality gates: pytest (22 passed)
2026-05-29 16:54:01 +02:00
18 changed files with 933 additions and 140 deletions
+4
View File
@@ -4,6 +4,10 @@
OpenSpec is the source of truth. Superpowers is the default workflow. Keep changes small, scoped, and verified.
## Communication
All agent output, code comments, commit messages, documentation, and artifacts must be in **English** unless the user explicitly requests another language.
## Priority order
1. Current user instruction
@@ -0,0 +1,54 @@
"""fix code-server bind-addr to host in DB template
Revision ID: 2026_05_29_fix_code_server_bind_addr
Revises: 2026_05_29_fix_web_tool_bind_address
Create Date: 2026-05-29 15:00:00.000000
"""
from typing import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "2026_05_29_fix_code_server_bind_addr"
down_revision: str | None = "2026_05_29_fix_web_tool_bind_address"
branch_labels: Sequence[str] | None = None
depends_on: Sequence[str] | None = None
def upgrade() -> None:
conn = op.get_bind()
# Find code-server tool types with broken --bind-addr in compose template
result = conn.execute(
sa.text("""
SELECT id, compose_template
FROM tool_types
WHERE name = 'code-server'
AND compose_template LIKE '%--bind-addr%'
""")
).fetchall()
for tool_id, compose_template in result:
updated = compose_template.replace(
"--bind-addr 0.0.0.0:8443", "--host 0.0.0.0"
).replace("--bind-addr", "--host 0.0.0.0")
conn.execute(
sa.text("""
UPDATE tool_types
SET compose_template = :compose_template
WHERE id = :id
"""),
{"compose_template": updated, "id": tool_id},
)
print(
f"Fixed code-server template ({tool_id}): replaced --bind-addr with --host"
)
def downgrade() -> None:
pass
@@ -0,0 +1,148 @@
"""Fix code-server bind address to include port
Revision ID: 2026_05_29_fix_code_server_bind_addr_port
Revises: 2026_05_29_remove_lsio_command_override
Create Date: 2026-05-29 18:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import yaml
# revision identifiers, used by Alembic.
revision: str = "2026_05_29_fix_code_server_bind_addr_port"
down_revision: Union[str, None] = "2026_05_29_remove_lsio_command_override"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _fix_tool_type_templates(conn) -> None:
"""Fix code-server tool type templates with broken --host override."""
result = conn.execute(
sa.text("""
SELECT id, compose_template, default_port
FROM tool_types
WHERE name = 'code-server'
AND compose_template LIKE '%--host%'
""")
).fetchall()
for tool_id, compose_template, default_port in result:
port = default_port or 8443
expected = f"--bind-addr 0.0.0.0:{port}"
# Replace any line containing --host with the correct bind-addr
lines = compose_template.split("\n")
new_lines = []
modified = False
for line in lines:
if "command:" in line and "--host" in line:
indent = line[: len(line) - len(line.lstrip())]
new_lines.append(f"{indent}command: {expected}")
modified = True
else:
new_lines.append(line)
if not modified:
continue
updated = "\n".join(new_lines)
conn.execute(
sa.text("""
UPDATE tool_types
SET compose_template = :compose_template
WHERE id = :id
"""),
{"compose_template": updated, "id": tool_id},
)
print(f"Fixed code-server template ({tool_id}): replaced --host with {expected}")
def _fix_instance_compose_files(conn) -> None:
"""Fix existing instance compose files on disk with broken --host override."""
from pathlib import Path
# Use information_schema to check if compose_path column exists
col_result = conn.execute(
sa.text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'tool_instances'
AND column_name = 'compose_path'
""")
).fetchone()
if not col_result:
print("compose_path column not found, skipping instance file fixes")
return
result = conn.execute(
sa.text("""
SELECT id, compose_path, tool_type_id
FROM tool_instances
WHERE compose_path IS NOT NULL
""")
).fetchall()
for instance_id, compose_path, tool_type_id in result:
path = Path(compose_path)
if not path.exists():
continue
try:
content = path.read_text()
except Exception:
continue
if "--host" not in content:
continue
# Get default_port from tool_type
port_result = conn.execute(
sa.text("""
SELECT default_port FROM tool_types WHERE id = :id
"""),
{"id": tool_type_id},
).fetchone()
port = port_result[0] if port_result and port_result[0] else 8443
expected = f"--bind-addr 0.0.0.0:{port}"
try:
data = yaml.safe_load(content)
except Exception:
continue
if not data or "services" not in data:
continue
modified = False
for svc in data["services"].values():
if "command" in svc:
cmd = svc["command"]
if "--host" in cmd:
svc["command"] = expected
modified = True
if not modified:
continue
try:
path.write_text(yaml.dump(data, default_flow_style=False))
print(
f"Fixed code-server instance compose ({instance_id}): "
f"replaced --host with {expected}"
)
except Exception as exc:
print(f"Failed to fix instance {instance_id}: {exc}")
def upgrade() -> None:
conn = op.get_bind()
_fix_tool_type_templates(conn)
_fix_instance_compose_files(conn)
def downgrade() -> None:
pass
@@ -0,0 +1,121 @@
"""Remove broken command override from LSIO code-server templates
Revision ID: 2026_05_29_remove_lsio_command_override
Revises: 2026_05_29_fix_code_server_bind_addr
Create Date: 2026-05-29 15:05:00.000000
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "2026_05_29_remove_lsio_command_override"
down_revision: str | None = "2026_05_29_fix_code_server_bind_addr"
branch_labels: Sequence[str] | None = None
depends_on: Sequence[str] | None = None
def upgrade() -> None:
conn = op.get_bind()
# Fix tool_types templates in DB
result = conn.execute(
sa.text("""
SELECT id, compose_template
FROM tool_types
WHERE name = 'code-server'
""")
).fetchall()
import yaml
from pathlib import Path
for tool_id, compose_template in result:
try:
data = yaml.safe_load(compose_template)
except Exception:
continue
if not data or "services" not in data:
continue
modified = False
for svc in data["services"].values():
image = svc.get("image", "")
if not image or "linuxserver" not in image:
continue
if "command" in svc:
cmd = svc["command"]
if "--bind-addr" in cmd or "--host" in cmd:
del svc["command"]
modified = True
if modified:
updated = yaml.dump(data, default_flow_style=False)
conn.execute(
sa.text("""
UPDATE tool_types
SET compose_template = :compose_template
WHERE id = :id
"""),
{"compose_template": updated, "id": tool_id},
)
print(f"Removed broken command override from LSIO template ({tool_id})")
# Fix existing instance compose files on disk
# Use information_schema to check if compose_path column exists
col_result = conn.execute(
sa.text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'tool_instances'
AND column_name = 'compose_path'
""")
).fetchone()
if col_result:
result = conn.execute(
sa.text("""
SELECT id, compose_path
FROM tool_instances
WHERE compose_path IS NOT NULL
""")
).fetchall()
for instance_id, compose_path in result:
path = Path(compose_path)
if not path.exists():
continue
try:
content = path.read_text()
data = yaml.safe_load(content)
except Exception:
continue
if not data or "services" not in data:
continue
modified = False
for svc in data["services"].values():
image = svc.get("image", "")
if not image or "linuxserver" not in image:
continue
if "command" in svc:
cmd = svc["command"]
if "--bind-addr" in cmd or "--host" in cmd:
del svc["command"]
modified = True
if modified:
path.write_text(yaml.dump(data, default_flow_style=False))
print(
f"Removed broken command override from instance compose "
f"({instance_id})"
)
def downgrade() -> None:
pass
+10 -10
View File
@@ -135,6 +135,16 @@ async def mark_all_read(
return MarkAllReadResponse(marked_count=marked)
@router.delete("", status_code=status.HTTP_200_OK)
async def clear_all_notifications(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db_session),
) -> ClearAllResponse:
"""Dismiss all notifications for the authenticated user."""
cleared = await notification_service.dismiss_all(session, user.id)
return ClearAllResponse(cleared_count=cleared)
@router.delete("/{notification_id}", status_code=status.HTTP_204_NO_CONTENT)
async def dismiss_notification(
notification_id: uuid.UUID,
@@ -149,13 +159,3 @@ async def dismiss_notification(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found",
) from exc
@router.delete("", status_code=status.HTTP_200_OK)
async def clear_all_notifications(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db_session),
) -> ClearAllResponse:
"""Dismiss all notifications for the authenticated user."""
cleared = await notification_service.dismiss_all(session, user.id)
return ClearAllResponse(cleared_count=cleared)
+123 -34
View File
@@ -52,7 +52,6 @@ from src.services.docker import (
find_free_port,
get_container_id,
get_container_logs,
get_container_name,
get_container_status,
recreate_tunnel,
render_compose_template,
@@ -618,7 +617,44 @@ def _modify_compose_file(
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None:
def _ensure_container_name_in_compose(compose_path: str, container_name: str) -> None:
"""Ensure compose file has explicit container_name for predictable naming.
Docker Compose auto-generates container names from the project directory
when container_name is absent. This breaks tunnel connectivity because
get_container_name(instance.name) cannot find the container. We inject
container_name into every service so the container has a predictable name.
"""
import yaml
from pathlib import Path
compose_file = Path(compose_path)
if not compose_file.exists():
return
content = compose_file.read_text()
compose_data = yaml.safe_load(content)
if not compose_data or "services" not in compose_data:
return
modified = False
for svc_name, svc_config in compose_data["services"].items():
if "container_name" not in svc_config:
svc_config["container_name"] = container_name.lower()
modified = True
if modified:
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
logger.info(
"Injected container_name '%s' into compose file",
container_name.lower(),
)
def _ensure_web_bind_address(
compose_path: str, tool_type_name: str, default_port: int
) -> None:
"""Auto-inject bind address for known web tools that default to 127.0.0.1.
Many web tools (code-server, jupyter) bind to localhost by default,
@@ -628,9 +664,12 @@ def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None:
import yaml
from pathlib import Path
KNOWN_BIND_FIXES = {
"code-server": "--host 0.0.0.0",
"jupyter-notebook": "start-notebook.sh --ip=0.0.0.0",
if default_port <= 0:
return
KNOWN_BIND_FIXES: dict[str, str] = {
"code-server": f"--bind-addr 0.0.0.0:{default_port}",
"jupyter-notebook": f"start-notebook.sh --ip=0.0.0.0 --port={default_port} --no-browser",
}
bind_command = KNOWN_BIND_FIXES.get(tool_type_name)
@@ -648,28 +687,65 @@ def _ensure_web_bind_address(compose_path: str, tool_type_name: str) -> None:
return
for service_config in compose_data["services"].values():
# Skip if command is already overridden
if "command" in service_config:
return
image = service_config.get("image", "")
if not image:
continue
# LSIO images already bind to 0.0.0.0 — command override breaks s6 init
if "linuxserver" in image:
existing_command = service_config.get("command", "")
if "--bind-addr" in existing_command or "--host" in existing_command:
del service_config["command"]
compose_file.write_text(
yaml.dump(compose_data, default_flow_style=False)
)
logger.warning(
"Removed broken command override from LSIO image: %s",
existing_command,
)
return
return
# Check if the image matches a known tool
if tool_type_name == "code-server" and (
is_code_server = tool_type_name == "code-server" and (
"code-server" in image or "coder" in image
):
service_config["command"] = bind_command
break
if tool_type_name == "jupyter-notebook" and (
)
is_jupyter = tool_type_name == "jupyter-notebook" and (
"jupyter" in image or "notebook" in image
):
service_config["command"] = bind_command
break
)
if not is_code_server and not is_jupyter:
continue
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
logger.info("Injected bind address for %s: %s", tool_type_name, bind_command)
existing_command = service_config.get("command", "")
if existing_command:
# Already correct — nothing to do
if bind_command in existing_command:
return
# Fix broken or outdated bind flags
if (
"--bind-addr" in existing_command
or "--host" in existing_command
or "--ip=" in existing_command
):
service_config["command"] = bind_command
compose_file.write_text(
yaml.dump(compose_data, default_flow_style=False)
)
logger.warning(
"Replaced broken bind address for %s: %s%s",
tool_type_name,
existing_command,
bind_command,
)
return
# Some other command override exists — don't touch it
return
# No command yet — inject the correct bind address
service_config["command"] = bind_command
compose_file.write_text(yaml.dump(compose_data, default_flow_style=False))
logger.info("Injected bind address for %s: %s", tool_type_name, bind_command)
return
@router.post(
@@ -1606,7 +1682,12 @@ async def start_instance(
# Auto-fix bind address for known web tools that default to localhost
if tool_type and tool_type.interface_type == "web":
_ensure_web_bind_address(instance.compose_path, tool_type.name)
_ensure_web_bind_address(
instance.compose_path, tool_type.name, tool_type.default_port
)
# Ensure predictable container name for tunnel connectivity
_ensure_container_name_in_compose(instance.compose_path, instance.name)
# Execute docker compose up with env file
logger.debug(
@@ -1634,24 +1715,23 @@ async def start_instance(
detail=f"failed to start instance: {stderr}",
)
# Get container ID and name
container_id = get_container_id(instance.name)
# Get container ID and name (use predictable name from compose)
expected_container_name = instance.name.lower()
container_id = get_container_id(expected_container_name)
if container_id:
instance.container_id = container_id
logger.debug("Container ID for instance %s: %s", instance.id, container_id)
container_name = get_container_name(instance.name)
if container_name:
instance.container_name = container_name
logger.debug("Container name for instance %s: %s", instance.id, container_name)
instance.container_name = expected_container_name
logger.debug("Container name for instance %s: %s", instance.id, expected_container_name)
# Connect container to backend network so API can reach it
logger.debug("Connecting container %s to backend network...", container_name)
connected = connect_container_to_network(container_name, "backend")
if connected:
logger.debug("Successfully connected %s to backend network", container_name)
else:
logger.warning("Failed to connect %s to backend network", container_name)
# Connect container to backend network so API can reach it
logger.debug("Connecting container %s to backend network...", expected_container_name)
connected = connect_container_to_network(expected_container_name, "backend")
if connected:
logger.debug("Successfully connected %s to backend network", expected_container_name)
else:
logger.warning("Failed to connect %s to backend network", expected_container_name)
# Verify container reached running state
if instance.container_id:
@@ -2078,6 +2158,15 @@ async def restart_instance(
exc,
)
# Re-apply compose fixes in case they were updated since last start
_sanitize_compose_file(instance.compose_path)
tool_type = await session.get(ToolType, instance.tool_type_id)
if tool_type and tool_type.interface_type == "web":
_ensure_web_bind_address(
instance.compose_path, tool_type.name, tool_type.default_port
)
_ensure_container_name_in_compose(instance.compose_path, instance.name)
returncode, stdout, stderr = execute_compose_command(
instance.compose_path, "restart"
)
@@ -2107,7 +2196,7 @@ async def restart_instance(
# Create new temporary tunnel
try:
tunnel_info = start_cloudflared_tunnel(
container_name=instance.container_name or instance.name,
container_name=instance.name.lower(),
port=instance_port,
)
instance.tunnel_id = tunnel_info["pid"]
+1 -1
View File
@@ -159,7 +159,7 @@ def execute_compose_command(
cmd.extend(["--env-file", env_file])
if action == "up":
cmd.extend(["up", "-d"])
cmd.extend(["up", "-d", "--force-recreate"])
elif action == "down":
cmd.extend(["down", "-v"])
elif action in ("start", "stop", "restart"):
+1 -5
View File
@@ -139,11 +139,7 @@ async def publish_lifecycle_event(
if not _should_notify(event_type, effective_status):
return
severity = (
"error"
if event_type == "instance.error"
else "success"
)
severity = "error" if event_type == "instance.error" else "success"
title = _derive_title(event_type)
try:
@@ -149,8 +149,8 @@ async def test_lifecycle_running_creates_notification(
assert len(notifications) == 1
n = notifications[0]
assert n.category == "instance"
assert n.severity == "info"
assert n.title == "Health Changed"
assert n.severity == "success"
assert n.title == "Container ready"
assert n.source_type == "tool_instances"
assert n.source_id == test_instance.id
@@ -315,9 +315,9 @@ async def test_notification_ownership_matches_instance_owner(
event_bus=event_bus,
session=db_session,
instance=instance,
event_type="instance.created",
status="pending",
message="Instance created",
event_type="instance.health_changed",
status="running",
message="Container running",
)
result = await db_session.execute(
@@ -184,7 +184,7 @@ class TestToolTypesAPIExtended:
"interfaces": ["web", "terminal"],
"default_port": 8443,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n command: --host 0.0.0.0\n ports:\n - '8443:8443'\n volumes:\n - \"{{REPO_PATH}}:/workspace\"",
"compose_template": "version: '3.8'\nservices:\n app:\n image: code-server\n command: --bind-addr 0.0.0.0:8443\n ports:\n - '8443:8443'\n volumes:\n - \"{{REPO_PATH}}:/workspace\"",
"readiness_probe": {
"command": "curl -f http://localhost:8443",
"timeout": 30,
@@ -351,8 +351,12 @@ async def test_dismiss_all_affects_only_caller(
cleared = await notification_service.dismiss_all(db_session, user_a.id)
assert cleared == 3
items_a, total_a = await notification_service.list_notifications(db_session, user_a.id)
items_b, total_b = await notification_service.list_notifications(db_session, user_b.id)
items_a, total_a = await notification_service.list_notifications(
db_session, user_a.id
)
items_b, total_b = await notification_service.list_notifications(
db_session, user_b.id
)
assert total_a == 0
assert total_b == 2
@@ -0,0 +1,34 @@
"""Unit tests for notification API route ordering."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from src.api.notifications import router as notifications_router
def test_delete_notifications_route_order() -> None:
"""DELETE /notifications must match before DELETE /notifications/{id}.
FastAPI matches routes in declaration order. The bulk clear endpoint
(DELETE /notifications) must be registered before the single dismiss
endpoint (DELETE /notifications/{notification_id}) or the path
parameter route will intercept the bulk route.
"""
app = FastAPI()
app.include_router(notifications_router)
client = TestClient(app)
# Verify the bulk delete route exists and returns the expected schema
# (it will 401 without auth, but that's fine — we just need to confirm
# routing doesn't hit the UUID-parameter route first)
response = client.delete("/notifications")
# Should get 401 (unauthenticated), NOT 422 (UUID parse error)
assert response.status_code == 401, (
f"Expected 401 (auth required), got {response.status_code}. "
f"Route order may be wrong — DELETE /notifications matched "
f"DELETE /notifications/{{notification_id}} instead."
)
# Verify the single dismiss route still works (also 401 without auth)
response = client.delete("/notifications/12345678-1234-1234-1234-123456789abc")
assert response.status_code == 401
@@ -411,8 +411,9 @@ class TestStartInstanceLegacyFallback:
@patch("src.api.tool_instances.wait_for_container_running")
@patch("src.api.tool_instances.execute_compose_command")
@patch("src.api.tool_instances.get_container_id")
@patch("src.api.tool_instances.get_container_name")
@patch("src.api.tool_instances.connect_container_to_network")
@patch("src.api.tool_instances._ensure_container_name_in_compose")
@patch("src.api.tool_instances._ensure_web_bind_address")
@patch("src.api.tool_instances._sanitize_compose_file")
@patch("src.api.tool_instances._prepare_manifest_instance")
@patch("src.api.tool_instances._get_user")
@@ -423,8 +424,9 @@ class TestStartInstanceLegacyFallback:
mock_get_user,
mock_prepare_manifest,
mock_sanitize,
mock_ensure_web_bind,
mock_ensure_container_name,
mock_connect_network,
mock_get_container_name,
mock_get_container_id,
mock_execute_compose,
mock_wait_container,
@@ -440,7 +442,6 @@ class TestStartInstanceLegacyFallback:
mock_get_project.return_value = AsyncMock()
mock_execute_compose.return_value = (0, "started", "")
mock_get_container_id.return_value = "abc123"
mock_get_container_name.return_value = "test-container"
mock_connect_network.return_value = True
mock_wait_container.return_value = {
"success": True,
@@ -509,8 +510,9 @@ class TestStartInstanceLegacyFallback:
@patch("src.api.tool_instances.wait_for_container_running")
@patch("src.api.tool_instances.execute_compose_command")
@patch("src.api.tool_instances.get_container_id")
@patch("src.api.tool_instances.get_container_name")
@patch("src.api.tool_instances.connect_container_to_network")
@patch("src.api.tool_instances._ensure_container_name_in_compose")
@patch("src.api.tool_instances._ensure_web_bind_address")
@patch("src.api.tool_instances._sanitize_compose_file")
@patch("src.api.tool_instances._prepare_manifest_instance")
@patch("src.api.tool_instances._get_user")
@@ -521,8 +523,9 @@ class TestStartInstanceLegacyFallback:
mock_get_user,
mock_prepare_manifest,
mock_sanitize,
mock_ensure_web_bind,
mock_ensure_container_name,
mock_connect_network,
mock_get_container_name,
mock_get_container_id,
mock_execute_compose,
mock_wait_container,
@@ -538,7 +541,6 @@ class TestStartInstanceLegacyFallback:
mock_get_project.return_value = AsyncMock()
mock_execute_compose.return_value = (0, "started", "")
mock_get_container_id.return_value = "abc123"
mock_get_container_name.return_value = "test-container"
mock_connect_network.return_value = True
mock_wait_container.return_value = {
"success": True,
@@ -606,8 +608,9 @@ class TestStartInstanceLegacyFallback:
@patch("src.api.tool_instances.wait_for_container_running")
@patch("src.api.tool_instances.execute_compose_command")
@patch("src.api.tool_instances.get_container_id")
@patch("src.api.tool_instances.get_container_name")
@patch("src.api.tool_instances.connect_container_to_network")
@patch("src.api.tool_instances._ensure_container_name_in_compose")
@patch("src.api.tool_instances._ensure_web_bind_address")
@patch("src.api.tool_instances._sanitize_compose_file")
@patch("src.api.tool_instances._prepare_manifest_instance")
@patch("src.api.tool_instances._get_user")
@@ -618,8 +621,9 @@ class TestStartInstanceLegacyFallback:
mock_get_user,
mock_prepare_manifest,
mock_sanitize,
mock_ensure_web_bind,
mock_ensure_container_name,
mock_connect_network,
mock_get_container_name,
mock_get_container_id,
mock_execute_compose,
mock_wait_container,
@@ -635,7 +639,6 @@ class TestStartInstanceLegacyFallback:
mock_get_project.return_value = AsyncMock()
mock_execute_compose.return_value = (0, "started", "")
mock_get_container_id.return_value = "abc123"
mock_get_container_name.return_value = "test-container"
mock_connect_network.return_value = True
mock_wait_container.return_value = {
"success": True,
@@ -705,12 +708,14 @@ class TestStartInstanceSshPermissions:
"""SSH key mounts trigger permission fixes after container starts."""
@patch("src.api.tool_instances.write_compose_file")
@patch("src.api.tool_instances.prepare_ssh_key_files")
@patch("src.api.tool_instances.apply_ssh_permissions")
@patch("src.api.tool_instances.wait_for_container_running")
@patch("src.api.tool_instances.execute_compose_command")
@patch("src.api.tool_instances.get_container_id")
@patch("src.api.tool_instances.get_container_name")
@patch("src.api.tool_instances.connect_container_to_network")
@patch("src.api.tool_instances._ensure_container_name_in_compose")
@patch("src.api.tool_instances._ensure_web_bind_address")
@patch("src.api.tool_instances._sanitize_compose_file")
@patch("src.api.tool_instances._get_user")
@patch("src.api.tool_instances._get_owned_project")
@@ -719,12 +724,14 @@ class TestStartInstanceSshPermissions:
mock_get_project,
mock_get_user,
mock_sanitize,
mock_ensure_web_bind,
mock_ensure_container_name,
mock_connect_network,
mock_get_container_name,
mock_get_container_id,
mock_execute_compose,
mock_wait_container,
mock_apply_ssh,
mock_prepare_ssh,
mock_write_compose,
mock_session,
fake_user_id,
@@ -743,7 +750,6 @@ class TestStartInstanceSshPermissions:
mock_get_project.return_value = AsyncMock()
mock_execute_compose.return_value = (0, "started", "")
mock_get_container_id.return_value = "abc123"
mock_get_container_name.return_value = "test-container"
mock_connect_network.return_value = True
mock_wait_container.return_value = {
"success": True,
@@ -836,12 +842,14 @@ class TestStartInstanceSshPermissions:
assert result["status"] == "running"
mock_apply_ssh.assert_called_once_with("abc123", "/home/user/.ssh", "user")
@patch("src.api.tool_instances.prepare_ssh_key_files")
@patch("src.api.tool_instances.apply_ssh_permissions")
@patch("src.api.tool_instances.wait_for_container_running")
@patch("src.api.tool_instances.execute_compose_command")
@patch("src.api.tool_instances.get_container_id")
@patch("src.api.tool_instances.get_container_name")
@patch("src.api.tool_instances.connect_container_to_network")
@patch("src.api.tool_instances._ensure_container_name_in_compose")
@patch("src.api.tool_instances._ensure_web_bind_address")
@patch("src.api.tool_instances._sanitize_compose_file")
@patch("src.api.tool_instances._get_user")
@patch("src.api.tool_instances._get_owned_project")
@@ -850,12 +858,14 @@ class TestStartInstanceSshPermissions:
mock_get_project,
mock_get_user,
mock_sanitize,
mock_ensure_web_bind,
mock_ensure_container_name,
mock_connect_network,
mock_get_container_name,
mock_get_container_id,
mock_execute_compose,
mock_wait_container,
mock_apply_ssh,
mock_prepare_ssh,
mock_session,
fake_user_id,
fake_project_id,
@@ -870,7 +880,6 @@ class TestStartInstanceSshPermissions:
mock_get_project.return_value = AsyncMock()
mock_execute_compose.return_value = (0, "started", "")
mock_get_container_id.return_value = "abc123"
mock_get_container_name.return_value = "test-container"
mock_connect_network.return_value = True
mock_wait_container.return_value = {
"success": True,
@@ -933,14 +942,15 @@ class TestStartInstanceSshPermissions:
mock_session.get.side_effect = _get
with patch("os.path.exists", return_value=True):
result = await start_instance(
project_id=fake_project_id,
repo_id=fake_repo_id,
instance_id=fake_instance_id,
data=None,
user_id=fake_user_id,
session=mock_session,
)
with patch("src.api.tool_instances._modify_compose_file"):
result = await start_instance(
project_id=fake_project_id,
repo_id=fake_repo_id,
instance_id=fake_instance_id,
data=None,
user_id=fake_user_id,
session=mock_session,
)
assert result["status"] == "running"
mock_apply_ssh.assert_called_once_with("abc123", "/root/.ssh", "root")
@@ -952,8 +962,9 @@ class TestStartInstanceManifestBranch:
@patch("src.api.tool_instances.wait_for_container_running")
@patch("src.api.tool_instances.execute_compose_command")
@patch("src.api.tool_instances.get_container_id")
@patch("src.api.tool_instances.get_container_name")
@patch("src.api.tool_instances.connect_container_to_network")
@patch("src.api.tool_instances._ensure_container_name_in_compose")
@patch("src.api.tool_instances._ensure_web_bind_address")
@patch("src.api.tool_instances._sanitize_compose_file")
@patch("src.api.tool_instances._prepare_manifest_instance")
@patch("src.api.tool_instances.write_compose_file")
@@ -966,8 +977,9 @@ class TestStartInstanceManifestBranch:
mock_write_compose,
mock_prepare_manifest,
mock_sanitize,
mock_ensure_web_bind,
mock_ensure_container_name,
mock_connect_network,
mock_get_container_name,
mock_get_container_id,
mock_execute_compose,
mock_wait_container,
@@ -987,7 +999,6 @@ class TestStartInstanceManifestBranch:
mock_get_project.return_value = AsyncMock()
mock_execute_compose.return_value = (0, "started", "")
mock_get_container_id.return_value = "abc123"
mock_get_container_name.return_value = "test-container"
mock_connect_network.return_value = True
mock_wait_container.return_value = {
"success": True,
+94 -1
View File
@@ -313,6 +313,96 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
term.focus();
const ws = connectWebSocket();
// Mobile touch scroll.
// In normal mode xterm.js has a scrollable viewport; in alternate
// screen (tmux/vim) there is no scrollback and the only way to
// scroll is to send mouse-wheel protocol sequences to the
// application. We detect which situation we're in by checking
// whether the viewport has scrollable height.
let touchCleanup: (() => void) | undefined;
if (isMobile) {
let startY = 0;
let startX = 0;
let isScrolling = false;
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length === 1) {
startY = e.touches[0].clientY;
startX = e.touches[0].clientX;
isScrolling = false;
}
};
const onTouchMove = (e: TouchEvent) => {
if (e.touches.length !== 1) return;
const touch = e.touches[0];
const deltaY = startY - touch.clientY;
const deltaX = Math.abs(startX - touch.clientX);
if (!isScrolling) {
if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 4) {
isScrolling = true;
}
}
if (isScrolling) {
e.preventDefault();
const viewport = container.querySelector(
".xterm-viewport",
) as HTMLElement | null;
if (!viewport) return;
// If the viewport is scrollable, scroll it directly.
// Otherwise we are in alternate screen (tmux/vim) and must
// send SGR 1006 mouse-wheel protocol data.
const hasScrollback =
viewport.scrollHeight > viewport.clientHeight;
if (hasScrollback) {
viewport.scrollTop += deltaY;
} else {
const ws = wsRef.current;
if (
ws?.readyState === WebSocket.OPEN &&
termRef.current
) {
// Use the cursor position as the wheel location so
// tmux knows which pane to scroll.
const buf = termRef.current.buffer.active;
const col = buf.cursorX + 1;
const row = buf.cursorY + 1;
// SGR 1006: 64 = wheel-up, 65 = wheel-down
const btn = deltaY > 0 ? 64 : 65;
ws.send(`\x1b[<${btn};${col};${row}M`);
}
}
startY = touch.clientY;
}
};
const onTouchEnd = () => {
isScrolling = false;
};
container.addEventListener("touchstart", onTouchStart, {
passive: true,
capture: true,
});
container.addEventListener("touchmove", onTouchMove, {
passive: false,
capture: true,
});
container.addEventListener("touchend", onTouchEnd, {
capture: true,
});
touchCleanup = () => {
container.removeEventListener("touchstart", onTouchStart, {
capture: true,
});
container.removeEventListener("touchmove", onTouchMove, {
capture: true,
});
container.removeEventListener("touchend", onTouchEnd, {
capture: true,
});
};
}
// Initial fit after layout settles (terminal must be opened first)
let fitAttempts = 0;
const doInitialFit = () => {
@@ -440,6 +530,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
"visibilitychange",
handleVisibilityChange,
);
if (touchCleanup) touchCleanup();
if (ws) {
ws.close(1000, "Component unmounting");
}
@@ -568,7 +659,9 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
};
return (
<div className={`terminal-wrapper ${isMobile ? "mobile" : ""} ${!showControls ? "no-controls" : ""}`}>
<div
className={`terminal-wrapper ${isMobile ? "mobile" : ""} ${!showControls ? "no-controls" : ""}`}
>
{showControls && (
<div className="terminal-header">
<div className="terminal-header-left">
+153 -32
View File
@@ -5,10 +5,15 @@ import {
TerminalSessionTabs,
type TerminalSessionInfo,
} from "../components/terminal-session-tabs";
import { Icon } from "../components/icon";
import { SpecialKeysStrip } from "../components/special-keys-strip";
import { SpecialKeysPanel } from "../components/special-keys-panel";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { useAutoHide } from "../hooks/use-auto-hide";
import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard";
import { useTerminalSessions } from "../hooks/use-terminal-sessions";
import type { TerminalSession } from "../api/terminal";
import type { ModifierKey } from "../hooks/use-special-keys";
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
sessions.map((s) => ({
@@ -39,7 +44,15 @@ export const TerminalPage: React.FC = () => {
Record<string, TerminalStatus>
>({});
const changeFontSizeRef = useRef<((delta: number) => void) | null>(null);
const sendDataRef = useRef<((data: string) => void) | null>(null);
const focusInputRef = useRef<(() => void) | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false);
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(
null,
);
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
useVirtualKeyboard();
const {
sessions,
@@ -162,6 +175,47 @@ export const TerminalPage: React.FC = () => {
setActiveSessionId,
]);
// Keep screen awake while terminal is open
useEffect(() => {
let wakeLock: WakeLockSentinel | null = null;
const requestWakeLock = async () => {
try {
if ("wakeLock" in navigator) {
wakeLock = await navigator.wakeLock.request("screen");
}
} catch {
// Wake lock may be denied; silently ignore
}
};
void requestWakeLock();
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
void requestWakeLock();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
wakeLock?.release().catch(() => {});
};
}, []);
// Lock page scroll on mobile terminal so swipes scroll the terminal buffer,
// not the page.
useEffect(() => {
if (!isMobile) return;
document.documentElement.classList.add("terminal-page-open");
document.body.classList.add("terminal-page-open");
return () => {
document.documentElement.classList.remove("terminal-page-open");
document.body.classList.remove("terminal-page-open");
};
}, [isMobile]);
// Click outside terminal content/header to exit fullscreen
const handleFullscreenClick = useCallback(
(e: React.MouseEvent<HTMLElement>) => {
@@ -205,15 +259,17 @@ export const TerminalPage: React.FC = () => {
const handleTerminalReady = useCallback(
(
_sendData: (data: string) => void,
sendData: (data: string) => void,
status: TerminalStatus,
_focusInput: () => void,
focusInput: () => void,
changeFontSize: (delta: number) => void,
) => {
setTerminalStatuses((prev) => ({
...prev,
[activeSessionId ?? "default"]: status,
}));
sendDataRef.current = sendData;
focusInputRef.current = focusInput;
changeFontSizeRef.current = changeFontSize;
},
[activeSessionId],
@@ -223,6 +279,10 @@ export const TerminalPage: React.FC = () => {
changeFontSizeRef.current?.(delta);
}, []);
const handleSendKey = useCallback((data: string) => {
sendDataRef.current?.(data);
}, []);
const handleReset = useCallback(() => {
if (activeSessionId && terminalRefs.current[activeSessionId]) {
terminalRefs.current[activeSessionId].current?.reset();
@@ -241,45 +301,85 @@ export const TerminalPage: React.FC = () => {
const sessionInfos = SESSIONS_TO_INFO(sessions);
if (isMobile) {
const activeSession = sessions.find((s) => s.id === activeSessionId);
const status =
terminalStatuses[activeSessionId ?? "default"] ?? "connecting";
return (
<section
className={`terminal-page mobile ${isFullscreen ? "fullscreen" : ""}`}
>
{/* Overlay status bar — floats over terminal, never resizes it */}
<div
className={`terminal-page-header mobile-header ${headerAutoHide.isVisible ? "visible" : "hidden"}`}
onClick={() => headerAutoHide.show()}
className={`mobile-terminal-overlay ${headerAutoHide.isVisible ? "visible" : "hidden"}`}
onClick={(e) => e.stopPropagation()}
>
<button
className="secondary-button"
onClick={() => navigate(-1)}
type="button"
>
Back
</button>
<h1>Terminal</h1>
<button
className="secondary-button"
onClick={() => setIsFullscreen((p) => !p)}
type="button"
>
{isFullscreen ? "Exit" : "Fullscreen"}
</button>
<div className="mobile-terminal-toolbar">
<div className="mobile-terminal-toolbar-left">
<button
className="mobile-terminal-toolbtn"
onClick={() => navigate(-1)}
type="button"
aria-label="Back"
>
<Icon name="arrow-left" size="sm" />
</button>
</div>
<div className="mobile-terminal-toolbar-center">
<span className="mobile-terminal-title">
{activeSession?.name || "Terminal"}
</span>
<span
className={`mobile-terminal-status status-dot ${status}`}
aria-label={`Connection status: ${status}`}
/>
</div>
<div className="mobile-terminal-toolbar-right">
<button
className="mobile-terminal-toolbtn"
onClick={() => handleFontSizeChange(-1)}
type="button"
aria-label="Decrease font size"
>
<span style={{ fontSize: "0.75rem" }}>A-</span>
</button>
<button
className="mobile-terminal-toolbtn"
onClick={() => handleFontSizeChange(1)}
type="button"
aria-label="Increase font size"
>
<span style={{ fontSize: "1rem" }}>A+</span>
</button>
<button
className="mobile-terminal-toolbtn"
onClick={() => navigate(-1)}
type="button"
aria-label="Exit terminal"
>
<Icon name="close" size="sm" />
</button>
</div>
</div>
<div className="mobile-terminal-overlay-tabs">
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId ?? ""}
onSelect={handleSelect}
onClose={handleClose}
onCreate={handleCreate}
onRename={handleRename}
isMobile={true}
/>
</div>
</div>
{/* Terminal content — always fills full viewport */}
<div
className={`mobile-tabs-container ${headerAutoHide.isVisible ? "visible" : "hidden"}`}
onClick={() => headerAutoHide.show()}
className="terminal-page-content mobile-full"
style={{ paddingBottom: isKeyboardOpen ? keyboardHeight : 0 }}
onClick={() => headerAutoHide.toggle()}
>
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId ?? ""}
onSelect={handleSelect}
onClose={handleClose}
onCreate={handleCreate}
onRename={handleRename}
isMobile={true}
/>
</div>
<div className="terminal-page-content">
{error && <div className="terminal-error-banner">{error}</div>}
{sessions
.filter((session) => session.id === activeSessionId)
@@ -291,6 +391,9 @@ export const TerminalPage: React.FC = () => {
sessionId={session.id}
onClose={() => handleClose(session.id)}
isMobile={true}
showControls={false}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
onTerminalReady={handleTerminalReady}
/>
</div>
@@ -301,6 +404,24 @@ export const TerminalPage: React.FC = () => {
</div>
)}
</div>
<SpecialKeysStrip
onSend={handleSendKey}
isVisible={!showSpecialKeysPanel}
onMoreClick={() => setShowSpecialKeysPanel(true)}
onKeepFocus={() => focusInputRef.current?.()}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
/>
<SpecialKeysPanel
onSend={handleSendKey}
isOpen={showSpecialKeysPanel}
onClose={() => setShowSpecialKeysPanel(false)}
onKeepFocus={() => focusInputRef.current?.()}
activeModifier={activeModifier}
onModifierChange={setActiveModifier}
/>
</section>
);
}
+128 -16
View File
@@ -77,6 +77,11 @@ body {
color: var(--ink);
}
html.terminal-page-open,
body.terminal-page-open {
overflow: hidden;
}
[data-theme="dark"] body {
background: radial-gradient(circle at top right, #2a2520, var(--bg));
}
@@ -2950,42 +2955,148 @@ a.nav-item,
background: #cd3131;
}
/* Mobile auto-hide header and tabs */
.terminal-page.mobile .terminal-page-header,
.mobile-tabs-container {
/* ============================================
Mobile Terminal Overlay
============================================ */
/* Mobile terminal page — no padding, terminal fills viewport */
.terminal-page.mobile {
padding: 0;
gap: 0;
height: 100vh;
height: 100dvh;
position: relative;
overflow: hidden;
}
/* Overlay status bar — floats over terminal, never resizes it */
.mobile-terminal-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: #2d2d2d;
border-bottom: 1px solid #3e3e3e;
transition:
transform 0.3s ease,
opacity 0.3s ease;
}
.terminal-page.mobile .terminal-page-header.hidden,
.mobile-tabs-container.hidden {
.mobile-terminal-overlay.hidden {
transform: translateY(-100%);
opacity: 0;
pointer-events: none;
}
.terminal-page.mobile .terminal-page-header.visible,
.mobile-tabs-container.visible {
.mobile-terminal-overlay.visible {
transform: translateY(0);
opacity: 1;
}
/* Toolbar row */
.mobile-terminal-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
gap: var(--space-2);
}
.mobile-terminal-toolbar-left,
.mobile-terminal-toolbar-right {
display: flex;
align-items: center;
gap: var(--space-1);
flex: 0 0 auto;
}
.mobile-terminal-toolbar-center {
display: flex;
align-items: center;
gap: var(--space-2);
flex: 1;
justify-content: center;
min-width: 0;
}
.mobile-terminal-title {
font-size: 0.875rem;
font-weight: 500;
color: #d4d4d4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mobile-terminal-status {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
flex-shrink: 0;
}
.mobile-terminal-status.connecting {
background: #f5f543;
animation: pulse 1.5s infinite;
}
.mobile-terminal-status.connected {
background: #0dbc79;
}
.mobile-terminal-status.disconnected,
.mobile-terminal-status.error {
background: #cd3131;
}
.mobile-terminal-toolbtn {
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-toolbtn:hover {
background: #3e3e3e;
}
/* Session tabs inside overlay */
.mobile-terminal-overlay-tabs {
background: #1e1e1e;
border-top: 1px solid #3e3e3e;
}
.mobile-terminal-overlay-tabs .terminal-session-tabs {
background: #1e1e1e;
border-bottom: none;
}
/* Terminal content — always fills full viewport on mobile */
.terminal-page-content.mobile-full {
flex: 1;
min-height: 0;
border: none;
border-radius: 0;
overflow: hidden;
}
/* Mobile fullscreen */
@media (max-width: 767px) {
.terminal-page.fullscreen {
padding: 0;
}
.terminal-page.mobile .terminal-page-header {
padding: var(--space-2);
gap: var(--space-2);
}
.terminal-page.mobile .terminal-page-header h1 {
font-size: 1rem;
}
.terminal-session-tab-name {
max-width: 80px;
}
@@ -3597,6 +3708,7 @@ a.nav-item,
padding: 0;
overflow: hidden;
position: relative;
touch-action: none;
}
/* xterm.js manages its own sizing */
+6 -3
View File
@@ -13,7 +13,11 @@ services:
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-headquarter} -d ${POSTGRES_DB:-headquarter}"]
test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER:-headquarter} -d ${POSTGRES_DB:-headquarter}",
]
interval: 10s
timeout: 5s
retries: 5
@@ -92,7 +96,7 @@ services:
AUTHENTIK_AUTHORIZE_URL: ${AUTHENTIK_AUTHORIZE_URL:-}
AUTHENTIK_TOKEN_URL: ${AUTHENTIK_TOKEN_URL:-}
volumes:
- repo_data:/data/repos
- /data/repos:/data/repos
- /data/instances:/data/instances
- avatar_uploads:/app/uploads
- /var/run/docker.sock:/var/run/docker.sock
@@ -116,7 +120,6 @@ services:
volumes:
postgres_data:
redis_data:
repo_data:
avatar_uploads:
networks:
+7 -4
View File
@@ -1,4 +1,4 @@
version: '3.8'
version: "3.8"
services:
# PostgreSQL Database
@@ -14,7 +14,11 @@ services:
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-headquarter} -d ${POSTGRES_DB:-headquarter}"]
test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER:-headquarter} -d ${POSTGRES_DB:-headquarter}",
]
interval: 10s
timeout: 5s
retries: 5
@@ -57,7 +61,7 @@ services:
REPO_BASE_PATH: /data/repos
INSTANCE_BASE_PATH: /data/instances
volumes:
- repo_data:/data/repos
- /data/repos:/data/repos
- /data/instances:/data/instances
ports:
- "8000:8000"
@@ -91,7 +95,6 @@ services:
volumes:
postgres_data:
redis_data:
repo_data:
networks:
backend: