* test(daemon): add regression tests for headerless raw reads of workspace-bound projects
Closes#7072.
The /raw/ and /preview/ routes call authorizeProjectRequest with
{ mode: 'read', allowNavigationQuery: true }. Before the
enforceLocalProjectDataPlaneRequest refactor, these routes delegated to
enforceVerifiedWorkspaceResourceRead, which required an explicit workspace
context for any workspace-bound project and returned
WORKSPACE_CONTEXT_REQUIRED (400) when headers were absent — breaking
local/desktop preview iframes and MCP preview URLs for projects with a
personal workspace_projects binding.
The two new test cases pin the corrected behaviour: a headerless request
to authorizeProjectRequest with allowNavigationQuery: true must succeed
(return true, not call any remote verifier) for both personal and team
workspace-bound projects, matching exactly what an iframe src or a bare
loopback fetch sends.
* chore: retrigger CI after flaky workspace-restoration P0
* chore: retrigger CI after repeated workspace-restoration flake
The v2.3 paste-image summary still said {id, file} and the v2.4 apply-dom
op list omitted insert-at-index and apply-content, contradicting the
detailed prose and the implemented bridge/host messages (#6141).
* docs: specify pricing vision model update
* docs: plan pricing vision model update
* docs: record pricing vision usage estimate
* test(pricing): cover vision model entitlement
* feat(pricing): add DeepSeek vision unlimited model
* fix(pricing): fit long vision model name in plan cards
* fix(pricing): widen usage chart label spacing
* feat(pricing): explain model entitlement activation
---------
Co-authored-by: zhanghuihua <zhanghuihua@zhanghuihuadeMacBook-Pro.local>
* ci: surface merge-queue CI failure ejections as a PR comment
A failed workload in a merge_group run ejects the queued PR just as
invisibly as a blocking label does: the required check fails on the
queue's transient ref, the PR's own checks stay green, and the queue
entry silently disappears. The needs-validation gate (#5519) announces
its own ejections, but every other ejection left no trace on the PR.
The validate job now produces a handoff/comment artifact after the gate
has already failed in a merge group, naming the failed jobs and steps, a
log excerpt of the failing assertions, the run, and the PRs batched ahead
in the group. comment.atom.yml already consumes merge_group handoffs.
merge_policy exposes whether it already announced a label ejection so a
label-only ejection still gets exactly one comment.
* ci: keep the merge-queue log excerpt working on gh >= 2.97
gh 2.97.0 (GHSA-3m3g-3wcr-px46) refuses to print an API body that contains
terminal escape sequences unless --allow-escape-sequences is passed, and
CI job logs are full of them, so the excerpt fetch returned nothing on a
current gh. Older gh rejects the flag as unknown. Probe the flag once and
pass it only when supported; the fake gh in the topology test now enforces
both behaviors so dropping the probe goes red.
* ci: make the merge-queue notice probe survive pipefail and replays
Capturing gh api --help before grepping keeps an early grep exit from
SIGPIPE-ing gh and reading as "flag unsupported" under pipefail; strip
CRLF from job logs; never list the gate job itself as a failed job when
a completed run is replayed. The fake gh pads its help text past the pipe
buffer so the old probe shape stays red.
* fix(web): validate onboarding runtime on continue
* chore: ignore local pi state
* refactor(web): remove unused onboarding code
* test(e2e): align onboarding Continue expectations
* fix(web): discard onboarding validation results the user moved past
Continue awaits a validation round trip before it persists the runtime
configuration and finishes onboarding, but its closure is frozen at click
time and nothing rechecked the intent afterwards. Two ways that surfaced:
- Back stays enabled while the request is in flight. Clicking it returned
the user to the model-source chooser, then the late success persisted the
configuration they had just abandoned and completed onboarding under them.
- The inputs being validated stay editable. A success for a replaced BYOK
key still completed onboarding against the click-time configuration.
`inputKey` already guarded what gets DISPLAYED — `visibleAgentTestState` and
`visibleProviderTestState` fall back to idle once it drifts. Apply the same
rule to what gets PERSISTED: `continueAttemptStillCurrent` requires the
attempt to still be on the runtime setup step, on the same runtime, and
against the inputs it validated, reading them from a ref the continuation
can actually ask.
* feat(web): move social links to the nav rail footer
The Feishu/Discord, X and mail entries used to live inside the
top-right account menu, one popup away from anyone looking for the
community. They now sit permanently in the home nav rail's bottom-left
footer, which previously rendered as a dead white strip.
The community slot follows the client language: Chinese UIs
(zh-CN / zh-TW) link to the OpenDesign Feishu group, every other locale
keeps Discord. X and mail are locale-independent.
* fix(web): keep the signed-out sign-in card off the social row
`.entry-local-mode-tip` was pinned to the rail footer's bottom edge
(position: absolute; bottom: 3px; z-index: 2). That was harmless while
the footer had nothing else in it, but the social row now occupies that
strip, so the opaque card painted straight over the Discord/Feishu, X
and mail links for exactly the signed-out users it is shown to.
Put the card in flow instead (order 2, above the social row's 3) and let
the footer size itself around both.
* fix(desktop,web): stop 10-minute export hangs and make export failures attributable
Three findings from PostHog on `artifact_export_result` (14d, image
format): 13.5% of image exports fail, 22.8% on 0.19.2, and 20 users
failed 3-11 times each with zero successes. All failures are packaged
desktop (od://).
1. waitForPrintableContent had no timeout. It awaits document.fonts.ready,
every <img> load/error, and a new Image() per CSS url() background —
under od:// any of those can settle neither way, leaving the promise
pending until the daemon's 600s desktop-IPC ceiling. 122 of 142
DESKTOP_RENDERER_UNAVAILABLE failures sat at ~10 minutes before the
user saw an error. Its sibling waitForPrintReadyHandshake already
guards itself with a 30s race for this exact reason; this one was
missed. Bound it in-page per resource (10s) with an outer backstop
(15s), resolving rather than rejecting: capturing without a late
resource beats failing ten minutes later, and the script already
treats a failed resource as fine.
2. error_code reported the literal "Error" for 148 failures / 82 users —
48% of them, and undiagnosable. The daemon classifies its own errors,
but the web export path kept only .error.message and dropped .code, so
exportErrorCode fell through message-regex matching to err.name. Carry
code + status through, prefer a meaningful err.name (TypeError etc),
fall back to the daemon envelope code, and report UNCLASSIFIED instead
of "Error".
3. CAPTURE_FAILED (90 events / 37 users) collapsed five distinct give-up
points into one code. Tag each stage so the bucket says which step of
the fallback chain gave up.
* fix(desktop): bound the artifact document load and release the renderer
End-to-end verification against a real Electron main process found the
first fix incomplete: the export never even reached
waitForPrintableContent. Probing the actual hang showed
[OD-PROBE] loadURL start <- and nothing after it
renderDeckSlides awaited window.loadURL() directly, and Electron
resolves that on did-finish-load, which Chromium fires only once EVERY
subresource has settled. One image or font URL that answers neither way
leaves it pending forever, so the export hung one step earlier than the
function I had bounded.
Wait on dom-ready instead (the capture pipeline only needs a parsed,
scriptable document; subresources are waitForPrintableContent's job and
it bounds itself), with a 15s backstop and the loadURL rejection kept
handled.
Bounding the waits still was not enough. The requests we stop waiting
for stay in flight and every later executeJavaScript queues behind a
renderer still busy with them, so the steps after the wait still cost
~38s and the total stayed unstable. Cancelling the outstanding loads on
timeout hands the renderer back.
Measured on an artifact whose <img> and CSS url() both point at a socket
that accepts and never answers, against a live daemon+desktop runtime:
before hung past the daemon's 600s IPC ceiling
bound loadURL 169s -> 53s, but unstable (a later run hung again)
+ stop() 25.7s / 25.8s / 25.7s, identical output every run
stage breakdown without stop() with stop()
loadMs 89 70-160
assetsMs 15001 15001
prepareMs 15340 6
renderMs 22459 10566
totalMs 52889 25700
Both Electron mocks that stub webContents grew the once() the real
EventEmitter always had; without it the new dom-ready wait threw and
base-href-precedence went red.
* fix(desktop): address review — propagate load failures, cancel on either bound
Two blocking findings from review, both real and both verified by
reverting each fix and watching the matching [P0] go red.
1. A did-fail-load was being swallowed. `.catch(() => undefined)` turned
a genuine main-document failure into an immediately-resolved race
winner, so the pipeline went on to capture Chromium's error page and
could report a successful-but-wrong export. That is worse than the
hang this function exists to prevent: the previous bare
`await window.loadURL(...)` rejected and renderDeckSlides reported
RENDER_FAILED. Race tagged outcomes instead — a failure that beats
dom-ready rethrows, while one arriving after dom-ready has already
won stays handled (onRejected is attached up front) rather than
escaping as an unhandled rejection.
2. Cancellation keyed off the outer timer only. The in-page per-resource
deadline is 10s and the outer bound is 15s, so whenever the renderer
is healthy the script returns on time, `timedOut` stays false, and
the abandoned requests were left in flight — exactly the state that
makes every later executeJavaScript crawl. My own measurement hid
this: assetsMs 15001 means the outer timer won there. The injected
script now counts the deadlines it fired and reports
`{ stalled }`, and either layer giving up triggers stop().
Re-verified end-to-end against a live daemon + desktop runtime on the
same artifact (an <img> and a CSS url() both pointing at a socket that
accepts and never answers): 25.9s / 25.7s / 25.7s, http=200,
byte-identical PNG, stage split loadMs 69 / assetsMs 15002 /
prepareMs 8 / renderMs 10549.
* fix(pricing): unify model display order
* fix(pricing): scope banner colors and footnote size
* fix(pricing): render all access checks consistently
* fix(pricing): center model access icons
#7190 (mine) had `planUnlimitedTier` read a personal tier out of a
team-namespaced id, so a Team Pro workspace saw 「无限使用」 on five models and
its zero balance hidden from the credits pill. "Team plans are paid too" was
the reasoning, and it is the wrong test — what matters is whether the plan
funds usage without touching the wallet:
• vela records in-plan usage through the `coding_plan` billing mode, which
its schema constrains to personal tiers
(`membership_tier_snapshot = ANY (ARRAY['go','plus','pro','max'])`), so a
Team workspace never produces a zero-charge call;
• `team_basic` is seats-only on top of that — `monthly_credits_per_seat` is
0 in the seeded team catalog.
#7187's balance preflight already refuses to stand down for Team workspaces,
so the badge was promising exactly what the preflight then blocked, and the
pill was hiding the number that explains the block.
Team ids now resolve to no tier: no badge, and the credits pill keeps showing
their real zero. Personal go/plus/pro/max are untouched.
* fix(web): hide the zero balance for subscribed plans
On Go / Plus / Pro / Max the popular models a user works with every day are
unlimited, so the wallet only meters flagship calls and legitimately sits at
$0.00. The workbench's top-right credits pill rendered that zero permanently
next to the avatar, which reads as "you are out of money" to a subscriber who
is not.
`shouldShowCreditsBalance` hides the pill for a subscribed tier whose balance
is exactly zero. Three cases deliberately keep it: a free plan (where zero is
the number that explains why hosted models are unavailable), an unresolved
plan (so the pill cannot flicker in and out as billing lands), and an
overdrawn wallet (the one case the user has to act on).
The tier table it asks lives in `state/plan-unlimited-models.ts` — the same
per-tier unlimited sets the model badge uses — so "which plans may hide their
zero" and "which models are unlimited" cannot drift apart.
* fix(web): badge unlimited models by plan tier, not just the campaign
The 「无限使用」 tag in the model chip and the compact model list was wired to
the DeepSeek V4 campaign alone, so it marked exactly two models for everyone.
What the product actually sells is an unlimited SET PER TIER — 3 models on Go,
4 on Plus, 5 on Pro, all 8 on Max — so a Pro subscriber saw no tag on Kimi
K2.7 Code (unlimited on their plan) while the metered MiniMax M2.7 sat right
next to it looking identical.
The badge now has two sources and the campaign stays first: inside the
campaign window DeepSeek V4 Pro / V4 Flash keep the campaign copy and tooltip
for everyone, free plans included. Only when the model is not a campaign model
does the badge ask the subscription, via `isUnlimitedModelForPlanTier`. BYOK
execution claims nothing — those calls are billed by the user's own provider.
Pricing had the Pro row wrong in the same place: it listed MiniMax M2.7 as
unlimited and GLM-5.2 as metered, the reverse of what Pro includes. Fixed
there too, and `e2e/tests/pricing-unlimited-models.test.ts` now pins the
marketing table and the runtime table together across the app boundary so
editing one side alone fails.
* fix(web): drop the invented tooltip from the plan unlimited badge
The plan branch shipped a tooltip line nobody wrote as product copy (and 19
translations of it). The campaign branch's tooltip is campaign copy with a real
source; the plan case has none, so it now renders the badge text alone rather
than an invented explanation. `od-tooltip` and the aria-label only attach when
there is real copy behind them.
* fix(e2e): type the pricing-parity guard under noUncheckedIndexedAccess
`Typecheck workspaces` failed on the new guard: the e2e package compiles with
noUncheckedIndexedAccess, where every regex capture is `string | undefined`, so
the `match![1]` shape from the first draft does not compile there even though
apps/web accepts it.
Capture access now goes through `captureOne` / `captureAll` / `tierEntry`,
which throw a message naming what could not be found instead of asserting
non-null — so a renamed constant fails as "unlimitedByTier not found — did it
get renamed?" rather than as a TypeError.
* fix(web): reuse the shipped Unlimited wording in ru/uk
The plan badge's Russian and Ukrainian values were written from scratch
("Без лимитов" / "Без обмежень") while the campaign badge beside them — the
same word, already shipped and translated — says "Безлимит" / "Безліміт".
Two different words for one state in the same popover is a translation bug,
so the badge now reuses the shipped wording. The other 17 locales already
matched.
* refactor(web): fold the badge's plan table into the one #7187 landed
#7187 shipped `runtime/amr-unlimited-models.ts` — the same per-tier unlimited
sets this branch had added under `state/plan-unlimited-models.ts` — while this
branch was open. Two copies of a product promise is exactly the drift the
cross-app guard exists to prevent, so the duplicate is gone and everything
reads the surviving table.
The badge's extra needs moved into it rather than staying in a second file:
`planUnlimitedTier` reads the tier out of a team-namespaced id (`team_pro`,
`team_max_yearly`) and `isUnlimitedModelForPlanTier` compares the model slug
past any provider prefix. `isUnlimitedAmrModelForPlan` is untouched — the
balance preflight matches the personal ladder exactly on purpose, since Team
workspaces spend their own balance (#7187), and folding the two questions
together would have changed that behaviour silently.
`e2e/tests/pricing-unlimited-models.test.ts` now parses the surviving table,
including the `...GO_UNLIMITED_MODELS` spreads it composes tiers with, so the
Pricing page and the runtime stay pinned to each other.
* feat(landing): restore the floating capsule header, trim the hero community button, drop the download badge dot
- Condensed header goes back to the centred Liquid Glass capsule (14px top
gap, 1280px max, pill radius) while keeping the icon-only action cluster
- Hero community button shrinks to 52px (46px on phones) with a smaller
Feishu mark and credits tag
- The red attention dot on the download CTAs (.hm-dl::after) is removed
* feat(landing): hero polish — selection frame back, green trait chips, smaller CTAs; header drops the account avatar, monochrome brand icons
- Hero: blueprint selection frame restored around the headline; trait chips
lose the dot and take a light coral fill; download + community CTAs one
notch smaller with more air above them; the block sits higher
- Header: signed-in avatar module removed from the marketing header; the
community hover card becomes title + quiet perk line; Feishu / Discord
marks are monochrome at rest and coloured on hover / focus
* feat(landing): more air between the hero chips and the CTA row
---------
Co-authored-by: Joey <236967869+joeylee12629-star@users.noreply.github.com>
* feat(landing): simplify header community entry, move language switcher to footer, video-first hero
- Header: community entry becomes a plain word (飞书群 / 飛書群 / Discord);
zh / zh-tw hover reveals the Feishu group QR; benefit list and framed
pill removed; Discord / Feishu rows dropped from the Community dropdown
- Language switcher moves from the fixed header into the footer bottom bar
(homepage + shared site-footer, opens upward); Header keeps the prop for
call-site compatibility but no longer renders it
- Hero: drop the Claude-Design eyebrow (SEO layer keeps it), move the
value-promise paragraph and the product shot into the About section,
replace the first-screen shot with a click-to-play YouTube walkthrough
* feat(landing): merge about statement with hero value promise, product shot replaces desktop-native panel art
* feat(landing): move community entry from header to hero, beside the download CTA
* feat(landing): full-bleed flush-top header with icon-only community + X links
* feat(landing): drop X from the Community dropdown (lives in the header icon cluster)
* feat(landing): restore hero community button; keep Download in the bar at every width
* feat(landing): Feishu mark for the community entry, credits nudge on header + hero entries, core-trait chips in hero
* feat(landing): reword the coding-agent chip
* feat(landing): reframe the local-run chip around data safety
* feat(landing): reword the brand chip to brand consistency
* feat(landing): shorten the data-safety chip
* feat(landing): merge BYOK and coding-agent chips
* feat(landing): right-align the footer language switcher next to the social icons
* feat(landing): tighten the about statement across all locales
* feat(landing): lead the hero chips with brand consistency
* feat(landing): icon-only language switcher back in the header action cluster
* feat(landing): hero headline from the redesign, scenario line, no selection frame; hesitant-only download prompt; drop Launch Week float
- Hero: "The Vibe Design Workspace for your brand." as the single-line
headline (all 17 locales), scenario line reordered to prototype / slides /
marketing image / video / dashboard, blueprint selection frame removed
- Download prompt: any download click suppresses it for 30 days; triggers are
now exit intent or scroll-past-hero + 20s dwell (the old 35s / 3-page rules
fired on cold traffic with an 84% close rate); tests updated
- Launch Week float removed from the homepage (754 closes vs 61 opens)
* chore(landing): drop a stray local screenshot script
* fix(landing): keep the brand intact on phones; thread canonical-only into the footer switcher
- <=640px hides the secondary icon cluster from the bar and pins the brand
so it no longer collapses to 0px at 320/375px (ja/zh/en); Download stays.
Layout test now asserts 320/375/420 alongside 1081/1367.
- The three canonical-only community pages and the canonical PPT tutorial
now pass localeCanonicalOnly to SiteFooter, matching their header; a
source-level test fails if a canonical-only page forgets the flag.
* fix(landing): make the hero video play button the single focus target
The facade wrapper was a focusable generic div while the labelled button was
pulled out of the tab order. The button is now the only focusable control and
native Enter / Space activation bubbles to the frame's click handler; the
custom keydown handler on the div is removed. Adds a source-level regression
test for the facade's accessibility contract.
* fix(landing): visible focus ring on the hero video play button
---------
Co-authored-by: Joey <236967869+joeylee12629-star@users.noreply.github.com>
* fix(web): restore Settings About update action row spacing
The About page's update row holds two sibling buttons — the primary
update action and "View release notes". #6142 dropped the
.settings-about-update-actions rule group while SettingsDialog.tsx kept
emitting the class, leaving the container at display: block. JSX puts no
whitespace text node between adjacent elements, so the two inline-flex
buttons rendered with their borders touching at exactly 0px.
Give the container back its flex row and 8px gap. Adds a Playwright UI
regression guard that stubs the packaged-desktop host bridge with a
downloaded update so both buttons render, then asserts the declared gap
and the distance the buttons actually land at.
* fix(web): stop the project frame flashing twice on entry from Home
Submitting from Home hands off through ProjectCreationPendingView while
POST /api/projects settles, then swaps in ProjectView. Both components
owned their own `div.app`, so React mounted a second element on the
hand-off and .app's 180ms od-fade-in entrance restarted from zero. The
pending surface only lives ~150ms — shorter than the animation it starts
— so the frame never finished its first fade before the second one
began, which reads as the project view flashing twice.
Move the .app shell up to App.tsx and render it around both branches.
React now reconciles one element across the hand-off: the entrance plays
once, continuously, while the contents swap underneath.
Measured in a real browser on the Home -> project transition:
before t=260 .app#1 od-fade-in@0 -> t=408 .app#2 od-fade-in@0 (restart)
after t=200 .app#1 od-fade-in@0 -> t=317 .app#1 od-fade-in@117 (continuous)
* fix(daemon): anchor run phases on first model event
Phase boundaries anchored on `firstTokenAt`, the first *text* token. A run
that opens with a tool call and stays silent until the end therefore had its
entire tool loop billed to `runtime_init`, leaving the generation phase
holding only the closing sentence. Tool-first is the dominant opening shape
for the OpenCode/OpenAI families, so `bottleneck_phase` reported startup for
runs that were working the whole time.
The decomposition was also not a partition: on the new fixture's 24.2s run the
old boundaries sum to 37.8s, because the tool loop is counted inside both
`runtime_init` and `tool_execution`.
Anchor phases on `telemetry.firstModelEventAt ?? telemetry.firstTokenAt` --
tracer marks only. Deliberately not the nearby `firstModelEventAt` composite,
whose `firstToolUseAt` fallback is scanned off event records on the daemon's
clock and is never reset per retry attempt.
Every published metric keeps its current value; a spec pins the four
first-token metrics so a future edit cannot silently redefine them. Adds
`runtime_init_to_first_model_event_ms`, `model_active_duration_ms`, and
`phase_schema_version` so old and new rows can be filtered apart. The
`stream_output` phase now subtracts tool time to stay mutually exclusive with
`tool_execution`.
Trace side gains a `model-active` phase-diagnostics entry rather than a second
span, to avoid an extra observation row per run.
* fix(daemon): measure the tool phase as wall-clock occupancy
Review found two ways the tool phase could exceed the window it sits inside,
both because it was built from `tool_duration_ms` -- the sum of paired
tool_use -> tool_result spans. That sum is the right published answer to "how
much tool work happened" but it is not elapsed time, so it cannot partition a
window:
- Parallel tool calls sum to more than the clock they occupy. Two overlapping
4s and 5s tools inside a 6s active window reported 9s of tool execution.
- `run.events` is never cleared between retry attempts, while the phase anchor
comes from the current attempt's lifecycle marks. A retried run charged the
previous attempt's tool spans to this attempt's window.
- ACP producers supply their own `startedAt`, which can predate the anchor when
their clock differs from ours.
In each case the inflated phase won `bottleneck_phase` with a duration longer
than the run had been active, and the previous `Math.max(0, ...)` floor on the
residual hid the symptom rather than fixing the partition.
Derive both `stream_output` and `tool_execution` from the union of tool
intervals clipped to [phaseAnchorAt, runEndAt]. The two are then halves of the
model-active window by construction. `tool_duration_ms` keeps summing paired
spans and is unchanged.
Three fixtures cover the parallel, retry, and producer-clock cases; all three
reported `tool_execution` as the bottleneck before this change.
* fix(daemon): keep the phase anchor on real model output
Second review round found three more ways the new boundaries could publish
wrong attribution.
A daemon-persisted artifact was being marked as the first model event. Agent
`artifact` events are emitted from exactly one place -- the daemon's close-time
persistence of plain-stream stdout -- and never by a runtime relaying model
output. On a plain-stream run that stamps first-token from its first buffered
stdout chunk, that late mark won the anchor and reported a 1s active window and
a 16s runtime init for a run that had been streaming since second 5. That is
the opposite of this PR's intent, and it falsified the claim that the
plain/BYOK family keeps its current behaviour through the first-token fallback:
the fallback was never reached, because the field was set -- just set by the
daemon, at the end of the run.
Fixed at the source (the tracer no longer treats an artifact event as a model
event, and neither does the scan-based fallback that feeds
`first_model_event_type`), and again as an invariant: the anchor is the
EARLIEST of `firstModelEventAt` and `firstTokenAt`, because the model cannot
have started responding after it emitted its first token. A late producer mark
can no longer drag the boundaries to the end of the run.
`phase_timing_status` still required `firstTokenAt`, so a fully instrumented
tool-only turn -- every boundary the phases are measured from present, no text
ever emitted -- was stamped `partial` and dropped by dashboards filtering on
complete timings. It now follows the anchor the phases actually use. The
first-token fields are unchanged.
Tool occupancy ignored a tool still outstanding at run end, handing the whole
active window to `stream_output` while `last_observed_phase` said
`tool_execution`. Outstanding spans are now closed at run end for phase
purposes; `tool_duration_ms` still sums completed pairs only.
Five fixtures, red before the change, including one in the tracer suite
pinning that an artifact event is not a model event.
* fix(daemon): scope outstanding tool spans to the current attempt
Closing every still-open tool span at run end interacted badly with the retry
path. `run.events` survives a retry while lifecycle telemetry does not, so an
attempt whose child was killed mid-tool leaves an unpaired tool_use behind.
Closing that at run end stretched it across the retry boundary: a tool_use at
3s, an attempt-2 anchor at 20s, and a run ending at 25s clipped to a 5s
`tool_execution` phase for an attempt that never called a tool.
Gate the close on the daemon-clock timestamp the tool_use was OBSERVED at,
which shares a clock with our own marks, rather than on its start. A
producer-supplied `startedAt` can legitimately predate the anchor on a tool
that is genuinely running, and clipping already handles that case -- gating on
the start would discard real occupancy instead.
Two fixtures: the killed-prior-attempt case (red before this change), and a
guard that an outstanding tool with a skewed producer start is still counted,
so the gate cannot be tightened into discarding live work.
* fix(daemon): give the summarizer a real attempt boundary
Both remaining review findings came from the same gap: the outstanding-tool
gate used `phaseAnchorAt` as a stand-in for "when this attempt started", and
those are different instants.
`phaseAnchorAt` falls back to `firstTokenAt` when no model-event mark exists.
On a legacy or recovery tool-first run -- tool_use at 4s, first text at 10s,
run ending at 30s -- the gate then discarded a tool this attempt had issued and
never finished, reported zero tool occupancy, and handed the whole active
window to `stream_output`. That is the exact shape this change exists to
measure. The gate now uses the attempt boundary; `phaseAnchorAt` is only used
for clipping.
For that boundary to exist, two producers had to stop destroying it:
- The retry path called `resetForAttempt`, which stamps `attemptStartedAt` and
`attemptIndex`, and then replaced `run.analyticsTelemetry` wholesale, so both
were dropped every time. It now spreads, keeping the `run.createdAt` fallback
for the logical run start. This also means `attempt_index` and
`attempt_duration_ms` -- already-published fields -- start reporting across
retries instead of silently vanishing.
- `prepareRestart` reused the run for a recharge resume and cleared its
terminal state but left `analyticsTelemetry` untouched, so a resumed attempt
inherited attempt 1's `firstModelEventAt`/`firstTokenAt`. Every boundary then
measured from before the recharge pause, putting the wait inside the new
attempt's model-active window. It now resets the marks, preserving only
`startRequestedAt` so queue time is still measured from the user's request.
Two fixtures, red before the change: a no-model-event outstanding tool, and a
recharge resume asserting the stale marks are gone while the logical run start
survives.
* fix(daemon): anchor ACP tool-first turns at the tool's start
Three findings from the fifth review round.
ACP accumulates `tool_call` frames and emits the canonical `tool_use` only once
the call reaches terminal status, carrying `startedAt` set to the first frame's
arrival. The universal `send()` path stamped the anchor with `Date.now()` when
that delayed event arrived, so a tool-only ACP turn put its anchor at tool
COMPLETION -- measuring the whole tool loop as runtime init. That is precisely
the case this PR exists to fix, still fully present for the ACP family. The
stream-event markers now carry the producer's `startedAt` (daemon clock, same
domain as our own marks) and the tracer stamps that instead of arrival time.
Tool ids are reused across a retry: sequential ids like `call_0` restart in the
retry's fresh session, and `openTools` is keyed by id alone. A killed
attempt's still-open `call_0` therefore merged with the retry's genuinely new
`call_0`, pairing attempt one's opening with attempt two's close and reporting
a 19s tool that never ran -- inflating `tool_duration_ms` as well as phase
occupancy. A still-open entry observed before the current attempt began is now
replaced rather than merged.
The Langfuse `model-active` diagnostic used different boundaries from the
metric it exists to be compared against: it preferred `firstModelEventAt` over
the earliest mark, and ended at `finalizeStartAt` rather than run end. On a
text-first stream those differ, so the same run reported two windows. It now
mirrors the canonical rules; the shared "earliest finite mark" logic is a named
helper.
* fix(daemon): stop reporting phase attribution we cannot support
Two findings from the sixth review round.
The first-model-event mark was first-write-wins, which is wrong for ACP. It
holds each toolCallId until terminal status, so two parallel calls can complete
in the opposite order they started: a call that began at 200ms can be the first
one we hear about, and the call that began at 100ms is then discarded. Every
phase boundary shifted later by the difference. The mark now keeps the earliest
finite timestamp with its type. Marks without a producer timestamp default to
arrival, which is monotonic, so this only ever moves the anchor earlier.
Phase occupancy is reconstructed from tool frames in `run.events`, which is a
bounded ring buffer. A run that opens with a 20s tool and then emits enough
output to evict it reconstructs zero occupancy, hands the whole active window
to `stream_output`, and -- because lifecycle marks live on the run and survive
eviction -- still stamped the bundle `complete`. The summarizer now reads the
same truncation signal the execution diagnostics in `runtimes/runs.ts` already
use (first surviving event id is no longer 1), downgrades a `complete` phase
bundle to `partial`, and withholds `bottleneck_phase` entirely rather than
naming whichever phase the buffer happened to keep. Timings that come from
lifecycle marks are unaffected and keep reporting.
Five fixtures. Two of them were green before their change and exist to pin the
boundary from the other side: a later event must not move the anchor forward,
and a truncated run must still report its mark-derived timings.
* fix(daemon): gate every tool interval on the opening attempt
The pairing path never checked which attempt opened the tool. A `tool_result`
that arrives after a retry has started -- a buffered stdout flush racing the
abort, which the ACP session code already documents as a real sequence -- was
paired with its attempt-1 opener into a single interval spanning the retry
boundary. Clipping then charged the new attempt 8.9s of tool execution inside a
9s window for an attempt that issued no tool at all.
Earlier rounds gated the two other ways a stale entry reaches an interval (id
reuse on open, close at run end) but did so at each site, which is why this
third path was missed. The rule is now stated once as `openedInCurrentAttempt`
and every append to `toolIntervals` goes through it, so a fourth path cannot
quietly skip the check.
`tool_duration_ms` deliberately does not pass through the gate and keeps its
whole-run paired-sum definition; the fixture asserts that value explicitly so
the distinction stays deliberate.
* fix(daemon): stop moving a published metric, and abstain when tools are unattributable
Two findings from the seventh review round.
Passing the producer's start into `markFirstModelEvent` moved
`time_to_first_model_event_ms`, which this PR promised not to touch. That field
means "when we first saw a model event"; for an ACP tool starting at 4s and
emitted at terminal 20s it began reporting 4s instead of 20s. My own invariance
fixture did not catch it because it only pins the four first-token metrics.
The mark is now split. `firstModelEventAt` goes back to first-write-wins on
arrival and keeps feeding the published metric unchanged. A new
`firstModelResponseAt` holds the earliest producer start (clamped to arrival, so
a fast producer clock cannot claim the model responded in the future) and is
what phase boundaries anchor on. The new phase field is renamed to
`runtime_init_to_first_model_response_ms` to match what it measures; it is
introduced by this PR and has never shipped.
Second, a tool id shared by two attempts cannot be attributed. When a retry
reuses an id whose previous opener is still outstanding, a later `tool_result`
could belong to either, and the two readings differ by seconds of occupancy.
Earlier rounds tried to pick a winner; nothing in the event log supports one.
The summarizer now records that an opener was displaced, marks the ledger
ambiguous when a result arrives for such an id, and withholds
`bottleneck_phase` and a `complete` phase status -- the same treatment
truncation already gets, and for the same reason. Timings derived from
lifecycle marks keep reporting.
Fixing this properly rather than abstaining needs an attempt or generation id
carried on the tool events themselves, which is a producer and event-schema
change; noted in the review thread rather than smuggled in here.
* fix(daemon): share one phase-anchor rule between both sinks
The Langfuse `model-active` diagnostic listed its own marks, so adding
`firstModelResponseAt` to the analytics anchor last commit left the mirror
behind again. On an ACP tool-first run with no text token it reported 10s
against the analytics value of 26s for the same run -- on precisely the runtime
shape this change targets, and in the one entry whose whole purpose is to be
compared across sinks.
This is the second time the two drifted, so the fix is structural rather than
another manual sync: the rule is exported as `phaseAnchorFromMarks` and both
call sites use it. Re-listing marks per sink is what allowed the drift, and a
third mark would have caused it again.
Validity rules are now identical by construction too, which resolves the
separate half of the finding.
* feat(landing): surface localized community benefits
* feat(landing): localize community benefits
* fix(landing): keep community entry inside mobile nav
* fix(landing): cover compact header transition band
---------
Co-authored-by: 徐燕来 <xuyanlai@refly.ai>
* feat(web): add Go campaign entries and route upgrades to pricing
* fix(web): localize Go campaign copy
Generated-By: looper 0.11.8 (runner=fixer, agent=codex)
* feat(web): match Go campaign demo UI
* test(web): align Go campaign source contract
* fix(web): align Go modal model icons
* fix(web): preserve Z.ai mark in Go modal list
* fix(web): localize and fit Go campaign modal
Generated-By: looper 0.11.8 (runner=fixer, agent=codex)
* fix(web): clamp Go campaign modal height
* fix(web): use local Go campaign icons
Generated-By: looper 0.11.8 (runner=fixer, agent=codex)
* test(e2e): stabilize Go campaign viewport assertion
Generated-By: looper 0.11.8 (runner=fixer, agent=codex)
* fix(landing): fill the Codex Plugin mega-menu column and adopt the dsh one-line installer
The product mega menu's Codex Plugin column rendered as a blank panel
next to the populated Solution and Agent columns because it carried a
head link only. Fill it with the plugin ecosystem's real destinations:
the install anchor (reusing the plugin page's translated phase label,
no new i18n keys) plus the Codex Design and DeepSeek Harness
collection pages.
The DeepSeek Harness agent guide still taught the manual
npm-install path that requires a preinstalled Node.js toolchain. The
published installer flow replaces it: per-OS one-line commands from
open-design.ai/install-dsh.*, an isolated user-directory toolchain
with no sudo and no global Node.js changes, pinned to the release
OpenDesign has verified, finishing in dsh web for API-key setup.
Setup section, credential note, quick-summary steps, and FAQ updated
in both the English and Chinese guides; installer-specific FAQ
entries added.
* fix(landing): shape the Feature column per design and promote DeepSeek Harness
Rework the first mega-panel column to the reviewed design: a localized
Feature category head (new header i18n key across all 18 locales) with
Codex Plugin as its entry, replacing the interim link list. In the
Agent column, move DeepSeek Harness to the top with a red new-dot to
mark the freshly launched integration.
* fix(landing): roll the one-line installer into every active locale of the Harness guide
The landing test suite guards the DeepSeek Harness guide content per
active locale, so the installer switch has to land everywhere at once:
ja/ko/de/fr/es/ru/pt-br/it/tr setup sections now carry the three
per-OS one-line commands, the installer behavior list, the updated
Web UI step and credential note, plus the reworked install FAQ and two
new installer FAQs (translated, not machine-padded). Retired locales
(vi/pl/id/nl/ar/uk/zh-tw) keep their legacy dead copy untouched.
The content guard flips accordingly: it now requires all three
installer URLs and rejects any resurfacing of the manual
npm-install path; EN/ZH phrase anchors track the new intro copy.
147/147 landing tests pass; astro check clean.
* fix(landing): refocus the Harness guide on design and add HowTo schema
The guide led with a 'does DeepSeek Harness have a GUI' angle that is
off the page's actual message. Refocus every active locale on the real
narrative — designing with DeepSeek Harness through OpenDesign:
design-first title and description, the GUI section, its TOC entry and
FAQ removed (en/zh plus the nine localized copies; retired locales
keep their dead copy).
SEO on top of the accurate tutorial: add HowTo structured data built
from the real five-step walkthrough (install → download → detect →
connect → first task) with per-step section anchors, joining the
existing Breadcrumb/WebPage/ItemList/FAQPage blocks. The content
guard's title anchor tracks the new intent.
* fix(landing): keep a #gui compatibility anchor on the Harness setup section
The GUI section was removed in the design-first refocus, which broke
saved and search-indexed /agents/deepseek-harness-design/#gui deep
links. Alias the anchor onto the setup section so those links keep
landing on content, without reintroducing the GUI copy.
* fix(landing): give each HowToStep its instructional text
The HowTo steps carried only headings and anchors; schema consumers
that require an instructional field saw five names with no directions.
Each step now carries its section's lead paragraph as plain text
(markdown links and code markers stripped), falling back to the
heading when a section opens without a paragraph.
---------
Co-authored-by: Joey <236967869+joeylee12629-star@users.noreply.github.com>
* fix(web): keep first Home run output live
* test(e2e): bound design system restoration probe
* fix(web): preserve live turn through authority reload
Generated-By: looper 0.11.8 (runner=fixer, agent=codex)
* feat(web): add an "other" answer with a free-text field to the survey
The eight choices cover what users already tell us most often, but they cannot
cover what we have not heard yet — and the people whose problem is not on the
list are exactly the ones worth hearing from. Picking "Something else" opens a
text field instead of finishing the survey.
The cost is paid only by the people who need it: everyone whose answer is on
the list still finishes in one tap. This is the open-ended question we cut
earlier, put back behind a door rather than in everyone way.
Wire shape follows PostHog open-choice convention — the response recorded is
the text they typed. Picking "Something else" and typing nothing still reports
the choice itself, because "none of these fit" is an answer and dropping it
would turn those people into non-responders.
Skip disappears once the choice is made; it would only have been a second way
to submit.
* style(web): use a neutral focus border on the survey text field
The green ring competed with the brand fill on the selected chip directly
above it — two green things one under the other, neither reading as primary.
A neutral border marks focus without joining that fight, and the inset fill
still separates the field from the card.
* fix(web): keep the survey submit button on the footer right edge
Submit replaces Skip in the footer, but only Skip carried the margin-left:auto
that pushes it right — so the moment the text field opened, the primary action
collapsed back against the step counter and jumped from the right edge to the
left. Measured from the card edge, both now sit at the same 17px gap.
* feat(web): ask the experience survey after a delivered artifact, not an export
The post-export survey could only reach people who exported. Over 30 days
~13k users exported while ~37k produced an artifact, so roughly two thirds
of the people who got real work out of the product were never asked — and
the ones who tried it, got something, and left without exporting are the
ones worth hearing from most.
The card is now armed by a delivered design run (resolveDesignDeliveryOutcome
=== 'delivered'), from the second such delivery on. The first delivery
measures a first impression rather than an experience, and lands at the one
moment the user most wants to look at what they just got; waiting for the
second costs almost no reach because deliveries repeat (~13 per user per 30
days).
Because a delivery is usually mid-session rather than end-of-session, the
3s delay now bails out if the user starts writing their next prompt.
'beforeinput' rather than 'keydown' so IME composition counts as typing.
A dropped chance is taken by the next delivery, so the card lands after the
turn the user actually stopped on.
Export no longer arms anything; the three FileViewer call sites are gone.
Every survey event carries trigger: 'post_generation' so the two regimes
stay distinguishable in the events table.
* feat(web): arm the experience survey on the first delivered artifact
Drops the two-delivery threshold to one. Waiting for a second run bought a
more considered opinion at the cost of everyone who produces one artifact
and leaves — the users we understand least, and the ones this survey was
widened to reach in the first place.
Asking on the first delivery is affordable because the card is not an
interruption: the component still waits out the delay and still drops the
chance entirely if the user starts typing, so an early ask that lands badly
costs a dropped chance rather than a derailed session.
The delivery counter stays. It is the seam the policy is made of, and it is
what makes an unwritable store read as "not yet qualified" — a store that
cannot write cannot record a dismissal either, so without it the card would
come back after every run with no way to stop it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(web): retire the experience survey when it is shown, not when it is answered
One ask per user was the intent, but `retireSurvey()` only ran from `finish`
and `close`. Ignoring the card — the most common way to decline a prompt —
left the flag unset, so the card returned after the user's next artifact, and
the one after that. `exposedRef` did not cover this: it is a ref, so it resets
on the next page load.
Retiring on exposure makes the rule what it claims to be: a user sees this
card at most once, whatever they do with it. A chance dropped by the typing
bail-out is still retried, because a card the user never saw did not spend
their one ask.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(web): drop the typing bail-out from the experience survey
The card no longer cancels itself when the user starts typing during the
three-second delay. With one ask per user, a dropped chance is the whole ask
for anyone whose habit is to type straight into the next prompt — and that
habit belongs to the engaged users this survey most wants to hear from.
Removes the `beforeinput` listener and the IME reasoning that went with it.
The delay stays: it keeps the card from animating in on top of the artifact,
which was always its other job.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(web): give the survey's free-text field an accessible name
Review finding from #7074, folded in with that branch. The textarea is
auto-focused the instant "Something else" is picked, so its accessible name
is the only thing telling a screen-reader user what the field is for — and it
had none. A placeholder is not a label and disappears on the first keystroke.
Reuses the placeholder string rather than adding a twentieth translation of
the same sentence, so the visible and announced names cannot drift.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore: ensure PR CI
---------
Co-authored-by: elifive555555 <296440099+elifive555555@users.noreply.github.com>
Co-authored-by: Eli <qiongyu1999@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: open-design-crew[bot] <4197780+open-design-crew[bot]@users.noreply.github.com>
The production landing-page promotion pinned DSH_BOOTSTRAP_VERSION: v1 in
the workflow, while the publisher refuses to overwrite an already-published
immutable version with different bytes. PR #6983 renamed the brand inside
install-dsh.ps1 and install-dsh.sh without bumping that pin, so the next
production deploy failed closed on
immutable bootstrap object already exists with different content:
bootstrap/dsh/v1/install-dsh.ps1
and skipped every later step, including the Cloudflare Pages deploy. The
version is a function of the installer bytes, so maintaining it as a
hand-written constant guarantees this drift.
The publisher now resolves the version itself: it probes published versions
in order using SHA256SUMS as the fingerprint for the whole set, reuses the
version that already holds these exact bytes, and mints the next one when
the bytes changed. Every published version keeps if-none-match: * and the
immutable cache header, so history stays permanent and unoverwritable --
only the manual bump is gone. A mutable bootstrap/dsh/latest.json pointer
records the current version, per-file sha256, and the publishing run, and
the verify step reads the resolved version instead of naming one.
DSH_BOOTSTRAP_VERSION survives as an explicit escape hatch: setting it
forces one version and still fails closed on a content mismatch.