use maud::html;
use crate::backends::gemini::decode_transcript_bytes;
use crate::filesystem::Filesystem;
use crate::types::TranscriptRole;
use super::dom;
use super::templates;
use super::APP;
const HISTORY_FILE: &str = ".lh_history.json";
pub(crate) async fn load_into_pending() {
let fs = super::shared_opfs();
let bytes = match fs.read(HISTORY_FILE).await {
Ok(b) if !b.is_empty() => b,
_ => return,
};
let bytes = super::encryption::open(&bytes).await.unwrap_or(bytes);
match decode_transcript_bytes(&bytes) {
Ok(entries) if !entries.is_empty() => {
paint_entries(&entries);
dom::scroll_to_bottom_soon("transcript");
}
Ok(_) => {
}
Err(err) => {
web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
"history decode: {err}"
)));
}
}
APP.with(|cell| cell.borrow_mut().pending_history = Some(bytes));
}
pub(crate) async fn save_from_agent() {
let bytes = APP.with(|cell| {
cell.borrow()
.agent
.as_ref()
.and_then(|a| a.history_bytes().ok().flatten())
});
let Some(bytes) = bytes else { return };
let fs = super::shared_opfs();
let data = super::encryption::seal(&bytes).await.unwrap_or(bytes);
if let Err(err) = fs.write_atomic(HISTORY_FILE, &data).await {
web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
"history save: {err}"
)));
}
}
pub(crate) fn take_pending() -> Option<Vec<u8>> {
APP.with(|cell| cell.borrow_mut().pending_history.take())
}
pub(crate) fn paint_entries(entries: &[crate::types::TranscriptEntry]) {
for entry in entries {
for tc in &entry.tool_calls {
let seg_id = APP.with(|cell| cell.borrow_mut().alloc_id());
let call = crate::types::ToolCall {
name: tc.name.clone(),
id: None,
args: tc.args.clone(),
canonical_path: None,
};
let mut block = templates::tool_call_block(seg_id, &call).into_string();
if tc.result.is_some() || tc.error.is_some() {
let result = crate::types::ToolResult {
name: tc.name.clone(),
id: None,
result: tc.result.clone(),
error: tc.error.clone(),
};
let result_html = templates::tool_call_result(&result).into_string();
block = inject_result(&block, seg_id, &result_html);
}
dom::append_html("transcript", &block);
}
if !entry.text.is_empty() {
let turn_id = APP.with(|cell| cell.borrow_mut().alloc_id());
let role = entry.role.as_str();
let body = match entry.role {
TranscriptRole::User => html! { (entry.text) },
TranscriptRole::Assistant => templates::rendered_markdown(&entry.text),
};
let html_str = templates::turn(turn_id, role, body, false).into_string();
dom::append_html("transcript", &html_str);
}
}
}
fn inject_result(block: &str, seg_id: u32, result_html: &str) -> String {
let slot = format!("id=\"tool-{seg_id}-result\"");
let empty = format!("{slot}></div>");
let filled = format!("{slot}>{result_html}</div>");
block.replace(&empty, &filled)
}
pub(crate) async fn clear_persisted() {
let fs = super::shared_opfs();
if let Err(err) = fs.write_atomic(HISTORY_FILE, &[]).await {
web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
"history clear: {err}"
)));
}
}
#[cfg(test)]
mod tests {
use super::inject_result;
use crate::types::ToolResult;
fn empty_block(seg_id: u32) -> String {
format!("<details id=\"tool-{seg_id}\"><div id=\"tool-{seg_id}-result\"></div></details>")
}
#[test]
fn injects_result_into_the_matching_slot() {
let block = empty_block(7);
let out = inject_result(&block, 7, "<pre>ok</pre>");
assert_eq!(
out,
"<details id=\"tool-7\"><div id=\"tool-7-result\"><pre>ok</pre></div></details>"
);
}
#[test]
fn leaves_block_untouched_when_slot_absent() {
let block = "<details id=\"tool-1\"><div>no slot here</div></details>";
assert_eq!(inject_result(block, 1, "<pre>x</pre>"), block);
let b = empty_block(2);
assert_eq!(inject_result(&b, 9, "<pre>x</pre>"), b);
}
#[test]
fn only_the_targeted_seg_id_is_filled() {
let block = format!("{}{}", empty_block(3), empty_block(4));
let out = inject_result(&block, 3, "RESULT");
assert!(out.contains("<div id=\"tool-3-result\">RESULT</div>"));
assert!(out.contains("<div id=\"tool-4-result\"></div>"));
}
#[test]
fn malicious_tool_result_is_escaped_end_to_end() {
let evil = serde_json::json!({
"note": "<img src=x onerror=alert(1)>",
"more": "</pre><script>steal()</script>"
});
let result = ToolResult {
name: "view_file".to_string(),
id: None,
result: Some(evil),
error: None,
};
let result_html = super::templates::tool_call_result(&result).into_string();
assert!(result_html.contains("<img"), "img tag not escaped: {result_html}");
assert!(
result_html.contains("<script>"),
"script tag not escaped: {result_html}"
);
assert!(!result_html.contains("<script>"), "live <script> leaked: {result_html}");
assert!(
!result_html.contains("<img src=x"),
"live <img> leaked: {result_html}"
);
let block = empty_block(5);
let spliced = inject_result(&block, 5, &result_html);
assert!(!spliced.contains("<script>"), "splice leaked live <script>");
assert!(spliced.contains("<script>"), "splice lost escaping");
}
#[test]
fn malicious_tool_error_is_escaped() {
let result = ToolResult {
name: "create_file".to_string(),
id: None,
result: None,
error: Some("<svg onload=alert(1)>boom".to_string()),
};
let html = super::templates::tool_call_result(&result).into_string();
assert!(html.contains("<svg"), "svg not escaped: {html}");
assert!(!html.contains("<svg onload"), "live <svg> leaked: {html}");
}
}