Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 79 additions & 3 deletions src-tauri/src/shared/codex_core.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
use base64::Engine;
use serde_json::{json, Value};
use std::collections::hash_map::DefaultHasher;
use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::Arc;
Expand Down Expand Up @@ -576,7 +579,10 @@ pub(crate) async fn resume_thread_core<E: EventSink>(
}
"file" => {
if let Some(url) = part.get("url").and_then(|v| v.as_str()) {
content_parts.push(json!({ "type": "image", "value": url }));
content_parts.push(json!({
"type": "image",
"value": frontend_image_value(url)
}));
}
}
_ => {}
Expand Down Expand Up @@ -1102,6 +1108,62 @@ pub(crate) async fn set_thread_name_core(
const URL_IMAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(10);
const URL_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024;

fn extension_from_mime(mime: &str) -> &'static str {
match mime.trim().to_ascii_lowercase().as_str() {
"image/png" => "png",
"image/jpeg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
"image/svg+xml" => "svg",
"image/bmp" => "bmp",
"image/tiff" => "tiff",
_ => "bin",
}
}

fn persist_data_image_to_temp_file(data_url: &str) -> Option<String> {
let trimmed = data_url.trim();
let (metadata, encoded) = trimmed
.strip_prefix("data:")?
.split_once(";base64,")?;
if !metadata.starts_with("image/") {
return None;
}
let estimated_len = encoded.len().saturating_mul(3) / 4;
if estimated_len > URL_IMAGE_MAX_BYTES {
return None;
}
let bytes = base64::engine::general_purpose::STANDARD
.decode(encoded)
.ok()?;
if bytes.len() > URL_IMAGE_MAX_BYTES {
return None;
}

let mut hasher = DefaultHasher::new();
metadata.hash(&mut hasher);
bytes.hash(&mut hasher);
let digest = hasher.finish();

let cache_dir = std::env::temp_dir().join("opencode-monitor-image-cache");
std::fs::create_dir_all(&cache_dir).ok()?;

let extension = extension_from_mime(metadata);
let path = cache_dir.join(format!("{digest:016x}.{extension}"));
if !path.exists() {
std::fs::write(&path, &bytes).ok()?;
}
path.to_str().map(|value| value.to_string())
}

fn frontend_image_value(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.starts_with("data:image/") {
return persist_data_image_to_temp_file(trimmed).unwrap_or_else(|| trimmed.to_string());
}
trimmed.to_string()
}

/// Build REST prompt parts from frontend input.
///
/// REST uses `{ type: "file", mime, url: "data:...", filename }` for images.
Expand Down Expand Up @@ -1141,7 +1203,6 @@ async fn build_rest_prompt_parts(
// Local file path — read and base64-encode.
match std::fs::read(trimmed) {
Ok(bytes) => {
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
let mime = mime_from_extension(trimmed);
let filename = std::path::Path::new(trimmed)
Expand Down Expand Up @@ -1629,7 +1690,10 @@ pub(crate) async fn send_user_message_core<E: EventSink>(
if trimmed.is_empty() {
continue;
}
content_parts.push(json!({ "type": "image", "value": trimmed }));
content_parts.push(json!({
"type": "image",
"value": frontend_image_value(trimmed)
}));
}
if !content_parts.is_empty() {
let user_item_id = {
Expand Down Expand Up @@ -2350,6 +2414,18 @@ mod tests {
});
}

#[test]
fn frontend_image_value_materializes_data_urls_to_temp_files() {
let data_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jXioAAAAASUVORK5CYII=";

let path = frontend_image_value(data_url);
let repeated = frontend_image_value(data_url);

assert!(!path.starts_with("data:"));
assert_eq!(path, repeated);
assert!(PathBuf::from(&path).exists());
}

#[test]
fn hidden_session_ids_are_read_from_workspace_settings() {
let runtime = Builder::new_current_thread()
Expand Down
86 changes: 86 additions & 0 deletions src/features/messages/components/Messages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,92 @@ describe("Messages", () => {
expect(useFileLinkOpenerMock).toHaveBeenCalledTimes(1);
});

it("virtualizes large message lists instead of rendering every row", async () => {
const items: ConversationItem[] = Array.from({ length: 60 }, (_, index) => ({
id: `msg-virtual-${index}`,
kind: "message",
role: "assistant",
text: `Virtualized message ${index}`,
}));
const offsetHeightDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"offsetHeight",
);
const offsetWidthDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
"offsetWidth",
);
const resizeObserverPrototype = window.ResizeObserver?.prototype;
const originalObserve = resizeObserverPrototype?.observe;
const originalUnobserve = resizeObserverPrototype?.unobserve;
const originalDisconnect = resizeObserverPrototype?.disconnect;

Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
configurable: true,
get() {
const element = this as HTMLElement;
if (element.classList.contains("messages")) {
return 720;
}
return 180;
},
});
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
configurable: true,
get() {
return 1024;
},
});
if (resizeObserverPrototype) {
resizeObserverPrototype.observe = () => undefined;
resizeObserverPrototype.unobserve = () => undefined;
resizeObserverPrototype.disconnect = () => undefined;
}

let unmount: (() => void) | null = null;
try {
const view = render(
<Messages
items={items}
threadId="thread-virtual"
workspaceId="ws-1"
isThinking={false}
openTargets={[]}
selectedOpenAppId=""
/>,
);
unmount = view.unmount;
const { container } = view;

await waitFor(() => {
const renderedMessages = container.querySelectorAll(".message");
expect(renderedMessages.length).toBeGreaterThan(0);
expect(renderedMessages.length).toBeLessThan(items.length);
});

expect(screen.getByText("Virtualized message 0")).toBeTruthy();
expect(screen.queryByText("Virtualized message 20")).toBeNull();
expect(screen.queryByText("Virtualized message 59")).toBeNull();
} finally {
unmount?.();
if (offsetHeightDescriptor) {
Object.defineProperty(HTMLElement.prototype, "offsetHeight", offsetHeightDescriptor);
} else {
delete (HTMLElement.prototype as { offsetHeight?: number }).offsetHeight;
}
if (offsetWidthDescriptor) {
Object.defineProperty(HTMLElement.prototype, "offsetWidth", offsetWidthDescriptor);
} else {
delete (HTMLElement.prototype as { offsetWidth?: number }).offsetWidth;
}
if (resizeObserverPrototype) {
resizeObserverPrototype.observe = originalObserve ?? (() => undefined);
resizeObserverPrototype.unobserve = originalUnobserve ?? (() => undefined);
resizeObserverPrototype.disconnect = originalDisconnect ?? (() => undefined);
}
}
});

it("renders title-only reasoning rows and keeps the working indicator generic", () => {
const items: ConversationItem[] = [
{
Expand Down
Loading