feat(web): add mobile task flow shell — #206
This commit is contained in:
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user