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

@ -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}`}