mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-27 00:17:53 +00:00
feat(frontend): pin recent chats (#4442)
* feat(frontend): pin recent chats * fix(threads): address pin-chat review feedback - Stop bumping updated_at on metadata-only PATCH (pin/unpin) via a new update_metadata(touch=False) path so unpinning no longer jumps a chat to the top of the updated_at-sorted recent list. - Narrow patchThreadMetadata to a ThreadMetadataPatchResponse matching the Gateway's actual response (no values/context). - Namespace the pinned metadata key as deerflow_pinned for consistency with deerflow_sidecar / deerflow_branch. - Cover touch/touch=False behavior in repo + router tests; document the e2e mock's updated_at preservation now mirrors production. * style(frontend): format thread utils test * fix(threads): make pinned ordering server-side * test(frontend): keep infinite-scroll fixture order stable * test(frontend): stabilize lark reconnect e2e * docs: clarify thread pin metadata contract
This commit is contained in:
parent
04b7e693f0
commit
68c0ffdac8
@ -45,6 +45,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.runtime import 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
|
||||
@ -116,6 +117,11 @@ 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 _message_id(message: Any) -> str | None:
|
||||
if isinstance(message, dict):
|
||||
raw = message.get("id")
|
||||
@ -973,13 +979,17 @@ async def patch_thread(thread_id: str, body: ThreadPatchRequest, request: Reques
|
||||
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``.
|
||||
# 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)
|
||||
try:
|
||||
await thread_store.update_metadata(thread_id, body.metadata)
|
||||
await thread_store.update_metadata(thread_id, body.metadata, touch=touch)
|
||||
except Exception:
|
||||
logger.exception("Failed to patch thread %s", sanitize_log_param(thread_id))
|
||||
raise HTTPException(status_code=500, detail="Failed to update thread")
|
||||
|
||||
# Re-read to get the merged metadata + refreshed updated_at
|
||||
# Re-read to get the merged metadata and the store's timestamp decision.
|
||||
record = await thread_store.get(thread_id) or record
|
||||
return ThreadResponse(
|
||||
thread_id=thread_id,
|
||||
|
||||
@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from deerflow.persistence.thread_meta.base import InvalidMetadataFilterError, ThreadMetaStore
|
||||
from deerflow.persistence.thread_meta.base import 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
|
||||
@ -16,6 +16,7 @@ if TYPE_CHECKING:
|
||||
__all__ = [
|
||||
"InvalidMetadataFilterError",
|
||||
"MemoryThreadMetaStore",
|
||||
"THREAD_PINNED_METADATA_KEY",
|
||||
"ThreadMetaRepository",
|
||||
"ThreadMetaRow",
|
||||
"ThreadMetaStore",
|
||||
|
||||
@ -19,6 +19,11 @@ from typing import Any
|
||||
|
||||
from deerflow.runtime.user_context import AUTO, _AutoSentinel
|
||||
|
||||
# Cross-component metadata key. Keep in sync with
|
||||
# ``frontend/src/core/threads/utils.ts`` and
|
||||
# ``frontend/tests/e2e/utils/mock-api.ts``.
|
||||
THREAD_PINNED_METADATA_KEY = "deerflow_pinned"
|
||||
|
||||
|
||||
class InvalidMetadataFilterError(ValueError):
|
||||
"""Raised when all client-supplied metadata filter keys are rejected."""
|
||||
@ -51,6 +56,12 @@ class ThreadMetaStore(abc.ABC):
|
||||
offset: int = 0,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Search threads.
|
||||
|
||||
Results are ordered with pinned threads first
|
||||
(``metadata.deerflow_pinned is True``), then by ``updated_at`` and
|
||||
``thread_id`` descending within each group.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
@ -62,12 +73,17 @@ class ThreadMetaStore(abc.ABC):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
async def update_metadata(self, thread_id: str, metadata: dict, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
|
||||
async def update_metadata(self, thread_id: str, metadata: dict, *, touch: bool = True, user_id: str | None | _AutoSentinel = AUTO) -> None:
|
||||
"""Merge ``metadata`` into the thread's metadata field.
|
||||
|
||||
Existing keys are overwritten by the new values; keys absent from
|
||||
``metadata`` are preserved. No-op if the thread does not exist
|
||||
or the owner check fails.
|
||||
|
||||
When ``touch`` is ``True`` (default) the row's ``updated_at`` is
|
||||
refreshed so the change bumps recency ordering. Pass ``touch=False``
|
||||
for metadata that is not conversation activity (e.g. pin/unpin) so the
|
||||
thread keeps its place in ``updated_at``-sorted lists.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@ -11,11 +11,12 @@ from typing import Any
|
||||
|
||||
from langgraph.store.base import BaseStore
|
||||
|
||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
||||
from deerflow.persistence.thread_meta.base import 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
|
||||
|
||||
THREADS_NS: tuple[str, ...] = ("threads",)
|
||||
SEARCH_PAGE_SIZE = 500
|
||||
|
||||
|
||||
class MemoryThreadMetaStore(ThreadMetaStore):
|
||||
@ -75,6 +76,12 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
||||
offset: int = 0,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Search threads by materializing matches, then sorting in Python.
|
||||
|
||||
The memory backend loads all matching rows in chunks before slicing so
|
||||
it can mirror SQL's pinned-first ordering. Use the SQL store for
|
||||
scalable paginated I/O.
|
||||
"""
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="MemoryThreadMetaStore.search")
|
||||
filter_dict: dict[str, Any] = {}
|
||||
if metadata:
|
||||
@ -84,13 +91,25 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
||||
if resolved_user_id is not None:
|
||||
filter_dict["user_id"] = resolved_user_id
|
||||
|
||||
items = await self._store.asearch(
|
||||
THREADS_NS,
|
||||
filter=filter_dict or None,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return [self._item_to_dict(item) for item in items]
|
||||
items = []
|
||||
search_offset = 0
|
||||
while True:
|
||||
page = await self._store.asearch(
|
||||
THREADS_NS,
|
||||
filter=filter_dict or None,
|
||||
limit=SEARCH_PAGE_SIZE,
|
||||
offset=search_offset,
|
||||
)
|
||||
if not page:
|
||||
break
|
||||
items.extend(page)
|
||||
if len(page) < SEARCH_PAGE_SIZE:
|
||||
break
|
||||
search_offset += len(page)
|
||||
|
||||
records = [self._item_to_dict(item) for item in items]
|
||||
records.sort(key=self._sort_key, reverse=True)
|
||||
return records[offset : offset + limit]
|
||||
|
||||
async def check_access(self, thread_id: str, user_id: str, *, require_existing: bool = False) -> bool:
|
||||
item = await self._store.aget(THREADS_NS, thread_id)
|
||||
@ -117,14 +136,15 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
||||
record["updated_at"] = now_iso()
|
||||
await self._store.aput(THREADS_NS, thread_id, record)
|
||||
|
||||
async def update_metadata(self, thread_id: str, metadata: dict, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
|
||||
async def update_metadata(self, thread_id: str, metadata: dict, *, touch: bool = True, user_id: str | None | _AutoSentinel = AUTO) -> None:
|
||||
record = await self._get_owned_record(thread_id, user_id, "MemoryThreadMetaStore.update_metadata")
|
||||
if record is None:
|
||||
return
|
||||
merged = dict(record.get("metadata") or {})
|
||||
merged.update(metadata)
|
||||
record["metadata"] = merged
|
||||
record["updated_at"] = now_iso()
|
||||
if touch:
|
||||
record["updated_at"] = now_iso()
|
||||
await self._store.aput(THREADS_NS, thread_id, record)
|
||||
|
||||
async def update_owner(self, thread_id: str, owner_user_id: str, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
|
||||
@ -157,3 +177,9 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
||||
"created_at": coerce_iso(val.get("created_at", "")),
|
||||
"updated_at": coerce_iso(val.get("updated_at", "")),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _sort_key(record: dict[str, Any]) -> tuple[bool, str, str]:
|
||||
metadata = record.get("metadata")
|
||||
pinned = isinstance(metadata, dict) and metadata.get(THREAD_PINNED_METADATA_KEY) is True
|
||||
return (pinned, str(record.get("updated_at") or ""), str(record.get("thread_id") or ""))
|
||||
|
||||
@ -6,11 +6,12 @@ import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import case, select, update
|
||||
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 InvalidMetadataFilterError, ThreadMetaStore
|
||||
from deerflow.persistence.thread_meta.base import 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
|
||||
@ -123,7 +124,15 @@ class ThreadMetaRepository(ThreadMetaStore):
|
||||
context. Pass ``user_id=None`` to bypass (migration/CLI).
|
||||
"""
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.search")
|
||||
stmt = select(ThreadMetaRow).order_by(ThreadMetaRow.updated_at.desc(), ThreadMetaRow.thread_id.desc())
|
||||
pinned_order = case(
|
||||
(json_match(ThreadMetaRow.metadata_json, THREAD_PINNED_METADATA_KEY, True), 1),
|
||||
else_=0,
|
||||
)
|
||||
stmt = select(ThreadMetaRow).order_by(
|
||||
pinned_order.desc(),
|
||||
ThreadMetaRow.updated_at.desc(),
|
||||
ThreadMetaRow.thread_id.desc(),
|
||||
)
|
||||
if resolved_user_id is not None:
|
||||
stmt = stmt.where(ThreadMetaRow.user_id == resolved_user_id)
|
||||
if status:
|
||||
@ -190,6 +199,7 @@ class ThreadMetaRepository(ThreadMetaStore):
|
||||
thread_id: str,
|
||||
metadata: dict,
|
||||
*,
|
||||
touch: bool = True,
|
||||
user_id: str | None | _AutoSentinel = AUTO,
|
||||
) -> None:
|
||||
"""Merge ``metadata`` into ``metadata_json``.
|
||||
@ -197,6 +207,9 @@ class ThreadMetaRepository(ThreadMetaStore):
|
||||
Read-modify-write inside a single session/transaction so concurrent
|
||||
callers see consistent state. No-op if the row does not exist or
|
||||
the user_id check fails.
|
||||
|
||||
``touch`` refreshes ``updated_at`` (default); pass ``touch=False`` to
|
||||
preserve recency ordering for metadata-only changes such as pin/unpin.
|
||||
"""
|
||||
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.update_metadata")
|
||||
async with self._sf() as session:
|
||||
@ -208,7 +221,14 @@ class ThreadMetaRepository(ThreadMetaStore):
|
||||
merged = dict(row.metadata_json or {})
|
||||
merged.update(metadata)
|
||||
row.metadata_json = merged
|
||||
row.updated_at = datetime.now(UTC)
|
||||
if touch:
|
||||
row.updated_at = datetime.now(UTC)
|
||||
else:
|
||||
# ``updated_at`` has an ``onupdate`` hook that fires on any row
|
||||
# UPDATE unless the column has an explicit SET value. Mark the
|
||||
# current value dirty so SQLAlchemy emits it in SET, skips the
|
||||
# hook, and preserves recency ordering.
|
||||
flag_modified(row, "updated_at")
|
||||
await session.commit()
|
||||
|
||||
async def update_owner(
|
||||
|
||||
@ -4,7 +4,7 @@ import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from deerflow.persistence.thread_meta import InvalidMetadataFilterError, ThreadMetaRepository
|
||||
from deerflow.persistence.thread_meta import THREAD_PINNED_METADATA_KEY, InvalidMetadataFilterError, ThreadMetaRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -137,6 +137,38 @@ class TestThreadMetaRepository:
|
||||
async def test_update_metadata_nonexistent_is_noop(self, repo):
|
||||
await repo.update_metadata("nonexistent", {"k": "v"}) # should not raise
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_metadata_touches_updated_at_by_default(self, repo):
|
||||
await repo.create("t1", metadata={"a": 1})
|
||||
original = (await repo.get("t1"))["updated_at"]
|
||||
|
||||
await repo.update_metadata("t1", {"b": 2})
|
||||
|
||||
record = await repo.get("t1")
|
||||
assert record["metadata"] == {"a": 1, "b": 2}
|
||||
assert record["updated_at"] >= original
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_metadata_touch_false_preserves_updated_at(self, repo):
|
||||
await repo.create("t1", metadata={"a": 1})
|
||||
original = (await repo.get("t1"))["updated_at"]
|
||||
|
||||
# Pin/unpin style patch must not bump recency ordering.
|
||||
await repo.update_metadata("t1", {THREAD_PINNED_METADATA_KEY: True}, touch=False)
|
||||
|
||||
record = await repo.get("t1")
|
||||
assert record["metadata"] == {"a": 1, THREAD_PINNED_METADATA_KEY: True}
|
||||
assert record["updated_at"] == original
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_orders_pinned_threads_before_newer_unpinned_threads(self, repo):
|
||||
await repo.create("older-pinned", metadata={THREAD_PINNED_METADATA_KEY: True})
|
||||
await repo.create("newer-unpinned")
|
||||
|
||||
results = await repo.search(limit=1)
|
||||
|
||||
assert [record["thread_id"] for record in results] == ["older-pinned"]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_owner_with_bypass_moves_row(self, repo):
|
||||
await repo.create("t1", user_id="default", metadata={"source": "channel"})
|
||||
|
||||
@ -17,7 +17,7 @@ from langgraph.types import Overwrite
|
||||
from app.gateway import services as gateway_services
|
||||
from app.gateway.routers import thread_runs, threads
|
||||
from deerflow.config.paths import Paths
|
||||
from deerflow.persistence.thread_meta import InvalidMetadataFilterError
|
||||
from deerflow.persistence.thread_meta import THREAD_PINNED_METADATA_KEY, InvalidMetadataFilterError
|
||||
from deerflow.persistence.thread_meta.memory import THREADS_NS, MemoryThreadMetaStore
|
||||
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor
|
||||
|
||||
@ -957,7 +957,12 @@ def test_get_thread_preserves_metadata_status_without_checkpoint(stored_status:
|
||||
assert response.json()["status"] == stored_status
|
||||
|
||||
|
||||
def test_patch_thread_returns_iso_and_advances_updated_at() -> None:
|
||||
def test_patch_thread_pin_returns_iso_and_preserves_updated_at() -> None:
|
||||
"""A pin/unpin PATCH must not bump ``updated_at``.
|
||||
|
||||
Pinning or unpinning a chat does not represent conversation activity.
|
||||
Timestamps are still surfaced as ISO via ``coerce_iso``.
|
||||
"""
|
||||
app, store, _checkpointer = _build_thread_app()
|
||||
thread_id = "patch-target"
|
||||
|
||||
@ -982,16 +987,53 @@ def test_patch_thread_returns_iso_and_advances_updated_at() -> None:
|
||||
asyncio.run(_seed())
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.patch(f"/api/threads/{thread_id}", json={"metadata": {"k": "v1"}})
|
||||
response = client.patch(
|
||||
f"/api/threads/{thread_id}",
|
||||
json={"metadata": {THREAD_PINNED_METADATA_KEY: True}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert _ISO_TIMESTAMP_RE.match(body["created_at"]), body["created_at"]
|
||||
assert _ISO_TIMESTAMP_RE.match(body["updated_at"]), body["updated_at"]
|
||||
# Patch issues a fresh ``updated_at`` via ``MemoryThreadMetaStore.update_metadata``,
|
||||
# so it must be > the migrated legacy ``created_at`` (both ISO strings
|
||||
# sort lexicographically by time when the format is consistent).
|
||||
assert body["updated_at"] > body["created_at"]
|
||||
# ``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}
|
||||
|
||||
|
||||
def test_patch_thread_non_pin_metadata_bumps_updated_at() -> None:
|
||||
"""The public metadata PATCH endpoint still bumps recency by default."""
|
||||
app, store, _checkpointer = _build_thread_app()
|
||||
thread_id = "patch-target"
|
||||
|
||||
legacy_created = "946684800.000000"
|
||||
legacy_updated = "946684800.000000"
|
||||
|
||||
async def _seed() -> None:
|
||||
await store.aput(
|
||||
THREADS_NS,
|
||||
thread_id,
|
||||
{
|
||||
"thread_id": thread_id,
|
||||
"status": "idle",
|
||||
"created_at": legacy_created,
|
||||
"updated_at": legacy_updated,
|
||||
"metadata": {"k": "v0"},
|
||||
},
|
||||
)
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(_seed())
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.patch(f"/api/threads/{thread_id}", json={"metadata": {"k": "v1"}})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert _ISO_TIMESTAMP_RE.match(body["updated_at"]), body["updated_at"]
|
||||
assert body["updated_at"] != body["created_at"]
|
||||
assert body["metadata"] == {"k": "v1"}
|
||||
|
||||
|
||||
@ -1044,6 +1086,44 @@ def test_search_threads_normalizes_legacy_unix_seconds_to_iso() -> None:
|
||||
assert _ISO_TIMESTAMP_RE.match(item["updated_at"]), item
|
||||
|
||||
|
||||
def test_search_threads_returns_pinned_threads_before_newer_unpinned_threads() -> None:
|
||||
app, store, _checkpointer = _build_thread_app()
|
||||
|
||||
async def _seed() -> None:
|
||||
await store.aput(
|
||||
THREADS_NS,
|
||||
"newer-unpinned",
|
||||
{
|
||||
"thread_id": "newer-unpinned",
|
||||
"status": "idle",
|
||||
"created_at": "2026-07-01T00:00:00+00:00",
|
||||
"updated_at": "2026-07-20T00:00:00+00:00",
|
||||
"metadata": {},
|
||||
},
|
||||
)
|
||||
await store.aput(
|
||||
THREADS_NS,
|
||||
"older-pinned",
|
||||
{
|
||||
"thread_id": "older-pinned",
|
||||
"status": "idle",
|
||||
"created_at": "2026-06-01T00:00:00+00:00",
|
||||
"updated_at": "2026-06-01T00:00:00+00:00",
|
||||
"metadata": {THREAD_PINNED_METADATA_KEY: True},
|
||||
},
|
||||
)
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(_seed())
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/api/threads/search", json={"limit": 1})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert [item["thread_id"] for item in response.json()] == ["older-pinned"]
|
||||
|
||||
|
||||
def test_memory_thread_meta_store_writes_iso_on_create() -> None:
|
||||
"""``MemoryThreadMetaStore.create`` must emit ISO so newly created
|
||||
threads serialize correctly without depending on the router's
|
||||
|
||||
@ -6,6 +6,8 @@ import {
|
||||
FileText,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Pin,
|
||||
PinOff,
|
||||
Share2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
@ -53,12 +55,15 @@ import {
|
||||
import {
|
||||
useDeleteThread,
|
||||
useInfiniteThreads,
|
||||
usePinThread,
|
||||
useRenameThread,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import {
|
||||
channelSourceOfThread,
|
||||
isThreadPinned,
|
||||
pathOfThread,
|
||||
sortPinnedThreads,
|
||||
titleOfThread,
|
||||
} from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
@ -91,6 +96,7 @@ export function RecentChatList() {
|
||||
return true;
|
||||
});
|
||||
}, [infiniteThreads]);
|
||||
const displayedThreads = useMemo(() => sortPinnedThreads(threads), [threads]);
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
@ -112,6 +118,7 @@ export function RecentChatList() {
|
||||
|
||||
const { mutate: deleteThread } = useDeleteThread();
|
||||
const { mutate: renameThread } = useRenameThread();
|
||||
const { mutate: updatePinnedThread } = usePinThread();
|
||||
|
||||
// Rename dialog state
|
||||
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||
@ -187,6 +194,25 @@ export function RecentChatList() {
|
||||
}
|
||||
}, [renameThread, renameThreadId, renameValue, t.common.renameFailed]);
|
||||
|
||||
const handleTogglePin = useCallback(
|
||||
(thread: AgentThread) => {
|
||||
updatePinnedThread(
|
||||
{
|
||||
threadId: thread.thread_id,
|
||||
pinned: !isThreadPinned(thread),
|
||||
},
|
||||
{
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t.chats.pinChatFailed,
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[t.chats.pinChatFailed, updatePinnedThread],
|
||||
);
|
||||
|
||||
const handleShare = useCallback(
|
||||
async (thread: AgentThread) => {
|
||||
// Always use Vercel URL for sharing so others can access
|
||||
@ -251,9 +277,10 @@ export function RecentChatList() {
|
||||
<SidebarGroupContent className="group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0">
|
||||
<SidebarMenu>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{threads.map((thread) => {
|
||||
{displayedThreads.map((thread) => {
|
||||
const isActive = pathOfThread(thread) === pathname;
|
||||
const channelSource = channelSourceOfThread(thread);
|
||||
const pinned = isThreadPinned(thread);
|
||||
return (
|
||||
<SidebarMenuItem
|
||||
key={thread.thread_id}
|
||||
@ -265,6 +292,12 @@ export function RecentChatList() {
|
||||
href={pathOfThread(thread)}
|
||||
>
|
||||
<ThreadChannelIcon source={channelSource} />
|
||||
{pinned && (
|
||||
<Pin
|
||||
aria-hidden="true"
|
||||
className="text-muted-foreground size-3.5 shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 truncate">
|
||||
{titleOfThread(thread)}
|
||||
</span>
|
||||
@ -296,6 +329,18 @@ export function RecentChatList() {
|
||||
side={"right"}
|
||||
align={"start"}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleTogglePin(thread)}
|
||||
>
|
||||
{pinned ? (
|
||||
<PinOff className="text-muted-foreground" />
|
||||
) : (
|
||||
<Pin className="text-muted-foreground" />
|
||||
)}
|
||||
<span>
|
||||
{pinned ? t.chats.unpinChat : t.chats.pinChat}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
handleRenameClick(
|
||||
|
||||
@ -514,6 +514,9 @@ export const enUS: Translations = {
|
||||
loadMoreToSearch: "Load more to search older conversations",
|
||||
loadingMore: "Loading more...",
|
||||
loadOlderChats: "Load older chats",
|
||||
pinChat: "Pin chat",
|
||||
unpinChat: "Unpin chat",
|
||||
pinChatFailed: "Failed to update pinned chat",
|
||||
},
|
||||
|
||||
// Sidecar
|
||||
|
||||
@ -413,6 +413,9 @@ export interface Translations {
|
||||
loadMoreToSearch: string;
|
||||
loadingMore: string;
|
||||
loadOlderChats: string;
|
||||
pinChat: string;
|
||||
unpinChat: string;
|
||||
pinChatFailed: string;
|
||||
};
|
||||
|
||||
// Sidecar
|
||||
|
||||
@ -491,6 +491,9 @@ export const zhCN: Translations = {
|
||||
loadMoreToSearch: "加载更多以搜索更早的对话",
|
||||
loadingMore: "正在加载...",
|
||||
loadOlderChats: "加载更早的对话",
|
||||
pinChat: "置顶对话",
|
||||
unpinChat: "取消置顶",
|
||||
pinChatFailed: "更新对话置顶状态失败",
|
||||
},
|
||||
|
||||
// Sidecar
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { fetch as fetchWithAuth } from "@/core/api/fetcher";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { ThreadTokenUsageResponse } from "./types";
|
||||
import type { AgentThread, ThreadTokenUsageResponse } from "./types";
|
||||
|
||||
export type ThreadCompactResponse = {
|
||||
thread_id: string;
|
||||
@ -34,6 +34,19 @@ export type BranchThreadFromTurnInput = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type ThreadMetadataPatch = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* The subset of thread fields the Gateway ``PATCH /api/threads/{id}`` handler
|
||||
* returns with meaningful values. The endpoint's ``ThreadResponse`` model also
|
||||
* serializes default ``values`` and ``interrupts``, but PATCH leaves those empty;
|
||||
* callers that need state should read it via a full thread fetch instead.
|
||||
*/
|
||||
export type ThreadMetadataPatchResponse = Pick<
|
||||
AgentThread,
|
||||
"thread_id" | "status" | "created_at" | "updated_at" | "metadata"
|
||||
>;
|
||||
|
||||
async function readThreadAPIError(
|
||||
response: Response,
|
||||
fallback: string,
|
||||
@ -97,6 +110,30 @@ export async function branchThreadFromTurn(
|
||||
return (await response.json()) as ThreadBranchResponse;
|
||||
}
|
||||
|
||||
export async function patchThreadMetadata(
|
||||
threadId: string,
|
||||
metadata: ThreadMetadataPatch,
|
||||
): Promise<ThreadMetadataPatchResponse> {
|
||||
const response = await fetchWithAuth(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ metadata }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readThreadAPIError(response, "Failed to update conversation."),
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as ThreadMetadataPatchResponse;
|
||||
}
|
||||
|
||||
export async function compactThreadContext(
|
||||
threadId: string,
|
||||
options: CompactThreadContextOptions = {},
|
||||
|
||||
@ -29,7 +29,12 @@ import { messageToStep } from "../tasks/steps";
|
||||
import type { UploadedFileInfo } from "../uploads";
|
||||
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
|
||||
|
||||
import { branchThreadFromTurn, fetchThreadTokenUsage } from "./api";
|
||||
import {
|
||||
branchThreadFromTurn,
|
||||
fetchThreadTokenUsage,
|
||||
patchThreadMetadata,
|
||||
type ThreadMetadataPatch,
|
||||
} from "./api";
|
||||
import {
|
||||
buildThreadsSearchQueryOptions,
|
||||
DEFAULT_THREAD_SEARCH_PARAMS,
|
||||
@ -43,6 +48,7 @@ import type {
|
||||
RunMessage,
|
||||
ThreadTokenUsageResponse,
|
||||
} from "./types";
|
||||
import { THREAD_PINNED_METADATA_KEY } from "./utils";
|
||||
|
||||
export type ThreadStreamOptions = {
|
||||
threadId?: string | null | undefined;
|
||||
@ -2137,6 +2143,62 @@ export function filterInfiniteThreadsCache(
|
||||
};
|
||||
}
|
||||
|
||||
function mergeThreadMetadata(
|
||||
thread: AgentThread,
|
||||
metadata: ThreadMetadataPatch,
|
||||
): AgentThread {
|
||||
return {
|
||||
...thread,
|
||||
metadata: {
|
||||
...(thread.metadata ?? {}),
|
||||
...metadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setThreadMetadataInCaches(
|
||||
queryClient: QueryClient,
|
||||
threadId: string,
|
||||
metadata: ThreadMetadataPatch,
|
||||
) {
|
||||
queryClient.setQueriesData(
|
||||
{
|
||||
queryKey: ["threads", "search"],
|
||||
exact: false,
|
||||
},
|
||||
(oldData: Array<AgentThread> | undefined) => {
|
||||
if (!oldData) {
|
||||
return oldData;
|
||||
}
|
||||
return oldData.map((thread) =>
|
||||
thread.thread_id === threadId
|
||||
? mergeThreadMetadata(thread, metadata)
|
||||
: thread,
|
||||
);
|
||||
},
|
||||
);
|
||||
queryClient.setQueriesData(
|
||||
{
|
||||
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
|
||||
exact: false,
|
||||
},
|
||||
(oldData: InfiniteData<AgentThread[]> | undefined) =>
|
||||
mapInfiniteThreadsCache(oldData, (thread) =>
|
||||
thread.thread_id === threadId
|
||||
? mergeThreadMetadata(thread, metadata)
|
||||
: thread,
|
||||
),
|
||||
);
|
||||
queryClient.setQueriesData(
|
||||
{
|
||||
queryKey: ["thread", "metadata", threadId],
|
||||
exact: false,
|
||||
},
|
||||
(oldData: AgentThread | null | undefined) =>
|
||||
oldData ? mergeThreadMetadata(oldData, metadata) : oldData,
|
||||
);
|
||||
}
|
||||
|
||||
export function useInfiniteThreads(
|
||||
params: InfiniteThreadsParams = {
|
||||
sortBy: "updated_at",
|
||||
@ -2263,6 +2325,34 @@ export function useBranchThread() {
|
||||
});
|
||||
}
|
||||
|
||||
export function usePinThread() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
threadId,
|
||||
pinned,
|
||||
}: {
|
||||
threadId: string;
|
||||
pinned: boolean;
|
||||
}) =>
|
||||
patchThreadMetadata(threadId, {
|
||||
[THREAD_PINNED_METADATA_KEY]: pinned,
|
||||
}),
|
||||
onSuccess(response, { threadId, pinned }) {
|
||||
setThreadMetadataInCaches(queryClient, threadId, {
|
||||
...(response.metadata ?? {}),
|
||||
[THREAD_PINNED_METADATA_KEY]: pinned,
|
||||
});
|
||||
},
|
||||
onSettled() {
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRunDetail(threadId: string, runId: string) {
|
||||
const apiClient = getAPIClient();
|
||||
return useQuery<Run>({
|
||||
|
||||
@ -2,6 +2,12 @@ import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import type { AgentThread, AgentThreadContext } from "./types";
|
||||
|
||||
// Namespaced to match other internal metadata keys (``deerflow_sidecar``,
|
||||
// ``deerflow_branch``) so it cannot collide with a future feature or a
|
||||
// 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 type ChannelThreadSource = {
|
||||
type: "im_channel";
|
||||
provider: string;
|
||||
@ -60,6 +66,24 @@ export function titleOfThread(thread: AgentThread) {
|
||||
return thread.values?.title ?? "Untitled";
|
||||
}
|
||||
|
||||
export function isThreadPinned(thread: Pick<AgentThread, "metadata">) {
|
||||
return thread.metadata?.[THREAD_PINNED_METADATA_KEY] === true;
|
||||
}
|
||||
|
||||
export function sortPinnedThreads<T extends Pick<AgentThread, "metadata">>(
|
||||
threads: readonly T[],
|
||||
) {
|
||||
return threads
|
||||
.map((thread, index) => ({ thread, index }))
|
||||
.sort((left, right) => {
|
||||
const pinnedDiff =
|
||||
Number(isThreadPinned(right.thread)) -
|
||||
Number(isThreadPinned(left.thread));
|
||||
return pinnedDiff || left.index - right.index;
|
||||
})
|
||||
.map(({ thread }) => thread);
|
||||
}
|
||||
|
||||
const CHANNEL_PROVIDER_LABELS: Record<string, string> = {
|
||||
dingtalk: "DingTalk",
|
||||
discord: "Discord",
|
||||
|
||||
@ -49,10 +49,20 @@ test.describe("Integrations settings", () => {
|
||||
mockLangGraphAPI(page);
|
||||
let authStartRequest: unknown;
|
||||
const authCompleteRequests: unknown[] = [];
|
||||
let authCompleteCount = 0;
|
||||
await page.route(
|
||||
"**/api/integrations/lark/auth/complete",
|
||||
async (route) => {
|
||||
authCompleteRequests.push(route.request().postDataJSON());
|
||||
authCompleteCount += 1;
|
||||
if (authCompleteCount > 1) {
|
||||
await route.fulfill({
|
||||
status: 504,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: "Authorization still pending." }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fallback();
|
||||
},
|
||||
);
|
||||
|
||||
@ -11,14 +11,16 @@ const TOTAL_THREADS = 120;
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const THREADS = Array.from({ length: TOTAL_THREADS }, (_, i) => {
|
||||
// Pad index so titles sort deterministically as strings. The thread-search
|
||||
// mock returns threads in the order provided, so paging boundaries are
|
||||
// stable across runs.
|
||||
// Pad index so titles sort deterministically as strings. Keep updated_at
|
||||
// monotonically descending to match the backend's updated_at-desc search
|
||||
// order, so paging boundaries are stable across runs.
|
||||
const index = String(i + 1).padStart(3, "0");
|
||||
return {
|
||||
thread_id: `00000000-0000-0000-0000-0000000${index.padStart(5, "0")}`,
|
||||
title: `Conversation ${index}`,
|
||||
updated_at: `2025-06-${String((i % 28) + 1).padStart(2, "0")}T12:00:00Z`,
|
||||
updated_at: new Date(
|
||||
Date.UTC(2025, 5, 30, 12, 0, 0) - i * 60_000,
|
||||
).toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
92
frontend/tests/e2e/thread-list-pin.spec.ts
Normal file
92
frontend/tests/e2e/thread-list-pin.spec.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI, THREAD_PINNED_METADATA_KEY } from "./utils/mock-api";
|
||||
|
||||
const NEWEST_THREAD_ID = "00000000-0000-0000-0000-000000000901";
|
||||
const OLDER_THREAD_ID = "00000000-0000-0000-0000-000000000902";
|
||||
|
||||
async function recentChatTitles(page: Page) {
|
||||
return page
|
||||
.locator('a[data-sidebar="menu-button"][href^="/workspace/chats/"]')
|
||||
.evaluateAll((links) =>
|
||||
links
|
||||
.map((link) => link.textContent?.replace(/\s+/g, " ").trim() ?? "")
|
||||
.filter((text) => text && text !== "New chat"),
|
||||
);
|
||||
}
|
||||
|
||||
test("sidebar recent chats can be pinned and unpinned", async ({ page }) => {
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [
|
||||
{
|
||||
thread_id: NEWEST_THREAD_ID,
|
||||
title: "Newest chat",
|
||||
updated_at: "2026-07-04T10:00:00Z",
|
||||
},
|
||||
{
|
||||
thread_id: OLDER_THREAD_ID,
|
||||
title: "Older chat",
|
||||
updated_at: "2026-07-03T10:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto("/workspace/chats/new");
|
||||
|
||||
await expect(page.getByText("Newest chat")).toBeVisible({ timeout: 15_000 });
|
||||
await expect
|
||||
.poll(() => recentChatTitles(page))
|
||||
.toEqual(["Newest chat", "Older chat"]);
|
||||
|
||||
const olderItem = page
|
||||
.locator(
|
||||
`a[data-sidebar="menu-button"][href="/workspace/chats/${OLDER_THREAD_ID}"]`,
|
||||
)
|
||||
.locator("xpath=..");
|
||||
await olderItem.hover();
|
||||
await olderItem.getByRole("button", { name: "More" }).click();
|
||||
await page.getByRole("menuitem", { name: "Pin chat" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => recentChatTitles(page))
|
||||
.toEqual(["Older chat", "Newest chat"]);
|
||||
|
||||
await olderItem.hover();
|
||||
await olderItem.getByRole("button", { name: "More" }).click();
|
||||
await page.getByRole("menuitem", { name: "Unpin chat" }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => recentChatTitles(page))
|
||||
.toEqual(["Newest chat", "Older chat"]);
|
||||
});
|
||||
|
||||
test("server-side search keeps old pinned chats in the first page", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [
|
||||
...Array.from({ length: 51 }, (_, index) => ({
|
||||
thread_id: `00000000-0000-0000-0000-000000001${String(index).padStart(3, "0")}`,
|
||||
title: `Recent chat ${index + 1}`,
|
||||
updated_at: new Date(
|
||||
Date.UTC(2026, 6, 25, 10, 0, 0) - index * 60_000,
|
||||
).toISOString(),
|
||||
})),
|
||||
{
|
||||
thread_id: OLDER_THREAD_ID,
|
||||
title: "Old pinned chat",
|
||||
updated_at: "2026-01-01T10:00:00Z",
|
||||
metadata: { [THREAD_PINNED_METADATA_KEY]: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto("/workspace/chats/new");
|
||||
|
||||
await expect(page.getByText("Old pinned chat")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await recentChatTitles(page)).slice(0, 2))
|
||||
.toEqual(["Old pinned chat", "Recent chat 1"]);
|
||||
});
|
||||
@ -16,6 +16,9 @@ export const MOCK_THREAD_ID = "00000000-0000-0000-0000-000000000001";
|
||||
export const MOCK_THREAD_ID_2 = "00000000-0000-0000-0000-000000000002";
|
||||
export const MOCK_SIDECAR_THREAD_ID = "00000000-0000-0000-0000-0000000000aa";
|
||||
export const MOCK_RUN_ID = "00000000-0000-0000-0000-000000000099";
|
||||
// Keep in sync with frontend runtime thread utils and the backend thread_meta
|
||||
// constant; the mock must mirror the same metadata contract for pin ordering.
|
||||
export const THREAD_PINNED_METADATA_KEY = "deerflow_pinned";
|
||||
|
||||
const MOCK_AUTH_USER = {
|
||||
id: "default",
|
||||
@ -318,6 +321,44 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
values: { title: thread.title ?? "Untitled", goal: thread.goal ?? null },
|
||||
});
|
||||
|
||||
const threadUpdatedAt = (thread: MockThread) =>
|
||||
Date.parse(thread.updated_at ?? "2025-01-01T00:00:00Z") || 0;
|
||||
|
||||
const sortThreadSearchResults = (items: readonly MockThread[]) =>
|
||||
[...items].sort((left, right) => {
|
||||
const pinnedDiff =
|
||||
Number(right.metadata?.[THREAD_PINNED_METADATA_KEY] === true) -
|
||||
Number(left.metadata?.[THREAD_PINNED_METADATA_KEY] === true);
|
||||
return (
|
||||
pinnedDiff ||
|
||||
threadUpdatedAt(right) - threadUpdatedAt(left) ||
|
||||
right.thread_id.localeCompare(left.thread_id)
|
||||
);
|
||||
});
|
||||
|
||||
const patchThreadMetadata = (
|
||||
threadId: string,
|
||||
metadata: Record<string, unknown>,
|
||||
) => {
|
||||
let updated: MockThread | undefined;
|
||||
threads = threads.map((thread) => {
|
||||
if (thread.thread_id !== threadId) {
|
||||
return thread;
|
||||
}
|
||||
// Preserve ``updated_at`` for pin/unpin metadata changes; the search mock
|
||||
// below mirrors the Gateway's server-side pinned-first ordering.
|
||||
updated = {
|
||||
...thread,
|
||||
metadata: {
|
||||
...(thread.metadata ?? {}),
|
||||
...metadata,
|
||||
},
|
||||
};
|
||||
return updated;
|
||||
});
|
||||
return updated;
|
||||
};
|
||||
|
||||
// Auth — keep workspace tests independent from a real gateway session.
|
||||
void page.route("**/api/v1/auth/me", (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
@ -611,7 +652,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
|
||||
// Thread search — sidebar thread list & chats list page
|
||||
void page.route("**/api/langgraph/threads/search", async (route) => {
|
||||
let body = threads.map(threadSearchResult);
|
||||
let body = sortThreadSearchResults(threads).map(threadSearchResult);
|
||||
|
||||
let limit: number | undefined;
|
||||
let offset = 0;
|
||||
@ -698,10 +739,23 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
});
|
||||
}
|
||||
if (route.request().method() === "PATCH") {
|
||||
const body = route.request().postDataJSON() as {
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
const updated = body.metadata
|
||||
? patchThreadMetadata(threadId, body.metadata)
|
||||
: matchingThread;
|
||||
if (!updated) {
|
||||
return route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: "Thread not found" }),
|
||||
});
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ thread_id: MOCK_THREAD_ID }),
|
||||
body: JSON.stringify(threadSearchResult(updated)),
|
||||
});
|
||||
}
|
||||
if (route.request().method() === "DELETE") {
|
||||
@ -744,6 +798,32 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
});
|
||||
|
||||
void page.route(/\/api\/threads\/[^/]+$/, (route) => {
|
||||
if (route.request().method() === "PATCH") {
|
||||
const threadId = decodeURIComponent(
|
||||
new URL(route.request().url()).pathname.split("/").at(-1) ?? "",
|
||||
);
|
||||
const body = route.request().postDataJSON() as {
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
const matchingThread = threads.find(
|
||||
(thread) => thread.thread_id === threadId,
|
||||
);
|
||||
const updated = body.metadata
|
||||
? patchThreadMetadata(threadId, body.metadata)
|
||||
: matchingThread;
|
||||
if (!updated) {
|
||||
return route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: `Thread ${threadId} not found` }),
|
||||
});
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(threadSearchResult(updated)),
|
||||
});
|
||||
}
|
||||
if (route.request().method() === "DELETE") {
|
||||
const threadId = decodeURIComponent(
|
||||
new URL(route.request().url()).pathname.split("/").at(-1) ?? "",
|
||||
|
||||
@ -1,12 +1,27 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { expect, test } from "@rstest/core";
|
||||
|
||||
import type { AgentThread } from "@/core/threads/types";
|
||||
import {
|
||||
channelSourceOfThread,
|
||||
isThreadPinned,
|
||||
pathOfThread,
|
||||
sortPinnedThreads,
|
||||
textOfMessage,
|
||||
THREAD_PINNED_METADATA_KEY,
|
||||
} from "@/core/threads/utils";
|
||||
|
||||
function makeThread(
|
||||
threadId: string,
|
||||
metadata: Record<string, unknown> = {},
|
||||
): AgentThread {
|
||||
return {
|
||||
thread_id: threadId,
|
||||
metadata,
|
||||
values: { title: threadId },
|
||||
} as unknown as AgentThread;
|
||||
}
|
||||
|
||||
test("uses standard chat route when thread has no agent context", () => {
|
||||
expect(pathOfThread("thread-123")).toBe("/workspace/chats/thread-123");
|
||||
expect(
|
||||
@ -62,6 +77,44 @@ test("prefers context.agent_name over metadata.agent_name", () => {
|
||||
).toBe("/workspace/agents/from-context/chats/thread-789");
|
||||
});
|
||||
|
||||
test("reads pinned thread metadata strictly from the pinned metadata key", () => {
|
||||
expect(
|
||||
isThreadPinned(
|
||||
makeThread("pinned", { [THREAD_PINNED_METADATA_KEY]: true }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isThreadPinned(
|
||||
makeThread("false", { [THREAD_PINNED_METADATA_KEY]: false }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isThreadPinned(
|
||||
makeThread("truthy", { [THREAD_PINNED_METADATA_KEY]: "true" }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isThreadPinned(makeThread("legacy-bare-key", { pinned: true }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isThreadPinned(makeThread("missing"))).toBe(false);
|
||||
});
|
||||
|
||||
test("sortPinnedThreads keeps pinned threads first without reordering groups", () => {
|
||||
const threads = [
|
||||
makeThread("recent-1"),
|
||||
makeThread("pinned-1", { [THREAD_PINNED_METADATA_KEY]: true }),
|
||||
makeThread("recent-2"),
|
||||
makeThread("pinned-2", { [THREAD_PINNED_METADATA_KEY]: true }),
|
||||
];
|
||||
|
||||
expect(sortPinnedThreads(threads).map((thread) => thread.thread_id)).toEqual([
|
||||
"pinned-1",
|
||||
"pinned-2",
|
||||
"recent-1",
|
||||
"recent-2",
|
||||
]);
|
||||
});
|
||||
|
||||
test("reads IM channel source metadata", () => {
|
||||
expect(
|
||||
channelSourceOfThread({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user