diff --git a/crates/app-tauri/src/chat.rs b/crates/app-tauri/src/chat.rs index db903f06..a0ae1675 100644 --- a/crates/app-tauri/src/chat.rs +++ b/crates/app-tauri/src/chat.rs @@ -256,8 +256,22 @@ fn progress_entity_key(progress: &ReplyProgressDto) -> Option { fn reply_chunk_bytes(chunk: &ReplyChunk) -> usize { match chunk { - ReplyChunk::UserPrompt { text, attachments } => { - text.len() + attachments.iter().map(String::len).sum::() + ReplyChunk::UserPrompt { + text, + attachments, + attachment_refs, + } => { + text.len() + + attachments.iter().map(String::len).sum::() + + attachment_refs + .iter() + .map(|attachment| { + attachment.reference.len() + + attachment.filename.len() + + attachment.mime.len() + + attachment.readable_path.len() + }) + .sum::() } ReplyChunk::TextDelta { text } => text.len(), ReplyChunk::ToolActivity { label } => label.len(), diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index de8100fa..cf9ea895 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -48,7 +48,7 @@ use crate::dto::{ AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachBackgroundTaskResultDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto, CellKind, ChangeAgentProfileDto, ChangeAgentProfileRequestDto, ChatAttachmentDto, ChatAttachmentInputDto, - ChatDraftScopeRequestDto, CloneOpenCodeProfileFromSeedRequestDto, + ChatDraftScopeRequestDto, ChatPromptAttachmentDto, CloneOpenCodeProfileFromSeedRequestDto, CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto, CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto, @@ -2356,7 +2356,9 @@ pub async fn agent_send( attachments.unwrap_or_default(), ) .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 .iter() .map(|attachment| attachment.filename.clone()) @@ -2369,6 +2371,7 @@ pub async fn agent_send( ReplyChunk::UserPrompt { text: prompt.clone(), attachments: attachment_labels, + attachment_refs, }, ); @@ -2629,15 +2632,21 @@ fn decode_attachment_content_base64(raw: &str) -> Result, 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() { return prompt.to_owned(); } let mut out = String::with_capacity(prompt.len() + attachments.len() * 160); out.push_str(prompt); out.push_str("\n\nAttachments imported by IdeA and readable from the agent sandbox:\n"); - for attachment in attachments { - out.push_str("- id: "); + for (attachment, attachment_ref) in attachments.iter().zip(refs.iter()) { + out.push_str("- "); + out.push_str(&attachment_ref.reference); + out.push_str(" id: "); out.push_str(&attachment.id); out.push_str("; file: "); out.push_str(&attachment.filename); @@ -2652,6 +2661,81 @@ fn prompt_with_imported_attachments(prompt: &str, attachments: &[ChatAttachmentD out } +fn chat_prompt_attachment_refs(attachments: &[ChatAttachmentDto]) -> Vec { + 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é /// (ARCHITECTURE §21.1-4, fenêtre annulable). /// diff --git a/crates/app-tauri/tests/chat_bridge.rs b/crates/app-tauri/tests/chat_bridge.rs index 9af76705..23b70dcb 100644 --- a/crates/app-tauri/tests/chat_bridge.rs +++ b/crates/app-tauri/tests/chat_bridge.rs @@ -106,8 +106,22 @@ fn progress_chunk_with_id( fn chunk_bytes(chunk: &ReplyChunk) -> usize { match chunk { - ReplyChunk::UserPrompt { text, attachments } => { - text.len() + attachments.iter().map(String::len).sum::() + ReplyChunk::UserPrompt { + text, + attachments, + attachment_refs, + } => { + text.len() + + attachments.iter().map(String::len).sum::() + + attachment_refs + .iter() + .map(|attachment| { + attachment.reference.len() + + attachment.filename.len() + + attachment.mime.len() + + attachment.readable_path.len() + }) + .sum::() } ReplyChunk::TextDelta { text } => text.len(), ReplyChunk::ToolActivity { label } => label.len(), diff --git a/crates/app-tauri/tests/dto_chat.rs b/crates/app-tauri/tests/dto_chat.rs index 8c32eb21..23bd463a 100644 --- a/crates/app-tauri/tests/dto_chat.rs +++ b/crates/app-tauri/tests/dto_chat.rs @@ -8,9 +8,9 @@ use app_tauri_lib::dto::{ CellKind, ChatAttachmentDto, ChatAttachmentInputDto, ChatDraftScopeRequestDto, - ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto, ReadChatDraftResponseDto, - ReattachChatDto, ReplyChunk, ReplyProgressDto, ReplyProgressKindDto, ReplyProgressSourceDto, - ReplyProgressStageDto, SaveChatDraftRequestDto, TerminalSessionDto, + ChatPromptAttachmentDto, ImportChatAttachmentsRequestDto, ImportChatAttachmentsResponseDto, + ReadChatDraftResponseDto, ReattachChatDto, ReplyChunk, ReplyProgressDto, ReplyProgressKindDto, + ReplyProgressSourceDto, ReplyProgressStageDto, SaveChatDraftRequestDto, TerminalSessionDto, }; use application::{LaunchAgentOutput, StructuredSessionDescriptor}; 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 { text: "run tests".into(), attachments: Vec::new(), + attachment_refs: Vec::new(), }) .unwrap(); 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 { text: "describe this".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(); assert_eq!( @@ -45,7 +53,14 @@ fn reply_chunk_user_prompt_serialises_attachment_labels() { json!({ "kind": "userPrompt", "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 { text: "u".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::ToolActivity { @@ -168,7 +191,8 @@ fn reply_chunk_deserialises_from_camel_case_wire_payload() { back, ReplyChunk::UserPrompt { 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 { text: "Hi".into(), attachments: Vec::new(), + attachment_refs: Vec::new(), }, ReplyChunk::TextDelta { text: "Hi".into() }, ReplyChunk::Final { diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index f1114b86..b29f4d8a 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -3206,6 +3206,22 @@ pub struct ChatAttachmentDto { 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 for ChatAttachmentDto { fn from(value: domain::ChatAttachment) -> Self { Self { @@ -3359,6 +3375,9 @@ pub enum ReplyChunk { /// Display labels for attachments submitted with the prompt. #[serde(default, skip_serializing_if = "Vec::is_empty")] attachments: Vec, + /// Canonical attachment references for this prompt, in backend import order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + attachment_refs: Vec, }, /// An assistant text fragment (incremental chat rendering). #[serde(rename_all = "camelCase")] diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index db9f8fc6..b6c70137 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -1691,8 +1691,21 @@ export interface ReplyProgress { toolName?: string; } +export interface ChatPromptAttachment { + reference: string; + filename: string; + mime?: string; + sizeBytes?: number; + readablePath?: string; +} + export type ReplyChunk = - | { kind: "userPrompt"; text: string; attachments?: string[] } + | { + kind: "userPrompt"; + text: string; + attachments?: string[]; + attachmentRefs?: ChatPromptAttachment[]; + } | { kind: "textDelta"; text: string } | { kind: "toolActivity"; label: string } | { kind: "progress"; progress: ReplyProgress } diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 37c50555..5461912b 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -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( + null) }, + } as unknown as Gateways} + > + + , + ); + + 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 () => { diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 07c15f77..a789d22f 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -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; + 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 && (
{turn.attachments.map((attachment) => ( -

- Fichier: {attachment} +

+ {attachment.reference} {attachment.filename}

))}
diff --git a/frontend/src/features/layout/LayoutGrid.chat.test.tsx b/frontend/src/features/layout/LayoutGrid.chat.test.tsx index 47490955..976b7bef 100644 --- a/frontend/src/features/layout/LayoutGrid.chat.test.tsx +++ b/frontend/src/features/layout/LayoutGrid.chat.test.tsx @@ -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(); diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index abaaab00..670849bd 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -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; + 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(() => + 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) => { + const container = event.currentTarget.closest("[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(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", }} >
void refreshStoppedAgentState()} /> ) : agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? ( - void setSessionWithInstrumentation("custom", sid)} - onConversationId={(cid) => void vm.setCellConversation(id, cid)} - /> +
+ void setSessionWithInstrumentation("custom", sid)} + onConversationId={(cid) => void vm.setCellConversation(id, cid)} + /> + {customChatSize && ( + + )} + +
) : (