merge(cli): références d'attachments + zone chat redimensionnable — #188 (QA verte)

This commit is contained in:
2026-08-13 13:01:12 +02:00
10 changed files with 513 additions and 42 deletions

View File

@ -256,8 +256,22 @@ fn progress_entity_key(progress: &ReplyProgressDto) -> Option<String> {
fn reply_chunk_bytes(chunk: &ReplyChunk) -> usize { fn reply_chunk_bytes(chunk: &ReplyChunk) -> usize {
match chunk { match chunk {
ReplyChunk::UserPrompt { text, attachments } => { ReplyChunk::UserPrompt {
text.len() + attachments.iter().map(String::len).sum::<usize>() text,
attachments,
attachment_refs,
} => {
text.len()
+ attachments.iter().map(String::len).sum::<usize>()
+ attachment_refs
.iter()
.map(|attachment| {
attachment.reference.len()
+ attachment.filename.len()
+ attachment.mime.len()
+ attachment.readable_path.len()
})
.sum::<usize>()
} }
ReplyChunk::TextDelta { text } => text.len(), ReplyChunk::TextDelta { text } => text.len(),
ReplyChunk::ToolActivity { label } => label.len(), ReplyChunk::ToolActivity { label } => label.len(),

View File

@ -48,7 +48,7 @@ use crate::dto::{
AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachBackgroundTaskResultDto, AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachBackgroundTaskResultDto,
AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto, CellKind, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto, CellKind,
ChangeAgentProfileDto, ChangeAgentProfileRequestDto, ChatAttachmentDto, ChatAttachmentInputDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto, ChatAttachmentDto, ChatAttachmentInputDto,
ChatDraftScopeRequestDto, CloneOpenCodeProfileFromSeedRequestDto, ChatDraftScopeRequestDto, ChatPromptAttachmentDto, CloneOpenCodeProfileFromSeedRequestDto,
CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto, CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto, CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
@ -2356,7 +2356,9 @@ pub async fn agent_send(
attachments.unwrap_or_default(), attachments.unwrap_or_default(),
) )
.await?; .await?;
let prompt_for_model = prompt_with_imported_attachments(&prompt, &imported_attachments); let attachment_refs = chat_prompt_attachment_refs(&imported_attachments);
let prompt_for_model =
prompt_with_imported_attachments(&prompt, &imported_attachments, &attachment_refs);
let attachment_labels = imported_attachments let attachment_labels = imported_attachments
.iter() .iter()
.map(|attachment| attachment.filename.clone()) .map(|attachment| attachment.filename.clone())
@ -2369,6 +2371,7 @@ pub async fn agent_send(
ReplyChunk::UserPrompt { ReplyChunk::UserPrompt {
text: prompt.clone(), text: prompt.clone(),
attachments: attachment_labels, attachments: attachment_labels,
attachment_refs,
}, },
); );
@ -2629,15 +2632,21 @@ fn decode_attachment_content_base64(raw: &str) -> Result<Vec<u8>, ErrorDto> {
}) })
} }
fn prompt_with_imported_attachments(prompt: &str, attachments: &[ChatAttachmentDto]) -> String { fn prompt_with_imported_attachments(
prompt: &str,
attachments: &[ChatAttachmentDto],
refs: &[ChatPromptAttachmentDto],
) -> String {
if attachments.is_empty() { if attachments.is_empty() {
return prompt.to_owned(); return prompt.to_owned();
} }
let mut out = String::with_capacity(prompt.len() + attachments.len() * 160); let mut out = String::with_capacity(prompt.len() + attachments.len() * 160);
out.push_str(prompt); out.push_str(prompt);
out.push_str("\n\nAttachments imported by IdeA and readable from the agent sandbox:\n"); out.push_str("\n\nAttachments imported by IdeA and readable from the agent sandbox:\n");
for attachment in attachments { for (attachment, attachment_ref) in attachments.iter().zip(refs.iter()) {
out.push_str("- id: "); out.push_str("- ");
out.push_str(&attachment_ref.reference);
out.push_str(" id: ");
out.push_str(&attachment.id); out.push_str(&attachment.id);
out.push_str("; file: "); out.push_str("; file: ");
out.push_str(&attachment.filename); out.push_str(&attachment.filename);
@ -2652,6 +2661,81 @@ fn prompt_with_imported_attachments(prompt: &str, attachments: &[ChatAttachmentD
out out
} }
fn chat_prompt_attachment_refs(attachments: &[ChatAttachmentDto]) -> Vec<ChatPromptAttachmentDto> {
let mut image_count = 0usize;
let mut file_count = 0usize;
attachments
.iter()
.map(|attachment| {
let reference = if attachment.mime.starts_with("image/") {
image_count += 1;
format!("[image {image_count}]")
} else {
file_count += 1;
format!("[file {file_count}]")
};
ChatPromptAttachmentDto {
reference,
filename: attachment.filename.clone(),
mime: attachment.mime.clone(),
size_bytes: attachment.size_bytes,
readable_path: attachment.readable_path.clone(),
}
})
.collect()
}
#[cfg(test)]
mod chat_prompt_attachment_projection_tests {
use super::*;
fn attachment(filename: &str, mime: &str, size_bytes: u64) -> ChatAttachmentDto {
ChatAttachmentDto {
id: format!("id-{filename}"),
filename: filename.to_owned(),
mime: mime.to_owned(),
size_bytes,
source_kind: domain::ChatAttachmentSourceKind::Clipboard,
storage_path: format!("agent-chat/session/id-{filename}-{filename}"),
readable_path: format!("/project/.ideai/attachments/agent-chat/session/{filename}"),
created_at: size_bytes,
}
}
#[test]
fn chat_prompt_attachment_refs_follow_import_order_and_kind_counters() {
let attachments = vec![
attachment("first.png", "image/png", 10),
attachment("notes.pdf", "application/pdf", 20),
attachment("second.jpg", "image/jpeg", 30),
];
let refs = chat_prompt_attachment_refs(&attachments);
assert_eq!(refs[0].reference, "[image 1]");
assert_eq!(refs[0].filename, "first.png");
assert_eq!(refs[1].reference, "[file 1]");
assert_eq!(refs[1].filename, "notes.pdf");
assert_eq!(refs[2].reference, "[image 2]");
assert_eq!(refs[2].filename, "second.jpg");
}
#[test]
fn prompt_with_imported_attachments_uses_canonical_refs() {
let attachments = vec![
attachment("first.png", "image/png", 10),
attachment("notes.pdf", "application/pdf", 20),
];
let refs = chat_prompt_attachment_refs(&attachments);
let prompt = prompt_with_imported_attachments("describe", &attachments, &refs);
assert!(prompt.contains("- [image 1] id: id-first.png; file: first.png"));
assert!(prompt.contains("- [file 1] id: id-notes.pdf; file: notes.pdf"));
assert!(prompt.contains("; path: /project/.ideai/attachments/agent-chat/session/notes.pdf"));
}
}
/// `cancel_resume` — annule la **reprise automatique** armée pour un agent limité /// `cancel_resume` — annule la **reprise automatique** armée pour un agent limité
/// (ARCHITECTURE §21.1-4, fenêtre annulable). /// (ARCHITECTURE §21.1-4, fenêtre annulable).
/// ///

View File

@ -106,8 +106,22 @@ fn progress_chunk_with_id(
fn chunk_bytes(chunk: &ReplyChunk) -> usize { fn chunk_bytes(chunk: &ReplyChunk) -> usize {
match chunk { match chunk {
ReplyChunk::UserPrompt { text, attachments } => { ReplyChunk::UserPrompt {
text.len() + attachments.iter().map(String::len).sum::<usize>() text,
attachments,
attachment_refs,
} => {
text.len()
+ attachments.iter().map(String::len).sum::<usize>()
+ attachment_refs
.iter()
.map(|attachment| {
attachment.reference.len()
+ attachment.filename.len()
+ attachment.mime.len()
+ attachment.readable_path.len()
})
.sum::<usize>()
} }
ReplyChunk::TextDelta { text } => text.len(), ReplyChunk::TextDelta { text } => text.len(),
ReplyChunk::ToolActivity { label } => label.len(), ReplyChunk::ToolActivity { label } => label.len(),

View File

@ -8,9 +8,9 @@
use app_tauri_lib::dto::{ use app_tauri_lib::dto::{
CellKind, ChatAttachmentDto, ChatAttachmentInputDto, ChatDraftScopeRequestDto, CellKind, ChatAttachmentDto, ChatAttachmentInputDto, ChatDraftScopeRequestDto,
ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto, ReadChatDraftResponseDto, ChatPromptAttachmentDto, ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto,
ReattachChatDto, ReplyChunk, ReplyProgressDto, ReplyProgressKindDto, ReplyProgressSourceDto, ReadChatDraftResponseDto, ReattachChatDto, ReplyChunk, ReplyProgressDto, ReplyProgressKindDto,
ReplyProgressStageDto, SaveChatDraftRequestDto, TerminalSessionDto, ReplyProgressSourceDto, ReplyProgressStageDto, SaveChatDraftRequestDto, TerminalSessionDto,
}; };
use application::{LaunchAgentOutput, StructuredSessionDescriptor}; use application::{LaunchAgentOutput, StructuredSessionDescriptor};
use domain::project::ProjectPath; use domain::project::ProjectPath;
@ -28,6 +28,7 @@ fn reply_chunk_user_prompt_serialises_exact_camel_case() {
let v = serde_json::to_value(ReplyChunk::UserPrompt { let v = serde_json::to_value(ReplyChunk::UserPrompt {
text: "run tests".into(), text: "run tests".into(),
attachments: Vec::new(), attachments: Vec::new(),
attachment_refs: Vec::new(),
}) })
.unwrap(); .unwrap();
assert_eq!(v, json!({ "kind": "userPrompt", "text": "run tests" })); assert_eq!(v, json!({ "kind": "userPrompt", "text": "run tests" }));
@ -38,6 +39,13 @@ fn reply_chunk_user_prompt_serialises_attachment_labels() {
let v = serde_json::to_value(ReplyChunk::UserPrompt { let v = serde_json::to_value(ReplyChunk::UserPrompt {
text: "describe this".into(), text: "describe this".into(),
attachments: vec!["clipboard.png".into()], attachments: vec!["clipboard.png".into()],
attachment_refs: vec![ChatPromptAttachmentDto {
reference: "[image 1]".into(),
filename: "clipboard.png".into(),
mime: "image/png".into(),
size_bytes: 9,
readable_path: "/project/.ideai/attachments/agent-chat/s/attach-1-clipboard.png".into(),
}],
}) })
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@ -45,7 +53,14 @@ fn reply_chunk_user_prompt_serialises_attachment_labels() {
json!({ json!({
"kind": "userPrompt", "kind": "userPrompt",
"text": "describe this", "text": "describe this",
"attachments": ["clipboard.png"] "attachments": ["clipboard.png"],
"attachmentRefs": [{
"reference": "[image 1]",
"filename": "clipboard.png",
"mime": "image/png",
"sizeBytes": 9,
"readablePath": "/project/.ideai/attachments/agent-chat/s/attach-1-clipboard.png"
}]
}) })
); );
} }
@ -128,6 +143,14 @@ fn reply_chunk_round_trips_through_json_for_every_variant() {
ReplyChunk::UserPrompt { ReplyChunk::UserPrompt {
text: "u".into(), text: "u".into(),
attachments: vec!["clipboard.png".into()], attachments: vec!["clipboard.png".into()],
attachment_refs: vec![ChatPromptAttachmentDto {
reference: "[image 1]".into(),
filename: "clipboard.png".into(),
mime: "image/png".into(),
size_bytes: 9,
readable_path: "/project/.ideai/attachments/agent-chat/s/attach-1-clipboard.png"
.into(),
}],
}, },
ReplyChunk::TextDelta { text: "x".into() }, ReplyChunk::TextDelta { text: "x".into() },
ReplyChunk::ToolActivity { ReplyChunk::ToolActivity {
@ -168,7 +191,8 @@ fn reply_chunk_deserialises_from_camel_case_wire_payload() {
back, back,
ReplyChunk::UserPrompt { ReplyChunk::UserPrompt {
text: "hi".into(), text: "hi".into(),
attachments: Vec::new() attachments: Vec::new(),
attachment_refs: Vec::new()
} }
); );
} }
@ -343,6 +367,7 @@ fn reattach_chat_dto_serialises_camel_case_with_typed_scrollback() {
ReplyChunk::UserPrompt { ReplyChunk::UserPrompt {
text: "Hi".into(), text: "Hi".into(),
attachments: Vec::new(), attachments: Vec::new(),
attachment_refs: Vec::new(),
}, },
ReplyChunk::TextDelta { text: "Hi".into() }, ReplyChunk::TextDelta { text: "Hi".into() },
ReplyChunk::Final { ReplyChunk::Final {

View File

@ -3206,6 +3206,22 @@ pub struct ChatAttachmentDto {
pub created_at: u64, pub created_at: u64,
} }
/// Canonical attachment projection attached to a streamed user prompt.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChatPromptAttachmentDto {
/// Stable textual reference inserted in the model prompt, e.g. `[file 1]`.
pub reference: String,
/// Original display filename.
pub filename: String,
/// MIME type.
pub mime: String,
/// Size in bytes.
pub size_bytes: u64,
/// Absolute managed path readable by the target agent sandbox.
pub readable_path: String,
}
impl From<domain::ChatAttachment> for ChatAttachmentDto { impl From<domain::ChatAttachment> for ChatAttachmentDto {
fn from(value: domain::ChatAttachment) -> Self { fn from(value: domain::ChatAttachment) -> Self {
Self { Self {
@ -3359,6 +3375,9 @@ pub enum ReplyChunk {
/// Display labels for attachments submitted with the prompt. /// Display labels for attachments submitted with the prompt.
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
attachments: Vec<String>, attachments: Vec<String>,
/// Canonical attachment references for this prompt, in backend import order.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
attachment_refs: Vec<ChatPromptAttachmentDto>,
}, },
/// An assistant text fragment (incremental chat rendering). /// An assistant text fragment (incremental chat rendering).
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]

View File

@ -1691,8 +1691,21 @@ export interface ReplyProgress {
toolName?: string; toolName?: string;
} }
export interface ChatPromptAttachment {
reference: string;
filename: string;
mime?: string;
sizeBytes?: number;
readablePath?: string;
}
export type ReplyChunk = export type ReplyChunk =
| { kind: "userPrompt"; text: string; attachments?: string[] } | {
kind: "userPrompt";
text: string;
attachments?: string[];
attachmentRefs?: ChatPromptAttachment[];
}
| { kind: "textDelta"; text: string } | { kind: "textDelta"; text: string }
| { kind: "toolActivity"; label: string } | { kind: "toolActivity"; label: string }
| { kind: "progress"; progress: ReplyProgress } | { kind: "progress"; progress: ReplyProgress }

View File

@ -78,7 +78,7 @@ describe("CustomAgentChatView", () => {
__resetProjectCliInteractionActivityForTests(); __resetProjectCliInteractionActivityForTests();
}); });
it("repaints attachment labels from user prompt scrollback", async () => { it("repaints compact attachment refs from legacy user prompt scrollback", async () => {
const agent = { const agent = {
launchAgentChat: vi.fn(), launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({ reattachAgentChat: vi.fn(async (sessionId: string) => ({
@ -119,7 +119,68 @@ describe("CustomAgentChatView", () => {
); );
expect(await screen.findByText("describe this")).toBeTruthy(); 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 () => { 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("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 () => { 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("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 () => { it("deduplicates clipboard files by metadata across clipboard sources and repeated paste", async () => {

View File

@ -17,6 +17,7 @@ import {
import type { import type {
AgentProfile, AgentProfile,
ChatPromptAttachment,
GatewayError, GatewayError,
ReplyChunk, ReplyChunk,
ReplyProgress, ReplyProgress,
@ -50,7 +51,7 @@ export interface CustomAgentChatViewProps {
} }
type ChatTurn = type ChatTurn =
| { role: "user"; text: string; attachments?: string[] } | { role: "user"; text: string; attachments?: ChatPromptAttachment[] }
| { role: "agent"; text: string; pending?: boolean } | { role: "agent"; text: string; pending?: boolean }
| { role: "tool"; label: string } | { role: "tool"; label: string }
| { role: "progress"; progress: ReplyProgress; settled?: boolean } | { 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( function appendUserPrompt(
turns: ChatTurn[], turns: ChatTurn[],
text: string, text: string,
attachments: string[] = [], attachments: ChatPromptAttachment[] = [],
): ChatTurn[] { ): ChatTurn[] {
const last = turns[turns.length - 1]; const last = turns[turns.length - 1];
if ( if (
@ -589,7 +642,7 @@ function foldChunk(state: ChatViewState, raw: unknown): ChatViewState {
turns: appendUserPrompt( turns: appendUserPrompt(
turns, turns,
String(raw.text ?? raw.prompt ?? ""), String(raw.text ?? raw.prompt ?? ""),
attachmentLabels(raw.attachments), attachmentRefs(raw.attachmentRefs, attachmentLabels(raw.attachments)),
), ),
entityProgressMap: state.entityProgressMap, entityProgressMap: state.entityProgressMap,
}; };
@ -1359,7 +1412,7 @@ export function CustomAgentChatView({
} }
const outgoingAttachments = attachments; const outgoingAttachments = attachments;
const attachmentInputs = outgoingAttachments.map((item) => item.input); 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 displayText = text || "Pièce jointe";
const interactionId = `${agentId}:${++interactionSeqRef.current}`; const interactionId = `${agentId}:${++interactionSeqRef.current}`;
activeInteractionIdRef.current = interactionId; activeInteractionIdRef.current = interactionId;
@ -1380,7 +1433,7 @@ export function CustomAgentChatView({
{ {
role: "user", role: "user",
text: displayText, text: displayText,
...(attachmentLabels.length > 0 ? { attachments: attachmentLabels } : {}), ...(attachmentRefs.length > 0 ? { attachments: attachmentRefs } : {}),
}, },
], ],
entityProgressMap: prev.entityProgressMap, entityProgressMap: prev.entityProgressMap,
@ -1855,8 +1908,8 @@ function ChatBubble({
{user && turn.attachments && turn.attachments.length > 0 && ( {user && turn.attachments && turn.attachments.length > 0 && (
<div className="mt-1 flex flex-col gap-0.5 text-xs text-muted"> <div className="mt-1 flex flex-col gap-0.5 text-xs text-muted">
{turn.attachments.map((attachment) => ( {turn.attachments.map((attachment) => (
<p key={attachment} className="truncate"> <p key={`${attachment.reference}-${attachment.filename}`} className="truncate">
Fichier: {attachment} {attachment.reference} {attachment.filename}
</p> </p>
))} ))}
</div> </div>

View File

@ -180,6 +180,37 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
expect(screen.getAllByText(/mock-attachment.txt/).length).toBeGreaterThan(0); 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 () => { it("opens the custom CLI even when the agent already has a structured background session (#169)", async () => {
const layout = new MockLayoutGateway(); const layout = new MockLayoutGateway();
const agent = new MockAgentGateway(); const agent = new MockAgentGateway();

View File

@ -16,7 +16,7 @@
* {@link normalizeWeights} function, kept out of the render for testability. * {@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 { Agent, AgentProfile } from "@/domain";
import type { LayoutNode } from "@/domain"; import type { LayoutNode } from "@/domain";
@ -407,6 +407,50 @@ const CELL_Z = {
turnActions: 6, turnActions: 6,
} as const; } 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({ function LeafView({
id, id,
session, session,
@ -526,6 +570,54 @@ function LeafView({
}, },
[cellModeStorageKey], [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] = const [pendingModeSwitch, setPendingModeSwitch] =
useState<PendingModeSwitch | null>(null); useState<PendingModeSwitch | null>(null);
const [trustedCustomCli, setTrustedCustomCli] = const [trustedCustomCli, setTrustedCustomCli] =
@ -1100,7 +1192,7 @@ function LeafView({
minWidth: 0, minWidth: 0,
border: "1px solid #2a2a2a", border: "1px solid #2a2a2a",
boxSizing: "border-box", boxSizing: "border-box",
overflow: "hidden", overflow: customChatSize ? "visible" : "hidden",
}} }}
> >
<div <div
@ -1464,20 +1556,85 @@ function LeafView({
onRefresh={() => void refreshStoppedAgentState()} onRefresh={() => void refreshStoppedAgentState()}
/> />
) : agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? ( ) : agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? (
<CustomAgentChatView <div
key={`${id}-${agentId}-custom`} data-custom-chat-shell
projectId={projectId} data-testid="custom-agent-chat-shell"
agentId={agentId} style={{
agentName={customCliAgent.name} position: customChatSize ? "absolute" : "relative",
profile={customCliProfile} top: 0,
cwd={cwd} left: 0,
layoutId={layoutId} zIndex: customChatSize ? 2 : 0,
nodeId={id} width: customChatSize ? customChatSize.width : "100%",
sessionId={session} height: customChatSize ? customChatSize.height : "100%",
conversationId={conversationId} minWidth: CUSTOM_CHAT_MIN_WIDTH,
onSessionId={(sid) => void setSessionWithInstrumentation("custom", sid)} minHeight: CUSTOM_CHAT_MIN_HEIGHT,
onConversationId={(cid) => void vm.setCellConversation(id, cid)} 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 <TerminalView
key={`${id}-${agentId ?? "plain"}-${attachGen}`} key={`${id}-${agentId ?? "plain"}-${attachGen}`}