feat(web): add mobile task flow shell — #206

This commit is contained in:
2026-08-15 01:31:49 +02:00
parent dcf6a84dba
commit a907586f41
4 changed files with 384 additions and 42 deletions

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