From a907586f41e74ecb3d62f5b106e64a5c5f5726f8 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 15 Aug 2026 01:31:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20add=20mobile=20task=20flow=20shell?= =?UTF-8?q?=20=E2=80=94=20#206?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/web/WebAgentCell.tsx | 17 +- frontend/src/features/web/WebMenus.test.tsx | 2 + frontend/src/features/web/WebMobile.test.tsx | 86 ++--- frontend/src/features/web/WebWorkspace.tsx | 321 ++++++++++++++++++- 4 files changed, 384 insertions(+), 42 deletions(-) diff --git a/frontend/src/features/web/WebAgentCell.tsx b/frontend/src/features/web/WebAgentCell.tsx index c1e2de6e..9d01fb41 100644 --- a/frontend/src/features/web/WebAgentCell.tsx +++ b/frontend/src/features/web/WebAgentCell.tsx @@ -46,9 +46,18 @@ interface WebAgentCellProps { * `key`, so the terminal/PTY is never recreated by it. */ refitSignal?: number; + /** Mobile task-flow session takes the whole viewport instead of a fixed cell. */ + fullHeight?: boolean; } -export function WebAgentCell({ projectId, agentId, cwd, nodeId, refitSignal }: WebAgentCellProps) { +export function WebAgentCell({ + projectId, + agentId, + cwd, + nodeId, + refitSignal, + fullHeight = false, +}: WebAgentCellProps) { const { agent } = useGateways(); const [sessionId, setSessionId] = useState(null); // Input API of the mounted xterm (#69), used by the mobile key bar. Stays @@ -81,7 +90,11 @@ export function WebAgentCell({ projectId, agentId, cwd, nodeId, refitSignal }: W // lets it shrink inside the flex column instead of pushing the key bar off.
({ beforeEach(() => { window.localStorage.clear(); + window.innerWidth = 1024; + window.dispatchEvent(new Event("resize")); vi.restoreAllMocks(); }); diff --git a/frontend/src/features/web/WebMobile.test.tsx b/frontend/src/features/web/WebMobile.test.tsx index 266611fb..94d3f877 100644 --- a/frontend/src/features/web/WebMobile.test.tsx +++ b/frontend/src/features/web/WebMobile.test.tsx @@ -1,15 +1,9 @@ /** - * #69 lot 4 — the web client on a phone viewport. + * #206 — the web client on a phone viewport. * - * Two guards: - * - * 1. **No desktop-only shell.** The mobile brief assumed the web client had to - * trade docks / floating windows / a layout grid for a vertical stack. It - * never had them: #13 shipped it as a single-column read-only surface. That - * is easy to regress by reaching for a desktop component, so these tests pin - * the boundary — the web client mounts no `layout-*` grid and no dock. - * 2. **The agent cell is phone-drivable** — it carries the key toolbar, since a - * virtual keyboard alone cannot send Ctrl-C/Esc/Tab/arrows. + * Mobile is now a dedicated task-flow shell: one full-screen task at a time, + * project selection in a phone sheet, bottom navigation for short decisions, + * and agent sessions opened full-screen rather than as a shrunken desktop cell. */ import { describe, it, expect, beforeEach } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; @@ -67,65 +61,83 @@ beforeEach(() => { // jsdom defaults to 1024px; pin a phone width so anything reading the viewport // sees a phone. (Tailwind breakpoints are CSS-only and unresolved in jsdom — // hence the structural assertions below rather than computed-style ones.) + window.localStorage.clear(); window.innerWidth = PHONE_WIDTH; window.dispatchEvent(new Event("resize")); }); describe("web client on a phone viewport", () => { - it("mounts no desktop-only layout grid, split or dock", async () => { + it("mounts the #206 mobile task-flow shell instead of the desktop shell", async () => { await renderPairedWebApp(); - await screen.findByText("Projets"); + await screen.findByTestId("web-mobile-task-canvas"); + expect(screen.getByTestId("web-workspace").getAttribute("data-shell")).toBe("mobile-task-flow"); + expect(screen.queryByTestId("web-desktop-shell")).toBeNull(); for (const testId of ["layout-grid", "layout-grid-container", "layout-split", "layout-tabs"]) { expect(screen.queryByTestId(testId)).toBeNull(); } }); - it("stacks the workspace vertically in a single scroll container", async () => { + it("uses a bottom phone navigation for task surfaces", async () => { await renderPairedWebApp(); + fireEvent.click(await screen.findByText("Demo")); + await screen.findByTestId("web-workstate"); - const workspace = await screen.findByTestId("web-workspace"); - expect(workspace.className).toContain("flex-col"); - const canvas = screen.getByTestId("web-shell-canvas"); + const nav = screen.getByRole("navigation", { name: "Navigation mobile" }); + expect(nav).toBeTruthy(); + expect(screen.getByRole("button", { name: "Travail" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Tickets" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Sprints" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Tickets" })); + expect(await screen.findByRole("heading", { name: "Tickets" })).toBeTruthy(); + + const canvas = screen.getByTestId("web-mobile-task-canvas"); expect(canvas.className).toContain("overflow-y-auto"); }); - it("gives the agent cell a key toolbar so a phone can send Ctrl-C/Esc/arrows", async () => { + it("opens the project selector as a full-screen mobile sheet", async () => { await renderPairedWebApp(); - fireEvent.click(await screen.findByText("Demo")); - await screen.findByTestId("web-workstate"); - fireEvent.click(screen.getByRole("button", { name: "Ouvrir" })); + fireEvent.click(await screen.findByRole("button", { name: "Projets" })); + const sheet = await screen.findByRole("dialog", { name: "Projets" }); - expect(await screen.findByTestId("web-agent-cell")).toBeTruthy(); - expect(screen.getByTestId("terminal-key-bar")).toBeTruthy(); - }); - - it("opens the Panneaux menu as a full-screen sheet at 360px (no fixed-width overflow)", async () => { - await renderPairedWebApp(); - - fireEvent.click(await screen.findByText("Demo")); - fireEvent.click(await screen.findByRole("button", { name: "Panneaux : Travail" })); - - const sheet = await screen.findByRole("dialog", { name: "Panneaux" }); - // Full-viewport overlay (`fixed inset-0`), not a fixed-width dropdown that - // could overflow a 360px screen. expect(sheet.className).toContain("fixed"); expect(sheet.className).toContain("inset-0"); + expect(screen.getByRole("button", { name: "Demo" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Gérer les projets" })).toBeTruthy(); }); - it("sizes the agent cell against the dynamic viewport, not a fixed 256px", async () => { + it("keeps tickets and sprints usable from the mobile task flow", async () => { + await renderPairedWebApp(); + + fireEvent.click(await screen.findByText("Demo")); + await screen.findByTestId("web-workstate"); + fireEvent.click(screen.getByRole("button", { name: "Tickets" })); + expect(await screen.findByRole("heading", { name: "Tickets" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "+ Ticket" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Sprints" })); + expect(await screen.findByRole("heading", { name: "Sprints" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "+ Sprint" })).toBeTruthy(); + }); + + it("opens an agent session full-screen with the mobile key toolbar", async () => { await renderPairedWebApp(); fireEvent.click(await screen.findByText("Demo")); await screen.findByTestId("web-workstate"); fireEvent.click(screen.getByRole("button", { name: "Ouvrir" })); - // `dvh` (not `vh`/`h-64`) is what survives a mobile URL bar collapsing. - // Match exact class tokens: `min-h-64` legitimately contains "h-64". + const session = await screen.findByTestId("web-mobile-agent-session"); + expect(session.className).toContain("fixed"); + expect(session.className).toContain("inset-0"); + expect(screen.getByRole("dialog", { name: "Session agent Archi" })).toBeTruthy(); + const cell = await screen.findByTestId("web-agent-cell"); const classes = cell.className.split(/\s+/); - expect(classes).toContain("h-[60dvh]"); + expect(classes).toContain("h-full"); expect(classes).not.toContain("h-64"); + expect(screen.getByTestId("terminal-key-bar")).toBeTruthy(); }); }); diff --git a/frontend/src/features/web/WebWorkspace.tsx b/frontend/src/features/web/WebWorkspace.tsx index 00cca113..0e587b02 100644 --- a/frontend/src/features/web/WebWorkspace.tsx +++ b/frontend/src/features/web/WebWorkspace.tsx @@ -90,6 +90,7 @@ export function WebWorkspace() { const projectsRef = useRef(null); // Set by the Sprints panel's "Voir tickets"; consumed once by the Tickets panel. const [focusSprintId, setFocusSprintId] = useState(null); + const mobile = useMobileWorkspace(); const openRoot = projects?.find((p) => p.id === openId)?.root ?? null; const openProject = useMemo( @@ -245,6 +246,28 @@ export function WebWorkspace() { [focusSprintId, openProject, panel, syncBus, syncProject], ); + if (mobile) { + return ( + void openReadOnly(id)} + onCreateProject={createProject} + onRefreshProjects={() => void refresh()} + onViewSprintTickets={(sprintId) => { + setFocusSprintId(sprintId); + selectPanel("tickets"); + }} + /> + ); + } + return (
(typeof window === "undefined" ? false : window.innerWidth < 768); + const [mobile, setMobile] = useState(read); + + useEffect(() => { + const onResize = () => setMobile(read()); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + + return mobile; +} + +function WebMobileTaskFlowShell({ + panel, + projects, + openId, + openProject, + openRoot, + error, + focusSprintId, + onSelectPanel, + onOpenProject, + onCreateProject, + onRefreshProjects, + onViewSprintTickets, +}: { + panel: WebProjectPanelId; + projects: Project[] | null; + openId: string | null; + openProject: Project | null; + openRoot: string | null; + error: string | null; + focusSprintId: string | null; + onSelectPanel: (panel: WebProjectPanelId) => void; + onOpenProject: (projectId: string) => void; + onCreateProject: (name: string, root: string) => Promise; + onRefreshProjects: () => void; + onViewSprintTickets: (sprintId: string) => void; +}) { + const activePanel = panel === "projects" || openId ? panel : "projects"; + const navItems: Array<{ id: WebProjectPanelId; label: string }> = [ + { id: "projects", label: "Projet" }, + { id: "work", label: "Travail" }, + { id: "tickets", label: "Tickets" }, + { id: "sprints", label: "Sprints" }, + { id: "agents", label: "Agent" }, + ]; + + return ( +
+
+
+

+ {WEB_PANEL_META[activePanel].label} +

+

+ {openProject ? openProject.name : "Choisir un projet"} +

+
+ onSelectPanel("projects")} + onRefreshProjects={onRefreshProjects} + /> +
+ +
+
+ + {error && } + +
+
+ + +
+ ); +} + +function MobileProjectSelector({ + projects, + openId, + onOpenProject, + onShowProjects, + onRefreshProjects, +}: { + projects: Project[] | null; + openId: string | null; + onOpenProject: (projectId: string) => void; + onShowProjects: () => void; + onRefreshProjects: () => void; +}) { + const [open, setOpen] = useState(false); + return ( + <> + + {open && ( + setOpen(false)} + entries={[ + ...(projects ?? []).map((project) => ({ + id: project.id, + label: project.name, + hint: project.root, + active: openId === project.id, + onSelect: () => { + onOpenProject(project.id); + setOpen(false); + }, + })), + { + id: "manage-projects", + label: "Gérer les projets", + hint: "Créer ou rafraîchir", + active: false, + onSelect: () => { + onShowProjects(); + setOpen(false); + }, + }, + { + id: "refresh-projects", + label: "Rafraîchir", + active: false, + onSelect: () => { + onRefreshProjects(); + setOpen(false); + }, + }, + ]} + /> + )} + + ); +} + +function WebMobileTaskSurface({ + panel, + projects, + openId, + openRoot, + focusSprintId, + onOpenProject, + onCreateProject, + onRefreshProjects, + onViewSprintTickets, +}: { + panel: WebProjectPanelId; + projects: Project[] | null; + openId: string | null; + openRoot: string | null; + focusSprintId: string | null; + onOpenProject: (projectId: string) => void; + onCreateProject: (name: string, root: string) => Promise; + onRefreshProjects: () => void; + onViewSprintTickets: (sprintId: string) => void; +}) { + if (panel === "projects") { + return ( + + ); + } + + if (!openId) { + return ( + +

Choisir un projet pour continuer.

+
+ ); + } + + return ( + + ); +} + function WebWorkspaceSurface({ panel, projects, @@ -765,18 +1029,20 @@ export function WebProjectPanelBody({ root, focusSprintId, onViewSprintTickets, + mobile = false, }: { panel: WebOpenablePanelId; projectId: string; root: string | null; focusSprintId: string | null; onViewSprintTickets: (sprintId: string) => void; + mobile?: boolean; }) { switch (panel) { case "context": return ; case "work": - return ; + return ; case "tickets": return ; case "sprints": @@ -826,7 +1092,15 @@ function ReconnectBanner() { } /** Live work-state + background + inbox for the opened project (F5). */ -function LiveProjectPanel({ projectId, root }: { projectId: string; root: string | null }) { +function LiveProjectPanel({ + projectId, + root, + mobile = false, +}: { + projectId: string; + root: string | null; + mobile?: boolean; +}) { const vm = useProjectWorkState(projectId); // Re-sync the read-model when the WS reconnects (events missed while offline). useLiveReconnect(vm.refresh); @@ -865,6 +1139,8 @@ function LiveProjectPanel({ projectId, root }: { projectId: string; root: string }); }, []); + const openAgent = openAgentId ? agents.find((a) => a.agentId === openAgentId) : null; + return (
@@ -904,7 +1180,7 @@ function LiveProjectPanel({ projectId, root }: { projectId: string; root: string )} - {root && openAgentId && ( + {root && openAgentId && !mobile && (
Agent @@ -928,6 +1204,45 @@ function LiveProjectPanel({ projectId, root }: { projectId: string; root: string />
)} + + {root && openAgentId && mobile && ( +
+
+
+

Session agent

+

+ {openAgent?.name ?? openAgentId} +

+
+ +
+
+ +
+
+ )} ); }