From 1d1f34692313057a089ff4a37270b9822786820f Mon Sep 17 00:00:00 2001
From: lucascouts <lucascs@protonmail.com>
Date: Sun, 27 Sep 2026 00:52:14 -0300
Subject: [PATCH] sidebar: show a thread whose turn ended with background work
 still running

The sidebar's thread icon only knew two live states: a turn in flight (the
grey spinner) and everything else. Claude Code runs work that outlives the
turn that launched it -- a `run_in_background` shell, an async subagent, a
monitor -- and once the prompt returns ACP has nothing to say about it:
AcpThread::status() is `running_turn.is_some()`, so the row went back to the
agent's plain icon while the work carried on, and only a later follow-up turn
brought the spinner back.

The adapter already tracked this. claude-agent-acp-plus consumes the SDK's
`background_tasks_changed` level to decide when a turn may settle; it now
forwards the same level, filtered of the tasks the SDK marks `ambient` ("not
activity"), as `session_info_update._meta["_claude/backgroundTasks"]` =
`{ count, tasks: [{ id, type, description }] }`, and only when the set
changes. Measured over ACP stdio against the real CLI on 2026-09-27: the
prompt answered at 4.9 s, `count: 1` had arrived at 3.9 s, and `count: 0`
arrived at 23.9 s -- outside any turn, which is the case this patch exists for.

What this adds:

- AcpThread reads the count off `session_info_update` and emits
  `BackgroundTasksUpdated` when it changes. An update without the key leaves
  the count alone: the adapter's title-only updates ride the same
  notification, and reading their silence as "none left" would clear it.
- `AgentThreadStatus::Background`, chosen when the turn is idle and the count
  is non-zero. It ranks below waiting-for-confirmation and error, exactly as
  Running does, so an alert is never hidden behind it.
- The icon is the same LoadCircle as a running turn, tinted Accent and turning
  at half the speed, so it still reads as "working" but the two stay apart.
  A tooltip names it: "Running in the Background".
- The "finished while you were away" dot now also fires on
  Background -> Completed, so the end of the last background task is news the
  way the end of a turn is.

What it deliberately does not do:

- It routes nothing through ThreadMetadataStore. The first draft marked the
  event as `affects_thread_metadata`, on the theory that the store was the only
  path to the sidebar; a mutation test showed the sidebar refreshed without
  it. Every thread event ends in `cx.notify()` on the ConversationView, and the
  agent panel observes every conversation view -- active or not -- and asks
  the sidebar to rebuild. The metadata route would have rewritten `updated_at`
  in the database twice per background task for nothing.
- Stop treats Background like Completed. "Stop Generation" cancels a turn,
  and there is no turn to cancel; offering it would promise to kill work it
  cannot reach.
- It does not let a Background thread be archived. Since 0.6.1_p20261021 it
  follows upstream, which refuses to archive a running or Unconfirmed
  thread: ArchiveSelectedThread returns on Background too, and the row shows
  no archive button, because archiving would hide a thread whose work still
  runs. The row's context menu still offers Archive Thread, as upstream's
  does for a running thread.
- The collapsed project header's spinner is unchanged: it still means a turn
  is running.

No other agent sends the key, so for them nothing changes. Rides the
claude-agent-acp-plus flag BY DEPENDENCY, like 0011 and 0013.

Test added (0.2.3_p20261009): a malformed count (-1, "2", 1.5, null) leaves the last one.
Tests added 2026-10-10 (story 032 v4): Background -> Completed marks a pinned row and header.
Test added (0.6.1_p20261021): archiving a Background thread is refused.

a4b39c38 review: (2026-11-01, story 072) Upstream #66255 now reports native
subagent state the way it reports ACP subagents, through update_session_state,
restore_subagent_outcomes and the linked session messages of #66101/#66102. This
patch reads its _meta in the SessionInfoUpdate arm of
AcpThread::handle_session_update, an arm upstream did not touch. Upstream
changed only the SessionMessage arm there. No new dispatch path bypasses
handle_session_update.
---

diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs
index 0a6f72d..92fb860 100644
--- a/crates/acp_thread/src/acp_thread.rs
+++ b/crates/acp_thread/src/acp_thread.rs
@@ -3940,6 +3940,15 @@ impl AccountUsage {
 /// forwards the SDK's `rate_limit_event` here instead of dropping it.
 const CLAUDE_RATE_LIMIT_META_KEY: &str = "_claude/rateLimit";
 
+/// The `_meta` key the ACP adapter writes the live background-task set under, on a
+/// `session_info_update`.
+///
+/// ACP has no notion of work that outlives a turn: once the prompt returns, the thread is
+/// idle as far as the protocol can say, while a `run_in_background` shell or an async
+/// subagent keeps running. `claude-agent-acp-plus` forwards the SDK's
+/// `background_tasks_changed` level here, as `{ "count": n, "tasks": [...] }`.
+const CLAUDE_BACKGROUND_TASKS_META_KEY: &str = "_claude/backgroundTasks";
+
 pub const TOKEN_USAGE_WARNING_THRESHOLD: f32 = 0.8;
 
 impl TokenUsage {
@@ -4046,6 +4055,7 @@ pub struct AcpThread {
     reported_usage: Option<ReportedTokenUsage>,
     token_usage: Option<TokenUsage>,
     account_usage: AccountUsage,
+    background_task_count: usize,
     cost: Option<SessionCost>,
     prompt_capabilities: acp_v2::PromptCapabilities,
     available_commands: Vec<acp_v2::AvailableCommand>,
@@ -4201,6 +4211,7 @@ pub enum AcpThreadEvent {
     NoticesUpdated,
     TokenUsageUpdated(OutputTokensUpdate),
     AccountUsageUpdated,
+    BackgroundTasksUpdated,
     EntryUpdated(usize),
     EntriesRemoved(Range<usize>),
     ToolAuthorizationRequested(PermissionRequestId),
@@ -4358,6 +4369,7 @@ impl AcpThread {
                 | AcpThreadEvent::NoticesUpdated
                 | AcpThreadEvent::TokenUsageUpdated(_)
                 | AcpThreadEvent::AccountUsageUpdated
+                | AcpThreadEvent::BackgroundTasksUpdated
                 | AcpThreadEvent::EntryUpdated(_)
                 | AcpThreadEvent::Retry(_)
                 | AcpThreadEvent::SubagentSpawned(_)
@@ -4413,6 +4425,7 @@ impl AcpThread {
             connection,
             token_usage: None,
             account_usage: AccountUsage::default(),
+            background_task_count: 0,
             cost: None,
             prompt_capabilities,
             available_commands: Vec::new(),
@@ -5247,6 +5260,12 @@ impl AcpThread {
         (!self.account_usage.is_empty()).then_some(&self.account_usage)
     }
 
+    /// How many tasks the agent reports running in the background — work that outlives
+    /// the turn that launched it, so [`Self::status`] reads `Idle` while it runs.
+    pub fn background_task_count(&self) -> usize {
+        self.background_task_count
+    }
+
     pub fn cost(&self) -> Option<&SessionCost> {
         self.cost.as_ref()
     }
@@ -5422,6 +5441,22 @@ impl AcpThread {
                 self.push_notice(notices::from_v1(notice).map_err(acp_v1::Error::from)?, cx);
             }
             acp_v1::SessionUpdate::SessionInfoUpdate(info_update) => {
+                // Absent for every agent but the Claude adapter, and absent from its own
+                // title-only updates too, so a missing key leaves the count alone rather
+                // than resetting it.
+                let background_task_count = info_update
+                    .meta
+                    .as_ref()
+                    .and_then(|meta| meta.get(CLAUDE_BACKGROUND_TASKS_META_KEY))
+                    .and_then(|payload| payload.get("count"))
+                    .and_then(|count| count.as_u64());
+                if let Some(count) = background_task_count
+                    && let Ok(count) = usize::try_from(count)
+                    && count != self.background_task_count
+                {
+                    self.background_task_count = count;
+                    cx.emit(AcpThreadEvent::BackgroundTasksUpdated);
+                }
                 self.update_session_info(info_update, cx);
             }
             acp_v1::SessionUpdate::AvailableCommandsUpdate(acp_v1::AvailableCommandsUpdate {
@@ -23293,6 +23328,136 @@ mod tests {
         );
     }
 
+    // 0029: the adapter's count is a non-negative integer. Anything else -- negative, a
+    // string, a fraction -- is not a count and leaves the last one in place.
+    #[gpui::test]
+    async fn test_session_info_update_ignores_a_malformed_background_count(
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, [], cx).await;
+        let connection = Rc::new(FakeAgentConnection::new());
+        let thread = cx
+            .update(|cx| {
+                connection.clone().new_session(
+                    project,
+                    PathList::new(&[Path::new(path!("/test"))]),
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+        let send_count = |count: serde_json::Value, cx: &mut TestAppContext| {
+            let mut meta = serde_json::Map::new();
+            meta.insert(
+                CLAUDE_BACKGROUND_TASKS_META_KEY.to_string(),
+                serde_json::json!({ "count": count, "tasks": [] }),
+            );
+            thread
+                .update(cx, |thread, cx| {
+                    thread.handle_session_update(
+                        acp_v1::SessionUpdate::SessionInfoUpdate(
+                            acp_v1::SessionInfoUpdate::new().meta(meta),
+                        ),
+                        cx,
+                    )
+                })
+                .expect("session info update should succeed");
+        };
+
+        send_count(serde_json::json!(2), cx);
+        for malformed in [
+            serde_json::json!(-1),
+            serde_json::json!("2"),
+            serde_json::json!(1.5),
+            serde_json::Value::Null,
+        ] {
+            send_count(malformed.clone(), cx);
+            thread.read_with(cx, |thread, _| {
+                assert_eq!(
+                    thread.background_task_count(),
+                    2,
+                    "count {malformed} must leave the last count"
+                )
+            });
+        }
+    }
+
+    #[gpui::test]
+    async fn test_session_info_update_tracks_background_task_count(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, [], cx).await;
+        let connection = Rc::new(FakeAgentConnection::new());
+        let thread = cx
+            .update(|cx| {
+                connection.clone().new_session(
+                    project,
+                    PathList::new(&[Path::new(path!("/test"))]),
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+
+        let events = Rc::new(RefCell::new(0usize));
+        let events_for_subscription = events.clone();
+        thread.update(cx, |_thread, cx| {
+            cx.subscribe(
+                &thread,
+                move |_thread, _event_thread, event: &AcpThreadEvent, _cx| {
+                    if matches!(event, AcpThreadEvent::BackgroundTasksUpdated) {
+                        *events_for_subscription.borrow_mut() += 1;
+                    }
+                },
+            )
+            .detach();
+        });
+
+        let send = |update: acp_v1::SessionInfoUpdate, cx: &mut TestAppContext| {
+            thread
+                .update(cx, |thread, cx| {
+                    thread
+                        .handle_session_update(acp_v1::SessionUpdate::SessionInfoUpdate(update), cx)
+                })
+                .expect("session info update should succeed");
+        };
+        let with_count = |count: u64| {
+            let mut meta = serde_json::Map::new();
+            meta.insert(
+                CLAUDE_BACKGROUND_TASKS_META_KEY.to_string(),
+                serde_json::json!({ "count": count, "tasks": [] }),
+            );
+            acp_v1::SessionInfoUpdate::new().meta(meta)
+        };
+
+        send(with_count(2), cx);
+        thread.read_with(cx, |thread, _| {
+            assert_eq!(thread.background_task_count(), 2)
+        });
+        assert_eq!(*events.borrow(), 1);
+
+        // A title-only update carries no count, and must not read as "none left".
+        send(acp_v1::SessionInfoUpdate::new().title("Renamed"), cx);
+        thread.read_with(cx, |thread, _| {
+            assert_eq!(thread.background_task_count(), 2)
+        });
+
+        // An unchanged count is not news.
+        send(with_count(2), cx);
+        assert_eq!(*events.borrow(), 1);
+
+        send(with_count(0), cx);
+        thread.read_with(cx, |thread, _| {
+            assert_eq!(thread.background_task_count(), 0);
+            assert_eq!(thread.status(), ThreadStatus::Idle);
+        });
+        assert_eq!(*events.borrow(), 2);
+    }
+
     #[gpui::test]
     async fn test_session_info_update_replaces_provisional_title_and_emits_event(
         cx: &mut TestAppContext,
diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs
index 69d201e..63b7ec7 100644
--- a/crates/agent_ui/src/agent_diff.rs
+++ b/crates/agent_ui/src/agent_diff.rs
@@ -1489,6 +1489,7 @@ impl AgentDiff {
             | AcpThreadEvent::SubmissionUpdated(_)
             | AcpThreadEvent::TokenUsageUpdated(_)
             | AcpThreadEvent::AccountUsageUpdated
+            | AcpThreadEvent::BackgroundTasksUpdated
             | AcpThreadEvent::NoticesUpdated
             | AcpThreadEvent::SubagentSpawned(_)
             | AcpThreadEvent::SubagentAssociationUpdated
diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs
index 94e8fc6..9f5b14b 100644
--- a/crates/agent_ui/src/conversation_view.rs
+++ b/crates/agent_ui/src/conversation_view.rs
@@ -350,6 +350,7 @@ impl Conversation {
                     | AcpThreadEvent::TitleUpdated
                     | AcpThreadEvent::TokenUsageUpdated(_)
                     | AcpThreadEvent::AccountUsageUpdated
+                    | AcpThreadEvent::BackgroundTasksUpdated
                     | AcpThreadEvent::EntryUpdated(_)
                     | AcpThreadEvent::EntriesRemoved(_)
                     | AcpThreadEvent::Retry(_)
@@ -790,6 +791,7 @@ fn affects_thread_metadata(event: &AcpThreadEvent) -> bool {
         | AcpThreadEvent::Retry(_)
         | AcpThreadEvent::TokenUsageUpdated(_)
         | AcpThreadEvent::AccountUsageUpdated
+        | AcpThreadEvent::BackgroundTasksUpdated
         | AcpThreadEvent::PromptCapabilitiesUpdated
         | AcpThreadEvent::AvailableCommandsUpdated(_)
         | AcpThreadEvent::ModeUpdated(_)
@@ -2293,6 +2295,10 @@ impl ConversationView {
                     });
                 }
             }
+            // Nothing to recompute here. The `cx.notify()` below is what reaches the
+            // sidebar: the agent panel observes every conversation view, active or not,
+            // and asks the sidebar to rebuild, which re-reads the thread's status.
+            AcpThreadEvent::BackgroundTasksUpdated => {}
             AcpThreadEvent::AccountUsageUpdated => {
                 // A redraw is all this needs: the account section is built from the thread
                 // when the tooltip opens, so there is no per-turn state to recompute.
diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs
index 3fd10d7..9c4bf4f 100644
--- a/crates/sidebar/src/sidebar.rs
+++ b/crates/sidebar/src/sidebar.rs
@@ -462,7 +462,14 @@ fn apply_live_row_state(
         && session_id
             .as_ref()
             .and_then(|sid| context.old_statuses.get(sid))
-            .is_some_and(|(s, _)| *s == AgentThreadStatus::Running)
+            .is_some_and(|(s, _)| {
+                // Work finishing out of sight is news whether it ended a
+                // turn or the last background task after one.
+                matches!(
+                    s,
+                    AgentThreadStatus::Running | AgentThreadStatus::Background
+                )
+            })
     {
         notified_threads.insert(thread.metadata.thread_id);
     }
@@ -2043,7 +2050,10 @@ impl Sidebar {
                         let old_status = old_statuses.get(&info.session_id).map(|(s, _)| *s);
                         new_live_statuses.insert(info.session_id.clone(), (info.status, thread_id));
                         if info.status == AgentThreadStatus::Completed
-                            && old_status == Some(AgentThreadStatus::Running)
+                            && matches!(
+                                old_status,
+                                Some(AgentThreadStatus::Running | AgentThreadStatus::Background)
+                            )
                         {
                             notified_threads.insert(thread_id);
                         }
@@ -6163,9 +6173,12 @@ impl Sidebar {
         match self.contents.entries.get(ix) {
             Some(ListEntry::Thread(thread)) => {
                 match thread.status {
+                    // Background work is work in progress: archiving would hide a thread whose
+                    // tasks still run, so it is refused like a running thread.
                     AgentThreadStatus::Running
                     | AgentThreadStatus::WaitingForConfirmation
-                    | AgentThreadStatus::Unconfirmed => {
+                    | AgentThreadStatus::Unconfirmed
+                    | AgentThreadStatus::Background => {
                         return;
                     }
                     AgentThreadStatus::Completed | AgentThreadStatus::Error => {}
@@ -6892,6 +6905,9 @@ impl Sidebar {
                                 })
                                 .into_any_element(),
                         ),
+                        // No archive button while background tasks run, as a running thread
+                        // shows none: the archive action refuses it.
+                        None if thread.status == AgentThreadStatus::Background => None,
                         None => Some(
                             IconButton::new("archive-thread", IconName::Archive)
                                 .hover_background(button_hover_bg)
@@ -8714,6 +8730,9 @@ fn all_thread_infos_for_workspace(
                 match thread.status() {
                     ThreadStatus::Unconfirmed => AgentThreadStatus::Unconfirmed,
                     ThreadStatus::Generating => AgentThreadStatus::Running,
+                    ThreadStatus::Idle if thread.background_task_count() > 0 => {
+                        AgentThreadStatus::Background
+                    }
                     ThreadStatus::Idle => AgentThreadStatus::Completed,
                 }
             };
diff --git a/crates/sidebar/src/sidebar_pinned_background_tests.rs b/crates/sidebar/src/sidebar_pinned_background_tests.rs
new file mode 100644
index 0000000..593574e
--- /dev/null
+++ b/crates/sidebar/src/sidebar_pinned_background_tests.rs
@@ -0,0 +1,206 @@
+// Story 032 v4, task 10.2 — Background→Completed on a pinned row (R2.12). Patch `0029`.
+//
+// Compiles only with `0029` applied (`AgentThreadStatus::Background`, the `_claude/backgroundTasks`
+// meta key): `0029` adds this file and its `mod` line, never `0049`. Materialize as a child module
+// of `sidebar_pinned_tests.rs`, at the end of that file:
+//     #[path = "sidebar_pinned_background_tests.rs"]
+//     mod background;
+// Filter: `cargo test -p sidebar --lib sidebar_pinned_tests::background` (also matched by `pinned`).
+//
+// The `Background` arm lives in this file's own reader (`entries` below), not in `0049`'s
+// `pinned_entries`, so `0049` keeps compiling without `0029` (546bba9's reason, design.md v4
+// GOTCHA). The rows print as `pinned_entries` does, plus " (background)" for that status.
+//
+// Hostile halves first: a turn that ends while background work keeps running (Running →
+// Background) is NOT news — no marker; then the end of that work (Background → Completed) on a
+// pinned thread that is not the active one IS news on its row and on the Pinned header, with the
+// section expanded and collapsed; and the converse — the active pinned thread gets no marker.
+
+use super::*;
+use pretty_assertions::assert_eq;
+
+/// The adapter's background-task level, as `0029`'s own sidebar test sends it.
+fn background_tasks(count: u64) -> acp_v1::SessionUpdate {
+    let mut meta = serde_json::Map::new();
+    meta.insert(
+        "_claude/backgroundTasks".to_string(),
+        serde_json::json!({ "count": count, "tasks": [] }),
+    );
+    acp_v1::SessionUpdate::SessionInfoUpdate(acp_v1::SessionInfoUpdate::new().meta(meta))
+}
+
+/// `pinned_entries` with the `Background` status printed as " (background)".
+fn entries(sidebar: &Entity<Sidebar>, cx: &mut VisualTestContext) -> Vec<String> {
+    let base = pinned_entries(sidebar, cx);
+    sidebar.read_with(cx, |sidebar, _cx| {
+        sidebar
+            .contents
+            .entries
+            .iter()
+            .enumerate()
+            .zip(base)
+            .map(|((ix, entry), line)| match entry {
+                ListEntry::Thread(thread)
+                    if thread.pinned_project_label.is_some()
+                        && thread.status == AgentThreadStatus::Background =>
+                {
+                    let label = thread.pinned_project_label.as_ref().unwrap();
+                    let title = thread.metadata.display_title();
+                    let live = if thread.is_live { " *" } else { "" };
+                    let notified = if sidebar
+                        .contents
+                        .is_thread_notified(&thread.metadata.thread_id)
+                    {
+                        " (!)"
+                    } else {
+                        ""
+                    };
+                    let selected = if sidebar.selection == Some(ix) {
+                        "  <== selected"
+                    } else {
+                        ""
+                    };
+                    format!("  {title} · {label}{live} (background){notified}{selected}")
+                }
+                _ => line,
+            })
+            .collect()
+    })
+}
+
+/// A window on `/project-a` with an agent panel and a pinned thread whose turn launched
+/// background work and then ended. With `away`, a second workspace is made active first, so the
+/// pinned thread is not the active one.
+async fn pinned_thread_with_background_work(
+    cx: &mut TestAppContext,
+    away: bool,
+) -> (
+    Entity<Sidebar>,
+    StubAgentConnection,
+    acp::SessionId,
+    &mut VisualTestContext,
+) {
+    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
+    cx.update(|cx| agent_ui::thread_organization::init(cx));
+    cx.run_until_parked();
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
+    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
+    let connection = StubAgentConnection::new();
+    open_thread_with_connection(&panel_a, connection.clone(), cx);
+    send_message(&panel_a, cx);
+    let session = active_session_id(&panel_a, cx);
+    save_test_thread_metadata(&session, &project_a, cx).await;
+    cx.update(|_, cx| connection.send_update(session.clone(), background_tasks(1), cx));
+    cx.run_until_parked();
+    let thread = thread_id_of(&session.0, cx);
+    store_pin(thread, cx);
+    if away {
+        let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
+        let project_b = project::Project::test(fs, [], cx).await;
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.test_add_workspace(project_b, window, cx);
+        });
+        cx.run_until_parked();
+    }
+    connection.end_turn(session.clone(), acp_v1::StopReason::EndTurn);
+    cx.run_until_parked();
+    (sidebar, connection, session, cx)
+}
+
+// R2.12 — out of sight: the turn ending with work still running is not news (hostile half: a
+// rule keyed on "the turn ended" would fire here); the work ending is, on the row and the header.
+#[gpui::test]
+async fn pinned_row_is_notified_when_its_background_work_ends_out_of_sight(
+    cx: &mut TestAppContext,
+) {
+    let (sidebar, connection, session, cx) = pinned_thread_with_background_work(cx, true).await;
+    assert_eq!(
+        entries(&sidebar, cx),
+        vec![
+            "v [project-a]",
+            "v Pinned",
+            "  Hello · project-a * (background)"
+        ],
+        "the turn ended with work running: Background, and no marker yet"
+    );
+    assert_eq!(pinned_header_has_notifications(&sidebar, cx), Some(false));
+
+    cx.update(|_, cx| connection.send_update(session.clone(), background_tasks(0), cx));
+    cx.run_until_parked();
+
+    assert_eq!(
+        entries(&sidebar, cx),
+        vec!["v [project-a]", "v Pinned", "  Hello · project-a * (!)"],
+        "Background → Completed out of sight marks the pinned row"
+    );
+    assert_eq!(
+        pinned_header_has_notifications(&sidebar, cx),
+        Some(true),
+        "and the Pinned header"
+    );
+}
+
+// R2.12 with Pinned collapsed — no row is shown, the header still reports the notification, and
+// expanding shows the row marked.
+#[gpui::test]
+async fn collapsed_pinned_header_reports_the_end_of_background_work(cx: &mut TestAppContext) {
+    let (sidebar, connection, session, cx) = pinned_thread_with_background_work(cx, true).await;
+    let header = pinned_header_ix(&sidebar, cx).expect("fixture: a Pinned header");
+    confirm_at(&sidebar, header, cx);
+    assert_eq!(
+        entries(&sidebar, cx),
+        vec!["v [project-a]", "> Pinned  <== selected"],
+        "fixture: Pinned is collapsed"
+    );
+    assert_eq!(pinned_header_has_notifications(&sidebar, cx), Some(false));
+
+    cx.update(|_, cx| connection.send_update(session.clone(), background_tasks(0), cx));
+    cx.run_until_parked();
+    sidebar.update(cx, |sidebar, cx| sidebar.update_entries(cx));
+    cx.run_until_parked();
+
+    assert_eq!(
+        pinned_header_has_notifications(&sidebar, cx),
+        Some(true),
+        "the collapsed Pinned header reports the end of the background work"
+    );
+    confirm_at(&sidebar, header, cx);
+    assert_eq!(
+        entries(&sidebar, cx),
+        vec![
+            "v [project-a]",
+            "v Pinned  <== selected",
+            "  Hello · project-a * (!)"
+        ],
+        "the row is marked once Pinned is expanded"
+    );
+}
+
+// R2.12 converse — the active pinned thread finishing its background work is in sight: no marker
+// on its row, none on the header.
+#[gpui::test]
+async fn active_pinned_thread_gets_no_marker_when_its_background_work_ends(
+    cx: &mut TestAppContext,
+) {
+    let (sidebar, connection, session, cx) = pinned_thread_with_background_work(cx, false).await;
+    assert_eq!(
+        entries(&sidebar, cx),
+        vec![
+            "v [project-a]",
+            "v Pinned",
+            "  Hello · project-a * (background)"
+        ],
+        "fixture: the active pinned thread runs in the background"
+    );
+
+    cx.update(|_, cx| connection.send_update(session.clone(), background_tasks(0), cx));
+    cx.run_until_parked();
+
+    assert_eq!(
+        entries(&sidebar, cx),
+        vec!["v [project-a]", "v Pinned", "  Hello · project-a *"],
+        "no marker on the active thread"
+    );
+    assert_eq!(pinned_header_has_notifications(&sidebar, cx), Some(false));
+}
diff --git a/crates/sidebar/src/sidebar_pinned_tests.rs b/crates/sidebar/src/sidebar_pinned_tests.rs
index e9d55f8..b5c1302 100644
--- a/crates/sidebar/src/sidebar_pinned_tests.rs
+++ b/crates/sidebar/src/sidebar_pinned_tests.rs
@@ -2115,3 +2115,6 @@ async fn filter_shows_matches_of_a_collapsed_pinned_section(cx: &mut TestAppCont
 
 #[path = "sidebar_pinned_markdown_tests.rs"]
 mod markdown;
+
+#[path = "sidebar_pinned_background_tests.rs"]
+mod background;
diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs
index f181fc5..16bee07 100644
--- a/crates/sidebar/src/sidebar_tests.rs
+++ b/crates/sidebar/src/sidebar_tests.rs
@@ -686,6 +686,7 @@ pub(super) fn visible_entries_as_strings(
                             let live = if thread.is_live { " *" } else { "" };
                             let status_str = match thread.status {
                                 AgentThreadStatus::Running => " (running)",
+                                AgentThreadStatus::Background => " (background)",
                                 AgentThreadStatus::Error => " (error)",
                                 AgentThreadStatus::WaitingForConfirmation => " (waiting)",
                                 _ => "",
@@ -4796,6 +4797,105 @@ async fn test_background_thread_completion_triggers_notification(cx: &mut TestAp
     );
 }
 
+#[gpui::test]
+async fn test_background_tasks_outlive_the_turn_in_the_sidebar(cx: &mut TestAppContext) {
+    let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
+    let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
+
+    let connection_a = StubAgentConnection::new();
+    open_thread_with_connection(&panel_a, connection_a.clone(), cx);
+    send_message(&panel_a, cx);
+
+    let session_id_a = active_session_id(&panel_a, cx);
+    save_test_thread_metadata(&session_id_a, &project_a, cx).await;
+
+    let background_tasks = |count: u64| {
+        let mut meta = serde_json::Map::new();
+        meta.insert(
+            "_claude/backgroundTasks".to_string(),
+            serde_json::json!({ "count": count, "tasks": [] }),
+        );
+        acp_v1::SessionUpdate::SessionInfoUpdate(acp_v1::SessionInfoUpdate::new().meta(meta))
+    };
+
+    // The turn launches a background shell, then ends while it keeps running.
+    cx.update(|_, cx| {
+        connection_a.send_update(session_id_a.clone(), background_tasks(1), cx);
+    });
+    cx.run_until_parked();
+
+    let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
+    let project_b = project::Project::test(fs, [], cx).await;
+    multi_workspace.update_in(cx, |mw, window, cx| {
+        mw.test_add_workspace(project_b, window, cx);
+    });
+    cx.run_until_parked();
+
+    connection_a.end_turn(session_id_a.clone(), acp_v1::StopReason::EndTurn);
+    cx.run_until_parked();
+
+    // The turn is over, so it is no longer "running" -- but the work is not done, so
+    // it is not "finished" either.
+    assert_eq!(
+        visible_entries_as_strings(&sidebar, cx),
+        vec![
+            //
+            "v [project-a]",
+            "  Hello * (background)",
+        ]
+    );
+
+    // Archiving is refused while the background work runs, as upstream refuses it for a
+    // running or Unconfirmed thread.
+    let background_index = sidebar.read_with(cx, |sidebar, _cx| {
+        sidebar
+            .contents
+            .entries
+            .iter()
+            .position(|entry| {
+                matches!(
+                    entry,
+                    ListEntry::Thread(thread) if thread.status == AgentThreadStatus::Background
+                )
+            })
+            .expect("the background thread is listed")
+    });
+    focus_sidebar(&sidebar, cx);
+    sidebar.update_in(cx, |sidebar, _window, _cx| {
+        sidebar.selection = Some(background_index);
+    });
+    cx.dispatch_action(ArchiveSelectedThread);
+    cx.run_until_parked();
+    assert_eq!(
+        visible_entries_as_strings(&sidebar, cx),
+        vec![
+            //
+            "v [project-a]",
+            "  Hello * (background)  <== selected",
+        ]
+    );
+    sidebar.update_in(cx, |sidebar, _window, _cx| {
+        sidebar.selection = None;
+    });
+
+    // The last background task ends between turns, with no turn to carry the news.
+    cx.update(|_, cx| {
+        connection_a.send_update(session_id_a.clone(), background_tasks(0), cx);
+    });
+    cx.run_until_parked();
+
+    assert_eq!(
+        visible_entries_as_strings(&sidebar, cx),
+        vec![
+            //
+            "v [project-a]",
+            "  Hello * (!)",
+        ]
+    );
+}
+
 pub(super) fn type_in_search(
     sidebar: &Entity<Sidebar>,
     query: &str,
diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs
index a2020c0..bb94064 100644
--- a/crates/ui/src/components/ai/thread_item.rs
+++ b/crates/ui/src/components/ai/thread_item.rs
@@ -13,6 +13,9 @@ pub enum AgentThreadStatus {
     Completed,
     Unconfirmed,
     Running,
+    /// The turn is over but the agent still has work running in the background — a
+    /// backgrounded shell, an async subagent — which a new turn may follow.
+    Background,
     WaitingForConfirmation,
     Error,
 }
@@ -389,6 +392,17 @@ impl RenderOnce for ThreadItem {
                         .with_rotate_animation(2),
                 )
                 .into_any_element()
+        } else if self.status == AgentThreadStatus::Background {
+            // The same glyph as a running turn, so it still reads as "working", but tinted
+            // and slower so the two stay apart at a glance.
+            icon_container()
+                .child(
+                    Icon::new(IconName::LoadCircle)
+                        .size(IconSize::Small)
+                        .color(Color::Accent)
+                        .with_rotate_animation(4),
+                )
+                .into_any_element()
         } else if let Some(status_icon) = status_icon {
             icon_container().child(status_icon).into_any_element()
         } else {
@@ -520,7 +534,9 @@ impl RenderOnce for ThreadItem {
 
         let show_tooltip = matches!(
             self.status,
-            AgentThreadStatus::Error | AgentThreadStatus::WaitingForConfirmation
+            AgentThreadStatus::Error
+                | AgentThreadStatus::WaitingForConfirmation
+                | AgentThreadStatus::Background
         );
 
         let linked_worktrees: Vec<ThreadItemWorktreeInfo> = self
@@ -749,6 +765,15 @@ impl RenderOnce for ThreadItem {
                         )
                         .child(Label::new("Waiting for Confirmation"))
                         .into_any_element(),
+                    AgentThreadStatus::Background => h_flex()
+                        .gap_1()
+                        .child(
+                            Icon::new(IconName::LoadCircle)
+                                .size(IconSize::Small)
+                                .color(Color::Accent),
+                        )
+                        .child(Label::new("Running in the Background"))
+                        .into_any_element(),
                     _ => gpui::Empty.into_any_element(),
                 }))
             })
@@ -820,6 +845,17 @@ impl Component for ThreadItem {
                     )
                     .into_any_element(),
             ),
+            single_example(
+                "Running in the Background",
+                container()
+                    .child(
+                        ThreadItem::new("ti-3b", "Start the dev server and watch the logs")
+                            .icon(IconName::AiClaude)
+                            .timestamp("1h")
+                            .status(AgentThreadStatus::Background),
+                    )
+                    .into_any_element(),
+            ),
             single_example(
                 "In Worktree",
                 container()
