diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..fcd326a5 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +# Test-only dependencies (browser-based responsive tests). +# Install with: pip install -r requirements.txt -r requirements-dev.txt +# python -m playwright install chromium +pytest>=8.0 +pytest-playwright>=0.5 diff --git a/static/style.css b/static/style.css index 0871d58c..b167f73c 100644 --- a/static/style.css +++ b/static/style.css @@ -4473,3 +4473,121 @@ footer { 50% { box-shadow: 0 0 8px 2px var(--accent); } } .help-pulse { animation: helpPulse 1s ease 3; } + +/* ========================================================================== + Mobile responsiveness (<= 768px). Desktop layout (> 768px) is untouched. + Goals: (1) kill horizontal overflow in the header / channel bar, + (2) make rules + jobs panels overlay the timeline instead of squeezing it, + (3) make the channel sidebar overlay the timeline instead of pushing it. + ========================================================================== */ +@media (max-width: 768px) { + + /* --- 1. Header + channel bar must not overflow the viewport width --- */ + + /* The header is a flex row of .header-left and .header-right; both must be + allowed to shrink so the button cluster wraps/scrolls instead of pushing + the document wider than the screen. */ + .header-left { + min-width: 0; + flex: 0 1 auto; + overflow: hidden; + } + header h1 { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .header-right { + min-width: 0; + flex: 1 1 auto; + gap: 6px; + overflow-x: auto; + scrollbar-width: none; + justify-content: flex-end; + } + .header-right::-webkit-scrollbar { display: none; } + /* agent-status pills were capped at 60vw; shrink further on a phone so the + toggle buttons stay reachable without sideways document scroll. */ + #agent-status { + max-width: 40vw; + } + + /* The channel bar is a 1fr/auto/1fr grid; the right column did not shrink. + Let it shrink and scroll its own content instead of widening the page. */ + .channel-bar-right { + min-width: 0; + } + + /* --- 2 & 3. Panels + sidebar overlay the chat instead of taking a column. + #content-area is a flex row; make it the positioning context so the + panels can be absolutely positioned over the timeline. The timeline keeps + full width because absolutely-positioned siblings leave the flow. --- */ + #content-area { + position: relative; + } + + /* Rules + jobs panels: full-height drawer over the right of the timeline. + The existing JS still toggles .hidden; the .hidden rules below keep the + slide-out + fade hide working, just translated instead of margin so they + never occupy inline space. */ + /* Scoped under #content-area so the override out-specifies the base + #jobs-panel rule, which lives in jobs.css and loads AFTER this file — + equal specificity would otherwise lose on source order. */ + #content-area #rules-panel, + #content-area #jobs-panel { + position: absolute; + top: 0; + right: 0; + bottom: 0; + height: 100%; + width: min(var(--panel-w), 90vw); + min-width: 0; + max-width: 90vw; + z-index: 30; + margin-right: 0; + box-shadow: -4px 0 16px rgba(0, 0, 0, 0.4); + transition: transform 0.25s ease, opacity 0.2s ease; + } + #content-area #rules-panel.hidden, + #content-area #jobs-panel.hidden { + margin-right: 0; + transform: translateX(100%); + } + + /* Channel sidebar: full-height drawer over the left of the timeline. */ + #channel-sidebar { + position: absolute; + top: 0; + left: 0; + bottom: 0; + height: 100%; + width: min(var(--channel-sidebar-w, 200px), 80vw); + max-width: 80vw; + z-index: 30; + box-shadow: 4px 0 16px rgba(0, 0, 0, 0.4); + } + /* #channel-sidebar.hidden already uses display:none, which keeps it out of + flow on mobile too — no override needed. */ + + /* --- 4. Message box: on a phone the sender label + session/mic/send/ + schedule controls crowded the textarea down to ~100px. Let the row wrap + and give the textarea its own full-width line above the controls, with a + finger-friendly height and a >=16px font (smaller fonts make iOS zoom the + page on focus). All controls stay present, just on the line below. --- */ + #input-row { + flex-wrap: wrap; + } + #input { + order: -1; + flex: 1 1 100%; + font-size: 16px; + min-height: 44px; + max-height: 140px; + } + /* Group the audio + send buttons at the right edge of the controls line. + margin-left:auto pushes the mic and everything after it (the send group) + to the right; the sender label + session button stay on the left. */ + #input-row #mic { + margin-left: auto; + } +} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..b5f9e8b6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,82 @@ +"""Shared fixtures for the browser-based responsive layout tests. + +Launches a real agentchattr web server on isolated ports (so it never +collides with a live instance) and exposes its base URL to the tests. +""" + +import os +import socket +import subprocess +import sys +import tempfile +import time +import urllib.request +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent + + +def _free_port() -> int: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + s.close() + return port + + +@pytest.fixture(scope="session") +def server(): + """Start the web UI on a throwaway port + data dir, yield its base URL.""" + web_port = _free_port() + http_port = _free_port() + sse_port = _free_port() + data_dir = tempfile.mkdtemp(prefix="agentchattr-test-") + + env = dict(os.environ) + proc = subprocess.Popen( + [ + sys.executable, "run.py", + "--port", str(web_port), + "--mcp-http-port", str(http_port), + "--mcp-sse-port", str(sse_port), + "--data-dir", data_dir, + ], + cwd=str(ROOT), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + + base_url = f"http://127.0.0.1:{web_port}" + deadline = time.time() + 30 + last_err = None + while time.time() < deadline: + if proc.poll() is not None: + out = proc.stdout.read().decode("utf-8", "replace") if proc.stdout else "" + raise RuntimeError(f"server exited early (code {proc.returncode}):\n{out}") + try: + with urllib.request.urlopen(base_url, timeout=1) as r: + if r.status == 200: + break + except Exception as e: # noqa: BLE001 - polling until ready + last_err = e + time.sleep(0.3) + else: + proc.terminate() + raise RuntimeError(f"server did not become ready: {last_err}") + + yield base_url + + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + + +# Viewport presets used across the responsive suite. +MOBILE = {"width": 375, "height": 812} # iPhone-class portrait +TABLET = {"width": 768, "height": 1024} # iPad-class portrait +DESKTOP = {"width": 1280, "height": 900} # laptop diff --git a/tests/test_responsive.py b/tests/test_responsive.py new file mode 100644 index 00000000..2cd8eac7 --- /dev/null +++ b/tests/test_responsive.py @@ -0,0 +1,183 @@ +"""Responsive-layout behavior tests. + +Goal: the chat must be usable on a phone WITHOUT changing how it behaves on +desktop. We drive a real browser at phone / laptop widths, toggle UI state +through the app's OWN functions (so we exercise real behavior, not a mock), +and assert on the rendered geometry. + +Two groups: + * mobile_* - new behavior we want (no sideways scroll; panels overlay the + chat instead of squeezing it) + * desktop_* - characterization of CURRENT behavior we must preserve + (panels stay side-by-side columns; no regression) +""" + +from conftest import MOBILE, DESKTOP + +TIMELINE = "main#timeline" +RULES = "#rules-panel" +SIDEBAR = "#channel-sidebar" + + +def _load(page, viewport, server): + page.set_viewport_size(viewport) + page.goto(server) + page.wait_for_timeout(300) + + +def _box(page, selector): + return page.locator(selector).bounding_box() + + +def _doc_overflow(page): + """How many px the document scrolls horizontally past the viewport.""" + return page.evaluate( + "() => document.documentElement.scrollWidth - window.innerWidth" + ) + + +def _open_rules(page): + page.evaluate("toggleRulesPanel()") + page.wait_for_timeout(400) # css margin transition + + +def _open_jobs(page): + page.evaluate("toggleJobsPanel()") + page.wait_for_timeout(400) + + +def _enable_sidebar(page): + page.evaluate("setChannelSidebarMode('sidebar')") + page.wait_for_timeout(400) + + +# -------------------------------------------------------------------------- +# MOBILE - the behavior we are adding +# -------------------------------------------------------------------------- + +def test_mobile_no_horizontal_overflow(page, server): + """A phone screen must not scroll sideways in the default view.""" + _load(page, MOBILE, server) + overflow = _doc_overflow(page) + assert overflow <= 1, f"page overflows horizontally by {overflow}px on mobile" + + +def test_mobile_rules_panel_does_not_squeeze_timeline(page, server): + """Opening the rules panel on a phone must overlay, not shrink the chat.""" + _load(page, MOBILE, server) + _open_rules(page) + timeline = _box(page, TIMELINE) + assert timeline is not None + assert timeline["width"] >= MOBILE["width"] * 0.85, ( + f"timeline only {timeline['width']}px of {MOBILE['width']}px; " + "rules panel is stealing horizontal space instead of overlaying" + ) + assert _doc_overflow(page) <= 1, "rules panel causes horizontal scroll on mobile" + + +def test_mobile_jobs_panel_does_not_squeeze_timeline(page, server): + """Opening the jobs panel on a phone must overlay, not shrink the chat.""" + _load(page, MOBILE, server) + _open_jobs(page) + timeline = _box(page, TIMELINE) + assert timeline is not None + assert timeline["width"] >= MOBILE["width"] * 0.85, ( + f"timeline only {timeline['width']}px of {MOBILE['width']}px; " + "jobs panel is stealing horizontal space instead of overlaying" + ) + assert _doc_overflow(page) <= 1, "jobs panel causes horizontal scroll on mobile" + + +def test_mobile_channel_sidebar_does_not_squeeze_timeline(page, server): + """Channel-sidebar mode on a phone must overlay, not shrink the chat.""" + _load(page, MOBILE, server) + _enable_sidebar(page) + timeline = _box(page, TIMELINE) + assert timeline is not None + assert timeline["x"] <= MOBILE["width"] * 0.1, ( + f"timeline starts at x={timeline['x']}; channel sidebar is pushing " + "the chat sideways instead of overlaying" + ) + assert _doc_overflow(page) <= 1, "sidebar mode causes horizontal scroll on mobile" + + +def test_mobile_input_box_is_usable_size(page, server): + """The message box must be a comfortable size to type in on a phone. + + On a 375px screen the surrounding controls (sender label, session/mic/ + send/schedule buttons) crowded the textarea down to ~102px wide. It needs + real width, a finger-friendly height, and a >=16px font so iOS does not + zoom the page when it gets focus. + """ + _load(page, MOBILE, server) + box = _box(page, "#input") + assert box is not None + assert box["width"] >= MOBILE["width"] * 0.7, ( + f"input only {box['width']}px wide of {MOBILE['width']}px; " + "surrounding controls are crowding the text box" + ) + assert box["height"] >= 44, f"input only {box['height']}px tall; too small to tap" + font_px = page.evaluate( + "() => parseFloat(getComputedStyle(document.querySelector('#input')).fontSize)" + ) + assert font_px >= 16, f"input font {font_px}px; <16px makes iOS zoom on focus" + + +def test_mobile_send_and_mic_align_right(page, server): + """On a phone, the send button and the mic button sit at the right edge. + + After the input wraps to its own line, the controls were left-packed, + leaving the send group ending ~57px short of the row's right edge. Send + and the audio (mic) button should hug the right, with mic just left of send. + """ + _load(page, MOBILE, server) + row = _box(page, "#input-row") + group = _box(page, ".send-group") + mic = _box(page, "#mic") + assert row and group and mic + row_right = row["x"] + row["width"] + group_right = group["x"] + group["width"] + mic_right = mic["x"] + mic["width"] + # send group hugs the right edge of the row + assert group_right >= row_right - 8, ( + f"send group ends at {group_right}px but row right edge is {row_right}px" + ) + # mic sits immediately to the left of the send group (grouped on the right) + gap = group["x"] - mic_right + assert 0 <= gap <= 16, f"mic not adjacent to send (gap {gap}px)" + assert mic["x"] >= row["x"] + row["width"] * 0.45, "mic is not on the right side" + + +# -------------------------------------------------------------------------- +# DESKTOP - characterization of behavior that must NOT change +# -------------------------------------------------------------------------- + +def test_desktop_no_horizontal_overflow(page, server): + """Laptop view never scrolled sideways; keep it that way.""" + _load(page, DESKTOP, server) + assert _doc_overflow(page) <= 1 + + +def test_desktop_rules_panel_sits_beside_timeline(page, server): + """On a laptop, the rules panel stays a side column to the right of chat.""" + _load(page, DESKTOP, server) + _open_rules(page) + timeline = _box(page, TIMELINE) + rules = _box(page, RULES) + assert timeline and rules + assert rules["x"] >= timeline["x"] + timeline["width"] - 2, "panel not to the right" + # same row (vertically overlapping) => side-by-side, not stacked + assert rules["y"] < timeline["y"] + timeline["height"] + assert timeline["y"] < rules["y"] + rules["height"] + + +def test_desktop_channel_sidebar_sits_beside_timeline(page, server): + """On a laptop, the channel sidebar stays a left column beside the chat.""" + _load(page, DESKTOP, server) + _enable_sidebar(page) + timeline = _box(page, TIMELINE) + sidebar = _box(page, SIDEBAR) + assert timeline and sidebar + assert sidebar["x"] + sidebar["width"] <= timeline["x"] + 2, "sidebar not to the left" + assert sidebar["y"] < timeline["y"] + timeline["height"] + assert timeline["y"] < sidebar["y"] + sidebar["height"]