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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user