merge(web): shell desktop parity — #203 (QA verte)
This commit is contained in:
@ -315,6 +315,7 @@ describe("TauriAgentGateway invoke payloads", () => {
|
|||||||
cols: 80,
|
cols: 80,
|
||||||
cellKind: "chat",
|
cellKind: "chat",
|
||||||
conversationId: "conversation-0",
|
conversationId: "conversation-0",
|
||||||
|
freshStart: false,
|
||||||
nodeId: "node-3",
|
nodeId: "node-3",
|
||||||
},
|
},
|
||||||
onOutput: expect.anything(),
|
onOutput: expect.anything(),
|
||||||
|
|||||||
@ -140,6 +140,7 @@ describe("HttpAgentGateway WS round-trip (B6 frames)", () => {
|
|||||||
rows: 24,
|
rows: 24,
|
||||||
cols: 80,
|
cols: 80,
|
||||||
conversationId: "resume-9",
|
conversationId: "resume-9",
|
||||||
|
freshStart: false,
|
||||||
});
|
});
|
||||||
expect(handle.sessionId).toBe("sess-1");
|
expect(handle.sessionId).toBe("sess-1");
|
||||||
// The conversation id minted by the launch is surfaced on the handle.
|
// The conversation id minted by the launch is surfaced on the handle.
|
||||||
|
|||||||
@ -71,6 +71,7 @@ describe("singleton agent — mock gateway guard (T5)", () => {
|
|||||||
nodeId: "node-A",
|
nodeId: "node-A",
|
||||||
sessionId: "mock-agent-session-1",
|
sessionId: "mock-agent-session-1",
|
||||||
kind: "pty",
|
kind: "pty",
|
||||||
|
runtimeState: "running",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@ -148,6 +149,7 @@ describe("singleton agent — dropdown guard (T6)", () => {
|
|||||||
nodeId: leafId,
|
nodeId: leafId,
|
||||||
sessionId: "mock-agent-session-1",
|
sessionId: "mock-agent-session-1",
|
||||||
kind: "pty",
|
kind: "pty",
|
||||||
|
runtimeState: "running",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
* desktop-only action.
|
* desktop-only action.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
|
||||||
import type { Gateways } from "@/ports";
|
import type { Gateways } from "@/ports";
|
||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
@ -51,6 +51,23 @@ function renderPairedWebApp(gateways: Gateways) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("WebWorkspace « Panneaux » menu (#90)", () => {
|
describe("WebWorkspace « Panneaux » menu (#90)", () => {
|
||||||
|
it("renders the #203 desktop shell zones with persistent surface navigation", async () => {
|
||||||
|
renderPairedWebApp(await seededGateways());
|
||||||
|
|
||||||
|
expect(await screen.findByTestId("web-desktop-shell")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("web-shell-topbar")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("web-shell-primary-rail")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("web-shell-canvas")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("web-shell-secondary-rail")).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByText("Demo"));
|
||||||
|
expect(await screen.findByText("Surface Travail")).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Surface Tickets" }));
|
||||||
|
expect(await screen.findByText("Surface Tickets")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "Surface Travail" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("only enables Projets while no project is open, with a help note", async () => {
|
it("only enables Projets while no project is open, with a help note", async () => {
|
||||||
renderPairedWebApp(await seededGateways());
|
renderPairedWebApp(await seededGateways());
|
||||||
|
|
||||||
@ -66,7 +83,9 @@ describe("WebWorkspace « Panneaux » menu (#90)", () => {
|
|||||||
|
|
||||||
// A disabled entry does nothing.
|
// A disabled entry does nothing.
|
||||||
fireEvent.click(contextEntry);
|
fireEvent.click(contextEntry);
|
||||||
expect(screen.queryByText("Contexte projet")).toBeTruthy(); // sheet still open, menu entry still there
|
expect(
|
||||||
|
within(screen.getByRole("dialog", { name: "Panneaux" })).queryByText("Contexte projet"),
|
||||||
|
).toBeTruthy(); // sheet still open, menu entry still there
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes the main surface to the selected panel once a project is open", async () => {
|
it("routes the main surface to the selected panel once a project is open", async () => {
|
||||||
|
|||||||
@ -86,7 +86,8 @@ describe("web client on a phone viewport", () => {
|
|||||||
|
|
||||||
const workspace = await screen.findByTestId("web-workspace");
|
const workspace = await screen.findByTestId("web-workspace");
|
||||||
expect(workspace.className).toContain("flex-col");
|
expect(workspace.className).toContain("flex-col");
|
||||||
expect(workspace.className).toContain("overflow-y-auto");
|
const canvas = screen.getByTestId("web-shell-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("gives the agent cell a key toolbar so a phone can send Ctrl-C/Esc/arrows", async () => {
|
||||||
|
|||||||
@ -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,
|
* After pairing: list/create projects (web variant — no native folder browse,
|
||||||
* see {@link WebProjectsPanel}), open one read-only, and route the main surface
|
* 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.
|
* re-synchronises the read-model after a WS outage.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AgentWorkState,
|
AgentWorkState,
|
||||||
@ -53,7 +54,7 @@ function describe(e: unknown): string {
|
|||||||
return String(e);
|
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 =
|
export type WebProjectPanelId =
|
||||||
| "context"
|
| "context"
|
||||||
| "work"
|
| "work"
|
||||||
@ -67,6 +68,16 @@ export type WebProjectPanelId =
|
|||||||
| "git"
|
| "git"
|
||||||
| "projects";
|
| "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). */
|
/** Menu order + French labels (ticket #78 — the human UI defaults to French). */
|
||||||
const WEB_PANEL_ORDER: WebProjectPanelId[] = [
|
const WEB_PANEL_ORDER: WebProjectPanelId[] = [
|
||||||
"context",
|
"context",
|
||||||
@ -82,20 +93,90 @@ const WEB_PANEL_ORDER: WebProjectPanelId[] = [
|
|||||||
"projects",
|
"projects",
|
||||||
];
|
];
|
||||||
|
|
||||||
const WEB_PANEL_LABEL: Record<WebProjectPanelId, string> = {
|
const WEB_PANEL_META: Record<WebProjectPanelId, WebPanelMeta> = {
|
||||||
context: "Contexte projet",
|
context: {
|
||||||
work: "Travail",
|
id: "context",
|
||||||
tickets: "Tickets",
|
label: "Contexte projet",
|
||||||
sprints: "Sprints",
|
shortLabel: "Contexte",
|
||||||
agents: "Agents",
|
description: "Contexte partagé du projet ouvert",
|
||||||
templates: "Templates",
|
placement: "secondary",
|
||||||
skills: "Skills",
|
},
|
||||||
permissions: "Permissions",
|
work: {
|
||||||
memory: "Mémoire",
|
id: "work",
|
||||||
git: "Git",
|
label: "Travail",
|
||||||
projects: "Projets",
|
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() {
|
export function WebWorkspace() {
|
||||||
const { project } = useGateways();
|
const { project } = useGateways();
|
||||||
const [projects, setProjects] = useState<Project[] | null>(null);
|
const [projects, setProjects] = useState<Project[] | null>(null);
|
||||||
@ -106,6 +187,10 @@ export function WebWorkspace() {
|
|||||||
const [focusSprintId, setFocusSprintId] = useState<string | null>(null);
|
const [focusSprintId, setFocusSprintId] = useState<string | null>(null);
|
||||||
|
|
||||||
const openRoot = projects?.find((p) => p.id === openId)?.root ?? 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 () => {
|
const refresh = useCallback(async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -155,44 +240,326 @@ export function WebWorkspace() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="web-workspace"
|
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 />
|
<div data-testid="web-desktop-shell" className="contents">
|
||||||
|
<WebDesktopTopBar
|
||||||
<WebPanelMenuButton activePanel={panel} hasProject={Boolean(openId)} onSelect={setPanel} />
|
activePanel={panel}
|
||||||
|
openProject={openProject}
|
||||||
{error && (
|
projectCount={projects?.length ?? null}
|
||||||
<Panel className="border-danger/40">
|
onShowProjects={() => setPanel("projects")}
|
||||||
<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()}
|
|
||||||
/>
|
/>
|
||||||
) : !openId ? (
|
|
||||||
<p className="text-sm text-muted">Ouvrir un projet pour utiliser ce panneau.</p>
|
<div className="flex min-h-0 flex-1 overflow-hidden md:grid md:grid-cols-[13rem_minmax(0,1fr)_20rem]">
|
||||||
) : (
|
<WebPrimaryRail
|
||||||
<WebPanelBody
|
activePanel={panel}
|
||||||
panel={panel}
|
hasProject={Boolean(openId)}
|
||||||
projectId={openId}
|
onSelect={setPanel}
|
||||||
root={openRoot}
|
/>
|
||||||
focusSprintId={focusSprintId}
|
|
||||||
onViewSprintTickets={(sprintId) => {
|
<main
|
||||||
setFocusSprintId(sprintId);
|
data-testid="web-shell-canvas"
|
||||||
setPanel("tickets");
|
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>
|
</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). */
|
/** Compact trigger + full-screen sheet for the "Panneaux" menu (#90). */
|
||||||
function WebPanelMenuButton({
|
function WebPanelMenuButton({
|
||||||
activePanel,
|
activePanel,
|
||||||
|
|||||||
Reference in New Issue
Block a user