Skip to content
Merged
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
116 changes: 116 additions & 0 deletions docs/mobile-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Mobile audit — TeamWork

**Written 2026-07-28** after a run of mobile bug reports that were all symptoms
of the same thing: TeamWork was designed at desktop width and made to *fit* a
phone, rather than designed for one.

The bugs are worth fixing and mostly are. This document is about the layer under
them — what a phone is actually good for, and what the app currently offers
instead.

> **Verification status: none of this is tested on a real device.** There is no
> touch hardware on the build machine. Every layout fix shipped so far is
> correct-by-construction and several were wrong in ways only a phone revealed —
> including one where suppressing a scroll gesture to fix chat broke Kanban
> scrolling entirely. Treat the recommendations as reasoned, not validated.

---

## 1. The navigation spends its scarcest resource badly

Five bottom-bar slots is all a phone gets. Today:

| Slot | Surface | Honest assessment on a phone |
|---|---|---|
| 1 | Spaces | Right. The overview you land on. |
| 2 | Chat | Right. The primary thing you do. |
| 3 | **Browser** | **Wrong.** A CDP screencast of a 1920×1080 desktop Chrome, scaled onto 390px. You can see that something is happening; you cannot read it or use it. |
| 4 | **Scheduler** | **Wrong.** Low-frequency admin — you set a schedule once and rarely look again. Also duplicated inside More. |
| 5 | More | Right, as a spillover. |

Meanwhile **Tasks (the Kanban board)** and **Library (notes)** are buried in
More — and those are the two most thumb-native surfaces in the product. Checking
a board and moving a card is exactly what phones are for. Reading a note is
exactly what phones are for.

**Recommendation — reorder to: Chat · Spaces · Tasks · Library · More.**
Browser, Terminal, Desktop, Traces, Observability, Progress, Scheduler and
Settings all live in More, where a surface you visit occasionally belongs.

## 2. Three panels are desktop-only in substance, not just in layout

Terminal, Desktop (noVNC) and Browser are "watch the agent work" surfaces built
around a large viewport. On a phone they are, at best, read-only reassurance.

That is not automatically wrong — glancing at what the agent is doing has real
value, and the terminal is genuinely usable in short bursts. But they should be
**framed** as glance surfaces rather than presented as equals to Chat.

**The browser is fixable, and worth fixing.** The sandbox drives Chrome over
CDP, so `Emulation.setDeviceMetricsOverride` can render the page at phone
dimensions instead of scaling a desktop screencast down. The agent would then
browse *as a phone*, which is both more readable on mobile AND more
representative of what most of the web now serves. That is a prax-sandbox +
BrowserPanel change, not a CSS one.

**The desktop (noVNC) is not fixable** in the same way — a Linux desktop is a
Linux desktop. Keep it in More and expect it to be used rarely from a phone.

## 3. The recurring layout traps (now guarded)

Every mobile bug so far has been one of a small number of patterns. Two are now
enforced by tests that fail the build:

| Pattern | Symptom | Status |
|---|---|---|
| `flex-1 flex-col overflow-hidden` without `min-h-0` | content clipped, unscrollable | **guarded** — 7 panels fixed |
| inline pixel width | column wider than the screen, dead band beside it | **guarded** |
| fixed-width sidebar in a row | content squeezed to a sliver | fixed in 3 panels, ungarded |
| popover with a fixed width | pushes the page wide | fixed in 5, ungarded |
| `vh` where `svh` is meant | pane pushed past the bottom edge on iOS | fixed in 1, **ungarded** |
| `window.resize` for keyboard changes | pane keeps a height that no longer fits | fixed in 2, **ungarded** |

The unguarded rows are the next thing to encode. A guard is worth more than a
fix, because the fix is one panel and the guard is every future panel.

## 4. The keyboard is a first-class layout state, and was treated as an edge case

The mobile tab bar is `fixed bottom-0`, so it anchors to the bottom of the
visible area and sits over the composer while you type. Two attempts:

