feat(projects): project workspaces with scoped chats and thread membership (#5265)

* feat(projects): project workspaces with scoped chats and thread membership

Backend:
- projects table model and migration; fail-closed ProjectRepository with
  ownership checks, CRUD/archive/restore/delete router, and atomic thread
  move between projects
- threads_meta.project_id column exposed as reserved deerflow_project_id
  metadata; project-aware thread create/search with pagination bounds and
  membership echoed in create responses
- first-run admission assigns the project only at genuine first run, seeded
  at write time and dropped when invalid; serialized against project
  deletion and thread assignment
- branch creation inherits the source thread's project membership (an
  archived/deleted project degrades the branch to unassigned instead of
  failing the request)

Frontend:
- projects data layer, thread move API, and sidebar projects section with
  flat/grouped modes, archived-project threads, and stable virtual-list
  offsets
- project detail page with project-scoped new chat
  (/workspace/chats/new?project=) and paginated thread list
- move-to-project thread menu, new-project dialog, archived-project gates
- project-scoped new chats pre-create the thread with membership before the
  first submit or /goal set, so runs never proceed outside the project
- goal-set preparation is fenced against conversation switches: a stale
  continuation is dropped instead of saving the goal or launching the
  abandoned submission on the newly opened conversation
- project thread lists join thread lifecycle invalidations (stop, pin) so
  an open project page never keeps stale titles, recency, or pagination

* fix(chats): keep archive undo toast when the sidebar row unmounts

The archive success toast was fired from per-mutate callbacks passed to
mutation.mutate. React Query drops those handlers when the observer
component unmounts before the mutation settles; archiving the open chat
removes its sidebar row mid-flight, so the undo toast never appeared and
the e2e archive-undo test timed out waiting for it.

Move the success/error handlers to the mutation level (useArchiveThread
options, same pattern as useMoveThreadToProject) where callbacks are
delivered even after the originating row unmounts.

* fix(projects): pin project thread listing contract and exclude archived chats

GET /api/projects/{id}/threads returned the thread store row verbatim
(list[dict], no response_model): user_id/assistant_id leaked, any future
ThreadMetaRow column would auto-leak, and the OpenAPI schema was empty.
Return a narrow ProjectThreadResponse (the exact fields ProjectThread
declares) with the same metadata secret redaction the surrounding thread
endpoints get from _MetadataRedactingResponse.

The listing also ran search() without the archived filter, so a retired
chat rendered as a normal row on the project page while the sidebar hid
it. Search archived=False to mirror the sidebar's archived:false lists;
restore stays on the global Archived tab.

Both regressions pinned by new router tests: wire-shape allowlist and
archived-member exclusion.

* docs(migrations): record the 0019/0020 chain against the bootstrap reservation

The tree now chains 0018 -> 0019_projects -> 0020_threads_meta_project_id,
so migrations/AGENTS.md was stale twice over: the revision index stopped at
0018 and the rolling-forward section still claimed the tree 'deliberately
remains at 0018'.

Document the new head and record the intentional numeric-prefix reuse of
0019: 0019_projects is in-chain while 0019_thread_incarnations stays the
reserved, allowlisted out-of-tree rollout id. The owning rollout revision
must re-parent onto this tree's head when it merges so alembic never sees
two heads off 0018; bootstrap.py now cross-references that note next to
_FORWARD_COMPATIBLE_REVISION.

* fix(chats): invalidate project thread lists on archive/restore

useArchiveThread refreshed the infinite sidebar cache, threads/search and
the per-thread metadata cache but not the project-scoped list
([...PROJECTS_QUERY_KEY, 'threads', id]) this PR adds — the one thread
mutation not wired to that key, after usePinThread, useRenameThread,
useDeleteThread, useMoveThreadToProject and invalidateStoppedThreadCaches.

An archive from a sidebar row while a project page is open therefore left
the archived chat rendered as a normal row until remount (and undo left it
missing). Invalidate the prefix in the mutation-level success handler.

Regression test asserts the project-list prefix is invalidated on success.

* fix(projects): fetch project discovery only in grouped sidebar mode

RecentChatList mounted two useProjects queries per sidebar render, but
knownProjectIds is consumed only by the grouped-mode exclusion filter; in
the default flat mode every page load paid two GET /api/projects?status=
round trips for data nothing read. Gate both queries on grouped mode —
GroupedProjectList fetches the same keys when the toggle is on and
TanStack dedupes the observers.

Also set retry: false on useProject: a deleted or foreign project 404s
deterministically, and the page renders a dedicated not-found state for
it, so the default 1s/2s/4s retry backoff kept deep links in 'loading'
for ~7s before that state appeared. Matches useThreadMetadata /
useThreadTokenUsage.

* fix(threads): fail closed on project-scoped create in memory mode

MemoryThreadMetaStore.create accepted project_id and silently ignored it,
making memory mode the one membership path that fails open: POST
/api/threads with a project id returned 200 and the run started
unassigned, violating the invariant that a run never proceeds outside the
selected project (the SQL store raises ProjectNotAssignableError inside
the insert transaction for the same request).

Raise ProjectNotAssignableError whenever project_id is present so the
router's existing 404 mapping applies, the frontend keeps the composer
text for a retry, and memory mode behaves exactly like SQL mode.
set_project already reports rejection; create now matches it.

Store-level test (raises, nothing persisted, project filter stays empty,
unscoped creates still work) plus a router-level test asserting the 404
and that no row is left behind.

* fix(projects): window the project page thread list

ProjectThreadsSection rendered every loaded page as a plain Link row, so a
long-lived project accumulated unbounded DOM on the page's scroll surface:
each load-more appended another 100 rows and every formatTimeAgo tick
re-rendered the whole list.

Reuse VirtualThreadList (now generic over any row shape with a
thread_id), pointing its scroll parent at this page's ScrollArea viewport
via the shared [data-slot="scroll-area-viewport"] selector used by
/workspace/chats; under the 60-row threshold it falls back to the plain
render, so small projects are unchanged.

* fix(projects): restore row dividers and pin them with a render test

The row class template literal concatenated transition-colors directly
with the conditional border-b token, so non-final rows rendered the
invalid class 'transition-colorsborder-b' and lost both the divider and
the transition. Compose the row classes with cn() and a boolean guard
instead.

The section moved out of page.tsx into a testable component so the row
markup finally has coverage: a DOM test asserts every row except the
final data row carries border-b (index-based, not last: — correct under
virtualization where the last mounted row is not the last data row), and
the untitled fallback plus load-more button render for a partial page.

* fix(projects): validate forward schemas and fence membership reads
This commit is contained in:
Zeren Wang 2026-09-08 11:00:26 +02:00 committed by GitHub
parent c55f242451
commit 5951c89b5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
70 changed files with 4977 additions and 509 deletions

View File

@ -1485,6 +1485,22 @@ The HTTP Gateway accepts `values`, `messages-tuple`, `updates`, `debug`, `tasks`
All dict-returning methods are validated against Gateway Pydantic response models in CI (`TestGatewayConformance`), ensuring the embedded client stays in sync with the HTTP API schemas. See `backend/packages/harness/deerflow/client.py` for full API documentation.
## Project membership
A conversation joins a project at creation time (when a project is selected) or
later through the move menu. Runs never modify membership: submitting a message
cannot assign or reassign a conversation. Moving a conversation out of a project
keeps it unassigned until it is explicitly moved again.
Moving a conversation refreshes its header affiliation as well as the project
lists, including when an older metadata request is still in flight.
Projects require the current database tables and columns. A database stamped
`0019_thread_incarnations` from the older 0018-based rollout is rejected at
startup if the project schema is missing. Follow the
[offline database recovery procedure](docs/database-forward-revision-recovery.md)
before starting this build against that database.
## Scheduled Tasks
DeerFlow now includes a first-class scheduled-task MVP in the workspace.

View File

@ -8,7 +8,15 @@ CORS is same-origin by default when requests enter through nginx on port 2026. S
Browser auth sessions are owned by `app.gateway.auth.session_cookie`. Login accepts a `remember_me` form flag, but the Gateway never stores passwords. `SessionCookiePolicy` persists the `HttpOnly access_token` cookie only for HTTPS/trusted-forwarded HTTPS, direct-host localhost HTTP, or explicit operator opt-in for insecure persistence; public HTTP sandbox URLs degrade to session cookies. Session-creating handlers stamp the final `max_age` on `request.state`; CSRF cookie creation mirrors it so the double-submit pair expires together, including re-issue after password changes and OIDC callbacks. A small `HttpOnly` preference cookie preserves the remember choice across re-issues. Logout clears all auth cookies and suppresses CSRF re-issue on the logout response.
Personal Access Tokens (`app.gateway.auth.pat`, `Authorization: Bearer dfp_...`) run as their owning user: an invalid Bearer is a hard 401 with no cookie fallback, which keeps `CSRFMiddleware`'s Bearer skip safe (origin checks still run). Scopes narrow within the allowlisted threads/runs routes; every other authenticated route 403s PAT callers (admin included). PAT management and `/change-password` require session auth; only SHA-256 digests are stored (`0017`).
Personal Access Tokens (`app.gateway.auth.pat`, `Authorization: Bearer dfp_...`) run as their owning user: an invalid Bearer is a hard 401 with no cookie fallback, which keeps `CSRFMiddleware`'s Bearer skip safe (origin checks still run). Scopes narrow within the allowlisted threads/runs/projects routes (including `POST /api/threads/{id}/move`); every other authenticated route 403s PAT callers (admin included). PAT management and `/change-password` require session auth; only SHA-256 digests are stored (`0017`).
Thread→project membership is written by thread creation (`POST /api/threads` with
a validated `project_id`), branch creation (the new row inherits the source
thread's project; an archived/deleted project degrades the branch to unassigned
instead of failing), and explicit moves (`POST /api/threads/{id}/move`); run
admission never modifies membership. The server-reserved `deerflow_project_id`
metadata key is a read-only exposure of the `threads_meta.project_id` column and
is stripped from client writes.
Localhost persistence deliberately reads the direct request `Host` and ignores `Forwarded` / `X-Forwarded-Host`. Scheme and auth-origin reconstruction still consume forwarding headers. The bundled nginx sets `X-Forwarded-Proto`, but preserves an upstream HTTPS value and does not overwrite every forwarded header, so the outer trusted proxy must replace or strip client-supplied forwarding headers before traffic reaches DeerFlow.

View File

@ -32,6 +32,7 @@ from app.gateway.routers import (
mcp_tasks,
memory,
models,
projects,
runs,
scheduled_tasks,
skills,
@ -825,6 +826,8 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
# Agents API is mounted at /api/agents
app.include_router(agents.router)
# Projects API is mounted at /api/projects
app.include_router(projects.router)
# Deployment-level subagent catalog and admin management.
app.include_router(subagents.router)

View File

@ -33,6 +33,9 @@ PAT_ALLOWED_SCOPES: frozenset[str] = frozenset(
"runs:create",
"runs:read",
"runs:cancel",
"projects:read",
"projects:write",
"projects:delete",
}
)
@ -53,6 +56,16 @@ _PAT_ROUTE_RULES: tuple[tuple[frozenset[str], re.Pattern[str]], ...] = (
(frozenset({"GET", "PUT", "DELETE"}), re.compile(r"^/api/threads/[^/]+/goal$")),
(frozenset({"GET", "POST"}), re.compile(r"^/api/threads/[^/]+/state$")),
(frozenset({"POST"}), re.compile(r"^/api/threads/[^/]+/(compact|history|branches)$")),
(frozenset({"POST"}), re.compile(r"^/api/threads/[^/]+/move$")),
# Projects subtree: same enumerated-no-dead-methods precision as the
# threads/runs rules — only the methods the projects router implements
# are admitted, so a future projects route is default-denied until
# explicitly listed. Scope narrowing (projects:read|write|delete and
# threads:write for move) stays enforced by ``@require_permission``.
(frozenset({"GET", "POST"}), re.compile(r"^/api/projects$")),
(frozenset({"GET", "PATCH", "DELETE"}), re.compile(r"^/api/projects/[^/]+$")),
(frozenset({"POST"}), re.compile(r"^/api/projects/[^/]+/(archive|restore)$")),
(frozenset({"GET"}), re.compile(r"^/api/projects/[^/]+/threads$")),
# Runs subtree: enumerated per implemented subroute instead of a
# ``runs(/.*)?`` wildcard, so a route added under /runs is default-denied
# until explicitly listed — the same no-dead-methods precision the

View File

@ -68,6 +68,10 @@ class Permissions:
RUNS_CREATE = "runs:create"
RUNS_READ = "runs:read"
RUNS_CANCEL = "runs:cancel"
# Projects
PROJECTS_READ = "projects:read"
PROJECTS_WRITE = "projects:write"
PROJECTS_DELETE = "projects:delete"
class AuthContext:
@ -149,6 +153,9 @@ _ALL_PERMISSIONS: list[str] = [
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.RUNS_CANCEL,
Permissions.PROJECTS_READ,
Permissions.PROJECTS_WRITE,
Permissions.PROJECTS_DELETE,
]

View File

@ -513,12 +513,14 @@ async def langgraph_runtime(app: FastAPI, startup_config: AppConfig) -> AsyncGen
app.state.thread_store = make_thread_store(sf, app.state.store)
if sf is not None:
from deerflow.persistence.mcp_tasks import McpTaskRepository
from deerflow.persistence.projects import ProjectRepository
from deerflow.persistence.scheduled_task_runs import (
ScheduledTaskRunRepository,
)
from deerflow.persistence.scheduled_tasks import ScheduledTaskRepository
from deerflow.persistence.subagent_batches import SubagentBatchRepository
app.state.project_repo = ProjectRepository(sf)
app.state.scheduled_task_repo = ScheduledTaskRepository(
sf,
run_repository=app.state.run_store,
@ -531,6 +533,7 @@ async def langgraph_runtime(app: FastAPI, startup_config: AppConfig) -> AsyncGen
app.state.subagent_batch_repo = SubagentBatchRepository(sf)
else:
app.state.mcp_task_repo = None
app.state.project_repo = None
app.state.subagent_batch_repo = None
app.state.scheduled_task_repo = None
app.state.scheduled_task_run_repo = None
@ -648,6 +651,7 @@ get_checkpointer: Callable[[Request], Checkpointer] = _require("checkpointer", "
get_run_event_store: Callable[[Request], RunEventStore] = _require("run_event_store", "Run event store")
get_feedback_repo: Callable[[Request], FeedbackRepository] = _require("feedback_repo", "Feedback")
get_run_store: Callable[[Request], RunStore] = _require("run_store", "Run store")
get_project_repo = _require("project_repo", "Projects")
def get_store(request: Request):

View File

@ -0,0 +1,167 @@
"""CRUD API for projects (Phase 1: organization only — no documents/trash)."""
import logging
from typing import Any, Literal
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field, field_validator
from app.gateway.authz import require_permission
from app.gateway.deps import get_project_repo, get_thread_store
from deerflow.runtime.secret_context import redact_metadata_secrets
from deerflow.utils.time import coerce_iso
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["projects"])
ProjectStatus = Literal["active", "archived"]
class ProjectResponse(BaseModel):
id: str
name: str
instructions: str
presentation: dict
status: str
created_at: str
updated_at: str
class ProjectCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=128)
instructions: str = ""
presentation: dict = Field(default_factory=dict)
class ProjectPatchRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=128)
instructions: str | None = None
presentation: dict | None = None
class ProjectListResponse(BaseModel):
projects: list[ProjectResponse]
class ProjectThreadResponse(BaseModel):
"""A thread row from ``GET /api/projects/{id}/threads``.
Deliberately narrow only the fields ``ProjectThread`` declares in
``frontend/src/core/projects/types.ts``. Store rows carry ownership
columns (``user_id``, ``assistant_id``) and ``ThreadMetaRow`` may grow;
without this model those would leak onto the wire and the route's
OpenAPI schema stays empty. Metadata is redacted here exactly as the
surrounding thread endpoints redact it via ``_MetadataRedactingResponse``.
"""
thread_id: str
display_name: str | None = None
created_at: str = ""
updated_at: str = ""
metadata: dict[str, Any] = Field(default_factory=dict)
@field_validator("metadata", mode="before", check_fields=False)
@classmethod
def _redact_metadata_secrets(cls, value: Any) -> Any:
return redact_metadata_secrets(value)
def _to_response(row: dict) -> ProjectResponse:
return ProjectResponse(
id=row["id"],
name=row["name"],
instructions=row.get("instructions", ""),
presentation=row.get("presentation") or {},
status=row["status"],
created_at=row.get("created_at", ""),
updated_at=row.get("updated_at", ""),
)
def _not_found() -> HTTPException:
# Fail closed: foreign projects are indistinguishable from missing ones.
return HTTPException(status_code=404, detail="Project not found")
@router.post("", response_model=ProjectResponse, status_code=201)
@require_permission("projects", "write")
async def create_project(body: ProjectCreateRequest, request: Request) -> ProjectResponse:
repo = get_project_repo(request)
return _to_response(await repo.create(name=body.name, instructions=body.instructions, presentation=body.presentation))
@router.get("", response_model=ProjectListResponse)
@require_permission("projects", "read")
async def list_projects(request: Request, status: ProjectStatus | None = None) -> ProjectListResponse:
repo = get_project_repo(request)
return ProjectListResponse(projects=[_to_response(r) for r in await repo.list(status=status)])
@router.get("/{project_id}", response_model=ProjectResponse)
@require_permission("projects", "read")
async def get_project(project_id: str, request: Request) -> ProjectResponse:
row = await get_project_repo(request).get(project_id)
if row is None:
raise _not_found()
return _to_response(row)
@router.patch("/{project_id}", response_model=ProjectResponse)
@require_permission("projects", "write")
async def patch_project(project_id: str, body: ProjectPatchRequest, request: Request) -> ProjectResponse:
row = await get_project_repo(request).patch(project_id, name=body.name, instructions=body.instructions, presentation=body.presentation)
if row is None:
raise _not_found()
return _to_response(row)
@router.post("/{project_id}/archive", response_model=ProjectResponse)
@require_permission("projects", "write")
async def archive_project(project_id: str, request: Request) -> ProjectResponse:
row = await get_project_repo(request).set_status(project_id, "archived")
if row is None:
raise _not_found()
return _to_response(row)
@router.post("/{project_id}/restore", response_model=ProjectResponse)
@require_permission("projects", "write")
async def restore_project(project_id: str, request: Request) -> ProjectResponse:
row = await get_project_repo(request).set_status(project_id, "active")
if row is None:
raise _not_found()
return _to_response(row)
@router.delete("/{project_id}", status_code=204)
@require_permission("projects", "delete")
async def delete_project(project_id: str, request: Request) -> None:
if not await get_project_repo(request).delete(project_id):
raise _not_found()
@router.get("/{project_id}/threads", response_model=list[ProjectThreadResponse])
@require_permission("projects", "read")
@require_permission("threads", "read")
async def list_project_threads(project_id: str, request: Request, limit: int = Query(default=100, ge=1, le=1000), offset: int = Query(default=0, ge=0)) -> list[ProjectThreadResponse]:
if await get_project_repo(request).get(project_id) is None:
raise _not_found()
# Active members only, mirroring the sidebar's `archived: false` lists:
# an archived chat leaves the project's pages the same way it leaves the
# sidebar and returns only via the global Archived tab.
rows = await get_thread_store(request).search(
project_id=project_id,
archived=False,
limit=limit,
offset=offset,
)
return [
ProjectThreadResponse(
thread_id=r["thread_id"],
display_name=r.get("display_name"),
created_at=coerce_iso(r.get("created_at", "")),
updated_at=coerce_iso(r.get("updated_at", "")),
metadata=r.get("metadata", {}),
)
for r in rows
]

View File

