merge(web): passage en custom CLI sur version téléphone — #224 (QA verte)
This commit is contained in:
@ -50,6 +50,8 @@ export interface CustomAgentChatViewProps {
|
|||||||
composerHeightPx?: number;
|
composerHeightPx?: number;
|
||||||
composerDefaultHeightPx?: number;
|
composerDefaultHeightPx?: number;
|
||||||
onComposerHeightPxChange?: (heightPx: number) => void;
|
onComposerHeightPxChange?: (heightPx: number) => void;
|
||||||
|
draftScope?: ChatDraftScope | null;
|
||||||
|
mobile?: boolean;
|
||||||
onSessionId: (sessionId: string | null) => void;
|
onSessionId: (sessionId: string | null) => void;
|
||||||
onConversationId: (conversationId: string | null) => void;
|
onConversationId: (conversationId: string | null) => void;
|
||||||
}
|
}
|
||||||
@ -725,6 +727,8 @@ export function CustomAgentChatView({
|
|||||||
composerHeightPx = FALLBACK_COMPOSER_HEIGHT,
|
composerHeightPx = FALLBACK_COMPOSER_HEIGHT,
|
||||||
composerDefaultHeightPx = FALLBACK_COMPOSER_HEIGHT,
|
composerDefaultHeightPx = FALLBACK_COMPOSER_HEIGHT,
|
||||||
onComposerHeightPxChange,
|
onComposerHeightPxChange,
|
||||||
|
draftScope: explicitDraftScope,
|
||||||
|
mobile = false,
|
||||||
onSessionId,
|
onSessionId,
|
||||||
onConversationId,
|
onConversationId,
|
||||||
}: CustomAgentChatViewProps) {
|
}: CustomAgentChatViewProps) {
|
||||||
@ -774,8 +778,12 @@ export function CustomAgentChatView({
|
|||||||
const scrollbackChunkCountRef = useRef(
|
const scrollbackChunkCountRef = useRef(
|
||||||
sessionId ? (chatSessionViewCache.get(sessionId)?.chunkCount ?? 0) : 0,
|
sessionId ? (chatSessionViewCache.get(sessionId)?.chunkCount ?? 0) : 0,
|
||||||
);
|
);
|
||||||
const detachAgentChatRef = useRef(agent.detachAgentChat);
|
const detachAgentChatRef = useRef<((sessionId: string) => Promise<void>) | undefined>(
|
||||||
detachAgentChatRef.current = agent.detachAgentChat;
|
agent.detachAgentChat ? (sid) => agent.detachAgentChat!(sid) : undefined,
|
||||||
|
);
|
||||||
|
detachAgentChatRef.current = agent.detachAgentChat
|
||||||
|
? (sid) => agent.detachAgentChat!(sid)
|
||||||
|
: undefined;
|
||||||
const slashQuerySeqRef = useRef(0);
|
const slashQuerySeqRef = useRef(0);
|
||||||
const draftSaveSeqRef = useRef(0);
|
const draftSaveSeqRef = useRef(0);
|
||||||
const interactionSeqRef = useRef(0);
|
const interactionSeqRef = useRef(0);
|
||||||
@ -881,10 +889,10 @@ export function CustomAgentChatView({
|
|||||||
agent.closeAgentChat,
|
agent.closeAgentChat,
|
||||||
);
|
);
|
||||||
const slashPrefix = slashCommandPrefix(draft);
|
const slashPrefix = slashCommandPrefix(draft);
|
||||||
const draftScope = useMemo<ChatDraftScope | null>(
|
const draftScope = useMemo<ChatDraftScope | null>(() => {
|
||||||
() => (layoutId ? { projectId, layoutId, nodeId } : null),
|
if (explicitDraftScope) return explicitDraftScope;
|
||||||
[layoutId, nodeId, projectId],
|
return layoutId ? { projectId, layoutId, nodeId } : null;
|
||||||
);
|
}, [explicitDraftScope, layoutId, nodeId, projectId]);
|
||||||
const currentConversationCacheKey = useMemo(
|
const currentConversationCacheKey = useMemo(
|
||||||
() => conversationCacheKey(projectId, agentId, conversationId),
|
() => conversationCacheKey(projectId, agentId, conversationId),
|
||||||
[agentId, conversationId, projectId],
|
[agentId, conversationId, projectId],
|
||||||
@ -1815,7 +1823,12 @@ export function CustomAgentChatView({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby={`profile-command-title-${nodeId}`}
|
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="flex min-w-0 items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@ -1931,7 +1944,10 @@ export function CustomAgentChatView({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
data-testid="custom-agent-chat-composer"
|
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 && (
|
{attachments.length > 0 && (
|
||||||
<div className="flex min-w-0 flex-wrap gap-2">
|
<div className="flex min-w-0 flex-wrap gap-2">
|
||||||
|
|||||||
16
frontend/src/features/agents/customCliEligibility.ts
Normal file
16
frontend/src/features/agents/customCliEligibility.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -25,3 +25,4 @@ export { useResumeProject } from "./useResumeProject";
|
|||||||
export type { ResumeProjectViewModel } from "./useResumeProject";
|
export type { ResumeProjectViewModel } from "./useResumeProject";
|
||||||
export { CustomAgentChatView } from "./CustomAgentChatView";
|
export { CustomAgentChatView } from "./CustomAgentChatView";
|
||||||
export type { CustomAgentChatViewProps } from "./CustomAgentChatView";
|
export type { CustomAgentChatViewProps } from "./CustomAgentChatView";
|
||||||
|
export { isCustomAgentCliAvailable } from "./customCliEligibility";
|
||||||
|
|||||||
@ -36,6 +36,7 @@ import {
|
|||||||
import { PluginLayoutCellView } from "@/features/plugins";
|
import { PluginLayoutCellView } from "@/features/plugins";
|
||||||
import {
|
import {
|
||||||
CustomAgentChatView,
|
CustomAgentChatView,
|
||||||
|
isCustomAgentCliAvailable,
|
||||||
modelServerOverlayText,
|
modelServerOverlayText,
|
||||||
describeModelServerDownload,
|
describeModelServerDownload,
|
||||||
useModelServerLaunchState,
|
useModelServerLaunchState,
|
||||||
@ -640,24 +641,13 @@ function LeafView({
|
|||||||
? profiles.find((p) => p.id === pinnedAgent.profileId)
|
? profiles.find((p) => p.id === pinnedAgent.profileId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const customCliAvailable = Boolean(
|
const customCliAvailable = Boolean(
|
||||||
agentId &&
|
agentId && isCustomAgentCliAvailable(pinnedProfile, agentGateway),
|
||||||
pinnedProfile?.structuredAdapter &&
|
|
||||||
agentGateway?.launchAgentChat &&
|
|
||||||
agentGateway?.reattachAgentChat &&
|
|
||||||
agentGateway?.sendAgentChat &&
|
|
||||||
agentGateway?.cancelAgentChat &&
|
|
||||||
agentGateway?.closeAgentChat,
|
|
||||||
);
|
);
|
||||||
const trustedCustomCliAvailable = Boolean(
|
const trustedCustomCliAvailable = Boolean(
|
||||||
cellMode === "custom" &&
|
cellMode === "custom" &&
|
||||||
agentId &&
|
agentId &&
|
||||||
trustedCustomCli?.agent.id === agentId &&
|
trustedCustomCli?.agent.id === agentId &&
|
||||||
trustedCustomCli.profile.structuredAdapter &&
|
isCustomAgentCliAvailable(trustedCustomCli.profile, agentGateway),
|
||||||
agentGateway?.launchAgentChat &&
|
|
||||||
agentGateway?.reattachAgentChat &&
|
|
||||||
agentGateway?.sendAgentChat &&
|
|
||||||
agentGateway?.cancelAgentChat &&
|
|
||||||
agentGateway?.closeAgentChat,
|
|
||||||
);
|
);
|
||||||
const effectiveCustomCliAvailable =
|
const effectiveCustomCliAvailable =
|
||||||
customCliAvailable || trustedCustomCliAvailable;
|
customCliAvailable || trustedCustomCliAvailable;
|
||||||
|
|||||||
@ -13,29 +13,54 @@
|
|||||||
* bails before ever calling the opener, making the assertions vacuous).
|
* bails before ever calling the opener, making the assertions vacuous).
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
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 { FitAddon } from "@xterm/addon-fit";
|
||||||
|
|
||||||
import type { Gateways } from "@/ports";
|
import type { Gateways } from "@/ports";
|
||||||
|
import type { AgentProfile } from "@/domain";
|
||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
import { MockAgentGateway } from "@/adapters/mock";
|
import { MockAgentGateway, MockProfileGateway } from "@/adapters/mock";
|
||||||
import { WebAgentCell } from "./WebAgentCell";
|
import { WebAgentCell } from "./WebAgentCell";
|
||||||
|
|
||||||
const PROJECT_ID = "p1";
|
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();
|
const agent = new MockAgentGateway();
|
||||||
await agent.createAgent(PROJECT_ID, { name: "Archi", profileId: "profile-1" });
|
const profileGateway = new MockProfileGateway();
|
||||||
return agent;
|
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(
|
function renderCell(
|
||||||
agent: MockAgentGateway,
|
agent: MockAgentGateway,
|
||||||
|
profile: MockProfileGateway,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
extra?: Partial<React.ComponentProps<typeof WebAgentCell>>,
|
extra?: Partial<React.ComponentProps<typeof WebAgentCell>>,
|
||||||
) {
|
) {
|
||||||
const gateways = { agent } as unknown as Gateways;
|
const gateways = { agent, profile } as unknown as Gateways;
|
||||||
return render(
|
return render(
|
||||||
<DIProvider gateways={gateways}>
|
<DIProvider gateways={gateways}>
|
||||||
<WebAgentCell projectId={PROJECT_ID} agentId={agentId} cwd="/srv/demo" {...extra} />
|
<WebAgentCell projectId={PROJECT_ID} agentId={agentId} cwd="/srv/demo" {...extra} />
|
||||||
@ -45,28 +70,94 @@ function renderCell(
|
|||||||
|
|
||||||
describe("WebAgentCell", () => {
|
describe("WebAgentCell", () => {
|
||||||
it("mounts and renders the cell without throwing (headless-safe)", async () => {
|
it("mounts and renders the cell without throwing (headless-safe)", async () => {
|
||||||
const agent = await seededAgentGateway();
|
const { agent, profile, agentId } = await seededAgentGateway();
|
||||||
const [seeded] = await agent.listAgents(PROJECT_ID);
|
renderCell(agent, profile, agentId);
|
||||||
renderCell(agent, seeded.id);
|
|
||||||
expect(screen.getByTestId("web-agent-cell")).toBeTruthy();
|
expect(screen.getByTestId("web-agent-cell")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("launches through agent.launchAgent with the given cwd", async () => {
|
it("launches through agent.launchAgent with the given cwd", async () => {
|
||||||
const agent = await seededAgentGateway();
|
const { agent, profile, agentId } = await seededAgentGateway();
|
||||||
const [seeded] = await agent.listAgents(PROJECT_ID);
|
|
||||||
const launchSpy = vi.spyOn(agent, "launchAgent");
|
const launchSpy = vi.spyOn(agent, "launchAgent");
|
||||||
|
|
||||||
renderCell(agent, seeded.id);
|
renderCell(agent, profile, agentId);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
if (launchSpy.mock.calls.length > 0) {
|
if (launchSpy.mock.calls.length > 0) {
|
||||||
expect(launchSpy.mock.calls[0][0]).toBe(PROJECT_ID);
|
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(launchSpy.mock.calls[0][2]).toMatchObject({ cwd: "/srv/demo" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
expect(true).toBe(true);
|
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
|
// 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 () => {
|
it("a refitSignal change calls FitAddon.fit() without relaunching the agent", async () => {
|
||||||
const agent = await seededAgentGateway();
|
const { agent, profile, agentId } = await seededAgentGateway();
|
||||||
const [seeded] = await agent.listAgents(PROJECT_ID);
|
|
||||||
const launchSpy = vi.spyOn(agent, "launchAgent");
|
const launchSpy = vi.spyOn(agent, "launchAgent");
|
||||||
const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
|
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));
|
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
|
||||||
fitSpy.mockClear();
|
fitSpy.mockClear();
|
||||||
|
|
||||||
@ -123,8 +213,8 @@ describe("WebAgentCell — refitSignal (ticket #61 web regression)", () => {
|
|||||||
Object.defineProperty(container, "clientHeight", { value: 200, configurable: true });
|
Object.defineProperty(container, "clientHeight", { value: 200, configurable: true });
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
<DIProvider gateways={{ agent } as unknown as Gateways}>
|
<DIProvider gateways={{ agent, profile } as unknown as Gateways}>
|
||||||
<WebAgentCell projectId={PROJECT_ID} agentId={seeded.id} cwd="/srv/demo" refitSignal={2} />
|
<WebAgentCell projectId={PROJECT_ID} agentId={agentId} cwd="/srv/demo" refitSignal={2} />
|
||||||
</DIProvider>,
|
</DIProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -136,18 +226,17 @@ describe("WebAgentCell — refitSignal (ticket #61 web regression)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not refit when refitSignal is left undefined", async () => {
|
it("does not refit when refitSignal is left undefined", async () => {
|
||||||
const agent = await seededAgentGateway();
|
const { agent, profile, agentId } = await seededAgentGateway();
|
||||||
const [seeded] = await agent.listAgents(PROJECT_ID);
|
|
||||||
const launchSpy = vi.spyOn(agent, "launchAgent");
|
const launchSpy = vi.spyOn(agent, "launchAgent");
|
||||||
const fitSpy = vi.spyOn(FitAddon.prototype, "fit");
|
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));
|
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
|
||||||
fitSpy.mockClear();
|
fitSpy.mockClear();
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
<DIProvider gateways={{ agent } as unknown as Gateways}>
|
<DIProvider gateways={{ agent, profile } as unknown as Gateways}>
|
||||||
<WebAgentCell projectId={PROJECT_ID} agentId={seeded.id} cwd="/srv/demo" />
|
<WebAgentCell projectId={PROJECT_ID} agentId={agentId} cwd="/srv/demo" />
|
||||||
</DIProvider>,
|
</DIProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -19,14 +19,20 @@
|
|||||||
* banner — no crash. It touches only gateways via DI (no `@tauri-apps/api`).
|
* 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 {
|
import type {
|
||||||
|
ChatDraftScope,
|
||||||
OpenTerminalOptions,
|
OpenTerminalOptions,
|
||||||
ReattachResult,
|
ReattachResult,
|
||||||
TerminalHandle,
|
TerminalHandle,
|
||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
|
import {
|
||||||
|
CustomAgentChatView,
|
||||||
|
isCustomAgentCliAvailable,
|
||||||
|
} from "@/features/agents";
|
||||||
import { TerminalView, type TerminalInputApi } from "@/features/terminals";
|
import { TerminalView, type TerminalInputApi } from "@/features/terminals";
|
||||||
import { TerminalKeyBar } from "./TerminalKeyBar";
|
import { TerminalKeyBar } from "./TerminalKeyBar";
|
||||||
|
|
||||||
@ -50,6 +56,18 @@ interface WebAgentCellProps {
|
|||||||
fullHeight?: boolean;
|
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({
|
export function WebAgentCell({
|
||||||
projectId,
|
projectId,
|
||||||
agentId,
|
agentId,
|
||||||
@ -58,12 +76,62 @@ export function WebAgentCell({
|
|||||||
refitSignal,
|
refitSignal,
|
||||||
fullHeight = false,
|
fullHeight = false,
|
||||||
}: WebAgentCellProps) {
|
}: WebAgentCellProps) {
|
||||||
const { agent } = useGateways();
|
const { agent, profile: profileGateway } = useGateways();
|
||||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
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
|
// 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
|
// `null` in headless renders where xterm cannot mount — the bar then renders
|
||||||
// disabled rather than throwing.
|
// disabled rather than throwing.
|
||||||
const [inputApi, setInputApi] = useState<TerminalInputApi | null>(null);
|
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(
|
const open = useCallback(
|
||||||
(options: OpenTerminalOptions, onData: (bytes: Uint8Array) => void): Promise<TerminalHandle> =>
|
(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"
|
: "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">
|
{showModeSwitch && (
|
||||||
<TerminalView
|
<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}
|
cwd={cwd}
|
||||||
agentMode
|
nodeId={`web-mobile-${agentId}`}
|
||||||
open={open}
|
sessionId={customSessionId}
|
||||||
reattach={reattach}
|
conversationId={customConversationId}
|
||||||
sessionId={sessionId}
|
draftScope={draftScope}
|
||||||
onSessionId={setSessionId}
|
mobile
|
||||||
onReady={setInputApi}
|
onSessionId={setCustomSessionId}
|
||||||
refitSignal={refitSignal}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user