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 {
match chunk {
ReplyChunk::UserPrompt { text, attachments } => {
text.len() + attachments.iter().map(String::len).sum::<usize>()
ReplyChunk::UserPrompt {
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::ToolActivity { label } => label.len(),

View File

@ -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<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() {
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<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é
/// (ARCHITECTURE §21.1-4, fenêtre annulable).
///

View File

@ -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::<usize>()
ReplyChunk::UserPrompt {
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::ToolActivity { label } => label.len(),

View File

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

View File

@ -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<domain::ChatAttachment> 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<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).
#[serde(rename_all = "camelCase")]

View File

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

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>

View File

@ -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();

View File

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