Skip to content
Open
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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/okena-app-core/src/remote_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,5 +219,6 @@ pub fn build_state_response(
claude_dialogs: Default::default(),
claude_sessions: Vec::new(),
claude_pr_links: Default::default(),
terminal_attention: Default::default(),
}
}
1 change: 1 addition & 0 deletions crates/okena-cli/src/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,7 @@ mod tests {
claude_dialogs: Default::default(),
claude_sessions: Vec::new(),
claude_pr_links: Default::default(),
terminal_attention: Default::default(),
}
}

Expand Down
17 changes: 17 additions & 0 deletions crates/okena-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,22 @@ pub struct StateResponse {
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
pub claude_pr_links:
std::collections::HashMap<String, Vec<crate::claude_remote::ApiClaudePrLink>>,
/// When each terminal last rang the bell or raised an `OSC 9` / `OSC 777`
/// notification, for terminals that ever did. Lets a client that does not
/// stream a terminal's output still flag it. Runtime-only; a client tells
/// new events by a timestamp that moved, never by comparing clocks.
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
pub terminal_attention: std::collections::HashMap<String, ApiTerminalAttention>,
}

/// Unix-millis timestamps of a terminal's last bell and last desktop
/// notification (see [`StateResponse::terminal_attention`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApiTerminalAttention {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bell_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notification_at: Option<u64>,
}

