fix(ui): toolbar CLI sans overlap + retrait du bouton History — #151 #160 (QA verte)

This commit is contained in:
2026-08-06 13:13:30 +02:00
parent 309c10e5e9
commit 03303aa276
2 changed files with 55 additions and 31 deletions

View File

@ -9,6 +9,7 @@
*/ */
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ComponentProps } from "react";
vi.mock("@xterm/xterm", () => ({ vi.mock("@xterm/xterm", () => ({
Terminal: class { Terminal: class {
@ -54,6 +55,7 @@ import {
MockProfileGateway, MockProfileGateway,
MockSystemGateway, MockSystemGateway,
MockTerminalGateway, MockTerminalGateway,
MockWorkStateGateway,
} from "@/adapters/mock"; } from "@/adapters/mock";
import { DIProvider } from "@/app/di"; import { DIProvider } from "@/app/di";
import { AnnouncementsProvider } from "@/features/announcements"; import { AnnouncementsProvider } from "@/features/announcements";
@ -66,6 +68,7 @@ interface GridSetup {
gateways: Gateways; gateways: Gateways;
layout: MockLayoutGateway; layout: MockLayoutGateway;
system: MockSystemGateway; system: MockSystemGateway;
workState: MockWorkStateGateway;
} }
async function makeSplitAgentGrid(): Promise<GridSetup> { async function makeSplitAgentGrid(): Promise<GridSetup> {
@ -74,6 +77,7 @@ async function makeSplitAgentGrid(): Promise<GridSetup> {
const profileGateway = new MockProfileGateway(); const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway(); const terminal = new MockTerminalGateway();
const system = new MockSystemGateway(); const system = new MockSystemGateway();
const workState = new MockWorkStateGateway();
await profileGateway.saveProfile(MOCK_REFERENCE_PROFILES[0]); await profileGateway.saveProfile(MOCK_REFERENCE_PROFILES[0]);
const agent = await agentGateway.createAgent("p1", { const agent = await agentGateway.createAgent("p1", {
@ -105,17 +109,22 @@ async function makeSplitAgentGrid(): Promise<GridSetup> {
profile: profileGateway, profile: profileGateway,
terminal, terminal,
system, system,
workState,
} as unknown as Gateways, } as unknown as Gateways,
layout, layout,
system, system,
workState,
}; };
} }
function renderGrid(gateways: Gateways) { function renderGrid(
gateways: Gateways,
props: Partial<ComponentProps<typeof LayoutGrid>> = {},
) {
return render( return render(
<DIProvider gateways={gateways}> <DIProvider gateways={gateways}>
<AnnouncementsProvider> <AnnouncementsProvider>
<LayoutGrid projectId="p1" cwd="/home/me/proj" /> <LayoutGrid projectId="p1" cwd="/home/me/proj" {...props} />
</AnnouncementsProvider> </AnnouncementsProvider>
</DIProvider>, </DIProvider>,
); );
@ -197,6 +206,44 @@ describe("LayoutGrid — ticket #48 cell control layering", () => {
expect(controls.style.overflow).toBe("hidden"); expect(controls.style.overflow).toBe("hidden");
}); });
it("keeps busy Cancel above persistent cell controls and removes History (#151, #160)", async () => {
const setup = await makeSplitAgentGrid();
await setup.layout.mutateLayout("p1", {
type: "setCellConversation",
target: setup.bId,
conversationId: "prior-conv",
});
setup.workState._setProjectWorkState("p1", {
agents: [
{
agentId: setup.agentId,
name: "Worker",
profileId: "mock-claude",
busy: { state: "busy", sinceMs: 1 },
},
],
});
renderGrid(setup.gateways, { onOpenConversation: vi.fn() });
await flush();
act(() => setup.system.emit(busy(setup.agentId, true)));
const cancel = await screen.findByRole("button", {
name: `cancel current turn ${setup.bId}`,
});
const status = cancel.parentElement as HTMLElement;
const { controls } = controlsFor(setup.bId);
expect(status.style.top).toBe("24px");
expect(status.style.zIndex).toBe("6");
expect(Number(status.style.zIndex)).toBeGreaterThan(Number(controls.style.zIndex));
expect(screen.queryByRole("button", {
name: `open cell conversation ${setup.bId}`,
})).toBeNull();
expect(screen.queryByText("History")).toBeNull();
});
it.each<CellAction>(["selector", "split", "close"])( it.each<CellAction>(["selector", "split", "close"])(
"keeps %s above/clickable over a terminal launch error", "keeps %s above/clickable over a terminal launch error",
async (action) => { async (action) => {

View File

@ -200,7 +200,6 @@ function NodeView({
workState={workState} workState={workState}
refreshWorkState={refreshWorkState} refreshWorkState={refreshWorkState}
refitSignal={refitSignal} refitSignal={refitSignal}
onOpenConversation={onOpenConversation}
/> />
); );
case "split": case "split":
@ -247,7 +246,6 @@ interface LeafViewProps {
workState: ProjectWorkState | null; workState: ProjectWorkState | null;
refreshWorkState: () => Promise<void>; refreshWorkState: () => Promise<void>;
refitSignal: number; refitSignal: number;
onOpenConversation?: (conversationId: string) => void;
} }
/** /**
@ -339,11 +337,13 @@ function goToCell(nodeId: string): void {
* status/error 3 (in-cell status strip, launch-error banner, notices) * status/error 3 (in-cell status strip, launch-error banner, notices)
* full-cell veils 4 (write-portal + F3 overlays; mutually exclusive) * full-cell veils 4 (write-portal + F3 overlays; mutually exclusive)
* cell controls 5 (always on top and clickable) * cell controls 5 (always on top and clickable)
* turn actions 6 (busy-state actions; Cancel must stay visible)
*/ */
const CELL_Z = { const CELL_Z = {
banner: 3, banner: 3,
veil: 4, veil: 4,
controls: 5, controls: 5,
turnActions: 6,
} as const; } as const;
function LeafView({ function LeafView({
@ -359,7 +359,6 @@ function LeafView({
workState, workState,
refreshWorkState, refreshWorkState,
refitSignal, refitSignal,
onOpenConversation,
}: LeafViewProps) { }: LeafViewProps) {
// A cell can be closed only when it lives inside a (binary) split: closing it // A cell can be closed only when it lives inside a (binary) split: closing it
// collapses the parent split, keeping the *sibling*. Splits are always binary // collapses the parent split, keeping the *sibling*. Splits are always binary
@ -679,9 +678,6 @@ function LeafView({
) ?? []; ) ?? [];
const busyByWorkState = agentWork?.busy.state === "busy" || Boolean(activeTicket); const busyByWorkState = agentWork?.busy.state === "busy" || Boolean(activeTicket);
const delegatedTicket = activeTicket?.source.kind === "agent" ? activeTicket : undefined; const delegatedTicket = activeTicket?.source.kind === "agent" ? activeTicket : undefined;
const historyConversationId =
activeTicket?.conversationId ?? conversationId ?? null;
async function interruptCurrentTurn(): Promise<void> { async function interruptCurrentTurn(): Promise<void> {
if (!agentId || !input) return; if (!agentId || !input) return;
try { try {
@ -1176,20 +1172,21 @@ function LeafView({
</button> </button>
)} )}
</div> </div>
{agentId && (busyByWorkState || inboxDepth > 0 || completedBackgroundTasks.length > 0 || historyConversationId) && ( {agentId && (busyByWorkState || inboxDepth > 0 || completedBackgroundTasks.length > 0) && (
<div <div
role="status" role="status"
aria-live="polite" aria-live="polite"
style={{ style={{
position: "absolute", position: "absolute",
top: 30, top: 24,
left: 4, left: 4,
zIndex: CELL_Z.banner, zIndex: busyByWorkState ? CELL_Z.turnActions : CELL_Z.banner,
display: "flex", display: "flex",
maxWidth: "calc(100% - 8px)", maxWidth: "calc(100% - 8px)",
alignItems: "center", alignItems: "center",
gap: 4, gap: 4,
overflow: "hidden", overflow: "hidden",
pointerEvents: "auto",
}} }}
> >
{busyByWorkState && ( {busyByWorkState && (
@ -1289,26 +1286,6 @@ function LeafView({
Cancel Cancel
</button> </button>
)} )}
{historyConversationId && onOpenConversation && (
<button
type="button"
aria-label={`open cell conversation ${id}`}
title="Open conversation history"
onClick={() => onOpenConversation(historyConversationId)}
style={{
flexShrink: 0,
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 3,
background: "var(--color-surface, #1e1e1e)",
color: "var(--color-content, #e0e0e0)",
cursor: "pointer",
fontSize: 11,
padding: "1px 6px",
}}
>
History
</button>
)}
</div> </div>
)} )}
{/* Option 1 (Terminal + MCP): every cell — plain or agent — renders the {/* Option 1 (Terminal + MCP): every cell — plain or agent — renders the