feat(web): open project surfaces in browser targets — #204
This commit is contained in:
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user