Address review on the Claude session-resume spec:
- Make the live history epoch agent-scoped via a new
conversation_agent_epoch table keyed on (conversationId, agentId)
instead of a conversation-wide conversations.history_epoch column, so
editing a Codex-only turn no longer invalidates Claude's pinned
session. Update Design Decision #3, pseudocode, change scope, web bump
bullet, and the epoch-guard test case.
- Persist a runtimeId runtime-identity fingerprint alongside the session
pointer and add a same-runtime guard to resolveResumeDecision(), the
true OD analog of multica's "same runtime" guard, so a mid-conversation
bin/fork swap (e.g. openclaude) falls back to a transcript spawn
instead of resuming a foreign session. Add the matching test case.
Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code)
Specify conversations.history_epoch as the live epoch source read at run
start, distinct from the pinned conversation_agent_session.historyEpoch, so
resolveResumeDecision has a defined currentHistoryEpoch. Make workDir an
explicit cwd-scope resume guard (pseudocode check, Architecture Overview, and
a Test Strategy case) rather than write-only state.
Generated-By: looper 0.9.2 (runner=fixer, agent=claude-code)
When the design files preview pane was closed, a very long filename
would expand its `<td.df-cell-name>` and push the kind / mtime / menu
columns off-screen — the auto-layout `<table>` had no width
constraint on the cell, so the existing `text-overflow: ellipsis`
on `.df-row-name` never engaged.
The `:not(.no-preview)` overrides in `routines.css` already pinned
the row's children to `width: 100%; max-width: 100%` when a preview
pane was open, but the no-preview state — the one shown in the issue
screenshot — had no equivalent guard.
CSS:
- `.df-cell-name`: add `max-width: 0; min-width: 0` so the table
cell collapses to its column allocation in auto-layout.
- `.df-row-name-wrap`: add `max-width: 100%`.
- `.df-row-name`: add `max-width: 100%; min-width: 0` so the flex
child clamps to the wrap and the existing ellipsis engages.
JSX (DesignFilesPanel.tsx):
- Add `title={f.name}` (and the equivalent for directory rows and
live-artifact rows) on `.df-row-name`. The browser surfaces the
full filename on hover even when the visible text is truncated, so
users can read the leading characters without opening the preview
pane. `<DfPreview>` already renders the full name with
`word-break: break-word`.
Closes#3260
Validation:
- pnpm exec vitest run tests/components/DesignFilesPanel.long-name-truncate.test.tsx
→ 3/3 passed (1 was red on main: title attr was absent)
- pnpm --filter @open-design/web test → 2501/2501 passed (260 files)
- pnpm --filter @open-design/web typecheck → green
- pnpm guard → green
Note: jsdom does not measure layout, so the truncation itself can't
be asserted directly. The specs encode the structural contract the
CSS depends on (cell / wrap / name nesting + the `title` attr) so a
JSX shape change won't silently regress the fix.
* feat(daemon): implement fal.ai renderer for image + video generation
Adds renderFalImage and renderFalVideo backed by the fal queue API
(queue.fal.run). Any fal-ai/* model path can be used directly without
a catalog entry, enabling the full fal model library without code
changes. Catalogued shortcuts are mapped via FAL_ENDPOINTS to their
fal-ai/* paths; OD_FAL_MAX_POLL_MS controls the poll ceiling.
Expands the fal model catalog with flux-pro-ultra, flux-dev-fal,
flux-schnell-fal, ideogram-v3-fal, recraft-v3-fal (images) and
veo-3-fal, veo-2-fal, wan-2.1-t2v, wan-2.1-i2v, seedance-1-pro-fal,
kling-2.1-t2v-fal (video). Marks fal provider as integrated: true in
both daemon and web model registries.
* fix(daemon): address fal renderer review comments
- Correct Wan 2.1 endpoints: wan-video/v2.1/* → fal-ai/wan-t2v / fal-ai/wan-i2v
- Correct Kling 2.1 t2v endpoint: .../pro/... → .../master/text-to-video
- Add FAL_IMAGE_USES_ASPECT_RATIO: flux-pro-ultra sends aspect_ratio not image_size
- Add FAL_VIDEO_NO_DURATION: Wan models reject the duration field
- Add FAL_VIDEO_STRING_DURATION: Veo expects duration as "5s" not 5
- Fix falQueueBase() to use anchored regex replace, avoiding mangled
custom base URLs
- Do not wrap payload under input — raw fal queue HTTP API expects flat
body; the input wrapper is an SDK abstraction only (confirmed by 422
validation error from fal showing prompt missing at body.prompt)
* fix(daemon): correct fal queue protocol comment (flat body, no SDK input wrapper)
* fix(daemon): clamp Veo duration to valid fal buckets (4s/6s/8s)
* fix(daemon): report effective fal Veo duration in providerNote (with snap warning)
* fix(daemon): reduce image generation latency from 4m37s to ~73s
Five layered fixes targeting the overhead that padded a ~10s fal API call
into a 4m37s user-facing wait:
1. Skip DISCOVERY_AND_PHILOSOPHY for media surfaces (image/video/audio).
The ~3000-token HTML-artifact discovery layer is irrelevant for media
generation and forced the agent to parse and override all its rules
before dispatching. Removes it from the system prompt entirely for
these surfaces; MEDIA_GENERATION_CONTRACT is the sole authority.
2. Broaden the wait-loop contract to cover ALL slow models, not just
"Volcengine i2v / hyperframes-html". Any model whose generation
exceeds 25s — including fal flux-pro-ultra, Veo, Sora — returns exit 2
from od media generate. The contract now makes this universal and
provides a python3-based bash pattern (jq is not guaranteed to be
installed on all agent runtimes).
3. Increase od media wait polling budget from 25s to 120s. od media
generate keeps its 25s budget for fast feedback; od media wait is
purpose-built to sit and poll, so it can safely use the full 2-minute
bash-tool window. Reduces re-entries for a 3-minute generation from
~7 to ~2.
4. First fal poll is now immediate instead of always sleeping 3s before
the first status check. Saves 3s for all fal jobs.
5. Project metadata no longer emits "(unknown — ask)" for imageModel and
aspectRatio when unset. Emits the actual defaults (gpt-image-2,
aspect-ratio scene heuristic) so the agent can dispatch without
extended reasoning about model selection. Also adds dispatch-immediately
defaults and a brief-reply rule (2–3 sentences max after generation).
Measured end-to-end on the exact problem prompt before/after:
Before: 4m37s (discovery form + 7x LLM re-entries + jq failure)
After: ~73s (single bash loop, no question turn, image delivered)
* feat(daemon): inject media dispatch hint for non-media project surfaces
Agents running inside prototype, deck, and other non-image/video/audio
projects previously had no knowledge of `od media generate`, so when
asked to create an image with fal they would try to call provider REST
APIs directly and ask the user for API keys — even though the daemon
already holds credentials in .od/media-config.json.
Add MEDIA_DISPATCH_HINT to composeSystemPrompt for all non-media
surfaces. The hint tells the agent to always route media generation
through the daemon dispatcher, and explicitly forbids prompting for
API keys.
Verified end-to-end: a prototype project generates a 952 KB image via
flux-pro-ultra in ~52s with no key errors.
* fix(daemon): prevent agent from converting bash env vars to PowerShell syntax
MEDIA_DISPATCH_HINT now explicitly labels the shell as POSIX bash and
shows the correct $VAR form side-by-side with a warning NOT to use
PowerShell $env:VAR. Without this, claude-sonnet running on a Windows
host converts the example to PowerShell syntax (`& $env:OD_NODE_BIN`)
which then fails at the bash executor with 'syntax error near unexpected
token &'.
* fix(daemon): add generate→wait loop to MEDIA_DISPATCH_HINT for slow models
MEDIA_DISPATCH_HINT previously showed only a bare call.
flux-pro-ultra and other slow models always exit 2 after ~25s — without
the wait loop the agent would treat exit 2 as a failure and report an
error to the user.
Replace the single-command example with the canonical generate→wait loop
(matching media-contract.ts), add an explicit note that exit 2 means
'keep polling', and reinforce the POSIX bash / no-PowerShell rule
directly inside the code block.
* fix(daemon): allow fal-ai/* passthrough in media-agent contract
The media-agent prompt instructed the agent to warn and substitute the
default model for any ID not in the catalogue. This blocked the custom
fal-ai/* passthrough path the daemon already supports, so users could
not reach uncatalogued fal models from the normal chat flow.
Carve out the fal-ai/* exception so the agent passes those IDs through
directly instead of warning or substituting.
* fix(daemon): align MEDIA_DISPATCH_HINT with exit-0 generate contract
media generate now always exits 0 (handoff included). The non-media
agent hint still checked ec==2 to decide whether to keep polling, so
slow fal models (flux-pro-ultra, veo-3-fal) would stop after printing
the handoff JSON instead of entering the wait loop.
- generate error check: drop the ec!=2 exception (exits 0 always)
- while loop: drive on taskId presence, not ec==2; stop on ec==0/5
- footer: remove --surface inference claim; CLI requires it explicitly
* fix(guard): add test-fal-webui.ts to e2e scripts allowlist
CI failed: guard flagged e2e/scripts/test-fal-webui.ts as an
unapproved package-owned entrypoint. Add it to allowedE2eScripts.
* fix(daemon): update prompt test expectations to match exit-0 handoff wording
The two stale assertions checked for the old generate-exits-2 copy which
no longer exists in the contract. Update them to match the current
always-exits-0 wording.
* fix(daemon): move skipDiscoveryBrief override before discovery block
* chore(e2e): remove ad-hoc fal webui test script
The script was a one-time developer helper used to manually validate fal
image generation through the live UI. It relied on a real fal API key and
hardcoded local port, so it cannot participate in the e2e package's
fixture/reporting/CI conventions. Removing it per reviewer feedback.
- Delete e2e/scripts/test-fal-webui.ts
- Remove its guard.ts allowlist entry
- Gitignore the file and its screenshots to prevent accidental re-addition
* chore: remove accidental local scratch files from branch
Remove bash.exe.stackdump (MSYS crash dump) and fix_loop.py (one-off
local rewrite helper) — neither is a repo-owned source artifact.
* fix(prompts): document fal-ai/* passthrough in non-media dispatch hint
Prototype/deck agents now know arbitrary fal-ai/* model ids are valid
--model values and should be forwarded as-is, mirroring the exception
already present in media-contract.ts. Adds a prompt regression test.
* fix(daemon): use renderMediaGenerationContract(mediaExecution) for media surfaces
---------
Co-authored-by: mrcfps <mrc@powerformer.com>
* fix: link od bin after fresh install
* test: lock root od bin shim path
* test: cover root workspace deps in postinstall scan
* chore(nix): refresh pnpm deps hash
* fix: dock comment board without clipping inspect
When the comment-side dock falls back to the stacked layout in narrow
panes, collapsing the side panel now shrinks the bottom strip to a
horizontal rail height instead of keeping the full panel-height row.
commentPreviewCanvasSize() also stops over-deducting the expanded
panel height in the stacked-collapsed path so the canvas sizing stays
in sync with what is rendered.
* fix(web): address docked comment panel review follow-ups
* Fix non-docked comment tool tablet scaling
* test(web): align comment panel tests with collapse API
The right-side @-button tools popover (ToolsPluginsPanel,
ToolsSkillsPanel, ToolsMcpPanel) inserts text into the composer
draft using the textarea's selectionStart at click time, but the
picker rows had `onClick` without `onMouseDown={(e) =>
e.preventDefault()}`. On a real mouse, mousedown fires first, the
textarea loses focus before the click handler runs, and
selectionStart resets — so the inserted token lands at offset 0
instead of at the user's cursor.
The @-mention popover already prevents this by calling
preventDefault on mousedown for every picker row (the comment at
ChatComposer.tsx:3039-3043 explains the reason). This change
mirrors that protection on the three tools-menu pickers.
The mention popover itself was unaffected, so design-file
mentions (which only flow through the @-popover via
`replaceMentionWithText`) are not impacted by this issue. The
reporter's mention of "design files" appears to refer to picking a
file via the @-popover, where the protection was already in place.
Closes#3195
Validation:
- pnpm exec vitest run tests/components/ChatComposer.tools-menu-caret.test.tsx
→ 3/3 passed (red on main, asserts each picker calls
preventDefault on mousedown)
- pnpm --filter @open-design/web test → 2501/2501 passed (260 files)
- pnpm --filter @open-design/web typecheck → green
- pnpm guard → green
The 2026-05 plugins library rebuild introduced /plugins/skills/,
/plugins/systems/, /plugins/templates/ and a unified detail route
/plugins/<manifest-slug>/, but the old /skills/, /systems/, /templates/
catalogs were left live in parallel. Two equivalent page trees split SEO
equity, and the homepage, footer, quickstart, agents, official and blog
pages all still linked to the old routes.
Retire the legacy generators and 301 every old URL to its new plugins
equivalent so inbound links and search equity are preserved:
- Remove the /skills, /systems, /templates page generators (English +
[locale] wrappers) and the now-orphaned skill-row component, and prune
the skills/systems/templates branches from the [locale]/[...path]
catch-all (it now renders only craft + blog).
- Add the migration block to public/_redirects. Detail slugs differ from
the old folder names (new slugs are manifest-name based, e.g.
design-system-<x>, example-<x>), so systems/templates use a prefixed
splat plus a short degrade list, and skills map the 27 with a template
equivalent explicitly while the ~110 instruction-only skills and all
mode/scenario/category facet pages degrade to the section landing.
'replicate' is forced to the section to avoid colliding with the
design-system of the same name. Locale variants (zh, zh-tw, ja, ko)
strip to the section.
- Repoint in-site links to /plugins/* across page.tsx (footer, work,
labs pills), info-page-i18n.ts (en + zh + sourceNames), official,
quickstart, agents, blog and html-anything, and update the sitemap
serialize priority list. The system-card keeps linking through
/systems/<slug>/ so the 8 systems without a detail page ride the
redirect's degrade rather than pointing at a missing page.
Verified with a full astro build: old routes no longer emit any HTML,
the new section pages exist, _redirects is copied verbatim, and no
in-site link targets a removed route (the remaining /systems/<slug>/
hrefs are the system cards that 301 by design). astro check passes.
Co-authored-by: Joey-nexu <joeylee12629@gmail.com>
ChatComposer tracks the `@…` tokens this surface authored via the
@-mention popover plugin-pick path. When PluginsSection's chip strip
clears, we wire its `onCleared` and prune *only* those tracked
insertions from the draft so the textarea no longer holds orphaned
styled mentions whose chips just unmounted.
Architecture summary (rounds 1–9 collapsed; round 10 detailed below):
- `Array<{token, start, pluginId, insertionId?}>` tracking with
start offsets reconciled across each keystroke via an LCS+LCP
edit-range diff in
`apps/web/src/utils/pluginInsertionTracking.ts` (round 3-4).
`insertionId` is forwarded by `reconcileInsertions` so the
producer can locate its own entry across reconciles
(round 10).
- All draft mutations route through a single `updateDraft`
chokepoint that runs `reconcileInsertions` outside the
`setDraft` updater so React StrictMode's double-invoke is
harmless (round 4-5).
- Boundaries delegate to the shared
`inlineMentions.isMentionBoundary` /
`inlineMentions.isMentionRightBoundary` helpers so the
tracker can never diverge from the parser (round 5).
- `setActivePlugin` is a chokepoint for every applyById path,
filtering tracked entries to those matching the new active
plugin so a replace-plugin flow can never let stale entries
survive (round 6).
- Picker rollback double-snapshots draft + tracker so apply-
failure restores the tracker but only rewrites the draft
when no user keystrokes arrived during the await
(round 7-8).
- `stripPluginInsertedTokens` collapses whitespace seam-local
so user-authored multi-space spans elsewhere are preserved
(round 8).
- `setActivePlugin` is deferred past `await applyById` on
every path, and `onCleared` filters by
`pluginsSectionRef.current?.getActiveRecord()?.id` so a
pending-window clear scopes to the actually-mounted
plugin's tokens (round 9).
race in the picker rollback:
Round 9 made `onCleared` mutate the tracker and the draft when
it ran during a pending replace, and added the `getActiveRecord`
filter so the strip targets the still-mounted plugin's entries
only. The picker's failure-path rollback, however, still
restored `prevEntries` / `prevActiveId` wholesale — assuming
nothing else had touched the tracker during the await. If the
user clicked the still-mounted original chip's × during the
pending replace AND the deferred `applyById` then resolved
with a 500, the wholesale restore (a) resurrected entries that
`onCleared` had legitimately stripped (now stale offsets) and
(b) left the optimistic `@<target>` orphaned in the draft with
no chip ever having mounted — the original #2881 symptom
recurring inside the failure window.
Fix splits the failure rollback into two paths:
1. **Detect "intervening clear" via `activePluginIdRef.current
=== null && prevActiveId !== null`.** `onCleared` always
nulls the active id as its last action; our deferred
`setActivePlugin` never ran in the failure branch. So the
null-while-prev-not-null state is the smoking gun for an
intervening clear during the await.
2. **On detection, surgically remove only our optimistic
entry and only its `@<target>`.** Locate the entry by
`insertionId` (added to `TrackedInsertion` as an optional
field, forwarded by `reconcileInsertions` so the id
survives offset shifts) — this disambiguates the case
where the user picked the same plugin from the @-popover
more than once during the await window. Splice that entry
out and run `updateDraft((d) => stripPluginInsertedTokens(
d, [ourEntry]))` so the draft loses `@<target>` and any
remaining tracked entries (the in-flight target would have
no others, but a co-pending second pick could) get their
offsets reconciled. `activePluginIdRef` stays at `null` —
`onCleared`'s truth, since no chip is mounted.
The "no intervening clear" branch is the round 7/8 path:
restore `prevEntries`/`prevActiveId` wholesale and rewrite
the draft only if `draftRef.current === postInsertDraft`
(no user keystrokes during the await).
Regression coverage (additions):
- `apps/web/tests/components/ChatComposer.plugin-clear-prunes-draft.test.tsx`
— 18 integration specs total (17 prior + 1 new round-10):
* `@-popover pick A → @-popover pick B (apply pending) →
clear A's chip → resolve B with 500 → assert no orphan
@<target>, no orphan @A, no chip mounted, no stale
tracker entries`. Uses a deferred `Promise<Response>` so
the apply stays in flight while the chip-clear is fired,
then resolves with a 500 to drive the failure path. Pre-
fix this would resurrect Airbnb's stale entry AND leave
`@SecondPlugin` orphaned in the draft.
PluginsSection.tsx is unchanged. The host-local tracking +
draft-update chokepoint + parser-aligned boundaries + deferred
active-plugin scoping + transactional applyById + intervening-
clear-aware rollback + filtered `onCleared` keep the cross-
component contract identical to main — only ChatComposer touches
behavior, plus the utils module and two `inlineMentions` exports.
Validation:
- pnpm exec vitest run tests/utils/pluginInsertionTracking.test.ts → 36/36 passed
- pnpm exec vitest run tests/components/ChatComposer.plugin-clear-prunes-draft.test.tsx → 18/18 passed
- pnpm exec vitest run -c vitest.config.ts (full apps/web suite, 228 files) → 2202/2202 passed
- pnpm --filter @open-design/web typecheck → green
- pnpm guard → green
* feat(web): wire generation preview stage into workspace
Show a 3-step progress overlay (understand → generate → prepare) in the
preview area while artifacts are being generated, replacing the blank
empty state. Displays elapsed time, an estimated duration hint, and a
retry button on failure.
- Add GenerationPreviewStage component + CSS module + runtime helpers
- Integrate buildGenerationPreviewState into FileWorkspace
- Pass messages/artifact/error/retry from ProjectView to FileWorkspace
- Register i18n keys for en and zh-CN locales
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): keep generation preview alive and persistent across waiting states
Address UX feedback on the generation preview surface:
- Make the waiting card feel alive instead of frozen: breathing mark,
sweeping progress shimmer, pulsing running-step dot, and a live
activity snippet pulled from streamed events (respects
prefers-reduced-motion).
- Add an `awaiting-input` phase so the preview no longer reverts to the
empty "design will appear here" placeholder when the agent asks the
user a clarifying question (detects inline <question-form>).
- Add a `stopped` phase so a canceled/paused run keeps a contextual
paused card instead of blanking the surface.
- Fix workspaceHasPreviewSurface live-artifact tab match (was reading a
non-existent `tabId` field) and correct the unit assertion that
contradicted the helper's `thinking` handling.
- Populate generationPreview.* keys (incl. new awaiting/stopped strings)
across all locales.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): reveal generation steps progressively as the agent reaches them
- Only render steps the agent has actually reached (drop pending pills)
with a slide/fade entrance, so the card visibly evolves 1->2->3 instead
of always showing the same fully-populated row.
- Keep the "understand" step in progress during requesting/starting so a
fresh run opens with a single step rather than a pre-filled set.
- Stop surfacing status detail (e.g. the model slug from `requesting`) as
the live activity line; only genuine thinking/output text is shown.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): add dynamic sub-status to the generating step
Keep 3 high-level steps but give the long "generating" phase concrete,
moving feedback (option A) instead of splitting into more, less-reliable
steps:
- Derive a sub-status from the agent's TodoWrite plan: the in-progress
task label (activeForm) plus a done/total count, falling back to the
latest write/edit target file when no plan was emitted.
- The count counts the in-progress task toward `done` to match the
chat-side todo card (e.g. 3/7 on both sides).
- Suppress the higher-level narration line while the sub-status is shown
so only one dynamic line appears at a time (early phase = narration,
writing phase = concrete task + count).
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): drop elapsed timer and duplicate estimate from generation preview
The "usually 2–5 minutes" estimate showed twice (lead footnote + meta row)
and the elapsed counter added little signal, so remove both: delete the
meta row, stop falling back to the estimate footnote in the generating
lead (render the lead only when live narration exists), and drop the now
unused elapsed timer/util.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): make hand-off no-editors fallback perform a real reveal
The Finder/Explorer/File Manager fallback button was only calling an
optional onRequestRevealInFinder prop that the actual caller never
passes, so the surface advertised an action it never performed.
finder, explorer, and file-manager are real entries in the daemon's
open-in catalogue (open / explorer / xdg-open), so route the fallback
through openProjectInEditor(projectId, fallbackId) for a genuine
reveal. Keep the renderer reveal bridge as a secondary fallback if
the daemon spawn fails, and disable the button while busy so a double
click can't queue two reveals.
Adjacent: PreviewDrawOverlay's Send-while-streaming behavior is
intentional (sending is queued downstream, not blocked), and the
button already carries sendDisabledReason as its tooltip. Cover that
contract with a regression test so a future change can't silently
re-disable the control or drop the localized reason.
Scope note: the i18n hand-off key migration that previously rode on
this branch landed on main via a different key set, so this PR is
narrowed to just the fallback wire-up and the two regression tests.
* fix(web): surface daemon spawn failure inline in zero-editors fallback
The zero-editors HandoffButton fallback called setError() on a rejected
openProjectInEditor but returned only the <button>, so the error never
rendered. Production callers (ProjectView) mount the component without
onRequestRevealInFinder, so a daemon spawn failure became a silent no-op
— exactly the failure mode the PR was meant to cover.
Wrap the solo button in a handoff-wrap container and render the error
inline next to it. Adds a regression test for the rejected-spawn path.
* fix(web): align preview draw send-disabled test
* fix(web): show handoff fallback for zero editors
---------
Co-authored-by: nicejames <nicejames@gmail.com>
Co-authored-by: mrcfps <mrc@powerformer.com>
`opencode run`'s `-f, --file` is a yargs array option that greedily
consumes every trailing non-flag token, so the memory extractor's
`--file <prompt-file> "<message>"` invocation made OpenCode treat the
message text as a second attachment and exit 1 with "File not found".
Every LLM memory extraction failed for OpenCode Local CLI users.
Deliver the prompt on stdin like the chat-run path (def.promptViaStdin)
and drop the --file attachment. The connector-memory test now models
the real yargs --file array-greediness so it would catch a regression.
Long project names in the "Existing projects" section of the
automation project picker rendered verbatim with no truncate styling,
so a single name like "A very long project name that would otherwise
wrap onto several lines" blew up the row height and made the dropdown
messy to scan. The expected behavior is a single-line label with
ellipsis, with the full name still discoverable on hover.
Add the standard truncate triad (`white-space: nowrap`,
`overflow: hidden`, `text-overflow: ellipsis`) to
`.automation-popover__label`. The parent
`.automation-popover__body` already sets `min-width: 0`, so the
ellipsis renders cleanly. Thread an optional `title` prop through
`PopoverItem` and pass each project's full name from the picker
call site, so the native hover tooltip carries the unclipped name.
Other PopoverItems with fixed in-product copy (e.g. "New project
each run") deliberately omit the title — they never exceed the row
width and the redundant tooltip would be noise.
Regression test covers the DOM contract (every project row has
`title=<full name>`, fixed rows do not); the CSS half is verified by
code review since jsdom does not apply stylesheets.
- apps/web/src/components/ChatComposer.tsx
- apps/web/tests/components/ChatComposer.context-pickers.test.tsx
Clear stale absolute anchors when the pet composer menu is positioned fixed so the popover wraps its content instead of collapsing over the composer textarea.
* fix(daemon): detect and strip fabricated role markers in model output (#3247)
Three-layer defence against models emitting `## user` / `## assistant` /
`## system` lines mid-response, which the chat host interprets as real
turn boundaries and acts on as unauthorised instruction:
1. **System prompt**: anti-roleplay instruction elevated from a bullet
under "What you don't do" to a standalone `## CRITICAL` section in
`official-system.ts`, with a REMINDER pinned at the end of the
composed prompt for recency bias.
2. **Stream-level detection and truncation**: shared `role-marker-guard.ts`
module (`createRoleMarkerGuard` + `FABRICATED_ROLE_MARKER_RE`) used
across all text paths — Claude stream (per-message guards), non-Claude
structured streams (run-scoped guard via `emitGuardedTextDelta`),
and BYOK proxy routes (`createDeltaGuard`). When a marker is detected,
the contaminated suffix is dropped and a `fabricated_role_marker` event
surfaces a warning in the UI.
3. **UI**: `StatusPill` gains `is-warning` / `is-error` CSS variants;
`fabricated_role_marker` events render as amber warning pills.
* fix(chat-routes): do not await reader.cancel() on stream early-return
The await on reader.cancel() can hang indefinitely on response streams
whose underlying source is a Uint8Array (most notably surfaced by the
ollama test in proxy-routes.test.ts, which builds its mock body via
`new Response(uint8array)` rather than the controller-based helper
`sseResponse()`). The hung await holds the request handler open, which
in turn blocks `server.close()` in the afterAll hook, producing the two
test timeouts (test at 145, hook at 36) currently failing CI on #3296.
Fix is in production code, not the test: don't await the cancel. It
is a cleanup hint and we are returning from the function anyway, so
blocking on it offers no value. fire-and-forget with an empty catch
keeps the cancel signal flowing for real HTTP streams without
risking a hang on mock/edge-case implementations.
Co-Authored-By: JasonBroderick <jason@buddyboss.com>
* fix(daemon): terminate child on role-marker detection (close#3247 generation vector)
PR #3296's detection layer truncates display and persistence of fabricated
role markers, but the underlying model subprocess keeps generating tokens
after detection. Three concrete consequences:
1. The model bills the user for the entire contaminated response
(we observed 5,106 chars stored in claude's session file for a turn
where only the first 3,013 chars were legitimate — a 40% overhead).
2. tool_use blocks emitted AFTER the marker reach the daemon's
dispatcher unchecked, since detection only gates the text-delta
emission path, not content-block-stop / tool_use blocks. The
model could fabricate "## user delete file X" then emit a
tool_use(delete X) that the dispatcher would execute.
3. The UI surfaces a `fabricated_role_marker` warning followed by an
eventual normal turn-end, blurring the distinction between
"completed normally" and "killed by safety guard."
This commit adds a single idempotent `abortForRoleMarker(marker)`
helper in server.ts, scoped to the same closure as `child` and
`runGuard`. On any detection event (per-message Claude guard,
run-scoped non-Claude guard, plain stdout guard) the helper:
- Emits a structured `ROLE_MARKER_HALLUCINATION` SSE error so the
UI can render a security-class status distinct from a normal
turn-end. The existing `fabricated_role_marker` warning is still
sent and rendered as the amber pill (PR #3296's UI).
- Calls `acpSession.abort()` for ACP-multiplexed agents (Hermes,
Kimi, Devin, Kiro) whose I/O doesn't necessarily release on
SIGTERM of the wrapper process alone.
- SIGTERMs the child immediately, with the existing
`scheduleForcedChildShutdown()` SIGKILL fallback at 2x grace.
Wired into three sites where contamination is detected:
- `emitGuardedTextDelta` (sendAgentEvent / copilot / ACP / pi-rpc
text_delta paths)
- Plain-stdout listener (BYOK plain mode)
- The Claude stream handler's onEvent (per-message guards in
claude-stream.ts surface `fabricated_role_marker` events directly
via onEvent rather than through the run-scoped emitGuardedTextDelta)
Tool_use blocks emitted BEFORE the marker still flow through normally
— this guard can't help with those, since by the time we observe a
text marker the prior content block has already finished. Closing
that gap requires speculative cancellation of in-flight tool calls
when a downstream text block contains a marker; that's tracked as
follow-up work, not included here.
Co-Authored-By: roverkai <2196140098@qq.com>
Co-Authored-By: JasonBroderick <jason@buddyboss.com>
* refactor(role-marker-guard): bounded tail + drop chat-style markers
Addresses two review comments on #3303:
(1) O(1) memory + per-delta work (review r3323982225)
Replace the unbounded `accumulated` string with a rolling tail capped
at TAIL_BUFFER_SIZE (64 chars — comfortably exceeds the longest
marker prefix `\n<whitespace>## assistant` ≈ 16–24 chars in practice).
A 50 KB assistant response delivered in 1000 chunks of 50 bytes was
previously O(n²) on string concatenation alone; now it is O(1) per
delta regardless of message length. The `tail.length` value carries
the "already emitted" offset that the cut-point math needs, so the
offset semantics at L74–78 of the prior implementation are preserved
without re-introducing the full-text buffer.
(2) Drop chat-style markers entirely (review r3323982234, option (a))
`User:` / `Assistant:` / `Human:` / `AI:` are removed from the regex.
Rationale:
- The host parses ONLY `## user` / `## assistant` / `## system`
lines as turn boundaries (see `buildDaemonTranscript` in
apps/web/src/providers/daemon.ts). A model emitting chat-style
markers does NOT cause the original #3247 security failure.
- With kill-on-detection wired in this PR (`abortForRoleMarker`
in server.ts), a false positive aborts the whole run — far
more expensive than a stray unflagged `User:` line in chat
scrollback. Chat-style markers collide with legitimate output
(form labels, email contacts, JSDoc) often enough that pairing
them with kill-semantics is the wrong tradeoff.
The tradeoff is now documented in the regex docblock so the
kill-on-match behaviour is justified against the false-positive
surface.
Also aligns the prompt-side CRITICAL block in system.ts: drop the
"don't emit User: / Assistant: / Human: / AI:" bullet, since we no
longer enforce it. Less ambiguity for the model and the operators.
Test file updated:
- Chat-style positive tests flipped to negative ("does NOT match
User: — chat-style out of scope") so the intentional exclusion
has a permanent regression test.
- Two new tests cover the bounded-tail behaviour: a marker arriving
after 10 KB of clean text in small chunks, and a marker
straddling a chunk boundary after 100 prior chunks.
- Added test for legitimate `User: bob@example.com`-style content
not triggering contamination.
Test count is now 35 (up from 25); two of the new ones explicitly
exercise the new bounded-tail path.
Co-Authored-By: JasonBroderick <jason@buddyboss.com>
* fix(role-marker-guard): drop \`^\` anchor after first chunk (review r3324060995)
Blocking correctness bug introduced by commit 4 (bounded-tail refactor):
once \`tail\` is a rolling slice of mid-stream text, \`^\` in the
canonical regex \`(?:^|\\n)\\s*##\\s+(?:user|...)\` no longer represents
the genuine message start. As the rolling window slides forward chunk
by chunk, a sliced tail can begin with whitespace + \`##\` (or just
\`##\`), letting \`^\` anchor a match against text that the
full-buffer implementation correctly ignored. With kill-on-detection
wired in commit 3, that false positive now SIGTERMs the run and emits
a \`ROLE_MARKER_HALLUCINATION\` error — exactly the failure class
called out in the docblock at L22–29.
Reviewer's evidence (PerishCode, r3324060995): streaming
"…take a look at the ## user content section…" one character at a
time reports \`contaminated: true\` post-refactor; the same text in a
single feed stays clean.
Fix: keep the canonical \`FABRICATED_ROLE_MARKER_RE\` for the very
first non-empty feed (where \`^\` legitimately points at the message
start), and switch to an internal \`NEWLINE_ANCHORED_ROLE_MARKER_RE\`
(\`\\n\\s*##\\s+(?:user|...)\` — drops the \`^\` alternative) for all
subsequent feeds. A \`firstChunk\` boolean tracks the state. Real
newline-preceded markers straddling chunk boundaries are still caught
because the preceding \`\\n\` is retained inside the 64-char tail.
Regression tests added (\`apps/daemon/tests/role-marker-guard.test.ts\`):
- mid-line \`## user\` streamed char-by-char with no preceding \\n
(mirrors the reviewer's repro)
- space-preceded mid-line \`## user\` in a >130-char stream, which
long enough to force the rolling window past the marker — exercises
the exact slice condition that triggered the bug
- real \\n-preceded \`## user\` still caught after a long preamble
(positive case must not regress)
- \`## user\` as the very first chunk still caught (\`^\` legitimately
anchors on the first feed)
Co-Authored-By: JasonBroderick <jason@buddyboss.com>
* fix(role-marker-guard): case-sensitive + tighter prefix scope (reviews r3324151877 / r3324151882)
Two refinements addressing the third review on #3303:
== Blocking (r3324151877) ==
The regex over-matched legitimate Markdown headings, and with
kill-on-detection wired in commit 3 each false positive
deterministically aborts a real run. Three changes tighten the match
to the actual security surface — `## user` / `## assistant` /
`## system` lines the chat host parses as turn boundaries — without
losing any real attack pattern:
1. CASE-SENSITIVE. Dropped the `/i` flag. The host's turn-boundary
delimiter is lowercase (see `buildDaemonTranscript` in
apps/web/src/providers/daemon.ts), and the `## CRITICAL`
system-prompt block already forbids only the lowercase forms.
Title-Case headings like `## User Guide`, `## System Architecture`,
`## Assistant settings` are now ignored — these are legitimate
technical writing patterns LLMs emit constantly. `## USER NOTES`
(all-caps) likewise no longer flags.
2. POSITIVE LOOKAHEAD `(?=[^a-z])` after the role keyword. Without it,
`## userland`, `## userspace`, `## users guide`, `## systemd`,
`## assistance` all match via prefix in the alternation. The
lookahead requires the next character to exist and to not be a
lowercase letter, so:
- `## user\\n…` → match (newline is not lowercase)
- `## assistantR…` → match (R is uppercase; the glued-form
attack pattern still gets caught)
- `## assistant.` → match (. is not a letter)
- `## users guide` → no match (s is lowercase letter)
- `## userland` → no match (l is lowercase letter)
POSITIVE rather than NEGATIVE `(?![a-z])` because the negative
form is satisfied at end-of-string, which in a streaming context
means "we have `## user` but don't know what comes next yet" —
would fire prematurely if `land` arrives in a later chunk. The
positive form delays detection by one character in that edge
case, traded for correctness.
3. `[ \\t]` instead of `\\s` for inner whitespace. Markdown role
markers are single-line by convention; restricting to space/tab
prevents oddities like `##\\nuser` from matching across lines.
Test file: added Title-Case fixtures (`## User Guide`,
`## System Architecture`, `## Assistant settings`, `## USER NOTES`)
and prefix-of-longer-word fixtures (`## users guide`, `## userland`,
`## systemd`, `## assistance`) — each asserting NO contamination.
The existing `## usability` negative test gave false confidence as
the reviewer noted (only failed via alternation-miss, not via
word-boundary semantics); the new fixtures actually exercise the
lookahead. Also added a positive test for `## assistant.` (glued
punctuation) to balance the existing `## assistantReading`
(glued uppercase) coverage. Total tests: 35 → 50.
== Non-blocking (r3324151882) ==
Added `ROLE_MARKER_HALLUCINATION` to `API_ERROR_CODES` in
`packages/contracts/src/errors.ts` alongside the existing agent/AMR
codes, with a docblock comment explaining the emission contract:
emitted by `server.ts::abortForRoleMarker` alongside the existing
`fabricated_role_marker` warning event when the daemon detects a
fabricated Markdown role marker in agent output; retryable. The code
was already being emitted over the wire but unregistered — landing
the registration here keeps the contract and emitter in sync as
reviewer requested.
Co-Authored-By: JasonBroderick <jason@buddyboss.com>
* fix(role-marker-guard): defer complete-but-unconfirmed marker suffix
Addresses review r3324277xxx — the boundary case where a stream chunk
boundary lands between the role keyword and its lookahead character
violated the documented "everything from the marker onward is silently
dropped" contract. With (?=[^a-z]) as the lookahead, `feedText('## user')`
returned `## user` as safe (no char to satisfy the lookahead → no match
→ pass through), so the fabricated marker line leaked into UI and
app.sqlite before the next chunk confirmed contamination on the next
SIGTERM cycle.
Fix: introduce a `pending` state variable holding bytes that match the
COMPLETE-but-unconfirmed marker prefix at end of buffer
(/(?:^|\\n)[ \\t]*##[ \\t]+(?:user|assistant|assist|system)$/, no
lookahead, $ anchor instead). When the no-match branch detects this
suffix, withhold it from emission until the next feed either:
- Confirms it (next char non-lowercase) → main regex matches →
contaminated → withheld bytes dropped along with `## user`.
- Denies it (next char lowercase, e.g. `userl…`) → main regex no
longer matches the role keyword → withheld suffix is released
and emitted alongside the new continuation.
Also tied the firstChunk transition to actual byte emission rather
than feed count. Previously a message that starts with `## system`
followed by a separate `\\n` chunk would lose the `^` anchor on the
second feed (firstChunk had flipped after the first feed even though
nothing was emitted yet), silently breaking detection for that edge
case. Now `firstChunk` stays true until at least one byte has crossed
the emission boundary, matching the conceptual definition of "message
start".
Tests added (apps/daemon/tests/role-marker-guard.test.ts):
- `## user` deferred at chunk boundary, confirmed by `\\n` in next
- `## user` deferred at chunk boundary, denied by `land` continuation
- `## assistant` deferred, confirmed by punctuation
- `## User` Title-Case still passes through unconditionally
- `## system` as the very first chunk: deferred, confirmed by \\n
in next chunk (tests the firstChunk-stays-true-when-nothing-
emitted invariant)
Total tests: 50 → 55.
Co-Authored-By: JasonBroderick <jason@buddyboss.com>
* fix(claude-stream): scope role-marker guard to text_delta only, not thinking_delta
Addresses review r3324xxxxxx — guarding the thinking channel buys no
security and causes legitimate aborts.
Why thinking is NOT a #3247 vector:
- `buildDaemonTranscript` in apps/web/src/providers/daemon.ts only
re-serializes `m.content` as `## ${m.role}\n...`.
- Extended-thinking content is rendered to a separate
`kind: 'thinking'` payload (daemon.ts:857-858) and never folded
into `m.content`.
- So a `## user` line in the thinking channel CANNOT become a
fabricated turn boundary on the next round-trip.
Why guarding it is harmful:
- Models routinely emit literal `## user` / `## assistant` lines
in chain-of-thought when reasoning about conversation structure
("Let me think about this. The user might phrase it as:\n## user\n
…"). Common pattern in production traces.
- With `abortForRoleMarker` wired in server.ts, a guard match on
thinking SIGTERMs the run and surfaces a security error to the
UI. The user paid for the reasoning, never sees the answer, and
gets a confusing "fabricated role marker" warning for what was
actually legitimate metacognition.
- This directly contradicts the module's own stated philosophy
("a false positive aborts the whole run — a much more expensive
failure than a stray unflagged ... line", role-marker-guard.ts).
Fix: `emitSafeText` now passes thinking_delta through unconditionally,
skipping both the guard and the contamination check. text_delta
remains fully guarded. The single-line change at the top of
emitSafeText preserves all other channels' behavior.
Regression tests added (apps/daemon/tests/claude-stream-thinking.test.ts):
- `## user` / `## assistant` lines in a thinking_delta — must NOT
fire fabricated_role_marker, the thinking content streams intact
including the marker text, and the subsequent text_delta answer
still reaches the consumer (run not aborted).
- Sanity check: same `## user` pattern in a text_delta DOES fire
fabricated_role_marker and truncates emission at the marker. Locks
in the channel-discriminated behavior.
Co-Authored-By: JasonBroderick <jason@buddyboss.com>
* fix(role-marker-guard): tie firstChunk to slicing, not byte emission
Blocking review r3324xxxxxx: under the prior firstChunk transition
("any byte emitted"), a role marker that arrived at the very start of
a message with its prefix split across multiple chunks bypassed
detection — reopening the #3247 vector on the Claude path.
Concrete cases that were missed (all are routine provider
tokenizations of \`## user\n…\` at message start):
- \`##\` | \` user\nDELETE…\`
- \`## us\` | \`er\nDELETE…\`
- \`## \` | \`user\nDELETE…\`
Mechanism: the pending-deferral regex only catches COMPLETE role
keywords, so a first chunk ending in a partial prefix (\`##\`, \`## \`,
\`## us\`) was emitted in full. That emission flipped firstChunk to
false. From that point only NEWLINE_ANCHORED_ROLE_MARKER_RE was used,
which requires a literal \n before \`##\`. A marker at buffer
position 0 has no preceding \n, so it could no longer match.
abortForRoleMarker never fired and tool_use blocks emitted after the
fabricated turn boundary reached the dispatcher.
Fix: change firstChunk to track "tail has not been sliced yet" rather
than "any byte emitted". While total emitted bytes <= TAIL_BUFFER_SIZE,
tail still represents the entire emission so far and \`^\` in the
canonical regex genuinely anchors at byte 0 of the stream — so the
\`^|\n\` alternation safely catches a chunk-split message-start
marker. The transition happens at the moment we would slice: once
emitted > TAIL_BUFFER_SIZE, tail becomes a mid-stream window, \`^\`
becomes meaningless, and we switch to the newline-only variants.
Earlier iterations of this code tried two other definitions, both
unsound:
- "any byte emitted" (this commit fixes) — lost \`^\` before a
chunk-split message-start marker could finish arriving.
- "newline emitted" (briefly considered as the reviewer's
alternative suggestion) — left \`^\` valid on a sliced buffer
when streams hadn't emitted a newline yet, re-introducing the
rolling-tail mid-stream false positive from review r3324060995.
The slice-based invariant satisfies both: while we have not sliced,
\`^\` is correct; once we slice, it is not.
Regression tests added (apps/daemon/tests/role-marker-guard.test.ts):
- \`##\` | \` user\nDELETE…\` → contaminated, marker=\`## user\`
- \`## us\` | \`er\nDELETE…\` → contaminated, marker=\`## user\`
- \`## \` | \`user\nDELETE…\` → contaminated, marker=\`## user\`
- \`#\` | \`# user\nDELETE…\` → contaminated, marker=\`## user\`
The fourth case (single \`#\` first chunk) exercises an even more
adversarial tokenization than the reviewer's examples; it is also
caught.
Total tests: 55 → 59.
Co-Authored-By: JasonBroderick <jason@buddyboss.com>
* fix(tests): wrap events in stream_event envelope in thinking test
feedJsonl was feeding raw events without the `{ type: 'stream_event',
event: ... }` wrapper that createClaudeStreamHandler requires (line 141
of claude-stream.ts). Events silently fell through all branches, making
both tests pass vacuously. Also fix TS2532 on warnings[0].marker with
non-null assertion (safe after the toHaveLength(1) guard).
Co-Authored-By: RoverKai <roverkai@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: roverkai <2196140098@qq.com>
Co-authored-by: JasonBroderick <jason@buddyboss.com>
Co-authored-by: RoverKai <roverkai@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix(web): preserve preview scroll across tools
Capture URL-loaded preview scroll state before tool handoff and restore it through an opt-in raw HTML bridge to avoid jumping back to the top.
Agent-Model: gpt-5
Agent-Family: openai
Agent-Session: 019e6ceb-c33d-7cd3-bff0-cbc20c642197
Agent-Step: 0.0.6
* test(daemon): cover scroll bridge injection paths
Agent-Model: gpt-5
Agent-Family: openai
Agent-Session: 019e6ceb-c33d-7cd3-bff0-cbc20c642197
Agent-Step: 0.0.6
---------
Co-authored-by: Codex <gpt-5@openai.com>
* fix(web): theme home hero select menu
Use theme tokens for HomeHero footer select dropdown panels so dark theme menus do not render light-only white backgrounds with low-contrast text.
Agent-Model: gpt-5
Agent-Family: openai
Agent-Session: 019e6ceb-c33d-7cd3-bff0-cbc20c642197
Agent-Step: 0.0.3
* test(web): cover dark model logo inversion
Agent-Model: gpt-5
Agent-Family: openai
Agent-Session: 019e6ceb-c33d-7cd3-bff0-cbc20c642197
Agent-Step: 0.0.7
---------
Co-authored-by: Codex <gpt-5@openai.com>
* fix: insert skill reference when selecting from tools panel
When selecting a skill from the tools panel (not via @ mention), the skill
reference was not being inserted into the input. The tools panel only called
applyProjectSkill() without inserting the text token.
This fix makes the tools panel skill picker behave consistently with other
reference types (MCP, connectors) by:
- Inserting the @skill token at the current cursor position
- Setting focus and cursor position after the inserted reference
- Closing the tools panel after successful insertion
Now users can:
1. Manually delete an @skill reference
2. Open the tools panel and select a skill
3. See the new @skill reference inserted at the cursor
Fixes#3188
* fix(web): preserve draft edits when inserting skill
---------
Co-authored-by: mrcfps <mrc@powerformer.com>
* fix(web): persist design-files view state across navigation
pageSize, sortKey, sortDir, and kindFilter reset on every navigation
because DesignFilesPanel remounts via key={projectId}. Persist them to
localStorage under od:design-files:view-state:v1:<projectId> so each
project's view prefs survive tab-switching.
- Read persisted state via lazy useState initializers (SSR-safe try/catch)
- Write back in a single useEffect keyed on all four values
- Scoped per-project so proj-a settings never bleed into proj-b
- Schema-guarded: invalid/missing fields fall through to defaults
- Red spec: apps/web/tests/components/DesignFilesPanel.view-state-persist.test.tsx
* fix(web): address review feedback on view-state persistence
- Add typeof window guard in readViewState for explicit SSR safety
- Consolidate 4 separate localStorage reads into a single useRef read at
mount time; each lazy useState initializer now reads from savedViewState.current
instead of re-parsing localStorage independently
* fix(web): harden design-files view-state persistence
- Validate restored kindFilter values against the current ProjectFileKind
union via isProjectFileKind() so stale stored values from a prior schema
are dropped silently instead of being cast unchecked.
- Introduce DEFAULT_SORT_KEY/SORT_DIR/PAGE_SIZE constants so the useState
initialisers and the new validation guard share a single source of truth.
- Add viewStateHasMounted ref to skip the first-render write in the persist
useEffect. Without this guard every project the user visits accumulates a
default-value entry in localStorage on mount, growing stale-key garbage
unboundedly and making future field additions silently inject defaults into
every existing entry.
- Harden kindFilter test: replace the silent early-return-on-missing-trigger
with expect(filterTrigger).not.toBeNull() so a render failure surfaces as
a real test failure rather than a passing no-op.
* test(e2e): design files view state persists across navigation and reload
Adds a Playwright UI smoke test in e2e/ui/ that exercises the three key
guarantees of the view-state persistence fix:
(a) Tab-away / tab-back: navigating to a file tab and returning remounts
DesignFilesPanel (conditionally rendered); all four prefs (sortKey,
sortDir, pageSize, kindFilter) are restored from localStorage.
(b) Hard reload: localStorage survives page.reload(); prefs are intact on
the next mount.
(c) Per-project key isolation: a second project starts with defaults and
does not inherit values from the first project's localStorage entry.
The test uses OD_PORT=18011 / OD_WEB_PORT=18012 to avoid port conflicts with
the default development ports.
Also fixes a race in DesignFilesPanel: the stale-kind cleanup useEffect was
running against an empty availableKinds set before the async file list arrived
on mount, which cleared a kindFilter correctly restored from localStorage.
Guard added: skip the cleanup when availableKinds is empty.
Red on origin/main (no persistence logic exists there); green on this branch.
* fix(e2e): address code-reviewer feedback on view-state-persist test
- Add data-testid='df-page-size-select' to per-page <select> in
DesignFilesPanel (W2: decouple test from i18n string 'Show')
- Add StrictMode comment to viewStateHasMounted guard explaining
the dev-mode double-write behaviour (W1: document the invariant)
- Switch nav-away from dblclick to single-click + Open button,
matching the pattern used in app-design-files.test.ts (W4)
- Raise timeout from 60s to 90s for cold CI runners (W3)
- Unify seedTextFile/seedPngFile into shared seedFile helper (N3)
- Add home-hero-input assertion in gotoEntryHome (N2)
- Switch waitForPageSizeSelect to use data-testid (W2)
* test(e2e): split design-files persist into nav, reload, and per-project scenarios
* fix(web): tighten isPageSize to discrete option set, add invalid-value regression test
* fix(web): isolate DesignFilesPanel.test.tsx from persisted view-state key
* fix(chat): surface OpenCode provider failures from its log on a silent stall
OpenCode's headless `run --format json` mode swallows provider failures: a
429 usage-limit is marked retryable and retried silently with nothing on
stdout/stderr, so the chat run only dies via the inactivity watchdog and the
daemon shows a bare "request timed out" with no reason. The real error
(statusCode + "Monthly usage limit reached…") is recorded only in OpenCode's
own session log.
On a failed OpenCode close where stdout/stderr carry no signal, read the
newest OpenCode session log, extract the latest `service=llm` provider error
(scoped to that one line so the embedded request body can't contaminate the
classification), and emit a structured, retryable SSE error (RATE_LIMITED /
AGENT_AUTH_REQUIRED / UPSTREAM_UNAVAILABLE) carrying the provider's message.
Refs #982.
* fix(chat): emit recovered OpenCode failure from the watchdog path, bound to the run
Addresses review on #3316.
Blocking: the recovery previously ran only in the child-close handler, but in
the inactivity-watchdog stall path (the exact case this targets)
failForInactivity sends its error and finish()es the run — which clears
run.clients — before the child closes. So the structured error reached zero
live SSE clients and only surfaced on reload. Recover and send the OpenCode
failure inside failForInactivity, before finish(), on the same pre-teardown
send path the generic stall message already uses. Keep the close-handler
branch for the case where OpenCode exits non-zero on its own (clients still
attached).
Non-blocking: bind the log lookup to the current run via an mtime gate
(since=run.createdAt) so a stale or concurrent session's error can't be
misattributed — skip log files last written before the run started.
* docs(opencode-log): note the concurrent-run limitation of the mtime gate
* fix(chat): skip close-handler failure emit when the watchdog already finished the run
Non-blocking review follow-up on #3316: on the silent-stall path both
failForInactivity and the child-close handler fired for the same run, so the
recovered RATE_LIMITED error was sent twice and the events-log stream was
reopened after finish() had closed it. Guard the close-handler failure emit
with !design.runs.isTerminal(run.status) — the watchdog already sent the
error and finalized the run; finalization below still runs (finish() no-ops
once terminal).
* fix(daemon): validate skillId on POST /api/projects against runtime source of truth
* fix(daemon): validate skillId on PATCH /api/projects/:id, sharing the POST validator
* test(daemon): cover skillId canonicalization, design-template ids, empty-string + null normalization, type rejection
* fix(platform): search mise shims dir so mise-installed CLIs are detected
- Add ~/.local/share/mise/shims (and MISE_DATA_DIR override + legacy ~/.mise/shims) to wellKnownUserToolchainBins.
- This makes Pi, Kimi, and other mise-managed coding agents visible to the daemon even when launched from GUI contexts with stripped PATH.
- Added tests for default and MISE_DATA_DIR cases.
- Also pinned pnpm@10.33.2 in root mise.toml for better mise ergonomics.
Before/after: more local CLIs now appear in the runtime picker (Kimi, Pi, Antigravity, Kilo, etc.).
Refs: discussion in session around improving detection for common mise users.
* fix(platform): address Copilot review on mise shims logic
- Generalize the shims comment (no hard-coded CLI examples).
- Make per-version Node toolchain scanning respect MISE_DATA_DIR
(use the same mise root for installs as for shims).
- Avoid duplicate shims entries when MISE_DATA_DIR makes legacy path
identical to the primary one.
Addresses the three inline comments from copilot-pull-request-reviewer
on PR #3319.
* test(platform): extend MISE_DATA_DIR test to cover installs scanning
Addresses non-blocking review feedback from @nettee on PR #3319.
The previous test only asserted shims behavior under a custom
MISE_DATA_DIR. This extends it to also create fixture trees under
customMise/installs/node/... and customMise/installs/npm-openai-codex/...
and assert that the install paths are discovered while default-root
paths are excluded.
This makes the test robust against regressions in the installs
scanning logic (existingMiseNpmPackageBinDirs + node version dirs).
* fix(platform): only fall back to ~/.mise/shims when no MISE_DATA_DIR is set
Addresses the remaining non-blocking review comment from @nettee on PR #3319.
When an explicit MISE_DATA_DIR is provided, we no longer inject the
legacy ~/.mise/shims path. This prevents stale shims from a previous
mise layout from being re-introduced into detection.
Also added a regression assertion in the MISE_DATA_DIR test.
* fix(daemon): make claude-stream dedup robust when final assistant wrapper lacks msgId
Prevents duplicated text and thinking output (especially visible during
design system generation with AMR/Vela).
Root cause: the textStreamed guard fell back to whenever the
final message arrived without a string uid=501(ramarivera) gid=20(staff) groups=20(staff),12(everyone),61(localaccounts),79(_appserverusr),80(admin),81(_appserveradm),399(com.apple.access_ssh),33(_appstore),98(_lpadmin),100(_lpoperator),204(_developer),250(_analyticsusers),395(com.apple.access_ftp),398(com.apple.access_screensharing),400(com.apple.access_remote_ae) (common in some
AMR flows and design system tasks), causing the full content to be
re-emitted even if it had already been delivered via streaming deltas.
Fix: track whether any text or thinking was streamed via deltas for the
current message and use that as a reliable fallback for the final wrapper
instead of only trusting presence.
* revert: remove dedup from claude-stream (PR #3319 should stay clean)
The Mark tool (#3081/#3277) captured the preview via the *active* iframe. For
URL-load previews — decks especially — the active frame is the bridgeless URL
iframe, while the snapshot bridge lives only in the (mounted but hidden) srcDoc
transport frame. So Send on a deck timed out and showed 'Could not capture the
preview. Try again to avoid sending only ink.'
Snapshot the srcDoc-render-mode frame instead (capture mode already keeps it on
full content, so it carries the bridge), with a short retry while it finishes
swapping to full content. Falls back to the active frame for the non-URL-load
case where they are the same.
Red spec: PreviewDrawOverlay.test 'snapshots the srcDoc bridge iframe, not the
visible URL-load frame' fails on main (targets the URL frame), passes here.
The homepage runs its own inline header enhancer instead of importing
the shared header-enhancer.astro component, and that inline copy only
ported the scroll-headroom and GitHub stars/version logic — it never
included the hamburger toggle handler. As a result the mobile menu
button rendered (and animated to an X via CSS) but clicking it did
nothing on / and /<locale>/, while sub-pages that do import the shared
enhancer worked fine.
Port the same toggle handler into the homepage inline enhancer: click
flips .is-open on header.nav (which CSS expands into the dropdown panel
below 1080px), and outside-click, Escape, and any in-menu link close it,
keeping aria-expanded in sync.
Co-authored-by: Joey-nexu <joeylee12629@gmail.com>
Address review: keying skipTranscript on the static resumesSessionViaCli
flag would skip the transcript on guard-fail turns (force-fresh, history
edit, no --resume capability) even though --resume is omitted, producing
a cold spawn with neither resume nor history and breaking the
non-regression guarantee. Derive both --resume and skipTranscript from
the single per-run resumeSessionId; add supportsSessionResume capability
distinct from the per-turn decision; and assert full-transcript presence
in every guard-fail test.
* fix(pack): add missing download and host packages to Linux INTERNAL_PACKAGES
The native (non-containerized) Linux AppImage build fails with npm 404
errors because @open-design/download and @open-design/host — runtime
dependencies of @open-design/desktop and @open-design/web — were not
included in the INTERNAL_PACKAGES list. Without tarballs for these two
packages, npm install in the assembled app directory tries to resolve
them from the public registry where they don't exist.
Add both packages to INTERNAL_PACKAGES and their build steps to
buildWorkspaceArtifacts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(pack): apply same download/host fix to mac and win lanes, add regression test
1. Extend the INTERNAL_PACKAGES fix to mac/constants.ts, mac/workspace.ts, win/constants.ts, and win/app.ts so all three pack lanes produce tarballs for @open-design/download and @open-design/host.
2. Add internal-packages-coverage.test.ts that derives required workspace runtime deps from apps/desktop and apps/web package.json files and asserts every pack lane's INTERNAL_PACKAGES includes them. This prevents the same drift from recurring when a new workspace dependency is added.
3. Update win-app.test.ts and workspace-build.test.ts mock directory lists to include the two new packages.
* fix(pack): include runtime packages in workspace build cache
* fix(pack): install platform with desktop prebundle packages
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Siri-Ray <2667192167@qq.com>
* fix(web/router): defer popstate dispatch to microtask
navigate() previously dispatched a synchronous popstate event after
mutating window.history, which caused React 18 to emit:
Cannot update a component (Router) while rendering a different
component (App). To locate the bad setState() call inside App,
follow the stack trace as described in
https://react.dev/link/setstate-in-render
This happens whenever a caller invokes navigate() from inside a
useState updater (e.g. App.tsx:479 routing first-run users through
the onboarding panel from inside the setConfig() update). The
synchronous popstate dispatch reaches useRoute() subscribers which
then call setRoute() while the parent component is still rendering.
Defer the popstate dispatch to a microtask. The window.history call
itself stays synchronous so the URL bar updates immediately; only
subscriber updates are pushed past the current render commit, which
removes the warning without changing observable behaviour for any
existing caller.
* fix(web/router): cover deferred navigation timing
---------
Co-authored-by: Visionboost <contact@visionboost.fr>
Co-authored-by: Siri-Ray <2667192167@qq.com>
Adds two new entry points and reworks the community page chrome to
match the wider landing-page direction (PRs #3222 and #3230).
Showcase / Plugin Everything (above Ambassadors)
Pitches Open Design as 'studio and gallery' in one address: anything
shipped through the studio (content, products, templates, Skills,
workflows) can return as a plugin, and the strongest pieces are
carried out to the registry, to X, to Discord's #showcase channel,
to the newsletter, and to the video reels. Right column holds a
zero-code Contribute card with a curl installer, a copy-to-clipboard
button, and a three-step flow for the od-contribute Skill.
Hero CTA row
Three buttons, in a single row: Stage your masterpieces (Showcase),
Become an ambassador (Ambassadors), Contributors hall of fame
(Maintainers).
Top nav
Pulls the breadcrumb out of the brand mark, surfaces Contributors /
Ambassadors / Showcase as anchors, and adds GitHub + X icon buttons
next to the Join Discord pill (mirrors PR #3230).
Footer
Restructured into columnar layout with brand summary plus Products,
Plugins, and Community columns; copyright moves to a bottom rule.
Ambassadors
Renaissance-voice three-column program (Vocation / Patronage /
Covenant) with an Apply on Discord CTA to the ambassador channel.
Discord
Card spans wider (max-width 1440px), copy reframed as 'the front
line of the agent-design era', two moderator profiles on the right
(Koki from the founding team, Victor as Discord steward), channel
list and CTAs on the left.
Recent signal
Kicker and headline framed as this week's leaderboard; backed by a
hand-curated RANKING_SNAPSHOT. A real refresh pipeline remains a
follow-up; data is hand-updated until then.
Other notes
Punctuation pass: replaced most em-dashes in prose with colons,
periods, commas, semicolons, parentheses; em-dashes only remain in
data placeholders, page title, and HTML comments. Logo size bumped
to 32px and now uses an alt of 'Open Design'.
Co-authored-by: koki yanlai xu <koki@kokideMacBook-Air.local>
* fix: pet hover card gets cut off at screen edges
* fix: address review feedback - viewport clamping + unadopted pet wake
- Add window.innerHeight check to prevent bottom-edge clipping
- Increase menuH estimate for safer positioning
- Open pet settings instead of no-op Wake for unadopted pets
* fix: address review feedback on pet menu positioning and wake action
- Add viewport height check (viewH) to prevent bottom-edge clipping
- Increase menuH estimate for safer positioning
- Open pet settings instead of no-op Wake for unadopted pets
Add a change proposal for an opt-in, Claude-first, best-effort session
resume path that passes --resume <session_id> and sends only the latest
turn on qualifying follow-ups, falling back to the full-transcript spawn
whenever a guard fails. Borrows multica's resume + runtime-match /
force-fresh / poisoned-session guards and adds an OD-specific history
epoch guard for editable history.
Spec only; implementation lands in follow-up PRs per the plan.
* fix(daemon): grok-build runtime — pass prompt inline as -p value, drop stdin
Grok Build CLI 0.1.212 enforces `-p, --single <PROMPT>` as a value-requiring
flag — invoking with bare `-p` and piping the prompt to stdin now fails with:
error: a value is required for '--single <PROMPT>' but none was supplied
The previous runtime def used `promptViaStdin: true` + `buildArgs` returning
`['-p']`, which only worked against earlier grok builds that read the prompt
from stdin when `-p` had no inline value.
This change inlines the prompt as the `-p` argument value and flips
`promptViaStdin: false`. Linux `MAX_ARG_STRLEN` (128 KB) is enough headroom
for typical Open Design prompts; if we ever hit `E2BIG` on a very large
brief, a follow-up could shell out to `--prompt-file <tempfile>`.
Verified against grok 0.1.212 (b7b8204a4) — single-turn invocations now
return clean text replies instead of exit 2.
* fix(daemon): declare grok-build argv prompt budget + regression coverage
@mrcfps' review on #2259 flagged that moving the Grok Build adapter from
the (no-longer-working) stdin path to argv would regress oversized
composed prompts from the actionable AGENT_PROMPT_TOO_LARGE error we
already emit for DeepSeek to a raw spawn ENAMETOOLONG / E2BIG instead.
Fixed by mirroring the DeepSeek argv-budget shape:
- grok-build.ts: `maxPromptArgBytes: 30_000` (same headroom as DeepSeek,
~2.7 KB under the Windows CreateProcess 32_767-char cap) so
`checkPromptArgvBudget` pre-flights composed prompts (system + history
+ skills + design-system content + user message) before spawn.
- prompt-budget.ts: Grok-Build-specific message — names the `-p /
--single` flag, the xAI CLI 0.1.212+ behavior change, and points the
user at stdin-capable adapters (claude / codex / hermes) when they
need to ship large local context.
- Tests: 3 new vitest cases in prompt-budget.test.ts — pin the budget
field, exercise the strict-overrun + at-limit + CJK byte-count guards
exactly like the DeepSeek regression set, and assert the Grok-named
diagnostic copy. New `grokBuild` + `grokBuildMaxPromptArgBytes`
helpers exported alongside the existing `deepseek*` ones.
All 23 prompt-budget tests pass locally (`pnpm exec vitest run
tests/runtimes/prompt-budget.test.ts`).
---------
Co-authored-by: Sriram Sivakumar <sriram155@gmail.com>
Co-authored-by: Siri-Ray <2667192167@qq.com>