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.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,

View File

@ -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",

View File

@ -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

View File

@ -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 ""))

View File

@ -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(

View File

@ -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"})

View File

@ -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

View File

@ -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(

View File

@ -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

View File

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

View File

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

View File

@ -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 = {},

View File

@ -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>({

View File

@ -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",

View File

@ -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();
},
);

View File

@ -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(),
};
});

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_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) ?? "",

View File

@ -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({