From fe58ffd8868a64b268d3dffdd81455f510851838 Mon Sep 17 00:00:00 2001
From: lucascouts <lucascs@protonmail.com>
Date: Sun, 4 Oct 2026 19:51:32 -0300
Subject: [PATCH] agent_tasks_panel: an Agent Tasks panel whose task history
 survives restarts

0037's card answers "what is running right now", and forgets: an ended task
leaves the card ten seconds later, and everything leaves it when Zeo quits.
Whether last night's build passed, or which subagent failed, was nowhere to
be read afterwards. This patch is the other half (story 014): a dockable
"Agent Tasks" panel listing every task the agent ran -- foreground and
background, live and finished, across every thread and every window -- kept
across restarts, and archivable.

The panel:

  * A crate of its own, agent_tasks_panel. The panel is "Agent Tasks", icon
    ListTodo, 320 px wide by default, docked left by default and movable to
    right or bottom. It toggles with agent_tasks_panel::ToggleFocus, bound in
    the Workspace context to ctrl-alt-shift-a on Linux and cmd-alt-shift-a on
    macOS (command palette: "agent tasks panel: toggle focus"). Not
    ctrl-alt-t, the first plan: upstream binds alt-ctrl-t to
    pane::CloseOtherItems in the Pane context, which shadows a Workspace
    binding whenever an editor has focus, and many Linux desktops grab that
    chord for a terminal.

  * Tasks are grouped by thread, threads ordered by their latest task, tasks
    newest first. Four filters -- Active, Finished, Failed, Archived -- with
    Active and Finished on by default; a toggle lasts for the session. A
    running row shows its live progress, read from whichever window's
    TasksCard holds the task, never from storage. Clicking a row opens its
    thread in the agent panel; a thread that no longer exists says so on the
    row instead of opening an empty one. Stop goes through the card's own
    request_stop, so it asks the same question 0037 asks. A finished task,
    or every finished task of a group, can be archived and unarchived; a
    running task cannot. Nothing is ever deleted automatically.

  * NO settings of its own -- no settings struct, nothing in default.json.
    The width persists with the workspace, as for every panel. The dock does
    not: the workspace saves a panel's dock only through that panel's
    settings, so this one keeps it in the key-value store under its panel key,
    "AgentTasksPanel". That key is global, so moving the panel in one window
    sets the dock for windows opened after it; an unreadable value falls back
    to left with a log line.

  * Loading never waits for the agent panel: zed.rs loads every panel in one
    join before the docks are restored, and with AI disabled the agent panel
    never appears. The card is attached when the agent panel is added, and
    re-attached when it is rebuilt; until then the panel shows the stored
    history and no live rows.

The history:

  * Its own sqlez domain, AgentTaskHistoryDb, one table keyed by (session id,
    task id). Not a table on ThreadMetadataDb, the obvious neighbour: sqlez
    migrations are positional per domain, and a changed entry aborts startup
    with "Migration changed for {domain} at step {index}". A migration
    appended to upstream's list would be overtaken by upstream's next one,
    and Zeo would stop opening. This list is the domain's alone, depends on
    no upstream domain, and is append-only: never edit an entry that has
    shipped, only add one after it. A database written before this patch
    gains the table on the first start.

  * Only the start and the outcome are written -- status, end time, tokens,
    tool uses. Progress while running is never stored.

  * The startup sweep: no task outlives the agent process that ran it, so
    every row still marked running when Zeo starts is marked interrupted,
    ending then. Activity heard while that sweep and the first load are in
    flight is queued and replayed after them, so a task started in the first
    frame is neither swept nor overwritten by the load. One ordered writer
    carries every write.

  * The storage cannot break startup. static_connection! never reports an
    open error: when the file cannot be opened, or any domain's migration
    fails, db::open_db logs it and falls back SILENTLY to a shared in-memory
    database. The store reads db::ALL_FILE_DB_FAILED -- not persistent(),
    which is false for test and ZED_STATELESS databases as well -- and marks
    the history unavailable: the panel shows "Task history is unavailable"
    with the cause above the live rows, writes are skipped, and Zeo runs as
    usual. A write that fails after a good open logs the session and task
    ids and keeps the in-memory state, so this session still shows the task.

Fed by the card -- the one change to 0037's code:

  * TasksCard now emits TaskActivity: Started once per task, Ended once per
    ending (one ending heard through two views of a session is one Ended,
    the rule 0037 already applies to the tray). A task first seen already
    ended gets a Started carrying its final status; a thread closed while a
    task runs ends that task as interrupted; and the card keeps emitting
    while disabled, hidden or minimized. It also emits ThreadTitleChanged,
    and the store moves every record of that session to the new title: a
    thread's first task is recorded before its title is generated, and was
    otherwise stored under the raw prompt.

  * agent_ui re-exports TaskActivity, TaskRecord, TasksCard and
    ThreadTitleChanged, and AgentPanel::tasks_card, test-only in 0037, is
    public. The store keeps a weak handle to every card it hears, so a task
    running in one window shows its progress, and Stop, in every window's
    panel.

The upstream touch is registration only, to keep each bump's refresh cheap:
the workspace member and dependency in Cargo.toml; the dependency in
crates/zed/Cargo.toml; agent_tasks_panel::init in crates/zed/src/main.rs,
after agent_ui::init, where app init lives; AgentTasksPanel::load joined with
the other panels in crates/zed/src/zed.rs; one line in each of the two
default keymaps. Cargo.lock gains the crate's own entry and one line in zed's
dependency list -- every dependency is a workspace crate or one already
locked, so no external crate is added.

Rides the claude-agent-acp-plus flag BY DEPENDENCY, like 0034, 0037 and 0038:
the panel is fed only by 0037's card, which lists only what this adapter
sends under _meta["_claude/tasks"] -- from claude-agent-acp-plus 0.24.0 on.
Without it the panel opens and records nothing. It also edits 0037's
tasks_card.rs, so it applies after 0038 and cannot ship without them.

zed's `test_action_namespaces` lists every action namespace and gains
`agent_tasks_panel` (added 2026-10-09; it had been failing since this patch
landed).

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

diff --git a/Cargo.lock b/Cargo.lock
index 1eb8238..c720b93 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -454,6 +454,33 @@ dependencies = [
  "util",
 ]
 
+[[package]]
+name = "agent_tasks_panel"
+version = "0.1.0"
+dependencies = [
+ "acp_thread",
+ "action_log",
+ "agent",
+ "agent-client-protocol",
+ "agent_ui",
+ "anyhow",
+ "collections",
+ "db",
+ "fs",
+ "futures 0.3.34",
+ "gpui",
+ "language_model",
+ "log",
+ "project",
+ "remote",
+ "remote_connection",
+ "serde_json",
+ "settings",
+ "ui",
+ "watch",
+ "workspace",
+]
+
 [[package]]
 name = "agent_ui"
 version = "0.1.0"
