1259 lines
40 KiB
TypeScript
1259 lines
40 KiB
TypeScript
/**
|
||
* Live web workspace — ticket #13/#86, extended by #90 (missing menus)
|
||
* 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 project, and split the shell by
|
||
* viewport. Desktop web reuses the AppImage layout system (`LayoutTabs`,
|
||
* `LayoutGrid`, floating/docked panels) without OS-detached windows. Mobile web
|
||
* keeps the simplified task-flow shell.
|
||
*
|
||
* The live work-state ("Travail") panel keeps its own bespoke, mobile-adapted
|
||
* implementation rather than the desktop `ProjectWorkStatePanel`: that component's
|
||
* "open" affordance attaches into a `LayoutGrid` cell on desktop. The mobile
|
||
* panel's own "Ouvrir" still mounts {@link WebAgentCell}, a touch-drivable
|
||
* terminal cell (F4), kept transport-neutral via the same
|
||
* {@link useProjectWorkState} hook and {@link WorkStateGateway} writes
|
||
* (cancel/retry). {@link useLiveReconnect} re-synchronises the read-model after
|
||
* a WS outage.
|
||
*/
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
|
||
import type {
|
||
AgentWorkState,
|
||
BackgroundCompletion,
|
||
GatewayError,
|
||
LayoutInfo,
|
||
Project,
|
||
} from "@/domain";
|
||
import { useGateways } from "@/app/di";
|
||
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
|
||
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
||
import { ProjectContextPanel } from "@/features/projects/ProjectContextPanel";
|
||
import { isDockedTo, type ViewPlacement } from "@/features/projects/viewPlacement";
|
||
import { AgentsPanel } from "@/features/agents";
|
||
import { TemplatesPanel } from "@/features/templates";
|
||
import { SkillsPanel } from "@/features/skills";
|
||
import { McpPanel, PermissionsPanel } from "@/features/permissions";
|
||
import { MemoryPanel } from "@/features/memory";
|
||
import { EmbedderSettings } from "@/features/embedder";
|
||
import { GitGraphView, GitPanel } from "@/features/git";
|
||
import { ConversationViewer } from "@/features/conversations";
|
||
import {
|
||
Button,
|
||
DockRegion,
|
||
FloatingWindow,
|
||
IconButton,
|
||
MenuBar,
|
||
Panel,
|
||
Spinner,
|
||
cn,
|
||
type FloatingWindowSize,
|
||
type MenuBarItem,
|
||
type MenuBarMenu,
|
||
} from "@/shared";
|
||
import { WebAgentCell } from "./WebAgentCell";
|
||
import { useLiveReconnect } from "./useLiveReconnect";
|
||
import { useLiveConnectionState } from "./useLiveConnectionState";
|
||
import { WebTicketsView } from "./tickets/WebTicketsView";
|
||
import { WebSprintsView } from "./tickets/WebSprintsView";
|
||
import { WebProjectsPanel } from "./WebProjectsPanel";
|
||
import { WebMenuSheet } from "./WebMenuSheet";
|
||
import { ProjectFilesPanel } from "./ProjectFilesPanel";
|
||
import {
|
||
WEB_PANEL_META,
|
||
type WebOpenablePanelId,
|
||
type WebProjectPanelId,
|
||
} from "./webSurfaceCatalog";
|
||
|
||
function describe(e: unknown): string {
|
||
if (e && typeof e === "object" && "message" in e) {
|
||
return String((e as GatewayError).message);
|
||
}
|
||
return String(e);
|
||
}
|
||
|
||
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");
|
||
// Set by the Sprints panel's "Voir tickets"; consumed once by the Tickets panel.
|
||
const [focusSprintId, setFocusSprintId] = useState<string | null>(null);
|
||
const mobile = useMobileWorkspace();
|
||
|
||
const openRoot = projects?.find((p) => p.id === openId)?.root ?? null;
|
||
const openProject = useMemo(
|
||
() => projects?.find((p) => p.id === openId) ?? null,
|
||
[openId, projects],
|
||
);
|
||
|
||
const refresh = useCallback(async () => {
|
||
setError(null);
|
||
try {
|
||
setProjects(await project.listProjects());
|
||
} catch (e) {
|
||
setError(describe(e));
|
||
}
|
||
}, [project]);
|
||
|
||
useEffect(() => {
|
||
void refresh();
|
||
}, [refresh]);
|
||
|
||
const selectPanel = useCallback(
|
||
(nextPanel: WebProjectPanelId) => {
|
||
setPanel(nextPanel);
|
||
},
|
||
[],
|
||
);
|
||
|
||
const openReadOnly = useCallback(
|
||
async (projectId: string) => {
|
||
setError(null);
|
||
setOpenId(null);
|
||
try {
|
||
// 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);
|
||
setOpenId(projectId);
|
||
setPanel("work");
|
||
} catch (e) {
|
||
setError(describe(e));
|
||
}
|
||
},
|
||
[project],
|
||
);
|
||
|
||
const createProject = useCallback(
|
||
async (name: string, root: string): Promise<Project> => {
|
||
setError(null);
|
||
try {
|
||
const created = await project.createProject(name, root);
|
||
await refresh();
|
||
return created;
|
||
} catch (e) {
|
||
setError(describe(e));
|
||
throw e;
|
||
}
|
||
},
|
||
[project, refresh],
|
||
);
|
||
|
||
if (mobile) {
|
||
return (
|
||
<WebMobileTaskFlowShell
|
||
panel={panel}
|
||
projects={projects}
|
||
openId={openId}
|
||
openProject={openProject}
|
||
openRoot={openRoot}
|
||
error={error}
|
||
focusSprintId={focusSprintId}
|
||
onSelectPanel={selectPanel}
|
||
onOpenProject={(id) => void openReadOnly(id)}
|
||
onCreateProject={createProject}
|
||
onRefreshProjects={() => void refresh()}
|
||
onViewSprintTickets={(sprintId) => {
|
||
setFocusSprintId(sprintId);
|
||
selectPanel("tickets");
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<WebDesktopWorkspace
|
||
projects={projects}
|
||
openId={openId}
|
||
openProject={openProject}
|
||
openRoot={openRoot}
|
||
error={error}
|
||
focusSprintId={focusSprintId}
|
||
onSelectPanel={selectPanel}
|
||
onOpenProject={(id) => void openReadOnly(id)}
|
||
onCreateProject={createProject}
|
||
onRefreshProjects={() => void refresh()}
|
||
onViewSprintTickets={(sprintId) => {
|
||
setFocusSprintId(sprintId);
|
||
selectPanel("tickets");
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function useMobileWorkspace(): boolean {
|
||
const read = () => (typeof window === "undefined" ? false : window.innerWidth < 768);
|
||
const [mobile, setMobile] = useState(read);
|
||
|
||
useEffect(() => {
|
||
const onResize = () => setMobile(read());
|
||
window.addEventListener("resize", onResize);
|
||
return () => window.removeEventListener("resize", onResize);
|
||
}, []);
|
||
|
||
return mobile;
|
||
}
|
||
|
||
type WebDesktopPanelId = WebProjectPanelId;
|
||
type WebDesktopPlacements = Partial<Record<WebDesktopPanelId, ViewPlacement>>;
|
||
|
||
const WEB_PANEL_SIZE: Record<WebDesktopPanelId, FloatingWindowSize> = {
|
||
projects: "md",
|
||
context: "lg",
|
||
work: "lg",
|
||
tickets: "lg",
|
||
sprints: "lg",
|
||
agents: "lg",
|
||
templates: "lg",
|
||
skills: "lg",
|
||
mcp: "lg",
|
||
permissions: "md",
|
||
memory: "lg",
|
||
git: "lg",
|
||
files: "lg",
|
||
};
|
||
|
||
function webPanelTitle(panel: WebDesktopPanelId): string {
|
||
return WEB_PANEL_META[panel].label;
|
||
}
|
||
|
||
function webPlacementOf(
|
||
placements: WebDesktopPlacements,
|
||
panel: WebDesktopPanelId,
|
||
): ViewPlacement {
|
||
return placements[panel] ?? "closed";
|
||
}
|
||
|
||
function webPanelsDockedTo(
|
||
placements: WebDesktopPlacements,
|
||
side: "left" | "right",
|
||
): WebDesktopPanelId[] {
|
||
return (Object.keys(WEB_PANEL_META) as WebDesktopPanelId[]).filter((panel) =>
|
||
isDockedTo(webPlacementOf(placements, panel), side),
|
||
);
|
||
}
|
||
|
||
function webFloatingPanels(placements: WebDesktopPlacements): WebDesktopPanelId[] {
|
||
return (Object.keys(WEB_PANEL_META) as WebDesktopPanelId[]).filter(
|
||
(panel) => webPlacementOf(placements, panel) === "floating",
|
||
);
|
||
}
|
||
|
||
function WebDesktopWorkspace({
|
||
projects,
|
||
openId,
|
||
openProject,
|
||
openRoot,
|
||
error,
|
||
focusSprintId,
|
||
onSelectPanel,
|
||
onOpenProject,
|
||
onCreateProject,
|
||
onRefreshProjects,
|
||
onViewSprintTickets,
|
||
}: {
|
||
projects: Project[] | null;
|
||
openId: string | null;
|
||
openProject: Project | null;
|
||
openRoot: string | null;
|
||
error: string | null;
|
||
focusSprintId: string | null;
|
||
onSelectPanel: (panel: WebProjectPanelId) => void;
|
||
onOpenProject: (projectId: string) => void;
|
||
onCreateProject: (name: string, root: string) => Promise<Project>;
|
||
onRefreshProjects: () => void;
|
||
onViewSprintTickets: (sprintId: string) => void;
|
||
}) {
|
||
const [placements, setPlacements] = useState<WebDesktopPlacements>({
|
||
work: { dock: "right" },
|
||
});
|
||
const [leftDockWidth, setLeftDockWidth] = useState(340);
|
||
const [rightDockWidth, setRightDockWidth] = useState(340);
|
||
const [activeLayout, setActiveLayout] = useState<LayoutInfo | null>(null);
|
||
const [viewerConversation, setViewerConversation] = useState<{
|
||
projectId: string;
|
||
conversationId: string;
|
||
} | null>(null);
|
||
|
||
useEffect(() => {
|
||
setActiveLayout(null);
|
||
}, [openId]);
|
||
|
||
const activeViewerConversation =
|
||
viewerConversation?.projectId === openProject?.id
|
||
? viewerConversation
|
||
: null;
|
||
|
||
function setPlacement(panel: WebDesktopPanelId, placement: Exclude<ViewPlacement, "detached">) {
|
||
setPlacements((prev) => {
|
||
const next: WebDesktopPlacements = { ...prev };
|
||
if (placement === "floating") {
|
||
for (const other of Object.keys(next) as WebDesktopPanelId[]) {
|
||
if (other !== panel && next[other] === "floating") delete next[other];
|
||
}
|
||
}
|
||
next[panel] = placement;
|
||
return next;
|
||
});
|
||
}
|
||
|
||
function closePanel(panel: WebDesktopPanelId) {
|
||
setPlacements((prev) => {
|
||
if (!(panel in prev)) return prev;
|
||
const next = { ...prev };
|
||
delete next[panel];
|
||
return next;
|
||
});
|
||
}
|
||
|
||
function dismissFloating() {
|
||
setPlacements((prev) => {
|
||
let changed = false;
|
||
const next = { ...prev };
|
||
for (const panel of Object.keys(next) as WebDesktopPanelId[]) {
|
||
if (next[panel] === "floating") {
|
||
delete next[panel];
|
||
changed = true;
|
||
}
|
||
}
|
||
return changed ? next : prev;
|
||
});
|
||
}
|
||
|
||
function openConversation(conversationId: string) {
|
||
if (!openProject) return;
|
||
setViewerConversation({ projectId: openProject.id, conversationId });
|
||
dismissFloating();
|
||
}
|
||
|
||
const panelOrder: WebDesktopPanelId[] = [
|
||
"context",
|
||
"work",
|
||
"tickets",
|
||
"sprints",
|
||
"agents",
|
||
"templates",
|
||
"skills",
|
||
"mcp",
|
||
"permissions",
|
||
"memory",
|
||
"git",
|
||
"files",
|
||
"projects",
|
||
];
|
||
|
||
function placementSubmenu(panel: WebDesktopPanelId): MenuBarItem[] {
|
||
const placement = webPlacementOf(placements, panel);
|
||
return [
|
||
{
|
||
id: "closed",
|
||
label: "Fermé",
|
||
active: placement === "closed",
|
||
onSelect: () => closePanel(panel),
|
||
},
|
||
{
|
||
id: "floating",
|
||
label: "Flottant",
|
||
active: placement === "floating",
|
||
onSelect: () => setPlacement(panel, "floating"),
|
||
},
|
||
{
|
||
id: "dock-left",
|
||
label: "Ancré à gauche",
|
||
active: isDockedTo(placement, "left"),
|
||
onSelect: () => setPlacement(panel, { dock: "left" }),
|
||
},
|
||
{
|
||
id: "dock-right",
|
||
label: "Ancré à droite",
|
||
active: isDockedTo(placement, "right"),
|
||
onSelect: () => setPlacement(panel, { dock: "right" }),
|
||
},
|
||
];
|
||
}
|
||
|
||
const menus: MenuBarMenu[] = [
|
||
{
|
||
id: "panels",
|
||
label: "Panneaux",
|
||
items: panelOrder.map((panel) => ({
|
||
id: panel,
|
||
label: webPanelTitle(panel),
|
||
active: webPlacementOf(placements, panel) !== "closed",
|
||
onSelect: () => {},
|
||
submenu: placementSubmenu(panel),
|
||
})),
|
||
},
|
||
];
|
||
|
||
const projectsManager = (
|
||
<WebProjectsPanel
|
||
projects={projects}
|
||
openId={openId}
|
||
onOpen={onOpenProject}
|
||
onCreate={onCreateProject}
|
||
onRefresh={onRefreshProjects}
|
||
/>
|
||
);
|
||
|
||
function renderPanel(panel: WebDesktopPanelId) {
|
||
if (panel === "projects") return projectsManager;
|
||
if (!openId) {
|
||
return <p className="text-sm text-muted">Ouvrir un projet pour utiliser ce panneau.</p>;
|
||
}
|
||
return (
|
||
<WebProjectPanelBody
|
||
panel={panel as WebOpenablePanelId}
|
||
projectId={openId}
|
||
root={openRoot}
|
||
focusSprintId={focusSprintId}
|
||
onViewSprintTickets={onViewSprintTickets}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function DockControls({ panel }: { panel: WebDesktopPanelId }) {
|
||
const placement = webPlacementOf(placements, panel);
|
||
const title = webPanelTitle(panel);
|
||
const activeClass = "bg-raised text-content ring-1 ring-border-strong";
|
||
return (
|
||
<div className="flex shrink-0 items-center gap-1">
|
||
<IconButton
|
||
size="sm"
|
||
title="Ancré à gauche"
|
||
aria-label={`Ancré à gauche — ${title}`}
|
||
aria-pressed={isDockedTo(placement, "left")}
|
||
className={cn(isDockedTo(placement, "left") && activeClass)}
|
||
onClick={() => setPlacement(panel, { dock: "left" })}
|
||
>
|
||
⇤
|
||
</IconButton>
|
||
<IconButton
|
||
size="sm"
|
||
title="Ancré à droite"
|
||
aria-label={`Ancré à droite — ${title}`}
|
||
aria-pressed={isDockedTo(placement, "right")}
|
||
className={cn(isDockedTo(placement, "right") && activeClass)}
|
||
onClick={() => setPlacement(panel, { dock: "right" })}
|
||
>
|
||
⇥
|
||
</IconButton>
|
||
<IconButton
|
||
size="sm"
|
||
title="Flottant"
|
||
aria-label={`Flottant — ${title}`}
|
||
aria-pressed={placement === "floating"}
|
||
className={cn(placement === "floating" && activeClass)}
|
||
onClick={() => setPlacement(panel, "floating")}
|
||
>
|
||
□
|
||
</IconButton>
|
||
<IconButton
|
||
size="sm"
|
||
title="Fermé"
|
||
aria-label={`Fermé — ${title}`}
|
||
onClick={() => closePanel(panel)}
|
||
>
|
||
×
|
||
</IconButton>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function renderDockedView(panel: WebDesktopPanelId) {
|
||
return (
|
||
<section
|
||
key={panel}
|
||
aria-label={`${webPanelTitle(panel)} panel`}
|
||
className="flex min-h-0 flex-1 flex-col border-b border-border last:border-b-0"
|
||
>
|
||
<header className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-2">
|
||
<span className="truncate text-sm font-medium text-content">
|
||
{webPanelTitle(panel)}
|
||
</span>
|
||
<DockControls panel={panel} />
|
||
</header>
|
||
<div className="min-h-0 flex-1 overflow-auto p-3">{renderPanel(panel)}</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const leftPanels = webPanelsDockedTo(placements, "left");
|
||
const rightPanels = webPanelsDockedTo(placements, "right");
|
||
const floatingList = webFloatingPanels(placements);
|
||
const activeLayoutKind = activeLayout?.kind ?? "terminal";
|
||
const showInlineProjects =
|
||
!openProject && webPlacementOf(placements, "projects") === "closed";
|
||
|
||
return (
|
||
<div
|
||
data-testid="web-workspace"
|
||
data-shell="desktop-layout"
|
||
className="flex h-full min-h-0 flex-col overflow-hidden bg-canvas"
|
||
>
|
||
<header className="flex h-12 shrink-0 items-center justify-between gap-4 border-b border-border bg-surface px-4">
|
||
<div className="min-w-0">
|
||
<p className="truncate text-sm font-semibold text-content">
|
||
{openProject ? openProject.name : "Aucun projet ouvert"}
|
||
</p>
|
||
<p className="truncate text-xs text-faint">
|
||
{openProject?.root ?? "Ouvrir un projet pour accéder aux layouts."}
|
||
</p>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => {
|
||
onSelectPanel("projects");
|
||
setPlacement("projects", "floating");
|
||
}}
|
||
>
|
||
Projets
|
||
</Button>
|
||
</header>
|
||
<MenuBar menus={menus} />
|
||
<ReconnectBanner />
|
||
{error && (
|
||
<div className="shrink-0 px-4 py-2">
|
||
<WebWorkspaceError message={error} />
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||
{leftPanels.length > 0 && (
|
||
<DockRegion
|
||
side="left"
|
||
width={leftDockWidth}
|
||
onResize={setLeftDockWidth}
|
||
aria-label="left dock"
|
||
>
|
||
{leftPanels.map(renderDockedView)}
|
||
</DockRegion>
|
||
)}
|
||
|
||
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||
{openProject ? (
|
||
<>
|
||
<LayoutTabs
|
||
projectId={openProject.id}
|
||
onActiveLayoutChange={setActiveLayout}
|
||
/>
|
||
{activeViewerConversation ? (
|
||
<ConversationViewer
|
||
key={`${activeViewerConversation.projectId}-${activeViewerConversation.conversationId}`}
|
||
projectId={activeViewerConversation.projectId}
|
||
conversationId={activeViewerConversation.conversationId}
|
||
onClose={() => setViewerConversation(null)}
|
||
onOpenConversation={openConversation}
|
||
/>
|
||
) : activeLayoutKind === "gitGraph" ? (
|
||
<GitGraphView
|
||
key={`${openProject.id}-${activeLayout?.id ?? "default"}-graph`}
|
||
projectId={openProject.id}
|
||
/>
|
||
) : (
|
||
<LayoutGrid
|
||
key={`${openProject.id}-${activeLayout?.id ?? "default"}`}
|
||
projectId={openProject.id}
|
||
cwd={openProject.root}
|
||
layoutId={activeLayout?.id}
|
||
onOpenConversation={openConversation}
|
||
/>
|
||
)}
|
||
</>
|
||
) : (
|
||
<div className="flex flex-1 justify-center overflow-y-auto p-6">
|
||
<div className="w-full max-w-xl">
|
||
{showInlineProjects ? (
|
||
projectsManager
|
||
) : (
|
||
<p className="text-sm text-muted">
|
||
Sélectionner ou créer un projet pour démarrer.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</main>
|
||
|
||
{rightPanels.length > 0 && (
|
||
<DockRegion
|
||
side="right"
|
||
width={rightDockWidth}
|
||
onResize={setRightDockWidth}
|
||
aria-label="right dock"
|
||
>
|
||
{rightPanels.map(renderDockedView)}
|
||
</DockRegion>
|
||
)}
|
||
</div>
|
||
|
||
{floatingList.map((panel) => (
|
||
<FloatingWindow
|
||
key={panel}
|
||
open
|
||
title={webPanelTitle(panel)}
|
||
size={WEB_PANEL_SIZE[panel]}
|
||
onClose={() => closePanel(panel)}
|
||
>
|
||
<div className="mb-3 flex justify-end border-b border-border pb-3">
|
||
<DockControls panel={panel} />
|
||
</div>
|
||
{renderPanel(panel)}
|
||
</FloatingWindow>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function WebMobileTaskFlowShell({
|
||
panel,
|
||
projects,
|
||
openId,
|
||
openProject,
|
||
openRoot,
|
||
error,
|
||
focusSprintId,
|
||
onSelectPanel,
|
||
onOpenProject,
|
||
onCreateProject,
|
||
onRefreshProjects,
|
||
onViewSprintTickets,
|
||
}: {
|
||
panel: WebProjectPanelId;
|
||
projects: Project[] | null;
|
||
openId: string | null;
|
||
openProject: Project | null;
|
||
openRoot: string | null;
|
||
error: string | null;
|
||
focusSprintId: string | null;
|
||
onSelectPanel: (panel: WebProjectPanelId) => void;
|
||
onOpenProject: (projectId: string) => void;
|
||
onCreateProject: (name: string, root: string) => Promise<Project>;
|
||
onRefreshProjects: () => void;
|
||
onViewSprintTickets: (sprintId: string) => void;
|
||
}) {
|
||
const activePanel = panel === "projects" || openId ? panel : "projects";
|
||
const navItems: Array<{ id: WebProjectPanelId; label: string }> = [
|
||
{ id: "projects", label: "Projet" },
|
||
{ id: "work", label: "Travail" },
|
||
{ id: "tickets", label: "Tickets" },
|
||
{ id: "sprints", label: "Sprints" },
|
||
{ id: "files", label: "Fichiers" },
|
||
{ id: "agents", label: "Agent" },
|
||
];
|
||
|
||
return (
|
||
<div
|
||
data-testid="web-workspace"
|
||
data-shell="mobile-task-flow"
|
||
className="flex h-full min-h-0 flex-col overflow-hidden bg-canvas"
|
||
>
|
||
<header
|
||
data-testid="web-mobile-task-header"
|
||
className="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-border bg-surface px-3 pl-[max(0.75rem,env(safe-area-inset-left))] pr-[max(0.75rem,env(safe-area-inset-right))]"
|
||
>
|
||
<div className="min-w-0">
|
||
<p className="text-[11px] font-semibold uppercase text-faint">
|
||
{WEB_PANEL_META[activePanel].label}
|
||
</p>
|
||
<p className="truncate text-sm font-medium text-content">
|
||
{openProject ? openProject.name : "Choisir un projet"}
|
||
</p>
|
||
</div>
|
||
<MobileProjectSelector
|
||
projects={projects}
|
||
openId={openId}
|
||
onOpenProject={onOpenProject}
|
||
onShowProjects={() => onSelectPanel("projects")}
|
||
onRefreshProjects={onRefreshProjects}
|
||
/>
|
||
</header>
|
||
|
||
<main
|
||
data-testid="web-mobile-task-canvas"
|
||
className="min-h-0 flex-1 overflow-y-auto px-3 pb-4 pt-3"
|
||
>
|
||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
|
||
<ReconnectBanner />
|
||
{error && <WebWorkspaceError message={error} />}
|
||
<WebMobileTaskSurface
|
||
panel={activePanel}
|
||
projects={projects}
|
||
openId={openId}
|
||
openRoot={openRoot}
|
||
focusSprintId={focusSprintId}
|
||
onOpenProject={onOpenProject}
|
||
onCreateProject={onCreateProject}
|
||
onRefreshProjects={onRefreshProjects}
|
||
onViewSprintTickets={onViewSprintTickets}
|
||
/>
|
||
</div>
|
||
</main>
|
||
|
||
<nav
|
||
data-testid="web-mobile-bottom-nav"
|
||
aria-label="Navigation mobile"
|
||
className="grid shrink-0 grid-cols-6 border-t border-border bg-surface pb-[env(safe-area-inset-bottom)]"
|
||
>
|
||
{navItems.map((item) => {
|
||
const disabled = item.id !== "projects" && !openId;
|
||
const selected = activePanel === item.id;
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
disabled={disabled}
|
||
aria-current={selected ? "page" : undefined}
|
||
onClick={() => onSelectPanel(item.id)}
|
||
className={cn(
|
||
"flex min-h-14 items-center justify-center px-1 text-xs font-medium",
|
||
selected ? "text-primary" : "text-muted",
|
||
disabled && "cursor-not-allowed opacity-45",
|
||
)}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</nav>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MobileProjectSelector({
|
||
projects,
|
||
openId,
|
||
onOpenProject,
|
||
onShowProjects,
|
||
onRefreshProjects,
|
||
}: {
|
||
projects: Project[] | null;
|
||
openId: string | null;
|
||
onOpenProject: (projectId: string) => void;
|
||
onShowProjects: () => void;
|
||
onRefreshProjects: () => void;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
return (
|
||
<>
|
||
<Button size="sm" variant="ghost" onClick={() => setOpen(true)}>
|
||
Projets
|
||
</Button>
|
||
{open && (
|
||
<WebMenuSheet
|
||
title="Projets"
|
||
note={projects === null ? "Chargement des projets…" : undefined}
|
||
onClose={() => setOpen(false)}
|
||
entries={[
|
||
...(projects ?? []).map((project) => ({
|
||
id: project.id,
|
||
label: project.name,
|
||
hint: project.root,
|
||
active: openId === project.id,
|
||
onSelect: () => {
|
||
onOpenProject(project.id);
|
||
setOpen(false);
|
||
},
|
||
})),
|
||
{
|
||
id: "manage-projects",
|
||
label: "Gérer les projets",
|
||
hint: "Créer ou rafraîchir",
|
||
active: false,
|
||
onSelect: () => {
|
||
onShowProjects();
|
||
setOpen(false);
|
||
},
|
||
},
|
||
{
|
||
id: "refresh-projects",
|
||
label: "Rafraîchir",
|
||
active: false,
|
||
onSelect: () => {
|
||
onRefreshProjects();
|
||
setOpen(false);
|
||
},
|
||
},
|
||
]}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function WebMobileTaskSurface({
|
||
panel,
|
||
projects,
|
||
openId,
|
||
openRoot,
|
||
focusSprintId,
|
||
onOpenProject,
|
||
onCreateProject,
|
||
onRefreshProjects,
|
||
onViewSprintTickets,
|
||
}: {
|
||
panel: WebProjectPanelId;
|
||
projects: Project[] | null;
|
||
openId: string | null;
|
||
openRoot: string | null;
|
||
focusSprintId: string | null;
|
||
onOpenProject: (projectId: string) => void;
|
||
onCreateProject: (name: string, root: string) => Promise<Project>;
|
||
onRefreshProjects: () => void;
|
||
onViewSprintTickets: (sprintId: string) => void;
|
||
}) {
|
||
if (panel === "projects") {
|
||
return (
|
||
<WebProjectsPanel
|
||
projects={projects}
|
||
openId={openId}
|
||
onOpen={onOpenProject}
|
||
onCreate={onCreateProject}
|
||
onRefresh={onRefreshProjects}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (!openId) {
|
||
return (
|
||
<Panel className="mt-2">
|
||
<p className="text-sm text-muted">Choisir un projet pour continuer.</p>
|
||
</Panel>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<WebProjectPanelBody
|
||
panel={panel}
|
||
projectId={openId}
|
||
root={openRoot}
|
||
focusSprintId={focusSprintId}
|
||
onViewSprintTickets={onViewSprintTickets}
|
||
mobile
|
||
/>
|
||
);
|
||
}
|
||
|
||
function WebWorkspaceError({ message }: { message: string }) {
|
||
return (
|
||
<Panel className="border-danger/40">
|
||
<p className="text-sm text-danger">{message}</p>
|
||
</Panel>
|
||
);
|
||
}
|
||
|
||
/** Renders the requested panel's body for the opened project. */
|
||
export function WebProjectPanelBody({
|
||
panel,
|
||
projectId,
|
||
root,
|
||
focusSprintId,
|
||
onViewSprintTickets,
|
||
mobile = false,
|
||
}: {
|
||
panel: WebOpenablePanelId;
|
||
projectId: string;
|
||
root: string | null;
|
||
focusSprintId: string | null;
|
||
onViewSprintTickets: (sprintId: string) => void;
|
||
mobile?: boolean;
|
||
}) {
|
||
switch (panel) {
|
||
case "context":
|
||
return <ProjectContextPanel projectId={projectId} />;
|
||
case "work":
|
||
return <LiveProjectPanel projectId={projectId} root={root} mobile={mobile} />;
|
||
case "tickets":
|
||
return <WebTicketsView projectId={projectId} focusSprintId={focusSprintId} />;
|
||
case "sprints":
|
||
return (
|
||
<WebSprintsView projectId={projectId} onViewSprintTickets={onViewSprintTickets} />
|
||
);
|
||
case "agents":
|
||
return <AgentsPanel projectId={projectId} projectRoot={root ?? ""} />;
|
||
case "templates":
|
||
return <TemplatesPanel projectId={projectId} />;
|
||
case "skills":
|
||
return <SkillsPanel projectId={projectId} />;
|
||
case "mcp":
|
||
return <McpPanel projectId={projectId} />;
|
||
case "permissions":
|
||
return <PermissionsPanel projectId={projectId} />;
|
||
case "memory":
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<MemoryPanel projectId={projectId} />
|
||
<EmbedderSettings />
|
||
</div>
|
||
);
|
||
case "git":
|
||
return <GitPanel projectId={projectId} />;
|
||
case "files":
|
||
return <ProjectFilesPanel projectId={projectId} mobile={mobile} />;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Global reconnection banner (F6). The F3 terminal path writes a "déconnecté"
|
||
* notice into xterm, but live-only surfaces (no terminal open) had no visible
|
||
* signal. This surfaces the shared live socket's `reconnecting` state so the user
|
||
* always knows the view may be momentarily stale; `useLiveReconnect` re-syncs the
|
||
* read-model on recovery. Inert on desktop (hook returns `null`).
|
||
*/
|
||
function ReconnectBanner() {
|
||
const connection = useLiveConnectionState();
|
||
if (connection !== "reconnecting") return null;
|
||
return (
|
||
<div
|
||
role="status"
|
||
data-testid="web-reconnect-banner"
|
||
className="flex items-center gap-2 rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning"
|
||
>
|
||
<Spinner size={12} />
|
||
Connexion perdue — reconnexion en cours…
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Live work-state + background + inbox for the opened project (F5). */
|
||
function LiveProjectPanel({
|
||
projectId,
|
||
root,
|
||
mobile = false,
|
||
}: {
|
||
projectId: string;
|
||
root: string | null;
|
||
mobile?: boolean;
|
||
}) {
|
||
const vm = useProjectWorkState(projectId);
|
||
// Re-sync the read-model when the WS reconnects (events missed while offline).
|
||
useLiveReconnect(vm.refresh);
|
||
const [openAgentId, setOpenAgentId] = useState<string | null>(null);
|
||
// Web equivalent of desktop `useLayout.layoutVersion` (ticket #61 web
|
||
// regression): the web shell has no `useLayout`/`LayoutGrid`, so nothing was
|
||
// ever bumping `refitSignal` on `TerminalView` — a cell opened after another
|
||
// one was left with stale xterm scaling, with no window-resize equivalent to
|
||
// "repair" it on mobile. Bumped on every cell open/close and forwarded as
|
||
// `refitSignal` to every visible `WebAgentCell` — a single shared counter, so
|
||
// it already covers several simultaneously-visible cells if this panel ever
|
||
// grows one.
|
||
const [cellLayoutVersion, setCellLayoutVersion] = useState(0);
|
||
const bumpCellLayoutVersion = useCallback(() => setCellLayoutVersion((v) => v + 1), []);
|
||
const agents = vm.state?.agents ?? [];
|
||
|
||
// Per-agent background-tasks disclosure (collapsed by default). A live refresh
|
||
// must never flip this — only drop an entry once its agent has no tasks left,
|
||
// so state doesn't leak for agents that no longer have a background section.
|
||
const [expandedBgAgents, setExpandedBgAgents] = useState<Set<string>>(new Set());
|
||
useEffect(() => {
|
||
const withTasks = new Set(
|
||
agents.filter((a) => (a.backgroundTasks ?? []).length > 0).map((a) => a.agentId),
|
||
);
|
||
setExpandedBgAgents((cur) => {
|
||
const next = new Set([...cur].filter((id) => withTasks.has(id)));
|
||
return next.size === cur.size ? cur : next;
|
||
});
|
||
}, [agents]);
|
||
const toggleBgExpanded = useCallback((agentId: string) => {
|
||
setExpandedBgAgents((cur) => {
|
||
const next = new Set(cur);
|
||
if (next.has(agentId)) next.delete(agentId);
|
||
else next.add(agentId);
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const openAgent = openAgentId ? agents.find((a) => a.agentId === openAgentId) : null;
|
||
|
||
return (
|
||
<Panel className="mt-2">
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<h3 className="text-sm font-semibold">
|
||
État live
|
||
<span className="ml-2 rounded bg-canvas px-1.5 py-0.5 text-[0.6rem] uppercase text-muted">
|
||
read-only
|
||
</span>
|
||
</h3>
|
||
<Button variant="ghost" size="sm" loading={vm.busy} onClick={() => void vm.refresh()}>
|
||
Rafraîchir
|
||
</Button>
|
||
</div>
|
||
|
||
{vm.error && <p role="alert" className="mb-2 text-xs text-danger">{vm.error}</p>}
|
||
|
||
{vm.busy && vm.state === null ? (
|
||
<p className="text-xs text-muted">Chargement de l'état…</p>
|
||
) : agents.length === 0 ? (
|
||
<p className="text-xs text-muted">Aucun agent actif.</p>
|
||
) : (
|
||
<ul className="flex flex-col divide-y divide-border" data-testid="web-workstate">
|
||
{agents.map((a) => (
|
||
<AgentLiveRow
|
||
key={a.agentId}
|
||
agent={a}
|
||
open={openAgentId === a.agentId}
|
||
onToggleOpen={() => {
|
||
setOpenAgentId((cur) => (cur === a.agentId ? null : a.agentId));
|
||
bumpCellLayoutVersion();
|
||
}}
|
||
onRefresh={vm.refresh}
|
||
bgExpanded={expandedBgAgents.has(a.agentId)}
|
||
onToggleBgExpanded={() => toggleBgExpanded(a.agentId)}
|
||
/>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
{root && openAgentId && !mobile && (
|
||
<div className="mt-3">
|
||
<div className="mb-1 flex items-center justify-between">
|
||
<span className="text-xs font-semibold text-muted">Agent</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => {
|
||
setOpenAgentId(null);
|
||
bumpCellLayoutVersion();
|
||
}}
|
||
>
|
||
Fermer
|
||
</Button>
|
||
</div>
|
||
<WebAgentCell
|
||
key={openAgentId}
|
||
projectId={projectId}
|
||
agentId={openAgentId}
|
||
cwd={root}
|
||
refitSignal={cellLayoutVersion}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{root && openAgentId && mobile && (
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={`Session agent ${openAgent?.name ?? openAgentId}`}
|
||
data-testid="web-mobile-agent-session"
|
||
className="fixed inset-0 z-50 flex min-h-0 flex-col bg-canvas"
|
||
>
|
||
<header className="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-border bg-surface px-3 pl-[max(0.75rem,env(safe-area-inset-left))] pr-[max(0.75rem,env(safe-area-inset-right))]">
|
||
<div className="min-w-0">
|
||
<p className="text-[11px] font-semibold uppercase text-faint">Session agent</p>
|
||
<p className="truncate text-sm font-medium text-content">
|
||
{openAgent?.name ?? openAgentId}
|
||
</p>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => {
|
||
setOpenAgentId(null);
|
||
bumpCellLayoutVersion();
|
||
}}
|
||
>
|
||
Fermer
|
||
</Button>
|
||
</header>
|
||
<div className="min-h-0 flex-1 p-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
|
||
<WebAgentCell
|
||
key={openAgentId}
|
||
projectId={projectId}
|
||
agentId={openAgentId}
|
||
cwd={root}
|
||
refitSignal={cellLayoutVersion}
|
||
fullHeight
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Panel>
|
||
);
|
||
}
|
||
|
||
/** One agent row: live/idle/busy + inbox + background tasks + open affordance. */
|
||
function AgentLiveRow({
|
||
agent,
|
||
open,
|
||
onToggleOpen,
|
||
onRefresh,
|
||
bgExpanded,
|
||
onToggleBgExpanded,
|
||
}: {
|
||
agent: AgentWorkState;
|
||
open: boolean;
|
||
onToggleOpen: () => void;
|
||
onRefresh: () => Promise<void>;
|
||
bgExpanded: boolean;
|
||
onToggleBgExpanded: () => void;
|
||
}) {
|
||
const live = agent.live !== undefined;
|
||
const busy = agent.busy?.state === "busy";
|
||
const inbox = [...(agent.inbox ?? [])].sort((a, b) => a.createdAtMs - b.createdAtMs);
|
||
const backgroundTasks = [...(agent.backgroundTasks ?? [])].sort(
|
||
(a, b) => b.updatedAtMs - a.updatedAtMs || a.taskId.localeCompare(b.taskId),
|
||
);
|
||
|
||
return (
|
||
<li className="py-2 first:pt-0 last:pb-0">
|
||
{/* #69 — at 360px the name + two badges + the button no longer fit on one
|
||
line, so the name takes its own row on phones and the status cluster
|
||
wraps under it. From `sm` up this collapses back to the original
|
||
single-line desktop layout. */}
|
||
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
|
||
<span className="min-w-0 truncate text-sm text-content">{agent.name}</span>
|
||
<span className="flex shrink-0 items-center gap-1.5 text-xs">
|
||
<span className={cn("rounded-full px-2 py-0.5 font-medium", live ? "bg-success/15 text-success" : "bg-raised text-muted")}>
|
||
{live ? "Live" : "Offline"}
|
||
</span>
|
||
<span className={cn("rounded-full px-2 py-0.5 font-medium", busy ? "bg-warning/15 text-warning" : "bg-raised text-muted")}>
|
||
{busy ? "Busy" : "Idle"}
|
||
</span>
|
||
<Button variant="ghost" size="sm" aria-pressed={open} onClick={onToggleOpen}>
|
||
{open ? "Fermer" : "Ouvrir"}
|
||
</Button>
|
||
</span>
|
||
</div>
|
||
|
||
{inbox.length > 0 && (
|
||
<div className="mt-2">
|
||
<p className="text-[11px] font-semibold uppercase tracking-wide text-faint">Inbox</p>
|
||
<ul aria-label={`${agent.name} inbox`} className="mt-1 flex flex-col gap-1">
|
||
{inbox.map((item) => (
|
||
<li key={item.id} className="flex min-w-0 items-start gap-2 text-xs text-muted">
|
||
<span className="mt-0.5 shrink-0 rounded-full bg-raised px-1.5 py-0.5 font-medium">{item.kind}</span>
|
||
<span className="min-w-0 flex-1 break-words">{item.body}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{backgroundTasks.length > 0 && (
|
||
<div className="mt-2">
|
||
<button
|
||
type="button"
|
||
aria-expanded={bgExpanded}
|
||
aria-controls={`web-bg-tasks-${agent.agentId}`}
|
||
aria-label={
|
||
bgExpanded
|
||
? `Masquer les background tasks de ${agent.name}`
|
||
: `Afficher les background tasks de ${agent.name}`
|
||
}
|
||
onClick={onToggleBgExpanded}
|
||
className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-faint hover:text-muted"
|
||
>
|
||
<span aria-hidden="true">{bgExpanded ? "▾" : "▸"}</span>
|
||
Background tasks
|
||
<span className="rounded-full bg-raised px-1.5 py-0.5 font-medium normal-case tracking-normal text-muted">
|
||
{backgroundTasks.length}
|
||
</span>
|
||
</button>
|
||
{bgExpanded && (
|
||
<ul
|
||
id={`web-bg-tasks-${agent.agentId}`}
|
||
aria-label={`${agent.name} background tasks`}
|
||
className="mt-1 flex flex-col gap-1"
|
||
>
|
||
{backgroundTasks.map((task) => (
|
||
<WebBackgroundTaskRow key={task.taskId} task={task} onRefresh={onRefresh} />
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
)}
|
||
</li>
|
||
);
|
||
}
|
||
|
||
const TASK_STATUS_LABEL: Record<BackgroundCompletion["status"], string> = {
|
||
running: "Running",
|
||
cancelling: "Cancelling",
|
||
completed: "Completed",
|
||
failed: "Failed",
|
||
cancelFailed: "Cancel failed",
|
||
cancelled: "Cancelled",
|
||
pending: "Pending",
|
||
delivered: "Delivered",
|
||
};
|
||
|
||
function taskStatusClass(status: BackgroundCompletion["status"]): string {
|
||
if (status === "running" || status === "pending" || status === "cancelling") {
|
||
return "bg-warning/15 text-warning";
|
||
}
|
||
if (status === "completed" || status === "delivered") return "bg-success/10 text-success";
|
||
if (status === "failed" || status === "cancelFailed" || status === "cancelled") {
|
||
return "bg-danger/10 text-danger";
|
||
}
|
||
return "bg-raised text-muted";
|
||
}
|
||
|
||
/** One background task with live status + cancel/retry via the DI gateway. */
|
||
function WebBackgroundTaskRow({
|
||
task,
|
||
onRefresh,
|
||
}: {
|
||
task: BackgroundCompletion;
|
||
onRefresh: () => Promise<void>;
|
||
}) {
|
||
const { workState } = useGateways();
|
||
const [actionBusy, setActionBusy] = useState(false);
|
||
const [message, setMessage] = useState<string | null>(null);
|
||
const canCancel = task.status === "running" || task.status === "pending";
|
||
const canRetry = task.status === "failed" || task.status === "cancelled";
|
||
|
||
async function runAction(action: (taskId: string) => Promise<void>): Promise<void> {
|
||
setActionBusy(true);
|
||
setMessage(null);
|
||
try {
|
||
await action(task.taskId);
|
||
await onRefresh();
|
||
} catch (e) {
|
||
setMessage(describe(e));
|
||
} finally {
|
||
setActionBusy(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<li className="min-w-0 text-xs text-muted">
|
||
{/* #69 — status + kind + Cancel + Retry overflow a phone row. On narrow
|
||
screens the status/kind pair keeps the first line and the two actions
|
||
wrap onto a second, right-aligned one; `sm` restores the single row. */}
|
||
<div className="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<span className={cn("shrink-0 rounded-full px-1.5 py-0.5 font-medium", taskStatusClass(task.status))}>
|
||
{TASK_STATUS_LABEL[task.status]}
|
||
</span>
|
||
<span className="min-w-0 flex-1 truncate text-content">{task.kind}</span>
|
||
</div>
|
||
<div className="flex shrink-0 items-center justify-end gap-1">
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
disabled={!canCancel || actionBusy}
|
||
loading={actionBusy}
|
||
onClick={() => void runAction((id) => workState.cancelBackgroundTask(id))}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
disabled={!canRetry || actionBusy}
|
||
loading={actionBusy}
|
||
onClick={() => void runAction((id) => workState.retryBackgroundTask(id))}
|
||
>
|
||
Retry
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{message && <p role="alert" className="mt-1 text-danger">{message}</p>}
|
||
</li>
|
||
);
|
||
}
|