diff --git a/apps/desktop/src/pages/CallsPage.tsx b/apps/desktop/src/pages/CallsPage.tsx
index 07d8cfb..7953797 100644
--- a/apps/desktop/src/pages/CallsPage.tsx
+++ b/apps/desktop/src/pages/CallsPage.tsx
@@ -1,10 +1,34 @@
+import { useEffect } from "react";
import { CallTable } from "../components/CallTable";
import { PageContent } from "../components/layout/PageContent";
import { appStore, useAppStore } from "../store/appStore";
import styles from "./CallsPage.module.scss";
+const CALL_REFRESH_INTERVAL_MS = 2_000;
+
export function CallsPage() {
const { calls } = useAppStore();
+
+ useEffect(() => {
+ let disposed = false;
+ const refreshCalls = () => {
+ if (!disposed && document.visibilityState === "visible") {
+ void appStore.refreshCalls();
+ }
+ };
+
+ refreshCalls();
+ const interval = window.setInterval(refreshCalls, CALL_REFRESH_INTERVAL_MS);
+ window.addEventListener("focus", refreshCalls);
+ document.addEventListener("visibilitychange", refreshCalls);
+ return () => {
+ disposed = true;
+ window.clearInterval(interval);
+ window.removeEventListener("focus", refreshCalls);
+ document.removeEventListener("visibilitychange", refreshCalls);
+ };
+ }, []);
+
const content =
void appStore.openCallDetails(call.call_id)} />
;
return ;
}
diff --git a/apps/desktop/src/store/appStore.ts b/apps/desktop/src/store/appStore.ts
index e8dde89..9a6f192 100644
--- a/apps/desktop/src/store/appStore.ts
+++ b/apps/desktop/src/store/appStore.ts
@@ -177,6 +177,14 @@ export const appStore = {
}
},
+ async refreshCalls() {
+ try {
+ update({ calls: await api.calls() });
+ } catch (cause) {
+ update({ error: cause instanceof Error ? cause.message : String(cause) });
+ }
+ },
+
async openCallDetails(callId: string) {
await perform(() => api.openCallDetails(callId));
},
diff --git a/server/src/cursor/request/background.rs b/server/src/cursor/request/background.rs
index 12a0b65..0fa5710 100644
--- a/server/src/cursor/request/background.rs
+++ b/server/src/cursor/request/background.rs
@@ -56,10 +56,9 @@ pub(super) fn project(
))
})?;
if reason != pb::BackgroundTaskCompletionReason::TaskFinished {
- return Err(Error::Protocol(format!(
- "background task notification is not a finished task: {}",
- reason.as_str_name()
- )));
+ // Progress and reparenting notifications are informational; the
+ // client batches them together with the real finish notification.
+ continue;
}
if completion.task_id.is_empty() || completion.title.is_empty() {
return Err(Error::Protocol(
@@ -110,7 +109,7 @@ pub(super) fn project(
let (first, _) = completions
.values()
.next()
- .expect("background completion action was validated as non-empty");
+ .ok_or_else(|| Error::Protocol("background task notification contains no finished task".into()))?;
let text = match (has_shell, has_subagent) {
(true, false) => SHELL_FOLLOW_UP.into(),
(false, true) => FOLLOW_UP.into(),
@@ -362,7 +361,25 @@ mod tests {
)
.unwrap_err()
.to_string()
- .contains("not a finished task"));
+ .contains("no finished task"));
+ }
+
+ #[test]
+ fn progress_notifications_batched_with_a_finish_are_ignored() {
+ let mut progress = completion();
+ progress.task_id = "child-id:task_progress:1".into();
+ progress.reason = pb::BackgroundTaskCompletionReason::TaskProgress as i32;
+ let projection = project(
+ &pb::BackgroundTaskCompletionAction {
+ completions: vec![progress, completion()],
+ },
+ pb::AgentMode::Agent as i32,
+ )
+ .unwrap();
+
+ assert!(projection.context.contains("agent_id: child-id"));
+ assert!(!projection.context.contains("task_progress"));
+ assert_eq!(projection.turn_user.text, FOLLOW_UP);
}
fn completion() -> pb::BackgroundTaskCompletion {