| author | |
| committer | |
| log | 0e1570bde5b64458449a45989f1b56d6fd651883 |
| tree | 15b40f93cec5f336f577f2b50fae67c79ca4e353 |
| parent | c37fd136edf546fd58daeac146548436c1fa38c2 |
| signature | Signed by PGP key B5690EEEBB952194 |
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() { |
| 408 | 408 | |
| 409 | 409 | return ( |
| 410 | 410 | <SettingsRow |
| 411 | serverScoped | |
| 411 | 412 | {...searchableSetting("agent-browser-access")} |
| 412 | 413 | 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." |
| 413 | 414 | status={ |
| ... | ... | @@ -516,8 +517,9 @@ export function IntegrationsSettingsPanel() { |
| 516 | 517 | return ( |
| 517 | 518 | <SettingsPageContainer> |
| 518 | 519 | <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. */} | |
| 521 | 523 | <AgentBrowserAccessSetting /> |
| 522 | 524 | {previewDefaultsDisabled ? ( |
| 523 | 525 | <DesktopOnlyBrowserDefaults>{previewDefaults}</DesktopOnlyBrowserDefaults> |
apps/web/src/components/settings/ProjectSettingsPanel.tsx+26-11| ... | ... | @@ -39,6 +39,7 @@ import { useComposerDraftStore } from "../../composerDraftStore"; |
| 39 | 39 | import { isElectron } from "../../env"; |
| 40 | 40 | import { |
| 41 | 41 | useClientSettings, |
| 42 | useEnvironmentSettings, | |
| 42 | 43 | useUpdateClientSettings, |
| 43 | 44 | usePrimarySettings, |
| 44 | 45 | } from "../../hooks/useSettings"; |
| ... | ... | @@ -69,7 +70,7 @@ import { |
| 69 | 70 | import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; |
| 70 | 71 | import { useProjects, useThreadShells } from "../../state/entities"; |
| 71 | 72 | import { projectEnvironment } from "../../state/projects"; |
| 72 | import { primaryServerProvidersAtom, serverEnvironment } from "../../state/server"; | |
| 73 | import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; | |
| 73 | 74 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 74 | 75 | import { ProviderModelPicker } from "../chat/ProviderModelPicker"; |
| 75 | 76 | import { TraitsPicker } from "../chat/TraitsPicker"; |
| ... | ... | @@ -292,10 +293,20 @@ export function ProjectSettingsPanel({ projectKey }: { projectKey: string }) { |
| 292 | 293 | function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 293 | 294 | const navigate = useNavigate(); |
| 294 | 295 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 296 | const representative = | |
| 297 | group.memberProjects.find( | |
| 298 | (member) => member.environmentId === group.environmentId && member.id === group.id, | |
| 299 | ) ?? group.memberProjects[0]!; | |
| 295 | 300 | 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; | |
| 296 | 308 | const updateClientSettings = useUpdateClientSettings(); |
| 297 | 309 | const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); |
| 298 | const serverProviders = useAtomValue(primaryServerProvidersAtom); | |
| 299 | 310 | const threads = useThreadShells(); |
| 300 | 311 | const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); |
| 301 | 312 | const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); |
| ... | ... | @@ -321,10 +332,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 321 | 332 | }, |
| 322 | 333 | }); |
| 323 | 334 | |
| 324 | const representative = | |
| 325 | group.memberProjects.find( | |
| 326 | (member) => member.environmentId === group.environmentId && member.id === group.id, | |
| 327 | ) ?? group.memberProjects[0]!; | |
| 328 | 335 | const faviconPath = representative.faviconPath ?? null; |
| 329 | 336 | const pickProjectFavicon = |
| 330 | 337 | typeof window !== "undefined" && |
| ... | ... | @@ -422,14 +429,22 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 422 | 429 | const instanceEntries = useMemo( |
| 423 | 430 | () => |
| 424 | 431 | sortProviderInstanceEntries( |
| 425 | applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), | |
| 432 | applyProviderInstanceSettings( | |
| 433 | deriveProviderInstanceEntries(serverProviders), | |
| 434 | projectSettings, | |
| 435 | ), | |
| 426 | 436 | ), |
| 427 | [serverProviders, settings], | |
| 437 | [serverProviders, projectSettings], | |
| 428 | 438 | ); |
| 429 | 439 | const modelOptionsByInstance = useMemo( |
| 430 | 440 | () => |
| 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], | |
| 433 | 448 | ); |
| 434 | 449 | const activeEntry = instanceEntries.find((entry) => entry.instanceId === resolvedInstanceId); |
| 435 | 450 | const setDefaultModel = useCallback( |
| ... | ... | @@ -864,7 +879,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { |
| 864 | 879 | onPromptChange={() => {}} |
| 865 | 880 | modelOptions={resolvedSelection.options ?? []} |
| 866 | 881 | allowPromptInjectedEffort={false} |
| 867 | planModeEnabled={settings.planModeEnabled} | |
| 882 | planModeEnabled={projectSettings.planModeEnabled} | |
| 868 | 883 | triggerVariant="outline" |
| 869 | 884 | triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" |
| 870 | 885 | onModelOptionsChange={(nextOptions) => { |
apps/web/src/components/settings/SettingsPanels.tsx+10| ... | ... | @@ -1827,6 +1827,7 @@ function LegacyFeaturesSection() { |
| 1827 | 1827 | } |
| 1828 | 1828 | /> |
| 1829 | 1829 | <SettingsRow |
| 1830 | serverScoped | |
| 1830 | 1831 | {...searchableSetting("legacy-token-streaming")} |
| 1831 | 1832 | 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." |
| 1832 | 1833 | control={ |
| ... | ... | @@ -1970,6 +1971,7 @@ export function GeneralSettingsPanel() { |
| 1970 | 1971 | {supportsAutoSettlement ? ( |
| 1971 | 1972 | <> |
| 1972 | 1973 | <SettingsRow |
| 1974 | serverScoped | |
| 1973 | 1975 | {...searchableSetting("auto-settle-merged-threads")} |
| 1974 | 1976 | description="Settle a thread when its pull request merges. Closed pull requests still settle automatically." |
| 1975 | 1977 | resetAction={ |
| ... | ... | @@ -1997,6 +1999,7 @@ export function GeneralSettingsPanel() { |
| 1997 | 1999 | /> |
| 1998 | 2000 | |
| 1999 | 2001 | <SettingsRow |
| 2002 | serverScoped | |
| 2000 | 2003 | {...searchableSetting("auto-settle-inactive-threads")} |
| 2001 | 2004 | description="Sidebar threads with no activity for this long settle automatically." |
| 2002 | 2005 | resetAction={ |
| ... | ... | @@ -2027,6 +2030,7 @@ export function GeneralSettingsPanel() { |
| 2027 | 2030 | /> |
| 2028 | 2031 | {settings.sidebarAutoSettleAfterDays !== null ? ( |
| 2029 | 2032 | <SettingsRow |
| 2033 | serverScoped | |
| 2030 | 2034 | title={searchableSetting("days-before-auto-settle").title} |
| 2031 | 2035 | description="Any new activity un-settles a thread automatically." |
| 2032 | 2036 | control={ |
| ... | ... | @@ -2135,6 +2139,7 @@ export function GeneralSettingsPanel() { |
| 2135 | 2139 | /> |
| 2136 | 2140 | |
| 2137 | 2141 | <SettingsRow |
| 2142 | serverScoped | |
| 2138 | 2143 | {...searchableSetting("provider-update-checks")} |
| 2139 | 2144 | description="Check installed provider CLIs for newer available versions." |
| 2140 | 2145 | resetAction={ |
| ... | ... | @@ -2162,6 +2167,7 @@ export function GeneralSettingsPanel() { |
| 2162 | 2167 | /> |
| 2163 | 2168 | |
| 2164 | 2169 | <SettingsRow |
| 2170 | serverScoped | |
| 2165 | 2171 | id={searchableSetting("background-activity").id} |
| 2166 | 2172 | title={ |
| 2167 | 2173 | <span className="inline-flex items-center gap-1.5"> |
| ... | ... | @@ -2245,6 +2251,7 @@ export function GeneralSettingsPanel() { |
| 2245 | 2251 | /> |
| 2246 | 2252 | |
| 2247 | 2253 | <SettingsRow |
| 2254 | serverScoped | |
| 2248 | 2255 | {...searchableSetting("new-threads")} |
| 2249 | 2256 | description="Pick the default workspace mode for newly created draft threads." |
| 2250 | 2257 | resetAction={ |
| ... | ... | @@ -2291,6 +2298,7 @@ export function GeneralSettingsPanel() { |
| 2291 | 2298 | |
| 2292 | 2299 | {settings.defaultThreadEnvMode === "worktree" ? ( |
| 2293 | 2300 | <SettingsRow |
| 2301 | serverScoped | |
| 2294 | 2302 | className="bg-muted/20 sm:pl-9" |
| 2295 | 2303 | title={searchableSetting("start-from-origin").title} |
| 2296 | 2304 | description="Creates the worktree from the latest matching branch on origin instead of your local branch." |
| ... | ... | @@ -2321,6 +2329,7 @@ export function GeneralSettingsPanel() { |
| 2321 | 2329 | ) : null} |
| 2322 | 2330 | |
| 2323 | 2331 | <SettingsRow |
| 2332 | serverScoped | |
| 2324 | 2333 | {...searchableSetting("add-project-starts-in")} |
| 2325 | 2334 | description='Leave empty to use "~/" when the Add Project browser opens.' |
| 2326 | 2335 | resetAction={ |
| ... | ... | @@ -2465,6 +2474,7 @@ export function GeneralSettingsPanel() { |
| 2465 | 2474 | ) : null} |
| 2466 | 2475 | |
| 2467 | 2476 | <SettingsRow |
| 2477 | serverScoped | |
| 2468 | 2478 | {...searchableSetting("text-generation-model")} |
| 2469 | 2479 | 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." |
| 2470 | 2480 | resetAction={ |
apps/web/src/components/settings/SourceControlSettings.tsx+3-1| ... | ... | @@ -593,7 +593,9 @@ export function SourceControlSettingsPanel() { |
| 593 | 593 | /> |
| 594 | 594 | )} |
| 595 | 595 | |
| 596 | {isPrimaryEnvironment ? <SourceControlWritingSettingsSection /> : null} | |
| 596 | {/* Its rows are serverScoped: without a primary they render inert with | |
| 597 | an explanation, which beats disappearing. */} | |
| 598 | <SourceControlWritingSettingsSection /> | |
| 597 | 599 | </SettingsPageContainer> |
| 598 | 600 | ); |
| 599 | 601 | } |
apps/web/src/components/settings/SourceControlWritingSettings.tsx+3| ... | ... | @@ -74,6 +74,7 @@ export function SourceControlWritingSettingsSection() { |
| 74 | 74 | return ( |
| 75 | 75 | <SettingsSection title="Text generation"> |
| 76 | 76 | <SettingsRow |
| 77 | serverScoped | |
| 77 | 78 | {...searchableSetting("source-control-writing-style")} |
| 78 | 79 | description={MODE_OPTIONS[style.mode].description} |
| 79 | 80 | resetAction={ |
| ... | ... | @@ -138,6 +139,7 @@ export function SourceControlWritingSettingsSection() { |
| 138 | 139 | </SettingsRow> |
| 139 | 140 | |
| 140 | 141 | <SettingsRow |
| 142 | serverScoped | |
| 141 | 143 | {...searchableSetting("follow-change-request-templates")} |
| 142 | 144 | description="Structures change request descriptions using the current repository's template when one is available." |
| 143 | 145 | resetAction={ |
| ... | ... | @@ -170,6 +172,7 @@ export function SourceControlWritingSettingsSection() { |
| 170 | 172 | /> |
| 171 | 173 | |
| 172 | 174 | <SettingsRow |
| 175 | serverScoped | |
| 173 | 176 | {...searchableSetting("source-control-writer-model")} |
| 174 | 177 | description="Optional model override for change descriptions, change request titles and descriptions, and branch or bookmark names. Off uses the global text generation model." |
| 175 | 178 | control={ |
apps/web/src/components/settings/settingsLayout.tsx+48-4| ... | ... | @@ -11,6 +11,10 @@ import { |
| 11 | 11 | useState, |
| 12 | 12 | } from "react"; |
| 13 | 13 | |
| 14 | import { | |
| 15 | PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE, | |
| 16 | usePrimarySettingsAvailable, | |
| 17 | } from "../../hooks/useSettings"; | |
| 14 | 18 | import { cn } from "../../lib/utils"; |
| 15 | 19 | import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePageContainer"; |
| 16 | 20 | import { Button } from "../ui/button"; |
| ... | ... | @@ -159,12 +163,19 @@ export function SettingsSection({ |
| 159 | 163 | ); |
| 160 | 164 | } |
| 161 | 165 | |
| 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 | */ | |
| 162 | 172 | export function SettingsRow({ |
| 163 | 173 | title, |
| 164 | 174 | description, |
| 165 | 175 | status, |
| 166 | 176 | resetAction, |
| 167 | 177 | control, |
| 178 | serverScoped = false, | |
| 168 | 179 | children, |
| 169 | 180 | className, |
| 170 | 181 | ...rowProps |
| ... | ... | @@ -174,9 +185,36 @@ export function SettingsRow({ |
| 174 | 185 | status?: ReactNode; |
| 175 | 186 | resetAction?: ReactNode; |
| 176 | 187 | control?: ReactNode; |
| 188 | serverScoped?: boolean; | |
| 177 | 189 | children?: ReactNode; |
| 178 | 190 | }) { |
| 179 | 191 | 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 | ); | |
| 180 | 218 | |
| 181 | 219 | return ( |
| 182 | 220 | <div |
| ... | ... | @@ -190,7 +228,7 @@ export function SettingsRow({ |
| 190 | 228 | <div className="flex min-h-5 items-center gap-1.5"> |
| 191 | 229 | <h3 className="text-sm font-medium tracking-[-0.005em] text-foreground">{title}</h3> |
| 192 | 230 | <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center"> |
| 193 | {resetAction} | |
| 231 | {renderedReset} | |
| 194 | 232 | </span> |
| 195 | 233 | </div> |
| 196 | 234 | {description ? ( |
| ... | ... | @@ -200,13 +238,19 @@ export function SettingsRow({ |
| 200 | 238 | ) : null} |
| 201 | 239 | {status ? <div className="pt-0.5 text-xs text-muted-foreground">{status}</div> : null} |
| 202 | 240 | </div> |
| 203 | {control ? ( | |
| 241 | {renderedControl ? ( | |
| 204 | 242 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> |
| 205 | {control} | |
| 243 | {renderedControl} | |
| 206 | 244 | </div> |
| 207 | 245 | ) : null} |
| 208 | 246 | </div> |
| 209 | {children} | |
| 247 | {unavailable && children ? ( | |
| 248 | <div inert className="opacity-50"> | |
| 249 | {children} | |
| 250 | </div> | |
| 251 | ) : ( | |
| 252 | children | |
| 253 | )} | |
| 210 | 254 | </div> |
| 211 | 255 | ); |
| 212 | 256 | } |
apps/web/src/hooks/useSettings.ts+23| ... | ... | @@ -39,6 +39,8 @@ import { |
| 39 | 39 | themeAllowsSidebarArtwork, |
| 40 | 40 | } from "~/themePalette"; |
| 41 | 41 | import * as Struct from "effect/Struct"; |
| 42 | import { toastManager } from "~/components/ui/toast"; | |
| 43 | import { isHostedStaticApp } from "~/hostedPairing"; | |
| 42 | 44 | import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; |
| 43 | 45 | import { |
| 44 | 46 | type EnvironmentPresentation, |
| ... | ... | @@ -316,6 +318,20 @@ export function usePrimarySettings<T = UnifiedSettings>( |
| 316 | 318 | return useMergedSettings(useAtomValue(primaryServerSettingsAtom), selector); |
| 317 | 319 | } |
| 318 | 320 | |
| 321 | export 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 | */ | |
| 330 | export function usePrimarySettingsAvailable(): boolean { | |
| 331 | const primaryEnvironment = usePrimaryEnvironment(); | |
| 332 | return primaryEnvironment !== null || !isHostedStaticApp(); | |
| 333 | } | |
| 334 | ||
| 319 | 335 | /** |
| 320 | 336 | * Whether an environment can hold every shared key right now. Gated on the |
| 321 | 337 | * auto-settlement capability because it is the newest of the shared keys: a |
| ... | ... | @@ -366,6 +382,13 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { |
| 366 | 382 | environmentId, |
| 367 | 383 | input: { patch: localPatch }, |
| 368 | 384 | }); |
| 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 | }); | |
| 369 | 392 | } |
| 370 | 393 | if (Object.keys(sharedPatch).length > 0) { |
| 371 | 394 | const targets = new Set(connectedEnvironmentIds); |
apps/web/src/hostedPairing.ts+7-2| ... | ... | @@ -31,7 +31,7 @@ function originFromUrl(value: string): string | null { |
| 31 | 31 | } |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | export function isHostedStaticApp(url: URL = new URL(window.location.href)): boolean { | |
| 34 | export function isHostedStaticApp(url?: URL): boolean { | |
| 35 | 35 | if (configuredBackendUrl()) { |
| 36 | 36 | return false; |
| 37 | 37 | } |
| ... | ... | @@ -40,8 +40,13 @@ export function isHostedStaticApp(url: URL = new URL(window.location.href)): boo |
| 40 | 40 | return true; |
| 41 | 41 | } |
| 42 | 42 | |
| 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 | ||
| 43 | 48 | const hostedOrigin = originFromUrl(configuredHostedAppUrl()); |
| 44 | return hostedOrigin !== null && url.origin === hostedOrigin; | |
| 49 | return hostedOrigin !== null && (url ?? new URL(window.location.href)).origin === hostedOrigin; | |
| 45 | 50 | } |
| 46 | 51 | |
| 47 | 52 | export function readHostedPairingRequest(url: URL = new URL(window.location.href)) { |