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