- #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:
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
76
frontend/src/features/projects/projectActivity.test.ts
Normal file
76
frontend/src/features/projects/projectActivity.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
34
frontend/src/features/projects/projectActivity.ts
Normal file
34
frontend/src/features/projects/projectActivity.ts
Normal 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";
|
||||
}
|
||||
84
frontend/src/features/projects/useProjectActivity.ts
Normal file
84
frontend/src/features/projects/useProjectActivity.ts
Normal 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user