From c547b71d0cf2b66df17262c704acb7375c99b1a1 Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 17 Aug 2026 17:56:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20passage=20en=20custom=20CLI=20sur?= =?UTF-8?q?=20version=20t=C3=A9l=C3=A9phone=20avec=20UI=20mobile=20adapt?= =?UTF-8?q?=C3=A9e=20=E2=80=94=20#224?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/agents/CustomAgentChatView.tsx | 32 +++- .../features/agents/customCliEligibility.ts | 16 ++ frontend/src/features/agents/index.ts | 1 + frontend/src/features/layout/LayoutGrid.tsx | 16 +- .../src/features/web/WebAgentCell.test.tsx | 135 +++++++++++++--- frontend/src/features/web/WebAgentCell.tsx | 147 ++++++++++++++++-- 6 files changed, 289 insertions(+), 58 deletions(-) create mode 100644 frontend/src/features/agents/customCliEligibility.ts diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 3b4837d9..2894fcbe 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -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) | 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( - () => (layoutId ? { projectId, layoutId, nodeId } : null), - [layoutId, nodeId, projectId], - ); + const draftScope = useMemo(() => { + 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", + )} >
@@ -1931,7 +1944,10 @@ export function CustomAgentChatView({
{attachments.length > 0 && (
diff --git a/frontend/src/features/agents/customCliEligibility.ts b/frontend/src/features/agents/customCliEligibility.ts new file mode 100644 index 00000000..17d8f134 --- /dev/null +++ b/frontend/src/features/agents/customCliEligibility.ts @@ -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, + ); +} diff --git a/frontend/src/features/agents/index.ts b/frontend/src/features/agents/index.ts index ab0d4df0..e1cf7e1e 100644 --- a/frontend/src/features/agents/index.ts +++ b/frontend/src/features/agents/index.ts @@ -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"; diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index a1c57cb3..1ef6e6f8 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -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; diff --git a/frontend/src/features/web/WebAgentCell.test.tsx b/frontend/src/features/web/WebAgentCell.test.tsx index 48200ec0..c3334532 100644 --- a/frontend/src/features/web/WebAgentCell.test.tsx +++ b/frontend/src/features/web/WebAgentCell.test.tsx @@ -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 { +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>, ) { - const gateways = { agent } as unknown as Gateways; + const gateways = { agent, profile } as unknown as Gateways; return render( @@ -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( + + + , + ); + + 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( - - + + , ); @@ -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( - - + + , ); diff --git a/frontend/src/features/web/WebAgentCell.tsx b/frontend/src/features/web/WebAgentCell.tsx index 9d01fb41..facec751 100644 --- a/frontend/src/features/web/WebAgentCell.tsx +++ b/frontend/src/features/web/WebAgentCell.tsx @@ -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(null); + const { agent, profile: profileGateway } = useGateways(); + const [tuiSessionId, setTuiSessionId] = useState(null); + const [customSessionId, setCustomSessionId] = useState(null); + const [customConversationId, setCustomConversationId] = useState(null); + const [mode, setMode] = useState<"tui" | "custom">("tui"); + const [resolvedAgent, setResolvedAgent] = useState(null); + const [resolvedProfile, setResolvedProfile] = useState(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(null); + const customCliAvailable = isCustomAgentCliAvailable(resolvedProfile, agent); + const showModeSwitch = Boolean( + fullHeight && customCliAvailable && resolvedAgent && resolvedProfile, + ); + const draftScope = useMemo( + () => ({ + 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 => @@ -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" } > -
- + + +
+ )} + {mode === "custom" && showModeSwitch && resolvedAgent && resolvedProfile ? ( + -
- + ) : ( + <> +
+ +
+ + + )}
); }