Compare commits

...

5 Commits

Author SHA1 Message Date
Developer 415aecc0dd fix: constrain dialog content to the viewport
- Add dynamic viewport bounds and scrollable body regions to dialogs and modals\n- Make tool-launch popups use the shared scrollable body pattern\n- Keep mobile sheets, action sheets, and notification popups scroll-contained\n- Restore the ProjectsPage test setup required for the frontend suite\n\nOpenSpec: fix-dialog-scroll-containment\nQuality gates: npm run typecheck, npm run lint, npm test (88 passed), npm run build
2026-07-15 10:20:20 +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
13 changed files with 350 additions and 122 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)
+53 -22
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,17 +19,20 @@ async def test_start_passes_container_user_to_docker_exec() -> None:
container_user="dev", container_user="dev",
) )
with patch( with (
"src.services.terminal.terminal_session.pty.openpty", patch(
return_value=(1, 2), "src.services.terminal.terminal_session.pty.openpty",
return_value=(1, 2),
),
patch("src.services.terminal.terminal_session.os.set_blocking") as set_blocking,
patch("src.services.terminal.terminal_session.tty.setraw"),
patch(
"src.services.terminal.terminal_session.asyncio.create_subprocess_exec",
new=AsyncMock(),
) as mock_exec,
patch("src.services.terminal.terminal_session.os.close"),
): ):
with patch("src.services.terminal.terminal_session.tty.setraw"): await session.start()
with patch(
"src.services.terminal.terminal_session.asyncio.create_subprocess_exec",
new=AsyncMock(),
) as mock_exec:
with patch("src.services.terminal.terminal_session.os.close"):
await session.start()
args, _kwargs = mock_exec.call_args args, _kwargs = mock_exec.call_args
assert "docker" in args assert "docker" in 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 (
"src.services.terminal.terminal_session.pty.openpty", patch(
return_value=(1, 2), "src.services.terminal.terminal_session.pty.openpty",
return_value=(1, 2),
),
patch("src.services.terminal.terminal_session.os.set_blocking"),
patch("src.services.terminal.terminal_session.tty.setraw"),
patch(
"src.services.terminal.terminal_session.asyncio.create_subprocess_exec",
new=AsyncMock(),
) as mock_exec,
patch("src.services.terminal.terminal_session.os.close"),
): ):
with patch("src.services.terminal.terminal_session.tty.setraw"): await session.start()
with patch(
"src.services.terminal.terminal_session.asyncio.create_subprocess_exec",
new=AsyncMock(),
) as mock_exec:
with patch("src.services.terminal.terminal_session.os.close"):
await session.start()
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",
]
@@ -48,6 +48,18 @@ 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;
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 +80,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 +125,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 +205,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) {
@@ -289,7 +333,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// the mouse-wheel scrolls through stale frames instead of the app. // the mouse-wheel scrolls through stale frames instead of the app.
// scrollback:0 keeps only the live viewport: no bar, no stale-frame // scrollback:0 keeps only the live viewport: no bar, no stale-frame
// wheel jank. (Scrollbar is also hidden via CSS for belt-and-suspenders.) // wheel jank. (Scrollbar is also hidden via CSS for belt-and-suspenders.)
scrollback: 10000, scrollback: 0,
ignoreBracketedPasteMode: false, ignoreBracketedPasteMode: false,
fastScrollSensitivity: 0, fastScrollSensitivity: 0,
scrollSensitivity: 0, scrollSensitivity: 0,
@@ -367,6 +411,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 +576,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 +683,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");
} }
@@ -740,14 +807,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
} }
@@ -578,19 +578,23 @@ export const InstanceList = ({
{showCreate && ( {showCreate && (
<div className="dialog-overlay" role="dialog" aria-modal="true"> <div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog"> <div className="dialog">
<h2>Launch Tool</h2> <div className="dialog-header">
<CreateSessionForm <h2>Launch Tool</h2>
projects={[]} </div>
repositories={[]} <div className="dialog-body">
toolTypes={toolTypes} <CreateSessionForm
fixedProjectId={projectId} projects={[]}
fixedRepoId={repoId} repositories={[]}
projectName={projectName} toolTypes={toolTypes}
repoName={repoName} fixedProjectId={projectId}
onSuccess={handleCreateSuccess} fixedRepoId={repoId}
onCancel={() => setShowCreate(false)} projectName={projectName}
submitLabel="Launch" repoName={repoName}
/> onSuccess={handleCreateSuccess}
onCancel={() => setShowCreate(false)}
submitLabel="Launch"
/>
</div>
</div> </div>
</div> </div>
)} )}
@@ -62,6 +62,7 @@ export function StartToolFAB() {
</button> </button>
</div> </div>
<div className="modal-body">
{workspacesLoading ? ( {workspacesLoading ? (
<p className="muted">Loading workspaces...</p> <p className="muted">Loading workspaces...</p>
) : workspaces.length === 0 ? ( ) : workspaces.length === 0 ? (
@@ -110,6 +111,7 @@ export function StartToolFAB() {
/> />
</> </>
)} )}
</div>
</div> </div>
</div> </div>
)} )}
@@ -0,0 +1,47 @@
import "@testing-library/jest-dom/vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { WorkspaceToolsPanel } from "./workspace-tools-panel";
import type { Workspace } from "../../../types/workspace";
vi.mock("../../../hooks/use-workspace-instances", () => ({
useWorkspaceInstances: () => ({
instances: [],
loading: false,
refresh: vi.fn(),
}),
}));
vi.mock("../tool/tool-starter", () => ({
ToolStarter: () => <div data-testid="tool-starter" />,
}));
const workspace: Workspace = {
id: "workspace-1",
name: "Main",
repo_id: "repo-1",
repo_name: "repository",
repo_ssh_key_id: null,
project_id: "project-1",
project_name: "Project",
user_id: "user-1",
branch: "main",
path: "/workspace",
status: "ready",
last_sync_at: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
instance_count: 0,
};
describe("WorkspaceToolsPanel", () => {
it("places the tool launcher inside the shared scrollable dialog body", () => {
const { container } = render(<WorkspaceToolsPanel workspace={workspace} />);
fireEvent.click(screen.getByRole("button", { name: "Start Tool" }));
const dialogBody = container.querySelector(".dialog-body");
expect(dialogBody).toContainElement(screen.getByTestId("tool-starter"));
});
});
@@ -64,15 +64,19 @@ export function WorkspaceToolsPanel({ workspace }: WorkspaceToolsPanelProps) {
{showModal && ( {showModal && (
<div className="dialog-overlay" onClick={() => setShowModal(false)}> <div className="dialog-overlay" onClick={() => setShowModal(false)}>
<div className="dialog" onClick={(e) => e.stopPropagation()}> <div className="dialog" onClick={(e) => e.stopPropagation()}>
<h3>Start Tool</h3> <div className="dialog-header">
<ToolStarter <h3>Start Tool</h3>
workspace={workspace} </div>
onStarted={() => { <div className="dialog-body">
setShowModal(false); <ToolStarter
void refresh(); workspace={workspace}
}} onStarted={() => {
onCancel={() => setShowModal(false)} setShowModal(false);
/> void refresh();
}}
onCancel={() => setShowModal(false)}
/>
</div>
</div> </div>
</div> </div>
)} )}
+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 }));
+13 -13
View File
@@ -453,12 +453,16 @@
backdrop-filter: blur(2px); backdrop-filter: blur(2px);
} }
.dialog { .dialog,
.modal-content,
.commit-dialog {
width: 100%; width: 100%;
max-width: 32rem; max-width: 32rem;
max-height: calc(100vh - var(--space-8)); max-height: calc(100vh - var(--space-8));
max-height: calc(100dvh - var(--space-8));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
overflow: hidden; overflow: hidden;
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -468,16 +472,6 @@
.modal-content { .modal-content {
/* deprecated alias */ /* deprecated alias */
width: 100%;
max-width: 32rem;
max-height: calc(100vh - var(--space-8));
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
} }
.dialog-lg { .dialog-lg {
@@ -500,9 +494,13 @@
line-height: var(--line-height-tight); line-height: var(--line-height-tight);
} }
.dialog-body { .dialog-body,
.modal-body {
flex: 1; flex: 1;
min-height: 0;
overflow: auto; overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding: var(--space-4); padding: var(--space-4);
} }
@@ -543,9 +541,11 @@
} }
.dialog, .dialog,
.modal-content { .modal-content,
.commit-dialog {
max-width: 100%; max-width: 100%;
max-height: calc(100vh - var(--space-6)); max-height: calc(100vh - var(--space-6));
max-height: calc(100dvh - var(--space-6));
border-radius: var(--radius-lg) var(--radius-lg) 0 0; border-radius: var(--radius-lg) var(--radius-lg) 0 0;
} }
} }
+10
View File
@@ -2328,8 +2328,10 @@ a.nav-item,
width: 100%; width: 100%;
max-width: 600px; max-width: 600px;
max-height: 70vh; max-height: 70vh;
max-height: 70dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
animation: slide-up 0.2s ease-out; animation: slide-up 0.2s ease-out;
} }
@@ -2365,8 +2367,12 @@ a.nav-item,
} }
.mobile-bottom-sheet-content { .mobile-bottom-sheet-content {
flex: 1;
min-height: 0;
padding: 8px 0; padding: 8px 0;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
} }
.mobile-bottom-sheet-item { .mobile-bottom-sheet-item {
@@ -3240,7 +3246,10 @@ a:active,
width: 100%; width: 100%;
max-width: 500px; max-width: 500px;
max-height: 80vh; max-height: 80vh;
max-height: 80dvh;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
animation: slideUp 0.3s ease; animation: slideUp 0.3s ease;
padding-bottom: env(safe-area-inset-bottom, 0); padding-bottom: env(safe-area-inset-bottom, 0);
} }
@@ -3602,6 +3611,7 @@ a:active,
max-width: none; max-width: none;
border-radius: 12px; border-radius: 12px;
max-height: 70vh; max-height: 70vh;
max-height: 70dvh;
} }
} }
@@ -0,0 +1,28 @@
# Constrain and Scroll Edit Dialogs and Popups
## Summary
Ensure every edit dialog, form popup, and modal popup remains usable on a viewport that is shorter than its content. Dialog chrome must stay within the visible viewport while the content area scrolls independently.
## Problem
The shared dialog system constrains only components that follow its `dialog-header` / `dialog-body` structure. Several tool-launch and commit dialogs place form content directly inside the container or use a bespoke container, so long forms can be clipped. Existing viewport sizing also relies on `vh`, which is unreliable when mobile browser chrome changes height.
## Scope
- Shared dialog and modal CSS in `apps/web/src/styles/global.css`.
- Mobile notification dropdown sizing in `apps/web/src/styles/utilities.css`.
- Tool-launch and commit popup markup that does not currently provide a scrollable content region.
## Acceptance Criteria
- [ ] Dialogs and modal popups are bounded by the current visible viewport, including mobile dynamic viewport changes.
- [ ] Headers and footer/action bars remain visible while long form content scrolls independently.
- [ ] Tool-launch and commit popups use the shared scrollable content pattern.
- [ ] Mobile sheets, action sheets, and notification dropdowns remain scrollable without propagating scroll gestures to the page.
- [ ] Relevant frontend tests, typecheck, lint, and production build pass.
## Non-Goals
- Redesigning dialog visuals or interaction flows.
- Changing page-level scrolling outside overlays.
@@ -0,0 +1,8 @@
# Constrain and Scroll Edit Dialogs and Popups — Tasks
- [x] Audit all dialog, modal, sheet, action-sheet, and popup implementations.
- [x] Strengthen shared dialog/modal viewport and body scrolling rules.
- [x] Update bespoke tool-launch and commit popups to use scrollable content regions.
- [x] Add focused coverage for the shared scrollable dialog-body markup.
- [x] Run frontend typecheck, lint, tests (88 passed), and production build.
- [x] Update project maps for changed source files.