diff --git a/frontend/src/app/main.tsx b/frontend/src/app/main.tsx index 6b444dca..a415314a 100644 --- a/frontend/src/app/main.tsx +++ b/frontend/src/app/main.tsx @@ -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( - {isWeb ? ( + {isWeb && webSurfaceParams ? ( + + ) : isWeb ? ( ) : viewParams?.kind === "panel" ? ( diff --git a/frontend/src/features/web/WebMenus.test.tsx b/frontend/src/features/web/WebMenus.test.tsx index 4f2cbe6b..b65cfd16 100644 --- a/frontend/src/features/web/WebMenus.test.tsx +++ b/frontend/src/features/web/WebMenus.test.tsx @@ -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()); diff --git a/frontend/src/features/web/WebSurfaceWindow.tsx b/frontend/src/features/web/WebSurfaceWindow.tsx new file mode 100644 index 00000000..2c5c6d64 --- /dev/null +++ b/frontend/src/features/web/WebSurfaceWindow.tsx @@ -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(params.panel); + const [focusSprintId, setFocusSprintId] = useState( + params.focusSprintId ?? null, + ); + + useEffect(() => { + return webSession.onUnauthorized(() => { + disconnectWebLive(); + setPaired(false); + }); + }, [webSession]); + + const viewSprintTickets = useCallback((sprintId: string) => { + setFocusSprintId(sprintId); + setPanel("tickets"); + }, []); + + return ( +
+
+
+

+ {WEB_PANEL_META[panel].label} +

+

{params.projectName}

+
+ + web + +
+
+ {!paired ? ( +
+ setPaired(true)} /> +
+ ) : params.projectId && params.projectRoot ? ( + + ) : ( + +

+ Cette surface web ne contient pas de projet valide. +

+
+ )} +
+
+ ); +} diff --git a/frontend/src/features/web/WebWorkspace.tsx b/frontend/src/features/web/WebWorkspace.tsx index 7a76c1bb..ce7aa1fd 100644 --- a/frontend/src/features/web/WebWorkspace.tsx +++ b/frontend/src/features/web/WebWorkspace.tsx @@ -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 = { - 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 = Object.fromEntries( - WEB_PANEL_ORDER.map((id) => [id, WEB_PANEL_META[id].label]), -) as Record; - export function WebWorkspace() { const { project } = useGateways(); const [projects, setProjects] = useState(null); const [error, setError] = useState(null); const [openId, setOpenId] = useState(null); const [panel, setPanel] = useState("projects"); + const [openNotice, setOpenNotice] = useState(null); // Set by the Sprints panel's "Voir tickets"; consumed once by the Tickets panel. const [focusSprintId, setFocusSprintId] = useState(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 (
setPanel("projects")} + onOpenHere={() => setOpenNotice(null)} + onOpenTab={() => openSurface("tab")} + onOpenWindow={() => openSurface("window")} />
@@ -271,6 +188,14 @@ export function WebWorkspace() { onSelect={setPanel} />
+ {openNotice && ( +
+ {openNotice} +
+ )} {error && } void; + onOpenHere: () => void; + onOpenTab: () => void; + onOpenWindow: () => void; }) { + const canOpenSurface = openProject !== null && isWebOpenablePanel(activePanel); return (
+ {projectCount === null ? "Projets…" : `${projectCount} projet${projectCount > 1 ? "s" : ""}`} @@ -396,6 +334,54 @@ function WebDesktopTopBar({ ); } +function WebSurfaceOpenControls({ + disabled, + onOpenHere, + onOpenTab, + onOpenWindow, +}: { + disabled: boolean; + onOpenHere: () => void; + onOpenTab: () => void; + onOpenWindow: () => void; +}) { + return ( +
+ + + +
+ ); +} + 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; + panel: WebOpenablePanelId; projectId: string; root: string | null; focusSprintId: string | null; diff --git a/frontend/src/features/web/index.ts b/frontend/src/features/web/index.ts index f3d55949..33e20011 100644 --- a/frontend/src/features/web/index.ts +++ b/frontend/src/features/web/index.ts @@ -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"; diff --git a/frontend/src/features/web/webSurfaceCatalog.ts b/frontend/src/features/web/webSurfaceCatalog.ts new file mode 100644 index 00000000..e871c3e6 --- /dev/null +++ b/frontend/src/features/web/webSurfaceCatalog.ts @@ -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; + +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 = { + 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 = Object.fromEntries( + WEB_PANEL_ORDER.map((id) => [id, WEB_PANEL_META[id].label]), +) as Record; + +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); +} diff --git a/frontend/src/features/web/webSurfaceNavigation.test.ts b/frontend/src/features/web/webSurfaceNavigation.test.ts new file mode 100644 index 00000000..e7120d1d --- /dev/null +++ b/frontend/src/features/web/webSurfaceNavigation.test.ts @@ -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): ReturnType => 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"); + }); +}); diff --git a/frontend/src/features/web/webSurfaceNavigation.ts b/frontend/src/features/web/webSurfaceNavigation.ts new file mode 100644 index 00000000..b11ca9e8 --- /dev/null +++ b/frontend/src/features/web/webSurfaceNavigation.ts @@ -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", + }; +}