feat(chats): add archive and restore (#5236)

* feat(chats): add archive and restore

* test(chats): observe archive search requests in pagination e2e

* docs(gateway): move thread lifecycle details out of inherited guidance

* docs(chats): add concise archive and restore RFC

* docs(chats): move archive RFC discussion to issue 5237
This commit is contained in:
Ryker_Feng 2026-09-06 22:30:26 +08:00 committed by GitHub
parent e3df6ea4a8
commit 98b8e4657e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1038 additions and 109 deletions

View File

@ -1185,6 +1185,12 @@ DEERFLOW_LANGGRAPH_URL=http://localhost:2026/api/langgraph # LangGraph API
See [`skills/public/claude-to-deerflow/SKILL.md`](skills/public/claude-to-deerflow/SKILL.md) for the full API reference.
### Chat Archive
Use **Archive chat** in a recent chat's sidebar menu to hide completed work while keeping its messages, files, and original link. The success message offers **Undo**. Open **Chats → Archived** to find archived conversations and restore them individually; an open archived conversation also shows a restore button in its header. Search filters the titles of loaded conversations, with **Load more** for older entries.
Archive and restore preserve the chat's activity time and pinned state. Archiving does not stop a running task or pause its schedules, and new activity does not automatically restore it. Use the existing Delete action when you intend to remove a conversation and its files.
### Session Goals
Use `/goal <completion condition>` to attach one active completion condition to the current thread. The goal is thread-scoped state, not a skill activation, so it stays active across turns until DeerFlow determines it has been satisfied or you clear it.

View File

@ -684,6 +684,12 @@ DEERFLOW_LANGGRAPH_URL=http://localhost:2026/api/langgraph # LangGraph API
Web UI 输入框支持浏览器侧语音听写。浏览器提供 Web Speech API 时麦克风按钮会把语音转写为本地草稿DeerFlow 只接收转写后的文本,音频处理交由浏览器或操作系统语音识别服务按其环境策略完成。用户可以在发送前继续检查和编辑文本。
### 会话归档
在侧栏最近会话的菜单中点击「归档」,可以隐藏已完成的会话,同时保留消息、文件和原链接。成功提示提供「撤销」。在「对话 → 已归档」中查看并逐条恢复;已打开的归档会话也会在顶部显示恢复入口。搜索匹配已加载会话的标题,较早记录可通过「加载更多」查找。
归档与恢复保留会话原有的活动时间和置顶状态。归档不会停止运行中的任务或暂停定时任务,新消息也不会自动恢复会话。需要移除会话及其文件时,使用原有的删除操作。
### Session Goals
`/goal <完成条件>` 为当前 thread 绑定一个激活态的完成条件。这个 goal 是 thread 维度的状态,而不是技能激活,所以它会跨轮次持续生效,直到 DeerFlow 判定它已被满足、或者你手动清除它。

View File

@ -134,26 +134,7 @@ startup gate rejects process-local memory and JSONL event stores when
Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runtime, all other `/api/*` → Gateway REST APIs.
**Branch/regenerate checkpoint invariant**: `app/gateway/checkpoint_lineage.py`
walks `parent_config` rather than globally ordered checkpoint history so replay
anchors stay on the selected lineage after regenerations create sibling branches.
New conversation branches persist the pre-user replay anchor before their visible
head through the state mutation graph, which preserves materialized state in both
full and delta checkpoint modes. Only an explicitly absent legacy parent link may
use chronological compatibility lookup; cycles, dangling links, and depth-limit
exhaustion fail closed. Existing single-checkpoint branches are never repaired by
copying a raw checkpoint because delta state is not self-contained in one tuple.
Both lookups additionally require the replay base to be a **settled** checkpoint
(`has_pending_tasks` — no scheduled `next` tasks). A checkpoint with pending tasks
is a mid-run snapshot: resuming from it replays the writes of the node that was
about to run. Message ids alone cannot exclude those, because middleware may
rewrite a message's id inside the run that produced it — `DynamicContextMiddleware`
moves the first user turn to `{id}__user` and gives `{id}` to the injected
reminder, so every checkpoint written before it holds the same prompt under an
unmatched id. Selecting one of those re-added the original prompt *after* the
edited one, and the model answered the question the edit was replacing (#4531).
`next` is not derivable on the degraded raw-checkpoint read path, which reports no
tasks; absence of evidence stays permissive there rather than failing closed.
Edit replay resolves its base through the same lineage-first path as regenerate;
it must pass `head_checkpoint` or it silently degrades to the chronological scan
that cannot tell sibling branches apart.
**Thread lifecycle**: Before changing branching, regeneration, edit replay, or
archive/search behavior, read [Thread lifecycle invariants](../../docs/THREAD_LIFECYCLE.md).
It owns lineage and settled-checkpoint rules, legacy fallback boundaries, archive
filtering before pagination, owner isolation, and activity-time preservation.

View File

@ -46,7 +46,7 @@ from app.gateway.utils import sanitize_log_param
from deerflow.agents.thread_state import THREAD_STATE_REDUCER_FIELDS
from deerflow.config.paths import Paths, get_paths
from deerflow.config.summarization_config import ContextSize
from deerflow.persistence.thread_meta import THREAD_PINNED_METADATA_KEY
from deerflow.persistence.thread_meta import THREAD_ARCHIVED_METADATA_KEY, THREAD_PINNED_METADATA_KEY
from deerflow.runtime import ThreadOperationKind, serialize_channel_values_for_api
from deerflow.runtime.checkpoint_mode import CheckpointModeMismatchError, CheckpointModeReconfigurationError
from deerflow.runtime.checkpoint_state import graph_reducer_channels, graph_state_schema, graph_writable_channels
@ -137,9 +137,9 @@ def _strip_reserved_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]:
return {k: v for k, v in metadata.items() if k not in _SERVER_RESERVED_METADATA_KEYS}
def _is_pin_metadata_patch(metadata: dict[str, Any]) -> bool:
"""Return True for the narrow pin/unpin PATCH shape."""
return set(metadata) == {THREAD_PINNED_METADATA_KEY} and isinstance(metadata.get(THREAD_PINNED_METADATA_KEY), bool)
def _is_organization_metadata_patch(metadata: dict[str, Any]) -> bool:
"""Recognize list-organization writes that must preserve activity time."""
return bool(metadata) and set(metadata) <= {THREAD_PINNED_METADATA_KEY, THREAD_ARCHIVED_METADATA_KEY} and all(isinstance(value, bool) for value in metadata.values())
def _message_id(message: Any) -> str | None:
@ -451,6 +451,7 @@ class ThreadCreateRequest(BaseModel):
class ThreadSearchRequest(BaseModel):
"""Request body for searching threads."""
archived: bool | None = Field(default=None, strict=True, description="Archive filter; omitted includes all, false includes legacy unarchived threads")
metadata: dict[str, Any] = Field(default_factory=dict, description="Metadata filter (exact match)")
limit: int = Field(default=100, ge=1, le=1000, description="Maximum results")
offset: int = Field(default=0, ge=0, description="Pagination offset")
@ -499,6 +500,13 @@ class ThreadPatchRequest(BaseModel):
_strip_reserved = field_validator("metadata")(classmethod(lambda cls, v: _strip_reserved_metadata(v)))
@field_validator("metadata")
@classmethod
def validate_archive_flag(cls, value: dict[str, Any]) -> dict[str, Any]:
if THREAD_ARCHIVED_METADATA_KEY in value and not isinstance(value[THREAD_ARCHIVED_METADATA_KEY], bool):
raise ValueError("deerflow_archived must be a boolean")
return value
class ThreadStateUpdateRequest(BaseModel):
"""Request body for updating thread state (human-in-the-loop resume)."""
@ -1083,6 +1091,7 @@ async def search_threads(body: ThreadSearchRequest, request: Request) -> list[Th
rows = await repo.search(
metadata=body.metadata or None,
status=body.status,
**({"archived": body.archived} if body.archived is not None else {}),
limit=body.limit,
offset=body.offset,
)
@ -1117,10 +1126,10 @@ async def patch_thread(thread_id: ThreadId, body: ThreadPatchRequest, request: R
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
# ``body.metadata`` already stripped by ``ThreadPatchRequest._strip_reserved``.
# Pin/unpin is not conversation activity, so it must not bump ``updated_at``.
# Pin/unpin and archive/restore are not conversation activity, so it must not bump ``updated_at``.
# Other metadata PATCH callers keep the public endpoint's existing recency
# contract unless they get their own explicit no-touch API surface.
touch = not _is_pin_metadata_patch(body.metadata)
touch = not _is_organization_metadata_patch(body.metadata)
try:
await thread_store.update_metadata(thread_id, body.metadata, touch=touch)
except Exception:

View File

@ -1216,3 +1216,18 @@ curl -X POST http://localhost:2026/api/langgraph/threads/abc123/runs/stream \
> `config.recursion_limit` explicitly — see the [Create Run](#create-run)
> section for details. Scheduled-task launches use
> `scheduler.recursion_limit` from `config.yaml` instead of a client body.
## Chat archive and restore
`POST /api/threads/search` accepts `archived: true` for archived chats or
`archived: false` for recent chats (including legacy rows without an archive flag).
Omit the field or use null to include both. Filtering applies before `limit` and
`offset` and is scoped to the authenticated user. Combine it with the existing
`metadata` and `status` filters when needed.
Archive with `PATCH /api/threads/{thread_id}` and body
`{"metadata":{"deerflow_archived":true}}`; use false to restore. The flag must be
a JSON boolean. Writes containing only boolean pin/archive flags preserve
`updated_at` and all other metadata. The owner-checked endpoint returns the normal
thread metadata response; original thread and artifact URLs remain available.
Archiving does not cancel runs, pause schedules, or change retention.

View File

@ -0,0 +1,38 @@
# Gateway thread lifecycle invariants
Read this guide before changing thread branching, regeneration, edit replay, or
archive/search metadata behavior. It supplements the Gateway module guide.
**Branch/regenerate checkpoint invariant**: `app/gateway/checkpoint_lineage.py`
walks `parent_config` rather than globally ordered checkpoint history so replay
anchors stay on the selected lineage after regenerations create sibling branches.
New conversation branches persist the pre-user replay anchor before their visible
head through the state mutation graph, which preserves materialized state in both
full and delta checkpoint modes. Only an explicitly absent legacy parent link may
use chronological compatibility lookup; cycles, dangling links, and depth-limit
exhaustion fail closed. Existing single-checkpoint branches are never repaired by
copying a raw checkpoint because delta state is not self-contained in one tuple.
Both lookups additionally require the replay base to be a **settled** checkpoint
(`has_pending_tasks` — no scheduled `next` tasks). A checkpoint with pending tasks
is a mid-run snapshot: resuming from it replays the writes of the node that was
about to run. Message ids alone cannot exclude those, because middleware may
rewrite a message's id inside the run that produced it — `DynamicContextMiddleware`
moves the first user turn to `{id}__user` and gives `{id}` to the injected
reminder, so every checkpoint written before it holds the same prompt under an
unmatched id. Selecting one of those re-added the original prompt *after* the
edited one, and the model answered the question the edit was replacing (#4531).
`next` is not derivable on the degraded raw-checkpoint read path, which reports no
tasks; absence of evidence stays permissive there rather than failing closed.
Edit replay resolves its base through the same lineage-first path as regenerate;
it must pass `head_checkpoint` or it silently degrades to the chronological scan
that cannot tell sibling branches apart.
### Chat archive
`POST /api/threads/search` accepts an optional strict boolean `archived`: omitted
or null preserves the unfiltered API, true selects only JSON boolean
`metadata.deerflow_archived=true`, and false includes missing/null/non-true legacy
flags. Both SQL and Memory thread stores filter before limit/offset and retain
owner isolation. PATCH validates archive flags as booleans; pin/archive-only
boolean metadata writes use `touch=False` to preserve activity ordering. Archive
never changes runtime status, checkpoints, files, schedules, or read permissions.

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from deerflow.persistence.thread_meta.base import THREAD_PINNED_METADATA_KEY, InvalidMetadataFilterError, ThreadMetaStore
from deerflow.persistence.thread_meta.base import THREAD_ARCHIVED_METADATA_KEY, THREAD_PINNED_METADATA_KEY, InvalidMetadataFilterError, ThreadMetaStore
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.thread_meta.sql import ThreadMetaRepository
@ -17,6 +17,7 @@ __all__ = [
"InvalidMetadataFilterError",
"MemoryThreadMetaStore",
"THREAD_PINNED_METADATA_KEY",
"THREAD_ARCHIVED_METADATA_KEY",
"ThreadMetaRepository",
"ThreadMetaRow",
"ThreadMetaStore",

View File

@ -23,6 +23,7 @@ from deerflow.runtime.user_context import AUTO, _AutoSentinel
# ``frontend/src/core/threads/utils.ts`` and
# ``frontend/tests/e2e/utils/mock-api.ts``.
THREAD_PINNED_METADATA_KEY = "deerflow_pinned"
THREAD_ARCHIVED_METADATA_KEY = "deerflow_archived"
class InvalidMetadataFilterError(ValueError):
@ -52,12 +53,16 @@ class ThreadMetaStore(abc.ABC):
*,
metadata: dict[str, Any] | None = None,
status: str | None = None,
archived: bool | None = None,
limit: int = 100,
offset: int = 0,
user_id: str | None | _AutoSentinel = AUTO,
) -> list[dict[str, Any]]:
"""Search threads.
``archived=None`` includes all threads; False includes legacy rows
without a true archive flag. Filtering precedes pagination.
Results are ordered with pinned threads first
(``metadata.deerflow_pinned is True``), then by ``updated_at`` and
``thread_id`` descending within each group.

View File

@ -12,7 +12,7 @@ from typing import Any
from langgraph.store.base import BaseStore
from deerflow.persistence.json_compat import json_value_matches
from deerflow.persistence.thread_meta.base import THREAD_PINNED_METADATA_KEY, ThreadMetaStore
from deerflow.persistence.thread_meta.base import THREAD_ARCHIVED_METADATA_KEY, THREAD_PINNED_METADATA_KEY, ThreadMetaStore
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
from deerflow.utils.time import coerce_iso, now_iso
@ -73,6 +73,7 @@ class MemoryThreadMetaStore(ThreadMetaStore):
*,
metadata: dict[str, Any] | None = None,
status: str | None = None,
archived: bool | None = None,
limit: int = 100,
offset: int = 0,
user_id: str | None | _AutoSentinel = AUTO,
@ -109,6 +110,8 @@ class MemoryThreadMetaStore(ThreadMetaStore):
records = [self._item_to_dict(item) for item in items]
if metadata:
records = [record for record in records if isinstance(record.get("metadata"), dict) and all(json_value_matches(record["metadata"], key, value) for key, value in metadata.items())]
if archived is not None:
records = [record for record in records if ((record.get("metadata") or {}).get(THREAD_ARCHIVED_METADATA_KEY) is True) == archived]
records.sort(key=self._sort_key, reverse=True)
return records[offset : offset + limit]

View File

@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.orm.attributes import flag_modified
from deerflow.persistence.json_compat import json_match
from deerflow.persistence.thread_meta.base import THREAD_PINNED_METADATA_KEY, InvalidMetadataFilterError, ThreadMetaStore
from deerflow.persistence.thread_meta.base import THREAD_ARCHIVED_METADATA_KEY, THREAD_PINNED_METADATA_KEY, InvalidMetadataFilterError, ThreadMetaStore
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
from deerflow.utils.time import coerce_iso
@ -114,6 +114,7 @@ class ThreadMetaRepository(ThreadMetaStore):
*,
metadata: dict[str, Any] | None = None,
status: str | None = None,
archived: bool | None = None,
limit: int = 100,
offset: int = 0,
user_id: str | None | _AutoSentinel = AUTO,
@ -153,6 +154,12 @@ class ThreadMetaRepository(ThreadMetaStore):
rejected_keys = ", ".join(sorted(str(k) for k in metadata))
raise InvalidMetadataFilterError(f"All metadata filter keys were rejected as unsafe: {rejected_keys}")
if archived is not None:
# CASE handles missing/JSON-null keys and non-boolean legacy values
# identically on SQLite and Postgres, including for the active view.
archive_flag = case((json_match(ThreadMetaRow.metadata_json, THREAD_ARCHIVED_METADATA_KEY, True), 1), else_=0)
stmt = stmt.where(archive_flag == int(archived))
stmt = stmt.limit(limit).offset(offset)
async with self._sf() as session:
result = await session.execute(stmt)

View File

@ -0,0 +1,59 @@
"""Archive is an owner-scoped list filter, never deletion or run state."""
import pytest
from langgraph.store.memory import InMemoryStore
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
from deerflow.persistence.thread_meta.sql import ThreadMetaRepository
ARCHIVED = "deerflow_archived"
@pytest.fixture(params=["memory", "sqlite"])
async def archive_store(request, tmp_path):
if request.param == "memory":
yield MemoryThreadMetaStore(InMemoryStore())
return
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'archive.db'}", sqlite_dir=str(tmp_path))
try:
yield ThreadMetaRepository(get_session_factory())
finally:
await close_engine()
@pytest.mark.anyio
async def test_archive_filter_precedes_pagination_and_includes_legacy(archive_store):
store = archive_store
for name, metadata in [("legacy", {}), ("false", {ARCHIVED: False}), ("null", {ARCHIVED: None}), ("string", {ARCHIVED: "true"}), ("integer", {ARCHIVED: 1})]:
await store.create(name, user_id="owner", metadata=metadata)
for index in range(4):
await store.create(f"archived-{index}", user_id="owner", metadata={ARCHIVED: True})
await store.create("other", user_id="other", metadata={ARCHIVED: True})
active = await store.search(archived=False, user_id="owner")
assert {row["thread_id"] for row in active} == {"legacy", "false", "null", "string", "integer"}
first = await store.search(archived=False, limit=2, user_id="owner")
second = await store.search(archived=False, limit=3, offset=2, user_id="owner")
assert first + second == active
archived = await store.search(archived=True, user_id="owner")
assert {row["thread_id"] for row in archived} == {f"archived-{index}" for index in range(4)}
assert len(await store.search(user_id="owner")) == 9
@pytest.mark.anyio
async def test_restore_preserves_thread_metadata_status_and_timestamps(archive_store):
store = archive_store
original = await store.create("chat", user_id="owner", display_name="Report", metadata={"deerflow_pinned": True})
await store.update_metadata("chat", {ARCHIVED: True}, touch=False, user_id="owner")
assert await store.search(archived=False, user_id="owner") == []
await store.update_metadata("chat", {ARCHIVED: False}, touch=False, user_id="other")
assert (await store.get("chat", user_id="owner"))["metadata"][ARCHIVED] is True
await store.update_metadata("chat", {ARCHIVED: False}, touch=False, user_id="owner")
restored = await store.get("chat", user_id="owner")
assert restored["updated_at"] == original["updated_at"]
assert restored["display_name"] == "Report"
assert restored["status"] == original["status"]
assert restored["metadata"] == {"deerflow_pinned": True, ARCHIVED: False}
assert len(await store.search(archived=False, user_id="owner")) == 1

View File

@ -50,8 +50,8 @@ class _PermissiveThreadMetaStore(MemoryThreadMetaStore):
async def create(self, thread_id, *, assistant_id=None, user_id=None, display_name=None, metadata=None): # type: ignore[override]
return await super().create(thread_id, assistant_id=assistant_id, user_id=None, display_name=display_name, metadata=metadata)
async def search(self, *, metadata=None, status=None, limit=100, offset=0, user_id=None): # type: ignore[override]
return await super().search(metadata=metadata, status=status, limit=limit, offset=offset, user_id=None)
async def search(self, *, metadata=None, status=None, limit=100, offset=0, user_id=None, archived=None): # type: ignore[override]
return await super().search(metadata=metadata, status=status, limit=limit, offset=offset, user_id=None, archived=archived)
class _ThreadTestRunManager:
@ -1140,7 +1140,8 @@ def test_get_thread_preserves_metadata_status_without_checkpoint(stored_status:
assert response.json()["status"] == stored_status
def test_patch_thread_pin_returns_iso_and_preserves_updated_at() -> None:
@pytest.mark.parametrize("key", [THREAD_PINNED_METADATA_KEY, "deerflow_archived"])
def test_patch_thread_pin_returns_iso_and_preserves_updated_at(key) -> None:
"""A pin/unpin PATCH must not bump ``updated_at``.
Pinning or unpinning a chat does not represent conversation activity.
@ -1172,7 +1173,7 @@ def test_patch_thread_pin_returns_iso_and_preserves_updated_at() -> None:
with TestClient(app) as client:
response = client.patch(
f"/api/threads/{thread_id}",
json={"metadata": {THREAD_PINNED_METADATA_KEY: True}},
json={"metadata": {key: True}},
)
assert response.status_code == 200, response.text
@ -1182,7 +1183,7 @@ def test_patch_thread_pin_returns_iso_and_preserves_updated_at() -> None:
# ``touch=False`` preserves the original ``updated_at``; both timestamps
# derive from the same legacy value, so they coerce to the same ISO string.
assert body["updated_at"] == body["created_at"]
assert body["metadata"] == {"k": "v0", THREAD_PINNED_METADATA_KEY: True}
assert body["metadata"] == {"k": "v0", key: True}
def test_patch_thread_non_pin_metadata_bumps_updated_at() -> None:
@ -3867,3 +3868,69 @@ class TestRestReadsCarryMessageSeq:
assert response.status_code == 200, response.text
messages = response.json()["values"]["messages"]
assert "deerflow_seq" not in (messages[0].get("additional_kwargs") or {})
def test_archive_search_filter_and_restore_through_api():
app, store, _ = _build_thread_app()
async def seed():
for name, metadata in [("active", {}), ("archived", {"deerflow_archived": True})]:
await store.aput(THREADS_NS, name, {"metadata": metadata, "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"})
asyncio.run(seed())
with TestClient(app) as client:
active = client.post("/api/threads/search", json={"archived": False, "limit": 1})
assert active.status_code == 200
assert [r["thread_id"] for r in active.json()] == ["active"]
archived = client.post("/api/threads/search", json={"archived": True})
assert [r["thread_id"] for r in archived.json()] == ["archived"]
assert len(client.post("/api/threads/search", json={}).json()) == 2
restored = client.patch("/api/threads/archived", json={"metadata": {"deerflow_archived": False}})
assert restored.status_code == 200
assert client.post("/api/threads/search", json={"archived": True}).json() == []
@pytest.mark.parametrize("value", ["true", 1, None, {}])
def test_archive_patch_rejects_non_boolean(value):
app, _, _ = _build_thread_app()
with TestClient(app) as client:
result = client.patch("/api/threads/invalid", json={"metadata": {"deerflow_archived": value}})
assert result.status_code == 422
def test_archived_chat_keeps_original_link_and_artifact_download(tmp_path, monkeypatch):
from app.gateway.routers import artifacts
app, store, _ = _build_thread_app()
app.include_router(artifacts.router)
artifact = tmp_path / "report.txt"
artifact.write_text("Completed report", encoding="utf-8")
monkeypatch.setattr(artifacts, "resolve_thread_virtual_path", lambda *args, **kwargs: artifact)
async def seed():
await store.aput(THREADS_NS, "report", {"metadata": {}, "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z"})
asyncio.run(seed())
with TestClient(app) as client:
response = client.patch("/api/threads/report", json={"metadata": {"deerflow_archived": True}})
assert response.status_code == 200
assert client.get("/api/threads/report").status_code == 200
download = client.get("/api/threads/report/artifacts/mnt/user-data/outputs/report.txt?download=true")
assert download.status_code == 200
assert download.text == "Completed report"
assert "attachment" in download.headers["content-disposition"]
assert artifact.read_text(encoding="utf-8") == "Completed report"
def test_archive_patch_cannot_modify_another_users_thread():
app, store, _ = _build_thread_app()
app.state.thread_store = MemoryThreadMetaStore(store)
async def seed():
await store.aput(THREADS_NS, "private", {"user_id": "someone-else", "metadata": {}})
asyncio.run(seed())
with TestClient(app) as client:
response = client.patch("/api/threads/private", json={"metadata": {"deerflow_archived": True}})
assert response.status_code == 404
assert asyncio.run(store.aget(THREADS_NS, "private")).value["metadata"] == {}

View File

@ -113,3 +113,15 @@ routes, writes the detailed result to `.next/performance-results.json`, and comp
totals with `performance-budgets.json`. Fix route ownership or split points when a
budget fails; do not raise a ceiling without documenting and reviewing the measured
regression.
Chat archive is a thread metadata flag (`deerflow_archived === true`), independent
of run status. Sidebar and Chats explicitly request the Gateway's optional
`archived` filter through `searchThreadsByArchive`; the SDK drops this extension,
so use the authenticated REST fetcher. Static demos retain SDK fixture queries.
`core/threads/archive.ts` waits for the write, cancels stale reads, merges only the
owned flag into metadata snapshots, then restarts metadata reads and resets list
pagination. Keep both default and Custom Agent header restore controls in sync.
Pin/archive responses must not merge unrelated metadata flags: out-of-order
organization requests can otherwise roll back each other's confirmed state.
Run-created optimistic snapshots have no archive flag: refresh archive-filtered
lists from the server instead of inserting those snapshots into either view.

View File

@ -27,6 +27,7 @@ import {
SidecarProvider,
SidecarTrigger,
} from "@/components/workspace/sidecar";
import { ThreadArchiveStatus } from "@/components/workspace/thread-archive-status";
import { ThreadBackgroundTasks } from "@/components/workspace/thread-background-tasks";
import { ThreadSubagentBatches } from "@/components/workspace/thread-subagent-batches";
import { ThreadTitle } from "@/components/workspace/thread-title";
@ -280,6 +281,14 @@ export default function AgentChatPage() {
thread={thread}
canonicalTitle={threadMetadata.data?.values?.title}
/>
{!isNewThread &&
!isMock &&
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
<ThreadArchiveStatus
threadId={threadId}
metadata={threadMetadata.data?.metadata}
/>
)}
</div>
<div className="flex shrink-0 items-center sm:mr-4">
{!isNewThread &&

View File

@ -1,16 +1,19 @@
"use client";
import { ArchiveRestore } from "lucide-react";
import Link from "next/link";
import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
ThreadChannelBadge,
ThreadChannelIcon,
} from "@/components/workspace/thread-channel-source";
import { VirtualThreadList } from "@/components/workspace/thread-list-virtualizer";
import { useThreadArchiveAction } from "@/components/workspace/use-thread-archive-action";
import {
WorkspaceBody,
WorkspaceContainer,
@ -25,15 +28,23 @@ import {
titleOfThread,
} from "@/core/threads/utils";
import { formatTimeAgo } from "@/core/utils/datetime";
import { env } from "@/env";
export default function ChatsPage() {
const { t } = useI18n();
const [view, setView] = useState("active");
const archived = view === "archived";
const staticWebsite = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
const archiveAction = useThreadArchiveAction();
const {
data: infiniteThreads,
isLoading,
isError,
refetch,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteThreads();
} = useInfiniteThreads({ archived: staticWebsite ? undefined : archived });
const threadListModel = useMemo(
() => buildThreadListModel(infiniteThreads?.pages ?? []),
[infiniteThreads?.pages],
@ -73,14 +84,26 @@ export default function ChatsPage() {
);
observer.observe(element);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage, isSearching]);
}, [fetchNextPage, hasNextPage, isFetchingNextPage, isSearching, view]);
return (
<WorkspaceContainer>
<WorkspaceHeader></WorkspaceHeader>
<WorkspaceBody>
<div className="flex size-full flex-col">
<header className="flex shrink-0 items-center justify-center pt-8">
<Tabs
value={view}
onValueChange={setView}
className="flex size-full flex-col"
>
<header className="mx-auto flex w-full max-w-(--container-width-md) shrink-0 flex-col gap-3 pt-8">
{!staticWebsite && (
<TabsList aria-label={t.pages.chats}>
<TabsTrigger value="active">{t.chats.activeChats}</TabsTrigger>
<TabsTrigger value="archived">
{t.chats.archivedChats}
</TabsTrigger>
</TabsList>
)}
<Input
type="search"
className="h-12 w-full max-w-(--container-width-md) text-xl"
@ -90,64 +113,111 @@ export default function ChatsPage() {
onChange={(e) => setSearch(e.target.value)}
/>
</header>
<main className="min-h-0 flex-1">
<ScrollArea className="size-full py-4">
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
<VirtualThreadList
estimateSize={76}
items={filteredThreads}
scrollParentSelector='[data-slot="scroll-area-viewport"]'
renderItem={(thread) => {
const channelSource = channelSourceOfThread(thread);
return (
<Link key={thread.thread_id} href={pathOfThread(thread)}>
<div className="flex flex-col gap-2 border-b p-4">
<div className="flex min-w-0 items-center gap-2">
<ThreadChannelIcon source={channelSource} />
<div className="min-w-0 flex-1 truncate">
{titleOfThread(thread)}
</div>
<ThreadChannelBadge
source={channelSource}
className="hidden sm:inline-flex"
/>
</div>
{thread.updated_at && (
<div className="text-muted-foreground text-sm">
{formatTimeAgo(thread.updated_at)}
<TabsContent value={view} className="min-h-0 flex-1">
<main className="h-full">
<ScrollArea className="size-full py-4">
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
{isError && (
<div role="alert" className="p-4 text-center">
<p>{t.chats.loadChatsFailed}</p>
<Button variant="outline" onClick={() => void refetch()}>
{t.chats.retryLoadChats}
</Button>
</div>
)}
{!isLoading && !isError && filteredThreads.length === 0 && (
<p
role="status"
className="text-muted-foreground p-8 text-center"
>
{isSearching
? t.chats.noMatchingChats
: archived
? t.chats.noArchivedChats
: t.chats.noActiveChats}
</p>
)}
<VirtualThreadList
estimateSize={76}
items={filteredThreads}
scrollParentSelector='[data-slot="scroll-area-viewport"]'
renderItem={(thread) => {
const channelSource = channelSourceOfThread(thread);
return (
<div
key={thread.thread_id}
className="flex items-center gap-2 border-b"
>
<Link
className="min-w-0 flex-1"
href={pathOfThread(thread)}
>
<div className="flex flex-col gap-2 p-4">
<div className="flex min-w-0 items-center gap-2">
<ThreadChannelIcon source={channelSource} />
<div className="min-w-0 flex-1 truncate">
{titleOfThread(thread)}
</div>
<ThreadChannelBadge
source={channelSource}
className="hidden sm:inline-flex"
/>
</div>
{thread.updated_at && (
<div className="text-muted-foreground text-sm">
{formatTimeAgo(thread.updated_at)}
</div>
)}
</div>
</Link>
{archived && (
<Button
className="mr-4 shrink-0"
variant="outline"
size="sm"
disabled={archiveAction.isPending}
onClick={() =>
archiveAction.setArchived(
thread.thread_id,
false,
)
}
>
<ArchiveRestore className="size-4" />
{t.chats.restoreChat}
</Button>
)}
</div>
</Link>
);
}}
/>
{hasNextPage && !isSearching && (
<div
ref={sentinelRef}
aria-hidden="true"
className="h-px w-full"
data-testid="chats-page-sentinel"
);
}}
/>
)}
{hasNextPage && isSearching && (
<div className="flex justify-center p-4">
<Button
variant="outline"
onClick={() => void fetchNextPage()}
disabled={isFetchingNextPage}
data-testid="chats-page-load-more"
>
{isFetchingNextPage
? t.chats.loadingMore
: t.chats.loadMoreToSearch}
</Button>
</div>
)}
</div>
</ScrollArea>
</main>
</div>
{hasNextPage && !isSearching && (
<div
ref={sentinelRef}
aria-hidden="true"
className="h-px w-full"
data-testid="chats-page-sentinel"
/>
)}
{hasNextPage && isSearching && (
<div className="flex justify-center p-4">
<Button
variant="outline"
onClick={() => void fetchNextPage()}
disabled={isFetchingNextPage}
data-testid="chats-page-load-more"
>
{isFetchingNextPage
? t.chats.loadingMore
: t.chats.loadMoreToSearch}
</Button>
</div>
)}
</div>
</ScrollArea>
</main>
</TabsContent>
</Tabs>
</WorkspaceBody>
</WorkspaceContainer>
);

View File

@ -24,6 +24,7 @@ import {
SidecarProvider,
SidecarTrigger,
} from "@/components/workspace/sidecar";
import { ThreadArchiveStatus } from "@/components/workspace/thread-archive-status";
import { ThreadBackgroundTasks } from "@/components/workspace/thread-background-tasks";
import { ThreadScheduledTasksLink } from "@/components/workspace/thread-scheduled-tasks-link";
import { ThreadSubagentBatches } from "@/components/workspace/thread-subagent-batches";
@ -293,6 +294,14 @@ export default function ChatPage() {
thread={thread}
canonicalTitle={threadMetadata.data?.values?.title}
/>
{!isNewThread &&
!isMock &&
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
<ThreadArchiveStatus
threadId={threadId}
metadata={threadMetadata.data?.metadata}
/>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{!isNewThread &&

View File

@ -1,6 +1,7 @@
"use client";
import {
Archive,
Download,
FileJson,
FileText,
@ -69,9 +70,11 @@ import { isIMEComposing } from "@/lib/ime";
import { ThreadChannelIcon } from "./thread-channel-source";
import { VirtualThreadList } from "./thread-list-virtualizer";
import { useThreadArchiveAction } from "./use-thread-archive-action";
export function RecentChatList() {
const { t } = useI18n();
const archiveAction = useThreadArchiveAction();
const router = useRouter();
const pathname = usePathname();
const { thread_id: threadIdFromPath, agent_name: agentNameFromPath } =
@ -84,7 +87,10 @@ export function RecentChatList() {
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteThreads();
} = useInfiniteThreads({
archived:
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? undefined : false,
});
const threadListModel = useMemo(
() => buildThreadListModel(infiniteThreads?.pages ?? []),
[infiniteThreads?.pages],
@ -436,6 +442,18 @@ export function RecentChatList() {
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem
disabled={archiveAction.isPending}
onSelect={() =>
archiveAction.setArchived(
thread.thread_id,
true,
)
}
>
<Archive className="text-muted-foreground" />
<span>{t.chats.archiveChat}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => handleDelete(thread)}

View File

@ -0,0 +1,39 @@
import { ArchiveRestore } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useI18n } from "@/core/i18n/hooks";
import { isThreadArchived } from "@/core/threads/utils";
import { useThreadArchiveAction } from "./use-thread-archive-action";
export function ThreadArchiveStatus({
threadId,
metadata,
}: {
threadId: string;
metadata?: Record<string, unknown> | null;
}) {
const { t } = useI18n();
const { setArchived, isPending } = useThreadArchiveAction();
if (!isThreadArchived({ metadata: metadata ?? {} })) return null;
return (
<div
className="flex shrink-0 items-center gap-1 text-xs"
title={t.chats.archiveDescription}
>
<span className="text-muted-foreground hidden sm:inline">
{t.chats.archivedChats}
</span>
<Button
size="sm"
variant="ghost"
disabled={isPending}
onClick={() => setArchived(threadId, false)}
aria-label={t.chats.restoreChat}
>
<ArchiveRestore className="size-4" />
<span className="hidden sm:inline">{t.chats.restoreChat}</span>
</Button>
</div>
);
}

View File

@ -3,6 +3,7 @@ import { useEffect } from "react";
import { useI18n } from "@/core/i18n/hooks";
import type { AgentThreadState } from "@/core/threads";
import { cn } from "@/lib/utils";
import { useThreadChat } from "./chats";
import { FlipDisplay } from "./flip-display";
@ -15,6 +16,7 @@ export type ThreadTitleProps = {
};
export function ThreadTitle({
className,
threadId,
thread,
canonicalTitle,
@ -48,5 +50,12 @@ export function ThreadTitle({
if (!title) {
return null;
}
return <FlipDisplay uniqueKey={threadId}>{title}</FlipDisplay>;
return (
<FlipDisplay
uniqueKey={threadId}
className={cn("min-w-0 [&>div]:truncate", className)}
>
{title}
</FlipDisplay>
);
}

View File

@ -0,0 +1,35 @@
import { toast } from "sonner";
import { useI18n } from "@/core/i18n/hooks";
import { useArchiveThread } from "@/core/threads/archive";
export function useThreadArchiveAction() {
const { t } = useI18n();
const mutation = useArchiveThread();
function setArchived(threadId: string, archived: boolean) {
mutation.mutate(
{ threadId, archived },
{
onSuccess() {
if (archived) {
toast.success(t.chats.archiveSuccess, {
description: t.chats.archiveDescription,
action: {
label: t.chats.undoArchive,
onClick: () => setArchived(threadId, false),
},
});
} else {
toast.success(t.chats.restoreSuccess);
}
},
onError() {
toast.error(t.chats.archiveFailed);
},
},
);
}
return { setArchived, isPending: mutation.isPending };
}

View File

@ -636,6 +636,21 @@ export const enUS: Translations = {
// Chats
chats: {
noActiveChats: "No recent chats",
activeChats: "Recent chats",
archivedChats: "Archived",
archiveChat: "Archive chat",
restoreChat: "Restore chat",
archiveSuccess: "Chat archived",
restoreSuccess: "Chat restored",
archiveFailed: "Failed to update archived chat",
archiveDescription:
"Archiving keeps messages and files. Running and scheduled tasks continue.",
undoArchive: "Undo",
noArchivedChats: "No archived chats",
noMatchingChats: "No matching chats in the loaded conversations",
loadChatsFailed: "Failed to load conversations",
retryLoadChats: "Retry",
searchChats: "Search chats",
branchLabel: (title, parentTitle) => `${title}, branch of ${parentTitle}`,
loadMoreToSearch: "Load more to search older conversations",

View File

@ -520,6 +520,20 @@ export interface Translations {
// Chats
chats: {
noActiveChats: string;
activeChats: string;
archivedChats: string;
archiveChat: string;
restoreChat: string;
archiveSuccess: string;
restoreSuccess: string;
archiveFailed: string;
archiveDescription: string;
undoArchive: string;
noArchivedChats: string;
noMatchingChats: string;
loadChatsFailed: string;
retryLoadChats: string;
searchChats: string;
branchLabel: (title: string, parentTitle: string) => string;
loadMoreToSearch: string;

View File

@ -607,6 +607,21 @@ export const zhCN: Translations = {
// Chats
chats: {
noActiveChats: "暂无近期会话",
activeChats: "近期会话",
archivedChats: "已归档",
archiveChat: "归档",
restoreChat: "恢复",
archiveSuccess: "已归档",
restoreSuccess: "已恢复",
archiveFailed: "更新会话归档状态失败",
archiveDescription:
"归档会保留消息和文件,不会停止运行中的任务或暂停定时任务。",
undoArchive: "撤销",
noArchivedChats: "暂无已归档会话",
noMatchingChats: "已加载的会话中没有匹配结果",
loadChatsFailed: "加载会话失败",
retryLoadChats: "重试",
searchChats: "搜索对话",
branchLabel: (title, parentTitle) => `${title},分叉自 ${parentTitle}`,
loadMoreToSearch: "加载更多以搜索更早的对话",

View File

@ -162,3 +162,33 @@ export async function compactThreadContext(
return (await response.json()) as ThreadCompactResponse;
}
/** Gateway extension not forwarded by the LangGraph SDK's search method. */
export async function searchThreadsByArchive({
archived,
metadata,
status,
limit,
offset,
}: {
archived: boolean;
metadata?: Record<string, unknown>;
status?: string;
limit: number;
offset: number;
}): Promise<AgentThread[]> {
const response = await fetchWithAuth(
`${getBackendBaseURL()}/api/threads/search`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ archived, metadata, status, limit, offset }),
},
);
if (!response.ok) {
throw new Error(
await readThreadAPIError(response, "Failed to load conversations."),
);
}
return (await response.json()) as AgentThread[];
}

View File

@ -0,0 +1,50 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { patchThreadMetadata } from "./api";
import {
INFINITE_THREADS_QUERY_KEY_PREFIX,
setThreadMetadataInCaches,
} from "./hooks";
import { THREAD_ARCHIVED_METADATA_KEY } from "./utils";
export function useArchiveThread() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
threadId,
archived,
}: {
threadId: string;
archived: boolean;
}) =>
patchThreadMetadata(threadId, {
[THREAD_ARCHIVED_METADATA_KEY]: archived,
}),
async onSuccess(_response, { threadId, archived }) {
// A response started before the write must not put the old state back.
await Promise.all([
queryClient.cancelQueries({
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
}),
queryClient.cancelQueries({ queryKey: ["threads", "search"] }),
queryClient.cancelQueries({
queryKey: ["thread", "metadata", threadId],
}),
]);
setThreadMetadataInCaches(queryClient, threadId, {
[THREAD_ARCHIVED_METADATA_KEY]: archived,
});
// Membership changed, so discard old offsets in both views. The current
// conversation snapshot stays mounted and its files remain accessible.
await Promise.all([
queryClient.resetQueries({
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
}),
queryClient.invalidateQueries({ queryKey: ["threads", "search"] }),
queryClient.invalidateQueries({
queryKey: ["thread", "metadata", threadId],
}),
]);
},
});
}

View File

@ -38,6 +38,7 @@ import {
branchThreadFromTurn,
fetchThreadTokenUsage,
patchThreadMetadata,
searchThreadsByArchive,
type ThreadMetadataPatch,
} from "./api";
import {
@ -1438,10 +1439,20 @@ export function upsertThreadInInfiniteCache(
queryClient: QueryClient,
thread: AgentThread,
) {
// Run-created snapshots do not carry archive metadata. Let the server
// decide membership instead of injecting a running chat into both views.
const hasArchiveFilter = ({ queryKey }: { queryKey: readonly unknown[] }) =>
typeof (queryKey[2] as InfiniteThreadsParams | undefined)?.archived ===
"boolean";
void queryClient.invalidateQueries({
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
predicate: hasArchiveFilter,
});
queryClient.setQueriesData(
{
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
exact: false,
predicate: (query) => !hasArchiveFilter(query),
},
(oldData: InfiniteData<AgentThread[]> | undefined) => {
if (!oldData) {
@ -2780,9 +2791,9 @@ const INFINITE_THREADS_NEXT_PAGE_PARAM = Symbol(
);
type InfiniteThreadsParams = Omit<
Parameters<ThreadsClient["search"]>[0],
NonNullable<Parameters<ThreadsClient["search"]>[0]>,
"limit" | "offset"
>;
> & { archived?: boolean };
type InfiniteThreadsSearchClient = {
threads: {
@ -2816,11 +2827,20 @@ export async function fetchInfiniteThreadsPage(
while (threads.length < pageSize) {
const currentLimit = pageSize - threads.length;
const response = (await apiClient.threads.search<AgentThreadState>({
...params,
limit: currentLimit,
offset,
})) as AgentThread[];
const response =
params.archived === undefined
? ((await apiClient.threads.search<AgentThreadState>({
...params,
limit: currentLimit,
offset,
})) as AgentThread[])
: await searchThreadsByArchive({
...params,
archived: params.archived,
metadata: params.metadata ?? undefined,
limit: currentLimit,
offset,
});
threads.push(...filterThreadSearchResults(response, params));
offset += response.length;
@ -2953,7 +2973,7 @@ function setThreadInCaches(
);
}
function setThreadMetadataInCaches(
export function setThreadMetadataInCaches(
queryClient: QueryClient,
threadId: string,
metadata: ThreadMetadataPatch,
@ -3116,9 +3136,8 @@ export function usePinThread() {
patchThreadMetadata(threadId, {
[THREAD_PINNED_METADATA_KEY]: pinned,
}),
onSuccess(response, { threadId, pinned }) {
onSuccess(_response, { threadId, pinned }) {
setThreadMetadataInCaches(queryClient, threadId, {
...(response.metadata ?? {}),
[THREAD_PINNED_METADATA_KEY]: pinned,
});
},

View File

@ -7,6 +7,11 @@ import type { AgentThread, AgentThreadContext } from "./types";
// client-supplied key. Keep in sync with the backend thread_meta constant and
// the E2E mock-api constant.
export const THREAD_PINNED_METADATA_KEY = "deerflow_pinned";
export const THREAD_ARCHIVED_METADATA_KEY = "deerflow_archived";
export function isThreadArchived(thread: Pick<AgentThread, "metadata">) {
return thread.metadata?.[THREAD_ARCHIVED_METADATA_KEY] === true;
}
export type ChannelThreadSource = {
type: "im_channel";

View File

@ -0,0 +1,183 @@
import { expect, test } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
const CHAT = "00000000-0000-0000-0000-000000000901";
const OTHER = "00000000-0000-0000-0000-000000000902";
test("archive keeps the open conversation, supports undo and restores from the archive", async ({
page,
}, testInfo) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: CHAT,
title: "Finished report",
updated_at: "2026-07-04T10:00:00Z",
metadata: { deerflow_pinned: true },
},
{
thread_id: OTHER,
title: "Current work",
updated_at: "2026-07-05T10:00:00Z",
},
],
});
await page.goto(`/workspace/chats/${CHAT}`, {
waitUntil: "domcontentloaded",
});
const sidebarLink = page.locator(
`a[data-sidebar="menu-button"][href="/workspace/chats/${CHAT}"]`,
);
const archive = async () => {
await sidebarLink.hover();
await sidebarLink
.locator("xpath=..")
.getByRole("button", { name: "More" })
.click();
await page
.getByRole("menuitem", { name: "Archive chat", exact: true })
.click();
};
await expect(sidebarLink).toBeVisible();
await archive();
await expect(sidebarLink).toHaveCount(0);
await expect(page).toHaveURL(new RegExp(CHAT));
await expect(
page.getByRole("button", { name: "Restore chat", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Undo", exact: true }).click();
await expect(sidebarLink).toBeVisible();
await archive();
await expect(sidebarLink).toHaveCount(0);
await page.reload({ waitUntil: "domcontentloaded" });
await expect(
page.getByRole("button", { name: "Restore chat", exact: true }),
).toBeVisible();
await page.goto("/workspace/chats", { waitUntil: "domcontentloaded" });
await expect(
page.locator("main").getByText("Current work", { exact: true }),
).toBeVisible();
await page.getByRole("tab", { name: "Archived", exact: true }).click();
await expect(
page.locator("main").getByText("Finished report", { exact: true }),
).toBeVisible();
await expect(
page.getByRole("tab", { name: "Archived", exact: true }),
).toHaveAttribute("aria-selected", "true");
await page.screenshot({
path: testInfo.outputPath("archived-list.png"),
animations: "disabled",
});
await page
.locator("main")
.getByRole("button", { name: "Restore chat", exact: true })
.click();
await expect(
page.getByText("No archived chats", { exact: true }),
).toBeVisible();
await expect(sidebarLink).toBeVisible();
});
test("failed archive keeps the chat visible", async ({ page }) => {
mockLangGraphAPI(page, { threads: [{ thread_id: CHAT, title: "Keep me" }] });
await page.route(`**/api/threads/${CHAT}`, (route) =>
route.request().method() === "PATCH"
? route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "Unavailable" }),
})
: route.fallback(),
);
await page.goto("/workspace/chats/new");
const link = page.locator(
`a[data-sidebar="menu-button"][href="/workspace/chats/${CHAT}"]`,
);
await link.hover();
await link.locator("xpath=..").getByRole("button", { name: "More" }).click();
await page
.getByRole("menuitem", { name: "Archive chat", exact: true })
.click();
await expect(
page.getByText("Failed to update archived chat", { exact: true }),
).toBeVisible();
await expect(link).toBeVisible();
});
test("active list includes legacy chats beyond a full page of archived chats", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
...Array.from({ length: 55 }, (_, index) => ({
thread_id: `archived-${index}`,
title: `Archived ${index}`,
updated_at: new Date(
Date.UTC(2026, 7, 1) - index * 60000,
).toISOString(),
metadata: { deerflow_archived: true },
})),
{
thread_id: CHAT,
title: "Legacy chat",
updated_at: "2020-01-01T00:00:00Z",
},
],
});
await page.goto("/workspace/chats", { waitUntil: "domcontentloaded" });
await expect(
page.locator("main").getByText("Legacy chat", { exact: true }),
).toBeVisible();
await expect(
page.locator("main").getByText("Archived 0", { exact: true }),
).toHaveCount(0);
await page.getByRole("tab", { name: "Archived", exact: true }).click();
await expect(
page.locator("main").getByText("Archived 0", { exact: true }),
).toBeVisible();
});
for (const customAgent of [false, true]) {
test(`archived ${customAgent ? "custom agent" : "default"} chat can be restored from its mobile header`, async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
mockLangGraphAPI(page, {
agents: [{ name: "researcher", description: "Research assistant" }],
threads: [
{
thread_id: CHAT,
title: "Archived report",
metadata: {
deerflow_archived: true,
...(customAgent ? { agent_name: "researcher" } : {}),
},
},
],
});
const url = customAgent
? `/workspace/agents/researcher/chats/${CHAT}`
: `/workspace/chats/${CHAT}`;
await page.goto(url, { waitUntil: "domcontentloaded" });
const restore = page.getByRole("button", {
name: "Restore chat",
exact: true,
});
await expect(restore).toBeVisible({ timeout: 15000 });
await expect
.poll(() =>
page
.getByText("Archived report", { exact: true })
.evaluate((element) => element.getBoundingClientRect().height),
)
.toBeLessThanOrEqual(24);
await page.screenshot({
path: testInfo.outputPath("archived-header-mobile.png"),
animations: "disabled",
});
await restore.click();
await expect(restore).toHaveCount(0);
await expect(page).toHaveURL(new RegExp(CHAT));
});
}

View File

@ -86,7 +86,14 @@ test.describe("Thread list infinite scroll (issue #3482)", () => {
// observer and never interferes with routing.
let searchRequestCount = 0;
page.on("request", (request) => {
if (request.url().includes("/api/langgraph/threads/search")) {
// Archive-filtered lists use the Gateway directly; SDK callers keep
// the LangGraph proxy path. Observe both search transports.
if (
request.method() === "POST" &&
/^\/api\/(?:langgraph\/)?threads\/search$/.test(
new URL(request.url()).pathname,
)
) {
searchRequestCount += 1;
}
});
@ -100,6 +107,7 @@ test.describe("Thread list infinite scroll (issue #3482)", () => {
timeout: 15_000,
});
const baselineRequests = searchRequestCount;
expect(baselineRequests).toBeGreaterThan(0);
// Type a query that matches nothing in the first page (and nothing at
// all, since titles are deterministic).

View File

@ -680,18 +680,26 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
});
// Thread search — sidebar thread list & chats list page
void page.route("**/api/langgraph/threads/search", async (route) => {
void page.route(/\/api\/(?:langgraph\/)?threads\/search$/, async (route) => {
let body = sortThreadSearchResults(threads).map(threadSearchResult);
let limit: number | undefined;
let offset = 0;
try {
const postData = route.request().postDataJSON() as {
archived?: boolean;
limit?: number;
offset?: number;
metadata?: Record<string, unknown>;
} | null;
if (postData) {
if (typeof postData.archived === "boolean") {
body = body.filter(
(thread) =>
(Reflect.get(thread.metadata, "deerflow_archived") === true) ===
postData.archived,
);
}
if (typeof postData.limit === "number") {
limit = postData.limit;
}

View File

@ -0,0 +1,166 @@
import { afterEach, expect, rs, test } from "@rstest/core";
import {
QueryClient,
QueryClientProvider,
useQuery,
} from "@tanstack/react-query";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import type { PropsWithChildren } from "react";
const mocks = rs.hoisted(() => ({ fetch: rs.fn() }));
rs.mock("@/core/api/fetcher", () => ({ fetch: mocks.fetch }));
import { useArchiveThread } from "@/core/threads/archive";
import { usePinThread } from "@/core/threads/hooks";
const original = {
thread_id: "chat",
metadata: { deerflow_pinned: true },
values: { title: "Report" },
updated_at: "2026-01-01T00:00:00Z",
};
afterEach(() => {
cleanup();
rs.clearAllMocks();
});
function setup() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const key = ["thread", "metadata", "chat", false];
client.setQueryData(key, original);
client.setQueryData(["threads", "searchInfinite", { archived: false }], {
pages: [[original]],
pageParams: [0],
});
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
return { client, key, ...renderHook(() => useArchiveThread(), { wrapper }) };
}
test("archive preserves the active snapshot and resets list pagination after success", async () => {
mocks.fetch.mockResolvedValue(
new Response(JSON.stringify({ metadata: { deerflow_archived: true } })),
);
const { client, key, result } = setup();
await act(async () => {
await result.current.mutateAsync({ threadId: "chat", archived: true });
});
expect(mocks.fetch).toHaveBeenCalledWith(
expect.stringContaining("/api/threads/chat"),
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({ metadata: { deerflow_archived: true } }),
}),
);
expect(client.getQueryData(key)).toEqual({
...original,
metadata: { deerflow_pinned: true, deerflow_archived: true },
});
expect(
client.getQueryData(["threads", "searchInfinite", { archived: false }]),
).toBeUndefined();
client.clear();
});
test("failed archive keeps the visible thread and metadata unchanged", async () => {
mocks.fetch.mockRejectedValue(new Error("Unavailable"));
const { client, key, result } = setup();
await act(async () => {
await expect(
result.current.mutateAsync({ threadId: "chat", archived: true }),
).rejects.toThrow("Unavailable");
});
expect(client.getQueryData(key)).toEqual(original);
expect(
client.getQueryData(["threads", "searchInfinite", { archived: false }]),
).toEqual({ pages: [[original]], pageParams: [0] });
client.clear();
});
test("archive restarts an initial metadata read cancelled by the mutation", async () => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
let reads = 0;
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
mocks.fetch.mockResolvedValue(
new Response(JSON.stringify({ metadata: { deerflow_archived: true } })),
);
const { result } = renderHook(
() => ({
metadata: useQuery({
queryKey: ["thread", "metadata", "chat", false],
queryFn: async () => {
reads += 1;
if (reads === 1) return new Promise<typeof original>(() => undefined);
return {
...original,
metadata: { ...original.metadata, deerflow_archived: true },
};
},
}),
mutation: useArchiveThread(),
}),
{ wrapper },
);
await waitFor(() => expect(reads).toBe(1));
await act(async () => {
await result.current.mutation.mutateAsync({
threadId: "chat",
archived: true,
});
});
await waitFor(() =>
expect(result.current.metadata.data?.metadata).toEqual({
deerflow_pinned: true,
deerflow_archived: true,
}),
);
client.clear();
});
test("a late pin response cannot roll back the confirmed archive flag", async () => {
const { client, key, result: archive } = setup();
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
const { result: pin } = renderHook(() => usePinThread(), { wrapper });
let finishPin!: (value: Response) => void;
mocks.fetch.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
finishPin = resolve;
}),
);
let pendingPin!: Promise<unknown>;
act(() => {
pendingPin = pin.current.mutateAsync({ threadId: "chat", pinned: true });
});
await waitFor(() => expect(finishPin).toBeDefined());
mocks.fetch.mockResolvedValue(
new Response(JSON.stringify({ metadata: { deerflow_archived: true } })),
);
await act(async () => {
await archive.current.mutateAsync({ threadId: "chat", archived: true });
});
await act(async () => {
finishPin(
new Response(
JSON.stringify({
metadata: { deerflow_pinned: true, deerflow_archived: false },
}),
),
);
await pendingPin;
});
expect(client.getQueryData(key)).toEqual({
...original,
metadata: { deerflow_pinned: true, deerflow_archived: true },
});
client.clear();
});

View File

@ -496,3 +496,21 @@ describe("invalidateStoppedThreadCaches", () => {
}
});
});
test("run-created snapshots without archive metadata cannot insert into filtered lists", () => {
const client = new QueryClient();
const recentKey = [...INFINITE_THREADS_QUERY_KEY_PREFIX, { archived: false }];
const archivedKey = [
...INFINITE_THREADS_QUERY_KEY_PREFIX,
{ archived: true },
];
const empty = makeInfiniteData([[]]);
client.setQueryData(recentKey, empty);
client.setQueryData(archivedKey, empty);
upsertThreadInInfiniteCache(client, makeThread("running-thread"));
expect(client.getQueryData(recentKey)).toEqual(empty);
expect(client.getQueryData(archivedKey)).toEqual(empty);
expect(client.getQueryState(recentKey)?.isInvalidated).toBe(true);
expect(client.getQueryState(archivedKey)?.isInvalidated).toBe(true);
client.clear();
});