Compare commits

...

10 Commits

Author SHA1 Message Date
Developer bb38b37ceb fix: reinitialize terminal on mobile viewport changes
Recreate the terminal when responsive classification changes so mobile scrollback and touch listeners are installed.\n\nOpenSpec: fix-mobile-terminal-scrolling\nQuality gates: npm run typecheck, npm run lint, npm test (89 passed), npm run build
2026-07-14 19:41:34 +00:00
Developer 6698c20f25 fix: restore mobile terminal scrolling
- Retain xterm normal-buffer history on mobile while preserving desktop zero-scrollback behavior\n- Repair ProjectsPage tests for session context and current project list markup\n- Add focused terminal scrollback coverage\n\nOpenSpec: fix-mobile-terminal-scrolling\nQuality gates: npm run typecheck, npm run lint, npm test (89 passed), npm run build
2026-07-14 19:07:02 +00:00
Developer 5e06a2a226 Merge fix/terminal-paste-partial-writes into dev 2026-07-14 10:34:33 +00:00
Developer 49180e4c6d fix: fully drain terminal paste writes to PTY
- Make the host PTY master non-blocking and wait for write readiness.
- Retry partial writes so a large bracketed paste always delivers its closing
  marker instead of leaving pi in paste mode.
- Add regression coverage for partial PTY writes and resolve diagnostics.

Quality gates: ruff, mypy, focused pytest (3 passed)
2026-07-14 10:34:33 +00:00
Developer 7a5538b53f Merge fix/web-terminal-bracketed-paste into dev 2026-07-14 10:02:30 +00:00
Developer 41d24beade fix: forward browser terminal pastes as bracketed input
- Track application bracketed-paste mode from terminal output.
- Capture browser and mobile clipboard pastes only while that mode is enabled,
  normalizing line endings and sending a single BPM-framed input event.
- Remove the synchronous output decoding and console diagnostics that could
  stall terminal rendering under output load.
- Keep the terminal's zero-scrollback configuration and resolve existing
  no-case-declarations lint blockers in terminal keyboard shortcuts.

Quality gates: npm run typecheck, npm run lint
2026-07-14 10:02:30 +00:00
Developer d1777b88ad Merge fix/terminal-paste-bpm into dev 2026-07-13 13:42:56 +00:00
Developer 691bbaa87b fix: restore web terminal multiline paste by reverting scrollback to 10000
- Revert xterm.js scrollback from 0 to 10000; empirical correlation with
  bracketed-paste mode failing (each line submitted as separate command).
- Add temporary browser-console diagnostics to confirm whether xterm receives
  pi's \e[?2004h enable sequence and whether outbound paste is BPM-wrapped.
- Keep CSS scrollbar hiding and wheel-sensitivity 0 so the original scroll-jank
  fix remains effective.

Typecheck: passed (apps/web)
2026-07-13 13:42:56 +00:00
Developer 6191565e80 Merge branch 'fix/terminal-tui-scrollbar' into dev 2026-07-11 12:07:58 +00:00
Developer 468f34202a fix(terminal): hide scrollbar and stop stale-frame wheel scroll for TUI tools
The web terminal only hosts full-screen TUI tools (pi-agent, opencode),
which repaint in place in the normal buffer and do not use the alternate
screen or mouse tracking. With scrollback enabled, every repaint
accumulated as history, so xterm's viewport scrollbar appeared and the
mouse-wheel scrolled through stale frames instead of interacting with the
app; the scrollbar column also perturbed FitAddon's column count.

- terminal.tsx: set scrollback:0 and scrollSensitivity/fastScrollSensitivity:0
  so only the live viewport is kept (no bar, no stale-frame wheel jank).
- utilities.css: hide .xterm-viewport scrollbar (scrollbar-width:none +
  ::-webkit-scrollbar display:none) as belt-and-suspenders.

