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:
@ -78,7 +78,7 @@ describe("CustomAgentChatView", () => {
|
||||
__resetProjectCliInteractionActivityForTests();
|
||||
});
|
||||
|
||||
it("repaints attachment labels from user prompt scrollback", async () => {
|
||||
it("repaints compact attachment refs from legacy user prompt scrollback", async () => {
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(),
|
||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||
@ -119,7 +119,68 @@ describe("CustomAgentChatView", () => {
|
||||
);
|
||||
|
||||
expect(await screen.findByText("describe this")).toBeTruthy();
|
||||
expect(screen.getByText("Fichier: clipboard.png")).toBeTruthy();
|
||||
expect(screen.getByText("[image 1] clipboard.png")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("prefers canonical attachment refs from user prompt scrollback", async () => {
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(),
|
||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
scrollback: [
|
||||
{
|
||||
kind: "userPrompt" as const,
|
||||
text: "compare these",
|
||||
attachments: ["screenshot.png", "error.log"],
|
||||
attachmentRefs: [
|
||||
{
|
||||
reference: "[image 1]",
|
||||
filename: "screenshot.png",
|
||||
mime: "image/png",
|
||||
sizeBytes: 12,
|
||||
readablePath: "/repo/.ideai/attachments/screenshot.png",
|
||||
},
|
||||
{
|
||||
reference: "[file 1]",
|
||||
filename: "error.log",
|
||||
mime: "text/plain",
|
||||
sizeBytes: 34,
|
||||
readablePath: "/repo/.ideai/attachments/error.log",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})),
|
||||
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="chat-session-1"
|
||||
conversationId="conversation-1"
|
||||
onSessionId={vi.fn()}
|
||||
onConversationId={vi.fn()}
|
||||
/>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("compare these")).toBeTruthy();
|
||||
expect(screen.getByText("[image 1] screenshot.png")).toBeTruthy();
|
||||
expect(screen.getByText("[file 1] error.log")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("detaches on unmount then reattaches with missed scrollback chunks", async () => {
|
||||
@ -2342,7 +2403,7 @@ describe("CustomAgentChatView", () => {
|
||||
},
|
||||
);
|
||||
expect(screen.getByText("Pièce jointe")).toBeTruthy();
|
||||
expect(screen.getByText("Fichier: paste-image.png")).toBeTruthy();
|
||||
expect(screen.getByText("[image 1] paste-image.png")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("accepts pasted generic files exposed only through clipboardData.files", async () => {
|
||||
@ -2503,7 +2564,7 @@ describe("CustomAgentChatView", () => {
|
||||
},
|
||||
);
|
||||
expect(screen.getByText("prefix inserted")).toBeTruthy();
|
||||
expect(screen.getByText("Fichier: mixed.txt")).toBeTruthy();
|
||||
expect(screen.getByText("[file 1] mixed.txt")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("deduplicates clipboard files by metadata across clipboard sources and repeated paste", async () => {
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
|
||||
import type {
|
||||
AgentProfile,
|
||||
ChatPromptAttachment,
|
||||
GatewayError,
|
||||
ReplyChunk,
|
||||
ReplyProgress,
|
||||
@ -50,7 +51,7 @@ export interface CustomAgentChatViewProps {
|
||||
}
|
||||
|
||||
type ChatTurn =
|
||||
| { role: "user"; text: string; attachments?: string[] }
|
||||
| { role: "user"; text: string; attachments?: ChatPromptAttachment[] }
|
||||
| { role: "agent"; text: string; pending?: boolean }
|
||||
| { role: "tool"; label: string }
|
||||
| { role: "progress"; progress: ReplyProgress; settled?: boolean }
|
||||
@ -158,10 +159,62 @@ function attachmentLabels(value: unknown): string[] {
|
||||
: [];
|
||||
}
|
||||
|
||||
function isImageFilename(filename: string): boolean {
|
||||
return /\.(apng|avif|gif|jpe?g|png|svg|webp)$/i.test(filename.trim());
|
||||
}
|
||||
|
||||
function fallbackAttachmentRefs(labels: string[]): ChatPromptAttachment[] {
|
||||
let imageCount = 0;
|
||||
let fileCount = 0;
|
||||
return labels.map((filename) => {
|
||||
const image = isImageFilename(filename);
|
||||
const reference = image ? `[image ${++imageCount}]` : `[file ${++fileCount}]`;
|
||||
return { reference, filename };
|
||||
});
|
||||
}
|
||||
|
||||
function attachmentRefs(value: unknown, labels: string[] = []): ChatPromptAttachment[] {
|
||||
if (Array.isArray(value)) {
|
||||
const refs = value
|
||||
.map((item): ChatPromptAttachment | null => {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const record = item as Record<string, unknown>;
|
||||
const reference = optionalString(record.reference);
|
||||
const filename = optionalString(record.filename);
|
||||
if (!reference || !filename) return null;
|
||||
return {
|
||||
reference,
|
||||
filename,
|
||||
mime: optionalString(record.mime),
|
||||
sizeBytes: typeof record.sizeBytes === "number" ? record.sizeBytes : undefined,
|
||||
readablePath: optionalString(record.readablePath),
|
||||
};
|
||||
})
|
||||
.filter((item): item is ChatPromptAttachment => Boolean(item));
|
||||
if (refs.length > 0) return refs;
|
||||
}
|
||||
return fallbackAttachmentRefs(labels);
|
||||
}
|
||||
|
||||
function attachmentRefsFromDrafts(attachments: AttachmentDraft[]): ChatPromptAttachment[] {
|
||||
let imageCount = 0;
|
||||
let fileCount = 0;
|
||||
return attachments.map((attachment) => {
|
||||
const mime = attachment.input.mime ?? "";
|
||||
const image = mime.startsWith("image/") || (!mime && isImageFilename(attachment.label));
|
||||
const reference = image ? `[image ${++imageCount}]` : `[file ${++fileCount}]`;
|
||||
return {
|
||||
reference,
|
||||
filename: attachment.label,
|
||||
mime: attachment.input.mime,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function appendUserPrompt(
|
||||
turns: ChatTurn[],
|
||||
text: string,
|
||||
attachments: string[] = [],
|
||||
attachments: ChatPromptAttachment[] = [],
|
||||
): ChatTurn[] {
|
||||
const last = turns[turns.length - 1];
|
||||
if (
|
||||
@ -589,7 +642,7 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState {
|
||||
turns: appendUserPrompt(
|
||||
turns,
|
||||
String(raw.text ?? raw.prompt ?? ""),
|
||||
attachmentLabels(raw.attachments),
|
||||
attachmentRefs(raw.attachmentRefs, attachmentLabels(raw.attachments)),
|
||||
),
|
||||
entityProgressMap: state.entityProgressMap,
|
||||
};
|
||||
@ -1359,7 +1412,7 @@ export function CustomAgentChatView({
|
||||
}
|
||||
const outgoingAttachments = attachments;
|
||||
const attachmentInputs = outgoingAttachments.map((item) => item.input);
|
||||
const attachmentLabels = outgoingAttachments.map((item) => item.label);
|
||||
const attachmentRefs = attachmentRefsFromDrafts(outgoingAttachments);
|
||||
const displayText = text || "Pièce jointe";
|
||||
const interactionId = `${agentId}:${++interactionSeqRef.current}`;
|
||||
activeInteractionIdRef.current = interactionId;
|
||||
@ -1380,7 +1433,7 @@ export function CustomAgentChatView({
|
||||
{
|
||||
role: "user",
|
||||
text: displayText,
|
||||
...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}),
|
||||
...(attachmentRefs.length > 0 ? { attachments: attachmentRefs } : {}),
|
||||
},
|
||||
],
|
||||
entityProgressMap: prev.entityProgressMap,
|
||||
@ -1855,8 +1908,8 @@ function ChatBubble({
|
||||
{user && turn.attachments && turn.attachments.length > 0 && (
|
||||
<div className="mt-1 flex flex-col gap-0.5 text-xs text-muted">
|
||||
{turn.attachments.map((attachment) => (
|
||||
<p key={attachment} className="truncate">
|
||||
Fichier: {attachment}
|
||||
<p key={`${attachment.reference}-${attachment.filename}`} className="truncate">
|
||||
{attachment.reference} {attachment.filename}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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}`}
|
||||
|
||||
Reference in New Issue
Block a user