Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -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
118 changes: 118 additions & 0 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
82 changes: 82 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
183 changes: 183 additions & 0 deletions tests/test_responsive.py
Original file line number Diff line number Diff line change
@@ -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"]