From 3b8ce66e33715a4b85b6439810dbe746bf495e54 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 15 Aug 2026 01:24:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20persist=20workspace=20presentation?= =?UTF-8?q?=20across=20browser=20hosts=20=E2=80=94=20#205?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/web/WebMenus.test.tsx | 25 ++- frontend/src/features/web/WebWorkspace.tsx | 193 ++++++++++++++++-- .../features/web/webWorkspaceSession.test.ts | 48 +++++ .../src/features/web/webWorkspaceSession.ts | 102 +++++++++ .../src/features/web/webWorkspaceSync.test.ts | 62 ++++++ frontend/src/features/web/webWorkspaceSync.ts | 125 ++++++++++++ 6 files changed, 542 insertions(+), 13 deletions(-) create mode 100644 frontend/src/features/web/webWorkspaceSession.test.ts create mode 100644 frontend/src/features/web/webWorkspaceSession.ts create mode 100644 frontend/src/features/web/webWorkspaceSync.test.ts create mode 100644 frontend/src/features/web/webWorkspaceSync.ts diff --git a/frontend/src/features/web/WebMenus.test.tsx b/frontend/src/features/web/WebMenus.test.tsx index b65cfd16..66e32fc5 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, vi } from "vitest"; +import { beforeEach, describe, it, expect, vi } from "vitest"; import { fireEvent, render, screen, within } from "@testing-library/react"; import type { Gateways } from "@/ports"; @@ -34,6 +34,11 @@ const okFetch: FetchLike = async () => ({ text: async () => "{}", }); +beforeEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); +}); + async function seededGateways(): Promise { const gateways = createMockGateways(); await gateways.project.createProject("Demo", "/srv/demo"); @@ -89,6 +94,24 @@ describe("WebWorkspace « Panneaux » menu (#90)", () => { open.mockRestore(); }); + it("restores the #205 presentation state per project", async () => { + const gateways = await seededGateways(); + const first = renderPairedWebApp(gateways); + + fireEvent.click(await screen.findByText("Demo")); + fireEvent.click(await screen.findByRole("button", { name: "Surface Tickets" })); + fireEvent.click(screen.getByRole("button", { name: "Rail gauche" })); + fireEvent.click(screen.getByRole("button", { name: "Rail droit" })); + + first.unmount(); + renderPairedWebApp(gateways); + + fireEvent.click(await screen.findByText("Demo")); + expect(await screen.findByText("Surface Tickets")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Rail gauche" }).getAttribute("aria-pressed")).toBe("false"); + expect(screen.getByRole("button", { name: "Rail droit" }).getAttribute("aria-pressed")).toBe("false"); + }); + it("only enables Projets while no project is open, with a help note", async () => { renderPairedWebApp(await seededGateways()); diff --git a/frontend/src/features/web/WebWorkspace.tsx b/frontend/src/features/web/WebWorkspace.tsx index ce7aa1fd..00cca113 100644 --- a/frontend/src/features/web/WebWorkspace.tsx +++ b/frontend/src/features/web/WebWorkspace.tsx @@ -21,7 +21,7 @@ * re-synchronises the read-model after a WS outage. */ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AgentWorkState, @@ -59,6 +59,15 @@ import { openWebSurfaceInBrowser, type WebSurfaceOpenTarget, } from "./webSurfaceNavigation"; +import { + DEFAULT_WEB_WORKSPACE_PRESENTATION, + loadWebWorkspacePresentation, + saveWebWorkspacePresentation, + withOpenedSurface, + type WebSurfacePlacement, + type WebSurfacePlacements, +} from "./webWorkspaceSession"; +import { createWebWorkspaceSyncBus, type WebWorkspaceSyncProject } from "./webWorkspaceSync"; function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { @@ -74,6 +83,11 @@ export function WebWorkspace() { const [openId, setOpenId] = useState(null); const [panel, setPanel] = useState("projects"); const [openNotice, setOpenNotice] = useState(null); + const [primaryRailOpen, setPrimaryRailOpen] = useState(true); + const [secondaryRailOpen, setSecondaryRailOpen] = useState(true); + const [openedSurfaces, setOpenedSurfaces] = useState({}); + const syncBus = useMemo(() => createWebWorkspaceSyncBus(), []); + const projectsRef = useRef(null); // Set by the Sprints panel's "Voir tickets"; consumed once by the Tickets panel. const [focusSprintId, setFocusSprintId] = useState(null); @@ -82,6 +96,33 @@ export function WebWorkspace() { () => projects?.find((p) => p.id === openId) ?? null, [openId, projects], ); + projectsRef.current = projects; + + const loadPresentationForProject = useCallback((projectId: string) => { + const saved = loadWebWorkspacePresentation(projectId); + setPanel(saved.activePanel); + setPrimaryRailOpen(saved.primaryRailOpen); + setSecondaryRailOpen(saved.secondaryRailOpen); + setOpenedSurfaces(saved.openedSurfaces); + return saved; + }, []); + + const syncProject = useCallback((p: Project): WebWorkspaceSyncProject => ({ + id: p.id, + name: p.name, + root: p.root, + }), []); + + const publishFocus = useCallback( + (p: Project, activePanel: WebProjectPanelId) => { + syncBus.publish({ + type: "focus", + project: syncProject(p), + activePanel, + }); + }, + [syncBus, syncProject], + ); const refresh = useCallback(async () => { setError(null); @@ -96,6 +137,47 @@ export function WebWorkspace() { void refresh(); }, [refresh]); + useEffect(() => () => syncBus.close(), [syncBus]); + + useEffect(() => { + return syncBus.subscribe((event) => { + if (event.type === "focus") { + const knownProject = projectsRef.current?.find((p) => p.id === event.project.id); + if (!knownProject) return; + void project.openProject(knownProject.id).then(() => { + setOpenId(knownProject.id); + loadPresentationForProject(knownProject.id); + setPanel(event.activePanel); + }).catch(() => {}); + return; + } + const sameProjectOpen = openId === event.project.id; + if (event.type === "surface-opened" && sameProjectOpen) { + setOpenedSurfaces((cur) => withOpenedSurface(cur, event.panel, event.placement)); + } + }); + }, [loadPresentationForProject, openId, project, syncBus]); + + useEffect(() => { + if (!openProject) return; + saveWebWorkspacePresentation(openProject.id, { + version: DEFAULT_WEB_WORKSPACE_PRESENTATION.version, + activePanel: panel === "projects" ? DEFAULT_WEB_WORKSPACE_PRESENTATION.activePanel : panel, + primaryRailOpen, + secondaryRailOpen, + openedSurfaces, + }); + }, [openProject, openedSurfaces, panel, primaryRailOpen, secondaryRailOpen]); + + const selectPanel = useCallback( + (nextPanel: WebProjectPanelId) => { + setPanel(nextPanel); + setOpenNotice(null); + if (openProject) publishFocus(openProject, nextPanel); + }, + [openProject, publishFocus], + ); + const openReadOnly = useCallback( async (projectId: string) => { setError(null); @@ -104,13 +186,15 @@ export function WebWorkspace() { // Read-only: resolve the project server-side; the live panel then streams // its work-state. No layout/PTY is mounted here. await project.openProject(projectId); + const saved = loadPresentationForProject(projectId); setOpenId(projectId); - setPanel("work"); + const nextProject = projectsRef.current?.find((p) => p.id === projectId); + if (nextProject) publishFocus(nextProject, saved.activePanel); } catch (e) { setError(describe(e)); } }, - [project], + [loadPresentationForProject, project, publishFocus], ); const createProject = useCallback( @@ -142,13 +226,23 @@ export function WebWorkspace() { }, target, ); + if (outcome.result === "tab" || outcome.result === "window") { + const placement: WebSurfacePlacement = outcome.result; + setOpenedSurfaces((cur) => withOpenedSurface(cur, panel, placement)); + syncBus.publish({ + type: "surface-opened", + project: syncProject(openProject), + panel, + placement, + }); + } 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], + [focusSprintId, openProject, panel, syncBus, syncProject], ); return ( @@ -162,17 +256,30 @@ export function WebWorkspace() { activePanel={panel} openProject={openProject} projectCount={projects?.length ?? null} - onShowProjects={() => setPanel("projects")} + primaryRailOpen={primaryRailOpen} + secondaryRailOpen={secondaryRailOpen} + onTogglePrimaryRail={() => setPrimaryRailOpen((open) => !open)} + onToggleSecondaryRail={() => setSecondaryRailOpen((open) => !open)} + onShowProjects={() => selectPanel("projects")} onOpenHere={() => setOpenNotice(null)} onOpenTab={() => openSurface("tab")} onOpenWindow={() => openSurface("window")} /> -
+
{openNotice && ( @@ -208,7 +315,7 @@ export function WebWorkspace() { onRefreshProjects={() => void refresh()} onViewSprintTickets={(sprintId) => { setFocusSprintId(sprintId); - setPanel("tickets"); + selectPanel("tickets"); }} />
@@ -219,9 +326,12 @@ export function WebWorkspace() { openId={openId} openProject={openProject} activePanel={panel} + open={secondaryRailOpen} + openedSurfaces={openedSurfaces} onOpenProject={(id) => void openReadOnly(id)} onRefreshProjects={() => void refresh()} - onSelectPanel={setPanel} + onSelectPanel={selectPanel} + onOpenSurfaceHere={selectPanel} /> @@ -289,6 +399,10 @@ function WebDesktopTopBar({ activePanel, openProject, projectCount, + primaryRailOpen, + secondaryRailOpen, + onTogglePrimaryRail, + onToggleSecondaryRail, onShowProjects, onOpenHere, onOpenTab, @@ -297,6 +411,10 @@ function WebDesktopTopBar({ activePanel: WebProjectPanelId; openProject: Project | null; projectCount: number | null; + primaryRailOpen: boolean; + secondaryRailOpen: boolean; + onTogglePrimaryRail: () => void; + onToggleSecondaryRail: () => void; onShowProjects: () => void; onOpenHere: () => void; onOpenTab: () => void; @@ -317,6 +435,22 @@ function WebDesktopTopBar({
+ + void; }) { return (