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

@ -48,8 +48,10 @@ if (typeof globalThis.ResizeObserver === "undefined") {
import type { DomainEvent } from "@/domain";
import type { Gateways } from "@/ports";
import {
MOCK_REFERENCE_PROFILES,
MockAgentGateway,
MockLayoutGateway,
MockProfileGateway,
MockSystemGateway,
MockTerminalGateway,
} from "@/adapters/mock";
@ -69,12 +71,14 @@ interface GridSetup {
async function makeSplitAgentGrid(): Promise<GridSetup> {
const layout = new MockLayoutGateway();
const agentGateway = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
await profileGateway.saveProfile(MOCK_REFERENCE_PROFILES[0]);
const agent = await agentGateway.createAgent("p1", {
name: "Worker",
profileId: "claude",
profileId: "mock-claude",
});
const initial = await layout.loadLayout("p1");
@ -98,6 +102,7 @@ async function makeSplitAgentGrid(): Promise<GridSetup> {
gateways: {
layout,
agent: agentGateway,
profile: profileGateway,
terminal,
system,
} as unknown as Gateways,
@ -174,6 +179,24 @@ function expectedOperation(action: CellAction, cellId: string) {
}
describe("LayoutGrid — ticket #48 cell control layering", () => {
it("keeps custom CLI mode controls compact inside the cell toolbar (#151)", async () => {
const setup = await makeSplitAgentGrid();
renderGrid(setup.gateways);
const group = await screen.findByRole("group", {
name: `mode CLI agent ${setup.bId}`,
});
const { controls } = controlsFor(setup.bId);
expect(group.textContent).toBe("TUI nativeCLI custom");
expect(group.style.maxWidth).toBe("128px");
expect(controls.style.left).toBe("4px");
expect(controls.style.right).toBe("16px");
expect(controls.style.justifyContent).toBe("flex-end");
expect(controls.style.overflow).toBe("hidden");
});
it.each<CellAction>(["selector", "split", "close"])(
"keeps %s above/clickable over a terminal launch error",
async (action) => {

View File

@ -922,6 +922,13 @@ function LeafView({
if (!conversationId) {
return doLaunch(opts, onData, undefined);
}
// If the cell was closed/remounted while the agent was running, resume
// silently. Layout/project changes must not interrupt an ongoing turn
// with a modal choice; the persisted conversation id is the continuity
// signal and `agentWasRunning` tells us this was not an intentional stop.
if (agentWasRunning) {
return doLaunch(opts, onData, conversationId);
}
// Resume case: defer the launch behind the popup. Fetch the best-effort
// enriched details (last topic + tokens) to enrich it; failure or empty
// ⇒ degraded mode (status only). Inspection never blocks the resume.
@ -982,6 +989,7 @@ function LeafView({
style={{
position: "absolute",
top: 2,
left: 4,
// Clear the xterm viewport scrollbar (rendered flush against the right
// edge, ~15px wide). Without this offset the right-most control — the
// close button — sits *behind* the scrollbar and is hard to click.
@ -992,6 +1000,8 @@ function LeafView({
display: "flex",
gap: 2,
alignItems: "center",
justifyContent: "flex-end",
overflow: "hidden",
}}
>
{/* Agent selector */}
@ -1053,7 +1063,8 @@ function LeafView({
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 3,
padding: "1px 2px",
maxWidth: 100,
width: 96,
minWidth: 56,
}}
>
<option value="">Plain</option>
@ -1079,6 +1090,7 @@ function LeafView({
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 3,
background: "var(--color-surface, #1e1e1e)",
maxWidth: 128,
}}
>
<button
@ -1100,6 +1112,10 @@ function LeafView({
fontSize: 11,
padding: "1px 6px",
cursor: "pointer",
maxWidth: 66,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
TUI native
@ -1122,6 +1138,10 @@ function LeafView({
fontSize: 11,
padding: "1px 6px",
cursor: "pointer",
maxWidth: 70,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
CLI custom

View File

@ -313,4 +313,40 @@ describe("LayoutGrid — resume popup wiring (T7)", () => {
expect(screen.queryByTestId("resume-conversation-popup")).toBeNull();
expect(inspectConversation).not.toHaveBeenCalled();
});
it("does not show the popup for a remounted cell whose agent was still running (#158)", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const a = await agent.createAgent("p1", { name: "MyAgent", profileId: "p" });
const leafId = await seedResumeLeaf(layout, a.id);
await layout.mutateLayout("p1", {
type: "setAgentRunning",
target: leafId,
running: true,
});
const launchAgent = vi.fn(
async (
_p: string,
_ag: string,
_opts: OpenTerminalOptions,
_onData: (b: Uint8Array) => void,
) => makeHandle("sess-running-resume"),
);
const inspectConversation = vi.fn();
const stubAgent = {
listAgents: () => Promise.resolve([a]),
launchAgent,
reattach: vi.fn(),
inspectConversation,
};
renderGrid(layout, stubAgent);
await waitFor(() => expect(launchAgent).toHaveBeenCalledTimes(1));
expect(screen.queryByTestId("resume-conversation-popup")).toBeNull();
expect(inspectConversation).not.toHaveBeenCalled();
const opts = launchAgent.mock.calls[0][2] as OpenTerminalOptions;
expect(opts.conversationId).toBe("prior-conv");
});
});

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]);
}

View File

@ -7,12 +7,15 @@
import { cn } from "../lib/cn";
import { IconButton } from "./IconButton";
import type { ReactNode } from "react";
export interface TabItem {
/** Stable id, returned by `onSelect`/`onClose`. */
id: string;
/** Visible label. */
label: string;
/** Optional leading visual, such as a status indicator. */
leading?: ReactNode;
}
export interface TabsProps {
@ -52,11 +55,12 @@ export function Tabs({ items, value, onSelect, onClose, className }: TabsProps)
aria-selected={active}
onClick={() => onSelect(tab.id)}
className={cn(
"px-2 py-1 text-sm focus-visible:outline-none",
"flex items-center gap-1.5 px-2 py-1 text-sm focus-visible:outline-none",
active ? "font-semibold text-content" : "text-muted hover:text-content",
)}
>
{tab.label}
{tab.leading}
<span>{tab.label}</span>
</button>
{onClose && (
<IconButton