From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: zed-patches <zed-patches@localhost>
Date: Sun, 25 Oct 2026 03:31:42 -0300
Subject: [PATCH] agent_ui: send a sub-thread's result to its parent, and list
 sub-threads as task items

Story 039 opens a sub-thread from a selected excerpt of an agent message and
records its parent in 0048's organization store. Nothing carried the outcome
back, and nothing showed which sub-threads exist or which one is still working.
This patch adds both halves (story 040).

Send to Parent. A sub-thread's composer footer gets a "Send to Parent" menu
with two choices, each a Tab stop with a tooltip and an accessible label:
  - "Send Last Reply" puts the last agent reply, under a header naming the
    sub-thread (`From sub-thread "<title>":`), into the parent's composer;
  - "Summarize and Send" asks the sub-thread for a summary in a visible turn
    and delivers that turn's reply the same way, only when the turn the
    request itself started ends with end of turn.
The text is appended after any draft and never sent: the operator reviews it.
A closed parent is opened first through 0040's router; a reply over 200,000
characters is cut on a char boundary with a count of what was left out. Both
choices are disabled, with the reason as tooltip, when the parent was deleted,
while a delivery is still reaching the parent, while a turn runs or a summary
waits, and (Summarize only) while a reported-activity session has not reported
since it loaded. The summary is bound to its own SUMMARY_REQUEST entry, not to
the activity generation alone: on the reported-activity transport the agent can
start a turn of its own that takes the generation the summary expected. A
request the agent rejects on that transport settles its submission Failed
with no turn and no Error event; that clears the summary too, the error shown
under the control.

Sub-thread items. A reporter in agent_ui turns 039's links and the sub-thread
threads' run state into SubThreadActivity (Linked, Running, Idle, Retitled),
emitted by 0037's tasks card beside its TaskActivity and replayed when a store
registers. 0035's TaskHistoryStore records each as an item under the parent's
session, task id `zeo-sub-thread:<sub session>`, kind `sub_thread`, so it
groups, archives and deletes through the same TaskKey paths as the adapter's
tasks. The card and the panel draw it with a "Sub-thread" chip, Running or
Idle, no Stop, no log; activating it opens the sub-thread, or says the thread
no longer exists.

History schema. Two steps appended to AgentTaskHistoryDb's migrations, steps
1-4 untouched:
  - step 5: `origin TEXT NOT NULL DEFAULT "adapter"` -- the stored column alone
    says what an item is; every sub-thread write is guarded on it, so a row the
    adapter wrote on the same key is never rewritten, and an unknown origin a
    newer Zeo wrote is skipped with a warning, like an unknown status;
  - step 6: `deleted INTEGER NOT NULL DEFAULT 0` -- deleting an archived
    sub-thread item keeps a hidden tombstone, because the parent link outlives
    the delete and the next start would record the item again. load_all skips
    it, deleted_sub_thread_keys() seeds the store's deleted set before the
    replay, and neither sub-thread write revives it. Adapter rows are still
    removed as before.
0056's log_bytes test pinned its step as the last migration; it now pins it by
its index (3), since step 5 follows it; the length is still never pinned.

Touch points in upstream and earlier-patch files: ThreadView gains the state
field, its focus handle and one footer call, and get_agent_message_content
becomes pub(crate); ConversationView::handle_thread_event forwards Stopped,
Error, Refusal, EntriesRemoved and SubmissionUpdated to the sub-thread view; tasks_card.rs and
agent_tasks_panel's panel.rs dispatch on the origin; agent_ui.rs re-exports
the reporter's types. Logs carry session ids, statuses and counts only, never a
title or a reply (zeo_sub_thread event=...).

Last of the claude-agent-acp-plus group, after 0070 whose sub-threads it
consumes. 115 tests added (unit, GPUI integration and history migrations).

Story 064 (2026-11-02): Zeo's task surfaces read Zed's native subagent
registry, beside the adapter's feed.
  - One row per child. tasks_card::merge folds a feed task into the
    registry's row only when their child session ids are equal, never on a
    title; the registry's status wins (Waiting and Unknown included), the
    feed's usage, last tool and summary are carried. TaskRow gains `source`
    (ClaudeTasks | AcpSubagent { child, can_cancel }) and its own
    TaskRowStatus.
  - The card. A row appears on SubagentSpawned alone; Stop sends
    session/cancel to that child when it allows it and is disabled with the
    reason otherwise, a cancel error shown on the row; Open shows the child.
  - The panel. Live rows under their parent; Open on a child the parent does
    not hold opens the parent at its card with a notice (0041's
    open_agent_subagent); agent_panel handles the card's OpenNativeSubagent.
  - History step 7: `child_session_id TEXT`, appended, steps 1-6 untouched;
    TaskOrigin gains acp_subagent and a record's id is
    `acp-subagent:<child>`, though the id prefix never decides the origin.
    A 0.7.0 database (test_data/history_0_7_0.sql, and the operator's real db
    copied to scratch) reads back byte for byte with a NULL child.
  - Restored outcomes are not endings: a child whose first observed status is
    terminal is shown Done and recorded nowhere.
  - Waiting reads Waiting. A child in RequiresAction reads "Waiting for you"
    on the card's progress line and in the panel's status text, in the
    warning colour, where both used to say Running: the card reads the row's
    TaskRowStatus, and the panel a live-only `waiting` field on TaskRecord
    that panel_row lays over the stored record (TasksCard::live_task_waiting).
    AgentTaskStatus still folds Waiting into Running, so expiry, the history
    and the tray are unchanged, and the field is never stored.
It lands here because 0071 is the last patch of the group that touches
tasks_card.rs, the history and the panel. 46 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---

diff --git a/crates/agent_tasks_panel/src/agent_tasks_panel.rs b/crates/agent_tasks_panel/src/agent_tasks_panel.rs
index 21945b6..3fe7e3b 100644
--- a/crates/agent_tasks_panel/src/agent_tasks_panel.rs
+++ b/crates/agent_tasks_panel/src/agent_tasks_panel.rs
@@ -7,7 +7,7 @@ mod store;
 
 use gpui::App;
 
-pub use history_db::{AgentTaskHistoryDb, HistoryEntry};
+pub use history_db::{AgentTaskHistoryDb, HistoryEntry, TaskOrigin};
 pub use panel::{AgentTasksPanel, PanelGroup, PanelRow, TaskFilter, ToggleFocus};
 pub use store::{Availability, TaskHistoryStore, TaskKey};
 
@@ -24,3 +24,6 @@ pub fn init(cx: &mut App) {
 
 #[cfg(test)]
 mod test_helpers;
+
+#[cfg(test)]
+mod native_subagent_support;
diff --git a/crates/agent_tasks_panel/src/history_db.rs b/crates/agent_tasks_panel/src/history_db.rs
index 9dc81c8..eb29f5b 100644
--- a/crates/agent_tasks_panel/src/history_db.rs
+++ b/crates/agent_tasks_panel/src/history_db.rs
@@ -39,12 +39,16 @@ use db::{
 };
 use gpui::SharedString;
 
+mod sub_thread;
+mod subagent;
+pub use sub_thread::{SUB_THREAD_KIND, SUB_THREAD_TASK_PREFIX, sub_thread_task_id};
+
 /// The history domain's connection: a clone of the shared application database.
 pub struct AgentTaskHistoryDb(ThreadSafeConnection);
 
 /// The migration list lives in a `static` so that the list registered with `inventory` and
 /// `AgentTaskHistoryDb::MIGRATIONS` are one allocation rather than two copies of a `const`.
-static HISTORY_MIGRATIONS: [&str; 4] = [
+static HISTORY_MIGRATIONS: [&str; 7] = [
     sql!(
     CREATE TABLE IF NOT EXISTS agent_task_history(
         session_id TEXT NOT NULL,
@@ -71,6 +75,16 @@ static HISTORY_MIGRATIONS: [&str; 4] = [
     sql!(ALTER TABLE agent_task_history ADD COLUMN tool_call_id TEXT;),
     // Step 4 (story 041): the size of the task's saved log, NULL when it has none (R6.9).
     sql!(ALTER TABLE agent_task_history ADD COLUMN log_bytes INTEGER;),
+    // Step 5 (story 040): who produced the item. Every row older than the column is an adapter
+    // task, which is what the default says (R5.6). Double-quoted because `'adapter'` inside
+    // `sql!` is a Rust char literal and does not compile; SQLite reads it as the string.
+    sql!(ALTER TABLE agent_task_history ADD COLUMN origin TEXT NOT NULL DEFAULT "adapter";),
+    // Step 6 (story 040, review 3.x F1): a deleted sub-thread item is kept as a hidden tombstone,
+    // or the parent's link would record it again at the next start (R5.10).
+    sql!(ALTER TABLE agent_task_history ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;),
+    // Step 7 (story 064): the ACP session of a native subagent record, NULL for every other
+    // row. Who produced a row stays the `origin` column, which gains `acp_subagent` (R2.2).
+    sql!(ALTER TABLE agent_task_history ADD COLUMN child_session_id TEXT;),
 ];
 
 impl Domain for AgentTaskHistoryDb {
@@ -94,6 +108,37 @@ pub struct HistoryEntry {
     pub label: Option<SharedString>,
     /// The size of the task's saved log; `None` when it has none.
     pub log_bytes: Option<u64>,
+    /// Who produced the item: the stored column alone decides, never its kind or id (R5.7).
+    pub origin: TaskOrigin,
+}
+
+/// Where a history item came from (story 040): a task the adapter reported, a sub-thread
+/// story 039 linked to its parent, or an ACP subagent read from Zed's registry (story 064).
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum TaskOrigin {
+    Adapter,
+    SubThread,
+    AcpSubagent,
+}
+
+impl TaskOrigin {
+    /// The text stored in the `origin` column.
+    pub fn as_text(self) -> &'static str {
+        match self {
+            TaskOrigin::Adapter => "adapter",
+            TaskOrigin::SubThread => "sub_thread",
+            TaskOrigin::AcpSubagent => "acp_subagent",
+        }
+    }
+
+    fn parse(text: &str) -> Result<Self> {
+        match text {
+            "adapter" => Ok(TaskOrigin::Adapter),
+            "sub_thread" => Ok(TaskOrigin::SubThread),
+            "acp_subagent" => Ok(TaskOrigin::AcpSubagent),
+            unknown => Err(anyhow!("unknown task origin {unknown:?}")),
+        }
+    }
 }
 
 // The only delete is the user's, of records they archived first (story 025): every other record
@@ -252,36 +297,56 @@ impl AgentTaskHistoryDb {
     /// Returns the indices into `keys` of the rows it actually removed: a key whose row is not
     /// archived in the database (an archive write that failed) is not deleted, and its caller
     /// must not delete that record's log either (story 041).
+    /// A sub-thread row is kept as a tombstone instead (R5.10): its link outlives the delete.
     pub async fn delete(&self, keys: &[(acp_v2::SessionId, AgentTaskId)]) -> Result<Vec<usize>> {
         let keys: Vec<(String, String)> = keys
             .iter()
             .map(|(session_id, task_id)| (session_id.to_string(), task_id.to_string()))
             .collect();
-        self.write(move |connection| {
-            connection.with_savepoint("agent_task_history_delete", || {
-                let mut archived = connection.select_row_bound::<(&str, &str), i64>(sql!(
-                    SELECT 1 FROM agent_task_history
-                    WHERE session_id = ?1 AND task_id = ?2 AND archived = 1
-                ))?;
-                let mut delete = connection.exec_bound(sql!(
-                    DELETE FROM agent_task_history
-                    WHERE session_id = ?1 AND task_id = ?2 AND archived = 1
-                ))?;
-                let mut removed = Vec::new();
-                for (index, (session_id, task_id)) in keys.iter().enumerate() {
-                    let ids = (session_id.as_str(), task_id.as_str());
-                    let present = archived(ids)
-                        .with_context(|| format!("reading {session_id}/{task_id}"))?
-                        .is_some();
-                    delete(ids).with_context(|| format!("deleting {session_id}/{task_id}"))?;
-                    if present {
-                        removed.push(index);
+        let (removed, tombstoned) = self
+            .write(move |connection| {
+                connection.with_savepoint("agent_task_history_delete", || {
+                    let mut archived =
+                        connection.select_row_bound::<(&str, &str), String>(sql!(
+                            SELECT origin FROM agent_task_history
+                            WHERE session_id = ?1 AND task_id = ?2 AND archived = 1 AND deleted = 0
+                        ))?;
+                    let mut delete = connection.exec_bound(sql!(
+                        DELETE FROM agent_task_history
+                        WHERE session_id = ?1 AND task_id = ?2 AND archived = 1 AND origin != ?3
+                    ))?;
+                    let mut tombstone = connection.exec_bound(sql!(
+                        UPDATE agent_task_history SET deleted = 1
+                        WHERE session_id = ?1 AND task_id = ?2 AND archived = 1 AND origin = ?3
+                    ))?;
+                    let sub_thread = TaskOrigin::SubThread.as_text();
+                    let mut removed = Vec::new();
+                    let mut tombstoned = Vec::new();
+                    for (index, (session_id, task_id)) in keys.iter().enumerate() {
+                        let ids = (session_id.as_str(), task_id.as_str());
+                        let origin = archived(ids)
+                            .with_context(|| format!("reading {session_id}/{task_id}"))?;
+                        let bound = (session_id.as_str(), task_id.as_str(), sub_thread);
+                        delete(bound)
+                            .with_context(|| format!("deleting {session_id}/{task_id}"))?;
+                        tombstone(bound)
+                            .with_context(|| format!("tombstoning {session_id}/{task_id}"))?;
+                        if let Some(origin) = origin {
+                            if origin == sub_thread {
+                                tombstoned.push((session_id.clone(), task_id.clone()));
+                            }
+                            removed.push(index);
+                        }
                     }
-                }
-                Ok(removed)
+                    Ok((removed, tombstoned))
+                })
             })
-        })
-        .await
+            .await?;
+        // Logged once the savepoint committed: a failed batch rolls every tombstone back.
+        for (session_id, task_id) in &tombstoned {
+            sub_thread::log_tombstone(session_id, task_id);
+        }
+        Ok(removed)
     }
 
     /// Sets or clears (`None`) the user's label of one task. Only that `(session_id, task_id)`
@@ -332,8 +397,9 @@ impl AgentTaskHistoryDb {
         let rows = self.select::<HistoryRow>(sql!(
             SELECT session_id, task_id, thread_title, project, kind, description, background,
                 status, started_at, ended_at, tokens, tool_uses, archived, label, tool_call_id,
-                log_bytes
+                log_bytes, origin
             FROM agent_task_history
+            WHERE deleted = 0
             ORDER BY started_at DESC, session_id, task_id
         ))?()
         .context("loading the agent task history")?;
@@ -373,7 +439,18 @@ impl Column for HistoryRow {
         let (label, next): (Option<String>, i32) = Column::column(statement, next)?;
         let (tool_call_id, next): (Option<String>, i32) = Column::column(statement, next)?;
         let (log_bytes, next): (Option<u64>, i32) = Column::column(statement, next)?;
+        let (origin, next): (String, i32) = Column::column(statement, next)?;
 
+        // Skipped like an unknown status: an origin a newer Zeo wrote is never guessed at.
+        let origin = match TaskOrigin::parse(&origin) {
+            Ok(origin) => origin,
+            Err(error) => {
+                return Ok((
+                    HistoryRow(Err(format!("{session_id}/{task_id}: {error:#}"))),
+                    next,
+                ));
+            }
+        };
         let status = match parse_status(&status) {
             Ok(status) => status,
             Err(error) => {
@@ -397,6 +474,9 @@ impl Column for HistoryRow {
             tokens,
             tool_uses,
             tool_call_id: tool_call_id.map(Into::into),
+            // Read back by origin and task id; the column is what `record_subagent` writes.
+            child_session_id: None,
+            waiting: false,
         };
         Ok((
             HistoryRow(Ok(HistoryEntry {
@@ -404,6 +484,7 @@ impl Column for HistoryRow {
                 archived,
                 label: label.map(Into::into),
                 log_bytes,
+                origin,
             })),
             next,
         ))
@@ -468,3 +549,31 @@ mod log_bytes;
 #[cfg(test)]
 #[path = "history_db/delete_removed_tests.rs"]
 mod delete_removed;
+
+#[cfg(test)]
+#[path = "history_db/origin_tests.rs"]
+mod origin;
+
+#[cfg(test)]
+#[path = "history_db/sub_thread_upsert_tests.rs"]
+mod sub_thread_upsert;
+
+#[cfg(test)]
+#[path = "history_db/tombstone_tests.rs"]
+mod tombstone;
+
+#[cfg(test)]
+#[path = "history_db/tombstone_step_tests.rs"]
+mod tombstone_step;
+
+#[cfg(test)]
+#[path = "history_db/subagent_step_tests.rs"]
+mod subagent_step;
+
+#[cfg(test)]
+#[path = "history_db/subagent_record_tests.rs"]
+mod subagent_record;
+
+#[cfg(test)]
+#[path = "history_db/subagent_real_copy_tests.rs"]
+mod subagent_real_copy;
diff --git a/crates/agent_tasks_panel/src/history_db/log_bytes_tests.rs b/crates/agent_tasks_panel/src/history_db/log_bytes_tests.rs
index 0a87f60..f8b9f43 100644
--- a/crates/agent_tasks_panel/src/history_db/log_bytes_tests.rs
+++ b/crates/agent_tasks_panel/src/history_db/log_bytes_tests.rs
@@ -8,11 +8,15 @@
 //
 // Validation filter: `cargo test -p agent_tasks_panel` (names carry `history_db_log_bytes`).
 //
-// Contract (design.md §Task history): the LAST step of `HISTORY_MIGRATIONS` is `ALTER TABLE
-// agent_task_history ADD COLUMN log_bytes INTEGER;` (NULL = no log); `AgentTaskHistoryDb::
-// set_log_bytes(session_id, task_id, bytes: Option<u64>)`; `HistoryEntry::log_bytes: Option<u64>`.
-// The step's index is not pinned: a batch sibling may append before this story, so the tests pin
-// the three shipped steps and the last one, never the length. Argument types are adapted in `set`.
+// Contract (design.md §Task history): the FOURTH step of `HISTORY_MIGRATIONS` (index 3) is
+// `ALTER TABLE agent_task_history ADD COLUMN log_bytes INTEGER;` (NULL = no log);
+// `AgentTaskHistoryDb::set_log_bytes(session_id, task_id, bytes: Option<u64>)`;
+// `HistoryEntry::log_bytes: Option<u64>`. Argument types are adapted in `set`.
+//
+// Story 040 (task 3.1, design.md GOTCHA on `log_bytes_tests.rs`): this file used to pin
+// `log_bytes` as the LAST step, as its own comment allowed until a later step arrived. Story 040
+// appends step 5 (`origin`), so the step is now pinned by index — the fourth — and the length is
+// still never pinned. Nothing else in this file changed.
 //
 // Hostile halves first: a database written by the SHIPPED steps, holding records, must open with
 // the new list and keep every record with no log; a size set on (s1, t1) must not land on
@@ -149,8 +153,8 @@ fn history_db_log_bytes_is_appended_after_the_shipped_steps() {
         "story 023's step is left as shipped"
     );
     assert!(
-        *steps.last().unwrap() == LOG_BYTES_STEP,
-        "this story's step is the last one"
+        steps.len() > 3 && steps[3] == LOG_BYTES_STEP,
+        "this story's step is the fourth one (index 3)"
     );
     assert_eq!(
         steps.iter().filter(|step| **step == LOG_BYTES_STEP).count(),
diff --git a/crates/agent_tasks_panel/src/history_db/origin_tests.rs b/crates/agent_tasks_panel/src/history_db/origin_tests.rs
new file mode 100644
index 0000000..70f426a
--- /dev/null
+++ b/crates/agent_tasks_panel/src/history_db/origin_tests.rs
@@ -0,0 +1,312 @@
+// Story 040, task 3.1 — migration step 5 and the origin of a history item (R5.6, R5.7; Q13).
+// Module path: `history_db::origin`. Wired in `src/history_db.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "history_db/origin_tests.rs"]
+//     mod origin;
+//
+// Validation filter: `cargo test -p agent_tasks_panel -- history_db` (names carry
+// `history_db_origin`).
+//
+// Contract (design.md §TaskHistoryStore and AgentTaskHistoryDb):
+//   - step 5 of `HISTORY_MIGRATIONS` (index 4) is
+//     `sql!(ALTER TABLE agent_task_history ADD COLUMN origin TEXT NOT NULL DEFAULT "adapter";)`,
+//     appended after the four shipped steps, which stay byte-identical. The default is
+//     double-quoted: `'adapter'` inside `sql!` is a malformed Rust char literal (Phase 3 finding,
+//     adopted by design.md 2026-10-20);
+//   - `pub enum TaskOrigin { Adapter, SubThread }`, stored as `'adapter'` / `'sub_thread'`;
+//   - `HistoryEntry::origin: TaskOrigin`, read by `load_all`;
+//   - `insert_started` writes no origin, so an adapter row takes the column default.
+// Assumed beyond design.md: `TaskOrigin` derives `Debug`, `Clone`, `Copy`, `PartialEq` and is
+// reachable as `crate::history_db::TaskOrigin` (re-exported at the crate root or not).
+//
+// Unknown origin text: `load_all` skips the row with a warning carrying its ids, as the shipped
+// code does for an unknown status, and keeps the rest of the history (design.md, 2026-10-20).
+//
+// Hostile halves first, for "the stored origin alone decides" (R5.7):
+//   - wrongly collapse: an ADAPTER task whose kind is `sub_thread` and whose id carries the
+//     `zeo-sub-thread:` prefix must stay `Adapter`;
+//   - wrongly split: a row stored with origin `sub_thread` but an adapter-looking kind and id
+//     must read `SubThread`;
+//   - then the benign fresh adapter record.
+
+use acp_thread::AgentTaskStatus;
+use db::sqlez::{connection::Connection, domain::Domain};
+use db::sqlez_macros::sql;
+use gpui::TestAppContext;
+
+use crate::history_db::TaskOrigin;
+use crate::test_helpers::*;
+use crate::{AgentTaskHistoryDb, HistoryEntry};
+
+/// Step 1 exactly as patch 0035 shipped it (the copy story 041's test carries).
+const SHIPPED_STEP_0: &str = sql!(
+    CREATE TABLE IF NOT EXISTS agent_task_history(
+        session_id TEXT NOT NULL,
+        task_id TEXT NOT NULL,
+        thread_title TEXT,
+        project TEXT NOT NULL,
+        kind TEXT NOT NULL,
+        description TEXT NOT NULL,
+        background INTEGER NOT NULL,
+        status TEXT NOT NULL,
+        started_at INTEGER NOT NULL,
+        ended_at INTEGER,
+        tokens INTEGER,
+        tool_uses INTEGER,
+        archived INTEGER NOT NULL DEFAULT 0,
+        PRIMARY KEY(session_id, task_id)
+    ) STRICT;
+    CREATE INDEX IF NOT EXISTS agent_task_history_started ON agent_task_history(started_at);
+);
+const SHIPPED_STEP_1: &str = sql!(ALTER TABLE agent_task_history ADD COLUMN label TEXT;);
+const SHIPPED_STEP_2: &str = sql!(ALTER TABLE agent_task_history ADD COLUMN tool_call_id TEXT;);
+const SHIPPED_STEP_3: &str = sql!(ALTER TABLE agent_task_history ADD COLUMN log_bytes INTEGER;);
+
+const ORIGIN_STEP: &str =
+    sql!(ALTER TABLE agent_task_history ADD COLUMN origin TEXT NOT NULL DEFAULT "adapter";);
+
+fn migrate_all(connection: &Connection) {
+    connection
+        .migrate(
+            AgentTaskHistoryDb::NAME,
+            AgentTaskHistoryDb::MIGRATIONS,
+            &mut |_, _, _| false,
+        )
+        .expect("the history migrations should apply");
+}
+
+/// One fixed SQL statement of this test (sqlez, not a process).
+fn run_sql_now(connection: &Connection, statement: &str) {
+    let mut run = connection.exec(statement).expect("statement prepares");
+    run().expect("statement runs");
+}
+
+fn origin_of(rows: &[HistoryEntry], session: &str, task: &str) -> TaskOrigin {
+    find(rows, session, task)
+        .unwrap_or_else(|| panic!("{session}/{task} is recorded"))
+        .origin
+}
+
+// ── The step itself (Q13) ──────────────────────────────────────────────────
+
+#[test]
+fn history_db_origin_step_is_appended_after_the_four_shipped_steps() {
+    let steps = AgentTaskHistoryDb::MIGRATIONS;
+    assert!(
+        steps.len() >= 5,
+        "a fifth step exists: {} steps",
+        steps.len()
+    );
+    assert!(steps[0] == SHIPPED_STEP_0, "step 1 is never edited");
+    assert!(
+        steps[1] == SHIPPED_STEP_1,
+        "step 2 (story 022) is never edited"
+    );
+    assert!(
+        steps[2] == SHIPPED_STEP_2,
+        "step 3 (story 023) is never edited"
+    );
+    assert!(
+        steps[3] == SHIPPED_STEP_3,
+        "step 4 (story 041) is never edited"
+    );
+    assert!(steps[4] == ORIGIN_STEP, "step 5 adds the origin column");
+    assert_eq!(
+        steps.iter().filter(|step| **step == ORIGIN_STEP).count(),
+        1,
+        "appended exactly once"
+    );
+}
+
+// Hostile (Q13, R5.6): a database written by the shipped steps 1–4, holding records with labels,
+// tool calls, logs and archive flags, opens with the new list and keeps every record and value,
+// each of origin "adapter"; no sub-thread item appears.
+#[test]
+fn history_db_origin_step_four_database_keeps_every_record_as_adapter() {
+    let connection = Connection::open_memory(Some("history_db_origin_step_four"));
+    connection
+        .migrate(
+            AgentTaskHistoryDb::NAME,
+            &[
+                SHIPPED_STEP_0,
+                SHIPPED_STEP_1,
+                SHIPPED_STEP_2,
+                SHIPPED_STEP_3,
+            ],
+            &mut |_, _, _| false,
+        )
+        .expect("the shipped steps apply");
+    for (session, task, log_bytes, archived) in [
+        ("s-old", "t1", "4096", 1),
+        ("s-old", "t2", "NULL", 0),
+        ("s-other", "t1", "1048620", 0),
+    ] {
+        run_sql_now(
+            &connection,
+            &format!(
+                "INSERT INTO agent_task_history \
+                 (session_id, task_id, thread_title, project, kind, description, background, \
+                  status, started_at, ended_at, archived, label, tool_call_id, log_bytes) \
+                 VALUES ('{session}', '{task}', 'Old', 'zeo', 'shell', 'Old task', 1, \
+                         'completed', 1000, 2000, {archived}, 'kept label', 'toolu_x', \
+                         {log_bytes})"
+            ),
+        );
+    }
+
+    migrate_all(&connection);
+    // A second open of the same database changes nothing more.
+    migrate_all(&connection);
+
+    let rows = connection
+        .select::<(String, String, Option<String>, Option<i64>, bool, String)>(
+            "SELECT session_id, task_id, label, log_bytes, archived, origin \
+             FROM agent_task_history ORDER BY session_id, task_id",
+        )
+        .expect("select")()
+    .expect("run");
+    assert_eq!(rows.len(), 3, "every record survives, and none is added");
+    let expected = [
+        ("s-old", "t1", Some(4096), true),
+        ("s-old", "t2", None, false),
+        ("s-other", "t1", Some(1_048_620), false),
+    ];
+    for ((session, task, label, log_bytes, archived, origin), want) in
+        rows.into_iter().zip(expected)
+    {
+        assert_eq!((session.as_str(), task.as_str()), (want.0, want.1));
+        assert_eq!(label.as_deref(), Some("kept label"), "{session}/{task}");
+        assert_eq!(log_bytes, want.2, "{session}/{task} keeps its log size");
+        assert_eq!(archived, want.3, "{session}/{task} keeps its archive flag");
+        assert_eq!(origin, "adapter", "{session}/{task} is an adapter record");
+    }
+}
+
+#[test]
+fn history_db_origin_column_defaults_to_adapter_on_an_empty_database() {
+    let connection = Connection::open_memory(Some("history_db_origin_empty"));
+    migrate_all(&connection);
+    run_sql_now(
+        &connection,
+        "INSERT INTO agent_task_history \
+         (session_id, task_id, project, kind, description, background, status, started_at) \
+         VALUES ('s1', 't1', 'zeo', 'shell', 'Task', 0, 'running', 1000)",
+    );
+    let origins = connection
+        .select::<String>("SELECT origin FROM agent_task_history")
+        .expect("select")()
+    .expect("run");
+    assert_eq!(origins, vec!["adapter".to_string()]);
+}
+
+// ── The origin decides (R5.7) ──────────────────────────────────────────────
+
+// Hostile (wrong collapse): an adapter task dressed as a sub-thread — kind `sub_thread`, an id
+// carrying the `zeo-sub-thread:` prefix — recorded by the adapter path stays an adapter task.
+#[gpui::test]
+async fn history_db_origin_adapter_task_resembling_a_sub_thread_stays_adapter(
+    _cx: &mut TestAppContext,
+) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_origin_resembling").await;
+    let mut by_kind = record("s1", "t-kind", AgentTaskStatus::Running, 1_000);
+    by_kind.kind = "sub_thread".into();
+    let by_id = record("s1", "zeo-sub-thread:s9", AgentTaskStatus::Completed, 2_000);
+    let mut by_both = record("s2", "zeo-sub-thread:s1", AgentTaskStatus::Running, 3_000);
+    by_both.kind = "sub_thread".into();
+    for record in [&by_kind, &by_id, &by_both] {
+        db.insert_started(record)
+            .await
+            .expect("insert_started should succeed");
+    }
+
+    let rows = load(&db).await;
+    assert_eq!(origin_of(&rows, "s1", "t-kind"), TaskOrigin::Adapter);
+    assert_eq!(
+        origin_of(&rows, "s1", "zeo-sub-thread:s9"),
+        TaskOrigin::Adapter
+    );
+    assert_eq!(
+        origin_of(&rows, "s2", "zeo-sub-thread:s1"),
+        TaskOrigin::Adapter
+    );
+}
+
+// Hostile (wrong split): a row stored as a sub-thread item reads `SubThread` even when nothing
+// else about it looks like one — no prefix, an adapter kind.
+#[gpui::test]
+async fn history_db_origin_stored_sub_thread_reads_sub_thread_whatever_its_kind_and_id(
+    _cx: &mut TestAppContext,
+) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_origin_stored_sub_thread").await;
+    run_sql(
+        &db,
+        "INSERT INTO agent_task_history \
+         (session_id, task_id, project, kind, description, background, status, started_at, \
+          origin) \
+         VALUES ('s1', 'plain-id', 'zeo', 'shell', 'Looks adapter', 0, 'completed', 1000, \
+                 'sub_thread')",
+    )
+    .await;
+    run_sql(
+        &db,
+        "INSERT INTO agent_task_history \
+         (session_id, task_id, project, kind, description, background, status, started_at) \
+         VALUES ('s1', 'zeo-sub-thread:s7', 'zeo', 'sub_thread', 'Looks sub-thread', 0, \
+                 'completed', 2000)",
+    )
+    .await;
+
+    let rows = load(&db).await;
+    assert_eq!(origin_of(&rows, "s1", "plain-id"), TaskOrigin::SubThread);
+    assert_eq!(
+        origin_of(&rows, "s1", "zeo-sub-thread:s7"),
+        TaskOrigin::Adapter,
+        "no origin written: the column default, whatever the kind and id say"
+    );
+}
+
+#[gpui::test]
+async fn history_db_origin_fresh_adapter_record_is_adapter(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_origin_fresh").await;
+    db.insert_started(&record("s1", "t1", AgentTaskStatus::Running, 1_000))
+        .await
+        .expect("insert_started should succeed");
+    assert_eq!(origin_of(&load(&db).await, "s1", "t1"), TaskOrigin::Adapter);
+}
+
+// R5.7 / Error handling table: an origin this build does not know is never read as one it does.
+#[gpui::test]
+async fn history_db_origin_unknown_text_is_skipped_with_a_warning(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_origin_unknown").await;
+    db.insert_started(&record("s1", "kept", AgentTaskStatus::Completed, 1_000))
+        .await
+        .expect("insert_started should succeed");
+    run_sql(
+        &db,
+        "INSERT INTO agent_task_history \
+         (session_id, task_id, project, kind, description, background, status, started_at, \
+          origin) \
+         VALUES ('s1', 'from-the-future', 'zeo', 'shell', 'Future', 0, 'completed', 2000, \
+                 'tray')",
+    )
+    .await;
+
+    capture_logs();
+    let rows = load(&db).await;
+    assert!(
+        find(&rows, "s1", "from-the-future").is_none(),
+        "a row of unknown origin is skipped, never listed as adapter or sub-thread"
+    );
+    assert_eq!(
+        origin_of(&rows, "s1", "kept"),
+        TaskOrigin::Adapter,
+        "the readable rows are still loaded"
+    );
+    assert!(
+        warn_lines()
+            .iter()
+            .any(|line| line.contains("s1") && line.contains("from-the-future")),
+        "the skip is a warning naming the row"
+    );
+}
diff --git a/crates/agent_tasks_panel/src/history_db/sub_thread.rs b/crates/agent_tasks_panel/src/history_db/sub_thread.rs
new file mode 100644
index 0000000..251e1e3
--- /dev/null
+++ b/crates/agent_tasks_panel/src/history_db/sub_thread.rs
@@ -0,0 +1,135 @@
+//! Story 040: the writes of a sub-thread item. A sub-thread is recorded under its parent's
+//! session, with a task id of its own, so it groups with the parent's tasks and archives and
+//! deletes through the same `TaskKey` paths (design.md, TaskHistoryStore and AgentTaskHistoryDb).
+
+use acp_thread::AgentTaskId;
+use agent_client_protocol::v2::schema as acp_v2;
+use agent_ui::{SubThreadRecord, TaskKey};
+use anyhow::{Context as _, Result};
+use db::sqlez_macros::sql;
+
+use super::{AgentTaskHistoryDb, TaskOrigin};
+
+/// Prefixed so a sub-thread's id never equals an id the adapter chose for a task of the same
+/// session. The prefix alone guarantees nothing, an adapter may pick any id, which is why both
+/// writes below are guarded on the stored origin.
+pub const SUB_THREAD_TASK_PREFIX: &str = "zeo-sub-thread:";
+
+/// The `kind` of a sub-thread item. Display only: the origin column decides what an item is.
+pub const SUB_THREAD_KIND: &str = "sub_thread";
+
+pub fn sub_thread_task_id(record: &SubThreadRecord) -> String {
+    format!("{SUB_THREAD_TASK_PREFIX}{}", record.sub_session)
+}
+
+/// The tombstone write of `delete` (R5.10). Session ids only: the title is user text (R8.1). A
+/// stored id without the prefix is logged as it is.
+pub(super) fn log_tombstone(parent_session: &str, task_id: &str) {
+    let sub_session = task_id
+        .strip_prefix(SUB_THREAD_TASK_PREFIX)
+        .unwrap_or(task_id);
+    log::info!(
+        "zeo_sub_thread event=history_write parent_session={parent_session} \
+         sub_session={sub_session} status=deleted result=ok"
+    );
+}
+
+/// Both sessions name the item in an error; the title is user text and never does.
+fn sessions_of(record: &SubThreadRecord) -> String {
+    format!(
+        "sub-thread {} of session {}",
+        record.sub_session, record.parent_session
+    )
+}
+
+impl AgentTaskHistoryDb {
+    /// Records a sub-thread item, or refreshes its status, description and end on a repeat
+    /// (R5.2, R5.3, R5.5). `status` is the stored text, `running` or `completed`; a running item
+    /// has no end. A conflict never touches `archived`, and never a row the adapter wrote on the
+    /// same key (R5.7).
+    pub async fn upsert_sub_thread(&self, record: &SubThreadRecord, status: &str) -> Result<()> {
+        let sessions = sessions_of(record);
+        let ended_at = (status != "running").then_some(record.at_ms);
+        // Every literal is bound: a single-quoted SQL string inside `sql!` does not compile.
+        let identity = (
+            record.parent_session.to_string(),
+            sub_thread_task_id(record),
+            record.project.to_string(),
+            SUB_THREAD_KIND,
+            record.title.to_string(),
+        );
+        let state = (
+            status.to_string(),
+            record.at_ms,
+            TaskOrigin::SubThread.as_text(),
+            ended_at,
+        );
+        self.write(move |connection| {
+            connection.exec_bound(sql!(
+                INSERT INTO agent_task_history(
+                    session_id, task_id, thread_title, project, kind, description, background,
+                    status, started_at, origin, ended_at
+                ) VALUES (?1, ?2, NULL, ?3, ?4, ?5, 0, ?6, ?7, ?8, ?9)
+                ON CONFLICT(session_id, task_id) DO UPDATE SET
+                    status = excluded.status,
+                    description = excluded.description,
+                    ended_at = excluded.ended_at
+                WHERE agent_task_history.origin = ?8 AND agent_task_history.deleted = 0
+            ))?((identity, state))
+        })
+        .await
+        .with_context(|| format!("recording {sessions} as {status}"))
+    }
+
+    /// What `Linked` and `Retitled` write: a new item is recorded Idle, an existing one gets only
+    /// its description. Status and end are left alone, so a second window's `Linked` cannot flip
+    /// a running sub-thread to Idle (R5.3, R7.4).
+    pub async fn record_sub_thread_link(&self, record: &SubThreadRecord) -> Result<()> {
+        let sessions = sessions_of(record);
+        let identity = (
+            record.parent_session.to_string(),
+            sub_thread_task_id(record),
+            record.project.to_string(),
+            SUB_THREAD_KIND,
+            record.title.to_string(),
+        );
+        let state = (
+            "completed",
+            record.at_ms,
+            TaskOrigin::SubThread.as_text(),
+            record.at_ms,
+        );
+        self.write(move |connection| {
+            connection.exec_bound(sql!(
+                INSERT INTO agent_task_history(
+                    session_id, task_id, thread_title, project, kind, description, background,
+                    status, started_at, origin, ended_at
+                ) VALUES (?1, ?2, NULL, ?3, ?4, ?5, 0, ?6, ?7, ?8, ?9)
+                ON CONFLICT(session_id, task_id) DO UPDATE SET
+                    description = excluded.description
+                WHERE agent_task_history.origin = ?8 AND agent_task_history.deleted = 0
+            ))?((identity, state))
+        })
+        .await
+        .with_context(|| format!("recording the link of {sessions}"))
+    }
+
+    /// The keys of the deleted sub-thread items (R5.10). The store seeds its `deleted` set with
+    /// them before any replayed `Linked` is applied, so a restart does not list them again.
+    pub async fn deleted_sub_thread_keys(&self) -> Result<Vec<TaskKey>> {
+        let keys = self.select_bound::<&str, (String, String)>(sql!(
+            SELECT session_id, task_id FROM agent_task_history
+            WHERE deleted = 1 AND origin = ?1
+        ))?(TaskOrigin::SubThread.as_text())
+        .context("loading the deleted sub-thread items")?;
+        Ok(keys
+            .into_iter()
+            .map(|(session_id, task_id)| {
+                (
+                    acp_v2::SessionId::new(session_id),
+                    AgentTaskId::from(task_id),
+                )
+            })
+            .collect())
+    }
+}
diff --git a/crates/agent_tasks_panel/src/history_db/sub_thread_upsert_tests.rs b/crates/agent_tasks_panel/src/history_db/sub_thread_upsert_tests.rs
new file mode 100644
index 0000000..e487520
--- /dev/null
+++ b/crates/agent_tasks_panel/src/history_db/sub_thread_upsert_tests.rs
@@ -0,0 +1,308 @@
+// Story 040, task 3.2 (database half) — `AgentTaskHistoryDb::upsert_sub_thread`: one statement that
+// records or refreshes a sub-thread item (R5.1, R5.2, R5.5, R5.7; Q14). Module path:
+// `history_db::sub_thread_upsert`. Wired in `src/history_db.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "history_db/sub_thread_upsert_tests.rs"]
+//     mod sub_thread_upsert;
+//
+// Validation filter: `cargo test -p agent_tasks_panel -- sub_thread` (names carry
+// `history_db_sub_thread`).
+//
+// Contract (design.md §TaskHistoryStore and AgentTaskHistoryDb, revised 2026-10-20):
+//   - `AgentTaskHistoryDb::upsert_sub_thread(&SubThreadRecord, status: &str)`, one
+//     `INSERT … ON CONFLICT(session_id, task_id) DO UPDATE … WHERE agent_task_history.origin =
+//     'sub_thread'` statement (every literal bound as `?n`): `session_id` = the parent's session,
+//     `task_id = 'zeo-sub-thread:' || sub_session`, `kind = 'sub_thread'`, `description` = the
+//     title, `origin = 'sub_thread'`; a conflict refreshes status, description and `ended_at`,
+//     never `archived`; an error names both session ids;
+//   - `AgentTaskHistoryDb::record_sub_thread_link(&SubThreadRecord)`, the same INSERT with status
+//     `completed`, whose conflict updates the DESCRIPTION ONLY (never status or `ended_at`), under
+//     the same origin guard — what `Linked` and `Retitled` write;
+//   - `agent_ui::SubThreadRecord { parent_session, sub_session, title, project, at_ms }`.
+// Assumed beyond design.md: both methods are `async` and return `anyhow::Result<()>` like every
+// other writer of this domain; `SubThreadRecord`'s fields are `pub` and it is `Clone`. Adjust in
+// `upsert`, `link_item` and `sub_record` only.
+//
+// Hostile halves first:
+//   - wrongly collapse: two sub-threads of one parent are two items; a sub-thread whose session id
+//     equals an adapter task id of the parent's session is a separate item;
+//   - a THIRD element against the derived key: an adapter task whose id is literally
+//     `zeo-sub-thread:<sub session>` already sits on the key both writers derive. Neither may
+//     rewrite that adapter row (its origin, kind, description and status stay) — the origin guard;
+//   - a link re-reported while the sub-thread runs (a second window's `Linked`, a `Retitled`)
+//     must not flip it to Idle: `record_sub_thread_link` leaves status and `ended_at` alone;
+//   - wrongly split: the same sub-thread reported again, running, idle and retitled, stays ONE
+//     item;
+//   - then the benign shape of a fresh item.
+
+use acp_thread::AgentTaskStatus;
+use agent_ui::SubThreadRecord;
+use gpui::TestAppContext;
+
+use crate::history_db::TaskOrigin;
+use crate::test_helpers::*;
+use crate::{AgentTaskHistoryDb, HistoryEntry};
+
+const PREFIX: &str = "zeo-sub-thread:";
+
+fn sub_record(parent: &str, sub: &str, title: &str, at_ms: i64) -> SubThreadRecord {
+    SubThreadRecord {
+        parent_session: sid(parent),
+        sub_session: sid(sub),
+        title: title.to_string().into(),
+        project: "project".into(),
+        at_ms,
+    }
+}
+
+async fn upsert(db: &AgentTaskHistoryDb, record: &SubThreadRecord, status: &str) {
+    db.upsert_sub_thread(record, status)
+        .await
+        .expect("upsert_sub_thread should succeed");
+}
+
+async fn link_item(db: &AgentTaskHistoryDb, record: &SubThreadRecord) {
+    db.record_sub_thread_link(record)
+        .await
+        .expect("record_sub_thread_link should succeed");
+}
+
+fn item<'a>(rows: &'a [HistoryEntry], parent: &str, sub: &str) -> Option<&'a HistoryEntry> {
+    find(rows, parent, &format!("{PREFIX}{sub}"))
+}
+
+fn items_of(rows: &[HistoryEntry], parent: &str) -> usize {
+    rows.iter()
+        .filter(|entry| {
+            entry.record.session_id.to_string() == parent && entry.origin == TaskOrigin::SubThread
+        })
+        .count()
+}
+
+// ── Wrongly collapse ───────────────────────────────────────────────────────
+
+#[gpui::test]
+async fn history_db_sub_thread_two_sub_threads_of_one_parent_are_two_items(
+    _cx: &mut TestAppContext,
+) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_sub_thread_two").await;
+    upsert(&db, &sub_record("p1", "sub-a", "Alpha", 1_000), "completed").await;
+    upsert(&db, &sub_record("p1", "sub-b", "Bravo", 2_000), "running").await;
+
+    let rows = load(&db).await;
+    assert_eq!(items_of(&rows, "p1"), 2, "one item per sub-thread");
+    let a = item(&rows, "p1", "sub-a").expect("sub-a is recorded");
+    let b = item(&rows, "p1", "sub-b").expect("sub-b is recorded");
+    assert_eq!(a.record.description.as_ref(), "Alpha");
+    assert_eq!(b.record.description.as_ref(), "Bravo");
+    assert_eq!(a.record.status, AgentTaskStatus::Completed);
+    assert_eq!(b.record.status, AgentTaskStatus::Running);
+}
+
+#[gpui::test]
+async fn history_db_sub_thread_session_named_like_an_adapter_task_is_a_separate_item(
+    _cx: &mut TestAppContext,
+) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_sub_thread_like_task").await;
+    db.insert_started(&record("p1", "s-sub", AgentTaskStatus::Running, 1_000))
+        .await
+        .expect("insert_started should succeed");
+    upsert(&db, &sub_record("p1", "s-sub", "Side", 2_000), "completed").await;
+
+    let rows = load(&db).await;
+    let task = find(&rows, "p1", "s-sub").expect("the adapter task is kept");
+    assert_eq!(task.origin, TaskOrigin::Adapter);
+    assert_eq!(task.record.description.as_ref(), "Task s-sub");
+    assert_eq!(task.record.status, AgentTaskStatus::Running);
+    let sub = item(&rows, "p1", "s-sub").expect("the sub-thread item is recorded beside it");
+    assert_eq!(sub.origin, TaskOrigin::SubThread);
+}
+
+// The derived key's third element: an adapter task already holding `zeo-sub-thread:<sub>`.
+#[gpui::test]
+async fn history_db_sub_thread_upsert_never_rewrites_an_adapter_row_on_its_key(
+    _cx: &mut TestAppContext,
+) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_sub_thread_third").await;
+    let squatter = format!("{PREFIX}s-sub");
+    db.insert_started(&record("p1", &squatter, AgentTaskStatus::Running, 1_000))
+        .await
+        .expect("insert_started should succeed");
+
+    // Whether the write is refused or ignored is the implementation's; the adapter row is not.
+    let _outcome = db
+        .upsert_sub_thread(&sub_record("p1", "s-sub", "Side", 2_000), "completed")
+        .await;
+
+    let rows = load(&db).await;
+    let task = find(&rows, "p1", &squatter).expect("the adapter row is still there");
+    assert_eq!(task.origin, TaskOrigin::Adapter, "still an adapter task");
+    assert_eq!(task.record.kind.as_ref(), "shell", "its kind is kept");
+    assert_eq!(
+        task.record.description.as_ref(),
+        format!("Task {squatter}"),
+        "its description is not replaced by the sub-thread's title"
+    );
+    assert_eq!(
+        task.record.status,
+        AgentTaskStatus::Running,
+        "its status is not replaced by the sub-thread's"
+    );
+}
+
+// ── record_sub_thread_link: description only (R5.2, R5.3, R5.5) ────────────
+
+#[gpui::test]
+async fn history_db_sub_thread_link_on_a_running_item_updates_only_the_description(
+    _cx: &mut TestAppContext,
+) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_sub_thread_link_running").await;
+    upsert(&db, &sub_record("p1", "sub-a", "Alpha", 1_000), "running").await;
+
+    // A second window's reporter links it again, then the title changes.
+    link_item(&db, &sub_record("p1", "sub-a", "Alpha", 2_000)).await;
+    link_item(&db, &sub_record("p1", "sub-a", "Alpha renamed", 3_000)).await;
+
+    let rows = load(&db).await;
+    assert_eq!(rows.len(), 1, "still one item: {rows:?}");
+    let entry = item(&rows, "p1", "sub-a").expect("recorded");
+    assert_eq!(
+        entry.record.status,
+        AgentTaskStatus::Running,
+        "a re-reported link never flips a running sub-thread to Idle"
+    );
+    assert_eq!(entry.record.ended_at_ms, None, "nor gives it an end");
+    assert_eq!(entry.record.started_at_ms, 1_000, "nor moves its start");
+    assert_eq!(entry.record.description.as_ref(), "Alpha renamed");
+}
+
+#[gpui::test]
+async fn history_db_sub_thread_link_never_rewrites_an_adapter_row_on_its_key(
+    _cx: &mut TestAppContext,
+) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_sub_thread_link_third").await;
+    let squatter = format!("{PREFIX}s-sub");
+    db.insert_started(&record("p1", &squatter, AgentTaskStatus::Running, 1_000))
+        .await
+        .expect("insert_started should succeed");
+
+    let _outcome = db
+        .record_sub_thread_link(&sub_record("p1", "s-sub", "Side", 2_000))
+        .await;
+
+    let rows = load(&db).await;
+    let task = find(&rows, "p1", &squatter).expect("the adapter row is still there");
+    assert_eq!(task.origin, TaskOrigin::Adapter);
+    assert_eq!(task.record.kind.as_ref(), "shell");
+    assert_eq!(task.record.description.as_ref(), format!("Task {squatter}"));
+    assert_eq!(task.record.status, AgentTaskStatus::Running);
+}
+
+#[gpui::test]
+async fn history_db_sub_thread_link_inserts_a_new_item_as_idle(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_sub_thread_link_new").await;
+    link_item(&db, &sub_record("p1", "sub-a", "Alpha", 1_000)).await;
+
+    let rows = load(&db).await;
+    let entry = item(&rows, "p1", "sub-a").expect("a new link is recorded");
+    assert_eq!(entry.origin, TaskOrigin::SubThread);
+    assert_eq!(entry.record.kind.as_ref(), "sub_thread");
+    assert_eq!(
+        entry.record.status,
+        AgentTaskStatus::Completed,
+        "stored as completed (Idle)"
+    );
+    assert_eq!(entry.record.description.as_ref(), "Alpha");
+}
+
+// ── Wrongly split (R5.2) ───────────────────────────────────────────────────
+
+#[gpui::test]
+async fn history_db_sub_thread_reported_again_stays_one_item(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_sub_thread_again").await;
+    let linked = sub_record("p1", "sub-a", "Alpha", 1_000);
+    upsert(&db, &linked, "completed").await;
+    // A restart, a second window: the same link reported again.
+    upsert(&db, &linked, "completed").await;
+    upsert(&db, &sub_record("p1", "sub-a", "Alpha", 2_000), "running").await;
+    upsert(&db, &sub_record("p1", "sub-a", "Alpha", 3_000), "completed").await;
+    // R5.5: a new title is the same item's new description.
+    upsert(
+        &db,
+        &sub_record("p1", "sub-a", "Alpha renamed", 4_000),
+        "completed",
+    )
+    .await;
+
+    let rows = load(&db).await;
+    assert_eq!(rows.len(), 1, "exactly one row: {rows:?}");
+    let only = item(&rows, "p1", "sub-a").expect("the item is recorded");
+    assert_eq!(only.record.description.as_ref(), "Alpha renamed");
+    assert_eq!(only.record.status, AgentTaskStatus::Completed);
+    assert!(
+        only.record.ended_at_ms.is_some(),
+        "an idle item carries when it stopped"
+    );
+}
+
+#[gpui::test]
+async fn history_db_sub_thread_upsert_keeps_the_archive_flag(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_sub_thread_archived").await;
+    upsert(&db, &sub_record("p1", "sub-a", "Alpha", 1_000), "completed").await;
+    db.set_archived(&[key("p1", &format!("{PREFIX}sub-a"))], true)
+        .await
+        .expect("set_archived should succeed");
+
+    upsert(&db, &sub_record("p1", "sub-a", "Alpha", 2_000), "running").await;
+
+    let rows = load(&db).await;
+    assert!(
+        item(&rows, "p1", "sub-a").expect("recorded").archived,
+        "a re-report never unarchives the item"
+    );
+}
+
+// ── The item's shape (R5.1) ────────────────────────────────────────────────
+
+#[gpui::test]
+async fn history_db_sub_thread_item_is_grouped_under_the_parent_with_the_title(
+    _cx: &mut TestAppContext,
+) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_sub_thread_shape").await;
+    upsert(&db, &sub_record("p1", "sub-a", "Alpha", 1_000), "running").await;
+
+    let rows = load(&db).await;
+    let entry = item(&rows, "p1", "sub-a").expect("recorded under the parent's session");
+    assert_eq!(entry.origin, TaskOrigin::SubThread);
+    assert_eq!(entry.record.kind.as_ref(), "sub_thread");
+    assert_eq!(entry.record.description.as_ref(), "Alpha");
+    assert_eq!(entry.record.project.as_ref(), "project");
+    assert_eq!(entry.record.started_at_ms, 1_000);
+    assert_eq!(entry.record.status, AgentTaskStatus::Running);
+    assert!(!entry.archived);
+    assert_eq!(entry.log_bytes, None, "a sub-thread item has no log");
+}
+
+// ── Failure (R5.8, Q4) ─────────────────────────────────────────────────────
+
+#[gpui::test]
+async fn history_db_sub_thread_failed_write_names_both_sessions(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_sub_thread_failure").await;
+    drop_history_table(&db).await;
+
+    let error = db
+        .upsert_sub_thread(
+            &sub_record("p-fail", "sub-fail", "Secret title", 1_000),
+            "running",
+        )
+        .await
+        .expect_err("the table is gone");
+    let text = format!("{error:#}");
+    assert!(text.contains("p-fail"), "names the parent session: {text}");
+    assert!(
+        text.contains("sub-fail"),
+        "names the sub-thread session: {text}"
+    );
+    assert!(!text.contains("Secret title"), "never the title: {text}");
+}
diff --git a/crates/agent_tasks_panel/src/history_db/subagent.rs b/crates/agent_tasks_panel/src/history_db/subagent.rs
new file mode 100644
index 0000000..a7fd361
--- /dev/null
+++ b/crates/agent_tasks_panel/src/history_db/subagent.rs
@@ -0,0 +1,66 @@
+//! Story 064: the write of a native ACP subagent record. Like a sub-thread item it is recorded
+//! under its parent's session with a task id of its own (`acp-subagent:<child>`), so it groups,
+//! archives and deletes through the same `TaskKey` paths; the child itself is never looked up in
+//! the thread metadata store, which does not hold children.
+
+use agent_client_protocol::v2::schema as acp_v2;
+use agent_ui::TaskRecord;
+use anyhow::{Context as _, Result};
+use db::sqlez_macros::sql;
+
+use super::{AgentTaskHistoryDb, TaskOrigin, status_text};
+
+impl AgentTaskHistoryDb {
+    /// Records a native subagent row, or refreshes its outcome on a repeat (R2.1). `record`
+    /// already carries the row's task id. A conflict never touches `archived`, and never a row
+    /// another origin wrote on the same key: an adapter may name a task `acp-subagent:…` too,
+    /// and the stored origin, not the id, decides what a row is.
+    pub async fn record_subagent(
+        &self,
+        record: &TaskRecord,
+        child: &acp_v2::SessionId,
+    ) -> Result<()> {
+        let ids = format!(
+            "subagent {child} (task {}) of session {}",
+            record.task_id, record.session_id
+        );
+        // Nested because `sqlez` binds tuples of at most ten values.
+        let identity = (
+            record.session_id.to_string(),
+            record.task_id.to_string(),
+            record.thread_title.as_ref().map(|title| title.to_string()),
+            record.project.to_string(),
+            record.kind.to_string(),
+            record.description.to_string(),
+            record.background,
+        );
+        let state = (
+            status_text(record.status),
+            record.started_at_ms,
+            record.ended_at_ms,
+            record.tokens,
+            record.tool_uses,
+            record.tool_call_id.as_ref().map(|id| id.to_string()),
+            TaskOrigin::AcpSubagent.as_text(),
+            child.to_string(),
+        );
+        self.write(move |connection| {
+            connection.exec_bound(sql!(
+                INSERT INTO agent_task_history(
+                    session_id, task_id, thread_title, project, kind, description, background,
+                    status, started_at, ended_at, tokens, tool_uses, tool_call_id, origin,
+                    child_session_id
+                ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
+                ON CONFLICT(session_id, task_id) DO UPDATE SET
+                    status = excluded.status,
+                    description = excluded.description,
+                    ended_at = excluded.ended_at,
+                    tokens = excluded.tokens,
+                    tool_uses = excluded.tool_uses
+                WHERE agent_task_history.origin = ?14 AND agent_task_history.deleted = 0
+            ))?((identity, state))
+        })
+        .await
+        .with_context(|| format!("recording {ids}"))
+    }
+}
diff --git a/crates/agent_tasks_panel/src/history_db/subagent_real_copy_tests.rs b/crates/agent_tasks_panel/src/history_db/subagent_real_copy_tests.rs
new file mode 100644
index 0000000..00f777a
--- /dev/null
+++ b/crates/agent_tasks_panel/src/history_db/subagent_real_copy_tests.rs
@@ -0,0 +1,139 @@
+// Story 064, task 3.2 — step 7 applied to a scratch copy of the operator's real history database
+// changes no existing row (R2.2; Q5, "the history migration applies on the operator's real
+// `agent_task_history` copy with 0 row diffs"). Module path: `history_db::subagent_real_copy`.
+// Wired in `src/history_db.rs`, after `mod subagent_record;`, as
+//
+//     #[cfg(test)]
+//     #[path = "history_db/subagent_real_copy_tests.rs"]
+//     mod subagent_real_copy;
+//
+// IGNORED by default: it needs the operator's data, which no fixture may carry. It runs only
+// through `zed-patches/scripts/proofs/064-native-subagents/history-real-copy.sh`, which makes the
+// scratch copy (`sqlite3 .backup` from a read-only connection), points `ZEO_064_HISTORY_DB_COPY`
+// at it, runs this test with `--ignored`, and diffs the copy again with sqlite3 on its own — two
+// independent readers of the same before/after. The synthetic 0.7.0 fixture of task 3.1
+// (`subagent_step_tests.rs`) is what the ordinary suite runs.
+//
+// Real migrator, real list: the copy is migrated with `AgentTaskHistoryDb::MIGRATIONS` through
+// sqlez's `Connection::migrate`, against the six steps the operator's 0.7.0 build recorded in its
+// `migrations` table — so an edited shipped step fails here with sqlez's own "Migration changed"
+// error before any row is compared.
+//
+// Hostile first: the copy is refused when the path is under Zeo's live data directory; every one
+// of the 18 pre-existing columns of every row (as SQLite's `quote()`, so storage classes count)
+// must be identical after the step (origin included); only then the defaults: no row reads
+// `acp_subagent`, every row reads `child_session_id IS NULL`, and there is no `source` column.
+
+use db::sqlez::{connection::Connection, domain::Domain};
+
+use crate::AgentTaskHistoryDb;
+
+const COPY_ENV: &str = "ZEO_064_HISTORY_DB_COPY";
+const NATIVE_ORIGIN: &str = "acp_subagent";
+
+/// The 0.7.0 columns, in table order (the operator's schema, read 2026-10-29).
+const REAL_COPY_COLUMNS: [&str; 18] = [
+    "session_id",
+    "task_id",
+    "thread_title",
+    "project",
+    "kind",
+    "description",
+    "background",
+    "status",
+    "started_at",
+    "ended_at",
+    "tokens",
+    "tool_uses",
+    "archived",
+    "label",
+    "tool_call_id",
+    "log_bytes",
+    "origin",
+    "deleted",
+];
+
+fn real_copy_rows(connection: &Connection) -> Vec<String> {
+    let quoted = REAL_COPY_COLUMNS
+        .iter()
+        .map(|column| format!("quote({column})"))
+        .collect::<Vec<_>>()
+        .join(" || '|' || ");
+    connection
+        .select::<String>(&format!(
+            "SELECT {quoted} FROM agent_task_history ORDER BY session_id, task_id"
+        ))
+        .expect("select")()
+    .expect("run")
+}
+
+#[test]
+#[ignore = "operator data: run through zed-patches/scripts/proofs/064-native-subagents/history-real-copy.sh"]
+fn history_db_subagent_step_real_copy_keeps_every_row() {
+    let path = std::env::var(COPY_ENV)
+        .unwrap_or_else(|_| panic!("{COPY_ENV} names the scratch copy; the proof script sets it"));
+    assert!(
+        !path.contains("/.local/share/zeo/"),
+        "never the live database, only a scratch copy of it: {path}"
+    );
+    let connection = Connection::open_file(&path);
+    let before = real_copy_rows(&connection);
+    assert!(
+        !before.is_empty(),
+        "precondition: the copy holds the operator's history"
+    );
+
+    connection
+        .migrate(
+            AgentTaskHistoryDb::NAME,
+            AgentTaskHistoryDb::MIGRATIONS,
+            &mut |_, _, _| false,
+        )
+        .expect("step 7 applies after the six steps the 0.7.0 build recorded");
+
+    let columns = connection
+        .select::<String>("SELECT name FROM pragma_table_info('agent_task_history')")
+        .expect("select")()
+    .expect("run");
+    assert!(
+        columns.iter().any(|name| name == "child_session_id"),
+        "step 7 adds `child_session_id`: {columns:?}"
+    );
+    assert!(
+        !columns.iter().any(|name| name == "source"),
+        "no `source` column (revision 1: the origin column says who produced a row): {columns:?}"
+    );
+    let after = real_copy_rows(&connection);
+    assert_eq!(
+        after.len(),
+        before.len(),
+        "no row is added or dropped ({} before)",
+        before.len()
+    );
+    let changed = before
+        .iter()
+        .zip(&after)
+        .filter(|(old, new)| old != new)
+        .count();
+    let first = before.iter().zip(&after).find(|(old, new)| old != new);
+    assert_eq!(
+        changed, 0,
+        "every pre-existing value reads back unchanged; first difference: {first:?}"
+    );
+
+    let defaults = connection
+        .select::<(i64, i64, i64)>(&format!(
+            "SELECT COUNT(*), \
+                    COALESCE(SUM(origin = '{NATIVE_ORIGIN}'), 0), \
+                    COALESCE(SUM(child_session_id IS NULL), 0) \
+             FROM agent_task_history"
+        ))
+        .expect("select")()
+    .expect("run");
+    let total = before.len() as i64;
+    assert_eq!(
+        defaults,
+        vec![(total, 0, total)],
+        "(rows, rows with origin acp_subagent, rows with no child): no old row became native"
+    );
+}
diff --git a/crates/agent_tasks_panel/src/history_db/subagent_record_tests.rs b/crates/agent_tasks_panel/src/history_db/subagent_record_tests.rs
new file mode 100644
index 0000000..ec39f73
--- /dev/null
+++ b/crates/agent_tasks_panel/src/history_db/subagent_record_tests.rs
@@ -0,0 +1,381 @@
+// Story 064, task 3.1 (write half) — a native subagent row's terminal transition is recorded under
+// its parent as `acp-subagent:<child>`, with origin `acp_subagent` and its child (R2.1, R2.2); it
+// survives a restart and archives and deletes like any other record (R2.3). Module path:
+// `history_db::subagent_record`. Wired in `src/history_db.rs`, after `mod subagent_step;`, as
+//
+//     #[cfg(test)]
+//     #[path = "history_db/subagent_record_tests.rs"]
+//     mod subagent_record;
+//
+// Validation filter: `cargo test -p agent_tasks_panel history_db` (names carry
+// `history_db_subagent_record`).
+//
+// Contract (design.md as revised by the operator, 2026-10-29, revision 1): the row is keyed
+// `(parent session id, "acp-subagent:<child session id>")`, `kind = "subagent"`, `origin =
+// 'acp_subagent'` (`TaskOrigin::AcpSubagent`, a third value of story 040's column, bound through
+// `as_text()`), `child_session_id = <child>` (step 7's only column); there is no `source` column;
+// the key stays `(session_id, task_id)`; a feed task keeps origin `adapter` and no child. The
+// write is `AgentTaskHistoryDb::record_subagent(&self, record: &TaskRecord, child:
+// &acp_v2::SessionId) -> anyhow::Result<()>` (operator, 2026-10-29), async like every writer here.
+// Assumed beyond that (adjust in `record_native` only): one upsert of `(record.session_id,
+// "acp-subagent:<child>")` (the record handed in already carries that task id) that writes origin
+// and child, refreshes status and end on a repeat, never touches `archived`, and never rewrites a
+// row of another origin on the same key — the guard `upsert_sub_thread` has for its own key;
+// `insert_started` keeps writing no origin, so a feed task takes the column default `adapter`.
+// The stored origin text and child are read straight from the table (`stored`); `load_all` must
+// also parse the new origin (`HistoryEntry::origin == TaskOrigin::AcpSubagent`), or it would skip
+// the row as unreadable.
+//
+// Hostile halves first:
+//   - a THIRD element on the derived key: an adapter task whose id is literally
+//     `acp-subagent:c-1` must keep its row, its text and its `adapter` origin when child c-1's
+//     ending is recorded — an id's spelling never decides the origin; a sub-thread item of the
+//     same session id is a separate row;
+//   - wrongly collapse: two children sharing a label are two rows; the same child id under two
+//     parents is two rows;
+//   - wrongly split: the same child ending twice is one row, holding the later outcome;
+//   - then the benign record, the restart, archive and delete.
+
+use acp_thread::AgentTaskStatus;
+use agent_client_protocol::v2::schema as acp_v2;
+use agent_ui::{SubThreadRecord, TaskRecord};
+use gpui::TestAppContext;
+
+use crate::history_db::TaskOrigin;
+use crate::test_helpers::*;
+use crate::{AgentTaskHistoryDb, HistoryEntry};
+
+const NATIVE_PREFIX: &str = "acp-subagent:";
+const FEED_ORIGIN: &str = "adapter";
+const NATIVE_ORIGIN: &str = "acp_subagent";
+
+fn native_task_id(child: &str) -> String {
+    format!("{NATIVE_PREFIX}{child}")
+}
+
+/// What the tasks card hands the history for a native row that reached `status`.
+fn native_record(
+    parent: &str,
+    child: &str,
+    label: &str,
+    status: AgentTaskStatus,
+    at_ms: i64,
+) -> TaskRecord {
+    let mut native = record(parent, &native_task_id(child), status, at_ms);
+    native.kind = "subagent".into();
+    native.description = label.to_string().into();
+    native.tool_call_id = None;
+    native
+}
+
+async fn record_native(db: &AgentTaskHistoryDb, native: &TaskRecord, child: &str) {
+    db.record_subagent(native, &acp_v2::SessionId::new(child.to_string()))
+        .await
+        .expect("record_subagent should succeed");
+}
+
+/// `(origin, child_session_id)` of one stored row, or `None` when the row does not exist.
+/// Asserts step 7's columns first, so a missing column fails here and not inside a query.
+async fn stored(
+    db: &AgentTaskHistoryDb,
+    parent: &str,
+    task: &str,
+) -> Option<(String, Option<String>)> {
+    let columns = db
+        .write(|connection| {
+            connection
+                .select::<String>("SELECT name FROM pragma_table_info('agent_task_history')")
+                .expect("select")()
+            .expect("run")
+        })
+        .await;
+    assert!(
+        columns.iter().any(|name| name == "child_session_id"),
+        "step 7 adds `child_session_id`: {columns:?}"
+    );
+    assert!(
+        !columns.iter().any(|name| name == "source"),
+        "no `source` column: the origin says who produced a row: {columns:?}"
+    );
+    let (parent, task) = (parent.to_string(), task.to_string());
+    let query = "SELECT origin, child_session_id FROM agent_task_history \
+                 WHERE session_id = ? AND task_id = ?";
+    db.write(move |connection| {
+        connection
+            .select_bound::<(&str, &str), (String, Option<String>)>(&query)
+            .expect("select")((parent.as_str(), task.as_str()))
+        .expect("run")
+        .into_iter()
+        .next()
+    })
+    .await
+}
+
+fn native_entries<'a>(rows: &'a [HistoryEntry], parent: &str) -> Vec<&'a HistoryEntry> {
+    rows.iter()
+        .filter(|entry| {
+            entry.record.session_id.to_string() == parent
+                && entry.record.task_id.starts_with(NATIVE_PREFIX)
+        })
+        .collect()
+}
+
+// ── Hostile: a third element on the derived key ────────────────────────────
+
+#[gpui::test]
+async fn history_db_subagent_record_never_rewrites_a_feed_row_on_its_key(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_subagent_record_squatter").await;
+    let squatter = native_task_id("c-1");
+    db.insert_started(&record("p1", &squatter, AgentTaskStatus::Running, 1_000))
+        .await
+        .expect("insert_started should succeed");
+    assert_eq!(
+        stored(&db, "p1", &squatter).await,
+        Some((FEED_ORIGIN.to_string(), None)),
+        "a feed task is an adapter row whatever its id spells"
+    );
+
+    // Refused or ignored is the implementation's; the feed row is not to be rewritten.
+    let _outcome = db
+        .record_subagent(
+            &native_record("p1", "c-1", "Explore", AgentTaskStatus::Completed, 2_000),
+            &acp_v2::SessionId::new("c-1"),
+        )
+        .await;
+
+    assert_eq!(
+        stored(&db, "p1", &squatter).await,
+        Some((FEED_ORIGIN.to_string(), None)),
+        "the feed row keeps its origin and names no child"
+    );
+    let rows = load(&db).await;
+    let task = find(&rows, "p1", &squatter).expect("the feed row is still there");
+    assert_eq!(task.record.description.as_ref(), format!("Task {squatter}"));
+    assert_eq!(task.record.status, AgentTaskStatus::Running);
+    assert_eq!(task.record.kind.as_ref(), "shell");
+    assert_eq!(task.origin, TaskOrigin::Adapter);
+}
+
+#[gpui::test]
+async fn history_db_subagent_record_and_a_sub_thread_of_one_session_id_are_two_rows(
+    _cx: &mut TestAppContext,
+) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_subagent_record_vs_sub_thread").await;
+    db.upsert_sub_thread(
+        &SubThreadRecord {
+            parent_session: sid("p1"),
+            sub_session: sid("c-1"),
+            title: "A sub-thread".into(),
+            project: "project".into(),
+            at_ms: 1_000,
+        },
+        "completed",
+    )
+    .await
+    .expect("upsert_sub_thread should succeed");
+    record_native(
+        &db,
+        &native_record("p1", "c-1", "Explore", AgentTaskStatus::Completed, 2_000),
+        "c-1",
+    )
+    .await;
+
+    let rows = load(&db).await;
+    assert!(
+        find(&rows, "p1", "zeo-sub-thread:c-1").is_some(),
+        "the sub-thread item is kept"
+    );
+    assert_eq!(
+        native_entries(&rows, "p1").len(),
+        1,
+        "and the subagent is its own row"
+    );
+    assert_eq!(
+        stored(&db, "p1", &native_task_id("c-1")).await,
+        Some((NATIVE_ORIGIN.to_string(), Some("c-1".to_string())))
+    );
+}
+
+// ── Wrongly collapse ───────────────────────────────────────────────────────
+
+#[gpui::test]
+async fn history_db_subagent_record_children_sharing_a_label_or_an_id_stay_apart(
+    _cx: &mut TestAppContext,
+) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_subagent_record_apart").await;
+    for (parent, child) in [("p1", "c-a"), ("p1", "c-b"), ("p2", "c-a")] {
+        record_native(
+            &db,
+            &native_record(parent, child, "Explore", AgentTaskStatus::Completed, 1_000),
+            child,
+        )
+        .await;
+    }
+
+    let rows = load(&db).await;
+    assert_eq!(
+        native_entries(&rows, "p1").len(),
+        2,
+        "one row per child, not per label"
+    );
+    assert_eq!(
+        native_entries(&rows, "p2").len(),
+        1,
+        "the same child id under another parent is another row"
+    );
+    for (parent, child) in [("p1", "c-a"), ("p1", "c-b"), ("p2", "c-a")] {
+        assert_eq!(
+            stored(&db, parent, &native_task_id(child)).await,
+            Some((NATIVE_ORIGIN.to_string(), Some(child.to_string()))),
+            "{parent}/{child} names its own child"
+        );
+    }
+}
+
+// ── Wrongly split ──────────────────────────────────────────────────────────
+
+#[gpui::test]
+async fn history_db_subagent_record_one_child_ending_twice_is_one_row(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_subagent_record_twice").await;
+    record_native(
+        &db,
+        &native_record("p1", "c-1", "Explore", AgentTaskStatus::Completed, 1_000),
+        "c-1",
+    )
+    .await;
+    record_native(
+        &db,
+        &native_record(
+            "p1",
+            "c-1",
+            "Explore, renamed",
+            AgentTaskStatus::Failed,
+            5_000,
+        ),
+        "c-1",
+    )
+    .await;
+
+    let rows = load(&db).await;
+    let natives = native_entries(&rows, "p1");
+    assert_eq!(natives.len(), 1, "one child is one row: {natives:?}");
+    assert_eq!(
+        natives[0].record.status,
+        AgentTaskStatus::Failed,
+        "the later outcome is the one kept"
+    );
+}
+
+// ── Benign: record, restart, archive, delete (R2.1, R2.3) ──────────────────
+
+#[gpui::test]
+async fn history_db_subagent_record_writes_key_origin_and_child(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_subagent_record_fresh").await;
+    record_native(
+        &db,
+        &native_record(
+            "p1",
+            "c-1",
+            "Explore the parser",
+            AgentTaskStatus::Stopped,
+            1_000,
+        ),
+        "c-1",
+    )
+    .await;
+
+    let rows = load(&db).await;
+    let entry = find(&rows, "p1", "acp-subagent:c-1").expect("keyed by parent and child");
+    assert_eq!(entry.record.description.as_ref(), "Explore the parser");
+    assert_eq!(entry.record.kind.as_ref(), "subagent");
+    assert_eq!(entry.record.status, AgentTaskStatus::Stopped);
+    assert!(!entry.archived);
+    assert_eq!(
+        entry.origin,
+        TaskOrigin::AcpSubagent,
+        "load_all reads the new origin instead of skipping the row"
+    );
+    assert_eq!(
+        stored(&db, "p1", "acp-subagent:c-1").await,
+        Some((NATIVE_ORIGIN.to_string(), Some("c-1".to_string())))
+    );
+}
+
+// Restart: the startup sweep runs, and the recorded row is listed as it ended.
+#[gpui::test]
+async fn history_db_subagent_record_is_listed_after_a_restart(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_subagent_record_restart").await;
+    record_native(
+        &db,
+        &native_record("p1", "c-1", "Explore", AgentTaskStatus::Completed, 1_000),
+        "c-1",
+    )
+    .await;
+    db.insert_started(&record("p1", "t-running", AgentTaskStatus::Running, 2_000))
+        .await
+        .expect("insert_started should succeed");
+
+    db.mark_running_interrupted()
+        .await
+        .expect("the startup sweep should succeed");
+
+    let rows = load(&db).await;
+    let entry = find(&rows, "p1", "acp-subagent:c-1").expect("listed after the restart");
+    assert_eq!(
+        entry.record.status,
+        AgentTaskStatus::Completed,
+        "an ended row is left as it ended"
+    );
+    assert_eq!(entry.origin, TaskOrigin::AcpSubagent);
+    assert_eq!(
+        find(&rows, "p1", "t-running").map(|entry| entry.record.status),
+        Some(AgentTaskStatus::Interrupted),
+        "precondition: the sweep did run"
+    );
+}
+
+#[gpui::test]
+async fn history_db_subagent_record_archives_and_deletes_like_any_record(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_subagent_record_archive").await;
+    let native_key = key("p1", "acp-subagent:c-1");
+    record_native(
+        &db,
+        &native_record("p1", "c-1", "Explore", AgentTaskStatus::Completed, 1_000),
+        "c-1",
+    )
+    .await;
+    // A feed task whose id is the bare child id: the delete must not reach it.
+    db.insert_started(&record("p1", "c-1", AgentTaskStatus::Completed, 2_000))
+        .await
+        .expect("insert_started should succeed");
+
+    db.set_archived(std::slice::from_ref(&native_key), true)
+        .await
+        .expect("set_archived should succeed");
+    record_native(
+        &db,
+        &native_record("p1", "c-1", "Explore", AgentTaskStatus::Failed, 3_000),
+        "c-1",
+    )
+    .await;
+    let rows = load(&db).await;
+    assert!(
+        find(&rows, "p1", "acp-subagent:c-1").is_some_and(|entry| entry.archived),
+        "a later ending of the child never unarchives its record"
+    );
+
+    let removed = db
+        .delete(std::slice::from_ref(&native_key))
+        .await
+        .expect("delete should succeed");
+    assert_eq!(removed, vec![0], "the archived record is deleted");
+    let rows = load(&db).await;
+    assert!(
+        find(&rows, "p1", "acp-subagent:c-1").is_none(),
+        "a deleted record is no longer listed"
+    );
+    assert!(
+        find(&rows, "p1", "c-1").is_some(),
+        "the feed task on the bare child id is untouched"
+    );
+}
diff --git a/crates/agent_tasks_panel/src/history_db/subagent_step_tests.rs b/crates/agent_tasks_panel/src/history_db/subagent_step_tests.rs
new file mode 100644
index 0000000..0fa9d7c
--- /dev/null
+++ b/crates/agent_tasks_panel/src/history_db/subagent_step_tests.rs
@@ -0,0 +1,352 @@
+// Story 064, task 3.1 (migration half) — step 7 of `HISTORY_MIGRATIONS`: `child_session_id`
+// (R2.2; Q5). Module path: `history_db::subagent_step`. Wired in `src/history_db.rs`, after
+// `mod tombstone_step;`, as
+//
+//     #[cfg(test)]
+//     #[path = "history_db/subagent_step_tests.rs"]
+//     mod subagent_step;
+//
+// Validation filter: `cargo test -p agent_tasks_panel history_db` (names carry
+// `history_db_subagent_step`). The writes are in `subagent_record_tests.rs`.
+//
+// Contract (design.md §Data Models as revised by the operator, 2026-10-29, revision 1; story.md
+// R2.2, Constraints):
+//   - `HISTORY_MIGRATIONS` becomes length 7: step 7 (index 6) is appended after the six shipped
+//     steps, which stay byte-identical, and adds ONLY a nullable `child_session_id TEXT` —
+//     `sql!(ALTER TABLE agent_task_history ADD COLUMN child_session_id TEXT;)`, pinned below;
+//   - there is no `source` column: who produced a row stays story 040's `origin` column, which
+//     gains a third value `acp_subagent` (`TaskOrigin::AcpSubagent`, bound through `as_text()`
+//     as `adapter` and `sub_thread` are — never a SQL literal) for native subagent records;
+//   - every row older than step 7 keeps its origin byte for byte and reads `child_session_id IS
+//     NULL`;
+//   - the key stays `PRIMARY KEY(session_id, task_id)`.
+//
+// Hostile halves first:
+//   - a real 0.7.0 database (`test_data/history_0_7_0.sql`: the operator's schema and stored
+//     migration texts, synthetic rows) keeps every pre-existing value byte for byte — including
+//     an adapter task whose id already starts with `acp-subagent:`, which must keep origin
+//     `adapter`: an id's spelling never decides the origin;
+//   - the key is still the pair: the same pair twice is refused (a second origin never buys a
+//     second row), and the same task id under two parents is two rows;
+//   - then the step's own pin.
+
+use db::sqlez::{connection::Connection, domain::Domain};
+use db::sqlez_macros::sql;
+
+use crate::AgentTaskHistoryDb;
+
+const NATIVE_ORIGIN: &str = "acp_subagent";
+const CHILD_COLUMN: &str = "child_session_id";
+
+/// Every column a 0.7.0 database has, in table order: what step 7 must leave untouched.
+const COLUMNS_0_7_0: [&str; 18] = [
+    "session_id",
+    "task_id",
+    "thread_title",
+    "project",
+    "kind",
+    "description",
+    "background",
+    "status",
+    "started_at",
+    "ended_at",
+    "tokens",
+    "tool_uses",
+    "archived",
+    "label",
+    "tool_call_id",
+    "log_bytes",
+    "origin",
+    "deleted",
+];
+
+const HISTORY_0_7_0: &str = include_str!("test_data/history_0_7_0.sql");
+
+// The six shipped steps, byte for byte (as `tombstone_step_tests.rs` copies steps 1–5, and step 6
+// as story 040's task 11.1 shipped it).
+const SHIPPED_STEP_0: &str = sql!(
+    CREATE TABLE IF NOT EXISTS agent_task_history(
+        session_id TEXT NOT NULL,
+        task_id TEXT NOT NULL,
+        thread_title TEXT,
+        project TEXT NOT NULL,
+        kind TEXT NOT NULL,
+        description TEXT NOT NULL,
+        background INTEGER NOT NULL,
+        status TEXT NOT NULL,
+        started_at INTEGER NOT NULL,
+        ended_at INTEGER,
+        tokens INTEGER,
+        tool_uses INTEGER,
+        archived INTEGER NOT NULL DEFAULT 0,
+        PRIMARY KEY(session_id, task_id)
+    ) STRICT;
+    CREATE INDEX IF NOT EXISTS agent_task_history_started ON agent_task_history(started_at);
+);
+const SHIPPED_STEP_1: &str = sql!(ALTER TABLE agent_task_history ADD COLUMN label TEXT;);
+const SHIPPED_STEP_2: &str = sql!(ALTER TABLE agent_task_history ADD COLUMN tool_call_id TEXT;);
+const SHIPPED_STEP_3: &str = sql!(ALTER TABLE agent_task_history ADD COLUMN log_bytes INTEGER;);
+const SHIPPED_STEP_4: &str =
+    sql!(ALTER TABLE agent_task_history ADD COLUMN origin TEXT NOT NULL DEFAULT "adapter";);
+const SHIPPED_STEP_5: &str =
+    sql!(ALTER TABLE agent_task_history ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;);
+const CHILD_STEP: &str = sql!(ALTER TABLE agent_task_history ADD COLUMN child_session_id TEXT;);
+
+fn subagent_step_migrate(connection: &Connection, steps: &[&'static str]) {
+    connection
+        .migrate(AgentTaskHistoryDb::NAME, steps, &mut |_, _, _| false)
+        .expect("the history migrations should apply");
+}
+
+/// One fixed SQL text of this test, through sqlez (a SQL statement, not a process).
+fn subagent_step_sql(connection: &Connection, statement: &str) {
+    let mut run = connection.exec(statement).expect("statement prepares");
+    run().expect("statement runs");
+}
+
+fn subagent_step_columns(connection: &Connection) -> Vec<String> {
+    connection
+        .select::<String>("SELECT name FROM pragma_table_info('agent_task_history')")
+        .expect("select")()
+    .expect("run")
+}
+
+/// Fails at an assertion, not at a query, when step 7 is missing.
+fn subagent_step_assert_columns(connection: &Connection) {
+    let columns = subagent_step_columns(connection);
+    assert!(
+        columns.iter().any(|name| name == CHILD_COLUMN),
+        "step 7 adds `{CHILD_COLUMN}`: {columns:?}"
+    );
+    assert!(
+        !columns.iter().any(|name| name == "source"),
+        "no `source` column: the origin column says who produced a row: {columns:?}"
+    );
+}
+
+/// Each row as one string of SQLite's own `quote()` of every 0.7.0 column, in key order: equal
+/// strings mean equal values AND equal storage classes, which is what byte-identical means here.
+fn subagent_step_rows_0_7_0(connection: &Connection) -> Vec<String> {
+    let quoted = COLUMNS_0_7_0
+        .iter()
+        .map(|column| format!("quote({column})"))
+        .collect::<Vec<_>>()
+        .join(" || '|' || ");
+    connection
+        .select::<String>(&format!(
+            "SELECT {quoted} FROM agent_task_history ORDER BY session_id, task_id"
+        ))
+        .expect("select")()
+    .expect("run")
+}
+
+/// sqlez prepares every statement of a text before running the first, so the fixture's
+/// `INSERT INTO migrations` would be prepared before its `CREATE TABLE migrations` ran. One
+/// statement at a time: a statement ends at a `;` closing a line outside any string literal.
+fn subagent_step_load_0_7_0(name: &str) -> Connection {
+    let connection = Connection::open_memory(Some(name));
+    let mut statement = String::new();
+    for line in HISTORY_0_7_0.lines() {
+        if statement.is_empty() && line.starts_with("--") {
+            continue;
+        }
+        statement.push_str(line);
+        statement.push('\n');
+        let inside_literal = statement.matches('\'').count() % 2 == 1;
+        if line.trim_end().ends_with(';') && !inside_literal {
+            subagent_step_sql(&connection, &statement);
+            statement.clear();
+        }
+    }
+    assert!(
+        statement.trim().is_empty(),
+        "the fixture ends with a statement"
+    );
+    connection
+}
+
+// ── Hostile: a real 0.7.0 database ─────────────────────────────────────────
+
+#[test]
+fn history_db_subagent_step_0_7_0_database_keeps_every_value_byte_for_byte() {
+    let connection = subagent_step_load_0_7_0("history_db_subagent_step_bytes");
+    let before = subagent_step_rows_0_7_0(&connection);
+    assert_eq!(before.len(), 9, "precondition: the fixture's nine rows");
+
+    subagent_step_migrate(&connection, AgentTaskHistoryDb::MIGRATIONS);
+    subagent_step_migrate(&connection, AgentTaskHistoryDb::MIGRATIONS);
+
+    subagent_step_assert_columns(&connection);
+    assert_eq!(
+        subagent_step_rows_0_7_0(&connection),
+        before,
+        "step 7 changes no pre-existing value (origin included), nor any storage class, and adds \
+         or drops no row"
+    );
+    let steps = connection
+        .select_bound::<&str, i64>("SELECT COUNT(*) FROM migrations WHERE domain = ?")
+        .expect("select")(AgentTaskHistoryDb::NAME)
+    .expect("run");
+    assert_eq!(
+        steps,
+        vec![AgentTaskHistoryDb::MIGRATIONS.len() as i64],
+        "the stored six steps were accepted as already run and step 7 was recorded once"
+    );
+}
+
+#[test]
+fn history_db_subagent_step_every_old_row_keeps_its_origin_and_names_no_child() {
+    let connection = subagent_step_load_0_7_0("history_db_subagent_step_defaults");
+    subagent_step_migrate(&connection, AgentTaskHistoryDb::MIGRATIONS);
+    subagent_step_assert_columns(&connection);
+
+    let rows = connection
+        .select::<(String, String, String, Option<String>)>(
+            "SELECT session_id, task_id, origin, child_session_id \
+             FROM agent_task_history ORDER BY session_id, task_id",
+        )
+        .expect("select")()
+    .expect("run");
+    let origins: Vec<(String, String)> = rows
+        .iter()
+        .map(|(_, task, origin, _)| (task.clone(), origin.clone()))
+        .collect();
+    assert_eq!(
+        origins,
+        [
+            ("a7f00d", "adapter"),
+            ("a7f00e", "adapter"),
+            ("b1a2c3", "adapter"),
+            ("w1", "adapter"),
+            ("acp-subagent:c-legacy", "adapter"),
+            ("b1a2c3", "adapter"),
+            ("u1", "adapter"),
+            ("zeo-sub-thread:s-3a", "sub_thread"),
+            ("zeo-sub-thread:s-3b", "sub_thread"),
+        ]
+        .map(|(task, origin)| (task.to_string(), origin.to_string())),
+        "every old row keeps its origin; an `acp-subagent:` id stays `adapter`"
+    );
+    for (session, task, _, child) in rows {
+        assert_eq!(
+            child, None,
+            "{session}/{task}: a row older than step 7 names no child"
+        );
+    }
+}
+
+// ── Hostile: the key is still the pair ─────────────────────────────────────
+
+#[test]
+fn history_db_subagent_step_primary_key_is_still_session_and_task() {
+    let connection = Connection::open_memory(Some("history_db_subagent_step_pk"));
+    subagent_step_migrate(&connection, AgentTaskHistoryDb::MIGRATIONS);
+    subagent_step_assert_columns(&connection);
+
+    let key_columns = connection
+        .select::<(String, i64)>(
+            "SELECT name, pk FROM pragma_table_info('agent_task_history') WHERE pk > 0 ORDER BY pk",
+        )
+        .expect("select")()
+    .expect("run");
+    assert_eq!(
+        key_columns,
+        vec![("session_id".to_string(), 1), ("task_id".to_string(), 2)],
+        "the key is (session_id, task_id) and nothing else"
+    );
+
+    let insert = |session: &str, task: &str, origin: &str, child: &str| {
+        let statement = format!(
+            "INSERT INTO agent_task_history \
+             (session_id, task_id, project, kind, description, background, status, started_at, \
+              origin, child_session_id) \
+             VALUES ('{session}', '{task}', 'zeo', 'subagent', 'Explore', 0, 'completed', 1000, \
+                     '{origin}', {child})"
+        );
+        connection.exec(&statement).and_then(|mut run| run())
+    };
+    insert("p-1", "acp-subagent:c-1", NATIVE_ORIGIN, "'c-1'").expect("a native row");
+    assert!(
+        insert("p-1", "acp-subagent:c-1", "adapter", "NULL").is_err(),
+        "a second origin never buys a second row for the same pair"
+    );
+    insert("p-2", "acp-subagent:c-1", NATIVE_ORIGIN, "'c-1'")
+        .expect("the same child id under another parent is another row");
+    insert("p-1", "c-1", "adapter", "NULL")
+        .expect("a feed task whose id is the bare child id is another row");
+    let count = connection
+        .select::<i64>("SELECT COUNT(*) FROM agent_task_history")
+        .expect("select")()
+    .expect("run");
+    assert_eq!(count, vec![3]);
+}
+
+// ── The step itself (R2.2) ─────────────────────────────────────────────────
+
+#[test]
+fn history_db_subagent_step_is_appended_after_the_six_shipped_steps() {
+    let steps = AgentTaskHistoryDb::MIGRATIONS;
+    assert!(
+        steps.len() >= 7,
+        "a seventh step exists: {} steps",
+        steps.len()
+    );
+    assert!(steps[0] == SHIPPED_STEP_0, "step 1 is never edited");
+    assert!(
+        steps[1] == SHIPPED_STEP_1,
+        "step 2 (story 022) is never edited"
+    );
+    assert!(
+        steps[2] == SHIPPED_STEP_2,
+        "step 3 (story 023) is never edited"
+    );
+    assert!(
+        steps[3] == SHIPPED_STEP_3,
+        "step 4 (story 041) is never edited"
+    );
+    assert!(
+        steps[4] == SHIPPED_STEP_4,
+        "step 5 (story 040, origin) is never edited"
+    );
+    assert!(
+        steps[5] == SHIPPED_STEP_5,
+        "step 6 (story 040, deleted) is never edited, nor folded into step 7"
+    );
+    assert!(
+        steps[6] == CHILD_STEP,
+        "step 7 adds only the child_session_id column"
+    );
+    assert_eq!(
+        steps.iter().filter(|step| **step == CHILD_STEP).count(),
+        1,
+        "appended exactly once"
+    );
+
+    let six = Connection::open_memory(Some("history_db_subagent_step_six"));
+    subagent_step_migrate(&six, &steps[..6]);
+    assert!(
+        !subagent_step_columns(&six)
+            .iter()
+            .any(|name| name == CHILD_COLUMN),
+        "`{CHILD_COLUMN}` comes from step 7, not from a shipped step"
+    );
+
+    let seven = Connection::open_memory(Some("history_db_subagent_step_seven"));
+    subagent_step_migrate(&seven, &steps[..7]);
+    subagent_step_assert_columns(&seven);
+
+    // By behaviour: a row written without the column names no child and is an adapter row.
+    subagent_step_sql(
+        &seven,
+        "INSERT INTO agent_task_history \
+         (session_id, task_id, project, kind, description, background, status, started_at) \
+         VALUES ('s', 't', 'zeo', 'shell', 'Task', 0, 'running', 1000)",
+    );
+    let written = seven
+        .select::<(String, Option<String>)>(
+            "SELECT origin, child_session_id FROM agent_task_history",
+        )
+        .expect("select")()
+    .expect("run");
+    assert_eq!(written, vec![("adapter".to_string(), None)]);
+}
diff --git a/crates/agent_tasks_panel/src/history_db/test_data/history_0_7_0.sql b/crates/agent_tasks_panel/src/history_db/test_data/history_0_7_0.sql
new file mode 100644
index 0000000..553c72b
--- /dev/null
+++ b/crates/agent_tasks_panel/src/history_db/test_data/history_0_7_0.sql
@@ -0,0 +1,74 @@
+-- Story 064, task 3.1: a Zeo 0.7.0 agent task history, as `0.7.0_p20261025` leaves it on disk.
+-- Schema and `migrations` rows copied byte for byte from the operator's real database
+-- (~/.local/share/zeo/db/0-zeo/db.sqlite, read-only, 2026-10-29): sqlez stores each step
+-- sqlformat-ed, so these are the texts step 7 must be appended after, not the `sql!` sources.
+-- The rows are synthetic (no operator data): every shape the real table holds (adapter shell,
+-- workflow and subagent tasks; archived; labelled; with and without tool call and log size;
+-- every status) plus story 040's sub-thread items, one of them a tombstone, and an adapter task
+-- whose id already starts with `acp-subagent:` (the prefix must never decide the source).
+-- Loaded by `subagent_step_tests.rs` with include_str!. No comment may follow the last statement.
+CREATE TABLE IF NOT EXISTS migrations (domain TEXT, step INTEGER, migration TEXT);
+INSERT INTO migrations (domain, step, migration) VALUES ('AgentTaskHistoryDb', 0, 'CREATE TABLE IF NOT EXISTS agent_task_history (
+  session_id TEXT NOT NULL,
+  task_id TEXT NOT NULL,
+  thread_title TEXT,
+  project TEXT NOT NULL,
+  kind TEXT NOT NULL,
+  description TEXT NOT NULL,
+  background INTEGER NOT NULL,
+  status TEXT NOT NULL,
+  started_at INTEGER NOT NULL,
+  ended_at INTEGER,
+  tokens INTEGER,
+  tool_uses INTEGER,
+  archived INTEGER NOT NULL DEFAULT 0,
+  PRIMARY KEY (session_id, task_id)
+) STRICT;
+CREATE INDEX IF NOT EXISTS agent_task_history_started ON agent_task_history (started_at);');
+INSERT INTO migrations (domain, step, migration) VALUES ('AgentTaskHistoryDb', 1, 'ALTER TABLE
+  agent_task_history
+ADD
+  COLUMN label TEXT;');
+INSERT INTO migrations (domain, step, migration) VALUES ('AgentTaskHistoryDb', 2, 'ALTER TABLE
+  agent_task_history
+ADD
+  COLUMN tool_call_id TEXT;');
+INSERT INTO migrations (domain, step, migration) VALUES ('AgentTaskHistoryDb', 3, 'ALTER TABLE
+  agent_task_history
+ADD
+  COLUMN log_bytes INTEGER;');
+INSERT INTO migrations (domain, step, migration) VALUES ('AgentTaskHistoryDb', 4, 'ALTER TABLE
+  agent_task_history
+ADD
+  COLUMN origin TEXT NOT NULL DEFAULT "adapter";');
+INSERT INTO migrations (domain, step, migration) VALUES ('AgentTaskHistoryDb', 5, 'ALTER TABLE
+  agent_task_history
+ADD
+  COLUMN deleted INTEGER NOT NULL DEFAULT 0;');
+CREATE TABLE agent_task_history (
+  session_id TEXT NOT NULL,
+  task_id TEXT NOT NULL,
+  thread_title TEXT,
+  project TEXT NOT NULL,
+  kind TEXT NOT NULL,
+  description TEXT NOT NULL,
+  background INTEGER NOT NULL,
+  status TEXT NOT NULL,
+  started_at INTEGER NOT NULL,
+  ended_at INTEGER,
+  tokens INTEGER,
+  tool_uses INTEGER,
+  archived INTEGER NOT NULL DEFAULT 0, label TEXT, tool_call_id TEXT, log_bytes INTEGER, origin TEXT NOT NULL DEFAULT "adapter", deleted INTEGER NOT NULL DEFAULT 0,
+  PRIMARY KEY (session_id, task_id)
+) STRICT;
+CREATE INDEX agent_task_history_started ON agent_task_history (started_at);
+INSERT INTO agent_task_history (session_id, task_id, thread_title, project, kind, description, background, status, started_at, ended_at, tokens, tool_uses, archived, label, tool_call_id, log_bytes, origin, deleted) VALUES
+  ('s-1', 'b1a2c3', 'Fix the parser', 'zeo', 'shell', 'cargo test -p parser', 1, 'completed', 1761000000000, 1761000060000, NULL, NULL, 0, NULL, 'toolu_01Shell', 4096, 'adapter', 0),
+  ('s-1', 'a7f00d', 'Fix the parser', 'zeo', 'subagent', 'Explore the parser', 1, 'completed', 1761000001000, 1761000301000, 52310, 41, 0, NULL, 'toolu_01Agent', NULL, 'adapter', 0),
+  ('s-1', 'a7f00e', 'Fix the parser', 'zeo', 'subagent', 'Explore the parser', 1, 'failed', 1761000002000, 1761000009000, 120, 2, 1, 'Second explorer', 'toolu_01Agent2', NULL, 'adapter', 0),
+  ('s-1', 'w1', 'Fix the parser', 'zeo', 'workflow', 'Release dry run', 0, 'stopped', 1761000003000, 1761000004000, NULL, NULL, 0, NULL, NULL, NULL, 'adapter', 0),
+  ('s-2', 'b1a2c3', NULL, '', 'shell', 'npm ci', 0, 'interrupted', 1761100000000, 1761100500000, NULL, NULL, 1, NULL, NULL, 0, 'adapter', 0),
+  ('s-2', 'acp-subagent:c-legacy', 'Odd ids', 'zeo', 'subagent', 'An adapter task whose id looks native', 1, 'completed', 1761100001000, 1761100002000, 9, 1, 0, NULL, 'toolu_01Odd', NULL, 'adapter', 0),
+  ('s-2', 'u1', 'Odd ids', 'zeo', 'shell', 'echo ''quoted'' "and" ✓ ünïcödé', 1, 'running', 1761100003000, NULL, NULL, NULL, 0, 'Lábel ✓', NULL, NULL, 'adapter', 0),
+  ('s-3', 'zeo-sub-thread:s-3a', NULL, 'zeo', 'sub_thread', 'A linked sub-thread', 0, 'completed', 1761200000000, 1761200001000, NULL, NULL, 0, NULL, NULL, NULL, 'sub_thread', 0),
+  ('s-3', 'zeo-sub-thread:s-3b', NULL, 'zeo', 'sub_thread', 'A deleted sub-thread', 0, 'completed', 1761200002000, 1761200003000, NULL, NULL, 1, NULL, NULL, NULL, 'sub_thread', 1);
diff --git a/crates/agent_tasks_panel/src/history_db/tombstone_step_tests.rs b/crates/agent_tasks_panel/src/history_db/tombstone_step_tests.rs
new file mode 100644
index 0000000..bcb4137
--- /dev/null
+++ b/crates/agent_tasks_panel/src/history_db/tombstone_step_tests.rs
@@ -0,0 +1,197 @@
+// Story 040, task 11.1 (migration half) — step 6 of `HISTORY_MIGRATIONS`, `deleted` (Q13; review
+// 3.x F1). Module path: `history_db::tombstone_step`. Wired in `src/history_db.rs`, after
+// `mod sub_thread_upsert;`, as
+//
+//     #[cfg(test)]
+//     #[path = "history_db/tombstone_step_tests.rs"]
+//     mod tombstone_step;
+//
+// Validation filter: `cargo test -p agent_tasks_panel -- history_db` (names carry
+// `history_db_tombstone`). The tombstone's behaviour is in `tombstone_tests.rs`.
+//
+// Contract (design.md v4, "Tombstone (v4, review 3.x F1, R5.10)"; story.md Q13): step 6 (index 5)
+// is `sql!(ALTER TABLE agent_task_history ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;)`,
+// appended after the five shipped steps, which stay byte-identical — never folded into step 5,
+// which already ran in every database of tasks 3–7.
+//
+// Hostile half first: a database written by the five shipped steps, holding rows of both
+// origins, keeps every row and value through step 6, each live; then the step's own pin.
+
+use db::sqlez::{connection::Connection, domain::Domain};
+use db::sqlez_macros::sql;
+
+use crate::AgentTaskHistoryDb;
+
+// The five shipped steps, byte for byte (steps 1–4 as `origin_tests.rs` copies them, step 5 as
+// story 040's task 3.1 shipped it).
+const SHIPPED_STEP_0: &str = sql!(
+    CREATE TABLE IF NOT EXISTS agent_task_history(
+        session_id TEXT NOT NULL,
+        task_id TEXT NOT NULL,
+        thread_title TEXT,
+        project TEXT NOT NULL,
+        kind TEXT NOT NULL,
+        description TEXT NOT NULL,
+        background INTEGER NOT NULL,
+        status TEXT NOT NULL,
+        started_at INTEGER NOT NULL,
+        ended_at INTEGER,
+        tokens INTEGER,
+        tool_uses INTEGER,
+        archived INTEGER NOT NULL DEFAULT 0,
+        PRIMARY KEY(session_id, task_id)
+    ) STRICT;
+    CREATE INDEX IF NOT EXISTS agent_task_history_started ON agent_task_history(started_at);
+);
+const SHIPPED_STEP_1: &str = sql!(ALTER TABLE agent_task_history ADD COLUMN label TEXT;);
+const SHIPPED_STEP_2: &str = sql!(ALTER TABLE agent_task_history ADD COLUMN tool_call_id TEXT;);
+const SHIPPED_STEP_3: &str = sql!(ALTER TABLE agent_task_history ADD COLUMN log_bytes INTEGER;);
+const SHIPPED_STEP_4: &str =
+    sql!(ALTER TABLE agent_task_history ADD COLUMN origin TEXT NOT NULL DEFAULT "adapter";);
+
+const TOMBSTONE_STEP: &str =
+    sql!(ALTER TABLE agent_task_history ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;);
+
+fn tombstone_migrate_all(connection: &Connection) {
+    connection
+        .migrate(
+            AgentTaskHistoryDb::NAME,
+            AgentTaskHistoryDb::MIGRATIONS,
+            &mut |_, _, _| false,
+        )
+        .expect("the history migrations should apply");
+}
+
+/// One fixed SQL statement of this test (sqlez, not a process).
+fn tombstone_sql_now(connection: &Connection, statement: &str) {
+    let mut run = connection.exec(statement).expect("statement prepares");
+    run().expect("statement runs");
+}
+
+// ── The step itself (Q13) ──────────────────────────────────────────────────
+
+#[test]
+fn history_db_tombstone_step_is_appended_after_the_five_shipped_steps() {
+    let steps = AgentTaskHistoryDb::MIGRATIONS;
+    assert!(
+        steps.len() >= 6,
+        "a sixth step exists: {} steps",
+        steps.len()
+    );
+    assert!(steps[0] == SHIPPED_STEP_0, "step 1 is never edited");
+    assert!(
+        steps[1] == SHIPPED_STEP_1,
+        "step 2 (story 022) is never edited"
+    );
+    assert!(
+        steps[2] == SHIPPED_STEP_2,
+        "step 3 (story 023) is never edited"
+    );
+    assert!(
+        steps[3] == SHIPPED_STEP_3,
+        "step 4 (story 041) is never edited"
+    );
+    assert!(
+        steps[4] == SHIPPED_STEP_4,
+        "step 5 (story 040, origin) is never edited, nor folded into step 6"
+    );
+    assert!(steps[5] == TOMBSTONE_STEP, "step 6 adds the deleted column");
+    assert_eq!(
+        steps.iter().filter(|step| **step == TOMBSTONE_STEP).count(),
+        1,
+        "appended exactly once"
+    );
+}
+
+// Hostile (Q13): a database written by the five shipped steps, holding adapter tasks and
+// sub-thread items, archived or not, opens with the new list and keeps every row and value, each
+// live (`deleted = 0`). A second open changes nothing more.
+#[test]
+fn history_db_tombstone_step_five_database_keeps_every_row_live() {
+    let connection = Connection::open_memory(Some("history_db_tombstone_step_five"));
+    connection
+        .migrate(
+            AgentTaskHistoryDb::NAME,
+            &[
+                SHIPPED_STEP_0,
+                SHIPPED_STEP_1,
+                SHIPPED_STEP_2,
+                SHIPPED_STEP_3,
+                SHIPPED_STEP_4,
+            ],
+            &mut |_, _, _| false,
+        )
+        .expect("the shipped steps apply");
+    for (session, task, kind, origin, archived, log_bytes) in [
+        ("p1", "t1", "shell", "adapter", 1, "4096"),
+        ("p1", "t2", "shell", "adapter", 0, "NULL"),
+        (
+            "p1",
+            "zeo-sub-thread:s-a",
+            "sub_thread",
+            "sub_thread",
+            1,
+            "NULL",
+        ),
+        (
+            "p1",
+            "zeo-sub-thread:s-b",
+            "sub_thread",
+            "sub_thread",
+            0,
+            "NULL",
+        ),
+    ] {
+        tombstone_sql_now(
+            &connection,
+            &format!(
+                "INSERT INTO agent_task_history \
+                 (session_id, task_id, thread_title, project, kind, description, background, \
+                  status, started_at, ended_at, archived, label, tool_call_id, log_bytes, origin) \
+                 VALUES ('{session}', '{task}', NULL, 'zeo', '{kind}', 'Kept {task}', 0, \
+                         'completed', 1000, 2000, {archived}, NULL, NULL, {log_bytes}, \
+                         '{origin}')"
+            ),
+        );
+    }
+
+    tombstone_migrate_all(&connection);
+    tombstone_migrate_all(&connection);
+
+    let columns = connection
+        .select::<String>("SELECT name FROM pragma_table_info('agent_task_history')")
+        .expect("select")()
+    .expect("run");
+    assert!(
+        columns.iter().any(|column| column == "deleted"),
+        "step 6 adds the deleted column: {columns:?}"
+    );
+    let rows = connection
+        .select::<(String, String, String, bool, Option<i64>, String, i64)>(
+            "SELECT session_id, task_id, description, archived, log_bytes, origin, deleted \
+             FROM agent_task_history ORDER BY session_id, task_id",
+        )
+        .expect("select")()
+    .expect("run");
+    assert_eq!(
+        rows.len(),
+        4,
+        "every row survives, and none is added: {rows:?}"
+    );
+    let expected = [
+        ("t1", true, Some(4096), "adapter"),
+        ("t2", false, None, "adapter"),
+        ("zeo-sub-thread:s-a", true, None, "sub_thread"),
+        ("zeo-sub-thread:s-b", false, None, "sub_thread"),
+    ];
+    for ((session, task, description, archived, log_bytes, origin, deleted), want) in
+        rows.into_iter().zip(expected)
+    {
+        assert_eq!((session.as_str(), task.as_str()), ("p1", want.0));
+        assert_eq!(description, format!("Kept {task}"), "{task} keeps its text");
+        assert_eq!(archived, want.1, "{task} keeps its archive flag");
+        assert_eq!(log_bytes, want.2, "{task} keeps its log size");
+        assert_eq!(origin, want.3, "{task} keeps its origin");
+        assert_eq!(deleted, 0, "{task} is live, not a tombstone");
+    }
+}
diff --git a/crates/agent_tasks_panel/src/history_db/tombstone_tests.rs b/crates/agent_tasks_panel/src/history_db/tombstone_tests.rs
new file mode 100644
index 0000000..6eed9b2
--- /dev/null
+++ b/crates/agent_tasks_panel/src/history_db/tombstone_tests.rs
@@ -0,0 +1,427 @@
+// Story 040, task 11.1 (database half) — the tombstone of a deleted sub-thread item (R5.10,
+// R7.6; Q14; review 3.x F1). Module path: `history_db::tombstone`. Wired in `src/history_db.rs`,
+// after `mod sub_thread_upsert;`, as
+//
+//     #[cfg(test)]
+//     #[path = "history_db/tombstone_tests.rs"]
+//     mod tombstone;
+//
+// Validation filter: `cargo test -p agent_tasks_panel -- history_db` (names carry
+// `history_db_tombstone`). Step 6 itself is pinned in `tombstone_step_tests.rs`.
+//
+// Contract (design.md v4, "Tombstone (v4, review 3.x F1, R5.10)"):
+//   - `AgentTaskHistoryDb::delete` keeps its contract — archived rows only, returns the indices it
+//     removed — but a row of origin `sub_thread` is kept with `deleted = 1`; an adapter row is
+//     still removed;
+//   - `load_all` skips `deleted = 1` rows; `AgentTaskHistoryDb::deleted_sub_thread_keys()` returns
+//     their keys;
+//   - `upsert_sub_thread` and `record_sub_thread_link` never rewrite nor revive a tombstone.
+// Assumed beyond design.md: `deleted_sub_thread_keys` is `async`, returns `anyhow::Result` of a
+// collection of `TaskKey` (`(SessionId, AgentTaskId)`) — adjust in `tombstones` only; a
+// tombstoned row counts among the indices `delete` returns, since it is deleted from the user's
+// view and `delete` "keeps its contract" (a sub-thread row has no log, so the log delete the
+// store runs for it is a no-op).
+//
+// Hostile halves first, for "a deleted item is the SAME item when it is reported again":
+//   - wrongly split (the revival): the same sub-thread linked, run and idled again after its
+//     delete stays deleted, and its row is never rewritten;
+//   - wrongly collapse: tombstoning one sub-thread hides neither its sibling under the same
+//     parent, nor the same sub session under another parent, nor an adapter task of the parent
+//     whose id is the sub session — the third element against the derived key
+//     `zeo-sub-thread:<sub>`;
+//   - the origin alone decides: an adapter row dressed as a sub-thread (kind `sub_thread`, the
+//     prefix) is removed, a stored sub-thread row with plain kind and id is tombstoned;
+//   - the guard is not over-broad: a live item of the same parent is still refreshed, and a new
+//     one still inserted, after a sibling's tombstone;
+//   - then the benign shapes (a non-archived item is not deleted; an archived adapter row goes).
+
+use acp_thread::AgentTaskStatus;
+use agent_ui::SubThreadRecord;
+use gpui::TestAppContext;
+
+use crate::history_db::TaskOrigin;
+use crate::test_helpers::*;
+use crate::{AgentTaskHistoryDb, HistoryEntry};
+
+const PREFIX: &str = "zeo-sub-thread:";
+
+fn tombstone_sub_record(parent: &str, sub: &str, title: &str, at_ms: i64) -> SubThreadRecord {
+    SubThreadRecord {
+        parent_session: sid(parent),
+        sub_session: sid(sub),
+        title: title.to_string().into(),
+        project: "project".into(),
+        at_ms,
+    }
+}
+
+fn sub_key(parent: &str, sub: &str) -> Key {
+    key(parent, &format!("{PREFIX}{sub}"))
+}
+
+async fn tombstone_upsert(db: &AgentTaskHistoryDb, record: &SubThreadRecord, status: &str) {
+    db.upsert_sub_thread(record, status)
+        .await
+        .expect("upsert_sub_thread should succeed");
+}
+
+async fn tombstone_link(db: &AgentTaskHistoryDb, record: &SubThreadRecord) {
+    db.record_sub_thread_link(record)
+        .await
+        .expect("record_sub_thread_link should succeed");
+}
+
+/// An archived, idle sub-thread item, ready to be deleted.
+async fn archived_item(db: &AgentTaskHistoryDb, parent: &str, sub: &str, title: &str, at: i64) {
+    tombstone_upsert(
+        db,
+        &tombstone_sub_record(parent, sub, title, at),
+        "completed",
+    )
+    .await;
+    db.set_archived(&[sub_key(parent, sub)], true)
+        .await
+        .expect("set_archived should succeed");
+}
+
+/// An archived, completed adapter task.
+async fn archived_task(db: &AgentTaskHistoryDb, task: &agent_ui::TaskRecord) {
+    db.insert_started(task)
+        .await
+        .expect("insert_started should succeed");
+    db.set_archived(&[(task.session_id.clone(), task.task_id.clone())], true)
+        .await
+        .expect("set_archived should succeed");
+}
+
+/// `deleted_sub_thread_keys`, as sorted `(session, task)` text pairs.
+async fn tombstones(db: &AgentTaskHistoryDb) -> Vec<(String, String)> {
+    let mut keys: Vec<(String, String)> = db
+        .deleted_sub_thread_keys()
+        .await
+        .expect("deleted_sub_thread_keys should succeed")
+        .into_iter()
+        .map(|(session_id, task_id)| (session_id.to_string(), task_id.to_string()))
+        .collect();
+    keys.sort();
+    keys
+}
+
+fn pair(session: &str, task: &str) -> (String, String) {
+    (session.to_string(), task.to_string())
+}
+
+fn sub_pair(parent: &str, sub: &str) -> (String, String) {
+    pair(parent, &format!("{PREFIX}{sub}"))
+}
+
+/// How many rows the table physically holds for `(session, task)`, hidden or not. Reads no
+/// column story 040 adds, so it answers on any schema.
+fn rows_on_key(db: &AgentTaskHistoryDb, session: &str, task: &str) -> usize {
+    db.select::<(String, String)>("SELECT session_id, task_id FROM agent_task_history")
+        .expect("select prepares")()
+    .expect("select runs")
+    .into_iter()
+    .filter(|(s, t)| s == session && t == task)
+    .count()
+}
+
+/// The stored `(description, status, deleted)` of one row. Reads the step-6 column: call it only
+/// after an assertion that fails without that step.
+fn stored_row(db: &AgentTaskHistoryDb, session: &str, task: &str) -> (String, String, i64) {
+    let rows = db
+        .select::<(String, String, String, String, i64)>(
+            "SELECT session_id, task_id, description, status, deleted FROM agent_task_history",
+        )
+        .expect("select prepares")()
+    .expect("select runs");
+    rows.into_iter()
+        .find(|(s, t, ..)| s == session && t == task)
+        .map(|(_, _, description, status, deleted)| (description, status, deleted))
+        .unwrap_or_else(|| panic!("{session}/{task} has a row"))
+}
+
+fn listed(rows: &[HistoryEntry], session: &str, task: &str) -> bool {
+    find(rows, session, task).is_some()
+}
+
+// ── Wrongly split: the revival (R5.10, Q14) ────────────────────────────────
+
+// The replay of the next start reports every link again, then the sub-thread may run: none of it
+// brings the deleted item back, and the tombstone row itself is never rewritten.
+#[gpui::test]
+async fn history_db_tombstone_no_sub_thread_write_revives_a_deleted_item(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_tombstone_no_revival").await;
+    archived_item(&db, "p1", "sub-a", "Alpha", 1_000).await;
+    db.delete(&[sub_key("p1", "sub-a")])
+        .await
+        .expect("delete should succeed");
+
+    tombstone_link(
+        &db,
+        &tombstone_sub_record("p1", "sub-a", "Alpha relinked", 2_000),
+    )
+    .await;
+    tombstone_upsert(
+        &db,
+        &tombstone_sub_record("p1", "sub-a", "Alpha running", 3_000),
+        "running",
+    )
+    .await;
+    tombstone_upsert(
+        &db,
+        &tombstone_sub_record("p1", "sub-a", "Alpha idle", 4_000),
+        "completed",
+    )
+    .await;
+    tombstone_link(
+        &db,
+        &tombstone_sub_record("p1", "sub-a", "Alpha retitled", 5_000),
+    )
+    .await;
+
+    let rows = load(&db).await;
+    assert!(
+        !listed(&rows, "p1", &format!("{PREFIX}sub-a")),
+        "a deleted item is never listed again: {rows:?}"
+    );
+    assert_eq!(
+        tombstones(&db).await,
+        vec![sub_pair("p1", "sub-a")],
+        "the item is still a tombstone"
+    );
+    assert_eq!(
+        rows_on_key(&db, "p1", &format!("{PREFIX}sub-a")),
+        1,
+        "one row on the key: no second row"
+    );
+    let (description, status, deleted) = stored_row(&db, "p1", &format!("{PREFIX}sub-a"));
+    assert_eq!(deleted, 1, "still deleted");
+    assert_eq!(
+        description, "Alpha",
+        "the tombstone's description is never rewritten"
+    );
+    assert_eq!(
+        status, "completed",
+        "the tombstone's status is never rewritten"
+    );
+}
+
+// ── Wrongly collapse ───────────────────────────────────────────────────────
+
+// One sub-thread's tombstone hides only that item: not its sibling under the same parent, not the
+// same sub session recorded under another parent, and not an adapter task of the parent whose id
+// is the sub session (the derived key's third element).
+#[gpui::test]
+async fn history_db_tombstone_hides_only_the_deleted_item(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_tombstone_only_that_item").await;
+    archived_item(&db, "p1", "sub-a", "Alpha", 1_000).await;
+    archived_item(&db, "p1", "sub-b", "Bravo", 1_100).await;
+    archived_item(&db, "p2", "sub-a", "Alpha elsewhere", 1_200).await;
+    archived_task(
+        &db,
+        &record("p1", "sub-a", AgentTaskStatus::Completed, 1_300),
+    )
+    .await;
+    db.insert_started(&record("p1", "t-live", AgentTaskStatus::Running, 1_400))
+        .await
+        .expect("insert_started should succeed");
+
+    let removed = db
+        .delete(&[sub_key("p1", "sub-a")])
+        .await
+        .expect("delete should succeed");
+
+    assert_eq!(
+        tombstones(&db).await,
+        vec![sub_pair("p1", "sub-a")],
+        "exactly the deleted item is a tombstone"
+    );
+    assert_eq!(
+        removed,
+        vec![0],
+        "the tombstoned item is reported as deleted"
+    );
+    assert_eq!(
+        rows_on_key(&db, "p1", &format!("{PREFIX}sub-a")),
+        1,
+        "the deleted sub-thread row is kept, hidden"
+    );
+    let rows = load(&db).await;
+    assert!(
+        !listed(&rows, "p1", &format!("{PREFIX}sub-a")),
+        "the deleted item is hidden"
+    );
+    assert!(
+        listed(&rows, "p1", &format!("{PREFIX}sub-b")),
+        "its sibling is still listed"
+    );
+    assert!(
+        listed(&rows, "p2", &format!("{PREFIX}sub-a")),
+        "the same sub session under another parent is still listed"
+    );
+    assert!(
+        listed(&rows, "p1", "sub-a"),
+        "an adapter task whose id is the sub session is still listed"
+    );
+    assert!(
+        listed(&rows, "p1", "t-live"),
+        "the parent's live task is still listed"
+    );
+    assert_eq!(
+        stored_row(&db, "p1", &format!("{PREFIX}sub-a")).2,
+        1,
+        "deleted = 1"
+    );
+    assert_eq!(
+        stored_row(&db, "p1", &format!("{PREFIX}sub-b")).2,
+        0,
+        "sibling deleted = 0"
+    );
+}
+
+// ── The origin alone decides (R5.7) ────────────────────────────────────────
+
+// Wrong split: a row stored as a sub-thread item, with nothing else about it looking like one,
+// is tombstoned, not removed.
+#[gpui::test]
+async fn history_db_tombstone_stored_sub_thread_with_plain_kind_and_id_is_tombstoned(
+    _cx: &mut TestAppContext,
+) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_tombstone_plain_sub_thread").await;
+    run_sql(
+        &db,
+        "INSERT INTO agent_task_history \
+         (session_id, task_id, project, kind, description, background, status, started_at, \
+          archived, origin) \
+         VALUES ('p1', 'plain-id', 'zeo', 'shell', 'Looks adapter', 0, 'completed', 1000, 1, \
+                 'sub_thread')",
+    )
+    .await;
+
+    db.delete(&[key("p1", "plain-id")])
+        .await
+        .expect("delete should succeed");
+
+    assert_eq!(
+        tombstones(&db).await,
+        vec![pair("p1", "plain-id")],
+        "the stored origin, not the kind or the id, makes it a sub-thread tombstone"
+    );
+    assert_eq!(rows_on_key(&db, "p1", "plain-id"), 1, "the row is kept");
+    assert!(!listed(&load(&db).await, "p1", "plain-id"), "and hidden");
+}
+
+// Wrong collapse: adapter rows — a plain one and one dressed as a sub-thread — are still removed
+// outright, and never reported as tombstones (the shipped behaviour, kept).
+#[gpui::test]
+async fn history_db_tombstone_archived_adapter_rows_are_still_removed(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_tombstone_adapter_removed").await;
+    archived_task(&db, &record("p1", "t1", AgentTaskStatus::Completed, 1_000)).await;
+    let mut dressed = record("p1", "zeo-sub-thread:s9", AgentTaskStatus::Completed, 1_100);
+    dressed.kind = "sub_thread".into();
+    archived_task(&db, &dressed).await;
+    assert_eq!(
+        find(&load(&db).await, "p1", "zeo-sub-thread:s9").map(|entry| entry.origin),
+        Some(TaskOrigin::Adapter),
+        "precondition: the dressed row is an adapter row"
+    );
+
+    let removed = db
+        .delete(&[key("p1", "t1"), key("p1", "zeo-sub-thread:s9")])
+        .await
+        .expect("delete should succeed");
+
+    assert_eq!(
+        removed,
+        vec![0, 1],
+        "both archived adapter rows are deleted"
+    );
+    assert_eq!(
+        rows_on_key(&db, "p1", "t1"),
+        0,
+        "the adapter row is removed"
+    );
+    assert_eq!(
+        rows_on_key(&db, "p1", "zeo-sub-thread:s9"),
+        0,
+        "the adapter row dressed as a sub-thread is removed, not kept"
+    );
+    assert!(
+        tombstones(&db).await.is_empty(),
+        "an adapter row is never a tombstone"
+    );
+}
+
+// ── The guard is not over-broad ────────────────────────────────────────────
+
+// After a sibling's tombstone, a live item of the same parent is still refreshed by both writes,
+// and a new sub-thread of that parent is still recorded.
+#[gpui::test]
+async fn history_db_tombstone_live_items_of_the_parent_still_take_writes(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_tombstone_live_writes").await;
+    archived_item(&db, "p1", "sub-a", "Alpha", 1_000).await;
+    tombstone_upsert(
+        &db,
+        &tombstone_sub_record("p1", "sub-b", "Bravo", 1_100),
+        "completed",
+    )
+    .await;
+    db.delete(&[sub_key("p1", "sub-a")])
+        .await
+        .expect("delete should succeed");
+
+    tombstone_upsert(
+        &db,
+        &tombstone_sub_record("p1", "sub-b", "Bravo", 2_000),
+        "running",
+    )
+    .await;
+    tombstone_link(
+        &db,
+        &tombstone_sub_record("p1", "sub-b", "Bravo renamed", 2_100),
+    )
+    .await;
+    tombstone_link(&db, &tombstone_sub_record("p1", "sub-c", "Charlie", 2_200)).await;
+
+    let rows = load(&db).await;
+    let bravo = find(&rows, "p1", &format!("{PREFIX}sub-b")).expect("the live item is listed");
+    assert_eq!(
+        bravo.record.status,
+        AgentTaskStatus::Running,
+        "the upsert refreshed it"
+    );
+    assert_eq!(
+        bravo.record.description.as_ref(),
+        "Bravo renamed",
+        "the link write refreshed it"
+    );
+    let charlie = find(&rows, "p1", &format!("{PREFIX}sub-c")).expect("a new item is recorded");
+    assert_eq!(charlie.origin, TaskOrigin::SubThread);
+}
+
+// ── Benign ─────────────────────────────────────────────────────────────────
+
+// The archived-only rule is kept: an item that is not archived is neither deleted nor tombstoned.
+#[gpui::test]
+async fn history_db_tombstone_unarchived_item_is_not_deleted(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_tombstone_unarchived").await;
+    tombstone_upsert(
+        &db,
+        &tombstone_sub_record("p1", "sub-a", "Alpha", 1_000),
+        "completed",
+    )
+    .await;
+
+    let removed = db
+        .delete(&[sub_key("p1", "sub-a")])
+        .await
+        .expect("delete should succeed");
+
+    assert!(removed.is_empty(), "nothing is reported deleted");
+    assert!(
+        listed(&load(&db).await, "p1", &format!("{PREFIX}sub-a")),
+        "the item is still listed"
+    );
+    assert!(tombstones(&db).await.is_empty(), "and is no tombstone");
+}
diff --git a/crates/agent_tasks_panel/src/native_subagent_support.rs b/crates/agent_tasks_panel/src/native_subagent_support.rs
new file mode 100644
index 0000000..885f065
--- /dev/null
+++ b/crates/agent_tasks_panel/src/native_subagent_support.rs
@@ -0,0 +1,245 @@
+// Story 064, task 5.1 — fixtures for the Agent Tasks panel's native subagent rows (R1.1, R1.6,
+// R2.3, R2.4). Patches `0035`/`0041`/`0053` (fixups). Wired in the crate's lib
+// (`src/agent_tasks_panel.rs`) beside `test_helpers`:
+//
+//     #[cfg(test)]
+//     mod native_subagent_support;
+//
+// and its two test files in `src/panel.rs` and `src/store.rs`:
+//
+//     #[cfg(test)]
+//     #[path = "panel/native_subagent_tests.rs"]
+//     mod native_subagent;                         // panel::native_subagent
+//     #[cfg(test)]
+//     #[path = "store/panel_native_subagent_restart_tests.rs"]
+//     mod panel_native_subagent_restart;           // store::panel_native_subagent_restart
+//
+// The restart file sits under `store` because only that module reaches the private
+// `GlobalTaskHistoryStore` (story 014's restart recipe); its name carries the Validation
+// filter `cargo test -p agent_tasks_panel panel`.
+//
+// A child is built as `agent_servers::acp::apply_subagent_update` builds one (see 4.1's
+// `native_subagent_support.rs`): an `agent_owned` thread under the parent's session,
+// `track_session` on the stub, its association, then `register_subagent`.
+//
+// Contract assumed beyond design.md (adjust HERE): the panel's row key of a native subagent is
+// design.md's history key, `(parent session, "acp-subagent:<child session>")`. Revision 1
+// (operator, 2026-10-29): no `source` column — step 7 adds only `child_session_id`, and a native
+// record carries the new origin value `acp_subagent` (`TaskOrigin::AcpSubagent`) in step 5's
+// existing `origin` column. The one SQL that names them is `seed_recorded_subagent`.
+
+#![allow(dead_code)]
+
+use std::path::Path;
+use std::rc::Rc;
+
+use acp_thread::{AcpThread, AgentConnection, StubAgentConnection};
+use agent_client_protocol::v1::schema as acp;
+use agent_client_protocol::v2::schema as acp_v2;
+use agent_ui::AgentPanel;
+use agent_ui::thread_metadata_store::{ThreadId, ThreadMetadata, ThreadMetadataStore};
+use gpui::{AppContext as _, Entity, VisualTestContext};
+use workspace::PathList;
+
+use crate::AgentTaskHistoryDb;
+use crate::test_helpers::*;
+
+/// The notice a recorded subagent row shows when only its parent could be opened (R2.4).
+pub(crate) const PARENT_ONLY_NOTICE: &str =
+    "This subagent's transcript is only available from its parent thread";
+
+pub(crate) fn subagent_key(parent: &acp_v2::SessionId, child: &str) -> Key {
+    (parent.clone(), format!("acp-subagent:{child}").into())
+}
+
+/// A stub that follows the subagents RFD; its first prompt ends at once, so the opening
+/// message persists the thread and leaves the parent idle.
+pub(crate) fn rfd_connection() -> StubAgentConnection {
+    let connection = StubAgentConnection::new().with_announced_subagents(true);
+    connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+        acp::ContentChunk::new("ok".into()),
+    )]);
+    connection
+}
+
+/// Announces `session` as a child of `parent` and registers it.
+pub(crate) fn spawn_child(
+    parent: &Entity<AcpThread>,
+    connection: &StubAgentConnection,
+    session: &str,
+    title: &str,
+    cx: &mut VisualTestContext,
+) -> Entity<AcpThread> {
+    let parent_session = session_of(parent, cx);
+    let shared: Rc<dyn AgentConnection> = Rc::new(connection.clone());
+    let child = cx.update(|_, cx| {
+        let project = parent.read(cx).project().clone();
+        let action_log = cx.new(|_| action_log::ActionLog::new(project.clone()));
+        cx.new(|cx| {
+            AcpThread::new(
+                Some(parent_session),
+                None,
+                None,
+                shared,
+                project,
+                action_log,
+                acp_v2::SessionId::new(session.to_string()),
+                watch::Receiver::constant(acp_v2::PromptCapabilities::new()),
+                cx,
+            )
+            .agent_owned()
+        })
+    });
+    cx.update(|_, cx| connection.track_session(&child, cx));
+    child
+        .update(cx, |child, cx| {
+            child.update_subagent_association(
+                &acp_v2::SubagentUpdate::new(session.to_string())
+                    .title(title.to_string())
+                    .capabilities(
+                        acp_v2::SubagentSessionCapabilities::new()
+                            .cancel(acp_v2::SessionCancelCapabilities::new()),
+                    ),
+                cx,
+            )
+        })
+        .expect("an agent-owned thread takes an association");
+    parent.update(cx, |parent, cx| parent.register_subagent(child.clone(), cx));
+    cx.run_until_parked();
+    child
+}
+
+pub(crate) fn report(
+    child: &Entity<AcpThread>,
+    state: acp_v2::StateUpdate,
+    cx: &mut VisualTestContext,
+) {
+    child
+        .update(cx, |child, cx| child.update_session_state(state, cx))
+        .expect("an agent-owned thread reports its state");
+    cx.run_until_parked();
+}
+
+pub(crate) fn report_running(child: &Entity<AcpThread>, cx: &mut VisualTestContext) {
+    report(
+        child,
+        acp_v2::StateUpdate::Running(acp_v2::RunningStateUpdate::new()),
+        cx,
+    );
+}
+
+pub(crate) fn report_done(child: &Entity<AcpThread>, cx: &mut VisualTestContext) {
+    report(
+        child,
+        acp_v2::StateUpdate::Idle(
+            acp_v2::IdleStateUpdate::new().stop_reason(acp_v2::StopReason::EndTurn),
+        ),
+        cx,
+    );
+}
+
+/// The parent's first outgoing session message to `child`: the place its card is drawn.
+pub(crate) fn announce_to(
+    parent: &Entity<AcpThread>,
+    child: &str,
+    cx: &mut VisualTestContext,
+) -> usize {
+    let parent_session = session_of(parent, cx);
+    parent
+        .update(cx, |parent, cx| {
+            parent.upsert_session_message(
+                acp_v2::SessionMessage::new(format!("launch-{child}"))
+                    .sender_session_id(parent_session)
+                    .recipient_session_id(acp_v2::SessionId::new(child.to_string()))
+                    .content(vec!["Look into it".into()]),
+                cx,
+            )
+        })
+        .expect("an outgoing session message applies");
+    cx.run_until_parked();
+    parent.read_with(cx, |parent, _| parent.entries().len() - 1)
+}
+
+/// Forty question/answer entries, so a scroll to an earlier entry is not clamped to the end.
+pub(crate) fn pad(parent: &Entity<AcpThread>, from: usize, cx: &mut VisualTestContext) {
+    parent.update(cx, |thread, cx| {
+        for i in from..from + 20 {
+            thread
+                .handle_session_update(
+                    acp::SessionUpdate::UserMessageChunk(acp::ContentChunk::new(
+                        format!("question {i}").into(),
+                    )),
+                    cx,
+                )
+                .expect("entry applies");
+            thread
+                .handle_session_update(
+                    acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
+                        format!("answer {i}").into(),
+                    )),
+                    cx,
+                )
+                .expect("entry applies");
+        }
+    });
+    cx.run_until_parked();
+}
+
+/// The session the conversation shows: a child after navigation, else the root.
+pub(crate) fn conversation_shows(
+    agent_panel: &Entity<AgentPanel>,
+    cx: &VisualTestContext,
+) -> Option<acp_v2::SessionId> {
+    agent_panel.read_with(cx, |panel, cx| {
+        panel
+            .active_conversation_view()
+            .and_then(|view| view.read(cx).active_thread().cloned())
+            .map(|thread_view| thread_view.read(cx).session_id.clone())
+    })
+}
+
+/// A decoy: a metadata row under the CHILD's session id, in a folder no window holds. A panel
+/// that looked the child up in the thread metadata store would route there.
+pub(crate) fn save_decoy_metadata_for(child: &str, cx: &mut VisualTestContext) {
+    cx.update(|_, cx| {
+        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
+            store.save(
+                ThreadMetadata {
+                    thread_id: ThreadId::new(),
+                    session_id: Some(acp_v2::SessionId::new(child.to_string())),
+                    agent_id: "stub".into(),
+                    title: Some("Decoy under the child's id".into()),
+                    title_override: None,
+                    updated_at: Default::default(),
+                    created_at: None,
+                    interacted_at: None,
+                    worktree_paths: project::WorktreePaths::from_folder_paths(&PathList::new(&[
+                        Path::new("/elsewhere/decoy"),
+                    ])),
+                    remote_connection: None,
+                    archived: false,
+                },
+                cx,
+            )
+        })
+    });
+    cx.run_until_parked();
+}
+
+/// A subagent record written by an earlier Zeo session: ended, keyed by `parent`, origin
+/// `acp_subagent`, its child in step 7's `child_session_id`. The child is not registered anywhere
+/// in this session. Double quotes: `'…'` inside `sql!` is a Rust char literal (step 5's note);
+/// this is a runtime string, and SQLite reads either form.
+pub(crate) async fn seed_recorded_subagent(
+    db: &AgentTaskHistoryDb,
+    parent: &acp_v2::SessionId,
+    child: &str,
+) {
+    let sql = format!(
+        "INSERT INTO agent_task_history(session_id, task_id, thread_title, project, kind, \
+         description, background, status, started_at, ended_at, origin, child_session_id) \
+         VALUES ('{parent}', 'acp-subagent:{child}', 'Parent', 'project', 'subagent', \
+         'Recorded child', 1, 'completed', 1000, 2000, 'acp_subagent', '{child}');"
+    );
+    run_sql(db, Box::leak(sql.into_boxed_str())).await;
+}
diff --git a/crates/agent_tasks_panel/src/panel.rs b/crates/agent_tasks_panel/src/panel.rs
index 3ec5bbc..96f158b 100644
--- a/crates/agent_tasks_panel/src/panel.rs
+++ b/crates/agent_tasks_panel/src/panel.rs
@@ -14,8 +14,9 @@ use std::{cmp::Reverse, ops::Range, sync::Arc};
 use acp_thread::{AgentTask, AgentTaskId, AgentTaskStatus};
 use agent_client_protocol::v2::schema as acp_v2;
 use agent_ui::{
-    AgentPanel, DEFAULT_THREAD_TITLE, OpenAgentSessionOptions, OpenedAgentSession, TaskRecord,
-    TasksCard, collapse_label, display_label, open_agent_session, task_log,
+    AgentPanel, DEFAULT_THREAD_TITLE, OpenAgentSessionOptions, OpenedAgentSession,
+    OpenedAgentSubagent, SUBAGENT_TASK_ID_PREFIX, TASK_WAITING_LABEL, TaskRecord, TasksCard,
+    collapse_label, display_label, open_agent_session, open_agent_subagent, task_log,
     thread_metadata_store::ThreadMetadataStore,
 };
 use anyhow::{Result, anyhow};
@@ -38,7 +39,28 @@ use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
 };
 
-use crate::{Availability, HistoryEntry, TaskHistoryStore, TaskKey};
+use crate::{Availability, HistoryEntry, TaskHistoryStore, TaskKey, TaskOrigin};
+
+mod sub_thread_row;
+
+/// What a native subagent row says when only its parent could be shown (R2.4).
+const PARENT_ONLY_NOTICE: &str =
+    "This subagent's transcript is only available from its parent thread";
+
+/// The child session of a native subagent row: the live record names it; a recorded one is
+/// told by its origin, and its task id carries the child after the prefix.
+fn native_subagent_child(key: &TaskKey, records: &[HistoryEntry]) -> Option<acp_v2::SessionId> {
+    let entry = records
+        .iter()
+        .find(|entry| entry.record.session_id == key.0 && entry.record.task_id == key.1)?;
+    if let Some(child) = &entry.record.child_session_id {
+        return Some(child.clone());
+    }
+    (entry.origin == TaskOrigin::AcpSubagent)
+        .then(|| entry.record.task_id.strip_prefix(SUBAGENT_TASK_ID_PREFIX))
+        .flatten()
+        .map(|child| acp_v2::SessionId::new(child.to_string()))
+}
 
 /// What a row says when its thread opened but holds no entry for the task's starting tool call:
 /// no id was recorded, or the call belongs to a nested subagent (story 023 R3.3).
@@ -483,7 +505,10 @@ impl AgentTasksPanel {
                     .is_running()
                     .then(|| store.live_task(&key.0, &key.1, cx))
                     .flatten()
-                    .map(|(_card, task)| task);
+                    .map(|(card, task)| {
+                        let waiting = card.read(cx).live_task_waiting(&key.0, &key.1, cx);
+                        LiveTask { task, waiting }
+                    });
                 let selected = self.selection.contains(&key);
                 let notice = self.notices.get(&key).cloned();
                 panel_row(
@@ -751,9 +776,16 @@ impl AgentTasksPanel {
     /// listener is updating this panel, and focusing the agent panel in a dock this panel
     /// shares would update it again (story 024 R1.7).
     pub fn open_task(&mut self, key: &TaskKey, window: &mut Window, cx: &mut Context<Self>) {
+        if let Some(child) = native_subagent_child(key, self.store.read(cx).records()) {
+            self.open_native_subagent(key, child, window, cx);
+            return;
+        }
         let (session_id, task_id) = key;
+        // A sub-thread item is keyed by its parent's session but opens its own (story 040).
+        let sub_session = sub_thread_row::sub_thread_session(key, self.store.read(cx).records());
+        let target = sub_session.as_ref().unwrap_or(session_id);
         let thread_exists = ThreadMetadataStore::try_global(cx)
-            .is_some_and(|store| store.read(cx).entry_by_session(session_id).is_some());
+            .is_some_and(|store| store.read(cx).entry_by_session(target).is_some());
         if !thread_exists {
             log::info!(
                 "agent tasks panel: not opening task {task_id} of session {session_id}: \
@@ -773,6 +805,9 @@ impl AgentTasksPanel {
             })
             .and_then(|entry| entry.record.tool_call_id.clone())
             .map(|id| acp_v2::ToolCallId::new(id.to_string()));
+        let target = target.clone();
+        // The sub-thread is opened as a thread, not at a task of it.
+        let task_id = sub_session.is_none().then(|| key.1.clone());
         let key = key.clone();
         let workspace = self.workspace.clone();
         let fallback_window = window.window_handle();
@@ -781,12 +816,12 @@ impl AgentTasksPanel {
                 let app_state =
                     workspace.read_with(cx, |workspace, _| workspace.app_state().clone())?;
                 anyhow::Ok(open_agent_session(
-                    key.0.clone(),
+                    target,
                     OpenAgentSessionOptions {
                         cwd: None,
                         fallback_window: Some(fallback_window),
                         tool_call_id,
-                        task_id: Some(key.1.clone()),
+                        task_id,
                     },
                     app_state,
                     cx,
@@ -803,6 +838,82 @@ impl AgentTasksPanel {
         .detach();
     }
 
+    /// Opens a native subagent through its parent, never through the metadata store, which
+    /// does not hold children (story 064, R1.6, R2.4).
+    fn open_native_subagent(
+        &mut self,
+        key: &TaskKey,
+        child: acp_v2::SessionId,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let (session_id, task_id) = key;
+        let parent_exists = ThreadMetadataStore::try_global(cx)
+            .is_some_and(|store| store.read(cx).entry_by_session(session_id).is_some());
+        if !parent_exists {
+            log::info!(
+                "agent tasks panel: not opening subagent {child} of session {session_id}: its \
+                 parent thread no longer exists"
+            );
+            self.set_notice(key, "Thread no longer exists", cx);
+            return;
+        }
+        let parent = session_id.clone();
+        let task_id = task_id.clone();
+        let key = key.clone();
+        let workspace = self.workspace.clone();
+        let fallback_window = window.window_handle();
+        cx.spawn(async move |this, cx| {
+            let routing = cx.update(|cx| {
+                let app_state =
+                    workspace.read_with(cx, |workspace, _| workspace.app_state().clone())?;
+                anyhow::Ok(open_agent_subagent(
+                    parent,
+                    child,
+                    OpenAgentSessionOptions {
+                        cwd: None,
+                        fallback_window: Some(fallback_window),
+                        tool_call_id: None,
+                        task_id: Some(task_id),
+                    },
+                    app_state,
+                    cx,
+                ))
+            });
+            let opened = match routing {
+                Ok(routing) => routing.await,
+                Err(error) => Err(error.context("this panel's workspace is gone")),
+            };
+            this.update(cx, |panel, cx| panel.finish_native_open(&key, opened, cx))
+                .ok();
+        })
+        .detach();
+    }
+
+    fn finish_native_open(
+        &mut self,
+        key: &TaskKey,
+        opened: Result<OpenedAgentSubagent>,
+        cx: &mut Context<Self>,
+    ) {
+        let (session_id, task_id) = key;
+        match opened {
+            Ok(opened) if !opened.child_shown => self.set_notice(key, PARENT_ONLY_NOTICE, cx),
+            Ok(_) => {
+                if self.notices.remove(key).is_some() {
+                    cx.notify();
+                }
+            }
+            Err(error) => {
+                log::warn!(
+                    "agent tasks panel: cannot open subagent task {task_id} of session \
+                     {session_id}: {error:#}"
+                );
+                self.set_notice(key, format!("{error:#}"), cx);
+            }
+        }
+    }
+
     /// Shows how opening `key`'s thread ended: a failure on the task's row, with a WARN line
     /// naming the session and the task (story 024 R1.6); an open with nothing to jump to says
     /// so (story 023 R3.3); a jump clears an earlier notice.
@@ -1607,8 +1718,15 @@ fn render_row(
     let row_id = row_element_id(&key.0, &key.1);
     let control_id =
         |control: &'static str| ElementId::NamedChild(Arc::new(row_id.clone()), control.into());
-    let (detail, detail_color) = match row.notice.as_ref().or(row.error.as_ref()) {
+    let sub_thread = sub_thread_row::is_sub_thread(&row.entry);
+    let message = row.notice.as_ref().or(row.error.as_ref());
+    let (detail, detail_color) = match message {
         Some(message) => (message.clone(), notice_color(message)),
+        None if sub_thread => (
+            sub_thread_row::sub_thread_state(record).into(),
+            Color::Muted,
+        ),
+        None if record.waiting => (row_detail(record).into(), Color::Warning),
         None => (row_detail(record).into(), status_color(record.status)),
     };
     // The user's label wins over the description, which `panel_row` keeps live (R3.9). The
@@ -1635,12 +1753,15 @@ fn render_row(
         .min_w_0()
         .max_w(relative(0.4))
         .flex_shrink_1()
-        .child(
+        .child(if sub_thread && message.is_none() {
+            sub_thread_row::sub_thread_state_label(record, detail_color)
+        } else {
             Label::new(detail)
                 .size(LabelSize::XSmall)
                 .color(detail_color)
-                .truncate(),
-        );
+                .truncate()
+                .into_any_element()
+        });
 
     let open_or_rename = match rename_editor {
         Some(editor) => render_rename_editor(label_slot.child(editor), detail_label, cx),
@@ -1660,20 +1781,27 @@ fn render_row(
                         // A flex line, so the text is as wide as itself and starts at the slot's
                         // left edge, shrinking only to truncate.
                         label_slot.child(
-                            h_flex().w_full().min_w_0().child(
-                                div()
-                                    .debug_selector({
-                                        let task_id = task_id.clone();
-                                        move || format!("agent-tasks-row-label-text-{task_id}")
-                                    })
-                                    .min_w_0()
-                                    .child(
-                                        Label::new(shown_label.clone())
-                                            .size(LabelSize::Small)
-                                            .single_line()
-                                            .truncate(),
-                                    ),
-                            ),
+                            h_flex()
+                                .w_full()
+                                .min_w_0()
+                                .gap_1()
+                                .when(sub_thread, |this| {
+                                    this.child(sub_thread_row::sub_thread_chip(record))
+                                })
+                                .child(
+                                    div()
+                                        .debug_selector({
+                                            let task_id = task_id.clone();
+                                            move || format!("agent-tasks-row-label-text-{task_id}")
+                                        })
+                                        .min_w_0()
+                                        .child(
+                                            Label::new(shown_label.clone())
+                                                .size(LabelSize::Small)
+                                                .single_line()
+                                                .truncate(),
+                                        ),
+                                ),
                         ),
                     )
                     .child(detail_label),
@@ -1969,9 +2097,15 @@ fn parse_dock_position(text: &str) -> Option<DockPosition> {
     }
 }
 
-/// `Running · 500 tokens · 4 tool uses`, or the outcome alone when no usage was reported.
+/// `Running · 500 tokens · 4 tool uses`, or the outcome alone when no usage was reported. A
+/// subagent waiting on the operator reads `Waiting for you` in place of `Running` (R1.8).
 fn row_detail(record: &TaskRecord) -> String {
-    let mut parts = vec![status_label(record.status).to_string()];
+    let status = if record.waiting && record.status.is_running() {
+        TASK_WAITING_LABEL
+    } else {
+        status_label(record.status)
+    };
+    let mut parts = vec![status.to_string()];
     if let Some(tokens) = record.tokens {
         parts.push(format!("{tokens} tokens"));
     }
@@ -2003,19 +2137,26 @@ fn status_color(status: AgentTaskStatus) -> Color {
     }
 }
 
+/// The card's view of a task while it runs, and whether it waits on the operator.
+struct LiveTask {
+    task: AgentTask,
+    waiting: bool,
+}
+
 /// A row for `entry`, with `live` — the card's view of the task while it runs — laid over
-/// the stored record, which holds no progress.
+/// the stored record, which holds no progress and never the waiting state.
 fn panel_row(
     entry: &HistoryEntry,
-    live: Option<AgentTask>,
+    live: Option<LiveTask>,
     selected: bool,
     notice: Option<SharedString>,
     error: Option<SharedString>,
 ) -> PanelRow {
     let mut entry = entry.clone();
     let can_stop = live.is_some();
-    if let Some(task) = live {
+    if let Some(LiveTask { task, waiting }) = live {
         let record = &mut entry.record;
+        record.waiting = waiting;
         record.status = task.status;
         record.description = task.description;
         record.tokens = task.usage.as_ref().map(|usage| usage.tokens);
@@ -2354,6 +2495,10 @@ mod close_button;
 #[path = "panel/scrollbar_tests.rs"]
 mod scrollbar;
 
+#[cfg(test)]
+#[path = "panel/sub_thread_row_tests.rs"]
+mod sub_thread_row_tests;
+
 #[cfg(test)]
 #[path = "panel/text_filter_tests.rs"]
 mod text_filter;
@@ -2386,6 +2531,14 @@ mod header_archive_filter;
 #[path = "panel/open_log_tests.rs"]
 mod open_log;
 
+#[cfg(test)]
+#[path = "panel/native_subagent_tests.rs"]
+mod native_subagent;
+
+#[cfg(test)]
+#[path = "panel/waiting_label_tests.rs"]
+mod waiting_label;
+
 #[cfg(test)]
 #[path = "panel/open_log_remote_tests.rs"]
 mod open_log_remote;
diff --git a/crates/agent_tasks_panel/src/panel/native_subagent_tests.rs b/crates/agent_tasks_panel/src/panel/native_subagent_tests.rs
new file mode 100644
index 0000000..429ed3d
--- /dev/null
+++ b/crates/agent_tasks_panel/src/panel/native_subagent_tests.rs
@@ -0,0 +1,132 @@
+// Story 064, task 5.1 — the Agent Tasks panel lists native subagents live and opens them (R1.1,
+// R1.6). Module path: `panel::native_subagent`; wired in `src/panel.rs` (see
+// `native_subagent_support.rs` for the wiring and the contract assumed). Validation:
+// `cargo test -p agent_tasks_panel panel`.
+//
+// Side effects are read where they land: which session the conversation shows, which thread
+// the agent panel holds as root, how many windows exist, the row's notice and the scroll
+// position of the shown thread.
+//
+// Hostile halves first:
+//   - Open on a loaded child shows THAT child: not its parent (which is what Open does today for
+//     a row keyed by the parent's session), not its sibling;
+//   - then the benign live rows.
+//
+// The unloaded child's fallback (R2.4) and the restart listing (R2.3) need a store swap, which
+// only the store module can make: they are in `store/panel_native_subagent_restart_tests.rs`.
+
+use gpui::TestAppContext;
+
+use crate::native_subagent_support::*;
+use crate::test_helpers::*;
+
+/// The agent panel's root thread (the conversation it holds), whatever it navigated to.
+fn root_session(panels: &Panels) -> Option<agent_client_protocol::v2::schema::SessionId> {
+    active_session(panels)
+}
+
+// ── R1.6 — Open on a loaded child navigates to it ───────────────────────────
+
+/// Hostile: Open on Bravo shows Bravo — not the parent (the key's own session), not Alpha.
+#[gpui::test]
+async fn panel_native_subagent_open_on_a_loaded_child_shows_that_child(cx: &mut TestAppContext) {
+    init_base(cx);
+    let mut panels = open_panels(cx).await;
+    let connection = rfd_connection();
+    let parent = open_thread_on(
+        &panels.agent_panel,
+        connection.clone(),
+        "Parent",
+        &mut panels.cx,
+    );
+    let parent_session = session_of(&parent, &panels.cx);
+    let alpha = spawn_child(&parent, &connection, "alpha", "Alpha", &mut panels.cx);
+    let bravo = spawn_child(&parent, &connection, "bravo", "Bravo", &mut panels.cx);
+    report_running(&alpha, &mut panels.cx);
+    report_running(&bravo, &mut panels.cx);
+    let key = subagent_key(&parent_session, "bravo");
+    assert!(
+        find_row(&mut panels, &key.0.to_string(), key.1.as_ref()).is_some(),
+        "precondition: Bravo is a live row of the panel"
+    );
+    assert_eq!(
+        conversation_shows(&panels.agent_panel, &panels.cx),
+        Some(parent_session.clone()),
+        "precondition: the conversation shows the parent"
+    );
+
+    open_from(&panels.tasks_panel, &key, &mut panels.cx);
+
+    assert_eq!(
+        conversation_shows(&panels.agent_panel, &panels.cx),
+        Some(agent_client_protocol::v2::schema::SessionId::new("bravo")),
+        "Open on a loaded child navigates the conversation to it"
+    );
+    assert_eq!(
+        root_session(&panels),
+        Some(parent_session),
+        "the parent stays the conversation's root"
+    );
+    let notice = find_row(&mut panels, &key.0.to_string(), key.1.as_ref())
+        .expect("still listed")
+        .notice;
+    assert_eq!(
+        notice, None,
+        "a navigation that reached the child says nothing"
+    );
+}
+
+// ── R1.1 — live rows from the merger ────────────────────────────────────────
+
+/// Two children, two live rows under the parent's group, keyed by child — and the parent's
+/// group is the only group: a child is never a thread group of its own.
+#[gpui::test]
+async fn panel_native_subagent_children_are_live_rows_of_their_parent(cx: &mut TestAppContext) {
+    init_base(cx);
+    let mut panels = open_panels(cx).await;
+    let connection = rfd_connection();
+    let parent = open_thread_on(
+        &panels.agent_panel,
+        connection.clone(),
+        "Parent",
+        &mut panels.cx,
+    );
+    let parent_session = session_of(&parent, &panels.cx);
+    let alpha = spawn_child(&parent, &connection, "alpha", "Same", &mut panels.cx);
+    let bravo = spawn_child(&parent, &connection, "bravo", "Same", &mut panels.cx);
+    report_running(&alpha, &mut panels.cx);
+    report_running(&bravo, &mut panels.cx);
+
+    let groups = groups(&mut panels);
+    assert!(
+        groups
+            .iter()
+            .all(|group| group.session_id.to_string() != "alpha"
+                && group.session_id.to_string() != "bravo"),
+        "a child is not a thread group of its own"
+    );
+    let parent_group = groups
+        .iter()
+        .find(|group| group.session_id == parent_session)
+        .expect("the parent's group lists its children");
+    let keys: Vec<String> = parent_group
+        .rows
+        .iter()
+        .map(|row| row.entry.record.task_id.to_string())
+        .collect();
+    assert!(
+        keys.contains(&"acp-subagent:alpha".to_string())
+            && keys.contains(&"acp-subagent:bravo".to_string()),
+        "two children sharing a title are two rows: {keys:?}"
+    );
+    let row = find_row(
+        &mut panels,
+        &parent_session.to_string(),
+        "acp-subagent:alpha",
+    )
+    .expect("Alpha's row");
+    assert!(
+        row.can_stop,
+        "a running child with a cancel grant can be stopped from the panel"
+    );
+}
diff --git a/crates/agent_tasks_panel/src/panel/sub_thread_row.rs b/crates/agent_tasks_panel/src/panel/sub_thread_row.rs
new file mode 100644
index 0000000..a8ce884
--- /dev/null
+++ b/crates/agent_tasks_panel/src/panel/sub_thread_row.rs
@@ -0,0 +1,73 @@
+//! Story 040: what a sub-thread item changes in a panel row (design.md, AgentTasksPanel rows).
+//!
+//! A sub-thread row is a row like any other — selection, archive, delete and rename stay on the
+//! shared `TaskKey` paths (R7.6) — except for three things kept here: its chip, its state and the
+//! session its activation opens. Stop and Open Log never show, because a sub-thread has no live
+//! card task and no saved log (`PanelRow::can_stop`, `can_open_log`).
+
+use acp_thread::AgentTaskStatus;
+use agent_client_protocol::v2::schema as acp_v2;
+use agent_ui::TaskRecord;
+use gpui::{AnyElement, SharedString};
+use ui::{Chip, Tooltip, prelude::*};
+
+use crate::history_db::SUB_THREAD_TASK_PREFIX;
+use crate::{HistoryEntry, TaskKey, TaskOrigin};
+
+pub(super) fn is_sub_thread(entry: &HistoryEntry) -> bool {
+    entry.origin == TaskOrigin::SubThread
+}
+
+/// "Running" while the stored status is running, "Idle" otherwise: the panel holds no handle
+/// on a reporter, and an interrupted item after a restart is simply idle (R5.4, R7.1).
+pub(super) fn sub_thread_state(record: &TaskRecord) -> &'static str {
+    if record.status == AgentTaskStatus::Running {
+        "Running"
+    } else {
+        "Idle"
+    }
+}
+
+/// The detail slot of a sub-thread row: its state, under a selector naming it.
+pub(super) fn sub_thread_state_label(record: &TaskRecord, color: Color) -> AnyElement {
+    let state = sub_thread_state(record);
+    let task_id = record.task_id.clone();
+    div()
+        .debug_selector(move || {
+            format!(
+                "agent-tasks-sub-thread-state-{}-{task_id}",
+                state.to_lowercase()
+            )
+        })
+        .min_w_0()
+        .child(Label::new(state).size(LabelSize::XSmall).color(color))
+        .into_any_element()
+}
+
+pub(super) fn sub_thread_chip(record: &TaskRecord) -> AnyElement {
+    let task_id = record.task_id.clone();
+    div()
+        .id(SharedString::from(format!(
+            "agent-tasks-sub-thread-chip-{task_id}"
+        )))
+        .debug_selector(move || format!("agent-tasks-sub-thread-chip-{task_id}"))
+        .flex_none()
+        .tooltip(Tooltip::text("A sub-thread of this thread"))
+        .child(Chip::new("Sub-thread").label_size(LabelSize::XSmall))
+        .into_any_element()
+}
+
+/// The session an activation of `key` opens: the sub-thread's own for a sub-thread item, never
+/// the parent's session that keys its row (R7.2); `None` for an adapter task.
+pub(super) fn sub_thread_session(
+    key: &TaskKey,
+    records: &[HistoryEntry],
+) -> Option<acp_v2::SessionId> {
+    let (session_id, task_id) = key;
+    records
+        .iter()
+        .find(|entry| entry.record.session_id == *session_id && entry.record.task_id == *task_id)
+        .filter(|entry| is_sub_thread(entry))?;
+    let sub = task_id.as_ref().strip_prefix(SUB_THREAD_TASK_PREFIX)?;
+    Some(acp_v2::SessionId::new(sub.to_string()))
+}
diff --git a/crates/agent_tasks_panel/src/panel/sub_thread_row_tests.rs b/crates/agent_tasks_panel/src/panel/sub_thread_row_tests.rs
new file mode 100644
index 0000000..b9b3b60
--- /dev/null
+++ b/crates/agent_tasks_panel/src/panel/sub_thread_row_tests.rs
@@ -0,0 +1,337 @@
+// Story 040, task 5.1 — sub-thread rows in the Agent Tasks panel (R7.1–R7.5, R7.7; Q11, Q12).
+// Module path: `panel::sub_thread_row_tests` (the production row lives in
+// `panel/sub_thread_row.rs`, so the test module takes another name). Wired in `src/panel.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "panel/sub_thread_row_tests.rs"]
+//     mod sub_thread_row_tests;
+//
+// Validation filter: `cargo test -p agent_tasks_panel -- panel` (names carry `panel_sub_thread`).
+//
+// Contract (design.md §AgentTasksPanel rows):
+//   - a `TaskOrigin::SubThread` entry is listed in its parent's group (`PanelGroup::session_id` =
+//     the parent's session) with the chip "Sub-thread" and the state Running when
+//     `entry.record.status == Running`, else Idle (design.md 2026-10-20); no Stop,
+//     no Open Log, no Expand (`PanelRow::can_stop` and `can_open_log` false);
+//   - filters: Active = running sub-threads too; Finished = sub-threads not running; Failed never
+//     holds one; the text filter matches the description (the title);
+//   - activation strips `zeo-sub-thread:` and opens THAT session through `open_agent_session`;
+//     with no metadata row for it the row says "Thread no longer exists".
+// Assumed beyond design.md — the debug selectors, kept in `CHIP` and `STATE` below so a rename is
+// one edit: `agent-tasks-sub-thread-chip-{task_id}` on the chip, and
+// `agent-tasks-sub-thread-state-{running|idle}-{task_id}` on the state label.
+// The store is fed as story 014's panel tests feed it: events on the agent panel's real card.
+//
+// Hostile halves first:
+//   - activation must open the SUB-thread, never the parent whose session keys the row; a
+//     sub-thread whose own thread is gone says so even though its parent exists (the shipped
+//     `open_task` checks the key's session — the parent's — and would open it);
+//   - a running sub-thread is not Finished and an idle one is not Active; neither is Failed;
+//   - the derived id `zeo-sub-thread:…` is not text the filter matches; the title is;
+//   - an adapter row keeps Stop and has no chip.
+
+use acp_thread::AgentTaskStatus;
+use agent_client_protocol::v2::schema as acp_v2;
+use agent_ui::{SubThreadActivity, SubThreadRecord, TasksCard};
+use gpui::{Entity, TestAppContext, VisualTestContext};
+
+use crate::test_helpers::*;
+use crate::{AgentTasksPanel, TaskFilter};
+
+const PREFIX: &str = "zeo-sub-thread:";
+
+fn chip_selector(task_id: &str) -> String {
+    format!("agent-tasks-sub-thread-chip-{task_id}")
+}
+
+fn state_selector(state: &str, task_id: &str) -> String {
+    format!("agent-tasks-sub-thread-state-{state}-{task_id}")
+}
+
+fn sub_record(parent: &acp_v2::SessionId, sub: &str, title: &str) -> SubThreadRecord {
+    SubThreadRecord {
+        parent_session: parent.clone(),
+        sub_session: sid(sub),
+        title: title.to_string().into(),
+        project: "project".into(),
+        at_ms: 1_000,
+    }
+}
+
+fn card_of(panels: &Panels) -> Entity<TasksCard> {
+    panels
+        .agent_panel
+        .read_with(&panels.cx, |panel, _| panel.tasks_card().clone())
+}
+
+fn emit_sub(panels: &mut Panels, activity: SubThreadActivity) {
+    let card = card_of(panels);
+    card.update(&mut panels.cx, |_, cx| cx.emit(activity));
+    panels.cx.run_until_parked();
+}
+
+fn draw(cx: &mut VisualTestContext) {
+    cx.update(|window, _| window.refresh());
+    cx.run_until_parked();
+}
+
+fn show_tasks_panel(panels: &mut Panels) {
+    let workspace = panels.workspace.clone();
+    workspace.update_in(&mut panels.cx, |workspace, window, cx| {
+        workspace.focus_panel::<AgentTasksPanel>(window, cx);
+    });
+    draw(&mut panels.cx);
+}
+
+fn painted(panels: &mut Panels, selector: String) -> bool {
+    let selector: &'static str = Box::leak(selector.into_boxed_str());
+    panels.cx.debug_bounds(selector).is_some()
+}
+
+fn only_filter(panels: &mut Panels, only: TaskFilter) {
+    let panel = panels.tasks_panel.clone();
+    panel.update(&mut panels.cx, |panel, cx| {
+        for filter in TaskFilter::ALL {
+            panel.set_filter(filter, filter == only, cx);
+        }
+    });
+    panels.cx.run_until_parked();
+}
+
+fn sub_task(sub: &str) -> String {
+    format!("{PREFIX}{sub}")
+}
+
+// ── Activation (R7.2, R7.3) ────────────────────────────────────────────────
+
+#[gpui::test]
+async fn panel_sub_thread_activation_opens_the_sub_thread_not_the_parent(cx: &mut TestAppContext) {
+    init_base(cx);
+    let mut panels = open_panels(cx).await;
+    let child = open_live_thread(&mut panels, "Child");
+    let child_session = session_of(&child, &panels.cx);
+    let parent = open_live_thread(&mut panels, "Parent");
+    let parent_session = session_of(&parent, &panels.cx);
+    assert_eq!(
+        active_session(&panels),
+        Some(parent_session.clone()),
+        "precondition"
+    );
+
+    emit_sub(
+        &mut panels,
+        SubThreadActivity::Linked(sub_record(
+            &parent_session,
+            &child_session.to_string(),
+            "Child",
+        )),
+    );
+    let item = key(
+        &parent_session.to_string(),
+        &sub_task(&child_session.to_string()),
+    );
+    let tasks_panel = panels.tasks_panel.clone();
+    open_from(&tasks_panel, &item, &mut panels.cx);
+
+    assert_eq!(
+        active_session(&panels),
+        Some(child_session.clone()),
+        "the sub-thread is shown, not its parent"
+    );
+    let notice = notice_of(
+        &tasks_panel,
+        &parent_session,
+        &sub_task(&child_session.to_string()),
+        &mut panels.cx,
+    );
+    assert!(
+        notice.as_deref() != Some("Thread no longer exists"),
+        "the sub-thread exists: {notice:?}"
+    );
+}
+
+#[gpui::test]
+async fn panel_sub_thread_gone_says_so_even_though_its_parent_exists(cx: &mut TestAppContext) {
+    init_base(cx);
+    let mut panels = open_panels(cx).await;
+    let parent = open_live_thread(&mut panels, "Parent");
+    let parent_session = session_of(&parent, &panels.cx);
+    // A sub-thread no metadata row knows: deleted since it was recorded.
+    emit_sub(
+        &mut panels,
+        SubThreadActivity::Linked(sub_record(&parent_session, "sub-gone", "Gone")),
+    );
+    let tasks_panel = panels.tasks_panel.clone();
+    let item = key(&parent_session.to_string(), &sub_task("sub-gone"));
+    let windows = window_count(&mut panels.cx);
+
+    open_from(&tasks_panel, &item, &mut panels.cx);
+
+    assert_eq!(
+        notice_of(
+            &tasks_panel,
+            &parent_session,
+            &sub_task("sub-gone"),
+            &mut panels.cx
+        )
+        .as_deref(),
+        Some("Thread no longer exists")
+    );
+    assert_eq!(
+        active_session(&panels),
+        Some(parent_session),
+        "nothing else is opened in its place"
+    );
+    assert_eq!(window_count(&mut panels.cx), windows, "no window is opened");
+}
+
+// ── Filters (R7.4) ─────────────────────────────────────────────────────────
+
+#[gpui::test]
+async fn panel_sub_thread_running_is_active_only_and_idle_is_finished_only(
+    cx: &mut TestAppContext,
+) {
+    init_base(cx);
+    let mut panels = open_panels(cx).await;
+    let parent = sid("p1");
+    emit_sub(
+        &mut panels,
+        SubThreadActivity::Linked(sub_record(&parent, "sub-run", "Runner")),
+    );
+    emit_sub(
+        &mut panels,
+        SubThreadActivity::Running(sub_record(&parent, "sub-run", "Runner")),
+    );
+    emit_sub(
+        &mut panels,
+        SubThreadActivity::Linked(sub_record(&parent, "sub-idle", "Sleeper")),
+    );
+    let running = ("p1".to_string(), sub_task("sub-run"));
+    let idle = ("p1".to_string(), sub_task("sub-idle"));
+
+    only_filter(&mut panels, TaskFilter::Finished);
+    let finished = visible_keys(&mut panels);
+    assert!(
+        !finished.contains(&running),
+        "a running sub-thread is not Finished"
+    );
+    assert!(finished.contains(&idle), "an idle sub-thread is Finished");
+
+    only_filter(&mut panels, TaskFilter::Active);
+    let active = visible_keys(&mut panels);
+    assert!(!active.contains(&idle), "an idle sub-thread is not Active");
+    assert!(active.contains(&running), "a running sub-thread is Active");
+
+    only_filter(&mut panels, TaskFilter::Failed);
+    let failed = visible_keys(&mut panels);
+    assert!(
+        !failed.contains(&running) && !failed.contains(&idle),
+        "Failed never holds a sub-thread"
+    );
+}
+
+// ── Text filter (R7.5) ─────────────────────────────────────────────────────
+
+#[gpui::test]
+async fn panel_sub_thread_text_filter_matches_the_title_not_the_derived_id(
+    cx: &mut TestAppContext,
+) {
+    init_base(cx);
+    let mut panels = open_panels(cx).await;
+    let parent = sid("p1");
+    emit_sub(
+        &mut panels,
+        SubThreadActivity::Linked(sub_record(&parent, "sub-a", "Quokka research")),
+    );
+    let item = ("p1".to_string(), sub_task("sub-a"));
+    let tasks_panel = panels.tasks_panel.clone();
+
+    tasks_panel.update_in(&mut panels.cx, |panel, window, cx| {
+        panel.set_filter_query("zeo-sub-thread", window, cx)
+    });
+    panels.cx.run_until_parked();
+    assert!(
+        !visible_keys(&mut panels).contains(&item),
+        "the derived id is not text the row shows"
+    );
+
+    tasks_panel.update_in(&mut panels.cx, |panel, window, cx| {
+        panel.set_filter_query("quokka", window, cx)
+    });
+    panels.cx.run_until_parked();
+    assert!(
+        visible_keys(&mut panels).contains(&item),
+        "the title matches"
+    );
+}
+
+// ── Row shape (R7.1, R7.7) ─────────────────────────────────────────────────
+
+#[gpui::test]
+async fn panel_sub_thread_row_has_its_chip_and_no_stop_or_log_beside_an_adapter_row(
+    cx: &mut TestAppContext,
+) {
+    init_base(cx);
+    let mut panels = open_panels(cx).await;
+    let parent = open_live_thread(&mut panels, "Parent");
+    let parent_session = session_of(&parent, &panels.cx);
+    send_tasks(
+        &parent,
+        vec![wire_task("t1", "running", 1_000, None)],
+        &mut panels.cx,
+    );
+    emit_sub(
+        &mut panels,
+        SubThreadActivity::Linked(sub_record(&parent_session, "sub-a", "Alpha")),
+    );
+    emit_sub(
+        &mut panels,
+        SubThreadActivity::Running(sub_record(&parent_session, "sub-a", "Alpha")),
+    );
+    show_tasks_panel(&mut panels);
+
+    let groups = groups(&mut panels);
+    let group = groups
+        .iter()
+        .find(|group| group.session_id == parent_session)
+        .expect("the parent's group is listed");
+    let sub_row = group
+        .rows
+        .iter()
+        .find(|row| row.entry.record.task_id.as_ref() == sub_task("sub-a"))
+        .expect("the sub-thread is listed in its parent's group");
+    assert!(!sub_row.can_stop, "no Stop on a sub-thread row");
+    assert!(!sub_row.can_open_log, "no Open Log on a sub-thread row");
+    let adapter_row = group
+        .rows
+        .iter()
+        .find(|row| row.entry.record.task_id.as_ref() == "t1")
+        .expect("the adapter task is listed");
+    assert!(adapter_row.can_stop, "the adapter row keeps Stop");
+
+    assert!(
+        !painted(&mut panels, chip_selector("t1")),
+        "an adapter row has no Sub-thread chip"
+    );
+    assert!(
+        painted(&mut panels, chip_selector(&sub_task("sub-a"))),
+        "the chip is drawn"
+    );
+    assert!(
+        painted(&mut panels, state_selector("running", &sub_task("sub-a"))),
+        "a running sub-thread reads Running"
+    );
+
+    emit_sub(
+        &mut panels,
+        SubThreadActivity::Idle(sub_record(&parent_session, "sub-a", "Alpha")),
+    );
+    draw(&mut panels.cx);
+    assert!(
+        painted(&mut panels, state_selector("idle", &sub_task("sub-a"))),
+        "a stopped sub-thread reads Idle"
+    );
+    let row = find_row(&mut panels, &parent_session.to_string(), &sub_task("sub-a"))
+        .expect("still listed once idle");
+    assert_ne!(row.entry.record.status, AgentTaskStatus::Running);
+}
diff --git a/crates/agent_tasks_panel/src/panel/waiting_label_tests.rs b/crates/agent_tasks_panel/src/panel/waiting_label_tests.rs
new file mode 100644
index 0000000..d7ccc7e
--- /dev/null
+++ b/crates/agent_tasks_panel/src/panel/waiting_label_tests.rs
@@ -0,0 +1,276 @@
+// Story 064, task 11.1 — the Agent Tasks panel's row for a subagent that needs the operator reads
+// "Waiting for you", never "Running"; a running one keeps "Running"; nothing new is persisted
+// (R1.8). Fixup of `0071`. Module path: `panel::waiting_label`; wired in `src/panel.rs` beside
+// `mod native_subagent;`:
+//
+//     #[cfg(test)]
+//     #[path = "panel/waiting_label_tests.rs"]
+//     mod waiting_label;
+//
+// Validation filter: `cargo test -p agent_tasks_panel panel`. Fixtures: `native_subagent_support.rs`.
+//
+// Contract (design.md, "Waiting text (R1.8)"): the row's status text is `row_detail(&TaskRecord)`,
+// the panel's own (private, reached as `super::row_detail`), which `render_row` paints for every
+// row that is neither a sub-thread nor carrying a notice; the Waiting state reaches it as a
+// live-only flag on the `TaskRecord` the card hands over, laid over the stored record by
+// `panel_row`. `record.status` stays `AgentTaskStatus::Running`, in the row, in the store and in
+// the history db, and `agent_task_history` gains no column. If the flag lands elsewhere, adjust
+// `detail` below only.
+//
+// Hostile halves first:
+//   - Waiting must NOT collapse into Running: the waiting child's row text is not "Running";
+//   - Running must NOT split into Waiting: a running sibling and a feed task of the same parent
+//     keep "Running";
+//   - Waiting must NOT be persisted: the stored record and the db say Running (or nothing) while
+//     the child waits, the db's columns are the ones it had before, and no stored status reads
+//     "waiting" — also after the child ends;
+//   - then the benign half: the child runs again and its row reads Running.
+
+use acp_thread::AgentTaskStatus;
+use agent_client_protocol::v2::schema as acp_v2;
+use gpui::{Entity, TestAppContext};
+
+use crate::native_subagent_support::*;
+use crate::test_helpers::*;
+use crate::{AgentTaskHistoryDb, TaskHistoryStore};
+
+/// The status text R1.8 names, byte for byte.
+const WAITING: &str = "Waiting for you";
+
+fn detail(row: &super::PanelRow) -> String {
+    super::row_detail(&row.entry.record)
+}
+
+fn report_waiting(child: &Entity<acp_thread::AcpThread>, panels: &mut Panels) {
+    report(
+        child,
+        acp_v2::StateUpdate::RequiresAction(acp_v2::RequiresActionStateUpdate::new()),
+        &mut panels.cx,
+    );
+}
+
+fn row(panels: &mut Panels, parent: &acp_v2::SessionId, task: &str) -> super::PanelRow {
+    find_row(panels, &parent.to_string(), task)
+        .unwrap_or_else(|| panic!("{task} is a row of the parent's group"))
+}
+
+async fn columns(db: &AgentTaskHistoryDb) -> Vec<String> {
+    db.write(|connection| {
+        connection
+            .select::<String>("SELECT name FROM pragma_table_info('agent_task_history')")
+            .expect("select")()
+        .expect("run")
+    })
+    .await
+}
+
+async fn stored_statuses(db: &AgentTaskHistoryDb) -> Vec<(String, String)> {
+    db.write(|connection| {
+        connection
+            .select::<(String, String)>("SELECT task_id, status FROM agent_task_history")
+            .expect("select")()
+        .expect("run")
+    })
+    .await
+}
+
+/// The record the store holds for `key`, without the card's live values laid over it.
+fn store_status(panels: &mut Panels, parent: &acp_v2::SessionId, task: &str) -> AgentTaskStatus {
+    panels.cx.update(|_, cx| {
+        let store = TaskHistoryStore::try_global(cx).expect("the crate's init made the store");
+        store
+            .read(cx)
+            .records()
+            .iter()
+            .find(|entry| {
+                entry.record.session_id == *parent && entry.record.task_id.as_ref() == task
+            })
+            .map(|entry| entry.record.status)
+            .unwrap_or_else(|| panic!("the store holds {task}"))
+    })
+}
+
+fn assert_reads_waiting(text: &str, what: &str) {
+    assert!(
+        text.starts_with(WAITING),
+        "{what}: a waiting row reads {WAITING:?}, it read {text:?}"
+    );
+    assert!(
+        !text.contains("Running"),
+        "{what}: a waiting row never reads \"Running\": {text:?}"
+    );
+}
+
+// ── R1.8 — Waiting is not Running ───────────────────────────────────────────
+
+/// Hostile: Alpha waits for the operator, Bravo runs. Alpha's row reads "Waiting for you";
+/// Bravo's keeps "Running". Today both read "Running".
+#[gpui::test]
+async fn panel_waiting_label_child_in_requires_action_reads_waiting_for_you(
+    cx: &mut TestAppContext,
+) {
+    init_base(cx);
+    let mut panels = open_panels(cx).await;
+    let connection = rfd_connection();
+    let parent = open_thread_on(
+        &panels.agent_panel,
+        connection.clone(),
+        "Parent",
+        &mut panels.cx,
+    );
+    let parent_session = session_of(&parent, &panels.cx);
+    let alpha = spawn_child(&parent, &connection, "alpha", "Alpha", &mut panels.cx);
+    let bravo = spawn_child(&parent, &connection, "bravo", "Bravo", &mut panels.cx);
+    report_waiting(&alpha, &mut panels);
+    report_running(&bravo, &mut panels.cx);
+
+    let alpha_row = row(&mut panels, &parent_session, "acp-subagent:alpha");
+    let bravo_row = row(&mut panels, &parent_session, "acp-subagent:bravo");
+    assert!(
+        detail(&bravo_row).starts_with("Running") && !detail(&bravo_row).contains(WAITING),
+        "a running sibling keeps \"Running\": {:?}",
+        detail(&bravo_row)
+    );
+    // The waiting row is still a live row: Stop reaches it, Archive does not.
+    assert_eq!(
+        alpha_row.entry.record.status,
+        AgentTaskStatus::Running,
+        "the row's status stays Running; only its text changes"
+    );
+    assert!(alpha_row.can_stop, "a waiting child can still be stopped");
+    assert!(!alpha_row.can_archive, "a waiting child is not archivable");
+    assert_reads_waiting(&detail(&alpha_row), "Alpha");
+}
+
+/// Hostile converse, third element: a feed task of the same parent has no Waiting state and
+/// keeps "Running" beside a waiting child.
+#[gpui::test]
+async fn panel_waiting_label_feed_task_beside_a_waiting_child_reads_running(
+    cx: &mut TestAppContext,
+) {
+    init_base(cx);
+    let mut panels = open_panels(cx).await;
+    let connection = rfd_connection();
+    let parent = open_thread_on(
+        &panels.agent_panel,
+        connection.clone(),
+        "Parent",
+        &mut panels.cx,
+    );
+    let parent_session = session_of(&parent, &panels.cx);
+    let alpha = spawn_child(&parent, &connection, "alpha", "Alpha", &mut panels.cx);
+    report_waiting(&alpha, &mut panels);
+    send_tasks(
+        &parent,
+        vec![wire_task("t1", "running", 1_000, Some((500, 4)))],
+        &mut panels.cx,
+    );
+
+    let feed = row(&mut panels, &parent_session, "t1");
+    assert_eq!(
+        detail(&feed),
+        "Running · 500 tokens · 4 tool uses",
+        "a feed task reads as it always has"
+    );
+    assert_reads_waiting(
+        &detail(&row(&mut panels, &parent_session, "acp-subagent:alpha")),
+        "Alpha",
+    );
+}
+
+// ── R1.8 — nothing new is persisted ─────────────────────────────────────────
+
+/// Hostile: Waiting is live-only. While Alpha waits, the store's record says Running and the db
+/// holds no "waiting"; when Alpha ends, the db row says completed; the table's columns never
+/// change.
+#[gpui::test]
+async fn panel_waiting_label_is_never_persisted(cx: &mut TestAppContext) {
+    let db = init_base(cx);
+    let mut panels = open_panels(cx).await;
+    let columns_before = columns(&db).await;
+    let connection = rfd_connection();
+    let parent = open_thread_on(
+        &panels.agent_panel,
+        connection.clone(),
+        "Parent",
+        &mut panels.cx,
+    );
+    let parent_session = session_of(&parent, &panels.cx);
+    let alpha = spawn_child(&parent, &connection, "alpha", "Alpha", &mut panels.cx);
+    report_waiting(&alpha, &mut panels);
+
+    // Read now, asserted last: the persistence checks below hold today and must keep holding.
+    let waiting_text = detail(&row(&mut panels, &parent_session, "acp-subagent:alpha"));
+    assert_eq!(
+        store_status(&mut panels, &parent_session, "acp-subagent:alpha"),
+        AgentTaskStatus::Running,
+        "the store's record stays Running while the child waits"
+    );
+    let while_waiting = stored_statuses(&db).await;
+    assert!(
+        while_waiting
+            .iter()
+            .filter(|(task, _)| task == "acp-subagent:alpha")
+            .all(|(_, status)| status == "running"),
+        "a waiting child is stored as running, if at all: {while_waiting:?}"
+    );
+    assert_eq!(
+        columns(&db).await,
+        columns_before,
+        "agent_task_history gains no column"
+    );
+
+    report_done(&alpha, &mut panels.cx);
+
+    let after = stored_statuses(&db).await;
+    assert!(
+        after
+            .iter()
+            .any(|(task, status)| task == "acp-subagent:alpha" && status == "completed"),
+        "the ended child is recorded as completed: {after:?}"
+    );
+    assert!(
+        after
+            .iter()
+            .all(|(_, status)| !status.to_lowercase().contains("wait")),
+        "no stored status reads waiting: {after:?}"
+    );
+    assert_eq!(columns(&db).await, columns_before, "still no new column");
+    assert_eq!(
+        detail(&row(&mut panels, &parent_session, "acp-subagent:alpha")),
+        "Completed",
+        "the ended row reads its outcome"
+    );
+    assert_reads_waiting(&waiting_text, "Alpha, while it waited");
+}
+
+// ── Benign — Waiting does not stick ─────────────────────────────────────────
+
+/// The operator answers and the child runs again: its row reads "Running" once more.
+#[gpui::test]
+async fn panel_waiting_label_child_running_again_reads_running(cx: &mut TestAppContext) {
+    init_base(cx);
+    let mut panels = open_panels(cx).await;
+    let connection = rfd_connection();
+    let parent = open_thread_on(
+        &panels.agent_panel,
+        connection.clone(),
+        "Parent",
+        &mut panels.cx,
+    );
+    let parent_session = session_of(&parent, &panels.cx);
+    let alpha = spawn_child(&parent, &connection, "alpha", "Alpha", &mut panels.cx);
+    report_waiting(&alpha, &mut panels);
+    assert_reads_waiting(
+        &detail(&row(&mut panels, &parent_session, "acp-subagent:alpha")),
+        "Alpha, waiting",
+    );
+
+    report_running(&alpha, &mut panels.cx);
+
+    let text = detail(&row(&mut panels, &parent_session, "acp-subagent:alpha"));
+    assert!(
+        text.starts_with("Running") && !text.contains(WAITING),
+        "back to Running: {text:?}"
+    );
+}
diff --git a/crates/agent_tasks_panel/src/store.rs b/crates/agent_tasks_panel/src/store.rs
index cdbb4a8..e8cdfc1 100644
--- a/crates/agent_tasks_panel/src/store.rs
+++ b/crates/agent_tasks_panel/src/store.rs
@@ -25,8 +25,8 @@ use std::{
 use acp_thread::{AgentTask, AgentTaskId};
 use agent_client_protocol::v2::schema as acp_v2;
 use agent_ui::{
-    TaskActivity, TaskLabels, TaskRecord, TasksCard, TasksCardEvent, ThreadTitleChanged,
-    normalize_user_label, task_log,
+    SubThreadActivity, TaskActivity, TaskLabels, TaskRecord, TasksCard, TasksCardEvent,
+    ThreadTitleChanged, normalize_user_label, task_log,
 };
 use anyhow::Result;
 use collections::{HashMap, HashSet};
@@ -36,7 +36,10 @@ use gpui::{
     SharedString, Subscription, Task, WeakEntity,
 };
 
-use crate::{AgentTaskHistoryDb, HistoryEntry};
+use crate::{AgentTaskHistoryDb, HistoryEntry, TaskOrigin};
+
+mod sub_thread;
+use sub_thread::SubThreadWrite;
 
 /// Whether the history domain can be read and written this session (R4.3).
 #[derive(Clone, Debug, PartialEq)]
@@ -72,6 +75,7 @@ pub struct TaskHistoryStore {
 struct RegisteredCard {
     card: WeakEntity<TasksCard>,
     _activity: Subscription,
+    _sub_threads: Subscription,
     _titles: Subscription,
     _card_events: Subscription,
     _changes: Subscription,
@@ -80,6 +84,8 @@ struct RegisteredCard {
 /// What a card tells the store, in the order the card told it.
 enum Heard {
     Activity(TaskActivity),
+    /// A sub-thread linked, running, idle or retitled (story 040).
+    SubThread(SubThreadActivity),
     Title(ThreadTitleChanged),
     /// A task's log was saved (`Some(size)`), or the task keeps none (`None`).
     LogSaved {
@@ -128,13 +134,21 @@ enum HistoryWrite {
         /// When the store heard it: a `None` never deletes a file saved after this.
         heard_at: SystemTime,
     },
+    /// A sub-thread item (story 040); it logs its own outcome.
+    SubThread(SubThreadWrite),
 }
 
 impl HistoryWrite {
     async fn run(self, db: &AgentTaskHistoryDb, cx: &AsyncApp) {
         let result = match &self {
+            // A native subagent is written once, at its ending, with its origin and child
+            // (story 064, R2.1): its start has nothing the ending will not also carry.
+            Self::Started(record) if record.child_session_id.is_some() => Ok(()),
             Self::Started(record) => db.insert_started(record).await,
-            Self::Ended(record) => db.update_ended(record).await,
+            Self::Ended(record) => match &record.child_session_id {
+                Some(child) => db.record_subagent(record, child).await,
+                None => db.update_ended(record).await,
+            },
             Self::Archived { keys, archived } => db.set_archived(keys, *archived).await,
             // Row first, then the file: a crash in between leaves an orphan file, which the next
             // load sweeps, never a record pointing at a missing log. A failed row delete keeps
@@ -176,8 +190,12 @@ impl HistoryWrite {
                 }
                 result
             }
+            Self::SubThread(write) => write.run(db).await,
         };
-        if let Err(error) = result {
+        // A sub-thread write already logged its outcome, with both session ids (R8.1).
+        if let Err(error) = result
+            && !matches!(self, Self::SubThread(_))
+        {
             log::error!(
                 "agent task history: failed to record {}; kept in memory only: {error:#}",
                 self.describe(),
@@ -220,6 +238,7 @@ impl HistoryWrite {
                 key: (session_id, task_id),
                 ..
             } => format!("the log size of task {task_id} in session {session_id}"),
+            Self::SubThread(write) => write.describe(),
         }
     }
 }
@@ -372,6 +391,9 @@ impl TaskHistoryStore {
         let activity = cx.subscribe(card, |store, _card, activity: &TaskActivity, cx| {
             store.hear(Heard::Activity(activity.clone()), cx);
         });
+        let sub_threads = cx.subscribe(card, |store, _card, activity: &SubThreadActivity, cx| {
+            store.hear(Heard::SubThread(activity.clone()), cx);
+        });
         let titles = cx.subscribe(card, |store, _card, change: &ThreadTitleChanged, cx| {
             store.hear(Heard::Title(change.clone()), cx);
         });
@@ -409,6 +431,7 @@ impl TaskHistoryStore {
             RegisteredCard {
                 card: card.downgrade(),
                 _activity: activity,
+                _sub_threads: sub_threads,
                 _titles: titles,
                 _card_events: card_events,
                 _changes: changes,
@@ -420,6 +443,9 @@ impl TaskHistoryStore {
         for change in titles {
             self.hear(Heard::Title(change), cx);
         }
+        // The card reported the links it found before this subscription existed to nobody;
+        // replayed now, they are heard like any other (the link write is idempotent).
+        card.update(cx, |card, cx| card.replay_sub_thread_activity(cx));
         cx.notify();
     }
 
@@ -553,14 +579,17 @@ impl TaskHistoryStore {
 
     fn finish_loading(
         &mut self,
-        loaded: Result<Vec<HistoryEntry>, SharedString>,
+        loaded: Result<(Vec<HistoryEntry>, Vec<TaskKey>), SharedString>,
         loading_began: SystemTime,
         cx: &mut Context<Self>,
     ) {
         let swept = loaded.is_ok();
         match loaded {
-            Ok(records) => {
+            Ok((records, deleted_sub_threads)) => {
                 self.records = records;
+                // Before the pending replay below: a card's replayed `Linked` must not bring a
+                // deleted sub-thread item back (R5.10).
+                self.deleted.extend(deleted_sub_threads);
                 // The loaded labels are the whole truth after a restart (R3.8): a key the
                 // global held from before is dropped.
                 if cx.has_global::<TaskLabels>() {
@@ -593,7 +622,7 @@ impl TaskHistoryStore {
                     key: (session_id, task_id),
                     ..
                 } => Some((session_id.to_string(), task_id.to_string())),
-                Heard::Activity(_) | Heard::Title(_) => None,
+                Heard::Activity(_) | Heard::Title(_) | Heard::SubThread(_) => None,
             })
             .collect();
         for heard in pending {
@@ -658,6 +687,7 @@ impl TaskHistoryStore {
         match heard {
             Heard::Activity(TaskActivity::Started(record)) => self.record_started(record),
             Heard::Activity(TaskActivity::Ended(record)) => self.record_ended(record),
+            Heard::SubThread(activity) => self.record_sub_thread(activity),
             Heard::Title(change) => self.record_title(change),
             Heard::LogSaved {
                 key,
@@ -709,11 +739,17 @@ impl TaskHistoryStore {
             record.tokens = None;
             record.tool_uses = None;
         }
+        // The card names a native subagent's child; the stored origin then says so (story 064).
+        let origin = match record.child_session_id {
+            Some(_) => TaskOrigin::AcpSubagent,
+            None => TaskOrigin::Adapter,
+        };
         self.insert_entry(HistoryEntry {
             record: record.clone(),
             archived: false,
             label: None,
             log_bytes: None,
+            origin,
         });
         self.queue_write(HistoryWrite::Started(record));
     }
@@ -825,10 +861,10 @@ impl TaskHistoryStore {
     }
 }
 
-/// The startup sweep (R1.5), then the first load.
-async fn startup(db: &AgentTaskHistoryDb) -> Result<Vec<HistoryEntry>> {
+/// The startup sweep (R1.5), then the first load and the deleted sub-thread items' keys.
+async fn startup(db: &AgentTaskHistoryDb) -> Result<(Vec<HistoryEntry>, Vec<TaskKey>)> {
     db.mark_running_interrupted().await?;
-    db.load_all().await
+    Ok((db.load_all().await?, db.deleted_sub_thread_keys().await?))
 }
 
 /// `load_all`'s order: `started_at DESC, session_id, task_id`.
@@ -900,7 +936,19 @@ mod tool_call_id;
 #[path = "store/delete_tests.rs"]
 mod delete;
 
+#[cfg(test)]
+#[path = "store/sub_thread_tests.rs"]
+mod sub_thread_items;
+
+#[cfg(test)]
+#[path = "store/sub_thread_tombstone_tests.rs"]
+mod sub_thread_tombstone;
+
 #[cfg(test)]
 #[path = "store/log_tests.rs"]
 // Not `mod log`: that would shadow the `log` crate for this file's `log::` macros.
 mod log_files;
+
+#[cfg(test)]
+#[path = "store/panel_native_subagent_restart_tests.rs"]
+mod panel_native_subagent_restart;
diff --git a/crates/agent_tasks_panel/src/store/panel_native_subagent_restart_tests.rs b/crates/agent_tasks_panel/src/store/panel_native_subagent_restart_tests.rs
new file mode 100644
index 0000000..f8c66b5
--- /dev/null
+++ b/crates/agent_tasks_panel/src/store/panel_native_subagent_restart_tests.rs
@@ -0,0 +1,215 @@
+// Story 064, task 5.1 — recorded native subagents after a restart, and the open fallback for a
+// child that is not loaded (R2.3, R2.4). Module path: `store::panel_native_subagent_restart`
+// (under `store` to reach `GlobalTaskHistoryStore`, story 014's restart recipe; the name
+// carries the Validation filter `cargo test -p agent_tasks_panel panel`). Fixtures:
+// `native_subagent_support.rs`, `test_helpers.rs`.
+//
+// Side effects are read where they land: the rows of the database itself (`load(&db)`), the
+// rows a freshly loaded panel lists, the session the agent panel shows, the window count, the
+// row's notice and the shown thread's scroll position.
+//
+// Hostile halves first:
+//   - a terminal transition is recorded ONCE, under the PARENT's session and the child's own
+//     key — never under the child's session, never twice (Started and Ended are one row);
+//   - after a restart the row reads its recorded, ended status — not running;
+//   - a child that is not loaded is never looked up in the thread metadata store: a decoy
+//     metadata row under the child's session id is never opened, and no window appears;
+//   - it opens the PARENT at the child's announcement, with the notice — never "Thread no
+//     longer exists", never 023's "no entry to jump to";
+//   - then the benign archive and delete of a recorded subagent row.
+
+use acp_thread::AgentTaskStatus;
+use gpui::{AppContext as _, Entity, TestAppContext};
+
+use super::{GlobalTaskHistoryStore, TaskHistoryStore};
+use crate::AgentTasksPanel;
+use crate::native_subagent_support::*;
+use crate::test_helpers::*;
+
+/// Restart: a new global store loaded from the same database, and a new panel over it.
+async fn restart(panels: &mut Panels, db: &crate::AgentTaskHistoryDb) -> Entity<AgentTasksPanel> {
+    let reloaded = panels
+        .cx
+        .update(|_, cx| cx.new(|cx| TaskHistoryStore::new(db.clone(), cx)));
+    panels
+        .cx
+        .update(|_, cx| cx.set_global(GlobalTaskHistoryStore(reloaded)));
+    let weak = panels.workspace.downgrade();
+    let loading = panels
+        .cx
+        .update(|window, cx| AgentTasksPanel::load(weak, window.to_async(cx)));
+    panels.cx.run_until_parked();
+    let panel = loading.await.expect("the Agent Tasks panel loads again");
+    panels.cx.run_until_parked();
+    panel
+}
+
+fn listed(
+    panel: &Entity<AgentTasksPanel>,
+    key: &Key,
+    panels: &mut Panels,
+) -> Option<crate::PanelRow> {
+    panel
+        .update(&mut panels.cx, |panel, cx| panel.groups(cx))
+        .into_iter()
+        .flat_map(|group| group.rows)
+        .find(|row| row.entry.record.session_id == key.0 && row.entry.record.task_id == key.1)
+}
+
+// ── R2.3 — recorded on the terminal transition, listed after a restart ──────
+
+#[gpui::test]
+async fn panel_native_subagent_is_recorded_once_and_listed_after_a_restart(
+    cx: &mut TestAppContext,
+) {
+    let db = init_base(cx);
+    let mut panels = open_panels(cx).await;
+    let connection = rfd_connection();
+    let parent = open_thread_on(
+        &panels.agent_panel,
+        connection.clone(),
+        "Parent",
+        &mut panels.cx,
+    );
+    let parent_session = session_of(&parent, &panels.cx);
+    let alpha = spawn_child(&parent, &connection, "alpha", "Alpha", &mut panels.cx);
+    report_running(&alpha, &mut panels.cx);
+    report_done(&alpha, &mut panels.cx);
+    let key = subagent_key(&parent_session, "alpha");
+
+    let stored = load(&db).await;
+    let rows: Vec<_> = stored
+        .iter()
+        .filter(|entry| entry.record.task_id == key.1)
+        .collect();
+    assert_eq!(rows.len(), 1, "one terminal transition, one row: {rows:?}");
+    assert_eq!(
+        rows[0].record.session_id, parent_session,
+        "keyed by the parent's session, never the child's"
+    );
+    assert_eq!(rows[0].record.status, AgentTaskStatus::Completed);
+    assert_eq!(
+        rows[0].origin,
+        crate::TaskOrigin::AcpSubagent,
+        "a native record carries the origin acp_subagent (Revision 1: no source column)"
+    );
+    assert!(
+        stored
+            .iter()
+            .all(|entry| entry.record.session_id.to_string() != "alpha"),
+        "nothing is recorded under the child's own session"
+    );
+
+    let panel = restart(&mut panels, &db).await;
+    let row = listed(&panel, &key, &mut panels).expect("the recorded subagent row is listed");
+    assert_eq!(
+        row.entry.record.status,
+        AgentTaskStatus::Completed,
+        "it reads its recorded ending, not running"
+    );
+    assert!(!row.can_stop, "an ended record cannot be stopped");
+}
+
+/// Archive and delete work on a recorded subagent row as on any other record (story 014, 025).
+#[gpui::test]
+async fn panel_native_subagent_record_archives_and_deletes(cx: &mut TestAppContext) {
+    let db = init_base(cx);
+    let mut panels = open_panels(cx).await;
+    let connection = rfd_connection();
+    let parent = open_thread_on(
+        &panels.agent_panel,
+        connection.clone(),
+        "Parent",
+        &mut panels.cx,
+    );
+    let parent_session = session_of(&parent, &panels.cx);
+    seed_recorded_subagent(&db, &parent_session, "ghost").await;
+    let panel = restart(&mut panels, &db).await;
+    let key = subagent_key(&parent_session, "ghost");
+    assert!(
+        listed(&panel, &key, &mut panels).is_some(),
+        "precondition: listed"
+    );
+
+    panel.update(&mut panels.cx, |panel, cx| panel.archive(&key, cx));
+    panels.cx.run_until_parked();
+    let archived = load(&db)
+        .await
+        .into_iter()
+        .find(|entry| entry.record.session_id == key.0 && entry.record.task_id == key.1)
+        .expect("still stored");
+    assert!(archived.archived, "archive is written to the database");
+
+    let store = panels.cx.update(|_, cx| TaskHistoryStore::global(cx));
+    store.update(&mut panels.cx, |store, cx| {
+        store.delete_archived(std::slice::from_ref(&key), cx)
+    });
+    panels.cx.run_until_parked();
+    assert!(
+        load(&db)
+            .await
+            .into_iter()
+            .all(|entry| !(entry.record.session_id == key.0 && entry.record.task_id == key.1)),
+        "delete removes the archived record"
+    );
+}
+
+// ── R2.4 — an unloaded child opens its parent at its card, and says so ──────
+
+#[gpui::test]
+async fn panel_native_subagent_open_on_an_unloaded_child_opens_the_parent_at_its_card(
+    cx: &mut TestAppContext,
+) {
+    let db = init_base(cx);
+    let mut panels = open_panels(cx).await;
+    let connection = rfd_connection();
+    let parent = open_thread_on(
+        &panels.agent_panel,
+        connection.clone(),
+        "Parent",
+        &mut panels.cx,
+    );
+    let parent_session = session_of(&parent, &panels.cx);
+    pad(&parent, 0, &mut panels.cx);
+    let announcement = announce_to(&parent, "ghost", &mut panels.cx);
+    pad(&parent, 20, &mut panels.cx);
+    pad(&parent, 40, &mut panels.cx);
+    // Recorded by an earlier Zeo session; "ghost" is registered nowhere in this one.
+    seed_recorded_subagent(&db, &parent_session, "ghost").await;
+    save_decoy_metadata_for("ghost", &mut panels.cx);
+    let panel = restart(&mut panels, &db).await;
+    let key = subagent_key(&parent_session, "ghost");
+    assert!(
+        listed(&panel, &key, &mut panels).is_some(),
+        "precondition: the recorded subagent row is listed"
+    );
+    let windows = window_count(&mut panels.cx);
+
+    open_from(&panel, &key, &mut panels.cx);
+
+    assert_eq!(
+        window_count(&mut panels.cx),
+        windows,
+        "no window opens for the decoy under the child's id"
+    );
+    assert_eq!(
+        active_session(&panels),
+        Some(parent_session.clone()),
+        "the parent is the thread shown"
+    );
+    assert_eq!(
+        conversation_shows(&panels.agent_panel, &panels.cx),
+        Some(parent_session.clone()),
+        "the conversation shows the parent itself"
+    );
+    assert_eq!(
+        notice_of(&panel, &parent_session, key.1.as_ref(), &mut panels.cx).as_deref(),
+        Some(PARENT_ONLY_NOTICE),
+        "the row says the transcript lives with the parent"
+    );
+    assert_eq!(
+        scroll_top(&panels.agent_panel, &mut panels.cx),
+        announcement,
+        "the parent opens at the child's card (its announcement), not at its top or end"
+    );
+}
diff --git a/crates/agent_tasks_panel/src/store/sub_thread.rs b/crates/agent_tasks_panel/src/store/sub_thread.rs
new file mode 100644
index 0000000..0db39eb
--- /dev/null
+++ b/crates/agent_tasks_panel/src/store/sub_thread.rs
@@ -0,0 +1,125 @@
+//! Story 040: the store's half of sub-thread items. `SubThreadActivity` from any registered card
+//! is applied to memory first, then queued on the same ordered writer as every task write
+//! (design.md, TaskHistoryStore and AgentTaskHistoryDb).
+
+use acp_thread::{AgentTaskId, AgentTaskStatus};
+use agent_ui::{SubThreadActivity, SubThreadRecord, TaskRecord};
+
+use super::{HistoryWrite, TaskHistoryStore};
+use crate::history_db::{SUB_THREAD_KIND, sub_thread_task_id};
+use crate::{AgentTaskHistoryDb, HistoryEntry, TaskOrigin};
+
+/// A sub-thread write, carried by `HistoryWrite::SubThread`.
+pub(super) enum SubThreadWrite {
+    /// `Linked` or `Retitled`: a new item Idle, an existing one its description only.
+    Link(SubThreadRecord),
+    /// `Running` or `Idle`, as the stored status text.
+    Status(SubThreadRecord, &'static str),
+}
+
+impl SubThreadWrite {
+    pub(super) async fn run(&self, db: &AgentTaskHistoryDb) -> anyhow::Result<()> {
+        let result = match self {
+            Self::Link(record) => db.record_sub_thread_link(record).await,
+            Self::Status(record, status) => db.upsert_sub_thread(record, status).await,
+        };
+        let (record, status) = self.parts();
+        // Session ids and the status only: the title is user text (R8.1).
+        match &result {
+            Ok(()) => log::info!(
+                "zeo_sub_thread event=history_write parent_session={} sub_session={} \
+                 status={status} result=ok",
+                record.parent_session,
+                record.sub_session
+            ),
+            Err(error) => log::error!(
+                "zeo_sub_thread event=history_write parent_session={} sub_session={} \
+                 status={status} result=failed; kept in memory only: {error:#}",
+                record.parent_session,
+                record.sub_session
+            ),
+        }
+        result
+    }
+
+    fn parts(&self) -> (&SubThreadRecord, &'static str) {
+        match self {
+            Self::Link(record) => (record, "link"),
+            Self::Status(record, status) => (record, status),
+        }
+    }
+
+    pub(super) fn describe(&self) -> String {
+        let (record, status) = self.parts();
+        format!(
+            "the {status} of sub-thread {} in session {}",
+            record.sub_session, record.parent_session
+        )
+    }
+}
+
+impl TaskHistoryStore {
+    pub(super) fn record_sub_thread(&mut self, activity: SubThreadActivity) {
+        let (record, status) = match activity {
+            SubThreadActivity::Linked(record) | SubThreadActivity::Retitled(record) => {
+                (record, None)
+            }
+            SubThreadActivity::Running(record) => (record, Some(AgentTaskStatus::Running)),
+            SubThreadActivity::Idle(record) => (record, Some(AgentTaskStatus::Completed)),
+        };
+        let task_id = AgentTaskId::from(sub_thread_task_id(&record));
+        let key = (record.parent_session.clone(), task_id.clone());
+        // Deleted this session: a later report must not bring the item back before restart.
+        if self.deleted.contains(&key) {
+            return;
+        }
+        match self.position(&record.parent_session, &task_id) {
+            // An adapter task on the same key is never rewritten, as the database guard says.
+            Some(index) if self.records[index].origin != TaskOrigin::SubThread => return,
+            Some(index) => {
+                let entry = &mut self.records[index].record;
+                entry.description = record.title.clone();
+                if let Some(status) = status {
+                    entry.status = status;
+                    entry.ended_at_ms = ended_at(status, &record);
+                }
+            }
+            None => {
+                let status = status.unwrap_or(AgentTaskStatus::Completed);
+                self.insert_entry(HistoryEntry {
+                    record: TaskRecord {
+                        session_id: record.parent_session.clone(),
+                        task_id,
+                        thread_title: None,
+                        project: record.project.clone(),
+                        kind: SUB_THREAD_KIND.into(),
+                        description: record.title.clone(),
+                        background: false,
+                        started_at_ms: record.at_ms,
+                        ended_at_ms: ended_at(status, &record),
+                        status,
+                        tokens: None,
+                        tool_uses: None,
+                        tool_call_id: None,
+                        child_session_id: None,
+                        waiting: false,
+                    },
+                    archived: false,
+                    label: None,
+                    log_bytes: None,
+                    origin: TaskOrigin::SubThread,
+                });
+            }
+        }
+        let write = match status {
+            None => SubThreadWrite::Link(record),
+            Some(AgentTaskStatus::Running) => SubThreadWrite::Status(record, "running"),
+            Some(_) => SubThreadWrite::Status(record, "completed"),
+        };
+        self.queue_write(HistoryWrite::SubThread(write));
+    }
+}
+
+fn ended_at(status: AgentTaskStatus, record: &SubThreadRecord) -> Option<i64> {
+    (!status.is_running()).then_some(record.at_ms)
+}
diff --git a/crates/agent_tasks_panel/src/store/sub_thread_tests.rs b/crates/agent_tasks_panel/src/store/sub_thread_tests.rs
new file mode 100644
index 0000000..f01e836
--- /dev/null
+++ b/crates/agent_tasks_panel/src/store/sub_thread_tests.rs
@@ -0,0 +1,486 @@
+// Story 040, task 3.2 (store half) — `TaskHistoryStore` persists `SubThreadActivity` from every
+// registered card (R5.1–R5.5, R5.8, R5.9; Q4, Q14). Module path: `store::sub_thread`. Wired in
+// `src/store.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "store/sub_thread_tests.rs"]
+//     mod sub_thread;
+//
+// Validation filter: `cargo test -p agent_tasks_panel -- sub_thread` (names carry
+// `store_sub_thread`).
+//
+// Contract (design.md §SubThreadReporter, §TaskHistoryStore and AgentTaskHistoryDb):
+//   - `agent_ui::{SubThreadActivity, SubThreadRecord}`; `SubThreadActivity::{Linked, Running,
+//     Idle, Retitled}(SubThreadRecord)`; `impl EventEmitter<SubThreadActivity> for TasksCard`;
+//   - `register_card` subscribes to it beside `TaskActivity`: `Linked` and `Retitled` →
+//     `record_sub_thread_link` (inserted `completed` when new, else the description only — never
+//     the status), `Running` → `running`, `Idle` → `completed` with `ended_at`; never `archived`;
+//     every write logs `zeo_sub_thread event=history_write` with both session ids and the status;
+//   - memory first, then the ordered writer; a failed write keeps memory and logs at error level
+//     with both session ids and never the title (R5.8);
+//   - the item: session = parent session, task id `zeo-sub-thread:<sub session>`, kind
+//     `sub_thread`, origin `TaskOrigin::SubThread`.
+// The store is fed as story 014's tests feed it: events emitted on the real card of an
+// `AgentPanel`. Assumed beyond design.md: `SubThreadRecord`'s fields are `pub`, it is `Clone`.
+//
+// Hostile halves first:
+//   - wrongly split: one link heard twice — and heard from a second registered card, as a second
+//     window would — is ONE item, in memory and on disk (R5.2);
+//   - a second window's `Linked`, or a `Retitled`, arriving while the sub-thread runs leaves it
+//     Running, in memory and on disk (R5.3, R7.4);
+//   - wrongly collapse: two sub-threads of one parent are two items; an adapter task whose kind
+//     is `sub_thread` stays an adapter task in memory (R5.7);
+//   - deleting a sub-thread item touches neither the organization store nor the thread metadata
+//     (Q14, R7.6);
+//   - then the benign lifecycle.
+
+use acp_thread::AgentTaskStatus;
+use agent_ui::thread_metadata_store::ThreadMetadataStore;
+use agent_ui::thread_organization::{ForkKind, ThreadOrganizationStore};
+use agent_ui::{SubThreadActivity, SubThreadRecord, TasksCard};
+use gpui::{AppContext as _, Entity, TestAppContext, VisualTestContext};
+
+use crate::history_db::TaskOrigin;
+use crate::test_helpers::*;
+use crate::{AgentTaskHistoryDb, HistoryEntry, TaskHistoryStore};
+
+const PREFIX: &str = "zeo-sub-thread:";
+
+fn sub_record(parent: &str, sub: &str, title: &str, at_ms: i64) -> SubThreadRecord {
+    SubThreadRecord {
+        parent_session: sid(parent),
+        sub_session: sid(sub),
+        title: title.to_string().into(),
+        project: "project".into(),
+        at_ms,
+    }
+}
+
+/// A store over `db`, registered on the rig's card.
+fn registered_store(db: &AgentTaskHistoryDb, rig: &mut CardRig) -> Entity<TaskHistoryStore> {
+    let store = rig.cx.new(|cx| TaskHistoryStore::new(db.clone(), cx));
+    rig.cx.run_until_parked();
+    let card = rig.card.clone();
+    store.update(&mut rig.cx, |store, cx| store.register_card(&card, cx));
+    rig.cx.run_until_parked();
+    store
+}
+
+fn emit_sub(card: &Entity<TasksCard>, activity: SubThreadActivity, cx: &mut VisualTestContext) {
+    card.update(cx, |_, cx| cx.emit(activity));
+    cx.run_until_parked();
+}
+
+fn entries(store: &Entity<TaskHistoryStore>, cx: &VisualTestContext) -> Vec<HistoryEntry> {
+    store.read_with(cx, |store, _| store.records().to_vec())
+}
+
+fn item<'a>(rows: &'a [HistoryEntry], parent: &str, sub: &str) -> Option<&'a HistoryEntry> {
+    find(rows, parent, &format!("{PREFIX}{sub}"))
+}
+
+fn sub_items(rows: &[HistoryEntry]) -> Vec<(String, String)> {
+    rows.iter()
+        .filter(|entry| entry.origin == TaskOrigin::SubThread)
+        .map(key_of)
+        .collect()
+}
+
+// ── Wrongly split (R5.2) ───────────────────────────────────────────────────
+
+#[gpui::test]
+async fn store_sub_thread_link_heard_twice_and_from_two_cards_is_one_item(cx: &mut TestAppContext) {
+    let mut rig = card_rig(cx).await;
+    let db = AgentTaskHistoryDb::open_test_db("store_sub_thread_twice").await;
+    let store = registered_store(&db, &mut rig);
+    // A second window's card, registered with the same store.
+    let second_card = rig.cx.new(|cx| TasksCard::new(cx));
+    store.update(&mut rig.cx, |store, cx| {
+        store.register_card(&second_card, cx)
+    });
+    rig.cx.run_until_parked();
+
+    let linked = sub_record("p1", "sub-a", "Alpha", 1_000);
+    emit_sub(
+        &rig.card,
+        SubThreadActivity::Linked(linked.clone()),
+        &mut rig.cx,
+    );
+    emit_sub(
+        &rig.card,
+        SubThreadActivity::Linked(linked.clone()),
+        &mut rig.cx,
+    );
+    emit_sub(&second_card, SubThreadActivity::Linked(linked), &mut rig.cx);
+
+    assert_eq!(
+        sub_items(&entries(&store, &rig.cx)),
+        vec![("p1".to_string(), format!("{PREFIX}sub-a"))],
+        "one item in memory"
+    );
+    assert_eq!(
+        sub_items(&load(&db).await),
+        vec![("p1".to_string(), format!("{PREFIX}sub-a"))],
+        "one item on disk"
+    );
+}
+
+// A re-report while running (design.md 2026-10-20: `Linked`/`Retitled` never touch the status).
+#[gpui::test]
+async fn store_sub_thread_second_card_link_while_running_stays_running(cx: &mut TestAppContext) {
+    capture_logs();
+    let mut rig = card_rig(cx).await;
+    let db = AgentTaskHistoryDb::open_test_db("store_sub_thread_relink_running").await;
+    let store = registered_store(&db, &mut rig);
+    let second_card = rig.cx.new(|cx| TasksCard::new(cx));
+    store.update(&mut rig.cx, |store, cx| {
+        store.register_card(&second_card, cx)
+    });
+    rig.cx.run_until_parked();
+
+    emit_sub(
+        &rig.card,
+        SubThreadActivity::Linked(sub_record("p1", "sub-a", "Alpha", 1_000)),
+        &mut rig.cx,
+    );
+    emit_sub(
+        &rig.card,
+        SubThreadActivity::Running(sub_record("p1", "sub-a", "Alpha", 2_000)),
+        &mut rig.cx,
+    );
+    // Another window's reporter reports the same link, then the title changes.
+    emit_sub(
+        &second_card,
+        SubThreadActivity::Linked(sub_record("p1", "sub-a", "Alpha", 3_000)),
+        &mut rig.cx,
+    );
+    emit_sub(
+        &rig.card,
+        SubThreadActivity::Retitled(sub_record("p1", "sub-a", "Alpha renamed", 4_000)),
+        &mut rig.cx,
+    );
+
+    let memory = entries(&store, &rig.cx);
+    let entry = item(&memory, "p1", "sub-a").expect("in memory");
+    assert_eq!(
+        entry.record.status,
+        AgentTaskStatus::Running,
+        "still Running in memory"
+    );
+    assert_eq!(entry.record.description.as_ref(), "Alpha renamed");
+    let disk = load(&db).await;
+    let entry = item(&disk, "p1", "sub-a").expect("on disk");
+    assert_eq!(
+        entry.record.status,
+        AgentTaskStatus::Running,
+        "still Running on disk"
+    );
+    assert_eq!(entry.record.description.as_ref(), "Alpha renamed");
+    assert_eq!(sub_items(&disk).len(), 1, "one item");
+    assert!(
+        log_lines()
+            .iter()
+            .any(|line| line.contains("zeo_sub_thread")
+                && line.contains("history_write")
+                && line.contains("p1")
+                && line.contains("sub-a")),
+        "every write is logged with both ids"
+    );
+    assert!(
+        log_lines()
+            .iter()
+            .all(|line| !line.contains("Alpha renamed")),
+        "never the title"
+    );
+}
+
+// ── Wrongly collapse (R5.1, R5.7) ──────────────────────────────────────────
+
+#[gpui::test]
+async fn store_sub_thread_two_sub_threads_of_one_parent_are_two_items(cx: &mut TestAppContext) {
+    let mut rig = card_rig(cx).await;
+    let db = AgentTaskHistoryDb::open_test_db("store_sub_thread_two").await;
+    let store = registered_store(&db, &mut rig);
+
+    emit_sub(
+        &rig.card,
+        SubThreadActivity::Linked(sub_record("p1", "sub-a", "Alpha", 1_000)),
+        &mut rig.cx,
+    );
+    emit_sub(
+        &rig.card,
+        SubThreadActivity::Linked(sub_record("p1", "sub-b", "Bravo", 2_000)),
+        &mut rig.cx,
+    );
+
+    let memory = entries(&store, &rig.cx);
+    assert_eq!(
+        item(&memory, "p1", "sub-a").map(|e| e.record.description.to_string()),
+        Some("Alpha".to_string())
+    );
+    assert_eq!(
+        item(&memory, "p1", "sub-b").map(|e| e.record.description.to_string()),
+        Some("Bravo".to_string())
+    );
+    assert_eq!(sub_items(&load(&db).await).len(), 2, "two items on disk");
+}
+
+#[gpui::test]
+async fn store_sub_thread_adapter_task_of_kind_sub_thread_stays_adapter(cx: &mut TestAppContext) {
+    let mut rig = card_rig(cx).await;
+    let db = AgentTaskHistoryDb::open_test_db("store_sub_thread_adapter_kind").await;
+    let store = registered_store(&db, &mut rig);
+
+    let mut look_alike = record(
+        "p1",
+        "zeo-sub-thread:sub-a",
+        AgentTaskStatus::Running,
+        1_000,
+    );
+    look_alike.kind = "sub_thread".into();
+    emit(&rig.card, started(&look_alike), &mut rig.cx);
+
+    let memory = entries(&store, &rig.cx);
+    let entry = find(&memory, "p1", "zeo-sub-thread:sub-a").expect("the task is recorded");
+    assert_eq!(entry.origin, TaskOrigin::Adapter, "in memory");
+    let disk = load(&db).await;
+    let entry = find(&disk, "p1", "zeo-sub-thread:sub-a").expect("the task is written");
+    assert_eq!(entry.origin, TaskOrigin::Adapter, "on disk");
+}
+
+// ── Delete touches the history only (Q14, R7.6) ────────────────────────────
+
+#[gpui::test]
+async fn store_sub_thread_archive_and_delete_leave_the_link_and_the_threads(
+    cx: &mut TestAppContext,
+) {
+    let mut rig = card_rig(cx).await;
+    let db = AgentTaskHistoryDb::open_test_db("store_sub_thread_delete").await;
+    let store = registered_store(&db, &mut rig);
+    rig.cx
+        .update(|_, cx| agent_ui::thread_organization::init(cx));
+    rig.cx.run_until_parked();
+
+    let parent_thread = open_thread_on(
+        &rig.agent_panel,
+        StopConnection::new(),
+        "Parent",
+        &mut rig.cx,
+    );
+    let parent_id = agent_ui::test_support::active_thread_id(&rig.agent_panel, &rig.cx);
+    let child_thread = open_thread_on(
+        &rig.agent_panel,
+        StopConnection::new(),
+        "Child",
+        &mut rig.cx,
+    );
+    let child_id = agent_ui::test_support::active_thread_id(&rig.agent_panel, &rig.cx);
+    let parent_session = session_of(&parent_thread, &rig.cx);
+    let child_session = session_of(&child_thread, &rig.cx);
+    rig.cx.update(|_, cx| {
+        ThreadOrganizationStore::global(cx).update(cx, |store, cx| {
+            store
+                .set_parent(child_id, parent_id, ForkKind::Clean, cx)
+                .expect("the link is recorded")
+        })
+    });
+    rig.cx.run_until_parked();
+
+    let record = SubThreadRecord {
+        parent_session: parent_session.clone(),
+        sub_session: child_session.clone(),
+        title: "Child".into(),
+        project: "project".into(),
+        at_ms: 1_000,
+    };
+    emit_sub(&rig.card, SubThreadActivity::Linked(record), &mut rig.cx);
+    let item_key: crate::TaskKey = (
+        parent_session.clone(),
+        format!("{PREFIX}{child_session}").into(),
+    );
+    assert!(
+        store.read_with(&rig.cx, |store, _| store.records().iter().any(|entry| (
+            entry.record.session_id.clone(),
+            entry.record.task_id.clone()
+        )
+            == item_key)),
+        "precondition: the item is recorded"
+    );
+
+    store.update(&mut rig.cx, |store, cx| {
+        store.archive(std::slice::from_ref(&item_key), cx)
+    });
+    rig.cx.run_until_parked();
+    store.update(&mut rig.cx, |store, cx| {
+        store.delete_archived(std::slice::from_ref(&item_key), cx)
+    });
+    rig.cx.run_until_parked();
+
+    assert!(
+        item(
+            &load(&db).await,
+            &parent_session.to_string(),
+            &child_session.to_string()
+        )
+        .is_none(),
+        "the history item is deleted"
+    );
+    rig.cx.update(|_, cx| {
+        let organization = ThreadOrganizationStore::global(cx);
+        assert_eq!(
+            organization.read(cx).parent(child_id),
+            Some((parent_id, ForkKind::Clean)),
+            "the parent link is untouched"
+        );
+        let metadata = ThreadMetadataStore::global(cx);
+        let metadata = metadata.read(cx);
+        let parent = metadata
+            .entry(parent_id)
+            .expect("the parent thread still exists");
+        assert!(!parent.archived, "the parent thread is not archived");
+        let child = metadata
+            .entry(child_id)
+            .expect("the sub-thread still exists");
+        assert!(!child.archived, "the sub-thread is not archived");
+    });
+}
+
+// ── Lifecycle (R5.1, R5.3, R5.5, R5.9) ─────────────────────────────────────
+
+#[gpui::test]
+async fn store_sub_thread_lifecycle_running_idle_and_retitled(cx: &mut TestAppContext) {
+    let mut rig = card_rig(cx).await;
+    let db = AgentTaskHistoryDb::open_test_db("store_sub_thread_lifecycle").await;
+    let store = registered_store(&db, &mut rig);
+
+    emit_sub(
+        &rig.card,
+        SubThreadActivity::Linked(sub_record("p1", "sub-a", "Alpha", 1_000)),
+        &mut rig.cx,
+    );
+    let linked = entries(&store, &rig.cx);
+    let entry = item(&linked, "p1", "sub-a").expect("Linked records the item");
+    assert_eq!(entry.origin, TaskOrigin::SubThread);
+    assert_eq!(entry.record.kind.as_ref(), "sub_thread");
+    assert_eq!(
+        entry.record.status,
+        AgentTaskStatus::Completed,
+        "a link alone is Idle"
+    );
+
+    emit_sub(
+        &rig.card,
+        SubThreadActivity::Running(sub_record("p1", "sub-a", "Alpha", 2_000)),
+        &mut rig.cx,
+    );
+    let running = entries(&store, &rig.cx);
+    assert_eq!(
+        item(&running, "p1", "sub-a").unwrap().record.status,
+        AgentTaskStatus::Running
+    );
+    assert_eq!(
+        item(&load(&db).await, "p1", "sub-a").unwrap().record.status,
+        AgentTaskStatus::Running,
+        "written too"
+    );
+
+    emit_sub(
+        &rig.card,
+        SubThreadActivity::Idle(sub_record("p1", "sub-a", "Alpha", 3_000)),
+        &mut rig.cx,
+    );
+    let idle = entries(&store, &rig.cx);
+    let entry = item(&idle, "p1", "sub-a").unwrap();
+    assert_eq!(entry.record.status, AgentTaskStatus::Completed);
+    assert!(
+        entry.record.ended_at_ms.is_some(),
+        "Idle carries when it stopped"
+    );
+
+    emit_sub(
+        &rig.card,
+        SubThreadActivity::Retitled(sub_record("p1", "sub-a", "Alpha renamed", 4_000)),
+        &mut rig.cx,
+    );
+    assert_eq!(
+        item(&entries(&store, &rig.cx), "p1", "sub-a")
+            .unwrap()
+            .record
+            .description
+            .as_ref(),
+        "Alpha renamed"
+    );
+    let disk = load(&db).await;
+    assert_eq!(
+        item(&disk, "p1", "sub-a")
+            .unwrap()
+            .record
+            .description
+            .as_ref(),
+        "Alpha renamed"
+    );
+    assert!(
+        !item(&disk, "p1", "sub-a").unwrap().archived,
+        "never archived by an event"
+    );
+}
+
+// R5.4: a sub-thread item stored Running is not Running after a restart.
+#[gpui::test]
+async fn store_sub_thread_restart_shows_every_item_idle(cx: &mut TestAppContext) {
+    let mut rig = card_rig(cx).await;
+    let db = AgentTaskHistoryDb::open_test_db("store_sub_thread_restart").await;
+    db.upsert_sub_thread(&sub_record("p1", "sub-a", "Alpha", 1_000), "running")
+        .await
+        .expect("upsert_sub_thread should succeed");
+
+    let store = registered_store(&db, &mut rig);
+
+    let memory = entries(&store, &rig.cx);
+    let entry = item(&memory, "p1", "sub-a").expect("loaded");
+    assert_ne!(
+        entry.record.status,
+        AgentTaskStatus::Running,
+        "never Running after a start"
+    );
+    assert_eq!(
+        entry.origin,
+        TaskOrigin::SubThread,
+        "still a sub-thread item"
+    );
+}
+
+// ── Failure (R5.8, Q4) ─────────────────────────────────────────────────────
+
+#[gpui::test]
+async fn store_sub_thread_failed_write_keeps_memory_and_logs_both_ids(cx: &mut TestAppContext) {
+    capture_logs();
+    let mut rig = card_rig(cx).await;
+    let db = AgentTaskHistoryDb::open_test_db("store_sub_thread_failure").await;
+    let store = registered_store(&db, &mut rig);
+    drop_history_table(&db).await;
+
+    emit_sub(
+        &rig.card,
+        SubThreadActivity::Linked(sub_record("p-fail", "sub-fail", "Secret title", 1_000)),
+        &mut rig.cx,
+    );
+
+    assert!(
+        item(&entries(&store, &rig.cx), "p-fail", "sub-fail").is_some(),
+        "kept in memory for the session"
+    );
+    let errors = error_lines();
+    assert!(
+        errors
+            .iter()
+            .any(|line| line.contains("p-fail") && line.contains("sub-fail")),
+        "an error names both sessions: {errors:?}"
+    );
+    assert!(
+        log_lines()
+            .iter()
+            .all(|line| !line.contains("Secret title")),
+        "no log line carries the title"
+    );
+}
diff --git a/crates/agent_tasks_panel/src/store/sub_thread_tombstone_tests.rs b/crates/agent_tasks_panel/src/store/sub_thread_tombstone_tests.rs
new file mode 100644
index 0000000..db30b58
--- /dev/null
+++ b/crates/agent_tasks_panel/src/store/sub_thread_tombstone_tests.rs
@@ -0,0 +1,272 @@
+// Story 040, task 11.1 (store half) — a deleted sub-thread item stays deleted across a restart
+// (R5.10, R7.6; Q14; review 3.x F1). Module path: `store::sub_thread_tombstone`. Wired in
+// `src/store.rs`, after `mod sub_thread_items;`, as
+//
+//     #[cfg(test)]
+//     #[path = "store/sub_thread_tombstone_tests.rs"]
+//     mod sub_thread_tombstone;
+//
+// Validation filter: `cargo test -p agent_tasks_panel -- sub_thread` (names carry
+// `store_sub_thread_tombstone`).
+//
+// Contract (design.md v4, "Tombstone (v4, review 3.x F1, R5.10)"): the store's delete leaves a
+// tombstone row; at load, `deleted_sub_thread_keys()` seeds the store's `deleted` set BEFORE the
+// `Linked` that `register_card` replays is applied, so the in-memory guard of
+// `record_sub_thread` covers the restart. A restart is modelled as story 014's tests model it: a
+// new `TaskHistoryStore` over the same database, registered on the card.
+//
+// Fidelity: the `Linked` of the restart is the card's REAL replay
+// (`TasksCard::replay_sub_thread_activity`, run by `register_card`) over a real
+// `ThreadOrganizationStore` link between two real threads — never an emitted fake. The first
+// test is the control proving that replay reaches the store in this rig; without it, "no item"
+// would hold vacuously.
+//
+// Hostile halves first:
+//   - wrongly split (the revival): after Delete, a new store over the same database hears the
+//     replayed `Linked` and lists no item, in memory and on disk — whether the store registers
+//     after its load ended, or while it is still loading (the replay is then queued and applied
+//     after the load);
+//   - wrongly collapse: the deleted item's sibling, linked to the same parent, is still listed
+//     after the restart;
+//   - then the benign control: a link never deleted is listed by a store over a new database.
+// Assumed beyond design.md: `deleted_sub_thread_keys` as in `history_db/tombstone_tests.rs`.
+
+use acp_thread::AcpThread;
+use agent_ui::thread_metadata_store::ThreadMetadataStore;
+use agent_ui::thread_organization::{ForkKind, ThreadOrganizationStore};
+use gpui::{AppContext as _, Entity, TestAppContext};
+
+use crate::history_db::TaskOrigin;
+use crate::test_helpers::*;
+use crate::{AgentTaskHistoryDb, HistoryEntry, TaskHistoryStore, TaskKey};
+
+const PREFIX: &str = "zeo-sub-thread:";
+
+/// A parent thread with two sub-threads linked to it in the organization store.
+struct LinkedThreads {
+    ids: [agent_ui::ThreadId; 3],
+    parent_session: String,
+    alpha_session: String,
+    bravo_session: String,
+}
+
+fn open_tombstone_thread(
+    rig: &mut CardRig,
+    title: &str,
+) -> (Entity<AcpThread>, agent_ui::ThreadId) {
+    let thread = open_thread_on(&rig.agent_panel, StopConnection::new(), title, &mut rig.cx);
+    let id = agent_ui::test_support::active_thread_id(&rig.agent_panel, &rig.cx);
+    (thread, id)
+}
+
+fn link_two_sub_threads(rig: &mut CardRig) -> LinkedThreads {
+    rig.cx
+        .update(|_, cx| agent_ui::thread_organization::init(cx));
+    rig.cx.run_until_parked();
+    let (parent, parent_id) = open_tombstone_thread(rig, "Parent");
+    let (alpha, alpha_id) = open_tombstone_thread(rig, "Alpha");
+    let (bravo, bravo_id) = open_tombstone_thread(rig, "Bravo");
+    rig.cx.update(|_, cx| {
+        ThreadOrganizationStore::global(cx).update(cx, |store, cx| {
+            for child in [alpha_id, bravo_id] {
+                store
+                    .set_parent(child, parent_id, ForkKind::Clean, cx)
+                    .expect("the link is recorded");
+            }
+        })
+    });
+    rig.cx.run_until_parked();
+    LinkedThreads {
+        ids: [parent_id, alpha_id, bravo_id],
+        parent_session: session_of(&parent, &rig.cx).to_string(),
+        alpha_session: session_of(&alpha, &rig.cx).to_string(),
+        bravo_session: session_of(&bravo, &rig.cx).to_string(),
+    }
+}
+
+/// A store over `db`, registered on the rig's card once its load ended (`register_card` replays
+/// the card's links).
+fn tombstone_store_after_load(
+    db: &AgentTaskHistoryDb,
+    rig: &mut CardRig,
+) -> Entity<TaskHistoryStore> {
+    let store = rig.cx.new(|cx| TaskHistoryStore::new(db.clone(), cx));
+    rig.cx.run_until_parked();
+    let card = rig.card.clone();
+    store.update(&mut rig.cx, |store, cx| store.register_card(&card, cx));
+    rig.cx.run_until_parked();
+    store
+}
+
+/// A store over `db`, registered on the rig's card before its load ended: the replayed links are
+/// heard while loading and applied after it.
+fn tombstone_store_while_loading(
+    db: &AgentTaskHistoryDb,
+    rig: &mut CardRig,
+) -> Entity<TaskHistoryStore> {
+    let card = rig.card.clone();
+    let store = rig.cx.new(|cx| {
+        let mut store = TaskHistoryStore::new(db.clone(), cx);
+        store.register_card(&card, cx);
+        store
+    });
+    rig.cx.run_until_parked();
+    store
+}
+
+fn tombstone_entries(store: &Entity<TaskHistoryStore>, rig: &CardRig) -> Vec<HistoryEntry> {
+    store.read_with(&rig.cx, |store, _| store.records().to_vec())
+}
+
+fn item_key(parent: &str, sub: &str) -> TaskKey {
+    (sid(parent), format!("{PREFIX}{sub}").into())
+}
+
+fn has_item(rows: &[HistoryEntry], parent: &str, sub: &str) -> bool {
+    rows.iter().any(|entry| {
+        entry.origin == TaskOrigin::SubThread
+            && entry.record.session_id.to_string() == parent
+            && entry.record.task_id == format!("{PREFIX}{sub}")
+    })
+}
+
+/// Records both items through the first store, then archives and deletes Alpha's.
+fn delete_alpha(store: &Entity<TaskHistoryStore>, links: &LinkedThreads, rig: &mut CardRig) {
+    // The real replay records both links in this store (idempotent if the reporter already did).
+    let card = rig.card.clone();
+    card.update(&mut rig.cx, |card, cx| card.replay_sub_thread_activity(cx));
+    rig.cx.run_until_parked();
+    let before = tombstone_entries(store, rig);
+    assert!(
+        has_item(&before, &links.parent_session, &links.alpha_session)
+            && has_item(&before, &links.parent_session, &links.bravo_session),
+        "precondition: both items are recorded: {before:?}"
+    );
+    let alpha = item_key(&links.parent_session, &links.alpha_session);
+    store.update(&mut rig.cx, |store, cx| {
+        store.archive(std::slice::from_ref(&alpha), cx)
+    });
+    rig.cx.run_until_parked();
+    store.update(&mut rig.cx, |store, cx| {
+        store.delete_archived(std::slice::from_ref(&alpha), cx)
+    });
+    rig.cx.run_until_parked();
+    assert!(
+        !has_item(
+            &tombstone_entries(store, rig),
+            &links.parent_session,
+            &links.alpha_session
+        ),
+        "precondition: the item is gone this session"
+    );
+}
+
+/// What the restarted store and the database show for Alpha and Bravo.
+async fn assert_alpha_stays_deleted(
+    restarted: &Entity<TaskHistoryStore>,
+    db: &AgentTaskHistoryDb,
+    links: &LinkedThreads,
+    rig: &mut CardRig,
+) {
+    let memory = tombstone_entries(restarted, rig);
+    assert!(
+        !has_item(&memory, &links.parent_session, &links.alpha_session),
+        "the replayed Linked does not bring the deleted item back: {memory:?}"
+    );
+    assert!(
+        has_item(&memory, &links.parent_session, &links.bravo_session),
+        "its sibling is still listed after the restart"
+    );
+    assert!(
+        !has_item(&load(db).await, &links.parent_session, &links.alpha_session),
+        "no live row is written back for the deleted item"
+    );
+    let tombstones: Vec<(String, String)> = db
+        .deleted_sub_thread_keys()
+        .await
+        .expect("deleted_sub_thread_keys should succeed")
+        .into_iter()
+        .map(|(session_id, task_id)| (session_id.to_string(), task_id.to_string()))
+        .collect();
+    assert_eq!(
+        tombstones,
+        vec![(
+            links.parent_session.clone(),
+            format!("{PREFIX}{}", links.alpha_session)
+        )],
+        "the deleted item is kept as a tombstone"
+    );
+    let [parent_id, alpha_id, bravo_id] = links.ids;
+    rig.cx.update(|_, cx| {
+        let organization = ThreadOrganizationStore::global(cx);
+        assert_eq!(
+            organization.read(cx).parent(alpha_id),
+            Some((parent_id, ForkKind::Clean)),
+            "the deleted item's link is untouched (R7.6)"
+        );
+        let metadata = ThreadMetadataStore::global(cx);
+        let metadata = metadata.read(cx);
+        for id in [parent_id, alpha_id, bravo_id] {
+            let entry = metadata.entry(id).expect("the thread still exists (R7.6)");
+            assert!(!entry.archived, "the thread is not archived (R7.6)");
+        }
+    });
+}
+
+// ── Control: the replay reaches the store in this rig ──────────────────────
+
+#[gpui::test]
+async fn store_sub_thread_tombstone_control_replay_lists_a_link_never_deleted(
+    cx: &mut TestAppContext,
+) {
+    let mut rig = card_rig(cx).await;
+    let links = link_two_sub_threads(&mut rig);
+    let db = AgentTaskHistoryDb::open_test_db("store_sub_thread_tombstone_control").await;
+
+    let store = tombstone_store_after_load(&db, &mut rig);
+
+    let memory = tombstone_entries(&store, &rig);
+    assert!(
+        has_item(&memory, &links.parent_session, &links.alpha_session)
+            && has_item(&memory, &links.parent_session, &links.bravo_session),
+        "register_card's replay records every link: {memory:?}"
+    );
+}
+
+// ── Wrongly split: the revival (R5.10) ─────────────────────────────────────
+
+#[gpui::test]
+async fn store_sub_thread_tombstone_deleted_item_stays_deleted_after_a_restart(
+    cx: &mut TestAppContext,
+) {
+    let mut rig = card_rig(cx).await;
+    let links = link_two_sub_threads(&mut rig);
+    let db = AgentTaskHistoryDb::open_test_db("store_sub_thread_tombstone_restart").await;
+    let first = tombstone_store_after_load(&db, &mut rig);
+    delete_alpha(&first, &links, &mut rig);
+    drop(first);
+    rig.cx.run_until_parked();
+
+    let restarted = tombstone_store_after_load(&db, &mut rig);
+
+    assert_alpha_stays_deleted(&restarted, &db, &links, &mut rig).await;
+}
+
+// The replay heard while the restarted store is still loading is applied after the load: the
+// tombstones must already be in the store's `deleted` set by then.
+#[gpui::test]
+async fn store_sub_thread_tombstone_deleted_item_stays_deleted_when_replayed_while_loading(
+    cx: &mut TestAppContext,
+) {
+    let mut rig = card_rig(cx).await;
+    let links = link_two_sub_threads(&mut rig);
+    let db = AgentTaskHistoryDb::open_test_db("store_sub_thread_tombstone_loading").await;
+    let first = tombstone_store_after_load(&db, &mut rig);
+    delete_alpha(&first, &links, &mut rig);
+    drop(first);
+    rig.cx.run_until_parked();
+
+    let restarted = tombstone_store_while_loading(&db, &mut rig);
+
+    assert_alpha_stays_deleted(&restarted, &db, &links, &mut rig).await;
+}
diff --git a/crates/agent_tasks_panel/src/test_helpers.rs b/crates/agent_tasks_panel/src/test_helpers.rs
index 595ca24..c569535 100644
--- a/crates/agent_tasks_panel/src/test_helpers.rs
+++ b/crates/agent_tasks_panel/src/test_helpers.rs
@@ -135,6 +135,7 @@ pub(crate) fn record(
         // Story 023: no tool call id unless a test gives one.
         tool_call_id: None,
         child_session_id: None,
+        waiting: false,
     }
 }
 
diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs
index 7e3846f..1358e1f 100644
--- a/crates/agent_ui/src/agent_panel.rs
+++ b/crates/agent_ui/src/agent_panel.rs
@@ -1602,6 +1602,17 @@ impl AgentPanel {
                     window,
                     cx,
                 ),
+                TasksCardEvent::OpenNativeSubagent {
+                    session_id,
+                    task_id,
+                    child,
+                } => panel.open_native_subagent_from_card(
+                    session_id.clone(),
+                    task_id.clone(),
+                    child.clone(),
+                    window,
+                    cx,
+                ),
                 // The task history records it; the panel has nothing to do.
                 TasksCardEvent::TaskLogSaved { .. } => {}
             },
@@ -1927,6 +1938,47 @@ impl AgentPanel {
         });
     }
 
+    /// Shows a native ACP subagent's own thread, which its parent's conversation holds
+    /// (story 064, R1.6). Navigation within the parent's conversation, as Zed's own subagent
+    /// card does: the parent stays the conversation's root.
+    fn open_native_subagent_from_card(
+        &mut self,
+        session_id: acp_v2::SessionId,
+        task_id: acp_thread::AgentTaskId,
+        child: acp_v2::SessionId,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let result = (|| -> Result<()> {
+            let conversation = self
+                .held_conversation_view(&session_id, cx)
+                .with_context(|| format!("the thread of session {session_id} is not open"))?;
+            anyhow::ensure!(
+                conversation.read(cx).thread_view(&child).is_some(),
+                "subagent {child} is not loaded in the thread of session {session_id}"
+            );
+            self.open_thread_by_session(session_id.clone(), window, cx);
+            let conversation = self
+                .active_conversation_view()
+                .filter(|active| **active == conversation)
+                .cloned()
+                .with_context(|| {
+                    format!("the thread of session {session_id} did not come forward")
+                })?;
+            conversation.update(cx, |conversation, cx| {
+                conversation.navigate_to_thread(child.clone(), window, cx)
+            });
+            Ok(())
+        })();
+        match result {
+            Ok(()) => log::info!(
+                "agent panel: opened subagent session {child} of session {session_id} from the \
+                 tasks card"
+            ),
+            Err(error) => self.show_card_open_failure(session_id, task_id, error, cx),
+        }
+    }
+
     /// Brings forward the thread the root thread of `session_id` names on its tool
     /// call `tool_call_id` as the subagent's own, and returns that thread's session.
     /// `Ok(None)` when there is none to show: no tool call id, no such root entry,
diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs
index 62939b4..c589882 100644
--- a/crates/agent_ui/src/agent_ui.rs
+++ b/crates/agent_ui/src/agent_ui.rs
@@ -28,6 +28,10 @@ mod model_selector_popover;
 mod profile_selector;
 mod set_as_default;
 pub mod sub_thread;
+mod sub_thread_reporter;
+mod sub_thread_send;
+#[cfg(test)]
+mod sub_thread_test_support;
 mod task_label;
 mod tasks_card;
 mod terminal_codegen;
@@ -89,17 +93,20 @@ pub use crate::agent_panel::{
 };
 use crate::agent_registry_ui::AgentRegistryPage;
 pub use crate::agent_session_router::{
-    OpenAgentSessionOptions, OpenedAgentSession, open_agent_session,
+    OpenAgentSessionOptions, OpenedAgentSession, OpenedAgentSubagent, open_agent_session,
+    open_agent_subagent,
 };
 pub use crate::inline_assistant::InlineAssistant;
 pub use crate::message_editor::MessageEditorEvent;
+pub use crate::sub_thread_reporter::{SubThreadActivity, SubThreadRecord};
 pub use crate::task_label::{
     TASK_LABEL_MAX_CHARS, TaskLabels, collapse_label, display_label, normalize_user_label,
 };
 /// Story 041: where a task's saved log lives; the Agent Tasks panel and its store use it too.
 pub use crate::tasks_card::task_log;
 pub use crate::tasks_card::{
-    TaskActivity, TaskKey, TaskRecord, TasksCard, TasksCardEvent, ThreadTitleChanged,
+    SUBAGENT_TASK_ID_PREFIX, TASK_WAITING_LABEL, TaskActivity, TaskKey, TaskRecord, TasksCard,
+    TasksCardEvent, ThreadTitleChanged,
 };
 pub use crate::thread_metadata_store::ThreadId;
 pub use agent_diff::{AgentDiffPane, AgentDiffToolbar};
diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs
index 3477ff5..3ff859b 100644
--- a/crates/agent_ui/src/conversation_view.rs
+++ b/crates/agent_ui/src/conversation_view.rs
@@ -2048,6 +2048,20 @@ impl ConversationView {
         if !is_current_owner {
             return;
         };
+        // Story 040: a pending Summarize and Send waits for its turn's end.
+        if matches!(
+            event,
+            AcpThreadEvent::Stopped { .. }
+                | AcpThreadEvent::Error
+                | AcpThreadEvent::Refusal
+                | AcpThreadEvent::EntriesRemoved(_)
+                | AcpThreadEvent::SubmissionUpdated(_)
+        ) && let Some(view) = self.thread_view(&session_id)
+        {
+            view.update(cx, |view, cx| {
+                view.on_sub_thread_turn_event(event, window, cx)
+            });
+        }
         let is_subagent = thread.read(cx).parent_session_id().is_some();
         if !is_subagent && affects_thread_metadata(event) {
             cx.emit(RootThreadUpdated);
diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs
index df6578d..c7b2397 100644
--- a/crates/agent_ui/src/conversation_view/thread_view.rs
+++ b/crates/agent_ui/src/conversation_view/thread_view.rs
@@ -741,6 +741,9 @@ impl PermissionSelection {
 
 pub struct ThreadView {
     pub(crate) root_thread_id: ThreadId,
+    pub(crate) send_to_parent: crate::sub_thread_send::SendToParentState,
+    /// The Send to Parent button's focus, so Tab reaches it (story 040, R1.6).
+    pub(crate) send_to_parent_focus_handle: FocusHandle,
     pub session_id: acp_v2::SessionId,
     pub parent_session_id: Option<acp_v2::SessionId>,
     pub thread: Entity<AcpThread>,
@@ -1285,6 +1288,8 @@ impl ThreadView {
             .collect();
         let mut this = Self {
             root_thread_id,
+            send_to_parent: Default::default(),
+            send_to_parent_focus_handle: cx.focus_handle().tab_stop(true).tab_index(0),
             session_id,
             parent_session_id,
             focus_handle: cx.focus_handle(),
@@ -5932,6 +5937,7 @@ impl ThreadView {
         let has_messages = self.has_visible_conversation(cx);
         let fills_container = !has_messages || editor_expanded;
         let composer_is_read_only = self.message_editor.read(cx).editor().read(cx).read_only(cx);
+        let send_to_parent = self.render_send_to_parent(window, cx);
         let discard_draft_button = composer_is_read_only.then(|| {
             div()
                 .debug_selector(|| "discard-protected-draft".into())
@@ -5977,6 +5983,7 @@ impl ThreadView {
                     .flex_grow_0()
                     .justify_between()
                     .gap_2()
+                    .children(send_to_parent)
                     .child(
                         v_flex()
                             .relative()
@@ -10013,7 +10020,7 @@ impl ThreadView {
             .into_any_element()
     }
 
-    fn get_agent_message_content(
+    pub(crate) fn get_agent_message_content(
         entries: &[AgentThreadEntry],
         entry_index: usize,
         cx: &App,
diff --git a/crates/agent_ui/src/sub_thread_reporter.rs b/crates/agent_ui/src/sub_thread_reporter.rs
new file mode 100644
index 0000000..e12a405
--- /dev/null
+++ b/crates/agent_ui/src/sub_thread_reporter.rs
@@ -0,0 +1,389 @@
+//! Story 040 — the tasks card reports sub-threads as task activity (design.md,
+//! SubThreadReporter).
+//!
+//! The reporter lives inside the card because the card is what
+//! `TaskHistoryStore::register_card` already subscribes to in every window: one more event type
+//! on it reaches the history with no new registration.
+
+use std::time::SystemTime;
+
+use acp_thread::{AcpThread, ThreadStatus};
+use agent_client_protocol::v2::schema as acp_v2;
+use collections::{HashMap, HashSet};
+use gpui::{App, Context, Entity, EventEmitter, SharedString, Subscription, Task};
+
+use crate::TasksCard;
+use crate::tasks_card::unix_ms;
+use crate::thread_metadata_store::{ThreadId, ThreadMetadata, ThreadMetadataStore};
+use crate::thread_organization::ThreadOrganizationStore;
+
+#[derive(Clone, Debug)]
+pub enum SubThreadActivity {
+    Linked(SubThreadRecord),
+    Running(SubThreadRecord),
+    Idle(SubThreadRecord),
+    Retitled(SubThreadRecord),
+}
+
+#[derive(Clone, Debug)]
+pub struct SubThreadRecord {
+    pub parent_session: acp_v2::SessionId,
+    pub sub_session: acp_v2::SessionId,
+    pub title: SharedString,
+    pub project: SharedString,
+    pub at_ms: i64,
+}
+
+impl EventEmitter<SubThreadActivity> for TasksCard {}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum SubThreadState {
+    Running,
+    Idle,
+}
+
+/// The card's memory of what it already told about sub-threads, so that a store that notifies
+/// many times, or a thread held through two views, is still one event per change (R5.2).
+pub(crate) struct SubThreadReporter {
+    /// Each reported sub-thread with the parent it was reported under.
+    reported_links: HashMap<ThreadId, ThreadId>,
+    /// Links already logged as unresolvable, so a dangling link is logged once, not per notify.
+    unresolved_links: HashSet<ThreadId>,
+    last_state: HashMap<acp_v2::SessionId, SubThreadState>,
+    last_title: HashMap<acp_v2::SessionId, SharedString>,
+    _organization_observation: Option<Subscription>,
+    _first_report: Option<Task<()>>,
+}
+
+impl SubThreadReporter {
+    /// Without an organization store there are no links, and so nothing to report, ever.
+    pub(crate) fn new(cx: &mut Context<TasksCard>) -> Self {
+        let Some(organization) = ThreadOrganizationStore::try_global(cx) else {
+            return Self::inert();
+        };
+        let observation = cx.observe(&organization, |card, _, cx| {
+            card.report_sub_thread_links(cx)
+        });
+        // Spawned rather than deferred: a deferred effect runs when the update that created the
+        // card ends, before `register_card` can have subscribed, and the links older than the card
+        // would reach nobody.
+        let first_report = cx.spawn(async move |card, cx| {
+            // A card dropped before this ran has nobody left to tell.
+            card.update(cx, |card, cx| card.report_sub_thread_links(cx))
+                .ok();
+        });
+        Self {
+            _organization_observation: Some(observation),
+            _first_report: Some(first_report),
+            ..Self::inert()
+        }
+    }
+
+    fn inert() -> Self {
+        Self {
+            reported_links: HashMap::default(),
+            unresolved_links: HashSet::default(),
+            last_state: HashMap::default(),
+            last_title: HashMap::default(),
+            _organization_observation: None,
+            _first_report: None,
+        }
+    }
+
+    /// The links the store holds that were not reported yet, each as `Linked`, in the store's
+    /// order. A link that went away is forgotten, so linking that thread again reports again.
+    fn take_new_links(&mut self, cx: &App) -> Vec<SubThreadActivity> {
+        let Some(organization) = ThreadOrganizationStore::try_global(cx) else {
+            return Vec::new();
+        };
+        let organization = organization.read(cx);
+        let links: Vec<(ThreadId, ThreadId)> = organization
+            .sub_threads()
+            .into_iter()
+            .filter_map(|child| Some((child, organization.parent(child)?.0)))
+            .collect();
+        let current: HashMap<ThreadId, ThreadId> = links.iter().copied().collect();
+        self.reported_links
+            .retain(|child, parent| current.get(child) == Some(parent));
+        self.unresolved_links
+            .retain(|child| current.contains_key(child));
+
+        let mut activity = Vec::new();
+        for (child, parent) in links {
+            if self.reported_links.contains_key(&child) {
+                continue;
+            }
+            match resolve_link(child, parent, cx) {
+                Some(record) => {
+                    self.reported_links.insert(child, parent);
+                    self.unresolved_links.remove(&child);
+                    activity.push(SubThreadActivity::Linked(record));
+                }
+                None => {
+                    if self.unresolved_links.insert(child) {
+                        log::debug!(
+                            "zeo_sub_thread event=link_skipped child_thread={} parent_thread={} \
+                             reason=no_session",
+                            child.to_key_string(),
+                            parent.to_key_string()
+                        );
+                    }
+                }
+            }
+        }
+        activity
+    }
+}
+
+impl TasksCard {
+    /// Called on every notify of the organization store, and once after creation.
+    pub(crate) fn report_sub_thread_links(&mut self, cx: &mut Context<Self>) {
+        for activity in self.sub_thread_reporter.take_new_links(cx) {
+            let record = match &activity {
+                SubThreadActivity::Linked(record) => Some(record.clone()),
+                _ => None,
+            };
+            self.emit_sub_thread_activity(activity, cx);
+            // 039 records a clean sub-thread's link deferred, after its first turn has already
+            // sent `StatusChanged`; without this, that turn would never be reported Running.
+            if let Some(record) = record {
+                self.report_running_if_generating(record, cx);
+            }
+        }
+    }
+
+    /// Every link the organization store holds, as `Linked`, then `Running` for each held
+    /// sub-thread mid-turn. `TaskHistoryStore::register_card` subscribes after the card exists,
+    /// often long after its first report ran, so it calls this right after subscribing; the
+    /// history's link upsert is idempotent, so hearing a link twice changes nothing.
+    pub fn replay_sub_thread_activity(&mut self, cx: &mut Context<Self>) {
+        let Some(organization) = ThreadOrganizationStore::try_global(cx) else {
+            return;
+        };
+        let links: Vec<(ThreadId, ThreadId)> = {
+            let organization = organization.read(cx);
+            organization
+                .sub_threads()
+                .into_iter()
+                .filter_map(|child| Some((child, organization.parent(child)?.0)))
+                .collect()
+        };
+        for (child, parent) in links {
+            let Some(record) = resolve_link(child, parent, cx) else {
+                continue;
+            };
+            let reporter = &mut self.sub_thread_reporter;
+            reporter.reported_links.insert(child, parent);
+            reporter.unresolved_links.remove(&child);
+            self.emit_sub_thread_activity(SubThreadActivity::Linked(record.clone()), cx);
+            if let Some(thread) = self.held_thread(&record.sub_session)
+                && thread.read(cx).status() == ThreadStatus::Generating
+            {
+                self.sub_thread_reporter
+                    .last_state
+                    .insert(record.sub_session.clone(), SubThreadState::Running);
+                self.emit_sub_thread_activity(SubThreadActivity::Running(record), cx);
+            }
+        }
+    }
+
+    fn report_running_if_generating(&mut self, record: SubThreadRecord, cx: &mut Context<Self>) {
+        let Some(thread) = self.held_thread(&record.sub_session) else {
+            return;
+        };
+        if thread.read(cx).status() != ThreadStatus::Generating {
+            return;
+        }
+        let reporter = &mut self.sub_thread_reporter;
+        if reporter.last_state.get(&record.sub_session) == Some(&SubThreadState::Running) {
+            return;
+        }
+        reporter
+            .last_state
+            .insert(record.sub_session.clone(), SubThreadState::Running);
+        self.emit_sub_thread_activity(SubThreadActivity::Running(record), cx);
+    }
+
+    /// A sub-thread the card stopped holding mid-turn has stopped generating with it: report it
+    /// Idle, or the history would keep it running until the next startup sweep.
+    pub(crate) fn report_unheld_sub_threads_idle(
+        &mut self,
+        held: &HashSet<acp_v2::SessionId>,
+        cx: &mut Context<Self>,
+    ) {
+        let stopped: Vec<acp_v2::SessionId> = self
+            .sub_thread_reporter
+            .last_state
+            .iter()
+            .filter(|(session, state)| {
+                **state == SubThreadState::Running && !held.contains(*session)
+            })
+            .map(|(session, _)| session.clone())
+            .collect();
+        for session in stopped {
+            // Kept as Idle rather than forgotten, so the thread held again re-emits nothing.
+            self.sub_thread_reporter
+                .last_state
+                .insert(session.clone(), SubThreadState::Idle);
+            if let Some(record) = record_of_session(&session, cx) {
+                self.emit_sub_thread_activity(SubThreadActivity::Idle(record), cx);
+            }
+        }
+    }
+
+    /// `StatusChanged` of a held root thread: `Running` or `Idle` when it is a sub-thread, once
+    /// per change of state (R5.3, R5.9).
+    pub(crate) fn report_sub_thread_status(
+        &mut self,
+        thread: &Entity<AcpThread>,
+        cx: &mut Context<Self>,
+    ) {
+        let state = match thread.read(cx).status() {
+            ThreadStatus::Generating => SubThreadState::Running,
+            ThreadStatus::Idle => SubThreadState::Idle,
+            // Not yet known to be either; the confirmed status follows with its own event.
+            ThreadStatus::Unconfirmed => return,
+        };
+        let Some(record) = self.sub_thread_record_of(thread, cx) else {
+            return;
+        };
+        let reporter = &mut self.sub_thread_reporter;
+        if reporter.last_state.get(&record.sub_session) == Some(&state) {
+            return;
+        }
+        reporter
+            .last_state
+            .insert(record.sub_session.clone(), state);
+        self.emit_sub_thread_activity(
+            match state {
+                SubThreadState::Running => SubThreadActivity::Running(record),
+                SubThreadState::Idle => SubThreadActivity::Idle(record),
+            },
+            cx,
+        );
+    }
+
+    /// `TitleUpdated` of a held root thread: `Retitled` with the new title when it is a
+    /// sub-thread, once per distinct title (R5.5).
+    pub(crate) fn report_sub_thread_title(
+        &mut self,
+        thread: &Entity<AcpThread>,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(title) = thread.read(cx).title() else {
+            return;
+        };
+        let Some(mut record) = self.sub_thread_record_of(thread, cx) else {
+            return;
+        };
+        let reporter = &mut self.sub_thread_reporter;
+        if reporter.last_title.get(&record.sub_session) == Some(&title) {
+            return;
+        }
+        reporter
+            .last_title
+            .insert(record.sub_session.clone(), title.clone());
+        // The thread's own title, not the stored one: the metadata store may follow this same
+        // event after the card has heard it.
+        record.title = title;
+        self.emit_sub_thread_activity(SubThreadActivity::Retitled(record), cx);
+    }
+
+    /// The record of `thread` when it is a sub-thread whose link resolves. A link heard here
+    /// before the store's notify reached the card is reported first, so the history never gets a
+    /// status for an item it has not created.
+    fn sub_thread_record_of(
+        &mut self,
+        thread: &Entity<AcpThread>,
+        cx: &mut Context<Self>,
+    ) -> Option<SubThreadRecord> {
+        let organization = ThreadOrganizationStore::try_global(cx)?;
+        let session_id = thread.read(cx).session_id().clone();
+        let child = ThreadMetadataStore::try_global(cx)?
+            .read(cx)
+            .entry_by_session(&session_id)?
+            .thread_id;
+        let (parent, _) = organization.read(cx).parent(child)?;
+        let record = resolve_link(child, parent, cx)?;
+        let reporter = &mut self.sub_thread_reporter;
+        if reporter.reported_links.get(&child) != Some(&parent) {
+            reporter.reported_links.insert(child, parent);
+            reporter.unresolved_links.remove(&child);
+            self.emit_sub_thread_activity(SubThreadActivity::Linked(record.clone()), cx);
+        }
+        Some(record)
+    }
+}
+
+/// The record of a sub-thread known only by its session id, for a thread the card no longer
+/// holds.
+fn record_of_session(session: &acp_v2::SessionId, cx: &App) -> Option<SubThreadRecord> {
+    let child = ThreadMetadataStore::try_global(cx)?
+        .read(cx)
+        .entry_by_session(session)?
+        .thread_id;
+    let (parent, _) = ThreadOrganizationStore::try_global(cx)?
+        .read(cx)
+        .parent(child)?;
+    resolve_link(child, parent, cx)
+}
+
+/// Reads `ThreadMetadataStore`, so it must not run inside one of that store's updates; the
+/// card's callbacks run as effects, after any update has ended.
+fn resolve_link(child: ThreadId, parent: ThreadId, cx: &App) -> Option<SubThreadRecord> {
+    let metadata = ThreadMetadataStore::try_global(cx)?;
+    let metadata = metadata.read(cx);
+    let child = metadata.entry(child)?;
+    let sub_session = child.session_id.clone()?;
+    let parent_session = metadata.entry(parent)?.session_id.clone()?;
+    Some(SubThreadRecord {
+        parent_session,
+        sub_session,
+        title: child.display_title(),
+        project: project_of(child),
+        at_ms: unix_ms(SystemTime::now()),
+    })
+}
+
+/// The last component of the thread's first folder, as the card names a held thread's project.
+pub(crate) fn project_of(metadata: &ThreadMetadata) -> SharedString {
+    metadata
+        .folder_paths()
+        .paths()
+        .first()
+        .and_then(|path| path.file_name())
+        .map(|name| SharedString::from(name.to_string_lossy().into_owned()))
+        .unwrap_or_default()
+}
+
+impl TasksCard {
+    /// The card's own rows first, then everyone listening: one source for both (R6, R5).
+    fn emit_sub_thread_activity(&mut self, activity: SubThreadActivity, cx: &mut Context<Self>) {
+        self.note_sub_thread_activity(&activity, cx);
+        emit_activity(activity, cx);
+    }
+}
+
+fn emit_activity(activity: SubThreadActivity, cx: &mut Context<TasksCard>) {
+    let (event, record) = match &activity {
+        SubThreadActivity::Linked(record) => ("linked", record),
+        SubThreadActivity::Running(record) => ("running", record),
+        SubThreadActivity::Idle(record) => ("idle", record),
+        SubThreadActivity::Retitled(record) => ("retitled", record),
+    };
+    // Session ids only: a title is user text and stays out of the log (R8.1).
+    log::info!(
+        "zeo_sub_thread event={event} parent_session={} sub_session={}",
+        record.parent_session,
+        record.sub_session
+    );
+    cx.emit(activity);
+}
+
+#[cfg(test)]
+#[path = "sub_thread_reporter/reporter_tests.rs"]
+mod reporter_tests;
+
+#[cfg(test)]
+#[path = "sub_thread_reporter/replay_tests.rs"]
+mod replay_tests;
diff --git a/crates/agent_ui/src/sub_thread_reporter/replay_tests.rs b/crates/agent_ui/src/sub_thread_reporter/replay_tests.rs
new file mode 100644
index 0000000..7e874a8
--- /dev/null
+++ b/crates/agent_ui/src/sub_thread_reporter/replay_tests.rs
@@ -0,0 +1,88 @@
+// Story 040, task 2.1 — the tech review's two reachable gaps: a link older than the history's
+// subscription is heard through `replay_sub_thread_activity`, and a sub-thread already mid-turn
+// when its link lands is reported Running after its `Linked`.
+
+use std::{cell::RefCell, rc::Rc};
+
+use acp_thread::ThreadStatus;
+use agent_client_protocol::v1::schema as acp_v1;
+use gpui::TestAppContext;
+
+use crate::SubThreadActivity;
+use crate::sub_thread_test_support::*;
+
+fn kinds_of(heard: &Rc<RefCell<Vec<SubThreadActivity>>>) -> Vec<(&'static str, String)> {
+    heard
+        .borrow()
+        .iter()
+        .map(|activity| match activity {
+            SubThreadActivity::Linked(record) => ("linked", record.sub_session.to_string()),
+            SubThreadActivity::Running(record) => ("running", record.sub_session.to_string()),
+            SubThreadActivity::Idle(record) => ("idle", record.sub_session.to_string()),
+            SubThreadActivity::Retitled(record) => ("retitled", record.sub_session.to_string()),
+        })
+        .collect()
+}
+
+#[gpui::test]
+async fn sub_thread_reporter_replay_reaches_a_late_subscriber(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    // Subscribed after the card already reported the link, as `register_card` is in production.
+    let (heard, _subscription) = record_activity(&card(&rig), &mut rig.cx);
+    assert!(
+        kinds_of(&heard).is_empty(),
+        "precondition: nothing heard yet"
+    );
+
+    card(&rig).update(&mut rig.cx, |card, cx| card.replay_sub_thread_activity(cx));
+    rig.cx.run_until_parked();
+
+    assert_eq!(
+        kinds_of(&heard),
+        vec![("linked", child.session.to_string())],
+        "the replay reports the existing link, and no status for an idle sub-thread"
+    );
+}
+
+#[gpui::test]
+async fn sub_thread_reporter_link_during_a_turn_reports_running(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    let (heard, _subscription) = record_activity(&card(&rig), &mut rig.cx);
+
+    let finish = rig.connection.defer_next_prompt_response();
+    set_composer(&child.view, "a side question", &mut rig.cx);
+    child
+        .view
+        .update_in(&mut rig.cx, |view, window, cx| view.send(window, cx));
+    rig.cx.run_until_parked();
+    assert_eq!(
+        child.thread.read_with(&rig.cx, |thread, _| thread.status()),
+        ThreadStatus::Generating,
+        "precondition: the thread is generating before it is linked"
+    );
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+
+    let sub = child.session.to_string();
+    assert_eq!(
+        kinds_of(&heard),
+        vec![("linked", sub.clone()), ("running", sub.clone())],
+        "Linked, then Running for the turn already under way"
+    );
+
+    finish
+        .send(Ok(acp_v1::PromptResponse::new(acp_v1::StopReason::EndTurn)))
+        .expect("the turn is still waiting");
+    rig.cx.run_until_parked();
+    assert_eq!(
+        kinds_of(&heard).last(),
+        Some(&("idle", sub)),
+        "Idle once the turn ends"
+    );
+}
diff --git a/crates/agent_ui/src/sub_thread_reporter/reporter_tests.rs b/crates/agent_ui/src/sub_thread_reporter/reporter_tests.rs
new file mode 100644
index 0000000..8f7a0c5
--- /dev/null
+++ b/crates/agent_ui/src/sub_thread_reporter/reporter_tests.rs
@@ -0,0 +1,326 @@
+// Story 040, task 2.1 — `SubThreadReporter`: the card reports every parent link and every status
+// and title change of a sub-thread as `SubThreadActivity` (R5.1, R5.2, R5.3, R5.5, R5.9; Q5).
+// Module path: `sub_thread_reporter::reporter_tests`. Wired in `src/sub_thread_reporter.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "sub_thread_reporter/reporter_tests.rs"]
+//     mod reporter_tests;
+//
+// Validation filter: `cargo test -p agent_ui -- sub_thread_reporter`. Fixtures:
+// `crate::sub_thread_test_support` (materialized with this task).
+//
+// Contract (design.md §SubThreadReporter):
+//   - `pub enum SubThreadActivity { Linked, Running, Idle, Retitled }(SubThreadRecord)`;
+//     `pub struct SubThreadRecord { parent_session, sub_session, title, project, at_ms }`;
+//     `impl EventEmitter<SubThreadActivity> for TasksCard`; both re-exported from `agent_ui`;
+//   - the reporter, owned by the card, observes `ThreadOrganizationStore` (skipped silently when
+//     `try_global` is `None`) and emits `Linked` once per link, and every existing link at
+//     creation; ids, title and project come from `ThreadMetadataStore`; a link whose parent or
+//     child has no session is skipped;
+//   - for a held root thread that is a sub-thread: `StatusChanged` → `Running`/`Idle` from
+//     `status()`, `TitleUpdated` → `Retitled`; a repeat of the last state is suppressed.
+// Assumed beyond design.md: `SubThreadActivity` and `SubThreadRecord` derive `Clone` and `Debug`;
+// `SubThreadRecord`'s fields are `pub` (agent_tasks_panel reads them).
+//
+// "Existing links at creation" is asserted through a subscriber registered right after the card
+// is built, which is how `TaskHistoryStore::register_card` hears a card: an emission made inside
+// `TasksCard::new` reaches nobody.
+//
+// Hostile halves first:
+//   - wrongly collapse: two sub-threads of one parent are two `Linked`, each with its own
+//     sub-thread session; the parent and an unlinked thread generating are NOT sub-threads;
+//   - wrongly split: one link is one `Linked`, however many times the store notifies; one turn is
+//     one `Running` and one `Idle`;
+//   - a dangling link (parent deleted) is never reported with a made-up parent;
+//   - then the benign record shape and the title.
+
+use std::{cell::RefCell, rc::Rc};
+
+use acp_thread::ThreadStatus;
+use agent_client_protocol::v1::schema as acp_v1;
+use gpui::{AppContext as _, TestAppContext};
+
+use crate::sub_thread_test_support::*;
+use crate::thread_organization::ThreadOrganizationStore;
+use crate::{SubThreadActivity, SubThreadRecord, TasksCard};
+
+fn kind(activity: &SubThreadActivity) -> &'static str {
+    match activity {
+        SubThreadActivity::Linked(_) => "linked",
+        SubThreadActivity::Running(_) => "running",
+        SubThreadActivity::Idle(_) => "idle",
+        SubThreadActivity::Retitled(_) => "retitled",
+    }
+}
+
+fn record(activity: &SubThreadActivity) -> &SubThreadRecord {
+    match activity {
+        SubThreadActivity::Linked(record)
+        | SubThreadActivity::Running(record)
+        | SubThreadActivity::Idle(record)
+        | SubThreadActivity::Retitled(record) => record,
+    }
+}
+
+/// `(kind, parent session, sub-thread session)` of every activity heard.
+fn summary(heard: &Rc<RefCell<Vec<SubThreadActivity>>>) -> Vec<(&'static str, String, String)> {
+    heard
+        .borrow()
+        .iter()
+        .map(|activity| {
+            let record = record(activity);
+            (
+                kind(activity),
+                record.parent_session.to_string(),
+                record.sub_session.to_string(),
+            )
+        })
+        .collect()
+}
+
+fn of_kind(heard: &Rc<RefCell<Vec<SubThreadActivity>>>, wanted: &str) -> Vec<(String, String)> {
+    summary(heard)
+        .into_iter()
+        .filter(|(kind, _, _)| *kind == wanted)
+        .map(|(_, parent, sub)| (parent, sub))
+        .collect()
+}
+
+// ── Wrongly collapse ───────────────────────────────────────────────────────
+
+#[gpui::test]
+async fn sub_thread_reporter_two_sub_threads_of_one_parent_are_two_links(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let first = open_conversation(&mut rig, "First", "first answer");
+    let second = open_conversation(&mut rig, "Second", "second answer");
+    let (heard, _subscription) = record_activity(&card(&rig), &mut rig.cx);
+
+    link(first.thread_id, parent.thread_id, &mut rig.cx);
+    link(second.thread_id, parent.thread_id, &mut rig.cx);
+
+    let mut linked = of_kind(&heard, "linked");
+    linked.sort();
+    let mut expected = vec![
+        (parent.session.to_string(), first.session.to_string()),
+        (parent.session.to_string(), second.session.to_string()),
+    ];
+    expected.sort();
+    assert_eq!(linked, expected, "one Linked per sub-thread, each its own");
+}
+
+#[gpui::test]
+async fn sub_thread_reporter_parent_and_unlinked_threads_report_no_status(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let lone = open_conversation(&mut rig, "Lone", "lone answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    let (heard, _subscription) = record_activity(&card(&rig), &mut rig.cx);
+
+    send_text(&mut rig, &parent.view, "parent again", "ok");
+    send_text(&mut rig, &lone.view, "lone again", "ok");
+
+    let statuses: Vec<_> = summary(&heard)
+        .into_iter()
+        .filter(|(kind, _, _)| *kind == "running" || *kind == "idle")
+        .collect();
+    assert!(
+        statuses.is_empty(),
+        "neither a parent nor an unlinked thread is a sub-thread: {statuses:?}"
+    );
+}
+
+// ── Wrongly split ──────────────────────────────────────────────────────────
+
+#[gpui::test]
+async fn sub_thread_reporter_one_link_is_one_linked_across_notifies(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    let (heard, _subscription) = record_activity(&card(&rig), &mut rig.cx);
+
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    for _ in 0..3 {
+        rig.cx
+            .update(|_, cx| ThreadOrganizationStore::global(cx).update(cx, |_, cx| cx.notify()));
+        rig.cx.run_until_parked();
+    }
+
+    assert_eq!(
+        of_kind(&heard, "linked"),
+        vec![(parent.session.to_string(), child.session.to_string())],
+        "reported once"
+    );
+}
+
+#[gpui::test]
+async fn sub_thread_reporter_one_turn_is_one_running_then_one_idle(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    let (heard, _subscription) = record_activity(&card(&rig), &mut rig.cx);
+
+    let finish = rig.connection.defer_next_prompt_response();
+    set_composer(&child.view, "a side question", &mut rig.cx);
+    child
+        .view
+        .update_in(&mut rig.cx, |view, window, cx| view.send(window, cx));
+    rig.cx.run_until_parked();
+    assert_eq!(
+        child.thread.read_with(&rig.cx, |thread, _| thread.status()),
+        ThreadStatus::Generating,
+        "precondition: the sub-thread is generating"
+    );
+    let pair = (parent.session.to_string(), child.session.to_string());
+    assert_eq!(
+        of_kind(&heard, "running"),
+        vec![pair.clone()],
+        "Running once"
+    );
+    assert!(
+        of_kind(&heard, "idle").is_empty(),
+        "not Idle while generating"
+    );
+
+    finish
+        .send(Ok(acp_v1::PromptResponse::new(acp_v1::StopReason::EndTurn)))
+        .expect("the turn is still waiting");
+    rig.cx.run_until_parked();
+
+    assert_eq!(of_kind(&heard, "running"), vec![pair.clone()], "still once");
+    assert_eq!(
+        of_kind(&heard, "idle"),
+        vec![pair],
+        "Idle once the turn ends"
+    );
+}
+
+// ── Links that cannot be reported ──────────────────────────────────────────
+
+#[gpui::test]
+async fn sub_thread_reporter_dangling_link_is_not_reported(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    delete_thread(parent.thread_id, &mut rig.cx);
+
+    // A card built after the parent is gone reads the dangling link at creation.
+    let fresh = rig.cx.update(|_, cx| cx.new(|cx| TasksCard::new(cx)));
+    let (heard, _subscription) = record_activity(&fresh, &mut rig.cx);
+    rig.cx.run_until_parked();
+
+    let about_child: Vec<_> = summary(&heard)
+        .into_iter()
+        .filter(|(_, _, sub)| *sub == child.session.to_string())
+        .collect();
+    assert!(
+        about_child.is_empty(),
+        "no item is made up for a link whose parent has no session: {about_child:?}"
+    );
+}
+
+#[gpui::test]
+async fn sub_thread_reporter_without_the_organization_store_reports_nothing(
+    cx: &mut TestAppContext,
+) {
+    init_without_organization(cx);
+    let mut rig = rig(cx).await;
+    let (heard, _subscription) = record_activity(&card(&rig), &mut rig.cx);
+    let only = open_conversation(&mut rig, "Only", "an answer");
+    send_text(&mut rig, &only.view, "again", "ok");
+
+    assert!(
+        summary(&heard).is_empty(),
+        "nothing to report, and no panic"
+    );
+}
+
+// ── Benign: existing links, the record, the title ──────────────────────────
+
+#[gpui::test]
+async fn sub_thread_reporter_reports_existing_links_at_creation(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+
+    // A link made before this card existed (another window, or before this story shipped).
+    let fresh = rig.cx.update(|_, cx| cx.new(|cx| TasksCard::new(cx)));
+    let (heard, _subscription) = record_activity(&fresh, &mut rig.cx);
+    rig.cx.run_until_parked();
+
+    assert_eq!(
+        of_kind(&heard, "linked"),
+        vec![(parent.session.to_string(), child.session.to_string())],
+        "the existing link is reported once, to a subscriber registered after creation"
+    );
+}
+
+#[gpui::test]
+async fn sub_thread_reporter_linked_record_carries_the_sub_thread_title_and_project(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    let (heard, _subscription) = record_activity(&card(&rig), &mut rig.cx);
+
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+
+    let heard = heard.borrow();
+    let linked = heard
+        .iter()
+        .find_map(|activity| match activity {
+            SubThreadActivity::Linked(record) => Some(record.clone()),
+            _ => None,
+        })
+        .expect("the link is reported");
+    assert_eq!(linked.parent_session, parent.session);
+    assert_eq!(linked.sub_session, child.session);
+    assert_eq!(
+        linked.title.as_ref(),
+        "Child",
+        "the sub-thread's title, not the parent's"
+    );
+    assert_eq!(linked.project.as_ref(), "project");
+    assert!(linked.at_ms > 0, "stamped with the time it was heard");
+}
+
+#[gpui::test]
+async fn sub_thread_reporter_retitle_of_a_sub_thread_is_reported(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    let (heard, _subscription) = record_activity(&card(&rig), &mut rig.cx);
+
+    // The parent's own title change is no sub-thread's.
+    set_title(&parent.thread, "Parent renamed", &mut rig.cx);
+    set_title(&child.thread, "Child renamed", &mut rig.cx);
+
+    let retitled: Vec<(String, String)> = heard
+        .borrow()
+        .iter()
+        .filter_map(|activity| match activity {
+            SubThreadActivity::Retitled(record) => {
+                Some((record.sub_session.to_string(), record.title.to_string()))
+            }
+            _ => None,
+        })
+        .collect();
+    assert_eq!(
+        retitled,
+        vec![(child.session.to_string(), "Child renamed".to_string())]
+    );
+}
diff --git a/crates/agent_ui/src/sub_thread_send.rs b/crates/agent_ui/src/sub_thread_send.rs
new file mode 100644
index 0000000..772ef58
--- /dev/null
+++ b/crates/agent_ui/src/sub_thread_send.rs
@@ -0,0 +1,247 @@
+//! Story 040 — Send to Parent (design.md, SendToParentControl): a sub-thread's last reply, or a
+//! summary it is asked for, appended to its parent's composer and never sent.
+
+use acp_thread::{AcpThread, AgentThreadEntry, ThreadStatus};
+use agent_client_protocol::v2::schema as acp_v2;
+use gpui::{App, SharedString};
+
+use crate::conversation_view::ThreadView;
+use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore};
+use crate::thread_organization::ThreadOrganizationStore;
+
+/// The request Summarize and Send submits as a visible turn of the sub-thread (R3.1).
+pub(crate) const SUMMARY_REQUEST: &str = "Summarize this sub-thread for its parent thread: \
+the question it explored, what was found, and any decision or open item. \
+Reply with the summary only.";
+
+/// The reply is cut here, in characters, so one giant reply cannot stall the parent's editor
+/// (R4.5); the header is never cut and does not count.
+const REPLY_CAP_CHARS: usize = 200_000;
+
+const DELETED_TOOLTIP: &str = "The parent thread was deleted";
+/// 039's wording for the same wait, so the two features read as one (story v2).
+const WAIT_TOOLTIP: &str = "Wait for the turn to end";
+const NO_REPLY_TOOLTIP: &str = "No reply to send yet";
+const DELIVERING_TOOLTIP: &str = "Sending to the parent thread";
+
+#[derive(Default)]
+pub(crate) struct SendToParentState {
+    pub(crate) pending_summary: Option<PendingSummary>,
+    pub(crate) notice: Option<SharedString>,
+    /// A Send Last Reply still reaching its parent: a second pick would open the parent twice
+    /// while the router waits for a new window (R2.6, review 7.x L2).
+    pub(crate) delivering: bool,
+}
+
+/// A Summarize and Send waiting for its turn: only a `Stopped` of the turn whose latest user
+/// message is this request delivers (R3.6, R3.7).
+#[derive(Default)]
+pub(crate) struct PendingSummary {
+    /// No earlier turn's `Stopped` is the summary's.
+    pub(crate) activity_generation: u64,
+    /// The request's entry is at or after this index. On the reported-activity transport the
+    /// generation moves when the agent reports activity, so a turn the agent starts on its own
+    /// can take the generation above; the entry is what names the summary's turn (review 7.x M4).
+    pub(crate) entries_before: usize,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) enum ParentLink {
+    /// Not a sub-thread, or no organization store: the control is not drawn (R1.2).
+    None,
+    Live {
+        parent: ThreadId,
+        parent_session: acp_v2::SessionId,
+    },
+    /// Linked to a parent that has no metadata row, or one with no session (R4.4).
+    Deleted,
+}
+
+/// Each choice enabled, or disabled with the tooltip that says why (R1.4, R1.5).
+pub(crate) struct Availability {
+    pub(crate) last_reply: Result<(), SharedString>,
+    pub(crate) summarize: Result<(), SharedString>,
+}
+
+pub(crate) fn parent_link(thread_id: ThreadId, cx: &App) -> ParentLink {
+    let Some(organization) = ThreadOrganizationStore::try_global(cx) else {
+        return ParentLink::None;
+    };
+    let Some((parent, _)) = organization.read(cx).parent(thread_id) else {
+        return ParentLink::None;
+    };
+    let parent_session = ThreadMetadataStore::try_global(cx).and_then(|store| {
+        store
+            .read(cx)
+            .entry(parent)
+            .and_then(|entry| entry.session_id.clone())
+    });
+    match parent_session {
+        Some(parent_session) => ParentLink::Live {
+            parent,
+            parent_session,
+        },
+        None => ParentLink::Deleted,
+    }
+}
+
+/// Pure: reads the link, the thread and the state, and changes nothing. The checks run in the
+/// order design.md fixes, so the most lasting reason is the one shown.
+pub(crate) fn availability(
+    link: &ParentLink,
+    thread: &AcpThread,
+    state: &SendToParentState,
+) -> Availability {
+    let blocked: Option<&'static str> = if *link == ParentLink::Deleted {
+        Some(DELETED_TOOLTIP)
+    } else if state.delivering {
+        Some(DELIVERING_TOOLTIP)
+    } else if thread.status() == ThreadStatus::Generating || state.pending_summary.is_some() {
+        Some(WAIT_TOOLTIP)
+    } else {
+        None
+    };
+    if let Some(reason) = blocked {
+        return Availability {
+            last_reply: Err(reason.into()),
+            summarize: Err(reason.into()),
+        };
+    }
+    let has_reply = thread
+        .entries()
+        .iter()
+        .any(|entry| matches!(entry, AgentThreadEntry::AssistantMessage(_)));
+    Availability {
+        last_reply: if has_reply {
+            Ok(())
+        } else {
+            Err(NO_REPLY_TOOLTIP.into())
+        },
+        // A turn from before the load may still be running: its end would be taken for the
+        // summary's (R3.8). Send Last Reply stays available, as upstream's Copy does.
+        summarize: if thread.status() == ThreadStatus::Unconfirmed {
+            Err(WAIT_TOOLTIP.into())
+        } else {
+            Ok(())
+        },
+    }
+}
+
+/// The last assistant run of the thread, as upstream's Copy reads it (R2.1).
+pub(crate) fn last_reply_text(entries: &[AgentThreadEntry], cx: &App) -> Option<String> {
+    let last = entries
+        .iter()
+        .rposition(|entry| matches!(entry, AgentThreadEntry::AssistantMessage(_)))?;
+    ThreadView::get_agent_message_content(entries, last, cx).filter(|text| !text.trim().is_empty())
+}
+
+/// The block appended to the parent's composer: a header naming the sub-thread, a blank line,
+/// then the reply, cut on a `char` boundary at the cap with the count of what was left out.
+pub(crate) fn compose_block(sub_thread_title: &str, reply: &str) -> String {
+    let header = format!("From sub-thread \"{sub_thread_title}\":\n\n");
+    match reply.char_indices().nth(REPLY_CAP_CHARS) {
+        None => format!("{header}{reply}"),
+        Some((cut, _)) => {
+            let left_out = reply[cut..].chars().count();
+            format!(
+                "{header}{}\n[truncated: {left_out} more characters]",
+                &reply[..cut]
+            )
+        }
+    }
+}
+
+pub(crate) mod control;
+mod delivery;
+mod summary;
+pub(crate) use delivery::deliver_to_parent;
+
+impl ThreadView {
+    /// Send Last Reply (R2): the last reply, under a header naming this sub-thread, appended to
+    /// the parent's composer. A choice the control shows disabled does nothing here either.
+    pub(crate) fn send_last_reply(
+        &mut self,
+        window: &mut gpui::Window,
+        cx: &mut gpui::Context<Self>,
+    ) {
+        self.send_to_parent.notice = None;
+        cx.notify();
+        let link = parent_link(self.root_thread_id, cx);
+        let ParentLink::Live { parent_session, .. } = &link else {
+            return;
+        };
+        let thread = self.thread.read(cx);
+        if availability(&link, thread, &self.send_to_parent)
+            .last_reply
+            .is_err()
+        {
+            return;
+        }
+        let Some(reply) = last_reply_text(thread.entries(), cx) else {
+            return;
+        };
+        let title = thread
+            .title()
+            .unwrap_or_else(|| crate::DEFAULT_THREAD_TITLE.into());
+        let block = compose_block(&title, &reply);
+        // Cleared by `deliver_to_parent` on every way it ends.
+        self.send_to_parent.delivering = true;
+        deliver_to_parent(
+            parent_session.clone(),
+            block,
+            cx.entity().downgrade(),
+            window,
+            cx,
+        );
+    }
+}
+
+#[cfg(test)]
+#[path = "sub_thread_send/helpers_tests.rs"]
+mod helpers_tests;
+
+#[cfg(test)]
+#[path = "sub_thread_send/send_to_parent_control_tests.rs"]
+mod send_to_parent_control_tests;
+
+#[cfg(test)]
+#[path = "sub_thread_send/deliver_to_parent_tests.rs"]
+mod deliver_to_parent_tests;
+
+#[cfg(test)]
+// The frozen story-040 test clones in a helper; the lint is silenced here, not edited there.
+#[allow(clippy::redundant_clone)]
+#[path = "sub_thread_send/summarize_and_send_tests.rs"]
+mod summarize_and_send_tests;
+
+#[cfg(test)]
+#[path = "sub_thread_send/receipt_test_support.rs"]
+mod receipt_test_support;
+
+#[cfg(test)]
+#[path = "sub_thread_send/summary_binding_tests.rs"]
+mod summary_binding_tests;
+
+#[cfg(test)]
+#[path = "sub_thread_send/unconfirmed_availability_tests.rs"]
+mod unconfirmed_availability_tests;
+
+#[cfg(test)]
+#[path = "sub_thread_send/delivering_availability_tests.rs"]
+mod delivering_availability_tests;
+
+#[cfg(test)]
+#[path = "sub_thread_send/in_flight_guard_tests.rs"]
+mod in_flight_guard_tests;
+
+#[cfg(test)]
+#[path = "sub_thread_send/summary_removed_entries_tests.rs"]
+mod summary_removed_entries_tests;
+
+#[cfg(test)]
+#[path = "sub_thread_send/summary_rejected_submit_tests.rs"]
+mod summary_rejected_submit_tests;
+
+#[cfg(test)]
+#[path = "sub_thread_send/choice_label_tests.rs"]
+mod choice_label_tests;
diff --git a/crates/agent_ui/src/sub_thread_send/choice_label_tests.rs b/crates/agent_ui/src/sub_thread_send/choice_label_tests.rs
new file mode 100644
index 0000000..0dc50f2
--- /dev/null
+++ b/crates/agent_ui/src/sub_thread_send/choice_label_tests.rs
@@ -0,0 +1,68 @@
+// Story 040, task 12.3 (validation fix round 1, audit claim Q12): the two Send to Parent choices
+// carry a screen-reader label each, enabled or not, and a disabled one names why (R1.6). GPUI's
+// test windows never activate their accessibility tree, so the labels of the last render are read
+// from `control::RENDERED_CHOICE_LABELS`, the card's pattern (`tasks_card::RENDERED_ARIA_LABELS`).
+
+use gpui::{Modifiers, TestAppContext, VisualTestContext};
+
+use super::control::RENDERED_CHOICE_LABELS;
+use crate::sub_thread_test_support::*;
+
+const LAST_REPLY_LABEL: &str =
+    "Send Last Reply: put this sub-thread's last reply in the parent thread's composer";
+const SUMMARIZE_LABEL: &str = "Summarize and Send: ask this sub-thread for a summary, then put it in the parent thread's composer";
+
+fn open_menu(cx: &mut VisualTestContext) -> Vec<String> {
+    RENDERED_CHOICE_LABELS.with_borrow_mut(Vec::clear);
+    let bounds = cx
+        .debug_bounds("send-to-parent")
+        .unwrap_or_else(|| panic!("the control is painted"));
+    cx.simulate_click(bounds.center(), Modifiers::none());
+    cx.run_until_parked();
+    draw(cx);
+    RENDERED_CHOICE_LABELS.with_borrow(|labels| labels.iter().map(ToString::to_string).collect())
+}
+
+#[gpui::test]
+async fn send_to_parent_control_choices_carry_accessible_labels(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    draw(&mut rig.cx);
+
+    let labels = open_menu(&mut rig.cx);
+
+    assert!(
+        labels.iter().any(|label| label == LAST_REPLY_LABEL),
+        "Send Last Reply is named: {labels:?}"
+    );
+    assert!(
+        labels.iter().any(|label| label == SUMMARIZE_LABEL),
+        "Summarize and Send is named: {labels:?}"
+    );
+}
+
+#[gpui::test]
+async fn send_to_parent_control_disabled_choices_name_why(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    let _finish = rig.connection.defer_next_prompt_response();
+    set_composer(&child.view, "still thinking", &mut rig.cx);
+    child
+        .view
+        .update_in(&mut rig.cx, |view, window, cx| view.send(window, cx));
+    rig.cx.run_until_parked();
+    draw(&mut rig.cx);
+
+    let labels = open_menu(&mut rig.cx);
+
+    for name in ["Send Last Reply", "Summarize and Send"] {
+        let expected = format!("{name}, unavailable: Wait for the turn to end");
+        assert!(labels.contains(&expected), "{expected:?} among {labels:?}");
+    }
+}
diff --git a/crates/agent_ui/src/sub_thread_send/control.rs b/crates/agent_ui/src/sub_thread_send/control.rs
new file mode 100644
index 0000000..1918051
--- /dev/null
+++ b/crates/agent_ui/src/sub_thread_send/control.rs
@@ -0,0 +1,147 @@
+//! Story 040, the footer control: a "Send to Parent" menu button on a sub-thread's composer, with
+//! "Send Last Reply" and "Summarize and Send" (R1).
+
+use gpui::{AnyElement, Context, Entity, Role, SharedString, Window};
+use ui::{ContextMenu, PopoverMenu, Tooltip, prelude::*};
+
+use super::{ParentLink, availability, parent_link};
+use crate::conversation_view::ThreadView;
+
+// GPUI's test windows never activate their accessibility tree, so tests read the choices'
+// screen-reader labels of the last render from here (the card's `RENDERED_ARIA_LABELS`).
+#[cfg(test)]
+thread_local! {
+    pub(crate) static RENDERED_CHOICE_LABELS: std::cell::RefCell<Vec<SharedString>> =
+        std::cell::RefCell::default();
+}
+
+/// A choice's screen-reader label, recorded in test builds (see `RENDERED_CHOICE_LABELS`).
+fn choice_aria_label(label: &str, state: &Result<(), SharedString>, purpose: &str) -> SharedString {
+    let aria_label = SharedString::from(match state {
+        Ok(()) => format!("{label}: {purpose}"),
+        Err(reason) => format!("{label}, unavailable: {reason}"),
+    });
+    #[cfg(test)]
+    RENDERED_CHOICE_LABELS.with_borrow_mut(|labels| labels.push(aria_label.clone()));
+    aria_label
+}
+
+/// One choice of the menu: a selectable entry when enabled, otherwise an inert row whose tooltip
+/// says why (R1.4, R1.5). Both carry an accessible label (R1.6): a div with no role never reaches
+/// the accessibility tree, and `ContextMenu` labels only its own string entries, so the content
+/// is given the `Label` role.
+fn choice(
+    menu: ContextMenu,
+    selector: &'static str,
+    label: &'static str,
+    purpose: &'static str,
+    state: Result<(), SharedString>,
+    view: Entity<ThreadView>,
+    run: fn(&mut ThreadView, &mut Window, &mut Context<ThreadView>),
+) -> ContextMenu {
+    match state {
+        Ok(()) => menu.custom_entry(
+            move |_, _| {
+                div()
+                    .id(selector)
+                    .role(Role::Label)
+                    .aria_label(choice_aria_label(label, &Ok(()), purpose))
+                    .debug_selector(move || selector.into())
+                    .tooltip(Tooltip::text(purpose))
+                    .child(Label::new(label))
+                    .into_any_element()
+            },
+            move |window, cx| view.update(cx, |view, cx| run(view, window, cx)),
+        ),
+        Err(reason) => menu.custom_row(move |_, _| {
+            let state = Err(reason.clone());
+            div()
+                .id(selector)
+                .role(Role::Label)
+                .aria_label(choice_aria_label(label, &state, purpose))
+                .debug_selector(move || selector.into())
+                .tooltip(Tooltip::text(reason.clone()))
+                .child(Label::new(label).color(Color::Disabled))
+                .into_any_element()
+        }),
+    }
+}
+
+impl ThreadView {
+    /// The control under a sub-thread's composer; nothing for a thread that is not a sub-thread
+    /// or where no organization store exists (R1.1, R1.2).
+    pub(crate) fn render_send_to_parent(
+        &mut self,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<AnyElement> {
+        let link = parent_link(self.root_thread_id, cx);
+        if link == ParentLink::None {
+            return None;
+        }
+        let available = availability(&link, self.thread.read(cx), &self.send_to_parent);
+        let view = cx.entity();
+        let menu = PopoverMenu::new("send-to-parent-menu")
+            .trigger_with_tooltip(
+                Button::new("send-to-parent", "Send to Parent")
+                    .label_size(LabelSize::Small)
+                    .track_focus(&self.send_to_parent_focus_handle)
+                    .aria_label("Send to Parent: send this sub-thread's reply to its parent"),
+                Tooltip::text("Send this sub-thread's reply to its parent thread"),
+            )
+            .menu(move |window, cx| {
+                let view = view.clone();
+                let last_reply = available.last_reply.clone();
+                let summarize = available.summarize.clone();
+                Some(ContextMenu::build(window, cx, move |menu, _, _| {
+                    let menu = choice(
+                        menu,
+                        "send-to-parent-last-reply",
+                        "Send Last Reply",
+                        "put this sub-thread's last reply in the parent thread's composer",
+                        last_reply,
+                        view.clone(),
+                        ThreadView::send_last_reply,
+                    );
+                    choice(
+                        menu,
+                        "send-to-parent-summarize",
+                        "Summarize and Send",
+                        "ask this sub-thread for a summary, then put it in the parent thread's composer",
+                        summarize,
+                        view,
+                        ThreadView::summarize_and_send,
+                    )
+                }))
+            });
+        let notice = self.send_to_parent.notice.clone().map(|notice| {
+            div()
+                .debug_selector(|| "send-to-parent-notice".into())
+                .child(
+                    Label::new(notice)
+                        .size(LabelSize::XSmall)
+                        .color(Color::Muted),
+                )
+        });
+        Some(
+            v_flex()
+                .gap_0p5()
+                .child(
+                    // Sized to the button, so the selector's centre is on it.
+                    h_flex().child(
+                        div()
+                            .flex_none()
+                            .debug_selector(|| "send-to-parent".into())
+                            .child(menu),
+                    ),
+                )
+                .children(notice)
+                .into_any_element(),
+        )
+    }
+
+    #[cfg(test)]
+    pub(crate) fn send_to_parent_focus_handle(&self) -> &gpui::FocusHandle {
+        &self.send_to_parent_focus_handle
+    }
+}
diff --git a/crates/agent_ui/src/sub_thread_send/deliver_to_parent_tests.rs b/crates/agent_ui/src/sub_thread_send/deliver_to_parent_tests.rs
new file mode 100644
index 0000000..77594f5
--- /dev/null
+++ b/crates/agent_ui/src/sub_thread_send/deliver_to_parent_tests.rs
@@ -0,0 +1,406 @@
+// Story 040, task 7.1 — Send Last Reply and `deliver_to_parent`: the block lands in the parent's
+// composer, unsent, after any draft, wherever the parent is (R2.1–R2.5, R4.1–R4.3, R8.1; Q4,
+// Q11). Module path: `sub_thread_send::deliver_to_parent_tests`. Wired in
+// `src/sub_thread_send.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "sub_thread_send/deliver_to_parent_tests.rs"]
+//     mod deliver_to_parent_tests;
+//
+// Validation filter: `cargo test -p agent_ui -- deliver_to_parent`. Fixtures:
+// `crate::sub_thread_test_support`; log capture: `crate::thread_organization::test_helpers`
+// (pub(crate)), the one logger this test binary shares — a second `set_logger` would panic.
+//
+// Contract (design.md §deliver_to_parent, §SendToParentControl):
+//   - `ThreadView::send_last_reply(&mut self, window, cx)` → `compose_block(title,
+//     last_reply_text)` → `deliver_to_parent(parent_session, block, origin, window, cx)`;
+//   - an open view of the parent in ANY window is used first; else `open_agent_session`, and the
+//     view is looked up again after the await; `Err(e)` → notice "Could not open the parent
+//     thread: {e:#}" on the sub-thread, a warning with both ids, nothing inserted;
+//   - `append_to_composer`: cursor to the end, `"\n\n"` first when the buffer is not empty, then
+//     the block; the editor is focused; nothing is sent; the parent's window and panel come
+//     forward;
+//   - every later exit that inserts nothing (no view after the await, the origin view gone, a
+//     window activation `Err`) warns `event=deliver_lost` and sets the notice when it can;
+//   - `zeo_sub_thread event=delivered|open_failed|deliver_lost` with session ids and `chars=N`,
+//     never text.
+// `deliver_to_parent` is assumed to take `&mut Window` and `&mut App` (its origin is a
+// `WeakEntity`, so it is not a `ThreadView` method); the lost-origin test calls it directly.
+//
+// Hostile halves first:
+//   - a draft whose cursor sits at its START keeps its text and gets the block AFTER it — the
+//     GOTCHA of `MessageEditor::insert_text`, which inserts at the cursor;
+//   - nothing is sent: neither thread gains a user message, neither starts a turn;
+//   - the parent open in ANOTHER window is the one written to, not a new view in this one;
+//   - an open failure inserts nothing anywhere and says why; so does an origin view gone before
+//     the parent opened (`deliver_lost`);
+//   - then the empty composer, the focus and the cursor.
+
+use std::time::Duration;
+
+use acp_thread::{StubAgentConnection, ThreadStatus};
+use gpui::{Focusable as _, TestAppContext, VisualTestContext, WeakEntity};
+use serde_json::json;
+
+use super::deliver_to_parent;
+use crate::sub_thread_test_support::*;
+use crate::thread_metadata_store::{ThreadId, ThreadMetadata, ThreadMetadataStore};
+use crate::thread_organization::test_helpers::{all_lines_lowercased, capture_logs};
+
+fn block(title: &str, reply: &str) -> String {
+    format!("From sub-thread \"{title}\":\n\n{reply}")
+}
+
+fn send_last_reply(conv: &Conv, cx: &mut VisualTestContext) {
+    conv.view
+        .update_in(cx, |view, window, cx| view.send_last_reply(window, cx));
+    cx.run_until_parked();
+}
+
+fn idle(conv: &Conv, cx: &VisualTestContext) -> bool {
+    conv.thread
+        .read_with(cx, |thread, _| thread.status() == ThreadStatus::Idle)
+}
+
+/// A parent known to the metadata store under `folder`, run by `crate::Agent::Stub`.
+fn saved_parent(
+    folder: &str,
+    cx: &mut VisualTestContext,
+) -> (ThreadId, agent_client_protocol::v2::schema::SessionId) {
+    let thread_id = ThreadId::new();
+    let session = agent_client_protocol::v2::schema::SessionId::new("closed-parent-session");
+    let now = chrono::Utc::now();
+    let folders = [std::path::Path::new(folder)];
+    cx.update(|_, cx| {
+        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
+            store.save(
+                ThreadMetadata {
+                    thread_id,
+                    session_id: Some(session.clone()),
+                    agent_id: crate::Agent::Stub.id(),
+                    title: Some("Closed parent".into()),
+                    title_override: None,
+                    updated_at: now,
+                    created_at: Some(now),
+                    interacted_at: None,
+                    worktree_paths: project::WorktreePaths::from_folder_paths(
+                        &workspace::PathList::new(&folders),
+                    ),
+                    remote_connection: None,
+                    archived: false,
+                },
+                cx,
+            )
+        })
+    });
+    cx.run_until_parked();
+    (thread_id, session)
+}
+
+fn shown_session(rig: &Rig) -> Option<String> {
+    rig.panel.read_with(&rig.cx, |panel, cx| {
+        panel
+            .active_agent_thread(cx)
+            .map(|thread| thread.read(cx).session_id().to_string())
+    })
+}
+
+// ── Hostile: the draft, and nothing sent (R2.2, R2.3, R2.5) ────────────────
+
+#[gpui::test]
+async fn deliver_to_parent_appends_after_a_draft_whose_cursor_is_at_its_start(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    set_composer(&parent.view, "my draft", &mut rig.cx);
+    let parent_editor = parent
+        .view
+        .read_with(&rig.cx, |view, _| view.message_editor.clone());
+    parent_editor.update_in(&mut rig.cx, |editor, window, cx| {
+        editor.set_cursor_offset(0, window, cx)
+    });
+    let sent_parent = user_message_count(&parent.thread, &rig.cx);
+    let sent_child = user_message_count(&child.thread, &rig.cx);
+
+    send_last_reply(&child, &mut rig.cx);
+
+    assert_eq!(
+        composer_text(&parent.view, &rig.cx),
+        format!("my draft\n\n{}", block("Child", "child answer")),
+        "the draft is kept and the block follows it after a blank line"
+    );
+    assert_eq!(
+        user_message_count(&parent.thread, &rig.cx),
+        sent_parent,
+        "nothing sent to the parent"
+    );
+    assert_eq!(
+        user_message_count(&child.thread, &rig.cx),
+        sent_child,
+        "no turn in the sub-thread"
+    );
+    assert!(
+        idle(&parent, &rig.cx) && idle(&child, &rig.cx),
+        "no agent turn starts"
+    );
+}
+
+// ── Hostile: the parent open in another window (R4.1) ──────────────────────
+
+#[gpui::test]
+async fn deliver_to_parent_writes_to_the_parent_open_in_another_window(cx: &mut TestAppContext) {
+    init(cx);
+    let mut a = rig(cx).await;
+    a.fs.insert_tree("/side", json!({ "file.txt": "" })).await;
+    let mut b = window_over(&a.fs.clone(), "/side", cx).await;
+    b.connection = a.connection.clone();
+    let parent = open_conversation(&mut a, "Parent", "parent answer");
+    let child = open_conversation(&mut b, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut b.cx);
+    let views_in_b = b
+        .panel
+        .read_with(&b.cx, |panel, _| panel.conversation_views().len());
+    b.cx.update(|window, _| window.activate_window());
+    b.cx.run_until_parked();
+
+    send_last_reply(&child, &mut b.cx);
+
+    assert_eq!(
+        composer_text(&parent.view, &a.cx),
+        block("Child", "child answer"),
+        "the open parent, in its own window, gets the block"
+    );
+    assert_eq!(
+        b.panel
+            .read_with(&b.cx, |panel, _| panel.conversation_views().len()),
+        views_in_b,
+        "no second view of the parent is opened here"
+    );
+    assert_eq!(
+        composer_text(&child.view, &b.cx),
+        "",
+        "the sub-thread's composer is untouched"
+    );
+    assert_eq!(
+        cx.update(|cx| cx.active_window()),
+        Some(a.window.into()),
+        "the parent's window comes forward"
+    );
+}
+
+// ── Hostile: open failure (R4.3, Q4) ───────────────────────────────────────
+
+#[gpui::test]
+async fn deliver_to_parent_open_failure_inserts_nothing_and_says_why(cx: &mut TestAppContext) {
+    init(cx);
+    let mut a = rig(cx).await;
+    a.fs.insert_tree("/side", json!({ "file.txt": "" })).await;
+    let mut b = window_over(&a.fs.clone(), "/side", cx).await;
+    b.connection = a.connection.clone();
+    let parent = open_conversation(&mut a, "Parent", "parent answer");
+    let child = open_conversation(&mut b, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut b.cx);
+    // The parent's window keeps its folder but loses its agent panel (story 024's failure recipe).
+    let panel_a = a.panel.clone();
+    a.workspace.update_in(&mut a.cx, |workspace, window, cx| {
+        workspace.remove_panel(&panel_a, window, cx)
+    });
+    drop(panel_a);
+    a.cx.run_until_parked();
+
+    capture_logs();
+    send_last_reply(&child, &mut b.cx);
+    // The router waits for an agent panel up to its 60 s safety net before it reports.
+    b.cx.executor().advance_clock(Duration::from_secs(61));
+    b.cx.run_until_parked();
+
+    let notice = notice_of(&child.view, &b.cx).expect("the sub-thread says why");
+    assert!(
+        notice.starts_with("Could not open the parent thread: "),
+        "the notice names the failure: {notice:?}"
+    );
+    assert_eq!(
+        composer_text(&child.view, &b.cx),
+        "",
+        "nothing lands in the sub-thread"
+    );
+    let lines = all_lines_lowercased();
+    let parent_id = parent.session.to_string().to_lowercase();
+    let child_id = child.session.to_string().to_lowercase();
+    assert!(
+        lines.iter().any(|line| line.contains("zeo_sub_thread")
+            && line.contains("open_failed")
+            && line.contains(&parent_id)
+            && line.contains(&child_id)),
+        "one event names both sessions: {lines:?}"
+    );
+    assert!(
+        lines.iter().all(|line| !line.contains("child answer")),
+        "no log line carries the reply"
+    );
+}
+
+// ── Hostile: the origin view is gone by the time the parent opens (deliver_lost) ──
+
+#[gpui::test]
+async fn deliver_to_parent_with_its_origin_gone_inserts_nothing_and_warns(cx: &mut TestAppContext) {
+    init(cx);
+    crate::test_support::set_stub_agent_connection(
+        StubAgentConnection::new().with_supports_load_session(true),
+    );
+    let mut rig = rig(cx).await;
+    let (_parent_id, parent_session) = saved_parent("/project", &mut rig.cx);
+    let block_text = block("Gone", "lost answer");
+    capture_logs();
+
+    // The sub-thread's view closed between the choice and the parent's opening.
+    let session = parent_session.clone();
+    rig.cx.update(|window, cx| {
+        deliver_to_parent(
+            session,
+            block_text.clone(),
+            WeakEntity::new_invalid(),
+            window,
+            cx,
+        )
+    });
+    rig.cx.run_until_parked();
+
+    let composers: Vec<String> = rig.panel.read_with(&rig.cx, |panel, cx| {
+        panel
+            .conversation_views()
+            .iter()
+            .filter_map(|conversation| conversation.read(cx).root_thread_view())
+            .map(|view| view.read(cx).message_editor.read(cx).text(cx))
+            .collect()
+    });
+    assert!(
+        composers.iter().all(|text| !text.contains("lost answer")),
+        "no composer receives the block: {composers:?}"
+    );
+    let lines = all_lines_lowercased();
+    assert!(
+        lines.iter().any(|line| line.contains("zeo_sub_thread")
+            && line.contains("deliver_lost")
+            && line.contains(&parent_session.to_string().to_lowercase())),
+        "the lost delivery is a warning naming the parent: {lines:?}"
+    );
+    assert!(
+        lines.iter().all(|line| !line.contains("lost answer")),
+        "never the text"
+    );
+}
+
+// ── Not open: opened, then inserted (R4.2) ─────────────────────────────────
+
+#[gpui::test]
+async fn deliver_to_parent_opens_a_closed_parent_then_inserts(cx: &mut TestAppContext) {
+    init(cx);
+    crate::test_support::set_stub_agent_connection(
+        StubAgentConnection::new().with_supports_load_session(true),
+    );
+    let mut rig = rig(cx).await;
+    // The panel loads the parent through its `Agent::Stub` connection, so that one must be the
+    // stub that supports loading (agent_panel.rs, test_open_thread_by_session_keeps_agent_and_folders).
+    rig.connection = crate::test_support::stub_agent_connection();
+    let loadable = rig.connection.clone();
+    rig.panel.update(&mut rig.cx, |panel, cx| {
+        panel.connection_store().update(cx, |store, cx| {
+            store.restart_connection(
+                crate::Agent::Stub,
+                std::rc::Rc::new(crate::test_support::StubAgentServer::new(loadable)),
+                cx,
+            );
+        });
+    });
+    rig.cx.run_until_parked();
+    let (parent_id, parent_session) = saved_parent("/project", &mut rig.cx);
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent_id, &mut rig.cx);
+    assert_ne!(
+        shown_session(&rig),
+        Some(parent_session.to_string()),
+        "precondition: the parent is not open"
+    );
+
+    send_last_reply(&child, &mut rig.cx);
+
+    assert_eq!(
+        shown_session(&rig),
+        Some(parent_session.to_string()),
+        "the parent is opened and brought forward"
+    );
+    let parent_view = rig
+        .panel
+        .read_with(&rig.cx, |panel, cx| panel.active_thread_view(cx))
+        .expect("the parent's view");
+    assert_eq!(
+        parent_view.read_with(&rig.cx, |view, cx| view.message_editor.read(cx).text(cx)),
+        block("Child", "child answer"),
+        "inserted once its composer exists"
+    );
+}
+
+// ── Benign: empty composer, focus, cursor, log (R2.1, R2.4, R8.1) ──────────
+
+#[gpui::test]
+async fn deliver_to_parent_fills_an_empty_composer_focused_with_the_cursor_at_the_end(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    capture_logs();
+
+    send_last_reply(&child, &mut rig.cx);
+
+    let expected = block("Child", "child answer");
+    assert_eq!(
+        composer_text(&parent.view, &rig.cx),
+        expected,
+        "exactly the block"
+    );
+    assert_eq!(
+        shown_session(&rig),
+        Some(parent.session.to_string()),
+        "the parent is in front"
+    );
+    let editor = parent
+        .view
+        .read_with(&rig.cx, |view, _| view.message_editor.clone());
+    assert!(
+        rig.cx.update(|window, cx| editor
+            .read(cx)
+            .focus_handle(cx)
+            .contains_focused(window, cx)),
+        "the parent's composer has focus"
+    );
+    editor.update_in(&mut rig.cx, |editor, window, cx| {
+        editor.insert_text("|", window, cx)
+    });
+    assert_eq!(
+        composer_text(&parent.view, &rig.cx),
+        format!("{expected}|"),
+        "the cursor is at the end of the inserted block"
+    );
+
+    let lines = all_lines_lowercased();
+    assert!(
+        lines.iter().any(|line| line.contains("zeo_sub_thread")
+            && line.contains("delivered")
+            && line.contains(&parent.session.to_string().to_lowercase())
+            && line.contains(&child.session.to_string().to_lowercase())
+            && line.contains("chars=")),
+        "the delivery is logged with ids and its size: {lines:?}"
+    );
+    assert!(
+        lines.iter().all(|line| !line.contains("child answer")),
+        "never the text"
+    );
+}
diff --git a/crates/agent_ui/src/sub_thread_send/delivering_availability_tests.rs b/crates/agent_ui/src/sub_thread_send/delivering_availability_tests.rs
new file mode 100644
index 0000000..8c746d0
--- /dev/null
+++ b/crates/agent_ui/src/sub_thread_send/delivering_availability_tests.rs
@@ -0,0 +1,190 @@
+// Story 040, task 11.3 (refine v4, review 7.x L2) — `availability` rows while a Send Last Reply
+// delivery is still reaching the parent (R2.6, R4.4; Q5). Module path:
+// `sub_thread_send::delivering_availability_tests`. Wired in `src/sub_thread_send.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "sub_thread_send/delivering_availability_tests.rs"]
+//     mod delivering_availability_tests;
+//
+// Validation filter: `cargo test -p agent_ui -- sub_thread_send`. Fixtures:
+// `crate::sub_thread_test_support` and `super::receipt_test_support` (task 11.2's setup-only
+// helpers, used for the Unconfirmed row).
+//
+// Contract (design.md v4 §SendToParentControl): `SendToParentState { pending_summary, notice,
+// delivering: bool }`, `Default`; `availability(link, thread, state)` disables BOTH choices with
+// "Sending to the parent thread" while `delivering`, after the Deleted check and before the
+// Unconfirmed and no-reply checks. The state is built the way design.md names it,
+// `SendToParentState { delivering: true, ..Default::default() }`.
+//
+// Not pinned here: whether `delivering` or a running turn / pending summary speaks first when
+// both hold. design.md v4 lists generating/pending before delivering; tasks.md 11.3 ToDo 2 puts
+// delivering before the generating check. Every row below holds under either order.
+//
+// Hostile halves first:
+//   - wrongly ALLOWED: a sub-thread with a reply, idle, offers both choices while its delivery
+//     is in flight (L2: a second pick opens a second window and appends a second block);
+//   - wrongly ALLOWED: Unconfirmed keeps Send Last Reply available (R3.8), and must not let it
+//     through while delivering; nor may "No reply to send yet" replace the in-flight reason;
+//   - wrongly DISABLED / wrong reason: a deleted parent still says it was deleted;
+//   - the converse "not delivering offers both" is helpers_tests.rs's
+//     `sub_thread_send_availability_idle_with_a_reply_enables_both`, unchanged.
+
+use acp_thread::{AcpThread, ThreadStatus};
+use agent_client_protocol::v1::schema as acp_v1;
+use gpui::{Entity, SharedString, TestAppContext, VisualTestContext};
+
+use super::receipt_test_support::open_unconfirmed_receipt_conversation;
+use super::{Availability, ParentLink, SendToParentState, availability};
+use crate::sub_thread_test_support::*;
+
+const SENDING: &str = "Sending to the parent thread";
+const DELETED: &str = "The parent thread was deleted";
+
+/// The state of a sub-thread whose Send Last Reply is still reaching its parent.
+fn in_flight_state() -> SendToParentState {
+    SendToParentState {
+        delivering: true,
+        ..Default::default()
+    }
+}
+
+fn choices_while(
+    link: &ParentLink,
+    thread: &Entity<AcpThread>,
+    state: &SendToParentState,
+    cx: &VisualTestContext,
+) -> (Result<(), String>, Result<(), String>) {
+    thread.read_with(cx, |thread, _| {
+        let Availability {
+            last_reply,
+            summarize,
+        } = availability(link, thread, state);
+        (
+            last_reply.map_err(|tooltip: SharedString| tooltip.to_string()),
+            summarize.map_err(|tooltip: SharedString| tooltip.to_string()),
+        )
+    })
+}
+
+fn live_link_to(parent: &Conv) -> ParentLink {
+    ParentLink::Live {
+        parent: parent.thread_id,
+        parent_session: parent.session.clone(),
+    }
+}
+
+// ── Hostile: an idle sub-thread with a reply, mid-delivery (R2.6) ──────────
+
+#[gpui::test]
+async fn sub_thread_send_availability_delivering_disables_both_choices(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    assert_eq!(
+        child.thread.read_with(&rig.cx, |thread, _| thread.status()),
+        ThreadStatus::Idle,
+        "precondition: no turn running, so only the delivery can disable the choices"
+    );
+
+    let (last_reply, summarize) = choices_while(
+        &live_link_to(&parent),
+        &child.thread,
+        &in_flight_state(),
+        &rig.cx,
+    );
+    assert_eq!(
+        last_reply,
+        Err(SENDING.to_string()),
+        "a second Send Last Reply waits for the first to reach the parent"
+    );
+    assert_eq!(
+        summarize,
+        Err(SENDING.to_string()),
+        "Summarize and Send waits too: both choices write to the same composer"
+    );
+}
+
+// ── Hostile: Unconfirmed lets Send Last Reply through, delivering must not ─
+
+#[gpui::test]
+async fn sub_thread_send_availability_delivering_while_unconfirmed_disables_both(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let (child, _connection) = open_unconfirmed_receipt_conversation(&mut rig, "a loaded reply");
+    assert_eq!(
+        child.thread.read_with(&rig.cx, |thread, _| thread.status()),
+        ThreadStatus::Unconfirmed,
+        "precondition"
+    );
+
+    let (last_reply, summarize) = choices_while(
+        &live_link_to(&parent),
+        &child.thread,
+        &in_flight_state(),
+        &rig.cx,
+    );
+    assert_eq!(
+        last_reply,
+        Err(SENDING.to_string()),
+        "the in-flight delivery is checked before Unconfirmed, which leaves Send Last Reply on"
+    );
+    assert_eq!(summarize, Err(SENDING.to_string()));
+}
+
+// ── Hostile: the in-flight reason is not replaced by "No reply to send yet" ─
+
+#[gpui::test]
+async fn sub_thread_send_availability_delivering_wins_over_no_reply(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "");
+    // The stub keeps a turn with no updates open until told how it ended (helpers_tests.rs).
+    rig.connection
+        .end_turn(child.session.clone(), acp_v1::StopReason::EndTurn);
+    rig.cx.run_until_parked();
+    assert_eq!(
+        child.thread.read_with(&rig.cx, |thread, _| thread.status()),
+        ThreadStatus::Idle,
+        "precondition: the turn ended with no reply, so no wait applies"
+    );
+
+    let (last_reply, summarize) = choices_while(
+        &live_link_to(&parent),
+        &child.thread,
+        &in_flight_state(),
+        &rig.cx,
+    );
+    assert_eq!(last_reply, Err(SENDING.to_string()));
+    assert_eq!(
+        summarize,
+        Err(SENDING.to_string()),
+        "no reply disables Send Last Reply only; delivering disables both"
+    );
+}
+
+// ── Order: a deleted parent still wins (R4.4) ──────────────────────────────
+
+#[gpui::test]
+async fn sub_thread_send_availability_deleted_parent_wins_over_delivering(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let child = open_conversation(&mut rig, "Child", "child answer");
+
+    let (last_reply, summarize) = choices_while(
+        &ParentLink::Deleted,
+        &child.thread,
+        &in_flight_state(),
+        &rig.cx,
+    );
+    assert_eq!(
+        last_reply,
+        Err(DELETED.to_string()),
+        "the more lasting reason is the one shown"
+    );
+    assert_eq!(summarize, Err(DELETED.to_string()));
+}
diff --git a/crates/agent_ui/src/sub_thread_send/delivery.rs b/crates/agent_ui/src/sub_thread_send/delivery.rs
new file mode 100644
index 0000000..8d815fb
--- /dev/null
+++ b/crates/agent_ui/src/sub_thread_send/delivery.rs
@@ -0,0 +1,199 @@
+//! Story 040, delivery: the block lands in the parent's composer, after any draft, focused and
+//! never sent, wherever the parent is open — or once the router has opened it (design.md,
+//! deliver_to_parent).
+
+use agent_client_protocol::v2::schema as acp_v2;
+use anyhow::Context as _;
+use gpui::{AnyWindowHandle, App, AsyncApp, Entity, Focusable as _, WeakEntity, Window};
+use workspace::{MultiWorkspace, Workspace};
+
+use crate::AgentPanel;
+use crate::agent_session_router::{OpenAgentSessionOptions, open_agent_session};
+use crate::conversation_view::ThreadView;
+
+/// A loaded conversation of `parent_session` in any window: the window, its workspace and the
+/// root thread view. Not called inside a window update, where that window would be leased and
+/// skipped — the caller always reaches it from a spawned task.
+fn find_parent_view(
+    parent_session: &acp_v2::SessionId,
+    origin_window: AnyWindowHandle,
+    cx: &App,
+) -> Option<(AnyWindowHandle, Entity<Workspace>, Entity<ThreadView>)> {
+    // The sub-thread's own window first, so a parent open there is the one written to.
+    let mut windows = cx.windows();
+    windows.sort_by_key(|window| *window != origin_window);
+    for window in windows {
+        let Some(multi_workspace) = window
+            .downcast::<MultiWorkspace>()
+            .and_then(|handle| handle.read(cx).ok())
+        else {
+            continue;
+        };
+        for workspace in multi_workspace.workspaces() {
+            let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
+                continue;
+            };
+            let view = panel
+                .read(cx)
+                .conversation_views()
+                .into_iter()
+                .filter(|view| view.read(cx).root_session_id.as_ref() == Some(parent_session))
+                .find_map(|view| view.read(cx).root_thread_view());
+            if let Some(view) = view {
+                return Some((window, workspace.clone(), view));
+            }
+        }
+    }
+    None
+}
+
+/// Appends `block` to the parent's composer, sending nothing (R2.2–R2.4). The cursor goes to the
+/// end first: `MessageEditor::insert_text` inserts at the cursor, which may sit inside a draft.
+fn append_to_composer(view: &mut ThreadView, block: &str, window: &mut Window, cx: &mut App) {
+    view.message_editor.update(cx, |editor, cx| {
+        let text = editor.text(cx);
+        editor.set_cursor_offset(text.len(), window, cx);
+        if !text.is_empty() {
+            editor.insert_text("\n\n", window, cx);
+        }
+        editor.insert_text(block, window, cx);
+    });
+    let focus = view.message_editor.focus_handle(cx);
+    window.focus(&focus, cx);
+}
+
+/// Shows `notice` on the sub-thread's view when it still exists.
+fn tell_origin(origin: &WeakEntity<ThreadView>, notice: String, cx: &mut AsyncApp) {
+    origin
+        .update(cx, |view, cx| {
+            view.send_to_parent.notice = Some(notice.into());
+            cx.notify();
+        })
+        // A view closed meanwhile has nobody left to tell; the warning already said it.
+        .ok();
+}
+
+/// Delivers `block` to the parent thread of session `parent_session` (R2, R4). `origin` is the
+/// sub-thread's view, held weakly: it may close while the parent opens.
+pub(crate) fn deliver_to_parent(
+    parent_session: acp_v2::SessionId,
+    block: String,
+    origin: WeakEntity<ThreadView>,
+    window: &mut Window,
+    cx: &mut App,
+) {
+    let fallback_window = window.window_handle();
+    let chars = block.chars().count();
+    cx.spawn(async move |cx| {
+        async {
+            // Read here, not before spawning: the caller is usually that very view, mid-update.
+            let sub_session = origin
+                .read_with(cx, |view, _| view.session_id.to_string())
+                .ok();
+            // Session ids and a count only: the block is the user's text (R8.1).
+            let ids = match &sub_session {
+                Some(sub) => format!("parent_session={parent_session} sub_session={sub}"),
+                None => format!("parent_session={parent_session}"),
+            };
+            if sub_session.is_none() {
+                log::warn!("zeo_sub_thread event=deliver_lost {ids} reason=origin_gone");
+                return;
+            }
+            let mut found = cx.update(|cx| find_parent_view(&parent_session, fallback_window, cx));
+            if found.is_none() {
+                let routing = cx.update(|cx| {
+                    // The sub-thread's own workspace: every workspace shares one `AppState`.
+                    let workspace = origin
+                        .read_with(cx, |view, _| view.workspace.upgrade())
+                        .ok()
+                        .flatten()
+                        .context("no window to open the parent thread from")?;
+                    let app_state = workspace.read(cx).app_state().clone();
+                    anyhow::Ok(open_agent_session(
+                        parent_session.clone(),
+                        OpenAgentSessionOptions {
+                            cwd: None,
+                            fallback_window: Some(fallback_window),
+                            tool_call_id: None,
+                            task_id: None,
+                        },
+                        app_state,
+                        cx,
+                    ))
+                });
+                let opened = match routing {
+                    Ok(routing) => routing.await,
+                    Err(error) => Err(error),
+                };
+                if let Err(error) = opened {
+                    log::warn!("zeo_sub_thread event=open_failed {ids} error={error:#}");
+                    tell_origin(
+                        &origin,
+                        format!("Could not open the parent thread: {error:#}"),
+                        cx,
+                    );
+                    return;
+                }
+                // Looked up again: no editor handle is ever held across the router's await.
+                found = cx.update(|cx| find_parent_view(&parent_session, fallback_window, cx));
+            }
+            let Some((parent_window, workspace, view)) = found else {
+                log::warn!("zeo_sub_thread event=deliver_lost {ids} reason=no_parent_view");
+                tell_origin(
+                    &origin,
+                    "Could not open the parent thread: it did not load".to_string(),
+                    cx,
+                );
+                return;
+            };
+            // The choice was made in a view that is gone: nothing is delivered on its behalf.
+            if origin.upgrade().is_none() {
+                log::warn!("zeo_sub_thread event=deliver_lost {ids} reason=origin_gone");
+                return;
+            }
+            let delivered = parent_window.update(cx, |root, window, cx| {
+                window.activate_window();
+                // A parent in a workspace the window is not showing would get the block unseen.
+                if let Ok(multi_workspace) = root.downcast::<MultiWorkspace>()
+                    && multi_workspace.read(cx).workspace() != &workspace
+                {
+                    multi_workspace.update(cx, |multi_workspace, cx| {
+                        multi_workspace.activate(workspace.clone(), None, window, cx)
+                    });
+                }
+                let panel = workspace.update(cx, |workspace, cx| {
+                    workspace.focus_panel::<AgentPanel>(window, cx)
+                });
+                if let Some(panel) = panel {
+                    panel.update(cx, |panel, cx| {
+                        panel.open_thread_by_session(parent_session.clone(), window, cx)
+                    });
+                }
+                view.update(cx, |view, cx| append_to_composer(view, &block, window, cx));
+            });
+            match delivered {
+                Ok(()) => log::info!("zeo_sub_thread event=delivered {ids} chars={chars}"),
+                Err(error) => {
+                    log::warn!(
+                        "zeo_sub_thread event=deliver_lost {ids} reason=window error={error:#}"
+                    );
+                    tell_origin(
+                        &origin,
+                        format!("Could not open the parent thread: {error:#}"),
+                        cx,
+                    );
+                }
+            }
+        }
+        .await;
+        // Every way the delivery ends gives the choices back (R2.6).
+        origin
+            .update(cx, |view, cx| {
+                view.send_to_parent.delivering = false;
+                cx.notify();
+            })
+            // A view closed meanwhile has no choices left to give back.
+            .ok();
+    })
+    .detach();
+}
diff --git a/crates/agent_ui/src/sub_thread_send/helpers_tests.rs b/crates/agent_ui/src/sub_thread_send/helpers_tests.rs
new file mode 100644
index 0000000..dd73b39
--- /dev/null
+++ b/crates/agent_ui/src/sub_thread_send/helpers_tests.rs
@@ -0,0 +1,367 @@
+// Story 040, task 6.1 — the pure helpers of Send to Parent: `parent_link`, `availability`,
+// `last_reply_text`, `compose_block`, `SUMMARY_REQUEST` (R1.4, R1.5, R2.1, R3.5, R4.4, R4.5; Q5).
+// Module path: `sub_thread_send::helpers_tests`. Wired in `src/sub_thread_send.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "sub_thread_send/helpers_tests.rs"]
+//     mod helpers_tests;
+//
+// Validation filter: `cargo test -p agent_ui -- sub_thread_send`. Fixtures:
+// `crate::sub_thread_test_support`.
+//
+// Contract (design.md §SendToParentControl):
+//   - `parent_link(thread_id, cx) -> ParentLink`, `ParentLink::{None, Live { parent,
+//     parent_session }, Deleted}`: `None` with no link or no organization store; `Deleted` when
+//     the parent has no metadata entry or its entry has no session; an archived parent is Live;
+//   - `availability(link, thread, state) -> Availability { last_reply, summarize }`, each
+//     `Result<(), SharedString>` whose `Err` is the tooltip; checks in this order: Deleted →
+//     "The parent thread was deleted"; generating or a pending summary → "Wait for the turn
+//     to end" (039's text, story v2); no agent message → "No reply to send yet" (last reply only);
+//   - `last_reply_text(entries, cx)`: the LAST assistant run;
+//   - `compose_block(title, reply)`: `From sub-thread "<title>":`, a blank line, the reply cut at
+//     200,000 characters on a `char` boundary plus the line `[truncated: N more characters]`;
+//   - `SUMMARY_REQUEST`, the fixed text of design.md §Summary request.
+// Assumed beyond design.md: `ParentLink` derives `Debug` + `PartialEq`; `availability` takes
+// `&ParentLink`, `&AcpThread` and `&SendToParentState` (adapt in `avail` only); `PendingSummary`
+// and `SendToParentState` are built here through their private fields (this is a child module).
+//
+// Where R4.5 and design.md disagree: R4.5 caps "the inserted text", design.md caps "the reply".
+// These tests follow design.md (the header is never cut and does not count toward the cap).
+//
+// Hostile halves first:
+//   - the check ORDER: a deleted parent says so even while generating; generating says "wait"
+//     even with no reply yet;
+//   - `parent_link` of a thread that HAS children but no parent is `None`;
+//   - `last_reply_text` is the last reply, not the first and not all of them;
+//   - the cap counts characters, not bytes: 150,000 two-byte characters are not cut; exactly
+//     200,000 are not cut; 200,001 are.
+
+use acp_thread::AcpThread;
+use agent_client_protocol::v1::schema as acp_v1;
+use gpui::{Entity, SharedString, TestAppContext, VisualTestContext};
+
+use super::{
+    Availability, ParentLink, PendingSummary, SUMMARY_REQUEST, SendToParentState, availability,
+    compose_block, last_reply_text, parent_link,
+};
+use crate::sub_thread_test_support::*;
+use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore};
+
+const DELETED: &str = "The parent thread was deleted";
+const WAIT: &str = "Wait for the turn to end";
+const NO_REPLY: &str = "No reply to send yet";
+
+fn avail(
+    link: &ParentLink,
+    thread: &Entity<AcpThread>,
+    state: &SendToParentState,
+    cx: &VisualTestContext,
+) -> (Result<(), String>, Result<(), String>) {
+    thread.read_with(cx, |thread, _| {
+        let Availability {
+            last_reply,
+            summarize,
+        } = availability(link, thread, state);
+        (
+            last_reply.map_err(|tooltip: SharedString| tooltip.to_string()),
+            summarize.map_err(|tooltip: SharedString| tooltip.to_string()),
+        )
+    })
+}
+
+fn link_of(thread_id: ThreadId, cx: &mut VisualTestContext) -> ParentLink {
+    cx.update(|_, cx| parent_link(thread_id, cx))
+}
+
+fn pending() -> SendToParentState {
+    SendToParentState {
+        pending_summary: Some(PendingSummary {
+            activity_generation: 1,
+            ..Default::default()
+        }),
+        notice: None,
+        ..Default::default()
+    }
+}
+
+fn live(parent: &Conv) -> ParentLink {
+    ParentLink::Live {
+        parent: parent.thread_id,
+        parent_session: parent.session.clone(),
+    }
+}
+
+// ── availability (R1.4, R1.5, R3.5, R4.4) ──────────────────────────────────
+
+#[gpui::test]
+async fn sub_thread_send_availability_deleted_parent_wins_over_generating(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let child = open_conversation(&mut rig, "Child", "an answer");
+    let _finish = rig.connection.defer_next_prompt_response();
+    set_composer(&child.view, "more", &mut rig.cx);
+    child
+        .view
+        .update_in(&mut rig.cx, |view, window, cx| view.send(window, cx));
+    rig.cx.run_until_parked();
+
+    let (last_reply, summarize) = avail(
+        &ParentLink::Deleted,
+        &child.thread,
+        &SendToParentState::default(),
+        &rig.cx,
+    );
+    assert_eq!(last_reply, Err(DELETED.to_string()));
+    assert_eq!(summarize, Err(DELETED.to_string()));
+}
+
+#[gpui::test]
+async fn sub_thread_send_availability_generating_waits_even_with_no_reply(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "");
+    let _finish = rig.connection.defer_next_prompt_response();
+    set_composer(&child.view, "still nothing", &mut rig.cx);
+    child
+        .view
+        .update_in(&mut rig.cx, |view, window, cx| view.send(window, cx));
+    rig.cx.run_until_parked();
+
+    let (last_reply, summarize) = avail(
+        &live(&parent),
+        &child.thread,
+        &SendToParentState::default(),
+        &rig.cx,
+    );
+    assert_eq!(
+        last_reply,
+        Err(WAIT.to_string()),
+        "waiting comes before no-reply"
+    );
+    assert_eq!(summarize, Err(WAIT.to_string()));
+}
+
+#[gpui::test]
+async fn sub_thread_send_availability_pending_summary_disables_both(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "an answer");
+
+    let (last_reply, summarize) = avail(&live(&parent), &child.thread, &pending(), &rig.cx);
+    assert_eq!(last_reply, Err(WAIT.to_string()));
+    assert_eq!(summarize, Err(WAIT.to_string()));
+}
+
+#[gpui::test]
+async fn sub_thread_send_availability_no_reply_disables_last_reply_only(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "");
+    // The stub keeps a turn with no updates open until told how it ended.
+    rig.connection
+        .end_turn(child.session.clone(), acp_v1::StopReason::EndTurn);
+    rig.cx.run_until_parked();
+
+    let (last_reply, summarize) = avail(
+        &live(&parent),
+        &child.thread,
+        &SendToParentState::default(),
+        &rig.cx,
+    );
+    assert_eq!(last_reply, Err(NO_REPLY.to_string()));
+    assert_eq!(summarize, Ok(()), "a summary can still be asked for");
+}
+
+#[gpui::test]
+async fn sub_thread_send_availability_idle_with_a_reply_enables_both(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "an answer");
+
+    let (last_reply, summarize) = avail(
+        &live(&parent),
+        &child.thread,
+        &SendToParentState::default(),
+        &rig.cx,
+    );
+    assert_eq!(last_reply, Ok(()));
+    assert_eq!(summarize, Ok(()));
+}
+
+// ── parent_link (R1.1, R1.2, R4.4) ─────────────────────────────────────────
+
+#[gpui::test]
+async fn sub_thread_send_parent_link_of_a_parent_is_none(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    let lone = open_conversation(&mut rig, "Lone", "lone answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+
+    assert_eq!(
+        link_of(parent.thread_id, &mut rig.cx),
+        ParentLink::None,
+        "having sub-threads is not having a parent"
+    );
+    assert_eq!(link_of(lone.thread_id, &mut rig.cx), ParentLink::None);
+    assert_eq!(link_of(child.thread_id, &mut rig.cx), live(&parent));
+}
+
+#[gpui::test]
+async fn sub_thread_send_parent_link_without_the_organization_store_is_none(
+    cx: &mut TestAppContext,
+) {
+    init_without_organization(cx);
+    let mut rig = rig(cx).await;
+    let only = open_conversation(&mut rig, "Only", "an answer");
+    assert_eq!(link_of(only.thread_id, &mut rig.cx), ParentLink::None);
+}
+
+#[gpui::test]
+async fn sub_thread_send_parent_link_deleted_parent_and_parent_without_session(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    let other_parent = open_conversation(&mut rig, "Other parent", "answer");
+    let other_child = open_conversation(&mut rig, "Other child", "answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    link(other_child.thread_id, other_parent.thread_id, &mut rig.cx);
+
+    delete_thread(parent.thread_id, &mut rig.cx);
+    assert_eq!(link_of(child.thread_id, &mut rig.cx), ParentLink::Deleted);
+
+    // The parent's row survives with no session.
+    rig.cx.update(|_, cx| {
+        let store = ThreadMetadataStore::global(cx);
+        let mut entry = store
+            .read(cx)
+            .entry(other_parent.thread_id)
+            .cloned()
+            .expect("the parent's row");
+        entry.session_id = None;
+        store.update(cx, |store, cx| store.save(entry, cx));
+    });
+    rig.cx.run_until_parked();
+    assert_eq!(
+        link_of(other_child.thread_id, &mut rig.cx),
+        ParentLink::Deleted
+    );
+}
+
+#[gpui::test]
+async fn sub_thread_send_parent_link_archived_parent_is_live(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    rig.cx.update(|_, cx| {
+        ThreadMetadataStore::global(cx)
+            .update(cx, |store, cx| store.archive(parent.thread_id, None, cx))
+    });
+    rig.cx.run_until_parked();
+
+    assert_eq!(link_of(child.thread_id, &mut rig.cx), live(&parent));
+}
+
+// ── last_reply_text (R2.1) ─────────────────────────────────────────────────
+
+#[gpui::test]
+async fn sub_thread_send_last_reply_is_the_last_not_the_first_nor_all(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let child = open_conversation(&mut rig, "Child", "first answer");
+    send_text(&mut rig, &child.view, "and then?", "second answer");
+
+    let text = child
+        .thread
+        .read_with(&rig.cx, |thread, cx| last_reply_text(thread.entries(), cx));
+    assert_eq!(text.as_deref().map(str::trim), Some("second answer"));
+}
+
+#[gpui::test]
+async fn sub_thread_send_last_reply_of_a_thread_with_no_reply_is_none(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let child = open_conversation(&mut rig, "Child", "");
+
+    let text = child
+        .thread
+        .read_with(&rig.cx, |thread, cx| last_reply_text(thread.entries(), cx));
+    assert_eq!(text, None);
+}
+
+// ── compose_block (R2.1, R4.5) ─────────────────────────────────────────────
+
+const CAP: usize = 200_000;
+
+#[test]
+fn sub_thread_send_compose_counts_characters_not_bytes() {
+    let reply = "é".repeat(150_000);
+    assert!(reply.len() > CAP, "precondition: more bytes than the cap");
+    let block = compose_block("Side", &reply);
+    assert!(
+        !block.contains("[truncated"),
+        "150,000 characters are under the cap"
+    );
+    assert!(block.ends_with(&reply));
+}
+
+#[test]
+fn sub_thread_send_compose_exactly_at_the_cap_is_not_cut() {
+    let reply = "a".repeat(CAP);
+    let block = compose_block("Side", &reply);
+    assert!(!block.contains("[truncated"));
+    assert!(block.ends_with(&reply));
+}
+
+#[test]
+fn sub_thread_send_compose_one_over_the_cap_is_cut_with_its_count() {
+    let reply = "a".repeat(CAP + 1);
+    let block = compose_block("Side", &reply);
+    let expected = format!(
+        "From sub-thread \"Side\":\n\n{}\n[truncated: 1 more characters]",
+        "a".repeat(CAP)
+    );
+    assert_eq!(block, expected);
+}
+
+#[test]
+fn sub_thread_send_compose_cuts_on_a_character_boundary() {
+    let reply = format!("{}{}", "é".repeat(CAP - 1), "😀😀😀");
+    let block = compose_block("Side", &reply);
+    let body = block
+        .strip_prefix("From sub-thread \"Side\":\n\n")
+        .expect("the header comes first");
+    let (kept, marker) = body
+        .rsplit_once('\n')
+        .expect("the marker is on its own line");
+    assert_eq!(kept.chars().count(), CAP, "exactly the cap, in characters");
+    assert_eq!(kept, format!("{}😀", "é".repeat(CAP - 1)));
+    assert_eq!(marker, "[truncated: 2 more characters]");
+}
+
+#[test]
+fn sub_thread_send_compose_has_the_header_and_a_blank_line() {
+    assert_eq!(
+        compose_block("Side quest", "The answer.\nWith two lines."),
+        "From sub-thread \"Side quest\":\n\nThe answer.\nWith two lines."
+    );
+}
+
+#[test]
+fn sub_thread_send_summary_request_is_the_designed_text() {
+    assert_eq!(
+        SUMMARY_REQUEST,
+        "Summarize this sub-thread for its parent thread: the question it explored, what was \
+         found, and any decision or open item. Reply with the summary only."
+    );
+}
diff --git a/crates/agent_ui/src/sub_thread_send/in_flight_guard_tests.rs b/crates/agent_ui/src/sub_thread_send/in_flight_guard_tests.rs
new file mode 100644
index 0000000..b8ccab8
--- /dev/null
+++ b/crates/agent_ui/src/sub_thread_send/in_flight_guard_tests.rs
@@ -0,0 +1,406 @@
+// Story 040, task 11.3 (refine v4, review 7.x L2) — the in-flight guard on Send Last Reply: while
+// a delivery is still reaching the parent, a second pick does nothing, and both choices come
+// back once the delivery ends, whichever way it ends (R2.6, R4.2, R4.3; Q4, Q11). Module path:
+// `sub_thread_send::in_flight_guard_tests`. Wired in `src/sub_thread_send.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "sub_thread_send/in_flight_guard_tests.rs"]
+//     mod in_flight_guard_tests;
+//
+// Validation filter: `cargo test -p agent_ui -- send_last_reply`. Fixtures:
+// `crate::sub_thread_test_support`; log capture: `crate::thread_organization::test_helpers`.
+//
+// Contract (design.md v4 §SendToParentControl, §deliver_to_parent):
+//   - `ThreadView::send_last_reply` sets `SendToParentState::delivering` before the delivery
+//     starts; `availability` then disables both choices with "Sending to the parent thread";
+//   - the flag is cleared when the delivery ends: delivered, or `open_failed`;
+//   - a closed parent is reached through `open_agent_session`: a window holding exactly its
+//     folders, else a NEW window over them, then that window's agent panel (up to 60 s).
+// Read through `parent_link` + `availability` on the view's own state (the control's source),
+// never through a field name, so the tests do not depend on how the flag is stored.
+//
+// Fixture: the parent is a saved thread under `/elsewhere`, a folder no window holds, so the
+// router opens a new window for it — L2's path (agent_session_router.rs, `OpenMode::NewWindow`).
+// A test app adds no agent panel to that window, so the delivery stays in flight until the test
+// adds one (success) or the router's 60 s wait runs out (open failure).
+//
+// Hostile halves first:
+//   - wrongly DUPLICATED: two picks in a row, before the parent exists anywhere, open one window,
+//     one view of the parent and one block — not one per pick;
+//   - wrongly ALLOWED: during the router's wait both choices say "Sending to the parent thread",
+//     and a pick then starts no second open (one `open_failed`, one window);
+//   - wrongly KEPT: after the delivery, and after an open failure, both choices are enabled again,
+//     and a later deliberate pick delivers a second block (the guard swallows only overlap);
+//   - wrongly SHARED: a sibling sub-thread of the same parent keeps both choices meanwhile.
+//
+// GOTCHA: `StubAgentConnection::load_session` returns an EMPTY thread, which the metadata store
+// saves as a draft with `session_id: None`; read afterwards, `parent_link` would say "The parent
+// thread was deleted". `replay_parent_history` adds the one entry a real load would bring back.
+
+use std::time::Duration;
+
+use acp_thread::StubAgentConnection;
+use agent_client_protocol::v1::schema as acp_v1;
+use agent_client_protocol::v2::schema as acp_v2;
+use gpui::{AnyWindowHandle, AppContext as _, Entity, TestAppContext, VisualTestContext};
+use serde_json::json;
+use workspace::MultiWorkspace;
+
+use super::{Availability, availability, parent_link};
+use crate::AgentPanel;
+use crate::sub_thread_test_support::*;
+use crate::thread_metadata_store::{ThreadId, ThreadMetadata, ThreadMetadataStore};
+use crate::thread_organization::test_helpers::{all_lines_lowercased, capture_logs};
+
+const SENDING: &str = "Sending to the parent thread";
+const NOBODY_HOLDS: &str = "/elsewhere";
+
+type Choices = (Result<(), String>, Result<(), String>);
+
+fn sub_thread_block(title: &str, reply: &str) -> String {
+    format!("From sub-thread \"{title}\":\n\n{reply}")
+}
+
+/// What the control would offer now, from the view's own link, thread and state.
+fn offered(conv: &Conv, cx: &mut VisualTestContext) -> Choices {
+    let view = conv.view.clone();
+    cx.update(|_, cx| {
+        let view = view.read(cx);
+        let link = parent_link(view.root_thread_id, cx);
+        let Availability {
+            last_reply,
+            summarize,
+        } = availability(&link, view.thread.read(cx), &view.send_to_parent);
+        (
+            last_reply.map_err(|tooltip| tooltip.to_string()),
+            summarize.map_err(|tooltip| tooltip.to_string()),
+        )
+    })
+}
+
+/// One pick of Send Last Reply, with nothing run after it: its delivery has not started yet.
+fn pick_send_last_reply(conv: &Conv, cx: &mut VisualTestContext) {
+    conv.view
+        .update_in(cx, |view, window, cx| view.send_last_reply(window, cx));
+}
+
+/// The rig's panel runs `crate::Agent::Stub` on a stub that can load a session back, as
+/// deliver_to_parent_tests.rs's closed-parent test sets it up.
+fn loadable_stub(panel: &Entity<AgentPanel>, cx: &mut VisualTestContext) {
+    let loadable = crate::test_support::stub_agent_connection();
+    panel.update(cx, |panel, cx| {
+        panel.connection_store().update(cx, |store, cx| {
+            store.restart_connection(
+                crate::Agent::Stub,
+                std::rc::Rc::new(crate::test_support::StubAgentServer::new(loadable)),
+                cx,
+            );
+        });
+    });
+    cx.run_until_parked();
+}
+
+/// A rig whose stub loads sessions, plus a folder no window holds.
+async fn guard_rig(cx: &mut TestAppContext) -> Rig {
+    init(cx);
+    crate::test_support::set_stub_agent_connection(
+        StubAgentConnection::new().with_supports_load_session(true),
+    );
+    let mut rig = rig(cx).await;
+    rig.connection = crate::test_support::stub_agent_connection();
+    loadable_stub(&rig.panel.clone(), &mut rig.cx);
+    rig.fs
+        .insert_tree(NOBODY_HOLDS, json!({ "file.txt": "" }))
+        .await;
+    rig
+}
+
+/// A parent known only to the metadata store, under `folder`, run by `crate::Agent::Stub`.
+fn saved_closed_parent(
+    session: &str,
+    folder: &str,
+    cx: &mut VisualTestContext,
+) -> (ThreadId, acp_v2::SessionId) {
+    let thread_id = ThreadId::new();
+    let session = acp_v2::SessionId::new(session);
+    let now = chrono::Utc::now();
+    let folders = [std::path::Path::new(folder)];
+    cx.update(|_, cx| {
+        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
+            store.save(
+                ThreadMetadata {
+                    thread_id,
+                    session_id: Some(session.clone()),
+                    agent_id: crate::Agent::Stub.id(),
+                    title: Some("Closed parent".into()),
+                    title_override: None,
+                    updated_at: now,
+                    created_at: Some(now),
+                    interacted_at: None,
+                    worktree_paths: project::WorktreePaths::from_folder_paths(
+                        &workspace::PathList::new(&folders),
+                    ),
+                    remote_connection: None,
+                    archived: false,
+                },
+                cx,
+            )
+        })
+    });
+    cx.run_until_parked();
+    (thread_id, session)
+}
+
+fn windows_now(cx: &mut TestAppContext) -> Vec<AnyWindowHandle> {
+    cx.update(|cx| cx.windows())
+}
+
+fn opened_since(before: &[AnyWindowHandle], cx: &mut TestAppContext) -> Vec<AnyWindowHandle> {
+    windows_now(cx)
+        .into_iter()
+        .filter(|window| !before.contains(window))
+        .collect()
+}
+
+/// Gives `window` the agent panel a test app never adds, on the loadable stub, so a delivery
+/// waiting for it can go on.
+fn give_agent_panel(window: AnyWindowHandle, cx: &mut TestAppContext) {
+    let workspace = window
+        .downcast::<MultiWorkspace>()
+        .expect("a workspace window")
+        .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
+        .expect("the window is open");
+    let mut window_cx = VisualTestContext::from_window(window, cx);
+    let panel = workspace.update_in(&mut window_cx, |workspace, window, cx| {
+        let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
+        workspace.add_panel(panel.clone(), window, cx);
+        panel
+    });
+    loadable_stub(&panel, &mut window_cx);
+}
+
+/// The composer of every view of `session`, in every window: one entry per view.
+fn composers_of(session: &acp_v2::SessionId, cx: &mut TestAppContext) -> Vec<String> {
+    cx.update(|cx| {
+        let mut composers = Vec::new();
+        for window in cx.windows() {
+            let Some(multi_workspace) = window
+                .downcast::<MultiWorkspace>()
+                .and_then(|handle| handle.read(cx).ok())
+            else {
+                continue;
+            };
+            for workspace in multi_workspace.workspaces() {
+                let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
+                    continue;
+                };
+                for conversation in panel.read(cx).conversation_views() {
+                    if conversation.read(cx).root_session_id.as_ref() != Some(session) {
+                        continue;
+                    }
+                    if let Some(view) = conversation.read(cx).root_thread_view() {
+                        composers.push(view.read(cx).message_editor.read(cx).text(cx));
+                    }
+                }
+            }
+        }
+        composers
+    })
+}
+
+/// What a real load does and the stub does not: the parent's history comes back with it. The
+/// stub loads an EMPTY thread, which the metadata store saves as a draft (`session_id: None`),
+/// so `parent_link` would read the parent as deleted and hide what the guard does.
+fn replay_parent_history(session: &acp_v2::SessionId, cx: &mut TestAppContext) {
+    let threads: Vec<Entity<acp_thread::AcpThread>> = cx.update(|cx| {
+        let mut threads = Vec::new();
+        for window in cx.windows() {
+            let Some(multi_workspace) = window
+                .downcast::<MultiWorkspace>()
+                .and_then(|handle| handle.read(cx).ok())
+            else {
+                continue;
+            };
+            for workspace in multi_workspace.workspaces() {
+                let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
+                    continue;
+                };
+                for conversation in panel.read(cx).conversation_views() {
+                    if conversation.read(cx).root_session_id.as_ref() == Some(session)
+                        && let Some(view) = conversation.read(cx).root_thread_view()
+                    {
+                        threads.push(view.read(cx).thread.clone());
+                    }
+                }
+            }
+        }
+        threads
+    });
+    assert_eq!(threads.len(), 1, "precondition: the parent is loaded once");
+    for thread in threads {
+        thread.update(cx, |thread, cx| {
+            thread
+                .handle_session_update(
+                    acp_v1::SessionUpdate::AgentMessageChunk(acp_v1::ContentChunk::new(
+                        "the parent's earlier answer".to_string().into(),
+                    )),
+                    cx,
+                )
+                .expect("the replayed history applies")
+        });
+    }
+    cx.run_until_parked();
+}
+
+// ── Hostile: two picks, one window, one view, one block (R2.6, R4.2; L2) ───
+
+#[gpui::test]
+async fn send_last_reply_twice_toward_a_closed_parent_opens_one_window_and_appends_one_block(
+    cx: &mut TestAppContext,
+) {
+    let mut rig = guard_rig(cx).await;
+    let (parent_id, parent_session) =
+        saved_closed_parent("guard-twice-parent", NOBODY_HOLDS, &mut rig.cx);
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent_id, &mut rig.cx);
+    let before = windows_now(cx);
+
+    // A double pick: the second comes before the first delivery has even looked for the parent.
+    pick_send_last_reply(&child, &mut rig.cx);
+    pick_send_last_reply(&child, &mut rig.cx);
+    rig.cx.run_until_parked();
+
+    let opened = opened_since(&before, cx);
+    for window in &opened {
+        give_agent_panel(*window, cx);
+    }
+    rig.cx.run_until_parked();
+
+    assert_eq!(
+        opened.len(),
+        1,
+        "one window is opened for the parent, not one per pick"
+    );
+    assert_eq!(
+        composers_of(&parent_session, cx),
+        vec![sub_thread_block("Child", "child answer")],
+        "one view of the parent, holding exactly one block"
+    );
+    replay_parent_history(&parent_session, cx);
+    assert_eq!(
+        offered(&child, &mut rig.cx),
+        (Ok(()), Ok(())),
+        "both choices are back once the delivery has landed"
+    );
+}
+
+// ── Hostile: disabled mid-delivery, a pick then opens nothing, back after an open failure ──
+
+#[gpui::test]
+async fn send_last_reply_in_flight_ignores_a_pick_and_re_enables_after_an_open_failure(
+    cx: &mut TestAppContext,
+) {
+    let mut rig = guard_rig(cx).await;
+    let (parent_id, parent_session) =
+        saved_closed_parent("guard-failure-parent", NOBODY_HOLDS, &mut rig.cx);
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent_id, &mut rig.cx);
+    let before = windows_now(cx);
+    capture_logs();
+
+    pick_send_last_reply(&child, &mut rig.cx);
+    rig.cx.run_until_parked();
+    // The router opened the parent's window and waits for its agent panel, which never comes.
+    assert_eq!(
+        offered(&child, &mut rig.cx),
+        (Err(SENDING.to_string()), Err(SENDING.to_string())),
+        "while the parent is being opened, both choices say why they wait"
+    );
+    pick_send_last_reply(&child, &mut rig.cx);
+    rig.cx.run_until_parked();
+    rig.cx.executor().advance_clock(Duration::from_secs(61));
+    rig.cx.run_until_parked();
+
+    let parent_id_text = parent_session.to_string().to_lowercase();
+    let failures = all_lines_lowercased()
+        .into_iter()
+        .filter(|line| {
+            line.contains("zeo_sub_thread")
+                && line.contains("open_failed")
+                && line.contains(&parent_id_text)
+        })
+        .count();
+    assert_eq!(failures, 1, "the pick made mid-delivery opened nothing");
+    assert_eq!(
+        opened_since(&before, cx).len(),
+        1,
+        "one window for the parent, from the first pick only"
+    );
+    let notice = notice_of(&child.view, &rig.cx).expect("the sub-thread says why");
+    assert!(
+        notice.starts_with("Could not open the parent thread: "),
+        "the open failure is shown: {notice:?}"
+    );
+    assert_eq!(
+        offered(&child, &mut rig.cx),
+        (Ok(()), Ok(())),
+        "both choices are back after the open failure"
+    );
+}
+
+// ── Hostile converse: the guard ends with the delivery, and is per sub-thread ──
+
+#[gpui::test]
+async fn send_last_reply_in_flight_disables_both_then_a_later_pick_delivers_again(
+    cx: &mut TestAppContext,
+) {
+    let mut rig = guard_rig(cx).await;
+    let (parent_id, parent_session) =
+        saved_closed_parent("guard-again-parent", NOBODY_HOLDS, &mut rig.cx);
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    let sibling = open_conversation(&mut rig, "Sibling", "sibling answer");
+    link(child.thread_id, parent_id, &mut rig.cx);
+    link(sibling.thread_id, parent_id, &mut rig.cx);
+    let before = windows_now(cx);
+
+    pick_send_last_reply(&child, &mut rig.cx);
+    assert_eq!(
+        offered(&child, &mut rig.cx),
+        (Err(SENDING.to_string()), Err(SENDING.to_string())),
+        "disabled from the pick on, before the delivery has run at all"
+    );
+    assert_eq!(
+        offered(&sibling, &mut rig.cx),
+        (Ok(()), Ok(())),
+        "another sub-thread of the same parent is not held back"
+    );
+    rig.cx.run_until_parked();
+    let opened = opened_since(&before, cx);
+    for window in &opened {
+        give_agent_panel(*window, cx);
+    }
+    rig.cx.run_until_parked();
+
+    let one = sub_thread_block("Child", "child answer");
+    assert_eq!(
+        composers_of(&parent_session, cx),
+        vec![one.clone()],
+        "the first delivery lands"
+    );
+    replay_parent_history(&parent_session, cx);
+    assert_eq!(
+        offered(&child, &mut rig.cx),
+        (Ok(()), Ok(())),
+        "both choices are back after the delivery"
+    );
+
+    // A deliberate pick after the first has landed is not overlap: it delivers.
+    pick_send_last_reply(&child, &mut rig.cx);
+    rig.cx.run_until_parked();
+    assert_eq!(
+        composers_of(&parent_session, cx),
+        vec![format!("{one}\n\n{one}")],
+        "the later pick appends its own block to the same composer"
+    );
+    assert_eq!(offered(&child, &mut rig.cx), (Ok(()), Ok(())));
+}
diff --git a/crates/agent_ui/src/sub_thread_send/receipt_test_support.rs b/crates/agent_ui/src/sub_thread_send/receipt_test_support.rs
new file mode 100644
index 0000000..307fccc
--- /dev/null
+++ b/crates/agent_ui/src/sub_thread_send/receipt_test_support.rs
@@ -0,0 +1,171 @@
+// Story 040, task 11.2 — setup only: conversations on the reported-activity ("receipt")
+// transport, where the agent, not Zeo, reports when a turn runs and ends. Module path:
+// `sub_thread_send::receipt_test_support`. Wired in `src/sub_thread_send.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "sub_thread_send/receipt_test_support.rs"]
+//     mod receipt_test_support;
+//
+// On this transport a send resolves when the agent ACCEPTS the message, the user message
+// appears only when the agent echoes it, and `Stopped` comes from the agent's Idle report
+// (`AcpThread::update_session_state`). That is what lets a test reproduce review 7.x M4: the
+// agent reports a turn of its own between the summary's submit and the summary's own turn.
+// Nothing here asserts; it drives `StubAgentConnection::with_receipt_submissions(true)` and the
+// thread's public update methods the way `conversation_view.rs`'s receipt tests do.
+
+#![allow(dead_code)]
+
+use acp_thread::{AcpThread, StubAgentConnection};
+use agent_client_protocol::v1::schema as acp_v1;
+use agent_client_protocol::v2::schema as acp_v2;
+use gpui::{Entity, VisualTestContext};
+
+use crate::sub_thread_test_support::{Conv, Rig, set_composer, set_title};
+
+fn conversation_on(rig: &mut Rig, connection: &StubAgentConnection) -> Conv {
+    // Under the connection's own agent id: the panel keeps one connection per agent id, and the
+    // rig's legacy conversations already hold the default one ("Test").
+    crate::test_support::open_thread_with_custom_connection(
+        &rig.panel,
+        connection.clone(),
+        &mut rig.cx,
+    );
+    let (thread, view) = rig.panel.read_with(&rig.cx, |panel, cx| {
+        (
+            panel
+                .active_agent_thread(cx)
+                .expect("a thread was just opened"),
+            panel.active_thread_view(cx).expect("its view is shown"),
+        )
+    });
+    let session = thread.read_with(&rig.cx, |thread, _| thread.session_id().clone());
+    let thread_id = crate::test_support::active_thread_id(&rig.panel, &rig.cx);
+    Conv {
+        thread,
+        view,
+        session,
+        thread_id,
+    }
+}
+
+/// A conversation on its own receipt connection that has run one complete turn — "Hello",
+/// answered `reply`, reported Idle with end of turn — titled `title`. Its state is confirmed.
+pub(crate) fn open_receipt_conversation(
+    rig: &mut Rig,
+    title: &str,
+    reply: &str,
+) -> (Conv, StubAgentConnection) {
+    let connection = StubAgentConnection::new().with_receipt_submissions(true);
+    let conv = conversation_on(rig, &connection);
+    let receipt = connection.defer_next_receipt_response();
+    set_composer(&conv.view, "Hello", &mut rig.cx);
+    conv.view
+        .update_in(&mut rig.cx, |view, window, cx| view.send(window, cx));
+    rig.cx.run_until_parked();
+    report_running(&conv.thread, &mut rig.cx);
+    receipt
+        .send(Ok(acp_v2::PromptResponse::new("hello-echo")))
+        .expect("the first message waits for its receipt");
+    rig.cx.run_until_parked();
+    echo_user_message(&conv.thread, "hello-echo", "Hello", &mut rig.cx);
+    agent_chunk(&conv.thread, reply, &mut rig.cx);
+    report_idle(&conv.thread, Some(acp_v2::StopReason::EndTurn), &mut rig.cx);
+    set_title(&conv.thread, title, &mut rig.cx);
+    (conv, connection)
+}
+
+/// A conversation titled "Child" on its own receipt connection, as it is right after a load: no
+/// state reported yet (`ThreadStatus::Unconfirmed`), with `reply` as its history's last agent
+/// message (none when empty).
+pub(crate) fn open_unconfirmed_receipt_conversation(
+    rig: &mut Rig,
+    reply: &str,
+) -> (Conv, StubAgentConnection) {
+    let connection = StubAgentConnection::new().with_receipt_submissions(true);
+    let conv = conversation_on(rig, &connection);
+    conv.thread.update(&mut rig.cx, |thread, cx| {
+        thread.mark_activity_unconfirmed(cx)
+    });
+    rig.cx.run_until_parked();
+    agent_chunk(&conv.thread, reply, &mut rig.cx);
+    set_title(&conv.thread, "Child", &mut rig.cx);
+    (conv, connection)
+}
+
+pub(crate) fn report_running(thread: &Entity<AcpThread>, cx: &mut VisualTestContext) {
+    thread
+        .update(cx, |thread, cx| {
+            thread.update_session_state(
+                acp_v2::StateUpdate::Running(acp_v2::RunningStateUpdate::new()),
+                cx,
+            )
+        })
+        .expect("the running report applies");
+    cx.run_until_parked();
+}
+
+pub(crate) fn report_idle(
+    thread: &Entity<AcpThread>,
+    stop_reason: Option<acp_v2::StopReason>,
+    cx: &mut VisualTestContext,
+) {
+    let idle = match stop_reason {
+        Some(reason) => acp_v2::IdleStateUpdate::new().stop_reason(reason),
+        None => acp_v2::IdleStateUpdate::new(),
+    };
+    thread
+        .update(cx, |thread, cx| {
+            thread.update_session_state(acp_v2::StateUpdate::Idle(idle), cx)
+        })
+        .expect("the idle report applies");
+    cx.run_until_parked();
+}
+
+/// One agent message chunk; an empty `text` sends nothing.
+pub(crate) fn agent_chunk(thread: &Entity<AcpThread>, text: &str, cx: &mut VisualTestContext) {
+    if text.is_empty() {
+        return;
+    }
+    thread
+        .update(cx, |thread, cx| {
+            thread.handle_session_update(
+                acp_v1::SessionUpdate::AgentMessageChunk(acp_v1::ContentChunk::new(
+                    text.to_string().into(),
+                )),
+                cx,
+            )
+        })
+        .expect("the agent chunk applies");
+    cx.run_until_parked();
+}
+
+/// The agent echoes the user message it took under the receipt id `id`.
+pub(crate) fn echo_user_message(
+    thread: &Entity<AcpThread>,
+    id: &str,
+    text: &str,
+    cx: &mut VisualTestContext,
+) {
+    thread
+        .update(cx, |thread, cx| {
+            thread.upsert_user_message(
+                acp_v2::UserMessage::new(id.to_string()).content(vec![text.into()]),
+                cx,
+            )
+        })
+        .expect("the echo applies");
+    cx.run_until_parked();
+}
+
+/// A turn the agent starts ON ITS OWN (a background-task continuation, review 7.x M4): it
+/// reports Running, answers `reply` and reports Idle with `stop_reason` — no user message.
+pub(crate) fn agent_initiated_turn(
+    thread: &Entity<AcpThread>,
+    reply: &str,
+    stop_reason: acp_v2::StopReason,
+    cx: &mut VisualTestContext,
+) {
+    report_running(thread, cx);
+    agent_chunk(thread, reply, cx);
+    report_idle(thread, Some(stop_reason), cx);
+}
diff --git a/crates/agent_ui/src/sub_thread_send/send_to_parent_control_tests.rs b/crates/agent_ui/src/sub_thread_send/send_to_parent_control_tests.rs
new file mode 100644
index 0000000..af9ae6c
--- /dev/null
+++ b/crates/agent_ui/src/sub_thread_send/send_to_parent_control_tests.rs
@@ -0,0 +1,203 @@
+// Story 040, task 6.2 — the Send to Parent control in a sub-thread's footer (R1.1–R1.6, R4.4;
+// Q11, Q12). Module path: `sub_thread_send::send_to_parent_control_tests`. Wired in
+// `src/sub_thread_send.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "sub_thread_send/send_to_parent_control_tests.rs"]
+//     mod send_to_parent_control_tests;
+//
+// Validation filter: `cargo test -p agent_ui -- send_to_parent_control`. Fixtures:
+// `crate::sub_thread_test_support`.
+//
+// Contract (design.md §SendToParentControl): `render_send_to_parent` is called from one hook in
+// `ThreadView::render_message_editor` and draws nothing for `ParentLink::None` (or without the
+// organization store); otherwise a menu button "Send to Parent" with exactly "Send Last Reply"
+// and "Summarize and Send", each disabled per `availability` with its tooltip; Tab stops and
+// accessible labels; `SendToParentState::notice` shows under the control until the next action.
+// Assumed beyond design.md (one place each):
+//   - debug selectors: `send-to-parent` (the button), `send-to-parent-last-reply` and
+//     `send-to-parent-summarize` (the two choices, while the menu is open),
+//     `send-to-parent-notice` (the notice);
+//   - the button's focus handle through `ThreadView::send_to_parent_focus_handle(&self)
+//     -> &FocusHandle`, the card's accessor pattern (`minimize_focus_handle`), read in
+//     `control_handle` only.
+//
+// Hostile halves first:
+//   - no control on an unlinked thread, nor on a PARENT (a thread with sub-threads but no parent),
+//     nor anywhere without the organization store;
+//   - a disabled choice is inert: activating "Send Last Reply" while the sub-thread generates puts
+//     nothing in the parent's composer;
+//   - then the control, its two choices, the notice and the Tab stop.
+
+use gpui::{FocusHandle, Modifiers, TestAppContext, VisualTestContext};
+
+use crate::conversation_view::ThreadView;
+use crate::sub_thread_test_support::*;
+
+const CONTROL: &str = "send-to-parent";
+const LAST_REPLY: &str = "send-to-parent-last-reply";
+const SUMMARIZE: &str = "send-to-parent-summarize";
+const NOTICE: &str = "send-to-parent-notice";
+
+fn click(selector: &'static str, cx: &mut VisualTestContext) {
+    let bounds = cx
+        .debug_bounds(selector)
+        .unwrap_or_else(|| panic!("{selector} is painted"));
+    cx.simulate_click(bounds.center(), Modifiers::none());
+    cx.run_until_parked();
+    draw(cx);
+}
+
+fn control_handle(view: &gpui::Entity<ThreadView>, cx: &VisualTestContext) -> FocusHandle {
+    view.read_with(cx, |view, _| view.send_to_parent_focus_handle().clone())
+}
+
+// ── Where the control is drawn (R1.1, R1.2) ────────────────────────────────
+
+#[gpui::test]
+async fn send_to_parent_control_absent_on_a_parent_and_an_unlinked_thread(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    // Opened last, the parent is the thread on screen once the link exists.
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    draw(&mut rig.cx);
+    assert!(
+        !painted(CONTROL, &mut rig.cx),
+        "a parent has no Send to Parent"
+    );
+
+    let _lone = open_conversation(&mut rig, "Lone", "lone answer");
+    draw(&mut rig.cx);
+    assert!(
+        !painted(CONTROL, &mut rig.cx),
+        "an unlinked thread has none"
+    );
+}
+
+#[gpui::test]
+async fn send_to_parent_control_drawn_on_a_sub_thread(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    draw(&mut rig.cx);
+    assert!(
+        !painted(CONTROL, &mut rig.cx),
+        "control: not before the link"
+    );
+
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    draw(&mut rig.cx);
+    assert!(painted(CONTROL, &mut rig.cx), "a sub-thread has it");
+}
+
+#[gpui::test]
+async fn send_to_parent_control_absent_without_the_organization_store(cx: &mut TestAppContext) {
+    init_without_organization(cx);
+    let mut rig = rig(cx).await;
+    let _only = open_conversation(&mut rig, "Only", "an answer");
+    draw(&mut rig.cx);
+    assert!(!painted(CONTROL, &mut rig.cx));
+}
+
+// ── A disabled choice is inert (R1.4) ──────────────────────────────────────
+
+#[gpui::test]
+async fn send_to_parent_control_disabled_choice_inserts_nothing(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    let _finish = rig.connection.defer_next_prompt_response();
+    set_composer(&child.view, "still thinking", &mut rig.cx);
+    child
+        .view
+        .update_in(&mut rig.cx, |view, window, cx| view.send(window, cx));
+    rig.cx.run_until_parked();
+    draw(&mut rig.cx);
+
+    click(CONTROL, &mut rig.cx);
+    if painted(LAST_REPLY, &mut rig.cx) {
+        click(LAST_REPLY, &mut rig.cx);
+    }
+
+    assert_eq!(
+        composer_text(&parent.view, &rig.cx),
+        "",
+        "nothing reaches the parent while the sub-thread generates"
+    );
+}
+
+// ── The menu (R1.3) and the notice ─────────────────────────────────────────
+
+#[gpui::test]
+async fn send_to_parent_control_offers_the_two_choices(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    draw(&mut rig.cx);
+    assert!(!painted(LAST_REPLY, &mut rig.cx), "closed until activated");
+
+    click(CONTROL, &mut rig.cx);
+
+    assert!(
+        painted(LAST_REPLY, &mut rig.cx),
+        "Send Last Reply is offered"
+    );
+    assert!(
+        painted(SUMMARIZE, &mut rig.cx),
+        "Summarize and Send is offered"
+    );
+}
+
+#[gpui::test]
+async fn send_to_parent_control_shows_the_notice_under_the_control(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    draw(&mut rig.cx);
+    assert!(!painted(NOTICE, &mut rig.cx), "no notice before any action");
+
+    child.view.update(&mut rig.cx, |view, cx| {
+        view.send_to_parent.notice = Some("Summary not sent: the reply did not finish".into());
+        cx.notify();
+    });
+    draw(&mut rig.cx);
+
+    assert!(painted(NOTICE, &mut rig.cx), "the notice is drawn");
+    assert_eq!(
+        notice_of(&child.view, &rig.cx).as_deref(),
+        Some("Summary not sent: the reply did not finish")
+    );
+}
+
+// ── Keyboard (R1.6, Q12) ───────────────────────────────────────────────────
+
+#[gpui::test]
+async fn send_to_parent_control_is_a_tab_stop(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    draw(&mut rig.cx);
+
+    let handle = control_handle(&child.view, &rig.cx);
+    let mut reached = false;
+    for _ in 0..200 {
+        rig.cx.update(|window, cx| window.focus_next(cx));
+        draw(&mut rig.cx);
+        if rig.cx.update(|window, _| handle.is_focused(window)) {
+            reached = true;
+            break;
+        }
+    }
+    assert!(reached, "Tab reaches Send to Parent");
+}
diff --git a/crates/agent_ui/src/sub_thread_send/summarize_and_send_tests.rs b/crates/agent_ui/src/sub_thread_send/summarize_and_send_tests.rs
new file mode 100644
index 0000000..f611d93
--- /dev/null
+++ b/crates/agent_ui/src/sub_thread_send/summarize_and_send_tests.rs
@@ -0,0 +1,277 @@
+// Story 040, task 7.2 — Summarize and Send: one visible summary turn in the sub-thread, delivered
+// to the parent only when THAT turn ends with end-of-turn (R3.1–R3.6, R8.1; Q4, Q11). Module
+// path: `sub_thread_send::summarize_and_send_tests`. Wired in `src/sub_thread_send.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "sub_thread_send/summarize_and_send_tests.rs"]
+//     mod summarize_and_send_tests;
+//
+// Validation filter: `cargo test -p agent_ui -- summarize_and_send`. Fixtures:
+// `crate::sub_thread_test_support`; log capture: `crate::thread_organization::test_helpers`.
+//
+// Contract (design.md §SendToParentControl, §Summary request):
+//   - `ThreadView::summarize_and_send(&mut self, window, cx)` submits `SUMMARY_REQUEST` through
+//     the view's normal send path, then stores `PendingSummary { activity_generation }` read
+//     after the submit;
+//   - on `AcpThreadEvent::Stopped` with that generation: `EndTurn` → `compose_block(title,
+//     last_reply_text)` → `deliver_to_parent`; any other stop reason → notice "Summary not sent:
+//     the reply did not finish"; `Error` / `Refusal` with a pending summary clear it with the
+//     notice; a non-matching generation does nothing; a dropped view delivers nothing;
+//   - `zeo_sub_thread event=summary_requested|summary_failed`, ids only.
+//
+// Hostile halves first:
+//   - a LATER turn of the sub-thread, after the summary was delivered, delivers nothing (R3.6);
+//   - a summary turn that is cancelled, fails or is refused delivers nothing and says so (R3.3);
+//   - a sub-thread view closed mid-summary delivers nothing (R3.4);
+//   - then the visible request and the delivered summary.
+
+use acp_thread::AgentThreadEntry;
+use agent_client_protocol::v1::schema as acp_v1;
+use futures::channel::oneshot;
+use gpui::{TestAppContext, VisualTestContext};
+use serde_json::json;
+
+use super::SUMMARY_REQUEST;
+use crate::sub_thread_test_support::*;
+use crate::thread_organization::test_helpers::{all_lines_lowercased, capture_logs};
+
+const NOT_SENT: &str = "Summary not sent: the reply did not finish";
+
+type Finish = oneshot::Sender<anyhow::Result<acp_v1::PromptResponse>>;
+
+fn block(title: &str, reply: &str) -> String {
+    format!("From sub-thread \"{title}\":\n\n{reply}")
+}
+
+/// Asks for the summary; the agent's turn answers `reply` and waits for the returned sender.
+fn summarize(rig: &mut Rig, child: &Conv, reply: &str, cx_label: &str) -> Finish {
+    let finish = rig.connection.defer_next_prompt_response();
+    child.view.update_in(&mut rig.cx, |view, window, cx| {
+        view.summarize_and_send(window, cx)
+    });
+    rig.cx.run_until_parked();
+    // A deferred stub turn streams none of the queued updates, so the reply is streamed here.
+    if !reply.is_empty() {
+        child
+            .thread
+            .update(&mut rig.cx, |thread, cx| {
+                thread.handle_session_update(
+                    acp_v1::SessionUpdate::AgentMessageChunk(acp_v1::ContentChunk::new(
+                        reply.to_string().into(),
+                    )),
+                    cx,
+                )
+            })
+            .expect("the reply chunk applies");
+        rig.cx.run_until_parked();
+    }
+    assert!(
+        child.view.read_with(&rig.cx, |view, _| view
+            .send_to_parent
+            .pending_summary
+            .is_some()),
+        "{cx_label}: the summary is pending while its turn runs"
+    );
+    finish
+}
+
+fn stop(finish: Finish, reason: acp_v1::StopReason, cx: &mut VisualTestContext) {
+    finish
+        .send(Ok(acp_v1::PromptResponse::new(reason)))
+        .expect("the summary turn is still waiting");
+    cx.run_until_parked();
+}
+
+fn last_user_text(child: &Conv, cx: &VisualTestContext) -> Option<String> {
+    child.thread.read_with(cx, |thread, cx| {
+        thread.entries().iter().rev().find_map(|entry| match entry {
+            AgentThreadEntry::UserMessage(message) => {
+                Some(message.content.to_markdown(cx).to_string())
+            }
+            _ => None,
+        })
+    })
+}
+
+fn linked_pair(rig: &mut Rig) -> (Conv, Conv) {
+    let parent = open_conversation(rig, "Parent", "parent answer");
+    let child = open_conversation(rig, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    (parent, child)
+}
+
+// ── Hostile: a later turn (R3.6) ───────────────────────────────────────────
+
+#[gpui::test]
+async fn summarize_and_send_a_later_turn_delivers_nothing_more(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let (parent, child) = linked_pair(&mut rig);
+    let finish = summarize(&mut rig, &child, "the summary", "first");
+    stop(finish, acp_v1::StopReason::EndTurn, &mut rig.cx);
+    let delivered = block("Child", "the summary");
+    assert_eq!(
+        composer_text(&parent.view, &rig.cx),
+        delivered,
+        "precondition: delivered once"
+    );
+
+    // The operator keeps talking in the sub-thread; that turn ends normally too.
+    send_text(&mut rig, &child.view, "one more thing", "a later answer");
+
+    assert_eq!(
+        composer_text(&parent.view, &rig.cx),
+        delivered,
+        "a turn Summarize and Send did not start delivers nothing"
+    );
+}
+
+// ── Hostile: the summary turn does not finish (R3.3) ───────────────────────
+
+#[gpui::test]
+async fn summarize_and_send_cancelled_turn_delivers_nothing_and_says_so(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let (parent, child) = linked_pair(&mut rig);
+    let finish = summarize(&mut rig, &child, "half a summary", "cancelled");
+
+    stop(finish, acp_v1::StopReason::Cancelled, &mut rig.cx);
+
+    assert_eq!(composer_text(&parent.view, &rig.cx), "", "nothing inserted");
+    assert_eq!(notice_of(&child.view, &rig.cx).as_deref(), Some(NOT_SENT));
+    assert!(
+        child.view.read_with(&rig.cx, |view, _| view
+            .send_to_parent
+            .pending_summary
+            .is_none()),
+        "the pending summary is cleared"
+    );
+}
+
+#[gpui::test]
+async fn summarize_and_send_refused_turn_delivers_nothing_and_says_so(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let (parent, child) = linked_pair(&mut rig);
+    let finish = summarize(&mut rig, &child, "", "refused");
+
+    stop(finish, acp_v1::StopReason::Refusal, &mut rig.cx);
+
+    assert_eq!(composer_text(&parent.view, &rig.cx), "");
+    assert_eq!(notice_of(&child.view, &rig.cx).as_deref(), Some(NOT_SENT));
+}
+
+#[gpui::test]
+async fn summarize_and_send_failed_turn_delivers_nothing_and_says_so(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let (parent, child) = linked_pair(&mut rig);
+    let finish = summarize(&mut rig, &child, "", "failed");
+    capture_logs();
+
+    finish
+        .send(Err(anyhow::anyhow!("the agent fell over")))
+        .expect("the summary turn is still waiting");
+    rig.cx.run_until_parked();
+
+    assert_eq!(composer_text(&parent.view, &rig.cx), "");
+    assert_eq!(notice_of(&child.view, &rig.cx).as_deref(), Some(NOT_SENT));
+    let lines = all_lines_lowercased();
+    assert!(
+        lines.iter().any(|line| line.contains("zeo_sub_thread")
+            && line.contains("summary_failed")
+            && line.contains(&child.session.to_string().to_lowercase())),
+        "the failure is logged with the ids: {lines:?}"
+    );
+}
+
+// ── Hostile: the view is closed mid-summary (R3.4) ─────────────────────────
+
+#[gpui::test]
+async fn summarize_and_send_closed_view_delivers_nothing(cx: &mut TestAppContext) {
+    init(cx);
+    let mut a = rig(cx).await;
+    a.fs.insert_tree("/side", json!({ "file.txt": "" })).await;
+    let mut b = window_over(&a.fs.clone(), "/side", cx).await;
+    b.connection = a.connection.clone();
+    let parent = open_conversation(&mut a, "Parent", "parent answer");
+    let child = open_conversation(&mut b, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut b.cx);
+    let finish = summarize(&mut b, &child, "too late", "closed");
+
+    // The sub-thread's window, and with it its view, goes away before the turn ends.
+    let window = b.window;
+    drop(child);
+    drop(b);
+    cx.update(|cx| {
+        window
+            .update(cx, |_, window, _| window.remove_window())
+            .expect("the window is still there")
+    });
+    cx.run_until_parked();
+    // The agent may still answer; whether anyone listens is not the point.
+    let _late = finish.send(Ok(acp_v1::PromptResponse::new(acp_v1::StopReason::EndTurn)));
+    cx.run_until_parked();
+
+    assert_eq!(
+        composer_text(&parent.view, &a.cx),
+        "",
+        "nothing is delivered"
+    );
+}
+
+// ── Benign: the visible request and the delivery (R3.1, R3.2) ──────────────
+
+#[gpui::test]
+async fn summarize_and_send_sends_the_request_visibly_then_delivers_the_summary(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let (parent, child) = linked_pair(&mut rig);
+    let sent_before = user_message_count(&child.thread, &rig.cx);
+    let parent_sent = user_message_count(&parent.thread, &rig.cx);
+    capture_logs();
+
+    let finish = summarize(&mut rig, &child, "Quokka summary", "happy");
+    assert_eq!(
+        user_message_count(&child.thread, &rig.cx),
+        sent_before + 1,
+        "exactly one visible message starts one turn"
+    );
+    assert_eq!(
+        last_user_text(&child, &rig.cx).as_deref().map(str::trim),
+        Some(SUMMARY_REQUEST)
+    );
+    assert_eq!(
+        composer_text(&parent.view, &rig.cx),
+        "",
+        "nothing before the turn ends"
+    );
+
+    stop(finish, acp_v1::StopReason::EndTurn, &mut rig.cx);
+
+    assert_eq!(
+        composer_text(&parent.view, &rig.cx),
+        block("Child", "Quokka summary")
+    );
+    assert_eq!(
+        user_message_count(&parent.thread, &rig.cx),
+        parent_sent,
+        "not sent in the parent"
+    );
+    let lines = all_lines_lowercased();
+    assert!(
+        lines.iter().any(|line| line.contains("zeo_sub_thread")
+            && line.contains("summary_requested")
+            && line.contains(&child.session.to_string().to_lowercase())),
+        "the request is logged with the ids: {lines:?}"
+    );
+    assert!(
+        lines
+            .iter()
+            .all(|line| !line.contains("summarize this sub-thread")
+                && !line.contains("quokka summary")),
+        "neither the request nor the summary text reaches a log"
+    );
+}
diff --git a/crates/agent_ui/src/sub_thread_send/summary.rs b/crates/agent_ui/src/sub_thread_send/summary.rs
new file mode 100644
index 0000000..e270eff
--- /dev/null
+++ b/crates/agent_ui/src/sub_thread_send/summary.rs
@@ -0,0 +1,195 @@
+//! Story 040, Summarize and Send: the sub-thread is asked for a summary in a visible turn, and
+//! only that turn's clean end delivers it (design.md, SendToParentControl; R3).
+
+use acp_thread::{AcpThread, AcpThreadEvent, AgentThreadEntry, SubmissionId, SubmissionState};
+use agent_client_protocol::v2::schema as acp_v2;
+use gpui::SharedString;
+use gpui::{Context, Task, Window};
+
+use super::{
+    ParentLink, PendingSummary, SUMMARY_REQUEST, availability, compose_block, deliver_to_parent,
+    last_reply_text, parent_link,
+};
+use crate::conversation_view::ThreadView;
+
+const NOT_SENT: &str = "Summary not sent: the reply did not finish";
+
+/// Where the thread stands against the summary's own turn.
+enum SummaryTurn {
+    /// The request is not in the thread yet, or a newer user message is not the latest one:
+    /// the turn that ended is not the summary's (R3.7).
+    NotYet,
+    /// The latest user message is the request: the turn that ended is the summary's.
+    Current,
+    /// A user message came after the request: its turn ended without a `Stopped` of its own
+    /// (legacy transport) and will never deliver.
+    Passed,
+}
+
+fn is_summary_request(content: &[acp_v2::ContentBlock]) -> bool {
+    content.iter().any(
+        |block| matches!(block, acp_v2::ContentBlock::Text(text) if text.text == SUMMARY_REQUEST),
+    )
+}
+
+/// The agent's refusal of the summary request itself. On the reported-activity transport it is
+/// only a settled submission: no `Error` and no turn follow, so nothing else would clear the
+/// summary (R3.3). The legacy transport also ends that turn with `Error`, which stays its path.
+fn rejected_summary_request(thread: &AcpThread, id: SubmissionId) -> Option<SharedString> {
+    if !thread.uses_reported_activity() {
+        return None;
+    }
+    let record = thread.submission(id)?;
+    match &record.state {
+        SubmissionState::Failed(error) if is_summary_request(&record.content) => {
+            Some(error.clone())
+        }
+        _ => None,
+    }
+}
+
+fn summary_turn(thread: &AcpThread, pending: &PendingSummary) -> SummaryTurn {
+    let user_messages = thread
+        .entries()
+        .iter()
+        .enumerate()
+        .skip(pending.entries_before)
+        .filter_map(|(index, entry)| match entry {
+            AgentThreadEntry::UserMessage(message) => Some((index, message)),
+            _ => None,
+        });
+    let mut request = None;
+    let mut latest = None;
+    for (index, message) in user_messages {
+        let is_request = is_summary_request(message.content.source_blocks());
+        if is_request && request.is_none() {
+            request = Some(index);
+        }
+        latest = Some(index);
+    }
+    match (request, latest) {
+        (Some(request), Some(latest)) if request == latest => SummaryTurn::Current,
+        (Some(_), Some(_)) => SummaryTurn::Passed,
+        _ => SummaryTurn::NotYet,
+    }
+}
+
+impl ThreadView {
+    /// Submits `SUMMARY_REQUEST` as a turn of this sub-thread, leaving its composer and draft as
+    /// they are (`send_content`), and waits for that turn to end (R3.1).
+    pub(crate) fn summarize_and_send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.send_to_parent.notice = None;
+        cx.notify();
+        let link = parent_link(self.root_thread_id, cx);
+        if !matches!(link, ParentLink::Live { .. }) {
+            return;
+        }
+        let thread = self.thread.read(cx);
+        // A composer send still resolving would race the request, and the user's own turn would
+        // take the generation stored below.
+        if self.is_loading_contents
+            || availability(&link, thread, &self.send_to_parent)
+                .summarize
+                .is_err()
+        {
+            return;
+        }
+        // The thread is idle (availability says so), so the turn this starts is the next
+        // activity generation at the earliest (R3.6).
+        let activity_generation = thread.activity_generation() + 1;
+        self.send_to_parent.pending_summary = Some(PendingSummary {
+            activity_generation,
+            entries_before: thread.entries().len(),
+        });
+        let session = self.session_id.clone();
+        log::info!("zeo_sub_thread event=summary_requested sub_session={session}");
+        let request = acp_v2::ContentBlock::Text(acp_v2::TextContent::new(SUMMARY_REQUEST));
+        // Upstream's own send of a ready message: it never reads nor clears the composer. A
+        // turn that fails ends with `AcpThreadEvent::Error`, and a request the agent rejects
+        // settles its submission `Failed`; either clears the summary (R3.3).
+        self.send_content(
+            Task::ready(Ok(Some((vec![request], Vec::new())))),
+            false,
+            window,
+            cx,
+        );
+    }
+
+    /// The end of a turn, or a removal of entries, forwarded by the conversation view. Delivers
+    /// the summary on a clean end of the summary's own turn (R3.2); any other end of it says so
+    /// (R3.3); another turn's end is not this feature's business (R3.6, R3.7).
+    pub(crate) fn on_sub_thread_turn_event(
+        &mut self,
+        event: &AcpThreadEvent,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(pending) = self.send_to_parent.pending_summary.as_ref() else {
+            return;
+        };
+        if let AcpThreadEvent::SubmissionUpdated(id) = event {
+            if let Some(error) = rejected_summary_request(self.thread.read(cx), *id) {
+                self.send_to_parent.pending_summary = None;
+                log::warn!(
+                    "zeo_sub_thread event=summary_failed sub_session={} reason=submit_rejected",
+                    self.session_id
+                );
+                self.send_to_parent.notice = Some(format!("Summary not sent: {error}").into());
+                cx.notify();
+            }
+            return;
+        }
+        let turn = summary_turn(self.thread.read(cx), pending);
+        let finished_cleanly = match (event, turn) {
+            // Not bound to the request's entry: a refused turn is truncated from its user
+            // message before this event, so the request is no longer there to find (R3.3).
+            (AcpThreadEvent::Error | AcpThreadEvent::Refusal, _) => false,
+            // The request's entry was removed — a rewind, or a refusal truncating the turn with
+            // no `Refusal` event (native agent): its turn can no longer deliver (R3.3).
+            (AcpThreadEvent::EntriesRemoved(range), SummaryTurn::NotYet)
+                if range.end > pending.entries_before =>
+            {
+                false
+            }
+            (_, SummaryTurn::NotYet) => return,
+            (
+                AcpThreadEvent::Stopped {
+                    activity_generation,
+                    ..
+                },
+                _,
+            ) if *activity_generation < pending.activity_generation => return,
+            (AcpThreadEvent::Stopped { stop_reason, .. }, SummaryTurn::Current) => {
+                *stop_reason == Some(acp_v2::StopReason::EndTurn)
+            }
+            (AcpThreadEvent::Stopped { .. }, SummaryTurn::Passed) => false,
+            _ => return,
+        };
+        self.send_to_parent.pending_summary = None;
+        cx.notify();
+        let session = self.session_id.clone();
+        let parent = match parent_link(self.root_thread_id, cx) {
+            ParentLink::Live { parent_session, .. } => Some(parent_session),
+            _ => None,
+        };
+        let thread = self.thread.read(cx);
+        let reply = finished_cleanly
+            .then(|| last_reply_text(thread.entries(), cx))
+            .flatten();
+        let (Some(parent_session), Some(reply)) = (parent, reply) else {
+            log::warn!(
+                "zeo_sub_thread event=summary_failed sub_session={session} reason=turn_not_finished"
+            );
+            self.send_to_parent.notice = Some(NOT_SENT.into());
+            return;
+        };
+        let title = thread
+            .title()
+            .unwrap_or_else(|| crate::DEFAULT_THREAD_TITLE.into());
+        let block = compose_block(&title, &reply);
+        // The same in-flight guard as Send Last Reply: a pick meanwhile would open the parent
+        // twice (R2.6).
+        self.send_to_parent.delivering = true;
+        deliver_to_parent(parent_session, block, cx.entity().downgrade(), window, cx);
+    }
+}
diff --git a/crates/agent_ui/src/sub_thread_send/summary_binding_tests.rs b/crates/agent_ui/src/sub_thread_send/summary_binding_tests.rs
new file mode 100644
index 0000000..2829156
--- /dev/null
+++ b/crates/agent_ui/src/sub_thread_send/summary_binding_tests.rs
@@ -0,0 +1,320 @@
+// Story 040, task 11.2 (refine v4, review 7.x M4 and L1) — the summary is bound to the turn its
+// own request started, and Summarize waits while the thread's state is unconfirmed (R3.3, R3.6,
+// R3.7, R3.8; Q4, Q11). Module path: `sub_thread_send::summary_binding_tests`. Wired in
+// `src/sub_thread_send.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "sub_thread_send/summary_binding_tests.rs"]
+//     mod summary_binding_tests;
+//
+// Validation filter: `cargo test -p agent_ui -- summarize_and_send`. Fixtures:
+// `crate::sub_thread_test_support` and `super::receipt_test_support` (setup only).
+//
+// Every case runs the sub-thread on the reported-activity transport, the one M4 is about: the
+// generation moves when the AGENT reports Running, not when Zeo sends, so a turn the agent
+// starts on its own after the summary's submit takes the generation the summary was waiting
+// for. How the summary binds to its submission (the prompt's own result, or the stopped turn's
+// latest user message being the request) is left to the implementation: these tests assert only
+// what reaches the parent's composer, the notice, whether the summary is still pending, and
+// what the agent was sent.
+//
+// Hostile halves first:
+//   - wrongly COLLAPSED: a turn the agent starts on its own, ending with end of turn while the
+//     summary is pending, is taken for the summary and its reply delivered (M4) — must insert
+//     nothing and keep waiting; the same turn ending cancelled must not fail the summary either;
+//   - wrongly SPLIT: once the agent's own turn has taken that generation, the summary's own turn
+//     carries a later one — it must still deliver, exactly once, and nothing after it does;
+//   - wrongly ALLOWED: Summarize picked while the thread is Unconfirmed submits nothing (L1);
+//     its converse, Send Last Reply in the same state, still delivers;
+//   - then the benign control: the summary's turn alone on this transport delivers.
+
+use agent_client_protocol::v2::schema as acp_v2;
+use futures::channel::oneshot;
+use gpui::{Modifiers, TestAppContext, VisualTestContext};
+
+use super::SUMMARY_REQUEST;
+use super::receipt_test_support::*;
+use crate::sub_thread_test_support::*;
+
+const NOT_SENT: &str = "Summary not sent: the reply did not finish";
+const CONTROL: &str = "send-to-parent";
+const LAST_REPLY: &str = "send-to-parent-last-reply";
+const SUMMARIZE: &str = "send-to-parent-summarize";
+/// The receipt id the agent gives the summary request; its echo carries it back.
+const SUMMARY_ECHO: &str = "summary-echo";
+
+fn block(title: &str, reply: &str) -> String {
+    format!("From sub-thread \"{title}\":\n\n{reply}")
+}
+
+fn pending(child: &Conv, cx: &VisualTestContext) -> bool {
+    child
+        .view
+        .read_with(cx, |view, _| view.send_to_parent.pending_summary.is_some())
+}
+
+fn generation(child: &Conv, cx: &VisualTestContext) -> u64 {
+    child
+        .thread
+        .read_with(cx, |thread, _| thread.activity_generation())
+}
+
+/// A legacy parent and a confirmed-idle child on the receipt transport, linked.
+fn receipt_pair(rig: &mut Rig) -> (Conv, Conv, acp_thread::StubAgentConnection) {
+    let parent = open_conversation(rig, "Parent", "parent answer");
+    let (child, connection) = open_receipt_conversation(rig, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    (parent, child, connection)
+}
+
+/// Picks Summarize and Send; the agent accepts the request (receipt) but has not taken it up
+/// yet — no echo, no Running.
+fn summarize_accepted(rig: &mut Rig, child: &Conv, connection: &acp_thread::StubAgentConnection) {
+    let receipt: oneshot::Sender<anyhow::Result<acp_v2::PromptResponse>> =
+        connection.defer_next_receipt_response();
+    child.view.update_in(&mut rig.cx, |view, window, cx| {
+        view.summarize_and_send(window, cx)
+    });
+    rig.cx.run_until_parked();
+    assert_eq!(
+        connection.take_receipt_prompt(),
+        Some(vec![SUMMARY_REQUEST.into()]),
+        "precondition: the summary request went to the agent"
+    );
+    receipt
+        .send(Ok(acp_v2::PromptResponse::new(SUMMARY_ECHO)))
+        .expect("the summary request waits for its receipt");
+    rig.cx.run_until_parked();
+    assert!(
+        pending(child, &rig.cx),
+        "precondition: the summary is pending"
+    );
+}
+
+/// The agent takes up the summary request: echo, Running, `reply`, Idle with end of turn.
+fn summary_turn(child: &Conv, reply: &str, cx: &mut VisualTestContext) {
+    echo_user_message(&child.thread, SUMMARY_ECHO, SUMMARY_REQUEST, cx);
+    report_running(&child.thread, cx);
+    agent_chunk(&child.thread, reply, cx);
+    report_idle(&child.thread, Some(acp_v2::StopReason::EndTurn), cx);
+}
+
+fn click(selector: &'static str, cx: &mut VisualTestContext) {
+    let bounds = cx
+        .debug_bounds(selector)
+        .unwrap_or_else(|| panic!("{selector} is painted"));
+    cx.simulate_click(bounds.center(), Modifiers::none());
+    cx.run_until_parked();
+    draw(cx);
+}
+
+// ── Hostile, wrongly collapsed: the agent's own turn is not the summary (R3.7) ──
+
+#[gpui::test]
+async fn summarize_and_send_agent_initiated_turn_while_pending_inserts_nothing(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let (parent, child, connection) = receipt_pair(&mut rig);
+    let before = generation(&child, &rig.cx);
+    summarize_accepted(&mut rig, &child, &connection);
+
+    // Before taking up the request, the agent runs a turn of its own and ends it cleanly.
+    agent_initiated_turn(
+        &child.thread,
+        "a background continuation",
+        acp_v2::StopReason::EndTurn,
+        &mut rig.cx,
+    );
+    assert_eq!(
+        generation(&child, &rig.cx),
+        before + 1,
+        "precondition: the agent's own turn took the next generation, the one the summary's turn \
+         would have had"
+    );
+
+    assert_eq!(
+        composer_text(&parent.view, &rig.cx),
+        "",
+        "a turn the agent started on its own delivers nothing"
+    );
+    assert!(
+        pending(&child, &rig.cx),
+        "the summary keeps waiting for its own turn"
+    );
+    assert_eq!(notice_of(&child.view, &rig.cx), None, "and nothing failed");
+}
+
+#[gpui::test]
+async fn summarize_and_send_agent_initiated_turn_cancelled_while_pending_keeps_waiting(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let (parent, child, connection) = receipt_pair(&mut rig);
+    summarize_accepted(&mut rig, &child, &connection);
+
+    agent_initiated_turn(
+        &child.thread,
+        "half a continuation",
+        acp_v2::StopReason::Cancelled,
+        &mut rig.cx,
+    );
+
+    assert_eq!(composer_text(&parent.view, &rig.cx), "");
+    assert_eq!(
+        notice_of(&child.view, &rig.cx),
+        None,
+        "another turn's end is not the summary's failure (R3.3 is about the summary's own turn)"
+    );
+    assert!(pending(&child, &rig.cx), "the summary keeps waiting");
+}
+
+// ── Hostile, wrongly split: the summary's own turn still delivers (R3.6, R3.7) ──
+
+#[gpui::test]
+async fn summarize_and_send_its_own_turn_delivers_once_after_an_agent_initiated_turn(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let (parent, child, connection) = receipt_pair(&mut rig);
+    summarize_accepted(&mut rig, &child, &connection);
+    agent_initiated_turn(
+        &child.thread,
+        "a background continuation",
+        acp_v2::StopReason::EndTurn,
+        &mut rig.cx,
+    );
+
+    summary_turn(&child, "the summary", &mut rig.cx);
+
+    let delivered = block("Child", "the summary");
+    assert_eq!(
+        composer_text(&parent.view, &rig.cx),
+        delivered,
+        "the summary's own turn delivers its reply, once"
+    );
+    assert!(!pending(&child, &rig.cx), "nothing is pending any more");
+    assert_eq!(notice_of(&child.view, &rig.cx), None);
+
+    // After delivery, neither another turn of the agent's own nor a repeated Idle adds anything.
+    agent_initiated_turn(
+        &child.thread,
+        "yet another continuation",
+        acp_v2::StopReason::EndTurn,
+        &mut rig.cx,
+    );
+    report_idle(
+        &child.thread,
+        Some(acp_v2::StopReason::EndTurn),
+        &mut rig.cx,
+    );
+    assert_eq!(
+        composer_text(&parent.view, &rig.cx),
+        delivered,
+        "delivered exactly once"
+    );
+}
+
+// ── Hostile, wrongly allowed: Summarize while Unconfirmed (R3.8) ───────────
+
+#[gpui::test]
+async fn summarize_and_send_disabled_while_unconfirmed(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let (child, connection) = open_unconfirmed_receipt_conversation(&mut rig, "a loaded reply");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    let sent_before = user_message_count(&child.thread, &rig.cx);
+    // Were the request submitted, the agent would accept it: the receipt is ready.
+    let _receipt = connection.defer_next_receipt_response();
+    draw(&mut rig.cx);
+
+    click(CONTROL, &mut rig.cx);
+    assert!(
+        painted(SUMMARIZE, &mut rig.cx),
+        "precondition: the choice is listed"
+    );
+    click(SUMMARIZE, &mut rig.cx);
+    // The guard holds on the direct path too, not only in the menu.
+    child.view.update_in(&mut rig.cx, |view, window, cx| {
+        view.summarize_and_send(window, cx)
+    });
+    rig.cx.run_until_parked();
+
+    assert_eq!(
+        connection.take_receipt_prompt(),
+        None,
+        "no summary request reaches the agent while the thread's state is unconfirmed"
+    );
+    assert_eq!(user_message_count(&child.thread, &rig.cx), sent_before);
+    assert!(!pending(&child, &rig.cx), "no summary is pending");
+    assert_eq!(composer_text(&parent.view, &rig.cx), "");
+
+    // Converse: Send Last Reply is not held back by Unconfirmed.
+    draw(&mut rig.cx);
+    if !painted(LAST_REPLY, &mut rig.cx) {
+        click(CONTROL, &mut rig.cx);
+    }
+    click(LAST_REPLY, &mut rig.cx);
+    assert_eq!(
+        composer_text(&parent.view, &rig.cx),
+        block("Child", "a loaded reply"),
+        "Send Last Reply still delivers while Unconfirmed"
+    );
+}
+
+#[gpui::test]
+async fn summarize_and_send_unconfirmed_then_first_report_running_delivers_nothing(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let (child, connection) = open_unconfirmed_receipt_conversation(&mut rig, "a loaded reply");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    let _receipt = connection.defer_next_receipt_response();
+    child.view.update_in(&mut rig.cx, |view, window, cx| {
+        view.summarize_and_send(window, cx)
+    });
+    rig.cx.run_until_parked();
+
+    // L1: the first report belongs to a turn from before the load, and it ends cleanly.
+    agent_initiated_turn(
+        &child.thread,
+        "the end of an earlier turn",
+        acp_v2::StopReason::EndTurn,
+        &mut rig.cx,
+    );
+
+    assert_eq!(
+        composer_text(&parent.view, &rig.cx),
+        "",
+        "a turn from before the load is never delivered as a summary"
+    );
+    assert_ne!(
+        notice_of(&child.view, &rig.cx).as_deref(),
+        Some(NOT_SENT),
+        "and no summary was waiting to fail"
+    );
+}
+
+// ── Benign control: the summary's turn alone on this transport ─────────────
+
+#[gpui::test]
+async fn summarize_and_send_on_the_reported_activity_transport_delivers(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let (parent, child, connection) = receipt_pair(&mut rig);
+    summarize_accepted(&mut rig, &child, &connection);
+
+    summary_turn(&child, "the summary", &mut rig.cx);
+
+    assert_eq!(
+        composer_text(&parent.view, &rig.cx),
+        block("Child", "the summary")
+    );
+    assert!(!pending(&child, &rig.cx));
+}
diff --git a/crates/agent_ui/src/sub_thread_send/summary_rejected_submit_tests.rs b/crates/agent_ui/src/sub_thread_send/summary_rejected_submit_tests.rs
new file mode 100644
index 0000000..3d2500e
--- /dev/null
+++ b/crates/agent_ui/src/sub_thread_send/summary_rejected_submit_tests.rs
@@ -0,0 +1,72 @@
+// Story 040, task 12.2 (validation fix round 1, audit finding "Happy/error path symmetry"):
+// on the reported-activity transport a Summarize submit the agent rejects settles `Failed` and
+// emits only `SubmissionUpdated` — no `Error`, no turn — so the summary must be cleared there,
+// with the failure as the notice, instead of leaving both choices on "Wait for the turn to end"
+// with nothing running (R3.3, R3.5; tasks.md 7.2 ToDo 1).
+
+use agent_client_protocol::v2::schema as acp_v2;
+use futures::channel::oneshot;
+use gpui::{SharedString, TestAppContext};
+
+use super::receipt_test_support::*;
+use super::{Availability, SUMMARY_REQUEST, availability, parent_link};
+use crate::sub_thread_test_support::*;
+
+#[gpui::test]
+async fn summarize_and_send_rejected_submit_on_the_receipt_transport_clears_the_summary(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let (child, connection) = open_receipt_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+
+    let receipt: oneshot::Sender<anyhow::Result<acp_v2::PromptResponse>> =
+        connection.defer_next_receipt_response();
+    child.view.update_in(&mut rig.cx, |view, window, cx| {
+        view.summarize_and_send(window, cx)
+    });
+    rig.cx.run_until_parked();
+    assert_eq!(
+        connection.take_receipt_prompt(),
+        Some(vec![SUMMARY_REQUEST.into()]),
+        "precondition: the summary request went to the agent"
+    );
+    let pending = |rig: &Rig| {
+        child.view.read_with(&rig.cx, |view, _| {
+            view.send_to_parent.pending_summary.is_some()
+        })
+    };
+    assert!(pending(&rig), "precondition: the summary is pending");
+
+    receipt
+        .send(Err(anyhow::anyhow!("the agent refused the message")))
+        .expect("the summary request waits for its receipt");
+    rig.cx.run_until_parked();
+
+    assert!(!pending(&rig), "a rejected submit no longer waits");
+    let notice = notice_of(&child.view, &rig.cx);
+    assert_eq!(
+        notice.as_deref(),
+        Some("Summary not sent: the agent refused the message"),
+        "the failure is shown under the control"
+    );
+    let choices = child.view.read_with(&rig.cx, |view, cx| {
+        let link = parent_link(view.root_thread_id, cx);
+        let Availability {
+            last_reply,
+            summarize,
+        } = availability(&link, view.thread.read(cx), &view.send_to_parent);
+        (
+            last_reply.map_err(|tooltip: SharedString| tooltip.to_string()),
+            summarize.map_err(|tooltip: SharedString| tooltip.to_string()),
+        )
+    });
+    assert_eq!(choices, (Ok(()), Ok(())), "both choices are offered again");
+    assert_eq!(
+        composer_text(&parent.view, &rig.cx),
+        "",
+        "nothing delivered"
+    );
+}
diff --git a/crates/agent_ui/src/sub_thread_send/summary_removed_entries_tests.rs b/crates/agent_ui/src/sub_thread_send/summary_removed_entries_tests.rs
new file mode 100644
index 0000000..f7918b1
--- /dev/null
+++ b/crates/agent_ui/src/sub_thread_send/summary_removed_entries_tests.rs
@@ -0,0 +1,60 @@
+// Story 040, task 11.2 (tech review 11.x M1): the summary's request entry removed while its turn
+// runs — here by a rewind of the earlier message — clears the pending summary with its notice,
+// instead of leaving both choices on "Wait for the turn to end" for good (R3.3).
+
+use acp_thread::AgentThreadEntry;
+use agent_client_protocol::v1::schema as acp_v1;
+use gpui::TestAppContext;
+
+use crate::sub_thread_test_support::*;
+
+const NOT_SENT: &str = "Summary not sent: the reply did not finish";
+
+#[gpui::test]
+async fn summarize_and_send_request_removed_by_a_rewind_clears_with_the_notice(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+
+    let finish = rig.connection.defer_next_prompt_response();
+    child.view.update_in(&mut rig.cx, |view, window, cx| {
+        view.summarize_and_send(window, cx)
+    });
+    rig.cx.run_until_parked();
+    let pending = |rig: &Rig| {
+        child.view.read_with(&rig.cx, |view, _| {
+            view.send_to_parent.pending_summary.is_some()
+        })
+    };
+    assert!(pending(&rig), "precondition: the summary is pending");
+
+    // The user rewinds to the first message while the summary's turn still runs.
+    let first = child.thread.read_with(&rig.cx, |thread, _| {
+        thread.entries().iter().find_map(|entry| match entry {
+            AgentThreadEntry::UserMessage(message) => message.client_id.clone(),
+            _ => None,
+        })
+    });
+    let first = first.expect("precondition: the first message has a client id");
+    child
+        .thread
+        .update(&mut rig.cx, |thread, cx| thread.rewind(first, cx))
+        .await
+        .expect("the rewind applies");
+    rig.cx.run_until_parked();
+
+    assert!(!pending(&rig), "the summary no longer waits");
+    assert_eq!(notice_of(&child.view, &rig.cx).as_deref(), Some(NOT_SENT));
+
+    // The turn's late end delivers nothing.
+    finish
+        .send(Ok(acp_v1::PromptResponse::new(acp_v1::StopReason::EndTurn)))
+        // The rewind may already have dropped the turn's receiver; either way nothing is sent.
+        .ok();
+    rig.cx.run_until_parked();
+    assert_eq!(composer_text(&parent.view, &rig.cx), "");
+}
diff --git a/crates/agent_ui/src/sub_thread_send/unconfirmed_availability_tests.rs b/crates/agent_ui/src/sub_thread_send/unconfirmed_availability_tests.rs
new file mode 100644
index 0000000..8520cf1
--- /dev/null
+++ b/crates/agent_ui/src/sub_thread_send/unconfirmed_availability_tests.rs
@@ -0,0 +1,185 @@
+// Story 040, task 11.2 (refine v4, review 7.x L1) — `availability` rows for a thread whose state
+// is not yet known (R3.8; Q5). Module path: `sub_thread_send::unconfirmed_availability_tests`.
+// Wired in `src/sub_thread_send.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "sub_thread_send/unconfirmed_availability_tests.rs"]
+//     mod unconfirmed_availability_tests;
+//
+// Validation filter: `cargo test -p agent_ui -- sub_thread_send`. Fixtures:
+// `crate::sub_thread_test_support` and `super::receipt_test_support` (task 11.2's setup-only
+// helpers for the reported-activity transport).
+//
+// Contract (design.md v4 §SendToParentControl, `availability` order of checks): Deleted → "The
+// parent thread was deleted" (both); generating or a pending summary → "Wait for the turn to end"
+// (both); delivering → "Sending to the parent thread" (both, task 11.3); `ThreadStatus::Unconfirmed`
+// → "Wait for the turn to end" for Summarize ONLY — Send Last Reply keeps its own rules, as
+// upstream's Copy does; no agent message → "No reply to send yet" (last reply only).
+// Only `SendToParentState::default()` is built here, so task 11.3's `delivering` field and
+// any change to `PendingSummary` leave this file alone.
+//
+// Hostile halves first:
+//   - wrongly ALLOWED: an Unconfirmed thread with a reply offers Summarize (L1 — its first active
+//     report may belong to a turn from before the load, and that reply would be delivered);
+//   - wrongly DISABLED: the same Unconfirmed thread must still offer Send Last Reply; and a
+//     reported-activity thread whose first report was Idle (confirmed, still generation 0) is not
+//     Unconfirmed and offers both;
+//   - order: a deleted parent still wins over Unconfirmed (the pending-summary row is
+//     helpers_tests.rs's, unchanged);
+//   - then the plain row with no reply.
+
+use acp_thread::{AcpThread, ThreadStatus};
+use gpui::{Entity, SharedString, TestAppContext, VisualTestContext};
+
+use super::receipt_test_support::*;
+use super::{Availability, ParentLink, SendToParentState, availability};
+use crate::sub_thread_test_support::*;
+
+const DELETED: &str = "The parent thread was deleted";
+const WAIT: &str = "Wait for the turn to end";
+const NO_REPLY: &str = "No reply to send yet";
+
+fn avail(
+    link: &ParentLink,
+    thread: &Entity<AcpThread>,
+    state: &SendToParentState,
+    cx: &VisualTestContext,
+) -> (Result<(), String>, Result<(), String>) {
+    thread.read_with(cx, |thread, _| {
+        let Availability {
+            last_reply,
+            summarize,
+        } = availability(link, thread, state);
+        (
+            last_reply.map_err(|tooltip: SharedString| tooltip.to_string()),
+            summarize.map_err(|tooltip: SharedString| tooltip.to_string()),
+        )
+    })
+}
+
+fn live(parent: &Conv) -> ParentLink {
+    ParentLink::Live {
+        parent: parent.thread_id,
+        parent_session: parent.session.clone(),
+    }
+}
+
+fn status(thread: &Entity<AcpThread>, cx: &VisualTestContext) -> ThreadStatus {
+    thread.read_with(cx, |thread, _| thread.status())
+}
+
+// ── Hostile: Unconfirmed must not offer Summarize (R3.8) ───────────────────
+
+#[gpui::test]
+async fn sub_thread_send_availability_unconfirmed_disables_summarize_only(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let (child, _connection) = open_unconfirmed_receipt_conversation(&mut rig, "a loaded reply");
+    assert_eq!(
+        status(&child.thread, &rig.cx),
+        ThreadStatus::Unconfirmed,
+        "precondition: no state reported since the load"
+    );
+
+    let (last_reply, summarize) = avail(
+        &live(&parent),
+        &child.thread,
+        &SendToParentState::default(),
+        &rig.cx,
+    );
+    assert_eq!(
+        summarize,
+        Err(WAIT.to_string()),
+        "Summarize waits until the agent says whether a turn is running"
+    );
+    assert_eq!(
+        last_reply,
+        Ok(()),
+        "Send Last Reply stays available while Unconfirmed, as upstream's Copy does"
+    );
+}
+
+#[gpui::test]
+async fn sub_thread_send_availability_unconfirmed_without_a_reply(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let (child, _connection) = open_unconfirmed_receipt_conversation(&mut rig, "");
+
+    let (last_reply, summarize) = avail(
+        &live(&parent),
+        &child.thread,
+        &SendToParentState::default(),
+        &rig.cx,
+    );
+    assert_eq!(
+        summarize,
+        Err(WAIT.to_string()),
+        "Unconfirmed disables Summarize with or without a reply"
+    );
+    assert_eq!(
+        last_reply,
+        Err(NO_REPLY.to_string()),
+        "Send Last Reply keeps its own rule, not the Unconfirmed wait"
+    );
+}
+
+// ── Hostile converse: a confirmed idle thread is not Unconfirmed ───────────
+
+#[gpui::test]
+async fn sub_thread_send_availability_confirmed_idle_at_generation_zero_offers_both(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let (child, _connection) = open_unconfirmed_receipt_conversation(&mut rig, "a loaded reply");
+    // The agent's first report says it is idle: the state is known, and no turn ever ran.
+    report_idle(&child.thread, None, &mut rig.cx);
+    assert_eq!(
+        status(&child.thread, &rig.cx),
+        ThreadStatus::Idle,
+        "precondition"
+    );
+    assert_eq!(
+        child
+            .thread
+            .read_with(&rig.cx, |thread, _| thread.activity_generation()),
+        0,
+        "precondition: still generation 0, so generation alone cannot tell the two apart"
+    );
+
+    let (last_reply, summarize) = avail(
+        &live(&parent),
+        &child.thread,
+        &SendToParentState::default(),
+        &rig.cx,
+    );
+    assert_eq!(
+        summarize,
+        Ok(()),
+        "a confirmed idle thread offers Summarize"
+    );
+    assert_eq!(last_reply, Ok(()));
+}
+
+// ── Order: the more lasting reason still wins ──────────────────────────────
+
+#[gpui::test]
+async fn sub_thread_send_availability_deleted_parent_wins_over_unconfirmed(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let (child, _connection) = open_unconfirmed_receipt_conversation(&mut rig, "a loaded reply");
+
+    let (last_reply, summarize) = avail(
+        &ParentLink::Deleted,
+        &child.thread,
+        &SendToParentState::default(),
+        &rig.cx,
+    );
+    assert_eq!(last_reply, Err(DELETED.to_string()));
+    assert_eq!(summarize, Err(DELETED.to_string()));
+}
diff --git a/crates/agent_ui/src/sub_thread_test_support.rs b/crates/agent_ui/src/sub_thread_test_support.rs
new file mode 100644
index 0000000..18e6f10
--- /dev/null
+++ b/crates/agent_ui/src/sub_thread_test_support.rs
@@ -0,0 +1,260 @@
+// Story 040 — shared fixtures for the agent_ui tests of Send to Parent and the sub-thread
+// reporter (tasks 2.1, 4.1, 6.1, 6.2, 7.1, 7.2). Materialize with the first of those tasks, in
+// `src/agent_ui.rs`:
+//
+//     #[cfg(test)]
+//     mod sub_thread_test_support;
+//
+// A rig is one window over `/project` with a real `AgentPanel` (and so its real `TasksCard`),
+// the real `ThreadMetadataStore` and, unless a test says otherwise, the real
+// `ThreadOrganizationStore` installed BEFORE the panel exists — so the card's reporter can
+// observe it from creation, as in Zeo, where `agent_ui::init` installs it at startup.
+//
+// Threads run on one shared `StubAgentConnection`; a sub-thread link is made the way story 039
+// makes it, `ThreadOrganizationStore::set_parent(child, parent, kind)`. Contract assumed beyond
+// design.md (adjust HERE, not in each test):
+//   - `ThreadView::send_to_parent` is a `pub(crate)` field (`notice_of` reads it);
+//   - `AgentPanel::tasks_card()` returns the panel's card (as in 0037's tests).
+
+#![allow(dead_code)]
+
+use std::{cell::RefCell, path::Path, rc::Rc};
+
+use acp_thread::{AcpThread, StubAgentConnection};
+use agent_client_protocol::v1::schema as acp_v1;
+use agent_client_protocol::v2::schema as acp;
+use fs::FakeFs;
+use gpui::{
+    AppContext as _, Entity, Subscription, TestAppContext, VisualTestContext, WindowHandle,
+};
+use project::Project;
+use serde_json::json;
+use workspace::{MultiWorkspace, Workspace};
+
+use crate::conversation_view::ThreadView;
+use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore};
+use crate::thread_organization::{ForkKind, ThreadOrganizationStore};
+use crate::{AgentPanel, SubThreadActivity, TasksCard};
+
+/// Upstream's agent_ui test globals, the agent globals, and the organization store.
+pub(crate) fn init(cx: &mut TestAppContext) {
+    init_without_organization(cx);
+    cx.update(|cx| crate::thread_organization::init(cx));
+    cx.run_until_parked();
+}
+
+/// The same, with no organization store installed (`try_global` is `None`).
+pub(crate) fn init_without_organization(cx: &mut TestAppContext) {
+    crate::conversation_view::tests::init_test(cx);
+    cx.update(|cx| {
+        agent::ThreadStore::init_global(cx);
+        language_model::LanguageModelRegistry::test(cx);
+    });
+}
+
+pub(crate) struct Rig {
+    pub fs: std::sync::Arc<FakeFs>,
+    pub window: WindowHandle<MultiWorkspace>,
+    pub workspace: Entity<Workspace>,
+    pub panel: Entity<AgentPanel>,
+    pub connection: StubAgentConnection,
+    pub cx: VisualTestContext,
+}
+
+/// One conversation of the rig's panel: its root thread, its view, its ids.
+#[derive(Clone)]
+pub(crate) struct Conv {
+    pub thread: Entity<AcpThread>,
+    pub view: Entity<ThreadView>,
+    pub session: acp::SessionId,
+    pub thread_id: ThreadId,
+}
+
+/// A window over `/project` with a visible agent panel. `init` (or `init_without_organization`)
+/// must have run.
+pub(crate) async fn rig(cx: &mut TestAppContext) -> Rig {
+    let fs = FakeFs::new(cx.executor());
+    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+    fs.insert_tree("/project", json!({ "file.txt": "" })).await;
+    window_over(&fs, "/project", cx).await
+}
+
+/// Another window over `root` on the same file system (`root` must exist on `fs`), with its own
+/// agent panel. Its `connection` is a fresh stub; share one by assigning it.
+pub(crate) async fn window_over(
+    fs: &std::sync::Arc<FakeFs>,
+    root: &str,
+    cx: &mut TestAppContext,
+) -> Rig {
+    let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
+    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let workspace = window
+        .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
+        .unwrap();
+    workspace.update(cx, |workspace, _| workspace.set_random_database_id());
+    let mut vcx = VisualTestContext::from_window(window.into(), cx);
+    vcx.simulate_resize(gpui::size(gpui::px(1400.), gpui::px(900.)));
+    let panel = workspace.update_in(&mut vcx, |workspace, window, cx| {
+        let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
+        workspace.add_panel(panel.clone(), window, cx);
+        workspace.focus_panel::<AgentPanel>(window, cx);
+        panel
+    });
+    vcx.run_until_parked();
+    Rig {
+        fs: fs.clone(),
+        window,
+        workspace,
+        panel,
+        connection: StubAgentConnection::new(),
+        cx: vcx,
+    }
+}
+
+/// The agent's next turn answers `reply` and ends with end-of-turn; an empty `reply` answers
+/// nothing at all (the turn ends with no agent message).
+pub(crate) fn next_reply(connection: &StubAgentConnection, reply: &str) {
+    if reply.is_empty() {
+        connection.set_next_prompt_updates(Vec::new());
+        return;
+    }
+    connection.set_next_prompt_updates(vec![acp_v1::SessionUpdate::AgentMessageChunk(
+        acp_v1::ContentChunk::new(reply.to_string().into()),
+    )]);
+}
+
+/// Opens a new conversation on the rig's connection, sends one message answered by `reply`
+/// (so it is a persisted thread with a session), titles it `title`, and returns it. It stays
+/// held by the panel (the stub cannot load a session back, so it is never released).
+pub(crate) fn open_conversation(rig: &mut Rig, title: &str, reply: &str) -> Conv {
+    crate::test_support::open_thread_with_connection(
+        &rig.panel,
+        rig.connection.clone(),
+        &mut rig.cx,
+    );
+    next_reply(&rig.connection, reply);
+    crate::test_support::send_message(&rig.panel, &mut rig.cx);
+    rig.cx.run_until_parked();
+    let (thread, view) = rig.panel.read_with(&rig.cx, |panel, cx| {
+        (
+            panel
+                .active_agent_thread(cx)
+                .expect("a thread was just opened"),
+            panel.active_thread_view(cx).expect("its view is shown"),
+        )
+    });
+    set_title(&thread, title, &mut rig.cx);
+    let session = thread.read_with(&rig.cx, |thread, _| thread.session_id().clone());
+    let thread_id = crate::test_support::active_thread_id(&rig.panel, &rig.cx);
+    Conv {
+        thread,
+        view,
+        session,
+        thread_id,
+    }
+}
+
+pub(crate) fn set_title(thread: &Entity<AcpThread>, title: &str, cx: &mut VisualTestContext) {
+    thread
+        .update(cx, |thread, cx| {
+            thread.handle_session_update(
+                acp_v1::SessionUpdate::SessionInfoUpdate(
+                    acp_v1::SessionInfoUpdate::new().title(title),
+                ),
+                cx,
+            )
+        })
+        .expect("the title update applies");
+    cx.run_until_parked();
+}
+
+/// Records `child` as a sub-thread of `parent`, as story 039 does.
+pub(crate) fn link(child: ThreadId, parent: ThreadId, cx: &mut VisualTestContext) {
+    cx.update(|_, cx| {
+        ThreadOrganizationStore::global(cx).update(cx, |store, cx| {
+            store
+                .set_parent(child, parent, ForkKind::Clean, cx)
+                .expect("the parent link is recorded")
+        })
+    });
+    cx.run_until_parked();
+}
+
+/// Deletes a thread's metadata row, as deleting the thread from the history does. The
+/// organization store keeps a child's link to it (story 031: a dangling link).
+pub(crate) fn delete_thread(thread_id: ThreadId, cx: &mut VisualTestContext) {
+    cx.update(|_, cx| {
+        ThreadMetadataStore::global(cx).update(cx, |store, cx| store.delete(thread_id, cx))
+    });
+    cx.run_until_parked();
+}
+
+/// Types `text` into `view`'s composer and sends it; the agent answers `reply`.
+pub(crate) fn send_text(rig: &mut Rig, view: &Entity<ThreadView>, text: &str, reply: &str) {
+    next_reply(&rig.connection, reply);
+    set_composer(view, text, &mut rig.cx);
+    view.update_in(&mut rig.cx, |view, window, cx| view.send(window, cx));
+    rig.cx.run_until_parked();
+}
+
+pub(crate) fn set_composer(view: &Entity<ThreadView>, text: &str, cx: &mut VisualTestContext) {
+    let editor = view.read_with(cx, |view, _| view.message_editor.clone());
+    editor.update_in(cx, |editor, window, cx| editor.set_text(text, window, cx));
+    cx.run_until_parked();
+}
+
+pub(crate) fn composer_text(view: &Entity<ThreadView>, cx: &VisualTestContext) -> String {
+    view.read_with(cx, |view, cx| view.message_editor.read(cx).text(cx))
+}
+
+/// The user messages of `thread`, in order — what was SENT, never what sits in a composer.
+pub(crate) fn user_message_count(thread: &Entity<AcpThread>, cx: &VisualTestContext) -> usize {
+    thread.read_with(cx, |thread, _| {
+        thread
+            .entries()
+            .iter()
+            .filter(|entry| matches!(entry, acp_thread::AgentThreadEntry::UserMessage(_)))
+            .count()
+    })
+}
+
+/// The notice Send to Parent shows under its control, if any.
+pub(crate) fn notice_of(view: &Entity<ThreadView>, cx: &VisualTestContext) -> Option<String> {
+    view.read_with(cx, |view, _| {
+        view.send_to_parent
+            .notice
+            .as_ref()
+            .map(|notice| notice.to_string())
+    })
+}
+
+pub(crate) fn card(rig: &Rig) -> Entity<TasksCard> {
+    rig.panel
+        .read_with(&rig.cx, |panel, _| panel.tasks_card().clone())
+}
+
+/// Every `SubThreadActivity` `card` emits from now on, in order.
+pub(crate) fn record_activity(
+    card: &Entity<TasksCard>,
+    cx: &mut VisualTestContext,
+) -> (Rc<RefCell<Vec<SubThreadActivity>>>, Subscription) {
+    let heard = Rc::new(RefCell::new(Vec::new()));
+    let subscription = cx.update(|_, cx| {
+        let heard = heard.clone();
+        cx.subscribe(card, move |_, activity: &SubThreadActivity, _| {
+            heard.borrow_mut().push(activity.clone())
+        })
+    });
+    (heard, subscription)
+}
+
+pub(crate) fn draw(cx: &mut VisualTestContext) {
+    cx.update(|window, _| window.refresh());
+    cx.run_until_parked();
+}
+
+/// Whether the element painted under `selector` exists after the last draw.
+pub(crate) fn painted(selector: impl Into<String>, cx: &mut VisualTestContext) -> bool {
+    let selector: &'static str = Box::leak(selector.into().into_boxed_str());
+    cx.debug_bounds(selector).is_some()
+}
diff --git a/crates/agent_ui/src/tasks_card.rs b/crates/agent_ui/src/tasks_card.rs
index 12858c7..1bc2a51 100644
--- a/crates/agent_ui/src/tasks_card.rs
+++ b/crates/agent_ui/src/tasks_card.rs
@@ -17,6 +17,7 @@ use std::{
     time::{Duration, Instant, SystemTime, UNIX_EPOCH},
 };
 
+use acp_thread::subagent_rows::subagent_rows;
 use acp_thread::{AcpThread, AcpThreadEvent, AgentTask, AgentTaskId, AgentTaskStatus};
 use agent_client_protocol::v2::schema as acp_v2;
 use agent_settings::AgentSettings;
@@ -31,14 +32,26 @@ use ui::{ButtonLike, Chip, CommonAnimationExt as _, Tooltip, prelude::*};
 
 use crate::DEFAULT_THREAD_TITLE;
 use crate::conversation_view::ConversationView;
+use crate::sub_thread_reporter::SubThreadReporter;
 use crate::task_label::{TaskLabels, collapse_label, display_label};
 
+mod merge;
+pub use merge::SUBAGENT_TASK_ID_PREFIX;
+mod sub_thread_row;
 pub mod task_log;
+pub use sub_thread_row::SubThreadRow;
 mod tray;
 
 #[cfg(test)]
 mod test_helpers;
 
+#[cfg(test)]
+mod native_subagent_support;
+
+#[cfg(test)]
+#[path = "tasks_card/native_subagent_tests.rs"]
+mod native_subagent;
+
 #[cfg(test)]
 #[path = "tasks_card/model_tests.rs"]
 mod model;
@@ -87,6 +100,12 @@ mod open_log;
 #[path = "tasks_card/open_log_remote_tests.rs"]
 mod open_log_remote;
 
+#[cfg(test)]
+// The frozen story-040 test clones in an assertion; the lint is silenced here, not edited there.
+#[allow(clippy::redundant_clone)]
+#[path = "tasks_card/sub_thread_row_tests.rs"]
+mod sub_thread_row_tests;
+
 #[cfg(test)]
 #[path = "tasks_card/open_subagent_tests.rs"]
 mod open_subagent;
@@ -119,6 +138,10 @@ mod progress;
 #[path = "tasks_card/progress_line_tests.rs"]
 mod progress_line;
 
+#[cfg(test)]
+#[path = "tasks_card/waiting_label_tests.rs"]
+mod waiting_label;
+
 // GPUI's test windows never activate their accessibility tree (the test platform's
 // `a11y_init` drops the activation callback), so `debug_a11y_tree_json` has nothing to
 // show; tests read the row controls' screen-reader labels of the last render from here.
@@ -178,6 +201,13 @@ pub enum TasksCardEvent {
         task_id: AgentTaskId,
         tool_call_id: Option<acp_v2::ToolCallId>,
     },
+    /// Show a native ACP subagent's own thread, which its parent's conversation holds
+    /// (story 064, R1.6). A failure comes back through `set_row_error` on the row.
+    OpenNativeSubagent {
+        session_id: acp_v2::SessionId,
+        task_id: AgentTaskId,
+        child: acp_v2::SessionId,
+    },
 }
 
 impl EventEmitter<TasksCardEvent> for TasksCard {}
@@ -230,6 +260,12 @@ pub struct TaskRecord {
     pub tool_uses: Option<u64>,
     /// The tool call that started the task, when the feed names one (story 023).
     pub tool_call_id: Option<SharedString>,
+    /// A native ACP subagent's own session (story 064): the history writes it through
+    /// `record_subagent`. `None` for every feed task.
+    pub child_session_id: Option<acp_v2::SessionId>,
+    /// A live subagent waiting on the operator (story 064, R1.8): laid over the record by the
+    /// Agent Tasks panel from the card's row, never stored — the history keeps `Running`.
+    pub waiting: bool,
 }
 
 impl TaskRecord {
@@ -253,6 +289,8 @@ impl TaskRecord {
                 .as_ref()
                 .filter(|id| !id.0.is_empty())
                 .map(|id| SharedString::from(id.0.to_string())),
+            child_session_id: None,
+            waiting: false,
         }
     }
 
@@ -269,12 +307,39 @@ pub struct ThreadGroup {
     /// The last component of the thread's first work directory, or empty.
     pub project: SharedString,
     pub rows: Vec<TaskRow>,
+    /// Sub-threads of this thread on show (story 040), after its task rows.
+    pub sub_threads: Vec<SubThreadRow>,
+}
+
+/// Where a row comes from. A native subagent is stopped through its own ACP session and only
+/// when the agent granted it; a feed task through `_claude/tasks/stop`.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum TaskSource {
+    ClaudeTasks,
+    AcpSubagent {
+        child: acp_v2::SessionId,
+        can_cancel: bool,
+    },
+}
+
+/// A row's own status, wider than `AgentTaskStatus` so a native subagent can show Waiting and
+/// Unknown without widening the feed's type.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum TaskRowStatus {
+    Running,
+    Waiting,
+    Done,
+    Failed,
+    Canceled,
+    Unknown,
 }
 
 /// One task on the card: a running task, or an ended one still on show.
 #[derive(Clone, Debug)]
 pub struct TaskRow {
     pub task: AgentTask,
+    pub source: TaskSource,
+    pub status: TaskRowStatus,
     pub watched: bool,
     /// Opened with its disclosure, or watched: a watched row is always expanded (R4.6).
     pub expanded: bool,
@@ -334,6 +399,15 @@ pub struct TasksCard {
     row_focus_handles: HashMap<(TaskKey, RowControl), FocusHandle>,
     /// The same for each group header's Open control; it goes with its group.
     group_focus_handles: HashMap<acp_v2::SessionId, FocusHandle>,
+    /// Tells the task history about sub-threads (story 040).
+    pub(crate) sub_thread_reporter: SubThreadReporter,
+    /// Each sub-thread on show, keyed by its own session (story 040, R6.1).
+    pub(crate) sub_thread_rows: HashMap<acp_v2::SessionId, SubThreadRow>,
+    /// The last status seen of each native subagent row (story 064). The registry has no
+    /// ending event, so an ending is a change into a terminal status seen here.
+    native_statuses: HashMap<TaskKey, TaskRowStatus>,
+    /// One subscription per registered child of a held thread, keyed by the row's key.
+    native_subscriptions: HashMap<TaskKey, Subscription>,
 }
 
 /// A per-row control that takes keyboard focus.
@@ -390,6 +464,8 @@ struct HeldThread {
 #[derive(Clone, Debug)]
 struct EndedRow {
     task: AgentTask,
+    source: TaskSource,
+    status: TaskRowStatus,
     expires_at: Instant,
     /// Higher means the ending reached the card more recently.
     sequence: u64,
@@ -428,6 +504,10 @@ impl TasksCard {
             pill_focus_handle: control_focus_handle(cx),
             row_focus_handles: HashMap::default(),
             group_focus_handles: HashMap::default(),
+            sub_thread_reporter: SubThreadReporter::new(cx),
+            sub_thread_rows: HashMap::default(),
+            native_statuses: HashMap::default(),
+            native_subscriptions: HashMap::default(),
         }
     }
 
@@ -478,6 +558,7 @@ impl TasksCard {
         let now = cx.background_executor().now();
         let mut seen_sessions = HashSet::default();
         let mut groups = Vec::new();
+        let sub_threads = self.sub_thread_rows_on_show(cx);
 
         for thread in self.held_threads().filter_map(|held| held.thread.upgrade()) {
             let thread = thread.read(cx);
@@ -487,10 +568,9 @@ impl TasksCard {
                 continue;
             }
 
-            let running: Vec<&AgentTask> = thread
-                .tasks()
-                .iter()
-                .filter(|task| task.status.is_running())
+            let running: Vec<TaskRow> = thread_rows(thread, cx)
+                .into_iter()
+                .filter(is_live)
                 .collect();
             // A task running again under an ended id is listed once, as running.
             let ended: Vec<&EndedRow> = self
@@ -499,22 +579,32 @@ impl TasksCard {
                 .filter(|((ended_session, task_id), ended)| {
                     ended_session == session_id
                         && ended.expires_at > now
-                        && !running.iter().any(|task| &task.id == task_id)
+                        && !running.iter().any(|row| &row.task.id == task_id)
                 })
                 .map(|(_, ended)| ended)
                 .collect();
 
-            let oldest_running = running.iter().map(|task| task.started_at).min();
+            let oldest_running = running.iter().map(|row| row.task.started_at).min();
             let latest_ending = ended.iter().map(|ended| ended.sequence).max();
             let mut rows: Vec<TaskRow> = running
                 .into_iter()
-                .chain(ended.into_iter().map(|ended| &ended.task))
-                .map(|task| self.row(thread, task.clone(), cx))
+                .chain(ended.into_iter().map(EndedRow::to_row))
+                .map(|row| self.dress(thread, row, cx))
                 .collect();
 
+            let subs: Vec<SubThreadRow> = sub_threads
+                .iter()
+                .filter(|row| &row.record.parent_session == session_id)
+                .cloned()
+                .collect();
+            let oldest_running = [oldest_running, Self::oldest_running_sub_thread(&subs)]
+                .into_iter()
+                .flatten()
+                .min();
             let order = match (oldest_running, latest_ending) {
                 (Some(oldest), _) => GroupOrder::Running(oldest),
                 (None, Some(latest)) => GroupOrder::EndedOnly(Reverse(latest)),
+                (None, None) if !subs.is_empty() => GroupOrder::EndedOnly(Reverse(0)),
                 (None, None) => continue,
             };
             rows.sort_by_key(|row| row.task.started_at);
@@ -527,9 +617,17 @@ impl TasksCard {
                         .unwrap_or_else(|| SharedString::new_static(DEFAULT_THREAD_TITLE)),
                     project: project_name(thread),
                     rows,
+                    sub_threads: subs,
                 },
             ));
         }
+        for group in Self::groups_of_unheld_parents(sub_threads, &seen_sessions, cx) {
+            let order = match Self::oldest_running_sub_thread(&group.sub_threads) {
+                Some(oldest) => GroupOrder::Running(oldest),
+                None => GroupOrder::EndedOnly(Reverse(0)),
+            };
+            groups.push((order, group));
+        }
 
         // The session id only breaks ties, so equal start times list the same way
         // on every render.
@@ -560,7 +658,19 @@ impl TasksCard {
         cx: &App,
     ) -> Option<AgentTask> {
         self.running_task(&(session_id.clone(), task_id.clone()), cx)
-            .cloned()
+    }
+
+    /// Whether that live task is a subagent waiting on the operator (R1.8 of story 064), which
+    /// `AgentTask`'s status cannot say: it folds Waiting into Running.
+    pub fn live_task_waiting(
+        &self,
+        session_id: &acp_v2::SessionId,
+        task_id: &AgentTaskId,
+        cx: &App,
+    ) -> bool {
+        self.current_row(&(session_id.clone(), task_id.clone()), cx)
+            .filter(is_live)
+            .is_some_and(|row| row.status == TaskRowStatus::Waiting)
     }
 
     pub fn toggle_minimized(&mut self, cx: &mut Context<Self>) {
@@ -700,10 +810,19 @@ impl TasksCard {
         cx: &mut Context<Self>,
     ) {
         let key = (session_id, task_id);
-        let Some(description) = self
-            .running_task(&key, cx)
-            .map(|task| task.description.clone())
-        else {
+        if let Some(TaskSource::AcpSubagent {
+            child,
+            can_cancel: false,
+        }) = self.current_row(&key, cx).map(|row| row.source)
+        {
+            log::debug!(
+                "tasks card: stop requested for subagent {child} of session {}, which its agent \
+                 does not allow stopping",
+                key.0
+            );
+            return;
+        }
+        let Some(description) = self.running_task(&key, cx).map(|task| task.description) else {
             log::debug!(
                 "tasks card: stop requested for task {} of session {}, which is not running",
                 key.1,
@@ -731,6 +850,14 @@ impl TasksCard {
     /// Sends the stop for a confirmed task, unless it ended while the prompt was
     /// open: that race is not the user's error, so it sends nothing and shows none.
     fn stop(&mut self, key: TaskKey, cx: &mut Context<Self>) {
+        if let Some(TaskSource::AcpSubagent { child, can_cancel }) = self
+            .current_row(&key, cx)
+            .filter(is_live)
+            .map(|row| row.source)
+        {
+            self.stop_native_subagent(key, child, can_cancel, cx);
+            return;
+        }
         let Some(thread) = self.thread_running(&key, cx) else {
             log::debug!(
                 "tasks card: task {} of session {} ended before its stop was confirmed; \
@@ -765,6 +892,51 @@ impl TasksCard {
         .detach();
     }
 
+    /// Cancels a native subagent through its own ACP session: never its parent, never a
+    /// sibling (R1.4). The returned task settles only when the child reports Idle, so it is
+    /// never awaited where the card renders.
+    fn stop_native_subagent(
+        &mut self,
+        key: TaskKey,
+        child: acp_v2::SessionId,
+        can_cancel: bool,
+        cx: &mut Context<Self>,
+    ) {
+        let (session_id, _) = &key;
+        if !can_cancel {
+            log::debug!(
+                "tasks card: subagent {child} of session {session_id} cannot be stopped; \
+                 nothing sent"
+            );
+            return;
+        }
+        let Some(child_thread) = self
+            .held_thread(session_id)
+            .and_then(|parent| parent.read(cx).subagent(&child).cloned())
+        else {
+            log::debug!(
+                "tasks card: subagent {child} of session {session_id} is no longer registered; \
+                 nothing sent"
+            );
+            return;
+        };
+        log::info!("tasks card: stopping subagent {child} of session {session_id}");
+        let request = child_thread.update(cx, |child, cx| child.cancel(cx));
+        cx.spawn(async move |card, cx| {
+            let Err(error) = request.await else {
+                return;
+            };
+            let message = format!("{error:#}");
+            log::warn!(
+                "tasks card: stopping subagent {child} of session {} failed: {message}",
+                key.0
+            );
+            card.update(cx, |card, cx| card.set_row_error(key, message.into(), cx))
+                .ok();
+        })
+        .detach();
+    }
+
     /// Moves one thread's foreground task to the background (R4.5). It asks
     /// nothing first: the task keeps running, so no work is lost. An error, or an
     /// answer that nothing moved, is shown on the row (R4.7).
@@ -777,6 +949,7 @@ impl TasksCard {
         let key = (session_id, task_id);
         let tool_call_id = self
             .running_task(&key, cx)
+            .as_ref()
             .and_then(background_target)
             .cloned();
         let (Some(tool_call_id), Some(thread)) = (tool_call_id, self.thread_running(&key, cx))
@@ -830,9 +1003,18 @@ impl TasksCard {
         cx: &mut Context<Self>,
     ) {
         let key = (session_id, task_id);
+        if let Some(TaskSource::AcpSubagent { child, .. }) = self.source_on_show(&key, cx) {
+            let (session_id, task_id) = key;
+            cx.emit(TasksCardEvent::OpenNativeSubagent {
+                session_id,
+                task_id,
+                child,
+            });
+            return;
+        }
         let tool_call_id = self
             .task_on_show(&key, cx)
-            .and_then(|task| task.tool_call_id.clone());
+            .and_then(|task| task.tool_call_id);
         self.emit_open(key, tool_call_id, cx);
     }
 
@@ -892,7 +1074,7 @@ impl TasksCard {
     ) {
         let tool_call_id = self
             .task_on_show(&(session_id.clone(), task_id.clone()), cx)
-            .and_then(|task| task.tool_call_id.clone());
+            .and_then(|task| task.tool_call_id);
         cx.emit(TasksCardEvent::OpenSubagent {
             session_id,
             task_id,
@@ -1021,20 +1203,26 @@ impl TasksCard {
         message: SharedString,
         cx: &mut Context<Self>,
     ) {
-        let task = self.current_task(&key, cx).cloned();
+        let task = self.current_task(&key, cx);
         self.row_errors.insert(key, RowError { message, task });
         cx.notify();
     }
 
-    fn row(&self, thread: &AcpThread, task: AgentTask, cx: &App) -> TaskRow {
-        let key = (thread.session_id().clone(), task.id.clone());
-        TaskRow {
-            watched: self.watched.contains(&key),
-            expanded: self.is_expanded(&key),
-            running_what: running_what(thread, &task, cx),
-            error: self.row_errors.get(&key).map(|error| error.message.clone()),
-            task,
+    /// Fills the card-only fields of a merged row by its key.
+    fn dress(&self, thread: &AcpThread, mut row: TaskRow, cx: &App) -> TaskRow {
+        let key = (thread.session_id().clone(), row.task.id.clone());
+        row.watched = self.watched.contains(&key);
+        row.expanded = self.is_expanded(&key);
+        if matches!(row.source, TaskSource::ClaudeTasks) {
+            row.running_what = running_what(thread, &row.task, cx);
         }
+        // The card's own error (a failed stop or open) is the newer news.
+        row.error = self
+            .row_errors
+            .get(&key)
+            .map(|error| error.message.clone())
+            .or(row.error);
+        row
     }
 
     fn is_expanded(&self, key: &TaskKey) -> bool {
@@ -1063,7 +1251,12 @@ impl TasksCard {
             AcpThreadEvent::TaskEnded { task_id, .. } => {
                 card.handle_task_ended(&thread, task_id, cx)
             }
-            AcpThreadEvent::TitleUpdated => card.handle_title_updated(&thread, cx),
+            AcpThreadEvent::TitleUpdated => {
+                card.handle_title_updated(&thread, cx);
+                card.report_sub_thread_title(&thread, cx);
+            }
+            AcpThreadEvent::StatusChanged => card.report_sub_thread_status(&thread, cx),
+            AcpThreadEvent::SubagentSpawned(_) => card.refresh_native_rows(&thread, cx),
             _ => {}
         });
         HeldThread {
@@ -1171,6 +1364,10 @@ impl TasksCard {
             );
             return;
         };
+        // Its child's own row ends when the registry says so (R3.4).
+        if names_registered_child(thread, &task) {
+            return;
+        }
         let status = task.status;
         // Read now: the adapter drops the ended task from its next snapshot, and the
         // card drops its ended row `ENDED_ROW_TTL` later (R6.1).
@@ -1196,12 +1393,14 @@ impl TasksCard {
         self.ended.insert(
             key.clone(),
             EndedRow {
+                source: TaskSource::ClaudeTasks,
+                status: TaskRowStatus::from(status),
                 task,
                 expires_at: now.checked_add(ENDED_ROW_TTL).unwrap_or(now),
                 sequence: self.endings_seen,
             },
         );
-        self.tell_tray(key.clone(), status, datagram, cx);
+        self.tell_tray(key.clone(), TaskRowStatus::from(status), datagram, cx);
         self.record_ended(record, cx);
         if copies_log && first_ending {
             self.save_log(key, &thread_entity, LogSave::CopyOnEnd, cx);
@@ -1293,6 +1492,149 @@ impl TasksCard {
         for record in records {
             self.record_started(record, cx);
         }
+        let threads: Vec<Entity<AcpThread>> = self
+            .held_threads()
+            .filter_map(|held| held.thread.upgrade())
+            .collect();
+        for thread in threads {
+            self.refresh_native_rows(&thread, cx);
+        }
+    }
+
+    /// Follows the registered children of a held thread and turns each change of a child's
+    /// status into what a feed task's events would have said (story 064, R1.1): a row on the
+    /// spawn alone, an ending — history, tray, ended row — on a change into a terminal status.
+    fn refresh_native_rows(&mut self, thread: &Entity<AcpThread>, cx: &mut Context<Self>) {
+        let session_id = thread.read(cx).session_id().clone();
+        let children: Vec<Entity<AcpThread>> = thread.read(cx).subagents().cloned().collect();
+        for child in children {
+            let key = (
+                session_id.clone(),
+                merge::subagent_task_id(child.read(cx).session_id()),
+            );
+            if self.native_subscriptions.contains_key(&key) {
+                continue;
+            }
+            let parent = thread.downgrade();
+            let subscription = cx.subscribe(&child, move |card, _child, event, cx| match event {
+                AcpThreadEvent::StatusChanged
+                | AcpThreadEvent::SubagentAssociationUpdated
+                | AcpThreadEvent::TitleUpdated => {
+                    if let Some(parent) = parent.upgrade() {
+                        card.refresh_native_rows(&parent, cx);
+                    }
+                }
+                _ => {}
+            });
+            self.native_subscriptions.insert(key, subscription);
+        }
+
+        let natives: Vec<TaskRow> = thread_rows(thread.read(cx), cx)
+            .into_iter()
+            .filter(|row| matches!(row.source, TaskSource::AcpSubagent { .. }))
+            .collect();
+        let mut endings = Vec::new();
+        let mut restored = Vec::new();
+        for row in natives {
+            let key = (session_id.clone(), row.task.id.clone());
+            let previous = self.native_statuses.insert(key.clone(), row.status);
+            if previous == Some(row.status) {
+                continue;
+            }
+            if row.status.is_terminal() {
+                // A reopened thread registers its native children after
+                // `restore_subagent_outcomes` (#66255), so a child first seen terminal ended in an
+                // earlier run: it is shown, but it was recorded and announced then (R2.5).
+                if previous.is_none() {
+                    restored.push(row);
+                } else {
+                    endings.push(row);
+                }
+                continue;
+            }
+            // Running again, as a resumed subagent does: its next ending is news again.
+            self.ended.remove(&key);
+            self.tray_weighed.remove(&key);
+            self.activity_ended.remove(&key);
+            // Unknown is not announced: a replayed child reads Unknown, and its recorded
+            // outcome must not be shadowed by a start it never made again (R1.7).
+            if row.status.is_active() {
+                let mut task = row.task;
+                task.status = AgentTaskStatus::Running;
+                let mut record = TaskRecord::new(thread.read(cx), &task);
+                record.child_session_id = task.child_session_id;
+                self.record_started(record, cx);
+            }
+        }
+        for row in restored {
+            let mut task = row.task.clone();
+            task.status = row.status.to_task_status();
+            self.show_native_ended((session_id.clone(), task.id.clone()), task, &row, cx);
+        }
+        for row in endings {
+            self.handle_native_ended(thread, row, cx);
+        }
+        self.update_tick(cx);
+        cx.notify();
+    }
+
+    fn handle_native_ended(
+        &mut self,
+        thread: &Entity<AcpThread>,
+        row: TaskRow,
+        cx: &mut Context<Self>,
+    ) {
+        let parent = thread.read(cx);
+        let session_id = parent.session_id().clone();
+        let now = SystemTime::now();
+        let datagram = tray::datagram_for_row(
+            &row,
+            &session_id,
+            parent
+                .work_dirs()
+                .and_then(|dirs| dirs.paths().first())
+                .map(|dir| dir.as_path()),
+            parent.title().as_deref(),
+            now,
+        );
+        let mut task = row.task.clone();
+        task.status = row.status.to_task_status();
+        task.ended_at.get_or_insert(now);
+        let mut record = TaskRecord::new(parent, &task);
+        record.child_session_id = task.child_session_id.clone();
+        let key = (session_id, task.id.clone());
+        log::info!(
+            "tasks card: subagent row {} of session {} ended as {:?}",
+            key.1,
+            key.0,
+            row.status
+        );
+        self.show_native_ended(key.clone(), task, &row, cx);
+        self.tell_tray(key, row.status, datagram, cx);
+        self.record_ended(record, cx);
+    }
+
+    /// Lists a native subagent's ending among the ended rows until it expires.
+    fn show_native_ended(
+        &mut self,
+        key: TaskKey,
+        task: AgentTask,
+        row: &TaskRow,
+        cx: &mut Context<Self>,
+    ) {
+        let shown_at = cx.background_executor().now();
+        self.endings_seen += 1;
+        self.ended.insert(
+            key,
+            EndedRow {
+                task,
+                source: row.source.clone(),
+                status: row.status,
+                expires_at: shown_at.checked_add(ENDED_ROW_TTL).unwrap_or(shown_at),
+                sequence: self.endings_seen,
+            },
+        );
+        self.schedule_expiry(cx);
     }
 
     /// Tells `zeo-systray` about an ending the user is not looking at, when it is
@@ -1300,7 +1642,7 @@ impl TasksCard {
     fn tell_tray(
         &mut self,
         key: TaskKey,
-        status: AgentTaskStatus,
+        status: TaskRowStatus,
         datagram: Option<tray::TaskOutcomeDatagram>,
         cx: &mut Context<Self>,
     ) {
@@ -1311,7 +1653,7 @@ impl TasksCard {
             return;
         };
         let watched = self.watched.contains(&key);
-        if !tray::should_notify(watched, status, in_view(&key.0, cx)) {
+        if !tray::should_notify_row(watched, status, in_view(&key.0, cx)) {
             return;
         }
         let (session_id, task_id) = &key;
@@ -1329,7 +1671,13 @@ impl TasksCard {
 
     /// Keeps one wake pending, at the earliest expiry; none when nothing is ended.
     fn schedule_expiry(&mut self, cx: &mut Context<Self>) {
-        let Some(earliest) = self.ended.values().map(|ended| ended.expires_at).min() else {
+        let earliest = self
+            .ended
+            .values()
+            .map(|ended| ended.expires_at)
+            .chain(self.earliest_sub_thread_expiry())
+            .min();
+        let Some(earliest) = earliest else {
             self._expiry = None;
             return;
         };
@@ -1357,6 +1705,7 @@ impl TasksCard {
                 self.row_errors.remove(&key);
             }
         }
+        self.expire_sub_thread_rows(now);
         self.schedule_expiry(cx);
         cx.notify();
     }
@@ -1397,15 +1746,11 @@ impl TasksCard {
     }
 
     fn any_running(&self, cx: &App) -> bool {
-        self.held_threads()
-            .filter_map(|held| held.thread.upgrade())
-            .any(|thread| {
-                thread
-                    .read(cx)
-                    .tasks()
-                    .iter()
-                    .any(|task| task.status.is_running())
-            })
+        self.any_sub_thread_running()
+            || self
+                .held_threads()
+                .filter_map(|held| held.thread.upgrade())
+                .any(|thread| thread_rows(thread.read(cx), cx).iter().any(is_live))
     }
 
     fn held_threads(&self) -> impl Iterator<Item = &HeldThread> {
@@ -1413,7 +1758,7 @@ impl TasksCard {
     }
 
     /// A live thread of `session_id` that the card holds, whatever its tasks.
-    fn held_thread(&self, session_id: &acp_v2::SessionId) -> Option<Entity<AcpThread>> {
+    pub(crate) fn held_thread(&self, session_id: &acp_v2::SessionId) -> Option<Entity<AcpThread>> {
         self.held_threads()
             .filter(|held| &held.session_id == session_id)
             .find_map(|held| held.thread.upgrade())
@@ -1433,41 +1778,46 @@ impl TasksCard {
             .filter(|held| &held.session_id == session_id)
             .filter_map(|held| held.thread.upgrade())
             .find(|thread| {
-                thread
-                    .read(cx)
-                    .tasks()
+                thread_rows(thread.read(cx), cx)
                     .iter()
-                    .any(|task| &task.id == task_id && task.status.is_running())
+                    .any(|row| &row.task.id == task_id && is_live(row))
             })
     }
 
-    fn running_task<'a>(&self, key: &TaskKey, cx: &'a App) -> Option<&'a AgentTask> {
-        self.current_task(key, cx)
-            .filter(|task| task.status.is_running())
+    fn running_task(&self, key: &TaskKey, cx: &App) -> Option<AgentTask> {
+        self.current_row(key, cx)
+            .filter(is_live)
+            .map(|row| row.task)
     }
 
     /// The task as its thread holds it now, else the final copy of an ended row
     /// the adapter has already dropped from its snapshot.
-    fn task_on_show<'a>(&'a self, key: &TaskKey, cx: &'a App) -> Option<&'a AgentTask> {
+    fn task_on_show(&self, key: &TaskKey, cx: &App) -> Option<AgentTask> {
         self.current_task(key, cx)
-            .or_else(|| self.ended.get(key).map(|ended| &ended.task))
+            .or_else(|| self.ended.get(key).map(|ended| ended.task.clone()))
+    }
+
+    /// Where the row on show comes from, an ended one included.
+    fn source_on_show(&self, key: &TaskKey, cx: &App) -> Option<TaskSource> {
+        self.current_row(key, cx)
+            .map(|row| row.source)
+            .or_else(|| self.ended.get(key).map(|ended| ended.source.clone()))
     }
 
     /// The task as its thread holds it now, whatever its status.
-    fn current_task<'a>(
-        &self,
-        (session_id, task_id): &TaskKey,
-        cx: &'a App,
-    ) -> Option<&'a AgentTask> {
+    fn current_task(&self, key: &TaskKey, cx: &App) -> Option<AgentTask> {
+        self.current_row(key, cx).map(|row| row.task)
+    }
+
+    /// The merged row of a held thread, whatever its status.
+    fn current_row(&self, (session_id, task_id): &TaskKey, cx: &App) -> Option<TaskRow> {
         self.held_threads()
             .filter(|held| &held.session_id == session_id)
             .filter_map(|held| held.thread.upgrade())
             .find_map(|thread| {
-                thread
-                    .read(cx)
-                    .tasks()
-                    .iter()
-                    .find(|task| &task.id == task_id)
+                thread_rows(thread.read(cx), cx)
+                    .into_iter()
+                    .find(|row| &row.task.id == task_id)
             })
     }
 
@@ -1489,6 +1839,10 @@ impl TasksCard {
             .retain(|(session_id, _)| held.contains(session_id));
         self.tray_weighed
             .retain(|(session_id, _)| held.contains(session_id));
+        self.native_statuses
+            .retain(|(session_id, _), _| held.contains(session_id));
+        self.native_subscriptions
+            .retain(|(session_id, _), _| held.contains(session_id));
         // Only here, after the panel's deferred sync: activation removes a view and
         // re-adds it, and that gap is not a closed thread (R1.7).
         let ended_at_ms = unix_ms(SystemTime::now());
@@ -1521,6 +1875,7 @@ impl TasksCard {
         for record in interrupted {
             cx.emit(TaskActivity::Ended(record));
         }
+        self.report_unheld_sub_threads_idle(&held, cx);
         self.schedule_expiry(cx);
     }
 }
@@ -1667,6 +2022,7 @@ impl TasksCard {
             title,
             project,
             rows,
+            sub_threads,
         } = group;
         let open_focus_handle = self
             .group_focus_handles
@@ -1720,6 +2076,11 @@ impl TasksCard {
                 rows.into_iter()
                     .map(|row| self.render_row(&session_id, row, now, cx)),
             )
+            .children(
+                sub_threads
+                    .into_iter()
+                    .map(|row| self.render_sub_thread_row(row, now, cx)),
+            )
             .into_any_element()
     }
 
@@ -1732,11 +2093,21 @@ impl TasksCard {
     ) -> AnyElement {
         let TaskRow {
             task,
+            source,
+            status,
             watched,
             expanded,
             running_what,
             error,
         } = row;
+        let native_cancel = match &source {
+            TaskSource::AcpSubagent { can_cancel, .. } => Some(*can_cancel),
+            TaskSource::ClaudeTasks => None,
+        };
+        let stoppable = match native_cancel {
+            Some(_) => status.is_active(),
+            None => task.status.is_running(),
+        };
         let task_id = task.id.clone();
         let key: TaskKey = (session_id.clone(), task_id.clone());
         let element_key = format!("{}-{}", session_id.0, task_id);
@@ -1778,9 +2149,9 @@ impl TasksCard {
                         .min_w_0()
                         .gap_1()
                         .child(
-                            Label::new(format!("{} ·", progress_activity(&task)))
+                            Label::new(format!("{} ·", row_activity(&task, status)))
                                 .size(LabelSize::XSmall)
-                                .color(Color::Muted)
+                                .color(row_activity_color(status))
                                 .single_line()
                                 .truncate(),
                         )
@@ -1887,18 +2258,29 @@ impl TasksCard {
             .flex_none()
             .child(watch_button);
 
-        let stop_button = task.status.is_running().then(|| {
+        let stop_button = stoppable.then(|| {
             let stop_focus_handle = self.row_focus_handle(&key, RowControl::Stop, cx);
+            // Shown disabled rather than hidden: the agent's refusal is the contract (R1.5).
+            let refused = native_cancel == Some(false);
+            let (stop_aria, stop_tooltip) = if refused {
+                (
+                    format!("Stop: {shown_label}. {STOP_NOT_ALLOWED}"),
+                    STOP_NOT_ALLOWED,
+                )
+            } else {
+                (format!("Stop: {shown_label}"), "Stop This Task")
+            };
             let button = IconButton::new(
                 SharedString::from(format!("tasks-card-stop-{element_key}")),
                 IconName::Stop,
             )
             .icon_size(IconSize::Small)
             .icon_color(Color::Muted)
-            .aria_label(row_aria_label(format!("Stop: {shown_label}")))
+            .disabled(refused)
+            .aria_label(row_aria_label(stop_aria))
             .tooltip(card_tooltip(
                 format!("stop-{task_id}").into(),
-                "Stop This Task".into(),
+                stop_tooltip.into(),
             ))
             .track_focus(&stop_focus_handle)
             .on_click(cx.listener({
@@ -1976,38 +2358,41 @@ impl TasksCard {
                 .child(button)
         });
 
-        // Only a subagent has a thread of its own to show (R5.4).
-        let open_thread_button = (task.kind == SUBAGENT_KIND).then(|| {
-            let open_thread_focus_handle = self.row_focus_handle(&key, RowControl::OpenThread, cx);
-            let button = IconButton::new(
-                SharedString::from(format!("tasks-card-open-thread-{element_key}")),
-                IconName::Thread,
-            )
-            .icon_size(IconSize::Small)
-            .icon_color(Color::Muted)
-            .aria_label(row_aria_label(format!(
-                "Open subagent thread: {shown_label}"
-            )))
-            .tooltip(card_tooltip(
-                format!("open-thread-{task_id}").into(),
-                "Open Thread".into(),
-            ))
-            .track_focus(&open_thread_focus_handle)
-            .on_click(cx.listener({
-                let session_id = session_id.clone();
-                let task_id = task_id.clone();
-                move |card, _, window, cx| {
-                    card.open_subagent(session_id.clone(), task_id.clone(), window, cx)
-                }
-            }));
-            div()
-                .debug_selector({
+        // Only a subagent has a thread of its own to show (R5.4); a native one's Open
+        // already shows it.
+        let open_thread_button =
+            (task.kind == SUBAGENT_KIND && native_cancel.is_none()).then(|| {
+                let open_thread_focus_handle =
+                    self.row_focus_handle(&key, RowControl::OpenThread, cx);
+                let button = IconButton::new(
+                    SharedString::from(format!("tasks-card-open-thread-{element_key}")),
+                    IconName::Thread,
+                )
+                .icon_size(IconSize::Small)
+                .icon_color(Color::Muted)
+                .aria_label(row_aria_label(format!(
+                    "Open subagent thread: {shown_label}"
+                )))
+                .tooltip(card_tooltip(
+                    format!("open-thread-{task_id}").into(),
+                    "Open Thread".into(),
+                ))
+                .track_focus(&open_thread_focus_handle)
+                .on_click(cx.listener({
+                    let session_id = session_id.clone();
                     let task_id = task_id.clone();
-                    move || format!("tasks-card-open-thread-{task_id}")
-                })
-                .flex_none()
-                .child(button)
-        });
+                    move |card, _, window, cx| {
+                        card.open_subagent(session_id.clone(), task_id.clone(), window, cx)
+                    }
+                }));
+                div()
+                    .debug_selector({
+                        let task_id = task_id.clone();
+                        move || format!("tasks-card-open-thread-{task_id}")
+                    })
+                    .flex_none()
+                    .child(button)
+            });
 
         let offers_background = task.status.is_running() && background_target(&task).is_some();
         let background_button = offers_background.then(|| {
@@ -2142,6 +2527,12 @@ impl TasksCard {
                     .rows
                     .iter()
                     .map(|row| (group.session_id.clone(), row.task.id.clone()))
+                    .chain(group.sub_threads.iter().map(|row| {
+                        (
+                            row.record.sub_session.clone(),
+                            AgentTaskId::from(format!("zeo-sub-thread:{}", row.record.sub_session)),
+                        )
+                    }))
             })
             .collect();
         self.row_focus_handles
@@ -2385,6 +2776,9 @@ const SHELL_KIND: &str = "shell";
 /// The task kind that runs in a thread of its own (R5.4).
 const SUBAGENT_KIND: &str = "subagent";
 
+/// The disabled Stop's reason on a native subagent the agent grants no cancel (R1.5).
+const STOP_NOT_ALLOWED: &str = "This agent does not allow stopping its subagents";
+
 /// A tail entry on one row of the block: `single_line` stops soft wrapping, but a line break
 /// inside the text would still start a new line and draw over the next entry.
 fn tail_line(line: &SharedString) -> SharedString {
@@ -2451,6 +2845,36 @@ pub(crate) fn progress_label(task: &AgentTask, now: SystemTime) -> Option<Shared
     Some(format!("{} · {}", progress_activity(task), format_duration(elapsed)).into())
 }
 
+/// What an active row paints on its second line, read from the row's own status (R1.8 of
+/// story 064): `progress_label` reads `AgentTask`, whose status folds Waiting into Running.
+#[cfg(test)]
+pub(crate) fn row_progress_label(row: &TaskRow, now: SystemTime) -> Option<SharedString> {
+    if !row.task.status.is_running() {
+        return None;
+    }
+    let elapsed = now.duration_since(row.task.started_at).unwrap_or_default();
+    let activity = row_activity(&row.task, row.status);
+    Some(format!("{activity} · {}", format_duration(elapsed)).into())
+}
+
+/// A subagent waiting on the operator says so in place of its tool line (R1.8 of story 064).
+fn row_activity(task: &AgentTask, status: TaskRowStatus) -> String {
+    match status {
+        TaskRowStatus::Waiting => TASK_WAITING_LABEL.to_string(),
+        _ => progress_activity(task),
+    }
+}
+
+fn row_activity_color(status: TaskRowStatus) -> Color {
+    match status {
+        TaskRowStatus::Waiting => Color::Warning,
+        _ => Color::Muted,
+    }
+}
+
+/// The text the subagent card shows for `RequiresAction` too (0072), so both surfaces agree.
+pub const TASK_WAITING_LABEL: &str = "Waiting for you";
+
 /// The last tool and the tool count, or `starting` while the feed reports no tool use.
 fn progress_activity(task: &AgentTask) -> String {
     let tool_uses = task.usage.as_ref().map_or(0, |usage| usage.tool_uses);
@@ -2508,12 +2932,48 @@ fn records_of(thread: &AcpThread) -> Vec<TaskRecord> {
     thread
         .tasks()
         .iter()
+        .filter(|task| !names_registered_child(thread, task))
         .map(|task| TaskRecord::new(thread, task))
         .collect()
 }
 
+/// A feed entry folded into its child's native row: the registry speaks for it (R3.1).
+fn names_registered_child(thread: &AcpThread, task: &AgentTask) -> bool {
+    task.child_session_id
+        .as_ref()
+        .is_some_and(|child| thread.subagent(child).is_some())
+}
+
+/// A thread's rows: its registered subagents and its feed tasks, one row per child.
+fn thread_rows(thread: &AcpThread, cx: &App) -> Vec<TaskRow> {
+    merge::merge(&subagent_rows(thread, cx), thread.tasks())
+}
+
+/// Listed as live: a running feed task, or a native subagent that has not ended — an
+/// Unknown child included, which has simply not reported yet (R1.1).
+fn is_live(row: &TaskRow) -> bool {
+    match row.source {
+        TaskSource::ClaudeTasks => row.task.status.is_running(),
+        TaskSource::AcpSubagent { .. } => !row.status.is_terminal(),
+    }
+}
+
+impl EndedRow {
+    fn to_row(&self) -> TaskRow {
+        TaskRow {
+            task: self.task.clone(),
+            source: self.source.clone(),
+            status: self.status,
+            watched: false,
+            expanded: false,
+            running_what: self.task.description.clone(),
+            error: None,
+        }
+    }
+}
+
 /// Milliseconds since the Unix epoch, negative before it, saturating at `i64`'s range.
-fn unix_ms(time: SystemTime) -> i64 {
+pub(crate) fn unix_ms(time: SystemTime) -> i64 {
     match time.duration_since(UNIX_EPOCH) {
         Ok(after) => i64::try_from(after.as_millis()).unwrap_or(i64::MAX),
         Err(before) => i64::try_from(before.duration().as_millis()).map_or(i64::MIN, |ms| -ms),
diff --git a/crates/agent_ui/src/tasks_card/merge.rs b/crates/agent_ui/src/tasks_card/merge.rs
new file mode 100644
index 0000000..ab3d0d3
--- /dev/null
+++ b/crates/agent_ui/src/tasks_card/merge.rs
@@ -0,0 +1,158 @@
+//! One task row per subagent, whichever source names it (story 064, R3). The registry is the
+//! authority on a child's status; the `_claude/tasks` feed only enriches the child it names.
+
+use std::time::SystemTime;
+
+use acp_thread::subagent_rows::{SubagentRow, SubagentRowStatus};
+use acp_thread::{AgentTask, AgentTaskStatus};
+use agent_client_protocol::v2::schema as acp_v2;
+use gpui::SharedString;
+
+use super::{TaskRow, TaskRowStatus, TaskSource};
+
+/// The prefix of a native row's task id, which is also its history `task_id`. The prefix
+/// only keeps the id apart from feed ids; the row's origin is read from `source`, never
+/// from the id.
+pub const SUBAGENT_TASK_ID_PREFIX: &str = "acp-subagent:";
+
+pub fn subagent_task_id(child: &acp_v2::SessionId) -> SharedString {
+    format!("{SUBAGENT_TASK_ID_PREFIX}{child}").into()
+}
+
+/// The card-only fields (watched, expanded, error, running_what) are left empty; the card
+/// fills them by row identity after the merge.
+pub fn merge(subagents: &[SubagentRow], feed: &[AgentTask]) -> Vec<TaskRow> {
+    let mut rows: Vec<TaskRow> = subagents.iter().map(native_row).collect();
+    for task in feed {
+        // Folding by title would collapse two children that share one (R3.3).
+        let named = task.child_session_id.as_ref().and_then(|child| {
+            rows.iter_mut().find(|row| {
+                matches!(&row.source, TaskSource::AcpSubagent { child: own, .. } if own == child)
+            })
+        });
+        match named {
+            Some(row) => enrich(&mut row.task, task),
+            None => {
+                if let Some(child) = &task.child_session_id {
+                    log::debug!(
+                        "tasks feed entry {} names subagent {child}, which is not registered",
+                        task.id
+                    );
+                }
+                rows.push(feed_row(task));
+            }
+        }
+    }
+    rows
+}
+
+fn native_row(row: &SubagentRow) -> TaskRow {
+    let status = TaskRowStatus::from(row.status);
+    let started_at = row.started_at.unwrap_or(SystemTime::UNIX_EPOCH);
+    TaskRow {
+        task: AgentTask {
+            id: subagent_task_id(&row.child_session_id),
+            tool_call_id: None,
+            kind: "subagent".into(),
+            description: row.label.clone(),
+            background: true,
+            depth: 0,
+            status: status.to_task_status(),
+            started_at,
+            ended_at: None,
+            usage: None,
+            last_tool: None,
+            summary: None,
+            has_output: false,
+            tail: None,
+            child_session_id: Some(row.child_session_id.clone()),
+        },
+        source: TaskSource::AcpSubagent {
+            child: row.child_session_id.clone(),
+            can_cancel: row.can_cancel,
+        },
+        status,
+        watched: false,
+        expanded: false,
+        running_what: row.label.clone(),
+        error: row.error.clone(),
+    }
+}
+
+/// Usage, last tool and summary come from the feed; status and label stay the registry's
+/// (R3.4).
+fn enrich(native: &mut AgentTask, feed: &AgentTask) {
+    native.tool_call_id = native
+        .tool_call_id
+        .take()
+        .or_else(|| feed.tool_call_id.clone());
+    native.usage = feed.usage.clone().or(native.usage.take());
+    native.last_tool = feed.last_tool.clone().or(native.last_tool.take());
+    native.summary = feed.summary.clone().or(native.summary.take());
+    native.has_output |= feed.has_output;
+    native.tail = feed.tail.clone().or(native.tail.take());
+    native.ended_at = feed.ended_at.or(native.ended_at);
+}
+
+fn feed_row(task: &AgentTask) -> TaskRow {
+    TaskRow {
+        status: TaskRowStatus::from(task.status),
+        source: TaskSource::ClaudeTasks,
+        watched: false,
+        expanded: false,
+        running_what: task.description.clone(),
+        error: None,
+        task: task.clone(),
+    }
+}
+
+impl From<SubagentRowStatus> for TaskRowStatus {
+    fn from(status: SubagentRowStatus) -> Self {
+        match status {
+            SubagentRowStatus::Running => Self::Running,
+            SubagentRowStatus::Waiting => Self::Waiting,
+            SubagentRowStatus::Done => Self::Done,
+            SubagentRowStatus::Failed => Self::Failed,
+            SubagentRowStatus::Canceled => Self::Canceled,
+            SubagentRowStatus::Unknown => Self::Unknown,
+        }
+    }
+}
+
+impl From<AgentTaskStatus> for TaskRowStatus {
+    fn from(status: AgentTaskStatus) -> Self {
+        match status {
+            AgentTaskStatus::Running => Self::Running,
+            AgentTaskStatus::Completed => Self::Done,
+            AgentTaskStatus::Failed => Self::Failed,
+            AgentTaskStatus::Stopped => Self::Canceled,
+            // Its outcome is not known, as R1.7 treats a replayed child.
+            AgentTaskStatus::Interrupted => Self::Unknown,
+        }
+    }
+}
+
+impl TaskRowStatus {
+    pub fn is_active(self) -> bool {
+        matches!(self, Self::Running | Self::Waiting)
+    }
+
+    pub fn is_terminal(self) -> bool {
+        matches!(self, Self::Done | Self::Failed | Self::Canceled)
+    }
+
+    /// The status the card's older paths (expiry, history, tray) read off `AgentTask`.
+    pub fn to_task_status(self) -> AgentTaskStatus {
+        match self {
+            Self::Running | Self::Waiting => AgentTaskStatus::Running,
+            Self::Done => AgentTaskStatus::Completed,
+            Self::Failed => AgentTaskStatus::Failed,
+            Self::Canceled => AgentTaskStatus::Stopped,
+            Self::Unknown => AgentTaskStatus::Interrupted,
+        }
+    }
+}
+
+#[cfg(test)]
+#[path = "merge_tests.rs"]
+mod tests;
diff --git a/crates/agent_ui/src/tasks_card/merge_tests.rs b/crates/agent_ui/src/tasks_card/merge_tests.rs
new file mode 100644
index 0000000..8a84722
--- /dev/null
+++ b/crates/agent_ui/src/tasks_card/merge_tests.rs
@@ -0,0 +1,428 @@
+// Story 064, task 2.1 — `TaskRowMerger`: native subagent rows and `_claude/tasks` entries become
+// one list, one row per child (R3.1, R3.2, R3.3, R3.4).
+//
+// Wired at the end of `crates/agent_ui/src/tasks_card/merge.rs` (new, fixup of 0037) as
+//
+//     #[cfg(test)]
+//     #[path = "merge_tests.rs"]
+//     mod tests;
+//
+// Validation filter: `cargo test -p agent_ui tasks_card::merge`.
+//
+// Contract (design.md §TaskRowMerger, §Tasks card and tray, §Data Models):
+//   - `pub fn merge(subagents: &[SubagentRow], feed: &[AgentTask]) -> Vec<TaskRow>`;
+//   - a feed task whose `child_session_id` equals a row's `child_session_id` is folded into that
+//     row: status from the row, usage / last tool / summary from the task; every other feed task
+//     and every other row is kept. Identity is the child session id, never the title;
+//   - `TaskRow` gains `source: TaskSource`, `TaskSource::{ClaudeTasks, AcpSubagent { child:
+//     SessionId, can_cancel: bool }}`;
+//   - `AgentTask` gains `child_session_id: Option<SessionId>`;
+//   - (operator, 2026-10-29, revision 1) `TaskRow` gains its own `status: TaskRowStatus`,
+//     `TaskRowStatus::{Running, Waiting, Done, Failed, Canceled, Unknown}`, so a merged row can
+//     show Waiting and Unknown; a native row's status is its `SubagentRowStatus`, variant for
+//     variant; a feed-only row maps its `AgentTaskStatus` into it.
+// Assumed beyond design.md (adjust HERE — `native_row` and `feed_task` — not in each test):
+//   - `SubagentRow` and its status enum are reachable as `acp_thread::subagent_rows::{SubagentRow,
+//     SubagentRowStatus}` (`pub mod subagent_rows`, as `pub mod subagents`); the status enum is
+//     named `SubagentRowStatus` (design.md says `TaskActivity`, a name `agent_ui` already uses);
+//   - `SubagentRow.started_at: Option<SystemTime>`; the other field types as in task 1.1's tests;
+//   - `TaskRowStatus` derives `Clone, Copy, Debug, PartialEq, Eq` and sits beside `TaskRow`;
+//   - the feed mapping pinned here is Running -> Running, Completed -> Done, Failed -> Failed,
+//     Stopped -> Canceled (`expected_feed_status`); `Interrupted` is not pinned — the operator's
+//     decision names no target for it;
+//   - row order is not part of the contract; rows are looked up, never indexed.
+//
+// Hostile halves first, for "a subagent is shown once" (identity = child session id):
+//   - wrongly collapse: two children sharing a label, a feed entry with the same description and
+//     no child, a feed entry naming an unregistered child, and a feed entry whose own id equals
+//     another child's id — each stays its own row;
+//   - wrongly split: a feed entry naming a registered child under a different description, and
+//     two feed entries naming the same child, fold into that child's single row;
+//   - then status disagreement (registry wins), and the benign feed-only and native-only cases.
+
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+use acp_thread::subagent_rows::{SubagentRow, SubagentRowStatus};
+use acp_thread::{AgentTask, AgentTaskStatus, AgentTaskUsage};
+use agent_client_protocol::v2::schema as acp_v2;
+use gpui::SharedString;
+
+use super::merge;
+use crate::tasks_card::{TaskRow, TaskRowStatus, TaskSource};
+
+fn merge_ms(ms: u64) -> SystemTime {
+    UNIX_EPOCH + Duration::from_millis(ms)
+}
+
+/// A native subagent row as task 1.1's `subagent_rows` produces it.
+fn native_row(child: &str, label: &str, status: SubagentRowStatus) -> SubagentRow {
+    SubagentRow {
+        child_session_id: acp_v2::SessionId::new(child),
+        label: SharedString::from(label.to_string()),
+        status,
+        can_cancel: true,
+        error: None,
+        started_at: Some(merge_ms(1_000)),
+    }
+}
+
+/// A `_claude/tasks` entry; `child` is its `childSessionId` (contract v3), `None` for v2.
+fn feed_task(
+    id: &str,
+    description: &str,
+    status: AgentTaskStatus,
+    child: Option<&str>,
+) -> AgentTask {
+    let ended = !status.is_running();
+    AgentTask {
+        id: SharedString::from(id.to_string()),
+        tool_call_id: Some(acp_v2::ToolCallId::new(format!("toolu_{id}"))),
+        kind: "subagent".into(),
+        description: SharedString::from(description.to_string()),
+        background: true,
+        depth: 0,
+        status,
+        started_at: merge_ms(1_000),
+        ended_at: ended.then(|| merge_ms(2_000)),
+        usage: Some(AgentTaskUsage {
+            tokens: 4_321,
+            tool_uses: 7,
+            duration: Duration::from_millis(9_000),
+        }),
+        last_tool: Some(SharedString::from(format!("Last tool of {id}"))),
+        summary: Some(SharedString::from(format!("Summary of {id}"))),
+        has_output: false,
+        tail: None,
+        child_session_id: child.map(acp_v2::SessionId::new),
+    }
+}
+
+fn merge_child_of(row: &TaskRow) -> Option<String> {
+    match &row.source {
+        TaskSource::AcpSubagent { child, .. } => Some(child.to_string()),
+        TaskSource::ClaudeTasks => None,
+    }
+}
+
+fn merge_native_rows<'a>(rows: &'a [TaskRow], child: &str) -> Vec<&'a TaskRow> {
+    rows.iter()
+        .filter(|row| merge_child_of(row).as_deref() == Some(child))
+        .collect()
+}
+
+fn merge_the_native_row<'a>(rows: &'a [TaskRow], child: &str) -> &'a TaskRow {
+    let found = merge_native_rows(rows, child);
+    assert_eq!(
+        found.len(),
+        1,
+        "child {child} has exactly one row: {:?}",
+        merge_summary(rows)
+    );
+    found[0]
+}
+
+fn merge_feed_ids(rows: &[TaskRow]) -> Vec<String> {
+    let mut ids: Vec<String> = rows
+        .iter()
+        .filter(|row| matches!(row.source, TaskSource::ClaudeTasks))
+        .map(|row| row.task.id.to_string())
+        .collect();
+    ids.sort();
+    ids
+}
+
+/// (source child or "feed", task id) per row, for failure messages.
+fn merge_summary(rows: &[TaskRow]) -> Vec<(String, String)> {
+    rows.iter()
+        .map(|row| {
+            (
+                merge_child_of(row).unwrap_or_else(|| "feed".to_string()),
+                row.task.id.to_string(),
+            )
+        })
+        .collect()
+}
+
+/// A native row shows its registry status, variant for variant (exhaustive: a seventh
+/// `SubagentRowStatus` fails to compile here).
+fn expected_native_status(status: SubagentRowStatus) -> TaskRowStatus {
+    match status {
+        SubagentRowStatus::Running => TaskRowStatus::Running,
+        SubagentRowStatus::Waiting => TaskRowStatus::Waiting,
+        SubagentRowStatus::Done => TaskRowStatus::Done,
+        SubagentRowStatus::Failed => TaskRowStatus::Failed,
+        SubagentRowStatus::Canceled => TaskRowStatus::Canceled,
+        SubagentRowStatus::Unknown => TaskRowStatus::Unknown,
+    }
+}
+
+/// A feed-only row's status from its entry; `None` where no mapping is pinned (`Interrupted`).
+fn expected_feed_status(status: AgentTaskStatus) -> Option<TaskRowStatus> {
+    match status {
+        AgentTaskStatus::Running => Some(TaskRowStatus::Running),
+        AgentTaskStatus::Completed => Some(TaskRowStatus::Done),
+        AgentTaskStatus::Failed => Some(TaskRowStatus::Failed),
+        AgentTaskStatus::Stopped => Some(TaskRowStatus::Canceled),
+        AgentTaskStatus::Interrupted => None,
+    }
+}
+
+// ── Wrongly collapse: distinct things stay distinct (R3.3, R3.2) ───────────
+
+#[test]
+fn tasks_card_merge_children_sharing_a_label_stay_two_rows() {
+    let subagents = [
+        native_row("c-a", "Explore", SubagentRowStatus::Running),
+        native_row("c-b", "Explore", SubagentRowStatus::Running),
+    ];
+    // Same description, no child: an old-style entry that only looks like the children.
+    let feed = [feed_task(
+        "t-plain",
+        "Explore",
+        AgentTaskStatus::Running,
+        None,
+    )];
+
+    let rows = merge(&subagents, &feed);
+
+    merge_the_native_row(&rows, "c-a");
+    merge_the_native_row(&rows, "c-b");
+    assert_eq!(
+        merge_feed_ids(&rows),
+        ["t-plain"],
+        "an entry without childSessionId is never folded by title: {:?}",
+        merge_summary(&rows)
+    );
+    assert_eq!(rows.len(), 3, "{:?}", merge_summary(&rows));
+}
+
+#[test]
+fn tasks_card_merge_entry_naming_an_unregistered_child_stays_a_feed_row() {
+    let subagents = [native_row("c-a", "Explore", SubagentRowStatus::Running)];
+    let feed = [feed_task(
+        "t-orphan",
+        "Explore",
+        AgentTaskStatus::Running,
+        Some("c-unregistered"),
+    )];
+
+    let rows = merge(&subagents, &feed);
+
+    merge_the_native_row(&rows, "c-a");
+    assert!(
+        merge_native_rows(&rows, "c-unregistered").is_empty(),
+        "no native row is invented for a child the registry does not hold"
+    );
+    assert_eq!(merge_feed_ids(&rows), ["t-orphan"]);
+    assert_eq!(rows.len(), 2, "{:?}", merge_summary(&rows));
+}
+
+// An entry whose own task id equals another child's session id folds into the child it NAMES,
+// never into the child its id happens to spell.
+#[test]
+fn tasks_card_merge_entry_id_spelling_a_child_id_folds_only_by_child_session_id() {
+    let subagents = [
+        native_row("c-a", "First", SubagentRowStatus::Running),
+        native_row("c-b", "Second", SubagentRowStatus::Running),
+    ];
+    let feed = [feed_task(
+        "c-b",
+        "Feed",
+        AgentTaskStatus::Running,
+        Some("c-a"),
+    )];
+
+    let rows = merge(&subagents, &feed);
+
+    let first = merge_the_native_row(&rows, "c-a");
+    let second = merge_the_native_row(&rows, "c-b");
+    assert_eq!(
+        first.task.summary.as_ref().map(|s| s.to_string()),
+        Some("Summary of c-b".to_string()),
+        "the entry enriches the child it names"
+    );
+    assert_ne!(
+        second.task.summary.as_ref().map(|s| s.to_string()),
+        Some("Summary of c-b".to_string()),
+        "and never the child whose id its own id spells"
+    );
+    assert!(
+        merge_feed_ids(&rows).is_empty(),
+        "{:?}",
+        merge_summary(&rows)
+    );
+    assert_eq!(rows.len(), 2);
+}
+
+// ── Wrongly split: one child is one row (R3.1) ─────────────────────────────
+
+#[test]
+fn tasks_card_merge_entry_naming_a_child_folds_into_its_row_whatever_its_description() {
+    let subagents = [native_row(
+        "c-a",
+        "Explore the parser",
+        SubagentRowStatus::Running,
+    )];
+    let feed = [feed_task(
+        "t-agent-7",
+        "Different words entirely",
+        AgentTaskStatus::Running,
+        Some("c-a"),
+    )];
+
+    let rows = merge(&subagents, &feed);
+
+    assert_eq!(
+        rows.len(),
+        1,
+        "one row for the child: {:?}",
+        merge_summary(&rows)
+    );
+    let row = merge_the_native_row(&rows, "c-a");
+    assert_eq!(
+        row.task.usage, feed[0].usage,
+        "the feed's usage is shown on the native row"
+    );
+    assert_eq!(row.task.last_tool, feed[0].last_tool, "and its last tool");
+    assert_eq!(row.task.summary, feed[0].summary, "and its summary");
+    assert!(merge_feed_ids(&rows).is_empty());
+}
+
+#[test]
+fn tasks_card_merge_two_entries_naming_one_child_are_still_one_row() {
+    let subagents = [native_row("c-a", "Explore", SubagentRowStatus::Running)];
+    let feed = [
+        feed_task("t-first", "Explore", AgentTaskStatus::Running, Some("c-a")),
+        feed_task(
+            "t-again",
+            "Explore again",
+            AgentTaskStatus::Running,
+            Some("c-a"),
+        ),
+    ];
+
+    let rows = merge(&subagents, &feed);
+
+    merge_the_native_row(&rows, "c-a");
+    assert!(
+        merge_feed_ids(&rows).is_empty(),
+        "no entry naming the child survives beside its row: {:?}",
+        merge_summary(&rows)
+    );
+    assert_eq!(rows.len(), 1);
+}
+
+// ── R3.4: the registry's status wins ───────────────────────────────────────
+
+#[test]
+fn tasks_card_merge_status_disagreement_shows_the_registry_status() {
+    for (native, feed_status) in [
+        (SubagentRowStatus::Waiting, AgentTaskStatus::Running),
+        (SubagentRowStatus::Unknown, AgentTaskStatus::Completed),
+        (SubagentRowStatus::Failed, AgentTaskStatus::Completed),
+        (SubagentRowStatus::Running, AgentTaskStatus::Completed),
+        (SubagentRowStatus::Done, AgentTaskStatus::Running),
+        (SubagentRowStatus::Canceled, AgentTaskStatus::Failed),
+    ] {
+        let expected = expected_native_status(native);
+        assert_ne!(
+            Some(expected),
+            expected_feed_status(feed_status),
+            "precondition for {native:?}: the two sources really disagree"
+        );
+        let row = native_row("c-a", "Explore", native);
+        let feed = [feed_task("t-1", "Explore", feed_status, Some("c-a"))];
+
+        let rows = merge(std::slice::from_ref(&row), &feed);
+
+        assert_eq!(
+            merge_the_native_row(&rows, "c-a").status,
+            expected,
+            "{native:?} vs feed {feed_status:?}: the registry's status is shown"
+        );
+    }
+}
+
+// ── Benign: each source alone ──────────────────────────────────────────────
+
+// R3.2: with no native rows, the feed's entries are the rows, unchanged.
+#[test]
+fn tasks_card_merge_feed_only_entries_are_shown_as_today() {
+    let feed = [
+        feed_task("t-shell", "Run the tests", AgentTaskStatus::Running, None),
+        feed_task("t-done", "Build", AgentTaskStatus::Completed, None),
+        feed_task("t-failed", "Lint", AgentTaskStatus::Failed, None),
+        feed_task("t-stopped", "Watch", AgentTaskStatus::Stopped, None),
+    ];
+
+    let rows = merge(&[], &feed);
+
+    assert_eq!(
+        merge_feed_ids(&rows),
+        ["t-done", "t-failed", "t-shell", "t-stopped"]
+    );
+    for task in &feed {
+        let row = rows
+            .iter()
+            .find(|row| row.task.id == task.id)
+            .expect("the entry is a row");
+        assert_eq!(&row.task, task, "a feed row carries its entry as it came");
+        assert!(matches!(row.source, TaskSource::ClaudeTasks));
+        assert_eq!(
+            Some(row.status),
+            expected_feed_status(task.status),
+            "{}: a feed row maps its entry's status",
+            task.id
+        );
+    }
+}
+
+// R1.3 through the merge: every native status reaches the row, Waiting and Unknown included.
+#[test]
+fn tasks_card_merge_native_rows_show_all_six_statuses() {
+    let statuses = [
+        ("c-run", SubagentRowStatus::Running),
+        ("c-wait", SubagentRowStatus::Waiting),
+        ("c-done", SubagentRowStatus::Done),
+        ("c-fail", SubagentRowStatus::Failed),
+        ("c-cancel", SubagentRowStatus::Canceled),
+        ("c-unknown", SubagentRowStatus::Unknown),
+    ];
+    let subagents = statuses.map(|(child, status)| native_row(child, "Explore", status));
+
+    let rows = merge(&subagents, &[]);
+
+    for (child, status) in statuses {
+        assert_eq!(
+            merge_the_native_row(&rows, child).status,
+            expected_native_status(status),
+            "{child}"
+        );
+    }
+}
+
+#[test]
+fn tasks_card_merge_native_rows_without_entries_are_kept_with_their_grant() {
+    let mut refused = native_row("c-refused", "No stop", SubagentRowStatus::Waiting);
+    refused.can_cancel = false;
+    let subagents = [
+        native_row("c-a", "Explore", SubagentRowStatus::Running),
+        refused,
+    ];
+
+    let rows = merge(&subagents, &[]);
+
+    assert_eq!(rows.len(), 2, "{:?}", merge_summary(&rows));
+    for (child, can_cancel) in [("c-a", true), ("c-refused", false)] {
+        match &merge_the_native_row(&rows, child).source {
+            TaskSource::AcpSubagent {
+                can_cancel: granted,
+                ..
+            } => assert_eq!(
+                *granted, can_cancel,
+                "{child}: the row keeps its Stop grant"
+            ),
+            TaskSource::ClaudeTasks => unreachable!("looked up as a native row"),
+        }
+    }
+}
diff --git a/crates/agent_ui/src/tasks_card/native_subagent_support.rs b/crates/agent_ui/src/tasks_card/native_subagent_support.rs
new file mode 100644
index 0000000..0d2623a
--- /dev/null
+++ b/crates/agent_ui/src/tasks_card/native_subagent_support.rs
@@ -0,0 +1,225 @@
+// Story 064, task 4.1 — shared fixtures for the tasks card's native subagent rows (R1.1, R1.4,
+// R1.5, R1.6, R6.1). Patch `0037` (fixup). Wired in `src/tasks_card.rs` beside `test_helpers`:
+//
+//     #[cfg(test)]
+//     mod native_subagent_support;
+//     #[cfg(test)]
+//     #[path = "tasks_card/native_subagent_tests.rs"]
+//     mod native_subagent;
+//
+// and in `src/tasks_card/tray.rs`, beside `mod dedupe;`:
+//
+//     #[cfg(test)]
+//     #[path = "tray/subagent_tests.rs"]
+//     mod subagent;
+//
+// Validation filter: `cargo test -p agent_ui tasks_card`.
+//
+// A child is built the way `agent_servers::acp::apply_subagent_update` builds one — an
+// `agent_owned` `AcpThread` whose parent session is the thread's, its association applied, then
+// `register_subagent` on the parent (which emits `SubagentSpawned`). The stub connection cannot
+// do that itself: subagent updates are routed by `agent_servers`, not by `AcpThread`. The child is
+// made known to the stub with `track_session`, so `session/cancel` on it reaches the stub's
+// `cancel_calls` instead of failing with "Session not found".
+//
+// Contract assumed beyond design.md (adjust HERE, not in each test):
+//   - `TaskRow::source: TaskSource` and `TaskSource::AcpSubagent { child: acp_v2::SessionId,
+//     can_cancel: bool }`, reachable as `super::TaskSource` from the card's module;
+//   - a native row still carries `task.id` (its row key with the parent's session), which the
+//     existing per-row selectors (`tasks-card-row-{id}`, `tasks-card-stop-{id}`,
+//     `tasks-card-open-{id}`) and `request_stop(parent, id, …)` use. The tests read the id from
+//     the row; they never spell it.
+//   - Revision 1 (operator, 2026-10-29): `TaskRow::status: TaskRowStatus` (Running, Waiting, Done,
+//     Failed, Canceled, Unknown), reachable as `super::TaskRowStatus`; the acp_thread side's row
+//     status is `SubagentRowStatus` (advisor A's 1.1).
+
+#![allow(dead_code)]
+
+use std::rc::Rc;
+use std::time::Duration;
+
+use acp_thread::{AcpThread, AgentConnection, StubAgentConnection};
+use agent_client_protocol::v1::schema as acp;
+use agent_client_protocol::v2::schema as acp_v2;
+use gpui::{AppContext as _, Entity, TestAppContext, VisualTestContext};
+use workspace::Workspace;
+
+use super::test_helpers::*;
+use super::{TaskRow, TaskSource, TasksCard};
+use crate::AgentPanel;
+
+/// A visible agent panel holding one titled parent thread on a stub that follows the subagents
+/// RFD.
+pub(crate) struct SubagentRig {
+    pub panel: Entity<AgentPanel>,
+    pub workspace: Entity<Workspace>,
+    pub connection: StubAgentConnection,
+    pub parent: Entity<AcpThread>,
+    pub parent_session: acp_v2::SessionId,
+    pub card: Entity<TasksCard>,
+    pub cx: VisualTestContext,
+}
+
+pub(crate) async fn subagent_rig(cx: &mut TestAppContext) -> SubagentRig {
+    let (panel, workspace, mut cx) = setup_card_panel_in_workspace(cx).await;
+    let connection = StubAgentConnection::new().with_announced_subagents(true);
+    crate::test_support::open_thread_with_connection(&panel, connection.clone(), &mut cx);
+    let parent = panel.read_with(&cx, |panel, cx| {
+        panel.active_agent_thread(cx).expect("a thread was opened")
+    });
+    send_info(
+        &parent,
+        acp::SessionInfoUpdate::new().title("Parent"),
+        &mut cx,
+    );
+    let parent_session = session_of(&parent, &cx);
+    let card = card(&panel, &cx);
+    SubagentRig {
+        panel,
+        workspace,
+        connection,
+        parent,
+        parent_session,
+        card,
+        cx,
+    }
+}
+
+impl SubagentRig {
+    /// Announces one child of the parent, as an ACP `subagent_update` does: its title (the
+    /// association's) and whether the agent lets Zed cancel it. The child reports no state yet.
+    pub(crate) fn spawn_child(
+        &mut self,
+        session: &str,
+        title: Option<&str>,
+        can_cancel: bool,
+    ) -> Entity<AcpThread> {
+        let parent_session = self.parent_session.clone();
+        let connection: Rc<dyn AgentConnection> = Rc::new(self.connection.clone());
+        let parent = self.parent.clone();
+        let child = self.cx.update(|_, cx| {
+            let project = parent.read(cx).project().clone();
+            let action_log = cx.new(|_| action_log::ActionLog::new(project.clone()));
+            cx.new(|cx| {
+                AcpThread::new(
+                    Some(parent_session),
+                    None,
+                    None,
+                    connection,
+                    project,
+                    action_log,
+                    acp_v2::SessionId::new(session.to_string()),
+                    watch::Receiver::constant(acp_v2::PromptCapabilities::new()),
+                    cx,
+                )
+                .agent_owned()
+            })
+        });
+        self.cx
+            .update(|_, cx| self.connection.track_session(&child, cx));
+        let mut update = acp_v2::SubagentUpdate::new(session.to_string());
+        if let Some(title) = title {
+            update = update.title(title.to_string());
+        }
+        if can_cancel {
+            update = update.capabilities(
+                acp_v2::SubagentSessionCapabilities::new()
+                    .cancel(acp_v2::SessionCancelCapabilities::new()),
+            );
+        }
+        child
+            .update(&mut self.cx, |child, cx| {
+                child.update_subagent_association(&update, cx)
+            })
+            .expect("an agent-owned thread takes an association");
+        // Its own update, as each ACP notification is applied.
+        self.parent.update(&mut self.cx, |parent, cx| {
+            parent.register_subagent(child.clone(), cx)
+        });
+        child
+    }
+
+    /// The child reports a state snapshot (the `state` of a v1 `subagent_update`).
+    pub(crate) fn report(&mut self, child: &Entity<AcpThread>, state: acp_v2::StateUpdate) {
+        child
+            .update(&mut self.cx, |child, cx| {
+                child.update_session_state(state, cx)
+            })
+            .expect("an agent-owned thread reports its state");
+        self.cx.run_until_parked();
+    }
+
+    pub(crate) fn report_running(&mut self, child: &Entity<AcpThread>) {
+        self.report(
+            child,
+            acp_v2::StateUpdate::Running(acp_v2::RunningStateUpdate::new()),
+        );
+    }
+
+    pub(crate) fn report_idle(&mut self, child: &Entity<AcpThread>, stop: acp_v2::StopReason) {
+        self.report(
+            child,
+            acp_v2::StateUpdate::Idle(acp_v2::IdleStateUpdate::new().stop_reason(stop)),
+        );
+    }
+
+    /// The card's row for `child`, found by its native source — never by title.
+    pub(crate) fn row_of(&mut self, child: &Entity<AcpThread>) -> Option<TaskRow> {
+        let child_session = session_of(child, &self.cx);
+        let card = self.card.clone();
+        rows_of(&card, &self.parent_session, &mut self.cx)
+            .into_iter()
+            .find(|row| {
+                matches!(
+                    &row.source,
+                    TaskSource::AcpSubagent { child, .. } if *child == child_session
+                )
+            })
+    }
+
+    /// How many rows of the parent's group stand for `child`.
+    pub(crate) fn rows_for(&mut self, child: &Entity<AcpThread>) -> usize {
+        let child_session = session_of(child, &self.cx);
+        let card = self.card.clone();
+        rows_of(&card, &self.parent_session, &mut self.cx)
+            .into_iter()
+            .filter(|row| {
+                matches!(
+                    &row.source,
+                    TaskSource::AcpSubagent { child, .. } if *child == child_session
+                )
+            })
+            .count()
+    }
+
+    /// Starts a parent turn that stays in flight: the stub keeps the prompt open until
+    /// `end_turn`, so the parent reads Generating meanwhile.
+    pub(crate) fn start_parent_turn(&mut self) {
+        crate::test_support::send_message(&self.panel, &mut self.cx);
+    }
+
+    /// The session of the thread the conversation shows (a child after navigation, else the
+    /// root).
+    pub(crate) fn shown_session(&self) -> Option<acp_v2::SessionId> {
+        self.panel.read_with(&self.cx, |panel, cx| {
+            panel
+                .active_conversation_view()
+                .and_then(|view| view.read(cx).active_thread().cloned())
+                .map(|thread_view| thread_view.read(cx).session_id.clone())
+        })
+    }
+
+    pub(crate) fn draw(&mut self) {
+        self.cx.update(|window, _| window.refresh());
+        self.cx.run_until_parked();
+    }
+
+    pub(crate) fn wait(&mut self, duration: Duration) {
+        advance(&mut self.cx, duration);
+    }
+}
+
+/// A selector string the test context can look up (`debug_bounds` takes `&'static str`).
+pub(crate) fn selector(text: String) -> &'static str {
+    Box::leak(text.into_boxed_str())
+}
diff --git a/crates/agent_ui/src/tasks_card/native_subagent_tests.rs b/crates/agent_ui/src/tasks_card/native_subagent_tests.rs
new file mode 100644
index 0000000..467c621
--- /dev/null
+++ b/crates/agent_ui/src/tasks_card/native_subagent_tests.rs
@@ -0,0 +1,361 @@
+// Story 064, task 4.1 — native subagents on the tasks card: rows, Stop, Open (R1.1, R1.4, R1.5,
+// R1.6). Module path: `tasks_card::native_subagent`; fixtures in `native_subagent_support.rs`
+// (its header names the wiring and the contract assumed beyond design.md). Validation:
+// `cargo test -p agent_ui tasks_card`.
+//
+// Side effects are read where they land, never from the card's own word: a Stop is the
+// `session/cancel` the stub connection received (which session, how many), the parent's own
+// `ThreadStatus`, and the sibling's native `ForegroundActivity`; an Open is the session the
+// conversation shows.
+//
+// Hostile halves first:
+//   - Stop cancels THAT child: never the parent (the child must not collapse into its parent),
+//     never a sibling, never a same-titled sibling (the title is not the identity);
+//   - a Stop the agent does not allow is shown DISABLED with its reason — not hidden, not tried
+//     and failed (AcpThread's own guard would turn a naive cancel into an error on the row);
+//   - Open navigates to the CHILD, not to the parent whose group the row sits in, and to the
+//     child that was opened, not its sibling;
+//   - then the benign row, Stop and Open.
+
+use std::time::Duration;
+
+use acp_thread::{ForegroundActivity, ThreadStatus};
+use agent_client_protocol::v2::schema as acp_v2;
+use gpui::{Modifiers, TestAppContext};
+
+use super::RENDERED_ARIA_LABELS;
+use super::native_subagent_support::*;
+use super::test_helpers::*;
+
+/// The disabled Stop's reason (R1.5), byte for byte.
+const STOP_NOT_ALLOWED: &str = "This agent does not allow stopping its subagents";
+
+/// Confirms the card's Stop prompt when it asks one: whether a native Stop asks first is the
+/// card's choice, what it sends is what these tests pin.
+fn answer_stop_if_asked(rig: &mut SubagentRig) {
+    if rig.cx.has_pending_prompt() {
+        rig.cx.simulate_prompt_answer("Stop");
+        rig.cx.run_until_parked();
+    }
+}
+
+fn stop_row(rig: &mut SubagentRig, child: &gpui::Entity<acp_thread::AcpThread>) {
+    let id = rig.row_of(child).expect("the child has a row").task.id;
+    let parent = rig.parent_session.clone();
+    let card = rig.card.clone();
+    card.update_in(&mut rig.cx, |card, window, cx| {
+        card.request_stop(parent, id, window, cx)
+    });
+    rig.cx.run_until_parked();
+    answer_stop_if_asked(rig);
+}
+
+// ── R1.4 — Stop cancels that child, and only it ─────────────────────────────
+
+/// Hostile: two running children and a parent turn in flight. Stopping Alpha sends exactly one
+/// `session/cancel`, for Alpha — not the parent, not Bravo — and the parent keeps generating
+/// while Bravo keeps running.
+#[gpui::test]
+async fn test_native_subagent_stop_cancels_only_that_child_and_the_parent_keeps_running(
+    cx: &mut TestAppContext,
+) {
+    let mut rig = subagent_rig(cx).await;
+    rig.start_parent_turn();
+    let alpha = rig.spawn_child("alpha", Some("Alpha"), true);
+    let bravo = rig.spawn_child("bravo", Some("Bravo"), true);
+    rig.report_running(&alpha);
+    rig.report_running(&bravo);
+    assert_eq!(
+        rig.parent.read_with(&rig.cx, |thread, _| thread.status()),
+        ThreadStatus::Generating,
+        "precondition: the parent's turn is in flight"
+    );
+    assert!(rig.row_of(&alpha).is_some() && rig.row_of(&bravo).is_some());
+    rig.connection.take_cancel_calls();
+
+    stop_row(&mut rig, &alpha);
+
+    assert_eq!(
+        rig.connection.take_cancel_calls(),
+        vec![acp_v2::SessionId::new("alpha")],
+        "session/cancel carries the CHILD's id, once, and nothing else is cancelled"
+    );
+    assert_eq!(
+        rig.parent.read_with(&rig.cx, |thread, _| thread.status()),
+        ThreadStatus::Generating,
+        "the parent keeps running"
+    );
+    assert_eq!(
+        bravo.read_with(&rig.cx, |thread, _| thread.foreground_activity()),
+        ForegroundActivity::Running,
+        "the sibling keeps running"
+    );
+    assert!(rig.row_of(&bravo).is_some(), "the sibling keeps its row");
+
+    // The agent confirms: Alpha's row ends, Bravo's stays.
+    rig.report_idle(&alpha, acp_v2::StopReason::Cancelled);
+    assert_eq!(
+        alpha.read_with(&rig.cx, |thread, _| thread.foreground_activity()),
+        ForegroundActivity::Idle
+    );
+    assert!(rig.row_of(&bravo).is_some());
+}
+
+/// Hostile: two children that share a title are two rows, and stopping one cancels only that
+/// one — a card keyed by title would cancel the wrong one or both.
+#[gpui::test]
+async fn test_native_subagent_stop_tells_same_titled_children_apart(cx: &mut TestAppContext) {
+    let mut rig = subagent_rig(cx).await;
+    let first = rig.spawn_child("worker-1", Some("Worker"), true);
+    let second = rig.spawn_child("worker-2", Some("Worker"), true);
+    rig.report_running(&first);
+    rig.report_running(&second);
+    assert_eq!(rig.rows_for(&first), 1);
+    assert_eq!(rig.rows_for(&second), 1);
+    rig.connection.take_cancel_calls();
+
+    stop_row(&mut rig, &second);
+
+    assert_eq!(
+        rig.connection.take_cancel_calls(),
+        vec![acp_v2::SessionId::new("worker-2")]
+    );
+    assert_eq!(
+        first.read_with(&rig.cx, |thread, _| thread.foreground_activity()),
+        ForegroundActivity::Running
+    );
+}
+
+/// A child that is waiting for the user (`requires_action`) can be stopped like a running one.
+#[gpui::test]
+async fn test_native_subagent_stop_reaches_a_waiting_child(cx: &mut TestAppContext) {
+    let mut rig = subagent_rig(cx).await;
+    let alpha = rig.spawn_child("alpha", Some("Alpha"), true);
+    rig.report(
+        &alpha,
+        acp_v2::StateUpdate::RequiresAction(acp_v2::RequiresActionStateUpdate::new()),
+    );
+    assert_eq!(
+        rig.row_of(&alpha).expect("Alpha's row").status,
+        super::TaskRowStatus::Waiting,
+        "the row reads Waiting (Revision 1: TaskRow's own status)"
+    );
+    rig.connection.take_cancel_calls();
+
+    stop_row(&mut rig, &alpha);
+
+    assert_eq!(
+        rig.connection.take_cancel_calls(),
+        vec![acp_v2::SessionId::new("alpha")]
+    );
+}
+
+/// `cancel()` fails: the error is on the row, the child is still running, and the cancel was
+/// attempted for that child.
+#[gpui::test]
+async fn test_native_subagent_failed_cancel_shows_on_the_row(cx: &mut TestAppContext) {
+    let mut rig = subagent_rig(cx).await;
+    let alpha = rig.spawn_child("alpha", Some("Alpha"), true);
+    rig.report_running(&alpha);
+    rig.connection.take_cancel_calls();
+    rig.connection
+        .set_next_cancel_error(anyhow::anyhow!("the agent refused: boom"));
+
+    stop_row(&mut rig, &alpha);
+
+    assert_eq!(
+        rig.connection.take_cancel_calls(),
+        vec![acp_v2::SessionId::new("alpha")]
+    );
+    let row = rig.row_of(&alpha).expect("the child is still listed");
+    let error = row.error.expect("the cancel error reaches the row");
+    assert!(error.contains("boom"), "row error was {error:?}");
+    assert_eq!(
+        alpha.read_with(&rig.cx, |thread, _| thread.foreground_activity()),
+        ForegroundActivity::Running,
+        "a failed cancel leaves the child running"
+    );
+}
+
+// ── R1.5 — no cancel grant: Stop is shown, disabled, with the reason ────────
+
+/// Hostile: the agent grants no cancel. The Stop control is painted (not hidden) and labelled
+/// with the reason; using it sends nothing and leaves no error on the row. A card that tried
+/// anyway would get AcpThread's own refusal back as a row error.
+#[gpui::test]
+async fn test_native_subagent_without_cancel_shows_stop_disabled_with_the_reason(
+    cx: &mut TestAppContext,
+) {
+    let mut rig = subagent_rig(cx).await;
+    let alpha = rig.spawn_child("alpha", Some("Alpha"), false);
+    rig.report_running(&alpha);
+    rig.connection.take_cancel_calls();
+    RENDERED_ARIA_LABELS.with_borrow_mut(Vec::clear);
+    rig.draw();
+
+    let id = rig.row_of(&alpha).expect("the child has a row").task.id;
+    let stop = selector(format!("tasks-card-stop-{id}"));
+    let bounds = rig
+        .cx
+        .debug_bounds(stop)
+        .expect("the Stop control is painted, disabled, not hidden");
+    let labels: Vec<String> = RENDERED_ARIA_LABELS
+        .with_borrow(|labels| labels.iter().map(|label| label.to_string()).collect());
+    assert!(
+        labels.iter().any(|label| label.contains(STOP_NOT_ALLOWED)),
+        "the disabled Stop names its reason: {labels:?}"
+    );
+
+    rig.cx.simulate_click(bounds.center(), Modifiers::none());
+    rig.cx.run_until_parked();
+    assert!(!rig.cx.has_pending_prompt(), "a disabled Stop asks nothing");
+    let parent = rig.parent_session.clone();
+    let card = rig.card.clone();
+    card.update_in(&mut rig.cx, |card, window, cx| {
+        card.request_stop(parent, id.clone(), window, cx)
+    });
+    rig.cx.run_until_parked();
+    answer_stop_if_asked(&mut rig);
+
+    assert!(
+        rig.connection.take_cancel_calls().is_empty(),
+        "nothing is cancelled without the agent's grant"
+    );
+    let row = rig.row_of(&alpha).expect("still listed");
+    assert!(
+        row.error.is_none(),
+        "no error: the Stop was never tried ({:?})",
+        row.error
+    );
+    assert_eq!(
+        alpha.read_with(&rig.cx, |thread, _| thread.foreground_activity()),
+        ForegroundActivity::Running
+    );
+}
+
+/// Converse: the grant arriving later enables Stop — the reason is about the grant, not the
+/// child.
+#[gpui::test]
+async fn test_native_subagent_stop_follows_a_later_cancel_grant(cx: &mut TestAppContext) {
+    let mut rig = subagent_rig(cx).await;
+    let alpha = rig.spawn_child("alpha", Some("Alpha"), false);
+    rig.report_running(&alpha);
+    alpha
+        .update(&mut rig.cx, |child, cx| {
+            child.update_subagent_association(
+                &acp_v2::SubagentUpdate::new("alpha").capabilities(
+                    acp_v2::SubagentSessionCapabilities::new()
+                        .cancel(acp_v2::SessionCancelCapabilities::new()),
+                ),
+                cx,
+            )
+        })
+        .expect("association update");
+    rig.cx.run_until_parked();
+    rig.connection.take_cancel_calls();
+
+    stop_row(&mut rig, &alpha);
+
+    assert_eq!(
+        rig.connection.take_cancel_calls(),
+        vec![acp_v2::SessionId::new("alpha")]
+    );
+}
+
+// ── R1.6 — Open navigates the conversation to the child ─────────────────────
+
+/// Hostile: Open on Bravo's row shows Bravo — not the parent whose group holds the row (which
+/// is what the card's Open does for a feed task) and not Alpha.
+#[gpui::test]
+async fn test_native_subagent_open_shows_that_child(cx: &mut TestAppContext) {
+    let mut rig = subagent_rig(cx).await;
+    let alpha = rig.spawn_child("alpha", Some("Alpha"), true);
+    let bravo = rig.spawn_child("bravo", Some("Bravo"), true);
+    rig.report_running(&alpha);
+    rig.report_running(&bravo);
+    assert_eq!(
+        rig.shown_session(),
+        Some(rig.parent_session.clone()),
+        "precondition: the conversation shows the parent"
+    );
+    rig.draw();
+
+    let id = rig.row_of(&bravo).expect("Bravo has a row").task.id;
+    let open = selector(format!("tasks-card-open-{id}"));
+    let bounds = rig.cx.debug_bounds(open).expect("Open is painted");
+    rig.cx.simulate_click(bounds.center(), Modifiers::none());
+    rig.cx.run_until_parked();
+
+    assert_eq!(
+        rig.shown_session(),
+        Some(acp_v2::SessionId::new("bravo")),
+        "Open navigates to the child, as the native card's Full Screen does"
+    );
+    // The parent is still the conversation's root: navigation, not a new conversation.
+    assert_eq!(
+        rig.panel.read_with(&rig.cx, |panel, cx| panel
+            .active_agent_thread(cx)
+            .map(|thread| thread.read(cx).session_id().clone())),
+        Some(rig.parent_session.clone())
+    );
+}
+
+/// An ended (idle) child's row still opens its child while it is on show.
+#[gpui::test]
+async fn test_native_subagent_open_on_an_ended_row(cx: &mut TestAppContext) {
+    let mut rig = subagent_rig(cx).await;
+    let alpha = rig.spawn_child("alpha", Some("Alpha"), true);
+    rig.report_running(&alpha);
+    rig.report_idle(&alpha, acp_v2::StopReason::EndTurn);
+    rig.draw();
+    let Some(row) = rig.row_of(&alpha) else {
+        panic!("the ended row is on show right after the ending");
+    };
+    let open = selector(format!("tasks-card-open-{}", row.task.id));
+    let bounds = rig.cx.debug_bounds(open).expect("Open is painted");
+    rig.cx.simulate_click(bounds.center(), Modifiers::none());
+    rig.cx.run_until_parked();
+    assert_eq!(rig.shown_session(), Some(acp_v2::SessionId::new("alpha")));
+}
+
+// ── R1.1 — a row within one frame of SubagentSpawned ────────────────────────
+
+/// The row appears on the spawn alone: no feed snapshot, no state report, no parent event
+/// other than `SubagentSpawned`. It is painted on the next frame.
+#[gpui::test]
+async fn test_native_subagent_row_appears_on_spawn(cx: &mut TestAppContext) {
+    let mut rig = subagent_rig(cx).await;
+    assert!(
+        !visible(&rig.card.clone(), &mut rig.cx),
+        "precondition: nothing on the card"
+    );
+
+    let alpha = rig.spawn_child("alpha", Some("Alpha"), true);
+    rig.cx.run_until_parked();
+
+    let row = rig.row_of(&alpha).expect("the spawn alone adds the row");
+    assert!(visible(&rig.card.clone(), &mut rig.cx), "the card shows");
+    rig.draw();
+    let painted = selector(format!("tasks-card-row-{}", row.task.id));
+    assert!(
+        rig.cx.debug_bounds(painted).is_some(),
+        "the row is painted on the next frame"
+    );
+    assert_eq!(rig.rows_for(&alpha), 1, "one row per child");
+}
+
+/// Two children, two rows, each found by its own session.
+#[gpui::test]
+async fn test_native_subagent_two_children_are_two_rows(cx: &mut TestAppContext) {
+    let mut rig = subagent_rig(cx).await;
+    let alpha = rig.spawn_child("alpha", Some("Alpha"), true);
+    let bravo = rig.spawn_child("bravo", None, true);
+    rig.report_running(&alpha);
+    rig.report_running(&bravo);
+    rig.wait(Duration::from_millis(10));
+    assert_eq!(rig.rows_for(&alpha), 1);
+    assert_eq!(rig.rows_for(&bravo), 1);
+    let alpha_id = rig.row_of(&alpha).unwrap().task.id;
+    let bravo_id = rig.row_of(&bravo).unwrap().task.id;
+    assert_ne!(alpha_id, bravo_id, "each row has its own key");
+}
diff --git a/crates/agent_ui/src/tasks_card/sub_thread_row.rs b/crates/agent_ui/src/tasks_card/sub_thread_row.rs
new file mode 100644
index 0000000..811bf57
--- /dev/null
+++ b/crates/agent_ui/src/tasks_card/sub_thread_row.rs
@@ -0,0 +1,287 @@
+//! Story 040: sub-thread rows in the tasks card (design.md, SubThreadReporter, "Card rows").
+//!
+//! A sub-thread is a whole conversation, not a task of its parent, so its row offers only Open:
+//! Stop, Send to Background, Expand, Open Log and Open Thread belong to adapter tasks (R6.5).
+
+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+
+use acp_thread::AgentTaskId;
+use agent_client_protocol::v2::schema as acp_v2;
+use collections::HashSet;
+use gpui::{AnyElement, App, Context, SharedString};
+use ui::{Chip, prelude::*};
+
+use super::{
+    ENDED_ROW_TTL, RowControl, TaskKey, TasksCard, TasksCardEvent, ThreadGroup, card_tooltip,
+    format_duration, row_aria_label,
+};
+use crate::DEFAULT_THREAD_TITLE;
+use crate::sub_thread_reporter::{SubThreadActivity, SubThreadRecord, project_of};
+use crate::thread_metadata_store::ThreadMetadataStore;
+
+/// The prefix of a sub-thread item's task id in the history; reused here so a row's Open and its
+/// error share one key with nothing the adapter reports.
+const SUB_THREAD_KEY_PREFIX: &str = "zeo-sub-thread:";
+
+/// One sub-thread on the card: running, or idle for the ended window (R6.1, R6.3).
+#[derive(Clone, Debug)]
+pub struct SubThreadRow {
+    pub record: SubThreadRecord,
+    pub running: bool,
+    /// When it turned Idle, on the card's clock; `None` while running.
+    pub idle_since: Option<Instant>,
+}
+
+impl SubThreadRow {
+    fn on_show(&self, now: Instant) -> bool {
+        self.running
+            || self
+                .idle_since
+                .is_some_and(|since| now < since + ENDED_ROW_TTL)
+    }
+
+    fn started_at(&self) -> SystemTime {
+        let ms = u64::try_from(self.record.at_ms).unwrap_or_default();
+        UNIX_EPOCH + Duration::from_millis(ms)
+    }
+}
+
+/// The key a sub-thread row's Open control and its error live under.
+fn row_key(sub_session: &acp_v2::SessionId) -> TaskKey {
+    (
+        sub_session.clone(),
+        AgentTaskId::from(format!("{SUB_THREAD_KEY_PREFIX}{sub_session}")),
+    )
+}
+
+impl TasksCard {
+    /// Keeps the card's rows in step with what the reporter tells the history, so the card and
+    /// the panel never disagree about a sub-thread.
+    pub(crate) fn note_sub_thread_activity(
+        &mut self,
+        activity: &SubThreadActivity,
+        cx: &mut Context<Self>,
+    ) {
+        match activity {
+            SubThreadActivity::Linked(_) => return,
+            SubThreadActivity::Running(record) => {
+                self.sub_thread_rows.insert(
+                    record.sub_session.clone(),
+                    SubThreadRow {
+                        record: record.clone(),
+                        running: true,
+                        idle_since: None,
+                    },
+                );
+            }
+            SubThreadActivity::Idle(record) => {
+                let now = cx.background_executor().now();
+                let row = self
+                    .sub_thread_rows
+                    .entry(record.sub_session.clone())
+                    .or_insert_with(|| SubThreadRow {
+                        record: record.clone(),
+                        running: false,
+                        idle_since: None,
+                    });
+                row.running = false;
+                row.idle_since = Some(now);
+                row.record.title = record.title.clone();
+                self.schedule_expiry(cx);
+            }
+            SubThreadActivity::Retitled(record) => {
+                let Some(row) = self.sub_thread_rows.get_mut(&record.sub_session) else {
+                    return;
+                };
+                row.record.title = record.title.clone();
+            }
+        }
+        self.update_tick(cx);
+        cx.notify();
+    }
+
+    /// The rows on show now, keyed by nothing: each names its parent in its record.
+    pub(super) fn sub_thread_rows_on_show(&self, cx: &App) -> Vec<SubThreadRow> {
+        let now = cx.background_executor().now();
+        let mut rows: Vec<SubThreadRow> = self
+            .sub_thread_rows
+            .values()
+            .filter(|row| row.on_show(now))
+            .cloned()
+            .collect();
+        rows.sort_by(|a, b| {
+            (a.record.at_ms, &a.record.sub_session.0)
+                .cmp(&(b.record.at_ms, &b.record.sub_session.0))
+        });
+        rows
+    }
+
+    /// The earliest moment an idle row leaves the card, for `schedule_expiry`.
+    pub(super) fn earliest_sub_thread_expiry(&self) -> Option<Instant> {
+        self.sub_thread_rows
+            .values()
+            .filter(|row| !row.running)
+            .filter_map(|row| row.idle_since)
+            .map(|since| since + ENDED_ROW_TTL)
+            .min()
+    }
+
+    pub(super) fn expire_sub_thread_rows(&mut self, now: Instant) {
+        let expired: Vec<acp_v2::SessionId> = self
+            .sub_thread_rows
+            .iter()
+            .filter(|(_, row)| !row.on_show(now))
+            .map(|(session, _)| session.clone())
+            .collect();
+        for session in expired {
+            self.sub_thread_rows.remove(&session);
+            self.row_errors.remove(&row_key(&session));
+        }
+    }
+
+    pub(super) fn any_sub_thread_running(&self) -> bool {
+        self.sub_thread_rows.values().any(|row| row.running)
+    }
+
+    /// Opens the sub-thread itself through the card's open path; a failure comes back on the row
+    /// through `set_row_error` (R6.4).
+    fn open_sub_thread(&mut self, sub_session: acp_v2::SessionId, cx: &mut Context<Self>) {
+        let (session_id, task_id) = row_key(&sub_session);
+        cx.emit(TasksCardEvent::OpenTask {
+            session_id,
+            task_id,
+            tool_call_id: None,
+        });
+    }
+
+    /// The order key of a group made only of sub-thread rows, and of the sub-threads inside a
+    /// held parent's group: running first, by start.
+    pub(super) fn oldest_running_sub_thread(rows: &[SubThreadRow]) -> Option<SystemTime> {
+        rows.iter()
+            .filter(|row| row.running)
+            .map(SubThreadRow::started_at)
+            .min()
+    }
+
+    /// Groups for the sub-threads whose parent no panel of this card holds, headed by the
+    /// parent's metadata (R6.2).
+    pub(super) fn groups_of_unheld_parents(
+        rows: Vec<SubThreadRow>,
+        held: &HashSet<acp_v2::SessionId>,
+        cx: &App,
+    ) -> Vec<ThreadGroup> {
+        let mut groups: Vec<ThreadGroup> = Vec::new();
+        for row in rows {
+            let parent = row.record.parent_session.clone();
+            if held.contains(&parent) {
+                continue;
+            }
+            if let Some(group) = groups.iter_mut().find(|group| group.session_id == parent) {
+                group.sub_threads.push(row);
+                continue;
+            }
+            let metadata = ThreadMetadataStore::try_global(cx)
+                .and_then(|store| store.read(cx).entry_by_session(&parent).cloned());
+            let (title, project) = match metadata {
+                Some(metadata) => (metadata.display_title(), project_of(&metadata)),
+                None => (
+                    SharedString::new_static(DEFAULT_THREAD_TITLE),
+                    row.record.project.clone(),
+                ),
+            };
+            groups.push(ThreadGroup {
+                session_id: parent,
+                title,
+                project,
+                rows: Vec::new(),
+                sub_threads: vec![row],
+            });
+        }
+        groups
+    }
+
+    pub(super) fn render_sub_thread_row(
+        &mut self,
+        row: SubThreadRow,
+        now: SystemTime,
+        cx: &mut Context<Self>,
+    ) -> AnyElement {
+        let sub = row.record.sub_session.clone();
+        let key = row_key(&sub);
+        let title = row.record.title.clone();
+        let error = self.row_errors.get(&key).map(|error| error.message.clone());
+        let state = if row.running {
+            let elapsed = now.duration_since(row.started_at()).unwrap_or_default();
+            format!("Running · {}", format_duration(elapsed))
+        } else {
+            "Idle".to_string()
+        };
+
+        let open_focus_handle = self.row_focus_handle(&key, RowControl::Open, cx);
+        let open_button = IconButton::new(
+            SharedString::from(format!("tasks-card-sub-thread-open-{sub}")),
+            IconName::ArrowUpRight,
+        )
+        .icon_size(IconSize::Small)
+        .icon_color(Color::Muted)
+        .aria_label(row_aria_label(format!("Open sub-thread: {title}")))
+        .tooltip(card_tooltip(
+            format!("sub-thread-open-{sub}").into(),
+            "Open Sub-thread".into(),
+        ))
+        .track_focus(&open_focus_handle)
+        .on_click(cx.listener({
+            let sub = sub.clone();
+            move |card, _, _, cx| card.open_sub_thread(sub.clone(), cx)
+        }));
+        let open_button = div()
+            .debug_selector({
+                let sub = sub.clone();
+                move || format!("tasks-card-sub-thread-open-{sub}")
+            })
+            .flex_none()
+            .child(open_button);
+
+        let line = h_flex()
+            .gap_1()
+            .min_w_0()
+            .child(
+                div()
+                    .flex_none()
+                    .child(Chip::new("Sub-thread").label_size(LabelSize::XSmall)),
+            )
+            .child(
+                div().flex_1().min_w_0().child(
+                    Label::new(title)
+                        .size(LabelSize::Small)
+                        .single_line()
+                        .truncate(),
+                ),
+            )
+            .child(
+                Label::new(state)
+                    .size(LabelSize::XSmall)
+                    .color(if row.running {
+                        Color::Accent
+                    } else {
+                        Color::Muted
+                    }),
+            )
+            .child(open_button);
+
+        v_flex()
+            .id(SharedString::from(format!("tasks-card-sub-thread-{sub}")))
+            .debug_selector(move || format!("tasks-card-sub-thread-{sub}"))
+            .pl_2()
+            .py_0p5()
+            .child(line)
+            .when_some(error, |this, error| {
+                this.child(
+                    Label::new(error)
+                        .size(LabelSize::XSmall)
+                        .color(Color::Error),
+                )
+            })
+            .into_any_element()
+    }
+}
diff --git a/crates/agent_ui/src/tasks_card/sub_thread_row_tests.rs b/crates/agent_ui/src/tasks_card/sub_thread_row_tests.rs
new file mode 100644
index 0000000..f0aad64
--- /dev/null
+++ b/crates/agent_ui/src/tasks_card/sub_thread_row_tests.rs
@@ -0,0 +1,389 @@
+// Story 040, task 4.1 — sub-thread rows in the tasks card (R6.1–R6.5; Q11, Q12). Module path:
+// `tasks_card::sub_thread_row_tests` (the production rows live in `tasks_card/sub_thread_row.rs`).
+// Wired in `src/tasks_card.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "tasks_card/sub_thread_row_tests.rs"]
+//     mod sub_thread_row_tests;
+//
+// Validation filter: `cargo test -p agent_ui -- tasks_card`. Fixtures:
+// `crate::sub_thread_test_support`.
+//
+// Contract (design.md §SubThreadReporter, "Card rows"):
+//   - `TasksCard::sub_thread_rows: HashMap<acp::SessionId, SubThreadRow>`, keyed by the
+//     sub-thread's session; `SubThreadRow { record, running: bool, idle_since: Option<Instant> }`;
+//   - a row is drawn while running and for `ENDED_ROW_TTL` after `Idle`, in the parent's group
+//     when the panel holds the parent, else in a group built from the parent's metadata;
+//   - the row offers only "Open" (Tab stop, tooltip, accessible label), which opens the
+//     sub-thread; no Stop, Send to Background, Expand, Open Log or Open Thread.
+// Assumed beyond design.md (one place each, rename here):
+//   - debug selectors `tasks-card-sub-thread-{sub session}` on the row and
+//     `tasks-card-sub-thread-open-{sub session}` on its Open control; the existing group header
+//     selector `tasks-card-open-group-{parent session}`;
+//   - Open's tooltip through the card's `card_tooltip`, name `sub-thread-open-{sub session}`, and
+//     its screen-reader label through `row_aria_label`, text `Open sub-thread: {title}`;
+//   - the card counts a sub-thread row as something to show (`is_visible`).
+//
+// Hostile halves first:
+//   - the sub-thread row carries none of the task controls, while the adapter rows beside it keep
+//     theirs, a subagent's "Open Thread" included (R6.5);
+//   - Open on the row opens the SUB-thread, not the parent whose group it sits in;
+//   - an idle row does not outlive the ended window; a running one is not dropped by it;
+//   - then the benign placement under a held and an unheld parent.
+
+use std::path::Path;
+use std::time::Duration;
+
+use agent_client_protocol::v1::schema as acp_v1;
+use agent_client_protocol::v2::schema as acp_v2;
+use gpui::{Entity, Modifiers, TestAppContext, VisualTestContext};
+use serde_json::json;
+
+use super::{ENDED_ROW_TTL, RENDERED_ARIA_LABELS, TasksCard};
+use crate::sub_thread_test_support::*;
+use crate::thread_metadata_store::{ThreadId, ThreadMetadata, ThreadMetadataStore};
+
+fn row_selector(sub: &acp_v2::SessionId) -> String {
+    format!("tasks-card-sub-thread-{sub}")
+}
+
+fn open_selector(sub: &acp_v2::SessionId) -> String {
+    format!("tasks-card-sub-thread-open-{sub}")
+}
+
+fn group_selector(parent: &acp_v2::SessionId) -> String {
+    format!("tasks-card-open-group-{parent}")
+}
+
+fn advance(cx: &mut VisualTestContext, duration: Duration) {
+    cx.executor().advance_clock(duration);
+    cx.run_until_parked();
+}
+
+fn sub_row_running(
+    card: &Entity<TasksCard>,
+    sub: &acp_v2::SessionId,
+    cx: &VisualTestContext,
+) -> Option<bool> {
+    card.read_with(cx, |card, _| {
+        card.sub_thread_rows.get(sub).map(|row| row.running)
+    })
+}
+
+/// One `_claude/tasks` wire task in the adapter's shape (story 012), for the adapter rows.
+fn wire_task(id: &str, kind: &str) -> serde_json::Value {
+    json!({
+        "id": id,
+        "toolCallId": format!("toolu_{id}"),
+        "type": kind,
+        "description": format!("Task {id}"),
+        "background": false,
+        "depth": 0,
+        "status": "running",
+        "startedAt": 1_000,
+        "endedAt": null,
+        "usage": null,
+        "lastTool": null,
+        "summary": null,
+        "hasOutput": false,
+        "tail": null,
+    })
+}
+
+fn send_tasks(conv: &Conv, tasks: Vec<serde_json::Value>, cx: &mut VisualTestContext) {
+    let mut meta = serde_json::Map::new();
+    meta.insert("_claude/tasks".to_string(), json!({ "tasks": tasks }));
+    conv.thread
+        .update(cx, |thread, cx| {
+            thread.handle_session_update(
+                acp_v1::SessionUpdate::SessionInfoUpdate(
+                    acp_v1::SessionInfoUpdate::new().meta(meta),
+                ),
+                cx,
+            )
+        })
+        .expect("the tasks snapshot applies");
+    cx.run_until_parked();
+}
+
+/// Starts a turn in `conv` that does not end until the returned sender answers.
+fn start_turn(
+    rig: &mut Rig,
+    conv: &Conv,
+) -> futures::channel::oneshot::Sender<anyhow::Result<acp_v1::PromptResponse>> {
+    let finish = rig.connection.defer_next_prompt_response();
+    set_composer(&conv.view, "a side question", &mut rig.cx);
+    conv.view
+        .update_in(&mut rig.cx, |view, window, cx| view.send(window, cx));
+    rig.cx.run_until_parked();
+    finish
+}
+
+fn end_turn(
+    finish: futures::channel::oneshot::Sender<anyhow::Result<acp_v1::PromptResponse>>,
+    cx: &mut VisualTestContext,
+) {
+    finish
+        .send(Ok(acp_v1::PromptResponse::new(acp_v1::StopReason::EndTurn)))
+        .expect("the turn is still waiting");
+    cx.run_until_parked();
+}
+
+/// A parent known only to the metadata store: not held by any agent panel.
+fn unheld_parent(cx: &mut VisualTestContext) -> (ThreadId, acp_v2::SessionId) {
+    let thread_id = ThreadId::new();
+    let session = acp_v2::SessionId::new("unheld-parent-session");
+    let now = chrono::Utc::now();
+    let folders = [Path::new("/elsewhere")];
+    cx.update(|_, cx| {
+        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
+            store.save(
+                ThreadMetadata {
+                    thread_id,
+                    session_id: Some(session.clone()),
+                    agent_id: crate::Agent::Stub.id(),
+                    title: Some("Away parent".into()),
+                    title_override: None,
+                    updated_at: now,
+                    created_at: Some(now),
+                    interacted_at: None,
+                    worktree_paths: project::WorktreePaths::from_folder_paths(
+                        &workspace::PathList::new(&folders),
+                    ),
+                    remote_connection: None,
+                    archived: false,
+                },
+                cx,
+            )
+        })
+    });
+    cx.run_until_parked();
+    (thread_id, session)
+}
+
+// ── R6.5: only Open, beside adapter rows that keep their controls ──────────
+
+#[gpui::test]
+async fn tasks_card_sub_thread_row_has_only_open_while_adapter_rows_keep_their_controls(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    send_tasks(
+        &parent,
+        vec![wire_task("t1", "shell"), wire_task("t2", "subagent")],
+        &mut rig.cx,
+    );
+    let _finish = start_turn(&mut rig, &child);
+    draw(&mut rig.cx);
+
+    let sub = &child.session;
+    assert!(
+        painted(row_selector(sub), &mut rig.cx),
+        "the sub-thread row is drawn"
+    );
+    assert!(painted(open_selector(sub), &mut rig.cx), "with its Open");
+    for control in [
+        "stop",
+        "background",
+        "expand",
+        "open-log",
+        "open-thread",
+        "watch",
+    ] {
+        for id in [sub.to_string(), format!("zeo-sub-thread:{sub}")] {
+            assert!(
+                !painted(format!("tasks-card-{control}-{id}"), &mut rig.cx),
+                "no {control} on a sub-thread row"
+            );
+        }
+    }
+    assert!(
+        painted("tasks-card-stop-t1", &mut rig.cx),
+        "the adapter row keeps Stop"
+    );
+    assert!(
+        painted("tasks-card-background-t1", &mut rig.cx),
+        "and Send to Background"
+    );
+    assert!(
+        painted("tasks-card-open-thread-t2", &mut rig.cx),
+        "a subagent task keeps its Open Thread"
+    );
+}
+
+// ── R6.4: Open opens the sub-thread ────────────────────────────────────────
+
+#[gpui::test]
+async fn tasks_card_sub_thread_open_shows_the_sub_thread_not_its_parent(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    let _finish = start_turn(&mut rig, &child);
+    draw(&mut rig.cx);
+    assert_eq!(
+        crate::test_support::active_session_id(&rig.panel, &rig.cx),
+        parent.session,
+        "precondition: the parent is in front"
+    );
+
+    let selector: &'static str = Box::leak(open_selector(&child.session).into_boxed_str());
+    let bounds = rig
+        .cx
+        .debug_bounds(selector)
+        .expect("the sub-thread row's Open is painted");
+    rig.cx.simulate_click(bounds.center(), Modifiers::none());
+    rig.cx.run_until_parked();
+
+    assert_eq!(
+        crate::test_support::active_session_id(&rig.panel, &rig.cx),
+        child.session,
+        "the sub-thread is shown"
+    );
+}
+
+// ── R6.3: Idle, then dropped after the ended window ────────────────────────
+
+#[gpui::test]
+async fn tasks_card_sub_thread_idle_row_stays_for_the_ended_window_then_goes(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    let card = card(&rig);
+
+    let finish = start_turn(&mut rig, &child);
+    advance(&mut rig.cx, ENDED_ROW_TTL + Duration::from_secs(5));
+    draw(&mut rig.cx);
+    assert_eq!(
+        sub_row_running(&card, &child.session, &rig.cx),
+        Some(true),
+        "a running row is never dropped by the ended window"
+    );
+    assert!(painted(row_selector(&child.session), &mut rig.cx));
+
+    end_turn(finish, &mut rig.cx);
+    draw(&mut rig.cx);
+    assert_eq!(
+        sub_row_running(&card, &child.session, &rig.cx),
+        Some(false),
+        "the row turns Idle"
+    );
+    assert!(
+        painted(row_selector(&child.session), &mut rig.cx),
+        "an Idle row stays on show"
+    );
+
+    advance(&mut rig.cx, ENDED_ROW_TTL + Duration::from_secs(1));
+    draw(&mut rig.cx);
+    assert!(
+        !painted(row_selector(&child.session), &mut rig.cx),
+        "dropped after the ended window"
+    );
+}
+
+// ── R6.1, R6.2: placement ──────────────────────────────────────────────────
+
+#[gpui::test]
+async fn tasks_card_sub_thread_row_sits_in_the_held_parent_group(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    let card = card(&rig);
+
+    let _finish = start_turn(&mut rig, &child);
+    draw(&mut rig.cx);
+
+    assert!(
+        card.update(&mut rig.cx, |card, cx| card.is_visible(cx)),
+        "a running sub-thread alone puts the card on screen"
+    );
+    let header: &'static str = Box::leak(group_selector(&parent.session).into_boxed_str());
+    let row: &'static str = Box::leak(row_selector(&child.session).into_boxed_str());
+    let header = rig
+        .cx
+        .debug_bounds(header)
+        .expect("the parent's group is drawn");
+    let row = rig
+        .cx
+        .debug_bounds(row)
+        .expect("the sub-thread row is drawn");
+    assert!(
+        row.top() >= header.top(),
+        "the row sits under its parent's header"
+    );
+    assert!(
+        !painted(group_selector(&child.session), &mut rig.cx),
+        "the sub-thread gets no group of its own"
+    );
+    let parent_session = card.read_with(&rig.cx, |card, _| {
+        card.sub_thread_rows
+            .get(&child.session)
+            .map(|row| row.record.parent_session.clone())
+    });
+    assert_eq!(parent_session, Some(parent.session.clone()));
+}
+
+#[gpui::test]
+async fn tasks_card_sub_thread_row_of_an_unheld_parent_gets_the_parent_group(
+    cx: &mut TestAppContext,
+) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let (parent_id, parent_session) = unheld_parent(&mut rig.cx);
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent_id, &mut rig.cx);
+
+    let _finish = start_turn(&mut rig, &child);
+    draw(&mut rig.cx);
+
+    assert!(
+        painted(group_selector(&parent_session), &mut rig.cx),
+        "a group headed by the parent, though no panel holds it"
+    );
+    assert!(painted(row_selector(&child.session), &mut rig.cx));
+}
+
+// ── Q12: tooltip and accessible label ──────────────────────────────────────
+
+#[gpui::test]
+async fn tasks_card_sub_thread_open_has_a_tooltip_and_an_accessible_label(cx: &mut TestAppContext) {
+    init(cx);
+    let mut rig = rig(cx).await;
+    let parent = open_conversation(&mut rig, "Parent", "parent answer");
+    let child = open_conversation(&mut rig, "Child", "child answer");
+    link(child.thread_id, parent.thread_id, &mut rig.cx);
+    let _finish = start_turn(&mut rig, &child);
+    draw(&mut rig.cx);
+
+    let labels: Vec<String> =
+        RENDERED_ARIA_LABELS.with(|labels| labels.borrow().iter().map(|l| l.to_string()).collect());
+    assert!(
+        labels.iter().any(|label| label == "Open sub-thread: Child"),
+        "Open is labelled for screen readers: {labels:?}"
+    );
+
+    let selector: &'static str = Box::leak(open_selector(&child.session).into_boxed_str());
+    let bounds = rig.cx.debug_bounds(selector).expect("Open is painted");
+    rig.cx
+        .simulate_mouse_move(bounds.center(), None, Modifiers::none());
+    advance(&mut rig.cx, Duration::from_secs(1));
+    draw(&mut rig.cx);
+    assert!(
+        painted(
+            format!("tasks-card-tooltip-sub-thread-open-{}", child.session),
+            &mut rig.cx
+        ),
+        "Open has a tooltip"
+    );
+}
diff --git a/crates/agent_ui/src/tasks_card/waiting_label_tests.rs b/crates/agent_ui/src/tasks_card/waiting_label_tests.rs
new file mode 100644
index 0000000..5ed7c77
--- /dev/null
+++ b/crates/agent_ui/src/tasks_card/waiting_label_tests.rs
@@ -0,0 +1,277 @@
+// Story 064, task 11.1 — a subagent that needs the operator reads "Waiting for you" on the tasks
+// card, never "Running" or "starting"; a running one keeps its progress line (R1.8). Fixup of
+// `0071`. Module path: `tasks_card::waiting_label`; wired in `src/tasks_card.rs` beside the other
+// card test modules:
+//
+//     #[cfg(test)]
+//     #[path = "tasks_card/waiting_label_tests.rs"]
+//     mod waiting_label;
+//
+// Validation filter: `cargo test -p agent_ui tasks_card`.
+//
+// Contract beyond design.md (adjust `second_line` below only): GPUI's test window records painted
+// BOUNDS, not painted text, so the text a row paints is read through a test-only sibling of
+// story 023's `progress_label`:
+//
+//     #[cfg(test)]
+//     pub(crate) fn row_progress_label(row: &TaskRow, now: SystemTime) -> Option<SharedString>
+//
+// — what an active row paints on its second line: "Waiting for you" (an elapsed time may follow)
+// while `row.status` is `TaskRowStatus::Waiting`, else exactly `progress_label(&row.task, now)`.
+// `progress_label` itself cannot carry the state: it reads `AgentTask`, whose status
+// `TaskRowStatus::to_task_status` folds Waiting into Running on purpose (design.md, "Waiting
+// text"), and that fold is kept for expiry, history and the tray.
+//
+// Hostile halves first:
+//   - Waiting must NOT collapse into Running: the row's text is not the "starting" progress line
+//     and says neither "Running" nor "starting" — and stays so when the feed enriches that child
+//     with tool use, which is what would bring `progress_activity` back;
+//   - Running must NOT split into Waiting: a running sibling in the same group, and a feed task of
+//     the same parent, keep their progress line — the state is the row's, not the group's;
+//   - Waiting must not stick: once the child runs again its row reads its progress line;
+//   - then the benign half: the waiting row is still a live row — painted, stoppable, no ended
+//     status, and `task.status` still Running.
+
+use std::time::SystemTime;
+
+use acp_thread::AgentTaskStatus;
+use agent_client_protocol::v2::schema as acp_v2;
+use gpui::{Entity, TestAppContext};
+use serde_json::json;
+
+use super::native_subagent_support::*;
+use super::test_helpers::*;
+use super::{TaskRow, TaskRowStatus};
+
+/// The status text R1.8 names, byte for byte.
+const WAITING: &str = "Waiting for you";
+
+fn second_line(row: &TaskRow, now: SystemTime) -> Option<String> {
+    super::row_progress_label(row, now).map(|text| text.to_string())
+}
+
+fn progress_line(row: &TaskRow, now: SystemTime) -> Option<String> {
+    super::progress_label(&row.task, now).map(|text| text.to_string())
+}
+
+fn report_waiting(rig: &mut SubagentRig, child: &Entity<acp_thread::AcpThread>) {
+    rig.report(
+        child,
+        acp_v2::StateUpdate::RequiresAction(acp_v2::RequiresActionStateUpdate::new()),
+    );
+}
+
+fn assert_reads_waiting(text: Option<String>, what: &str) {
+    let text = text.unwrap_or_else(|| panic!("{what}: a waiting row paints a status line"));
+    assert!(
+        text.starts_with(WAITING),
+        "{what}: a waiting row reads {WAITING:?}, it read {text:?}"
+    );
+    assert!(
+        !text.contains("Running") && !text.contains("starting"),
+        "{what}: a waiting row never reads \"Running\" or \"starting\": {text:?}"
+    );
+}
+
+// ── R1.8 — Waiting is not Running ───────────────────────────────────────────
+
+/// Hostile: a child in RequiresAction. Today the card paints it as a running row that has not
+/// used a tool yet — "starting · 0s".
+#[gpui::test]
+async fn test_tasks_card_waiting_child_reads_waiting_for_you(cx: &mut TestAppContext) {
+    let mut rig = subagent_rig(cx).await;
+    let alpha = rig.spawn_child("alpha", Some("Alpha"), true);
+    report_waiting(&mut rig, &alpha);
+    let row = rig.row_of(&alpha).expect("the waiting child has a row");
+    assert_eq!(
+        row.status,
+        TaskRowStatus::Waiting,
+        "precondition: the merger reads the child as Waiting"
+    );
+
+    let now = SystemTime::now();
+    assert_reads_waiting(second_line(&row, now), "Alpha");
+    assert_ne!(
+        second_line(&row, now),
+        progress_line(&row, now),
+        "the waiting text replaces the progress line"
+    );
+}
+
+/// Hostile: the feed names the waiting child and reports tool use. Enrichment brings the last
+/// tool and the count onto the row's task — which is exactly what `progress_activity` prints —
+/// but the row still reads Waiting.
+#[gpui::test]
+async fn test_tasks_card_waiting_child_enriched_by_the_feed_still_reads_waiting(
+    cx: &mut TestAppContext,
+) {
+    let mut rig = subagent_rig(cx).await;
+    let alpha = rig.spawn_child("alpha", Some("Alpha"), true);
+    report_waiting(&mut rig, &alpha);
+    let mut entry = wire_task("feed-alpha", "running", true, 1_000);
+    entry["type"] = json!("subagent");
+    entry["childSessionId"] = json!("alpha");
+    entry["lastTool"] = json!("Bash");
+    entry["usage"] = json!({ "tokens": 900, "toolUses": 3, "durationMs": 2_000 });
+    let parent = rig.parent.clone();
+    send_tasks(&parent, vec![entry], &mut rig.cx);
+
+    assert_eq!(
+        rig.rows_for(&alpha),
+        1,
+        "the feed entry folds into the child's row"
+    );
+    let row = rig.row_of(&alpha).expect("the waiting child has a row");
+    assert_eq!(
+        row.status,
+        TaskRowStatus::Waiting,
+        "the registry's status wins"
+    );
+    assert_eq!(
+        row.task.last_tool.as_deref(),
+        Some("Bash"),
+        "precondition: the feed enriched the row"
+    );
+    let now = SystemTime::now();
+    assert_reads_waiting(second_line(&row, now), "Alpha, enriched");
+    assert!(
+        !second_line(&row, now).unwrap_or_default().contains("Bash"),
+        "the tool line does not come back while the child waits"
+    );
+}
+
+// ── R1.8 converse — Running is not Waiting ──────────────────────────────────
+
+/// Hostile converse: Alpha waits, Bravo runs, in the same parent's group. Bravo keeps its
+/// progress line; only Alpha's row changes.
+#[gpui::test]
+async fn test_tasks_card_running_sibling_of_a_waiting_child_keeps_its_progress(
+    cx: &mut TestAppContext,
+) {
+    let mut rig = subagent_rig(cx).await;
+    let alpha = rig.spawn_child("alpha", Some("Alpha"), true);
+    let bravo = rig.spawn_child("bravo", Some("Bravo"), true);
+    report_waiting(&mut rig, &alpha);
+    rig.report_running(&bravo);
+
+    let now = SystemTime::now();
+    let alpha_row = rig.row_of(&alpha).expect("Alpha's row");
+    let bravo_row = rig.row_of(&bravo).expect("Bravo's row");
+    assert_eq!(bravo_row.status, TaskRowStatus::Running);
+    let bravo_text = second_line(&bravo_row, now);
+    assert_eq!(
+        bravo_text,
+        progress_line(&bravo_row, now),
+        "a running child keeps its progress line"
+    );
+    assert!(
+        bravo_text
+            .as_deref()
+            .is_some_and(|text| text.starts_with("starting") && !text.contains(WAITING)),
+        "Bravo has used no tool yet: {bravo_text:?}"
+    );
+    assert_reads_waiting(second_line(&alpha_row, now), "Alpha");
+}
+
+/// Hostile converse, third element: a feed task of the same parent — no registry, no Waiting
+/// state — keeps the progress line it has always had.
+#[gpui::test]
+async fn test_tasks_card_feed_task_beside_a_waiting_child_keeps_its_progress(
+    cx: &mut TestAppContext,
+) {
+    let mut rig = subagent_rig(cx).await;
+    let alpha = rig.spawn_child("alpha", Some("Alpha"), true);
+    report_waiting(&mut rig, &alpha);
+    let parent = rig.parent.clone();
+    send_tasks(
+        &parent,
+        vec![wire_task("t1", "running", true, 1_000)],
+        &mut rig.cx,
+    );
+
+    let parent_session = rig.parent_session.clone();
+    let card = rig.card.clone();
+    let feed_row =
+        find_row(&card, &parent_session, "t1", &mut rig.cx).expect("the feed task has a row");
+    assert_eq!(feed_row.status, TaskRowStatus::Running);
+    let now = SystemTime::now();
+    let text = second_line(&feed_row, now);
+    assert_eq!(text, progress_line(&feed_row, now));
+    assert!(
+        text.as_deref().is_some_and(|text| !text.contains(WAITING)),
+        "a feed task never reads Waiting: {text:?}"
+    );
+    assert_reads_waiting(
+        second_line(&rig.row_of(&alpha).expect("Alpha's row"), now),
+        "Alpha",
+    );
+}
+
+/// Waiting does not stick: the operator answers, the child runs again, and its row reads its
+/// progress line once more.
+#[gpui::test]
+async fn test_tasks_card_child_running_again_after_waiting_reads_its_progress(
+    cx: &mut TestAppContext,
+) {
+    let mut rig = subagent_rig(cx).await;
+    let alpha = rig.spawn_child("alpha", Some("Alpha"), true);
+    rig.report_running(&alpha);
+    report_waiting(&mut rig, &alpha);
+    let now = SystemTime::now();
+    assert_reads_waiting(
+        second_line(&rig.row_of(&alpha).expect("Alpha's row"), now),
+        "Alpha, waiting",
+    );
+
+    rig.report_running(&alpha);
+
+    let row = rig.row_of(&alpha).expect("Alpha's row");
+    assert_eq!(row.status, TaskRowStatus::Running);
+    let now = SystemTime::now();
+    let text = second_line(&row, now);
+    assert_eq!(text, progress_line(&row, now), "back to the progress line");
+    assert!(
+        text.as_deref().is_some_and(|text| !text.contains(WAITING)),
+        "{text:?}"
+    );
+}
+
+// ── Benign — the waiting row is still a live row ────────────────────────────
+
+/// The waiting row is painted with its Stop, paints no ended status (which is where an
+/// "Running"/"Completed" word would sit), and its task status is still Running: expiry,
+/// history and the tray read that fold and must not change.
+#[gpui::test]
+async fn test_tasks_card_waiting_row_stays_a_live_row(cx: &mut TestAppContext) {
+    let mut rig = subagent_rig(cx).await;
+    let alpha = rig.spawn_child("alpha", Some("Alpha"), true);
+    report_waiting(&mut rig, &alpha);
+    rig.draw();
+
+    let row = rig.row_of(&alpha).expect("Alpha's row");
+    assert_eq!(
+        row.task.status,
+        AgentTaskStatus::Running,
+        "the AgentTask fold stays Running (expiry, history, tray)"
+    );
+    let id = row.task.id.clone();
+    assert!(
+        rig.cx
+            .debug_bounds(selector(format!("tasks-card-row-{id}")))
+            .is_some(),
+        "the waiting row is painted"
+    );
+    assert!(
+        rig.cx
+            .debug_bounds(selector(format!("tasks-card-stop-{id}")))
+            .is_some(),
+        "a waiting child keeps its Stop"
+    );
+    assert!(
+        rig.cx
+            .debug_bounds(selector(format!("tasks-card-ended-{id}")))
+            .is_none(),
+        "a waiting row paints no ended status"
+    );
+    assert_reads_waiting(second_line(&row, SystemTime::now()), "Alpha");
+}
