Commit Graph
65 Commits
Author SHA1 Message Date
a1chzt 82cdd50ece Stabilize API empty response detection 2026-05-11 16:26:03 +08:00
a1chzt 51505389bf Fix empty API response comment cleanup 2026-05-11 16:11:56 +08:00
a1chzt 2890e5d760 Handle empty API responses as no output 2026-05-11 15:51:17 +08:00
shangxinyu1andGitHub d45bf3fb9a test: expand entry and settings automation coverage (#954)
* test: harden new project panel metadata coverage

* test: expand entry e2e coverage

* test: drop e2e docs from the guarded package

* test: cover examples gallery interactions

* test: cover examples preview modal actions

* test: cover examples preview escape fullscreen

* test: cover examples template prompt filtering

* test: cover updated settings and entry tabs

* test: fix entry/settings coverage type drift

* test: fix example preview fetch assertion

* test: fix new project panel skill fixture
2026-05-11 10:49:42 +08:00
976a5900f8 fix: clear stale upload failure banner when previewing files (#797)
* fix: clear stale upload failure banner when previewing existing files

Closes #786

- Clear uploadError in openFile() so navigating to a file dismisses the banner
- Scope banner visibility to the Design Files tab so stale errors do not bleed
  into preview surfaces
- Add test pinning that no banner is rendered when there is no upload error

* fix(workspace): move upload banner into DesignFilesPanel + interactive test

Per @mrcfps + @lefarcen review on PR #797:

- Move the upload-error banner from FileWorkspace into DesignFilesPanel
  body. Hide it whenever the in-panel preview is active (the missed
  flow that mrcfps and lefarcen flagged: single-click preview kept
  activeTab on DESIGN_FILES_TAB, so the old guard left the banner
  mounted above the preview).
- Keep a fallback banner in FileWorkspace that fires only when
  activeTab is not Design Files. This preserves the partial-upload
  visibility flagged by chatgpt-codex-connector: a partial upload
  opens the last successful file (flipping activeTab to a viewer)
  and the failure note still surfaces.
- Wrap uploadProjectFiles in try/catch so thrown errors surface a
  banner instead of disappearing.
- Replace the brittle viewer-empty assertion with two interactive
  vitest cases: (1) mock-fail upload, banner visible, preview file,
  banner hidden, close preview, banner back, dismiss, banner gone;
  (2) partial-upload uploaded+failed, banner appears on the viewer
  surface with the existing 'Uploaded N file(s), but M failed' text.
- Add df-upload-banner class and stable test ids upload-error-banner
  and upload-error-dismiss so future tests don't rely on the
  generic viewer-empty class.

Closes #786 staleness; addresses follow-up review.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: mrcfps <mrc@powerformer.com>
2026-05-10 23:56:24 +08:00
ZihuailinandGitHub 06e677cb72 Fix pending prompt clearing for templates (#1148) 2026-05-10 21:52:49 +08:00
SidandGitHub e948405c22 fix(web): surface connector auth errors and stop silent popup close (#725) (#1128)
* fix(web): surface connector auth errors and stop silent popup close (#725)

Two layered bugs caused the "Twitter Connect button does nothing" symptom:

1. ConnectorsBrowser dropped result.error from connectConnector. On
   Electron desktop the popup is never opened (electronAPI.openExternal
   path), so the existing renderConnectorAuthError(null, ...) was a
   no-op and the user got zero feedback.

2. registry.ts silently called authWindow?.close() whenever the connect
   response did not carry { kind: 'redirect_required', redirectUrl },
   leaving web users with a popup that vanishes without explanation.

Patch:
- Add a per-connector connectorAuthorizationError state and render it
  as an inline banner on both ConnectorCard and ConnectorDetailDrawer
  (mirrors the existing cancel-failed pattern; reuses the existing
  .connector-authorization-error styling).
- Replace authWindow?.close() with a renderConnectorAuthInfo helper
  that branches on auth.kind ('connected' | 'pending' | unknown) and
  writes an explanatory message to the popup before the user closes it.
- Tests: 1 registry test for the pending/info popup branch, 2
  ConnectorsBrowser tests for surfacing and clearing the inline banner.

* fix(web): clear connector auth error on background status refresh

Addresses review feedback from @mrcfps and the Codex bot on PR #1128:
the inline error banner stayed visible even after background status
refresh marked the connector as `connected` (e.g. user completes auth
out-of-band through the Composio dashboard, then focus/poll/message
refresh observes the connection).

- Add clearConnectorAuthorizationErrorsForConnected helper next to the
  existing pending-state helpers; same shape, returns the same object
  reference when nothing changes so React skips a re-render.
- Wire it into reloadConnectorStatuses so every status refresh path
  (pending poll, focus, OAuth callback message) drops stale errors for
  any connector now reported as connected.
- Add 2 unit tests for the helper next to the existing pending-state
  helper tests in EntryView.test.ts.
2026-05-10 19:38:18 +08:00
Priyanshu KayarkarandGitHub eabf3a6e86 feat: add collapsible MCP JSON field-mapping helper (#1136)
* feat(web): add collapsible MCP JSON helper component

* feat(web): add collapsible MCP JSON field-mapping helper

* test(web): add McpJsonHelper component tests for toggle behavior

* fix(web): scope helper id per row and show helper

* test(web): rewrite McpJsonHelper tests to use row-scoped ids

* feat(mcp): use stable _localId for McpRow keys and aria-controls\n\n- Add _localId to DraftRow and genLocalId()\n- Use _localId as React key and helper id to avoid duplicate DOM ids\n- Move helper outside transport branches so helper is visible for all transports\n- Fix malformed template.homepage anchor

* fix(web): restore _localId-scoped helperId and helper visibility for all transports

* test(web): replace integration test with _localId-scoped helper tests

* test(web): exercise McpJsonHelper via production McpClientSection in jsdom

* fix(web): resolve typecheck errors

* test(web):expand rows before querying helper toggles to fix timeout
2026-05-10 19:37:46 +08:00
soulmeandGitHub cbb3c0e33a Improve design files grouping (#1082)
Add a modified-date grouping mode to make busy design workspaces easier to scan as generated files accumulate. The new view keeps existing batch actions and pagination available, adds localized labels, and covers date boundaries with component tests.
2026-05-10 11:55:34 +08:00
587c783dc0 feat(web): add Finalize design package + Continue in CLI buttons (#451) (#974)
* feat(daemon): expose resolvedDir on GET /api/projects/:id (#451 prereq)

Native projects (no metadata.baseDir) live at <projects root>/<id>, where
projects root is daemon-side state. The web client cannot reconstruct an
absolute path on its own, and shell.openPath on a relative path is
undefined behavior. Without resolvedDir, the upcoming Continue in CLI
button (#451) would render permanently disabled for native projects.

Mirrors PR #832's pattern of exposing designMdPath in its response.
Computed via the existing resolveProjectDir(...) helper. No behavior
change to existing callers; they ignore the new field.

Adds ProjectDetailResponse contract type and a focused projects-routes
test covering imported-folder, native, and unknown-id paths.

* feat(web): add parseProvenance helper for DESIGN.md staleness checks

Pure helper that extracts Project ID, design system, current artifact,
transcript message count, and generated UTC timestamp from the
`## Provenance` section emitted by the daemon's finalize synthesis
prompt (apps/daemon/src/finalize-design.ts). Used by useDesignMdState
to derive the Continue in CLI button's stale/fresh state without an
additional daemon endpoint.

Handles missing section, "none" sentinels for design system /
artifact, and malformed timestamps without throwing. Tests cover all
four branches.

* feat(web): add buildClipboardPrompt template for Continue in CLI

Inline single-source-of-truth template per #451 spec §3.4. Names the
project, the working directory, and the DESIGN.md-first operating
contract for the receiving `claude` CLI session. Trailing TODO is
the blank task slot the issue body specifies — left empty so the user
fills it in before submitting.

Also lands the shared copyToClipboard helper (jsdom-safe canonical path
+ execCommand fallback) so the new button and any future caller share
one fallback path, mirroring the inline pattern in FileViewer.tsx.

Tests cover happy-path field rendering, "none"/"unknown" sentinels
when DESIGN.md fields are absent, and both clipboard branches.

* feat(web): add useProjectDetail + useDesignMdState hooks

useProjectDetail wraps GET /api/projects/:id, surfacing the resolvedDir
field and falling back to metadata.baseDir for older daemons that don't
include it. Continue in CLI needs an absolute working directory so the
desktop bridge can openPath it; the web client never reconstructs the
path itself.

useDesignMdState fetches the project's file list, downloads DESIGN.md
when present, parses the Provenance section, and computes a stale
verdict by comparing the recorded generatedAt against the max mtime of
non-DESIGN.md files and the max conversation updatedAt. Drives the
button's three-state UI (disabled / fresh / stale) without a
daemon-side endpoint.

Tests cover happy path, fallback, and both stale branches plus the
pure computeStale helper for the null-timestamp edge case.

* feat(web): add useFinalizeProject hook with cancel + error-code mapping

Wraps POST /api/projects/:id/finalize/anthropic for the Finalize design
package button. Three concerns:

  1. Lifecycle: idle → pending → success | error. Double-clicking the
     button aborts the prior in-flight request before starting a new
     one so the daemon never sees stacked finalize calls per project.

  2. Cancellation: AbortController plumbed through fetch + a 130 s
     timer (daemon timeout 120 s + 10 s buffer). Cancel returns to idle
     cleanly — it's a user gesture, not an error surface.

  3. Daemon error mapping: when the response is non-OK, body.error.code
     drives the canonical user-facing toast string (table covers all
     7 codes the daemon emits today plus a network-error catch-all).
     body.error.details, when a string, surfaces alongside the category
     message so account-usage-cap responses (Anthropic 400 →
     UPSTREAM_UNAVAILABLE) can show the upstream's own reason instead
     of just the daemon's category label — committed to lefarcen on
     #450 verification reply.

Tests cover request body shape, all 8 error codes via it.each, the
network-error path, the details-surfacing branch, the cancel ⇒ idle
flow, and the unknown-code → catch-all message branch.

* feat(web): add useTerminalLaunch with electron/web detection

Capability-detected wrapper around window.electronAPI.openPath. On
desktop the bridge forwards to shell.openPath, which opens the OS
file manager at the project working directory (per Electron's
contract for directory paths — it is NOT a terminal launcher;
spawning a terminal application is deferred per #451 Non-goals). On
browser builds the hook reports web-fallback so the caller renders
a manual-instruction toast naming the working directory.

Treats any non-empty string return from shell.openPath as ok: false
so platform-specific failures surface the manual fallback toast.
Behavior is exercised end-to-end by the upcoming
ContinueInCliButton tests.

* feat(desktop): expose shell.openPath via electronAPI bridge

Adds an openPath bridge method that the Continue in CLI button (#451)
uses to surface the project working directory in the OS file manager.
shell.openPath is part of Electron's contract and resolves to '' on
success / a non-empty error string on failure; the IPC handler
forwards the result so the renderer can decide between the success
toast and the manual fallback toast without a separate error channel.

Empty / non-string inputs short-circuit to a self-describing error
string so the renderer never needs to worry about undefined-input
crashes from the main process.

Web side: extracts Window.electronAPI into a single global declaration
at apps/web/src/types/electron.d.ts so future bridge methods land in
one place. Two pre-existing inline declare-global blocks
(NewProjectPanel.tsx, providers/registry.ts) are deleted in favor of
that single source of truth — the inline ones each carried a partial
shape of the bridge and were diverging from the desktop preload.

* feat(web): add FinalizeDesignButton, ContinueInCliButton, ProjectActionsToolbar

Project-level toolbar that hosts the two new actions from #451.
Mounted between AppChromeHeader and the chat/workspace split (wiring
lands in the next commit). Per-file actions (Export PDF/PPTX/ZIP,
Deploy) stay in the FileViewer share menu.

FinalizeDesignButton has three idle labels driven by DESIGN.md
existence + staleness, plus a pending state with a spinner and a
cancel link that maps to useFinalizeProject's AbortController. Error
toasts are owned by ProjectView so the button doesn't carry its own
toast surface.

ContinueInCliButton renders disabled with a Finalize-pointing
tooltip when DESIGN.md is missing (so the workflow is discoverable
rather than hidden), enabled when fresh, and enabled with a stale
chip otherwise. Chip text is the spec's canonical "Spec is stale —
regenerate?" — N-turns-ago is deferred per spec §4.6.

Toast.tsx is a tiny transient component that mirrors
PromptTemplatePreviewModal's state-based toast pattern; supports a
secondary details line so daemon error envelopes that carry an
upstream explanation (e.g. Anthropic account-usage cap) can surface
the real reason alongside the daemon's category label.

CSS appends one block to apps/web/src/index.css mirroring the
existing app-project-title token usage; no CSS modules in this
repo (verified by grep).

* test(web): cover ContinueInCliButton states + interaction wiring

Three rendered states (DESIGN.md missing → disabled with the
Finalize-pointing tooltip; DESIGN.md fresh → enabled, no chip;
DESIGN.md stale → enabled with the canonical "Spec is stale —
regenerate?" chip), plus three onClick branches (no-op when
disabled, fires once when fresh, fires once when stale).

Click-handler integration with clipboard / shell.openPath / toast
lives in ProjectView (the button is presentational and takes the
handler in via props), so those are covered by Phase K's wiring +
the manual smoke test rather than the per-component test.

* feat(web): wire Continue in CLI + Finalize buttons into ProjectView

Mounts the new project-actions toolbar between AppChromeHeader and
the chat/workspace split, hidden when workspaceFocused so the
focus-mode artifact view stays uncluttered.

Wires the four hooks (useProjectDetail, useDesignMdState,
useFinalizeProject, useTerminalLaunch) to a single shared toast
surface. handleFinalize reads the request body from the existing
config: AppConfig prop and uses effectiveMaxTokens(config) to match
the chat-flow's maxTokens defaulting; on success it refreshes
useDesignMdState so the toolbar re-renders with the new chip state.

handleContinueInCli builds the literal clipboard prompt, copies it,
opens the working directory via shell.openPath on desktop /
falls through to a manual-instruction toast on browser, and surfaces
shell.openPath failures with a fallback toast that names the path.

Errors lift into the same toast surface (a useEffect tied to
finalize.error) so the daemon's category message + body.error.details
reach the user as the spec's two-line render — covered by hook test
16a in the prior commit.

⌘+Shift+K (mac) / Ctrl+Shift+K (others) is the keyboard
accelerator for Continue in CLI; capture-phase, platform-gated,
no-op when DESIGN.md is missing. Mirrors the existing FileWorkspace
shortcut idiom and does not collide with ⌘+P (Quick Switcher).

* fix(web): distinguish timeout abort from user cancel in useFinalizeProject

Addresses codex P2 finding on PR #974: the catch block treated every
AbortError as a user-initiated cancel and reset to idle silently. If
the internal 130 s timeout fired, users saw no failure signal but the
daemon's synthesis call may still have been in flight.

Adds a timedOutRef set inside the setTimeout callback before
controller.abort(), and branches in the catch: timeout → status
'error' with new TIMEOUT code ("Finalize timed out after 130 s. The
daemon may still be running."), user cancel → existing idle reset.
Reset the ref at the start of every trigger() so a previous timeout
doesn't poison the next call.

Adds one test using vi.useFakeTimers() that advances past 130_001 ms
and asserts the TIMEOUT error surface.

* fix(web): surface clipboard failures by rendering the prompt in the toast

Addresses codex P2 finding on PR #974: handleContinueInCli ignored
copyToClipboard's return value, so when both clipboard paths failed
(restricted browser context / insecure origin) the toast still said
"paste the prompt" though nothing had been copied — leaving users
with no manual-copy recourse in exactly the environments where the
fallback should help.

handleContinueInCli now branches on copyToClipboard's boolean return.
On failure the toast renders the prepared prompt in a scrollable
<pre> block and pins itself open (no auto-dismiss) so the user has
time to select-and-copy manually. Includes a Dismiss button + the
working directory in the secondary details line so the user has the
information needed to proceed.

The folder-open call is skipped on copy failure because there's
nothing to paste yet; the user copies first, then re-clicks Continue
in CLI when they're ready.

Toast component grows an optional Updating VS Code Server to version 41dd792b5e652393e7787322889ed5fdc58bd75b
Removing previous installation...
Installing VS Code Server for Linux x64 (41dd792b5e652393e7787322889ed5fdc58bd75b)
Downloading:       0%  0%  0%  0%  0%  0%  0%  0%  0%  0%  0%  0%  0%  0%  0%  0%  0%  1%  1%  1%  1%  1%  1%  1%  1%  1%  1%  1%  1%  1%  1%  1%  1%  1%  1%  2%  2%  2%  2%  2%  2%  2%  2%  2%  2%  2%  2%  2%  2%  2%  2%  2%  2%  3%  3%  3%  3%  3%  3%  3%  3%  3%  3%  3%  3%  3%  3%  3%  3%  3%  3%  4%  4%  4%  4%  4%  4%  4%  4%  4%  4%  4%  4%  4%  4%  4%  4%  4%  4%  5%  5%  5%  5%  5%  5%  5%  5%  5%  5%  5%  5%  5%  5%  5%  5%  5%  5%  6%  6%  6%  6%  6%  6%  6%  6%  6%  6%  6%  6%  6%  6%  6%  6%  6%  6%  7%  7%  7%  7%  7%  7%  7%  7%  7%  7%  7%  7%  7%  7%  7%  7%  7%  7%  8%  8%  8%  8%  8%  8%  8%  8%  8%  8%  8%  8%  8%  8%  8%  8%  8%  8%  9%  9%  9%  9%  9%  9%  9%  9%  9%  9%  9%  9%  9%  9%  9%  9%  9% 10% 10% 10% 10% 10% 10% 10% 10% 10% 10% 10% 10% 10% 10% 10% 10% 10% 10% 11% 11% 11% 11% 11% 11% 11% 11% 11% 11% 11% 11% 11% 11% 11% 11% 11% 11% 12% 12% 12% 12% 12% 12% 12% 12% 12% 12% 12% 12% 12% 12% 12% 12% 12% 12% 13% 13% 13% 13% 13% 13% 13% 13% 13% 13% 13% 13% 13% 13% 13% 13% 13% 13% 14% 14% 14% 14% 14% 14% 14% 14% 14% 14% 14% 14% 14% 14% 14% 14% 14% 14% 15% 15% 15% 15% 15% 15% 15% 15% 15% 15% 15% 15% 15% 15% 15% 15% 15% 15% 16% 16% 16% 16% 16% 16% 16% 16% 16% 16% 16% 16% 16% 16% 16% 16% 16% 16% 17% 17% 17% 17% 17% 17% 17% 17% 17% 17% 17% 17% 17% 17% 17% 17% 17% 17% 18% 18% 18% 18% 18% 18% 18% 18% 18% 18% 18% 18% 18% 18% 18% 18% 18% 19% 19% 19% 19% 19% 19% 19% 19% 19% 19% 19% 19% 19% 19% 19% 19% 19% 19% 20% 20% 20% 20% 20% 20% 20% 20% 20% 20% 20% 20% 20% 20% 20% 20% 20% 20% 21% 21% 21% 21% 21% 21% 21% 21% 21% 21% 21% 21% 21% 21% 21% 21% 21% 21% 22% 22% 22% 22% 22% 22% 22% 22% 22% 22% 22% 22% 22% 22% 22% 22% 22% 22% 23% 23% 23% 23% 23% 23% 23% 23% 23% 23% 23% 23% 23% 23% 23% 23% 23% 23% 24% 24% 24% 24% 24% 24% 24% 24% 24% 24% 24% 24% 24% 24% 24% 24% 24% 24% 25% 25% 25% 25% 25% 25% 25% 25% 25% 25% 25% 25% 25% 25% 25% 25% 25% 25% 26% 26% 26% 26% 26% 26% 26% 26% 26% 26% 26% 26% 26% 26% 26% 26% 26% 26% 27% 27% 27% 27% 27% 27% 27% 27% 27% 27% 27% 27% 27% 27% 27% 27% 27% 28% 28% 28% 28% 28% 28% 28% 28% 28% 28% 28% 28% 28% 28% 28% 28% 28% 28% 29% 29% 29% 29% 29% 29% 29% 29% 29% 29% 29% 29% 29% 29% 29% 29% 29% 29% 30% 30% 30% 30% 30% 30% 30% 30% 30% 30% 30% 30% 30% 30% 30% 30% 30% 30% 31% 31% 31% 31% 31% 31% 31% 31% 31% 31% 31% 31% 31% 31% 31% 31% 31% 31% 32% 32% 32% 32% 32% 32% 32% 32% 32% 32% 32% 32% 32% 32% 32% 32% 32% 32% 33% 33% 33% 33% 33% 33% 33% 33% 33% 33% 33% 33% 33% 33% 33% 33% 33% 33% 34% 34% 34% 34% 34% 34% 34% 34% 34% 34% 34% 34% 34% 34% 34% 34% 34% 34% 35% 35% 35% 35% 35% 35% 35% 35% 35% 35% 35% 35% 35% 35% 35% 35% 35% 35% 36% 36% 36% 36% 36% 36% 36% 36% 36% 36% 36% 36% 36% 36% 36% 36% 36% 37% 37% 37% 37% 37% 37% 37% 37% 37% 37% 37% 37% 37% 37% 37% 37% 37% 37% 38% 38% 38% 38% 38% 38% 38% 38% 38% 38% 38% 38% 38% 38% 38% 38% 38% 38% 39% 39% 39% 39% 39% 39% 39% 39% 39% 39% 39% 39% 39% 39% 39% 39% 39% 39% 40% 40% 40% 40% 40% 40% 40% 40% 40% 40% 40% 40% 40% 40% 40% 40% 40% 40% 41% 41% 41% 41% 41% 41% 41% 41% 41% 41% 41% 41% 41% 41% 41% 41% 41% 41% 42% 42% 42% 42% 42% 42% 42% 42% 42% 42% 42% 42% 42% 42% 42% 42% 42% 42% 43% 43% 43% 43% 43% 43% 43% 43% 43% 43% 43% 43% 43% 43% 43% 43% 43% 43% 44% 44% 44% 44% 44% 44% 44% 44% 44% 44% 44% 44% 44% 44% 44% 44% 44% 44% 45% 45% 45% 45% 45% 45% 45% 45% 45% 45% 45% 45% 45% 45% 45% 45% 45% 46% 46% 46% 46% 46% 46% 46% 46% 46% 46% 46% 46% 46% 46% 46% 46% 46% 46% 47% 47% 47% 47% 47% 47% 47% 47% 47% 47% 47% 47% 47% 47% 47% 47% 47% 47% 48% 48% 48% 48% 48% 48% 48% 48% 48% 48% 48% 48% 48% 48% 48% 48% 48% 48% 49% 49% 49% 49% 49% 49% 49% 49% 49% 49% 49% 49% 49% 49% 49% 49% 49% 49% 50% 50% 50% 50% 50% 50% 50% 50% 50% 50% 50% 50% 50% 50% 50% 50% 50% 50% 51% 51% 51% 51% 51% 51% 51% 51% 51% 51% 51% 51% 51% 51% 51% 51% 51% 51% 52% 52% 52% 52% 52% 52% 52% 52% 52% 52% 52% 52% 52% 52% 52% 52% 52% 52% 53% 53% 53% 53% 53% 53% 53% 53% 53% 53% 53% 53% 53% 53% 53% 53% 53% 53% 54% 54% 54% 54% 54% 54% 54% 54% 54% 54% 54% 54% 54% 54% 54% 54% 54% 55% 55% 55% 55% 55% 55% 55% 55% 55% 55% 55% 55% 55% 55% 55% 55% 55% 55% 56% 56% 56% 56% 56% 56% 56% 56% 56% 56% 56% 56% 56% 56% 56% 56% 56% 56% 57% 57% 57% 57% 57% 57% 57% 57% 57% 57% 57% 57% 57% 57% 57% 57% 57% 57% 58% 58% 58% 58% 58% 58% 58% 58% 58% 58% 58% 58% 58% 58% 58% 58% 58% 58% 59% 59% 59% 59% 59% 59% 59% 59% 59% 59% 59% 59% 59% 59% 59% 59% 59% 59% 60% 60% 60% 60% 60% 60% 60% 60% 60% 60% 60% 60% 60% 60% 60% 60% 60% 60% 61% 61% 61% 61% 61% 61% 61% 61% 61% 61% 61% 61% 61% 61% 61% 61% 61% 61% 62% 62% 62% 62% 62% 62% 62% 62% 62% 62% 62% 62% 62% 62% 62% 62% 62% 62% 63% 63% 63% 63% 63% 63% 63% 63% 63% 63% 63% 63% 63% 63% 63% 63% 63% 64% 64% 64% 64% 64% 64% 64% 64% 64% 64% 64% 64% 64% 64% 64% 64% 64% 64% 65% 65% 65% 65% 65% 65% 65% 65% 65% 65% 65% 65% 65% 65% 65% 65% 65% 65% 66% 66% 66% 66% 66% 66% 66% 66% 66% 66% 66% 66% 66% 66% 66% 66% 66% 66% 67% 67% 67% 67% 67% 67% 67% 67% 67% 67% 67% 67% 67% 67% 67% 67% 67% 67% 68% 68% 68% 68% 68% 68% 68% 68% 68% 68% 68% 68% 68% 68% 68% 68% 68% 68% 69% 69% 69% 69% 69% 69% 69% 69% 69% 69% 69% 69% 69% 69% 69% 69% 69% 69% 70% 70% 70% 70% 70% 70% 70% 70% 70% 70% 70% 70% 70% 70% 70% 70% 70% 70% 71% 71% 71% 71% 71% 71% 71% 71% 71% 71% 71% 71% 71% 71% 71% 71% 71% 71% 72% 72% 72% 72% 72% 72% 72% 72% 72% 72% 72% 72% 72% 72% 72% 72% 72% 73% 73% 73% 73% 73% 73% 73% 73% 73% 73% 73% 73% 73% 73% 73% 73% 73% 73% 74% 74% 74% 74% 74% 74% 74% 74% 74% 74% 74% 74% 74% 74% 74% 74% 74% 74% 75% 75% 75% 75% 75% 75% 75% 75% 75% 75% 75% 75% 75% 75% 75% 75% 75% 75% 76% 76% 76% 76% 76% 76% 76% 76% 76% 76% 76% 76% 76% 76% 76% 76% 76% 76% 77% 77% 77% 77% 77% 77% 77% 77% 77% 77% 77% 77% 77% 77% 77% 77% 77% 77% 78% 78% 78% 78% 78% 78% 78% 78% 78% 78% 78% 78% 78% 78% 78% 78% 78% 78% 79% 79% 79% 79% 79% 79% 79% 79% 79% 79% 79% 79% 79% 79% 79% 79% 79% 79% 80% 80% 80% 80% 80% 80% 80% 80% 80% 80% 80% 80% 80% 80% 80% 80% 80% 80% 81% 81% 81% 81% 81% 81% 81% 81% 81% 81% 81% 81% 81% 81% 81% 81% 81% 81% 82% 82% 82% 82% 82% 82% 82% 82% 82% 82% 82% 82% 82% 82% 82% 82% 82% 83% 83% 83% 83% 83% 83% 83% 83% 83% 83% 83% 83% 83% 83% 83% 83% 83% 83% 84% 84% 84% 84% 84% 84% 84% 84% 84% 84% 84% 84% 84% 84% 84% 84% 84% 84% 85% 85% 85% 85% 85% 85% 85% 85% 85% 85% 85% 85% 85% 85% 85% 85% 85% 85% 86% 86% 86% 86% 86% 86% 86% 86% 86% 86% 86% 86% 86% 86% 86% 86% 86% 86% 87% 87% 87% 87% 87% 87% 87% 87% 87% 87% 87% 87% 87% 87% 87% 87% 87% 87% 88% 88% 88% 88% 88% 88% 88% 88% 88% 88% 88% 88% 88% 88% 88% 88% 88% 88% 89% 89% 89% 89% 89% 89% 89% 89% 89% 89% 89% 89% 89% 89% 89% 89% 89% 89% 90% 90% 90% 90% 90% 90% 90% 90% 90% 90% 90% 90% 90% 90% 90% 90% 90% 90% 91% 91% 91% 91% 91% 91% 91% 91% 91% 91% 91% 91% 91% 91% 91% 91% 91% 92% 92% 92% 92% 92% 92% 92% 92% 92% 92% 92% 92% 92% 92% 92% 92% 92% 92% 93% 93% 93% 93% 93% 93% 93% 93% 93% 93% 93% 93% 93% 93% 93% 93% 93% 93% 94% 94% 94% 94% 94% 94% 94% 94% 94% 94% 94% 94% 94% 94% 94% 94% 94% 94% 95% 95% 95% 95% 95% 95% 95% 95% 95% 95% 95% 95% 95% 95% 95% 95% 95% 95% 96% 96% 96% 96% 96% 96% 96% 96% 96% 96% 96% 96% 96% 96% 96% 96% 96% 96% 97% 97% 97% 97% 97% 97% 97% 97% 97% 97% 97% 97% 97% 97% 97% 97% 97% 97% 98% 98% 98% 98% 98% 98% 98% 98% 98% 98% 98% 98% 98% 98% 98% 98% 98% 98% 99% 99% 99% 99% 99% 99% 99% 99% 99% 99% 99% 99% 99% 99% 99% 99% 99% 99%100%100%
Unpacking:   0%  1%  2%  3%  4%  5%  6%  7%  8%  9% 10% 11% 12% 13% 14% 15% 16% 17% 18% 19% 20% 21% 22% 23% 24% 25% 26% 27% 28% 29% 30% 31% 32% 33% 34% 35% 36% 37% 38% 39% 40% 41% 42% 43% 44% 45% 46% 47% 48% 49% 50% 51% 52% 53% 54% 55% 56% 57% 58% 59% 60% 61% 62% 63% 64% 65% 66% 67% 68% 69% 70% 71% 72% 73% 74% 75% 76% 77% 78% 79% 80% 81% 82% 83% 84% 85% 86% 87% 88% 89% 90% 91% 92% 93% 94% 95% 96% 97% 98% 99%100%
Unpacked 4009 files and folders to /home/bryan/.vscode-server/bin/41dd792b5e652393e7787322889ed5fdc58bd75b.
Looking for compatibility check script at /home/bryan/.vscode-server/bin/41dd792b5e652393e7787322889ed5fdc58bd75b/bin/helpers/check-requirements.sh
Running compatibility check script
Compatibility check successful (0) prop and the auto-dismiss
TTL is suppressed whenever code is present. CSS adds .od-toast-code
(monospace, max-height 240 with overflow-auto) and .od-toast-dismiss
styling.

Six new Toast tests cover details rendering, code rendering,
no-auto-dismiss when code is present, auto-dismiss when code is
absent, and the Dismiss button affordance.

* fix(web): make ContinueInCliButton disabled-state guidance visible

Addresses mrcfps's PR #974 review: native <button disabled> does
not fire hover/focus events in browsers we ship against, so a
`title` tooltip on the disabled button never surfaces. The only
guidance for the missing-DESIGN.md state was effectively invisible —
defeating the spec's "discoverable, not hidden" intent.

Renders the help text as a visible sibling <span> next to the
disabled button instead. Adds aria-describedby pointing the button
at the hint's id so assistive tech announces the explanation when
the disabled button gets focus. The native `disabled` attribute
stays so the button still can't be clicked or submitted.

CSS adds .project-actions-disabled-hint (muted italic, 11.5px,
matches the existing meta/secondary text style on this surface).

Test asserts the role="note" hint is in the DOM with the canonical
text and that the button's aria-describedby links to its id.

* fix(web): keep ProjectActionsToolbar at natural height inside the .app grid

The .app container was `grid-template-rows: auto 1fr` — only two
rows. Adding ProjectActionsToolbar as a third child between
AppChromeHeader and the chat/workspace split made the toolbar the
2nd grid item, so it took the `1fr` row (filling roughly half the
viewport) while the split got pushed into an implicit auto row at
its content's natural height. Surfaced as a screenshot from Bryan
showing the toolbar's background bleeding across most of the screen.

Extend grid-template-rows to `auto auto 1fr` and pin the split to
`grid-row: 3` explicitly. Now:
- Toolbar visible: row 1 = header (auto), row 2 = toolbar (auto),
  row 3 = split (1fr, fills remaining viewport).
- Toolbar hidden via hidden=workspaceFocused → ProjectActionsToolbar
  returns null, row 2 collapses to 0px (auto with no content), split
  still fills row 3.

No JS changes; existing 609 tests still green.

* fix(web): guard useFinalizeProject state writes against superseded triggers

Addresses mrcfps's PR #974 P1 review on useFinalizeProject.ts:132
(also called out as P1.3 in lefarcen's deep-dive review).

Calling trigger() twice in quick succession aborted the first
controller and swapped abortRef to the new one, but the first
request's later AbortError catch still unconditionally called
setStatus('idle') / setError(null). That cleared the spinner and
re-enabled both toolbar buttons while the replacement finalize was
still pending — defeating the de-duplication this hook was meant to
enforce.

Adds an isCurrent() closure (`abortRef.current === controller`)
and gates every state-write site after the await: success path,
non-OK envelope path, AbortError-timeout, AbortError-cancel, and
network-error all bail early when the trigger has been superseded.
Per mrcfps: "make every state write request-scoped."

Regression test triggers twice in quick succession with a
never-resolving fetch, awaits the first promise (it rejects with
AbortError), and asserts status stays 'pending' rather than
collapsing to 'idle' under the replacement's lifetime.

* fix(desktop): allowlist-validate shell.openPath against registered project roots

Addresses mrcfps's PR #974 P1 review on runtime.ts:305 (also called
out as P1.2 in lefarcen's deep-dive review): the new
`shell:open-path` IPC handler accepted any renderer-supplied
string and forwarded it straight into Electron's `shell.openPath`,
widening the renderer→main trust boundary so XSS or a compromised
renderer dependency could open arbitrary local paths to the user.

Adds an explicit gate around the bridge:

  1. validateExistingDirectory(p) — floor check that rejects empty
     strings, relative paths, files, apps, and non-existent paths;
     realpath-resolves so symlink games can't be used to register
     one path and reach another.

  2. createProjectRootGate() — Set-backed allowlist of
     daemon-validated project working directories. The renderer
     calls registerProjectRoot(absDir) once per project mount via
     a new IPC method (preload bridge); the main process only
     opens paths that pass both the floor check and the allowlist.

ProjectView wires the registration via a useEffect tied to
projectDetail.resolvedDir, so the active project's daemon-supplied
working directory is always the one being approved (not a renderer-
synthesized string).

Threat-model caveat documented in the runtime.ts comment block: an
attacker that fully controls the renderer can also call register
with arbitrary paths. Closing that gap fully requires a daemon-side
round-trip to derive the canonical resolvedDir from the daemon's
project registry, which is deferred to keep this PR focused.
Today's allowlist still defends against accidental misuse, bugs,
and common XSS payloads that don't know to call register first.

Adds apps/packaged/tests/desktop-project-root-gate.test.ts with 13
cases: floor-validation rejection cases (empty / relative / missing
/ file), happy-path resolution, symlink realpath canonicalization,
and the allowlist's register/isApproved/reset semantics. Mirrors
the existing apps/packaged/tests/desktop-url-allowlist.test.ts
pattern from PR #911 — the packaged workspace hosts the test
because apps/desktop has no vitest setup yet.

* fix(daemon): wire request-lifecycle abort signal through finalize route

Addresses mrcfps's PR #974 P1 review on
apps/daemon/src/server.ts:3831-3837 (also called out as P1.1 in
lefarcen's deep-dive review): `POST /api/projects/:id/finalize/anthropic`
called `finalizeDesignPackage(...)` without threading any
request-lifecycle abort, so cancelling the browser fetch only
aborted the UI-side request — the daemon's 60–120 s Anthropic call
kept running and still wrote DESIGN.md after the UI returned to idle.

Adds an AbortController inside the route handler, fired from
`res.on('close')`, and threads its signal into the existing
`signal?: AbortSignal` parameter on `FinalizeOptions`
(finalize-design.ts:70). `callAnthropicWithRetry` already passes
the signal through to the underlying fetch, so a client disconnect
now propagates all the way to the Anthropic SDK call.

Listener-event choice: `res.on('close')` is the canonical event
for "client disconnected before response was sent" in Express. The
common alternative `req.on('close')` fires whenever the *request*
stream finishes — for POST routes that means as soon as the
body-parser middleware drains the body, well before the route does
any work. Using req.on('close') would have flipped the abort
controller in every successful run; the test caught this empirically.

Caveat documented in the route's comment block: an abort fired
*after* the upstream response has been received but *before* the
atomic write completes still allows the write to land. The SDK
contract bounds the network round-trip, not the post-network disk
handoff.

Adds tests/finalize-route-abort.test.ts: spins up the test server,
mocks global fetch to capture the daemon-side AbortSignal at the
Anthropic call, sends the request via raw http (so we can destroy
the underlying socket), waits until the server reaches the
Anthropic call, then destroys the socket and asserts that the
daemon-side signal received an abort event within 5 s.

Three pre-existing project-watchers chokidar tests show flaky
timeouts under full-suite concurrency but pass in isolation;
unrelated to this fix.

* fix(daemon): refactor finalize-route-abort test to satisfy strict TS narrowing

The CI typecheck (`pnpm --filter @open-design/daemon typecheck`,
which runs both tsconfig.json and tsconfig.tests.json) caught what
my pre-push validation missed: TS narrowed `capturedSignal` to
literal `null` because vitest's mockImplementation closure can't
prove its callback runs, leaving the bare `let capturedSignal:
AbortSignal | null = null` permanently typed at its initial value.
At line 184 (`expect(capturedSignal?.aborted).toBe(true)`) the
right-hand side of the optional-chain became unreachable, and TS
flagged it as `Property 'aborted' does not exist on type 'never'`.

Switches to the standard ref-object pattern
(`const capture: { signal: AbortSignal | null } = { signal: null }`).
TS narrows let bindings inside closures conservatively but treats
object-property writes as opaque, so `capture.signal` reads
correctly across the closure boundary. Logic is unchanged.

(Pre-push oversight: ran `pnpm --filter @open-design/web typecheck`
but not the full repo `pnpm typecheck` after the daemon test
landed; the daemon's own typecheck would have caught this. Adding
`pnpm typecheck` back into the standard pre-push checklist.)

* fix(desktop): make shell.openPath gate daemon-controlled and reject .app bundles

Addresses lefarcen + mrcfps PR #974 P1 reviews on the previous path
allowlist (commit 8bf56597):

  - mrcfps (runtime.ts:45): `validateExistingDirectory` accepted
    macOS `.app` bundles because they're directories, so the gate
    would forward `/Applications/Safari.app` (or any other app
    bundle) into shell.openPath and *launch* the application — a
    stronger capability than the bridge's intended "reveal the
    project folder" feature.

  - lefarcen (runtime.ts:396): the allowlist was renderer-controlled.
    A compromised renderer could call `shell:register-project-root`
    with any existing absolute directory and then `shell:open-path`
    that same path; the IPC injection issue I'd documented as
    "deferred" was the central reviewer concern, not an acceptable
    caveat. Both reviewers asked for the gate to be derived from
    a daemon-authoritative source.

The redesign drops the renderer-controlled register/openPath pair
and replaces it with a single `openPath(projectId)` bridge call.
The desktop main process resolves the project ID by calling the
daemon's `GET /api/projects/:id` endpoint over the web sidecar
proxy (which already forwards `/api/*` to the daemon — verified
in apps/web/sidecar/server.ts:209 and apps/web/next.config.ts:77),
parses `resolvedDir` from the response, validates it against the
floor (absolute, exists, is-directory, not .app), and only then
forwards to `shell.openPath`. The renderer never names the path
directly, so a compromised renderer cannot escalate to opening
arbitrary local paths — it can only name a project the daemon
already knows about, and the canonical path comes from the daemon's
own response.

Surface changes:

  - `runtime.ts`: `createProjectRootGate` removed.
    `fetchResolvedProjectDir(webUrl, projectId, fetchImpl?)` added.
    `validateExistingDirectory` rejects `.app` suffix after the
    realpath check (so symlinked launders are caught too).
    `shell:open-path` handler signature changes from `(path)` to
    `(projectId)`; `shell:register-project-root` handler removed.

  - `preload.cts`: `openPath(projectId)`; `registerProjectRoot`
    removed from the bridge surface.

  - `apps/web/src/types/electron.d.ts`: type updated to match.

  - `useTerminalLaunch.ts`: `open(projectId)` instead of
    `open(dir)`.

  - `ProjectView.tsx`: passes `project.id` to
    `terminalLauncher.open`; the registerProjectRoot useEffect is
    deleted. Toast text still reads `projectDir` (from
    `useProjectDetail.resolvedDir`) for fallback messages — the
    *display* path is independent of the *open* mechanism.

  - `apps/packaged/tests/desktop-project-root-gate.test.ts`:
    rewritten to cover `validateExistingDirectory` (8 cases
    including the new `.app` suffix and symlinked-bundle rejection)
    and `fetchResolvedProjectDir` (8 cases including empty/invalid
    project ids, daemon HTTP success/failure, missing resolvedDir,
    network error, and URL canonicalization).

Total: 16 passing tests, ~330 LOC churn including test rewrites.

Lesson learned (from the iteration loop, not the code): when a
reviewer asks for "ideally X, or at least Y," shipping Y with a
deferred-X note flags the gap rather than fixing it. Either ship X
or argue Y is sufficient; don't middle-ground.

* feat(contracts,sidecar-proto): add desktop-auth IPC + fromTrustedPicker

Schema-only prep for the PR #974 round-3 fix. Adds the two type
extensions the daemon HTTP gate and the desktop main process will
build on:

- packages/sidecar-proto: SIDECAR_MESSAGES.REGISTER_DESKTOP_AUTH, with a
  base64-validated `{ secret }` payload + RegisterDesktopAuthResult.
  Updates normalizeDaemonSidecarMessage to accept the new message and
  pins both branches (accept + reject) in tests/index.test.ts.

- packages/contracts: ProjectMetadata.fromTrustedPicker — a marker the
  daemon stamps on folder-imported projects whose POST /api/import/folder
  passed the desktop HMAC gate. The marker is privileged in the same
  way as `baseDir`: only the gated import handler sets it, and the
  desktop main process refuses to forward `shell.openPath` for
  folder-imported projects whose metadata lacks it.

* fix(daemon): gate /api/import/folder on desktop HMAC token

Closes the renderer→arbitrary-baseDir→shell.openPath bypass chain
flagged by lefarcen and mrcfps in round 3 of PR #974. Both reviewers
converged on the same gap: the previous round only moved path
resolution into the daemon, but renderer JS could still POST
/api/import/folder with any absolute path, get a project ID back, and
then call openPath(projectId) to reveal the attacker-chosen path.

Daemon-side closure:

- New module-scope desktop auth secret + setter exported from
  apps/daemon/src/server.ts. The secret is null at boot (web/standalone
  mode unaffected) and gets set when the desktop main process
  registers it over the daemon's sidecar IPC.

- New `verifyDesktopImportToken` pure helper. Verifies tokens shaped
  `${nonce}~${exp}~${signature}` against HMAC-SHA256(secret, baseDir +
  "\n" + nonce + "\n" + exp). Field separator is `~` (not `.`) because
  ISO 8601 expiries embed dots; `~` is in neither base64url nor ISO
  8601 character sets. Rejects expired tokens, replayed nonces, and
  expiries beyond 2× the 60s TTL.

- New middleware on POST /api/import/folder. When the secret is set,
  every request must carry a valid `X-OD-Desktop-Import-Token` header
  bound to the requested baseDir. Rejected requests return 403 with
  FORBIDDEN. When the secret is unset (no desktop registered), the
  route is unchanged so web-only deployments and standalone daemons
  keep working.

- Trusted imports get `metadata.fromTrustedPicker: true` stamped on
  the project. POST /api/projects and PATCH /api/projects/:id reject
  any client-supplied `fromTrustedPicker` (privileged the same way as
  `baseDir`), and the PATCH preservation block re-stamps the marker
  on partial-metadata patches so it cannot be silently stripped.

- Daemon sidecar IPC handler: REGISTER_DESKTOP_AUTH calls
  setDesktopAuthSecret with the base64-decoded secret. The HTTP and
  IPC servers share a process so the registration takes effect
  immediately for the next inbound /api/import/folder call.

Tests:

- apps/daemon/tests/desktop-import-token-gate.test.ts (15 cases): web
  mode acceptance, no-token rejection, malformed-token rejection,
  wrong-secret rejection, wrong-baseDir rejection, expired rejection,
  oversized-window rejection, valid mint + trusted-picker stamp +
  replay rejection, plus 6 pure-helper cases for verifyDesktopImportToken.
  afterAll() clears the secret to keep the shared HTTP server clean
  for sibling test files.

- apps/daemon/tests/projects-routes.test.ts (+2 cases): POST and PATCH
  reject `fromTrustedPicker` in client-supplied metadata.

Existing folder-import-route.test.ts continues to pass because none of
those tests register a desktop secret; the gate stays dormant.

* fix(desktop,web): atomic pickAndImport replacing pickFolder; openPath trusted-picker check

Closes the renderer→arbitrary-baseDir bypass at the bridge boundary.
The renderer no longer receives a raw filesystem path from the main
process; the picker dialog and the import call live in a single
main-process transaction.

Desktop main:

- runDesktopMain generates a per-process 32-byte secret and registers
  it with the daemon over the daemon's sidecar IPC *before* the
  BrowserWindow is created. registerDesktopAuthWithDaemon retries a
  few times because tools-dev / tools-pack spawn daemon, web, and
  desktop as siblings, so the daemon may not be listening yet on
  desktop boot. A failed registration logs a warning and the runtime
  refuses pickAndImport calls (no secret → no token can be minted).

- runtime.ts replaces the `dialog:pick-folder` IPC with
  `dialog:pick-and-import`. The handler shows the picker, mints an
  HMAC token bound to the chosen path, POSTs /api/import/folder via
  the discovered web URL with the token + body, and returns the
  daemon's ImportFolderResponse to the renderer (or a structured
  failure envelope). Renderer never sees the path or the token.

- shell:open-path now consults a new pure helper
  `isOpenPathAllowedForProject` that refuses folder-imported projects
  whose metadata lacks `fromTrustedPicker: true`. This is the literal
  interpretation of mrcfps's round-3 follow-up: openPath is gated to
  projects whose resolvedDir came from the trusted-picker flow, not
  just transitively via the import gate. Native projects (no
  baseDir → daemon-owned <projectsRoot>/<id>) are always safe to open.

- fetchResolvedProjectDir now returns a `ResolvedProjectDirContext`
  with hasBaseDir + fromTrustedPicker so the openPath handler can
  enforce the marker check.

- New `signDesktopImportToken` pure helper mirrors the daemon-side
  signer with the same `~`-separated wire shape, exported for the
  packaged workspace's test file.

Preload bridge:

- `pickFolder` is deleted. The new `pickAndImport(init?)` returns the
  daemon's import response or a structured failure. `openPath` keeps
  its existing signature; its trust gate now lives in the main
  process.

Web renderer:

- electron.d.ts drops `pickFolder` and adds `pickAndImport` with the
  shared DesktopPickAndImportResult union pulled from contracts.

- NewProjectPanel: when running on Electron (pickAndImport bridge
  present), the "Open folder" button calls pickAndImport atomically
  and forwards the response through a new `onImportFolderResponse`
  prop. On web (no bridge), the existing manual baseDir input keeps
  working — browser builds have no shell.openPath surface so a
  renderer-named path cannot escalate.

- EntryView and App.tsx pass through the new callback. App's
  `handleImportFolderResponse` updates state from the response without
  a second fetch (the import already happened in the main process).

Tests (apps/packaged/tests/desktop-project-root-gate.test.ts):

- 3 cases for `isOpenPathAllowedForProject`: native allowed,
  trusted-picker allowed, legacy folder-import refused.

- 6 cases for `signDesktopImportToken`: shape (~-separated), determinism,
  signature flips when secret/baseDir/nonce/exp changes.

- Existing fetchResolvedProjectDir cases extended for the new
  `context` shape and additional cases that prove the metadata
  inspection (hasBaseDir, fromTrustedPicker) reads the daemon
  response correctly.

* fix(daemon): make desktop import-folder gate fail-closed (PR #974 round 4)

lefarcen P1 on round 3 of PR #974: the gate's `secret == null → accept`
branch (originally intended to keep web-only deployments unaffected)
let a renderer bypass the import boundary in two real desktop edges:

- Startup race: desktop's REGISTER_DESKTOP_AUTH IPC hasn't reached the
  daemon yet, but the renderer is already alive in the BrowserWindow
  and races to fetch /api/import/folder directly with arbitrary baseDir.
- Daemon restart mid-session: the new daemon process boots tokenless
  while a desktop is still running. Same shape: renderer fetches the
  route, daemon falls through to "web mode", accepts the untrusted
  baseDir. shell.openPath rejects (no fromTrustedPicker marker) but
  the daemon's other file APIs (read/write project files, list
  directories) operate on the attacker-chosen path.

Two coordinated mechanisms close that:

(1) Sticky in-process flag. `desktopAuthEverRegistered` flips to true
    on first non-null `setDesktopAuthSecret(...)` and never goes back.
    setDesktopAuthSecret(null) (used by tests) does NOT relax the gate
    so production code can never silently fall back to fail-open. Add
    `resetDesktopAuthForTests()` for vitest cleanup.

(2) Orchestrator-pinned mode via OD_REQUIRE_DESKTOP_AUTH=1 read at
    module load. tools-dev / tools-pack / apps/packaged set this when
    the daemon is spawned in a desktop-bundled flow (separate commits).
    With the env set, the gate is active from request 0 — a renderer
    racing /api/import/folder before registration completes gets a
    503 DESKTOP_AUTH_PENDING (transient, retry).

Standalone-daemon (web-only) deployments where neither mechanism fires
keep the gate dormant and the route's behavior unchanged.

Also addresses lefarcen P3 (whitespace HMAC mismatch): the desktop
signs the exact picker output, so the daemon must verify the same
string. The previous version trimmed `baseDir` before HMAC, which
would reject legitimate paths whose final component carried edge
whitespace. Use the raw request-body baseDir for verification; the
existing trim()+realpath() logic still normalizes for fs operations.

New error code: `DESKTOP_AUTH_PENDING` (HTTP 503, retryable).

Tests:

- `stays fail-closed (503 DESKTOP_AUTH_PENDING) after a registered
  secret is cleared` — exercises the sticky flag.
- `verifies the exact request-body baseDir, not a trimmed version` —
  pins the round-4 P3 fix.
- All existing desktop-import-token-gate cases continue to pass; the
  beforeEach/afterEach/afterAll resetters now use
  resetDesktopAuthForTests() to honor the sticky flag.

* fix(tools-dev,packaged): pin desktop import-auth on daemon spawn

PR #974 round-4 P1 follow-through. The daemon-side fail-closed gate
needs OD_REQUIRE_DESKTOP_AUTH=1 in the daemon's spawn env whenever
the daemon is paired with a desktop, so the gate is active from
request 0 and the daemon-restart-mid-session bypass cannot reopen.

tools-dev:
- spawnDaemonRuntime accepts a `requireDesktopAuth` option that
  appends OD_REQUIRE_DESKTOP_AUTH=1 to the spawn env.
- startDaemon takes the same flag and additionally checks whether a
  desktop runtime is already alive in this namespace; either branch
  pins the env (revival case where the daemon died mid-session and
  the user runs `tools-dev start daemon` to bring it back up).
- startApp threads the bundled-target list down so the daemon spawn
  knows when desktop is queued in the same orchestration even though
  the daemon starts first.
- The `start` / `restart` / `run` command actions pass the resolved
  target list into startApp.

apps/packaged:
- Packaged builds always pair a desktop with the daemon, so
  startPackagedSidecars unconditionally sets OD_REQUIRE_DESKTOP_AUTH=1
  in the daemon child env. Headless builds also flow through this
  same path, so the same gate applies.

Standalone-daemon flows unaffected: `tools-dev start daemon` (alone,
no desktop running, no desktop in the bundled target list) does not
set the env, and the daemon's gate stays dormant — current web-only
behavior is preserved.

* fix(desktop,web): align project-id regex with daemon; surface pickAndImport failures

mrcfps round-4 nits on PR #974.

apps/desktop/src/main/runtime.ts (mrcfps #1): the previous client-side
regex `^[a-zA-Z0-9_-]+$` rejected `.` even though the daemon's
canonical isSafeId / POST /api/projects accept `[A-Za-z0-9._-]{1,128}`.
Result: dotted ids like `my-project.v2` were valid backend-side but
got "project id contains disallowed characters" before
fetchResolvedProjectDir even hit the network, regressing Continue in
CLI / Finalize for those projects. Align the regex with the daemon's
shape, comment-tag the rationale.

apps/packaged/tests/desktop-project-root-gate.test.ts: add a
regression case for a dotted id and one for the 128-char length cap
(the new regex exposes both, the old regex obscured the dotted one).

apps/web/src/components/NewProjectPanel.tsx (mrcfps #2): the
`if (!result || result.ok !== true) return` branch swallowed every
non-OK pickAndImport shape (`desktop auth secret not registered`,
`web sidecar URL not available`, daemon HTTP errors with details)
the same way as the explicit `{ canceled: true }` cancel — leaving
the user with a silent no-op when the trusted-picker flow couldn't
even get off the ground. Reserve silent-return for the cancel case
only; surface every other reason via a Toast (existing component,
already used by ProjectView for related Continue-in-CLI flows).
The new `formatPickAndImportErrorDetails` helper flattens daemon
ApiError envelopes into a single readable secondary line so the
operator sees both the category ("Open folder failed: daemon
returned HTTP 503") and the upstream reason
("desktop auth required but secret not yet registered").

* docs(architecture): document desktop folder-import auth boundary

lefarcen P3 on PR #974 round 4: the `Folder import` section in
docs/architecture.md still documented only realpath / sandbox /
RUNTIME_DATA_DIR checks and omitted the new desktop HMAC trust
boundary, replay/TTL behavior, fail-closed semantics, daemon-restart
edge, and legacy-import migration note. Without that subsection it's
hard to review whether the 60s TTL, the `~`-separated token shape,
or the legacy folder-imports needing re-pick are intentional product
decisions or overlooked gaps.

Add a "Desktop folder-import auth (PR #974)" subsection covering:
- The trust handshake (32-byte secret over sidecar IPC at desktop boot).
- Token shape (`${nonce}~${exp}~${signature}`), HMAC payload, and
  why `.` cannot be the field separator (ISO 8601 expiries embed dots).
- TTL and replay behavior (60s, single-use, 2× TTL upper bound).
- Fail-closed mechanisms — sticky in-process flag and
  OD_REQUIRE_DESKTOP_AUTH env var pinning.
- Web-only deployments are unaffected (browser builds have no
  shell.openPath surface).
- The `metadata.fromTrustedPicker` marker and the openPath-side
  defense-in-depth check.
- Legacy folder-imports need re-pick to use the Continue-in-CLI button.
- Daemon-restart edge: 503 DESKTOP_AUTH_PENDING until desktop
  re-registers; restart desktop to recover.

* fix(packaged): skip desktop-auth gate in headless mode (PR #974 round 5 P2)

Round 5 (lefarcen P2): packaged headless mode (daemon+web only, no
Electron) was inheriting OD_REQUIRE_DESKTOP_AUTH=1 from the round-4
unconditional pin in startPackagedSidecars. Headless never runs desktop
main, so no client could ever register an HMAC secret and folder import
returned 503 DESKTOP_AUTH_PENDING permanently — even though headless has
no shell.openPath surface to exploit.

Plumb a required `requireDesktopAuth: boolean` option through
startPackagedSidecars: apps/packaged/src/index.ts (Electron entry)
passes true; apps/packaged/src/headless.ts passes false. Extract
buildPackagedDaemonSpawnEnv as a pure helper so vitest can pin both
branches without spawning a child process.

Tests added in apps/packaged/tests/sidecars.test.ts cover both branches
plus OD_LEGACY_DATA_DIR / daemonCliEntry env forwarding edges.

Refs: nexu-io/open-design#974

* fix(desktop,daemon): lazy auth retry + canonical HMAC binding (PR #974 round 5 P1+P3)

Round 5 (lefarcen P1, mrcfps): a daemon restart under
OD_REQUIRE_DESKTOP_AUTH=1 left desktop holding a stale secret while the
new daemon process required a fresh registration — folder import
returned 503 DESKTOP_AUTH_PENDING permanently until the user restarted
desktop. Same dead-end if the startup handshake missed its retry window.

Round 5 (lefarcen P3): the daemon verified the HMAC against raw
request-body baseDir, then trimmed before realpath(). A picker selection
of "/tmp/foo " could authorize an import of "/tmp/foo" — token bound to
a different path than the one imported.

Three coordinated fixes:

1. P1 lazy retry: extract pickAndImportFolder as a pure helper that
   takes injected fetch / mintToken / registerDesktopAuth deps. On 503
   DESKTOP_AUTH_PENDING from /api/import/folder, re-invoke the
   registration callback once, mint a fresh token (new nonce + new exp
   keeps replay protection), and POST again. Single retry, no infinite
   loop. Other failure shapes return immediately to the renderer.

2. P1 wiring: runDesktopMain now ALWAYS passes desktopAuthSecret to the
   runtime regardless of whether the initial handshake succeeded, plus
   a registerDesktopAuthWithDaemon callback the runtime invokes lazily.
   Soften the startup warning text to match the new recovery semantics.

3. P3 binding: trim picker output ONCE on the desktop side before both
   signing the HMAC and POSTing. Daemon-side verification stays against
   raw request-body baseDir (round-4 behavior); the daemon's defensive
   trim before realpath() is now a no-op for desktop traffic and only
   load-bearing for web-mode callers (path.isAbsolute("  /foo  ") is
   false). End-to-end: desktop-signed string == request body == HMAC-
   verified string == realpath() input.

Tests:

- apps/packaged/tests/desktop-pick-and-import.test.ts (NEW, 7 cases):
  lazy-retry happy path; lazy-retry exhausted (re-register WAS called);
  single-attempt happy path (no unnecessary IPC); optional-callback
  no-op; non-503 failures bypass retry; network errors; non-PENDING 503
  bypasses retry.

- apps/daemon/tests/desktop-import-token-gate.test.ts: replace round-4
  whitespace test with two round-5 binding tests — the trimmed string
  flows end-to-end (HMAC verifies, project metadata.baseDir equals
  realpath of trimmed input), and a request whose body baseDir diverges
  from the HMAC-bound string is rejected 403.

docs/architecture.md §"Desktop folder-import auth" — update the daemon-
restart-edge bullet to describe the lazy-retry recovery (round 4 said
"restart desktop to recover", which is now wrong) and add a headless-
packaged-mode bullet describing the round-5 P2 gate exclusion.

Refs: nexu-io/open-design#974

* feat(sidecar-proto,daemon): surface desktopAuthGateActive over STATUS IPC (PR #974 round 6 prep)

Round 6 (mrcfps): the split-start dev flow `tools-dev start daemon` ->
`tools-dev start desktop` was leaving the daemon ungated because
`OD_REQUIRE_DESKTOP_AUTH=1` is only injected when daemon and desktop
spawn in the same orchestrator invocation. To fix that, tools-dev needs
to introspect the running daemon's gate state before launching desktop
main — but the existing STATUS IPC didn't carry the flag.

This commit extends `DaemonStatusSnapshot` with a required
`desktopAuthGateActive: boolean` and wires the daemon sidecar's STATUS
handler (and the public `status()` method on the handle) to recompute
the value from `isDesktopAuthGateActive()` per request, since the flag
flips after `REGISTER_DESKTOP_AUTH` and stays sticky.

Extracted `withCurrentDesktopAuthGate(snapshot)` as a tiny pure helper
so the wiring is testable without booting a real IPC server. The new
test pins four scenarios:
- no secret registered (web-only mode) -> false
- after `setDesktopAuthSecret(buf)` -> true
- after `setDesktopAuthSecret(null)` (sticky) -> still true
- input snapshot's stale value is overridden by the live flag

The orchestrator-side consumer lands in the next commit
(`tools/dev/src/desktop-auth-gate.ts`).

Refs: nexu-io/open-design#974

* fix(tools-dev): auto-restart ungated daemon before desktop start (PR #974 round 6 mrcfps)

Round 6 (mrcfps): the split-start dev sequence
`tools-dev start daemon` -> `tools-dev start desktop` was leaving the
daemon running without `OD_REQUIRE_DESKTOP_AUTH=1`. The env var is
only injected when (A) daemon and desktop spawn in the same
orchestrator invocation (`startApp` line ~682) or (B) a desktop
runtime is already alive at daemon spawn time (`startDaemon` lines
~595-596). Neither fires for the split flow, so a renderer (or any
local HTTP client) could `POST /api/import/folder` directly with an
arbitrary `baseDir` before the desktop's first registration POST.
Round-5's lazy retry didn't help: it triggers on `503 DESKTOP_AUTH_PENDING`,
and the ungated daemon returns 200.

Close the gap by introspecting the running daemon's
`desktopAuthGateActive` (added to the STATUS IPC in the prior
commit) at the start of `startApp(DESKTOP, ...)`. When the daemon
reports the gate inactive, stop the daemon (and web, if running),
respawn the daemon with `requireDesktopAuth: true`, restart web,
then proceed with the desktop start. Restart order is critical and
pinned by tests: web stops FIRST (so the web->daemon proxy doesn't
serve a transient 502 against the down-then-up daemon), then daemon
stops, then daemon respawns gated, then web restarts.

The bundled-targets path (`pnpm tools-dev`) is unaffected because
trigger (A) already armed the gate at first daemon spawn — the
helper costs one ~800ms STATUS IPC roundtrip and returns no-op.

Helper lives in its own module (`tools/dev/src/desktop-auth-gate.ts`)
so the regression test can import it without triggering the
`cli.parse()` side effect at the bottom of `tools/dev/src/index.ts`.
Five `node:test` cases pin the call sequence — no daemon, gate
active, gate inactive + no web, gate inactive + web running, log
shape — so a future refactor can't silently regress the gate.

Two synthetic `DaemonStatusSnapshot` literals in `inspectAppStatus`
and `inspect` (used when the IPC is unreachable) get
`desktopAuthGateActive: false` to satisfy the now-required type
field — semantically correct since "no daemon answering" trivially
means "no gate active."

`docs/architecture.md` adds a new bullet under the Desktop folder-
import auth section describing this auto-restart behavior.

Refs: nexu-io/open-design#974

* fix(daemon): combine finalize request-abort + timeout signals (PR #974 round 7 lefarcen P1)

Round 6 wired the route handler to pass `finalizeAbort.signal` into
`finalizeDesignPackage`, but the helper only created its own
DEFAULT_TIMEOUT_MS controller when no caller signal was supplied. The
result: a client that stayed connected could hold the finalize lock and
upstream call indefinitely. Always create the timeout controller; when
the caller passes a signal, combine both via `AbortSignal.any` so
neither cancel path replaces the other.

Adds two regression tests in finalize-design.test.ts:
- timeout fires when caller signal never aborts
- pre-aborted caller signal still cancels

Adds an internal `timeoutMs` option to FinalizeOptions so tests can
exercise the abort path without a 120 s wait or fake-timer chains.
Production callers omit it; default remains DEFAULT_TIMEOUT_MS.

* fix(daemon): allow PATCH preserving existing fromTrustedPicker marker (PR #974 round 7 lefarcen P2)

The PATCH /api/projects/:id handler was rejecting any metadata that
contained `fromTrustedPicker`, including the unchanged `true` marker
that the linked-folder UI re-spreads when editing `linkedDirs`. Trusted
folder-imported projects could not update other metadata fields without
400-ing on their own marker.

Switch the rejection condition from `'in'` to a value comparison: only
reject when the incoming value differs from the persisted one
(`patch.metadata.fromTrustedPicker !== existingMeta?.fromTrustedPicker`).
That keeps acquisition (existing=undefined, patch true) and flip
(existing=true, patch false) attempts blocked while letting the UI
re-spread the existing marker.

POST /api/projects stays strict; that path has no existingMeta.

Adds two regression tests in desktop-import-token-gate.test.ts:
- allows PATCH preserving the existing fromTrustedPicker:true marker
- rejects PATCH that flips fromTrustedPicker on a trusted project

* fix(desktop,packaged): main-process api uses daemon URL not webUrl (PR #974 round 7 lefarcen P2)

Packaged builds load the renderer from `od://app/` and report that URL
through `discoverWebUrl`. But Node-side `globalThis.fetch` (undici) does
not route through Electron's registered `od://` protocol handler — that
handler runs in the renderer's protocol scope, not in main-process Node.
So `pickAndImportFolder` and `fetchResolvedProjectDir` calls from main
silently failed in packaged builds against the protocol scheme.

Add `discoverDaemonUrl` to `DesktopRuntimeOptions` and `DesktopMainOptions`.
The packaged shell already has the sidecar's real `http://127.0.0.1:<port>`
URL (`sidecars.daemon.url` from STATUS IPC) — thread it through to the
runtime. Main-process API calls now prefer the daemon URL and fall back
to the renderer URL for tools-dev (where it is itself http://127.0.0.1).

`PickAndImportFolderDeps.webUrl` renamed to `apiBaseUrl` so the boundary
is explicit at the type level; `fetchResolvedProjectDir`'s first
parameter renamed similarly. tools-dev callers see no behavior change —
their web URL is already an http://127.0.0.1 URL Node fetch can hit.

Test (`apps/packaged/tests/desktop-pick-and-import.test.ts`):
- existing 7 cases updated to the new prop name (no behavior change)
- new case pins URL composition: builds `${apiBaseUrl}/api/import/folder`
  and never produces a custom-protocol URL.

Note for review: this test pins URL composition; full Electron protocol
handler integration (renderer fetch through `od://`) is not exercised in
unit tests here.

* fix(tools-dev): preserve daemon/web ports across desktop-auth gate restart (PR #974 round 7 lefarcen P2)

Round 6 added the split-start auto-restart in ensureDaemonGateForDesktop
to close the dev-flow gap where `start daemon` then `start desktop`
left the daemon ungated. The restart was passing the current
`start desktop` CLI options to startDaemonGated/startWeb, which meant a
stack started with `--daemon-port 17456 --web-port 17573` could be
silently moved to random ports during the hardening restart, breaking
browsers and scripts pinned to those ports.

Extract the running ports from the STATUS snapshots (daemon.url and
web.url) and forward them as explicit `{ port }` callback args. The
closure in `tools/dev/src/index.ts` overrides the corresponding option
when a port was extracted; null falls back to the original CLI flags.

Adds three regression tests in tools/dev/tests/desktop-auth-gate.test.ts:
- preserves the running daemon port across the hardening restart
- preserves the running web port across the hardening restart
- falls back to caller options (port:null) when the URL has no port

* fix(web): refresh useDesignMdState on file/chat events (PR #974 round 7 mrcfps)

useDesignMdState() previously only recomputed on mount and on explicit
refresh() (called once after finalize). Once the user kept working —
editing files or sending more chat turns — the stale/fresh badge could
drift out of sync because file mtimes and conversation updatedAt moved
past the recorded generatedAt without the hook re-checking.

Hook accepts an optional `refreshKey: number` arg; ProjectView keeps a
counter and bumps it on three events:
- file-changed SSE (covers tool-emitted file mutations)
- live_artifact* SSE (covers chat turns that emit artifacts)
- streaming `true → false` edge (covers pure-text chat turns)

The hook treats refreshKey as a compute() dep; React's Object.is
comparison short-circuits the no-op renders, so each bump is a single
recompute pass.

Adds a regression test in useDesignMdState.test.tsx:
- flips stale state after a refreshKey bump without remounting

* fix(web): degraded-state useDesignMdState on malformed provenance (PR #974 round 7 mrcfps)

useDesignMdState used to report `{ isStale: false, staleReason: null }`
when the parser could not extract a comparison timestamp from the
DESIGN.md `## Provenance` section. The pinned test made that the
documented behavior. As mrcfps pointed out, that fails open exactly
when the freshness signal is most untrustworthy: any provenance-
formatting drift silently disables the staleness warning.

Extend `DesignMdStaleReason` with a third variant `'unknown-provenance'`.
On `generatedMs === null`, return `{ isStale: true, staleReason: 'unknown-provenance' }`.
ContinueInCliButton renders a distinct chip text "Spec freshness
unknown — regenerate to refresh signal" for that variant; the button
stays enabled because not-comparable is not the same as broken state.

Tests:
- modify the existing pinned test to assert the new degraded state
- add an end-to-end useDesignMdState test feeding a malformed Provenance
  section through compute() so a regression that re-pins fresh-on-null
  at the hook level (not just computeStale) fails fast
- add ContinueInCliButton render + click tests for the new chip

---------

Co-authored-by: DevForgeAI CI/CD Engineer <devforge-ai@development.ai>
Co-authored-by: lefarcen <935902669@qq.com>
2026-05-10 11:44:32 +08:00
617fb043fe feat(settings): add fetch models button for BYOK providers (#1034)
* feat(settings): add fetch models button for BYOK providers

* fix(settings): exclude Ollama from fetch models, add manual-entry hint

* fix(provider-models): classify non-JSON upstream errors by HTTP status

* fix(i18n): drop redundant English overrides from non-English locales

* fix(provider-models): allow ollama through allowlist, return unsupported_protocol

---------

Co-authored-by: haolin122 <hl6593@nyu.edu>
2026-05-09 22:28:03 +08:00
039cc3d674 fix(settings): add install onboarding links for unavailable local CLIs (#985)
* fix(settings): add install onboarding links for unavailable local CLIs

* fix(settings): rename Claude config dir label to config directory

---------

Co-authored-by: mrcfps <mrc@powerformer.com>
2026-05-09 21:49:44 +08:00
SidandGitHub 5db578123e fix(web): dispatch Examples preview on od.preview.type (#897) (#1001)
* fix(web): dispatch Examples preview on od.preview.type (#897)

The Examples gallery unconditionally fetched `/api/skills/:id/example`,
and the daemon endpoint only resolves HTML files (`example.html`,
`assets/template.html`, `assets/index.html`, `examples/*.html`). Skills
that declare `od.preview.type: image` (`hatch-pet`) or
`od.preview.type: markdown` (`dcf-valuation`, `last30days`,
`x-research`) ship no such HTML — the fetch returns 404 and the modal
landed on the misleading "Couldn't load this example. The example HTML
failed to fetch." copy.

Dispatch on `previewType` at the data layer (`fetchSkillExample`) and
at the render layer (`PreviewModal`):

- `fetchSkillExample(id, previewType)` short-circuits any non-`html`
  value to `{ unavailable: true, kind }` without firing a network call.
- `PreviewView` grows an optional `unavailable: { kind }` shape; the
  modal renders a calm "no shipped preview" placeholder distinct from
  loading and error states. The Share menu disables (no HTML to export).
- `ExamplesTab` tracks `previewUnavailable` per skill alongside the
  existing `previews` / `previewErrors` maps; the card placeholder
  swaps to "open to learn more" copy so users don't hover waiting for
  a render that won't come.
- New `preview.unavailableTitle` / `preview.unavailableBody` and
  `examples.unavailablePlaceholder` / `examples.shareUnavailable` keys
  shipped across all 17 locales. Body copy uses the raw preview kind
  (`{kind}` placeholder) so future kinds slot in without a copy change.

Tests: registry-level coverage that the dispatch never hits the network
for non-html types; PreviewModal-level coverage that the unavailable
affordance is mutually exclusive with loading/error and disables the
Share menu; ExamplesTab-level coverage that the gallery renders the
unavailable state for image/markdown skills and routes html skills
through the existing fetch path. Updated the existing `#860` retry
regression test for the new two-arg signature.

* fix(web): use neutral noun for preview.unavailableBody copy (#1001 review)

P3 from lefarcen: `'a {kind} document'` reads awkwardly when `{kind}`
is `image` ("a image document") and the article disagreement
undermined the PR body's claim that future kinds slot in without
copy changes. Drop the article and replace `document` with a more
neutral noun (`output` / `resultat` / `产物` / `出力` / etc.) so
every kind reads naturally:

- `produces {kind} output` (English)
- `produit un résultat {kind}` (French)
- `生成 {kind} 产物` (Simplified Chinese)
- … and 14 more

`{kind}` placeholder stays literal in every locale; surrounding
vocabulary for skill / prompt / chat preserved per existing
file conventions.
2026-05-09 21:32:45 +08:00
CIoudherdandGitHub 724d071c01 feat: add design file rename support (#894)
* feat(contracts): add project file rename contract

* feat(daemon): add safe project file rename API

* feat(web): support renaming design files

* fix(daemon): handle case-only file renames

* fix(web): prevent rename collisions with pending sketches

* fix(daemon): preserve source names during rename

* test(daemon): cover rename symlink escapes

* fix(daemon): avoid clobbering rename targets

* test(web): align rename tests after rebase

* test(web): align rename tests with latest main
2026-05-09 21:24:36 +08:00
meshackmandGitHub 461a312002 fix: fix link handling in example preview iframe sandbox (#701)
* fix: fix link handling in example preview iframe sandbox

* fix: added missing single quote

* fix(srcdoc): security/correctness issues
- Use location.hash = href for hash-link navigation so hashchange
  events, CSS :target, history/back and named anchors work correctly
- Always pass 'noopener,noreferrer' to window.open() for _blank links
- Guard e.target with instanceof Element before calling .closest() to
  avoid TypeError on text node click targets

* fix(srcdoc): security/correctness issues
- Added a protocol allowlist
- Re-navigation to same hash
- Consistency with rest of shim IIFE using var instead of let and const

* fix(srcdoc): scroll to top on bare hash links (#)

Intercept href="#" clicks and scroll to top of page in the iframe sandbox to mimic native browser behaviour.

* test(web): update PreviewModal sandbox test assertions to include popup flags
2026-05-09 21:24:25 +08:00
zztdanandGitHub fe879036fb fix(web): restore media config from daemon on startup (#687)
* fix(web): restore media config from daemon on startup

* fix(media): preserve stored keys on settings save

* fix(web): harden daemon media restore flow

* fix(web): unify media provider empty-state rules

* fix(desktop): retry loading discovered web url

* fix(web): preserve local media providers on partial daemon reload

* fix(web): preserve media providers on daemon reload

* fix(web): skip media migration for masked-only local state

* fix(web): preserve daemon media state across reloads
2026-05-09 19:31:08 +08:00
Paul SteanandGitHub f3535cdd9f feat(web): scroll question forms to top of viewport instead of pinning to bottom (#1044)
* feat(web): scroll question forms to top of viewport instead of pinning to bottom

* fix: reset scroll state on composer send after form has claimed control

* fix: use getBoundingClientRect for form scroll position; guard early-return on streaming

* fix: smooth scroll for all chat-scroll operations; polyfill scrollTo for jsdom tests

* fix: revert streaming bottom-pin to instant to avoid scroll event thrash

* fix: revert initial-load bottom-pin to instant to avoid scroll event thrash
2026-05-09 19:29:54 +08:00
Marc ChanandGitHub 223d35f073 fix: improve Orbit and packaged data-dir startup errors (#1067) 2026-05-09 16:47:01 +08:00
EliandGitHub 1bf7836471 feat(web): redesign top bar — lift Share/Present, zoom dropdown, focus toggle (#1048)
* feat(web): redesign top bar — lift Share/Present, add zoom dropdown, move focus toggle

- AppChromeHeader: add #app-chrome-file-actions portal anchor so file viewers
  can render their primary actions (Present/Share) up in the project chrome
  instead of cramming a second toolbar row.
- HtmlFileViewer / LiveArtifactViewer: portal Present + Share into the top
  bar via createPortal; Share gets a real chrome-action-primary button.
- HtmlFileViewer: replace the 100% reset button with a zoom dropdown
  (50/75/100/125/150/200) with click-outside + Esc handling.
- HtmlFileViewer: move Preview/Source tabs next to Reload (left side, view
  modes); move Tweaks to the right cluster next to Inspect/Edit.
- HtmlFileViewer: showPresent no longer requires deck — any HTML artifact
  with loaded source can be presented (prototype/slide/regular HTML).
- LiveArtifactViewer: add Present (in-tab/fullscreen/new-tab) with iframe
  ref + previewBodyRef wrapper; in-tab present hides chrome and overlays an
  exit button (Esc also exits).
- ChatPane: add chevron-left collapse icon in chat header (onCollapse prop)
  so users can hide the chat from where it lives.
- FileWorkspace: focus toggle is now icon-only and only renders when chat is
  collapsed, sitting on the LEFT of the workspace tabs row as a chevron-right
  expand button — direction matches where chat re-emerges from.
- index.css: add chrome-action-primary/secondary, zoom-menu, present-exit-btn,
  app-chrome-file-actions styling, plus a narrow-width media query that
  collapses secondary action labels.

* fix(web): tests — fall back to inline render when chrome portal anchor missing

The Share/Present primary actions render via createPortal into
#app-chrome-file-actions, which only exists when AppChromeHeader has
mounted. Vitest renders FileViewer / LiveArtifactViewer in isolation,
so the portal anchor was absent and the buttons disappeared from the
test DOM, breaking 7 share-menu tests.

- HtmlFileViewer / LiveArtifactViewer: when chromeActionsHost is null,
  render the present/share JSX inline instead of returning null. UX is
  identical in production (host is always present); tests now find the
  buttons without needing a portal-aware harness.
- FileWorkspace tests: rewrite the "focus toggle in tab bar" assertions
  to reflect the new design — the toggle lives in ChatPane while the
  chat is open, and FileWorkspace only renders an icon-only expand
  button on the LEFT of the tab bar once chat is collapsed.
2026-05-09 15:26:22 +08:00
lefarcenandGitHub 6f74ac304d fix(web): expand design file row click target (#1039) 2026-05-09 14:46:09 +08:00
lefarcenandGitHub 286b6cdf8d fix(web): make privacy consent choices explicit (#1031) 2026-05-09 14:37:43 +08:00
CaprikaandGitHub 8feb2e586c fix(connectors): preserve OAuth state and advertised tool counts (#1036)
* fix(connectors): preserve oauth state and advertised counts

* test(connectors): type fixture for advertised count

* docs(connectors): align tool count badge contract

* docs(connectors): clarify curated tool names role
2026-05-09 13:54:35 +08:00
SidandGitHub 78ae6feb59 fix(web): surface empty-annotation state for Inspect/Picker (#890) (#1005)
When the agent emits an HTML artifact with no `data-od-id` /
`data-screen-label` annotations (a freeform PRD → HTML pass through a
Claude-Code-compatible CLI without going through a skill, for
example), the existing Inspect / Picker affordances no-oped silently:

- The bridge's click handler walks up to <html>, finds nothing tagged,
  and bails before emitting `od:comment-target` — by design, since
  posting a synthetic id here would change save-to-source semantics
  for inspect overrides (the persisted CSS keys off the same
  elementId).
- The host then sat at "Click any element with `data-od-id` to tune
  its style" — phrased as if the user just hadn't found the right
  element, when the page in fact had nothing matching at all.
- Picker mode (Tweaks → Picker) had no hint at all.

The bridge already broadcasts `od:comment-targets` with the full list
on every mode toggle and DOM mutation, but the host's existing
listener was gated on `boardMode` only — Inspect mode never learned
the artifact's annotation count.

Two surgical fixes:

1. `FileViewer.tsx`: a dedicated `od:comment-targets` listener that
   installs whenever Inspect OR Comments mode is active, mirroring
   the bridge's broadcast into `liveCommentTargets`. The
   comment-mode-only listener still owns its hover / click / pod
   events; this new listener only handles the targets list.
2. `FileViewer.tsx`: the inspect-empty-hint banner now dispatches on
   `liveCommentTargets.size === 0`. Empty: a clear "this artifact has
   no `data-od-id` annotations yet — ask the agent to add them"
   message that names the missing attribute. Populated: existing
   instructive copy. Mirrored across Inspect and Picker modes so the
   failure surface gives the same calibration signal in both.

Tests:

- `tests/runtime/srcdoc-bridge-empty-targets.test.ts` (3 cases): pin
  the bridge contract this fix depends on. Run the IIFE in jsdom and
  assert (a) `allTargets()` posts an empty list for unannotated DOM,
  (b) clicks on unannotated elements do NOT post `od:comment-target`
  (regression pin against future "synthetic id" fallbacks that would
  silently change save-to-source semantics), (c) clicks DO still
  resolve to an annotated ancestor when one exists.
- `tests/components/FileViewer.inspect-empty-hint.test.tsx` (3
  cases): pin the host dispatch — empty state in Inspect mode, the
  switch back to instructive copy when targets show up, and the
  mirrored affordance in Picker mode.

Out of scope (flagged in the design comment so it isn't lost):
- The follow-up scenario from #890 ("parent has data-od-id, target
  child does not → adjustments hit the parent") is a different bug
  that needs either synthetic-id fallback or a UI affordance to
  descend into the click target. Leaving that to a follow-up so this
  PR stays narrow.
- i18n: the existing inspect-empty-hint copy is hardcoded English;
  rolling it into the 17-locale Dict is a separate cleanup.
2026-05-09 11:20:13 +08:00
初晨andGitHub 9ef136ced5 fix: sync Orbit last run with selected prompt template (#937)
* fix(orbit): scope last run to selected template

* fix(orbit): preserve legacy last run on upgrade

* fix(orbit): pin legacy last-run fallback on refresh

* fix(orbit): pin template id at run start

* test(web): sync orbit fixtures with skill summary
2026-05-09 11:19:59 +08:00
lefarcenandGitHub 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
Paul SteanandGitHub 0b039777b9 fix/Bug#772-DesignFilesSortButton-DesignFilesTableNowSortableColumns (#804)
* Disclaimer: Changes made using OpenCode with Big Pickle, an AI code assistant.

1. Removed the ↑ button from DesignFilesPanel.tsx (was a "back" button that
    closed the preview pane — confusingly placed in the header as if it were for
    sorting).

2. Converted the file list to a single <table> with sortable columns:
    - Replaced the section-based grouping (Pages, Sketches, Scripts, Images,
    Other) with a flat, sortable table
    - Added column headers: Name, Kind, Modified — all clickable to toggle
    sort direction
    - Default sort is by modified time descending (same as previous behavior)
    - Sort indicator arrows (↑/↓) show the active sort column and direction
    - Live artifacts remain as a separate section above the table

3. Added i18n keys designFiles.colName, designFiles.colKind, designFiles.
    colModified to all 17 locale files and the type definitions.

4. Updated CSS with table layout styles (.df-table, .df-file-row, column
    width classes, sortable header styles).

    Files modified:
    - apps/web/src/components/DesignFilesPanel.tsx
    - apps/web/src/index.css
    - apps/web/src/i18n/types.ts
    - apps/web/src/i18n/locales/en.ts
    - apps/web/src/i18n/locales/*.ts (all 16 other locale files)

* Updated to preserve keyboard access to sorting

* Fixed keyboard to focus/activate/launch file from Design Files list. Single space bar will show preview, double spare bar will open the file as a tab

* Top pagination bar (above the table):
     - "Show" dropdown with options 15, 30 (default), 45, 60, All
     - Page range indicator (1–20 of 45)
     - Previous / Next buttons

     Bottom pagination bar (below the table):
     - Previous / Next buttons
     - "Go to page" dropdown listing all page numbers
     - Same page range indicator

     Implementation details:
     - All controls use native <select> and <button> elements — fully keyboard
     accessible (Tab, arrow keys, Enter/Space)
     - Page resets to 0 when page size changes
     - safePage clamps to valid bounds when file count changes (e.g. after delete)
     - "All" sets page size to total file count (effectively one page)
     - Prev/Next buttons show disabled state at boundaries with reduced opacity

* All 46 test files, all 385 tests pass. Here's what the regression test covers:
     ┌────────────────────┬──────────────────────────────────────────────────────┐
     │Test                │What it verifies                                      │
     ├────────────────────┼──────────────────────────────────────────────────────┤
     │default page size   │500 files → only 30 .df-file-row elements in DOM      │
     ├────────────────────┼──────────────────────────────────────────────────────┤
     │page size All       │changing per-page to "All" shows all 500 rows         │
     ├────────────────────┼──────────────────────────────────────────────────────┤
     │page size 60        │changing to 60 shows 60 rows                          │
     ├────────────────────┼──────────────────────────────────────────────────────┤
     │Next navigation     │clicking Next advances page and shows file-31 (sorted │
     │                    │by mtime desc)                                        │
     ├────────────────────┼──────────────────────────────────────────────────────┤
     │Prev/Next disabled  │Prev disabled on page 0, Next disabled on last page   │
     │states              │                                                      │
     ├────────────────────┼──────────────────────────────────────────────────────┤
     │jump to page        │bottom dropdown jumps to page 3 (shows file-91)       │
     ├────────────────────┼──────────────────────────────────────────────────────┤
     │page info text      │1–30 of 500 → after Next → 31–60 of 500               │
     ├────────────────────┼──────────────────────────────────────────────────────┤
     │render time         │renders 500 files in under 2s                         │
     └────────────────────┴──────────────────────────────────────────────────────┘

* Fixed i18n for DesignFiles, and Fixed DesignFilesPanel Test

* Fixed - P3 — .df-thead rule defined but never applied

* Fixed keyboard use for file navigation, focus and button usage

* Fix i18n for x of y in design files pagination

* Fixed SafePage clamping

* Fixed dupe file total count

* Fixed x of y i18n

* Fixed DeleteSelected i18n and missing from Test

* fix effective pagesize issue, and change duplicate file kind to a filesize

* Readded page/everything selection and i18n

* Fixed i18n issues

* Resolved indonesian i18n issue with cloudflare keys

* Fixed unrelated cloudflare i18n issues as requested in Pull Request by reviewer

* Fix e2e test: click filename button instead of row for preview

The DesignFilesPanel was refactored from <button> rows to a <tr> with
a nested <button> for the filename. The e2e test was still clicking
the <tr> which has no onClick handler, so the preview never appeared.

* Remove duplicate formatSize helper, reuse humanBytes instead
2026-05-09 09:01:57 +08:00
72fd9a73a2 fix(web): keep chat auto-scroll glued to bottom across streaming chunks (#989)
Issue #983: when an assistant turn streamed in, the chat log stopped
auto-scrolling even though the user was sitting at the bottom of the
conversation. Root cause: the auto-scroll effect re-measured
`scrollHeight - scrollTop - clientHeight` AFTER the new chunk had
already grown the element. A single tool-use card or markdown render
adds 100+ px in one tick, so the post-content distance check (`< 80`)
skipped the scroll exactly when the user expected it most.

Switch the gate to the existing `scrolledFromBottom` state. That flag
is maintained by the user-driven scroll listener (only flipped by a
real scroll event, not by content growth), so it carries the user's
pre-content intent through to the effect. New content auto-scrolls
when the user was glued to the bottom; scrollback sessions still
preserve their position.

Existing chat-scroll-preservation tests still pass (6/6); the prior-
state behavior we test there is bottom-pinned vs absolute-restore on
tab switch, which this change does not affect.

Co-authored-by: Nagendhra <nagendhra405@gmail.com>
2026-05-09 08:15:15 +08:00
05461c64fd fix(connectors): show stable curated tool count in connector card badge (#748) (#767)
* fix(connectors): show stable curated tool count in connector card badge (#748)

The connector card's "N tools" badge in `apps/web/src/components/
EntryView.tsx` rendered `connector.tools.length` for both pre- and
post-Composio-hydration states, so the displayed count lurched
without explanation:

- Before configuring a Composio API key, GitHub showed "2 tools"
  (the static fallback catalog at `composio.ts:21-53`).
- After hydration, the daemon merged in the full Composio provider
  inventory (`composio.ts:725-778`, ~868 GitHub tools), and the
  same badge jumped to "868 tools".

Same field name, two different concepts — `connector.tools` is
"everything the provider ships" while the agent-callable subset
is `definition.allowedToolNames` (read-only auto-approval tools
that pass `isAgentPreviewListableTool()`).

Fix: surface `allowedToolNames` on the wire `ConnectorDetail` and
have the badge use that count instead. The detail drawer below
the card still iterates over `connector.tools` to enumerate the
full inventory — the count and the list are intentionally
different surfaces.

The badge now stays close to "tools the agent can actually
invoke" (≈2-30 for GitHub, depending on auto-allowed read tools)
instead of the raw provider inventory (~868). No 800x jump on
hydration.

Wire format change:
- packages/contracts/src/api/connectors.ts: add
  `allowedToolNames: string[]` to ConnectorDetail
- apps/daemon/src/connectors/catalog.ts: same field on the
  daemon-internal type, populated in `connectorDefinitionToDetail()`
  as a defensive copy of `definition.allowedToolNames`
- packages/contracts/src/examples.ts: extend the example fixture
- apps/web/src/components/EntryView.tsx: badge call sites switch
  to `connector.allowedToolNames?.length ?? connector.tools.length`
  (the `??` keeps the badge alive against any older daemon build
  that hasn't shipped the field yet)

Tests:
- 4 new daemon tests in connectors-service.test.ts pin the
  contract: getConnector() emits the field, the array is a
  defensive copy, and the #748 regression guard simulates the
  Composio post-hydration shape (tools.length=801,
  allowedToolNames.length=1) to prove the badge invariant
- web EntryView.test.ts fixtures updated to satisfy the new
  required field

Verified locally:
- daemon vitest: 925/925
- web vitest: 332/332
- daemon/web/contracts typecheck clean
- i18n-check passes
- Live `/api/connectors/discovery` returns the new field; pre-
  hydration GitHub/Notion/Google Drive badges all read "2 tools"
  (no regression vs before this change)

Fixes #748

* fix(connectors): split drawer badge vs inventory counts; fix daemon test typecheck (#767 review)

Two follow-ups for @mrcfps's review on PR #767.

1) **P1: Drawer empty-state regression.** The previous commit reused
the curated `toolCount` for both the header badge AND the inventory
section's loading gate / empty-state branch / section count. The
inventory section renders `connector.tools` directly, so a hydrated
connector with raw provider tools but an empty allowlist (e.g. a
write-only Composio surface) would render "no tools available" and
hide the actual inventory list — exactly the contradiction my own
PR description warned against.

Fix splits the two surfaces:
  - `badgeToolCount` (curated, via the new exported helper
    `getConnectorBadgeToolCount`) feeds the card and drawer header
    badge — the summary count, where the #748 stability matters.
  - `inventoryToolCount = connector.tools.length` (inline) drives the
    drawer's loading gate, section count, and empty-state branch —
    the surfaces describing the actual rendered list.

The card has no inventory section so it stays on the badge helper
unchanged.

2) **CI: daemon test typecheck failed.** The connectors-service test's
`provisionedTools[0].safety` index access tripped daemon
`tsconfig.tests.json`'s `noUncheckedIndexedAccess` strict setting,
even though my local `tsc -p tsconfig.json --noEmit` was clean —
that config is a separate compilation. Bind through a defined-checked
local before reading `.safety`, per @mrcfps's exact suggestion.

Tests:
  - 4 new web tests in `EntryView.test.ts` pin the
    `getConnectorBadgeToolCount` contract, including the explicit
    regression: a connector with `allowedToolNames=[]` and
    `tools.length=800` returns badge=0 but the inventory length
    stays at 800 — the drawer's empty-state branch must use the
    inventory count, never the badge count.
  - Existing daemon test fixed without losing assertion coverage.

Verified locally:
  - daemon vitest: 921/921
  - web vitest: 336/336 (was 332, +4)
  - daemon `tsc -p tsconfig.json` and `tsc -p tsconfig.tests.json` (the CI killer): both clean
  - web `tsc -b --noEmit` clean
  - i18n-check passes

Process learning baked into this PR: from now on I'll always run the
`tsconfig.tests.json` separately before pushing, since the workspace
typecheck script chains both and the second one is what CI fails on.

* fix(connectors): pin badge to curated catalog count, not the dynamic execution allowlist (#767 review v2)

@lefarcen and @mrcfps both flagged that the previous iteration of
this PR (commit 447a270) used `allowedToolNames` as the badge
source. That field isn't actually stable across Composio hydration:
`apps/daemon/src/connectors/composio.ts:758-761` extends it with
every provider-discovered tool whose classified safety is
read+auto, so a connector like GitHub goes from 2 → ≈52 on
hydration — better than 2 → 868, but still a 26x jump and still
the kind of unexplained number-lurch the issue is about. My
regression test only covered write-classified tools so this
read-path inflation slipped through.

Fix: introduce `curatedToolNames` as a separate field that is
locked to the static catalog and never extended by discovery.

- `packages/contracts/src/api/connectors.ts` and
  `apps/daemon/src/connectors/catalog.ts`: add
  `curatedToolNames: string[]` to `ConnectorDetail` (required on
  the wire) and as an optional field on
  `ConnectorCatalogDefinition` (defaults to allowedToolNames at
  serialize time).
- `apps/daemon/src/connectors/composio.ts`:
  `definitionFromToolkit()` now sets
  `curatedToolNames = [...staticDefinition.allowedToolNames]` —
  the catalog set, no extension. The runtime `allowedToolNames`
  keeps its dynamic auto-allow behavior so the execution gate is
  unchanged end-to-end.
- `apps/daemon/src/connectors/catalog.ts`:
  `connectorDefinitionToDetail()` populates
  `curatedToolNames: [...(definition.curatedToolNames ?? definition.allowedToolNames)]`,
  so non-Composio connectors (no discovery layer) trivially mirror
  the two fields.
- `apps/web/src/components/EntryView.tsx`:
  `getConnectorBadgeToolCount` becomes a 3-tier fallback —
  `curatedToolNames` first, then `allowedToolNames`, then
  `tools.length`. The order means a half-deployed daemon (allowed
  but not curated) still produces a more meaningful number than
  the raw inventory; only when both are missing do we fall back
  to the original buggy behavior.

Tests added:
- daemon `connectors-service.test.ts`: 3 new tests pin the
  contract — wire shape, hydration stability (catalog stays at 1
  while allowedToolNames grows to 51), and defensive copy.
- web `EntryView.test.ts`: 5 tests on `getConnectorBadgeToolCount`
  including the @lefarcen scenario explicitly: tools=800,
  allowedToolNames=52, curatedToolNames=2 → badge=2.

Verified locally:
- daemon vitest: 924/924 (was 921, +3)
- web vitest: 337/337 (was 332, +5 net)
- daemon `tsconfig.json` and `tsconfig.tests.json` (the CI
  killer): both clean
- web `tsc -b --noEmit` clean
- contracts typecheck clean
- i18n-check passes
- Live wire format: `/api/connectors/discovery` ships
  `curatedToolNames: ['github.github_search_repositories', 'github.github_get_issue']`
  for GitHub (length 2)

* fix: make ConnectorDetail.{allowed,curated}ToolNames optional in shape

After merging main, several existing test fixtures (EntryView,
SettingsDialog.orbit, ConnectorsBrowser) declared `ConnectorDetail`
inline without the new `allowedToolNames` / `curatedToolNames` keys
the PR introduced. Daemon-built payloads always populate both fields
(see `connectorDefinitionToDetail` and the Composio normalization
path), so making the wire shape `?`-optional is safe for runtime
consumers and avoids touching every fixture. Updates the daemon-side
mirror type to match and adds non-null assertions in the daemon tests
that read these fields directly.

---------

Co-authored-by: lefarcen <935902669@qq.com>
2026-05-08 23:42:52 +08:00
soulmeandGitHub e3423c2b7b feat: add draggable file tab reordering (#936) 2026-05-08 22:21:19 +08:00
5d7568ba2c fix(web): confirm before clearing a saved Media provider API key (#875)
The Clear button on Settings → Media providers wiped the saved
apiKey + baseUrl + model in a single click with no recovery — a
fat-fingered click on the wrong row would silently delete a key
the user just pasted in.

Wrap the existing onClick in `window.confirm()` matching the same
pattern the codebase already uses for destructive actions
(conversation delete, design delete, FileWorkspace file delete).
The prompt is localized via a new `settings.mediaProviderClearConfirm`
key with `{name}` placeholders for the provider label, translated
across all 17 locales.

Updated the existing media-provider clear test to auto-accept the
prompt, plus added a sibling test asserting that dismissing the
prompt leaves the saved config intact.

Co-authored-by: Nagendhra <nagendhra405@gmail.com>
2026-05-08 22:15:59 +08:00
f951ccb612 fix: keep examples filter counts consistent (#949)
* fix: keep examples filter counts consistent

* test: cover scoped examples scenario counts

* test: satisfy examples fixture typing

---------

Co-authored-by: leprincep35700 <leprincep35700@users.noreply.github.com>
2026-05-08 21:41:52 +08:00
ce5f20918c test: cover model option rendering (#948)
* test: cover model option rendering

* fix: strengthen model option regression coverage

---------

Co-authored-by: leprincep35700 <leprincep35700@users.noreply.github.com>
2026-05-08 21:38:13 +08:00
Siri-RayandGitHub 208f09c60e fix: settle completed runs and clean up shutdown children (#924)
* fix: clean up completed and shutting down runs

* fix: bound daemon CLI shutdown

Generated-By: looper 0.6.0 (runner=fixer, agent=codex)

* fix: harden daemon shutdown cleanup

Generated-By: looper 0.6.0 (runner=fixer, agent=codex)

* fix: harden daemon shutdown cleanup

Generated-By: looper 0.6.0 (runner=fixer, agent=codex)

* test: align acp abort fake with typed child
2026-05-08 21:05:22 +08:00
netteeandGitHub 32d820e4ee fix(daemon): typecheck leaf modules (#943)
* update drift

* fix(daemon): typecheck leaf modules

* fix(daemon): decode Qoder stdout buffers

Generated-By: looper 0.5.6 (runner=fixer, agent=opencode)
2026-05-08 20:01:25 +08:00
CaprikaandGitHub 4f647f56ba [codex] Optimize Composio connector previews (#907)
* Optimize Composio connector previews

* Fix partial connector tool preview hydration

* Cancel pending connector authorization on daemon

* Preserve Composio cached tool counts

* Avoid pending state after OAuth launch failure

* Preserve static tool count fallback

* Fix connector preview retry state

* Remove Composio auth config metrics

* Hydrate unknown connector tool previews

* Fix remaining connector review threads

* Stop failed connector preview spinner

* Hydrate only targeted agent connectors
2026-05-08 20:01:06 +08:00
1e8926271b Harden security scan findings and upgrade dependencies (#806)
* feat: add accent color control and launcher for Open Design

* fix: remove launcher binary from PR

* test: cover accent appearance edge cases

* Harden security scan findings and upgrade deps

* Address proxy security review

* Pin jsdom for web test stability

---------

Co-authored-by: ferasbusiness666 <ferasbusiness666@users.noreply.github.com>
Co-authored-by: lefarcen <935902669@qq.com>
2026-05-08 19:46:34 +08:00
d592f6087f feat(mcp): external MCP client with daemon-managed OAuth and 39 design-focused templates (#898)
* feat(mcp): add external MCP client with daemon-managed OAuth and 17 design-focused templates

Open Design now acts as an MCP CLIENT and surfaces tools from third-party
MCP servers to the underlying agent (Claude Code, Hermes, Kimi).

Daemon
- New mcp-config / mcp-oauth / mcp-tokens modules: persist server entries
  to .od/mcp-config.json, run the OAuth dance for HTTP/SSE servers
  end-to-end on the daemon (so cloud deployments work and tokens
  survive across turns), and inject Authorization: Bearer headers into
  the per-spawn .mcp.json the daemon writes for Claude Code (or the
  ACP mcpServers map for Hermes/Kimi).
- /api/mcp/servers and /api/mcp/oauth/{start,status,disconnect}
  endpoints, plus spawn-time wiring in agents that hands the configured
  servers to the active agent CLI.
- System-prompt directive for connected external MCPs so the model
  does not chase Claude Code's synthetic *_authenticate /
  *_complete_authentication tools when the Bearer is already pinned.

Web
- Settings -> External MCP servers panel with per-row OAuth Connect /
  Disconnect / Refresh affordances and per-row template hints.
- New "Add server" picker categorized into 7 groups
  (image-generation, image-editing, web-capture, ui-components,
  data-viz, publishing, utilities) with a search box, sticky close
  button, collapsible <details> sections (auto-expand on search),
  60vh capped scroll region, and a pinned Custom-server footer.
- ChatComposer /mcp slash and MCP picker button forward to the new
  Settings tab; AssistantMessage renders MCP tool calls inline;
  markdown autolinker handles bare http(s) URLs (incl. OAuth links)
  before italic markers so OAuth callback URLs do not get
  italic-fragmented mid-token.

Contracts
- packages/contracts/src/api/mcp.ts owns the wire shapes
  (McpServerConfig, McpTemplate with stable McpTemplateCategory
  enum, McpServersResponse, OAuth start/status/disconnect bodies, the
  postMessage payload from the OAuth callback).

Templates (17 built-in)
- image-generation: Higgsfield (OpenClaw, OAuth HTTP), Pollinations,
  Allyson (animated SVG), AWS Bedrock Image (uvx).
- image-editing: Imagician, ImageSorcery.
- web-capture: just-every screenshot-website-fast, ScreenshotOne.
- ui-components: 21st.dev Magic, shadcn/ui, FlyonUI.
- data-viz: AntV Chart, Mermaid.
- publishing: EdgeOne Pages.
- utilities: Filesystem, GitHub, Fetch.

Tests
- apps/daemon/tests/mcp-{config,oauth,tokens,spawn}.test.ts cover
  storage round-trip, OAuth helpers, token persistence, spawn-time
  wiring, every template's transport / command / args / env-field
  invariants, and the canonical category enum.
- apps/web/tests/runtime/markdown.test.tsx covers the new autolinker
  ordering rules.

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

* feat(mcp): add 21 more design-focused templates and a `design-systems` category

Expands the built-in MCP picker from 17 to 38 templates so users can compose
the full Open Design craft loop (design-system intake → generate → edit →
audit → publish) without leaving the Settings dialog. Every install spec is
verified live against the upstream README; templates that needed Go binaries,
multi-step `init` ceremonies, or massive runtime stacks (PostgreSQL + Redis
+ Ollama) are intentionally deferred so picking a template still resolves to
a working server in one click.

New `design-systems` category between `web-capture` and `ui-components`
(reflects the upstream-of-components position in the workflow). Mirrored in
`McpTemplateCategory` on both contracts and daemon, and `CATEGORY_ORDER` on
the web side.

New templates by category:

- image-generation (+4): prompt-to-asset (icons / favicons / OG / logos with
  free-tier routing across Cloudflare AI / NVIDIA NIM / HF / Stable Horde),
  Nano Banana (hosted streamable HTTP, virtual try-on + product placement),
  Seedream (hosted streamable HTTP, ByteDance Seedream v3-v5 + SeedEdit),
  fal.ai (uvx, 600+ models incl. FLUX / Kling / Hunyuan / MusicGen).
- image-editing (+3): Photopea (34 layered-editor tools — closes the PSD
  gap), Topaz Labs (AI upscale / denoise / sharpen), Transloadit (86+ media
  pipeline robots).
- web-capture (+1): Pagecast (browser → demo GIF / MP4 with auto-zoom).
- design-systems (+4, NEW category): Figma-Context (Framelink, designs →
  code), Design Token Bridge (Tailwind ⇄ CSS ⇄ Figma ⇄ M3 / SwiftUI / W3C
  DTCG + WCAG contrast), Design System Extractor (Storybook scrape),
  Aesthetics Wiki (cottagecore / dark-academia / y2k / … moodboards).
- data-viz (+2): MCP Dashboards (45+ chart types + KPI dashboards),
  Excalidraw Architect (hand-drawn architecture diagrams).
- publishing (+6): PageDrop, PDFSpark, OGForge, QRMint, Slideshot
  (HTML → PDF / PPTX / PNG with 7 themes), Deckrun (Markdown → PDF / video,
  hosted free tier with no key required).
- utilities (+1): A11y axe-core (WCAG 2.0/2.1/2.2 + color-contrast + ARIA).

Tests cover every new template's wiring (command, args, env / header
required-vs-optional, secret flag), the category enum invariant, and
in-category declaration order for image-generation, design-systems and
publishing buckets where the order is what users see in the picker. 21 new
test cases pass; full mcp-config suite is green.

Templates intentionally deferred (documented in PR body): figma-use
(needs Figma desktop with --remote-debugging-port=9222), m-moire (multi-step
`memi suite init` + daemon ceremony), gemini-media-mcp + trident-mcp (Go
binaries — no npx / uvx path), Pixelle-MCP (full app with web UI + ComfyUI
backend), storybook-addon-mcp (lives inside user's Storybook, not standalone),
primitiv (multi-step init / build / serve), ReftrixMCP (PostgreSQL + Redis +
Ollama + DINOv2), narasimhaponnada/mermaid (overlap with peng-shawn).

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

* feat(mcp): add figma-use template (write designs from chat) under design-systems

figma-use is the natural counterpart to Figma-Context already in this PR:
where Framelink reads Figma designs into the model, figma-use writes back
into the canvas (90+ tools — create frames / text / components / variants,
render JSX into Figma, export PNG/SVG, query nodes via XPath, lint for
WCAG / auto-layout / hardcoded colors, analyze design systems).

Wired as an HTTP MCP template (`http://localhost:38451/mcp`) because
`figma-use mcp serve` only exposes HTTP — there's no stdio mode in the
upstream `serve.ts`. No API key. Two prerequisites the user owns are
spelled out in the description so picking the template still resolves to
a working server: (1) start Figma with `--remote-debugging-port=9222`
(or `figma-use daemon start --pipe` on Figma 126+), and (2) leave
`npx figma-use mcp serve` running in a terminal.

Inserted between `design-system-extractor` and `aesthetics-wiki` so the
design-systems category reads as a workflow: read existing design (Figma
Context) → translate tokens (Token Bridge) → extract from Storybook
(Extractor) → write back to Figma (figma-use) → break creative block
(Aesthetics Wiki).

Tests cover the new template's transport (`http`), endpoint URL, the
empty header-fields invariant (no auth required), and bump the
design-systems group order to include it.

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

* feat(settings): i18n the External MCP / MCP server / Connectors sidebar entries and make the dialog header track the active section

The External MCP sidebar entry this PR introduces was hardcoded English
("External MCP / Add MCP tools (Higgsfield, GitHub…)"). Same for the
adjacent Connectors and MCP server entries. The dialog header was also
pinned to "Execution & model" copy, so opening Settings → External MCP
showed a header that lied about which section the user was on.

Adds six translation keys — `settings.connectorsTitle/Hint`,
`settings.mcpServerTitle/Hint`, `settings.externalMcpTitle/Hint` — and
translates them across all 17 locales (ar, de, en, es-ES, fa, fr, hu, id,
ja, ko, pl, pt-BR, ru, tr, uk, zh-CN, zh-TW).

`SettingsDialog` now derives the header title/subtitle from the active
section (11 sections total) instead of a single hardcoded pair, so each
section renders an honest header.

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

* test(e2e): pin level: 3 on dialog heading lookups for Pets and Connectors

CI's Validate workspace job (#1479) failed two Playwright cases with the
strict-mode violation:

  getByRole('dialog').getByRole('heading', { name: 'Pets' })
  resolved to 2 elements:
    1) <h2>Pets</h2>
    2) <h3>Pets</h3>

Same root cause as the unit-test fix already in this PR: the dynamic
dialog `<h2>` now echoes the section's own `<h3>` because the dialog
header tracks the active section. Disambiguate to `level: 3` so each
assertion still pins the section heading specifically (which is what
the test intends to verify).

Audit of the rest of e2e/ for `dialog.getByRole('heading', ...)` —
settings-api-protocol.test.ts looks for "OpenAI API" / "Anthropic API"
section h3s which never appear in the dialog `<h2>` (always
"Execution & model"), so those stay safe.

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

* fix(mcp): bind OAuth refresh to the issuing client and skip stale tokens

Persist the OAuth client context (token endpoint, client_id, client_secret,
issuer, redirect_uri, resource) alongside the bearer token so refresh hits
the same client the refresh_token was bound to (RFC 6749 §6). The previous
refresh path re-ran beginAuth with a dummy OOB redirect URI, which kept
getOrRegisterClient from finding the original DCR client and made
providers reject the refresh on the next chat turn. Refreshes now reuse
the persisted endpoint/client pair directly.

Also stop injecting expired access tokens at spawn time when refresh is
unavailable or fails. Pinning a stale Bearer made every Claude MCP call
401 while the prompt still treated the server as connected; on that path
we now skip the entry and let the UI surface a reconnect.

Generated-By: looper 0.6.1 (runner=fixer, agent=claude-code)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 17:59:20 +08:00
shangxinyu1andGitHub 8fee22d358 Fix stuck chat runs and unintended cancels (#896)
* Fix stuck chat runs and unintended cancels

* Harden chat run stall watchdog
2026-05-08 15:47:44 +08:00
e14b8092ea feat: add Orbit activity summaries (#681)
* feat: add Orbit activity summaries

* fix(orbit): make runs navigable while agent continues

* fix(web): widen minimum chat panel

* feat: support Orbit template selection

* fix(daemon): avoid bogus skill side-file preflight

* fix(web): collapse orbit artifact project cards

* fix(web): preserve orbit project card titles

* fix: improve Orbit run daily briefing

* fix: handle Orbit digest data failures

* fix: load Orbit templates and connector tools reliably

* fix: keep Orbit summary counts consistent

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: apply Orbit template skill context

* fix: cache and curate connector tools for Orbit

* fix: align Orbit defaults and connector discovery

* fix: simplify Orbit template settings

* fix: move connectors into settings

* fix: compact connector settings catalog

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: prevent connector action button from stretching into pill

The icon-only connect/disconnect buttons in the embedded connectors
catalog inherited min-width: 92px / 106px from the non-embedded pill
rules, overriding the 24px square sizing and causing the buttons to
overlap the card head text. Reset min-width to 0 in the embedded
icon-only rule so the compact square layout holds.

* fix(web): align live artifact file rows

* fix: clean up Orbit connector settings lifecycle

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix: address Orbit review regressions

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* feat(web): localize Orbit and connector settings

* feat(web): gate Orbit runs without connectors

* feat(web): refine connector settings UX

* feat(web): safeguard Composio key clearing

* fix(web): refresh Composio tool badges

* feat(web): show connector logos

* feat(daemon): localize Orbit prompt window

* fix(daemon): clarify blocked connector callback closes

* test(daemon): harden flaky async probes

* fix(web): align Indonesian connector locale keys

* test(web): align connector browser props

* fix(web): preserve explicit credential clears

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): time out Composio logo proxy fetches

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): localize Indonesian connector settings copy

Translate the new connector settings strings in the Indonesian locale and lock them with a regression test so this surface no longer silently falls back to English.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): preserve discovered connector tools

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): preserve onboarding autosave completion

Keep settings autosave from clearing onboarding completion after the close gesture, and expose the desktop main types from source so workspace validation can typecheck packaged imports without a prior desktop build.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): defer Composio catalog cache hydration

Load persisted Composio catalog data only after the runtime data directory is configured so startup cannot read another namespace's cache. Add a regression test that exercises the module-load singleton path.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): treat discovery completion independently

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): preserve latest settings draft on close

Use the latest persisted settings draft when the dialog closes so onboarding completion does not race a stale daemon sync and overwrite newer Orbit/template selections.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): avoid syncing draft Composio key on Orbit run

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): localize Orbit settings copy

Translate the new Indonesian Orbit and autosave strings so the settings UI no longer falls back to English and the locale regression stays covered.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): prefer fresh connector catalog state

Keep refetched connector status/auth data authoritative while retaining discovery-only tool metadata so the connectors UI stays consistent after refreshes.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): declare Indonesian locale fallback keys explicitly

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): inline Indonesian fallback strings for CI

Replace the Indonesian locale's per-key English lookups with explicit strings so workspace typecheck no longer depends on brittle build-mode resolution in CI.

Add a regression test that blocks those per-key English lookups from reappearing in the CI-sensitive fallback sections.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): restrict proxied connector logos to image MIME types

Reject non-image upstream logo responses so the daemon never serves third-party HTML from its localhost origin.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* test(e2e): align settings dialog regressions

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): decouple Orbit runs from media sync failures

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): keep SPA catch-all export-compatible

Disable dynamic catch-all params for the exported SPA shell so Next.js static builds can emit the root route again. Add a regression test covering the route config against the web export mode.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): preserve Orbit config and workspace routes

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): block SVG in connector logo proxy

Reject SVG and other unsafe proxied logo responses so third-party logo content cannot execute under the daemon origin, while keeping raster logo fetches working and making rejected responses non-cacheable.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): fall back to static catalog for empty cache

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): disable Orbit run before connector gate resolves

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(desktop): export shipped desktop types

Point the desktop ./main type export at the generated declaration so installed consumers resolve the published file set.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): restore persisted question form selections

Render historical submitted answers directly so reloaded question forms keep their locked selections visible.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): retry forced media sync autosave

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): keep Composio logo timeout through body read

Keep the Composio logo fetch timeout active until the response body is fully consumed so stalled body reads abort and clear the inflight cache entry. Add a regression test that proves a delayed body read times out and the next request can recover.\n\nGenerated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): refresh Orbit gate after connector auth

Re-check connector availability when the settings window regains focus so Orbit unlocks as soon as a connector finishes authenticating in the same settings session.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): keep connector detail tool lists intact

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): ignore malformed Orbit summaries

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(e2e): stabilize design-system multi-select flow

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): cap Composio logo cache growth

Bound the Composio logo cache with LRU eviction and expired-entry pruning so repeated untrusted logo requests cannot grow daemon memory without limit.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): bound proxied Composio logo payloads

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): align autosave settings tests

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): remove stray CSS conflict marker

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fixer: address PR #681 follow-up items

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): restore restart routes and connector flows

* fix(web): keep SPA export route static

* fix(web): stabilize chat scroll tests

---------

Co-authored-by: lefarcen <935902669@qq.com>
2026-05-08 14:27:46 +08:00
lefarcenandGitHub b9d30aa30e test(web): de-flake chat-scroll-preservation across tab switches (#886)
The earlier shape installed instance-level Object.defineProperty mocks
on the *remounted* chat-log only after `await switchTab('Chat')`. Inside
that act() the component schedules a rAF that writes scrollTop on the
new element; depending on whether jsdom's rAF polyfill flushed before
the await resolved, the write either landed on the still-default
prototype setter (lost) or the not-yet-installed instance setter (also
lost). The instance mock's closure-captured remountedTop then served
its initial 0 forever and the assertion failed nondeterministically
across CI runs without any product-code change.

Patch the geometry at HTMLElement.prototype level so any chat-log
React mounts later automatically reads/writes through a
test-controlled `geom` object. The component's restore rAF can fire
at any point and still write to the same place the assertion reads
from. Verified 8/8 clean local runs.
2026-05-08 14:16:12 +08:00
661d11e60b fix(web): confirm before clearing the saved Composio API key (#877)
The Clear button on Settings → Connectors removed the daemon-stored
Composio key in a single click with no recovery — a stray click
wiped a credential the user had to fetch back from app.composio.dev.

Wrap the existing onClick in window.confirm() matching the same
pattern the codebase already uses for destructive actions
(conversation delete, design delete, FileWorkspace file delete,
and the Media providers Clear button shipped alongside this in
issue #737). The prompt copy stays in English to match the rest
of the Composio section, which is hardcoded English today.

Updated the existing 'clears a saved Composio key' test to
auto-accept the prompt, plus added a sibling test asserting that
dismissing the prompt leaves the daemon-stored key intact in the
saved payload.

Co-authored-by: Nagendhra <nagendhra405@gmail.com>
2026-05-08 12:39:04 +08:00
netteeandGitHub 8930b9650c feat: Add a toggle to reveal media provider API keys (#867) 2026-05-08 11:46:21 +08:00
655d561f38 fix(web): show explicit error/retry state when example preview HTML fails to load (#863)
* fix(web): show explicit error/retry state when example preview HTML fails to load

Reporter (#860) saw the example preview modal stuck with the toolbar
buttons greyed out and only restarting the app got back to a usable
state. Lefarcen confirmed the diagnosis: when /api/skills/:id/example
fails, fetchSkillExample returns null, the modal stays at preview.loading
forever, and the share menu's disabled={!activeHtml} guard sits in the
disabled position with no recovery path.

Three changes:

1. fetchSkillExample now returns a discriminated { html } | { error }
   instead of collapsing every failure into null, so callers can tell a
   real fetch failure from a normal load.

2. PreviewView gains an optional error field. When set, PreviewModal
   renders a stacked title/body/Retry affordance instead of the
   indefinite "Loading…" placeholder. Retry re-fires onView so the
   parent can re-run its fetch.

3. ExamplesTab tracks per-skill errors alongside per-skill html, clears
   the in-flight value before each fetch, and wires onView from the
   modal into loadPreview so the Retry button actually retries.

i18n: three new keys (preview.errorTitle, preview.errorBody,
preview.retry), translated across all 17 locales. The locales-aligned
test stays green.

CSS: .ds-modal-error stacks the new content vertically inside the
existing .ds-modal-empty positioning, no other modals affected.

* fix(web): stabilize preview onView and guard parallel preview fetches

Codex caught a real bug in the round-1 fix: the inline
onView={() => loadPreview(...)} prop was recreated on every parent
render, and PreviewModal's mount effect re-fires onView whenever its
identity changes. A persistent fetch failure would update state,
recreate the prop, re-fire the effect, re-run loadPreview, and burn
through the error UI in a flash instead of waiting for a Retry click.

Pin a stable onPreviewView via a useRef-backed callback so the modal
sees a single identity for the lifetime of the panel; loadPreview is
reached through the ref, so its closure refresh on state updates no
longer leaks into the modal's effect dependencies.

While in this surface, also add lefarcen's race guard: a synchronous
inFlightRef Set so two parallel loadPreview calls (e.g. card hover
firing while the modal opens) cannot both pass the cache check before
either setState lands. The first caller adds the id pre-await; the
second sees it and exits early. try/finally clears the entry on both
success and failure paths.

Adds tests/components/preview-modal-error-state.test.tsx covering:
- error UI renders when view.error is set,
- Retry click calls onView with the active view id,
- re-rendering with the same onView identity does not re-fire the
  modal's mount effect (pins the no-auto-retry contract).

* fix(web): close Retry over the active skill id, not the modal-internal view id

mrcfps caught a real regression in round 2: PreviewModal calls
onView(activeId) where activeId is the modal-local view id ('preview'
in this component). The previous round forwarded that argument
straight into loadPreview, so the mount effect and Retry button hit
/api/skills/preview/example instead of /api/skills/{skill-id}/example.
The new error state could not actually recover.

Mirror the active skill id into a ref alongside loadPreviewRef and
have onPreviewView ignore the modal-forwarded argument, fetching the
selected skill via the ref instead. The callback identity stays
stable, so the no-auto-retry contract from round 2 still holds.

Adds tests/components/examples-tab-retry.test.tsx that mounts the
real ExamplesTab, mocks fetchSkillExample to reject, opens the
preview, clicks Retry, and asserts the second call hits the same
skill id (and explicitly never gets called with 'preview').

---------

Co-authored-by: Nagendhra <nagendhra405@gmail.com>
2026-05-08 11:16:14 +08:00
kamiandGitHub 2eae7da24b feat: support Cloudflare Pages custom domains (#851)
* Support Cloudflare Pages custom domains without hiding pages.dev fallback

Keep the default Pages preview as the first public link while optional owned-zone binding provisions DNS and Pages custom-domain state in parallel.

Constraint: Cloudflare deploys must use the existing direct-upload API path with no Wrangler dependency.

Constraint: pages.dev must stay visible even while custom-domain verification is pending.

Rejected: Vercel custom-domain support | outside requested Cloudflare-only scope.

Rejected: overwriting arbitrary CNAME records | risks taking over user-managed DNS.

Confidence: high

Scope-risk: moderate

Directive: Do not expose providerMetadata through public deploy contracts; keep custom-domain DNS ownership checks conservative.

Tested: pnpm --dir apps/daemon exec vitest run -c vitest.config.ts tests/deploy.test.ts tests/deploy-routes.test.ts

Tested: pnpm --filter @open-design/contracts build && pnpm --filter @open-design/contracts typecheck && pnpm --filter @open-design/contracts test

Tested: pnpm --filter @open-design/web typecheck && pnpm --filter @open-design/web test -- providers/registry.test.ts components/FileViewer.test.tsx i18n/locales.test.ts

Tested: pnpm i18n:check && pnpm guard && pnpm typecheck

Tested: pnpm --filter @open-design/daemon build && pnpm --filter @open-design/web build && git diff --check

Not-tested: real Cloudflare account/token/domain smoke test

* Preserve Cloudflare fallback correctness under large accounts and races

Constraint: Cloudflare Pages keeps pages.dev as the primary usable fallback while custom domains remain optional typed metadata.
Rejected: Treating custom-domain DNS or binding failure as a top-level deployment failure | pages.dev can still be ready and usable.
Confidence: high
Scope-risk: moderate
Directive: Keep custom-domain finality tied to Cloudflare Pages API active status plus URL reachability; do not expose providerMetadata.
Tested: pnpm --dir apps/daemon exec vitest run -c vitest.config.ts tests/deploy.test.ts tests/deploy-routes.test.ts; pnpm --filter @open-design/web test -- components/FileViewer.test.tsx i18n/locales.test.ts providers/registry.test.ts; pnpm --filter @open-design/daemon typecheck; pnpm --filter @open-design/web typecheck; pnpm i18n:check; git diff --check; pnpm guard; pnpm typecheck; pnpm --filter @open-design/daemon build; pnpm --filter @open-design/web build
Not-tested: Real Cloudflare token/account/zone smoke test.

* Keep impeccable design notes local

Constraint: .impeccable.md is local assistant/design context and should not be part of the PR diff.
Rejected: Keeping the file tracked while adding it to .gitignore | tracked files are not ignored by Git.
Confidence: high
Scope-risk: narrow
Directive: Keep .impeccable.md untracked and ignored; do not rely on it for required project documentation.
Tested: git check-ignore -v .impeccable.md; git diff --check
Not-tested: Full workspace tests not rerun for ignore-only metadata change.
2026-05-08 11:11:22 +08:00
77824ec029 fix(web): preserve Chat scroll position across Chat/Comments tab switches (#790) (#841)
* fix(web): preserve Chat scroll position across Chat/Comments tab switches (#790)

The chat-log <div> in ChatPane is conditionally rendered (the inner
`{tab === 'chat' ? <>...</> : null}` branch). When the user switches
to Comments and back, the chat-log is unmounted and remounted; the
remounted element starts at scrollTop=0, and the initial-bottom-scroll
effect skips because didInitialScrollRef.current is already true from
the original mount. Result: the conversation view jumps to the top
instead of preserving the user's reading position.

Replaced the empty-deps scroll listener with a tab-keyed effect that:
1. Captures scrollTop in the existing onScroll handler so the saved
   position is always current.
2. On every mount of the chat-log (when tab becomes 'chat'), restores
   the saved scrollTop on the next animation frame so layout finishes
   before the scroll write lands.

The existing scrolledFromBottom signal that drives the jump-to-bottom
button is folded into the same handler and now correctly re-attaches
on every chat-log remount, fixing a secondary issue where that listener
would silently stop firing after a tab toggle.

* fix(web): preserve bottom-pinned chat across off-tab streaming and snapshot on unmount

Round 1 saved an absolute scrollTop, so a user who left Chat while
pinned to the bottom came back above any new messages that streamed
in while Comments was open. Save a discriminated state instead:
{ pinnedToBottom: true } when the user was within 50px of the bottom,
otherwise { scrollTop }. On remount, pinned state snaps to the new
scrollHeight so bottom-followers stay pinned; non-pinned state
restores the absolute offset.

Also snapshot the final scroll state in the effect cleanup before
removing the listener, so programmatic scrolls or layout shifts
right before unmount don't leave the ref stale.

Adds tests/components/chat-scroll-preservation.test.tsx covering
both branches.

* fix(web): clear saved chat scroll state on conversation switch

The savedChatScrollRef persisted across conversation changes, so
switching to Comments while on conversation A and then switching
to conversation B would, on returning to Chat, restore A's
scrollTop instead of starting fresh at the bottom.

Reset the ref alongside didInitialScrollRef when activeConversationId
changes. Added a third test covering the cross-conversation case.

* fix(web): scroll new conversation to its bottom when conv switch happened off-tab

When activeConversationId changed while the user was on the Comments
tab, the conversation-reset effect cleared didInitialScrollRef and
the saved scroll ref, but the initial-bottom-scroll effect couldn't
do anything because logRef.current was null. Returning to Chat then
left the new conversation at scrollTop: 0 instead of its initial
bottom.

Add `tab` to the initial-scroll deps so the effect re-runs when the
chat-log remounts, picks up the cleared didInitialScrollRef state,
and scrolls the fresh conversation to its scrollHeight.

Updated the cross-conversation test to assert the new conversation
lands at its bottom (1000), not at scrollTop: 0.

* fix(web): resync jump-to-latest button when restoring saved chat scroll position

The rAF restore branch wrote scrollTop but never refreshed
scrolledFromBottom, so a user who left Chat ~60px from the bottom
and returned to find new messages stacked underneath would land
hundreds of pixels above the latest turn while the jump-to-latest
button stayed hidden until they manually scrolled.

Recompute the distance and update scrolledFromBottom inside the
restore rAF, mirroring what onScroll already does. Adds a test that
asserts the jump-to-latest button is visible immediately after a
non-pinned restore over a grown scrollHeight.

---------

Co-authored-by: Nagendhra <nagendhra405@gmail.com>
2026-05-08 11:10:56 +08:00
8bb9900603 fix(web): scope settings save validation + sanitize payload to active sidebar section (#739) (#827)
The footer Save button's enabled state was computed purely from execution-mode
completeness (BYOK requires apiKey + model + valid baseUrl; Local CLI requires
a selected available agent). That check ran regardless of which sidebar section
the user was on, so a draft mode toggle on the execution section that left
required fields empty would lock the Save button across every other section.

After clicking BYOK without filling fields and navigating to Language or
Appearance, the user could not save unrelated changes in those sections even
though they had nothing to do with execution mode.

Two paired helpers in apps/web/src/components/SettingsDialog.tsx address this:

shouldEnableSettingsSave(cfg, activeSection, agents, isBaseUrlValid) returns
true on any section other than 'execution' so unrelated sections do not get
blocked by an incomplete execution draft. On 'execution' it keeps the
original mode-completeness check unchanged (within-section invariant).

sanitizeSettingsSavePayload(cfg, initial, activeSection, agents,
isBaseUrlValid) is the counterpart used at the onSave call site. When Save
is enabled on a non-execution section but the user's draft execution config
is incomplete, it reverts the execution-mode fields (mode, apiKey,
apiProtocol, apiVersion, apiProtocolConfigs, apiProviderBaseUrl, baseUrl,
model, agentId, agentCliEnv, maxTokens) to their `initial` values so the
unrelated section change is committed without leaving the app in a broken
execution state. Within the execution section, or when execution is already
valid, the cfg passes through unchanged.

Both lefarcen and chatgpt-codex flagged this persistence gap on the first
revision of this PR; mrcfps marked it blocking. The sanitize helper is the
fix lefarcen suggested (revert-to-initial when the active section is not
execution and the execution draft is incomplete).

Tests in apps/web/tests/components/SettingsDialog.test.ts:
- shouldEnableSettingsSave: 4 cases (the cross-section fix, daemon mode
  validity, api mode validity, regression guard for within-execution).
- sanitizeSettingsSavePayload: 5 cases (revert path, no-op when execution
  is valid, no-op on the execution section itself, every non-execution
  section covered, edge case where the agent registry says unavailable but
  initial cfg was already valid daemon).

Local: web tests 33/33, web typecheck and pnpm guard all clean.

Co-authored-by: Nagendhra <nagendhra405@gmail.com>
2026-05-08 10:57:12 +08:00
56bf6ee1b6 feat: agent-callable research command and /search (#615)
* feat: pre-generation research (Tavily) for grounded generation

Adds an optional pre-generation research step so the agent can produce
slides / prototypes / decks grounded in real sources instead of guessing.

User flow:
  1. Settings -> Tavily Search -> paste API key (or set TAVILY_API_KEY).
  2. Click the new Research button in the chat composer.
  3. On send, the daemon runs a Tavily search, prepends the findings
     as a <research_context> block ahead of the system prompt, and
     spawns the agent. Research progress shows up as status pills in
     the chat stream; the agent cites sources inline as [1]/[2]/...

Phase 1 surface:
  - Single provider (Tavily), single depth ('shallow'), no LLM
    synthesis pass (Tavily's `answer` is the summary).
  - Composer toggle only; no popover / depth picker yet.
  - Reuses the existing `status` SSE agent payload + StatusPill UI
    so no new event variants or renderer code are needed.

Layers touched:
  - contracts: ResearchOptions / Source / Findings DTOs;
    ChatRequest.research; export from index.
  - daemon: apps/daemon/src/research/{index,tavily}.ts orchestrator
    + provider; tavily added to MEDIA_PROVIDERS and ENV_KEYS; hook
    in startChatRun before prompt assembly.
  - web: ChatComposer toggle + ChatSendMeta; threaded through
    ChatPane / ProjectView / streamViaDaemon into ChatRequest.

Side fix (required to land the feature, but useful on its own):
  contracts internal relative imports lacked the `.js` suffix that
  NodeNext module resolution requires. This was already breaking
  `pnpm --filter @open-design/daemon typecheck` on main; without the
  fix, none of the new research types were visible to the daemon.
  All internal contracts imports now carry `.js`.

Spec: specs/current/research-feature.md (phases 2-4 outlined for
follow-up: composer popover, multi-provider, deep recursion, example
skills with research_recommends).

Verified:
  - pnpm --filter @open-design/contracts typecheck/test
  - pnpm --filter @open-design/daemon typecheck (the chokidar
    project-watchers test is a pre-existing flake, unrelated)
  - pnpm --filter @open-design/web typecheck
  - node scripts/verify-media-models.mjs

* fix(daemon): clamp Tavily max_results to 20

Tavily's /search endpoint requires `max_results` in [0, 20]; sending a
larger value (e.g. when `research.depth: "deep"` resolves to 30) returns
400 and `runResearch` silently falls back to no-research. Clamp at the
provider boundary so Phase 2 depth tiers above 20 still produce results
instead of failing the request.

Generated-By: looper 0.6.1 (runner=fixer, agent=claude-code)

* Remove stale research merge leftovers

* Add agent-callable research search

* Fix Indonesian locale typecheck

* Fix research command invocation edge cases

* Harden slash search prompt expansion

* Honor research source caps in command contract

* Require search reports in design files

* Add research data provider settings

* Wire web research provider fallback order

* Update research provider fallback wording

* Revert "Update research provider fallback wording"

This reverts commit 86fb6001e3.

* Revert "Wire web research provider fallback order"

This reverts commit 4c9e16036b.

* Revert "Add research data provider settings"

This reverts commit 23630d1746.

* Add Dexter and Last30Days research skills

* Add DCF and Last30Days OD skills

* Add Last30Days and Dexter skills

* Resolve research review threads

---------

Co-authored-by: a1chzt <chizblank@gmail.com>
2026-05-08 10:33:44 +08:00
shangxinyu1andGitHub 7107623ee2 test: expand entry and settings automation coverage (#811)
* test: harden new project panel metadata coverage

* test: add settings and connector sync coverage

* test: expand entry e2e coverage

* test: satisfy exact optional property types in entry connector flow

* test: keep entry Playwright coverage under e2e/ui

* test: tighten coverage docs and settings test cleanup

* test: drop e2e docs from the guarded package

* docs: move automation coverage docs out of e2e

* test: restore clipboard cleanup without delete

* test: match composio save dialog behavior

* test: avoid placeholder assertion after composio save

* test: expect closeModal on settings saves

* test: align settings save assertions with closeModal flags

* test: fix settings save mocks

* test: align composio replacement hint
2026-05-08 09:30:16 +08:00
d4b547caa7 fix(web): keep saved Composio API key indicator visible while typing a replacement (#741) (#751)
The saved-key badge was wired to `isSavedState = apiKeyConfigured && !hasPendingEdit`,
which made it disappear on the first keystroke as soon as the user
started typing a draft replacement. Users reading the settings panel
saw the saved key indicator vanish before they had clicked Save and
reasonably assumed the stored credential had already been overwritten
or removed. Credential editing is a high-trust workflow; a UI that
fakes a state change before the durable write is the wrong default.

Replaced the boolean derivation with a single helper
`deriveComposioCredentialState` returning one of `empty | pending-new |
saved | saved-pending`. The component now shows the saved-key badge
for both `saved` and `saved-pending`, so the indicator stays anchored
while the user types. The hint text differentiates all four states so
the unsaved-replacement case is still clearly called out.

Helper is exported and unit-tested in
`apps/web/tests/components/SettingsDialog.test.ts` against the
empty, pending-new, saved, and saved-pending states plus the
whitespace-only-draft edge case that should still resolve to
`saved`.

Co-authored-by: Nagendhra <nagendhra405@gmail.com>
2026-05-07 21:12:32 +08:00
kamiandGitHub 09eb88f683 Add Cloudflare Pages artifact deployment
Adds Cloudflare Pages artifact deployment support.
2026-05-07 20:04:22 +08:00