diff --git a/README.md b/README.md index ef5ece0b6..dd2590e55 100644 --- a/README.md +++ b/README.md @@ -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 ` 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. diff --git a/README_zh.md b/README_zh.md index 228b1581d..1466bf045 100644 --- a/README_zh.md +++ b/README_zh.md @@ -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 判定它已被满足、或者你手动清除它。 diff --git a/backend/app/gateway/AGENTS.md b/backend/app/gateway/AGENTS.md index 5d0eb2798..f1223fccc 100644 --- a/backend/app/gateway/AGENTS.md +++ b/backend/app/gateway/AGENTS.md @@ -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. diff --git a/backend/app/gateway/routers/threads.py b/backend/app/gateway/routers/threads.py index 146fa3217..4fe6e4419 100644 --- a/backend/app/gateway/routers/threads.py +++ b/backend/app/gateway/routers/threads.py @@ -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: diff --git a/backend/docs/API.md b/backend/docs/API.md index 7651dc6c7..f7a90e621 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -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. diff --git a/backend/docs/THREAD_LIFECYCLE.md b/backend/docs/THREAD_LIFECYCLE.md new file mode 100644 index 000000000..902dffa64 --- /dev/null +++ b/backend/docs/THREAD_LIFECYCLE.md @@ -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. diff --git a/backend/packages/harness/deerflow/persistence/thread_meta/__init__.py b/backend/packages/harness/deerflow/persistence/thread_meta/__init__.py index 66151d4ab..41bc8283e 100644 --- a/backend/packages/harness/deerflow/persistence/thread_meta/__init__.py +++ b/backend/packages/harness/deerflow/persistence/thread_meta/__init__.py @@ -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", diff --git a/backend/packages/harness/deerflow/persistence/thread_meta/base.py b/backend/packages/harness/deerflow/persistence/thread_meta/base.py index 869ca2b31..409fc6673 100644 --- a/backend/packages/harness/deerflow/persistence/thread_meta/base.py +++ b/backend/packages/harness/deerflow/persistence/thread_meta/base.py @@ -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. diff --git a/backend/packages/harness/deerflow/persistence/thread_meta/memory.py b/backend/packages/harness/deerflow/persistence/thread_meta/memory.py index 5673a23ae..bf5c7f320 100644 --- a/backend/packages/harness/deerflow/persistence/thread_meta/memory.py +++ b/backend/packages/harness/deerflow/persistence/thread_meta/memory.py @@ -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] diff --git a/backend/packages/harness/deerflow/persistence/thread_meta/sql.py b/backend/packages/harness/deerflow/persistence/thread_meta/sql.py index 7f9f310e1..9fb4c3db3 100644 --- a/backend/packages/harness/deerflow/persistence/thread_meta/sql.py +++ b/backend/packages/harness/deerflow/persistence/thread_meta/sql.py @@ -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) diff --git a/backend/tests/test_thread_archive.py b/backend/tests/test_thread_archive.py new file mode 100644 index 000000000..8e127100d --- /dev/null +++ b/backend/tests/test_thread_archive.py @@ -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 diff --git a/backend/tests/test_threads_router.py b/backend/tests/test_threads_router.py index 252eabf50..31fcce0bd 100644 --- a/backend/tests/test_threads_router.py +++ b/backend/tests/test_threads_router.py @@ -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"] == {} diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index ccef652fa..7896e648c 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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. diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index 128eba6c3..05410c9de 100644 --- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -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" && ( + + )}
{!isNewThread && diff --git a/frontend/src/app/workspace/chats/page.tsx b/frontend/src/app/workspace/chats/page.tsx index 64e9fa058..c9e73f21a 100644 --- a/frontend/src/app/workspace/chats/page.tsx +++ b/frontend/src/app/workspace/chats/page.tsx @@ -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 ( -
-
+ +
+ {!staticWebsite && ( + + {t.chats.activeChats} + + {t.chats.archivedChats} + + + )} setSearch(e.target.value)} />
-
- -
- { - const channelSource = channelSourceOfThread(thread); - return ( - -
-
- -
- {titleOfThread(thread)} -
- -
- {thread.updated_at && ( -
- {formatTimeAgo(thread.updated_at)} + +
+ +
+ {isError && ( +
+

{t.chats.loadChatsFailed}

+ +
+ )} + {!isLoading && !isError && filteredThreads.length === 0 && ( +

+ {isSearching + ? t.chats.noMatchingChats + : archived + ? t.chats.noArchivedChats + : t.chats.noActiveChats} +

+ )} + { + const channelSource = channelSourceOfThread(thread); + return ( +
+ +
+
+ +
+ {titleOfThread(thread)} +
+ +
+ {thread.updated_at && ( +
+ {formatTimeAgo(thread.updated_at)} +
+ )}
+ + {archived && ( + )}
- - ); - }} - /> - {hasNextPage && !isSearching && ( - - -
-
+ {hasNextPage && !isSearching && ( + + +
+ +
); diff --git a/frontend/src/components/workspace/chats/chat-page.tsx b/frontend/src/components/workspace/chats/chat-page.tsx index a4b66f8de..28a9a344e 100644 --- a/frontend/src/components/workspace/chats/chat-page.tsx +++ b/frontend/src/components/workspace/chats/chat-page.tsx @@ -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" && ( + + )}
{!isNewThread && diff --git a/frontend/src/components/workspace/recent-chat-list.tsx b/frontend/src/components/workspace/recent-chat-list.tsx index 956128615..b1f0e5b35 100644 --- a/frontend/src/components/workspace/recent-chat-list.tsx +++ b/frontend/src/components/workspace/recent-chat-list.tsx @@ -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() { + + archiveAction.setArchived( + thread.thread_id, + true, + ) + } + > + + {t.chats.archiveChat} + handleDelete(thread)} diff --git a/frontend/src/components/workspace/thread-archive-status.tsx b/frontend/src/components/workspace/thread-archive-status.tsx new file mode 100644 index 000000000..d2d0ed13d --- /dev/null +++ b/frontend/src/components/workspace/thread-archive-status.tsx @@ -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 | null; +}) { + const { t } = useI18n(); + const { setArchived, isPending } = useThreadArchiveAction(); + if (!isThreadArchived({ metadata: metadata ?? {} })) return null; + return ( +
+ + {t.chats.archivedChats} + + +
+ ); +} diff --git a/frontend/src/components/workspace/thread-title.tsx b/frontend/src/components/workspace/thread-title.tsx index 11412f01f..82dfce864 100644 --- a/frontend/src/components/workspace/thread-title.tsx +++ b/frontend/src/components/workspace/thread-title.tsx @@ -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 {title}; + return ( + div]:truncate", className)} + > + {title} + + ); } diff --git a/frontend/src/components/workspace/use-thread-archive-action.ts b/frontend/src/components/workspace/use-thread-archive-action.ts new file mode 100644 index 000000000..2ff11b7d0 --- /dev/null +++ b/frontend/src/components/workspace/use-thread-archive-action.ts @@ -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 }; +} diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index d95578dd0..f93bdbcd3 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -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", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 6a9a2fc97..150a26a9b 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -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; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 5e3f7c481..9f3789eb8 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -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: "加载更多以搜索更早的对话", diff --git a/frontend/src/core/threads/api.ts b/frontend/src/core/threads/api.ts index 3759e0160..75bcf979b 100644 --- a/frontend/src/core/threads/api.ts +++ b/frontend/src/core/threads/api.ts @@ -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; + status?: string; + limit: number; + offset: number; +}): Promise { + 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[]; +} diff --git a/frontend/src/core/threads/archive.ts b/frontend/src/core/threads/archive.ts new file mode 100644 index 000000000..a271a34e3 --- /dev/null +++ b/frontend/src/core/threads/archive.ts @@ -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], + }), + ]); + }, + }); +} diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index a4e206ee4..710f1d45f 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -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 | undefined) => { if (!oldData) { @@ -2780,9 +2791,9 @@ const INFINITE_THREADS_NEXT_PAGE_PARAM = Symbol( ); type InfiniteThreadsParams = Omit< - Parameters[0], + NonNullable[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({ - ...params, - limit: currentLimit, - offset, - })) as AgentThread[]; + const response = + params.archived === undefined + ? ((await apiClient.threads.search({ + ...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, }); }, diff --git a/frontend/src/core/threads/utils.ts b/frontend/src/core/threads/utils.ts index aff8aa377..fe451e0b0 100644 --- a/frontend/src/core/threads/utils.ts +++ b/frontend/src/core/threads/utils.ts @@ -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) { + return thread.metadata?.[THREAD_ARCHIVED_METADATA_KEY] === true; +} export type ChannelThreadSource = { type: "im_channel"; diff --git a/frontend/tests/e2e/thread-archive.spec.ts b/frontend/tests/e2e/thread-archive.spec.ts new file mode 100644 index 000000000..16f1c5418 --- /dev/null +++ b/frontend/tests/e2e/thread-archive.spec.ts @@ -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)); + }); +} diff --git a/frontend/tests/e2e/thread-list-infinite-scroll.spec.ts b/frontend/tests/e2e/thread-list-infinite-scroll.spec.ts index 5b5681f4f..d6fa662d3 100644 --- a/frontend/tests/e2e/thread-list-infinite-scroll.spec.ts +++ b/frontend/tests/e2e/thread-list-infinite-scroll.spec.ts @@ -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). diff --git a/frontend/tests/e2e/utils/mock-api.ts b/frontend/tests/e2e/utils/mock-api.ts index 5401dce25..e0682c0b6 100644 --- a/frontend/tests/e2e/utils/mock-api.ts +++ b/frontend/tests/e2e/utils/mock-api.ts @@ -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; } | 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; } diff --git a/frontend/tests/unit/core/threads/archive.dom.test.tsx b/frontend/tests/unit/core/threads/archive.dom.test.tsx new file mode 100644 index 000000000..1c6b5b4f1 --- /dev/null +++ b/frontend/tests/unit/core/threads/archive.dom.test.tsx @@ -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) => ( + {children} + ); + 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) => ( + {children} + ); + 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(() => 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) => ( + {children} + ); + const { result: pin } = renderHook(() => usePinThread(), { wrapper }); + let finishPin!: (value: Response) => void; + mocks.fetch.mockImplementationOnce( + () => + new Promise((resolve) => { + finishPin = resolve; + }), + ); + let pendingPin!: Promise; + 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(); +}); diff --git a/frontend/tests/unit/core/threads/infinite.test.ts b/frontend/tests/unit/core/threads/infinite.test.ts index c06306473..b13b1488f 100644 --- a/frontend/tests/unit/core/threads/infinite.test.ts +++ b/frontend/tests/unit/core/threads/infinite.test.ts @@ -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(); +});