* Keep chat typing and web CSS resolution responsive Cache the normalized mention index by entity array and scan only concrete @ positions so the composer does not rescan every file/plugin/skill token on each draft update. Link the shared components package at the root importer so Turbopack/PostCSS can resolve the exported component stylesheet from the web CSS entrypoint. Constraint: Chat composer typing updates run on every keystroke in Electron, and tools-dev/Next evaluates global CSS from the workspace root. Rejected: Debouncing textarea state updates | would make text entry feel stale and would not remove the expensive synchronous parser path. Rejected: Relative-importing package source CSS from apps/web | would bypass the package export contract and hardcode package layout into the app stylesheet. Confidence: high Scope-risk: narrow Directive: Keep inline mention parsing proportional to draft mention markers, and keep package CSS imports resolvable through workspace package links. Tested: pnpm install Tested: pnpm --filter @open-design/web test -- tests/utils/inlineMentions.test.ts Tested: pnpm --filter @open-design/web typecheck Tested: pnpm --filter @open-design/web build Tested: pnpm guard Not-tested: Full web Vitest remains blocked by unrelated CSS selector tests observed before this commit. * Keep composer typing off the render path Move keystroke-time draft updates into the textarea/ref path while deferring React draft rendering, mention overlay work, picker filtering, and localStorage persistence so Electron input echo stays within frame budget. Constraint: Electron chat input was visibly lagging for plain text and mention text while the right iframe stayed responsive. Rejected: Keep the textarea fully controlled | every keystroke still synchronizes the largest ChatComposer render path. Confidence: high Scope-risk: moderate Directive: Keep prompt submission, slash commands, and mention insertion reading draftRef when draft rendering is deferred. Tested: pnpm guard; pnpm --filter @open-design/web typecheck; pnpm --filter @open-design/web build; pnpm exec vitest run -c vitest.config.ts tests/components/ChatComposer.infinite-render.test.tsx; pnpm exec vitest run -c vitest.config.ts tests/components/ChatComposer.context-pickers.test.tsx; pnpm exec vitest run -c vitest.config.ts tests/utils/inlineMentions.test.ts; Electron inspect eval plain text avg 0.03ms max 0.5ms; Electron inspect eval @ path avg 0.03ms max 0.1ms; HTTP smoke status 200 with no components CSS resolve error. Not-tested: Full web Vitest suite and Playwright UI suite. * Keep Electron chat input responsive under heavy projects Constraint: Electron project views can carry large chat/file DOM and Chromium can throttle or defer visible renderer work under tab/window visibility edge cases. Rejected: Re-rendering the composer draft on every plain keystroke | it couples textarea input to mention overlay, resize, storage, and project layout work. Confidence: medium Scope-risk: moderate Directive: Keep plain text input on the textarea/ref path; only commit React draft state immediately for mention, slash, chip, or imperative draft mutations. Tested: pnpm guard; pnpm --filter @open-design/web typecheck; pnpm exec vitest run -c vitest.config.ts tests/components/ChatComposer.infinite-render.test.tsx; pnpm --filter @open-design/desktop test; pnpm --filter @open-design/desktop typecheck; desktop inspect actual project composer with OS SendKeys. Not-tested: Push/PR remains blocked by GitHub 403 for mimicryluden on nexu-io/open-design. * Keep heavy project typing isolated from preview layout Constraint: Electron project panes can carry large DOM histories and preview frames that make textarea paint sensitive to parent layout. Rejected: Further debouncing ChatComposer input state | app-side handler costs were already low while native textarea updates still lagged. Confidence: high Scope-risk: moderate Directive: Keep the composer mounted through the fixed layer/slot pair so future layout changes do not pull typing back into the split-pane flex reflow path. Tested: pnpm --filter @open-design/web typecheck; pnpm exec vitest run -c vitest.config.ts tests/components/ChatComposer.infinite-render.test.tsx tests/components/ChatComposer.context-pickers.test.tsx; pnpm guard; pnpm --filter @open-design/web build; desktop inspect fixed-layer DOM and SendKeys probe avg beforeinput->input 1.4ms max 2.5ms Not-tested: PR publish blocked by GitHub repository permissions if remote still rejects mimicryluden * Keep stale run recovery from hiding generated previews Constraint: Completed daemon runs can outlive the in-memory run registry after a restart. Rejected: Replaying succeeded rows with empty producedFiles | It turns completed messages into failed messages when the registry is gone. Confidence: high Scope-risk: narrow Directive: Only active run statuses should enter daemon reattach recovery. Tested: vitest generation-preview; vitest ProjectView reattach restore; web typecheck; pnpm guard; web build; desktop DOM eval failureText=false iframeCount=2 Not-tested: Fresh provider generation against an external model * Keep composer contracts aligned with latest main Rebase conflict resolution restored the current composer contract while leaving the lower-risk latency fixes in separate layers. Constraint: The branch was rebased over newer workspace/session context composer contracts. Rejected: Keeping the older composer conflict side because it removed current ChatComposer props and broke web typecheck. Confidence: high Scope-risk: narrow Directive: Preserve upstream ChatComposer contracts when rebasing input-latency work. Tested: web typecheck; targeted ChatComposer, inlineMentions, ProjectView generation-preview tests; desktop window-chrome test Not-tested: Full browser interaction after fork push * Fix daemon Nix workspace symlink pruning --------- Co-authored-by: Siri-Ray <2667192167@qq.com>
Open Design — Nix flake
This flake exposes Open Design as a reproducible package, a nix run entry
point, a dev shell, and Home Manager / NixOS modules. The architecture
mirrors the runtime: the daemon (od CLI, Express API on /api/*)
and the web frontend (Next.js static SPA at apps/web/out/) are
separate packages and separate services — you can run either or
both.
Outputs
| Output | What it is |
|---|---|
packages.<system>.daemon |
The @open-design/daemon package — produces bin/od. Default output. |
packages.<system>.web |
The Next.js static export (apps/web/out/) ready to drop into any static file server. |
apps.<system>.default |
nix run github:nexu-io/open-design — boots the daemon. |
devShells.<system>.default |
Node 24 + Corepack-pinned pnpm 10.33 — reproduces pnpm install locally. |
homeManagerModules.{default,open-design} |
Home Manager module — primary individual-developer interface. |
nixosModules.{default,open-design} |
NixOS module — secondary, for shared/server installs. |
Try it without installing
nix run github:nexu-io/open-design # boots the daemon on :7457
nix develop github:nexu-io/open-design # drop into the dev shell
(1) Home Manager — the recommended path
For an individual workstation, add the flake as an input and import the default module:
{
inputs.open-design.url = "github:nexu-io/open-design";
outputs = { self, home-manager, open-design, ... }: {
homeConfigurations.you = home-manager.lib.homeManagerConfiguration {
modules = [
open-design.homeManagerModules.default
{
services.open-design = {
enable = true;
autoStart = true; # systemd --user / launchd agent
webFrontend.enable = true; # also run the static SPA on :5174
};
}
];
};
};
}
What this wires up:
- Linux:
systemd --userunitsopen-design.serviceand (optionally)open-design-web.service.systemctl --user status open-design. - macOS:
launchdagentsio.nexu.open-designand (optionally)io.nexu.open-design-web.launchctl print gui/$UID/io.nexu.open-design. - Data lives in
$HOME/.od/by default — overridedataDirto relocate.
(2) NixOS — for shared/server installs
{
imports = [ inputs.open-design.nixosModules.default ];
services.open-design = {
enable = true;
autoStart = true;
openFirewall = true;
webFrontend.enable = true;
user = "open-design";
group = "open-design";
};
}
This creates a system user, drops a tmpfiles rule for /var/lib/open-design,
and runs the daemon under hardened systemd (ProtectSystem=strict,
PrivateTmp, ReadWritePaths scoped to the data directory). Use this
when you want a single shared instance — for individual user
configuration prefer the Home Manager module.
(3) webFrontend — when to use it, when to bring your own server
Open Design's frontend is a static SPA that issues relative /api/*,
/artifacts/*, and /frames/* requests. Three serving options:
| Option | When |
|---|---|
webFrontend.enable = true |
You want one-line setup. The module spawns a tiny Caddy file server on webFrontend.port (default 5174) that serves the SPA and reverse-proxies the three path prefixes to the daemon. |
webFrontend.enable = false (default) |
You're running nginx / Caddy / Apache / Traefik yourself. Point your server's document root at ${pkgs.open-design.web} (or the packages.<system>.web output) and replicate the proxy contract in section (4). |
| Skip the frontend entirely | You only need the daemon's API for headless agent dispatch. |
The two services are independent. autoStart controls the daemon;
webFrontend.enable controls the static server. Mix freely.
Bring-your-own-server gotcha: if your proxy listens on any origin that differs from the daemon's bind (different host or different port — even loopback split-port like
http://127.0.0.1:8080while the daemon stays on:7457), the daemon's same-origin gate will 403 the SPA's writes until you tell it about that origin. Either setservices.open-design.webFrontend.allowedOrigins = [ "<your-proxy-origin>" ](which feedsOD_ALLOWED_ORIGINS) or, for the loopback-only split-port case, setextraEnv.OD_WEB_PORT = "<proxy-port>". See section (4) for the full decision tree.
Exposing the bundled frontend on a non-loopback host
By default webFrontend.host = "127.0.0.1" so enabling the bundled
caddy does not publish anything beyond loopback. To intentionally
share with a LAN, two settings must be widened together — the
modules assert at eval time that the second is set whenever the
first is widened:
services.open-design.webFrontend = {
enable = true;
host = "0.0.0.0"; # caddy listener
# Every external origin browsers will load the SPA from. The daemon
# matches each entry against the browser's `Origin` header AND adds
# its host:port to the `Host`-header allowlist (Caddy v2 reverse_proxy
# preserves the original Host upstream by default), so list each
# scheme + hostname combo you actually use.
allowedOrigins = [
"http://laptop.local:5174"
"https://laptop.local:5174"
];
};
# On NixOS you also need:
services.open-design.openFirewall = true;
Under the hood allowedOrigins is forwarded to the daemon as the
OD_ALLOWED_ORIGINS environment variable (comma-separated). If you
run the daemon outside the modules — for example, behind your own
nginx/caddy — set OD_ALLOWED_ORIGINS directly in the daemon's
environment with the same shape:
OD_ALLOWED_ORIGINS=http://host1:port,https://host1:port,http://host2:port
Each entry must be a bare origin (scheme://host[:port]); only
http:// and https:// schemes are accepted, and the daemon refuses
to start if any entry fails to parse. The variable widens only the
general /api/* same-origin gate — connector-credential and
live-artifact preview/refresh routes stay strictly loopback-only by
design.
(4) Same-origin proxying contract
The web package is built with OD_DAEMON_URL = "" so the bundled JS
issues relative requests — /api/*, /artifacts/*, /frames/* —
instead of baking a daemon URL into the export. There is no runtime
config endpoint; the SPA does not read OD_DAEMON_URL from the
serving environment.
The serving contract is therefore: the static export must be served
same-origin with a reverse proxy to the daemon. The bundled caddy
service does exactly this — webFrontend listens on
webFrontend.port and reverse-proxies the three path prefixes above
to 127.0.0.1:<cfg.port>, with flush_interval -1 and no encode on
/api/* so SSE streams flush immediately (gzip would buffer chunked
responses for ~80s and surface as ERR_INCOMPLETE_CHUNKED_ENCODING).
If you serve the static bundle yourself, replicate that shape:
- Document root →
${pkgs.open-design.web}(orpackages.<system>.web). - Reverse-proxy
/api/*,/artifacts/*,/frames/*to the daemon's bind address;/api/*must stream chunks immediately and skip response compression. - SPA fallback for unmatched paths →
index.html.
The static-server's environment does not need any Open Design env
vars — but the daemon's environment usually does, because its
same-origin gate is built from OD_BIND_HOST:port (loopback hosts
included). The browser's Origin and Host are whatever your proxy
exposes, so unless that matches 127.0.0.1:<daemon-port> exactly,
the daemon will 403 every PUT/POST until told otherwise:
| Your custom-server setup | What to set on the daemon |
|---|---|
Proxy at http://127.0.0.1:<daemon-port> (same host, same port — unusual) |
Nothing. |
Proxy at a loopback host but different port (e.g. http://127.0.0.1:8080 while daemon is on :7457) |
Either extraEnv.OD_WEB_PORT = "8080" (whitelists 8080 on every loopback host) or services.open-design.webFrontend.allowedOrigins. |
Proxy on any non-loopback host (LAN IP, mDNS name, Tailscale name, public domain — https://od.example.com, http://laptop.local:5174, …) |
services.open-design.webFrontend.allowedOrigins = [ "<full origin>" ]. List every scheme + host[:port] combo a browser might load the SPA from. |
webFrontend.allowedOrigins is forwarded to the daemon as
OD_ALLOWED_ORIGINS; if you run the daemon outside the modules,
export OD_ALLOWED_ORIGINS directly with the same shape (see
section (3)). The variable widens only the general /api/* gate —
connector-credential and live-artifact preview/refresh routes stay
strictly loopback-only by design.
(5) Secrets — DO NOT put them in your Nix config
The environmentFile option takes a path to a KEY=VALUE file that the
service unit reads. Use it for BYOK API keys (Anthropic, OpenAI, Gemini),
provider tokens, and anything else you do not want world-readable in
/nix/store.
Recommended secret managers:
- sops-nix — age- or PGP-encrypted YAML, decrypted into runtime files at activation.
- agenix — age-encrypted single
files, dropped into
/run/agenix/at boot.
Either renders to a file like /run/secrets/open-design.env; pass that
path:
services.open-design.environmentFile = "/run/secrets/open-design.env";
Never inline a secret with pkgs.writeText or home.file.
First-build hash pinning
nix/pnpm-deps.nix is the generated single source of truth for the
vendored pnpm store hash used by both nix/package-daemon.nix and
nix/package-web.nix. Treat it like a lock artifact, not a hand-edited
source file. If pnpm-lock.yaml changes and you are intentionally
maintaining the Nix packaging, run:
pnpm nix:update-hash
The script temporarily swaps one consumer to lib.fakeHash, runs the
matching nix build .#<consumer> --print-build-logs, extracts the
expected hash from the failure output, writes it back into
nix/pnpm-deps.nix, and restores the consumer file. The script runs via
node --experimental-strip-types, so CI can invoke it without first
installing the workspace.
CI
.github/workflows/nix-check.yml runs nix flake check on pushes to
main and can also be started manually with workflow_dispatch.
Pull requests that touch Nix inputs, daemon/web Nix build closures, or the
generated hash maintenance workflows are validated earlier in
.github/workflows/ci.yml via the required Validate workspace gate.
That PR path runs nix flake check for flake.*, nix/**, root lock and
workspace manifests, and files that are actually in the daemon/web Nix
closures. The flake also filters each derivation down to only the workspace
packages it actually installs, so unrelated package/tool changes stay off the
slower Nix path and do not churn the other derivation's pnpm store hash.
When a PR run fails because nix/pnpm-deps.nix is stale, the CI job also
tries to regenerate a hash-only patch:
- same-repo PRs get a bot-authored commit pushed back to the PR branch when
the generated patch only touches
nix/pnpm-deps.nix; - fork PRs get a PR comment plus a workflow artifact containing the patch;
- the failing run still stays red until the generated patch lands and a fresh validation run passes.