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
34 changes: 34 additions & 0 deletions frontend/e2e/mobile-survey.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Mobile layout survey — screenshots every workspace panel at a phone
* viewport and fails on horizontal overflow.
*
* NOT part of CI: needs the local dev stack (vite:5173 + teamwork:8000) and a
* real project id. Run it whenever touching panel layout:
*
* PROJECT=<id> SHOTS=/tmp/shots npx playwright test e2e/mobile-survey.spec.ts
*
* This harness is how the 2026-08 mobile refactor found the desktop-layout
* crush (fixed w-72 panes, stacked companion chats, 35svh chat bands) that
* three earlier spot-fix passes missed. Screenshots are the ground truth —
* grep found none of those problems.
*/
import { test, expect } from '@playwright/test';

const PROJECT = process.env.PROJECT ?? 'a98cd46a-952a-429b-b807-0967a9a18785';
const SHOTS = process.env.SHOTS ?? 'test-results/mobile-survey';
const VIEWS = ['chat', 'tasks', 'files', 'library', 'terminal', 'browser',
'progress', 'observability', 'memory', 'scheduler', 'settings', 'desktop'];

test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 2 });

for (const view of VIEWS) {
test(`mobile ${view}`, async ({ page }) => {
await page.addInitScript(([p, v]) => localStorage.setItem(`tw:view:${p}`, v), [PROJECT, view]);
await page.goto(`http://localhost:5173/project/${PROJECT}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
await page.screenshot({ path: `${SHOTS}/${view}.png` });
const overflowX = await page.evaluate(
() => document.documentElement.scrollWidth > document.documentElement.clientWidth);
expect(overflowX, `${view} must not scroll horizontally`).toBe(false);
});
}
13 changes: 12 additions & 1 deletion frontend/src/components/panels/BrowserChatSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,20 @@ export function BrowserChatSidebar({ projectId, activeView, onTraceClick, conten
return (
<div
ref={containerRef}
className={`flex flex-col h-full md:border-r relative ${darkMode ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-gray-50'} browser-chat-sidebar`}
className={`flex flex-col relative ${darkMode ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-gray-50'} browser-chat-sidebar
fixed inset-x-0 bottom-0 z-40 h-[60svh] min-h-[12rem] border-t shadow-2xl
pb-[calc(3.5rem+env(safe-area-inset-bottom,0px))]
md:pb-0 md:static md:inset-auto md:z-auto md:h-full md:min-h-0 md:shadow-none md:border-t-0 md:border-r`}
style={{ '--bcs-width': `${width}px` } as React.CSSProperties}
>
{/* MOBILE: a bottom sheet OVER the tool surface, not a flex sibling
beside/below it. As a sibling it took ~half the viewport, so the
terminal showed three lines of shell above an empty half-screen
chat, and the browser screencast was crushed the same way — the
exact failure SpacePage already solved (see its sheet comments for
the svh / z-index / keyboard-padding reasoning; this mirrors it).
DESKTOP (md:) is byte-for-byte the old layout: static, full-height,
right border, resizable via --bcs-width. */}
{/* Header */}
<div className={`px-4 py-3 border-b ${darkMode ? 'border-slate-700' : 'border-gray-200'}`}>
<div className="flex items-center gap-2">
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/components/panels/BrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ export function BrowserPanel({ projectId, isVisible, onClose }: BrowserPanelProp
const [error, setError] = useState<string | null>(null);
const [browserAvailable, setBrowserAvailable] = useState<boolean | null>(null);
const [wsDebug, setWsDebug] = useState('');
const [showChat, setShowChat] = useState(true);
// Closed by default on a phone: the sheet covers 60% of the tool the
// moment it opens, so it must be a deliberate tap, not the landing state.
const [showChat, setShowChat] = useState(() => window.matchMedia('(min-width: 768px)').matches);
const screenSize = useRef({ width: 1280, height: 900 });

// Tab-cast state: A+V stream of the active sandbox-Chrome tab. The
Expand Down Expand Up @@ -603,7 +605,7 @@ export function BrowserPanel({ projectId, isVisible, onClose }: BrowserPanelProp
{showChat && projectId && (
// Mobile: 50% flex row (canvas gets the other half). Desktop:
// md:contents drops the wrapper so the sidebar keeps its width.
<div className="flex-1 min-h-0 md:contents">
<div className="contents">
<BrowserChatSidebar
projectId={projectId}
activeView="browser"
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/components/panels/DesktopPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ function isTeamWorkShortcut(e: KeyboardEvent): boolean {

export function DesktopPanel({ projectId, isVisible, onClose }: Props) {
const dark = useUIStore((s) => s.darkMode);
const [showChat, setShowChat] = useState(true);
// Closed by default on a phone: the sheet covers 60% of the tool the
// moment it opens, so it must be a deliberate tap, not the landing state.
const [showChat, setShowChat] = useState(() => window.matchMedia('(min-width: 768px)').matches);
const [toast, setToast] = useState<string | null>(null);
const [keyboardCaptured, setKeyboardCaptured] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
Expand Down Expand Up @@ -404,7 +406,7 @@ export function DesktopPanel({ projectId, isVisible, onClose }: Props) {
{showChat && projectId && (
// Mobile: 50% flex row (desktop gets the other half). Desktop:
// md:contents drops the wrapper so the sidebar keeps its width.
<div className="flex-1 min-h-0 md:contents">
<div className="contents">
<BrowserChatSidebar
projectId={projectId}
activeView="desktop"
Expand Down
21 changes: 17 additions & 4 deletions frontend/src/components/panels/FileBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
Save,
MessageSquare,
Download,
ChevronLeft,
} from 'lucide-react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
Expand Down Expand Up @@ -254,9 +255,14 @@ export function FileBrowser({ projectId, onOpenClaudePanel }: FileBrowserProps)

return (
<div className={`flex-1 flex min-h-0 ${panelBg}`}>
{/* Left: Tree view */}
{/* Left: Tree view.
MOBILE: drill-in navigation — this pane IS the screen until a file
is picked, then it yields entirely to the viewer (a fixed w-72
beside a flex-1 viewer left the viewer ~100px on a 390px phone).
Selection doubles as the navigation state, so no new state and
desktop is untouched. */}
<div
className={`w-72 flex-shrink-0 flex flex-col border-r ${borderColor} ${treeBg}`}
className={`${selectedFilePath ? 'hidden md:flex' : 'flex w-full'} md:w-72 flex-shrink-0 flex-col border-r ${borderColor} ${treeBg}`}
>
{/* Tree header */}
<div
Expand Down Expand Up @@ -359,14 +365,21 @@ export function FileBrowser({ projectId, onOpenClaudePanel }: FileBrowserProps)
</div>
</div>

{/* Right: File content viewer */}
<div className="flex-1 flex flex-col min-w-0">
{/* Right: File content viewer — on mobile, only once a file is picked. */}
<div className={`${selectedFilePath ? 'flex' : 'hidden md:flex'} flex-1 flex-col min-w-0`}>
{selectedFilePath ? (
<>
{/* File header */}
<div
className={`px-4 py-2 flex items-center gap-2 border-b ${borderColor} flex-shrink-0`}
>
<button
onClick={() => setSelectedFilePath(null)}
className={`md:hidden -ml-1 p-1.5 rounded min-w-[44px] min-h-[44px] flex items-center justify-center ${darkMode ? 'text-gray-300 hover:bg-slate-700' : 'text-gray-600 hover:bg-gray-100'}`}
aria-label="Back to file list"
>
<ChevronLeft className="w-5 h-5" />
</button>
<FileIcon fileName={selectedFilePath.split('/').pop() || ''} />
<span className={`text-sm font-medium truncate ${textPrimary}`}>
{selectedFilePath}
Expand Down
29 changes: 24 additions & 5 deletions frontend/src/components/panels/LibraryPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
Trash2, Save, Pencil, User, Sparkles, Lock, Unlock, ArrowRightCircle,
Settings, FileCode, Archive, Inbox, Network, Stethoscope, Play,
Link as LinkIcon, AlertTriangle, CheckCircle, Circle, Clock,
RefreshCw, MessageSquare, StickyNote, Home,
RefreshCw, MessageSquare, StickyNote, Home, ChevronLeft,
} from 'lucide-react';
import {
useLibrary,
Expand Down Expand Up @@ -424,8 +424,15 @@ export function LibraryPanel({ isVisible, onClose, onGoHome, focusProject, onFoc

return (
<div className={clsx('flex-1 flex min-w-0 min-h-0 h-full', bg)}>
{/* ── Sidebar ──────────────────────────────── */}
<div className={clsx('w-72 border-r overflow-y-auto shrink-0 min-h-0', border, sidebarBg)}>
{/* ── Sidebar ──────────────────────────────────────────────────
MOBILE: drill-in — the sidebar IS the screen until something is
opened (mainView leaves 'empty' or a note is being created), then
it yields entirely to the main pane. The old fixed w-72 beside a
flex-1 pane left the note a text-wrapped sliver on a 390px phone.
mainView doubles as the navigation state; desktop is untouched. */}
<div className={clsx(
mainView.kind === 'empty' && !creatingNoteIn ? 'flex flex-col w-full' : 'hidden md:block',
'md:w-72 border-r overflow-y-auto shrink-0 min-h-0', border, sidebarBg)}>
<div className={clsx('px-3 py-2.5 flex items-center justify-between border-b sticky top-0 z-10', border, sidebarBg)}>
<div className="flex items-center gap-2">
<FolderOpen className={clsx('w-4 h-4', dark ? 'text-indigo-400' : 'text-indigo-600')} />
Expand Down Expand Up @@ -800,8 +807,20 @@ export function LibraryPanel({ isVisible, onClose, onGoHome, focusProject, onFoc
})}
</div>

{/* ── Main pane ───────────────────────────── */}
<div className="flex-1 flex flex-col min-w-0 min-h-0 overflow-hidden">
{/* ── Main pane — on mobile, only once something is opened ── */}
<div className={clsx(
mainView.kind === 'empty' && !creatingNoteIn ? 'hidden md:flex' : 'flex',
'flex-1 flex-col min-w-0 min-h-0 overflow-hidden')}>
{/* Mobile back to the library nav — mirrors FileBrowser's drill-in. */}
{(mainView.kind !== 'empty' || creatingNoteIn) && (
<button
onClick={() => { setMainView({ kind: 'empty' }); setCreatingNoteIn(null); }}
className={clsx('md:hidden flex items-center gap-1.5 px-3 py-2.5 border-b text-sm font-medium',
border, dark ? 'text-gray-300 active:bg-slate-800' : 'text-gray-600 active:bg-gray-100')}
>
<ChevronLeft className="w-4 h-4" /> Library
</button>
)}
{creatingNoteIn && (
<div className={clsx('px-4 py-3 border-b space-y-2', border)}>
<div className={clsx('text-xs font-semibold', t1)}>
Expand Down
15 changes: 12 additions & 3 deletions frontend/src/components/panels/LibrarySpaceView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -446,8 +446,14 @@ export function LibrarySpaceView({ project, dark, onClose, embedded }: Props) {
)}
</div>

<div className="flex-1 overflow-x-auto overflow-y-hidden p-4">
<div className="flex gap-3 h-full min-w-max mx-auto justify-center">
{/* Desktop: columns are full-height and scroll internally, so the
board itself must not scroll vertically. Mobile: a column is
taller than the screen, and overflow-y-hidden made everything
below the fold unreachable — you could scroll sideways but not
down. Below md the board scrolls vertically and the columns
size to their content instead of fighting for a bounded height. */}
<div className="flex-1 overflow-x-auto overflow-y-auto md:overflow-y-hidden p-4">
<div className="flex gap-3 h-auto md:h-full min-w-max mx-auto justify-center items-start md:items-stretch">
{columns.map((col: LibraryTaskColumn) => {
const isDropTarget = dragOverColumn === col.id;
return (
Expand Down Expand Up @@ -513,7 +519,10 @@ export function LibrarySpaceView({ project, dark, onClose, embedded }: Props) {
)}
</div>

<div className="flex-1 overflow-y-auto p-2 space-y-2">
{/* On mobile the board scrolls, so a column must grow to
its content; an inner overflow-y-auto here would create
a second, nested scroll region that traps the gesture. */}
<div className="flex-1 md:overflow-y-auto p-2 space-y-2">
{(tasksByColumn[col.id] ?? []).map((task) => (
<TaskCard
key={task.id}
Expand Down
26 changes: 18 additions & 8 deletions frontend/src/components/panels/SpacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -637,15 +637,25 @@ export function SpacePage({ spaceSlug, onBack }: Props) {
)}
{showingSpaceChat && (
<div className={clsx(
'w-full md:w-[var(--chat-w)] flex flex-col shrink-0 min-h-0',
'border-t md:border-t-0 md:border-l',
'flex flex-col min-h-0',
// MOBILE: an overlay sheet, not a flex sibling. As a sibling it
// competed with the tab content for height, so on a tall board it
// was pushed below the fold and simply never appeared. Fixed to
// the bottom above everything, it always shows when toggled.
// z-40 clears the mobile tab bar (z-30), and the sheet sits ON
// TOP of it rather than behind: 'over content' is the whole point.
// pb-mobile-nav gives the bar its 3.5rem back, and the CSS rule
// zeroes that padding the moment the keyboard opens and the bar
// hides — otherwise a dead strip sits under the composer.
'fixed inset-x-0 bottom-0 z-40 h-[60svh] min-h-[12rem] border-t shadow-2xl',
'pb-mobile-nav pb-[calc(3.5rem+env(safe-area-inset-bottom,0px))] md:pb-0',
// DESKTOP: back to the resizable in-flow column it was.
'md:static md:inset-auto md:z-auto md:h-auto md:min-h-0 md:shadow-none',
'md:w-[var(--chat-w)] md:shrink-0 md:border-t-0 md:border-l',
// svh, not vh: on iOS `vh` is the LARGE viewport — the height as
// if the browser chrome were hidden — so a 40vh pane is taller than
// 40% of what you can actually see, and with shrink-0 it can be
// pushed past the bottom edge. `svh` is the small viewport, which
// is the one that is always really there. A floor keeps it usable
// rather than a sliver on a short screen.
'h-[45svh] min-h-[10rem] md:h-auto md:min-h-0',
// if the browser chrome were hidden — so a 60vh pane is taller than
// 60% of what you can actually see and runs past the bottom edge.
// `svh` is the small viewport, the one that is always really there.
)}
style={{
borderColor: 'var(--space-accent-border)',
Expand Down
10 changes: 7 additions & 3 deletions frontend/src/components/panels/TaskBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -542,8 +542,12 @@ export function TaskBoard({ projectId, agents, isCoachingProject, onWatchLive }:
</div>

{/* Columns */}
<div className="flex-1 overflow-x-auto overflow-y-hidden">
<div className="flex gap-4 p-4 h-full min-w-max">
{/* MOBILE: scroll-snap per column, sized to ~85vw so the next column
PEEKS at the edge — the affordance that there is more board. Without
it, cards clipped mid-word at the viewport edge with no hint that a
swipe would reveal them. Desktop keeps free horizontal scrolling. */}
<div className="flex-1 overflow-x-auto overflow-y-hidden snap-x snap-mandatory md:snap-none">
<div className="flex gap-3 md:gap-4 p-3 md:p-4 h-full min-w-max">
{COLUMNS.map((col) => {
const tasks = tasksByColumn[col.status];
const isCreatingHere = creatingInColumn === col.status;
Expand All @@ -552,7 +556,7 @@ export function TaskBoard({ projectId, agents, isCoachingProject, onWatchLive }:
<div
key={col.status}
className={clsx(
'flex flex-col w-72 rounded-lg border-t-2 border',
'flex flex-col w-[85vw] max-w-[20rem] snap-start md:w-72 md:max-w-none rounded-lg border-t-2 border',
col.accent,
columnBorder,
darkMode ? 'bg-slate-800/40' : 'bg-white/60',
Expand Down
15 changes: 9 additions & 6 deletions frontend/src/components/panels/TerminalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ export function TerminalPanel({ projectId, isVisible, onClose }: TerminalPanelPr
useEffect(() => { lockedRef.current = locked; }, [locked]);
const [error, setError] = useState<string | null>(null);
const [reconnectKey, setReconnectKey] = useState(0);
const [showChat, setShowChat] = useState(true);
// Closed by default on a phone: the sheet covers 60% of the tool the
// moment it opens, so it must be a deliberate tap, not the landing state.
const [showChat, setShowChat] = useState(() => window.matchMedia('(min-width: 768px)').matches);

// Re-fit xterm when the chat toggle changes the terminal's available width.
useEffect(() => {
Expand Down Expand Up @@ -245,11 +247,12 @@ export function TerminalPanel({ projectId, isVisible, onClose }: TerminalPanelPr
</div>

{showChat && projectId && (
// Mobile: this wrapper is a 50% flex row (terminal gets the other
// half). Desktop: md:contents removes the wrapper box so the
// sidebar is a direct flex child and keeps its resizable width
// (.browser-chat-sidebar in index.css).
<div className="flex-1 min-h-0 md:contents">
// display:contents at EVERY size. The old mobile branch made this a
// 50% flex row ("terminal gets the other half") — which is exactly
// the crush the sidebar's sheet mode now replaces. The sidebar is
// position:fixed on mobile, so it must not participate in this flex
// row at all; on desktop `contents` is what it already was.
<div className="contents">
<BrowserChatSidebar
projectId={projectId}
activeView="terminal"
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/pages/ProjectWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,13 @@ export function ProjectWorkspace() {
own header toggle (libraryHideChat) so the chat column can be
hidden without leaving the library view. */}
{((activeView === 'files') || (activeView === 'library' && !libraryHideChat)) && projectId && (
<div className="order-1 md:order-none flex-shrink-0 flex flex-col h-[35svh] md:h-auto border-t md:border-t-0 border-slate-700">
// MOBILE: hidden. This wrapper hard-coded a 35svh chat band stacked
// under the panel — the third of three zones squeezing the explorer.
// On a phone the companion chat is the panel-owned bottom SHEET
// (FileBrowser's own toggle opens it over the file view), and the main
// Chat tab is one tap away; a permanently-open second chat is exactly
// what made these views unusable. Desktop keeps the in-flow column.
<div className="hidden md:flex order-1 md:order-none flex-shrink-0 flex-col h-[35svh] md:h-auto border-t md:border-t-0 border-slate-700">
<div className="flex-1 min-h-0 flex flex-col pb-mobile-nav pb-[calc(3.5rem+env(safe-area-inset-bottom,0px))] md:pb-0">
{activeView === 'library' ? (
<BrowserChatSidebar projectId={projectId} activeView={activeView} onTraceClick={handleTraceClick} contentContext={contentContext} />
Expand Down
Loading