Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1129,6 +1129,112 @@ function walkthroughConnectAccount() {
openApiKeysModalBody();
}

// --- Composer generation settings (STU-385) ---
//
// The composer's gear opens this dialog, which presents the existing model
// selector and supported generation options. #code-generator-model still lives
// here and remains the single element the generation pipeline reads, so
// selections made in this dialog reach the actual request unchanged — no
// key-storage or transport change. Provider-key management is a shortcut into
// the canonical account connection editor (openApiKeysModal, STU-384), never a
// second independent editor.
//
// Closing this dialog from normal use must never reopen the walkthrough: the
// featured bug the milestone tracks is an ordinary settings close resuming
// onboarding. openComposerSettings runs with the standalone flag set (NOT the
// walkthrough-originating flag), so closeComposerSettings leaves the walkthrough
// untouched; only the walkthrough's own connect step flips that flag.

let composerSettingsChangeWired = false;

/**
* Keep the dialog's model mirror in step with the canonical #code-generator-model
* selector that still lives on the page. The mirror copies the canonical value,
* reuses the canonical availability labels (free tier marks PRO models "(PRO)"),
* and mirrors the canonical free-tier upgrade notice — so the dialog never
* drifts from the page or the pipeline. #code-generator-model remains the single
* element the generation request reads.
*/
function syncComposerSettingsModel() {
const canonical = document.getElementById("code-generator-model");
const mirror = document.getElementById("composer-settings-model");
if (!canonical || !mirror) return;
mirror.value = canonical.value;
const canonicalOptions = Array.from(canonical.options);
Array.from(mirror.options).forEach((mo, i) => {
const co = canonicalOptions[i];
if (co) mo.textContent = co.textContent;
});
Comment on lines +1162 to +1167

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Plan resolution leaves model mirror stale

When plan resolution finishes with settings open, composer-settings-model retains its earlier selection and labels. A free-tier reset changes the canonical model to Gemini, while the dialog still displays Pro.

Learn more

The dialog mirror synchronizes only when the dialog opens or its own model control changes. Startup intentionally leaves composer controls interactive while authentication and subscription requests run. Later, updateModelSelectorGating can relabel options and reset a Pro selection to FREE_MODEL, but it never synchronizes the open mirror.

Example: A user opens settings before startup plan resolution, selects Claude, and then resolves as free-tier. The canonical selector resets to Gemini, but the open dialog still displays Claude without the current free-tier labels. The next generation uses Gemini despite the visible selection.

Recommended fix: Synchronize the composer mirror whenever updateModelSelectorGating changes the canonical selector or its labels. Also refresh the dialog capability row after a value reset.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

const mirrorNotice = document.getElementById("composer-settings-free-notice");
if (mirrorNotice) {
if (document.getElementById("model-selector-free-notice")) {
mirrorNotice.hidden = false;
mirrorNotice.innerHTML =
`Free plan — Gemini only. ` +
`<button type="button" onclick="openPricingModal()" style="color:#3b82f6;background:none;border:none;cursor:pointer;font:inherit;padding:0;text-decoration:underline;">Upgrade for all models</button>`;
} else {
mirrorNotice.hidden = true;
}
}
}

/**
* Reflect the selected model's supported generation options into the dialog and
* keep the composer's image-attach affordance in step (vision model → attach
* allowed; non-vision model → attach hidden and any attached images dropped).
*/
function renderComposerSettingsOptions() {
const select = document.getElementById("code-generator-model");
const capability = document.getElementById("composer-image-capability");
const note = document.getElementById("composer-image-capability-note");
if (!select) return;
const label = getModelLabel(select.value);
const supports = modelSupportsImages(select.value);
if (capability) {
capability.textContent = supports ? "Supported" : "Not supported";
}
if (note) {
note.textContent = supports
? `${label} accepts reference images you attach to the prompt.`
: `Images can't be attached with ${label}. Attached images are removed when you switch to it.`;
}
updatePromptImageAvailability();
}

