perf(frontend): bound delivery, bundles, and long-running UI work (#4622)

* docs: design frontend performance remediation

* docs: plan frontend performance remediation

* test(frontend): add route asset performance budgets

* perf(nginx): compress textual responses safely

* perf(frontend): lazy load case study media

* perf(frontend): bound static demo file tracing

* perf(frontend): restore static locale boundaries

* perf(frontend): defer closed workspace panels

* perf(frontend): split editors and deduplicate highlighting

* perf(frontend): index incremental message derivation

* perf(frontend): stabilize paged history cache policy

* perf(frontend): bound streaming markdown renders

* perf(frontend): virtualize message history

* perf(frontend): bound and virtualize chat lists

* perf(frontend): suspend inactive decorative animation

* perf(browser): stream latest frames as binary

* perf(artifacts): stream bounded text previews

* docs: finalize performance runtime boundaries

* style(backend): apply test formatting

* fix(frontend): keep translation functions client-side

* perf(frontend): defer decorative animation bundles

* test(frontend): lock optimized route budgets

* fix: harden frontend performance boundaries

* test(frontend): update i18n provider fixture

* fix(frontend): preserve sidebar pagination position

* style(backend): format artifact range test
This commit is contained in:
DanielWalnut 2026-08-01 22:19:59 +08:00 committed by GitHub
parent 5d8c4c2272
commit 459dd78707
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
105 changed files with 5148 additions and 1053 deletions

View File

@ -35,6 +35,8 @@ Nginx is the single public entry: it serves the frontend and proxies `/api/langg
to the Gateway's LangGraph runtime, rewriting it to Gateway's native `/api/*` routes; all
other `/api/*` go straight to the Gateway REST routers. See
[backend/AGENTS.md](backend/AGENTS.md) for the runtime and router detail.
It compresses HTML and configured textual assets, while deliberately leaving SSE,
fonts, images, audio, and video uncompressed at the proxy layer.
Both compose files publish that entry as `"${BIND_HOST:-127.0.0.1}:${PORT:-2026}:2026"`
**loopback by default**, matching the README's documented deployment model. A bare

View File

@ -222,6 +222,17 @@ This section accumulates work toward the **2.1.0** milestone
### Changed
- **frontend performance:** Keep the public root and localized docs static;
lazy-load closed workspace panels and editor/highlighter dependencies;
incrementally derive streamed message state; bound streaming Markdown work;
virtualize long message and chat lists; pause offscreen decorative effects;
and enforce representative route JS/CSS budgets.
- **browser:** Negotiate binary Browser Live JPEG frames, retain the legacy
JSON/base64 protocol for older clients, coalesce presentation to the latest
frame per refresh, and revoke replaced object URLs.
- **artifacts:** Stream regular text artifacts with HTTP byte-range support and
limit the initial Web UI preview to 1 MiB until the user explicitly loads the
complete file.
- **sandbox:** The Helm chart now defaults per-sandbox Services to `ClusterIP`
instead of `NodePort`, so the code-execution sandbox is reachable only inside
the cluster via Service DNS (`http://sandbox-<id>-svc.<ns>.svc.cluster.local`)

View File

@ -945,6 +945,12 @@ After each run, DeerFlow records a workspace change summary for the run-owned `w
Files presented through `present_files` remain part of the thread's artifact state, and the Web UI restores the artifact panel and selected document after a page refresh. The currently selected formal artifact is refreshed once when the run finishes so edits become visible without a manual reload. Existing UTF-8 text artifacts under `/mnt/user-data/outputs` can also be edited and explicitly saved from the panel on Unix and Windows while the thread is idle; saves use content revisions to prevent overwriting agent changes.
Text artifacts are streamed with HTTP byte-range support. The Web UI initially
loads at most 1 MiB, shows the preview size when a file is larger, and waits for
an explicit **Load full file** action before fetching the remainder or mounting
the full code editor. Active HTML, XHTML, and SVG artifacts remain forced
downloads at the Gateway boundary.
With `AioSandboxProvider`, shell execution runs inside isolated containers. With `LocalSandboxProvider`, file tools still map to per-thread directories on the host, but host `bash` is disabled by default because it is not a secure isolation boundary. Re-enable host bash only for fully trusted local workflows. Host bash commands have a wall-clock timeout, and long-lived processes should be started in the background with output redirected to a workspace log.
`AioSandboxProvider` normally detects thread-data mounts from its backend: local
@ -979,6 +985,11 @@ uv run playwright install chromium
Then uncomment the `group: browser` tool entries in `config.yaml` (`browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_get_text`, `browser_back`, `browser_screenshot`, `browser_close`). `make dev` / Docker startup detects an enabled `browser_navigate` tool and preserves the `browser` extra on dependency syncs. The Gateway fails startup if browser control is configured but Playwright is missing, and `/api/features` hides the Browser UI unless the backend can actually serve it. Keep `headless: true` and `allow_private_addresses: false` for anything but local, trusted debugging. Attaching to an existing Chrome with `cdp_url` cannot enforce DeerFlow's subresource/redirect SSRF guard and therefore fails closed unless `allow_unguarded_cdp: true` explicitly acknowledges that risk; use it only with a trusted local browser. Browser sessions are process-local; keep `GATEWAY_WORKERS=1` while this tool group is enabled because ordinary uvicorn worker dispatch does not provide thread affinity.
The workspace Browser Live client negotiates binary JPEG WebSocket frames,
keeps only the newest pending frame per display refresh, and revokes replaced
object URLs. Gateway control messages remain JSON, and clients that do not
request the binary capability retain the legacy JSON/base64 frame protocol.
### Context Engineering
**Isolated Sub-Agent Context**: Each sub-agent runs in its own isolated context. This means that the sub-agent will not be able to see the context of the main agent or other sub-agents. This is important to ensure that the sub-agent is able to focus on the task at hand and not be distracted by the context of the main agent or other sub-agents.
@ -1200,6 +1211,12 @@ The JSON includes compact review records with `priority`, `location`,
`blocking_call`, `event_loop_exposure`, `reason`, and `code`.
Gateway artifact serving now forces active web content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) to download as attachments instead of inline rendering, reducing XSS risk for generated artifacts.
Frontend route asset budgets can be checked with `cd frontend && pnpm
perf:check`. The command measures `/login` from a normal production build, then
performs a production static-demo build for the fixture-backed workspace routes.
It measures the unique JavaScript and CSS referenced by representative routes
and writes the detailed result to `.next/performance-results.json`.
## License
This project is open source and available under the [MIT License](./LICENSE).

View File

@ -309,6 +309,13 @@ Agentic browser sessions are process-local. The Gateway startup safety gate reje
uvicorn worker dispatch does not provide thread affinity for browser tools, REST
navigation, and the Live WebSocket.
Browser Live screenshots remain JPEG bytes inside the harness and the Gateway's
bounded, drop-oldest frame queue. WebSocket clients that request
`frame_format=binary` receive binary messages; control metadata remains JSON.
The legacy no-parameter protocol still base64-encodes frames into JSON at the
Gateway boundary for backward compatibility. Unknown `frame_format` values
receive a JSON error and close code 1008.
## Architecture
### Harness / App Split
@ -495,7 +502,7 @@ Localhost persistence deliberately reads the direct request `Host` and ignores `
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
| **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete |
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `POST /branches` - create a new main-thread branch from a completed assistant turn checkpoint and, when an addressable pre-user replay checkpoint exists, materialize it into the branch namespace so the inherited response remains regeneratable. Workspace files are not checkpointed, so the branch only best-effort copies the current workspace when branching from the **latest** turn (`workspace_clone_mode="current_thread_best_effort"`); branching from an older/historical turn skips the copy (`workspace_clone_mode="skipped_historical_turn"`) so the branch never inherits files that only exist in a later timeline. Thread-scoped runtime channels (`sandbox`, `thread_data`) are not copied onto the branch: the parent's `sandbox_id` binds path mappings and the release lifecycle to the parent's workspace, so the branch lazily acquires its own sandbox instead. Branch creation also seeds the new thread's run-event feed from the branch checkpoint's visible messages (`history_seed_mode` in the response): the thread feed reads run_events, not checkpoints, so without the seed the inherited history disappears from the UI after the branch's first run (#4380). Seeded rows are grouped into one synthetic run per inherited turn (`branch-seed-{thread_id}-{n}`, a new turn opening at every persisted human message, including an allowlisted hidden `ask_clarification` reply) because `run_id` is a turn identity to the feed's consumers, not a provenance tag: regenerating an inherited answer supersedes that row's whole `run_id` in `GET /messages/page`, so one shared id for the entire seed deleted the complete inherited history on a branch's first regenerate (#4458); `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; `POST /compact` - manually summarize older active context into `summary_text` and retain the recent message window, blocked while a run is in flight; unexpected failures are logged server-side and return a generic 500 detail |
| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types. `PUT /{path}` atomically replaces an existing UTF-8 text file under `/mnt/user-data/outputs` when its expected SHA-256 still matches; active runs conflict, and non-mounted sandbox providers receive the same update explicitly. Atomic replacement applies the existing POSIX permission handling when descriptor-based APIs are available and otherwise keeps the platform-native temporary-file permissions (Windows). |
| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - stream regular text and binary artifacts with `FileResponse`, including byte-`Range` 206/416 behavior used by bounded text previews and media seeking; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types. `PUT /{path}` atomically replaces an existing UTF-8 text file under `/mnt/user-data/outputs` when its expected SHA-256 still matches; active runs conflict, and non-mounted sandbox providers receive the same update explicitly. Atomic replacement applies the existing POSIX permission handling when descriptor-based APIs are available and otherwise keeps the platform-native temporary-file permissions (Windows). |
| **Suggestions** (`/api/suggestions`) | `GET /config` - returns global suggestions config boolean; `POST /threads/{id}/suggestions` - generate follow-up questions; rich list/block model content is normalized and inline reasoning (`<think>...</think>`, including unclosed/truncated blocks from reasoning models like MiniMax-M3) is stripped before JSON parsing |
| **Input Polish** (`/api/input-polish`) | `POST /` - rewrite a composer draft before it is sent. This is a short authenticated `runs:create` LLM request using `input_polish` config; it does not create a LangGraph run, persist a message, or modify thread state. Shares the non-graph one-shot LLM path (`deerflow.utils.oneshot_llm.run_oneshot_llm`) with the suggestions route so model build + Langfuse metadata + invoke stay in one place; validates the same stripped view of the draft it sends to the model, and preserves literal `<think>` substrings in the rewrite (`strip_think_blocks(truncate_unclosed=False)`) |
| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block. Before the first journaled run, an empty run-event message feed is seeded from an existing checkpoint head so legacy checkpoint-only history receives earlier thread-global sequence numbers and remains visible after the new run; a thread with no checkpoint or an already-populated feed skips this compatibility path. `POST /regenerate/prepare` - prepare clean input + checkpoint metadata for regenerating the latest completed or interrupted assistant answer, carrying the latest non-empty thread title in graph input so resuming an older checkpoint cannot roll back a later manual rename (#4457); `POST /edit-regenerate/prepare` - prepare a checkpoint replay from the latest editable human turn with a replacement user message and edit replay metadata; it carries the current thread title the same way, but only when the replay base already has one — an untitled base belongs to a thread the title middleware has not named yet, so pinning the current title there would keep a name generated from the prompt the edit just replaced; `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/messages` - paginated per-run messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /{rid}/workspace-changes` - workspace/output file change summary and optional diffs; `GET /../messages` - legacy thread message array; `GET /../messages/page` - backward thread-global `seq` history page with middleware/subagent-AI/successful-regenerate/edit-replay filtering and page-run-scoped feedback enrichment; subagent AI callbacks remain available through run events while parent `task` ToolMessages stay visible for card restoration; `GET /../token-usage` - aggregate tokens plus an optional `context_usage` percentage. Context usage approximately counts messages from the latest materialized thread state through `build_thread_checkpoint_state_accessor`, so full and delta checkpoint modes expose the same input. The percentage uses the latest run's model and its configured `context_window`. |

View File

@ -12,7 +12,7 @@ from pathlib import Path
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, PlainTextResponse, Response
from fastapi.responses import FileResponse, Response
from pydantic import BaseModel, Field
from app.gateway.authz import require_permission
@ -159,6 +159,51 @@ def _build_attachment_headers(filename: str, extra_headers: dict[str, str] | Non
return headers
def _slice_byte_range(content: bytes, range_header: str | None) -> tuple[bytes, int, dict[str, str]]:
"""Apply one RFC 9110 byte range to an in-memory archive member."""
size = len(content)
headers = {"Accept-Ranges": "bytes"}
if range_header is None:
return content, 200, headers
def unsatisfied() -> HTTPException:
return HTTPException(
status_code=416,
detail="Requested range is not satisfiable",
headers={"Accept-Ranges": "bytes", "Content-Range": f"bytes */{size}"},
)
if not range_header.startswith("bytes=") or "," in range_header:
raise unsatisfied()
range_spec = range_header.removeprefix("bytes=")
if "-" not in range_spec:
raise unsatisfied()
start_text, end_text = range_spec.split("-", 1)
try:
if start_text:
start = int(start_text)
end = size - 1 if not end_text else min(int(end_text), size - 1)
else:
suffix_length = int(end_text)
if suffix_length <= 0:
raise unsatisfied()
start = max(size - suffix_length, 0)
end = size - 1
except ValueError as exc:
raise unsatisfied() from exc
if size == 0 or start < 0 or start >= size or end < start:
raise unsatisfied()
ranged_content = content[start : end + 1]
headers.update(
{
"Content-Range": f"bytes {start}-{end}/{size}",
"Content-Length": str(len(ranged_content)),
}
)
return ranged_content, 206, headers
def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool:
"""Check if file is text by examining content for null bytes."""
try:
@ -239,18 +284,14 @@ def _load_skill_archive_member(actual_skill_path: Path, skill_file_path: str, in
return content, mime_type
def _read_artifact_payload(actual_path: Path, path: str, download: bool) -> tuple[str, str | None, bytes | str | None]:
def _read_artifact_payload(actual_path: Path, path: str, download: bool) -> tuple[str, str | None]:
"""Worker-thread body for the regular branch of ``get_artifact``.
Stat probes, MIME sniffing (``mimetypes`` lazily stats the system MIME
database on first use), and text reads are blocking filesystem IO. Returns
a ``(kind, mime_type, payload)`` plan the handler turns into a response on
the loop: ``("file", mime, None)`` (attachment / forced-download active
content, streamed by ``FileResponse``), ``("inline_file", mime, None)``
(inline binary preview also streamed by ``FileResponse`` so the client
can issue byte-``Range`` requests, e.g. to seek within audio/video
artifacts instead of always replaying from byte 0), or ``("text", mime,
str)``. Behavior/error codes match the previous inline logic.
Stat probes and MIME sniffing (``mimetypes`` lazily stats the system MIME
database on first use) are blocking filesystem IO. Returns a
``(kind, mime_type)`` plan the handler turns into a streamed
``FileResponse``. Inline text and binary previews both use FileResponse so
clients can request a bounded byte range instead of buffering a whole file.
"""
if not actual_path.exists():
raise HTTPException(status_code=404, detail=f"Artifact not found: {path}")
@ -259,15 +300,12 @@ def _read_artifact_payload(actual_path: Path, path: str, download: bool) -> tupl
mime_type, _ = mimetypes.guess_type(actual_path)
# Active content / explicit download is streamed by FileResponse — no read here.
if download or mime_type in ACTIVE_CONTENT_MIME_TYPES:
return ("file", mime_type, None)
return ("file", mime_type)
if mime_type and mime_type.startswith("text/"):
return ("text", mime_type, actual_path.read_text(encoding="utf-8"))
return ("inline_file", mime_type)
if is_text_file_by_content(actual_path):
return ("text", mime_type, actual_path.read_text(encoding="utf-8"))
# Binary inline preview (images, audio, video, PDFs, ...): stream via
# FileResponse instead of buffering the whole file in memory, so it also
# gets FileResponse's built-in byte-Range handling (see get_artifact).
return ("inline_file", mime_type, None)
return ("inline_file", mime_type or "text/plain")
return ("inline_file", mime_type)
@router.get(
@ -338,23 +376,37 @@ async def get_artifact(thread_id: ThreadId, path: str, request: Request, downloa
if download or mime_type in ACTIVE_CONTENT_MIME_TYPES:
return Response(content=content, media_type=mime_type or "application/octet-stream", headers=_build_attachment_headers(download_name, cache_headers))
# Archive members are already bounded during extraction. Preserve byte
# semantics here so the frontend can request only its preview budget,
# including a final partial UTF-8 sequence.
request_headers = request.headers if request is not None else {}
range_header = None if request_headers.get("if-range") else request_headers.get("range")
ranged_content, status_code, range_headers = _slice_byte_range(content, range_header)
inline_headers = {**cache_headers, **range_headers}
if mime_type and mime_type.startswith("text/"):
return PlainTextResponse(content=content.decode("utf-8"), media_type=mime_type, headers=cache_headers)
return Response(content=ranged_content, status_code=status_code, media_type=mime_type, headers=inline_headers)
# Default to plain text for unknown types that look like text
try:
return PlainTextResponse(content=content.decode("utf-8"), media_type="text/plain", headers=cache_headers)
content.decode("utf-8")
return Response(content=ranged_content, status_code=status_code, media_type="text/plain", headers=inline_headers)
except UnicodeDecodeError:
return Response(content=content, media_type=mime_type or "application/octet-stream", headers=cache_headers)
return Response(
content=ranged_content,
status_code=status_code,
media_type=mime_type or "application/octet-stream",
headers=inline_headers,
)
actual_path = await asyncio.to_thread(resolve_thread_virtual_path, thread_id, path, user_id=owner_user_id)
logger.info(f"Resolving artifact path: thread_id={thread_id}, requested_path={path}, actual_path={actual_path}")
# Offload path stat + MIME sniff + file reads (all blocking filesystem IO).
# Active content and explicit downloads are streamed by FileResponse, so the
# worker only reports the kind; inline text/binary payloads are read in-thread.
kind, mime_type, payload = await asyncio.to_thread(_read_artifact_payload, actual_path, path, download)
# Offload path stat + MIME sniff (blocking filesystem IO). Every regular
# artifact response is streamed by FileResponse; the worker only reports
# disposition and media type.
kind, mime_type = await asyncio.to_thread(_read_artifact_payload, actual_path, path, download)
if kind == "file":
# Always force download for active content types to prevent script
@ -362,23 +414,14 @@ async def get_artifact(thread_id: ThreadId, path: str, request: Request, downloa
return FileResponse(path=actual_path, filename=actual_path.name, media_type=mime_type, headers=_build_attachment_headers(actual_path.name))
if kind == "inline_file":
# FileResponse (unlike a fully-buffered Response) honors byte-Range
# requests. Browsers issue these when seeking an <audio>/<video>
# element backed by a remote URL; serving the same bytes through a
# plain Response ignores Range headers and always replays from byte
# 0, which is why dragging an audio/video artifact's progress bar
# reset playback to the start instead of jumping to the new position.
# FileResponse honors byte-Range requests for large text previews and
# media seeking without buffering the full artifact in the Gateway.
return FileResponse(
path=actual_path,
media_type=mime_type,
headers={"Content-Disposition": _build_content_disposition("inline", actual_path.name)},
)
if kind == "text":
assert isinstance(payload, str)
content_sha256 = hashlib.sha256(payload.encode("utf-8")).hexdigest()
return PlainTextResponse(content=payload, media_type=mime_type, headers={"ETag": f'"{content_sha256}"'})
raise AssertionError(f"Unhandled artifact response kind: {kind!r}")

View File

@ -1,4 +1,5 @@
import asyncio
import base64
import contextlib
import json
import logging
@ -176,13 +177,40 @@ def _ws_origin_allowed(websocket: WebSocket) -> bool:
return False
async def _send_browser_frame(websocket: WebSocket, data: bytes, *, binary: bool) -> None:
if binary:
await websocket.send_bytes(data)
return
payload = {"type": "frame", "data": base64.b64encode(data).decode("ascii")}
await websocket.send_text(json.dumps(payload))
async def _negotiate_browser_frame_format(websocket: WebSocket) -> bool | None:
"""Accept the socket and resolve the optional frame transport capability."""
requested_format = websocket.query_params.get("frame_format")
await websocket.accept()
if requested_format not in {None, "binary"}:
await websocket.send_text(
json.dumps(
{
"type": "error",
"message": f"Unsupported frame_format: {requested_format}",
},
),
)
await websocket.close(code=1008)
return None
return requested_format == "binary"
@router.websocket("/threads/{thread_id}/browser/stream")
async def browser_stream(websocket: WebSocket, thread_id: ThreadId) -> None:
"""Bidirectional live browser stream.
Server client: JSON ``{"type":"frame","data":"<base64 jpeg>"}`` frames
captured via CDP screencast. Client server: input events (click, move,
down, up, wheel, key, text, navigate) that drive the live page.
Server client: binary JPEG frames when ``frame_format=binary`` is
requested; legacy clients retain JSON base64 frames. Status and navigation
metadata remain JSON. Client server: input events (click, move, down, up,
wheel, key, text, navigate) that drive the live page.
"""
user = await _authenticate_ws(websocket)
if user is None:
@ -224,11 +252,13 @@ async def browser_stream(websocket: WebSocket, thread_id: ThreadId) -> None:
await websocket.close(code=4501)
return
await websocket.accept()
use_binary_frames = await _negotiate_browser_frame_format(websocket)
if use_binary_frames is None:
return
token = set_current_user(user)
loop = asyncio.get_running_loop()
frame_queue: asyncio.Queue[str] = asyncio.Queue(maxsize=4)
frame_queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=4)
send_lock = asyncio.Lock()
input_event = asyncio.Event()
input_queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=64)
@ -239,7 +269,7 @@ async def browser_stream(websocket: WebSocket, thread_id: ThreadId) -> None:
async with send_lock:
await websocket.send_text(json.dumps(payload))
def _on_frame(data: str) -> None:
def _on_frame(data: bytes) -> None:
# Invoked on the private Playwright loop; hop to this loop and drop the
# oldest frame when the client can't keep up (screencast is lossy).
def _enqueue() -> None:
@ -295,7 +325,8 @@ async def browser_stream(websocket: WebSocket, thread_id: ThreadId) -> None:
async def _pump_frames() -> None:
while True:
data = await frame_queue.get()
await _send_payload({"type": "frame", "data": data})
async with send_lock:
await _send_browser_frame(websocket, data, binary=use_binary_frames)
async def _send_url() -> None:
# Report the page's real URL so the client's address bar reflects the

View File

@ -15,7 +15,6 @@ private loop so the core harness installs without it.
from __future__ import annotations
import asyncio
import base64
import contextlib
import logging
import os
@ -301,7 +300,7 @@ class BrowserSession:
# Live screencast state. When streaming, ``_on_frame`` is retained so the
# screencast can be re-bound to a new page — login/OAuth flows commonly
# open a popup or a fresh tab, and the user must see (and drive) it.
self._on_frame: Callable[[str], None] | None = None
self._on_frame: Callable[[bytes], None] | None = None
# The page the live screencast's CDP session is currently bound to. Frames
# are captured from ``self._page`` (the live active page), but the CDP
# repaint signal is tied to a specific page; when the active page diverges
@ -538,10 +537,9 @@ class BrowserSession:
page = await self._ensure_page()
return await page.screenshot(full_page=full_page, type="png")
async def _live_frame(self) -> str:
async def _live_frame(self) -> bytes:
page = await self._ensure_page()
shot = await page.screenshot(type="jpeg", quality=_LIVE_FRAME_JPEG_QUALITY)
return base64.b64encode(shot).decode("ascii")
return await page.screenshot(type="jpeg", quality=_LIVE_FRAME_JPEG_QUALITY)
async def _emit_live_frame(self) -> None:
if self._on_frame is None:
@ -679,7 +677,7 @@ class BrowserSession:
self._page_listener_bound = False
self._request_guard_bound = False
async def _start_screencast(self, on_frame: Callable[[str], None]) -> None:
async def _start_screencast(self, on_frame: Callable[[bytes], None]) -> None:
"""Start Live mode and send an initial JPEG frame.
This used to attach Chrome's CDP screencast and then turn every repaint
@ -698,7 +696,7 @@ class BrowserSession:
await self._emit_live_frame()
self._schedule_settle_live_frames()
async def _stop_screencast(self, on_frame: Callable[[str], None] | None = None) -> None:
async def _stop_screencast(self, on_frame: Callable[[bytes], None] | None = None) -> None:
if on_frame is not None and self._on_frame is not on_frame:
return
self._on_frame = None
@ -792,7 +790,7 @@ class BrowserSession:
with self._activity():
return await self._loop.run(self._screenshot_bytes(full_page))
async def live_frame(self) -> str:
async def live_frame(self) -> bytes:
with self._activity():
return await self._loop.run(self._live_frame())
@ -815,11 +813,11 @@ class BrowserSession:
with self._activity():
return await self._loop.run(self._tabs())
async def start_screencast(self, on_frame: Callable[[str], None]) -> None:
async def start_screencast(self, on_frame: Callable[[bytes], None]) -> None:
with self._activity():
await self._loop.run(self._start_screencast(on_frame))
async def stop_screencast(self, on_frame: Callable[[str], None] | None = None) -> None:
async def stop_screencast(self, on_frame: Callable[[bytes], None] | None = None) -> None:
with self._activity():
await self._loop.run(self._stop_screencast(on_frame))

View File

@ -1,7 +1,7 @@
"""Regression anchor: serving artifacts must not block the event loop.
``get_artifact`` probes the artifact path (``exists`` / ``is_file``), reads
text content (``read_text``), sniffs text-ness (``is_text_file_by_content``),
``get_artifact`` probes the artifact path (``exists`` / ``is_file``), sniffs
text-ness (``is_text_file_by_content``),
and extracts ``.skill`` archive members all blocking filesystem IO. The
handler offloads each branch's IO via ``asyncio.to_thread``; if any regresses
back onto the event loop, the strict Blockbuster gate raises ``BlockingError``
@ -65,8 +65,10 @@ async def test_get_artifact_text_does_not_block_event_loop(tmp_path: Path, monke
resp = await _get_artifact("t1", vpath, request=None, download=False)
assert isinstance(resp, FileResponse)
assert resp.status_code == 200
assert resp.body == b"hello world"
assert Path(resp.path) == target
assert resp.headers.get("content-disposition", "").startswith("inline;")
async def test_get_artifact_binary_does_not_block_event_loop(tmp_path: Path, monkeypatch) -> None:

View File

@ -35,19 +35,22 @@ def test_get_artifact_reads_utf8_text_file_on_windows_locale(tmp_path, monkeypat
original_read_text = Path.read_text
def read_text_with_gbk_default(self, *args, **kwargs):
kwargs.setdefault("encoding", "gbk")
def reject_artifact_read_text(self, *args, **kwargs):
if self == artifact_path:
pytest.fail("text files must stream")
return original_read_text(self, *args, **kwargs)
monkeypatch.setattr(Path, "read_text", read_text_with_gbk_default)
monkeypatch.setattr(Path, "read_text", reject_artifact_read_text)
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path, user_id=None: artifact_path)
request = _make_request()
response = asyncio.run(call_unwrapped(artifacts_router.get_artifact, "thread-1", "mnt/user-data/outputs/note.txt", request))
app = make_authed_test_app()
app.include_router(artifacts_router.router)
with TestClient(app) as client:
response = client.get("/api/threads/thread-1/artifacts/mnt/user-data/outputs/note.txt")
assert bytes(response.body).decode("utf-8") == text
assert response.media_type == "text/plain"
assert response.headers["etag"] == f'"{hashlib.sha256(text.encode("utf-8")).hexdigest()}"'
assert response.text == text
assert response.headers["content-type"].startswith("text/plain")
assert response.headers["accept-ranges"] == "bytes"
@asynccontextmanager
@ -329,6 +332,60 @@ def test_update_artifact_reports_active_run_conflict(tmp_path, monkeypatch) -> N
assert artifact_path.read_text(encoding="utf-8") == "before"
def test_get_artifact_text_preview_supports_bounded_range_requests(tmp_path, monkeypatch) -> None:
payload = ("0123456789abcdef" * 131_072).encode()
artifact_path = tmp_path / "large.txt"
artifact_path.write_bytes(payload)
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path, user_id=None: artifact_path)
app = make_authed_test_app()
app.include_router(artifacts_router.router)
with TestClient(app) as client:
preview = client.get(
"/api/threads/thread-1/artifacts/mnt/user-data/outputs/large.txt",
headers={"Range": "bytes=0-1048575"},
)
invalid = client.get(
"/api/threads/thread-1/artifacts/mnt/user-data/outputs/large.txt",
headers={"Range": f"bytes={len(payload)}-"},
)
assert preview.status_code == 206
assert preview.content == payload[:1_048_576]
assert preview.headers["content-range"] == f"bytes 0-1048575/{len(payload)}"
assert preview.headers["content-disposition"].startswith("inline;")
assert invalid.status_code == 416
assert invalid.headers["content-range"] == f"bytes */{len(payload)}"
def test_get_skill_archive_preview_supports_bounded_range_requests(tmp_path, monkeypatch) -> None:
payload = ("skill preview \u4e2d\u6587\n" * 100_000).encode()
skill_path = tmp_path / "sample.skill"
with zipfile.ZipFile(skill_path, "w", compression=zipfile.ZIP_DEFLATED) as zip_ref:
zip_ref.writestr("SKILL.md", payload)
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path, user_id=None: skill_path)
app = make_authed_test_app()
app.include_router(artifacts_router.router)
with TestClient(app) as client:
preview = client.get(
"/api/threads/thread-1/artifacts/mnt/user-data/outputs/sample.skill/SKILL.md",
headers={"Range": "bytes=0-1048575"},
)
invalid = client.get(
"/api/threads/thread-1/artifacts/mnt/user-data/outputs/sample.skill/SKILL.md",
headers={"Range": f"bytes={len(payload)}-"},
)
assert preview.status_code == 206
assert preview.content == payload[:1_048_576]
assert preview.headers["accept-ranges"] == "bytes"
assert preview.headers["content-range"] == f"bytes 0-1048575/{len(payload)}"
assert invalid.status_code == 416
assert invalid.headers["content-range"] == f"bytes */{len(payload)}"
@pytest.mark.parametrize(("filename", "content"), ACTIVE_ARTIFACT_CASES)
def test_get_artifact_forces_download_for_active_content(tmp_path, monkeypatch, filename: str, content: str) -> None:
artifact_path = tmp_path / filename
@ -370,7 +427,7 @@ def test_get_artifact_download_false_does_not_force_attachment(tmp_path, monkeyp
assert response.status_code == 200
assert response.text == "hello"
assert "content-disposition" not in response.headers
assert response.headers["content-disposition"].startswith("inline;")
def test_get_artifact_binary_preview_is_inline_file_response(tmp_path, monkeypatch) -> None:

View File

@ -301,7 +301,7 @@ async def test_wheel_input_falls_back_to_native_wheel_when_js_scroll_fails():
@pytest.mark.asyncio
async def test_live_frame_returns_base64_jpeg_screenshot():
async def test_live_frame_returns_jpeg_bytes_without_base64_expansion():
session = BrowserSession(
MagicMock(),
headless=True,
@ -314,7 +314,7 @@ async def test_live_frame_returns_base64_jpeg_screenshot():
frame = await session._live_frame()
assert frame == "/9hq cGVnLWJ5dGVz".replace(" ", "")
assert frame == b"\xff\xd8jpeg-bytes"
page.screenshot.assert_awaited_once_with(type="jpeg", quality=_LIVE_FRAME_JPEG_QUALITY)
@ -390,7 +390,7 @@ async def test_continuous_inputs_refresh_before_input_stops(monkeypatch):
page.keyboard.press = AsyncMock()
session._ensure_page = AsyncMock(return_value=page)
session._on_frame = MagicMock()
session._live_frame = AsyncMock(return_value="frame")
session._live_frame = AsyncMock(return_value=b"frame")
session._schedule_settle_live_frames = MagicMock()
stop = asyncio.Event()
@ -633,7 +633,7 @@ async def test_live_frame_screenshots_current_active_page_after_switch():
frame = await session._live_frame()
new_page.screenshot.assert_awaited_once_with(type="jpeg", quality=_LIVE_FRAME_JPEG_QUALITY)
assert frame # base64 payload of the new page
assert frame # JPEG bytes from the new page
@pytest.mark.asyncio
@ -646,10 +646,10 @@ async def test_stop_screencast_ignores_stale_connection_callback():
viewport={"width": 1000, "height": 500},
)
def old_frame(_data: str) -> None:
def old_frame(_data: bytes) -> None:
pass
def new_frame(_data: str) -> None:
def new_frame(_data: bytes) -> None:
pass
session._on_frame = new_frame
@ -675,10 +675,10 @@ async def test_start_screencast_rejects_second_live_viewer():
page = MagicMock()
session._ensure_page = AsyncMock(return_value=page)
def old_frame(_data: str) -> None:
def old_frame(_data: bytes) -> None:
pass
def new_frame(_data: str) -> None:
def new_frame(_data: bytes) -> None:
pass
session._on_frame = old_frame

View File

@ -1,3 +1,4 @@
import json
import logging
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
@ -10,7 +11,12 @@ from starlette.websockets import WebSocketDisconnect
from app.gateway.auth.models import User
from app.gateway.routers import browser as browser_router
from app.gateway.routers.browser import _should_apply_browser_seed, _ws_origin_allowed
from app.gateway.routers.browser import (
_negotiate_browser_frame_format,
_send_browser_frame,
_should_apply_browser_seed,
_ws_origin_allowed,
)
class _FakeWebSocket:
@ -207,6 +213,64 @@ def test_ws_origin_allowed_same_origin_host():
assert _ws_origin_allowed(ws) is True
@pytest.mark.asyncio
async def test_send_browser_frame_uses_binary_websocket_message_when_requested():
websocket = MagicMock()
websocket.send_bytes = AsyncMock()
websocket.send_text = AsyncMock()
await _send_browser_frame(websocket, b"\xff\xd8jpeg", binary=True)
websocket.send_bytes.assert_awaited_once_with(b"\xff\xd8jpeg")
websocket.send_text.assert_not_awaited()
@pytest.mark.asyncio
async def test_send_browser_frame_keeps_legacy_base64_json_protocol():
websocket = MagicMock()
websocket.send_bytes = AsyncMock()
websocket.send_text = AsyncMock()
await _send_browser_frame(websocket, b"\xff\xd8jpeg", binary=False)
websocket.send_bytes.assert_not_awaited()
payload = json.loads(websocket.send_text.await_args.args[0])
assert payload == {"type": "frame", "data": "/9hq cGVn".replace(" ", "")}
@pytest.mark.asyncio
async def test_browser_frame_format_rejects_unknown_capability():
websocket = MagicMock()
websocket.query_params = {"frame_format": "avif"}
websocket.accept = AsyncMock()
websocket.send_text = AsyncMock()
websocket.close = AsyncMock()
result = await _negotiate_browser_frame_format(websocket)
assert result is None
websocket.accept.assert_awaited_once()
payload = json.loads(websocket.send_text.await_args.args[0])
assert payload["type"] == "error"
assert "frame_format" in payload["message"]
websocket.close.assert_awaited_once_with(code=1008)
@pytest.mark.asyncio
@pytest.mark.parametrize(("value", "expected"), [(None, False), ("binary", True)])
async def test_browser_frame_format_accepts_legacy_and_binary(value, expected):
websocket = MagicMock()
websocket.query_params = {} if value is None else {"frame_format": value}
websocket.accept = AsyncMock()
websocket.send_text = AsyncMock()
websocket.close = AsyncMock()
assert await _negotiate_browser_frame_format(websocket) is expected
websocket.accept.assert_awaited_once()
websocket.send_text.assert_not_awaited()
websocket.close.assert_not_awaited()
def test_ws_origin_allowed_rejects_cross_origin():
ws = _FakeWebSocket({"origin": "https://evil.example.com", "host": "app.example.com"})
assert _ws_origin_allowed(ws) is False

View File

@ -0,0 +1,32 @@
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
CONFIGS = (
REPO_ROOT / "docker/nginx/nginx.conf",
REPO_ROOT / "docker/nginx/nginx.local.conf",
)
@pytest.mark.parametrize("config_path", CONFIGS, ids=lambda path: path.name)
def test_nginx_compresses_only_safe_textual_responses(config_path: Path) -> None:
config = config_path.read_text(encoding="utf-8")
assert "gzip on;" in config
assert "gzip_vary on;" in config
assert "gzip_proxied any;" in config
assert "gzip_min_length 1024;" in config
assert "gzip_comp_level 5;" in config
gzip_types = config.split("gzip_types", 1)[1].split(";", 1)[0].split()
assert gzip_types == [
"text/css",
"text/javascript",
"application/javascript",
"application/json",
"application/xml",
"image/svg+xml",
]
assert "text/event-stream" not in gzip_types
assert not any(content_type.startswith(("font/", "audio/", "video/")) for content_type in gzip_types)

View File

@ -10,6 +10,21 @@ http {
keepalive_timeout 65;
types_hash_max_size 2048;
# Compress textual delivery only. SSE and already-compressed media are
# deliberately absent because buffering/compression hurts their latency.
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_min_length 1024;
gzip_comp_level 5;
gzip_types
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml;
# Logging
access_log /dev/stdout;
error_log /dev/stderr;
@ -291,4 +306,4 @@ http {
proxy_read_timeout 600s;
}
}
}
}

