feat(frontend): tickets/sprints surface for the web workspace (#86 lot 2)

Adds a Live/Tickets/Sprints tab navigation to WebWorkspace after a project
is opened — a mobile-first adaptation, not a port of the desktop docks/
floating windows, per carnet #86. The web-server transport (17 ticket_*/
sprint_* commands) and the HttpTicketGateway were already wired (lot 1 +
pre-existing gateway code); this lot is UI only.

- Tickets tab: search/filters, list grouped by sprint, create screen,
  detail view with stacked accordion sections (Résumé/Statut et priorité/
  Carnet open by default; Agents assignés/Liens/Zone dangereuse collapsed),
  delete confirmation, optimistic-concurrency conflict banner.
- Sprints tab: create/rename/reorder (Monter/Descendre)/delete with
  confirmation, add tickets via a mobile full-screen ticket picker (never
  a small desktop modal), "Voir tickets" filters the Tickets tab to one
  sprint.
- Reuses the transport-neutral hooks as-is (useTickets, useTicketDetail,
  useTicketSearch) — only presentation and copy are web-specific, in
  French per decision #78 (new local label module, not a reuse of the
  English desktop ticketMeta labels).
- Workaround: `list_agents` isn't in the web-server allowlist, so agent
  names/assignment options are derived from `get_project_work_state`
  (already used by the Live tab) instead of the desktop's useProjectAgents.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 07:35:20 +02:00
parent 45def4844a
commit e69361feb7
11 changed files with 1942 additions and 1 deletions

View File

@ -29,6 +29,8 @@ 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";
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
@ -118,12 +120,109 @@ export function WebWorkspace() {
)}
{openId && (
<LiveProjectPanel projectId={openId} root={openRoot} />
<ProjectTabs projectId={openId} root={openRoot} />
)}
</div>
);
}
type ProjectTab = "live" | "tickets" | "sprints";
const PROJECT_TABS: { id: ProjectTab; label: string }[] = [
{ id: "live", label: "Live" },
{ id: "tickets", label: "Tickets" },
{ id: "sprints", label: "Sprints" },
];
/**
* Project navigation for the web workspace (ticket #86): `Live` / `Tickets` /
* `Sprints`, a compact tablist above the project surfaces — never the desktop
* dock/layout-grid/floating-window shell (carnet #86). Each tab keeps its own
* data (own hook instance), so switching away and back re-fetches fresh rather
* than caching stale state across tabs.
*/
function ProjectTabs({ projectId, root }: { projectId: string; root: string | null }) {
const [tab, setTab] = useState<ProjectTab>("live");
// Set by the Sprints tab's "Voir tickets"; consumed once by the Tickets tab.
const [focusSprintId, setFocusSprintId] = useState<string | null>(null);
return (
<div className="mt-2 flex flex-col gap-3">
<div
role="tablist"
aria-label="Navigation du projet"
className="flex gap-1 overflow-x-auto border-b border-border"
>
{PROJECT_TABS.map((t) => (
<button
key={t.id}
type="button"
role="tab"
id={`web-project-tab-${t.id}`}
aria-selected={tab === t.id}
aria-controls={`web-project-tabpanel-${t.id}`}
tabIndex={tab === t.id ? 0 : -1}
onClick={() => setTab(t.id)}
onKeyDown={(e) => {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
const i = PROJECT_TABS.findIndex((x) => x.id === tab);
const next =
e.key === "ArrowRight"
? (i + 1) % PROJECT_TABS.length
: (i - 1 + PROJECT_TABS.length) % PROJECT_TABS.length;
setTab(PROJECT_TABS[next].id);
}}
className={cn(
"min-h-[32px] shrink-0 rounded-t-md border-b-2 px-3 py-1.5 text-sm font-medium transition-colors",
tab === t.id
? "border-primary text-content"
: "border-transparent text-muted hover:text-content",
)}
>
{t.label}
</button>
))}
</div>
<div
role="tabpanel"
id="web-project-tabpanel-live"
aria-labelledby="web-project-tab-live"
hidden={tab !== "live"}
>
{tab === "live" && <LiveProjectPanel projectId={projectId} root={root} />}
</div>
<div
role="tabpanel"
id="web-project-tabpanel-tickets"
aria-labelledby="web-project-tab-tickets"
hidden={tab !== "tickets"}
>
{tab === "tickets" && (
<WebTicketsView projectId={projectId} focusSprintId={focusSprintId} />
)}
</div>
<div
role="tabpanel"
id="web-project-tabpanel-sprints"
aria-labelledby="web-project-tab-sprints"
hidden={tab !== "sprints"}
>
{tab === "sprints" && (
<WebSprintsView
projectId={projectId}
onViewSprintTickets={(sprintId) => {
setFocusSprintId(sprintId);
setTab("tickets");
}}
/>
)}
</div>
</div>
);
}
/**
* 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