@@ -22444,6 +22471,7 @@ dependencies = [
  "agent_servers",
  "agent_settings",
  "agent_skills",
+ "agent_tasks_panel",
  "agent_ui",
  "anyhow",
  "ashpd",
diff --git a/Cargo.toml b/Cargo.toml
index 2c21684..1ea9c62 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,6 +9,7 @@ members = [
     "crates/agent_servers",
     "crates/agent_settings",
     "crates/agent_skills",
+    "crates/agent_tasks_panel",
     "crates/agent_ui",
     "crates/agent_ui_benchmarks",
     "crates/ai_onboarding",
@@ -293,6 +294,7 @@ agent = { path = "crates/agent" }
 agent_servers = { path = "crates/agent_servers" }
 agent_settings = { path = "crates/agent_settings" }
 agent_skills = { path = "crates/agent_skills" }
+agent_tasks_panel = { path = "crates/agent_tasks_panel" }
 agent_ui = { path = "crates/agent_ui" }
 ai_onboarding = { path = "crates/ai_onboarding" }
 anthropic = { path = "crates/anthropic" }
diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json
index 15a6f72..c5df522 100644
--- a/assets/keymaps/default-linux.json
+++ b/assets/keymaps/default-linux.json
@@ -711,6 +711,7 @@
       "ctrl-shift-g": "git_panel::ToggleFocus",
       "ctrl-shift-d": "debug_panel::ToggleFocus",
       "ctrl-?": "agent::ToggleFocus",
+      "ctrl-alt-shift-a": "agent_tasks_panel::ToggleFocus",
       "alt-save": "workspace::SaveAll",
       "ctrl-alt-s": "workspace::SaveAll",
       "ctrl-k n": "encoding_selector::Toggle",
diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json
index d05a7b2..ca2c467 100644
--- a/assets/keymaps/default-macos.json
+++ b/assets/keymaps/default-macos.json
@@ -763,6 +763,7 @@
       "ctrl-shift-g": "git_panel::ToggleFocus",
       "cmd-shift-d": "debug_panel::ToggleFocus",
       "cmd-?": "agent::ToggleFocus",
+      "cmd-alt-shift-a": "agent_tasks_panel::ToggleFocus",
       "cmd-alt-s": "workspace::SaveAll",
       "cmd-k n": "encoding_selector::Toggle",
       "cmd-k m": "language_selector::Toggle",
diff --git a/crates/agent_tasks_panel/Cargo.toml b/crates/agent_tasks_panel/Cargo.toml
new file mode 100644
index 0000000..6010073
--- /dev/null
+++ b/crates/agent_tasks_panel/Cargo.toml
@@ -0,0 +1,43 @@
+[package]
+name = "agent_tasks_panel"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/agent_tasks_panel.rs"
+doctest = false
+
+[dependencies]
+acp_thread.workspace = true
+agent-client-protocol.workspace = true
+agent_ui.workspace = true
+anyhow.workspace = true
+collections.workspace = true
+db.workspace = true
+futures.workspace = true
+gpui.workspace = true
+log.workspace = true
+settings.workspace = true
+ui.workspace = true
+workspace.workspace = true
+
+[dev-dependencies]
+acp_thread = { workspace = true, features = ["test-support"] }
+action_log.workspace = true
+agent = { workspace = true, features = ["test-support"] }
+agent_ui = { workspace = true, features = ["test-support"] }
+db = { workspace = true, features = ["test-support"] }
+fs = { workspace = true, features = ["test-support"] }
+gpui = { workspace = true, features = ["test-support"] }
+language_model = { workspace = true, features = ["test-support"] }
+project = { workspace = true, features = ["test-support"] }
+remote = { workspace = true, features = ["test-support"] }
+remote_connection = { workspace = true, features = ["test-support"] }
+serde_json.workspace = true
+watch.workspace = true
+workspace = { workspace = true, features = ["test-support"] }
diff --git a/crates/agent_tasks_panel/LICENSE-GPL b/crates/agent_tasks_panel/LICENSE-GPL
new file mode 120000
index 0000000..89e542f
--- /dev/null
+++ b/crates/agent_tasks_panel/LICENSE-GPL
@@ -0,0 +1 @@
+../../LICENSE-GPL
\ No newline at end of file
diff --git a/crates/agent_tasks_panel/src/agent_tasks_panel.rs b/crates/agent_tasks_panel/src/agent_tasks_panel.rs
new file mode 100644
index 0000000..21945b6
--- /dev/null
+++ b/crates/agent_tasks_panel/src/agent_tasks_panel.rs
@@ -0,0 +1,26 @@
+//! The Agent Tasks panel: a history of every task the agent ran, kept in a database domain of
+//! its own so that it survives restarts without touching any upstream domain.
+
+mod history_db;
+mod panel;
+mod store;
+
+use gpui::App;
+
+pub use history_db::{AgentTaskHistoryDb, HistoryEntry};
+pub use panel::{AgentTasksPanel, PanelGroup, PanelRow, TaskFilter, ToggleFocus};
+pub use store::{Availability, TaskHistoryStore, TaskKey};
+
+/// Creates the global `TaskHistoryStore`, which sweeps and loads the history at once, and
+/// registers the panel's `ToggleFocus` on every workspace. A second call does nothing, so the
+/// action is never registered twice.
+pub fn init(cx: &mut App) {
+    if TaskHistoryStore::try_global(cx).is_some() {
+        return;
+    }
+    TaskHistoryStore::init(cx);
+    panel::register_actions(cx);
+}
+
+#[cfg(test)]
+mod test_helpers;
diff --git a/crates/agent_tasks_panel/src/history_db.rs b/crates/agent_tasks_panel/src/history_db.rs
new file mode 100644
index 0000000..8d21c8a
--- /dev/null
+++ b/crates/agent_tasks_panel/src/history_db.rs
@@ -0,0 +1,297 @@
+//! `AgentTaskHistoryDb`: the agent task history, in a `sqlez` domain of its own.
+//!
+//! The domain is separate from every upstream one (R4.1). `sqlez` migrations are positional per
+//! domain and a changed entry aborts startup with "Migration changed for {domain} at step
+//! {index}", so a table appended to an upstream list would be overtaken by upstream's next
+//! migration. Here only this list decides the steps, and it is append-only: never edit an entry
+//! that has shipped, only add one after it.
+//!
+//! How an open failure surfaces (read from `crates/db/src/db.rs`, which R4.3's detection
+//! depends on): `static_connection!` opens nothing itself. It registers this domain's migrations
+//! with `inventory` and gives the type a `global(cx)` that clones the one shared
+//! `db::AppDatabase` connection. That connection is opened once at startup by
+//! `db::open_db::<AppMigrator>`, which runs every registered domain's migrations — this one
+//! included — in a single savepoint. When the file cannot be opened, or any domain's migration
+//! fails after its retries, `open_db` logs the error and FALLS BACK SILENTLY to a shared
+//! in-memory database (`FALLBACK_MEMORY_DB`): no `Err` ever reaches this domain. Two signals
+//! remain:
+//!   - `db::ALL_FILE_DB_FAILED` is set to `true` (upstream's workspace shows its own
+//!     "Failed to load the database file" notice from it);
+//!   - the connection's `persistent()` is `false`.
+//!
+//! `persistent()` alone cannot tell the fallback apart: test databases and `ZED_STATELESS`
+//! sessions are in memory by design and report `false` too, while `ALL_FILE_DB_FAILED` stays
+//! `false` for both. A failure of this domain's own statements after a good open (a dropped
+//! table, a broken file) surfaces as an `Err` from the query, as everywhere else in `sqlez`.
+
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use acp_thread::{AgentTaskId, AgentTaskStatus};
+use agent_client_protocol::v2::schema as acp_v2;
+use agent_ui::TaskRecord;
+use anyhow::{Context as _, Result, anyhow};
+use db::{
+    sqlez::{
+        bindable::Column, domain::Domain, statement::Statement,
+        thread_safe_connection::ThreadSafeConnection,
+    },
+    sqlez_macros::sql,
+};
+
+/// 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; 1] = [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);
+)];
+
+impl Domain for AgentTaskHistoryDb {
+    const NAME: &str = stringify!(AgentTaskHistoryDb);
+
+    // Append-only (Q12): a database created by an earlier list must open with this one.
+    const MIGRATIONS: &[&str] = &HISTORY_MIGRATIONS;
+}
+
+// No dependencies: the history domain migrates after nothing upstream (R4.1).
+db::static_connection!(AgentTaskHistoryDb, []);
+
+/// One task of the history: the record story 013's card emitted, and whether the user archived
+/// it. The flag belongs to the history, not to the live record.
+#[derive(Clone, Debug, PartialEq)]
+pub struct HistoryEntry {
+    pub record: TaskRecord,
+    pub archived: bool,
+}
+
+// There is deliberately no delete statement: every record is kept until the user deletes Zeo's
+// data (R3.5).
+impl AgentTaskHistoryDb {
+    /// Records a task the first time it is seen. A repeat of the same `(session_id, task_id)` is
+    /// ignored, so a later snapshot never rewrites the record nor reopens an ended task. A task
+    /// first seen already ended carries its end fields, and they are written with it.
+    pub async fn insert_started(&self, record: &TaskRecord) -> Result<()> {
+        let ids = ids_of(&record.session_id, &record.task_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(),
+        );
+        let state = (
+            record.background,
+            status_text(record.status),
+            record.started_at_ms,
+            record.ended_at_ms,
+            record.tokens,
+            record.tool_uses,
+        );
+        self.write(move |connection| {
+            connection.exec_bound(sql!(
+                INSERT OR IGNORE INTO agent_task_history(
+                    session_id, task_id, thread_title, project, kind, description,
+                    background, status, started_at, ended_at, tokens, tool_uses
+                ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
+            ))?((identity, state))
+        })
+        .await
+        .with_context(|| format!("recording the start of {ids}"))
+    }
+
+    /// Writes a task's outcome — status, end time, tokens and tool uses — on its own row only.
+    pub async fn update_ended(&self, record: &TaskRecord) -> Result<()> {
+        let ids = ids_of(&record.session_id, &record.task_id);
+        let bindings = (
+            status_text(record.status),
+            record.ended_at_ms,
+            record.tokens,
+            record.tool_uses,
+            record.session_id.to_string(),
+            record.task_id.to_string(),
+        );
+        self.write(move |connection| {
+            connection.exec_bound(sql!(
+                UPDATE agent_task_history
+                SET status = ?1, ended_at = ?2, tokens = ?3, tool_uses = ?4
+                WHERE session_id = ?5 AND task_id = ?6
+            ))?(bindings)
+        })
+        .await
+        .with_context(|| format!("recording the end of {ids}"))
+    }
+
+    /// Gives every task of `session_id` the thread's newest title. A thread's first task is
+    /// usually recorded under its provisional title (the user's prompt), and its group is
+    /// titled from these rows once the thread is no longer open.
+    pub async fn update_thread_title(
+        &self,
+        session_id: &acp_v2::SessionId,
+        title: &str,
+    ) -> Result<()> {
+        let session = session_id.to_string();
+        let bindings = (title.to_string(), session.clone());
+        self.write(move |connection| {
+            connection.exec_bound(sql!(
+                UPDATE agent_task_history SET thread_title = ?1 WHERE session_id = ?2
+            ))?(bindings)
+        })
+        .await
+        .with_context(|| format!("recording the title of session {session}"))
+    }
+
+    /// The startup sweep (R1.5): no task outlives the agent process that ran it, so every row
+    /// still running when Zeo starts is marked interrupted, ending now. Finished rows are left
+    /// as they are, so running it twice changes nothing more.
+    pub async fn mark_running_interrupted(&self) -> Result<()> {
+        let bindings = (
+            status_text(AgentTaskStatus::Interrupted),
+            now_ms(),
+            status_text(AgentTaskStatus::Running),
+        );
+        self.write(move |connection| {
+            connection.exec_bound(sql!(
+                UPDATE agent_task_history SET status = ?1, ended_at = ?2 WHERE status = ?3
+            ))?(bindings)
+        })
+        .await
+        .context("marking running tasks interrupted")
+    }
+
+    /// Archives or unarchives a batch of tasks in one transaction (Q13): when any row fails,
+    /// every row of the batch is left as it was.
+    pub async fn set_archived(
+        &self,
+        keys: &[(acp_v2::SessionId, AgentTaskId)],
+        archived: bool,
+    ) -> Result<()> {
+        let keys: Vec<(String, String)> = keys
+            .iter()
+            .map(|(session_id, task_id)| (session_id.to_string(), task_id.to_string()))
+            .collect();
+        let action = if archived { "archiving" } else { "unarchiving" };
+        self.write(move |connection| {
+            connection.with_savepoint("agent_task_history_set_archived", || {
+                let mut update = connection.exec_bound(sql!(
+                    UPDATE agent_task_history SET archived = ?1
+                    WHERE session_id = ?2 AND task_id = ?3
+                ))?;
+                for (session_id, task_id) in &keys {
+                    update((archived, session_id.as_str(), task_id.as_str()))
+                        .with_context(|| format!("{action} {session_id}/{task_id}"))?;
+                }
+                Ok(())
+            })
+        })
+        .await
+    }
+
+    /// Every record, archived or not, newest first.
+    pub async fn load_all(&self) -> Result<Vec<HistoryEntry>> {
+        self.select::<HistoryEntry>(sql!(
+            SELECT session_id, task_id, thread_title, project, kind, description, background,
+                status, started_at, ended_at, tokens, tool_uses, archived
+            FROM agent_task_history
+            ORDER BY started_at DESC, session_id, task_id
+        ))?()
+        .context("loading the agent task history")
+    }
+}
+
+impl Column for HistoryEntry {
+    fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+        let (session_id, next): (String, i32) = Column::column(statement, start_index)?;
+        let (task_id, next): (String, i32) = Column::column(statement, next)?;
+        let (thread_title, next): (Option<String>, i32) = Column::column(statement, next)?;
+        let (project, next): (String, i32) = Column::column(statement, next)?;
+        let (kind, next): (String, i32) = Column::column(statement, next)?;
+        let (description, next): (String, i32) = Column::column(statement, next)?;
+        let (background, next): (bool, i32) = Column::column(statement, next)?;
+        let (status, next): (String, i32) = Column::column(statement, next)?;
+        let (started_at_ms, next): (i64, i32) = Column::column(statement, next)?;
+        let (ended_at_ms, next): (Option<i64>, i32) = Column::column(statement, next)?;
+        let (tokens, next): (Option<u64>, i32) = Column::column(statement, next)?;
+        let (tool_uses, next): (Option<u64>, i32) = Column::column(statement, next)?;
+        let (archived, next): (bool, i32) = Column::column(statement, next)?;
+
+        let status = parse_status(&status)
+            .with_context(|| format!("reading the record of {session_id}/{task_id}"))?;
+        let record = TaskRecord {
+            session_id: acp_v2::SessionId::new(session_id),
+            task_id: AgentTaskId::from(task_id),
+            thread_title: thread_title.map(Into::into),
+            project: project.into(),
+            kind: kind.into(),
+            description: description.into(),
+            background,
+            started_at_ms,
+            ended_at_ms,
+            status,
+            tokens,
+            tool_uses,
+        };
+        Ok((HistoryEntry { record, archived }, next))
+    }
+}
+
+fn ids_of(session_id: &acp_v2::SessionId, task_id: &AgentTaskId) -> String {
+    format!("{session_id}/{task_id}")
+}
+
+/// Story 012's status vocabulary, as stored in the `status` column.
+fn status_text(status: AgentTaskStatus) -> &'static str {
+    match status {
+        AgentTaskStatus::Running => "running",
+        AgentTaskStatus::Completed => "completed",
+        AgentTaskStatus::Failed => "failed",
+        AgentTaskStatus::Stopped => "stopped",
+        AgentTaskStatus::Interrupted => "interrupted",
+    }
+}
+
+fn parse_status(text: &str) -> Result<AgentTaskStatus> {
+    match text {
+        "running" => Ok(AgentTaskStatus::Running),
+        "completed" => Ok(AgentTaskStatus::Completed),
+        "failed" => Ok(AgentTaskStatus::Failed),
+        "stopped" => Ok(AgentTaskStatus::Stopped),
+        "interrupted" => Ok(AgentTaskStatus::Interrupted),
+        unknown => Err(anyhow!("unknown task status {unknown:?}")),
+    }
+}
+
+/// Milliseconds since the Unix epoch, negative before it, saturating at `i64`'s range — the
+/// same clock the card stamps `started_at_ms` with.
+fn now_ms() -> i64 {
+    match SystemTime::now().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),
+    }
+}
+
+#[cfg(test)]
+#[path = "history_db/migration_tests.rs"]
+mod migration;
+
+#[cfg(test)]
+#[path = "history_db/statement_tests.rs"]
+mod statements;
diff --git a/crates/agent_tasks_panel/src/history_db/migration_tests.rs b/crates/agent_tasks_panel/src/history_db/migration_tests.rs
new file mode 100644
index 0000000..442fcdf
--- /dev/null
+++ b/crates/agent_tasks_panel/src/history_db/migration_tests.rs
@@ -0,0 +1,188 @@
+// Story 014, task 2.1 — the history domain and its first migration (R4.1, R4.2, Q12).
+// Module path: `history_db::migration` (see `src/test_helpers.rs` for the declarations).
+
+use db::sqlez::{connection::Connection, domain::Domain};
+
+use crate::AgentTaskHistoryDb;
+
+fn migrate_all(connection: &Connection) {
+    connection
+        .migrate(
+            AgentTaskHistoryDb::NAME,
+            AgentTaskHistoryDb::MIGRATIONS,
+            &mut |_, _, _| false,
+        )
+        .expect("the history migrations should apply");
+}
+
+fn names(connection: &Connection, kind: &str, name: &str) -> Vec<String> {
+    let query = format!("SELECT name FROM sqlite_master WHERE type = '{kind}' AND name = '{name}'");
+    connection
+        .select::<String>(&query)
+        .expect("sqlite_master query should prepare")()
+    .expect("sqlite_master query should run")
+}
+
+fn count_rows(connection: &Connection) -> Vec<i64> {
+    connection
+        .select::<i64>("SELECT COUNT(*) FROM agent_task_history")
+        .expect("count should prepare")()
+    .expect("count should run")
+}
+
+// R4.1 — a domain of its own: its own name, and no upstream domain it must migrate after.
+#[test]
+fn migration_domain_is_its_own_with_no_upstream_dependency() {
+    assert_eq!(AgentTaskHistoryDb::NAME, "AgentTaskHistoryDb");
+    assert!(
+        !AgentTaskHistoryDb::MIGRATIONS.is_empty(),
+        "the domain has at least its first migration"
+    );
+    let registered: Vec<&db::DomainMigration> = db::inventory::iter::<db::DomainMigration>
+        .into_iter()
+        .filter(|migration| migration.name == AgentTaskHistoryDb::NAME)
+        .collect();
+    assert_eq!(
+        registered.len(),
+        1,
+        "the domain is registered exactly once (static_connection!)"
+    );
+    assert!(
+        registered[0].dependencies.is_empty(),
+        "the history domain must not depend on any upstream domain, got {:?}",
+        registered[0].dependencies
+    );
+    assert!(
+        std::ptr::eq(registered[0].migrations, AgentTaskHistoryDb::MIGRATIONS),
+        "the registered list is the domain's own list"
+    );
+}
+
+// R4.2 — on an empty database the migration creates the table and its index.
+#[test]
+fn migration_creates_table_and_index_on_an_empty_database() {
+    let connection = Connection::open_memory(Some("agent_task_history_migration_empty"));
+    migrate_all(&connection);
+
+    assert_eq!(
+        names(&connection, "table", "agent_task_history"),
+        vec!["agent_task_history".to_string()]
+    );
+    assert_eq!(
+        names(&connection, "index", "agent_task_history_started"),
+        vec!["agent_task_history_started".to_string()]
+    );
+    assert_eq!(count_rows(&connection), vec![0]);
+}
+
+// The primary key is the pair: the same task id in two sessions is two rows, and the same
+// pair twice is refused by the table itself.
+#[test]
+fn migration_primary_key_is_session_and_task_pair() {
+    let connection = Connection::open_memory(Some("agent_task_history_migration_pk"));
+    migrate_all(&connection);
+
+    let insert = |session: &str, task: &str| {
+        let sql = format!(
+            "INSERT INTO agent_task_history \
+             (session_id, task_id, project, kind, description, background, status, started_at) \
+             VALUES ('{session}', '{task}', 'project', 'shell', 'Task', 0, 'running', 1000)"
+        );
+        connection.exec(&sql).and_then(|mut run| run())
+    };
+
+    insert("session-a", "task-1").expect("first row");
+    insert("session-b", "task-1").expect("same task id in another session is another row");
+    insert("session-a", "task-2").expect("another task id in the same session is another row");
+    assert!(
+        insert("session-a", "task-1").is_err(),
+        "the same (session_id, task_id) twice must violate the primary key"
+    );
+    assert_eq!(count_rows(&connection), vec![3]);
+}
+
+// R4.2 — a database written before this patch: other domains' tables and migration rows
+// already exist. The history migration adds its table and leaves the rest untouched.
+#[test]
+fn migration_on_a_pre_patch_database_adds_the_table_and_keeps_upstream_data() {
+    let connection = Connection::open_memory(Some("agent_task_history_migration_pre_patch"));
+    let upstream: &[&str] = &[
+        "CREATE TABLE upstream_items(id INTEGER PRIMARY KEY, name TEXT NOT NULL) STRICT;",
+        "ALTER TABLE upstream_items ADD COLUMN note TEXT;",
+    ];
+    connection
+        .migrate("UpstreamStandInDb", upstream, &mut |_, _, _| false)
+        .expect("stand-in upstream migrations");
+    connection
+        .exec("INSERT INTO upstream_items (id, name, note) VALUES (1, 'kept', 'n')")
+        .and_then(|mut run| run())
+        .expect("upstream row");
+
+    migrate_all(&connection);
+
+    assert_eq!(
+        names(&connection, "table", "agent_task_history"),
+        vec!["agent_task_history".to_string()]
+    );
+    let kept = connection
+        .select::<String>("SELECT name FROM upstream_items WHERE id = 1")
+        .expect("select")()
+    .expect("run");
+    assert_eq!(kept, vec!["kept".to_string()]);
+
+    // Upstream's list can grow afterwards without touching the history domain (positional
+    // migrations are per domain).
+    let upstream_grown: &[&str] = &[
+        upstream[0],
+        upstream[1],
+        "CREATE TABLE upstream_more(id INTEGER PRIMARY KEY) STRICT;",
+    ];
+    connection
+        .migrate("UpstreamStandInDb", upstream_grown, &mut |_, _, _| false)
+        .expect("a grown upstream list still applies");
+    migrate_all(&connection);
+}
+
+// Q12 — append-only: a database created by migration 1 opens with the current list, keeps its
+// rows, and re-running the whole list is a no-op rather than a "Migration changed" error.
+#[test]
+fn migration_opens_a_database_created_by_its_first_migration() {
+    let connection = Connection::open_memory(Some("agent_task_history_migration_first"));
+    connection
+        .migrate(
+            AgentTaskHistoryDb::NAME,
+            &AgentTaskHistoryDb::MIGRATIONS[..1],
+            &mut |_, _, _| false,
+        )
+        .expect("migration 1 alone should apply");
+    connection
+        .exec(
+            "INSERT INTO agent_task_history \
+             (session_id, task_id, thread_title, project, kind, description, background, \
+              status, started_at, ended_at, tokens, tool_uses) \
+             VALUES ('s-old', 't-old', 'Old thread', 'project', 'agent', 'Old task', 1, \
+                     'completed', 1000, 2000, 10, 2)",
+        )
+        .and_then(|mut run| run())
+        .expect("a row written by migration 1's schema");
+
+    migrate_all(&connection);
+    migrate_all(&connection);
+
+    let rows = connection
+        .select::<(String, String, String, i64)>(
+            "SELECT session_id, task_id, status, archived FROM agent_task_history",
+        )
+        .expect("select")()
+    .expect("run");
+    assert_eq!(
+        rows,
+        vec![(
+            "s-old".to_string(),
+            "t-old".to_string(),
+            "completed".to_string(),
+            0
+        )],
+        "the row from migration 1 survives, archived defaults to 0"
+    );
+}
diff --git a/crates/agent_tasks_panel/src/history_db/statement_tests.rs b/crates/agent_tasks_panel/src/history_db/statement_tests.rs
new file mode 100644
index 0000000..718acb4
--- /dev/null
+++ b/crates/agent_tasks_panel/src/history_db/statement_tests.rs
@@ -0,0 +1,419 @@
+// Story 014, task 2.2 — the domain's statements (R1.2, R1.3, R1.5, R3.1, R3.3, R3.5, Q13).
+// Module path: `history_db::statements`. Hostile fixtures come before benign ones.
+
+use acp_thread::AgentTaskId;
+use acp_thread::AgentTaskStatus;
+use gpui::TestAppContext;
+
+use crate::AgentTaskHistoryDb;
+use crate::test_helpers::*;
+
+// ── insert_started: identity of a task is (session_id, task_id) ────────────
+
+// Hostile (wrong collapse): two different task ids in one session are two rows.
+#[gpui::test]
+async fn history_db_insert_started_keeps_two_tasks_of_one_session_apart(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_two_tasks_one_session").await;
+    db.insert_started(&record("s1", "t1", AgentTaskStatus::Running, 1_000))
+        .await
+        .unwrap();
+    db.insert_started(&record("s1", "t2", AgentTaskStatus::Running, 2_000))
+        .await
+        .unwrap();
+
+    let rows = load(&db).await;
+    assert_eq!(
+        rows.len(),
+        2,
+        "two task ids in one session must be two rows"
+    );
+    assert!(find(&rows, "s1", "t1").is_some());
+    assert!(find(&rows, "s1", "t2").is_some());
+}
+
+// Hostile (wrong collapse): the same task id in two sessions is two rows.
+#[gpui::test]
+async fn history_db_insert_started_keeps_same_task_id_in_two_sessions_apart(
+    _cx: &mut TestAppContext,
+) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_same_task_two_sessions").await;
+    db.insert_started(&record("s1", "t1", AgentTaskStatus::Running, 1_000))
+        .await
+        .unwrap();
+    db.insert_started(&record("s2", "t1", AgentTaskStatus::Running, 2_000))
+        .await
+        .unwrap();
+
+    let rows = load(&db).await;
+    assert_eq!(
+        rows.len(),
+        2,
+        "one task id in two sessions must be two rows"
+    );
+    assert_eq!(find(&rows, "s1", "t1").unwrap().record.started_at_ms, 1_000);
+    assert_eq!(find(&rows, "s2", "t1").unwrap().record.started_at_ms, 2_000);
+}
+
+// Hostile (third element): keys whose parts concatenate to the same text stay apart —
+// a derived "session+task" key would merge them.
+#[gpui::test]
+async fn history_db_insert_started_keeps_keys_that_concatenate_alike_apart(
+    _cx: &mut TestAppContext,
+) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_concatenate_alike").await;
+    for (session, task) in [("s:1", "x"), ("s", "1:x"), ("s1", "x"), ("s", "1x")] {
+        db.insert_started(&record(session, task, AgentTaskStatus::Running, 1_000))
+            .await
+            .unwrap();
+    }
+    let rows = load(&db).await;
+    assert_eq!(rows.len(), 4, "four distinct pairs are four rows: {rows:?}");
+}
+
+// Hostile (wrong split): the same task repeated by later snapshots is one row, and the
+// first record wins — a repeat does not rewrite it.
+#[gpui::test]
+async fn history_db_insert_started_twice_keeps_one_row(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_insert_twice").await;
+    let first = record("s1", "t1", AgentTaskStatus::Running, 1_000);
+    let mut repeat = first.clone();
+    repeat.description = "Renamed by a later snapshot".into();
+    repeat.thread_title = Some("Renamed thread".into());
+
+    db.insert_started(&first).await.unwrap();
+    db.insert_started(&repeat).await.unwrap();
+    db.insert_started(&first).await.unwrap();
+
+    let rows = load(&db).await;
+    assert_eq!(
+        rows.len(),
+        1,
+        "repeated snapshots of one task must be one row"
+    );
+    assert_eq!(rows[0].record.description.as_ref(), "Task t1");
+}
+
+// Hostile: a snapshot replayed after the task ended must not reopen it as running.
+#[gpui::test]
+async fn history_db_insert_started_after_end_does_not_reopen(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_insert_after_end").await;
+    let running = record("s1", "t1", AgentTaskStatus::Running, 1_000);
+    db.insert_started(&running).await.unwrap();
+    db.update_ended(&record("s1", "t1", AgentTaskStatus::Completed, 1_000))
+        .await
+        .unwrap();
+    db.insert_started(&running).await.unwrap();
+
+    let rows = load(&db).await;
+    assert_eq!(rows.len(), 1);
+    assert_eq!(rows[0].record.status, AgentTaskStatus::Completed);
+    assert_eq!(rows[0].record.ended_at_ms, Some(2_000));
+}
+
+// Benign: every R1.1 field round-trips, unarchived, running, no outcome yet.
+#[gpui::test]
+async fn history_db_insert_started_records_every_field(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_insert_fields").await;
+    let mut started = record("s1", "t1", AgentTaskStatus::Running, 1_234);
+    started.kind = "agent".into();
+    started.background = true;
+    started.thread_title = Some("Build thread".into());
+    started.project = "zeo".into();
+    db.insert_started(&started).await.unwrap();
+
+    let rows = load(&db).await;
+    assert_eq!(rows.len(), 1);
+    let entry = &rows[0];
+    assert!(!entry.archived);
+    let got = &entry.record;
+    assert_eq!(got.session_id.to_string(), "s1");
+    assert_eq!(got.task_id.as_ref(), "t1");
+    assert_eq!(
+        got.thread_title.as_ref().map(|t| t.as_ref()),
+        Some("Build thread")
+    );
+    assert_eq!(got.project.as_ref(), "zeo");
+    assert_eq!(got.kind.as_ref(), "agent");
+    assert_eq!(got.description.as_ref(), "Task t1");
+    assert!(got.background);
+    assert_eq!(got.status, AgentTaskStatus::Running);
+    assert_eq!(got.started_at_ms, 1_234);
+    assert_eq!(got.ended_at_ms, None);
+    assert_eq!(got.tokens, None);
+    assert_eq!(got.tool_uses, None);
+}
+
+// R1.8: a task first seen already ended is one record carrying its outcome.
+#[gpui::test]
+async fn history_db_insert_started_writes_the_end_fields_a_record_carries(
+    _cx: &mut TestAppContext,
+) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_insert_ended").await;
+    let mut first_seen_ended = record("s1", "quick", AgentTaskStatus::Failed, 1_000);
+    first_seen_ended.ended_at_ms = Some(1_250);
+    first_seen_ended.tokens = Some(77);
+    first_seen_ended.tool_uses = Some(2);
+    db.insert_started(&first_seen_ended).await.unwrap();
+    db.insert_started(&record("s1", "slow", AgentTaskStatus::Running, 1_100))
+        .await
+        .unwrap();
+
+    let rows = load(&db).await;
+    let quick = &find(&rows, "s1", "quick").unwrap().record;
+    assert_eq!(
+        quick.status,
+        AgentTaskStatus::Failed,
+        "not stored as running"
+    );
+    assert_eq!(quick.ended_at_ms, Some(1_250));
+    assert_eq!(quick.tokens, Some(77));
+    assert_eq!(quick.tool_uses, Some(2));
+    let slow = &find(&rows, "s1", "slow").unwrap().record;
+    assert_eq!(slow.status, AgentTaskStatus::Running);
+    assert_eq!(slow.ended_at_ms, None, "a running record writes no end");
+    assert_eq!(slow.tokens, None);
+
+    // The startup sweep must not touch the record that arrived ended.
+    db.mark_running_interrupted().await.unwrap();
+    let rows = load(&db).await;
+    assert_eq!(
+        find(&rows, "s1", "quick").unwrap().record.status,
+        AgentTaskStatus::Failed
+    );
+}
+
+// ── update_ended ───────────────────────────────────────────────────────────
+
+// Hostile: ending (s1, t1) leaves (s2, t1) running.
+#[gpui::test]
+async fn history_db_update_ended_writes_the_outcome_on_its_own_row_only(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_update_ended").await;
+    db.insert_started(&record("s1", "t1", AgentTaskStatus::Running, 1_000))
+        .await
+        .unwrap();
+    db.insert_started(&record("s2", "t1", AgentTaskStatus::Running, 1_500))
+        .await
+        .unwrap();
+
+    let mut outcome = record("s1", "t1", AgentTaskStatus::Failed, 1_000);
+    outcome.ended_at_ms = Some(9_000);
+    outcome.tokens = Some(4_321);
+    outcome.tool_uses = Some(17);
+    db.update_ended(&outcome).await.unwrap();
+
+    let rows = load(&db).await;
+    let ended = &find(&rows, "s1", "t1").unwrap().record;
+    assert_eq!(ended.status, AgentTaskStatus::Failed);
+    assert_eq!(ended.ended_at_ms, Some(9_000));
+    assert_eq!(ended.tokens, Some(4_321));
+    assert_eq!(ended.tool_uses, Some(17));
+    assert_eq!(ended.started_at_ms, 1_000, "the start is not rewritten");
+
+    let other = &find(&rows, "s2", "t1").unwrap().record;
+    assert_eq!(other.status, AgentTaskStatus::Running);
+    assert_eq!(other.ended_at_ms, None);
+    assert_eq!(other.tokens, None);
+}
+
+// ── mark_running_interrupted (R1.5) ────────────────────────────────────────
+
+#[gpui::test]
+async fn history_db_mark_running_interrupted_touches_only_running_rows(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_mark_interrupted").await;
+    for record in [
+        record("s1", "run-a", AgentTaskStatus::Running, 1_000),
+        record("s2", "run-b", AgentTaskStatus::Running, 1_100),
+    ] {
+        db.insert_started(&record).await.unwrap();
+    }
+    for (task, status) in [
+        ("done", AgentTaskStatus::Completed),
+        ("bad", AgentTaskStatus::Failed),
+        ("halt", AgentTaskStatus::Stopped),
+        ("old", AgentTaskStatus::Interrupted),
+    ] {
+        let mut running = record("s1", task, AgentTaskStatus::Running, 500);
+        db.insert_started(&running).await.unwrap();
+        running = record("s1", task, status, 500);
+        db.update_ended(&running).await.unwrap();
+    }
+    db.set_archived(&[key("s1", "done")], true).await.unwrap();
+
+    db.mark_running_interrupted().await.unwrap();
+
+    let rows = load(&db).await;
+    assert_eq!(rows.len(), 6, "the sweep deletes nothing");
+    for task in ["run-a", "run-b"] {
+        let session = if task == "run-a" { "s1" } else { "s2" };
+        let swept = &find(&rows, session, task).unwrap().record;
+        assert_eq!(swept.status, AgentTaskStatus::Interrupted, "{task}");
+        assert!(swept.ended_at_ms.is_some(), "{task} gets an end time");
+    }
+    for (task, status) in [
+        ("done", AgentTaskStatus::Completed),
+        ("bad", AgentTaskStatus::Failed),
+        ("halt", AgentTaskStatus::Stopped),
+        ("old", AgentTaskStatus::Interrupted),
+    ] {
+        let kept = find(&rows, "s1", task).unwrap();
+        assert_eq!(kept.record.status, status, "{task} keeps its outcome");
+        assert_eq!(
+            kept.record.ended_at_ms,
+            Some(1_500),
+            "{task} keeps its end time"
+        );
+        assert_eq!(kept.record.tokens, Some(100), "{task} keeps its tokens");
+    }
+    assert!(
+        find(&rows, "s1", "done").unwrap().archived,
+        "archived flag untouched"
+    );
+
+    // Running it again (a second startup) changes nothing more.
+    db.mark_running_interrupted().await.unwrap();
+    let again = load(&db).await;
+    assert_eq!(
+        find(&again, "s1", "run-a").unwrap().record.ended_at_ms,
+        find(&rows, "s1", "run-a").unwrap().record.ended_at_ms
+    );
+}
+
+// ── set_archived (R3.1, R3.3, R3.5, Q13) ───────────────────────────────────
+
+// Hostile: archiving (s1, t1) does not archive (s2, t1).
+#[gpui::test]
+async fn history_db_set_archived_targets_the_pair_not_the_task_id(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_archive_pair").await;
+    for session in ["s1", "s2"] {
+        db.insert_started(&record(session, "t1", AgentTaskStatus::Running, 1_000))
+            .await
+            .unwrap();
+        db.update_ended(&record(session, "t1", AgentTaskStatus::Completed, 1_000))
+            .await
+            .unwrap();
+    }
+    db.set_archived(&[key("s1", "t1")], true).await.unwrap();
+
+    let rows = load(&db).await;
+    assert!(find(&rows, "s1", "t1").unwrap().archived);
+    assert!(!find(&rows, "s2", "t1").unwrap().archived);
+}
+
+// Q13: a batch that fails part-way leaves every row as it was.
+#[gpui::test]
+async fn history_db_set_archived_is_atomic_when_a_row_fails_mid_batch(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_archive_atomic").await;
+    for task in ["a", "boom", "c", "d"] {
+        db.insert_started(&record("s1", task, AgentTaskStatus::Running, 1_000))
+            .await
+            .unwrap();
+        db.update_ended(&record("s1", task, AgentTaskStatus::Completed, 1_000))
+            .await
+            .unwrap();
+    }
+    db.set_archived(&[key("s1", "d")], true).await.unwrap();
+
+    run_sql(
+        &db,
+        "CREATE TRIGGER fail_mid_batch BEFORE UPDATE ON agent_task_history \
+         WHEN NEW.task_id = 'boom' \
+         BEGIN SELECT RAISE(ABORT, 'forced mid-batch failure'); END;",
+    )
+    .await;
+
+    let batch = [
+        key("s1", "a"),
+        key("s1", "boom"),
+        key("s1", "c"),
+        key("s1", "d"),
+    ];
+    let result = db.set_archived(&batch, true).await;
+    assert!(
+        result.is_err(),
+        "the forced failure must surface as an error"
+    );
+    let rows = load(&db).await;
+    assert!(
+        !find(&rows, "s1", "a").unwrap().archived,
+        "row before the failure rolled back"
+    );
+    assert!(!find(&rows, "s1", "boom").unwrap().archived);
+    assert!(!find(&rows, "s1", "c").unwrap().archived);
+    assert!(
+        find(&rows, "s1", "d").unwrap().archived,
+        "the earlier state is intact"
+    );
+
+    let unarchive = [key("s1", "d"), key("s1", "boom")];
+    assert!(db.set_archived(&unarchive, false).await.is_err());
+    assert!(
+        find(&load(&db).await, "s1", "d").unwrap().archived,
+        "an unarchive batch is atomic too"
+    );
+
+    // Without the sabotage the same batch succeeds: the failure was the trigger, not the
+    // statement.
+    run_sql(&db, "DROP TRIGGER fail_mid_batch").await;
+    db.set_archived(&batch, true).await.unwrap();
+    assert!(load(&db).await.iter().all(|entry| entry.archived));
+}
+
+// Benign: archive hides nothing from storage, unarchive restores, every record is kept.
+#[gpui::test]
+async fn history_db_set_archived_round_trips_and_keeps_every_record(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_archive_round_trip").await;
+    for task in ["a", "b", "c"] {
+        db.insert_started(&record("s1", task, AgentTaskStatus::Running, 1_000))
+            .await
+            .unwrap();
+        db.update_ended(&record("s1", task, AgentTaskStatus::Completed, 1_000))
+            .await
+            .unwrap();
+    }
+    db.set_archived(&[key("s1", "a"), key("s1", "b")], true)
+        .await
+        .unwrap();
+    let rows = load(&db).await;
+    assert_eq!(rows.len(), 3, "archiving deletes nothing (R3.5)");
+    assert!(find(&rows, "s1", "a").unwrap().archived);
+    assert!(find(&rows, "s1", "b").unwrap().archived);
+    assert!(!find(&rows, "s1", "c").unwrap().archived);
+
+    db.set_archived(&[key("s1", "a")], false).await.unwrap();
+    let rows = load(&db).await;
+    assert_eq!(rows.len(), 3);
+    let a = find(&rows, "s1", "a").unwrap();
+    assert!(!a.archived);
+    assert_eq!(
+        a.record.status,
+        AgentTaskStatus::Completed,
+        "the outcome is unchanged"
+    );
+    assert!(find(&rows, "s1", "b").unwrap().archived);
+
+    let empty: [(agent_client_protocol::v2::schema::SessionId, AgentTaskId); 0] = [];
+    db.set_archived(&empty, true).await.unwrap();
+}
+
+// ── load_all ───────────────────────────────────────────────────────────────
+
+#[gpui::test]
+async fn history_db_load_all_returns_newest_first(_cx: &mut TestAppContext) {
+    let db = AgentTaskHistoryDb::open_test_db("history_db_load_order").await;
+    assert!(load(&db).await.is_empty(), "an empty domain loads as empty");
+    for (session, task, at) in [
+        ("s1", "old", 1_000),
+        ("s2", "newest", 3_000),
+        ("s1", "mid", 2_000),
+    ] {
+        db.insert_started(&record(session, task, AgentTaskStatus::Running, at))
+            .await
+            .unwrap();
+    }
+    let order: Vec<String> = load(&db)
+        .await
+        .iter()
+        .map(|entry| entry.record.task_id.to_string())
+        .collect();
+    assert_eq!(order, vec!["newest", "mid", "old"]);
+}
diff --git a/crates/agent_tasks_panel/src/panel.rs b/crates/agent_tasks_panel/src/panel.rs
new file mode 100644
index 0000000..4a00207
--- /dev/null
+++ b/crates/agent_tasks_panel/src/panel.rs
@@ -0,0 +1,1069 @@
+//! `AgentTasksPanel`: the dock panel that lists the agent task history (R2).
+//!
+//! Records come from the global `TaskHistoryStore`, grouped by thread and filtered by
+//! outcome. The panel lists every task of every window, so a running row reads its progress
+//! from whichever registered `TasksCard` holds it (R2.3) — never from the store, which never
+//! writes progress (R1.4).
+//!
+//! The dock the panel sits in is saved in the key-value store under the panel's key: the
+//! workspace saves each panel's size, but a panel's dock only through that panel's settings,
+//! and this panel has none.
+
+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, TaskRecord, TasksCard,
+    thread_metadata_store::ThreadMetadataStore,
+};
+use anyhow::{Result, anyhow};
+use collections::HashMap;
+use db::kvp::KeyValueStore;
+use gpui::{
+    Action, AnyElement, App, AsyncWindowContext, Context, ElementId, Entity, EventEmitter,
+    FocusHandle, Focusable, IntoElement, Pixels, Render, SharedString, Subscription, Task,
+    UniformListScrollHandle, WeakEntity, Window, actions, px, uniform_list,
+};
+use settings::SettingsStore;
+use ui::{Banner, ButtonLike, ButtonStyle, Severity, TintColor, Tooltip, prelude::*};
+use workspace::{
+    Workspace,
+    dock::{DockPosition, Panel, PanelEvent},
+};
+
+use crate::{Availability, HistoryEntry, TaskHistoryStore, TaskKey};
+
+actions!(
+    agent_tasks_panel,
+    [
+        /// Toggles focus on the Agent Tasks panel.
+        ToggleFocus
+    ]
+);
+
+/// Registers the panel's `ToggleFocus` on every workspace created from now on.
+pub(crate) fn register_actions(cx: &mut App) {
+    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
+        workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
+            workspace.toggle_panel_focus::<AgentTasksPanel>(window, cx);
+        });
+    })
+    .detach();
+}
+
+/// One of the panel's list filters (R2.4). Each is a toggle; a row shows when the filter it
+/// falls under is on.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub enum TaskFilter {
+    /// Running tasks.
+    Active,
+    /// Completed, stopped or interrupted tasks.
+    Finished,
+    /// Failed tasks.
+    Failed,
+    /// Archived tasks, whatever their outcome — and only them.
+    Archived,
+}
+
+impl TaskFilter {
+    pub const ALL: [TaskFilter; 4] = [
+        TaskFilter::Active,
+        TaskFilter::Finished,
+        TaskFilter::Failed,
+        TaskFilter::Archived,
+    ];
+
+    fn label(self) -> &'static str {
+        match self {
+            TaskFilter::Active => "Active",
+            TaskFilter::Finished => "Finished",
+            TaskFilter::Failed => "Failed",
+            TaskFilter::Archived => "Archived",
+        }
+    }
+
+    fn tooltip(self, enabled: bool) -> &'static str {
+        match (self, enabled) {
+            (TaskFilter::Active, false) => "Show Running Tasks",
+            (TaskFilter::Active, true) => "Hide Running Tasks",
+            (TaskFilter::Finished, false) => "Show Completed, Stopped and Interrupted Tasks",
+            (TaskFilter::Finished, true) => "Hide Completed, Stopped and Interrupted Tasks",
+            (TaskFilter::Failed, false) => "Show Failed Tasks",
+            (TaskFilter::Failed, true) => "Hide Failed Tasks",
+            (TaskFilter::Archived, false) => "Show Archived Tasks",
+            (TaskFilter::Archived, true) => "Hide Archived Tasks",
+        }
+    }
+}
+
+/// Which filters are on. Active and Finished by default (R2.4).
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+struct TaskFilters {
+    active: bool,
+    finished: bool,
+    failed: bool,
+    archived: bool,
+}
+
+impl Default for TaskFilters {
+    fn default() -> Self {
+        Self {
+            active: true,
+            finished: true,
+            failed: false,
+            archived: false,
+        }
+    }
+}
+
+impl TaskFilters {
+    fn is_enabled(&self, filter: TaskFilter) -> bool {
+        match filter {
+            TaskFilter::Active => self.active,
+            TaskFilter::Finished => self.finished,
+            TaskFilter::Failed => self.failed,
+            TaskFilter::Archived => self.archived,
+        }
+    }
+
+    fn set(&mut self, filter: TaskFilter, enabled: bool) {
+        let flag = match filter {
+            TaskFilter::Active => &mut self.active,
+            TaskFilter::Finished => &mut self.finished,
+            TaskFilter::Failed => &mut self.failed,
+            TaskFilter::Archived => &mut self.archived,
+        };
+        *flag = enabled;
+    }
+
+    /// Whether `entry` is listed: an archived task only under Archived (R3.1), any other
+    /// under the filter its status falls under.
+    fn shows(&self, entry: &HistoryEntry) -> bool {
+        if entry.archived {
+            self.archived
+        } else {
+            self.is_enabled(status_filter(entry.record.status))
+        }
+    }
+}
+
+/// The filter an unarchived task falls under by its status.
+fn status_filter(status: AgentTaskStatus) -> TaskFilter {
+    match status {
+        AgentTaskStatus::Running => TaskFilter::Active,
+        AgentTaskStatus::Completed | AgentTaskStatus::Stopped | AgentTaskStatus::Interrupted => {
+            TaskFilter::Finished
+        }
+        AgentTaskStatus::Failed => TaskFilter::Failed,
+    }
+}
+
+/// One thread's visible rows.
+#[derive(Clone, Debug)]
+pub struct PanelGroup {
+    pub session_id: acp_v2::SessionId,
+    pub title: SharedString,
+    pub rows: Vec<PanelRow>,
+}
+
+/// One visible task.
+#[derive(Clone, Debug)]
+pub struct PanelRow {
+    /// The stored record, carrying the card's live values while the task runs (R2.3).
+    pub entry: HistoryEntry,
+    /// The task is running in a thread the card holds, so Stop can reach it.
+    pub can_stop: bool,
+    /// The task has ended and is not archived yet (R3.4).
+    pub can_archive: bool,
+    /// Why the last click on the row opened nothing (R2.7).
+    pub notice: Option<SharedString>,
+    /// The card's error for this row (a failed stop), if any.
+    pub error: Option<SharedString>,
+}
+
+/// One line of the list: a thread's header or one of its tasks. Both are one line high,
+/// because `uniform_list` measures the first item and lays every other out at its height.
+enum ListEntry {
+    Header {
+        session_id: acp_v2::SessionId,
+        title: SharedString,
+        task_count: usize,
+        /// A listed row of the group has ended unarchived, so archiving the group does
+        /// something (R3.2).
+        can_archive: bool,
+        archive_focus: FocusHandle,
+    },
+    Row(PanelRow, RowFocus),
+}
+
+/// The focus handles of one row's controls, kept across renders so keyboard focus stays on a
+/// control while the list repaints (Q14).
+#[derive(Clone)]
+struct RowFocus {
+    open: FocusHandle,
+    stop: FocusHandle,
+    /// Archive, or Unarchive on an archived row: the two share the row's last slot.
+    archive: FocusHandle,
+}
+
+impl RowFocus {
+    fn new(cx: &mut App) -> Self {
+        Self {
+            open: control_focus_handle(cx),
+            stop: control_focus_handle(cx),
+            archive: control_focus_handle(cx),
+        }
+    }
+}
+
+/// What the panel reads from the cards in one pass: each held thread's current title and each
+/// row's error.
+#[derive(Default)]
+struct CardSnapshot {
+    titles: HashMap<acp_v2::SessionId, SharedString>,
+    errors: HashMap<(acp_v2::SessionId, AgentTaskId), SharedString>,
+}
+
+impl CardSnapshot {
+    fn read(cards: &[Entity<TasksCard>], cx: &App) -> Self {
+        let mut snapshot = Self::default();
+        for group in cards.iter().flat_map(|card| card.read(cx).rows(cx)) {
+            for row in group.rows {
+                if let Some(error) = row.error {
+                    snapshot
+                        .errors
+                        .insert((group.session_id.clone(), row.task.id), error);
+                }
+            }
+            snapshot.titles.insert(group.session_id, group.title);
+        }
+        snapshot
+    }
+}
+
+pub struct AgentTasksPanel {
+    focus_handle: FocusHandle,
+    position: DockPosition,
+    /// The workspace whose agent panel a click opens threads in.
+    workspace: WeakEntity<Workspace>,
+    store: Entity<TaskHistoryStore>,
+    filters: TaskFilters,
+    /// One per filter toggle, in `TaskFilter::ALL` order, kept across renders so keyboard
+    /// focus survives a repaint (Q14).
+    filter_focus_handles: [FocusHandle; 4],
+    /// Per listed row and per listed group; entries of rows and groups no longer listed are
+    /// dropped on render.
+    row_focus_handles: HashMap<TaskKey, RowFocus>,
+    group_focus_handles: HashMap<acp_v2::SessionId, FocusHandle>,
+    /// A row's message after a click that opened nothing, until a later click opens it.
+    notices: HashMap<TaskKey, SharedString>,
+    scroll_handle: UniformListScrollHandle,
+    /// The lines of the last render, read by the `uniform_list` callback.
+    entries: Vec<ListEntry>,
+    /// The latest write of the dock position; a newer move replaces (and so cancels) it.
+    _save_position: Task<()>,
+    _subscriptions: Vec<Subscription>,
+}
+
+impl AgentTasksPanel {
+    /// Builds the panel for `workspace` in its saved dock and records the activity of its
+    /// agent panel's card with the global store — now when the agent panel is already there,
+    /// else as soon as the workspace adds one. It never waits for the agent panel: Zeo loads
+    /// both together, and with AI disabled there is none.
+    pub fn load(
+        workspace: WeakEntity<Workspace>,
+        cx: AsyncWindowContext,
+    ) -> Task<Result<Entity<Self>>> {
+        cx.spawn(async move |cx| {
+            let kvp = cx.update(|_, cx| KeyValueStore::global(cx))?;
+            let saved = cx
+                .background_spawn(async move { kvp.read_kvp(<Self as Panel>::panel_key()) })
+                .await;
+            let position = saved_position(saved);
+            workspace.update_in(cx, |workspace, window, cx| {
+                let store = TaskHistoryStore::try_global(cx).ok_or_else(|| {
+                    anyhow!("agent_tasks_panel::init must run before the Agent Tasks panel loads")
+                })?;
+                let agent_panel = workspace.panel::<AgentPanel>(cx);
+                let workspace_entity = cx.entity();
+                Ok(cx.new(|cx| {
+                    Self::new(store, position, &workspace_entity, agent_panel, window, cx)
+                }))
+            })?
+        })
+    }
+
+    fn new(
+        store: Entity<TaskHistoryStore>,
+        position: DockPosition,
+        workspace: &Entity<Workspace>,
+        agent_panel: Option<Entity<AgentPanel>>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let subscriptions = vec![
+            cx.observe(&store, |_, _, cx| cx.notify()),
+            // The agent panel loads alongside this one, and is torn down and rebuilt when AI
+            // is turned off and on again; each one brings a new card.
+            cx.subscribe_in(
+                workspace,
+                window,
+                |panel, _, event: &workspace::Event, _window, cx| {
+                    if let workspace::Event::PanelAdded(view) = event
+                        && let Ok(agent_panel) = view.clone().downcast::<AgentPanel>()
+                    {
+                        panel.attach_card(&agent_panel, cx);
+                    }
+                },
+            ),
+        ];
+        let mut panel = Self {
+            focus_handle: cx.focus_handle(),
+            position,
+            workspace: workspace.downgrade(),
+            store,
+            filters: TaskFilters::default(),
+            filter_focus_handles: std::array::from_fn(|_| control_focus_handle(cx)),
+            row_focus_handles: HashMap::default(),
+            group_focus_handles: HashMap::default(),
+            notices: HashMap::default(),
+            scroll_handle: UniformListScrollHandle::new(),
+            entries: Vec::new(),
+            _save_position: Task::ready(()),
+            _subscriptions: subscriptions,
+        };
+        if let Some(agent_panel) = agent_panel {
+            panel.attach_card(&agent_panel, cx);
+        }
+        panel
+    }
+
+    /// Registers `agent_panel`'s card with the store. The store notifies on every registered
+    /// card's change, which is what repaints a running row in any window.
+    fn attach_card(&mut self, agent_panel: &Entity<AgentPanel>, cx: &mut Context<Self>) {
+        let card = agent_panel.read(cx).tasks_card().clone();
+        self.store
+            .update(cx, |store, cx| store.register_card(&card, cx));
+    }
+
+    /// The visible rows (after filters), grouped by thread: groups ordered by their newest
+    /// task, rows newest first (R2.2).
+    pub fn groups(&self, cx: &App) -> Vec<PanelGroup> {
+        let store = self.store.read(cx);
+        let cards: Vec<Entity<TasksCard>> = store.cards().collect();
+        let snapshot = CardSnapshot::read(&cards, cx);
+
+        let mut rows: Vec<PanelRow> = store
+            .records()
+            .iter()
+            .filter(|entry| self.filters.shows(entry))
+            .map(|entry| {
+                let key = (
+                    entry.record.session_id.clone(),
+                    entry.record.task_id.clone(),
+                );
+                let live = entry
+                    .record
+                    .status
+                    .is_running()
+                    .then(|| store.live_task(&key.0, &key.1, cx))
+                    .flatten()
+                    .map(|(_card, task)| task);
+                let notice = self.notices.get(&key).cloned();
+                panel_row(entry, live, notice, snapshot.errors.get(&key).cloned())
+            })
+            .collect();
+        rows.sort_by(|a, b| newest_first(&a.entry, &b.entry));
+        group_rows(rows, &snapshot.titles)
+    }
+
+    pub fn is_filter_enabled(&self, filter: TaskFilter) -> bool {
+        self.filters.is_enabled(filter)
+    }
+
+    pub fn set_filter(&mut self, filter: TaskFilter, enabled: bool, cx: &mut Context<Self>) {
+        if self.filters.is_enabled(filter) == enabled {
+            return;
+        }
+        self.filters.set(filter, enabled);
+        cx.notify();
+    }
+
+    /// Opens the thread of `key`'s task in this workspace's agent panel (R2.5) — which brings
+    /// forward a conversation it already holds rather than loading a second one. A thread
+    /// that no longer exists opens nothing: the row says so instead (R2.7), since the agent
+    /// panel would otherwise start an empty thread under the stale session id.
+    pub fn open_task(&mut self, key: &TaskKey, window: &mut Window, cx: &mut Context<Self>) {
+        let (session_id, task_id) = key;
+        let thread_exists = ThreadMetadataStore::try_global(cx)
+            .is_some_and(|store| store.read(cx).entry_by_session(session_id).is_some());
+        if !thread_exists {
+            log::info!(
+                "agent tasks panel: not opening task {task_id} of session {session_id}: \
+                 its thread no longer exists"
+            );
+            self.set_notice(key, "Thread no longer exists", cx);
+            return;
+        }
+        let opened = self
+            .workspace
+            .update(cx, |workspace, cx| {
+                let agent_panel = workspace.focus_panel::<AgentPanel>(window, cx)?;
+                agent_panel.update(cx, |agent_panel, cx| {
+                    agent_panel.open_thread_by_session(session_id.clone(), window, cx)
+                });
+                Some(())
+            })
+            .ok()
+            .flatten();
+        if opened.is_none() {
+            log::error!(
+                "agent tasks panel: cannot open task {task_id} of session {session_id}: \
+                 this workspace has no agent panel"
+            );
+            self.set_notice(key, "The agent panel is not available", cx);
+            return;
+        }
+        if self.notices.remove(key).is_some() {
+            cx.notify();
+        }
+    }
+
+    /// Stops `key`'s running task through the card that holds it live, so the panel's Stop
+    /// is the card's: the same confirmation, and an error kept on the card's row, which the
+    /// panel reads back (R2.6).
+    pub fn request_stop(&mut self, key: &TaskKey, window: &mut Window, cx: &mut Context<Self>) {
+        let (session_id, task_id) = key;
+        let Some((card, _task)) = self.store.read(cx).live_task(session_id, task_id, cx) else {
+            log::debug!(
+                "agent tasks panel: stop requested for task {task_id} of session {session_id}, \
+                 which no card holds running"
+            );
+            return;
+        };
+        card.update(cx, |card, cx| {
+            card.request_stop(session_id.clone(), task_id.clone(), window, cx)
+        });
+    }
+
+    /// Archives `key`'s task (R3.1). A running task is never archived (R3.4).
+    pub fn archive(&mut self, key: &TaskKey, cx: &mut Context<Self>) {
+        let (session_id, task_id) = key;
+        let store = self.store.read(cx);
+        let finished = store.records().iter().any(|entry| {
+            entry.record.session_id == *session_id
+                && entry.record.task_id == *task_id
+                && !entry.record.status.is_running()
+        });
+        if !finished {
+            log::debug!(
+                "agent tasks panel: not archiving task {task_id} of session {session_id}: \
+                 it is running or not recorded"
+            );
+            return;
+        }
+        self.store
+            .update(cx, |store, cx| store.archive(std::slice::from_ref(key), cx));
+    }
+
+    /// Returns `key`'s task to the filters it matched before it was archived (R3.3).
+    pub fn unarchive(&mut self, key: &TaskKey, cx: &mut Context<Self>) {
+        self.store.update(cx, |store, cx| {
+            store.unarchive(std::slice::from_ref(key), cx)
+        });
+    }
+
+    /// Archives every finished task of `session_id`'s thread; its running tasks stay (R3.2).
+    pub fn archive_group(&mut self, session_id: &acp_v2::SessionId, cx: &mut Context<Self>) {
+        self.store
+            .update(cx, |store, cx| store.archive_group(session_id, cx));
+    }
+
+    fn set_notice(&mut self, key: &TaskKey, notice: &'static str, cx: &mut Context<Self>) {
+        self.notices
+            .insert(key.clone(), SharedString::new_static(notice));
+        cx.notify();
+    }
+
+    /// The panel's message when the history cannot be kept this session (R4.3).
+    pub fn status_message(&self, cx: &App) -> Option<SharedString> {
+        match self.store.read(cx).availability() {
+            Availability::Available => None,
+            Availability::Unavailable(cause) => {
+                Some(format!("Task history is unavailable: {cause}").into())
+            }
+        }
+    }
+
+    fn render_filters(&self, cx: &mut Context<Self>) -> impl IntoElement {
+        h_flex()
+            .p_1()
+            .gap_1()
+            .flex_wrap()
+            .border_b_1()
+            .border_color(cx.theme().colors().border)
+            .children(
+                TaskFilter::ALL
+                    .into_iter()
+                    .zip(&self.filter_focus_handles)
+                    .map(|(filter, focus_handle)| {
+                        let enabled = self.filters.is_enabled(filter);
+                        Button::new(
+                            SharedString::from(format!("agent-tasks-filter-{}", filter.label())),
+                            filter.label(),
+                        )
+                        .label_size(LabelSize::Small)
+                        .style(ButtonStyle::Subtle)
+                        .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+                        .toggle_state(enabled)
+                        .tooltip(Tooltip::text(filter.tooltip(enabled)))
+                        .track_focus(focus_handle)
+                        .on_click(cx.listener(move |panel, _, _, cx| {
+                            panel.set_filter(filter, !enabled, cx);
+                        }))
+                    }),
+            )
+    }
+
+    fn render_entries(&self, range: Range<usize>, cx: &mut Context<Self>) -> Vec<AnyElement> {
+        range
+            .filter_map(|index| self.entries.get(index))
+            .map(|entry| match entry {
+                ListEntry::Header {
+                    session_id,
+                    title,
+                    task_count,
+                    can_archive,
+                    archive_focus,
+                } => render_header(
+                    session_id,
+                    title,
+                    *task_count,
+                    *can_archive,
+                    archive_focus,
+                    cx,
+                ),
+                ListEntry::Row(row, focus) => render_row(row, focus, cx),
+            })
+            .collect()
+    }
+
+    /// The list's lines for `groups`, each with the focus handles of its controls. Handles of
+    /// rows and groups no longer listed are dropped here, so the maps track what is shown.
+    fn list_entries(&mut self, groups: Vec<PanelGroup>, cx: &mut App) -> Vec<ListEntry> {
+        let mut row_focus_handles = HashMap::default();
+        let mut group_focus_handles = HashMap::default();
+        let mut entries = Vec::new();
+        for group in groups {
+            let archive_focus = self
+                .group_focus_handles
+                .remove(&group.session_id)
+                .unwrap_or_else(|| control_focus_handle(cx));
+            group_focus_handles.insert(group.session_id.clone(), archive_focus.clone());
+            entries.push(ListEntry::Header {
+                can_archive: group.rows.iter().any(|row| row.can_archive),
+                session_id: group.session_id,
+                title: group.title,
+                task_count: group.rows.len(),
+                archive_focus,
+            });
+            for row in group.rows {
+                let key = (
+                    row.entry.record.session_id.clone(),
+                    row.entry.record.task_id.clone(),
+                );
+                let focus = self
+                    .row_focus_handles
+                    .remove(&key)
+                    .unwrap_or_else(|| RowFocus::new(cx));
+                row_focus_handles.insert(key, focus.clone());
+                entries.push(ListEntry::Row(row, focus));
+            }
+        }
+        self.row_focus_handles = row_focus_handles;
+        self.group_focus_handles = group_focus_handles;
+        entries
+    }
+}
+
+/// A thread's header: its title, its task count, and — while one of its listed tasks has
+/// ended unarchived — the control that archives the group's finished tasks (R3.2).
+fn render_header(
+    session_id: &acp_v2::SessionId,
+    title: &SharedString,
+    task_count: usize,
+    can_archive: bool,
+    archive_focus: &FocusHandle,
+    cx: &mut Context<AgentTasksPanel>,
+) -> AnyElement {
+    let header_id = ElementId::NamedChild(
+        Arc::new(ElementId::Name("agent-tasks-group".into())),
+        session_id.to_string().into(),
+    );
+    let archive_button = can_archive.then(|| {
+        action_button(
+            ElementId::NamedChild(Arc::new(header_id.clone()), "archive".into()),
+            IconName::Archive,
+            format!("Archive the finished tasks of {title}"),
+            "Archive This Thread's Finished Tasks",
+            archive_focus,
+        )
+        .on_click(cx.listener({
+            let session_id = session_id.clone();
+            move |panel, _, _, cx| panel.archive_group(&session_id, cx)
+        }))
+    });
+    h_flex()
+        .id(header_id)
+        .h_7()
+        .px_2()
+        .gap_1()
+        .justify_between()
+        .child(
+            div().min_w_0().flex_1().child(
+                Label::new(title.clone())
+                    .size(LabelSize::Small)
+                    .color(Color::Muted)
+                    .truncate(),
+            ),
+        )
+        .child(
+            h_flex()
+                .flex_none()
+                .gap_1()
+                .child(
+                    Label::new(task_count.to_string())
+                        .size(LabelSize::XSmall)
+                        .color(Color::Muted),
+                )
+                .children(archive_button),
+        )
+        .into_any_element()
+}
+
+/// `row`'s line: its description and its progress or outcome — together the control that
+/// opens its thread (R2.5) — then Stop while it runs (R2.6), or Archive once it has ended
+/// (R3.1, R3.4), or Unarchive when archived (R3.3). Watched or expanded detail stays on the
+/// card, so every row keeps the one-line height `uniform_list` relies on.
+fn render_row(row: &PanelRow, focus: &RowFocus, cx: &mut Context<AgentTasksPanel>) -> AnyElement {
+    let record = &row.entry.record;
+    let key: TaskKey = (record.session_id.clone(), record.task_id.clone());
+    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()) {
+        Some(message) => (message.clone(), Color::Error),
+        None => (row_detail(record).into(), status_color(record.status)),
+    };
+    let description = record.description.clone();
+
+    let open_button = ButtonLike::new(control_id("open"))
+        .full_width()
+        .child(
+            h_flex()
+                .w_full()
+                .min_w_0()
+                .gap_2()
+                .justify_between()
+                .child(
+                    div().min_w_0().flex_1().child(
+                        Label::new(description.clone())
+                            .size(LabelSize::Small)
+                            .truncate(),
+                    ),
+                )
+                // Shrinks and truncates too, so a long error cannot push the row past the
+                // panel.
+                .child(
+                    div().min_w_0().flex_shrink_1().child(
+                        Label::new(detail)
+                            .size(LabelSize::XSmall)
+                            .color(detail_color)
+                            .truncate(),
+                    ),
+                ),
+        )
+        .aria_label(format!("Open thread: {description}"))
+        .tooltip({
+            let description = description.clone();
+            move |_window, cx| Tooltip::with_meta(description.clone(), None, "Open Thread", cx)
+        })
+        .track_focus(&focus.open)
+        .on_click(cx.listener({
+            let key = key.clone();
+            move |panel, _, window, cx| panel.open_task(&key, window, cx)
+        }));
+
+    let stop_button = row.can_stop.then(|| {
+        action_button(
+            control_id("stop"),
+            IconName::Stop,
+            format!("Stop: {description}"),
+            "Stop This Task",
+            &focus.stop,
+        )
+        .on_click(cx.listener({
+            let key = key.clone();
+            move |panel, _, window, cx| panel.request_stop(&key, window, cx)
+        }))
+    });
+    let archive_button = if row.entry.archived {
+        Some(
+            action_button(
+                control_id("unarchive"),
+                IconName::Undo,
+                format!("Unarchive: {description}"),
+                "Unarchive This Task",
+                &focus.archive,
+            )
+            .on_click(cx.listener(move |panel, _, _, cx| panel.unarchive(&key, cx))),
+        )
+    } else {
+        row.can_archive.then(|| {
+            action_button(
+                control_id("archive"),
+                IconName::Archive,
+                format!("Archive: {description}"),
+                "Archive This Task",
+                &focus.archive,
+            )
+            .on_click(cx.listener({
+                let key = key.clone();
+                move |panel, _, _, cx| panel.archive(&key, cx)
+            }))
+        })
+    };
+
+    h_flex()
+        .id(row_id)
+        .h_7()
+        .pl_2()
+        .pr_2()
+        .gap_1()
+        .child(div().min_w_0().flex_1().child(open_button))
+        .children(stop_button)
+        .children(archive_button)
+        .into_any_element()
+}
+
+/// A row or header action: a small muted icon with its tooltip, its screen-reader label and
+/// its keyboard focus.
+fn action_button(
+    id: ElementId,
+    icon: IconName,
+    aria_label: String,
+    tooltip: &'static str,
+    focus_handle: &FocusHandle,
+) -> IconButton {
+    IconButton::new(id, icon)
+        .icon_size(IconSize::Small)
+        .icon_color(Color::Muted)
+        .aria_label(aria_label)
+        .tooltip(Tooltip::text(tooltip))
+        .track_focus(focus_handle)
+}
+
+/// A row's element id, keyed by its task rather than its place in the list, so a row's hover
+/// and click state stay with it when rows above it come and go. Nested children keep the pair
+/// unambiguous where a joined string would not be (`a-b` + `c` against `a` + `b-c`).
+fn row_element_id(session_id: &acp_v2::SessionId, task_id: &AgentTaskId) -> ElementId {
+    let session = ElementId::NamedChild(
+        Arc::new(ElementId::Name("agent-tasks-row".into())),
+        session_id.to_string().into(),
+    );
+    ElementId::NamedChild(Arc::new(session), task_id.clone())
+}
+
+/// The dock a saved value names; Left — the default — when nothing usable was saved.
+fn saved_position(saved: Result<Option<String>>) -> DockPosition {
+    match saved {
+        Ok(None) => DockPosition::Left,
+        Ok(Some(text)) => parse_dock_position(&text).unwrap_or_else(|| {
+            log::warn!("agent tasks panel: unknown saved dock position {text:?}; docking left");
+            DockPosition::Left
+        }),
+        Err(error) => {
+            log::error!(
+                "agent tasks panel: reading the saved dock position failed; docking left: \
+                 {error:#}"
+            );
+            DockPosition::Left
+        }
+    }
+}
+
+fn dock_position_text(position: DockPosition) -> &'static str {
+    match position {
+        DockPosition::Left => "left",
+        DockPosition::Right => "right",
+        DockPosition::Bottom => "bottom",
+    }
+}
+
+fn parse_dock_position(text: &str) -> Option<DockPosition> {
+    match text {
+        "left" => Some(DockPosition::Left),
+        "right" => Some(DockPosition::Right),
+        "bottom" => Some(DockPosition::Bottom),
+        _ => None,
+    }
+}
+
+/// `Running · 500 tokens · 4 tool uses`, or the outcome alone when no usage was reported.
+fn row_detail(record: &TaskRecord) -> String {
+    let mut parts = vec![status_label(record.status).to_string()];
+    if let Some(tokens) = record.tokens {
+        parts.push(format!("{tokens} tokens"));
+    }
+    if let Some(tool_uses) = record.tool_uses {
+        parts.push(match tool_uses {
+            1 => "1 tool use".to_string(),
+            count => format!("{count} tool uses"),
+        });
+    }
+    parts.join(" · ")
+}
+
+fn status_label(status: AgentTaskStatus) -> &'static str {
+    match status {
+        AgentTaskStatus::Running => "Running",
+        AgentTaskStatus::Completed => "Completed",
+        AgentTaskStatus::Failed => "Failed",
+        AgentTaskStatus::Stopped => "Stopped",
+        AgentTaskStatus::Interrupted => "Interrupted",
+    }
+}
+
+fn status_color(status: AgentTaskStatus) -> Color {
+    match status {
+        AgentTaskStatus::Running => Color::Muted,
+        AgentTaskStatus::Completed => Color::Success,
+        AgentTaskStatus::Failed => Color::Error,
+        AgentTaskStatus::Stopped | AgentTaskStatus::Interrupted => Color::Warning,
+    }
+}
+
+/// 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.
+fn panel_row(
+    entry: &HistoryEntry,
+    live: Option<AgentTask>,
+    notice: Option<SharedString>,
+    error: Option<SharedString>,
+) -> PanelRow {
+    let mut entry = entry.clone();
+    let can_stop = live.is_some();
+    if let Some(task) = live {
+        let record = &mut entry.record;
+        record.status = task.status;
+        record.description = task.description;
+        record.tokens = task.usage.as_ref().map(|usage| usage.tokens);
+        record.tool_uses = task.usage.as_ref().map(|usage| usage.tool_uses);
+    }
+    PanelRow {
+        can_stop,
+        can_archive: !entry.archived && !entry.record.status.is_running(),
+        notice,
+        error,
+        entry,
+    }
+}
+
+/// Newest task first; session and task id break ties, so equal start times list the same
+/// way on every render.
+fn newest_first(a: &HistoryEntry, b: &HistoryEntry) -> std::cmp::Ordering {
+    let key = |entry: &HistoryEntry| {
+        (
+            Reverse(entry.record.started_at_ms),
+            entry.record.session_id.0.clone(),
+            entry.record.task_id.clone(),
+        )
+    };
+    key(a).cmp(&key(b))
+}
+
+/// Groups rows already sorted newest first by session. Groups come out in the order of their
+/// first row — so by their newest task — and keep their rows' order. A group is titled by
+/// the card while the card holds its thread (the title may have changed since its tasks were
+/// recorded), else by its newest record that carries a title.
+fn group_rows(
+    rows: Vec<PanelRow>,
+    live_titles: &HashMap<acp_v2::SessionId, SharedString>,
+) -> Vec<PanelGroup> {
+    let mut groups: Vec<PanelGroup> = Vec::new();
+    let mut index_of: HashMap<acp_v2::SessionId, usize> = HashMap::default();
+    for row in rows {
+        let session_id = row.entry.record.session_id.clone();
+        let index = *index_of.entry(session_id.clone()).or_insert_with(|| {
+            groups.push(PanelGroup {
+                session_id,
+                title: SharedString::default(),
+                rows: Vec::new(),
+            });
+            groups.len() - 1
+        });
+        groups[index].rows.push(row);
+    }
+    for group in &mut groups {
+        group.title = live_titles
+            .get(&group.session_id)
+            .cloned()
+            .or_else(|| {
+                group
+                    .rows
+                    .iter()
+                    .find_map(|row| row.entry.record.thread_title.clone())
+            })
+            .unwrap_or_else(|| SharedString::new_static(DEFAULT_THREAD_TITLE));
+    }
+    groups
+}
+
+/// A focus handle for one control, reachable with Tab. GPUI ignores an element's own
+/// `tab_index` once the element is given a handle, so the handle carries it.
+fn control_focus_handle(cx: &mut App) -> FocusHandle {
+    cx.focus_handle().tab_stop(true).tab_index(0)
+}
+
+impl EventEmitter<PanelEvent> for AgentTasksPanel {}
+
+impl Focusable for AgentTasksPanel {
+    fn focus_handle(&self, _cx: &App) -> FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl Render for AgentTasksPanel {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let groups = self.groups(cx);
+        self.entries = self.list_entries(groups, cx);
+        let entry_count = self.entries.len();
+
+        v_flex()
+            .key_context("AgentTasksPanel")
+            .track_focus(&self.focus_handle)
+            .size_full()
+            .bg(cx.theme().colors().panel_background)
+            .child(self.render_filters(cx))
+            .when_some(self.status_message(cx), |panel, message| {
+                panel.child(
+                    div().p_1().child(
+                        Banner::new()
+                            .severity(Severity::Warning)
+                            .wrap_content(true)
+                            .child(Label::new(message).size(LabelSize::Small)),
+                    ),
+                )
+            })
+            .map(|panel| {
+                if entry_count == 0 {
+                    panel.child(
+                        v_flex()
+                            .flex_1()
+                            .items_center()
+                            .justify_center()
+                            .p_4()
+                            .child(
+                                Label::new("No tasks match the selected filters")
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted),
+                            ),
+                    )
+                } else {
+                    panel.child(
+                        uniform_list(
+                            "agent-tasks-list",
+                            entry_count,
+                            cx.processor(|panel, range: Range<usize>, _window, cx| {
+                                panel.render_entries(range, cx)
+                            }),
+                        )
+                        .flex_1()
+                        .track_scroll(&self.scroll_handle),
+                    )
+                }
+            })
+    }
+}
+
+impl Panel for AgentTasksPanel {
+    fn persistent_name() -> &'static str {
+        "AgentTasksPanel"
+    }
+
+    fn panel_key() -> &'static str {
+        "AgentTasksPanel"
+    }
+
+    fn position(&self, _window: &Window, _cx: &App) -> DockPosition {
+        self.position
+    }
+
+    fn position_is_valid(&self, position: DockPosition) -> bool {
+        matches!(
+            position,
+            DockPosition::Left | DockPosition::Right | DockPosition::Bottom
+        )
+    }
+
+    fn set_position(
+        &mut self,
+        position: DockPosition,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.position = position;
+        let kvp = KeyValueStore::global(cx);
+        let value = dock_position_text(position).to_string();
+        self._save_position = cx.background_spawn(async move {
+            if let Err(error) = kvp
+                .write_kvp(<Self as Panel>::panel_key().to_string(), value)
+                .await
+            {
+                log::error!("agent tasks panel: saving the dock position failed: {error:#}");
+            }
+        });
+        // A dock moves its panels only when it re-reads their positions, which it does on a
+        // settings change. Panels with settings of their own get one by writing them; this
+        // panel has none, so it asks for the same re-read without changing any setting.
+        cx.update_global::<SettingsStore, _>(|_, _| {});
+        cx.notify();
+    }
+
+    fn default_size(&self, _window: &Window, _cx: &App) -> Pixels {
+        px(320.)
+    }
+
+    fn icon(&self, _window: &Window, _cx: &App) -> Option<IconName> {
+        Some(IconName::ListTodo)
+    }
+
+    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
+        Some("Agent Tasks")
+    }
+
+    fn toggle_action(&self) -> Box<dyn Action> {
+        Box::new(ToggleFocus)
+    }
+
+    fn activation_priority(&self) -> u32 {
+        8
+    }
+}
+
+#[cfg(test)]
+#[path = "panel/list_tests.rs"]
+mod list;
+
+#[cfg(test)]
+#[path = "panel/multi_window_tests.rs"]
+mod multi_window;
+
+#[cfg(test)]
+#[path = "panel/persistence_tests.rs"]
+mod persistence;
+
+#[cfg(test)]
+#[path = "panel/actions_tests.rs"]
+mod actions;
diff --git a/crates/agent_tasks_panel/src/panel/actions_tests.rs b/crates/agent_tasks_panel/src/panel/actions_tests.rs
new file mode 100644
index 0000000..6a955ef
--- /dev/null
+++ b/crates/agent_tasks_panel/src/panel/actions_tests.rs
@@ -0,0 +1,365 @@
+// Story 014, task 4.2 — row actions: open, vanished thread, stop, archive (R2.5, R2.6, R2.7,
+// R3.1, R3.2, R3.3, R3.4, Q4, Q11). Module path: `panel::actions`.
+
+use acp_thread::AgentTaskStatus;
+use gpui::TestAppContext;
+
+use crate::TaskFilter;
+use crate::test_helpers::*;
+
+fn only(panels: &mut Panels, on: &[TaskFilter]) {
+    let all = [
+        TaskFilter::Active,
+        TaskFilter::Finished,
+        TaskFilter::Failed,
+        TaskFilter::Archived,
+    ];
+    panels.tasks_panel.update(&mut panels.cx, |panel, cx| {
+        for filter in all {
+            panel.set_filter(filter, on.contains(&filter), cx);
+        }
+    });
+    panels.cx.run_until_parked();
+}
+
+fn defaults(panels: &mut Panels) {
+    only(panels, &[TaskFilter::Active, TaskFilter::Finished]);
+}
+
+fn open(panels: &mut Panels, session: &str, task: &str) {
+    let target = key(session, task);
+    panels
+        .tasks_panel
+        .update_in(&mut panels.cx, |panel, window, cx| {
+            panel.open_task(&target, window, cx)
+        });
+    panels.cx.run_until_parked();
+}
+
+async fn seed_finished(
+    db: &crate::AgentTaskHistoryDb,
+    session: &str,
+    task: &str,
+    status: AgentTaskStatus,
+    at: i64,
+) {
+    db.insert_started(&record(session, task, AgentTaskStatus::Running, at))
+        .await
+        .unwrap();
+    db.update_ended(&record(session, task, status, at))
+        .await
+        .unwrap();
+}
+
+// R2.5 — a click opens the task's own thread. Hostile: both threads hold the same task id.
+#[gpui::test]
+async fn panel_actions_click_opens_the_thread_of_the_task(cx: &mut TestAppContext) {
+    init_base(cx);
+    let mut panels = open_panels(cx).await;
+    let thread_a = open_live_thread(&mut panels, "Thread A");
+    send_tasks(
+        &thread_a,
+        vec![wire_task("t", "running", 1_000, None)],
+        &mut panels.cx,
+    );
+    let thread_b = open_live_thread(&mut panels, "Thread B");
+    send_tasks(
+        &thread_b,
+        vec![wire_task("t", "running", 2_000, None)],
+        &mut panels.cx,
+    );
+    let a = session_of(&thread_a, &panels.cx);
+    let b = session_of(&thread_b, &panels.cx);
+    assert_eq!(active_session(&panels), Some(b.clone()));
+
+    open(&mut panels, &a.to_string(), "t");
+    assert_eq!(
+        active_session(&panels),
+        Some(a.clone()),
+        "opens thread A, not the latest"
+    );
+    assert_eq!(
+        find_row(&mut panels, &a.to_string(), "t").unwrap().notice,
+        None
+    );
+
+    open(&mut panels, &b.to_string(), "t");
+    assert_eq!(active_session(&panels), Some(b));
+}
+
+// R2.7 — a recorded task whose thread is gone says so on its row and opens nothing.
+#[gpui::test]
+async fn panel_actions_click_on_a_task_whose_thread_vanished_says_so(cx: &mut TestAppContext) {
+    let db = init_base(cx);
+    seed_finished(&db, "gone-session", "t", AgentTaskStatus::Completed, 1_000).await;
+    let mut panels = open_panels(cx).await;
+    let thread = open_live_thread(&mut panels, "Still here");
+    send_tasks(
+        &thread,
+        vec![wire_task("t", "running", 2_000, None)],
+        &mut panels.cx,
+    );
+    let here = session_of(&thread, &panels.cx);
+    let before = active_session(&panels);
+    assert_eq!(before, Some(here.clone()));
+
+    open(&mut panels, "gone-session", "t");
+
+    assert_eq!(active_session(&panels), before, "no empty thread is opened");
+    let notice = find_row(&mut panels, "gone-session", "t")
+        .expect("the row is still listed")
+        .notice
+        .expect("the row says why nothing opened");
+    assert!(notice.contains("Thread no longer exists"), "got {notice:?}");
+    assert_eq!(
+        find_row(&mut panels, &here.to_string(), "t")
+            .unwrap()
+            .notice,
+        None,
+        "the same task id in a live thread carries no notice"
+    );
+}
+
+// R2.6, R3.4 — Stop only on running rows; archive only on finished rows.
+#[gpui::test]
+async fn panel_actions_running_rows_offer_stop_and_finished_rows_offer_archive(
+    cx: &mut TestAppContext,
+) {
+    let db = init_base(cx);
+    seed_finished(&db, "older", "past", AgentTaskStatus::Failed, 500).await;
+    let mut panels = open_panels(cx).await;
+    only(
+        &mut panels,
+        &[TaskFilter::Active, TaskFilter::Finished, TaskFilter::Failed],
+    );
+    let thread = open_live_thread(&mut panels, "Live");
+    send_tasks(
+        &thread,
+        vec![
+            wire_task("done", "running", 900, None),
+            wire_task("run", "running", 1_000, None),
+        ],
+        &mut panels.cx,
+    );
+    send_tasks(
+        &thread,
+        vec![
+            wire_task("done", "completed", 900, Some((10, 1))),
+            wire_task("run", "running", 1_000, None),
+        ],
+        &mut panels.cx,
+    );
+    let session = session_of(&thread, &panels.cx).to_string();
+
+    let run = find_row(&mut panels, &session, "run").unwrap();
+    assert!(run.can_stop, "a running row offers Stop");
+    assert!(!run.can_archive, "a running row never offers archive");
+    for (session, task) in [(session.as_str(), "done"), ("older", "past")] {
+        let row = find_row(&mut panels, session, task).unwrap();
+        assert!(!row.can_stop, "{task}: nothing to stop");
+        assert!(row.can_archive, "{task}: a finished row offers archive");
+    }
+}
+
+// R2.6 — Stop goes through the card's `request_stop`: story 013's prompt (it names the task),
+// Cancel sends nothing, a confirmed stop reaches THAT task of THAT thread, and a failed stop's
+// error — kept by the card — is read back on the panel row. Hostile: two threads share the
+// task id.
+#[gpui::test]
+async fn panel_actions_stop_uses_the_card_flow_and_shows_its_error(cx: &mut TestAppContext) {
+    init_base(cx);
+    let mut panels = open_panels(cx).await;
+    let connection = StopConnection::new();
+    *connection.stop_error.lock().unwrap() = Some("stop failed for task run: boom");
+    let other = open_thread_on(
+        &panels.agent_panel,
+        connection.clone(),
+        "Other",
+        &mut panels.cx,
+    );
+    send_tasks(
+        &other,
+        vec![wire_task("run", "running", 500, None)],
+        &mut panels.cx,
+    );
+    let thread = open_thread_on(
+        &panels.agent_panel,
+        connection.clone(),
+        "Live",
+        &mut panels.cx,
+    );
+    send_tasks(
+        &thread,
+        vec![wire_task("run", "running", 1_000, None)],
+        &mut panels.cx,
+    );
+    let session = session_of(&thread, &panels.cx).to_string();
+    let target = key(&session, "run");
+
+    panels
+        .tasks_panel
+        .update_in(&mut panels.cx, |panel, window, cx| {
+            panel.request_stop(&target, window, cx)
+        });
+    panels.cx.run_until_parked();
+    assert!(panels.cx.has_pending_prompt(), "Stop asks before stopping");
+    let (message, detail) = panels.cx.pending_prompt().unwrap();
+    assert!(
+        format!("{message} {detail}").contains("Task run"),
+        "the card's prompt names the task: {message:?} / {detail:?}"
+    );
+    panels.cx.simulate_prompt_answer("Cancel");
+    panels.cx.run_until_parked();
+    assert!(connection.stops().is_empty(), "Cancel sends nothing");
+    assert_eq!(
+        find_row(&mut panels, &session, "run")
+            .unwrap()
+            .entry
+            .record
+            .status,
+        AgentTaskStatus::Running
+    );
+
+    panels
+        .tasks_panel
+        .update_in(&mut panels.cx, |panel, window, cx| {
+            panel.request_stop(&target, window, cx)
+        });
+    panels.cx.run_until_parked();
+    panels.cx.simulate_prompt_answer("Stop");
+    panels.cx.run_until_parked();
+    assert_eq!(
+        connection.stops(),
+        vec![(session.clone(), "run".to_string())],
+        "only that thread's task is stopped"
+    );
+    let row = find_row(&mut panels, &session, "run").expect("the task is still listed");
+    assert_eq!(row.entry.record.status, AgentTaskStatus::Running);
+    let error = row
+        .error
+        .expect("the card's stop error reaches the panel row");
+    assert!(error.contains("boom"), "row error was {error:?}");
+    let other_session = session_of(&other, &panels.cx).to_string();
+    assert_eq!(
+        find_row(&mut panels, &other_session, "run").unwrap().error,
+        None,
+        "the other thread's same-id row carries no error"
+    );
+}
+
+// R3.1, R3.3 — archive hides a finished row from every filter but Archived and keeps the
+// record; unarchive returns it to the filters it matched.
+#[gpui::test]
+async fn panel_actions_archive_hides_a_finished_row_until_unarchived(cx: &mut TestAppContext) {
+    let db = init_base(cx);
+    seed_finished(&db, "s1", "c1", AgentTaskStatus::Completed, 1_000).await;
+    seed_finished(&db, "s1", "c2", AgentTaskStatus::Completed, 1_100).await;
+    seed_finished(&db, "s2", "c1", AgentTaskStatus::Completed, 1_200).await;
+    let mut panels = open_panels(cx).await;
+
+    let target = key("s1", "c1");
+    panels
+        .tasks_panel
+        .update(&mut panels.cx, |panel, cx| panel.archive(&target, cx));
+    panels.cx.run_until_parked();
+
+    assert!(
+        find_row(&mut panels, "s1", "c1").is_none(),
+        "hidden by default"
+    );
+    assert!(find_row(&mut panels, "s1", "c2").is_some());
+    assert!(
+        find_row(&mut panels, "s2", "c1").is_some(),
+        "same task id elsewhere stays"
+    );
+    only(
+        &mut panels,
+        &[TaskFilter::Active, TaskFilter::Finished, TaskFilter::Failed],
+    );
+    assert!(
+        find_row(&mut panels, "s1", "c1").is_none(),
+        "hidden from every filter"
+    );
+    only(&mut panels, &[TaskFilter::Archived]);
+    assert!(
+        find_row(&mut panels, "s1", "c1").is_some(),
+        "shown under Archived"
+    );
+    let stored = load(&db).await;
+    assert!(
+        find(&stored, "s1", "c1").unwrap().archived,
+        "the record is kept, archived"
+    );
+
+    panels
+        .tasks_panel
+        .update(&mut panels.cx, |panel, cx| panel.unarchive(&target, cx));
+    panels.cx.run_until_parked();
+    assert!(
+        find_row(&mut panels, "s1", "c1").is_none(),
+        "no longer under Archived"
+    );
+    defaults(&mut panels);
+    assert!(
+        find_row(&mut panels, "s1", "c1").is_some(),
+        "back under Finished"
+    );
+}
+
+// R3.2 — archiving a group leaves its running task in Active and moves the rest to Archived.
+#[gpui::test]
+async fn panel_actions_archive_group_leaves_the_running_task_in_active(cx: &mut TestAppContext) {
+    let db = init_base(cx);
+    seed_finished(
+        &db,
+        "other-session",
+        "done",
+        AgentTaskStatus::Completed,
+        500,
+    )
+    .await;
+    let mut panels = open_panels(cx).await;
+    let thread = open_live_thread(&mut panels, "Live");
+    send_tasks(
+        &thread,
+        vec![
+            wire_task("done", "running", 900, None),
+            wire_task("run", "running", 1_000, None),
+        ],
+        &mut panels.cx,
+    );
+    send_tasks(
+        &thread,
+        vec![
+            wire_task("done", "completed", 900, Some((10, 1))),
+            wire_task("run", "running", 1_000, None),
+        ],
+        &mut panels.cx,
+    );
+    let session = session_of(&thread, &panels.cx);
+    let s = session.to_string();
+
+    panels.tasks_panel.update(&mut panels.cx, |panel, cx| {
+        panel.archive_group(&session, cx)
+    });
+    panels.cx.run_until_parked();
+
+    assert!(
+        find_row(&mut panels, &s, "run").is_some(),
+        "the running task stays in Active"
+    );
+    assert!(
+        find_row(&mut panels, &s, "done").is_none(),
+        "the finished task left the defaults"
+    );
+    assert!(
+        find_row(&mut panels, "other-session", "done").is_some(),
+        "another thread's task with the same id is untouched"
+    );
+    only(&mut panels, &[TaskFilter::Archived]);
+    assert!(find_row(&mut panels, &s, "done").is_some());
+    assert!(
+        find_row(&mut panels, &s, "run").is_none(),
+        "the running task is not archived"
+    );
+}
diff --git a/crates/agent_tasks_panel/src/panel/list_tests.rs b/crates/agent_tasks_panel/src/panel/list_tests.rs
new file mode 100644
index 0000000..f5cbc99
--- /dev/null
+++ b/crates/agent_tasks_panel/src/panel/list_tests.rs
@@ -0,0 +1,291 @@
+// Story 014, task 4.1 — the dock panel, its list and its filters (R2.1, R2.2, R2.3, R2.4,
+// R4.3, Q11, Q14). Module path: `panel::list`. GPUI integration tests over the real
+// TasksCard, the real store and the per-test app database.
+
+use acp_thread::AgentTaskStatus;
+use gpui::{TestAppContext, px};
+use ui::IconName;
+use workspace::dock::{DockPosition, Panel};
+
+use crate::test_helpers::*;
+use crate::{AgentTaskHistoryDb, AgentTasksPanel, TaskFilter};
+
+async fn seed_finished(db: &AgentTaskHistoryDb, record: agent_ui::TaskRecord) {
+    let mut running = record.clone();
+    running.status = AgentTaskStatus::Running;
+    running.ended_at_ms = None;
+    running.tokens = None;
+    running.tool_uses = None;
+    db.insert_started(&running).await.unwrap();
+    db.update_ended(&record).await.unwrap();
+}
+
+fn set_filters(panels: &mut Panels, on: &[TaskFilter]) {
+    let all = [
+        TaskFilter::Active,
+        TaskFilter::Finished,
+        TaskFilter::Failed,
+        TaskFilter::Archived,
+    ];
+    panels.tasks_panel.update(&mut panels.cx, |panel, cx| {
+        for filter in all {
+            panel.set_filter(filter, on.contains(&filter), cx);
+        }
+    });
+    panels.cx.run_until_parked();
+}
+
+fn sorted(mut keys: Vec<(String, String)>) -> Vec<(String, String)> {
+    keys.sort();
+    keys
+}
+
+fn pair(session: &str, task: &str) -> (String, String) {
+    (session.to_string(), task.to_string())
+}
+
+// R2.1, Q14 — dock, size, icon, tooltip and toggle action; `init` created the store.
+#[gpui::test]
+async fn panel_list_docks_left_by_default_at_320px_and_accepts_every_dock(cx: &mut TestAppContext) {
+    init_base(cx);
+    let mut panels = open_panels(cx).await;
+
+    panels.cx.update(|_, cx| {
+        crate::TaskHistoryStore::global(cx);
+    });
+    assert_eq!(
+        <AgentTasksPanel as Panel>::persistent_name(),
+        "AgentTasksPanel"
+    );
+    panels
+        .tasks_panel
+        .update_in(&mut panels.cx, |panel, window, cx| {
+            assert!(matches!(panel.position(window, cx), DockPosition::Left));
+            assert_eq!(panel.default_size(window, cx), px(320.));
+            for position in [
+                DockPosition::Left,
+                DockPosition::Right,
+                DockPosition::Bottom,
+            ] {
+                assert!(
+                    panel.position_is_valid(position),
+                    "{position:?} is a valid dock"
+                );
+            }
+            assert_eq!(panel.icon(window, cx), Some(IconName::ListTodo));
+            assert!(
+                panel.icon_tooltip(window, cx).is_some(),
+                "the dock button carries a tooltip"
+            );
+            assert!(panel.toggle_action().partial_eq(&crate::ToggleFocus));
+            assert_eq!(panel.status_message(cx), None, "history is available");
+        });
+}
+
+#[gpui::test]
+async fn panel_list_toggle_focus_action_opens_the_panel_in_its_dock(cx: &mut TestAppContext) {
+    init_base(cx);
+    let mut panels = open_panels(cx).await;
+    let tasks_panel_id = panels.tasks_panel.entity_id();
+    let shown = |panels: &Panels| {
+        panels.workspace.read_with(&panels.cx, |workspace, cx| {
+            let dock = workspace.left_dock().read(cx);
+            dock.is_open()
+                && dock.visible_panel().map(|panel| panel.panel_id()) == Some(tasks_panel_id)
+        })
+    };
+    assert!(!shown(&panels), "the panel starts closed");
+
+    panels.cx.dispatch_action(crate::ToggleFocus);
+    panels.cx.run_until_parked();
+    assert!(shown(&panels), "ToggleFocus opens the Agent Tasks panel");
+}
+
+// R2.2 — grouped by thread (session), groups by their newest task, rows newest first.
+// Hostile: two threads with the same title stay two groups; one thread whose title changed
+// between tasks stays one group; one task id in two threads stays in its own group.
+#[gpui::test]
+async fn panel_list_groups_threads_by_their_newest_task_and_lists_rows_newest_first(
+    cx: &mut TestAppContext,
+) {
+    let db = init_base(cx);
+    let titled = |session: &str, task: &str, at: i64, title: &str| {
+        let mut finished = record(session, task, AgentTaskStatus::Completed, at);
+        finished.thread_title = Some(title.to_string().into());
+        finished
+    };
+    // X: oldest 1000, newest 5000. Y: oldest 3000, newest 4000. Z: only 4500.
+    seed_finished(&db, titled("x", "a1", 1_000, "Same title")).await;
+    seed_finished(&db, titled("x", "shared", 2_000, "Same title")).await;
+    seed_finished(&db, titled("x", "a2", 5_000, "Renamed title")).await;
+    seed_finished(&db, titled("y", "b1", 3_000, "Same title")).await;
+    seed_finished(&db, titled("y", "shared", 4_000, "Same title")).await;
+    seed_finished(&db, titled("z", "c1", 4_500, "Another")).await;
+
+    let mut panels = open_panels(cx).await;
+    let groups = groups(&mut panels);
+
+    let order: Vec<String> = groups.iter().map(|g| g.session_id.to_string()).collect();
+    assert_eq!(
+        order,
+        vec!["x", "z", "y"],
+        "groups ordered by their newest task"
+    );
+    let rows_of = |session: &str| -> Vec<String> {
+        groups
+            .iter()
+            .find(|g| g.session_id.to_string() == session)
+            .unwrap()
+            .rows
+            .iter()
+            .map(|row| row.entry.record.task_id.to_string())
+            .collect()
+    };
+    assert_eq!(
+        rows_of("x"),
+        vec!["a2", "shared", "a1"],
+        "rows newest first"
+    );
+    assert_eq!(rows_of("y"), vec!["shared", "b1"]);
+    assert_eq!(rows_of("z"), vec!["c1"]);
+}
+
+// R2.4 — Active and Finished on by default; Failed and Archived off. Archived rows show
+// under Archived only.
+#[gpui::test]
+async fn panel_list_filters_default_to_active_and_finished(cx: &mut TestAppContext) {
+    let db = init_base(cx);
+    for (task, status, at) in [
+        ("c", AgentTaskStatus::Completed, 1_000),
+        ("s", AgentTaskStatus::Stopped, 1_100),
+        ("i", AgentTaskStatus::Interrupted, 1_200),
+        ("f", AgentTaskStatus::Failed, 1_300),
+        ("ac", AgentTaskStatus::Completed, 1_400),
+        ("af", AgentTaskStatus::Failed, 1_500),
+    ] {
+        seed_finished(&db, record("s1", task, status, at)).await;
+    }
+    db.set_archived(&[key("s1", "ac"), key("s1", "af")], true)
+        .await
+        .unwrap();
+
+    let mut panels = open_panels(cx).await;
+    panels.tasks_panel.read_with(&panels.cx, |panel, _| {
+        assert!(panel.is_filter_enabled(TaskFilter::Active));
+        assert!(panel.is_filter_enabled(TaskFilter::Finished));
+        assert!(!panel.is_filter_enabled(TaskFilter::Failed));
+        assert!(!panel.is_filter_enabled(TaskFilter::Archived));
+    });
+    assert_eq!(
+        sorted(visible_keys(&mut panels)),
+        sorted(vec![pair("s1", "c"), pair("s1", "s"), pair("s1", "i")]),
+        "Finished = completed, stopped, interrupted; archived rows hidden"
+    );
+
+    set_filters(
+        &mut panels,
+        &[TaskFilter::Active, TaskFilter::Finished, TaskFilter::Failed],
+    );
+    assert_eq!(
+        sorted(visible_keys(&mut panels)),
+        sorted(vec![
+            pair("s1", "c"),
+            pair("s1", "s"),
+            pair("s1", "i"),
+            pair("s1", "f")
+        ]),
+        "an archived failed row stays hidden under Failed"
+    );
+
+    set_filters(&mut panels, &[TaskFilter::Failed]);
+    assert_eq!(visible_keys(&mut panels), vec![pair("s1", "f")]);
+
+    set_filters(&mut panels, &[TaskFilter::Archived]);
+    assert_eq!(
+        sorted(visible_keys(&mut panels)),
+        sorted(vec![pair("s1", "ac"), pair("s1", "af")]),
+        "Archived shows the archived rows only"
+    );
+}
+
+// R2.3, R1.4 — a running row reads its live values from the card; progress is not stored.
+// End to end: snapshot → TasksCard → TaskActivity → store (registered on the card by
+// `AgentTasksPanel::load`) → panel.
+#[gpui::test]
+async fn panel_list_running_row_shows_live_progress_from_the_card(cx: &mut TestAppContext) {
+    let db = init_base(cx);
+    let mut panels = open_panels(cx).await;
+    let thread = open_live_thread(&mut panels, "Live thread");
+    let session = session_of(&thread, &panels.cx).to_string();
+
+    send_tasks(
+        &thread,
+        vec![wire_task("live", "running", 10_000, Some((500, 4)))],
+        &mut panels.cx,
+    );
+    let row = find_row(&mut panels, &session, "live").expect("the running task is listed");
+    assert_eq!(row.entry.record.status, AgentTaskStatus::Running);
+    assert_eq!(row.entry.record.tokens, Some(500));
+    assert_eq!(row.entry.record.tool_uses, Some(4));
+    let group_title = groups(&mut panels)
+        .into_iter()
+        .find(|g| g.session_id.to_string() == session)
+        .map(|g| g.title.to_string());
+    assert_eq!(group_title.as_deref(), Some("Live thread"));
+
+    send_tasks(
+        &thread,
+        vec![wire_task("live", "running", 10_000, Some((800, 6)))],
+        &mut panels.cx,
+    );
+    let row = find_row(&mut panels, &session, "live").unwrap();
+    assert_eq!(
+        row.entry.record.tokens,
+        Some(800),
+        "live, not the stored start"
+    );
+
+    let stored = load(&db).await;
+    let stored = find(&stored, &session, "live").expect("the start was recorded");
+    assert_eq!(stored.record.status, AgentTaskStatus::Running);
+    assert_eq!(stored.record.tokens, None, "progress is not written (R1.4)");
+
+    set_filters(&mut panels, &[TaskFilter::Finished]);
+    assert!(
+        find_row(&mut panels, &session, "live").is_none(),
+        "Active off hides it"
+    );
+    set_filters(&mut panels, &[TaskFilter::Active]);
+    assert!(find_row(&mut panels, &session, "live").is_some());
+}
+
+// R4.3 — the domain fails to open: Zeo starts, the panel says why, live rows still show.
+#[gpui::test]
+async fn panel_list_unavailable_history_shows_a_message_and_still_lists_live_rows(
+    cx: &mut TestAppContext,
+) {
+    let db = init_base(cx);
+    drop_history_table(&db).await;
+    let mut panels = open_panels(cx).await;
+
+    let message = panels
+        .tasks_panel
+        .update(&mut panels.cx, |panel, cx| panel.status_message(cx))
+        .expect("an unavailable history is announced in the panel");
+    assert!(
+        message.contains("Task history is unavailable"),
+        "got {message:?}"
+    );
+
+    let thread = open_live_thread(&mut panels, "Live thread");
+    let session = session_of(&thread, &panels.cx).to_string();
+    send_tasks(
+        &thread,
+        vec![wire_task("live", "running", 10_000, None)],
+        &mut panels.cx,
+    );
+    assert!(
+        find_row(&mut panels, &session, "live").is_some(),
+        "the card's live rows are still listed"
+    );
+}
diff --git a/crates/agent_tasks_panel/src/panel/multi_window_tests.rs b/crates/agent_tasks_panel/src/panel/multi_window_tests.rs
new file mode 100644
index 0000000..3cac469
--- /dev/null
+++ b/crates/agent_tasks_panel/src/panel/multi_window_tests.rs
@@ -0,0 +1,240 @@
+// Story 014, task 4.1 review — the cards the panel reads (R2.3) and how it reaches them.
+// Module path: `panel::multi_window`.
+//
+// The panel lists every task of every window, so a running row reads its live values from
+// whichever registered card holds it; and the card is attached whenever the agent panel
+// appears, because Zeo loads the agent panel alongside this one rather than before it.
+
+use std::path::Path;
+
+use acp_thread::AgentTaskStatus;
+use agent_ui::AgentPanel;
+use fs::FakeFs;
+use gpui::{AppContext as _, Entity, TestAppContext, VisualTestContext};
+use project::Project;
+use serde_json::json;
+use workspace::{MultiWorkspace, Workspace};
+
+use crate::test_helpers::*;
+use crate::{AgentTasksPanel, PanelRow, TaskHistoryStore};
+
+/// A new window holding a workspace over `root`, without any panel.
+async fn open_window(
+    cx: &mut TestAppContext,
+    root: &str,
+) -> (Entity<Workspace>, VisualTestContext) {
+    let fs = FakeFs::new(cx.executor());
+    cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+    fs.insert_tree(root, json!({ "file.txt": "" })).await;
+    let project = Project::test(fs.clone(), [Path::new(root)], cx).await;
+    let multi_workspace =
+        cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let workspace = multi_workspace
+        .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
+        .unwrap();
+    let cx = VisualTestContext::from_window(multi_workspace.into(), cx);
+    cx.simulate_resize(gpui::size(gpui::px(1400.), gpui::px(900.)));
+    (workspace, cx)
+}
+
+fn add_agent_panel(
+    workspace: &Entity<Workspace>,
+    cx: &mut VisualTestContext,
+) -> Entity<AgentPanel> {
+    let agent_panel = workspace.update_in(cx, |workspace, window, cx| {
+        let agent_panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
+        workspace.add_panel(agent_panel.clone(), window, cx);
+        workspace.focus_panel::<AgentPanel>(window, cx);
+        agent_panel
+    });
+    cx.run_until_parked();
+    agent_panel
+}
+
+async fn add_tasks_panel(
+    workspace: &Entity<Workspace>,
+    cx: &mut VisualTestContext,
+) -> Entity<AgentTasksPanel> {
+    let weak = workspace.downgrade();
+    let loading = cx.update(|window, cx| AgentTasksPanel::load(weak, window.to_async(cx)));
+    cx.run_until_parked();
+    let tasks_panel = loading.await.expect("the Agent Tasks panel loads");
+    workspace.update_in(cx, |workspace, window, cx| {
+        workspace.add_panel(tasks_panel.clone(), window, cx);
+    });
+    cx.run_until_parked();
+    tasks_panel
+}
+
+fn row_of(
+    panel: &Entity<AgentTasksPanel>,
+    session: &str,
+    task: &str,
+    cx: &mut VisualTestContext,
+) -> Option<PanelRow> {
+    panel
+        .update(cx, |panel, cx| panel.groups(cx))
+        .into_iter()
+        .flat_map(|group| group.rows)
+        .find(|row| {
+            row.entry.record.session_id.to_string() == session
+                && row.entry.record.task_id.as_ref() == task
+        })
+}
+
+fn card_count(cx: &mut VisualTestContext) -> usize {
+    cx.update(|_, cx| TaskHistoryStore::global(cx).read(cx).cards().count())
+}
+
+// R2.3 across windows: a task running in window B shows live in window A's panel, Stop is
+// offered there, and once B's card is released nothing reads it any more.
+#[gpui::test]
+async fn panel_reads_live_values_from_the_card_of_another_window(cx: &mut TestAppContext) {
+    init_base(cx);
+    let mut panels = open_panels(cx).await;
+    let (workspace_b, mut cx_b) = open_window(cx, "/other").await;
+    let agent_panel_b = add_agent_panel(&workspace_b, &mut cx_b);
+    let tasks_panel_b = add_tasks_panel(&workspace_b, &mut cx_b).await;
+    assert_eq!(
+        card_count(&mut panels.cx),
+        2,
+        "one card per window is registered"
+    );
+
+    let thread_b = open_thread_on(&agent_panel_b, StopConnection::new(), "Thread B", &mut cx_b);
+    let session_b = session_of(&thread_b, &cx_b).to_string();
+    send_tasks(
+        &thread_b,
+        vec![wire_task("live", "running", 10_000, Some((700, 5)))],
+        &mut cx_b,
+    );
+    panels.cx.run_until_parked();
+
+    let row = find_row(&mut panels, &session_b, "live").expect("window A lists B's task");
+    assert_eq!(row.entry.record.status, AgentTaskStatus::Running);
+    assert_eq!(
+        row.entry.record.tokens,
+        Some(700),
+        "B's live tokens, read in A"
+    );
+    assert_eq!(row.entry.record.tool_uses, Some(5));
+    assert!(
+        row.can_stop,
+        "B's card holds the task live, so A offers Stop"
+    );
+
+    send_tasks(
+        &thread_b,
+        vec![wire_task("live", "running", 10_000, Some((900, 6)))],
+        &mut cx_b,
+    );
+    panels.cx.run_until_parked();
+    let row = find_row(&mut panels, &session_b, "live").unwrap();
+    assert_eq!(row.entry.record.tokens, Some(900), "A follows B's progress");
+
+    // Release B's card: close its window and drop every handle that keeps its panels alive.
+    let card_b = agent_panel_b.read_with(&cx_b, |panel, _| panel.tasks_card().downgrade());
+    drop((thread_b, agent_panel_b, tasks_panel_b, workspace_b));
+    cx_b.update(|window, _| window.remove_window());
+    cx_b.run_until_parked();
+    panels.cx.run_until_parked();
+    assert!(card_b.upgrade().is_none(), "B's card is released");
+    assert_eq!(
+        card_count(&mut panels.cx),
+        1,
+        "the released card left the store"
+    );
+
+    let row = find_row(&mut panels, &session_b, "live").expect("the record stays listed");
+    assert!(
+        !row.can_stop,
+        "no card holds the task any more: nothing to stop"
+    );
+    assert_eq!(
+        row.entry.record.tokens, None,
+        "no live value from a released card"
+    );
+}
+
+// Zeo loads the agent panel alongside this one, so the card usually arrives after `load`:
+// it is registered when the agent panel is added, and again when the agent panel is replaced.
+#[gpui::test]
+async fn panel_attaches_the_card_of_an_agent_panel_added_after_it(cx: &mut TestAppContext) {
+    init_base(cx);
+    cx.update(|cx| crate::init(cx));
+    let (workspace, mut cx) = open_window(cx, "/project").await;
+    let tasks_panel = add_tasks_panel(&workspace, &mut cx).await;
+    assert_eq!(card_count(&mut cx), 0, "no agent panel yet, so no card");
+
+    let agent_panel = add_agent_panel(&workspace, &mut cx);
+    assert_eq!(
+        card_count(&mut cx),
+        1,
+        "the late agent panel's card is registered"
+    );
+    let thread = open_thread_on(&agent_panel, StopConnection::new(), "Late thread", &mut cx);
+    let session = session_of(&thread, &cx).to_string();
+    send_tasks(
+        &thread,
+        vec![wire_task("late", "running", 10_000, Some((300, 2)))],
+        &mut cx,
+    );
+
+    let recorded = cx.update(|_, cx| {
+        TaskHistoryStore::global(cx)
+            .read(cx)
+            .records()
+            .iter()
+            .any(|entry| {
+                entry.record.session_id.to_string() == session
+                    && entry.record.task_id.as_ref() == "late"
+            })
+    });
+    assert!(
+        recorded,
+        "the store records the task the late card reported"
+    );
+    let row = row_of(&tasks_panel, &session, "late", &mut cx).expect("the task is listed");
+    assert_eq!(
+        row.entry.record.tokens,
+        Some(300),
+        "read live from the late card"
+    );
+    assert!(row.can_stop);
+
+    // Replace the agent panel, as turning AI off and on again does.
+    let old_card = agent_panel.read_with(&cx, |panel, _| panel.tasks_card().downgrade());
+    workspace.update_in(&mut cx, |workspace, window, cx| {
+        workspace.remove_panel(&agent_panel, window, cx);
+    });
+    drop((thread, agent_panel));
+    cx.run_until_parked();
+    let new_agent_panel = add_agent_panel(&workspace, &mut cx);
+    let new_card = new_agent_panel.read_with(&cx, |panel, _| panel.tasks_card().clone());
+    let registered = cx.update(|_, cx| {
+        TaskHistoryStore::global(cx)
+            .read(cx)
+            .cards()
+            .any(|card| card == new_card)
+    });
+    assert!(registered, "the replacing agent panel's card is registered");
+    assert!(
+        old_card.upgrade().is_none(),
+        "the replaced card is released"
+    );
+    assert_eq!(
+        card_count(&mut cx),
+        1,
+        "only the live card stays registered"
+    );
+
+    emit(
+        &new_card,
+        started(&record("replaced", "t1", AgentTaskStatus::Running, 20_000)),
+        &mut cx,
+    );
+    assert!(
+        row_of(&tasks_panel, "replaced", "t1", &mut cx).is_some(),
+        "activity on the new card reaches the store and the panel"
+    );
+}
diff --git a/crates/agent_tasks_panel/src/panel/persistence_tests.rs b/crates/agent_tasks_panel/src/panel/persistence_tests.rs
new file mode 100644
index 0000000..9e2149a
--- /dev/null
+++ b/crates/agent_tasks_panel/src/panel/persistence_tests.rs
@@ -0,0 +1,82 @@
+// Story 014, task 4.1 review — the dock the panel sits in survives a reload (R2.1).
+// Module path: `panel::persistence`.
+
+use gpui::{Entity, VisualTestContext};
+use workspace::dock::{DockPosition, Panel};
+
+use crate::AgentTasksPanel;
+use crate::test_helpers::*;
+
+async fn reload(panels: &mut Panels) -> Entity<AgentTasksPanel> {
+    let weak = panels.workspace.downgrade();
+    let loading = panels
+        .cx
+        .update(|window, cx| AgentTasksPanel::load(weak, window.to_async(cx)));
+    panels.cx.run_until_parked();
+    loading.await.expect("the Agent Tasks panel loads again")
+}
+
+fn position_of(panel: &Entity<AgentTasksPanel>, cx: &mut VisualTestContext) -> DockPosition {
+    panel.update_in(cx, |panel, window, cx| panel.position(window, cx))
+}
+
+fn move_to(panels: &mut Panels, position: DockPosition) {
+    panels
+        .tasks_panel
+        .update_in(&mut panels.cx, |panel, window, cx| {
+            panel.set_position(position, window, cx)
+        });
+    panels.cx.run_until_parked();
+}
+
+#[gpui::test]
+async fn panel_dock_position_moves_the_panel_and_survives_a_reload(cx: &mut gpui::TestAppContext) {
+    init_base(cx);
+    let mut panels = open_panels(cx).await;
+    assert_eq!(
+        position_of(&panels.tasks_panel, &mut panels.cx),
+        DockPosition::Left
+    );
+
+    move_to(&mut panels, DockPosition::Right);
+    let docked_right = panels.workspace.read_with(&panels.cx, |workspace, cx| {
+        workspace
+            .right_dock()
+            .read(cx)
+            .panel::<AgentTasksPanel>()
+            .is_some()
+    });
+    assert!(
+        docked_right,
+        "the dock moves the panel when its position changes"
+    );
+
+    let reloaded = reload(&mut panels).await;
+    assert_eq!(
+        position_of(&reloaded, &mut panels.cx),
+        DockPosition::Right,
+        "a fresh load docks where the panel was left"
+    );
+
+    move_to(&mut panels, DockPosition::Bottom);
+    let reloaded = reload(&mut panels).await;
+    assert_eq!(position_of(&reloaded, &mut panels.cx), DockPosition::Bottom);
+}
+
+#[gpui::test]
+async fn panel_dock_position_falls_back_to_left_on_an_unknown_saved_value(
+    cx: &mut gpui::TestAppContext,
+) {
+    init_base(cx);
+    let mut panels = open_panels(cx).await;
+    let kvp = panels.cx.update(|_, cx| db::kvp::KeyValueStore::global(cx));
+    kvp.write_kvp(
+        <AgentTasksPanel as Panel>::panel_key().to_string(),
+        "upside-down".to_string(),
+    )
+    .await
+    .unwrap();
+
+    let reloaded = reload(&mut panels).await;
+    assert_eq!(position_of(&reloaded, &mut panels.cx), DockPosition::Left);
+}
diff --git a/crates/agent_tasks_panel/src/store.rs b/crates/agent_tasks_panel/src/store.rs
new file mode 100644
index 0000000..2dd96cd
--- /dev/null
+++ b/crates/agent_tasks_panel/src/store.rs
@@ -0,0 +1,470 @@
+//! `TaskHistoryStore`: the agent task history in memory, and the single writer of its domain.
+//!
+//! The store is fed only by the `TasksCard`s registered with it (one per workspace's agent
+//! panel): their `TaskActivity` events, and their `ThreadTitleChanged` events, which move every
+//! record of a thread to its newest title. Every change updates memory first and then the
+//! database, so a failed write leaves this session's view intact (R1.6).
+//!
+//! Startup order matters (R1.5): no task outlives the agent process that ran it, so every row
+//! still `running` is swept to `interrupted` before anything else touches the domain. Activity
+//! heard while that sweep and the first load are in flight is held back and applied after
+//! them — otherwise a task started in the first frame would be swept, or overwritten by the
+//! load.
+
+use std::{cmp, sync::atomic::Ordering};
+
+use acp_thread::{AgentTask, AgentTaskId};
+use agent_client_protocol::v2::schema as acp_v2;
+use agent_ui::{TaskActivity, TaskRecord, TasksCard, ThreadTitleChanged};
+use anyhow::Result;
+use collections::HashMap;
+use futures::{StreamExt as _, channel::mpsc};
+use gpui::{
+    App, AppContext as _, Context, Entity, EntityId, Global, SharedString, Subscription, Task,
+    WeakEntity,
+};
+
+use crate::{AgentTaskHistoryDb, HistoryEntry};
+
+/// Whether the history domain can be read and written this session (R4.3).
+#[derive(Clone, Debug, PartialEq)]
+pub enum Availability {
+    Available,
+    /// The domain failed to open; carries the cause for the panel to show.
+    Unavailable(SharedString),
+}
+
+struct GlobalTaskHistoryStore(Entity<TaskHistoryStore>);
+
+impl Global for GlobalTaskHistoryStore {}
+
+pub struct TaskHistoryStore {
+    /// Every record, newest first (the order `load_all` returns).
+    records: Vec<HistoryEntry>,
+    availability: Availability,
+    /// `Some` until the startup sweep and load finish: what was heard meanwhile, in order.
+    pending: Option<Vec<Heard>>,
+    /// Every registered card — one per workspace's agent panel, across every window — so
+    /// registering a card twice records once, and live values are read from all of them.
+    cards: HashMap<EntityId, RegisteredCard>,
+    writes: mpsc::UnboundedSender<HistoryWrite>,
+    /// Runs the startup sweep and load, then every write in the order it was queued.
+    _writer: Task<()>,
+}
+
+/// A card the store listens to. Weak: the agent panel owns its card, and a released card
+/// leaves the map through `observe_release`.
+struct RegisteredCard {
+    card: WeakEntity<TasksCard>,
+    _activity: Subscription,
+    _titles: Subscription,
+    _changes: Subscription,
+}
+
+/// What a card tells the store, in the order the card told it.
+enum Heard {
+    Activity(TaskActivity),
+    Title(ThreadTitleChanged),
+}
+
+/// A task's key in the history: the pair, since a task id is unique only within its session.
+pub type TaskKey = (acp_v2::SessionId, AgentTaskId);
+
+/// A database write, queued so that writes reach the domain in the order activity arrived:
+/// an `update_ended` that overtook its `insert_started` would update nothing, and an archive
+/// that overtook the `insert_started` of its task would archive nothing.
+enum HistoryWrite {
+    Started(TaskRecord),
+    Ended(TaskRecord),
+    Archived { keys: Vec<TaskKey>, archived: bool },
+    Titled(ThreadTitleChanged),
+}
+
+impl HistoryWrite {
+    async fn run(self, db: &AgentTaskHistoryDb) {
+        let result = match &self {
+            Self::Started(record) => db.insert_started(record).await,
+            Self::Ended(record) => db.update_ended(record).await,
+            Self::Archived { keys, archived } => db.set_archived(keys, *archived).await,
+            Self::Titled(change) => {
+                db.update_thread_title(&change.session_id, &change.title)
+                    .await
+            }
+        };
+        if let Err(error) = result {
+            log::error!(
+                "agent task history: failed to record {}; kept in memory only: {error:#}",
+                self.describe(),
+            );
+        }
+    }
+
+    /// What the write records, naming every session and task id it touches.
+    fn describe(&self) -> String {
+        match self {
+            Self::Started(record) => format!(
+                "the start of task {} in session {}",
+                record.task_id, record.session_id
+            ),
+            Self::Ended(record) => format!(
+                "the end of task {} in session {}",
+                record.task_id, record.session_id
+            ),
+            Self::Archived { keys, archived } => {
+                let action = if *archived {
+                    "archiving"
+                } else {
+                    "unarchiving"
+                };
+                let tasks = keys
+                    .iter()
+                    .map(|(session_id, task_id)| format!("task {task_id} in session {session_id}"))
+                    .collect::<Vec<_>>()
+                    .join(", ");
+                format!("the {action} of {tasks}")
+            }
+            Self::Titled(change) => format!("the new title of session {}", change.session_id),
+        }
+    }
+}
+
+impl TaskHistoryStore {
+    /// Creates the global store over the application database (the crate's `init`).
+    pub fn init(cx: &mut App) {
+        if cx.has_global::<GlobalTaskHistoryStore>() {
+            return;
+        }
+        let db = AgentTaskHistoryDb::global(cx);
+        let store = cx.new(|cx| Self::new(db, cx));
+        cx.set_global(GlobalTaskHistoryStore(store));
+    }
+
+    pub fn try_global(cx: &App) -> Option<Entity<Self>> {
+        cx.try_global::<GlobalTaskHistoryStore>()
+            .map(|store| store.0.clone())
+    }
+
+    /// The global store. Panics when `init` has not run, like every GPUI global.
+    pub fn global(cx: &App) -> Entity<Self> {
+        cx.global::<GlobalTaskHistoryStore>().0.clone()
+    }
+
+    /// A store over `db`: sweeps running rows to `interrupted`, then loads every record.
+    ///
+    /// `static_connection!` never returns an open error: when the database file cannot be
+    /// opened, `db::open_db` falls back silently to an in-memory database and raises
+    /// `db::ALL_FILE_DB_FAILED`. That flag is what marks the history unavailable here —
+    /// `persistent()` is `false` for test and `ZED_STATELESS` databases too.
+    pub fn new(db: AgentTaskHistoryDb, cx: &mut Context<Self>) -> Self {
+        let on_fallback = db::ALL_FILE_DB_FAILED.load(Ordering::Acquire);
+        Self::with_fallback_flag(db, on_fallback, cx)
+    }
+
+    /// `new`, with the fallback signal passed in. The flag is process-wide, so tests inject it
+    /// here rather than raising it for every test running in parallel.
+    pub(crate) fn with_fallback_flag(
+        db: AgentTaskHistoryDb,
+        on_fallback: bool,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let (writes, mut queue) = mpsc::unbounded::<HistoryWrite>();
+        let writer = cx.spawn(async move |this, cx| {
+            let loaded = if on_fallback {
+                Err(SharedString::from(
+                    "Zeo could not open its database file and runs on an in-memory one, \
+                     so task history is not kept this session",
+                ))
+            } else {
+                startup(&db)
+                    .await
+                    .map_err(|error| format!("{error:#}").into())
+            };
+            if this
+                .update(cx, |store, cx| store.finish_loading(loaded, cx))
+                .is_err()
+            {
+                return;
+            }
+            while let Some(write) = queue.next().await {
+                write.run(&db).await;
+            }
+        });
+        Self {
+            records: Vec::new(),
+            availability: Availability::Available,
+            pending: Some(Vec::new()),
+            cards: HashMap::default(),
+            writes,
+            _writer: writer,
+        }
+    }
+
+    /// Records the activity of one workspace's `TasksCard`. A card already registered is left
+    /// as it is, so a second registration never records a task twice.
+    pub fn register_card(&mut self, card: &Entity<TasksCard>, cx: &mut Context<Self>) {
+        let card_id = card.entity_id();
+        if self.cards.contains_key(&card_id) {
+            return;
+        }
+        let activity = cx.subscribe(card, |store, _card, activity: &TaskActivity, cx| {
+            store.hear(Heard::Activity(activity.clone()), cx);
+        });
+        let titles = cx.subscribe(card, |store, _card, change: &ThreadTitleChanged, cx| {
+            store.hear(Heard::Title(change.clone()), cx);
+        });
+        // Progress changes no record, but the panels of every window show it live (R2.3), so
+        // they repaint on this store's notifications for any card's change.
+        let changes = cx.observe(card, |_, _, cx| cx.notify());
+        cx.observe_release(card, move |store, _card, cx| {
+            store.cards.remove(&card_id);
+            cx.notify();
+        })
+        .detach();
+        self.cards.insert(
+            card_id,
+            RegisteredCard {
+                card: card.downgrade(),
+                _activity: activity,
+                _titles: titles,
+                _changes: changes,
+            },
+        );
+        // The card announced the titles of the threads it already held before this
+        // registration; a stored row may still carry an older one.
+        let titles = card.read(cx).thread_titles(cx);
+        for change in titles {
+            self.hear(Heard::Title(change), cx);
+        }
+        cx.notify();
+    }
+
+    /// Every registered card still alive.
+    pub fn cards(&self) -> impl Iterator<Item = Entity<TasksCard>> + '_ {
+        self.cards
+            .values()
+            .filter_map(|registered| registered.card.upgrade())
+    }
+
+    /// The running task `(session_id, task_id)` as the card holding it reports it, with that
+    /// card — the one a Stop must go through. `None` when no registered card holds it live.
+    pub fn live_task(
+        &self,
+        session_id: &acp_v2::SessionId,
+        task_id: &AgentTaskId,
+        cx: &App,
+    ) -> Option<(Entity<TasksCard>, AgentTask)> {
+        self.cards().find_map(|card| {
+            let task = card.read(cx).live_task(session_id, task_id, cx)?;
+            Some((card, task))
+        })
+    }
+
+    /// Every record, archived or not, newest first.
+    pub fn records(&self) -> &[HistoryEntry] {
+        &self.records
+    }
+
+    pub fn availability(&self) -> &Availability {
+        &self.availability
+    }
+
+    /// Archives `keys` (R3.1): hidden from every filter but Archived, the record kept.
+    pub fn archive(&mut self, keys: &[TaskKey], cx: &mut Context<Self>) {
+        self.set_archived(keys.to_vec(), true, cx);
+    }
+
+    /// Returns `keys` to the filters they matched before (R3.3): only the flag changes.
+    pub fn unarchive(&mut self, keys: &[TaskKey], cx: &mut Context<Self>) {
+        self.set_archived(keys.to_vec(), false, cx);
+    }
+
+    /// Archives every finished task of `session_id`, leaving its running ones unarchived
+    /// (R3.2): a running task is never archived (R3.4).
+    pub fn archive_group(&mut self, session_id: &acp_v2::SessionId, cx: &mut Context<Self>) {
+        let keys = self
+            .records
+            .iter()
+            .filter(|entry| {
+                entry.record.session_id == *session_id && !entry.record.status.is_running()
+            })
+            .map(|entry| {
+                (
+                    entry.record.session_id.clone(),
+                    entry.record.task_id.clone(),
+                )
+            })
+            .collect();
+        self.set_archived(keys, true, cx);
+    }
+
+    fn finish_loading(
+        &mut self,
+        loaded: Result<Vec<HistoryEntry>, SharedString>,
+        cx: &mut Context<Self>,
+    ) {
+        match loaded {
+            Ok(records) => self.records = records,
+            Err(cause) => {
+                log::error!("agent task history is unavailable this session: {cause}");
+                self.availability = Availability::Unavailable(cause);
+            }
+        }
+        for heard in self.pending.take().unwrap_or_default() {
+            self.apply(heard);
+        }
+        cx.notify();
+    }
+
+    fn hear(&mut self, heard: Heard, cx: &mut Context<Self>) {
+        if let Some(pending) = self.pending.as_mut() {
+            pending.push(heard);
+            return;
+        }
+        self.apply(heard);
+        cx.notify();
+    }
+
+    fn apply(&mut self, heard: Heard) {
+        match heard {
+            Heard::Activity(TaskActivity::Started(record)) => self.record_started(record),
+            Heard::Activity(TaskActivity::Ended(record)) => self.record_ended(record),
+            Heard::Title(change) => self.record_title(change),
+        }
+    }
+
+    /// A task seen for the first time is recorded with whatever it carries — its outcome too,
+    /// when first seen ended (R1.1, R1.8). A key already recorded is left alone: a repeated
+    /// snapshot (R1.3), or a re-opened session replaying `Started`, never rewrites a record nor
+    /// brings an ended or interrupted task back to running.
+    fn record_started(&mut self, mut record: TaskRecord) {
+        if self.position(&record.session_id, &record.task_id).is_some() {
+            return;
+        }
+        if record.status.is_running() {
+            // A running task's usage is progress, which is never stored (R1.4): the first
+            // snapshot that lists a task may already report some, and the panel reads it live
+            // from the card while the task runs.
+            record.tokens = None;
+            record.tool_uses = None;
+        }
+        self.insert_entry(HistoryEntry {
+            record: record.clone(),
+            archived: false,
+        });
+        self.queue_write(HistoryWrite::Started(record));
+    }
+
+    /// Writes a task's outcome (R1.2). Repeated endings are accepted — a resumed task ends
+    /// again — and each overwrites the outcome, as `update_ended` does in the database.
+    fn record_ended(&mut self, record: TaskRecord) {
+        let Some(index) = self.position(&record.session_id, &record.task_id) else {
+            // `Started` always precedes `Ended`; should one ever be missing, the ending is
+            // still recorded, in one row carrying its outcome.
+            self.record_started(record);
+            return;
+        };
+        let stored = &mut self.records[index].record;
+        stored.status = record.status;
+        stored.ended_at_ms = record.ended_at_ms;
+        stored.tokens = record.tokens;
+        stored.tool_uses = record.tool_uses;
+        self.queue_write(HistoryWrite::Ended(record));
+    }
+
+    /// Moves every record of the session to its thread's newest title. Nothing is written when
+    /// every record already carries it: cards announce a title again whenever they hold its
+    /// thread again, and a session with no record yet has nothing to rename — its first
+    /// record will carry the title.
+    fn record_title(&mut self, change: ThreadTitleChanged) {
+        let mut renamed = false;
+        for entry in &mut self.records {
+            let record = &mut entry.record;
+            if record.session_id == change.session_id
+                && record.thread_title.as_ref() != Some(&change.title)
+            {
+                record.thread_title = Some(change.title.clone());
+                renamed = true;
+            }
+        }
+        if renamed {
+            self.queue_write(HistoryWrite::Titled(change));
+        }
+    }
+
+    /// Sets the flag in memory, then queues one write for the whole batch. Memory keeps the
+    /// user's choice even when that write fails, so this session still shows it (R1.6).
+    fn set_archived(&mut self, keys: Vec<TaskKey>, archived: bool, cx: &mut Context<Self>) {
+        if keys.is_empty() {
+            return;
+        }
+        for (session_id, task_id) in &keys {
+            if let Some(index) = self.position(session_id, task_id) {
+                self.records[index].archived = archived;
+            }
+        }
+        self.queue_write(HistoryWrite::Archived { keys, archived });
+        cx.notify();
+    }
+
+    fn position(&self, session_id: &acp_v2::SessionId, task_id: &AgentTaskId) -> Option<usize> {
+        self.records.iter().position(|entry| {
+            entry.record.session_id == *session_id && entry.record.task_id == *task_id
+        })
+    }
+
+    /// Inserts keeping `load_all`'s order: newest first, then session and task id.
+    fn insert_entry(&mut self, entry: HistoryEntry) {
+        let index = self.records.partition_point(|existing| {
+            newest_first(&existing.record, &entry.record) == cmp::Ordering::Less
+        });
+        self.records.insert(index, entry);
+    }
+
+    /// Queues a write unless the domain is unavailable: there is then no database to keep the
+    /// record in (or only the in-memory fallback), and memory already holds it.
+    fn queue_write(&self, write: HistoryWrite) {
+        if matches!(self.availability, Availability::Unavailable(_)) {
+            return;
+        }
+        if let Err(error) = self.writes.unbounded_send(write) {
+            log::error!(
+                "agent task history: the writer has stopped; {} is kept in memory only",
+                error.into_inner().describe(),
+            );
+        }
+    }
+}
+
+/// The startup sweep (R1.5), then the first load.
+async fn startup(db: &AgentTaskHistoryDb) -> Result<Vec<HistoryEntry>> {
+    db.mark_running_interrupted().await?;
+    db.load_all().await
+}
+
+/// `load_all`'s order: `started_at DESC, session_id, task_id`.
+fn newest_first(a: &TaskRecord, b: &TaskRecord) -> cmp::Ordering {
+    b.started_at_ms
+        .cmp(&a.started_at_ms)
+        .then_with(|| a.session_id.0.cmp(&b.session_id.0))
+        .then_with(|| a.task_id.cmp(&b.task_id))
+}
+
+#[cfg(test)]
+#[path = "store/lifecycle_tests.rs"]
+mod lifecycle;
+
+#[cfg(test)]
+#[path = "store/fallback_tests.rs"]
+mod fallback;
+
+#[cfg(test)]
+#[path = "store/replay_tests.rs"]
+mod replay;
+
+#[cfg(test)]
+#[path = "store/archive_tests.rs"]
+mod archive;
+
+#[cfg(test)]
+#[path = "store/title_tests.rs"]
+mod title;
diff --git a/crates/agent_tasks_panel/src/store/archive_tests.rs b/crates/agent_tasks_panel/src/store/archive_tests.rs
new file mode 100644
index 0000000..3b19046
--- /dev/null
+++ b/crates/agent_tasks_panel/src/store/archive_tests.rs
@@ -0,0 +1,168 @@
+// Story 014, task 3.2 — archive, unarchive and archive_group (R3.1, R3.2, R3.3, R3.4, Q4).
+// Module path: `store::archive`. Hostile fixtures come before benign ones.
+
+use acp_thread::AgentTaskStatus;
+use gpui::{AppContext as _, Entity, TestAppContext, VisualTestContext};
+
+use crate::test_helpers::*;
+use crate::{AgentTaskHistoryDb, HistoryEntry, TaskHistoryStore};
+
+async fn seeded_store(
+    name: &'static str,
+    finished: &[(&str, &str, AgentTaskStatus)],
+    cx: &mut TestAppContext,
+) -> (AgentTaskHistoryDb, Entity<TaskHistoryStore>, CardRig) {
+    let mut rig = card_rig(cx).await;
+    let db = AgentTaskHistoryDb::open_test_db(name).await;
+    for (index, (session, task, status)) in finished.iter().enumerate() {
+        let at = 1_000 + index as i64 * 100;
+        db.insert_started(&record(session, task, AgentTaskStatus::Running, at))
+            .await
+            .unwrap();
+        db.update_ended(&record(session, task, *status, at))
+            .await
+            .unwrap();
+    }
+    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();
+    (db, store, rig)
+}
+
+fn entries(store: &Entity<TaskHistoryStore>, cx: &VisualTestContext) -> Vec<HistoryEntry> {
+    store.read_with(cx, |store, _| store.records().to_vec())
+}
+
+fn archived(entries: &[HistoryEntry], session: &str, task: &str) -> bool {
+    find(entries, session, task)
+        .unwrap_or_else(|| panic!("({session}, {task}) should be recorded"))
+        .archived
+}
+
+// R3.2 hostile: the group's running task is never archived; another session — even one
+// holding the same task id, or a session id that extends this one — is not touched.
+#[gpui::test]
+async fn store_archive_group_archives_finished_rows_but_never_a_running_one(
+    cx: &mut TestAppContext,
+) {
+    let (db, store, mut rig) = seeded_store(
+        "store_archive_group",
+        &[
+            ("s1", "done", AgentTaskStatus::Completed),
+            ("s1", "bad", AgentTaskStatus::Failed),
+            ("s1", "halt", AgentTaskStatus::Stopped),
+            ("s1", "lost", AgentTaskStatus::Interrupted),
+            ("s2", "done", AgentTaskStatus::Completed),
+            ("s10", "other", AgentTaskStatus::Completed),
+        ],
+        cx,
+    )
+    .await;
+    let card = rig.card.clone();
+    emit(
+        &card,
+        started(&record("s1", "run", AgentTaskStatus::Running, 9_000)),
+        &mut rig.cx,
+    );
+
+    store.update(&mut rig.cx, |store, cx| store.archive_group(&sid("s1"), cx));
+    rig.cx.run_until_parked();
+
+    for view in [entries(&store, &rig.cx), load(&db).await] {
+        for task in ["done", "bad", "halt", "lost"] {
+            assert!(
+                archived(&view, "s1", task),
+                "finished s1/{task} is archived"
+            );
+        }
+        assert!(
+            !archived(&view, "s1", "run"),
+            "the running task stays unarchived"
+        );
+        assert_eq!(
+            find(&view, "s1", "run").unwrap().record.status,
+            AgentTaskStatus::Running
+        );
+        assert!(
+            !archived(&view, "s2", "done"),
+            "same task id, other session: untouched"
+        );
+        assert!(
+            !archived(&view, "s10", "other"),
+            "a session id extending s1: untouched"
+        );
+    }
+}
+
+// R3.1, R3.3: archive keeps the record and its outcome; unarchive returns it as it was.
+#[gpui::test]
+async fn store_archive_and_unarchive_round_trip_one_task(cx: &mut TestAppContext) {
+    let (db, store, mut rig) = seeded_store(
+        "store_archive_round_trip",
+        &[
+            ("s1", "t1", AgentTaskStatus::Failed),
+            ("s2", "t1", AgentTaskStatus::Completed),
+        ],
+        cx,
+    )
+    .await;
+
+    store.update(&mut rig.cx, |store, cx| {
+        store.archive(&[key("s1", "t1")], cx)
+    });
+    rig.cx.run_until_parked();
+    for view in [entries(&store, &rig.cx), load(&db).await] {
+        assert_eq!(view.len(), 2, "archiving keeps the record");
+        assert!(archived(&view, "s1", "t1"));
+        assert!(
+            !archived(&view, "s2", "t1"),
+            "the pair is the key, not the task id"
+        );
+        assert_eq!(
+            find(&view, "s1", "t1").unwrap().record.status,
+            AgentTaskStatus::Failed
+        );
+    }
+
+    store.update(&mut rig.cx, |store, cx| {
+        store.unarchive(&[key("s1", "t1")], cx)
+    });
+    rig.cx.run_until_parked();
+    for view in [entries(&store, &rig.cx), load(&db).await] {
+        assert!(!archived(&view, "s1", "t1"));
+        assert_eq!(
+            find(&view, "s1", "t1").unwrap().record.status,
+            AgentTaskStatus::Failed,
+            "it returns with the outcome it had, so it matches the same filters"
+        );
+    }
+}
+
+// Q4: an archive whose write fails is kept in memory and logged with the ids.
+#[gpui::test]
+async fn store_archive_write_failure_keeps_memory_and_logs_ids(cx: &mut TestAppContext) {
+    let (db, store, mut rig) = seeded_store(
+        "store_archive_write_failure",
+        &[("session-a1", "task-a1", AgentTaskStatus::Completed)],
+        cx,
+    )
+    .await;
+    drop_history_table(&db).await;
+    capture_logs();
+
+    store.update(&mut rig.cx, |store, cx| {
+        store.archive(&[key("session-a1", "task-a1")], cx)
+    });
+    rig.cx.run_until_parked();
+
+    assert!(archived(&entries(&store, &rig.cx), "session-a1", "task-a1"));
+    let errors = error_lines();
+    assert!(
+        errors
+            .iter()
+            .any(|line| line.contains("session-a1") && line.contains("task-a1")),
+        "the failed archive is logged with both ids: {errors:?}"
+    );
+}
diff --git a/crates/agent_tasks_panel/src/store/fallback_tests.rs b/crates/agent_tasks_panel/src/store/fallback_tests.rs
new file mode 100644
index 0000000..dc523ec
--- /dev/null
+++ b/crates/agent_tasks_panel/src/store/fallback_tests.rs
@@ -0,0 +1,72 @@
+// Story 014, task 3.1 — R4.3's silent-fallback path (Q4). Module path: `store::fallback`.
+//
+// Task 2.1 found that `static_connection!` never returns an open error: when the database
+// file cannot be opened, `db::open_db` falls back to an in-memory database and raises the
+// process-wide `db::ALL_FILE_DB_FAILED`. Raising that flag here would turn every store test
+// running in parallel unavailable, so the signal is injected through
+// `TaskHistoryStore::with_fallback_flag`, the constructor `new` delegates to with the flag's
+// value. The panel's "history is unavailable" banner is task 4.1's (`panel::list`).
+
+use acp_thread::AgentTaskStatus;
+use gpui::AppContext as _;
+
+use crate::test_helpers::*;
+use crate::{AgentTaskHistoryDb, Availability, TaskHistoryStore};
+
+#[gpui::test]
+async fn store_on_the_fallback_database_is_unavailable_with_a_cause_and_an_error_log(
+    cx: &mut gpui::TestAppContext,
+) {
+    let mut rig = card_rig(cx).await;
+    // Stands in for the in-memory fallback; a row in it must neither be swept nor shown.
+    let db = AgentTaskHistoryDb::open_test_db("store_fallback").await;
+    db.insert_started(&record("s0", "old", AgentTaskStatus::Running, 500))
+        .await
+        .unwrap();
+    capture_logs();
+
+    let store = rig
+        .cx
+        .new(|cx| TaskHistoryStore::with_fallback_flag(db.clone(), true, 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();
+
+    let cause = store.read_with(&rig.cx, |store, _| match store.availability() {
+        Availability::Unavailable(cause) => Some(cause.clone()),
+        Availability::Available => None,
+    });
+    let cause = cause.expect("a store on the fallback database is Unavailable");
+    assert!(
+        !cause.is_empty(),
+        "the cause is carried for the panel to show"
+    );
+    let errors = error_lines();
+    assert!(
+        errors.iter().any(|line| line.contains(cause.as_ref())),
+        "the fallback is logged at error level with its cause: {errors:?}"
+    );
+    assert!(
+        store.read_with(&rig.cx, |store, _| store.records().is_empty()),
+        "nothing is loaded from the fallback"
+    );
+
+    emit(
+        &card,
+        started(&record("s1", "live", AgentTaskStatus::Running, 1_000)),
+        &mut rig.cx,
+    );
+    assert_eq!(
+        store.read_with(&rig.cx, |store, _| store.records().len()),
+        1,
+        "this session's tasks are still held in memory"
+    );
+    let stored = load(&db).await;
+    assert_eq!(stored.len(), 1, "nothing is written to the fallback");
+    assert_eq!(
+        find(&stored, "s0", "old").unwrap().record.status,
+        AgentTaskStatus::Running,
+        "the fallback is not swept"
+    );
+}
diff --git a/crates/agent_tasks_panel/src/store/lifecycle_tests.rs b/crates/agent_tasks_panel/src/store/lifecycle_tests.rs
new file mode 100644
index 0000000..3521644
--- /dev/null
+++ b/crates/agent_tasks_panel/src/store/lifecycle_tests.rs
@@ -0,0 +1,391 @@
+// Story 014, task 3.1 — TaskHistoryStore: startup sweep, card registration, writes, failure
+// paths (R1.1, R1.2, R1.5, R1.6, R4.3, Q4, Q11). Module path: `store::lifecycle`.
+//
+// The store is fed only through `register_card`: each test registers the real `TasksCard`
+// of a workspace's `AgentPanel` and emits `TaskActivity` on it. The card's own emission
+// from snapshots is proved in agent_ui (`tasks_card::activity`), and snapshot → panel end
+// to end in `panel::list`.
+
+use acp_thread::AgentTaskStatus;
+use gpui::{AppContext as _, Entity, VisualTestContext};
+
+use crate::test_helpers::*;
+use crate::{AgentTaskHistoryDb, Availability, HistoryEntry, TaskHistoryStore};
+
+/// 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 entries(store: &Entity<TaskHistoryStore>, cx: &VisualTestContext) -> Vec<HistoryEntry> {
+    store.read_with(cx, |store, _| store.records().to_vec())
+}
+
+fn is_available(store: &Entity<TaskHistoryStore>, cx: &VisualTestContext) -> bool {
+    store.read_with(cx, |store, _| {
+        matches!(store.availability(), Availability::Available)
+    })
+}
+
+// ── Startup (R1.5) ─────────────────────────────────────────────────────────
+
+#[gpui::test]
+async fn store_startup_sweeps_running_records_to_interrupted_and_loads_all(
+    cx: &mut gpui::TestAppContext,
+) {
+    let mut rig = card_rig(cx).await;
+    let db = AgentTaskHistoryDb::open_test_db("store_startup_sweep").await;
+    db.insert_started(&record(
+        "s1",
+        "left-running",
+        AgentTaskStatus::Running,
+        1_000,
+    ))
+    .await
+    .unwrap();
+    db.insert_started(&record("s1", "done", AgentTaskStatus::Completed, 2_000))
+        .await
+        .unwrap();
+    db.insert_started(&record(
+        "s2",
+        "left-running",
+        AgentTaskStatus::Running,
+        3_000,
+    ))
+    .await
+    .unwrap();
+
+    let store = registered_store(&db, &mut rig);
+
+    assert!(is_available(&store, &rig.cx));
+    let memory = entries(&store, &rig.cx);
+    assert_eq!(memory.len(), 3, "everything recorded is loaded");
+    for session in ["s1", "s2"] {
+        let swept = &find(&memory, session, "left-running").unwrap().record;
+        assert_eq!(
+            swept.status,
+            AgentTaskStatus::Interrupted,
+            "{session} in memory"
+        );
+        assert!(swept.ended_at_ms.is_some());
+    }
+    assert_eq!(
+        find(&memory, "s1", "done").unwrap().record.status,
+        AgentTaskStatus::Completed,
+        "a finished record is not swept"
+    );
+    let stored = load(&db).await;
+    assert_eq!(
+        find(&stored, "s2", "left-running").unwrap().record.status,
+        AgentTaskStatus::Interrupted,
+        "the sweep is written, not only shown"
+    );
+}
+
+// Q11 — a restart with a running record: a new store over the same database.
+#[gpui::test]
+async fn store_restart_reports_a_task_running_at_shutdown_as_interrupted(
+    cx: &mut gpui::TestAppContext,
+) {
+    let mut rig = card_rig(cx).await;
+    let db = AgentTaskHistoryDb::open_test_db("store_restart_running").await;
+    let first_run = registered_store(&db, &mut rig);
+    let card = rig.card.clone();
+    emit(
+        &card,
+        started(&record("s1", "build", AgentTaskStatus::Running, 1_000)),
+        &mut rig.cx,
+    );
+    emit(
+        &card,
+        started(&record("s1", "lint", AgentTaskStatus::Running, 1_100)),
+        &mut rig.cx,
+    );
+    emit(
+        &card,
+        ended(&record("s1", "lint", AgentTaskStatus::Completed, 1_100)),
+        &mut rig.cx,
+    );
+    drop(first_run);
+    rig.cx.run_until_parked();
+
+    let second_run = registered_store(&db, &mut rig);
+    let memory = entries(&second_run, &rig.cx);
+    assert_eq!(memory.len(), 2);
+    assert_eq!(
+        find(&memory, "s1", "build").unwrap().record.status,
+        AgentTaskStatus::Interrupted
+    );
+    assert_eq!(
+        find(&memory, "s1", "lint").unwrap().record.status,
+        AgentTaskStatus::Completed
+    );
+}
+
+// ── Registration and identity (hostile halves first) ───────────────────────
+
+// Registering the same card twice (a second workspace load, a reloaded panel) must not
+// double-record: one subscription per card.
+#[gpui::test]
+async fn store_registering_a_card_twice_records_each_task_once(cx: &mut gpui::TestAppContext) {
+    let mut rig = card_rig(cx).await;
+    let db = AgentTaskHistoryDb::open_test_db("store_register_twice").await;
+    let store = registered_store(&db, &mut rig);
+    let card = rig.card.clone();
+    store.update(&mut rig.cx, |store, cx| store.register_card(&card, cx));
+    rig.cx.run_until_parked();
+
+    emit(
+        &card,
+        started(&record("s1", "t1", AgentTaskStatus::Running, 1_000)),
+        &mut rig.cx,
+    );
+    assert_eq!(entries(&store, &rig.cx).len(), 1, "one row in memory");
+    assert_eq!(load(&db).await.len(), 1);
+}
+
+#[gpui::test]
+async fn store_keeps_two_tasks_of_one_session_and_one_task_id_of_two_sessions_apart(
+    cx: &mut gpui::TestAppContext,
+) {
+    let mut rig = card_rig(cx).await;
+    let db = AgentTaskHistoryDb::open_test_db("store_identity_apart").await;
+    let store = registered_store(&db, &mut rig);
+    let card = rig.card.clone();
+    emit(
+        &card,
+        started(&record("s1", "t1", AgentTaskStatus::Running, 1_000)),
+        &mut rig.cx,
+    );
+    emit(
+        &card,
+        started(&record("s1", "t2", AgentTaskStatus::Running, 1_100)),
+        &mut rig.cx,
+    );
+    emit(
+        &card,
+        started(&record("s2", "t1", AgentTaskStatus::Running, 1_200)),
+        &mut rig.cx,
+    );
+    // A third pair whose parts concatenate like ("s1", "t1").
+    emit(
+        &card,
+        started(&record("s", "1t1", AgentTaskStatus::Running, 1_300)),
+        &mut rig.cx,
+    );
+
+    assert_eq!(entries(&store, &rig.cx).len(), 4, "in memory");
+    assert_eq!(load(&db).await.len(), 4, "in the database");
+
+    emit(
+        &card,
+        ended(&record("s1", "t1", AgentTaskStatus::Completed, 1_000)),
+        &mut rig.cx,
+    );
+    let memory = entries(&store, &rig.cx);
+    assert_eq!(
+        find(&memory, "s1", "t1").unwrap().record.status,
+        AgentTaskStatus::Completed
+    );
+    assert_eq!(
+        find(&memory, "s2", "t1").unwrap().record.status,
+        AgentTaskStatus::Running
+    );
+    assert_eq!(
+        find(&memory, "s", "1t1").unwrap().record.status,
+        AgentTaskStatus::Running
+    );
+}
+
+#[gpui::test]
+async fn store_records_a_repeated_start_once_and_never_reopens_an_ended_task(
+    cx: &mut gpui::TestAppContext,
+) {
+    let mut rig = card_rig(cx).await;
+    let db = AgentTaskHistoryDb::open_test_db("store_repeat_once").await;
+    let store = registered_store(&db, &mut rig);
+    let card = rig.card.clone();
+    let first = record("s1", "t1", AgentTaskStatus::Running, 1_000);
+    let mut repeat = first.clone();
+    repeat.description = "A later snapshot".into();
+    repeat.tokens = Some(999);
+
+    emit(&card, started(&first), &mut rig.cx);
+    emit(&card, started(&repeat), &mut rig.cx);
+    let memory = entries(&store, &rig.cx);
+    assert_eq!(
+        memory.len(),
+        1,
+        "one task however many snapshots repeat it (R1.3)"
+    );
+    assert_eq!(memory[0].record.description.as_ref(), "Task t1");
+    assert_eq!(load(&db).await.len(), 1);
+
+    emit(
+        &card,
+        ended(&record("s1", "t1", AgentTaskStatus::Stopped, 1_000)),
+        &mut rig.cx,
+    );
+    emit(&card, started(&first), &mut rig.cx);
+    assert_eq!(
+        entries(&store, &rig.cx)[0].record.status,
+        AgentTaskStatus::Stopped,
+        "a replayed start does not reopen an ended task"
+    );
+    assert_eq!(load(&db).await[0].record.status, AgentTaskStatus::Stopped);
+}
+
+// Benign: record → end, in memory and in the database (R1.1, R1.2); and a task first seen
+// ended (Started carrying the outcome, then Ended) is one finished record (R1.8).
+#[gpui::test]
+async fn store_records_a_start_and_its_outcome(cx: &mut gpui::TestAppContext) {
+    let mut rig = card_rig(cx).await;
+    let db = AgentTaskHistoryDb::open_test_db("store_start_and_end").await;
+    let store = registered_store(&db, &mut rig);
+    let card = rig.card.clone();
+    let mut start = record("s1", "build", AgentTaskStatus::Running, 1_000);
+    start.thread_title = Some("Build thread".into());
+    start.background = true;
+    emit(&card, started(&start), &mut rig.cx);
+
+    let memory = entries(&store, &rig.cx);
+    assert_eq!(memory.len(), 1);
+    assert_eq!(memory[0].record.status, AgentTaskStatus::Running);
+    assert!(!memory[0].archived);
+    let stored = load(&db).await;
+    assert_eq!(stored.len(), 1);
+    assert_eq!(
+        stored[0].record.thread_title.as_ref().map(|t| t.as_ref()),
+        Some("Build thread")
+    );
+    assert!(stored[0].record.background);
+
+    let mut outcome = record("s1", "build", AgentTaskStatus::Failed, 1_000);
+    outcome.ended_at_ms = Some(5_000);
+    outcome.tokens = Some(2_048);
+    outcome.tool_uses = Some(12);
+    emit(&card, ended(&outcome), &mut rig.cx);
+
+    let in_memory = find(&entries(&store, &rig.cx), "s1", "build")
+        .unwrap()
+        .clone();
+    let in_db = find(&load(&db).await, "s1", "build").unwrap().clone();
+    for entry in [in_memory, in_db] {
+        assert_eq!(entry.record.status, AgentTaskStatus::Failed);
+        assert_eq!(entry.record.ended_at_ms, Some(5_000));
+        assert_eq!(entry.record.tokens, Some(2_048));
+        assert_eq!(entry.record.tool_uses, Some(12));
+    }
+
+    let quick = record("s1", "quick", AgentTaskStatus::Completed, 6_000);
+    emit(&card, started(&quick), &mut rig.cx);
+    emit(&card, ended(&quick), &mut rig.cx);
+    let in_db = load(&db).await;
+    let stored_quick = find(&in_db, "s1", "quick").expect("one record");
+    assert_eq!(stored_quick.record.status, AgentTaskStatus::Completed);
+    assert_eq!(stored_quick.record.ended_at_ms, Some(7_000));
+    assert_eq!(in_db.len(), 2);
+}
+
+// ── Failure paths (R1.6, R4.3, Q4) ─────────────────────────────────────────
+
+#[gpui::test]
+async fn store_write_failure_logs_the_ids_and_keeps_memory(cx: &mut gpui::TestAppContext) {
+    let mut rig = card_rig(cx).await;
+    let db = AgentTaskHistoryDb::open_test_db("store_write_failure").await;
+    let store = registered_store(&db, &mut rig);
+    let card = rig.card.clone();
+    assert!(is_available(&store, &rig.cx));
+    drop_history_table(&db).await;
+    capture_logs();
+
+    emit(
+        &card,
+        started(&record(
+            "session-w1",
+            "task-w1",
+            AgentTaskStatus::Running,
+            1_000,
+        )),
+        &mut rig.cx,
+    );
+    let memory = entries(&store, &rig.cx);
+    assert_eq!(memory.len(), 1, "the task stays visible for this session");
+    assert_eq!(memory[0].record.status, AgentTaskStatus::Running);
+    let errors = error_lines();
+    assert!(
+        errors
+            .iter()
+            .any(|line| line.contains("session-w1") && line.contains("task-w1")),
+        "the failed write is logged at error level with both ids: {errors:?}"
+    );
+
+    capture_logs();
+    emit(
+        &card,
+        ended(&record(
+            "session-w1",
+            "task-w1",
+            AgentTaskStatus::Completed,
+            1_000,
+        )),
+        &mut rig.cx,
+    );
+    assert_eq!(
+        entries(&store, &rig.cx)[0].record.status,
+        AgentTaskStatus::Completed,
+        "the outcome is kept in memory"
+    );
+    assert!(
+        error_lines()
+            .iter()
+            .any(|line| line.contains("session-w1") && line.contains("task-w1")),
+        "the failed update is logged with both ids"
+    );
+}
+
+// R4.3, open-failure path: the domain's first query fails. (The silent in-memory fallback
+// path depends on what task 2.1 records about `static_connection!`; see the story report.)
+#[gpui::test]
+async fn store_unavailable_domain_reports_the_cause_and_still_keeps_live_tasks(
+    cx: &mut gpui::TestAppContext,
+) {
+    let mut rig = card_rig(cx).await;
+    let db = AgentTaskHistoryDb::open_test_db("store_unavailable").await;
+    drop_history_table(&db).await;
+    capture_logs();
+
+    let store = registered_store(&db, &mut rig);
+
+    let cause = store.read_with(&rig.cx, |store, _| match store.availability() {
+        Availability::Unavailable(cause) => Some(cause.clone()),
+        Availability::Available => None,
+    });
+    let cause = cause.expect("a domain whose first query fails is Unavailable");
+    assert!(
+        !cause.is_empty(),
+        "the cause is carried for the panel to show"
+    );
+    assert!(entries(&store, &rig.cx).is_empty());
+    assert!(
+        !error_lines().is_empty(),
+        "the failed open is logged at error level with its cause"
+    );
+
+    let card = rig.card.clone();
+    emit(
+        &card,
+        started(&record("s1", "live", AgentTaskStatus::Running, 1_000)),
+        &mut rig.cx,
+    );
+    assert_eq!(
+        entries(&store, &rig.cx).len(),
+        1,
+        "with history unavailable, this session's tasks are still held in memory"
+    );
+}
diff --git a/crates/agent_tasks_panel/src/store/replay_tests.rs b/crates/agent_tasks_panel/src/store/replay_tests.rs
new file mode 100644
index 0000000..08e63f0
--- /dev/null
+++ b/crates/agent_tasks_panel/src/store/replay_tests.rs
@@ -0,0 +1,108 @@
+// Story 014, task 3.1 — activity the card replays or delivers early (R1.2, R1.3, R1.5).
+// Module path: `store::replay`.
+//
+// Found while building task 1.1: a resumed task gets a second `Ended` for its key, and a
+// re-opened session emits `Started` again for the tasks it still lists. And the startup sweep
+// must not catch a task the card announces before the sweep has finished.
+
+use acp_thread::AgentTaskStatus;
+use gpui::{AppContext as _, Entity, VisualTestContext};
+
+use crate::test_helpers::*;
+use crate::{AgentTaskHistoryDb, HistoryEntry, TaskHistoryStore};
+
+fn entries(store: &Entity<TaskHistoryStore>, cx: &VisualTestContext) -> Vec<HistoryEntry> {
+    store.read_with(cx, |store, _| store.records().to_vec())
+}
+
+#[gpui::test]
+async fn store_holds_activity_heard_during_the_startup_sweep_until_after_it(
+    cx: &mut gpui::TestAppContext,
+) {
+    let mut rig = card_rig(cx).await;
+    let db = AgentTaskHistoryDb::open_test_db("store_replay_early").await;
+    db.insert_started(&record("s0", "old", AgentTaskStatus::Running, 500))
+        .await
+        .unwrap();
+
+    // Registered and fed before the sweep and load have had a chance to run.
+    let store = rig.cx.new(|cx| TaskHistoryStore::new(db.clone(), cx));
+    let card = rig.card.clone();
+    store.update(&mut rig.cx, |store, cx| store.register_card(&card, cx));
+    card.update(&mut rig.cx, |_, cx| {
+        cx.emit(started(&record(
+            "s1",
+            "early",
+            AgentTaskStatus::Running,
+            1_000,
+        )))
+    });
+    rig.cx.run_until_parked();
+
+    for view in [entries(&store, &rig.cx), load(&db).await] {
+        assert_eq!(view.len(), 2);
+        assert_eq!(
+            find(&view, "s0", "old").unwrap().record.status,
+            AgentTaskStatus::Interrupted,
+            "the row left running is swept"
+        );
+        assert_eq!(
+            find(&view, "s1", "early").unwrap().record.status,
+            AgentTaskStatus::Running,
+            "a task started during the sweep is not"
+        );
+    }
+}
+
+#[gpui::test]
+async fn store_never_reopens_an_interrupted_task_and_keeps_the_latest_ending(
+    cx: &mut gpui::TestAppContext,
+) {
+    let mut rig = card_rig(cx).await;
+    let db = AgentTaskHistoryDb::open_test_db("store_replay_resumed").await;
+    db.insert_started(&record("s1", "swept", AgentTaskStatus::Running, 500))
+        .await
+        .unwrap();
+    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();
+
+    // The session is re-opened and lists the task as running again.
+    emit(
+        &card,
+        started(&record("s1", "swept", AgentTaskStatus::Running, 500)),
+        &mut rig.cx,
+    );
+    for view in [entries(&store, &rig.cx), load(&db).await] {
+        assert_eq!(
+            find(&view, "s1", "swept").unwrap().record.status,
+            AgentTaskStatus::Interrupted,
+            "a replayed start does not bring a swept task back to running"
+        );
+    }
+
+    // A resumed task ends twice; the second outcome is the one kept.
+    emit(
+        &card,
+        started(&record("s1", "resumed", AgentTaskStatus::Running, 1_000)),
+        &mut rig.cx,
+    );
+    emit(
+        &card,
+        ended(&record("s1", "resumed", AgentTaskStatus::Stopped, 1_000)),
+        &mut rig.cx,
+    );
+    let mut again = record("s1", "resumed", AgentTaskStatus::Completed, 1_000);
+    again.ended_at_ms = Some(9_000);
+    again.tokens = Some(4_096);
+    emit(&card, ended(&again), &mut rig.cx);
+    for view in [entries(&store, &rig.cx), load(&db).await] {
+        let resumed = &find(&view, "s1", "resumed").unwrap().record;
+        assert_eq!(resumed.status, AgentTaskStatus::Completed);
+        assert_eq!(resumed.ended_at_ms, Some(9_000));
+        assert_eq!(resumed.tokens, Some(4_096));
+        assert_eq!(view.len(), 2);
+    }
+}
diff --git a/crates/agent_tasks_panel/src/store/title_tests.rs b/crates/agent_tasks_panel/src/store/title_tests.rs
new file mode 100644
index 0000000..1ffb554
--- /dev/null
+++ b/crates/agent_tasks_panel/src/store/title_tests.rs
@@ -0,0 +1,314 @@
+// Story 014, task 6.1 fix — a thread's records follow its newest title (R1.1, R1.6).
+// Module path: `store::title`.
+//
+// Found by the GUI proof: a thread's first task is recorded under its provisional title (the
+// user's prompt), because the generated title arrives later. Its group was then titled with
+// the raw prompt once the card no longer showed the thread, and again after a restart. The
+// card now announces each held thread's title (`ThreadTitleChanged`), and the store moves
+// every record of the session to it, in memory and in the database.
+
+use acp_thread::{AcpThread, AgentTaskStatus};
+use agent_ui::{TaskRecord, ThreadTitleChanged};
+use gpui::{AppContext as _, Entity, SharedString, VisualTestContext};
+
+use super::GlobalTaskHistoryStore;
+use crate::test_helpers::*;
+use crate::{AgentTaskHistoryDb, AgentTasksPanel, HistoryEntry, TaskHistoryStore};
+
+const PROMPT: &str = "with the bash tool and run_in_background true, start one long sleep";
+const GENERATED: &str = "Background sleep command";
+
+fn entries(store: &Entity<TaskHistoryStore>, cx: &VisualTestContext) -> Vec<HistoryEntry> {
+    store.read_with(cx, |store, _| store.records().to_vec())
+}
+
+/// A store over `db`, loaded, then 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 titled(mut record: TaskRecord, title: &str) -> TaskRecord {
+    record.thread_title = Some(SharedString::from(title.to_string()));
+    record
+}
+
+fn retitle(session: &str, title: &str) -> ThreadTitleChanged {
+    ThreadTitleChanged {
+        session_id: sid(session),
+        title: SharedString::from(title.to_string()),
+    }
+}
+
+fn announce(rig: &mut CardRig, change: ThreadTitleChanged) {
+    rig.card.update(&mut rig.cx, |_, cx| cx.emit(change));
+    rig.cx.run_until_parked();
+}
+
+fn title_of(entries: &[HistoryEntry], session: &str, task: &str) -> Option<String> {
+    find(entries, session, task)
+        .expect("the task is recorded")
+        .record
+        .thread_title
+        .as_ref()
+        .map(|title| title.to_string())
+}
+
+fn generated() -> Option<String> {
+    Some(GENERATED.to_string())
+}
+
+// Hostile (wrong scope): the new title reaches every row of its session — the one recorded
+// under the prompt included — and no row of another session.
+#[gpui::test]
+async fn store_moves_every_record_of_a_session_to_its_new_title(cx: &mut gpui::TestAppContext) {
+    let mut rig = card_rig(cx).await;
+    let db = AgentTaskHistoryDb::open_test_db("store_title_moves").await;
+    let store = registered_store(&db, &mut rig);
+    let card = rig.card.clone();
+
+    let first = titled(record("s1", "a", AgentTaskStatus::Completed, 1_000), PROMPT);
+    emit(&card, started(&first), &mut rig.cx);
+    emit(&card, ended(&first), &mut rig.cx);
+    let other = record("s2", "x", AgentTaskStatus::Running, 1_500);
+    emit(&card, started(&other), &mut rig.cx);
+
+    announce(&mut rig, retitle("s1", GENERATED));
+    let later = titled(
+        record("s1", "b", AgentTaskStatus::Running, 2_000),
+        GENERATED,
+    );
+    emit(&card, started(&later), &mut rig.cx);
+
+    for view in [entries(&store, &rig.cx), load(&db).await] {
+        assert_eq!(view.len(), 3);
+        assert_eq!(
+            title_of(&view, "s1", "a"),
+            generated(),
+            "the row recorded under the prompt takes the generated title"
+        );
+        assert_eq!(title_of(&view, "s1", "b"), generated());
+        assert_eq!(
+            title_of(&view, "s2", "x"),
+            Some("Thread s2".to_string()),
+            "another session's rows are untouched"
+        );
+        let a = &find(&view, "s1", "a").unwrap().record;
+        assert_eq!(
+            (a.status, a.ended_at_ms, a.tokens),
+            (AgentTaskStatus::Completed, Some(2_000), Some(100)),
+            "a rename changes nothing but the title"
+        );
+    }
+
+    // A restart: a new store over the same database loads the renamed rows.
+    let reloaded = rig.cx.new(|cx| TaskHistoryStore::new(db.clone(), cx));
+    rig.cx.run_until_parked();
+    let view = entries(&reloaded, &rig.cx);
+    assert_eq!(title_of(&view, "s1", "a"), generated());
+    assert_eq!(title_of(&view, "s2", "x"), Some("Thread s2".to_string()));
+}
+
+// A title every row already carries writes nothing (a card announces again whenever it holds
+// a thread again); a rename whose write fails is logged with the session and kept in memory.
+#[gpui::test]
+async fn store_writes_a_title_only_when_it_changes_and_keeps_a_failed_rename(
+    cx: &mut gpui::TestAppContext,
+) {
+    let mut rig = card_rig(cx).await;
+    let db = AgentTaskHistoryDb::open_test_db("store_title_writes").await;
+    let store = registered_store(&db, &mut rig);
+    let card = rig.card.clone();
+    let first = titled(
+        record("session-t1", "a", AgentTaskStatus::Running, 1_000),
+        PROMPT,
+    );
+    emit(&card, started(&first), &mut rig.cx);
+    // Every later rename of a title is refused by the database.
+    run_sql(
+        &db,
+        "CREATE TRIGGER refuse_rename BEFORE UPDATE OF thread_title ON agent_task_history \
+         BEGIN SELECT RAISE(ABORT, 'rename refused'); END",
+    )
+    .await;
+
+    capture_logs();
+    announce(&mut rig, retitle("session-t1", PROMPT));
+    announce(&mut rig, retitle("session-without-records", GENERATED));
+    let errors = error_lines();
+    assert!(
+        errors.is_empty(),
+        "an unchanged title, or a session with no record, writes nothing: {errors:?}"
+    );
+    assert_eq!(entries(&store, &rig.cx).len(), 1, "a title records no task");
+
+    capture_logs();
+    announce(&mut rig, retitle("session-t1", GENERATED));
+    let errors = error_lines();
+    assert!(
+        errors.iter().any(|line| line.contains("session-t1")),
+        "the failed rename is logged at error level with its session: {errors:?}"
+    );
+    assert_eq!(
+        title_of(&entries(&store, &rig.cx), "session-t1", "a"),
+        generated(),
+        "memory keeps the new title"
+    );
+    assert_eq!(
+        title_of(&load(&db).await, "session-t1", "a"),
+        Some(PROMPT.to_string()),
+        "the refused write left the row as it was"
+    );
+}
+
+// A title heard while the startup sweep and load run is applied after them, to the rows the
+// load brought back — not lost, and not overwritten by the load.
+#[gpui::test]
+async fn store_applies_a_title_heard_during_the_startup_sweep_after_the_load(
+    cx: &mut gpui::TestAppContext,
+) {
+    let mut rig = card_rig(cx).await;
+    let db = AgentTaskHistoryDb::open_test_db("store_title_early").await;
+    db.insert_started(&titled(
+        record("s1", "old", AgentTaskStatus::Completed, 500),
+        PROMPT,
+    ))
+    .await
+    .unwrap();
+
+    let store = rig.cx.new(|cx| TaskHistoryStore::new(db.clone(), cx));
+    let card = rig.card.clone();
+    store.update(&mut rig.cx, |store, cx| store.register_card(&card, cx));
+    announce(&mut rig, retitle("s1", GENERATED));
+
+    for view in [entries(&store, &rig.cx), load(&db).await] {
+        assert_eq!(title_of(&view, "s1", "old"), generated());
+    }
+}
+
+fn open_thread_titled(rig: &mut CardRig, title: &str) -> Entity<AcpThread> {
+    open_thread_on(&rig.agent_panel, StopConnection::new(), title, &mut rig.cx)
+}
+
+// The card holds a thread before the store registers it (a thread restored at startup): the
+// store asks the card for the titles it already holds, and renames what the last run stored.
+#[gpui::test]
+async fn store_takes_the_titles_of_threads_the_card_held_before_registering(
+    cx: &mut gpui::TestAppContext,
+) {
+    let mut rig = card_rig(cx).await;
+    let thread = open_thread_titled(&mut rig, GENERATED);
+    let session = session_of(&thread, &rig.cx).to_string();
+    let db = AgentTaskHistoryDb::open_test_db("store_title_pull").await;
+    db.insert_started(&titled(
+        record(&session, "a", AgentTaskStatus::Completed, 500),
+        PROMPT,
+    ))
+    .await
+    .unwrap();
+
+    let store = registered_store(&db, &mut rig);
+
+    for view in [entries(&store, &rig.cx), load(&db).await] {
+        assert_eq!(title_of(&view, &session, "a"), generated());
+    }
+}
+
+// End to end through the real card: a task listed while the thread still carries its
+// provisional title (the prompt), then the generated title arrives.
+#[gpui::test]
+async fn store_follows_a_real_thread_from_its_prompt_to_its_generated_title(
+    cx: &mut gpui::TestAppContext,
+) {
+    let mut rig = card_rig(cx).await;
+    let db = AgentTaskHistoryDb::open_test_db("store_title_live").await;
+    let store = registered_store(&db, &mut rig);
+    agent_ui::test_support::open_thread_with_custom_connection(
+        &rig.agent_panel,
+        StopConnection::new(),
+        &mut rig.cx,
+    );
+    rig.cx.run_until_parked();
+    let thread = rig.agent_panel.read_with(&rig.cx, |panel, cx| {
+        panel
+            .active_agent_thread(cx)
+            .expect("a thread was just opened")
+    });
+    thread.update(&mut rig.cx, |thread, cx| {
+        thread.set_provisional_title(PROMPT.into(), cx)
+    });
+    rig.cx.run_until_parked();
+    let session = session_of(&thread, &rig.cx).to_string();
+    send_tasks(
+        &thread,
+        vec![wire_task("a", "running", 1_000, None)],
+        &mut rig.cx,
+    );
+    assert_eq!(
+        title_of(&entries(&store, &rig.cx), &session, "a"),
+        Some(PROMPT.to_string()),
+        "the first task is recorded under the provisional title"
+    );
+
+    send_info(
+        &thread,
+        agent_client_protocol::v1::schema::SessionInfoUpdate::new().title(GENERATED),
+        &mut rig.cx,
+    );
+
+    for view in [entries(&store, &rig.cx), load(&db).await] {
+        assert_eq!(title_of(&view, &session, "a"), generated());
+    }
+}
+
+// What the user saw: a thread the card no longer shows is grouped under the title of its
+// stored rows — after a restart too, when the panel is rebuilt over a store reloaded from the
+// same database.
+#[gpui::test]
+async fn panel_groups_a_thread_under_its_newest_title_after_a_restart(
+    cx: &mut gpui::TestAppContext,
+) {
+    let db = init_base(cx);
+    let mut panels = open_panels(cx).await;
+    let card = panels
+        .agent_panel
+        .read_with(&panels.cx, |panel, _| panel.tasks_card().clone());
+    let only = titled(record("s1", "a", AgentTaskStatus::Completed, 1_000), PROMPT);
+    emit(&card, started(&only), &mut panels.cx);
+    emit(&card, ended(&only), &mut panels.cx);
+    card.update(&mut panels.cx, |_, cx| cx.emit(retitle("s1", GENERATED)));
+    panels.cx.run_until_parked();
+
+    let title = |groups: Vec<crate::PanelGroup>| {
+        groups
+            .into_iter()
+            .find(|group| group.session_id == sid("s1"))
+            .map(|group| group.title.to_string())
+    };
+    assert_eq!(title(groups(&mut panels)), generated());
+
+    // Restart: a new global store loaded from the same database, and a new panel over it.
+    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();
+    let groups = panel.update(&mut panels.cx, |panel, cx| panel.groups(cx));
+    assert_eq!(
+        title(groups),
+        generated(),
+        "the restarted panel titles the group with the generated title, not the prompt"
+    );
+}
diff --git a/crates/agent_tasks_panel/src/test_helpers.rs b/crates/agent_tasks_panel/src/test_helpers.rs
new file mode 100644
index 0000000..bc29a16
--- /dev/null
+++ b/crates/agent_tasks_panel/src/test_helpers.rs
@@ -0,0 +1,554 @@
+// Story 014 — shared fixtures for the agent_tasks_panel tests (patch 0035).
+//
+// Declared in the crate's lib (`src/agent_tasks_panel.rs`) as
+//
+//     #[cfg(test)]
+//     mod test_helpers;
+//
+// in `src/history_db.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "history_db/migration_tests.rs"]
+//     mod migration;
+//     #[cfg(test)]
+//     #[path = "history_db/statement_tests.rs"]
+//     mod statements;
+//
+// in `src/store.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "store/lifecycle_tests.rs"]
+//     mod lifecycle;
+//     #[cfg(test)]
+//     #[path = "store/archive_tests.rs"]
+//     mod archive;
+//
+// and in `src/panel.rs` as
+//
+//     #[cfg(test)]
+//     #[path = "panel/list_tests.rs"]
+//     mod list;
+//     #[cfg(test)]
+//     #[path = "panel/actions_tests.rs"]
+//     mod actions;
+//
+// so each test path carries its Validation filter: `history_db::migration`, `history_db`,
+// `store`, `store::archive`, `panel::list`, `panel::actions`.
+//
+// Dev-dependencies the tests need (all already in Zed's workspace lockfile):
+//   acp_thread, agent, agent_ui, db, fs, gpui, language_model, project, workspace, remote,
+//   remote_connection — each with `features = ["test-support"]` (`remote` and
+//   `remote_connection` only to unify features: without them `remote_connection` fails with
+//   E0004 on `RemoteConnectionOptions::Mock`) — plus action_log, agent-client-protocol,
+//   anyhow, log, serde_json, ui, watch.
+//
+// Contract assumed beyond design.md's signatures. When the implementation drifts, adjust
+// HERE (and in `record()`), not in each test:
+//   - `TaskRecord` derives `Clone` + `Debug`; `HistoryEntry` derives `Clone` + `Debug`.
+//   - `acp_thread::AgentTaskId` (012, `= SharedString`); archive keys are
+//     `(acp_v2::SessionId, AgentTaskId)`.
+//   - The crate root re-exports `AgentTaskHistoryDb`, `HistoryEntry`, `TaskHistoryStore`,
+//     `Availability`, `AgentTasksPanel`, `TaskFilter` (Copy + PartialEq), `PanelGroup`,
+//     `PanelRow`, and the `ToggleFocus` action.
+//   - `TaskHistoryStore::new(db, cx)` (entity constructor; runs the startup sweep and load),
+//     `TaskHistoryStore::global(cx) -> Entity<Self>`, `register_card(&Entity<TasksCard>, cx)`,
+//     `records() -> &[HistoryEntry]`, `availability() -> &Availability`,
+//     `archive(&[key], cx)`, `unarchive(&[key], cx)`, `archive_group(&acp_v2::SessionId, cx)`.
+//     The store is fed ONLY through `register_card`: tests emit `TaskActivity` on the real
+//     card of an `AgentPanel` (the `EventEmitter` impl is public).
+//   - `Availability::{Available, Unavailable(SharedString)}`.
+//   - `AgentTasksPanel::load(WeakEntity<Workspace>, AsyncWindowContext)
+//     -> Task<Result<Entity<Self>>>`, as `AgentPanel::load`; `groups(cx) -> Vec<PanelGroup>`
+//     (visible rows only, after filters); `PanelGroup { session_id, title: SharedString,
+//     rows: Vec<PanelRow> }`; `PanelRow { entry: HistoryEntry, can_stop: bool,
+//     can_archive: bool, notice: Option<SharedString>, error: Option<SharedString> }`, where
+//     a running row's `entry` carries the card's live values (`TasksCard::live_task`) and
+//     `error` is the card row's error; `is_filter_enabled(TaskFilter) -> bool`,
+//     `set_filter(TaskFilter, bool, cx)`; `status_message(cx) -> Option<SharedString>`;
+//     `open_task(&key, window, cx)`, `request_stop(&key, window, cx)` (delegates to
+//     `TasksCard::request_stop`), `archive(&key, cx)`, `unarchive(&key, cx)`,
+//     `archive_group(&acp_v2::SessionId, cx)`.
+
+#![allow(dead_code)]
+
+use std::{
+    any::Any,
+    cell::RefCell,
+    path::Path,
+    rc::Rc,
+    sync::{Arc, Mutex, Once},
+};
+
+use acp_thread::{AcpThread, AgentConnection, AgentTaskId, AgentTaskStatus, StubAgentConnection};
+use agent_client_protocol::v1::schema as acp;
+use agent_client_protocol::v2::schema as acp_v2;
+use agent_ui::{AgentPanel, TaskActivity, TaskRecord, TasksCard};
+use anyhow::{Result, anyhow};
+use fs::FakeFs;
+use gpui::{App, AppContext as _, Entity, SharedString, Task, TestAppContext, VisualTestContext};
+use project::{AgentId, Project};
+use serde_json::json;
+use workspace::{MultiWorkspace, PathList, Workspace};
+
+use crate::{AgentTaskHistoryDb, AgentTasksPanel, HistoryEntry, PanelGroup, PanelRow};
+
+pub(crate) type Key = (acp_v2::SessionId, AgentTaskId);
+
+pub(crate) const TASKS_KEY: &str = "_claude/tasks";
+
+pub(crate) fn sid(session: &str) -> acp_v2::SessionId {
+    acp_v2::SessionId::new(session.to_string())
+}
+
+pub(crate) fn key(session: &str, task: &str) -> Key {
+    (sid(session), AgentTaskId::from(task.to_string()))
+}
+
+pub(crate) fn key_of(entry: &HistoryEntry) -> (String, String) {
+    (
+        entry.record.session_id.to_string(),
+        entry.record.task_id.to_string(),
+    )
+}
+
+/// A task record as the card hands it over. Finished records carry an outcome.
+pub(crate) fn record(
+    session: &str,
+    task: &str,
+    status: AgentTaskStatus,
+    started_ms: i64,
+) -> TaskRecord {
+    let ended = !matches!(status, AgentTaskStatus::Running);
+    TaskRecord {
+        session_id: sid(session),
+        task_id: AgentTaskId::from(task.to_string()),
+        thread_title: Some(SharedString::from(format!("Thread {session}"))),
+        project: "project".into(),
+        kind: "shell".into(),
+        description: SharedString::from(format!("Task {task}")),
+        background: false,
+        started_at_ms: started_ms,
+        ended_at_ms: ended.then_some(started_ms + 1_000),
+        status,
+        tokens: ended.then_some(100),
+        tool_uses: ended.then_some(3),
+    }
+}
+
+pub(crate) fn started(record: &TaskRecord) -> TaskActivity {
+    TaskActivity::Started(record.clone())
+}
+
+pub(crate) fn ended(record: &TaskRecord) -> TaskActivity {
+    TaskActivity::Ended(record.clone())
+}
+
+pub(crate) fn find<'a>(
+    entries: &'a [HistoryEntry],
+    session: &str,
+    task: &str,
+) -> Option<&'a HistoryEntry> {
+    entries.iter().find(|entry| {
+        entry.record.session_id.to_string() == session && entry.record.task_id.as_ref() == task
+    })
+}
+
+pub(crate) async fn load(db: &AgentTaskHistoryDb) -> Vec<HistoryEntry> {
+    db.load_all().await.expect("load_all should succeed")
+}
+
+/// Runs raw SQL on the domain's connection (test-only sabotage: triggers, dropped tables).
+pub(crate) async fn run_sql(db: &AgentTaskHistoryDb, sql: &'static str) {
+    db.write(move |connection| {
+        connection.exec(sql).expect("statement should prepare")().expect("statement should run")
+    })
+    .await;
+}
+
+pub(crate) async fn drop_history_table(db: &AgentTaskHistoryDb) {
+    run_sql(db, "DROP TABLE agent_task_history").await;
+}
+
+// ── Log capture (R1.6, R4.3, Q4) ───────────────────────────────────────────
+//
+// A process-wide logger that records into a thread-local buffer, so parallel tests do not
+// see each other's lines. GPUI test executors run spawned work on the test's own thread.
+
+thread_local! {
+    static CAPTURED: RefCell<Vec<(log::Level, String)>> = const { RefCell::new(Vec::new()) };
+}
+
+struct CaptureLogger;
+
+impl log::Log for CaptureLogger {
+    fn enabled(&self, _metadata: &log::Metadata) -> bool {
+        true
+    }
+
+    fn log(&self, record: &log::Record) {
+        let mut line = record.args().to_string();
+        struct Collect<'a>(&'a mut String);
+        impl<'kvs> log::kv::VisitSource<'kvs> for Collect<'_> {
+            fn visit_pair(
+                &mut self,
+                key: log::kv::Key<'kvs>,
+                value: log::kv::Value<'kvs>,
+            ) -> Result<(), log::kv::Error> {
+                self.0.push_str(&format!(" {key}={value}"));
+                Ok(())
+            }
+        }
+        let _ = record.key_values().visit(&mut Collect(&mut line));
+        CAPTURED.with(|captured| captured.borrow_mut().push((record.level(), line)));
+    }
+
+    fn flush(&self) {}
+}
+
+static INSTALL: Once = Once::new();
+
+/// Starts capturing log lines on this thread. Panics when another logger owns the process,
+/// rather than letting a log assertion pass or fail for the wrong reason.
+pub(crate) fn capture_logs() {
+    INSTALL.call_once(|| {
+        log::set_boxed_logger(Box::new(CaptureLogger))
+            .expect("another logger is installed; the log capture cannot run");
+        log::set_max_level(log::LevelFilter::Trace);
+    });
+    CAPTURED.with(|captured| captured.borrow_mut().clear());
+}
+
+pub(crate) fn error_lines() -> Vec<String> {
+    CAPTURED.with(|captured| {
+        captured
+            .borrow()
+            .iter()
+            .filter(|(level, _)| *level == log::Level::Error)
+            .map(|(_, line)| line.clone())
+            .collect()
+    })
+}
+
+// ── A connection that records stops (story 012's task action) ──────────────
+
+#[derive(Clone)]
+pub(crate) struct StopConnection {
+    stub: StubAgentConnection,
+    next_session: Arc<Mutex<usize>>,
+    pub stops: Arc<Mutex<Vec<(String, String)>>>,
+    pub stop_error: Arc<Mutex<Option<&'static str>>>,
+}
+
+impl StopConnection {
+    pub(crate) fn new() -> Self {
+        Self {
+            stub: StubAgentConnection::new(),
+            next_session: Arc::new(Mutex::new(0)),
+            stops: Arc::default(),
+            stop_error: Arc::default(),
+        }
+    }
+
+    pub(crate) fn stops(&self) -> Vec<(String, String)> {
+        self.stops.lock().unwrap().clone()
+    }
+}
+
+impl AgentConnection for StopConnection {
+    fn agent_id(&self) -> AgentId {
+        self.stub.agent_id()
+    }
+
+    fn telemetry_id(&self) -> SharedString {
+        "agent-tasks-panel-test".into()
+    }
+
+    fn new_session(
+        self: Rc<Self>,
+        project: Entity<Project>,
+        work_dirs: PathList,
+        cx: &mut App,
+    ) -> Task<Result<Entity<AcpThread>>> {
+        let session_id = {
+            let mut next = self.next_session.lock().unwrap();
+            *next += 1;
+            acp_v2::SessionId::new(format!("tasks-panel-session-{}", *next))
+        };
+        let action_log = cx.new(|_| action_log::ActionLog::new(project.clone()));
+        let thread = cx.new(|cx| {
+            AcpThread::new(
+                None,
+                None,
+                Some(work_dirs),
+                self.clone(),
+                project,
+                action_log,
+                session_id,
+                watch::Receiver::constant(
+                    agent_client_protocol::v2::schema::PromptCapabilities::new(),
+                ),
+                cx,
+            )
+        });
+        Task::ready(Ok(thread))
+    }
+
+    fn auth_methods(&self) -> &[agent_client_protocol::v2::schema::AuthMethod] {
+        &[]
+    }
+
+    fn authenticate(
+        &self,
+        _method: agent_client_protocol::v2::schema::AuthMethodId,
+        _cx: &mut App,
+    ) -> Task<Result<()>> {
+        Task::ready(Ok(()))
+    }
+
+    fn prompt(
+        &self,
+        _params: agent_client_protocol::v2::schema::PromptRequest,
+        _cx: &mut App,
+    ) -> Task<Result<acp::PromptResponse>> {
+        Task::ready(Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)))
+    }
+
+    fn cancel(&self, _session_id: &acp_v2::SessionId, _cx: &mut App) -> Result<()> {
+        Ok(())
+    }
+
+    fn stop_task(
+        self: Rc<Self>,
+        session_id: &acp_v2::SessionId,
+        task_id: &str,
+        _cx: &mut App,
+    ) -> Task<Result<()>> {
+        self.stops
+            .lock()
+            .unwrap()
+            .push((session_id.to_string(), task_id.to_string()));
+        match *self.stop_error.lock().unwrap() {
+            None => Task::ready(Ok(())),
+            Some(message) => Task::ready(Err(anyhow!(message))),
+        }
+    }
+
+    fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+        self
+    }
+}
+
+// ── Workspace rigs ─────────────────────────────────────────────────────────
+
+/// Settings, the isolated per-test app database, and the agent globals — everything before
+/// `agent_tasks_panel::init`, so a test can seed or sabotage the history domain first.
+pub(crate) fn init_base(cx: &mut TestAppContext) -> AgentTaskHistoryDb {
+    agent_ui::test_support::init_test(cx);
+    cx.update(|cx| {
+        // `agent_ui::test_support::init_test` leaves this out; agent_ui's own rig sets it.
+        agent_ui::thread_metadata_store::ThreadMetadataStore::init_global(cx);
+        agent::ThreadStore::init_global(cx);
+        language_model::LanguageModelRegistry::test(cx);
+        AgentTaskHistoryDb::global(cx)
+    })
+}
+
+pub(crate) struct Panels {
+    pub workspace: Entity<Workspace>,
+    pub agent_panel: Entity<AgentPanel>,
+    pub tasks_panel: Entity<AgentTasksPanel>,
+    pub cx: VisualTestContext,
+}
+
+pub(crate) struct CardRig {
+    pub workspace: Entity<Workspace>,
+    pub agent_panel: Entity<AgentPanel>,
+    pub card: Entity<TasksCard>,
+    pub cx: VisualTestContext,
+}
+
+/// A workspace with only the agent panel (and so its real `TasksCard`). No crate `init`.
+pub(crate) async fn card_rig(cx: &mut TestAppContext) -> CardRig {
+    init_base(cx);
+    let (workspace, agent_panel, cx) = open_agent_workspace(cx).await;
+    let card = agent_panel.read_with(&cx, |panel, _| panel.tasks_card().clone());
+    CardRig {
+        workspace,
+        agent_panel,
+        card,
+        cx,
+    }
+}
+
+/// Emits a `TaskActivity` on the real card, as the card itself does on a snapshot.
+pub(crate) fn emit(card: &Entity<TasksCard>, activity: TaskActivity, cx: &mut VisualTestContext) {
+    card.update(cx, |_, cx| cx.emit(activity));
+    cx.run_until_parked();
+}
+
+async fn open_agent_workspace(
+    cx: &mut TestAppContext,
+) -> (Entity<Workspace>, Entity<AgentPanel>, VisualTestContext) {
+    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;
+    let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
+    let multi_workspace =
+        cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let workspace = multi_workspace
+        .read_with(cx, |multi_workspace, _cx| {
+            multi_workspace.workspace().clone()
+        })
+        .unwrap();
+    let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
+    cx.simulate_resize(gpui::size(gpui::px(1400.), gpui::px(900.)));
+    let agent_panel = workspace.update_in(&mut cx, |workspace, window, cx| {
+        let agent_panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
+        workspace.add_panel(agent_panel.clone(), window, cx);
+        workspace.focus_panel::<AgentPanel>(window, cx);
+        agent_panel
+    });
+    cx.run_until_parked();
+    (workspace, agent_panel, cx)
+}
+
+/// Runs the crate's real `init`, opens a workspace with the agent panel, then loads the
+/// Agent Tasks panel through `AgentTasksPanel::load` — the order Zeo starts in.
+pub(crate) async fn open_panels(cx: &mut TestAppContext) -> Panels {
+    cx.update(|cx| crate::init(cx));
+    let (workspace, agent_panel, mut cx) = open_agent_workspace(cx).await;
+    let weak = workspace.downgrade();
+    let loading = cx.update(|window, cx| AgentTasksPanel::load(weak, window.to_async(cx)));
+    cx.run_until_parked();
+    let tasks_panel = loading.await.expect("the Agent Tasks panel loads");
+    workspace.update_in(&mut cx, |workspace, window, cx| {
+        workspace.add_panel(tasks_panel.clone(), window, cx);
+    });
+    cx.run_until_parked();
+    Panels {
+        workspace,
+        agent_panel,
+        tasks_panel,
+        cx,
+    }
+}
+
+/// Opens a thread in the agent panel on `connection`, sends one message so it is a real
+/// (persisted) thread, titles it, and returns it.
+pub(crate) fn open_thread_on<C>(
+    agent_panel: &Entity<AgentPanel>,
+    connection: C,
+    title: &str,
+    cx: &mut VisualTestContext,
+) -> Entity<AcpThread>
+where
+    C: 'static + AgentConnection + Send + Clone,
+{
+    agent_ui::test_support::open_thread_with_custom_connection(agent_panel, connection, cx);
+    agent_ui::test_support::send_message(agent_panel, cx);
+    cx.run_until_parked();
+    let thread = agent_panel.read_with(cx, |panel, cx| {
+        panel
+            .active_agent_thread(cx)
+            .expect("a thread was just opened")
+    });
+    send_info(&thread, acp::SessionInfoUpdate::new().title(title), cx);
+    thread
+}
+
+pub(crate) fn open_live_thread(panels: &mut Panels, title: &str) -> Entity<AcpThread> {
+    open_thread_on(
+        &panels.agent_panel,
+        StopConnection::new(),
+        title,
+        &mut panels.cx,
+    )
+}
+
+pub(crate) fn session_of(thread: &Entity<AcpThread>, cx: &VisualTestContext) -> acp_v2::SessionId {
+    thread.read_with(cx, |thread, _| thread.session_id().clone())
+}
+
+pub(crate) fn active_session(panels: &Panels) -> Option<acp_v2::SessionId> {
+    panels.agent_panel.read_with(&panels.cx, |panel, cx| {
+        panel
+            .active_agent_thread(cx)
+            .map(|thread| thread.read(cx).session_id().clone())
+    })
+}
+
+pub(crate) fn send_info(
+    thread: &Entity<AcpThread>,
+    update: acp::SessionInfoUpdate,
+    cx: &mut VisualTestContext,
+) {
+    thread
+        .update(cx, |thread, cx| {
+            thread.handle_session_update(acp::SessionUpdate::SessionInfoUpdate(update), cx)
+        })
+        .expect("session info update should apply");
+    cx.run_until_parked();
+}
+
+/// One `_claude/tasks` wire task (story 012's camelCase shape).
+pub(crate) fn wire_task(
+    id: &str,
+    status: &str,
+    started_at_ms: u64,
+    usage: Option<(u64, u64)>,
+) -> serde_json::Value {
+    let ended = status != "running";
+    json!({
+        "id": id,
+        "toolCallId": format!("toolu_{id}"),
+        "type": "shell",
+        "description": format!("Task {id}"),
+        "background": false,
+        "depth": 0,
+        "status": status,
+        "startedAt": started_at_ms,
+        "endedAt": if ended { Some(started_at_ms + 1_000) } else { None },
+        "usage": usage.map(|(tokens, tool_uses)| json!({
+            "tokens": tokens,
+            "toolUses": tool_uses,
+            "durationMs": 1_000,
+        })),
+        "lastTool": null,
+        "summary": null,
+    })
+}
+
+/// Feeds a `_claude/tasks` snapshot (a level: it replaces the thread's list).
+pub(crate) fn send_tasks(
+    thread: &Entity<AcpThread>,
+    tasks: Vec<serde_json::Value>,
+    cx: &mut VisualTestContext,
+) {
+    let mut meta = serde_json::Map::new();
+    meta.insert(TASKS_KEY.to_string(), json!({ "tasks": tasks }));
+    send_info(thread, acp::SessionInfoUpdate::new().meta(meta), cx);
+}
+
+pub(crate) fn groups(panels: &mut Panels) -> Vec<PanelGroup> {
+    panels
+        .tasks_panel
+        .update(&mut panels.cx, |panel, cx| panel.groups(cx))
+}
+
+pub(crate) fn find_row(panels: &mut Panels, session: &str, task: &str) -> Option<PanelRow> {
+    groups(panels)
+        .into_iter()
+        .flat_map(|group| group.rows)
+        .find(|row| {
+            row.entry.record.session_id.to_string() == session
+                && row.entry.record.task_id.as_ref() == task
+        })
+}
+
+pub(crate) fn visible_keys(panels: &mut Panels) -> Vec<(String, String)> {
+    groups(panels)
+        .into_iter()
+        .flat_map(|group| group.rows)
+        .map(|row| key_of(&row.entry))
+        .collect()
+}
diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs
index c62124c..258f20a 100644
--- a/crates/agent_ui/src/agent_panel.rs
+++ b/crates/agent_ui/src/agent_panel.rs
@@ -4559,8 +4559,8 @@ impl AgentPanel {
             .is_some_and(|thread| thread.read(cx).session_id() == session_id)
     }
 