@ -46,7 +46,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_ARCHIVED_METADATA_KEY, THREAD_PINNED_METADATA_KEY
from deerflow.persistence.thread_meta import PROJECT_FILTER_UNSET, THREAD_ARCHIVED_METADATA_KEY, THREAD_PINNED_METADATA_KEY, THREAD_PROJECT_METADATA_KEY
from deerflow.runtime import ThreadOperationKind, 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
@ -111,7 +111,7 @@ def _checkpoint_mode_http_error(exc: Exception, thread_id: str) -> HTTPException
# owner identity through the API surface. Defense-in-depth — the
# row-level invariant is still ``threads_meta.user_id`` populated from
# the auth contextvar; this list closes the metadata-blob echo gap.
_SERVER_RESERVED_METADATA_KEYS: frozenset[str] = frozenset({"owner_id", "user_id"})
_SERVER_RESERVED_METADATA_KEYS: frozenset[str] = frozenset({"owner_id", "user_id", THREAD_PROJECT_METADATA_KEY})
_SIDECAR_METADATA_KEY = "deerflow_sidecar"
_BRANCH_METADATA_KEY = "deerflow_branch"
_BRANCH_TITLE_SEQUENCE_METADATA_KEY = "branch_title_sequence"
@ -444,6 +444,7 @@ class ThreadCreateRequest(BaseModel):
thread_id: ThreadId | None = Field(default=None, description="Optional thread ID (auto-generated if omitted)")
assistant_id: str | None = Field(default=None, description="Associate thread with an assistant")
metadata: dict[str, Any] = Field(default_factory=dict, description="Initial metadata")
project_id: str | None = Field(default=None, description="Assign the new thread to this project (validated server-side)")
_strip_reserved = field_validator("metadata")(classmethod(lambda cls, v: _strip_reserved_metadata(v)))
@ -453,6 +454,7 @@ class ThreadSearchRequest(BaseModel):
archived: bool | None = Field(default=None, strict=True, description="Archive filter; omitted includes all, false includes legacy unarchived threads")
metadata: dict[str, Any] = Field(default_factory=dict, description="Metadata filter (exact match)")
project_id: str | None = Field(default=None, description="Filter by project; explicit null = unassigned threads; omit key for all")
limit: int = Field(default=100, ge=1, le=1000, description="Maximum results")
offset: int = Field(default=0, ge=0, description="Pagination offset")
status: str | None = Field(default=None, description="Filter by thread status")
@ -826,13 +828,20 @@ async def create_thread(body: ThreadCreateRequest, request: Request) -> ThreadRe
return _existing_thread_response(thread_id, existing_record)
# Write thread_meta so the thread appears in /threads/search immediately
from deerflow.persistence.projects import ProjectNotAssignableError
try:
await thread_store.create(
created_record = await thread_store.create(
thread_id,
assistant_id=getattr(body, "assistant_id", None),
**thread_owner_kwargs,
metadata=body.metadata,
project_id=body.project_id,
)
except ProjectNotAssignableError:
# Fail closed: missing, foreign, or archived projects are
# indistinguishable at the API surface.
raise HTTPException(status_code=404, detail="Project not found") from None
except IntegrityError:
# The idempotency read above and this insert are not atomic: a
# concurrent request for the same thread_id can commit in between, so
@ -869,13 +878,11 @@ async def create_thread(body: ThreadCreateRequest, request: Request) -> ThreadRe
raise HTTPException(status_code=500, detail="Failed to create thread")
logger.info("Thread created: %s", sanitize_log_param(thread_id))
return ThreadResponse(
thread_id=thread_id,
status="idle",
created_at=now,
updated_at=now,
metadata=body.metadata,
)
# Respond from the persisted record — the store stamps
# ``metadata.deerflow_project_id`` from the assigned project_id column, so
# echoing ``body.metadata`` here would omit the membership the retry path
# (``_existing_thread_response``) reports.
return _existing_thread_response(thread_id, created_record)
@router.post("/{thread_id}/branches", response_model=ThreadBranchResponse)
@ -952,6 +959,15 @@ async def _branch_thread_with_reservation(
"branch_parent_message_id": body.message_id,
"branch_created_at": now,
}
# A branch extends its source conversation, so the new row inherits the
# source thread's project membership — an unassigned branch would surface
# under Recent chats instead of the source thread's project group. The
# store re-validates the project inside the insert (same fail-closed path
# as create/move), and the inherited id may be stale only when the project
# was archived or deleted after the source read.
from deerflow.persistence.projects import ProjectNotAssignableError
source_project_id = (source_metadata or {}).get(THREAD_PROJECT_METADATA_KEY)
if body.title:
display_name = body.title
@ -1026,17 +1042,30 @@ async def _branch_thread_with_reservation(
logger.exception("Failed to write branch checkpoint for thread %s", sanitize_log_param(new_thread_id))
raise HTTPException(status_code=500, detail="Failed to create branch") from None
async def _write_branch_row(project_id: str | None) -> None:
try:
await thread_store.create(
new_thread_id,
assistant_id=source_record.get("assistant_id"),
display_name=display_name,
metadata=branch_metadata,
project_id=project_id,
**thread_owner_kwargs,
)
except ProjectNotAssignableError:
raise
except Exception:
logger.exception("Failed to write branch thread_meta for %s", sanitize_log_param(new_thread_id))
raise HTTPException(status_code=500, detail="Failed to create branch") from None
try:
await thread_store.create(
new_thread_id,
assistant_id=source_record.get("assistant_id"),
display_name=display_name,
metadata=branch_metadata,
**thread_owner_kwargs,
)
except Exception:
logger.exception("Failed to write branch thread_meta for %s", sanitize_log_param(new_thread_id))
raise HTTPException(status_code=500, detail="Failed to create branch") from None
await _write_branch_row(source_project_id)
except ProjectNotAssignableError:
# The source project became unassignable (archived, or deleted in a
# race that cleared the source row's membership after our read): keep
# the branch usable as an unassigned thread — the pre-inheritance
# behavior for sources without an active project.
await _write_branch_row(None)
# The thread feed (GET /messages, /messages/page) reads the run-event
# store, not checkpoints, and a fresh branch has no run_events — so the
@ -1087,11 +1116,15 @@ async def search_threads(body: ThreadSearchRequest, request: Request) -> list[Th
from deerflow.persistence.thread_meta import InvalidMetadataFilterError
repo = get_thread_store(request)
# Three-state project filter: key absent → no filter; explicit null →
# unassigned threads only; string → members of that project.
project_filter = body.project_id if "project_id" in body.model_fields_set else PROJECT_FILTER_UNSET
try:
rows = await repo.search(
metadata=body.metadata or None,
status=body.status,
**({"archived": body.archived} if body.archived is not None else {}),
project_id=project_filter,
limit=body.limit,
offset=body.offset,
)
@ -1147,6 +1180,33 @@ async def patch_thread(thread_id: ThreadId, body: ThreadPatchRequest, request: R
)
class ThreadMoveRequest(BaseModel):
"""Request body for moving a thread into/out of a project."""
project_id: str | None = Field(..., description="Target project id, or null to unassign")
@router.post("/{thread_id}/move", response_model=ThreadResponse)
@require_permission("threads", "write", owner_check=True, require_existing=True)
async def move_thread(thread_id: ThreadId, body: ThreadMoveRequest, request: Request) -> ThreadResponse:
"""Move a thread between projects (or out). Organizational only: history,
run state, and per-thread files are untouched (RFC v2 §6)."""
from app.gateway.deps import get_thread_store
thread_store = get_thread_store(request)
moved = await thread_store.set_project(thread_id, body.project_id)
if not moved:
raise HTTPException(status_code=404, detail="Thread or project not found")
record = await thread_store.get(thread_id)
return ThreadResponse(
thread_id=thread_id,
status=record.get("status", "idle"),
created_at=coerce_iso(record.get("created_at", "")),
updated_at=coerce_iso(record.get("updated_at", "")),
metadata=record.get("metadata", {}),
)
@router.get("/{thread_id}", response_model=ThreadResponse)
@require_permission("threads", "read", owner_check=True)
async def get_thread(thread_id: ThreadId, request: Request) -> ThreadResponse:

View File

@ -201,14 +201,24 @@ async def _ensure_thread_metadata(
if existing is None:
if require_existing_thread:
raise LookupError(f"Thread {record.thread_id} was deleted during run admission")
from deerflow.persistence.thread_meta import THREAD_PROJECT_METADATA_KEY
run_metadata = record.metadata or {}
metadata = {
key: value
for key, value in run_metadata.items()
# Strip the run-scoped trace id (existing) and the reserved
# membership key: run admission never modifies project membership —
# the column is written only by POST /api/threads and
# /threads/{id}/move — so the key must not persist either.
if key not in (DEERFLOW_TRACE_METADATA_KEY, THREAD_PROJECT_METADATA_KEY)
}
await thread_store.create(
record.thread_id,
assistant_id=record.assistant_id,
# Seeded from the run that created the thread, minus the run-scoped
# trace id: a thread spans many runs and as many trace ids, so
# pinning the first one here would be misleading rather than useful.
metadata={key: value for key, value in (record.metadata or {}).items() if key != DEERFLOW_TRACE_METADATA_KEY},
metadata=metadata,
)
return
async def _terminal_record_stream_missing(bridge: StreamBridge, record: RunRecord) -> bool:

View File

@ -18,7 +18,7 @@ Three-branch decision (see ``_decide_state``)
| empty (no DeerFlow tables) | ``create_all`` + ``alembic stamp head`` |
| legacy (DeerFlow tables, no alembic) | ``create_all`` (baseline tables only, as backfill) + ``stamp 0001_baseline`` + ``upgrade head`` |
| versioned (one locally known revision) | ``alembic upgrade head`` |
| reviewed forward-compatible revision 0019 | warn and skip migration |
| reviewed forward revision with local columns | warn and skip migration |
| unknown, empty, or multiple revision rows | refuse to start |
The legacy branch handles pre-alembic databases that already have at least one
@ -116,7 +116,14 @@ _KNOWN_REVISIONS: frozenset[str] | None = None
# server default, table, index, constraint, or data backfill. The owning 0019
# change must cross-pin this revision id and schema shape in tests. Amending
# that DDL requires re-auditing old-repository reads and writes before this
# exception remains valid.
# exception remains valid. The exception also requires every current ORM table
# and column: the original 0018 + incarnation columns shape lacks projects and
# is no longer compatible with this build. Both skip paths validate that floor.
# Note: this tree's own chain already carries ``0019_projects`` /
# ``0020_threads_meta_project_id`` off ``0018_oauth_identity_pg_partial``; the
# ``0019_`` numeric prefix is intentionally reused. When the owning rollout
# revision merges it must re-parent onto the current head (see
# ``migrations/AGENTS.md``) so alembic never sees two heads off 0018.
_FORWARD_COMPATIBLE_REVISION = "0019_thread_incarnations"
# Baseline (stamp target for legacy DBs). Pinned here so the bootstrap layer
@ -317,6 +324,32 @@ async def _read_database_revision(conn: Any) -> str:
return revision
def _validate_forward_schema(sync_conn: Any) -> None:
"""Require the local repository schema before skipping unknown migrations.
This is a presence check, not a general schema compatibility proof. The
allowlisted additive DDL still needs its separate read/write audit. Derive
the local floor from ORM metadata so a new mapped column cannot silently
invalidate the existing exception again.
"""
import deerflow.persistence.models # noqa: F401
from deerflow.persistence.base import Base
inspector = sa_inspect(sync_conn)
tables = set(inspector.get_table_names())
missing = []
for name, table in sorted(Base.metadata.tables.items()):
if name not in tables:
missing.append(name)
continue
columns = {column["name"] for column in inspector.get_columns(name)}
missing.extend(f"{name}.{column.name}" for column in table.columns if column.name not in columns)
if missing:
raise RuntimeError(
f"bootstrap: revision {_FORWARD_COMPATIBLE_REVISION!r} is missing required local schema: {', '.join(missing)}; refusing to start. See docs/database-forward-revision-recovery.md for the audited offline migration path."
)
def _reflect_state(sync_conn: Any) -> dict[str, bool]:
"""Inspect *sync_conn* (sync connection inside ``run_sync``) and return:
@ -597,15 +630,18 @@ async def bootstrap_schema(engine: AsyncEngine, *, backend: str, postgres_schema
raise
async with engine.connect() as conn:
current_revision = await _read_database_revision(conn)
if current_revision != _FORWARD_COMPATIBLE_REVISION:
raise
if current_revision != _FORWARD_COMPATIBLE_REVISION:
raise
await conn.run_sync(_validate_forward_schema)
logger.warning(
"bootstrap: database advanced concurrently to explicitly forward-compatible revision %s; skipping the stale local upgrade",
current_revision,
)
elif database_revision == _FORWARD_COMPATIBLE_REVISION:
async with engine.connect() as conn:
await conn.run_sync(_validate_forward_schema)
logger.warning(
"bootstrap: database revision %s is newer than local head %s but is explicitly forward-compatible; skipping migration",
"bootstrap: database revision %s is explicitly forward-compatible with local head %s and has its required tables and columns; skipping migration",
database_revision,
head,
)

View File

@ -2,7 +2,7 @@
DeerFlow's application tables (`runs`, `threads_meta`, `feedback`, `users`, `run_events`, plus the four `channel_*` tables) are owned by alembic via a **hybrid bootstrap** strategy. LangGraph's checkpointer tables (`checkpoints`, `checkpoint_blobs`, `checkpoint_writes`, `checkpoint_migrations`) live in the same database but are owned by LangGraph and excluded from alembic's view via `migrations/_env_filters.py::include_object`.
**Convention**: every ORM model change (new column, new table, new index) MUST ship as an alembic revision under `migrations/versions/`. The Gateway runs `alembic upgrade head` automatically on startup; users do not run `alembic` manually in production.
**Convention**: every ORM model change (new column, new table, new index) MUST ship as an alembic revision under `migrations/versions/`. The Gateway runs `alembic upgrade head` automatically on startup; routine production upgrades do not require manual Alembic commands. The audited offline recovery below is an exception for the out-of-tree incarnation revision.
**Hybrid bootstrap** (`persistence/bootstrap.py::bootstrap_schema`, invoked from `persistence/engine.py::init_engine`):
@ -11,35 +11,58 @@ DeerFlow's application tables (`runs`, `threads_meta`, `feedback`, `users`, `run
| empty (no DeerFlow tables) | `create_all` + `alembic stamp head` |
| legacy (DeerFlow tables, no `alembic_version`) | `create_all` (baseline tables only, backfill) + `alembic stamp 0001_baseline` + `upgrade head` |
| versioned (one locally known `alembic_version` row) | `alembic upgrade head` |
| forward-compatible (`0019_thread_incarnations`) | warn and skip migration |
| `0019_thread_incarnations` with all current ORM tables/columns | warn and skip migration |
| `0019_thread_incarnations` missing local tables/columns | refuse startup; offline recovery required |
| unknown revision, empty version table, or multiple version rows | fail closed and refuse to start |
The legacy branch handles pre-alembic databases that already have at least one DeerFlow-owned table. `create_all` runs first because stamping at `0001_baseline` makes alembic skip the baseline's own `create_table` DDL on the subsequent upgrade — so any baseline table introduced into `Base.metadata` after the user's DB was first provisioned (e.g. the `channel_*` tables from PR #1930 for users upgrading across multiple releases) would otherwise never be created, and the first request hitting that table would 500 with `no such table`. The backfill is **restricted to `_BASELINE_TABLE_NAMES`** so it does not also create tables that future revisions introduce — those revisions' own `op.create_table` would otherwise fail with `relation already exists`. A guard test pins `_BASELINE_TABLE_NAMES` against `0001_baseline.upgrade()`'s actual output, so editing 0001 to add or remove a table forces a matching update to the constant. Column-level shape (pre-#3658 vs post-#3658 vs manual-ALTER for `token_usage_by_model`) is answered by each `versions/*.py` revision via the idempotent helpers in `migrations/_helpers.py` (`safe_add_column` / `safe_drop_column`) which no-op when the change is already present and `logger.warning` on shape drift. **Adding a new ORM column / table only requires a new revision file — no edit to `bootstrap.py` is needed** *unless* the new revision adds a new baseline table (rare; only happens when a new model is part of the baseline rather than introduced by its own revision).
The empty-DB path keeps using `create_all` because `Base.metadata` is the only authoritative schema source — `create_all` renders both SQLite (JSON, type affinity) and Postgres (JSONB, partial indexes) correctly without anyone having to keep a hand-written baseline in lockstep. `0001_baseline.upgrade()` is therefore almost never executed in practice; it exists as a stamp target + chain root.
**Rolling forward compatibility**: this migration tree deliberately remains at
`0018_oauth_identity_pg_partial`, but an older Gateway may briefly share a
database with the expand-only `0019_thread_incarnations` deployment. Bootstrap
reads `alembic_version` while holding its backend lock and accepts exactly one
row. A locally known revision follows the normal upgrade path. The one unknown
revision `0019_thread_incarnations` is explicitly allowlisted: bootstrap logs a
warning and leaves the newer schema untouched. Any other unknown revision, an
empty version table, or multiple version rows fails closed. Do not broaden the
allowlist without proving that old repositories can read, insert, and update
through the newer schema; nullable additive columns are covered by
`tests/test_persistence_forward_revision_compat.py`. This exception is reviewed
only for the expand-only 0019 shape: nullable VARCHAR(32)
**Rolling forward compatibility**: the local chain head is `0020_threads_meta_project_id`
(`0018_oauth_identity_pg_partial``0019_projects``0020_threads_meta_project_id`).
Bootstrap reads `alembic_version` while holding its backend lock and accepts
exactly one row. A locally known revision follows the normal upgrade path. The
one unknown revision `0019_thread_incarnations` is conditionally allowlisted:
bootstrap first requires every current ORM table and column, then logs a
warning and leaves the schema untouched. The original rollout shape (0018 plus
the two incarnation columns) is now rejected: it lacks `projects` and
`threads_meta.project_id`. Seeding current head is only a positive compatibility
fixture; tests must also construct the original 0018-based schema and assert
rejection on both the direct startup and SQLite race-recovery paths. The check
uses `conn.run_sync` reflection and derives its local floor from `Base.metadata`
so future ORM additions cannot silently bypass it. This checks presence only;
the additive DDL audit below still owns type/constraint compatibility. Any other
unknown revision, an empty version table, or multiple version rows fails
closed. Do not broaden the allowlist without proving that old repositories can
read, insert, and update through the newer schema; nullable additive columns
are covered by `tests/test_persistence_forward_revision_compat.py`. This
exception is reviewed only for the expand-only 0019 shape: nullable VARCHAR(32)
`threads_meta.incarnation` and `mcp_tasks.thread_incarnation` columns with no
server default, table, index, constraint, or data backfill. The 0019 migration
must cross-pin its revision id and schema shape against the bootstrap contract;
amending that DDL requires a fresh old-repository compatibility audit. Because SQLite has no
cross-process bootstrap mutex, an old process may read 0018 immediately before
another process commits 0019. If its now-stale Alembic upgrade fails, bootstrap
re-reads the version and recovers only for the exact allowlisted 0019 while that
revision remains absent from the local migration tree. Do not generalize this
recovery or apply it to a binary that owns 0019; its migration failures must
remain fatal.
server default, table, index, constraint, or data backfill. The owning
`0019_thread_incarnations` migration must cross-pin its revision id and schema
shape against the bootstrap contract; amending that DDL requires a fresh
old-repository compatibility audit. The `0019_` numeric prefix is intentionally
reused: `0019_projects` is this tree's in-chain revision, while
`0019_thread_incarnations` is the reserved, out-of-tree rollout id allowlisted
above — revision ids only need to be unique, not numerically ordered, but the
owning rollout revision must re-parent from `0018_oauth_identity_pg_partial`
onto this tree's head when it merges so `alembic` never sees two heads off
0018. Because SQLite has no cross-process bootstrap mutex, an old process may
read 0018 immediately before another process commits 0019. If its now-stale
Alembic upgrade fails, bootstrap re-reads the version and recovers only for the
exact allowlisted 0019 with all current ORM tables and columns while that
revision remains absent from the local migration tree. Do not generalize this recovery or apply it to a binary that
owns 0019; its migration failures must remain fatal.
For an existing database with the original 0018-plus-incarnation shape, use the
[audited offline recovery procedure](../../../../../../docs/database-forward-revision-recovery.md).
Bootstrap never re-stamps an unknown revision automatically. After stopping all
writers, backing up, and verifying the exact additive schema, the operator may
purge-stamp the known 0018 parent and apply this tree's 0019/0020 migrations;
the extra nullable columns and their data remain intact. A regression exercises
that procedure from the original schema and verifies repository reads/inserts
and preservation of incarnation data.
**Concurrency safety**: Postgres uses `pg_advisory_lock` to serialise concurrent Gateway instances. SQLite uses a per-engine `asyncio.Lock` for same-process startup and is best-effort across processes via SQLite's file-level write lock + `PRAGMA busy_timeout`; multi-instance deployments should use Postgres. Column revisions in `versions/` additionally use idempotent helpers (`_helpers.py::safe_add_column`, `safe_drop_column`) so repeated post-baseline changes and retries are no-ops when the change is already present.
@ -47,7 +70,7 @@ remain fatal.
```bash
cd backend && make migrate-rev MSG="add foo column to runs"
```
This invokes `alembic revision --autogenerate` against the live ORM models. Review the generated file under `migrations/versions/` and switch raw `op.add_column` / `op.drop_column` calls to the idempotent helpers from `_helpers.py` before committing. There is no `make migrate` / `make migrate-stamp` target on purpose — the only execution path is Gateway startup, which keeps operational mistakes off the table.
This invokes `alembic revision --autogenerate` against the live ORM models. Review the generated file under `migrations/versions/` and switch raw `op.add_column` / `op.drop_column` calls to the idempotent helpers from `_helpers.py` before committing. There is no `make migrate` / `make migrate-stamp` target on purpose — routine upgrades execute at Gateway startup; the documented offline recovery is reserved for the audited out-of-tree schema.
**Extension-owned tables.** An extension that persists data owns its schema
end to end and must not register models against `deerflow.persistence.base.Base`
@ -113,6 +136,8 @@ on installs that never enabled it. The convention is:
- `migrations/versions/0016_subagent_batches.py` — creates durable native-subagent batch and item tables, including owner/submission idempotency, item identity, lease/recovery state, and result fields
- `migrations/versions/0017_personal_access_tokens.py` — creates the personal access token table for programmatic API access
- `migrations/versions/0018_oauth_identity_pg_partial.py` — converts `idx_users_oauth_identity` to a partial index on Postgres (`postgresql_where`), matching what `UserRow.__table_args__` already builds via `create_all`; `0001_baseline` never applied the predicate on Postgres, so every `alembic upgrade head`-provisioned deployment carried a full index until this revision. Postgres-only, idempotent (checks `pg_index.indpred` directly), no-op on SQLite (already partial via `sqlite_where`) and on a DB where the index doesn't exist yet. Originally generated as 0017 and renumbered to 0018 after 0017_personal_access_tokens merged first and kept that slot
- `migrations/versions/0019_projects.py` — creates the `projects` table (id/user_id/name/instructions/presentation/status + timestamps) for the Projects Phase-1 organization feature; chains after `0018_oauth_identity_pg_partial`
- `migrations/versions/0020_threads_meta_project_id.py` — adds nullable `threads_meta.project_id` plus `ix_threads_meta_project_id` (no FK by design: project delete clears membership first, and the reserved `deerflow_project_id` metadata key stays in sync); chains after `0019_projects` and is the current head. The `0019_` numeric prefix is reused by the reserved out-of-tree `0019_thread_incarnations` — see the rolling-forward section above
- `persistence/bootstrap.py``bootstrap_schema(engine, backend=...)`, the three-branch provisioning decision, locked revision validation, and the narrow 0019 forward-compatibility exception
- `extensions/loader.py::load_extensions` — registers each spec's `table_prefix` with `register_extension_table_prefix()`
- Tests: `tests/test_persistence_bootstrap.py` (branches), `tests/test_persistence_bootstrap_concurrency.py` (concurrency), `tests/test_persistence_bootstrap_regression.py` (issue #3682), `tests/test_persistence_migrations_env.py` (filter, including extension-owned tables), `tests/test_extension_loader.py::TestTablePrefixRegistration` (spec-to-filter wiring), `tests/blocking_io/test_persistence_bootstrap.py` (asyncio.to_thread anchor), `tests/test_migration_0004_run_ownership_dedupe.py` + `tests/test_migration_0007_scheduled_run_active_dedupe.py` (dedupe-before-unique-index pre-steps)

View File

@ -0,0 +1,45 @@
"""projects.
Revision ID: 0019_projects
Revises: 0018_oauth_identity_pg_partial
Create Date: 2026-09-06
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0019_projects"
down_revision: str | Sequence[str] | None = "0018_oauth_identity_pg_partial"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if not inspector.has_table("projects"):
op.create_table(
"projects",
sa.Column("id", sa.String(length=64), nullable=False),
sa.Column("user_id", sa.String(length=64), nullable=False),
sa.Column("name", sa.String(length=128), nullable=False),
sa.Column("instructions", sa.Text(), nullable=False),
sa.Column("presentation", sa.JSON(), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_projects_user_id", "projects", ["user_id"])
op.create_index("ix_projects_status", "projects", ["status"])
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if inspector.has_table("projects"):
op.drop_index("ix_projects_status", table_name="projects")
op.drop_index("ix_projects_user_id", table_name="projects")
op.drop_table("projects")

View File

@ -0,0 +1,37 @@
"""threads_meta.project_id.
Revision ID: 0020_threads_meta_project_id
Revises: 0019_projects
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0020_threads_meta_project_id"
down_revision: str | Sequence[str] | None = "0019_projects"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
from deerflow.persistence.migrations._helpers import safe_add_column
safe_add_column("threads_meta", sa.Column("project_id", sa.String(length=64), nullable=True))
inspector = sa.inspect(op.get_bind())
existing = {i["name"] for i in inspector.get_indexes("threads_meta")}
if "ix_threads_meta_project_id" not in existing:
op.create_index("ix_threads_meta_project_id", "threads_meta", ["project_id"])
def downgrade() -> None:
from deerflow.persistence.migrations._helpers import safe_drop_column
inspector = sa.inspect(op.get_bind())
existing = {i["name"] for i in inspector.get_indexes("threads_meta")}
if "ix_threads_meta_project_id" in existing:
op.drop_index("ix_threads_meta_project_id", table_name="threads_meta")
safe_drop_column("threads_meta", "project_id")

View File

@ -26,6 +26,7 @@ from deerflow.persistence.managed_subagents.model import ManagedSubagentRow
from deerflow.persistence.mcp_tasks.model import McpTaskRow
from deerflow.persistence.models.run_event import RunEventRow
from deerflow.persistence.personal_access_tokens.model import PersonalAccessTokenRow
from deerflow.persistence.projects.model import ProjectRow
from deerflow.persistence.run.model import RunRow
from deerflow.persistence.scheduled_task_runs.model import ScheduledTaskRunRow
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskRow
@ -44,6 +45,7 @@ __all__ = [
"McpTaskRow",
"ManagedSubagentRow",
"PersonalAccessTokenRow",
"ProjectRow",
"RunEventRow",
"RunRow",
"ScheduledTaskRow",

View File

@ -0,0 +1,8 @@
"""Project persistence — ORM model and SQL repository."""
from __future__ import annotations
from deerflow.persistence.projects.model import ProjectRow
from deerflow.persistence.projects.sql import ProjectNotAssignableError, ProjectRepository
__all__ = ["ProjectNotAssignableError", "ProjectRepository", "ProjectRow"]

View File

@ -0,0 +1,36 @@
"""ORM model for projects.
One row per user-owned project. ``id`` (uuid4 hex) is the only external
identity; ``name``/``presentation`` are display attributes renaming touches
exactly this row and never affects membership (threads reference
``threads_meta.project_id``). ``instructions`` is user-authored project
context; Phase 1 stores and PATCHes it, Phase 2 injects it. There is
deliberately no ``memory_mode``/sharing/agent-config column: no consumer
exists in Phase 1/2 (RFC v2 §3, §4.1).
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import JSON, DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
class ProjectRow(Base):
__tablename__ = "projects"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
user_id: Mapped[str] = mapped_column(String(64), index=True)
name: Mapped[str] = mapped_column(String(128))
instructions: Mapped[str] = mapped_column(Text, default="")
presentation: Mapped[dict] = mapped_column(JSON, default=dict)
status: Mapped[str] = mapped_column(String(16), default="active", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)

View File

@ -0,0 +1,177 @@
"""SQLAlchemy-backed project repository.
Ownership discipline mirrors ``ThreadMetaRepository``: every method resolves
the caller via ``resolve_user_id(..., AUTO)`` and filters by ``user_id``;
a foreign project is indistinguishable from a missing one (callers map
``None``/``False`` to 404). ``delete`` runs the Phase-1 membership-clearing
transaction from RFC v2 §5.1 (the ``project_documents`` statement joins this
transaction in Phase 2).
"""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime
from typing import Any, Literal
from sqlalchemy import delete as sa_delete
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.projects.model import ProjectRow
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
logger = logging.getLogger(__name__)
ProjectStatus = Literal["active", "archived"]
class ProjectNotAssignableError(ValueError):
"""Raised when a thread cannot be assigned to a project.
The project is missing, foreign to the caller, or archived the
atomicity rules (RFC v2 §5.2) make these indistinguishable inside the
mutating statement, so callers get one signal and map it to 404 (explicit
API) or a dropped key (run admission).
"""
class ProjectRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _row_to_dict(row: ProjectRow) -> dict[str, Any]:
d = row.to_dict()
for key in ("created_at", "updated_at"):
val = d.get(key)
if isinstance(val, datetime):
d[key] = coerce_iso(val)
return d
async def create(
self,
*,
name: str,
instructions: str = "",
presentation: dict | None = None,
user_id: str | None | _AutoSentinel = AUTO,
) -> dict:
resolved_user_id = resolve_user_id(user_id, method_name="ProjectRepository.create")
now = datetime.now(UTC)
row = ProjectRow(
id=uuid.uuid4().hex,
user_id=resolved_user_id,
name=name,
instructions=instructions,
presentation=presentation or {},
status="active",
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
async def get(self, project_id: str, *, user_id: str | None | _AutoSentinel = AUTO) -> dict | None:
resolved_user_id = resolve_user_id(user_id, method_name="ProjectRepository.get")
async with self._sf() as session:
row = await session.get(ProjectRow, project_id)
if row is None:
return None
if resolved_user_id is not None and row.user_id != resolved_user_id:
return None
return self._row_to_dict(row)
async def list(
self,
*,
status: ProjectStatus | None = None,
user_id: str | None | _AutoSentinel = AUTO,
) -> list[dict]:
resolved_user_id = resolve_user_id(user_id, method_name="ProjectRepository.list")
stmt = select(ProjectRow).order_by(ProjectRow.created_at.asc(), ProjectRow.id.asc())
if resolved_user_id is not None:
stmt = stmt.where(ProjectRow.user_id == resolved_user_id)
if status is not None:
stmt = stmt.where(ProjectRow.status == status)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._row_to_dict(r) for r in result.scalars()]
async def patch(
self,
project_id: str,
*,
name: str | None = None,
instructions: str | None = None,
presentation: dict | None = None,
user_id: str | None | _AutoSentinel = AUTO,
) -> dict | None:
resolved_user_id = resolve_user_id(user_id, method_name="ProjectRepository.patch")
async with self._sf() as session:
row = await session.get(ProjectRow, project_id)
if row is None or (resolved_user_id is not None and row.user_id != resolved_user_id):
return None
if name is not None:
row.name = name
if instructions is not None:
row.instructions = instructions
if presentation is not None:
row.presentation = presentation
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
async def set_status(
self,
project_id: str,
status: ProjectStatus,
*,
user_id: str | None | _AutoSentinel = AUTO,
) -> dict | None:
"""Idempotent status flip; returns the current row or None (missing/foreign)."""
resolved_user_id = resolve_user_id(user_id, method_name="ProjectRepository.set_status")
async with self._sf() as session:
row = await session.get(ProjectRow, project_id)
if row is None or (resolved_user_id is not None and row.user_id != resolved_user_id):
return None
row.status = status
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
async def delete(self, project_id: str, *, user_id: str | None | _AutoSentinel = AUTO) -> bool:
"""Delete a project and clear membership in one transaction (no filesystem work).
Returns True when the project row was deleted; False when missing/foreign.
"""
resolved_user_id = resolve_user_id(user_id, method_name="ProjectRepository.delete")
async with self._sf() as session:
async with session.begin():
# Lock the project row before touching it (FOR UPDATE on
# Postgres; the clause renders nothing on SQLite). Membership
# assignment (ThreadMetaRepository.set_project/create) takes
# the same row lock before writing ``project_id``, so an
# assigner either commits first and has its membership cleared
# below, or blocks until this transaction commits and then
# re-reads the row as gone — no dangling ``threads_meta.project_id``
# (RFC v2 §14.14). Missing/foreign rows keep the False result.
lock_stmt = select(ProjectRow).where(ProjectRow.id == project_id)
if resolved_user_id is not None:
lock_stmt = lock_stmt.where(ProjectRow.user_id == resolved_user_id)
locked = (await session.execute(lock_stmt.with_for_update())).scalar_one_or_none()
if locked is None:
return False
if resolved_user_id is not None:
await session.execute(update(ThreadMetaRow).where(ThreadMetaRow.project_id == project_id, ThreadMetaRow.user_id == resolved_user_id).values(project_id=None, updated_at=ThreadMetaRow.__table__.c.updated_at))
result = await session.execute(sa_delete(ProjectRow).where(ProjectRow.id == project_id, ProjectRow.user_id == resolved_user_id))
else:
await session.execute(update(ThreadMetaRow).where(ThreadMetaRow.project_id == project_id).values(project_id=None, updated_at=ThreadMetaRow.__table__.c.updated_at))
result = await session.execute(sa_delete(ProjectRow).where(ProjectRow.id == project_id))
return result.rowcount > 0

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from deerflow.persistence.thread_meta.base import THREAD_ARCHIVED_METADATA_KEY, THREAD_PINNED_METADATA_KEY, InvalidMetadataFilterError, ThreadMetaStore
from deerflow.persistence.thread_meta.base import PROJECT_FILTER_UNSET, THREAD_ARCHIVED_METADATA_KEY, THREAD_PINNED_METADATA_KEY, THREAD_PROJECT_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,8 +16,11 @@ if TYPE_CHECKING:
__all__ = [
"InvalidMetadataFilterError",
"MemoryThreadMetaStore",
"PROJECT_FILTER_UNSET",
"THREAD_PINNED_METADATA_KEY",
"THREAD_ARCHIVED_METADATA_KEY",
"THREAD_PINNED_METADATA_KEY",
"THREAD_PROJECT_METADATA_KEY",
"ThreadMetaRepository",
"ThreadMetaRow",
"ThreadMetaStore",

View File

@ -15,7 +15,7 @@ three-state semantics (see :mod:`deerflow.runtime.user_context`):
from __future__ import annotations
import abc
from typing import Any
from typing import Any, ClassVar, Final
from deerflow.runtime.user_context import AUTO, _AutoSentinel
@ -25,6 +25,28 @@ from deerflow.runtime.user_context import AUTO, _AutoSentinel
THREAD_PINNED_METADATA_KEY = "deerflow_pinned"
THREAD_ARCHIVED_METADATA_KEY = "deerflow_archived"
# Cross-component metadata key. Keep in sync with
# ``frontend/src/core/threads/utils.ts`` and
# ``frontend/tests/e2e/utils/mock-api.ts``.
THREAD_PROJECT_METADATA_KEY = "deerflow_project_id"
class _ProjectFilterUnset:
"""Sentinel for ``search(project_id=...)``: absent filter vs explicit unassigned."""
_instance: ClassVar[_ProjectFilterUnset | None] = None
def __new__(cls) -> _ProjectFilterUnset:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __repr__(self) -> str:
return "<PROJECT_FILTER_UNSET>"
PROJECT_FILTER_UNSET: Final = _ProjectFilterUnset()
class InvalidMetadataFilterError(ValueError):
"""Raised when all client-supplied metadata filter keys are rejected."""
@ -40,8 +62,19 @@ class ThreadMetaStore(abc.ABC):
user_id: str | None | _AutoSentinel = AUTO,
display_name: str | None = None,
metadata: dict | None = None,
project_id: str | None = None,
) -> dict:
pass
"""Create a thread row; when ``project_id`` is set, validate the
project inside the insert transaction and raise
``ProjectNotAssignableError`` on failure (no partial row)."""
@abc.abstractmethod
async def set_project(self, thread_id: str, project_id: str | None, *, user_id: str | None | _AutoSentinel = AUTO) -> bool:
"""Atomically move a thread into/out of a project (RFC v2 §5.2).
Returns False when the thread is missing/foreign, or the target
project is missing/foreign/archived. Must not touch ``updated_at``.
"""
@abc.abstractmethod
async def get(self, thread_id: str, *, user_id: str | None | _AutoSentinel = AUTO) -> dict | None:
@ -54,6 +87,7 @@ class ThreadMetaStore(abc.ABC):
metadata: dict[str, Any] | None = None,
status: str | None = None,
archived: bool | None = None,
project_id: str | None | _ProjectFilterUnset = PROJECT_FILTER_UNSET,
limit: int = 100,
offset: int = 0,
user_id: str | None | _AutoSentinel = AUTO,

View File

@ -12,7 +12,7 @@ from typing import Any
from langgraph.store.base import BaseStore
from deerflow.persistence.json_compat import json_value_matches
from deerflow.persistence.thread_meta.base import THREAD_ARCHIVED_METADATA_KEY, THREAD_PINNED_METADATA_KEY, ThreadMetaStore
from deerflow.persistence.thread_meta.base import PROJECT_FILTER_UNSET, THREAD_ARCHIVED_METADATA_KEY, THREAD_PINNED_METADATA_KEY, ThreadMetaStore, _ProjectFilterUnset
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
from deerflow.utils.time import coerce_iso, now_iso
@ -48,7 +48,18 @@ class MemoryThreadMetaStore(ThreadMetaStore):
user_id: str | None | _AutoSentinel = AUTO,
display_name: str | None = None,
metadata: dict | None = None,
project_id: str | None = None,
) -> dict:
# Memory mode has no projects backend in Phase 1. Fail closed exactly
# like the SQL store does for a missing/foreign/archived project: a
# create carrying ``project_id`` raises ``ProjectNotAssignableError``
# (the router maps it to 404) instead of silently persisting an
# unassigned thread that a run would then proceed under. Mirrors
# ``set_project`` below, which already reports rejection.
if project_id is not None:
from deerflow.persistence.projects import ProjectNotAssignableError
raise ProjectNotAssignableError(project_id)
resolved_user_id = resolve_user_id(user_id, method_name="MemoryThreadMetaStore.create")
now = now_iso()
record: dict[str, Any] = {
@ -65,6 +76,11 @@ class MemoryThreadMetaStore(ThreadMetaStore):
await self._store.aput(THREADS_NS, thread_id, record)
return record
async def set_project(self, thread_id: str, project_id: str | None, *, user_id: str | None | _AutoSentinel = AUTO) -> bool:
# Memory mode has no projects backend in Phase 1: membership moves
# are unsupported and always report rejection.
return False
async def get(self, thread_id: str, *, user_id: str | None | _AutoSentinel = AUTO) -> dict | None:
return await self._get_owned_record(thread_id, user_id, "MemoryThreadMetaStore.get")
@ -74,6 +90,7 @@ class MemoryThreadMetaStore(ThreadMetaStore):
metadata: dict[str, Any] | None = None,
status: str | None = None,
archived: bool | None = None,
project_id: str | None | _ProjectFilterUnset = PROJECT_FILTER_UNSET,
limit: int = 100,
offset: int = 0,
user_id: str | None | _AutoSentinel = AUTO,
@ -85,6 +102,9 @@ class MemoryThreadMetaStore(ThreadMetaStore):
scalable paginated I/O.
"""
resolved_user_id = resolve_user_id(user_id, method_name="MemoryThreadMetaStore.search")
if not isinstance(project_id, _ProjectFilterUnset) and project_id is not None:
# Memory mode has no projects backend: no thread can be a member.
return []
filter_dict: dict[str, Any] = {}
if status:
filter_dict["status"] = status

View File

@ -16,6 +16,7 @@ class ThreadMetaRow(Base):
thread_id: Mapped[str] = mapped_column(String(64), primary_key=True)
assistant_id: Mapped[str | None] = mapped_column(String(128), index=True)
user_id: Mapped[str | None] = mapped_column(String(64), index=True)
project_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
display_name: Mapped[str | None] = mapped_column(String(256))
status: Mapped[str] = mapped_column(String(20), default="idle")
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)

View File

@ -11,7 +11,7 @@ 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 THREAD_ARCHIVED_METADATA_KEY, THREAD_PINNED_METADATA_KEY, InvalidMetadataFilterError, ThreadMetaStore
from deerflow.persistence.thread_meta.base import PROJECT_FILTER_UNSET, THREAD_ARCHIVED_METADATA_KEY, THREAD_PINNED_METADATA_KEY, THREAD_PROJECT_METADATA_KEY, InvalidMetadataFilterError, ThreadMetaStore, _ProjectFilterUnset
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
@ -26,7 +26,10 @@ class ThreadMetaRepository(ThreadMetaStore):
@staticmethod
def _row_to_dict(row: ThreadMetaRow) -> dict[str, Any]:
d = row.to_dict()
d["metadata"] = d.pop("metadata_json", None) or {}
d["metadata"] = dict(d.pop("metadata_json", None) or {})
project_id = d.pop("project_id", None)
if project_id is not None:
d["metadata"][THREAD_PROJECT_METADATA_KEY] = project_id
for key in ("created_at", "updated_at"):
val = d.get(key)
if isinstance(val, datetime):
@ -43,26 +46,103 @@ class ThreadMetaRepository(ThreadMetaStore):
user_id: str | None | _AutoSentinel = AUTO,
display_name: str | None = None,
metadata: dict | None = None,
project_id: str | None = None,
) -> dict:
# Auto-resolve user_id from contextvar when AUTO; explicit None
# creates an orphan row (used by migration scripts).
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.create")
now = datetime.now(UTC)
row = ThreadMetaRow(
thread_id=thread_id,
assistant_id=assistant_id,
user_id=resolved_user_id,
display_name=display_name,
metadata_json=metadata or {},
created_at=now,
updated_at=now,
)
async with self._sf() as session:
if session.get_bind().dialect.name == "sqlite":
await session.execute(text("BEGIN IMMEDIATE"))
if project_id is not None:
from deerflow.persistence.projects import ProjectNotAssignableError
from deerflow.persistence.projects.model import ProjectRow
# Lock the project row (FOR UPDATE on Postgres; the clause
# renders nothing on SQLite) so a concurrent
# ProjectRepository.delete — which holds the same lock across
# its membership-clear and DELETE — either commits first (this
# read then finds no row) or waits for this transaction.
# RFC v2 §14.14: no dangling ``threads_meta.project_id``.
locked = await session.scalar(
select(ProjectRow.id)
.where(
ProjectRow.id == project_id,
ProjectRow.user_id == resolved_user_id,
ProjectRow.status == "active",
)
.with_for_update()
)
if locked is None:
raise ProjectNotAssignableError(project_id)
row = ThreadMetaRow(
thread_id=thread_id,
assistant_id=assistant_id,
user_id=resolved_user_id,
display_name=display_name,
status="idle",
metadata_json=metadata or {},
project_id=project_id,
created_at=now,
updated_at=now,
)
session.add(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
async def set_project(
self,
thread_id: str,
project_id: str | None,
*,
user_id: str | None | _AutoSentinel = AUTO,
) -> bool:
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.set_project")
from deerflow.persistence.projects.model import ProjectRow
async with self._sf() as session:
if session.get_bind().dialect.name == "sqlite":
# Read-then-write transaction: take the write lock up front
# (create() precedent) so a concurrent writer cannot
# interleave between the project check and the UPDATE.
await session.execute(text("BEGIN IMMEDIATE"))
if project_id is not None:
# Lock the project row (FOR UPDATE on Postgres; the clause
# renders nothing on SQLite) so a concurrent
# ProjectRepository.delete — which holds the same lock across
# its membership-clear and DELETE — either commits first (this
# read then finds no row) or waits for this transaction.
# RFC v2 §14.14: no dangling ``threads_meta.project_id``.
locked = await session.scalar(
select(ProjectRow.id)
.where(
ProjectRow.id == project_id,
ProjectRow.user_id == resolved_user_id,
ProjectRow.status == "active",
)
.with_for_update()
)
if locked is None:
await session.commit()
return False
stmt = (
update(ThreadMetaRow)
.where(ThreadMetaRow.thread_id == thread_id)
.values(
project_id=project_id,
# Explicit self-assignment: satisfies the column so the
# ``onupdate`` hook does not bump recency (pin precedent, G5).
updated_at=ThreadMetaRow.__table__.c.updated_at,
)
)
if resolved_user_id is not None:
stmt = stmt.where(ThreadMetaRow.user_id == resolved_user_id)
result = await session.execute(stmt)
await session.commit()
return result.rowcount > 0
async def get(
self,
thread_id: str,
@ -115,6 +195,7 @@ class ThreadMetaRepository(ThreadMetaStore):
metadata: dict[str, Any] | None = None,
status: str | None = None,
archived: bool | None = None,
project_id: str | None | _ProjectFilterUnset = PROJECT_FILTER_UNSET,
limit: int = 100,
offset: int = 0,
user_id: str | None | _AutoSentinel = AUTO,
@ -159,6 +240,11 @@ class ThreadMetaRepository(ThreadMetaStore):
# identically on SQLite and Postgres, including for the active view.
archive_flag = case((json_match(ThreadMetaRow.metadata_json, THREAD_ARCHIVED_METADATA_KEY, True), 1), else_=0)
stmt = stmt.where(archive_flag == int(archived))
if not isinstance(project_id, _ProjectFilterUnset):
if project_id is None:
stmt = stmt.where(ThreadMetaRow.project_id.is_(None))
else:
stmt = stmt.where(ThreadMetaRow.project_id == project_id)
stmt = stmt.limit(limit).offset(offset)
async with self._sf() as session:

View File

@ -91,6 +91,9 @@ async def test_route_permissions_disabled_preserves_all_permissions(monkeypatch)
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.RUNS_CANCEL,
Permissions.PROJECTS_READ,
Permissions.PROJECTS_WRITE,
Permissions.PROJECTS_DELETE,
]
cached.assert_not_called()
@ -107,6 +110,9 @@ async def test_route_permissions_use_async_provider_and_trusted_principal(monkey
Permissions.THREADS_WRITE,
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.PROJECTS_READ,
Permissions.PROJECTS_WRITE,
Permissions.PROJECTS_DELETE,
]
assert [(request.resource, request.action, request.target) for request in provider.requests] == [
("route", "read", Permissions.THREADS_READ),
@ -115,6 +121,9 @@ async def test_route_permissions_use_async_provider_and_trusted_principal(monkey
("route", "create", Permissions.RUNS_CREATE),
("route", "read", Permissions.RUNS_READ),
("route", "cancel", Permissions.RUNS_CANCEL),
("route", "read", Permissions.PROJECTS_READ),
("route", "write", Permissions.PROJECTS_WRITE),
("route", "delete", Permissions.PROJECTS_DELETE),
]
principal = provider.requests[0].principal
assert principal.user_id == "user-123"
@ -137,6 +146,9 @@ async def test_route_permissions_fail_closed_denies_only_the_failed_permission(m
Permissions.THREADS_DELETE,
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.PROJECTS_READ,
Permissions.PROJECTS_WRITE,
Permissions.PROJECTS_DELETE,
]
@ -154,6 +166,9 @@ async def test_route_permissions_fail_open_allows_the_failed_permission(monkeypa
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.RUNS_CANCEL,
Permissions.PROJECTS_READ,
Permissions.PROJECTS_WRITE,
Permissions.PROJECTS_DELETE,
]
@ -171,6 +186,9 @@ async def test_route_permissions_fail_open_allows_the_failed_permission(monkeypa
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.RUNS_CANCEL,
Permissions.PROJECTS_READ,
Permissions.PROJECTS_WRITE,
Permissions.PROJECTS_DELETE,
],
),
],

View File

@ -11,6 +11,7 @@ from types import SimpleNamespace
import pytest
from langgraph.store.memory import InMemoryStore
from deerflow.persistence.projects import ProjectNotAssignableError
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
from deerflow.runtime.user_context import reset_current_user, set_current_user
@ -209,3 +210,27 @@ async def test_explicit_none_bypasses_filter(store):
row = await store.get("t-alpha", user_id=None)
assert row is not None
@pytest.mark.anyio
@pytest.mark.no_auto_user
async def test_create_with_project_id_fails_closed(store):
"""Memory mode has no projects backend; a create carrying a project id
must fail closed (ProjectNotAssignableError) instead of silently
persisting an unassigned thread the router maps the error to 404 and
the frontend keeps the composer for a retry, matching the SQL store's
missing/foreign/archived project behavior."""
with _as_user(USER_A):
with pytest.raises(ProjectNotAssignableError):
await store.create("t-proj", project_id="p1")
# Nothing persisted, and the store's project filter fails closed too.
assert await store.search() == []
assert await store.search(project_id="p1") == []
# Unscoped creates still work.
await store.create("t-plain")
assert [r["thread_id"] for r in await store.search()] == ["t-plain"]
# Membership moves report rejection (never a silent unassign).
assert await store.set_project("t-plain", "p1") is False

View File

@ -157,7 +157,7 @@ async def test_migration_dedupes_duplicate_active_rows_before_unique_index(tmp_p
with sqlite3.connect(db_path) as raw:
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
# Bootstrap upgrades through the later revisions after 0004.
assert version_row[0] == "0018_oauth_identity_pg_partial"
assert version_row[0] == "0020_threads_meta_project_id"
# Sanity: the invariant the index enforces is now true — at most one
# active row per thread.

View File

@ -173,7 +173,7 @@ async def test_migration_supersedes_duplicate_active_runs_before_unique_index(tm
with sqlite3.connect(db_path) as raw:
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
assert version_row[0] == "0018_oauth_identity_pg_partial"
assert version_row[0] == "0020_threads_meta_project_id"
# Sanity: the invariant the index enforces now holds — at most one
# active row per task_id.

View File

@ -57,7 +57,7 @@ async def test_migration_interrupts_legacy_queue_and_adds_claim_fields(tmp_path:
# Bootstrap always advances to the repository head after exercising
# the 0015 migration behavior below.
assert version == "0018_oauth_identity_pg_partial"
assert version == "0020_threads_meta_project_id"
assert {"lease_owner", "lease_expires_at", "attempt_count"} <= columns.keys()
assert columns["attempt_count"]["nullable"] is False
assert overlap_policy == "enqueue"

View File

@ -0,0 +1,68 @@
"""Migration tests for 0019_projects and 0020_threads_meta_project_id."""
from __future__ import annotations
import pytest
import sqlalchemy as sa
from deerflow.persistence.engine import close_engine, init_engine
from deerflow.persistence.migrations import _helpers # noqa: F401 (ensures helpers importable)
pytestmark = pytest.mark.asyncio
async def _fresh_db(tmp_path):
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
return url
async def test_0019_creates_projects_table(tmp_path):
await _fresh_db(tmp_path)
try:
from deerflow.persistence.engine import get_engine
engine = get_engine()
async with engine.connect() as conn:
def _inspect(sync_conn):
inspector = sa.inspect(sync_conn)
assert inspector.has_table("projects")
cols = {c["name"]: c for c in inspector.get_columns("projects")}
assert set(cols) == {
"id",
"user_id",
"name",
"instructions",
"presentation",
"status",
"created_at",
"updated_at",
}
index_names = {i["name"] for i in inspector.get_indexes("projects")}
assert "ix_projects_user_id" in index_names
assert "ix_projects_status" in index_names
await conn.run_sync(_inspect)
finally:
await close_engine()
async def test_0020_adds_project_id_column(tmp_path):
await _fresh_db(tmp_path)
try:
from deerflow.persistence.engine import get_engine
engine = get_engine()
async with engine.connect() as conn:
def _inspect(sync_conn):
inspector = sa.inspect(sync_conn)
cols = {c["name"] for c in inspector.get_columns("threads_meta")}
assert "project_id" in cols
index_names = {i["name"] for i in inspector.get_indexes("threads_meta")}
assert "ix_threads_meta_project_id" in index_names
await conn.run_sync(_inspect)
finally:
await close_engine()

View File

@ -561,6 +561,46 @@ def test_pat_runs_policy_admits_exactly_the_mounted_routes():
assert not is_pat_allowed_route(method, path), f"{method} {path} is not implemented and must stay denied"
def test_pat_projects_policy_admits_exactly_the_mounted_routes():
"""Projects subtree (and the thread move endpoint) follow the same
enumerated-no-dead-methods discipline as the runs subtree: every
method/path the projects router actually implements is admitted (derived
from the mounted router, not a hand-maintained list), and deliberately
unimplemented neighbors stay default-denied. A new projects route fails
here until explicitly allowlisted; a removed one leaves a dead rule
visible."""
from fastapi.routing import APIRoute
from app.gateway.auth.pat import is_pat_allowed_route
from app.gateway.routers.projects import router
def concrete(path: str) -> str:
return path.replace("{project_id}", "p1")
for route in router.routes:
if not isinstance(route, APIRoute):
continue
path = concrete(route.path)
for method in sorted(route.methods - {"HEAD", "OPTIONS"}):
assert is_pat_allowed_route(method, path), f"{method} {path} is implemented but PAT-denied"
for method, path in [
("PUT", "/api/projects"),
("DELETE", "/api/projects"),
("PUT", "/api/projects/p1"),
("GET", "/api/projects/p1/archive"),
("GET", "/api/projects/p1/restore"),
("POST", "/api/projects/p1/threads"),
]:
assert not is_pat_allowed_route(method, path), f"{method} {path} is not implemented and must stay denied"
# Thread move (POST /api/threads/{id}/move) is admitted for PATs holding
# threads:write; other methods on the same path stay denied.
move_path = "/api/threads/6f1c2f0e-3b7a-4d2e-9c1a-2b5f0e8a1d3c/move"
assert is_pat_allowed_route("POST", move_path) is True
assert is_pat_allowed_route("GET", move_path) is False
def test_pat_scopes_enforced_on_stateless_run_entry(client):
"""Follow-up to the review's P1-1: the stateless run entrypoints now
carry @require_permission("runs", "create"), so a threads:read-only PAT

View File

@ -48,7 +48,7 @@ from deerflow.persistence.migrations._helpers import _normalize_default
asyncio_test = pytest.mark.asyncio
HEAD = "0018_oauth_identity_pg_partial"
HEAD = "0020_threads_meta_project_id"
BASELINE = "0001_baseline"

View File

@ -28,7 +28,7 @@ from deerflow.persistence.bootstrap import bootstrap_schema
pytestmark = pytest.mark.asyncio
HEAD = "0018_oauth_identity_pg_partial"
HEAD = "0020_threads_meta_project_id"
def _url(tmp_path: Path) -> str:

View File

@ -76,7 +76,7 @@ async def test_legacy_database_recovers_token_usage_column(tmp_path: Path) -> No
cols = {row[1] for row in raw.execute("PRAGMA table_info(runs)").fetchall()}
assert "token_usage_by_model" in cols
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
assert version_row[0] == "0018_oauth_identity_pg_partial"
assert version_row[0] == "0020_threads_meta_project_id"
# And the read path that originally 500'd must now succeed.
sf = get_session_factory()
@ -116,6 +116,6 @@ async def test_legacy_database_with_manual_alter_still_bootstraps(tmp_path: Path
# No duplicate column -- list, not set, to catch dupes.
assert cols.count("token_usage_by_model") == 1
version_row = raw.execute("SELECT version_num FROM alembic_version").fetchone()
assert version_row[0] == "0018_oauth_identity_pg_partial"
assert version_row[0] == "0020_threads_meta_project_id"
finally:
await close_engine()

View File

@ -12,6 +12,7 @@ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
import pytest
import sqlalchemy as sa
from alembic import command as alembic_command
from alembic.util.exc import CommandError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
@ -28,7 +29,7 @@ from deerflow.persistence.engine import close_engine, get_engine, init_engine_fr
from deerflow.persistence.mcp_tasks import McpTaskRepository
from deerflow.persistence.thread_meta import ThreadMetaRepository
HEAD = "0018_oauth_identity_pg_partial"
HEAD = "0020_threads_meta_project_id"
POSTGRES_URL = os.environ.get("TEST_POSTGRES_URI")
@ -58,6 +59,94 @@ async def _seed_head(engine) -> None:
assert await _database_revision(engine) == HEAD
async def _seed_original_forward_schema(engine) -> None:
# The rollout predates projects: seeding today's head masks missing columns.
await asyncio.to_thread(_upgrade, _get_alembic_config(engine), "0018_oauth_identity_pg_partial")
await _add_forward_columns(engine)
await _set_database_revision(engine, _FORWARD_COMPATIBLE_REVISION)
@pytest.mark.asyncio
@pytest.mark.parametrize("concurrent", [False, True])
async def test_original_forward_schema_fails_closed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, concurrent: bool) -> None:
engine = create_async_engine(_url(tmp_path, "original-forward.db"))
try:
await _seed_original_forward_schema(engine)
if concurrent:
await _set_database_revision(engine, "0018_oauth_identity_pg_partial")
def concurrent_upgrade(_cfg, _revision):
asyncio.run(_set_database_revision(engine, _FORWARD_COMPATIBLE_REVISION))
raise CommandError("revision advanced concurrently")
monkeypatch.setattr(bootstrap_mod, "_upgrade", concurrent_upgrade)
with pytest.raises(RuntimeError, match="missing.*projects.*threads_meta.project_id"):
await bootstrap_schema(engine, backend="sqlite")
assert await _database_revision(engine) == _FORWARD_COMPATIBLE_REVISION
async with engine.connect() as conn:
tables = await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names())
assert "projects" not in tables
finally:
await engine.dispose()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("ddl", "missing"),
[
("DROP TABLE projects", "projects"),
("ALTER TABLE projects DROP COLUMN instructions", "projects.instructions"),
("ALTER TABLE threads_meta DROP COLUMN project_id", "threads_meta.project_id"),
],
)
async def test_forward_revision_rejects_partial_project_schema(tmp_path: Path, ddl: str, missing: str) -> None:
engine = create_async_engine(_url(tmp_path, "partial-projects.db"))
try:
await _seed_head(engine)
await _add_forward_columns(engine)
async with engine.begin() as conn:
if "DROP COLUMN project_id" in ddl:
await conn.execute(sa.text("DROP INDEX ix_threads_meta_project_id"))
await conn.execute(sa.text(ddl))
await _set_database_revision(engine, _FORWARD_COMPATIBLE_REVISION)
with pytest.raises(RuntimeError, match=f"missing required local schema: {missing};"):
await bootstrap_schema(engine, backend="sqlite")
assert await _database_revision(engine) == _FORWARD_COMPATIBLE_REVISION
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_audited_original_forward_schema_can_upgrade_preserving_incarnations(tmp_path: Path) -> None:
engine = create_async_engine(_url(tmp_path, "forward-recovery.db"))
try:
await _seed_original_forward_schema(engine)
async with engine.begin() as conn:
await conn.execute(
sa.text("INSERT INTO threads_meta (thread_id, status, metadata_json, created_at, updated_at, incarnation) VALUES ('existing', 'idle', '{}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, :incarnation)"),
{"incarnation": "a" * 32},
)
# Documented offline operator recovery, only after verifying the exact
# 0018 + two nullable columns shape. Bootstrap never re-stamps an unknown DB.
await asyncio.to_thread(alembic_command.stamp, _get_alembic_config(engine), "0018_oauth_identity_pg_partial", purge=True)
await bootstrap_schema(engine, backend="sqlite")
assert await _database_revision(engine) == HEAD
repository = ThreadMetaRepository(async_sessionmaker(engine, expire_on_commit=False))
assert [row["thread_id"] for row in await repository.search(user_id=None)] == ["existing"]
assert (await repository.create("new", user_id=None))["thread_id"] == "new"
async with engine.connect() as conn:
assert (await conn.execute(sa.text("SELECT incarnation FROM threads_meta WHERE thread_id = 'existing'"))).scalar_one() == "a" * 32
columns = await conn.run_sync(lambda sync: sa.inspect(sync).get_columns("mcp_tasks"))
assert "thread_incarnation" in {column["name"] for column in columns}
finally:
await engine.dispose()
@pytest.mark.asyncio
async def test_known_older_revision_upgrades_normally(tmp_path: Path) -> None:
engine = create_async_engine(_url(tmp_path, "known.db"))

View File

@ -0,0 +1,84 @@
"""Tests for ProjectRepository (SQLAlchemy-backed)."""
import pytest
from deerflow.persistence.projects import ProjectRepository
@pytest.fixture
async def repo(tmp_path):
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
url = f"sqlite+aiosqlite:///{tmp_path / 'test.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
yield ProjectRepository(get_session_factory())
await close_engine()
class TestProjectRepository:
@pytest.mark.anyio
async def test_create_and_get(self, repo):
row = await repo.create(name="Infra overhaul", user_id="u1")
assert row["status"] == "active"
assert row["instructions"] == ""
fetched = await repo.get(row["id"], user_id="u1")
assert fetched is not None and fetched["name"] == "Infra overhaul"
@pytest.mark.anyio
async def test_get_is_fail_closed_for_foreign_user(self, repo):
row = await repo.create(name="p", user_id="u1")
assert await repo.get(row["id"], user_id="u2") is None
@pytest.mark.anyio
async def test_list_filters_status(self, repo):
a = await repo.create(name="a", user_id="u1")
b = await repo.create(name="b", user_id="u1")
await repo.set_status(b["id"], "archived", user_id="u1")
active = await repo.list(status="active", user_id="u1")
assert [r["id"] for r in active] == [a["id"]]
archived = await repo.list(status="archived", user_id="u1")
assert [r["id"] for r in archived] == [b["id"]]
# other users see nothing
assert await repo.list(user_id="u2") == []
@pytest.mark.anyio
async def test_patch_rename_and_instructions(self, repo):
row = await repo.create(name="old", user_id="u1")
updated = await repo.patch(row["id"], name="new", instructions="ctx", presentation={"icon": "folder"}, user_id="u1")
assert updated is not None
assert updated["name"] == "new" and updated["instructions"] == "ctx"
assert updated["presentation"] == {"icon": "folder"}
@pytest.mark.anyio
async def test_patch_foreign_is_none(self, repo):
row = await repo.create(name="p", user_id="u1")
assert await repo.patch(row["id"], name="x", user_id="u2") is None
@pytest.mark.anyio
async def test_set_status_is_idempotent(self, repo):
row = await repo.create(name="p", user_id="u1")
first = await repo.set_status(row["id"], "archived", user_id="u1")
second = await repo.set_status(row["id"], "archived", user_id="u1")
assert first is not None and second is not None
assert first["status"] == second["status"] == "archived"
@pytest.mark.anyio
async def test_delete_returns_false_for_missing_or_foreign(self, repo):
row = await repo.create(name="p", user_id="u1")
assert await repo.delete(row["id"], user_id="u2") is False
assert await repo.delete("nope", user_id="u1") is False
assert await repo.delete(row["id"], user_id="u1") is True
assert await repo.get(row["id"], user_id="u1") is None
@pytest.mark.anyio
async def test_delete_clears_membership_and_keeps_thread(self, repo, tmp_path):
from deerflow.persistence.thread_meta import ThreadMetaRepository
threads = ThreadMetaRepository(repo._sf)
p = await repo.create(name="P", user_id="u1")
await threads.create("t1", user_id="u1", project_id=p["id"])
assert await repo.delete(p["id"], user_id="u1") is True
record = await threads.get("t1", user_id="u1")
assert record is not None # thread row intact
assert "deerflow_project_id" not in record["metadata"]

View File

@ -0,0 +1,355 @@
"""Router tests for the projects CRUD API (Phase 1).
Harness mirrors ``test_channel_connections_router.py`` (real SQLAlchemy repo
on a temp sqlite engine + TestClient) and ``_router_auth_helpers`` (stub auth
middleware), extended to also set the request-scoped user ContextVar that
``ProjectRepository`` / ``ThreadMetaRepository`` resolve ownership from, and
to take the user id from a header so cross-user isolation can be exercised.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import anyio
import pytest
from fastapi import FastAPI, Request, Response
from fastapi.testclient import TestClient
from starlette.middleware.base import BaseHTTPMiddleware
from app.gateway.authz import AuthContext, Permissions
from app.gateway.routers import projects
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.persistence.projects import ProjectRepository
from deerflow.persistence.thread_meta import THREAD_ARCHIVED_METADATA_KEY, THREAD_PROJECT_METADATA_KEY, ThreadMetaRepository
from deerflow.runtime.user_context import reset_current_user, set_current_user
_STUB_PERMISSIONS: list[str] = [
Permissions.THREADS_READ,
Permissions.THREADS_WRITE,
Permissions.THREADS_DELETE,
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.RUNS_CANCEL,
Permissions.PROJECTS_READ,
Permissions.PROJECTS_WRITE,
Permissions.PROJECTS_DELETE,
]
_USER_HEADER = "x-test-user"
_PERMISSIONS_HEADER = "x-test-permissions"
class _StubAuthMiddleware(BaseHTTPMiddleware):
"""Stamp a fake AuthContext and set the user ContextVar per request.
Mirrors production ``AuthMiddleware`` (``request.state.auth`` +
``set_current_user``) so ``@require_permission`` and the
ContextVar-resolving repositories behave as in the real gateway.
The user id comes from the ``x-test-user`` header (default ``user-a``) so a
single app can drive multiple identities. The granted permissions come from
the ``x-test-permissions`` header (comma-separated; default the full stub
list) so scope-narrowed callers can be exercised.
"""
async def dispatch(self, request: Request, call_next: Any) -> Response:
user_id = request.headers.get(_USER_HEADER, "user-a")
user = SimpleNamespace(id=user_id, system_role="user")
request.state.user = user
permissions_header = request.headers.get(_PERMISSIONS_HEADER)
permissions = permissions_header.split(",") if permissions_header else list(_STUB_PERMISSIONS)
request.state.auth = AuthContext(user=user, permissions=permissions)
token = set_current_user(user)
try:
return await call_next(request)
finally:
reset_current_user(token)
async def _init_db(tmp_path) -> None:
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'projects.db'}", sqlite_dir=str(tmp_path))
def _build_projects_app(tmp_path, *, project_repo: Any = "default") -> FastAPI:
"""Build a stub-authed FastAPI app with real SQL project/thread repos."""
anyio.run(_init_db, tmp_path)
sf = get_session_factory()
app = FastAPI()
app.add_middleware(_StubAuthMiddleware)
app.state.project_repo = ProjectRepository(sf) if project_repo == "default" else project_repo
app.state.thread_store = ThreadMetaRepository(sf)
app.include_router(projects.router)
return app
def _as_user(user_id: str) -> dict[str, str]:
return {_USER_HEADER: user_id}
def _seed_thread(app: FastAPI, thread_id: str, *, user_id: str, project_id: str | None = None, metadata: dict | None = None) -> dict:
"""Create a thread row directly through the store as ``user_id``."""
async def _run() -> dict:
token = set_current_user(SimpleNamespace(id=user_id))
try:
return await app.state.thread_store.create(thread_id, project_id=project_id, metadata=metadata)
finally:
reset_current_user(token)
return anyio.run(_run)
def _search_threads(app: FastAPI, *, user_id: str, **kwargs: Any) -> list[dict]:
async def _run() -> list[dict]:
token = set_current_user(SimpleNamespace(id=user_id))
try:
return await app.state.thread_store.search(**kwargs)
finally:
reset_current_user(token)
return anyio.run(_run)
@pytest.fixture(autouse=True)
def _close_engine_after_test():
yield
anyio.run(close_engine)
def test_create_list_get_project(tmp_path):
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
created = client.post("/api/projects", json={"name": "Infra overhaul", "instructions": "ctx"})
assert created.status_code == 201, created.text
project = created.json()
assert project["name"] == "Infra overhaul"
assert project["instructions"] == "ctx"
assert project["status"] == "active"
assert project["presentation"] == {}
listing = client.get("/api/projects")
assert listing.status_code == 200
assert [p["id"] for p in listing.json()["projects"]] == [project["id"]]
fetched = client.get(f"/api/projects/{project['id']}")
assert fetched.status_code == 200
assert fetched.json()["id"] == project["id"]
def test_get_patch_delete_foreign_project_returns_404(tmp_path):
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
project = client.post("/api/projects", json={"name": "mine"}).json()
# Fail closed: a foreign project is indistinguishable from a missing one.
assert client.get(f"/api/projects/{project['id']}", headers=_as_user("user-b")).status_code == 404
assert client.patch(f"/api/projects/{project['id']}", json={"name": "x"}, headers=_as_user("user-b")).status_code == 404
assert client.delete(f"/api/projects/{project['id']}", headers=_as_user("user-b")).status_code == 404
# Owner is unaffected.
assert client.get(f"/api/projects/{project['id']}").status_code == 200
assert client.get("/api/projects", headers=_as_user("user-b")).json()["projects"] == []
def test_patch_rename_does_not_touch_membership(tmp_path):
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
project = client.post("/api/projects", json={"name": "old"}).json()
_seed_thread(app, "thread-1", user_id="user-a", project_id=project["id"])
patched = client.patch(f"/api/projects/{project['id']}", json={"name": "new"})
assert patched.status_code == 200
assert patched.json()["name"] == "new"
members = _search_threads(app, user_id="user-a", project_id=project["id"])
assert [t["thread_id"] for t in members] == ["thread-1"]
def test_archive_restore_idempotent(tmp_path):
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
project = client.post("/api/projects", json={"name": "p"}).json()
pid = project["id"]
for _ in range(2):
archived = client.post(f"/api/projects/{pid}/archive")
assert archived.status_code == 200
assert archived.json()["status"] == "archived"
assert client.get("/api/projects", params={"status": "active"}).json()["projects"] == []
assert [p["id"] for p in client.get("/api/projects", params={"status": "archived"}).json()["projects"]] == [pid]
for _ in range(2):
restored = client.post(f"/api/projects/{pid}/restore")
assert restored.status_code == 200
assert restored.json()["status"] == "active"
assert [p["id"] for p in client.get("/api/projects").json()["projects"]] == [pid]
def test_delete_unlinks_threads_and_keeps_them(tmp_path):
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
project = client.post("/api/projects", json={"name": "p"}).json()
pid = project["id"]
_seed_thread(app, "thread-1", user_id="user-a", project_id=pid)
deleted = client.delete(f"/api/projects/{pid}")
assert deleted.status_code == 204
assert client.get(f"/api/projects/{pid}").status_code == 404
# Thread row survives with membership cleared.
threads = _search_threads(app, user_id="user-a")
assert [t["thread_id"] for t in threads] == ["thread-1"]
assert THREAD_PROJECT_METADATA_KEY not in threads[0]["metadata"]
assert _search_threads(app, user_id="user-a", project_id=pid) == []
def test_project_threads_lists_members_only(tmp_path):
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
p1 = client.post("/api/projects", json={"name": "p1"}).json()
p2 = client.post("/api/projects", json={"name": "p2"}).json()
_seed_thread(app, "thread-p1", user_id="user-a", project_id=p1["id"])
_seed_thread(app, "thread-p2", user_id="user-a", project_id=p2["id"])
_seed_thread(app, "thread-loose", user_id="user-a")
response = client.get(f"/api/projects/{p1['id']}/threads")
assert response.status_code == 200
assert [t["thread_id"] for t in response.json()] == ["thread-p1"]
# Unknown project -> 404, not an empty list.
assert client.get("/api/projects/nope/threads").status_code == 404
@pytest.mark.parametrize(
"params",
[
{"limit": -1},
{"limit": 0},
{"limit": 1001},
{"offset": -1},
],
)
def test_project_threads_rejects_out_of_bounds_pagination(tmp_path, params):
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
project = client.post("/api/projects", json={"name": "p"}).json()
response = client.get(f"/api/projects/{project['id']}/threads", params=params)
assert response.status_code == 422
@pytest.mark.parametrize("params", [{"limit": 1, "offset": 0}, {"limit": 1000}])
def test_project_threads_accepts_boundary_pagination(tmp_path, params):
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
project = client.post("/api/projects", json={"name": "p"}).json()
_seed_thread(app, "thread-1", user_id="user-a", project_id=project["id"])
response = client.get(f"/api/projects/{project['id']}/threads", params=params)
assert response.status_code == 200
assert [t["thread_id"] for t in response.json()] == ["thread-1"]
def test_project_threads_requires_threads_read(tmp_path):
"""The listing returns thread records (titles/metadata), so it must require
``threads:read`` alongside ``projects:read`` matching
``/api/threads/search``. A caller holding only ``projects:read`` (e.g. a
scoped PAT) must be denied."""
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
project = client.post("/api/projects", json={"name": "p"}).json()
pid = project["id"]
_seed_thread(app, "thread-1", user_id="user-a", project_id=pid)
without_threads_read = {
**_as_user("user-a"),
_PERMISSIONS_HEADER: ",".join(p for p in _STUB_PERMISSIONS if p != Permissions.THREADS_READ),
}
denied = client.get(f"/api/projects/{pid}/threads", headers=without_threads_read)
assert denied.status_code == 403
allowed = client.get(f"/api/projects/{pid}/threads", headers=_as_user("user-a"))
assert allowed.status_code == 200
assert [t["thread_id"] for t in allowed.json()] == ["thread-1"]
def test_project_threads_redacts_legacy_auth_token(tmp_path):
"""Legacy ``auth_token`` metadata must not leak through the project thread
listing thread endpoints already redact it via
``_MetadataRedactingResponse``; this listing must match."""
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
project = client.post("/api/projects", json={"name": "p"}).json()
pid = project["id"]
_seed_thread(app, "thread-1", user_id="user-a", project_id=pid, metadata={"auth_token": "sk-legacy-secret", "keep": "x"})
response = client.get(f"/api/projects/{pid}/threads")
assert response.status_code == 200
metadata = response.json()[0]["metadata"]
assert "auth_token" not in metadata
assert metadata["keep"] == "x"
assert metadata[THREAD_PROJECT_METADATA_KEY] == pid
# Redaction is response-side only; the stored row keeps the legacy key.
stored = _search_threads(app, user_id="user-a", project_id=pid)
assert stored[0]["metadata"]["auth_token"] == "sk-legacy-secret"
def test_project_threads_metadata_unchanged_without_legacy_key(tmp_path):
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
project = client.post("/api/projects", json={"name": "p"}).json()
pid = project["id"]
_seed_thread(app, "thread-1", user_id="user-a", project_id=pid, metadata={"keep": "x"})
response = client.get(f"/api/projects/{pid}/threads")
assert response.status_code == 200
assert response.json()[0]["metadata"] == {"keep": "x", THREAD_PROJECT_METADATA_KEY: pid}
def test_memory_backend_unavailable(tmp_path):
app = _build_projects_app(tmp_path, project_repo=None)
with TestClient(app) as client:
assert client.get("/api/projects").status_code == 503
assert client.post("/api/projects", json={"name": "p"}).status_code == 503
def test_project_threads_wire_shape_is_narrow(tmp_path):
"""The listing must not leak store-row internals: ownership columns
(``user_id``/``assistant_id``) and any future ``ThreadMetaRow`` column
stay off the wire, and the OpenAPI schema is no longer empty. The model
pins exactly the fields ``ProjectThread`` declares."""
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
project = client.post("/api/projects", json={"name": "p"}).json()
pid = project["id"]
_seed_thread(app, "thread-1", user_id="user-a", project_id=pid, metadata={"keep": "x"})
response = client.get(f"/api/projects/{pid}/threads")
assert response.status_code == 200
rows = response.json()
assert len(rows) == 1
row = rows[0]
assert set(row) == {"thread_id", "display_name", "created_at", "updated_at", "metadata"}
assert row["thread_id"] == "thread-1"
assert row["metadata"] == {"keep": "x", THREAD_PROJECT_METADATA_KEY: pid}
def test_project_threads_excludes_archived_members(tmp_path):
"""Archived chats leave the project listing the same way they leave the
sidebar (``archived: false`` semantics): no silent normal-row rendering
of a retired chat on the project page. The store keeps the row; restore
flows through the global Archived tab as elsewhere."""
app = _build_projects_app(tmp_path)
with TestClient(app) as client:
project = client.post("/api/projects", json={"name": "p"}).json()
pid = project["id"]
_seed_thread(app, "active-1", user_id="user-a", project_id=pid)
_seed_thread(app, "archived-1", user_id="user-a", project_id=pid, metadata={THREAD_ARCHIVED_METADATA_KEY: True})
response = client.get(f"/api/projects/{pid}/threads")
assert response.status_code == 200
assert [t["thread_id"] for t in response.json()] == ["active-1"]
# The archived row still exists in the store (unfiltered search).
stored = _search_threads(app, user_id="user-a", project_id=pid)
assert {t["thread_id"] for t in stored} == {"active-1", "archived-1"}

View File

@ -468,6 +468,204 @@ class TestThreadMetaRepository:
hits = {r["thread_id"] for r in await repo.search(metadata={"id": large})}
assert hits == {"t1"}
@pytest.mark.anyio
async def test_membership_exposed_via_reserved_metadata_key(self, repo):
from deerflow.persistence.thread_meta import THREAD_PROJECT_METADATA_KEY
record = await repo.create("t1", user_id="u1")
assert THREAD_PROJECT_METADATA_KEY not in record["metadata"]
# membership is set in Task 4; the raw column must never leak top-level
assert "project_id" not in record
@pytest.mark.anyio
async def test_row_to_dict_does_not_leak_reserved_key_into_stored_metadata(self, repo):
"""Regression (Task 3 review): ``_row_to_dict`` must copy ``metadata_json``
before injecting the reserved project key. Without the copy the injected
dict IS the ORM row's ``metadata_json`` object, so reading a member
thread mutates the row in place and a later update in the same session
persists ``deerflow_project_id`` into stored user metadata."""
from deerflow.persistence.projects import ProjectRepository
from deerflow.persistence.thread_meta.model import ThreadMetaRow
projects = ProjectRepository(repo._sf)
p = await projects.create(name="P", user_id="u1")
await repo.create("t1", user_id="u1", metadata={"keep": 1})
# Assign membership directly on the row (store-level assignment is Task 4).
async with repo._sf() as session:
row = await session.get(ThreadMetaRow, "t1")
row.project_id = p["id"]
await session.commit()
record = await repo.get("t1", user_id="u1")
assert record["metadata"]["deerflow_project_id"] == p["id"]
# The review's failure mode is same-session: converting a row for read
# must not dirty the ORM row's stored dict, or a later update in that
# session persists the reserved key. ``repo.get`` never exposes its
# session, so replicate its conversion on a row from this session.
async with repo._sf() as session:
row = await session.get(ThreadMetaRow, "t1")
repo._row_to_dict(row) # same conversion repo.get performs
assert "deerflow_project_id" not in row.metadata_json
await repo.update_metadata("t1", {"new": 2}, user_id="u1")
async with repo._sf() as session:
row = await session.get(ThreadMetaRow, "t1")
assert "deerflow_project_id" not in row.metadata_json
assert row.metadata_json["keep"] == 1
assert row.metadata_json["new"] == 2
@pytest.mark.anyio
async def test_set_project_moves_and_preserves_updated_at(self, repo):
from deerflow.persistence.projects import ProjectRepository
projects = ProjectRepository(repo._sf)
p = await projects.create(name="P", user_id="u1")
await repo.create("t1", user_id="u1")
before = (await repo.get("t1", user_id="u1"))["updated_at"]
assert await repo.set_project("t1", p["id"], user_id="u1") is True
record = await repo.get("t1", user_id="u1")
assert record["metadata"]["deerflow_project_id"] == p["id"]
assert record["updated_at"] == before # G5: move must not bump recency
# move out
assert await repo.set_project("t1", None, user_id="u1") is True
assert "deerflow_project_id" not in (await repo.get("t1", user_id="u1"))["metadata"]
@pytest.mark.anyio
async def test_set_project_rejects_foreign_thread_foreign_project_archived(self, repo):
from deerflow.persistence.projects import ProjectRepository
projects = ProjectRepository(repo._sf)
mine = await projects.create(name="mine", user_id="u1")
await projects.create(name="theirs", user_id="u2") # a foreign-owned project exists
archived = await projects.create(name="arch", user_id="u1")
await projects.set_status(archived["id"], "archived", user_id="u1")
await repo.create("t1", user_id="u1")
await repo.create("t2", user_id="u2")
assert await repo.set_project("t1", mine["id"], user_id="u2") is False # foreign thread
assert await repo.set_project("t2", mine["id"], user_id="u2") is False # foreign project
assert await repo.set_project("t1", archived["id"], user_id="u1") is False # archived
assert await repo.set_project("t1", "missing", user_id="u1") is False # missing
assert (await repo.get("t1", user_id="u1"))["metadata"].get("deerflow_project_id") is None
@pytest.mark.anyio
async def test_run_admission_never_seeds_project_membership(self, repo):
"""Negative contract: run admission never writes thread→project membership.
A run admitted with the reserved ``deerflow_project_id`` metadata key
must leave the row's ``project_id`` column NULL, and the key must not
persist into ``metadata_json`` either membership is written only by
POST /api/threads (create) and /threads/{id}/move.
"""
from app.gateway.services import _ensure_thread_metadata
from deerflow.persistence.projects import ProjectRepository
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.runtime.runs.manager import RunRecord
from deerflow.runtime.runs.schemas import DisconnectMode, RunStatus
from deerflow.runtime.runs.worker import RunContext
projects = ProjectRepository(repo._sf)
project = await projects.create(name="P")
record = RunRecord(run_id="run-1", thread_id="t1", assistant_id="lead-agent", status=RunStatus.pending, on_disconnect=DisconnectMode.cancel, metadata={"deerflow_project_id": project["id"]})
run_ctx = RunContext(checkpointer=None, thread_store=repo)
await _ensure_thread_metadata(run_ctx, record, owner_user_id=None)
async with repo._sf() as session:
row = await session.get(ThreadMetaRow, "t1")
assert row is not None and row.project_id is None
assert "deerflow_project_id" not in row.metadata_json
@pytest.mark.anyio
async def test_create_with_project_assignment_and_rejection(self, repo):
from deerflow.persistence.projects import ProjectNotAssignableError, ProjectRepository
projects = ProjectRepository(repo._sf)
p = await projects.create(name="P", user_id="u1")
record = await repo.create("t1", user_id="u1", project_id=p["id"])
assert record["metadata"]["deerflow_project_id"] == p["id"]
with pytest.raises(ProjectNotAssignableError):
await repo.create("t2", user_id="u1", project_id="missing")
assert await repo.get("t2", user_id="u1") is None # no partial row
@pytest.mark.anyio
async def test_search_project_filter_three_states(self, repo):
from deerflow.persistence.projects import ProjectRepository
from deerflow.persistence.thread_meta.base import PROJECT_FILTER_UNSET
projects = ProjectRepository(repo._sf)
p = await projects.create(name="P", user_id="u1")
await repo.create("t1", user_id="u1", project_id=p["id"])
await repo.create("t2", user_id="u1")
all_rows = await repo.search(user_id="u1", project_id=PROJECT_FILTER_UNSET)
assert {r["thread_id"] for r in all_rows} == {"t1", "t2"}
unassigned = await repo.search(user_id="u1", project_id=None)
assert [r["thread_id"] for r in unassigned] == ["t2"]
in_project = await repo.search(user_id="u1", project_id=p["id"])
assert [r["thread_id"] for r in in_project] == ["t1"]
@pytest.mark.anyio
async def test_concurrent_move_vs_project_delete_never_dangles(self, repo):
"""§5.2 race: move-vs-delete resolves to cleared membership or rejection."""
import asyncio
from deerflow.persistence.projects import ProjectRepository
projects = ProjectRepository(repo._sf)
for i in range(10):
p = await projects.create(name=f"P{i}", user_id="u1")
tid = f"trace-{i}"
await repo.create(tid, user_id="u1")
moved, deleted = await asyncio.gather(
repo.set_project(tid, p["id"], user_id="u1"),
projects.delete(p["id"], user_id="u1"),
)
record = await repo.get(tid, user_id="u1")
membership = record["metadata"].get("deerflow_project_id")
if moved and not deleted:
# delete lost the race before our read: membership may still be
# set only if the project row still exists
assert membership is None or await projects.get(membership, user_id="u1") is not None
else:
assert membership is None
@pytest.mark.anyio
async def test_concurrent_create_vs_project_delete_never_dangles(self, repo):
"""§14.14 race: create-with-assignment vs delete resolves to a
rejected create or cleared membership never a thread whose
metadata references a deleted project."""
import asyncio
from deerflow.persistence.projects import ProjectNotAssignableError, ProjectRepository
projects = ProjectRepository(repo._sf)
async def create_in_project(tid: str, project_id: str) -> bool:
try:
await repo.create(tid, user_id="u1", project_id=project_id)
except ProjectNotAssignableError:
return False
return True
for i in range(10):
p = await projects.create(name=f"P{i}", user_id="u1")
tid = f"trace-{i}"
await asyncio.gather(
create_in_project(tid, p["id"]),
projects.delete(p["id"], user_id="u1"),
)
record = await repo.get(tid, user_id="u1")
if record is not None:
membership = record["metadata"].get("deerflow_project_id")
if membership is not None:
# A carried key is only valid while the project row exists.
assert await projects.get(membership, user_id="u1") is not None
class TestJsonMatchCompilation:
"""Verify compiled SQL for both SQLite and PostgreSQL dialects."""

View File

@ -4,6 +4,7 @@ from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import anyio
import pytest
from _router_auth_helpers import make_authed_test_app
from fastapi import FastAPI, HTTPException
@ -17,10 +18,19 @@ 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 THREAD_PINNED_METADATA_KEY, InvalidMetadataFilterError
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
from deerflow.persistence.projects import ProjectRepository
from deerflow.persistence.thread_meta import (
PROJECT_FILTER_UNSET,
THREAD_PINNED_METADATA_KEY,
THREAD_PROJECT_METADATA_KEY,
InvalidMetadataFilterError,
ThreadMetaRepository,
)
from deerflow.persistence.thread_meta.memory import THREADS_NS, MemoryThreadMetaStore
from deerflow.runtime import ConflictError, ThreadOperationKind
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor
from deerflow.runtime.user_context import reset_current_user, set_current_user
_ISO_TIMESTAMP_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
@ -47,11 +57,11 @@ class _PermissiveThreadMetaStore(MemoryThreadMetaStore):
return not require_existing
return True
async def create(self, thread_id, *, assistant_id=None, user_id=None, display_name=None, metadata=None): # type: ignore[override]
return await super().create(thread_id, assistant_id=assistant_id, user_id=None, display_name=display_name, metadata=metadata)
async def create(self, thread_id, *, assistant_id=None, user_id=None, display_name=None, metadata=None, project_id=None): # type: ignore[override]
return await super().create(thread_id, assistant_id=assistant_id, user_id=None, display_name=display_name, metadata=metadata, project_id=project_id)
async def search(self, *, metadata=None, status=None, limit=100, offset=0, user_id=None, archived=None): # type: ignore[override]
return await super().search(metadata=metadata, status=status, limit=limit, offset=offset, user_id=None, archived=archived)
async def search(self, *, metadata=None, status=None, limit=100, offset=0, user_id=None, archived=None, project_id=PROJECT_FILTER_UNSET): # type: ignore[override]
return await super().search(metadata=metadata, status=status, limit=limit, offset=offset, user_id=None, archived=archived, project_id=project_id)
class _ThreadTestRunManager:
@ -692,7 +702,7 @@ def test_create_thread_returns_existing_when_insert_loses_race() -> None:
super().__init__(backing)
self._raised = False
async def create(self, thread_id, *, assistant_id=None, user_id=None, display_name=None, metadata=None): # type: ignore[override]
async def create(self, thread_id, *, assistant_id=None, user_id=None, display_name=None, metadata=None, project_id=None): # type: ignore[override]
if not self._raised:
self._raised = True
await super().create(
@ -701,6 +711,7 @@ def test_create_thread_returns_existing_when_insert_loses_race() -> None:
user_id=user_id,
display_name=display_name,
metadata=metadata,
project_id=project_id,
)
raise IntegrityError(
"INSERT INTO threads_meta",
@ -713,6 +724,7 @@ def test_create_thread_returns_existing_when_insert_loses_race() -> None:
user_id=user_id,
display_name=display_name,
metadata=metadata,
project_id=project_id,
)
app.state.thread_store = _RacingThreadMetaStore(store)
@ -752,10 +764,10 @@ def test_insert_race_recovery_claims_unscoped_row_for_trusted_owner() -> None:
"""Our insert loses to a competing create that already wrote an
unscoped row, exactly the interleaving the recovery path exists for."""
async def create(self, thread_id, *, assistant_id=None, user_id=None, display_name=None, metadata=None): # type: ignore[override]
async def create(self, thread_id, *, assistant_id=None, user_id=None, display_name=None, metadata=None, project_id=None): # type: ignore[override]
# The competing request commits its (owner-less) row here, then our
# insert loses the primary-key race.
await super().create(thread_id, user_id=None, metadata=metadata)
await super().create(thread_id, user_id=None, metadata=metadata, project_id=project_id)
raise IntegrityError(
"INSERT INTO threads_meta",
{},
@ -3934,3 +3946,328 @@ def test_archive_patch_cannot_modify_another_users_thread():
response = client.patch("/api/threads/private", json={"metadata": {"deerflow_archived": True}})
assert response.status_code == 404
assert asyncio.run(store.aget(THREADS_NS, "private")).value["metadata"] == {}
# ---------------------------------------------------------------------------
# Project membership surface (Phase 1): create/search/move + reserved key
# ---------------------------------------------------------------------------
#
# The memory harness above cannot exercise project membership (the memory
# store ignores it by design), so these tests build a stub-authed app on real
# SQL repos — same harness shape as ``test_projects_router.py``.
from test_projects_router import _StubAuthMiddleware # noqa: E402
async def _init_threads_db(tmp_path) -> None:
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'threads.db'}", sqlite_dir=str(tmp_path))
def _build_project_threads_app(tmp_path) -> FastAPI:
"""Stub-authed app with real SQL thread/project repos."""
anyio.run(_init_threads_db, tmp_path)
session_factory = get_session_factory()
app = FastAPI()
app.add_middleware(_StubAuthMiddleware)
app.state.thread_store = ThreadMetaRepository(session_factory)
app.state.project_repo = ProjectRepository(session_factory)
app.state.checkpointer = InMemorySaver()
app.state.run_manager = _ThreadTestRunManager()
app.include_router(threads.router)
return app
def _create_project(app: FastAPI, *, user_id: str = "user-a", name: str = "Project") -> dict:
async def _run() -> dict:
token = set_current_user(SimpleNamespace(id=user_id))
try:
return await app.state.project_repo.create(name=name)
finally:
reset_current_user(token)
return anyio.run(_run)
def _archive_project(app: FastAPI, project_id: str, *, user_id: str = "user-a") -> None:
async def _run() -> None:
token = set_current_user(SimpleNamespace(id=user_id))
try:
await app.state.project_repo.set_status(project_id, "archived")
finally:
reset_current_user(token)
anyio.run(_run)
@pytest.fixture(autouse=True)
def _close_sql_engine_after_test():
yield
anyio.run(close_engine)
def test_create_thread_with_project_assigns(tmp_path):
app = _build_project_threads_app(tmp_path)
project = _create_project(app)
with TestClient(app) as client:
created = client.post("/api/threads", json={"project_id": project["id"]})
assert created.status_code == 200, created.text
thread_id = created.json()["thread_id"]
fetched = client.get(f"/api/threads/{thread_id}")
assert fetched.json()["metadata"][THREAD_PROJECT_METADATA_KEY] == project["id"]
hits = client.post("/api/threads/search", json={"project_id": project["id"]}).json()
assert [h["thread_id"] for h in hits] == [thread_id]
def test_create_thread_response_includes_persisted_project_membership(tmp_path):
"""The create response must echo the persisted record, not body.metadata.
The store stamps ``metadata.deerflow_project_id`` from the assigned
``project_id`` column; a response built from ``body.metadata`` omits it
and disagrees with the idempotent-retry response for the same thread.
"""
app = _build_project_threads_app(tmp_path)
project = _create_project(app)
with TestClient(app) as client:
created = client.post("/api/threads", json={"project_id": project["id"]})
assert created.status_code == 200, created.text
assert created.json()["metadata"][THREAD_PROJECT_METADATA_KEY] == project["id"]
retry = client.post("/api/threads", json={"thread_id": created.json()["thread_id"], "project_id": project["id"]})
assert retry.status_code == 200, retry.text
assert retry.json() == created.json()
def test_create_thread_response_without_project_has_no_membership_key(tmp_path):
"""Regression guard: no project_id → the key must not appear in the response."""
app = _build_project_threads_app(tmp_path)
with TestClient(app) as client:
created = client.post("/api/threads", json={"metadata": {"keep": "v"}})
assert created.status_code == 200, created.text
assert created.json()["metadata"] == {"keep": "v"}
def test_create_thread_with_missing_or_foreign_project_404(tmp_path):
app = _build_project_threads_app(tmp_path)
foreign = _create_project(app, user_id="user-b", name="Foreign")
with TestClient(app) as client:
missing = client.post("/api/threads", json={"thread_id": "thread-missing-proj", "project_id": "no-such-project"})
assert missing.status_code == 404, missing.text
assert missing.json()["detail"] == "Project not found"
foreign_resp = client.post("/api/threads", json={"thread_id": "thread-foreign-proj", "project_id": foreign["id"]})
assert foreign_resp.status_code == 404, foreign_resp.text
def test_create_thread_with_project_in_memory_mode_404():
"""Memory mode has no projects backend: a project-scoped create must fail
closed with the same 404 the SQL store produces for a missing project
not silently persist an unassigned thread whose run would then proceed
outside the selected project (``ensureProjectThread`` keeps the composer
text for a retry on this failure)."""
app, _, _ = _build_thread_app()
with TestClient(app) as client:
created = client.post("/api/threads", json={"thread_id": "thread-mem-proj", "project_id": "p1"})
assert created.status_code == 404, created.text
assert created.json()["detail"] == "Project not found"
# The store's project filter fails closed too; no row was persisted.
hits = client.post("/api/threads/search", json={"project_id": "p1"}).json()
assert hits == []
# Unscoped creates still work in memory mode.
plain = client.post("/api/threads", json={"thread_id": "thread-mem-plain"})
assert plain.status_code == 200, plain.text
def test_create_and_patch_strip_deerflow_project_id_metadata_key(tmp_path):
app = _build_project_threads_app(tmp_path)
with TestClient(app) as client:
created = client.post("/api/threads", json={"metadata": {THREAD_PROJECT_METADATA_KEY: "forged", "keep": "v"}})
assert created.status_code == 200, created.text
thread_id = created.json()["thread_id"]
assert created.json()["metadata"] == {"keep": "v"}
fetched = client.get(f"/api/threads/{thread_id}")
assert fetched.json()["metadata"] == {"keep": "v"}
patched = client.patch(f"/api/threads/{thread_id}", json={"metadata": {THREAD_PROJECT_METADATA_KEY: "forged-2"}})
assert patched.status_code == 200, patched.text
assert THREAD_PROJECT_METADATA_KEY not in patched.json()["metadata"]
def test_search_threads_project_filter_absent_null_value(tmp_path):
app = _build_project_threads_app(tmp_path)
project = _create_project(app)
with TestClient(app) as client:
in_project = client.post("/api/threads", json={"project_id": project["id"]}).json()["thread_id"]
unassigned = client.post("/api/threads", json={}).json()["thread_id"]
all_hits = {t["thread_id"] for t in client.post("/api/threads/search", json={}).json()}
assert all_hits == {in_project, unassigned}
only_project = {t["thread_id"] for t in client.post("/api/threads/search", json={"project_id": project["id"]}).json()}
assert only_project == {in_project}
only_unassigned = {t["thread_id"] for t in client.post("/api/threads/search", json={"project_id": None}).json()}
assert only_unassigned == {unassigned}
def test_move_thread_to_project_and_out(tmp_path):
app = _build_project_threads_app(tmp_path)
project = _create_project(app)
with TestClient(app) as client:
thread_id = client.post("/api/threads", json={}).json()["thread_id"]
moved = client.post(f"/api/threads/{thread_id}/move", json={"project_id": project["id"]})
assert moved.status_code == 200, moved.text
assert moved.json()["metadata"][THREAD_PROJECT_METADATA_KEY] == project["id"]
out = client.post(f"/api/threads/{thread_id}/move", json={"project_id": None})
assert out.status_code == 200, out.text
assert THREAD_PROJECT_METADATA_KEY not in out.json()["metadata"]
# The key is required-but-nullable: omitting it is a 422.
missing_key = client.post(f"/api/threads/{thread_id}/move", json={})
assert missing_key.status_code == 422, missing_key.text
def test_move_thread_to_archived_or_foreign_project_404(tmp_path):
app = _build_project_threads_app(tmp_path)
archived = _create_project(app, name="Archived")
foreign = _create_project(app, user_id="user-b", name="Foreign")
_archive_project(app, archived["id"])
with TestClient(app) as client:
thread_id = client.post("/api/threads", json={}).json()["thread_id"]
to_archived = client.post(f"/api/threads/{thread_id}/move", json={"project_id": archived["id"]})
assert to_archived.status_code == 404, to_archived.text
to_foreign = client.post(f"/api/threads/{thread_id}/move", json={"project_id": foreign["id"]})
assert to_foreign.status_code == 404, to_foreign.text
def test_move_thread_does_not_bump_updated_at(tmp_path):
app = _build_project_threads_app(tmp_path)
project = _create_project(app)
with TestClient(app) as client:
thread_id = client.post("/api/threads", json={}).json()["thread_id"]
before = client.get(f"/api/threads/{thread_id}").json()["updated_at"]
moved = client.post(f"/api/threads/{thread_id}/move", json={"project_id": project["id"]})
assert moved.status_code == 200, moved.text
assert moved.json()["updated_at"] == before
after = client.get(f"/api/threads/{thread_id}").json()["updated_at"]
assert after == before
def _seed_branchable_thread(app: FastAPI, thread_id: str) -> None:
"""Write a three-turn conversation so a middle AI turn can be branched."""
human_1 = HumanMessage(id="human-1", content="First question")
ai_1 = AIMessage(id="ai-1", content="First answer")
human_2 = HumanMessage(id="human-2", content="Second question")
async def _seed(parent_config: dict) -> None:
after_human_1 = await _write_checkpoint(
app.state.checkpointer,
thread_id,
str(uuid6()),
[human_1],
step=1,
parent_config=parent_config,
)
after_ai_1 = await _write_checkpoint(
app.state.checkpointer,
thread_id,
str(uuid6()),
[human_1, ai_1],
step=2,
parent_config=after_human_1,
)
await _write_checkpoint(
app.state.checkpointer,
thread_id,
str(uuid6()),
[human_1, ai_1, human_2],
step=3,
parent_config=after_ai_1,
)
initial = asyncio.run(app.state.checkpointer.aget_tuple({"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}))
assert initial is not None
asyncio.run(_seed(initial.config))
def test_branch_inherits_source_project_membership(tmp_path):
"""A branch of a project thread stays in the source thread's project.
Branching writes a new thread_meta row; without inheritance it is
unassigned and the sidebar surfaces it under Recent chats instead of the
source thread's project group.
"""
app = _build_project_threads_app(tmp_path)
project = _create_project(app)
source_thread_id = "source-project-branch"
with TestClient(app) as client:
created = client.post(
"/api/threads",
json={"thread_id": source_thread_id, "project_id": project["id"]},
)
assert created.status_code == 200, created.text
_seed_branchable_thread(app, source_thread_id)
branch = client.post(
f"/api/threads/{source_thread_id}/branches",
json={"message_id": "ai-1", "message_ids": ["ai-1"]},
)
assert branch.status_code == 200, branch.text
branch_id = branch.json()["thread_id"]
assert branch.json()["parent_thread_id"] == source_thread_id
fetched = client.get(f"/api/threads/{branch_id}")
assert fetched.status_code == 200, fetched.text
assert fetched.json()["metadata"][THREAD_PROJECT_METADATA_KEY] == project["id"]
hits = client.post("/api/threads/search", json={"project_id": project["id"]}).json()
assert {h["thread_id"] for h in hits} == {source_thread_id, branch_id}
unassigned = client.post("/api/threads/search", json={"project_id": None}).json()
assert [h["thread_id"] for h in unassigned] == []
def test_branch_from_archived_project_thread_degrades_to_unassigned(tmp_path):
"""Branching stays available when the source project was archived meanwhile.
The branch inherits through the same validated create path as assignment;
an archived project is no longer assignable, so the branch row is created
unassigned (pre-inheritance behavior) instead of failing the request.
"""
app = _build_project_threads_app(tmp_path)
project = _create_project(app)
source_thread_id = "source-archived-branch"
with TestClient(app) as client:
created = client.post(
"/api/threads",
json={"thread_id": source_thread_id, "project_id": project["id"]},
)
assert created.status_code == 200, created.text
_seed_branchable_thread(app, source_thread_id)
_archive_project(app, project["id"])
branch = client.post(
f"/api/threads/{source_thread_id}/branches",
json={"message_id": "ai-1", "message_ids": ["ai-1"]},
)
assert branch.status_code == 200, branch.text
branch_id = branch.json()["thread_id"]
fetched = client.get(f"/api/threads/{branch_id}")
assert fetched.status_code == 200, fetched.text
assert THREAD_PROJECT_METADATA_KEY not in fetched.json()["metadata"]
unassigned = client.post("/api/threads/search", json={"project_id": None}).json()
assert {h["thread_id"] for h in unassigned} == {branch_id}

View File

@ -0,0 +1,70 @@
# Recovering the original thread-incarnation database revision
The Projects build requires `projects` and `threads_meta.project_id`. An older
deployment may have stamped `0019_thread_incarnations` on a database containing
only `0018_oauth_identity_pg_partial` plus two nullable `VARCHAR(32)` columns:
`threads_meta.incarnation` and `mcp_tasks.thread_incarnation`. That shape cannot
serve this build's repositories. Startup now rejects it without changing the
schema or revision, and reports the missing tables/columns.
Normal databases on this tree's known migration chain upgrade automatically.
The procedure below is only for the exact original incarnation rollout shape.
An incarnation-stamped database that already has all current ORM tables and
columns can still use the audited compatibility exception without re-stamping.
## Offline migration
1. Stop every Gateway, scheduler, and other process writing to the database.
Take a restorable database backup and rehearse these steps on a copy.
2. Verify there is exactly one `alembic_version` row, containing
`0019_thread_incarnations`. Inspect the owning deployment's migration and
actual database schema: it must be the local 0018 schema plus only the two
nullable columns above, with no added defaults, constraints, tables, indexes,
or data backfills. Neither `projects` nor `threads_meta.project_id` may already
exist for this recovery path. If the shape differs, use a migration reviewed
for that deployment; do not use the commands below.
3. From this checkout's `backend/`, set `DEERFLOW_RECOVERY_DATABASE_URL` to the
target async SQLAlchemy URL (`sqlite+aiosqlite:////absolute/path/database.db`
or `postgresql+asyncpg://…`). For Postgres, also set
`DEERFLOW_RECOVERY_POSTGRES_SCHEMA` to the configured application schema, if
one is used. Keep credentials out of shell history.
4. Rebase the version marker to the verified common parent and run the normal
migrations. `purge=True` is necessary because this tree does not contain the
out-of-tree revision; it replaces the version row, not application data.
```bash
uv run python - <<'PY'
import asyncio
import os
from alembic import command
from sqlalchemy.ext.asyncio import create_async_engine
from deerflow.persistence.bootstrap import _get_alembic_config
engine = create_async_engine(os.environ["DEERFLOW_RECOVERY_DATABASE_URL"])
cfg = _get_alembic_config(
engine,
postgres_schema=os.environ.get("DEERFLOW_RECOVERY_POSTGRES_SCHEMA", ""),
)
command.stamp(cfg, "0018_oauth_identity_pg_partial", purge=True)
command.upgrade(cfg, "head")
asyncio.run(engine.dispose())
PY
```
This applies `0019_projects` and `0020_threads_meta_project_id`, preserving
the two incarnation columns and their existing values. Do not stamp directly
to head: that would skip the DDL and reproduce the missing-column failure.
5. Confirm the version is `0020_threads_meta_project_id`, the project table and
membership column/index exist, and existing incarnation values are retained.
Start this build, verify existing conversations load and a new conversation
can be created, then resume service. Do not restart older binaries that
cannot read this tree's head revision.
Bootstrap never performs this re-stamp itself. The regression in
`backend/tests/test_persistence_forward_revision_compat.py` constructs the
original schema, verifies startup rejection, and exercises the recovery while
checking thread reads/inserts and preservation of incarnation data. The future
incarnation migration must chain from the current local head and handle these
already-present nullable columns idempotently.

View File

@ -63,6 +63,13 @@
as removable "missing" entries instead of silently widening the allowlist.
6. Components subscribe to thread state and render updates
Project moves in `core/threads/hooks.ts` cancel all per-thread metadata query
variants after the write succeeds, merge only `deerflow_project_id`, then
invalidate/refetch that metadata prefix. This fences delayed pre-move reads and
restarts initial reads that have no cached snapshot. Search and project-thread
lists are invalidated on settlement. Keep the delayed-read regression in
`tests/unit/core/threads/move-thread.dom.test.tsx` for moves and removal.
The chat header's context-window control is intentionally persistent: while `context_usage` is unavailable, `ContextUsageBadge` renders a gauge placeholder rather than unmounting; once data arrives, the same position shows the percentage. `useThreadTokenUsage` retains placeholder data only when the response `thread_id` still matches the active route, so same-thread refetches do not flicker and cross-thread navigation never displays the previous chat's usage.
Settings skill uploads reject archives larger than 100 MiB before starting the
@ -79,7 +86,7 @@ Composer drafts are tab-scoped browser state. `core/threads/composer-draft.ts` s
Auth UI note: the login page's "keep me signed in" option submits only `remember_me` to the Gateway and may persist only the email address through `core/auth/remember-login.ts`. Passwords and tokens must never be stored in frontend storage; the `HttpOnly access_token` and readable `csrf_token` cookies remain Gateway-owned.
`/goal` and `/compact` are built-in composer commands, not skill activations. `src/components/workspace/input-box.tsx` intercepts `/goal`, `/goal clear`, and `/goal <condition>` before normal chat submission, calling Gateway `GET/PUT/DELETE /api/threads/{thread_id}/goal`. Setting `/goal <condition>` also submits the condition text as the next user task so the agent starts running immediately; status and clear do not start a run. Goal and compact requests are tied to the current `threadId` with an `AbortController`, so switching threads or unmounting the composer aborts in-flight requests and stale responses cannot update the new thread's composer state. The chat pages render `GoalStatus` above the composer from `AgentThreadState.goal`, with local optimistic state until an incremental goal update or final state reload arrives. `/compact` calls `POST /api/threads/{thread_id}/compact` to summarize older active context while leaving the full visible chat history intact; it is skipped on new/empty threads and blocked server-side while a run is in flight. Thread rename uses the same serialized state-write route; the rename dialog stays open and surfaces the server error when an active run returns 409.
`/goal` and `/compact` are built-in composer commands, not skill activations. `src/components/workspace/input-box.tsx` intercepts `/goal`, `/goal clear`, and `/goal <condition>` before normal chat submission, calling Gateway `GET/PUT/DELETE /api/threads/{thread_id}/goal`. Setting `/goal <condition>` also submits the condition text as the next user task so the agent starts running immediately; status and clear do not start a run. On a project-scoped new chat (`/workspace/chats/new?project=…`), the chat page's project pre-create runs before the goal PUT via the composer's `onPrepareThread` callback: the goal endpoint materializes a missing thread row itself, and an unassigned row would make the later idempotent thread create return it without assigning the project. Goal and compact requests are tied to the current `threadId` with an `AbortController`, so switching threads or unmounting the composer aborts in-flight requests and stale responses cannot update the new thread's composer state. The chat pages render `GoalStatus` above the composer from `AgentThreadState.goal`, with local optimistic state until an incremental goal update or final state reload arrives. `/compact` calls `POST /api/threads/{thread_id}/compact` to summarize older active context while leaving the full visible chat history intact; it is skipped on new/empty threads and blocked server-side while a run is in flight. Thread rename uses the same serialized state-write route; the rename dialog stays open and surfaces the server error when an active run returns 409.
The `/` skill list stays reachable after a skill is selected: typing `/` in the editable text beside the chip reopens it, and picking an entry swaps the chip rather than adding a second one, because the wire format carries exactly one leading `/skill`. That list offers skills only while a chip is selected — a builtin command owns the whole composer line, so `/goal` behind a selected skill would submit as chat text instead of running the command. The trigger itself is unchanged: a slash only opens the list at the start of the input (`getLeadingSlashSkillQuery`), pinned by `tests/e2e/chat.spec.ts`.

View File

@ -0,0 +1,278 @@
"use client";
import {
Archive,
Folder,
MessageSquarePlus,
RotateCcw,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Empty,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ProjectThreadsSection } from "@/components/workspace/projects/project-threads-section";
import {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { useI18n } from "@/core/i18n/hooks";
import {
useArchiveProject,
useDeleteProject,
useInfiniteProjectThreads,
usePatchProject,
useProject,
useRestoreProject,
type Project,
} from "@/core/projects";
import { isIMEComposing } from "@/lib/ime";
function newProjectChatPath(projectId: string): string {
return `/workspace/chats/new?project=${encodeURIComponent(projectId)}`;
}
export default function ProjectPage() {
const { t } = useI18n();
const { id: projectId } = useParams<{ id: string }>();
const projectQuery = useProject(projectId);
const project = projectQuery.data;
// Skip the threads request entirely when the project 404s — the endpoint
// itself 404s for a missing project, so firing it would be pure noise.
const threadsQuery = useInfiniteProjectThreads(projectId, {
enabled: project != null,
});
useEffect(() => {
document.title = project?.name
? `${project.name} - ${t.pages.appName}`
: `${t.projects.title} - ${t.pages.appName}`;
}, [project?.name, t.projects.title, t.pages.appName]);
return (
<WorkspaceContainer>
<WorkspaceHeader />
<WorkspaceBody>
<ScrollArea className="size-full">
<div className="mx-auto flex w-full max-w-(--container-width-md) flex-col gap-8 p-6 pt-8">
{projectQuery.isError ? (
<ProjectNotFoundState />
) : project == null ? (
<div className="text-muted-foreground py-16 text-center text-sm">
{t.common.loading}
</div>
) : (
<>
<ProjectHeader project={project} />
<ProjectThreadsSection query={threadsQuery} />
{/* Keying on updated_at re-syncs the rename draft whenever the
project changes underneath (e.g. rename round-trip). */}
<ProjectSettingsSection
key={project.updated_at}
project={project}
/>
</>
)}
</div>
</ScrollArea>
</WorkspaceBody>
</WorkspaceContainer>
);
}
function ProjectNotFoundState() {
const { t } = useI18n();
return (
<Empty className="py-16">
<EmptyHeader>
<EmptyMedia variant="icon">
<Folder />
</EmptyMedia>
<EmptyTitle>{t.projects.notFound}</EmptyTitle>
</EmptyHeader>
</Empty>
);
}
function ProjectHeader({ project }: { project: Project }) {
const { t } = useI18n();
return (
<header className="flex flex-wrap items-center gap-3">
<h1 className="min-w-0 flex-1 truncate text-2xl font-semibold">
{project.name}
</h1>
{project.status === "archived" && (
<Badge variant="secondary">{t.projects.archived}</Badge>
)}
{project.status !== "archived" && (
<Button asChild>
<Link href={newProjectChatPath(project.id)}>
<MessageSquarePlus />
{t.projects.newChat}
</Link>
</Button>
)}
</header>
);
}
function ProjectSettingsSection({ project }: { project: Project }) {
const { t } = useI18n();
const router = useRouter();
const patchProject = usePatchProject();
const archiveProject = useArchiveProject();
const restoreProject = useRestoreProject();
const deleteProject = useDeleteProject();
const [name, setName] = useState(project.name);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const trimmedName = name.trim();
const canSave =
trimmedName.length > 0 &&
trimmedName !== project.name &&
!patchProject.isPending;
const isArchived = project.status === "archived";
const handleRename = () => {
if (!canSave) {
return;
}
patchProject.mutate(
{ projectId: project.id, input: { name: trimmedName } },
{
onError: (error) => {
toast.error(
error instanceof Error && error.message
? error.message
: t.common.renameFailed,
);
},
},
);
};
const handleArchiveToggle = () => {
const mutation = isArchived ? restoreProject : archiveProject;
const fallback = isArchived
? t.projects.restoreFailed
: t.projects.archiveFailed;
mutation.mutate(project.id, {
onError: (error) => {
toast.error(
error instanceof Error && error.message ? error.message : fallback,
);
},
});
};
const handleDelete = () => {
deleteProject.mutate(project.id, {
onSuccess: () => {
router.push("/workspace/chats");
},
onError: (error) => {
toast.error(
error instanceof Error && error.message
? error.message
: t.projects.deleteFailed,
);
},
});
};
return (
<section className="flex flex-col gap-4">
<h2 className="text-muted-foreground text-sm font-medium">
{t.projects.settings}
</h2>
<div className="flex flex-col gap-2">
<label
htmlFor="project-name-input"
className="text-muted-foreground text-xs"
>
{t.projects.namePlaceholder}
</label>
<div className="flex items-center gap-2">
<Input
id="project-name-input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t.projects.namePlaceholder}
onKeyDown={(e) => {
if (e.key === "Enter" && !isIMEComposing(e)) {
e.preventDefault();
handleRename();
}
}}
/>
<Button variant="outline" disabled={!canSave} onClick={handleRename}>
{t.common.save}
</Button>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
disabled={archiveProject.isPending || restoreProject.isPending}
onClick={handleArchiveToggle}
>
{isArchived ? <RotateCcw /> : <Archive />}
{isArchived ? t.projects.restore : t.projects.archive}
</Button>
<Button
variant="destructive"
onClick={() => setIsDeleteDialogOpen(true)}
>
<Trash2 />
{t.projects.deleteProject}
</Button>
</div>
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t.projects.deleteProject}</DialogTitle>
<DialogDescription>
{t.projects.deleteProjectConfirm}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDeleteDialogOpen(false)}
>
{t.common.cancel}
</Button>
<Button
variant="destructive"
disabled={deleteProject.isPending}
onClick={handleDelete}
>
{t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}

View File

@ -1,6 +1,9 @@
"use client";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { Folder } from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
@ -44,9 +47,12 @@ import {
import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useProject } from "@/core/projects";
import { useLocalSettings, useThreadSettings } from "@/core/settings";
import { createThread } from "@/core/threads/api";
import {
useBranchThread,
INFINITE_THREADS_QUERY_KEY_PREFIX,
useThreadMetadata,
useThreadStream,
useThreadTokenUsage,
@ -55,7 +61,7 @@ import {
selectContextUsage,
threadTokenUsageToTokenUsage,
} from "@/core/threads/token-usage";
import { textOfMessage } from "@/core/threads/utils";
import { projectIdOfThread, textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { cn } from "@/lib/utils";
@ -66,14 +72,25 @@ import { useThreadChat } from "./use-thread-chat";
export default function ChatPage() {
const { t } = useI18n();
const router = useRouter();
const searchParams = useSearchParams();
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
useThreadChat();
// Project-scoped new chat: `/workspace/chats/new?project={id}` assigns the
// thread to the project on the FIRST submit — primarily via an explicit
// `POST /api/threads` pre-create with `project_id`, with the run-request
// metadata seed as the fallback channel (the SDK's own threads.create
// strips the reserved key, so the seed alone races the sidebar). Only
// meaningful while the thread is still lazy (`isNewThread`); once
// materialized the URL is replaced with the thread route and the param is
// gone. Invalid ids are dropped by backend admission.
const projectParam = isNewThread ? searchParams.get("project") : null;
// `isNewThread` tracks whether the backend has the thread yet — gates the
// SDK's history fetch (see issue #2746). `isWelcomeMode` is the visual
// welcome layout (centered input, hero, quick actions); we flip it to false
// the moment the user submits so the UI animates immediately, even though
// `isNewThread` stays true until the backend actually creates the thread.
const [isWelcomeMode, setIsWelcomeMode] = useState(isNewThread);
const queryClient = useQueryClient();
const [settings, setSettings] = useThreadSettings(threadId);
const [localSettings, setLocalSettings] = useLocalSettings();
const { enabled: browserControlEnabled } = useBrowserControlEnabled();
@ -178,15 +195,70 @@ export default function ChatPage() {
threadMetadata.isLoading,
]);
// Born assigned: pre-create the thread row with its project so the sidebar
// lists it under the project immediately. Idempotent server-side on
// `thread_id`, so retrying the same first message (same `threadId` while
// `isNewThread`) reuses the existing row instead of double-creating. This
// is the sole membership channel — run requests never carry the project
// key.
//
// Also runs before InputBox issues a `/goal <condition>` PUT (via
// `onPrepareThread`): the goal endpoint materializes a missing thread row
// itself, and an unassigned row would make this later idempotent create a
// membership no-op.
const ensureProjectThread = useCallback(async () => {
if (!projectParam) {
return;
}
try {
await createThread(threadId, projectParam);
void queryClient.invalidateQueries({
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
});
} catch (error) {
// Any failure (e.g. the project was deleted/archived between page load
// and submit): do NOT submit unassigned. Reject so PromptInput keeps
// the composer's text for a retry; the send in-flight guard is never
// engaged on this path, so retrying works immediately.
toast.error(t.projects.projectUnavailable);
throw error;
}
}, [threadId, projectParam, queryClient, t]);
// Submission fence. The cleanup runs when `threadId` changes (conversation
// switch on a persisted page — sidebar navigation keeps this component
// mounted), when the new-chat project scope changes (the sidebar "New
// chat" link can drop `?project=` without a pathname change), or on
// unmount. handleSubmit awaits the project pre-create before sending, and
// a navigation during that await must not let the stale continuation
// start a run for the abandoned conversation: its onStart would rewrite
// the newly selected conversation's URL and its completion would clear
// the new composer.
const submissionEpochRef = useRef(0);
useEffect(() => {
return () => {
submissionEpochRef.current += 1;
};
}, [threadId, projectParam]);
const handleSubmit = useCallback(
(message: PromptInputMessage, options?: InputBoxSubmitOptions) => {
async (message: PromptInputMessage, options?: InputBoxSubmitOptions) => {
const submissionEpoch = submissionEpochRef.current;
await ensureProjectThread();
// Conversation switched (or the page unmounted) while the project
// pre-create was pending: drop the submission. Reject silently — the
// user has already moved on, so a toast would land on the new
// conversation — and PromptInput keeps the current composer text.
if (submissionEpochRef.current !== submissionEpoch) {
throw new Error("thread-submission-stale");
}
const sendPromise = sendMessage(threadId, message, undefined, options);
if (message.files.length > 0) {
return sendPromise;
}
void sendPromise;
},
[sendMessage, threadId],
[sendMessage, threadId, ensureProjectThread],
);
const handleSubmitHumanInput = useCallback(
async (request: HumanInputRequest, response: HumanInputResponse) => {
@ -270,6 +342,14 @@ export default function ChatPage() {
[thread.messages],
);
// Project affiliation chip: shown once the materialized thread's metadata
// carries `deerflow_project_id` (written by the create/move endpoints and
// exposed here read-only).
const affiliatedProjectId =
!isNewThread && !isMock && threadMetadata.data
? projectIdOfThread(threadMetadata.data)
: null;
return (
<ThreadContext.Provider value={{ thread, isMock }}>
<SidecarProvider
@ -288,7 +368,7 @@ export default function ChatPage() {
)}
>
{!isMock && <SidebarTrigger className="md:hidden" />}
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm font-medium">
<ThreadTitle
threadId={threadId}
thread={thread}
@ -302,6 +382,9 @@ export default function ChatPage() {
metadata={threadMetadata.data?.metadata}
/>
)}
{affiliatedProjectId && (
<ProjectAffiliationBadge projectId={affiliatedProjectId} />
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{!isNewThread &&
@ -463,6 +546,7 @@ export default function ChatPage() {
setSettings("context", context)
}
onGoalChange={setLocalGoal}
onPrepareThread={ensureProjectThread}
onSubmit={handleSubmit}
onStop={handleStop}
/>
@ -489,3 +573,24 @@ export default function ChatPage() {
</ThreadContext.Provider>
);
}
/**
* Small chip in the chat header linking to the thread's project. Hidden
* while the project lookup is pending or when it fails (e.g. the project
* was deleted) an unresolvable affiliation degrades silently.
*/
function ProjectAffiliationBadge({ projectId }: { projectId: string }) {
const { data: project } = useProject(projectId);
if (!project) {
return null;
}
return (
<Link
href={`/workspace/projects/${encodeURIComponent(project.id)}`}
className="text-muted-foreground hover:text-foreground inline-flex max-w-40 shrink-0 items-center gap-1 truncate rounded-full border px-2 py-0.5 text-xs font-normal transition-colors"
>
<Folder className="size-3 shrink-0" />
<span className="truncate">{project.name}</span>
</Link>
);
}

View File

@ -25,6 +25,10 @@ export function resetThreadChatAfterDelete(detail: ThreadChatResetDetail) {
);
}
// Sentinel distinguishing "no identity minted yet" from a minted identity
// whose scope is a project-less new chat (project === null).
const NEW_CHAT_SCOPE_UNSET = Symbol("deerflow.newChatScopeUnset");
export function useThreadChat() {
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const pathname = usePathname();
@ -79,6 +83,38 @@ export function useThreadChat() {
setThreadIdState(threadIdFromPath);
}, [pathname, threadIdFromPath]);
// A new-chat identity is minted for the entry scope (the new chat's
// `project` query). The sidebar "New chat" link can leave
// `/new?project=…` for plain `/new` without a pathname change, so the
// pathname sync above keeps the old identity — which may already be
// pre-created under the previous project, and whose in-flight submission
// would otherwise survive the navigation. Re-mint whenever the project
// scope changes while still on a new-chat path.
const newChatScopeRef = useRef<string | null | symbol>(NEW_CHAT_SCOPE_UNSET);
useEffect(() => {
const project = isNewPath ? searchParams.get("project") : null;
if (!isNewPath) {
newChatScopeRef.current = NEW_CHAT_SCOPE_UNSET;
return;
}
if (newChatScopeRef.current === project) {
return;
}
const previousScope = newChatScopeRef.current;
newChatScopeRef.current = project;
if (previousScope === NEW_CHAT_SCOPE_UNSET) {
return;
}
// Scope changed (project added, removed, or swapped): the old identity
// belongs to the previous scope, so allocate a fresh one. The threadId
// flip also runs every fence keyed on `threadId` (composer goal aborts,
// chat-page submission epoch).
const nextThreadId = uuid();
newThreadIdRef.current = nextThreadId;
setIsNewThreadState(true);
setThreadIdState(nextThreadId);
}, [isNewPath, pathname, searchParams]);
useEffect(() => {
const handleReset = (event: Event) => {
const detail = (event as CustomEvent<ThreadChatResetDetail>).detail;

View File

@ -297,6 +297,7 @@ export function InputBox({
onContextChange,
onFollowupsVisibilityChange,
onGoalChange,
onPrepareThread,
onSubmit,
onStop,
...props
@ -340,6 +341,16 @@ export function InputBox({
) => void;
onFollowupsVisibilityChange?: (visible: boolean) => void;
onGoalChange?: (goal: GoalState | null) => void;
/**
* Prepare a not-yet-materialized thread before a builtin command creates
* it server-side. The `/goal <condition>` PUT endpoint materializes a
* missing thread row itself, so a project-scoped new chat uses this to
* assign membership first the later idempotent thread create would
* otherwise return that unassigned row without the project. Only runs for
* goal-set: status/clear never create a thread server-side. Rejecting
* aborts the command and keeps the composer's text for a retry.
*/
onPrepareThread?: () => void | Promise<void>;
onSubmit?: (
message: PromptInputMessage,
options?: InputBoxSubmitOptions,
@ -1193,6 +1204,35 @@ export function InputBox({
// clearing it (PromptInput only preserves input on a rejected submit).
return Promise.reject(new Error("goal-too-long"));
}
if (submitAction.command.kind === "set") {
// A goal-set PUT creates the thread server-side when missing, so a
// project-scoped new chat must assign membership first. The prepare
// callback toasts its own failure; reject so the composer keeps the
// text for a retry instead of issuing an unassigned goal.
//
// Fence against conversation switches while preparation runs: the
// goal PUT registers its AbortController only when it starts, so
// the thread-change/unmount cleanup cannot cancel an in-flight
// prepare. Capture the goal-request epoch before the await — the
// cleanup bumps it via abortGoalRequest — and drop the stale
// continuation before it can clear the new conversation's composer
// or launch the abandoned submission.
const requestEpoch = goalRequestStateRef.current.sequence;
try {
await onPrepareThread?.();
} catch (error) {
return Promise.reject(
error instanceof Error
? error
: new Error("thread preparation failed"),
);
}
if (goalRequestStateRef.current.sequence !== requestEpoch) {
// Reject (not resolve) so PromptInput keeps the current
// conversation's composer text untouched.
return Promise.reject(new Error("goal-preparation-stale"));
}
}
promptHistoryIndexRef.current = null;
promptHistoryDraftRef.current = "";
setFollowups([]);
@ -1229,6 +1269,7 @@ export function InputBox({
handleCompactCommand,
handleGoalCommand,
handleStopStreaming,
onPrepareThread,
selectedSlashSkill,
status,
submitThreadMessage,

View File

@ -0,0 +1,216 @@
"use client";
import { Check, FolderInput, FolderMinus, Plus } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { useI18n } from "@/core/i18n/hooks";
import { useCreateProject, useProjects } from "@/core/projects";
import { useMoveThreadToProject } from "@/core/threads/hooks";
import type { AgentThread } from "@/core/threads/types";
import { projectIdOfThread } from "@/core/threads/utils";
import { isIMEComposing } from "@/lib/ime";
/**
* "Move to project" submenu for a thread's dropdown menu: active projects
* (check mark on the current one), "New project…" (opens the create dialog),
* and "Remove from project" when the thread is assigned. Archived projects
* never appear because only the "active" list is queried.
*
* Renders ONLY menu content. DropdownMenuContent unmounts its children when
* the menu closes, so the create dialog lives outside the menu: the
* "New project…" item delegates to `onNewProject`, and the caller mounts
* `NewProjectDialog` as a sibling of the DropdownMenu (the same pattern the
* rename dialog uses in `ThreadSidebarItem`).
*/
export function MoveToProjectMenu({
thread,
onNewProject,
onMoveProject,
}: {
thread: AgentThread;
onNewProject: () => void;
onMoveProject: (projectId: string | null) => void;
}) {
const { t } = useI18n();
const { data: projects } = useProjects("active");
const currentProjectId = projectIdOfThread(thread);
// Presentational only: the move mutation lives on the persistent
// `ThreadSidebarItem` (this submenu unmounts when the dropdown closes, so
// a mutation owned here would lose its error handling on failure).
const handleMove = useCallback(
(projectId: string | null) => {
if (projectId === currentProjectId) {
return;
}
onMoveProject(projectId);
},
[currentProjectId, onMoveProject],
);
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderInput className="text-muted-foreground" />
<span>{t.projects.moveToProject}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56">
{projects?.map((project) => (
<DropdownMenuItem
key={project.id}
onSelect={() => handleMove(project.id)}
>
{project.id === currentProjectId ? (
<Check className="text-muted-foreground" />
) : (
<span aria-hidden="true" className="size-4 shrink-0" />
)}
<span className="truncate">{project.name}</span>
</DropdownMenuItem>
))}
{projects && projects.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuItem onSelect={onNewProject}>
<Plus className="text-muted-foreground" />
<span>{t.projects.newProject}</span>
</DropdownMenuItem>
{currentProjectId !== null && (
<DropdownMenuItem onSelect={() => handleMove(null)}>
<FolderMinus className="text-muted-foreground" />
<span>{t.projects.removeFromProject}</span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem disabled>
<span className="text-xs">{t.projects.moveToProjectHint}</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
/**
* "New project" dialog for the move-to-project flow. Must be mounted
* OUTSIDE the thread's DropdownMenuContent (as a sibling of the
* DropdownMenu) so it survives the menu closing. On successful create, the
* thread is moved into the new project.
*/
export function NewProjectDialog({
thread,
open,
onOpenChange,
}: {
thread: AgentThread;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const { mutate: createProject, isPending: isCreating } = useCreateProject();
const { mutate: moveThreadToProject } = useMoveThreadToProject();
const [createName, setCreateName] = useState("");
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (!nextOpen) {
setCreateName("");
}
onOpenChange(nextOpen);
},
[onOpenChange],
);
const handleCreateSubmit = useCallback(() => {
const name = createName.trim();
if (!name || isCreating) {
return;
}
createProject(
{ name },
{
onSuccess: (project) => {
handleOpenChange(false);
moveThreadToProject(
{ threadId: thread.thread_id, projectId: project.id },
{
onError: (error) => {
toast.error(
error instanceof Error && error.message
? error.message
: t.projects.moveFailed,
);
},
},
);
},
onError: (error) => {
toast.error(
error instanceof Error && error.message
? error.message
: t.projects.createFailed,
);
},
},
);
}, [
createProject,
createName,
isCreating,
handleOpenChange,
moveThreadToProject,
t.projects.createFailed,
t.projects.moveFailed,
thread.thread_id,
]);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t.projects.newProject}</DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
value={createName}
onChange={(e) => setCreateName(e.target.value)}
placeholder={t.projects.namePlaceholder}
onKeyDown={(e) => {
if (e.key === "Enter" && !isIMEComposing(e)) {
e.preventDefault();
handleCreateSubmit();
}
}}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
{t.common.cancel}
</Button>
<Button
onClick={handleCreateSubmit}
disabled={!createName.trim() || isCreating}
>
{t.projects.create}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,355 @@
"use client";
import {
Archive,
ChevronRight,
Folder,
FolderTree,
List,
Plus,
} from "lucide-react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import { useCreateProject, useProjects, type Project } from "@/core/projects";
import { useLocalSettings } from "@/core/settings";
import { isStaticWebsiteOnly } from "@/core/static-mode";
import { useInfiniteThreads } from "@/core/threads/hooks";
import { flattenThreadBranches } from "@/core/threads/thread-branch-tree";
import { buildThreadListModel } from "@/core/threads/thread-list-model";
import type { AgentThread } from "@/core/threads/types";
import { pathOfThread, projectIdOfThread } from "@/core/threads/utils";
import { env } from "@/env";
import { isIMEComposing } from "@/lib/ime";
import { ThreadSidebarItem } from "./recent-chat-list";
function projectPath(projectId: string): string {
return `/workspace/projects/${projectId}`;
}
function ProjectThreadGroup({
project,
threads,
recentThreadId,
}: {
project: Project;
threads: readonly AgentThread[];
/** Global most-recent thread id — mirrors flat mode's `threads[0]?.thread_id`. */
recentThreadId: string | undefined;
}) {
const pathname = usePathname();
const [open, setOpen] = useState(true);
const href = projectPath(project.id);
const branchEntries = useMemo(
() => flattenThreadBranches([...threads]),
[threads],
);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={pathname === href}>
<Link href={href} title={project.name}>
<Folder />
<span className="min-w-0 truncate">{project.name}</span>
</Link>
</SidebarMenuButton>
<CollapsibleTrigger asChild>
<SidebarMenuAction
aria-label={project.name}
className="[&>svg]:transition-transform [&[data-state=open]>svg]:rotate-90"
>
<ChevronRight />
</SidebarMenuAction>
</CollapsibleTrigger>
</SidebarMenuItem>
<CollapsibleContent>
<SidebarMenu className="border-sidebar-border ml-4 border-l pl-2">
{branchEntries.map((entry) => (
<ThreadSidebarItem
key={entry.thread.thread_id}
thread={entry.thread}
isActive={pathOfThread(entry.thread) === pathname}
branchEntry={entry}
recentThreadId={recentThreadId}
/>
))}
</SidebarMenu>
</CollapsibleContent>
</Collapsible>
);
}
function ArchivedProjectsGroup({
projects,
threadsByProject,
recentThreadId,
}: {
projects: readonly Project[];
threadsByProject: ReadonlyMap<string, readonly AgentThread[]>;
recentThreadId: string | undefined;
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton>
<Archive />
<span className="min-w-0 truncate">{t.projects.archived}</span>
<ChevronRight className="ml-auto transition-transform [[data-state=open]>&]:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
</SidebarMenuItem>
<CollapsibleContent>
<SidebarMenu className="border-sidebar-border ml-4 border-l pl-2">
{projects.map((project) => (
<ProjectThreadGroup
key={project.id}
project={project}
threads={threadsByProject.get(project.id) ?? []}
recentThreadId={recentThreadId}
/>
))}
</SidebarMenu>
</CollapsibleContent>
</Collapsible>
);
}
/**
* Grouped mode: active projects as collapsible headers with their recent
* threads (grouped client-side from the already-fetched infinite thread
* pages), plus a collapsed "Archived" section at the bottom.
*/
function GroupedProjectList() {
const { data: activeProjects } = useProjects("active");
const { data: archivedProjects } = useProjects("archived");
const { data: infiniteThreads } = useInfiniteThreads({
archived:
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? undefined : false,
});
const { thread_id: threadIdFromPath } = useParams<{
thread_id: string;
agent_name?: string;
}>();
const threadListModel = useMemo(
() => buildThreadListModel(infiniteThreads?.pages ?? []),
[infiniteThreads?.pages],
);
// Same value flat mode passes to `ThreadSidebarItem` — the global most-recent
// thread, NOT the capped displayedThreads or any group-local first entry.
const globalRecentThreadId = threadListModel.threads[0]?.thread_id;
// Mirror `RecentChatList`'s active-thread exception: the path-active thread
// is appended even when it falls beyond the unpinned display cap, so it
// must join the partition input too — otherwise an active assigned chat
// renders in neither the flat list nor its project group.
const partitionableThreads = useMemo(() => {
if (
!threadIdFromPath ||
threadListModel.displayedThreads.some(
(thread) => thread.thread_id === threadIdFromPath,
)
) {
return threadListModel.displayedThreads;
}
const activeThread = threadListModel.byId.get(threadIdFromPath);
return activeThread
? [...threadListModel.displayedThreads, activeThread]
: threadListModel.displayedThreads;
}, [threadIdFromPath, threadListModel]);
const threadsByProject = useMemo(() => {
const grouped = new Map<string, AgentThread[]>();
for (const thread of partitionableThreads) {
const projectId = projectIdOfThread(thread);
if (projectId === null) {
continue;
}
const projectThreads = grouped.get(projectId);
if (projectThreads) {
projectThreads.push(thread);
} else {
grouped.set(projectId, [thread]);
}
}
return grouped;
}, [partitionableThreads]);
if (
(!activeProjects || activeProjects.length === 0) &&
(!archivedProjects || archivedProjects.length === 0)
) {
return null;
}
return (
<SidebarMenu>
{activeProjects?.map((project) => (
<ProjectThreadGroup
key={project.id}
project={project}
threads={threadsByProject.get(project.id) ?? []}
recentThreadId={globalRecentThreadId}
/>
))}
{archivedProjects && archivedProjects.length > 0 && (
<ArchivedProjectsGroup
projects={archivedProjects}
threadsByProject={threadsByProject}
recentThreadId={globalRecentThreadId}
/>
)}
</SidebarMenu>
);
}
export function ProjectsSection() {
const { t } = useI18n();
const [settings, setSettings] = useLocalSettings();
const groupByProject = settings.projectsDisplayMode === "grouped";
const { mutate: createProject, isPending: isCreating } = useCreateProject();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [createName, setCreateName] = useState("");
const handleCreateSubmit = useCallback(() => {
const name = createName.trim();
if (!name || isCreating) {
return;
}
createProject(
{ name },
{
onSuccess: () => {
setCreateDialogOpen(false);
setCreateName("");
},
onError: (error) => {
toast.error(
error instanceof Error && error.message
? error.message
: t.projects.createFailed,
);
},
},
);
}, [createProject, createName, isCreating, t.projects.createFailed]);
// Static-demo mode has no Gateway and no projects: hide the whole section
// (New project button, grouped toggle, and groups are all dead actions).
if (isStaticWebsiteOnly()) {
return null;
}
return (
<SidebarGroup>
<SidebarGroupLabel className="justify-between pr-1">
<span>{t.projects.title}</span>
<span className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="size-5 [&>svg]:size-3.5"
title={
groupByProject
? t.projects.switchToFlat
: t.projects.switchToGrouped
}
aria-label={
groupByProject
? t.projects.switchToFlat
: t.projects.switchToGrouped
}
onClick={() =>
setSettings(
"projectsDisplayMode",
groupByProject ? "flat" : "grouped",
)
}
data-testid="projects-display-mode-toggle"
>
{groupByProject ? <FolderTree /> : <List />}
</Button>
<Button
variant="ghost"
size="icon"
className="size-5 [&>svg]:size-3.5"
title={t.projects.newProject}
aria-label={t.projects.newProject}
onClick={() => setCreateDialogOpen(true)}
data-testid="projects-new-project-button"
>
<Plus />
</Button>
</span>
</SidebarGroupLabel>
{groupByProject && (
<SidebarGroupContent className="group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0">
<GroupedProjectList />
</SidebarGroupContent>
)}
{/* New project dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t.projects.newProject}</DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
value={createName}
onChange={(e) => setCreateName(e.target.value)}
placeholder={t.projects.namePlaceholder}
onKeyDown={(e) => {
if (e.key === "Enter" && !isIMEComposing(e)) {
e.preventDefault();
handleCreateSubmit();
}
}}
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setCreateDialogOpen(false)}
>
{t.common.cancel}
</Button>
<Button
onClick={handleCreateSubmit}
disabled={!createName.trim() || isCreating}
>
{t.projects.create}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</SidebarGroup>
);
}

View File

@ -0,0 +1,89 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { VirtualThreadList } from "@/components/workspace/thread-list-virtualizer";
import { useI18n } from "@/core/i18n/hooks";
import { type ProjectThreadsQueryResult } from "@/core/projects";
import { pathOfThread } from "@/core/threads/utils";
import { formatTimeAgo } from "@/core/utils/datetime";
import { cn } from "@/lib/utils";
export function ProjectThreadsSection({
query,
}: {
query: ProjectThreadsQueryResult;
}) {
const { t } = useI18n();
const threads = query.data?.pages.flatMap((page) => page) ?? [];
return (
<section className="flex flex-col gap-2">
<h2 className="text-muted-foreground text-sm font-medium">
{t.projects.threads}
</h2>
<div className="rounded-lg border">
{query.isError ? (
<div className="text-muted-foreground p-4 text-sm">
{t.projects.threadsLoadFailed}
</div>
) : threads.length === 0 && !query.isLoading ? (
<div className="text-muted-foreground p-4 text-sm">
{t.projects.empty}
</div>
) : (
// The page scrolls inside its own ScrollArea; the list windows rows
// against that viewport so paging through a long-lived project never
// grows unbounded DOM (same windowing the sidebar and
// /workspace/chats use).
<VirtualThreadList
estimateSize={56}
items={threads}
scrollParentSelector='[data-slot="scroll-area-viewport"]'
renderItem={(thread, index) => (
<Link
key={thread.thread_id}
href={pathOfThread({
thread_id: thread.thread_id,
metadata: thread.metadata,
})}
>
<div
className={cn(
"hover:bg-muted/50 flex min-w-0 items-center gap-2 p-4 transition-colors",
index !== threads.length - 1 && "border-b",
)}
>
<div className="min-w-0 flex-1 truncate">
{thread.display_name?.trim()
? thread.display_name
: t.projects.untitled}
</div>
{thread.updated_at && (
<div className="text-muted-foreground shrink-0 text-sm">
{formatTimeAgo(thread.updated_at)}
</div>
)}
</div>
</Link>
)}
/>
)}
</div>
{query.hasNextPage && (
<Button
variant="ghost"
size="sm"
className="justify-center text-xs"
onClick={() => void query.fetchNextPage()}
disabled={query.isFetchingNextPage}
data-testid="project-threads-load-more"
>
{query.isFetchingNextPage
? t.chats.loadingMore
: t.chats.loadOlderChats}
</Button>
)}
</section>
);
}

View File

@ -49,32 +49,55 @@ import { resetThreadChatAfterDelete } from "@/components/workspace/chats/use-thr
import { getAPIClient } from "@/core/api";
import { writeTextToClipboard } from "@/core/clipboard";
import { useI18n } from "@/core/i18n/hooks";
import { useProjects } from "@/core/projects";
import { useLocalSettings } from "@/core/settings";
import { isStaticWebsiteOnly } from "@/core/static-mode";
import { exportThread, type ThreadExportFormat } from "@/core/threads/export";
import {
useDeleteThread,
useInfiniteThreads,
useMoveThreadToProject,
usePinThread,
useRenameThread,
} from "@/core/threads/hooks";
import { flattenThreadBranches } from "@/core/threads/thread-branch-tree";
import {
flattenThreadBranches,
type ThreadBranchEntry,
} from "@/core/threads/thread-branch-tree";
import { buildThreadListModel } from "@/core/threads/thread-list-model";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import {
channelSourceOfThread,
isThreadPinned,
pathOfThread,
projectIdOfThread,
titleOfThread,
} from "@/core/threads/utils";
import { env } from "@/env";
import { isIMEComposing } from "@/lib/ime";
import { MoveToProjectMenu, NewProjectDialog } from "./move-to-project-menu";
import { ThreadChannelIcon } from "./thread-channel-source";
import { VirtualThreadList } from "./thread-list-virtualizer";
import { useThreadArchiveAction } from "./use-thread-archive-action";
export function RecentChatList() {
/**
* A single thread row in the sidebar: link + hover action menu (pin, rename,
* share, export, delete) + rename dialog. Shared by the flat recent-chat list
* and the grouped-by-project rendering in `ProjectsSection`.
*/
export function ThreadSidebarItem({
thread,
isActive,
branchEntry,
recentThreadId,
}: {
thread: AgentThread;
isActive: boolean;
branchEntry?: ThreadBranchEntry | undefined;
recentThreadId?: string | undefined;
}) {
const { t } = useI18n();
const archiveAction = useThreadArchiveAction();
const router = useRouter();
const pathname = usePathname();
const { thread_id: threadIdFromPath, agent_name: agentNameFromPath } =
@ -82,131 +105,80 @@ export function RecentChatList() {
thread_id: string;
agent_name?: string;
}>();
const {
data: infiniteThreads,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteThreads({
archived:
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? undefined : false,
});
const threadListModel = useMemo(
() => buildThreadListModel(infiniteThreads?.pages ?? []),
[infiniteThreads?.pages],
);
const { threads } = threadListModel;
const displayedThreads = useMemo(() => {
if (
!threadIdFromPath ||
threadListModel.displayedThreads.some(
(thread) => thread.thread_id === threadIdFromPath,
)
) {
return threadListModel.displayedThreads;
}
const activeThread = threadListModel.byId.get(threadIdFromPath);
return activeThread
? [...threadListModel.displayedThreads, activeThread]
: threadListModel.displayedThreads;
}, [threadIdFromPath, threadListModel]);
const branchList = useMemo(() => {
const entries = flattenThreadBranches(displayedThreads);
return {
entriesById: new Map(
entries.map((entry) => [entry.thread.thread_id, entry]),
),
threads: entries.map((entry) => entry.thread),
};
}, [displayedThreads]);
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const element = sentinelRef.current;
if (!element || !hasNextPage || !threadListModel.canLoadMore) {
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting && hasNextPage && !isFetchingNextPage) {
void fetchNextPage();
}
},
{ rootMargin: "120px 0px 120px 0px" },
);
observer.observe(element);
return () => observer.disconnect();
}, [
fetchNextPage,
hasNextPage,
isFetchingNextPage,
threadListModel.canLoadMore,
]);
const { mutate: deleteThread } = useDeleteThread();
const { mutate: renameThread } = useRenameThread();
const { mutate: updatePinnedThread } = usePinThread();
// The move mutation is owned here (not inside `MoveToProjectMenu`) because
// selecting a project closes the dropdown and unmounts the menu — a
// per-mutate `onError` registered there would be dropped before a failed
// request settles, failing silently. This row persists, so a hook-level
// `onError` always fires. Same ownership-hoisting precedent as the
// `NewProjectDialog` below.
const { mutate: moveThreadToProject } = useMoveThreadToProject({
onError: (error) => {
toast.error(
error instanceof Error && error.message
? error.message
: t.projects.moveFailed,
);
},
});
const handleMoveProject = useCallback(
(projectId: string | null) => {
moveThreadToProject({ threadId: thread.thread_id, projectId });
},
[moveThreadToProject, thread.thread_id],
);
const archiveAction = useThreadArchiveAction();
// Rename dialog state
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
const [renameThreadId, setRenameThreadId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [newProjectDialogOpen, setNewProjectDialogOpen] = useState(false);
const handleDelete = useCallback(
(thread: AgentThread) => {
const currentPathname =
typeof window === "undefined" ? pathname : window.location.pathname;
const threadPath = pathOfThread(thread);
const nextThreadPath = pathOfThread("new", {
agent_name: agentNameFromPath,
});
const isNewThreadPath = currentPathname === nextThreadPath;
const isCurrentThread =
thread.thread_id === threadIdFromPath ||
threadPath === currentPathname ||
(isNewThreadPath && threads[0]?.thread_id === thread.thread_id);
const handleDelete = useCallback(() => {
const currentPathname =
typeof window === "undefined" ? pathname : window.location.pathname;
const threadPath = pathOfThread(thread);
const nextThreadPath = pathOfThread("new", {
agent_name: agentNameFromPath,
});
const isNewThreadPath = currentPathname === nextThreadPath;
const isCurrentThread =
thread.thread_id === threadIdFromPath ||
threadPath === currentPathname ||
(isNewThreadPath && recentThreadId === thread.thread_id);
deleteThread({
threadId: thread.thread_id,
onRemoteDeleted: isCurrentThread
? () => {
resetThreadChatAfterDelete({
deletedThreadId: thread.thread_id,
nextPath: nextThreadPath,
force: true,
});
void router.replace(nextThreadPath);
}
: undefined,
});
},
[
agentNameFromPath,
deleteThread,
pathname,
router,
threadIdFromPath,
threads,
],
);
const handleRenameClick = useCallback(
(threadId: string, currentTitle: string) => {
setRenameThreadId(threadId);
setRenameValue(currentTitle);
setRenameDialogOpen(true);
},
[],
);
deleteThread({
threadId: thread.thread_id,
onRemoteDeleted: isCurrentThread
? () => {
resetThreadChatAfterDelete({
deletedThreadId: thread.thread_id,
nextPath: nextThreadPath,
force: true,
});
void router.replace(nextThreadPath);
}
: undefined,
});
}, [
agentNameFromPath,
deleteThread,
pathname,
recentThreadId,
router,
thread,
threadIdFromPath,
]);
const handleRenameSubmit = useCallback(() => {
if (renameThreadId && renameValue.trim()) {
if (renameValue.trim()) {
renameThread(
{ threadId: renameThreadId, title: renameValue.trim() },
{ threadId: thread.thread_id, title: renameValue.trim() },
{
onSuccess: () => {
setRenameDialogOpen(false);
setRenameThreadId(null);
setRenameValue("");
},
onError: (error) => {
@ -219,54 +191,48 @@ export function RecentChatList() {
},
);
}
}, [renameThread, renameThreadId, renameValue, t.common.renameFailed]);
}, [renameThread, thread.thread_id, renameValue, t.common.renameFailed]);
const handleTogglePin = useCallback(
(thread: AgentThread) => {
updatePinnedThread(
{
threadId: thread.thread_id,
pinned: !isThreadPinned(thread),
const handleTogglePin = useCallback(() => {
updatePinnedThread(
{
threadId: thread.thread_id,
pinned: !isThreadPinned(thread),
},
{
onError: (err) => {
toast.error(
err instanceof Error ? err.message : t.chats.pinChatFailed,
);
},
{
onError: (err) => {
toast.error(
err instanceof Error ? err.message : t.chats.pinChatFailed,
);
},
},
);
},
[t.chats.pinChatFailed, updatePinnedThread],
);
},
);
}, [t.chats.pinChatFailed, thread, updatePinnedThread]);
const handleShare = useCallback(
async (thread: AgentThread) => {
// Always use Vercel URL for sharing so others can access
const VERCEL_URL = "https://deer-flow-v2.vercel.app";
const isLocalhost =
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1";
// On localhost: use Vercel URL; On production: use current origin
const baseUrl = isLocalhost ? VERCEL_URL : window.location.origin;
const shareUrl = `${baseUrl}${pathOfThread(thread)}`;
try {
const didCopy = await writeTextToClipboard(shareUrl);
if (!didCopy) {
toast.error(t.clipboard.failedToCopyToClipboard);
return;
}
toast.success(t.clipboard.linkCopied);
} catch {
const handleShare = useCallback(async () => {
// Always use Vercel URL for sharing so others can access
const VERCEL_URL = "https://deer-flow-v2.vercel.app";
const isLocalhost =
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1";
// On localhost: use Vercel URL; On production: use current origin
const baseUrl = isLocalhost ? VERCEL_URL : window.location.origin;
const shareUrl = `${baseUrl}${pathOfThread(thread)}`;
try {
const didCopy = await writeTextToClipboard(shareUrl);
if (!didCopy) {
toast.error(t.clipboard.failedToCopyToClipboard);
return;
}
},
[t],
);
toast.success(t.clipboard.linkCopied);
} catch {
toast.error(t.clipboard.failedToCopyToClipboard);
}
}, [t, thread]);
const handleExport = useCallback(
async (thread: AgentThread, format: ThreadExportFormat) => {
async (format: ThreadExportFormat) => {
try {
const apiClient = getAPIClient();
const state = await apiClient.threads.getState<AgentThreadState>(
@ -283,217 +249,137 @@ export function RecentChatList() {
toast.error(t.common.exportFailed);
}
},
[t],
[t, thread],
);
if (threads.length === 0) {
return null;
}
const channelSource = channelSourceOfThread(thread);
const pinned = isThreadPinned(thread);
const parentTitle = branchEntry?.parentThread
? titleOfThread(branchEntry.parentThread)
: null;
const title = titleOfThread(thread);
const branchLabel = parentTitle
? t.chats.branchLabel(title, parentTitle)
: undefined;
return (
<>
<SidebarGroup>
<SidebarGroupLabel>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
? t.sidebar.recentChats
: t.sidebar.demoChats}
</SidebarGroupLabel>
<SidebarGroupContent className="group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0">
<SidebarMenu>
{/* Keep pagination at the old list boundary when this switches to virtual rows. */}
<div
className="flex w-full flex-col gap-1"
style={{ overflowAnchor: "none" }}
<SidebarMenuItem className="group/side-menu-item">
<SidebarMenuButton isActive={isActive} asChild>
<Link
aria-label={branchLabel}
className="text-muted-foreground min-w-0 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
data-branch-depth={
branchEntry && branchEntry.depth > 0 ? branchEntry.depth : undefined
}
data-branch-parent-id={branchEntry?.parentThread?.thread_id}
href={pathOfThread(thread)}
title={branchLabel}
>
{branchEntry && branchEntry.depth > 0 && (
<span
aria-hidden="true"
className="text-muted-foreground/70 shrink-0 font-mono text-[10px] leading-none"
data-testid="thread-branch-stem"
style={{
marginLeft: `${Math.min(branchEntry.depth - 1, 1) * 8}px`,
}}
>
<VirtualThreadList
estimateSize={36}
gap={4}
items={branchList.threads}
scrollParentSelector='[data-sidebar="content"]'
renderItem={(thread) => {
const isActive = pathOfThread(thread) === pathname;
const channelSource = channelSourceOfThread(thread);
const pinned = isThreadPinned(thread);
const branchEntry = branchList.entriesById.get(
thread.thread_id,
);
const parentTitle = branchEntry?.parentThread
? titleOfThread(branchEntry.parentThread)
: null;
const title = titleOfThread(thread);
const branchLabel = parentTitle
? t.chats.branchLabel(title, parentTitle)
: undefined;
return (
<SidebarMenuItem
key={thread.thread_id}
className="group/side-menu-item"
>
<SidebarMenuButton isActive={isActive} asChild>
<Link
aria-label={branchLabel}
className="text-muted-foreground min-w-0 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
data-branch-depth={
branchEntry && branchEntry.depth > 0
? branchEntry.depth
: undefined
}
data-branch-parent-id={
branchEntry?.parentThread?.thread_id
}
href={pathOfThread(thread)}
title={branchLabel}
>
{branchEntry && branchEntry.depth > 0 && (
<span
aria-hidden="true"
className="text-muted-foreground/70 shrink-0 font-mono text-[10px] leading-none"
data-testid="thread-branch-stem"
style={{
marginLeft: `${Math.min(branchEntry.depth - 1, 1) * 8}px`,
}}
>
{branchEntry.isLastSibling ? "└─" : "├─"}
</span>
)}
<ThreadChannelIcon source={channelSource} />
{pinned && (
<Pin
aria-hidden="true"
className="text-muted-foreground size-3.5 shrink-0"
/>
)}
<span className="min-w-0 truncate">{title}</span>
{channelSource && (
<span
className="bg-muted text-muted-foreground ml-auto inline-flex h-5 max-w-14 shrink-0 items-center rounded-md px-1.5 text-[10px] font-medium"
title={`${channelSource.label} channel`}
>
<span className="truncate">
{channelSource.label}
</span>
</span>
)}
</Link>
</SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="bg-background/50 hover:bg-background after:left-0!"
>
<MoreHorizontal />
<span className="sr-only">{t.common.more}</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
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(
thread.thread_id,
titleOfThread(thread),
)
}
>
<Pencil className="text-muted-foreground" />
<span>{t.common.rename}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => handleShare(thread)}
>
<Share2 className="text-muted-foreground" />
<span>{t.common.share}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Download className="text-muted-foreground" />
<span>{t.common.export}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "markdown")
}
>
<FileText className="text-muted-foreground" />
<span>{t.common.exportAsMarkdown}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => handleExport(thread, "json")}
>
<FileJson className="text-muted-foreground" />
<span>{t.common.exportAsJSON}</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem
disabled={archiveAction.isPending}
onSelect={() =>
archiveAction.setArchived(
thread.thread_id,
true,
)
}
>
<Archive className="text-muted-foreground" />
<span>{t.chats.archiveChat}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => handleDelete(thread)}
>
<Trash2 className="text-muted-foreground" />
<span>{t.common.delete}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarMenuItem>
);
}}
/>
{hasNextPage && threadListModel.canLoadMore && (
<>
<Button
variant="ghost"
size="sm"
className="mx-2 my-1 w-[calc(100%-1rem)] justify-center text-xs"
onClick={() => void fetchNextPage()}
disabled={isFetchingNextPage}
data-testid="recent-chat-list-load-more"
>
{isFetchingNextPage
? t.chats.loadingMore
: t.chats.loadOlderChats}
</Button>
<div
ref={sentinelRef}
aria-hidden="true"
className="h-px w-full"
data-testid="recent-chat-list-sentinel"
/>
</>
{branchEntry.isLastSibling ? "└─" : "├─"}
</span>
)}
<ThreadChannelIcon source={channelSource} />
{pinned && (
<Pin
aria-hidden="true"
className="text-muted-foreground size-3.5 shrink-0"
/>
)}
<span className="min-w-0 truncate">{title}</span>
{channelSource && (
<span
className="bg-muted text-muted-foreground ml-auto inline-flex h-5 max-w-14 shrink-0 items-center rounded-md px-1.5 text-[10px] font-medium"
title={`${channelSource.label} channel`}
>
<span className="truncate">{channelSource.label}</span>
</span>
)}
</Link>
</SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="bg-background/50 hover:bg-background after:left-0!"
>
<MoreHorizontal />
<span className="sr-only">{t.common.more}</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={"right"}
align={"start"}
>
<DropdownMenuItem onSelect={handleTogglePin}>
{pinned ? (
<PinOff className="text-muted-foreground" />
) : (
<Pin className="text-muted-foreground" />
)}
</div>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<span>{pinned ? t.chats.unpinChat : t.chats.pinChat}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
setRenameValue(titleOfThread(thread));
setRenameDialogOpen(true);
}}
>
<Pencil className="text-muted-foreground" />
<span>{t.common.rename}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void handleShare()}>
<Share2 className="text-muted-foreground" />
<span>{t.common.share}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Download className="text-muted-foreground" />
<span>{t.common.export}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onSelect={() => void handleExport("markdown")}
>
<FileText className="text-muted-foreground" />
<span>{t.common.exportAsMarkdown}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void handleExport("json")}>
<FileJson className="text-muted-foreground" />
<span>{t.common.exportAsJSON}</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem
disabled={archiveAction.isPending}
onSelect={() => archiveAction.setArchived(thread.thread_id, true)}
>
<Archive className="text-muted-foreground" />
<span>{t.chats.archiveChat}</span>
</DropdownMenuItem>
<MoveToProjectMenu
thread={thread}
onNewProject={() => setNewProjectDialogOpen(true)}
onMoveProject={handleMoveProject}
/>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleDelete}>
<Trash2 className="text-muted-foreground" />
<span>{t.common.delete}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Rename Dialog */}
<Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
@ -525,6 +411,201 @@ export function RecentChatList() {
</DialogFooter>
</DialogContent>
</Dialog>
</>
{/* New Project Dialog (mounted outside the DropdownMenu so it survives
the menu closing when "New project…" is selected) */}
<NewProjectDialog
thread={thread}
open={newProjectDialogOpen}
onOpenChange={setNewProjectDialogOpen}
/>
</SidebarMenuItem>
);
}
export function RecentChatList() {
const { t } = useI18n();
const pathname = usePathname();
const { thread_id: threadIdFromPath } = useParams<{
thread_id: string;
agent_name?: string;
}>();
const [settings] = useLocalSettings();
const groupByProject = settings.projectsDisplayMode === "grouped";
// Static-demo mode never renders project groups (ProjectsSection is hidden
// and project queries stay off), so a persisted "grouped" preference must
// behave as flat — otherwise assigned threads would vanish from this list.
const grouped = groupByProject && !isStaticWebsiteOnly();
const {
data: infiniteThreads,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteThreads({
archived:
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? undefined : false,
});
// Project discovery doubles as the exclusion oracle for grouped mode: an
// assigned thread may leave this flat list ONLY when its project group can
// actually render in `ProjectsSection`. Any query error (or not-yet-loaded
// data) yields `null` → exclude nothing (fail-visible), and a thread whose
// project id is unknown to both lists stays here too. TanStack dedupes
// these shared queries with `GroupedProjectList`.
// The discovery queries only feed that grouped-mode filter; in the default
// flat mode the results are read by nobody, so keep the two project round
// trips off the page load (`GroupedProjectList` fetches these same keys
// when grouped mode is on, and TanStack dedupes the observers).
const activeProjectsQuery = useProjects("active", { enabled: grouped });
const archivedProjectsQuery = useProjects("archived", { enabled: grouped });
const knownProjectIds = useMemo(() => {
const activeProjects = activeProjectsQuery.data;
const archivedProjects = archivedProjectsQuery.data;
if (
activeProjectsQuery.isError ||
archivedProjectsQuery.isError ||
!activeProjects ||
!archivedProjects
) {
return null;
}
return new Set(
[...activeProjects, ...archivedProjects].map((project) => project.id),
);
}, [
activeProjectsQuery.data,
activeProjectsQuery.isError,
archivedProjectsQuery.data,
archivedProjectsQuery.isError,
]);
const threadListModel = useMemo(
() => buildThreadListModel(infiniteThreads?.pages ?? []),
[infiniteThreads?.pages],
);
const { threads } = threadListModel;
const displayedThreads = useMemo(() => {
if (
!threadIdFromPath ||
threadListModel.displayedThreads.some(
(thread) => thread.thread_id === threadIdFromPath,
)
) {
return threadListModel.displayedThreads;
}
const activeThread = threadListModel.byId.get(threadIdFromPath);
return activeThread
? [...threadListModel.displayedThreads, activeThread]
: threadListModel.displayedThreads;
}, [threadIdFromPath, threadListModel]);
// In grouped mode, project-assigned threads render under their project
// header in `ProjectsSection`; this list keeps only unassigned threads —
// plus any assigned thread whose project cannot render a group (unknown id,
// or project discovery failed/loading), so no chat silently vanishes.
const visibleThreads = useMemo(
() =>
grouped && knownProjectIds
? displayedThreads.filter((thread) => {
const projectId = projectIdOfThread(thread);
return projectId === null || !knownProjectIds.has(projectId);
})
: displayedThreads,
[grouped, displayedThreads, knownProjectIds],
);
const branchList = useMemo(() => {
const entries = flattenThreadBranches(visibleThreads);
return {
entriesById: new Map(
entries.map((entry) => [entry.thread.thread_id, entry]),
),
threads: entries.map((entry) => entry.thread),
};
}, [visibleThreads]);
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const element = sentinelRef.current;
if (!element || !hasNextPage || !threadListModel.canLoadMore) {
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting && hasNextPage && !isFetchingNextPage) {
void fetchNextPage();
}
},
{ rootMargin: "120px 0px 120px 0px" },
);
observer.observe(element);
return () => observer.disconnect();
}, [
fetchNextPage,
hasNextPage,
isFetchingNextPage,
threadListModel.canLoadMore,
]);
// Grouped mode moves project threads under `ProjectsSection`, so this list
// can be empty while older pages (possibly holding unassigned threads) still
// exist on the server. Keep the pagination controls mounted then; flat mode
// keeps the original "empty list renders nothing" behavior.
if (
visibleThreads.length === 0 &&
!(grouped && hasNextPage && threadListModel.canLoadMore)
) {
return null;
}
return (
<SidebarGroup>
<SidebarGroupLabel>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
? t.sidebar.recentChats
: t.sidebar.demoChats}
</SidebarGroupLabel>
<SidebarGroupContent className="group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0">
<SidebarMenu>
{/* Keep pagination at the old list boundary when this switches to virtual rows. */}
<div
className="flex w-full flex-col gap-1"
style={{ overflowAnchor: "none" }}
>
<VirtualThreadList
estimateSize={36}
gap={4}
items={branchList.threads}
scrollParentSelector='[data-sidebar="content"]'
renderItem={(thread) => (
<ThreadSidebarItem
key={thread.thread_id}
thread={thread}
isActive={pathOfThread(thread) === pathname}
branchEntry={branchList.entriesById.get(thread.thread_id)}
recentThreadId={threads[0]?.thread_id}
/>
)}
/>
{hasNextPage && threadListModel.canLoadMore && (
<>
<Button
variant="ghost"
size="sm"
className="mx-2 my-1 w-[calc(100%-1rem)] justify-center text-xs"
onClick={() => void fetchNextPage()}
disabled={isFetchingNextPage}
data-testid="recent-chat-list-load-more"
>
{isFetchingNextPage
? t.chats.loadingMore
: t.chats.loadOlderChats}
</Button>
<div
ref={sentinelRef}
aria-hidden="true"
className="h-px w-full"
data-testid="recent-chat-list-sentinel"
/>
</>
)}
</div>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}

View File

@ -9,7 +9,11 @@ import {
type ReactNode,
} from "react";
import type { AgentThread } from "@/core/threads/types";
/**
* Minimal row shape the virtualizer keys on; list items only need a stable
* ``thread_id`` (sidebar rows, /workspace/chats rows, project page rows).
*/
type ThreadListRow = { thread_id: string };
const VIRTUALIZATION_THRESHOLD = 60;
@ -21,7 +25,7 @@ export function calculateScrollMargin(
return Math.max(0, rootTop - scrollParentTop + scrollTop);
}
export function VirtualThreadList({
export function VirtualThreadList<T extends ThreadListRow>({
estimateSize,
gap = 0,
items,
@ -30,8 +34,8 @@ export function VirtualThreadList({
}: {
estimateSize: number;
gap?: number;
items: readonly AgentThread[];
renderItem: (thread: AgentThread, index: number) => ReactNode;
items: readonly T[];
renderItem: (item: T, index: number) => ReactNode;
scrollParentSelector: string;
}) {
const rootRef = useRef<HTMLDivElement | null>(null);
@ -44,13 +48,26 @@ export function VirtualThreadList({
const root = rootRef.current;
const scrollParent = getScrollElement();
if (!root || !scrollParent) return;
setScrollMargin(
calculateScrollMargin(
root.getBoundingClientRect().top,
scrollParent.getBoundingClientRect().top,
scrollParent.scrollTop,
),
);
const measure = () => {
setScrollMargin(
calculateScrollMargin(
root.getBoundingClientRect().top,
scrollParent.getBoundingClientRect().top,
scrollParent.scrollTop,
),
);
};
measure();
// Sidebar sections above the list (project groups, archived section)
// resize without changing items.length, shifting the list's offset.
// Observe the scroll parent and its children so the margin is
// recomputed whenever any of them changes size.
const observer = new ResizeObserver(measure);
observer.observe(scrollParent);
for (const child of scrollParent.children) {
observer.observe(child);
}
return () => observer.disconnect();
}, [getScrollElement, items.length]);
const virtualizer = useVirtualizer({
count: items.length,

View File

@ -5,30 +5,27 @@ import { useArchiveThread } from "@/core/threads/archive";
export function useThreadArchiveAction() {
const { t } = useI18n();
const mutation = useArchiveThread();
const mutation = useArchiveThread({
onSuccess(_data, { threadId, archived }) {
if (archived) {
toast.success(t.chats.archiveSuccess, {
description: t.chats.archiveDescription,
action: {
label: t.chats.undoArchive,
onClick: () => setArchived(threadId, false),
},
});
} else {
toast.success(t.chats.restoreSuccess);
}
},
onError() {
toast.error(t.chats.archiveFailed);
},
});
function setArchived(threadId: string, archived: boolean) {
mutation.mutate(
{ threadId, archived },
{
onSuccess() {
if (archived) {
toast.success(t.chats.archiveSuccess, {
description: t.chats.archiveDescription,
action: {
label: t.chats.undoArchive,
onClick: () => setArchived(threadId, false),
},
});
} else {
toast.success(t.chats.restoreSuccess);
}
},
onError() {
toast.error(t.chats.archiveFailed);
},
},
);
mutation.mutate({ threadId, archived });
}
return { setArchived, isPending: mutation.isPending };

View File

@ -18,6 +18,13 @@ import { cn } from "@/lib/utils";
import { GithubIcon } from "./github-icon";
import { Tooltip } from "./tooltip";
// Workspace sections that have an index route (/workspace/<section>/page.tsx)
// and can therefore be linked to from the breadcrumb.
const LINKABLE_SECTIONS: Record<string, true> = {
agents: true,
chats: true,
"scheduled-tasks": true,
};
export function WorkspaceContainer({
className,
@ -69,7 +76,7 @@ export function WorkspaceHeader({
<>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
{segments.length >= 2 ? (
{segments[1] && LINKABLE_SECTIONS[segments[1]] ? (
<BreadcrumbLink asChild>
<Link href={`/${segments[0]}/${segments[1]}`}>
{nameOfSegment(segments[1], t)}

View File

@ -10,6 +10,7 @@ import {
} from "@/components/ui/sidebar";
import { WorkspaceChannelsList } from "./channels/workspace-channels-list";
import { ProjectsSection } from "./projects-section";
import { RecentChatList } from "./recent-chat-list";
import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
@ -28,7 +29,12 @@ export function WorkspaceSidebar({
<SidebarContent>
<WorkspaceNavChatList />
<WorkspaceChannelsList />
{isSidebarOpen && <RecentChatList />}
{isSidebarOpen && (
<>
<ProjectsSection />
<RecentChatList />
</>
)}
</SidebarContent>
<SidebarFooter>
<WorkspaceNavMenu />

View File

@ -313,6 +313,39 @@ export const enUS: Translations = {
scheduledTasks: "Scheduled tasks",
agentsDisabledTooltip: "Feature not enabled",
},
// Sidebar projects section
projects: {
title: "Projects",
newProject: "New project",
namePlaceholder: "Project name",
moveToProject: "Move to project",
moveToProjectHint:
"Moving a chat doesn't remove the content already in it.",
removeFromProject: "Remove from project",
archive: "Archive",
restore: "Restore",
deleteProject: "Delete project",
deleteProjectConfirm:
"Deleting this project unlinks its chats. Chats, their history, and their files are not deleted.",
archived: "Archived",
empty: "No chats in this project yet.",
newChat: "New chat",
create: "Create",
createFailed: "Failed to create project",
moveFailed: "Failed to move chat",
archiveFailed: "Failed to archive project",
restoreFailed: "Failed to restore project",
deleteFailed: "Failed to delete project",
switchToGrouped: "Group chats by project",
switchToFlat: "Show flat chat list",
threads: "Chats",
threadsLoadFailed: "Couldn't load project chats",
untitled: "Untitled",
settings: "Settings",
notFound: "Project not found or deleted.",
projectUnavailable:
"Couldn't link the chat to the project. Your message was not sent — try again.",
},
backgroundTasks: {
label: "Background tasks",

View File

@ -236,6 +236,38 @@ export interface Translations {
agentsDisabledTooltip: string;
channels: string;
};
// Sidebar projects section
projects: {
title: string;
newProject: string;
namePlaceholder: string;
moveToProject: string;
moveToProjectHint: string;
removeFromProject: string;
archive: string;
restore: string;
deleteProject: string;
deleteProjectConfirm: string;
archived: string;
empty: string;
newChat: string;
// Runtime states and actions
create: string;
createFailed: string;
moveFailed: string;
archiveFailed: string;
restoreFailed: string;
deleteFailed: string;
switchToGrouped: string;
switchToFlat: string;
// Project page
threads: string;
threadsLoadFailed: string;
untitled: string;
settings: string;
notFound: string;
projectUnavailable: string;
};
// Thread-scoped MCP background tasks
backgroundTasks: {

View File

@ -296,6 +296,37 @@ export const zhCN: Translations = {
scheduledTasks: "定时任务",
agentsDisabledTooltip: "功能未启用",
},
// Sidebar projects section
projects: {
title: "项目",
newProject: "新建项目",
namePlaceholder: "项目名称",
moveToProject: "移动到项目",
moveToProjectHint: "移动对话不会移除其中已有的内容。",
removeFromProject: "移出项目",
archive: "归档",
restore: "恢复",
deleteProject: "删除项目",
deleteProjectConfirm:
"删除项目将解除其对话的关联。对话、历史记录及文件均不会被删除。",
archived: "已归档",
empty: "该项目下还没有对话。",
newChat: "新建对话",
create: "创建",
createFailed: "创建项目失败",
moveFailed: "移动对话失败",
archiveFailed: "归档项目失败",
restoreFailed: "恢复项目失败",
deleteFailed: "删除项目失败",
switchToGrouped: "按项目分组对话",
switchToFlat: "显示平铺对话列表",
threads: "对话",
threadsLoadFailed: "无法加载项目对话",
untitled: "未命名",
settings: "设置",
notFound: "项目不存在或已被删除。",
projectUnavailable: "无法关联到该项目,消息未发送。请重试。",
},
backgroundTasks: {
label: "后台任务",

View File

@ -0,0 +1,188 @@
import { fetch as fetchWithAuth } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
import type {
Project,
ProjectCreateInput,
ProjectPatchInput,
ProjectStatus,
ProjectThread,
} from "./types";
export type ProjectListResponse = {
projects: Project[];
};
export const PROJECTS_QUERY_KEY = ["projects"] as const;
async function readProjectAPIError(
response: Response,
fallback: string,
): Promise<string> {
try {
const body = (await response.json()) as { detail?: unknown };
if (typeof body.detail === "string" && body.detail) {
return body.detail;
}
} catch {
// Fall through to the caller-provided message.
}
return fallback;
}
function projectUrl(projectId: string, suffix = ""): string {
return `${getBackendBaseURL()}/api/projects/${encodeURIComponent(projectId)}${suffix}`;
}
export async function listProjects(status?: ProjectStatus): Promise<Project[]> {
const response = await fetchWithAuth(
`${getBackendBaseURL()}/api/projects${status ? `?status=${encodeURIComponent(status)}` : ""}`,
{
method: "GET",
},
);
if (!response.ok) {
throw new Error(
await readProjectAPIError(response, "Failed to load projects."),
);
}
const body = (await response.json()) as ProjectListResponse;
return body.projects;
}
export async function getProject(projectId: string): Promise<Project> {
const response = await fetchWithAuth(projectUrl(projectId), {
method: "GET",
});
if (!response.ok) {
throw new Error(
await readProjectAPIError(response, "Failed to load project."),
);
}
return (await response.json()) as Project;
}
export async function createProject(
input: ProjectCreateInput,
): Promise<Project> {
const response = await fetchWithAuth(`${getBackendBaseURL()}/api/projects`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: input.name,
instructions: input.instructions ?? "",
presentation: input.presentation ?? {},
}),
});
if (!response.ok) {
throw new Error(
await readProjectAPIError(response, "Failed to create project."),
);
}
return (await response.json()) as Project;
}
export async function patchProject(
projectId: string,
input: ProjectPatchInput,
): Promise<Project> {
const response = await fetchWithAuth(projectUrl(projectId), {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...(input.name !== undefined ? { name: input.name } : {}),
...(input.instructions !== undefined
? { instructions: input.instructions }
: {}),
...(input.presentation !== undefined
? { presentation: input.presentation }
: {}),
}),
});
if (!response.ok) {
throw new Error(
await readProjectAPIError(response, "Failed to update project."),
);
}
return (await response.json()) as Project;
}
export async function archiveProject(projectId: string): Promise<Project> {
const response = await fetchWithAuth(projectUrl(projectId, "/archive"), {
method: "POST",
});
if (!response.ok) {
throw new Error(
await readProjectAPIError(response, "Failed to archive project."),
);
}
return (await response.json()) as Project;
}
export async function restoreProject(projectId: string): Promise<Project> {
const response = await fetchWithAuth(projectUrl(projectId, "/restore"), {
method: "POST",
});
if (!response.ok) {
throw new Error(
await readProjectAPIError(response, "Failed to restore project."),
);
}
return (await response.json()) as Project;
}
export async function deleteProject(projectId: string): Promise<void> {
const response = await fetchWithAuth(projectUrl(projectId), {
method: "DELETE",
});
if (!response.ok) {
throw new Error(
await readProjectAPIError(response, "Failed to delete project."),
);
}
}
export async function listProjectThreads(
projectId: string,
params: { limit?: number; offset?: number } = {},
): Promise<ProjectThread[]> {
const search = new URLSearchParams();
if (params.limit !== undefined) {
search.set("limit", String(params.limit));
}
if (params.offset !== undefined) {
search.set("offset", String(params.offset));
}
const query = search.size > 0 ? `?${search.toString()}` : "";
const response = await fetchWithAuth(
projectUrl(projectId, `/threads${query}`),
{
method: "GET",
},
);
if (!response.ok) {
throw new Error(
await readProjectAPIError(response, "Failed to load project threads."),
);
}
return (await response.json()) as ProjectThread[];
}

View File

@ -0,0 +1,168 @@
import {
type InfiniteData,
type QueryClient,
type UseInfiniteQueryResult,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { isStaticWebsiteOnly } from "../static-mode";
import { INFINITE_THREADS_QUERY_KEY_PREFIX } from "../threads/hooks";
import {
archiveProject,
createProject,
deleteProject,
getProject,
listProjects,
listProjectThreads,
patchProject,
PROJECTS_QUERY_KEY,
restoreProject,
} from "./api";
import type {
Project,
ProjectCreateInput,
ProjectPatchInput,
ProjectStatus,
ProjectThread,
} from "./types";
/**
* Invalidate project queries. Archive/restore/delete change how member threads
* group in the sidebar, so those mutations also invalidate the infinite threads
* search cache via ``includeThreads``.
*/
function invalidateProjectCaches(
queryClient: QueryClient,
{ includeThreads = false }: { includeThreads?: boolean } = {},
) {
void queryClient.invalidateQueries({ queryKey: PROJECTS_QUERY_KEY });
if (includeThreads) {
void queryClient.invalidateQueries({
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
});
}
}
export function useProjects(
status?: ProjectStatus,
{ enabled = true }: { enabled?: boolean } = {},
) {
return useQuery<Project[]>({
queryKey: [...PROJECTS_QUERY_KEY, { status }],
queryFn: () => listProjects(status),
// Static-demo mode has no Gateway; never fire project requests there.
enabled: enabled && !isStaticWebsiteOnly(),
});
}
export function useProject(id: string) {
return useQuery<Project>({
queryKey: [...PROJECTS_QUERY_KEY, "detail", id],
queryFn: () => getProject(id),
// A deleted or foreign project 404s deterministically and the page has a
// dedicated not-found state for it; do not spend the default retry
// backoff (~7s) in "loading" first. Matches useThreadMetadata /
// useThreadTokenUsage.
retry: false,
enabled: !isStaticWebsiteOnly(),
});
}
export const PROJECT_THREADS_PAGE_SIZE = 100;
/**
* Project-keyed infinite thread list. Keying the accumulation on the project
* id fences pagination: an older-page response can only land in the query it
* was issued for, and a first-page invalidation refetches through TanStack
* instead of racing manual offset state.
*/
export function useInfiniteProjectThreads(
id: string,
{ enabled = true }: { enabled?: boolean } = {},
) {
return useInfiniteQuery<
ProjectThread[],
Error,
InfiniteData<ProjectThread[]>,
readonly unknown[],
number
>({
queryKey: [...PROJECTS_QUERY_KEY, "threads", id],
initialPageParam: 0,
queryFn: ({ pageParam }) =>
listProjectThreads(id, {
limit: PROJECT_THREADS_PAGE_SIZE,
offset: pageParam,
}),
getNextPageParam: (lastPage, allPages) =>
lastPage.length === PROJECT_THREADS_PAGE_SIZE
? allPages.reduce((total, page) => total + page.length, 0)
: undefined,
enabled: enabled && !isStaticWebsiteOnly(),
});
}
export type ProjectThreadsQueryResult = UseInfiniteQueryResult<
InfiniteData<ProjectThread[]>,
Error
>;
export function useCreateProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: ProjectCreateInput) => createProject(input),
onSettled() {
invalidateProjectCaches(queryClient);
},
});
}
export function usePatchProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
projectId,
input,
}: {
projectId: string;
input: ProjectPatchInput;
}) => patchProject(projectId, input),
onSettled() {
invalidateProjectCaches(queryClient);
},
});
}
export function useArchiveProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (projectId: string) => archiveProject(projectId),
onSettled() {
invalidateProjectCaches(queryClient, { includeThreads: true });
},
});
}
export function useRestoreProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (projectId: string) => restoreProject(projectId),
onSettled() {
invalidateProjectCaches(queryClient, { includeThreads: true });
},
});
}
export function useDeleteProject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (projectId: string) => deleteProject(projectId),
onSettled() {
invalidateProjectCaches(queryClient, { includeThreads: true });
},
});
}

View File

@ -0,0 +1,3 @@
export * from "./api";
export * from "./hooks";
export * from "./types";

View File

@ -0,0 +1,41 @@
export type ProjectStatus = "active" | "archived";
export type ProjectPresentation = {
icon?: string;
color?: string;
};
export type Project = {
id: string;
name: string;
instructions: string;
presentation: ProjectPresentation;
status: ProjectStatus;
created_at: string;
updated_at: string;
};
export type ProjectCreateInput = {
name: string;
instructions?: string;
presentation?: ProjectPresentation;
};
export type ProjectPatchInput = {
name?: string;
instructions?: string;
presentation?: ProjectPresentation;
};
/**
* A thread row as returned by ``GET /api/projects/{id}/threads``: the thread
* metadata store's search shape (``metadata`` carries
* ``deerflow_project_id``; ``display_name`` is the wire title).
*/
export type ProjectThread = {
thread_id: string;
display_name?: string | null;
metadata?: Record<string, unknown> | null;
created_at?: string;
updated_at?: string;
};

View File

@ -5,6 +5,7 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
notification: {
enabled: true,
},
projectsDisplayMode: "flat",
tokenUsage: {
headerTotal: true,
inlineMode: "per_turn",
@ -66,6 +67,7 @@ export interface LocalSettings {
notification: {
enabled: boolean;
};
projectsDisplayMode: "flat" | "grouped";
tokenUsage: {
headerTotal: boolean;
inlineMode: TokenUsageInlineMode;
@ -100,6 +102,9 @@ function mergeLocalSettings(settings?: Partial<LocalSettings>): LocalSettings {
...DEFAULT_LOCAL_SETTINGS.notification,
...settings?.notification,
},
projectsDisplayMode:
settings?.projectsDisplayMode ??
DEFAULT_LOCAL_SETTINGS.projectsDisplayMode,
};
}

View File

@ -52,13 +52,25 @@ function mergeSettingsSection<K extends keyof LocalSettings>(
key: K,
value: Partial<LocalSettings[K]>,
): LocalSettings {
const current = settings[key];
if (
current !== null &&
typeof current === "object" &&
value !== null &&
typeof value === "object"
) {
return {
...settings,
[key]: {
...current,
...value,
},
} as LocalSettings;
}
return {
...settings,
[key]: {
...settings[key],
...value,
},
} as LocalSettings;
[key]: value,
};
}
function handleStorage(event: StorageEvent) {

View File

@ -134,6 +134,54 @@ export async function patchThreadMetadata(
return (await response.json()) as ThreadMetadataPatchResponse;
}
export async function createThread(
threadId: string,
projectId?: string,
): Promise<AgentThread> {
const response = await fetchWithAuth(`${getBackendBaseURL()}/api/threads`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
thread_id: threadId,
...(projectId ? { project_id: projectId } : {}),
}),
});
if (!response.ok) {
throw new Error(
await readThreadAPIError(response, "Failed to create conversation."),
);
}
return (await response.json()) as AgentThread;
}
export async function moveThreadToProject(
threadId: string,
projectId: string | null,
): Promise<ThreadMetadataPatchResponse> {
const response = await fetchWithAuth(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/move`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ project_id: projectId }),
},
);
if (!response.ok) {
throw new Error(
await readThreadAPIError(response, "Failed to move conversation."),
);
}
return (await response.json()) as ThreadMetadataPatchResponse;
}
export async function compactThreadContext(
threadId: string,
options: CompactThreadContextOptions = {},

View File

@ -1,25 +1,38 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { patchThreadMetadata } from "./api";
import { PROJECTS_QUERY_KEY } from "../projects/api";
import { patchThreadMetadata, type ThreadMetadataPatchResponse } from "./api";
import {
INFINITE_THREADS_QUERY_KEY_PREFIX,
setThreadMetadataInCaches,
} from "./hooks";
import { THREAD_ARCHIVED_METADATA_KEY } from "./utils";
export function useArchiveThread() {
export type ArchiveThreadVariables = {
threadId: string;
archived: boolean;
};
export type ArchiveThreadOptions = {
onSuccess?: (
data: ThreadMetadataPatchResponse,
variables: ArchiveThreadVariables,
) => void;
onError?: (error: Error, variables: ArchiveThreadVariables) => void;
};
export function useArchiveThread(options: ArchiveThreadOptions = {}) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
threadId,
archived,
}: {
threadId: string;
archived: boolean;
}) =>
mutationFn: ({ threadId, archived }: ArchiveThreadVariables) =>
patchThreadMetadata(threadId, {
[THREAD_ARCHIVED_METADATA_KEY]: archived,
}),
// The callback is registered at the mutation level, not per `mutate` call:
// per-call handlers are dropped when the originating row unmounts (the
// archived chat leaves the sidebar list mid-flight), which silently lost
// the success toast before.
async onSuccess(_response, { threadId, archived }) {
// A response started before the write must not put the old state back.
await Promise.all([
@ -44,7 +57,18 @@ export function useArchiveThread() {
queryClient.invalidateQueries({
queryKey: ["thread", "metadata", threadId],
}),
// The project page's own thread list
// ([...PROJECTS_QUERY_KEY, "threads", id, ...]) changes membership
// with the archive flag; every other thread mutation (pin, rename,
// delete, move, stop) invalidates this prefix, so archive must too.
queryClient.invalidateQueries({
queryKey: [...PROJECTS_QUERY_KEY, "threads"],
}),
]);
options.onSuccess?.(_response, { threadId, archived });
},
onError: (error, variables) => {
options.onError?.(error, variables);
},
});
}

View File

@ -26,6 +26,7 @@ import {
isHiddenFromUIMessage,
} from "../messages/utils";
import type { FileInMessage } from "../messages/utils";
import { PROJECTS_QUERY_KEY } from "../projects/api";
import type { LocalSettings } from "../settings";
import { isSidecarThread, SIDECAR_METADATA_KEY } from "../sidecar/thread";
import { useSubtaskContext, useUpdateSubtask } from "../tasks/context";
@ -37,6 +38,7 @@ import { promptInputFilePartToFile, uploadFiles } from "../uploads";
import {
branchThreadFromTurn,
fetchThreadTokenUsage,
moveThreadToProject,
patchThreadMetadata,
searchThreadsByArchive,
type ThreadMetadataPatch,
@ -61,7 +63,10 @@ import type {
RunMessage,
ThreadTokenUsageResponse,
} from "./types";
import { THREAD_PINNED_METADATA_KEY } from "./utils";
import {
THREAD_PINNED_METADATA_KEY,
THREAD_PROJECT_METADATA_KEY,
} from "./utils";
export type ThreadStreamOptions = {
threadId?: string | null | undefined;
@ -1504,6 +1509,11 @@ export function invalidateStoppedThreadCaches(
void queryClient.invalidateQueries({
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
});
// A finished run updates the title/recency the project page thread list
// shows ([...PROJECTS_QUERY_KEY, "threads", id, ...]).
void queryClient.invalidateQueries({
queryKey: [...PROJECTS_QUERY_KEY, "threads"],
});
if (!threadId || isMock) {
return;
@ -3146,6 +3156,58 @@ export function usePinThread() {
void queryClient.invalidateQueries({
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
});
// Pin changes the ordering the project page thread list shows
// ([...PROJECTS_QUERY_KEY, "threads", id, ...]); without this, cached
// pages keep the old order and pagination can duplicate or skip
// entries across the refetch boundary.
void queryClient.invalidateQueries({
queryKey: [...PROJECTS_QUERY_KEY, "threads"],
});
},
});
}
export function useMoveThreadToProject(options?: {
onError?: (
error: Error,
variables: { threadId: string; projectId: string | null },
) => void;
}) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
threadId,
projectId,
}: {
threadId: string;
projectId: string | null;
}) => moveThreadToProject(threadId, projectId),
// Hook-level error handler: survives the caller's dropdown unmounting,
// unlike a per-mutate `onError` passed from inside a closing menu.
onError: options?.onError,
async onSuccess(_response, { threadId, projectId }) {
// An older GET must not overwrite the confirmed affiliation. Match all
// metadata variants, including an initial read with no cached snapshot.
await queryClient.cancelQueries({
queryKey: ["thread", "metadata", threadId],
});
setThreadMetadataInCaches(queryClient, threadId, {
[THREAD_PROJECT_METADATA_KEY]: projectId,
});
await queryClient.invalidateQueries({
queryKey: ["thread", "metadata", threadId],
});
},
onSettled() {
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
void queryClient.invalidateQueries({
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
});
// Moving a thread changes membership of project thread lists
// ([...PROJECTS_QUERY_KEY, "threads", id, ...]).
void queryClient.invalidateQueries({
queryKey: [...PROJECTS_QUERY_KEY, "threads"],
});
},
});
}
@ -3328,6 +3390,11 @@ export function useDeleteThread() {
void queryClient.invalidateQueries({
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
});
// Deleting a thread changes membership of project thread lists
// ([...PROJECTS_QUERY_KEY, "threads", id, ...]).
void queryClient.invalidateQueries({
queryKey: [...PROJECTS_QUERY_KEY, "threads"],
});
},
});
}
@ -3360,6 +3427,11 @@ export function useRenameThread() {
for (const filter of filters) {
void queryClient.invalidateQueries(filter);
}
// The project page thread list is REST-shaped, not covered by
// setThreadTitleInCaches; invalidate it so renamed titles refresh.
void queryClient.invalidateQueries({
queryKey: [...PROJECTS_QUERY_KEY, "threads"],
});
},
});
}

View File

@ -13,6 +13,11 @@ export function isThreadArchived(thread: Pick<AgentThread, "metadata">) {
return thread.metadata?.[THREAD_ARCHIVED_METADATA_KEY] === true;
}
// Reserved metadata key recording a thread's project membership
// (``metadata.deerflow_project_id``). Keep in sync with the backend
// thread_meta constant and the E2E mock-api constant.
export const THREAD_PROJECT_METADATA_KEY = "deerflow_project_id";
export type ChannelThreadSource = {
type: "im_channel";
provider: string;
@ -75,6 +80,15 @@ export function isThreadPinned(thread: Pick<AgentThread, "metadata">) {
return thread.metadata?.[THREAD_PINNED_METADATA_KEY] === true;
}
export function projectIdOfThread(
thread: Pick<AgentThread, "metadata">,
): string | null {
const projectId = thread.metadata?.[THREAD_PROJECT_METADATA_KEY];
return typeof projectId === "string" && projectId.length > 0
? projectId
: null;
}
export function sortPinnedThreads<T extends Pick<AgentThread, "metadata">>(
threads: readonly T[],
) {

View File

@ -1,6 +1,137 @@
import { expect, test, type Route } from "@playwright/test";
import { expect, test, type Page, type Route } from "@playwright/test";
import { handleRunStream, mockLangGraphAPI } from "./utils/mock-api";
import {
handleRunStream,
MOCK_THREAD_ID,
mockLangGraphAPI,
} from "./utils/mock-api";
test.describe("Project-scoped submit staleness", () => {
test.beforeEach(async ({ page }) => {
// A settled conversation to switch to mid-submit. Seeded at setup (not
// created during the test): opening a session-created thread page in the
// mock races the empty-thread redirect, while a setup-seeded thread is
// the stable pattern other specs rely on.
mockLangGraphAPI(page, {
threads: [
{
thread_id: MOCK_THREAD_ID,
title: "Settled chat",
updated_at: "2025-06-01T12:00:00Z",
},
],
});
});
function holdThreadCreate(page: Page) {
let releaseCreate!: () => void;
let markIntercepted!: () => void;
const intercepted = new Promise<void>((resolve) => {
markIntercepted = resolve;
});
const createHeld = new Promise<void>((resolve) => {
releaseCreate = resolve;
});
void page.route("**/api/threads", async (route) => {
if (route.request().method() === "POST") {
markIntercepted();
await createHeld;
}
return route.fallback();
});
return { intercepted, releaseCreate };
}
test("switching conversations during goal preparation drops the stale continuation", async ({
page,
}) => {
// Regression: the goal PUT registers its AbortController only after the
// project pre-create resolves, so the thread-change cleanup cannot abort
// an in-flight prepare. Navigating away while preparation is pending
// must drop the continuation — no goal save, composer clear, or
// abandoned run may touch the newly opened conversation.
const textarea = page.getByPlaceholder(/how can i assist you/i);
const settledChat = page.getByRole("link", {
name: "Settled chat",
exact: true,
});
const goalPuts: string[] = [];
const runStreams: string[] = [];
page.on("request", (request) => {
const url = request.url();
if (
request.method() === "PUT" &&
/\/api\/threads\/[^/]+\/goal$/.test(url)
) {
goalPuts.push(url);
}
if (request.method() === "POST" && url.includes("/runs/stream")) {
runStreams.push(url);
}
});
const { intercepted, releaseCreate } = holdThreadCreate(page);
await page.goto("/workspace/chats/new?project=proj-1");
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("/goal finish all tests");
await textarea.press("Enter");
// The project pre-create must actually be in flight before we navigate:
// releasing a request that was never intercepted would make the
// assertions pass without exercising the stale continuation at all.
await intercepted;
// Switch conversations while the project pre-create is held.
await settledChat.click();
await expect(page).toHaveURL(new RegExp(`/chats/${MOCK_THREAD_ID}$`));
releaseCreate();
// Let any stale continuation run to completion before asserting: a goal
// PUT that fires late must still be caught.
await page.waitForTimeout(1500);
await expect(page.getByText("finish all tests")).toBeHidden();
await expect.poll(() => goalPuts.length).toBe(0);
await expect.poll(() => runStreams.length).toBe(0);
await expect(page).toHaveURL(new RegExp(`/chats/${MOCK_THREAD_ID}$`));
});
test("dropping the project scope mid-submission resets the thread identity", async ({
page,
}) => {
// Regression: the sidebar "New chat" link leaves /new?project=… for
// plain /new without a pathname change, so the thread identity and the
// submission fences keyed on `threadId` survive the navigation. The
// abandoned submission must not start a run against the previous
// scope's pre-created thread or rewrite the URL to it.
const textarea = page.getByPlaceholder(/how can i assist you/i);
const runStreams: string[] = [];
page.on("request", (request) => {
if (
request.method() === "POST" &&
request.url().includes("/runs/stream")
) {
runStreams.push(request.url());
}
});
const { intercepted, releaseCreate } = holdThreadCreate(page);
await page.goto("/workspace/chats/new?project=proj-1");
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("run inside the project");
await textarea.press("Enter");
// The project pre-create must be in flight before navigating away.
await intercepted;
await page.getByRole("link", { name: "New chat", exact: true }).click();
await expect(page).toHaveURL(/\/workspace\/chats\/new$/);
releaseCreate();
// The stale submission must not start a run for the previous scope's
// identity, nor rewrite the URL to it.
await page.waitForTimeout(1500);
await expect.poll(() => runStreams.length).toBe(0);
await expect(page).toHaveURL(/\/workspace\/chats\/new$/);
});
});
function textFromMessageContent(content: unknown) {
if (typeof content === "string") {
@ -686,6 +817,44 @@ test.describe("Chat workspace", () => {
await expect.poll(() => streamCalls).toBe(1);
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible();
});
test("goal command assigns the project before saving the goal", async ({
page,
}) => {
// Regression: the goal PUT endpoint materializes a missing thread row
// itself, so the project-scoped thread create must land first — an
// unassigned row would make the later idempotent createThread return it
// without assigning the requested project.
const events: string[] = [];
let createProjectId: string | null = null;
page.on("request", (request) => {
const url = request.url();
if (request.method() === "POST" && url.endsWith("/api/threads")) {
events.push("create-thread");
createProjectId =
(request.postDataJSON() as { project_id?: string } | null)
?.project_id ?? null;
}
if (
request.method() === "PUT" &&
/\/api\/threads\/[^/]+\/goal$/.test(url)
) {
events.push("save-goal");
}
});
await page.goto("/workspace/chats/new?project=proj-1");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("/goal finish all tests");
await textarea.press("Enter");
await expect(
page.locator("span.font-medium", { hasText: "finish all tests" }),
).toBeVisible();
expect(createProjectId).toBe("proj-1");
expect(events.slice(0, 2)).toEqual(["create-thread", "save-goal"]);
});
test("goal command keeps the welcome header clear of the goal status", async ({
page,

View File

@ -20,6 +20,11 @@ export const MOCK_RUN_ID = "00000000-0000-0000-0000-000000000099";
// constant; the mock must mirror the same metadata contract for pin ordering.
export const THREAD_PINNED_METADATA_KEY = "deerflow_pinned";
// Keep in sync with frontend runtime thread utils and the backend thread_meta
// constant; the mock must mirror the same metadata contract for project
// membership.
export const THREAD_PROJECT_METADATA_KEY = "deerflow_project_id";
const MOCK_AUTH_USER = {
id: "default",
email: "default@test.local",
@ -809,13 +814,40 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
const body = route.request().postDataJSON() as {
thread_id?: string;
metadata?: Record<string, unknown>;
project_id?: string;
};
const threadId = body.thread_id ?? MOCK_SIDECAR_THREAD_ID;
// The backend stamps `metadata.deerflow_project_id` from the assigned
// project_id column; mirror that so project membership is readable.
const metadata = {
...body.metadata,
...(body.project_id
? { [THREAD_PROJECT_METADATA_KEY]: body.project_id }
: {}),
};
// Mirror the backend idempotency contract: a repeat POST for an
// existing thread_id returns the record unchanged (goal and other
// state intact) instead of resetting it.
const existing = threads.find((thread) => thread.thread_id === threadId);
if (existing) {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
thread_id: threadId,
created_at: existing.updated_at ?? new Date().toISOString(),
updated_at: existing.updated_at ?? new Date().toISOString(),
metadata: existing.metadata ?? {},
status: "idle",
values: {},
}),
});
}
upsertThread({
thread_id: threadId,
title: "Side chat",
updated_at: new Date().toISOString(),
metadata: body.metadata ?? {},
metadata: metadata,
messages: [],
});
return route.fulfill({
@ -825,7 +857,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
thread_id: threadId,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
metadata: body.metadata ?? {},
metadata: metadata,
status: "idle",
values: {},
}),
@ -885,6 +917,46 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
return route.fallback();
});
// Projects API — Phase 1 default-empty mocks so existing specs are
// unaffected; project-aware specs register their own routes on top.
void page.route(/\/api\/projects(\?|$)/, (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ projects: [] }),
});
}
return route.fallback();
});
void page.route(/\/api\/threads\/[^/]+\/move$/, (route) => {
if (route.request().method() === "POST") {
const threadId = decodeURIComponent(
new URL(route.request().url()).pathname.split("/").at(-2) ?? "",
);
const body = route.request().postDataJSON() as {
project_id?: string | null;
};
const updated = patchThreadMetadata(threadId, {
[THREAD_PROJECT_METADATA_KEY]: body.project_id ?? null,
});
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)),
});
}
return route.fallback();
});
void page.route(/\/api\/threads\/[^/]+\/branches$/, (route) => {
if (route.request().method() === "POST") {
const pathParts = new URL(route.request().url()).pathname.split("/");

View File

@ -0,0 +1,102 @@
import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { cleanup, render } from "@testing-library/react";
import type { PropsWithChildren } from "react";
import { ProjectThreadsSection } from "@/components/workspace/projects/project-threads-section";
import { I18nProvider } from "@/core/i18n/context";
import type { ProjectThreadsQueryResult } from "@/core/projects";
import type { ProjectThread } from "@/core/projects/types";
// Keep the row links inert under happy-dom; only the href wiring matters.
rs.mock("next/link", () => {
const MockLink = ({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) => <a href={href}>{children}</a>;
return { default: MockLink };
});
function makeThread(id: string, title: string): ProjectThread {
return {
thread_id: id,
display_name: title,
metadata: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T00:00:00Z",
};
}
function makeQuery(threads: ProjectThread[]): ProjectThreadsQueryResult {
return {
data: { pages: [threads], pageParams: [0] },
isError: false,
isLoading: false,
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: rs.fn(),
} as unknown as ProjectThreadsQueryResult;
}
function Wrapper({ children }: PropsWithChildren) {
return <I18nProvider initialLocale="en-US">{children}</I18nProvider>;
}
afterEach(() => {
cleanup();
});
describe("ProjectThreadsSection", () => {
it("renders a divider on every row except the final one", () => {
const { container } = render(
<Wrapper>
<ProjectThreadsSection
query={makeQuery([
makeThread("t-1", "First"),
makeThread("t-2", "Second"),
makeThread("t-3", "Third"),
])}
/>
</Wrapper>,
);
const links = container.querySelectorAll<HTMLAnchorElement>("a");
expect(links).toHaveLength(3);
expect(links[0]?.href).toContain("/workspace/chats/t-1");
const rowDivs = [...links].map((link) => link.firstElementChild);
expect(rowDivs[0]?.classList.contains("border-b")).toBe(true);
expect(rowDivs[1]?.classList.contains("border-b")).toBe(true);
// Only the final data row drops the divider — the check must hold even
// when virtualization mounts just a window of rows.
expect(rowDivs[2]?.classList.contains("border-b")).toBe(false);
});
it("shows the untitled fallback and the load-more button for a partial page", () => {
const { container } = render(
<Wrapper>
<ProjectThreadsSection
query={
{
data: {
pages: [[makeThread("t-1", " ")]],
pageParams: [0],
},
isError: false,
isLoading: false,
hasNextPage: true,
isFetchingNextPage: false,
fetchNextPage: rs.fn(),
} as unknown as ProjectThreadsQueryResult
}
/>
</Wrapper>,
);
expect(container.textContent).toContain("Untitled");
expect(
container.querySelector('[data-testid="project-threads-load-more"]'),
).not.toBeNull();
});
});

View File

@ -10,6 +10,7 @@ import type { PropsWithChildren } from "react";
const mocks = rs.hoisted(() => ({ fetch: rs.fn() }));
rs.mock("@/core/api/fetcher", () => ({ fetch: mocks.fetch }));
import { PROJECTS_QUERY_KEY } from "@/core/projects/api";
import { useArchiveThread } from "@/core/threads/archive";
import { usePinThread } from "@/core/threads/hooks";
@ -164,3 +165,26 @@ test("a late pin response cannot roll back the confirmed archive flag", async ()
});
client.clear();
});
test("archive invalidates project-scoped thread lists on success", async () => {
mocks.fetch.mockResolvedValue(
new Response(JSON.stringify({ metadata: { deerflow_archived: true } })),
);
const { client, result } = setup();
const projectListKey = [...PROJECTS_QUERY_KEY, "threads", "proj-1"];
client.setQueryData(projectListKey, {
pages: [[{ ...original }]],
pageParams: [0],
});
const invalidate = rs.spyOn(client, "invalidateQueries");
await act(async () => {
await result.current.mutateAsync({ threadId: "chat", archived: true });
});
// The project page's thread list changes membership with the archive flag;
// every other thread mutation invalidates this prefix (regression: archive
// was the lone path leaving an open project page stale).
const keys = invalidate.mock.calls.map(([filters]) => filters?.queryKey);
expect(keys).toContainEqual([...PROJECTS_QUERY_KEY, "threads"]);
expect(client.getQueryState(projectListKey)?.isInvalidated).toBe(true);
client.clear();
});

View File

@ -5,6 +5,7 @@ import {
type InfiniteData,
} from "@tanstack/react-query";
import { PROJECTS_QUERY_KEY } from "@/core/projects/api";
import {
fetchInfiniteThreadsPage,
filterInfiniteThreadsCache,
@ -324,7 +325,7 @@ describe("invalidateStoppedThreadCaches", () => {
"thread-1",
false,
]);
expect(queryKeys()).toContainEqual(["thread-token-usage", "thread-1"]);
expect(queryKeys()).toContainEqual([...PROJECTS_QUERY_KEY, "threads"]);
});
test("preserves loaded history pages while invalidating", () => {
@ -360,7 +361,7 @@ describe("invalidateStoppedThreadCaches", () => {
"thread-1",
true,
]);
expect(queryKeys()).not.toContainEqual(["thread-token-usage", "thread-1"]);
expect(queryKeys()).toContainEqual([...PROJECTS_QUERY_KEY, "threads"]);
});
test("wraps SDK stop and refreshes caches after it resolves", async () => {

View File

@ -0,0 +1,112 @@
import { afterEach, expect, rs, test } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import type { PropsWithChildren } from "react";
const mocks = rs.hoisted(() => ({ fetch: rs.fn(), get: rs.fn() }));
rs.mock("@/core/api/fetcher", () => ({ fetch: mocks.fetch }));
rs.mock("@/core/api", () => ({
getAPIClient: () => ({ threads: { get: mocks.get } }),
}));
import {
useMoveThreadToProject,
useThreadMetadata,
} from "@/core/threads/hooks";
const original = {
thread_id: "chat",
metadata: { deerflow_project_id: "project-a", deerflow_pinned: true },
};
afterEach(() => {
cleanup();
rs.resetAllMocks();
});
test("move updates only affiliation and marks inactive metadata stale", async () => {
const client = new QueryClient();
const key = ["thread", "metadata", "chat", false];
client.setQueryData(key, original);
mocks.fetch.mockResolvedValue(
new Response(JSON.stringify({ metadata: { deerflow_pinned: false } })),
);
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
const { result, unmount } = renderHook(() => useMoveThreadToProject(), {
wrapper,
});
try {
await act(async () => {
await result.current.mutateAsync({
threadId: "chat",
projectId: "project-b",
});
});
expect(client.getQueryData(key)).toEqual({
...original,
metadata: { ...original.metadata, deerflow_project_id: "project-b" },
});
expect(client.getQueryState(key)?.isInvalidated).toBe(true);
} finally {
unmount();
client.clear();
}
});
for (const cached of [false, true]) {
for (const projectId of ["project-b", null]) {
test(`move to ${projectId} fences a delayed metadata read (cached: ${cached})`, async () => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const key = ["thread", "metadata", "chat", false];
if (cached) client.setQueryData(key, original);
const moved = {
...original,
metadata: { ...original.metadata, deerflow_project_id: projectId },
};
let finishOldRead!: (value: typeof original) => void;
const oldRead = new Promise<typeof original>((resolve) => {
finishOldRead = resolve;
});
mocks.get.mockReturnValueOnce(oldRead).mockResolvedValue(moved);
mocks.fetch.mockResolvedValue(new Response(JSON.stringify(moved)));
const wrapper = ({ children }: PropsWithChildren) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
const { result, unmount } = renderHook(
() => ({
metadata: useThreadMetadata("chat"),
move: useMoveThreadToProject(),
}),
{ wrapper },
);
try {
await waitFor(() => expect(mocks.get).toHaveBeenCalledTimes(1));
await act(async () => {
await result.current.move.mutateAsync({
threadId: "chat",
projectId,
});
});
await act(async () => {
finishOldRead(original);
await oldRead;
});
await waitFor(() => {
expect(result.current.metadata.data?.metadata).toEqual(
moved.metadata,
);
expect(result.current.metadata.isFetching).toBe(false);
});
expect(mocks.get).toHaveBeenCalledTimes(2);
expect(client.getQueryData(key)).toEqual(moved);
} finally {
unmount();
client.clear();
}
});
}
}