Skip to content
Draft
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

## May 2026

### 06/01 · Fix — Follow-up messages restore for OpenCode and avoid duplicates
Follow-up prompts you send in OpenCode sessions now reappear correctly after you restart Orbit. Claude sessions no longer show the same follow-up message twice when the CLI echoes it back in the stream.

### 05/25 · Fix — No flashing console windows on Windows
Sending messages, watching git status, and spawning agent CLIs no longer briefly opens a black terminal window in the background.

Expand Down
1 change: 1 addition & 0 deletions tauri/src/journal/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ pub use parser::parse_journal;
pub use processor::process_line;
pub use processor::process_line_codex;
pub use processor::process_line_opencode;
pub use processor::{injected_user_text, is_duplicate_injected_user_line, push_injected_user_line};
pub use state::JournalState;
99 changes: 97 additions & 2 deletions tauri/src/journal/processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,50 @@ fn count_changed_lines(old_text: &str, new_text: &str) -> LinesChanged {

/// Process a single raw JSONL line from PTY stdout and update state.
/// This is the real-time counterpart to parse_journal (which reads files).
/// Plain-text user JSONL that Orbit injects before spawn (`create_session` / `send_message`).
pub fn injected_user_text(line: &str) -> Option<String> {
let val: serde_json::Value = serde_json::from_str(line.trim()).ok()?;
if val.get("type").and_then(|v| v.as_str()) != Some("user") {
return None;
}
let content = val.get("message")?.get("content")?;
let text = content.as_str()?;
if text.trim().is_empty() {
None
} else {
Some(text.to_string())
}
}

/// True when `line` is a plain-text user echo Orbit already showed (not tool_result blocks).
pub fn is_duplicate_injected_user_line(state: &JournalState, line: &str) -> bool {
let Some(text) = injected_user_text(line) else {
return false;
};
state.entries.iter().rev().any(|e| {
e.entry_type == JournalEntryType::User
&& e.text
.as_deref()
.is_some_and(|existing| existing.trim() == text.trim())
})
}

/// Restore a persisted Orbit user prompt into the journal (used by OpenCode on DB replay).
pub fn push_injected_user_line(state: &mut JournalState, line: &str) -> bool {
let Some(text) = injected_user_text(line) else {
return false;
};
if is_duplicate_injected_user_line(state, line) {
return false;
}
state.entries.push(JournalEntry {
entry_type: JournalEntryType::User,
text: Some(text),
..JournalEntry::default()
});
true
}

pub fn process_line(state: &mut JournalState, line: &str) {
let trimmed = line.trim();
if trimmed.is_empty() {
Expand Down Expand Up @@ -684,8 +728,10 @@ pub fn process_line_opencode(state: &mut JournalState, line: &str) {
}
}

// Initial user prompt is injected by create_session; OpenCode may echo user JSONL.
"user" => {}
// OpenCode JSONL has no user events; Orbit persists prompts in Claude-compatible JSONL.
"user" => {
push_injected_user_line(state, line);
}

_ => {
process_line(state, line);
Expand Down Expand Up @@ -956,6 +1002,25 @@ pub fn process_line_codex(state: &mut JournalState, line: &str) {
}
}

#[cfg(test)]
mod injected_user_line_tests {
use super::*;
use crate::test_utils::{user_text, TestCase};

#[test]
fn should_detect_duplicate_injected_user_line() {
let mut t = TestCase::new("should_detect_duplicate_injected_user_line");
t.phase("Act");
let mut state = JournalState::default();
process_line(&mut state, &user_text("hello"));
let dup = is_duplicate_injected_user_line(&state, &user_text("hello"));
let tool = is_duplicate_injected_user_line(&state, &crate::test_utils::tool_result("out"));
t.phase("Assert");
t.eq("duplicate plain user", dup, true);
t.eq("tool_result is not a duplicate", tool, false);
}
}

#[cfg(test)]
mod process_line_tests {
use super::*;
Expand Down Expand Up @@ -1369,6 +1434,36 @@ mod process_line_opencode_tests {
t.eq("cache read", state.cache_read, 4u64);
}

#[test]
fn should_restore_orbit_persisted_user_prompt_on_opencode_replay() {
let mut t = TestCase::new("should_restore_orbit_persisted_user_prompt_on_opencode_replay");
t.phase("Act");
let mut state = JournalState::default();
process_line_opencode(
&mut state,
r#"{"type":"user","message":{"content":"Fix the tests"},"timestamp":"2026-01-01T00:00:00Z"}"#,
);
t.phase("Assert");
t.len("one user entry", &state.entries, 1);
t.eq(
"text restored",
state.entries[0].text.as_deref(),
Some("Fix the tests"),
);
}

#[test]
fn should_not_duplicate_opencode_user_on_replay() {
let mut t = TestCase::new("should_not_duplicate_opencode_user_on_replay");
t.phase("Act");
let mut state = JournalState::default();
let line = r#"{"type":"user","message":{"content":"same prompt"}}"#;
process_line_opencode(&mut state, line);
process_line_opencode(&mut state, line);
t.phase("Assert");
t.len("still one user entry", &state.entries, 1);
}

#[test]
fn should_not_create_empty_entry_for_opencode_space_text_sequence() {
let mut t = TestCase::new("should_not_create_empty_entry_for_opencode_space_text_sequence");
Expand Down
4 changes: 4 additions & 0 deletions tauri/src/services/process_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ pub fn apply_silent(cmd: &mut Command) {
use std::os::windows::process::CommandExt;
cmd.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
{
let _ = cmd;
}
}

fn is_windows_script_shim(path: &str) -> bool {
Expand Down
45 changes: 45 additions & 0 deletions tauri/src/services/session_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,16 @@ impl SessionManager {
);
}

let skip_duplicate_user = {
let m = manager.read().unwrap_or_else(|e| e.into_inner());
m.journal_states.get(&session_id).is_some_and(|s| {
crate::journal::is_duplicate_injected_user_line(s, &trimmed)
})
};
if skip_duplicate_user {
continue;
}

let _ = db.insert_output(session_id, &trimmed);
let _ = app.emit(
"session:raw-output",
Expand Down Expand Up @@ -1731,6 +1741,41 @@ mod tests {
);
}

#[test]
fn should_restore_follow_up_user_message_for_opencode_from_db() {
let mut t = TestCase::new("should_restore_follow_up_user_message_for_opencode_from_db");
t.phase("Seed");
let db = make_db();
let sid = db
.create_session(
None,
None,
"/tmp",
"ignore",
None,
None,
Some("opencode"),
Some("openrouter/minimax"),
)
.expect("session");
seed_outputs(
&db,
sid,
&[&crate::test_utils::user_text("Also fix OpenCode history")],
);
t.phase("Act");
let mut sm = SessionManager::new(Arc::clone(&db));
sm.restore_from_db();
t.phase("Assert");
let journal = sm.get_journal(sid);
t.len("one user entry", &journal, 1);
t.eq(
"follow-up text restored",
journal[0].text.as_deref(),
Some("Also fix OpenCode history"),
);
}

#[test]
fn should_restore_follow_up_user_message_from_db() {
let mut t = TestCase::new("should_restore_follow_up_user_message_from_db");
Expand Down
Loading