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>
|
||||
|
||||
Reference in New Issue
Block a user