mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 13:39:26 +00:00
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:
parent
e3df6ea4a8
commit
98b8e4657e
@ -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.
|
||||
|
||||
@ -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 判定它已被满足、或者你手动清除它。
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
38
backend/docs/THREAD_LIFECYCLE.md
Normal file
38
backend/docs/THREAD_LIFECYCLE.md
Normal 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.
|
||||
@ -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",
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
59
backend/tests/test_thread_archive.py
Normal file
59
backend/tests/test_thread_archive.py
Normal 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
|
||||
@ -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"] == {}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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)}
|
||||
|
||||
39
frontend/src/components/workspace/thread-archive-status.tsx
Normal file
39
frontend/src/components/workspace/thread-archive-status.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: "加载更多以搜索更早的对话",
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
50
frontend/src/core/threads/archive.ts
Normal file
50
frontend/src/core/threads/archive.ts
Normal 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],
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@ -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";
|
||||
|
||||
183
frontend/tests/e2e/thread-archive.spec.ts
Normal file
183
frontend/tests/e2e/thread-archive.spec.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
@ -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).
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
166
frontend/tests/unit/core/threads/archive.dom.test.tsx
Normal file
166
frontend/tests/unit/core/threads/archive.dom.test.tsx
Normal 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();
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user