diff --git a/.env.example b/.env.example index b7a145e..63e3f46 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,17 @@ MCP_ENABLED=false # created on the first grant. Stores only token hashes. #TEAMWORK_AGENT_CLIENTS_PATH=~/.teamwork/agent-clients.json +# ─── Desktop (noVNC from the sandbox) ─────────────────────────────────────── +# Where TeamWork proxies the desktop panel from. Empty disables the panel. +# • TeamWork on the host, sandbox in Docker (the usual shape): +# DESKTOP_VNC_URL=http://127.0.0.1:6080 +# • TeamWork inside the sandbox's compose network: +# DESKTOP_VNC_URL=http://sandbox:6080 +# Unset, the desktop asset proxy 503s and the clipboard socket is refused — so +# set it whenever the sandbox is running, or the panel fails in a way that looks +# like an auth problem rather than a missing setting. +DESKTOP_VNC_URL= + # ─── Sandbox connection (terminal / browser / desktop) ────────────────────── # These tell TeamWork how to reach the agent's sandbox container for the # terminal (docker exec), browser (Chrome DevTools Protocol screencast), and diff --git a/.gitignore b/.gitignore index 657c4f6..149df27 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,14 @@ # Environment variables .env +# Any .env derivative — backups especially. A dated copy made during an incident +# ("cp .env .env.bak-$(date +%s)") is a full set of live credentials sitting in +# the working tree, and it only has to be caught by one `git add -A` once. Prax +# already ignores `.env.bak*`; this repo did not, so the same habit was safe in +# one repo and a leak in the other. +.env.bak* +.env.backup* +.env.save +.env.orig .env.local .env.*.local .env.development diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a32c2d3..19f9942 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -51,6 +51,17 @@ function App() { const root = document.documentElement.style; 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); }; update(); vv.addEventListener('resize', update); diff --git a/frontend/src/__tests__/mobile-layout.test.ts b/frontend/src/__tests__/mobile-layout.test.ts new file mode 100644 index 0000000..4670bf5 --- /dev/null +++ b/frontend/src/__tests__/mobile-layout.test.ts @@ -0,0 +1,71 @@ +/** + * Structural guards for the mobile layout traps in this codebase. + * + * Three bugs shipped from the same two mistakes, and each was reported as + * "mobile is broken" rather than as anything a unit test would have caught: + * + * - an inline pixel width applies at EVERY breakpoint and outranks Tailwind, + * so a desktop resizer's value became the phone's column width — content + * wider than the screen, with a dead band beside it; + * - a flex child defaults to `min-height: auto`, so `flex-col overflow-hidden` + * without `min-h-0` refuses to shrink and CLIPS its content instead of + * letting the inner scroller work. + * + * These assert against the source because both are properties of the markup, and + * jsdom has no layout engine — it cannot tell you something is off-screen. A + * grep-shaped test is a weak test in general, but here it encodes exactly the + * mistake a future edit would repeat. + */ +import { describe, expect, it } from 'vitest'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SRC = join(dirname(fileURLToPath(import.meta.url)), '..'); + +function tsxFiles(dir: string): string[] { + return readdirSync(dir).flatMap((entry) => { + const full = join(dir, entry); + if (statSync(full).isDirectory()) return tsxFiles(full); + return full.endsWith('.tsx') && !full.includes('.test.') ? [full] : []; + }); +} + +describe('mobile layout', () => { + it('never sets a pixel width inline (it would outrank w-full on phones)', () => { + const offenders: string[] = []; + for (const file of tsxFiles(SRC)) { + readFileSync(file, 'utf8').split('\n').forEach((line, i) => { + // Percentages are fine — progress bars scale with their parent. + // Pixel values and bare numbers are the danger. + if (/style=\{\{\s*\[?['"]?(width|minWidth)/.test(line) && !line.includes('%')) { + offenders.push(`${file.replace(SRC, '')}:${i + 1}`); + } + }); + } + expect( + offenders, + 'pass the value as a CSS variable and consume it with a md: class, so ' + + 'mobile keeps w-full', + ).toEqual([]); + }); + + it('gives every clipping flex column a min-h-0 so it can scroll', () => { + const offenders: string[] = []; + for (const file of tsxFiles(SRC)) { + readFileSync(file, 'utf8').split('\n').forEach((line, i) => { + const isFlexCol = /flex-col/.test(line); + const clips = /overflow-hidden/.test(line); + const grows = /flex-1/.test(line); + if (isFlexCol && clips && grows && !/min-h-0/.test(line)) { + offenders.push(`${file.replace(SRC, '')}:${i + 1}`); + } + }); + } + expect( + offenders, + 'a flex child will not shrink below its content without min-h-0, so ' + + 'overflow-hidden clips instead of scrolling', + ).toEqual([]); + }); +}); diff --git a/frontend/src/components/chat/ClaudeCodeStatus.tsx b/frontend/src/components/chat/ClaudeCodeStatus.tsx index 625ea89..92af02d 100644 --- a/frontend/src/components/chat/ClaudeCodeStatus.tsx +++ b/frontend/src/components/chat/ClaudeCodeStatus.tsx @@ -55,7 +55,7 @@ export function ClaudeCodeStatus({ darkMode }: ClaudeCodeStatusProps) { {/* Expanded dropdown */} {expanded && ( -