fix(ui): Lot A #151 #158 #159 (QA verte)

- #151: chevauchement des boutons dans la barre supérieure pendant une conversation agent

- #158: popup de reprise de conversation intempestive (ajout cellule / changement layout / projet)

- #159: pastille d'activité de projet
This commit is contained in:
2026-08-06 10:23:23 +02:00
parent fb19ee48dc
commit c5d6c3b98e
10 changed files with 351 additions and 6 deletions

View File

@ -7,7 +7,7 @@
* read as faint text rather than a control.
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, within } from "@testing-library/react";
import type { TabItem } from "@/shared";
import { ProjectTabs } from "./ProjectTabs";
@ -27,6 +27,7 @@ function renderTabs(props: Partial<React.ComponentProps<typeof ProjectTabs>> = {
onClose={props.onClose ?? vi.fn()}
projectsPanelOpen={props.projectsPanelOpen ?? false}
onOpenProjectsPanel={onOpenProjectsPanel}
activityByProjectId={props.activityByProjectId}
/>,
);
return { onOpenProjectsPanel };
@ -74,3 +75,24 @@ describe("ProjectTabs add-project button (#46)", () => {
expect(onOpenProjectsPanel).toHaveBeenCalledTimes(1);
});
});
describe("ProjectTabs project activity dots (#159)", () => {
it("renders the neutral activity dot to the left of a project name by default", () => {
renderTabs({ items: TABS });
const tab = screen.getByRole("tab", { name: /alpha/ });
expect(
within(tab).getByTitle("alpha : aucune conversation active"),
).toBeTruthy();
});
it("renders idle and busy project activity states", () => {
renderTabs({
items: TABS,
activityByProjectId: { a: "idle", b: "busy" },
});
expect(screen.getByTitle("alpha : conversation ouverte")).toBeTruthy();
expect(screen.getByTitle("beta : activité en cours")).toBeTruthy();
});
});

View File

