From 68c0ffdac88719a9d026dd3ea6e1f18ab039f4e1 Mon Sep 17 00:00:00 2001 From: Ryker_Feng <90562015+18062706139fcz@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:47:58 +0800 Subject: [PATCH] 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 --- backend/app/gateway/routers/threads.py | 14 ++- .../persistence/thread_meta/__init__.py | 3 +- .../deerflow/persistence/thread_meta/base.py | 18 +++- .../persistence/thread_meta/memory.py | 46 +++++++-- .../deerflow/persistence/thread_meta/sql.py | 28 +++++- backend/tests/test_thread_meta_repo.py | 34 ++++++- backend/tests/test_threads_router.py | 94 +++++++++++++++++-- .../components/workspace/recent-chat-list.tsx | 47 +++++++++- frontend/src/core/i18n/locales/en-US.ts | 3 + frontend/src/core/i18n/locales/types.ts | 3 + frontend/src/core/i18n/locales/zh-CN.ts | 3 + frontend/src/core/threads/api.ts | 39 +++++++- frontend/src/core/threads/hooks.ts | 92 +++++++++++++++++- frontend/src/core/threads/utils.ts | 24 +++++ frontend/tests/e2e/integrations.spec.ts | 10 ++ .../e2e/thread-list-infinite-scroll.spec.ts | 10 +- frontend/tests/e2e/thread-list-pin.spec.ts | 92 ++++++++++++++++++ frontend/tests/e2e/utils/mock-api.ts | 84 ++++++++++++++++- .../tests/unit/core/threads/utils.test.ts | 53 +++++++++++ 19 files changed, 662 insertions(+), 35 deletions(-) create mode 100644 frontend/tests/e2e/thread-list-pin.spec.ts diff --git a/backend/app/gateway/routers/threads.py b/backend/app/gateway/routers/threads.py index b3cf2d5da..31ac9ac8b 100644 --- a/backend/app/gateway/routers/threads.py +++ b/backend/app/gateway/routers/threads.py @@ -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, diff --git a/backend/packages/harness/deerflow/persistence/thread_meta/__init__.py b/backend/packages/harness/deerflow/persistence/thread_meta/__init__.py index b5231f0f9..66151d4ab 100644 --- a/backend/packages/harness/deerflow/persistence/thread_meta/__init__.py +++ b/backend/packages/harness/deerflow/persistence/thread_meta/__init__.py @@ -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", diff --git a/backend/packages/harness/deerflow/persistence/thread_meta/base.py b/backend/packages/harness/deerflow/persistence/thread_meta/base.py index 4207b4daa..f9fecb30d 100644 --- a/backend/packages/harness/deerflow/persistence/thread_meta/base.py +++ b/backend/packages/harness/deerflow/persistence/thread_meta/base.py @@ -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 diff --git a/backend/packages/harness/deerflow/persistence/thread_meta/memory.py b/backend/packages/harness/deerflow/persistence/thread_meta/memory.py index b17d994f8..310b13032 100644 --- a/backend/packages/harness/deerflow/persistence/thread_meta/memory.py +++ b/backend/packages/harness/deerflow/persistence/thread_meta/memory.py @@ -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 "")) diff --git a/backend/packages/harness/deerflow/persistence/thread_meta/sql.py b/backend/packages/harness/deerflow/persistence/thread_meta/sql.py index a5e7f51c5..56ae32196 100644 --- a/backend/packages/harness/deerflow/persistence/thread_meta/sql.py +++ b/backend/packages/harness/deerflow/persistence/thread_meta/sql.py @@ -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( diff --git a/backend/tests/test_thread_meta_repo.py b/backend/tests/test_thread_meta_repo.py index c6fff8868..b07f477fe 100644 --- a/backend/tests/test_thread_meta_repo.py +++ b/backend/tests/test_thread_meta_repo.py @@ -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"}) diff --git a/backend/tests/test_threads_router.py b/backend/tests/test_threads_router.py index bc751f12f..e2c0f5f2f 100644 --- a/backend/tests/test_threads_router.py +++ b/backend/tests/test_threads_router.py @@ -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 diff --git a/frontend/src/components/workspace/recent-chat-list.tsx b/frontend/src/components/workspace/recent-chat-list.tsx index 826b8af87..c7fd24cb7 100644 --- a/frontend/src/components/workspace/recent-chat-list.tsx +++ b/frontend/src/components/workspace/recent-chat-list.tsx @@ -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(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() {
- {threads.map((thread) => { + {displayedThreads.map((thread) => { const isActive = pathOfThread(thread) === pathname; const channelSource = channelSourceOfThread(thread); + const pinned = isThreadPinned(thread); return ( + {pinned && ( +