diff --git a/docs/guide/sessions.md b/docs/guide/sessions.md index c933af37..877fac78 100644 --- a/docs/guide/sessions.md +++ b/docs/guide/sessions.md @@ -37,6 +37,16 @@ updated_at DESC`), so the sidebar and the Sessions page never disagree. Hovering a session gives you pin and delete. Double-clicking its name renames it. +## Using a phone + +Tap the navigation icon in the header to open the sidebar, then choose a +session or **New** to pick a workspace. Selecting an item closes the drawer; +you can also close it with its close button or by tapping the dimmed backdrop. + +Use the send arrow beside the composer to submit a message. The keyboard's +Return key can still insert a new line. Long slash-command lists scroll inside +the picker, keeping the composer and navigation in view. + ## Model and effort The pills under the composer show the session's live model and effort level. diff --git a/tests/browser/mobile.spec.mts b/tests/browser/mobile.spec.mts new file mode 100644 index 00000000..52408806 --- /dev/null +++ b/tests/browser/mobile.spec.mts @@ -0,0 +1,27 @@ +import {test,expect} from '@playwright/test'; +test('phone can send, open workspace navigation, and scroll slash commands without page overflow',async({page})=>{ + await page.setViewportSize({width:375,height:812}); + const session={id:'mobile',title:'Mobile session',workspace:'/workspaces/demo',status:'idle',kind:'task',pinned:false};let submitted=''; + await page.route('**/api/**',async route=>{ + const p=new URL(route.request().url()).pathname; + const value=p.endsWith('/auth/status')?{authed:true}:p==='/api/sessions'?{sessions:[session],executor:'host'}:p==='/api/workspaces'?{root:'/workspaces',workspaces:[{name:'demo',path:'/workspaces/demo',isGit:false}]}:p.endsWith('/commands')?{commands:Array.from({length:10},(_,i)=>({name:`cmd${i}`,description:'A command',source:'extension'}))}:p.endsWith('/config')?{live:false,state:{model:{id:'test',name:'Test',provider:'local'},thinkingLevel:'medium'},stats:null,thinking:{levels:[]},models:{models:[]}}:p==='/api/browser'?{running:false,sessions:[],routines:[]}:p.endsWith('/canvases')?[]:p==='/api/voice'?{enabled:false}:p.endsWith('/prompt')?(submitted=route.request().postDataJSON().message,{ok:true}):session; + await route.fulfill({json:value}); + }); + await page.addInitScript(()=>{(window as any).EventSource=class {onmessage:any;onopen:any;onerror:any;addEventListener(){}close(){}};localStorage.setItem('sidebarCollapsed','true');}); + await page.goto('/s/mobile'); + await page.getByLabel('Message',{exact:true}).fill('Hello from a phone'); + await page.getByRole('button',{name:'Send message',exact:true}).click(); + await expect.poll(()=>submitted).toBe('Hello from a phone'); + await expect(page.getByLabel('Sidebar',{exact:true})).toBeHidden(); + await page.getByLabel('Open navigation',{exact:true}).click(); + await expect(page.getByLabel('Sidebar',{exact:true})).toBeVisible(); + await expect(page.getByText('demo',{exact:true}).first()).toBeVisible(); + await page.getByLabel('Close navigation',{exact:true}).click(); + const dimensions=await page.evaluate(()=>({w:document.body.scrollWidth,h:document.body.scrollHeight,vw:innerWidth,vh:innerHeight,font:getComputedStyle(document.querySelector('.prompt-input')!).fontSize})); + expect(dimensions.w).toBeLessThanOrEqual(dimensions.vw);expect(dimensions.h).toBeLessThanOrEqual(dimensions.vh);expect(dimensions.font).toBe('16px'); + await page.setViewportSize({width:375,height:500});await page.getByLabel('Message',{exact:true}).fill('/cmd'); + const menu=page.locator('.prompt-shell > .absolute');await expect(menu).toBeVisible(); + expect(await menu.evaluate(e=>e.scrollHeight>e.clientHeight)).toBe(true); + await menu.evaluate(e=>e.scrollTop=e.scrollHeight);expect(await menu.evaluate(e=>e.scrollTop)).toBeGreaterThan(0); + await page.screenshot({path:'/tmp/pithagoras-mobile-issue3.png'}); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 15bce4ff..e2b694f9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,3 +1,4 @@ +import { LuMenu, LuX } from "react-icons/lu"; import { appendLiveEvent, resetLiveEvents } from "./live-events"; import { useCallback, useEffect, useRef, useState } from "react"; import { Navigate, Route, Routes, useNavigate, useParams } from "react-router-dom"; @@ -73,6 +74,13 @@ function Shell({ }) { const { sessionId, tab } = useParams<{ sessionId?: string; tab?: string }>(); const navigate = useNavigate(); + const [mobileNav, setMobileNav] = useState(false); + useEffect(() => { setMobileNav(false); }, [sessionId, view, settings]); + useEffect(() => { + const escape = (e: KeyboardEvent) => { if (e.key === "Escape") setMobileNav(false); }; + document.addEventListener("keydown", escape); + return () => document.removeEventListener("keydown", escape); + }, []); const [sessions, setSessions] = useState([]); // The task list deliberately excludes agent and routine sessions, but their @@ -222,19 +230,24 @@ function Shell({ const active = listed ?? (other?.id === sessionId ? other : null); return ( -
+
+ {mobileNav && } navigate(`/${to}`)} - onSelect={(id) => navigate(`/s/${id}`)} + onNavigate={(to) => { setMobileNav(false); navigate(`/${to}`); }} + onSelect={(id) => { setMobileNav(false); navigate(`/s/${id}`); }} onCreate={async (workspacePath) => { const s = await api.createSession(workspacePath); await refreshSessions(); + setMobileNav(false); navigate(`/s/${s.id}`); }} onDelete={async (id) => { @@ -261,7 +274,12 @@ function Shell({ }} /> -
+
+
+
+ + {active?.title || "Pithagoras"} +
{error &&
{error}
} {view === "sessions" ? (
{matches.length > 0 && ( -
+
{matches.map((c) => (