feat(chat): resize vertical du composer avec poignée haut-droit (modèle carnet ticket) — #193

This commit is contained in:
2026-08-14 09:33:05 +02:00
parent d0521db6b6
commit 2b2da223d5
5 changed files with 642 additions and 544 deletions

View File

@ -180,35 +180,47 @@ 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 () => {
it("persists custom CLI composer height per layout cell", async () => {
const rectSpy = vi
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
.mockReturnValue({
x: 0,
y: 0,
width: 600,
height: 400,
top: 0,
right: 600,
bottom: 400,
left: 0,
toJSON: () => {},
} as DOMRect);
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);
try {
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();
const composer = await screen.findByLabelText(/message CLI custom/) as HTMLTextAreaElement;
const handle = screen.getByRole("button", {
name: `redimensionner le composer ${leafId}`,
});
expect(composer.style.height).toBe("64px");
expect(window.localStorage.getItem(`idea.custom-chat-composer-height.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);
fireEvent.mouseDown(handle, { clientY: 200 });
fireEvent.mouseMove(window, { clientY: 100 });
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();
await waitFor(() => expect(composer.style.height).toBe("164px"));
expect(window.localStorage.getItem(`idea.custom-chat-composer-height.p1.${leafId}`))
.toBe("164");
} finally {
rectSpy.mockRestore();
}
});
it("opens the custom CLI even when the agent already has a structured background session (#169)", async () => {

View File

@ -16,7 +16,7 @@
* {@link normalizeWeights} function, kept out of the render for testability.
*/
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { Agent, AgentProfile } from "@/domain";
import type { LayoutNode } from "@/domain";
@ -407,47 +407,16 @@ 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;
const CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT = 64;
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;
function readCustomChatComposerHeight(storageKey: string): number {
if (typeof window === "undefined") return CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT;
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 });
const parsed = Number(window.localStorage.getItem(storageKey));
if (!Number.isFinite(parsed)) return CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT;
return Math.max(CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT, Math.round(parsed));
} catch {
return null;
return CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT;
}
}
@ -570,53 +539,25 @@ function LeafView({
},
[cellModeStorageKey],
);
const customChatSizeStorageKey = `idea.custom-chat-size.${projectId}.${id}`;
const [customChatSize, setCustomChatSizeState] = useState<CustomChatSize | null>(() =>
readCustomChatSize(customChatSizeStorageKey),
const composerHeightStorageKey = `idea.custom-chat-composer-height.${projectId}.${id}`;
const [customChatComposerHeight, setCustomChatComposerHeightState] = useState(() =>
readCustomChatComposerHeight(composerHeightStorageKey),
);
const setCustomChatSize = useCallback(
(size: CustomChatSize | null) => {
setCustomChatSizeState(size);
const setCustomChatComposerHeight = useCallback(
(height: number) => {
const nextHeight = Math.max(
CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT,
Math.round(height),
);
setCustomChatComposerHeightState(nextHeight);
if (typeof window === "undefined") return;
try {
if (size) {
window.localStorage.setItem(customChatSizeStorageKey, JSON.stringify(size));
} else {
window.localStorage.removeItem(customChatSizeStorageKey);
}
window.localStorage.setItem(composerHeightStorageKey, String(nextHeight));
} 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],
[composerHeightStorageKey],
);
const [pendingModeSwitch, setPendingModeSwitch] =
useState<PendingModeSwitch | null>(null);
@ -1192,7 +1133,7 @@ function LeafView({
minWidth: 0,
border: "1px solid #2a2a2a",
boxSizing: "border-box",
overflow: customChatSize ? "visible" : "hidden",
overflow: "hidden",
}}
>
<div
@ -1556,85 +1497,23 @@ function LeafView({
onRefresh={() => void refreshStoppedAgentState()}
/>
) : agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? (
<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>
<CustomAgentChatView
key={`${id}-${agentId}-custom`}
projectId={projectId}
agentId={agentId}
agentName={customCliAgent.name}
profile={customCliProfile}
cwd={cwd}
layoutId={layoutId}
nodeId={id}
sessionId={session}
conversationId={conversationId}
composerHeightPx={customChatComposerHeight}
composerDefaultHeightPx={CUSTOM_CHAT_COMPOSER_DEFAULT_HEIGHT}
onComposerHeightPxChange={setCustomChatComposerHeight}
onSessionId={(sid) => void setSessionWithInstrumentation("custom", sid)}
onConversationId={(cid) => void vm.setCellConversation(id, cid)}
/>
) : (
<TerminalView
key={`${id}-${agentId ?? "plain"}-${attachGen}`}