Wheel no longer scrolls stale frames; in-app scrolling uses the tool's own
keys. Headquarter-only change; no tool is touched.
2026-07-11 12:07:47 +00:00
23 changed files with 1173 additions and 960 deletions
+4 -13
View File
@@ -16,7 +16,7 @@ dir: .
Trust boundary: index routes, map orients, source decides. Trust boundary: index routes, map orients, source decides.
## role ## 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 ## parent
- -
## children ## children
@@ -32,15 +32,6 @@ Infrastructure and configuration package for "Headquarter," a self-hosted projec
- .pi - .pi
index: .pi/.pi-map.index.md index: .pi/.pi-map.index.md
map: .pi/.pi-map.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 - .sisyphus
index: .sisyphus/.pi-map.index.md index: .sisyphus/.pi-map.index.md
map: .sisyphus/.pi-map.md map: .sisyphus/.pi-map.md
@@ -62,12 +53,12 @@ Infrastructure and configuration package for "Headquarter," a self-hosted projec
- scripts - scripts
index: scripts/.pi-map.index.md index: scripts/.pi-map.index.md
map: scripts/.pi-map.md map: scripts/.pi-map.md
- tests
index: tests/.pi-map.index.md
map: tests/.pi-map.md
- tool-images - tool-images
index: tool-images/.pi-map.index.md index: tool-images/.pi-map.index.md
map: tool-images/.pi-map.md map: tool-images/.pi-map.md
- uploads
index: uploads/.pi-map.index.md
map: uploads/.pi-map.md
## files ## files
- .env.example - .env.example
- .gitignore - .gitignore
+16 -16
View File
@@ -18,25 +18,25 @@ index: ./.pi-map.index.md
Trust boundary: index routes, map orients, source decides. Trust boundary: index routes, map orients, source decides.
## role ## 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 ## 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 - .env.example | Provides example environment variable configuration for a full-stack application with database, caching, authentication, and deployment settings
- .gitignore | Specifies intentionally untracked files and directories to exclude from version control across multiple technologies and environments. - .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 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 - 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 notable changes and version history for a project management application with Git repository features. - CHANGELOG.md | Documents version history and notable changes for a project management and Git repository application
- 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 - 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 | Provides documentation and onboarding instructions for "Headquarter," a self-hosted platform for managing projects, git repositories, and development tools. - 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 | 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.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-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 - 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 | Tracks progress of a backend-frontend refactoring project across completed phases, pending tasks, and remaining work. - progress.md | Documents the progress and remaining tasks for a backend-frontend refactoring project involving modularization, code reorganization, and verification.
- swap-pane | Empty package initialization file that marks a directory as a Python package. - 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 | 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-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 | 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-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 | Implementation report documenting a UI component cleanup pass that refactored inline styles into CSS utility classes and design tokens. - 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 ## 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 ## tags
docker, ui, application, web, css, rework, redis, frontend docker, web, git, application, ui, rework, python, redis
## symbols ## symbols
- -
## workflows ## workflows
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps dir: apps
## role ## role
Contains the main application entry points and executable modules for the project. Package apps
## parent ## parent
index: ./.pi-map.index.md index: ./.pi-map.index.md
map: ./.pi-map.md map: ./.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: apps
index: apps/.pi-map.index.md index: apps/.pi-map.index.md
## role ## role
Contains the main application entry points and executable modules for the project. Package apps
## files ## files
## arch ## arch
Modular application structure with separate deployable units, likely following microservices or multi-app monorepo pattern. Contains 0 files.
## tags ## tags
- -
## symbols ## symbols
@@ -19,6 +19,8 @@ from typing import Any
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_session_counters_by_instance: dict[str, int] = {}
class TerminalSession: class TerminalSession:
"""Manages a single terminal session with event-driven PTY I/O. """Manages a single terminal session with event-driven PTY I/O.
@@ -46,9 +48,6 @@ class TerminalSession:
# Max WebSocket frame size # Max WebSocket frame size
MAX_FRAME_SIZE = 64 * 1024 MAX_FRAME_SIZE = 64 * 1024
# Session number counter per instance_id for auto-naming
_instance_counters: dict[str, int] = {}
def __init__( def __init__(
self, self,
session_id: str, session_id: str,
@@ -99,11 +98,11 @@ class TerminalSession:
# Ack timeout fallback # Ack timeout fallback
self._ack_timeout_handle: asyncio.TimerHandle | None = None self._ack_timeout_handle: asyncio.TimerHandle | None = None
@classmethod @staticmethod
def _generate_name(cls, instance_id: str) -> str: def _generate_name(instance_id: str) -> str:
"""Generate an auto-incremented session name for the instance.""" """Generate an auto-incremented session name for the instance."""
count = cls._instance_counters.get(instance_id, 0) + 1 count = _session_counters_by_instance.get(instance_id, 0) + 1
cls._instance_counters[instance_id] = count _session_counters_by_instance[instance_id] = count
return f"Session {count}" return f"Session {count}"
async def start(self, startup_command: str | None = None) -> None: async def start(self, startup_command: str | None = None) -> None:
@@ -121,8 +120,12 @@ class TerminalSession:
if self.container_user: if self.container_user:
exec_cmd.extend(["--user", 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() 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 # Put the host PTY into raw mode so it behaves as a pass-through
# pipe. openpty() leaves the slave in canonical mode by default, # pipe. openpty() leaves the slave in canonical mode by default,
@@ -204,6 +207,9 @@ class TerminalSession:
try: try:
data = os.read(self._master_fd, 4096) data = os.read(self._master_fd, 4096)
except BlockingIOError:
# The readiness notification raced with another callback.
return
except (OSError, IOError) as exc: except (OSError, IOError) as exc:
logger.debug("PTY read error for session %s: %s", self.session_id, exc) logger.debug("PTY read error for session %s: %s", self.session_id, exc)
self._handle_eof() self._handle_eof()
@@ -343,12 +349,42 @@ class TerminalSession:
pass pass
logger.info("Session %s EOF handled, websockets closed", self.session_id) 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: 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: if self._master_fd is None or self._closed:
return return
fd = self._master_fd
remaining = memoryview(data)
try: 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() self.last_activity = time.time()
except (OSError, IOError) as exc: except (OSError, IOError) as exc:
logger.debug("PTY write error for session %s: %s", self.session_id, exc) logger.debug("PTY write error for session %s: %s", self.session_id, exc)
+45 -14
View File
@@ -1,9 +1,9 @@
"""Unit tests for TerminalSession docker exec invocation.""" """Unit tests for TerminalSession PTY handling."""
import uuid import uuid
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest import pytest # pyright: ignore[reportMissingImports]
from src.services.terminal.terminal_session import TerminalSession from src.services.terminal.terminal_session import TerminalSession
@@ -19,16 +19,19 @@ async def test_start_passes_container_user_to_docker_exec() -> None:
container_user="dev", container_user="dev",
) )
with patch( with (
patch(
"src.services.terminal.terminal_session.pty.openpty", "src.services.terminal.terminal_session.pty.openpty",
return_value=(1, 2), return_value=(1, 2),
): ),
with patch("src.services.terminal.terminal_session.tty.setraw"): patch("src.services.terminal.terminal_session.os.set_blocking") as set_blocking,
with patch( patch("src.services.terminal.terminal_session.tty.setraw"),
patch(
"src.services.terminal.terminal_session.asyncio.create_subprocess_exec", "src.services.terminal.terminal_session.asyncio.create_subprocess_exec",
new=AsyncMock(), new=AsyncMock(),
) as mock_exec: ) as mock_exec,
with patch("src.services.terminal.terminal_session.os.close"): patch("src.services.terminal.terminal_session.os.close"),
):
await session.start() await session.start()
args, _kwargs = mock_exec.call_args args, _kwargs = mock_exec.call_args
@@ -38,6 +41,7 @@ async def test_start_passes_container_user_to_docker_exec() -> None:
user_index = args.index("--user") user_index = args.index("--user")
assert args[user_index + 1] == "dev" assert args[user_index + 1] == "dev"
assert "container-123" in args assert "container-123" in args
set_blocking.assert_called_once_with(1, False)
@pytest.mark.unit @pytest.mark.unit
@@ -50,17 +54,44 @@ async def test_start_omits_user_when_not_configured() -> None:
container_id="container-123", container_id="container-123",
) )
with patch( with (
patch(
"src.services.terminal.terminal_session.pty.openpty", "src.services.terminal.terminal_session.pty.openpty",
return_value=(1, 2), return_value=(1, 2),
): ),
with patch("src.services.terminal.terminal_session.tty.setraw"): patch("src.services.terminal.terminal_session.os.set_blocking"),
with patch( patch("src.services.terminal.terminal_session.tty.setraw"),
patch(
"src.services.terminal.terminal_session.asyncio.create_subprocess_exec", "src.services.terminal.terminal_session.asyncio.create_subprocess_exec",
new=AsyncMock(), new=AsyncMock(),
) as mock_exec: ) as mock_exec,
with patch("src.services.terminal.terminal_session.os.close"): patch("src.services.terminal.terminal_session.os.close"),
):
await session.start() await session.start()
args, _kwargs = mock_exec.call_args args, _kwargs = mock_exec.call_args
assert "--user" not in 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",
]
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/web dir: apps/web
## role ## 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 ## parent
index: apps/.pi-map.index.md index: apps/.pi-map.index.md
map: apps/.pi-map.md map: apps/.pi-map.md
+11 -11
View File
@@ -4,21 +4,21 @@ dir: apps/web
index: apps/web/.pi-map.index.md index: apps/web/.pi-map.index.md
## role ## 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 ## 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 - .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 - 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 | 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 - 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 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 - 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 | 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-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 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 - 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 settings for a modern React/Vite web application with strict type checking and ES module output. | dep: TypeScript, Vite, React - 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 plugin, dev server port, and Vitest test settings | dep: vite, @vitejs/plugin-react - vite.config.ts | Configures Vite build tool with React support, dev server port, and Vitest testing settings | dep: vite, @vitejs/plugin-react
## arch ## 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 ## tags
react, vite, application, eslint, typescript, web, nginx, xterm react, vite, eslint, application, typescript, nginx, configures, web
## symbols ## symbols
- -
## workflows ## workflows
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/web/src dir: apps/web/src
## role ## 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 ## parent
index: apps/web/.pi-map.index.md index: apps/web/.pi-map.index.md
map: apps/web/.pi-map.md map: apps/web/.pi-map.md
+6 -6
View File
@@ -4,15 +4,15 @@ dir: apps/web/src
index: apps/web/src/.pi-map.index.md index: apps/web/src/.pi-map.index.md
## role ## 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 ## 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 - 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 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 - 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 core TypeScript type definitions for user sessions, projects, repositories, and workspaces in an application. | exp: SessionUser, SessionPayload, Project, WorkspaceSummary, RepositorySummary, ProjectWithRepos - types.ts | Defines TypeScript type declarations for user sessions, projects, repositories, and workspaces in an application. | exp: SessionUser, SessionPayload, Project, WorkspaceSummary, RepositorySummary, ProjectWithRepos
## arch ## 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 ## tags
pages, styles, css, router, react, session, project, summary pages, styles, css, router, react, session, dom, project
## symbols ## symbols
- AppRouter - AppRouter
- SessionUser - SessionUser
+1 -1
View File
@@ -2,7 +2,7 @@
dir: apps/web/src/components dir: apps/web/src/components
## role ## 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 ## parent
index: apps/web/src/.pi-map.index.md index: apps/web/src/.pi-map.index.md
map: apps/web/src/.pi-map.md map: apps/web/src/.pi-map.md
+13 -13
View File
@@ -4,22 +4,22 @@ dir: apps/web/src/components
index: apps/web/src/components/.pi-map.index.md index: apps/web/src/components/.pi-map.index.md
## role ## 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 ## 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 - 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-highlighted code editor with line numbers using a lightweight editor library. | exp: CodeEditor | dep: react, react-simple-code-editor, ../utils/language - 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 | Defines reusable React components for displaying loading, error, and empty data states in a UI. | exp: LoadingState, ErrorState, EmptyState | dep: ./icon, icon - 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 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 - 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 spinner overlay with optional text label that conditionally displays based on visibility prop | exp: func:LoadingOverlay({ visible, label }: LoadingOverlayProps) | dep: ./icon, icon - 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 | dep: @testing-library/react, react-router-dom, vitest, ./protected-route, ../state/auth - 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 | A React component that conditionally renders children or redirects to login based on authentication state | exp: ProtectedRoute | dep: react-router-dom, ../state/auth - 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 blocks with optional line numbers, language badge, and copy-to-clipboard functionality. | exp: SyntaxHighlighter | dep: react, ./icon, ../utils/language - 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 toast notification rules that map instance events to UI categories and severity levels. | dep: vitest, ./toast-rules, ../types/events - 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 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 - 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 ## 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 ## tags
toast, state, react, icon, loading, event, code, editor toast, state, icon, react, loading, code, event, editor
## symbols ## symbols
- LoadingOverlay - LoadingOverlay
- mapEventToCategory - mapEventToCategory
@@ -2,7 +2,7 @@
dir: apps/web/src/components/features dir: apps/web/src/components/features
## role ## 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 ## parent
index: apps/web/src/components/.pi-map.index.md index: apps/web/src/components/.pi-map.index.md
map: apps/web/src/components/.pi-map.md map: apps/web/src/components/.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: apps/web/src/components/features
index: apps/web/src/components/features/.pi-map.index.md index: apps/web/src/components/features/.pi-map.index.md
## role ## 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 ## files
## arch ## arch
Feature-based component colocation pattern with domain-driven folder structure, separating shared/common components from page-specific feature implementations. Contains 0 files.
## tags ## tags
- -
## symbols ## symbols
@@ -2,7 +2,7 @@
dir: apps/web/src/components/features/terminal dir: apps/web/src/components/features/terminal
## role ## 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 ## parent
index: apps/web/src/components/features/.pi-map.index.md index: apps/web/src/components/features/.pi-map.index.md
map: apps/web/src/components/features/.pi-map.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 index: apps/web/src/components/features/terminal/.pi-map.index.md
## role ## 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 ## 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 - 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-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 - 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 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-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 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 - 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 | 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.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 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-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 | 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 - 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 ## 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 ## tags
terminal, special, keys, session, tabs, react, view, strip terminal, special, session, keys, react, tabs, view, strip
## symbols ## symbols
- DesktopTerminalView - DesktopTerminalView
- MobileTerminalView - 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);
});
});
@@ -48,6 +48,22 @@ const MIN_FONT_SIZE = 4;
const MAX_FONT_SIZE = 24; const MAX_FONT_SIZE = 24;
const RECONNECT_ATTEMPTS = 3; const RECONNECT_ATTEMPTS = 3;
const RECONNECT_DELAY_BASE = 1000; const RECONNECT_DELAY_BASE = 1000;
const BRACKETED_PASTE_ENABLE_SEQUENCE = [0x1b, 0x5b, 0x3f, 0x32, 0x30, 0x30, 0x34, 0x68];
const BRACKETED_PASTE_DISABLE_SEQUENCE = [0x1b, 0x5b, 0x3f, 0x32, 0x30, 0x30, 0x34, 0x6c];
const BRACKETED_PASTE_CONTROL_TAIL_LENGTH =
BRACKETED_PASTE_ENABLE_SEQUENCE.length - 1;
export function getTerminalScrollbackLimit(isMobile: boolean): number {
return isMobile ? 10_000 : 0;
}
function matchesByteSequence(
data: Uint8Array,
start: number,
sequence: readonly number[],
): boolean {
return sequence.every((byte, index) => data[start + index] === byte);
}
export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>( export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
( (
@@ -68,6 +84,8 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const termRef = useRef<Terminal | null>(null); const termRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null); const fitAddonRef = useRef<FitAddon | null>(null);
const bracketedPasteEnabledRef = useRef(false);
const pasteTextRef = useRef<(text: string) => void>(() => {});
const reconnectAttemptsRef = useRef(0); const reconnectAttemptsRef = useRef(0);
const onTerminalReadyRef = useRef(onTerminalReady); const onTerminalReadyRef = useRef(onTerminalReady);
onTerminalReadyRef.current = onTerminalReady; onTerminalReadyRef.current = onTerminalReady;
@@ -111,6 +129,41 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
ws.binaryType = "arraybuffer"; ws.binaryType = "arraybuffer";
wsRef.current = ws; wsRef.current = ws;
let bracketedPasteControlTail = new Uint8Array(0);
const updateBracketedPasteMode = (data: Uint8Array) => {
const combined = new Uint8Array(
bracketedPasteControlTail.length + data.length,
);
combined.set(bracketedPasteControlTail);
combined.set(data, bracketedPasteControlTail.length);
for (let index = 0; index < combined.length; index++) {
if (
index + BRACKETED_PASTE_ENABLE_SEQUENCE.length <= combined.length &&
matchesByteSequence(
combined,
index,
BRACKETED_PASTE_ENABLE_SEQUENCE,
)
) {
bracketedPasteEnabledRef.current = true;
} else if (
index + BRACKETED_PASTE_DISABLE_SEQUENCE.length <= combined.length &&
matchesByteSequence(
combined,
index,
BRACKETED_PASTE_DISABLE_SEQUENCE,
)
) {
bracketedPasteEnabledRef.current = false;
}
}
bracketedPasteControlTail = combined.slice(
Math.max(0, combined.length - BRACKETED_PASTE_CONTROL_TAIL_LENGTH),
);
};
ws.onopen = () => { ws.onopen = () => {
setStatus("connected"); setStatus("connected");
setError(null); setError(null);
@@ -156,6 +209,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
if (event.data instanceof ArrayBuffer) { if (event.data instanceof ArrayBuffer) {
const data = new Uint8Array(event.data); const data = new Uint8Array(event.data);
updateBracketedPasteMode(data);
termRef.current.write(data); termRef.current.write(data);
// Flow control: accumulate processed bytes // Flow control: accumulate processed bytes
@@ -276,10 +330,14 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
lineHeight: 1.2, lineHeight: 1.2,
letterSpacing: 0, letterSpacing: 0,
allowTransparency: false, allowTransparency: false,
scrollback: 10000, // Desktop tools repaint in place, so retaining their normal buffer
// creates stale frames that native wheel scrolling can revisit. Mobile
// instead uses its custom touch handler to scroll normal-buffer output,
// which requires retained history.
scrollback: getTerminalScrollbackLimit(isMobile),
ignoreBracketedPasteMode: false, ignoreBracketedPasteMode: false,
fastScrollSensitivity: 5, fastScrollSensitivity: 0,
scrollSensitivity: 1, scrollSensitivity: 0,
smoothScrollDuration: 0, smoothScrollDuration: 0,
theme: { theme: {
background: "#1e1e1e", background: "#1e1e1e",
@@ -354,6 +412,32 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
term.focus(); term.focus();
const ws = connectWebSocket(); const ws = connectWebSocket();
pasteTextRef.current = (text: string) => {
const currentWs = wsRef.current;
if (currentWs?.readyState !== WebSocket.OPEN) return;
if (bracketedPasteEnabledRef.current) {
const normalizedText = text.replace(/\r\n|\r|\n/g, "\r");
currentWs.send(`\x1b[200~${normalizedText}\x1b[201~`);
return;
}
term.paste(text);
};
const handleBrowserPaste = (event: ClipboardEvent) => {
if (!bracketedPasteEnabledRef.current) return;
const text = event.clipboardData?.getData("text/plain");
if (text === undefined || wsRef.current?.readyState !== WebSocket.OPEN) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
pasteTextRef.current(text);
};
container.addEventListener("paste", handleBrowserPaste, true);
// Mobile touch scroll. // Mobile touch scroll.
// In normal mode xterm.js has a scrollable viewport; in alternate // In normal mode xterm.js has a scrollable viewport; in alternate
// screen (tmux/vim) there is no scrollback and the only way to // screen (tmux/vim) there is no scrollback and the only way to
@@ -600,6 +684,9 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
handleVisibilityChange, handleVisibilityChange,
); );
if (touchCleanup) touchCleanup(); if (touchCleanup) touchCleanup();
container.removeEventListener("paste", handleBrowserPaste, true);
pasteTextRef.current = () => {};
bracketedPasteEnabledRef.current = false;
if (ws) { if (ws) {
ws.close(1000, "Component unmounting"); ws.close(1000, "Component unmounting");
} }
@@ -613,7 +700,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// Ignore disposal errors from partially torn-down terminal // Ignore disposal errors from partially torn-down terminal
} }
}; };
}, [instanceId, connectWebSocket]); }, [instanceId, connectWebSocket, isMobile]);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
fit: () => { fit: () => {
@@ -721,14 +808,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
const handlePaste = async () => { const handlePaste = async () => {
try { try {
const text = await navigator.clipboard.readText(); pasteTextRef.current(await navigator.clipboard.readText());
// Route through xterm.js instead of sending raw text directly.
// term.paste() wraps the content in bracketed-paste markers
// (\e[200~...\e[201~) when the app has enabled BPM, so multiline
// pastes arrive as a single input rather than one prompt per line.
// It emits via onData, which the existing handler forwards to the
// WebSocket, so the readyState check happens there.
termRef.current?.paste(text);
} catch { } catch {
// Clipboard API not available // Clipboard API not available
} }
+10 -7
View File
@@ -169,6 +169,10 @@ export const useTerminalPage = () => {
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey; const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
if (!isAltShift) return; if (!isAltShift) return;
const activeSessionIndex = activeSessionId
? sessions.findIndex((session) => session.id === activeSessionId)
: -1;
switch (e.key.toLowerCase()) { switch (e.key.toLowerCase()) {
case "n": case "n":
e.preventDefault(); e.preventDefault();
@@ -187,17 +191,14 @@ export const useTerminalPage = () => {
break; break;
case "arrowleft": case "arrowleft":
e.preventDefault(); e.preventDefault();
if (activeSessionId) { if (activeSessionIndex > 0) {
const idx = sessions.findIndex((s) => s.id === activeSessionId); setActiveSessionId(sessions[activeSessionIndex - 1].id);
if (idx > 0) setActiveSessionId(sessions[idx - 1].id);
} }
break; break;
case "arrowright": case "arrowright":
e.preventDefault(); e.preventDefault();
if (activeSessionId) { if (activeSessionIndex >= 0 && activeSessionIndex < sessions.length - 1) {
const idx = sessions.findIndex((s) => s.id === activeSessionId); setActiveSessionId(sessions[activeSessionIndex + 1].id);
if (idx < sessions.length - 1)
setActiveSessionId(sessions[idx + 1].id);
} }
break; break;
case "r": case "r":
@@ -208,6 +209,8 @@ export const useTerminalPage = () => {
e.preventDefault(); e.preventDefault();
setIsFullscreen((prev) => !prev); setIsFullscreen((prev) => !prev);
break; break;
default:
break;
} }
}; };
+22 -27
View File
@@ -1,17 +1,26 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { ProjectsPage } from "./ProjectsPage"; import { ProjectsPage } from "./ProjectsPage";
import * as projectsApi from "../api/projects"; 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", id: "proj-1",
name: "Alpha Project", name: "Alpha Project",
description: "First project", description: "First project",
owner_id: "user-1", owner_id: "user-1",
default_ssh_key_id: null, default_ssh_key_id: null,
repositories: [],
created_at: "2026-07-01T00:00:00Z",
}, },
{ {
id: "proj-2", id: "proj-2",
@@ -19,6 +28,8 @@ const mockProjects = [
description: null, description: null,
owner_id: "user-1", owner_id: "user-1",
default_ssh_key_id: null, default_ssh_key_id: null,
repositories: [],
created_at: "2026-07-01T00:00:00Z",
}, },
]; ];
@@ -31,9 +42,7 @@ describe("ProjectsPage", () => {
it("renders loading state initially", () => { it("renders loading state initially", () => {
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {})); vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
render( render(
<MemoryRouter> <MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
<ProjectsPage />
</MemoryRouter>
); );
expect(screen.getByText(/loading projects/i)).toBeInTheDocument(); expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
}); });
@@ -41,9 +50,7 @@ describe("ProjectsPage", () => {
it("renders project list after loading", async () => { it("renders project list after loading", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects); vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
render( render(
<MemoryRouter> <MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
<ProjectsPage />
</MemoryRouter>
); );
await waitFor(() => { await waitFor(() => {
@@ -56,9 +63,7 @@ describe("ProjectsPage", () => {
it("renders empty state when no projects", async () => { it("renders empty state when no projects", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]); vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
render( render(
<MemoryRouter> <MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
<ProjectsPage />
</MemoryRouter>
); );
await waitFor(() => { await waitFor(() => {
@@ -69,9 +74,7 @@ describe("ProjectsPage", () => {
it("renders error state with retry button", async () => { it("renders error state with retry button", async () => {
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail")); vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
render( render(
<MemoryRouter> <MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
<ProjectsPage />
</MemoryRouter>
); );
await waitFor(() => { await waitFor(() => {
@@ -85,9 +88,7 @@ describe("ProjectsPage", () => {
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]); const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
render( render(
<MemoryRouter> <MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
<ProjectsPage />
</MemoryRouter>
); );
await waitFor(() => { await waitFor(() => {
@@ -118,9 +119,7 @@ describe("ProjectsPage", () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]); vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
render( render(
<MemoryRouter> <MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
<ProjectsPage />
</MemoryRouter>
); );
await waitFor(() => { await waitFor(() => {
@@ -138,16 +137,14 @@ describe("ProjectsPage", () => {
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]); const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
render( render(
<MemoryRouter> <MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
<ProjectsPage />
</MemoryRouter>
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument(); 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"); if (!alphaCard) throw new Error("Card not found");
fireEvent.click(within(alphaCard).getByRole("button", { name: /edit/i })); fireEvent.click(within(alphaCard).getByRole("button", { name: /edit/i }));
@@ -171,16 +168,14 @@ describe("ProjectsPage", () => {
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined); const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
render( render(
<MemoryRouter> <MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
<ProjectsPage />
</MemoryRouter>
); );
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument(); 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"); if (!alphaCard) throw new Error("Card not found");
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i })); fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
+33 -8
View File
@@ -152,7 +152,9 @@
} }
.font-mono { .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 { .font-medium {
@@ -938,6 +940,14 @@ a.nav-item,
overscroll-behavior: auto; overscroll-behavior: auto;
max-height: 100% !important; max-height: 100% !important;
width: 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 /* On mobile the custom touch handler scrolls the buffer; disable native
@@ -3809,7 +3819,9 @@ a:active,
color: var(--muted); color: var(--muted);
cursor: pointer; cursor: pointer;
border-radius: var(--radius-md); 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 { .delete-btn:hover {
@@ -3836,7 +3848,9 @@ a:active,
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
font-weight: 500; font-weight: 500;
text-decoration: none; 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 { .workspace-header-action-btn:hover {
@@ -3911,7 +3925,9 @@ a:active,
color: var(--muted); color: var(--muted);
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
cursor: pointer; cursor: pointer;
transition: background 0.12s ease, color 0.12s ease; transition:
background 0.12s ease,
color 0.12s ease;
} }
.copy-button:hover { .copy-button:hover {
@@ -4157,7 +4173,10 @@ a:active,
} }
.drag-item-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); border-color: var(--brand);
} }
@@ -4173,7 +4192,9 @@ a:active,
padding: var(--space-4); padding: var(--space-4);
border-radius: var(--radius-md); border-radius: var(--radius-md);
overflow: auto; 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); font-size: var(--font-size-sm);
line-height: var(--line-height-normal); line-height: var(--line-height-normal);
} }
@@ -4321,7 +4342,9 @@ a:active,
color: var(--muted); color: var(--muted);
cursor: pointer; cursor: pointer;
font-weight: 600; font-weight: 600;
transition: border-color 0.15s, color 0.15s; transition:
border-color 0.15s,
color 0.15s;
} }
.sidebar-create-button:hover { .sidebar-create-button:hover {
@@ -4346,7 +4369,9 @@ a:active,
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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 { .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).