feat(web): persist workspace presentation across browser hosts — #205

This commit is contained in:
2026-08-15 01:24:23 +02:00
parent 723e2987ef
commit 3b8ce66e33
6 changed files with 542 additions and 13 deletions

View File

@ -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<string | null>(null);
const [panel, setPanel] = useState<WebProjectPanelId>("projects");
const [openNotice, setOpenNotice] = useState<string | null>(null);
const [primaryRailOpen, setPrimaryRailOpen] = useState(true);
const [secondaryRailOpen, setSecondaryRailOpen] = useState(true);
const [openedSurfaces, setOpenedSurfaces] = useState<WebSurfacePlacements>({});
const syncBus = useMemo(() => createWebWorkspaceSyncBus(), []);
const projectsRef = useRef<Project[] | null>(null);
// Set by the Sprints panel's "Voir tickets"; consumed once by the Tickets panel.
const [focusSprintId, setFocusSprintId] = useState<string | null>(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")}
/>
<div className="flex min-h-0 flex-1 overflow-hidden md:grid md:grid-cols-[13rem_minmax(0,1fr)_20rem]">
<div
className={cn(
"flex min-h-0 flex-1 overflow-hidden md:grid",
primaryRailOpen && secondaryRailOpen && "md:grid-cols-[13rem_minmax(0,1fr)_20rem]",
primaryRailOpen && !secondaryRailOpen && "md:grid-cols-[13rem_minmax(0,1fr)]",
!primaryRailOpen && secondaryRailOpen && "md:grid-cols-[minmax(0,1fr)_20rem]",
!primaryRailOpen && !secondaryRailOpen && "md:grid-cols-[minmax(0,1fr)]",
)}
>
<WebPrimaryRail
activePanel={panel}
hasProject={Boolean(openId)}
onSelect={setPanel}
open={primaryRailOpen}
onSelect={selectPanel}
/>
<main
@ -185,7 +292,7 @@ export function WebWorkspace() {
<WebPanelMenuButton
activePanel={panel}
hasProject={Boolean(openId)}
onSelect={setPanel}
onSelect={selectPanel}
/>
</div>
{openNotice && (
@ -208,7 +315,7 @@ export function WebWorkspace() {
onRefreshProjects={() => void refresh()}
onViewSprintTickets={(sprintId) => {
setFocusSprintId(sprintId);
setPanel("tickets");
selectPanel("tickets");
}}
/>
</div>
@ -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}
/>
</div>
</div>
@ -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({
</span>
</div>
<div className="flex shrink-0 items-center gap-2 text-xs text-muted">
<Button
variant="ghost"
size="sm"
aria-pressed={primaryRailOpen}
onClick={onTogglePrimaryRail}
>
Rail gauche
</Button>
<Button
variant="ghost"
size="sm"
aria-pressed={secondaryRailOpen}
onClick={onToggleSecondaryRail}
>
Rail droit
</Button>
<WebSurfaceOpenControls
disabled={!canOpenSurface}
onOpenHere={onOpenHere}
@ -385,17 +519,19 @@ function WebSurfaceOpenControls({
function WebPrimaryRail({
activePanel,
hasProject,
open,
onSelect,
}: {
activePanel: WebProjectPanelId;
hasProject: boolean;
open: 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"
className={cn("hidden min-h-0 flex-col overflow-y-auto bg-surface p-2", open && "md:flex")}
>
<div className="px-2 pb-2 pt-1 text-[11px] font-semibold uppercase text-faint">
Surfaces
@ -443,26 +579,35 @@ function WebSecondaryRail({
openId,
openProject,
activePanel,
open,
openedSurfaces,
onOpenProject,
onRefreshProjects,
onSelectPanel,
onOpenSurfaceHere,
}: {
projects: Project[] | null;
openId: string | null;
openProject: Project | null;
activePanel: WebProjectPanelId;
open: boolean;
openedSurfaces: WebSurfacePlacements;
onOpenProject: (projectId: string) => void;
onRefreshProjects: () => void;
onSelectPanel: (panel: WebProjectPanelId) => void;
onOpenSurfaceHere: (panel: WebProjectPanelId) => void;
}) {
const secondaryPanels = WEB_PANEL_ORDER.filter(
(id) => WEB_PANEL_META[id].placement === "secondary",
);
const openedSurfaceEntries = Object.entries(openedSurfaces) as Array<
[WebOpenablePanelId, WebSurfacePlacement]
>;
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"
className={cn("hidden min-h-0 flex-col gap-3 overflow-y-auto bg-surface p-3", open && "md:flex")}
>
<section className="rounded-md border border-border bg-canvas p-3">
<div className="flex items-center justify-between gap-2">
@ -542,6 +687,30 @@ function WebSecondaryRail({
})}
</div>
</section>
<section className="rounded-md border border-border bg-canvas p-3">
<h2 className="text-xs font-semibold uppercase text-faint">Vues ouvertes</h2>
{openedSurfaceEntries.length === 0 ? (
<p className="mt-2 text-xs text-muted">Aucune vue ouverte.</p>
) : (
<ul className="mt-2 flex flex-col gap-1.5">
{openedSurfaceEntries.map(([surface, placement]) => (
<li key={surface}>
<button
type="button"
onClick={() => onOpenSurfaceHere(surface)}
className="flex w-full items-center justify-between gap-2 rounded-md px-2.5 py-2 text-left text-xs text-muted transition-colors hover:bg-raised hover:text-content"
>
<span>{WEB_PANEL_META[surface].label}</span>
<span className="shrink-0 rounded bg-raised px-1.5 py-0.5 text-[11px] text-faint">
{placement === "window" ? "fenêtre" : "onglet"}
</span>
</button>
</li>
))}
</ul>
)}
</section>
</aside>
);
}