Compare commits

..

8 Commits

Author SHA1 Message Date
Developer f9f9372ee7 fix: merge web terminal resilience 2026-07-18 10:32:56 +00:00
Developer ea42165ed2 fix: harden web terminal paste and reconnect
- Queue bounded ordered terminal input so acknowledgements remain responsive
- Prevent stale sockets and retries from replacing healthy connections
- Preserve desktop scrollback behavior and add terminal regression coverage

Quality gates: frontend tests (91 passed), typecheck, lint, build, Python compilation, LSP diagnostics. Backend pytest skipped by user request.
2026-07-17 22:11:24 +00:00
Developer c178225c8b Merge branch 'fix/git-mount-clone-reuse' into dev 2026-07-17 20:54:21 +00:00
Developer b5e961ebe9 fix: reuse cached config profile git mounts
- Reuse valid deterministic git mount clones on repeated starts
- Remove incomplete clone destinations before retrying
- Add regression coverage for cached and partial clones
- Document OpenSpec change fix-config-profile-git-mount-clone-reuse

Quality gates: pytest (13 passed), ruff, mypy
2026-07-17 20:54:01 +00:00
Developer 5d379c5f8b merge: add Headquarter favicon 2026-07-15 13:06:41 +00:00
Developer 671540ded9 feat: add Headquarter favicon
- Add a geometric evergreen Headquarter mark for browser tabs\n- Register the SVG favicon and matching browser theme color\n\nOpenSpec: add-project-favicon\nQuality gates: npm run build
2026-07-15 13:06:40 +00:00
Developer ad26fd9f35 merge: dialog scroll containment 2026-07-15 10:20:39 +00:00
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
25 changed files with 608 additions and 81 deletions
+60 -5
View File
@@ -3,6 +3,8 @@
import asyncio
import json
import logging
from asyncio import QueueFull
from json import JSONDecodeError
import uuid
from contextlib import suppress
@@ -31,6 +33,9 @@ from src.services.terminal.terminal_manager import (
router = APIRouter()
logger = logging.getLogger(__name__)
MAX_PENDING_INPUT_MESSAGES = 64
MAX_TERMINAL_INPUT_BYTES = 1024 * 1024
class SessionRef:
"""Mutable reference to a terminal session, allowing updates during reset."""
@@ -320,8 +325,38 @@ async def _handle_terminal_websocket(
)
async def _input_write_loop(input_queue: asyncio.Queue[tuple[object, bytes]]) -> None:
"""Serialize PTY writes without blocking terminal control messages."""
while True:
session, data = await input_queue.get()
try:
await session.write_input(data) # type: ignore[attr-defined]
except Exception:
logger.debug("Terminal input write failed", exc_info=True)
finally:
input_queue.task_done()
def _queue_terminal_input(
input_queue: asyncio.Queue[tuple[object, bytes]], session: object, data: bytes
) -> bool:
"""Queue bounded terminal input without blocking control-message processing."""
if len(data) > MAX_TERMINAL_INPUT_BYTES:
return False
try:
input_queue.put_nowait((session, data))
except QueueFull:
return False
return True
async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> None:
"""Read input from WebSocket and send to container."""
"""Receive terminal messages while a dedicated worker serializes PTY input."""
input_queue: asyncio.Queue[tuple[object, bytes]] = asyncio.Queue(
maxsize=MAX_PENDING_INPUT_MESSAGES
)
input_writer = asyncio.create_task(_input_write_loop(input_queue))
try:
while True:
session = session_ref.session
@@ -331,7 +366,12 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
message = await websocket.receive()
if message["type"] == "websocket.receive":
if "bytes" in message:
await session.write_input(message["bytes"])
if not _queue_terminal_input(
input_queue, session, message["bytes"]
):
logger.warning("Terminal input buffer exceeded for %s", instance_id)
await websocket.close(code=1009, reason="Terminal input buffer full")
break
elif "text" in message:
text = message["text"]
# A text frame that parses to a JSON object with a
@@ -345,13 +385,22 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
if text.startswith("{"):
try:
parsed = json.loads(text)
except json.JSONDecodeError:
except JSONDecodeError:
parsed = None
if isinstance(parsed, dict) and "type" in parsed:
ctrl = parsed
if ctrl is None:
await session.write_input(text.encode("utf-8"))
if not _queue_terminal_input(
input_queue, session, text.encode("utf-8")
):
logger.warning(
"Terminal input buffer exceeded for %s", instance_id
)
await websocket.close(
code=1009, reason="Terminal input buffer full"
)
break
continue
msg_type = ctrl["type"]
@@ -403,8 +452,14 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
continue
elif message["type"] == "websocket.disconnect":
break
except Exception:
except WebSocketDisconnect:
pass
except (RuntimeError, TypeError, ValueError) as exc:
logger.debug("Terminal WebSocket receive loop ended: %s", exc)
finally:
input_writer.cancel()
with suppress(asyncio.CancelledError):
await input_writer
async def _heartbeat_loop(websocket: WebSocket) -> None:
+19 -15
View File
@@ -283,16 +283,27 @@ def clone_git_repo(
url_hash = hashlib.md5(f"{remote_url}:{branch_segment}".encode()).hexdigest()[:12]
repo_name = remote_url.split("/")[-1].replace(".git", "") or "repo"
clone_dir = os.path.join(clone_parent, "git-mounts", f"{repo_name}-{url_hash}")
repo_path = clone_repository(
remote_url,
None, # No SSH key for now - can be added later
clone_dir,
branch or "main",
project_name=project_name,
)
clone_name = _slugify_directory_name(project_name) if project_name else "repo-clone"
repo_path = os.path.join(clone_dir, clone_name)
if not os.path.exists(repo_path):
if os.path.isdir(os.path.join(repo_path, ".git")):
# Reuse the deterministic per-repository cache on repeated starts.
try:
pull_repository_updates(repo_path, remote_url)
logger.debug("Pulled updates for git mount %s", remote_url)
except Exception as exc:
logger.warning("Failed to pull updates for %s: %s", remote_url, exc)
else:
try:
# A failed clone can leave its destination behind. Remove only the
# computed clone path so the next start can retry cleanly.
if os.path.lexists(repo_path):
logger.warning("Removing incomplete git mount clone at %s", repo_path)
if os.path.isdir(repo_path) and not os.path.islink(repo_path):
shutil.rmtree(repo_path)
else:
os.unlink(repo_path)
os.makedirs(clone_dir, exist_ok=True)
repo_path = clone_repository(
remote_url,
@@ -305,13 +316,6 @@ def clone_git_repo(
except Exception as exc:
logger.warning("Clone failed for git mount %s: %s", remote_url, exc)
raise
else:
# Repo exists - pull latest updates
try:
pull_repository_updates(repo_path, remote_url)
logger.debug("Pulled updates for git mount %s", remote_url)
except Exception as exc:
logger.warning("Failed to pull updates for %s: %s", remote_url, exc)
# Handle branch checkout if specified
if branch and repo_path:
+77 -2
View File
@@ -1,7 +1,15 @@
"""Integration tests for multi-session terminal WebSocket and REST API."""
import asyncio
import pytest
from fastapi.testclient import TestClient
from src.api.system.terminal import (
MAX_TERMINAL_INPUT_BYTES,
SessionRef,
_queue_terminal_input,
_write_loop,
)
from src.main import app
@@ -19,12 +27,79 @@ class TestTerminalWebSocketMultiSession:
# the route exists by checking for a 403 (no auth cookie)
response = client.get("/ws/tool-instances/test-instance/terminal/test-session")
# WebSocket endpoint returns 403 when accessed via HTTP GET
assert response.status_code in (403, 404)
assert response.status_code == 403 or response.status_code == 404
def test_default_session_alias_route_exists(self, client):
"""The default session alias route should still exist."""
response = client.get("/ws/tool-instances/test-instance/terminal")
assert response.status_code in (403, 404)
assert response.status_code == 403 or response.status_code == 404
def test_terminal_input_queue_rejects_excess_input_without_blocking() -> None:
"""A stalled PTY writer cannot make the input queue grow without limit."""
input_queue: asyncio.Queue[tuple[object, bytes]] = asyncio.Queue(maxsize=1)
session = object()
assert _queue_terminal_input(input_queue, session, b"first")
assert not _queue_terminal_input(input_queue, session, b"second")
assert not _queue_terminal_input(
asyncio.Queue(), session, b"x" * (MAX_TERMINAL_INPUT_BYTES + 1)
)
@pytest.mark.asyncio
async def test_ack_is_processed_while_a_pty_write_is_waiting() -> None:
"""A blocked paste writer must not block flow-control acknowledgements."""
write_started = asyncio.Event()
class Session:
_closed = False
def __init__(self) -> None:
self.acks: list[int] = []
self.write_finished = False
def is_alive(self) -> bool:
return True
async def write_input(self, _data: bytes) -> None:
write_started.set()
try:
await asyncio.Event().wait()
finally:
self.write_finished = True
def acknowledge_data(self, char_count: int) -> None:
self.acks.append(char_count)
class WebSocket:
def __init__(self) -> None:
self.messages = iter(
[
{"type": "websocket.receive", "bytes": b"large paste"},
{"type": "websocket.receive", "text": '{"type":"ack","chars":4096}'},
{"type": "websocket.disconnect"},
]
)
self.receive_count = 0
async def receive(self):
self.receive_count += 1
if self.receive_count > 1:
await write_started.wait()
return next(self.messages)
session = Session()
websocket = WebSocket()
task = asyncio.create_task(_write_loop(SessionRef(session), websocket, "instance"))
await asyncio.wait_for(write_started.wait(), timeout=0.1)
await asyncio.sleep(0)
assert session.acks == [4096]
await task
assert session.write_finished
class TestTerminalRestApi:
@@ -1,5 +1,6 @@
"""Unit tests for the tool instance service."""
import hashlib
import uuid
from unittest.mock import MagicMock, AsyncMock
@@ -8,6 +9,7 @@ import pytest
from src.services.tool.instance_service import (
_get_repository_mount_name,
_stack_profile_mounts_with_git_mounts,
clone_git_repo,
modify_compose_file,
prepare_manifest_instance,
)
@@ -67,6 +69,61 @@ class TestGetRepositoryMountName:
assert _get_repository_mount_name(project, repo) == "project-v2-0"
@pytest.mark.unit
class TestCloneGitRepo:
"""Regression tests for reusable config-profile git mount clones."""
def test_reuses_existing_clone(self, monkeypatch, tmp_path) -> None:
from src.services.tool import instance_service
remote_url = "https://gitlab.com/example/dotfiles"
branch = None
clone_parent = str(tmp_path)
url_hash = hashlib.md5(f"{remote_url}:default".encode()).hexdigest()[:12]
repo_path = (
tmp_path
/ "git-mounts"
/ f"dotfiles-{url_hash}"
/ "repo-clone"
)
(repo_path / ".git").mkdir(parents=True)
clone = MagicMock(side_effect=AssertionError("existing clone must be reused"))
pull = MagicMock()
monkeypatch.setattr(instance_service, "clone_repository", clone)
monkeypatch.setattr(instance_service, "pull_repository_updates", pull)
result = clone_git_repo(remote_url, branch, clone_parent)
assert result == str(repo_path)
clone.assert_not_called()
pull.assert_called_once_with(str(repo_path), remote_url)
def test_replaces_incomplete_clone_before_retry(self, monkeypatch, tmp_path) -> None:
from src.services.tool import instance_service
remote_url = "https://gitlab.com/example/dotfiles"
url_hash = hashlib.md5(f"{remote_url}:main".encode()).hexdigest()[:12]
clone_dir = tmp_path / "git-mounts" / f"dotfiles-{url_hash}"
repo_path = clone_dir / "repo-clone"
repo_path.mkdir(parents=True)
(repo_path / "partial-file").write_text("incomplete")
def clone(_url, _key, destination, _branch, project_name=None):
assert destination == str(clone_dir)
assert project_name is None
assert not repo_path.exists()
(repo_path / ".git").mkdir(parents=True)
return str(repo_path)
monkeypatch.setattr(instance_service, "clone_repository", clone)
result = clone_git_repo(remote_url, "main", str(tmp_path))
assert result == str(repo_path)
assert (repo_path / ".git").is_dir()
@pytest.mark.unit
class TestStackProfileMountsWithGitMounts:
"""Tests for _stack_profile_mounts_with_git_mounts."""
+2
View File
@@ -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 />
+6
View File
@@ -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

@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { getTerminalScrollbackLimit } from "./terminal.tsx";
import {
getTerminalScrollbackLimit,
isCurrentWebSocket,
shouldRetryWebSocketClose,
} from "./terminal.tsx";
describe("getTerminalScrollbackLimit", () => {
it("retains normal-buffer history for custom mobile swipe scrolling", () => {
@@ -10,4 +14,19 @@ describe("getTerminalScrollbackLimit", () => {
it("keeps desktop scrollback disabled to prevent stale-frame wheel scrolling", () => {
expect(getTerminalScrollbackLimit(false)).toBe(0);
});
it("rejects stale WebSocket callbacks after a replacement connection", () => {
const current = {} as WebSocket;
const stale = {} as WebSocket;
expect(isCurrentWebSocket(current, current)).toBe(true);
expect(isCurrentWebSocket(current, stale)).toBe(false);
});
it("retries a heartbeat timeout but not a server socket replacement", () => {
expect(shouldRetryWebSocketClose(4000, "Heartbeat timeout")).toBe(true);
expect(shouldRetryWebSocketClose(4000, "New connection established")).toBe(
false,
);
});
});
@@ -57,6 +57,17 @@ export function getTerminalScrollbackLimit(isMobile: boolean): number {
return isMobile ? 10_000 : 0;
}
export function isCurrentWebSocket(
current: WebSocket | null,
candidate: WebSocket,
): boolean {
return current === candidate;
}
export function shouldRetryWebSocketClose(code: number, reason: string): boolean {
return code !== 1000 && !(code === 4000 && reason === "New connection established");
}
function matchesByteSequence(
data: Uint8Array,
start: number,
@@ -87,6 +98,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
const bracketedPasteEnabledRef = useRef(false);
const pasteTextRef = useRef<(text: string) => void>(() => {});
const reconnectAttemptsRef = useRef(0);
const reconnectTimerRef = useRef<number | null>(null);
const onTerminalReadyRef = useRef(onTerminalReady);
onTerminalReadyRef.current = onTerminalReady;
const handleFontSizeChangeRef = useRef<(delta: number) => void>(() => {});
@@ -115,7 +127,23 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
return fontSize;
}, [fontSize]);
const clearReconnectTimer = useCallback(() => {
if (reconnectTimerRef.current !== null) {
window.clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
}, []);
const connectWebSocket = useCallback(() => {
const currentWs = wsRef.current;
if (
currentWs?.readyState === WebSocket.CONNECTING ||
currentWs?.readyState === WebSocket.OPEN
) {
return currentWs;
}
clearReconnectTimer();
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
@@ -165,6 +193,11 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
};
ws.onopen = () => {
if (!isCurrentWebSocket(wsRef.current, ws)) {
ws.close(1000, "Superseded connection");
return;
}
setStatus("connected");
setError(null);
reconnectAttemptsRef.current = 0;
@@ -205,7 +238,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
};
ws.onmessage = (event) => {
if (!termRef.current) return;
if (!isCurrentWebSocket(wsRef.current, ws) || !termRef.current) return;
if (event.data instanceof ArrayBuffer) {
const data = new Uint8Array(event.data);
@@ -264,6 +297,10 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
};
ws.onclose = (event) => {
if (!isCurrentWebSocket(wsRef.current, ws)) return;
wsRef.current = null;
if (ackTimeout) window.clearTimeout(ackTimeout);
// Clean up heartbeat check
if (heartbeatCheckRef.current) {
window.clearInterval(heartbeatCheckRef.current);
@@ -279,18 +316,12 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
return;
}
if (event.code === 1000) {
if (!shouldRetryWebSocketClose(event.code, event.reason)) {
setStatus("disconnected");
return;
}
if (event.code === 4000) {
// Server closed old connection for concurrent connection - don't reconnect
// The new connection is already established
return;
}
// Transient errors: attempt reconnection
// Transient errors: attempt reconnection.
setStatus("disconnected");
setError(`Connection closed (code: ${event.code})`);
@@ -299,11 +330,14 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
const delay =
RECONNECT_DELAY_BASE *
Math.pow(2, reconnectAttemptsRef.current - 1);
setTimeout(() => {
if (isUnmountingRef.current) {
return;
}
if (document.visibilityState !== "hidden") {
clearReconnectTimer();
reconnectTimerRef.current = window.setTimeout(() => {
reconnectTimerRef.current = null;
if (
!isUnmountingRef.current &&
document.visibilityState !== "hidden" &&
wsRef.current === null
) {
connectWebSocket();
}
}, delay);
@@ -311,15 +345,18 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
};
ws.onerror = () => {
if (!isCurrentWebSocket(wsRef.current, ws)) return;
setStatus("error");
setError("WebSocket error");
};
return ws;
}, [instanceId, sessionId]);
}, [clearReconnectTimer, instanceId, sessionId]);
useEffect(() => {
if (!terminalRef.current) return;
isUnmountingRef.current = false;
permanentErrorRef.current = null;
// Initialize terminal
const currentFontSize = calculateFontSize();
@@ -410,7 +447,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// Open xterm first (must happen before fit)
term.open(container);
term.focus();
const ws = connectWebSocket();
connectWebSocket();
pasteTextRef.current = (text: string) => {
const currentWs = wsRef.current;
@@ -658,14 +695,12 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// Visibility API for reconnection
const handleVisibilityChange = () => {
const currentWs = wsRef.current;
if (
document.visibilityState === "visible" &&
ws &&
ws.readyState !== WebSocket.OPEN
!permanentErrorRef.current &&
(currentWs === null || currentWs.readyState === WebSocket.CLOSED)
) {
if (permanentErrorRef.current) {
return;
}
reconnectAttemptsRef.current = 0;
connectWebSocket();
}
@@ -674,6 +709,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
return () => {
isUnmountingRef.current = true;
clearReconnectTimer();
clearTimeout(resizeTimeout);
clearTimeout(windowResizeTimeout);
clearTimeout(headerHideTimeout);
@@ -687,8 +723,10 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
container.removeEventListener("paste", handleBrowserPaste, true);
pasteTextRef.current = () => {};
bracketedPasteEnabledRef.current = false;
if (ws) {
ws.close(1000, "Component unmounting");
const currentWs = wsRef.current;
wsRef.current = null;
if (currentWs) {
currentWs.close(1000, "Component unmounting");
}
if (heartbeatCheckRef.current) {
window.clearInterval(heartbeatCheckRef.current);
@@ -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>
)}
+13 -13
View File
@@ -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;
}
}
+10
View File
@@ -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;
}
}
+3
View File
@@ -10,6 +10,9 @@ map: openspec/.pi-map.md
- openspec/changes/archive
index: openspec/changes/archive/.pi-map.index.md
map: openspec/changes/archive/.pi-map.md
- openspec/changes/fix-config-profile-git-mount-clone-reuse
index: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.index.md
map: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.md
- openspec/changes/fix-container-status-false-positive
index: openspec/changes/fix-container-status-false-positive/.pi-map.index.md
map: openspec/changes/fix-container-status-false-positive/.pi-map.md
@@ -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,20 @@
# openspec/changes/fix-config-profile-git-mount-clone-reuse (index)
dir: openspec/changes/fix-config-profile-git-mount-clone-reuse
## role
Documents and tracks a bug fix for reusing cached Config Profile git mount clones during tool instance startup.
## parent
index: openspec/changes/.pi-map.index.md
map: openspec/changes/.pi-map.md
## children
-
## files
- change.md
- tasks.md
## links
index: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.index.md
map: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.md
## workflows
-
## dirty
-
@@ -0,0 +1,20 @@
# openspec/changes/fix-config-profile-git-mount-clone-reuse
dir: openspec/changes/fix-config-profile-git-mount-clone-reuse
index: openspec/changes/fix-config-profile-git-mount-clone-reuse/.pi-map.index.md
## role
Documents and tracks a bug fix for reusing cached Config Profile git mount clones during tool instance startup.
## files
- change.md | Describes the cached clone regression, required behavior, implementation scope, and verification plan.
- tasks.md | Tracks investigation, regression testing, implementation, project-map maintenance, verification, and commit status.
## arch
Documentation-only OpenSpec change package with separate change rationale and implementation task checklist.
## tags
config, profile, git, mount, clone, cache, startup, fix
## symbols
-
## workflows
-
## dirty
-
@@ -0,0 +1,27 @@
# Fix config profile git mount clone reuse
## Problem
Starting a tool instance with a Config Profile git mount can omit the mount when the repository was already cloned into the instance cache. The startup log reports that the destination path already exists and is not an empty directory.
## Root cause
`clone_git_repo()` computes a deterministic cache directory but calls `clone_repository()` before checking whether that directory already contains a clone. `git clone` therefore fails on repeated starts or overlapping start requests. `resolve_single_git_mount()` treats auxiliary mount failures as non-blocking, so startup continues without the configured volume.
## Required behavior
1. A valid existing git mount clone must be reused and updated instead of cloned again.
2. A missing clone must still be created normally.
3. An incomplete clone directory must not permanently prevent a later retry.
4. A clone/update failure remains non-blocking at the git mount resolver boundary.
## Scope
- Correct clone-cache handling in `apps/api/src/services/tool/instance_service.py`.
- Add focused regression tests in `apps/api/tests/unit/test_instance_service.py`.
- No API, database, frontend, or Docker Compose contract changes.
## Verification
- Targeted `pytest` for git mount clone reuse and instance service tests.
- Ruff and mypy checks for changed backend files.
@@ -0,0 +1,9 @@
# Tasks: fix config profile git mount clone reuse
- [x] Capture the failing runtime trace from an affected tool session.
- [x] Add a regression test proving a valid cached clone is reused.
- [x] Update `clone_git_repo()` to check the deterministic clone path before cloning.
- [x] Recover safely from an incomplete clone directory.
- [x] Run targeted backend tests and quality checks.
- [x] Update project maps and validate map freshness.
- [x] Commit the verified fix.
@@ -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.
@@ -0,0 +1,35 @@
# Fix Web Terminal Resilience
## Summary
Prevent large browser pastes from blocking flow-control acknowledgements, and prevent stale reconnect callbacks from replacing a healthy terminal WebSocket.
## Problem
The terminal WebSocket handler awaits each PTY write inline. A large paste can wait for the PTY to become writable while the same handler stops receiving acknowledgement messages. If output flow control has paused PTY reads, the acknowledgement that would resume output remains unread, leaving the terminal apparently frozen.
Separately, reconnect timers and visibility callbacks can create a second socket after a connection becomes healthy. The terminal manager then closes the existing session socket, interrupting active input or rendering.
## Scope
- Queue bounded terminal input onto a single ordered writer so the WebSocket receive loop continues handling acknowledgements, resize, reset, and disconnect messages.
- Make browser reconnection single-owner: stale socket callbacks and retry timers MUST NOT replace a current healthy socket.
- Add focused regression tests for the queueing and reconnect behavior.
## Non-goals
- Re-enable desktop normal-buffer scrollback or mouse-wheel scrolling. Desktop continues to use zero scrollback and disabled wheel sensitivity to avoid the known stale TUI-frame regression.
- Change terminal session persistence, authentication, PTY transport, or mobile touch scrolling behavior.
## Risk and rollback
The bounded input queue must preserve input ordering, reject excess input without blocking control messages, and be cancelled when the WebSocket disconnects. Socket ownership checks must not prevent a legitimate reconnect after a real disconnect. Roll back by reverting the backend queue and frontend ownership changes; existing direct PTY input and retry behavior then resumes.
## Acceptance Criteria
- [ ] A blocked PTY write does not prevent the WebSocket handler from processing a subsequent flow-control acknowledgement.
- [ ] Input bytes are still written to the PTY in arrival order, and excess queued input is rejected rather than growing without limit.
- [ ] A stale socket close event or retry callback cannot replace an open current socket.
- [ ] Component cleanup cancels pending reconnect timers and closes the current socket.
- [ ] Desktop scrollback and wheel settings remain unchanged.
- [ ] Focused backend/frontend tests and relevant quality gates pass.
@@ -0,0 +1,27 @@
# Fix Web Terminal Resilience — Tasks
## Review Workload Forecast
| Field | Value |
| ------- | ------- |
| Estimated changed lines | 180280 |
| 400-line budget risk | Low |
| Chained PRs recommended | No |
| Suggested split | Single focused change |
| Delivery strategy | single-pr |
| Chain strategy | feature-branch-chain |
Decision needed before apply: No
Chained PRs recommended: No
Chain strategy: feature-branch-chain
400-line budget risk: Low
## Tasks
- [x] **RED — backend input/control concurrency:** characterize a PTY write that waits for readiness while an acknowledgement is received; prove the acknowledgement is handled without waiting for that write to finish.
- [x] **GREEN — ordered input writer:** move PTY writes behind one cancellable ordered queue/worker while retaining the current public WebSocket message protocol and input ordering.
- [x] **TRIANGULATE — lifecycle:** cover worker cancellation and queued-write failure/disconnect handling.
- [x] **RED — frontend socket ownership:** characterize stale close/retry callbacks after a newer socket has become current.
- [x] **GREEN — reconnect ownership:** ensure only the current socket can update state or schedule a retry; cancel retry timers during cleanup.
- [x] **REFACTOR:** keep the connection lifecycle readable and avoid changing the intentional desktop scrollback configuration.
- [x] **Verify:** run targeted backend and frontend tests, frontend typecheck/lint/build, backend checks practical in the isolated worktree, and inspect diagnostics. (Backend pytest is unavailable locally: no pytest/uv executable; Docker test execution was explicitly declined.)