Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb38b37ceb | |||
| 6698c20f25 | |||
| 5e06a2a226 | |||
| 49180e4c6d | |||
| 7a5538b53f | |||
| 41d24beade | |||
| d1777b88ad | |||
| 691bbaa87b | |||
| 6191565e80 | |||
| 468f34202a | |||
| 996a858892 | |||
| ff7fcb6e8d | |||
| 3f06224b75 | |||
| a1a77c99a6 |
+4
-13
@@ -16,7 +16,7 @@ dir: .
|
||||
Trust boundary: index routes, map orients, source decides.
|
||||
|
||||
## role
|
||||
Infrastructure and configuration package for "Headquarter," a self-hosted project management platform with Git repository features, containing deployment configs, development tooling, and UI refactoring documentation.
|
||||
Package .
|
||||
## parent
|
||||
-
|
||||
## children
|
||||
@@ -32,15 +32,6 @@ Infrastructure and configuration package for "Headquarter," a self-hosted projec
|
||||
- .pi
|
||||
index: .pi/.pi-map.index.md
|
||||
map: .pi/.pi-map.md
|
||||
- .pi-lens
|
||||
index: .pi-lens/.pi-map.index.md
|
||||
map: .pi-lens/.pi-map.md
|
||||
- .pytest_cache
|
||||
index: .pytest_cache/.pi-map.index.md
|
||||
map: .pytest_cache/.pi-map.md
|
||||
- .ruff_cache
|
||||
index: .ruff_cache/.pi-map.index.md
|
||||
map: .ruff_cache/.pi-map.md
|
||||
- .sisyphus
|
||||
index: .sisyphus/.pi-map.index.md
|
||||
map: .sisyphus/.pi-map.md
|
||||
@@ -62,12 +53,12 @@ Infrastructure and configuration package for "Headquarter," a self-hosted projec
|
||||
- scripts
|
||||
index: scripts/.pi-map.index.md
|
||||
map: scripts/.pi-map.md
|
||||
- tests
|
||||
index: tests/.pi-map.index.md
|
||||
map: tests/.pi-map.md
|
||||
- tool-images
|
||||
index: tool-images/.pi-map.index.md
|
||||
map: tool-images/.pi-map.md
|
||||
- uploads
|
||||
index: uploads/.pi-map.index.md
|
||||
map: uploads/.pi-map.md
|
||||
## files
|
||||
- .env.example
|
||||
- .gitignore
|
||||
|
||||
+16
-16
@@ -18,25 +18,25 @@ index: ./.pi-map.index.md
|
||||
Trust boundary: index routes, map orients, source decides.
|
||||
|
||||
## role
|
||||
Infrastructure and configuration package for "Headquarter," a self-hosted project management platform with Git repository features, containing deployment configs, development tooling, and UI refactoring documentation.
|
||||
Package .
|
||||
## files
|
||||
- .env.example | Provides a template of environment variables for configuring database, Redis, sessions, domains, Authentik OAuth, frontend, and Docker settings for the application. | dep: Postgres, Redis, Authentik, Vite, Docker Compose, Traefik
|
||||
- .gitignore | Specifies intentionally untracked files and directories to exclude from version control across multiple technologies and environments.
|
||||
- AGENTS.md | Defines rules, workflow conventions, and verification standards for AI agents operating on the project, using OpenSpec as the source of truth and Superpowers as the default workflow. | dep: OpenSpec, Superpowers, Git
|
||||
- CHANGELOG.md | Documents notable changes and version history for a project management application with Git repository features.
|
||||
- Makefile | Provides common development automation commands for a Dockerized full-stack application, including service lifecycle, testing, linting, migrations, and cleanup. | dep: docker compose, alembic, pytest, playwright, ruff, mypy, npm
|
||||
- README.md | Provides documentation and onboarding instructions for "Headquarter," a self-hosted platform for managing projects, git repositories, and development tools.
|
||||
- docker-compose.traefik.yml | Defines a Docker Compose deployment configuration for a multi-service application (PostgreSQL, Redis, web frontend, API) behind an existing Traefik reverse proxy with TLS termination. | dep: postgres:15-alpine, redis:7-alpine, traefik, docker.sock, authentik
|
||||
- docker-compose.yml | Defines a multi-container Docker Compose configuration for a full-stack application with PostgreSQL, Redis, an API service, and a web frontend. | dep: postgres:15-alpine, redis:7-alpine, apps/api, apps/web
|
||||
- progress.md | Tracks progress of a backend-frontend refactoring project across completed phases, pending tasks, and remaining work.
|
||||
- swap-pane | Empty package initialization file that marks a directory as a Python package.
|
||||
- ui-audit-spacing-typography.md | Audit report documenting spacing, typography, and visual rhythm issues in the web UI, including missing CSS classes, inconsistent units, incomplete design tokens, and recommendations for fixes.
|
||||
- ui-rework-foundations-apply.md | Documenting the implementation of Pass 1 (Foundations) of a web UI spacing/typography/visual-rhythm rework, summarizing changed files, commits, validation results, and next steps. | dep: tokens.css, global.css, utilities.css, ConfigProfileListSidebar.tsx, ConfigProfileEditorPanel.tsx, instance-list.tsx, openspec artifacts
|
||||
- ui-rework-pass2-apply.md | Implementation report documenting a UI component cleanup pass that refactored inline styles into CSS utility classes and design tokens.
|
||||
- .env.example | Provides example environment variable configuration for a full-stack application with database, caching, authentication, and deployment settings
|
||||
- .gitignore | Specifies files and directories for Git to ignore across a project using Beads/Dolt, Python, Node, and various IDE/OS tooling. | dep: git, dolt, beads, python, node, npm, yarn, pnpm, pytest, mypy, ruff, coverage
|
||||
- AGENTS.md | Defines operational guidelines and workflows for AI agents collaborating on a software project governed by OpenSpec | dep: OpenSpec, superpowers (brainstorming, writing-plans, test-driven-development, systematic-debugging, verification-before-completion, using-git-worktrees, dispatching-parallel-agents), git
|
||||
- CHANGELOG.md | Documents version history and notable changes for a project management and Git repository application
|
||||
- Makefile | Provides standardized development commands for managing a Docker-based full-stack application with API, web frontend, database, and testing infrastructure | dep: docker compose, alembic, pytest, ruff, mypy, playwright, npm, postgres, redis
|
||||
- README.md | Documentation for a self-hosted development platform that manages projects, git repositories, and development tools with OAuth2 authentication | dep: FastAPI, SQLAlchemy, Pydantic, Alembic, python-jose, React, TypeScript, Vite, React Router, Docker, PostgreSQL, Traefik, Authentik
|
||||
- docker-compose.traefik.yml | Deploys a multi-service application (PostgreSQL, Redis, web frontend, API) behind an existing Traefik reverse proxy with TLS termination | dep: docker, docker-compose, traefik, postgres, redis, node/vite, python/fastapi
|
||||
- docker-compose.yml | Defines a multi-service Docker Compose stack for a web application with PostgreSQL, Redis, API backend, and web frontend services | dep: Docker, Docker Compose, PostgreSQL, Redis, Vite, asyncpg, Python/FastAPI (implied), Node.js (implied)
|
||||
- progress.md | Documents the progress and remaining tasks for a backend-frontend refactoring project involving modularization, code reorganization, and verification.
|
||||
- swap-pane | Provides a command to swap the position of two tmux panes within a window or between windows | dep: tmux, client, window, layout, cmd-find, cmd-parse, options
|
||||
- ui-audit-spacing-typography.md | A detailed audit report identifying critical CSS styling gaps, mobile layout failures, and design system inconsistencies in a web application's UI components and stylesheets. | dep: React/TSX components, CSS stylesheets (global.css, utilities.css, tokens.css, page-specific CSS), JSX/TSX files in apps/web/src/components and apps/web/src/pages
|
||||
- ui-rework-foundations-apply.md | Documents the implementation of Pass 1 (Foundations) of a web UI spacing/typography/visual-rhythm rework, including design token expansion, primitive CSS class additions, component refactors, and OpenSpec documentation. | dep: CSS design tokens, React/TSX components, OpenSpec documentation system, Git/SSH, npm build toolchain
|
||||
- ui-rework-pass2-apply.md | Documents the implementation and verification of Pass 2 of a web UI spacing/typography rework, refactoring inline styles into utility classes and design tokens across six components. | dep: CSS custom properties, BEM methodology, utility-first CSS, React/TSX components, OpenSpec documentation system
|
||||
## arch
|
||||
Dockerized microservices architecture with Docker Compose orchestrating PostgreSQL, Redis, API, and web frontend services; uses Traefik reverse proxy with TLS termination, environment-based configuration, and Makefile-driven development workflow with documented UI design token system.
|
||||
Contains 13 files.
|
||||
## tags
|
||||
docker, ui, application, web, css, rework, redis, frontend
|
||||
docker, web, git, application, ui, rework, python, redis
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps
|
||||
|
||||
## role
|
||||
Contains the main application entry points and executable modules for the project.
|
||||
Package apps
|
||||
## parent
|
||||
index: ./.pi-map.index.md
|
||||
map: ./.pi-map.md
|
||||
|
||||
+2
-2
@@ -4,10 +4,10 @@ dir: apps
|
||||
index: apps/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Contains the main application entry points and executable modules for the project.
|
||||
Package apps
|
||||
## files
|
||||
## arch
|
||||
Modular application structure with separate deployable units, likely following microservices or multi-app monorepo pattern.
|
||||
Contains 0 files.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
|
||||
@@ -334,66 +334,73 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
|
||||
await session.write_input(message["bytes"])
|
||||
elif "text" in message:
|
||||
text = message["text"]
|
||||
# A text frame that parses to a JSON object with a
|
||||
# "type" field is a control message and must NEVER be
|
||||
# written to the PTY (e.g. the heartbeat {"type":"pong"}
|
||||
# must be consumed, not typed into the shell/pi). Handle
|
||||
# known types and ignore unknown ones. Everything else
|
||||
# (keystrokes, bracketed-paste content, plain text) is
|
||||
# forwarded as raw terminal input.
|
||||
ctrl = None
|
||||
if text.startswith("{"):
|
||||
# Control message (JSON)
|
||||
try:
|
||||
ctrl = json.loads(text)
|
||||
msg_type = ctrl.get("type")
|
||||
|
||||
if msg_type == "resize":
|
||||
cols = ctrl.get("cols", 80)
|
||||
rows = ctrl.get("rows", 24)
|
||||
logger.debug(
|
||||
"Received resize message for instance %s: %sx%s",
|
||||
instance_id,
|
||||
cols,
|
||||
rows,
|
||||
)
|
||||
await session.resize(cols, rows)
|
||||
elif msg_type == "ack":
|
||||
char_count = ctrl.get("chars", 0)
|
||||
if char_count > 0:
|
||||
session.acknowledge_data(char_count)
|
||||
elif msg_type == "reset":
|
||||
# Reset terminal session (scoped to current slot)
|
||||
logger.debug(
|
||||
"Resetting terminal session for instance %s (slot=%s)",
|
||||
session.instance_id,
|
||||
session_ref.slot_session_id,
|
||||
)
|
||||
await websocket.send_json(
|
||||
{"type": "status", "status": "resetting"}
|
||||
)
|
||||
|
||||
# Reset the session scoped to its slot
|
||||
new_session = await terminal_manager.reset_session(
|
||||
session.instance_id,
|
||||
session.container_id,
|
||||
startup_command=session.startup_command,
|
||||
session_id=session_ref.slot_session_id,
|
||||
name=session.name,
|
||||
container_user=session.container_user,
|
||||
)
|
||||
|
||||
# Update the mutable session reference
|
||||
session_ref.session = new_session
|
||||
|
||||
# Attach to new session
|
||||
await terminal_manager.attach_websocket(
|
||||
new_session, websocket
|
||||
)
|
||||
await websocket.send_json(
|
||||
{"type": "status", "status": "connected"}
|
||||
)
|
||||
|
||||
# Continue the loop with the new session
|
||||
continue
|
||||
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
# Not a valid JSON control message, treat as regular input
|
||||
await session.write_input(text.encode("utf-8"))
|
||||
else:
|
||||
parsed = None
|
||||
if isinstance(parsed, dict) and "type" in parsed:
|
||||
ctrl = parsed
|
||||
|
||||
if ctrl is None:
|
||||
await session.write_input(text.encode("utf-8"))
|
||||
continue
|
||||
|
||||
msg_type = ctrl["type"]
|
||||
if msg_type == "resize":
|
||||
cols = ctrl.get("cols", 80)
|
||||
rows = ctrl.get("rows", 24)
|
||||
logger.debug(
|
||||
"Received resize message for instance %s: %sx%s",
|
||||
instance_id,
|
||||
cols,
|
||||
rows,
|
||||
)
|
||||
await session.resize(cols, rows)
|
||||
elif msg_type == "ack":
|
||||
char_count = ctrl.get("chars", 0)
|
||||
if char_count > 0:
|
||||
session.acknowledge_data(char_count)
|
||||
elif msg_type == "reset":
|
||||
# Reset terminal session (scoped to current slot)
|
||||
logger.debug(
|
||||
"Resetting terminal session for instance %s (slot=%s)",
|
||||
session.instance_id,
|
||||
session_ref.slot_session_id,
|
||||
)
|
||||
await websocket.send_json(
|
||||
{"type": "status", "status": "resetting"}
|
||||
)
|
||||
|
||||
# Reset the session scoped to its slot
|
||||
new_session = await terminal_manager.reset_session(
|
||||
session.instance_id,
|
||||
session.container_id,
|
||||
startup_command=session.startup_command,
|
||||
session_id=session_ref.slot_session_id,
|
||||
name=session.name,
|
||||
container_user=session.container_user,
|
||||
)
|
||||
|
||||
# Update the mutable session reference
|
||||
session_ref.session = new_session
|
||||
|
||||
# Attach to new session
|
||||
await terminal_manager.attach_websocket(new_session, websocket)
|
||||
await websocket.send_json(
|
||||
{"type": "status", "status": "connected"}
|
||||
)
|
||||
|
||||
# Continue the loop with the new session
|
||||
continue
|
||||
elif message["type"] == "websocket.disconnect":
|
||||
break
|
||||
except Exception:
|
||||
|
||||
@@ -12,12 +12,15 @@ import signal
|
||||
import struct
|
||||
import fcntl
|
||||
import time
|
||||
import tty
|
||||
import uuid
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_session_counters_by_instance: dict[str, int] = {}
|
||||
|
||||
|
||||
class TerminalSession:
|
||||
"""Manages a single terminal session with event-driven PTY I/O.
|
||||
@@ -45,9 +48,6 @@ class TerminalSession:
|
||||
# Max WebSocket frame size
|
||||
MAX_FRAME_SIZE = 64 * 1024
|
||||
|
||||
# Session number counter per instance_id for auto-naming
|
||||
_instance_counters: dict[str, int] = {}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_id: str,
|
||||
@@ -98,11 +98,11 @@ class TerminalSession:
|
||||
# Ack timeout fallback
|
||||
self._ack_timeout_handle: asyncio.TimerHandle | None = None
|
||||
|
||||
@classmethod
|
||||
def _generate_name(cls, instance_id: str) -> str:
|
||||
@staticmethod
|
||||
def _generate_name(instance_id: str) -> str:
|
||||
"""Generate an auto-incremented session name for the instance."""
|
||||
count = cls._instance_counters.get(instance_id, 0) + 1
|
||||
cls._instance_counters[instance_id] = count
|
||||
count = _session_counters_by_instance.get(instance_id, 0) + 1
|
||||
_session_counters_by_instance[instance_id] = count
|
||||
return f"Session {count}"
|
||||
|
||||
async def start(self, startup_command: str | None = None) -> None:
|
||||
@@ -120,8 +120,21 @@ class TerminalSession:
|
||||
if self.container_user:
|
||||
exec_cmd.extend(["--user", self.container_user])
|
||||
|
||||
# Create a pseudo-terminal on the host
|
||||
# Create a pseudo-terminal on the host. The master must be
|
||||
# non-blocking: a browser paste can be larger than the PTY input
|
||||
# buffer, and write_input() drains it asynchronously without dropping
|
||||
# the closing bracketed-paste marker.
|
||||
self._master_fd, slave_fd = pty.openpty()
|
||||
os.set_blocking(self._master_fd, False)
|
||||
|
||||
# Put the host PTY into raw mode so it behaves as a pass-through
|
||||
# pipe. openpty() leaves the slave in canonical mode by default,
|
||||
# which line-buffers input, splits multiline pastes at newlines,
|
||||
# and mangles bracketed-paste markers before docker exec / the
|
||||
# foreground app (e.g. pi) ever see them. Real terminal discipline
|
||||
# (echo, canonical editing for readline) is provided by the
|
||||
# in-container PTY that `docker exec -t` allocates.
|
||||
tty.setraw(slave_fd)
|
||||
|
||||
# Set the terminal size initially
|
||||
self._set_terminal_size(self._cols, self._rows)
|
||||
@@ -194,6 +207,9 @@ class TerminalSession:
|
||||
|
||||
try:
|
||||
data = os.read(self._master_fd, 4096)
|
||||
except BlockingIOError:
|
||||
# The readiness notification raced with another callback.
|
||||
return
|
||||
except (OSError, IOError) as exc:
|
||||
logger.debug("PTY read error for session %s: %s", self.session_id, exc)
|
||||
self._handle_eof()
|
||||
@@ -333,12 +349,42 @@ class TerminalSession:
|
||||
pass
|
||||
logger.info("Session %s EOF handled, websockets closed", self.session_id)
|
||||
|
||||
async def _wait_for_write_ready(self, fd: int) -> None:
|
||||
"""Wait until a non-blocking PTY master can accept more input."""
|
||||
loop = asyncio.get_running_loop()
|
||||
writable = loop.create_future()
|
||||
|
||||
def mark_writable() -> None:
|
||||
if not writable.done():
|
||||
writable.set_result(None)
|
||||
|
||||
loop.add_writer(fd, mark_writable)
|
||||
try:
|
||||
await writable
|
||||
finally:
|
||||
loop.remove_writer(fd)
|
||||
|
||||
async def write_input(self, data: bytes) -> None:
|
||||
"""Write input to the PTY master."""
|
||||
"""Write all terminal input bytes to the PTY master in order."""
|
||||
if self._master_fd is None or self._closed:
|
||||
return
|
||||
|
||||
fd = self._master_fd
|
||||
remaining = memoryview(data)
|
||||
try:
|
||||
os.write(self._master_fd, data)
|
||||
while remaining and not self._closed and self._master_fd == fd:
|
||||
try:
|
||||
written = os.write(fd, remaining)
|
||||
except BlockingIOError:
|
||||
await self._wait_for_write_ready(fd)
|
||||
continue
|
||||
|
||||
if written == 0:
|
||||
await self._wait_for_write_ready(fd)
|
||||
continue
|
||||
|
||||
remaining = remaining[written:]
|
||||
|
||||
self.last_activity = time.time()
|
||||
except (OSError, IOError) as exc:
|
||||
logger.debug("PTY write error for session %s: %s", self.session_id, exc)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Unit tests for TerminalSession docker exec invocation."""
|
||||
"""Unit tests for TerminalSession PTY handling."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest # pyright: ignore[reportMissingImports]
|
||||
|
||||
from src.services.terminal.terminal_session import TerminalSession
|
||||
|
||||
@@ -19,16 +19,20 @@ async def test_start_passes_container_user_to_docker_exec() -> None:
|
||||
container_user="dev",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"src.services.terminal.terminal_session.pty.openpty",
|
||||
return_value=(1, 2),
|
||||
):
|
||||
with patch(
|
||||
with (
|
||||
patch(
|
||||
"src.services.terminal.terminal_session.pty.openpty",
|
||||
return_value=(1, 2),
|
||||
),
|
||||
patch("src.services.terminal.terminal_session.os.set_blocking") as set_blocking,
|
||||
patch("src.services.terminal.terminal_session.tty.setraw"),
|
||||
patch(
|
||||
"src.services.terminal.terminal_session.asyncio.create_subprocess_exec",
|
||||
new=AsyncMock(),
|
||||
) as mock_exec:
|
||||
with patch("src.services.terminal.terminal_session.os.close"):
|
||||
await session.start()
|
||||
) as mock_exec,
|
||||
patch("src.services.terminal.terminal_session.os.close"),
|
||||
):
|
||||
await session.start()
|
||||
|
||||
args, _kwargs = mock_exec.call_args
|
||||
assert "docker" in args
|
||||
@@ -37,6 +41,7 @@ async def test_start_passes_container_user_to_docker_exec() -> None:
|
||||
user_index = args.index("--user")
|
||||
assert args[user_index + 1] == "dev"
|
||||
assert "container-123" in args
|
||||
set_blocking.assert_called_once_with(1, False)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -49,16 +54,44 @@ async def test_start_omits_user_when_not_configured() -> None:
|
||||
container_id="container-123",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"src.services.terminal.terminal_session.pty.openpty",
|
||||
return_value=(1, 2),
|
||||
):
|
||||
with patch(
|
||||
with (
|
||||
patch(
|
||||
"src.services.terminal.terminal_session.pty.openpty",
|
||||
return_value=(1, 2),
|
||||
),
|
||||
patch("src.services.terminal.terminal_session.os.set_blocking"),
|
||||
patch("src.services.terminal.terminal_session.tty.setraw"),
|
||||
patch(
|
||||
"src.services.terminal.terminal_session.asyncio.create_subprocess_exec",
|
||||
new=AsyncMock(),
|
||||
) as mock_exec:
|
||||
with patch("src.services.terminal.terminal_session.os.close"):
|
||||
await session.start()
|
||||
) as mock_exec,
|
||||
patch("src.services.terminal.terminal_session.os.close"),
|
||||
):
|
||||
await session.start()
|
||||
|
||||
args, _kwargs = mock_exec.call_args
|
||||
assert "--user" not in args
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_input_retries_partial_pty_writes() -> None:
|
||||
"""A large paste is fully written even when the PTY accepts it in chunks."""
|
||||
session = TerminalSession(
|
||||
session_id=str(uuid.uuid4()),
|
||||
instance_id=uuid.uuid4(),
|
||||
container_id="container-123",
|
||||
)
|
||||
session._master_fd = 42
|
||||
|
||||
with patch(
|
||||
"src.services.terminal.terminal_session.os.write",
|
||||
side_effect=[2, 2, 1],
|
||||
) as write:
|
||||
await session.write_input(b"hello")
|
||||
|
||||
assert [bytes(call.args[1]) for call in write.call_args_list] == [
|
||||
b"hello",
|
||||
b"llo",
|
||||
b"o",
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/web
|
||||
|
||||
## role
|
||||
Frontend web application serving as the user-facing UI for the "headquarter" project, built as a React/Vite single-page application and containerized for production deployment.
|
||||
Frontend web application for a terminal/code-editor interface named "Headquarter," built with React/TypeScript using Vite and served as a static single-page app.
|
||||
## parent
|
||||
index: apps/.pi-map.index.md
|
||||
map: apps/.pi-map.md
|
||||
|
||||
+11
-11
@@ -4,21 +4,21 @@ dir: apps/web
|
||||
index: apps/web/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Frontend web application serving as the user-facing UI for the "headquarter" project, built as a React/Vite single-page application and containerized for production deployment.
|
||||
Frontend web application for a terminal/code-editor interface named "Headquarter," built with React/TypeScript using Vite and served as a static single-page app.
|
||||
## files
|
||||
- .env.example | Provides example environment variable configuration for a Vite-based frontend application | dep: Vite
|
||||
- .env.example | Template/example file defining environment variables for a Vite-based application's API and OAuth configuration | dep: Vite
|
||||
- .eslintrc.cjs | Configures ESLint for a TypeScript browser project with modern ES2022 module support | dep: eslint, @typescript-eslint/parser, @typescript-eslint/eslint-plugin
|
||||
- Dockerfile | Multi-stage Docker build for a Node.js/Vite frontend application compiled to static assets and served by nginx with non-root user security | dep: node:20-alpine, nginx:alpine, npm, wget
|
||||
- index.html | HTML entry point for a React/Vite single-page application that loads a TypeScript root component | dep: Google Fonts (Inter, IBM Plex Mono), /src/main.tsx
|
||||
- nginx.conf | Configures an nginx web server to serve a single-page application with gzip compression, client-side routing support, cache control for static assets, and a health check endpoint. | dep: nginx
|
||||
- package-lock.json | Auto-generated dependency lock file that records exact versions and resolved URLs of all npm packages for reproducible installs of a React-based web application named "headquarter-web" | dep: npm, react, react-dom, react-router-dom, axios, tailwindcss, prismjs, xterm, vite, vitest, typescript, eslint, @babel/core, @vitejs/plugin-react, @testing-library/react, jsdom
|
||||
- package.json | Defines the configuration, dependencies, and scripts for a React-based web application called "headquarter-web" built with Vite and TypeScript. | dep: react, react-dom, react-router-dom, axios, tailwindcss, prismjs, react-simple-code-editor, xterm, xterm-addon-fit, xterm-addon-web-links, xterm-addon-webgl, @phosphor-icons/react, vite, typescript, vitest, eslint, @testing-library/react, jsdom
|
||||
- tsconfig.json | Configures TypeScript compiler settings for a modern React/Vite web application with strict type checking and ES module output. | dep: TypeScript, Vite, React
|
||||
- vite.config.ts | Configures Vite build tool with React plugin, dev server port, and Vitest test settings | dep: vite, @vitejs/plugin-react
|
||||
- Dockerfile | Multi-stage Docker build for a Node.js/Vite frontend application compiled to static assets and served by nginx as a non-root user | dep: node:20-alpine, nginx:alpine, npm, wget
|
||||
- index.html | Entry point HTML file for a React application named "Headquarter" that loads a TypeScript module and Google Fonts | dep: Google Fonts (Inter, IBM Plex Mono), main.tsx module
|
||||
- nginx.conf | Configures Nginx as a web server for a single-page application with gzip compression, client-side routing support, cache control for static assets, and a health check endpoint. | dep: nginx
|
||||
- package-lock.json | Records exact dependency versions for a React-based web application to ensure reproducible installs | dep: npm, React, React Router, Tailwind CSS, Vite, Vitest, TypeScript, ESLint, xterm, PrismJS, axios, phosphor-icons
|
||||
- package.json | Defines a React-based web application project named "headquarter-web" with build tooling, testing, and UI dependencies for a terminal/code editor interface. | dep: react, react-dom, react-router-dom, vite, typescript, tailwindcss, axios, prismjs, xterm, @phosphor-icons/react, react-simple-code-editor, vitest, eslint
|
||||
- tsconfig.json | Configures TypeScript compiler options for a modern React/Vite project with ES2020 target and strict type checking | dep: TypeScript, React, Vite
|
||||
- vite.config.ts | Configures Vite build tool with React support, dev server port, and Vitest testing settings | dep: vite, @vitejs/plugin-react
|
||||
## arch
|
||||
Modern React SPA architecture using TypeScript with Vite build tooling, containerized via multi-stage Docker build producing static assets served by nginx, with strict type checking, ESLint code quality, and Vitest testing integration.
|
||||
Containerized single-page React/TypeScript app using Vite for build/dev, Vitest for testing, ESLint for linting, and nginx for static asset delivery with client-side routing support.
|
||||
## tags
|
||||
react, vite, application, eslint, typescript, web, nginx, xterm
|
||||
react, vite, eslint, application, typescript, nginx, configures, web
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/web/src
|
||||
|
||||
## role
|
||||
Frontend web application bootstrap and core infrastructure package providing React SPA initialization, routing, and shared type definitions.
|
||||
Frontend web client that bootstraps and renders a React single-page application with routing, authentication, and session-driven UI.
|
||||
## parent
|
||||
index: apps/web/.pi-map.index.md
|
||||
map: apps/web/.pi-map.md
|
||||
|
||||
@@ -4,15 +4,15 @@ dir: apps/web/src
|
||||
index: apps/web/src/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Frontend web application bootstrap and core infrastructure package providing React SPA initialization, routing, and shared type definitions.
|
||||
Frontend web client that bootstraps and renders a React single-page application with routing, authentication, and session-driven UI.
|
||||
## files
|
||||
- main.tsx | Entry point that bootstraps a React SPA with routing, authentication, and session context providers. | dep: react, react-dom/client, react-router-dom, ./router, ./state/auth, ./state/sessions, ./styles/tokens.css, ./styles/global.css, ./styles/utilities.css, ./styles/syntax-highlight.css, ./styles/pages/git-history.css, ./styles/pages/projects.css, ./styles/pages/sessions.css, ./styles/pages/ssh-keys.css, ./styles/pages/workspace-detail.css, ./styles/pages/workspaces.css
|
||||
- router.tsx | Defines the React Router configuration for a web application with nested routes, protected routes, and redirects. | exp: AppRouter | dep: react-router-dom, ./components/app-shell, ./components/protected-route, ./pages/DashboardPage, ./pages/PlaceholderPage, ./pages/ProfilePage, ./pages/ProjectsPage, ./pages/GitRepositoriesPage, ./pages/GitHistoryPage, ./pages/ProjectSettingsPage, ./pages/SettingsPage, ./pages/TerminalPage, ./pages/ToolWorkshopPage, ./pages/SshKeysPage, ./pages/ConfigProfilesPage, ./pages/SessionsPage, ./pages/WorkspacesPage, ./pages/WorkspaceDetailPage
|
||||
- types.ts | Defines core TypeScript type definitions for user sessions, projects, repositories, and workspaces in an application. | exp: SessionUser, SessionPayload, Project, WorkspaceSummary, RepositorySummary, ProjectWithRepos
|
||||
- main.tsx | Entry point that bootstraps a React SPA with routing, authentication, and session state management | dep: react, react-dom/client, react-router-dom, ./router, ./state/auth, ./state/sessions, ./styles/tokens.css, ./styles/global.css, ./styles/utilities.css, ./styles/syntax-highlight.css, ./styles/pages/git-history.css, ./styles/pages/projects.css, ./styles/pages/sessions.css, ./styles/pages/ssh-keys.css, ./styles/pages/workspace-detail.css, ./styles/pages/workspaces.css, react-dom
|
||||
- router.tsx | Defines the React Router configuration for a multi-page application with nested routes, protected authentication, and redirects. | exp: AppRouter | dep: react-router-dom, ./components/app-shell, ./components/protected-route, ./pages/DashboardPage, ./pages/PlaceholderPage, ./pages/ProfilePage, ./pages/ProjectsPage, ./pages/GitRepositoriesPage, ./pages/GitHistoryPage, ./pages/ProjectSettingsPage, ./pages/SettingsPage, ./pages/TerminalPage, ./pages/ToolWorkshopPage, ./pages/SshKeysPage, ./pages/ConfigProfilesPage, ./pages/SessionsPage, ./pages/WorkspacesPage, ./pages/WorkspaceDetailPage
|
||||
- types.ts | Defines TypeScript type declarations for user sessions, projects, repositories, and workspaces in an application. | exp: SessionUser, SessionPayload, Project, WorkspaceSummary, RepositorySummary, ProjectWithRepos
|
||||
## arch
|
||||
Single-page application using React with context-based dependency injection for auth/session state, declarative nested routing with route guards, and centralized TypeScript domain modeling.
|
||||
React SPA built with a declarative React Router configuration, protected/nested routes, and centralized TypeScript types for users, sessions, projects, repositories, and workspaces.
|
||||
## tags
|
||||
pages, styles, css, router, react, session, project, summary
|
||||
pages, styles, css, router, react, session, dom, project
|
||||
## symbols
|
||||
- AppRouter
|
||||
- SessionUser
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/web/src/components
|
||||
|
||||
## role
|
||||
Provides reusable, type-safe React UI components for the web application including layout shells, data display states, iconography, code editing, syntax highlighting, authentication guards, and toast notification rules.
|
||||
Package components
|
||||
## parent
|
||||
index: apps/web/src/.pi-map.index.md
|
||||
map: apps/web/src/.pi-map.md
|
||||
|
||||
@@ -4,22 +4,22 @@ dir: apps/web/src/components
|
||||
index: apps/web/src/components/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides reusable, type-safe React UI components for the web application including layout shells, data display states, iconography, code editing, syntax highlighting, authentication guards, and toast notification rules.
|
||||
Package components
|
||||
## files
|
||||
- app-shell.tsx | Provides the main application layout shell with navigation, session management, and responsive mobile/desktop views for a React application. | exp: AppShell | dep: react-router-dom, ../api/sessions, ../hooks/use-theme, ../utils/open-session, ../state/auth, ../state/sessions, ../hooks/use-mobile-viewport, ../state/events, ../state/toast, ../state/notifications, ./features/notification/event-toast-bridge, ./features/notification/notification-center, ./icon, ./features/mobile/mobile-nav, ./features/tool/start-tool-fab, ../utils/icons
|
||||
- code-editor.tsx | A React component that renders a syntax-highlighted code editor with line numbers using a lightweight editor library. | exp: CodeEditor | dep: react, react-simple-code-editor, ../utils/language
|
||||
- data-states.tsx | Defines reusable React components for displaying loading, error, and empty data states in a UI. | exp: LoadingState, ErrorState, EmptyState | dep: ./icon, icon
|
||||
- icon.tsx | Provides a centralized, type-safe React component that maps semantic icon names to Phosphor icons with configurable size, weight, color, and accessibility properties. | exp: IconName, IconProps, Icon | dep: react, @phosphor-icons/react
|
||||
- loading-overlay.tsx | Renders an accessible loading spinner overlay with optional text label that conditionally displays based on visibility prop | exp: func:LoadingOverlay({ visible, label }: LoadingOverlayProps) | dep: ./icon, icon
|
||||
- protected-route.test.tsx | Tests a ProtectedRoute component that guards content based on authentication state | dep: @testing-library/react, react-router-dom, vitest, ./protected-route, ../state/auth
|
||||
- protected-route.tsx | A React component that conditionally renders children or redirects to login based on authentication state | exp: ProtectedRoute | dep: react-router-dom, ../state/auth
|
||||
- syntax-highlighter.tsx | A React component that renders syntax-highlighted code blocks with optional line numbers, language badge, and copy-to-clipboard functionality. | exp: SyntaxHighlighter | dep: react, ./icon, ../utils/language
|
||||
- toast-rules.test.ts | Unit tests for toast notification rules that map instance events to UI categories and severity levels. | dep: vitest, ./toast-rules, ../types/events
|
||||
- toast-rules.ts | Maps instance events to toast notifications with deduplication logic to prevent spam. | exp: func:mapEventToCategory(event: InstanceEventPayload) → string, call:event.event.startsWith, func:mapEventToSeverity(event: InstanceEventPayload) → "info" | "warning" | "error" | "success", func:handleEventToast(event: InstanceEventPayload) → void, call:shouldShowToast, call:toast.info, call:toast.success, call:toast.warning, call:toast.error, func:clearToastDedup() → void, call:lastToastTime.clear | dep: ../state/toast, ../types/events, toast, InstanceEventPayload
|
||||
- app-shell.tsx | Main application layout shell with responsive navigation, session management, and global state providers for notifications and toasts. | exp: AppShell | dep: react-router-dom, ../api/sessions, ../hooks/use-theme, ../utils/open-session, ../state/auth, ../state/sessions, ../hooks/use-mobile-viewport, ../state/events, ../state/toast, ../state/notifications, ./features/notification/event-toast-bridge, ./features/notification/notification-center, ./icon, ./features/mobile/mobile-nav, ./features/tool/start-tool-fab, ../utils/icons
|
||||
- code-editor.tsx | A React component that renders a syntax-highlighting code editor with line numbers using react-simple-code-editor. | exp: CodeEditor | dep: react, react-simple-code-editor, ../utils/language
|
||||
- data-states.tsx | Provides reusable UI components for rendering loading, error, and empty data states. | exp: LoadingState, ErrorState, EmptyState | dep: ./icon, Icon
|
||||
- icon.tsx | Provides a centralized, type-safe React icon component that maps semantic names to Phosphor icons with configurable sizes, weights, and colors. | exp: IconName, IconProps, Icon | dep: react, @phosphor-icons/react
|
||||
- loading-overlay.tsx | Renders an accessible loading overlay with optional text label when visible | exp: func:LoadingOverlay({ visible, label }: LoadingOverlayProps) | dep: ./icon, icon
|
||||
- protected-route.test.tsx | Tests a ProtectedRoute component that guards content based on authentication state, showing loading or redirecting to login | dep: @testing-library/react, react-router-dom, vitest, ./protected-route
|
||||
- protected-route.tsx | Conditionally renders children or redirects to login based on authentication state | exp: ProtectedRoute | dep: react-router-dom, ../state/auth
|
||||
- syntax-highlighter.tsx | A React component that renders syntax-highlighted code with optional line numbers and a copy-to-clipboard button | exp: SyntaxHighlighter | dep: react, ./icon, ../utils/language, React, icon, language utils
|
||||
- toast-rules.test.ts | Unit tests for mapping instance events to toast notification categories and severities | dep: vitest, ./toast-rules, ../types/events
|
||||
- toast-rules.ts | Maps instance events to toast notifications with deduplication logic | exp: func:mapEventToCategory(event: InstanceEventPayload) → string, call:event.event.startsWith, func:mapEventToSeverity(event: InstanceEventPayload) → "info" | "warning" | "error" | "success", func:handleEventToast(event: InstanceEventPayload) → void, call:shouldShowToast, call:toast.info, call:toast.success, call:toast.warning, call:toast.error, func:clearToastDedup() → void, call:lastToastTime.clear | dep: ../state/toast, ../types/events, toast, InstanceEventPayload
|
||||
## arch
|
||||
Follows a component-based architecture with presentational/ui components, separation of concerns (display logic vs. business rules in `.ts` files), test co-location, and centralized abstractions for common patterns like icons and data states.
|
||||
Contains 10 files.
|
||||
## tags
|
||||
toast, state, react, icon, loading, event, code, editor
|
||||
toast, state, icon, react, loading, code, event, editor
|
||||
## symbols
|
||||
- LoadingOverlay
|
||||
- mapEventToCategory
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/web/src/components/features
|
||||
|
||||
## role
|
||||
Reusable UI components organized by domain-specific feature areas for the web application frontend.
|
||||
Feature-specific UI components organized by business domain/capability for the web
|
||||
## parent
|
||||
index: apps/web/src/components/.pi-map.index.md
|
||||
map: apps/web/src/components/.pi-map.md
|
||||
|
||||
@@ -4,10 +4,10 @@ dir: apps/web/src/components/features
|
||||
index: apps/web/src/components/features/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Reusable UI components organized by domain-specific feature areas for the web application frontend.
|
||||
Feature-specific UI components organized by business domain/capability for the web
|
||||
## files
|
||||
## arch
|
||||
Feature-based component colocation pattern with domain-driven folder structure, separating shared/common components from page-specific feature implementations.
|
||||
Contains 0 files.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: apps/web/src/components/features/terminal
|
||||
|
||||
## role
|
||||
Provides responsive, cross-platform terminal UI components with session management, virtual special keys, and WebSocket-backed xterm.js integration for web-based terminal access.
|
||||
Provides the UI components for a responsive, multi-session terminal interface in the web app, including desktop and mobile layouts, session tabs, and special-key input controls.
|
||||
## parent
|
||||
index: apps/web/src/components/features/.pi-map.index.md
|
||||
map: apps/web/src/components/features/.pi-map.md
|
||||
|
||||
@@ -4,19 +4,19 @@ dir: apps/web/src/components/features/terminal
|
||||
index: apps/web/src/components/features/terminal/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides responsive, cross-platform terminal UI components with session management, virtual special keys, and WebSocket-backed xterm.js integration for web-based terminal access.
|
||||
Provides the UI components for a responsive, multi-session terminal interface in the web app, including desktop and mobile layouts, session tabs, and special-key input controls.
|
||||
## files
|
||||
- DesktopTerminalView.tsx | Renders a desktop terminal view with session tabs, fullscreen controls, and multiple terminal instances | exp: DesktopTerminalView | dep: react, ./terminal, ./terminal-session-tabs, ../../../api/terminal
|
||||
- MobileTerminalView.tsx | Renders a mobile-optimized terminal interface with session tabs, toolbar controls, special keys input, and keyboard-aware layout adjustments. | exp: MobileTerminalView | dep: react, ./terminal, ./terminal-session-tabs, ../../icon, ./special-keys-strip, ./special-keys-panel, ../../../hooks/use-special-keys, ../../../api/terminal, terminal, terminal-session-tabs, icon, special-keys-strip, special-keys-panel, use-special-keys, api/terminal
|
||||
- special-keys-panel.tsx | Renders an overlay panel of special keyboard keys (Home, End, F1-F12, Ctrl+C, etc.) that sends terminal sequences when clicked and supports modifier key combinations. | exp: SpecialKeysPanel | dep: react, ../../../hooks/use-special-keys, React, use-special-keys hook
|
||||
- special-keys-strip.tsx | Renders a strip of virtual special keys (Esc, Tab, Ctrl, Alt, arrows, etc.) for sending terminal key sequences with optional modifier support. | exp: SpecialKeysStrip | dep: react, ../../../hooks/use-special-keys, use-special-keys hook
|
||||
- terminal-session-tabs.test.tsx | Tests a React terminal session tabs component for rendering, selection, closing with confirmation, renaming, session limits, and connection status display. | dep: @testing-library/react, vitest, ./terminal-session-tabs
|
||||
- terminal-session-tabs.tsx | Renders an interactive tab bar for managing multiple terminal sessions with selection, renaming, closing, and creation capabilities | exp: TerminalSessionInfo, TerminalSessionTabsProps, TerminalSessionTabs | dep: react, React, useState, useRef, useCallback
|
||||
- terminal.tsx | A React terminal component that provides an interactive xterm.js-based terminal with WebSocket connectivity, mobile touch support, and session management. | exp: TerminalProps, TerminalRef, TerminalComponent | dep: react, xterm, xterm-addon-fit, xterm-addon-web-links, xterm/css/xterm.css, ../../../hooks/use-special-keys, React
|
||||
- DesktopTerminalView.tsx | Renders the desktop layout for a multi-session terminal UI, managing tabs, fullscreen mode, font controls, and reset confirmation. | exp: DesktopTerminalView | dep: react, ./terminal, ./terminal-session-tabs, ../../../api/terminal, React, TerminalComponent, TerminalSessionTabs, TerminalSession
|
||||
- MobileTerminalView.tsx | Renders a mobile-specific terminal interface with session tabs, toolbar controls, special key inputs, and multiple terminal instances. | exp: MobileTerminalView | dep: react, ./terminal, ./terminal-session-tabs, ../../icon, ./special-keys-strip, ./special-keys-panel, ../../../hooks/use-special-keys, ../../../api/terminal, React, TerminalComponent, TerminalSessionTabs, Icon, SpecialKeysStrip, SpecialKeysPanel
|
||||
- special-keys-panel.tsx | Renders a modal overlay panel of special keyboard keys (Home, End, F1-F12, Ctrl+C, etc.) that sends escape sequences to a terminal when clicked. | exp: SpecialKeysPanel | dep: react, ../../../hooks/use-special-keys
|
||||
- special-keys-strip.tsx | Renders a touch-accessible strip of special keyboard keys (Esc, Tab, arrows, modifiers) that sends terminal escape sequences with optional modifier support. | exp: SpecialKeysStrip | dep: react, ../../../hooks/use-special-keys, React, use-special-keys hook
|
||||
- terminal-session-tabs.test.tsx | Unit tests for a TerminalSessionTabs React component covering tab rendering, selection, close confirmation, inline renaming, session limit enforcement, and connection status display. | dep: @testing-library/react, vitest, ./terminal-session-tabs
|
||||
- terminal-session-tabs.tsx | Renders a tabbed interface for managing multiple terminal sessions with support for switching, renaming, closing with confirmation, and creating new sessions. | exp: TerminalSessionInfo, TerminalSessionTabsProps, TerminalSessionTabs | dep: react, React
|
||||
- terminal.tsx | | exp: TerminalProps, TerminalRef, TerminalComponent | dep: react, xterm, xterm-addon-fit, xterm-addon-web-links, xterm/css/xterm.css, ../../../hooks/use-special-keys
|
||||
## arch
|
||||
Component-based architecture with platform-specific view layers (desktop/mobile), modular special keys subsystems (strip/overlay panel), tab-based session management with React state, and xterm.js integration via WebSocket abstraction with touch/mobile adaptations.
|
||||
Modular React component architecture organized by feature, with separate presentational components for desktop/mobile viewports, shared tab/session management, and helper components for terminal input handling.
|
||||
## tags
|
||||
terminal, special, keys, session, tabs, react, view, strip
|
||||
terminal, special, session, keys, react, tabs, view, strip
|
||||
## symbols
|
||||
- DesktopTerminalView
|
||||
- MobileTerminalView
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getTerminalScrollbackLimit } from "./terminal.tsx";
|
||||
|
||||
describe("getTerminalScrollbackLimit", () => {
|
||||
it("retains normal-buffer history for custom mobile swipe scrolling", () => {
|
||||
expect(getTerminalScrollbackLimit(true)).toBe(10_000);
|
||||
});
|
||||
|
||||
it("keeps desktop scrollback disabled to prevent stale-frame wheel scrolling", () => {
|
||||
expect(getTerminalScrollbackLimit(false)).toBe(0);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -169,6 +169,10 @@ export const useTerminalPage = () => {
|
||||
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
|
||||
if (!isAltShift) return;
|
||||
|
||||
const activeSessionIndex = activeSessionId
|
||||
? sessions.findIndex((session) => session.id === activeSessionId)
|
||||
: -1;
|
||||
|
||||
switch (e.key.toLowerCase()) {
|
||||
case "n":
|
||||
e.preventDefault();
|
||||
@@ -187,17 +191,14 @@ export const useTerminalPage = () => {
|
||||
break;
|
||||
case "arrowleft":
|
||||
e.preventDefault();
|
||||
if (activeSessionId) {
|
||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
||||
if (idx > 0) setActiveSessionId(sessions[idx - 1].id);
|
||||
if (activeSessionIndex > 0) {
|
||||
setActiveSessionId(sessions[activeSessionIndex - 1].id);
|
||||
}
|
||||
break;
|
||||
case "arrowright":
|
||||
e.preventDefault();
|
||||
if (activeSessionId) {
|
||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
||||
if (idx < sessions.length - 1)
|
||||
setActiveSessionId(sessions[idx + 1].id);
|
||||
if (activeSessionIndex >= 0 && activeSessionIndex < sessions.length - 1) {
|
||||
setActiveSessionId(sessions[activeSessionIndex + 1].id);
|
||||
}
|
||||
break;
|
||||
case "r":
|
||||
@@ -208,6 +209,8 @@ export const useTerminalPage = () => {
|
||||
e.preventDefault();
|
||||
setIsFullscreen((prev) => !prev);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ProjectsPage } from "./ProjectsPage";
|
||||
import * as projectsApi from "../api/projects";
|
||||
import { SessionsProvider } from "../state/sessions";
|
||||
import type { ProjectWithRepos } from "../types";
|
||||
|
||||
const mockProjects = [
|
||||
vi.mock("../api/sessions", () => ({
|
||||
getUserSessions: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const mockProjects: ProjectWithRepos[] = [
|
||||
{
|
||||
id: "proj-1",
|
||||
name: "Alpha Project",
|
||||
description: "First project",
|
||||
owner_id: "user-1",
|
||||
default_ssh_key_id: null,
|
||||
repositories: [],
|
||||
created_at: "2026-07-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "proj-2",
|
||||
@@ -19,6 +28,8 @@ const mockProjects = [
|
||||
description: null,
|
||||
owner_id: "user-1",
|
||||
default_ssh_key_id: null,
|
||||
repositories: [],
|
||||
created_at: "2026-07-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -31,9 +42,7 @@ describe("ProjectsPage", () => {
|
||||
it("renders loading state initially", () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||
);
|
||||
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
|
||||
});
|
||||
@@ -41,9 +50,7 @@ describe("ProjectsPage", () => {
|
||||
it("renders project list after loading", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -56,9 +63,7 @@ describe("ProjectsPage", () => {
|
||||
it("renders empty state when no projects", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -69,9 +74,7 @@ describe("ProjectsPage", () => {
|
||||
it("renders error state with retry button", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -85,9 +88,7 @@ describe("ProjectsPage", () => {
|
||||
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -118,9 +119,7 @@ describe("ProjectsPage", () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -138,16 +137,14 @@ describe("ProjectsPage", () => {
|
||||
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
|
||||
const alphaCard = screen.getByText("Alpha Project").closest(".project-list-item") as HTMLElement | null;
|
||||
if (!alphaCard) throw new Error("Card not found");
|
||||
|
||||
fireEvent.click(within(alphaCard).getByRole("button", { name: /edit/i }));
|
||||
@@ -171,16 +168,14 @@ describe("ProjectsPage", () => {
|
||||
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
|
||||
const alphaCard = screen.getByText("Alpha Project").closest(".project-list-item") as HTMLElement | null;
|
||||
if (!alphaCard) throw new Error("Card not found");
|
||||
|
||||
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
|
||||
|
||||
@@ -152,7 +152,9 @@
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-family:
|
||||
"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
monospace;
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
@@ -938,6 +940,14 @@ a.nav-item,
|
||||
overscroll-behavior: auto;
|
||||
max-height: 100% !important;
|
||||
width: 100% !important;
|
||||
/* Full-screen TUI tools repaint in place; scrollback is 0 (see terminal.tsx).
|
||||
Hide the viewport scrollbar so it never appears or captures input. */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* On mobile the custom touch handler scrolls the buffer; disable native
|
||||
@@ -3809,7 +3819,9 @@ a:active,
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
transition:
|
||||
background 0.12s ease,
|
||||
color 0.12s ease;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
@@ -3836,7 +3848,9 @@ a:active,
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: background 0.12s ease, border-color 0.12s ease;
|
||||
transition:
|
||||
background 0.12s ease,
|
||||
border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.workspace-header-action-btn:hover {
|
||||
@@ -3911,7 +3925,9 @@ a:active,
|
||||
color: var(--muted);
|
||||
font-size: var(--font-size-xs);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
transition:
|
||||
background 0.12s ease,
|
||||
color 0.12s ease;
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
@@ -4157,7 +4173,10 @@ a:active,
|
||||
}
|
||||
|
||||
.drag-item-active {
|
||||
background: var(--brand-light, color-mix(in srgb, var(--brand) 10%, transparent));
|
||||
background: var(
|
||||
--brand-light,
|
||||
color-mix(in srgb, var(--brand) 10%, transparent)
|
||||
);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
@@ -4173,7 +4192,9 @@ a:active,
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: auto;
|
||||
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-family:
|
||||
"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-normal);
|
||||
}
|
||||
@@ -4321,7 +4342,9 @@ a:active,
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.sidebar-create-button:hover {
|
||||
@@ -4346,7 +4369,9 @@ a:active,
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.mobile-fab:hover {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Restore Mobile Terminal Scrolling
|
||||
|
||||
## Summary
|
||||
|
||||
The recent terminal scrollback optimization disabled scrollback for every viewport. Mobile terminal swipe handling still scrolls xterm's normal buffer programmatically, so swipes in normal-buffer tools no longer have retained output to move through.
|
||||
|
||||
## Root Cause
|
||||
|
||||
`468f342` changed the terminal configuration to `scrollback: 0` globally to prevent stale repaint frames and wheel scrolling on desktop. The mobile touch handler calls `term.scrollLines()` when the normal buffer is active. With zero scrollback, that call has no scrollable history and becomes a no-op.
|
||||
|
||||
## Scope
|
||||
|
||||
- `apps/web/src/components/features/terminal/terminal.tsx`
|
||||
- Focused terminal configuration test
|
||||
|
||||
## Fix
|
||||
|
||||
Retain a bounded xterm scrollback buffer on mobile only (`10000` lines), while leaving desktop at zero scrollback and with wheel sensitivity disabled. Mobile's existing custom touch handler remains responsible for moving through normal-buffer history; alternate-screen swipes continue to send SGR wheel events to the active TUI.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] A mobile terminal with normal-buffer output exceeding one screen scrolls via a vertical swipe.
|
||||
- [ ] Alternate-screen terminal scrolling continues to use the existing SGR wheel-event path.
|
||||
- [ ] Desktop keeps zero xterm scrollback and disabled native wheel scrolling, so stale repaint frames do not return.
|
||||
- [ ] Focused unit test and frontend quality gates pass.
|
||||
|
||||
## Related
|
||||
|
||||
- `468f342 fix(terminal): hide scrollbar and stop stale-frame wheel scroll for TUI tools`
|
||||
- `openspec/changes/fix-terminal-container-overflow`
|
||||
@@ -0,0 +1,9 @@
|
||||
# Restore Mobile Terminal Scrolling — Tasks
|
||||
|
||||
- [x] Add a mobile-specific terminal scrollback limit while preserving zero scrollback on desktop.
|
||||
- [x] Reinitialize the terminal when the responsive mobile classification changes so its scrollback and touch handler match the active viewport.
|
||||
- [x] Add focused tests for the responsive scrollback configuration.
|
||||
- [x] Run the full frontend test suite (89 tests passed after repairing the `ProjectsPage` test setup).
|
||||
- [x] Run frontend typecheck, lint, focused tests, and production build.
|
||||
- [ ] Perform mobile normal-buffer and alternate-screen manual QA.
|
||||
- [ ] Update project maps for changed source files (the map patch tool currently fails with an unsupported `temperature` parameter).
|
||||
Reference in New Issue
Block a user