merge(web): passage en custom CLI sur version téléphone — #224 (QA verte)

This commit is contained in:
2026-08-17 17:56:52 +02:00
6 changed files with 289 additions and 58 deletions

View File

@ -50,6 +50,8 @@ export interface CustomAgentChatViewProps {
composerHeightPx?: number;
composerDefaultHeightPx?: number;
onComposerHeightPxChange?: (heightPx: number) => void;
draftScope?: ChatDraftScope | null;
mobile?: boolean;
onSessionId: (sessionId: string | null) => void;
onConversationId: (conversationId: string | null) => void;
}
@ -725,6 +727,8 @@ export function CustomAgentChatView({
composerHeightPx = FALLBACK_COMPOSER_HEIGHT,
composerDefaultHeightPx = FALLBACK_COMPOSER_HEIGHT,
onComposerHeightPxChange,
draftScope: explicitDraftScope,
mobile = false,
onSessionId,
onConversationId,
}: CustomAgentChatViewProps) {
@ -774,8 +778,12 @@ export function CustomAgentChatView({
const scrollbackChunkCountRef = useRef(
sessionId ? (chatSessionViewCache.get(sessionId)?.chunkCount ?? 0) : 0,
);
const detachAgentChatRef = useRef(agent.detachAgentChat);
detachAgentChatRef.current = agent.detachAgentChat;
const detachAgentChatRef = useRef<((sessionId: string) => Promise<void>) | undefined>(
agent.detachAgentChat ? (sid) => agent.detachAgentChat!(sid) : undefined,
);
detachAgentChatRef.current = agent.detachAgentChat
? (sid) => agent.detachAgentChat!(sid)
: undefined;
const slashQuerySeqRef = useRef(0);
const draftSaveSeqRef = useRef(0);
const interactionSeqRef = useRef(0);
@ -881,10 +889,10 @@ export function CustomAgentChatView({
agent.closeAgentChat,
);
const slashPrefix = slashCommandPrefix(draft);
const draftScope = useMemo<ChatDraftScope | null>(
() => (layoutId ? { projectId, layoutId, nodeId } : null),
[layoutId, nodeId, projectId],
);
const draftScope = useMemo<ChatDraftScope | null>(() => {
if (explicitDraftScope) return explicitDraftScope;
return layoutId ? { projectId, layoutId, nodeId } : null;
}, [explicitDraftScope, layoutId, nodeId, projectId]);
const currentConversationCacheKey = useMemo(
() => conversationCacheKey(projectId, agentId, conversationId),
[agentId, conversationId, projectId],
@ -1815,7 +1823,12 @@ export function CustomAgentChatView({
role="dialog"
aria-modal="true"
aria-labelledby={`profile-command-title-${nodeId}`}
className="m-3 rounded-md border border-border bg-surface p-3 shadow-lg"
className={cn(
"border border-border bg-surface p-3 shadow-lg",
mobile
? "fixed inset-x-0 bottom-0 z-40 m-0 rounded-t-md pb-[max(0.75rem,env(safe-area-inset-bottom))]"
: "m-3 rounded-md",
)}
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
@ -1931,7 +1944,10 @@ export function CustomAgentChatView({
<div
data-testid="custom-agent-chat-composer"
className="flex shrink-0 flex-col gap-2 border-t border-border bg-raised/40 p-2"
className={cn(
"flex shrink-0 flex-col gap-2 border-t border-border bg-raised/40 p-2",
mobile && "pb-[max(0.5rem,env(safe-area-inset-bottom))]",
)}
>
{attachments.length > 0 && (
<div className="flex min-w-0 flex-wrap gap-2">

View File

@ -0,0 +1,16 @@
import type { AgentProfile } from "@/domain";
import type { AgentGateway } from "@/ports";
export function isCustomAgentCliAvailable(
profile: AgentProfile | null | undefined,
agentGateway: AgentGateway | null | undefined,
): boolean {
return Boolean(
profile?.structuredAdapter &&
agentGateway?.launchAgentChat &&
agentGateway?.reattachAgentChat &&
agentGateway?.sendAgentChat &&
agentGateway?.cancelAgentChat &&
agentGateway?.closeAgentChat,
);
}

View File

@ -25,3 +25,4 @@ export { useResumeProject } from "./useResumeProject";
export type { ResumeProjectViewModel } from "./useResumeProject";
export { CustomAgentChatView } from "./CustomAgentChatView";
export type { CustomAgentChatViewProps } from "./CustomAgentChatView";
export { isCustomAgentCliAvailable } from "./customCliEligibility";

View File

@ -36,6 +36,7 @@ import {
import { PluginLayoutCellView } from "@/features/plugins";
import {
CustomAgentChatView,
isCustomAgentCliAvailable,
modelServerOverlayText,
describeModelServerDownload,
useModelServerLaunchState,
@ -640,24 +641,13 @@ function LeafView({
? profiles.find((p) => p.id === pinnedAgent.profileId)
: undefined;
const customCliAvailable = Boolean(
agentId &&
pinnedProfile?.structuredAdapter &&
agentGateway?.launchAgentChat &&
agentGateway?.reattachAgentChat &&
agentGateway?.sendAgentChat &&
agentGateway?.cancelAgentChat &&
agentGateway?.closeAgentChat,
agentId && isCustomAgentCliAvailable(pinnedProfile, agentGateway),
);
const trustedCustomCliAvailable = Boolean(
cellMode === "custom" &&
agentId &&
trustedCustomCli?.agent.id === agentId &&
trustedCustomCli.profile.structuredAdapter &&
agentGateway?.launchAgentChat &&
agentGateway?.reattachAgentChat &&
agentGateway?.sendAgentChat &&
agentGateway?.cancelAgentChat &&
agentGateway?.closeAgentChat,
isCustomAgentCliAvailable(trustedCustomCli.profile, agentGateway),
);
const effectiveCustomCliAvailable =
customCliAvailable || trustedCustomCliAvailable;

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