-    #[cfg(test)]
-    pub(crate) fn tasks_card(&self) -> &Entity<TasksCard> {
+    /// The tasks card this panel hosts; its `TaskActivity` feeds the task history.
+    pub fn tasks_card(&self) -> &Entity<TasksCard> {
         &self.tasks_card
     }
 
diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs
index b61157e..e6bf264 100644
--- a/crates/agent_ui/src/agent_ui.rs
+++ b/crates/agent_ui/src/agent_ui.rs
@@ -87,6 +87,7 @@ pub use crate::agent_panel::{
 use crate::agent_registry_ui::AgentRegistryPage;
 pub use crate::inline_assistant::InlineAssistant;
 pub use crate::message_editor::MessageEditorEvent;
+pub use crate::tasks_card::{TaskActivity, TaskRecord, TasksCard, ThreadTitleChanged};
 pub use crate::thread_metadata_store::ThreadId;
 pub use agent_diff::{AgentDiffPane, AgentDiffToolbar};
 pub use conversation_view::open_markdown_in_workspace;
diff --git a/crates/agent_ui/src/tasks_card.rs b/crates/agent_ui/src/tasks_card.rs
index d653a99..75c8c81 100644
--- a/crates/agent_ui/src/tasks_card.rs
+++ b/crates/agent_ui/src/tasks_card.rs
@@ -8,11 +8,12 @@
 //! next snapshot while the card still has to show it for `ENDED_ROW_TTL`.
 //!
 //! Disabled, hidden or minimized only stops painting: the card keeps syncing
-//! threads and receiving their events in every state.
+//! threads and receiving their events in every state — and so keeps emitting
+//! `TaskActivity`, which feeds the task history (story 014, patch `0035`).
 
 use std::{
     cmp::Reverse,
-    time::{Duration, Instant, SystemTime},
+    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
 };
 
 use acp_thread::{AcpThread, AcpThreadEvent, AgentTask, AgentTaskId, AgentTaskStatus};
@@ -79,6 +80,14 @@ mod open;
 #[path = "tasks_card/group_open_tests.rs"]
 mod group_open;
 
+#[cfg(test)]
+#[path = "tasks_card/activity_tests.rs"]
+mod activity;
+
+#[cfg(test)]
+#[path = "tasks_card/title_tests.rs"]
+mod title;
+
 /// How long an ended task's final status stays on the card (R1.6).
 pub(crate) const ENDED_ROW_TTL: Duration = Duration::from_secs(10);
 
@@ -110,6 +119,77 @@ pub enum TasksCardEvent {
 
 impl EventEmitter<TasksCardEvent> for TasksCard {}
 
+/// The life of a task, told to whoever keeps a record that outlives the card's
+/// rows (story 014). Progress alone tells nothing (R1.4).
+#[derive(Clone, Debug)]
+pub enum TaskActivity {
+    /// The first time a task appears in any thread the card holds — always before
+    /// that task's `Ended` (R1.1, R1.3, R1.8).
+    Started(TaskRecord),
+    /// Once per ending, however many views of its session deliver it (R1.2,
+    /// R1.10); also, as `Interrupted`, for a task still running when the card stops
+    /// holding its thread (R1.7).
+    Ended(TaskRecord),
+}
+
+impl EventEmitter<TaskActivity> for TasksCard {}
+
+/// A held thread's newest title, told to the task history so that every record
+/// of the thread follows it (story 014): a thread's first task is usually
+/// recorded under the provisional title — the user's prompt — before the
+/// generated one arrives. Emitted when the card starts holding a titled thread
+/// and on every later change of that title, once per distinct title per
+/// session. Apart from `TaskActivity`, which tells the life of a task.
+#[derive(Clone, Debug, PartialEq)]
+pub struct ThreadTitleChanged {
+    pub session_id: acp_v2::SessionId,
+    pub title: SharedString,
+}
+
+impl EventEmitter<ThreadTitleChanged> for TasksCard {}
+
+/// A task as the history records it: what it was, and how it ended.
+#[derive(Clone, Debug, PartialEq)]
+pub struct TaskRecord {
+    pub session_id: acp_v2::SessionId,
+    pub task_id: AgentTaskId,
+    pub thread_title: Option<SharedString>,
+    /// The last component of the thread's first work directory, or empty.
+    pub project: SharedString,
+    pub kind: SharedString,
+    pub description: SharedString,
+    pub background: bool,
+    /// Milliseconds since the Unix epoch.
+    pub started_at_ms: i64,
+    pub ended_at_ms: Option<i64>,
+    pub status: AgentTaskStatus,
+    pub tokens: Option<u64>,
+    pub tool_uses: Option<u64>,
+}
+
+impl TaskRecord {
+    fn new(thread: &AcpThread, task: &AgentTask) -> Self {
+        Self {
+            session_id: thread.session_id().clone(),
+            task_id: task.id.clone(),
+            thread_title: thread.title(),
+            project: project_name(thread),
+            kind: task.kind.clone(),
+            description: task.description.clone(),
+            background: task.background,
+            started_at_ms: unix_ms(task.started_at),
+            ended_at_ms: task.ended_at.map(unix_ms),
+            status: task.status,
+            tokens: task.usage.as_ref().map(|usage| usage.tokens),
+            tool_uses: task.usage.as_ref().map(|usage| usage.tool_uses),
+        }
+    }
+
+    fn key(&self) -> TaskKey {
+        (self.session_id.clone(), self.task_id.clone())
+    }
+}
+
 /// The rows of one thread, as the card lists them.
 #[derive(Clone, Debug)]
 pub struct ThreadGroup {
@@ -144,6 +224,16 @@ pub struct TasksCard {
     /// Endings already weighed for the tray. A thread held through two views
     /// delivers each `TaskEnded` twice, and one ending is one datagram at most.
     tray_weighed: HashSet<TaskKey>,
+    /// Every task announced as `TaskActivity::Started`, with its newest record: a
+    /// closed thread may be gone by the time the card learns of it, and its running
+    /// tasks still have to end as interrupted (R1.7).
+    recorded: HashMap<TaskKey, TaskRecord>,
+    /// Endings already announced as `TaskActivity::Ended`, on `tray_weighed`'s
+    /// rule: one ending heard through two views is one `Ended` (R1.10).
+    activity_ended: HashSet<TaskKey>,
+    /// The last title announced as `ThreadTitleChanged` for each held session, so
+    /// a title is announced once however many views or events repeat it.
+    announced_titles: HashMap<acp_v2::SessionId, SharedString>,
     /// Asked before telling the tray about an ending (R5.3); `None` for a card no
     /// panel hosts, which tells the tray nothing.
     in_view: Option<InViewQuery>,
@@ -221,6 +311,9 @@ impl TasksCard {
             row_errors: HashMap::default(),
             endings_seen: 0,
             tray_weighed: HashSet::default(),
+            recorded: HashMap::default(),
+            activity_ended: HashSet::default(),
+            announced_titles: HashMap::default(),
             in_view: None,
             _expiry: None,
             _tick: None,
@@ -268,6 +361,7 @@ impl TasksCard {
         }
         // Dropping what is left of `previous` drops its subscriptions.
         self.views = held;
+        self.record_held_tasks(cx);
         self.forget_unheld_sessions(cx);
         self.update_tick(cx);
         cx.notify();
@@ -352,6 +446,18 @@ impl TasksCard {
         self.minimized
     }
 
+    /// A task as its thread reports it while it runs (R2.3 of story 014); `None`
+    /// once it has ended, or when no held thread of `session_id` lists it.
+    pub fn live_task(
+        &self,
+        session_id: &acp_v2::SessionId,
+        task_id: &AgentTaskId,
+        cx: &App,
+    ) -> Option<AgentTask> {
+        self.running_task(&(session_id.clone(), task_id.clone()), cx)
+            .cloned()
+    }
+
     pub fn toggle_minimized(&mut self, cx: &mut Context<Self>) {
         self.minimized = !self.minimized;
         self.update_tick(cx);
@@ -687,7 +793,7 @@ impl TasksCard {
             AcpThreadEvent::TaskEnded { task_id, .. } => {
                 card.handle_task_ended(&thread, task_id, cx)
             }
-            AcpThreadEvent::TitleUpdated => cx.notify(),
+            AcpThreadEvent::TitleUpdated => card.handle_title_updated(&thread, cx),
             _ => {}
         });
         HeldThread {
@@ -721,6 +827,7 @@ impl TasksCard {
             return;
         }
         held.thread = Some(Self::hold_thread(&root, cx));
+        self.record_held_tasks(cx);
         self.forget_unheld_sessions(cx);
         self.update_tick(cx);
         cx.notify();
@@ -753,12 +860,20 @@ impl TasksCard {
         });
         // Same rule as the thread's own announcements: an ending is news again
         // once its task leaves the list or runs again.
-        self.tray_weighed.retain(|(weighed_session, task_id)| {
-            weighed_session != session_id
+        let still_ended = |(other_session, task_id): &TaskKey| {
+            other_session != session_id
                 || tasks
                     .iter()
                     .any(|task| &task.id == task_id && !task.status.is_running())
+        };
+        self.tray_weighed.retain(still_ended);
+        self.activity_ended.retain(still_ended);
+        self.recorded.retain(|(recorded_session, task_id), _| {
+            recorded_session != session_id || tasks.iter().any(|task| &task.id == task_id)
         });
+        for record in records_of(thread) {
+            self.record_started(record, cx);
+        }
         // A background task can run for minutes between snapshots, so a snapshot
         // re-arms the tick as well as expanding the card does.
         self.update_tick(cx);
@@ -796,6 +911,9 @@ impl TasksCard {
             thread.title().as_deref(),
             SystemTime::now(),
         );
+        // Built from the copy kept in `ended`: the adapter drops an ended task from
+        // its next snapshot.
+        let record = TaskRecord::new(thread, &task);
         let key = (session_id, task.id.clone());
         let now = cx.background_executor().now();
         self.endings_seen += 1;
@@ -808,10 +926,96 @@ impl TasksCard {
             },
         );
         self.tell_tray(key, status, datagram, cx);
+        self.record_ended(record, cx);
         self.schedule_expiry(cx);
         cx.notify();
     }
 
+    /// Emits `Started` the first time a task's key is seen, and keeps its newest
+    /// record either way. Repeated snapshots and progress emit nothing (R1.3, R1.4).
+    fn record_started(&mut self, record: TaskRecord, cx: &mut Context<Self>) {
+        if let Some(recorded) = self.recorded.get_mut(&record.key()) {
+            *recorded = record;
+            return;
+        }
+        self.recorded.insert(record.key(), record.clone());
+        cx.emit(TaskActivity::Started(record));
+    }
+
+    /// Emits `Ended` once per ending, after the task's `Started` (R1.8, R1.10).
+    fn record_ended(&mut self, record: TaskRecord, cx: &mut Context<Self>) {
+        if !self.activity_ended.insert(record.key()) {
+            return;
+        }
+        self.record_started(record.clone(), cx);
+        cx.emit(TaskActivity::Ended(record));
+    }
+
+    fn handle_title_updated(&mut self, thread: &Entity<AcpThread>, cx: &mut Context<Self>) {
+        let (session_id, title) = {
+            let thread = thread.read(cx);
+            (thread.session_id().clone(), thread.title())
+        };
+        if let Some(title) = title {
+            self.announce_title(ThreadTitleChanged { session_id, title }, cx);
+        }
+        cx.notify();
+    }
+
+    /// Emits `ThreadTitleChanged` unless `change` is the title last announced for
+    /// its session, and moves the records kept for the session's tasks to it: an
+    /// `Interrupted` ending built from them must not carry the old title.
+    fn announce_title(&mut self, change: ThreadTitleChanged, cx: &mut Context<Self>) {
+        if self.announced_titles.get(&change.session_id) == Some(&change.title) {
+            return;
+        }
+        self.announced_titles
+            .insert(change.session_id.clone(), change.title.clone());
+        for ((session_id, _), record) in &mut self.recorded {
+            if *session_id == change.session_id {
+                record.thread_title = Some(change.title.clone());
+            }
+        }
+        cx.emit(change);
+    }
+
+    /// The title of every held thread that has one, once per session: what a
+    /// listener registered after the card started holding them has not heard.
+    pub fn thread_titles(&self, cx: &App) -> Vec<ThreadTitleChanged> {
+        let mut seen = HashSet::default();
+        self.held_threads()
+            .filter_map(|held| held.thread.upgrade())
+            .filter_map(|thread| {
+                let thread = thread.read(cx);
+                let session_id = thread.session_id();
+                if !seen.insert(session_id.clone()) {
+                    return None;
+                }
+                Some(ThreadTitleChanged {
+                    session_id: session_id.clone(),
+                    title: thread.title()?,
+                })
+            })
+            .collect()
+    }
+
+    /// Records the tasks a thread already lists when the card starts holding it:
+    /// that list is the first snapshot of them the card sees. Its title is
+    /// announced first, so the history renames what it kept of the thread before.
+    fn record_held_tasks(&mut self, cx: &mut Context<Self>) {
+        for change in self.thread_titles(cx) {
+            self.announce_title(change, cx);
+        }
+        let records: Vec<TaskRecord> = self
+            .held_threads()
+            .filter_map(|held| held.thread.upgrade())
+            .flat_map(|thread| records_of(thread.read(cx)))
+            .collect();
+        for record in records {
+            self.record_started(record, cx);
+        }
+    }
+
     /// Tells `zeo-systray` about an ending the user is not looking at, when it is
     /// watched or failed (R5.1–R5.3). The send runs off the UI thread.
     fn tell_tray(
@@ -994,6 +1198,38 @@ impl TasksCard {
             .retain(|(session_id, _), _| held.contains(session_id));
         self.tray_weighed
             .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());
+        let mut interrupted: Vec<TaskRecord> = self
+            .recorded
+            .iter()
+            .filter(|((session_id, _), record)| {
+                !held.contains(session_id) && record.status.is_running()
+            })
+            .map(|(_, record)| TaskRecord {
+                status: AgentTaskStatus::Interrupted,
+                ended_at_ms: Some(ended_at_ms),
+                ..record.clone()
+            })
+            .collect();
+        interrupted.sort_by(|a, b| {
+            (&a.session_id.0, a.started_at_ms, &a.task_id).cmp(&(
+                &b.session_id.0,
+                b.started_at_ms,
+                &b.task_id,
+            ))
+        });
+        self.recorded
+            .retain(|(session_id, _), _| held.contains(session_id));
+        self.activity_ended
+            .retain(|(session_id, _)| held.contains(session_id));
+        // A thread held again is announced again, for the history to check.
+        self.announced_titles
+            .retain(|session_id, _| held.contains(session_id));
+        for record in interrupted {
+            cx.emit(TaskActivity::Ended(record));
+        }
         self.schedule_expiry(cx);
     }
 }
@@ -1687,6 +1923,23 @@ fn format_duration(duration: Duration) -> String {
     }
 }
 
+/// One record per task the thread lists now.
+fn records_of(thread: &AcpThread) -> Vec<TaskRecord> {
+    thread
+        .tasks()
+        .iter()
+        .map(|task| TaskRecord::new(thread, task))
+        .collect()
+}
+
+/// Milliseconds since the Unix epoch, negative before it, saturating at `i64`'s range.
+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),
+    }
+}
+
 /// The last component of the thread's first work directory, or empty for a thread
 /// opened outside a project.
 fn project_name(thread: &AcpThread) -> SharedString {
diff --git a/crates/agent_ui/src/tasks_card/activity_tests.rs b/crates/agent_ui/src/tasks_card/activity_tests.rs
new file mode 100644
index 0000000..1da7c16
--- /dev/null
+++ b/crates/agent_ui/src/tasks_card/activity_tests.rs
@@ -0,0 +1,850 @@
+// Story 014, task 1.1 — TasksCard emits TaskActivity, and AgentPanel exposes the card
+// (R1.1, R1.2, R1.3, R1.4). Declared in `src/tasks_card.rs` next to story 013's modules:
+//
+//     #[cfg(test)]
+//     #[path = "tasks_card/activity_tests.rs"]
+//     mod activity;
+//
+// so its path carries the Validation filter `tasks_card::activity`. It reuses story 013's
+// `tasks_card/test_helpers.rs` (its `card()` helper goes through `AgentPanel::tasks_card()`).
+//
+// Contract assumed beyond design.md: `TaskActivity` has exactly the two variants
+// `Started(TaskRecord)` and `Ended(TaskRecord)`; `TaskRecord` (design.md's fields) derives
+// `Clone` + `Debug`; `TasksCard::live_task(&self, &acp_v2::SessionId, &AgentTaskId, &App)
+// -> Option<AgentTask>`; closing a thread is `sync_threads` without its view (story 013).
+
+use std::{cell::RefCell, rc::Rc};
+
+use acp_thread::{AgentTaskId, AgentTaskStatus};
+use agent_client_protocol::v2::schema as acp_v2;
+use agent_settings::{AgentSettings, TasksCardSettings};
+use gpui::{Entity, Subscription, TestAppContext, VisualTestContext};
+use serde_json::json;
+use settings::{Settings as _, TasksCardPosition};
+
+use super::test_helpers::*;
+use super::{TaskActivity, TaskRecord, TasksCard};
+
+#[derive(Clone, Debug)]
+enum Seen {
+    Started(TaskRecord),
+    Ended(TaskRecord),
+}
+
+fn watch(
+    card: &Entity<TasksCard>,
+    cx: &mut VisualTestContext,
+) -> (Rc<RefCell<Vec<Seen>>>, Subscription) {
+    let seen = Rc::new(RefCell::new(Vec::new()));
+    let sink = seen.clone();
+    let subscription = cx.update(|_, cx| {
+        cx.subscribe(card, move |_, event: &TaskActivity, _| {
+            sink.borrow_mut().push(match event {
+                TaskActivity::Started(record) => Seen::Started(record.clone()),
+                TaskActivity::Ended(record) => Seen::Ended(record.clone()),
+            })
+        })
+    });
+    (seen, subscription)
+}
+
+fn starts(seen: &Rc<RefCell<Vec<Seen>>>) -> Vec<(String, String)> {
+    seen.borrow()
+        .iter()
+        .filter_map(|event| match event {
+            Seen::Started(record) => {
+                Some((record.session_id.to_string(), record.task_id.to_string()))
+            }
+            Seen::Ended(_) => None,
+        })
+        .collect()
+}
+
+fn ends(seen: &Rc<RefCell<Vec<Seen>>>) -> Vec<TaskRecord> {
+    seen.borrow()
+        .iter()
+        .filter_map(|event| match event {
+            Seen::Ended(record) => Some(record.clone()),
+            Seen::Started(_) => None,
+        })
+        .collect()
+}
+
+fn with_progress(
+    mut task: serde_json::Value,
+    tokens: u64,
+    last_tool: &str,
+    summary: &str,
+) -> serde_json::Value {
+    task["usage"] = json!({ "tokens": tokens, "toolUses": tokens / 100, "durationMs": 10 });
+    task["lastTool"] = json!(last_tool);
+    task["summary"] = json!(summary);
+    task
+}
+
+// Hostile (wrong collapse): two tasks of one session are two starts. Hostile (wrong split):
+// later snapshots of the same task — even with a changed description — start nothing new.
+#[gpui::test]
+async fn tasks_card_activity_emits_started_once_per_task_of_a_session(cx: &mut TestAppContext) {
+    let (panel, mut cx) = setup_card_panel(cx).await;
+    let connection = TaskActionsConnection::new();
+    let thread = open_titled_thread(&panel, &connection, "Build thread", &mut cx);
+    let session = session_of(&thread, &cx).to_string();
+    let card = card(&panel, &cx);
+    let (seen, _subscription) = watch(&card, &mut cx);
+
+    send_tasks(
+        &thread,
+        vec![
+            wire_task("a", "running", false, 1_000),
+            wire_task("b", "running", true, 2_000),
+        ],
+        &mut cx,
+    );
+    assert_eq!(
+        starts(&seen),
+        vec![
+            (session.clone(), "a".to_string()),
+            (session, "b".to_string())
+        ]
+    );
+
+    let mut renamed = wire_task("a", "running", false, 1_000);
+    renamed["description"] = json!("A renamed description");
+    send_tasks(
+        &thread,
+        vec![renamed, wire_task("b", "running", true, 2_000)],
+        &mut cx,
+    );
+    send_tasks(
+        &thread,
+        vec![
+            wire_task("a", "running", false, 1_000),
+            wire_task("b", "running", true, 2_000),
+        ],
+        &mut cx,
+    );
+    assert_eq!(
+        seen.borrow().len(),
+        2,
+        "repeated snapshots emit nothing: {:?}",
+        seen.borrow()
+    );
+}
+
+// Hostile (wrong collapse): the same task id in two threads is two starts, one per session.
+#[gpui::test]
+async fn tasks_card_activity_keeps_one_task_id_in_two_threads_apart(cx: &mut TestAppContext) {
+    let (panel, mut cx) = setup_card_panel(cx).await;
+    let connection = TaskActionsConnection::new();
+    let card = card(&panel, &cx);
+    let (seen, _subscription) = watch(&card, &mut cx);
+
+    let first = open_titled_thread(&panel, &connection, "First", &mut cx);
+    send_tasks(
+        &first,
+        vec![wire_task("a", "running", false, 1_000)],
+        &mut cx,
+    );
+    let second = open_titled_thread(&panel, &connection, "Second", &mut cx);
+    send_tasks(
+        &second,
+        vec![wire_task("a", "running", false, 1_000)],
+        &mut cx,
+    );
+
+    let first_session = session_of(&first, &cx).to_string();
+    let second_session = session_of(&second, &cx).to_string();
+    assert_ne!(first_session, second_session);
+    assert_eq!(
+        starts(&seen),
+        vec![
+            (first_session, "a".to_string()),
+            (second_session, "a".to_string()),
+        ]
+    );
+}
+
+// Benign: the start carries what R1.1 records.
+#[gpui::test]
+async fn tasks_card_activity_started_carries_the_record_fields(cx: &mut TestAppContext) {
+    let (panel, mut cx) = setup_card_panel(cx).await;
+    let connection = TaskActionsConnection::new();
+    let thread = open_titled_thread(&panel, &connection, "Build thread", &mut cx);
+    let session = session_of(&thread, &cx);
+    let card = card(&panel, &cx);
+    let (seen, _subscription) = watch(&card, &mut cx);
+
+    let mut task = wire_task("sub-1", "running", true, 1_700_000_000_123);
+    task["type"] = json!("agent");
+    task["description"] = json!("Review the patch");
+    send_tasks(&thread, vec![task], &mut cx);
+
+    let events = seen.borrow().clone();
+    assert_eq!(events.len(), 1, "{events:?}");
+    let Seen::Started(record) = &events[0] else {
+        panic!("expected a start, got {events:?}");
+    };
+    assert_eq!(record.session_id, session);
+    assert_eq!(record.task_id.to_string(), "sub-1");
+    assert_eq!(
+        record.thread_title.as_ref().map(|title| title.to_string()),
+        Some("Build thread".to_string())
+    );
+    assert!(!record.project.is_empty(), "the project is recorded");
+    assert_eq!(record.kind.to_string(), "agent");
+    assert_eq!(record.description.to_string(), "Review the patch");
+    assert!(record.background);
+    assert_eq!(record.status, AgentTaskStatus::Running);
+    assert_eq!(record.started_at_ms, 1_700_000_000_123);
+    assert_eq!(record.ended_at_ms, None);
+}
+
+// R1.2: the end is emitted once, with its outcome; a repeated end snapshot emits nothing.
+#[gpui::test]
+async fn tasks_card_activity_emits_ended_once_with_the_outcome(cx: &mut TestAppContext) {
+    let (panel, mut cx) = setup_card_panel(cx).await;
+    let connection = TaskActionsConnection::new();
+    let thread = open_titled_thread(&panel, &connection, "Build thread", &mut cx);
+    let session = session_of(&thread, &cx);
+    let card = card(&panel, &cx);
+    let (seen, _subscription) = watch(&card, &mut cx);
+
+    send_tasks(
+        &thread,
+        vec![
+            wire_task("ok", "running", false, 1_000),
+            wire_task("ko", "running", false, 2_000),
+        ],
+        &mut cx,
+    );
+    let mut completed = wire_task("ok", "completed", false, 1_000);
+    completed["usage"] = json!({ "tokens": 1_200, "toolUses": 7, "durationMs": 1_000 });
+    send_tasks(
+        &thread,
+        vec![completed.clone(), wire_task("ko", "running", false, 2_000)],
+        &mut cx,
+    );
+    send_tasks(
+        &thread,
+        vec![completed, wire_task("ko", "failed", false, 2_000)],
+        &mut cx,
+    );
+
+    let ended = ends(&seen);
+    assert_eq!(ended.len(), 2, "one end per task: {ended:?}");
+    let ok = ended.iter().find(|r| r.task_id == "ok").unwrap();
+    assert_eq!(ok.session_id, session);
+    assert_eq!(ok.status, AgentTaskStatus::Completed);
+    assert_eq!(ok.ended_at_ms, Some(2_000));
+    assert_eq!(ok.tokens, Some(1_200));
+    assert_eq!(ok.tool_uses, Some(7));
+    let ko = ended.iter().find(|r| r.task_id == "ko").unwrap();
+    assert_eq!(ko.status, AgentTaskStatus::Failed);
+    assert_eq!(starts(&seen).len(), 2, "an end is not a new start");
+}
+
+// R1.4: progress-only changes (tokens, last tool, summary while running) emit nothing.
+#[gpui::test]
+async fn tasks_card_activity_progress_only_changes_emit_nothing(cx: &mut TestAppContext) {
+    let (panel, mut cx) = setup_card_panel(cx).await;
+    let connection = TaskActionsConnection::new();
+    let thread = open_titled_thread(&panel, &connection, "Build thread", &mut cx);
+    let card = card(&panel, &cx);
+    let (seen, _subscription) = watch(&card, &mut cx);
+
+    send_tasks(
+        &thread,
+        vec![wire_task("a", "running", false, 1_000)],
+        &mut cx,
+    );
+    for (tokens, tool, summary) in [
+        (100, "Bash", "Building"),
+        (900, "Read", "Linking"),
+        (4_000, "Edit", "Almost"),
+    ] {
+        send_tasks(
+            &thread,
+            vec![with_progress(
+                wire_task("a", "running", false, 1_000),
+                tokens,
+                tool,
+                summary,
+            )],
+            &mut cx,
+        );
+    }
+    assert_eq!(
+        seen.borrow().len(),
+        1,
+        "only the start: {:?}",
+        seen.borrow()
+    );
+}
+
+fn key_of(record: &TaskRecord) -> (String, String) {
+    (record.session_id.to_string(), record.task_id.to_string())
+}
+
+/// Every Ended has a Started for the same key earlier in the stream.
+fn assert_started_before_ended(events: &[Seen]) {
+    for (index, event) in events.iter().enumerate() {
+        if let Seen::Ended(ended) = event {
+            let started_earlier = events[..index].iter().any(|earlier| {
+                matches!(earlier, Seen::Started(started) if key_of(started) == key_of(ended))
+            });
+            assert!(
+                started_earlier,
+                "Ended {:?} before its Started: {events:?}",
+                key_of(ended)
+            );
+        }
+    }
+}
+
+// R1.8: a task first seen already ended yields Started then Ended, in that order, for each
+// such task of the snapshot.
+#[gpui::test]
+async fn tasks_card_activity_first_seen_ended_emits_started_then_ended(cx: &mut TestAppContext) {
+    let (panel, mut cx) = setup_card_panel(cx).await;
+    let connection = TaskActionsConnection::new();
+    let thread = open_titled_thread(&panel, &connection, "Quick thread", &mut cx);
+    let card = card(&panel, &cx);
+    let (seen, _subscription) = watch(&card, &mut cx);
+
+    let mut quick = wire_task("quick", "completed", false, 1_000);
+    quick["usage"] = json!({ "tokens": 50, "toolUses": 1, "durationMs": 1_000 });
+    send_tasks(
+        &thread,
+        vec![quick, wire_task("broken", "failed", false, 1_100)],
+        &mut cx,
+    );
+
+    let events = seen.borrow().clone();
+    assert_eq!(
+        events.len(),
+        4,
+        "a Started and an Ended per task: {events:?}"
+    );
+    assert_started_before_ended(&events);
+    let ended = ends(&seen);
+    let quick = ended.iter().find(|r| r.task_id == "quick").unwrap();
+    assert_eq!(quick.status, AgentTaskStatus::Completed);
+    assert_eq!(quick.ended_at_ms, Some(2_000));
+    assert_eq!(quick.tokens, Some(50));
+    let broken = ended.iter().find(|r| r.task_id == "broken").unwrap();
+    assert_eq!(broken.status, AgentTaskStatus::Failed);
+
+    send_tasks(
+        &thread,
+        vec![wire_task("quick", "completed", false, 1_000)],
+        &mut cx,
+    );
+    assert_eq!(
+        seen.borrow().len(),
+        4,
+        "a repeated ended snapshot emits nothing"
+    );
+}
+
+// R1.7: closing a thread with a running task ends it as interrupted; a closed thread with only
+// ended tasks emits nothing more; a kept thread holding the same task id emits nothing.
+#[gpui::test]
+async fn tasks_card_activity_closing_a_thread_interrupts_only_its_running_tasks(
+    cx: &mut TestAppContext,
+) {
+    let (panel, mut cx) = setup_card_panel(cx).await;
+    let connection = TaskActionsConnection::new();
+    let a = open_titled_thread(&panel, &connection, "A", &mut cx);
+    send_tasks(
+        &a,
+        vec![
+            wire_task("t1", "running", false, 1_000),
+            wire_task("t0", "running", false, 900),
+        ],
+        &mut cx,
+    );
+    send_tasks(
+        &a,
+        vec![
+            wire_task("t1", "running", false, 1_000),
+            wire_task("t0", "completed", false, 900),
+        ],
+        &mut cx,
+    );
+    let b = open_titled_thread(&panel, &connection, "B", &mut cx);
+    send_tasks(&b, vec![wire_task("t1", "running", false, 2_000)], &mut cx);
+    send_tasks(
+        &b,
+        vec![wire_task("t1", "completed", false, 2_000)],
+        &mut cx,
+    );
+    let c = open_titled_thread(&panel, &connection, "C", &mut cx);
+    send_tasks(&c, vec![wire_task("t1", "running", false, 3_000)], &mut cx);
+    let sa = session_of(&a, &cx);
+    let card = card(&panel, &cx);
+    let (seen, _subscription) = watch(&card, &mut cx);
+
+    // The panel now holds only C.
+    let c_view = panel.read_with(&cx, |panel, _| {
+        panel.active_conversation_view().cloned().unwrap()
+    });
+    card.update(&mut cx, |card, cx| {
+        card.sync_threads([&c_view].into_iter(), cx)
+    });
+    cx.run_until_parked();
+
+    let events = seen.borrow().clone();
+    assert_eq!(events.len(), 1, "only A's running task ends: {events:?}");
+    let Seen::Ended(interrupted) = &events[0] else {
+        panic!("expected an end, got {events:?}");
+    };
+    assert_eq!(interrupted.session_id, sa);
+    assert_eq!(interrupted.task_id.to_string(), "t1");
+    assert_eq!(interrupted.status, AgentTaskStatus::Interrupted);
+    assert!(
+        interrupted.ended_at_ms.is_some(),
+        "an interrupted task gets an end time"
+    );
+}
+
+// R1.9: the card records while disabled, while hidden (nothing running) and while minimized.
+#[gpui::test]
+async fn tasks_card_activity_keeps_emitting_while_disabled_hidden_or_minimized(
+    cx: &mut TestAppContext,
+) {
+    let (panel, mut cx) = setup_card_panel(cx).await;
+    let card = card(&panel, &cx);
+    let (seen, _subscription) = watch(&card, &mut cx);
+    assert!(!visible(&card, &mut cx), "hidden: no task runs yet");
+
+    cx.update(|_, cx| {
+        AgentSettings::override_global(
+            AgentSettings {
+                tasks_card: TasksCardSettings {
+                    enabled: false,
+                    position: TasksCardPosition::TopRight,
+                },
+                ..AgentSettings::get_global(cx).clone()
+            },
+            cx,
+        );
+    });
+    let connection = TaskActionsConnection::new();
+    let thread = open_titled_thread(&panel, &connection, "A", &mut cx);
+    send_tasks(
+        &thread,
+        vec![wire_task("while-disabled", "running", false, 1_000)],
+        &mut cx,
+    );
+    assert_eq!(starts(&seen).len(), 1, "a start while disabled is emitted");
+
+    cx.update(|_, cx| {
+        AgentSettings::override_global(
+            AgentSettings {
+                tasks_card: TasksCardSettings {
+                    enabled: true,
+                    position: TasksCardPosition::TopRight,
+                },
+                ..AgentSettings::get_global(cx).clone()
+            },
+            cx,
+        );
+    });
+    card.update(&mut cx, |card, cx| card.toggle_minimized(cx));
+    cx.run_until_parked();
+    send_tasks(
+        &thread,
+        vec![
+            wire_task("while-disabled", "completed", false, 1_000),
+            wire_task("while-minimized", "running", false, 2_000),
+        ],
+        &mut cx,
+    );
+    assert_eq!(starts(&seen).len(), 2, "a start while minimized is emitted");
+    assert_eq!(ends(&seen).len(), 1, "an end while minimized is emitted");
+}
+
+// R2.3 support: `live_task` reads the running task by (session, task); another session's
+// same id, or an unknown session, is None.
+#[gpui::test]
+async fn tasks_card_activity_live_task_reads_by_session_and_task(cx: &mut TestAppContext) {
+    let (panel, mut cx) = setup_card_panel(cx).await;
+    let connection = TaskActionsConnection::new();
+    let a = open_titled_thread(&panel, &connection, "A", &mut cx);
+    let mut a_t1 = wire_task("t1", "running", false, 1_000);
+    a_t1["description"] = json!("A's t1");
+    send_tasks(&a, vec![a_t1], &mut cx);
+    let b = open_titled_thread(&panel, &connection, "B", &mut cx);
+    let mut b_t1 = wire_task("t1", "running", false, 2_000);
+    b_t1["description"] = json!("B's t1");
+    send_tasks(
+        &b,
+        vec![b_t1, wire_task("only-b", "running", false, 2_100)],
+        &mut cx,
+    );
+    let sa = session_of(&a, &cx);
+    let sb = session_of(&b, &cx);
+    let card = card(&panel, &cx);
+
+    let live = |session: &acp_v2::SessionId, task: &str, cx: &mut VisualTestContext| {
+        let id = AgentTaskId::from(task.to_string());
+        card.read_with(cx, |card, cx| card.live_task(session, &id, cx))
+    };
+    assert_eq!(
+        live(&sa, "t1", &mut cx).map(|task| task.description.to_string()),
+        Some("A's t1".to_string())
+    );
+    assert_eq!(
+        live(&sb, "t1", &mut cx).map(|task| task.description.to_string()),
+        Some("B's t1".to_string())
+    );
+    assert!(
+        live(&sa, "only-b", &mut cx).is_none(),
+        "another session's task id"
+    );
+    assert!(
+        live(
+            &acp_v2::SessionId::new("unknown-session".to_string()),
+            "t1",
+            &mut cx
+        )
+        .is_none()
+    );
+}
+
+// R1.10 (refine v2): one task ending that reaches the card through two views of one session
+// is one `Ended`. Two views share one `AcpThread` because `AcpConnection` hands the thread it
+// already holds to a second caller loading the same session id; the card subscribes to that
+// thread once per view, so it hears each `TaskEnded` twice (story 013 measured this and
+// dedupes it for the tray). The connection below copies 013's `tray/dedupe_tests.rs`
+// fixture, which is private to that module.
+//
+// Every ending here is `completed` and unwatched, so `tray::should_notify` is false and no
+// datagram can reach a live `zeo-systray` — no `ENV_LOCK` needed (it is private to `tray`).
+mod two_views {
+    use std::{
+        any::Any,
+        collections::HashMap,
+        rc::Rc,
+        sync::{Arc, Mutex},
+    };
+
+    use acp_thread::{AcpThread, AgentConnection, AgentTaskStatus};
+    use agent_client_protocol::v1::schema as acp;
+    use agent_client_protocol::v2::schema as acp_v2;
+    use anyhow::{Result, anyhow};
+    use gpui::{
+        App, AppContext as _, Entity, SharedString, Task, TestAppContext, VisualTestContext,
+        WeakEntity,
+    };
+    use project::{AgentId, Project};
+    use workspace::{PathList, Workspace};
+
+    use super::{ends, starts, watch};
+    use crate::agent_connection_store::AgentConnectionStore;
+    use crate::tasks_card::TasksCard;
+    use crate::tasks_card::test_helpers::*;
+    use crate::test_support::{StubAgentServer, open_thread_with_custom_connection};
+    use crate::{Agent, AgentPanel, AgentThreadSource, ConversationView};
+
+    /// `TaskActionsConnection`, plus the session reuse of `AcpConnection`: loading a
+    /// session that is already open returns the thread that holds it.
+    #[derive(Clone)]
+    struct SharedSessionConnection {
+        inner: TaskActionsConnection,
+        threads: Arc<Mutex<HashMap<acp_v2::SessionId, WeakEntity<AcpThread>>>>,
+    }
+
+    impl SharedSessionConnection {
+        fn new() -> Self {
+            Self {
+                inner: TaskActionsConnection::new(),
+                threads: Arc::default(),
+            }
+        }
+    }
+
+    impl AgentConnection for SharedSessionConnection {
+        fn agent_id(&self) -> AgentId {
+            self.inner.agent_id()
+        }
+
+        fn telemetry_id(&self) -> SharedString {
+            self.inner.telemetry_id()
+        }
+
+        fn new_session(
+            self: Rc<Self>,
+            project: Entity<Project>,
+            work_dirs: PathList,
+            cx: &mut App,
+        ) -> Task<Result<Entity<AcpThread>>> {
+            let created = Rc::new(self.inner.clone()).new_session(project, work_dirs, cx);
+            let threads = self.threads.clone();
+            cx.spawn(async move |cx| {
+                let thread = created.await?;
+                let session_id = cx.update(|cx| thread.read(cx).session_id().clone());
+                threads
+                    .lock()
+                    .map_err(|_| anyhow!("thread registry poisoned"))?
+                    .insert(session_id, thread.downgrade());
+                Ok(thread)
+            })
+        }
+
+        fn supports_load_session(&self) -> bool {
+            true
+        }
+
+        fn load_session(
+            self: Rc<Self>,
+            session_id: acp_v2::SessionId,
+            _project: Entity<Project>,
+            _work_dirs: PathList,
+            _title: Option<SharedString>,
+            _cx: &mut App,
+        ) -> Task<Result<Entity<AcpThread>>> {
+            let held = self
+                .threads
+                .lock()
+                .ok()
+                .and_then(|threads| threads.get(&session_id).and_then(WeakEntity::upgrade));
+            Task::ready(held.ok_or_else(|| anyhow!("session {session_id} is not open")))
+        }
+
+        fn auth_methods(&self) -> &[agent_client_protocol::v2::schema::AuthMethod] {
+            &[]
+        }
+
+        fn authenticate(
+            &self,
+            method: agent_client_protocol::v2::schema::AuthMethodId,
+            cx: &mut App,
+        ) -> Task<Result<()>> {
+            self.inner.authenticate(method, cx)
+        }
+
+        fn prompt(
+            &self,
+            params: agent_client_protocol::v2::schema::PromptRequest,
+            cx: &mut App,
+        ) -> Task<Result<acp::PromptResponse>> {
+            self.inner.prompt(params, cx)
+        }
+
+        fn cancel(&self, session_id: &acp_v2::SessionId, cx: &mut App) -> Result<()> {
+            self.inner.cancel(session_id, cx)
+        }
+
+        fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+            self
+        }
+    }
+
+    /// Opens a new thread on `connection` in the panel, titles it, returns its root thread.
+    fn open_shared_titled_thread(
+        panel: &Entity<AgentPanel>,
+        connection: &SharedSessionConnection,
+        title: &str,
+        cx: &mut VisualTestContext,
+    ) -> Entity<AcpThread> {
+        open_thread_with_custom_connection(panel, connection.clone(), cx);
+        let thread = panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap());
+        send_info(&thread, acp::SessionInfoUpdate::new().title(title), cx);
+        thread
+    }
+
+    /// A second conversation view, outside the panel, that loads the open `session_id`.
+    fn second_view_of(
+        session_id: &acp_v2::SessionId,
+        connection: &SharedSessionConnection,
+        workspace: &Entity<Workspace>,
+        cx: &mut VisualTestContext,
+    ) -> Entity<ConversationView> {
+        let project = workspace.read_with(cx, |workspace, _| workspace.project().clone());
+        let view = cx.update(|window, cx| {
+            let connection_store = cx.new(|cx| AgentConnectionStore::new(project.clone(), cx));
+            cx.new(|cx| {
+                ConversationView::new(
+                    Rc::new(StubAgentServer::new(connection.clone()).with_connection_agent_id()),
+                    connection_store,
+                    Agent::Custom {
+                        id: connection.agent_id(),
+                    },
+                    Some(session_id.clone()),
+                    None,
+                    None,
+                    None,
+                    None,
+                    workspace.downgrade(),
+                    project,
+                    None,
+                    AgentThreadSource::AgentPanel,
+                    window,
+                    cx,
+                )
+            })
+        });
+        cx.run_until_parked();
+        view
+    }
+
+    fn root_thread_of(
+        view: &Entity<ConversationView>,
+        cx: &VisualTestContext,
+    ) -> Entity<AcpThread> {
+        view.read_with(cx, |view, cx| view.root_thread(cx))
+            .expect("the view holds a root thread")
+    }
+
+    fn active_view(panel: &Entity<AgentPanel>, cx: &VisualTestContext) -> Entity<ConversationView> {
+        panel
+            .read_with(cx, |panel, _| panel.active_conversation_view().cloned())
+            .expect("the panel shows a conversation")
+    }
+
+    /// How many of the card's held views hold `thread`.
+    fn times_held(
+        card: &Entity<TasksCard>,
+        thread: &Entity<AcpThread>,
+        cx: &VisualTestContext,
+    ) -> usize {
+        card.read_with(cx, |card, _| {
+            card.views
+                .iter()
+                .filter_map(|held| held.thread.as_ref())
+                .filter(|held| held.thread_id == thread.entity_id())
+                .count()
+        })
+    }
+
+    fn ended_keys(seen: &Rc<std::cell::RefCell<Vec<super::Seen>>>) -> Vec<(String, String)> {
+        ends(seen).iter().map(super::key_of).collect()
+    }
+
+    // Hostile (wrong split), the rule itself: one ending heard through two views is one Ended.
+    // Hostile (wrong collapse), both converse fixtures of the dedupe key: the same task id
+    // ending in ANOTHER session still ends (a key without the session would swallow it), and
+    // ANOTHER task of the shared session still ends (a key without the task id would swallow it).
+    #[gpui::test]
+    async fn tasks_card_activity_one_ending_through_two_views_emits_one_ended(
+        cx: &mut TestAppContext,
+    ) {
+        let (panel, workspace, mut cx) = setup_card_panel_in_workspace(cx).await;
+        let card = card(&panel, &cx);
+        let (seen, _subscription) = watch(&card, &mut cx);
+        let connection = SharedSessionConnection::new();
+
+        // The third element: another session, held by the panel, with the same task id.
+        let other = open_shared_titled_thread(&panel, &connection, "Other", &mut cx);
+        send_tasks(
+            &other,
+            vec![wire_task("t1", "running", false, 500)],
+            &mut cx,
+        );
+        let other_session = session_of(&other, &cx);
+
+        // The shared session, held through the panel's view and a second view.
+        let shared = open_shared_titled_thread(&panel, &connection, "Shared", &mut cx);
+        let session = session_of(&shared, &cx);
+        assert_ne!(session, other_session, "two sessions");
+        let first = active_view(&panel, &cx);
+        let second = second_view_of(&session, &connection, &workspace, &mut cx);
+        assert_ne!(first.entity_id(), second.entity_id(), "two views");
+        assert_eq!(
+            root_thread_of(&second, &cx).entity_id(),
+            shared.entity_id(),
+            "the second view shares the first view's thread"
+        );
+        let mut views = panel.read_with(&cx, |panel, _| panel.conversation_views());
+        views.push(second);
+        card.update(&mut cx, |card, cx| card.sync_threads(views.iter(), cx));
+        cx.run_until_parked();
+        assert_eq!(
+            times_held(&card, &shared, &cx),
+            2,
+            "the card holds the shared thread twice"
+        );
+        assert_eq!(
+            times_held(&card, &other, &cx),
+            1,
+            "the card holds the other thread"
+        );
+
+        send_tasks(
+            &shared,
+            vec![
+                wire_task("t1", "running", false, 1_000),
+                wire_task("t2", "running", false, 1_100),
+            ],
+            &mut cx,
+        );
+        let other_key = (other_session.to_string(), "t1".to_string());
+        let shared_t1 = (session.to_string(), "t1".to_string());
+        let shared_t2 = (session.to_string(), "t2".to_string());
+        assert_eq!(
+            starts(&seen),
+            vec![other_key.clone(), shared_t1.clone(), shared_t2.clone()],
+            "one Started per task, however many views hear its snapshot"
+        );
+
+        // The other session's t1 ends first, so a session-blind key would be taken already.
+        send_tasks(
+            &other,
+            vec![wire_task("t1", "completed", false, 500)],
+            &mut cx,
+        );
+        assert_eq!(ended_keys(&seen), vec![other_key.clone()]);
+
+        // The shared t1 ends once; both views deliver it.
+        assert_eq!(
+            times_held(&card, &shared, &cx),
+            2,
+            "still held through both views"
+        );
+        send_tasks(
+            &shared,
+            vec![
+                wire_task("t1", "completed", false, 1_000),
+                wire_task("t2", "running", false, 1_100),
+            ],
+            &mut cx,
+        );
+        cx.run_until_parked();
+        assert_eq!(
+            ended_keys(&seen),
+            vec![other_key.clone(), shared_t1.clone()],
+            "one ending through two views is one Ended, and another session's same id is apart"
+        );
+        let shared_end = ends(&seen)
+            .into_iter()
+            .find(|record| super::key_of(record) == shared_t1)
+            .unwrap();
+        assert_eq!(shared_end.session_id, session);
+        assert_eq!(shared_end.status, AgentTaskStatus::Completed);
+
+        // Another task of the shared session still ends, once.
+        send_tasks(
+            &shared,
+            vec![
+                wire_task("t1", "completed", false, 1_000),
+                wire_task("t2", "completed", false, 1_100),
+            ],
+            &mut cx,
+        );
+        cx.run_until_parked();
+        assert_eq!(
+            times_held(&card, &shared, &cx),
+            2,
+            "both views held to the end"
+        );
+        assert_eq!(
+            ended_keys(&seen),
+            vec![other_key, shared_t1, shared_t2],
+            "one Ended per ending: {:?}",
+            ends(&seen)
+        );
+        assert_eq!(starts(&seen).len(), 3, "an ending is not a new start");
+    }
+}
diff --git a/crates/agent_ui/src/tasks_card/title_tests.rs b/crates/agent_ui/src/tasks_card/title_tests.rs
new file mode 100644
index 0000000..bb346d0
--- /dev/null
+++ b/crates/agent_ui/src/tasks_card/title_tests.rs
@@ -0,0 +1,198 @@
+// Story 014, task 6.1 fix — the card announces each held thread's title, so the task history
+// can move a thread's records to it (`ThreadTitleChanged`). Declared in `src/tasks_card.rs`:
+//
+//     #[cfg(test)]
+//     #[path = "tasks_card/title_tests.rs"]
+//     mod title;
+//
+// so its path carries the filter `tasks_card::title`. No test here ends a task in a thread
+// out of view, so none reaches the tray.
+
+use std::{cell::RefCell, rc::Rc};
+
+use acp_thread::{AcpThread, AgentTaskStatus};
+use agent_client_protocol::v1::schema as acp;
+use gpui::{Entity, SharedString, Subscription, TestAppContext, VisualTestContext};
+
+use super::test_helpers::*;
+use super::{TaskActivity, TaskRecord, TasksCard, ThreadTitleChanged};
+use crate::AgentPanel;
+use crate::test_support::open_thread_with_custom_connection;
+
+const PROMPT: &str = "with the bash tool and run_in_background true, start one long sleep";
+const GENERATED: &str = "Background sleep command";
+
+fn watch_titles(
+    card: &Entity<TasksCard>,
+    cx: &mut VisualTestContext,
+) -> (Rc<RefCell<Vec<(String, String)>>>, Subscription) {
+    let seen = Rc::new(RefCell::new(Vec::new()));
+    let sink = seen.clone();
+    let subscription = cx.update(|_, cx| {
+        cx.subscribe(card, move |_, change: &ThreadTitleChanged, _| {
+            sink.borrow_mut()
+                .push((change.session_id.to_string(), change.title.to_string()))
+        })
+    });
+    (seen, subscription)
+}
+
+fn watch_endings(
+    card: &Entity<TasksCard>,
+    cx: &mut VisualTestContext,
+) -> (Rc<RefCell<Vec<TaskRecord>>>, Subscription) {
+    let seen = Rc::new(RefCell::new(Vec::new()));
+    let sink = seen.clone();
+    let subscription = cx.update(|_, cx| {
+        cx.subscribe(card, move |_, activity: &TaskActivity, _| {
+            if let TaskActivity::Ended(record) = activity {
+                sink.borrow_mut().push(record.clone());
+            }
+        })
+    });
+    (seen, subscription)
+}
+
+/// A new thread still without a title, as the panel holds it before the first prompt.
+fn open_untitled_thread(
+    panel: &Entity<AgentPanel>,
+    connection: &TaskActionsConnection,
+    cx: &mut VisualTestContext,
+) -> Entity<AcpThread> {
+    open_thread_with_custom_connection(panel, connection.clone(), cx);
+    cx.run_until_parked();
+    panel.read_with(cx, |panel, cx| panel.active_agent_thread(cx).unwrap())
+}
+
+fn set_provisional(thread: &Entity<AcpThread>, title: &str, cx: &mut VisualTestContext) {
+    let title = SharedString::from(title.to_string());
+    thread.update(cx, |thread, cx| thread.set_provisional_title(title, cx));
+    cx.run_until_parked();
+}
+
+fn set_title(thread: &Entity<AcpThread>, title: &str, cx: &mut VisualTestContext) {
+    send_info(thread, acp::SessionInfoUpdate::new().title(title), cx);
+}
+
+// Hostile (wrong collapse): the prompt and the generated title are two announcements.
+// Hostile (wrong split): an update that leaves the title as it was announces nothing.
+#[gpui::test]
+async fn tasks_card_title_announces_each_distinct_title_of_a_thread_once(cx: &mut TestAppContext) {
+    let (panel, mut cx) = setup_card_panel(cx).await;
+    let connection = TaskActionsConnection::new();
+    let card = card(&panel, &cx);
+    let (seen, _subscription) = watch_titles(&card, &mut cx);
+    let thread = open_untitled_thread(&panel, &connection, &mut cx);
+    let session = session_of(&thread, &cx).to_string();
+    assert!(
+        seen.borrow().is_empty(),
+        "a thread without a title announces nothing: {:?}",
+        seen.borrow()
+    );
+
+    set_provisional(&thread, PROMPT, &mut cx);
+    set_title(&thread, GENERATED, &mut cx);
+    // `TitleUpdated` again, the title unchanged: the generated one outranks the provisional.
+    set_provisional(&thread, "a later preview", &mut cx);
+    set_title(&thread, GENERATED, &mut cx);
+
+    assert_eq!(
+        *seen.borrow(),
+        vec![
+            (session.clone(), PROMPT.to_string()),
+            (session, GENERATED.to_string()),
+        ]
+    );
+}
+
+// A thread the card starts holding already titled is announced at once, and listed by
+// `thread_titles` for a listener that registers later; a thread held again is announced
+// again, since what was stored for it may be older.
+#[gpui::test]
+async fn tasks_card_title_announces_a_thread_when_it_is_held(cx: &mut TestAppContext) {
+    let (panel, mut cx) = setup_card_panel(cx).await;
+    let connection = TaskActionsConnection::new();
+    let card = card(&panel, &cx);
+    let a = open_titled_thread(&panel, &connection, "A", &mut cx);
+    let a_view = panel.read_with(&cx, |panel, _| {
+        panel.active_conversation_view().cloned().unwrap()
+    });
+    let b = open_titled_thread(&panel, &connection, "B", &mut cx);
+    let b_view = panel.read_with(&cx, |panel, _| {
+        panel.active_conversation_view().cloned().unwrap()
+    });
+    let (sa, sb) = (session_of(&a, &cx), session_of(&b, &cx));
+
+    let mut titles: Vec<(String, String)> = card.read_with(&cx, |card, cx| {
+        card.thread_titles(cx)
+            .into_iter()
+            .map(|change| (change.session_id.to_string(), change.title.to_string()))
+            .collect()
+    });
+    titles.sort();
+    assert_eq!(
+        titles,
+        vec![
+            (sa.to_string(), "A".to_string()),
+            (sb.to_string(), "B".to_string()),
+        ]
+    );
+
+    let (seen, _subscription) = watch_titles(&card, &mut cx);
+    card.update(&mut cx, |card, cx| {
+        card.sync_threads([&b_view].into_iter(), cx)
+    });
+    cx.run_until_parked();
+    assert!(
+        seen.borrow().is_empty(),
+        "letting a thread go announces nothing: {:?}",
+        seen.borrow()
+    );
+    card.update(&mut cx, |card, cx| {
+        card.sync_threads([&b_view, &a_view].into_iter(), cx)
+    });
+    cx.run_until_parked();
+    assert_eq!(
+        *seen.borrow(),
+        vec![(sa.to_string(), "A".to_string())],
+        "only the thread held again is announced again"
+    );
+}
+
+// A task still running when its thread closes ends as interrupted under the thread's newest
+// title, not the one it was first recorded under. The title arrives after the last sync that
+// held the thread, so no later snapshot of its tasks refreshes the kept record.
+#[gpui::test]
+async fn tasks_card_title_reaches_the_interrupted_ending_of_a_closed_thread(
+    cx: &mut TestAppContext,
+) {
+    let (panel, mut cx) = setup_card_panel(cx).await;
+    let connection = TaskActionsConnection::new();
+    let card = card(&panel, &cx);
+    let thread = open_untitled_thread(&panel, &connection, &mut cx);
+    set_provisional(&thread, PROMPT, &mut cx);
+    send_tasks(
+        &thread,
+        vec![wire_task("t1", "running", true, 1_000)],
+        &mut cx,
+    );
+    let other = open_titled_thread(&panel, &connection, "Other", &mut cx);
+    let other_view = panel.read_with(&cx, |panel, _| {
+        panel.active_conversation_view().cloned().unwrap()
+    });
+    set_title(&thread, GENERATED, &mut cx);
+    let session = session_of(&thread, &cx);
+    assert_ne!(session, session_of(&other, &cx));
+
+    let (endings, _subscription) = watch_endings(&card, &mut cx);
+    card.update(&mut cx, |card, cx| {
+        card.sync_threads([&other_view].into_iter(), cx)
+    });
+    cx.run_until_parked();
+
+    let endings = endings.borrow().clone();
+    assert_eq!(endings.len(), 1, "{endings:?}");
+    assert_eq!(endings[0].session_id, session);
+    assert_eq!(endings[0].status, AgentTaskStatus::Interrupted);
+    assert_eq!(endings[0].thread_title.as_deref(), Some(GENERATED));
+}
diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml
index 9e9ea59..54267c1 100644
--- a/crates/zed/Cargo.toml
+++ b/crates/zed/Cargo.toml
@@ -74,6 +74,7 @@ agent_servers = { workspace = true, optional = true }
 agent_settings.workspace = true
 agent_skills.workspace = true
 agent_ui = { workspace = true, features = ["audio"] }
