Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 671540ded9 | |||
| ad26fd9f35 | |||
| 415aecc0dd | |||
| 5e06a2a226 | |||
| 49180e4c6d | |||
| 7a5538b53f | |||
| 41d24beade |
@@ -19,6 +19,8 @@ from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_session_counters_by_instance: dict[str, int] = {}
|
||||
|
||||
|
||||
class TerminalSession:
|
||||
"""Manages a single terminal session with event-driven PTY I/O.
|
||||
@@ -46,9 +48,6 @@ class TerminalSession:
|
||||
# Max WebSocket frame size
|
||||
MAX_FRAME_SIZE = 64 * 1024
|
||||
|
||||
# Session number counter per instance_id for auto-naming
|
||||
_instance_counters: dict[str, int] = {}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_id: str,
|
||||
@@ -99,11 +98,11 @@ class TerminalSession:
|
||||
# Ack timeout fallback
|
||||
self._ack_timeout_handle: asyncio.TimerHandle | None = None
|
||||
|
||||
@classmethod
|
||||
def _generate_name(cls, instance_id: str) -> str:
|
||||
@staticmethod
|
||||
def _generate_name(instance_id: str) -> str:
|
||||
"""Generate an auto-incremented session name for the instance."""
|
||||
count = cls._instance_counters.get(instance_id, 0) + 1
|
||||
cls._instance_counters[instance_id] = count
|
||||
count = _session_counters_by_instance.get(instance_id, 0) + 1
|
||||
_session_counters_by_instance[instance_id] = count
|
||||
return f"Session {count}"
|
||||
|
||||
async def start(self, startup_command: str | None = None) -> None:
|
||||
@@ -121,8 +120,12 @@ class TerminalSession:
|
||||
if self.container_user:
|
||||
exec_cmd.extend(["--user", self.container_user])
|
||||
|
||||
# Create a pseudo-terminal on the host
|
||||
# Create a pseudo-terminal on the host. The master must be
|
||||
# non-blocking: a browser paste can be larger than the PTY input
|
||||
# buffer, and write_input() drains it asynchronously without dropping
|
||||
# the closing bracketed-paste marker.
|
||||
self._master_fd, slave_fd = pty.openpty()
|
||||
os.set_blocking(self._master_fd, False)
|
||||
|
||||
# Put the host PTY into raw mode so it behaves as a pass-through
|
||||
# pipe. openpty() leaves the slave in canonical mode by default,
|
||||
@@ -204,6 +207,9 @@ class TerminalSession:
|
||||
|
||||
try:
|
||||
data = os.read(self._master_fd, 4096)
|
||||
except BlockingIOError:
|
||||
# The readiness notification raced with another callback.
|
||||
return
|
||||
except (OSError, IOError) as exc:
|
||||
logger.debug("PTY read error for session %s: %s", self.session_id, exc)
|
||||
self._handle_eof()
|
||||
@@ -343,12 +349,42 @@ class TerminalSession:
|
||||
pass
|
||||
logger.info("Session %s EOF handled, websockets closed", self.session_id)
|
||||
|
||||
async def _wait_for_write_ready(self, fd: int) -> None:
|
||||
"""Wait until a non-blocking PTY master can accept more input."""
|
||||
loop = asyncio.get_running_loop()
|
||||
writable = loop.create_future()
|
||||
|
||||
def mark_writable() -> None:
|
||||
if not writable.done():
|
||||
writable.set_result(None)
|
||||
|
||||
loop.add_writer(fd, mark_writable)
|
||||
try:
|
||||
await writable
|
||||
finally:
|
||||
loop.remove_writer(fd)
|
||||
|
||||
async def write_input(self, data: bytes) -> None:
|
||||
"""Write input to the PTY master."""
|
||||
"""Write all terminal input bytes to the PTY master in order."""
|
||||
if self._master_fd is None or self._closed:
|
||||
return
|
||||
|
||||
fd = self._master_fd
|
||||
remaining = memoryview(data)
|
||||
try:
|
||||
os.write(self._master_fd, data)
|
||||
while remaining and not self._closed and self._master_fd == fd:
|
||||
try:
|
||||
written = os.write(fd, remaining)
|
||||
except BlockingIOError:
|
||||
await self._wait_for_write_ready(fd)
|
||||
continue
|
||||
|
||||
if written == 0:
|
||||
await self._wait_for_write_ready(fd)
|
||||
continue
|
||||
|
||||
remaining = remaining[written:]
|
||||
|
||||
self.last_activity = time.time()
|
||||
except (OSError, IOError) as exc:
|
||||
logger.debug("PTY write error for session %s: %s", self.session_id, exc)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Unit tests for TerminalSession docker exec invocation."""
|
||||
"""Unit tests for TerminalSession PTY handling."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest # pyright: ignore[reportMissingImports]
|
||||
|
||||
from src.services.terminal.terminal_session import TerminalSession
|
||||
|
||||
@@ -19,17 +19,20 @@ async def test_start_passes_container_user_to_docker_exec() -> None:
|
||||
container_user="dev",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"src.services.terminal.terminal_session.pty.openpty",
|
||||
return_value=(1, 2),
|
||||
with (
|
||||
patch(
|
||||
"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"):
|
||||
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()
|
||||
await session.start()
|
||||
|
||||
args, _kwargs = mock_exec.call_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")
|
||||
assert args[user_index + 1] == "dev"
|
||||
assert "container-123" in args
|
||||
set_blocking.assert_called_once_with(1, False)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -50,17 +54,44 @@ async def test_start_omits_user_when_not_configured() -> None:
|
||||
container_id="container-123",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"src.services.terminal.terminal_session.pty.openpty",
|
||||
return_value=(1, 2),
|
||||
with (
|
||||
patch(
|
||||
"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"):
|
||||
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()
|
||||
await session.start()
|
||||
|
||||
args, _kwargs = mock_exec.call_args
|
||||
assert "--user" not in args
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_input_retries_partial_pty_writes() -> None:
|
||||
"""A large paste is fully written even when the PTY accepts it in chunks."""
|
||||
session = TerminalSession(
|
||||
session_id=str(uuid.uuid4()),
|
||||
instance_id=uuid.uuid4(),
|
||||
container_id="container-123",
|
||||
)
|
||||
session._master_fd = 42
|
||||
|
||||
with patch(
|
||||
"src.services.terminal.terminal_session.os.write",
|
||||
side_effect=[2, 2, 1],
|
||||
) as write:
|
||||
await session.write_input(b"hello")
|
||||
|
||||
assert [bytes(call.args[1]) for call in write.call_args_list] == [
|
||||
b"hello",
|
||||
b"llo",
|
||||
b"o",
|
||||
]
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#275d4b" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>Headquarter</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<title>Headquarter</title>
|
||||
<rect width="64" height="64" rx="15" fill="#275d4b"/>
|
||||
<path fill="#fffef9" d="M17 15h8v13h14V15h8v34h-8V36H25v13h-8z"/>
|
||||
<path fill="#9dcdb7" d="M25 28h14v8H25z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 266 B |
@@ -48,6 +48,18 @@ const MIN_FONT_SIZE = 4;
|
||||
const MAX_FONT_SIZE = 24;
|
||||
const RECONNECT_ATTEMPTS = 3;
|
||||
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>(
|
||||
(
|
||||
@@ -68,6 +80,8 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const bracketedPasteEnabledRef = useRef(false);
|
||||
const pasteTextRef = useRef<(text: string) => void>(() => {});
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const onTerminalReadyRef = useRef(onTerminalReady);
|
||||
onTerminalReadyRef.current = onTerminalReady;
|
||||
@@ -111,6 +125,41 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
ws.binaryType = "arraybuffer";
|
||||
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 = () => {
|
||||
setStatus("connected");
|
||||
setError(null);
|
||||
@@ -156,14 +205,9 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
const data = new Uint8Array(event.data);
|
||||
updateBracketedPasteMode(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
|
||||
ackAccumulator += data.length;
|
||||
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.
|
||||
// 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,
|
||||
scrollback: 0,
|
||||
ignoreBracketedPasteMode: false,
|
||||
fastScrollSensitivity: 0,
|
||||
scrollSensitivity: 0,
|
||||
@@ -367,6 +411,32 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
term.focus();
|
||||
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.
|
||||
// In normal mode xterm.js has a scrollable viewport; in alternate
|
||||
// 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
|
||||
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;
|
||||
if (currentWs?.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
@@ -619,6 +683,9 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
handleVisibilityChange,
|
||||
);
|
||||
if (touchCleanup) touchCleanup();
|
||||
container.removeEventListener("paste", handleBrowserPaste, true);
|
||||
pasteTextRef.current = () => {};
|
||||
bracketedPasteEnabledRef.current = false;
|
||||
if (ws) {
|
||||
ws.close(1000, "Component unmounting");
|
||||
}
|
||||
@@ -740,14 +807,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
|
||||
const handlePaste = async () => {
|
||||
try {
|
||||
const text = 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);
|
||||
pasteTextRef.current(await navigator.clipboard.readText());
|
||||
} catch {
|
||||
// Clipboard API not available
|
||||
}
|
||||
|
||||
@@ -578,19 +578,23 @@ export const InstanceList = ({
|
||||
{showCreate && (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h2>Launch Tool</h2>
|
||||
<CreateSessionForm
|
||||
projects={[]}
|
||||
repositories={[]}
|
||||
toolTypes={toolTypes}
|
||||
fixedProjectId={projectId}
|
||||
fixedRepoId={repoId}
|
||||
projectName={projectName}
|
||||
repoName={repoName}
|
||||
onSuccess={handleCreateSuccess}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
submitLabel="Launch"
|
||||
/>
|
||||
<div className="dialog-header">
|
||||
<h2>Launch Tool</h2>
|
||||
</div>
|
||||
<div className="dialog-body">
|
||||
<CreateSessionForm
|
||||
projects={[]}
|
||||
repositories={[]}
|
||||
toolTypes={toolTypes}
|
||||
fixedProjectId={projectId}
|
||||
fixedRepoId={repoId}
|
||||
projectName={projectName}
|
||||
repoName={repoName}
|
||||
onSuccess={handleCreateSuccess}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
submitLabel="Launch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -62,6 +62,7 @@ export function StartToolFAB() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{workspacesLoading ? (
|
||||
<p className="muted">Loading workspaces...</p>
|
||||
) : workspaces.length === 0 ? (
|
||||
@@ -110,6 +111,7 @@ export function StartToolFAB() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</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 && (
|
||||
<div className="dialog-overlay" onClick={() => setShowModal(false)}>
|
||||
<div className="dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Start Tool</h3>
|
||||
<ToolStarter
|
||||
workspace={workspace}
|
||||
onStarted={() => {
|
||||
setShowModal(false);
|
||||
void refresh();
|
||||
}}
|
||||
onCancel={() => setShowModal(false)}
|
||||
/>
|
||||
<div className="dialog-header">
|
||||
<h3>Start Tool</h3>
|
||||
</div>
|
||||
<div className="dialog-body">
|
||||
<ToolStarter
|
||||
workspace={workspace}
|
||||
onStarted={() => {
|
||||
setShowModal(false);
|
||||
void refresh();
|
||||
}}
|
||||
onCancel={() => setShowModal(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -169,6 +169,10 @@ export const useTerminalPage = () => {
|
||||
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
|
||||
if (!isAltShift) return;
|
||||
|
||||
const activeSessionIndex = activeSessionId
|
||||
? sessions.findIndex((session) => session.id === activeSessionId)
|
||||
: -1;
|
||||
|
||||
switch (e.key.toLowerCase()) {
|
||||
case "n":
|
||||
e.preventDefault();
|
||||
@@ -187,17 +191,14 @@ export const useTerminalPage = () => {
|
||||
break;
|
||||
case "arrowleft":
|
||||
e.preventDefault();
|
||||
if (activeSessionId) {
|
||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
||||
if (idx > 0) setActiveSessionId(sessions[idx - 1].id);
|
||||
if (activeSessionIndex > 0) {
|
||||
setActiveSessionId(sessions[activeSessionIndex - 1].id);
|
||||
}
|
||||
break;
|
||||
case "arrowright":
|
||||
e.preventDefault();
|
||||
if (activeSessionId) {
|
||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
||||
if (idx < sessions.length - 1)
|
||||
setActiveSessionId(sessions[idx + 1].id);
|
||||
if (activeSessionIndex >= 0 && activeSessionIndex < sessions.length - 1) {
|
||||
setActiveSessionId(sessions[activeSessionIndex + 1].id);
|
||||
}
|
||||
break;
|
||||
case "r":
|
||||
@@ -208,6 +209,8 @@ export const useTerminalPage = () => {
|
||||
e.preventDefault();
|
||||
setIsFullscreen((prev) => !prev);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ProjectsPage } from "./ProjectsPage";
|
||||
import * as projectsApi from "../api/projects";
|
||||
import { SessionsProvider } from "../state/sessions";
|
||||
import type { ProjectWithRepos } from "../types";
|
||||
|
||||
const mockProjects = [
|
||||
vi.mock("../api/sessions", () => ({
|
||||
getUserSessions: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const mockProjects: ProjectWithRepos[] = [
|
||||
{
|
||||
id: "proj-1",
|
||||
name: "Alpha Project",
|
||||
description: "First project",
|
||||
owner_id: "user-1",
|
||||
default_ssh_key_id: null,
|
||||
repositories: [],
|
||||
created_at: "2026-07-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "proj-2",
|
||||
@@ -19,6 +28,8 @@ const mockProjects = [
|
||||
description: null,
|
||||
owner_id: "user-1",
|
||||
default_ssh_key_id: null,
|
||||
repositories: [],
|
||||
created_at: "2026-07-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -31,9 +42,7 @@ describe("ProjectsPage", () => {
|
||||
it("renders loading state initially", () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||
);
|
||||
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
|
||||
});
|
||||
@@ -41,9 +50,7 @@ describe("ProjectsPage", () => {
|
||||
it("renders project list after loading", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -56,9 +63,7 @@ describe("ProjectsPage", () => {
|
||||
it("renders empty state when no projects", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -69,9 +74,7 @@ describe("ProjectsPage", () => {
|
||||
it("renders error state with retry button", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -85,9 +88,7 @@ describe("ProjectsPage", () => {
|
||||
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -118,9 +119,7 @@ describe("ProjectsPage", () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -138,16 +137,14 @@ describe("ProjectsPage", () => {
|
||||
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
|
||||
const alphaCard = screen.getByText("Alpha Project").closest(".project-list-item") as HTMLElement | null;
|
||||
if (!alphaCard) throw new Error("Card not found");
|
||||
|
||||
fireEvent.click(within(alphaCard).getByRole("button", { name: /edit/i }));
|
||||
@@ -171,16 +168,14 @@ describe("ProjectsPage", () => {
|
||||
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
<MemoryRouter><SessionsProvider><ProjectsPage /></SessionsProvider></MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
|
||||
const alphaCard = screen.getByText("Alpha Project").closest(".project-list-item") as HTMLElement | null;
|
||||
if (!alphaCard) throw new Error("Card not found");
|
||||
|
||||
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
|
||||
|
||||
@@ -453,12 +453,16 @@
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
.dialog,
|
||||
.modal-content,
|
||||
.commit-dialog {
|
||||
width: 100%;
|
||||
max-width: 32rem;
|
||||
max-height: calc(100vh - var(--space-8));
|
||||
max-height: calc(100dvh - var(--space-8));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
@@ -468,16 +472,6 @@
|
||||
|
||||
.modal-content {
|
||||
/* 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 {
|
||||
@@ -500,9 +494,13 @@
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
.dialog-body,
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
@@ -543,9 +541,11 @@
|
||||
}
|
||||
|
||||
.dialog,
|
||||
.modal-content {
|
||||
.modal-content,
|
||||
.commit-dialog {
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - var(--space-6));
|
||||
max-height: calc(100dvh - var(--space-6));
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2328,8 +2328,10 @@ a.nav-item,
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-height: 70vh;
|
||||
max-height: 70dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
animation: slide-up 0.2s ease-out;
|
||||
}
|
||||
|
||||
@@ -2365,8 +2367,12 @@ a.nav-item,
|
||||
}
|
||||
|
||||
.mobile-bottom-sheet-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 8px 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.mobile-bottom-sheet-item {
|
||||
@@ -3240,7 +3246,10 @@ a:active,
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 80vh;
|
||||
max-height: 80dvh;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
animation: slideUp 0.3s ease;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
@@ -3602,6 +3611,7 @@ a:active,
|
||||
max-width: none;
|
||||
border-radius: 12px;
|
||||
max-height: 70vh;
|
||||
max-height: 70dvh;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Add a Headquarter Favicon
|
||||
|
||||
## Summary
|
||||
|
||||
Add a compact, recognizable favicon for Headquarter and register it in the web document head.
|
||||
|
||||
## Design
|
||||
|
||||
Use a geometric cream `H` on the product's evergreen brand field. The mark remains identifiable at small browser-tab sizes, avoids font rendering dependencies, and matches both light and dark application themes.
|
||||
|
||||
## Scope
|
||||
|
||||
- `apps/web/public/favicon.svg`
|
||||
- `apps/web/index.html`
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] The browser uses a dedicated Headquarter favicon.
|
||||
- [ ] The mark remains legible at small sizes and on light or dark browser chrome.
|
||||
- [ ] The HTML declares the icon type and a matching browser theme color.
|
||||
- [ ] Frontend production build passes.
|
||||
@@ -0,0 +1,6 @@
|
||||
# Add a Headquarter Favicon — Tasks
|
||||
|
||||
- [x] Create the favicon asset.
|
||||
- [x] Register the favicon and browser theme color in the web entry document.
|
||||
- [x] Run the frontend production build.
|
||||
- [x] Update project maps for changed source files.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user