/// OS window bounds in screen pixels.
Expand Down Expand Up @@ -1818,6 +1834,7 @@ mod tests {
claude_dialogs: Default::default(),
claude_sessions: Vec::new(),
claude_pr_links: Default::default(),
terminal_attention: Default::default(),
};
let json = serde_json::to_string(&resp).unwrap();
let parsed: StateResponse = serde_json::from_str(&json).unwrap();
Expand Down
6 changes: 6 additions & 0 deletions crates/okena-core/src/ws.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ pub enum WsInbound {
SetWindows {
windows: Vec<ApiWindow>,
},
/// Whether this connection wants the periodic `system_stats_changed`
/// push (on by default). A client that never shows host metrics turns it
/// off; on a phone the 2 s cadence adds up to megabytes a day.
SetSystemStats {
enabled: bool,
},
SendText {
terminal_id: String,
text: String,
Expand Down
16 changes: 16 additions & 0 deletions crates/okena-daemon-core/src/command_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4457,6 +4457,22 @@ pub async fn daemon_command_loop(
resp.claude_dialogs = claude_dialogs;
resp.claude_sessions = claude_sessions;
resp.claude_pr_links = claude_pr_links;
resp.terminal_attention = {
let registry = terminals.lock();
registry
.iter()
.filter_map(|(id, term)| match term.attention() {
(None, None) => None,
(bell_at, notification_at) => Some((
id.clone(),
okena_core::api::ApiTerminalAttention {
bell_at,
notification_at,
},
)),
})
.collect()
};

// `match` (not `.expect`) so the daemon-core crate stays clean
// under `clippy::expect_used` had it been enabled — the serialize
Expand Down
1 change: 0 additions & 1 deletion crates/okena-daemon-core/src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,6 @@ impl DaemonCore {
tokio::task::spawn_local(crate::git_poll::run_git_poll(
reactor.workspace.clone(),
git_status_tx.clone(),
reactor.state_version.clone(),
remote_subscribed_terminals.clone(),
remote_visible_projects.clone(),
git_poll_trigger_rx,
Expand Down
29 changes: 8 additions & 21 deletions crates/okena-daemon-core/src/git_poll.rs
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,6 @@ fn apply_github_result(
ci_checks: &mut HashMap<String, Option<git::CiCheckSummary>>,
last: &mut HashMap<String, GitStatus>,
git_status_tx: &watch::Sender<HashMap<String, ApiGitStatus>>,
state_version: &watch::Sender<u64>,
) {
let GithubPollResult {
head_generations,
Expand Down Expand Up @@ -562,7 +561,7 @@ fn apply_github_result(
status.pr_info = pr_infos.get(id).cloned().flatten();
status.ci_checks = ci_checks.get(id).cloned().flatten();
}
publish(last, &enriched, git_status_tx, state_version);
publish(last, &enriched, git_status_tx);
}

/// Change signals for the walk schedule besides HEAD triggers.
Expand All @@ -583,12 +582,13 @@ pub struct GitChangeSignals {
/// cycles stable even when targeted triggers wake the loop between cadence ticks.
/// PR/CI lookups retain their existing visible-project adaptive cadence.
///
/// Bumps `state_version` on a real change so a snapshot/broadcast observer can
/// react; the *primary* output is the `git_status_tx` watch.
/// Publishes into the `git_status_tx` watch only. Remote clients get git status
/// as its own `git_status_changed` message and patch their copy of the state, so
/// a git change does not bump `state_version` (which would make every client
/// refetch the whole state).
pub async fn run_git_poll(
workspace: Arc<Mutex<Workspace>>,
git_status_tx: Arc<watch::Sender<HashMap<String, ApiGitStatus>>>,
state_version: watch::Sender<u64>,
remote_subscribed_terminals: Arc<RwLock<HashMap<u64, HashSet<String>>>>,
remote_visible_projects: Arc<RwLock<HashMap<u64, HashSet<String>>>>,
mut trigger_rx: mpsc::UnboundedReceiver<GitPollTrigger>,
Expand Down Expand Up @@ -783,7 +783,7 @@ pub async fn run_git_poll(
// never block the branch/diff badge from appearing.
// No receiver-count exit: receivers exist only while clients are connected,
// so "none left" also means "none yet". The loop ends with the daemon's LocalSet.
publish(&mut last, &new_statuses, &git_status_tx, &state_version);
publish(&mut last, &new_statuses, &git_status_tx);

// ── 4. Start GitHub PR/CI fan-out without blocking local git refreshes ─
// Only visible projects (plus anything explicitly asked for) and only
Expand Down Expand Up @@ -886,7 +886,6 @@ pub async fn run_git_poll(
&mut ci_checks,
&mut last,
&git_status_tx,
&state_version,
),
GithubPassMessage::Finished(ids) => {
for id in &ids {
Expand Down Expand Up @@ -974,15 +973,14 @@ fn clear_github_cache_for_ids(
}
}

/// Broadcast the slimmed `ApiGitStatus` map into `git_status_tx` and bump
/// `state_version`, but only on a real change. `last` holds the previously
/// Broadcast the slimmed `ApiGitStatus` map into `git_status_tx`, but only on a
/// real change. `last` holds the previously
/// published richer `GitStatus` map (the GUI's `commit_statuses` change check);
/// no-ops when `new_statuses` equals it, so re-committing the same data is free.
fn publish(
last: &mut HashMap<String, GitStatus>,
new_statuses: &HashMap<String, GitStatus>,
git_status_tx: &watch::Sender<HashMap<String, ApiGitStatus>>,
state_version: &watch::Sender<u64>,
) {
if new_statuses == last {
return;
Expand All @@ -991,7 +989,6 @@ fn publish(
let api_statuses: HashMap<String, ApiGitStatus> =
last.iter().map(|(id, s)| (id.clone(), to_api(s))).collect();
git_status_tx.send_replace(api_statuses);
state_version.send_modify(|v| *v += 1);
}

#[cfg(test)]
Expand All @@ -1006,7 +1003,6 @@ mod tests {
let workspace = Arc::new(Mutex::new(Workspace::new(empty_workspace_data())));
let (tx, rx) = watch::channel(HashMap::<String, ApiGitStatus>::new());
drop(rx);
let (state_version, _svrx) = watch::channel(0u64);
let subscribed = Arc::new(RwLock::new(HashMap::new()));
let client_visible = Arc::new(RwLock::new(HashMap::new()));
let (_trigger_tx, trigger_rx) = mpsc::unbounded_channel();
Expand All @@ -1016,7 +1012,6 @@ mod tests {
_ = run_git_poll(
workspace,
Arc::new(tx),
state_version,
subscribed,
client_visible,
trigger_rx,
Expand Down Expand Up @@ -1471,13 +1466,11 @@ mod tests {
let terminals: TerminalsRegistry = Arc::new(Mutex::new(HashMap::new()));
terminals.lock().insert("t".into(), terminal.clone());
let (status_tx, status_rx) = watch::channel(HashMap::new());
let (state_version, _) = watch::channel(0u64);
let (trigger_tx, trigger_rx) = mpsc::unbounded_channel();
let (index_change_tx, index_change_rx) = mpsc::unbounded_channel();
tokio::task::spawn_local(run_git_poll(
workspace,
Arc::new(status_tx),
state_version,
Arc::new(RwLock::new(HashMap::new())),
Arc::new(RwLock::new(HashMap::new())),
trigger_rx,
Expand Down Expand Up @@ -1629,15 +1622,12 @@ mod tests {
ci_checks: HashMap<String, Option<git::CiCheckSummary>>,
last: HashMap<String, GitStatus>,
git_status_tx: watch::Sender<HashMap<String, ApiGitStatus>>,
state_version: watch::Sender<u64>,
_rx: watch::Receiver<HashMap<String, ApiGitStatus>>,
_state_rx: watch::Receiver<u64>,
}

impl ApplyHarness {
fn new() -> Self {
let (git_status_tx, _rx) = watch::channel(HashMap::new());
let (state_version, _state_rx) = watch::channel(0);
Self {
schedule: GithubPollSchedule::default(),
pr_infos: HashMap::new(),
Expand All @@ -1650,9 +1640,7 @@ mod tests {
},
)]),
git_status_tx,
state_version,
_rx,
_state_rx,
}
}

Expand All @@ -1671,7 +1659,6 @@ mod tests {
&mut self.ci_checks,
&mut self.last,
&self.git_status_tx,
&self.state_version,
);
}
}
Expand Down
15 changes: 11 additions & 4 deletions crates/okena-daemon-core/src/pty_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -487,16 +487,23 @@ fn process_activity_edges(
// Drain ALL THREE edges with `|=` (never `||`): each is a one-shot that must
// be consumed to clear it, so short-circuiting would leak the notification
// queue (and miss a bell that follows a command-finish in the same batch).
//
// Bells and notifications are also stamped on the terminal, so clients
// that do not stream it see them through the state.
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as u64);
let active: Vec<String> = {
let reg = terminals.lock();
dirty_terminal_ids
.iter()
.filter(|tid| {
reg.get(*tid).is_some_and(|t| {
let mut a = t.take_pending_command_finished();
a |= t.take_pending_bell();
a |= !t.take_pending_notifications().is_empty();
a
let finished = t.take_pending_command_finished();
let bell = t.take_pending_bell();
let notification = !t.take_pending_notifications().is_empty();
t.record_attention(bell, notification, now_ms);
finished || bell || notification
})
})
.cloned()
Expand Down
6 changes: 5 additions & 1 deletion crates/okena-mobile-ffi/src/client/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,17 @@ impl MobileConnectionHandler {
&self.terminals
}

/// Seconds since last WS activity (terminal output).
/// Seconds since anything last arrived on the WebSocket.
pub fn seconds_since_activity(&self) -> f64 {
self.last_activity.lock().elapsed().as_secs_f64()
}
}

impl ConnectionHandler for MobileConnectionHandler {
fn on_inbound(&self) {
*self.last_activity.lock() = Instant::now();
}

fn create_terminal(
&self,
_connection_id: &str,
Expand Down
Loading
Loading