1. **Viewport arithmetic** — `window.innerHeight - visualViewport.height > 150`.
Failed on iOS, where the layout viewport can shrink too, leaving the
difference near zero and the rule silently never applying.
2. **Focus** — a `focusin`/`focusout` listener on text fields. Deterministic,
no threshold to tune, works everywhere.

The lesson generalises: **ask the question directly rather than inferring it
from a measurement.** The same mistake produced the terminal bug — panels
listened to `window.resize`, which never fires for a keyboard, instead of
`visualViewport`.

## 5. What a phone-first version would look like

Not a proposal to build today, but the shape worth aiming at:

- **Chat is the app.** Everything else is somewhere you go and come back from.
- **The board is one tap away**, because "what is the agent doing / what is
left" is the most common mobile question.
- **Agent-work surfaces are glances** — a screenshot-and-status view of the
browser/desktop rather than a live interactive canvas, with the live version
reserved for desktop.
- **Long text is read, not authored.** Note *reading* is mobile-native; note
editing mostly is not, and the editor should degrade gracefully rather than
pretend.
- **Nothing is `fixed` except the tab bar**, and the tab bar knows about the
keyboard.

## Priorities

1. **Reorder the tab bar** — cheap, high impact, no risk. *(Doing now.)*
2. **Guard the remaining layout patterns** — `svh`, `visualViewport` listeners.
3. **Mobile-emulated browser** via CDP device metrics — turns a dead tab into a
useful one.
4. **Get a real device into the loop.** Everything above is reasoning. The three
bugs that reached the user today were all found by looking at a phone, and
none would have been caught by any test written here.
140 changes: 140 additions & 0 deletions frontend/e2e/mobile.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Mobile layout checks at a real phone viewport.
*
* Every mobile bug reported so far was found by a human looking at a phone, and
* none would have been caught by a jsdom test — jsdom has no layout engine, so
* it cannot tell you something is off-screen, clipped, or wider than the window.
* Playwright can: it lays out for real, at a real viewport, with touch enabled.
*
* These assertions are the bugs that actually happened, written down:
*
* - a desktop column width leaked to mobile and left a dead band beside the
* content (inline pixel width outranking `w-full`);
* - the trace summary was crushed to a one-line sliver by a sibling that
* refused to shrink, with no way to scroll to it;
* - long unbreakable URLs made the message list wider than the screen, so
* vertical scrolls drifted sideways.
*
* Run against a live instance:
* BASE_URL=https://teamwork.your-tailnet.ts.net npx playwright test e2e/mobile.spec.ts
*
* NOT covered here, and worth being explicit about: the on-screen KEYBOARD.
* Playwright cannot summon one, so `visualViewport` never shrinks and the
* composer-hidden-by-the-tab-bar class of bug stays device-only. Focus events
* fire, so the class toggle is checkable; the geometry it exists to fix is not.
*/
import { devices, expect, test, type Page } from '@playwright/test';

const BASE = process.env.BASE_URL || 'http://localhost:3000';

test.use({ ...devices['iPhone 13'], baseURL: BASE });

/** Widest element that overflows the viewport, if any — for a useful failure. */
async function horizontalOverflow(page: Page) {
return page.evaluate(() => {
const vw = document.documentElement.clientWidth;
const guilty: { tag: string; cls: string; width: number }[] = [];
document.querySelectorAll('*').forEach((el) => {
const r = el.getBoundingClientRect();
if (r.width > vw + 1 && r.width > 0) {
guilty.push({
tag: el.tagName.toLowerCase(),
cls: (el.className || '').toString().slice(0, 80),
width: Math.round(r.width),
});
}
});
return { viewport: vw, scrollWidth: document.documentElement.scrollWidth, guilty: guilty.slice(0, 5) };
});
}

/** Open the first project's workspace.
*
* The card is a clickable div, not a link — my first version looked for an
* `href` and silently stayed on the projects page, so four "passing" checks
* were measuring a page with almost nothing on it. The screenshot said so
* immediately, which is the argument for taking them.
*/
async function openWorkspace(page: Page) {
await page.goto('/');
await page.waitForLoadState('networkidle');
await expect(page.locator('text=My Projects').first()).toBeVisible({ timeout: 15_000 });
const card = page.locator('div[class*="cursor-pointer"]').first();
await expect(card).toBeVisible({ timeout: 5_000 });
await card.click();
await page.waitForLoadState('networkidle');
// The tab bar only exists inside a workspace; wait for it rather than a
// fixed timeout, so a slow load fails as a timeout and not as a wrong answer.
await expect(page.locator('.mobile-tab-bar')).toBeVisible({ timeout: 15_000 });
}

