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.
|
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
|
### 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.
|
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 只接收转写后的文本,音频处理交由浏览器或操作系统语音识别服务按其环境策略完成。用户可以在发送前继续检查和编辑文本。
|
Web UI 输入框支持浏览器侧语音听写。浏览器提供 Web Speech API 时,麦克风按钮会把语音转写为本地草稿;DeerFlow 只接收转写后的文本,音频处理交由浏览器或操作系统语音识别服务按其环境策略完成。用户可以在发送前继续检查和编辑文本。
|
||||||
|
|
||||||
|
### 会话归档
|
||||||
|
|
||||||
|
在侧栏最近会话的菜单中点击「归档」,可以隐藏已完成的会话,同时保留消息、文件和原链接。成功提示提供「撤销」。在「对话 → 已归档」中查看并逐条恢复;已打开的归档会话也会在顶部显示恢复入口。搜索匹配已加载会话的标题,较早记录可通过「加载更多」查找。
|
||||||
|
|
||||||
|
归档与恢复保留会话原有的活动时间和置顶状态。归档不会停止运行中的任务或暂停定时任务,新消息也不会自动恢复会话。需要移除会话及其文件时,使用原有的删除操作。
|
||||||
|
|
||||||
### Session Goals
|
### Session Goals
|
||||||
|
|
||||||
用 `/goal <完成条件>` 为当前 thread 绑定一个激活态的完成条件。这个 goal 是 thread 维度的状态,而不是技能激活,所以它会跨轮次持续生效,直到 DeerFlow 判定它已被满足、或者你手动清除它。
|
用 `/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.
|
Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runtime, all other `/api/*` → Gateway REST APIs.
|
||||||
|
|
||||||
**Branch/regenerate checkpoint invariant**: `app/gateway/checkpoint_lineage.py`
|
**Thread lifecycle**: Before changing branching, regeneration, edit replay, or
|
||||||
walks `parent_config` rather than globally ordered checkpoint history so replay
|
archive/search behavior, read [Thread lifecycle invariants](../../docs/THREAD_LIFECYCLE.md).
|
||||||
anchors stay on the selected lineage after regenerations create sibling branches.
|
It owns lineage and settled-checkpoint rules, legacy fallback boundaries, archive
|
||||||
New conversation branches persist the pre-user replay anchor before their visible
|
filtering before pagination, owner isolation, and activity-time preservation.
|
||||||
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.
|
|
||||||
|
|||||||
@ -46,7 +46,7 @@ from app.gateway.utils import sanitize_log_param
|
|||||||
from deerflow.agents.thread_state import THREAD_STATE_REDUCER_FIELDS
|
from deerflow.agents.thread_state import THREAD_STATE_REDUCER_FIELDS
|
||||||
from deerflow.config.paths import Paths, get_paths
|
from deerflow.config.paths import Paths, get_paths
|
||||||
from deerflow.config.summarization_config import ContextSize
|
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 import ThreadOperationKind, serialize_channel_values_for_api
|
||||||
from deerflow.runtime.checkpoint_mode import CheckpointModeMismatchError, CheckpointModeReconfigurationError
|
from deerflow.runtime.checkpoint_mode import CheckpointModeMismatchError, CheckpointModeReconfigurationError
|
||||||
from deerflow.runtime.checkpoint_state import graph_reducer_channels, graph_state_schema, graph_writable_channels
|
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}
|
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:
|
def _is_organization_metadata_patch(metadata: dict[str, Any]) -> bool:
|
||||||
"""Return True for the narrow pin/unpin PATCH shape."""
|
"""Recognize list-organization writes that must preserve activity time."""
|
||||||
return set(metadata) == {THREAD_PINNED_METADATA_KEY} and isinstance(metadata.get(THREAD_PINNED_METADATA_KEY), bool)
|
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:
|
def _message_id(message: Any) -> str | None:
|
||||||
@ -451,6 +451,7 @@ class ThreadCreateRequest(BaseModel):
|
|||||||
class ThreadSearchRequest(BaseModel):
|
class ThreadSearchRequest(BaseModel):
|
||||||
"""Request body for searching threads."""
|
"""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)")
|
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")
|
limit: int = Field(default=100, ge=1, le=1000, description="Maximum results")
|
||||||
offset: int = Field(default=0, ge=0, description="Pagination offset")
|
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)))
|
_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):
|
class ThreadStateUpdateRequest(BaseModel):
|
||||||
"""Request body for updating thread state (human-in-the-loop resume)."""
|
"""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(
|
rows = await repo.search(
|
||||||
metadata=body.metadata or None,
|
metadata=body.metadata or None,
|
||||||
status=body.status,
|
status=body.status,
|
||||||
|
**({"archived": body.archived} if body.archived is not None else {}),
|
||||||
limit=body.limit,
|
limit=body.limit,
|
||||||
offset=body.offset,
|
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")
|
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
|
||||||
|
|
||||||
# ``body.metadata`` already stripped by ``ThreadPatchRequest._strip_reserved``.
|
# ``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
|
# Other metadata PATCH callers keep the public endpoint's existing recency
|
||||||
# contract unless they get their own explicit no-touch API surface.
|
# 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:
|
try:
|
||||||
await thread_store.update_metadata(thread_id, body.metadata, touch=touch)
|
await thread_store.update_metadata(thread_id, body.metadata, touch=touch)
|
||||||
except Exception:
|
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)
|
> `config.recursion_limit` explicitly — see the [Create Run](#create-run)
|
||||||
> section for details. Scheduled-task launches use
|
> section for details. Scheduled-task launches use
|
||||||
> `scheduler.recursion_limit` from `config.yaml` instead of a client body.
|
> `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 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.memory import MemoryThreadMetaStore
|
||||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||||
from deerflow.persistence.thread_meta.sql import ThreadMetaRepository
|
from deerflow.persistence.thread_meta.sql import ThreadMetaRepository
|
||||||
@ -17,6 +17,7 @@ __all__ = [
|
|||||||
"InvalidMetadataFilterError",
|
"InvalidMetadataFilterError",
|
||||||
"MemoryThreadMetaStore",
|
"MemoryThreadMetaStore",
|
||||||
"THREAD_PINNED_METADATA_KEY",
|
"THREAD_PINNED_METADATA_KEY",
|
||||||
|
"THREAD_ARCHIVED_METADATA_KEY",
|
||||||
"ThreadMetaRepository",
|
"ThreadMetaRepository",
|
||||||
"ThreadMetaRow",
|
"ThreadMetaRow",
|
||||||
"ThreadMetaStore",
|
"ThreadMetaStore",
|
||||||
|
|||||||
@ -23,6 +23,7 @@ from deerflow.runtime.user_context import AUTO, _AutoSentinel
|
|||||||
# ``frontend/src/core/threads/utils.ts`` and
|
# ``frontend/src/core/threads/utils.ts`` and
|
||||||
# ``frontend/tests/e2e/utils/mock-api.ts``.
|
# ``frontend/tests/e2e/utils/mock-api.ts``.
|
||||||
THREAD_PINNED_METADATA_KEY = "deerflow_pinned"
|
THREAD_PINNED_METADATA_KEY = "deerflow_pinned"
|
||||||
|
THREAD_ARCHIVED_METADATA_KEY = "deerflow_archived"
|
||||||
|
|
||||||
|
|
||||||
class InvalidMetadataFilterError(ValueError):
|
class InvalidMetadataFilterError(ValueError):
|
||||||
@ -52,12 +53,16 @@ class ThreadMetaStore(abc.ABC):
|
|||||||
*,
|
*,
|
||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
|
archived: bool | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
user_id: str | None | _AutoSentinel = AUTO,
|
user_id: str | None | _AutoSentinel = AUTO,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Search threads.
|
"""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
|
Results are ordered with pinned threads first
|
||||||
(``metadata.deerflow_pinned is True``), then by ``updated_at`` and
|
(``metadata.deerflow_pinned is True``), then by ``updated_at`` and
|
||||||
``thread_id`` descending within each group.
|
``thread_id`` descending within each group.
|
||||||
|
|||||||
@ -12,7 +12,7 @@ from typing import Any
|
|||||||
from langgraph.store.base import BaseStore
|
from langgraph.store.base import BaseStore
|
||||||
|
|
||||||
from deerflow.persistence.json_compat import json_value_matches
|
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.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
|
||||||
from deerflow.utils.time import coerce_iso, now_iso
|
from deerflow.utils.time import coerce_iso, now_iso
|
||||||
|
|
||||||
@ -73,6 +73,7 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
|||||||
*,
|
*,
|
||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
|
archived: bool | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
user_id: str | None | _AutoSentinel = AUTO,
|
user_id: str | None | _AutoSentinel = AUTO,
|
||||||
@ -109,6 +110,8 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
|||||||
records = [self._item_to_dict(item) for item in items]
|
records = [self._item_to_dict(item) for item in items]
|
||||||
if metadata:
|
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())]
|
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)
|
records.sort(key=self._sort_key, reverse=True)
|
||||||
return records[offset : offset + limit]
|
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 sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
from deerflow.persistence.json_compat import json_match
|
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.persistence.thread_meta.model import ThreadMetaRow
|
||||||
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
|
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
|
||||||
from deerflow.utils.time import coerce_iso
|
from deerflow.utils.time import coerce_iso
|
||||||
@ -114,6 +114,7 @@ class ThreadMetaRepository(ThreadMetaStore):
|
|||||||
*,
|
*,
|
||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
|
archived: bool | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
user_id: str | None | _AutoSentinel = AUTO,
|
user_id: str | None | _AutoSentinel = AUTO,
|
||||||
@ -153,6 +154,12 @@ class ThreadMetaRepository(ThreadMetaStore):
|
|||||||
rejected_keys = ", ".join(sorted(str(k) for k in metadata))
|
rejected_keys = ", ".join(sorted(str(k) for k in metadata))
|
||||||
raise InvalidMetadataFilterError(f"All metadata filter keys were rejected as unsafe: {rejected_keys}")
|
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)
|
stmt = stmt.limit(limit).offset(offset)
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
result = await session.execute(stmt)
|
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]
|
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)
|
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]
|
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)
|
return await super().search(metadata=metadata, status=status, limit=limit, offset=offset, user_id=None, archived=archived)
|
||||||
|
|
||||||
|
|
||||||
class _ThreadTestRunManager:
|
class _ThreadTestRunManager:
|
||||||
@ -1140,7 +1140,8 @@ def test_get_thread_preserves_metadata_status_without_checkpoint(stored_status:
|
|||||||
assert response.json()["status"] == 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``.
|
"""A pin/unpin PATCH must not bump ``updated_at``.
|
||||||
|
|
||||||
Pinning or unpinning a chat does not represent conversation activity.
|
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:
|
with TestClient(app) as client:
|
||||||
response = client.patch(
|
response = client.patch(
|
||||||
f"/api/threads/{thread_id}",
|
f"/api/threads/{thread_id}",
|
||||||
json={"metadata": {THREAD_PINNED_METADATA_KEY: True}},
|
json={"metadata": {key: True}},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200, response.text
|
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
|
# ``touch=False`` preserves the original ``updated_at``; both timestamps
|
||||||
# derive from the same legacy value, so they coerce to the same ISO string.
|
# derive from the same legacy value, so they coerce to the same ISO string.
|
||||||
assert body["updated_at"] == body["created_at"]
|
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:
|
def test_patch_thread_non_pin_metadata_bumps_updated_at() -> None:
|
||||||
@ -3867,3 +3868,69 @@ class TestRestReadsCarryMessageSeq:
|
|||||||
assert response.status_code == 200, response.text
|
assert response.status_code == 200, response.text
|
||||||
messages = response.json()["values"]["messages"]
|
messages = response.json()["values"]["messages"]
|
||||||
assert "deerflow_seq" not in (messages[0].get("additional_kwargs") or {})
|
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
|
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
|
budget fails; do not raise a ceiling without documenting and reviewing the measured
|
||||||
regression.
|
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,
|
SidecarProvider,
|
||||||
SidecarTrigger,
|
SidecarTrigger,
|
||||||
} from "@/components/workspace/sidecar";
|
} from "@/components/workspace/sidecar";
|
||||||
|
import { ThreadArchiveStatus } from "@/components/workspace/thread-archive-status";
|
||||||
import { ThreadBackgroundTasks } from "@/components/workspace/thread-background-tasks";
|
import { ThreadBackgroundTasks } from "@/components/workspace/thread-background-tasks";
|
||||||
import { ThreadSubagentBatches } from "@/components/workspace/thread-subagent-batches";
|
import { ThreadSubagentBatches } from "@/components/workspace/thread-subagent-batches";
|
||||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||||
@ -280,6 +281,14 @@ export default function AgentChatPage() {
|
|||||||
thread={thread}
|
thread={thread}
|
||||||
canonicalTitle={threadMetadata.data?.values?.title}
|
canonicalTitle={threadMetadata.data?.values?.title}
|
||||||
/>
|
/>
|
||||||
|
{!isNewThread &&
|
||||||
|
!isMock &&
|
||||||
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
||||||
|
<ThreadArchiveStatus
|
||||||
|
threadId={threadId}
|
||||||
|
metadata={threadMetadata.data?.metadata}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center sm:mr-4">
|
<div className="flex shrink-0 items-center sm:mr-4">
|
||||||
{!isNewThread &&
|
{!isNewThread &&
|
||||||
|
|||||||
@ -1,16 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { ArchiveRestore } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
ThreadChannelBadge,
|
ThreadChannelBadge,
|
||||||
ThreadChannelIcon,
|
ThreadChannelIcon,
|
||||||
} from "@/components/workspace/thread-channel-source";
|
} from "@/components/workspace/thread-channel-source";
|
||||||
import { VirtualThreadList } from "@/components/workspace/thread-list-virtualizer";
|
import { VirtualThreadList } from "@/components/workspace/thread-list-virtualizer";
|
||||||
|
import { useThreadArchiveAction } from "@/components/workspace/use-thread-archive-action";
|
||||||
import {
|
import {
|
||||||
WorkspaceBody,
|
WorkspaceBody,
|
||||||
WorkspaceContainer,
|
WorkspaceContainer,
|
||||||
@ -25,15 +28,23 @@ import {
|
|||||||
titleOfThread,
|
titleOfThread,
|
||||||
} from "@/core/threads/utils";
|
} from "@/core/threads/utils";
|
||||||
import { formatTimeAgo } from "@/core/utils/datetime";
|
import { formatTimeAgo } from "@/core/utils/datetime";
|
||||||
|
import { env } from "@/env";
|
||||||
|
|
||||||
export default function ChatsPage() {
|
export default function ChatsPage() {
|
||||||
const { t } = useI18n();
|
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 {
|
const {
|
||||||
data: infiniteThreads,
|
data: infiniteThreads,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
} = useInfiniteThreads();
|
} = useInfiniteThreads({ archived: staticWebsite ? undefined : archived });
|
||||||
const threadListModel = useMemo(
|
const threadListModel = useMemo(
|
||||||
() => buildThreadListModel(infiniteThreads?.pages ?? []),
|
() => buildThreadListModel(infiniteThreads?.pages ?? []),
|
||||||
[infiniteThreads?.pages],
|
[infiniteThreads?.pages],
|
||||||
@ -73,14 +84,26 @@ export default function ChatsPage() {
|
|||||||
);
|
);
|
||||||
observer.observe(element);
|
observer.observe(element);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage, isSearching]);
|
}, [fetchNextPage, hasNextPage, isFetchingNextPage, isSearching, view]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceContainer>
|
<WorkspaceContainer>
|
||||||
<WorkspaceHeader></WorkspaceHeader>
|
<WorkspaceHeader></WorkspaceHeader>
|
||||||
<WorkspaceBody>
|
<WorkspaceBody>
|
||||||
<div className="flex size-full flex-col">
|
<Tabs
|
||||||
<header className="flex shrink-0 items-center justify-center pt-8">
|
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
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
className="h-12 w-full max-w-(--container-width-md) text-xl"
|
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)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
<main className="min-h-0 flex-1">
|
<TabsContent value={view} className="min-h-0 flex-1">
|
||||||
<ScrollArea className="size-full py-4">
|
<main className="h-full">
|
||||||
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
|
<ScrollArea className="size-full py-4">
|
||||||
<VirtualThreadList
|
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
|
||||||
estimateSize={76}
|
{isError && (
|
||||||
items={filteredThreads}
|
<div role="alert" className="p-4 text-center">
|
||||||
scrollParentSelector='[data-slot="scroll-area-viewport"]'
|
<p>{t.chats.loadChatsFailed}</p>
|
||||||
renderItem={(thread) => {
|
<Button variant="outline" onClick={() => void refetch()}>
|
||||||
const channelSource = channelSourceOfThread(thread);
|
{t.chats.retryLoadChats}
|
||||||
return (
|
</Button>
|
||||||
<Link key={thread.thread_id} href={pathOfThread(thread)}>
|
</div>
|
||||||
<div className="flex flex-col gap-2 border-b p-4">
|
)}
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
{!isLoading && !isError && filteredThreads.length === 0 && (
|
||||||
<ThreadChannelIcon source={channelSource} />
|
<p
|
||||||
<div className="min-w-0 flex-1 truncate">
|
role="status"
|
||||||
{titleOfThread(thread)}
|
className="text-muted-foreground p-8 text-center"
|
||||||
</div>
|
>
|
||||||
<ThreadChannelBadge
|
{isSearching
|
||||||
source={channelSource}
|
? t.chats.noMatchingChats
|
||||||
className="hidden sm:inline-flex"
|
: archived
|
||||||
/>
|
? t.chats.noArchivedChats
|
||||||
</div>
|
: t.chats.noActiveChats}
|
||||||
{thread.updated_at && (
|
</p>
|
||||||
<div className="text-muted-foreground text-sm">
|
)}
|
||||||
{formatTimeAgo(thread.updated_at)}
|
<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>
|
</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>
|
</div>
|
||||||
</Link>
|
);
|
||||||
);
|
}}
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{hasNextPage && !isSearching && (
|
|
||||||
<div
|
|
||||||
ref={sentinelRef}
|
|
||||||
aria-hidden="true"
|
|
||||||
className="h-px w-full"
|
|
||||||
data-testid="chats-page-sentinel"
|
|
||||||
/>
|
/>
|
||||||
)}
|
{hasNextPage && !isSearching && (
|
||||||
{hasNextPage && isSearching && (
|
<div
|
||||||
<div className="flex justify-center p-4">
|
ref={sentinelRef}
|
||||||
<Button
|
aria-hidden="true"
|
||||||
variant="outline"
|
className="h-px w-full"
|
||||||
onClick={() => void fetchNextPage()}
|
data-testid="chats-page-sentinel"
|
||||||
disabled={isFetchingNextPage}
|
/>
|
||||||
data-testid="chats-page-load-more"
|
)}
|
||||||
>
|
{hasNextPage && isSearching && (
|
||||||
{isFetchingNextPage
|
<div className="flex justify-center p-4">
|
||||||
? t.chats.loadingMore
|
<Button
|
||||||
: t.chats.loadMoreToSearch}
|
variant="outline"
|
||||||
</Button>
|
onClick={() => void fetchNextPage()}
|
||||||
</div>
|
disabled={isFetchingNextPage}
|
||||||
)}
|
data-testid="chats-page-load-more"
|
||||||
</div>
|
>
|
||||||
</ScrollArea>
|
{isFetchingNextPage
|
||||||
</main>
|
? t.chats.loadingMore
|
||||||
</div>
|
: t.chats.loadMoreToSearch}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</main>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</WorkspaceBody>
|
</WorkspaceBody>
|
||||||
</WorkspaceContainer>
|
</WorkspaceContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import {
|
|||||||
SidecarProvider,
|
SidecarProvider,
|
||||||
SidecarTrigger,
|
SidecarTrigger,
|
||||||
} from "@/components/workspace/sidecar";
|
} from "@/components/workspace/sidecar";
|
||||||
|
import { ThreadArchiveStatus } from "@/components/workspace/thread-archive-status";
|
||||||
import { ThreadBackgroundTasks } from "@/components/workspace/thread-background-tasks";
|
import { ThreadBackgroundTasks } from "@/components/workspace/thread-background-tasks";
|
||||||
import { ThreadScheduledTasksLink } from "@/components/workspace/thread-scheduled-tasks-link";
|
import { ThreadScheduledTasksLink } from "@/components/workspace/thread-scheduled-tasks-link";
|
||||||
import { ThreadSubagentBatches } from "@/components/workspace/thread-subagent-batches";
|
import { ThreadSubagentBatches } from "@/components/workspace/thread-subagent-batches";
|
||||||
@ -293,6 +294,14 @@ export default function ChatPage() {
|
|||||||
thread={thread}
|
thread={thread}
|
||||||
canonicalTitle={threadMetadata.data?.values?.title}
|
canonicalTitle={threadMetadata.data?.values?.title}
|
||||||
/>
|
/>
|
||||||
|
{!isNewThread &&
|
||||||
|
!isMock &&
|
||||||
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
||||||
|
<ThreadArchiveStatus
|
||||||
|
threadId={threadId}
|
||||||
|
metadata={threadMetadata.data?.metadata}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
{!isNewThread &&
|
{!isNewThread &&
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
Archive,
|
||||||
Download,
|
Download,
|
||||||
FileJson,
|
FileJson,
|
||||||
FileText,
|
FileText,
|
||||||
@ -69,9 +70,11 @@ import { isIMEComposing } from "@/lib/ime";
|
|||||||
|
|
||||||
import { ThreadChannelIcon } from "./thread-channel-source";
|
import { ThreadChannelIcon } from "./thread-channel-source";
|
||||||
import { VirtualThreadList } from "./thread-list-virtualizer";
|
import { VirtualThreadList } from "./thread-list-virtualizer";
|
||||||
|
import { useThreadArchiveAction } from "./use-thread-archive-action";
|
||||||
|
|
||||||
export function RecentChatList() {
|
export function RecentChatList() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const archiveAction = useThreadArchiveAction();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { thread_id: threadIdFromPath, agent_name: agentNameFromPath } =
|
const { thread_id: threadIdFromPath, agent_name: agentNameFromPath } =
|
||||||
@ -84,7 +87,10 @@ export function RecentChatList() {
|
|||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
} = useInfiniteThreads();
|
} = useInfiniteThreads({
|
||||||
|
archived:
|
||||||
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? undefined : false,
|
||||||
|
});
|
||||||
const threadListModel = useMemo(
|
const threadListModel = useMemo(
|
||||||
() => buildThreadListModel(infiniteThreads?.pages ?? []),
|
() => buildThreadListModel(infiniteThreads?.pages ?? []),
|
||||||
[infiniteThreads?.pages],
|
[infiniteThreads?.pages],
|
||||||
@ -436,6 +442,18 @@ export function RecentChatList() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={archiveAction.isPending}
|
||||||
|
onSelect={() =>
|
||||||
|
archiveAction.setArchived(
|
||||||
|
thread.thread_id,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Archive className="text-muted-foreground" />
|
||||||
|
<span>{t.chats.archiveChat}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={() => handleDelete(thread)}
|
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 { useI18n } from "@/core/i18n/hooks";
|
||||||
import type { AgentThreadState } from "@/core/threads";
|
import type { AgentThreadState } from "@/core/threads";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { useThreadChat } from "./chats";
|
import { useThreadChat } from "./chats";
|
||||||
import { FlipDisplay } from "./flip-display";
|
import { FlipDisplay } from "./flip-display";
|
||||||
@ -15,6 +16,7 @@ export type ThreadTitleProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ThreadTitle({
|
export function ThreadTitle({
|
||||||
|
className,
|
||||||
threadId,
|
threadId,
|
||||||
thread,
|
thread,
|
||||||
canonicalTitle,
|
canonicalTitle,
|
||||||
@ -48,5 +50,12 @@ export function ThreadTitle({
|
|||||||
if (!title) {
|
if (!title) {
|
||||||
return null;
|
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
|
||||||
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",
|
searchChats: "Search chats",
|
||||||
branchLabel: (title, parentTitle) => `${title}, branch of ${parentTitle}`,
|
branchLabel: (title, parentTitle) => `${title}, branch of ${parentTitle}`,
|
||||||
loadMoreToSearch: "Load more to search older conversations",
|
loadMoreToSearch: "Load more to search older conversations",
|
||||||
|
|||||||
@ -520,6 +520,20 @@ export interface Translations {
|
|||||||
|
|
||||||
// Chats
|
// Chats
|
||||||
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;
|
searchChats: string;
|
||||||
branchLabel: (title: string, parentTitle: string) => string;
|
branchLabel: (title: string, parentTitle: string) => string;
|
||||||
loadMoreToSearch: string;
|
loadMoreToSearch: string;
|
||||||
|
|||||||
@ -607,6 +607,21 @@ export const zhCN: Translations = {
|
|||||||
|
|
||||||
// Chats
|
// Chats
|
||||||
chats: {
|
chats: {
|
||||||
|
noActiveChats: "暂无近期会话",
|
||||||
|
activeChats: "近期会话",
|
||||||
|
archivedChats: "已归档",
|
||||||
|
archiveChat: "归档",
|
||||||
|
restoreChat: "恢复",
|
||||||
|
archiveSuccess: "已归档",
|
||||||
|
restoreSuccess: "已恢复",
|
||||||
|
archiveFailed: "更新会话归档状态失败",
|
||||||
|
archiveDescription:
|
||||||
|
"归档会保留消息和文件,不会停止运行中的任务或暂停定时任务。",
|
||||||
|
undoArchive: "撤销",
|
||||||
|
noArchivedChats: "暂无已归档会话",
|
||||||
|
noMatchingChats: "已加载的会话中没有匹配结果",
|
||||||
|
loadChatsFailed: "加载会话失败",
|
||||||
|
retryLoadChats: "重试",
|
||||||
searchChats: "搜索对话",
|
searchChats: "搜索对话",
|
||||||
branchLabel: (title, parentTitle) => `${title},分叉自 ${parentTitle}`,
|
branchLabel: (title, parentTitle) => `${title},分叉自 ${parentTitle}`,
|
||||||
loadMoreToSearch: "加载更多以搜索更早的对话",
|
loadMoreToSearch: "加载更多以搜索更早的对话",
|
||||||
|
|||||||
@ -162,3 +162,33 @@ export async function compactThreadContext(
|
|||||||
|
|
||||||
return (await response.json()) as ThreadCompactResponse;
|
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,
|
branchThreadFromTurn,
|
||||||
fetchThreadTokenUsage,
|
fetchThreadTokenUsage,
|
||||||
patchThreadMetadata,
|
patchThreadMetadata,
|
||||||
|
searchThreadsByArchive,
|
||||||
type ThreadMetadataPatch,
|
type ThreadMetadataPatch,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import {
|
import {
|
||||||
@ -1438,10 +1439,20 @@ export function upsertThreadInInfiniteCache(
|
|||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
thread: AgentThread,
|
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(
|
queryClient.setQueriesData(
|
||||||
{
|
{
|
||||||
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
|
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
|
||||||
exact: false,
|
exact: false,
|
||||||
|
predicate: (query) => !hasArchiveFilter(query),
|
||||||
},
|
},
|
||||||
(oldData: InfiniteData<AgentThread[]> | undefined) => {
|
(oldData: InfiniteData<AgentThread[]> | undefined) => {
|
||||||
if (!oldData) {
|
if (!oldData) {
|
||||||
@ -2780,9 +2791,9 @@ const INFINITE_THREADS_NEXT_PAGE_PARAM = Symbol(
|
|||||||
);
|
);
|
||||||
|
|
||||||
type InfiniteThreadsParams = Omit<
|
type InfiniteThreadsParams = Omit<
|
||||||
Parameters<ThreadsClient["search"]>[0],
|
NonNullable<Parameters<ThreadsClient["search"]>[0]>,
|
||||||
"limit" | "offset"
|
"limit" | "offset"
|
||||||
>;
|
> & { archived?: boolean };
|
||||||
|
|
||||||
type InfiniteThreadsSearchClient = {
|
type InfiniteThreadsSearchClient = {
|
||||||
threads: {
|
threads: {
|
||||||
@ -2816,11 +2827,20 @@ export async function fetchInfiniteThreadsPage(
|
|||||||
|
|
||||||
while (threads.length < pageSize) {
|
while (threads.length < pageSize) {
|
||||||
const currentLimit = pageSize - threads.length;
|
const currentLimit = pageSize - threads.length;
|
||||||
const response = (await apiClient.threads.search<AgentThreadState>({
|
const response =
|
||||||
...params,
|
params.archived === undefined
|
||||||
limit: currentLimit,
|
? ((await apiClient.threads.search<AgentThreadState>({
|
||||||
offset,
|
...params,
|
||||||
})) as AgentThread[];
|
limit: currentLimit,
|
||||||
|
offset,
|
||||||
|
})) as AgentThread[])
|
||||||
|
: await searchThreadsByArchive({
|
||||||
|
...params,
|
||||||
|
archived: params.archived,
|
||||||
|
metadata: params.metadata ?? undefined,
|
||||||
|
limit: currentLimit,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
|
||||||
threads.push(...filterThreadSearchResults(response, params));
|
threads.push(...filterThreadSearchResults(response, params));
|
||||||
offset += response.length;
|
offset += response.length;
|
||||||
@ -2953,7 +2973,7 @@ function setThreadInCaches(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setThreadMetadataInCaches(
|
export function setThreadMetadataInCaches(
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
threadId: string,
|
threadId: string,
|
||||||
metadata: ThreadMetadataPatch,
|
metadata: ThreadMetadataPatch,
|
||||||
@ -3116,9 +3136,8 @@ export function usePinThread() {
|
|||||||
patchThreadMetadata(threadId, {
|
patchThreadMetadata(threadId, {
|
||||||
[THREAD_PINNED_METADATA_KEY]: pinned,
|
[THREAD_PINNED_METADATA_KEY]: pinned,
|
||||||
}),
|
}),
|
||||||
onSuccess(response, { threadId, pinned }) {
|
onSuccess(_response, { threadId, pinned }) {
|
||||||
setThreadMetadataInCaches(queryClient, threadId, {
|
setThreadMetadataInCaches(queryClient, threadId, {
|
||||||
...(response.metadata ?? {}),
|
|
||||||
[THREAD_PINNED_METADATA_KEY]: pinned,
|
[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
|
// client-supplied key. Keep in sync with the backend thread_meta constant and
|
||||||
// the E2E mock-api constant.
|
// the E2E mock-api constant.
|
||||||
export const THREAD_PINNED_METADATA_KEY = "deerflow_pinned";
|
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 = {
|
export type ChannelThreadSource = {
|
||||||
type: "im_channel";
|
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.
|
// observer and never interferes with routing.
|
||||||
let searchRequestCount = 0;
|
let searchRequestCount = 0;
|
||||||
page.on("request", (request) => {
|
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;
|
searchRequestCount += 1;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -100,6 +107,7 @@ test.describe("Thread list infinite scroll (issue #3482)", () => {
|
|||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
});
|
});
|
||||||
const baselineRequests = searchRequestCount;
|
const baselineRequests = searchRequestCount;
|
||||||
|
expect(baselineRequests).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Type a query that matches nothing in the first page (and nothing at
|
// Type a query that matches nothing in the first page (and nothing at
|
||||||
// all, since titles are deterministic).
|
// all, since titles are deterministic).
|
||||||
|
|||||||
@ -680,18 +680,26 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Thread search — sidebar thread list & chats list page
|
// 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 body = sortThreadSearchResults(threads).map(threadSearchResult);
|
||||||
|
|
||||||
let limit: number | undefined;
|
let limit: number | undefined;
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
try {
|
try {
|
||||||
const postData = route.request().postDataJSON() as {
|
const postData = route.request().postDataJSON() as {
|
||||||
|
archived?: boolean;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
} | null;
|
} | null;
|
||||||
if (postData) {
|
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") {
|
if (typeof postData.limit === "number") {
|
||||||
limit = postData.limit;
|
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