deer-flow/frontend/AGENTS.md
DanielWalnut 459dd78707
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
2026-08-01 22:19:59 +08:00

31 KiB

AGENTS.md

This file provides guidance to AI coding agents (Claude Code, Codex, and others) when working with the DeerFlow frontend. It is the source of truth; the sibling CLAUDE.md imports it via @AGENTS.md.

Project Overview

DeerFlow Frontend is a Next.js 16 web interface for an AI agent system. It communicates with a LangGraph-based backend to provide thread-based AI conversations with streaming responses, artifacts, and a skills/tools system.

Stack: Next.js 16, React 19, TypeScript 5.8, Tailwind CSS 4, pnpm 10.26.2. Requires Node.js 22+ and pnpm 10.26.2+.

Core dependencies

  • LangGraph SDK (@langchain/langgraph-sdk ^1.5.3) — Agent orchestration and streaming
  • LangChain Core (@langchain/core ^1.1.15) — Fundamental AI building blocks
  • TanStack Query (@tanstack/react-query ^5.90.17) — Server state management
  • UI: Shadcn UI, MagicUI, React Bits, and Vercel AI SDK elements (generated from registries — see Code Style)

Commands

Command Purpose
pnpm dev Dev server with Turbopack (http://localhost:3000)
pnpm build Production build
pnpm check Lint + type check (run before committing)
pnpm lint ESLint only
pnpm lint:fix ESLint with auto-fix
pnpm format Prettier check (pnpm format:write to apply)
pnpm test Run unit tests with Rstest
pnpm test:e2e Run E2E tests with Playwright (Chromium)
pnpm typecheck TypeScript type check (tsc --noEmit)
pnpm start Start production server

Unit tests live under tests/unit/ and mirror the src/ layout (e.g., tests/unit/core/api/stream-mode.test.ts tests src/core/api/stream-mode.ts). Powered by Rstest; import source modules via the @/ path alias.

Rstest runs them as two projects (rstest.config.ts). *.test.ts / *.test.tsx run in a plain node environment — that is nearly the whole suite, and it is the default for anything that is pure logic. *.dom.test.ts / *.dom.test.tsx run in happy-dom, for tests that need a document: hooks driven through renderHook from @testing-library/react, and components. Keep the split — a DOM environment costs roughly 3x the runtime of the node suite, so tests that do not render should not opt into it. A hook whose behavior only exists under real React (effect ordering, cleanup on unmount, re-render on store change) belongs in a .dom.test.* file rather than a node test that mocks react itself.

E2E tests live under tests/e2e/ and use Playwright with Chromium. They mock all backend APIs via page.route() network interception and test real page interactions (navigation, chat input, streaming responses). Config: playwright.config.ts.

Architecture

Frontend (Next.js) ──▶ LangGraph SDK ──▶ LangGraph Backend (lead_agent)
                                              ├── Sub-Agents
                                              └── Tools & Skills

The frontend is a stateful chat application. Users create threads (conversations), send messages, set thread-scoped /goal completion conditions, and receive streamed AI responses. The backend orchestrates agents that can produce artifacts (files/code), todos, and goal state updates.

Source Layout (src/)

  • app/ — Next.js App Router. Routes include / (landing), /workspace/chats/[thread_id] (chat), /workspace/agents/[agent_name] and /workspace/agents/new (custom agents), /blog/…, the (auth)/{login,setup,auth/callback} flow, /[lang]/docs/…, and /api/… route handlers (e.g. /api/memory).
  • components/ — React components:
    • ui/ — Shadcn UI primitives (auto-generated, ESLint-ignored)
    • ai-elements/ — Vercel AI SDK elements (auto-generated, ESLint-ignored)
    • workspace/ — Chat page components (messages, artifacts, settings)
    • landing/ — Landing page sections
    • docs/ — Docs / MDX rendering components
  • core/ — Business logic, the heart of the app. Domains include threads/ (creation, streaming, state), api/ (LangGraph client singleton), agents/ (custom agents), auth/ (authentication), artifacts/, channels/ (IM connections), integrations/ (managed third-party integration status/install clients such as Lark CLI), i18n/ (en-US, zh-CN), settings/, memory/, skills/, messages/, mcp/, models/, input-polish/ (pre-send draft rewrite API), voice-input/ (browser speech-recognition helpers), suggestions/, tasks/, todos/, tools/, workspace-changes/ (run-scoped changed-file summaries and diff fetching), config/, notification/, blog/, plus rendering helpers (rehype/, streamdown/) and utils/.
  • hooks/ — Shared React hooks
  • lib/ — Utilities (cn() from clsx + tailwind-merge)
  • content/ — MDX content (blog posts, docs) rendered by the app
  • styles/ — Global CSS with Tailwind v4 @import syntax and CSS variables for theming
  • typings/ — Ambient TypeScript declarations
  • Root files: env.js (env validation), mdx-components.ts (MDX component map)

Data Flow

  1. Optional composer helpers such as core/input-polish can rewrite the local draft before submission, and core/voice-input can transcribe browser microphone input into that same local draft; confirmed user input then flows to thread hooks (core/threads/hooks.ts) → LangGraph SDK streaming
  2. Stream events update thread state (messages, artifacts, todos, goal). The main thread stream uses the LangGraph SDK's throttle: true mode so updates received in the same macrotask coalesce before React is notified; do not replace it with a numeric delay without validating the SDK's trailing-debounce behavior on a continuous stream. File-tool artifact auto-open work must run in an effect with timer cleanup; never schedule timers while rendering streamed write_file or str_replace updates. 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 Settings > Tools MCP switch calls the targeted PATCH /api/mcp/config mutation, disables switches until that mutation's success refetch completes, displays the backend error detail through a toast, and invalidates ["mcpConfig"] only after success.
  6. Components subscribe to thread state and render updates

The chat header's context-window control is intentionally persistent: while context_usage is unavailable, ContextUsageBadge renders a gauge placeholder rather than unmounting; once data arrives, the same position shows the percentage. useThreadTokenUsage retains placeholder data only when the response thread_id still matches the active route, so same-thread refetches do not flicker and cross-thread navigation never displays the previous chat's usage.

Run duration is run-scoped UI metadata even though the compatibility field additional_kwargs.turn_duration is repeated on historical AI messages. core/messages/run-duration.ts folds those copies into one display anchored after the run's last visible message group. MessageList owns the temporary client-side duration for a just-completed live turn until authoritative history arrives. The duration is total run wall-clock time, not per-message reasoning time; reasoning disclosure and run activity/duration are rendered separately.

The workspace-change card follows the same rule: it is resolved from (threadId, runId) alone, so every AI message of a run would render an identical copy. A run ends in more than one terminal assistant bubble whenever the model emits answer text that never gains a tool call, so core/messages/workspace-change-anchor.ts picks the run's last assistant bubble and MessageListItem renders the badge only for that anchor (#4555). Any future run-scoped display belongs in the same place — do not hang one off every message. The two anchor helpers deliberately differ in which group types they accept as a run's last position, because an anchor is only useful where the display is actually rendered: run duration is emitted by MessageList around every group, so it accepts any type, while the workspace-change card comes from MessageListItem and so restricts to assistant. Keep a new helper's candidate set matched to its own render site rather than unifying them.

Composer drafts are tab-scoped browser state. core/threads/composer-draft.ts stores only text plus the selected slash-skill name in sessionStorage, keyed by user, agent, and logical conversation scope. New-chat pages pass the stable scope "new" because their runtime threadId is a fresh UUID on every reload; established conversations use their real thread ID. InputBox waits for enabled skills before restoring a skill chip, degrades a missing/disabled skill back to editable slash text, and clears the stored draft through SendMessageOptions.onSent only after the send passes the in-flight guard. Attachments, sidecar quotes, voice state, and polish undo state are not persisted.

Auth UI note: the login page's "keep me signed in" option submits only remember_me to the Gateway and may persist only the email address through core/auth/remember-login.ts. Passwords and tokens must never be stored in frontend storage; the HttpOnly access_token and readable csrf_token cookies remain Gateway-owned.

/goal and /compact are built-in composer commands, not skill activations. src/components/workspace/input-box.tsx intercepts /goal, /goal clear, and /goal <condition> before normal chat submission, calling Gateway GET/PUT/DELETE /api/threads/{thread_id}/goal. Setting /goal <condition> also submits the condition text as the next user task so the agent starts running immediately; status and clear do not start a run. Goal and compact requests are tied to the current threadId with an AbortController, so switching threads or unmounting the composer aborts in-flight requests and stale responses cannot update the new thread's composer state. The chat pages render GoalStatus above the composer from AgentThreadState.goal, with local optimistic state until the next stream values update arrives. /compact calls POST /api/threads/{thread_id}/compact to summarize older active context while leaving the full visible chat history intact; it is skipped on new/empty threads and blocked server-side while a run is in flight. Thread rename uses the same serialized state-write route; the rename dialog stays open and surfaces the server error when an active run returns 409.

Human input requests are a structured message protocol layered on normal chat history. The backend writes request payloads to ToolMessage.artifact.human_input, src/core/messages/human-input.ts owns the runtime validators/types, and src/components/workspace/messages/human-input-card.tsx renders the reusable card. The protocol is versioned on the request side only: v1 covers free_text / choice_with_other, and v2 adds form (typed fields — text/textarea/number/select/multi_select/checkbox/date — with required-field validation in the card). Replies deliberately stay on the v1 response protocol: the form card submits a response_kind: "text" reply whose value is the human-readable summary plus one JSON block keyed by stable field names (buildHumanInputFormSubmissionValue — the readable part alone is ambiguous because labels/values may contain the separators), so the model can reconstruct the submitted mapping without a structured response kind. The validators reject unknown versions/modes (and field names colliding with JS Object.prototype members) so future protocol bumps degrade to the plain-text ToolMessage fallback rather than rendering a broken card. Form values are read through own-property access only (readHumanInputFormValue); select fields stay controlled from their empty-string placeholder state through selection; checkbox fields are native <input type="checkbox"> controls seeded to an explicit false (buildInitialHumanInputFormValues) so an untouched checkbox submits as "no" while a required checkbox keeps must-agree semantics (no HTML required attribute — native constraint validation would intercept the custom submit path), and form controls carry label/htmlFor, aria-required plus a visually-hidden localized "required" marker, and aria-invalid/error associations whose error node stays mounted while any field is still invalid. Composer-bypass closure: deriveHumanInputThreadState treats a visible plain human message as answering the latest unanswered request opened before it (only the latest — nothing guarantees a single outstanding request across runs, and closing all would silently swallow older decisions; an older request left open simply becomes the active card again). This lets current users bypass a structured form through the normal composer and preserves compatibility with old v1-only frontends that degrade a v2 request to plain text. MessageList owns answered/latest/pending state for visible cards, but derives answered responses from raw thread.messages because replies are hidden; pending cards clear when the hidden reply appears, when dispatch is dropped, or when a new thread.error reports an async stream failure. Page-level card submit callbacks must send a normal human message and put hide_from_ui: true plus the response payload in the fourth sendMessage(..., options) argument as options.additionalKwargs; the third argument remains run context such as { agent_name }. Composer entry points remain enabled while a human-input request is open; a normal visible message intentionally bypasses the card and starts the next run without structured response metadata.

Tool-calling AI messages can contain user-visible text as well as tool_calls. core/messages/utils.ts keeps these turns in an assistant:processing group, and components/workspace/messages/message-group.tsx must render the visible text as a processing step instead of treating the message as only tool metadata. This preserves provider text such as error explanations or "trying another approach" notes during tool-heavy runs. While the current turn is still loading, a content-only AI message after the latest visible human input also stays in that processing group until the turn settles: a provider may append tool-call chunks to the same message later, and classifying it as a final assistant bubble too early makes the text jump into the steps panel. MessageGroup therefore renders processing text even before the first tool call arrives. The same rule applies after an earlier tool call: a later content-only AI message remains visible after the current last tool-call step while streaming, because that message may itself gain another tool call before the turn settles. Because the same message is rendered by two different components over its lifetime, reasoning must sit above the answer text in both. MessageListItem paints the settled bubble's <Reasoning> disclosure above its content, so MessageGroup puts the trailing reasoning disclosure above the assistant text that follows it and convertToSteps emits a message's reasoning step before its content step — otherwise the two swap places the instant the turn settles (#4576). Assistant text emitted before that reasoning keeps its earlier position; only the answer the reasoning produced moves below it.

Edit-and-rerun is deliberately latest-turn-only. core/messages/utils.ts::getLatestEditableTurn() exposes a human turn only when the transcript is idle and the most recent visible turn ends in a terminal assistant message. core/threads/hooks.ts::editAndRegenerateMessage() calls POST /api/threads/{id}/runs/edit-regenerate/prepare, submits the returned replacement message/checkpoint/metadata through the same LangGraph stream path as regenerate, optimistically hides the superseded message ids, and clears the optimistic replacement once the persisted replacement arrives.

MessageGroup builds its tool-result and browser-preview lookups once per processing group before converting messages to steps. The lookup preserves the first non-empty result and first screenshot-bearing browser view for each tool-call ID, matching the streamed-message display semantics without repeatedly scanning the full group for every tool call.

Key Patterns

  • Server Components by default, "use client" only for interactive components
  • Static root boundarysrc/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/
  • Run stream options are sanitized by core/api/stream-mode.ts: the Gateway-supported set is values, messages-tuple, updates, debug, tasks, checkpoints, and custom; any request containing an unsupported mode throws before HTTP instead of being partially forwarded or silently defaulting to values. streamResumable is retained by thread hooks only for SDK-side reconnect bookkeeping but stripped before the HTTP request because the Gateway does not accept that request option; actual replay uses the SSE Last-Event-ID cursor. Keep this boundary aligned with the backend request schema; messages and events are not supported and must not be forwarded.
  • SSE replay gaps are handled in core/api/api-client.ts, which wraps both initial and joined run streams because the upstream SDK ignores unknown event names. An id-less backend gap control frame clears stale reconnect metadata, emits an internal stream_replay_gap custom event, reloads durable thread values, and rejoins after the server-provided retained tail, with up to five recovery rejoins after the original stream (six total stream calls on an all-gap exhaustion path). The wrapper remains a lazy async iterable because the SDK consumes it with for await. core/threads/hooks.ts clears optimistic/transient/subtask state, invalidates durable history caches, and shows the localized recovery warning; never let a gap fall through as a normal stream finish or cancel the still-running backend run.
  • Streaming Markdown rendering is owned by core/streamdown: Streamdown's animated / isAnimating API handles incremental word animation, while the shared streamdownRenderingPlugins config registers the named code-highlighting and Mermaid plugins required by Streamdown 2.5. Keep wrappers and derived configs wired to that shared object; do not reintroduce a rehype plugin that wraps every word, because reparsing a growing block remounts old words and replays their animation.
  • Citation links in message and artifact Markdown must derive their citation: label from the full ReactNode children tree, since Streamdown may provide element or array children during streaming rather than a plain string.
  • Environment validation uses @t3-oss/env-nextjs with Zod schemas (src/env.js). Skip with SKIP_ENV_VALIDATION=1
  • Subtask step history and runtime metadata (core/tasks/) — the subtask card shows a subagent's full step timeline (#3779): its assistant reasoning turns interleaved with the tools it ran. Subtask.steps[] is accumulated live from task_running events (appended via mergeSteps, not overwritten) and backfilled on expand for historical runs by fetchSubtaskSteps, which pages the events endpoint scoped to one task (GET /runs/{runId}/events?event_types=subagent.step&task_id=…&after_seq=…) until a short page, so the run-wide limit can't truncate the timeline. task_started carries the effective model_name; task_running carries a cumulative usage snapshot after each completed LLM call. core/tasks/lifecycle.ts normalizes these additive events, and computeNextSubtask keeps the largest cumulative total so replayed or late SSE frames cannot double-count or roll the folded card backward. Terminal ToolMessage metadata (subagent_model_name / subagent_token_usage) restores the same values from normal history after reload; no per-card event fetch is needed. core/tasks/steps.ts is the pure step model: messageToStep (live), eventsToSteps (reload), mergeSteps (dedup by message_index), and stepsForDisplay (what the card renders — keeps tool steps + AI steps with text, drops the trailing final-answer AI step when completed since it's shown as result). core/tasks/context.tsx's useUpdateSubtask applies updates against a tasksRef mirroring the latest state (not a closure snapshot), so a late-resolving fetchSubtaskSteps backfill merges into current state instead of clobbering SSE steps or sibling subtasks that arrived meanwhile. The owning run_id is carried onto history content messages in buildVisibleHistoryMessages so the card can resolve the events endpoint.

Interaction Ownership

  • src/app/workspace/chats/[thread_id]/page.tsx owns composer busy-state wiring.
  • src/app/workspace/chats/[thread_id]/page.tsx owns branch-from-turn submission and navigation; sidecar MessageList instances do not receive the branch action.
  • src/app/workspace/chats/[thread_id]/page.tsx and src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx own edit-and-rerun submission wiring because the page must preserve normal/custom-agent run context; MessageList only detects the latest editable user turn and renders the inline editor.
  • src/app/workspace/chats/[thread_id]/page.tsx gates the Workspace Browser trigger and browser right panel on /api/features -> browser_control.enabled; default/failed feature discovery hides the browser control so optional backend installs do not show a dead Live socket.
  • 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.

Code Style

  • Imports: Enforced ordering (builtin → external → internal → parent → sibling), alphabetized, newlines between groups. Use inline type imports: import { type Foo }.
  • Unused variables: Prefix with _.
  • Class names: Use cn() from @/lib/utils for conditional Tailwind classes.
  • Path alias: @/* maps to src/*.
  • Components: ui/ and ai-elements/ are generated from registries (Shadcn, MagicUI, React Bits, Vercel AI SDK) — don't manually edit these.

Environment

Backend API URLs are optional; an nginx proxy is used by default:

NEXT_PUBLIC_BACKEND_BASE_URL=http://localhost:8001
NEXT_PUBLIC_LANGGRAPH_BASE_URL=http://localhost:8001/api

Leave these unset for the standard make dev / Docker flow, where nginx serves the public /api/langgraph/* prefix and rewrites it to Gateway's native /api/* routes.

To reach a dev server on anything other than localhost — a LAN address, or a proxied hostname — list the host in DEER_FLOW_DEV_ALLOWED_ORIGINS (comma-separated; a full URL is reduced to its host). It feeds Next's allowedDevOrigins, which gates /_next/*, fonts, and HMR. Without it those requests get a 403 and the page renders server-side but never hydrates, so nothing on it — including the login form — responds. Development only; production builds ignore it.

Resources

Contributing

When adding features:

  1. Follow the established src/ structure
  2. Add TypeScript types and proper error handling
  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.