feat(web): add desktop parity shell — #203
This commit is contained in:
@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Live web workspace — ticket #13/#86, extended by #90 (missing menus).
|
||||
* Live web workspace — ticket #13/#86, extended by #90 (missing menus)
|
||||
* and #203 (desktop shell parity).
|
||||
*
|
||||
* After pairing: list/create projects (web variant — no native folder browse,
|
||||
* see {@link WebProjectsPanel}), open one read-only, and route the main surface
|
||||
@ -19,7 +20,7 @@
|
||||
* re-synchronises the read-model after a WS outage.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type {
|
||||
AgentWorkState,
|
||||
@ -53,7 +54,7 @@ function describe(e: unknown): string {
|
||||
return String(e);
|
||||
}
|
||||
|
||||
/** The panels reachable from the web "Panneaux" menu (#90). */
|
||||
/** The surfaces reachable from the web shell navigation (#90/#203). */
|
||||
export type WebProjectPanelId =
|
||||
| "context"
|
||||
| "work"
|
||||
@ -67,6 +68,16 @@ export type WebProjectPanelId =
|
||||
| "git"
|
||||
| "projects";
|
||||
|
||||
type WebPanelPlacement = "primary" | "secondary";
|
||||
|
||||
interface WebPanelMeta {
|
||||
id: WebProjectPanelId;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
description: string;
|
||||
placement: WebPanelPlacement;
|
||||
}
|
||||
|
||||
/** Menu order + French labels (ticket #78 — the human UI defaults to French). */
|
||||
const WEB_PANEL_ORDER: WebProjectPanelId[] = [
|
||||
"context",
|
||||
@ -82,20 +93,90 @@ const WEB_PANEL_ORDER: WebProjectPanelId[] = [
|
||||
"projects",
|
||||
];
|
||||
|
||||
const WEB_PANEL_LABEL: Record<WebProjectPanelId, string> = {
|
||||
context: "Contexte projet",
|
||||
work: "Travail",
|
||||
tickets: "Tickets",
|
||||
sprints: "Sprints",
|
||||
agents: "Agents",
|
||||
templates: "Templates",
|
||||
skills: "Skills",
|
||||
permissions: "Permissions",
|
||||
memory: "Mémoire",
|
||||
git: "Git",
|
||||
projects: "Projets",
|
||||
const WEB_PANEL_META: Record<WebProjectPanelId, WebPanelMeta> = {
|
||||
context: {
|
||||
id: "context",
|
||||
label: "Contexte projet",
|
||||
shortLabel: "Contexte",
|
||||
description: "Contexte partagé du projet ouvert",
|
||||
placement: "secondary",
|
||||
},
|
||||
work: {
|
||||
id: "work",
|
||||
label: "Travail",
|
||||
shortLabel: "Travail",
|
||||
description: "État live des agents et tâches",
|
||||
placement: "primary",
|
||||
},
|
||||
tickets: {
|
||||
id: "tickets",
|
||||
label: "Tickets",
|
||||
shortLabel: "Tickets",
|
||||
description: "Suivi et détail des tickets",
|
||||
placement: "primary",
|
||||
},
|
||||
sprints: {
|
||||
id: "sprints",
|
||||
label: "Sprints",
|
||||
shortLabel: "Sprints",
|
||||
description: "Organisation des sprints",
|
||||
placement: "primary",
|
||||
},
|
||||
agents: {
|
||||
id: "agents",
|
||||
label: "Agents",
|
||||
shortLabel: "Agents",
|
||||
description: "Catalogue et lancement des agents",
|
||||
placement: "primary",
|
||||
},
|
||||
templates: {
|
||||
id: "templates",
|
||||
label: "Templates",
|
||||
shortLabel: "Templates",
|
||||
description: "Templates d'agents",
|
||||
placement: "secondary",
|
||||
},
|
||||
skills: {
|
||||
id: "skills",
|
||||
label: "Skills",
|
||||
shortLabel: "Skills",
|
||||
description: "Skills assignés et éditables",
|
||||
placement: "secondary",
|
||||
},
|
||||
permissions: {
|
||||
id: "permissions",
|
||||
label: "Permissions",
|
||||
shortLabel: "Perms",
|
||||
description: "Permissions MCP et sandbox",
|
||||
placement: "secondary",
|
||||
},
|
||||
memory: {
|
||||
id: "memory",
|
||||
label: "Mémoire",
|
||||
shortLabel: "Mémoire",
|
||||
description: "Mémoire projet et embedder",
|
||||
placement: "secondary",
|
||||
},
|
||||
git: {
|
||||
id: "git",
|
||||
label: "Git",
|
||||
shortLabel: "Git",
|
||||
description: "État Git du projet",
|
||||
placement: "primary",
|
||||
},
|
||||
projects: {
|
||||
id: "projects",
|
||||
label: "Projets",
|
||||
shortLabel: "Explorer",
|
||||
description: "Liste et création de projets",
|
||||
placement: "primary",
|
||||
},
|
||||
};
|
||||
|
||||
const WEB_PANEL_LABEL: Record<WebProjectPanelId, string> = Object.fromEntries(
|
||||
WEB_PANEL_ORDER.map((id) => [id, WEB_PANEL_META[id].label]),
|
||||
) as Record<WebProjectPanelId, string>;
|
||||
|
||||
export function WebWorkspace() {
|
||||
const { project } = useGateways();
|
||||
const [projects, setProjects] = useState<Project[] | null>(null);
|
||||
@ -106,6 +187,10 @@ export function WebWorkspace() {
|
||||
const [focusSprintId, setFocusSprintId] = useState<string | null>(null);
|
||||
|
||||
const openRoot = projects?.find((p) => p.id === openId)?.root ?? null;
|
||||
const openProject = useMemo(
|
||||
() => projects?.find((p) => p.id === openId) ?? null,
|
||||
[openId, projects],
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null);
|
||||
@ -155,44 +240,326 @@ export function WebWorkspace() {
|
||||
return (
|
||||
<div
|
||||
data-testid="web-workspace"
|
||||
className="flex h-full flex-col gap-4 overflow-y-auto p-4 pb-[max(1rem,env(safe-area-inset-bottom))] pl-[max(1rem,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))] sm:p-6"
|
||||
data-shell="desktop-parity"
|
||||
className="flex h-full min-h-0 flex-col overflow-hidden bg-canvas"
|
||||
>
|
||||
<ReconnectBanner />
|
||||
|
||||
<WebPanelMenuButton activePanel={panel} hasProject={Boolean(openId)} onSelect={setPanel} />
|
||||
|
||||
{error && (
|
||||
<Panel className="border-danger/40">
|
||||
<p className="text-sm text-danger">{error}</p>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{panel === "projects" ? (
|
||||
<WebProjectsPanel
|
||||
projects={projects}
|
||||
openId={openId}
|
||||
onOpen={(id) => void openReadOnly(id)}
|
||||
onCreate={createProject}
|
||||
onRefresh={() => void refresh()}
|
||||
<div data-testid="web-desktop-shell" className="contents">
|
||||
<WebDesktopTopBar
|
||||
activePanel={panel}
|
||||
openProject={openProject}
|
||||
projectCount={projects?.length ?? null}
|
||||
onShowProjects={() => setPanel("projects")}
|
||||
/>
|
||||
) : !openId ? (
|
||||
<p className="text-sm text-muted">Ouvrir un projet pour utiliser ce panneau.</p>
|
||||
) : (
|
||||
<WebPanelBody
|
||||
panel={panel}
|
||||
projectId={openId}
|
||||
root={openRoot}
|
||||
focusSprintId={focusSprintId}
|
||||
onViewSprintTickets={(sprintId) => {
|
||||
setFocusSprintId(sprintId);
|
||||
setPanel("tickets");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden md:grid md:grid-cols-[13rem_minmax(0,1fr)_20rem]">
|
||||
<WebPrimaryRail
|
||||
activePanel={panel}
|
||||
hasProject={Boolean(openId)}
|
||||
onSelect={setPanel}
|
||||
/>
|
||||
|
||||
<main
|
||||
data-testid="web-shell-canvas"
|
||||
className="min-w-0 flex-1 overflow-y-auto bg-canvas p-4 pb-[max(1rem,env(safe-area-inset-bottom))] pl-[max(1rem,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))] sm:p-6 md:border-x md:border-border md:p-5"
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4">
|
||||
<ReconnectBanner />
|
||||
<div className="md:hidden">
|
||||
<WebPanelMenuButton
|
||||
activePanel={panel}
|
||||
hasProject={Boolean(openId)}
|
||||
onSelect={setPanel}
|
||||
/>
|
||||
</div>
|
||||
{error && <WebWorkspaceError message={error} />}
|
||||
<WebWorkspaceSurface
|
||||
panel={panel}
|
||||
projects={projects}
|
||||
openId={openId}
|
||||
openRoot={openRoot}
|
||||
focusSprintId={focusSprintId}
|
||||
onOpenProject={(id) => void openReadOnly(id)}
|
||||
onCreateProject={createProject}
|
||||
onRefreshProjects={() => void refresh()}
|
||||
onViewSprintTickets={(sprintId) => {
|
||||
setFocusSprintId(sprintId);
|
||||
setPanel("tickets");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<WebSecondaryRail
|
||||
projects={projects}
|
||||
openId={openId}
|
||||
openProject={openProject}
|
||||
activePanel={panel}
|
||||
onOpenProject={(id) => void openReadOnly(id)}
|
||||
onRefreshProjects={() => void refresh()}
|
||||
onSelectPanel={setPanel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WebWorkspaceSurface({
|
||||
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 <p className="text-sm text-muted">Ouvrir un projet pour utiliser ce panneau.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<WebPanelBody
|
||||
panel={panel}
|
||||
projectId={openId}
|
||||
root={openRoot}
|
||||
focusSprintId={focusSprintId}
|
||||
onViewSprintTickets={onViewSprintTickets}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function WebWorkspaceError({ message }: { message: string }) {
|
||||
return (
|
||||
<Panel className="border-danger/40">
|
||||
<p className="text-sm text-danger">{message}</p>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function WebDesktopTopBar({
|
||||
activePanel,
|
||||
openProject,
|
||||
projectCount,
|
||||
onShowProjects,
|
||||
}: {
|
||||
activePanel: WebProjectPanelId;
|
||||
openProject: Project | null;
|
||||
projectCount: number | null;
|
||||
onShowProjects: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-testid="web-shell-topbar"
|
||||
className="hidden h-12 shrink-0 items-center justify-between gap-4 border-b border-border bg-surface px-4 md:flex"
|
||||
>
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="text-sm font-semibold text-content">
|
||||
Surface {WEB_PANEL_META[activePanel].label}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-xs text-muted">
|
||||
{openProject ? openProject.name : "Aucun projet ouvert"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-xs text-muted">
|
||||
<span>
|
||||
{projectCount === null ? "Projets…" : `${projectCount} projet${projectCount > 1 ? "s" : ""}`}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={onShowProjects}>
|
||||
Gérer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WebPrimaryRail({
|
||||
activePanel,
|
||||
hasProject,
|
||||
onSelect,
|
||||
}: {
|
||||
activePanel: WebProjectPanelId;
|
||||
hasProject: boolean;
|
||||
onSelect: (panel: WebProjectPanelId) => void;
|
||||
}) {
|
||||
return (
|
||||
<nav
|
||||
data-testid="web-shell-primary-rail"
|
||||
aria-label="Surfaces principales"
|
||||
className="hidden min-h-0 flex-col overflow-y-auto bg-surface p-2 md:flex"
|
||||
>
|
||||
<div className="px-2 pb-2 pt-1 text-[11px] font-semibold uppercase text-faint">
|
||||
Surfaces
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{WEB_PANEL_ORDER.map((id) => {
|
||||
const meta = WEB_PANEL_META[id];
|
||||
const disabled = id !== "projects" && !hasProject;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
aria-label={`Surface ${meta.label}`}
|
||||
aria-current={activePanel === id ? "page" : undefined}
|
||||
disabled={disabled}
|
||||
title={disabled ? "Ouvrir un projet pour utiliser ce panneau." : meta.description}
|
||||
onClick={() => onSelect(id)}
|
||||
className={cn(
|
||||
"flex min-h-10 w-full flex-col rounded-md px-3 py-2 text-left transition-colors",
|
||||
activePanel === id
|
||||
? "bg-primary text-on-primary"
|
||||
: "text-muted hover:bg-raised hover:text-content",
|
||||
disabled && "cursor-not-allowed opacity-45 hover:bg-transparent hover:text-muted",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-medium">{meta.shortLabel}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-[11px]",
|
||||
activePanel === id ? "text-on-primary/80" : "text-faint",
|
||||
)}
|
||||
>
|
||||
{meta.placement === "primary" ? "Canvas" : "Rail secondaire"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function WebSecondaryRail({
|
||||
projects,
|
||||
openId,
|
||||
openProject,
|
||||
activePanel,
|
||||
onOpenProject,
|
||||
onRefreshProjects,
|
||||
onSelectPanel,
|
||||
}: {
|
||||
projects: Project[] | null;
|
||||
openId: string | null;
|
||||
openProject: Project | null;
|
||||
activePanel: WebProjectPanelId;
|
||||
onOpenProject: (projectId: string) => void;
|
||||
onRefreshProjects: () => void;
|
||||
onSelectPanel: (panel: WebProjectPanelId) => void;
|
||||
}) {
|
||||
const secondaryPanels = WEB_PANEL_ORDER.filter(
|
||||
(id) => WEB_PANEL_META[id].placement === "secondary",
|
||||
);
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-testid="web-shell-secondary-rail"
|
||||
className="hidden min-h-0 flex-col gap-3 overflow-y-auto bg-surface p-3 md:flex"
|
||||
>
|
||||
<section className="rounded-md border border-border bg-canvas p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-xs font-semibold uppercase text-faint">Projet actif</h2>
|
||||
<Button variant="ghost" size="sm" onClick={() => onSelectPanel("projects")}>
|
||||
Gérer
|
||||
</Button>
|
||||
</div>
|
||||
{openProject ? (
|
||||
<div className="mt-2 min-w-0">
|
||||
<p className="truncate text-sm font-medium text-content">{openProject.name}</p>
|
||||
<p className="mt-1 break-words text-xs text-faint">{openProject.root}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-muted">Aucun projet ouvert.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-md border border-border bg-canvas p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-xs font-semibold uppercase text-faint">Liste projets</h2>
|
||||
<Button variant="ghost" size="sm" onClick={onRefreshProjects}>
|
||||
Rafraîchir
|
||||
</Button>
|
||||
</div>
|
||||
{activePanel === "projects" ? (
|
||||
<p className="mt-3 text-xs text-muted">La liste complète est ouverte dans le canvas.</p>
|
||||
) : projects === null ? (
|
||||
<span className="mt-3 inline-flex items-center gap-1.5 text-xs text-muted">
|
||||
<Spinner size={12} /> Chargement…
|
||||
</span>
|
||||
) : projects.length === 0 ? (
|
||||
<p className="mt-3 text-xs text-muted">Aucun projet.</p>
|
||||
) : (
|
||||
<ul className="mt-3 flex flex-col gap-1.5">
|
||||
{projects.map((project) => (
|
||||
<li key={project.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={openId === project.id}
|
||||
onClick={() => onOpenProject(project.id)}
|
||||
className="flex w-full flex-col rounded-md border border-border bg-raised px-2.5 py-2 text-left transition-colors hover:border-primary aria-pressed:border-primary"
|
||||
>
|
||||
<span className="truncate text-xs font-medium text-content">{project.name}</span>
|
||||
<span className="truncate text-[11px] text-faint">{project.root}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-md border border-border bg-canvas p-3">
|
||||
<h2 className="text-xs font-semibold uppercase text-faint">Secondaire</h2>
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
{secondaryPanels.map((id) => {
|
||||
const disabled = !openId;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
aria-label={`Ouvrir ${WEB_PANEL_META[id].label}`}
|
||||
disabled={disabled}
|
||||
aria-current={activePanel === id ? "page" : undefined}
|
||||
onClick={() => onSelectPanel(id)}
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-2 text-left text-xs transition-colors",
|
||||
activePanel === id
|
||||
? "bg-primary text-on-primary"
|
||||
: "text-muted hover:bg-raised hover:text-content",
|
||||
disabled && "cursor-not-allowed opacity-45 hover:bg-transparent hover:text-muted",
|
||||
)}
|
||||
>
|
||||
{WEB_PANEL_META[id].label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact trigger + full-screen sheet for the "Panneaux" menu (#90). */
|
||||
function WebPanelMenuButton({
|
||||
activePanel,
|
||||
|
||||
Reference in New Issue
Block a user