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

@ -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 () => {

View File

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