WebWorkspace gagne un menu « Panneaux » qui route la surface principale du projet ouvert vers les panneaux transport-neutres déjà réutilisables (contexte, agents, templates, skills, permissions, mémoire, git) en plus des surfaces web existantes (travail live, tickets, sprints), ainsi qu'un variant web du panneau Projets (création par chemin serveur saisi manuellement, sans browse natif). WebApp gagne un menu « Paramètres » (Profils IA, Appareils, Déploiement désactivé "Desktop uniquement") qui remplace l'ancien bouton unique "Appareils". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
564 lines
19 KiB
TypeScript
564 lines
19 KiB
TypeScript
/**
|
|
* Live web workspace — ticket #13/#86, extended by #90 (missing menus).
|
|
*
|
|
* 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).
|
|
*
|
|
* 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, which the web client never
|
|
* mounts (#69 pins this — no `layout-*` grid on web). This panel's own "Ouvrir"
|
|
* instead mounts {@link WebAgentCell}, a touch-drivable terminal cell (F4) — the
|
|
* mobile equivalent, 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, useState } from "react";
|
|
|
|
import type {
|
|
AgentWorkState,
|
|
BackgroundCompletion,
|
|
GatewayError,
|
|
Project,
|
|
} from "@/domain";
|
|
import { useGateways } from "@/app/di";
|
|
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
|
|
import { ProjectContextPanel } from "@/features/projects/ProjectContextPanel";
|
|
import { AgentsPanel } from "@/features/agents";
|
|
import { TemplatesPanel } from "@/features/templates";
|
|
import { SkillsPanel } from "@/features/skills";
|
|
import { PermissionsPanel } from "@/features/permissions";
|
|
import { MemoryPanel } from "@/features/memory";
|
|
import { EmbedderSettings } from "@/features/embedder";
|
|
import { GitPanel } from "@/features/git";
|
|
import { Button, Panel, Spinner, cn } 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";
|
|
|
|
function describe(e: unknown): string {
|
|
if (e && typeof e === "object" && "message" in e) {
|
|
return String((e as GatewayError).message);
|
|
}
|
|
return String(e);
|
|
}
|
|
|
|
/** The panels reachable from the web "Panneaux" menu (#90). */
|
|
export type WebProjectPanelId =
|
|
| "context"
|
|
| "work"
|
|
| "tickets"
|
|
| "sprints"
|
|
| "agents"
|
|
| "templates"
|
|
| "skills"
|
|
| "permissions"
|
|
| "memory"
|
|
| "git"
|
|
| "projects";
|
|
|
|
/** 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_LABEL: Record<WebProjectPanelId, string> = {
|
|
context: "Contexte projet",
|
|
work: "Travail",
|
|
tickets: "Tickets",
|
|
sprints: "Sprints",
|
|
agents: "Agents",
|
|
templates: "Templates",
|
|
skills: "Skills",
|
|
permissions: "Permissions",
|
|
memory: "Mémoire",
|
|
git: "Git",
|
|
projects: "Projets",
|
|
};
|
|
|
|
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 openRoot = projects?.find((p) => p.id === openId)?.root ?? null;
|
|
|
|
const refresh = useCallback(async () => {
|
|
setError(null);
|
|
try {
|
|
setProjects(await project.listProjects());
|
|
} catch (e) {
|
|
setError(describe(e));
|
|
}
|
|
}, [project]);
|
|
|
|
useEffect(() => {
|
|
void refresh();
|
|
}, [refresh]);
|
|
|
|
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],
|
|
);
|
|
|
|
return (
|
|
<div
|
|
data-testid="web-workspace"
|
|
className="flex h-full flex-col gap-4 overflow-y-auto p-4 pb-[max(1rem,env(safe-area-inset-bottom))] pl-[max(1rem,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))] sm:p-6"
|
|
>
|
|
<ReconnectBanner />
|
|
|
|
<WebPanelMenuButton activePanel={panel} hasProject={Boolean(openId)} onSelect={setPanel} />
|
|
|
|
{error && (
|
|
<Panel className="border-danger/40">
|
|
<p className="text-sm text-danger">{error}</p>
|
|
</Panel>
|
|
)}
|
|
|
|
{panel === "projects" ? (
|
|
<WebProjectsPanel
|
|
projects={projects}
|
|
openId={openId}
|
|
onOpen={(id) => void openReadOnly(id)}
|
|
onCreate={createProject}
|
|
onRefresh={() => void refresh()}
|
|
/>
|
|
) : !openId ? (
|
|
<p className="text-sm text-muted">Ouvrir un projet pour utiliser ce panneau.</p>
|
|
) : (
|
|
<WebPanelBody
|
|
panel={panel}
|
|
projectId={openId}
|
|
root={openRoot}
|
|
focusSprintId={focusSprintId}
|
|
onViewSprintTickets={(sprintId) => {
|
|
setFocusSprintId(sprintId);
|
|
setPanel("tickets");
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Compact trigger + full-screen sheet for the "Panneaux" menu (#90). */
|
|
function WebPanelMenuButton({
|
|
activePanel,
|
|
hasProject,
|
|
onSelect,
|
|
}: {
|
|
activePanel: WebProjectPanelId;
|
|
hasProject: boolean;
|
|
onSelect: (panel: WebProjectPanelId) => void;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
return (
|
|
<div className="flex items-center justify-between">
|
|
<Button variant="ghost" size="sm" onClick={() => setOpen(true)}>
|
|
Panneaux : {WEB_PANEL_LABEL[activePanel]}
|
|
</Button>
|
|
{open && (
|
|
<WebMenuSheet
|
|
title="Panneaux"
|
|
note={
|
|
!hasProject ? "Ouvrir un projet pour utiliser ce panneau." : undefined
|
|
}
|
|
onClose={() => setOpen(false)}
|
|
entries={WEB_PANEL_ORDER.map((id) => {
|
|
const disabled = id !== "projects" && !hasProject;
|
|
return {
|
|
id,
|
|
label: WEB_PANEL_LABEL[id],
|
|
active: id === activePanel,
|
|
disabled,
|
|
hint: disabled ? "Ouvrir un projet pour utiliser ce panneau." : undefined,
|
|
onSelect: () => {
|
|
onSelect(id);
|
|
setOpen(false);
|
|
},
|
|
};
|
|
})}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Renders the requested panel's body for the opened project. */
|
|
function WebPanelBody({
|
|
panel,
|
|
projectId,
|
|
root,
|
|
focusSprintId,
|
|
onViewSprintTickets,
|
|
}: {
|
|
panel: Exclude<WebProjectPanelId, "projects">;
|
|
projectId: string;
|
|
root: string | null;
|
|
focusSprintId: string | null;
|
|
onViewSprintTickets: (sprintId: string) => void;
|
|
}) {
|
|
switch (panel) {
|
|
case "context":
|
|
return <ProjectContextPanel projectId={projectId} />;
|
|
case "work":
|
|
return <LiveProjectPanel projectId={projectId} root={root} />;
|
|
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 "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} />;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 }: { projectId: string; root: string | null }) {
|
|
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);
|
|
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;
|
|
});
|
|
}, []);
|
|
|
|
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))
|
|
}
|
|
onRefresh={vm.refresh}
|
|
bgExpanded={expandedBgAgents.has(a.agentId)}
|
|
onToggleBgExpanded={() => toggleBgExpanded(a.agentId)}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{root && openAgentId && (
|
|
<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)}>
|
|
Fermer
|
|
</Button>
|
|
</div>
|
|
<WebAgentCell key={openAgentId} projectId={projectId} agentId={openAgentId} cwd={root} />
|
|
</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",
|
|
completed: "Completed",
|
|
failed: "Failed",
|
|
cancelled: "Cancelled",
|
|
pending: "Pending",
|
|
delivered: "Delivered",
|
|
};
|
|
|
|
function taskStatusClass(status: BackgroundCompletion["status"]): string {
|
|
if (status === "running" || status === "pending") return "bg-warning/15 text-warning";
|
|
if (status === "completed" || status === "delivered") return "bg-success/10 text-success";
|
|
if (status === "failed" || 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>
|
|
);
|
|
}
|