merge(cli): références d'attachments + zone chat redimensionnable — #188 (QA verte)
This commit is contained in:
@ -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(),
|
||||||
|
|||||||
@ -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).
|
||||||
///
|
///
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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")]
|
||||||
|
|||||||
@ -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 }
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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}`}
|
||||||
|
|||||||
Reference in New Issue
Block a user