feat(web): add an Open Design Labs settings page with the Design Harness switch (#7445)

* feat(web): add an Open Design Labs settings page with the Design Harness switch

OD Next has been opt-in through `odNextStrategyMode` since #7362, but the only
way to reach it was `od config set` or an environment variable — and
`PACKAGED_CHILD_ENV_ALLOWLIST` strips that variable before the packaged daemon
ever sees it. A packaged user had no way to choose either direction, so the
strategy was effectively invisible to everyone outside a terminal.

Add the missing surface: a Labs page in Settings, between General and
Instructions, carrying one row for Design Harness.

The section reads and writes on its own rather than threading through `cfg` /
`setCfg`. `apps/web/src/types.ts`'s `AppConfig` is a web-local projection that
does not carry `odNextStrategyMode`, and `syncConfigToDaemon` serialises an
explicit allow-list, so the dialog route costs three edits and fails silently
if the allow-list one is missed. One `GET /api/strategies/od-next/rollout`
answers the saved mode, who set it, and whether the local safety latch has
tripped; one single-field `PUT /api/app-config` writes it back, which the
daemon merges per key.

Three states lock the switch, each with its own sentence: an environment
variable owns the mode, the local latch has tripped (it overrides the saved
mode, so the switch would otherwise read as on while every run took the
ordinary route), or the status could not be read at all. The lock uses
`aria-disabled` rather than `disabled` so the explanation stays reachable by
keyboard.

Turning the switch off writes `'off'` rather than clearing the key: an absent
value means "never touched", which is worth telling apart from a deliberate
opt-out. `observe` renders as off and is never rewritten — it is a developer
diagnostic mode, and clearing it silently would break a debugging session.

"Applies to your next generation" is a permanent line under the item name, not
a confirmation that appears after the fact. The switch has no visible effect
until the next run, so the user should read that before flipping it. Save
outcome reports on the dialog's existing autosave pill, the same surface every
other Settings edit uses.

`settingsSectionToTracking` also gains a `labs` case. Its default returns
`configure_execution_mode`, so without this a Labs page view would have been
counted as an execution-mode view and inflated that funnel.

* fix(web): start one harness write per click burst

Found by clicking the switch three times in a row against a live daemon: all
three writes went out. `busy` is state, so the second and third clicks in the
same tick still read the pre-render closure, where `busy` is false and `on`
still holds the old value — each one starts its own write from a stale
baseline.

Gate re-entry on a ref that flips synchronously instead. `busy` keeps driving
the dimmed styling, which is all it was ever able to do reliably.

* feat(analytics): report which harness produced a run, and Labs opt-in

The Labs switch exists so a new generation strategy can be compared against
the old one. That comparison needs two things nothing was reporting: which
side of it a given run landed on, and whether anyone is turning the switch on
at all.

`run_created` / `run_finished` gain `harness` plus `harness_fallback_reason`.
The reason is the more useful half: the switch can be on and the run still take
the ordinary route — the project's task type, the selected agent, an explicitly
chosen plugin, or an unverified runtime capability each route back, silently.
Without the reason code, "I enabled it and nothing changed" is unanswerable
from the data; with it, the size of each silent-fallback bucket is a query.

Stamped once, on the run-created base props. `run_finished` spreads those, and
the crash-recovery replay stores them verbatim, so the dimension reaches all
three emitters from a single assignment rather than three that can drift.

`labs_item_toggled` is deliberately generic: the experiment is a property
(`item_id`), not part of the event name, so a second experiment reuses the
event and every dashboard built on it keeps working. Same for `harness` — a
third harness is one more enum member, not a new field.

The event fires after the preference is persisted, not on click. A failed write
rolls the switch back, and an event asserting a preference the machine does not
hold is worse than a missing one.

Nothing new is built for delivery: `$insert_id` already dedupes (analytics.ts),
identity already rides as a PostHog super-property (identity.ts), and both
sinks already gate on consent. Best-effort throughout — no local queue, no
retry, no compensation path.

* feat(web): collect why users turn a Labs experiment off

The opt-out event carried no reason, so PostHog could show how many people
turned Design Harness off and nothing about why — and the `reason` fields the
contract already reserved stayed permanently empty.

Ask inline, right after the switch moves. Not a dialog: the user has just
declined something, and a modal asking them to justify it reads as friction.

The panel resolves exactly once, and every way out of it reports — a chosen
reason, an explicit skip, the 120-second timeout, or leaving the page with the
question still open. That last one matters: without it an abandoned question is
a silent gap, and the share of people who declined to answer reads lower than
it is. `skipped` is not a reason, it is the recorded absence of one, which is
why a timeout and a deliberate skip store the same value.

Picking "other" stops the clock. It is an explicit intent to write something,
and taking the panel away mid-sentence would discard it.

The opt-out still emits immediately without a reason, and the answer arrives as
a second event. Opt-out counts stay whole even when nobody answers; reason
distribution reads off the follow-up.

Also aligns the switch itself with the toggle the rest of Settings already
uses (`.toggle-slider` — 36x20, `--brand`); it had been drawn larger and in a
different green. Hover and focus-visible states are new: the shared toggles
only lift on hover in dark mode, so in the default theme this control gave no
pointer affordance at all and keyboard focus was invisible.

* fix(web): match the Labs reason panel to the approved mock

The chips shipped as full pills copied from the older assistant-feedback
panel; the mock asks for rounded rectangles. Take the geometry from the mock
directly — 8px radius, 6x11 padding, 7px gaps, 9px on the input and submit —
with tokens standing in for its literal greys.

Also stop the switch drifting. The row was centre-aligned, so opening the
reason panel pushed the switch down to the middle of a block it does not
belong to. Top-align the row and offset the switch to sit level with the name
and hint instead, which is where the mock puts it and where it stays whether
or not the panel is open.

* fix(daemon): keep the harness decision across a clarification continuation

Answering a clarification creates a second physical Run, and the rollout is
only evaluated on the branch that resolves a project — which that path does not
take. So a continuation carried no decision at all, and its `run_created`,
`run_finished` and recovery replay reported no harness.

The runs that hit this are exactly the OD Next runs that stopped to ask a
question. Dropping them leaves the comparison the dimension exists for biased
toward tasks that never needed clarifying, which is the opposite of a neutral
sample.

Inherit from the source Run rather than re-reading the setting: the user may
have flipped the switch while the question was on screen, and this Run belongs
to the decision its task started under. `resolveClarificationContinuation`
already validates that source against the locked task, so it is the trustworthy
copy — the same reasoning, and the same seam, as the task input snapshot the
continuation already inherits beside it.

An idempotent strategy retry skips the evaluation branch for the same reason
and gets the same treatment, reading the decision off the run it is retrying.

Regression coverage asserts inheritance through both `/api/runs` and
`/api/chat`, and that a source Run with no decision still yields none —
absent and "took the ordinary route" are different facts, and a task that
predates the strategy must not be back-filled into either arm.

Reported in QA of #7445.

* feat(web): say what the Design Harness switch does, not when it applies

The line under the item explained timing to someone who had already decided.
It now says what turning it on gets you — the latest OpenDesign generation
strategy, better-looking output — and marks it as beta, which is the part that
sets expectations for something shipped through Labs.

* fix(web): three defects in the Labs switch found in QA

**Double-counted opt-outs.** Turning the switch off opens the reason question;
turning it back on left that question open, still asking about a switch that
was now on. The stale question settled later as a skip, so a fumbled off/on
reported two opt-outs against a single reason row. Re-enabling now retracts
the question, restoring the invariant every count depends on: one reported
opt-out, one reason row.

**The saved confirmation never went away.** The dialog's autosave pill is a
shared surface with no timer of its own — whoever sets it owns clearing it,
and this switch only ever set it. The confirmation sat there for the rest of
the session and followed the user into every other settings section. It now
returns to idle after three seconds, and immediately if the user leaves the
section first, since by then it describes an edit they can no longer see.

**The disabled submit button read as blank.** The mock draws white-on-grey
there, which against this palette lands at roughly 1.9:1 — the label was
effectively invisible rather than merely dimmed. Mute the surface instead of
the text so the word stays legible while the button is unavailable.

All three verified against the running client, and each has a regression test.

* style(web): sit autosave status on the chrome row and soften the settings card edge

Two visual corrections to the Settings page.

The autosave pill moves from the bottom of the viewport to the app's own
top chrome row, centred on it. OPEND-2148 parked it at the bottom to get
it off the Local CLI pickers; the chrome row clears them just as well and
is the better home — on the full page that band is empty, and in the
dialog it sits above the panel rather than over any of its content. What
that ticket guarantees is unchanged: a viewport-level layer, out of the
controls' band, and under the body-portaled model picker. Its spec is
updated to pin that guarantee rather than the edge it hangs from.

The content card's hairline steps down from `--border` to `--border-soft`.
At that size the card is a large, quiet plane and the standard #dbdbdb
rule read as a hard box drawn around it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
Co-authored-by: wangchenglong <honam884844@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
open-design-crew[bot]
2026-08-27 07:01:04 +00:00
committed by GitHub
co-authored by Claude Opus 5 Cheems wangchenglong
parent 75e231ec7e
commit 2828a086c8
37 changed files with 2019 additions and 12 deletions
+23
View File
@@ -20,6 +20,7 @@ import {
buildRunCreatedV4Aliases,
buildRunFinishedV4Aliases,
deriveConfigureGlobals,
harnessAnalyticsFromRolloutDecision,
modelIdForTracking,
sessionModeToTracking,
type TrackingDesignSystemSource,
@@ -1432,6 +1433,19 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) {
sourceRunId,
taskRunIndex,
};
// A continuation is a second physical Run of the same logical task, and the
// rollout is only evaluated on the branch that resolves a project — which
// this path skips. Without inheriting, every answered clarification would
// report no harness at all, quietly dropping the OD Next runs that asked a
// question from the comparison the dimension exists for.
//
// Inherited from the source Run rather than re-read from settings on
// purpose: the user may have flipped the switch while the question was on
// screen, and this Run belongs to the decision its task started under.
// `resolveClarificationContinuation` already verified that source against
// the locked task, so it is the trustworthy copy.
const sourceDecision = design.runs.get(sourceRunId)?.strategyRolloutDecision;
if (sourceDecision) meta.strategyRolloutDecision = sourceDecision;
}
/** Authorize every bound run mutation before plugin or snapshot resolution. */
@@ -2262,6 +2276,11 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) {
meta.odNextTaskInputSnapshot = design.runs.get(
clarificationContinuation.sourceRunId,
)?.odNextTaskInputSnapshot ?? null;
} else if (idempotentStrategyRetry?.strategyRolloutDecision) {
// Same skipped-evaluation shape as the continuation above. A retry is the
// same logical request, so it reports the decision that request already
// made rather than making a fresh one.
meta.strategyRolloutDecision = idempotentStrategyRetry.strategyRolloutDecision;
}
let runProject: ProjectRecord | null = null;
if (typeof meta.projectId === 'string' && meta.projectId) {
@@ -3534,6 +3553,10 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) {
}
: {}),
};
// Read off the run rather than the local decision variable: the run is
// what `run_finished` and the crash-recovery replay both see, so stamping
// it here is the only place this dimension has to be added.
Object.assign(baseProps, harnessAnalyticsFromRolloutDecision(run.strategyRolloutDecision));
Object.assign(baseProps, buildRunCreatedV4Aliases(baseProps, taskLineage));
design.runs.setAnalyticsRecovery?.(run, {
context: analyticsContext,
@@ -266,6 +266,10 @@ function createRunsServiceStub() {
requestFingerprint: meta.requestFingerprint,
workspaceScope: meta.workspaceScope,
odNextTaskInputSnapshot: meta.odNextTaskInputSnapshot ?? null,
// Mirrors the real store (`runtimes/runs.ts`), which persists the
// rollout decision onto the run. Without it a continuation's inherited
// decision would be invisible to assertions here.
strategyRolloutDecision: meta.strategyRolloutDecision ?? null,
status: 'queued',
createdAt: Date.now(),
updatedAt: Date.now(),
@@ -558,6 +562,92 @@ async function startServer(opts?: {
}
describe('POST /api/runs — workspace mutation gate', () => {
it.each(['/api/runs', '/api/chat'])(
'carries the source Run harness decision into a clarification continuation through %s',
async (route) => {
// The rollout is only evaluated on the branch that resolves a project,
// and a continuation does not take that branch. Before this was
// inherited, answering a clarification produced a Run with no decision
// at all — so `run_created` / `run_finished` / the recovery replay for
// every OD Next task that asked a question reported no harness, dropping
// exactly those runs from the comparison the dimension exists for.
const baseUrl = await startServer();
const snapshot = seedAwaitingClarificationTask();
const activeDecision = {
schemaVersion: 1 as const,
decisionClass: 'active' as const,
requestedMode: 'active' as const,
effectiveMode: 'active' as const,
taskType: 'prototype' as const,
assignmentBucket: 42,
eligible: true,
syntheticCanary: false,
reasonCodes: [],
primaryReasonCode: 'od_next_rollout_eligible',
};
runsServiceStub?.seed({
id: 'run-strategy-request',
projectId: PERSONAL_PROJECT,
conversationId: 'conversation-strategy',
assistantMessageId: 'assistant-strategy-request',
agentId: 'codex',
pluginId: 'od-next-strategy',
appliedPluginSnapshotId: snapshot.snapshotId,
strategyRolloutDecision: activeDecision,
status: 'succeeded',
});
const response = await fetch(`${baseUrl}${route}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
taskExecutionId: 'task-strategy-clarification',
projectId: PERSONAL_PROJECT,
conversationId: 'conversation-strategy',
agentId: 'codex',
userMessageId: 'user-strategy-answer',
assistantMessageId: 'assistant-strategy-answer',
clientRequestId: 'client-strategy-answer',
message: 'Desktop workspace',
currentPrompt: 'Desktop workspace',
}),
});
const responseText = await response.text();
expect(response.status, responseText).toBe(202);
expect(lastCreatedRun.strategyRolloutDecision).toMatchObject({
effectiveMode: 'active',
primaryReasonCode: 'od_next_rollout_eligible',
});
},
);
it('leaves a continuation undecided when its source Run never had a decision', async () => {
// Absent and "took the ordinary route" are different facts. A task that
// started before the strategy existed must not be back-filled into either
// arm of the comparison.
const baseUrl = await startServer();
seedAwaitingClarificationTask();
const response = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
taskExecutionId: 'task-strategy-clarification',
projectId: PERSONAL_PROJECT,
conversationId: 'conversation-strategy',
agentId: 'codex',
userMessageId: 'user-strategy-answer',
assistantMessageId: 'assistant-strategy-answer',
clientRequestId: 'client-strategy-answer',
message: 'Desktop workspace',
currentPrompt: 'Desktop workspace',
}),
});
expect(response.status, await response.text()).toBe(202);
expect(lastCreatedRun.strategyRolloutDecision).toBeNull();
});
it.each(['/api/runs', '/api/chat'])(
'atomically binds an explicit clarification handle through %s',
async (route) => {
+14
View File
@@ -146,6 +146,7 @@ import type {
FeedbackSubmitResultProps,
ConversationForkResultProps,
SettingsViewProps,
LabsItemToggledProps,
SettingsCliTestResultProps,
SettingsByokModelsFetchResultProps,
SettingsByokTestResultProps,
@@ -1249,6 +1250,19 @@ export function trackSettingsView(
send(track, 'settings_view', props);
}
// ---- Labs ----------------------------------------------------------------
/**
* Fires after the preference is persisted, not on click.
*
* The event asserts "this install now prefers X". A failed write rolls the
* switch back, so reporting the click would assert something that is not true
* of the machine. Losing the rare failed toggle is the cheaper error.
*/
export function trackLabsItemToggled(track: Track, props: LabsItemToggledProps): void {
send(track, 'labs_item_toggled', props);
}
export function trackSettingsCliTestResult(
track: Track,
props: SettingsCliTestResultProps,
@@ -0,0 +1,340 @@
/* Labs experiment rows.
*
* The mock (`cheems-work/od-lab/ref/od-labs-switch.html`) draws a filled,
* borderless row rather than the bordered card `.toggle-row` renders, and puts
* the item name and its help affordance on one line with the switch opposite.
* Tokens rather than the mock's literal greys, so the row follows the rest of
* Settings when the palette moves.
*/
/* The global `.settings-section` supplies the flex column, the 12px gap between
blocks, and the entrance animation, so this file adds no wrapper rule of its
own — overriding `display` here would cancel that gap. */
.pageDesc {
margin: 0;
font-size: 13px;
line-height: 1.65;
color: var(--text-muted);
}
.row {
display: flex;
/* Top-aligned, not centred: the row grows when the reason panel opens, and a
centred switch would slide down away from the item it belongs to. The
switch's own margin puts it level with the name + hint block. */
align-items: flex-start;
justify-content: space-between;
gap: 20px;
padding: 14px 16px;
border-radius: var(--radius);
background: var(--bg-panel);
}
.rowText {
display: flex;
min-width: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 3px;
}
.rowTitle {
display: flex;
align-items: center;
gap: 2px;
}
.rowName {
font-size: 13.5px;
font-weight: 600;
color: var(--text);
}
/* The "applies next generation" line is permanent, not a transient
confirmation: the switch has no visible effect until the next run, so the
user should read that before flipping it rather than after. */
.rowHint {
font-size: 12px;
line-height: 1.45;
color: var(--text-muted);
}
.rowNotice {
display: inline-flex;
align-items: center;
gap: 5px;
margin-top: 2px;
font-size: 12px;
line-height: 1.45;
color: var(--text-muted);
}
.tooltipHost {
position: relative;
display: inline-flex;
align-items: center;
}
.tooltipTrigger {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--text-muted);
cursor: help;
transition: color 200ms cubic-bezier(0.23, 1, 0.32, 1);
}
.tooltipTrigger:hover,
.tooltipTrigger:focus-visible {
color: var(--text);
}
.tooltip {
position: absolute;
top: calc(100% + 8px);
left: -10px;
z-index: 2;
display: flex;
width: max-content;
max-width: 340px;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border-radius: var(--radius);
background: var(--text-strong, #1f2328);
box-shadow: var(--shadow-lg, 0 8px 24px rgba(0, 0, 0, 0.22));
color: var(--bg, #fff);
animation: labsTooltipIn 200ms cubic-bezier(0.23, 1, 0.32, 1);
}
.tooltipBody {
font-size: 12.5px;
line-height: 1.6;
}
.tooltipScope {
font-size: 11.5px;
line-height: 1.55;
opacity: 0.7;
}
@keyframes labsTooltipIn {
from {
opacity: 0;
transform: translateY(-3px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Geometry and on-colour match `.toggle-slider` (the Memory / Skills switch,
`styles/viewer/memory.css`) so Labs does not introduce a second toggle look.
The hover states are new: the shared toggles only lift on hover in dark mode,
which left this control with no pointer affordance at all in the default
theme — hovering looked identical to resting. */
.switch {
position: relative;
flex: none;
width: 36px;
height: 20px;
margin-top: 10px;
padding: 0;
border: 0;
border-radius: var(--radius-pill);
background: var(--border-strong);
cursor: pointer;
transition: background-color 200ms var(--ease-out), box-shadow 200ms var(--ease-out);
}
.switch:hover:not([aria-disabled='true']) {
background: color-mix(in srgb, var(--text-muted) 42%, var(--border-strong));
}
.switch:focus-visible {
outline: none;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 45%, transparent);
}
.switchOn {
background: var(--brand);
}
.switchOn:hover:not([aria-disabled='true']) {
background: color-mix(in srgb, var(--text) 14%, var(--brand));
}
.switch[aria-disabled='true'] {
cursor: not-allowed;
opacity: 0.45;
}
.switchKnob {
position: absolute;
top: 3px;
left: 3px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: transform 200ms var(--ease-out);
}
.switchOn .switchKnob {
transform: translateX(16px);
}
@media (prefers-reduced-motion: reduce) {
.tooltip { animation: none; }
.switch,
.switchKnob { transition: none; }
}
/* Opt-out reason panel.
*
* Sits inside the row rather than in a dialog: the user has just declined
* something, and a modal asking them to justify it reads as friction.
* Geometry follows the approved mock (`cheems-work/od-lab/ref/od-labs-switch.html`
* — 8px radius chips, 6x11 padding, 7px gaps), with the mock's literal greys
* replaced by tokens. Note this is deliberately NOT the pill shape the older
* assistant-feedback chips use; the mock asks for a rounded rectangle. */
.optOut {
display: flex;
flex-direction: column;
animation: labsOptOutIn 200ms var(--ease-out);
}
.optOutPrompt {
margin-top: 10px;
font-size: 12.5px;
line-height: 1.45;
color: var(--text-muted);
}
.optOutChoices {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 7px;
margin-top: 11px;
}
.optOutChip {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 11px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
color: var(--text-muted);
font-size: 12px;
line-height: 1.3;
cursor: pointer;
transition: border-color 200ms var(--ease-out), color 200ms var(--ease-out);
}
.optOutChip:hover,
.optOutChip:focus-visible {
border-color: var(--border-strong);
color: var(--text-strong);
}
.optOutChipActive,
.optOutChipActive:hover {
border-color: var(--border-strong);
color: var(--text-strong);
}
/* Skip is an escape, not a peer of the reasons — borderless so the real
answers keep the visual weight. Padding matches the chips so the baseline
stays on the same line. */
.optOutSkip {
display: inline-flex;
align-items: center;
padding: 6px 5px;
border: 0;
background: transparent;
color: var(--text-soft, var(--text-muted));
font-size: 12px;
line-height: 1.3;
cursor: pointer;
transition: color 200ms var(--ease-out);
}
.optOutSkip:hover,
.optOutSkip:focus-visible {
color: var(--text);
}
.optOutInputRow {
display: flex;
align-items: center;
gap: 8px;
max-width: 520px;
margin-top: 11px;
}
.optOutInput {
flex: 1 1 auto;
min-width: 0;
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font-family: inherit;
font-size: 12.5px;
line-height: 1.3;
}
.optOutInput:focus {
outline: none;
border-color: var(--border-strong);
}
.optOutSubmit {
flex: none;
padding: 9px 16px;
border: 0;
border-radius: var(--radius);
background: var(--text);
color: var(--bg);
font-size: 12.5px;
line-height: 1.3;
white-space: nowrap;
cursor: pointer;
transition: opacity 200ms var(--ease-out);
}
/* Readable while disabled. The mock draws white-on-grey here, which lands at
about 1.9:1 against this palette's `--border-strong` — the label reads as a
blank button rather than a disabled one. Mute the surface instead of the
text so the word stays legible. */
.optOutSubmit:disabled {
cursor: not-allowed;
background: var(--bg-subtle);
color: var(--text-muted);
opacity: 1;
}
@keyframes labsOptOutIn {
from {
opacity: 0;
transform: translateY(-3px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.optOut { animation: none; }
}
+423
View File
@@ -0,0 +1,423 @@
import { useCallback, useEffect, useId, useRef, useState } from 'react';
import type {
OdNextRolloutControlResponse,
OdNextRolloutMode,
TrackingLabsOptOutReason,
} from '@open-design/contracts';
import { trackLabsItemToggled } from '../analytics/events';
import { useAnalytics } from '../analytics/provider';
import { useT } from '../i18n';
import { Icon } from './Icon';
import styles from './LabsSection.module.css';
/**
* Why this component reads and writes on its own instead of going through
* `cfg` / `setCfg` like the other Settings sections:
*
* `apps/web/src/types.ts`'s `AppConfig` is a web-local projection that does
* not carry `odNextStrategyMode`, and `syncConfigToDaemon` serialises an
* explicit allow-list of fields. Threading this switch through that pipeline
* means three edits, and forgetting the allow-list one fails silently — the
* toggle would look saved and never reach the daemon. A self-contained read
* and a single-field PUT avoid the whole class of bug, and keep the Labs
* surface deletable in one piece when the experiment converges.
*/
type SwitchLock = 'latched' | 'env' | 'unreadable';
interface LabsHarnessState {
on: boolean;
lock: SwitchLock | null;
}
const LOADING: LabsHarnessState | null = null;
/** `PUT /api/app-config` merges per key, so a single-field body is safe. */
async function writeHarnessMode(mode: OdNextRolloutMode): Promise<void> {
const response = await fetch('/api/app-config', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ odNextStrategyMode: mode }),
});
if (!response.ok) throw new Error(`app-config write failed (${response.status})`);
}
/**
* Project the daemon's three-valued rollout mode onto the switch.
*
* `observe` reads as off because it does not run the new harness — but the
* switch never rewrites it on its own: it is a developer-set diagnostic mode,
* and silently clearing it would break someone's debugging session. The user's
* next deliberate toggle resolves it to `active` / `off` naturally.
*
* The lock order matters. A latch is the safety valve — it overrides the saved
* mode in `readOdNextRolloutControlStatus` — so it wins over the environment
* note, which in turn wins over a plain saved value.
*/
export function harnessStateFromStatus(
status: OdNextRolloutControlResponse['status'],
): LabsHarnessState {
const on = status.requestedMode === 'active';
if (status.latch) return { on, lock: 'latched' };
if (status.requestedModeSource === 'env') return { on, lock: 'env' };
return { on, lock: null };
}
function LabsTooltip({ label, body, scope }: { label: string; body: string; scope: string }) {
const [open, setOpen] = useState(false);
const tooltipId = useId();
return (
<span className={styles.tooltipHost}>
<button
type="button"
className={styles.tooltipTrigger}
aria-label={label}
aria-describedby={open ? tooltipId : undefined}
aria-expanded={open}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
// The trigger explains, it does not act. Swallow the click so it
// cannot reach the row and flip the switch by accident.
onClick={(event) => event.preventDefault()}
>
<Icon name="help-circle" size={14} aria-hidden />
</button>
{open ? (
<span className={styles.tooltip} role="tooltip" id={tooltipId}>
<span className={styles.tooltipBody}>{body}</span>
<span className={styles.tooltipScope}>{scope}</span>
</span>
) : null}
</span>
);
}
/**
* How long the reason panel waits before recording a non-answer.
*
* Long enough that it is not a trick question, short enough that the row does
* not stay in a feedback state for the rest of the session. Not shown as a
* countdown: a visible timer turns a question into a deadline.
*/
const OPT_OUT_PROMPT_TTL_MS = 120_000;
/** How long the saved confirmation stays up before the pill returns to idle. */
const SAVED_PILL_TTL_MS = 3_000;
/** Free-text cap. Long enough for a real sentence, short of an essay. */
const CUSTOM_REASON_MAX = 200;
const OPT_OUT_CHOICES: ReadonlyArray<{ reason: TrackingLabsOptOutReason; labelKey: 'labs.optOutWorseOutput' | 'labs.optOutTooSlow' | 'labs.optOutNotWhatIWanted' }> = [
{ reason: 'worse_output', labelKey: 'labs.optOutWorseOutput' },
{ reason: 'too_slow', labelKey: 'labs.optOutTooSlow' },
{ reason: 'not_what_i_wanted', labelKey: 'labs.optOutNotWhatIWanted' },
];
interface OptOutPanelProps {
onAnswer: (answer: { reason: TrackingLabsOptOutReason[]; customReason?: string }) => void;
}
/**
* Asks why, once, right after the user turns an experiment off.
*
* Inline rather than a dialog: the user just declined something, and a modal
* asking them to justify it reads as friction. It resolves exactly once —
* every path out (a choice, skip, the timeout, or leaving the page) reports,
* so the share of people who declined to answer is visible rather than missing.
*/
function OptOutPanel({ onAnswer }: OptOutPanelProps) {
const t = useT();
const [expanded, setExpanded] = useState(false);
const [text, setText] = useState('');
const answerRef = useRef(onAnswer);
answerRef.current = onAnswer;
useEffect(() => {
// Picking "other" is an explicit intent to write something. Taking the
// panel away mid-sentence would be hostile, so the clock stops there —
// a discrete state change, not hover-tracking.
if (expanded) return undefined;
const timer = window.setTimeout(() => {
answerRef.current({ reason: ['skipped'] });
}, OPT_OUT_PROMPT_TTL_MS);
return () => window.clearTimeout(timer);
}, [expanded]);
const trimmed = text.trim();
return (
<div className={styles.optOut}>
<span className={styles.optOutPrompt}>{t('labs.optOutPrompt')}</span>
<div className={styles.optOutChoices}>
{OPT_OUT_CHOICES.map((choice) => (
<button
key={choice.reason}
type="button"
className={styles.optOutChip}
onClick={() => onAnswer({ reason: [choice.reason] })}
>
{t(choice.labelKey)}
</button>
))}
<button
type="button"
className={`${styles.optOutChip}${expanded ? ` ${styles.optOutChipActive}` : ''}`}
aria-expanded={expanded}
onClick={() => setExpanded(true)}
>
{t('labs.optOutOther')}
</button>
<button
type="button"
className={styles.optOutSkip}
onClick={() => onAnswer({ reason: ['skipped'] })}
>
{t('labs.optOutSkip')}
</button>
</div>
{expanded ? (
<div className={styles.optOutInputRow}>
<input
type="text"
className={styles.optOutInput}
maxLength={CUSTOM_REASON_MAX}
placeholder={t('labs.optOutOtherPlaceholder')}
aria-label={t('labs.optOutOtherPlaceholder')}
value={text}
autoFocus
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key !== 'Enter' || !trimmed) return;
onAnswer({ reason: ['other'], customReason: trimmed });
}}
/>
<button
type="button"
className={styles.optOutSubmit}
disabled={!trimmed}
onClick={() => onAnswer({ reason: ['other'], customReason: trimmed })}
>
{t('labs.optOutSubmit')}
</button>
</div>
) : null}
</div>
);
}
export interface LabsSectionProps {
/**
* Drives the dialog-level autosave pill. This switch writes immediately
* rather than through the dialog's debounced autosave, so it reports its own
* outcome on the same surface every other Settings edit uses.
*/
onAutosaveStatus?: (status: 'saving' | 'saved' | 'error' | 'idle') => void;
}
export function LabsSection({ onAutosaveStatus }: LabsSectionProps) {
const t = useT();
const analytics = useAnalytics();
const [state, setState] = useState<LabsHarnessState | null>(LOADING);
const [busy, setBusy] = useState(false);
const noticeId = useId();
// Non-null while the reason panel is up. Held in a ref as well as state so
// the unmount path can settle it without re-rendering a dying component.
const [askingReason, setAskingReason] = useState(false);
const reasonPendingRef = useRef(false);
// Guards against a slow earlier write re-applying UI after a later toggle,
// and against setState landing on an unmounted section.
const writeTokenRef = useRef(0);
const mountedRef = useRef(true);
// `busy` drives the disabled styling, but it cannot gate re-entry: a second
// click in the same tick reads the pre-render closure, where `busy` is still
// false and `state.on` is still the old value, so it would start a second
// write from a stale baseline. The ref flips synchronously and is what the
// guard actually reads.
const writeInFlightRef = useRef(false);
// The dialog's autosave pill is a shared surface with no timer of its own —
// whoever sets it owns clearing it. Without this the confirmation sat there
// for the rest of the session, following the user into every other section.
const savedPillTimerRef = useRef<number | null>(null);
const autosaveRef = useRef(onAutosaveStatus);
autosaveRef.current = onAutosaveStatus;
const reportSaved = useCallback(() => {
autosaveRef.current?.('saved');
if (savedPillTimerRef.current != null) window.clearTimeout(savedPillTimerRef.current);
savedPillTimerRef.current = window.setTimeout(() => {
savedPillTimerRef.current = null;
autosaveRef.current?.('idle');
}, SAVED_PILL_TTL_MS);
}, []);
useEffect(() => () => {
// Leaving the section takes the confirmation with it; it describes an edit
// the user can no longer see.
if (savedPillTimerRef.current == null) return;
window.clearTimeout(savedPillTimerRef.current);
savedPillTimerRef.current = null;
autosaveRef.current?.('idle');
}, []);
useEffect(() => {
mountedRef.current = true;
let cancelled = false;
void (async () => {
try {
const response = await fetch('/api/strategies/od-next/rollout');
if (!response.ok) throw new Error(`rollout status failed (${response.status})`);
const body = (await response.json()) as OdNextRolloutControlResponse;
if (cancelled) return;
setState(harnessStateFromStatus(body.status));
} catch {
if (cancelled) return;
// An unreachable daemon must not blank the page: show the row, locked,
// with the reason spelled out.
setState({ on: false, lock: 'unreadable' });
}
})();
return () => {
cancelled = true;
mountedRef.current = false;
};
}, []);
const answerOptOut = useCallback(
(answer: { reason: TrackingLabsOptOutReason[]; customReason?: string }) => {
// Exactly once. A chip click that races the timeout, or a timeout that
// races unmount, must not produce two reason rows for one opt-out.
if (!reasonPendingRef.current) return;
reasonPendingRef.current = false;
const custom = answer.customReason?.trim() ?? '';
trackLabsItemToggled(analytics.track, {
item_id: 'design_harness',
to: 'off',
source: 'settings',
reason: answer.reason,
has_custom_reason: custom.length > 0,
...(custom ? { custom_reason: custom } : {}),
});
if (mountedRef.current) setAskingReason(false);
},
[analytics.track],
);
useEffect(() => () => {
// Leaving the page with the question still open is the same signal as
// letting it time out: the user moved on. Recording it keeps every opt-out
// paired with exactly one reason row, so the share who declined to answer
// is a number rather than a gap.
if (reasonPendingRef.current) answerOptOut({ reason: ['skipped'] });
}, [answerOptOut]);
const toggle = useCallback(() => {
if (!state || state.lock || writeInFlightRef.current) return;
const next = !state.on;
const previous = state.on;
const token = ++writeTokenRef.current;
writeInFlightRef.current = true;
setState({ ...state, on: next });
setBusy(true);
onAutosaveStatus?.('saving');
void (async () => {
try {
// `'off'` rather than clearing the key: an absent value reads as
// "never touched", and the two are worth telling apart later.
await writeHarnessMode(next ? 'active' : 'off');
if (token !== writeTokenRef.current || !mountedRef.current) return;
// After the write, not on click: a failed write rolls the switch back,
// and an event for a preference the machine does not hold is worse
// than a missing one.
trackLabsItemToggled(analytics.track, {
item_id: 'design_harness',
to: next ? 'on' : 'off',
source: 'settings',
});
if (next) {
// Turning it back on retracts the question. Left open, a fumbled
// off/on/off would report two opt-outs against a single reason row
// and leave the panel asking about a switch that is now on. Settling
// here keeps the invariant every count depends on: one reported
// opt-out, one reason row.
answerOptOut({ reason: ['skipped'] });
} else {
// The opt-out itself is already reported above; the reason arrives
// as a second event once the user answers. Counting opt-outs from
// the first and reasons from the second keeps the opt-out count
// whole even when nobody answers.
reasonPendingRef.current = true;
setAskingReason(true);
}
reportSaved();
} catch {
if (token !== writeTokenRef.current || !mountedRef.current) return;
setState((current) => (current ? { ...current, on: previous } : current));
onAutosaveStatus?.('error');
} finally {
if (token === writeTokenRef.current) {
writeInFlightRef.current = false;
if (mountedRef.current) setBusy(false);
}
}
})();
}, [analytics.track, answerOptOut, onAutosaveStatus, reportSaved, state]);
const lockNoticeKey = state?.lock === 'latched'
? 'labs.latchedNotice'
: state?.lock === 'env'
? 'labs.envOverrideNotice'
: state?.lock === 'unreadable'
? 'labs.loadFailedNotice'
: null;
const on = state?.on ?? false;
// A section that has not resolved yet is not operable either — treating the
// pending read as locked keeps the switch from accepting a click it would
// immediately overwrite.
const locked = state == null || state.lock != null;
return (
<section className="settings-section">
<p className={styles.pageDesc}>{t('labs.pageDesc')}</p>
<div className={styles.row}>
<div className={styles.rowText}>
<span className={styles.rowTitle}>
<span className={styles.rowName}>{t('labs.harnessName')}</span>
<LabsTooltip
label={t('labs.itemAbout', { name: t('labs.harnessName') })}
body={t('labs.harnessTooltip')}
scope={t('labs.harnessScope')}
/>
</span>
<span className={styles.rowHint}>{t('labs.harnessHint')}</span>
{lockNoticeKey ? (
<span className={styles.rowNotice} role="status" id={noticeId}>
<Icon name="info" size={12} aria-hidden />
{t(lockNoticeKey)}
</span>
) : null}
{askingReason ? <OptOutPanel onAnswer={answerOptOut} /> : null}
</div>
{/* `aria-disabled` rather than `disabled`: a disabled button leaves the
tab order, so a screen-reader user never reaches the sentence that
explains why the switch will not move. `toggle` refuses the click
on its own. */}
<button
type="button"
role="switch"
aria-checked={on}
aria-label={t('labs.harnessName')}
aria-disabled={locked || busy}
aria-describedby={lockNoticeKey ? noticeId : undefined}
className={`${styles.switch}${on ? ` ${styles.switchOn}` : ''}`}
onClick={toggle}
data-testid="labs-harness-switch"
>
<span className={styles.switchKnob} aria-hidden />
</button>
</div>
</section>
);
}
+21 -3
View File
@@ -67,6 +67,7 @@ import {
} from '../utils/visibleAgents';
import { ExportDiagnosticsRow } from './ExportDiagnosticsButton';
import { Icon } from './Icon';
import { LabsSection } from './LabsSection';
import { defaultAgentModelId, effectiveAgentModelChoice } from './agentModelSelection';
import {
CUSTOM_MODEL_SENTINEL,
@@ -221,6 +222,7 @@ import {
export type SettingsSection =
| 'general'
| 'labs'
| 'execution'
| 'workspace'
| 'instructions'
@@ -3848,6 +3850,7 @@ export function SettingsDialog({
// not twice (heading + tab).
const sectionHeader: Record<SettingsSection, { title: string; subtitle: string }> = {
general: { title: t('settings.general'), subtitle: t('settings.generalHint') },
labs: { title: t('labs.title'), subtitle: t('labs.navHint') },
execution: { title: t('settings.title'), subtitle: t('settings.subtitle') },
workspace: { title: t('settings.workspace'), subtitle: t('settings.workspaceHint') },
instructions: {
@@ -4208,9 +4211,9 @@ export function SettingsDialog({
onClick={pageMode ? undefined : (e) => e.stopPropagation()}
>
{/* Autosave feedback is viewport-level rather than part of the
top-right dialog chrome. Local CLI pickers and the page-mode
content both occupy that upper band, so keeping this passive
status at the bottom avoids obscuring the active controls. */}
top-right dialog chrome: it rides the app's own top chrome row, so
a passive status never covers the Local CLI pickers that occupy the
panel's upper band (OPEND-2148). */}
<div className="settings-autosave-layer">
<div
className={`settings-autosave is-${autosaveStatus}`}
@@ -4340,6 +4343,17 @@ export function SettingsDialog({
<small>{t('settings.generalHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'labs' ? ' active' : ''}`}
onClick={() => setActiveSection('labs')}
>
<Icon name="sparkles" size={18} />
<span>
<strong>{t('labs.title')}</strong>
<small>{t('labs.navHint')}</small>
</span>
</button>
<button
type="button"
className={`settings-nav-item${activeSection === 'instructions' ? ' active' : ''}`}
@@ -5954,6 +5968,10 @@ export function SettingsDialog({
</section>
) : null}
{activeSection === 'labs' ? (
<LabsSection onAutosaveStatus={setAutosaveStatus} />
) : null}
{activeSection === 'designSystems' ? (
<DesignSystemsSection
cfg={cfg}
+19
View File
@@ -5296,4 +5296,23 @@ export const ar: Dict = {
'experienceSurvey.skip': "تخطٍ",
'experienceSurvey.thanksTitle': "شكراً لك!",
'experienceSurvey.thanksBody': "ملاحظاتك تصل إلى الفريق مباشرة.",
'labs.title': "مختبر Open Design",
'labs.itemAbout': "حول {name}",
'labs.navHint': "جرّب الإمكانات قبل إطلاقها.",
'labs.pageDesc': "احصل على وصول مبكر إلى إمكانات لم تُطلق رسمياً بعد. غالباً ما تكون أفضل من السلوك الحالي، وإيقافها يعيد الوضع الحالي فوراً.",
'labs.harnessName': "Design Harness",
'labs.harnessHint': "عند التفعيل، ستستخدم عملية التوليد التالية أحدث استراتيجية من OpenDesign بنتائج أكثر إتقاناً بوضوح (تجريبي)",
'labs.harnessTooltip': "يبدّل إلى agent harness الذي بناه Open Design لأعمال التصميم. يتحسن التخطيط والبنية ومستوى الاكتمال في المخرجات بشكل ملحوظ.",
'labs.harnessScope': "يغطي حالياً النماذج الأولية والشرائح والمواد التسويقية و Hyperframes. بعض الوكلاء غير مدعومة بعد.",
'labs.envOverrideNotice': "يتحكم متغير بيئة في هذا الإعداد، لذا لا يمكن تغييره هنا.",
'labs.latchedNotice': "تم الإيقاف المؤقت تلقائياً بعد اكتشاف مشكلة. التوليد يستخدم الطريقة السابقة.",
'labs.loadFailedNotice': "تعذّرت قراءة هذا الإعداد. تأكد من أن daemon المحلي قيد التشغيل.",
'labs.optOutPrompt': "تم الرجوع إلى الطريقة السابقة. ما الذي لم يناسبك؟",
'labs.optOutWorseOutput': "جودة المخرجات تراجعت",
'labs.optOutTooSlow': "بطيء جداً",
'labs.optOutNotWhatIWanted': "ليس ما أردته",
'labs.optOutOther': "أخرى",
'labs.optOutSkip': "تخطٍ",
'labs.optOutOtherPlaceholder': "ما الذي لم يناسبك تحديداً؟",
'labs.optOutSubmit': "إرسال",
};
+19
View File
@@ -5296,4 +5296,23 @@ export const de: Dict = {
'experienceSurvey.skip': "Überspringen",
'experienceSurvey.thanksTitle': "Danke!",
'experienceSurvey.thanksBody': "Dein Feedback geht direkt ans Team.",
'labs.title': "Open Design Labs",
'labs.itemAbout': "Über {name}",
'labs.navHint': "Funktionen testen, bevor sie erscheinen.",
'labs.pageDesc': "Teste Funktionen, die noch nicht offiziell verfügbar sind. Sie sind meist besser als das aktuelle Verhalten, und ein Ausschalten stellt den bisherigen Stand sofort wieder her.",
'labs.harnessName': "Design Harness",
'labs.harnessHint': "Aktiviert nutzt die nächste Generierung die neueste Strategie von OpenDesign — merklich feinere Ergebnisse (Beta)",
'labs.harnessTooltip': "Wechselt zu dem Agent-Harness, das Open Design für Designarbeit gebaut hat. Layout, Struktur und Vollständigkeit der Ergebnisse werden spürbar besser.",
'labs.harnessScope': "Deckt derzeit Prototypen, Folien, Marketing-Assets und Hyperframes ab. Einige Agents werden noch nicht unterstützt.",
'labs.envOverrideNotice': "Eine Umgebungsvariable steuert diese Einstellung, sie kann hier nicht geändert werden.",
'labs.latchedNotice': "Nach einem erkannten Problem automatisch pausiert. Die Generierung nutzt das bisherige Verfahren.",
'labs.loadFailedNotice': "Diese Einstellung konnte nicht gelesen werden. Prüfe, ob der lokale Daemon läuft.",
'labs.optOutPrompt': "Zurück zum bisherigen Verfahren. Was hat nicht gepasst?",
'labs.optOutWorseOutput': "Ergebnisse wurden schlechter",
'labs.optOutTooSlow': "Zu langsam",
'labs.optOutNotWhatIWanted': "Nicht was ich wollte",
'labs.optOutOther': "Sonstiges",
'labs.optOutSkip': "Überspringen",
'labs.optOutOtherPlaceholder': "Was genau hat nicht gepasst?",
'labs.optOutSubmit': "Absenden",
};
+19
View File
@@ -5310,4 +5310,23 @@ export const en: Dict = {
'experienceSurvey.skip': "Skip",
'experienceSurvey.thanksTitle': "Thank you!",
'experienceSurvey.thanksBody': "Your feedback goes straight to the team.",
'labs.title': "Open Design Labs",
'labs.itemAbout': "About {name}",
'labs.navHint': "Try capabilities before they ship.",
'labs.pageDesc': "Get early access to capabilities that have not shipped yet. They are usually better than what you have today, and turning one off restores the current behaviour right away.",
'labs.harnessName': "Design Harness",
'labs.harnessHint': "Your next generation will use OpenDesign's latest strategy, with noticeably more polished results (beta)",
'labs.harnessTooltip': "Switches to the agent harness Open Design built for design work. Output is noticeably better in layout, structure, and completeness.",
'labs.harnessScope': "Covers prototypes, slides, marketing assets, and Hyperframes today. Some agents are not supported yet.",
'labs.envOverrideNotice': "An environment variable is controlling this setting, so it cannot be changed here.",
'labs.latchedNotice': "Paused automatically after a problem was detected. Generation is using the original approach.",
'labs.loadFailedNotice': "Could not read this setting. Check that the local daemon is running.",
'labs.optOutPrompt': "Switched back to the previous approach. What did not work?",
'labs.optOutWorseOutput': "Output got worse",
'labs.optOutTooSlow': "Too slow",
'labs.optOutNotWhatIWanted': "Not what I wanted",
'labs.optOutOther': "Other",
'labs.optOutSkip': "Skip",
'labs.optOutOtherPlaceholder': "What specifically did not work?",
'labs.optOutSubmit': "Submit",
};
+19
View File
@@ -5296,4 +5296,23 @@ export const esES: Dict = {
'experienceSurvey.skip': "Omitir",
'experienceSurvey.thanksTitle': "¡Gracias!",
'experienceSurvey.thanksBody': "Tu opinión llega directamente al equipo.",
'labs.title': "Open Design Labs",
'labs.itemAbout': "Acerca de {name}",
'labs.navHint': "Prueba funciones antes de su lanzamiento.",
'labs.pageDesc': "Accede antes que nadie a funciones que aún no se han lanzado. Suelen ser mejores que el comportamiento actual y, al desactivarlas, todo vuelve al estado actual de inmediato.",
'labs.harnessName': "Design Harness",
'labs.harnessHint': "Al activarlo, la próxima generación usará la última estrategia de OpenDesign, con resultados mucho más cuidados (beta)",
'labs.harnessTooltip': "Cambia al harness de agente que Open Design creó para trabajo de diseño. La maquetación, la estructura y el nivel de acabado mejoran de forma notable.",
'labs.harnessScope': "Hoy cubre prototipos, diapositivas, materiales de marketing y Hyperframes. Algunos agentes aún no son compatibles.",
'labs.envOverrideNotice': "Una variable de entorno controla este ajuste, así que no se puede cambiar aquí.",
'labs.latchedNotice': "Pausado automáticamente tras detectar un problema. La generación usa el método anterior.",
'labs.loadFailedNotice': "No se pudo leer este ajuste. Comprueba que el daemon local esté en ejecución.",
'labs.optOutPrompt': "Se ha vuelto al método anterior. ¿Qué no ha funcionado?",
'labs.optOutWorseOutput': "Peores resultados",
'labs.optOutTooSlow': "Demasiado lento",
'labs.optOutNotWhatIWanted': "No es lo que quería",
'labs.optOutOther': "Otro",
'labs.optOutSkip': "Omitir",
'labs.optOutOtherPlaceholder': "¿Qué es lo que no ha funcionado?",
'labs.optOutSubmit': "Enviar",
};
+19
View File
@@ -5291,4 +5291,23 @@ export const fa: Dict = {
'experienceSurvey.skip': "رد کردن",
'experienceSurvey.thanksTitle': "ممنون!",
'experienceSurvey.thanksBody': "بازخورد شما مستقیم به تیم می‌رسد.",
'labs.title': "آزمایشگاه Open Design",
'labs.itemAbout': "درباره {name}",
'labs.navHint': "قابلیت‌ها را پیش از انتشار امتحان کنید.",
'labs.pageDesc': "به قابلیت‌هایی که هنوز رسماً منتشر نشده‌اند زودتر دسترسی پیدا کنید. معمولاً از رفتار فعلی بهترند و با خاموش کردن، وضعیت فعلی بلافاصله برمی‌گردد.",
'labs.harnessName': "Design Harness",
'labs.harnessHint': "با فعال‌سازی، تولید بعدی از جدیدترین استراتژی OpenDesign استفاده می‌کند و خروجی محسوساً پرداخته‌تر است (بتا)",
'labs.harnessTooltip': "به agent harness ای که Open Design برای کارهای طراحی ساخته سوئیچ می‌کند. چیدمان، ساختار و کامل بودن خروجی به‌طور محسوس بهتر می‌شود.",
'labs.harnessScope': "در حال حاضر نمونه‌های اولیه، اسلایدها، محتوای بازاریابی و Hyperframes را پوشش می‌دهد. برخی agent ها هنوز پشتیبانی نمی‌شوند.",
'labs.envOverrideNotice': "این تنظیم توسط یک متغیر محیطی کنترل می‌شود، بنابراین اینجا قابل تغییر نیست.",
'labs.latchedNotice': "پس از شناسایی یک مشکل به‌طور خودکار متوقف شد. تولید از روش قبلی استفاده می‌کند.",
'labs.loadFailedNotice': "خواندن این تنظیم ممکن نشد. بررسی کنید که daemon محلی در حال اجرا باشد.",
'labs.optOutPrompt': "به روش قبلی برگشتیم. چه چیزی مناسب نبود؟",
'labs.optOutWorseOutput': "کیفیت خروجی افت کرد",
'labs.optOutTooSlow': "خیلی کند است",
'labs.optOutNotWhatIWanted': "چیزی که می‌خواستم نبود",
'labs.optOutOther': "سایر",
'labs.optOutSkip': "رد کردن",
'labs.optOutOtherPlaceholder': "دقیقاً چه چیزی مناسب نبود؟",
'labs.optOutSubmit': "ارسال",
};
+19
View File
@@ -5296,4 +5296,23 @@ export const fr: Dict = {
'experienceSurvey.skip': "Passer",
'experienceSurvey.thanksTitle': "Merci !",
'experienceSurvey.thanksBody': "Votre retour va directement à l'équipe.",
'labs.title': "Open Design Labs",
'labs.itemAbout': "À propos de {name}",
'labs.navHint': "Essayez des fonctionnalités avant leur sortie.",
'labs.pageDesc': "Accédez en avant-première à des fonctionnalités qui ne sont pas encore publiées. Elles sont en général meilleures que le comportement actuel, et les désactiver rétablit immédiatement l état actuel.",
'labs.harnessName': "Design Harness",
'labs.harnessHint': "Une fois activé, la prochaine génération utilise la dernière stratégie d'OpenDesign, pour un rendu nettement plus soigné (bêta)",
'labs.harnessTooltip': "Bascule vers le harness d agent conçu par Open Design pour le design. La mise en page, la structure et le niveau de finition des productions sont nettement meilleurs.",
'labs.harnessScope': "Couvre aujourd hui les prototypes, les présentations, les supports marketing et les Hyperframes. Certains agents ne sont pas encore pris en charge.",
'labs.envOverrideNotice': "Une variable d environnement contrôle ce réglage, il ne peut pas être modifié ici.",
'labs.latchedNotice': "Mis en pause automatiquement après la détection d un problème. La génération utilise la méthode habituelle.",
'labs.loadFailedNotice': "Impossible de lire ce réglage. Vérifiez que le daemon local est en cours d exécution.",
'labs.optOutPrompt': "Retour à la méthode précédente. Qu'est-ce qui n'allait pas ?",
'labs.optOutWorseOutput': "Résultats moins bons",
'labs.optOutTooSlow': "Trop lent",
'labs.optOutNotWhatIWanted': "Pas ce que je voulais",
'labs.optOutOther': "Autre",
'labs.optOutSkip': "Ignorer",
'labs.optOutOtherPlaceholder': "Qu'est-ce qui n'allait pas exactement ?",
'labs.optOutSubmit': "Envoyer",
};
+19
View File
@@ -5296,4 +5296,23 @@ export const hu: Dict = {
'experienceSurvey.skip': "Kihagyás",
'experienceSurvey.thanksTitle': "Köszönjük!",
'experienceSurvey.thanksBody': "A visszajelzésed közvetlenül a csapathoz kerül.",
'labs.title': "Open Design Labs",
'labs.itemAbout': "{name} névjegye",
'labs.navHint': "Próbáld ki a funkciókat a megjelenés előtt.",
'labs.pageDesc': "Korai hozzáférés olyan képességekhez, amelyek még nem jelentek meg. Általában jobbak a jelenlegi működésnél, és kikapcsolva azonnal visszaáll a mostani állapot.",
'labs.harnessName': "Design Harness",
'labs.harnessHint': "Bekapcsolva a következő generálás az OpenDesign legújabb stratégiáját használja, érezhetően igényesebb eredménnyel (béta)",
'labs.harnessTooltip': "Átvált arra az agent harness re, amelyet az Open Design tervezői munkához épített. Az elrendezés, a szerkezet és a kidolgozottság érezhetően jobb lesz.",
'labs.harnessScope': "Jelenleg prototípusokat, diákat, marketinganyagokat és Hyperframes elemeket fed le. Néhány agent még nem támogatott.",
'labs.envOverrideNotice': "Ezt a beállítást egy környezeti változó vezérli, ezért itt nem módosítható.",
'labs.latchedNotice': "Egy észlelt hiba után automatikusan szüneteltetve. A generálás a korábbi módszert használja.",
'labs.loadFailedNotice': "Nem sikerült beolvasni ezt a beállítást. Ellenőrizd, hogy fut e a helyi daemon.",
'labs.optOutPrompt': "Visszaálltunk a korábbi módszerre. Mi nem volt megfelelő?",
'labs.optOutWorseOutput': "Rosszabb eredmény",
'labs.optOutTooSlow': "Túl lassú",
'labs.optOutNotWhatIWanted': "Nem ezt szerettem volna",
'labs.optOutOther': "Egyéb",
'labs.optOutSkip': "Kihagyás",
'labs.optOutOtherPlaceholder': "Pontosan mi nem volt megfelelő?",
'labs.optOutSubmit': "Elküldés",
};
+19
View File
@@ -5296,4 +5296,23 @@ export const id: Dict = {
'experienceSurvey.skip': "Lewati",
'experienceSurvey.thanksTitle': "Terima kasih!",
'experienceSurvey.thanksBody': "Masukan Anda langsung sampai ke tim.",
'labs.title': "Open Design Labs",
'labs.itemAbout': "Tentang {name}",
'labs.navHint': "Coba fitur sebelum dirilis.",
'labs.pageDesc': "Dapatkan akses awal ke kemampuan yang belum dirilis. Umumnya lebih baik dari perilaku saat ini, dan mematikannya langsung mengembalikan keadaan sekarang.",
'labs.harnessName': "Design Harness",
'labs.harnessHint': "Setelah diaktifkan, generasi berikutnya memakai strategi terbaru OpenDesign dengan hasil yang jauh lebih rapi (beta)",
'labs.harnessTooltip': "Beralih ke agent harness yang dibangun Open Design untuk pekerjaan desain. Tata letak, struktur, dan kelengkapan hasil terasa jauh lebih baik.",
'labs.harnessScope': "Saat ini mencakup prototipe, slide, aset pemasaran, dan Hyperframes. Sebagian agent belum didukung.",
'labs.envOverrideNotice': "Pengaturan ini dikendalikan variabel lingkungan, jadi tidak bisa diubah di sini.",
'labs.latchedNotice': "Dijeda otomatis setelah masalah terdeteksi. Generasi memakai cara sebelumnya.",
'labs.loadFailedNotice': "Tidak bisa membaca pengaturan ini. Pastikan daemon lokal sedang berjalan.",
'labs.optOutPrompt': "Kembali ke metode sebelumnya. Apa yang kurang pas?",
'labs.optOutWorseOutput': "Hasil jadi lebih buruk",
'labs.optOutTooSlow': "Terlalu lambat",
'labs.optOutNotWhatIWanted': "Bukan yang saya inginkan",
'labs.optOutOther': "Lainnya",
'labs.optOutSkip': "Lewati",
'labs.optOutOtherPlaceholder': "Apa yang kurang pas secara spesifik?",
'labs.optOutSubmit': "Kirim",
};
+19
View File
@@ -5296,4 +5296,23 @@ export const it: Dict = {
'experienceSurvey.skip': "Salta",
'experienceSurvey.thanksTitle': "Grazie!",
'experienceSurvey.thanksBody': "Il tuo feedback arriva direttamente al team.",
'labs.title': "Open Design Labs",
'labs.itemAbout': "Informazioni su {name}",
'labs.navHint': "Prova le funzionalità prima del rilascio.",
'labs.pageDesc': "Accedi in anteprima a funzionalità non ancora rilasciate. Di solito sono migliori del comportamento attuale e disattivarle ripristina subito lo stato corrente.",
'labs.harnessName': "Design Harness",
'labs.harnessHint': "Se attivo, la prossima generazione usa la strategia più recente di OpenDesign, con risultati molto più curati (beta)",
'labs.harnessTooltip': "Passa all harness dell agente che Open Design ha creato per il lavoro di design. Impaginazione, struttura e livello di completezza migliorano in modo evidente.",
'labs.harnessScope': "Oggi copre prototipi, slide, materiali di marketing e Hyperframes. Alcuni agenti non sono ancora supportati.",
'labs.envOverrideNotice': "Una variabile d ambiente controlla questa impostazione, quindi non può essere modificata qui.",
'labs.latchedNotice': "Messo in pausa automaticamente dopo aver rilevato un problema. La generazione usa il metodo precedente.",
'labs.loadFailedNotice': "Impossibile leggere questa impostazione. Verifica che il daemon locale sia in esecuzione.",
'labs.optOutPrompt': "Tornato al metodo precedente. Cosa non ha funzionato?",
'labs.optOutWorseOutput': "Risultati peggiori",
'labs.optOutTooSlow': "Troppo lento",
'labs.optOutNotWhatIWanted': "Non è quello che volevo",
'labs.optOutOther': "Altro",
'labs.optOutSkip': "Salta",
'labs.optOutOtherPlaceholder': "Cosa non ha funzionato esattamente?",
'labs.optOutSubmit': "Invia",
};
+19
View File
@@ -5296,4 +5296,23 @@ export const ja: Dict = {
'experienceSurvey.skip': "スキップ",
'experienceSurvey.thanksTitle': "ありがとうございます!",
'experienceSurvey.thanksBody': "いただいた声はチームに直接届きます。",
'labs.title': "Open Design ラボ",
'labs.itemAbout': "{name} について",
'labs.navHint': "未リリースの機能をひと足先に。",
'labs.pageDesc': "まだ正式リリースされていない機能をひと足先に試せます。多くの場合は現在の挙動より優れており、オフにすればすぐに元の状態へ戻ります。",
'labs.harnessName': "Design Harness",
'labs.harnessHint': "オンにすると、次回の生成から OpenDesign の最新生成戦略を使用します。仕上がりがより洗練されます(ベータ)",
'labs.harnessTooltip': "Open Design がデザイン用途に構築した agent harness に切り替えます。レイアウト・構成・完成度が目に見えて良くなります。",
'labs.harnessScope': "現在はプロトタイプ、スライド、マーケティング素材、Hyperframes に対応しています。一部の agent は未対応です。",
'labs.envOverrideNotice': "環境変数が優先されているため、ここでは変更できません。",
'labs.latchedNotice': "問題を検知したため自動的に停止しました。生成は従来の方式を使用しています。",
'labs.loadFailedNotice': "この設定を読み込めませんでした。ローカルの daemon が起動しているか確認してください。",
'labs.optOutPrompt': "元の生成方式に戻しました。どこが合いませんでしたか?",
'labs.optOutWorseOutput': "成果物の質が下がった",
'labs.optOutTooSlow': "遅い",
'labs.optOutNotWhatIWanted': "求めていたものと違う",
'labs.optOutOther': "その他",
'labs.optOutSkip': "スキップ",
'labs.optOutOtherPlaceholder': "具体的にどこが合いませんでしたか?",
'labs.optOutSubmit': "送信",
};
+19
View File
@@ -5296,4 +5296,23 @@ export const ko: Dict = {
'experienceSurvey.skip': "건너뛰기",
'experienceSurvey.thanksTitle': "감사합니다!",
'experienceSurvey.thanksBody': "보내주신 의견은 팀에 바로 전달됩니다.",
'labs.title': "Open Design 랩",
'labs.itemAbout': "{name} 정보",
'labs.navHint': "아직 출시되지 않은 기능을 미리 사용해 보세요.",
'labs.pageDesc': "아직 정식 출시되지 않은 기능을 먼저 사용해 볼 수 있습니다. 대체로 현재 동작보다 낫고, 언제든 꺼서 원래대로 되돌릴 수 있습니다.",
'labs.harnessName': "Design Harness",
'labs.harnessHint': "켜면 다음 생성부터 OpenDesign 최신 생성 전략을 사용해 결과물이 한층 정교해집니다 (베타)",
'labs.harnessTooltip': "Open Design 이 디자인 작업을 위해 만든 agent harness 로 전환합니다. 레이아웃, 구조, 완성도가 눈에 띄게 좋아집니다.",
'labs.harnessScope': "현재 프로토타입, 슬라이드, 마케팅 자산, Hyperframes 를 지원합니다. 일부 agent 는 아직 지원되지 않습니다.",
'labs.envOverrideNotice': "환경 변수가 이 설정을 제어하고 있어 여기서 변경할 수 없습니다.",
'labs.latchedNotice': "문제가 감지되어 자동으로 중지되었습니다. 생성은 기존 방식을 사용합니다.",
'labs.loadFailedNotice': "이 설정을 읽지 못했습니다. 로컬 daemon 이 실행 중인지 확인하세요.",
'labs.optOutPrompt': "이전 생성 방식으로 되돌렸습니다. 어떤 점이 아쉬웠나요?",
'labs.optOutWorseOutput': "결과물이 나빠짐",
'labs.optOutTooSlow': "너무 느림",
'labs.optOutNotWhatIWanted': "원하던 것이 아님",
'labs.optOutOther': "기타",
'labs.optOutSkip': "건너뛰기",
'labs.optOutOtherPlaceholder': "구체적으로 어떤 점이 아쉬웠나요?",
'labs.optOutSubmit': "제출",
};
+19
View File
@@ -5296,4 +5296,23 @@ export const pl: Dict = {
'experienceSurvey.skip': "Pomiń",
'experienceSurvey.thanksTitle': "Dziękujemy!",
'experienceSurvey.thanksBody': "Twoja opinia trafia prosto do zespołu.",
'labs.title': "Open Design Labs",
'labs.itemAbout': "Informacje o {name}",
'labs.navHint': "Wypróbuj funkcje przed premierą.",
'labs.pageDesc': "Uzyskaj wcześniejszy dostęp do funkcji, które nie zostały jeszcze wydane. Zwykle są lepsze od obecnego działania, a wyłączenie natychmiast przywraca stan bieżący.",
'labs.harnessName': "Design Harness",
'labs.harnessHint': "Po włączeniu następna generacja użyje najnowszej strategii OpenDesign — wyraźnie staranniejsze efekty (beta)",
'labs.harnessTooltip': "Przełącza na agent harness, który Open Design zbudował do pracy projektowej. Układ, struktura i kompletność wyników są zauważalnie lepsze.",
'labs.harnessScope': "Obecnie obejmuje prototypy, slajdy, materiały marketingowe i Hyperframes. Część agentów nie jest jeszcze obsługiwana.",
'labs.envOverrideNotice': "Tym ustawieniem steruje zmienna środowiskowa, więc nie można go tu zmienić.",
'labs.latchedNotice': "Automatycznie wstrzymane po wykryciu problemu. Generowanie korzysta z dotychczasowej metody.",
'labs.loadFailedNotice': "Nie udało się odczytać tego ustawienia. Sprawdź, czy lokalny daemon działa.",
'labs.optOutPrompt': "Wrócono do poprzedniej metody. Co nie zadziałało?",
'labs.optOutWorseOutput': "Gorsze wyniki",
'labs.optOutTooSlow': "Za wolno",
'labs.optOutNotWhatIWanted': "To nie to, czego chciałem",
'labs.optOutOther': "Inne",
'labs.optOutSkip': "Pomiń",
'labs.optOutOtherPlaceholder': "Co dokładnie nie zadziałało?",
'labs.optOutSubmit': "Wyślij",
};
+19
View File
@@ -5296,4 +5296,23 @@ export const ptBR: Dict = {
'experienceSurvey.skip': "Pular",
'experienceSurvey.thanksTitle': "Obrigado!",
'experienceSurvey.thanksBody': "Seu feedback vai direto para o time.",
'labs.title': "Open Design Labs",
'labs.itemAbout': "Sobre {name}",
'labs.navHint': "Teste recursos antes do lançamento.",
'labs.pageDesc': "Tenha acesso antecipado a recursos que ainda não foram lançados. Costumam ser melhores que o comportamento atual, e desligar restaura o estado atual na hora.",
'labs.harnessName': "Design Harness",
'labs.harnessHint': "Ao ativar, a próxima geração usará a estratégia mais recente da OpenDesign, com resultados bem mais caprichados (beta)",
'labs.harnessTooltip': "Passa a usar o harness de agente que a Open Design criou para trabalho de design. Layout, estrutura e nível de acabamento ficam visivelmente melhores.",
'labs.harnessScope': "Hoje cobre protótipos, slides, materiais de marketing e Hyperframes. Alguns agentes ainda não têm suporte.",
'labs.envOverrideNotice': "Uma variável de ambiente controla esta configuração, então ela não pode ser alterada aqui.",
'labs.latchedNotice': "Pausado automaticamente após um problema ser detectado. A geração está usando o método anterior.",
'labs.loadFailedNotice': "Não foi possível ler esta configuração. Verifique se o daemon local está em execução.",
'labs.optOutPrompt': "Voltamos ao método anterior. O que não funcionou?",
'labs.optOutWorseOutput': "Resultados piores",
'labs.optOutTooSlow': "Muito lento",
'labs.optOutNotWhatIWanted': "Não era o que eu queria",
'labs.optOutOther': "Outro",
'labs.optOutSkip': "Pular",
'labs.optOutOtherPlaceholder': "O que exatamente não funcionou?",
'labs.optOutSubmit': "Enviar",
};
+19
View File
@@ -5296,4 +5296,23 @@ export const ru: Dict = {
'experienceSurvey.skip': "Пропустить",
'experienceSurvey.thanksTitle': "Спасибо!",
'experienceSurvey.thanksBody': "Ваш отзыв попадёт прямо к команде.",
'labs.title': "Open Design Labs",
'labs.itemAbout': "О {name}",
'labs.navHint': "Пробуйте возможности до релиза.",
'labs.pageDesc': "Ранний доступ к возможностям, которые ещё не вышли. Обычно они лучше текущего поведения, а выключение сразу возвращает всё как было.",
'labs.harnessName': "Design Harness",
'labs.harnessHint': "После включения следующая генерация использует новейшую стратегию OpenDesign — результат заметно аккуратнее (бета)",
'labs.harnessTooltip': "Переключает на agent harness, созданный Open Design для дизайнерских задач. Вёрстка, структура и завершённость результата заметно лучше.",
'labs.harnessScope': "Сейчас поддерживаются прототипы, слайды, маркетинговые материалы и Hyperframes. Некоторые агенты пока не поддерживаются.",
'labs.envOverrideNotice': "Настройкой управляет переменная окружения, здесь её изменить нельзя.",
'labs.latchedNotice': "Автоматически приостановлено после обнаружения проблемы. Генерация использует прежний способ.",
'labs.loadFailedNotice': "Не удалось прочитать эту настройку. Проверьте, запущен ли локальный daemon.",
'labs.optOutPrompt': "Вернулись к прежнему способу. Что не подошло?",
'labs.optOutWorseOutput': "Результат стал хуже",
'labs.optOutTooSlow': "Слишком медленно",
'labs.optOutNotWhatIWanted': "Не то, что я хотел",
'labs.optOutOther': "Другое",
'labs.optOutSkip': "Пропустить",
'labs.optOutOtherPlaceholder': "Что именно не подошло?",
'labs.optOutSubmit': "Отправить",
};
+19
View File
@@ -5296,4 +5296,23 @@ export const th: Dict = {
'experienceSurvey.skip': "ข้ามไป",
'experienceSurvey.thanksTitle': "ขอบคุณ!",
'experienceSurvey.thanksBody': "ความคิดเห็นของคุณจะส่งตรงถึงทีมงาน",
'labs.title': "Open Design Labs",
'labs.itemAbout': "เกี่ยวกับ {name}",
'labs.navHint': "ทดลองความสามารถก่อนเปิดตัวจริง",
'labs.pageDesc': "เข้าถึงความสามารถที่ยังไม่เปิดตัวอย่างเป็นทางการก่อนใคร โดยทั่วไปดีกว่าการทำงานปัจจุบัน และปิดเมื่อไหร่ก็กลับสู่สถานะเดิมทันที",
'labs.harnessName': "Design Harness",
'labs.harnessHint': "เมื่อเปิด การสร้างครั้งถัดไปจะใช้กลยุทธ์ล่าสุดของ OpenDesign ให้ผลงานประณีตขึ้นชัดเจน (เบต้า)",
'labs.harnessTooltip': "สลับไปใช้ agent harness ที่ Open Design สร้างขึ้นสำหรับงานออกแบบ ผลลัพธ์จะดีขึ้นชัดเจนทั้งการจัดวาง โครงสร้าง และความสมบูรณ์",
'labs.harnessScope': "ปัจจุบันครอบคลุมต้นแบบ สไลด์ สื่อการตลาด และ Hyperframes โดย agent บางตัวยังไม่รองรับ",
'labs.envOverrideNotice': "การตั้งค่านี้ถูกควบคุมด้วยตัวแปรสภาพแวดล้อม จึงเปลี่ยนที่นี่ไม่ได้",
'labs.latchedNotice': "หยุดชั่วคราวอัตโนมัติหลังตรวจพบปัญหา การสร้างกำลังใช้วิธีเดิม",
'labs.loadFailedNotice': "อ่านการตั้งค่านี้ไม่ได้ กรุณาตรวจสอบว่า daemon ในเครื่องกำลังทำงานอยู่",
'labs.optOutPrompt': "กลับไปใช้วิธีเดิมแล้ว มีอะไรไม่เหมาะ",
'labs.optOutWorseOutput': "ผลงานแย่ลง",
'labs.optOutTooSlow': "ช้าเกินไป",
'labs.optOutNotWhatIWanted': "ไม่ใช่สิ่งที่ต้องการ",
'labs.optOutOther': "อื่น ๆ",
'labs.optOutSkip': "ข้าม",
'labs.optOutOtherPlaceholder': "อะไรที่ไม่เหมาะบ้าง",
'labs.optOutSubmit': "ส่ง",
};
+19
View File
@@ -5296,4 +5296,23 @@ export const tr: Dict = {
'experienceSurvey.skip': "Atla",
'experienceSurvey.thanksTitle': "Teşekkürler!",
'experienceSurvey.thanksBody': "Geri bildiriminiz doğrudan ekibe ulaşıyor.",
'labs.title': "Open Design Labs",
'labs.itemAbout': "{name} hakkında",
'labs.navHint': "Yayınlanmadan önce özellikleri deneyin.",
'labs.pageDesc': "Henüz yayınlanmamış özelliklere erken erişin. Genellikle mevcut davranıştan daha iyidir ve kapattığınızda her şey anında eski haline döner.",
'labs.harnessName': "Design Harness",
'labs.harnessHint': "Açtığınızda bir sonraki üretim OpenDesign'ın en yeni stratejisini kullanır, çıktı belirgin şekilde daha özenli olur (beta)",
'labs.harnessTooltip': "Open Design in tasarım işi için geliştirdiği agent harness a geçer. Çıktının yerleşimi, yapısı ve tamamlanmışlığı gözle görülür şekilde daha iyi olur.",
'labs.harnessScope': "Şu anda prototipleri, slaytları, pazarlama içeriklerini ve Hyperframes i kapsıyor. Bazı agent lar henüz desteklenmiyor.",
'labs.envOverrideNotice': "Bu ayarı bir ortam değişkeni yönetiyor, burada değiştirilemez.",
'labs.latchedNotice': "Bir sorun algılandıktan sonra otomatik olarak duraklatıldı. Üretim önceki yöntemi kullanıyor.",
'labs.loadFailedNotice': "Bu ayar okunamadı. Yerel daemon un çalıştığını kontrol edin.",
'labs.optOutPrompt': "Önceki yönteme geri dönüldü. Ne uymadı?",
'labs.optOutWorseOutput': "Çıktı kötüleşti",
'labs.optOutTooSlow': "Çok yavaş",
'labs.optOutNotWhatIWanted': "İstediğim bu değildi",
'labs.optOutOther': "Diğer",
'labs.optOutSkip': "Atla",
'labs.optOutOtherPlaceholder': "Tam olarak ne uymadı?",
'labs.optOutSubmit': "Gönder",
};
+19
View File
@@ -5296,4 +5296,23 @@ export const uk: Dict = {
'experienceSurvey.skip': "Пропустити",
'experienceSurvey.thanksTitle': "Дякуємо!",
'experienceSurvey.thanksBody': "Ваш відгук потрапляє прямо до команди.",
'labs.title': "Open Design Labs",
'labs.itemAbout': "Про {name}",
'labs.navHint': "Спробуйте можливості до релізу.",
'labs.pageDesc': "Ранній доступ до можливостей, які ще не вийшли. Зазвичай вони кращі за поточну поведінку, а вимкнення одразу повертає все як було.",
'labs.harnessName': "Design Harness",
'labs.harnessHint': "Після ввімкнення наступна генерація використає найновішу стратегію OpenDesign — результат помітно охайніший (бета)",
'labs.harnessTooltip': "Перемикає на agent harness, створений Open Design для дизайнерських задач. Верстка, структура та завершеність результату помітно кращі.",
'labs.harnessScope': "Наразі підтримуються прототипи, слайди, маркетингові матеріали та Hyperframes. Деякі агенти ще не підтримуються.",
'labs.envOverrideNotice': "Налаштуванням керує змінна середовища, тут його змінити не можна.",
'labs.latchedNotice': "Автоматично призупинено після виявлення проблеми. Генерація використовує попередній спосіб.",
'labs.loadFailedNotice': "Не вдалося прочитати це налаштування. Перевірте, чи запущено локальний daemon.",
'labs.optOutPrompt': "Повернулися до попереднього способу. Що не підійшло?",
'labs.optOutWorseOutput': "Результат став гіршим",
'labs.optOutTooSlow': "Занадто повільно",
'labs.optOutNotWhatIWanted': "Не те, що я хотів",
'labs.optOutOther': "Інше",
'labs.optOutSkip': "Пропустити",
'labs.optOutOtherPlaceholder': "Що саме не підійшло?",
'labs.optOutSubmit': "Надіслати",
};
+19
View File
@@ -5745,4 +5745,23 @@ export const zhCN: Dict = {
'experienceSurvey.skip': "跳过",
'experienceSurvey.thanksTitle': "感谢反馈!",
'experienceSurvey.thanksBody': "你的反馈会直接同步给团队。",
'labs.title': "Open Design 实验室",
'labs.itemAbout': "关于 {name}",
'labs.navHint': "抢先体验尚未正式发布的能力。",
'labs.pageDesc': "抢先体验尚未正式发布的能力,通常已优于当前版本;随时关闭即可恢复现状。",
'labs.harnessName': "Design Harness",
'labs.harnessHint': "开启后,下次生成将使用 OpenDesign 最新生成策略,生成效果更精美(内测中)",
'labs.harnessTooltip': "开启后改用 Open Design 为设计场景专门打造的 agent harness,产物在版式、结构和完成度上会明显更好。",
'labs.harnessScope': "目前覆盖原型、PPT、营销物料与 Hyperframes;部分 agent 暂不支持。",
'labs.envOverrideNotice': "当前由环境变量接管,无法在此修改。",
'labs.latchedNotice': "检测到问题后已自动暂停,生成正在使用原来的方式。",
'labs.loadFailedNotice': "读取不到这项设置,请确认本地 daemon 正在运行。",
'labs.optOutPrompt': "已切回原来的生成方式。哪里不合适?",
'labs.optOutWorseOutput': "产物变差",
'labs.optOutTooSlow': "太慢",
'labs.optOutNotWhatIWanted': "不是我要的",
'labs.optOutOther': "其他",
'labs.optOutSkip': "跳过",
'labs.optOutOtherPlaceholder': "具体是哪里不合适?",
'labs.optOutSubmit': "提交",
};
+19
View File
@@ -5752,4 +5752,23 @@ export const zhTW: Dict = {
'experienceSurvey.skip': "略過",
'experienceSurvey.thanksTitle': "感謝回饋!",
'experienceSurvey.thanksBody': "你的回饋會直接同步給團隊。",
'labs.title': "Open Design 實驗室",
'labs.itemAbout': "關於 {name}",
'labs.navHint': "搶先體驗尚未正式發布的能力。",
'labs.pageDesc': "搶先體驗尚未正式發布的能力,通常已優於目前版本;隨時關閉即可恢復現狀。",
'labs.harnessName': "Design Harness",
'labs.harnessHint': "開啟後,下次生成將使用 OpenDesign 最新生成策略,生成效果更精緻(內測中)",
'labs.harnessTooltip': "開啟後改用 Open Design 為設計場景打造的 agent harness,產物在版面、結構與完成度上會明顯更好。",
'labs.harnessScope': "目前涵蓋原型、簡報、行銷素材與 Hyperframes;部分 agent 尚未支援。",
'labs.envOverrideNotice': "目前由環境變數接管,無法在此修改。",
'labs.latchedNotice': "偵測到問題後已自動暫停,生成正在使用原本的方式。",
'labs.loadFailedNotice': "讀取不到這項設定,請確認本機 daemon 正在執行。",
'labs.optOutPrompt': "已切回原本的生成方式。哪裡不合適?",
'labs.optOutWorseOutput': "產物變差",
'labs.optOutTooSlow': "太慢",
'labs.optOutNotWhatIWanted': "不是我要的",
'labs.optOutOther': "其他",
'labs.optOutSkip': "略過",
'labs.optOutOtherPlaceholder': "具體是哪裡不合適?",
'labs.optOutSubmit': "送出",
};
+19
View File
@@ -5466,4 +5466,23 @@ export interface Dict {
'experienceSurvey.skip': string;
'experienceSurvey.thanksTitle': string;
'experienceSurvey.thanksBody': string;
'labs.title': string;
'labs.itemAbout': string;
'labs.navHint': string;
'labs.pageDesc': string;
'labs.harnessName': string;
'labs.harnessHint': string;
'labs.harnessTooltip': string;
'labs.harnessScope': string;
'labs.envOverrideNotice': string;
'labs.latchedNotice': string;
'labs.loadFailedNotice': string;
'labs.optOutPrompt': string;
'labs.optOutWorseOutput': string;
'labs.optOutTooSlow': string;
'labs.optOutNotWhatIWanted': string;
'labs.optOutOther': string;
'labs.optOutSkip': string;
'labs.optOutOtherPlaceholder': string;
'labs.optOutSubmit': string;
}
+21 -5
View File
@@ -927,11 +927,23 @@
/* Keep passive save feedback out of dialog/page layout and below the
body-portaled model picker (z-index 10500). This leaves Local CLI controls
unobscured in dialog, page, and narrow presentations while the full status
copy remains readable. */
copy remains readable.
It rides the app's TOP chrome row (52px — `.workspace-tabs-chrome`,
viewer/routines.css), centred on that row's line. OPEND-2148 originally
parked it at the bottom of the viewport to get it off the Local CLI
pickers; the chrome row clears them just as well and is the better home for
it — on the full page that band is empty, and in the dialog it sits above
the panel rather than over any of its content. What that ticket actually
guarantees is unchanged: a viewport-level layer, out of the controls' band,
and under the body-portaled model picker. */
.settings-autosave-layer {
position: fixed;
left: 50%;
bottom: 24px;
top: 0;
height: 52px;
display: flex;
align-items: center;
z-index: 9000;
transform: translateX(-50%);
max-width: calc(100vw - 32px);
@@ -1551,10 +1563,14 @@
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
/* The whole content block rides one white card (spec: white surface +
#dbdbdb hairline + large radius) floating on the shell ground. */
/* The whole content block rides one white card (white surface + hairline +
large radius) floating on the shell ground. */
background: var(--bg);
border: 1px solid var(--border);
/* One step lighter than the app's default `--border`: at this size the card
is a large, quiet plane, and the standard #dbdbdb rule read as a hard box
drawn around it. `--border-soft` also moves the right way in dark mode
(#494949, i.e. softer than --border's #5c5c5c). */
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
}
/* Stretching the window widens only the card — the content column stays a
@@ -0,0 +1,508 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
OdNextRolloutControlStatus,
OdNextRolloutLatchStatus,
OdNextRolloutMode,
OdNextRolloutModeSource,
} from '@open-design/contracts';
import { LabsSection } from '../../src/components/LabsSection';
import { I18nProvider } from '../../src/i18n';
const track = vi.fn();
vi.mock('../../src/analytics/provider', () => ({
useAnalytics: () => ({ track }),
}));
function status(overrides: {
requestedMode?: OdNextRolloutMode;
requestedModeSource?: OdNextRolloutModeSource;
latch?: OdNextRolloutLatchStatus | null;
} = {}): OdNextRolloutControlStatus {
const requestedMode = overrides.requestedMode ?? 'off';
return {
strategyId: 'od-next-strategy',
scope: 'daemon_instance',
requestedMode,
requestedModeSource: overrides.requestedModeSource ?? 'default',
effectiveMode: requestedMode,
latch: overrides.latch ?? null,
revision: 0,
updatedAt: null,
lastEvent: null,
resetAllowed: false,
};
}
interface Stub {
rolloutStatus?: OdNextRolloutControlStatus;
rolloutFails?: boolean;
writeFails?: boolean;
}
function stubFetch(options: Stub = {}) {
const writes: unknown[] = [];
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
if (url === '/api/strategies/od-next/rollout') {
if (options.rolloutFails) return new Response('{}', { status: 500 });
return new Response(
JSON.stringify({ status: options.rolloutStatus ?? status() }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/app-config') {
writes.push(JSON.parse(String(init?.body ?? '{}')));
if (options.writeFails) return new Response('{}', { status: 500 });
return new Response('{"config":{}}', { status: 200 });
}
throw new Error(`unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
return { writes, fetchMock };
}
function renderSection(onAutosaveStatus?: (s: 'saving' | 'saved' | 'error' | 'idle') => void) {
return render(
<I18nProvider initial="en">
<LabsSection onAutosaveStatus={onAutosaveStatus} />
</I18nProvider>,
);
}
function switchEl(): HTMLButtonElement {
return screen.getByTestId('labs-harness-switch') as HTMLButtonElement;
}
describe('LabsSection', () => {
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
track.mockClear();
});
it('renders the harness row off and operable on a machine that never configured it', async () => {
stubFetch();
renderSection();
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
expect(switchEl().getAttribute('aria-checked')).toBe('false');
expect(screen.getByText('Design Harness')).toBeTruthy();
expect(
screen.getByText(
"Your next generation will use OpenDesign's latest strategy, with noticeably more polished results (beta)",
),
).toBeTruthy();
});
it('renders on when the installation saved active', async () => {
stubFetch({ rolloutStatus: status({ requestedMode: 'active', requestedModeSource: 'app_config' }) });
renderSection();
await waitFor(() => expect(switchEl().getAttribute('aria-checked')).toBe('true'));
expect(switchEl().getAttribute('aria-disabled')).toBe('false');
});
it('shows observe as off without rewriting it', async () => {
const { writes } = stubFetch({
rolloutStatus: status({ requestedMode: 'observe', requestedModeSource: 'app_config' }),
});
renderSection();
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
expect(switchEl().getAttribute('aria-checked')).toBe('false');
expect(writes).toEqual([]);
});
it('writes active on the first turn-on and reports it on the autosave surface', async () => {
const { writes } = stubFetch();
const onAutosaveStatus = vi.fn();
renderSection(onAutosaveStatus);
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
fireEvent.click(switchEl());
await waitFor(() => expect(writes).toEqual([{ odNextStrategyMode: 'active' }]));
expect(switchEl().getAttribute('aria-checked')).toBe('true');
await waitFor(() => expect(onAutosaveStatus.mock.calls.map((c) => c[0])).toEqual(['saving', 'saved']));
});
it('writes an explicit off rather than clearing the key', async () => {
const { writes } = stubFetch({
rolloutStatus: status({ requestedMode: 'active', requestedModeSource: 'app_config' }),
});
renderSection();
await waitFor(() => expect(switchEl().getAttribute('aria-checked')).toBe('true'));
fireEvent.click(switchEl());
await waitFor(() => expect(writes).toEqual([{ odNextStrategyMode: 'off' }]));
});
it('rolls the switch back and reports an error when the write fails', async () => {
stubFetch({ writeFails: true });
const onAutosaveStatus = vi.fn();
renderSection(onAutosaveStatus);
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
fireEvent.click(switchEl());
await waitFor(() => expect(onAutosaveStatus).toHaveBeenCalledWith('error'));
expect(switchEl().getAttribute('aria-checked')).toBe('false');
expect(switchEl().getAttribute('aria-disabled')).toBe('false');
});
it('starts one write for a burst of clicks in the same tick', async () => {
// `busy` is state, so a second click in the same tick still sees the
// pre-render closure: `busy` false and the old `on`. Without a guard that
// flips synchronously, each click in the burst starts its own write from a
// stale baseline.
const { writes } = stubFetch();
renderSection();
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
const target = switchEl();
target.click();
target.click();
target.click();
await waitFor(() => expect(writes.length).toBeGreaterThan(0));
expect(writes).toEqual([{ odNextStrategyMode: 'active' }]);
await waitFor(() => expect(switchEl().getAttribute('aria-checked')).toBe('true'));
});
it('accepts a second toggle once the first write has settled', async () => {
const { writes } = stubFetch();
renderSection();
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
fireEvent.click(switchEl());
await waitFor(() => expect(writes).toEqual([{ odNextStrategyMode: 'active' }]));
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
fireEvent.click(switchEl());
await waitFor(() => expect(writes).toEqual([
{ odNextStrategyMode: 'active' },
{ odNextStrategyMode: 'off' },
]));
expect(switchEl().getAttribute('aria-checked')).toBe('false');
});
it('reports the toggle only after the preference is persisted', async () => {
const { writes } = stubFetch();
renderSection();
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
fireEvent.click(switchEl());
await waitFor(() => expect(writes).toHaveLength(1));
await waitFor(() => expect(track).toHaveBeenCalledTimes(1));
expect(track.mock.calls[0]?.[0]).toBe('labs_item_toggled');
expect(track.mock.calls[0]?.[1]).toEqual({
item_id: 'design_harness',
to: 'on',
source: 'settings',
});
});
it('reports the opt-out direction on the way back off', async () => {
stubFetch({ rolloutStatus: status({ requestedMode: 'active', requestedModeSource: 'app_config' }) });
renderSection();
await waitFor(() => expect(switchEl().getAttribute('aria-checked')).toBe('true'));
fireEvent.click(switchEl());
await waitFor(() => expect(track).toHaveBeenCalledTimes(1));
expect(track.mock.calls[0]?.[1]).toMatchObject({ to: 'off', source: 'settings' });
});
it('reports nothing when the write fails', async () => {
// The switch rolls back, so the install does not hold the preference the
// event would have asserted.
stubFetch({ writeFails: true });
const onAutosaveStatus = vi.fn();
renderSection(onAutosaveStatus);
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
fireEvent.click(switchEl());
await waitFor(() => expect(onAutosaveStatus).toHaveBeenCalledWith('error'));
expect(track).not.toHaveBeenCalled();
});
describe('opt-out reason', () => {
async function optOut() {
stubFetch({ rolloutStatus: status({ requestedMode: 'active', requestedModeSource: 'app_config' }) });
renderSection();
await waitFor(() => expect(switchEl().getAttribute('aria-checked')).toBe('true'));
fireEvent.click(switchEl());
await waitFor(() => expect(track).toHaveBeenCalledTimes(1));
await screen.findByText('Switched back to the previous approach. What did not work?');
}
function reasonEvents() {
return track.mock.calls.filter((c) => (c[1] as { reason?: unknown }).reason);
}
it('asks only after an opt-out, never after opting in', async () => {
stubFetch();
renderSection();
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
fireEvent.click(switchEl());
await waitFor(() => expect(track).toHaveBeenCalledTimes(1));
expect(screen.queryByText('Switched back to the previous approach. What did not work?')).toBeNull();
});
it('reports a chosen reason as a second event, leaving the opt-out count intact', async () => {
await optOut();
fireEvent.click(screen.getByText('Too slow'));
await waitFor(() => expect(track).toHaveBeenCalledTimes(2));
expect(track.mock.calls[0]?.[1]).toEqual({ item_id: 'design_harness', to: 'off', source: 'settings' });
expect(track.mock.calls[1]?.[1]).toEqual({
item_id: 'design_harness',
to: 'off',
source: 'settings',
reason: ['too_slow'],
has_custom_reason: false,
});
expect(screen.queryByText('Switched back to the previous approach. What did not work?')).toBeNull();
});
it('records an explicit skip', async () => {
await optOut();
fireEvent.click(screen.getByText('Skip'));
await waitFor(() => expect(reasonEvents()).toHaveLength(1));
expect(reasonEvents()[0]?.[1]).toMatchObject({ reason: ['skipped'], has_custom_reason: false });
});
it('carries the free text when the user picks other', async () => {
await optOut();
fireEvent.click(screen.getByText('Other'));
const input = screen.getByLabelText('What specifically did not work?') as HTMLInputElement;
fireEvent.change(input, { target: { value: ' layout drifts on long decks ' } });
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => expect(reasonEvents()).toHaveLength(1));
expect(reasonEvents()[0]?.[1]).toMatchObject({
reason: ['other'],
has_custom_reason: true,
custom_reason: 'layout drifts on long decks',
});
});
it('keeps submit unavailable until the free text has content', async () => {
await optOut();
fireEvent.click(screen.getByText('Other'));
const submit = screen.getByText('Submit') as HTMLButtonElement;
expect(submit.disabled).toBe(true);
fireEvent.change(screen.getByLabelText('What specifically did not work?'), {
target: { value: ' ' },
});
expect(submit.disabled).toBe(true);
fireEvent.change(screen.getByLabelText('What specifically did not work?'), {
target: { value: 'x' },
});
expect(submit.disabled).toBe(false);
});
it('records a skip when the question times out unanswered', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
await optOut();
expect(reasonEvents()).toHaveLength(0);
await vi.advanceTimersByTimeAsync(120_000);
await waitFor(() => expect(reasonEvents()).toHaveLength(1));
expect(reasonEvents()[0]?.[1]).toMatchObject({ reason: ['skipped'] });
} finally {
vi.useRealTimers();
}
});
it('stops the clock once the user starts writing a custom reason', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
await optOut();
fireEvent.click(screen.getByText('Other'));
await vi.advanceTimersByTimeAsync(300_000);
// Taking the panel away mid-sentence would discard what they typed.
expect(reasonEvents()).toHaveLength(0);
expect(screen.getByLabelText('What specifically did not work?')).toBeTruthy();
} finally {
vi.useRealTimers();
}
});
it('retracts the question when the user turns the switch back on', async () => {
// A fumbled off/on used to leave the panel asking about a switch that was
// already back on, and reported two opt-outs against a single reason row
// once the stale question finally settled.
await optOut();
fireEvent.click(switchEl());
await waitFor(() => expect(switchEl().getAttribute('aria-checked')).toBe('true'));
await waitFor(() =>
expect(screen.queryByText('Switched back to the previous approach. What did not work?')).toBeNull());
const offs = track.mock.calls.filter((c) => (c[1] as { to?: string }).to === 'off');
const ons = track.mock.calls.filter((c) => (c[1] as { to?: string }).to === 'on');
// One reported opt-out, one reason row for it, one opt-in.
expect(offs).toHaveLength(2);
expect(offs.filter((c) => (c[1] as { reason?: unknown }).reason)).toHaveLength(1);
expect(ons).toHaveLength(1);
});
it('records a skip when the user leaves the page with the question open', async () => {
// Otherwise every abandoned question is a silent gap, and the share of
// people who declined to answer reads lower than it is.
await optOut();
cleanup();
await waitFor(() => expect(reasonEvents()).toHaveLength(1));
expect(reasonEvents()[0]?.[1]).toMatchObject({ reason: ['skipped'] });
});
it('answers exactly once even when two paths out race', async () => {
await optOut();
fireEvent.click(screen.getByText('Too slow'));
cleanup();
await waitFor(() => expect(reasonEvents()).toHaveLength(1));
expect(reasonEvents()[0]?.[1]).toMatchObject({ reason: ['too_slow'] });
});
});
it('clears the saved confirmation instead of leaving it up', async () => {
// The dialog's autosave pill is shared and has no timer of its own, so the
// confirmation used to sit there for the rest of the session and follow the
// user into every other settings section.
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
stubFetch();
const onAutosaveStatus = vi.fn();
renderSection(onAutosaveStatus);
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
fireEvent.click(switchEl());
await waitFor(() => expect(onAutosaveStatus).toHaveBeenCalledWith('saved'));
expect(onAutosaveStatus).not.toHaveBeenCalledWith('idle');
await vi.advanceTimersByTimeAsync(3_000);
await waitFor(() => expect(onAutosaveStatus).toHaveBeenCalledWith('idle'));
} finally {
vi.useRealTimers();
}
});
it('takes the saved confirmation down when the section is left early', async () => {
stubFetch();
const onAutosaveStatus = vi.fn();
renderSection(onAutosaveStatus);
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
fireEvent.click(switchEl());
await waitFor(() => expect(onAutosaveStatus).toHaveBeenCalledWith('saved'));
cleanup();
expect(onAutosaveStatus).toHaveBeenCalledWith('idle');
});
it('locks the switch and explains when an environment variable owns the mode', async () => {
stubFetch({
rolloutStatus: status({ requestedMode: 'active', requestedModeSource: 'env' }),
});
renderSection();
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('true'));
expect(switchEl().getAttribute('aria-checked')).toBe('true');
expect(
screen.getByText('An environment variable is controlling this setting, so it cannot be changed here.'),
).toBeTruthy();
});
it('locks the switch and explains when the local safety latch has tripped', async () => {
stubFetch({
rolloutStatus: status({
requestedMode: 'active',
requestedModeSource: 'app_config',
latch: { mode: 'observe', reasonCode: 'quality_regression', latchedAt: 1 },
}),
});
renderSection();
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('true'));
expect(
screen.getByText('Paused automatically after a problem was detected. Generation is using the original approach.'),
).toBeTruthy();
});
it('reports the latch, not the environment, when both would lock the switch', async () => {
stubFetch({
rolloutStatus: status({
requestedMode: 'active',
requestedModeSource: 'env',
latch: { mode: 'off', reasonCode: 'machine_contract_leak', latchedAt: 1 },
}),
});
renderSection();
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('true'));
expect(
screen.getByText('Paused automatically after a problem was detected. Generation is using the original approach.'),
).toBeTruthy();
expect(
screen.queryByText('An environment variable is controlling this setting, so it cannot be changed here.'),
).toBeNull();
});
it('keeps the page usable when the daemon cannot be reached', async () => {
const { writes } = stubFetch({ rolloutFails: true });
renderSection();
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('true'));
expect(screen.getByText('Design Harness')).toBeTruthy();
expect(
screen.getByText('Could not read this setting. Check that the local daemon is running.'),
).toBeTruthy();
fireEvent.click(switchEl());
expect(writes).toEqual([]);
});
it('reveals the explanation on hover and on keyboard focus, and never toggles from it', async () => {
const { writes } = stubFetch();
renderSection();
await waitFor(() => expect(switchEl().getAttribute('aria-disabled')).toBe('false'));
const trigger = screen.getByLabelText('About Design Harness');
expect(screen.queryByRole('tooltip')).toBeNull();
fireEvent.mouseEnter(trigger);
expect(screen.getByRole('tooltip').textContent).toContain('agent harness');
fireEvent.mouseLeave(trigger);
expect(screen.queryByRole('tooltip')).toBeNull();
fireEvent.focus(trigger);
expect(screen.getByRole('tooltip').textContent).toContain('Hyperframes');
fireEvent.blur(trigger);
expect(screen.queryByRole('tooltip')).toBeNull();
fireEvent.click(trigger);
expect(writes).toEqual([]);
expect(switchEl().getAttribute('aria-checked')).toBe('false');
});
});
@@ -152,7 +152,11 @@ describe('OPEND-2148 — Settings autosave feedback obscures Local CLI controls'
expect(ruleValue(feedbackLayer, 'position')).toBe('fixed');
expect(ruleValue(feedbackLayer, 'left')).toBe('50%');
expect(ruleValue(feedbackLayer, 'bottom')).toBe('24px');
// The layer moved from the bottom of the viewport onto the app's top
// chrome row. What this ticket guarantees is that passive status keeps
// clear of the Local CLI controls in the panel's upper band and stays
// under the model picker — not the specific edge it hangs from.
expect(ruleValue(feedbackLayer, 'top')).toBe('0');
expect(ruleValue(feedbackLayer, 'transform')).toBe('translateX(-50%)');
const feedbackZIndex = Number(ruleValue(feedbackLayer, 'z-index'));
expect(feedbackZIndex).toBeGreaterThan(1700);
@@ -66,6 +66,8 @@ export type AnalyticsEventName =
| 'assistant_feedback_reason_submit'
// Settings
| 'settings_view'
// Labs experiment opt-in / opt-out (generic across experiments).
| 'labs_item_toggled'
| 'settings_cli_test_result'
| 'settings_byok_test_result'
| 'settings_byok_models_fetch_result'
@@ -8,7 +8,7 @@ import type { DesignSystemApplyResultProps, DesignSystemCreateResultProps, Desig
import type { OnboardingCompletedProps, OnboardingCompleteResultProps, OnboardingFirstGenerationCompletedProps, OnboardingFirstPromptSentProps, OnboardingPromptPrefilledProps, OnboardingRuntimeScanResultProps } from './onboarding.js';
import type { PageViewProps } from './page-view.js';
import type {
AgentDetectDiagnosticProps, ArtifactDeployResultProps, ArtifactEditResultProps, ArtifactExportResultProps, ArtifactPublishResultProps, AssistantFeedbackClickProps, AssistantFeedbackReasonClickProps, AssistantFeedbackReasonSubmitProps, AssistantFeedbackReasonViewProps, ByokPreflightBlockedProps, ContextLinkResultProps, ConversationForkResultProps, FeedbackSubmitResultProps, FileUploadResultProps, FileVersionRestoreResultProps, LangfuseReportResultProps, MediaGenerationResultProps, PackagedRuntimeFailedProps, PluginImportResultProps, PluginReplacementResultProps, ProjectCreateResultProps, RunCreatedProps, RunFinishedProps, RunRetryAttemptedProps, RunRetryFinishedProps, SettingsByokModelsFetchResultProps, SettingsByokTestResultProps, SettingsCliTestResultProps, SettingsConnectorAuthResultProps, SettingsViewProps, SketchExportResultProps, SketchSaveResultProps, SpeakerNotesSaveResultProps, UpdateApplyObservedProps, UpdateCheckResultProps, UpdateInstallResultProps } from './result-events.js';
AgentDetectDiagnosticProps, ArtifactDeployResultProps, ArtifactEditResultProps, ArtifactExportResultProps, ArtifactPublishResultProps, AssistantFeedbackClickProps, AssistantFeedbackReasonClickProps, AssistantFeedbackReasonSubmitProps, AssistantFeedbackReasonViewProps, ByokPreflightBlockedProps, ContextLinkResultProps, ConversationForkResultProps, FeedbackSubmitResultProps, FileUploadResultProps, FileVersionRestoreResultProps, LabsItemToggledProps, LangfuseReportResultProps, MediaGenerationResultProps, PackagedRuntimeFailedProps, PluginImportResultProps, PluginReplacementResultProps, ProjectCreateResultProps, RunCreatedProps, RunFinishedProps, RunRetryAttemptedProps, RunRetryFinishedProps, SettingsByokModelsFetchResultProps, SettingsByokTestResultProps, SettingsCliTestResultProps, SettingsConnectorAuthResultProps, SettingsViewProps, SketchExportResultProps, SketchSaveResultProps, SpeakerNotesSaveResultProps, UpdateApplyObservedProps, UpdateCheckResultProps, UpdateInstallResultProps } from './result-events.js';
import type { SurfaceViewProps } from './surface-view.js';
import type { AmrAuthResultProps, UiClickProps } from './ui-click.js';
import type {
@@ -71,6 +71,7 @@ export type AnalyticsEventPayload =
props: AssistantFeedbackReasonSubmitProps;
}
| { event: 'settings_view'; props: SettingsViewProps }
| { event: 'labs_item_toggled'; props: LabsItemToggledProps }
| { event: 'settings_cli_test_result'; props: SettingsCliTestResultProps }
| { event: 'settings_byok_test_result'; props: SettingsByokTestResultProps }
| {
@@ -4,7 +4,7 @@
*/
import { HYPERFRAMES_VIDEO_MODEL } from '../../api/projects.js';
import type { AnalyticsConfigureGlobals, TrackingConfigureAvailability, TrackingConfigureType, TrackingRuntimeType } from '../public-params.js';
import type { TrackingArtifactKind, TrackingByokProviderId, TrackingCliProviderId, TrackingExecutionMode, TrackingFeedbackProviderId, TrackingFidelity, TrackingFileSizeBucket, TrackingFileType, TrackingNewProjectTab, TrackingProjectKind } from './shared-enums.js';
import type { TrackingArtifactKind, TrackingByokProviderId, TrackingCliProviderId, TrackingExecutionMode, TrackingFeedbackProviderId, TrackingFidelity, TrackingFileSizeBucket, TrackingFileType, TrackingHarness, TrackingNewProjectTab, TrackingProjectKind } from './shared-enums.js';
import type { TrackingSessionMode, TrackingSettingsArea } from './ui-click.js';
// ---- Enum mapping helpers (code ↔ CSV wire format) -----------------------
@@ -338,11 +338,16 @@ export function settingsSectionToTracking(
return 'memory';
case 'privacy':
return 'privacy';
case 'labs':
return 'labs';
case 'notifications':
return 'notifications';
case 'externalMcp':
return 'external_mcp';
default:
// Unmapped sections fall back to the execution area for dashboard
// continuity. Anything user-reachable belongs in a case above:
// landing here silently inflates the execution-mode funnel.
return 'configure_execution_mode';
}
}
@@ -528,3 +533,27 @@ export function normalizeCustomReason(
): string {
return (text ?? '').trim();
}
/**
* Project a run's rollout decision onto the two analytics fields.
*
* Structural input on purpose: this file stays free of the strategy contract,
* and a test can hand it a literal. A run with no decision at all (every run
* before the strategy existed, and every run on an install that never opted in)
* reports nothing rather than guessing `ordinary` — absent and "took the
* ordinary route" are different facts and the dashboard should be able to tell
* them apart.
*/
export function harnessAnalyticsFromRolloutDecision(
decision: { effectiveMode?: string; primaryReasonCode?: string } | null | undefined,
): { harness?: TrackingHarness; harness_fallback_reason?: string } {
if (!decision || typeof decision.effectiveMode !== 'string') return {};
if (decision.effectiveMode === 'active') return { harness: 'od_next' };
return {
harness: 'ordinary',
...(typeof decision.primaryReasonCode === 'string' && decision.primaryReasonCode
? { harness_fallback_reason: decision.primaryReasonCode }
: {}),
};
}
@@ -15,7 +15,7 @@ import type { ArtifactOriginEntrySurface, ArtifactOriginStatus } from '../../api
import type { AgentDiagnosticReason, AgentDiagnosticSeverity } from '../../api/registry.js';
import type { TrackingDesignSystemEditSurface, TrackingDesignSystemKind, TrackingDesignSystemLengthBucket, TrackingDesignSystemOrigin, TrackingDesignSystemRunEntryFrom } from './design-systems.js';
import type { TrackingSettingsPage } from './event-names.js';
import type { TrackingAmrOpenCodeErrorPhase, TrackingAmrOpenCodeLastEventType, TrackingAmrOpenCodeLastToolKind, TrackingAmrOpenCodeLastToolStatus, TrackingArtifactKind, TrackingArtifactWriteSource, TrackingArtifactWriteStatus, TrackingByokPreflightBlockReason, TrackingByokProviderId, TrackingCliProviderId, TrackingDesignSystemSource, TrackingExecutionMode, TrackingExportFormat, TrackingExportResult, TrackingFeedbackAction, TrackingFeedbackProviderId, TrackingFeedbackRating, TrackingFeedbackRatingWithNone, TrackingFeedbackReasonCode, TrackingFidelity, TrackingFileSizeBucket, TrackingFileType, TrackingFirstModelEventType, TrackingLangfuseDeliveryStatus, TrackingLangfuseDropReason, TrackingLangfuseReportResult, TrackingLangfuseReportSkipReason, TrackingProjectKind, TrackingProjectSource, TrackingPublishErrorCode, TrackingResult, TrackingRunCancelOrigin, TrackingRunCloseReason, TrackingRunDiagnosticSource, TrackingRunFailureCategory, TrackingRunFailureDetail, TrackingRunFailureStage, TrackingRunFailureUserAction, TrackingRunLifecyclePhase, TrackingRunPhaseTimingStatus, TrackingRunResult, TrackingRunRetryFinalResult, TrackingRunRetryStrategy, TrackingRunRetrySuppressedReason, TrackingRunTerminalTrigger, TrackingStderrLineCountBucket, TrackingTestResult, TrackingTokenCountSource } from './shared-enums.js';
import type { TrackingAmrOpenCodeErrorPhase, TrackingAmrOpenCodeLastEventType, TrackingAmrOpenCodeLastToolKind, TrackingAmrOpenCodeLastToolStatus, TrackingArtifactKind, TrackingArtifactWriteSource, TrackingArtifactWriteStatus, TrackingByokPreflightBlockReason, TrackingByokProviderId, TrackingCliProviderId, TrackingDesignSystemSource, TrackingExecutionMode, TrackingExportFormat, TrackingExportResult, TrackingFeedbackAction, TrackingFeedbackProviderId, TrackingFeedbackRating, TrackingFeedbackRatingWithNone, TrackingFeedbackReasonCode, TrackingFidelity, TrackingFileSizeBucket, TrackingFileType, TrackingFirstModelEventType, TrackingHarness, TrackingLabsItemId, TrackingLabsOptOutReason, TrackingLabsSystemReason, TrackingLabsToggleSource, TrackingLangfuseDeliveryStatus, TrackingLangfuseDropReason, TrackingLangfuseReportResult, TrackingLangfuseReportSkipReason, TrackingProjectKind, TrackingProjectSource, TrackingPublishErrorCode, TrackingResult, TrackingRunCancelOrigin, TrackingRunCloseReason, TrackingRunDiagnosticSource, TrackingRunFailureCategory, TrackingRunFailureDetail, TrackingRunFailureStage, TrackingRunFailureUserAction, TrackingRunLifecyclePhase, TrackingRunPhaseTimingStatus, TrackingRunResult, TrackingRunRetryFinalResult, TrackingRunRetryStrategy, TrackingRunRetrySuppressedReason, TrackingRunTerminalTrigger, TrackingStderrLineCountBucket, TrackingTestResult, TrackingTokenCountSource } from './shared-enums.js';
import type { ConversationForkAnalyticsContext, TrackingFileVersionSource, TrackingPluginImportSource, TrackingSessionMode, TrackingSettingsArea } from './ui-click.js';
// ---- Result events -------------------------------------------------------
@@ -446,6 +446,25 @@ export interface RunCreatedProps extends RunTaskLineageProps {
attempt_count?: number;
generation_slo_window_ms?: number;
recharge_wait_duration_ms?: number;
/**
* Which harness actually produced this run.
*
* The whole point of the Labs switch is a before/after comparison, and that
* comparison is only possible if every run says which side it is on. Read
* from the run's own rollout decision rather than the live setting, because
* the setting can change while the run is in flight.
*/
harness?: TrackingHarness;
/**
* Why a run took the ordinary route while the user had the switch on.
*
* Answers the question a support thread cannot: "I enabled it and nothing
* changed." The switch can be on and still not apply — the project's task
* type, the selected agent, an explicitly chosen plugin, or an unverified
* runtime capability each route back. Omitted when `harness` is `od_next`,
* and when the user never opted in (there is nothing to explain).
*/
harness_fallback_reason?: string;
}
export interface RunFinishedProps extends Omit<RunCreatedProps, 'area'> {
@@ -1037,6 +1056,32 @@ export interface AssistantFeedbackReasonSubmitProps
action: 'submit_feedback_reason';
}
/**
* One Labs experiment switch moved.
*
* Generic on purpose: the experiment is a property (`item_id`), not part of the
* event name, so a second experiment reuses this event and every existing
* PostHog query keeps working.
*
* Direction and reason ride on the same event because the dashboard reads them
* together — opt-out rate is `to: 'off'` split by `reason`. The reason panel
* appears *after* the switch moves, so an opt-out emits twice: once
* immediately without a reason, once more if the user gives one. Count
* opt-outs from the first, read reasons from the second.
*/
export interface LabsItemToggledProps {
item_id: TrackingLabsItemId;
to: 'on' | 'off';
source: TrackingLabsToggleSource;
/** Only when `source` is `system`. */
system_reason?: TrackingLabsSystemReason;
/** Only on the follow-up emit after an opt-out, once the user answers. */
reason?: TrackingLabsOptOutReason[];
has_custom_reason?: boolean;
/** Raw free text from the "other" field. Empty when unused. */
custom_reason?: string;
}
// SETTINGS view + result events (page=settings)
export interface SettingsViewProps {
page_name: TrackingSettingsPage;
@@ -564,3 +564,44 @@ export type TrackingFileSizeBucket =
| '1_10mb'
| '10_100mb'
| '100mb_plus';
/**
* Which agent harness actually produced a run.
*
* Deliberately a value, not an event-name suffix: a third harness is one more
* member here and every existing query keeps working. `ordinary` means the run
* took the pre-existing strategy route, whatever the user's Labs switch said —
* see `harness_fallback_reason` for why.
*/
export type TrackingHarness = 'od_next' | 'ordinary';
/**
* A Labs experiment. Carried as a property so the toggle event stays generic:
* a second experiment adds one member here and reuses the same event.
*/
export type TrackingLabsItemId = 'design_harness';
/** Who moved a Labs switch. `system` is not a user action — see `TrackingLabsSystemReason`. */
export type TrackingLabsToggleSource = 'settings' | 'cli' | 'system';
/**
* Why the system moved a Labs switch on the user's behalf. Kept separate from
* the opt-out reasons so "the user turned this off" and "we turned it off for
* them" can never be summed together by accident.
*/
export type TrackingLabsSystemReason = 'env_override' | 'latched' | 'restored';
/**
* Why a user turned a Labs experiment off.
*
* `skipped` is not a reason — it is the absence of one, recorded so the share
* of people who declined to answer is visible instead of missing. A timeout and
* an explicit "skip" record the same value on purpose.
*/
export type TrackingLabsOptOutReason =
| 'worse_output'
| 'too_slow'
| 'not_what_i_wanted'
| 'other'
| 'skipped';
@@ -1515,6 +1515,7 @@ export type TrackingSettingsArea =
| 'design_systems'
| 'project_locations'
| 'privacy'
| 'labs'
| 'about';
export interface SettingsSidebarClickProps {
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { harnessAnalyticsFromRolloutDecision } from '../src/analytics/events.js';
describe('harnessAnalyticsFromRolloutDecision', () => {
it('reports od_next with no fallback reason when the strategy ran', () => {
expect(
harnessAnalyticsFromRolloutDecision({ effectiveMode: 'active', primaryReasonCode: 'od_next_rollout_eligible' }),
).toEqual({ harness: 'od_next' });
});
it('carries why a run fell back so "I turned it on and nothing changed" is answerable', () => {
expect(
harnessAnalyticsFromRolloutDecision({
effectiveMode: 'observe',
primaryReasonCode: 'od_next_rollout_agent_ineligible',
}),
).toEqual({ harness: 'ordinary', harness_fallback_reason: 'od_next_rollout_agent_ineligible' });
});
it('treats off the same as observe — neither produced the new harness', () => {
expect(
harnessAnalyticsFromRolloutDecision({ effectiveMode: 'off', primaryReasonCode: 'od_next_rollout_off' }),
).toEqual({ harness: 'ordinary', harness_fallback_reason: 'od_next_rollout_off' });
});
it('stays silent when there is no decision at all', () => {
// Absent and "took the ordinary route" are different facts: every run from
// before the strategy existed would otherwise be counted as a control-group
// sample it never was.
expect(harnessAnalyticsFromRolloutDecision(null)).toEqual({});
expect(harnessAnalyticsFromRolloutDecision(undefined)).toEqual({});
expect(harnessAnalyticsFromRolloutDecision({})).toEqual({});
});
it('omits an empty reason rather than emitting a blank string', () => {
expect(harnessAnalyticsFromRolloutDecision({ effectiveMode: 'off', primaryReasonCode: '' })).toEqual({
harness: 'ordinary',
});
});
});
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { settingsSectionToTracking } from '../src/analytics/events.js';
describe('settingsSectionToTracking', () => {
it('gives the Labs settings page an area of its own', () => {
// The fallback is `configure_execution_mode`, so a section that reaches it
// is not merely unlabelled — it is counted as an execution-mode view and
// inflates that funnel. Labs is user-reachable from the sidebar, so it
// needs its own area.
expect(settingsSectionToTracking('labs')).toBe('labs');
});
it('keeps each mapped sidebar section distinct from the execution fallback', () => {
const sections = [
'instructions',
'memory',
'media',
'privacy',
'about',
'labs',
] as const;
const areas = sections.map((section) => settingsSectionToTracking(section));
expect(areas).not.toContain('configure_execution_mode');
expect(new Set(areas).size).toBe(sections.length);
});
it('still resolves an unknown section rather than throwing', () => {
expect(settingsSectionToTracking('not-a-section')).toBe('configure_execution_mode');
});
});