authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-01 19:26:07-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 19:26:07-07:00
log0e1570bde5b64458449a45989f1b56d6fd651883
tree15b40f93cec5f336f577f2b50fae67c79ca4e353
parentc37fd136edf546fd58daeac146548436c1fa38c2
signature Signed by PGP key B5690EEEBB952194

fix(web): project default model works on the hosted app (#9142)

Co-authored-by: Claude Code <noreply@anthropic.com>

8 files changed, 124 insertions(+), 20 deletions(-)

apps/web/src/components/settings/IntegrationsSettings.tsx+4-2
......@@ -408,6 +408,7 @@ function AgentBrowserAccessSetting() {
408408
409409 return (
410410 <SettingsRow
411 serverScoped
411412 {...searchableSetting("agent-browser-access")}
412413 description="Let agents open and drive the preview browser. When off, the browser tools and the instructions describing them are withheld from agent sessions. Your own browser panel is unaffected."
413414 status={
......@@ -516,8 +517,9 @@ export function IntegrationsSettingsPanel() {
516517 return (
517518 <SettingsPageContainer>
518519 <SettingsSection id="browser" title="Browser">
519 {/* Server-authoritative, so it stays editable on every client and sits
520 outside the block covering the desktop-only defaults. */}
520 {/* Server-authoritative, so it stays editable on any client anchored to
521 a server; `serverScoped` covers the hosted app, which has none. It
522 sits outside the block covering the desktop-only defaults. */}
521523 <AgentBrowserAccessSetting />
522524 {previewDefaultsDisabled ? (
523525 <DesktopOnlyBrowserDefaults>{previewDefaults}</DesktopOnlyBrowserDefaults>
apps/web/src/components/settings/ProjectSettingsPanel.tsx+26-11
......@@ -39,6 +39,7 @@ import { useComposerDraftStore } from "../../composerDraftStore";
3939import { isElectron } from "../../env";
4040import {
4141 useClientSettings,
42 useEnvironmentSettings,
4243 useUpdateClientSettings,
4344 usePrimarySettings,
4445} from "../../hooks/useSettings";
......@@ -69,7 +70,7 @@ import {
6970import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";
7071import { useProjects, useThreadShells } from "../../state/entities";
7172import { projectEnvironment } from "../../state/projects";
72import { primaryServerProvidersAtom, serverEnvironment } from "../../state/server";
73import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";
7374import { useAtomCommand } from "../../state/use-atom-command";
7475import { ProviderModelPicker } from "../chat/ProviderModelPicker";
7576import { TraitsPicker } from "../chat/TraitsPicker";
......@@ -292,10 +293,20 @@ export function ProjectSettingsPanel({ projectKey }: { projectKey: string }) {
292293function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
293294 const navigate = useNavigate();
294295 const primaryEnvironmentId = usePrimaryEnvironmentId();
296 const representative =
297 group.memberProjects.find(
298 (member) => member.environmentId === group.environmentId && member.id === group.id,
299 ) ?? group.memberProjects[0]!;
295300 const settings = usePrimarySettings();
301 // Provider instances and model options belong to the environment that runs
302 // the project's threads. The hosted app has no primary environment, so
303 // reading them from there would show "No providers available" everywhere.
304 const projectSettings = useEnvironmentSettings(representative.environmentId);
305 const serverProviders =
306 useAtomValue(serverEnvironment.providersValueAtom(representative.environmentId)) ??
307 EMPTY_SERVER_PROVIDERS;
296308 const updateClientSettings = useUpdateClientSettings();
297309 const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
298 const serverProviders = useAtomValue(primaryServerProvidersAtom);
299310 const threads = useThreadShells();
300311 const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false });
301312 const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false });
......@@ -321,10 +332,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
321332 },
322333 });
323334
324 const representative =
325 group.memberProjects.find(
326 (member) => member.environmentId === group.environmentId && member.id === group.id,
327 ) ?? group.memberProjects[0]!;
328335 const faviconPath = representative.faviconPath ?? null;
329336 const pickProjectFavicon =
330337 typeof window !== "undefined" &&
......@@ -422,14 +429,22 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
422429 const instanceEntries = useMemo(
423430 () =>
424431 sortProviderInstanceEntries(
425 applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings),
432 applyProviderInstanceSettings(
433 deriveProviderInstanceEntries(serverProviders),
434 projectSettings,
435 ),
426436 ),
427 [serverProviders, settings],
437 [serverProviders, projectSettings],
428438 );
429439 const modelOptionsByInstance = useMemo(
430440 () =>
431 getCustomModelOptionsByInstance(settings, serverProviders, resolvedInstanceId, resolvedModel),
432 [resolvedInstanceId, resolvedModel, serverProviders, settings],
441 getCustomModelOptionsByInstance(
442 projectSettings,
443 serverProviders,
444 resolvedInstanceId,
445 resolvedModel,
446 ),
447 [resolvedInstanceId, resolvedModel, serverProviders, projectSettings],
433448 );
434449 const activeEntry = instanceEntries.find((entry) => entry.instanceId === resolvedInstanceId);
435450 const setDefaultModel = useCallback(
......@@ -864,7 +879,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
864879 onPromptChange={() => {}}
865880 modelOptions={resolvedSelection.options ?? []}
866881 allowPromptInjectedEffort={false}
867 planModeEnabled={settings.planModeEnabled}
882 planModeEnabled={projectSettings.planModeEnabled}
868883 triggerVariant="outline"
869884 triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground"
870885 onModelOptionsChange={(nextOptions) => {
apps/web/src/components/settings/SettingsPanels.tsx+10
......@@ -1827,6 +1827,7 @@ function LegacyFeaturesSection() {
18271827 }
18281828 />
18291829 <SettingsRow
1830 serverScoped
18301831 {...searchableSetting("legacy-token-streaming")}
18311832 description="Paints assistant output token by token instead of in complete chunks. Not recommended: it is significantly slower, and long responses become harder to follow. Kept only for compatibility with the old behavior."
18321833 control={
......@@ -1970,6 +1971,7 @@ export function GeneralSettingsPanel() {
19701971 {supportsAutoSettlement ? (
19711972 <>
19721973 <SettingsRow
1974 serverScoped
19731975 {...searchableSetting("auto-settle-merged-threads")}
19741976 description="Settle a thread when its pull request merges. Closed pull requests still settle automatically."
19751977 resetAction={
......@@ -1997,6 +1999,7 @@ export function GeneralSettingsPanel() {
19971999 />
19982000
19992001 <SettingsRow
2002 serverScoped
20002003 {...searchableSetting("auto-settle-inactive-threads")}
20012004 description="Sidebar threads with no activity for this long settle automatically."
20022005 resetAction={
......@@ -2027,6 +2030,7 @@ export function GeneralSettingsPanel() {
20272030 />
20282031 {settings.sidebarAutoSettleAfterDays !== null ? (
20292032 <SettingsRow
2033 serverScoped
20302034 title={searchableSetting("days-before-auto-settle").title}
20312035 description="Any new activity un-settles a thread automatically."
20322036 control={
......@@ -2135,6 +2139,7 @@ export function GeneralSettingsPanel() {
21352139 />
21362140
21372141 <SettingsRow
2142 serverScoped
21382143 {...searchableSetting("provider-update-checks")}
21392144 description="Check installed provider CLIs for newer available versions."
21402145 resetAction={
......@@ -2162,6 +2167,7 @@ export function GeneralSettingsPanel() {
21622167 />
21632168
21642169 <SettingsRow
2170 serverScoped
21652171 id={searchableSetting("background-activity").id}
21662172 title={
21672173 <span className="inline-flex items-center gap-1.5">
......@@ -2245,6 +2251,7 @@ export function GeneralSettingsPanel() {
22452251 />
22462252
22472253 <SettingsRow
2254 serverScoped
22482255 {...searchableSetting("new-threads")}
22492256 description="Pick the default workspace mode for newly created draft threads."
22502257 resetAction={
......@@ -2291,6 +2298,7 @@ export function GeneralSettingsPanel() {
22912298
22922299 {settings.defaultThreadEnvMode === "worktree" ? (
22932300 <SettingsRow
2301 serverScoped
22942302 className="bg-muted/20 sm:pl-9"
22952303 title={searchableSetting("start-from-origin").title}
22962304 description="Creates the worktree from the latest matching branch on origin instead of your local branch."
......@@ -2321,6 +2329,7 @@ export function GeneralSettingsPanel() {
23212329 ) : null}
23222330
23232331 <SettingsRow
2332 serverScoped
23242333 {...searchableSetting("add-project-starts-in")}
23252334 description='Leave empty to use "~/" when the Add Project browser opens.'
23262335 resetAction={
......@@ -2465,6 +2474,7 @@ export function GeneralSettingsPanel() {
24652474 ) : null}
24662475
24672476 <SettingsRow
2477 serverScoped
24682478 {...searchableSetting("text-generation-model")}
24692479 description="Default model for generated text like thread titles and source control content. Source control settings can override it with a dedicated source control writer model."
24702480 resetAction={
apps/web/src/components/settings/SourceControlSettings.tsx+3-1
......@@ -593,7 +593,9 @@ export function SourceControlSettingsPanel() {
593593 />
594594 )}
595595
596 {isPrimaryEnvironment ? <SourceControlWritingSettingsSection /> : null}
596 {/* Its rows are serverScoped: without a primary they render inert with
597 an explanation, which beats disappearing. */}
598 <SourceControlWritingSettingsSection />
597599 </SettingsPageContainer>
598600 );
599601}
apps/web/src/components/settings/SourceControlWritingSettings.tsx+3
......@@ -74,6 +74,7 @@ export function SourceControlWritingSettingsSection() {
7474 return (
7575 <SettingsSection title="Text generation">
7676 <SettingsRow
77 serverScoped
7778 {...searchableSetting("source-control-writing-style")}
7879 description={MODE_OPTIONS[style.mode].description}
7980 resetAction={
......@@ -138,6 +139,7 @@ export function SourceControlWritingSettingsSection() {
138139 </SettingsRow>
139140
140141 <SettingsRow
142 serverScoped
141143 {...searchableSetting("follow-change-request-templates")}
142144 description="Structures change request descriptions using the current repository's template when one is available."
143145 resetAction={
......@@ -170,6 +172,7 @@ export function SourceControlWritingSettingsSection() {
170172 />
171173
172174 <SettingsRow
175 serverScoped
173176 {...searchableSetting("source-control-writer-model")}
174177 description="Optional model override for change descriptions, change request titles and descriptions, and branch or bookmark names. Off uses the global text generation model."
175178 control={
apps/web/src/components/settings/settingsLayout.tsx+48-4
......@@ -11,6 +11,10 @@ import {
1111 useState,
1212} from "react";
1313
14import {
15 PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE,
16 usePrimarySettingsAvailable,
17} from "../../hooks/useSettings";
1418import { cn } from "../../lib/utils";
1519import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePageContainer";
1620import { Button } from "../ui/button";
......@@ -159,12 +163,19 @@ export function SettingsSection({
159163 );
160164}
161165
166/**
167 * One setting. `serverScoped` marks rows whose value lives in the primary
168 * environment's settings.json; where there is no primary (the hosted app)
169 * the control goes inert with a tooltip instead of showing an editable
170 * default that would never save.
171 */
162172export function SettingsRow({
163173 title,
164174 description,
165175 status,
166176 resetAction,
167177 control,
178 serverScoped = false,
168179 children,
169180 className,
170181 ...rowProps
......@@ -174,9 +185,36 @@ export function SettingsRow({
174185 status?: ReactNode;
175186 resetAction?: ReactNode;
176187 control?: ReactNode;
188 serverScoped?: boolean;
177189 children?: ReactNode;
178190}) {
179191 const targetRef = useSettingsSearchTarget<HTMLDivElement>(rowProps.id);
192 const primarySettingsAvailable = usePrimarySettingsAvailable();
193 const unavailable = serverScoped && !primarySettingsAvailable;
194 const renderedReset = unavailable ? null : resetAction;
195 const renderedControl =
196 unavailable && control ? (
197 <Tooltip>
198 <TooltipTrigger
199 render={
200 // Focusable so keyboard users can still reach the explanation.
201 <span
202 tabIndex={0}
203 className="flex w-full items-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-auto"
204 />
205 }
206 >
207 <div inert className="flex w-full items-center gap-2 opacity-50 sm:w-auto">
208 {control}
209 </div>
210 </TooltipTrigger>
211 <TooltipPopup side="top" className="max-w-72">
212 {PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE}
213 </TooltipPopup>
214 </Tooltip>
215 ) : (
216 control
217 );
180218
181219 return (
182220 <div
......@@ -190,7 +228,7 @@ export function SettingsRow({
190228 <div className="flex min-h-5 items-center gap-1.5">
191229 <h3 className="text-sm font-medium tracking-[-0.005em] text-foreground">{title}</h3>
192230 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
193 {resetAction}
231 {renderedReset}
194232 </span>
195233 </div>
196234 {description ? (
......@@ -200,13 +238,19 @@ export function SettingsRow({
200238 ) : null}
201239 {status ? <div className="pt-0.5 text-xs text-muted-foreground">{status}</div> : null}
202240 </div>
203 {control ? (
241 {renderedControl ? (
204242 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
205 {control}
243 {renderedControl}
206244 </div>
207245 ) : null}
208246 </div>
209 {children}
247 {unavailable && children ? (
248 <div inert className="opacity-50">
249 {children}
250 </div>
251 ) : (
252 children
253 )}
210254 </div>
211255 );
212256}
apps/web/src/hooks/useSettings.ts+23
......@@ -39,6 +39,8 @@ import {
3939 themeAllowsSidebarArtwork,
4040} from "~/themePalette";
4141import * as Struct from "effect/Struct";
42import { toastManager } from "~/components/ui/toast";
43import { isHostedStaticApp } from "~/hostedPairing";
4244import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";
4345import {
4446 type EnvironmentPresentation,
......@@ -316,6 +318,20 @@ export function usePrimarySettings<T = UnifiedSettings>(
316318 return useMergedSettings(useAtomValue(primaryServerSettingsAtom), selector);
317319}
318320
321export const PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE =
322 "This setting is saved on a server, and the hosted app is not anchored to one. Change it from the desktop app or from the server's own address.";
323
324/**
325 * Whether primary-scoped server settings have a server to live on. The
326 * hosted app connects to every environment as a remote, so it has no primary:
327 * `usePrimarySettings` reads schema defaults there and writes have nowhere
328 * to go. Desktop and server-served web always have one.
329 */
330export function usePrimarySettingsAvailable(): boolean {
331 const primaryEnvironment = usePrimaryEnvironment();
332 return primaryEnvironment !== null || !isHostedStaticApp();
333}
334
319335/**
320336 * Whether an environment can hold every shared key right now. Gated on the
321337 * auto-settlement capability because it is the newest of the shared keys: a
......@@ -366,6 +382,13 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) {
366382 environmentId,
367383 input: { patch: localPatch },
368384 });
385 } else {
386 // Dropping the write silently leaves the control looking saved.
387 toastManager.add({
388 type: "warning",
389 title: "Setting not saved",
390 description: PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE,
391 });
369392 }
370393 if (Object.keys(sharedPatch).length > 0) {
371394 const targets = new Set(connectedEnvironmentIds);
apps/web/src/hostedPairing.ts+7-2
......@@ -31,7 +31,7 @@ function originFromUrl(value: string): string | null {
3131 }
3232}
3333
34export function isHostedStaticApp(url: URL = new URL(window.location.href)): boolean {
34export function isHostedStaticApp(url?: URL): boolean {
3535 if (configuredBackendUrl()) {
3636 return false;
3737 }
......@@ -40,8 +40,13 @@ export function isHostedStaticApp(url: URL = new URL(window.location.href)): boo
4040 return true;
4141 }
4242
43 // No window (tests, static render) means no origin to be hosted at.
44 if (url === undefined && typeof window === "undefined") {
45 return false;
46 }
47
4348 const hostedOrigin = originFromUrl(configuredHostedAppUrl());
44 return hostedOrigin !== null && url.origin === hostedOrigin;
49 return hostedOrigin !== null && (url ?? new URL(window.location.href)).origin === hostedOrigin;
4550}
4651
4752export function readHostedPairingRequest(url: URL = new URL(window.location.href)) {