merge(web): UX mobile task-flow dédiée — #206 (QA verte)

This commit is contained in:
2026-08-15 01:33:43 +02:00
4 changed files with 384 additions and 42 deletions

View File

@ -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<string | null>(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.
<div
data-testid="web-agent-cell"
className="flex h-[60dvh] min-h-64 w-full flex-col overflow-hidden rounded-md border border-border sm:h-80"
className={
fullHeight
? "flex h-full min-h-0 w-full flex-col overflow-hidden border border-border"
: "flex h-[60dvh] min-h-64 w-full flex-col overflow-hidden rounded-md border border-border sm:h-80"
}
>
<div className="min-h-0 flex-1">
<TerminalView

View File

@ -36,6 +36,8 @@ const okFetch: FetchLike = async () => ({
beforeEach(() => {
window.localStorage.clear();
window.innerWidth = 1024;
window.dispatchEvent(new Event("resize"));
vi.restoreAllMocks();
});

View File

@ -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();
});
});

View File

@ -90,6 +90,7 @@ export function WebWorkspace() {
const projectsRef = useRef<Project[] | null>(null);
// Set by the Sprints panel's "Voir tickets"; consumed once by the Tickets panel.
const [focusSprintId, setFocusSprintId] = useState<string | null>(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 (
<WebMobileTaskFlowShell
panel={panel}
projects={projects}
openId={openId}
openProject={openProject}
openRoot={openRoot}
error={error}
focusSprintId={focusSprintId}
onSelectPanel={selectPanel}
onOpenProject={(id) => void openReadOnly(id)}
onCreateProject={createProject}
onRefreshProjects={() => void refresh()}
onViewSprintTickets={(sprintId) => {
setFocusSprintId(sprintId);
selectPanel("tickets");
}}
/>
);
}
return (
<div
data-testid="web-workspace"
@ -339,6 +362,247 @@ export function WebWorkspace() {
);
}
function useMobileWorkspace(): boolean {
const read = () => (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<Project>;
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 (
<div
data-testid="web-workspace"
data-shell="mobile-task-flow"
className="flex h-full min-h-0 flex-col overflow-hidden bg-canvas"
>
<header
data-testid="web-mobile-task-header"
className="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-border bg-surface px-3 pl-[max(0.75rem,env(safe-area-inset-left))] pr-[max(0.75rem,env(safe-area-inset-right))]"
>
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase text-faint">
{WEB_PANEL_META[activePanel].label}
</p>
<p className="truncate text-sm font-medium text-content">
{openProject ? openProject.name : "Choisir un projet"}
</p>
</div>
<MobileProjectSelector
projects={projects}
openId={openId}
onOpenProject={onOpenProject}
onShowProjects={() => onSelectPanel("projects")}
onRefreshProjects={onRefreshProjects}
/>
</header>
<main
data-testid="web-mobile-task-canvas"
className="min-h-0 flex-1 overflow-y-auto px-3 pb-4 pt-3"
>
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
<ReconnectBanner />
{error && <WebWorkspaceError message={error} />}
<WebMobileTaskSurface
panel={activePanel}
projects={projects}
openId={openId}
openRoot={openRoot}
focusSprintId={focusSprintId}
onOpenProject={onOpenProject}
onCreateProject={onCreateProject}
onRefreshProjects={onRefreshProjects}
onViewSprintTickets={onViewSprintTickets}
/>
</div>
</main>
<nav
data-testid="web-mobile-bottom-nav"
aria-label="Navigation mobile"
className="grid shrink-0 grid-cols-5 border-t border-border bg-surface pb-[env(safe-area-inset-bottom)]"
>
{navItems.map((item) => {
const disabled = item.id !== "projects" && !openId;
const selected = activePanel === item.id;
return (
<button
key={item.id}
type="button"
disabled={disabled}
aria-current={selected ? "page" : undefined}
onClick={() => onSelectPanel(item.id)}
className={cn(
"flex min-h-14 items-center justify-center px-1 text-xs font-medium",
selected ? "text-primary" : "text-muted",
disabled && "cursor-not-allowed opacity-45",
)}
>
{item.label}
</button>
);
})}
</nav>
</div>
);
}
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 (
<>
<Button size="sm" variant="ghost" onClick={() => setOpen(true)}>
Projets
</Button>
{open && (
<WebMenuSheet
title="Projets"
note={projects === null ? "Chargement des projets…" : undefined}
onClose={() => 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<Project>;
onRefreshProjects: () => void;
onViewSprintTickets: (sprintId: string) => void;
}) {
if (panel === "projects") {
return (
<WebProjectsPanel
projects={projects}
openId={openId}
onOpen={onOpenProject}
onCreate={onCreateProject}
onRefresh={onRefreshProjects}
/>
);
}
if (!openId) {
return (
<Panel className="mt-2">
<p className="text-sm text-muted">Choisir un projet pour continuer.</p>
</Panel>
);
}
return (
<WebProjectPanelBody
panel={panel}
projectId={openId}
root={openRoot}
focusSprintId={focusSprintId}
onViewSprintTickets={onViewSprintTickets}
mobile
/>
);
}
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 <ProjectContextPanel projectId={projectId} />;
case "work":
return <LiveProjectPanel projectId={projectId} root={root} />;
return <LiveProjectPanel projectId={projectId} root={root} mobile={mobile} />;
case "tickets":
return <WebTicketsView projectId={projectId} focusSprintId={focusSprintId} />;
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 (
<Panel className="mt-2">
<div className="mb-2 flex items-center justify-between">
@ -904,7 +1180,7 @@ function LiveProjectPanel({ projectId, root }: { projectId: string; root: string
</ul>
)}
{root && openAgentId && (
{root && openAgentId && !mobile && (
<div className="mt-3">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-semibold text-muted">Agent</span>
@ -928,6 +1204,45 @@ function LiveProjectPanel({ projectId, root }: { projectId: string; root: string
/>
</div>
)}
{root && openAgentId && mobile && (
<div
role="dialog"
aria-modal="true"
aria-label={`Session agent ${openAgent?.name ?? openAgentId}`}
data-testid="web-mobile-agent-session"
className="fixed inset-0 z-50 flex min-h-0 flex-col bg-canvas"
>
<header className="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-border bg-surface px-3 pl-[max(0.75rem,env(safe-area-inset-left))] pr-[max(0.75rem,env(safe-area-inset-right))]">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase text-faint">Session agent</p>
<p className="truncate text-sm font-medium text-content">
{openAgent?.name ?? openAgentId}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setOpenAgentId(null);
bumpCellLayoutVersion();
}}
>
Fermer
</Button>
</header>
<div className="min-h-0 flex-1 p-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
<WebAgentCell
key={openAgentId}
projectId={projectId}
agentId={openAgentId}
cwd={root}
refitSignal={cellLayoutVersion}
fullHeight
/>
</div>
</div>
)}
</Panel>
);
}