Files
IdeA/frontend/src/features/projects/ProjectTabs.tsx
Blomios b167226179 feat(ui): LED projet — exploite les signaux d'interaction CLI custom (#198, QA verte)
Projette l'état d'interaction agent→projet sur la LED via des signaux explicites de la CLI custom:

- projectCliInteractionActivity: modèle d'activité d'interaction (InteractionStarted/Finished/Stopped, interactionId/seq, version).

- useProjectActivity + ProjectTabs: projection live « interaction en cours » → LED, retour immédiat à l'état attendu au stop ou à la fin réelle de l'échange (pas d'état collant).

- CustomAgentChatView: émet les signaux d'interaction CLI custom (début d'envoi, fin de tour, stop) consommés par l'activité projet — sans dériver la LED du texte streamé ou d'heuristiques.

Tout côté frontend: aucun signal runtime manquant (les signaux proviennent de la CLI custom).

Tests QA verts: projectActivity.test.ts, ProjectTabs.test.tsx (+ useProjectActivity.test.tsx, CustomAgentChatView.test.tsx).
2026-08-12 15:44:30 +02:00

116 lines
3.3 KiB
TypeScript

/**
* `ProjectTabs` — the project tab bar rendered below the app header.
*
* Shows one tab per open project with a close control, and a "+" button to the
* right that opens the **Projects** panel (floating) — the single entry point
* for creating a project or switching to a known one (#26). Purely
* presentational: all state lives in the parent (ProjectsView via useProjects).
*
* The bar is always present (even with no open project) so the "+" stays
* reachable; the empty case shows a muted placeholder next to it.
*/
import type { TabItem } from "@/shared";
import { Button, Tabs, cn } from "@/shared";
import type { ProjectActivityStatus } from "./projectActivity";
export interface ProjectTabsProps {
items: TabItem[];
activeTabId: string | null;
onSelect: (id: string) => void;
onClose: (id: string) => void;
/** Whether the Projects panel is currently open (in any placement). */
projectsPanelOpen: boolean;
/** Open the Projects panel (floating) — create/switch project. */
onOpenProjectsPanel: () => void;
/** Per-project custom CLI activity, keyed by project id. Missing = none. */
activityByProjectId?: Record<string, ProjectActivityStatus>;
className?: string;
}
export function ProjectTabs({
items,
activeTabId,
onSelect,
onClose,
projectsPanelOpen,
onOpenProjectsPanel,
activityByProjectId = {},
className,
}: ProjectTabsProps) {
const tabs = items.map((item) => ({
...item,
leading: (
<ProjectActivityDot
status={activityByProjectId[item.id] ?? "none"}
label={item.label}
/>
),
}));
return (
<div
className={cn(
"flex shrink-0 items-center gap-1 border-b border-border bg-surface px-2 py-1",
className,
)}
>
{items.length === 0 ? (
<p className="px-2 text-sm text-muted">No open tabs.</p>
) : (
<Tabs
items={tabs}
value={activeTabId}
onSelect={onSelect}
onClose={onClose}
/>
)}
{/* Add-project affordance (#46): always a bordered `secondary` button so
it reads as a clickable control in every state — including zero open
projects — instead of the near-invisible `ghost` glyph it used to be.
When the Projects panel is open it also shows the pressed ring. */}
<Button
size="sm"
variant="secondary"
onClick={onOpenProjectsPanel}
aria-label="open projects panel"
aria-pressed={projectsPanelOpen}
title="Ajouter un projet"
className={cn(
"shrink-0",
projectsPanelOpen && "ring-1 ring-border-strong",
)}
>
+
</Button>
</div>
);
}
function ProjectActivityDot({
status,
label,
}: {
status: ProjectActivityStatus;
label: string;
}) {
const title =
status === "busy"
? `${label} : Échange en cours`
: `${label} : Prêt`;
return (
<span
data-testid={`project-activity-${label}`}
aria-hidden="true"
title={title}
className={cn(
"h-2.5 w-2.5 shrink-0 rounded-full border",
status === "busy" &&
"animate-pulse border-warning bg-warning shadow-[0_0_0_2px_rgba(212,155,58,0.16)]",
status === "idle" && "border-success bg-success",
status === "none" && "border-success bg-success",
)}
/>
);
}