feat(web): open project surfaces in browser targets — #204

This commit is contained in:
2026-08-15 01:16:23 +02:00
parent 7fea538d0d
commit 0d543fb298
8 changed files with 513 additions and 132 deletions

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;