From e544c463ae820371a19efc4494aead240a546109 Mon Sep 17 00:00:00 2001 From: dynaum Date: Fri, 29 May 2026 15:41:55 -0400 Subject: [PATCH 1/4] test: add browser-based responsive layout tests (red) Drives a real browser at phone/laptop widths against a throwaway server instance. Mobile tests fail today (horizontal overflow; rules panel and channel sidebar squeeze the chat). Desktop tests characterize current side-by-side behavior so the fix cannot regress it. Co-Authored-By: Claude Opus 4.8 --- requirements-dev.txt | 5 ++ tests/conftest.py | 82 +++++++++++++++++++++++++++ tests/test_responsive.py | 118 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 requirements-dev.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_responsive.py 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/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..0bf8df06 --- /dev/null +++ b/tests/test_responsive.py @@ -0,0 +1,118 @@ +"""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 _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_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" + + +# -------------------------------------------------------------------------- +# 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"] From fe1286329757dacb1b26a46d7a629f4739c8cf29 Mon Sep 17 00:00:00 2001 From: dynaum Date: Fri, 29 May 2026 15:49:58 -0400 Subject: [PATCH 2/4] feat: make web UI responsive on mobile (<=768px) Adds a single additive @media (max-width: 768px) block to style.css. Desktop layout (>768px) is untouched. - Header/channel-bar no longer overflow: button cluster shrinks and scrolls horizontally instead of widening the document. - Rules and jobs panels overlay the chat as full-height drawers instead of taking an inline column that squeezed the timeline. - Channel sidebar overlays the chat from the left instead of pushing it. The jobs-panel override is scoped under #content-area so it out-specifies the base rule in jobs.css (which loads after style.css). All panel/sidebar toggles keep working unchanged; only their mobile rendering changes. Adds browser-based responsive tests (phone + laptop widths) covering the rules panel, jobs panel, channel sidebar, and horizontal-overflow cases. Co-Authored-By: Claude Opus 4.8 --- static/style.css | 96 ++++++++++++++++++++++++++++++++++++++++ tests/test_responsive.py | 18 ++++++++ 2 files changed, 114 insertions(+) diff --git a/static/style.css b/static/style.css index 0871d58c..8b859ea9 100644 --- a/static/style.css +++ b/static/style.css @@ -4473,3 +4473,99 @@ 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. */ +} diff --git a/tests/test_responsive.py b/tests/test_responsive.py index 0bf8df06..a2a36065 100644 --- a/tests/test_responsive.py +++ b/tests/test_responsive.py @@ -41,6 +41,11 @@ def _open_rules(page): 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) @@ -70,6 +75,19 @@ def test_mobile_rules_panel_does_not_squeeze_timeline(page, server): 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) From 30092ea744a8089d6d33a581ba46b18f5d1750b6 Mon Sep 17 00:00:00 2001 From: dynaum Date: Fri, 29 May 2026 15:56:43 -0400 Subject: [PATCH 3/4] feat: enlarge message box on mobile On a 375px phone the sender label + session/mic/send/schedule controls crowded the textarea down to ~100px wide. On mobile the input row now wraps and the textarea takes its own full-width line above the controls, with a 44px min-height and a 16px font (smaller fonts make iOS zoom on focus). All controls stay present, on the line below. Desktop layout is unchanged. Co-Authored-By: Claude Opus 4.8 --- static/style.css | 16 ++++++++++++++++ tests/test_responsive.py | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/static/style.css b/static/style.css index 8b859ea9..21facf83 100644 --- a/static/style.css +++ b/static/style.css @@ -4568,4 +4568,20 @@ footer { } /* #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; + } } diff --git a/tests/test_responsive.py b/tests/test_responsive.py index a2a36065..8db72a3b 100644 --- a/tests/test_responsive.py +++ b/tests/test_responsive.py @@ -101,6 +101,28 @@ def test_mobile_channel_sidebar_does_not_squeeze_timeline(page, server): 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" + + # -------------------------------------------------------------------------- # DESKTOP - characterization of behavior that must NOT change # -------------------------------------------------------------------------- From 7b11171824e9c5b2836767b4e586a1aab4785224 Mon Sep 17 00:00:00 2001 From: dynaum Date: Fri, 29 May 2026 17:33:11 -0400 Subject: [PATCH 4/4] feat: right-align mic + send buttons on mobile After the input wraps to its own line, the controls were left-packed and the send group ended ~57px short of the row's right edge. margin-left:auto on the mic button pushes the audio + send buttons to the right; the sender label and session button stay on the left. Desktop unchanged. Co-Authored-By: Claude Opus 4.8 --- static/style.css | 6 ++++++ tests/test_responsive.py | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/static/style.css b/static/style.css index 21facf83..b167f73c 100644 --- a/static/style.css +++ b/static/style.css @@ -4584,4 +4584,10 @@ footer { 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/test_responsive.py b/tests/test_responsive.py index 8db72a3b..2cd8eac7 100644 --- a/tests/test_responsive.py +++ b/tests/test_responsive.py @@ -123,6 +123,31 @@ def test_mobile_input_box_is_usable_size(page, server): 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 # --------------------------------------------------------------------------