+agent_tasks_panel.workspace = true
 anyhow.workspace = true
 askpass.workspace = true
 assets.workspace = true
diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs
index d7b112f..1c52bfc 100644
--- a/crates/zed/src/main.rs
+++ b/crates/zed/src/main.rs
@@ -722,6 +722,7 @@ fn main() {
             false,
             cx,
         );
+        agent_tasks_panel::init(cx);
         zed::watch_user_agents_md(app_state.fs.clone(), cx);
 
         repl::init(app_state.fs.clone(), cx);
diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs
index 081d193..cc427d6 100644
--- a/crates/zed/src/zed.rs
+++ b/crates/zed/src/zed.rs
@@ -783,6 +783,8 @@ fn initialize_panels(window: &mut Window, cx: &mut Context<Workspace>) -> Task<a
         let git_panel = GitPanel::load(workspace_handle.clone(), cx.clone());
         let channels_panel =
             collab_ui::collab_panel::CollabPanel::load(workspace_handle.clone(), cx.clone());
+        let agent_tasks_panel =
+            agent_tasks_panel::AgentTasksPanel::load(workspace_handle.clone(), cx.clone());
         let debug_panel = DebugPanel::load(workspace_handle.clone(), cx);
 
         async fn add_panel_when_ready(
@@ -807,6 +809,7 @@ fn initialize_panels(window: &mut Window, cx: &mut Context<Workspace>) -> Task<a
             add_panel_when_ready(git_panel, workspace_handle.clone(), cx.clone()),
             add_panel_when_ready(channels_panel, workspace_handle.clone(), cx.clone()),
             add_panel_when_ready(debug_panel, workspace_handle.clone(), cx.clone()),
+            add_panel_when_ready(agent_tasks_panel, workspace_handle.clone(), cx.clone()),
             initialize_agent_panel(workspace_handle.clone(), cx.clone()).map(|r| r.log_err()),
         );
 
@@ -6326,6 +6329,7 @@ mod tests {
                 "action",
                 "activity_indicator",
                 "agent",
+                "agent_tasks_panel",
                 "agents_sidebar",
                 "app_menu",
                 "assistant",
