feat(web): open project surfaces in browser targets — #204
This commit is contained in:
@ -5,7 +5,7 @@ import ReactDOM from "react-dom/client";
|
|||||||
|
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import { PluginLayoutWindow, ViewWindow, parseViewWindowParams } from "./ViewWindow";
|
import { PluginLayoutWindow, ViewWindow, parseViewWindowParams } from "./ViewWindow";
|
||||||
import { WebApp } from "@/features/web";
|
import { WebApp, WebSurfaceWindow, parseWebSurfaceWindowParams } from "@/features/web";
|
||||||
import { DIProvider, resolveTransport } from "./di";
|
import { DIProvider, resolveTransport } from "./di";
|
||||||
import { RootErrorBoundary, installGlobalErrorLogging } from "./RootErrorBoundary";
|
import { RootErrorBoundary, installGlobalErrorLogging } from "./RootErrorBoundary";
|
||||||
import "@/shared/styles/theme.css";
|
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
|
// 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.
|
// follows the main window's focused project; otherwise the full app.
|
||||||
const viewParams = parseViewWindowParams(window.location.search);
|
const viewParams = parseViewWindowParams(window.location.search);
|
||||||
|
const webSurfaceParams = parseWebSurfaceWindowParams(window.location.search);
|
||||||
|
|
||||||
// Ticket #74, F1: publish the transport this bundle was built for. `define`
|
// 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
|
// 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>
|
<React.StrictMode>
|
||||||
<RootErrorBoundary>
|
<RootErrorBoundary>
|
||||||
<DIProvider>
|
<DIProvider>
|
||||||
{isWeb ? (
|
{isWeb && webSurfaceParams ? (
|
||||||
|
<WebSurfaceWindow params={webSurfaceParams} />
|
||||||
|
) : isWeb ? (
|
||||||
<WebApp />
|
<WebApp />
|
||||||
) : viewParams?.kind === "panel" ? (
|
) : viewParams?.kind === "panel" ? (
|
||||||
<ViewWindow panel={viewParams.panel} />
|
<ViewWindow panel={viewParams.panel} />
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
* that selecting an entry routes the main surface without ever crashing on a
|
* that selecting an entry routes the main surface without ever crashing on a
|
||||||
* desktop-only action.
|
* 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 { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
|
||||||
import type { Gateways } from "@/ports";
|
import type { Gateways } from "@/ports";
|
||||||
@ -68,6 +68,27 @@ describe("WebWorkspace « Panneaux » menu (#90)", () => {
|
|||||||
expect(screen.getByRole("button", { name: "Surface Travail" })).toBeTruthy();
|
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 () => {
|
it("only enables Projets while no project is open, with a help note", async () => {
|
||||||
renderPairedWebApp(await seededGateways());
|
renderPairedWebApp(await seededGateways());
|
||||||
|
|
||||||
|
|||||||
71
frontend/src/features/web/WebSurfaceWindow.tsx
Normal file
71
frontend/src/features/web/WebSurfaceWindow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,14 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* 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).
|
* and #203/#204 (desktop shell parity + browser-openable surfaces).
|
||||||
*
|
*
|
||||||
* 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
|
||||||
* through the **"Panneaux" menu** (#90) across the transport-neutral panels also
|
* through the **"Panneaux" menu** (#90) across the transport-neutral panels also
|
||||||
* used by the desktop `ProjectsView` (context/tickets/sprints/agents/templates/
|
* used by the desktop `ProjectsView` (context/tickets/sprints/agents/templates/
|
||||||
* skills/permissions/memory/git/projects) plus the web-specific live work-state
|
* skills/permissions/memory/git/projects) plus the web-specific live work-state
|
||||||
* view below. Selecting a panel replaces the main surface — never a floating
|
* view below. Selecting a panel replaces the main surface; project-scoped
|
||||||
* window or dock (desktop-only chrome, out of scope for web, per Architect).
|
* 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
|
* The live work-state ("Travail") panel keeps its own bespoke, mobile-adapted
|
||||||
* implementation rather than the desktop `ProjectWorkStatePanel`: that component's
|
* implementation rather than the desktop `ProjectWorkStatePanel`: that component's
|
||||||
@ -46,6 +47,18 @@ import { WebTicketsView } from "./tickets/WebTicketsView";
|
|||||||
import { WebSprintsView } from "./tickets/WebSprintsView";
|
import { WebSprintsView } from "./tickets/WebSprintsView";
|
||||||
import { WebProjectsPanel } from "./WebProjectsPanel";
|
import { WebProjectsPanel } from "./WebProjectsPanel";
|
||||||
import { WebMenuSheet } from "./WebMenuSheet";
|
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 {
|
function describe(e: unknown): string {
|
||||||
if (e && typeof e === "object" && "message" in e) {
|
if (e && typeof e === "object" && "message" in e) {
|
||||||
@ -54,135 +67,13 @@ function describe(e: unknown): string {
|
|||||||
return String(e);
|
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() {
|
export function WebWorkspace() {
|
||||||
const { project } = useGateways();
|
const { project } = useGateways();
|
||||||
const [projects, setProjects] = useState<Project[] | null>(null);
|
const [projects, setProjects] = useState<Project[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
const [panel, setPanel] = useState<WebProjectPanelId>("projects");
|
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.
|
// Set by the Sprints panel's "Voir tickets"; consumed once by the Tickets panel.
|
||||||
const [focusSprintId, setFocusSprintId] = useState<string | null>(null);
|
const [focusSprintId, setFocusSprintId] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -237,6 +128,29 @@ export function WebWorkspace() {
|
|||||||
[project, refresh],
|
[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 (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="web-workspace"
|
data-testid="web-workspace"
|
||||||
@ -249,6 +163,9 @@ export function WebWorkspace() {
|
|||||||
openProject={openProject}
|
openProject={openProject}
|
||||||
projectCount={projects?.length ?? null}
|
projectCount={projects?.length ?? null}
|
||||||
onShowProjects={() => setPanel("projects")}
|
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]">
|
<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}
|
onSelect={setPanel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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} />}
|
{error && <WebWorkspaceError message={error} />}
|
||||||
<WebWorkspaceSurface
|
<WebWorkspaceSurface
|
||||||
panel={panel}
|
panel={panel}
|
||||||
@ -342,7 +267,7 @@ function WebWorkspaceSurface({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WebPanelBody
|
<WebProjectPanelBody
|
||||||
panel={panel}
|
panel={panel}
|
||||||
projectId={openId}
|
projectId={openId}
|
||||||
root={openRoot}
|
root={openRoot}
|
||||||
@ -365,12 +290,19 @@ function WebDesktopTopBar({
|
|||||||
openProject,
|
openProject,
|
||||||
projectCount,
|
projectCount,
|
||||||
onShowProjects,
|
onShowProjects,
|
||||||
|
onOpenHere,
|
||||||
|
onOpenTab,
|
||||||
|
onOpenWindow,
|
||||||
}: {
|
}: {
|
||||||
activePanel: WebProjectPanelId;
|
activePanel: WebProjectPanelId;
|
||||||
openProject: Project | null;
|
openProject: Project | null;
|
||||||
projectCount: number | null;
|
projectCount: number | null;
|
||||||
onShowProjects: () => void;
|
onShowProjects: () => void;
|
||||||
|
onOpenHere: () => void;
|
||||||
|
onOpenTab: () => void;
|
||||||
|
onOpenWindow: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const canOpenSurface = openProject !== null && isWebOpenablePanel(activePanel);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="web-shell-topbar"
|
data-testid="web-shell-topbar"
|
||||||
@ -385,6 +317,12 @@ function WebDesktopTopBar({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2 text-xs text-muted">
|
<div className="flex shrink-0 items-center gap-2 text-xs text-muted">
|
||||||
|
<WebSurfaceOpenControls
|
||||||
|
disabled={!canOpenSurface}
|
||||||
|
onOpenHere={onOpenHere}
|
||||||
|
onOpenTab={onOpenTab}
|
||||||
|
onOpenWindow={onOpenWindow}
|
||||||
|
/>
|
||||||
<span>
|
<span>
|
||||||
{projectCount === null ? "Projets…" : `${projectCount} projet${projectCount > 1 ? "s" : ""}`}
|
{projectCount === null ? "Projets…" : `${projectCount} projet${projectCount > 1 ? "s" : ""}`}
|
||||||
</span>
|
</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({
|
function WebPrimaryRail({
|
||||||
activePanel,
|
activePanel,
|
||||||
hasProject,
|
hasProject,
|
||||||
@ -604,14 +590,14 @@ function WebPanelMenuButton({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Renders the requested panel's body for the opened project. */
|
/** Renders the requested panel's body for the opened project. */
|
||||||
function WebPanelBody({
|
export function WebProjectPanelBody({
|
||||||
panel,
|
panel,
|
||||||
projectId,
|
projectId,
|
||||||
root,
|
root,
|
||||||
focusSprintId,
|
focusSprintId,
|
||||||
onViewSprintTickets,
|
onViewSprintTickets,
|
||||||
}: {
|
}: {
|
||||||
panel: Exclude<WebProjectPanelId, "projects">;
|
panel: WebOpenablePanelId;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
root: string | null;
|
root: string | null;
|
||||||
focusSprintId: string | null;
|
focusSprintId: string | null;
|
||||||
|
|||||||
@ -6,5 +6,11 @@
|
|||||||
export { WebApp } from "./WebApp";
|
export { WebApp } from "./WebApp";
|
||||||
export { PairingScreen } from "./PairingScreen";
|
export { PairingScreen } from "./PairingScreen";
|
||||||
export { WebWorkspace } from "./WebWorkspace";
|
export { WebWorkspace } from "./WebWorkspace";
|
||||||
|
export { WebSurfaceWindow } from "./WebSurfaceWindow";
|
||||||
|
export {
|
||||||
|
buildWebSurfaceWindowUrl,
|
||||||
|
openWebSurfaceInBrowser,
|
||||||
|
parseWebSurfaceWindowParams,
|
||||||
|
} from "./webSurfaceNavigation";
|
||||||
export { WebAgentCell } from "./WebAgentCell";
|
export { WebAgentCell } from "./WebAgentCell";
|
||||||
export { TerminalKeyBar } from "./TerminalKeyBar";
|
export { TerminalKeyBar } from "./TerminalKeyBar";
|
||||||
|
|||||||
133
frontend/src/features/web/webSurfaceCatalog.ts
Normal file
133
frontend/src/features/web/webSurfaceCatalog.ts
Normal 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);
|
||||||
|
}
|
||||||
64
frontend/src/features/web/webSurfaceNavigation.test.ts
Normal file
64
frontend/src/features/web/webSurfaceNavigation.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
97
frontend/src/features/web/webSurfaceNavigation.ts
Normal file
97
frontend/src/features/web/webSurfaceNavigation.ts
Normal 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user