feat(cli): références d'attachments [file n]/[image n] dans le transcript + zone chat redimensionnable — #188 (QA verte)

- Attachments: projection canonique ChatPromptAttachmentDto (référence [file n]/[image n] par compteur de type, ordre d'import) insérée dans le prompt modèle et transportée par ReplyChunk::UserPrompt (attachmentRefs) ; rendu compact côté CustomAgentChatView.
- Resize: la zone chat (LeafView/LayoutGrid) est redimensionnable, taille actuelle conservée par défaut, préférence locale persistée.
- Tests: commands (projection des refs), dto_chat, chat_bridge, CustomAgentChatView, LayoutGrid.chat.
This commit is contained in:
2026-08-13 13:01:03 +02:00
parent 8b8468dacd
commit 4f38e1c506
10 changed files with 513 additions and 42 deletions

View File

@ -180,6 +180,37 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
expect(screen.getAllByText(/mock-attachment.txt/).length).toBeGreaterThan(0);
});
it("resizes the custom CLI chat locally per cell and can reset to the default size", async () => {
const gateways = await seeded(structuredProfile);
const layout = gateways.layout as MockLayoutGateway;
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
renderGrid(gateways);
const shell = await screen.findByTestId("custom-agent-chat-shell");
expect(shell.style.width).toBe("100%");
expect(window.localStorage.getItem(`idea.custom-chat-size.p1.${leafId}`)).toBeNull();
fireEvent.mouseDown(screen.getByRole("button", { name: `resize custom chat ${leafId}` }), {
clientX: 0,
clientY: 0,
});
fireEvent.mouseMove(window, { clientX: 640, clientY: 420 });
fireEvent.mouseUp(window);
await waitFor(() => expect(shell.style.width).toBe("640px"));
expect(shell.style.height).toBe("420px");
expect(JSON.parse(window.localStorage.getItem(`idea.custom-chat-size.p1.${leafId}`) ?? "{}"))
.toEqual({ width: 640, height: 420 });
fireEvent.click(screen.getByRole("button", { name: `reset custom chat size ${leafId}` }));
await waitFor(() => expect(shell.style.width).toBe("100%"));
expect(window.localStorage.getItem(`idea.custom-chat-size.p1.${leafId}`)).toBeNull();
});
it("opens the custom CLI even when the agent already has a structured background session (#169)", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();

View File

@ -16,7 +16,7 @@
* {@link normalizeWeights} function, kept out of the render for testability.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
import type { Agent, AgentProfile } from "@/domain";
import type { LayoutNode } from "@/domain";
@ -407,6 +407,50 @@ const CELL_Z = {
turnActions: 6,
} as const;
const CUSTOM_CHAT_MIN_WIDTH = 360;
const CUSTOM_CHAT_MIN_HEIGHT = 280;
const CUSTOM_CHAT_MAX_WIDTH = 1200;
const CUSTOM_CHAT_MAX_HEIGHT = 900;
interface CustomChatSize {
width: number;
height: number;
}
function clampCustomChatSize(size: CustomChatSize): CustomChatSize {
const viewportWidth =
typeof window === "undefined" ? CUSTOM_CHAT_MAX_WIDTH : window.innerWidth - 24;
const viewportHeight =
typeof window === "undefined" ? CUSTOM_CHAT_MAX_HEIGHT : window.innerHeight - 24;
return {
width: Math.round(
Math.min(
Math.max(size.width, CUSTOM_CHAT_MIN_WIDTH),
Math.max(CUSTOM_CHAT_MIN_WIDTH, Math.min(CUSTOM_CHAT_MAX_WIDTH, viewportWidth)),
),
),
height: Math.round(
Math.min(
Math.max(size.height, CUSTOM_CHAT_MIN_HEIGHT),
Math.max(CUSTOM_CHAT_MIN_HEIGHT, Math.min(CUSTOM_CHAT_MAX_HEIGHT, viewportHeight)),
),
),
};
}
function readCustomChatSize(storageKey: string): CustomChatSize | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(storageKey);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<CustomChatSize>;
if (typeof parsed.width !== "number" || typeof parsed.height !== "number") return null;
return clampCustomChatSize({ width: parsed.width, height: parsed.height });
} catch {
return null;
}
}
function LeafView({
id,
session,
@ -526,6 +570,54 @@ function LeafView({
},
[cellModeStorageKey],
);
const customChatSizeStorageKey = `idea.custom-chat-size.${projectId}.${id}`;
const [customChatSize, setCustomChatSizeState] = useState<CustomChatSize | null>(() =>
readCustomChatSize(customChatSizeStorageKey),
);
const setCustomChatSize = useCallback(
(size: CustomChatSize | null) => {
setCustomChatSizeState(size);
if (typeof window === "undefined") return;
try {
if (size) {
window.localStorage.setItem(customChatSizeStorageKey, JSON.stringify(size));
} else {
window.localStorage.removeItem(customChatSizeStorageKey);
}
} catch {
/* local preference only */
}
},
[customChatSizeStorageKey],
);
const startCustomChatResize = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => {
const container = event.currentTarget.closest<HTMLElement>("[data-custom-chat-shell]");
if (!container) return;
event.preventDefault();
event.stopPropagation();
const rect = container.getBoundingClientRect();
const startX = event.clientX;
const startY = event.clientY;
const startWidth = customChatSize?.width ?? rect.width;
const startHeight = customChatSize?.height ?? rect.height;
const onMove = (moveEvent: MouseEvent) => {
setCustomChatSize(
clampCustomChatSize({
width: startWidth + moveEvent.clientX - startX,
height: startHeight + moveEvent.clientY - startY,
}),
);
};
const onUp = () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
},
[customChatSize, setCustomChatSize],
);
const [pendingModeSwitch, setPendingModeSwitch] =
useState<PendingModeSwitch | null>(null);
const [trustedCustomCli, setTrustedCustomCli] =
@ -1100,7 +1192,7 @@ function LeafView({
minWidth: 0,
border: "1px solid #2a2a2a",
boxSizing: "border-box",
overflow: "hidden",
overflow: customChatSize ? "visible" : "hidden",
}}
>
<div
@ -1464,20 +1556,85 @@ function LeafView({
onRefresh={() => void refreshStoppedAgentState()}
/>
) : agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? (
<CustomAgentChatView
key={`${id}-${agentId}-custom`}
projectId={projectId}
agentId={agentId}
agentName={customCliAgent.name}
profile={customCliProfile}
cwd={cwd}
layoutId={layoutId}
nodeId={id}
sessionId={session}
conversationId={conversationId}
onSessionId={(sid) => void setSessionWithInstrumentation("custom", sid)}
onConversationId={(cid) => void vm.setCellConversation(id, cid)}
/>
<div
data-custom-chat-shell
data-testid="custom-agent-chat-shell"
style={{
position: customChatSize ? "absolute" : "relative",
top: 0,
left: 0,
zIndex: customChatSize ? 2 : 0,
width: customChatSize ? customChatSize.width : "100%",
height: customChatSize ? customChatSize.height : "100%",
minWidth: CUSTOM_CHAT_MIN_WIDTH,
minHeight: CUSTOM_CHAT_MIN_HEIGHT,
maxWidth: "calc(100vw - 24px)",
maxHeight: "calc(100vh - 24px)",
boxShadow: customChatSize ? "0 18px 48px rgba(0, 0, 0, 0.42)" : undefined,
}}
>
<CustomAgentChatView
key={`${id}-${agentId}-custom`}
projectId={projectId}
agentId={agentId}
agentName={customCliAgent.name}
profile={customCliProfile}
cwd={cwd}
layoutId={layoutId}
nodeId={id}
sessionId={session}
conversationId={conversationId}
onSessionId={(sid) => void setSessionWithInstrumentation("custom", sid)}
onConversationId={(cid) => void vm.setCellConversation(id, cid)}
/>
{customChatSize && (
<button
type="button"
aria-label={`reset custom chat size ${id}`}
title="Reset chat size"
onClick={() => setCustomChatSize(null)}
style={{
position: "absolute",
right: 30,
bottom: 6,
zIndex: 30,
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 3,
background: "var(--color-surface, #1e1e1e)",
color: "var(--color-content, #e0e0e0)",
cursor: "pointer",
fontSize: 11,
padding: "1px 6px",
}}
>
Reset
</button>
)}
<button
type="button"
aria-label={`resize custom chat ${id}`}
title="Resize chat"
onMouseDown={startCustomChatResize}
style={{
position: "absolute",
right: 6,
bottom: 6,
zIndex: 30,
width: 16,
height: 16,
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 3,
background: "var(--color-surface, #1e1e1e)",
color: "var(--color-muted, #9aa7b4)",
cursor: "nwse-resize",
fontSize: 11,
lineHeight: "12px",
padding: 0,
}}
>
↘
</button>
</div>
) : (
<TerminalView
key={`${id}-${agentId ?? "plain"}-${attachGen}`}