feat(chat): resize vertical du composer avec poignée haut-droit (modèle carnet ticket) — #193
This commit is contained in:
@ -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 () => {
|
||||
|
||||
@ -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}`}
|
||||
|
||||
Reference in New Issue
Block a user