View File

@ -14,6 +14,21 @@ http {
keepalive_timeout 65;
types_hash_max_size 2048;
# Compress textual delivery only. SSE and already-compressed media are
# deliberately absent because buffering/compression hurts their latency.
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_min_length 1024;
gzip_comp_level 5;
gzip_types
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml;
# Logging
access_log logs/nginx-access.log;
error_log logs/nginx-error.log;

View File

@ -0,0 +1,84 @@
# Frontend Performance Baseline and Delivery Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Turn the current route-weight and delivery findings into repeatable gates, enable safe compression, eliminate eager case-study image downloads, and remove the mock route's whole-project trace.
**Architecture:** A production-server measurement script owns route asset accounting and compares it with a checked-in budget. Nginx owns textual response compression. Landing images become semantic lazy images. Static demo data is exposed through an explicit manifest so route handlers never derive filesystem paths from request input.
**Tech Stack:** Next.js 16, TypeScript, Rstest, Node.js, Nginx, pnpm.
**Global Constraints:** Work only in `/Users/minimax/workspace/deer-flow/.worktrees/fix-frontend-performance`. Preserve current API routes. Do not gzip SSE or already-compressed media. Every fix follows RED, GREEN, regression proof, then commit.
## Task 1: Add a reproducible route-asset budget gate
**Files:**
- Create: `frontend/scripts/measure-route-assets.mjs`
- Create: `frontend/performance-budgets.json`
- Create: `frontend/tests/unit/scripts/measure-route-assets.test.ts`
- Modify: `frontend/package.json`
- Modify: `frontend/AGENTS.md`
- [ ] Write a failing unit test for exported `extractAssetPaths(html)` and `evaluateBudgets(measurements, budgets)`. Assert duplicate scripts are counted once and a one-byte overage fails with the route, asset class, actual bytes, and limit.
- [ ] Run `cd frontend && pnpm test -- tests/unit/scripts/measure-route-assets.test.ts` and record the missing-module failure.
- [ ] Implement pure helpers in `measure-route-assets.mjs`. The CLI must build with `NEXT_PUBLIC_STATIC_WEBSITE_ONLY=true`, spawn `next start` on a free local port, fetch `/`, `/workspace/chats`, the canonical demo thread, `/en/docs`, and `/blog/posts`, resolve referenced `/_next/static/` files under `.next/static`, and emit `performance-results.json`.
- [ ] Check in these initial ceilings, all below the measured baseline: `/` JS 1,050,000 and CSS 150,000; `/workspace/chats` JS 2,750,000 and CSS 150,000; demo chat JS 3,500,000 and CSS 150,000; `/en/docs` and `/blog/posts` JS 3,000,000 and CSS 230,000.
- [ ] Add `"perf:check": "node scripts/measure-route-assets.mjs --check"` and document the gate and static-demo requirement in `frontend/AGENTS.md`.
- [ ] Run the focused test GREEN. Temporarily lower the `/` JS budget to `1`, prove `pnpm perf:check` fails, restore the file, and defer the final passing gate until the bundle plan lands.
- [ ] Commit: `test(frontend): add route asset performance budgets`.
## Task 2: Enable safe Nginx compression
**Files:**
- Modify: `docker/nginx/nginx.conf`
- Modify: `docker/nginx/nginx.local.conf`
- Create: `backend/tests/test_nginx_compression.py`
- Modify: `AGENTS.md`
- [ ] Write a failing test that parses both configs and asserts the same compression policy: `gzip on`, `gzip_vary on`, minimum length 1024, compression level 5, and types limited to HTML default plus CSS, JavaScript, JSON, XML, and SVG. Assert `text/event-stream`, fonts, images other than SVG, audio, and video are absent.
- [ ] Run `cd backend && uv run pytest tests/test_nginx_compression.py -q` and capture RED.
- [ ] Add the identical directives to both `http` blocks. Include `gzip_proxied any`; do not add a wildcard content type.
- [ ] Update the service-topology note in root `AGENTS.md` to state that Nginx compresses textual responses but deliberately excludes SSE and pre-compressed media.
- [ ] Run the focused test GREEN. If local Nginx is available, start the configured service and verify `curl --compressed -I` returns `Content-Encoding: gzip` for HTML while an SSE response is uncompressed.
- [ ] Revert the directive block, prove the test fails, restore it, rerun GREEN.
- [ ] Commit: `perf(nginx): compress textual responses safely`.
## Task 3: Lazy-load landing case-study images
**Files:**
- Modify: `frontend/src/components/landing/sections/case-study-section.tsx`
- Create: `frontend/tests/unit/components/landing/case-study-section.dom.test.tsx`
- [ ] Write a DOM test that renders the section and asserts each card has an actual `img` with `loading="lazy"`, `decoding="async"`, intrinsic dimensions, descriptive alt text, and no inline/background-image URL.
- [ ] Run the focused test and capture RED against the CSS backgrounds.
- [ ] Replace background-image cards with a positioned `next/image` or native image. Keep the overlay and visual crop, set explicit `sizes`, and make only an above-the-fold image eager if measurement proves it is visible on initial viewport.
- [ ] Run the DOM test GREEN and verify in a browser/network trace that offscreen JPEG requests do not start before scrolling.
- [ ] Revert the component change, prove RED, restore, rerun GREEN.
- [ ] Commit: `perf(frontend): lazy load case study media`.
## Task 4: Replace request-derived mock filesystem traversal with a static manifest
**Files:**
- Modify: `frontend/src/core/threads/static-demo.ts`
- Modify: `frontend/src/app/mock/api/threads/[thread_id]/artifacts/[[...artifact_path]]/route.ts`
- Modify: `frontend/src/app/mock/api/threads/[thread_id]/history/route.ts`
- Modify: `frontend/src/app/mock/api/threads/search/route.ts`
- Modify: `frontend/src/app/workspace/page.tsx`
- Create: `frontend/tests/unit/core/threads/static-demo.test.ts`
- Create: `frontend/tests/unit/app/mock/static-artifact-route.test.ts`
- [ ] Add failing tests for `resolveStaticDemoArtifact(threadId, segments)` covering a known artifact, unknown thread, traversal segments, and encoded traversal. Add a route test that asserts the response comes from the manifest-backed resolver.
- [ ] Run both focused tests and capture RED.
- [ ] Define an explicit immutable demo manifest in `static-demo.ts`; normalize and validate all path segments before a lookup. Route handlers may read only paths returned by that manifest.
- [ ] Replace request-time `readdirSync`, `readFileSync`, and `statSync` in mock routes/workspace discovery with manifest imports or `fs/promises` during server execution. Keep response payloads byte-for-byte compatible.
- [ ] Run focused tests GREEN.
- [ ] Run `NEXT_PUBLIC_STATIC_WEBSITE_ONLY=true pnpm build` and assert the prior Turbopack "whole project unintentionally traced" warning and mock artifact import trace are absent.
- [ ] Revert the resolver use, prove the route test RED, restore, rerun focused tests and build.
- [ ] Commit: `perf(frontend): bound static demo file tracing`.
## Final verification
- [ ] Run `cd frontend && pnpm check && pnpm test`.
- [ ] Run `cd backend && uv run pytest tests/test_nginx_compression.py -q`.
- [ ] Run the static-demo production build and confirm zero unexpected-file/whole-project trace warnings.
- [ ] Record the new route measurements in the PR description; do not loosen a budget to make the gate pass.

View File

@ -0,0 +1,102 @@
# Frontend Bundle and Static Rendering Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Restore static rendering for public routes and keep locale dictionaries, settings pages, editors, artifact panels, and Shiki out of routes that do not use them.
**Architecture:** The root layout becomes locale-agnostic and static. Public locale-aware routes resolve one route-owned dictionary; the interactive auth/workspace provider owns both dictionaries because formatter functions cannot cross the RSC serialization boundary and language switching must remain immediate. Feature hosts load code at the user-interaction boundary. Syntax highlighting produces one HTML tree per code block.
**Tech Stack:** Next.js App Router, React 19, TypeScript, Rstest, Streamdown, Shiki, CodeMirror.
**Global Constraints:** Preserve visible behavior and deep links. Dynamic imports must have a stable loading/error state. Do not move authentication checks into public routes. All user-facing architecture changes update the appropriate `AGENTS.md`.
## Task 1: Make the root layout static and scope route styles
**Files:**
- Modify: `frontend/src/app/layout.tsx`
- Create or modify: `frontend/src/app/(auth)/layout.tsx`
- Modify: `frontend/src/app/workspace/layout.tsx`
- Modify: `frontend/src/app/[lang]/docs/layout.tsx`
- Modify: `frontend/src/app/blog/layout.tsx`
- Modify: `frontend/src/components/landing/header.tsx`
- Create: `frontend/tests/unit/app/layout-boundaries.test.ts`
- [ ] Write a source-boundary test asserting root layout does not import `cookies`, `detectLocaleServer`, KaTeX CSS, Streamdown CSS, or workspace providers. Assert workspace/docs layouts own only the styles they render.
- [ ] Run the focused test and capture RED.
- [ ] Reduce root layout to global reset/theme metadata and `<html lang={DEFAULT_LOCALE}>`. Pass an explicit default locale to the public landing header. Install locale/provider and rich-content CSS only in auth/workspace/docs/blog layouts that need them.
- [ ] Run the focused test GREEN and `pnpm build`. Verify `/` and `/en/docs` are emitted without request-time cookie dependency and their production responses no longer contain `private, no-store` solely because of the root layout.
- [ ] Revert root locale detection, prove RED, restore, rerun.
- [ ] Commit: `perf(frontend): restore static public layout boundaries`.
## Task 2: Scope locale dictionaries by route boundary
**Files:**
- Modify: `frontend/src/core/i18n/context.tsx`
- Modify: `frontend/src/core/i18n/translations.ts`
- Modify: `frontend/src/core/i18n/server.ts`
- Modify: `frontend/src/core/i18n/hooks.ts`
- Create: `frontend/tests/unit/core/i18n/context.dom.test.tsx`
- Modify: `frontend/tests/unit/core/i18n/translations.test.ts`
- [ ] Write failing tests for `loadTranslations("en-US")` and `loadTranslations("zh-CN")`, the auth/workspace provider's immediate language switch, and `document.documentElement.lang` synchronization.
- [ ] Run focused i18n tests and capture RED.
- [ ] Replace public static imports of both dictionaries with an exhaustive server loader map returning `import("./locales/en-US")` or `import("./locales/zh-CN")`. Server layouts pass only a serializable locale. Keep both formatter-bearing dictionaries inside the interactive auth/workspace client boundary so switching does not require an RSC-invalid function prop or a loading flash.
- [ ] Keep the public `useI18n()` contract stable and reject unsupported locale strings through the existing locale parser.
- [ ] Run focused tests GREEN and use route measurement/source ownership tests to assert public routes do not inherit the interactive two-locale provider.
- [ ] Revert the loader map, prove the chunk-ownership test RED, restore, rerun.
- [ ] Commit: `perf(frontend): split locale dictionaries by route`.
## Task 3: Split interaction-only settings and workspace panels
**Files:**
- Modify: `frontend/src/components/settings/settings-dialog-host.tsx`
- Modify: `frontend/src/components/settings/settings-dialog.tsx`
- Modify: `frontend/src/components/workspace/settings/account-settings-page.tsx`
- Modify: `frontend/src/components/workspace/settings/appearance-settings-page.tsx`
- Modify: `frontend/src/components/workspace/settings/channels-settings-page.tsx`
- Modify: `frontend/src/components/workspace/settings/integrations-settings-page.tsx`
- Modify: `frontend/src/components/workspace/settings/memory-settings-page.tsx`
- Modify: `frontend/src/components/workspace/settings/notification-settings-page.tsx`
- Modify: `frontend/src/components/workspace/settings/skill-settings-page.tsx`
- Modify: `frontend/src/components/workspace/settings/tool-settings-page.tsx`
- Modify: `frontend/src/components/workspace/settings/about-settings-page.tsx`
- Modify: `frontend/src/components/workspace/sidecar/sidecar-panel.tsx`
- Modify: `frontend/src/components/workspace/artifacts/index.ts`
- Modify: `frontend/src/components/workspace/browser-view/index.ts`
- Modify: `frontend/src/components/workspace/changes/workspace-change-panel.tsx`
- Modify: `frontend/src/components/workspace/citations/citation-sources-panel.tsx`
- Create: `frontend/tests/unit/components/settings/settings-dialog-host.dom.test.tsx`
- Create: `frontend/tests/unit/components/workspace/lazy-panels.dom.test.tsx`
- [ ] Write failing DOM tests asserting no settings page module or closed artifact/browser panel is evaluated before its trigger opens; use module spies and a visible loading shell.
- [ ] Run focused tests and capture RED.
- [ ] Wrap the settings dialog and each heavy settings page with `next/dynamic`; use `ssr:false` only for browser-only modules. Apply the same boundary to artifact detail, browser live view, and other closed workspace panels.
- [ ] Preserve a single `SettingsDialogHost` and keyboard/deep-link opening behavior.
- [ ] Run tests GREEN and confirm the chats route no longer references settings/editor/browser chunks initially.
- [ ] Revert one boundary, prove its module-evaluation assertion RED, restore.
- [ ] Commit: `perf(frontend): defer closed workspace panels`.
## Task 4: Split CodeMirror languages and render one Shiki tree
**Files:**
- Modify: `frontend/src/components/workspace/code-editor.tsx`
- Create: `frontend/src/components/workspace/code-editor-languages.ts`
- Modify: `frontend/src/components/ai-elements/code-block.tsx`
- Create: `frontend/src/components/ai-elements/shiki-highlight.tsx`
- Create: `frontend/tests/unit/components/ai-elements/code-block.dom.test.tsx`
- Create: `frontend/tests/unit/components/workspace/code-editor.dom.test.tsx`
- [ ] Write failing tests asserting the editor imports only the selected language adapter, unknown languages fall back to plain text, and one code block calls `codeToHtml` once and creates one highlighted DOM subtree.
- [ ] Run focused tests and capture RED.
- [ ] Add an exhaustive language-to-import loader for CodeMirror modes/themes. Lazy-load the editor host only when an editable text artifact is opened.
- [ ] Lazy-load Shiki highlighting behind the code block boundary. Generate one highlighted HTML string and switch theme using Shiki CSS variables or a single dual-theme token tree, not two calls/two trees.
- [ ] Run focused tests GREEN. Assert the landing/chats initial asset list contains neither CodeMirror nor Shiki chunks.
- [ ] Revert the single-tree implementation, prove the call-count test RED, restore.
- [ ] Commit: `perf(frontend): split editors and deduplicate highlighting`.
## Task 5: Close the bundle/static gate
- [ ] Run `cd frontend && pnpm check && pnpm test`.
- [ ] Run `NEXT_PUBLIC_STATIC_WEBSITE_ONLY=true pnpm build` and `pnpm perf:check`.
- [ ] Inspect the five route asset lists and confirm each heavy chunk has one owning route/interaction.
- [ ] Commit the one-time post-optimization calibration separately as `test(frontend): lock optimized route budgets`; each ceiling must stay below the captured baseline where the route improved and must not be raised afterward.

View File

@ -0,0 +1,95 @@
# Frontend Chat Runtime Performance Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Keep streaming chat, long histories, Markdown, and chat navigation responsive with work proportional to the changed or visible data rather than the entire history.
**Architecture:** Pure indexed derivation functions update only affected turns. Query policy avoids surprise focus refetches. TanStack Virtual owns visible message/chat windows with measured rows and anchored scrolling. Streaming Markdown commits bounded chunks at a fixed cadence.
**Tech Stack:** React 19, TanStack Query, `@tanstack/react-virtual`, Rstest/happy-dom, TypeScript.
**Global Constraints:** Preserve message ordering, tool-result association, human-input cards, usage totals, stick-to-bottom behavior, pagination anchors, and accessibility. Add the virtualization dependency only after its first RED test.
## Task 1: Index message-derived state once per update
**Files:**
- Modify: `frontend/src/components/workspace/messages/message-list.tsx`
- Modify: `frontend/src/components/workspace/messages/message-group.tsx`
- Modify: `frontend/src/core/messages/utils.ts`
- Create: `frontend/src/core/messages/derived-state.ts`
- Create: `frontend/tests/unit/core/messages/derived-state.test.ts`
- Modify: `frontend/tests/unit/components/workspace/messages/message-group.test.ts`
- [ ] Write failing pure tests for `deriveMessageState(messages, previous?)`: assistant-turn usage, tool-call/result lookup, workspace-change anchors, and stable object identity for unchanged completed turns when only the streaming tail changes.
- [ ] Add a 5,000-message operation-count fixture that fails if derivation revisits every completed item on a tail-only update.
- [ ] Run focused tests and capture RED.
- [ ] Implement maps keyed by message ID/tool-call ID and a step-index map keyed by step identity. Replace `steps.indexOf(step)` and repeated full-history scans in render paths with precomputed indices.
- [ ] Memoize the derived state at the message-list boundary and pass precise slices to groups/items.
- [ ] Run tests GREEN; revert the incremental reuse branch to prove the tail-update test RED, then restore.
- [ ] Commit: `perf(frontend): index incremental message derivation`.
## Task 2: Make history pagination focus-stable
**Files:**
- Modify: `frontend/src/core/threads/hooks.ts`
- Create: `frontend/tests/unit/core/threads/thread-history-options.test.ts`
- [ ] Write a failing query-options test asserting `refetchOnWindowFocus: false`, explicit `staleTime`, and unchanged next-page cursor behavior.
- [ ] Run the focused test and capture RED.
- [ ] Set `refetchOnWindowFocus: false` for paged immutable history while leaving active-run streaming/cache writes authoritative. Use a concrete five-minute `staleTime`; explicit refresh/invalidation remains available.
- [ ] Run GREEN; revert the option, prove RED, restore.
- [ ] Commit: `perf(frontend): stabilize paged history cache policy`.
## Task 3: Bound streaming Markdown work
**Files:**
- Modify: `frontend/src/components/workspace/messages/markdown-content.tsx`
- Modify: `frontend/tests/unit/components/workspace/messages/markdown-content.dom.test.tsx`
- Modify: `frontend/tests/unit/components/workspace/messages/markdown-content.test.ts`
- [ ] Add fake-timer tests asserting a long streaming append causes no more than one rendered-content update per 50 ms, the final content becomes exact within 300 ms after stream completion, unmount cancels work, and reduced-motion renders immediately.
- [ ] Run focused tests and capture RED against requestAnimationFrame-per-growth behavior.
- [ ] Replace per-frame growing-string state with a bounded scheduler: retain the latest target in a ref, commit at most every 50 ms, reveal at least 64 new characters per commit, and flush the exact target on completion. Memoize parsed output by the committed string.
- [ ] Run GREEN. Restore the old RAF scheduler temporarily and confirm the update-count assertion RED, then restore the fix.
- [ ] Commit: `perf(frontend): bound streaming markdown renders`.
## Task 4: Virtualize long message histories
**Files:**
- Modify: `frontend/package.json`
- Modify: `pnpm-lock.yaml`
- Create: `frontend/src/components/workspace/messages/virtual-message-list.tsx`
- Modify: `frontend/src/components/workspace/messages/message-list.tsx`
- Create: `frontend/tests/unit/components/workspace/messages/virtual-message-list.dom.test.tsx`
- Modify: relevant Playwright chat history tests under `frontend/tests/e2e/`
- [ ] Write a DOM test with 2,000 variable-height rows and assert fewer than 80 message groups are mounted, the first/last items become reachable, and appending while pinned keeps the bottom anchored.
- [ ] Add a pagination-anchor test: prepending older rows keeps the previously visible message at the same visual offset.
- [ ] Run the tests and capture RED.
- [ ] Add `@tanstack/react-virtual` with pnpm. Implement measured rows with stable message-group IDs, overscan 8, explicit scroll-margin handling, and pin-to-bottom state integrated with `use-stick-to-bottom`.
- [ ] Keep live assistant/tool rows mounted while active even if measurement changes; announce newly arrived assistant content through the existing accessible live region.
- [ ] Run DOM and focused E2E tests GREEN. Temporarily replace the virtual items with the full array and prove the mount-count test RED, then restore.
- [ ] Commit: `perf(frontend): virtualize message history`.
## Task 5: Normalize, bound, and virtualize chat navigation
**Files:**
- Modify: `frontend/src/components/workspace/recent-chat-list.tsx`
- Modify: `frontend/src/app/workspace/chats/page.tsx`
- Create: `frontend/src/core/threads/thread-list-model.ts`
- Create: `frontend/tests/unit/core/threads/thread-list-model.test.ts`
- Create: `frontend/tests/unit/components/workspace/recent-chat-list.dom.test.tsx`
- [ ] Write failing tests proving sidebar and page share one normalized `Map<threadId, ThreadSummary>`, sorting happens only when page data changes, retained pages are capped at 200 threads, and a 1,000-row fixture mounts fewer than 60 rows.
- [ ] Run focused tests and capture RED.
- [ ] Move dedupe/sort into one memoized selector. Use bounded infinite-query retention and virtual rows for both consumers. Keep sentinel pagination based on the virtualizer's final item instead of a permanently rendered DOM tail.
- [ ] Preserve active-thread visibility and keyboard/focus semantics.
- [ ] Run tests GREEN and relevant chats E2E. Reintroduce full mapping to prove mount-count RED, restore.
- [ ] Commit: `perf(frontend): bound and virtualize chat lists`.
## Final verification
- [ ] Run `cd frontend && pnpm check && pnpm test`.
- [ ] Run Playwright coverage for long history, pagination, active streaming, and chat navigation.
- [ ] Profile a 5,000-message synthetic thread: record commit count and maximum rendered rows before/after in the PR description.
- [ ] Run `pnpm perf:check` and confirm virtualization does not breach route budgets.

View File

@ -0,0 +1,106 @@
# Frontend Live Media and Artifact Performance Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Stop hidden animations, remove Browser Live base64/JSON/frame-state overhead, and make large text artifact preview bounded end to end.
**Architecture:** A shared visibility hook gates animation loops. Browser Live negotiates binary JPEG frames while retaining legacy JSON/base64 fallback. The browser client owns an object URL outside React render cadence and presents at most once per animation frame. Artifact text uses `FileResponse` range semantics and a 1 MiB preview contract with an explicit full-load action.
**Tech Stack:** React 19, WebSocket, FastAPI/Starlette, Playwright CDP, TypeScript/Rstest, Python/pytest.
**Global Constraints:** Read the relevant Browser Automation and Artifact sections of `backend/AGENTS.md` before edits. Preserve legacy Browser Live clients. Revoke every object URL. Preserve artifact download/content-disposition security and path ownership checks. Backend changes are test-first and must pass Ruff.
## Task 1: Pause decorative work when hidden or offscreen
**Files:**
- Create: `frontend/src/core/dom/use-render-activity.ts`
- Create: `frontend/tests/unit/core/dom/use-render-activity.dom.test.tsx`
- Modify: `frontend/src/components/ui/galaxy.jsx`
- Modify: `frontend/src/components/ui/magic-bento.tsx`
- Create: `frontend/tests/unit/components/ui/galaxy.dom.test.tsx`
- Create: `frontend/tests/unit/components/ui/magic-bento.dom.test.tsx`
- [ ] Write failing tests for `useRenderActivity(ref)`: false when document hidden or intersection false, true only when both visible, and observer/listener cleanup on unmount.
- [ ] Add component tests proving Galaxy cancels RAF while inactive and Magic Bento attaches pointer movement only to its container and coalesces work to one RAF.
- [ ] Run focused tests and capture RED.
- [ ] Implement the shared hook using `visibilitychange` plus `IntersectionObserver`. Gate Galaxy's RAF lifecycle. Replace Magic Bento's document-wide mousemove with container `pointermove` and one pending RAF.
- [ ] Run GREEN; disable the visibility gate to prove the RAF test RED, restore.
- [ ] Commit: `perf(frontend): suspend hidden landing effects`.
## Task 2: Negotiate binary Browser Live frames in the backend
**Files:**
- Modify: `backend/packages/harness/deerflow/community/browser_automation/session.py`
- Modify: `backend/app/gateway/routers/browser.py`
- Modify: `backend/tests/test_browser_automation.py`
- Modify: `backend/tests/test_browser_router.py`
- Modify: `backend/AGENTS.md`
- [ ] Add failing session tests asserting `_live_frame()` returns raw JPEG `bytes`, never base64 text.
- [ ] Add router tests for `?frame_format=binary`: capability is accepted, frame events use `websocket.send_bytes`, control/status events remain JSON, and a client without the query receives the legacy `{"type":"frame","data":"..."}` JSON payload.
- [ ] Run `cd backend && uv run pytest tests/test_browser_automation.py tests/test_browser_router.py -q` and capture RED.
- [ ] Change `frame_queue` to `asyncio.Queue[bytes]`; keep drop-oldest backpressure. Encode base64 only inside the legacy gateway send path. Reject unsupported capability values with a JSON error and close code 1008.
- [ ] Update `backend/AGENTS.md` with the wire contract and compatibility boundary.
- [ ] Run focused tests GREEN. Reintroduce session-layer base64 to prove the raw-byte assertion RED, restore.
- [ ] Commit: `perf(browser): stream negotiated binary live frames`.
## Task 3: Present Browser Live frames outside React state cadence
**Files:**
- Modify: `frontend/src/components/workspace/browser-view/use-browser-stream.ts`
- Create: `frontend/src/components/workspace/browser-view/frame-buffer.ts`
- Create: `frontend/tests/unit/components/workspace/browser-view/frame-buffer.dom.test.ts`
- Modify: `frontend/tests/e2e/browser-feature.spec.ts`
- [ ] Write failing tests for `FrameBuffer`: multiple binary frames before RAF expose only the latest frame, replaced/dropped URLs are revoked, close revokes the current URL, and legacy JSON/base64 still renders.
- [ ] Assert the WebSocket URL includes `frame_format=binary` and binary frames are not passed through `JSON.parse`.
- [ ] Run focused tests and capture RED.
- [ ] Set `binaryType="blob"`. Keep status/control in React state, but feed frame blobs to `FrameBuffer`, which owns one pending RAF and one object URL. Expose the current URL through `useSyncExternalStore` or an imperative image ref; do not allocate a data URL.
- [ ] Run tests and browser E2E GREEN. Revert to per-message state to prove the coalescing test RED, restore.
- [ ] Commit: `perf(browser): coalesce binary frame presentation`.
## Task 4: Serve text artifacts with byte ranges
**Files:**
- Modify: `backend/app/gateway/routers/artifacts.py`
- Modify: `backend/tests/test_artifacts_router.py`
- Modify: `backend/tests/blocking_io/test_artifacts_router.py`
- [ ] Add failing API tests for a UTF-8 text file: full GET remains inline with the correct media type, `Range: bytes=0-1048575` returns 206/`Content-Range`, invalid ranges return 416, and active HTML/SVG content remains forced-download.
- [ ] Add a blocking-I/O regression asserting the async route does not call `Path.read_text` or `Path.read_bytes` for normal text preview.
- [ ] Run focused backend tests and capture RED.
- [ ] Use Starlette `FileResponse` for safe text and binary inline responses, passing the detected textual media type and existing security headers. Retain attachment handling for active content and explicit downloads.
- [ ] Run focused tests GREEN. Revert safe text to `PlainTextResponse`, prove range/blocking tests RED, restore.
- [ ] Commit: `perf(artifacts): stream ranged text responses`.
## Task 5: Bound the client artifact preview to 1 MiB
**Files:**
- Modify: `frontend/src/core/artifacts/loader.ts`
- Modify: `frontend/src/core/artifacts/preview.ts`
- Modify: `frontend/src/components/workspace/artifacts/artifact-file-detail.tsx`
- Create: `frontend/tests/unit/core/artifacts/loader.test.ts`
- Modify: `frontend/tests/unit/core/artifacts/preview.test.ts`
- Modify: `frontend/tests/e2e/artifact-preview.spec.ts`
- [ ] Write failing tests that require `Range: bytes=0-1048575`, parse status 206 and `Content-Range`, mark the result `{ truncated: true, totalBytes }`, and preserve full/small responses.
- [ ] Add a DOM/E2E assertion that truncated text shows byte counts plus an explicit “Load full file” action, and CodeMirror is not mounted before that action.
- [ ] Run focused tests and capture RED.
- [ ] Add `ARTIFACT_PREVIEW_MAX_BYTES = 1_048_576`. Decode only the returned prefix, display a truncation banner, and refetch without Range only after explicit consent. Plain/Markdown preview remains available for the prefix; editable CodeMirror requires full content.
- [ ] Run tests GREEN. Remove the Range header temporarily to prove RED, restore.
- [ ] Commit: `perf(artifacts): bound large text previews`.
## Task 6: Documentation and cross-stack verification
**Files:**
- Modify: `README.md`
- Modify: `frontend/AGENTS.md`
- Modify: `backend/AGENTS.md`
- Modify: `CHANGELOG.md`
- [ ] Document Browser Live binary negotiation/fallback, the 1 MiB artifact preview behavior, and performance verification commands.
- [ ] Run `cd backend && make format && make lint && uv run pytest tests/test_browser_automation.py tests/test_browser_router.py tests/test_artifacts_router.py tests/blocking_io/test_artifacts_router.py -q`.
- [ ] Run `cd frontend && pnpm check && pnpm test && pnpm test:e2e -- tests/e2e/browser-feature.spec.ts tests/e2e/artifact-preview.spec.ts`.
- [ ] Run the full root-supported checks that are practical locally, then `cd frontend && NEXT_PUBLIC_STATIC_WEBSITE_ONLY=true pnpm build && pnpm perf:check`.
- [ ] Use browser DevTools to verify binary websocket frames, bounded frame presentation, object URL cleanup after panel close, a 206 artifact preview, and an explicit full-load request.
- [ ] Commit: `docs: document frontend performance boundaries`.

View File

@ -0,0 +1,317 @@
# Frontend performance remediation — design
## Background
A production-oriented audit identified seventeen performance risks across the
default DeerFlow web deployment. The audit was originally performed against an
older `main`, so this design starts from a source re-audit of
`origin/main@17461ee5` rather than treating the old findings as immutable.
Upstream has already improved three parts of the original report:
- render-facing stream messages are coalesced to an 80 ms budget and the main
history/live/optimistic merge is memoized;
- processing groups build tool-result and browser-view indexes once instead of
scanning the group for every tool call;
- binary artifacts are served with `FileResponse`, including byte-range support.
Those changes are preserved. The remaining work is one comprehensive PR made
of independently reviewable commits, with a measurement and regression gate
after each slice.
## Goals
1. Fix every still-reproducible item from the seventeen-item audit rather than
only the easiest P0 subset.
2. Bound network bytes, client bundle bytes, retained DOM, repeated stream
computation, animation work, browser-frame copies, artifact preview memory,
and mock-route filesystem work.
3. Preserve chat ordering, streaming semantics, history pagination, anchored
scrolling, locale behavior, artifact downloads, Browser Live control, and
the default Docker/local deployment topology.
4. Add deterministic tests or executable measurements for every fix so later
changes cannot silently restore the same cost.
5. Deliver the work as one PR whose commits can be reviewed and reverted by
subsystem.
## Non-goals
- Replacing LangGraph, Streamdown, Nextra, CodeMirror, or the existing query
layer.
- Changing model, sandbox, agent, or scheduler behavior.
- Optimizing third-party hosted deployments that bypass DeerFlow's bundled
nginx; the default nginx/Next deployment is the delivery contract here.
- Claiming a performance win from source shape alone. Production build output
and runtime measurements are required.
## Current finding matrix
| # | Audit topic | Current status on `17461ee5` | Planned disposition |
|---|---|---|---|
| 1 | No explicit gzip/Brotli in bundled nginx | Reproduced | Add portable gzip for compressible static/document responses while excluding SSE and already-compressed media; test the generated nginx config and live headers. |
| 2 | Full-history work on every stream chunk | Partially fixed upstream | Keep the 80 ms render coalescer, then make grouping, usage, and human-input derivation reuse an immutable prefix and recompute only the active tail. Compare incremental results with the existing full derivation in tests. |
| 3 | Loaded message history is not virtualized | Reproduced | Add turn-level dynamic-height windowing with anchored prepend and overscan; keep the active streaming turn mounted. |
| 4 | No component-level code splitting | Reproduced | Lazy-load settings, inactive settings sections, Browser Live, artifact preview/editor, and other interaction-only heavy surfaces. |
| 5 | Docs/blog carry excessive shared dependencies | Needs current production measurement; source risk remains | Establish a fresh route asset baseline, remove workspace-only providers/styles from the root layout, and enforce route budgets. |
| 6 | Root locale cookie makes the whole site dynamic | Reproduced | Make the root layout static; move cookie-aware locale providers to auth/workspace and keep public locale resolution route-owned. |
| 7 | Streaming Markdown repeatedly parses growing text | Reproduced | Align reveal updates to the stream render budget, eliminate the per-animation-frame parse loop, and keep cheap streaming code rendering until the block settles. |
| 8 | Focus can refetch every loaded history page | Reproduced | Disable focus refetch for immutable paged history and explicitly invalidate/refetch on run lifecycle events. |
| 9 | Tool lookup and `steps.indexOf` produce quadratic work | Partially fixed upstream | Preserve the new lookup maps; replace remaining repeated `steps.indexOf(...)` calls with indexed conversion metadata. |
| 10 | Code blocks run and retain two Shiki renders | Reproduced | Lazy-load Shiki and produce one dual-theme CSS-variable rendering, with request/result caching and stale-effect protection. |
| 11 | Below-fold landing case-study images load eagerly | Reproduced | Replace CSS backgrounds with optimized lazy images, correct responsive sizes, and retain the card overlay/hover behavior. |
| 12 | Galaxy and Magic Bento do offscreen/unthrottled work | Reproduced | Pause on invisibility/document hide, honor reduced motion, and coalesce pointer work to one animation frame. |
| 13 | Browser Live uses JSON + base64 + React state per frame | Reproduced | Send JPEGs as binary WebSocket messages, use revocable object URLs, and present only the latest frame per paint. Keep JSON for control metadata. |
| 14 | Text artifact preview reads/renders the full file | Partially fixed upstream | Serve text through range-capable responses, request a bounded preview first, show truncation/load-full affordances, and avoid mounting CodeMirror for oversized previews. |
| 15 | Chat lists render every loaded row in two surfaces | Reproduced | Share normalized query data, virtualize the full chats page, and bound the sidebar to a recent/pinned window with explicit older-chat navigation. |
| 16 | Global styles/translations/background queries are too broad | Reproduced | Scope Markdown/KaTeX/Nextra styles by route, avoid shipping both locale payloads where possible, and mount queries only with their visible feature surface. |
| 17 | Mock API uses synchronous IO and dynamic project tracing | Reproduced | Use async cached reads and a deterministic demo-thread manifest so request handling and output tracing do not scan the project tree. |
## Design
### 1. Reproducible performance baseline and budgets
Add a repository script that builds the production frontend and reports, per
representative route, the transitive first-load JavaScript and CSS from Next's
build manifests. The representative routes are:
- `/`
- `/login`
- `/workspace/chats`
- `/workspace/chats/<fixture-id>`
- `/en/docs`
- `/blog/posts`
The script writes no tracked build artifact. It prints stable JSON plus a human
summary and can compare against a checked-in budget file. Budgets are set only
after the fresh `origin/main` baseline is captured; the PR must improve the
audited routes and may not regress an unrelated route beyond a small explicit
tolerance. A second smoke check starts the production stack and records
`Content-Encoding`, `Cache-Control`, and transferred bytes for HTML, JS, CSS,
JSON, and SSE samples.
Long-thread and large-artifact fixtures provide runtime gates:
- a synthetic history with hundreds of heterogeneous turns;
- a continuously growing Markdown answer with code, math, and citations;
- a multi-megabyte UTF-8 artifact;
- a burst of Browser Live JPEG frames.
Unit tests pin bounded derivation and protocol behavior; browser measurements
record DOM node count, rendered turn count, and frame URL cleanup.
### 2. Delivery, static rendering, and route ownership
The bundled nginx enables gzip for HTML, JavaScript, CSS, JSON, XML, and SVG.
It does not gzip `text/event-stream`, fonts, images, video, archives, or other
already-compressed payloads. Proxy buffering remains disabled for streaming
routes. A config test and a live header smoke test prevent an apparently valid
directive from being placed in the wrong nginx context.
The top-level Next layout becomes request-invariant:
- it owns only document structure, the theme provider, and truly global CSS;
- it does not call `cookies()`;
- Streamdown and KaTeX styles move to the layouts that render rich content;
- landing navigation uses the public default locale, docs derive locale from
their explicit `[lang]` segment, and blog keeps its own locale selection;
- auth and workspace layouts read the locale cookie and mount `I18nProvider`.
The client provider receives only the selected locale from its server layout
and synchronizes `document.documentElement.lang` when the workspace/auth locale
changes. Translation dictionaries include formatter functions and therefore
cannot cross the React Server Component serialization boundary. The
interactive auth/workspace provider deliberately owns both small dictionaries
so switching language remains immediate, while public landing, docs, and blog
routes resolve one route-owned locale without mounting that provider. This
retains the existing language switch behavior without making public routes
dynamically rendered or putting both dictionaries in every route bundle.
### 3. Bundle boundaries
Interaction-only code must not be reachable from the initial workspace chunk.
The workspace root keeps only a small settings-store listener. The settings
dialog is imported after it opens, and each settings page is imported only when
selected. Browser Live, artifact code/preview tooling, and CodeMirror language
packages are loaded only when their panels and modes are used.
Shiki is loaded through an async highlighter boundary instead of a static
top-level import. One Shiki call emits light and dark token variables in one DOM
tree; theme changes are CSS-only. A bounded cache keys on code, language, line
number mode, and highlighter configuration. Effects ignore stale resolutions
when code changes or the component unmounts.
The baseline script verifies that docs/blog no longer inherit workspace-only
chunks and that a closed settings/artifact/browser surface contributes no
interaction-only chunk to initial workspace loading.
### 4. Bounded chat rendering and derivation
Message virtualization happens at the existing `ThreadMessageGroup` boundary,
not individual tool steps. A small headless `@tanstack/react-virtual` adapter
owns dynamic measurements, overscan, and stable identity keys while the
existing conversation component remains the scroll owner. The newest streaming
group is always in the render window. Prepending an older history page
preserves the visual anchor; bottom-follow behavior continues only when the
user was already pinned to the bottom. Selection, edit/regenerate controls,
human-input cards, artifact links, and run-duration anchors remain inside their
current group. A dependency-size comparison is part of the bundle gate; if the
adapter would break the workspace route budget, the same interface is
implemented locally rather than accepting an initial-load regression.
Full derivation remains the reference algorithm. A new incremental adapter
stores the settled group prefix and recomputes only from the earliest message
whose identity/content/run metadata changed. Tests feed append, in-place stream
mutation, tool result, reasoning, history prepend, checkpoint replacement,
summarization bridge, edit/regenerate, and hidden human-input sequences through
both algorithms and require identical groups.
Turn usage, run-duration anchors, workspace-change anchors, and human-input
state use the same stable-prefix boundary or keyed indexes. The active tail may
change every 80 ms, but historical turns are not rescanned. Remaining
`steps.indexOf(...)` calls are removed by attaching the index while steps are
created.
Paged history uses `refetchOnWindowFocus: false` and a nonzero stale window.
Run finish, stop, regenerate, edit-and-regenerate, and explicit refresh remain
authoritative invalidation points.
### 5. Streaming Markdown
The SDK/coalescer's 80 ms snapshot is the upper-frequency source of renderable
content. The current smooth-reveal hook must not turn one snapshot into a new
full Markdown parse on every animation frame. It will reveal at the same bounded
cadence (or render the coalesced snapshot directly when the delta is small),
while Streamdown's visual animation handles appearance without creating extra
source strings.
Incomplete code blocks continue to use the cheap streaming `<pre>/<code>`
components. Shiki, Mermaid, and other expensive settled rendering activates
only after the relevant block/turn is stable. DOM tests cover cancellation,
rapid target replacement, incomplete fences/lists, reduced motion, and the
final exact content.
### 6. Landing visuals
Case-study cards use `next/image` with `fill`, responsive `sizes`, and lazy
loading. Only genuinely above-the-fold media can be priority-loaded. Source
images may be re-encoded if doing so materially lowers bytes without visible
quality loss; generated files remain deterministic and are compared in the
route transfer report.
Galaxy owns a visibility state derived from `IntersectionObserver`, document
visibility, and `prefers-reduced-motion`. Its RAF exists only while rendering is
allowed. Magic Bento listens for pointer movement only while its section is
active and coalesces geometry reads/writes to one RAF. Both components cancel
pending work and animations on cleanup. Tests use mocked RAF/observers to prove
that offscreen, hidden, reduced-motion, and unmounted states perform no frames.
### 7. Browser Live binary frame path
Browser session capture returns JPEG bytes rather than base64 text. The updated
client requests `frame_format=binary` in the WebSocket URL. The gateway keeps
its bounded lossy frame queue and sends frame entries with
`WebSocket.send_bytes`; URL, tab, rejection, and input messages remain JSON
text. Connections without the capability retain the legacy base64 JSON frame
for rolling-deployment compatibility. The new protocol is self-demultiplexing
by WebSocket message type and does not add an extra binary header.
The client treats binary messages as blobs, keeps only the newest pending frame,
and publishes at most one object URL per animation frame. Replaced and unmounted
URLs are revoked. JSON parsing is performed only for text messages. The static
artifact screenshot fallback and all input/control behavior stay unchanged.
Backend tests cover capability negotiation, byte delivery, the legacy fallback,
queue dropping, metadata text frames, auth, and disconnect cleanup; frontend
tests cover coalescing and URL revocation.
### 8. Bounded artifact previews
Active content remains download-only and binary media remains range-streamed.
Regular text artifacts move to a range-capable inline `FileResponse` path. The
frontend initially asks for a fixed byte range and reads response metadata to
distinguish complete from truncated content. It shows file size and a clear
"Load full file" action when truncated; download always returns the original
file.
The 1 MiB preview limit is owned by the frontend, while the shared HTTP Range
contract lets both regular files and bounded archive members honor it. The
preview handles an incomplete UTF-8 tail safely, and tests cover ASCII,
multibyte boundary, empty, exact-limit, oversized, skill-archive, active, and
binary files. Large text opens in the lightweight preview first; CodeMirror is
not instantiated until content is within its safe budget or the user explicitly
loads the full file.
### 9. Conversation lists, queries, and mock data
`useInfiniteThreads` remains the single cache. A shared selector deduplicates
and sorts pages once so the sidebar and chats page do not repeat normalization.
The full chats page uses fixed/dynamic row virtualization. The sidebar renders a
bounded recent window plus all pinned entries, and routes users to the full
page for older conversations instead of silently auto-loading/rendering an
unbounded list.
Channel/provider, scheduler, and other feature queries mount only with their
visible page/panel. Locale payloads are route-scoped: public routes own one
selected locale, while the interactive auth/workspace boundary owns both for
instant switching. Route-scoped CSS is verified in the build manifest rather
than inferred from import location.
Mock route handlers use promise-based filesystem APIs and a cached,
deterministically generated demo-thread manifest. The workspace redirect and
thread search do not call `readdirSync` or dynamically construct project-wide
paths at request time. A manifest consistency test fails if demo fixtures are
added or removed without regeneration.
## Error handling and compatibility
- Performance fallbacks preserve content: a failed lazy import shows the
existing surface error boundary; a failed bounded preview offers download;
unsupported binary Browser Live frames fall back to the latest static
screenshot.
- No user-authored content is moved into `dangerouslySetInnerHTML` beyond the
existing Shiki output boundary.
- WebSocket auth, origin, and exact thread-owner checks remain before session
acquisition.
- Range requests do not weaken the active-content download policy or path
traversal validation.
- Reduced work must not hide persisted history, discard a Browser Live control
event, or change the final streamed Markdown text.
## Implementation and commit slices
The single PR is implemented in this order:
1. Baseline/budget tooling and failing regression tests.
2. Nginx compression, static root ownership, route CSS/locale split, landing
image delivery.
3. Lazy bundle boundaries and single-pass Shiki.
4. Incremental chat derivation, history query policy, message/chat
virtualization, and bounded Markdown cadence.
5. Visibility-aware landing effects and binary Browser Live frames.
6. Range-bounded text artifact previews and async/cached mock data.
7. Documentation, production build comparison, full frontend/backend checks,
and review fixes.
Each behavior change follows red-green-refactor. Commits remain independently
testable, but the PR description reports the aggregate before/after result and
maps every audit item to its evidence.
## Verification and completion gate
The PR is not complete until all of the following are true:
- every row in the seventeen-item matrix has a code change or current evidence
proving that no change is required;
- focused unit/DOM/backend tests pass and demonstrate their initial failure;
- `cd frontend && pnpm check`, `pnpm test`, and `pnpm build` pass;
- relevant Playwright suites pass for streaming, history prepend, settings,
artifacts, and Browser Live;
- backend artifact/browser tests, the blocking-IO gate for changed async code,
Ruff lint, and format checks pass;
- nginx configuration validation and live compression/SSE smoke checks pass;
- production route asset/transfer budgets pass and the before/after report is
included in the PR;
- source review, automated review, PR review threads, and CI contain no
unresolved actionable finding.
An absence of review comments is not sufficient by itself: the final audit must
walk this matrix and attach authoritative evidence for each row.

View File

@ -71,6 +71,11 @@ The frontend is a stateful chat application. Users create **threads** (conversat
`ThreadState.artifacts` remains the authoritative artifact list. The artifacts provider persists only thread-scoped panel UI state (`open`, selected path, and a refresh bootstrap cache) in session storage; an initial empty stream value must not overwrite that restored state before history finishes loading.
Formal artifact content is refreshed once when the run finishes; transient `write-file:` previews remain message-driven.
The detail view exposes explicit editing only for an already-opened formal UTF-8 text artifact under `/mnt/user-data/outputs`. Drafts stay in provider memory until Save so switching right-side panels cannot discard them, render in Markdown/HTML preview, and are protected from remote refreshes by the loaded SHA-256 revision. Saving is disabled during an active run; a changed revision preserves the draft and surfaces a conflict instead of overwriting agent output.
Regular artifact text loads request at most the first 1 MiB through an HTTP
byte range. A truncated preview must stay lightweight and expose an explicit
full-file action; do not mount CodeMirror for that artifact until the user
requests and receives the complete content. The Gateway retains range
ownership and returns 206/416 through `FileResponse`.
3. `useThreadHistory` loads persisted conversation pages from `GET /api/threads/{id}/messages/page`, preserving the backend's thread-global event `seq`; rendering overlays checkpoint/live copies at their matching canonical identities (a summarized checkpoint may contain a protected early input plus a recent tail). Context-compaction rescue diffs every retained visible identity rather than slicing at the first anchor, and keeps a run-scoped ledger of committed visible messages so replacement updates and repeated rolling checkpoint windows cannot erase an already displayed step. The resolver suppresses checkpoint/transient prefixes whose canonical position is still behind an unloaded cursor page instead of collapsing that unknown gap before a recent anchor, then adds optimistic messages without timestamp re-sorting. History invalidation preserves already-loaded pages so their established ordering positions are not discarded. Dynamic context re-keys the submitted user message from `X` to `X__user`; UI identity matching normalizes that reserved suffix only for human messages so the submitted frame and checkpoint replacement remain one visible turn. A locally submitted turn also records its pre-submit identity baseline: if `messages-tuple` publishes new AI/tool steps before `values` publishes that turn's human message, render ordering moves only those non-baseline visible steps behind the new human while leaving history, hidden controls, and reconnected runs untouched. Keep that local order anchor through finish, stop, and stream error because the SDK's settled frame can retain transient event order; replace it on the next local submit and clear it on thread switch or replay-gap recovery.
4. Stop actions call the LangGraph SDK stream stop path; `core/threads/hooks.ts` invalidates current-thread, thread-history, token-usage, and sidebar/search caches immediately and schedules one follow-up refetch because SDK stop may finish via abort + fire-and-forget cancel before backend title finalization commits
5. TanStack Query manages server state; localStorage stores user settings. The
@ -106,6 +111,13 @@ Edit-and-rerun is deliberately latest-turn-only. `core/messages/utils.ts::getLat
### Key Patterns
- **Server Components by default**, `"use client"` only for interactive components
- **Static root boundary**`src/app/layout.tsx` must not read cookies or import
chat-only KaTeX/Streamdown styles. Auth and workspace layouts own the cookie-derived
locale provider; docs derive locale from their route, and blog owns its preference
cookie. Public server routes load one dictionary at a time through
`core/i18n/translations.ts`; the interactive auth/workspace client provider owns both
formatter-bearing dictionaries because functions cannot cross the RSC boundary.
Keep public `/` static and keep rich-content CSS on the routes that render it.
- **Thread hooks** (`useThreadStream`, `useSubmitThread`, `useThreads`) are the primary API interface
- **Thread routes** — construct Web UI chat paths through `core/threads/utils.ts::pathOfThread()`, which percent-encodes both custom agent names and thread IDs before inserting them into route segments
- **LangGraph client** is a singleton obtained via `getAPIClient()` in `core/api/`
@ -125,6 +137,12 @@ Edit-and-rerun is deliberately latest-turn-only. `core/messages/utils.ts::getLat
- `src/app/workspace/chats/[thread_id]/page.tsx` and `src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx` own active-goal display state for their composer overlays.
- `src/components/workspace/messages/message-list.tsx` owns human-input card answered/latest/pending gating; entry pages only translate a submitted card response into `sendMessage` calls.
- `src/components/workspace/browser-view/browser-view-panel.tsx` forwards each physical pointer click as one `click` input; do not also emit `down`/`up` for the same gesture because the remote Playwright click would run twice.
- `src/components/workspace/browser-view/use-browser-stream.ts` requests binary JPEG
frames with `frame_format=binary`; status, URL, tabs, and navigation rejection
messages remain JSON. `LatestBrowserFrameBuffer` keeps only the newest pending
frame, publishes through `useSyncExternalStore` at most once per animation
frame, and owns object-URL revocation. Keep the Gateway's legacy JSON/base64
frame path for older clients.
- `src/core/threads/hooks.ts` owns pre-submit upload state and thread submission.
- `src/components/workspace/chats/chat-box.tsx` owns the desktop right-panel layout, and **all three** right panels (artifacts, sidecar, browser) share one `ResizablePanelGroup` — do not fork a non-resizable branch per panel kind, which is how the artifacts divider silently lost its drag handle (#4465). Open/close is `collapse()` / `resize()` on the side panel's imperative handle, not conditional rendering, so the width can animate. Three constraints hold that together: the size transition is applied from the group as `[&>[data-panel]]:transition-[flex-grow]` because the sized flex item is the library's own `[data-panel]` element rather than the child `className` lands on; it is applied only while an open/close is in flight, so a drag is not interpolated frame by frame; and during the animation the panel content is held at its final width in `cqw` and clipped, because a reflowing message list re-runs its scroll-to-bottom (pinned by `tests/e2e/sidecar-chat.spec.ts`'s no-animated-scroll test) and a re-wrapping composer changes which responsive labels it shows. Because the panel is `collapsible`, the library can also collapse it to `0%` on its own when a drag crosses `minSize`, without going through the state that owns it. `onResize` records the last positive size while the pointer moves, but the owning `sidecar` / `browserView` / `artifactsOpen` state must only mirror a final `0%` layout from `onLayoutChanged`, after pointer release; closing on the first `0%` resize frame breaks a continuous drag that reaches the edge and then reverses before release.
@ -165,3 +183,12 @@ When adding features:
3. Write unit tests under `tests/unit/` (`pnpm test`) and E2E tests under `tests/e2e/` (`pnpm test:e2e`)
4. Run `pnpm check` before committing
5. Update this `AGENTS.md` when architecture, commands, or conventions change
Route asset budgets are enforced with `pnpm perf:check`. The command measures
`/login` from a normal production build, then builds in static-demo mode for the
fixture-backed workspace routes. It starts the production server on temporary local
ports, measures the unique JavaScript and CSS files referenced by representative
routes, writes the detailed result to `.next/performance-results.json`, and compares
totals with `performance-budgets.json`. Fix route ownership or split points when a
budget fails; do not raise a ceiling without documenting and reviewing the measured
regression.

View File

@ -12,6 +12,7 @@
"format:write": "prettier --write .",
"lint": "eslint . --ext .ts,.tsx",
"lint:fix": "eslint . --ext .ts,.tsx --fix",
"perf:check": "node scripts/measure-route-assets.mjs --check",
"preview": "next build && next start",
"start": "next start",
"test": "rstest",
@ -49,6 +50,7 @@
"@streamdown/mermaid": "1.0.2",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.90.17",
"@tanstack/react-virtual": "^3.13.23",
"@types/hast": "^3.0.4",
"@uiw/codemirror-theme-basic": "^4.25.4",
"@uiw/codemirror-theme-monokai": "^4.25.4",

View File

@ -0,0 +1,11 @@
{
"/login": { "css": 170000, "js": 850000 },
"/": { "css": 175000, "js": 1050000 },
"/workspace/chats": { "css": 190000, "js": 1750000 },
"/workspace/chats/7cfa5f8f-a2f8-47ad-acbd-da7137baf990": {
"css": 190000,
"js": 4100000
},
"/en/docs": { "css": 270000, "js": 4200000 },
"/blog/posts": { "css": 270000, "js": 4200000 }
}

View File

@ -98,6 +98,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.90.17
version: 5.90.20(react@19.2.4)
'@tanstack/react-virtual':
specifier: ^3.13.23
version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@types/hast':
specifier: ^3.0.4
version: 3.0.4

View File

@ -0,0 +1,249 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { readFile, stat, writeFile } from "node:fs/promises";
import net from "node:net";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const DEMO_THREAD_ID = "7cfa5f8f-a2f8-47ad-acbd-da7137baf990";
export const ROUTES = [
"/",
"/login",
"/workspace/chats",
`/workspace/chats/${DEMO_THREAD_ID}`,
"/en/docs",
"/blog/posts",
];
/**
* @param {boolean} staticWebsiteOnly
* @param {Readonly<Record<string, string | undefined>>} baseEnvironment
*/
export function createBuildEnvironment(
staticWebsiteOnly,
baseEnvironment = process.env,
) {
const environment = { ...baseEnvironment };
if (staticWebsiteOnly) {
environment.NEXT_PUBLIC_STATIC_WEBSITE_ONLY = "true";
} else {
delete environment.NEXT_PUBLIC_STATIC_WEBSITE_ONLY;
}
return environment;
}
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const frontendDir = path.resolve(scriptDir, "..");
export function extractAssetPaths(html) {
const assets = { css: [], js: [] };
const seen = { css: new Set(), js: new Set() };
const attributePattern = /(?:src|href)=["']([^"']+)["']/g;
for (const match of html.matchAll(attributePattern)) {
const value = match[1];
if (!value?.startsWith("/_next/static/")) continue;
const relativePath = value.slice("/_next/static/".length).split("?", 1)[0];
if (!relativePath) continue;
const kind = relativePath.endsWith(".css")
? "css"
: relativePath.endsWith(".js")
? "js"
: null;
if (kind && !seen[kind].has(relativePath)) {
seen[kind].add(relativePath);
assets[kind].push(relativePath);
}
}
return assets;
}
export function evaluateBudgets(measurements, budgets) {
const failures = [];
for (const [route, limits] of Object.entries(budgets)) {
const actual = measurements[route];
if (!actual) {
failures.push(`${route}: route was not measured`);
continue;
}
for (const kind of ["css", "js"]) {
if (actual[kind] <= limits[kind]) continue;
const overage = actual[kind] - limits[kind];
failures.push(
`${route} ${kind}: ${actual[kind]} bytes exceeds ${limits[kind]} bytes by ${overage} ${overage === 1 ? "byte" : "bytes"}`,
);
}
}
return failures;
}
export function formatMeasurementSummary(measurements) {
const lines = ["Route asset summary"];
for (const [route, measurement] of Object.entries(measurements)) {
lines.push(
`${route} [${measurement.buildMode}] JS ${measurement.js} B | CSS ${measurement.css} B | HTML ${measurement.html} B`,
);
}
return lines.join("\n");
}
async function getFreePort() {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close();
reject(new Error("Failed to allocate a local port"));
return;
}
const { port } = address;
server.close((error) => (error ? reject(error) : resolve(port)));
});
});
}
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: frontendDir,
env: process.env,
stdio: "inherit",
...options,
});
child.on("error", reject);
child.on("exit", (code, signal) => {
if (code === 0) resolve();
else reject(new Error(`${command} exited with ${code ?? signal}`));
});
});
}
async function waitForServer(baseUrl, child) {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(`Next.js server exited with ${child.exitCode}`);
}
try {
const response = await fetch(baseUrl, { redirect: "manual" });
if (response.status < 500) return;
} catch {
// The server is still starting.
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error("Timed out waiting for the Next.js production server");
}
async function measureRoute(baseUrl, route) {
const response = await fetch(`${baseUrl}${route}`, { redirect: "manual" });
if (!response.ok) {
throw new Error(`${route} returned HTTP ${response.status}`);
}
const html = await response.text();
const assets = extractAssetPaths(html);
const totals = { css: 0, js: 0 };
for (const kind of ["css", "js"]) {
for (const relativePath of assets[kind]) {
const assetPath = path.join(frontendDir, ".next", "static", relativePath);
totals[kind] += (await stat(assetPath)).size;
}
}
return { ...totals, assets, html: Buffer.byteLength(html) };
}
async function measureBuild(routes, staticWebsiteOnly) {
const env = createBuildEnvironment(staticWebsiteOnly);
await run("pnpm", ["exec", "next", "build"], {
env,
});
const port = await getFreePort();
const server = spawn(
"pnpm",
[
"exec",
"next",
"start",
"--hostname",
"127.0.0.1",
"--port",
String(port),
],
{
cwd: frontendDir,
env,
stdio: ["ignore", "inherit", "inherit"],
},
);
try {
const baseUrl = `http://127.0.0.1:${port}`;
await waitForServer(baseUrl, server);
const measurements = {};
for (const route of routes) {
measurements[route] = {
...(await measureRoute(baseUrl, route)),
buildMode: staticWebsiteOnly ? "static-demo" : "normal",
};
}
return measurements;
} finally {
if (server.exitCode === null) {
await new Promise((resolve) => {
const forceStop = setTimeout(() => server.kill("SIGKILL"), 5_000);
server.once("exit", () => {
clearTimeout(forceStop);
resolve();
});
server.kill("SIGTERM");
});
}
}
}
async function main() {
const check = process.argv.includes("--check");
const measurements = {
...(await measureBuild(["/login"], false)),
...(await measureBuild(
ROUTES.filter((route) => route !== "/login"),
true,
)),
};
await writeFile(
path.join(frontendDir, ".next", "performance-results.json"),
`${JSON.stringify(measurements, null, 2)}\n`,
);
process.stdout.write(`${JSON.stringify(measurements, null, 2)}\n`);
process.stdout.write(`${formatMeasurementSummary(measurements)}\n`);
if (check) {
const budgets = JSON.parse(
await readFile(
path.join(frontendDir, "performance-budgets.json"),
"utf8",
),
);
const failures = evaluateBudgets(measurements, budgets);
if (failures.length > 0) {
throw new Error(`Route asset budgets failed:\n${failures.join("\n")}`);
}
}
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
main().catch((error) => {
process.stderr.write(
`${error instanceof Error ? error.stack : String(error)}\n`,
);
process.exitCode = 1;
});
}

