fix(web): émet chat.attached pour les sessions agents structurées au lieu de UNSUPPORTED — #229
This commit is contained in:
@ -89,6 +89,98 @@ describe("CustomAgentChatView", () => {
|
||||
__resetProjectCliInteractionActivityForTests();
|
||||
});
|
||||
|
||||
it("shows the technical open failure on mobile with retry and native TUI fallback", async () => {
|
||||
const openNativeTui = vi.fn();
|
||||
const agent = {
|
||||
launchAgentChat: vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("spawn codex ENOENT"))
|
||||
.mockResolvedValueOnce({ sessionId: "chat-session-1" }),
|
||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
scrollback: [],
|
||||
})),
|
||||
sendAgentChat: vi.fn(async () => {}),
|
||||
cancelAgentChat: vi.fn(async () => {}),
|
||||
closeAgentChat: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
render(
|
||||
<DIProvider
|
||||
gateways={{
|
||||
agent,
|
||||
system: { pickFile: vi.fn(async () => null) },
|
||||
} as unknown as Gateways}
|
||||
>
|
||||
<CustomAgentChatView
|
||||
projectId="project-1"
|
||||
agentId="agent-1"
|
||||
agentName="Worker"
|
||||
profile={profile}
|
||||
cwd="/repo"
|
||||
nodeId="node-1"
|
||||
sessionId={null}
|
||||
conversationId={null}
|
||||
mobile
|
||||
onOpenNativeTui={openNativeTui}
|
||||
onSessionId={vi.fn()}
|
||||
onConversationId={vi.fn()}
|
||||
/>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(within(alert).getByText("Le CLI custom n'a pas pu démarrer.")).toBeTruthy();
|
||||
expect(within(alert).getByText("Cause: spawn codex ENOENT")).toBeTruthy();
|
||||
expect(screen.queryByText(/Vérifiez la configuration du profil/)).toBeNull();
|
||||
|
||||
fireEvent.click(within(alert).getByRole("button", { name: "Ouvrir en TUI native" }));
|
||||
expect(openNativeTui).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(within(alert).getByRole("button", { name: "Réessayer" }));
|
||||
await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
|
||||
});
|
||||
|
||||
it("uses the same technical open failure on desktop", async () => {
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(async () => {
|
||||
throw new Error("permission denied: /opt/codex");
|
||||
}),
|
||||
reattachAgentChat: vi.fn(),
|
||||
sendAgentChat: vi.fn(async () => {}),
|
||||
cancelAgentChat: vi.fn(async () => {}),
|
||||
closeAgentChat: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
render(
|
||||
<DIProvider
|
||||
gateways={{
|
||||
agent,
|
||||
system: { pickFile: vi.fn(async () => null) },
|
||||
} as unknown as Gateways}
|
||||
>
|
||||
<CustomAgentChatView
|
||||
projectId="project-1"
|
||||
agentId="agent-1"
|
||||
agentName="Worker"
|
||||
profile={profile}
|
||||
cwd="/repo"
|
||||
nodeId="node-1"
|
||||
sessionId={null}
|
||||
conversationId={null}
|
||||
onSessionId={vi.fn()}
|
||||
onConversationId={vi.fn()}
|
||||
/>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(within(alert).getByText("Cause: permission denied: /opt/codex")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Ouvrir en TUI native" })).toBeNull();
|
||||
});
|
||||
|
||||
it("repaints attachment labels from user prompt scrollback", async () => {
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(),
|
||||
|
||||
@ -52,6 +52,7 @@ export interface CustomAgentChatViewProps {
|
||||
onComposerHeightPxChange?: (heightPx: number) => void;
|
||||
draftScope?: ChatDraftScope | null;
|
||||
mobile?: boolean;
|
||||
onOpenNativeTui?: () => void;
|
||||
onSessionId: (sessionId: string | null) => void;
|
||||
onConversationId: (conversationId: string | null) => void;
|
||||
}
|
||||
@ -118,6 +119,11 @@ interface SlashCommandInvocation {
|
||||
arguments: unknown[];
|
||||
}
|
||||
|
||||
interface ChatErrorState {
|
||||
message: string;
|
||||
openFailure: boolean;
|
||||
}
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as GatewayError).message);
|
||||
@ -125,9 +131,9 @@ function describe(e: unknown): string {
|
||||
return String(e);
|
||||
}
|
||||
|
||||
function describeOpenFailure(e: unknown, mobile: boolean): string {
|
||||
if (!mobile) return describe(e);
|
||||
return "Le CLI custom n'a pas pu démarrer. Vérifiez la configuration du profil ou utilisez la TUI native.";
|
||||
function describeOpenFailure(e: unknown): string {
|
||||
const message = describe(e).trim();
|
||||
return message || "Cause technique non disponible.";
|
||||
}
|
||||
|
||||
function instrumentationError(e: unknown): Record<string, unknown> {
|
||||
@ -734,6 +740,7 @@ export function CustomAgentChatView({
|
||||
onComposerHeightPxChange,
|
||||
draftScope: explicitDraftScope,
|
||||
mobile = false,
|
||||
onOpenNativeTui,
|
||||
onSessionId,
|
||||
onConversationId,
|
||||
}: CustomAgentChatViewProps) {
|
||||
@ -770,9 +777,10 @@ export function CustomAgentChatView({
|
||||
const [profileDialog, setProfileDialog] =
|
||||
useState<ProfileCommandDialogState | null>(null);
|
||||
const [opening, setOpening] = useState(Boolean(sessionId || conversationId));
|
||||
const [openRetryNonce, setOpenRetryNonce] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<ChatErrorState | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const chatRootRef = useRef<HTMLDivElement | null>(null);
|
||||
@ -1371,7 +1379,9 @@ export function CustomAgentChatView({
|
||||
sessionId: externalSessionId,
|
||||
error: instrumentationError(e),
|
||||
});
|
||||
if (!cancelled) setError(describeOpenFailure(e, mobile));
|
||||
if (!cancelled) {
|
||||
setError({ message: describeOpenFailure(e), openFailure: true });
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setOpening(false);
|
||||
}
|
||||
@ -1390,7 +1400,7 @@ export function CustomAgentChatView({
|
||||
applyChatViewState,
|
||||
currentConversationCacheKey,
|
||||
conversationId,
|
||||
mobile,
|
||||
openRetryNonce,
|
||||
]);
|
||||
|
||||
const canSend = useMemo(
|
||||
@ -1490,7 +1500,10 @@ export function CustomAgentChatView({
|
||||
|
||||
async function executeSlashCommand(invocation: SlashCommandInvocation) {
|
||||
if (!agent.executeSlashCommand) {
|
||||
setError(`Commande ${invocation.name} indisponible dans ce runtime.`);
|
||||
setError({
|
||||
message: `Commande ${invocation.name} indisponible dans ce runtime.`,
|
||||
openFailure: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setDraft("");
|
||||
@ -1546,7 +1559,7 @@ export function CustomAgentChatView({
|
||||
}
|
||||
} catch (e) {
|
||||
const message = `Commande ${invocation.name} échouée: ${describe(e)}`;
|
||||
setError(message);
|
||||
setError({ message, openFailure: false });
|
||||
updateChatViewState((prev) => ({
|
||||
turns: [...prev.turns, { role: "error", text: message, receivedAtMs: Date.now() }],
|
||||
progressIndex: prev.progressIndex,
|
||||
@ -1633,7 +1646,7 @@ export function CustomAgentChatView({
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
setError({ message: describe(e), openFailure: false });
|
||||
}
|
||||
}
|
||||
|
||||
@ -1704,7 +1717,7 @@ export function CustomAgentChatView({
|
||||
} catch (recoveryError) {
|
||||
finishInteraction();
|
||||
setBusy(false);
|
||||
setError(describe(recoveryError));
|
||||
setError({ message: describe(recoveryError), openFailure: false });
|
||||
updateChatViewState((prev) => ({
|
||||
turns: [
|
||||
...prev.turns,
|
||||
@ -1717,7 +1730,7 @@ export function CustomAgentChatView({
|
||||
}
|
||||
finishInteraction();
|
||||
setBusy(false);
|
||||
setError(describe(e));
|
||||
setError({ message: describe(e), openFailure: false });
|
||||
updateChatViewState((prev) => ({
|
||||
turns: [...prev.turns, { role: "error", text: describe(e), receivedAtMs: Date.now() }],
|
||||
progressIndex: prev.progressIndex,
|
||||
@ -1779,7 +1792,7 @@ export function CustomAgentChatView({
|
||||
});
|
||||
return;
|
||||
}
|
||||
setError(describe(e));
|
||||
setError({ message: describe(e), openFailure: false });
|
||||
updateChatViewState((prev) => ({
|
||||
turns: [
|
||||
...prev.turns,
|
||||
@ -1821,9 +1834,40 @@ export function CustomAgentChatView({
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="m-3 rounded-md border border-danger/40 bg-danger/10 p-2 text-xs text-danger">
|
||||
{error}
|
||||
</p>
|
||||
<div
|
||||
role="alert"
|
||||
className="m-3 rounded-md border border-danger/40 bg-danger/10 p-2 text-xs text-danger"
|
||||
>
|
||||
{error.openFailure ? (
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<div className="font-medium">Le CLI custom n'a pas pu démarrer.</div>
|
||||
<div className="break-words">
|
||||
Cause: {error.message}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
disabled={opening}
|
||||
onClick={() => setOpenRetryNonce((value) => value + 1)}
|
||||
>
|
||||
Réessayer
|
||||
</Button>
|
||||
{onOpenNativeTui && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={onOpenNativeTui}
|
||||
>
|
||||
Ouvrir en TUI native
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
error.message
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{profileDialog && (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user