mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
fix(web): restore consistent app header layout (#1432)
* docs: add NotebookLM GitHub export script (#1062) * docs: add NotebookLM GitHub export script * fix: make NotebookLM export TOC anchors work * fix: escape TOC link text markdown chars * fix: include merged PRs when exporting --prs all * fix: allow --prs merged mode * fix: treat --limit as total export budget * fix: avoid starving buckets under global --limit * fix: support --issues none and handle repos w/ issues disabled * fix: avoid underfilling export when buckets empty * fix: keep disabled-issues fallback quiet * fix: silence disabled issues fallback * fix: satisfy script typecheck * prevent duplicate saves and add template deletion (#1294) * prevent duplicate template entries on repeated save * add delete button to saved template list Templates can now be removed from the template picker via a hover x button, calling the existing DELETE /api/templates/:id endpoint. * add missing onDeleteTemplate prop in test fixtures * add template deletion flow test for NewProjectPanel * reject template names longer than 100 characters * preserve original createdAt on template update * feat: add FAQ page skill (#1162) * fix: set writable OD_DATA_DIR default for nix run Fixes #1157 When running via 'nix run github:nexu-io/open-design', the daemon attempted to create runtime state under the Nix store package path: /nix/store/.../lib/open-design/.od/projects The Nix store is read-only at runtime, causing startup to fail with ENOENT when mkdir() tried to create the projects directory. This commit updates the nix run wrapper to export OD_DATA_DIR with a writable default ($HOME/.od) when the variable is unset. Users can still override it by setting OD_DATA_DIR before running. The Home Manager and NixOS modules already set OD_DATA_DIR, so they are unaffected by this change. * feat: add FAQ page skill Add a new skill for generating Frequently Asked Questions pages with: - Collapsible accordion sections for Q&A pairs - Real-time search functionality - Category filtering (Billing, Account, Technical, General) - Smooth animations and transitions - Keyboard navigation support - Mobile-friendly responsive design - Semantic HTML with proper ARIA attributes The skill includes: - SKILL.md with triggers, workflow, and output contract - example.html demonstrating a complete FAQ page with 12 questions Use cases: help centers, support pages, product documentation * fix: address PR review feedback for FAQ page skill - Fix craft slugs: use accessibility-baseline and state-coverage instead of non-existent slugs - Remove overly broad 'questions and answers' trigger - Add edge case handling for insufficient/excessive FAQs - Remove search highlighting requirement (XSS risk) - Update self-check to reflect filtering instead of highlighting Addresses review comments from @lefarcen and @chatgpt-codex-connector * feat: add localized copy for faq-page skill Add German, French, and Russian translations for the FAQ page skill example prompt to fix validation test failure. - DE: FAQ-Seite mit Akkordeon-Abschnitten, Suchfunktion und Kategoriefilterung - FR: Page FAQ avec sections accordéon, recherche et filtrage par catégorie - RU: Страница FAQ со складными секциями-аккордеонами, поиском и фильтрацией * fix: escape apostrophe in French translation Use double quotes to avoid syntax error with d'auth * fix(platform): add legacy ~/.fnm path to wellKnownUserToolchainBins (#1110) * fix(platform): add legacy ~/.fnm path to wellKnownUserToolchainBins fnm legacy installations use ~/.fnm/node-versions. Closes #1102 * fix: remove stray .fnm token from type declaration * docs: add Windows troubleshooting guide (#478) (#1170) * docs: add Windows troubleshooting guide (#478) Add docs/windows-troubleshooting.md with step-by-step fixes for the most common native-Windows setup errors: - Node 24 / nvm-windows gotchas (fake nvm file in System32) - pnpm not found after installation - Build scripts blocked by pnpm 10 (better-sqlite3, sharp) - Visual Studio / gyp build errors - Starting the dev server - Optional OpenCode CLI setup Also update CONTRIBUTING.md and QUICKSTART.md to link to the new guide instead of the vague "file an issue if it doesn't" note. * docs: fix Windows guide command accuracy (#1170) Address all 6 inline review comments from lefarcen: - Pin npm-global pnpm install to @10.33.2 (matches packageManager field) - Use where.exe instead of bare where (PowerShell alias conflict) - Fix OpenCode package: opencode-ai (not opencode), binary is opencode - Add EPERM fallback note for corepack enable on protected installs - Add Python check for gyp ERR! find Python - Expand diagnostic checklist with corepack, python, execution policy Also remove redundant corepack pnpm --version from checklist. * feat(daemon): inject compiled design-system tokens + fixture into prompts (#1385) * feat(daemon): inject compiled design-system tokens + fixture into prompts Follow-up to #1231. The prior PR landed the structured form of two brands (`default` + `kami`) and codified the schema; this PR teaches the daemon to actually consume those files when assembling the system prompt, so agents stop having to re-derive token names from DESIGN.md prose every turn. Gated behind `OD_DESIGN_TOKEN_CHANNEL=1` for the smoke-test phase — flag-off keeps the daemon byte-equivalent to today's behavior, flag-on appends two new prompt blocks (the brand's `tokens.css` :root contract and its `components.html` reference fixture) right after the existing DESIGN.md block. Brands without those sibling files (every brand except `default` and `kami` today) skip silently in either mode. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(daemon): only swallow ENOENT/ENOTDIR in readFileOptional, rethrow rest Reviewer feedback (nettee, #1385). The prior catch-all hid permission errors, EISDIR, and broken packaged-resource paths behind the same "undefined = absent" branch the legacy ~138-brand fallback uses, which would let `OD_DESIGN_TOKEN_CHANNEL=1` silently degrade to the DESIGN.md-only prompt while reporting success. That corrupts the exact signal the smoke-test rollout depends on. Now `readFileOptional` only returns undefined for ENOENT / ENOTDIR (real "file does not exist" cases) and rethrows everything else. Added a focused test that plants a directory at the tokens.css path to exercise the EISDIR branch, plus a partial-presence regression test to confirm the stricter contract preserves the legacy fallback. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: chaoxiaoche <chaoxiaoche@192.168.10.16> Co-authored-by: Cursor <cursoragent@cursor.com> * feat(daemon): make connection-test timeouts configurable (#1222) * feat(daemon): make connection-test timeouts configurable Provider and agent connection tests had hardcoded 12s / 45s budgets, which are too tight for slow networks or distant providers (the user sees "timeout" in Settings with no way to extend the budget). - Add OD_CONNECTION_TEST_PROVIDER_TIMEOUT_MS (default 12_000) - Add OD_CONNECTION_TEST_AGENT_TIMEOUT_MS (default 45_000) - Invalid values (non-numeric, zero, negative, fractional) emit a console.warn and fall back to the default, so a typo in the env never silently disables the safety timeout. - Export resolveConnectionTestTimeoutMs for unit testing; cover the three resolution paths (fallback / honored override / invalid). 41 connection-test tests pass (+3 new), full daemon suite 1170/1170. * fix(daemon): reject connection-test timeout overrides above Node's setTimeout maximum Node's `setTimeout` silently clamps any delay above `2^31-1` ms (2_147_483_647) to ~1 ms with a TimeoutOverflowWarning. The previous `Number.isInteger(n) && n >= 1` check accepted oversized values unchanged and passed them straight to `setTimeout`, so an override that *intended* to raise the budget — e.g. `OD_CONNECTION_TEST_AGENT_TIMEOUT_MS=3000000000` — instead caused every connection test to fail almost immediately. The safety timeout was effectively disarmed. Add `MAX_CONNECTION_TEST_TIMEOUT_MS = 2_147_483_647` and switch the guard to `Number.isSafeInteger(n) && n >= 1 && n <= MAX...`. The boundary value is still accepted; one millisecond past it falls back with a warn. Regression test exercises `3_000_000_000`, `2_147_483_647`, and `2_147_483_648`. Addresses #1222 review feedback from @chatgpt-codex-connector, @mrcfps, and @lefarcen. * fix(security): strip trailing dot in normalizeBracketedIpv6 (FQDN SSRF bypass) (#1122) * fix(security): strip trailing dot in normalizeBracketedIpv6 (FQDN bypass) new URL('http://192.168.1.5./').hostname returns '192.168.1.5.' — the trailing dot is the RFC 1034 absolute-FQDN form and resolves identically to '192.168.1.5'. parseIpv4 fails on the dotted form, so 169.254.169.254. slips past the metadata-service block, 192.168.1.5. slips past the LAN block, and localhost. slips past the loopback identification. Strip trailing dots in normalizeBracketedIpv6 so all downstream checks (isLoopbackApiHost, isBlockedExternalApiHostname, isBlockedIpv4, IPv6 range tests) see the canonical form. Adds 6 vitest cases covering loopback FQDN forms (localhost., foo.localhost., 127.0.0.1.) and SSRF FQDN bypasses (169.254.169.254., 192.168.1.5., 10.0.0.5.). Refs nexu-io/open-design#1119 review feedback (P2 from @lefarcen). * test(connectionTest): tighten trailing-dot coverage per #1122 review Two issues from #1122 review: 1. (P2 from @mrcfps + codex bot) The original `foo.localhost.` case asserted error===undefined on validateBaseUrl, which only proves the URL passed validation — not that the host is identified as loopback. Replaced with direct isLoopbackApiHost(...) assertions on the actual loopback FQDN forms (localhost., 127.0.0.1., 127.0.0.5.) so the test exercises the loopback path the comment claims. 2. (P3 from @lefarcen) Original blocked-FQDN tests covered only 3 of 7 ranges that isBlockedIpv4 handles. Added a dedicated case per range (0.0.0.0/8, 10/8, 100.64/10, 169.254/16, 172.16/12, 192.168/16, multicast >=224) so future regressions in normalizeBracketedIpv6 surface against the full coverage. * docs: drop misleading foo.localhost./endsWith claim in normalizer comment @lefarcen review feedback: isLoopbackApiHost only accepts exact 'localhost', '::1', loopback IPv4, and mapped loopback IPv4 — there's no subdomain or endsWith handling, so referencing 'foo.localhost.' overstates what the trailing-dot strip enables. Rewrite the comment to match actual call sites (isLoopbackApiHost equality + isBlockedIpv4 numeric parse). * feat(daemon): export self-contained HTML via /export/*?inline=1 endpoint (#1312) * test(daemon): add Red unit tests for inlineRelativeAssets helper 14 cases pinning the behavior contract for the upcoming apps/daemon/src/inline-assets.ts helper: - link/script inlining with verbatim body preservation - non-src script attrs preserved (type=module, defer, crossorigin) - relative path resolution (root + nested + deep-nested owners) - self-closing and single-quoted attr forms - negative cases: missing rel, rel=preload, absolute/data/blob/leading-slash - escaping: </style and </script inside body - null-fileReader graceful degradation - duplicate identical tags fully replaced (diverges from apps/web/src/components/FileViewer.tsx:5313's first-match-only; locked decision per plan §3.3) - HTML-escaped data-od-inline-asset attr Tests intentionally Red — module ../src/inline-assets.js does not yet exist. Phase B-G of plan declarative-roaming-gosling.md will turn them green by porting FileViewer.tsx:5248-5354 server-side. Refs nexu-io/open-design#368. * feat(daemon): port inlineRelativeAssets server-side for export endpoint Adds apps/daemon/src/inline-assets.ts — a pure helper that takes (html, ownerFileName, fileReader closure) and returns the HTML with every relative <link rel=stylesheet> and <script src> contents inlined into <style data-od-inline-asset="…">/<script>…</script> blocks. The fileReader closure keeps the helper free of fs/Express coupling so the route handler owns the filesystem boundary. Port source: apps/web/src/components/FileViewer.tsx:5248-5354 — five functions (inlineRelativeAssets, resolveProjectRelativePath, baseDirFor, readHtmlAttr, escapeHtmlAttr). The fetch hop becomes the fileReader closure; replace-all replaces first-match-only per locked design decision §3.3 (inline comment in inline-assets.ts cites the divergence from FileViewer.tsx:5313 and notes the web inline path is on a deprecation track since PR #384 made URL-load the default). Phase B-G of plan declarative-roaming-gosling.md. All 14 unit cases from the Red commit (a60a9023) now pass; tightens one case to use a realistic '&'-only filename (the original `<`/`>`-bearing filename was unreachable in real filesystems and exposed a regex limitation the web client carries too). Daemon delta: +14 tests (1704 → 1718). Typecheck clean. Refs nexu-io/open-design#368. * test(daemon): add Red integration tests for /export/*?inline=1 route 9 HTTP cases against GET /api/projects/:id/export/*?inline=1: - 3-file React-ish layout returns self-contained HTML (wiring guard: body assertions catch removal of the await inlineRelativeAssets(...) line, not just helper-internals changes) - missing inline / non-canonical values (0, false, foo, empty) → 400 - non-HTML file → 400 UNSUPPORTED_FILE_TYPE - missing file → 404 FILE_NOT_FOUND - invalid project id (..) → some 4xx (Express normalizes before route) - null-origin OPTIONS preflight → 204 + Access-Control-Allow-Origin: * - missing sibling asset → 200 with <link> tag intact, other asset inlined - nested HTML entry (pages/index.html + ../shared/util.js) → 200 inlined 8 of 9 tests Red (404 / 403); the invalid-project-id case is tolerant about how Express rejects .. so it accidentally passes Red — Green will tighten to 400 BAD_REQUEST via isSafeId. Phase C-R of plan declarative-roaming-gosling.md. C-G will register the route in apps/daemon/src/import-export-routes.ts. Refs nexu-io/open-design#368. * feat(daemon): wire GET /api/projects/:id/export/*?inline=1 endpoint Adds the export-inline endpoint into registerProjectExportRoutes (import-export-routes.ts) alongside /export/pdf and /archive. The route: - Validates project id via ctx.validation.isSafeId - Requires ?inline=1 (accept-list: 1 / true / yes / on, matching Part 1's parseForceInline at file-viewer-render-mode.ts:59-66) - Reads the owner HTML via ctx.projectFiles.readProjectFile; maps ENOENT to 404 FILE_NOT_FOUND, everything else to 400 BAD_REQUEST - Gates non-HTML callers with 400 UNSUPPORTED_FILE_TYPE - Builds a fileReader closure that silently returns null on any sibling read failure (failure-local, not fatal — matches the web client's null-filter at FileViewer.tsx:5311) - Hands the buffer + relPath to inlineRelativeAssets and returns the result as text/html DI: RegisterProjectExportRoutesDeps gains 'projectFiles' | 'validation'; server.ts:2879 passes the corresponding deps. Mirrors the dep shape of RegisterFinalizeRoutesDeps used by PR #832's /finalize/anthropic. Null-origin support intentionally omitted (decision §10 in the PR description): the daemon's null-origin allowlist is /raw/* and /codex-pets/.../spritesheet only, and export consumers are same-origin UI or server-side tooling — sandboxed-iframe srcdoc previews fetch /raw/* instead. Integration test #7 pins the 403 contract so a future allowlist change is deliberate. Phase C-G of plan declarative-roaming-gosling.md. All 23 tests green (14 unit + 9 integration); full daemon suite 1727 passing (delta +9 over B-G's 1718). Typecheck clean. Refs nexu-io/open-design#368. * test(daemon): add Red regression for inlined-body tag-literal corruption Reproduces the correctness bug Siri-Ray (looper) and codex-bot flagged on PR #1312: the reduce/split-join approach in inlineRelativeAssets re-scans the progressively mutated HTML, so a tag literal that happens to appear inside an already-inlined asset body gets the inner literal also replaced — corrupting the body and producing duplicate inlining. Concrete reproducer (CSS, where </style escape doesn't touch <link>): HTML: <link rel="stylesheet" href="a.css"> <link rel="stylesheet" href="b.css"> a.css: /* see also <link rel="stylesheet" href="b.css"> */ b.css: body{color:red} Under split/join the second pass splits on `<link rel="stylesheet" href="b.css">` and matches BOTH the real outer tag AND the literal inside a.css's comment. Result: b.css's <style> block is injected inside a.css's comment, and b.css gets inlined twice. Phase F-R of plan declarative-roaming-gosling.md (post-PR-#1312 review round). F-G will rewrite the helper to collect matches by position in the original HTML and concat slices in a single pass, so already-inlined content is never re-scanned. Refs nexu-io/open-design#1312 review threads at apps/daemon/src/inline-assets.ts:122 (Siri-Ray looper + codex bot). * feat(daemon): replace inliner reduce/split-join with position-based concat Fixes the inlined-body tag-literal corruption Siri-Ray (looper) + codex-bot flagged on PR #1312. The previous `replaceAllOccurrences` (`source.split(from).join(to)`) re-scanned the progressively mutated HTML on each pass, so a tag literal that appeared inside an already- inlined CSS/JS body got the inner literal replaced too, producing duplicate inlining and corrupted bodies. New shape: collect every match's {start, end} byte span from the ORIGINAL html via `matchAll`, await the per-match replacements in parallel, sort by start, and concat slices of the original html with the replacement strings in a single pass. Text introduced by an earlier replacement is never scanned for matches. The dup-tag fix (decision §8 — replace every occurrence, not first-match-only) is preserved: every original-tag position gets its own slice, so all duplicates are inlined. Also extracts buildInlineStyleBlock / buildInlineScriptBlock so the match-collection loops stay readable. Phase F-G of plan declarative-roaming-gosling.md. Regression test (c809bccc) goes Green; all 24 unit + integration tests pass; daemon suite still clean. Refs nexu-io/open-design#1312. * test(daemon): add Red CSP-sandbox test + P3 coverage gaps from PR #1312 review Three tests covering lefarcen's review on PR #1312: 1. [Red] CSP sandbox header (P2, lefarcen @ import-export-routes.ts:423). Top-level browser navigation to /export/*?inline=1 sends no Origin header, so the daemon middleware lets it through and any JS in the exported document runs with daemon-origin privileges. Asserts the response sends `Content-Security-Policy: sandbox allow-scripts` so the browser treats it as a sandboxed iframe with an opaque origin (scripts still run, but no cookies / no /api/ access). This test fails until G1-G adds the header in the handler. 2. [Green-on-commit] Accept-list cases (P3, lefarcen @ test.ts:262). PR body decision §7 promises `inline=true/yes/on` case-insensitive, but round-1 tests only exercised inline=1. Pin the full accept list (true / yes / on + TRUE / Yes / ON). Already passes — the route's parser already implements the accept list; this just makes the contract testable. 3. [Green-on-commit] isSafeId guard (P3, lefarcen @ test.ts:287). Previous `..` test was normalized by Express before reaching the route. New input uses `bad!id` (URL-safe, but outside isSafeId's /^[A-Za-z0-9._-]+$/ char class), so Express passes it into req.params unchanged and isSafeId rejects with the documented 400 BAD_REQUEST envelope. Phase G1-R / H of plan declarative-roaming-gosling.md. Refs nexu-io/open-design#1312 review comments. * feat(daemon): send Content-Security-Policy: sandbox allow-scripts on /export Closes the same-origin XSS surface lefarcen flagged on PR #1312 (P2 at import-export-routes.ts:423): top-level browser navigation to the export URL sends no Origin header, so the daemon's /api middleware admits the request and any JS in the exported document executes with daemon-origin privileges (cookies, /api/, localStorage). `Content-Security-Policy: sandbox allow-scripts` on the response makes the browser treat the document as a sandboxed iframe with an opaque origin. Scripts still execute (necessary for the screenshot use case — the whole point of inlining JS), but they cannot read cookies, hit /api/, or otherwise escalate to the daemon's origin. Phase G1-G of plan declarative-roaming-gosling.md. Daemon delta: +3 tests (the Red CSP test from58151356turns Green; the P3 coverage gap tests stay green). Refs nexu-io/open-design#1312. * test(daemon): add Red regression for <link> stylesheet attr preservation Currently `<link rel="stylesheet" href="print.css" media="print">` becomes a plain `<style data-od-inline-asset="print.css">…</style>` with no media query — print-only styles apply unconditionally. Same problem for `title` (alternate stylesheet sets), `disabled` (initial disabled state), and `nonce` (CSP nonce). All four are valid on both `<link rel=stylesheet>` and `<style>` per HTML spec, so the inliner must carry them across. PR #1312 round-2 review (lefarcen P2 @ inline-assets.ts:44). Phase G2-R; G2-G will extend buildInlineStyleBlock to copy the four attrs off the source <link>. Refs nexu-io/open-design#1312. * feat(daemon): preserve <link> stylesheet semantics on inlined <style> Closes lefarcen's P2 review note on PR #1312 (inline-assets.ts:44): `<link rel="stylesheet" href="print.css" media="print">` was becoming a plain <style> with no media query, so print-only styles applied unconditionally. Same issue for `title` (alternate stylesheet sets), `disabled` (initial disabled state), and `nonce` (CSP nonce). buildInlineStyleBlock now carries four attrs across from the source <link>: - media, title, nonce (value attrs, HTML-escaped via escapeHtmlAttr) - disabled (boolean attr — copied as bare presence) Other <link> attrs (rel, href, type, crossorigin, integrity, referrerpolicy) don't apply to <style> and are intentionally dropped. New `hasBooleanHtmlAttr` helper distinguishes presence-as-attr from substring-inside-another-attr-value via a regex that requires a word boundary after the name (whitespace, `=`, or `>`). Phase G2-G of plan declarative-roaming-gosling.md. All 28 tests pass. Refs nexu-io/open-design#1312. * docs(daemon): narrow inliner contract claim + document size-limit policy Closes lefarcen's P2 review notes on PR #1312: 1. "Self-contained" incomplete (inline-assets.ts:67): the helper only rewrites top-level <link rel=stylesheet> / <script src>. `<img src>`, CSS `url(...)`, CSS `@import`, ES module imports, font sources, and similar remain external in the response. The PR title/body claimed "self-contained HTML" which over-promised for screenshot tooling expecting bundled images/fonts. Module docstring now enumerates the full not-rewritten list and names the screenshot path as the primary use case (headless browser fetches each external asset on render, so inline-CSS- and-JS-only is sufficient). The route handler comment block mirrors the contract. A fully offline export with image/font bundling is filed as a follow-up — out of scope for this PR. 2. No response cap (inline-assets.ts:72): the helper does concurrent reads + multiple string copies and could spike daemon memory. The daemon is local-first (single-user, developer's machine — see open_design_architecture.md), so the effective ceiling is the size of the user's own project. The docstring now states this rationale and names the conditions under which a bounded-concurrency reader and output-size limit would be needed (non-trusted callers). Docs-only — no behavior change, all 28 tests still pass. Refs nexu-io/open-design#1312. * test(daemon): add Red regression for hasBooleanHtmlAttr quoted-value match PR #1312 round-2 review (lefarcen P3): `hasBooleanHtmlAttr` tests the tag string with no attr-quoting awareness, so the literal text `disabled` appearing inside any quoted attribute value followed by another whitespace char satisfies `\sdisabled(?=\s|=|/?>)`. <link rel=stylesheet href=x.css data-note="content disabled stuff"> emits a <style disabled> block, silently disabling a stylesheet the author wrote without that attr. Also adds a counterweight test for the legitimate-disabled case (<link … disabled>) so the next-commit fix doesn't over-correct and start dropping real boolean attrs. Phase I3-R of plan declarative-roaming-gosling.md (post-PR-#1312 round-2 review). I3-G will strip quoted attribute values from the tag string before testing for the bare attr. Refs nexu-io/open-design#1312. * feat(daemon): make hasBooleanHtmlAttr quote-aware to avoid false positives Closes lefarcen's P3 review note on PR #1312: `hasBooleanHtmlAttr` previously ran `\sname(?=\s|=|/?>)` over the full tag string, so the literal text `disabled` appearing inside any quoted attribute value followed by whitespace satisfied the regex. Source tags like `<link rel=stylesheet href=x.css data-note="content disabled stuff">` were emitting a <style disabled> block — silently disabling a stylesheet the author wrote without that attr. Fix: strip `="…"` and `='…'` substrings out of the tag with two regex passes BEFORE testing for the bare attr. The lookahead still requires `\s|=|/?>` after the attr name, so `<link disabled>`, `<link disabled="">`, `<link disabled/>`, etc. all match — but the attr name as a substring of any quoted value cannot match because values have been stripped to `""` / `''`. Phase I3-G of plan declarative-roaming-gosling.md. All 30 tests green (28 prior + 2 round-3 regression cases: false-positive and legitimate-disabled). Refs nexu-io/open-design#1312. * test(daemon): add Red cap-enforcement tests + scaffold InlineOptions PR #1312 round-2 review (lefarcen P2 — still open): round-2 only documented that no cap is enforced. Reviewer pushed back: the helper still builds unbounded candidate arrays + runs Promise.all over all asset reads + concatenates the full output in memory. Need actual limits in code. This commit adds the Red test surface that drives the next commit's enforcement: - InlineAssetsLimitError("owner") when owner HTML > maxOwnerBytes - InlineAssetsLimitError("candidates") when tag matches > maxCandidates - Per-asset graceful: oversized asset → tag stays as URL ref - InlineAssetsLimitError("total") when assembled output > maxTotalBytes - Bounded read concurrency: peak in-flight reads ≤ maxReadConcurrency - Integration: route maps the throw to 413 PAYLOAD_TOO_LARGE InlineOptions interface is added to the helper signature as a no-op test-door (per feedback_test_doors_over_fake_timers.md), so tests can exercise tiny fixtures while production callers use module-level defaults. The next commit (H3-G) wires the enforcement. Phase H3-R of plan declarative-roaming-gosling.md. Daemon delta on this commit: +6 tests (5 unit + 1 integration), all Red. Refs nexu-io/open-design#1312. * feat(daemon): enforce inliner caps + map limit errors to 413 PAYLOAD_TOO_LARGE Closes lefarcen's still-open P2 review on PR #1312 round 2 ("the code still builds unbounded candidate arrays + Promise.all over all asset reads + concatenates the full output in memory"). Caps are now enforced in code with the documented defaults: MAX_INLINE_OWNER_BYTES = 2 MiB MAX_INLINE_ASSET_BYTES = 5 MiB per sibling MAX_INLINE_CANDIDATES = 500 link/script matches MAX_INLINE_TOTAL_BYTES = 50 MiB assembled output MAX_INLINE_READ_CONCURRENCY = 8 simultaneous fileReader calls Enforcement points: - Owner cap (input): fires immediately at function entry. Cheap — Buffer.byteLength of the already-decoded UTF-8 string. - Candidate cap (planning): fires after matchAll, BEFORE any sibling read. Pathological HTML with thousands of <link>/<script src> tags is rejected without opening a single file descriptor. - Asset cap (per-sibling): post-read length check; oversized assets return null from the wrapped reader, so the tag stays as a URL ref and the response is still 200. This is the only "graceful" cap — one bad asset doesn't fail the whole export. - Total cap (output): tracked across the slice-and-concat loop, guarding both preserved-html slices AND injected replacements. - Concurrency cap (planning): a tiny in-module runWithConcurrency worker-pool keeps at most maxReadConcurrency fileReader calls in flight, with order-preserving results. `InlineAssetsLimitError` carries a `limit` discriminator so logs and clients can disambiguate owner/asset/candidates/total. The route handler catches it and emits 413 PAYLOAD_TOO_LARGE. Drive-by error-envelope fix while in the route: UNSUPPORTED_FILE_TYPE (an unregistered ApiErrorCode) → UNSUPPORTED_MEDIA_TYPE (the canonical code) with HTTP 415. The round-1 string was a slip; caught by reading packages/contracts/src/errors.ts:11 while wiring PAYLOAD_TOO_LARGE. Phase H3-G of plan declarative-roaming-gosling.md. All 36 tests green (28 prior + 2 round-3 quoted-attr + 5 cap unit + 1 cap integration). Refs nexu-io/open-design#1312. * feat(daemon): enforce inliner caps pre-buffer via AssetHandle contract Closes lefarcen's still-open P2 review on PR #1312 round 3 ("the helper enforces maxTotalBytes only after all candidate assets have already been read and converted to replacement strings" / "maxAssetBytes is checked after fileReader fully buffers each sibling"). Round-3 caps were defensive against the final output size but did not bound peak memory during read fanout — 500 assets at 5 MiB each could materialize ~2.5 GiB before the 413 fired. Contract change: InlineAssetReader now returns `AssetHandle | null` where AssetHandle is `{ readonly size: number; read(): Promise<...> }`. Callers expose `size` from a cheap stat-equivalent (the route uses `resolveProjectFilePath`) and defer the full materialization to `read()`. The helper checks size against maxAssetBytes BEFORE invoking read, and against the running total BEFORE the reservation is committed. Enforcement flow inside runWithConcurrency: 1. await fileReader(p.resolved) → cheap stat-only call 2. if (handle.size > maxAssetBytes) return null ← pre-buffer 3. if (runningBytes + handle.size > maxTotalBytes) ← pre-buffer totalAborted = true; return null 4. runningBytes += handle.size ← reserve 5. await handle.read() ← only now 6. if (read returned null) runningBytes -= refund `totalAborted` is a shared flag the workers check at entry, so once the running total hits the cap, no new reads start. With maxReadConcurrency = 8, at most ~8 stat-side calls finish after abort — peak memory bounded. The concat-time guard stays as the exact final assertion (the pre-buffer reservation is approximate — it counts the original tag bytes and skips wrapper overhead). Route closure updated to do `resolveProjectFilePath` first, then `readProjectFile` inside the deferred `read()`. Test reader helpers (`readerFrom` + the concurrency-test reader) updated to the new shape. Two new unit tests pin the pre-buffer semantics: - `maxAssetBytes` is checked via handle.size BEFORE handle.read() (the reader's `read()` throws — must never run) - Running total abort stops further reads once exceeded (counting reader observes ≤ 2 reads when cap should fire after the first) Phase K of plan declarative-roaming-gosling.md (post-PR-#1312 round-3 review). All 38 tests green (36 prior + 2 round-4 pre-buffer cases). Refs nexu-io/open-design#1312. * test(daemon): add Red test pinning owner pre-buffer 413 before mime 415 PR #1312 round-5 (lefarcen P2): the route currently reads the owner file with readProjectFile() before any size check, so a 100 MiB owner HTML is fully buffered into memory before the helper's ownerBytes check fires. The fix is to stat with resolveProjectFilePath first, reject pre-buffer with 413 PAYLOAD_TOO_LARGE on oversize, then fold in the mime check (still 415 on mismatch, now pre-buffer), then readProjectFile when both gates pass. The Red→Green discriminator is the combination 'oversize AND non-HTML': pre-fix the route reads the buffer first and the text/plain mime check fires → 415; post-fix the route stats first and the size check fires before the mime check → 413. Asserting 'got 413, not 415' pins both the pre-buffer property and the check ordering (size before mime, per lefarcen's locked round-5 sequence). 2 MiB+1 byte fixture is acceptable in test setup; MAX_INLINE_OWNER_BYTES is the production 2 MiB so no test-door is needed. Red verified: AssertionError: expected 415 to be 413 (pre-fix flow reads → mime → 415). * feat(daemon): stat owner before readProjectFile in /export route to bound owner pre-buffer PR #1312 round-5 (lefarcen P2 confirmed at PR-1312#issuecomment-4424868413 follow-up): the route previously called readProjectFile() unconditionally on the owner, so a 100 MiB owner HTML was fully buffered into memory before the helper's ownerBytes check fired with InlineAssetsLimitError ('owner'). That meant the 413 envelope returned to the caller but only after peak memory had already hit the file size. Fix mirrors the sibling-asset stat-then-read contract round 4 added via the AssetHandle interface: call resolveProjectFilePath first (cheap stat), reject pre-buffer with 413 PAYLOAD_TOO_LARGE on size > MAX_INLINE_OWNER_BYTES, fold in the mime check (still 415 UNSUPPORTED_MEDIA_TYPE on mismatch, now also pre-buffer per lefarcen's 'fold-in is welcome'), then readProjectFile() only when both gates pass. Size check fires before mime check, so an oversize non-HTML file returns 413 rather than 415 — the observable Red→Green discriminator for this round. The helper's ownerBytes check (inline-assets.ts:127-133) stays as defense-in-depth for direct in-process callers that skip the route and for any drift between stat-reported size and the bytes returned by readFile. Verifies the round-5 Red at apps/daemon/tests/export-inline-route.ts ('returns 413 (not 415) for an oversize non-HTML file'). Daemon suite 1743/1743 passing. * test(daemon): add Red test pinning stat-vs-actual byte reconciliation PR #1312 round-5 (lefarcen P3 confirmed at PR-1312#issuecomment-4424868413 follow-up): the helper trusts handle.size for the running-total guard and never reconciles with the actual byte length of content unless the per-asset cap is exceeded. A reader that under-reports size (stale stat, UTF-8 expansion at decode, sparse file, deliberate lie) can let many strings materialize in memory before the concat-time guard at the bottom of inlineRelativeAssets throws — defeating the round-4 pre-buffer cap intent. Fix is lefarcen-confirmed path-a: post-read, the helper computes actualBytes = Buffer.byteLength(content, 'utf8'), reconciles runningBytes (add actualBytes, refund handle.size), and if running total exceeds maxTotalBytes flips totalAborted = true and returns null. Subsequent workers see totalAborted before invoking their own read(). Helper still throws InlineAssetsLimitError('total') after Promise.all settles — preserving the round-2/3/4 graceful-fallback pattern instead of racing throws across in-flight workers. Red→Green discriminator is read count. Pre-fix the helper trusts the lying handle.size (10), so both reads complete (each returning 1000 bytes) under the reservation total of 56+10+10=76 < cap 500. The concat-time guard then catches the 2000+-byte assembly and throws 'total' — but only after both reads materialized in memory. Post-fix worker 1's reconciliation trips totalAborted as soon as actualBytes (1000) is folded into runningBytes; worker 2 skips its read. Red verified: AssertionError expected 1, received 2 (pre-fix flow completes both reads before concat-guard fires). * feat(daemon): reconcile inliner reservation with post-read actual bytes PR #1312 round-5 (lefarcen P3 confirmed at PR-1312#issuecomment-4424868413 follow-up, path-a): the helper trusted handle.size for the running- total guard and only reconciled with actual bytes for the per-asset cap. A reader that under-reported size — stale stat, UTF-8 decode expansion at read time, sparse file, deliberate lie — could let many strings materialize before the concat-time guard at the bottom of inlineRelativeAssets caught the excess. That defeated the round-4 pre-buffer cap intent. Fix: after a successful read(), compute actualBytes = Buffer.byteLength(content, 'utf8'), reconcile runningBytes by folding in (actualBytes - handle.size), and re-check the total cap. If the reconciliation pushes runningBytes past maxTotalBytes, drop the asset's inlining (tag stays as URL ref), set totalAborted = true to block subsequent worker reads, and let Promise.all settle. The helper then throws InlineAssetsLimitError('total') below — matching the round-2/3/4 graceful-fallback pattern (no throw-before-settle race between in-flight workers). The per-asset cap check at line 228 is preserved for stat-lying readers that blow a single asset past maxAssetBytes; that branch refunds handle.size and drops without flipping totalAborted, so sibling assets still get a fair shot. Verifies the round-5 Red at apps/daemon/tests/export-inline-route.ts ('reconciles handle.size with actual content bytes'). Daemon suite 1744/1744 passing. --------- Co-authored-by: DevForgeAI CI/CD Engineer <devforge-ai@development.ai> * fix: truncate long template names on project cards (#1220) (#1302) Add min-width: 0 to .design-card-name so text-overflow: ellipsis works correctly in flex layouts. Long template names were pushing the task execution status (Running, Failed, etc.) out of view on project cards. Closes #1220 Co-authored-by: laomo <laomo@openclaw.ai> * fix(desktop): swallow setTypeOfService EINVAL crashes in dev main (#647) (#1298) * fix(desktop): swallow harmless setTypeOfService EINVAL crashes in dev main The packaged Electron entry (apps/packaged/src/logging.ts) already filters the undici "setTypeOfService EINVAL" crash that issue #895 introduced for the prod build, but the dev / source-built desktop entry was missing the parallel guard. Result: switching settings tabs in a from-source desktop run could fire a fresh fetch, undici would try to set IP_TOS on the outbound socket, the kernel would refuse on certain macOS / VPN configurations, and the rejection bubbled to Electron's default handler as the "JavaScript error in the main process" dialog reported in issue #647. Add the same defensive filter to apps/desktop: - isHarmlessSocketOptionError matches only the canonical undici shape (syscall name AND EINVAL code). A contradicting code (EACCES, EPERM, etc) explicitly fails the match so real bugs don't get hidden. - The uncaughtException handler logs harmless cases at warn and returns silently. For anything else it removes itself from the listener list and re-throws via setImmediate, restoring Node's default crash path so Electron's native dialog renders exactly as it would without this filter. - unhandledRejection mirrors the same harmless / fall-through split. The filter is installed BEFORE app.whenReady so it is armed by the time the renderer fires its first fetch. The helper is duplicated rather than imported from apps/packaged because AGENTS.md forbids cross-app private-source imports. The file header calls out the parallel and notes that the two copies should stay in sync until the helper is promoted to a shared workspace package (follow-up); the contract is identical so a regression in one will surface in the other's test suite. Tests in apps/desktop/tests/main/uncaught-exception.test.ts mirror apps/packaged/tests/logging.test.ts: 8 cases pinning the matcher shape, 2 cases pinning the handler's harmless-log-warn vs fall-through-rethrow split. Validated: pnpm guard, pnpm --filter @open-design/desktop typecheck, pnpm --filter @open-design/desktop build, and pnpm --filter @open-design/desktop test (14 passed, 10 new). * fix(desktop,packaged): fail-fast on non-harmless unhandled rejections The previous unhandledRejection listeners logged non-harmless reasons and returned, which kept the main process alive after any rejected promise. A real bug, a failed IPC registration, or any unexpected async exception was reduced to a console line instead of surfacing through Node/Electron's default crash path the filter was meant to preserve. Both copies now route non-harmless rejections through a parallel factory (createDesktopUnhandledRejectionHandler / createFatalUnhandledRejectionHandler) that mirrors the uncaughtException policy: harmless setTypeOfService EINVAL shapes log at warn and return, anything else logs at error, removes the listener, and re-throws via setImmediate. Listener removal happens before the scheduled throw, so the rethrown reason lands in the uncaughtException path with no recursion. Tests cover the harmless branch, the detach + ordered rethrow, and non-Error / primitive rejection reasons (Promise.reject(42)) which must fall through. Desktop suite: 13/13, packaged suite: 16/16. Flagged on PR #1298 by Siri-Ray and the codex P2 review thread; the two file copies stay in lockstep per the AGENTS.md sync invariant. --------- Co-authored-by: Nagendhra <nagendhra405@gmail.com> * feature: refine assistant artifact feedback (#1379) * feature: refine assistant artifact feedback * fix: clear hidden custom feedback reason * test: update assistant feedback expectations * fix: support object-style question-form options (#1293) * fix: support object-style question-form options * fix: preserve stable option values in form submissions * fix(daemon/acp): terminate ACP child after clean prompt completion (#1286) * fix(daemon/acp): terminate ACP child after clean prompt completion (Bug B / #1265) Some ACP agents (notably Devin for Terminal) keep the child process alive after stdin closes, waiting for the next prompt. Open Design spawns a fresh agent per chat turn and relies on child.on('close') to finalize the run, so without an explicit signal-driven shutdown the chat sits stuck in the 'working' state indefinitely. Three small, targeted changes: - apps/daemon/src/acp.ts: After a clean session/prompt response we schedule a 500ms grace period and then SIGTERM the child. This mirrors the pattern detectAcpModels() already uses after model discovery. The grace period leaves well-behaved agents that exit on stdin.end() unaffected. - apps/daemon/src/acp.ts: New completedSuccessfully() method on the session handle reports whether the prompt resolved without a fatal error or abort, so the consumer can distinguish 'clean signal exit' from 'genuine signal failure'. - apps/daemon/src/server.ts: child.on('close') now treats a SIGTERM exit as 'succeeded' when acpSession.completedSuccessfully() is true. - apps/web/src/providers/daemon.ts: Trust the server's authoritative endStatus; the signal/non-zero-code safety net no longer overrides an explicit 'succeeded' status, so the chat doesn't surface a fake 'agent exited with signal SIGTERM' error after a clean ACP run. Daemon tests cover the SIGTERM grace timer, clean early-exit (timer cleared), and completedSuccessfully() abort/error states. Manual UI test on plain main + this fix confirms Devin chats now return to ready automatically after Done · ... * fix(daemon/connectionTest): treat ACP clean SIGTERM as success Codex review on #1286 caught that the new SIGTERM in attachAcpSession breaks ACP connection tests for agents that don't shut down on stdin.end() (the exact Devin behavior the patch targets). attachAgentStreamHandlers() in connectionTest.ts now also respects acpSession.completedSuccessfully(), mirroring the same check we apply in server.ts. Without this, a clean prompt response followed by our SIGTERM would set winner.signal === 'SIGTERM', flip exitedCleanly to false, and the connection test would report 'agent_spawn_failed' even when the agent had returned a healthy response. Also widened the AgentSpawnHandle type so completedSuccessfully is visible on the structural type used inside connectionTest.ts. All 56 daemon tests still pass; typecheck + guard clean. * fix(daemon/acp): narrow ACP success-on-signal override to forced-SIGTERM Looper review on #1286 caught that the success predicate was broader than the SIGTERM case it was meant to handle. `completedSuccessfully()` flips to true as soon as the ACP `session/prompt` response is processed, but it does not say why the child later closed. With the broad predicate, an ACP agent that returned a prompt result and then exited with code 1 (or was killed by SIGKILL/SIGSEGV) was still marked 'succeeded', regressing the existing close-status behavior for genuine post-response process failures. Scope the override to the exact forced-shutdown shape this PR introduces: code === null && signal === 'SIGTERM' && acpCleanCompletion Applied to both `server.ts` (chat run finalization) and `connectionTest.ts` (connection-test classification). Any other post-response failure now falls through to 'failed' / 'agent_spawn_failed' as before. All 59 daemon tests still pass; typecheck + guard clean. * fix(web/daemon): only bypass exit-code safety net on explicit server success Looper review on #1286 caught that the previous web change trusted `endStatus === 'succeeded'` absolutely, but `endStatus` can become 'succeeded' in two distinct ways: 1. The SSE end event explicitly carries `status: 'succeeded'` (authoritative server declaration). 2. The end event omits or has an invalid `status` field and the handler silently falls back to 'succeeded' as a local default. Both produced `endStatus === 'succeeded'` in the existing code, so the new safety-net bypass treated them identically. That regressed backward compat: a compatible or older daemon emitting an end event like `{code:1}` or `{code:null,signal:"SIGTERM"}` with no `status` would suddenly skip the failure banner. Track explicit success separately via `serverDeclaredSuccess`, set true only when: - The SSE end event has `status === 'succeeded'`, or - The fallback `fetchChatRunStatus` REST path returns `status === 'succeeded'` (which the existing `isChatRunStatus()` guard already proves is explicit). The safety net is now bypassed only on that explicit signal; the local-fallback success path still reaches the exit-code/signal check so real failures surface as before. Adds three web-side regression tests in `apps/web/tests/providers/sse.test.ts`: - Explicit `status: 'succeeded'` + SIGTERM → onDone called, no error - End event with `{code:1}` and no `status` → onError surfaces 'agent exited with code 1' as before - End event with `{code:null,signal:'SIGTERM'}` and no `status` → onError surfaces 'agent exited with signal SIGTERM' as before `pnpm guard` + daemon typecheck clean; 27/27 SSE tests pass (up from 24). * Fix Codex wrapper launch paths (#1395) * test: add Memory and Routines coverage (#1400) * test: align extended Playwright coverage with current UI behavior * test: address extended suite review feedback * test: fix Codex fallback config hydration in e2e * test: add Memory and Routines coverage * test: fix Memory and Routines component test typing * test: include Memory and Routines e2e in extended suite * refactor(settings): use tiled language picker instead of dropdown (#1406) The Language section in Settings rendered a single-button dropdown trigger that opened a floating menu. With one visible label and lots of empty panel space, the layout misled users into thinking only one language existed. Replace the dropdown trigger + portaled menu with an inline tile grid that shows every locale at a glance and clicks directly to switch. Side effects of the new layout: the languageOpen / languageMenuRect state, the dynamic placement effect, the resize-close effect, the mousedown click-outside handler, and the languageRef are gone. The global Escape handler no longer needs to guard against the menu being open. CSS for .settings-language-picker, .settings-language-button, .settings-language-menu, and .settings-language-option is replaced by .settings-language-grid (auto-fill 180px minmax columns) + .settings-language-tile. Tests in SettingsDialog.execution.test.tsx that drove the dropdown (click trigger → click menuitemradio → assert menu closed) are rewritten to drive the tiles directly via the radio role. Refs #1347 * fix(web): restore consistent app header layout * fix(web): restore consistent app header layout Generated-By: looper 0.7.2 (runner=fixer, agent=opencode) * fix(web): restore consistent app header layout Generated-By: looper 0.7.2 (runner=fixer, agent=opencode) * fix(web): restore consistent app header layout Generated-By: looper 0.7.2 (runner=fixer, agent=opencode) * fix(web): hide project output chips in header --------- Co-authored-by: Prantik Medhi <140103052+prantikmedhi@users.noreply.github.com> Co-authored-by: 이용진 <90879448+Leesin0222@users.noreply.github.com> Co-authored-by: Nicholas-Xiong <2482929840@qq.com> Co-authored-by: Hesam <chngyzkhanwhsht@gmail.com> Co-authored-by: Yuhao Chen <godcorn001@outlook.com> Co-authored-by: chaoxiaoche <fanzhen910412@gmail.com> Co-authored-by: chaoxiaoche <chaoxiaoche@192.168.10.16> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: eggward han <32223217+Eggwardhan@users.noreply.github.com> Co-authored-by: @aaronjmars <61592645+aaronjmars@users.noreply.github.com> Co-authored-by: Bryan <121247296+bankielewicz@users.noreply.github.com> Co-authored-by: DevForgeAI CI/CD Engineer <devforge-ai@development.ai> Co-authored-by: mrzhangkris <92247501+mrzhangkris@users.noreply.github.com> Co-authored-by: laomo <laomo@openclaw.ai> Co-authored-by: Nagendhra Madishetti <nagendhra.madishetti24@gmail.com> Co-authored-by: Nagendhra <nagendhra405@gmail.com> Co-authored-by: Mason <jinmeihong0201@gmail.com> Co-authored-by: Yiang Yiyan <15089131836@163.com> Co-authored-by: Rocky <101849785+MrRockySL@users.noreply.github.com> Co-authored-by: nettee <nettee.liu@gmail.com> Co-authored-by: shangxinyu1 <shangxinyu@refly.ai> Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
This commit is contained in:
parent
9811b16eba
commit
3d3119333c
92 changed files with 8196 additions and 1434 deletions
|
|
@ -36,7 +36,7 @@ pnpm typecheck # tsc -b --noEmit
|
|||
pnpm --filter @open-design/web build # web package build when needed
|
||||
```
|
||||
|
||||
Node `~24` and pnpm `10.33.x` are required. `nvm` / `fnm` are optional; use `nvm install 24 && nvm use 24` or `fnm install 24 && fnm use 24` if you prefer managing Node that way. macOS, Linux, and WSL2 are the primary paths. Windows native should work but isn't a primary target — file an issue if it doesn't.
|
||||
Node `~24` and pnpm `10.33.x` are required. `nvm` / `fnm` are optional; use `nvm install 24 && nvm use 24` or `fnm install 24 && fnm use 24` if you prefer managing Node that way. macOS, Linux, and WSL2 are the primary paths. Windows native is supported; see [`docs/windows-troubleshooting.md`](docs/windows-troubleshooting.md) for the common setup gotchas.
|
||||
|
||||
## Docker Setup
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ Run the full product locally.
|
|||
|
||||
- **Node.js:** `~24` (Node 24.x). The repo enforces this through `package.json#engines`.
|
||||
- **pnpm:** `10.33.x`. The repo pins `pnpm@10.33.2` through `packageManager`; use Corepack so the pinned version is selected automatically.
|
||||
- **OS:** macOS, Linux, and WSL2 are the primary paths. Windows native should work for most flows, but WSL2 is the safer baseline.
|
||||
- **OS:** macOS, Linux, and WSL2 are the primary paths. Windows native is supported; see [`docs/windows-troubleshooting.md`](docs/windows-troubleshooting.md) for common setup gotchas.
|
||||
- **Optional local agent CLI:** Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen, Qoder CLI, GitHub Copilot CLI, etc. If none are installed, use the BYOK API mode from Settings.
|
||||
|
||||
### Local agent CLI and PATH
|
||||
|
|
|
|||
|
|
@ -621,6 +621,19 @@ export function attachAcpSession({
|
|||
finished = true;
|
||||
clearStageTimer();
|
||||
stdin.end();
|
||||
// Some ACP agents (e.g. Devin for Terminal) keep the child process
|
||||
// alive after stdin closes, waiting for the next prompt. Each Open
|
||||
// Design run spawns its own agent process per turn, so the child must
|
||||
// terminate for `child.on('close')` to fire and the chat run to
|
||||
// finalize — otherwise the chat stays stuck in the "working" state.
|
||||
// Give the child a short grace period to exit on its own first; if it
|
||||
// doesn't, SIGTERM forces it. This mirrors the pattern in
|
||||
// detectAcpModels() which already kills the child after a clean
|
||||
// model-discovery probe completes (see line ~270 in this file).
|
||||
const cleanExitTimer = setTimeout(() => {
|
||||
if (!child.killed) child.kill('SIGTERM');
|
||||
}, 500);
|
||||
child.once('close', () => clearTimeout(cleanExitTimer));
|
||||
return;
|
||||
}
|
||||
if (sessionId && model && model !== 'default' && obj.id === expectedId) {
|
||||
|
|
@ -648,6 +661,13 @@ export function attachAcpSession({
|
|||
hasFatalError() {
|
||||
return fatal;
|
||||
},
|
||||
completedSuccessfully() {
|
||||
// Returns true when the prompt request resolved without a fatal error
|
||||
// and was not aborted. The chat consumer treats this as a successful
|
||||
// run even if the child process subsequently exited via SIGTERM
|
||||
// (which is expected for agents that don't shut down on stdin.end()).
|
||||
return finished && !fatal && !aborted;
|
||||
},
|
||||
abort() {
|
||||
if (aborted || finished) return;
|
||||
aborted = true;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export {
|
|||
inspectAgentExecutableResolution,
|
||||
resolveAgentExecutable,
|
||||
} from './runtimes/executables.js';
|
||||
export { applyAgentLaunchEnv, resolveAgentLaunch } from './runtimes/launch.js';
|
||||
export { resolveAgentBin } from './runtimes/resolution.js';
|
||||
export { spawnEnvForAgent } from './runtimes/env.js';
|
||||
export { buildLiveArtifactsMcpServersForAgent } from './runtimes/mcp.js';
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ import { promises as fsp } from 'node:fs';
|
|||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
applyAgentLaunchEnv,
|
||||
getAgentDef,
|
||||
inspectAgentExecutableResolution,
|
||||
resolveAgentBin,
|
||||
resolveAgentLaunch,
|
||||
spawnEnvForAgent,
|
||||
} from './agents.js';
|
||||
import { createCommandInvocation } from '@open-design/platform';
|
||||
|
|
@ -49,11 +49,52 @@ import {
|
|||
export { validateBaseUrl } from '@open-design/contracts/api/connectionTest';
|
||||
|
||||
// Aggressive but not punitive — happy paths usually return in under 2 s.
|
||||
const PROVIDER_TIMEOUT_MS = 12_000;
|
||||
// Override with OD_CONNECTION_TEST_PROVIDER_TIMEOUT_MS for slow networks
|
||||
// or distant providers; invalid values fall back to the default.
|
||||
const DEFAULT_PROVIDER_TIMEOUT_MS = 12_000;
|
||||
// CLI boot time is dominated by adapter auth/session restore; the heavy
|
||||
// adapters (Codex, Cursor Agent) regularly take 5–10 s on a cold first
|
||||
// run, so 45 s leaves headroom without making a hung child invisible.
|
||||
const AGENT_TIMEOUT_MS = 45_000;
|
||||
// Override with OD_CONNECTION_TEST_AGENT_TIMEOUT_MS.
|
||||
const DEFAULT_AGENT_TIMEOUT_MS = 45_000;
|
||||
// Node's `setTimeout` silently clamps any delay above this to ~1 ms
|
||||
// (with a TimeoutOverflowWarning), so an override meant to *extend*
|
||||
// the budget — e.g. `OD_CONNECTION_TEST_AGENT_TIMEOUT_MS=3000000000` —
|
||||
// would actually make every connection test fail almost immediately.
|
||||
// Reject above the cap so the safety timeout cannot be accidentally
|
||||
// disarmed by an oversized env value.
|
||||
const MAX_CONNECTION_TEST_TIMEOUT_MS = 2_147_483_647;
|
||||
|
||||
export function resolveConnectionTestTimeoutMs(
|
||||
key: 'OD_CONNECTION_TEST_PROVIDER_TIMEOUT_MS' | 'OD_CONNECTION_TEST_AGENT_TIMEOUT_MS',
|
||||
fallback: number,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): number {
|
||||
const raw = env[key];
|
||||
if (raw === undefined || raw === '') return fallback;
|
||||
const n = Number(raw);
|
||||
if (!Number.isSafeInteger(n) || n < 1 || n > MAX_CONNECTION_TEST_TIMEOUT_MS) {
|
||||
console.warn(
|
||||
`connection-test: ignoring ${key}=${JSON.stringify(raw)} (must be a positive integer between 1 and ${MAX_CONNECTION_TEST_TIMEOUT_MS} ms); using ${fallback}ms`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function providerTimeoutMs(): number {
|
||||
return resolveConnectionTestTimeoutMs(
|
||||
'OD_CONNECTION_TEST_PROVIDER_TIMEOUT_MS',
|
||||
DEFAULT_PROVIDER_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
function agentTimeoutMs(): number {
|
||||
return resolveConnectionTestTimeoutMs(
|
||||
'OD_CONNECTION_TEST_AGENT_TIMEOUT_MS',
|
||||
DEFAULT_AGENT_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
const AGENT_COMPLETION_DEBOUNCE_MS = 500;
|
||||
const AGENT_KILL_GRACE_MS = 2_000;
|
||||
// Truncates the assistant reply we surface in the success copy so a
|
||||
|
|
@ -545,7 +586,7 @@ export async function testProviderConnection(
|
|||
} else {
|
||||
input.signal?.addEventListener('abort', abortFromParent, { once: true });
|
||||
}
|
||||
const timer = setTimeout(() => controller.abort(), PROVIDER_TIMEOUT_MS);
|
||||
const timer = setTimeout(() => controller.abort(), providerTimeoutMs());
|
||||
|
||||
try {
|
||||
const modelError = await validateLocalOpenAiModel(
|
||||
|
|
@ -851,7 +892,10 @@ export function createAgentSink(): AgentSink {
|
|||
|
||||
interface AgentSpawnHandle {
|
||||
child: ReturnType<typeof spawn>;
|
||||
acpSession?: { hasFatalError?: () => boolean } | null;
|
||||
acpSession?: {
|
||||
hasFatalError?: () => boolean;
|
||||
completedSuccessfully?: () => boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function attachAgentStreamHandlers(
|
||||
|
|
@ -863,7 +907,10 @@ function attachAgentStreamHandlers(
|
|||
send: (event: string, payload: unknown) => void,
|
||||
appendRawStdout?: (chunk: string) => void,
|
||||
): AgentSpawnHandle {
|
||||
let acpSession: { hasFatalError?: () => boolean } | null = null;
|
||||
let acpSession: {
|
||||
hasFatalError?: () => boolean;
|
||||
completedSuccessfully?: () => boolean;
|
||||
} | null = null;
|
||||
child.stdout?.setEncoding('utf8');
|
||||
child.stderr?.setEncoding('utf8');
|
||||
if (def.streamFormat === 'claude-stream-json') {
|
||||
|
|
@ -956,12 +1003,9 @@ async function testAgentConnectionInternal(
|
|||
validateAgentCliEnv(input.agentCliEnv),
|
||||
input.agentId,
|
||||
);
|
||||
const executableResolution = inspectAgentExecutableResolution(
|
||||
def,
|
||||
configuredAgentEnv,
|
||||
);
|
||||
const resolvedBin = resolveAgentBin(input.agentId, configuredAgentEnv);
|
||||
if (!resolvedBin) {
|
||||
const executableResolution = resolveAgentLaunch(def, configuredAgentEnv);
|
||||
const resolvedBin = executableResolution.selectedPath;
|
||||
if (!resolvedBin || !executableResolution.launchPath) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'agent_not_installed',
|
||||
|
|
@ -1081,16 +1125,16 @@ async function testAgentConnectionInternal(
|
|||
}
|
||||
const stdinMode =
|
||||
def.promptViaStdin || def.streamFormat === 'acp-json-rpc' ? 'pipe' : 'ignore';
|
||||
const env = spawnEnvForAgent(
|
||||
const env = applyAgentLaunchEnv(spawnEnvForAgent(
|
||||
input.agentId,
|
||||
{
|
||||
...process.env,
|
||||
...(def.env || {}),
|
||||
},
|
||||
configuredAgentEnv,
|
||||
);
|
||||
), executableResolution);
|
||||
const invocation = createCommandInvocation({
|
||||
command: resolvedBin,
|
||||
command: executableResolution.launchPath,
|
||||
args,
|
||||
env,
|
||||
});
|
||||
|
|
@ -1129,11 +1173,11 @@ async function testAgentConnectionInternal(
|
|||
const latencyMs = Date.now() - start;
|
||||
const detail = redactSecrets(winner.error.message);
|
||||
const guidance = redactSecrets(
|
||||
codexExecutableGuidance(
|
||||
`${codexExecutableGuidance(
|
||||
input.agentId,
|
||||
executableResolution.configuredOverridePath,
|
||||
executableResolution.pathResolvedPath,
|
||||
),
|
||||
)}${executableResolution.diagnostic ? ` ${executableResolution.diagnostic}` : ''}`,
|
||||
);
|
||||
const errnoCode = (winner.error as NodeJS.ErrnoException).code;
|
||||
const isMissing = errnoCode === 'ENOENT';
|
||||
|
|
@ -1152,7 +1196,28 @@ async function testAgentConnectionInternal(
|
|||
|
||||
const latencyMs = Date.now() - start;
|
||||
const buffered = sink.getText().trim();
|
||||
const exitedCleanly = winner.code === 0 && !winner.signal;
|
||||
// ACP agents that don't shut down on stdin.end() (e.g. Devin for
|
||||
// Terminal) are now SIGTERM'd from attachAcpSession after a clean
|
||||
// prompt completion, which sets `winner.signal === 'SIGTERM'`. For
|
||||
// that exact forced-shutdown shape we trust the ACP-level success
|
||||
// signal so connection tests don't report `agent_spawn_failed`
|
||||
// despite a healthy assistant response (see #1265 / #1286).
|
||||
//
|
||||
// Scope the override narrowly: only `code === null` AND
|
||||
// `signal === 'SIGTERM'` AND `acpCleanCompletion` count as a clean
|
||||
// forced shutdown. Any other post-response process failure (non-zero
|
||||
// exit code, SIGKILL, SIGSEGV, etc.) still falls through to
|
||||
// `agent_spawn_failed`, preserving the existing connection-test
|
||||
// failure behavior for genuine post-response problems.
|
||||
const acpCleanCompletion =
|
||||
typeof acpSession?.completedSuccessfully === 'function' &&
|
||||
acpSession.completedSuccessfully();
|
||||
const acpForcedShutdown =
|
||||
winner.code === null &&
|
||||
winner.signal === 'SIGTERM' &&
|
||||
acpCleanCompletion;
|
||||
const exitedCleanly =
|
||||
(winner.code === 0 && !winner.signal) || acpForcedShutdown;
|
||||
if (buffered) {
|
||||
const rawSample = truncateSample(buffered);
|
||||
if (rawSample && isLikelyModelErrorText(rawSample)) {
|
||||
|
|
@ -1195,11 +1260,11 @@ async function testAgentConnectionInternal(
|
|||
.join(' · '),
|
||||
);
|
||||
const guidance = redactSecrets(
|
||||
codexExecutableGuidance(
|
||||
`${codexExecutableGuidance(
|
||||
input.agentId,
|
||||
executableResolution.configuredOverridePath,
|
||||
executableResolution.pathResolvedPath,
|
||||
),
|
||||
)}${executableResolution.diagnostic ? ` ${executableResolution.diagnostic}` : ''}`,
|
||||
);
|
||||
const label = buffered ? 'exit_failed' : 'no_text';
|
||||
console.warn(
|
||||
|
|
@ -1227,7 +1292,7 @@ async function testAgentConnectionInternal(
|
|||
child.stdin.end(SMOKE_PROMPT, 'utf8');
|
||||
}
|
||||
const cancellationPromise = new Promise<{ kind: 'timeout' } | { kind: 'aborted' }>((resolve) => {
|
||||
timer = setTimeout(() => resolve({ kind: 'timeout' }), AGENT_TIMEOUT_MS);
|
||||
timer = setTimeout(() => resolve({ kind: 'timeout' }), agentTimeoutMs());
|
||||
abortHandler = () => resolve({ kind: 'aborted' });
|
||||
if (input.signal?.aborted) {
|
||||
abortHandler();
|
||||
|
|
@ -1326,11 +1391,15 @@ export async function testAgentConnection(
|
|||
const configuredAgentEnv = agentCliEnvForAgent(validatedPrefs, input.agentId);
|
||||
const def = getAgentDef(input.agentId);
|
||||
const executableResolution = def
|
||||
? inspectAgentExecutableResolution(def, configuredAgentEnv)
|
||||
? resolveAgentLaunch(def, configuredAgentEnv)
|
||||
: {
|
||||
configuredOverridePath: null,
|
||||
pathResolvedPath: null,
|
||||
selectedPath: null,
|
||||
launchPath: null,
|
||||
launchKind: 'selected' as const,
|
||||
childPathPrepend: [],
|
||||
diagnostic: null,
|
||||
};
|
||||
if (
|
||||
input.agentId === 'codex' &&
|
||||
|
|
@ -1341,7 +1410,7 @@ export async function testAgentConnection(
|
|||
return {
|
||||
...primaryResult,
|
||||
configuredExecutablePath: executableResolution.configuredOverridePath,
|
||||
usedExecutablePath: executableResolution.configuredOverridePath,
|
||||
usedExecutablePath: executableResolution.launchPath ?? executableResolution.configuredOverridePath,
|
||||
usedExecutableSource: 'configured',
|
||||
...(executableResolution.pathResolvedPath
|
||||
? { detectedExecutablePath: executableResolution.pathResolvedPath }
|
||||
|
|
@ -1358,7 +1427,7 @@ export async function testAgentConnection(
|
|||
...primaryResult,
|
||||
configuredExecutablePath: configuredCodexBin,
|
||||
detectedExecutablePath: executableResolution.pathResolvedPath,
|
||||
usedExecutablePath: executableResolution.pathResolvedPath,
|
||||
usedExecutablePath: executableResolution.launchPath ?? executableResolution.pathResolvedPath,
|
||||
usedExecutableSource: 'fallback_invalid',
|
||||
detail: redactSecrets(
|
||||
codexInvalidConfiguredPathFallbackDetail(
|
||||
|
|
@ -1392,7 +1461,7 @@ export async function testAgentConnection(
|
|||
...fallbackResult,
|
||||
configuredExecutablePath: executableResolution.configuredOverridePath,
|
||||
detectedExecutablePath: executableResolution.pathResolvedPath,
|
||||
usedExecutablePath: executableResolution.pathResolvedPath,
|
||||
usedExecutablePath: executableResolution.launchPath ?? executableResolution.pathResolvedPath,
|
||||
usedExecutableSource: 'fallback_failed',
|
||||
detail: redactSecrets(
|
||||
codexExecutableFallbackSuccessDetail(
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ function migrate(db: SqliteDb): void {
|
|||
events_json TEXT,
|
||||
attachments_json TEXT,
|
||||
produced_files_json TEXT,
|
||||
feedback_json TEXT,
|
||||
started_at INTEGER,
|
||||
ended_at INTEGER,
|
||||
position INTEGER NOT NULL,
|
||||
|
|
@ -218,6 +219,9 @@ function migrate(db: SqliteDb): void {
|
|||
if (!messageCols.some((c: DbRow) => c.name === 'comment_attachments_json')) {
|
||||
db.exec(`ALTER TABLE messages ADD COLUMN comment_attachments_json TEXT`);
|
||||
}
|
||||
if (!messageCols.some((c: DbRow) => c.name === 'feedback_json')) {
|
||||
db.exec(`ALTER TABLE messages ADD COLUMN feedback_json TEXT`);
|
||||
}
|
||||
|
||||
const previewCommentCols = db.prepare(`PRAGMA table_info(preview_comments)`).all() as DbRow[];
|
||||
if (!previewCommentCols.some((c: DbRow) => c.name === 'selection_kind')) {
|
||||
|
|
@ -609,6 +613,22 @@ export function getTemplate(db: SqliteDb, id: string) {
|
|||
return row ? normalizeTemplate(row) : null;
|
||||
}
|
||||
|
||||
export function findTemplateByNameAndProject(
|
||||
db: SqliteDb,
|
||||
name: string,
|
||||
sourceProjectId: string,
|
||||
) {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT id, name, description, source_project_id AS sourceProjectId,
|
||||
files_json AS filesJson, created_at AS createdAt
|
||||
FROM templates
|
||||
WHERE name = ? AND source_project_id = ?`,
|
||||
)
|
||||
.get(name, sourceProjectId) as DbRow | undefined;
|
||||
return row ? normalizeTemplate(row) : null;
|
||||
}
|
||||
|
||||
export function insertTemplate(db: SqliteDb, t: DbRow) {
|
||||
db.prepare(
|
||||
`INSERT INTO templates (id, name, description, source_project_id, files_json, created_at)
|
||||
|
|
@ -624,6 +644,17 @@ export function insertTemplate(db: SqliteDb, t: DbRow) {
|
|||
return getTemplate(db, t.id);
|
||||
}
|
||||
|
||||
export function updateTemplate(
|
||||
db: SqliteDb,
|
||||
id: string,
|
||||
t: { description: string | null; files: unknown[] },
|
||||
) {
|
||||
db.prepare(
|
||||
`UPDATE templates SET description = ?, files_json = ? WHERE id = ?`,
|
||||
).run(t.description, JSON.stringify(t.files), id);
|
||||
return getTemplate(db, id);
|
||||
}
|
||||
|
||||
export function deleteTemplate(db: SqliteDb, id: string) {
|
||||
db.prepare(`DELETE FROM templates WHERE id = ?`).run(id);
|
||||
}
|
||||
|
|
@ -724,6 +755,7 @@ export function listMessages(db: SqliteDb, conversationId: string) {
|
|||
attachments_json AS attachmentsJson,
|
||||
comment_attachments_json AS commentAttachmentsJson,
|
||||
produced_files_json AS producedFilesJson,
|
||||
feedback_json AS feedbackJson,
|
||||
created_at AS createdAt, started_at AS startedAt, ended_at AS endedAt,
|
||||
position
|
||||
FROM messages
|
||||
|
|
@ -745,7 +777,7 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
|
|||
SET role = ?, content = ?, agent_id = ?, agent_name = ?,
|
||||
run_id = ?, run_status = ?, last_run_event_id = ?,
|
||||
events_json = ?, attachments_json = ?, comment_attachments_json = ?,
|
||||
produced_files_json = ?, started_at = ?, ended_at = ?
|
||||
produced_files_json = ?, feedback_json = ?, started_at = ?, ended_at = ?
|
||||
WHERE id = ?`,
|
||||
).run(
|
||||
m.role,
|
||||
|
|
@ -759,6 +791,7 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
|
|||
m.attachments ? JSON.stringify(m.attachments) : null,
|
||||
m.commentAttachments ? JSON.stringify(m.commentAttachments) : null,
|
||||
m.producedFiles ? JSON.stringify(m.producedFiles) : null,
|
||||
m.feedback ? JSON.stringify(m.feedback) : null,
|
||||
m.startedAt ?? null,
|
||||
m.endedAt ?? null,
|
||||
m.id,
|
||||
|
|
@ -770,17 +803,17 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
|
|||
)
|
||||
.get(conversationId) as DbRow | undefined;
|
||||
const position = (max?.m ?? -1) + 1;
|
||||
// 17 values: id, conversation_id, role, content, agent_id, agent_name,
|
||||
// 18 values: id, conversation_id, role, content, agent_id, agent_name,
|
||||
// run_id, run_status, last_run_event_id, events_json, attachments_json,
|
||||
// comment_attachments_json, produced_files_json, started_at, ended_at,
|
||||
// comment_attachments_json, produced_files_json, feedback_json, started_at, ended_at,
|
||||
// position, created_at.
|
||||
db.prepare(
|
||||
`INSERT INTO messages
|
||||
(id, conversation_id, role, content, agent_id, agent_name,
|
||||
run_id, run_status, last_run_event_id, events_json,
|
||||
attachments_json, comment_attachments_json, produced_files_json,
|
||||
started_at, ended_at, position, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
feedback_json, started_at, ended_at, position, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
m.id,
|
||||
conversationId,
|
||||
|
|
@ -795,6 +828,7 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
|
|||
m.attachments ? JSON.stringify(m.attachments) : null,
|
||||
m.commentAttachments ? JSON.stringify(m.commentAttachments) : null,
|
||||
m.producedFiles ? JSON.stringify(m.producedFiles) : null,
|
||||
m.feedback ? JSON.stringify(m.feedback) : null,
|
||||
m.startedAt ?? null,
|
||||
m.endedAt ?? null,
|
||||
position,
|
||||
|
|
@ -815,6 +849,7 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
|
|||
attachments_json AS attachmentsJson,
|
||||
comment_attachments_json AS commentAttachmentsJson,
|
||||
produced_files_json AS producedFilesJson,
|
||||
feedback_json AS feedbackJson,
|
||||
created_at AS createdAt, started_at AS startedAt, ended_at AS endedAt,
|
||||
position
|
||||
FROM messages WHERE id = ?`,
|
||||
|
|
@ -1058,6 +1093,7 @@ function normalizeMessage(row: DbRow) {
|
|||
attachments: parseJsonOrUndef(row.attachmentsJson),
|
||||
commentAttachments: parseJsonOrUndef(row.commentAttachmentsJson),
|
||||
producedFiles: parseJsonOrUndef(row.producedFilesJson),
|
||||
feedback: parseJsonOrUndef(row.feedbackJson),
|
||||
createdAt: row.createdAt ?? undefined,
|
||||
startedAt: row.startedAt ?? undefined,
|
||||
endedAt: row.endedAt ?? undefined,
|
||||
|
|
|
|||
|
|
@ -62,6 +62,60 @@ export async function readDesignSystem(root: string, id: string): Promise<string
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured (compiled) form of a brand's design system. Optional sibling
|
||||
* files alongside DESIGN.md that, when present, give agents a
|
||||
* machine-readable token contract and a worked fixture instead of having
|
||||
* to re-derive both from prose. Both fields are individually optional —
|
||||
* the daemon falls back to the DESIGN.md-only path when neither is
|
||||
* available, which is the current state for the ~138 brands without
|
||||
* hand-authored or derived tokens.
|
||||
*
|
||||
* - `tokensCss` — verbatim content of `<brand>/tokens.css`.
|
||||
* - `fixtureHtml` — verbatim content of `<brand>/components.html`.
|
||||
*/
|
||||
export type DesignSystemAssets = {
|
||||
tokensCss?: string | undefined;
|
||||
fixtureHtml?: string | undefined;
|
||||
};
|
||||
|
||||
export async function readDesignSystemAssets(
|
||||
root: string,
|
||||
id: string,
|
||||
): Promise<DesignSystemAssets> {
|
||||
const [tokensCss, fixtureHtml] = await Promise.all([
|
||||
readFileOptional(path.join(root, id, 'tokens.css')),
|
||||
readFileOptional(path.join(root, id, 'components.html')),
|
||||
]);
|
||||
return { tokensCss, fixtureHtml };
|
||||
}
|
||||
|
||||
async function readFileOptional(file: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await readFile(file, 'utf8');
|
||||
} catch (err) {
|
||||
// Only swallow "file genuinely does not exist" failures. Today the
|
||||
// ~138 brands without hand-authored or derived tokens.css /
|
||||
// components.html siblings hit this path on the empty side, which
|
||||
// is the legacy fallback we deliberately preserve. Every other
|
||||
// failure mode — permission denied (`EACCES`), parent-shadowed by
|
||||
// a non-directory (`EPERM`), a directory at the file path
|
||||
// (`EISDIR`), a broken packaged-resource symlink, a transient I/O
|
||||
// error — means the token channel is misconfigured; the caller
|
||||
// (and the smoke-test rollout) needs to see that explicitly
|
||||
// instead of silently degrading to the DESIGN.md-only prompt and
|
||||
// making the experiment look ineffective for the wrong reason.
|
||||
if (isAbsenceError(err)) return undefined;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function isAbsenceError(err: unknown): boolean {
|
||||
if (typeof err !== 'object' || err === null) return false;
|
||||
const code = (err as { code?: unknown }).code;
|
||||
return code === 'ENOENT' || code === 'ENOTDIR';
|
||||
}
|
||||
|
||||
function summarize(raw: string): string {
|
||||
const lines = raw.split(/\r?\n/);
|
||||
const firstH1 = lines.findIndex((l) => /^#\s+/.test(l));
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import type { Express } from 'express';
|
||||
import type { RouteDeps } from './server-context.js';
|
||||
import {
|
||||
InlineAssetsLimitError,
|
||||
MAX_INLINE_OWNER_BYTES,
|
||||
inlineRelativeAssets,
|
||||
type InlineAssetReader,
|
||||
} from './inline-assets.js';
|
||||
|
||||
export interface RegisterImportRoutesDeps extends RouteDeps<'db' | 'http' | 'uploads' | 'node' | 'ids' | 'paths' | 'imports' | 'auth' | 'projectStore' | 'conversations' | 'projectFiles'> {}
|
||||
|
||||
|
|
@ -216,13 +222,15 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
|
|||
|
||||
}
|
||||
|
||||
export interface RegisterProjectExportRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'projectStore' | 'exports'> {}
|
||||
export interface RegisterProjectExportRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'projectStore' | 'exports' | 'projectFiles' | 'validation'> {}
|
||||
|
||||
export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectExportRoutesDeps) {
|
||||
const { db } = ctx;
|
||||
const { sendApiError } = ctx.http;
|
||||
const { PROJECTS_DIR } = ctx.paths;
|
||||
const { getProject } = ctx.projectStore;
|
||||
const { readProjectFile, resolveProjectFilePath } = ctx.projectFiles;
|
||||
const { isSafeId } = ctx.validation;
|
||||
const {
|
||||
buildProjectArchive,
|
||||
buildBatchArchive,
|
||||
|
|
@ -345,6 +353,177 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx
|
|||
}
|
||||
});
|
||||
|
||||
// Export endpoint: serves an HTML body with every same-project
|
||||
// top-level `<link rel=stylesheet>` / `<script src>` inlined.
|
||||
// Counterpart to GET /api/projects/:id/raw/* — that route stays
|
||||
// URL-load (one request per asset; FileViewer's default since
|
||||
// PR #384). This route exists for explicit "Inline top-level
|
||||
// CSS/JS" exports + the screenshot path where the headless browser
|
||||
// fetches the response and renders it.
|
||||
//
|
||||
// Scope is intentionally narrow: only `<link rel=stylesheet>` and
|
||||
// `<script src>` are rewritten. `<img src>`, CSS `url(...)` refs,
|
||||
// `@import`, ES module imports, font sources, and similar remain
|
||||
// external in the response — see the docstring on
|
||||
// `apps/daemon/src/inline-assets.ts` for the full not-rewritten list
|
||||
// and rationale. A fully offline "self-contained" export with image
|
||||
// and font bundling would be a follow-up issue.
|
||||
//
|
||||
// Null-origin (sandboxed iframe srcdoc) callers are intentionally
|
||||
// NOT supported — the only consumers are the daemon UI (same-origin)
|
||||
// and server-side screenshot tooling (no Origin header). The
|
||||
// response also carries `Content-Security-Policy: sandbox
|
||||
// allow-scripts` so top-level browser navigation (no Origin header,
|
||||
// would otherwise pass the daemon middleware) cannot escalate to
|
||||
// daemon-origin privileges through script execution.
|
||||
//
|
||||
// See nexu-io/open-design#368 and the architecture lock at
|
||||
// https://github.com/nexu-io/open-design/issues/368#issuecomment-4366243218.
|
||||
app.get('/api/projects/:id/export/*', async (req, res) => {
|
||||
try {
|
||||
if (!isSafeId(req.params.id)) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid project id');
|
||||
}
|
||||
|
||||
const inlineRaw =
|
||||
typeof req.query.inline === 'string' ? req.query.inline.trim().toLowerCase() : '';
|
||||
if (!['1', 'true', 'yes', 'on'].includes(inlineRaw)) {
|
||||
return sendApiError(
|
||||
res,
|
||||
400,
|
||||
'BAD_REQUEST',
|
||||
"query parameter 'inline=1' is required",
|
||||
);
|
||||
}
|
||||
|
||||
const project = getProject(db, req.params.id);
|
||||
const relPath = (req.params as any)[0];
|
||||
|
||||
// PR #1312 round-5 (lefarcen P2): stat the owner file BEFORE
|
||||
// readProjectFile so a 100 MiB owner HTML is rejected after a
|
||||
// cheap stat() call, not after a 100 MiB readFile() into memory.
|
||||
// The size check + mime check both run pre-buffer here, mirroring
|
||||
// the sibling-asset stat-then-read contract round 4 already
|
||||
// applied via AssetHandle. Size fires before mime so an oversize
|
||||
// non-HTML file returns 413 (not 415) — that ordering is the
|
||||
// observable Red→Green for this round.
|
||||
//
|
||||
// The helper's ownerBytes check (inline-assets.ts:127-133) stays
|
||||
// as defense-in-depth: it still catches direct in-process callers
|
||||
// that skip the route and any future drift in the size reported
|
||||
// by stat vs the bytes actually returned by readFile.
|
||||
let ownerMeta;
|
||||
try {
|
||||
ownerMeta = await resolveProjectFilePath(
|
||||
PROJECTS_DIR,
|
||||
req.params.id,
|
||||
relPath,
|
||||
project?.metadata,
|
||||
);
|
||||
} catch (err: any) {
|
||||
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
||||
return sendApiError(
|
||||
res,
|
||||
status,
|
||||
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
|
||||
String(err),
|
||||
);
|
||||
}
|
||||
|
||||
if (ownerMeta.size > MAX_INLINE_OWNER_BYTES) {
|
||||
return sendApiError(
|
||||
res,
|
||||
413,
|
||||
'PAYLOAD_TOO_LARGE',
|
||||
`owner html ${ownerMeta.size} bytes exceeds MAX_INLINE_OWNER_BYTES ${MAX_INLINE_OWNER_BYTES}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!ownerMeta.mime.startsWith('text/html')) {
|
||||
return sendApiError(
|
||||
res,
|
||||
415,
|
||||
'UNSUPPORTED_MEDIA_TYPE',
|
||||
'export endpoint only supports HTML files',
|
||||
);
|
||||
}
|
||||
|
||||
let file;
|
||||
try {
|
||||
file = await readProjectFile(PROJECTS_DIR, req.params.id, relPath, project?.metadata);
|
||||
} catch (err: any) {
|
||||
const status = err && err.code === 'ENOENT' ? 404 : 400;
|
||||
return sendApiError(
|
||||
res,
|
||||
status,
|
||||
status === 404 ? 'FILE_NOT_FOUND' : 'BAD_REQUEST',
|
||||
String(err),
|
||||
);
|
||||
}
|
||||
|
||||
// PR #1312 round-4 (lefarcen P2): stat first, then read. This
|
||||
// lets the helper short-circuit on maxAssetBytes / maxTotalBytes
|
||||
// BEFORE the buffer is materialized into memory. A 100 MiB
|
||||
// sibling file is rejected after the cheap stat call, not after
|
||||
// a 100 MiB readFile.
|
||||
const fileReader: InlineAssetReader = async (sibling) => {
|
||||
let meta;
|
||||
try {
|
||||
meta = await resolveProjectFilePath(
|
||||
PROJECTS_DIR,
|
||||
req.params.id,
|
||||
sibling,
|
||||
project?.metadata,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
size: meta.size,
|
||||
read: async () => {
|
||||
try {
|
||||
const siblingFile = await readProjectFile(
|
||||
PROJECTS_DIR,
|
||||
req.params.id,
|
||||
sibling,
|
||||
project?.metadata,
|
||||
);
|
||||
return siblingFile.buffer.toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const rendered = await inlineRelativeAssets(
|
||||
file.buffer.toString('utf8'),
|
||||
relPath,
|
||||
fileReader,
|
||||
);
|
||||
// PR #1312 round-2 (lefarcen P2): top-level browser navigation to
|
||||
// this URL sends no Origin header, so the /api middleware lets it
|
||||
// through. Without a CSP, any JS in the exported document would
|
||||
// run at daemon origin with access to /api/, cookies, localStorage,
|
||||
// etc. `sandbox allow-scripts` treats the response like a sandboxed
|
||||
// iframe with an opaque origin — scripts execute (that's the point
|
||||
// of inlining JS for screenshot tooling), but cannot read cookies,
|
||||
// hit /api/, or escalate to daemon-origin privileges.
|
||||
res.setHeader('Content-Security-Policy', 'sandbox allow-scripts');
|
||||
res.type('text/html').send(rendered);
|
||||
} catch (err: any) {
|
||||
// PR #1312 round-3 (lefarcen P2): the inliner's cap-enforcement
|
||||
// throws InlineAssetsLimitError when the owner HTML, candidate
|
||||
// count, or assembled output exceeds the module-level limits.
|
||||
// Map every such throw to a 413 PAYLOAD_TOO_LARGE envelope so
|
||||
// callers see a structured error rather than a generic 400.
|
||||
if (err instanceof InlineAssetsLimitError || err?.name === 'InlineAssetsLimitError') {
|
||||
return sendApiError(res, 413, 'PAYLOAD_TOO_LARGE', String(err));
|
||||
}
|
||||
sendApiError(res, 400, 'BAD_REQUEST', String(err));
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
export interface RegisterFinalizeRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'projectStore' | 'validation' | 'finalize'> {}
|
||||
|
|
|
|||
413
apps/daemon/src/inline-assets.ts
Normal file
413
apps/daemon/src/inline-assets.ts
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
// Server-side port of the web-client inliner at
|
||||
// apps/web/src/components/FileViewer.tsx:5248-5354 (@ base SHA 5bd97631).
|
||||
// Powers the GET /api/projects/:id/export/*?inline=1 endpoint that
|
||||
// inlines TOP-LEVEL relative `<link rel=stylesheet>` and
|
||||
// `<script src=...>` tags into the response HTML — the viewer itself
|
||||
// stays URL-load by default since PR #384 (Part 1 of
|
||||
// nexu-io/open-design#368).
|
||||
//
|
||||
// Scope: this helper handles two tag families only. The following are
|
||||
// NOT rewritten and remain external in the response:
|
||||
// - <img src>, <video src>, <audio src>, <source src>, <iframe src>
|
||||
// - CSS `url(...)` references (in inlined stylesheets or otherwise)
|
||||
// - CSS `@import` directives
|
||||
// - ES module `import` / `export from` statements inside JS bodies
|
||||
// - <link rel=preload|prefetch|icon|...> (only rel=stylesheet inlines)
|
||||
// - <link rel=stylesheet> with absolute / data: / blob: hrefs
|
||||
// - <font-face> src attrs / @font-face url() references
|
||||
//
|
||||
// Callers that need a fully bundled offline artifact (e.g. an HTML
|
||||
// archive that opens on a machine with no network access) must layer
|
||||
// their own asset rewriting on top of this primitive or build a
|
||||
// stricter "fully self-contained export" follow-up. The screenshot
|
||||
// path is the primary motivator: a headless browser fetches each
|
||||
// referenced asset on render, so the inline-CSS-and-JS-only contract
|
||||
// is sufficient.
|
||||
//
|
||||
// Memory profile: the helper holds one Buffer-as-string copy of the
|
||||
// owner HTML plus one string copy of each sibling asset body, plus the
|
||||
// concatenated output. The daemon is local-first (single-user, on the
|
||||
// developer's machine — see open_design_architecture.md), so the
|
||||
// effective ceiling is the size of the user's own project; no hard
|
||||
// cap is enforced. If you're surfacing this endpoint to non-trusted
|
||||
// callers later, you'll want a bounded-concurrency reader and an
|
||||
// output-size limit.
|
||||
|
||||
/**
|
||||
* A handle to a project-relative asset. Decoupled into `size` (cheap;
|
||||
* sourced from `stat` or equivalent) and `read()` (full materialization)
|
||||
* so the helper can short-circuit on `maxAssetBytes` / `maxTotalBytes`
|
||||
* BEFORE the asset is buffered into memory. PR #1312 round-4 (lefarcen
|
||||
* P2): the round-3 caps fired only after every candidate had already
|
||||
* been fully read + decoded, so 500 assets at 5 MiB each could
|
||||
* materialize 2.5 GiB before the 413. The size-then-read contract
|
||||
* lets us cap pre-buffer.
|
||||
*/
|
||||
export interface AssetHandle {
|
||||
readonly size: number;
|
||||
read(): Promise<string | null>;
|
||||
}
|
||||
|
||||
export interface InlineAssetReader {
|
||||
(relPath: string): Promise<AssetHandle | null>;
|
||||
}
|
||||
|
||||
export interface InlineOptions {
|
||||
/** Max byte length of the owner HTML; exceeds → throw InlineAssetsLimitError("owner"). */
|
||||
maxOwnerBytes?: number;
|
||||
/** Max byte length of a single inlined asset body; exceeds → tag stays as URL ref. */
|
||||
maxAssetBytes?: number;
|
||||
/** Max number of `<link>`/`<script>` matches in the owner HTML; exceeds → throw "candidates". */
|
||||
maxCandidates?: number;
|
||||
/** Max byte length of the assembled output; exceeds → throw InlineAssetsLimitError("total"). */
|
||||
maxTotalBytes?: number;
|
||||
/** Max concurrent fileReader invocations; defaults to MAX_INLINE_READ_CONCURRENCY. */
|
||||
maxReadConcurrency?: number;
|
||||
}
|
||||
|
||||
// Module-level defaults. PR #1312 round-3 (lefarcen P2): the daemon is
|
||||
// local-first, but the helper still needs defensive caps to prevent
|
||||
// a pathological project (or a future non-trusted caller) from
|
||||
// causing unbounded read fanout / memory blow-up. These values let
|
||||
// any realistic developer project through while rejecting clearly
|
||||
// pathological inputs.
|
||||
export const MAX_INLINE_OWNER_BYTES = 2 * 1024 * 1024; // 2 MiB
|
||||
export const MAX_INLINE_ASSET_BYTES = 5 * 1024 * 1024; // 5 MiB per asset
|
||||
export const MAX_INLINE_CANDIDATES = 500; // <link>/<script src> matches
|
||||
export const MAX_INLINE_TOTAL_BYTES = 50 * 1024 * 1024; // 50 MiB output
|
||||
export const MAX_INLINE_READ_CONCURRENCY = 8; // simultaneous fileReader calls
|
||||
|
||||
/**
|
||||
* Thrown by `inlineRelativeAssets` when a configured cap is exceeded.
|
||||
* The route handler maps every `InlineAssetsLimitError` to a 413
|
||||
* `PAYLOAD_TOO_LARGE` envelope; the `limit` field identifies which
|
||||
* cap fired so logs/clients can disambiguate.
|
||||
*/
|
||||
export class InlineAssetsLimitError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly limit: 'owner' | 'asset' | 'candidates' | 'total',
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'InlineAssetsLimitError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function inlineRelativeAssets(
|
||||
html: string,
|
||||
ownerFileName: string,
|
||||
fileReader: InlineAssetReader,
|
||||
opts: InlineOptions = {},
|
||||
): Promise<string> {
|
||||
// Each pending entry records the exact byte span in the ORIGINAL html
|
||||
// plus the builder that turns the asset's content into the
|
||||
// replacement string. We never mutate-then-rescan, so a tag literal
|
||||
// that happens to appear inside an inlined asset body is left
|
||||
// untouched.
|
||||
//
|
||||
// Two divergences from apps/web/src/components/FileViewer.tsx:5265-5314:
|
||||
//
|
||||
// 1. Position-based, single-pass concat (vs. `.reduce` over `.replace`).
|
||||
// The web client's reduce-over-replace re-scans the mutated string
|
||||
// on each pass, which (a) replaces only the first occurrence of a
|
||||
// duplicate tag and (b) corrupts already-inlined bodies that contain
|
||||
// another tag's literal substring. This helper avoids both by
|
||||
// operating on captured indices in the original input.
|
||||
// 2. Duplicate identical tags: this helper inlines every occurrence
|
||||
// (the web client's first-match-only is a side-effect of `.replace`
|
||||
// semantics). The web inline path is on a deprecation track since
|
||||
// PR #384 made URL-load the default, so the divergence is
|
||||
// forward-pointing.
|
||||
const maxOwnerBytes = opts.maxOwnerBytes ?? MAX_INLINE_OWNER_BYTES;
|
||||
const maxAssetBytes = opts.maxAssetBytes ?? MAX_INLINE_ASSET_BYTES;
|
||||
const maxCandidates = opts.maxCandidates ?? MAX_INLINE_CANDIDATES;
|
||||
const maxTotalBytes = opts.maxTotalBytes ?? MAX_INLINE_TOTAL_BYTES;
|
||||
const maxReadConcurrency = opts.maxReadConcurrency ?? MAX_INLINE_READ_CONCURRENCY;
|
||||
|
||||
const ownerBytes = Buffer.byteLength(html, 'utf8');
|
||||
if (ownerBytes > maxOwnerBytes) {
|
||||
throw new InlineAssetsLimitError(
|
||||
`owner html ${ownerBytes} bytes exceeds maxOwnerBytes ${maxOwnerBytes}`,
|
||||
'owner',
|
||||
);
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
start: number;
|
||||
end: number;
|
||||
resolved: string;
|
||||
build: (content: string) => string;
|
||||
}
|
||||
|
||||
const pending: Pending[] = [];
|
||||
|
||||
for (const match of html.matchAll(/<link\b[^>]*>/gi)) {
|
||||
const tag = match[0];
|
||||
const start = match.index!;
|
||||
const rel = readHtmlAttr(tag, 'rel');
|
||||
const href = readHtmlAttr(tag, 'href');
|
||||
if (!rel || !/\bstylesheet\b/i.test(rel) || !href) continue;
|
||||
const resolved = resolveProjectRelativePath(ownerFileName, href);
|
||||
if (!resolved) continue;
|
||||
pending.push({
|
||||
start,
|
||||
end: start + tag.length,
|
||||
resolved,
|
||||
build: (css) => buildInlineStyleBlock(tag, href, css),
|
||||
});
|
||||
}
|
||||
|
||||
for (const match of html.matchAll(
|
||||
/<script\b[^>]*\bsrc\s*=\s*["'][^"']+["'][^>]*>\s*<\/script>/gi,
|
||||
)) {
|
||||
const tag = match[0];
|
||||
const start = match.index!;
|
||||
const src = readHtmlAttr(tag, 'src');
|
||||
if (!src) continue;
|
||||
const resolved = resolveProjectRelativePath(ownerFileName, src);
|
||||
if (!resolved) continue;
|
||||
pending.push({
|
||||
start,
|
||||
end: start + tag.length,
|
||||
resolved,
|
||||
build: (js) => buildInlineScriptBlock(tag, js),
|
||||
});
|
||||
}
|
||||
|
||||
if (pending.length === 0) return html;
|
||||
|
||||
if (pending.length > maxCandidates) {
|
||||
throw new InlineAssetsLimitError(
|
||||
`found ${pending.length} candidates, exceeds maxCandidates ${maxCandidates}`,
|
||||
'candidates',
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by start so we can splice slices of the original html in order.
|
||||
// Link and script regions cannot overlap in a well-formed document
|
||||
// (the HTML parser disallows nested <script> / <link>), so we don't
|
||||
// need overlap-resolution logic.
|
||||
pending.sort((a, b) => a.start - b.start);
|
||||
|
||||
// Running-total tracker. Owner html bytes contribute first; each
|
||||
// accepted asset reserves its stat-size BEFORE the read. The
|
||||
// concat-time guard further down is the exact final check; this
|
||||
// pre-buffer reservation is the early-abort that bounds peak memory
|
||||
// (PR #1312 round-4, lefarcen P2). Reservation is approximate
|
||||
// (counts the original tag bytes that get replaced, doesn't count
|
||||
// wrapper overhead) — close enough for resource bounding, and the
|
||||
// concat-time guard catches any remaining drift.
|
||||
let runningBytes = ownerBytes;
|
||||
let totalAborted = false;
|
||||
|
||||
const replacements = await runWithConcurrency(
|
||||
pending,
|
||||
maxReadConcurrency,
|
||||
async (p) => {
|
||||
if (totalAborted) return null;
|
||||
const handle = await fileReader(p.resolved);
|
||||
if (handle == null) return null;
|
||||
// Per-asset cap, pre-buffer (stat-based — no read yet).
|
||||
if (handle.size > maxAssetBytes) return null;
|
||||
// Total cap, pre-buffer. The check + write below has no `await`
|
||||
// between them, so under Node's single-threaded event loop the
|
||||
// pair is atomic with respect to other workers — no race.
|
||||
if (runningBytes + handle.size > maxTotalBytes) {
|
||||
totalAborted = true;
|
||||
return null;
|
||||
}
|
||||
runningBytes += handle.size;
|
||||
const content = await handle.read();
|
||||
if (content == null) {
|
||||
// Read failed/returned null AFTER stat said it was OK — refund
|
||||
// the reservation so the total stays honest for any in-flight
|
||||
// siblings still racing the cap check.
|
||||
runningBytes -= handle.size;
|
||||
return null;
|
||||
}
|
||||
const actualBytes = Buffer.byteLength(content, 'utf8');
|
||||
if (actualBytes > maxAssetBytes) {
|
||||
// Defensive: handle.size may have been stale or the reader
|
||||
// ignored its own promise. Refund and drop the inlining.
|
||||
runningBytes -= handle.size;
|
||||
return null;
|
||||
}
|
||||
// PR #1312 round-5 (lefarcen P3 confirmed path-a): reconcile the
|
||||
// pre-read reservation with the actual content byte length. A
|
||||
// stat-lying reader (stale stat, UTF-8 decode expansion, sparse
|
||||
// file, deliberate under-report) could otherwise let many strings
|
||||
// materialize before the concat-time guard catches it. The per-
|
||||
// asset check above protects against single-asset blow-up; this
|
||||
// adjustment + re-check guards the running total.
|
||||
runningBytes += actualBytes - handle.size;
|
||||
if (runningBytes > maxTotalBytes) {
|
||||
// Drop this asset's inlining (tag stays as URL ref), set the
|
||||
// abort flag so subsequent workers skip their read(), let
|
||||
// Promise.all settle, then throw 'total' below. No throw-
|
||||
// before-settle race — matches the round-2/3/4 graceful-
|
||||
// fallback pattern.
|
||||
totalAborted = true;
|
||||
return null;
|
||||
}
|
||||
return p.build(content);
|
||||
},
|
||||
);
|
||||
|
||||
if (totalAborted) {
|
||||
throw new InlineAssetsLimitError(
|
||||
`running total exceeded maxTotalBytes ${maxTotalBytes} during reads`,
|
||||
'total',
|
||||
);
|
||||
}
|
||||
|
||||
// Concat slices in one pass with a running output-size guard. The
|
||||
// guard counts both the original-html slices we preserve and the
|
||||
// replacement strings we inject. Exact final check; the
|
||||
// pre-buffer reservation above is approximate (raw asset size,
|
||||
// doesn't account for <style>/<script> wrapper overhead) so this
|
||||
// catches any drift.
|
||||
const parts: string[] = [];
|
||||
let cursor = 0;
|
||||
let totalBytes = 0;
|
||||
const guard = (segment: string) => {
|
||||
totalBytes += Buffer.byteLength(segment, 'utf8');
|
||||
if (totalBytes > maxTotalBytes) {
|
||||
throw new InlineAssetsLimitError(
|
||||
`assembled output ${totalBytes} bytes exceeds maxTotalBytes ${maxTotalBytes}`,
|
||||
'total',
|
||||
);
|
||||
}
|
||||
};
|
||||
for (let i = 0; i < pending.length; i++) {
|
||||
const { start, end } = pending[i]!;
|
||||
const replacement = replacements[i];
|
||||
const before = html.slice(cursor, start);
|
||||
guard(before);
|
||||
parts.push(before);
|
||||
const inject = replacement ?? html.slice(start, end);
|
||||
guard(inject);
|
||||
parts.push(inject);
|
||||
cursor = end;
|
||||
}
|
||||
const tail = html.slice(cursor);
|
||||
guard(tail);
|
||||
parts.push(tail);
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` over `items` with at most `limit` invocations in flight at
|
||||
* any moment. Order of `items` and of the returned results is
|
||||
* preserved. Used to bound concurrent fileReader calls so a project
|
||||
* with hundreds of `<link>`/`<script>` tags doesn't open hundreds of
|
||||
* file descriptors simultaneously.
|
||||
*/
|
||||
async function runWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
const out: R[] = new Array(items.length);
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
while (true) {
|
||||
const idx = next++;
|
||||
if (idx >= items.length) return;
|
||||
out[idx] = await fn(items[idx]!);
|
||||
}
|
||||
}
|
||||
const workers = Math.max(1, Math.min(limit, items.length));
|
||||
await Promise.all(Array.from({ length: workers }, () => worker()));
|
||||
return out;
|
||||
}
|
||||
|
||||
// Attrs that affect <style> semantics and must be carried across from
|
||||
// the source <link rel=stylesheet> so the inlined output matches the
|
||||
// behavior of the original URL-loaded stylesheet:
|
||||
// - media — media query (e.g. `media="print"` for print-only)
|
||||
// - title — alternate stylesheet sets
|
||||
// - disabled — boolean: initial disabled state
|
||||
// - nonce — CSP nonce passthrough
|
||||
// All four are valid on both <link rel=stylesheet> and <style>. Other
|
||||
// <link> attrs (rel, href, type, crossorigin, integrity, referrerpolicy)
|
||||
// don't apply to <style> and are intentionally dropped.
|
||||
const STYLE_PRESERVED_LINK_ATTRS = ['media', 'title', 'nonce'] as const;
|
||||
const STYLE_PRESERVED_BOOLEAN_ATTRS = ['disabled'] as const;
|
||||
|
||||
function buildInlineStyleBlock(tag: string, href: string, css: string): string {
|
||||
const carried: string[] = [];
|
||||
for (const name of STYLE_PRESERVED_LINK_ATTRS) {
|
||||
const value = readHtmlAttr(tag, name);
|
||||
if (value != null) carried.push(`${name}="${escapeHtmlAttr(value)}"`);
|
||||
}
|
||||
for (const name of STYLE_PRESERVED_BOOLEAN_ATTRS) {
|
||||
if (hasBooleanHtmlAttr(tag, name)) carried.push(name);
|
||||
}
|
||||
const attrString = carried.length === 0 ? '' : ` ${carried.join(' ')}`;
|
||||
return (
|
||||
`<style data-od-inline-asset="${escapeHtmlAttr(href)}"${attrString}>\n` +
|
||||
`${css.replace(/<\/style/gi, '<\\/style')}\n</style>`
|
||||
);
|
||||
}
|
||||
|
||||
function buildInlineScriptBlock(tag: string, js: string): string {
|
||||
const open = tag.match(/^<script\b[^>]*>/i)?.[0] ?? '<script>';
|
||||
const attrs = open
|
||||
.replace(/^<script/i, '')
|
||||
.replace(/>$/i, '')
|
||||
.replace(/\ssrc\s*=\s*(['"])[\s\S]*?\1/i, '');
|
||||
return `<script${attrs}>\n${js.replace(/<\/script/gi, '<\\/script')}\n</script>`;
|
||||
}
|
||||
|
||||
export function baseDirFor(fileName: string): string {
|
||||
const idx = fileName.lastIndexOf('/');
|
||||
return idx >= 0 ? fileName.slice(0, idx + 1) : '';
|
||||
}
|
||||
|
||||
export function resolveProjectRelativePath(
|
||||
ownerFileName: string,
|
||||
assetRef: string,
|
||||
): string | null {
|
||||
if (/^(?:https?:|data:|blob:|mailto:|tel:|#|\/)/i.test(assetRef)) return null;
|
||||
try {
|
||||
const url = new URL(assetRef, `https://od.local/${baseDirFor(ownerFileName)}`);
|
||||
if (url.origin !== 'https://od.local') return null;
|
||||
return decodeURIComponent(url.pathname.replace(/^\/+/, ''));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readHtmlAttr(tag: string, name: string): string | null {
|
||||
const match = tag.match(new RegExp(`\\s${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i'));
|
||||
return match?.[2] ?? null;
|
||||
}
|
||||
|
||||
// HTML boolean attribute presence test — matches `<tag … name>` or
|
||||
// `<tag … name=""…>` without requiring a value, but does NOT match a
|
||||
// substring inside another attribute's value (e.g. `data-note="content
|
||||
// disabled stuff"` must NOT count as `disabled` being set).
|
||||
//
|
||||
// Implementation: strip quoted attribute values out of the tag first
|
||||
// (replace `"…"` and `'…'` with empty quotes), then run the lookahead
|
||||
// regex over the remaining structural-attr-only string. The lookahead
|
||||
// requires `\s|=|/?>` after the attr name, so a bare `name`,
|
||||
// `name=""`, `name="…"`, or `name/>` all match — but a substring of
|
||||
// any value cannot match because values have been stripped.
|
||||
const ATTR_VALUE_QUOTE_DOUBLE_RE = /=\s*"[^"]*"/g;
|
||||
const ATTR_VALUE_QUOTE_SINGLE_RE = /=\s*'[^']*'/g;
|
||||
export function hasBooleanHtmlAttr(tag: string, name: string): boolean {
|
||||
const stripped = tag
|
||||
.replace(ATTR_VALUE_QUOTE_DOUBLE_RE, '=""')
|
||||
.replace(ATTR_VALUE_QUOTE_SINGLE_RE, "=''");
|
||||
return new RegExp(`\\s${name}(?=\\s|=|/?>)`, 'i').test(stripped);
|
||||
}
|
||||
|
||||
export function escapeHtmlAttr(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
|
|
@ -11,7 +11,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
|||
const { insertProject, validateLinkedDirs, getProject, updateProject, dbDeleteProject, removeProjectDir } = ctx.projectStore;
|
||||
const { writeProjectFile, readProjectFile, ensureProject, listFiles, listTabs, setTabs, resolveProjectDir } = ctx.projectFiles;
|
||||
const { insertConversation, getConversation, listConversations, updateConversation, deleteConversation, listMessages, upsertMessage, listPreviewComments, upsertPreviewComment, updatePreviewCommentStatus, deletePreviewComment } = ctx.conversations;
|
||||
const { getTemplate, listTemplates, deleteTemplate, insertTemplate } = ctx.templates;
|
||||
const { getTemplate, listTemplates, deleteTemplate, insertTemplate, findTemplateByNameAndProject, updateTemplate } = ctx.templates;
|
||||
const { listLatestProjectRunStatuses, listProjectsAwaitingInput, normalizeProjectDisplayStatus, composeProjectDisplayStatus, listProjects } = ctx.status;
|
||||
const { subscribeFileEvents, activeProjectEventSinks } = ctx.events;
|
||||
const { randomId } = ctx.ids;
|
||||
|
|
@ -505,6 +505,9 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
|||
if (typeof name !== 'string' || !name.trim()) {
|
||||
return res.status(400).json({ error: 'name required' });
|
||||
}
|
||||
if (name.length > 100) {
|
||||
return res.status(400).json({ error: 'name must be 100 characters or fewer' });
|
||||
}
|
||||
if (typeof sourceProjectId !== 'string') {
|
||||
return res.status(400).json({ error: 'sourceProjectId required' });
|
||||
}
|
||||
|
|
@ -535,14 +538,25 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
|||
});
|
||||
}
|
||||
}
|
||||
const t = insertTemplate(db, {
|
||||
id: randomId(),
|
||||
name: name.trim(),
|
||||
description: typeof description === 'string' ? description : null,
|
||||
sourceProjectId,
|
||||
files: snapshot,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
const trimmedName = name.trim();
|
||||
const descValue = typeof description === 'string' ? description : null;
|
||||
const existing = findTemplateByNameAndProject(db, trimmedName, sourceProjectId);
|
||||
let t;
|
||||
if (existing) {
|
||||
t = updateTemplate(db, existing.id, {
|
||||
description: descValue,
|
||||
files: snapshot,
|
||||
});
|
||||
} else {
|
||||
t = insertTemplate(db, {
|
||||
id: randomId(),
|
||||
name: trimmedName,
|
||||
description: descValue,
|
||||
sourceProjectId,
|
||||
files: snapshot,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
res.json({ template: t });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: String(err) });
|
||||
|
|
|
|||
|
|
@ -99,6 +99,22 @@ export interface ComposeInput {
|
|||
| undefined;
|
||||
designSystemBody?: string | undefined;
|
||||
designSystemTitle?: string | undefined;
|
||||
// Compiled (machine-readable) form of the active brand's design system,
|
||||
// shipped as sibling files to DESIGN.md when available. Both fields are
|
||||
// optional and only injected when the daemon is running with the
|
||||
// `OD_DESIGN_TOKEN_CHANNEL` env flag enabled (today's experimental
|
||||
// gate). When present they are appended AFTER the DESIGN.md block so
|
||||
// prose still sets the high-level voice and the structured form
|
||||
// disambiguates token names + worked component shapes.
|
||||
//
|
||||
// - `designSystemTokensCss` — verbatim `tokens.css` :root contract
|
||||
// that the agent pastes into the
|
||||
// artifact's <style>.
|
||||
// - `designSystemFixtureHtml` — verbatim `components.html` reference
|
||||
// fixture demonstrating button / card /
|
||||
// type-scale shapes wired to the tokens.
|
||||
designSystemTokensCss?: string | undefined;
|
||||
designSystemFixtureHtml?: string | undefined;
|
||||
// Craft references the active skill opted into via `od.craft.requires`.
|
||||
// The daemon resolves the slug list to file contents and concatenates
|
||||
// them with section headers; we inject them between the DESIGN.md and
|
||||
|
|
@ -151,6 +167,8 @@ export function composeSystemPrompt({
|
|||
skillMode,
|
||||
designSystemBody,
|
||||
designSystemTitle,
|
||||
designSystemTokensCss,
|
||||
designSystemFixtureHtml,
|
||||
craftBody,
|
||||
craftSections,
|
||||
memoryBody,
|
||||
|
|
@ -199,6 +217,27 @@ export function composeSystemPrompt({
|
|||
);
|
||||
}
|
||||
|
||||
// Structured (compiled) form of the active brand. The DESIGN.md above
|
||||
// sets voice and intent; the tokens.css block below is the SAME
|
||||
// contract in machine-readable form — names + values the agent pastes
|
||||
// verbatim instead of re-deriving from prose. The components.html
|
||||
// fixture grounds the token vocabulary in worked component shapes
|
||||
// (button / card / type roles) so the agent can copy fragments
|
||||
// directly. Both blocks are individually gated: missing files (today,
|
||||
// every brand except `default` and `kami`) skip silently, preserving
|
||||
// the legacy DESIGN.md-only behaviour for the other ~138 brands.
|
||||
if (designSystemTokensCss && designSystemTokensCss.trim().length > 0) {
|
||||
parts.push(
|
||||
`\n\n## Active design system tokens${designSystemTitle ? ` — ${designSystemTitle}` : ''}\n\nThe block below is this brand's tokens.css contract — every \`:root\` custom property and any scoped override (e.g. \`:root[lang=...]\`) the brand defines. **Paste the unscoped \`:root { ... }\` block verbatim into the artifact's first \`<style>\`** so every \`var(--*)\` reference resolves at runtime.\n\nDo not invent new tokens. Do not redefine these values. Do not write raw hex outside this :root block. The DESIGN.md above is prose; this is the binding contract.\n\n\`\`\`css\n${designSystemTokensCss.trim()}\n\`\`\``,
|
||||
);
|
||||
}
|
||||
|
||||
if (designSystemFixtureHtml && designSystemFixtureHtml.trim().length > 0) {
|
||||
parts.push(
|
||||
`\n\n## Reference fixture${designSystemTitle ? ` — ${designSystemTitle}` : ''}\n\nA self-contained worked artifact in this design system. Match its component shapes (button structure, card structure, type-scale rhythm, focus ring, spacing cadence) when generating new artifacts. Copying fragments is encouraged as long as you keep the \`var(--*)\` references intact — they are already wired to the tokens above.\n\n\`\`\`html\n${designSystemFixtureHtml.trim()}\n\`\`\``,
|
||||
);
|
||||
}
|
||||
|
||||
if (craftBody && craftBody.trim().length > 0) {
|
||||
const sectionLabel =
|
||||
Array.isArray(craftSections) && craftSections.length > 0
|
||||
|
|
|
|||
164
apps/daemon/src/runtimes/launch.ts
Normal file
164
apps/daemon/src/runtimes/launch.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { accessSync, constants, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
||||
import path, { delimiter } from 'node:path';
|
||||
import { inspectAgentExecutableResolution } from './executables.js';
|
||||
import type { RuntimeAgentDef } from './types.js';
|
||||
|
||||
export type AgentLaunchKind = 'selected' | 'codex-native';
|
||||
|
||||
export type AgentLaunchResolution = ReturnType<typeof inspectAgentExecutableResolution> & {
|
||||
launchPath: string | null;
|
||||
launchKind: AgentLaunchKind;
|
||||
childPathPrepend: string[];
|
||||
diagnostic: string | null;
|
||||
};
|
||||
|
||||
export function resolveAgentLaunch(
|
||||
def: RuntimeAgentDef,
|
||||
configuredEnv: Record<string, string> = {},
|
||||
): AgentLaunchResolution {
|
||||
const resolution = inspectAgentExecutableResolution(def, configuredEnv);
|
||||
if (!resolution.selectedPath) {
|
||||
return { ...resolution, launchPath: null, launchKind: 'selected', childPathPrepend: [], diagnostic: null };
|
||||
}
|
||||
const childPathPrepend = path.isAbsolute(resolution.selectedPath)
|
||||
? [path.dirname(resolution.selectedPath)]
|
||||
: [];
|
||||
if (def.id !== 'codex') {
|
||||
return { ...resolution, launchPath: resolution.selectedPath, launchKind: 'selected', childPathPrepend, diagnostic: null };
|
||||
}
|
||||
const native = tryResolveCodexNativeBinary(resolution.selectedPath);
|
||||
return {
|
||||
...resolution,
|
||||
launchPath: native.path ?? resolution.selectedPath,
|
||||
launchKind: native.path ? 'codex-native' : 'selected',
|
||||
childPathPrepend: [...childPathPrepend, ...native.childPathPrepend],
|
||||
diagnostic: native.diagnostic,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyAgentLaunchEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
launch: Pick<AgentLaunchResolution, 'childPathPrepend'>,
|
||||
): NodeJS.ProcessEnv {
|
||||
if (launch.childPathPrepend.length === 0) return env;
|
||||
const existing = typeof env.PATH === 'string' ? env.PATH : '';
|
||||
const PATH = [...launch.childPathPrepend, ...existing.split(delimiter)]
|
||||
.filter((entry, index, entries) => entry.length > 0 && entries.indexOf(entry) === index)
|
||||
.join(delimiter);
|
||||
return { ...env, PATH };
|
||||
}
|
||||
|
||||
function tryResolveCodexNativeBinary(wrapperPath: string): {
|
||||
path: string | null;
|
||||
childPathPrepend: string[];
|
||||
diagnostic: string | null;
|
||||
} {
|
||||
const packageSuffix = codexNativePackageSuffix();
|
||||
const targetTriple = codexNativeTargetTriple();
|
||||
for (const root of codexSearchRoots(wrapperPath)) {
|
||||
for (const candidate of codexNativeCandidates(root, packageSuffix, targetTriple)) {
|
||||
if (isExecutableFile(candidate.path)) {
|
||||
return { path: candidate.path, childPathPrepend: existingDirectories(candidate.childPathPrepend), diagnostic: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!looksLikeCodexNodeWrapper(wrapperPath)) return { path: null, childPathPrepend: [], diagnostic: null };
|
||||
return {
|
||||
path: null,
|
||||
childPathPrepend: [],
|
||||
diagnostic: `Codex native binary was not found for ${packageSuffix}/${targetTriple}; falling back to wrapper ${wrapperPath}. Set CODEX_BIN to a native Codex binary if this wrapper cannot launch from a GUI environment.`,
|
||||
};
|
||||
}
|
||||
|
||||
function codexSearchRoots(wrapperPath: string): string[] {
|
||||
const roots = new Set<string>();
|
||||
for (const seed of [wrapperPath, safeRealpath(wrapperPath)]) {
|
||||
if (!seed) continue;
|
||||
let current = path.dirname(seed);
|
||||
while (current !== path.dirname(current)) {
|
||||
roots.add(current);
|
||||
current = path.dirname(current);
|
||||
}
|
||||
}
|
||||
return [...roots];
|
||||
}
|
||||
|
||||
function codexNativeCandidates(
|
||||
root: string,
|
||||
packageSuffix: string,
|
||||
targetTriple: string,
|
||||
): Array<{ path: string; childPathPrepend: string[] }> {
|
||||
const scoped = path.join(root, 'node_modules', '@openai');
|
||||
const packageDirs = [path.join(scoped, `codex-${packageSuffix}`)];
|
||||
try {
|
||||
for (const entry of readdirSync(scoped, { encoding: 'utf8', withFileTypes: true })) {
|
||||
if (entry.isDirectory() && entry.name.startsWith('codex-')) packageDirs.push(path.join(scoped, entry.name));
|
||||
}
|
||||
} catch {
|
||||
// Optional package layouts vary by npm version; absence uses wrapper fallback.
|
||||
}
|
||||
return [...new Set(packageDirs)].flatMap((dir) => {
|
||||
const vendorPathDir = path.join(dir, 'vendor', targetTriple, 'path');
|
||||
const childPathPrepend = [vendorPathDir];
|
||||
return [
|
||||
{ path: path.join(dir, 'vendor', targetTriple, 'codex', 'codex'), childPathPrepend },
|
||||
{ path: path.join(dir, 'vendor', targetTriple, 'codex', 'codex.exe'), childPathPrepend },
|
||||
{ path: path.join(dir, 'codex'), childPathPrepend },
|
||||
{ path: path.join(dir, 'bin', 'codex'), childPathPrepend },
|
||||
{ path: path.join(dir, 'vendor', 'codex'), childPathPrepend },
|
||||
{ path: path.join(dir, 'codex.exe'), childPathPrepend },
|
||||
{ path: path.join(dir, 'bin', 'codex.exe'), childPathPrepend },
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function codexNativePackageSuffix(): string {
|
||||
return `${process.platform}-${process.arch}`;
|
||||
}
|
||||
|
||||
function codexNativeTargetTriple(): string {
|
||||
if (process.platform === 'darwin' && process.arch === 'arm64') return 'aarch64-apple-darwin';
|
||||
if (process.platform === 'darwin' && process.arch === 'x64') return 'x86_64-apple-darwin';
|
||||
if (process.platform === 'linux' && process.arch === 'arm64') return 'aarch64-unknown-linux-musl';
|
||||
if (process.platform === 'linux' && process.arch === 'x64') return 'x86_64-unknown-linux-musl';
|
||||
if (process.platform === 'win32' && process.arch === 'arm64') return 'aarch64-pc-windows-msvc';
|
||||
if (process.platform === 'win32' && process.arch === 'x64') return 'x86_64-pc-windows-msvc';
|
||||
return `${process.platform}-${process.arch}`;
|
||||
}
|
||||
|
||||
function looksLikeCodexNodeWrapper(filePath: string): boolean {
|
||||
try {
|
||||
const body = readFileSync(filePath, { encoding: 'utf8' }).slice(0, 64_000);
|
||||
return /node|@openai\/codex|codex-/i.test(body);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function safeRealpath(filePath: string): string | null {
|
||||
try {
|
||||
return realpathSync(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function existingDirectories(dirs: string[]): string[] {
|
||||
return dirs.filter((dir) => {
|
||||
try {
|
||||
return statSync(dir).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isExecutableFile(filePath: string): boolean {
|
||||
try {
|
||||
if (!statSync(filePath).isFile()) return false;
|
||||
if (process.platform !== 'win32') accessSync(filePath, constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,8 @@ import {
|
|||
detectAgents,
|
||||
getAgentDef,
|
||||
isKnownModel,
|
||||
resolveAgentBin,
|
||||
applyAgentLaunchEnv,
|
||||
resolveAgentLaunch,
|
||||
sanitizeCustomModel,
|
||||
spawnEnvForAgent,
|
||||
} from './agents.js';
|
||||
|
|
@ -37,7 +38,7 @@ import { installFromTarget, uninstallById, sanitizeRepoName } from './library-in
|
|||
import { buildWindowsFolderDialogCommand, parseFolderDialogStdout } from './native-folder-dialog.js';
|
||||
import { listCodexPets, readCodexPetSpritesheet } from './codex-pets.js';
|
||||
import { syncCommunityPets } from './community-pets-sync.js';
|
||||
import { listDesignSystems, readDesignSystem } from './design-systems.js';
|
||||
import { listDesignSystems, readDesignSystem, readDesignSystemAssets } from './design-systems.js';
|
||||
import {
|
||||
composeMemoryBody,
|
||||
deleteMemoryEntry,
|
||||
|
|
@ -189,6 +190,8 @@ import {
|
|||
insertRoutine,
|
||||
insertRoutineRun,
|
||||
insertTemplate,
|
||||
findTemplateByNameAndProject,
|
||||
updateTemplate,
|
||||
listProjectsAwaitingInput,
|
||||
listConversations,
|
||||
listDeployments,
|
||||
|
|
@ -2748,7 +2751,7 @@ export async function startServer({
|
|||
updatePreviewCommentStatus,
|
||||
deletePreviewComment,
|
||||
};
|
||||
const templateDeps = { getTemplate, listTemplates, deleteTemplate, insertTemplate };
|
||||
const templateDeps = { getTemplate, listTemplates, deleteTemplate, insertTemplate, findTemplateByNameAndProject, updateTemplate };
|
||||
const projectStatusDeps = {
|
||||
listLatestProjectRunStatuses,
|
||||
listProjectsAwaitingInput,
|
||||
|
|
@ -2961,6 +2964,8 @@ export async function startServer({
|
|||
paths: pathDeps,
|
||||
projectStore: projectStoreDeps,
|
||||
exports: projectExportDeps,
|
||||
projectFiles: projectFileDeps,
|
||||
validation: validationDeps,
|
||||
});
|
||||
registerProjectFileRoutes(app, {
|
||||
db,
|
||||
|
|
@ -3056,6 +3061,15 @@ export async function startServer({
|
|||
|
||||
let designSystemBody;
|
||||
let designSystemTitle;
|
||||
// Compiled (tokens.css + components.html) form of the active brand.
|
||||
// Gated by `OD_DESIGN_TOKEN_CHANNEL` while the experiment is in the
|
||||
// smoke-test phase: flag-off keeps the daemon byte-equivalent to the
|
||||
// pre-PR-C path; flag-on appends the tokens contract + reference
|
||||
// fixture to the system prompt for any brand that ships those files
|
||||
// (today: `default` and `kami`; every other brand falls through
|
||||
// silently because the files are absent).
|
||||
let designSystemTokensCss;
|
||||
let designSystemFixtureHtml;
|
||||
if (effectiveDesignSystemId) {
|
||||
const systems = await listAllDesignSystems();
|
||||
const summary = systems.find((s) => s.id === effectiveDesignSystemId);
|
||||
|
|
@ -3064,6 +3078,23 @@ export async function startServer({
|
|||
(await readDesignSystem(DESIGN_SYSTEMS_DIR, effectiveDesignSystemId)) ??
|
||||
(await readDesignSystem(USER_DESIGN_SYSTEMS_DIR, effectiveDesignSystemId)) ??
|
||||
undefined;
|
||||
if (process.env.OD_DESIGN_TOKEN_CHANNEL === '1') {
|
||||
// Try built-in dir first, then user-installed dir, mirroring the
|
||||
// DESIGN.md fallback chain above. Any individual file may be
|
||||
// missing (e.g. tokens.css present, components.html absent); the
|
||||
// composer gates each block independently.
|
||||
const builtIn = await readDesignSystemAssets(DESIGN_SYSTEMS_DIR, effectiveDesignSystemId);
|
||||
const installed = builtIn.tokensCss && builtIn.fixtureHtml
|
||||
? builtIn
|
||||
: {
|
||||
tokensCss: builtIn.tokensCss
|
||||
?? (await readDesignSystemAssets(USER_DESIGN_SYSTEMS_DIR, effectiveDesignSystemId)).tokensCss,
|
||||
fixtureHtml: builtIn.fixtureHtml
|
||||
?? (await readDesignSystemAssets(USER_DESIGN_SYSTEMS_DIR, effectiveDesignSystemId)).fixtureHtml,
|
||||
};
|
||||
designSystemTokensCss = installed.tokensCss;
|
||||
designSystemFixtureHtml = installed.fixtureHtml;
|
||||
}
|
||||
}
|
||||
|
||||
const template =
|
||||
|
|
@ -3124,6 +3155,8 @@ export async function startServer({
|
|||
skillMode,
|
||||
designSystemBody,
|
||||
designSystemTitle,
|
||||
designSystemTokensCss,
|
||||
designSystemFixtureHtml,
|
||||
craftBody,
|
||||
craftSections,
|
||||
memoryBody,
|
||||
|
|
@ -3622,7 +3655,8 @@ export async function startServer({
|
|||
configuredAgentEnv = {};
|
||||
}
|
||||
|
||||
const resolvedBin = resolveAgentBin(agentId, configuredAgentEnv);
|
||||
const agentLaunch = resolveAgentLaunch(def, configuredAgentEnv);
|
||||
const resolvedBin = agentLaunch.selectedPath;
|
||||
|
||||
const args = def.buildArgs(
|
||||
composed,
|
||||
|
|
@ -3644,7 +3678,7 @@ export async function startServer({
|
|||
// doesn't have to special-case it.
|
||||
const cmdShimBudgetError = checkWindowsCmdShimCommandLineBudget(
|
||||
def,
|
||||
resolvedBin,
|
||||
agentLaunch.launchPath ?? resolvedBin,
|
||||
args,
|
||||
);
|
||||
if (cmdShimBudgetError) {
|
||||
|
|
@ -3671,7 +3705,7 @@ export async function startServer({
|
|||
// users hit a generic `spawn ENAMETOOLONG`.
|
||||
const directExeBudgetError = checkWindowsDirectExeCommandLineBudget(
|
||||
def,
|
||||
resolvedBin,
|
||||
agentLaunch.launchPath ?? resolvedBin,
|
||||
args,
|
||||
);
|
||||
if (directExeBudgetError) {
|
||||
|
|
@ -3763,7 +3797,7 @@ export async function startServer({
|
|||
// pointing at /api/agents instead of silently falling back to
|
||||
// spawn(def.bin) — that fallback re-introduces the exact ENOENT symptom
|
||||
// from issue #10.
|
||||
if (!resolvedBin) {
|
||||
if (!resolvedBin || !agentLaunch.launchPath) {
|
||||
revokeToolToken('child_exit');
|
||||
unregisterChatAgentEventSink();
|
||||
send('error', createSseErrorPayload(
|
||||
|
|
@ -3819,7 +3853,7 @@ export async function startServer({
|
|||
def.promptViaStdin || def.streamFormat === 'acp-json-rpc'
|
||||
? 'pipe'
|
||||
: 'ignore';
|
||||
const env = {
|
||||
const env = applyAgentLaunchEnv({
|
||||
...spawnEnvForAgent(
|
||||
def.id,
|
||||
{
|
||||
|
|
@ -3829,10 +3863,10 @@ export async function startServer({
|
|||
configuredAgentEnv,
|
||||
),
|
||||
...odMediaEnv,
|
||||
};
|
||||
}, agentLaunch);
|
||||
spawnedAgentEnv = env;
|
||||
const invocation = createCommandInvocation({
|
||||
command: resolvedBin,
|
||||
command: agentLaunch.launchPath,
|
||||
args,
|
||||
env,
|
||||
});
|
||||
|
|
@ -4228,9 +4262,27 @@ export async function startServer({
|
|||
));
|
||||
return design.runs.finish(run, 'failed', code, signal);
|
||||
}
|
||||
// ACP agents that don't shut down on stdin.end() (e.g. Devin for
|
||||
// Terminal) are forced to exit via SIGTERM from attachAcpSession after
|
||||
// a clean prompt completion. Without an override, the chat run would
|
||||
// be marked `failed` because `code === 0` fails (code is null on a
|
||||
// signal exit). `completedSuccessfully()` reports whether the ACP
|
||||
// session resolved without a fatal error or abort.
|
||||
//
|
||||
// Scope the override narrowly to the exact forced-shutdown shape this
|
||||
// PR introduces: code is null AND signal is SIGTERM AND the ACP
|
||||
// session reported clean completion. Any other post-response failure
|
||||
// (non-zero exit code, SIGKILL, SIGSEGV, etc.) still propagates as
|
||||
// `failed`, preserving the existing close-status behavior for genuine
|
||||
// post-response process problems.
|
||||
const acpCleanCompletion =
|
||||
typeof acpSession?.completedSuccessfully === 'function' &&
|
||||
acpSession.completedSuccessfully();
|
||||
const acpForcedShutdown =
|
||||
code === null && signal === 'SIGTERM' && acpCleanCompletion;
|
||||
const status = run.cancelRequested
|
||||
? 'canceled'
|
||||
: code === 0
|
||||
: code === 0 || acpForcedShutdown
|
||||
? 'succeeded'
|
||||
: 'failed';
|
||||
if (status === 'failed') {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
|||
import { EventEmitter } from 'node:events';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import path from 'node:path';
|
||||
import { test } from 'vitest';
|
||||
import { test, vi } from 'vitest';
|
||||
import { attachAcpSession, buildAcpSessionNewParams, normalizeModels } from '../src/acp.js';
|
||||
|
||||
const DEFAULT_MODEL_OPTION = { id: 'default', label: 'Default (CLI config)' };
|
||||
|
|
@ -253,6 +253,101 @@ function agentModelStatuses(events: Array<{ event: string; payload: unknown }>):
|
|||
.map((entry) => (entry.payload as { model?: unknown }).model);
|
||||
}
|
||||
|
||||
test('attachAcpSession force-terminates the child after a clean prompt completion if it does not exit on stdin.end()', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const child = new FakeAcpChild();
|
||||
|
||||
const session = attachAcpSession({
|
||||
child: child as never,
|
||||
prompt: 'hello',
|
||||
cwd: '/tmp/od-project',
|
||||
model: null,
|
||||
mcpServers: [],
|
||||
send: () => {},
|
||||
});
|
||||
|
||||
// Drive the protocol through to a clean prompt completion.
|
||||
child.stdout.write(`${JSON.stringify({ id: 1, result: {} })}\n`);
|
||||
child.stdout.write(`${JSON.stringify({ id: 2, result: { sessionId: 'session-1' } })}\n`);
|
||||
child.stdout.write(`${JSON.stringify({ id: 3, result: { usage: { inputTokens: 1, outputTokens: 2 } } })}\n`);
|
||||
|
||||
// Child has not exited yet (simulates Devin for Terminal keeping the
|
||||
// process alive past stdin.end()).
|
||||
assert.equal(child.killed, false);
|
||||
|
||||
// After the grace period elapses, attachAcpSession should SIGTERM the
|
||||
// child so child.on('close') can fire and the chat run can finalize.
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
assert.equal(child.killed, true);
|
||||
|
||||
// The session reports the prompt completed successfully so the consumer
|
||||
// can mark the run as 'succeeded' even though the underlying exit was
|
||||
// signal-driven.
|
||||
assert.equal(session.completedSuccessfully(), true);
|
||||
assert.equal(session.hasFatalError(), false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test('attachAcpSession does not double-kill a child that exits cleanly on stdin.end()', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const child = new FakeAcpChild();
|
||||
|
||||
attachAcpSession({
|
||||
child: child as never,
|
||||
prompt: 'hello',
|
||||
cwd: '/tmp/od-project',
|
||||
model: null,
|
||||
mcpServers: [],
|
||||
send: () => {},
|
||||
});
|
||||
|
||||
child.stdout.write(`${JSON.stringify({ id: 1, result: {} })}\n`);
|
||||
child.stdout.write(`${JSON.stringify({ id: 2, result: { sessionId: 'session-1' } })}\n`);
|
||||
child.stdout.write(`${JSON.stringify({ id: 3, result: {} })}\n`);
|
||||
|
||||
// Well-behaved agent exits on its own before the grace period elapses.
|
||||
child.emit('close', 0, null);
|
||||
assert.equal(child.killed, false);
|
||||
|
||||
// The grace-period timer should have been cleared by the close handler,
|
||||
// so advancing time should not trigger a SIGTERM on the now-closed child.
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
assert.equal(child.killed, false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test('attachAcpSession.completedSuccessfully reflects abort and fatal-error states', () => {
|
||||
const child = new FakeAcpChild();
|
||||
|
||||
const session = attachAcpSession({
|
||||
child: child as never,
|
||||
prompt: 'hello',
|
||||
cwd: '/tmp/od-project',
|
||||
model: null,
|
||||
mcpServers: [],
|
||||
send: () => {},
|
||||
});
|
||||
|
||||
// Before any protocol traffic the session is not yet complete.
|
||||
assert.equal(session.completedSuccessfully(), false);
|
||||
|
||||
// Drive through session creation, then abort before the prompt completes.
|
||||
child.stdout.write(`${JSON.stringify({ id: 1, result: {} })}\n`);
|
||||
child.stdout.write(`${JSON.stringify({ id: 2, result: { sessionId: 'session-1' } })}\n`);
|
||||
session.abort();
|
||||
|
||||
// Aborted runs are not "successful completions" even though `finished` is
|
||||
// set internally — the consumer should treat them as canceled, not
|
||||
// succeeded.
|
||||
assert.equal(session.completedSuccessfully(), false);
|
||||
});
|
||||
|
||||
class FakeAcpChild extends EventEmitter {
|
||||
stdin = new PassThrough();
|
||||
stdout = new PassThrough();
|
||||
|
|
|
|||
|
|
@ -128,6 +128,27 @@ describe('preview comment persistence', () => {
|
|||
|
||||
expect(listMessages(db, 'conversation-1')[0]?.commentAttachments).toEqual([attachment]);
|
||||
});
|
||||
|
||||
it('persists assistant feedback on messages', () => {
|
||||
const db = seededDb();
|
||||
const feedback = {
|
||||
rating: 'positive' as const,
|
||||
reasonCodes: ['matched_request', 'other'],
|
||||
customReason: 'The output was ready to present.',
|
||||
reasonsSubmittedAt: 1_700_000_000_400,
|
||||
createdAt: 1_700_000_000_000,
|
||||
updatedAt: 1_700_000_000_500,
|
||||
};
|
||||
|
||||
upsertMessage(db, 'conversation-1', {
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
content: 'Done',
|
||||
feedback,
|
||||
});
|
||||
|
||||
expect(listMessages(db, 'conversation-1')[0]?.feedback).toEqual(feedback);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preview comment agent payload', () => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
createAgentSink,
|
||||
isSmokeOkReply,
|
||||
redactSecrets,
|
||||
resolveConnectionTestTimeoutMs,
|
||||
testAgentConnection,
|
||||
testProviderConnection,
|
||||
} from '../src/connectionTest.js';
|
||||
|
|
@ -1732,3 +1733,77 @@ describe('connection test helpers', () => {
|
|||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('connection test timeout overrides', () => {
|
||||
it('returns the fallback when the override is missing or empty', () => {
|
||||
expect(
|
||||
resolveConnectionTestTimeoutMs('OD_CONNECTION_TEST_PROVIDER_TIMEOUT_MS', 12_000, {}),
|
||||
).toBe(12_000);
|
||||
expect(
|
||||
resolveConnectionTestTimeoutMs('OD_CONNECTION_TEST_AGENT_TIMEOUT_MS', 45_000, {
|
||||
OD_CONNECTION_TEST_AGENT_TIMEOUT_MS: '',
|
||||
}),
|
||||
).toBe(45_000);
|
||||
});
|
||||
|
||||
it('honors a positive integer override', () => {
|
||||
expect(
|
||||
resolveConnectionTestTimeoutMs('OD_CONNECTION_TEST_PROVIDER_TIMEOUT_MS', 12_000, {
|
||||
OD_CONNECTION_TEST_PROVIDER_TIMEOUT_MS: '30000',
|
||||
}),
|
||||
).toBe(30_000);
|
||||
expect(
|
||||
resolveConnectionTestTimeoutMs('OD_CONNECTION_TEST_AGENT_TIMEOUT_MS', 45_000, {
|
||||
OD_CONNECTION_TEST_AGENT_TIMEOUT_MS: '120000',
|
||||
}),
|
||||
).toBe(120_000);
|
||||
});
|
||||
|
||||
it('warns and falls back on non-numeric, zero, negative, or non-integer overrides', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
try {
|
||||
for (const bad of ['fast', '0', '-1', '1.5', 'NaN']) {
|
||||
expect(
|
||||
resolveConnectionTestTimeoutMs('OD_CONNECTION_TEST_PROVIDER_TIMEOUT_MS', 12_000, {
|
||||
OD_CONNECTION_TEST_PROVIDER_TIMEOUT_MS: bad,
|
||||
}),
|
||||
).toBe(12_000);
|
||||
}
|
||||
expect(warn).toHaveBeenCalled();
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// Regression: a previous version of resolveConnectionTestTimeoutMs
|
||||
// accepted any positive integer, but Node's setTimeout silently
|
||||
// clamps delays above 2^31-1 to ~1 ms (with a TimeoutOverflowWarning).
|
||||
// An override that meant to extend the budget would instead make
|
||||
// every connection test fail almost immediately — the safety
|
||||
// timeout would be effectively disarmed.
|
||||
it('rejects values above the Node setTimeout maximum (2^31-1)', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
try {
|
||||
const tooLarge = '3000000000'; // ~50 minutes; exceeds 2_147_483_647 ms
|
||||
expect(
|
||||
resolveConnectionTestTimeoutMs('OD_CONNECTION_TEST_AGENT_TIMEOUT_MS', 45_000, {
|
||||
OD_CONNECTION_TEST_AGENT_TIMEOUT_MS: tooLarge,
|
||||
}),
|
||||
).toBe(45_000);
|
||||
// The exact maximum is still accepted; anything past it is not.
|
||||
expect(
|
||||
resolveConnectionTestTimeoutMs('OD_CONNECTION_TEST_AGENT_TIMEOUT_MS', 45_000, {
|
||||
OD_CONNECTION_TEST_AGENT_TIMEOUT_MS: '2147483647',
|
||||
}),
|
||||
).toBe(2_147_483_647);
|
||||
expect(
|
||||
resolveConnectionTestTimeoutMs('OD_CONNECTION_TEST_AGENT_TIMEOUT_MS', 45_000, {
|
||||
OD_CONNECTION_TEST_AGENT_TIMEOUT_MS: '2147483648',
|
||||
}),
|
||||
).toBe(45_000);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
114
apps/daemon/tests/design-system-assets.test.ts
Normal file
114
apps/daemon/tests/design-system-assets.test.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// Focused test for readDesignSystemAssets — the new sibling-file reader
|
||||
// that lets the daemon ship the compiled (tokens.css + components.html)
|
||||
// form of a brand alongside its DESIGN.md prose. The legacy reader
|
||||
// (`readDesignSystem`, returning DESIGN.md content) already has implicit
|
||||
// coverage through the showcase + chat-route tests; this file pins the
|
||||
// new helper's contract so future changes can't silently regress the
|
||||
// "either or both files may be absent" semantics that PR-C relies on
|
||||
// for graceful fallback across the ~138 brands without compiled tokens
|
||||
// today.
|
||||
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { readDesignSystemAssets } from '../src/design-systems.js';
|
||||
|
||||
function fresh(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), 'od-design-system-assets-'));
|
||||
}
|
||||
|
||||
function brandDir(root: string, id: string): string {
|
||||
const dir = path.join(root, id);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('readDesignSystemAssets', () => {
|
||||
it('returns both fields when tokens.css and components.html are both present', async () => {
|
||||
const root = fresh();
|
||||
const dir = brandDir(root, 'sample');
|
||||
writeFileSync(path.join(dir, 'tokens.css'), ':root {\n --bg: #fff;\n}\n');
|
||||
writeFileSync(
|
||||
path.join(dir, 'components.html'),
|
||||
'<!doctype html><html><body>fixture</body></html>\n',
|
||||
);
|
||||
|
||||
const assets = await readDesignSystemAssets(root, 'sample');
|
||||
expect(assets.tokensCss).toContain('--bg: #fff');
|
||||
expect(assets.fixtureHtml).toContain('fixture');
|
||||
});
|
||||
|
||||
it('returns the single field that exists when its sibling is missing (per-file independence)', async () => {
|
||||
const root = fresh();
|
||||
const dir = brandDir(root, 'tokens-only');
|
||||
writeFileSync(path.join(dir, 'tokens.css'), ':root { --x: 1; }');
|
||||
|
||||
const tokensOnly = await readDesignSystemAssets(root, 'tokens-only');
|
||||
expect(tokensOnly.tokensCss).toBe(':root { --x: 1; }');
|
||||
expect(tokensOnly.fixtureHtml).toBeUndefined();
|
||||
|
||||
const fixtureDir = brandDir(root, 'fixture-only');
|
||||
writeFileSync(path.join(fixtureDir, 'components.html'), '<p>only</p>');
|
||||
|
||||
const fixtureOnly = await readDesignSystemAssets(root, 'fixture-only');
|
||||
expect(fixtureOnly.tokensCss).toBeUndefined();
|
||||
expect(fixtureOnly.fixtureHtml).toBe('<p>only</p>');
|
||||
});
|
||||
|
||||
it('returns an empty object when the brand directory has neither file', async () => {
|
||||
const root = fresh();
|
||||
brandDir(root, 'prose-only');
|
||||
|
||||
const assets = await readDesignSystemAssets(root, 'prose-only');
|
||||
expect(assets.tokensCss).toBeUndefined();
|
||||
expect(assets.fixtureHtml).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns an empty object when the brand directory itself does not exist (legacy ~138-brand fallback)', async () => {
|
||||
const root = fresh();
|
||||
const assets = await readDesignSystemAssets(root, 'nonexistent-brand');
|
||||
expect(assets.tokensCss).toBeUndefined();
|
||||
expect(assets.fixtureHtml).toBeUndefined();
|
||||
});
|
||||
|
||||
// Reviewer feedback (nettee, PR-C #1385): the prior implementation
|
||||
// swallowed every readFile() error as "absent", which would silently
|
||||
// hide non-absence failures (EACCES, EISDIR, broken packaged
|
||||
// resource paths, transient I/O) and ship the legacy DESIGN.md-only
|
||||
// prompt as if the token channel had succeeded. That corrupts the
|
||||
// exact signal the smoke-test rollout depends on. The reader now
|
||||
// only swallows ENOENT / ENOTDIR; everything else must surface.
|
||||
it('rejects on non-absence read failures so token-channel misconfigurations surface', async () => {
|
||||
const root = fresh();
|
||||
const dir = brandDir(root, 'broken-tokens');
|
||||
// Plant a DIRECTORY at the tokens.css path. readFile() rejects
|
||||
// with EISDIR — a real-world stand-in for permission / packaged-
|
||||
// resource path bugs that should fail visibly, not silently fall
|
||||
// back. EACCES would be more lifelike but is hard to simulate
|
||||
// portably across CI runners; EISDIR exercises the exact same
|
||||
// "non-absence error" branch.
|
||||
mkdirSync(path.join(dir, 'tokens.css'));
|
||||
|
||||
await expect(readDesignSystemAssets(root, 'broken-tokens')).rejects.toThrow(
|
||||
/EISDIR|illegal operation|directory/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('still treats ENOENT as absence even when one sibling is present (per-file independence holds under the stricter contract)', async () => {
|
||||
// Pin the flip side of the rejection test above: tightening the
|
||||
// catch must NOT regress the legacy ~138-brand fallback. With
|
||||
// tokens.css present and components.html absent, the reader
|
||||
// returns the present side and undefined for the missing one,
|
||||
// exactly as before.
|
||||
const root = fresh();
|
||||
const dir = brandDir(root, 'partial');
|
||||
writeFileSync(path.join(dir, 'tokens.css'), ':root { --x: 1; }');
|
||||
|
||||
const assets = await readDesignSystemAssets(root, 'partial');
|
||||
expect(assets.tokensCss).toBe(':root { --x: 1; }');
|
||||
expect(assets.fixtureHtml).toBeUndefined();
|
||||
});
|
||||
});
|
||||
686
apps/daemon/tests/export-inline-route.test.ts
Normal file
686
apps/daemon/tests/export-inline-route.test.ts
Normal file
|
|
@ -0,0 +1,686 @@
|
|||
import type http from 'node:http';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { inlineRelativeAssets, type InlineAssetReader } from '../src/inline-assets.js';
|
||||
import { startServer } from '../src/server.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit — inlineRelativeAssets pure helper
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// These tests pin the behavior contract documented in
|
||||
// `~/.claude/plans/declarative-roaming-gosling.md` §2.3. The helper is a
|
||||
// server-side port of the web-client logic at `apps/web/src/components/
|
||||
// FileViewer.tsx:5248-5354` (@ base SHA 5bd97631); the divergence from
|
||||
// `FileViewer.tsx:5313` (replace-all vs first-match) is locked decision §3.3.
|
||||
|
||||
function readerFrom(files: Record<string, string>) {
|
||||
return async (relPath: string) => {
|
||||
const value = files[relPath];
|
||||
if (typeof value !== 'string') return null;
|
||||
return {
|
||||
size: Buffer.byteLength(value, 'utf8'),
|
||||
read: async () => value,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
describe('inlineRelativeAssets', () => {
|
||||
it('inlines a single <link rel=stylesheet> with verbatim CSS body', async () => {
|
||||
const html =
|
||||
'<!doctype html><html><head><link rel="stylesheet" href="a.css"></head><body></body></html>';
|
||||
const out = await inlineRelativeAssets(html, 'index.html', readerFrom({ 'a.css': 'body{color:red}' }));
|
||||
expect(out).toContain('<style data-od-inline-asset="a.css">');
|
||||
expect(out).toContain('body{color:red}');
|
||||
expect(out).not.toContain('<link rel="stylesheet" href="a.css">');
|
||||
});
|
||||
|
||||
it('inlines a <script src> preserving non-src attrs (type=module, defer, crossorigin)', async () => {
|
||||
const html =
|
||||
'<html><head><script type="module" defer crossorigin src="x.js"></script></head></html>';
|
||||
const out = await inlineRelativeAssets(html, 'index.html', readerFrom({ 'x.js': 'console.log(1)' }));
|
||||
expect(out).toMatch(/<script[^>]*type="module"[^>]*>/);
|
||||
expect(out).toMatch(/<script[^>]*\bdefer\b[^>]*>/);
|
||||
expect(out).toMatch(/<script[^>]*\bcrossorigin\b[^>]*>/);
|
||||
expect(out).toContain('console.log(1)');
|
||||
expect(out).not.toContain('src="x.js"');
|
||||
});
|
||||
|
||||
it('resolves relative paths for both nested and root owners', async () => {
|
||||
const nestedOut = await inlineRelativeAssets(
|
||||
'<script src="../shared/util.js"></script>',
|
||||
'pages/index.html',
|
||||
readerFrom({ 'shared/util.js': 'export const x = 1;' }),
|
||||
);
|
||||
expect(nestedOut).toContain('export const x = 1;');
|
||||
|
||||
const rootOut = await inlineRelativeAssets(
|
||||
'<link rel="stylesheet" href="a.css">',
|
||||
'index.html',
|
||||
readerFrom({ 'a.css': '.root{}' }),
|
||||
);
|
||||
expect(rootOut).toContain('.root{}');
|
||||
});
|
||||
|
||||
it('handles self-closing <link …/> form', async () => {
|
||||
const html = '<link rel="stylesheet" href="a.css" />';
|
||||
const out = await inlineRelativeAssets(html, 'index.html', readerFrom({ 'a.css': '/*ok*/' }));
|
||||
expect(out).toContain('/*ok*/');
|
||||
expect(out).not.toContain('href="a.css"');
|
||||
});
|
||||
|
||||
it("accepts single-quoted attrs (href='a.css')", async () => {
|
||||
const html = `<link rel='stylesheet' href='a.css'>`;
|
||||
const out = await inlineRelativeAssets(html, 'index.html', readerFrom({ 'a.css': '/*single*/' }));
|
||||
expect(out).toContain('/*single*/');
|
||||
});
|
||||
|
||||
it('does NOT rewrite a <link> tag without a rel attribute', async () => {
|
||||
const html = '<link href="a.css">';
|
||||
const out = await inlineRelativeAssets(html, 'index.html', readerFrom({ 'a.css': '.x{}' }));
|
||||
expect(out).toBe(html);
|
||||
});
|
||||
|
||||
it('does NOT rewrite <link rel="preload"> (only rel=stylesheet)', async () => {
|
||||
const html = '<link rel="preload" href="x.css">';
|
||||
const out = await inlineRelativeAssets(html, 'index.html', readerFrom({ 'x.css': '.x{}' }));
|
||||
expect(out).toBe(html);
|
||||
});
|
||||
|
||||
it('does NOT rewrite absolute / data / blob / mailto / tel / anchor / leading-slash refs', async () => {
|
||||
const cases = [
|
||||
'<link rel="stylesheet" href="https://cdn.example.com/x.css">',
|
||||
'<link rel="stylesheet" href="http://cdn.example.com/x.css">',
|
||||
'<link rel="stylesheet" href="data:text/css,body{}">',
|
||||
'<link rel="stylesheet" href="blob:abc">',
|
||||
'<link rel="stylesheet" href="/abs/path.css">',
|
||||
'<script src="https://cdn.example.com/x.js"></script>',
|
||||
'<script src="data:text/javascript,1+1"></script>',
|
||||
'<script src="/abs/x.js"></script>',
|
||||
];
|
||||
const reader = readerFrom({}); // never called
|
||||
for (const html of cases) {
|
||||
const out = await inlineRelativeAssets(html, 'index.html', reader);
|
||||
expect(out).toBe(html);
|
||||
}
|
||||
});
|
||||
|
||||
it('escapes </style inside CSS body to <\\/style', async () => {
|
||||
const css = 'body::before{content:"</style>"}';
|
||||
const out = await inlineRelativeAssets(
|
||||
'<link rel="stylesheet" href="a.css">',
|
||||
'index.html',
|
||||
readerFrom({ 'a.css': css }),
|
||||
);
|
||||
expect(out).toContain('<\\/style');
|
||||
expect(out).not.toMatch(/<\/style[^>]*?>\s*<\/style>/);
|
||||
expect(out.match(/<\/style>/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
it('escapes </script inside JS body to <\\/script', async () => {
|
||||
const js = 'const x = "</script>"';
|
||||
const out = await inlineRelativeAssets(
|
||||
'<script src="x.js"></script>',
|
||||
'index.html',
|
||||
readerFrom({ 'x.js': js }),
|
||||
);
|
||||
expect(out).toContain('<\\/script');
|
||||
expect(out.match(/<\/script>/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves tag intact when fileReader returns null, but still inlines other assets', async () => {
|
||||
const html =
|
||||
'<link rel="stylesheet" href="missing.css"><script src="present.js"></script>';
|
||||
const out = await inlineRelativeAssets(
|
||||
html,
|
||||
'index.html',
|
||||
readerFrom({ 'present.js': 'ok' }),
|
||||
);
|
||||
expect(out).toContain('<link rel="stylesheet" href="missing.css">');
|
||||
expect(out).toContain('ok');
|
||||
expect(out).not.toContain('src="present.js"');
|
||||
});
|
||||
|
||||
it('replaces ALL occurrences of identical duplicate tags (diverges from FileViewer.tsx:5313)', async () => {
|
||||
// The web client uses `.replace(from, () => to)` which only replaces the
|
||||
// first match. Locked decision §3.3: the server helper replaces all.
|
||||
const html = '<script src="x.js"></script>\n<script src="x.js"></script>';
|
||||
const out = await inlineRelativeAssets(html, 'index.html', readerFrom({ 'x.js': 'BODY' }));
|
||||
expect(out.match(/src="x\.js"/g) ?? []).toEqual([]);
|
||||
expect(out.match(/BODY/g)?.length).toBe(2);
|
||||
});
|
||||
|
||||
it('HTML-escapes the href value in data-od-inline-asset attr', async () => {
|
||||
// Using `&` only — the realistic case for filenames that need escaping.
|
||||
// `<`, `>`, `"` are forbidden in real filenames on most platforms and
|
||||
// additionally break the tag-matching regex (a limitation inherited
|
||||
// from the web client at FileViewer.tsx:5271). The escapeHtmlAttr fn
|
||||
// itself covers `&`, `"`, `<`, `>` by inspection.
|
||||
const href = 'weird&name.css';
|
||||
const html = `<link rel="stylesheet" href="${href}">`;
|
||||
const out = await inlineRelativeAssets(html, 'index.html', readerFrom({ [href]: '.x{}' }));
|
||||
expect(out).toContain('data-od-inline-asset="weird&name.css"');
|
||||
expect(out).not.toContain(`data-od-inline-asset="${href}"`);
|
||||
});
|
||||
|
||||
it('does not treat "disabled" inside a quoted attribute value as the disabled boolean attr', async () => {
|
||||
// PR #1312 round-2 review (lefarcen P3): the current
|
||||
// `hasBooleanHtmlAttr` regex `\sdisabled(?=\s|=|/?>)` tests the
|
||||
// tag string with NO attr-quoting awareness, so the literal text
|
||||
// `disabled` appearing inside any quoted attribute value, followed
|
||||
// by another whitespace char, satisfies the lookahead. A source
|
||||
// tag like
|
||||
// <link rel=stylesheet href=x.css data-note="content disabled stuff">
|
||||
// would then emit a <style disabled> block — silently disabling
|
||||
// a stylesheet the author wrote without that attr.
|
||||
const html =
|
||||
'<link rel="stylesheet" href="x.css" data-note="content disabled stuff">';
|
||||
const out = await inlineRelativeAssets(html, 'index.html', readerFrom({ 'x.css': '.x{}' }));
|
||||
expect(out).toMatch(/<style\b[^>]*data-od-inline-asset/);
|
||||
expect(out).not.toMatch(/<style\b[^>]*\bdisabled\b/);
|
||||
});
|
||||
|
||||
it('still detects disabled when it is a real boolean attr (regression for the dedup fix)', async () => {
|
||||
// Counterweight to the previous case: don't over-correct and
|
||||
// start dropping the legitimate `disabled` attr.
|
||||
const html = '<link rel="stylesheet" href="x.css" disabled>';
|
||||
const out = await inlineRelativeAssets(html, 'index.html', readerFrom({ 'x.css': '.x{}' }));
|
||||
expect(out).toMatch(/<style\b[^>]*\bdisabled\b/);
|
||||
});
|
||||
|
||||
it('preserves <link> attrs (media, title, disabled, nonce) on the generated <style> tag', async () => {
|
||||
// PR #1312 round-2 (lefarcen P2 @ inline-assets.ts:44): a stylesheet
|
||||
// <link> with `media="print"` was becoming a plain <style> with no
|
||||
// media query, so print-only styles applied unconditionally. Same
|
||||
// problem for `title` (alternate stylesheet sets), `disabled`
|
||||
// (initial disabled state), `nonce` (CSP nonce). All four are valid
|
||||
// attributes on both <link rel=stylesheet> and <style> per HTML
|
||||
// spec, so the inliner should copy them across.
|
||||
const html =
|
||||
'<link rel="stylesheet" href="print.css" media="print" title="Print">' +
|
||||
'<link rel="stylesheet" href="alt.css" disabled>' +
|
||||
'<link rel="stylesheet" href="csp.css" nonce="abc123">';
|
||||
const out = await inlineRelativeAssets(
|
||||
html,
|
||||
'index.html',
|
||||
readerFrom({
|
||||
'print.css': '.p{}',
|
||||
'alt.css': '.a{}',
|
||||
'csp.css': '.c{}',
|
||||
}),
|
||||
);
|
||||
expect(out).toMatch(/<style\b[^>]*\bmedia="print"[^>]*>[\s\S]*?\.p\{\}/);
|
||||
expect(out).toMatch(/<style\b[^>]*\btitle="Print"[^>]*>[\s\S]*?\.p\{\}/);
|
||||
expect(out).toMatch(/<style\b[^>]*\bdisabled\b[^>]*>[\s\S]*?\.a\{\}/);
|
||||
expect(out).toMatch(/<style\b[^>]*\bnonce="abc123"[^>]*>[\s\S]*?\.c\{\}/);
|
||||
});
|
||||
|
||||
it('resolves deep-nested owner (a/b/c/index.html + ../../shared/util.js)', async () => {
|
||||
const out = await inlineRelativeAssets(
|
||||
'<script src="../../shared/util.js"></script>',
|
||||
'a/b/c/index.html',
|
||||
readerFrom({ 'a/shared/util.js': 'DEEP' }),
|
||||
);
|
||||
expect(out).toContain('DEEP');
|
||||
expect(out).not.toContain('src="../../shared/util.js"');
|
||||
});
|
||||
|
||||
// ---- Cap enforcement (PR #1312 round-3, lefarcen P2) ---------------
|
||||
// The helper accepts an InlineOptions bag (test-door per
|
||||
// feedback_test_doors_over_fake_timers.md) so the tests can exercise
|
||||
// each cap with tiny fixtures rather than 2-50 MiB on-disk writes.
|
||||
// Production callers use the module-level defaults.
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
it('throws InlineAssetsLimitError("owner") when the owner html exceeds maxOwnerBytes', async () => {
|
||||
const html = '<html><head>' + 'x'.repeat(500) + '</head></html>';
|
||||
await expect(
|
||||
inlineRelativeAssets(html, 'index.html', readerFrom({}), { maxOwnerBytes: 100 }),
|
||||
).rejects.toMatchObject({
|
||||
name: 'InlineAssetsLimitError',
|
||||
limit: 'owner',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws InlineAssetsLimitError("candidates") when tag matches exceed maxCandidates', async () => {
|
||||
// Build HTML with 5 link tags, cap at 3.
|
||||
const html = Array.from({ length: 5 }, (_, i) =>
|
||||
`<link rel="stylesheet" href="a${i}.css">`,
|
||||
).join('');
|
||||
await expect(
|
||||
inlineRelativeAssets(html, 'index.html', readerFrom({}), { maxCandidates: 3 }),
|
||||
).rejects.toMatchObject({
|
||||
name: 'InlineAssetsLimitError',
|
||||
limit: 'candidates',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves a tag intact (no replacement) when its asset body exceeds maxAssetBytes', async () => {
|
||||
const html =
|
||||
'<link rel="stylesheet" href="big.css"><link rel="stylesheet" href="small.css">';
|
||||
const out = await inlineRelativeAssets(
|
||||
html,
|
||||
'index.html',
|
||||
readerFrom({
|
||||
'big.css': 'a'.repeat(2000), // exceeds cap
|
||||
'small.css': '.s{}',
|
||||
}),
|
||||
{ maxAssetBytes: 1000 },
|
||||
);
|
||||
// Oversized asset stays as a URL ref (graceful — the export still
|
||||
// succeeds; the consumer sees an un-inlined link instead of inflated
|
||||
// memory or a 413 for one bad asset).
|
||||
expect(out).toContain('<link rel="stylesheet" href="big.css">');
|
||||
// The small asset still inlines normally.
|
||||
expect(out).toContain('.s{}');
|
||||
expect(out).not.toContain('href="small.css"');
|
||||
});
|
||||
|
||||
it('throws InlineAssetsLimitError("total") when the assembled output exceeds maxTotalBytes', async () => {
|
||||
const html =
|
||||
'<link rel="stylesheet" href="a.css"><link rel="stylesheet" href="b.css">';
|
||||
const big = 'x'.repeat(800);
|
||||
await expect(
|
||||
inlineRelativeAssets(
|
||||
html,
|
||||
'index.html',
|
||||
readerFrom({ 'a.css': big, 'b.css': big }),
|
||||
{ maxTotalBytes: 1000 },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
name: 'InlineAssetsLimitError',
|
||||
limit: 'total',
|
||||
});
|
||||
});
|
||||
|
||||
it('checks maxAssetBytes via handle.size BEFORE invoking handle.read()', async () => {
|
||||
// PR #1312 round-4 (lefarcen P2): the maxAssetBytes cap must fire
|
||||
// pre-buffer. A reader whose read() throws is fine — the helper
|
||||
// must not invoke it once the stat-side size exceeds the cap.
|
||||
let readsAttempted = 0;
|
||||
const sizeOnlyReader = async (relPath: string) => ({
|
||||
size: 10_000,
|
||||
read: async (): Promise<string | null> => {
|
||||
readsAttempted += 1;
|
||||
throw new Error(`read should not happen for ${relPath}`);
|
||||
},
|
||||
});
|
||||
const html = '<link rel="stylesheet" href="big.css">';
|
||||
const out = await inlineRelativeAssets(html, 'index.html', sizeOnlyReader, {
|
||||
maxAssetBytes: 1_000,
|
||||
});
|
||||
expect(readsAttempted).toBe(0);
|
||||
expect(out).toContain('<link rel="stylesheet" href="big.css">');
|
||||
});
|
||||
|
||||
it('stops dispatching reads once running total exceeds maxTotalBytes', async () => {
|
||||
// PR #1312 round-4 (lefarcen P2): the running-total guard must
|
||||
// abort the worker pool, not wait for the final concat. With a
|
||||
// tiny totalBytes cap and 20 candidates each contributing 800
|
||||
// bytes of stat-size, we expect at most a few reads to actually
|
||||
// run before the abort flag short-circuits the rest. Concurrency
|
||||
// is 1 so the abort timing is deterministic.
|
||||
let reads = 0;
|
||||
const countingReader = async (relPath: string) => ({
|
||||
size: 800,
|
||||
read: async () => {
|
||||
reads += 1;
|
||||
return `/* ${relPath} */`;
|
||||
},
|
||||
});
|
||||
const html = Array.from({ length: 20 }, (_, i) =>
|
||||
`<link rel="stylesheet" href="a${i}.css">`,
|
||||
).join('');
|
||||
await expect(
|
||||
inlineRelativeAssets(html, 'index.html', countingReader, {
|
||||
maxTotalBytes: 1_000,
|
||||
maxReadConcurrency: 1,
|
||||
}),
|
||||
).rejects.toMatchObject({ name: 'InlineAssetsLimitError', limit: 'total' });
|
||||
// Owner html is ~760 bytes. First asset's 800 stat-size pushes
|
||||
// running over 1000 → abort. So at most ONE read should fire.
|
||||
expect(reads).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('reconciles handle.size with actual content bytes — trips total abort post-read on stat-lying readers', async () => {
|
||||
// PR #1312 round-5 (lefarcen P3 confirmed at PR-1312#issuecomment-4424868413
|
||||
// follow-up, path-a): the helper must reconcile handle.size with the
|
||||
// actual byte length of `content` AFTER `read()`, not just trust the
|
||||
// stat-side number. A reader that under-reports size (stale stat,
|
||||
// UTF-8 expansion at decode, sparse file, deliberate lie) would
|
||||
// otherwise let many strings materialize before the concat-time
|
||||
// guard at the bottom of the helper throws — defeating the round-4
|
||||
// pre-buffer cap intent.
|
||||
//
|
||||
// Discriminator: read count. Pre-fix the helper trusts handle.size
|
||||
// (10), so both reads complete (each returning 1000 bytes) under
|
||||
// the reservation total of 56+10+10=76 < cap 500; the concat-time
|
||||
// guard then catches the 2000+-byte assembly and throws 'total'.
|
||||
// Post-fix worker 1's reconciliation trips totalAborted as soon as
|
||||
// its actualBytes (1000) is added to runningBytes, pushing running
|
||||
// over the cap; worker 2 then sees totalAborted and returns null
|
||||
// without invoking read(). One read, not two.
|
||||
//
|
||||
// Lefarcen-confirmed path-a (drop-asset + abort + throw 'total'
|
||||
// after Promise.all settles): preserves the round-2/3/4 graceful-
|
||||
// fallback pattern instead of racing throws between in-flight
|
||||
// workers.
|
||||
let reads = 0;
|
||||
const lyingReader: InlineAssetReader = async (_relPath: string) => ({
|
||||
size: 10, // stat lies — actual is 100x
|
||||
read: async () => {
|
||||
reads += 1;
|
||||
return 'x'.repeat(1000);
|
||||
},
|
||||
});
|
||||
const html = '<script src="a.js"></script><script src="b.js"></script>';
|
||||
await expect(
|
||||
inlineRelativeAssets(html, 'index.html', lyingReader, {
|
||||
maxTotalBytes: 500,
|
||||
maxReadConcurrency: 1, // sequential so the abort timing is deterministic
|
||||
}),
|
||||
).rejects.toMatchObject({ name: 'InlineAssetsLimitError', limit: 'total' });
|
||||
// Pre-fix: 2 (helper trusts stat → both reads complete → concat catches).
|
||||
// Post-fix: 1 (worker 1 reconciles after read, trips abort; worker 2 skipped).
|
||||
expect(reads).toBe(1);
|
||||
});
|
||||
|
||||
it('caps concurrent file reads at maxReadConcurrency', async () => {
|
||||
// A reader that records peak concurrency inside read(): increments
|
||||
// on entry, decrements on exit, tracks the high-water mark. The
|
||||
// size lookup is synchronous-fast so it doesn't contribute.
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
const readerWithCounter = async (relPath: string) => {
|
||||
const body = `/* ${relPath} */`;
|
||||
return {
|
||||
size: Buffer.byteLength(body, 'utf8'),
|
||||
read: async () => {
|
||||
inFlight += 1;
|
||||
if (inFlight > peak) peak = inFlight;
|
||||
// Yield a microtask so other concurrent calls can interleave.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
inFlight -= 1;
|
||||
return body;
|
||||
},
|
||||
};
|
||||
};
|
||||
const html = Array.from({ length: 20 }, (_, i) =>
|
||||
`<link rel="stylesheet" href="a${i}.css">`,
|
||||
).join('');
|
||||
await inlineRelativeAssets(html, 'index.html', readerWithCounter, { maxReadConcurrency: 4 });
|
||||
expect(peak).toBeLessThanOrEqual(4);
|
||||
expect(peak).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not re-replace a tag literal that appears inside an already-inlined asset body', async () => {
|
||||
// Regression for nexu-io/open-design#1312 review feedback (Siri-Ray
|
||||
// looper + codex bot): the previous reduce/split-join approach
|
||||
// re-scanned the progressively mutated HTML, so a tag literal that
|
||||
// happened to appear inside an inlined asset body got the inner
|
||||
// literal also replaced — corrupting the body.
|
||||
//
|
||||
// The reproducer uses two <link rel=stylesheet> tags where a.css's
|
||||
// body contains the literal text of b.css's <link> tag (e.g. inside
|
||||
// a CSS comment or content: declaration). The </style escape on
|
||||
// CSS bodies doesn't touch <link>, so split/join over the mutated
|
||||
// HTML finds the literal inside a.css's inline body and replaces
|
||||
// it on the second pass — injecting b.css's inline body where the
|
||||
// literal comment text used to be.
|
||||
const html =
|
||||
'<link rel="stylesheet" href="a.css"><link rel="stylesheet" href="b.css">';
|
||||
const aCssBody = '/* see also <link rel="stylesheet" href="b.css"> */';
|
||||
const bCssBody = 'body{color:red}';
|
||||
const out = await inlineRelativeAssets(
|
||||
html,
|
||||
'index.html',
|
||||
readerFrom({ 'a.css': aCssBody, 'b.css': bCssBody }),
|
||||
);
|
||||
// The literal <link> string inside a.css's comment must survive
|
||||
// verbatim — position-based replacement only touches the original
|
||||
// outer-tag spans, not text introduced by earlier replacements.
|
||||
expect(out).toContain('/* see also <link rel="stylesheet" href="b.css"> */');
|
||||
// b.css's body is inlined exactly once, at the real outer tag's
|
||||
// position — not injected inside a.css's inline body.
|
||||
expect(out.match(/body\{color:red\}/g)?.length).toBe(1);
|
||||
// Neither original outer <link href="…"> survives as a URL ref.
|
||||
expect(out).not.toMatch(/<link\b[^>]*\bhref="a\.css"/);
|
||||
expect(out).not.toMatch(/<link\b[^>]*\bhref="b\.css"(?![^<]*\*\/)/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP integration — GET /api/projects/:id/export/*?inline=1
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('GET /api/projects/:id/export/*?inline=1 route', () => {
|
||||
let server: http.Server;
|
||||
let baseUrl: string;
|
||||
let projectsRoot: string;
|
||||
const projectId = 'proj-export-inline-test';
|
||||
|
||||
const cssBody = 'body{color:#0a0}';
|
||||
const jsBody = 'window.OD_EXPORT_OK = 42;';
|
||||
const nestedJsBody = 'export const N = 7;';
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = (await startServer({ port: 0, returnServer: true })) as {
|
||||
url: string;
|
||||
server: http.Server;
|
||||
};
|
||||
baseUrl = started.url;
|
||||
server = started.server;
|
||||
|
||||
projectsRoot = path.join(process.env.OD_DATA_DIR!, 'projects');
|
||||
const dir = path.join(projectsRoot, projectId);
|
||||
const pages = path.join(dir, 'pages');
|
||||
const shared = path.join(dir, 'shared');
|
||||
await mkdir(dir, { recursive: true });
|
||||
await mkdir(pages, { recursive: true });
|
||||
await mkdir(shared, { recursive: true });
|
||||
|
||||
await writeFile(
|
||||
path.join(dir, 'index.html'),
|
||||
'<!doctype html><html><head>' +
|
||||
'<link rel="stylesheet" href="app.css">' +
|
||||
'<script src="app.js"></script>' +
|
||||
'</head><body><div id="root"></div></body></html>',
|
||||
);
|
||||
await writeFile(path.join(dir, 'app.css'), cssBody);
|
||||
await writeFile(path.join(dir, 'app.js'), jsBody);
|
||||
|
||||
await writeFile(
|
||||
path.join(dir, 'partial.html'),
|
||||
'<!doctype html><html><head>' +
|
||||
'<link rel="stylesheet" href="missing.css">' +
|
||||
'<script src="app.js"></script>' +
|
||||
'</head><body></body></html>',
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
path.join(pages, 'index.html'),
|
||||
'<!doctype html><html><head>' +
|
||||
'<script src="../shared/util.js"></script>' +
|
||||
'</head></html>',
|
||||
);
|
||||
await writeFile(path.join(shared, 'util.js'), nestedJsBody);
|
||||
});
|
||||
|
||||
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
|
||||
|
||||
const exportUrl = (name: string, query = 'inline=1') =>
|
||||
`${baseUrl}/api/projects/${projectId}/export/${name}${query ? `?${query}` : ''}`;
|
||||
|
||||
it('returns a self-contained HTML body when ?inline=1 on a 3-file layout', async () => {
|
||||
const res = await fetch(exportUrl('index.html'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toContain('text/html');
|
||||
const body = await res.text();
|
||||
// Wiring guard: removing the await inlineRelativeAssets(...) line in the
|
||||
// handler fails these assertions, not just the helper-internals tests.
|
||||
expect(body).toContain(cssBody);
|
||||
expect(body).toContain(jsBody);
|
||||
expect(body).not.toContain('href="app.css"');
|
||||
expect(body).not.toContain('src="app.js"');
|
||||
expect(body).toContain('<style data-od-inline-asset="app.css">');
|
||||
});
|
||||
|
||||
it('returns 400 BAD_REQUEST when ?inline is missing', async () => {
|
||||
const res = await fetch(exportUrl('index.html', ''));
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe('BAD_REQUEST');
|
||||
});
|
||||
|
||||
it('returns 400 for non-canonical inline values (0, false, foo)', async () => {
|
||||
for (const q of ['inline=0', 'inline=false', 'inline=foo', 'inline=']) {
|
||||
const res = await fetch(exportUrl('index.html', q));
|
||||
expect(res.status).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 415 UNSUPPORTED_MEDIA_TYPE for non-HTML files', async () => {
|
||||
// Drift fix discovered in PR #1312 round-3: the round-1 code emitted
|
||||
// `UNSUPPORTED_FILE_TYPE` (status 400) which is not a registered
|
||||
// ApiErrorCode in packages/contracts/src/errors.ts. The canonical
|
||||
// code for "wrong content type" is UNSUPPORTED_MEDIA_TYPE with HTTP
|
||||
// 415, so the route now uses both.
|
||||
const res = await fetch(exportUrl('app.css'));
|
||||
expect(res.status).toBe(415);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe('UNSUPPORTED_MEDIA_TYPE');
|
||||
});
|
||||
|
||||
it('returns 404 FILE_NOT_FOUND for a nonexistent file', async () => {
|
||||
const res = await fetch(exportUrl('missing.html'));
|
||||
expect(res.status).toBe(404);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe('FILE_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('returns 400 BAD_REQUEST for an invalid project id (..)', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/projects/../export/index.html?inline=1`);
|
||||
// Express normalizes `..` segments before routing, so this should not
|
||||
// reach our handler; the daemon's middleware or routing answers first.
|
||||
// Either way, the request must NOT succeed at extracting a parent
|
||||
// directory.
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.status).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it('rejects null-origin requests with 403 (export is for same-origin / server-side callers only)', async () => {
|
||||
// Unlike /raw/*, the /export/* route is NOT in the daemon's null-
|
||||
// origin allowlist (server.ts _NULL_ORIGIN_SAFE_GET_RE). The export
|
||||
// consumer set is the daemon UI (same-origin) and server-side
|
||||
// screenshot tooling (no Origin header at all); sandboxed-iframe
|
||||
// srcdoc previews fetch through /raw/ instead, where each asset has
|
||||
// its own URL. This test pins the contract so a future change that
|
||||
// adds /export/ to the allowlist has to update it deliberately.
|
||||
const res = await fetch(exportUrl('index.html'), { headers: { Origin: 'null' } });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 200 with the <link> tag intact when a sibling asset is missing', async () => {
|
||||
const res = await fetch(exportUrl('partial.html'));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.text();
|
||||
expect(body).toContain('<link rel="stylesheet" href="missing.css">');
|
||||
expect(body).toContain(jsBody);
|
||||
expect(body).not.toContain('src="app.js"');
|
||||
});
|
||||
|
||||
it('inlines a nested HTML entry (pages/index.html + ../shared/util.js)', async () => {
|
||||
const res = await fetch(exportUrl('pages/index.html'));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.text();
|
||||
expect(body).toContain(nestedJsBody);
|
||||
expect(body).not.toContain('src="../shared/util.js"');
|
||||
});
|
||||
|
||||
it('sends Content-Security-Policy: sandbox allow-scripts to block daemon-origin privilege escalation', async () => {
|
||||
// PR #1312 round-2 review (lefarcen P2 @ import-export-routes.ts:423):
|
||||
// top-level browser navigation to the export URL sends no Origin
|
||||
// header, so the daemon middleware lets it through and any JS in
|
||||
// the exported document runs with daemon-origin privileges (access
|
||||
// to /api/, cookies, localStorage). CSP `sandbox allow-scripts`
|
||||
// treats the response like a sandboxed iframe with an opaque origin:
|
||||
// scripts execute (which the export needs — that's the whole point
|
||||
// of inlining JS) but cannot read cookies, hit /api/, or otherwise
|
||||
// escalate to the daemon's origin.
|
||||
const res = await fetch(exportUrl('index.html'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-security-policy')).toBe('sandbox allow-scripts');
|
||||
});
|
||||
|
||||
it('accepts inline=true / yes / on / TRUE / Yes / ON (case-insensitive accept list per decision §7)', async () => {
|
||||
// PR #1312 round-2 review (lefarcen P3 @ export-inline-route.test.ts:262):
|
||||
// PR body decision §7 promises `inline=true/yes/on` case-insensitive
|
||||
// matching parseForceInline at file-viewer-render-mode.ts:59-66, but
|
||||
// round-1 tests only exercised inline=1. Pin the full accept list.
|
||||
for (const q of ['inline=true', 'inline=yes', 'inline=on', 'inline=TRUE', 'inline=Yes', 'inline=ON']) {
|
||||
const res = await fetch(exportUrl('index.html', q));
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 413 PAYLOAD_TOO_LARGE when the owner file blows past the candidates cap', async () => {
|
||||
// PR #1312 round-3 (lefarcen P2): the route must surface the
|
||||
// InlineAssetsLimitError as a structured 413 envelope, not let it
|
||||
// propagate as a 400 BAD_REQUEST. Generated owner has 501
|
||||
// `<link rel=stylesheet>` tags, one above the default
|
||||
// MAX_INLINE_CANDIDATES (500). The candidates cap fires after
|
||||
// matchAll, BEFORE any sibling read, so the fact that `a.css`
|
||||
// doesn't exist on disk is irrelevant.
|
||||
const dir = path.join(projectsRoot, projectId);
|
||||
const huge = '<!doctype html><html><head>' +
|
||||
'<link rel="stylesheet" href="a.css">'.repeat(501) +
|
||||
'</head></html>';
|
||||
await writeFile(path.join(dir, 'too-many-tags.html'), huge);
|
||||
const res = await fetch(exportUrl('too-many-tags.html'));
|
||||
expect(res.status).toBe(413);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe('PAYLOAD_TOO_LARGE');
|
||||
});
|
||||
|
||||
it('returns 413 (not 415) for an oversize non-HTML file — proves owner cap fires pre-buffer', async () => {
|
||||
// PR #1312 round-5 (lefarcen P2): the route must stat the owner with
|
||||
// resolveProjectFilePath BEFORE readProjectFile and reject sizes
|
||||
// above MAX_INLINE_OWNER_BYTES with 413 PAYLOAD_TOO_LARGE. The
|
||||
// Red→Green discriminator is the combination "oversize AND
|
||||
// non-HTML": pre-fix, the route reads the buffer first and the
|
||||
// text/plain mime check at file.mime fires → 415. Post-fix, the
|
||||
// route stats first and the size check fires before the mime
|
||||
// check → 413. Asserting "got 413, not 415" pins both the
|
||||
// pre-buffer property and the check ordering (size before mime,
|
||||
// per lefarcen's locked round-5 sequence).
|
||||
//
|
||||
// 2 MiB+1 byte fixture is acceptable in test setup; MAX_INLINE_OWNER_BYTES
|
||||
// is 2 MiB so this is the minimal fixture that exceeds the cap with the
|
||||
// production constant (no test-door needed).
|
||||
const dir = path.join(projectsRoot, projectId);
|
||||
const overCap = 2 * 1024 * 1024 + 1;
|
||||
await writeFile(path.join(dir, 'huge.txt'), Buffer.alloc(overCap, 0x61));
|
||||
const res = await fetch(exportUrl('huge.txt'));
|
||||
expect(res.status).toBe(413);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe('PAYLOAD_TOO_LARGE');
|
||||
});
|
||||
|
||||
it('rejects an invalid project id (chars outside isSafeId char class) with 400 BAD_REQUEST', async () => {
|
||||
// PR #1312 round-2 review (lefarcen P3 @ export-inline-route.test.ts:287):
|
||||
// the previous `..` test was rejected by Express path normalization
|
||||
// before the route saw it, so it didn't actually exercise the
|
||||
// isSafeId guard. We need an id that (a) Express passes through
|
||||
// unchanged into req.params and (b) isSafeId rejects. The `!` char
|
||||
// is URL-safe (no percent-encoding needed) and not in isSafeId's
|
||||
// /^[A-Za-z0-9._-]+$/ char class, so it hits the route's first
|
||||
// checkpoint and returns the documented envelope.
|
||||
const res = await fetch(exportUrl('index.html').replace(`/${projectId}/`, '/bad!id/'));
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: { code: string; message: string } };
|
||||
expect(body.error.code).toBe('BAD_REQUEST');
|
||||
expect(body.error.message).toContain('invalid project id');
|
||||
});
|
||||
});
|
||||
260
apps/daemon/tests/memory-routes.test.ts
Normal file
260
apps/daemon/tests/memory-routes.test.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import type http from 'node:http';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
memoryDir,
|
||||
readMemoryEntry,
|
||||
readMemoryIndex,
|
||||
} from '../src/memory.js';
|
||||
import {
|
||||
__resetExtractionsForTests,
|
||||
recordHeuristic,
|
||||
} from '../src/memory-extractions.js';
|
||||
import { startServer } from '../src/server.js';
|
||||
|
||||
interface StartedServer {
|
||||
url: string;
|
||||
server: http.Server;
|
||||
}
|
||||
|
||||
const dataDir = process.env.OD_DATA_DIR as string;
|
||||
|
||||
let baseUrl: string;
|
||||
let server: http.Server;
|
||||
|
||||
async function closeServer(nextServer: http.Server | undefined): Promise<void> {
|
||||
if (!nextServer) return;
|
||||
await new Promise<void>((resolve) => nextServer.close(() => resolve()));
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = (await startServer({
|
||||
port: 0,
|
||||
returnServer: true,
|
||||
})) as StartedServer;
|
||||
baseUrl = started.url;
|
||||
server = started.server;
|
||||
});
|
||||
|
||||
afterAll(() => closeServer(server));
|
||||
|
||||
beforeEach(async () => {
|
||||
await fsp.rm(memoryDir(dataDir), { recursive: true, force: true });
|
||||
__resetExtractionsForTests();
|
||||
});
|
||||
|
||||
describe('memory routes', () => {
|
||||
it('lists the default memory state when the store is empty', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/memory`);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const json = await res.json() as {
|
||||
enabled: boolean;
|
||||
rootDir: string;
|
||||
index: string;
|
||||
entries: unknown[];
|
||||
extraction: unknown;
|
||||
};
|
||||
expect(json.enabled).toBe(true);
|
||||
expect(json.rootDir).toBe(memoryDir(dataDir));
|
||||
expect(json.index).toContain('# Memory');
|
||||
expect(json.entries).toEqual([]);
|
||||
expect(json.extraction).toBeNull();
|
||||
});
|
||||
|
||||
it('creates, reads, updates, and deletes a memory entry', async () => {
|
||||
const createRes = await fetch(`${baseUrl}/api/memory`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'UI preferences',
|
||||
description: 'Persistent rendering preferences',
|
||||
type: 'user',
|
||||
body: '- Prefer dark mode\n- Prefer generous spacing',
|
||||
}),
|
||||
});
|
||||
expect(createRes.status).toBe(200);
|
||||
const created = await createRes.json() as {
|
||||
entry: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
body: string;
|
||||
};
|
||||
};
|
||||
expect(created.entry.id).toBe('user_ui_preferences');
|
||||
|
||||
const getRes = await fetch(`${baseUrl}/api/memory/${created.entry.id}`);
|
||||
expect(getRes.status).toBe(200);
|
||||
const fetched = await getRes.json() as { entry: { body: string } };
|
||||
expect(fetched.entry.body).toContain('Prefer dark mode');
|
||||
|
||||
const updateRes = await fetch(`${baseUrl}/api/memory/${created.entry.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'UI preferences',
|
||||
description: 'Updated preference',
|
||||
type: 'user',
|
||||
body: '- Prefer spacious layouts',
|
||||
}),
|
||||
});
|
||||
expect(updateRes.status).toBe(200);
|
||||
|
||||
const stored = await readMemoryEntry(dataDir, created.entry.id);
|
||||
expect(stored?.description).toBe('Updated preference');
|
||||
expect(stored?.body).toContain('Prefer spacious layouts');
|
||||
|
||||
const deleteRes = await fetch(`${baseUrl}/api/memory/${created.entry.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
expect(deleteRes.status).toBe(200);
|
||||
|
||||
const listRes = await fetch(`${baseUrl}/api/memory`);
|
||||
const listJson = await listRes.json() as { entries: unknown[] };
|
||||
expect(listJson.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it('saves the memory index and returns it from the list payload', async () => {
|
||||
const nextIndex = '# Memory\n\n- user_ui_preferences.md\n';
|
||||
const putRes = await fetch(`${baseUrl}/api/memory/index`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ index: nextIndex }),
|
||||
});
|
||||
expect(putRes.status).toBe(200);
|
||||
|
||||
expect(await readMemoryIndex(dataDir)).toBe(nextIndex);
|
||||
|
||||
const listRes = await fetch(`${baseUrl}/api/memory`);
|
||||
const listJson = await listRes.json() as { index: string };
|
||||
expect(listJson.index).toBe(nextIndex);
|
||||
});
|
||||
|
||||
it('lists extraction history and supports deleting one row', async () => {
|
||||
const firstId = recordHeuristic({
|
||||
userMessage: 'Remember I prefer dark mode',
|
||||
writtenCount: 1,
|
||||
writtenIds: ['user_ui_preferences'],
|
||||
});
|
||||
recordHeuristic({
|
||||
userMessage: 'No durable memory in this turn',
|
||||
writtenCount: 0,
|
||||
writtenIds: [],
|
||||
});
|
||||
|
||||
const listRes = await fetch(`${baseUrl}/api/memory/extractions`);
|
||||
expect(listRes.status).toBe(200);
|
||||
const listJson = await listRes.json() as {
|
||||
extractions: Array<{ id: string; phase: string; userMessagePreview: string }>;
|
||||
};
|
||||
expect(listJson.extractions).toHaveLength(2);
|
||||
expect(listJson.extractions[0]?.userMessagePreview).toContain('No durable memory');
|
||||
|
||||
const deleteRes = await fetch(`${baseUrl}/api/memory/extractions/${firstId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
expect(deleteRes.status).toBe(200);
|
||||
const deleteJson = await deleteRes.json() as { removed: number };
|
||||
expect(deleteJson.removed).toBe(1);
|
||||
|
||||
const afterRes = await fetch(`${baseUrl}/api/memory/extractions`);
|
||||
const afterJson = await afterRes.json() as {
|
||||
extractions: Array<{ id: string }>;
|
||||
};
|
||||
expect(afterJson.extractions).toHaveLength(1);
|
||||
expect(afterJson.extractions[0]?.id).not.toBe(firstId);
|
||||
});
|
||||
|
||||
it('clears the extraction history buffer', async () => {
|
||||
recordHeuristic({
|
||||
userMessage: 'Remember I prefer dark mode',
|
||||
writtenCount: 1,
|
||||
writtenIds: ['user_ui_preferences'],
|
||||
});
|
||||
recordHeuristic({
|
||||
userMessage: 'Remember I like weekly summaries',
|
||||
writtenCount: 1,
|
||||
writtenIds: ['user_weekly_summaries'],
|
||||
});
|
||||
|
||||
const clearRes = await fetch(`${baseUrl}/api/memory/extractions`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
expect(clearRes.status).toBe(200);
|
||||
const clearJson = await clearRes.json() as { removed: number };
|
||||
expect(clearJson.removed).toBe(2);
|
||||
|
||||
const listRes = await fetch(`${baseUrl}/api/memory/extractions`);
|
||||
const listJson = await listRes.json() as { extractions: unknown[] };
|
||||
expect(listJson.extractions).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts heuristic memories from a user message and reports the changed entries', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/memory/extract`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userMessage: 'Remember: prefer dark mode for UI examples.',
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const json = await res.json() as {
|
||||
changed: Array<{ id: string; name: string; type: string }>;
|
||||
attemptedLLM: boolean;
|
||||
};
|
||||
expect(json.attemptedLLM).toBe(false);
|
||||
expect(json.changed).toHaveLength(1);
|
||||
expect(json.changed[0]).toMatchObject({
|
||||
id: 'feedback_prefer_dark_mode_for_ui_examples',
|
||||
name: 'Remembered note',
|
||||
type: 'feedback',
|
||||
});
|
||||
|
||||
const listRes = await fetch(`${baseUrl}/api/memory`);
|
||||
const listJson = await listRes.json() as {
|
||||
entries: Array<{ id: string; name: string }>;
|
||||
};
|
||||
expect(listJson.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'feedback_prefer_dark_mode_for_ui_examples',
|
||||
name: 'Remembered note',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns the composed system prompt body from indexed memory entries', async () => {
|
||||
await fetch(`${baseUrl}/api/memory`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'User role',
|
||||
description: 'User is a product designer',
|
||||
type: 'user',
|
||||
body: '- Role / identity: product designer',
|
||||
}),
|
||||
});
|
||||
await fetch(`${baseUrl}/api/memory`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Project goal',
|
||||
description: 'Ship a cleaner onboarding flow',
|
||||
type: 'project',
|
||||
body: '- Goal: ship a cleaner onboarding flow',
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/memory/system-prompt`);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json() as { body: string };
|
||||
expect(json.body).toContain('### User');
|
||||
expect(json.body).toContain('**User role** — User is a product designer');
|
||||
expect(json.body).toContain('### Project');
|
||||
expect(json.body).toContain('**Project goal** — Ship a cleaner onboarding flow');
|
||||
});
|
||||
});
|
||||
|
|
@ -218,4 +218,94 @@ describe('composeSystemPrompt', () => {
|
|||
expect(prompt).not.toContain('- `github` (github)');
|
||||
});
|
||||
});
|
||||
|
||||
// The daemon experiment for compiling a brand's design system from prose
|
||||
// (DESIGN.md) into a machine-readable contract (tokens.css) plus a worked
|
||||
// fixture (components.html) lives in PR-C. The composer exposes two new
|
||||
// optional inputs (`designSystemTokensCss`, `designSystemFixtureHtml`)
|
||||
// that the daemon populates only when `OD_DESIGN_TOKEN_CHANNEL=1` is set
|
||||
// AND the active brand actually ships those files. These tests pin the
|
||||
// injection shape so the prompt structure cannot drift silently.
|
||||
describe('design-system token + fixture injection (#PR-C)', () => {
|
||||
const sampleTokensCss = ':root {\n --bg: #ffffff;\n --fg: #111111;\n --accent: #0050d8;\n}';
|
||||
const sampleFixtureHtml = '<!doctype html>\n<html lang="en">\n <body><button class="btn btn-primary">Subscribe</button></body>\n</html>';
|
||||
|
||||
it('appends BOTH a tokens block and a fixture block when both inputs are present', () => {
|
||||
const prompt = composeSystemPrompt({
|
||||
designSystemTitle: 'default',
|
||||
designSystemBody: '# Neutral Modern\n\n> Category: Utility\n\nProse description.',
|
||||
designSystemTokensCss: sampleTokensCss,
|
||||
designSystemFixtureHtml: sampleFixtureHtml,
|
||||
});
|
||||
|
||||
expect(prompt).toContain('## Active design system tokens — default');
|
||||
expect(prompt).toContain('Paste the unscoped `:root { ... }` block verbatim');
|
||||
expect(prompt).toContain('--accent: #0050d8;');
|
||||
|
||||
expect(prompt).toContain('## Reference fixture — default');
|
||||
expect(prompt).toContain('Match its component shapes');
|
||||
expect(prompt).toContain('class="btn btn-primary"');
|
||||
});
|
||||
|
||||
it('keeps the prompt byte-equivalent to the legacy path when both inputs are omitted', () => {
|
||||
const baseline = composeSystemPrompt({
|
||||
designSystemTitle: 'default',
|
||||
designSystemBody: '# Neutral Modern\n\nProse only.',
|
||||
});
|
||||
const withFlagOffEquivalent = composeSystemPrompt({
|
||||
designSystemTitle: 'default',
|
||||
designSystemBody: '# Neutral Modern\n\nProse only.',
|
||||
designSystemTokensCss: undefined,
|
||||
designSystemFixtureHtml: undefined,
|
||||
});
|
||||
|
||||
expect(withFlagOffEquivalent).toBe(baseline);
|
||||
expect(withFlagOffEquivalent).not.toContain('## Active design system tokens');
|
||||
expect(withFlagOffEquivalent).not.toContain('## Reference fixture');
|
||||
});
|
||||
|
||||
it('gates the tokens and fixture blocks independently — either may be absent', () => {
|
||||
const tokensOnly = composeSystemPrompt({
|
||||
designSystemTitle: 'default',
|
||||
designSystemBody: '# x\n\nbody',
|
||||
designSystemTokensCss: sampleTokensCss,
|
||||
});
|
||||
expect(tokensOnly).toContain('## Active design system tokens — default');
|
||||
expect(tokensOnly).not.toContain('## Reference fixture');
|
||||
|
||||
const fixtureOnly = composeSystemPrompt({
|
||||
designSystemTitle: 'default',
|
||||
designSystemBody: '# x\n\nbody',
|
||||
designSystemFixtureHtml: sampleFixtureHtml,
|
||||
});
|
||||
expect(fixtureOnly).not.toContain('## Active design system tokens');
|
||||
expect(fixtureOnly).toContain('## Reference fixture — default');
|
||||
});
|
||||
|
||||
it('places the tokens + fixture blocks AFTER the DESIGN.md prose block (prose sets voice, structured form binds names)', () => {
|
||||
const prompt = composeSystemPrompt({
|
||||
designSystemTitle: 'default',
|
||||
designSystemBody: 'PROSE_BODY_MARKER',
|
||||
designSystemTokensCss: sampleTokensCss,
|
||||
designSystemFixtureHtml: sampleFixtureHtml,
|
||||
});
|
||||
const proseAt = prompt.indexOf('PROSE_BODY_MARKER');
|
||||
const tokensAt = prompt.indexOf('## Active design system tokens');
|
||||
const fixtureAt = prompt.indexOf('## Reference fixture');
|
||||
expect(proseAt).toBeGreaterThan(0);
|
||||
expect(tokensAt).toBeGreaterThan(proseAt);
|
||||
expect(fixtureAt).toBeGreaterThan(tokensAt);
|
||||
});
|
||||
|
||||
it('treats whitespace-only inputs as absent (defensive, matches DESIGN.md block behavior)', () => {
|
||||
const prompt = composeSystemPrompt({
|
||||
designSystemTitle: 'default',
|
||||
designSystemBody: '# x\n\nbody',
|
||||
designSystemTokensCss: ' \n \t ',
|
||||
designSystemFixtureHtml: '\n\n',
|
||||
});
|
||||
expect(prompt).not.toContain('## Active design system tokens');
|
||||
expect(prompt).not.toContain('## Reference fixture');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
320
apps/daemon/tests/routine-routes.test.ts
Normal file
320
apps/daemon/tests/routine-routes.test.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import express from 'express';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
closeDatabase,
|
||||
getRoutine,
|
||||
insertProject,
|
||||
insertRoutineRun,
|
||||
openDatabase,
|
||||
} from '../src/db.js';
|
||||
import { registerRoutineRoutes } from '../src/routine-routes.js';
|
||||
|
||||
describe('routine routes', () => {
|
||||
let tempDir: string;
|
||||
|
||||
async function listen(app: express.Express) {
|
||||
const server = app.listen(0, '127.0.0.1');
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('listening', () => resolve());
|
||||
server.once('error', reject);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('failed to resolve test server port');
|
||||
}
|
||||
return {
|
||||
server,
|
||||
port: address.port,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(path.join(os.tmpdir(), 'od-routine-routes-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const db = openDatabase(tempDir, { dataDir: tempDir });
|
||||
const nextRunAt = vi.fn(() => new Date('2026-05-13T01:00:00.000Z'));
|
||||
const rescheduleOne = vi.fn();
|
||||
const unschedule = vi.fn();
|
||||
const runNow = vi.fn(async (routineId: string) => {
|
||||
insertRoutineRun(db, {
|
||||
id: 'run-1',
|
||||
routineId,
|
||||
trigger: 'manual',
|
||||
status: 'queued',
|
||||
projectId: 'proj-run',
|
||||
conversationId: 'conv-run',
|
||||
agentRunId: 'agent-run-1',
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
return {
|
||||
projectId: 'proj-run',
|
||||
conversationId: 'conv-run',
|
||||
agentRunId: 'agent-run-1',
|
||||
completion: Promise.resolve({ status: 'queued' }),
|
||||
};
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
registerRoutineRoutes(app, {
|
||||
db,
|
||||
routines: {
|
||||
routineService: {
|
||||
nextRunAt,
|
||||
rescheduleOne,
|
||||
runNow,
|
||||
unschedule,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
return { app, db, nextRunAt, rescheduleOne, runNow, unschedule };
|
||||
}
|
||||
|
||||
it('creates a reuse-mode routine and includes the computed next run', async () => {
|
||||
const { app, db, rescheduleOne } = buildApp();
|
||||
const now = Date.now();
|
||||
insertProject(db, {
|
||||
id: 'proj-1',
|
||||
name: 'Routine target',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const { server, port } = await listen(app);
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/api/routines`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Weekly digest',
|
||||
prompt: 'Summarize GitHub and design activity.',
|
||||
schedule: {
|
||||
kind: 'weekly',
|
||||
weekday: 3,
|
||||
time: '09:00',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
target: { mode: 'reuse', projectId: 'proj-1' },
|
||||
enabled: true,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const json = await res.json() as {
|
||||
routine: {
|
||||
id: string;
|
||||
name: string;
|
||||
target: { mode: string; projectId: string };
|
||||
nextRunAt: number;
|
||||
};
|
||||
};
|
||||
expect(json.routine.name).toBe('Weekly digest');
|
||||
expect(json.routine.target).toEqual({ mode: 'reuse', projectId: 'proj-1' });
|
||||
expect(json.routine.nextRunAt).toBe(new Date('2026-05-13T01:00:00.000Z').getTime());
|
||||
|
||||
const stored = getRoutine(db, json.routine.id);
|
||||
expect(stored?.projectId).toBe('proj-1');
|
||||
expect(rescheduleOne).toHaveBeenCalledWith(json.routine.id);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it('patches enabled state and target mode, then reschedules the routine', async () => {
|
||||
const { app, db, rescheduleOne } = buildApp();
|
||||
const now = Date.now();
|
||||
insertProject(db, {
|
||||
id: 'proj-1',
|
||||
name: 'Routine target',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const { server: createServer, port } = await listen(app);
|
||||
try {
|
||||
const createRes = await fetch(`http://127.0.0.1:${port}/api/routines`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Daily digest',
|
||||
prompt: 'Summarize activity.',
|
||||
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
|
||||
target: { mode: 'create_each_run' },
|
||||
enabled: true,
|
||||
}),
|
||||
});
|
||||
const created = await createRes.json() as { routine: { id: string } };
|
||||
|
||||
const patchRes = await fetch(`http://127.0.0.1:${port}/api/routines/${created.routine.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enabled: false,
|
||||
target: { mode: 'reuse', projectId: 'proj-1' },
|
||||
}),
|
||||
});
|
||||
expect(patchRes.status).toBe(200);
|
||||
|
||||
const patched = await patchRes.json() as {
|
||||
routine: { enabled: boolean; target: { mode: string; projectId: string } };
|
||||
};
|
||||
expect(patched.routine.enabled).toBe(false);
|
||||
expect(patched.routine.target).toEqual({ mode: 'reuse', projectId: 'proj-1' });
|
||||
expect(rescheduleOne).toHaveBeenLastCalledWith(created.routine.id);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => createServer.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it('runs a routine now and exposes its run history', async () => {
|
||||
const { app, runNow } = buildApp();
|
||||
const { server, port } = await listen(app);
|
||||
try {
|
||||
const createRes = await fetch(`http://127.0.0.1:${port}/api/routines`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Daily digest',
|
||||
prompt: 'Summarize activity.',
|
||||
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
|
||||
target: { mode: 'create_each_run' },
|
||||
enabled: true,
|
||||
}),
|
||||
});
|
||||
const created = await createRes.json() as { routine: { id: string } };
|
||||
|
||||
const runRes = await fetch(`http://127.0.0.1:${port}/api/routines/${created.routine.id}/run`, {
|
||||
method: 'POST',
|
||||
});
|
||||
expect(runRes.status).toBe(202);
|
||||
const runJson = await runRes.json() as {
|
||||
projectId: string;
|
||||
conversationId: string;
|
||||
agentRunId: string;
|
||||
run: { status: string; trigger: string };
|
||||
};
|
||||
expect(runJson.projectId).toBe('proj-run');
|
||||
expect(runJson.conversationId).toBe('conv-run');
|
||||
expect(runJson.agentRunId).toBe('agent-run-1');
|
||||
expect(runJson.run.status).toBe('queued');
|
||||
expect(runNow).toHaveBeenCalledWith(created.routine.id);
|
||||
|
||||
const runsRes = await fetch(`http://127.0.0.1:${port}/api/routines/${created.routine.id}/runs?limit=10`);
|
||||
expect(runsRes.status).toBe(200);
|
||||
const runsJson = await runsRes.json() as { runs: Array<{ id: string; status: string }> };
|
||||
expect(runsJson.runs).toHaveLength(1);
|
||||
expect(runsJson.runs[0]).toMatchObject({ id: 'run-1', status: 'queued' });
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects reuse-mode creation when the target project does not exist', async () => {
|
||||
const { app } = buildApp();
|
||||
const { server, port } = await listen(app);
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/api/routines`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Weekly digest',
|
||||
prompt: 'Summarize GitHub and design activity.',
|
||||
schedule: {
|
||||
kind: 'weekly',
|
||||
weekday: 3,
|
||||
time: '09:00',
|
||||
timezone: 'UTC',
|
||||
},
|
||||
target: { mode: 'reuse', projectId: 'missing-project' },
|
||||
enabled: true,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const json = await res.json() as { error: string };
|
||||
expect(json.error).toContain('target project missing-project not found');
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it('deletes a routine and unschedules it', async () => {
|
||||
const { app, unschedule } = buildApp();
|
||||
const { server, port } = await listen(app);
|
||||
try {
|
||||
const createRes = await fetch(`http://127.0.0.1:${port}/api/routines`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Daily digest',
|
||||
prompt: 'Summarize activity.',
|
||||
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
|
||||
target: { mode: 'create_each_run' },
|
||||
enabled: true,
|
||||
}),
|
||||
});
|
||||
const created = await createRes.json() as { routine: { id: string } };
|
||||
|
||||
const deleteRes = await fetch(`http://127.0.0.1:${port}/api/routines/${created.routine.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
expect(deleteRes.status).toBe(204);
|
||||
expect(unschedule).toHaveBeenCalledWith(created.routine.id);
|
||||
|
||||
const getRes = await fetch(`http://127.0.0.1:${port}/api/routines/${created.routine.id}`);
|
||||
expect(getRes.status).toBe(404);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 404 for run history on an unknown routine', async () => {
|
||||
const { app } = buildApp();
|
||||
const { server, port } = await listen(app);
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/api/routines/missing/runs?limit=10`);
|
||||
expect(res.status).toBe(404);
|
||||
const json = await res.json() as { error: string };
|
||||
expect(json.error).toBe('routine not found');
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid schedule input during routine creation', async () => {
|
||||
const { app } = buildApp();
|
||||
const { server, port } = await listen(app);
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/api/routines`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Broken hourly digest',
|
||||
prompt: 'Summarize activity.',
|
||||
schedule: { kind: 'hourly', minute: 75 },
|
||||
target: { mode: 'create_each_run' },
|
||||
enabled: true,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const json = await res.json() as { error: string };
|
||||
expect(json.error).toContain('minute');
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -11,12 +11,14 @@ import { tmpdir } from 'node:os';
|
|||
import { join } from 'node:path';
|
||||
import {
|
||||
AGENT_DEFS,
|
||||
applyAgentLaunchEnv,
|
||||
buildLiveArtifactsMcpServersForAgent,
|
||||
checkPromptArgvBudget,
|
||||
checkWindowsCmdShimCommandLineBudget,
|
||||
checkWindowsDirectExeCommandLineBudget,
|
||||
detectAgents,
|
||||
inspectAgentExecutableResolution,
|
||||
resolveAgentLaunch,
|
||||
resolveAgentExecutable,
|
||||
spawnEnvForAgent,
|
||||
} from '../../../src/agents.js';
|
||||
|
|
@ -25,6 +27,7 @@ import type { RuntimeAgentDef } from '../../../src/runtimes/types.js';
|
|||
export {
|
||||
assert,
|
||||
AGENT_DEFS,
|
||||
applyAgentLaunchEnv,
|
||||
buildLiveArtifactsMcpServersForAgent,
|
||||
checkPromptArgvBudget,
|
||||
checkWindowsCmdShimCommandLineBudget,
|
||||
|
|
@ -36,6 +39,7 @@ export {
|
|||
mkdirSync,
|
||||
mkdtempSync,
|
||||
resolveAgentExecutable,
|
||||
resolveAgentLaunch,
|
||||
rmSync,
|
||||
spawnEnvForAgent,
|
||||
tmpdir,
|
||||
|
|
|
|||
159
apps/daemon/tests/runtimes/launch.test.ts
Normal file
159
apps/daemon/tests/runtimes/launch.test.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { delimiter, join } from 'node:path';
|
||||
import { realpathSync, symlinkSync } from 'node:fs';
|
||||
import { test } from 'vitest';
|
||||
import {
|
||||
applyAgentLaunchEnv,
|
||||
assert,
|
||||
chmodSync,
|
||||
codex,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
resolveAgentLaunch,
|
||||
rmSync,
|
||||
tmpdir,
|
||||
withEnvSnapshot,
|
||||
writeFileSync,
|
||||
} from './helpers/test-helpers.js';
|
||||
|
||||
const fsTest = process.platform === 'win32' ? test.skip : test;
|
||||
|
||||
test('applyAgentLaunchEnv prepends the selected executable dirname and dedupes PATH', () => {
|
||||
const launch = {
|
||||
childPathPrepend: ['/opt/tools/bin', '/opt/tools/bin'],
|
||||
};
|
||||
|
||||
const env = applyAgentLaunchEnv(
|
||||
{ PATH: ['/usr/bin', '/opt/tools/bin', '/bin', '/usr/bin'].join(delimiter) },
|
||||
launch,
|
||||
);
|
||||
|
||||
assert.equal(env.PATH, ['/opt/tools/bin', '/usr/bin', '/bin'].join(delimiter));
|
||||
});
|
||||
|
||||
fsTest('resolveAgentLaunch selects nvm-installed codex under a minimal PATH and prepends its dirname', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'od-launch-nvm-'));
|
||||
try {
|
||||
return withEnvSnapshot(['HOME', 'PATH', 'OD_AGENT_HOME'], () => {
|
||||
const binDir = join(home, '.nvm', 'versions', 'node', '24.11.0', 'bin');
|
||||
const codexBin = join(binDir, 'codex');
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
writeFileSync(codexBin, '#!/bin/sh\nexit 0\n');
|
||||
chmodSync(codexBin, 0o755);
|
||||
process.env.HOME = home;
|
||||
process.env.PATH = '/usr/bin:/bin';
|
||||
process.env.OD_AGENT_HOME = home;
|
||||
|
||||
const launch = resolveAgentLaunch(codex);
|
||||
|
||||
assert.equal(launch.selectedPath, codexBin);
|
||||
assert.equal(launch.launchPath, codexBin);
|
||||
assert.deepEqual(launch.childPathPrepend, [binDir]);
|
||||
});
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
fsTest('resolveAgentLaunch resolves a Codex npm wrapper to the native packaged binary', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'od-launch-codex-wrapper-'));
|
||||
try {
|
||||
return withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], () => {
|
||||
const wrapperPkgDir = join(root, 'node_modules', '@openai', 'codex');
|
||||
const wrapperRealPath = join(wrapperPkgDir, 'bin', 'codex.js');
|
||||
const wrapperLinkDir = join(root, 'node_modules', '.bin');
|
||||
const wrapperLinkPath = join(wrapperLinkDir, 'codex');
|
||||
const nativePkgDir = join(wrapperPkgDir, 'node_modules', '@openai', `codex-${process.platform}-${process.arch}`);
|
||||
const nativePathDir = join(nativePkgDir, 'vendor', codexNativeTargetTriple(), 'path');
|
||||
const nativeBin = join(nativePkgDir, 'vendor', codexNativeTargetTriple(), 'codex', 'codex');
|
||||
mkdirSync(join(wrapperPkgDir, 'bin'), { recursive: true });
|
||||
mkdirSync(wrapperLinkDir, { recursive: true });
|
||||
mkdirSync(join(nativePkgDir, 'vendor', codexNativeTargetTriple(), 'codex'), { recursive: true });
|
||||
mkdirSync(nativePathDir, { recursive: true });
|
||||
writeFileSync(wrapperRealPath, '#!/usr/bin/env node\nrequire("@openai/codex");\n');
|
||||
writeFileSync(nativeBin, '#!/bin/sh\nexit 0\n');
|
||||
chmodSync(wrapperRealPath, 0o755);
|
||||
chmodSync(nativeBin, 0o755);
|
||||
symlinkSync(wrapperRealPath, wrapperLinkPath);
|
||||
process.env.PATH = wrapperLinkDir;
|
||||
process.env.OD_AGENT_HOME = root;
|
||||
|
||||
const launch = resolveAgentLaunch(codex);
|
||||
|
||||
assert.equal(launch.selectedPath, wrapperLinkPath);
|
||||
assert.equal(launch.launchPath, realpathSync(nativeBin));
|
||||
assert.equal(launch.launchKind, 'codex-native');
|
||||
assert.deepEqual(launch.childPathPrepend, [wrapperLinkDir, realpathSync(nativePathDir)]);
|
||||
assert.equal(launch.diagnostic, null);
|
||||
});
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function codexNativeTargetTriple(): string {
|
||||
if (process.platform === 'darwin' && process.arch === 'arm64') return 'aarch64-apple-darwin';
|
||||
if (process.platform === 'darwin' && process.arch === 'x64') return 'x86_64-apple-darwin';
|
||||
if (process.platform === 'linux' && process.arch === 'arm64') return 'aarch64-unknown-linux-musl';
|
||||
if (process.platform === 'linux' && process.arch === 'x64') return 'x86_64-unknown-linux-musl';
|
||||
if (process.platform === 'win32' && process.arch === 'arm64') return 'aarch64-pc-windows-msvc';
|
||||
if (process.platform === 'win32' && process.arch === 'x64') return 'x86_64-pc-windows-msvc';
|
||||
return `${process.platform}-${process.arch}`;
|
||||
}
|
||||
|
||||
fsTest('resolveAgentLaunch preserves a direct native CODEX_BIN override as the selected launch path', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'od-launch-codex-direct-native-'));
|
||||
try {
|
||||
return withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], () => {
|
||||
const nativeBin = join(root, 'codex-native');
|
||||
const pathCodex = join(root, 'codex');
|
||||
writeFileSync(nativeBin, '#!/bin/sh\nexit 0\n');
|
||||
writeFileSync(pathCodex, '#!/bin/sh\nexit 0\n');
|
||||
chmodSync(nativeBin, 0o755);
|
||||
chmodSync(pathCodex, 0o755);
|
||||
process.env.PATH = root;
|
||||
process.env.OD_AGENT_HOME = root;
|
||||
|
||||
const launch = resolveAgentLaunch(codex, { CODEX_BIN: nativeBin });
|
||||
|
||||
assert.equal(launch.configuredOverridePath, nativeBin);
|
||||
assert.equal(launch.pathResolvedPath, pathCodex);
|
||||
assert.equal(launch.selectedPath, nativeBin);
|
||||
assert.equal(launch.launchPath, nativeBin);
|
||||
assert.equal(launch.launchKind, 'selected');
|
||||
assert.deepEqual(launch.childPathPrepend, [root]);
|
||||
assert.equal(launch.diagnostic, null);
|
||||
});
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
fsTest('resolveAgentLaunch falls back to the Codex wrapper when the native package is missing', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'od-launch-codex-fallback-'));
|
||||
try {
|
||||
return withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], () => {
|
||||
const wrapperPkgDir = join(root, 'node_modules', '@openai', 'codex');
|
||||
const wrapperRealPath = join(wrapperPkgDir, 'bin', 'codex.js');
|
||||
const wrapperLinkDir = join(root, 'node_modules', '.bin');
|
||||
const wrapperLinkPath = join(wrapperLinkDir, 'codex');
|
||||
mkdirSync(join(wrapperPkgDir, 'bin'), { recursive: true });
|
||||
mkdirSync(wrapperLinkDir, { recursive: true });
|
||||
writeFileSync(wrapperRealPath, '#!/usr/bin/env node\nrequire("@openai/codex");\n');
|
||||
chmodSync(wrapperRealPath, 0o755);
|
||||
symlinkSync(wrapperRealPath, wrapperLinkPath);
|
||||
process.env.PATH = wrapperLinkDir;
|
||||
process.env.OD_AGENT_HOME = root;
|
||||
|
||||
const launch = resolveAgentLaunch(codex);
|
||||
|
||||
assert.equal(launch.selectedPath, wrapperLinkPath);
|
||||
assert.equal(launch.launchPath, wrapperLinkPath);
|
||||
assert.equal(launch.launchKind, 'selected');
|
||||
assert.deepEqual(launch.childPathPrepend, [wrapperLinkDir]);
|
||||
assert.match(launch.diagnostic ?? '', /native binary/i);
|
||||
assert.match(launch.diagnostic ?? '', /CODEX_BIN/);
|
||||
});
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
@ -29,6 +29,7 @@ import {
|
|||
import { readProcessStamp } from "@open-design/platform";
|
||||
|
||||
import { createDesktopRuntime } from "./runtime.js";
|
||||
import { attachDesktopProcessErrorFilter } from "./uncaught-exception.js";
|
||||
|
||||
// Re-export pure URL-policy helpers so the packaged workspace's
|
||||
// vitest can pin their behaviour without spinning up a full Electron
|
||||
|
|
@ -171,6 +172,15 @@ export async function runDesktopMain(
|
|||
runtime: SidecarRuntimeContext<SidecarStamp>,
|
||||
options: DesktopMainOptions = {},
|
||||
): Promise<void> {
|
||||
// Install the defensive uncaughtException filter BEFORE awaiting
|
||||
// app.whenReady, so a setTypeOfService EINVAL thrown by undici during
|
||||
// the renderer's first fetch is intercepted rather than surfacing as
|
||||
// Electron's "JavaScript error in main process" dialog (issue #647).
|
||||
// The packaged entry has the parallel filter wired in
|
||||
// apps/packaged/src/logging.ts; both must stay in sync until the
|
||||
// helper is promoted to a shared workspace package.
|
||||
attachDesktopProcessErrorFilter();
|
||||
|
||||
await app.whenReady();
|
||||
|
||||
// PR #974: mint a per-process auth secret and hand it to the daemon
|
||||
|
|
|
|||
155
apps/desktop/src/main/uncaught-exception.ts
Normal file
155
apps/desktop/src/main/uncaught-exception.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
/**
|
||||
* Defensive uncaught-exception filter for the dev / source-built desktop
|
||||
* Electron main process.
|
||||
*
|
||||
* The packaged Electron entry (`apps/packaged/src/logging.ts`) already
|
||||
* carries the same filter, where it was added for issue #895 (the
|
||||
* `setTypeOfService EINVAL` crash from undici socket internals on certain
|
||||
* macOS / VPN configurations). The same crash reproduces on the dev
|
||||
* entry when users switch settings tabs because the renderer fires a
|
||||
* fresh `fetch` and undici tries to set the IP_TOS byte on the outbound
|
||||
* socket; on a kernel that refuses, the rejection bubbles to Electron's
|
||||
* default handler and surfaces as the "JavaScript error in main process"
|
||||
* dialog reported in issue #647.
|
||||
*
|
||||
* Why this file isn't a direct import from `apps/packaged`: AGENTS.md
|
||||
* forbids one app package from importing another app's private `src/`.
|
||||
* The honest fix is to promote the helper to a shared workspace package
|
||||
* (e.g. `@open-design/platform`); doing that as part of this bug-fix
|
||||
* sprint would balloon the PR. Until that promotion lands, this module
|
||||
* is the source of truth for the desktop entry's filter and the two
|
||||
* copies should stay in sync. Any change to the matching rules here
|
||||
* should land in `apps/packaged/src/logging.ts` in the same PR.
|
||||
*
|
||||
* Behaviour contract (mirrors the packaged version):
|
||||
*
|
||||
* 1. `isHarmlessSocketOptionError(value)` recognises the canonical
|
||||
* undici `setTypeOfService EINVAL` shape and nothing else. A
|
||||
* contradicting `code` (e.g. `EACCES`) explicitly fails the match
|
||||
* so real bugs don't get hidden.
|
||||
*
|
||||
* 2. The installed `uncaughtException` handler logs harmless errors
|
||||
* and returns silently. For anything else it removes itself from
|
||||
* the listener list and re-throws via `setImmediate`, restoring
|
||||
* Node's default crash path so Electron's native error dialog
|
||||
* renders exactly as it would without this filter.
|
||||
*
|
||||
* 3. The installed `unhandledRejection` handler mirrors that policy:
|
||||
* harmless `setTypeOfService EINVAL` rejections log at warn and
|
||||
* return; every other rejection logs at error, removes the
|
||||
* listener, and schedules a re-throw via `setImmediate`. Without
|
||||
* the detach + rethrow a non-harmless rejection would land in
|
||||
* this log line and silently keep the process alive, which would
|
||||
* hide real main-process bugs (issue #647 review by Siri-Ray and
|
||||
* the codex P2 thread on PR #1298).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Recognise the known-harmless `setTypeOfService EINVAL` shape thrown by
|
||||
* undici socket internals when the kernel refuses to set the IP_TOS byte
|
||||
* on an outbound socket. Exported so a unit test can pin the exact
|
||||
* surface this filter matches.
|
||||
*/
|
||||
export function isHarmlessSocketOptionError(value: unknown): boolean {
|
||||
if (!(value instanceof Error)) return false;
|
||||
const message = typeof value.message === 'string' ? value.message : '';
|
||||
if (!message) return false;
|
||||
if (!message.includes('setTypeOfService')) return false;
|
||||
const code = (value as NodeJS.ErrnoException).code;
|
||||
if (typeof code === 'string' && code.length > 0) {
|
||||
// Structured code is authoritative when present. Only EINVAL
|
||||
// qualifies; anything else (EACCES, EPERM, ECONNRESET, …) is a
|
||||
// contradicting signal and the filter rejects the match so a real
|
||||
// bug carrying a stale `setTypeOfService` substring in its message
|
||||
// doesn't slip through.
|
||||
return code === 'EINVAL';
|
||||
}
|
||||
// No structured code: fall back to message-based detection. libuv's
|
||||
// error string is `<syscall> <errcode>` so the EINVAL token sits
|
||||
// alongside the syscall name.
|
||||
return message.includes('EINVAL');
|
||||
}
|
||||
|
||||
/** Minimal logger surface the desktop entry already has by way of
|
||||
* `console.*`. Exposed as an injectable parameter so tests can capture
|
||||
* log calls without mocking the console. */
|
||||
export interface DesktopErrorFilterLogger {
|
||||
warn: (message: string, meta?: Record<string, unknown>) => void;
|
||||
error: (message: string, meta?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
/** Build the named handler so a unit test can drive it without
|
||||
* installing it on the live `process` object. */
|
||||
export function createDesktopUncaughtExceptionHandler(
|
||||
logger: DesktopErrorFilterLogger,
|
||||
): (error: unknown) => void {
|
||||
const handler = (error: unknown): void => {
|
||||
if (isHarmlessSocketOptionError(error)) {
|
||||
logger.warn('desktop main swallowed harmless socket option error', { error });
|
||||
return;
|
||||
}
|
||||
logger.error('desktop main fatal uncaught exception', { error });
|
||||
process.removeListener('uncaughtException', handler);
|
||||
setImmediate(() => {
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
return handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parallel factory for the `unhandledRejection` listener. Harmless
|
||||
* undici `setTypeOfService EINVAL` rejections log at warn and return;
|
||||
* anything else logs at error, removes the listener, and schedules a
|
||||
* re-throw via `setImmediate`. The detached re-throw lands in the
|
||||
* `uncaughtException` listener installed above, which then walks its
|
||||
* own non-harmless path (log + detach + rethrow), so Node's default
|
||||
* crash semantics take over and Electron's native error dialog
|
||||
* renders as it would without this filter. Pinned by a unit test so a
|
||||
* future refactor that drops the detach can't silently regress.
|
||||
*/
|
||||
export function createDesktopUnhandledRejectionHandler(
|
||||
logger: DesktopErrorFilterLogger,
|
||||
): (reason: unknown) => void {
|
||||
const handler = (reason: unknown): void => {
|
||||
if (isHarmlessSocketOptionError(reason)) {
|
||||
logger.warn('desktop main swallowed harmless socket option rejection', { reason });
|
||||
return;
|
||||
}
|
||||
logger.error('desktop main unhandled rejection', { reason });
|
||||
process.removeListener('unhandledRejection', handler);
|
||||
setImmediate(() => {
|
||||
throw reason;
|
||||
});
|
||||
};
|
||||
return handler;
|
||||
}
|
||||
|
||||
/** Install the filter on `process`. Idempotent across the dev entry's
|
||||
* hot-reload paths because Node's listener registry deduplicates
|
||||
* identical function references; we capture the once-built handler
|
||||
* inside this module so repeated calls don't pile up. */
|
||||
let installedHandler: ((error: unknown) => void) | null = null;
|
||||
|
||||
export function attachDesktopProcessErrorFilter(
|
||||
logger: DesktopErrorFilterLogger = consoleLogger(),
|
||||
): void {
|
||||
if (installedHandler !== null) return;
|
||||
const handler = createDesktopUncaughtExceptionHandler(logger);
|
||||
installedHandler = handler;
|
||||
process.on('uncaughtException', handler);
|
||||
process.on('unhandledRejection', createDesktopUnhandledRejectionHandler(logger));
|
||||
}
|
||||
|
||||
function consoleLogger(): DesktopErrorFilterLogger {
|
||||
return {
|
||||
warn: (message, meta) => {
|
||||
if (meta === undefined) console.warn(message);
|
||||
else console.warn(message, meta);
|
||||
},
|
||||
error: (message, meta) => {
|
||||
if (meta === undefined) console.error(message);
|
||||
else console.error(message, meta);
|
||||
},
|
||||
};
|
||||
}
|
||||
194
apps/desktop/tests/main/uncaught-exception.test.ts
Normal file
194
apps/desktop/tests/main/uncaught-exception.test.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
/**
|
||||
* Regression coverage for the desktop main entry's harmless-socket-option
|
||||
* filter, added to fix issue #647. The packaged entry has the parallel
|
||||
* filter pinned in `apps/packaged/tests/logging.test.ts`; this suite
|
||||
* mirrors it so the two copies of the matcher stay in lockstep until the
|
||||
* helper is promoted to a shared workspace package.
|
||||
*
|
||||
* Match strategy is intentionally narrow — name the syscall AND verify
|
||||
* the EINVAL code — so a future regression that broadens the filter to
|
||||
* "every EINVAL" (which would silently swallow real bugs) trips a test.
|
||||
*
|
||||
* @see https://github.com/nexu-io/open-design/issues/647
|
||||
* @see https://github.com/nexu-io/open-design/issues/895
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createDesktopUncaughtExceptionHandler,
|
||||
createDesktopUnhandledRejectionHandler,
|
||||
isHarmlessSocketOptionError,
|
||||
type DesktopErrorFilterLogger,
|
||||
} from '../../src/main/uncaught-exception.js';
|
||||
|
||||
function stubLogger(): DesktopErrorFilterLogger {
|
||||
return {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('isHarmlessSocketOptionError (desktop main, issue #647)', () => {
|
||||
it('matches the canonical undici setTypeOfService EINVAL shape', () => {
|
||||
const error = new Error('setTypeOfService EINVAL') as NodeJS.ErrnoException;
|
||||
error.code = 'EINVAL';
|
||||
error.syscall = 'setTypeOfService';
|
||||
expect(isHarmlessSocketOptionError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches when only the message has both tokens (no code property set)', () => {
|
||||
const error = new Error('setTypeOfService EINVAL');
|
||||
expect(isHarmlessSocketOptionError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches when code is EINVAL and message references setTypeOfService', () => {
|
||||
const error = new Error('Error: setTypeOfService failed') as NodeJS.ErrnoException;
|
||||
error.code = 'EINVAL';
|
||||
expect(isHarmlessSocketOptionError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT match a generic EINVAL', () => {
|
||||
// Guard against a future refactor that broadens the filter to
|
||||
// "every EINVAL" and silently swallows configuration errors.
|
||||
const error = new Error('write EINVAL') as NodeJS.ErrnoException;
|
||||
error.code = 'EINVAL';
|
||||
expect(isHarmlessSocketOptionError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT match a setTypeOfService error with a different errno (EACCES)', () => {
|
||||
const error = new Error('setTypeOfService EACCES') as NodeJS.ErrnoException;
|
||||
error.code = 'EACCES';
|
||||
expect(isHarmlessSocketOptionError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats the structured code as authoritative when it contradicts the message', () => {
|
||||
// A stale `setTypeOfService EINVAL` substring in a message paired
|
||||
// with a real EACCES code must NOT swallow the underlying
|
||||
// permission failure.
|
||||
const error = new Error('setTypeOfService EINVAL') as NodeJS.ErrnoException;
|
||||
error.code = 'EACCES';
|
||||
expect(isHarmlessSocketOptionError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-Error values', () => {
|
||||
expect(isHarmlessSocketOptionError('setTypeOfService EINVAL')).toBe(false);
|
||||
expect(isHarmlessSocketOptionError(null)).toBe(false);
|
||||
expect(isHarmlessSocketOptionError(undefined)).toBe(false);
|
||||
expect(isHarmlessSocketOptionError({ message: 'setTypeOfService EINVAL', code: 'EINVAL' })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an Error with a non-string message', () => {
|
||||
const error = new Error();
|
||||
(error as { message?: unknown }).message = 42;
|
||||
expect(isHarmlessSocketOptionError(error)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDesktopUncaughtExceptionHandler (desktop main, issue #647)', () => {
|
||||
it('logs at warn level and returns silently for the harmless shape', () => {
|
||||
const logger = stubLogger();
|
||||
const handler = createDesktopUncaughtExceptionHandler(logger);
|
||||
const error = new Error('setTypeOfService EINVAL') as NodeJS.ErrnoException;
|
||||
error.code = 'EINVAL';
|
||||
const removeListener = vi.spyOn(process, 'removeListener');
|
||||
const setImmediateSpy = vi.spyOn(global, 'setImmediate');
|
||||
|
||||
handler(error);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
expect(removeListener).not.toHaveBeenCalled();
|
||||
expect(setImmediateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs at error level, detaches itself, and re-throws for any other error', () => {
|
||||
const logger = stubLogger();
|
||||
const handler = createDesktopUncaughtExceptionHandler(logger);
|
||||
const error = new Error('boom');
|
||||
const removeListener = vi.spyOn(process, 'removeListener').mockReturnValue(process);
|
||||
// Capture the deferred throw without letting it escape into the
|
||||
// test runner.
|
||||
const setImmediateSpy = vi
|
||||
.spyOn(global, 'setImmediate')
|
||||
.mockImplementation(((cb: () => void) => {
|
||||
// Capture the callback for assertion but don't invoke it
|
||||
// synchronously, the real semantics rely on the call
|
||||
// happening AFTER the listener has been removed.
|
||||
return cb as unknown as NodeJS.Immediate;
|
||||
}) as unknown as typeof setImmediate);
|
||||
|
||||
handler(error);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
// Listener removal MUST happen before the deferred throw so the
|
||||
// crash falls through to Node's default handler rather than
|
||||
// re-entering this filter.
|
||||
expect(removeListener).toHaveBeenCalledWith('uncaughtException', handler);
|
||||
expect(setImmediateSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDesktopUnhandledRejectionHandler (desktop main, issue #647 review follow-up)', () => {
|
||||
it('logs at warn level and returns silently for a harmless rejection', () => {
|
||||
const logger = stubLogger();
|
||||
const handler = createDesktopUnhandledRejectionHandler(logger);
|
||||
const reason = new Error('setTypeOfService EINVAL') as NodeJS.ErrnoException;
|
||||
reason.code = 'EINVAL';
|
||||
const removeListener = vi.spyOn(process, 'removeListener');
|
||||
const setImmediateSpy = vi.spyOn(global, 'setImmediate');
|
||||
|
||||
handler(reason);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
expect(removeListener).not.toHaveBeenCalled();
|
||||
expect(setImmediateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs at error level, detaches itself, and re-throws for a non-harmless rejection', () => {
|
||||
// Regression guard: a previous revision of this file logged
|
||||
// non-harmless rejections and returned, which kept the dev main
|
||||
// process alive after arbitrary rejected promises and hid real
|
||||
// bugs from Node/Electron's default fail-fast path. The
|
||||
// unhandledRejection handler must mirror the uncaughtException
|
||||
// fall-through so only the EINVAL shape is swallowed.
|
||||
const logger = stubLogger();
|
||||
const handler = createDesktopUnhandledRejectionHandler(logger);
|
||||
const reason = new Error('real ipc registration failure');
|
||||
const removeListener = vi.spyOn(process, 'removeListener').mockReturnValue(process);
|
||||
const setImmediateSpy = vi
|
||||
.spyOn(global, 'setImmediate')
|
||||
.mockImplementation(((cb: () => void) => cb as unknown as NodeJS.Immediate) as unknown as typeof setImmediate);
|
||||
|
||||
handler(reason);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(removeListener).toHaveBeenCalledWith('unhandledRejection', handler);
|
||||
expect(setImmediateSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('treats non-Error rejection reasons as non-harmless and falls through', () => {
|
||||
// Primitive rejections (e.g. `Promise.reject(42)`) are never the
|
||||
// undici socket shape, so the handler must take the fail-fast
|
||||
// path rather than silently log them away.
|
||||
const logger = stubLogger();
|
||||
const handler = createDesktopUnhandledRejectionHandler(logger);
|
||||
const removeListener = vi.spyOn(process, 'removeListener').mockReturnValue(process);
|
||||
const setImmediateSpy = vi
|
||||
.spyOn(global, 'setImmediate')
|
||||
.mockImplementation(((cb: () => void) => cb as unknown as NodeJS.Immediate) as unknown as typeof setImmediate);
|
||||
|
||||
handler(42);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
expect(removeListener).toHaveBeenCalledWith('unhandledRejection', handler);
|
||||
expect(setImmediateSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -114,6 +114,38 @@ export function createFatalUncaughtExceptionHandler(
|
|||
return handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parallel factory for the `unhandledRejection` listener installed by
|
||||
* `attachPackagedDesktopProcessLogging`. The harmless / fall-through
|
||||
* split must match `createFatalUncaughtExceptionHandler`: harmless
|
||||
* `setTypeOfService EINVAL` rejections log at warn and return,
|
||||
* anything else logs at error, removes the listener, and re-throws
|
||||
* via `setImmediate`.
|
||||
*
|
||||
* Without the detach + rethrow a non-harmless rejection would land in
|
||||
* this log line and silently keep the process alive, which would hide
|
||||
* real main-process bugs from Node/Electron's default fail-fast path
|
||||
* (the exact regression Siri-Ray and the codex P2 thread flagged on
|
||||
* the parallel desktop filter in PR #1298). The same matcher feeds
|
||||
* both factories, so the apps/desktop sibling stays in lockstep.
|
||||
*/
|
||||
export function createFatalUnhandledRejectionHandler(
|
||||
logger: PackagedDesktopLogger,
|
||||
): (reason: unknown) => void {
|
||||
const handler = (reason: unknown): void => {
|
||||
if (isHarmlessSocketOptionError(reason)) {
|
||||
logger.warn("packaged desktop swallowed harmless socket option rejection", { reason });
|
||||
return;
|
||||
}
|
||||
logger.error("packaged desktop unhandled rejection", { reason });
|
||||
process.removeListener("unhandledRejection", handler);
|
||||
setImmediate(() => {
|
||||
throw reason;
|
||||
});
|
||||
};
|
||||
return handler;
|
||||
}
|
||||
|
||||
function normalizeMeta(meta: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
|
||||
if (meta == null) return undefined;
|
||||
return Object.fromEntries(
|
||||
|
|
@ -230,13 +262,12 @@ export function attachPackagedDesktopProcessLogging(options: {
|
|||
// existed. See `createFatalUncaughtExceptionHandler` for the
|
||||
// unit-tested factory.
|
||||
process.on("uncaughtException", createFatalUncaughtExceptionHandler(logger));
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
if (isHarmlessSocketOptionError(reason)) {
|
||||
logger.warn("packaged desktop swallowed harmless socket option rejection", { reason });
|
||||
return;
|
||||
}
|
||||
logger.error("packaged desktop unhandled rejection", { reason });
|
||||
});
|
||||
// The unhandledRejection handler must mirror the uncaughtException
|
||||
// policy: harmless EINVAL shapes are swallowed, every other reason
|
||||
// takes the fail-fast path so a real main-process bug surfaces
|
||||
// through Node/Electron's default crash semantics instead of being
|
||||
// hidden as a log line. See `createFatalUnhandledRejectionHandler`.
|
||||
process.on("unhandledRejection", createFatalUnhandledRejectionHandler(logger));
|
||||
process.on("beforeExit", (code) => {
|
||||
logger.warn("packaged desktop beforeExit", { code });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import {
|
||||
createFatalUncaughtExceptionHandler,
|
||||
createFatalUnhandledRejectionHandler,
|
||||
isHarmlessSocketOptionError,
|
||||
type PackagedDesktopLogger,
|
||||
} from '../src/logging.js';
|
||||
|
|
@ -193,3 +194,80 @@ describe('createFatalUncaughtExceptionHandler (issue #906)', () => {
|
|||
expect(logger.error).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
// The parallel `unhandledRejection` listener mirrors the
|
||||
// uncaughtException policy: harmless EINVAL rejections log at warn
|
||||
// and return, anything else logs at error, detaches the listener, and
|
||||
// schedules a re-throw via setImmediate so Node/Electron's default
|
||||
// fail-fast path takes over. Before this factory landed, the inline
|
||||
// listener logged non-harmless rejections and returned, which silently
|
||||
// kept the main process alive after any rejected promise. Siri-Ray
|
||||
// and the codex P2 thread on PR #1298 flagged the same gap on the
|
||||
// parallel apps/desktop filter, so the two copies stay in lockstep.
|
||||
describe('createFatalUnhandledRejectionHandler (issue #647 review follow-up)', () => {
|
||||
it('logs harmless socket option rejections at warn level and returns silently', () => {
|
||||
const logger = stubLogger();
|
||||
const handler = createFatalUnhandledRejectionHandler(logger);
|
||||
const harmless = new Error('setTypeOfService EINVAL') as NodeJS.ErrnoException;
|
||||
harmless.code = 'EINVAL';
|
||||
|
||||
const removeListenerSpy = vi.spyOn(process, 'removeListener');
|
||||
const setImmediateSpy = vi.spyOn(globalThis, 'setImmediate');
|
||||
|
||||
handler(harmless);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
expect(removeListenerSpy).not.toHaveBeenCalled();
|
||||
expect(setImmediateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes itself from unhandledRejection listeners before scheduling the rethrow', () => {
|
||||
const logger = stubLogger();
|
||||
const handler = createFatalUnhandledRejectionHandler(logger);
|
||||
|
||||
const removeListenerSpy = vi.spyOn(process, 'removeListener');
|
||||
const scheduled: Array<() => void> = [];
|
||||
const setImmediateSpy = vi
|
||||
.spyOn(globalThis, 'setImmediate')
|
||||
.mockImplementation(((fn: () => void) => {
|
||||
scheduled.push(fn);
|
||||
return 0 as unknown as NodeJS.Immediate;
|
||||
}) as typeof setImmediate);
|
||||
|
||||
const realBug = new Error('failed ipc registration');
|
||||
handler(realBug);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
expect(removeListenerSpy).toHaveBeenCalledWith('unhandledRejection', handler);
|
||||
const removeOrder = removeListenerSpy.mock.invocationCallOrder[0]!;
|
||||
const setImmediateOrder = setImmediateSpy.mock.invocationCallOrder[0]!;
|
||||
expect(removeOrder).toBeLessThan(setImmediateOrder);
|
||||
|
||||
expect(scheduled).toHaveLength(1);
|
||||
expect(() => scheduled[0]!()).toThrow(realBug);
|
||||
});
|
||||
|
||||
it('falls through for primitive rejection reasons (Promise.reject(42))', () => {
|
||||
// A primitive reason is never the undici socket shape, so the
|
||||
// handler must reach the fail-fast path. Without this guard a
|
||||
// `Promise.reject('boom')` would silently log and disappear.
|
||||
const logger = stubLogger();
|
||||
const handler = createFatalUnhandledRejectionHandler(logger);
|
||||
|
||||
const removeListenerSpy = vi.spyOn(process, 'removeListener');
|
||||
const scheduled: Array<() => void> = [];
|
||||
vi
|
||||
.spyOn(globalThis, 'setImmediate')
|
||||
.mockImplementation(((fn: () => void) => {
|
||||
scheduled.push(fn);
|
||||
return 0 as unknown as NodeJS.Immediate;
|
||||
}) as typeof setImmediate);
|
||||
|
||||
handler(42);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
expect(removeListenerSpy).toHaveBeenCalledWith('unhandledRejection', handler);
|
||||
expect(scheduled).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import {
|
|||
importFolderProject,
|
||||
listProjects,
|
||||
listTemplates,
|
||||
deleteTemplate,
|
||||
patchProject,
|
||||
} from './state/projects';
|
||||
import { useI18n } from './i18n';
|
||||
|
|
@ -509,6 +510,12 @@ export function App() {
|
|||
setTemplates(list);
|
||||
}, []);
|
||||
|
||||
const handleDeleteTemplate = useCallback(async (id: string) => {
|
||||
const ok = await deleteTemplate(id);
|
||||
if (ok) await refreshTemplates();
|
||||
return ok;
|
||||
}, [refreshTemplates]);
|
||||
|
||||
const reloadMediaProvidersFromDaemon = useCallback(async () => {
|
||||
const result = await fetchMediaProvidersFromDaemon();
|
||||
if (result.status !== 'ok') {
|
||||
|
|
@ -1004,6 +1011,7 @@ export function App() {
|
|||
designSystems={enabledDS}
|
||||
projects={projects}
|
||||
templates={templates}
|
||||
onDeleteTemplate={handleDeleteTemplate}
|
||||
promptTemplates={promptTemplates}
|
||||
defaultDesignSystemId={config.designSystemId}
|
||||
config={config}
|
||||
|
|
|
|||
|
|
@ -52,11 +52,17 @@ export interface DirectionCard {
|
|||
bodyFont: string;
|
||||
}
|
||||
|
||||
export interface FormOption {
|
||||
label: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface FormQuestion {
|
||||
id: string;
|
||||
label: string;
|
||||
type: QuestionType;
|
||||
options?: string[];
|
||||
options?: FormOption[];
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
help?: string;
|
||||
|
|
@ -159,9 +165,7 @@ function tryParseForm(body: string, attrs: Record<string, string>): QuestionForm
|
|||
: `q${i + 1}`;
|
||||
const label = typeof qo.label === 'string' ? qo.label : id;
|
||||
const type = normalizeType(qo.type);
|
||||
const options = Array.isArray(qo.options)
|
||||
? qo.options.filter((o): o is string => typeof o === 'string')
|
||||
: undefined;
|
||||
const options = parseOptions(qo.options);
|
||||
const placeholder = typeof qo.placeholder === 'string' ? qo.placeholder : undefined;
|
||||
const help = typeof qo.help === 'string' ? qo.help : undefined;
|
||||
const required = qo.required === true;
|
||||
|
|
@ -172,14 +176,7 @@ function tryParseForm(body: string, attrs: Record<string, string>): QuestionForm
|
|||
? qo.maxSelections
|
||||
: undefined;
|
||||
const cards = parseDirectionCards(qo.cards);
|
||||
const defaultValue =
|
||||
typeof qo.defaultValue === 'string'
|
||||
? qo.defaultValue
|
||||
: Array.isArray(qo.defaultValue)
|
||||
? qo.defaultValue.filter((v): v is string => typeof v === 'string')
|
||||
: typeof qo.default === 'string'
|
||||
? qo.default
|
||||
: undefined;
|
||||
const defaultValue = parseDefaultValue(qo, options);
|
||||
questions.push({
|
||||
id,
|
||||
label,
|
||||
|
|
@ -225,6 +222,57 @@ function normalizeType(raw: unknown): QuestionType {
|
|||
return 'text';
|
||||
}
|
||||
|
||||
function parseOptions(raw: unknown): FormOption[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const options = raw
|
||||
.map(parseOption)
|
||||
.filter((option): option is FormOption => option !== null);
|
||||
return options.length > 0 ? options : undefined;
|
||||
}
|
||||
|
||||
function parseOption(raw: unknown): FormOption | null {
|
||||
if (typeof raw === 'string') {
|
||||
const label = raw.trim();
|
||||
return label.length > 0 ? { label, value: label } : null;
|
||||
}
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const label = typeof obj.label === 'string' ? obj.label.trim() : '';
|
||||
if (label.length === 0) return null;
|
||||
const value =
|
||||
typeof obj.value === 'string' && obj.value.trim().length > 0
|
||||
? obj.value.trim()
|
||||
: label;
|
||||
const description =
|
||||
typeof obj.description === 'string' && obj.description.trim().length > 0
|
||||
? obj.description.trim()
|
||||
: undefined;
|
||||
return {
|
||||
label,
|
||||
value,
|
||||
...(description ? { description } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDefaultValue(
|
||||
question: Record<string, unknown>,
|
||||
options: FormOption[] | undefined,
|
||||
): string | string[] | undefined {
|
||||
const raw =
|
||||
typeof question.defaultValue === 'string' || Array.isArray(question.defaultValue)
|
||||
? question.defaultValue
|
||||
: typeof question.default === 'string'
|
||||
? question.default
|
||||
: undefined;
|
||||
if (typeof raw === 'string') return formOptionValueForLabel({ options }, raw);
|
||||
if (Array.isArray(raw)) {
|
||||
return raw
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
.map((value) => formOptionValueForLabel({ options }, value));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseDirectionCards(raw: unknown): DirectionCard[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const out: DirectionCard[] = [];
|
||||
|
|
@ -266,10 +314,41 @@ export function formatFormAnswers(
|
|||
for (const q of form.questions) {
|
||||
const v = answers[q.id];
|
||||
let display: string;
|
||||
if (Array.isArray(v)) display = v.length > 0 ? v.join(', ') : '(skipped)';
|
||||
else if (typeof v === 'string') display = v.trim().length > 0 ? v.trim() : '(skipped)';
|
||||
if (Array.isArray(v)) {
|
||||
display = v.length > 0 ? v.map((value) => formOptionDisplayForValue(q, value)).join(', ') : '(skipped)';
|
||||
} else if (typeof v === 'string') {
|
||||
display = v.trim().length > 0 ? formOptionDisplayForValue(q, v.trim()) : '(skipped)';
|
||||
}
|
||||
else display = '(skipped)';
|
||||
lines.push(`- ${q.label}: ${display}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formOptionDisplayForValue(
|
||||
question: Pick<FormQuestion, 'options'>,
|
||||
value: string,
|
||||
): string {
|
||||
const match = question.options?.find((option) => option.value === value || option.label === value);
|
||||
if (!match) return value;
|
||||
if (match.value === match.label) return match.label;
|
||||
return `${match.label} [value: ${match.value}]`;
|
||||
}
|
||||
|
||||
export function formOptionLabelForValue(
|
||||
question: Pick<FormQuestion, 'options'>,
|
||||
value: string,
|
||||
): string {
|
||||
const match = question.options?.find((option) => option.value === value || option.label === value);
|
||||
return match?.label ?? value;
|
||||
}
|
||||
|
||||
export function formOptionValueForLabel(
|
||||
question: Pick<FormQuestion, 'options'>,
|
||||
labelOrValue: string,
|
||||
): string {
|
||||
const match = question.options?.find(
|
||||
(option) => option.value === labelOrValue || option.label === labelOrValue,
|
||||
);
|
||||
return match?.value ?? labelOrValue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||
import { Fragment, type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { ToolCard } from "./ToolCard";
|
||||
import { renderMarkdown } from "../runtime/markdown";
|
||||
import { projectFileUrl } from "../providers/registry";
|
||||
|
|
@ -9,7 +9,6 @@ import {
|
|||
import { stripArtifact } from "../artifacts/strip";
|
||||
import { QuestionFormView, parseSubmittedAnswers } from "./QuestionForm";
|
||||
import { Icon } from "./Icon";
|
||||
import { MessageFeedback } from "./MessageFeedback";
|
||||
import { useT } from "../i18n";
|
||||
import { unfinishedTodosFromEvents, type TodoItem } from "../runtime/todos";
|
||||
import type { Dict } from "../i18n/types";
|
||||
|
|
@ -19,7 +18,14 @@ import {
|
|||
messageTime,
|
||||
relativeTimeLong,
|
||||
} from "../utils/chatTime";
|
||||
import type { AgentEvent, ChatMessage, ProjectFile } from "../types";
|
||||
import type {
|
||||
AgentEvent,
|
||||
ChatMessage,
|
||||
ChatMessageFeedbackChange,
|
||||
ChatMessageFeedbackRating,
|
||||
ChatMessageFeedbackReasonCode,
|
||||
ProjectFile,
|
||||
} from "../types";
|
||||
|
||||
type TranslateFn = (
|
||||
key: keyof Dict,
|
||||
|
|
@ -44,6 +50,7 @@ interface Props {
|
|||
// to AssistantMessage; ProjectView wires it into onSend.
|
||||
onSubmitForm?: (text: string) => void;
|
||||
onContinueRemainingTasks?: (todos: TodoItem[]) => void;
|
||||
onFeedback?: (change: ChatMessageFeedbackChange) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -65,6 +72,7 @@ export function AssistantMessage({
|
|||
nextUserContent,
|
||||
onSubmitForm,
|
||||
onContinueRemainingTasks,
|
||||
onFeedback,
|
||||
}: Props) {
|
||||
const t = useT();
|
||||
const events = message.events ?? [];
|
||||
|
|
@ -86,6 +94,23 @@ export function AssistantMessage({
|
|||
!!isLast &&
|
||||
unfinishedTodos.length > 0 &&
|
||||
!!onContinueRemainingTasks;
|
||||
const showFeedback =
|
||||
!!onFeedback &&
|
||||
isFeedbackEligible({
|
||||
streaming,
|
||||
message,
|
||||
hasEmptyResponse,
|
||||
hasUnfinishedTodos: unfinishedTodos.length > 0,
|
||||
hasArtifactWork: hasArtifactWorkSignal(message, produced.length),
|
||||
});
|
||||
const showCompletionRow =
|
||||
showFeedback ||
|
||||
streaming ||
|
||||
!!message.startedAt ||
|
||||
!!message.endedAt ||
|
||||
!!usage ||
|
||||
unfinishedTodos.length > 0 ||
|
||||
hasEmptyResponse;
|
||||
// Track which forms the user submitted in this session so we lock them
|
||||
// immediately on click (without waiting for the parent to re-render).
|
||||
const [locallySubmitted, setLocallySubmitted] = useState<Set<string>>(
|
||||
|
|
@ -157,28 +182,96 @@ export function AssistantMessage({
|
|||
onContinue={() => onContinueRemainingTasks?.(unfinishedTodos)}
|
||||
/>
|
||||
) : null}
|
||||
<AssistantFooter
|
||||
streaming={streaming}
|
||||
startedAt={message.startedAt}
|
||||
endedAt={message.endedAt}
|
||||
usage={usage}
|
||||
hasUnfinishedTodos={unfinishedTodos.length > 0}
|
||||
hasEmptyResponse={hasEmptyResponse}
|
||||
/>
|
||||
{/* Feedback widget for issue #1288 — gated on `produced.length > 0`
|
||||
because the issue scopes feedback to turns that produce a final
|
||||
artifact, not text-only acknowledgements or question-form turns
|
||||
(lefarcen review on PR #1308). The `runSucceeded`
|
||||
guard keeps it off failed runs, and `!hasEmptyResponse` keeps it
|
||||
off agents that succeeded silently with no content. */}
|
||||
{runSucceeded && !hasEmptyResponse && produced.length > 0 ? (
|
||||
<MessageFeedback messageId={message.id} />
|
||||
{showCompletionRow ? (
|
||||
<div className="assistant-completion-row">
|
||||
{showFeedback ? (
|
||||
<AssistantFeedback
|
||||
feedback={message.feedback}
|
||||
onFeedback={onFeedback}
|
||||
footerProps={{
|
||||
streaming,
|
||||
startedAt: message.startedAt,
|
||||
endedAt: message.endedAt,
|
||||
usage,
|
||||
hasUnfinishedTodos: unfinishedTodos.length > 0,
|
||||
hasEmptyResponse,
|
||||
forceVisible: true,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<AssistantFooter
|
||||
streaming={streaming}
|
||||
startedAt={message.startedAt}
|
||||
endedAt={message.endedAt}
|
||||
usage={usage}
|
||||
hasUnfinishedTodos={unfinishedTodos.length > 0}
|
||||
hasEmptyResponse={hasEmptyResponse}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isFeedbackEligible({
|
||||
streaming,
|
||||
message,
|
||||
hasEmptyResponse,
|
||||
hasUnfinishedTodos,
|
||||
hasArtifactWork,
|
||||
}: {
|
||||
streaming: boolean;
|
||||
message: ChatMessage;
|
||||
hasEmptyResponse: boolean;
|
||||
hasUnfinishedTodos: boolean;
|
||||
hasArtifactWork: boolean;
|
||||
}): boolean {
|
||||
if (streaming || hasEmptyResponse || hasUnfinishedTodos) return false;
|
||||
if (!hasArtifactWork) return false;
|
||||
if (message.runStatus) return message.runStatus === "succeeded";
|
||||
return !!message.endedAt;
|
||||
}
|
||||
|
||||
function hasArtifactWorkSignal(message: ChatMessage, producedFileCount: number): boolean {
|
||||
if (producedFileCount > 0) return true;
|
||||
if (message.content.includes("<artifact")) return true;
|
||||
if (hasLiveArtifactMutation(message.events ?? [])) return true;
|
||||
return hasSuccessfulFileMutation(message.events ?? []);
|
||||
}
|
||||
|
||||
function hasLiveArtifactMutation(events: AgentEvent[]): boolean {
|
||||
return events.some((event) => {
|
||||
if (event.kind !== "live_artifact") return false;
|
||||
return event.action === "created" || event.action === "updated";
|
||||
});
|
||||
}
|
||||
|
||||
function hasSuccessfulFileMutation(events: AgentEvent[]): boolean {
|
||||
const errorByToolId = new Map<string, boolean>();
|
||||
for (const event of events) {
|
||||
if (event.kind === "tool_result") {
|
||||
errorByToolId.set(event.toolUseId, event.isError);
|
||||
}
|
||||
}
|
||||
return events.some((event) => {
|
||||
if (event.kind !== "tool_use") return false;
|
||||
if (!isFileMutationToolName(event.name)) return false;
|
||||
return errorByToolId.get(event.id) !== true;
|
||||
});
|
||||
}
|
||||
|
||||
function isFileMutationToolName(name: string): boolean {
|
||||
return (
|
||||
name === "Write" ||
|
||||
name === "write" ||
|
||||
name === "create_file" ||
|
||||
name === "Edit" ||
|
||||
name === "str_replace_edit"
|
||||
);
|
||||
}
|
||||
|
||||
function MessageTimestamp({
|
||||
message,
|
||||
t,
|
||||
|
|
@ -232,6 +325,17 @@ function appendRoleModel(label: string, model: string | null): string {
|
|||
return `${label} · ${model}`;
|
||||
}
|
||||
|
||||
interface AssistantFooterProps {
|
||||
streaming: boolean;
|
||||
startedAt: number | undefined;
|
||||
endedAt: number | undefined;
|
||||
usage: Extract<AgentEvent, { kind: "usage" }> | undefined;
|
||||
hasUnfinishedTodos: boolean;
|
||||
hasEmptyResponse: boolean;
|
||||
feedbackControls?: ReactNode;
|
||||
forceVisible?: boolean;
|
||||
}
|
||||
|
||||
function AssistantFooter({
|
||||
streaming,
|
||||
startedAt,
|
||||
|
|
@ -239,17 +343,20 @@ function AssistantFooter({
|
|||
usage,
|
||||
hasUnfinishedTodos,
|
||||
hasEmptyResponse,
|
||||
}: {
|
||||
streaming: boolean;
|
||||
startedAt: number | undefined;
|
||||
endedAt: number | undefined;
|
||||
usage: Extract<AgentEvent, { kind: "usage" }> | undefined;
|
||||
hasUnfinishedTodos: boolean;
|
||||
hasEmptyResponse: boolean;
|
||||
}) {
|
||||
feedbackControls,
|
||||
forceVisible = false,
|
||||
}: AssistantFooterProps) {
|
||||
const t = useT();
|
||||
const elapsed = useLiveElapsed(streaming, startedAt, endedAt);
|
||||
if (!streaming && !elapsed && !usage && !hasUnfinishedTodos && !hasEmptyResponse) return null;
|
||||
if (
|
||||
!forceVisible &&
|
||||
!streaming &&
|
||||
!elapsed &&
|
||||
!usage &&
|
||||
!hasUnfinishedTodos &&
|
||||
!hasEmptyResponse
|
||||
)
|
||||
return null;
|
||||
return (
|
||||
<div
|
||||
className="assistant-footer"
|
||||
|
|
@ -274,10 +381,216 @@ function AssistantFooter({
|
|||
? ` · $${usage.costUsd.toFixed(4)}`
|
||||
: ""}
|
||||
</span>
|
||||
{feedbackControls}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantFeedback({
|
||||
feedback,
|
||||
onFeedback,
|
||||
footerProps,
|
||||
}: {
|
||||
feedback: ChatMessage["feedback"];
|
||||
onFeedback: (change: ChatMessageFeedbackChange) => void;
|
||||
footerProps: AssistantFooterProps;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [burstKey, setBurstKey] = useState(0);
|
||||
const [reasonRating, setReasonRating] =
|
||||
useState<ChatMessageFeedbackRating | null>(null);
|
||||
const [draftReasonCodes, setDraftReasonCodes] = useState<
|
||||
Set<ChatMessageFeedbackReasonCode>
|
||||
>(() => new Set());
|
||||
const [customReason, setCustomReason] = useState("");
|
||||
const selected = feedback?.rating;
|
||||
useEffect(() => {
|
||||
if (selected) return;
|
||||
setReasonRating(null);
|
||||
}, [selected]);
|
||||
const toggleFeedback = (rating: ChatMessageFeedbackRating) => {
|
||||
const nextRating = selected === rating ? null : rating;
|
||||
if (nextRating === "positive") setBurstKey((key) => key + 1);
|
||||
setDraftReasonCodes(new Set());
|
||||
setCustomReason("");
|
||||
setReasonRating(nextRating);
|
||||
onFeedback(nextRating ? { rating: nextRating } : null);
|
||||
};
|
||||
const toggleReasonCode = (code: ChatMessageFeedbackReasonCode) => {
|
||||
const next = new Set(draftReasonCodes);
|
||||
if (next.has(code)) {
|
||||
next.delete(code);
|
||||
if (code === "other") setCustomReason("");
|
||||
} else {
|
||||
next.add(code);
|
||||
}
|
||||
setDraftReasonCodes(next);
|
||||
};
|
||||
const submitReasons = () => {
|
||||
if (!reasonRating) return;
|
||||
const trimmedCustomReason = customReason.trim();
|
||||
onFeedback({
|
||||
rating: reasonRating,
|
||||
reasonCodes: [...draftReasonCodes],
|
||||
customReason:
|
||||
draftReasonCodes.has("other") && trimmedCustomReason
|
||||
? trimmedCustomReason
|
||||
: undefined,
|
||||
reasonsSubmittedAt: Date.now(),
|
||||
});
|
||||
setReasonRating(null);
|
||||
};
|
||||
const reasonOptions = reasonRating
|
||||
? feedbackReasonOptions(reasonRating, t)
|
||||
: [];
|
||||
const reasonEmoji = reasonRating === "positive" ? "😊" : "😔";
|
||||
const showOtherInput = draftReasonCodes.has("other");
|
||||
const canSubmit =
|
||||
draftReasonCodes.size > 0 || (showOtherInput && customReason.trim().length > 0);
|
||||
const controls = (
|
||||
<span
|
||||
className="assistant-feedback"
|
||||
role="group"
|
||||
aria-label={t("assistant.feedbackPrompt")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="assistant-feedback-button"
|
||||
data-selected={selected === "positive" ? "true" : "false"}
|
||||
aria-pressed={selected === "positive"}
|
||||
aria-label={t("assistant.feedbackPositive")}
|
||||
title={t("assistant.feedbackPositive")}
|
||||
onClick={() => toggleFeedback("positive")}
|
||||
>
|
||||
<Icon name="thumbs-up" size={13} />
|
||||
{burstKey > 0 ? (
|
||||
<span
|
||||
key={burstKey}
|
||||
className="assistant-feedback-burst"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="assistant-feedback-button"
|
||||
data-selected={selected === "negative" ? "true" : "false"}
|
||||
aria-pressed={selected === "negative"}
|
||||
aria-label={t("assistant.feedbackNegative")}
|
||||
title={t("assistant.feedbackNegative")}
|
||||
onClick={() => toggleFeedback("negative")}
|
||||
>
|
||||
<Icon name="thumbs-down" size={13} />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<div className="assistant-feedback-wrap">
|
||||
<AssistantFooter {...footerProps} feedbackControls={controls} />
|
||||
{reasonRating ? (
|
||||
<div className="assistant-feedback-reasons">
|
||||
<div className="assistant-feedback-reason-title">
|
||||
<span>{t("assistant.feedbackReasonTitle")}</span>
|
||||
<span className="assistant-feedback-reason-emoji" aria-hidden="true">
|
||||
{reasonEmoji}
|
||||
</span>
|
||||
</div>
|
||||
<div className="assistant-feedback-reason-options">
|
||||
{reasonOptions.map((option) => (
|
||||
<label
|
||||
key={option.code}
|
||||
className="assistant-feedback-reason-option"
|
||||
data-selected={draftReasonCodes.has(option.code) ? "true" : "false"}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draftReasonCodes.has(option.code)}
|
||||
onChange={() => toggleReasonCode(option.code)}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{showOtherInput ? (
|
||||
<textarea
|
||||
className="assistant-feedback-custom"
|
||||
value={customReason}
|
||||
placeholder={t("assistant.feedbackReasonPlaceholder")}
|
||||
rows={2}
|
||||
onChange={(event) => setCustomReason(event.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="assistant-feedback-submit"
|
||||
disabled={!canSubmit}
|
||||
onClick={submitReasons}
|
||||
>
|
||||
{t("assistant.feedbackReasonSubmit")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function feedbackReasonOptions(
|
||||
rating: ChatMessageFeedbackRating,
|
||||
t: TranslateFn,
|
||||
): Array<{ code: ChatMessageFeedbackReasonCode; label: string }> {
|
||||
const codes: ChatMessageFeedbackReasonCode[] =
|
||||
rating === "positive"
|
||||
? [
|
||||
"matched_request",
|
||||
"strong_visual",
|
||||
"useful_structure",
|
||||
"easy_to_continue",
|
||||
"other",
|
||||
]
|
||||
: [
|
||||
"missed_request",
|
||||
"weak_visual",
|
||||
"incomplete_output",
|
||||
"hard_to_use",
|
||||
"other",
|
||||
];
|
||||
return codes.map((code) => ({ code, label: feedbackReasonLabel(code, t) }));
|
||||
}
|
||||
|
||||
function feedbackReasonLabel(
|
||||
code: ChatMessageFeedbackReasonCode,
|
||||
t: TranslateFn,
|
||||
): string {
|
||||
switch (code) {
|
||||
case "matched_request":
|
||||
return t("assistant.feedbackReasonPositiveMatched");
|
||||
case "strong_visual":
|
||||
return t("assistant.feedbackReasonPositiveVisual");
|
||||
case "useful_structure":
|
||||
return t("assistant.feedbackReasonPositiveUseful");
|
||||
case "easy_to_continue":
|
||||
return t("assistant.feedbackReasonPositiveEasy");
|
||||
case "missed_request":
|
||||
return t("assistant.feedbackReasonNegativeMissed");
|
||||
case "weak_visual":
|
||||
return t("assistant.feedbackReasonNegativeVisual");
|
||||
case "incomplete_output":
|
||||
return t("assistant.feedbackReasonNegativeIncomplete");
|
||||
case "hard_to_use":
|
||||
return t("assistant.feedbackReasonNegativeHard");
|
||||
case "other":
|
||||
return t("assistant.feedbackReasonOther");
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
function UnfinishedTodosPanel({
|
||||
todos,
|
||||
canContinue,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useT } from '../i18n';
|
|||
import type { Dict } from '../i18n/types';
|
||||
import { projectRawUrl } from '../providers/registry';
|
||||
import type { TodoItem } from '../runtime/todos';
|
||||
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ChatMessage, Conversation, PreviewComment, ProjectFile, ProjectMetadata, SkillSummary } from '../types';
|
||||
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ChatMessage, ChatMessageFeedbackChange, Conversation, PreviewComment, ProjectFile, ProjectMetadata, SkillSummary } from '../types';
|
||||
import { dayKey, dayLabel, exactDateTime, messageTime, relativeTimeLong } from '../utils/chatTime';
|
||||
import { commentsToAttachments, simplePositionLabel } from '../comments';
|
||||
import { AssistantMessage } from './AssistantMessage';
|
||||
|
|
@ -228,6 +228,7 @@ interface Props {
|
|||
// routes that text through onSend (no attachments).
|
||||
onSubmitForm?: (text: string) => void;
|
||||
onContinueRemainingTasks?: (assistantMessage: ChatMessage, todos: TodoItem[]) => void;
|
||||
onAssistantFeedback?: (assistantMessage: ChatMessage, change: ChatMessageFeedbackChange) => void;
|
||||
// Header "+" button — kicks off ProjectView's create-conversation flow.
|
||||
onNewConversation?: () => void;
|
||||
newConversationDisabled?: boolean;
|
||||
|
|
@ -279,6 +280,7 @@ export function ChatPane({
|
|||
initialDraft,
|
||||
onSubmitForm,
|
||||
onContinueRemainingTasks,
|
||||
onAssistantFeedback,
|
||||
onNewConversation,
|
||||
newConversationDisabled = false,
|
||||
conversations,
|
||||
|
|
@ -690,6 +692,11 @@ export function ChatPane({
|
|||
? (todos) => onContinueRemainingTasks(m, todos)
|
||||
: undefined
|
||||
}
|
||||
onFeedback={
|
||||
onAssistantFeedback
|
||||
? (rating) => onAssistantFeedback(m, rating)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import { PetRail } from './pet/PetRail';
|
|||
import { PromptTemplatePreviewModal } from './PromptTemplatePreviewModal';
|
||||
import { PromptTemplatesTab } from './PromptTemplatesTab';
|
||||
import { apiProtocolLabel } from '../utils/apiProtocol';
|
||||
import { AppChromeHeader, SettingsIconButton } from './AppChromeHeader';
|
||||
|
||||
type TopTab = 'designs' | 'templates' | 'design-systems' | 'image-templates' | 'video-templates';
|
||||
|
||||
|
|
@ -54,6 +55,7 @@ interface Props {
|
|||
designSystems: DesignSystemSummary[];
|
||||
projects: Project[];
|
||||
templates: ProjectTemplate[];
|
||||
onDeleteTemplate: (id: string) => Promise<boolean>;
|
||||
promptTemplates: PromptTemplateSummary[];
|
||||
defaultDesignSystemId: string | null;
|
||||
config: AppConfig;
|
||||
|
|
@ -232,6 +234,7 @@ export function EntryView({
|
|||
designSystems,
|
||||
projects,
|
||||
templates,
|
||||
onDeleteTemplate,
|
||||
promptTemplates,
|
||||
defaultDesignSystemId,
|
||||
config,
|
||||
|
|
@ -473,6 +476,15 @@ export function EntryView({
|
|||
|
||||
return (
|
||||
<div className="entry-shell">
|
||||
<AppChromeHeader
|
||||
actions={(
|
||||
<SettingsIconButton
|
||||
onClick={() => onOpenSettings()}
|
||||
title={t('entry.openSettingsTitle')}
|
||||
ariaLabel={t('entry.openSettingsAria')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={`entry${petRailHidden ? '' : ' has-pet-rail'}`}
|
||||
style={{
|
||||
|
|
@ -482,21 +494,12 @@ export function EntryView({
|
|||
}}
|
||||
>
|
||||
<aside className="entry-side" style={{ width: sidebarWidth }}>
|
||||
<div className="entry-brand">
|
||||
<span className="entry-brand-mark" aria-hidden>
|
||||
<img src="/app-icon.svg" alt="" className="brand-mark-img" draggable={false} />
|
||||
</span>
|
||||
<div className="entry-brand-text">
|
||||
<div className="entry-brand-title-row">
|
||||
<span className="entry-brand-title">{t('app.brand')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NewProjectPanel
|
||||
skills={skills}
|
||||
designSystems={designSystems}
|
||||
defaultDesignSystemId={defaultDesignSystemId}
|
||||
templates={templates}
|
||||
onDeleteTemplate={onDeleteTemplate}
|
||||
promptTemplates={promptTemplates}
|
||||
onCreate={handleCreate}
|
||||
onImportClaudeDesign={onImportClaudeDesign}
|
||||
|
|
@ -513,7 +516,9 @@ export function EntryView({
|
|||
type="button"
|
||||
className="foot-pill foot-pill-env"
|
||||
onClick={() => onOpenSettings()}
|
||||
aria-label={t('settings.envConfigure')}
|
||||
aria-label={`${t('settings.envConfigure')}: ${
|
||||
config.mode === 'daemon' ? t('settings.localCli') : apiProtocolLabel(config.apiProtocol)
|
||||
} ${envMetaLine}`}
|
||||
title={t('settings.envConfigure')}
|
||||
>
|
||||
<Icon name="settings" size={12} />
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ type IconName =
|
|||
| 'sparkles'
|
||||
| 'stop'
|
||||
| 'sun-moon'
|
||||
| 'thumbs-down'
|
||||
| 'thumbs-up'
|
||||
| 'tweaks'
|
||||
| 'upload'
|
||||
| 'trash'
|
||||
|
|
@ -440,6 +442,20 @@ export function Icon({ name, size = 14, strokeWidth = 1.6, ...rest }: Props) {
|
|||
<path d="m19.1 4.9-1.4 1.4" />
|
||||
</svg>
|
||||
);
|
||||
case 'thumbs-up':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M7 10v11" />
|
||||
<path d="M15 6.8 14 10h4.5a2 2 0 0 1 2 2.3l-1.1 6.6A2.5 2.5 0 0 1 17 21H6a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h2.8L12 4a2 2 0 0 1 3 2.8Z" />
|
||||
</svg>
|
||||
);
|
||||
case 'thumbs-down':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M7 14V3" />
|
||||
<path d="m15 17.2-1-3.2h4.5a2 2 0 0 0 2-2.3L19.4 5A2.5 2.5 0 0 0 17 3H6a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h2.8L12 20a2 2 0 0 0 3-2.8Z" />
|
||||
</svg>
|
||||
);
|
||||
case 'tweaks':
|
||||
return (
|
||||
<svg {...common}>
|
||||
|
|
|
|||
|
|
@ -1,205 +0,0 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useT } from '../i18n';
|
||||
import {
|
||||
useMessageFeedback,
|
||||
type FeedbackRating,
|
||||
type MessageFeedback as MessageFeedbackValue,
|
||||
} from '../state/message-feedback';
|
||||
|
||||
interface Props {
|
||||
messageId: string;
|
||||
/**
|
||||
* Test seam: drop in a deterministic `Date.now()` so snapshot timing
|
||||
* isn't sensitive to wall-clock. Defaults to the real clock.
|
||||
*/
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight feedback widget rendered under each completed assistant
|
||||
* turn (issue #1288). The visibility gate (only after a successful
|
||||
* run) is the caller's responsibility — this component assumes it's
|
||||
* mounted at the right moment and always renders something.
|
||||
*
|
||||
* Lifecycle:
|
||||
*
|
||||
* - **Idle.** Shows the helpful-prompt copy and two thumb buttons.
|
||||
* Clicking either persists the rating immediately and flips to the
|
||||
* submitted state.
|
||||
*
|
||||
* - **Submitted positive.** Confirmation chip plus a Change button
|
||||
* that clears the rating so the user can re-rate. Issue Open
|
||||
* Question 2 ("should users be able to change feedback after
|
||||
* submitting it?") — yes, the lightweight thing is to allow it.
|
||||
*
|
||||
* - **Submitted negative.** Same confirmation chip plus an optional
|
||||
* comment textarea. The comment is persisted on Send, so the
|
||||
* reducer-style record is { rating: 'negative', comment, submittedAt }.
|
||||
* A blank submit just confirms the comment area was acknowledged
|
||||
* without changing the recorded value, matching the issue's
|
||||
* "Optional follow-up reason or comment after negative feedback".
|
||||
*
|
||||
* Persistence is handled by `useMessageFeedback` (localStorage in v1;
|
||||
* see `state/message-feedback.ts` for the rationale).
|
||||
*/
|
||||
export function MessageFeedback({ messageId, now = Date.now }: Props) {
|
||||
const t = useT();
|
||||
const [feedback, setFeedback] = useMessageFeedback(messageId);
|
||||
// Seed the textarea from any saved comment at mount time so a
|
||||
// rehydrated negative feedback shows its prior text. The effect
|
||||
// below re-seeds on rating transitions (idle -> negative, or a
|
||||
// cross-mount sync flipping the rating) without overriding the
|
||||
// user's in-progress edits.
|
||||
const [draftComment, setDraftComment] = useState<string>(
|
||||
() => feedback?.comment ?? '',
|
||||
);
|
||||
const [commentJustSaved, setCommentJustSaved] = useState(false);
|
||||
const lastSeededRatingRef = useRef<FeedbackRating | null>(
|
||||
feedback?.rating ?? null,
|
||||
);
|
||||
|
||||
// Re-seed draftComment whenever the rating itself transitions
|
||||
// (e.g. clearFeedback -> null -> negative again, or a cross-tab
|
||||
// update flips us into a different state). The dependency is
|
||||
// `feedback?.rating` specifically — NOT `feedback?.comment` —
|
||||
// because once the user types into the textarea we must not
|
||||
// override their draft with a stale saved comment. Cleared
|
||||
// comments (user erased the textarea then hit Send) deliberately
|
||||
// surface as an empty draft.
|
||||
useEffect(() => {
|
||||
const nextRating = feedback?.rating ?? null;
|
||||
if (nextRating !== lastSeededRatingRef.current) {
|
||||
lastSeededRatingRef.current = nextRating;
|
||||
setDraftComment(feedback?.comment ?? '');
|
||||
setCommentJustSaved(false);
|
||||
}
|
||||
}, [feedback?.rating, feedback?.comment]);
|
||||
|
||||
const submitRating = (rating: FeedbackRating) => {
|
||||
const submittedAt = now();
|
||||
setFeedback({ rating, submittedAt });
|
||||
setCommentJustSaved(false);
|
||||
};
|
||||
|
||||
const submitComment = () => {
|
||||
if (!feedback) return;
|
||||
const comment = draftComment.trim();
|
||||
const next: MessageFeedbackValue = {
|
||||
...feedback,
|
||||
comment: comment || undefined,
|
||||
submittedAt: feedback.submittedAt,
|
||||
};
|
||||
setFeedback(next);
|
||||
setCommentJustSaved(true);
|
||||
};
|
||||
|
||||
const clearFeedback = () => {
|
||||
setFeedback(null);
|
||||
setDraftComment('');
|
||||
setCommentJustSaved(false);
|
||||
};
|
||||
|
||||
if (!feedback) {
|
||||
return (
|
||||
<div className="message-feedback" data-state="idle">
|
||||
<span className="message-feedback-prompt">{t('feedback.prompt')}</span>
|
||||
<div className="message-feedback-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="message-feedback-button"
|
||||
aria-label={t('feedback.thumbsUp')}
|
||||
title={t('feedback.thumbsUp')}
|
||||
onClick={() => submitRating('positive')}
|
||||
data-testid="message-feedback-positive"
|
||||
>
|
||||
<span aria-hidden>👍</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="message-feedback-button"
|
||||
aria-label={t('feedback.thumbsDown')}
|
||||
title={t('feedback.thumbsDown')}
|
||||
onClick={() => submitRating('negative')}
|
||||
data-testid="message-feedback-negative"
|
||||
>
|
||||
<span aria-hidden>👎</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const confirmationKey
|
||||
= feedback.rating === 'positive'
|
||||
? 'feedback.submittedPositive'
|
||||
: 'feedback.submittedNegative';
|
||||
// Send is enabled when the textarea content differs from what's
|
||||
// already persisted. That covers three intents: writing a new
|
||||
// comment, editing an existing one, and clearing one (typed empty
|
||||
// -> Send -> comment removed). Disabling on draft === saved keeps
|
||||
// the button from being a no-op tap target.
|
||||
const savedComment = feedback.comment ?? '';
|
||||
const sendDisabled = draftComment === savedComment;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="message-feedback"
|
||||
data-state="submitted"
|
||||
data-rating={feedback.rating}
|
||||
>
|
||||
<span
|
||||
className="message-feedback-confirmation"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{t(confirmationKey)}
|
||||
</span>
|
||||
{feedback.rating === 'negative' ? (
|
||||
<div className="message-feedback-comment">
|
||||
<label className="message-feedback-comment-label">
|
||||
{t('feedback.commentLabel')}
|
||||
<textarea
|
||||
className="message-feedback-comment-input"
|
||||
placeholder={t('feedback.commentPlaceholder')}
|
||||
value={draftComment}
|
||||
onChange={(e) => {
|
||||
setDraftComment(e.target.value);
|
||||
if (commentJustSaved) setCommentJustSaved(false);
|
||||
}}
|
||||
data-testid="message-feedback-comment"
|
||||
/>
|
||||
</label>
|
||||
<div className="message-feedback-comment-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="message-feedback-comment-submit"
|
||||
onClick={submitComment}
|
||||
disabled={sendDisabled}
|
||||
data-testid="message-feedback-comment-submit"
|
||||
>
|
||||
{t('feedback.commentSubmit')}
|
||||
</button>
|
||||
{commentJustSaved ? (
|
||||
<span
|
||||
className="message-feedback-comment-saved"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{t('feedback.commentSaved')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="message-feedback-change"
|
||||
onClick={clearFeedback}
|
||||
data-testid="message-feedback-change"
|
||||
>
|
||||
{t('feedback.change')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -140,6 +140,7 @@ interface Props {
|
|||
designSystems: DesignSystemSummary[];
|
||||
defaultDesignSystemId: string | null;
|
||||
templates: ProjectTemplate[];
|
||||
onDeleteTemplate: (id: string) => Promise<boolean>;
|
||||
promptTemplates: PromptTemplateSummary[];
|
||||
onCreate: (input: CreateInput & { requestId?: string }) => void;
|
||||
onImportClaudeDesign?: (file: File) => Promise<void> | void;
|
||||
|
|
@ -203,6 +204,7 @@ export function NewProjectPanel({
|
|||
designSystems,
|
||||
defaultDesignSystemId,
|
||||
templates,
|
||||
onDeleteTemplate,
|
||||
promptTemplates,
|
||||
onCreate,
|
||||
onImportClaudeDesign,
|
||||
|
|
@ -772,6 +774,7 @@ export function NewProjectPanel({
|
|||
templates={templates}
|
||||
value={templateId}
|
||||
onChange={setTemplateId}
|
||||
onDelete={onDeleteTemplate}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t('newproj.toggleAnimations')}
|
||||
|
|
@ -1212,10 +1215,12 @@ function TemplatePicker({
|
|||
templates,
|
||||
value,
|
||||
onChange,
|
||||
onDelete,
|
||||
}: {
|
||||
templates: ProjectTemplate[];
|
||||
value: string | null;
|
||||
onChange: (id: string | null) => void;
|
||||
onDelete: (id: string) => Promise<boolean>;
|
||||
}) {
|
||||
const t = useT();
|
||||
return (
|
||||
|
|
@ -1243,6 +1248,10 @@ function TemplatePicker({
|
|||
key={tpl.id}
|
||||
active={value === tpl.id}
|
||||
onClick={() => onChange(tpl.id)}
|
||||
onDelete={async () => {
|
||||
const ok = await onDelete(tpl.id);
|
||||
if (ok && value === tpl.id) onChange(null);
|
||||
}}
|
||||
name={tpl.name}
|
||||
description={tpl.description ?? fallbackDesc}
|
||||
/>
|
||||
|
|
@ -1553,27 +1562,40 @@ function PromptTemplateAvatar({
|
|||
function TemplateOption({
|
||||
active,
|
||||
onClick,
|
||||
onDelete,
|
||||
name,
|
||||
description,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
onDelete: () => void;
|
||||
name: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`template-option${active ? ' active' : ''}`}
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<span className={`template-radio${active ? ' active' : ''}`} aria-hidden />
|
||||
<span className="template-option-text">
|
||||
<span className="template-option-name">{name}</span>
|
||||
<span className="template-option-desc">{description}</span>
|
||||
</span>
|
||||
</button>
|
||||
<div className={`template-option${active ? ' active' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="template-option-select"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<span className={`template-radio${active ? ' active' : ''}`} aria-hidden />
|
||||
<span className="template-option-text">
|
||||
<span className="template-option-name">{name}</span>
|
||||
<span className="template-option-desc">{description}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="template-option-delete"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
||||
title="Delete template"
|
||||
aria-label={`Delete template ${name}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ import type {
|
|||
ChatAttachment,
|
||||
ChatCommentAttachment,
|
||||
ChatMessage,
|
||||
ChatMessageFeedbackChange,
|
||||
Conversation,
|
||||
DesignSystemSummary,
|
||||
OpenTabsState,
|
||||
|
|
@ -78,7 +79,6 @@ import type {
|
|||
PreviewComment,
|
||||
PreviewCommentTarget,
|
||||
ProjectFile,
|
||||
ProjectPlatform,
|
||||
ProjectTemplate,
|
||||
LiveArtifactEventItem,
|
||||
LiveArtifactSummary,
|
||||
|
|
@ -239,56 +239,6 @@ function projectEventToAgentEvent(evt: ProjectEvent): LiveArtifactEventItem['eve
|
|||
};
|
||||
}
|
||||
|
||||
const PLATFORM_LABELS: Record<ProjectPlatform, string> = {
|
||||
auto: 'Auto',
|
||||
responsive: 'Responsive web',
|
||||
'web-desktop': 'Desktop web',
|
||||
'mobile-ios': 'iOS app',
|
||||
'mobile-android': 'Android app',
|
||||
tablet: 'Tablet app',
|
||||
'desktop-app': 'Desktop app',
|
||||
};
|
||||
|
||||
function labelProjectPlatform(platform: ProjectPlatform | string): string {
|
||||
return PLATFORM_LABELS[platform as ProjectPlatform] ?? platform;
|
||||
}
|
||||
|
||||
function projectTargetPlatforms(project: Project): string[] {
|
||||
const targets = project.metadata?.platformTargets;
|
||||
if (Array.isArray(targets) && targets.length > 0) {
|
||||
return [...new Set(targets)].map(labelProjectPlatform);
|
||||
}
|
||||
if (project.metadata?.platform) {
|
||||
return [labelProjectPlatform(project.metadata.platform)];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
type ProjectFeatureChip = {
|
||||
label: string;
|
||||
title: string;
|
||||
tone: 'landing' | 'widgets';
|
||||
};
|
||||
|
||||
function projectFeatureChips(project: Project): ProjectFeatureChip[] {
|
||||
const chips: ProjectFeatureChip[] = [];
|
||||
if (project.metadata?.includeLandingPage) {
|
||||
chips.push({
|
||||
label: 'Landing page',
|
||||
title: 'Landing page companion surface is enabled for this project',
|
||||
tone: 'landing',
|
||||
});
|
||||
}
|
||||
if (project.metadata?.includeOsWidgets) {
|
||||
chips.push({
|
||||
label: 'OS widgets',
|
||||
title: 'Home-screen, lock-screen, or quick-access OS widget surfaces are enabled',
|
||||
tone: 'widgets',
|
||||
});
|
||||
}
|
||||
return chips;
|
||||
}
|
||||
|
||||
export function ProjectView({
|
||||
project,
|
||||
routeFileName,
|
||||
|
|
@ -917,6 +867,37 @@ export function ProjectView({
|
|||
[project.id, activeConversationId],
|
||||
);
|
||||
|
||||
const handleAssistantFeedback = useCallback(
|
||||
(assistantMessage: ChatMessage, change: ChatMessageFeedbackChange) => {
|
||||
const now = Date.now();
|
||||
updateMessageById(
|
||||
assistantMessage.id,
|
||||
(prev) =>
|
||||
change
|
||||
? {
|
||||
...prev,
|
||||
feedback: {
|
||||
rating: change.rating,
|
||||
reasonCodes: change.reasonCodes,
|
||||
customReason: change.customReason,
|
||||
reasonsSubmittedAt: change.reasonsSubmittedAt,
|
||||
createdAt:
|
||||
prev.feedback?.rating === change.rating
|
||||
? prev.feedback.createdAt
|
||||
: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
}
|
||||
: {
|
||||
...prev,
|
||||
feedback: undefined,
|
||||
},
|
||||
true,
|
||||
);
|
||||
},
|
||||
[updateMessageById],
|
||||
);
|
||||
|
||||
const appendAssistantErrorEvent = useCallback(
|
||||
(messageId: string, message: string) => {
|
||||
if (!message) return;
|
||||
|
|
@ -1986,13 +1967,6 @@ export function ProjectView({
|
|||
return [skill, ds].filter(Boolean).join(' · ') || t('project.metaFreeform');
|
||||
}, [skills, designTemplates, designSystems, project.skillId, project.designSystemId, t]);
|
||||
|
||||
const targetPlatforms = useMemo(() => projectTargetPlatforms(project), [project]);
|
||||
const targetPlatformsLabel = targetPlatforms.join(', ');
|
||||
const visibleTargetPlatforms = targetPlatforms.slice(0, 5);
|
||||
const hiddenTargetPlatformCount = Math.max(0, targetPlatforms.length - visibleTargetPlatforms.length);
|
||||
const featureChips = useMemo(() => projectFeatureChips(project), [project]);
|
||||
const featureChipsLabel = featureChips.map((chip) => chip.label).join(', ');
|
||||
|
||||
const isDeck = useMemo(
|
||||
() =>
|
||||
(skills.find((s) => s.id === project.skillId) ??
|
||||
|
|
@ -2342,43 +2316,6 @@ export function ProjectView({
|
|||
</span>
|
||||
<span className="meta" data-testid="project-meta">{projectMeta}</span>
|
||||
</span>
|
||||
{targetPlatforms.length > 0 ? (
|
||||
<span
|
||||
className="project-target-platforms"
|
||||
data-testid="project-target-platforms"
|
||||
title={`Target platforms: ${targetPlatformsLabel}`}
|
||||
>
|
||||
<span className="project-target-platforms-label">Targets</span>
|
||||
{visibleTargetPlatforms.map((platform) => (
|
||||
<span className="project-target-platform-chip" key={platform}>
|
||||
{platform}
|
||||
</span>
|
||||
))}
|
||||
{hiddenTargetPlatformCount > 0 ? (
|
||||
<span className="project-target-platform-chip is-count">
|
||||
+{hiddenTargetPlatformCount}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
{featureChips.length > 0 ? (
|
||||
<span
|
||||
className="project-feature-chips"
|
||||
data-testid="project-feature-chips"
|
||||
title={`Enabled design outputs: ${featureChipsLabel}`}
|
||||
>
|
||||
<span className="project-feature-chips-label">Includes</span>
|
||||
{featureChips.map((chip) => (
|
||||
<span
|
||||
className={`project-feature-chip is-${chip.tone}`}
|
||||
key={chip.tone}
|
||||
title={chip.title}
|
||||
>
|
||||
{chip.label}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</AppChromeHeader>
|
||||
<div
|
||||
|
|
@ -2423,6 +2360,7 @@ export function ProjectView({
|
|||
void handleSend(text, [], []);
|
||||
}}
|
||||
onContinueRemainingTasks={handleContinueRemainingTasks}
|
||||
onAssistantFeedback={handleAssistantFeedback}
|
||||
onNewConversation={handleNewConversation}
|
||||
newConversationDisabled={newConversationDisabled}
|
||||
conversations={conversations}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
import type { DirectionCard, QuestionForm } from '../artifacts/question-form';
|
||||
import { formatFormAnswers } from '../artifacts/question-form';
|
||||
import type { DirectionCard, FormOption, QuestionForm } from '../artifacts/question-form';
|
||||
import { formatFormAnswers, formOptionValueForLabel } from '../artifacts/question-form';
|
||||
|
||||
interface Props {
|
||||
form: QuestionForm;
|
||||
|
|
@ -102,16 +102,21 @@ export function QuestionFormView({ form, interactive, submittedAnswers, onSubmit
|
|||
{q.type === 'radio' && q.options ? (
|
||||
<div className="qf-options">
|
||||
{q.options.map((opt) => (
|
||||
<label key={opt} className={`qf-chip${value === opt ? ' qf-chip-on' : ''}`}>
|
||||
<label
|
||||
key={opt.value}
|
||||
className={`qf-chip${value === opt.value ? ' qf-chip-on' : ''}`}
|
||||
title={opt.description}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`${form.id}-${q.id}`}
|
||||
value={opt}
|
||||
checked={value === opt}
|
||||
value={opt.value}
|
||||
checked={value === opt.value}
|
||||
disabled={locked}
|
||||
onChange={() => update(q.id, opt)}
|
||||
aria-label={opt.label}
|
||||
onChange={() => update(q.id, opt.value)}
|
||||
/>
|
||||
<span>{opt}</span>
|
||||
<OptionCopy option={opt} />
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -120,22 +125,24 @@ export function QuestionFormView({ form, interactive, submittedAnswers, onSubmit
|
|||
<div className="qf-options">
|
||||
{q.options.map((opt) => {
|
||||
const arr = Array.isArray(value) ? value : [];
|
||||
const on = arr.includes(opt);
|
||||
const on = arr.includes(opt.value);
|
||||
const maxed =
|
||||
q.maxSelections !== undefined && !on && arr.length >= q.maxSelections;
|
||||
return (
|
||||
<label
|
||||
key={opt}
|
||||
key={opt.value}
|
||||
title={opt.description}
|
||||
className={`qf-chip${on ? ' qf-chip-on' : ''}${maxed ? ' qf-chip-disabled' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
value={opt}
|
||||
value={opt.value}
|
||||
checked={on}
|
||||
disabled={locked || maxed}
|
||||
onChange={() => toggleCheckbox(q.id, opt, q.maxSelections)}
|
||||
aria-label={opt.label}
|
||||
onChange={() => toggleCheckbox(q.id, opt.value, q.maxSelections)}
|
||||
/>
|
||||
<span>{opt}</span>
|
||||
<OptionCopy option={opt} />
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
|
|
@ -152,8 +159,8 @@ export function QuestionFormView({ form, interactive, submittedAnswers, onSubmit
|
|||
{t('qf.choose')}
|
||||
</option>
|
||||
{q.options.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
<option key={opt.value} value={opt.value} title={opt.description}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
@ -221,6 +228,15 @@ export function QuestionFormView({ form, interactive, submittedAnswers, onSubmit
|
|||
);
|
||||
}
|
||||
|
||||
function OptionCopy({ option }: { option: FormOption }) {
|
||||
return (
|
||||
<span className="qf-chip-copy">
|
||||
<span>{option.label}</span>
|
||||
{option.description ? <span className="qf-chip-desc">{option.description}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectionCardView({
|
||||
card,
|
||||
formId,
|
||||
|
|
@ -340,10 +356,17 @@ export function parseSubmittedAnswers(
|
|||
answers[id] = value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0 && s.toLowerCase() !== '(skipped)');
|
||||
.filter((s) => s.length > 0 && s.toLowerCase() !== '(skipped)')
|
||||
.map((s) => formOptionValueForLabel(q, parseSubmittedOptionToken(s)));
|
||||
} else {
|
||||
answers[id] = value.toLowerCase() === '(skipped)' ? '' : value;
|
||||
answers[id] = value.toLowerCase() === '(skipped)' ? '' : formOptionValueForLabel(q, parseSubmittedOptionToken(value));
|
||||
}
|
||||
}
|
||||
return Object.keys(answers).length > 0 ? answers : null;
|
||||
}
|
||||
|
||||
function parseSubmittedOptionToken(raw: string): string {
|
||||
const match = /\s+\[value:\s*([^\]]+)\]\s*$/i.exec(raw);
|
||||
if (!match) return raw.trim();
|
||||
return match[1]!.trim();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -681,7 +681,6 @@ export function SettingsDialog({
|
|||
};
|
||||
}, []);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [languageOpen, setLanguageOpen] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection);
|
||||
// Scroll the right-hand content pane back to the top whenever the user
|
||||
// picks a different settings section. Without this, switching from a
|
||||
|
|
@ -689,7 +688,6 @@ export function SettingsDialog({
|
|||
// (About) keeps the previous scrollTop, so the new section's header
|
||||
// can land out of view and the panel reads as half-loaded. Issue #634.
|
||||
const settingsContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const [languageMenuRect, setLanguageMenuRect] = useState<DOMRect | null>(null);
|
||||
const [agentRescanRunning, setAgentRescanRunning] = useState(false);
|
||||
const [agentRescanNotice, setAgentRescanNotice] =
|
||||
useState<RescanNotice | null>(null);
|
||||
|
|
@ -714,7 +712,6 @@ export function SettingsDialog({
|
|||
const [agentCustomModelIds, setAgentCustomModelIds] = useState<
|
||||
ReadonlySet<string>
|
||||
>(() => new Set());
|
||||
const languageRef = useRef<HTMLDivElement | null>(null);
|
||||
// Imperative handle for the External MCP section. The dialog footer Save
|
||||
// routes through this when the MCP tab is active so the user can press the
|
||||
// single Save button at the bottom instead of hunting for the inner one.
|
||||
|
|
@ -797,41 +794,6 @@ export function SettingsDialog({
|
|||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!languageOpen) return;
|
||||
const updateRect = () => {
|
||||
const button = languageRef.current?.querySelector('button');
|
||||
setLanguageMenuRect(button?.getBoundingClientRect() ?? null);
|
||||
};
|
||||
updateRect();
|
||||
function onDown(e: MouseEvent) {
|
||||
if (languageRef.current?.contains(e.target as Node)) return;
|
||||
setLanguageOpen(false);
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setLanguageOpen(false);
|
||||
}
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
window.addEventListener('resize', updateRect);
|
||||
window.addEventListener('scroll', updateRect, true);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('resize', updateRect);
|
||||
window.removeEventListener('scroll', updateRect, true);
|
||||
};
|
||||
}, [languageOpen]);
|
||||
|
||||
// Close the language menu on window resize so its placement (computed on
|
||||
// open) cannot end up stale relative to the new viewport dimensions.
|
||||
useEffect(() => {
|
||||
if (!languageOpen) return;
|
||||
const handleResize = () => setLanguageOpen(false);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [languageOpen]);
|
||||
|
||||
const installedCount = useMemo(
|
||||
() => agents.filter((a) => a.available).length,
|
||||
[agents],
|
||||
|
|
@ -1382,19 +1344,15 @@ export function SettingsDialog({
|
|||
}, [onPersist]);
|
||||
|
||||
// Global Escape closes the dialog. With no footer button anymore the
|
||||
// close affordances are: top-right X · backdrop click · Escape. We
|
||||
// skip the handler when an inline popover (e.g. the language menu
|
||||
// listbox) is open, because that menu owns its own Escape handling
|
||||
// and closing the dialog out from under it would be jarring.
|
||||
// close affordances are: top-right X · backdrop click · Escape.
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (languageOpen) return;
|
||||
onClose();
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [onClose, languageOpen]);
|
||||
}, [onClose]);
|
||||
|
||||
const protocolProviders = useMemo(
|
||||
() => KNOWN_PROVIDERS.filter((p) => p.protocol === apiProtocol),
|
||||
|
|
@ -2552,73 +2510,30 @@ export function SettingsDialog({
|
|||
<p className="hint">{t('settings.languageHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-language-picker" ref={languageRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-language-button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={languageOpen}
|
||||
onClick={() => setLanguageOpen((v) => !v)}
|
||||
>
|
||||
<span className="settings-language-icon" aria-hidden="true">
|
||||
<Icon name="languages" size={22} strokeWidth={1.8} />
|
||||
</span>
|
||||
<span className="settings-language-text">
|
||||
<span className="settings-language-title">
|
||||
{LOCALE_LABEL[locale]}
|
||||
</span>
|
||||
<span className="settings-language-code">{locale}</span>
|
||||
</span>
|
||||
<Icon name="chevron-down" size={16} />
|
||||
</button>
|
||||
{languageOpen && languageMenuRect ? (() => {
|
||||
const spaceBelow = window.innerHeight - languageMenuRect.bottom;
|
||||
const spaceAbove = languageMenuRect.top;
|
||||
// Prefer downward if at least 200px available (enough for ~5 options)
|
||||
const openDownward = spaceBelow >= spaceAbove || spaceBelow >= 200;
|
||||
<div className="settings-language-grid" role="radiogroup" aria-label={t('settings.language')}>
|
||||
{LOCALES.map((code) => {
|
||||
const active = locale === code;
|
||||
return (
|
||||
<div
|
||||
className="settings-language-menu"
|
||||
role="menu"
|
||||
style={{
|
||||
top: openDownward ? languageMenuRect.bottom + 6 : undefined,
|
||||
bottom: openDownward
|
||||
? undefined
|
||||
: window.innerHeight - languageMenuRect.top + 6,
|
||||
left: languageMenuRect.left,
|
||||
width: languageMenuRect.width,
|
||||
'--menu-available-h': `${(openDownward ? spaceBelow : spaceAbove) - 6}px`,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{LOCALES.map((code) => {
|
||||
const active = locale === code;
|
||||
return (
|
||||
<button
|
||||
key={code}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={active}
|
||||
className={`settings-language-option${active ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
setLocale(code as Locale);
|
||||
setLanguageOpen(false);
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<span className="settings-language-option-title">
|
||||
{LOCALE_LABEL[code]}
|
||||
</span>
|
||||
<span className="settings-language-option-code">
|
||||
{code}
|
||||
</span>
|
||||
</span>
|
||||
{active ? <Icon name="check" size={16} /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
key={code}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
className={`settings-language-tile${active ? ' active' : ''}`}
|
||||
onClick={() => setLocale(code as Locale)}
|
||||
>
|
||||
<span className="settings-language-tile-text">
|
||||
<span className="settings-language-tile-title">
|
||||
{LOCALE_LABEL[code]}
|
||||
</span>
|
||||
<span className="settings-language-tile-code">
|
||||
{code}
|
||||
</span>
|
||||
</span>
|
||||
{active ? <Icon name="check" size={16} /> : null}
|
||||
</button>
|
||||
);
|
||||
})() : null}
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,11 @@ export const FR_SKILL_COPY: Record<string, { description?: string; examplePrompt
|
|||
},
|
||||
'eng-runbook': {
|
||||
examplePrompt:
|
||||
'Rédigez un runbook pour notre service d’auth — alertes, dashboards, procédures standard, rotation on-call.',
|
||||
"Rédigez un runbook pour notre service d'auth — alertes, dashboards, procédures standard, rotation on-call.",
|
||||
},
|
||||
'faq-page': {
|
||||
examplePrompt:
|
||||
'Une page FAQ avec sections accordéon pliables, recherche et filtrage par catégorie.',
|
||||
},
|
||||
'finance-report': {
|
||||
examplePrompt:
|
||||
|
|
|
|||
|
|
@ -56,6 +56,10 @@ export const RU_SKILL_COPY: Record<string, { description?: string; examplePrompt
|
|||
examplePrompt:
|
||||
'Напишите runbook для нашего сервиса аутентификации — алерты, дашборды, стандартные процедуры, график on-call.',
|
||||
},
|
||||
'faq-page': {
|
||||
examplePrompt:
|
||||
'Страница FAQ со складными секциями-аккордеонами, поиском и фильтрацией по категориям.',
|
||||
},
|
||||
'finance-report': {
|
||||
examplePrompt:
|
||||
'Подготовьте финансовый отчет за Q3 для early-stage SaaS — MRR, burn, gross margin, top accounts.',
|
||||
|
|
|
|||
|
|
@ -96,6 +96,10 @@ const DE_SKILL_COPY: Record<string, LocalizedSkillCopy> = {
|
|||
examplePrompt:
|
||||
'Schreiben Sie ein Runbook für unseren Auth-Service — Alerts, Dashboards, Standardverfahren, On-Call-Rotation.',
|
||||
},
|
||||
'faq-page': {
|
||||
examplePrompt:
|
||||
'Eine FAQ-Seite mit zusammenklappbaren Akkordeon-Abschnitten, Suchfunktion und Kategoriefilterung.',
|
||||
},
|
||||
'finance-report': {
|
||||
examplePrompt:
|
||||
'Erstellen Sie einen Q3-Finanzbericht für ein Early-Stage-SaaS — MRR, Burn, Bruttomarge, Top-Accounts.',
|
||||
|
|
|
|||
|
|
@ -960,6 +960,21 @@ export const ar: Dict = {
|
|||
'assistant.role': 'المساعد',
|
||||
'assistant.workingLabel': 'جاري العمل',
|
||||
'assistant.doneLabel': 'تم',
|
||||
'assistant.feedbackPrompt': 'ملاحظات',
|
||||
'assistant.feedbackPositive': 'مفيد',
|
||||
'assistant.feedbackNegative': 'غير مفيد',
|
||||
'assistant.feedbackReasonTitle': 'Tell us why',
|
||||
'assistant.feedbackReasonPositiveMatched': 'Understood my request',
|
||||
'assistant.feedbackReasonPositiveVisual': 'Looks good',
|
||||
'assistant.feedbackReasonPositiveUseful': 'Useful structure',
|
||||
'assistant.feedbackReasonPositiveEasy': 'Easy to keep editing',
|
||||
'assistant.feedbackReasonNegativeMissed': 'Missed my request',
|
||||
'assistant.feedbackReasonNegativeVisual': 'Visual quality needs work',
|
||||
'assistant.feedbackReasonNegativeIncomplete': 'Incomplete output',
|
||||
'assistant.feedbackReasonNegativeHard': 'Hard to use',
|
||||
'assistant.feedbackReasonOther': 'Other',
|
||||
'assistant.feedbackReasonPlaceholder': 'Add a short note...',
|
||||
'assistant.feedbackReasonSubmit': 'Submit',
|
||||
'assistant.emptyResponseLabel': 'No output',
|
||||
'assistant.emptyResponseMessage': 'The provider ended the request without returning text or an artifact. Try another model or provider, check quota, or retry.',
|
||||
'assistant.unfinishedLabel': 'توقف مع عمل غير مكتمل',
|
||||
|
|
|
|||
|
|
@ -848,6 +848,21 @@ export const de: Dict = {
|
|||
'assistant.role': 'Assistent',
|
||||
'assistant.workingLabel': 'Arbeitet',
|
||||
'assistant.doneLabel': 'Fertig',
|
||||
'assistant.feedbackPrompt': 'Feedback',
|
||||
'assistant.feedbackPositive': 'Hilfreich',
|
||||
'assistant.feedbackNegative': 'Nicht hilfreich',
|
||||
'assistant.feedbackReasonTitle': 'Tell us why',
|
||||
'assistant.feedbackReasonPositiveMatched': 'Understood my request',
|
||||
'assistant.feedbackReasonPositiveVisual': 'Looks good',
|
||||
'assistant.feedbackReasonPositiveUseful': 'Useful structure',
|
||||
'assistant.feedbackReasonPositiveEasy': 'Easy to keep editing',
|
||||
'assistant.feedbackReasonNegativeMissed': 'Missed my request',
|
||||
'assistant.feedbackReasonNegativeVisual': 'Visual quality needs work',
|
||||
'assistant.feedbackReasonNegativeIncomplete': 'Incomplete output',
|
||||
'assistant.feedbackReasonNegativeHard': 'Hard to use',
|
||||
'assistant.feedbackReasonOther': 'Other',
|
||||
'assistant.feedbackReasonPlaceholder': 'Add a short note...',
|
||||
'assistant.feedbackReasonSubmit': 'Submit',
|
||||
'assistant.emptyResponseLabel': 'No output',
|
||||
'assistant.emptyResponseMessage': 'The provider ended the request without returning text or an artifact. Try another model or provider, check quota, or retry.',
|
||||
'assistant.unfinishedLabel': 'Mit unerledigter Arbeit gestoppt',
|
||||
|
|
|
|||
|
|
@ -1114,6 +1114,21 @@ export const en: Dict = {
|
|||
'assistant.role': 'Assistant',
|
||||
'assistant.workingLabel': 'Working',
|
||||
'assistant.doneLabel': 'Done',
|
||||
'assistant.feedbackPrompt': 'Feedback',
|
||||
'assistant.feedbackPositive': 'Helpful',
|
||||
'assistant.feedbackNegative': 'Not helpful',
|
||||
'assistant.feedbackReasonTitle': 'Tell us why',
|
||||
'assistant.feedbackReasonPositiveMatched': 'Understood my request',
|
||||
'assistant.feedbackReasonPositiveVisual': 'Looks good',
|
||||
'assistant.feedbackReasonPositiveUseful': 'Useful structure',
|
||||
'assistant.feedbackReasonPositiveEasy': 'Easy to keep editing',
|
||||
'assistant.feedbackReasonNegativeMissed': 'Missed my request',
|
||||
'assistant.feedbackReasonNegativeVisual': 'Visual quality needs work',
|
||||
'assistant.feedbackReasonNegativeIncomplete': 'Incomplete output',
|
||||
'assistant.feedbackReasonNegativeHard': 'Hard to use',
|
||||
'assistant.feedbackReasonOther': 'Other',
|
||||
'assistant.feedbackReasonPlaceholder': 'Add a short note...',
|
||||
'assistant.feedbackReasonSubmit': 'Submit',
|
||||
'assistant.emptyResponseLabel': 'No output',
|
||||
'assistant.emptyResponseMessage': 'The provider ended the request without returning text or an artifact. Try another model or provider, check quota, or retry.',
|
||||
'assistant.unfinishedLabel': 'Stopped with unfinished work',
|
||||
|
|
@ -1173,16 +1188,6 @@ export const en: Dict = {
|
|||
'sketch.textPrompt': 'Text:',
|
||||
'sketch.textModalTitle': 'Add text',
|
||||
|
||||
'feedback.prompt': 'Was this response helpful?',
|
||||
'feedback.thumbsUp': 'Helpful',
|
||||
'feedback.thumbsDown': 'Not helpful',
|
||||
'feedback.submittedPositive': 'Thanks for the feedback.',
|
||||
'feedback.submittedNegative': "Thanks, we'll use this to improve.",
|
||||
'feedback.commentLabel': 'What could be better? (optional)',
|
||||
'feedback.commentPlaceholder': 'Tell us what we could have done differently',
|
||||
'feedback.commentSubmit': 'Send comment',
|
||||
'feedback.commentSaved': 'Comment saved',
|
||||
'feedback.change': 'Change',
|
||||
|
||||
'pet.title': 'Pets',
|
||||
'pet.subtitle': 'Adopt a tiny companion that floats over your workspace.',
|
||||
|
|
|
|||
|
|
@ -849,6 +849,21 @@ export const esES: Dict = {
|
|||
'assistant.role': 'Asistente',
|
||||
'assistant.workingLabel': 'Trabajando',
|
||||
'assistant.doneLabel': 'Listo',
|
||||
'assistant.feedbackPrompt': 'Comentarios',
|
||||
'assistant.feedbackPositive': 'Útil',
|
||||
'assistant.feedbackNegative': 'No útil',
|
||||
'assistant.feedbackReasonTitle': 'Tell us why',
|
||||
'assistant.feedbackReasonPositiveMatched': 'Understood my request',
|
||||
'assistant.feedbackReasonPositiveVisual': 'Looks good',
|
||||
'assistant.feedbackReasonPositiveUseful': 'Useful structure',
|
||||
'assistant.feedbackReasonPositiveEasy': 'Easy to keep editing',
|
||||
'assistant.feedbackReasonNegativeMissed': 'Missed my request',
|
||||
'assistant.feedbackReasonNegativeVisual': 'Visual quality needs work',
|
||||
'assistant.feedbackReasonNegativeIncomplete': 'Incomplete output',
|
||||
'assistant.feedbackReasonNegativeHard': 'Hard to use',
|
||||
'assistant.feedbackReasonOther': 'Other',
|
||||
'assistant.feedbackReasonPlaceholder': 'Add a short note...',
|
||||
'assistant.feedbackReasonSubmit': 'Submit',
|
||||
'assistant.emptyResponseLabel': 'No output',
|
||||
'assistant.emptyResponseMessage': 'The provider ended the request without returning text or an artifact. Try another model or provider, check quota, or retry.',
|
||||
'assistant.unfinishedLabel': 'Detenido con tareas pendientes',
|
||||
|
|
|
|||
|
|
@ -943,6 +943,21 @@ export const fa: Dict = {
|
|||
'assistant.role': 'دستیار',
|
||||
'assistant.workingLabel': 'در حال کار',
|
||||
'assistant.doneLabel': 'انجام شد',
|
||||
'assistant.feedbackPrompt': 'بازخورد',
|
||||
'assistant.feedbackPositive': 'مفید',
|
||||
'assistant.feedbackNegative': 'غیرمفید',
|
||||
'assistant.feedbackReasonTitle': 'Tell us why',
|
||||
'assistant.feedbackReasonPositiveMatched': 'Understood my request',
|
||||
'assistant.feedbackReasonPositiveVisual': 'Looks good',
|
||||
'assistant.feedbackReasonPositiveUseful': 'Useful structure',
|
||||
'assistant.feedbackReasonPositiveEasy': 'Easy to keep editing',
|
||||
'assistant.feedbackReasonNegativeMissed': 'Missed my request',
|
||||
'assistant.feedbackReasonNegativeVisual': 'Visual quality needs work',
|
||||
'assistant.feedbackReasonNegativeIncomplete': 'Incomplete output',
|
||||
'assistant.feedbackReasonNegativeHard': 'Hard to use',
|
||||
'assistant.feedbackReasonOther': 'Other',
|
||||
'assistant.feedbackReasonPlaceholder': 'Add a short note...',
|
||||
'assistant.feedbackReasonSubmit': 'Submit',
|
||||
'assistant.emptyResponseLabel': 'No output',
|
||||
'assistant.emptyResponseMessage': 'The provider ended the request without returning text or an artifact. Try another model or provider, check quota, or retry.',
|
||||
'assistant.unfinishedLabel': 'با کار ناتمام متوقف شد',
|
||||
|
|
|
|||
|
|
@ -960,6 +960,21 @@ export const fr: Dict = {
|
|||
'assistant.role': 'Assistant',
|
||||
'assistant.workingLabel': 'En cours',
|
||||
'assistant.doneLabel': 'Terminé',
|
||||
'assistant.feedbackPrompt': 'Avis',
|
||||
'assistant.feedbackPositive': 'Utile',
|
||||
'assistant.feedbackNegative': 'Pas utile',
|
||||
'assistant.feedbackReasonTitle': 'Tell us why',
|
||||
'assistant.feedbackReasonPositiveMatched': 'Understood my request',
|
||||
'assistant.feedbackReasonPositiveVisual': 'Looks good',
|
||||
'assistant.feedbackReasonPositiveUseful': 'Useful structure',
|
||||
'assistant.feedbackReasonPositiveEasy': 'Easy to keep editing',
|
||||
'assistant.feedbackReasonNegativeMissed': 'Missed my request',
|
||||
'assistant.feedbackReasonNegativeVisual': 'Visual quality needs work',
|
||||
'assistant.feedbackReasonNegativeIncomplete': 'Incomplete output',
|
||||
'assistant.feedbackReasonNegativeHard': 'Hard to use',
|
||||
'assistant.feedbackReasonOther': 'Other',
|
||||
'assistant.feedbackReasonPlaceholder': 'Add a short note...',
|
||||
'assistant.feedbackReasonSubmit': 'Submit',
|
||||
'assistant.emptyResponseLabel': 'No output',
|
||||
'assistant.emptyResponseMessage': 'The provider ended the request without returning text or an artifact. Try another model or provider, check quota, or retry.',
|
||||
'assistant.unfinishedLabel': 'Arrêté avec du travail non terminé',
|
||||
|
|
|
|||
|
|
@ -960,6 +960,21 @@ export const hu: Dict = {
|
|||
'assistant.role': 'Asszisztens',
|
||||
'assistant.workingLabel': 'Dolgozik',
|
||||
'assistant.doneLabel': 'Kész',
|
||||
'assistant.feedbackPrompt': 'Visszajelzés',
|
||||
'assistant.feedbackPositive': 'Hasznos',
|
||||
'assistant.feedbackNegative': 'Nem hasznos',
|
||||
'assistant.feedbackReasonTitle': 'Tell us why',
|
||||
'assistant.feedbackReasonPositiveMatched': 'Understood my request',
|
||||
'assistant.feedbackReasonPositiveVisual': 'Looks good',
|
||||
'assistant.feedbackReasonPositiveUseful': 'Useful structure',
|
||||
'assistant.feedbackReasonPositiveEasy': 'Easy to keep editing',
|
||||
'assistant.feedbackReasonNegativeMissed': 'Missed my request',
|
||||
'assistant.feedbackReasonNegativeVisual': 'Visual quality needs work',
|
||||
'assistant.feedbackReasonNegativeIncomplete': 'Incomplete output',
|
||||
'assistant.feedbackReasonNegativeHard': 'Hard to use',
|
||||
'assistant.feedbackReasonOther': 'Other',
|
||||
'assistant.feedbackReasonPlaceholder': 'Add a short note...',
|
||||
'assistant.feedbackReasonSubmit': 'Submit',
|
||||
'assistant.emptyResponseLabel': 'No output',
|
||||
'assistant.emptyResponseMessage': 'The provider ended the request without returning text or an artifact. Try another model or provider, check quota, or retry.',
|
||||
'assistant.unfinishedLabel': 'Befejezetlen munkával állt le',
|
||||
|
|
|
|||
|
|
@ -1111,6 +1111,21 @@ export const id: Dict = {
|
|||
'assistant.role': 'Asisten',
|
||||
'assistant.workingLabel': 'Sedang bekerja',
|
||||
'assistant.doneLabel': 'Selesai',
|
||||
'assistant.feedbackPrompt': 'Masukan',
|
||||
'assistant.feedbackPositive': 'Membantu',
|
||||
'assistant.feedbackNegative': 'Tidak membantu',
|
||||
'assistant.feedbackReasonTitle': 'Tell us why',
|
||||
'assistant.feedbackReasonPositiveMatched': 'Understood my request',
|
||||
'assistant.feedbackReasonPositiveVisual': 'Looks good',
|
||||
'assistant.feedbackReasonPositiveUseful': 'Useful structure',
|
||||
'assistant.feedbackReasonPositiveEasy': 'Easy to keep editing',
|
||||
'assistant.feedbackReasonNegativeMissed': 'Missed my request',
|
||||
'assistant.feedbackReasonNegativeVisual': 'Visual quality needs work',
|
||||
'assistant.feedbackReasonNegativeIncomplete': 'Incomplete output',
|
||||
'assistant.feedbackReasonNegativeHard': 'Hard to use',
|
||||
'assistant.feedbackReasonOther': 'Other',
|
||||
'assistant.feedbackReasonPlaceholder': 'Add a short note...',
|
||||
'assistant.feedbackReasonSubmit': 'Submit',
|
||||
'assistant.emptyResponseLabel': 'No output',
|
||||
'assistant.emptyResponseMessage': 'The provider ended the request without returning text or an artifact. Try another model or provider, check quota, or retry.',
|
||||
'assistant.unfinishedLabel': 'Belum selesai',
|
||||
|
|
|
|||
|
|
@ -847,6 +847,21 @@ export const ja: Dict = {
|
|||
'assistant.role': 'アシスタント',
|
||||
'assistant.workingLabel': '作業中',
|
||||
'assistant.doneLabel': '完了',
|
||||
'assistant.feedbackPrompt': 'フィードバック',
|
||||
'assistant.feedbackPositive': '役に立った',
|
||||
'assistant.feedbackNegative': '役に立たなかった',
|
||||
'assistant.feedbackReasonTitle': 'Tell us why',
|
||||
'assistant.feedbackReasonPositiveMatched': 'Understood my request',
|
||||
'assistant.feedbackReasonPositiveVisual': 'Looks good',
|
||||
'assistant.feedbackReasonPositiveUseful': 'Useful structure',
|
||||
'assistant.feedbackReasonPositiveEasy': 'Easy to keep editing',
|
||||
'assistant.feedbackReasonNegativeMissed': 'Missed my request',
|
||||
'assistant.feedbackReasonNegativeVisual': 'Visual quality needs work',
|
||||
'assistant.feedbackReasonNegativeIncomplete': 'Incomplete output',
|
||||
'assistant.feedbackReasonNegativeHard': 'Hard to use',
|
||||
'assistant.feedbackReasonOther': 'Other',
|
||||
'assistant.feedbackReasonPlaceholder': 'Add a short note...',
|
||||
'assistant.feedbackReasonSubmit': 'Submit',
|
||||
'assistant.emptyResponseLabel': 'No output',
|
||||
'assistant.emptyResponseMessage': 'The provider ended the request without returning text or an artifact. Try another model or provider, check quota, or retry.',
|
||||
'assistant.unfinishedLabel': '未完了の作業があります',
|
||||
|
|
|
|||
|
|
@ -960,6 +960,21 @@ export const ko: Dict = {
|
|||
'assistant.role': '어시스턴트 (Assistant)',
|
||||
'assistant.workingLabel': '작업 중',
|
||||
'assistant.doneLabel': '완료됨',
|
||||
'assistant.feedbackPrompt': '피드백',
|
||||
'assistant.feedbackPositive': '도움 됨',
|
||||
'assistant.feedbackNegative': '도움 안 됨',
|
||||
'assistant.feedbackReasonTitle': 'Tell us why',
|
||||
'assistant.feedbackReasonPositiveMatched': 'Understood my request',
|
||||
'assistant.feedbackReasonPositiveVisual': 'Looks good',
|
||||
'assistant.feedbackReasonPositiveUseful': 'Useful structure',
|
||||
'assistant.feedbackReasonPositiveEasy': 'Easy to keep editing',
|
||||
'assistant.feedbackReasonNegativeMissed': 'Missed my request',
|
||||
'assistant.feedbackReasonNegativeVisual': 'Visual quality needs work',
|
||||
'assistant.feedbackReasonNegativeIncomplete': 'Incomplete output',
|
||||
'assistant.feedbackReasonNegativeHard': 'Hard to use',
|
||||
'assistant.feedbackReasonOther': 'Other',
|
||||
'assistant.feedbackReasonPlaceholder': 'Add a short note...',
|
||||
'assistant.feedbackReasonSubmit': 'Submit',
|
||||
'assistant.emptyResponseLabel': 'No output',
|
||||
'assistant.emptyResponseMessage': 'The provider ended the request without returning text or an artifact. Try another model or provider, check quota, or retry.',
|
||||
'assistant.unfinishedLabel': '작업을 마치지 못하고 중지됨',
|
||||
|
|
|
|||
|
|
@ -960,6 +960,21 @@ export const pl: Dict = {
|
|||
'assistant.role': 'Asystent',
|
||||
'assistant.workingLabel': 'Pracuję',
|
||||
'assistant.doneLabel': 'Gotowe',
|
||||
'assistant.feedbackPrompt': 'Opinia',
|
||||
'assistant.feedbackPositive': 'Pomocne',
|
||||
'assistant.feedbackNegative': 'Niepomocne',
|
||||
'assistant.feedbackReasonTitle': 'Tell us why',
|
||||
'assistant.feedbackReasonPositiveMatched': 'Understood my request',
|
||||
'assistant.feedbackReasonPositiveVisual': 'Looks good',
|
||||
'assistant.feedbackReasonPositiveUseful': 'Useful structure',
|
||||
'assistant.feedbackReasonPositiveEasy': 'Easy to keep editing',
|
||||
'assistant.feedbackReasonNegativeMissed': 'Missed my request',
|
||||
'assistant.feedbackReasonNegativeVisual': 'Visual quality needs work',
|
||||
'assistant.feedbackReasonNegativeIncomplete': 'Incomplete output',
|
||||
'assistant.feedbackReasonNegativeHard': 'Hard to use',
|
||||
'assistant.feedbackReasonOther': 'Other',
|
||||
'assistant.feedbackReasonPlaceholder': 'Add a short note...',
|
||||
'assistant.feedbackReasonSubmit': 'Submit',
|
||||
'assistant.emptyResponseLabel': 'No output',
|
||||
'assistant.emptyResponseMessage': 'The provider ended the request without returning text or an artifact. Try another model or provider, check quota, or retry.',
|
||||
'assistant.unfinishedLabel': 'Zatrzymano z niedokończonymi zadaniami',
|
||||
|
|
|
|||
|
|
@ -1001,6 +1001,21 @@ export const ptBR: Dict = {
|
|||
'assistant.role': 'Assistente',
|
||||
'assistant.workingLabel': 'Trabalhando',
|
||||
'assistant.doneLabel': 'Concluído',
|
||||
'assistant.feedbackPrompt': 'Feedback',
|
||||
'assistant.feedbackPositive': 'Útil',
|
||||
'assistant.feedbackNegative': 'Não útil',
|
||||
'assistant.feedbackReasonTitle': 'Tell us why',
|
||||
'assistant.feedbackReasonPositiveMatched': 'Understood my request',
|
||||
'assistant.feedbackReasonPositiveVisual': 'Looks good',
|
||||
'assistant.feedbackReasonPositiveUseful': 'Useful structure',
|
||||
'assistant.feedbackReasonPositiveEasy': 'Easy to keep editing',
|
||||
'assistant.feedbackReasonNegativeMissed': 'Missed my request',
|
||||
'assistant.feedbackReasonNegativeVisual': 'Visual quality needs work',
|
||||
'assistant.feedbackReasonNegativeIncomplete': 'Incomplete output',
|
||||
'assistant.feedbackReasonNegativeHard': 'Hard to use',
|
||||
'assistant.feedbackReasonOther': 'Other',
|
||||
'assistant.feedbackReasonPlaceholder': 'Add a short note...',
|
||||
'assistant.feedbackReasonSubmit': 'Submit',
|
||||
'assistant.emptyResponseLabel': 'No output',
|
||||
'assistant.emptyResponseMessage': 'The provider ended the request without returning text or an artifact. Try another model or provider, check quota, or retry.',
|
||||
'assistant.unfinishedLabel': 'Interrompido com trabalho pendente',
|
||||
|
|
|
|||
|
|
@ -1001,6 +1001,21 @@ export const ru: Dict = {
|
|||
'assistant.role': 'Ассистент',
|
||||
'assistant.workingLabel': 'Работает',
|
||||
'assistant.doneLabel': 'Готово',
|
||||
'assistant.feedbackPrompt': 'Отзыв',
|
||||
'assistant.feedbackPositive': 'Полезно',
|
||||
'assistant.feedbackNegative': 'Не полезно',
|
||||
'assistant.feedbackReasonTitle': 'Tell us why',
|
||||
'assistant.feedbackReasonPositiveMatched': 'Understood my request',
|
||||
'assistant.feedbackReasonPositiveVisual': 'Looks good',
|
||||
'assistant.feedbackReasonPositiveUseful': 'Useful structure',
|
||||
'assistant.feedbackReasonPositiveEasy': 'Easy to keep editing',
|
||||
'assistant.feedbackReasonNegativeMissed': 'Missed my request',
|
||||
'assistant.feedbackReasonNegativeVisual': 'Visual quality needs work',
|
||||
'assistant.feedbackReasonNegativeIncomplete': 'Incomplete output',
|
||||
'assistant.feedbackReasonNegativeHard': 'Hard to use',
|
||||
'assistant.feedbackReasonOther': 'Other',
|
||||
'assistant.feedbackReasonPlaceholder': 'Add a short note...',
|
||||
'assistant.feedbackReasonSubmit': 'Submit',
|
||||
'assistant.emptyResponseLabel': 'No output',
|
||||
'assistant.emptyResponseMessage': 'The provider ended the request without returning text or an artifact. Try another model or provider, check quota, or retry.',
|
||||
'assistant.unfinishedLabel': 'Остановлено с незавершенной работой',
|
||||
|
|
|
|||
|
|
@ -953,6 +953,21 @@ export const th: Dict = {
|
|||
'assistant.role': 'หน่วยผู้ช่วยเหลือส่วนตัว',
|
||||
'assistant.workingLabel': 'ดำเนินระบบรับทำงานอยู่',
|
||||
'assistant.doneLabel': 'บรรลุสู่ระดับพร้อมแล้ว',
|
||||
'assistant.feedbackPrompt': 'ข้อเสนอแนะ',
|
||||
'assistant.feedbackPositive': 'มีประโยชน์',
|
||||
'assistant.feedbackNegative': 'ไม่มีประโยชน์',
|
||||
'assistant.feedbackReasonTitle': 'Tell us why',
|
||||
'assistant.feedbackReasonPositiveMatched': 'Understood my request',
|
||||
'assistant.feedbackReasonPositiveVisual': 'Looks good',
|
||||
'assistant.feedbackReasonPositiveUseful': 'Useful structure',
|
||||
'assistant.feedbackReasonPositiveEasy': 'Easy to keep editing',
|
||||
'assistant.feedbackReasonNegativeMissed': 'Missed my request',
|
||||
'assistant.feedbackReasonNegativeVisual': 'Visual quality needs work',
|
||||
'assistant.feedbackReasonNegativeIncomplete': 'Incomplete output',
|
||||
'assistant.feedbackReasonNegativeHard': 'Hard to use',
|
||||
'assistant.feedbackReasonOther': 'Other',
|
||||
'assistant.feedbackReasonPlaceholder': 'Add a short note...',
|
||||
'assistant.feedbackReasonSubmit': 'Submit',
|
||||
'assistant.emptyResponseLabel': 'No output',
|
||||
'assistant.emptyResponseMessage': 'The provider ended the request without returning text or an artifact. Try another model or provider, check quota, or retry.',
|
||||
'assistant.unfinishedLabel': 'พ้นจากโหมดเพราะงานตกค้าง',
|
||||
|
|
|
|||
|
|
@ -947,6 +947,21 @@ export const tr: Dict = {
|
|||
'assistant.role': 'Asistan',
|
||||
'assistant.workingLabel': 'Çalışıyor',
|
||||
'assistant.doneLabel': 'Bitti',
|
||||
'assistant.feedbackPrompt': 'Geri bildirim',
|
||||
'assistant.feedbackPositive': 'Yararlı',
|
||||
'assistant.feedbackNegative': 'Yararlı değil',
|
||||
'assistant.feedbackReasonTitle': 'Tell us why',
|
||||
'assistant.feedbackReasonPositiveMatched': 'Understood my request',
|
||||
'assistant.feedbackReasonPositiveVisual': 'Looks good',
|
||||
'assistant.feedbackReasonPositiveUseful': 'Useful structure',
|
||||
'assistant.feedbackReasonPositiveEasy': 'Easy to keep editing',
|
||||
'assistant.feedbackReasonNegativeMissed': 'Missed my request',
|
||||
'assistant.feedbackReasonNegativeVisual': 'Visual quality needs work',
|
||||
'assistant.feedbackReasonNegativeIncomplete': 'Incomplete output',
|
||||
'assistant.feedbackReasonNegativeHard': 'Hard to use',
|
||||
'assistant.feedbackReasonOther': 'Other',
|
||||
'assistant.feedbackReasonPlaceholder': 'Add a short note...',
|
||||
'assistant.feedbackReasonSubmit': 'Submit',
|
||||
'assistant.emptyResponseLabel': 'No output',
|
||||
'assistant.emptyResponseMessage': 'The provider ended the request without returning text or an artifact. Try another model or provider, check quota, or retry.',
|
||||
'assistant.unfinishedLabel': 'Bitmemiş işlerle durduruldu',
|
||||
|
|
|
|||
|
|
@ -1002,6 +1002,21 @@ export const uk: Dict = {
|
|||
'assistant.role': 'Асистент',
|
||||
'assistant.workingLabel': 'Роботи',
|
||||
'assistant.doneLabel': 'Готово',
|
||||
'assistant.feedbackPrompt': 'Відгук',
|
||||
'assistant.feedbackPositive': 'Корисно',
|
||||
'assistant.feedbackNegative': 'Не корисно',
|
||||
'assistant.feedbackReasonTitle': 'Tell us why',
|
||||
'assistant.feedbackReasonPositiveMatched': 'Understood my request',
|
||||
'assistant.feedbackReasonPositiveVisual': 'Looks good',
|
||||
'assistant.feedbackReasonPositiveUseful': 'Useful structure',
|
||||
'assistant.feedbackReasonPositiveEasy': 'Easy to keep editing',
|
||||
'assistant.feedbackReasonNegativeMissed': 'Missed my request',
|
||||
'assistant.feedbackReasonNegativeVisual': 'Visual quality needs work',
|
||||
'assistant.feedbackReasonNegativeIncomplete': 'Incomplete output',
|
||||
'assistant.feedbackReasonNegativeHard': 'Hard to use',
|
||||
'assistant.feedbackReasonOther': 'Other',
|
||||
'assistant.feedbackReasonPlaceholder': 'Add a short note...',
|
||||
'assistant.feedbackReasonSubmit': 'Submit',
|
||||
'assistant.emptyResponseLabel': 'No output',
|
||||
'assistant.emptyResponseMessage': 'The provider ended the request without returning text or an artifact. Try another model or provider, check quota, or retry.',
|
||||
'assistant.unfinishedLabel': 'Зупинено з незавершеною роботою',
|
||||
|
|
|
|||
|
|
@ -1081,6 +1081,21 @@ export const zhCN: Dict = {
|
|||
'assistant.role': '助手',
|
||||
'assistant.workingLabel': '执行中',
|
||||
'assistant.doneLabel': '已完成',
|
||||
'assistant.feedbackPrompt': '反馈',
|
||||
'assistant.feedbackPositive': '有帮助',
|
||||
'assistant.feedbackNegative': '没有帮助',
|
||||
'assistant.feedbackReasonTitle': '选择原因',
|
||||
'assistant.feedbackReasonPositiveMatched': '理解了我的需求',
|
||||
'assistant.feedbackReasonPositiveVisual': '视觉效果满意',
|
||||
'assistant.feedbackReasonPositiveUseful': '结构有帮助',
|
||||
'assistant.feedbackReasonPositiveEasy': '方便继续修改',
|
||||
'assistant.feedbackReasonNegativeMissed': '没有理解需求',
|
||||
'assistant.feedbackReasonNegativeVisual': '视觉效果不理想',
|
||||
'assistant.feedbackReasonNegativeIncomplete': '产物不完整',
|
||||
'assistant.feedbackReasonNegativeHard': '不方便使用',
|
||||
'assistant.feedbackReasonOther': '其他',
|
||||
'assistant.feedbackReasonPlaceholder': '补充说明...',
|
||||
'assistant.feedbackReasonSubmit': '提交',
|
||||
'assistant.emptyResponseLabel': '无输出',
|
||||
'assistant.emptyResponseMessage': '服务商结束了请求,但没有返回文本或设计产物。请尝试更换模型或服务商、检查额度,或重试。',
|
||||
'assistant.unfinishedLabel': '已停止,仍有未完成任务',
|
||||
|
|
@ -1138,16 +1153,6 @@ export const zhCN: Dict = {
|
|||
'sketch.textPrompt': '请输入文本:',
|
||||
'sketch.textModalTitle': '添加文本',
|
||||
|
||||
'feedback.prompt': '本次回复有帮助吗?',
|
||||
'feedback.thumbsUp': '有帮助',
|
||||
'feedback.thumbsDown': '没帮助',
|
||||
'feedback.submittedPositive': '感谢您的反馈。',
|
||||
'feedback.submittedNegative': '感谢您的反馈,我们会据此改进。',
|
||||
'feedback.commentLabel': '哪里可以改进?(可选)',
|
||||
'feedback.commentPlaceholder': '告诉我们可以做得更好的地方',
|
||||
'feedback.commentSubmit': '发送评论',
|
||||
'feedback.commentSaved': '评论已保存',
|
||||
'feedback.change': '更改',
|
||||
|
||||
'pet.title': '宠物',
|
||||
'pet.tabBuiltIn': '内置',
|
||||
|
|
|
|||
|
|
@ -1036,6 +1036,21 @@ export const zhTW: Dict = {
|
|||
'assistant.role': '助手',
|
||||
'assistant.workingLabel': '執行中',
|
||||
'assistant.doneLabel': '已完成',
|
||||
'assistant.feedbackPrompt': '意見回饋',
|
||||
'assistant.feedbackPositive': '有幫助',
|
||||
'assistant.feedbackNegative': '沒有幫助',
|
||||
'assistant.feedbackReasonTitle': '選擇原因',
|
||||
'assistant.feedbackReasonPositiveMatched': '理解了我的需求',
|
||||
'assistant.feedbackReasonPositiveVisual': '視覺效果滿意',
|
||||
'assistant.feedbackReasonPositiveUseful': '結構有幫助',
|
||||
'assistant.feedbackReasonPositiveEasy': '方便繼續修改',
|
||||
'assistant.feedbackReasonNegativeMissed': '沒有理解需求',
|
||||
'assistant.feedbackReasonNegativeVisual': '視覺效果不理想',
|
||||
'assistant.feedbackReasonNegativeIncomplete': '產物不完整',
|
||||
'assistant.feedbackReasonNegativeHard': '不方便使用',
|
||||
'assistant.feedbackReasonOther': '其他',
|
||||
'assistant.feedbackReasonPlaceholder': '補充說明...',
|
||||
'assistant.feedbackReasonSubmit': '提交',
|
||||
'assistant.emptyResponseLabel': '無輸出',
|
||||
'assistant.emptyResponseMessage': '服務商結束了請求,但沒有返回文字或設計產物。請嘗試更換模型或服務商、檢查額度,或重試。',
|
||||
'assistant.outTokens': '{n} 輸出',
|
||||
|
|
|
|||
|
|
@ -1365,6 +1365,21 @@ export interface Dict {
|
|||
'assistant.role': string;
|
||||
'assistant.workingLabel': string;
|
||||
'assistant.doneLabel': string;
|
||||
'assistant.feedbackPrompt': string;
|
||||
'assistant.feedbackPositive': string;
|
||||
'assistant.feedbackNegative': string;
|
||||
'assistant.feedbackReasonTitle': string;
|
||||
'assistant.feedbackReasonPositiveMatched': string;
|
||||
'assistant.feedbackReasonPositiveVisual': string;
|
||||
'assistant.feedbackReasonPositiveUseful': string;
|
||||
'assistant.feedbackReasonPositiveEasy': string;
|
||||
'assistant.feedbackReasonNegativeMissed': string;
|
||||
'assistant.feedbackReasonNegativeVisual': string;
|
||||
'assistant.feedbackReasonNegativeIncomplete': string;
|
||||
'assistant.feedbackReasonNegativeHard': string;
|
||||
'assistant.feedbackReasonOther': string;
|
||||
'assistant.feedbackReasonPlaceholder': string;
|
||||
'assistant.feedbackReasonSubmit': string;
|
||||
'assistant.emptyResponseLabel': string;
|
||||
'assistant.emptyResponseMessage': string;
|
||||
'assistant.unfinishedLabel': string;
|
||||
|
|
@ -1543,15 +1558,4 @@ export interface Dict {
|
|||
'sketch.closeConfirm': string;
|
||||
'sketch.textPrompt': string;
|
||||
'sketch.textModalTitle': string;
|
||||
// Message-level feedback widget (issue #1288)
|
||||
'feedback.prompt': string;
|
||||
'feedback.thumbsUp': string;
|
||||
'feedback.thumbsDown': string;
|
||||
'feedback.submittedPositive': string;
|
||||
'feedback.submittedNegative': string;
|
||||
'feedback.commentLabel': string;
|
||||
'feedback.commentPlaceholder': string;
|
||||
'feedback.commentSubmit': string;
|
||||
'feedback.commentSaved': string;
|
||||
'feedback.change': string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -543,12 +543,12 @@ code {
|
|||
}
|
||||
.app-project-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.app-project-title-line {
|
||||
display: flex;
|
||||
|
|
@ -557,6 +557,7 @@ code {
|
|||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.app-project-title .title {
|
||||
color: var(--text-strong);
|
||||
|
|
@ -580,69 +581,6 @@ code {
|
|||
min-width: 0;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
.project-target-platforms,
|
||||
.project-feature-chips {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
max-width: min(100%, 280px);
|
||||
height: 22px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
.project-feature-chips {
|
||||
max-width: min(100%, 260px);
|
||||
}
|
||||
.project-target-platforms-label,
|
||||
.project-feature-chips-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
line-height: 18px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.project-target-platform-chip,
|
||||
.project-feature-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
max-width: 92px;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 78%, transparent);
|
||||
border-radius: 999px;
|
||||
color: var(--text-muted);
|
||||
background: color-mix(in srgb, var(--bg-subtle) 88%, transparent);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
.project-feature-chip.is-landing {
|
||||
color: color-mix(in srgb, var(--accent) 72%, var(--text-strong));
|
||||
border-color: color-mix(in srgb, var(--accent) 26%, transparent);
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--bg-subtle));
|
||||
}
|
||||
.project-feature-chip.is-widgets {
|
||||
color: color-mix(in srgb, #0891b2 72%, var(--text-strong));
|
||||
border-color: color-mix(in srgb, #0891b2 26%, transparent);
|
||||
background: color-mix(in srgb, #0891b2 10%, var(--bg-subtle));
|
||||
}
|
||||
.project-target-platform-chip.is-count {
|
||||
min-width: 28px;
|
||||
max-width: 36px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-strong);
|
||||
background: color-mix(in srgb, var(--accent, #7c5cff) 12%, var(--bg-subtle));
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -3502,107 +3440,60 @@ code {
|
|||
.field-row { display: flex; gap: 6px; align-items: stretch; }
|
||||
.field-row input { flex: 1; }
|
||||
.field-row .icon-btn { white-space: nowrap; padding: 6px 12px; }
|
||||
.settings-language-picker { position: relative; }
|
||||
.settings-language-button {
|
||||
width: 100%;
|
||||
.settings-language-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.settings-language-tile {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
box-shadow: none;
|
||||
min-width: 0;
|
||||
}
|
||||
.settings-language-button:hover {
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.settings-language-button[aria-expanded="true"] {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.settings-language-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--bg-panel) 0%, var(--bg-subtle) 100%);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.settings-language-text,
|
||||
.settings-language-option > span:first-child {
|
||||
.settings-language-tile-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.settings-language-title,
|
||||
.settings-language-option-title {
|
||||
.settings-language-tile-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.settings-language-code,
|
||||
.settings-language-option-code {
|
||||
.settings-language-tile-code {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.settings-language-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
max-width: calc(100vw - 24px);
|
||||
/* 7 locales × ~58px + menu chrome (padding, gap, border) ≈ 428px;
|
||||
--menu-available-h is set by JS to the distance between the trigger
|
||||
and whichever viewport edge the menu opens toward, so the menu never
|
||||
overflows the viewport regardless of placement direction. The 60vh
|
||||
cap keeps the menu from feeling cramped on short viewports. */
|
||||
max-height: min(428px, 60vh, var(--menu-available-h, 428px));
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
.settings-language-option {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
.settings-language-tile:hover {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
/* Differentiate hover, selected, and keyboard-focus states so the
|
||||
currently-selected language is visually distinct even when the
|
||||
pointer is hovering a different row. Issue #628. */
|
||||
.settings-language-option:hover { background: var(--bg-subtle); }
|
||||
.settings-language-option.active {
|
||||
pointer is hovering a different tile. Issue #628 (carries over from
|
||||
the previous dropdown form). */
|
||||
.settings-language-tile.active {
|
||||
background: var(--accent-tint);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.settings-language-option.active:hover { background: var(--accent-soft); }
|
||||
.settings-language-option:focus-visible {
|
||||
.settings-language-tile.active .settings-language-tile-title { color: var(--accent); }
|
||||
.settings-language-tile.active:hover { background: var(--accent-soft); }
|
||||
.settings-language-tile:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
|
@ -3841,7 +3732,7 @@ code {
|
|||
============================================================ */
|
||||
.entry-shell {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
background: var(--bg);
|
||||
|
|
@ -3930,6 +3821,7 @@ code {
|
|||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding-top: 24px;
|
||||
}
|
||||
.newproj-tabs-shell {
|
||||
position: relative;
|
||||
|
|
@ -4533,13 +4425,10 @@ code {
|
|||
}
|
||||
.template-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
align-items: center;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 120ms ease, background 120ms ease;
|
||||
}
|
||||
|
|
@ -4548,6 +4437,33 @@ code {
|
|||
border-color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
.template-option-select {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 12px 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.template-option-delete {
|
||||
flex: none;
|
||||
padding: 4px 10px;
|
||||
margin-right: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
opacity: 0;
|
||||
transition: opacity 120ms ease, color 120ms ease;
|
||||
}
|
||||
.template-option:hover .template-option-delete { opacity: 1; }
|
||||
.template-option-delete:hover { color: var(--danger, #e53e3e); }
|
||||
.template-radio {
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
|
|
@ -6743,6 +6659,7 @@ button.connector-action.is-loading {
|
|||
.design-card-name {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
|
@ -11624,6 +11541,21 @@ button.ghost.mcp-copy-btn:hover:not(:disabled) {
|
|||
transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
|
||||
}
|
||||
.qf-chip input { width: auto; margin: 0; display: none; }
|
||||
.qf-chip-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.qf-chip-desc {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.qf-chip-on .qf-chip-desc {
|
||||
color: inherit;
|
||||
opacity: 0.72;
|
||||
}
|
||||
.qf-chip:hover { border-color: var(--border-strong); }
|
||||
.qf-chip-disabled {
|
||||
cursor: not-allowed;
|
||||
|
|
@ -12512,11 +12444,17 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
|||
/* ============================================================
|
||||
Assistant message footer (bottom-of-message status pill)
|
||||
============================================================ */
|
||||
.assistant-completion-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.assistant-footer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 4px 10px;
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border);
|
||||
|
|
@ -12552,136 +12490,202 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
|||
background: var(--amber);
|
||||
}
|
||||
|
||||
/* Message-level feedback widget (issue #1288). Sits under the
|
||||
assistant footer, mirrors the pill / chip vocabulary used by the
|
||||
surrounding chat surface so it reads as "part of the completed
|
||||
turn" rather than as a modal. */
|
||||
.message-feedback {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.message-feedback-prompt {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.message-feedback-actions {
|
||||
.assistant-feedback-wrap {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
max-width: min(360px, 100%);
|
||||
}
|
||||
.message-feedback-button {
|
||||
.assistant-feedback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: 2px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.assistant-feedback-button {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
overflow: visible;
|
||||
}
|
||||
.assistant-feedback-button:hover {
|
||||
background: var(--bg-subtle);
|
||||
border-color: var(--border);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.assistant-feedback-button[data-selected="true"] {
|
||||
color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
border-color: var(--accent-soft);
|
||||
}
|
||||
.assistant-feedback-button[data-selected="true"] svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
.assistant-feedback-burst {
|
||||
position: absolute;
|
||||
inset: 50% auto auto 50%;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.assistant-feedback-burst span {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
opacity: 0;
|
||||
animation: assistant-feedback-burst 620ms ease-out forwards;
|
||||
}
|
||||
.assistant-feedback-burst span:nth-child(2) {
|
||||
background: var(--amber);
|
||||
animation-delay: 25ms;
|
||||
--burst-angle: 58deg;
|
||||
}
|
||||
.assistant-feedback-burst span:nth-child(3) {
|
||||
background: var(--green);
|
||||
animation-delay: 45ms;
|
||||
--burst-angle: 116deg;
|
||||
}
|
||||
.assistant-feedback-burst span:nth-child(4) {
|
||||
animation-delay: 65ms;
|
||||
--burst-angle: 174deg;
|
||||
}
|
||||
.assistant-feedback-burst span:nth-child(5) {
|
||||
background: var(--blue);
|
||||
animation-delay: 85ms;
|
||||
--burst-angle: 232deg;
|
||||
}
|
||||
.assistant-feedback-burst span:nth-child(6) {
|
||||
background: var(--accent);
|
||||
animation-delay: 105ms;
|
||||
--burst-angle: 290deg;
|
||||
}
|
||||
|
||||
@keyframes assistant-feedback-burst {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: rotate(var(--burst-angle, 0deg)) translate(0, 0) scale(0.5);
|
||||
}
|
||||
18% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: rotate(var(--burst-angle, 0deg)) translate(18px, 0) scale(0.9);
|
||||
}
|
||||
}
|
||||
.assistant-feedback-reasons {
|
||||
width: min(340px, 100%);
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease, border-color 120ms ease;
|
||||
background: var(--bg-panel);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
.message-feedback-button:hover {
|
||||
background: color-mix(in srgb, var(--accent) 8%, var(--bg-subtle));
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||
}
|
||||
.message-feedback-button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.message-feedback-confirmation {
|
||||
.assistant-feedback-reason-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
background: var(--bg-subtle);
|
||||
gap: 5px;
|
||||
margin-bottom: 7px;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.assistant-feedback-reason-emoji {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
.assistant-feedback-reason-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.assistant-feedback-reason-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-height: 26px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.message-feedback[data-rating="positive"] .message-feedback-confirmation {
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--bg-subtle));
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||
}
|
||||
.message-feedback[data-rating="negative"] .message-feedback-confirmation {
|
||||
background: color-mix(in srgb, var(--amber) 10%, var(--bg-subtle));
|
||||
border-color: color-mix(in srgb, var(--amber) 35%, var(--border));
|
||||
}
|
||||
.message-feedback-change {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--text-muted);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.message-feedback-change:hover { color: var(--text); }
|
||||
.message-feedback-change:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.message-feedback-comment {
|
||||
flex-basis: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
max-width: 520px;
|
||||
}
|
||||
.message-feedback-comment-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.message-feedback-comment-input {
|
||||
min-height: 64px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
color: var(--text);
|
||||
resize: vertical;
|
||||
}
|
||||
.message-feedback-comment-input:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.message-feedback-comment-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.message-feedback-comment-submit {
|
||||
padding: 4px 12px;
|
||||
background: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--bg-panel);
|
||||
font: inherit;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.message-feedback-comment-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.message-feedback-comment-saved {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.assistant-feedback-reason-option:hover {
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.assistant-feedback-reason-option[data-selected="true"] {
|
||||
border-color: var(--accent-soft);
|
||||
background: var(--accent-tint);
|
||||
color: var(--accent);
|
||||
}
|
||||
.assistant-feedback-reason-option input {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.assistant-feedback-custom {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
resize: vertical;
|
||||
}
|
||||
.assistant-feedback-custom:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-tint);
|
||||
}
|
||||
.assistant-feedback-submit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 26px;
|
||||
margin-top: 8px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--accent-soft);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
}
|
||||
.assistant-feedback-submit:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.assistant-feedback-submit:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.unfinished-todos {
|
||||
|
|
|
|||
|
|
@ -322,6 +322,15 @@ async function consumeDaemonRun({
|
|||
let exitCode: number | null = null;
|
||||
let exitSignal: string | null = null;
|
||||
let endStatus: ChatRunStatus | null = null;
|
||||
// Tracks whether the server explicitly declared `status: 'succeeded'` in
|
||||
// the SSE end payload (or via the fallback run-status fetch). Distinct
|
||||
// from `endStatus === 'succeeded'`, which can be a local fallback when
|
||||
// the SSE end event omits or sends an invalid `status` field. Only the
|
||||
// explicit declaration is allowed to bypass the exit-code/signal safety
|
||||
// net below — a missing-status fallback keeps the old behavior so a
|
||||
// failure response with `{code:1}` or `{code:null,signal:"SIGTERM"}` and
|
||||
// no `status` field still surfaces an error banner.
|
||||
let serverDeclaredSuccess = false;
|
||||
let lastEventId: string | null = initialLastEventId ?? null;
|
||||
let canceled = false;
|
||||
const cancelRun = () => {
|
||||
|
|
@ -430,6 +439,11 @@ async function consumeDaemonRun({
|
|||
if (event.event === 'end') {
|
||||
exitCode = typeof event.data.code === 'number' ? event.data.code : null;
|
||||
exitSignal = typeof event.data.signal === 'string' ? event.data.signal : null;
|
||||
// `serverDeclaredSuccess` records whether the server explicitly
|
||||
// set `status: 'succeeded'` in the end payload — the local
|
||||
// `'succeeded'` fallback below does not count and must keep
|
||||
// hitting the exit-code/signal safety net later.
|
||||
serverDeclaredSuccess = event.data.status === 'succeeded';
|
||||
endStatus = isChatRunStatus(event.data.status) ? event.data.status : 'succeeded';
|
||||
onRunStatus?.(endStatus);
|
||||
}
|
||||
|
|
@ -444,6 +458,11 @@ async function consumeDaemonRun({
|
|||
endStatus = status.status;
|
||||
exitCode = status.exitCode ?? null;
|
||||
exitSignal = status.signal ?? null;
|
||||
// Fallback REST path: `status.status` is explicitly declared by the
|
||||
// daemon's run record (it passed `isChatRunStatus()` above), so an
|
||||
// explicit `'succeeded'` here is just as authoritative as the SSE
|
||||
// end-event success.
|
||||
serverDeclaredSuccess = status.status === 'succeeded';
|
||||
onRunStatus?.(endStatus);
|
||||
} else {
|
||||
handlers.onError(new Error('daemon stream disconnected before run completed'));
|
||||
|
|
@ -453,7 +472,24 @@ async function consumeDaemonRun({
|
|||
|
||||
if (endStatus === 'canceled') return;
|
||||
|
||||
if (endStatus === 'failed' || exitSignal || (exitCode !== null && exitCode !== 0)) {
|
||||
// Trust the server's authoritative success declaration. When the server
|
||||
// explicitly sets `status: 'succeeded'` (either in the SSE end payload
|
||||
// or via the fallback run-status fetch), the run completed cleanly even
|
||||
// if the underlying process exited via a signal — some agents (e.g.
|
||||
// ACP agents like Devin for Terminal) intentionally exit via SIGTERM
|
||||
// after a clean prompt completion because they don't shut down on
|
||||
// `stdin.end()`. The signal/non-zero-code safety net is bypassed only
|
||||
// for that explicit declaration; a missing/invalid `status` from a
|
||||
// compatible or older daemon still falls back to `endStatus =
|
||||
// 'succeeded'` for the run-status surface but must keep the safety net
|
||||
// intact so a real failure response like `{code:1}` or
|
||||
// `{code:null,signal:"SIGTERM"}` without `status` still surfaces an
|
||||
// error banner.
|
||||
const looksLikeFailure =
|
||||
endStatus === 'failed' ||
|
||||
(!serverDeclaredSuccess &&
|
||||
(exitSignal || (exitCode !== null && exitCode !== 0)));
|
||||
if (looksLikeFailure) {
|
||||
const tail = stderrBuf.trim().slice(-400);
|
||||
handlers.onError(
|
||||
new Error(`agent exited with ${exitSignal ? `signal ${exitSignal}` : `code ${exitCode}`}${tail ? `\n${tail}` : ''}`),
|
||||
|
|
|
|||
|
|
@ -1,187 +0,0 @@
|
|||
/**
|
||||
* localStorage-backed feedback store for issue #1288.
|
||||
*
|
||||
* The acceptance criteria call for feedback that is "visually clear after
|
||||
* submission" and "leaves room for future feedback metadata, such as
|
||||
* reason, free-text comment, artifact id, task id, or Agent run id". We
|
||||
* persist locally rather than round-tripping through the daemon for v1
|
||||
* because:
|
||||
*
|
||||
* 1. The daemon's `messages` table is column-strict; adding a feedback
|
||||
* column would require a schema migration plus a contract bump in
|
||||
* `packages/contracts/src/api/chat.ts`. The team has not yet defined
|
||||
* the analytics pipeline shape (lefarcen's clarifying comment on
|
||||
* the issue), so persisting prematurely on the daemon would lock
|
||||
* in a shape that may need to change.
|
||||
*
|
||||
* 2. Local storage matches the lightweight, non-blocking UX the issue
|
||||
* asks for and survives reload, which is the minimum for the
|
||||
* "feedback state is visually clear after submission" criterion.
|
||||
* A future PR can replace the storage layer (or add a daemon
|
||||
* mirror) without touching the React surface, since the hook's
|
||||
* contract is just `(MessageFeedback | null, setter)`.
|
||||
*
|
||||
* Storage key shape: `open-design:message-feedback:<messageId>`. We
|
||||
* intentionally do not namespace by project / conversation since
|
||||
* `messageId` is already globally unique in the daemon's
|
||||
* `messages` table and the values would either match or be stale (in
|
||||
* which case the orphan entries are harmless 80-byte rows that the
|
||||
* browser GCs on its own quota policy).
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export type FeedbackRating = 'positive' | 'negative';
|
||||
|
||||
export interface MessageFeedback {
|
||||
rating: FeedbackRating;
|
||||
/** Optional free-text reason or comment, currently captured only for negative feedback. */
|
||||
comment?: string;
|
||||
/** Epoch ms for telemetry / "submitted at" labels. */
|
||||
submittedAt: number;
|
||||
}
|
||||
|
||||
const STORAGE_PREFIX = 'open-design:message-feedback:';
|
||||
|
||||
function storageKey(messageId: string): string {
|
||||
return `${STORAGE_PREFIX}${messageId}`;
|
||||
}
|
||||
|
||||
function safeWindow(): Window | null {
|
||||
return typeof window === 'undefined' ? null : window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persisted feedback for a single message. Returns `null` when
|
||||
* nothing has been recorded yet, on storage errors, or on shape mismatch
|
||||
* (e.g. an older / future schema version landed in storage). Callers
|
||||
* treat any non-null result as authoritative.
|
||||
*/
|
||||
export function readMessageFeedback(messageId: string): MessageFeedback | null {
|
||||
const w = safeWindow();
|
||||
if (!w) return null;
|
||||
let raw: string | null;
|
||||
try {
|
||||
raw = w.localStorage.getItem(storageKey(messageId));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const candidate = parsed as Record<string, unknown>;
|
||||
const rating = candidate.rating;
|
||||
if (rating !== 'positive' && rating !== 'negative') return null;
|
||||
const submittedAt = typeof candidate.submittedAt === 'number'
|
||||
? candidate.submittedAt
|
||||
: Date.now();
|
||||
const comment = typeof candidate.comment === 'string'
|
||||
? candidate.comment
|
||||
: undefined;
|
||||
return { rating, comment, submittedAt };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const FEEDBACK_EVENT_NAME = 'open-design:message-feedback';
|
||||
|
||||
interface FeedbackEventDetail {
|
||||
messageId: string;
|
||||
value: MessageFeedback | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist or clear the feedback for a single message. Passing `null`
|
||||
* removes the entry so the UI flips back to the unsubmitted state and
|
||||
* the user can re-rate.
|
||||
*
|
||||
* The broadcast contract: a same-tab `open-design:message-feedback`
|
||||
* CustomEvent always fires with the new value in `detail.value`,
|
||||
* regardless of whether the underlying storage write succeeded.
|
||||
* Listeners apply the value directly instead of re-reading storage so
|
||||
* a setItem failure (private mode, quota exceeded, disabled storage)
|
||||
* does not clobber the in-memory confirmation the user just saw
|
||||
* (codex + lefarcen P2 on PR #1308). The clear path likewise emits the
|
||||
* broadcast so two mounted hooks for the same message return to idle
|
||||
* together when the user clicks Change (Siri-Ray + lefarcen P2 on
|
||||
* PR #1308).
|
||||
*/
|
||||
export function writeMessageFeedback(
|
||||
messageId: string,
|
||||
feedback: MessageFeedback | null,
|
||||
): void {
|
||||
const w = safeWindow();
|
||||
if (!w) return;
|
||||
try {
|
||||
if (feedback === null) {
|
||||
w.localStorage.removeItem(storageKey(messageId));
|
||||
} else {
|
||||
w.localStorage.setItem(storageKey(messageId), JSON.stringify(feedback));
|
||||
}
|
||||
} catch {
|
||||
// Storage quota / disabled storage / private-mode rejection: the
|
||||
// UI keeps the in-memory state so the user still sees a confirmation
|
||||
// for this session. The broadcast below ensures every mounted hook
|
||||
// for this messageId picks up the new value from the event detail
|
||||
// even though `readMessageFeedback` would now return null.
|
||||
}
|
||||
try {
|
||||
const detail: FeedbackEventDetail = { messageId, value: feedback };
|
||||
w.dispatchEvent(new CustomEvent(FEEDBACK_EVENT_NAME, { detail }));
|
||||
} catch {
|
||||
/* IE-style CustomEvent shim missing — fine, single-mount remains correct */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for a single message's feedback. Returns the current value
|
||||
* and a setter that updates both storage and any other mounted listeners
|
||||
* for the same messageId (e.g. when the same message renders in two
|
||||
* places, like the chat pane plus a debug panel).
|
||||
*/
|
||||
export function useMessageFeedback(
|
||||
messageId: string,
|
||||
): [MessageFeedback | null, (next: MessageFeedback | null) => void] {
|
||||
const [value, setValue] = useState<MessageFeedback | null>(() =>
|
||||
readMessageFeedback(messageId),
|
||||
);
|
||||
|
||||
// Keep two mounts of this hook for the same messageId in sync.
|
||||
// Cross-tab updates land via the platform `storage` event and we
|
||||
// re-read from storage to pick up the new value. Same-tab updates
|
||||
// land via our `open-design:message-feedback` CustomEvent and we
|
||||
// apply the broadcast value directly — re-reading from storage at
|
||||
// this point would clobber the in-memory state if the writer's
|
||||
// setItem call failed (private mode / quota / disabled storage).
|
||||
useEffect(() => {
|
||||
const w = safeWindow();
|
||||
if (!w) return;
|
||||
const onStorage = (evt: StorageEvent) => {
|
||||
if (evt.key !== null && evt.key !== storageKey(messageId)) return;
|
||||
setValue(readMessageFeedback(messageId));
|
||||
};
|
||||
const onCustom = (evt: Event) => {
|
||||
const detail = (evt as CustomEvent<FeedbackEventDetail>).detail;
|
||||
if (!detail || detail.messageId !== messageId) return;
|
||||
setValue(detail.value);
|
||||
};
|
||||
w.addEventListener('storage', onStorage);
|
||||
w.addEventListener(FEEDBACK_EVENT_NAME, onCustom);
|
||||
// Re-read on mount in case storage changed between the lazy init
|
||||
// and the effect attaching (rare, but cheap to cover).
|
||||
setValue(readMessageFeedback(messageId));
|
||||
return () => {
|
||||
w.removeEventListener('storage', onStorage);
|
||||
w.removeEventListener(FEEDBACK_EVENT_NAME, onCustom);
|
||||
};
|
||||
}, [messageId]);
|
||||
|
||||
const set = (next: MessageFeedback | null): void => {
|
||||
setValue(next);
|
||||
writeMessageFeedback(messageId, next);
|
||||
};
|
||||
|
||||
return [value, set];
|
||||
}
|
||||
|
|
@ -8,6 +8,9 @@ import type {
|
|||
AudioKind,
|
||||
ChatAttachment,
|
||||
ChatCommentAttachment,
|
||||
ChatMessageFeedback,
|
||||
ChatMessageFeedbackRating,
|
||||
ChatMessageFeedbackReasonCode,
|
||||
ChatMessage,
|
||||
ConnectionTestKind,
|
||||
ConnectionTestProtocol,
|
||||
|
|
@ -351,7 +354,24 @@ export interface LiveArtifactEventItem {
|
|||
event: Extract<AgentEvent, { kind: 'live_artifact' | 'live_artifact_refresh' }>;
|
||||
}
|
||||
|
||||
export type { ChatAttachment, ChatCommentAttachment, ChatMessage };
|
||||
export type ChatMessageFeedbackChange =
|
||||
| ({
|
||||
rating: ChatMessageFeedbackRating;
|
||||
} & Partial<
|
||||
Pick<
|
||||
ChatMessageFeedback,
|
||||
'reasonCodes' | 'customReason' | 'reasonsSubmittedAt'
|
||||
>
|
||||
>)
|
||||
| null;
|
||||
|
||||
export type {
|
||||
ChatAttachment,
|
||||
ChatCommentAttachment,
|
||||
ChatMessage,
|
||||
ChatMessageFeedbackRating,
|
||||
ChatMessageFeedbackReasonCode,
|
||||
};
|
||||
|
||||
export interface Artifact {
|
||||
identifier: string;
|
||||
|
|
|
|||
70
apps/web/tests/artifacts/question-form.test.ts
Normal file
70
apps/web/tests/artifacts/question-form.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatFormAnswers, splitOnQuestionForms } from '../../src/artifacts/question-form';
|
||||
|
||||
describe('splitOnQuestionForms', () => {
|
||||
it('normalizes string and object question options', () => {
|
||||
const input = [
|
||||
'<question-form id="discovery" title="Quick brief">',
|
||||
'{',
|
||||
' "questions": [',
|
||||
' {',
|
||||
' "id": "platform",',
|
||||
' "label": "Primary surface",',
|
||||
' "type": "radio",',
|
||||
' "required": true,',
|
||||
' "options": [',
|
||||
' "Responsive",',
|
||||
' { "label": "Mobile (iOS/Android)", "description": "Phone-first app prototype", "value": "mobile" },',
|
||||
' { "label": "Desktop web", "description": "Browser-first prototype" },',
|
||||
' { "description": "Missing label" }',
|
||||
' ]',
|
||||
' }',
|
||||
' ]',
|
||||
'}',
|
||||
'</question-form>',
|
||||
].join('\n');
|
||||
|
||||
const segments = splitOnQuestionForms(input);
|
||||
expect(segments).toHaveLength(1);
|
||||
expect(segments[0]).toMatchObject({ kind: 'form' });
|
||||
if (segments[0]?.kind !== 'form') throw new Error('expected parsed form segment');
|
||||
|
||||
expect(segments[0].form.questions[0]?.options).toEqual([
|
||||
{ label: 'Responsive', value: 'Responsive' },
|
||||
{
|
||||
label: 'Mobile (iOS/Android)',
|
||||
value: 'mobile',
|
||||
description: 'Phone-first app prototype',
|
||||
},
|
||||
{
|
||||
label: 'Desktop web',
|
||||
value: 'Desktop web',
|
||||
description: 'Browser-first prototype',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves stable option values when formatting object-option answers', () => {
|
||||
const text = formatFormAnswers(
|
||||
{
|
||||
id: 'discovery',
|
||||
title: 'Quick brief',
|
||||
questions: [
|
||||
{
|
||||
id: 'platform',
|
||||
label: 'Primary surface',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Mobile (iOS/Android)', value: 'mobile' },
|
||||
{ label: 'Desktop web', value: 'Desktop web' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ platform: 'mobile' },
|
||||
);
|
||||
|
||||
expect(text).toContain('- Primary surface: Mobile (iOS/Android) [value: mobile]');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,17 +1,14 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
/**
|
||||
* Visibility-gate coverage for the feedback widget (issue #1288). The
|
||||
* lefarcen P2 review on PR #1308 pointed out that mounting the
|
||||
* widget on every `runSucceeded && !hasEmptyResponse` turn would
|
||||
* surface it after text-only acknowledgements and question-form
|
||||
* replies that don't produce a final artifact. The issue is scoped
|
||||
* to final-artifact turns specifically, so the gate now also
|
||||
* requires `produced.length > 0`.
|
||||
* Visibility-gate coverage for assistant artifact feedback (issue #1288).
|
||||
* Feedback should only appear for successful assistant turns that produce
|
||||
* or update an artifact, not for text-only acknowledgements, failed runs,
|
||||
* streaming turns, or empty responses.
|
||||
*/
|
||||
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AssistantMessage } from '../../src/components/AssistantMessage';
|
||||
import type { ChatMessage, ProjectFile } from '../../src/types';
|
||||
|
|
@ -56,9 +53,12 @@ describe('AssistantMessage feedback gate (issue #1288)', () => {
|
|||
message={baseMessage({ producedFiles: [producedFile('index.html')] })}
|
||||
streaming={false}
|
||||
projectId="proj-1"
|
||||
onFeedback={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Was this response helpful?')).toBeTruthy();
|
||||
expect(screen.getByRole('group', { name: 'Feedback' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'Helpful' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'Not helpful' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hides the feedback widget for a successful text-only turn with no producedFiles', () => {
|
||||
|
|
@ -71,9 +71,10 @@ describe('AssistantMessage feedback gate (issue #1288)', () => {
|
|||
message={baseMessage({ producedFiles: [] })}
|
||||
streaming={false}
|
||||
projectId="proj-1"
|
||||
onFeedback={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('Was this response helpful?')).toBeNull();
|
||||
expect(screen.queryByRole('group', { name: 'Feedback' })).toBeNull();
|
||||
});
|
||||
|
||||
it('hides the feedback widget while the turn is still streaming', () => {
|
||||
|
|
@ -86,9 +87,10 @@ describe('AssistantMessage feedback gate (issue #1288)', () => {
|
|||
})}
|
||||
streaming
|
||||
projectId="proj-1"
|
||||
onFeedback={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('Was this response helpful?')).toBeNull();
|
||||
expect(screen.queryByRole('group', { name: 'Feedback' })).toBeNull();
|
||||
});
|
||||
|
||||
it('hides the feedback widget when the run failed', () => {
|
||||
|
|
@ -100,9 +102,10 @@ describe('AssistantMessage feedback gate (issue #1288)', () => {
|
|||
})}
|
||||
streaming={false}
|
||||
projectId="proj-1"
|
||||
onFeedback={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('Was this response helpful?')).toBeNull();
|
||||
expect(screen.queryByRole('group', { name: 'Feedback' })).toBeNull();
|
||||
});
|
||||
|
||||
it('hides the feedback widget when the run ended with an empty_response status', () => {
|
||||
|
|
@ -116,8 +119,9 @@ describe('AssistantMessage feedback gate (issue #1288)', () => {
|
|||
})}
|
||||
streaming={false}
|
||||
projectId="proj-1"
|
||||
onFeedback={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText('Was this response helpful?')).toBeNull();
|
||||
expect(screen.queryByRole('group', { name: 'Feedback' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
560
apps/web/tests/components/MemorySection.test.tsx
Normal file
560
apps/web/tests/components/MemorySection.test.tsx
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MemorySection } from '../../src/components/MemorySection';
|
||||
import { I18nProvider } from '../../src/i18n';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalEventSource = globalThis.EventSource;
|
||||
|
||||
class StubEventSource {
|
||||
url: string;
|
||||
listeners = new Map<string, Array<(event: MessageEvent) => void>>();
|
||||
static instances: StubEventSource[] = [];
|
||||
constructor(url: string | URL) {
|
||||
this.url = String(url);
|
||||
StubEventSource.instances.push(this);
|
||||
}
|
||||
addEventListener(type: string, listener: (event: MessageEvent) => void) {
|
||||
const existing = this.listeners.get(type) ?? [];
|
||||
existing.push(listener);
|
||||
this.listeners.set(type, existing);
|
||||
}
|
||||
emit(type: string, data: unknown) {
|
||||
const event = { data: JSON.stringify(data) } as MessageEvent;
|
||||
for (const listener of this.listeners.get(type) ?? []) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
|
||||
function renderMemorySection() {
|
||||
render(
|
||||
<I18nProvider initial="en">
|
||||
<MemorySection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('MemorySection', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
globalThis.fetch = originalFetch;
|
||||
StubEventSource.instances = [];
|
||||
if (originalEventSource) {
|
||||
globalThis.EventSource = originalEventSource;
|
||||
} else {
|
||||
// @ts-expect-error jsdom shim cleanup
|
||||
delete globalThis.EventSource;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows the no-provider banner when the latest extraction skipped for missing credentials', async () => {
|
||||
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = input.toString();
|
||||
if (url === '/api/memory') {
|
||||
return new Response(JSON.stringify({
|
||||
enabled: true,
|
||||
rootDir: '/tmp/memory',
|
||||
index: '# Memory\n',
|
||||
entries: [],
|
||||
extraction: null,
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (url === '/api/memory/extractions') {
|
||||
return new Response(JSON.stringify({
|
||||
extractions: [
|
||||
{
|
||||
id: 'ex-1',
|
||||
phase: 'skipped',
|
||||
reason: 'no-provider',
|
||||
kind: 'llm',
|
||||
startedAt: Date.now(),
|
||||
userMessagePreview: 'Remember my UI preferences',
|
||||
},
|
||||
],
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
renderMemorySection();
|
||||
|
||||
expect(await screen.findByText('LLM memory extraction is not running')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/No API key found for the memory extractor/i),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('creates a new memory entry and refreshes the list', async () => {
|
||||
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
||||
let entries = [] as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
updatedAt: number;
|
||||
}>;
|
||||
const createBodies: unknown[] = [];
|
||||
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
if (url === '/api/memory' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({
|
||||
enabled: true,
|
||||
rootDir: '/tmp/memory',
|
||||
index: '# Memory\n',
|
||||
entries,
|
||||
extraction: null,
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (url === '/api/memory/extractions') {
|
||||
return new Response(JSON.stringify({ extractions: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/memory' && init?.method === 'POST') {
|
||||
const body = JSON.parse(String(init.body));
|
||||
createBodies.push(body);
|
||||
entries = [
|
||||
{
|
||||
id: 'user_ui_preferences',
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
type: body.type,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
];
|
||||
return new Response(JSON.stringify({
|
||||
entry: {
|
||||
id: 'user_ui_preferences',
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
type: body.type,
|
||||
body: body.body,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
renderMemorySection();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'New memory' }));
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. UI preferences'), {
|
||||
target: { value: 'UI preferences' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('One sentence — what is this memory about?'), {
|
||||
target: { value: 'Persistent UI rendering preferences' },
|
||||
});
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText(/- Rule one[\s\S]*When to apply: optional scope/),
|
||||
{
|
||||
target: { value: '- Prefer dark mode\n- Prefer generous spacing' },
|
||||
},
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('UI preferences')).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText('✓ Memory created')).toBeTruthy();
|
||||
expect(createBodies).toEqual([
|
||||
{
|
||||
name: 'UI preferences',
|
||||
description: 'Persistent UI rendering preferences',
|
||||
type: 'user',
|
||||
body: '- Prefer dark mode\n- Prefer generous spacing',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows unsaved index state and saves the updated index', async () => {
|
||||
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
||||
let savedIndex = '# Memory\n\n- Existing bullet\n';
|
||||
const putBodies: string[] = [];
|
||||
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
if (url === '/api/memory' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({
|
||||
enabled: true,
|
||||
rootDir: '/tmp/memory',
|
||||
index: savedIndex,
|
||||
entries: [],
|
||||
extraction: null,
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (url === '/api/memory/extractions') {
|
||||
return new Response(JSON.stringify({ extractions: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/memory/index' && init?.method === 'PUT') {
|
||||
const body = JSON.parse(String(init.body));
|
||||
putBodies.push(body.index);
|
||||
savedIndex = body.index;
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
renderMemorySection();
|
||||
|
||||
fireEvent.click(await screen.findByText('MEMORY.md (index)'));
|
||||
const indexArea = screen.getByRole('textbox') as HTMLTextAreaElement;
|
||||
fireEvent.change(indexArea, {
|
||||
target: { value: '# Memory\n\n- Existing bullet\n- New bullet\n' },
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/Unsaved changes/i)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save index' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('✓ Index saved')).toBeTruthy();
|
||||
});
|
||||
expect(putBodies).toEqual(['# Memory\n\n- Existing bullet\n- New bullet\n']);
|
||||
});
|
||||
|
||||
it('clears extraction history after clicking Clear', async () => {
|
||||
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
||||
const deletedUrls: string[] = [];
|
||||
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
if (url === '/api/memory') {
|
||||
return new Response(JSON.stringify({
|
||||
enabled: true,
|
||||
rootDir: '/tmp/memory',
|
||||
index: '# Memory\n',
|
||||
entries: [],
|
||||
extraction: null,
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (url === '/api/memory/extractions' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({
|
||||
extractions: [
|
||||
{
|
||||
id: 'ex-1',
|
||||
phase: 'success',
|
||||
kind: 'llm',
|
||||
startedAt: Date.now(),
|
||||
finishedAt: Date.now() + 1200,
|
||||
userMessagePreview: 'Remember I prefer dark mode',
|
||||
proposedCount: 1,
|
||||
writtenCount: 1,
|
||||
},
|
||||
],
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (url === '/api/memory/extractions' && init?.method === 'DELETE') {
|
||||
deletedUrls.push(url);
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
renderMemorySection();
|
||||
|
||||
fireEvent.click(await screen.findByText('Extraction history'));
|
||||
expect(await screen.findByText('Remember I prefer dark mode')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No extractions yet. The next chat turn will populate this list.')).toBeTruthy();
|
||||
});
|
||||
expect(deletedUrls).toEqual(['/api/memory/extractions']);
|
||||
});
|
||||
|
||||
it('loads preview, edits an entry, and refreshes the saved content', async () => {
|
||||
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
||||
let entryBody = '- Prefer compact cards';
|
||||
let entryDescription = 'Initial preference';
|
||||
const putBodies: unknown[] = [];
|
||||
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
if (url === '/api/memory' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({
|
||||
enabled: true,
|
||||
rootDir: '/tmp/memory',
|
||||
index: '# Memory\n',
|
||||
entries: [
|
||||
{
|
||||
id: 'user_ui_preferences',
|
||||
name: 'UI preferences',
|
||||
description: entryDescription,
|
||||
type: 'user',
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
],
|
||||
extraction: null,
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (url === '/api/memory/extractions') {
|
||||
return new Response(JSON.stringify({ extractions: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/memory/user_ui_preferences' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({
|
||||
entry: {
|
||||
id: 'user_ui_preferences',
|
||||
name: 'UI preferences',
|
||||
description: entryDescription,
|
||||
type: 'user',
|
||||
body: entryBody,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (url === '/api/memory/user_ui_preferences' && init?.method === 'PUT') {
|
||||
const body = JSON.parse(String(init.body));
|
||||
putBodies.push(body);
|
||||
entryDescription = body.description;
|
||||
entryBody = body.body;
|
||||
return new Response(JSON.stringify({
|
||||
entry: {
|
||||
id: 'user_ui_preferences',
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
type: body.type,
|
||||
body: body.body,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
renderMemorySection();
|
||||
|
||||
const card = await screen.findByText('UI preferences');
|
||||
const row = card.closest('.library-card') as HTMLElement;
|
||||
|
||||
fireEvent.click(within(row).getByTitle('Preview'));
|
||||
expect(await screen.findByText('Prefer compact cards')).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(row).getByTitle('Edit'));
|
||||
fireEvent.change(await screen.findByDisplayValue('Initial preference'), {
|
||||
target: { value: 'Updated preference' },
|
||||
});
|
||||
fireEvent.change(
|
||||
await screen.findByDisplayValue('- Prefer compact cards'),
|
||||
{ target: { value: '- Prefer spacious layouts' } },
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('✓ Memory saved')).toBeTruthy();
|
||||
});
|
||||
expect(putBodies).toEqual([
|
||||
{
|
||||
id: 'user_ui_preferences',
|
||||
name: 'UI preferences',
|
||||
description: 'Updated preference',
|
||||
type: 'user',
|
||||
body: '- Prefer spacious layouts',
|
||||
},
|
||||
]);
|
||||
expect(screen.getByText('Updated preference')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('deletes an existing memory entry from the list', async () => {
|
||||
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
||||
let entries = [
|
||||
{
|
||||
id: 'user_ui_preferences',
|
||||
name: 'UI preferences',
|
||||
description: 'Persistent UI rendering preferences',
|
||||
type: 'user',
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
];
|
||||
const deletedUrls: string[] = [];
|
||||
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
if (url === '/api/memory' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({
|
||||
enabled: true,
|
||||
rootDir: '/tmp/memory',
|
||||
index: '# Memory\n',
|
||||
entries,
|
||||
extraction: null,
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (url === '/api/memory/extractions') {
|
||||
return new Response(JSON.stringify({ extractions: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/memory/user_ui_preferences' && init?.method === 'DELETE') {
|
||||
deletedUrls.push(url);
|
||||
entries = [];
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
renderMemorySection();
|
||||
|
||||
const card = (await screen.findByText('UI preferences')).closest('.library-card') as HTMLElement;
|
||||
fireEvent.click(within(card).getByTitle('Delete'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('✓ Memory deleted')).toBeTruthy();
|
||||
expect(screen.getByText(/No memory yet\./)).toBeTruthy();
|
||||
});
|
||||
expect(deletedUrls).toEqual(['/api/memory/user_ui_preferences']);
|
||||
});
|
||||
|
||||
it('deletes a single extraction row without clearing the whole history', async () => {
|
||||
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
||||
const deletedUrls: string[] = [];
|
||||
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
if (url === '/api/memory') {
|
||||
return new Response(JSON.stringify({
|
||||
enabled: true,
|
||||
rootDir: '/tmp/memory',
|
||||
index: '# Memory\n',
|
||||
entries: [],
|
||||
extraction: null,
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (url === '/api/memory/extractions' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({
|
||||
extractions: [
|
||||
{
|
||||
id: 'ex-1',
|
||||
phase: 'success',
|
||||
kind: 'llm',
|
||||
startedAt: Date.now(),
|
||||
userMessagePreview: 'Remember I prefer dark mode',
|
||||
writtenCount: 1,
|
||||
},
|
||||
{
|
||||
id: 'ex-2',
|
||||
phase: 'skipped',
|
||||
reason: 'no-match',
|
||||
kind: 'heuristic',
|
||||
startedAt: Date.now() - 1000,
|
||||
userMessagePreview: 'No durable memory in this turn',
|
||||
},
|
||||
],
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (url === '/api/memory/extractions/ex-1' && init?.method === 'DELETE') {
|
||||
deletedUrls.push(url);
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
renderMemorySection();
|
||||
|
||||
fireEvent.click(await screen.findByText('Extraction history'));
|
||||
expect(await screen.findByText('Remember I prefer dark mode')).toBeTruthy();
|
||||
expect(screen.getByText('No durable memory in this turn')).toBeTruthy();
|
||||
|
||||
const row = screen.getByText('Remember I prefer dark mode').closest('li') as HTMLElement;
|
||||
fireEvent.click(within(row).getByRole('button', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Remember I prefer dark mode')).toBeNull();
|
||||
});
|
||||
expect(screen.getByText('No durable memory in this turn')).toBeTruthy();
|
||||
expect(deletedUrls).toEqual(['/api/memory/extractions/ex-1']);
|
||||
});
|
||||
|
||||
it('applies extraction and change SSE events to the visible lists', async () => {
|
||||
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
||||
let entries = [
|
||||
{
|
||||
id: 'user_ui_preferences',
|
||||
name: 'UI preferences',
|
||||
description: 'Initial preference',
|
||||
type: 'user',
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
if (url === '/api/memory' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({
|
||||
enabled: true,
|
||||
rootDir: '/tmp/memory',
|
||||
index: '# Memory\n',
|
||||
entries,
|
||||
extraction: null,
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (url === '/api/memory/extractions') {
|
||||
return new Response(JSON.stringify({ extractions: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
renderMemorySection();
|
||||
|
||||
fireEvent.click(await screen.findByText('Extraction history'));
|
||||
expect(screen.getByText('UI preferences')).toBeTruthy();
|
||||
expect(screen.getByText('No extractions yet. The next chat turn will populate this list.')).toBeTruthy();
|
||||
|
||||
const es = StubEventSource.instances[0]!;
|
||||
es.emit('extraction', {
|
||||
id: 'ex-1',
|
||||
phase: 'running',
|
||||
kind: 'llm',
|
||||
startedAt: Date.now(),
|
||||
userMessagePreview: 'Remember I prefer dark mode',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Remember I prefer dark mode')).toBeTruthy();
|
||||
expect(screen.getAllByText('Running…').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
entries = [
|
||||
...entries,
|
||||
{
|
||||
id: 'project_brief',
|
||||
name: 'Project brief',
|
||||
description: 'Pinned project context',
|
||||
type: 'project',
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
];
|
||||
es.emit('change', { kind: 'upsert', id: 'project_brief' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Project brief')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
/**
|
||||
* Render-level coverage for `<MessageFeedback>` (issue #1288). Drives
|
||||
* the widget's three states (idle, submitted positive, submitted
|
||||
* negative + comment) end to end through the real
|
||||
* `useMessageFeedback` hook so the localStorage round-trip is
|
||||
* exercised at the same time. The visibility gate (only after the
|
||||
* assistant message finishes successfully) lives in
|
||||
* `AssistantMessage.tsx` and is not the responsibility of this
|
||||
* component, so it is not asserted here.
|
||||
*/
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MessageFeedback } from '../../src/components/MessageFeedback';
|
||||
import { readMessageFeedback } from '../../src/state/message-feedback';
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe('MessageFeedback (issue #1288)', () => {
|
||||
it('shows the helpful-prompt and two thumb buttons in the idle state', () => {
|
||||
render(<MessageFeedback messageId="msg-idle" />);
|
||||
expect(screen.getByText('Was this response helpful?')).toBeTruthy();
|
||||
expect(screen.getByTestId('message-feedback-positive')).toBeTruthy();
|
||||
expect(screen.getByTestId('message-feedback-negative')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('persists a positive rating and flips to the confirmation chip on click', () => {
|
||||
render(<MessageFeedback messageId="msg-pos" now={() => 1700000001} />);
|
||||
fireEvent.click(screen.getByTestId('message-feedback-positive'));
|
||||
|
||||
expect(screen.getByText('Thanks for the feedback.')).toBeTruthy();
|
||||
expect(readMessageFeedback('msg-pos')).toEqual({
|
||||
rating: 'positive',
|
||||
submittedAt: 1700000001,
|
||||
comment: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('persists a negative rating and surfaces the optional comment textarea', () => {
|
||||
render(<MessageFeedback messageId="msg-neg" now={() => 1700000002} />);
|
||||
fireEvent.click(screen.getByTestId('message-feedback-negative'));
|
||||
|
||||
expect(screen.getByText("Thanks, we'll use this to improve.")).toBeTruthy();
|
||||
expect(screen.getByTestId('message-feedback-comment')).toBeTruthy();
|
||||
expect(readMessageFeedback('msg-neg')).toEqual({
|
||||
rating: 'negative',
|
||||
submittedAt: 1700000002,
|
||||
comment: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('records a negative comment on submit and shows the saved confirmation', () => {
|
||||
render(<MessageFeedback messageId="msg-neg-c" now={() => 1700000003} />);
|
||||
fireEvent.click(screen.getByTestId('message-feedback-negative'));
|
||||
|
||||
const textarea = screen.getByTestId('message-feedback-comment') as HTMLTextAreaElement;
|
||||
fireEvent.change(textarea, { target: { value: 'preview opened the pointer file' } });
|
||||
fireEvent.click(screen.getByTestId('message-feedback-comment-submit'));
|
||||
|
||||
expect(screen.getByText('Comment saved')).toBeTruthy();
|
||||
expect(readMessageFeedback('msg-neg-c')).toEqual({
|
||||
rating: 'negative',
|
||||
comment: 'preview opened the pointer file',
|
||||
submittedAt: 1700000003,
|
||||
});
|
||||
});
|
||||
|
||||
it('disables the Send button when the textarea is empty (no blank-comment writes)', () => {
|
||||
render(<MessageFeedback messageId="msg-neg-blank" />);
|
||||
fireEvent.click(screen.getByTestId('message-feedback-negative'));
|
||||
|
||||
const submit = screen.getByTestId('message-feedback-comment-submit') as HTMLButtonElement;
|
||||
expect(submit.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('clears feedback when Change is clicked, returning to the idle state', () => {
|
||||
// Issue Open Question 2 ("should users be able to change feedback
|
||||
// after submitting it?") — answered yes in this v1: clicking
|
||||
// Change unsticks the rating so the user can re-rate.
|
||||
render(<MessageFeedback messageId="msg-change" />);
|
||||
fireEvent.click(screen.getByTestId('message-feedback-positive'));
|
||||
expect(readMessageFeedback('msg-change')).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId('message-feedback-change'));
|
||||
|
||||
expect(screen.getByText('Was this response helpful?')).toBeTruthy();
|
||||
expect(readMessageFeedback('msg-change')).toBeNull();
|
||||
});
|
||||
|
||||
it('rehydrates the submitted state when storage already has a value at mount time', () => {
|
||||
// Reload-survival: the issue's "feedback state is visually clear
|
||||
// after submission" criterion implies the chip stays visible after
|
||||
// a refresh.
|
||||
window.localStorage.setItem(
|
||||
'open-design:message-feedback:msg-rehydrate',
|
||||
JSON.stringify({ rating: 'positive', submittedAt: 1700000010 }),
|
||||
);
|
||||
render(<MessageFeedback messageId="msg-rehydrate" />);
|
||||
expect(screen.getByText('Thanks for the feedback.')).toBeTruthy();
|
||||
// The idle prompt must NOT also appear.
|
||||
expect(screen.queryByText('Was this response helpful?')).toBeNull();
|
||||
});
|
||||
|
||||
it('lets the user clear a saved comment by erasing the textarea and clicking Send', () => {
|
||||
// Lefarcen P3 (#1308 review): the prior `draftComment ||
|
||||
// feedback.comment || ''` controlled value made the textarea
|
||||
// snap back to the saved comment whenever the draft was empty,
|
||||
// so the user could never erase a saved comment without
|
||||
// clicking Change first. With the draft-only value the user
|
||||
// can erase + Send to clear.
|
||||
window.localStorage.setItem(
|
||||
'open-design:message-feedback:msg-clear-comment',
|
||||
JSON.stringify({
|
||||
rating: 'negative',
|
||||
comment: 'preview opened the pointer file',
|
||||
submittedAt: 1700000020,
|
||||
}),
|
||||
);
|
||||
render(<MessageFeedback messageId="msg-clear-comment" />);
|
||||
|
||||
const textarea = screen.getByTestId('message-feedback-comment') as HTMLTextAreaElement;
|
||||
expect(textarea.value).toBe('preview opened the pointer file');
|
||||
fireEvent.change(textarea, { target: { value: '' } });
|
||||
expect(textarea.value).toBe('');
|
||||
|
||||
fireEvent.click(screen.getByTestId('message-feedback-comment-submit'));
|
||||
expect(readMessageFeedback('msg-clear-comment')).toEqual({
|
||||
rating: 'negative',
|
||||
comment: undefined,
|
||||
submittedAt: 1700000020,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the in-session confirmation visible when localStorage writes fail (private mode / quota)', () => {
|
||||
// Codex + lefarcen P2 (#1308 review): a failing setItem used to
|
||||
// unstick the just-submitted rating because the CustomEvent
|
||||
// listener re-read storage (now null) and overrode the in-memory
|
||||
// state. The fix puts the new value in the event detail so
|
||||
// listeners apply it directly.
|
||||
const setItemSpy = vi
|
||||
.spyOn(window.localStorage, 'setItem')
|
||||
.mockImplementation(() => {
|
||||
throw new Error('QuotaExceededError');
|
||||
});
|
||||
render(<MessageFeedback messageId="msg-quota" now={() => 1700000030} />);
|
||||
fireEvent.click(screen.getByTestId('message-feedback-positive'));
|
||||
|
||||
expect(screen.getByText('Thanks for the feedback.')).toBeTruthy();
|
||||
expect(screen.queryByText('Was this response helpful?')).toBeNull();
|
||||
setItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('keeps two mounts of the same messageId in sync (positive submit + Change)', () => {
|
||||
// Siri-Ray (#1308 review): the previous implementation broke the
|
||||
// same-tab sync contract on the clear path because it early-
|
||||
// returned before dispatching the CustomEvent. Two mounts of the
|
||||
// same message must reach the same state on both Submit and Clear.
|
||||
render(
|
||||
<div>
|
||||
<div data-testid="mount-a">
|
||||
<MessageFeedback messageId="msg-shared" now={() => 1700000040} />
|
||||
</div>
|
||||
<div data-testid="mount-b">
|
||||
<MessageFeedback messageId="msg-shared" now={() => 1700000040} />
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
|
||||
// Both start in idle.
|
||||
expect(screen.getAllByText('Was this response helpful?')).toHaveLength(2);
|
||||
|
||||
// Click positive on mount A: both mounts flip to submitted.
|
||||
const positiveButtons = screen.getAllByTestId('message-feedback-positive');
|
||||
fireEvent.click(positiveButtons[0]!);
|
||||
expect(screen.getAllByText('Thanks for the feedback.')).toHaveLength(2);
|
||||
expect(screen.queryByText('Was this response helpful?')).toBeNull();
|
||||
|
||||
// Click Change on mount B: both mounts return to idle.
|
||||
const changeButtons = screen.getAllByTestId('message-feedback-change');
|
||||
fireEvent.click(changeButtons[1]!);
|
||||
expect(screen.getAllByText('Was this response helpful?')).toHaveLength(2);
|
||||
expect(screen.queryByText('Thanks for the feedback.')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -26,6 +26,7 @@ describe('NewProjectPanel media provider badges', () => {
|
|||
designSystems={[]}
|
||||
defaultDesignSystemId={null}
|
||||
templates={[]}
|
||||
onDeleteTemplate={vi.fn()}
|
||||
promptTemplates={[]}
|
||||
onCreate={vi.fn()}
|
||||
mediaProviders={{
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ describe('NewProjectPanel design system defaults', () => {
|
|||
designSystems={designSystems}
|
||||
defaultDesignSystemId="clay"
|
||||
templates={[]}
|
||||
onDeleteTemplate={vi.fn()}
|
||||
promptTemplates={[]}
|
||||
onCreate={vi.fn()}
|
||||
/>,
|
||||
|
|
@ -119,6 +120,7 @@ describe('NewProjectPanel design system defaults', () => {
|
|||
designSystems={designSystems}
|
||||
defaultDesignSystemId="clay"
|
||||
templates={[]}
|
||||
onDeleteTemplate={vi.fn()}
|
||||
promptTemplates={[]}
|
||||
onCreate={onCreate}
|
||||
/>,
|
||||
|
|
@ -156,6 +158,7 @@ describe('NewProjectPanel design system defaults', () => {
|
|||
designSystems={designSystems}
|
||||
defaultDesignSystemId="clay"
|
||||
templates={[]}
|
||||
onDeleteTemplate={vi.fn()}
|
||||
promptTemplates={[]}
|
||||
onCreate={onCreate}
|
||||
/>,
|
||||
|
|
@ -195,6 +198,7 @@ describe('NewProjectPanel design system defaults', () => {
|
|||
designSystems={designSystems}
|
||||
defaultDesignSystemId={null}
|
||||
templates={[]}
|
||||
onDeleteTemplate={vi.fn()}
|
||||
promptTemplates={[]}
|
||||
onCreate={onCreate}
|
||||
/>,
|
||||
|
|
@ -224,6 +228,7 @@ describe('NewProjectPanel design system defaults', () => {
|
|||
designSystems={designSystems}
|
||||
defaultDesignSystemId="clay"
|
||||
templates={[]}
|
||||
onDeleteTemplate={vi.fn()}
|
||||
promptTemplates={[]}
|
||||
onCreate={onCreate}
|
||||
connectors={[]}
|
||||
|
|
@ -259,6 +264,7 @@ describe('NewProjectPanel design system defaults', () => {
|
|||
designSystems={designSystems}
|
||||
defaultDesignSystemId="clay"
|
||||
templates={[]}
|
||||
onDeleteTemplate={vi.fn()}
|
||||
promptTemplates={[]}
|
||||
onCreate={onCreate}
|
||||
/>,
|
||||
|
|
@ -293,6 +299,7 @@ describe('NewProjectPanel design system defaults', () => {
|
|||
designSystems={designSystems}
|
||||
defaultDesignSystemId="clay"
|
||||
templates={[]}
|
||||
onDeleteTemplate={vi.fn()}
|
||||
promptTemplates={[]}
|
||||
onCreate={emptyOnCreate}
|
||||
/>,
|
||||
|
|
@ -312,6 +319,7 @@ describe('NewProjectPanel design system defaults', () => {
|
|||
designSystems={designSystems}
|
||||
defaultDesignSystemId="clay"
|
||||
templates={templates}
|
||||
onDeleteTemplate={vi.fn()}
|
||||
promptTemplates={[]}
|
||||
onCreate={templateOnCreate}
|
||||
/>,
|
||||
|
|
@ -345,6 +353,7 @@ describe('NewProjectPanel design system defaults', () => {
|
|||
designSystems={designSystems}
|
||||
defaultDesignSystemId="clay"
|
||||
templates={[]}
|
||||
onDeleteTemplate={vi.fn()}
|
||||
promptTemplates={[]}
|
||||
onCreate={onCreate}
|
||||
/>,
|
||||
|
|
@ -379,6 +388,7 @@ describe('NewProjectPanel design system defaults', () => {
|
|||
designSystems={designSystems}
|
||||
defaultDesignSystemId="clay"
|
||||
templates={[]}
|
||||
onDeleteTemplate={vi.fn()}
|
||||
promptTemplates={[]}
|
||||
onCreate={onCreate}
|
||||
/>,
|
||||
|
|
@ -417,6 +427,7 @@ describe('NewProjectPanel design system defaults', () => {
|
|||
designSystems={designSystems}
|
||||
defaultDesignSystemId="clay"
|
||||
templates={[]}
|
||||
onDeleteTemplate={vi.fn()}
|
||||
promptTemplates={[]}
|
||||
onCreate={onCreate}
|
||||
/>,
|
||||
|
|
@ -498,6 +509,7 @@ describe('NewProjectPanel design system defaults', () => {
|
|||
designSystems={designSystems}
|
||||
defaultDesignSystemId="clay"
|
||||
templates={[]}
|
||||
onDeleteTemplate={vi.fn()}
|
||||
promptTemplates={[]}
|
||||
onCreate={onCreate}
|
||||
/>,
|
||||
|
|
@ -524,3 +536,30 @@ describe('NewProjectPanel design system defaults', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NewProjectPanel template deletion', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver;
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
});
|
||||
|
||||
it('calls onDeleteTemplate when user clicks delete button', async () => {
|
||||
const onDelete = vi.fn().mockResolvedValue(true);
|
||||
render(
|
||||
<NewProjectPanel
|
||||
skills={skills}
|
||||
designSystems={designSystems}
|
||||
defaultDesignSystemId="clay"
|
||||
templates={templates}
|
||||
onDeleteTemplate={onDelete}
|
||||
promptTemplates={[]}
|
||||
onCreate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'From template' }));
|
||||
const deleteBtn = screen.getByLabelText(/delete template/i);
|
||||
fireEvent.click(deleteBtn);
|
||||
expect(onDelete).toHaveBeenCalledWith('tmpl-landing');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { QuestionFormView } from '../../src/components/QuestionForm';
|
||||
import { QuestionFormView, parseSubmittedAnswers } from '../../src/components/QuestionForm';
|
||||
import type { QuestionForm } from '../../src/artifacts/question-form';
|
||||
|
||||
const form: QuestionForm = {
|
||||
|
|
@ -13,13 +13,78 @@ const form: QuestionForm = {
|
|||
id: 'tone',
|
||||
label: 'Visual tone (pick up to two)',
|
||||
type: 'checkbox',
|
||||
options: ['Editorial / magazine', 'Modern minimal', 'Soft gradients'],
|
||||
options: [
|
||||
{ label: 'Editorial / magazine', value: 'Editorial / magazine' },
|
||||
{ label: 'Modern minimal', value: 'Modern minimal' },
|
||||
{ label: 'Soft gradients', value: 'Soft gradients' },
|
||||
],
|
||||
maxSelections: 2,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const richForm = {
|
||||
id: 'discovery',
|
||||
title: 'Quick brief',
|
||||
questions: [
|
||||
{
|
||||
id: 'platform',
|
||||
label: 'Primary surface',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'Responsive', value: 'Responsive' },
|
||||
{
|
||||
label: 'Mobile (iOS/Android)',
|
||||
description: 'Phone-first app prototype',
|
||||
value: 'mobile',
|
||||
},
|
||||
{
|
||||
label: 'Desktop web',
|
||||
description: 'Browser-first prototype',
|
||||
value: 'Desktop web',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as QuestionForm;
|
||||
|
||||
const checkboxObjectForm = {
|
||||
id: 'discovery',
|
||||
title: 'Quick brief',
|
||||
questions: [
|
||||
{
|
||||
id: 'tone',
|
||||
label: 'Visual tone',
|
||||
type: 'checkbox',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'Editorial / magazine', value: 'editorial' },
|
||||
{ label: 'Soft gradients', value: 'soft-gradients' },
|
||||
{ label: 'Modern minimal', value: 'modern-minimal' },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as QuestionForm;
|
||||
|
||||
const selectObjectForm = {
|
||||
id: 'discovery',
|
||||
title: 'Quick brief',
|
||||
questions: [
|
||||
{
|
||||
id: 'platform',
|
||||
label: 'Primary surface',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'Mobile (iOS/Android)', value: 'mobile' },
|
||||
{ label: 'Desktop web', value: 'desktop-web' },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as QuestionForm;
|
||||
|
||||
describe('QuestionFormView', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
|
|
@ -43,4 +108,83 @@ describe('QuestionFormView', () => {
|
|||
expect(screen.getByText('answered')).toBeTruthy();
|
||||
expect(container.querySelectorAll('input[type="checkbox"]:checked')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('parses submitted object-option values from readable answer text', () => {
|
||||
expect(
|
||||
parseSubmittedAnswers(
|
||||
richForm,
|
||||
[
|
||||
'[form answers - discovery]',
|
||||
'- Primary surface: Mobile (iOS/Android) [value: mobile]',
|
||||
].join('\n'),
|
||||
),
|
||||
).toEqual({ platform: 'mobile' });
|
||||
});
|
||||
|
||||
it('renders radio object options and submits the readable label with stable value', () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(<QuestionFormView form={richForm} interactive onSubmit={onSubmit} />);
|
||||
|
||||
expect(screen.getByText('Responsive')).toBeTruthy();
|
||||
expect(screen.getByText('Mobile (iOS/Android)')).toBeTruthy();
|
||||
expect(screen.getByText('Phone-first app prototype')).toBeTruthy();
|
||||
expect(screen.getByText('Desktop web')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Mobile (iOS/Android)'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send answers' }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit.mock.calls[0]?.[0]).toContain(
|
||||
'- Primary surface: Mobile (iOS/Android) [value: mobile]',
|
||||
);
|
||||
expect(onSubmit.mock.calls[0]?.[1]).toEqual({ platform: 'mobile' });
|
||||
});
|
||||
|
||||
it('submits required checkbox object options with stable values', () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { container } = render(
|
||||
<QuestionFormView form={checkboxObjectForm} interactive onSubmit={onSubmit} />,
|
||||
);
|
||||
|
||||
const submit = screen.getByRole('button', { name: 'Send answers' });
|
||||
expect((submit as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Editorial / magazine'));
|
||||
fireEvent.click(screen.getByLabelText('Soft gradients'));
|
||||
|
||||
expect(container.querySelectorAll('input[type="checkbox"]:checked')).toHaveLength(2);
|
||||
expect((submit as HTMLButtonElement).disabled).toBe(false);
|
||||
|
||||
fireEvent.click(submit);
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit.mock.calls[0]?.[0]).toContain('Editorial / magazine [value: editorial]');
|
||||
expect(onSubmit.mock.calls[0]?.[0]).toContain('Soft gradients [value: soft-gradients]');
|
||||
expect(onSubmit.mock.calls[0]?.[1]).toEqual({
|
||||
tone: ['editorial', 'soft-gradients'],
|
||||
});
|
||||
});
|
||||
|
||||
it('submits required select object options with stable values', () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { container } = render(
|
||||
<QuestionFormView form={selectObjectForm} interactive onSubmit={onSubmit} />,
|
||||
);
|
||||
|
||||
const submit = screen.getByRole('button', { name: 'Send answers' });
|
||||
expect((submit as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
const select = container.querySelector('select');
|
||||
if (!select) throw new Error('expected select control');
|
||||
fireEvent.change(select, { target: { value: 'mobile' } });
|
||||
|
||||
expect((submit as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(submit);
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit.mock.calls[0]?.[0]).toContain(
|
||||
'- Primary surface: Mobile (iOS/Android) [value: mobile]',
|
||||
);
|
||||
expect(onSubmit.mock.calls[0]?.[1]).toEqual({ platform: 'mobile' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
468
apps/web/tests/components/RoutinesSection.test.tsx
Normal file
468
apps/web/tests/components/RoutinesSection.test.tsx
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Routine } from '@open-design/contracts';
|
||||
|
||||
import { RoutinesSection } from '../../src/components/RoutinesSection';
|
||||
import * as router from '../../src/router';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalConfirm = window.confirm;
|
||||
|
||||
describe('RoutinesSection', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
globalThis.fetch = originalFetch;
|
||||
window.confirm = originalConfirm;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('creates a weekly routine that reuses an existing project', async () => {
|
||||
let routines: Routine[] = [];
|
||||
const projects = [{ id: 'proj-1', name: 'Routine Test Project' }];
|
||||
const createBodies: unknown[] = [];
|
||||
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
if (url === '/api/routines' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({ routines }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/projects' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({ projects }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/routines' && init?.method === 'POST') {
|
||||
const body = JSON.parse(String(init.body));
|
||||
createBodies.push(body);
|
||||
routines = [{
|
||||
id: 'routine-1',
|
||||
name: body.name,
|
||||
prompt: body.prompt,
|
||||
schedule: body.schedule,
|
||||
target: body.target,
|
||||
skillId: null,
|
||||
agentId: null,
|
||||
enabled: true,
|
||||
nextRunAt: Date.now() + 3600_000,
|
||||
lastRun: null,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}];
|
||||
return new Response(JSON.stringify({ routine: routines[0] }), {
|
||||
status: 201,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
render(<RoutinesSection />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'New routine' }));
|
||||
fireEvent.change(screen.getByLabelText('Name'), {
|
||||
target: { value: 'Weekly digest' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('Prompt'), {
|
||||
target: { value: 'Summarize GitHub and design activity.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Weekly' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Wed' }));
|
||||
fireEvent.click(screen.getAllByRole('radio')[1]!);
|
||||
fireEvent.change(screen.getAllByRole('combobox')[1]!, {
|
||||
target: { value: 'proj-1' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Weekly digest')).toBeTruthy();
|
||||
});
|
||||
expect(createBodies).toEqual([
|
||||
{
|
||||
name: 'Weekly digest',
|
||||
prompt: 'Summarize GitHub and design activity.',
|
||||
schedule: {
|
||||
kind: 'weekly',
|
||||
weekday: 3,
|
||||
time: '09:00',
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
},
|
||||
target: {
|
||||
mode: 'reuse',
|
||||
projectId: 'proj-1',
|
||||
},
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('pauses and resumes an existing routine through PATCH updates', async () => {
|
||||
let routines: Routine[] = [{
|
||||
id: 'routine-1',
|
||||
name: 'Morning briefing',
|
||||
prompt: 'Morning summary',
|
||||
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
|
||||
target: { mode: 'create_each_run' },
|
||||
skillId: null,
|
||||
agentId: null,
|
||||
enabled: true,
|
||||
nextRunAt: Date.now() + 3600_000,
|
||||
lastRun: null,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}];
|
||||
const patchBodies: unknown[] = [];
|
||||
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
if (url === '/api/routines' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({ routines }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/projects' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({ projects: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/routines/routine-1' && init?.method === 'PATCH') {
|
||||
const body = JSON.parse(String(init.body));
|
||||
patchBodies.push(body);
|
||||
const current = routines[0]!;
|
||||
routines = [{
|
||||
...current,
|
||||
enabled: body.enabled,
|
||||
updatedAt: Date.now(),
|
||||
}];
|
||||
return new Response(JSON.stringify({ routine: routines[0] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
render(<RoutinesSection />);
|
||||
|
||||
const row = await screen.findByText('Morning briefing');
|
||||
const card = row.closest('li')!;
|
||||
|
||||
fireEvent.click(within(card).getByRole('button', { name: 'Pause' }));
|
||||
await waitFor(() => {
|
||||
expect(within(card).getByRole('button', { name: 'Resume' })).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(within(card).getByRole('button', { name: 'Resume' }));
|
||||
await waitFor(() => {
|
||||
expect(within(card).getByRole('button', { name: 'Pause' })).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(patchBodies).toEqual([{ enabled: false }, { enabled: true }]);
|
||||
});
|
||||
|
||||
it('runs a routine now and loads its history', async () => {
|
||||
let routines: Routine[] = [{
|
||||
id: 'routine-1',
|
||||
name: 'Morning briefing',
|
||||
prompt: 'Morning summary',
|
||||
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
|
||||
target: { mode: 'create_each_run' },
|
||||
skillId: null,
|
||||
agentId: null,
|
||||
enabled: true,
|
||||
nextRunAt: Date.now() + 3600_000,
|
||||
lastRun: null,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}];
|
||||
const runBodies: string[] = [];
|
||||
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
if (url === '/api/routines' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({ routines }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/projects' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({ projects: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/routines/routine-1/run' && init?.method === 'POST') {
|
||||
runBodies.push(url);
|
||||
const current = routines[0]!;
|
||||
routines = [{
|
||||
...current,
|
||||
lastRun: {
|
||||
runId: 'run-1',
|
||||
status: 'queued',
|
||||
trigger: 'manual',
|
||||
startedAt: Date.now(),
|
||||
projectId: 'proj-run',
|
||||
conversationId: 'conv-run',
|
||||
agentRunId: 'agent-run-1',
|
||||
},
|
||||
}];
|
||||
return new Response(JSON.stringify({
|
||||
routine: routines[0],
|
||||
run: routines[0]!.lastRun,
|
||||
projectId: 'proj-run',
|
||||
conversationId: 'conv-run',
|
||||
agentRunId: 'agent-run-1',
|
||||
}), {
|
||||
status: 202,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/routines/routine-1/runs?limit=10') {
|
||||
return new Response(JSON.stringify({
|
||||
runs: [
|
||||
{
|
||||
id: 'run-1',
|
||||
routineId: 'routine-1',
|
||||
trigger: 'manual',
|
||||
status: 'queued',
|
||||
projectId: 'proj-run',
|
||||
conversationId: 'conv-run',
|
||||
agentRunId: 'agent-run-1',
|
||||
startedAt: Date.now(),
|
||||
completedAt: null,
|
||||
summary: null,
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
render(<RoutinesSection />);
|
||||
|
||||
const row = await screen.findByText('Morning briefing');
|
||||
const card = row.closest('li')!;
|
||||
|
||||
fireEvent.click(within(card).getByRole('button', { name: 'Run now' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(card).getByRole('button', { name: 'Hide history' })).toBeTruthy();
|
||||
});
|
||||
expect(await screen.findByText('manual')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'Open project' })).toBeTruthy();
|
||||
expect(runBodies).toEqual(['/api/routines/routine-1/run']);
|
||||
});
|
||||
|
||||
it('shows a validation error when reuse mode is selected without a project', async () => {
|
||||
const postBodies: unknown[] = [];
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
if (url === '/api/routines' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({ routines: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/projects' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({ projects: [{ id: 'proj-1', name: 'Routine Test Project' }] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/routines' && init?.method === 'POST') {
|
||||
postBodies.push(JSON.parse(String(init.body)));
|
||||
return new Response(JSON.stringify({}), { status: 400, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
render(<RoutinesSection />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'New routine' }));
|
||||
fireEvent.change(screen.getByLabelText('Name'), {
|
||||
target: { value: 'Weekly digest' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('Prompt'), {
|
||||
target: { value: 'Summarize GitHub and design activity.' },
|
||||
});
|
||||
fireEvent.click(screen.getAllByRole('radio')[1]!);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Create' })).toBeTruthy();
|
||||
});
|
||||
expect(postBodies).toEqual([]);
|
||||
});
|
||||
|
||||
it('deletes a routine after confirmation', async () => {
|
||||
let routines: Routine[] = [{
|
||||
id: 'routine-1',
|
||||
name: 'Morning briefing',
|
||||
prompt: 'Morning summary',
|
||||
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
|
||||
target: { mode: 'create_each_run' },
|
||||
skillId: null,
|
||||
agentId: null,
|
||||
enabled: true,
|
||||
nextRunAt: Date.now() + 3600_000,
|
||||
lastRun: null,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}];
|
||||
const deletedUrls: string[] = [];
|
||||
window.confirm = vi.fn(() => true);
|
||||
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
if (url === '/api/routines' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({ routines }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/projects' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({ projects: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/routines/routine-1' && init?.method === 'DELETE') {
|
||||
deletedUrls.push(url);
|
||||
routines = [];
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
render(<RoutinesSection />);
|
||||
|
||||
const row = (await screen.findByText('Morning briefing')).closest('li')!;
|
||||
fireEvent.click(within(row).getByRole('button', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No routines yet.')).toBeTruthy();
|
||||
});
|
||||
expect(deletedUrls).toEqual(['/api/routines/routine-1']);
|
||||
});
|
||||
|
||||
it('opens the project referenced by a routine run from history', async () => {
|
||||
const navigateSpy = vi.spyOn(router, 'navigate').mockImplementation(() => {});
|
||||
const routines = [{
|
||||
id: 'routine-1',
|
||||
name: 'Morning briefing',
|
||||
prompt: 'Morning summary',
|
||||
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
|
||||
target: { mode: 'create_each_run' },
|
||||
enabled: true,
|
||||
nextRunAt: Date.now() + 3600_000,
|
||||
lastRun: null,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}];
|
||||
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
if (url === '/api/routines' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({ routines }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/projects' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({ projects: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/routines/routine-1/runs?limit=10') {
|
||||
return new Response(JSON.stringify({
|
||||
runs: [
|
||||
{
|
||||
id: 'run-1',
|
||||
routineId: 'routine-1',
|
||||
trigger: 'manual',
|
||||
status: 'succeeded',
|
||||
projectId: 'proj-run',
|
||||
conversationId: 'conv-run',
|
||||
agentRunId: 'agent-run-1',
|
||||
startedAt: Date.now(),
|
||||
completedAt: Date.now() + 2000,
|
||||
summary: 'Done',
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
render(<RoutinesSection />);
|
||||
|
||||
const row = (await screen.findByText('Morning briefing')).closest('li')!;
|
||||
fireEvent.click(within(row).getByRole('button', { name: 'History' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Open project' }));
|
||||
|
||||
expect(navigateSpy).toHaveBeenCalledWith(
|
||||
{ kind: 'project', projectId: 'proj-run', fileName: null },
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the empty history state when a routine has never run', async () => {
|
||||
const routines = [{
|
||||
id: 'routine-1',
|
||||
name: 'Morning briefing',
|
||||
prompt: 'Morning summary',
|
||||
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
|
||||
target: { mode: 'create_each_run' },
|
||||
enabled: true,
|
||||
nextRunAt: Date.now() + 3600_000,
|
||||
lastRun: null,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}];
|
||||
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
if (url === '/api/routines' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({ routines }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/projects' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({ projects: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/routines/routine-1/runs?limit=10') {
|
||||
return new Response(JSON.stringify({ runs: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
render(<RoutinesSection />);
|
||||
|
||||
const row = (await screen.findByText('Morning briefing')).closest('li')!;
|
||||
fireEvent.click(within(row).getByRole('button', { name: 'History' }));
|
||||
|
||||
expect(await screen.findByText('No runs yet.')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -1462,42 +1462,30 @@ describe('SettingsDialog language interactions', () => {
|
|||
document.documentElement.removeAttribute('dir');
|
||||
});
|
||||
|
||||
it('opens the language menu and marks the current locale as selected', async () => {
|
||||
it('shows every locale as a tile and marks the current locale as selected', async () => {
|
||||
renderLanguageSettingsDialog('en');
|
||||
|
||||
const trigger = screen.getByRole('button', { name: /English/i });
|
||||
fireEvent.click(trigger);
|
||||
|
||||
const options = await screen.findAllByRole('menuitemradio');
|
||||
expect(options).toHaveLength(LOCALES.length);
|
||||
expect(screen.getByRole('menuitemradio', { name: /English/i }).getAttribute('aria-checked')).toBe('true');
|
||||
expect(screen.getByRole('menuitemradio', { name: /简体中文/i }).getAttribute('aria-checked')).toBe('false');
|
||||
const tiles = await screen.findAllByRole('radio');
|
||||
expect(tiles).toHaveLength(LOCALES.length);
|
||||
expect(screen.getByRole('radio', { name: /English/i }).getAttribute('aria-checked')).toBe('true');
|
||||
expect(screen.getByRole('radio', { name: /简体中文/i }).getAttribute('aria-checked')).toBe('false');
|
||||
});
|
||||
|
||||
it('switches locale immediately, updates localStorage, and closes the menu', async () => {
|
||||
it('switches locale immediately and updates localStorage', async () => {
|
||||
renderLanguageSettingsDialog('en');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /English/i }));
|
||||
fireEvent.click(await screen.findByRole('menuitemradio', { name: /简体中文/i }));
|
||||
fireEvent.click(screen.getByRole('radio', { name: /简体中文/i }));
|
||||
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /简体中文/i })).toBeTruthy();
|
||||
expect(screen.getByRole('radio', { name: /简体中文/i }).getAttribute('aria-checked')).toBe('true');
|
||||
expect(window.localStorage.getItem('open-design:locale')).toBe('zh-CN');
|
||||
expect(document.documentElement.getAttribute('lang')).toBe('zh-CN');
|
||||
expect(document.documentElement.getAttribute('dir')).toBe('ltr');
|
||||
});
|
||||
|
||||
it('sets rtl direction for rtl locales and closes the menu on escape', async () => {
|
||||
it('sets rtl direction for rtl locales', async () => {
|
||||
renderLanguageSettingsDialog('en');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /English/i }));
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /English/i }));
|
||||
fireEvent.click(await screen.findByRole('menuitemradio', { name: /فارسی/i }));
|
||||
fireEvent.click(screen.getByRole('radio', { name: /فارسی/i }));
|
||||
|
||||
expect(window.localStorage.getItem('open-design:locale')).toBe('fa');
|
||||
expect(document.documentElement.getAttribute('lang')).toBe('fa');
|
||||
|
|
@ -1507,8 +1495,7 @@ describe('SettingsDialog language interactions', () => {
|
|||
it('does not route language changes through autosave and closing does not revert an applied locale', async () => {
|
||||
const { onPersist, onClose } = renderLanguageSettingsDialog('en');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /English/i }));
|
||||
fireEvent.click(await screen.findByRole('menuitemradio', { name: /Deutsch/i }));
|
||||
fireEvent.click(screen.getByRole('radio', { name: /Deutsch/i }));
|
||||
|
||||
expect(window.localStorage.getItem('open-design:locale')).toBe('de');
|
||||
expect(document.documentElement.getAttribute('lang')).toBe('de');
|
||||
|
|
|
|||
341
apps/web/tests/components/chat-feedback.test.tsx
Normal file
341
apps/web/tests/components/chat-feedback.test.tsx
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
if (typeof HTMLElement.prototype.scrollTo !== 'function') {
|
||||
HTMLElement.prototype.scrollTo = function (
|
||||
options?: ScrollToOptions | number,
|
||||
_y?: number,
|
||||
) {
|
||||
if (typeof options === 'object' && options !== null) {
|
||||
if (options.top !== undefined) this.scrollTop = options.top;
|
||||
if (options.left !== undefined) this.scrollLeft = options.left;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChatPane } from '../../src/components/ChatPane';
|
||||
import type { ChatMessage, ChatMessageFeedbackChange } from '../../src/types';
|
||||
|
||||
function completedAssistant(
|
||||
input: Partial<ChatMessage> = {},
|
||||
): ChatMessage {
|
||||
return {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: 'Done',
|
||||
createdAt: 1_700_000_000_000,
|
||||
startedAt: 1_700_000_000_000,
|
||||
endedAt: 1_700_000_003_000,
|
||||
runStatus: 'succeeded',
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
||||
function completedArtifactAssistant(
|
||||
input: Partial<ChatMessage> = {},
|
||||
): ChatMessage {
|
||||
return completedAssistant({
|
||||
producedFiles: [
|
||||
{
|
||||
name: 'index.html',
|
||||
size: 1024,
|
||||
mtime: 1_700_000_003_000,
|
||||
kind: 'html',
|
||||
mime: 'text/html',
|
||||
},
|
||||
],
|
||||
...input,
|
||||
});
|
||||
}
|
||||
|
||||
function completedEditAssistant(
|
||||
input: Partial<ChatMessage> = {},
|
||||
): ChatMessage {
|
||||
return completedAssistant({
|
||||
events: [
|
||||
{
|
||||
kind: 'tool_use',
|
||||
id: 'edit-1',
|
||||
name: 'Edit',
|
||||
input: { file_path: 'index.html' },
|
||||
},
|
||||
{
|
||||
kind: 'tool_result',
|
||||
toolUseId: 'edit-1',
|
||||
content: 'Done',
|
||||
isError: false,
|
||||
},
|
||||
],
|
||||
...input,
|
||||
});
|
||||
}
|
||||
|
||||
function completedLiveArtifactAssistant(
|
||||
input: Partial<ChatMessage> = {},
|
||||
): ChatMessage {
|
||||
return completedAssistant({
|
||||
events: [
|
||||
{
|
||||
kind: 'live_artifact',
|
||||
action: 'updated',
|
||||
projectId: 'project-1',
|
||||
artifactId: 'live-1',
|
||||
title: 'Ricky Dental Poster',
|
||||
refreshStatus: 'idle',
|
||||
},
|
||||
],
|
||||
...input,
|
||||
});
|
||||
}
|
||||
|
||||
function renderChatPane({
|
||||
messages,
|
||||
streaming = false,
|
||||
onAssistantFeedback = vi.fn(),
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
streaming?: boolean;
|
||||
onAssistantFeedback?: (
|
||||
assistantMessage: ChatMessage,
|
||||
change: ChatMessageFeedbackChange,
|
||||
) => void;
|
||||
}) {
|
||||
return {
|
||||
onAssistantFeedback,
|
||||
...render(
|
||||
<ChatPane
|
||||
messages={messages}
|
||||
streaming={streaming}
|
||||
error={null}
|
||||
projectId="project-1"
|
||||
projectFiles={[]}
|
||||
onEnsureProject={async () => 'project-1'}
|
||||
onSend={() => {}}
|
||||
onStop={() => {}}
|
||||
conversations={[]}
|
||||
activeConversationId="conversation-1"
|
||||
onSelectConversation={() => {}}
|
||||
onDeleteConversation={() => {}}
|
||||
onAssistantFeedback={onAssistantFeedback}
|
||||
/>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe('chat assistant feedback', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('collects feedback only after an assistant turn produces an artifact', () => {
|
||||
renderChatPane({
|
||||
messages: [completedAssistant()],
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('group', { name: 'Feedback' })).toBeNull();
|
||||
});
|
||||
|
||||
it('collects positive and negative feedback on completed artifact results', () => {
|
||||
const { onAssistantFeedback } = renderChatPane({
|
||||
messages: [completedArtifactAssistant()],
|
||||
});
|
||||
const feedbackGroup = screen.getByRole('group', { name: 'Feedback' });
|
||||
const footer = document.querySelector('.assistant-footer');
|
||||
|
||||
expect(feedbackGroup.textContent).not.toContain('Feedback');
|
||||
expect(footer?.contains(feedbackGroup)).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Helpful' }));
|
||||
expect(onAssistantFeedback).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ id: 'assistant-1' }),
|
||||
{ rating: 'positive' },
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Not helpful' }));
|
||||
expect(onAssistantFeedback).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ id: 'assistant-1' }),
|
||||
{ rating: 'negative' },
|
||||
);
|
||||
expect(document.querySelector('.assistant-feedback-burst')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows feedback after completed artifact edits without newly produced files', () => {
|
||||
renderChatPane({
|
||||
messages: [completedEditAssistant()],
|
||||
});
|
||||
|
||||
expect(screen.getByRole('group', { name: 'Feedback' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows feedback after completed live artifact updates', () => {
|
||||
renderChatPane({
|
||||
messages: [completedLiveArtifactAssistant()],
|
||||
});
|
||||
|
||||
expect(screen.getByRole('group', { name: 'Feedback' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps every artifact turn feedback control visible and independent', () => {
|
||||
const { onAssistantFeedback } = renderChatPane({
|
||||
messages: [
|
||||
completedArtifactAssistant({ id: 'assistant-1' }),
|
||||
{
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: 'Make another version',
|
||||
createdAt: 1_700_000_004_000,
|
||||
},
|
||||
completedArtifactAssistant({ id: 'assistant-2', createdAt: 1_700_000_005_000 }),
|
||||
],
|
||||
});
|
||||
|
||||
const groups = screen.getAllByRole('group', { name: 'Feedback' });
|
||||
expect(groups).toHaveLength(2);
|
||||
|
||||
fireEvent.click(within(groups[0]!).getByRole('button', { name: 'Helpful' }));
|
||||
expect(onAssistantFeedback).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ id: 'assistant-1' }),
|
||||
{ rating: 'positive' },
|
||||
);
|
||||
|
||||
fireEvent.click(within(groups[1]!).getByRole('button', { name: 'Not helpful' }));
|
||||
expect(onAssistantFeedback).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ id: 'assistant-2' }),
|
||||
{ rating: 'negative' },
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the persisted feedback state without saved copy', () => {
|
||||
renderChatPane({
|
||||
messages: [
|
||||
completedArtifactAssistant({
|
||||
feedback: {
|
||||
rating: 'negative',
|
||||
createdAt: 1_700_000_004_000,
|
||||
updatedAt: 1_700_000_004_000,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Feedback saved')).toBeNull();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Not helpful' }).getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Helpful' }).getAttribute('aria-pressed'),
|
||||
).toBe('false');
|
||||
});
|
||||
|
||||
it('clicking an already selected feedback rating clears it', () => {
|
||||
const { onAssistantFeedback } = renderChatPane({
|
||||
messages: [
|
||||
completedArtifactAssistant({
|
||||
feedback: {
|
||||
rating: 'positive',
|
||||
createdAt: 1_700_000_004_000,
|
||||
updatedAt: 1_700_000_004_000,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Helpful' }));
|
||||
expect(onAssistantFeedback).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ id: 'assistant-1' }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('collects preset and custom reasons after a rating is selected', () => {
|
||||
const { onAssistantFeedback } = renderChatPane({
|
||||
messages: [completedArtifactAssistant()],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Helpful' }));
|
||||
expect(screen.getByText('Tell us why')).toBeTruthy();
|
||||
expect(screen.getByText('😊')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Understood my request'));
|
||||
fireEvent.click(screen.getByLabelText('Other'));
|
||||
fireEvent.change(screen.getByPlaceholderText('Add a short note...'), {
|
||||
target: { value: 'The layout is ready to present.' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit' }));
|
||||
|
||||
expect(onAssistantFeedback).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ id: 'assistant-1' }),
|
||||
expect.objectContaining({
|
||||
rating: 'positive',
|
||||
reasonCodes: ['matched_request', 'other'],
|
||||
customReason: 'The layout is ready to present.',
|
||||
reasonsSubmittedAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByText('Tell us why')).toBeNull();
|
||||
});
|
||||
|
||||
it('clears custom reason when Other is deselected', () => {
|
||||
const { onAssistantFeedback } = renderChatPane({
|
||||
messages: [completedArtifactAssistant()],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Helpful' }));
|
||||
fireEvent.click(screen.getByLabelText('Other'));
|
||||
fireEvent.change(screen.getByPlaceholderText('Add a short note...'), {
|
||||
target: { value: 'This note should not be submitted.' },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText('Other'));
|
||||
expect(screen.queryByPlaceholderText('Add a short note...')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Understood my request'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit' }));
|
||||
|
||||
expect(onAssistantFeedback).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ id: 'assistant-1' }),
|
||||
expect.objectContaining({
|
||||
rating: 'positive',
|
||||
reasonCodes: ['matched_request'],
|
||||
customReason: undefined,
|
||||
reasonsSubmittedAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a sad marker for negative feedback reasons', () => {
|
||||
renderChatPane({
|
||||
messages: [completedArtifactAssistant()],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Not helpful' }));
|
||||
|
||||
expect(screen.getByText('Tell us why')).toBeTruthy();
|
||||
expect(screen.getByText('😔')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not ask for feedback while the assistant is still running', () => {
|
||||
renderChatPane({
|
||||
streaming: true,
|
||||
messages: [
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
createdAt: 1_700_000_000_000,
|
||||
startedAt: 1_700_000_000_000,
|
||||
runStatus: 'running',
|
||||
producedFiles: [
|
||||
{
|
||||
name: 'index.html',
|
||||
size: 1024,
|
||||
mtime: 1_700_000_003_000,
|
||||
kind: 'html',
|
||||
mime: 'text/html',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('group', { name: 'Feedback' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -245,6 +245,113 @@ describe('streamViaDaemon', () => {
|
|||
expect(handlers.onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats an explicit succeeded status with a SIGTERM exit as a successful run', async () => {
|
||||
// ACP agents that don't shut down on stdin.end() (e.g. Devin for Terminal)
|
||||
// are SIGTERM'd by the daemon after a clean prompt completion. The end
|
||||
// event still declares `status: 'succeeded'`, and the chat must trust
|
||||
// that authoritative success even though `signal === 'SIGTERM'` would
|
||||
// otherwise look like a failure to the exit-code/signal safety net.
|
||||
const handlers = createDaemonHandlers();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ runId: 'run-1' }))
|
||||
.mockResolvedValueOnce(
|
||||
sseResponse(
|
||||
[
|
||||
'event: stdout',
|
||||
'data: {"chunk":"ok"}',
|
||||
'',
|
||||
'event: end',
|
||||
'data: {"code":null,"signal":"SIGTERM","status":"succeeded"}',
|
||||
'',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(handlers.onDone).toHaveBeenCalledWith('ok');
|
||||
expect(handlers.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still surfaces an error when the end event has a non-zero code and no status field', async () => {
|
||||
// Regression guard for the local 'succeeded' fallback at the end-event
|
||||
// handler: a compatible or older daemon may omit `status` from the end
|
||||
// payload, in which case `endStatus` is filled with the local default
|
||||
// `'succeeded'`. The exit-code/signal safety net must still apply for
|
||||
// that case so a real failure is not silently suppressed.
|
||||
const handlers = createDaemonHandlers();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ runId: 'run-1' }))
|
||||
.mockResolvedValueOnce(
|
||||
sseResponse(
|
||||
[
|
||||
'event: end',
|
||||
'data: {"code":1}',
|
||||
'',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(handlers.onError).toHaveBeenCalledWith(new Error('agent exited with code 1'));
|
||||
expect(handlers.onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still surfaces an error when the end event has a signal but no status field', async () => {
|
||||
// Same regression as above for the signal arm of the safety net. Without
|
||||
// explicit `status: 'succeeded'` from the server, a SIGTERM-style signal
|
||||
// exit must keep producing an error banner — only the explicit ACP
|
||||
// success path is allowed to bypass.
|
||||
const handlers = createDaemonHandlers();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ runId: 'run-1' }))
|
||||
.mockResolvedValueOnce(
|
||||
sseResponse(
|
||||
[
|
||||
'event: end',
|
||||
'data: {"code":null,"signal":"SIGTERM"}',
|
||||
'',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(handlers.onError).toHaveBeenCalledWith(new Error('agent exited with signal SIGTERM'));
|
||||
expect(handlers.onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the daemon run alive when the browser-side stream aborts', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const controller = new AbortController();
|
||||
|
|
|
|||
|
|
@ -1,139 +0,0 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
/**
|
||||
* Coverage for the localStorage-backed feedback store (issue #1288).
|
||||
* The store keeps the daemon out of the hot path for v1 so the
|
||||
* analytics pipeline can be designed without a contract migration;
|
||||
* these tests pin the persistence shape and the cross-mount sync
|
||||
* behaviour so a future swap-out for a daemon-backed implementation
|
||||
* doesn't quietly drop a feature the UI already depends on.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
readMessageFeedback,
|
||||
writeMessageFeedback,
|
||||
} from '../../src/state/message-feedback';
|
||||
|
||||
const STORAGE_KEY = (id: string) => `open-design:message-feedback:${id}`;
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe('message-feedback storage', () => {
|
||||
it('returns null when no feedback has been recorded for a message', () => {
|
||||
expect(readMessageFeedback('msg-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('round-trips a positive rating with the submittedAt timestamp', () => {
|
||||
writeMessageFeedback('msg-1', { rating: 'positive', submittedAt: 1700000000 });
|
||||
expect(readMessageFeedback('msg-1')).toEqual({
|
||||
rating: 'positive',
|
||||
submittedAt: 1700000000,
|
||||
comment: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips a negative rating with a free-text comment', () => {
|
||||
writeMessageFeedback('msg-2', {
|
||||
rating: 'negative',
|
||||
comment: 'preview opened the pointer file',
|
||||
submittedAt: 1700000005,
|
||||
});
|
||||
expect(readMessageFeedback('msg-2')).toEqual({
|
||||
rating: 'negative',
|
||||
comment: 'preview opened the pointer file',
|
||||
submittedAt: 1700000005,
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the entry when null is written', () => {
|
||||
writeMessageFeedback('msg-3', { rating: 'positive', submittedAt: 1 });
|
||||
expect(readMessageFeedback('msg-3')).not.toBeNull();
|
||||
writeMessageFeedback('msg-3', null);
|
||||
expect(readMessageFeedback('msg-3')).toBeNull();
|
||||
expect(window.localStorage.getItem(STORAGE_KEY('msg-3'))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null and does not throw when the stored value is corrupted JSON', () => {
|
||||
// A bad write from a parallel tab or a manual user edit should not
|
||||
// crash the chat pane; treating the entry as "not yet rated" is the
|
||||
// safe behaviour because the UI can offer a fresh rating instead
|
||||
// of showing a stale (wrong) confirmation.
|
||||
window.localStorage.setItem(STORAGE_KEY('msg-4'), 'not json');
|
||||
expect(readMessageFeedback('msg-4')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the stored object is missing the rating field', () => {
|
||||
window.localStorage.setItem(
|
||||
STORAGE_KEY('msg-5'),
|
||||
JSON.stringify({ submittedAt: 42 }),
|
||||
);
|
||||
expect(readMessageFeedback('msg-5')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the stored rating is an unknown value', () => {
|
||||
// Defends against a future schema that introduces, say, a `neutral`
|
||||
// rating: the older runtime must drop the entry rather than render
|
||||
// a degraded badge that does not match the dictionary.
|
||||
window.localStorage.setItem(
|
||||
STORAGE_KEY('msg-6'),
|
||||
JSON.stringify({ rating: 'neutral', submittedAt: 42 }),
|
||||
);
|
||||
expect(readMessageFeedback('msg-6')).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the messageId as the storage key so different messages do not collide', () => {
|
||||
writeMessageFeedback('msg-7-a', { rating: 'positive', submittedAt: 1 });
|
||||
writeMessageFeedback('msg-7-b', { rating: 'negative', submittedAt: 2 });
|
||||
expect(readMessageFeedback('msg-7-a')?.rating).toBe('positive');
|
||||
expect(readMessageFeedback('msg-7-b')?.rating).toBe('negative');
|
||||
});
|
||||
|
||||
it('broadcasts a CustomEvent carrying the new value on every successful write', () => {
|
||||
// Regression for the codex + lefarcen P2: a setItem failure used
|
||||
// to leave the broadcast in place but with no value to apply, so
|
||||
// listeners would re-read storage and get null. The new contract
|
||||
// always includes the value in `detail.value` so listeners can
|
||||
// apply it directly without trusting storage.
|
||||
const seen: unknown[] = [];
|
||||
const handler = (evt: Event) => seen.push((evt as CustomEvent).detail);
|
||||
window.addEventListener('open-design:message-feedback', handler);
|
||||
|
||||
writeMessageFeedback('msg-broadcast', { rating: 'positive', submittedAt: 7 });
|
||||
writeMessageFeedback('msg-broadcast', null);
|
||||
|
||||
window.removeEventListener('open-design:message-feedback', handler);
|
||||
expect(seen).toEqual([
|
||||
{ messageId: 'msg-broadcast', value: { rating: 'positive', submittedAt: 7 } },
|
||||
{ messageId: 'msg-broadcast', value: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('still broadcasts the new value when localStorage.setItem throws (private mode / quota)', () => {
|
||||
// The whole point of carrying the value in the event: writers in
|
||||
// private-mode browsers still keep the in-memory confirmation.
|
||||
const seen: unknown[] = [];
|
||||
const handler = (evt: Event) => seen.push((evt as CustomEvent).detail);
|
||||
window.addEventListener('open-design:message-feedback', handler);
|
||||
const setItemSpy = vi
|
||||
.spyOn(window.localStorage, 'setItem')
|
||||
.mockImplementation(() => {
|
||||
throw new Error('QuotaExceededError');
|
||||
});
|
||||
|
||||
writeMessageFeedback('msg-quota', { rating: 'positive', submittedAt: 9 });
|
||||
|
||||
window.removeEventListener('open-design:message-feedback', handler);
|
||||
setItemSpy.mockRestore();
|
||||
expect(seen).toEqual([
|
||||
{ messageId: 'msg-quota', value: { rating: 'positive', submittedAt: 9 } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
63
docs/notebooklm.md
Normal file
63
docs/notebooklm.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# NotebookLM: export issues/PRs from Open Design
|
||||
|
||||
Open Design gets a lot of feedback via GitHub Issues + PRs. If you want NotebookLM to help with:
|
||||
|
||||
- support answers (with citations)
|
||||
- clustering + taxonomy of user scenarios
|
||||
- backlog extraction
|
||||
- evaluation datasets / benchmark prompts
|
||||
|
||||
…start by exporting a repo snapshot into a single Markdown file and upload it as a source in NotebookLM.
|
||||
|
||||
## Export issues + PRs to Markdown
|
||||
|
||||
Prereqs:
|
||||
- `gh` (GitHub CLI) installed + authenticated
|
||||
- Node + pnpm (for `tsx`)
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/notebooklm-export-github.ts \
|
||||
--repo nexu-io/open-design \
|
||||
--issues open \
|
||||
--prs open \
|
||||
--limit 50
|
||||
```
|
||||
|
||||
By default, output goes to:
|
||||
|
||||
```
|
||||
notebooklm/<owner>__<repo>.md
|
||||
```
|
||||
|
||||
You can override the output path:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/notebooklm-export-github.ts \
|
||||
--repo nexu-io/open-design \
|
||||
--out notebooklm/open-design-snapshot.md
|
||||
```
|
||||
|
||||
### Flags
|
||||
|
||||
- `--repo owner/name` (required)
|
||||
- `--out <path>` (optional)
|
||||
- `--issues open|closed|all|none` (default: `open`)
|
||||
- `--prs open|closed|merged|all` (default: `open`)
|
||||
- `--limit <n>` (default: `50`) — **total item budget across issues + PRs**. If you select multiple states (e.g. `--issues all --prs all`), the exporter will stop once it has written `n` total items.
|
||||
|
||||
## Upload to NotebookLM
|
||||
|
||||
1) Open NotebookLM
|
||||
2) Create a new notebook
|
||||
3) Add a source → upload the generated `.md`
|
||||
4) Ask questions like:
|
||||
- “Summarize the top recurring user problems this week, with links.”
|
||||
- “Group issues into a taxonomy (installation, provider auth, UI bugs, exports).”
|
||||
- “Suggest 10 high-confidence ‘good first issues’ with rationale.”
|
||||
|
||||
## Notes
|
||||
|
||||
- The exporter truncates long bodies to keep the file manageable.
|
||||
- It’s intentionally read-only: it doesn’t change issues or PRs.
|
||||
218
docs/windows-troubleshooting.md
Normal file
218
docs/windows-troubleshooting.md
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
# Windows Troubleshooting Guide
|
||||
|
||||
Open Design runs on Windows natively, but the path is less travelled than macOS, Linux, or WSL2. This guide covers the most common errors you will hit on a fresh Windows machine and the exact fix for each.
|
||||
|
||||
> **Tip:** If you already have WSL2 set up, that is the smoothest path on Windows. This guide is for native Windows (PowerShell).
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Tool | Version | How to verify |
|
||||
|---|---|---|
|
||||
| Node.js | `~24` | `node -v` |
|
||||
| pnpm | `10.33.x` | `pnpm -v` |
|
||||
| Git | any recent | `git --version` |
|
||||
|
||||
---
|
||||
|
||||
## 1. Node 24 installation
|
||||
|
||||
### Symptom
|
||||
`node -v` returns something older than `v24.x.x`, or you do not have Node installed at all.
|
||||
|
||||
### Fix
|
||||
|
||||
**Option A — nvm-windows (recommended)**
|
||||
|
||||
1. Install [nvm-windows](https://github.com/coreybutler/nvm-windows/releases).
|
||||
2. In a fresh PowerShell window:
|
||||
|
||||
```powershell
|
||||
nvm install 24
|
||||
nvm use 24
|
||||
node -v # should print v24.x.x
|
||||
```
|
||||
|
||||
**Option B — Official installer**
|
||||
|
||||
Download and run the Node 24 `.msi` from [nodejs.org](https://nodejs.org/).
|
||||
|
||||
### Common nvm-windows gotcha
|
||||
|
||||
If running `nvm version` or `node -v` pops up a Windows dialog that asks *"How do you want to open this file?"*, a fake `nvm` file (no extension) has been created in `C:\Windows\System32`.
|
||||
|
||||
**Fix:** Delete that file, then restart PowerShell.
|
||||
|
||||
---
|
||||
|
||||
## 2. pnpm not found
|
||||
|
||||
### Symptom
|
||||
|
||||
```text
|
||||
pnpm : The term 'pnpm' is not recognized as the name of a cmdlet...
|
||||
```
|
||||
|
||||
### Fix (Corepack — recommended)
|
||||
|
||||
The repo pins `pnpm@10.33.2` in `packageManager`. Corepack selects that exact version automatically:
|
||||
|
||||
```powershell
|
||||
corepack enable
|
||||
corepack pnpm --version # should print 10.33.2
|
||||
```
|
||||
|
||||
> **Note:** If `corepack enable` fails with `EPERM` or `EACCES` (common when Node is installed under `C:\Program Files\nodejs`), use the npm-global fallback in the next section instead.
|
||||
|
||||
|
||||
|
||||
### Fix (npm global — alternative)
|
||||
|
||||
If Corepack is not available:
|
||||
|
||||
```powershell
|
||||
npm install -g pnpm@10.33.2
|
||||
pnpm -v # should print 10.33.2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Build scripts blocked
|
||||
|
||||
### Symptom
|
||||
|
||||
During `pnpm install` you see:
|
||||
|
||||
```text
|
||||
Ignored build scripts: better-sqlite3, ...
|
||||
```
|
||||
|
||||
Later, `pnpm tools-dev run web` fails with native-module errors.
|
||||
|
||||
### Fix
|
||||
|
||||
pnpm 10 blocks lifecycle scripts by default. Allow the packages that need native compilation:
|
||||
|
||||
```powershell
|
||||
pnpm approve-builds
|
||||
```
|
||||
|
||||
Approve any packages that appear in the list (commonly `better-sqlite3`, `electron`, and `esbuild`). Then re-run:
|
||||
|
||||
```powershell
|
||||
pnpm install
|
||||
```
|
||||
|
||||
> **Note:** `better-sqlite3` may fall back to compiling from source on Windows. If `pnpm install` hangs or fails on this package, make sure the Visual Studio Build Tools (step 4) are installed *before* running `pnpm install`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Visual Studio / `gyp` build errors
|
||||
|
||||
### Symptom
|
||||
|
||||
```text
|
||||
gyp ERR! find VS could not find Visual Studio
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```text
|
||||
error MSB8036: The Windows SDK version was not found
|
||||
```
|
||||
|
||||
### Fix
|
||||
|
||||
Install **Build Tools for Visual Studio 2022** with the following workloads:
|
||||
|
||||
- **Desktop development with C++**
|
||||
- **MSVC v143 - VS 2022 C++ x64/x86 build tools**
|
||||
- **Windows 11 SDK** (or Windows 10 SDK if you are on Windows 10)
|
||||
|
||||
Download: [https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022](https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022)
|
||||
|
||||
If you see `gyp ERR! find Python`, verify Python is installed:
|
||||
|
||||
```powershell
|
||||
python --version # or py --version
|
||||
```
|
||||
|
||||
If missing, install Python 3.x from [python.org](https://www.python.org/downloads/) and ensure it's on PATH.
|
||||
|
||||
After installing all build tools, open a **fresh** PowerShell window and re-run `pnpm install`.
|
||||
|
||||
---
|
||||
|
||||
## 5. PowerShell execution policy
|
||||
|
||||
### Symptom
|
||||
|
||||
```text
|
||||
cannot be loaded because running scripts is disabled on this system.
|
||||
```
|
||||
|
||||
### Fix
|
||||
|
||||
On fresh Windows installs, PowerShell blocks script execution by default:
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
```
|
||||
|
||||
Restart PowerShell after changing the policy.
|
||||
|
||||
---
|
||||
|
||||
## 6. Start the dev server
|
||||
|
||||
### Symptom
|
||||
You have completed the steps above but are not sure how to launch the app.
|
||||
|
||||
### Fix
|
||||
|
||||
From the repository root:
|
||||
|
||||
```powershell
|
||||
pnpm tools-dev run web
|
||||
```
|
||||
|
||||
Expected output ends with something like:
|
||||
|
||||
```text
|
||||
Open Design dev server ready
|
||||
- Local: http://localhost:17573
|
||||
```
|
||||
|
||||
The exact port may change; always read the terminal output.
|
||||
|
||||
---
|
||||
|
||||
## Quick diagnostic checklist
|
||||
|
||||
Run these commands in PowerShell before opening an issue. Include the output in your report.
|
||||
|
||||
```powershell
|
||||
node -v
|
||||
pnpm -v
|
||||
where.exe pnpm
|
||||
where.exe node
|
||||
where.exe opencode
|
||||
corepack --version
|
||||
python --version # or py --version
|
||||
Get-ExecutionPolicy -List
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Optional: OpenCode agent CLI on Windows
|
||||
|
||||
OpenCode is one of the local agent CLIs Open Design can drive. If you want to use it:
|
||||
|
||||
```powershell
|
||||
npm install -g opencode-ai
|
||||
where.exe opencode # should show C:\Users\YOUR_USERNAME\AppData\Roaming\npm\opencode.cmd
|
||||
opencode --version
|
||||
```
|
||||
|
||||
If Open Design still shows OpenCode as *not installed* in **Settings → Execution & model**, click **Rescan** after confirming the `opencode.cmd` directory is on your user `PATH`.
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
"scripts": {
|
||||
"test": "vitest run -c vitest.config.ts",
|
||||
"test:ui:critical": "playwright test -c playwright.config.ts ui/entry-chrome-flows.test.ts ui/entry-configuration-flows.test.ts ui/project-management-flows.test.ts",
|
||||
"test:ui:extended": "OD_PLAYWRIGHT_TIMEOUT=10000 playwright test -c playwright.config.ts ui/app.test.ts ui/api-empty-response.test.ts ui/app-restoration.test.ts ui/app-manual-edit.test.ts ui/app-design-files.test.ts ui/settings-connectors-auth-happy-path.test.ts ui/settings-connectors-auth-recovery.test.ts ui/settings-api-protocol.test.ts ui/settings-local-cli-codex-fallback.test.ts ui/workspace-keyboard-flows.test.ts ui/examples-preview-core.test.ts ui/examples-preview-share.test.ts ui/real-daemon-run.test.ts",
|
||||
"test:ui:extended": "OD_PLAYWRIGHT_TIMEOUT=10000 playwright test -c playwright.config.ts ui/app.test.ts ui/api-empty-response.test.ts ui/app-restoration.test.ts ui/app-manual-edit.test.ts ui/app-design-files.test.ts ui/settings-connectors-auth-happy-path.test.ts ui/settings-connectors-auth-recovery.test.ts ui/settings-api-protocol.test.ts ui/settings-local-cli-codex-fallback.test.ts ui/settings-memory-routines.test.ts ui/workspace-keyboard-flows.test.ts ui/examples-preview-core.test.ts ui/examples-preview-share.test.ts ui/real-daemon-run.test.ts",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -41,9 +41,8 @@ test.beforeEach(async ({ page }) => {
|
|||
test('pet pill toggle hides and shows the pet rail', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByTestId('new-project-panel')).toBeVisible();
|
||||
await expect(page.locator('.entry-brand')).toBeVisible();
|
||||
await expect(page.locator('.entry-brand .entry-brand-title')).toHaveText('Open Design');
|
||||
await expect(page.locator('.app-chrome-header')).toHaveCount(0);
|
||||
await expect(page.locator('.app-chrome-header')).toBeVisible();
|
||||
await expect(page.locator('.app-chrome-name')).toHaveText('Open Design');
|
||||
await expect(page.locator('.pet-rail')).toBeVisible();
|
||||
|
||||
const hideToggle = page.locator('.pet-pill-toggle');
|
||||
|
|
@ -82,18 +81,17 @@ test('entry chrome avoids horizontal overflow on compact desktop width', async (
|
|||
await page.setViewportSize({ width: 820, height: 900 });
|
||||
await page.goto('/');
|
||||
await expect(page.getByTestId('new-project-panel')).toBeVisible();
|
||||
await expect(page.locator('.entry-brand')).toBeVisible();
|
||||
await expect(page.locator('.app-chrome-header')).toBeVisible();
|
||||
|
||||
// The brand row replaced the old global chrome header; if it overflows
|
||||
// horizontally on a compact desktop, the logo/title/settings cog will
|
||||
// wrap or push the layout sideways. Keep it pinned to no-overflow.
|
||||
const brandOverflow = await page.evaluate(() => {
|
||||
const brand = document.querySelector('.entry-brand');
|
||||
if (!(brand instanceof HTMLElement)) return null;
|
||||
return Math.max(0, brand.scrollWidth - brand.clientWidth);
|
||||
// The restored global chrome header must remain compact enough that the
|
||||
// logo/title/settings cog do not wrap or push the entry layout sideways.
|
||||
const headerOverflow = await page.evaluate(() => {
|
||||
const header = document.querySelector('.app-chrome-header');
|
||||
if (!(header instanceof HTMLElement)) return null;
|
||||
return Math.max(0, header.scrollWidth - header.clientWidth);
|
||||
});
|
||||
expect(brandOverflow).not.toBeNull();
|
||||
expect(brandOverflow!).toBeLessThanOrEqual(2);
|
||||
expect(headerOverflow).not.toBeNull();
|
||||
expect(headerOverflow!).toBeLessThanOrEqual(2);
|
||||
|
||||
const pageOverflow = await page.evaluate(() =>
|
||||
Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
|
||||
|
|
|
|||
|
|
@ -196,10 +196,9 @@ test('connectors search supports empty results and keyboard-closeable details',
|
|||
|
||||
await page.goto('/');
|
||||
// Connector cards + search now live under Settings → Connectors. Open the
|
||||
// settings dialog via the entry sidebar's "Configure execution mode" pill
|
||||
// and switch to the Connectors section before exercising the
|
||||
// settings dialog and switch to the Connectors section before exercising the
|
||||
// search/empty/details flow.
|
||||
await page.getByRole('button', { name: 'Configure execution mode' }).click();
|
||||
await page.getByRole('button', { name: 'Open settings' }).click();
|
||||
const settingsDialog = page.getByRole('dialog');
|
||||
await expect(settingsDialog).toBeVisible();
|
||||
await settingsDialog.getByRole('button', { name: /^Connectors\b/ }).click();
|
||||
|
|
@ -254,7 +253,7 @@ test('saving a Composio key from Settings unlocks the connectors gate immediatel
|
|||
});
|
||||
|
||||
await gotoEntryHome(page);
|
||||
await page.getByRole('button', { name: 'Configure execution mode' }).click();
|
||||
await page.getByRole('button', { name: 'Open settings' }).click();
|
||||
const settingsDialog = page.getByRole('dialog');
|
||||
await expect(settingsDialog).toBeVisible();
|
||||
await settingsDialog.getByRole('button', { name: /^Connectors\b/ }).click();
|
||||
|
|
@ -316,7 +315,7 @@ test('typing a draft replacement Composio key does not trigger global autosave',
|
|||
});
|
||||
|
||||
await gotoEntryHome(page);
|
||||
await page.getByRole('button', { name: 'Configure execution mode' }).click();
|
||||
await page.getByRole('button', { name: 'Open settings' }).click();
|
||||
const settingsDialog = page.getByRole('dialog');
|
||||
await expect(settingsDialog).toBeVisible();
|
||||
await settingsDialog.getByRole('button', { name: /^Connectors\b/ }).click();
|
||||
|
|
|
|||
|
|
@ -94,6 +94,34 @@ async function openLocalCliSettings(
|
|||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/app-config', async (route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
config: {
|
||||
onboardingCompleted: true,
|
||||
agentId: typeof config.agentId === 'string' ? config.agentId : 'codex',
|
||||
agentCliEnv: config.agentCliEnv ?? {},
|
||||
agentModels: config.agentModels ?? {},
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
disabledSkills: [],
|
||||
disabledDesignSystems: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/agents', async (route) => {
|
||||
await route.fulfill({ json: { agents } });
|
||||
});
|
||||
|
|
|
|||
353
e2e/ui/settings-memory-routines.test.ts
Normal file
353
e2e/ui/settings-memory-routines.test.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
const STORAGE_KEY = 'open-design:config';
|
||||
|
||||
function baseConfig(): Record<string, unknown> {
|
||||
return {
|
||||
mode: 'daemon',
|
||||
apiKey: '',
|
||||
apiProtocol: 'openai',
|
||||
apiVersion: '',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
apiProviderBaseUrl: 'https://api.openai.com/v1',
|
||||
agentId: 'codex',
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
onboardingCompleted: true,
|
||||
mediaProviders: {},
|
||||
agentModels: {},
|
||||
agentCliEnv: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function seedSettingsBase(page: Page) {
|
||||
await page.addInitScript(({ key, value }) => {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
}, { key: STORAGE_KEY, value: baseConfig() });
|
||||
|
||||
await page.route('**/api/health', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: '{"ok":true}',
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/agents', async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
agents: [
|
||||
{
|
||||
id: 'codex',
|
||||
name: 'Codex CLI',
|
||||
bin: 'codex',
|
||||
available: true,
|
||||
version: '0.130.0',
|
||||
models: [{ id: 'default', label: 'Default' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openSettings(page: Page) {
|
||||
await page.goto('/');
|
||||
await page.getByTitle('Configure execution mode').click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function openMemorySettings(page: Page) {
|
||||
const dialog = await openSettings(page);
|
||||
await dialog.getByRole('button', { name: /^Memory\b/ }).click();
|
||||
await expect(dialog.getByText('MEMORY.md')).toBeVisible();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function openRoutinesSettings(page: Page) {
|
||||
const dialog = await openSettings(page);
|
||||
await dialog.getByRole('button', { name: /^Routines\b/ }).click();
|
||||
await expect(
|
||||
dialog.getByText('Scheduled, unattended agent sessions. Each run starts a new'),
|
||||
).toBeVisible();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
test.describe('Settings Memory and Routines flows', () => {
|
||||
test('creates a memory entry and keeps it visible after reopening settings', async ({ page }) => {
|
||||
await seedSettingsBase(page);
|
||||
|
||||
let enabled = true;
|
||||
let index = '# Memory\n';
|
||||
let entries: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
updatedAt: number;
|
||||
body?: string;
|
||||
}> = [];
|
||||
|
||||
await page.route('**/api/memory', async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
enabled,
|
||||
rootDir: '/tmp/memory',
|
||||
index,
|
||||
entries: entries.map(({ body, ...summary }) => summary),
|
||||
extraction: null,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (method === 'POST') {
|
||||
const payload = route.request().postDataJSON() as Record<string, string>;
|
||||
const entry = {
|
||||
id: 'user_ui_preferences',
|
||||
name: payload.name ?? '',
|
||||
description: payload.description ?? '',
|
||||
type: payload.type ?? 'user',
|
||||
body: payload.body ?? '',
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
entries = [entry];
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ entry }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 404, body: '{}' });
|
||||
});
|
||||
|
||||
await page.route('**/api/memory/extractions', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ extractions: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/memory/events', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/event-stream',
|
||||
body: '',
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/memory/config', async (route) => {
|
||||
const payload = route.request().postDataJSON() as { enabled?: boolean };
|
||||
if (typeof payload.enabled === 'boolean') enabled = payload.enabled;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ enabled, extraction: null }),
|
||||
});
|
||||
});
|
||||
|
||||
const dialog = await openMemorySettings(page);
|
||||
|
||||
await dialog.getByRole('button', { name: 'New memory' }).click();
|
||||
await dialog.getByPlaceholder('e.g. UI preferences').fill('UI preferences');
|
||||
await dialog.getByPlaceholder('One sentence — what is this memory about?').fill(
|
||||
'Persistent rendering preferences',
|
||||
);
|
||||
await dialog
|
||||
.getByPlaceholder(/- Rule one[\s\S]*When to apply: optional scope/)
|
||||
.fill('- Prefer dark mode');
|
||||
await dialog.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
await expect(dialog.getByText('UI preferences')).toBeVisible();
|
||||
await expect(dialog.locator('.memory-flash-pill')).toContainText('Memory created');
|
||||
|
||||
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
|
||||
const reopened = await openMemorySettings(page);
|
||||
await expect(reopened.getByText('UI preferences')).toBeVisible();
|
||||
await expect(reopened.getByText('Persistent rendering preferences')).toBeVisible();
|
||||
});
|
||||
|
||||
test('disables memory injection and keeps the disabled banner after reopening settings', async ({ page }) => {
|
||||
await seedSettingsBase(page);
|
||||
|
||||
let enabled = true;
|
||||
|
||||
await page.route('**/api/memory', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
enabled,
|
||||
rootDir: '/tmp/memory',
|
||||
index: '# Memory\n',
|
||||
entries: [],
|
||||
extraction: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/memory/extractions', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ extractions: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/memory/events', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/event-stream',
|
||||
body: '',
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/memory/config', async (route) => {
|
||||
const payload = route.request().postDataJSON() as { enabled?: boolean };
|
||||
if (typeof payload.enabled === 'boolean') enabled = payload.enabled;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ enabled, extraction: null }),
|
||||
});
|
||||
});
|
||||
|
||||
const dialog = await openMemorySettings(page);
|
||||
await dialog.getByLabel('Enable memory injection').uncheck();
|
||||
await expect(dialog.locator('.memory-disabled-banner')).toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
|
||||
const reopened = await openMemorySettings(page);
|
||||
await expect(reopened.locator('.memory-disabled-banner')).toBeVisible();
|
||||
});
|
||||
|
||||
test('creates a routine and loads its history after Run now', async ({ page }) => {
|
||||
await seedSettingsBase(page);
|
||||
|
||||
const projects = [{ id: 'proj-1', name: 'Routine Test Project' }];
|
||||
let routines: Array<Record<string, unknown>> = [];
|
||||
let runs: Array<Record<string, unknown>> = [];
|
||||
|
||||
await page.route('**/api/projects', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ projects }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/routines', async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === 'GET') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ routines }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (method === 'POST') {
|
||||
const payload = route.request().postDataJSON() as Record<string, unknown>;
|
||||
const routine = {
|
||||
id: 'routine-1',
|
||||
name: payload.name,
|
||||
prompt: payload.prompt,
|
||||
schedule: payload.schedule,
|
||||
target: payload.target,
|
||||
enabled: true,
|
||||
nextRunAt: Date.now() + 3600_000,
|
||||
lastRun: null,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
routines = [routine];
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ routine }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ status: 404, body: '{}' });
|
||||
});
|
||||
|
||||
await page.route('**/api/routines/routine-1/run', async (route) => {
|
||||
const startedAt = Date.now();
|
||||
const lastRun = {
|
||||
runId: 'run-1',
|
||||
status: 'queued',
|
||||
trigger: 'manual',
|
||||
startedAt,
|
||||
projectId: 'proj-run',
|
||||
conversationId: 'conv-run',
|
||||
agentRunId: 'agent-run-1',
|
||||
};
|
||||
routines = [{ ...routines[0], lastRun }];
|
||||
runs = [
|
||||
{
|
||||
id: 'run-1',
|
||||
routineId: 'routine-1',
|
||||
trigger: 'manual',
|
||||
status: 'queued',
|
||||
projectId: 'proj-run',
|
||||
conversationId: 'conv-run',
|
||||
agentRunId: 'agent-run-1',
|
||||
startedAt,
|
||||
completedAt: null,
|
||||
summary: null,
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
await route.fulfill({
|
||||
status: 202,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
routine: routines[0],
|
||||
run: lastRun,
|
||||
projectId: 'proj-run',
|
||||
conversationId: 'conv-run',
|
||||
agentRunId: 'agent-run-1',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/routines/routine-1/runs?limit=10', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ runs }),
|
||||
});
|
||||
});
|
||||
|
||||
const dialog = await openRoutinesSettings(page);
|
||||
|
||||
await dialog.getByRole('button', { name: 'New routine' }).click();
|
||||
await dialog.getByLabel('Name').fill('Weekly digest');
|
||||
await dialog.getByLabel('Prompt').fill('Summarize GitHub and design activity.');
|
||||
await dialog.getByRole('tab', { name: 'Weekly' }).click();
|
||||
await dialog.getByRole('button', { name: 'Wed' }).click();
|
||||
await dialog.getByText('Reuse an existing project', { exact: true }).click();
|
||||
await dialog.getByRole('combobox').nth(1).selectOption('proj-1');
|
||||
await dialog.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
await expect(dialog.getByText('Weekly digest')).toBeVisible();
|
||||
|
||||
const row = dialog.locator('.routines-item', { hasText: 'Weekly digest' }).first();
|
||||
await expect(row).toBeVisible();
|
||||
await row.getByRole('button', { name: 'Run now' }).click();
|
||||
await expect(row.getByRole('button', { name: 'Hide history' })).toBeVisible();
|
||||
await expect(dialog.getByText('manual')).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: 'Open project' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -42,6 +42,28 @@ export interface ChatRunCreateRequest extends ChatRequest {
|
|||
|
||||
export type ChatRunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled';
|
||||
|
||||
export type ChatMessageFeedbackRating = 'positive' | 'negative';
|
||||
|
||||
export type ChatMessageFeedbackReasonCode =
|
||||
| 'matched_request'
|
||||
| 'strong_visual'
|
||||
| 'useful_structure'
|
||||
| 'easy_to_continue'
|
||||
| 'missed_request'
|
||||
| 'weak_visual'
|
||||
| 'incomplete_output'
|
||||
| 'hard_to_use'
|
||||
| 'other';
|
||||
|
||||
export interface ChatMessageFeedback {
|
||||
rating: ChatMessageFeedbackRating;
|
||||
reasonCodes?: ChatMessageFeedbackReasonCode[];
|
||||
customReason?: string;
|
||||
reasonsSubmittedAt?: number;
|
||||
createdAt: number;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export interface ChatRunCreateResponse {
|
||||
runId: string;
|
||||
}
|
||||
|
|
@ -134,6 +156,7 @@ export interface ChatMessage {
|
|||
attachments?: ChatAttachment[];
|
||||
commentAttachments?: ChatCommentAttachment[];
|
||||
producedFiles?: ProjectFile[];
|
||||
feedback?: ChatMessageFeedback;
|
||||
/**
|
||||
* Request-only marker for the final assistant-message persistence pass.
|
||||
* The daemon does not store or return this field; it only uses it to
|
||||
|
|
|
|||
|
|
@ -21,9 +21,14 @@ declare const URL: {
|
|||
};
|
||||
|
||||
function normalizeBracketedIpv6(hostname: string): string {
|
||||
return hostname.startsWith('[') && hostname.endsWith(']')
|
||||
? hostname.slice(1, -1).toLowerCase()
|
||||
: hostname.toLowerCase();
|
||||
const stripped = hostname.startsWith('[') && hostname.endsWith(']')
|
||||
? hostname.slice(1, -1)
|
||||
: hostname;
|
||||
// FQDN trailing-dot form (RFC 1034) resolves identically to the dotless form,
|
||||
// so `localhost.` must normalize to `localhost` before the equality check in
|
||||
// isLoopbackApiHost — and `0.0.0.0.`, `10.0.0.1.`, etc. must normalize before
|
||||
// isBlockedIpv4 parses them. Strips one or more trailing dots.
|
||||
return stripped.toLowerCase().replace(/\.+$/, '');
|
||||
}
|
||||
|
||||
function parseIpv4(hostname: string): [number, number, number, number] | null {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { validateBaseUrl } from '../src/api/connectionTest';
|
||||
import {
|
||||
isLoopbackApiHost,
|
||||
validateBaseUrl,
|
||||
} from '../src/api/connectionTest';
|
||||
|
||||
describe('provider base URL validation', () => {
|
||||
it('allows public endpoints and loopback local providers', () => {
|
||||
|
|
@ -14,6 +17,15 @@ describe('provider base URL validation', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('identifies trailing-dot FQDN forms of loopback hosts as loopback', () => {
|
||||
// Direct assertion against isLoopbackApiHost — validateBaseUrl alone
|
||||
// can't distinguish "passed because loopback" from "passed because
|
||||
// not blocked", which the previous test revision conflated.
|
||||
for (const host of ['localhost.', '127.0.0.1.', '127.0.0.5.']) {
|
||||
expect(isLoopbackApiHost(host)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks private, link-local, CGNAT, multicast, and mapped forms', () => {
|
||||
for (const baseUrl of [
|
||||
'http://0.0.0.0:11434/v1',
|
||||
|
|
@ -34,4 +46,24 @@ describe('provider base URL validation', () => {
|
|||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks trailing-dot FQDN bypass across every blocked IPv4 range', () => {
|
||||
// The trailing-dot strip in normalizeBracketedIpv6 must apply to
|
||||
// every range isBlockedIpv4 covers — not just the three originally
|
||||
// demonstrated. One representative case per range:
|
||||
for (const baseUrl of [
|
||||
'http://0.0.0.0.:11434/v1', // 0.0.0.0/8
|
||||
'http://10.0.0.5.:11434/v1', // 10/8
|
||||
'http://100.64.0.1.:11434/v1', // 100.64/10 CGNAT
|
||||
'http://169.254.169.254./latest/meta-data', // 169.254/16 metadata
|
||||
'http://172.16.0.5.:11434/v1', // 172.16/12
|
||||
'http://192.168.1.5.:11434/v1', // 192.168/16
|
||||
'http://224.0.0.1.:11434/v1', // multicast >=224
|
||||
]) {
|
||||
expect(validateBaseUrl(baseUrl)).toMatchObject({
|
||||
error: 'Internal IPs blocked',
|
||||
forbidden: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -524,6 +524,10 @@ export function wellKnownUserToolchainBins(
|
|||
root: join(home, ".local", "share", "fnm", "node-versions"),
|
||||
segments: ["installation", "bin"],
|
||||
},
|
||||
{
|
||||
root: join(home, ".fnm", "node-versions"),
|
||||
segments: ["installation", "bin"],
|
||||
},
|
||||
]) {
|
||||
for (const dir of existingChildBinDirs(installRoot.root, installRoot.segments)) {
|
||||
dirs.push(dir);
|
||||
|
|
@ -540,10 +544,38 @@ function existingChildBinDirs(root: string, segments: string[]): string[] {
|
|||
} catch {
|
||||
return out;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
for (const entry of sortVersionedDirEntries(entries)) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const candidate = join(root, entry.name, ...segments);
|
||||
if (existsSync(candidate)) out.push(candidate);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type SemverParts = [major: number, minor: number, patch: number];
|
||||
|
||||
function sortVersionedDirEntries(entries: import("node:fs").Dirent<string>[]): import("node:fs").Dirent<string>[] {
|
||||
return [...entries].sort((left, right) => compareVersionLikeDirNames(left.name, right.name));
|
||||
}
|
||||
|
||||
function compareVersionLikeDirNames(left: string, right: string): number {
|
||||
const leftSemver = parseVersionLikeDirName(left);
|
||||
const rightSemver = parseVersionLikeDirName(right);
|
||||
if (leftSemver && rightSemver) {
|
||||
for (let index = 0; index < leftSemver.length; index += 1) {
|
||||
const difference = rightSemver[index] - leftSemver[index];
|
||||
if (difference !== 0) return difference;
|
||||
}
|
||||
} else if (leftSemver) {
|
||||
return -1;
|
||||
} else if (rightSemver) {
|
||||
return 1;
|
||||
}
|
||||
return left.localeCompare(right);
|
||||
}
|
||||
|
||||
function parseVersionLikeDirName(name: string): SemverParts | null {
|
||||
const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(name);
|
||||
if (!match) return null;
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -502,26 +502,42 @@ describe("wellKnownUserToolchainBins", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("expands per-version Node toolchains for mise / nvm / fnm", () => {
|
||||
it("surfaces GUI-safe PATH additions and sorts versioned Node bins by highest semver first", () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "wkutb-versioned-"));
|
||||
try {
|
||||
const miseBin = join(home, ".local", "share", "mise", "installs", "node", "24.14.1", "bin");
|
||||
const nvmBin = join(home, ".nvm", "versions", "node", "v22.10.0", "bin");
|
||||
const newestNvmBin = join(home, ".nvm", "versions", "node", "v24.1.0", "bin");
|
||||
const olderNvmBin = join(home, ".nvm", "versions", "node", "v22.10.0", "bin");
|
||||
const fnmBin = join(home, ".local", "share", "fnm", "node-versions", "v20.11.1", "installation", "bin");
|
||||
mkdirSync(miseBin, { recursive: true });
|
||||
mkdirSync(nvmBin, { recursive: true });
|
||||
mkdirSync(newestNvmBin, { recursive: true });
|
||||
mkdirSync(olderNvmBin, { recursive: true });
|
||||
mkdirSync(fnmBin, { recursive: true });
|
||||
writeFileSync(join(miseBin, "marker"), "");
|
||||
writeFileSync(join(nvmBin, "marker"), "");
|
||||
writeFileSync(join(newestNvmBin, "marker"), "");
|
||||
writeFileSync(join(olderNvmBin, "marker"), "");
|
||||
writeFileSync(join(fnmBin, "marker"), "");
|
||||
chmodSync(join(miseBin, "marker"), 0o644);
|
||||
chmodSync(join(nvmBin, "marker"), 0o644);
|
||||
chmodSync(join(newestNvmBin, "marker"), 0o644);
|
||||
chmodSync(join(olderNvmBin, "marker"), 0o644);
|
||||
chmodSync(join(fnmBin, "marker"), 0o644);
|
||||
|
||||
const dirs = wellKnownUserToolchainBins({ home, env: {}, includeSystemBins: false });
|
||||
const dirs = wellKnownUserToolchainBins({
|
||||
home,
|
||||
env: { PATH: "/usr/bin:/bin:/usr/sbin:/sbin" },
|
||||
includeSystemBins: true,
|
||||
});
|
||||
const newestNvmIdx = dirs.indexOf(newestNvmBin);
|
||||
const olderNvmIdx = dirs.indexOf(olderNvmBin);
|
||||
|
||||
expect(dirs).toContain("/opt/homebrew/bin");
|
||||
expect(dirs).toContain("/usr/local/bin");
|
||||
expect(dirs).toContain(miseBin);
|
||||
expect(dirs).toContain(nvmBin);
|
||||
expect(dirs).toContain(newestNvmBin);
|
||||
expect(dirs).toContain(olderNvmBin);
|
||||
expect(dirs).toContain(fnmBin);
|
||||
expect(newestNvmIdx).toBeGreaterThan(-1);
|
||||
expect(olderNvmIdx).toBeGreaterThan(newestNvmIdx);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
|
|
|
|||
384
scripts/notebooklm-export-github.ts
Normal file
384
scripts/notebooklm-export-github.ts
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Export GitHub Issues + PRs for a repo into one Markdown file, suitable for uploading to NotebookLM.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx scripts/notebooklm-export-github.ts --repo owner/name [--out path] [--issues open|closed|all] [--prs open|closed|all] [--limit 50]
|
||||
*/
|
||||
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
type GhLabel = { name?: string };
|
||||
type GhUser = { login?: string };
|
||||
|
||||
type GhItem = {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
labels?: GhLabel[];
|
||||
author?: GhUser;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
body?: string;
|
||||
};
|
||||
|
||||
type IssueStateFlag = "open" | "closed";
|
||||
type PrStateFlag = "open" | "closed" | "merged";
|
||||
|
||||
type IssueMode = "open" | "closed" | "all" | "none";
|
||||
type PrMode = "open" | "closed" | "merged" | "all";
|
||||
|
||||
function parseArgs(argv: string[]) {
|
||||
const args: Record<string, string | boolean> = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (typeof a !== "string" || !a.startsWith("--")) continue;
|
||||
const key = a.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (typeof next !== "string" || next.startsWith("--")) {
|
||||
args[key] = true;
|
||||
} else {
|
||||
args[key] = next;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function fail(msg: string): never {
|
||||
process.stderr.write(`${msg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function mustString(v: unknown, name: string): string {
|
||||
if (typeof v === "string" && v.trim()) return v.trim();
|
||||
fail(`Missing required flag --${name}`);
|
||||
}
|
||||
|
||||
function asIssueMode(v: unknown, dflt: IssueMode): IssueMode {
|
||||
if (typeof v !== "string") return dflt;
|
||||
const s = v.trim();
|
||||
if (s === "open" || s === "closed" || s === "all" || s === "none") return s;
|
||||
fail(`Invalid value '${s}' (expected open|closed|all|none)`);
|
||||
}
|
||||
|
||||
function asPrMode(v: unknown, dflt: PrMode): PrMode {
|
||||
if (typeof v !== "string") return dflt;
|
||||
const s = v.trim();
|
||||
if (s === "open" || s === "closed" || s === "merged" || s === "all") return s;
|
||||
fail(`Invalid value '${s}' (expected open|closed|merged|all)`);
|
||||
}
|
||||
|
||||
function asLimit(v: unknown, dflt: number): number {
|
||||
if (typeof v !== "string") return dflt;
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n) || n <= 0) fail(`Invalid --limit '${v}'`);
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
function ensureGh() {
|
||||
try {
|
||||
execFileSync("gh", ["--version"], { stdio: "ignore" });
|
||||
} catch {
|
||||
fail("gh CLI not found. Install GitHub CLI: https://cli.github.com/");
|
||||
}
|
||||
}
|
||||
|
||||
function runGhIssueJson(repo: string, state: IssueStateFlag, limit: number): GhItem[] {
|
||||
const baseArgs = ["issue", "list", "-R", repo, "--limit", String(limit), "--state", state];
|
||||
const jsonFields = [
|
||||
"number",
|
||||
"title",
|
||||
"url",
|
||||
"labels",
|
||||
"author",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"body"
|
||||
];
|
||||
|
||||
const result = spawnSync("gh", [...baseArgs, "--json", jsonFields.join(",")], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
});
|
||||
|
||||
if (result.status === 0) {
|
||||
const parsed = JSON.parse(result.stdout ?? "null") as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed as GhItem[];
|
||||
}
|
||||
|
||||
const stderr = String(result.stderr ?? result.error?.message ?? "");
|
||||
// Some repos disable issues (e.g. github/.github). Treat that as an empty bucket
|
||||
// so PR-only exports can continue quietly.
|
||||
if (/disabled issues/i.test(stderr) || /has disabled issues/i.test(stderr)) return [];
|
||||
|
||||
const err = new Error(`gh issue list failed for ${repo} (${state})`);
|
||||
(err as Error & { stderr?: string; stdout?: string; cause?: unknown }).stderr = stderr;
|
||||
(err as Error & { stderr?: string; stdout?: string; cause?: unknown }).stdout = String(result.stdout ?? "");
|
||||
(err as Error & { stderr?: string; stdout?: string; cause?: unknown }).cause = result.error ?? undefined;
|
||||
throw err;
|
||||
}
|
||||
|
||||
function runGhPrJson(repo: string, state: PrStateFlag, limit: number): GhItem[] {
|
||||
const baseArgs = ["pr", "list", "-R", repo, "--limit", String(limit), "--state", state];
|
||||
const jsonFields = [
|
||||
"number",
|
||||
"title",
|
||||
"url",
|
||||
"labels",
|
||||
"author",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"body"
|
||||
];
|
||||
|
||||
const out = execFileSync("gh", [...baseArgs, "--json", jsonFields.join(",")], {
|
||||
encoding: "utf8"
|
||||
});
|
||||
const parsed = JSON.parse(out) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed as GhItem[];
|
||||
}
|
||||
|
||||
function getIssueStates(mode: IssueMode): IssueStateFlag[] {
|
||||
if (mode === "none") return [];
|
||||
if (mode === "all") return ["open", "closed"];
|
||||
return [mode];
|
||||
}
|
||||
|
||||
function getPrStates(mode: PrMode): PrStateFlag[] {
|
||||
if (mode === "all") return ["open", "closed", "merged"];
|
||||
return [mode];
|
||||
}
|
||||
|
||||
function safeLabelList(labels?: GhLabel[]): string {
|
||||
const names = (labels ?? [])
|
||||
.map((l) => (typeof l?.name === "string" ? l.name.trim() : ""))
|
||||
.filter(Boolean);
|
||||
return names.length ? names.join(", ") : "-";
|
||||
}
|
||||
|
||||
function safeLogin(u?: GhUser): string {
|
||||
const login = typeof u?.login === "string" ? u.login.trim() : "";
|
||||
return login || "-";
|
||||
}
|
||||
|
||||
function clipBody(body?: string, maxChars = 3000): string {
|
||||
const s = typeof body === "string" ? body : "";
|
||||
if (s.length <= maxChars) return s;
|
||||
return s.slice(0, maxChars).trimEnd() + "\n\n…(truncated)";
|
||||
}
|
||||
|
||||
function slugOutPath(repo: string): string {
|
||||
const [owner, name] = repo.split("/");
|
||||
return path.join("notebooklm", `${owner}__${name}.md`);
|
||||
}
|
||||
|
||||
function mdEscape(s: string): string {
|
||||
// For headings, keep it simple: trim and avoid CRs.
|
||||
return s.replace(/\r/g, "").trim();
|
||||
}
|
||||
|
||||
function escapeMdLinkText(s: string): string {
|
||||
// Escape characters that can break Markdown link text, especially `[` and `]`.
|
||||
// We also escape backslashes first to avoid double-escaping.
|
||||
return s.replace(/\\/g, "\\\\").replace(/\[/g, "\\[").replace(/\]/g, "\\]");
|
||||
}
|
||||
|
||||
function makeToc(items: { anchor: string; title: string }[]): string {
|
||||
return items
|
||||
.map((i) => `- [${escapeMdLinkText(i.title)}](#${i.anchor})`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function anchorFor(prefix: string, n: number, title: string): string {
|
||||
// GitHub-ish anchor; good enough for NotebookLM/markdown viewers.
|
||||
const base = `${prefix}-${n}-${title}`
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.slice(0, 80)
|
||||
.replace(/^-|-$/g, "");
|
||||
return base || `${prefix}-${n}`;
|
||||
}
|
||||
|
||||
function renderItems(section: "Issue" | "PR", state: string, items: GhItem[]): string {
|
||||
const lines: string[] = [];
|
||||
for (const it of items) {
|
||||
const t = mdEscape(it.title ?? "(no title)");
|
||||
const anchor = anchorFor(section.toLowerCase(), it.number, t);
|
||||
// Explicit anchor so the generated TOC links work in common Markdown renderers.
|
||||
// (Relying on renderer-specific heading slug rules is fragile.)
|
||||
lines.push(`<a id="${anchor}"></a>`);
|
||||
lines.push(`### #${it.number} ${t}`);
|
||||
lines.push("");
|
||||
lines.push(`- Link: ${it.url}`);
|
||||
lines.push(`- Type: ${section}`);
|
||||
lines.push(`- State: ${state}`);
|
||||
lines.push(`- Author: ${safeLogin(it.author)}`);
|
||||
lines.push(`- Labels: ${safeLabelList(it.labels)}`);
|
||||
lines.push(`- Created: ${it.createdAt ?? "-"}`);
|
||||
lines.push(`- Updated: ${it.updatedAt ?? "-"}`);
|
||||
lines.push("");
|
||||
const body = clipBody(it.body);
|
||||
if (body.trim()) {
|
||||
lines.push("Body:");
|
||||
lines.push("");
|
||||
lines.push(body);
|
||||
} else {
|
||||
lines.push("Body: (empty)");
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("---");
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const repo = mustString(args.repo, "repo");
|
||||
const issuesMode = asIssueMode(args.issues, "open");
|
||||
const prsMode = asPrMode(args.prs, "open");
|
||||
const limit = asLimit(args.limit, 50);
|
||||
|
||||
const outPath = typeof args.out === "string" ? args.out : slugOutPath(repo);
|
||||
const absOut = path.isAbsolute(outPath)
|
||||
? outPath
|
||||
: path.join(process.cwd(), outPath);
|
||||
|
||||
ensureGh();
|
||||
|
||||
const generatedAt = new Date().toISOString();
|
||||
|
||||
const issueStates = getIssueStates(issuesMode);
|
||||
const prStates = getPrStates(prsMode);
|
||||
|
||||
// `--limit` is a TOTAL budget across all exported items (issues + PRs),
|
||||
// even when multiple states are selected (e.g. `--issues all --prs all`).
|
||||
//
|
||||
// To avoid starving later selections (e.g. `--prs merged` yielding zero PRs
|
||||
// because issues consumed the whole budget), we fetch small batches for each
|
||||
// selected bucket and then interleave them round-robin up to the total limit.
|
||||
//
|
||||
// NOTE: We intentionally over-fetch up to `limit` from each selected bucket.
|
||||
// This avoids under-filling the snapshot when some buckets are empty/small.
|
||||
// (Example: issues disabled but `--issues all` was requested.)
|
||||
const perBucketFetch = limit;
|
||||
|
||||
const issuesByState: Record<IssueStateFlag, GhItem[]> = { open: [], closed: [] };
|
||||
for (const st of issueStates) {
|
||||
issuesByState[st] = runGhIssueJson(repo, st, perBucketFetch);
|
||||
}
|
||||
|
||||
const prsByState: Record<PrStateFlag, GhItem[]> = { open: [], closed: [], merged: [] };
|
||||
for (const st of prStates) {
|
||||
prsByState[st] = runGhPrJson(repo, st, perBucketFetch);
|
||||
}
|
||||
|
||||
// Round-robin selection across requested buckets.
|
||||
type Bucket = { kind: "issue" | "pr"; state: string; items: GhItem[]; idx: number };
|
||||
const buckets: Bucket[] = [];
|
||||
for (const st of issueStates) buckets.push({ kind: "issue", state: st, items: issuesByState[st], idx: 0 });
|
||||
for (const st of prStates) buckets.push({ kind: "pr", state: st, items: prsByState[st], idx: 0 });
|
||||
|
||||
const selectedIssuesByState: Record<IssueStateFlag, GhItem[]> = { open: [], closed: [] };
|
||||
const selectedPrsByState: Record<PrStateFlag, GhItem[]> = { open: [], closed: [], merged: [] };
|
||||
|
||||
let picked = 0;
|
||||
while (picked < limit) {
|
||||
let advanced = false;
|
||||
for (const b of buckets) {
|
||||
if (picked >= limit) break;
|
||||
const it = b.items[b.idx];
|
||||
if (!it) continue;
|
||||
b.idx++;
|
||||
advanced = true;
|
||||
picked++;
|
||||
if (b.kind === "issue") {
|
||||
if (b.state === "open") selectedIssuesByState.open.push(it);
|
||||
else selectedIssuesByState.closed.push(it);
|
||||
} else {
|
||||
if (b.state === "open") selectedPrsByState.open.push(it);
|
||||
else if (b.state === "closed") selectedPrsByState.closed.push(it);
|
||||
else selectedPrsByState.merged.push(it);
|
||||
}
|
||||
}
|
||||
if (!advanced) break; // no more items anywhere
|
||||
}
|
||||
|
||||
// Replace exported-by-state maps with the budgeted selections.
|
||||
for (const st of issueStates) issuesByState[st] = selectedIssuesByState[st];
|
||||
for (const st of prStates) prsByState[st] = selectedPrsByState[st];
|
||||
|
||||
// Build TOC anchors.
|
||||
const tocIssues: { anchor: string; title: string }[] = [];
|
||||
for (const st of issueStates) {
|
||||
for (const it of issuesByState[st]) {
|
||||
const t = mdEscape(it.title ?? "(no title)");
|
||||
tocIssues.push({
|
||||
anchor: anchorFor("issue", it.number, t),
|
||||
title: `Issue #${it.number}: ${t}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const tocPrs: { anchor: string; title: string }[] = [];
|
||||
for (const st of prStates) {
|
||||
for (const it of prsByState[st]) {
|
||||
const t = mdEscape(it.title ?? "(no title)");
|
||||
tocPrs.push({
|
||||
anchor: anchorFor("pr", it.number, t),
|
||||
title: `PR #${it.number}: ${t}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const md: string[] = [];
|
||||
md.push(`repo: ${repo}`);
|
||||
md.push(`generatedAt: ${generatedAt}`);
|
||||
md.push(`issues: ${issuesMode}`);
|
||||
md.push(`prs: ${prsMode}`);
|
||||
md.push(`limit: ${limit}`);
|
||||
md.push("");
|
||||
md.push("# NotebookLM Export: GitHub Issues + PRs");
|
||||
md.push("");
|
||||
md.push("## Table of Contents");
|
||||
md.push("");
|
||||
md.push("### Issues");
|
||||
md.push("");
|
||||
md.push(tocIssues.length ? makeToc(tocIssues) : "- (none)");
|
||||
md.push("");
|
||||
md.push("### PRs");
|
||||
md.push("");
|
||||
md.push(tocPrs.length ? makeToc(tocPrs) : "- (none)");
|
||||
md.push("");
|
||||
md.push("---");
|
||||
md.push("");
|
||||
md.push("## Issues");
|
||||
md.push("");
|
||||
for (const st of issueStates) {
|
||||
md.push(`### State: ${st}`);
|
||||
md.push("");
|
||||
md.push(renderItems("Issue", st, issuesByState[st]));
|
||||
}
|
||||
|
||||
md.push("## PRs");
|
||||
md.push("");
|
||||
for (const st of prStates) {
|
||||
md.push(`### State: ${st}`);
|
||||
md.push("");
|
||||
md.push(renderItems("PR", st, prsByState[st]));
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(absOut), { recursive: true });
|
||||
fs.writeFileSync(absOut, md.join("\n"), "utf8");
|
||||
|
||||
process.stdout.write(`Wrote ${absOut}\n`);
|
||||
}
|
||||
|
||||
main();
|
||||
113
skills/faq-page/SKILL.md
Normal file
113
skills/faq-page/SKILL.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
---
|
||||
name: faq-page
|
||||
description: |
|
||||
A Frequently Asked Questions (FAQ) page with collapsible accordion sections,
|
||||
search functionality, and category filtering. Use when the brief asks for
|
||||
"FAQ", "help center", "questions", or "support page".
|
||||
triggers:
|
||||
- "faq"
|
||||
- "FAQ"
|
||||
- "frequently asked questions"
|
||||
- "help center"
|
||||
- "support page"
|
||||
- "Q&A"
|
||||
- "常见问题"
|
||||
- "帮助中心"
|
||||
od:
|
||||
mode: prototype
|
||||
platform: desktop
|
||||
scenario: support
|
||||
featured: 8
|
||||
preview:
|
||||
type: html
|
||||
entry: index.html
|
||||
design_system:
|
||||
requires: true
|
||||
sections: [color, typography, layout, components]
|
||||
craft:
|
||||
requires: [typography, accessibility-baseline, state-coverage]
|
||||
---
|
||||
|
||||
# FAQ Page Skill
|
||||
|
||||
Produce a single FAQ page with collapsible accordion sections, search, and category filtering.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Read the active DESIGN.md** (injected above). Use the component tokens for
|
||||
interactive elements (accordion headers, search input, category pills).
|
||||
2. **Pick the domain** from the brief (e.g., SaaS product, e-commerce, service)
|
||||
and write 12–18 real FAQ entries across 3–4 categories.
|
||||
- **Edge cases**:
|
||||
- If the brief provides fewer than 8 FAQs, ask for more content or generate
|
||||
realistic questions based on the domain.
|
||||
- For 1–5 FAQs, skip categories and search; show a simple list.
|
||||
- For very long answers (>100 words), break into paragraphs or bullet points
|
||||
to maintain readability.
|
||||
3. **Sections**, in order:
|
||||
- **Header** — page title ("Frequently Asked Questions" or "Help Center"),
|
||||
optional subtitle (1 sentence explaining what users can find here).
|
||||
- **Search bar** — prominent search input with placeholder text and icon.
|
||||
Functional JS to filter questions in real-time.
|
||||
- **Category filters** — 3–4 pill-style buttons to filter by category
|
||||
(e.g., "Billing", "Account", "Technical", "General"). "All" selected by default.
|
||||
- **FAQ accordion** — collapsible question/answer pairs:
|
||||
- Question as clickable header with expand/collapse icon (chevron or plus/minus).
|
||||
- Answer hidden by default, expands on click with smooth animation.
|
||||
- Each entry has `data-category` attribute for filtering.
|
||||
- **Footer CTA** — "Still have questions?" section with contact link or
|
||||
support email.
|
||||
4. **Write** a single HTML document:
|
||||
- `<!doctype html>` through `</html>`, CSS and JS inline.
|
||||
- Accordion uses semantic HTML (`<details>` and `<summary>` for progressive
|
||||
enhancement, or custom JS with proper ARIA attributes).
|
||||
- Search filters questions by matching text in question or answer.
|
||||
- Category filters show/hide questions based on `data-category`.
|
||||
- Smooth transitions for expand/collapse (max-height or grid-template-rows).
|
||||
- `data-od-id` on header, search, categories, accordion container, footer.
|
||||
5. **Self-check**:
|
||||
- Questions are specific and realistic (not generic placeholders).
|
||||
- Answers are concise (2–4 sentences) but complete.
|
||||
- Keyboard navigation works (Tab through questions, Enter to expand).
|
||||
- Search is case-insensitive and filters by matching text.
|
||||
- Only one accordion item expanded at a time (optional, depends on UX preference).
|
||||
- Mobile-friendly (accordion headers are tappable, search is usable).
|
||||
|
||||
## Output contract
|
||||
|
||||
Emit between `<artifact>` tags:
|
||||
|
||||
```
|
||||
<artifact identifier="faq-page" type="text/html" title="FAQ Page">
|
||||
<!doctype html>
|
||||
<html>...</html>
|
||||
</artifact>
|
||||
```
|
||||
|
||||
One sentence before the artifact, nothing after.
|
||||
|
||||
## Example questions by category
|
||||
|
||||
**Billing**
|
||||
- How do I update my payment method?
|
||||
- What payment methods do you accept?
|
||||
- Can I get a refund?
|
||||
- How do I cancel my subscription?
|
||||
|
||||
**Account**
|
||||
- How do I reset my password?
|
||||
- Can I change my email address?
|
||||
- How do I delete my account?
|
||||
- What happens to my data after I cancel?
|
||||
|
||||
**Technical**
|
||||
- What browsers do you support?
|
||||
- Is there a mobile app?
|
||||
- How do I export my data?
|
||||
- What are your API rate limits?
|
||||
|
||||
**General**
|
||||
- What is [Product Name]?
|
||||
- How do I get started?
|
||||
- Do you offer customer support?
|
||||
- Where can I find your terms of service?
|
||||
517
skills/faq-page/example.html
Normal file
517
skills/faq-page/example.html
Normal file
|
|
@ -0,0 +1,517 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FAQ - Help Center</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1a1a1a;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 60px 24px;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
color: #0a0a0a;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.125rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 16px 48px 16px 20px;
|
||||
font-size: 1rem;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 12px;
|
||||
background: white;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #0066ff;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.categories {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 40px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.category-btn {
|
||||
padding: 10px 20px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 24px;
|
||||
background: white;
|
||||
color: #666;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.category-btn:hover {
|
||||
border-color: #0066ff;
|
||||
color: #0066ff;
|
||||
}
|
||||
|
||||
.category-btn.active {
|
||||
background: #0066ff;
|
||||
border-color: #0066ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.faq-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.faq-item {
|
||||
background: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.faq-item:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.faq-item.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.faq-question {
|
||||
width: 100%;
|
||||
padding: 20px 24px;
|
||||
text-align: left;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
color: #0a0a0a;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.faq-question:hover {
|
||||
color: #0066ff;
|
||||
}
|
||||
|
||||
.faq-icon {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.faq-item.open .faq-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.faq-answer {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease-out;
|
||||
}
|
||||
|
||||
.faq-answer-content {
|
||||
padding: 0 24px 20px;
|
||||
color: #555;
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.faq-item.open .faq-answer {
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.footer-cta {
|
||||
margin-top: 64px;
|
||||
padding: 32px;
|
||||
background: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-cta h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 12px;
|
||||
color: #0a0a0a;
|
||||
}
|
||||
|
||||
.footer-cta p {
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.contact-link {
|
||||
display: inline-block;
|
||||
padding: 12px 32px;
|
||||
background: #0066ff;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.contact-link:hover {
|
||||
background: #0052cc;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: #999;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-results.show {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header data-od-id="header">
|
||||
<h1>Frequently Asked Questions</h1>
|
||||
<p class="subtitle">Find answers to common questions about our product and services</p>
|
||||
</header>
|
||||
|
||||
<div class="search-box" data-od-id="search">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
id="searchInput"
|
||||
placeholder="Search for answers..."
|
||||
aria-label="Search FAQ"
|
||||
>
|
||||
<svg class="search-icon" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M9 17A8 8 0 1 0 9 1a8 8 0 0 0 0 16zM18 18l-4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="categories" data-od-id="categories">
|
||||
<button class="category-btn active" data-category="all">All</button>
|
||||
<button class="category-btn" data-category="billing">Billing</button>
|
||||
<button class="category-btn" data-category="account">Account</button>
|
||||
<button class="category-btn" data-category="technical">Technical</button>
|
||||
<button class="category-btn" data-category="general">General</button>
|
||||
</div>
|
||||
|
||||
<div class="faq-list" data-od-id="faq-list">
|
||||
<!-- Billing -->
|
||||
<div class="faq-item" data-category="billing">
|
||||
<button class="faq-question" aria-expanded="false">
|
||||
<span>How do I update my payment method?</span>
|
||||
<svg class="faq-icon" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M5 7.5L10 12.5L15 7.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="faq-answer">
|
||||
<div class="faq-answer-content">
|
||||
You can update your payment method in your account settings under the Billing section. Click on "Payment Methods", then "Add New Card" or "Edit" next to your existing card. Changes take effect immediately for future billing cycles.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item" data-category="billing">
|
||||
<button class="faq-question" aria-expanded="false">
|
||||
<span>What payment methods do you accept?</span>
|
||||
<svg class="faq-icon" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M5 7.5L10 12.5L15 7.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="faq-answer">
|
||||
<div class="faq-answer-content">
|
||||
We accept all major credit cards (Visa, Mastercard, American Express, Discover), PayPal, and bank transfers for annual plans. All payments are processed securely through Stripe.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item" data-category="billing">
|
||||
<button class="faq-question" aria-expanded="false">
|
||||
<span>Can I get a refund?</span>
|
||||
<svg class="faq-icon" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M5 7.5L10 12.5L15 7.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="faq-answer">
|
||||
<div class="faq-answer-content">
|
||||
We offer a 30-day money-back guarantee for all new subscriptions. If you're not satisfied, contact our support team within 30 days of your initial purchase for a full refund. Refunds are processed within 5-7 business days.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account -->
|
||||
<div class="faq-item" data-category="account">
|
||||
<button class="faq-question" aria-expanded="false">
|
||||
<span>How do I reset my password?</span>
|
||||
<svg class="faq-icon" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M5 7.5L10 12.5L15 7.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="faq-answer">
|
||||
<div class="faq-answer-content">
|
||||
Click "Forgot Password" on the login page and enter your email address. You'll receive a password reset link within a few minutes. The link expires after 24 hours for security reasons.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item" data-category="account">
|
||||
<button class="faq-question" aria-expanded="false">
|
||||
<span>Can I change my email address?</span>
|
||||
<svg class="faq-icon" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M5 7.5L10 12.5L15 7.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="faq-answer">
|
||||
<div class="faq-answer-content">
|
||||
Yes, go to Account Settings and click "Change Email". You'll need to verify your new email address before the change takes effect. Your login credentials will be updated automatically.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item" data-category="account">
|
||||
<button class="faq-question" aria-expanded="false">
|
||||
<span>How do I delete my account?</span>
|
||||
<svg class="faq-icon" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M5 7.5L10 12.5L15 7.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="faq-answer">
|
||||
<div class="faq-answer-content">
|
||||
To delete your account, go to Account Settings and scroll to the bottom. Click "Delete Account" and confirm your decision. This action is permanent and cannot be undone. All your data will be permanently deleted within 30 days.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Technical -->
|
||||
<div class="faq-item" data-category="technical">
|
||||
<button class="faq-question" aria-expanded="false">
|
||||
<span>What browsers do you support?</span>
|
||||
<svg class="faq-icon" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M5 7.5L10 12.5L15 7.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="faq-answer">
|
||||
<div class="faq-answer-content">
|
||||
We support the latest versions of Chrome, Firefox, Safari, and Edge. For the best experience, we recommend keeping your browser up to date. Internet Explorer is not supported.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item" data-category="technical">
|
||||
<button class="faq-question" aria-expanded="false">
|
||||
<span>Is there a mobile app?</span>
|
||||
<svg class="faq-icon" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M5 7.5L10 12.5L15 7.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="faq-answer">
|
||||
<div class="faq-answer-content">
|
||||
Yes, we have native apps for both iOS and Android. Download them from the App Store or Google Play. The mobile apps sync seamlessly with your web account.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item" data-category="technical">
|
||||
<button class="faq-question" aria-expanded="false">
|
||||
<span>How do I export my data?</span>
|
||||
<svg class="faq-icon" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M5 7.5L10 12.5L15 7.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="faq-answer">
|
||||
<div class="faq-answer-content">
|
||||
Go to Settings > Data & Privacy > Export Data. Choose your preferred format (CSV, JSON, or PDF) and click "Request Export". You'll receive a download link via email within 24 hours.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- General -->
|
||||
<div class="faq-item" data-category="general">
|
||||
<button class="faq-question" aria-expanded="false">
|
||||
<span>What is your product?</span>
|
||||
<svg class="faq-icon" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M5 7.5L10 12.5L15 7.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="faq-answer">
|
||||
<div class="faq-answer-content">
|
||||
We're a comprehensive platform that helps teams collaborate more effectively. Our tools include project management, real-time communication, file sharing, and analytics—all in one place.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item" data-category="general">
|
||||
<button class="faq-question" aria-expanded="false">
|
||||
<span>How do I get started?</span>
|
||||
<svg class="faq-icon" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M5 7.5L10 12.5L15 7.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="faq-answer">
|
||||
<div class="faq-answer-content">
|
||||
Sign up for a free account, complete the onboarding tutorial, and invite your team members. Our interactive guide will walk you through the key features. You can also schedule a demo with our team.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-item" data-category="general">
|
||||
<button class="faq-question" aria-expanded="false">
|
||||
<span>Do you offer customer support?</span>
|
||||
<svg class="faq-icon" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M5 7.5L10 12.5L15 7.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="faq-answer">
|
||||
<div class="faq-answer-content">
|
||||
Yes! We offer 24/7 email support for all users, live chat for paid plans, and phone support for enterprise customers. Average response time is under 2 hours.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="no-results" id="noResults">
|
||||
<p>No questions found matching your search.</p>
|
||||
</div>
|
||||
|
||||
<div class="footer-cta" data-od-id="footer-cta">
|
||||
<h2>Still have questions?</h2>
|
||||
<p>Can't find what you're looking for? Our support team is here to help.</p>
|
||||
<a href="mailto:support@example.com" class="contact-link">Contact Support</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Accordion functionality
|
||||
const faqItems = document.querySelectorAll('.faq-item');
|
||||
|
||||
faqItems.forEach(item => {
|
||||
const question = item.querySelector('.faq-question');
|
||||
|
||||
question.addEventListener('click', () => {
|
||||
const isOpen = item.classList.contains('open');
|
||||
|
||||
// Close all items
|
||||
faqItems.forEach(i => {
|
||||
i.classList.remove('open');
|
||||
i.querySelector('.faq-question').setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
// Open clicked item if it was closed
|
||||
if (!isOpen) {
|
||||
item.classList.add('open');
|
||||
question.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Search functionality
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const noResults = document.getElementById('noResults');
|
||||
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
const searchTerm = e.target.value.toLowerCase();
|
||||
let visibleCount = 0;
|
||||
|
||||
faqItems.forEach(item => {
|
||||
const question = item.querySelector('.faq-question span').textContent.toLowerCase();
|
||||
const answer = item.querySelector('.faq-answer-content').textContent.toLowerCase();
|
||||
const matchesSearch = question.includes(searchTerm) || answer.includes(searchTerm);
|
||||
const matchesCategory = item.dataset.category === currentCategory || currentCategory === 'all';
|
||||
|
||||
if (matchesSearch && matchesCategory) {
|
||||
item.classList.remove('hidden');
|
||||
visibleCount++;
|
||||
} else {
|
||||
item.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
noResults.classList.toggle('show', visibleCount === 0);
|
||||
});
|
||||
|
||||
// Category filtering
|
||||
const categoryBtns = document.querySelectorAll('.category-btn');
|
||||
let currentCategory = 'all';
|
||||
|
||||
categoryBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
currentCategory = btn.dataset.category;
|
||||
|
||||
// Update active state
|
||||
categoryBtns.forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
|
||||
// Filter items
|
||||
const searchTerm = searchInput.value.toLowerCase();
|
||||
let visibleCount = 0;
|
||||
|
||||
faqItems.forEach(item => {
|
||||
const question = item.querySelector('.faq-question span').textContent.toLowerCase();
|
||||
const answer = item.querySelector('.faq-answer-content').textContent.toLowerCase();
|
||||
const matchesSearch = !searchTerm || question.includes(searchTerm) || answer.includes(searchTerm);
|
||||
const matchesCategory = currentCategory === 'all' || item.dataset.category === currentCategory;
|
||||
|
||||
if (matchesSearch && matchesCategory) {
|
||||
item.classList.remove('hidden');
|
||||
visibleCount++;
|
||||
} else {
|
||||
item.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
noResults.classList.toggle('show', visibleCount === 0);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in a new issue