test.describe('mobile layout', () => {
test('nothing is wider than the screen', async ({ page }) => {
await openWorkspace(page);
const result = await horizontalOverflow(page);
expect(
result.guilty,
`elements wider than the ${result.viewport}px viewport — a desktop width `
+ 'has leaked to mobile, or unbreakable text is not wrapping',
).toEqual([]);
});

test('the page itself does not scroll sideways', async ({ page }) => {
await openWorkspace(page);
const { scrollWidth, viewport } = await horizontalOverflow(page);
expect(scrollWidth, 'the document scrolls horizontally').toBeLessThanOrEqual(viewport + 1);
});

test('the bottom tab bar is present and reachable', async ({ page }) => {
await openWorkspace(page);
const bar = page.locator('.mobile-tab-bar');
await expect(bar).toBeVisible();

// Touch targets: anything under ~40px is a miss waiting to happen.
const buttons = bar.locator('button');
const n = await buttons.count();
expect(n, 'the tab bar should have five slots').toBeGreaterThanOrEqual(4);
for (let i = 0; i < n; i++) {
const box = await buttons.nth(i).boundingBox();
expect(box!.height, `tab ${i} is only ${box!.height}px tall`).toBeGreaterThanOrEqual(40);
}
});

test('a long unbreakable URL does not widen the layout', async ({ page }) => {
await openWorkspace(page);
// Inject the exact shape that caused the sideways drift.
await page.evaluate(() => {
const host = document.querySelector('.message-text') || document.body;
const p = document.createElement('p');
p.className = 'message-text';
p.textContent = 'https://x.com/someone/status/2081762065392541951?s=46&extra=' + 'a'.repeat(120);
host.appendChild(p);
});
const result = await horizontalOverflow(page);
expect(result.guilty, 'a long URL made something wider than the screen').toEqual([]);
});

test('every scrollable pane can actually scroll to its end', async ({ page }) => {
await openWorkspace(page);
const clipped = await page.evaluate(() => {
const bad: { cls: string; scrollH: number; clientH: number }[] = [];
document.querySelectorAll('.overflow-hidden').forEach((el) => {
const e = el as HTMLElement;
// Content taller than the box, with no scroller of its own and none
// among its children, is content nobody can reach.
if (e.scrollHeight > e.clientHeight + 4) {
const hasScroller = e.querySelector('.overflow-y-auto, .overflow-auto');
if (!hasScroller) {
bad.push({
cls: e.className.toString().slice(0, 90),
scrollH: e.scrollHeight,
clientH: e.clientHeight,
});
}
}
});
return bad.slice(0, 5);
});
expect(clipped, 'content is clipped with no way to scroll to it').toEqual([]);
});
});
50 changes: 40 additions & 10 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,11 @@ function App() {
root.setProperty('--app-height', `${vv.height}px`);
root.setProperty('--app-top', `${vv.offsetTop}px`);

// Is the on-screen keyboard up? The visual viewport shrinks while the
// layout viewport does not, so the gap between them is the keyboard.
// 150px is comfortably more than the browser chrome that also comes and
// goes on scroll, and comfortably less than any real keyboard.
//
// This matters because the mobile tab bar is `fixed bottom-0`, which
// pins it to the LAYOUT bottom — so when the keyboard opens it sits
// squarely over the composer and you cannot see what you are typing.
const keyboardOpen = window.innerHeight - vv.height > 150;
document.documentElement.classList.toggle('keyboard-open', keyboardOpen);
// NOTE: keyboard detection is deliberately NOT done here. The obvious
// heuristic — `window.innerHeight - vv.height > 150` — was tried and does
// not work on iOS, where the layout viewport can shrink along with the
// visual one, leaving a difference near zero and the check silently
// false. See the focus listener below, which asks the question directly.
};
update();
vv.addEventListener('resize', update);
Expand All @@ -72,6 +67,41 @@ function App() {
};
}, []);

