Skip to content

Commit 1ca6030

Browse files
committed
feat: add unified diff generation for file edits and fix replay message ordering
1 parent dc8bf01 commit 1ca6030

File tree

5 files changed

+173
-14
lines changed

5 files changed

+173
-14
lines changed

src-tauri/Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ reqwest-eventsource = "0.6"
4343
libc = "0.2"
4444
chrono = { version = "0.4", features = ["clock"] }
4545
shell-words = "1.1"
46+
similar = "2.6"
4647
toml = "0.8"
4748

4849
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]

src-tauri/src/backend/event_translator.rs

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
//! OpenCode ↔ CodexMonitor translation happens here in Rust.
88
99
use serde_json::{json, Value};
10+
use similar::TextDiff;
1011
use std::collections::HashMap;
1112
use std::sync::atomic::{AtomicU64, Ordering};
1213

@@ -1307,6 +1308,36 @@ fn file_path_from_raw_input(raw_input: &Value) -> Option<String> {
13071308
.map(ToOwned::to_owned)
13081309
}
13091310

1311+
/// Generate a unified diff from oldString/newString edit input.
1312+
/// Returns a unified diff string with @@ hunk headers that the frontend can render.
1313+
fn generate_edit_diff(raw_input: &Value, file_path: &str) -> Option<String> {
1314+
let old_string = raw_input.get("oldString").and_then(|v| v.as_str())?;
1315+
let new_string = raw_input.get("newString").and_then(|v| v.as_str())?;
1316+
1317+
// Don't generate diff for empty old/new (pure create or delete)
1318+
if old_string.is_empty() && new_string.is_empty() {
1319+
return None;
1320+
}
1321+
1322+
let diff = TextDiff::from_lines(old_string, new_string);
1323+
let mut output = String::new();
1324+
1325+
// Add file header
1326+
output.push_str(&format!("--- a/{file_path}\n"));
1327+
output.push_str(&format!("+++ b/{file_path}\n"));
1328+
1329+
// Generate unified diff hunks
1330+
for hunk in diff.unified_diff().context_radius(3).iter_hunks() {
1331+
output.push_str(&hunk.to_string());
1332+
}
1333+
1334+
if output.contains("@@") {
1335+
Some(output)
1336+
} else {
1337+
None
1338+
}
1339+
}
1340+
13101341
fn build_tool_item(
13111342
item_id: &str,
13121343
item_type: &str,
@@ -1355,7 +1386,11 @@ fn build_tool_item(
13551386
if changes.is_empty() {
13561387
if let Some(input) = raw_input {
13571388
if let Some(path) = file_path_from_raw_input(input) {
1358-
changes.push(json!({ "path": path, "kind": "modify" }));
1389+
let mut change = json!({ "path": path, "kind": "modify" });
1390+
if let Some(diff) = generate_edit_diff(input, &path) {
1391+
change["diff"] = json!(diff);
1392+
}
1393+
changes.push(change);
13591394
}
13601395
}
13611396
}
@@ -1498,6 +1533,46 @@ mod tests {
14981533
assert_eq!(events[0]["params"]["item"]["status"], "completed");
14991534
}
15001535

1536+
#[test]
1537+
fn edit_tool_generates_unified_diff() {
1538+
let mut state = make_state();
1539+
let completed = json!({
1540+
"type": "message.part.updated",
1541+
"properties": {
1542+
"part": {
1543+
"type": "tool",
1544+
"id": "tc_edit_diff",
1545+
"tool": "edit",
1546+
"state": {
1547+
"status": "completed",
1548+
"input": {
1549+
"filePath": "src/main.rs",
1550+
"oldString": "fn main() {\n println!(\"Hello\");\n}",
1551+
"newString": "fn main() {\n println!(\"Hello, world!\");\n}"
1552+
},
1553+
"output": "File edited."
1554+
}
1555+
}
1556+
}
1557+
});
1558+
let events = translate_sse_event(&completed, &mut state);
1559+
assert_eq!(events.len(), 1);
1560+
assert_eq!(events[0]["method"], "item/completed");
1561+
let item = &events[0]["params"]["item"];
1562+
assert_eq!(item["type"], "fileChange");
1563+
1564+
let changes = item["changes"].as_array().expect("changes should be array");
1565+
assert_eq!(changes.len(), 1);
1566+
assert_eq!(changes[0]["path"], "src/main.rs");
1567+
1568+
let diff = changes[0]["diff"].as_str().expect("diff should be string");
1569+
assert!(diff.contains("--- a/src/main.rs"));
1570+
assert!(diff.contains("+++ b/src/main.rs"));
1571+
assert!(diff.contains("@@"));
1572+
assert!(diff.contains("- println!(\"Hello\");"));
1573+
assert!(diff.contains("+ println!(\"Hello, world!\");"));
1574+
}
1575+
15011576
#[test]
15021577
fn text_after_tool_completion_uses_new_agent_message_item() {
15031578
let mut state = make_state();

src-tauri/src/shared/codex_core.rs

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,39 @@ fn should_include_hidden_sessions(sort_key: &Option<String>) -> bool {
6969
.unwrap_or(false)
7070
}
7171

72+
fn replay_message_order_key(message: &Value) -> Option<String> {
73+
let timestamp = message
74+
.get("createdAt")
75+
.or_else(|| message.get("created_at"))
76+
.or_else(|| message.get("updatedAt"))
77+
.or_else(|| message.get("updated_at"))
78+
.or_else(|| message.get("time").and_then(|time| time.get("created")))
79+
.or_else(|| message.get("time").and_then(|time| time.get("createdAt")))
80+
.or_else(|| message.get("time").and_then(|time| time.get("created_at")))
81+
.or_else(|| message.get("time").and_then(|time| time.get("updated")))
82+
.or_else(|| message.get("time").and_then(|time| time.get("updatedAt")))
83+
.or_else(|| message.get("time").and_then(|time| time.get("updated_at")))?;
84+
85+
if let Some(value) = timestamp.as_u64() {
86+
return Some(format!("{value:020}"));
87+
}
88+
if let Some(value) = timestamp.as_i64() {
89+
return Some(format!("{value:020}"));
90+
}
91+
timestamp
92+
.as_str()
93+
.map(str::trim)
94+
.filter(|value| !value.is_empty())
95+
.map(ToOwned::to_owned)
96+
}
97+
98+
fn sort_replay_messages_chronologically(messages: &mut [Value]) {
99+
messages.sort_by(|a, b| match (replay_message_order_key(a), replay_message_order_key(b)) {
100+
(Some(a_key), Some(b_key)) => a_key.cmp(&b_key),
101+
_ => std::cmp::Ordering::Equal,
102+
});
103+
}
104+
72105
async fn hidden_session_ids_for_workspace(
73106
workspaces: &Mutex<HashMap<String, WorkspaceEntry>>,
74107
workspace_id: &str,
@@ -136,7 +169,9 @@ pub(crate) async fn resume_thread_core<E: EventSink>(
136169
let mut latest_assistant_info: Option<Value> = None;
137170

138171
if let Some(msg_list) = messages.as_array() {
139-
for msg_entry in msg_list {
172+
let mut ordered_messages = msg_list.clone();
173+
sort_replay_messages_chronologically(&mut ordered_messages);
174+
for msg_entry in &ordered_messages {
140175
let role = msg_entry
141176
.get("info")
142177
.and_then(|i| i.get("role"))
@@ -1336,6 +1371,32 @@ mod tests {
13361371
assert!(!should_include_hidden_sessions(&None));
13371372
}
13381373

1374+
#[test]
1375+
fn sort_replay_messages_orders_oldest_first_by_timestamp() {
1376+
let mut messages = vec![
1377+
json!({
1378+
"id": "msg-newer",
1379+
"time": { "created": 200 }
1380+
}),
1381+
json!({
1382+
"id": "msg-older",
1383+
"time": { "created": 100 }
1384+
}),
1385+
json!({
1386+
"id": "msg-middle",
1387+
"createdAt": 150
1388+
}),
1389+
];
1390+
1391+
sort_replay_messages_chronologically(&mut messages);
1392+
1393+
let ids: Vec<String> = messages
1394+
.iter()
1395+
.filter_map(|msg| msg.get("id").and_then(|v| v.as_str()).map(ToOwned::to_owned))
1396+
.collect();
1397+
assert_eq!(ids, vec!["msg-older", "msg-middle", "msg-newer"]);
1398+
}
1399+
13391400
#[test]
13401401
fn private_and_loopback_ips_are_disallowed() {
13411402
assert!(ip_is_disallowed(

src/features/messages/components/Messages.tsx

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -208,27 +208,42 @@ export const Messages = memo(function Messages({
208208
);
209209

210210
useEffect(() => {
211+
const itemsToExpand: string[] = [];
211212
for (let index = visibleItems.length - 1; index >= 0; index -= 1) {
212213
const item = visibleItems[index];
214+
if (manuallyToggledExpandedRef.current.has(item.id)) {
215+
continue;
216+
}
213217
if (
214218
item.kind === "tool" &&
215219
item.toolType === "plan" &&
216220
(item.output ?? "").trim().length > 0
217221
) {
218-
if (manuallyToggledExpandedRef.current.has(item.id)) {
219-
return;
220-
}
221-
setExpandedItems((prev) => {
222-
if (prev.has(item.id)) {
223-
return prev;
224-
}
225-
const next = new Set(prev);
226-
next.add(item.id);
227-
return next;
228-
});
229-
return;
222+
itemsToExpand.push(item.id);
223+
break;
224+
}
225+
if (
226+
item.kind === "tool" &&
227+
item.toolType === "fileChange" &&
228+
item.changes?.some((change) => change.diff)
229+
) {
230+
itemsToExpand.push(item.id);
230231
}
231232
}
233+
if (itemsToExpand.length === 0) {
234+
return;
235+
}
236+
setExpandedItems((prev) => {
237+
const next = new Set(prev);
238+
let changed = false;
239+
for (const id of itemsToExpand) {
240+
if (!next.has(id)) {
241+
next.add(id);
242+
changed = true;
243+
}
244+
}
245+
return changed ? next : prev;
246+
});
232247
}, [visibleItems]);
233248

234249
useEffect(() => {

0 commit comments

Comments
 (0)