Commit graph

30 commits

Author SHA1 Message Date
BayesWang
af4a62b69a
Add configurable project locations (#2041)
* add daemon project location support

* wire project locations into web settings

* localize project location settings

* move default project location to settings

* polish project location selection cards

* fix project location i18n gaps

* fix external project validation cleanup
2026-05-31 04:47:45 +00:00
吴杨帆
6155ad8cbe
fix(web): surface Claude Design zip import failures (#1862) (#3047)
Show a toast when the daemon rejects a ZIP import instead of silently
closing the file picker with no feedback.
2026-05-27 06:24:38 +00:00
lefarcen
c14baf07d3 Merge origin/main into release/v0.8.0
PR #2461 sync prep — resolves 14 conflicts merging 84 main-side commits
on top of 58 release-side commits accumulated during the 0.8.0 cycle.

Resolution summary:

Take main (theirs) where main carried deliberate forward progress:
- apps/web/src/components/PluginCard.tsx — 7 hunks, i18n migration:
  hardcoded English aria-labels/titles replaced with t() calls keyed
  on pluginCard.* (all 8 keys verified present in en.ts).
- apps/web/src/components/TasksView.tsx — 1 hunk, source-ingestion
  feature: sortedRoutines (newest-first), sourceIngestionTemplates,
  patchSourceForm, submitSourceIngestion. activeCount/pausedCount
  semantics preserved (now keyed on sortedRoutines, count unchanged).
- e2e/ui/app.test.ts — new node:fs/promises + tmpdir + path + @/timeouts
  imports needed by main-side test helpers.
- e2e/ui/settings-local-cli-codex-fallback.test.ts — menu-dismissal
  helper block added by main.

Keep both sides where each added a different field to the same object
literal:
- apps/web/src/components/ProjectView.tsx (locale + analyticsHints
  spread).
- apps/web/src/components/DesignSystemFlow.tsx (locale + analyticsHints).

Take release (ours) where release carried deliberate work that ships
0.8.0:
- CHANGELOG.md — release-side 0.8.0 entry + PR link refs; main's
  Unreleased section was the same body of work, now finalized.
- apps/landing-page/public/{apple-touch-icon,favicon}.png +
  apps/web/public/app-icon.svg — release-side visual refresh assets
  consistent with 0.8.0 stable ship.
- tools/pack/src/linux.ts — packageVersion const required by line 466;
  taking main's empty line would build-error.
- e2e/ui/project-management-flows.test.ts +
  e2e/ui/settings-api-protocol.test.ts +
  e2e/ui/settings-memory-routines.test.ts — release-side release-smoke
  hardening (shangxinyu1 + PerishFire) takes precedence on overlap.

Closes-issue / unblocks: PR #2461 sync release/v0.8.0 → main.
2026-05-23 12:17:18 +08:00
Devayan Dewri
1b908a8481
fix(daemon): restore full assistant turn after mid-flight reload reattach (#2383)
* fix(daemon): restore full assistant turn after mid-flight reload reattach

When a daemon run is in progress and the browser reloads, the client
reattaches and the artifact recovers, but the restored chat turn drops
assistant text, thinking events, and producedFiles. Three independent
defects combine to cause this:

1. The reattach onDone never populated producedFiles. The pre-turn file
   snapshot used as the diff baseline lived only in a closure. Now it is
   persisted on the assistant message as preTurnFileNames so the reattach
   path can rebuild the diff after reload.

2. The SSE replay used a strict `>` cursor compare. A client that had
   already persisted lastRunEventId equal to the final event id received
   zero replay events on terminal-run reattach, fell into the status-only
   REST fallback, and never fired a clean onDone. The server now replays
   the final buffered event on terminal-run reattach when the cursor is
   at or past the end, so the client always sees a terminal signal.

3. The text buffer flushed on visibilitychange but not on pagehide.
   Hard reloads on browsers where visibilitychange does not fire before
   teardown could lose the last ~250ms of streamed text from the
   persisted message. A pagehide listener now flushes synchronously.

Refactor: extracted computeProducedFiles helper so the send and reattach
flows share the diff logic and cannot drift apart again.

Tests:
- apps/web/tests/components/ProjectView.reattach-restore.test.tsx
  covers: reattach onDone populates producedFiles from preTurnFileNames;
  reattach reaches succeeded via SSE even when only the end event replays;
  computeProducedFiles unit cases.
- apps/daemon/tests/runs.test.ts adds replay-cursor coverage for both
  the terminal-replay safety branch and the no-duplicate normal branch.

* fix(daemon): persist preTurnFileNames end-to-end on the messages table

Review on #2383 caught that `ChatMessage.preTurnFileNames` (added in
packages/contracts) had no daemon-side persistence: the messages
schema, upsertMessage, and normalizeMessage all ignored the field.
saveMessage() would PUT the field, the daemon would silently drop it,
and a real page reload would read a row without `preTurnFileNames`, so
the reattach onDone fell back to `new Set(nextFiles.map(...))` and
still missed files produced earlier in the turn.

This commit closes the round trip:

- New `pre_turn_file_names_json TEXT` column on the messages table,
  with a forward-compatible ALTER for existing databases (same pattern
  as agent_id / feedback_json / run_status).
- Both upsertMessage branches (UPDATE and INSERT) now serialize
  m.preTurnFileNames into the new column.
- listMessages, the post-upsert readback SELECT, and normalizeMessage
  surface the column back to callers.

Round-trip tests in apps/daemon/tests/db-pre-turn-file-names.test.ts
cover: write+listMessages, the UPDATE upsert path preserving the
baseline, and a legacy-row case returning undefined.

* fix(web): preserve terminal status + full multi-file diff on reattach

Two correctness issues caught in review of the prior reattach commits:

1. The reattach onDone path hard-coded `runStatus: 'succeeded'`, which
   overwrote a 'failed' or 'canceled' status that the replayed terminal
   event had already recorded via onRunStatus. Restored messages would
   come back as success even when the run had actually failed or been
   canceled. Now derives the final status from `prev.runStatus` via the
   existing `resolveSucceededRunStatus` helper, mirroring the send path
   at line 2333.

2. When `findExistingArtifactProjectFile()` recovered an existing
   on-disk artifact, the produced-files list was replaced with that
   single file, dropping any other files the turn had created earlier.
   Now always computes the full diff against `preTurnFileNames`, then
   appends the recovered artifact only if it isn't already in that
   set. Extracted as `mergeRecoveredArtifact(diff, recovered)` so the
   logic is a unit-testable invariant.

Tests in ProjectView.reattach-restore.test.tsx:
- mergeRecoveredArtifact: three cases (recovered appended to pre-files,
  no duplication when already in the diff, passthrough on no recovery).
- reattach failed-status: onRunStatus('failed') → onDone → final
  saveMessage has runStatus 'failed', not 'succeeded'.
- reattach canceled-status: same shape for cancellation.

* fix(web): force keepalive PUT on pagehide so the last buffered chunk survives reload

Review on #2383 caught that onPageHide() only called flush(), which
updates React state then schedules persistSoon() — a 500ms debounce.
On a hard reload the page tears down before that timer fires, so the
final ~250ms of streamed text never reaches the daemon.

Threaded a new flushAndPersistNow() callback through
createBufferedTextUpdates(). Both buffer call sites (send-path +
reattach-path) supply it backed by persistMessageById(id, { keepalive:
true }). saveMessage in state/projects.ts forwards the new
SaveMessageOptions.keepalive flag onto fetch's keepalive option, which
the browser honors specifically for unload-time requests.

onPageHide now calls flush() followed by flushAndPersistNow?.(), so:
- flush() pushes the buffered delta into React state synchronously
- the immediate persistMessageById then PUTs the updated message with
  keepalive:true, surviving document teardown

Regression test in ProjectView.reattach-restore.test.tsx: stream a
delta, dispatch pagehide, assert saveMessage was called with the
flushed content AND { keepalive: true } before the 500ms debounce
would otherwise have fired.
2026-05-22 18:47:12 +08:00
shangxinyu1
10e2019c59
Fix plugin publish and Open Design PR workflow UX (#2564)
* Fix plugin publish and PR workflow UX

* Update plugin workflow test expectations

* Fix fake gh repo view verification path

* Fix plugin publish headless tests and preserve PATH in shell wrappers.

The publish-repo flow needs real git commits and fake gh auth output that
matches gh auth status parsing. Login shells no longer drop PATH so test
fakes and agent wrappers stay visible to nested gh/git calls.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Restore plugin action card when share-task startup fails.

If startGeneratedPluginShareTask rejects before a task is created, clear
hiddenAssistantPluginActionPaths so the assistant action card reappears.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Make daemon vitest self-contained for publish-github CLI shell-outs.

Build dist/cli.js in tests/setup.ts when missing and set OD_DAEMON_CLI_PATH
before server.ts resolves OD_BIN, so headless plugin tests pass from a clean
checkout without a prior manual daemon build.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 19:21:17 +08:00
Siri-Ray
b236b37b7d
Remove resume conversation button (#2562) 2026-05-21 17:55:03 +08:00
Bryan
c530d163f8
feat(web): "Resume conversation in new chat" UI — #462 Commit B (companion to #1718) (#2264)
* feat(contracts): add handoff request/response DTOs

Adds HandoffRequest, HandoffResponse, and HANDOFF_SCHEMA_VERSION for
the upcoming POST /api/projects/:id/handoff synthesis endpoint. Mirrors
the finalize.ts subpath pattern (package.json#exports + esbuild entry +
index re-export) so daemon and web can import
@open-design/contracts/api/handoff.

Refs nexu-io/open-design#462.

* feat(daemon): add handoff synthesis pipeline (buildHandoffPrompt + synthesizeHandoffPrompt)

Adds `apps/daemon/src/handoff-design.ts` exposing the resume-conversation
synthesis primitives the upcoming `POST /api/projects/:id/handoff` route will
call into.

- `buildHandoffPrompt({ projectId, transcriptJsonl, transcriptMessageCount,
  now })` returns the system + user prompts. System prompt asks Claude to
  emit a structured Markdown body with Context / Decisions made / Open
  questions / Current focus / Provenance, with Provenance bullets explicitly
  flat (no Markdown emphasis on labels) to preempt the PR #1584 round-2
  parser bug.
- `synthesizeHandoffPrompt(db, projectsRoot, projectId, options)` reuses the
  existing finalize-design pipeline pieces: `exportProjectTranscript` →
  `truncateTranscriptForPrompt` → `buildHandoffPrompt` →
  `callAnthropicWithRetry` → `extractDesignMd`, but without the lockfile,
  disk write, design-system, or artifact-resolution paths.
- Promotes `DEFAULT_TIMEOUT_MS` in finalize-design.ts to `export const` so
  handoff shares the same 120s upstream-call bound.

Refs nexu-io/open-design#462.

* feat(daemon): wire POST /api/projects/:id/handoff route

Adds the handoff HTTP route and registers it in server.ts. Validation
block + error-mapping shape mirror registerFinalizeRoutes (BYOK payload,
upstream-error → ApiErrorCode mapping, redactSecrets on the raw upstream
body). Handoff has no lockfile, so the CONFLICT branch is omitted.

`res.on('close')` is wired to flip an AbortController whose signal is
threaded into synthesizeHandoffPrompt, so a UI-side cancel actually
aborts the daemon-side Anthropic call rather than letting it keep
running after the client walks away (mirrors the PR #974 fix for
finalize).

- `apps/daemon/src/handoff-routes.ts` — new, exports registerHandoffRoutes
  + RegisterHandoffRoutesDeps.
- `apps/daemon/src/server-context.ts` — adds handoff slot to ServerContext.
- `apps/daemon/src/route-context-contract.ts` — adds RegisterHandoffRoutesDeps
  to the compile-time coverage assertion.
- `apps/daemon/src/server.ts` — imports synthesizeHandoffPrompt +
  registerHandoffRoutes, builds handoffDeps, registers the route next
  to finalize.
- `apps/daemon/tests/handoff-route.test.ts` — 12 HTTP-layer tests:
  validation (400/403/404), happy path, upstream error mapping
  (401/429/502/502 non-JSON), api-key redaction.
- `apps/daemon/tests/handoff-route-abort.test.ts` — client-disconnect
  aborts the daemon-side controller.

Refs nexu-io/open-design#462.

* fix(daemon): map TranscriptExportLockedError to 409 CONFLICT on handoff route

`exportProjectTranscript` acquires a per-project `.transcript.lock`
internally (apps/daemon/src/transcript-export.ts:131-163) and throws
`TranscriptExportLockedError` on EEXIST. Concurrent handoff requests —
or a handoff that races `/api/projects/:id/finalize/anthropic` — lost
that lock and surfaced as 500 INTERNAL_ERROR through the route's
generic catch.

- `apps/daemon/src/handoff-routes.ts` — catch `TranscriptExportLockedError`
  and return `409 CONFLICT` ahead of the generic 500 branch, mirroring
  the existing `FinalizePackageLockedError → 409 CONFLICT` mapping at
  `apps/daemon/src/import-export-routes.ts:603-605`.
- `apps/daemon/src/server.ts` — thread `TranscriptExportLockedError`
  through `handoffDeps` so the route can match without a direct import.
- `apps/daemon/src/handoff-design.ts` — correct the module header
  comment that incorrectly claimed "no lockfile (concurrent handoff
  calls are safe)" — handoff does not add its own lock, but it does
  transitively acquire `.transcript.lock` via the transcript-export
  call.
- `apps/daemon/tests/handoff-route.test.ts` — regression test that
  pre-acquires `.transcript.lock` on disk via `fs.openSync(lockPath, 'wx')`
  before firing a handoff request, asserts 409 CONFLICT.

Refs nexu-io/open-design#462 — addresses @nettee's blocking review on
PR #1718 (comment 3242251338).

* fix(daemon): keep handoff request timeout armed through the response body read

`synthesizeHandoffPrompt` cleared the upstream-call timeout in a `finally`
that ran as soon as `callAnthropicWithRetry` returned. But `fetch()`
resolves once the upstream sends *headers* — so the subsequent
`await response.json()` body read ran with no timeout. A response that
sends headers and then stalls its body could hang `/api/projects/:id/handoff`
indefinitely instead of failing.

- `apps/daemon/src/handoff-design.ts` — move `clearTimeout(timeoutId)` into a
  single outer `finally` spanning both the call and the `response.json()`
  body parse, so the timeout stays armed until the body is fully consumed.
- `apps/daemon/src/handoff-design.ts` — the body-parse catch now re-throws
  `AbortError` as-is, mirroring the call-phase catch. Without this a
  body-phase timeout would surface as `502` "non-JSON body"; re-throwing
  lets the route map it to the intended `503` "handoff timed out"
  (`handoff-routes.ts:122-124`).
- `apps/daemon/tests/handoff-design.test.ts` — regression test: a `fetchImpl`
  returning a `Response` whose body never closes after headers, raced
  against a 500ms deadline, asserts the call aborts (not hangs) and rejects
  with `AbortError`.

Refs nexu-io/open-design#462 — addresses @nettee's round-2 blocking review
on PR #1718 (`handoff-design.ts:196`).

* fix(daemon): map upstream 400 to 400 BAD_REQUEST on handoff route

`callAnthropicWithRetry` preserves a non-retryable upstream status, so an
Anthropic HTTP 400 (`invalid_request_error` — unknown model, invalid
maxTokens, malformed body) reached the route's `FinalizeUpstreamError`
branch and fell through to `502 UPSTREAM_UNAVAILABLE`. That reported
deterministic caller input as a transient server outage, inviting
pointless retries and hiding which field was wrong.

- `apps/daemon/src/handoff-routes.ts` — special-case `err.status === 400`
  to `400 BAD_REQUEST` with the redacted upstream detail, ahead of the
  generic 502. Also refresh the route docblock: it claimed the 409 branch
  was omitted (stale since the R1 TranscriptExportLockedError fix) and
  that error mapping fully mirrors finalize (now diverges on 400).
- `apps/daemon/tests/handoff-route.test.ts` — route test driving an
  Anthropic `400 invalid_request_error`: asserts 400 BAD_REQUEST, the
  upstream detail is surfaced, and an echoed key is redacted.
- `packages/contracts/tests/package-runtime.test.ts` — import
  `@open-design/contracts/api/handoff` through the package `exports` map
  and assert `HANDOFF_SCHEMA_VERSION`, covering the built publish surface
  (esbuild entry + exports map + root re-export) that the source-only
  `handoff-contract.test.ts` does not exercise.

Refs nexu-io/open-design#462 — addresses @nettee's round-3 blocking
review on PR #1718.

* fix(daemon): await the now-async external base-URL validator on handoff route

Main's #1176 (`9a64fccd`) made `validateExternalApiBaseUrl` DNS-aware and
asynchronous (`validateBaseUrlResolved`) and updated the proxy and finalize
callers to `await` it. The handoff route — added on this branch in parallel,
against the old synchronous validator — still called it without `await`, so
`validated` was a Promise: `validated.error` / `validated.forbidden` were
`undefined`, the SSRF / malformed-URL guard silently no-opped, and a bad
`baseUrl` fell through to the upstream call and surfaced as 502.

A semantic merge break — no textual conflict, green on the branch in
isolation, red once CI re-merged latest main.

- `apps/daemon/src/handoff-routes.ts` — `await validateExternalApiBaseUrl(...)`,
  mirroring the finalize route (`import-export-routes.ts:561`). The handler
  is already `async`.

The existing `handoff-route.test.ts` cases "400 BAD_REQUEST when baseUrl is
not a valid URL" and "403 FORBIDDEN when baseUrl points at a private internal
IP" already encode this — red against branch + latest main, green now.

Refs nexu-io/open-design#462 — PR #1718 CI fix.

* chore(daemon): list handoff in the assertServerContextSatisfiesRoutes literal

The `assertServerContextSatisfiesRoutes({...})` call in `server.ts` enumerates
every route registrar's deps but omitted `handoff`. Adding `handoff: handoffDeps`
makes the literal complete and consistent with the other route deps.

This was not a typecheck break: route-dep coverage is guaranteed by the
`Assert<ServerContext extends AllRegisteredRouteDeps>` type in
`route-context-contract.ts` — and `AllRegisteredRouteDeps` already includes
`RegisterHandoffRoutesDeps` — not by this assertion-call literal. The literal
has omitted `handoff` since this branch's first push (`806db576`) through green
CI throughout; `tsc -p tsconfig.json --noEmit` is clean before and after.

Refs nexu-io/open-design#462 — addresses @nettee's round-4 review note on PR #1718.

* feat(web): add "Resume conversation in new chat" action (#462)

Adds a Resume control to the chat header, next to "New conversation".
Clicking it synthesizes a handoff prompt from the current transcript
via POST /api/projects/:id/handoff, opens a fresh conversation, and
auto-sends the synthesized prompt as its first user message — so a
drifted session resumes without the user replaying context by hand.
The old conversation is preserved.

- synthesizeHandoff() web-state wrapper in apps/web/src/state/projects.ts
- resume-conversation icon button in ChatPane (onResumeConversation /
  resumeConversationDisabled props)
- handleResumeConversation + pendingResumeRef + auto-send effect in
  ProjectView; effect gates on messagesConversationId so the prompt
  cannot fire before the new conversation's message read settles
- chat.resumeConversation i18n key across all 19 locales

Commit B of #462; Commit A is the daemon endpoint (PR #1718). This
branch is stacked on feat/handoff-endpoint so the web code resolves
@open-design/contracts/api/handoff.

* fix(daemon): scope handoff to one conversation + reject empty transcripts (#462)

Addresses the review on #1718 and #2264:

- mrcfps (#2264): the handoff endpoint exported the whole project's
  transcript, so a multi-conversation project blended unrelated chats
  into the synthesized prompt. HandoffRequest now carries a required
  conversationId; the route validates it belongs to the project
  (404 CONVERSATION_NOT_FOUND), and exportProjectTranscript takes an
  optional conversationId filter so only that conversation is exported.
- nettee (#1718): a zero-message conversation still called Anthropic and
  fabricated a handoff. synthesizeHandoffPrompt now throws
  EmptyTranscriptError on messageCount === 0; the route maps it to
  400 EMPTY_TRANSCRIPT before any BYOK tokens are spent.

HANDOFF_SCHEMA_VERSION bumped to 2 (conversationId is a new required
request field). Regression tests: a two-conversation scoping test, an
empty-conversation route + pipeline test, and a transcript-export
conversationId-filter unit test.

* feat(web): send conversationId with the resume handoff request (#462)

Follows the handoff endpoint becoming conversation-scoped. The resume
flow now passes the active conversationId to POST /handoff so the
synthesized prompt summarizes only the conversation being resumed.
handleResumeConversation bails when there is no active conversation;
synthesizeHandoff and the resume tests carry the new field.

* feat(daemon): add `od project handoff` CLI + register handoff error codes (#462)

Addresses the second-round review on #1718 and #2264:

- mrcfps (#2264): per AGENTS.md "Capability exposure (UI/CLI dual-track)",
  a user-facing capability must be reachable through the `od` CLI, not
  only the web UI. Adds `od project handoff <id> --conversation <id>
  --api-key <key> --model <model> [--base-url] [--max-tokens] [--json]`,
  driving the same POST /api/projects/:id/handoff endpoint. The logic
  lives in a testable handoff-cli.ts sibling module (mirrors
  artifacts-cli.ts) so cli.ts's import-time dispatch stays out of tests.
- nettee (#1718): the route emitted CONVERSATION_NOT_FOUND and
  EMPTY_TRANSCRIPT, which were absent from the shared API_ERROR_CODES
  union. Both are now registered in packages/contracts/src/errors.ts,
  with a contract test pinning them so the route and contract cannot
  drift again.

A CLI contract test covers the conversation-scoped request shape,
--json output, flag validation, and daemon-error surfacing.

* fix(daemon): fail `od project handoff` on a malformed 2xx response (#462)

Addresses nettee's review on #1718: runProjectHandoff treated any 2xx
response as success, so a broken daemon/proxy 200 with malformed or
shape-invalid JSON would print `undefined` (or `{}` under --json) and
still exit 0 — breaking the fail-fast contract scripts rely on. It now
validates the body is a well-formed HandoffResponse via an
isHandoffResponse type guard and fails fast otherwise. Regression tests
cover a shape-invalid and an unparseable 200 body.

* feat(web): surface the daemon's classified handoff error in the resume toast (#462)

Addresses mrcfps's non-blocking note on #2264: synthesizeHandoff returned
null for every non-2xx response, so RATE_LIMITED, EMPTY_TRANSCRIPT, and an
upstream 400 with provider detail all collapsed into one generic "check
your API key" toast — even though handoff-routes.ts had already classified
and sanitized them.

synthesizeHandoff now returns the daemon's structured `{ error }` on a
classified failure; `null` stays reserved for a transport failure or an
unparseable body. handleResumeConversation surfaces error.message plus
redacted details for the `{ error }` case, and a distinct
daemon-unreachable message for null.

* fix(web): omit empty baseUrl from the resume handoff request (#462)

Addresses mrcfps's review on #2264: the default Anthropic config
normalizes baseUrl to '' (config.ts), and the handoff route 400s an
explicit empty baseUrl — so the Resume action failed before synthesis
for every user who never set a custom base URL.

handleResumeConversation now forwards baseUrl only when config.baseUrl
is a non-empty string, matching the contract's optional-field semantics.
Tests: the default-config path asserts baseUrl is absent from the
request, and a new case covers a custom baseUrl being forwarded.

* refactor(daemon): dispatch `od project handoff` before the generic project parser (#462)

Addresses nettee's non-blocking note on #1718: runProject ran the shared
parseFlags(PROJECT_*) before reaching the handoff switch case, so a
malformed `od project handoff` invocation (`--unknown`, `--max-tokens`
with no value) threw out of the generic parser instead of hitting
handoff-cli's structured fail() — the entrypoint behaved differently
from the unit-tested runProjectHandoff helper.

The handoff sub now short-circuits before parseFlags / projectDaemonUrl,
so `od project handoff` runs exactly runProjectHandoff with no
intervening parsing. handoff-cli.test.ts gains unknown-flag and
missing-value cases covering the structured fail path.

---------

Co-authored-by: DevForgeAI CI/CD Engineer <devforge-ai@development.ai>
2026-05-20 13:28:27 +08:00
kami
1523defad1
feat(web): add plugin registry detail drawer (#2087)
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 22:12:16 +08:00
张东明
bac56415a2
fix(web): surface daemon error messages for invalid folder imports (#1923)
* fix(web): surface daemon error messages for invalid folder imports

importFolderProject() was swallowing non-2xx responses by returning
null, so the UI could only show a generic "Open folder failed: <path>"
message even though the daemon already returns specific errors like
"cannot import the filesystem root" or "folder not found".

Parse the daemon error body and throw so the panel displays the actual
reason. Also show feedback for empty path input instead of silently
returning.

Fixes #1186

* test(web): update folder import test to match new error propagation

The existing test expected a generic "Open folder failed: <path>"
message from a boolean return. Update to match the new behavior where
the daemon's error message is thrown and displayed directly.
2026-05-17 15:00:49 +08:00
Tom Huang
76defffb93
Garnet hemisphere (#1702)
* feat(chat-composer): enhance mention handling and input overlay

- Introduced a new overlay for inline mentions in the chat composer, improving user experience by visually indicating mentions as users type.
- Updated the `ChatComposer` component to manage mention entities and integrate them into the input field, allowing for better context and interaction.
- Enhanced the `AssistantMessage` component to support the display of plugin action panels based on the current project context, facilitating easier plugin management.
- Refactored related components to ensure consistent handling of project files and mentions across the application.

This update significantly improves the chat interaction model, making it more intuitive for users to engage with mentions and plugins.

* feat(plugin-management): enhance plugin action panels and UI components

- Updated the `AssistantMessage` component to include plugin action panels based on the latest project context, improving user interaction with generated plugins.
- Refactored the `PluginsView` to support detailed views for available marketplace entries, allowing users to access more information and actions for each plugin.
- Introduced new CSS styles for improved visual representation of plugin-related UI elements, enhancing overall user experience.
- Enhanced the `listPlugins` function to include an option for fetching hidden plugins, providing more flexibility in plugin management.

This update significantly improves the usability and functionality of the plugin management system, making it easier for users to interact with and manage their plugins.

* fix(assistant-message): refine plugin folder candidate selection logic

- Updated the `pluginFoldersTouchedThisTurn` function to improve the logic for selecting plugin folder candidates based on touched paths and message content.
- Introduced a new helper function, `pathMatchesFolderFileBasename`, to enhance the matching criteria for folder candidates.
- Added a check for explicit folder matches before falling back to a single candidate, improving accuracy in folder selection.
- Modified the `shouldRenderSlotAsText` function in `HomeHero` to include the name parameter, refining the rendering logic for slot text.

These changes enhance the functionality and reliability of the assistant message component in managing plugin folder candidates.

* feat(plugin-folder-actions): implement agent-routed CLI actions for plugin management

- Introduced a new `PluginFolderAgentAction` type to streamline actions related to plugin folders, including install, publish, and contribute.
- Updated the `DesignFilesPanel`, `FileWorkspace`, and `AssistantMessage` components to utilize the new agent action handling, improving user interaction with generated plugins.
- Refactored the action handling logic to send commands to the agent, enhancing the workflow for managing plugin folders.
- Added corresponding tests to ensure the new functionality works as expected and integrates seamlessly with existing components.

This update significantly enhances the plugin management experience by routing actions through the agent, allowing for a more cohesive and interactive user experience.

* Fix PR 1702 CI blockers

* Fix PR 1702 remaining CI checks

* Prebuild AGUI adapter after install

* Restore plugin project snapshot wiring

* feat(marketplace): refactor marketplace URL handling and enhance fetching logic

- Introduced new functions to normalize marketplace URLs and manage fetching of marketplace manifests, improving the reliability of marketplace integrations.
- Updated the server and plugin logic to utilize the new fetching mechanisms, ensuring consistent handling of marketplace data.
- Enhanced tests to cover new URL normalization and fetching scenarios, ensuring robustness in marketplace management.

This update significantly improves the marketplace experience by streamlining URL handling and enhancing data fetching capabilities.

* Fix project auto-send cleanup spec
2026-05-14 21:12:50 +08:00
lefarcen
b268bbe169 Merge origin/garnet-hemisphere (post-9e196d34) — Use Plugin handoff fix
Brings in 11 new garnet commits, most importantly:
- 1a90aef4 feat(plugin-use): implement plugin use handoff functionality —
  fixes the bug QA reported where /plugins Use Plugin would 422 silently
  for template plugins; new flow hands off to HomeView with the plugin
  pre-bound + input form prompted there.
- 2ac58544 feat(plugin-inputs): enhance plugin input handling with file
  upload support — extends PluginInputsForm for file uploads.
- 3b167b69 feat(plugins): registry protocol — new @open-design/registry-protocol
  workspace package (needs build before daemon boot).
- Plus enhancements to plugin metadata, GitHub installer, plugin detail
  view, login/whoami, static HTML preview paths.

Conflicts resolved:
- packages/contracts/src/api/projects.ts: HEAD's skipDiscoveryBrief
  field + garnet's contextPlugins (@-mention plugin context refs) both
  kept on ProjectMetadata.
- apps/landing-page/* (3 files): accepted HEAD — garnet had the older
  single-page landing-page header; main has the multi-page layout
  (/skills/, /systems/, /templates/, /craft/) with dynamic counts. Not
  related to the Use Plugin core fix.

New @open-design/registry-protocol package must be built before daemon
boots; pnpm install does this via postinstall already.
2026-05-14 16:32:35 +08:00
pftom
56c264c9bd feat(plugins): add login and whoami commands for GitHub CLI authentication
- Introduced `login` and `whoami` commands to the plugin CLI, enabling users to authenticate with the Open Design registry via GitHub CLI.
- The `login` command wraps GitHub CLI authentication, allowing users to specify a host, defaulting to GitHub.
- The `whoami` command retrieves and displays the authenticated GitHub account information, with an option for JSON output.
- Updated the CLI help documentation to include usage instructions for the new commands.
- Enhanced error handling for GitHub CLI dependencies and authentication status.

This update improves the user experience by simplifying the authentication process for plugin publishing.
2026-05-14 07:25:05 +08:00
lefarcen
d83b228c81 Merge remote-tracking branch 'origin/garnet-hemisphere' into reconcile/garnet-main-merge 2026-05-13 23:52:33 +08:00
pftom
7c48fbd902 feat(plugins): enhance PluginsView with new marketplace management features
- Updated the PluginsView component to include new tabs for 'Installed', 'Available', and 'Sources', improving the organization of plugin management.
- Introduced functions for adding, refreshing, removing, and setting trust for plugin marketplaces, enhancing the marketplace interaction capabilities.
- Enhanced the UI to reflect the new structure, including updated CSS styles for better visual consistency and usability.
- Added tests to ensure the functionality of the new marketplace features and verify the correct rendering of available plugins.

This update significantly improves the user experience in managing plugins and marketplaces, providing a more intuitive interface for users.
2026-05-13 23:42:41 +08:00
lefarcen
d3602be666 Merge origin/main into garnet-hemisphere (reconcile)
Merge of `origin/main` (`03ed3960`, 2026-05-13 pre-0.7.0) into the
161-commit garnet-hemisphere line, reconciling the product-vibe-coded
plugin/marketplace/EntryShell surfaces from garnet with the routines /
skills / live-artifacts feature work landed on main since the fork point.

Headline decisions (full rationale + side-by-side screenshots in
`specs/change/20260513-garnet-skills-automations/reconcile-result-vs-garnet.md`):

- #1 SettingsDialog: keep main's Memory / Skills / External MCP /
  Connectors / Routines / MCP server nav items even though the top-level
  /integrations + /automations routes also cover them. Two entries
  coexist for now; revisit once Track A/B fill in the placeholder content.
- #2 EntryView: accept garnet's thin wrapper delegating to EntryShell.
  Main's PetRail sidebar + image-templates/video-templates tabs are
  intentionally deferred to a follow-up that re-integrates them into
  the new EntryShell layout.
- #3 /integrations + /automations top-level routes: kept (garnet's
  product intent). Skills tab is still a "Coming soon" placeholder
  awaiting Track A; Routines/Schedules/Live-artifacts cards on
  /automations are still mock awaiting Track B.
- #5 DesignFilesPanel: hybrid — main's pagination as primary list,
  garnet's Plugin folders section preserved between the live-artifacts
  block and the pagination block. (by-kind sections drop in favour of
  pagination; plugin-folders rendering stays because it is a
  garnet-specific product addition.)
- #7 server.ts (10 hunks, ~5400 conflict lines): manual hunk-by-hunk
  merge. Both daemon admin routes + plugin/genui routes (garnet) and
  routines/memory/skills upgrades (main) preserved. Garnet's inline
  project route block kept alongside main's `registerProjectRoutes` /
  `registerProjectUploadRoutes` modular wiring — duplicate route
  audit is a follow-up. Garnet's POST /api/projects plugin-snapshot
  resolution + default-scenario fallback is intentionally dropped from
  the inline body (now handled by registerProjectRoutes) and listed for
  follow-up re-integration into `project-routes.ts`.

Verification (worktree at /Users/elian/Documents/open-design-garnet):
- `pnpm typecheck` exits 0 across all workspace packages
- daemon (`pnpm tools-dev run web --namespace reconcile-shots`) boots,
  serves `/api/daemon/status` healthy, and survives a Playwright
  walkthrough of /integrations / /automations / home / projects /
  design-systems / plugins / settings dialog
- `@open-design/plugin-runtime` package built (was missing dist/ on
  garnet); without it the daemon's plugins/* imports fail at boot

Track A (Skills tab → real SkillsSection) and Track B (Automations
cards → real routines / live-artifacts backend) are the two remaining
follow-ups blocking the placeholder/mock content from going live. See
`spec.md` and `track-skills.md` in the same directory.
2026-05-13 22:29:21 +08:00
pftom
c36609c47d feat(daemon, web): implement plugin sharing project creation and enhance CLI functionality
- Added new flags for conversation, message, agent, and model in the CLI to support enhanced plugin sharing features.
- Introduced a new API endpoint for creating share projects for plugins, allowing users to publish to GitHub or contribute to Open Design.
- Updated the UI components to facilitate the new sharing functionalities, including prompts for user input during the sharing process.
- Enhanced the project management system to handle new plugin share actions, improving user interaction and experience.
- Added tests to ensure the reliability of the new sharing features and their integration within the existing plugin management system.

This update significantly enhances the plugin ecosystem by enabling users to share their creations more effectively and streamline collaboration.
2026-05-13 07:01:12 +08:00
sukumarp2022
b167991d7c
feat: add project-level and user-level custom instructions (#1304)
* feat: add project-level and user-level custom instructions

Implements #510 — editable custom instructions that get injected into
every model message, at both user level (Settings → Memory) and
project level (pencil icon in project header).

- Add customInstructions to Project, AppConfigPrefs contracts
- Add custom_instructions column migration to projects table
- Inject user + project instructions into system prompt (after memory,
  before design system; project-level wins on conflict)
- Add Settings textarea for user-level instructions
- Add inline editor bar in ProjectView for project-level instructions
- Sync user-level instructions through daemon app-config round-trip

* fix: address PR review — validation, draft reset, length limit

- Reset instructionsDraft on Cancel and toggle close (stale draft bug)
- Thread customInstructions through POST /api/projects create handler
- Add type + length validation (5000 chars) in PATCH handler
- Enforce length cap in app-config applyConfigValue
- Add maxLength={5000} to both UI textareas
- Resync draft via useEffect when editor is closed
- Remove stray run.sh from commit

* fix: address maintainer review — save race condition, precedence wording

- Make handleSaveInstructions async with await + revert on failure
- Add instructionsSaving state to disable Save/Cancel/textarea during save
- Clarify precedence wording with concrete example in both prompt composers
- UpdateProjectRequest already has customInstructions (verified)

* fix: use server-returned project in save handler, drop optimistic update

The previous optimistic-update + revert approach captured a stale project
snapshot in the useCallback closure. On failure, reverting with the captured
object could clobber unrelated project fields that changed during the
async request.

Switch to pessimistic update: wait for patchProject to succeed, then call
onProjectChange(result) with the server-returned project object. The
instructionsSaving flag disables the editor UI during the round-trip.

* fix: align create/PATCH validation for customInstructions

Create endpoint now rejects invalid types and >5000 char values with
400 instead of silently truncating, matching the PATCH handler behavior.
2026-05-12 14:27:57 -04:00
pftom
5f71968f61 feat(daemon, web): implement plugin sharing features for GitHub and Open Design contributions
- Added new API endpoints for publishing plugins to GitHub and contributing to Open Design, enhancing the plugin sharing capabilities.
- Introduced functions for handling plugin sharing actions, including `publishGeneratedPluginToGitHub` and `contributeGeneratedPluginToOpenDesign`.
- Updated the `DesignFilesPanel` and `FileWorkspace` components to support new sharing functionalities, allowing users to publish or contribute plugins directly from the interface.
- Enhanced the UI with new buttons for publishing and contributing plugins, improving user interaction and experience.
- Added tests to ensure the reliability of the new sharing features and their integration within the existing plugin management system.

This update significantly improves the plugin ecosystem by enabling users to share their creations with the community and streamline collaboration.
2026-05-12 22:39:32 +08:00
pftom
26d21a942e feat(web): enhance plugin input handling and categorization
- Added support for plugin inputs in the EntryShell and HomeView components, allowing for more dynamic plugin interactions.
- Updated the PluginsHomeSection to include subcategory filtering, improving the user experience when navigating plugins.
- Enhanced the PluginsView and related components to reflect the new categorization model, transitioning to a workflow-based approach.
- Refactored tests to ensure coverage for new input handling and categorization features, maintaining reliability across the application.

This update significantly improves the plugin management experience by providing clearer categorization and enhanced input handling for plugins.
2026-05-12 21:59:38 +08:00
pftom
6f818d971d feat(daemon, web): implement plugin folder installation and enhance atom worker registry
- Added a new API endpoint for installing plugins from specified folder paths, improving the plugin management experience.
- Introduced functions for normalizing and validating project plugin folder paths, ensuring robust error handling.
- Implemented a registry for built-in atom workers, allowing for dynamic signal aggregation during pipeline execution.
- Enhanced the `runStageWithRegistry` function to support multiple atom workers, merging their outputs with pessimistic logic.
- Updated the UI components to display plugin folder candidates and facilitate user interactions for plugin installation.
- Added tests for the new atom worker registry and plugin folder installation features, ensuring reliability and correctness.

This update significantly enhances the plugin installation process and the overall functionality of the atom worker system, providing users with better tools for managing plugins and their interactions.
2026-05-12 21:38:45 +08:00
pftom
13d5598b0c feat(web, daemon): enhance plugin import functionality and UI components
- Added support for uploading plugins via zip files and folders, improving the plugin import process.
- Introduced a new `PluginImportModal` for a streamlined user experience when importing plugins.
- Updated the `PluginsView` to include disabled states for unfinished plugin areas, enhancing clarity for users.
- Refactored various components to utilize the new `resolvePluginQueryFallback` function for improved localization handling.
- Enhanced CSS styles for better visual feedback and responsiveness in the plugin import interface.

This update significantly improves the plugin management experience, making it easier for users to import and manage plugins effectively.
2026-05-12 20:46:17 +08:00
pftom
443aea72c5 feat(daemon, web): enhance plugin handling and UI integration
- Introduced a new plugin upload mechanism with file size limits and memory storage, allowing users to upload plugins directly.
- Implemented fallback logic for plugin application, ensuring projects can be created without explicit plugin requests.
- Enhanced the UI to support plugin selection and integration, including a new `PluginsView` component for managing plugins.
- Updated various components to utilize localized text for plugin queries, improving user experience across different languages.
- Added tests for new plugin functionalities and local skill loading, ensuring reliability and correctness.

This update significantly improves the plugin management experience, providing users with better tools for plugin integration and interaction.
2026-05-12 20:42:40 +08:00
pftom
b3dc3c3e0c feat(web): integrate applied plugin snapshot for enhanced user experience
- Added support for displaying an active plugin as a context chip in user messages when a project is created with a pinned plugin. This replaces the in-composer plugin rail to avoid re-prompting users for plugin selection.
- Introduced `applied_plugin_snapshot_id` in the database schema and updated relevant components (ChatComposer, ChatPane, ProjectView) to handle the new functionality.
- Implemented fetching of the applied plugin snapshot in ProjectView to ensure the active plugin is rendered correctly.
- Enhanced CSS for the plugin chip to improve visual presentation.

This change streamlines the user experience by providing context on previously selected plugins directly within the chat interface.
2026-05-11 22:53:40 +08:00
pftom
175629193f feat(web): PluginLoopHome minimum-closed-loop entry on Home
Replace the tabbed NewProjectPanel on Home with PluginLoopHome — a
single prompt textarea + plugin grid. Picking a plugin calls
/api/plugins/:id/apply, binds the snapshot, and lets the user submit
the prompt straight into a new project; createProject forwards
pluginId / appliedPluginSnapshotId so daemon pins the plugin to the
project + conversation, and ProjectView auto-sends the first message
once via a sessionStorage flag so the user lands inside a running
pipeline.

Also: InlinePluginsRail / PluginsSection gain a `kinds` filter so the
ChatComposer strip only surfaces user-facing skill/scenario/bundle
plugins (atoms stay pipeline-side).
2026-05-11 21:38:57 +08:00
Zihuailin
06e677cb72
Fix pending prompt clearing for templates (#1148) 2026-05-10 21:52:49 +08:00
Cursor Agent
adc2afd769
feat(web): plugin composer surface — applyPlugin + Rail + Inputs + GenUI renderer
Plan §3.C1–§3.C4.

Web composer integration for the plugin system:

- apps/web/src/state/projects.ts gains:
  * applyPlugin(pluginId, { inputs?, projectId?, grantCaps? }) — wraps
    POST /api/plugins/:id/apply and returns the typed ApplyResult.
  * listPlugins() — wraps GET /api/plugins.
  * renderPluginBriefTemplate(template, inputs) — substitutes
    {{var}} placeholders inside useCase.query as the user types so the
    composer's brief textarea re-renders live.

- New components:
  * InlinePluginsRail — the card strip that lives below the input box
    on Home and inside ChatComposer. Supports 'wide' / 'strip' layouts
    + taskKind / mode filters.
  * ContextChipStrip — typed ContextItem chips above the brief input.
    Optional onRemove for clearing the applied plugin.
  * PluginInputsForm — JSON-Schema-light form rendered between the
    input and Send. Required fields gate Send via onValidityChange;
    string/text/select/number/boolean field types are supported.
  * GenUISurfaceRenderer — first-class confirmation + oauth-prompt
    surfaces (form + choice fall back to a JSON Schema preview +
    free-form textarea until Phase 2A.5).
  * GenUIInbox — drawer that lists every persisted surface answer for
    a project; revoke calls POST /api/projects/:id/genui/:sid/revoke.

- jsdom tests under apps/web/tests/components/:
  * InlinePluginsRail (mount fetch, click → applyPlugin → onApplied,
    taskKind filter)
  * PluginInputsForm (validity gating, default hydration, select
    options)
  * GenUISurfaceRenderer (confirmation true/false branches; oauth
    surface forwards { authorized, connectorId } per spec §10.3.1)

Web test suite: 567 → 575 (added 8 plugin component cases). The
NewProjectPanel / ChatComposer / ProjectView mounts will land in the
follow-up commit so this PR's diff stays reviewable.

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 11:47:12 +00:00
lefarcen
afb331a288
feat: add opt-in Langfuse telemetry (#800)
* docs(specs): add langfuse telemetry change spec

Captures the design for forwarding completed agent runs to Langfuse,
including data-model mapping, field-budget caps, privacy gates,
build-secret injection, GDPR right-to-deletion approach, and the
resolved decisions on default consent, identifier shape, region, and
ownership.

* feat(daemon): add langfuse-trace module and telemetry prefs

Adds the dependency-free building blocks for forwarding completed
agent runs to Langfuse. Two layers:

- AppConfigPrefs gains installationId and a TelemetryPrefs object with
  metrics / content / artifactManifest gates. The daemon validator
  treats telemetry like agentModels — replace-on-write, drop-when-empty,
  reject non-boolean inner values.

- New langfuse-trace.ts builds a {trace-create, generation-create}
  pair from a ReportContext, capping prompt at 8 KB, output at 16 KB,
  artifacts at 50 entries, and dropping any batch larger than 1 MB
  before send. reportRunCompleted is no-op when LANGFUSE_PUBLIC_KEY /
  LANGFUSE_SECRET_KEY are unset (so dev runs and forks never emit) and
  short-circuits on prefs.metrics === false.

Server-side wiring into the run-close path lands in a follow-up.

* fix(langfuse): default to US Langfuse region

End-to-end smoke against the project's actual dev key on 2026-05-07
returned 401 from cloud.langfuse.com (EU) and 207 from
us.cloud.langfuse.com (US), confirming the org lives in US. Update the
default base URL, the matching test, and the spec's Q3 decision row to
match. Self-hosted or EU-region operators can still override via the
LANGFUSE_BASE_URL env var.

* feat(daemon): wire langfuse trace forwarding into run-close

Adds the daemon-side glue to forward completed agent runs:

- runs.ts gains an optional onTerminate hook fired once per run after it
  reaches a terminal state. Errors thrown from the hook are caught and
  logged, never propagated, so telemetry can never break the run path.

- New langfuse-bridge.ts assembles a ReportContext from the in-memory
  run record, the conversation's persisted assistant message, and the
  user's app-config preferences. It tolerates a missing message (e.g.
  when web has not yet PUT the final delta) and a missing app-config.

- server.ts stashes the original user prompt on the run object inside
  startChatRun so the bridge can include it without crossing the
  createChatRunService boundary, and registers the hook callback when
  building the run service.

Behavior remains a no-op unless LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY
are set in the daemon env AND telemetry.metrics is true in app-config.
A live smoke against us.cloud.langfuse.com on 2026-05-07 confirmed the
matching trace + generation schema is accepted (HTTP 207, both events
201 created).

* fix(langfuse): address PR #800 review feedback

P1 — Move trace forwarding off the daemon-internal run-close hook and
onto the message-persistence path. The original onTerminate hook ran
inside finish() the moment the SSE 'end' event was emitted, which is
*before* the web client's onDone handler refreshes project files and
PUTs producedFiles + final assistant content back to SQLite. Reading
SQLite at that moment routinely missed both. The fix: drop the runs.ts
hook entirely and trigger from PUT /api/projects/:id/conversations/:cid/
messages/:mid when the saved row carries a terminal runStatus. A
reportedRuns Set guards against the multiple PUT calls web makes per
turn (each retry / state update). Set entries auto-evict after the same
30 min TTL the runs map uses. Web persists a terminal-status message in
all three completion paths — onDone (succeeded), onError (failed), and
cancel (canceled) — so this catches every run shape.

P2 — postLangfuseBatch now parses the 207 Multi-Status response body.
Langfuse legacy ingestion always returns 207, and response.ok is true
for 207, so per-event validation errors used to slip through silently.
We now warn when body.errors is non-empty. Two new unit tests.

P2 — truncate() and the HARD_BATCH cap now compare UTF-8 byte length,
not String.length (which counts UTF-16 code units). A 4096-character
CJK prompt occupies 12 KB, well over the 8 KB input cap. truncate also
walks backwards to a UTF-8 leading byte so the cut never lands inside a
multi-byte codepoint. New unit test covers '设'.repeat(4096).

P2 — Spec R7 now lists the actual Langfuse trace deletion endpoint
(DELETE /api/public/traces/{traceId} for single, DELETE /api/public/traces
with body for batch). Verified by curl on us.cloud.langfuse.com:
DELETE /api/public/traces/X → 200; the path the original spec named
(POST /api/public/trace/X) returns 404. Reference link points at
langfuse.com/docs/administration/data-deletion.

P3 — Q4 (legacy ingestion vs OTel) moved from Open Questions to
Resolved Decisions. The implementation already commits to legacy and
the trade-off was discussed during design; the open-question status was
stale.

* feat(web): privacy consent surface + Settings → Privacy tab

Adds the user-facing half of the telemetry feature so the daemon-side
hook from PR #800 has something to talk to.

- AppConfig gains optional `installationId` (anonymous v4 uuid generated
  on first opt-in; null after explicit decline; undefined when the user
  has never seen the consent surface) and `telemetry: TelemetryConfig`
  ({metrics, content, artifactManifest}). syncConfigToDaemon round-trips
  both fields so the bridge module sees the same prefs.

- SettingsDialog grows a Privacy section with two states. When the user
  has never made a consent decision (typical first-run path), the
  section renders the GDPR-aligned consent card: a kicker, the disclosure
  body listing both metrics and conversation content as separate bullets,
  and two equally-prominent buttons ("Share usage data" / "Don't share").
  The Don't-share path keeps the app fully usable (core app must work
  with all tracking declined). After a decision the same panel switches
  to three independent toggles + the anonymous ID + a "Delete my data"
  button that rotates the ID and turns everything off.

- App.tsx points the welcome modal at the new Privacy section so the
  consent decision is the first thing a fresh installation sees.

- 17 i18n keys land in en + zh-CN + zh-TW with hand-translated copy,
  and as English placeholders in the remaining 14 locales — enough for
  the parity check to pass while leaving room for proper localisation
  in a follow-up. Dict type updated.

- Minimal index.css for the consent card + toggle rows so the panel is
  legible without depending on follow-up design polish.

Telemetry remains a no-op end-to-end until the user clicks Share usage
data: the daemon gate (prefs.metrics === true) keeps every code path
short-circuited otherwise.

* refactor(web): rebuild Privacy panel using project-native settings primitives

The first cut used custom .settings-privacy-* classes + raw HTML
checkboxes that didn't match any other Settings tab. Replace with the
shell other sections already use:

- settings-subsection containers with section-head + h4 + .hint
- seg-control / seg-btn pill toggles ("active" / "offline") for each of
  the three telemetry preferences, mirroring NotificationsSection
- a 2-cell seg-control for the consent card so Share usage data and
  Don't share carry identical visual weight (the GDPR equal-prominence
  requirement that the previous accent / outline split missed)
- ghost button + readonly text input for the installation id row,
  mirroring the API-key field pattern elsewhere

Drop the bespoke CSS block in favor of inheriting the existing
settings-section / seg-control / ghost styling. The only privacy-
specific style left is a tight definition list inside the consent card
for the metrics + content disclosure rows.

* refactor(web): use .toggle-row iOS switch for Privacy preferences

Active/offline pills (the seg-control single-cell pattern that
NotificationsSection uses) read awkwardly for a flat preference list.
Switch the three telemetry toggles to .toggle-row — the same control
NewProjectPanel uses for "speaker notes" / "animations": label + hint
on the left, iOS-style sliding switch on the right, full-row click
target. The consent card's two-button seg-control stays as-is — there
the equal-weight pill pair is exactly what GDPR equal-prominence wants.

* feat(web): standalone first-run privacy consent banner

Replaces the Settings-dialog-as-onboarding hack with a dedicated
bottom-right banner card that mounts whenever the user has never made
a privacy decision (cfg.installationId === undefined). The banner is
prominent (anchored to the corner with a soft shadow) but
non-blocking, mirrors cookie-consent UX, and shares the project's
panel styling — same .modal-elevated background, --radius-lg corners,
--shadow-lg lift.

Wiring:

- App.tsx imports PrivacyConsentModal and renders it at the root,
  gated on installationId === undefined && !settingsOpen so it doesn't
  double up with the Privacy tab's own consent card when Settings is
  already showing.
- Share / Don't share both go through handleConfigPersist, so the
  resulting installationId + telemetry prefs land in localStorage and
  the daemon at the same time, reusing the existing autosave plumbing.
- The previous attempt that pinned the welcome SettingsDialog to the
  Privacy section is reverted; onboarding now stays focused on agent
  configuration, and the consent decision lives in its own surface.

* fix(web): keep privacy banner visible while Settings welcome modal is open

The banner gated itself on `!settingsOpen` to avoid double-rendering
with the Privacy tab's consent card. But the first-run path opens the
Settings welcome modal automatically when `onboardingCompleted=false`,
which fired immediately after bootstrap — so the banner flashed for a
moment and then vanished behind the modal backdrop.

Drop the `!settingsOpen` clause so the banner stays mounted whenever
the user has not yet made a privacy decision, and bump its z-index
above the modal backdrop (200 vs 100) so first-run users can actually
reach the consent buttons. The minor visual overlap with the Privacy
tab's own card is fine: clicking either copy resolves both surfaces.

* copy(privacy): soften consent button labels

Banner action buttons now read "Help improve Open Design" / "Not now"
(en, with hand translations in zh-CN / zh-TW and English placeholders
in the other 13 locales) instead of "Share usage data" / "Don't share".

The new wording aligns the affirmative action with the kicker copy
("Help us improve Open Design") and reads less alarming, while the
disclosure list above still names both data categories explicitly so
the consent stays informed under GDPR. The decline button stays as a
soft "Not now" rather than an aggressive "Don't share" so the reject
path doesn't read as hostile to the user.

No structural change — the two-cell seg-control still gives the buttons
identical visual weight, and the underlying side-effects are unchanged
(installationId is generated on Help / nulled on Not now, and the
telemetry prefs flip the same way).

* feat(telemetry): expand trace fields for evals & dataset construction

Each Langfuse trace now ships the full per-turn + per-install fact
sheet that the eval/dataset workflow needs, instead of only the bare
turn id + token count from before. Everything below is gated by
`prefs.metrics === true`; nothing here is content (those gates remain
separate).

Per-turn:
- model — first-class generation.model field, drives Langfuse cost
  lookup and model-grouping in the UI; also mirrored in trace.metadata
  and trace.tags so list-view filters work.
- reasoning — generation.modelParameters.{ reasoning } so the Model
  Parameters card lights up; mirrored in metadata.
- skillId / designSystemId — metadata + tags, so dataset slices can
  group by which skill/DS produced which output.

Per-process / build (constant within one daemon run, cached at start):
- appVersion / appChannel / packaged from app-version.ts
- nodeVersion (process.version), os (platform()), osRelease,
  arch (os.arch())
- clientType — desktop vs web, derived from a new X-OD-Client header
  the web layer sets in providers/daemon.ts (with a User-Agent sniff
  fallback for third-party callers).

Plumbing:
- startChatRun stashes model / reasoning / skillId / designSystemId
  on the run object alongside the existing userPrompt stash.
- POST /api/runs reads X-OD-Client and stores run.clientType.
- langfuse-bridge collects RuntimeInfo once per process and merges
  per-run client carrier; ReportContext gains optional `turn` +
  `runtime` blocks; existing fields stay backward compatible.

Spec gains a "Telemetry Fields Catalog" section enumerating every
field, its source, and the gate it lives under, so the eval team has a
single place to look up what's available without reading the trace
schema by example.

Tests:
- new langfuse-trace tests cover turn tags, runtime tags, generation
  model/modelParameters promotion, modelParameters omission when
  reasoning is unset, and metadata mirroring.
- langfuse-bridge gains an end-to-end "turn-level config" test that
  threads model/reasoning/skill/DS/clientType + appVersion through
  the bridge and asserts the Langfuse payload shape.
- existing tests adjusted to tolerate host-dependent os tag.

* copy(privacy): trim Share button to verb phrase only

"Help improve Open Design" overflowed the equal-width 2-cell
seg-control on the consent banner — the product name is already in
the kicker + headline above the buttons, so the button itself only
needs the verb phrase. Drop the product name from all locales:

- en: Help improve Open Design → Help improve
- zh-CN: 帮助改进 Open Design → 帮助改进
- zh-TW: 協助改進 Open Design → 協助改進

The decline button ("Not now" / "暂不" / "暫不") was already short, so
the two buttons now have comparable length and the equal-prominence
seg-control fits cleanly. Standalone Settings → Privacy panel uses
the same labels for consistency.

* fix(web): defer Settings welcome modal until privacy decision is made

Previously bootstrap raced two surfaces against each other on first
launch: the privacy consent banner (gated on installationId ===
undefined) and the Settings welcome modal (gated on
onboardingCompleted === false). The banner's higher z-index kept it
above the backdrop visually, but having two foreground surfaces at
once is still confusing UX.

Sequence them instead: bootstrap only opens the welcome modal when
the user has *already* resolved consent (installationId !== undefined).
Until then the banner owns the foreground alone. Once the user clicks
Help improve / Not now, the corresponding handler hands off to the
welcome modal if onboarding is still pending. End state matches what
it was before — just without the simultaneous-render flash.

* debug(privacy): log banner gate state to track sudden disappearance

Two console.log points to find which setCfg call (or stale bundle) is
flipping cfg.installationId from undefined to a value while the banner
is visible. To remove once the regression is reproduced.

* fix(privacy): keep installationId + telemetry out of localStorage

Daemon is now the single source of truth for the privacy decision.
Why this matters: the consent banner gates on
\`config.installationId === undefined\`, but loadConfig() merges
localStorage on top of the daemon's reply, so a stale uuid in
\`open-design:config\` (left over from a previous opt-in) was
re-hydrating the React state and immediately syncing back to the
daemon — defeating "Delete my data" and re-suppressing the banner
within milliseconds of every page load.

The deeper reason to fix it here, not just patch the gate: a privacy
identifier persisted in browser storage that the user can't see or
clear without DevTools is a compliance liability. Anything users can
revoke needs one canonical place to store it. Daemon \`app-config.json\`
already serves that role for everything else gated through
syncConfigToDaemon, so installationId + telemetry now ride that path
exclusively:

- saveConfig() strips both keys before writing localStorage.
- loadConfig() strips both keys when reading older stale payloads,
  so existing installs migrate transparently on next launch.
- syncConfigToDaemon() / mergeDaemonConfig still round-trip them, so
  the React state stays in sync with the daemon as before.

Net effect: clearing app-config.json (or hitting "Delete my data") now
fully resets the install identity, with no residual cohort key in
browser storage.

* feat(privacy): scrub secrets + PII from prompt/output before send

When prefs.content is on, daemon now runs the prompt and assistant
text through a regex scrubber (apps/daemon/src/redact.ts) before
posting to Langfuse. The scrubber is the simplest thing that gives
the user-facing copy a truthful claim — pure regex, zero new
dependencies, fully auditable in this Apache-2.0 repo (vs. pulling a
single-maintainer 5-month-old npm package into a core process).

Categories covered (each replaced with [REDACTED:<kind>]):

- Anthropic / OpenAI sk- keys (incl. proj/live/test/ant variants)
- Langfuse pk-lf- / sk-lf- (specific rule wins over generic sk-)
- GitHub gh[opsur]_ tokens
- AWS access key ids (AKIA + 16 uppercase)
- Google API keys (AIza + 35)
- Slack xox[abprs]- tokens
- Stripe live/test keys
- JWT header.payload.signature triples
- Bearer-header values (scheme word stays readable)
- Emails, IPv4, US-style phone numbers
- Credit cards — 13–19 digit runs that pass a Luhn check, so order ids
  and unix-nanos timestamps that fail Luhn pass through unchanged

Not covered, stated openly in spec + i18n: names, postal addresses,
business-secret semantics, raw 40-hex tokens (too high a false-positive
cost for artifact slugs). Those would require an ML layer.

Wired in:
- apps/daemon/src/redact.ts — exports redactSecrets() +
  redactSecretsWithCounts() helper for future audit-summary metadata.
- apps/daemon/src/langfuse-bridge.ts — runs both prompt and output
  through redactSecrets() before they reach the trace builder.
- 18 unit tests cover every pattern plus negative cases (Luhn-failing
  digit runs, out-of-range IPv4 octets, idempotence on re-redacted
  text, ordinary prose passthrough).
- i18n privacyContentHint on en + zh-CN + zh-TW (plus 14 locale
  placeholders) enumerates the categories so the consent disclosure
  matches the implementation — the GDPR informed-consent requirement.
- spec gains a Pre-send Redaction subsection with the regex shape
  table + intentional non-coverage list.

Drive-by: dropped the [privacy] debug logs that traced the now-fixed
bootstrap regression.

* fix(telemetry): make Langfuse reporting resilient

* feat(telemetry): nest Langfuse turn observations

* feat(telemetry): emit Langfuse tool spans

* fix(telemetry): report after finalized message writes

* fix(telemetry): honor persisted terminal status

* fix(web): let consent banner yield page clicks

* fix(telemetry): report current turn prompt only
2026-05-09 10:06:01 +08:00
Sid
8b0625aa6f
fix(web): unbreak Create button on plain HTTP / LAN-IP deployments (#849) (#900)
`crypto.randomUUID()` is restricted to secure contexts (HTTPS or
`localhost`), so when Open Design is served over plain HTTP on a
LAN IP — the standard Docker / unRAID / NAS self-hosted setup,
e.g. `http://192.168.1.10:17573` — Chromium silently makes the
function undefined. Calls then throw
`TypeError: crypto.randomUUID is not a function`, which the
`try/catch` around `createProject()` swallows by returning `null`,
which the click handler reads as "no project, do nothing". The
Create button effectively becomes a silent no-op for every LAN-IP
user (issue #849, also reported as #394).

Centralize the call into a new `apps/web/src/utils/uuid.ts` helper
with a three-tier fallback per @lefarcen's review:

  1. `crypto.randomUUID()` — secure-context happy path, native and
     cryptographically random.
  2. `crypto.getRandomValues()` + RFC 4122 §4.4 byte layout — still
     available in non-secure contexts since the Web Crypto API is
     not gated by `isSecureContext`. Yields a real v4 UUID with
     crypto-quality entropy.
  3. `Math.random()` — last-resort polyfill for environments
     missing both, kept because the IDs we generate (project ids,
     message ids, client request ids) are scoped to a single
     user's local browser session — cryptographic uniqueness
     isn't required, just enough entropy to avoid collisions.

Replace all four `crypto.randomUUID()` callsites confirmed in
@lefarcen's audit:

- `apps/web/src/state/projects.ts:48` (createProject id)
- `apps/web/src/components/ProjectView.tsx:986` (user message id)
- `apps/web/src/components/ProjectView.tsx:1013` (assistant message id)
- `apps/web/src/components/ProjectView.tsx:1263` (daemon stream
  clientRequestId)

with calls to the new `randomUUID()` helper.

Tests: 6 new tests in `apps/web/tests/utils/uuid.test.ts` cover
each fallback tier, RFC 4122 v4 format validation (regex + explicit
version/variant nibble checks), the explicit "doesn't throw when
`crypto.randomUUID` is undefined" assertion that pins the #849
root cause, and a 1000-iteration uniqueness check on the
`getRandomValues` path.

Verified locally:
- web vitest: 522/522 (was 516, +6)
- web `tsc -b --noEmit` clean
- `tsx scripts/i18n-check.ts` passes
2026-05-08 16:50:59 +08:00
INFINITY
988fd6db5e
feat: import existing local folder as project (#597) (#624)
* feat(contracts): types for folder-import endpoint

Add ImportFolderRequest, ImportFolderResponse to the public contract
surface. Extend ProjectMetadata with a baseDir field — when set, the
project's files live at this absolute path instead of .od/projects/<id>/.
Stored as the realpath() result so symlinks cannot redirect later writes.

Refs nexu-io/open-design#597

* feat(daemon): support metadata.baseDir for folder-rooted projects

Add resolveProjectDir() and metadata-aware variants of listFiles,
readProjectFile, writeProjectFile, ensureProject so a project's files
can live under metadata.baseDir (the user's chosen folder) instead of
.od/projects/<id>/. metadata.baseDir is opt-in — projects without it
keep the existing .od/projects/<id>/ behavior unchanged.

When listFiles walks a baseDir-rooted project, it skips conventional
build / install dirs (node_modules, .git, dist, build, .next, .nuxt,
.turbo, .cache, .output, out, coverage, __pycache__, .venv, vendor,
target, .od, .tmp) so the file panel stays focused on design content
instead of being dominated by lockfiles and node_modules.

Add detectEntryFile() — best-effort lookup for index.html or any
.html at the folder root, used by the import endpoint to seed the
initial active tab.

Refs nexu-io/open-design#597

* feat(daemon): add POST /api/import/folder endpoint

Creates a project rooted at the submitted local folder. metadata.baseDir
points at that folder and OD reads / writes there directly — no copy,
no shadow tree, mirroring how Cursor / Claude Code / Aider behave. The
user owns the workspace and is responsible for their own version
control.

Safety:
- baseDir is canonicalized via fs.promises.realpath() at import time so
  user-controlled symlinks can't redirect later writes. resolveSafe
  enforces the bounds check against the literal stored path; without
  realpath, a symlink (e.g. ~/sneaky → /etc) would let writeProjectFile
  escape the project tree at every later call because the OS follows
  the symlink at open() time.
- Post-realpath lstat ensures the canonical target is itself a real
  directory (defense-in-depth).
- The data directory (RUNTIME_DATA_DIR) and its descendants are
  refused after symlink resolution so a redirect into the daemon's
  own state can't masquerade as a project import.

The web client wires this through state/projects.ts → App.tsx,
landing the user on the auto-detected entry file when present.

Refs nexu-io/open-design#597

* feat(desktop): expose native folder picker to renderer

Adds an Electron preload script that exposes window.electronAPI.pickFolder
via contextBridge. Wires dialog.showOpenDialog through ipcMain so the
web UI can open a native folder selector for project import. Browser-only
users fall back to a text input for the absolute path (handled in the
web layer); the picker stays an optional convenience on the desktop
binary.

ipcMain.handle() registers handlers in an internal map that is not
exposed via eventNames(), so the natural-looking guard
  if (!ipcMain.eventNames().includes('dialog:pick-folder')) ipcMain.handle(...)
is always true. On a second createDesktopRuntime() call (dev hot-reload,
packaged-vs-electron mode swap) the body re-runs and ipcMain.handle()
throws 'Attempted to register a second handler'. Use removeHandler()
+ handle() unconditionally — removeHandler() is a documented no-op
when nothing is registered, making the pair idempotent.

Includes *.cts in the apps/desktop tsconfig so the preload script is
typechecked.

Refs nexu-io/open-design#597

* feat(web): add 'From existing folder' option to New Project

UI surface for the import flow:
- A new 'Open folder' affordance in NewProjectPanel that uses the
  native picker on Electron (window.electronAPI.pickFolder) and falls
  back to an absolute-path text input in the browser.
- importFolderProject() in state/projects.ts: typed wrapper around
  POST /api/import/folder using @open-design/contracts types.
- App.tsx wires the response: prepend the new project to the list,
  navigate to it, and select the auto-detected entry file as the
  active tab.

Skill / design-system pickers from the existing prototype tab are
reused — folder import is a project-creation flow, not a separate
project type.

Refs nexu-io/open-design#597

* docs(architecture): document folder-import endpoint

Adds POST /api/import/folder to the daemon API table and a 'Folder
import' section explaining the single-mode design (direct read/write
in metadata.baseDir, mirroring Cursor / Claude Code / Aider), the
realpath() canonicalization, the RUNTIME_DATA_DIR refusal, and the
SKIP_DIRS list applied to listFiles for baseDir-rooted projects.

Refs nexu-io/open-design#597

* test(daemon): unit + integration tests for folder import

Two new files:

apps/daemon/tests/folder-import-projects.test.ts (13 unit tests):
- resolveProjectDir behavior under all metadata combinations,
  including the fallback when baseDir is relative and the
  isSafeId-bypass when baseDir is set
- detectEntryFile: index.html priority, .html fallback, null when
  no html, no descent into subdirs
- listFiles with metadata.baseDir: walk, SKIP_DIRS hides node_modules
  / .git / dist, back-compat for projects without baseDir

apps/daemon/tests/folder-import-route.test.ts (10 integration tests):
- Happy path: baseDir stored in metadata, importedFrom='folder',
  conversation created, entry file detected
- Error paths: missing baseDir, empty, relative, non-existent,
  pointing at a file
- Security: realpath canonicalization (the symlink test was the one
  that surfaced the original /var vs /private/var mismatch in
  RUNTIME_DATA_DIR comparison on macOS)
- Security: a symlink that resolves into RUNTIME_DATA_DIR is rejected
  after realpath, not before

Refs nexu-io/open-design#597

* fix(daemon): wire baseDir metadata into chat + deploy reads

Two bugs caught in Codex automated review of #624:

1. chat-route was passing the metadata object directly as the listFiles
   opts argument: `listFiles(PROJECTS_DIR, projectId, chatMeta)`. The
   listFiles contract reads opts.metadata, not opts itself, so this
   silently fell back to .od/projects/<id>/ instead of the imported
   folder. existingProjectFiles was empty for baseDir-rooted projects.
   Wrap as `{ metadata: chatMeta }`.

2. deploy.ts read project files via readProjectFile without the
   metadata third argument, so for baseDir-rooted projects the deploy
   and preflight endpoints would look in .od/projects/<id>/ and fail
   with file-not-found instead of reading the imported folder. Thread
   options.metadata through buildDeployFilePlan → readProjectFile and
   pass project?.metadata at the two server.ts callsites
   (`POST /api/projects/:id/deploy` and the preflight endpoint).

Add a regression test that locks the listFiles contract: passing a
bare metadata object as opts must NOT scan baseDir — it must fall back
to the standard project dir, otherwise callers can leak the wrong
folder by mistake.

Refs nexu-io/open-design#597, #624 (Codex review)

* fix(daemon): ensure correct metadata handling in folder import

Addressed issues with metadata handling in folder import functionality. Updated the listFiles and readProjectFile methods to correctly utilize the metadata.baseDir, ensuring that project files are read from the intended directory. Added regression tests to verify that passing a bare metadata object does not inadvertently scan the baseDir, maintaining the integrity of project file access.

Refs nexu-io/open-design#597

* fix(daemon): security hardening from Codex review of #624

P1 findings from automated review:

1. POST /api/projects + PATCH /api/projects/:id rejected
   client-supplied metadata.baseDir. baseDir is privileged: it lets a
   project root inside the user's filesystem, and the realpath() +
   RUNTIME_DATA_DIR reentry checks live only on /api/import/folder.
   Allowing it on the generic create/patch path lets an attacker
   smuggle e.g. /etc through and bypass every import-time guard.
   Both endpoints now refuse a baseDir field with 400.

2. resolveSafeReal() helper: realpath()s each candidate path (or its
   longest existing prefix for write paths) and re-validates against
   realpath(projectRoot). The original resolveSafe() only did a
   string-prefix check, which was fooled by symlinks *inside* a
   baseDir-rooted project. A repo containing 'assets -> /Users/me/.ssh'
   passed the literal prefix check but readFile() followed the link
   at open() time. resolveSafeReal() is now used by readProjectFile,
   writeProjectFile, and deleteProjectFile.

3. Multer chat-upload destination now resolves to metadata.baseDir for
   imported folder projects via a module-level lookup wired to db at
   startServer() boot. Previously attachments landed in
   .od/projects/<id>/ even for baseDir projects, so the agent (which
   runs with cwd=baseDir) couldn't open them.

P2 findings:

4. searchProjectFiles threads metadata through listFiles +
   resolveProjectDir so /api/projects/:id/search hits the right tree.
5. buildProjectArchive + buildBatchArchive now accept metadata so
   'Download .zip' works for imported folder projects.
6. Watcher subscribe() resolves to baseDir for imported projects so
   live-reload SSE actually fires when the user edits files in their
   own folder. Registry stays keyed by the canonical directory.
7. Template snapshotting reads source-project files with metadata
   so a template can be saved from a baseDir-rooted source.

Tests:

- Regression: POST /api/projects with metadata.baseDir → 400.
- Regression: descendant symlink (assets/leak.txt -> /etc/hosts) is
  refused on the raw read endpoint.

Refs nexu-io/open-design#597, #624 (Codex P1+P2 review)

* fix(daemon): close two regressions found in #624 review round 2

@mrcfps caught two more correctness gaps:

1. Archive root symlink escape — buildProjectArchive accepts an optional
   ?root=<subdir> param to scope the zip to a subdirectory. The path was
   resolved with the string-only resolveSafe(), so a directory symlink
   inside an imported folder (docs -> /Users/me/.ssh) passed the prefix
   check and collectArchiveEntries() then walked outside the project
   tree. Switch to the symlink-aware resolveSafeReal() — the same one
   that already protects raw read/write/delete paths. The walker itself
   already skips dirent symlinks via !isDirectory && !isFile, so
   canonicalizing the root is the only missing piece.

2. PATCH metadata wiped baseDir — updateProject() replaces metadata
   wholesale. The previous guard only blocked an explicit baseDir
   change, but a normal patch that *omits* baseDir (a UI editing
   linkedDirs only sends { metadata: { kind, linkedDirs } }) silently
   detached imported projects from their folder root. Subsequent
   reads/writes/watch/deploy fell back to .od/projects/<id>.

   Re-stamp the immutable folder-import fields (baseDir, importedFrom='folder')
   from the existing project record onto the incoming patch when the
   project is imported. A patch that supplies a *different* baseDir
   still gets rejected as before; a patch that supplies the *same*
   baseDir is accepted as a no-op. A patch on a non-imported project
   that tries to set baseDir is also still rejected (preserves the
   POST /api/projects guard from the previous round).

Tests:

- archive endpoint: ?root=<symlink-to-/etc> → 400.
- patch endpoint: PATCH that omits baseDir on an imported project keeps
  baseDir intact (project still resolves to the user's folder after).

Refs nexu-io/open-design#597, #624 (Codex P1 round 2)

* fix(web): add Indonesian deploy provider copy

---------

Co-authored-by: INFINITY <valentyn.sotov@trendarena.app>
Co-authored-by: Siri-Ray <2667192167@qq.com>
2026-05-07 20:43:31 +08:00
PerishFire
cfebff9653
Align app directories and isolate e2e tests (#102)
* chore: align app directories

* test: consolidate external suites under e2e
2026-04-30 09:47:03 +08:00
Renamed from src/state/projects.ts (Browse further)