// Is a text field focused? That is the question worth asking — not "how tall
// is the viewport", which was the first attempt and failed on the platform it
// was written for.
//
// The mobile tab bar is `fixed bottom-0`, so it anchors to the bottom of the
// visible area and sits over the message composer while you type. Focus is
// deterministic: the field either has it or it does not, on every browser,
// with no threshold to tune and nothing to be wrong about.
useEffect(() => {
const isTextField = (el: EventTarget | null) => {
const node = el as HTMLElement | null;
if (!node || !node.tagName) return false;
const tag = node.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea' || node.isContentEditable;
};
const onFocus = (e: FocusEvent) => {
if (isTextField(e.target)) {
document.documentElement.classList.add('keyboard-open');
}
};
const onBlur = (e: FocusEvent) => {
// relatedTarget is where focus is GOING. Moving between two fields should
// not flash the bar back for one frame.
if (isTextField(e.target) && !isTextField(e.relatedTarget)) {
document.documentElement.classList.remove('keyboard-open');
}
};
document.addEventListener('focusin', onFocus);
document.addEventListener('focusout', onBlur);
return () => {
document.removeEventListener('focusin', onFocus);
document.removeEventListener('focusout', onBlur);
};
}, []);

return (
<BrowserRouter>
{/* Last line of defence. Panels have their own boundaries; this one keeps
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/__tests__/mobile-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,40 @@ describe('mobile layout', () => {
).toEqual([]);
});
});

it('uses svh, not vh, for mobile pane heights', () => {
// On iOS `vh` is the LARGE viewport — as if browser chrome were hidden —
// so an h-[40vh] pane is taller than 40% of what is actually visible and
// can be pushed past the bottom edge. `svh` is the small viewport, the one
// that is always really there. Desktop-only values (md:h-…) are exempt:
// desktop browsers do not have the dual-viewport problem.
const offenders: string[] = [];
for (const file of tsxFiles(SRC)) {
readFileSync(file, 'utf8').split('\n').forEach((line, i) => {
const mobileVh = /(?<!md:)h-\[\d+vh\]/.test(line) || /max-h-\[\d+vh\](?!.*md:)/.test(line);
if (mobileVh && !line.includes('svh') && !line.includes('dvh')) {
offenders.push(`${file.replace(SRC, '')}:${i + 1}`);
}
});
}
expect(offenders, 'vh on a mobile pane overflows the visible area on iOS — '
+ 'use svh (always-visible viewport) or dvh').toEqual([]);
});

it('pairs every window resize listener with a visualViewport one', () => {
// window.resize never fires when a mobile keyboard opens — only the visual
// viewport shrinks. A pane that refits on window.resize alone keeps a
// height that no longer fits and its lower half sits behind the keyboard.
// This is how the terminal lost its bottom half.
const offenders: string[] = [];
for (const file of tsxFiles(SRC)) {
const text = readFileSync(file, 'utf8');
const listens = text.includes("addEventListener('resize'")
&& text.includes('window.addEventListener');
if (listens && !text.includes('visualViewport')) {
offenders.push(file.replace(SRC, ''));
}
}
expect(offenders, 'a resize listener without a visualViewport listener '
+ 'will not see the keyboard').toEqual([]);
});
8 changes: 8 additions & 0 deletions frontend/src/components/panels/ClaudePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,17 @@ function EmbeddedTerminal({
}
};
window.addEventListener('resize', handleResize);
// Same as TerminalPanel: a mobile keyboard shrinks the VISUAL viewport
// only, so `window.resize` never fires and the pane keeps a height that no
// longer fits.
const vv = window.visualViewport;
vv?.addEventListener('resize', handleResize);
vv?.addEventListener('scroll', handleResize);

return () => {
window.removeEventListener('resize', handleResize);
vv?.removeEventListener('resize', handleResize);
vv?.removeEventListener('scroll', handleResize);
ws.close();
term.dispose();
};
Expand Down
Loading
Loading