feat(web): passage en custom CLI sur version téléphone avec UI mobile adaptée — #224

This commit is contained in:
2026-08-17 17:56:47 +02:00
parent 9de51c7f43
commit c547b71d0c
6 changed files with 289 additions and 58 deletions

View File

@ -13,29 +13,54 @@
* bails before ever calling the opener, making the assertions vacuous).
*/
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { FitAddon } from "@xterm/addon-fit";
import type { Gateways } from "@/ports";
import type { AgentProfile } from "@/domain";
import { DIProvider } from "@/app/di";
import { MockAgentGateway } from "@/adapters/mock";
import { MockAgentGateway, MockProfileGateway } from "@/adapters/mock";
import { WebAgentCell } from "./WebAgentCell";
const PROJECT_ID = "p1";
const ptyProfile: AgentProfile = {
id: "profile-1",
name: "Native TUI",
command: "bash",
args: [],
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
detect: null,
cwdTemplate: "{projectRoot}",
};
const structuredProfile: AgentProfile = {
...ptyProfile,
id: "structured-1",
name: "Structured Codex",
command: "codex",
structuredAdapter: "codex",
};
async function seededAgentGateway(): Promise<MockAgentGateway> {
async function seededAgentGateway(
profile: AgentProfile = ptyProfile,
): Promise<{ agent: MockAgentGateway; profile: MockProfileGateway; agentId: string }> {
const agent = new MockAgentGateway();
await agent.createAgent(PROJECT_ID, { name: "Archi", profileId: "profile-1" });
return agent;
const profileGateway = new MockProfileGateway();
await profileGateway.configureProfiles([profile]);
const created = await agent.createAgent(PROJECT_ID, {
name: "Archi",
profileId: profile.id,
});
return { agent, profile: profileGateway, agentId: created.id };
}
function renderCell(
agent: MockAgentGateway,
profile: MockProfileGateway,
agentId: string,
extra?: Partial<React.ComponentProps<typeof WebAgentCell>>,
) {
const gateways = { agent } as unknown as Gateways;
const gateways = { agent, profile } as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<WebAgentCell projectId={PROJECT_ID} agentId={agentId} cwd="/srv/demo" {...extra} />
@ -45,28 +70,94 @@ function renderCell(
describe("WebAgentCell", () => {
it("mounts and renders the cell without throwing (headless-safe)", async () => {
const agent = await seededAgentGateway();
const [seeded] = await agent.listAgents(PROJECT_ID);
renderCell(agent, seeded.id);
const { agent, profile, agentId } = await seededAgentGateway();
renderCell(agent, profile, agentId);
expect(screen.getByTestId("web-agent-cell")).toBeTruthy();
});
it("launches through agent.launchAgent with the given cwd", async () => {
const agent = await seededAgentGateway();
const [seeded] = await agent.listAgents(PROJECT_ID);
const { agent, profile, agentId } = await seededAgentGateway();
const launchSpy = vi.spyOn(agent, "launchAgent");
renderCell(agent, seeded.id);
renderCell(agent, profile, agentId);
await waitFor(() => {
if (launchSpy.mock.calls.length > 0) {
expect(launchSpy.mock.calls[0][0]).toBe(PROJECT_ID);
expect(launchSpy.mock.calls[0][1]).toBe(seeded.id);
expect(launchSpy.mock.calls[0][1]).toBe(agentId);
expect(launchSpy.mock.calls[0][2]).toMatchObject({ cwd: "/srv/demo" });
}
});
expect(true).toBe(true);
});
it("does not show the mobile custom CLI switch for a PTY-only profile", async () => {
const { agent, profile, agentId } = await seededAgentGateway(ptyProfile);
renderCell(agent, profile, agentId, { fullHeight: true });
await new Promise((resolve) => requestAnimationFrame(resolve));
expect(screen.queryByRole("group", { name: "Mode CLI agent mobile" })).toBeNull();
expect(screen.getByTestId("terminal-key-bar")).toBeTruthy();
});
it("shows mobile custom CLI for structured profiles and hides the terminal keybar in custom mode", async () => {
const { agent, profile, agentId } = await seededAgentGateway(structuredProfile);
const launchChatSpy = vi.spyOn(agent, "launchAgentChat");
renderCell(agent, profile, agentId, { fullHeight: true });
expect(await screen.findByRole("group", { name: "Mode CLI agent mobile" })).toBeTruthy();
expect(screen.getByTestId("terminal-key-bar")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "CLI custom" }));
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
expect(screen.queryByTestId("terminal-key-bar")).toBeNull();
await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1));
expect(launchChatSpy.mock.calls[0][2]).toMatchObject({
cwd: "/srv/demo",
nodeId: `web-mobile-${agentId}`,
});
});
it("reattaches the mobile custom CLI session when returning to custom mode", async () => {
const { agent, profile, agentId } = await seededAgentGateway(structuredProfile);
const launchChatSpy = vi.spyOn(agent, "launchAgentChat");
const reattachChatSpy = vi.spyOn(agent, "reattachAgentChat");
renderCell(agent, profile, agentId, { fullHeight: true });
fireEvent.click(await screen.findByRole("button", { name: "CLI custom" }));
await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1));
const sessionId = (await launchChatSpy.mock.results[0].value).sessionId;
fireEvent.click(screen.getByRole("button", { name: "TUI native" }));
await waitFor(() => expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull());
fireEvent.click(screen.getByRole("button", { name: "CLI custom" }));
await waitFor(() => expect(reattachChatSpy).toHaveBeenCalledWith(sessionId, expect.any(Function)));
expect(launchChatSpy).toHaveBeenCalledTimes(1);
});
it("falls back to native TUI when the mobile agent loses custom CLI support", async () => {
const { agent, profile, agentId } = await seededAgentGateway(structuredProfile);
const created = (await agent.listAgents(PROJECT_ID)).find((candidate) => candidate.id === agentId);
if (!created) throw new Error("seeded agent missing");
const { rerender } = renderCell(agent, profile, agentId, { fullHeight: true });
fireEvent.click(await screen.findByRole("button", { name: "CLI custom" }));
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
await agent.changeAgentProfile(PROJECT_ID, created.id, ptyProfile.id, 24, 80);
rerender(
<DIProvider gateways={{ agent, profile } as unknown as Gateways}>
<WebAgentCell projectId={PROJECT_ID} agentId={agentId} cwd="/srv/demo" fullHeight />
</DIProvider>,
);
await waitFor(() => expect(screen.getByTestId("terminal-view")).toBeTruthy());
expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull();
expect(screen.getByTestId("terminal-key-bar")).toBeTruthy();
});
});
// xterm needs a real layout engine to mount under jsdom; without these
@ -104,12 +195,11 @@ describe("WebAgentCell — refitSignal (ticket #61 web regression)", () => {
});
it("a refitSignal change calls FitAddon.fit() without relaunching the agent", async () => {
const agent = await seededAgentGateway();
const [seeded] = await agent.listAgents(PROJECT_ID);
const { agent, profile, agentId } = await seededAgentGateway();
const launchSpy = vi.spyOn(agent, "launchAgent");
const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
const { rerender } = renderCell(agent, seeded.id, { refitSignal: 1 });
const { rerender } = renderCell(agent, profile, agentId, { refitSignal: 1 });
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
fitSpy.mockClear();
@ -123,8 +213,8 @@ describe("WebAgentCell — refitSignal (ticket #61 web regression)", () => {
Object.defineProperty(container, "clientHeight", { value: 200, configurable: true });
rerender(
<DIProvider gateways={{ agent } as unknown as Gateways}>
<WebAgentCell projectId={PROJECT_ID} agentId={seeded.id} cwd="/srv/demo" refitSignal={2} />
<DIProvider gateways={{ agent, profile } as unknown as Gateways}>
<WebAgentCell projectId={PROJECT_ID} agentId={agentId} cwd="/srv/demo" refitSignal={2} />
</DIProvider>,
);
@ -136,18 +226,17 @@ describe("WebAgentCell — refitSignal (ticket #61 web regression)", () => {
});
it("does not refit when refitSignal is left undefined", async () => {
const agent = await seededAgentGateway();
const [seeded] = await agent.listAgents(PROJECT_ID);
const { agent, profile, agentId } = await seededAgentGateway();
const launchSpy = vi.spyOn(agent, "launchAgent");
const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
const { rerender } = renderCell(agent, seeded.id);
const { rerender } = renderCell(agent, profile, agentId);
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
fitSpy.mockClear();
rerender(
<DIProvider gateways={{ agent } as unknown as Gateways}>
<WebAgentCell projectId={PROJECT_ID} agentId={seeded.id} cwd="/srv/demo" />
<DIProvider gateways={{ agent, profile } as unknown as Gateways}>
<WebAgentCell projectId={PROJECT_ID} agentId={agentId} cwd="/srv/demo" />
</DIProvider>,
);

View File

@ -19,14 +19,20 @@
* banner — no crash. It touches only gateways via DI (no `@tauri-apps/api`).
*/
import { useCallback, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { Agent, AgentProfile } from "@/domain";
import type {
ChatDraftScope,
OpenTerminalOptions,
ReattachResult,
TerminalHandle,
} from "@/ports";
import { useGateways } from "@/app/di";
import {
CustomAgentChatView,
isCustomAgentCliAvailable,
} from "@/features/agents";
import { TerminalView, type TerminalInputApi } from "@/features/terminals";
import { TerminalKeyBar } from "./TerminalKeyBar";
@ -50,6 +56,18 @@ interface WebAgentCellProps {
fullHeight?: boolean;
}
function sameResolvedAgent(a: Agent | null, b: Agent | null): boolean {
return a?.id === b?.id && a?.name === b?.name && a?.profileId === b?.profileId;
}
function sameResolvedProfile(a: AgentProfile | null, b: AgentProfile | null): boolean {
return (
a?.id === b?.id &&
a?.name === b?.name &&
a?.structuredAdapter === b?.structuredAdapter
);
}
export function WebAgentCell({
projectId,
agentId,
@ -58,12 +76,62 @@ export function WebAgentCell({
refitSignal,
fullHeight = false,
}: WebAgentCellProps) {
const { agent } = useGateways();
const [sessionId, setSessionId] = useState<string | null>(null);
const { agent, profile: profileGateway } = useGateways();
const [tuiSessionId, setTuiSessionId] = useState<string | null>(null);
const [customSessionId, setCustomSessionId] = useState<string | null>(null);
const [customConversationId, setCustomConversationId] = useState<string | null>(null);
const [mode, setMode] = useState<"tui" | "custom">("tui");
const [resolvedAgent, setResolvedAgent] = useState<Agent | null>(null);
const [resolvedProfile, setResolvedProfile] = useState<AgentProfile | null>(null);
// Input API of the mounted xterm (#69), used by the mobile key bar. Stays
// `null` in headless renders where xterm cannot mount — the bar then renders
// disabled rather than throwing.
const [inputApi, setInputApi] = useState<TerminalInputApi | null>(null);
const customCliAvailable = isCustomAgentCliAvailable(resolvedProfile, agent);
const showModeSwitch = Boolean(
fullHeight && customCliAvailable && resolvedAgent && resolvedProfile,
);
const draftScope = useMemo<ChatDraftScope>(
() => ({
projectId,
layoutId: "web-mobile",
nodeId: `agent-${agentId}`,
}),
[agentId, projectId],
);
useEffect(() => {
let cancelled = false;
// Reconcile after every parent rerender: mobile has no layout event stream
// here, and a profile hot-swap can keep the same agentId/projectId. The
// snapshot guards below avoid a fetch->setState render loop.
Promise.all([agent.listAgents(projectId), profileGateway.listProfiles()])
.then(([agents, profiles]) => {
if (cancelled) return;
const nextAgent = agents.find((candidate) => candidate.id === agentId) ?? null;
const nextProfile = nextAgent
? profiles.find((candidate) => candidate.id === nextAgent.profileId) ?? null
: null;
setResolvedAgent((current) =>
sameResolvedAgent(current, nextAgent) ? current : nextAgent,
);
setResolvedProfile((current) =>
sameResolvedProfile(current, nextProfile) ? current : nextProfile,
);
})
.catch(() => {
if (cancelled) return;
setResolvedAgent((current) => (current === null ? current : null));
setResolvedProfile((current) => (current === null ? current : null));
});
return () => {
cancelled = true;
};
});
useEffect(() => {
if (mode === "custom" && !customCliAvailable) setMode("tui");
}, [customCliAvailable, mode]);
const open = useCallback(
(options: OpenTerminalOptions, onData: (bytes: Uint8Array) => void): Promise<TerminalHandle> =>
@ -96,19 +164,70 @@ export function WebAgentCell({
: "flex h-[60dvh] min-h-64 w-full flex-col overflow-hidden rounded-md border border-border sm:h-80"
}
>
<div className="min-h-0 flex-1">
<TerminalView
{showModeSwitch && (
<div
role="group"
aria-label="Mode CLI agent mobile"
className="flex shrink-0 gap-1 border-b border-border bg-surface p-2"
>
<button
type="button"
aria-pressed={mode === "tui"}
className={`min-h-10 flex-1 rounded-md border px-3 text-sm font-medium ${
mode === "tui"
? "border-primary bg-primary text-on-primary"
: "border-border bg-raised text-content"
}`}
onClick={() => setMode("tui")}
>
TUI native
</button>
<button
type="button"
aria-pressed={mode === "custom"}
className={`min-h-10 flex-1 rounded-md border px-3 text-sm font-medium ${
mode === "custom"
? "border-primary bg-primary text-on-primary"
: "border-border bg-raised text-content"
}`}
onClick={() => setMode("custom")}
>
CLI custom
</button>
</div>
)}
{mode === "custom" && showModeSwitch && resolvedAgent && resolvedProfile ? (
<CustomAgentChatView
projectId={projectId}
agentId={agentId}
agentName={resolvedAgent.name}
profile={resolvedProfile}
cwd={cwd}
agentMode
open={open}
reattach={reattach}
sessionId={sessionId}
onSessionId={setSessionId}
onReady={setInputApi}
refitSignal={refitSignal}
nodeId={`web-mobile-${agentId}`}
sessionId={customSessionId}
conversationId={customConversationId}
draftScope={draftScope}
mobile
onSessionId={setCustomSessionId}
onConversationId={setCustomConversationId}
/>
</div>
<TerminalKeyBar api={inputApi} />
) : (
<>
<div className="min-h-0 flex-1">
<TerminalView
cwd={cwd}
agentMode
open={open}
reattach={reattach}
sessionId={tuiSessionId}
onSessionId={setTuiSessionId}
onReady={setInputApi}
refitSignal={refitSignal}
/>
</div>
<TerminalKeyBar api={inputApi} />
</>
)}
</div>
);
}