View File

@ -5,6 +5,8 @@ import { GatewayOfflineFallback } from "@/components/workspace/gateway-offline-f
import { AuthProvider } from "@/core/auth/AuthProvider";
import { getServerSideUser } from "@/core/auth/server";
import { assertNever } from "@/core/auth/types";
import { I18nProvider } from "@/core/i18n/context";
import { detectLocaleServer } from "@/core/i18n/server";
export const dynamic = "force-dynamic";
@ -13,22 +15,29 @@ export default async function AuthLayout({
}: {
children: ReactNode;
}) {
const locale = await detectLocaleServer();
const result = await getServerSideUser();
let content: ReactNode;
switch (result.tag) {
case "authenticated":
redirect("/workspace");
case "needs_setup":
// Allow access to setup page
return <AuthProvider initialUser={result.user}>{children}</AuthProvider>;
content = (
<AuthProvider initialUser={result.user}>{children}</AuthProvider>
);
break;
case "system_setup_required":
case "unauthenticated":
return <AuthProvider initialUser={null}>{children}</AuthProvider>;
content = <AuthProvider initialUser={null}>{children}</AuthProvider>;
break;
case "gateway_unavailable":
// Auth pages have no banner of their own, so render one here. The
// fallback's AuthProvider replaces the bare-HTML branch that
// previously locked users out without any logout/retry capability.
return (
content = (
<GatewayOfflineFallback renderBanner>
<div className="flex h-screen flex-col items-center justify-center gap-4">
<p className="text-muted-foreground">
@ -37,9 +46,12 @@ export default async function AuthLayout({
</div>
</GatewayOfflineFallback>
);
break;
case "config_error":
throw new Error(result.message);
default:
assertNever(result);
}
return <I18nProvider initialLocale={locale}>{content}</I18nProvider>;
}

View File

@ -1,3 +1,5 @@
import "katex/dist/katex.min.css";
import { getPageMap } from "nextra/page-map";
import { Layout } from "nextra-theme-docs";

View File

@ -1,16 +1,28 @@
import "katex/dist/katex.min.css";
import { Layout } from "nextra-theme-docs";
import { Footer } from "@/components/landing/footer";
import { Header } from "@/components/landing/header";
import { getBlogIndexData } from "@/core/blog";
import { detectLocaleServer } from "@/core/i18n/server";
import "nextra-theme-docs/style.css";
export default async function BlogLayout({ children }) {
const { pageMap } = await getBlogIndexData();
const [locale, { pageMap }] = await Promise.all([
detectLocaleServer(),
getBlogIndexData(),
]);
return (
<Layout
navbar={<Header className="relative max-w-full px-10" homeURL="/" />}
navbar={
<Header
className="relative max-w-full px-10"
homeURL="/"
locale={locale}
/>
}
pageMap={pageMap}
sidebar={{ defaultOpen: true }}
docsRepositoryBase="https://github.com/bytedance/deer-flow/tree/main/frontend"

View File

@ -1,27 +1,27 @@
import "katex/dist/katex.min.css";
import "streamdown/styles.css";
import "@/styles/globals.css";
import { type Metadata } from "next";
import { ThemeProvider } from "@/components/theme-provider";
import { I18nProvider } from "@/core/i18n/context";
import { detectLocaleServer } from "@/core/i18n/server";
import { DEFAULT_LOCALE } from "@/core/i18n/locale";
export const metadata: Metadata = {
title: "DeerFlow",
description: "A LangChain-based framework for building super agents.",
};
export default async function RootLayout({
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const locale = await detectLocaleServer();
return (
<html lang={locale} suppressContentEditableWarning suppressHydrationWarning>
<html
lang={DEFAULT_LOCALE}
suppressContentEditableWarning
suppressHydrationWarning
>
<body>
<ThemeProvider attribute="class" enableSystem disableTransitionOnChange>
<I18nProvider initialLocale={locale}>{children}</I18nProvider>
{children}
</ThemeProvider>
</body>
</html>

View File

@ -1,8 +1,7 @@
import fs from "fs";
import path from "path";
import type { NextRequest } from "next/server";
import { resolveStaticDemoArtifact } from "@/core/threads/static-demo";
export async function GET(
request: NextRequest,
{
@ -14,36 +13,42 @@ export async function GET(
}>;
},
) {
const threadId = (await params).thread_id;
let artifactPath = (await params).artifact_path?.join("/") ?? "";
if (artifactPath.startsWith("mnt/")) {
artifactPath = path.resolve(
process.cwd(),
artifactPath.replace("mnt/", `public/demo/threads/${threadId}/`),
);
if (fs.existsSync(artifactPath)) {
if (request.nextUrl.searchParams.get("download") === "true") {
// Attach the file to the response
const headers = new Headers();
headers.set(
"Content-Disposition",
`attachment; filename="${artifactPath}"`,
);
return new Response(fs.readFileSync(artifactPath), {
status: 200,
headers,
});
}
if (artifactPath.endsWith(".mp4")) {
return new Response(fs.readFileSync(artifactPath), {
status: 200,
headers: {
"Content-Type": "video/mp4",
},
});
}
return new Response(fs.readFileSync(artifactPath), { status: 200 });
}
const { thread_id: threadId, artifact_path: artifactSegments = [] } =
await params;
const publicPath = resolveStaticDemoArtifact(threadId, artifactSegments);
if (!publicPath) return new Response("File not found", { status: 404 });
const upstreamRequestHeaders: Record<string, string> = {};
for (const headerName of ["Range", "If-Range"] as const) {
const value = request.headers.get(headerName);
if (value) upstreamRequestHeaders[headerName] = value;
}
return new Response("File not found", { status: 404 });
const upstream = await fetch(
new URL(publicPath, request.nextUrl.origin).toString(),
{ headers: upstreamRequestHeaders, signal: request.signal },
);
if (
(!upstream.ok && upstream.status !== 416) ||
(!upstream.body && upstream.status !== 416)
) {
return new Response("File not found", { status: 404 });
}
const headers = new Headers();
for (const headerName of [
"Accept-Ranges",
"Content-Length",
"Content-Range",
"Content-Type",
]) {
const value = upstream.headers.get(headerName);
if (value) headers.set(headerName, value);
}
if (request.nextUrl.searchParams.get("download") === "true") {
headers.set(
"Content-Disposition",
`attachment; filename="${artifactSegments.at(-1)}"`,
);
}
return new Response(upstream.body, { status: upstream.status, headers });
}

View File

@ -1,18 +1,24 @@
import fs from "fs";
import path from "path";
import type { NextRequest } from "next/server";
import { DEMO_THREAD_IDS } from "@/core/threads/static-demo";
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ thread_id: string }> },
) {
const threadId = (await params).thread_id;
const jsonString = fs.readFileSync(
path.resolve(process.cwd(), `public/demo/threads/${threadId}/thread.json`),
"utf8",
if (!DEMO_THREAD_IDS.some((candidate) => candidate === threadId)) {
return new Response("Thread not found", { status: 404 });
}
const response = await fetch(
new URL(
`/demo/threads/${encodeURIComponent(threadId)}/thread.json`,
request.nextUrl.origin,
),
{ signal: request.signal },
);
const json = JSON.parse(jsonString);
if (!response.ok) return new Response("Thread not found", { status: 404 });
const json = (await response.json()) as { history?: unknown[] };
if (Array.isArray(json.history)) {
return Response.json(json);
}

View File

@ -1,5 +1,4 @@
import fs from "fs";
import path from "path";
import { DEMO_THREAD_IDS } from "@/core/threads/static-demo";
type ThreadSearchRequest = {
limit?: number;
@ -37,36 +36,35 @@ export async function POST(request: Request) {
const sortBy = body.sortBy ?? "updated_at";
const sortOrder = body.sortOrder ?? "desc";
const threadsDir = fs.readdirSync(
path.resolve(process.cwd(), "public/demo/threads"),
{
withFileTypes: true,
},
);
const threadData = threadsDir
.map<MockThreadSearchResult | null>((threadId) => {
if (threadId.isDirectory() && !threadId.name.startsWith(".")) {
const threadData = JSON.parse(
fs.readFileSync(
path.resolve(`public/demo/threads/${threadId.name}/thread.json`),
"utf8",
),
) as Record<string, unknown>;
return {
...threadData,
thread_id: threadId.name,
updated_at:
typeof threadData.updated_at === "string"
? threadData.updated_at
: typeof threadData.created_at === "string"
? threadData.created_at
: undefined,
};
}
return null;
})
const origin = new URL(request.url).origin;
const threadData = (
await Promise.all(
DEMO_THREAD_IDS.map(
async (threadId): Promise<MockThreadSearchResult | null> => {
const response = await fetch(
new URL(
`/demo/threads/${encodeURIComponent(threadId)}/thread.json`,
origin,
),
{ signal: request.signal },
);
if (!response.ok) return null;
const data = (await response.json()) as Record<string, unknown>;
const thread: MockThreadSearchResult = {
...data,
thread_id: threadId,
updated_at:
typeof data.updated_at === "string"
? data.updated_at
: typeof data.created_at === "string"
? data.created_at
: undefined,
};
return thread;
},
),
)
)
.filter((thread): thread is MockThreadSearchResult => thread !== null)
.sort((a, b) => {
const aTimestamp = a[sortBy];

View File

@ -6,11 +6,12 @@ import { CommunitySection } from "@/components/landing/sections/community-sectio
import { SandboxSection } from "@/components/landing/sections/sandbox-section";
import { SkillsSection } from "@/components/landing/sections/skills-section";
import { WhatsNewSection } from "@/components/landing/sections/whats-new-section";
import { DEFAULT_LOCALE } from "@/core/i18n/locale";
export default function LandingPage() {
return (
<div className="min-h-screen w-full overflow-x-clip bg-[#0a0a0a]">
<Header />
<Header locale={DEFAULT_LOCALE} />
<main className="flex w-full flex-col">
<Hero />
<CaseStudySection />

View File

@ -10,6 +10,7 @@ import {
ThreadChannelBadge,
ThreadChannelIcon,
} from "@/components/workspace/thread-channel-source";
import { VirtualThreadList } from "@/components/workspace/thread-list-virtualizer";
import {
WorkspaceBody,
WorkspaceContainer,
@ -17,6 +18,7 @@ import {
} from "@/components/workspace/workspace-container";
import { useI18n } from "@/core/i18n/hooks";
import { useInfiniteThreads } from "@/core/threads/hooks";
import { buildThreadListModel } from "@/core/threads/thread-list-model";
import {
channelSourceOfThread,
pathOfThread,
@ -32,10 +34,11 @@ export default function ChatsPage() {
hasNextPage,
isFetchingNextPage,
} = useInfiniteThreads();
const threads = useMemo(
() => infiniteThreads?.pages.flat() ?? [],
[infiniteThreads],
const threadListModel = useMemo(
() => buildThreadListModel(infiniteThreads?.pages ?? []),
[infiniteThreads?.pages],
);
const { threads } = threadListModel;
const [search, setSearch] = useState("");
const isSearching = search.trim().length > 0;
@ -90,30 +93,35 @@ export default function ChatsPage() {
<main className="min-h-0 flex-1">
<ScrollArea className="size-full py-4">
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
{filteredThreads.map((thread) => {
const channelSource = channelSourceOfThread(thread);
return (
<Link key={thread.thread_id} href={pathOfThread(thread)}>
<div className="flex flex-col gap-2 border-b p-4">
<div className="flex min-w-0 items-center gap-2">
<ThreadChannelIcon source={channelSource} />
<div className="min-w-0 flex-1 truncate">
{titleOfThread(thread)}
<VirtualThreadList
estimateSize={76}
items={filteredThreads}
scrollParentSelector='[data-slot="scroll-area-viewport"]'
renderItem={(thread) => {
const channelSource = channelSourceOfThread(thread);
return (
<Link key={thread.thread_id} href={pathOfThread(thread)}>
<div className="flex flex-col gap-2 border-b p-4">
<div className="flex min-w-0 items-center gap-2">
<ThreadChannelIcon source={channelSource} />
<div className="min-w-0 flex-1 truncate">
{titleOfThread(thread)}
</div>
<ThreadChannelBadge
source={channelSource}
className="hidden sm:inline-flex"
/>
</div>
<ThreadChannelBadge
source={channelSource}
className="hidden sm:inline-flex"
/>
{thread.updated_at && (
<div className="text-muted-foreground text-sm">
{formatTimeAgo(thread.updated_at)}
</div>
)}
</div>
{thread.updated_at && (
<div className="text-muted-foreground text-sm">
{formatTimeAgo(thread.updated_at)}
</div>
)}
</div>
</Link>
);
})}
</Link>
);
}}
/>
{hasNextPage && !isSearching && (
<div
ref={sentinelRef}

View File

@ -1,9 +1,14 @@
import "katex/dist/katex.min.css";
import "streamdown/styles.css";
import { redirect } from "next/navigation";
import { GatewayOfflineFallback } from "@/components/workspace/gateway-offline-fallback";
import { AuthProvider } from "@/core/auth/AuthProvider";
import { getServerSideUser } from "@/core/auth/server";
import { assertNever } from "@/core/auth/types";
import { I18nProvider } from "@/core/i18n/context";
import { detectLocaleServer } from "@/core/i18n/server";
import { WorkspaceContent } from "./workspace-content";
@ -12,15 +17,19 @@ export const dynamic = "force-dynamic";
export default async function WorkspaceLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const locale = await detectLocaleServer();
const result = await getServerSideUser();
let content: React.ReactNode;
switch (result.tag) {
case "authenticated":
return (
content = (
<AuthProvider initialUser={result.user}>
<WorkspaceContent>{children}</WorkspaceContent>
</AuthProvider>
);
break;
case "needs_setup":
redirect("/setup");
case "system_setup_required":
@ -31,14 +40,17 @@ export default async function WorkspaceLayout({
// GatewayOfflineFallback supplies the AuthProvider; WorkspaceContent
// already mounts the banner inside its sidebar layout, so renderBanner
// stays false here to avoid double-mounting.
return (
content = (
<GatewayOfflineFallback>
<WorkspaceContent gatewayUnavailable>{children}</WorkspaceContent>
</GatewayOfflineFallback>
);
break;
case "config_error":
throw new Error(result.message);
default:
assertNever(result);
}
return <I18nProvider initialLocale={locale}>{content}</I18nProvider>;
}

View File

@ -1,20 +1,11 @@
import fs from "fs";
import path from "path";
import { redirect } from "next/navigation";
import { DEMO_THREAD_IDS } from "@/core/threads/static-demo";
import { env } from "@/env";
export default function WorkspacePage() {
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
const firstThread = fs
.readdirSync(path.resolve(process.cwd(), "public/demo/threads"), {
withFileTypes: true,
})
.find((thread) => thread.isDirectory() && !thread.name.startsWith("."));
if (firstThread) {
return redirect(`/workspace/chats/${firstThread.name}`);
}
return redirect(`/workspace/chats/${DEMO_THREAD_IDS[0]}`);
}
return redirect("/workspace/chats/new");
}

View File

@ -13,7 +13,7 @@ import {
useRef,
useState,
} from "react";
import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki";
import type { BundledLanguage } from "shiki";
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
@ -29,48 +29,13 @@ const CodeBlockContext = createContext<CodeBlockContextType>({
code: "",
});
const lineNumberTransformer: ShikiTransformer = {
name: "line-numbers",
line(node, line) {
node.children.unshift({
type: "element",
tagName: "span",
properties: {
className: [
"inline-block",
"min-w-10",
"mr-4",
"text-right",
"select-none",
"text-muted-foreground",
],
},
children: [{ type: "text", value: String(line) }],
});
},
};
export async function highlightCode(
code: string,
language: BundledLanguage,
showLineNumbers = false,
) {
const transformers: ShikiTransformer[] = showLineNumbers
? [lineNumberTransformer]
: [];
return await Promise.all([
codeToHtml(code, {
lang: language,
theme: "one-light",
transformers,
}),
codeToHtml(code, {
lang: language,
theme: "one-dark-pro",
transformers,
}),
]);
const highlighter = await import("./shiki-highlight");
return await highlighter.highlightCode(code, language, showLineNumbers);
}
export const CodeBlock = ({
@ -82,20 +47,22 @@ export const CodeBlock = ({
...props
}: CodeBlockProps) => {
const [html, setHtml] = useState<string>("");
const [darkHtml, setDarkHtml] = useState<string>("");
const mounted = useRef(false);
const renderId = useRef(0);
useEffect(() => {
highlightCode(code, language, showLineNumbers).then(([light, dark]) => {
if (!mounted.current) {
setHtml(light);
setDarkHtml(dark);
mounted.current = true;
}
});
const currentRenderId = ++renderId.current;
setHtml("");
void highlightCode(code, language, showLineNumbers)
.then((highlighted) => {
if (currentRenderId === renderId.current) setHtml(highlighted);
})
.catch(() => {
// The raw-code fallback below remains visible when Shiki cannot load
// or a model emits an unsupported language identifier.
});
return () => {
mounted.current = false;
renderId.current += 1;
};
}, [code, language, showLineNumbers]);
@ -109,16 +76,17 @@ export const CodeBlock = ({
{...props}
>
<div className="relative size-full">
<div
className="[&>pre]:bg-background! [&>pre]:text-foreground! size-full overflow-auto dark:hidden [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:text-sm [&>pre]:whitespace-pre-wrap"
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
dangerouslySetInnerHTML={{ __html: html }}
/>
<div
className="[&>pre]:bg-background! [&>pre]:text-foreground! hidden size-full overflow-auto dark:block [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:text-sm [&>pre]:whitespace-pre-wrap"
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
dangerouslySetInnerHTML={{ __html: darkHtml }}
/>
{html ? (
<div
className="[&>pre]:bg-background! [&>pre]:text-foreground! size-full overflow-auto [&_code]:font-mono [&_code]:text-sm [&>pre]:m-0 [&>pre]:text-sm [&>pre]:whitespace-pre-wrap"
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<pre className="bg-background text-foreground size-full overflow-auto p-4 font-mono text-sm whitespace-pre-wrap">
<code>{code}</code>
</pre>
)}
{children && (
<div className="absolute top-2 right-2 flex items-center gap-2">
{children}

View File

@ -0,0 +1,75 @@
import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki";
export const HIGHLIGHT_CACHE_LIMIT = 64;
export const HIGHLIGHT_CACHE_MAX_CODE_LENGTH = 100_000;
const HIGHLIGHT_CONFIGURATION_VERSION = 1;
const highlightCache = new Map<string, Promise<string>>();
const lineNumberTransformer: ShikiTransformer = {
name: "line-numbers",
line(node, line) {
node.children.unshift({
type: "element",
tagName: "span",
properties: {
className: [
"inline-block",
"min-w-10",
"mr-4",
"text-right",
"select-none",
"text-muted-foreground",
],
},
children: [{ type: "text", value: String(line) }],
});
},
};
export async function highlightCode(
code: string,
language: BundledLanguage,
showLineNumbers = false,
) {
const render = () =>
codeToHtml(code, {
lang: language,
themes: { light: "one-light", dark: "one-dark-pro" },
defaultColor: "light",
transformers: showLineNumbers ? [lineNumberTransformer] : [],
});
if (code.length > HIGHLIGHT_CACHE_MAX_CODE_LENGTH) {
return await render();
}
const key = JSON.stringify([
HIGHLIGHT_CONFIGURATION_VERSION,
code,
language,
showLineNumbers,
]);
const cached = highlightCache.get(key);
if (cached) {
highlightCache.delete(key);
highlightCache.set(key, cached);
return await cached;
}
const highlighted = render();
highlightCache.set(key, highlighted);
if (highlightCache.size > HIGHLIGHT_CACHE_LIMIT) {
const oldestKey = highlightCache.keys().next().value;
if (oldestKey !== undefined) highlightCache.delete(oldestKey);
}
try {
return await highlighted;
} catch (error) {
if (highlightCache.get(key) === highlighted) highlightCache.delete(key);
throw error;
}
}
export function clearHighlightCacheForTests() {
highlightCache.clear();
}

View File

@ -3,7 +3,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { NumberTicker } from "@/components/ui/number-ticker";
import type { Locale } from "@/core/i18n/locale";
import { DEFAULT_LOCALE, type Locale } from "@/core/i18n/locale";
import { getI18n } from "@/core/i18n/server";
import { env } from "@/env";
import { cn } from "@/lib/utils";
@ -18,7 +18,7 @@ export type HeaderProps = {
export async function Header({ className, homeURL, locale }: HeaderProps) {
const isExternalHome = !homeURL;
const { locale: resolvedLocale, t } = await getI18n(locale);
const { locale: resolvedLocale, t } = await getI18n(locale ?? DEFAULT_LOCALE);
const lang = resolvedLocale.substring(0, 2);
return (
<header

View File

@ -2,16 +2,19 @@
import { ChevronRightIcon } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { AuroraText } from "@/components/ui/aurora-text";
import { Button } from "@/components/ui/button";
import { FlickeringGrid } from "@/components/ui/flickering-grid";
import Galaxy from "@/components/ui/galaxy";
import { useRenderActivity } from "@/core/dom/render-activity";
import { env } from "@/env";
import { cn } from "@/lib/utils";
const Galaxy = dynamic(() => import("@/components/ui/galaxy"), { ssr: false });
const HERO_WORDS = [
"Deep Research",
"Collect Data",
@ -29,6 +32,9 @@ const HERO_WORDS = [
];
export function Hero({ className }: { className?: string }) {
const galaxyContainerRef = useRef<HTMLDivElement>(null);
const renderGalaxy = useRenderActivity(galaxyContainerRef);
return (
<div
className={cn(
@ -36,15 +42,20 @@ export function Hero({ className }: { className?: string }) {
className,
)}
>
<div className="absolute inset-0 z-0 bg-black/40">
<Galaxy
mouseRepulsion={false}
starSpeed={0.2}
density={0.6}
glowIntensity={0.35}
twinkleIntensity={0.3}
speed={0.5}
/>
<div
ref={galaxyContainerRef}
className="absolute inset-0 z-0 bg-black/40"
>
{renderGalaxy && (
<Galaxy
mouseRepulsion={false}
starSpeed={0.2}
density={0.6}
glowIntensity={0.35}
twinkleIntensity={0.3}
speed={0.5}
/>
)}
</div>
<FlickeringGrid
className="absolute inset-0 z-0 mask-[url(/images/deer.svg)] mask-size-[100vw] mask-center mask-no-repeat md:mask-size-[72vh]"

View File

@ -1,3 +1,4 @@
import Image from "next/image";
import Link from "next/link";
import { Card } from "@/components/ui/card";
@ -60,15 +61,19 @@ export function CaseStudySection({ className }: { className?: string }) {
rel="noopener noreferrer"
>
<Card className="group/card relative h-64 overflow-hidden">
<div
className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat transition-all duration-300 group-hover/card:scale-110 group-hover/card:brightness-90"
style={{
backgroundImage: `url(/images/${caseStudy.threadId}.jpg)`,
}}
></div>
<Image
src={`/images/${caseStudy.threadId}.jpg`}
alt={`${caseStudy.title} preview`}
width={640}
height={360}
loading="lazy"
decoding="async"
sizes="(min-width: 1024px) 320px, (min-width: 768px) 50vw, 100vw"
className="absolute inset-0 z-0 h-full w-full object-cover transition-all duration-300 group-hover/card:scale-110 group-hover/card:brightness-90"
/>
<div
className={cn(
"flex h-full w-full translate-y-[calc(100%-60px)] flex-col items-center",
"relative z-10 flex h-full w-full translate-y-[calc(100%-60px)] flex-col items-center",
"transition-all duration-300",
"group-hover/card:translate-y-[calc(100%-128px)]",
)}

View File

@ -1,11 +1,22 @@
"use client";
import dynamic from "next/dynamic";
import { useRef } from "react";
import { useRenderActivity } from "@/core/dom/render-activity";
import { cn } from "@/lib/utils";
import ProgressiveSkillsAnimation from "../progressive-skills-animation";
import { Section } from "../section";
const ProgressiveSkillsAnimation = dynamic(
() => import("../progressive-skills-animation"),
{ ssr: false },
);
export function SkillsSection({ className }: { className?: string }) {
const animationRef = useRef<HTMLDivElement>(null);
const renderAnimation = useRenderActivity(animationRef, false);
return (
<Section
className={cn("h-[calc(100vh-64px)] w-full bg-white/2", className)}
@ -20,8 +31,8 @@ export function SkillsSection({ className }: { className?: string }) {
</div>
}
>
<div className="relative overflow-hidden">
<ProgressiveSkillsAnimation />
<div ref={animationRef} className="relative min-h-96 overflow-hidden">
{renderAnimation && <ProgressiveSkillsAnimation />}
</div>
</Section>
);

View File

@ -1,10 +1,21 @@
"use client";
import MagicBento, { type BentoCardProps } from "@/components/ui/magic-bento";
import dynamic from "next/dynamic";
import { useEffect, useRef, type RefObject } from "react";
import { type BentoCardProps } from "@/components/ui/magic-bento";
import {
usePrefersReducedMotion,
useRenderActivity,
} from "@/core/dom/render-activity";
import { cn } from "@/lib/utils";
import { Section } from "../section";
const MagicBento = dynamic(() => import("@/components/ui/magic-bento"), {
ssr: false,
});
const COLOR = "#0a0a0a";
const features: BentoCardProps[] = [
{
@ -49,15 +60,103 @@ const features: BentoCardProps[] = [
];
export function WhatsNewSection({ className }: { className?: string }) {
const bentoContainerRef = useRef<HTMLDivElement>(null);
const renderBento = useRenderActivity(bentoContainerRef, false, false);
const reducedMotion = usePrefersReducedMotion();
useBentoSpotlight(bentoContainerRef, renderBento && !reducedMotion);
return (
<Section
className={cn("", className)}
title="Whats New in DeerFlow 2.0"
subtitle="DeerFlow is now evolving from a Deep Research agent into a full-stack Super Agent"
>
<div className="flex w-full items-center justify-center">
<MagicBento data={features} />
<div
ref={bentoContainerRef}
className="flex min-h-96 w-full items-center justify-center"
>
{renderBento && (
<MagicBento
data={features}
disableAnimations={reducedMotion}
enableSpotlight={false}
/>
)}
</div>
</Section>
);
}
function useBentoSpotlight(
containerRef: RefObject<HTMLDivElement | null>,
active: boolean,
) {
useEffect(() => {
const container = containerRef.current;
if (!container || !active) return;
let pendingPointerFrame: number | undefined;
let pointer: { x: number; y: number } | undefined;
const clearGlow = () => {
container
.querySelectorAll<HTMLElement>(".magic-bento-card")
.forEach((card) => card.style.setProperty("--glow-intensity", "0"));
};
const renderPointer = () => {
pendingPointerFrame = undefined;
if (!pointer) return;
const { x, y } = pointer;
pointer = undefined;
const radius = 300;
const proximity = radius * 0.5;
const fadeDistance = radius * 0.75;
container
.querySelectorAll<HTMLElement>(".magic-bento-card")
.forEach((card) => {
const rect = card.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const distance =
Math.hypot(x - centerX, y - centerY) -
Math.max(rect.width, rect.height) / 2;
const effectiveDistance = Math.max(0, distance);
const intensity =
effectiveDistance <= proximity
? 1
: effectiveDistance <= fadeDistance
? (fadeDistance - effectiveDistance) /
(fadeDistance - proximity)
: 0;
card.style.setProperty(
"--glow-x",
`${((x - rect.left) / rect.width) * 100}%`,
);
card.style.setProperty(
"--glow-y",
`${((y - rect.top) / rect.height) * 100}%`,
);
card.style.setProperty("--glow-intensity", intensity.toString());
card.style.setProperty("--glow-radius", `${radius}px`);
});
};
const handlePointerMove = (event: PointerEvent) => {
pointer = { x: event.clientX, y: event.clientY };
if (pendingPointerFrame !== undefined) return;
pendingPointerFrame = requestAnimationFrame(renderPointer);
};
container.addEventListener("pointermove", handlePointerMove, {
passive: true,
});
container.addEventListener("pointerleave", clearGlow);
return () => {
if (pendingPointerFrame !== undefined) {
cancelAnimationFrame(pendingPointerFrame);
}
container.removeEventListener("pointermove", handlePointerMove);
container.removeEventListener("pointerleave", clearGlow);
clearGlow();
};
}, [active, containerRef]);
}

View File

@ -193,7 +193,18 @@ export function ArtifactFileDetail({
isSupportPreview,
toolResult,
});
const { content, url, sha256 } = useArtifactContent({
const {
content,
url,
sha256,
truncated,
previewBytes,
totalBytes,
fullContentRequested,
loadFullContent,
isLoading,
error,
} = useArtifactContent({
threadId,
filepath: filepathFromProps,
enabled: isCodeFile && !isWriteFile,
@ -243,6 +254,9 @@ export function ArtifactFileDetail({
artifactViewState.initialViewMode,
);
const [isInstalling, setIsInstalling] = useState(false);
const isLoadingFullContent = fullContentRequested && isLoading;
const effectiveViewMode =
truncated && language === "html" ? "code" : viewMode;
useEffect(() => {
setViewMode(artifactViewState.initialViewMode);
}, [artifactViewState.initialViewMode]);
@ -299,7 +313,7 @@ export function ArtifactFileDetail({
},
}));
queryClient.setQueryData(
["artifact", filepathFromProps, threadId, isMock],
["artifact", filepathFromProps, threadId, isMock, fullContentRequested],
(
current:
| { content?: string; url?: string; sha256?: string }
@ -339,6 +353,7 @@ export function ArtifactFileDetail({
canEdit,
filepath,
filepathFromProps,
fullContentRequested,
isDirty,
isMock,
isSaving,
@ -410,7 +425,7 @@ export function ArtifactFileDetail({
</ArtifactTitle>
</div>
<div className="flex min-w-0 grow items-center justify-center gap-2">
{artifactViewState.canPreview && (
{artifactViewState.canPreview && !truncated && (
<ToggleGroup
className="mx-auto"
type="single"
@ -540,7 +555,7 @@ export function ArtifactFileDetail({
<ArtifactAction
icon={CopyIcon}
label={t.clipboard.copyToClipboard}
disabled={!content}
disabled={!content || truncated}
onClick={() => {
void (async () => {
const didCopy = await writeTextToClipboard(
@ -597,55 +612,145 @@ export function ArtifactFileDetail({
</ArtifactActions>
</div>
</ArtifactHeader>
<ArtifactContent className="p-0">
{artifactViewState.canPreview &&
viewMode === "preview" &&
(language === "markdown" || language === "html") && (
<ArtifactFilePreview
content={editorContent}
language={language ?? "text"}
scrollKey={filepathFromProps}
url={url}
<ArtifactContent className="flex flex-col p-0">
{truncated && (
<div className="border-border bg-muted/40 flex shrink-0 items-center justify-between gap-3 border-b px-4 py-2 text-sm">
<span className="text-muted-foreground">
{t.artifactPreview.limited(
formatArtifactBytes(previewBytes) ?? "1 MiB",
formatArtifactBytes(totalBytes),
)}
</span>
<Button size="sm" variant="outline" onClick={loadFullContent}>
{t.artifactPreview.loadFullFile}
</Button>
</div>
)}
{isLoadingFullContent && (
<div className="border-border text-muted-foreground flex shrink-0 items-center gap-2 border-b px-4 py-2 text-sm">
<LoaderIcon className="size-4 animate-spin" />
{t.artifactPreview.loadingFullFile}
</div>
)}
<div className="min-h-0 flex-1">
{error && (
<ArtifactPreviewError
filepath={filepath}
threadId={threadId}
isMock={isMock}
message={t.artifactPreview.previewFailed}
downloadLabel={t.common.download}
/>
)}
{isCodeFile && viewMode === "code" && (
<CodeEditor
className="size-full resize-none rounded-none border-none"
value={editorContent ?? ""}
readonly={!isEditing}
disabled={thread.isLoading || isSaving}
autoFocus={isEditing}
onChange={(nextContent) => {
setDrafts((current) => ({
...current,
[filepath]: {
...(current[filepath] ?? activeDraft),
draftContent: nextContent,
},
}));
}}
onSave={() => void handleSave()}
/>
)}
{!isCodeFile && canPreviewInBrowser && (
<iframe
className="size-full"
sandbox=""
src={urlOfArtifact({ filepath, threadId, isMock })}
/>
)}
{!isCodeFile && !canPreviewInBrowser && (
<ArtifactDownloadFallback
filepath={filepath}
threadId={threadId}
isMock={isMock}
/>
)}
{artifactViewState.canPreview &&
!error &&
effectiveViewMode === "preview" &&
!isLoading &&
(!truncated || language === "markdown") &&
(language === "markdown" || language === "html") && (
<ArtifactFilePreview
content={editorContent}
language={language}
scrollKey={filepathFromProps}
url={url}
/>
)}
{isCodeFile &&
!error &&
effectiveViewMode === "code" &&
!truncated &&
!isLoading && (
<CodeEditor
className="size-full resize-none rounded-none border-none"
value={editorContent ?? ""}
readonly={!isEditing}
disabled={thread.isLoading || isSaving}
autoFocus={isEditing}
onChange={(nextContent) => {
setDrafts((current) => ({
...current,
[filepath]: {
...(current[filepath] ?? activeDraft),
draftContent: nextContent,
},
}));
}}
onSave={() => void handleSave()}
language={language}
/>
)}
{isCodeFile &&
!error &&
truncated &&
effectiveViewMode === "code" && (
<pre className="size-full overflow-auto p-4 font-mono text-sm whitespace-pre-wrap">
{visibleContent}
</pre>
)}
{!isCodeFile && canPreviewInBrowser && (
<iframe
className="size-full"
sandbox=""
src={urlOfArtifact({ filepath, threadId, isMock })}
/>
)}
{!isCodeFile && !canPreviewInBrowser && (
<ArtifactDownloadFallback
filepath={filepath}
threadId={threadId}
isMock={isMock}
/>
)}
</div>
</ArtifactContent>
</Artifact>
);
}
function ArtifactPreviewError({
filepath,
threadId,
isMock,
message,
downloadLabel,
}: {
filepath: string;
threadId: string;
isMock?: boolean;
message: string;
downloadLabel: string;
}) {
return (
<div className="flex size-full items-center justify-center p-6">
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
<p className="text-muted-foreground text-sm">{message}</p>
<Button asChild>
<a
href={urlOfArtifact({
filepath,
threadId,
download: true,
isMock,
})}
target="_blank"
rel="noopener noreferrer"
>
<DownloadIcon className="size-4" />
{downloadLabel}
</a>
</Button>
</div>
</div>
);
}
function formatArtifactBytes(bytes: number | undefined) {
if (bytes === undefined) return undefined;
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
}
function ArtifactDownloadFallback({
filepath,
threadId,

View File

@ -50,6 +50,8 @@ export function browserStreamURL(threadId: string, seedUrl?: string): string {
? window.location.origin
: "";
const wsOrigin = origin.replace(/^http/i, "ws");
const query = seedUrl ? `?seed=${encodeURIComponent(seedUrl)}` : "";
return `${wsOrigin}/api/threads/${encodeURIComponent(threadId)}/browser/stream${query}`;
const query = new URLSearchParams();
query.set("frame_format", "binary");
if (seedUrl) query.set("seed", seedUrl);
return `${wsOrigin}/api/threads/${encodeURIComponent(threadId)}/browser/stream?${query.toString()}`;
}

View File

@ -0,0 +1,63 @@
/** Coalesces lossy browser frames to the display refresh rate. */
export class LatestBrowserFrameBuffer {
private pendingFrame: Blob | null = null;
private pendingFrameRequest: number | null = null;
private currentUrl: string | null = null;
private readonly listeners = new Set<() => void>();
getSnapshot = () => this.currentUrl;
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
push(frame: Blob) {
this.pendingFrame = frame;
if (this.pendingFrameRequest !== null) return;
this.pendingFrameRequest = requestAnimationFrame(() => {
this.pendingFrameRequest = null;
const latestFrame = this.pendingFrame;
this.pendingFrame = null;
if (!latestFrame) return;
const nextUrl = URL.createObjectURL(latestFrame);
this.revokeCurrentObjectUrl();
this.currentUrl = nextUrl;
this.notify();
});
}
replaceWithUrl(url: string) {
this.cancelPendingFrame();
this.revokeCurrentObjectUrl();
this.currentUrl = url;
this.notify();
}
dispose() {
this.cancelPendingFrame();
this.revokeCurrentObjectUrl();
this.notify();
}
private cancelPendingFrame() {
this.pendingFrame = null;
if (this.pendingFrameRequest !== null) {
cancelAnimationFrame(this.pendingFrameRequest);
this.pendingFrameRequest = null;
}
}
private revokeCurrentObjectUrl() {
if (this.currentUrl?.startsWith("blob:")) {
URL.revokeObjectURL(this.currentUrl);
}
this.currentUrl = null;
}
private notify() {
this.listeners.forEach((listener) => listener());
}
}

View File

@ -1,8 +1,15 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
useCallback,
useEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
import { browserStreamURL } from "./api";
import { LatestBrowserFrameBuffer } from "./frame-buffer";
export interface BrowserTab {
index: number;
@ -38,8 +45,8 @@ function normalizeSeedUrl(url: string | null | undefined): string {
* Manage a live browser screencast WebSocket.
*
* When ``enabled`` is true, opens the stream, exposes the latest JPEG frame as
* a data URL, and returns a ``sendInput`` callback that forwards user input to
* the live page. Closes and cleans up when disabled or unmounted.
* an object URL, and returns a ``sendInput`` callback that forwards user input
* to the live page. Closes and cleans up when disabled or unmounted.
*
* ``seedUrl`` is only read when a connection is first established (via a ref, so
* it is NOT a reconnect trigger). A separate effect steers an already-open live
@ -56,7 +63,12 @@ export function useBrowserStream(
) => void,
) {
const [status, setStatus] = useState<BrowserStreamStatus>("idle");
const [frameUrl, setFrameUrl] = useState<string | null>(null);
const [frameBuffer] = useState(() => new LatestBrowserFrameBuffer());
const frameUrl = useSyncExternalStore(
frameBuffer.subscribe,
frameBuffer.getSnapshot,
() => null,
);
const [liveUrl, setLiveUrl] = useState<string | null>(null);
const [tabs, setTabs] = useState<BrowserTab[]>([]);
const [connectionAttempt, setConnectionAttempt] = useState(0);
@ -97,11 +109,11 @@ export function useBrowserStream(
return;
}
setConnectionAttempt(0);
setFrameUrl(null);
frameBuffer.dispose();
setLiveUrl(null);
setTabs([]);
liveUrlRef.current = null;
}, [enabled, threadId]);
}, [enabled, frameBuffer, threadId]);
useEffect(() => {
if (!enabled) {
@ -119,6 +131,7 @@ export function useBrowserStream(
// after open (the server already aligns the page to the connect-time seed).
liveUrlRef.current = seedRef.current ?? null;
const socket = new WebSocket(browserStreamURL(threadId, seedRef.current));
socket.binaryType = "blob";
socketRef.current = socket;
const scheduleReconnect = () => {
@ -156,22 +169,24 @@ export function useBrowserStream(
setConnectionAttempt(0);
setStatus("open");
};
socket.onmessage = async (message) => {
socket.onmessage = (message) => {
try {
const raw =
typeof message.data === "string"
? message.data
: message.data instanceof Blob
? await message.data.text()
: message.data instanceof ArrayBuffer
? new TextDecoder().decode(message.data)
: String(message.data);
// The message may resolve after cleanup (async Blob/ArrayBuffer decode);
// do not write state for a socket the effect already tore down.
if (closedByEffect) {
if (closedByEffect) return;
if (message.data instanceof Blob) {
frameBuffer.push(
message.data.type === "image/jpeg"
? message.data
: new Blob([message.data], { type: "image/jpeg" }),
);
return;
}
const payload = JSON.parse(raw) as {
if (message.data instanceof ArrayBuffer) {
frameBuffer.push(new Blob([message.data], { type: "image/jpeg" }));
return;
}
if (typeof message.data !== "string") return;
const payload = JSON.parse(message.data) as {
type?: string;
data?: string;
url?: string;
@ -179,7 +194,7 @@ export function useBrowserStream(
tabs?: BrowserTab[];
};
if (payload.type === "frame" && payload.data) {
setFrameUrl(`data:image/jpeg;base64,${payload.data}`);
frameBuffer.replaceWithUrl(`data:image/jpeg;base64,${payload.data}`);
} else if (payload.type === "url" && payload.url) {
liveUrlRef.current = payload.url;
setLiveUrl(payload.url);
@ -212,8 +227,9 @@ export function useBrowserStream(
}
socketRef.current = null;
socket.close();
frameBuffer.dispose();
};
}, [connectionAttempt, enabled, threadId]);
}, [connectionAttempt, enabled, frameBuffer, threadId]);
// Steer an already-open stream toward a changed seed in-band instead of
// rebuilding the socket. Only navigates when the live page differs from the

View File

@ -1,4 +1,5 @@
import { FilesIcon, XIcon } from "lucide-react";
import dynamic from "next/dynamic";
import { usePathname } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
@ -25,14 +26,47 @@ import { env } from "@/env";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import {
ArtifactFileDetail,
ArtifactFileList,
useArtifacts,
} from "../artifacts";
import { BrowserViewPanel, useMaybeBrowserView } from "../browser-view";
import { useArtifacts } from "../artifacts/context";
import { useMaybeBrowserView } from "../browser-view/context";
import { useThread } from "../messages/context";
import { SidecarPanel, useMaybeSidecar } from "../sidecar";
import { useMaybeSidecar } from "../sidecar/context";
function RightPanelLoading() {
return (
<div className="grid size-full place-items-center">
<p role="status" className="text-muted-foreground text-sm">
Loading panel
</p>
</div>
);
}
const ArtifactFileDetail = dynamic(
() =>
import("../artifacts/artifact-file-detail").then(
(module) => module.ArtifactFileDetail,
),
{ loading: RightPanelLoading },
);
const ArtifactFileList = dynamic(
() =>
import("../artifacts/artifact-file-list").then(
(module) => module.ArtifactFileList,
),
{ loading: RightPanelLoading },
);
const BrowserViewPanel = dynamic(
() =>
import("../browser-view/browser-view-panel").then(
(module) => module.BrowserViewPanel,
),
{ loading: RightPanelLoading },
);
const SidecarPanel = dynamic(
() =>
import("../sidecar/sidecar-panel").then((module) => module.SidecarPanel),
{ loading: RightPanelLoading },
);
const RIGHT_PANEL_ANIMATION_MS = 280;
const RIGHT_PANEL_DEFAULT_SIZE = "40%";

View File

@ -0,0 +1,101 @@
import type { Extension } from "@uiw/react-codemirror";
export type CodeEditorLanguage =
| "css"
| "html"
| "javascript"
| "json"
| "markdown"
| "python"
| "text";
export function normalizeCodeEditorLanguage(
language: string | null | undefined,
): CodeEditorLanguage {
switch (language?.toLowerCase()) {
case "css":
case "scss":
case "sass":
case "less":
return "css";
case "html":
case "xml":
return "html";
case "javascript":
case "typescript":
case "jsx":
case "tsx":
return "javascript";
case "json":
case "jsonc":
case "json5":
return "json";
case "markdown":
case "mdx":
return "markdown";
case "python":
case "py":
return "python";
default:
return "text";
}
}
async function loadLanguageExtension(
language: CodeEditorLanguage,
): Promise<Extension[]> {
switch (language) {
case "css":
return [(await import("@codemirror/lang-css")).css()];
case "html":
return [(await import("@codemirror/lang-html")).html()];
case "javascript":
return [(await import("@codemirror/lang-javascript")).javascript()];
case "json":
return [(await import("@codemirror/lang-json")).json()];
case "markdown": {
const markdownModule = await import("@codemirror/lang-markdown");
return [
markdownModule.markdown({ base: markdownModule.markdownLanguage }),
];
}
case "python":
return [(await import("@codemirror/lang-python")).python()];
case "text":
return [];
}
}
async function loadTheme(theme: "light" | "dark"): Promise<Extension> {
if (theme === "dark") {
const { monokaiInit } = await import("@uiw/codemirror-theme-monokai");
return monokaiInit({
settings: {
background: "transparent",
gutterBackground: "transparent",
gutterForeground: "#555",
gutterActiveForeground: "#fff",
fontSize: "var(--text-sm)",
},
});
}
const { basicLightInit } = await import("@uiw/codemirror-theme-basic");
return basicLightInit({
settings: {
background: "transparent",
fontSize: "var(--text-sm)",
},
});
}
export async function loadCodeEditorExtensions(
language: string | null | undefined,
theme: "light" | "dark",
) {
const normalizedLanguage = normalizeCodeEditorLanguage(language);
const [extensions, loadedTheme] = await Promise.all([
loadLanguageExtension(normalizedLanguage),
loadTheme(theme),
]);
return { extensions, theme: loadedTheme };
}

View File

@ -1,38 +1,14 @@
"use client";
import { css } from "@codemirror/lang-css";
import { html } from "@codemirror/lang-html";
import { javascript } from "@codemirror/lang-javascript";
import { json } from "@codemirror/lang-json";
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { python } from "@codemirror/lang-python";
import { languages } from "@codemirror/language-data";
import { basicLightInit } from "@uiw/codemirror-theme-basic";
import { monokaiInit } from "@uiw/codemirror-theme-monokai";
import CodeMirror from "@uiw/react-codemirror";
import { useTheme } from "next-themes";
import { useMemo } from "react";
import { useEffect, useState } from "react";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { loadCodeEditorExtensions } from "./code-editor-extensions";
import { useThread } from "./messages/context";
const customDarkTheme = monokaiInit({
settings: {
background: "transparent",
gutterBackground: "transparent",
gutterForeground: "#555",
gutterActiveForeground: "#fff",
fontSize: "var(--text-sm)",
},
});
const customLightTheme = basicLightInit({
settings: {
background: "transparent",
fontSize: "var(--text-sm)",
},
});
export function CodeEditor({
className,
@ -44,6 +20,7 @@ export function CodeEditor({
onChange,
onSave,
settings,
language,
}: {
className?: string;
placeholder?: string;
@ -54,25 +31,29 @@ export function CodeEditor({
onChange?: (value: string) => void;
onSave?: () => void;
settings?: unknown;
language?: string;
}) {
const {
thread: { isLoading },
} = useThread();
const { resolvedTheme } = useTheme();
const [loaded, setLoaded] = useState<
Awaited<ReturnType<typeof loadCodeEditorExtensions>> | undefined
>();
const extensions = useMemo(() => {
return [
css(),
html(),
javascript({}),
json(),
markdown({
base: markdownLanguage,
codeLanguages: languages,
}),
python(),
];
}, []);
useEffect(() => {
let cancelled = false;
setLoaded(undefined);
void loadCodeEditorExtensions(
language,
resolvedTheme === "dark" ? "dark" : "light",
).then((extensions) => {
if (!cancelled) setLoaded(extensions);
});
return () => {
cancelled = true;
};
}, [language, resolvedTheme]);
return (
<div
@ -90,7 +71,7 @@ export function CodeEditor({
}
}}
>
{isLoading ? (
{isLoading || !loaded ? (
<Textarea
className={cn(
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!",
@ -109,8 +90,8 @@ export function CodeEditor({
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!",
"px-2 py-0! [&_.cm-line]:px-2! [&_.cm-line]:py-0!",
)}
theme={resolvedTheme === "dark" ? customDarkTheme : customLightTheme}
extensions={extensions}
theme={loaded.theme}
extensions={loaded.extensions}
basicSetup={{
foldGutter:
(settings as { foldGutter?: boolean })?.foldGutter ?? false,

View File

@ -44,7 +44,8 @@ type StreamingCodeProps = ComponentProps<"code"> & {
};
const SMOOTH_REVEAL_MIN_DELTA = 80;
const SMOOTH_REVEAL_MIN_CHARS_PER_FRAME = 8;
const SMOOTH_REVEAL_CADENCE_MS = 50;
const SMOOTH_REVEAL_MIN_CHARS_PER_COMMIT = 64;
const SMOOTH_REVEAL_DURATION_MS = 300;
const StreamingCodeBlockContext = createContext(false);
@ -55,73 +56,61 @@ function useSmoothStreamingContent(content: string, isLoading: boolean) {
const [displayContent, setDisplayContent] = useState(initialContent);
const displayContentRef = useRef(initialContent);
const targetContentRef = useRef(content);
const sawLoadingRef = useRef(isLoading);
useEffect(() => {
if (isLoading) {
sawLoadingRef.current = true;
}
}, [isLoading]);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const revealStartedAtRef = useRef<number | null>(null);
useEffect(() => {
targetContentRef.current = content;
const current = displayContentRef.current;
const delta = content.length - current.length;
const prefersReducedMotion =
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
const shouldSmoothReveal =
delta >= SMOOTH_REVEAL_MIN_DELTA &&
content !== current &&
content.startsWith(current) &&
(isLoading || sawLoadingRef.current);
!prefersReducedMotion &&
isLoading;
if (!shouldSmoothReveal) {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
revealStartedAtRef.current = null;
if (current !== content) {
displayContentRef.current = content;
setDisplayContent(content);
}
if (!isLoading) {
sawLoadingRef.current = false;
}
return;
}
let cancelled = false;
let frame: number | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
let generation = 0;
const startedAt = performance.now();
const startLength = current.length;
const tick = (now: number, scheduledGeneration: number) => {
if (cancelled || scheduledGeneration !== generation) {
return;
}
generation += 1;
if (frame !== null) {
cancelAnimationFrame(frame);
frame = null;
}
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
const tick = () => {
timerRef.current = null;
const target = targetContentRef.current;
const latest = displayContentRef.current;
if (!target.startsWith(latest) || latest.length >= target.length) {
if (!isLoading) {
sawLoadingRef.current = false;
}
revealStartedAtRef.current = null;
return;
}
const progress = Math.min(
1,
(now - startedAt) / SMOOTH_REVEAL_DURATION_MS,
const startedAt = revealStartedAtRef.current ?? performance.now();
revealStartedAtRef.current = startedAt;
const remainingDuration = Math.max(
SMOOTH_REVEAL_CADENCE_MS,
SMOOTH_REVEAL_DURATION_MS - (performance.now() - startedAt),
);
const elapsedLength = startLength + Math.ceil(delta * progress);
const nextLength = Math.max(
latest.length + SMOOTH_REVEAL_MIN_CHARS_PER_FRAME,
elapsedLength,
const remainingCommits = Math.max(
1,
Math.ceil(remainingDuration / SMOOTH_REVEAL_CADENCE_MS),
);
const nextLength = Math.min(
target.length,
latest.length +
Math.max(
SMOOTH_REVEAL_MIN_CHARS_PER_COMMIT,
Math.ceil((target.length - latest.length) / remainingCommits),
),
);
const next = target.slice(0, nextLength);
displayContentRef.current = next;
@ -129,32 +118,28 @@ function useSmoothStreamingContent(content: string, isLoading: boolean) {
if (next.length < target.length) {
scheduleTick();
} else if (!isLoading) {
sawLoadingRef.current = false;
} else {
revealStartedAtRef.current = null;
}
};
const scheduleTick = () => {
const scheduledGeneration = ++generation;
frame = requestAnimationFrame((now) => tick(now, scheduledGeneration));
timer = setTimeout(
() => tick(performance.now(), scheduledGeneration),
50,
);
if (timerRef.current !== null) return;
revealStartedAtRef.current ??= performance.now();
timerRef.current = setTimeout(tick, SMOOTH_REVEAL_CADENCE_MS);
};
scheduleTick();
}, [content, isLoading]);
useEffect(() => {
return () => {
cancelled = true;
generation += 1;
if (frame !== null) {
cancelAnimationFrame(frame);
}
if (timer !== null) {
clearTimeout(timer);
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, [content, isLoading]);
}, []);
return {
content: displayContent,

View File

@ -76,6 +76,10 @@ function MessageGroupComponent({
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
const steps = useMemo(() => convertToSteps(messages), [messages]);
const stepIndexByStep = useMemo(
() => new Map(steps.map((step, index) => [step, index] as const)),
[steps],
);
const debugStepByMessageId = useMemo(
() =>
new Map(
@ -104,20 +108,20 @@ function MessageGroupComponent({
}, [steps]);
const aboveLastToolCallSteps = useMemo(() => {
if (lastToolCallStep) {
const index = steps.indexOf(lastToolCallStep);
const index = stepIndexByStep.get(lastToolCallStep) ?? -1;
return steps.slice(0, index);
}
return [];
}, [lastToolCallStep, steps]);
}, [lastToolCallStep, stepIndexByStep, steps]);
const afterLastToolCallAssistantTextSteps = useMemo(() => {
if (!lastToolCallStep) {
return [];
}
const index = steps.indexOf(lastToolCallStep);
const index = stepIndexByStep.get(lastToolCallStep) ?? -1;
return steps
.slice(index + 1)
.filter((step) => step.type === "assistantText");
}, [lastToolCallStep, steps]);
}, [lastToolCallStep, stepIndexByStep, steps]);
const collapsibleAboveLastToolCallSteps = useMemo(
() =>
aboveLastToolCallSteps.filter((step) => step.type !== "assistantText"),
@ -125,13 +129,13 @@ function MessageGroupComponent({
);
const lastReasoningStep = useMemo(() => {
if (lastToolCallStep) {
const index = steps.indexOf(lastToolCallStep);
const index = stepIndexByStep.get(lastToolCallStep) ?? -1;
return steps.slice(index + 1).find((step) => step.type === "reasoning");
} else {
const filteredSteps = steps.filter((step) => step.type === "reasoning");
return filteredSteps[filteredSteps.length - 1];
}
}, [lastToolCallStep, steps]);
}, [lastToolCallStep, stepIndexByStep, steps]);
// Assistant text emitted after the trailing reasoning is the answer that
// reasoning produced, so it renders below the reasoning disclosure. The
// settled assistant bubble always paints reasoning above content, and the
@ -142,11 +146,11 @@ function MessageGroupComponent({
if (!lastReasoningStep) {
return [];
}
const index = steps.indexOf(lastReasoningStep);
const index = stepIndexByStep.get(lastReasoningStep) ?? -1;
return steps
.slice(index + 1)
.filter((step) => step.type === "assistantText");
}, [lastReasoningStep, steps]);
}, [lastReasoningStep, stepIndexByStep, steps]);
const belowLastReasoningSteps = useMemo(
() => new Set<CoTStep>(belowLastReasoningAssistantTextSteps),
[belowLastReasoningAssistantTextSteps],
@ -276,7 +280,7 @@ function MessageGroupComponent({
);
const renderStep = (step: CoTStep) => {
const stepIndex = steps.indexOf(step);
const stepIndex = stepIndexByStep.get(step) ?? -1;
if (step.type === "assistantText") {
return [
renderDebugSummary(step.messageId, stepIndex),
@ -364,7 +368,7 @@ function MessageGroupComponent({
<>
{renderDebugSummary(
lastToolCallStep.messageId,
steps.indexOf(lastToolCallStep),
stepIndexByStep.get(lastToolCallStep) ?? -1,
)}
<FlipDisplay uniqueKey={lastToolCallStep.id ?? ""}>
{renderToolCall(lastToolCallStep, { isLast: true })}
@ -380,7 +384,7 @@ function MessageGroupComponent({
<>
{renderDebugSummary(
lastReasoningStep.messageId,
steps.indexOf(lastReasoningStep),
stepIndexByStep.get(lastReasoningStep) ?? -1,
)}
<Button
key={lastReasoningStep.id}

View File

@ -27,6 +27,11 @@ import {
import { Button } from "@/components/ui/button";
import { extractArtifactsFromThread } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import {
deriveAssistantTurnUsageState,
deriveStableMessageGroups,
type AssistantTurnUsageState,
} from "@/core/messages/derived-state";
import {
deriveHumanInputThreadState,
extractHumanInputRequest,
@ -34,10 +39,7 @@ import {
type HumanInputRequest,
type HumanInputResponse,
} from "@/core/messages/human-input";
import {
getMessageRunId,
getRunDurationDisplaysByGroupIndex,
} from "@/core/messages/run-duration";
import { getRunDurationDisplaysByGroupIndex } from "@/core/messages/run-duration";
import {
buildTokenDebugSteps,
type TokenDebugStep,
@ -48,10 +50,8 @@ import {
extractPresentFilesFromMessage,
extractTextFromMessage,
getAssistantTurnCopyData,
getAssistantTurnUsageMessages,
getBranchableAssistantGroupIds,
getLatestEditableTurn,
getMessageGroups,
getStreamingMessageLookup,
hasContent,
hasPresentFiles,
@ -94,66 +94,11 @@ import {
import { RunActivity, RunDuration } from "./run-duration";
import { MessageListSkeleton } from "./skeleton";
import { SubtaskCard } from "./subtask-card";
import { VirtualMessageList } from "./virtual-message-list";
const EMPTY_TOKEN_DEBUG_STEPS: TokenDebugStep[] = [];
const EMPTY_ARTIFACT_PATHS: readonly string[] = [];
function messageStableKey(message: Message) {
if (
message.type === "tool" &&
typeof message.tool_call_id === "string" &&
message.tool_call_id.length > 0
) {
return `tool:${message.tool_call_id}`;
}
if (typeof message.id === "string" && message.id.length > 0) {
return `message:${message.id}`;
}
return null;
}
function sameMessageIdentity(previous: Message, next: Message) {
if (previous === next) {
return true;
}
if (previous.type !== next.type) {
return false;
}
const previousKey = messageStableKey(previous);
const nextKey = messageStableKey(next);
return previousKey !== null && previousKey === nextKey;
}
function sameRunDurationMetadata(previous: Message, next: Message) {
return (
getMessageRunId(previous) === getMessageRunId(next) &&
Object.is(
previous.additional_kwargs?.turn_duration,
next.additional_kwargs?.turn_duration,
)
);
}
function canReuseMessageGroup(
previous: ThreadMessageGroup | undefined,
next: ThreadMessageGroup,
): previous is ThreadMessageGroup {
if (
!previous ||
previous.id !== next.id ||
previous.type !== next.type ||
previous.messages.length !== next.messages.length
) {
return false;
}
return previous.messages.every(
(message, index) =>
next.messages[index] !== undefined &&
sameMessageIdentity(message, next.messages[index]) &&
sameRunDurationMetadata(message, next.messages[index]),
);
}
function sameStrings(previous: readonly string[], next: readonly string[]) {
return (
previous.length === next.length &&
@ -181,22 +126,13 @@ function useStableMessageGroups(
const previousGroupsRef = useRef<ThreadMessageGroup[]>([]);
const previousIsLoadingRef = useRef(false);
return useMemo(() => {
const nextGroups = getMessageGroups(messages, {
isCurrentTurnLoading: isLoading,
});
const previousGroups = previousGroupsRef.current;
const activeGroupIndex =
isLoading || previousIsLoadingRef.current ? nextGroups.length - 1 : -1;
const stableGroups = nextGroups.map((group, index) => {
// Keep the actively streaming group fresh even if the SDK mutates a
// message object in place while appending token content.
if (index === activeGroupIndex) {
return group;
}
return canReuseMessageGroup(previousGroups[index], group)
? previousGroups[index]
: group;
});
const stableGroups = deriveStableMessageGroups(
messages,
isLoading,
previousGroups,
previousIsLoadingRef.current,
);
previousGroupsRef.current = stableGroups;
previousIsLoadingRef.current = isLoading;
return stableGroups;
@ -490,8 +426,17 @@ export function MessageList({
}, [groupedMessages]);
const updateSubtask = useUpdateSubtask();
const lastGroupIndex = groupedMessages.length - 1;
const turnUsageMessagesByGroupIndex =
getAssistantTurnUsageMessages(groupedMessages);
const previousTurnUsageStateRef = useRef<AssistantTurnUsageState | undefined>(
undefined,
);
const turnUsageMessagesByGroupIndex = useMemo(() => {
const state = deriveAssistantTurnUsageState(
groupedMessages,
previousTurnUsageStateRef.current,
);
previousTurnUsageStateRef.current = state;
return state.byGroupIndex;
}, [groupedMessages]);
const runDurationDisplaysByGroupIndex = useMemo(
() => getRunDurationDisplaysByGroupIndex(groupedMessages),
[groupedMessages],
@ -1036,328 +981,338 @@ export function MessageList({
hasMore={hasMoreHistory}
loadMore={loadMoreHistory}
/>
{groupedMessages.map((group, groupIndex) => {
const turnUsageMessages = turnUsageMessagesByGroupIndex[groupIndex];
const groupIsLoading =
thread.isLoading && groupIndex === lastGroupIndex;
<VirtualMessageList
groups={groupedMessages}
isLoading={thread.isLoading}
renderGroup={(group, groupIndex) => {
const turnUsageMessages =
turnUsageMessagesByGroupIndex[groupIndex];
const groupIsLoading =
thread.isLoading && groupIndex === lastGroupIndex;
if (group.type === "human" || group.type === "assistant") {
return withRunDuration(
group,
groupIndex,
<div
data-assistant-turn={
group.type === "assistant" ? "" : undefined
}
className={cn(
"w-full",
group.type === "assistant" && "group/assistant-turn",
)}
>
{group.messages.map((msg) => {
const item = (
<MessageListItem
message={msg}
isLoading={
thread.isLoading &&
groupIndex === groupedMessages.length - 1
}
threadId={threadId}
artifactPaths={artifactPaths}
runId={
group.type === "assistant"
? (msg as { run_id?: string }).run_id
: undefined
}
showCopyButton={group.type !== "assistant"}
showWorkspaceChanges={workspaceChangeAnchorGroupIndices.has(
groupIndex,
)}
canEdit={
group.type === "human" &&
Boolean(msg.id) &&
msg.id === latestEditableHumanMessageId &&
canEdit &&
!replayActionBusy &&
Boolean(onEditAndRegenerateMessage)
}
isEditPending={editingMessageId === msg.id}
onEditAndRegenerate={
group.type === "human" &&
msg.id &&
onEditAndRegenerateMessage
? async (replacementText) => {
const targetId = msg.id;
if (!targetId) {
return false;
if (group.type === "human" || group.type === "assistant") {
return withRunDuration(
group,
groupIndex,
<div
data-assistant-turn={
group.type === "assistant" ? "" : undefined
}
className={cn(
"w-full",
group.type === "assistant" && "group/assistant-turn",
)}
>
{group.messages.map((msg) => {
const item = (
<MessageListItem
message={msg}
isLoading={
thread.isLoading &&
groupIndex === groupedMessages.length - 1
}
threadId={threadId}
artifactPaths={artifactPaths}
runId={
group.type === "assistant"
? (msg as { run_id?: string }).run_id
: undefined
}
showCopyButton={group.type !== "assistant"}
showWorkspaceChanges={workspaceChangeAnchorGroupIndices.has(
groupIndex,
)}
canEdit={
group.type === "human" &&
Boolean(msg.id) &&
msg.id === latestEditableHumanMessageId &&
canEdit &&
!replayActionBusy &&
Boolean(onEditAndRegenerateMessage)
}
isEditPending={editingMessageId === msg.id}
onEditAndRegenerate={
group.type === "human" &&
msg.id &&
onEditAndRegenerateMessage
? async (replacementText) => {
const targetId = msg.id;
if (!targetId) {
return false;
}
setEditingMessageId(targetId);
try {
return await onEditAndRegenerateMessage(
targetId,
replacementText,
);
} finally {
setEditingMessageId(null);
}
}
setEditingMessageId(targetId);
try {
return await onEditAndRegenerateMessage(
targetId,
replacementText,
);
} finally {
setEditingMessageId(null);
}
}
: undefined
}
/>
);
if (
group.type !== "assistant" ||
!enableSidecarActions ||
msg.type !== "ai"
) {
return <div key={`${group.id}/${msg.id}`}>{item}</div>;
}
return (
<div
key={`${group.id}/${msg.id}`}
onMouseUp={(event) =>
handleAssistantTextSelection(
event,
msg,
groupIndex + 1,
)
}
>
{item}
</div>
);
})}
{renderTokenUsage({
messages: group.messages,
turnUsageMessages,
})}
{group.type === "assistant" &&
renderAssistantActions(
group.messages,
isAssistantMessageGroupStreaming(
group.messages,
streamingMessages,
),
group.id !== undefined &&
branchableAssistantGroupIds.has(group.id),
group.id === latestAssistantGroupId,
)}
</div>,
);
} else if (group.type === "assistant:clarification") {
const message = group.messages[0];
if (!message) {
return null;
}
const humanInputRequest = extractHumanInputRequest(message);
if (humanInputRequest) {
const answeredResponse =
humanInputState.answeredResponses.get(
humanInputRequest.request_id,
) ?? null;
const pending = pendingHumanInputRequestIds.has(
humanInputRequest.request_id,
);
return withRunDuration(
group,
groupIndex,
<div className="w-full">
<HumanInputCard
answeredResponse={answeredResponse}
disabled={
thread.isLoading ||
pending ||
Boolean(answeredResponse) ||
humanInputState.latestOpenRequestId !==
humanInputRequest.request_id ||
!onSubmitHumanInput
}
pending={pending}
request={humanInputRequest}
onSubmit={
onSubmitHumanInput
? (response) =>
handleSubmitHumanInput(
humanInputRequest,
response,
)
: undefined
}
/>
);
if (
group.type !== "assistant" ||
!enableSidecarActions ||
msg.type !== "ai"
) {
return <div key={`${group.id}/${msg.id}`}>{item}</div>;
}
return (
<div
key={`${group.id}/${msg.id}`}
onMouseUp={(event) =>
handleAssistantTextSelection(
event,
msg,
groupIndex + 1,
)
}
>
{item}
</div>
);
})}
{renderTokenUsage({
messages: group.messages,
turnUsageMessages,
})}
{group.type === "assistant" &&
renderAssistantActions(
group.messages,
isAssistantMessageGroupStreaming(
group.messages,
streamingMessages,
),
group.id !== undefined &&
branchableAssistantGroupIds.has(group.id),
group.id === latestAssistantGroupId,
)}
</div>,
);
} else if (group.type === "assistant:clarification") {
const message = group.messages[0];
if (!message) {
return null;
}
const humanInputRequest = extractHumanInputRequest(message);
if (humanInputRequest) {
const answeredResponse =
humanInputState.answeredResponses.get(
humanInputRequest.request_id,
) ?? null;
const pending = pendingHumanInputRequestIds.has(
humanInputRequest.request_id,
);
return withRunDuration(
group,
groupIndex,
<div className="w-full">
<HumanInputCard
answeredResponse={answeredResponse}
disabled={
thread.isLoading ||
pending ||
Boolean(answeredResponse) ||
humanInputState.latestOpenRequestId !==
humanInputRequest.request_id ||
!onSubmitHumanInput
}
pending={pending}
request={humanInputRequest}
onSubmit={
onSubmitHumanInput
? (response) =>
handleSubmitHumanInput(
humanInputRequest,
response,
)
: undefined
}
/>
{renderTokenUsage({
messages: group.messages,
turnUsageMessages,
})}
</div>,
);
}
if (hasContent(message)) {
return withRunDuration(
group,
groupIndex,
<div className="w-full">
<MarkdownContent
content={extractContentFromMessage(message)}
isLoading={thread.isLoading}
/>
{renderTokenUsage({
messages: group.messages,
turnUsageMessages,
})}
</div>,
);
}
return withRunDuration(group, groupIndex, null);
} else if (group.type === "assistant:present-files") {
const files: string[] = [];
for (const message of group.messages) {
if (hasPresentFiles(message)) {
const presentFiles = extractPresentFilesFromMessage(message);
files.push(...presentFiles);
{renderTokenUsage({
messages: group.messages,
turnUsageMessages,
})}
</div>,
);
}
if (hasContent(message)) {
return withRunDuration(
group,
groupIndex,
<div className="w-full">
<MarkdownContent
content={extractContentFromMessage(message)}
isLoading={thread.isLoading}
/>
{renderTokenUsage({
messages: group.messages,
turnUsageMessages,
})}
</div>,
);
}
return withRunDuration(group, groupIndex, null);
} else if (group.type === "assistant:present-files") {
const files: string[] = [];
for (const message of group.messages) {
if (hasPresentFiles(message)) {
const presentFiles =
extractPresentFilesFromMessage(message);
files.push(...presentFiles);
}
}
return withRunDuration(
group,
groupIndex,
<div className="w-full">
{group.messages[0] && hasContent(group.messages[0]) && (
<MarkdownContent
content={extractContentFromMessage(group.messages[0])}
isLoading={thread.isLoading}
className="mb-4"
/>
)}
<ArtifactFileList files={files} threadId={threadId} />
{renderTokenUsage({
messages: group.messages,
turnUsageMessages,
})}
</div>,
);
} else if (group.type === "assistant:subagent") {
const tasks = new Set<Subtask>();
for (const message of group.messages) {
if (message.type === "ai") {
for (const toolCall of message.tool_calls ?? []) {
if (toolCall.name === "task") {
const taskId = toolCall.id;
if (!taskId) {
continue;
}
const status = derivePendingSubtaskStatus(
taskId,
group.messages,
groupIsLoading,
);
const task: Subtask = {
id: taskId,
subagent_type: toolCall.args.subagent_type,
description: toolCall.args.description,
prompt: toolCall.args.prompt,
status,
...(status === "failed"
? { error: t.subtasks.failed }
: {}),
};
updateSubtask(task);
tasks.add(task);
}
}
} else if (message.type === "tool") {
const taskId = message.tool_call_id;
if (taskId) {
const parsed = parseSubtaskResult(
extractTextFromMessage(message),
message.additional_kwargs,
);
updateSubtask({ id: taskId, ...parsed });
}
}
}
const results: React.ReactNode[] = [];
const subagentDebugMessageIds: string[] = [];
if (tasks.size > 0) {
results.push(
<div
key="subtask-count"
className="text-muted-foreground pt-2 text-sm font-normal"
>
{t.subtasks.executing(tasks.size)}
</div>,
);
}
for (const message of group.messages.filter(
(message) => message.type === "ai",
)) {
if (hasReasoning(message)) {
results.push(
<MessageGroup
key={"thinking-group-" + message.id}
messages={[message]}
isLoading={groupIsLoading}
deferBrowserPreviews={thread.isLoading}
tokenDebugSteps={getTokenDebugStepsForMessages([
message,
])}
showTokenDebugSummaries={showTokenDebugSummaries}
/>,
);
} else if (message.id) {
subagentDebugMessageIds.push(message.id);
}
const taskIds = message.tool_calls?.flatMap((toolCall) =>
toolCall.name === "task" && toolCall.id
? [toolCall.id]
: [],
);
for (const taskId of taskIds ?? []) {
results.push(
<SubtaskCard
key={"task-group-" + taskId}
taskId={taskId}
threadId={threadId}
runId={(message as { run_id?: string }).run_id}
isLoading={groupIsLoading}
/>,
);
}
}
return withRunDuration(
group,
groupIndex,
<div className="relative z-1 flex flex-col gap-2">
{results}
{renderTokenUsage({
messages: group.messages,
turnUsageMessages,
debugMessageIds: subagentDebugMessageIds,
})}
</div>,
);
}
return withRunDuration(
group,
groupIndex,
<div className="w-full">
{group.messages[0] && hasContent(group.messages[0]) && (
<MarkdownContent
content={extractContentFromMessage(group.messages[0])}
isLoading={thread.isLoading}
className="mb-4"
/>
)}
<ArtifactFileList files={files} threadId={threadId} />
<MessageGroup
messages={group.messages}
isLoading={groupIsLoading}
deferBrowserPreviews={thread.isLoading}
threadId={threadId}
tokenDebugSteps={getTokenDebugStepsForMessages(
group.messages,
)}
showTokenDebugSummaries={showTokenDebugSummaries}
/>
{renderTokenUsage({
messages: group.messages,
turnUsageMessages,
inlineDebug: false,
})}
</div>,
);
} else if (group.type === "assistant:subagent") {
const tasks = new Set<Subtask>();
for (const message of group.messages) {
if (message.type === "ai") {
for (const toolCall of message.tool_calls ?? []) {
if (toolCall.name === "task") {
const taskId = toolCall.id;
if (!taskId) {
continue;
}
const status = derivePendingSubtaskStatus(
taskId,
group.messages,
groupIsLoading,
);
const task: Subtask = {
id: taskId,
subagent_type: toolCall.args.subagent_type,
description: toolCall.args.description,
prompt: toolCall.args.prompt,
status,
...(status === "failed"
? { error: t.subtasks.failed }
: {}),
};
updateSubtask(task);
tasks.add(task);
}
}
} else if (message.type === "tool") {
const taskId = message.tool_call_id;
if (taskId) {
const parsed = parseSubtaskResult(
extractTextFromMessage(message),
message.additional_kwargs,
);
updateSubtask({ id: taskId, ...parsed });
}
}
}
const results: React.ReactNode[] = [];
const subagentDebugMessageIds: string[] = [];
if (tasks.size > 0) {
results.push(
<div
key="subtask-count"
className="text-muted-foreground pt-2 text-sm font-normal"
>
{t.subtasks.executing(tasks.size)}
</div>,
);
}
for (const message of group.messages.filter(
(message) => message.type === "ai",
)) {
if (hasReasoning(message)) {
results.push(
<MessageGroup
key={"thinking-group-" + message.id}
messages={[message]}
isLoading={groupIsLoading}
deferBrowserPreviews={thread.isLoading}
tokenDebugSteps={getTokenDebugStepsForMessages([message])}
showTokenDebugSummaries={showTokenDebugSummaries}
/>,
);
} else if (message.id) {
subagentDebugMessageIds.push(message.id);
}
const taskIds = message.tool_calls?.flatMap((toolCall) =>
toolCall.name === "task" && toolCall.id ? [toolCall.id] : [],
);
for (const taskId of taskIds ?? []) {
results.push(
<SubtaskCard
key={"task-group-" + taskId}
taskId={taskId}
threadId={threadId}
runId={(message as { run_id?: string }).run_id}
isLoading={groupIsLoading}
/>,
);
}
}
return withRunDuration(
group,
groupIndex,
<div className="relative z-1 flex flex-col gap-2">
{results}
{renderTokenUsage({
messages: group.messages,
turnUsageMessages,
debugMessageIds: subagentDebugMessageIds,
})}
</div>,
);
}
return withRunDuration(
group,
groupIndex,
<div className="w-full">
<MessageGroup
messages={group.messages}
isLoading={groupIsLoading}
deferBrowserPreviews={thread.isLoading}
threadId={threadId}
tokenDebugSteps={getTokenDebugStepsForMessages(
group.messages,
)}
showTokenDebugSummaries={showTokenDebugSummaries}
/>
{renderTokenUsage({
messages: group.messages,
turnUsageMessages,
inlineDebug: false,
})}
</div>,
);
})}
}}
/>
{thread.isLoading && !hasActiveAssistantText && (
<div className="w-full">
<RunActivity startTime={turnStartTime} />

View File

@ -0,0 +1,183 @@
"use client";
import {
defaultRangeExtractor,
useVirtualizer,
type Range,
} from "@tanstack/react-virtual";
import {
useCallback,
useLayoutEffect,
useMemo,
useRef,
type Key,
type ReactNode,
} from "react";
import { useStickToBottomContext } from "use-stick-to-bottom";
import type { MessageGroup } from "@/core/messages/utils";
const VIRTUALIZATION_THRESHOLD = 60;
const ESTIMATED_ROW_HEIGHT = 176;
function groupKey(group: MessageGroup | undefined, index: number) {
return (
group?.id ??
group?.messages.find((message) => message.id)?.id ??
`${group?.type ?? "message"}:${index}`
);
}
export function VirtualMessageList({
groups,
isLoading,
renderGroup,
}: {
groups: readonly MessageGroup[];
isLoading: boolean;
renderGroup: (group: MessageGroup, index: number) => ReactNode;
}) {
const { isAtBottom, scrollRef, scrollToBottom } = useStickToBottomContext();
const activeIndex = isLoading ? groups.length - 1 : -1;
const getItemKey = useCallback(
(index: number) => groupKey(groups[index], index),
[groups],
);
const rangeExtractor = useCallback(
(range: Range) => {
const indices = defaultRangeExtractor(range);
if (activeIndex >= 0 && !indices.includes(activeIndex)) {
indices.push(activeIndex);
indices.sort((a, b) => a - b);
}
return indices;
},
[activeIndex],
);
const virtualizer = useVirtualizer({
count: groups.length,
estimateSize: () => ESTIMATED_ROW_HEIGHT,
getItemKey,
getScrollElement: () => scrollRef.current,
overscan: 8,
rangeExtractor,
});
const virtualItems = virtualizer.getVirtualItems();
const shouldVirtualize = groups.length >= VIRTUALIZATION_THRESHOLD;
const positionedInitialVirtualWindowRef = useRef(false);
const previousCountRef = useRef(groups.length);
const previousFirstKeyRef = useRef<string | undefined>(undefined);
const anchorRef = useRef<{ key: Key; viewportOffset: number } | undefined>(
undefined,
);
useLayoutEffect(() => {
let settleFrame: number | undefined;
if (
shouldVirtualize &&
isAtBottom &&
!positionedInitialVirtualWindowRef.current
) {
positionedInitialVirtualWindowRef.current = true;
const scrollToLatest = () => {
virtualizer.scrollToIndex(groups.length - 1, { align: "end" });
};
scrollToLatest();
// Dynamic row measurement changes the total after the first layout.
// Re-anchor once with measured sizes so a long restored conversation
// cannot land around the estimated midpoint.
settleFrame = requestAnimationFrame(scrollToLatest);
} else if (!shouldVirtualize) {
positionedInitialVirtualWindowRef.current = false;
}
if (groups.length > previousCountRef.current && isAtBottom) {
void scrollToBottom({
animation: "instant",
preserveScrollPosition: true,
});
}
previousCountRef.current = groups.length;
return () => {
if (settleFrame !== undefined) cancelAnimationFrame(settleFrame);
};
}, [
groups.length,
isAtBottom,
scrollToBottom,
shouldVirtualize,
virtualizer,
]);
useLayoutEffect(() => {
const firstKey = getItemKey(0);
const previousFirstKey = previousFirstKeyRef.current;
const anchor = anchorRef.current;
if (
previousFirstKey !== undefined &&
firstKey !== previousFirstKey &&
anchor
) {
const anchorIndex = groups.findIndex(
(group, index) => groupKey(group, index) === anchor.key,
);
if (anchorIndex >= 0) {
const anchorStart = virtualizer.getOffsetForIndex(
anchorIndex,
"start",
)?.[0];
if (anchorStart !== undefined) {
virtualizer.scrollToOffset(anchorStart - anchor.viewportOffset, {
behavior: "auto",
});
}
}
}
previousFirstKeyRef.current = firstKey;
const scrollOffset = virtualizer.scrollOffset ?? 0;
const firstVisible = virtualItems.find((item) => item.end >= scrollOffset);
if (firstVisible) {
anchorRef.current = {
key: firstVisible.key,
viewportOffset: firstVisible.start - scrollOffset,
};
}
}, [getItemKey, groups, virtualItems, virtualizer]);
const renderedAll = useMemo(
() =>
shouldVirtualize
? null
: groups.map((group, index) => (
<div key={getItemKey(index)}>{renderGroup(group, index)}</div>
)),
[getItemKey, groups, renderGroup, shouldVirtualize],
);
if (!shouldVirtualize) {
return <div className="flex flex-col gap-8">{renderedAll}</div>;
}
return (
<div
className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualItems.map((virtualRow) => {
const group = groups[virtualRow.index];
if (!group) return null;
return (
<div
key={virtualRow.key}
ref={virtualizer.measureElement}
data-index={virtualRow.index}
className="absolute top-0 left-0 w-full pb-8"
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
{renderGroup(group, virtualRow.index)}
</div>
);
})}
</div>
);
}

View File

@ -55,18 +55,19 @@ import {
usePinThread,
useRenameThread,
} from "@/core/threads/hooks";
import { buildThreadListModel } from "@/core/threads/thread-list-model";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import {
channelSourceOfThread,
isThreadPinned,
pathOfThread,
sortPinnedThreads,
titleOfThread,
} from "@/core/threads/utils";
import { env } from "@/env";
import { isIMEComposing } from "@/lib/ime";
import { ThreadChannelIcon } from "./thread-channel-source";
import { VirtualThreadList } from "./thread-list-virtualizer";
export function RecentChatList() {
const { t } = useI18n();
@ -83,22 +84,30 @@ export function RecentChatList() {
hasNextPage,
isFetchingNextPage,
} = useInfiniteThreads();
const threads = useMemo(() => {
const seen = new Set<string>();
return (infiniteThreads?.pages.flat() ?? []).filter((thread) => {
if (seen.has(thread.thread_id)) {
return false;
}
seen.add(thread.thread_id);
return true;
});
}, [infiniteThreads]);
const displayedThreads = useMemo(() => sortPinnedThreads(threads), [threads]);
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 sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const element = sentinelRef.current;
if (!element || !hasNextPage) {
if (!element || !hasNextPage || !threadListModel.canLoadMore) {
return;
}
const observer = new IntersectionObserver(
@ -111,7 +120,12 @@ export function RecentChatList() {
);
observer.observe(element);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
}, [
fetchNextPage,
hasNextPage,
isFetchingNextPage,
threadListModel.canLoadMore,
]);
const { mutate: deleteThread } = useDeleteThread();
const { mutate: renameThread } = useRenameThread();
@ -269,124 +283,134 @@ export function RecentChatList() {
</SidebarGroupLabel>
<SidebarGroupContent className="group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0">
<SidebarMenu>
<div className="flex w-full flex-col gap-1">
{displayedThreads.map((thread) => {
const isActive = pathOfThread(thread) === pathname;
const channelSource = channelSourceOfThread(thread);
const pinned = isThreadPinned(thread);
return (
<SidebarMenuItem
key={thread.thread_id}
className="group/side-menu-item"
>
<SidebarMenuButton isActive={isActive} asChild>
<Link
className="text-muted-foreground min-w-0 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)}
>
<ThreadChannelIcon source={channelSource} />
{pinned && (
<Pin
aria-hidden="true"
className="text-muted-foreground size-3.5 shrink-0"
/>
)}
<span className="min-w-0 truncate">
{titleOfThread(thread)}
</span>
{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"}
{/* 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={displayedThreads}
scrollParentSelector='[data-sidebar="content"]'
renderItem={(thread) => {
const isActive = pathOfThread(thread) === pathname;
const channelSource = channelSourceOfThread(thread);
const pinned = isThreadPinned(thread);
return (
<SidebarMenuItem
key={thread.thread_id}
className="group/side-menu-item"
>
<SidebarMenuButton isActive={isActive} asChild>
<Link
className="text-muted-foreground min-w-0 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)}
>
<DropdownMenuItem
onSelect={() => handleTogglePin(thread)}
>
{pinned ? (
<PinOff className="text-muted-foreground" />
) : (
<Pin className="text-muted-foreground" />
)}
<span>
{pinned ? t.chats.unpinChat : t.chats.pinChat}
<ThreadChannelIcon source={channelSource} />
{pinned && (
<Pin
aria-hidden="true"
className="text-muted-foreground size-3.5 shrink-0"
/>
)}
<span className="min-w-0 truncate">
{titleOfThread(thread)}
</span>
{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>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
handleRenameClick(
thread.thread_id,
titleOfThread(thread),
)
}
)}
</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"}
>
<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>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => handleDelete(thread)}
>
<Trash2 className="text-muted-foreground" />
<span>{t.common.delete}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarMenuItem>
);
})}
{hasNextPage && (
<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>
<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"

View File

@ -1,11 +1,26 @@
"use client";
import { SettingsDialog } from "./settings-dialog";
import dynamic from "next/dynamic";
import {
setSettingsDialogOpen,
useSettingsDialog,
} from "./settings-dialog-store";
const SettingsDialog = dynamic(
() => import("./settings-dialog").then((module) => module.SettingsDialog),
{
ssr: false,
loading: () => (
<div className="bg-background/80 fixed inset-0 z-50 grid place-items-center backdrop-blur-sm">
<p role="status" className="text-muted-foreground text-sm">
Loading settings
</p>
</div>
),
},
);
/**
* The single application-wide Settings dialog instance.
*
@ -15,6 +30,7 @@ import {
*/
export function SettingsDialogHost() {
const { open, section } = useSettingsDialog();
if (!open) return null;
return (
<SettingsDialog
open={open}

View File

@ -11,6 +11,7 @@ import {
UserIcon,
WrenchIcon,
} from "lucide-react";
import dynamic from "next/dynamic";
import { useEffect, useMemo, useState } from "react";
import {
@ -20,18 +21,75 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { ChannelsSettingsPage } from "@/components/workspace/settings/channels-settings-page";
import { IntegrationsSettingsPage } from "@/components/workspace/settings/integrations-settings-page";
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
import { NotificationSettingsPage } from "@/components/workspace/settings/notification-settings-page";
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
function SettingsPageLoading() {
return (
<p role="status" className="text-muted-foreground py-8 text-center text-sm">
Loading
</p>
);
}
const AccountSettingsPage = dynamic(
() =>
import("./account-settings-page").then(
(module) => module.AccountSettingsPage,
),
{ loading: SettingsPageLoading },
);
const AppearanceSettingsPage = dynamic(
() =>
import("./appearance-settings-page").then(
(module) => module.AppearanceSettingsPage,
),
{ loading: SettingsPageLoading },
);
const ChannelsSettingsPage = dynamic(
() =>
import("./channels-settings-page").then(
(module) => module.ChannelsSettingsPage,
),
{ loading: SettingsPageLoading },
);
const IntegrationsSettingsPage = dynamic(
() =>
import("./integrations-settings-page").then(
(module) => module.IntegrationsSettingsPage,
),
{ loading: SettingsPageLoading },
);
const MemorySettingsPage = dynamic(
() =>
import("./memory-settings-page").then(
(module) => module.MemorySettingsPage,
),
{ loading: SettingsPageLoading },
);
const NotificationSettingsPage = dynamic(
() =>
import("./notification-settings-page").then(
(module) => module.NotificationSettingsPage,
),
{ loading: SettingsPageLoading },
);
const SkillSettingsPage = dynamic(
() =>
import("./skill-settings-page").then((module) => module.SkillSettingsPage),
{ loading: SettingsPageLoading },
);
const ToolSettingsPage = dynamic(
() =>
import("./tool-settings-page").then((module) => module.ToolSettingsPage),
{ loading: SettingsPageLoading },
);
const AboutSettingsPage = dynamic(
() =>
import("./about-settings-page").then((module) => module.AboutSettingsPage),
{ loading: SettingsPageLoading },
);
export type SettingsSection =
| "account"
| "appearance"

View File

@ -0,0 +1,102 @@
"use client";
import { useVirtualizer } from "@tanstack/react-virtual";
import {
useCallback,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import type { AgentThread } from "@/core/threads/types";
const VIRTUALIZATION_THRESHOLD = 60;
export function calculateScrollMargin(
rootTop: number,
scrollParentTop: number,
scrollTop: number,
) {
return Math.max(0, rootTop - scrollParentTop + scrollTop);
}
export function VirtualThreadList({
estimateSize,
gap = 0,
items,
renderItem,
scrollParentSelector,
}: {
estimateSize: number;
gap?: number;
items: readonly AgentThread[];
renderItem: (thread: AgentThread, index: number) => ReactNode;
scrollParentSelector: string;
}) {
const rootRef = useRef<HTMLDivElement | null>(null);
const getScrollElement = useCallback(
() => rootRef.current?.closest<HTMLElement>(scrollParentSelector) ?? null,
[scrollParentSelector],
);
const [scrollMargin, setScrollMargin] = useState(0);
useLayoutEffect(() => {
const root = rootRef.current;
const scrollParent = getScrollElement();
if (!root || !scrollParent) return;
setScrollMargin(
calculateScrollMargin(
root.getBoundingClientRect().top,
scrollParent.getBoundingClientRect().top,
scrollParent.scrollTop,
),
);
}, [getScrollElement, items.length]);
const virtualizer = useVirtualizer({
count: items.length,
estimateSize: () => estimateSize,
getItemKey: (index) => items[index]?.thread_id ?? index,
getScrollElement,
overscan: 8,
scrollMargin,
});
if (items.length < VIRTUALIZATION_THRESHOLD) {
return (
<div
ref={rootRef}
className="flex w-full flex-col"
style={{ gap: `${gap}px` }}
>
{items.map(renderItem)}
</div>
);
}
return (
<div
ref={rootRef}
className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const thread = items[virtualRow.index];
if (!thread) return null;
return (
<div
key={virtualRow.key}
ref={virtualizer.measureElement}
data-index={virtualRow.index}
className="absolute top-0 left-0 w-full"
style={{
paddingBottom: `${gap}px`,
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
}}
>
{renderItem(thread, virtualRow.index)}
</div>
);
})}
</div>
);
}

View File

@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useThread } from "@/components/workspace/messages/context";
@ -18,6 +18,8 @@ export function useArtifactContent({
return filepath.startsWith("write-file:");
}, [filepath]);
const { thread, isMock } = useThread();
const [fullFilepath, setFullFilepath] = useState<string | null>(null);
const fullContentRequested = fullFilepath === filepath;
const content = useMemo(() => {
if (isWriteFile) {
return loadArtifactContentFromToolCall({ url: filepath, thread });
@ -26,9 +28,14 @@ export function useArtifactContent({
}, [filepath, isWriteFile, thread]);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["artifact", filepath, threadId, isMock],
queryKey: ["artifact", filepath, threadId, isMock, fullContentRequested],
queryFn: () => {
return loadArtifactContent({ filepath, threadId, isMock });
return loadArtifactContent({
filepath,
threadId,
isMock,
full: fullContentRequested,
});
},
enabled,
staleTime: 0,
@ -46,10 +53,19 @@ export function useArtifactContent({
}
}, [enabled, isWriteFile, refetch, thread.isLoading]);
const loadFullContent = useCallback(() => {
setFullFilepath(filepath);
}, [filepath]);
return {
content: isWriteFile ? content : data?.content,
url: isWriteFile ? undefined : data?.url,
sha256: isWriteFile ? undefined : data?.sha256,
truncated: isWriteFile ? false : (data?.truncated ?? false),
previewBytes: isWriteFile ? undefined : data?.previewBytes,
totalBytes: isWriteFile ? undefined : data?.totalBytes,
fullContentRequested,
loadFullContent,
isLoading,
error,
};

View File

@ -15,29 +15,82 @@ async function sha256OfText(content: string): Promise<string> {
).join("");
}
export const ARTIFACT_PREVIEW_MAX_BYTES = 1024 * 1024;
function parseContentRange(value: string | null) {
const match = value?.match(/^bytes (?:(\d+)-(\d+)|\*)\/(\d+)$/);
if (!match) return undefined;
return {
end: match[2] === undefined ? undefined : Number(match[2]),
total: Number(match[3]),
};
}
export async function loadArtifactContent({
filepath,
threadId,
isMock,
full = false,
}: {
filepath: string;
threadId: string;
isMock?: boolean;
full?: boolean;
}) {
let enhancedFilepath = filepath;
if (filepath.endsWith(".skill")) {
enhancedFilepath = filepath + "/SKILL.md";
}
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock });
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Failed to load artifact: HTTP ${response.status}`);
const response = await fetch(url, {
cache: "no-store",
headers: full
? undefined
: { Range: `bytes=0-${ARTIFACT_PREVIEW_MAX_BYTES - 1}` },
});
const contentRange = parseContentRange(response.headers.get("Content-Range"));
if (response.status === 416 && contentRange?.total === 0) {
return {
content: "",
url,
truncated: false,
previewBytes: 0,
totalBytes: 0,
sha256: await sha256OfText(""),
};
}
const text = await response.text();
if (!response.ok) {
throw new Error(`Failed to load artifact: ${response.status}`);
}
const bytes = await response.arrayBuffer();
const truncated =
!full &&
response.status === 206 &&
(contentRange?.end === undefined ||
contentRange.total > contentRange.end + 1);
// Streaming decode intentionally holds an incomplete trailing UTF-8 code
// point instead of fabricating U+FFFD at the range boundary.
const content = new TextDecoder().decode(bytes, { stream: truncated });
const etag = response.headers.get("etag");
const sha256 =
etag?.match(/^"([0-9a-f]{64})"$/)?.[1] ?? (await sha256OfText(text));
return { content: text, url, sha256 };
etag?.match(/^"([0-9a-f]{64})"$/)?.[1] ??
(!truncated ? await sha256OfText(content) : undefined);
const contentLengthHeader = response.headers.get("Content-Length");
const contentLength =
contentLengthHeader === null ? undefined : Number(contentLengthHeader);
return {
content,
url,
sha256,
truncated,
previewBytes: bytes.byteLength,
totalBytes:
contentRange?.total ??
(contentLength !== undefined && Number.isFinite(contentLength)
? contentLength
: undefined),
};
}
export function loadArtifactContentFromToolCall({

View File

@ -0,0 +1,103 @@
import { useEffect, useState, type RefObject } from "react";
export type RenderActivityListener = (active: boolean) => void;
/**
* Reports whether an element is on screen, in a visible document, and allowed
* to animate by the user's motion preference.
* Consumers can use this to suspend expensive animation loops.
*/
export function observeRenderActivity(
element: Element,
listener: RenderActivityListener,
initialElementVisible = true,
respectReducedMotion = true,
) {
let documentVisible = !document.hidden;
let elementVisible =
typeof IntersectionObserver === "undefined" ? true : initialElementVisible;
const motionPreference =
!respectReducedMotion || typeof matchMedia === "undefined"
? undefined
: matchMedia("(prefers-reduced-motion: reduce)");
let reducedMotion = motionPreference?.matches ?? false;
let lastActive: boolean | undefined;
const notify = () => {
const active = documentVisible && elementVisible && !reducedMotion;
if (active !== lastActive) {
lastActive = active;
listener(active);
}
};
const handleVisibilityChange = () => {
documentVisible = !document.hidden;
notify();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
const handleMotionPreferenceChange = (event: MediaQueryListEvent) => {
reducedMotion = event.matches;
notify();
};
motionPreference?.addEventListener("change", handleMotionPreferenceChange);
const observer =
typeof IntersectionObserver === "undefined"
? undefined
: new IntersectionObserver(([entry]) => {
elementVisible = entry?.isIntersecting ?? false;
notify();
});
observer?.observe(element);
notify();
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
motionPreference?.removeEventListener(
"change",
handleMotionPreferenceChange,
);
observer?.disconnect();
};
}
export function useRenderActivity(
ref: RefObject<Element | null>,
initialActive = true,
respectReducedMotion = true,
) {
// Keep server and first client render aligned; the observer corrects this
// immediately after mount.
const [active, setActive] = useState(initialActive);
useEffect(() => {
const element = ref.current;
if (!element) return;
return observeRenderActivity(
element,
setActive,
initialActive,
respectReducedMotion,
);
}, [initialActive, ref, respectReducedMotion]);
return active;
}
export function usePrefersReducedMotion() {
const [reducedMotion, setReducedMotion] = useState(false);
useEffect(() => {
if (typeof matchMedia === "undefined") return;
const preference = matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setReducedMotion(preference.matches);
update();
preference.addEventListener("change", update);
return () => preference.removeEventListener("change", update);
}, []);
return reducedMotion;
}

View File

@ -0,0 +1,12 @@
import type { Locale } from "./locale";
import { enUS } from "./locales/en-US";
import type { Translations } from "./locales/types";
import { zhCN } from "./locales/zh-CN";
// Translation dictionaries contain formatter functions, so they must be
// selected inside a Client Component rather than serialized through an RSC
// boundary.
export const clientTranslations: Record<Locale, Translations> = {
"en-US": enUS,
"zh-CN": zhCN,
};

View File

@ -1,12 +1,23 @@
"use client";
import { createContext, useContext, useState, type ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import type { Locale } from "@/core/i18n";
import type { Translations } from "@/core/i18n/locales";
import { clientTranslations } from "./client-translations";
export interface I18nContextType {
locale: Locale;
setLocale: (locale: Locale) => void;
t: Translations;
}
export const I18nContext = createContext<I18nContextType | null>(null);
@ -19,14 +30,21 @@ export function I18nProvider({
initialLocale: Locale;
}) {
const [locale, setLocale] = useState<Locale>(initialLocale);
const [t, setTranslations] = useState<Translations>(
clientTranslations[initialLocale],
);
const handleSetLocale = (newLocale: Locale) => {
const handleSetLocale = useCallback((newLocale: Locale) => {
setLocale(newLocale);
document.cookie = `locale=${newLocale}; path=/; max-age=31536000`;
};
setTranslations(clientTranslations[newLocale]);
}, []);
useEffect(() => {
document.documentElement.lang = locale;
}, [locale]);
return (
<I18nContext.Provider value={{ locale, setLocale: handleSetLocale }}>
<I18nContext.Provider value={{ locale, setLocale: handleSetLocale, t }}>
{children}
</I18nContext.Provider>
);

View File

@ -4,19 +4,11 @@ import { useEffect } from "react";
import { useI18nContext } from "./context";
import { getLocaleFromCookie, setLocaleInCookie } from "./cookies";
import { translations } from "./translations";
import {
DEFAULT_LOCALE,
detectLocale,
normalizeLocale,
type Locale,
} from "./index";
import { detectLocale, normalizeLocale, type Locale } from "./index";
export function useI18n() {
const { locale, setLocale } = useI18nContext();
const t = translations[locale] ?? translations[DEFAULT_LOCALE];
const { locale, setLocale, t } = useI18nContext();
const changeLocale = (newLocale: Locale) => {
setLocale(newLocale);

View File

@ -115,6 +115,17 @@ export const enUS: Translations = {
saveFailed: "Failed to save artifact",
},
artifactPreview: {
limited: (previewSize, totalSize) =>
totalSize
? `Showing the first ${previewSize} of ${totalSize}.`
: `Showing the first ${previewSize}.`,
loadFullFile: "Load full file",
loadingFullFile: "Loading full file...",
previewFailed:
"This file could not be previewed. You can still download it.",
},
// Citations
citations: {
sourcesSummary: (count) =>

View File

@ -97,6 +97,13 @@ export interface Translations {
saveFailed: string;
};
artifactPreview: {
limited: (previewSize: string, totalSize?: string) => string;
loadFullFile: string;
loadingFullFile: string;
previewFailed: string;
};
// Citations
citations: {
sourcesSummary: (count: number) => string;

View File

@ -113,6 +113,16 @@ export const zhCN: Translations = {
saveFailed: "保存文件失败",
},
artifactPreview: {
limited: (previewSize, totalSize) =>
totalSize
? `当前显示 ${totalSize} 中的前 ${previewSize}`
: `当前显示前 ${previewSize}`,
loadFullFile: "加载完整文件",
loadingFullFile: "正在加载完整文件...",
previewFailed: "无法预览此文件,但仍可下载原始文件。",
},
// Citations
citations: {
sourcesSummary: (count) => `使用了 ${count} 个来源`,

View File

@ -1,7 +1,7 @@
import { cookies } from "next/headers";
import { DEFAULT_LOCALE, normalizeLocale, type Locale } from "./locale";
import { translations } from "./translations";
import { normalizeLocale, type Locale } from "./locale";
import { loadTranslations } from "./translations";
export async function detectLocaleServer(): Promise<Locale> {
const cookieStore = await cookies();
@ -33,7 +33,7 @@ export async function getI18n(localeOverride?: string | Locale) {
const locale = localeOverride
? normalizeLocale(localeOverride)
: await detectLocaleServer();
const t = translations[locale] ?? translations[DEFAULT_LOCALE];
const t = await loadTranslations(locale);
return {
locale,
t,

View File

@ -1,7 +1,11 @@
import type { Locale } from "./locale";
import { enUS, zhCN, type Translations } from "./locales";
import type { Translations } from "./locales";
export const translations: Record<Locale, Translations> = {
"en-US": enUS,
"zh-CN": zhCN,
const translationLoaders: Record<Locale, () => Promise<Translations>> = {
"en-US": async () => (await import("./locales/en-US")).enUS,
"zh-CN": async () => (await import("./locales/zh-CN")).zhCN,
};
export async function loadTranslations(locale: Locale): Promise<Translations> {
return await translationLoaders[locale]();
}

View File

@ -0,0 +1,228 @@
import type { Message } from "@langchain/langgraph-sdk";
import { getMessageRunId } from "./run-duration";
import {
getMessageGroups,
isHiddenFromUIMessage,
type MessageGroup,
} from "./utils";
function messageStableKey(message: Message) {
if (
message.type === "tool" &&
typeof message.tool_call_id === "string" &&
message.tool_call_id.length > 0
) {
return `tool:${message.tool_call_id}`;
}
if (typeof message.id === "string" && message.id.length > 0) {
return `message:${message.id}`;
}
return null;
}
function sameMessageIdentity(previous: Message, next: Message) {
if (previous.type !== next.type) return false;
const previousKey = messageStableKey(previous);
return previousKey !== null && previousKey === messageStableKey(next);
}
function sameMessageValue(previous: Message, next: Message) {
if (previous === next) return true;
if (!sameMessageIdentity(previous, next)) return false;
const previousRecord = previous as unknown as Record<string, unknown>;
const nextRecord = next as unknown as Record<string, unknown>;
const previousKeys = Object.keys(previousRecord);
const nextKeys = Object.keys(nextRecord);
return (
previousKeys.length === nextKeys.length &&
previousKeys.every((key) => Object.is(previousRecord[key], nextRecord[key]))
);
}
function sameReusableMessage(previous: Message, next: Message) {
return (
sameMessageValue(previous, next) &&
getMessageRunId(previous) === getMessageRunId(next) &&
Object.is(
previous.additional_kwargs?.turn_duration,
next.additional_kwargs?.turn_duration,
)
);
}
function canReuseMessageGroup(
previous: MessageGroup | undefined,
next: MessageGroup,
): previous is MessageGroup {
if (
!previous ||
previous.id !== next.id ||
previous.type !== next.type ||
previous.messages.length !== next.messages.length
) {
return false;
}
return previous.messages.every((message, index) => {
const nextMessage = next.messages[index];
return (
nextMessage !== undefined && sameReusableMessage(message, nextMessage)
);
});
}
function stabilizeGroups(
nextGroups: MessageGroup[],
previousGroups: readonly MessageGroup[],
activeGroupIndex: number,
) {
return nextGroups.map((group, index) =>
index !== activeGroupIndex &&
canReuseMessageGroup(previousGroups[index], group)
? previousGroups[index]
: group,
);
}
export function deriveStableMessageGroups(
messages: Message[],
isLoading: boolean,
previousGroups: readonly MessageGroup[],
previousIsLoading: boolean,
): MessageGroup[] {
if (isLoading && previousGroups.length > 0) {
let turnStartIndex = -1;
for (let index = messages.length - 1; index >= 0; index--) {
const candidate = messages[index];
if (candidate?.type === "human" && !isHiddenFromUIMessage(candidate)) {
turnStartIndex = index;
break;
}
}
const turnStartMessage = messages[turnStartIndex];
let previousTurnStartGroupIndex = -1;
if (turnStartMessage) {
for (let index = previousGroups.length - 1; index >= 0; index -= 1) {
const group = previousGroups[index];
if (
group?.type === "human" &&
group.messages.some((message) =>
sameMessageIdentity(message, turnStartMessage),
)
) {
previousTurnStartGroupIndex = index;
break;
}
}
}
if (turnStartIndex >= 0 && previousTurnStartGroupIndex >= 0) {
const previousPrefix = previousGroups.slice(
0,
previousTurnStartGroupIndex,
);
const nextPrefixMessages = messages
.slice(0, turnStartIndex)
.filter((message) => !isHiddenFromUIMessage(message));
const previousPrefixMessages = previousPrefix.flatMap(
(group) => group.messages,
);
const canReusePrefix =
nextPrefixMessages.length === previousPrefixMessages.length &&
nextPrefixMessages.every((message, index) => {
const previousMessage = previousPrefixMessages[index];
return (
previousMessage !== undefined &&
sameReusableMessage(previousMessage, message)
);
});
const prefix = canReusePrefix
? previousPrefix
: stabilizeGroups(
getMessageGroups(messages.slice(0, turnStartIndex)),
previousPrefix,
-1,
);
const previousTail = previousGroups.slice(previousTurnStartGroupIndex);
const nextTail = getMessageGroups(messages.slice(turnStartIndex), {
isCurrentTurnLoading: true,
});
const stableTail = stabilizeGroups(
nextTail,
previousTail,
nextTail.length - 1,
);
return [...prefix, ...stableTail];
}
}
const nextGroups = getMessageGroups(messages, {
isCurrentTurnLoading: isLoading,
});
const activeGroupIndex =
isLoading || previousIsLoading ? nextGroups.length - 1 : -1;
return stabilizeGroups(nextGroups, previousGroups, activeGroupIndex);
}
export type AssistantTurnUsageState = {
groups: readonly MessageGroup[];
byGroupIndex: Array<Message[] | null>;
};
export function deriveAssistantTurnUsageState(
groups: readonly MessageGroup[],
previous?: AssistantTurnUsageState,
): AssistantTurnUsageState {
let firstChangedIndex = 0;
if (previous) {
const commonLength = Math.min(groups.length, previous.groups.length);
while (
firstChangedIndex < commonLength &&
groups[firstChangedIndex] === previous.groups[firstChangedIndex]
) {
firstChangedIndex += 1;
}
if (
firstChangedIndex === groups.length &&
groups.length === previous.groups.length
) {
return previous;
}
}
let recomputeStart = firstChangedIndex;
while (
recomputeStart > 0 &&
groups[recomputeStart]?.type !== "human" &&
groups[recomputeStart - 1]?.type !== "human"
) {
recomputeStart -= 1;
}
const byGroupIndex: Array<Message[] | null> = Array.from(
{ length: groups.length },
(_, index) =>
index < recomputeStart ? (previous?.byGroupIndex[index] ?? null) : null,
);
let turnStartIndex: number | null = null;
for (let index = recomputeStart; index < groups.length; index += 1) {
const group = groups[index];
if (!group) continue;
if (group.type === "human") {
turnStartIndex = null;
continue;
}
turnStartIndex ??= index;
const nextGroup = groups[index + 1];
if (nextGroup && nextGroup.type !== "human") continue;
byGroupIndex[index] = groups
.slice(turnStartIndex, index + 1)
.flatMap((currentGroup) => currentGroup.messages)
.filter((message) => message.type === "ai");
turnStartIndex = null;
}
return { groups, byGroupIndex };
}

View File

@ -2486,6 +2486,11 @@ type ThreadHistoryOptions = {
pendingSupersededRunIds?: ReadonlySet<string>;
};
export const THREAD_HISTORY_QUERY_POLICY = {
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1_000,
} as const;
export function useThreadHistory(
threadId: string,
{ enabled = true, pendingSupersededRunIds }: ThreadHistoryOptions = {},
@ -2497,6 +2502,7 @@ export function useThreadHistory(
ReturnType<typeof threadHistoryQueryKey>,
number | null
>({
...THREAD_HISTORY_QUERY_POLICY,
queryKey: threadHistoryQueryKey(threadId),
enabled: enabled && Boolean(threadId),
initialPageParam: null,

View File

@ -19,6 +19,112 @@ export const DEMO_THREAD_IDS = [
"fe3f7974-1bcb-4a01-a950-79673baafefd",
] as const;
export const STATIC_DEMO_ARTIFACTS: Readonly<
Record<string, readonly string[]>
> = {
"21cfea46-34bd-4aa6-9e1f-3009452fbeb9": [
"user-data/outputs/doraemon-moe-comic.jpg",
],
"3823e443-4e2b-4679-b496-a9506eae462b": [
"user-data/outputs/fei-fei-li-podcast-timeline.md",
],
"4f3e55ee-f853-43db-bfb3-7d1a411f03cb": [
"user-data/outputs/darcy-proposal-reference.jpg",
"user-data/outputs/darcy-proposal-video.mp4",
],
"5aa47db1-d0cb-4eb9-aea5-3dac1b371c5a": [
"user-data/outputs/jiangsu-football/css/style.css",
"user-data/outputs/jiangsu-football/favicon.html",
"user-data/outputs/jiangsu-football/index.html",
"user-data/outputs/jiangsu-football/js/data.js",
"user-data/outputs/jiangsu-football/js/main.js",
],
"7cfa5f8f-a2f8-47ad-acbd-da7137baf990": [
"user-data/outputs/index.html",
"user-data/outputs/script.js",
"user-data/outputs/style.css",
],
"7f9dc56c-e49c-4671-a3d2-c492ff4dce0c": [
"user-data/outputs/leica-master-photography-article.md",
"user-data/outputs/leica-nyc-candid.jpg",
"user-data/outputs/leica-paris-decisive-moment.jpg",
"user-data/outputs/leica-tokyo-night.jpg",
],
"90040b36-7eba-4b97-ba89-02c3ad47a8b9": [
"user-data/outputs/american-woman-newyork.jpg",
"user-data/outputs/american-woman-shanghai.jpg",
],
"ad76c455-5bf9-4335-8517-fc03834ab828": [
"user-data/outputs/titanic_summary.txt",
"user-data/outputs/visualizations/class_gender_survival.png",
"user-data/outputs/visualizations/correlation_heatmap.png",
"user-data/outputs/visualizations/family_size_analysis.png",
"user-data/outputs/visualizations/fare_analysis.png",
"user-data/outputs/visualizations/survival_by_age.png",
"user-data/outputs/visualizations/survival_by_class.png",
"user-data/outputs/visualizations/survival_overview.png",
"user-data/uploads/titanic.csv",
],
"b83fbb2a-4e36-4d82-9de0-7b2a02c2092a": [
"user-data/outputs/caren-hero.jpg",
"user-data/outputs/caren-ingredients.jpg",
"user-data/outputs/caren-lifestyle.jpg",
"user-data/outputs/caren-products.jpg",
"user-data/outputs/index.html",
],
"c02bb4d5-4202-490e-ae8f-ff4864fc0d2e": [
"user-data/outputs/index.html",
"user-data/outputs/script.js",
"user-data/outputs/styles.css",
],
"d3e5adaf-084c-4dd5-9d29-94f1d6bccd98": [
"user-data/outputs/diana_hu_research.md",
],
"f4125791-0128-402a-8ca9-50e0947557e4": ["user-data/outputs/index.html"],
"fe3f7974-1bcb-4a01-a950-79673baafefd": [
"user-data/outputs/index.html",
"user-data/outputs/research_deerflow_20260201.md",
],
};
const STATIC_DEMO_ARTIFACT_SETS = Object.fromEntries(
Object.entries(STATIC_DEMO_ARTIFACTS).map(([threadId, artifacts]) => [
threadId,
new Set(artifacts),
]),
) as Readonly<Record<string, ReadonlySet<string>>>;
export function resolveStaticDemoArtifact(
threadId: string,
encodedSegments: readonly string[],
): string | null {
const allowedArtifacts = STATIC_DEMO_ARTIFACT_SETS[threadId];
if (!allowedArtifacts || encodedSegments[0] !== "mnt") return null;
let segments: string[];
try {
segments = encodedSegments.map((segment) => decodeURIComponent(segment));
} catch {
return null;
}
if (
segments.some(
(segment) =>
segment.length === 0 ||
segment === "." ||
segment === ".." ||
segment.includes("/") ||
segment.includes("\\"),
)
) {
return null;
}
const artifactPath = segments.slice(1).join("/");
if (!allowedArtifacts.has(artifactPath)) return null;
return `/demo/threads/${threadId}/${artifactPath}`;
}
export type ThreadSearchParams = NonNullable<
Parameters<ThreadsClient["search"]>[0]
>;

View File

@ -0,0 +1,43 @@
import type { AgentThread } from "./types";
import { isThreadPinned, sortPinnedThreads } from "./utils";
const MAX_VISIBLE_THREADS = 200;
const modelCache = new WeakMap<object, ThreadListModel>();
export type ThreadListModel = {
byId: ReadonlyMap<string, AgentThread>;
threads: readonly AgentThread[];
displayedThreads: readonly AgentThread[];
canLoadMore: boolean;
};
export function buildThreadListModel(
pages: readonly (readonly AgentThread[])[],
): ThreadListModel {
const cacheKey = pages as object;
const cached = modelCache.get(cacheKey);
if (cached) return cached;
const byId = new Map<string, AgentThread>();
for (const page of pages) {
for (const thread of page) {
if (!byId.has(thread.thread_id)) {
byId.set(thread.thread_id, thread);
}
}
}
const threads = [...byId.values()];
const sortedThreads = sortPinnedThreads(threads);
const pinnedThreads = sortedThreads.filter(isThreadPinned);
const recentThreads = sortedThreads
.filter((thread) => !isThreadPinned(thread))
.slice(0, MAX_VISIBLE_THREADS);
const model: ThreadListModel = {
byId,
threads,
displayedThreads: [...pinnedThreads, ...recentThreads],
canLoadMore: recentThreads.length < MAX_VISIBLE_THREADS,
};
modelCache.set(cacheKey, model);
return model;
}

View File

@ -279,6 +279,10 @@
--sidebar-ring: oklch(0.708 0 0);
}
html.dark .shiki span {
color: var(--shiki-dark) !important;
}
.dark {
--background: oklch(0.24 0.0036 106.64);
--foreground: oklch(0.985 0 0);

View File

@ -7,6 +7,7 @@ const MARKDOWN_ARTIFACT_PATH = "/artifact-fixtures/report.md";
const JSON_ARTIFACT_PATH = "/artifact-fixtures/report.json";
const PRESENTED_ARTIFACT_PATH = "/mnt/user-data/outputs/presented-report.md";
const PDF_ARTIFACT_PATH = "/artifact-fixtures/report.pdf";
const LARGE_JSON_ARTIFACT_PATH = "/mnt/user-data/outputs/large-report.json";
const IN_PROGRESS_THREAD_ID = "00000000-0000-0000-0000-000000003119";
const COMPLETE_THREAD_ID = "00000000-0000-0000-0000-000000003120";
const MARKDOWN_THREAD_ID = "00000000-0000-0000-0000-000000003121";
@ -15,6 +16,7 @@ const JSON_THREAD_ID = "00000000-0000-0000-0000-000000003122";
const PRESENTED_THREAD_ID = "00000000-0000-0000-0000-000000003123";
const PERSISTED_PANEL_THREAD_ID = "00000000-0000-0000-0000-000000003125";
const PDF_THREAD_ID = "00000000-0000-0000-0000-000000003124";
const LARGE_JSON_THREAD_ID = "00000000-0000-0000-0000-000000003126";
function writeFileMessages({
path = ARTIFACT_PATH,
@ -62,7 +64,7 @@ function writeFileMessages({
return messages;
}
function presentFilesMessages() {
function presentFilesMessages(path = PRESENTED_ARTIFACT_PATH) {
return [
{
type: "human",
@ -78,7 +80,7 @@ function presentFilesMessages() {
id: "present-file-artifact",
name: "present_files",
args: {
filepaths: [PRESENTED_ARTIFACT_PATH],
filepaths: [path],
},
},
],
@ -268,6 +270,60 @@ test.describe("Artifact preview stability", () => {
).toBeVisible();
});
test("loads a large code artifact only after explicit confirmation", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: LARGE_JSON_THREAD_ID,
title: "Large artifact preview",
messages: presentFilesMessages(LARGE_JSON_ARTIFACT_PATH),
artifacts: [LARGE_JSON_ARTIFACT_PATH],
},
],
});
const ranges: Array<string | undefined> = [];
await page.route(
`**/api/threads/${LARGE_JSON_THREAD_ID}/artifacts/mnt/user-data/outputs/large-report.json`,
(route) => {
const range = route.request().headers().range;
ranges.push(range);
if (range) {
const body = '{"preview":"PARTIAL_FILE_MARKER"}';
return route.fulfill({
status: 206,
contentType: "application/json",
headers: {
"Content-Range": `bytes 0-${Buffer.byteLength(body) - 1}/2000000`,
},
body,
});
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: '{"complete":"FULL_FILE_MARKER"}',
});
},
);
await page.goto(`/workspace/chats/${LARGE_JSON_THREAD_ID}`);
await expect(page.getByText("large-report.json").first()).toBeVisible({
timeout: 15_000,
});
await page.getByText("large-report.json").first().click();
const artifactsPanel = page.locator("#artifacts");
await expect(artifactsPanel.getByText(/PARTIAL_FILE_MARKER/)).toBeVisible();
await expect(artifactsPanel.locator(".cm-editor")).toHaveCount(0);
await artifactsPanel
.getByRole("button", { name: "Load full file" })
.click();
await expect(artifactsPanel.getByText(/FULL_FILE_MARKER/)).toBeVisible();
expect(ranges).toEqual(["bytes=0-1048575", undefined]);
});
test("keeps an opened presented artifact in the header dropdown", async ({
page,
}) => {

View File

@ -94,6 +94,54 @@ test.describe("Thread history", () => {
).toBeVisible({ timeout: 15_000 });
});
test("keeps a thousand-turn history DOM bounded while preserving navigation", async ({
page,
}) => {
const messages = Array.from({ length: 1_000 }, (_, turn) => [
{
type: "human",
id: `long-human-${turn}`,
content: `Long history question ${turn}`,
},
{
type: "ai",
id: `long-ai-${turn}`,
content: `Long history answer ${turn}`,
},
]).flat();
mockLangGraphAPI(page, {
threads: [
{
thread_id: MOCK_THREAD_ID,
title: "Virtualized long history",
updated_at: "2025-06-03T12:00:00Z",
messages,
},
],
});
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
await expect(page.getByText("Long history answer 999")).toBeVisible({
timeout: 15_000,
});
const conversation = page.getByRole("log");
const scroller = conversation.locator(":scope > div").first();
await expect
.poll(() => conversation.locator("[data-index]").count())
.toBeLessThan(60);
await scroller.dispatchEvent("wheel", { deltaY: -1_000 });
await scroller.evaluate((element) => {
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll"));
});
await expect(page.getByText("Long history question 0")).toBeVisible({
timeout: 15_000,
});
expect(await conversation.locator("[data-index]").count()).toBeLessThan(60);
});
test("keeps rendered messages ordered when the latest history page advances", async ({
page,
}) => {
@ -226,9 +274,20 @@ test.describe("Thread history", () => {
await expect
.poll(() => cursorPageRequestCount, { timeout: 15_000 })
.toBeGreaterThan(0);
const conversation = page.getByRole("log");
const scroller = conversation.locator(":scope > div").first();
await scroller.dispatchEvent("wheel", { deltaY: -1_000 });
await scroller.evaluate((element) => {
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll"));
});
await expect(page.getByText(originalPrompt)).toBeVisible({
timeout: 15_000,
});
await scroller.evaluate((element) => {
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll"));
});
await expect(page.getByText("Completed in 11m 44s")).toBeVisible();
const latestPageRequestsBeforeSubmit = latestPageRequestCount;
@ -239,15 +298,32 @@ test.describe("Thread history", () => {
await expect
.poll(() => latestPageRequestCount, { timeout: 15_000 })
.toBeGreaterThan(latestPageRequestsBeforeSubmit);
await expect(page.getByText(originalPrompt)).toBeVisible();
await expect(page.getByText(followUpPrompt)).toBeVisible();
await expect(page.getByText("Completed in 11m 44s")).toBeVisible();
const originalBox = await page.getByText(originalPrompt).boundingBox();
const followUpBox = await page.getByText(followUpPrompt).boundingBox();
expect(originalBox).not.toBeNull();
expect(followUpBox).not.toBeNull();
expect(originalBox!.y).toBeLessThan(followUpBox!.y);
let preservedDurationFound = false;
for (let step = 0; step <= 12; step += 1) {
await scroller.evaluate((element, ratio) => {
element.scrollTop =
(element.scrollHeight - element.clientHeight) * ratio;
element.dispatchEvent(new Event("scroll"));
}, step / 12);
if (await page.getByText("Completed in 11m 44s").isVisible()) {
preservedDurationFound = true;
break;
}
}
expect(preservedDurationFound).toBe(true);
await scroller.evaluate((element) => {
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll"));
});
await expect(page.getByText(originalPrompt)).toBeVisible();
await scroller.evaluate((element) => {
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll"));
});
await expect(page.getByText(followUpPrompt)).toBeVisible();
});
test("shows a completed run duration once after multi-step history", async ({

View File

@ -0,0 +1,45 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "@rstest/core";
const FRONTEND_ROOT = path.resolve(__dirname, "../../..");
function source(relativePath: string) {
return readFileSync(path.join(FRONTEND_ROOT, relativePath), "utf8");
}
describe("layout performance boundaries", () => {
it("keeps request locale and rich-content styles out of the root layout", () => {
const rootLayout = source("src/app/layout.tsx");
expect(rootLayout).not.toContain("detectLocaleServer");
expect(rootLayout).not.toContain("I18nProvider");
expect(rootLayout).not.toContain("katex/dist/katex.min.css");
expect(rootLayout).not.toContain("streamdown/styles.css");
expect(rootLayout).toContain("DEFAULT_LOCALE");
});
it("assigns rich-content styles to routes that render them", () => {
expect(source("src/app/workspace/layout.tsx")).toContain(
'import "streamdown/styles.css"',
);
expect(source("src/app/[lang]/docs/layout.tsx")).toContain(
'import "katex/dist/katex.min.css"',
);
expect(source("src/app/blog/layout.tsx")).toContain(
'import "katex/dist/katex.min.css"',
);
});
it("passes only serializable locale state through server layouts", () => {
for (const layout of [
source("src/app/(auth)/layout.tsx"),
source("src/app/workspace/layout.tsx"),
]) {
expect(layout).toContain("detectLocaleServer");
expect(layout).not.toContain("initialTranslations");
expect(layout).not.toContain("getI18n");
}
});
});

View File

@ -0,0 +1,85 @@
import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { NextRequest } from "next/server";
import { GET } from "@/app/mock/api/threads/[thread_id]/artifacts/[[...artifact_path]]/route";
describe("static artifact mock route", () => {
afterEach(() => {
rs.restoreAllMocks();
});
it("streams only a manifest-owned public artifact", async () => {
const fetchMock = rs
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response("artifact body", { status: 200 }));
const request = new NextRequest(
"http://deer-flow.test/mock/api/threads/thread/artifacts/file",
);
const response = await GET(request, {
params: Promise.resolve({
thread_id: "7cfa5f8f-a2f8-47ad-acbd-da7137baf990",
artifact_path: ["mnt", "user-data", "outputs", "index.html"],
}),
});
expect(fetchMock).toHaveBeenCalledWith(
"http://deer-flow.test/demo/threads/7cfa5f8f-a2f8-47ad-acbd-da7137baf990/user-data/outputs/index.html",
expect.objectContaining({ signal: request.signal }),
);
expect(response.status).toBe(200);
expect(await response.text()).toBe("artifact body");
});
it("preserves bounded range requests and partial-response metadata", async () => {
const fetchMock = rs.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("partial", {
status: 206,
headers: {
"Accept-Ranges": "bytes",
"Content-Length": "7",
"Content-Range": "bytes 0-6/2000000",
"Content-Type": "text/plain",
},
}),
);
const request = new NextRequest(
"http://deer-flow.test/mock/api/threads/thread/artifacts/file",
{ headers: { Range: "bytes=0-1048575" } },
);
const response = await GET(request, {
params: Promise.resolve({
thread_id: "7cfa5f8f-a2f8-47ad-acbd-da7137baf990",
artifact_path: ["mnt", "user-data", "outputs", "index.html"],
}),
});
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining("/demo/threads/"),
expect.objectContaining({
headers: expect.objectContaining({ Range: "bytes=0-1048575" }),
}),
);
expect(response.status).toBe(206);
expect(response.headers.get("Accept-Ranges")).toBe("bytes");
expect(response.headers.get("Content-Range")).toBe("bytes 0-6/2000000");
});
it("rejects traversal before fetching", async () => {
const fetchMock = rs.spyOn(globalThis, "fetch");
const request = new NextRequest(
"http://deer-flow.test/mock/api/threads/thread/artifacts/file",
);
const response = await GET(request, {
params: Promise.resolve({
thread_id: "7cfa5f8f-a2f8-47ad-acbd-da7137baf990",
artifact_path: ["mnt", "user-data", "outputs", "..", "thread.json"],
}),
});
expect(response.status).toBe(404);
expect(fetchMock).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,18 @@
import { describe, expect, it, rs } from "@rstest/core";
import { render, screen } from "@testing-library/react";
rs.mock("shiki", () => ({
codeToHtml: rs.fn(async () => {
throw new Error("highlighter unavailable");
}),
}));
describe("CodeBlock", () => {
it("shows raw code while the async highlighter is unavailable", async () => {
const { CodeBlock } = await import("@/components/ai-elements/code-block");
render(<CodeBlock code="const immediate = true" language="typescript" />);
expect(screen.getByText("const immediate = true")).toBeTruthy();
});
});

View File

@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, rs } from "@rstest/core";
const codeToHtml = rs.fn(async () => '<pre class="shiki">code</pre>');
rs.mock("shiki", () => ({ codeToHtml }));
describe("highlightCode", () => {
beforeEach(async () => {
codeToHtml.mockClear();
const { clearHighlightCacheForTests } =
await import("@/components/ai-elements/shiki-highlight");
clearHighlightCacheForTests();
});
it("creates one dual-theme highlighted tree", async () => {
const { highlightCode } =
await import("@/components/ai-elements/shiki-highlight");
expect(await highlightCode("const n = 1", "typescript", true)).toContain(
'class="shiki"',
);
expect(codeToHtml).toHaveBeenCalledTimes(1);
expect(codeToHtml).toHaveBeenCalledWith(
"const n = 1",
expect.objectContaining({
themes: { light: "one-light", dark: "one-dark-pro" },
}),
);
});
it("reuses results and evicts the least recently used entry at the bound", async () => {
const { HIGHLIGHT_CACHE_LIMIT, highlightCode } =
await import("@/components/ai-elements/shiki-highlight");
await highlightCode("first", "typescript");
await highlightCode("first", "typescript");
expect(codeToHtml).toHaveBeenCalledTimes(1);
for (let index = 1; index <= HIGHLIGHT_CACHE_LIMIT; index += 1) {
await highlightCode(`code-${index}`, "typescript");
}
expect(codeToHtml).toHaveBeenCalledTimes(HIGHLIGHT_CACHE_LIMIT + 1);
await highlightCode("first", "typescript");
expect(codeToHtml).toHaveBeenCalledTimes(HIGHLIGHT_CACHE_LIMIT + 2);
});
it("does not retain oversized source strings in the highlight cache", async () => {
const { HIGHLIGHT_CACHE_MAX_CODE_LENGTH, highlightCode } =
await import("@/components/ai-elements/shiki-highlight");
const oversizedCode = "x".repeat(HIGHLIGHT_CACHE_MAX_CODE_LENGTH + 1);
await highlightCode(oversizedCode, "typescript");
await highlightCode(oversizedCode, "typescript");
expect(codeToHtml).toHaveBeenCalledTimes(2);
});
});

View File

@ -4,6 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { RememberSessionOption } from "@/components/auth/remember-session-option";
import { I18nContext } from "@/core/i18n/context";
import { zhCN } from "@/core/i18n/locales/zh-CN";
describe("RememberSessionOption", () => {
test("uses the active locale for setup and login copy", () => {
@ -14,6 +15,7 @@ describe("RememberSessionOption", () => {
value: {
locale: "zh-CN",
setLocale: () => undefined,
t: zhCN,
},
},
createElement(RememberSessionOption, {

View File

@ -0,0 +1,22 @@
import { describe, expect, it } from "@rstest/core";
import { render, screen } from "@testing-library/react";
import { CaseStudySection } from "@/components/landing/sections/case-study-section";
describe("CaseStudySection", () => {
it("uses semantic, lazy, intrinsically-sized images", () => {
render(<CaseStudySection />);
const images = screen.getAllByRole("img");
expect(images).toHaveLength(6);
for (const image of images) {
expect(image.getAttribute("loading")).toBe("lazy");
expect(image.getAttribute("decoding")).toBe("async");
expect(Number(image.getAttribute("width"))).toBeGreaterThan(0);
expect(Number(image.getAttribute("height"))).toBeGreaterThan(0);
expect(image.getAttribute("alt")?.length).toBeGreaterThan(0);
}
expect(document.querySelector('[style*="background-image"]')).toBeNull();
});
});

View File

@ -0,0 +1,56 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@rstest/core";
const frontendRoot = join(import.meta.dirname, "../../../..");
describe("decorative animation scheduling", () => {
it("suspends the Galaxy render loop when its container is inactive", () => {
const source = readFileSync(
join(frontendRoot, "src/components/landing/hero.tsx"),
"utf8",
);
expect(source).toContain("useRenderActivity");
expect(source).toContain("renderGalaxy && (");
expect(source).toContain(
'dynamic(() => import("@/components/ui/galaxy"), { ssr: false })',
);
});
it("scopes and coalesces Magic Bento spotlight pointer work", () => {
const source = readFileSync(
join(
frontendRoot,
"src/components/landing/sections/whats-new-section.tsx",
),
"utf8",
);
expect(source).toContain("useRenderActivity");
expect(source).toContain("enableSpotlight={false}");
expect(source).toContain('import("@/components/ui/magic-bento")');
expect(source).toContain("ssr: false");
expect(source).toContain(
"useRenderActivity(bentoContainerRef, false, false)",
);
expect(source).toContain("disableAnimations={reducedMotion}");
expect(source).toContain('container.addEventListener("pointermove"');
expect(source).toContain("pendingPointerFrame");
});
it("does not load the skills animation before its section is visible", () => {
const source = readFileSync(
join(frontendRoot, "src/components/landing/sections/skills-section.tsx"),
"utf8",
);
expect(source).toContain('import("../progressive-skills-animation")');
expect(source).toContain("ssr: false");
expect(source).toContain("useRenderActivity(animationRef, false)");
expect(source).toContain(
"renderAnimation && <ProgressiveSkillsAnimation />",
);
});
});

View File

@ -0,0 +1,23 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@rstest/core";
describe("large artifact preview", () => {
it("requires an explicit full load before mounting the code editor", () => {
const source = readFileSync(
join(
import.meta.dirname,
"../../../../../src/components/workspace/artifacts/artifact-file-detail.tsx",
),
"utf8",
);
expect(source).toContain("loadFullContent");
expect(source).toContain("!truncated &&");
expect(source).toContain("artifactPreview.loadFullFile");
expect(source).toContain("ArtifactPreviewError");
expect(source).toContain("artifactPreview.previewFailed");
expect(source).toContain("download: true");
});
});

View File

@ -0,0 +1,25 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@rstest/core";
const browserViewRoot = join(
import.meta.dirname,
"../../../../../src/components/workspace/browser-view",
);
describe("browser stream transport", () => {
it("requests binary frames and does not decode binary messages as JSON", () => {
const apiSource = readFileSync(join(browserViewRoot, "api.ts"), "utf8");
const hookSource = readFileSync(
join(browserViewRoot, "use-browser-stream.ts"),
"utf8",
);
expect(apiSource).toContain('query.set("frame_format", "binary")');
expect(hookSource).toContain('socket.binaryType = "blob"');
expect(hookSource).toContain("useSyncExternalStore");
expect(hookSource).toContain("frameBuffer.push");
expect(hookSource).not.toContain("await message.data.text()");
});
});

View File

@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { LatestBrowserFrameBuffer } from "@/components/workspace/browser-view/frame-buffer";
describe("LatestBrowserFrameBuffer", () => {
afterEach(() => {
rs.restoreAllMocks();
rs.unstubAllGlobals();
});
it("publishes only the latest binary frame in one animation frame", () => {
let scheduled: FrameRequestCallback | undefined;
const requestFrame = rs.fn((callback: FrameRequestCallback) => {
scheduled = callback;
return 7;
});
rs.stubGlobal("requestAnimationFrame", requestFrame);
rs.stubGlobal("cancelAnimationFrame", rs.fn());
const createObjectURL = rs
.spyOn(URL, "createObjectURL")
.mockReturnValueOnce("blob:first");
const revokeObjectURL = rs.spyOn(URL, "revokeObjectURL");
const listener = rs.fn();
const frames = new LatestBrowserFrameBuffer();
frames.subscribe(listener);
const dropped = new Blob(["dropped"], { type: "image/jpeg" });
const latest = new Blob(["latest"], { type: "image/jpeg" });
frames.push(dropped);
frames.push(latest);
expect(requestFrame).toHaveBeenCalledOnce();
expect(listener).not.toHaveBeenCalled();
scheduled?.(16);
expect(createObjectURL).toHaveBeenCalledOnce();
expect(createObjectURL).toHaveBeenCalledWith(latest);
expect(frames.getSnapshot()).toBe("blob:first");
expect(listener).toHaveBeenCalledOnce();
expect(revokeObjectURL).not.toHaveBeenCalled();
});
it("revokes replaced and disposed object URLs", () => {
const callbacks: FrameRequestCallback[] = [];
rs.stubGlobal(
"requestAnimationFrame",
rs.fn((callback: FrameRequestCallback) => {
callbacks.push(callback);
return callbacks.length;
}),
);
rs.stubGlobal("cancelAnimationFrame", rs.fn());
rs.spyOn(URL, "createObjectURL")
.mockReturnValueOnce("blob:first")
.mockReturnValueOnce("blob:second");
const revokeObjectURL = rs.spyOn(URL, "revokeObjectURL");
const frames = new LatestBrowserFrameBuffer();
frames.push(new Blob(["one"]));
callbacks.shift()?.(16);
frames.push(new Blob(["two"]));
callbacks.shift()?.(32);
expect(revokeObjectURL).toHaveBeenCalledWith("blob:first");
frames.dispose();
expect(revokeObjectURL).toHaveBeenCalledWith("blob:second");
expect(frames.getSnapshot()).toBeNull();
});
});

View File

@ -5,6 +5,8 @@ import { renderToStaticMarkup } from "react-dom/server";
import { CitationSourcesPanel } from "@/components/workspace/citations/citation-sources-panel";
import type { CitationSource } from "@/core/citations/sources";
import { I18nContext } from "@/core/i18n/context";
import { enUS } from "@/core/i18n/locales/en-US";
import { zhCN } from "@/core/i18n/locales/zh-CN";
const sources: CitationSource[] = [
{
@ -80,6 +82,7 @@ function renderPanel(
value: {
locale: initialLocale,
setLocale: () => undefined,
t: initialLocale === "zh-CN" ? zhCN : enUS,
},
},
createElement(CitationSourcesPanel, { sources: panelSources }),

View File

@ -0,0 +1,24 @@
import { describe, expect, it } from "@rstest/core";
import {
loadCodeEditorExtensions,
normalizeCodeEditorLanguage,
} from "@/components/workspace/code-editor-extensions";
describe("CodeEditor extension loading", () => {
it.each([
["tsx", "javascript"],
["scss", "css"],
["mdx", "markdown"],
["py", "python"],
["yaml", "text"],
] as const)("normalizes %s to %s", (input, expected) => {
expect(normalizeCodeEditorLanguage(input)).toBe(expected);
});
it("loads only the selected language and theme extension", async () => {
const loaded = await loadCodeEditorExtensions("python", "dark");
expect(loaded.extensions).toHaveLength(1);
expect(loaded.theme).toBeTruthy();
});
});

View File

@ -0,0 +1,40 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "@rstest/core";
const FRONTEND_ROOT = path.resolve(__dirname, "../../../..");
const read = (relativePath: string) =>
readFileSync(path.join(FRONTEND_ROOT, relativePath), "utf8");
describe("interaction-only bundle boundaries", () => {
it("does not import the settings dialog until its store is open", () => {
const host = read(
"src/components/workspace/settings/settings-dialog-host.tsx",
);
expect(host).toContain("dynamic(");
expect(host).toContain("if (!open)");
expect(host).not.toContain(
'import { SettingsDialog } from "./settings-dialog"',
);
});
it("loads each settings page from its active section", () => {
const dialog = read(
"src/components/workspace/settings/settings-dialog.tsx",
);
expect(dialog.match(/dynamic\(/g)).toHaveLength(9);
expect(dialog).not.toMatch(
/import \{ \w+SettingsPage \} from "@\/components\/workspace\/settings\//,
);
});
it("keeps right-panel implementations behind dynamic imports", () => {
const chatBox = read("src/components/workspace/chats/chat-box.tsx");
expect(chatBox).toContain('import dynamic from "next/dynamic"');
expect(chatBox).not.toMatch(
/import \{ (?:ArtifactFileDetail|ArtifactFileList|BrowserViewPanel|SidecarPanel)/,
);
expect(chatBox.match(/dynamic\(/g)?.length).toBeGreaterThanOrEqual(4);
});
});

View File

@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { HumanInputCard } from "@/components/workspace/messages/human-input-card";
import { I18nContext } from "@/core/i18n/context";
import { enUS } from "@/core/i18n/locales/en-US";
import type { HumanInputRequest } from "@/core/messages/human-input";
const formRequest: HumanInputRequest = {
@ -21,7 +22,7 @@ const formRequest: HumanInputRequest = {
function renderCard() {
return render(
<I18nContext.Provider
value={{ locale: "en-US", setLocale: () => undefined }}
value={{ locale: "en-US", setLocale: () => undefined, t: enUS }}
>
<HumanInputCard request={formRequest} onSubmit={() => undefined} />
</I18nContext.Provider>,
@ -65,7 +66,7 @@ describe("HumanInputCard form validation (DOM)", () => {
const warnSpy = rs.spyOn(console, "warn").mockImplementation(() => ({}));
render(
<I18nContext.Provider
value={{ locale: "en-US", setLocale: () => undefined }}
value={{ locale: "en-US", setLocale: () => undefined, t: enUS }}
>
<HumanInputCard
request={{

View File

@ -8,6 +8,7 @@ import {
shouldSubmitHumanInputTextOnKeyDown,
} from "@/components/workspace/messages/human-input-card";
import { I18nContext } from "@/core/i18n/context";
import { enUS } from "@/core/i18n/locales/en-US";
import type {
HumanInputRequest,
HumanInputResponse,
@ -216,6 +217,7 @@ function renderCard(props: Partial<Parameters<typeof HumanInputCard>[0]> = {}) {
value: {
locale: "en-US",
setLocale: () => undefined,
t: enUS,
},
},
createElement(HumanInputCard, {

View File

@ -1,9 +1,44 @@
import { afterEach, describe, expect, it } from "@rstest/core";
import { cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
afterEach(cleanup);
afterEach(() => {
cleanup();
rs.useRealTimers();
});
describe("MarkdownContent bounded streaming cadence", () => {
it("commits at most once per 50ms and flushes exact completed content", async () => {
rs.useFakeTimers();
const content = "x".repeat(200);
const { container, rerender } = render(
<MarkdownContent content={content} isLoading={true} />,
);
expect(container.textContent).toBe("");
act(() => {
void rs.advanceTimersByTime(49);
});
expect(container.textContent).toBe("");
act(() => {
void rs.advanceTimersByTime(1);
});
const firstCommitLength = container.textContent?.length ?? 0;
expect(firstCommitLength).toBeGreaterThanOrEqual(64);
expect(firstCommitLength).toBeLessThan(200);
act(() => {
void rs.advanceTimersByTime(49);
});
expect(container.textContent).toHaveLength(firstCommitLength);
act(() =>
rerender(<MarkdownContent content={content} isLoading={false} />),
);
expect(container.textContent).toBe(content);
});
});
describe("MarkdownContent streaming list transitions (DOM)", () => {
it("reveals the same list item with marker animation when content arrives", async () => {

View File

@ -5,6 +5,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { MessageGroup } from "@/components/workspace/messages/message-group";
import { I18nContext } from "@/core/i18n/context";
import { enUS } from "@/core/i18n/locales/en-US";
const artifactsMockState = rs.hoisted(() => ({
autoOpen: false,
@ -535,6 +536,7 @@ function renderGroup(
value: {
locale: "en-US",
setLocale: () => undefined,
t: enUS,
},
},
createElement(MessageGroup, { ...props, messages }),

View File

@ -0,0 +1,22 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "@rstest/core";
describe("message history virtualization", () => {
it("uses measured stable-key rows and keeps the active tail mounted", () => {
const source = readFileSync(
path.resolve(
__dirname,
"../../../../../src/components/workspace/messages/virtual-message-list.tsx",
),
"utf8",
);
expect(source).toContain("useVirtualizer");
expect(source).toContain("measureElement");
expect(source).toContain("getItemKey");
expect(source).toContain("activeIndex");
expect(source).toContain("overscan: 8");
});
});

View File

@ -0,0 +1,14 @@
import { describe, expect, it } from "@rstest/core";
import { calculateScrollMargin } from "@/components/workspace/thread-list-virtualizer";
describe("calculateScrollMargin", () => {
it("keeps a nested list aligned to its scrolling ancestor", () => {
expect(calculateScrollMargin(180, 40, 0)).toBe(140);
expect(calculateScrollMargin(-320, 40, 500)).toBe(140);
});
it("does not produce a negative virtual-list origin", () => {
expect(calculateScrollMargin(20, 40, 0)).toBe(0);
});
});

View File

@ -1,12 +1,16 @@
import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { loadArtifactContent } from "@/core/artifacts/loader";
afterEach(() => {
rs.restoreAllMocks();
});
import {
ARTIFACT_PREVIEW_MAX_BYTES,
loadArtifactContent,
} from "@/core/artifacts/loader";
describe("loadArtifactContent", () => {
afterEach(() => {
rs.restoreAllMocks();
rs.unstubAllGlobals();
});
it("uses the server content revision when available", async () => {
rs.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("content", {
@ -23,7 +27,7 @@ describe("loadArtifactContent", () => {
expect(loaded.sha256).toBe("a".repeat(64));
});
it("computes a revision for active text responses without a SHA-256 ETag", async () => {
it("computes a revision for a complete response without a SHA-256 ETag", async () => {
rs.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("content", {
status: 200,
@ -40,4 +44,105 @@ describe("loadArtifactContent", () => {
"ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73",
);
});
it("requests only the preview byte budget and reports truncation", async () => {
const bytes = new TextEncoder().encode("preview");
const fetchMock = rs.fn(async (_url: string, init?: RequestInit) => {
expect(new Headers(init?.headers).get("Range")).toBe(
`bytes=0-${ARTIFACT_PREVIEW_MAX_BYTES - 1}`,
);
return new Response(bytes, {
status: 206,
headers: {
"Content-Range": `bytes 0-${bytes.length - 1}/2000000`,
},
});
});
rs.stubGlobal("fetch", fetchMock);
const result = await loadArtifactContent({
filepath: "/mnt/user-data/outputs/large.txt",
threadId: "thread-1",
});
expect(result.content).toBe("preview");
expect(result.truncated).toBe(true);
expect(result.totalBytes).toBe(2_000_000);
expect(result.sha256).toBeUndefined();
});
it("loads and revisions the full file only when explicitly requested", async () => {
const fetchMock = rs.fn(async (_url: string, init?: RequestInit) => {
expect(new Headers(init?.headers).has("Range")).toBe(false);
return new Response("complete", {
status: 200,
headers: { "Content-Length": "8" },
});
});
rs.stubGlobal("fetch", fetchMock);
const result = await loadArtifactContent({
filepath: "/mnt/user-data/outputs/large.txt",
threadId: "thread-1",
full: true,
});
expect(result).toMatchObject({
content: "complete",
truncated: false,
totalBytes: 8,
sha256:
"eebbf6457e46a7f63acdf9b97390f790ba443d60cfa44b607da7e5c40aa1cc1d",
});
});
it("does not render a replacement character for a split UTF-8 code point", async () => {
const emojiBytes = new TextEncoder().encode("abc😀");
const partial = emojiBytes.slice(0, -2);
rs.stubGlobal(
"fetch",
rs.fn(
async () =>
new Response(partial, {
status: 206,
headers: {
"Content-Range": `bytes 0-${partial.length - 1}/${emojiBytes.length + 10}`,
},
}),
),
);
const result = await loadArtifactContent({
filepath: "/mnt/user-data/outputs/unicode.txt",
threadId: "thread-1",
});
expect(result.content).toBe("abc");
});
it("treats an unsatisfied range on an empty file as empty content", async () => {
rs.stubGlobal(
"fetch",
rs.fn(
async () =>
new Response(null, {
status: 416,
headers: { "Content-Range": "bytes */0" },
}),
),
);
const result = await loadArtifactContent({
filepath: "/mnt/user-data/outputs/empty.txt",
threadId: "thread-1",
});
expect(result).toMatchObject({
content: "",
truncated: false,
totalBytes: 0,
sha256:
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
});
});
});

View File

@ -0,0 +1,137 @@
import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { observeRenderActivity } from "@/core/dom/render-activity";
class IntersectionObserverMock {
static instances: IntersectionObserverMock[] = [];
callback: IntersectionObserverCallback;
disconnect = rs.fn();
observe = rs.fn();
constructor(callback: IntersectionObserverCallback) {
this.callback = callback;
IntersectionObserverMock.instances.push(this);
}
emit(isIntersecting: boolean) {
this.callback(
[{ isIntersecting } as IntersectionObserverEntry],
this as never,
);
}
}
class MediaQueryListMock {
matches = false;
addEventListener = rs.fn();
removeEventListener = rs.fn();
private listener?: (event: MediaQueryListEvent) => void;
constructor() {
this.addEventListener.mockImplementation(
(_type: string, listener: (event: MediaQueryListEvent) => void) => {
this.listener = listener;
},
);
}
emit(matches: boolean) {
this.matches = matches;
this.listener?.({ matches } as MediaQueryListEvent);
}
}
describe("observeRenderActivity", () => {
afterEach(() => {
IntersectionObserverMock.instances = [];
rs.restoreAllMocks();
rs.unstubAllGlobals();
});
it("pauses for hidden documents and offscreen elements, then cleans up", () => {
rs.stubGlobal("IntersectionObserver", IntersectionObserverMock);
let hidden = false;
rs.spyOn(document, "hidden", "get").mockImplementation(() => hidden);
const removeEventListener = rs.spyOn(document, "removeEventListener");
const states: boolean[] = [];
const cleanup = observeRenderActivity(
document.createElement("div"),
(active) => {
states.push(active);
},
);
const observer = IntersectionObserverMock.instances[0]!;
expect(states).toEqual([true]);
observer.emit(false);
expect(states).toEqual([true, false]);
observer.emit(true);
hidden = true;
document.dispatchEvent(new Event("visibilitychange"));
expect(states).toEqual([true, false, true, false]);
hidden = false;
document.dispatchEvent(new Event("visibilitychange"));
expect(states.at(-1)).toBe(true);
cleanup();
expect(observer.disconnect).toHaveBeenCalledOnce();
expect(removeEventListener).toHaveBeenCalledWith(
"visibilitychange",
expect.any(Function),
);
});
it("pauses animations while reduced motion is requested", () => {
const mediaQuery = new MediaQueryListMock();
rs.stubGlobal(
"matchMedia",
rs.fn(() => mediaQuery),
);
const states: boolean[] = [];
const cleanup = observeRenderActivity(
document.createElement("div"),
(active) => states.push(active),
);
expect(matchMedia).toHaveBeenCalledWith("(prefers-reduced-motion: reduce)");
expect(states).toEqual([true]);
mediaQuery.emit(true);
expect(states).toEqual([true, false]);
mediaQuery.emit(false);
expect(states).toEqual([true, false, true]);
cleanup();
expect(mediaQuery.removeEventListener).toHaveBeenCalledWith(
"change",
expect.any(Function),
);
});
it("can preserve visible content while reduced motion is requested", () => {
const mediaQuery = new MediaQueryListMock();
mediaQuery.matches = true;
rs.stubGlobal(
"matchMedia",
rs.fn(() => mediaQuery),
);
const states: boolean[] = [];
const cleanup = observeRenderActivity(
document.createElement("div"),
(active) => states.push(active),
true,
false,
);
expect(states).toEqual([true]);
expect(matchMedia).not.toHaveBeenCalled();
cleanup();
});
});

View File

@ -0,0 +1,44 @@
import { afterEach, describe, expect, it } from "@rstest/core";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { I18nProvider, useI18nContext } from "@/core/i18n/context";
afterEach(cleanup);
function Consumer() {
const { locale, setLocale, t } = useI18nContext();
return (
<>
<output>{`${locale}:${t.locale.localName}`}</output>
<button type="button" onClick={() => setLocale("zh-CN")}>
switch
</button>
</>
);
}
describe("I18nProvider", () => {
it("keeps locale and its dictionary in sync", async () => {
render(
<I18nProvider initialLocale="en-US">
<Consumer />
</I18nProvider>,
);
expect(screen.getByText("en-US:English")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "switch" }));
expect(await screen.findByText("zh-CN:中文")).toBeTruthy();
expect(document.documentElement.lang).toBe("zh-CN");
});
it("selects the initial dictionary inside the client boundary", () => {
render(
<I18nProvider initialLocale="zh-CN">
<Consumer />
</I18nProvider>,
);
expect(screen.getByText("zh-CN:中文")).toBeTruthy();
});
});

View File

@ -1,13 +1,17 @@
import { describe, expect, it } from "@rstest/core";
import { translations } from "@/core/i18n/translations";
import { loadTranslations } from "@/core/i18n/translations";
describe("AI disclaimer translations", () => {
it("provides the requested overseas and domestic copy", () => {
expect(translations["en-US"].inputBox.disclaimer).toBe(
it("loads only the requested overseas and domestic copy", async () => {
const [english, chinese] = await Promise.all([
loadTranslations("en-US"),
loadTranslations("zh-CN"),
]);
expect(english.inputBox.disclaimer).toBe(
"Deerflow is AI and can make mistakes",
);
expect(translations["zh-CN"].inputBox.disclaimer).toBe(
expect(chinese.inputBox.disclaimer).toBe(
"内容由AI生成重要信息请务必核查",
);
});

View File

@ -0,0 +1,155 @@
import type { Message } from "@langchain/langgraph-sdk";
import { describe, expect, it } from "@rstest/core";
import {
deriveAssistantTurnUsageState,
deriveStableMessageGroups,
} from "@/core/messages/derived-state";
import { getMessageGroups } from "@/core/messages/utils";
function message(type: Message["type"], id: string, content: string): Message {
return { type, id, content } as Message;
}
describe("incremental message derivation", () => {
it("reuses completed groups when only the streaming turn changes", () => {
const messages = Array.from({ length: 1_000 }, (_, turn) => [
message("human", `h-${turn}`, `question ${turn}`),
message("ai", `a-${turn}`, `answer ${turn}`),
]).flat();
const initial = deriveStableMessageGroups(messages, false, [], false);
const nextMessages = [
...messages.slice(0, -1),
message("ai", "a-999", "answer 999 streaming"),
];
const next = deriveStableMessageGroups(nextMessages, true, initial, false);
expect(next).toHaveLength(initial.length);
expect(next[0]).toBe(initial[0]);
expect(next.at(-3)).toBe(initial.at(-3));
expect(next.at(-1)).not.toBe(initial.at(-1));
});
it("does not reuse a historical group when a same-id message changes", () => {
const messages = [
message("human", "h-1", "question"),
message("ai", "a-1", "original answer"),
message("human", "h-2", "next question"),
];
const initial = deriveStableMessageGroups(messages, false, [], false);
const refreshed = deriveStableMessageGroups(
[messages[0]!, message("ai", "a-1", "corrected answer"), messages[2]!],
false,
initial,
false,
);
expect(refreshed[1]).not.toBe(initial[1]);
expect(refreshed[1]?.messages[0]?.content).toBe("corrected answer");
});
it("matches the reference grouping when older history is prepended during streaming", () => {
const currentTurn = [
message("human", "h-2", "current question"),
message("ai", "a-2", "streaming answer"),
];
const initial = deriveStableMessageGroups(currentTurn, true, [], false);
const withHistory = [
message("human", "h-1", "older question"),
message("ai", "a-1", "older answer"),
...currentTurn,
];
const derived = deriveStableMessageGroups(withHistory, true, initial, true);
expect(derived).toEqual(
getMessageGroups(withHistory, { isCurrentTurnLoading: true }),
);
expect(derived.map((group) => group.id)).toContain("h-1");
});
it("matches the reference grouping across append, tool, reconnect, and hidden-message updates", () => {
const toolCalling = {
...message("ai", "a-tool", ""),
tool_calls: [{ id: "call-1", name: "bash", args: {} }],
} as Message;
const toolResult = {
...message("tool", "tool-1", "done"),
name: "bash",
tool_call_id: "call-1",
} as Message;
const hidden = {
...message("ai", "summary-1", "hidden summary"),
name: "summary",
} as Message;
const states: Array<{ messages: Message[]; loading: boolean }> = [
{ messages: [message("human", "h-1", "question")], loading: true },
{
messages: [message("human", "h-1", "question"), toolCalling],
loading: true,
},
{
messages: [
message("human", "h-1", "question"),
toolCalling,
toolResult,
],
loading: true,
},
{
messages: [
message("human", "h-1", "question"),
{ ...toolCalling },
{ ...toolResult },
hidden,
message("ai", "a-final", "answer"),
],
loading: false,
},
];
let previousGroups: ReturnType<typeof getMessageGroups> = [];
let previousIsLoading = false;
for (const state of states) {
const derived = deriveStableMessageGroups(
state.messages,
state.loading,
previousGroups,
previousIsLoading,
);
expect(derived).toEqual(
getMessageGroups(state.messages, {
isCurrentTurnLoading: state.loading,
}),
);
previousGroups = derived;
previousIsLoading = state.loading;
}
});
it("reuses completed turn usage arrays on a tail-only update", () => {
const messages = [
message("human", "h-1", "one"),
message("ai", "a-1", "answer one"),
message("human", "h-2", "two"),
message("ai", "a-2", "answer two"),
];
const groups = deriveStableMessageGroups(messages, false, [], false);
const initial = deriveAssistantTurnUsageState(groups);
const nextMessages = [
...messages.slice(0, -1),
message("ai", "a-2", "answer two streaming"),
];
const nextGroups = deriveStableMessageGroups(
nextMessages,
true,
groups,
false,
);
const next = deriveAssistantTurnUsageState(nextGroups, initial);
expect(next.byGroupIndex[1]).toBe(initial.byGroupIndex[1]);
expect(next.byGroupIndex.at(-1)).not.toBe(initial.byGroupIndex.at(-1));
});
});

View File

@ -5,6 +5,7 @@ import { act, renderHook } from "@testing-library/react";
import { createElement, type ReactNode } from "react";
import { I18nContext } from "@/core/i18n/context";
import { enUS } from "@/core/i18n/locales/en-US";
import { DEFAULT_LOCAL_SETTINGS } from "@/core/settings/local";
const streamMockState = rs.hoisted(() => ({
@ -52,6 +53,7 @@ test("keeps early streamed steps behind a local user message after finish", asyn
value: {
locale: "en-US",
setLocale: () => undefined,
t: enUS,
},
},
children,

Some files were not shown because too many files have changed in this diff Show More