Compare commits

...

6 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
8 changed files with 273 additions and 95 deletions
@@ -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",
]
@@ -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,14 +209,9 @@ 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);
// TEMP diagnostic: log when pi's bracketed-paste enable sequence reaches xterm
const text = new TextDecoder().decode(data);
if (text.includes("\x1b[?2004h")) {
console.log("[BPMDIAG] enable sequence (\\e[?2004h) reached xterm");
}
// Flow control: accumulate processed bytes // Flow control: accumulate processed bytes
ackAccumulator += data.length; ackAccumulator += data.length;
if (ackAccumulator >= ACK_THRESHOLD) { if (ackAccumulator >= ACK_THRESHOLD) {
@@ -282,14 +330,11 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
lineHeight: 1.2, lineHeight: 1.2,
letterSpacing: 0, letterSpacing: 0,
allowTransparency: false, allowTransparency: false,
// This terminal only ever hosts full-screen TUI tools (pi-agent, // Desktop tools repaint in place, so retaining their normal buffer
// opencode), which repaint in place in the normal buffer and do not // creates stale frames that native wheel scrolling can revisit. Mobile
// use the alternate screen or mouse tracking. With scrollback, every // instead uses its custom touch handler to scroll normal-buffer output,
// repaint accumulates as history → a viewport scrollbar appears and // which requires retained history.
// the mouse-wheel scrolls through stale frames instead of the app. scrollback: getTerminalScrollbackLimit(isMobile),
// scrollback:0 keeps only the live viewport: no bar, no stale-frame
// wheel jank. (Scrollbar is also hidden via CSS for belt-and-suspenders.)
scrollback: 10000,
ignoreBracketedPasteMode: false, ignoreBracketedPasteMode: false,
fastScrollSensitivity: 0, fastScrollSensitivity: 0,
scrollSensitivity: 0, scrollSensitivity: 0,
@@ -367,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
@@ -506,12 +577,6 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// Handle terminal input // Handle terminal input
term.onData((data) => { term.onData((data) => {
// TEMP diagnostic: log paste-like outbound data and whether BPM-wrapped
if (data.length > 10 && (data.includes("\n") || data.includes("\r"))) {
const bpmWrapped = data.startsWith("\x1b[200~") && data.endsWith("\x1b[201~");
console.log("[PASTEDIAG] bpmWrapped=" + bpmWrapped + " len=" + data.length);
}
const currentWs = wsRef.current; const currentWs = wsRef.current;
if (currentWs?.readyState !== WebSocket.OPEN) return; if (currentWs?.readyState !== WebSocket.OPEN) return;
@@ -619,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");
} }
@@ -632,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: () => {
@@ -740,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 }));
@@ -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).