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:
Ryker_Feng 2026-07-26 20:47:58 +08:00 committed by GitHub
parent 04b7e693f0
commit 68c0ffdac8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 662 additions and 35 deletions

View File

@ -45,6 +45,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.runtime import serialize_channel_values_for_api from deerflow.runtime import 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
@ -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} 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: def _message_id(message: Any) -> str | None:
if isinstance(message, dict): if isinstance(message, dict):
raw = message.get("id") 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") 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``.
# 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: try:
await thread_store.update_metadata(thread_id, body.metadata) await thread_store.update_metadata(thread_id, body.metadata, touch=touch)
except Exception: except Exception:
logger.exception("Failed to patch thread %s", sanitize_log_param(thread_id)) logger.exception("Failed to patch thread %s", sanitize_log_param(thread_id))
raise HTTPException(status_code=500, detail="Failed to update thread") 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 record = await thread_store.get(thread_id) or record
return ThreadResponse( return ThreadResponse(
thread_id=thread_id, thread_id=thread_id,

View File

@ -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 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.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
@ -16,6 +16,7 @@ if TYPE_CHECKING:
__all__ = [ __all__ = [
"InvalidMetadataFilterError", "InvalidMetadataFilterError",
"MemoryThreadMetaStore", "MemoryThreadMetaStore",
"THREAD_PINNED_METADATA_KEY",
"ThreadMetaRepository", "ThreadMetaRepository",
"ThreadMetaRow", "ThreadMetaRow",
"ThreadMetaStore", "ThreadMetaStore",

View File

@ -19,6 +19,11 @@ from typing import Any
from deerflow.runtime.user_context import AUTO, _AutoSentinel 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): class InvalidMetadataFilterError(ValueError):
"""Raised when all client-supplied metadata filter keys are rejected.""" """Raised when all client-supplied metadata filter keys are rejected."""
@ -51,6 +56,12 @@ class ThreadMetaStore(abc.ABC):
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.
Results are ordered with pinned threads first
(``metadata.deerflow_pinned is True``), then by ``updated_at`` and
``thread_id`` descending within each group.
"""
pass pass
@abc.abstractmethod @abc.abstractmethod
@ -62,12 +73,17 @@ class ThreadMetaStore(abc.ABC):
pass pass
@abc.abstractmethod @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. """Merge ``metadata`` into the thread's metadata field.
Existing keys are overwritten by the new values; keys absent from Existing keys are overwritten by the new values; keys absent from
``metadata`` are preserved. No-op if the thread does not exist ``metadata`` are preserved. No-op if the thread does not exist
or the owner check fails. 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 pass

View File

@ -11,11 +11,12 @@ from typing import Any
from langgraph.store.base import BaseStore 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.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
THREADS_NS: tuple[str, ...] = ("threads",) THREADS_NS: tuple[str, ...] = ("threads",)
SEARCH_PAGE_SIZE = 500
class MemoryThreadMetaStore(ThreadMetaStore): class MemoryThreadMetaStore(ThreadMetaStore):
@ -75,6 +76,12 @@ class MemoryThreadMetaStore(ThreadMetaStore):
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 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") resolved_user_id = resolve_user_id(user_id, method_name="MemoryThreadMetaStore.search")
filter_dict: dict[str, Any] = {} filter_dict: dict[str, Any] = {}
if metadata: if metadata:
@ -84,13 +91,25 @@ class MemoryThreadMetaStore(ThreadMetaStore):
if resolved_user_id is not None: if resolved_user_id is not None:
filter_dict["user_id"] = resolved_user_id filter_dict["user_id"] = resolved_user_id
items = await self._store.asearch( items = []
THREADS_NS, search_offset = 0
filter=filter_dict or None, while True:
limit=limit, page = await self._store.asearch(
offset=offset, THREADS_NS,
) filter=filter_dict or None,
return [self._item_to_dict(item) for item in items] 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: 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) item = await self._store.aget(THREADS_NS, thread_id)
@ -117,14 +136,15 @@ class MemoryThreadMetaStore(ThreadMetaStore):
record["updated_at"] = now_iso() record["updated_at"] = now_iso()
await self._store.aput(THREADS_NS, thread_id, record) 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") record = await self._get_owned_record(thread_id, user_id, "MemoryThreadMetaStore.update_metadata")
if record is None: if record is None:
return return
merged = dict(record.get("metadata") or {}) merged = dict(record.get("metadata") or {})
merged.update(metadata) merged.update(metadata)
record["metadata"] = merged record["metadata"] = merged
record["updated_at"] = now_iso() if touch:
record["updated_at"] = now_iso()
await self._store.aput(THREADS_NS, thread_id, record) 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: 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", "")), "created_at": coerce_iso(val.get("created_at", "")),
"updated_at": coerce_iso(val.get("updated_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 ""))

View File

@ -6,11 +6,12 @@ import logging
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any 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.ext.asyncio import AsyncSession, async_sessionmaker
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 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.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
@ -123,7 +124,15 @@ class ThreadMetaRepository(ThreadMetaStore):
context. Pass ``user_id=None`` to bypass (migration/CLI). context. Pass ``user_id=None`` to bypass (migration/CLI).
""" """
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.search") 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: if resolved_user_id is not None:
stmt = stmt.where(ThreadMetaRow.user_id == resolved_user_id) stmt = stmt.where(ThreadMetaRow.user_id == resolved_user_id)
if status: if status:
@ -190,6 +199,7 @@ class ThreadMetaRepository(ThreadMetaStore):
thread_id: str, thread_id: str,
metadata: dict, metadata: dict,
*, *,
touch: bool = True,
user_id: str | None | _AutoSentinel = AUTO, user_id: str | None | _AutoSentinel = AUTO,
) -> None: ) -> None:
"""Merge ``metadata`` into ``metadata_json``. """Merge ``metadata`` into ``metadata_json``.
@ -197,6 +207,9 @@ class ThreadMetaRepository(ThreadMetaStore):
Read-modify-write inside a single session/transaction so concurrent Read-modify-write inside a single session/transaction so concurrent
callers see consistent state. No-op if the row does not exist or callers see consistent state. No-op if the row does not exist or
the user_id check fails. 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") resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.update_metadata")
async with self._sf() as session: async with self._sf() as session:
@ -208,7 +221,14 @@ class ThreadMetaRepository(ThreadMetaStore):
merged = dict(row.metadata_json or {}) merged = dict(row.metadata_json or {})
merged.update(metadata) merged.update(metadata)
row.metadata_json = merged 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() await session.commit()
async def update_owner( async def update_owner(

View File

@ -4,7 +4,7 @@ import logging
import pytest import pytest
from deerflow.persistence.thread_meta import InvalidMetadataFilterError, ThreadMetaRepository from deerflow.persistence.thread_meta import THREAD_PINNED_METADATA_KEY, InvalidMetadataFilterError, ThreadMetaRepository
@pytest.fixture @pytest.fixture
@ -137,6 +137,38 @@ class TestThreadMetaRepository:
async def test_update_metadata_nonexistent_is_noop(self, repo): async def test_update_metadata_nonexistent_is_noop(self, repo):
await repo.update_metadata("nonexistent", {"k": "v"}) # should not raise 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 @pytest.mark.anyio
async def test_update_owner_with_bypass_moves_row(self, repo): async def test_update_owner_with_bypass_moves_row(self, repo):
await repo.create("t1", user_id="default", metadata={"source": "channel"}) await repo.create("t1", user_id="default", metadata={"source": "channel"})

View File

@ -17,7 +17,7 @@ from langgraph.types import Overwrite
from app.gateway import services as gateway_services from app.gateway import services as gateway_services
from app.gateway.routers import thread_runs, threads from app.gateway.routers import thread_runs, threads
from deerflow.config.paths import Paths 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.persistence.thread_meta.memory import THREADS_NS, MemoryThreadMetaStore
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor 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 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() app, store, _checkpointer = _build_thread_app()
thread_id = "patch-target" thread_id = "patch-target"
@ -982,16 +987,53 @@ def test_patch_thread_returns_iso_and_advances_updated_at() -> None:
asyncio.run(_seed()) asyncio.run(_seed())
with TestClient(app) as client: 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 assert response.status_code == 200, response.text
body = response.json() body = response.json()
assert _ISO_TIMESTAMP_RE.match(body["created_at"]), body["created_at"] assert _ISO_TIMESTAMP_RE.match(body["created_at"]), body["created_at"]
assert _ISO_TIMESTAMP_RE.match(body["updated_at"]), body["updated_at"] assert _ISO_TIMESTAMP_RE.match(body["updated_at"]), body["updated_at"]
# Patch issues a fresh ``updated_at`` via ``MemoryThreadMetaStore.update_metadata``, # ``touch=False`` preserves the original ``updated_at``; both timestamps
# so it must be > the migrated legacy ``created_at`` (both ISO strings # derive from the same legacy value, so they coerce to the same ISO string.
# sort lexicographically by time when the format is consistent). assert body["updated_at"] == body["created_at"]
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"} 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 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: def test_memory_thread_meta_store_writes_iso_on_create() -> None:
"""``MemoryThreadMetaStore.create`` must emit ISO so newly created """``MemoryThreadMetaStore.create`` must emit ISO so newly created
threads serialize correctly without depending on the router's threads serialize correctly without depending on the router's

View File

@ -6,6 +6,8 @@ import {
FileText, FileText,
MoreHorizontal, MoreHorizontal,
Pencil, Pencil,
Pin,
PinOff,
Share2, Share2,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
@ -53,12 +55,15 @@ import {
import { import {
useDeleteThread, useDeleteThread,
useInfiniteThreads, useInfiniteThreads,
usePinThread,
useRenameThread, useRenameThread,
} from "@/core/threads/hooks"; } from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { import {
channelSourceOfThread, channelSourceOfThread,
isThreadPinned,
pathOfThread, pathOfThread,
sortPinnedThreads,
titleOfThread, titleOfThread,
} from "@/core/threads/utils"; } from "@/core/threads/utils";
import { env } from "@/env"; import { env } from "@/env";
@ -91,6 +96,7 @@ export function RecentChatList() {
return true; return true;
}); });
}, [infiniteThreads]); }, [infiniteThreads]);
const displayedThreads = useMemo(() => sortPinnedThreads(threads), [threads]);
const sentinelRef = useRef<HTMLDivElement | null>(null); const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
@ -112,6 +118,7 @@ export function RecentChatList() {
const { mutate: deleteThread } = useDeleteThread(); const { mutate: deleteThread } = useDeleteThread();
const { mutate: renameThread } = useRenameThread(); const { mutate: renameThread } = useRenameThread();
const { mutate: updatePinnedThread } = usePinThread();
// Rename dialog state // Rename dialog state
const [renameDialogOpen, setRenameDialogOpen] = useState(false); const [renameDialogOpen, setRenameDialogOpen] = useState(false);
@ -187,6 +194,25 @@ export function RecentChatList() {
} }
}, [renameThread, renameThreadId, renameValue, t.common.renameFailed]); }, [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( const handleShare = useCallback(
async (thread: AgentThread) => { async (thread: AgentThread) => {
// Always use Vercel URL for sharing so others can access // 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"> <SidebarGroupContent className="group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0">
<SidebarMenu> <SidebarMenu>
<div className="flex w-full flex-col gap-1"> <div className="flex w-full flex-col gap-1">
{threads.map((thread) => { {displayedThreads.map((thread) => {
const isActive = pathOfThread(thread) === pathname; const isActive = pathOfThread(thread) === pathname;
const channelSource = channelSourceOfThread(thread); const channelSource = channelSourceOfThread(thread);
const pinned = isThreadPinned(thread);
return ( return (
<SidebarMenuItem <SidebarMenuItem
key={thread.thread_id} key={thread.thread_id}
@ -265,6 +292,12 @@ export function RecentChatList() {
href={pathOfThread(thread)} href={pathOfThread(thread)}
> >
<ThreadChannelIcon source={channelSource} /> <ThreadChannelIcon source={channelSource} />
{pinned && (
<Pin
aria-hidden="true"
className="text-muted-foreground size-3.5 shrink-0"
/>
)}
<span className="min-w-0 truncate"> <span className="min-w-0 truncate">
{titleOfThread(thread)} {titleOfThread(thread)}
</span> </span>
@ -296,6 +329,18 @@ export function RecentChatList() {
side={"right"} side={"right"}
align={"start"} 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 <DropdownMenuItem
onSelect={() => onSelect={() =>
handleRenameClick( handleRenameClick(

View File

@ -514,6 +514,9 @@ export const enUS: Translations = {
loadMoreToSearch: "Load more to search older conversations", loadMoreToSearch: "Load more to search older conversations",
loadingMore: "Loading more...", loadingMore: "Loading more...",
loadOlderChats: "Load older chats", loadOlderChats: "Load older chats",
pinChat: "Pin chat",
unpinChat: "Unpin chat",
pinChatFailed: "Failed to update pinned chat",
}, },
// Sidecar // Sidecar

View File

@ -413,6 +413,9 @@ export interface Translations {
loadMoreToSearch: string; loadMoreToSearch: string;
loadingMore: string; loadingMore: string;
loadOlderChats: string; loadOlderChats: string;
pinChat: string;
unpinChat: string;
pinChatFailed: string;
}; };
// Sidecar // Sidecar

View File

@ -491,6 +491,9 @@ export const zhCN: Translations = {
loadMoreToSearch: "加载更多以搜索更早的对话", loadMoreToSearch: "加载更多以搜索更早的对话",
loadingMore: "正在加载...", loadingMore: "正在加载...",
loadOlderChats: "加载更早的对话", loadOlderChats: "加载更早的对话",
pinChat: "置顶对话",
unpinChat: "取消置顶",
pinChatFailed: "更新对话置顶状态失败",
}, },
// Sidecar // Sidecar

View File

@ -1,7 +1,7 @@
import { fetch as fetchWithAuth } from "@/core/api/fetcher"; import { fetch as fetchWithAuth } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import type { ThreadTokenUsageResponse } from "./types"; import type { AgentThread, ThreadTokenUsageResponse } from "./types";
export type ThreadCompactResponse = { export type ThreadCompactResponse = {
thread_id: string; thread_id: string;
@ -34,6 +34,19 @@ export type BranchThreadFromTurnInput = {
title?: string; 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( async function readThreadAPIError(
response: Response, response: Response,
fallback: string, fallback: string,
@ -97,6 +110,30 @@ export async function branchThreadFromTurn(
return (await response.json()) as ThreadBranchResponse; 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( export async function compactThreadContext(
threadId: string, threadId: string,
options: CompactThreadContextOptions = {}, options: CompactThreadContextOptions = {},

View File

@ -29,7 +29,12 @@ import { messageToStep } from "../tasks/steps";
import type { UploadedFileInfo } from "../uploads"; import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads"; import { promptInputFilePartToFile, uploadFiles } from "../uploads";
import { branchThreadFromTurn, fetchThreadTokenUsage } from "./api"; import {
branchThreadFromTurn,
fetchThreadTokenUsage,
patchThreadMetadata,
type ThreadMetadataPatch,
} from "./api";
import { import {
buildThreadsSearchQueryOptions, buildThreadsSearchQueryOptions,
DEFAULT_THREAD_SEARCH_PARAMS, DEFAULT_THREAD_SEARCH_PARAMS,
@ -43,6 +48,7 @@ import type {
RunMessage, RunMessage,
ThreadTokenUsageResponse, ThreadTokenUsageResponse,
} from "./types"; } from "./types";
import { THREAD_PINNED_METADATA_KEY } from "./utils";
export type ThreadStreamOptions = { export type ThreadStreamOptions = {
threadId?: string | null | undefined; 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( export function useInfiniteThreads(
params: InfiniteThreadsParams = { params: InfiniteThreadsParams = {
sortBy: "updated_at", 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) { export function useRunDetail(threadId: string, runId: string) {
const apiClient = getAPIClient(); const apiClient = getAPIClient();
return useQuery<Run>({ return useQuery<Run>({

View File

@ -2,6 +2,12 @@ import type { Message } from "@langchain/langgraph-sdk";
import type { AgentThread, AgentThreadContext } from "./types"; 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 = { export type ChannelThreadSource = {
type: "im_channel"; type: "im_channel";
provider: string; provider: string;
@ -60,6 +66,24 @@ export function titleOfThread(thread: AgentThread) {
return thread.values?.title ?? "Untitled"; 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> = { const CHANNEL_PROVIDER_LABELS: Record<string, string> = {
dingtalk: "DingTalk", dingtalk: "DingTalk",
discord: "Discord", discord: "Discord",

View File

@ -49,10 +49,20 @@ test.describe("Integrations settings", () => {
mockLangGraphAPI(page); mockLangGraphAPI(page);
let authStartRequest: unknown; let authStartRequest: unknown;
const authCompleteRequests: unknown[] = []; const authCompleteRequests: unknown[] = [];
let authCompleteCount = 0;
await page.route( await page.route(
"**/api/integrations/lark/auth/complete", "**/api/integrations/lark/auth/complete",
async (route) => { async (route) => {
authCompleteRequests.push(route.request().postDataJSON()); 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(); await route.fallback();
}, },
); );

View File

@ -11,14 +11,16 @@ const TOTAL_THREADS = 120;
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
const THREADS = Array.from({ length: TOTAL_THREADS }, (_, i) => { const THREADS = Array.from({ length: TOTAL_THREADS }, (_, i) => {
// Pad index so titles sort deterministically as strings. The thread-search // Pad index so titles sort deterministically as strings. Keep updated_at
// mock returns threads in the order provided, so paging boundaries are // monotonically descending to match the backend's updated_at-desc search
// stable across runs. // order, so paging boundaries are stable across runs.
const index = String(i + 1).padStart(3, "0"); const index = String(i + 1).padStart(3, "0");
return { return {
thread_id: `00000000-0000-0000-0000-0000000${index.padStart(5, "0")}`, thread_id: `00000000-0000-0000-0000-0000000${index.padStart(5, "0")}`,
title: `Conversation ${index}`, 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(),
}; };
}); });

View 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"]);
});

View File

@ -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_THREAD_ID_2 = "00000000-0000-0000-0000-000000000002";
export const MOCK_SIDECAR_THREAD_ID = "00000000-0000-0000-0000-0000000000aa"; export const MOCK_SIDECAR_THREAD_ID = "00000000-0000-0000-0000-0000000000aa";
export const MOCK_RUN_ID = "00000000-0000-0000-0000-000000000099"; 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 = { const MOCK_AUTH_USER = {
id: "default", id: "default",
@ -318,6 +321,44 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
values: { title: thread.title ?? "Untitled", goal: thread.goal ?? null }, 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. // Auth — keep workspace tests independent from a real gateway session.
void page.route("**/api/v1/auth/me", (route) => { void page.route("**/api/v1/auth/me", (route) => {
if (route.request().method() === "GET") { if (route.request().method() === "GET") {
@ -611,7 +652,7 @@ 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 = threads.map(threadSearchResult); let body = sortThreadSearchResults(threads).map(threadSearchResult);
let limit: number | undefined; let limit: number | undefined;
let offset = 0; let offset = 0;
@ -698,10 +739,23 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
}); });
} }
if (route.request().method() === "PATCH") { 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({ return route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ thread_id: MOCK_THREAD_ID }), body: JSON.stringify(threadSearchResult(updated)),
}); });
} }
if (route.request().method() === "DELETE") { if (route.request().method() === "DELETE") {
@ -744,6 +798,32 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
}); });
void page.route(/\/api\/threads\/[^/]+$/, (route) => { 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") { if (route.request().method() === "DELETE") {
const threadId = decodeURIComponent( const threadId = decodeURIComponent(
new URL(route.request().url()).pathname.split("/").at(-1) ?? "", new URL(route.request().url()).pathname.split("/").at(-1) ?? "",

View File

@ -1,12 +1,27 @@
import type { Message } from "@langchain/langgraph-sdk"; import type { Message } from "@langchain/langgraph-sdk";
import { expect, test } from "@rstest/core"; import { expect, test } from "@rstest/core";
import type { AgentThread } from "@/core/threads/types";
import { import {
channelSourceOfThread, channelSourceOfThread,
isThreadPinned,
pathOfThread, pathOfThread,
sortPinnedThreads,
textOfMessage, textOfMessage,
THREAD_PINNED_METADATA_KEY,
} from "@/core/threads/utils"; } 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", () => { test("uses standard chat route when thread has no agent context", () => {
expect(pathOfThread("thread-123")).toBe("/workspace/chats/thread-123"); expect(pathOfThread("thread-123")).toBe("/workspace/chats/thread-123");
expect( expect(
@ -62,6 +77,44 @@ test("prefers context.agent_name over metadata.agent_name", () => {
).toBe("/workspace/agents/from-context/chats/thread-789"); ).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", () => { test("reads IM channel source metadata", () => {
expect( expect(
channelSourceOfThread({ channelSourceOfThread({