function openComposerSettings() {
// A standalone settings visit, not onboarding: closing it must not touch the
// walkthrough (same contract as openApiKeysModal).
walkthroughSettingsActive = false;
syncComposerSettingsModel();
renderComposerSettingsOptions();
Comment on lines +1208 to +1209

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Early Pro selection bypasses gating

Before subscription reconciliation, openComposerSettings exposes ungated Pro choices. A free user's selection skips pricing, then generation silently falls back to Gemini.

Learn more

The page becomes interactive while the asynchronous auth and subscription startup still runs. The canonical Pro-gating listener is installed only when updateModelSelectorGating runs after reconciliation. Opening this dialog earlier copies the raw canonical options, and its change handler can select a Pro model before that listener exists. The pipeline later waits for reconciliation, then getEffectiveModel replaces that selection with the free model without opening pricing.

Example: A returning free user clicks the gear during a slow subscription request and selects Claude Opus 5. The dialog retains Claude and no pricing modal appears, but the generator request uses Gemini.

Recommended fix: Keep the model control disabled or in a loading state until sessionReadiness settles. Then run updateModelSelectorGating(), synchronize the mirror, and enable selection.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

if (!composerSettingsChangeWired) {
const mirror = document.getElementById("composer-settings-model");
if (mirror) {
mirror.addEventListener("change", () => {
// Forward the dialog selection to the canonical selector and let its
// existing handler apply gating (PRO-on-free resets to FREE_MODEL and
// opens pricing), model info and image-capability updates. Then resync
// the mirror to reflect any reset and refresh the dialog's rows.
const canonical = document.getElementById("code-generator-model");
canonical.value = mirror.value;
canonical.dispatchEvent(new Event("change"));
syncComposerSettingsModel();
renderComposerSettingsOptions();
});
}
composerSettingsChangeWired = true;
}
const trigger = document.querySelector(
'.composer [aria-label="Generation settings"]',
);
openModal("composer-settings-modal", { trigger });
}

function closeComposerSettings(event) {
if (event && event.target !== event.currentTarget) return;
closeModal("composer-settings-modal");
}

function closeApiKeysModal(event) {
if (event && event.target !== event.currentTarget) return;
const modal = document.getElementById("api-keys-modal");
Expand Down Expand Up @@ -7055,6 +7161,8 @@ window.copyCode = copyCode;
window.retryWithDifferentModel = retryWithDifferentModel;
window.openApiKeysModal = openApiKeysModal;
window.closeApiKeysModal = closeApiKeysModal;
window.openComposerSettings = openComposerSettings;
window.closeComposerSettings = closeComposerSettings;
window.walkthroughConnectAccount = walkthroughConnectAccount;
window.closeWalkthroughModal = closeWalkthroughModal;
window.openWalkthroughModal = openWalkthroughModal;
Expand Down
18 changes: 16 additions & 2 deletions e2e/stu376-composer.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -274,12 +274,26 @@ test.describe("submission wiring at desktop viewport", () => {
);
});

test("the settings control opens the actual generation settings", async ({ page }) => {
test("the settings control opens the generation settings dialog", async ({ page }) => {
await page.goto("/");

await page.click('.tools [aria-label="Generation settings"]');
// STU-385: the gear opens the composer generation-settings dialog (model +
// generation options), not the API-keys editor directly.
await expect(page.locator("#composer-settings-modal")).toHaveClass(/open/);
await expect(page.locator("#composer-settings-modal")).toHaveAttribute(
"aria-hidden",
"false",
);

// Provider-key management is a shortcut into the canonical account
// connection editor (STU-384), not a second independent editor.
await page.click("#composer-settings-modal [aria-controls='api-keys-modal']");
await expect(page.locator("#api-keys-modal")).toHaveClass(/open/);
await expect(page.locator("#api-keys-modal")).toHaveAttribute("aria-hidden", "false");
await expect(page.locator("#api-keys-modal")).toHaveAttribute(
"aria-hidden",
"false",
);
});

test("attachments keep the existing size and count validation", async ({ page }) => {
Expand Down
Loading
Loading