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
10 changes: 10 additions & 0 deletions docs/guide/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions tests/browser/mobile.spec.mts
Original file line number Diff line number Diff line change
@@ -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'});
});
29 changes: 24 additions & 5 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<Session[]>([]);
// The task list deliberately excludes agent and routine sessions, but their
Expand Down Expand Up @@ -222,19 +230,24 @@ function Shell({
const active = listed ?? (other?.id === sessionId ? other : null);

return (
<div className="flex h-screen bg-canvas">
<div className="flex h-[100dvh] min-h-0 overflow-hidden bg-canvas">
{mobileNav && <button aria-label="Dismiss navigation" onClick={() => setMobileNav(false)} className="fixed inset-0 z-40 bg-black/50 md:hidden" />}
<div id="mobile-navigation" className={`${mobileNav ? "fixed inset-y-0 left-0 z-50 flex" : "hidden"} h-full shrink-0 md:static md:z-auto md:flex`}>
{mobileNav && <button type="button" aria-label="Close navigation" onClick={() => setMobileNav(false)} className="absolute right-2 top-3 z-20 rounded-lg p-2 text-fg md:hidden"><LuX size={20}/></button>}
<Sidebar
forceExpanded={mobileNav}
sessions={sessions}
workspaces={workspaces}
executor={executor}
activeId={sessionId ?? null}
view={view}
hasBrowser={hasBrowser}
onNavigate={(to) => 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) => {
Expand All @@ -261,7 +274,12 @@ function Shell({
}}
/>

<main className="flex min-w-0 flex-1 flex-col">
</div>
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<header className="flex shrink-0 items-center gap-3 border-b border-line px-3 py-2 md:hidden">
<button type="button" aria-label="Open navigation" aria-expanded={mobileNav} aria-controls="mobile-navigation" onClick={() => setMobileNav(true)} className="rounded-lg p-2 text-fg hover:bg-fg/10"><LuMenu size={20}/></button>
<span className="truncate text-sm text-fg">{active?.title || "Pithagoras"}</span>
</header>
{error && <div className="bg-danger/10 px-4 py-2 text-sm text-danger">{error}</div>}
{view === "sessions" ? (
<SessionsPage
Expand Down Expand Up @@ -318,7 +336,8 @@ function Shell({
} else if (name === "new") {
const s = await api.createSession(active.workspace);
await refreshSessions();
navigate(`/s/${s.id}`);
setMobileNav(false);
navigate(`/s/${s.id}`);
} else if (name === "name" && args.trim()) {
await api.renameSession(active.id, args.trim());
refreshSessions();
Expand Down
4 changes: 2 additions & 2 deletions web/src/components/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -444,12 +444,12 @@ export function Chat({
>
<div className="prompt-shell relative mx-auto w-full max-w-3xl">
{matches.length > 0 && (
<div className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-xl border border-line bg-surface shadow-pop">
<div className="absolute bottom-full left-0 right-0 mb-2 max-h-[min(16rem,35dvh)] overflow-y-auto overscroll-contain rounded-xl border border-line bg-surface shadow-pop">
{matches.map((c) => (
<button
key={c.name}
type="button"
onMouseDown={(e) => {
onClick={(e) => {
e.preventDefault();
setInput(`/${c.name} `);
}}
Expand Down
7 changes: 5 additions & 2 deletions web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ function slugify(input: string): string {
const NEW = "__new__";

export function Sidebar({
forceExpanded = false,
sessions,
workspaces,
executor,
Expand All @@ -64,6 +65,7 @@ export function Sidebar({
onOpenSettings,
onNavigate,
}: {
forceExpanded?: boolean;
sessions: Session[];
workspaces: Workspace[];
executor: string;
Expand All @@ -81,7 +83,8 @@ export function Sidebar({
onOpenSettings: () => void;
onNavigate: (to: "sessions" | "agent" | "routines" | "browser" | "audit") => void;
}) {
const [collapsed, setCollapsed] = useState(() => localStorage.getItem("sidebarCollapsed") === "true");
const [storedCollapsed, setCollapsed] = useState(() => localStorage.getItem("sidebarCollapsed") === "true");
const collapsed = forceExpanded ? false : storedCollapsed;
const toggleSidebar = () => {
setCollapsed(value => {
localStorage.setItem("sidebarCollapsed", String(!value));
Expand Down Expand Up @@ -137,7 +140,7 @@ export function Sidebar({
<aside aria-label="Sidebar" className={`relative flex shrink-0 flex-col overflow-hidden border-r border-line bg-surface transition-[width] duration-300 ease-in-out motion-reduce:transition-none ${collapsed ? "w-12" : "w-64"}`}>
<button type="button" onClick={toggleSidebar} aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
title={collapsed ? "Expand sidebar" : "Collapse sidebar"} aria-expanded={!collapsed} aria-controls="sidebar-content"
className="absolute right-2 top-3 z-10 grid h-8 w-8 place-items-center rounded-lg text-fg-subtle transition-colors hover:bg-canvas hover:text-fg focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
className="hidden md:grid absolute right-2 top-3 z-10 grid h-8 w-8 place-items-center rounded-lg text-fg-subtle transition-colors hover:bg-canvas hover:text-fg focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
{collapsed ? <LuPanelLeftOpen size={18} /> : <LuPanelLeftClose size={18} />}
</button>
<div id="sidebar-content" className={`min-h-0 w-64 flex-1 flex-col ${collapsed ? "hidden" : "flex"}`}>
Expand Down
2 changes: 2 additions & 0 deletions web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -402,3 +402,5 @@
.voice-profile p {margin:10px 0;color:rgb(var(--fg-muted));}
.voice-profile table {width:100%;margin:12px 0;}.voice-profile td{padding:5px 3px;border-bottom:1px solid rgb(var(--line));}.voice-profile td:not(:first-child){text-align:right;white-space:nowrap;}
.voice-profile pre {white-space:pre-wrap;font-size:10px;max-height:160px;overflow:auto;}.voice-profile button{padding:6px 10px;border:1px solid rgb(var(--line));border-radius:8px;}

@media (max-width: 767px) { .prompt-input { font-size: 16px; } }