merge(web): surfaces ouvrables ici/onglet/fenêtre — #204 (QA verte)

This commit is contained in:
2026-08-15 01:18:22 +02:00
8 changed files with 513 additions and 132 deletions

View File

@ -5,7 +5,7 @@ import ReactDOM from "react-dom/client";
import { App } from "./App";
import { PluginLayoutWindow, ViewWindow, parseViewWindowParams } from "./ViewWindow";
import { WebApp } from "@/features/web";
import { WebApp, WebSurfaceWindow, parseWebSurfaceWindowParams } from "@/features/web";
import { DIProvider, resolveTransport } from "./di";
import { RootErrorBoundary, installGlobalErrorLogging } from "./RootErrorBoundary";
import "@/shared/styles/theme.css";
@ -19,6 +19,7 @@ if (!root) {
// project id). When the param is present and valid, render only that view — it
// follows the main window's focused project; otherwise the full app.
const viewParams = parseViewWindowParams(window.location.search);
const webSurfaceParams = parseWebSurfaceWindowParams(window.location.search);
// Ticket #74, F1: publish the transport this bundle was built for. `define`
// inlines it as a literal, and assigning it is a side effect, so it survives
@ -39,7 +40,9 @@ ReactDOM.createRoot(root).render(
<React.StrictMode>
<RootErrorBoundary>
<DIProvider>
{isWeb ? (
{isWeb && webSurfaceParams ? (
<WebSurfaceWindow params={webSurfaceParams} />
) : isWeb ? (
<WebApp />
) : viewParams?.kind === "panel" ? (
<ViewWindow panel={viewParams.panel} />

View File

@ -7,7 +7,7 @@
* that selecting an entry routes the main surface without ever crashing on a
* desktop-only action.
*/
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import type { Gateways } from "@/ports";
@ -68,6 +68,27 @@ describe("WebWorkspace « Panneaux » menu (#90)", () => {
expect(screen.getByRole("button", { name: "Surface Travail" })).toBeTruthy();
});
it("offers #204 open here/tab/window controls and falls back to a tab", async () => {
const open = vi
.spyOn(window, "open")
.mockReturnValueOnce(null)
.mockReturnValueOnce({ closed: false } as Window);
renderPairedWebApp(await seededGateways());
fireEvent.click(await screen.findByText("Demo"));
expect(await screen.findByText("Surface Travail")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Fenêtre" }));
expect(open).toHaveBeenCalledTimes(2);
expect(open.mock.calls[0][0]).toContain("webPanel=work");
expect(open.mock.calls[0][1]).toBe("idea-web-work");
expect(open.mock.calls[1][1]).toBe("_blank");
expect(await screen.findByText("Fenêtre bloquée — ouverture en onglet.")).toBeTruthy();
open.mockRestore();
});
it("only enables Projets while no project is open, with a help note", async () => {
renderPairedWebApp(await seededGateways());

View File

@ -0,0 +1,71 @@
import { useCallback, useEffect, useState } from "react";
import { Panel } from "@/shared";
import { disconnectWebLive, getWebSession, type WebSession } from "@/adapters/http";
import { PairingScreen } from "./PairingScreen";
import { WebProjectPanelBody } from "./WebWorkspace";
import { WEB_PANEL_META, type WebOpenablePanelId } from "./webSurfaceCatalog";
import type { WebSurfaceWindowParams } from "./webSurfaceNavigation";
export interface WebSurfaceWindowProps {
params: WebSurfaceWindowParams;
session?: WebSession;
}
export function WebSurfaceWindow({ params, session }: WebSurfaceWindowProps) {
const webSession = session ?? getWebSession();
const [paired, setPaired] = useState(() => webSession.isPaired());
const [panel, setPanel] = useState<WebOpenablePanelId>(params.panel);
const [focusSprintId, setFocusSprintId] = useState<string | null>(
params.focusSprintId ?? null,
);
useEffect(() => {
return webSession.onUnauthorized(() => {
disconnectWebLive();
setPaired(false);
});
}, [webSession]);
const viewSprintTickets = useCallback((sprintId: string) => {
setFocusSprintId(sprintId);
setPanel("tickets");
}, []);
return (
<div className="flex h-full flex-col bg-canvas text-content">
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border bg-surface px-4 py-2.5">
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold text-content">
{WEB_PANEL_META[panel].label}
</h1>
<p className="truncate text-xs text-muted">{params.projectName}</p>
</div>
<span className="shrink-0 rounded-md bg-raised px-1.5 py-0.5 text-[0.65rem] font-medium uppercase text-muted">
web
</span>
</header>
<main className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
{!paired ? (
<div className="mx-auto w-full max-w-xl">
<PairingScreen session={webSession} onPaired={() => setPaired(true)} />
</div>
) : params.projectId && params.projectRoot ? (
<WebProjectPanelBody
panel={panel}
projectId={params.projectId}
root={params.projectRoot}
focusSprintId={focusSprintId}
onViewSprintTickets={viewSprintTickets}
/>
) : (
<Panel>
<p className="text-sm text-muted">
Cette surface web ne contient pas de projet valide.
</p>
</Panel>
)}
</main>
</div>
);
}

View File

@ -1,14 +1,15 @@
/**
* Live web workspace — ticket #13/#86, extended by #90 (missing menus)
* and #203 (desktop shell parity).
* and #203/#204 (desktop shell parity + browser-openable surfaces).
*
* After pairing: list/create projects (web variant — no native folder browse,
* see {@link WebProjectsPanel}), open one read-only, and route the main surface
* through the **"Panneaux" menu** (#90) across the transport-neutral panels also
* used by the desktop `ProjectsView` (context/tickets/sprints/agents/templates/
* skills/permissions/memory/git/projects) plus the web-specific live work-state
* view below. Selecting a panel replaces the main surface — never a floating
* window or dock (desktop-only chrome, out of scope for web, per Architect).
* view below. Selecting a panel replaces the main surface; project-scoped
* surfaces can also be opened into browser tabs/windows (#204), without using
* the desktop-only Tauri window gateway.
*
* The live work-state ("Travail") panel keeps its own bespoke, mobile-adapted
* implementation rather than the desktop `ProjectWorkStatePanel`: that component's
@ -46,6 +47,18 @@ import { WebTicketsView } from "./tickets/WebTicketsView";
import { WebSprintsView } from "./tickets/WebSprintsView";
import { WebProjectsPanel } from "./WebProjectsPanel";
import { WebMenuSheet } from "./WebMenuSheet";
import {
WEB_PANEL_LABEL,
WEB_PANEL_META,
WEB_PANEL_ORDER,
isWebOpenablePanel,
type WebOpenablePanelId,
type WebProjectPanelId,
} from "./webSurfaceCatalog";
import {
openWebSurfaceInBrowser,
type WebSurfaceOpenTarget,
} from "./webSurfaceNavigation";
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
@ -54,135 +67,13 @@ function describe(e: unknown): string {
return String(e);
}
/** The surfaces reachable from the web shell navigation (#90/#203). */
export type WebProjectPanelId =
| "context"
| "work"
| "tickets"
| "sprints"
| "agents"
| "templates"
| "skills"
| "permissions"
| "memory"
| "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",
"work",
"tickets",
"sprints",
"agents",
"templates",
"skills",
"permissions",
"memory",
"git",
"projects",
];
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);
const [error, setError] = useState<string | null>(null);
const [openId, setOpenId] = useState<string | null>(null);
const [panel, setPanel] = useState<WebProjectPanelId>("projects");
const [openNotice, setOpenNotice] = useState<string | null>(null);
// Set by the Sprints panel's "Voir tickets"; consumed once by the Tickets panel.
const [focusSprintId, setFocusSprintId] = useState<string | null>(null);
@ -237,6 +128,29 @@ export function WebWorkspace() {
[project, refresh],
);
const openSurface = useCallback(
(target: WebSurfaceOpenTarget) => {
setOpenNotice(null);
if (!openProject || !isWebOpenablePanel(panel)) return;
const outcome = openWebSurfaceInBrowser(
{
panel,
projectId: openProject.id,
projectRoot: openProject.root,
projectName: openProject.name,
focusSprintId: panel === "tickets" ? focusSprintId : null,
},
target,
);
if (target === "window" && outcome.result === "tab") {
setOpenNotice("Fenêtre bloquée — ouverture en onglet.");
} else if (outcome.result === "blocked") {
setOpenNotice("Le navigateur a bloqué l'ouverture de la surface.");
}
},
[focusSprintId, openProject, panel],
);
return (
<div
data-testid="web-workspace"
@ -249,6 +163,9 @@ export function WebWorkspace() {
openProject={openProject}
projectCount={projects?.length ?? null}
onShowProjects={() => setPanel("projects")}
onOpenHere={() => setOpenNotice(null)}
onOpenTab={() => openSurface("tab")}
onOpenWindow={() => openSurface("window")}
/>
<div className="flex min-h-0 flex-1 overflow-hidden md:grid md:grid-cols-[13rem_minmax(0,1fr)_20rem]">
@ -271,6 +188,14 @@ export function WebWorkspace() {
onSelect={setPanel}
/>
</div>
{openNotice && (
<div
role="status"
className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning"
>
{openNotice}
</div>
)}
{error && <WebWorkspaceError message={error} />}
<WebWorkspaceSurface
panel={panel}
@ -342,7 +267,7 @@ function WebWorkspaceSurface({
}
return (
<WebPanelBody
<WebProjectPanelBody
panel={panel}
projectId={openId}
root={openRoot}
@ -365,12 +290,19 @@ function WebDesktopTopBar({
openProject,
projectCount,
onShowProjects,
onOpenHere,
onOpenTab,
onOpenWindow,
}: {
activePanel: WebProjectPanelId;
openProject: Project | null;
projectCount: number | null;
onShowProjects: () => void;
onOpenHere: () => void;
onOpenTab: () => void;
onOpenWindow: () => void;
}) {
const canOpenSurface = openProject !== null && isWebOpenablePanel(activePanel);
return (
<div
data-testid="web-shell-topbar"
@ -385,6 +317,12 @@ function WebDesktopTopBar({
</span>
</div>
<div className="flex shrink-0 items-center gap-2 text-xs text-muted">
<WebSurfaceOpenControls
disabled={!canOpenSurface}
onOpenHere={onOpenHere}
onOpenTab={onOpenTab}
onOpenWindow={onOpenWindow}
/>
<span>
{projectCount === null ? "Projets…" : `${projectCount} projet${projectCount > 1 ? "s" : ""}`}
</span>
@ -396,6 +334,54 @@ function WebDesktopTopBar({
);
}
function WebSurfaceOpenControls({
disabled,
onOpenHere,
onOpenTab,
onOpenWindow,
}: {
disabled: boolean;
onOpenHere: () => void;
onOpenTab: () => void;
onOpenWindow: () => void;
}) {
return (
<div
aria-label="Ouvrir la surface"
className="flex items-center overflow-hidden rounded-md border border-border bg-canvas"
>
<Button
variant="ghost"
size="sm"
aria-pressed="true"
disabled={disabled}
className="rounded-none border-r border-border"
onClick={onOpenHere}
>
Ici
</Button>
<Button
variant="ghost"
size="sm"
disabled={disabled}
className="rounded-none border-r border-border"
onClick={onOpenTab}
>
Onglet
</Button>
<Button
variant="ghost"
size="sm"
disabled={disabled}
className="rounded-none"
onClick={onOpenWindow}
>
Fenêtre
</Button>
</div>
);
}
function WebPrimaryRail({
activePanel,
hasProject,
@ -604,14 +590,14 @@ function WebPanelMenuButton({
}
/** Renders the requested panel's body for the opened project. */
function WebPanelBody({
export function WebProjectPanelBody({
panel,
projectId,
root,
focusSprintId,
onViewSprintTickets,
}: {
panel: Exclude<WebProjectPanelId, "projects">;
panel: WebOpenablePanelId;
projectId: string;
root: string | null;
focusSprintId: string | null;

View File

@ -6,5 +6,11 @@
export { WebApp } from "./WebApp";
export { PairingScreen } from "./PairingScreen";
export { WebWorkspace } from "./WebWorkspace";
export { WebSurfaceWindow } from "./WebSurfaceWindow";
export {
buildWebSurfaceWindowUrl,
openWebSurfaceInBrowser,
parseWebSurfaceWindowParams,
} from "./webSurfaceNavigation";
export { WebAgentCell } from "./WebAgentCell";
export { TerminalKeyBar } from "./TerminalKeyBar";

View File

@ -0,0 +1,133 @@
/** Shared catalogue for web shell surfaces (#203/#204). */
export type WebProjectPanelId =
| "context"
| "work"
| "tickets"
| "sprints"
| "agents"
| "templates"
| "skills"
| "permissions"
| "memory"
| "git"
| "projects";
export type WebOpenablePanelId = Exclude<WebProjectPanelId, "projects">;
export type WebPanelPlacement = "primary" | "secondary";
export interface WebPanelMeta {
id: WebProjectPanelId;
label: string;
shortLabel: string;
description: string;
placement: WebPanelPlacement;
}
/** Menu order + French labels (ticket #78 — the human UI defaults to French). */
export const WEB_PANEL_ORDER: WebProjectPanelId[] = [
"context",
"work",
"tickets",
"sprints",
"agents",
"templates",
"skills",
"permissions",
"memory",
"git",
"projects",
];
export 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",
},
};
export 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 const WEB_OPENABLE_PANEL_ORDER: WebOpenablePanelId[] = WEB_PANEL_ORDER.filter(
(id): id is WebOpenablePanelId => id !== "projects",
);
export function isWebOpenablePanel(panel: string): panel is WebOpenablePanelId {
return (WEB_OPENABLE_PANEL_ORDER as string[]).includes(panel);
}

View File

@ -0,0 +1,64 @@
import { describe, expect, it, vi } from "vitest";
import {
buildWebSurfaceWindowUrl,
openWebSurfaceInBrowser,
parseWebSurfaceWindowParams,
} from "./webSurfaceNavigation";
describe("web surface navigation (#204)", () => {
it("builds and parses a detached web surface URL", () => {
const url = buildWebSurfaceWindowUrl({
baseHref: "https://idea.local/workspace?old=1#ignored",
panel: "tickets",
projectId: "p-1",
projectRoot: "/srv/demo project",
projectName: "Demo",
focusSprintId: "s-1",
});
expect(url).toBe(
"https://idea.local/workspace?webPanel=tickets&projectId=p-1&projectRoot=%2Fsrv%2Fdemo+project&projectName=Demo&focusSprintId=s-1",
);
expect(parseWebSurfaceWindowParams(new URL(url).search)).toEqual({
panel: "tickets",
projectId: "p-1",
projectRoot: "/srv/demo project",
projectName: "Demo",
focusSprintId: "s-1",
});
});
it("rejects non-openable or incomplete detached surface URLs", () => {
expect(parseWebSurfaceWindowParams("?webPanel=projects&projectId=p&projectRoot=/x&projectName=X")).toBeNull();
expect(parseWebSurfaceWindowParams("?webPanel=tickets&projectId=p&projectRoot=/x")).toBeNull();
});
it("falls back from browser window to tab when the popup is blocked", () => {
const open = vi
.fn((..._args: Parameters<Window["open"]>): ReturnType<Window["open"]> => null)
.mockReturnValueOnce(null)
.mockReturnValueOnce({ closed: false } as Window);
const outcome = openWebSurfaceInBrowser(
{
baseHref: "https://idea.local/",
panel: "git",
projectId: "p-1",
projectRoot: "/srv/demo",
projectName: "Demo",
},
"window",
open,
);
expect(outcome.result).toBe("tab");
expect(open).toHaveBeenNthCalledWith(
1,
outcome.url,
"idea-web-git",
"popup,width=1280,height=900,noopener,noreferrer",
);
expect(open).toHaveBeenNthCalledWith(2, outcome.url, "_blank", "noopener,noreferrer");
});
});

View File

@ -0,0 +1,97 @@
import {
isWebOpenablePanel,
type WebOpenablePanelId,
} from "./webSurfaceCatalog";
export type WebSurfaceOpenTarget = "tab" | "window";
export type WebSurfaceOpenResult = "tab" | "window" | "blocked";
export interface WebSurfaceWindowParams {
panel: WebOpenablePanelId;
projectId: string;
projectRoot: string;
projectName: string;
focusSprintId?: string | null;
}
export interface OpenWebSurfaceInput extends WebSurfaceWindowParams {
baseHref?: string;
}
export interface OpenWebSurfaceOutcome {
result: WebSurfaceOpenResult;
url: string;
}
type BrowserOpen = Window["open"];
const QUERY_PANEL = "webPanel";
const QUERY_PROJECT_ID = "projectId";
const QUERY_PROJECT_ROOT = "projectRoot";
const QUERY_PROJECT_NAME = "projectName";
const QUERY_FOCUS_SPRINT = "focusSprintId";
export function parseWebSurfaceWindowParams(search: string): WebSurfaceWindowParams | null {
const params = new URLSearchParams(search);
const panel = params.get(QUERY_PANEL);
const projectId = params.get(QUERY_PROJECT_ID);
const projectRoot = params.get(QUERY_PROJECT_ROOT);
const projectName = params.get(QUERY_PROJECT_NAME);
if (!panel || !isWebOpenablePanel(panel) || !projectId || !projectRoot || !projectName) {
return null;
}
return {
panel,
projectId,
projectRoot,
projectName,
focusSprintId: params.get(QUERY_FOCUS_SPRINT),
};
}
export function buildWebSurfaceWindowUrl(input: OpenWebSurfaceInput): string {
const href =
input.baseHref ??
(typeof window !== "undefined" && window.location
? window.location.href
: "http://localhost/");
const url = new URL(href);
url.hash = "";
url.search = "";
url.searchParams.set(QUERY_PANEL, input.panel);
url.searchParams.set(QUERY_PROJECT_ID, input.projectId);
url.searchParams.set(QUERY_PROJECT_ROOT, input.projectRoot);
url.searchParams.set(QUERY_PROJECT_NAME, input.projectName);
if (input.focusSprintId) {
url.searchParams.set(QUERY_FOCUS_SPRINT, input.focusSprintId);
}
return url.toString();
}
export function openWebSurfaceInBrowser(
input: OpenWebSurfaceInput,
target: WebSurfaceOpenTarget,
open: BrowserOpen = window.open.bind(window),
): OpenWebSurfaceOutcome {
const url = buildWebSurfaceWindowUrl(input);
if (target === "tab") {
return {
url,
result: open(url, "_blank", "noopener,noreferrer") ? "tab" : "blocked",
};
}
const popup = open(
url,
`idea-web-${input.panel}`,
"popup,width=1280,height=900,noopener,noreferrer",
);
if (popup) return { url, result: "window" };
return {
url,
result: open(url, "_blank", "noopener,noreferrer") ? "tab" : "blocked",
};
}