@ -12,6 +12,7 @@
import type { TabItem } from "@/shared";
import { Button, Tabs, cn } from "@/shared";
import type { ProjectActivityStatus } from "./projectActivity";
export interface ProjectTabsProps {
items: TabItem[];
@ -22,6 +23,8 @@ export interface ProjectTabsProps {
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;
}
@ -32,8 +35,19 @@ export function ProjectTabs({
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(
@ -45,7 +59,7 @@ export function ProjectTabs({
<p className="px-2 text-sm text-muted">No open tabs.</p>
) : (
<Tabs
items={items}
items={tabs}
value={activeTabId}
onSelect={onSelect}
onClose={onClose}
@ -72,3 +86,32 @@ export function ProjectTabs({
</div>
);
}
function ProjectActivityDot({
status,
label,
}: {
status: ProjectActivityStatus;
label: string;
}) {
const title =
status === "busy"
? `${label} : activité en cours`
: status === "idle"
? `${label} : conversation ouverte`
: `${label} : aucune conversation active`;
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-border-strong bg-muted",
)}
/>
);
}

View File

@ -70,6 +70,7 @@ import {
} from "@/shared";
import { useGateways } from "@/app/di";
import { useProjects } from "./useProjects";
import { useProjectActivity } from "./useProjectActivity";
import { ProjectTabs } from "./ProjectTabs";
import { ViewPanelBody, type ViewPanelId } from "./ViewPanelBody";
import {
@ -201,6 +202,7 @@ function rendezvousToastStateLabel(state: string): string {
export function ProjectsView() {
const vm = useProjects();
const projectActivity = useProjectActivity(vm.openTabs.map((tab) => tab.id));
const {
system,
window: windowGateway,
@ -859,6 +861,7 @@ export function ProjectsView() {
activeTabId={vm.activeTabId}
onSelect={(id) => vm.activateTab(id)}
onClose={(id) => void vm.closeTab(id)}
activityByProjectId={projectActivity}
projectsPanelOpen={placementOf(placements, "projects") !== "closed"}
onOpenProjectsPanel={() => {
setSettingsSection(null);

View File

@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { projectActivityStatus } from "./projectActivity";
describe("projectActivityStatus (#159)", () => {
it("is none when no custom CLI conversation is visible in the work-state", () => {
expect(projectActivityStatus({ agents: [], conversations: [] })).toBe("none");
});
it("is idle when a structured custom CLI session has a conversation but no work", () => {
expect(
projectActivityStatus({
agents: [
{
agentId: "a1",
name: "A",
profileId: "p",
live: { kind: "structured", nodeId: "n1", sessionId: "s1" },
busy: { state: "idle" },
tickets: [],
},
],
conversations: [],
}),
).toBe("idle");
});
it("is busy when any agent is busy", () => {
expect(
projectActivityStatus({
agents: [
{
agentId: "a1",
name: "A",
profileId: "p",
busy: { state: "busy", ticket: "ticket-1", sinceMs: 1 },
tickets: [],
},
],
conversations: [],
}),
).toBe("busy");
});
it("is busy for running or pending background work tied to a conversation", () => {
expect(
projectActivityStatus({
agents: [
{
agentId: "a1",
name: "A",
profileId: "p",
busy: { state: "idle" },
tickets: [],
backgroundTasks: [
{
taskId: "t1",
ownerAgentId: "a1",
projectId: "p1",
kind: "command",
status: "running",
exitCode: null,
summary: null,
stdoutTail: null,
stderrTail: null,
conversationId: "c1",
updatedAtMs: 1,
},
],
},
],
conversations: [],
}),
).toBe("busy");
});
});

View File

@ -0,0 +1,34 @@
import type { BackgroundCompletion, ProjectWorkState } from "@/domain";
export type ProjectActivityStatus = "none" | "idle" | "busy";
const ACTIVE_BACKGROUND_STATUSES: ReadonlySet<BackgroundCompletion["status"]> =
new Set(["pending", "running"]);
export function projectActivityStatus(
state: ProjectWorkState | null | undefined,
): ProjectActivityStatus {
if (!state) return "none";
const hasBusyAgent = state.agents.some((agent) => {
if (agent.busy.state === "busy") return true;
if (agent.tickets.some((ticket) => ticket.status === "inProgress")) return true;
return (agent.backgroundTasks ?? []).some((task) =>
ACTIVE_BACKGROUND_STATUSES.has(task.status),
);
});
if (hasBusyAgent) return "busy";
const hasConversation = state.conversations.length > 0 ||
state.agents.some((agent) => {
if (agent.live?.kind === "structured") return true;
if (agent.tickets.some((ticket) => ticket.conversationId.trim() !== "")) {
return true;
}
return (agent.backgroundTasks ?? []).some(
(task) => task.conversationId?.trim(),
);
});
return hasConversation ? "idle" : "none";
}

View File

@ -0,0 +1,84 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ProjectWorkState } from "@/domain";
import { useGateways } from "@/app/di";
import {
projectActivityStatus,
type ProjectActivityStatus,
} from "./projectActivity";
export type ProjectActivityMap = Record<string, ProjectActivityStatus>;
const REFRESH_EVENTS = new Set([
"agentLaunched",
"agentExited",
"agentBusyChanged",
"delegationReady",
"orchestratorRequestProcessed",
"backgroundTaskChanged",
"agentInboxChanged",
"agentWakeChanged",
]);
export function useProjectActivity(projectIds: string[]): ProjectActivityMap {
const { system, workState } = useGateways();
const projectKey = [...new Set(projectIds)].sort().join("\0");
const stableProjectIds = useMemo(
() => [...new Set(projectIds)].sort(),
// eslint-disable-next-line react-hooks/exhaustive-deps
[projectKey],
);
const [states, setStates] = useState<Record<string, ProjectWorkState | null>>(
{},
);
const refresh = useCallback(async () => {
if (stableProjectIds.length === 0) {
setStates({});
return;
}
const entries = await Promise.all(
stableProjectIds.map(async (projectId) => {
try {
return [
projectId,
await workState.getProjectWorkState(projectId),
] as const;
} catch {
return [projectId, null] as const;
}
}),
);
setStates(Object.fromEntries(entries));
}, [stableProjectIds, workState]);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
if (!system) return;
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void system
.onDomainEvent((event) => {
if (REFRESH_EVENTS.has(event.type)) void refresh();
})
.then((un) => {
if (cancelled) un();
else unsubscribe = un;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [refresh, system]);
return useMemo(() => {
const next: ProjectActivityMap = {};
for (const projectId of stableProjectIds) {
next[projectId] = projectActivityStatus(states[projectId]);
}
return next;
}, [stableProjectIds, states]);
}