Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,5 @@ export { AddModelDialog, ModelParametersDialog } from './provider-add-model-dial
export { ProviderEndpointField } from './provider-endpoint-field.js';

export { ConnectionDefaultAction } from './connection-default-action.js';

export { attachModelsSaveBarClearance } from './models-save-bar-clearance.js';
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/


/** The settings pane spends this as `scroll-padding-block-end`, so a control
* the keyboard reaches is scrolled clear of the sticky Save bar instead of
* under it (`styles/settings/provider-editor.css`). */
const CLEARANCE_PROPERTY = '--provider-models-save-bar-space';
const PANE_SELECTOR = '.settingsMainPane';

/** Ref callback for the model Save bar: publishes the bar's height to the
* settings pane it is stuck to.
*
* The bar hangs over the bottom of the pane, and the model rows scroll in a
* viewport of their own: tabbing from one row's control to the next scrolls
* that viewport, and no amount of scrolling inside it can move a row out from
* under a bar stuck to the pane. With the clearance published the pane itself
* scrolls the focused control above the bar. The height is measured rather
* than tokenized because the bar wraps at narrow widths, the way the
* composer dock's clearance is measured in `use-focused-preview.ts`.
*
* A ref callback rather than a hook: the detail view that renders the bar is
* recorded renderer debt whose hook and capability budgets are frozen, so the
* measurement belongs to this feature instead. */
export function attachModelsSaveBarClearance(bar: HTMLElement | null) {
const pane = bar?.closest<HTMLElement>(PANE_SELECTOR);
if (!bar || !pane) return;
const measure = () => {
pane.style.setProperty(CLEARANCE_PROPERTY, `${Math.ceil(bar.getBoundingClientRect().height)}px`);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(bar);
return () => {
observer.disconnect();
pane.style.removeProperty(CLEARANCE_PROPERTY);
};
}
63 changes: 41 additions & 22 deletions apps/desktop/src/renderer/settings/provider-connection-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ import { PasswordInput } from './password-input';
import { SettingsExpandableRow } from './settings-expandable-row';
import { SettingsActions, SettingsRow, SettingsSection } from './settings-section';
import { providerDisplay } from './provider-display';
import { CapabilityEditor, AddModelDialog, ModelParametersDialog } from '../features/connection-settings';
import { CapabilityEditor, AddModelDialog, ModelParametersDialog, attachModelsSaveBarClearance } from '../features/connection-settings';
import {
RuntimeHostSettingsGenerationBoundary,
useRuntimeHostSettingsErrorReporter,
Expand Down Expand Up @@ -693,27 +693,46 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
label={copy.noModelsMatch}
end={<Button variant="ghost" size="sm" label={copy.cancel} onClick={() => setModelFilter('')} />}
/>
) : visibleModelRows.map(({ id, entry }) => {
const label = entry?.displayName?.trim() || id;
const rowLabel = entry?.isDefault ? (
<HStack gap={2} vAlign="center">
<span>{label}</span>
<Badge variant="neutral" label={providerCopy.panel.default} />
</HStack>
) : label;
return (
<SettingsRow key={id} label={rowLabel} end={<>
{/* Astryx tooltips resolve a portal on mount, causing a style recalculation per model row. */}
<span title={copy.declareCapabilities}>
<IconButton variant="ghost" size="sm" icon={<Icon icon="wrench" size="sm" />}
label={copy.declareCapabilitiesAria(label)}
isDisabled={allActionsBusy} onClick={() => openRow({ model: id })} />
</span>
{modelEnableSwitch(id, label)}
</>} />
) : (
/* The rows get their own viewport so a large catalog stops
stretching the page: a provider with hundreds of models used to
push the filter above it out of view and bury the advanced
request settings below under the whole list. The filter, the
model count and the section actions stay outside this element,
so they hold still while the rows scroll.

A second `.settingsRows` rather than a bare div: the row
styling in rows.css is written as `.settingsRows > …`, so the
rows keep their edge-to-edge track, their hover wash and the
460px container query inside the viewport too.

);
})}
`max-height` only, so a short or filtered-down list is as tall
as its rows instead of reserving an empty box, and no
`overscroll-behavior`: a wheel that reaches the end of the list
should carry on down the page rather than stop dead. */
<div className="settingsRows providerModelRowsViewport">
{visibleModelRows.map(({ id, entry }) => {
const label = entry?.displayName?.trim() || id;
const rowLabel = entry?.isDefault ? (
<HStack gap={2} vAlign="center">
<span>{label}</span>
<Badge variant="neutral" label={providerCopy.panel.default} />
</HStack>
) : label;
return (
<SettingsRow key={id} label={rowLabel} end={<>
{/* Astryx tooltips resolve a portal on mount, causing a style recalculation per model row. */}
<span title={copy.declareCapabilities}>
<IconButton variant="ghost" size="sm" icon={<Icon icon="wrench" size="sm" />}
label={copy.declareCapabilitiesAria(label)}
isDisabled={allActionsBusy} onClick={() => openRow({ model: id })} />
</span>
{modelEnableSwitch(id, label)}
</>} />
);
})}
</div>
)}
</SettingsSection>
)}
<ModelParametersDialog
Expand Down Expand Up @@ -852,7 +871,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
</SettingsActions>
</SettingsSection>
{!retired && (
<div className="providerModelsSaveBar" role="group" aria-label={copy.modelSelection}>
<div ref={attachModelsSaveBarClearance} className="providerModelsSaveBar" role="group" aria-label={copy.modelSelection}>
<span role="status">{hasEnabledModelChanges ? copy.modelsUnsaved : copy.modelsSaved}</span>
<HStack gap={2}>
<Button variant="secondary" label={copy.cancel} isDisabled={!hasEnabledModelChanges || allActionsBusy}
Expand Down
42 changes: 42 additions & 0 deletions apps/desktop/src/renderer/styles/settings/provider-editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,37 @@
color: var(--muted-foreground);
}

/* The model rows of a provider connection, in their own scroll viewport.
* Settings → 模型 → a connection expands every chat-capable model into the
* page, so a large catalog made the page itself hundreds of rows tall: the
* filter scrolled away while browsing, and the advanced request settings
* below sat under the whole list.
*
* `max-height`, not `height`: a small catalog, a filtered-down list and the
* empty-result row are as tall as their content, with no blank box reserved
* under them. The bound tracks the window — 60vh keeps the surrounding
* section visible in a short window, and the 32rem cap stops the list from
* eating a tall one.
*
* `overscroll-behavior` is deliberately left at `auto`: once the list is
* scrolled to its end the wheel carries on down the settings page, so the
* viewport cannot swallow the gesture of a user heading for the sections
* below it. */
.providerModelRowsViewport {
max-height: min(32rem, 60vh);
overflow-y: auto;

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.

P2: overflow-y: auto also makes overflow-x clip, and the rows are edge-to-edge, so the trailing Switch's :focus-visible ring (about 3px outside the element) gets cut off on the right edge. I measured and screenshotted this in both the 1280 and the 480 story. rows.css documents exactly this as a keyboard-visibility regression to avoid. A small inset that you take back keeps the ring visible and the track aligned, e.g. padding: var(--space-1); margin-inline: calc(-1 * var(--space-1));.

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.

[P2] Keep keyboard-focused model controls above the sticky Save bar in short windows

At the supported 320px minimum window height, this nested scroll container can bring keyboard focus completely underneath .providerModelsSaveBar. Reproduced on the live LargeConnectionDetailShortWindow story: resize to 600x320, toggle a model to show the Save bar, focus model 443's Switch as setup, then press Tab to model 444's parameter button. The focused button is at y=288..316 while Save occupies y=251..316; elementFromPoint at the button center hits the Save bar. The list's y=138..330 is only partly visible, and inner scrolling does not compensate for the sticky overlay. Also reproduced at 600x480 and 800x320. Switching off this rule and the max-height (restoring the old page scroll behavior) makes the same Tab target visible at y=190..218, above Save.

Please coordinate focus scrolling with the sticky bar/visible portion of the inner viewport, or ensure the entire list viewport fits in the unobscured pane. Add a short-height keyboard-focus assertion: current tests only check that Save is visible and that the last row lies inside the list's own rect, which both pass while the focused control is completely obscured. Screenshot retained locally at /tmp/6165-600x320-focus.png. Existing 480x800 case does not expose this.

/* A scroll container clips at its padding edge, and model rows run flush
* with it: `rows.css` takes their inline padding away so the row track
* reaches the section's edges. Anything a row paints outside its own box —
* the keyboard focus ring of the trailing Switch (`--focus-ring-offset` out,
* `--focus-ring-width` wide), the hover wash a clickable row steps out by —
* would be cut by the new boundary. The viewport steps out by that same
* `--space-2` and takes it straight back as padding, so the rows keep the
* edges they had and everything painted around them stays inside the clip. */
margin: calc(-1 * var(--space-2));
padding: var(--space-2);
}

.providerEndpointField {
display: grid;
gap: 6px;
Expand Down Expand Up @@ -89,6 +120,17 @@
accent-color: var(--primary);
}

/* The pane spends the Save bar's measured height as scroll padding, so a
control the keyboard reaches is scrolled clear of the bar instead of under
it. Needed since the model rows scroll in a viewport of their own: focus
moving down the list scrolls that viewport, which cannot move a row out from
beneath a bar stuck to the pane. The height is published by
`features/connection-settings/models-save-bar-clearance.ts`; where it is absent
the padding is nothing, so the other settings pages are untouched. */
.settingsMainPane {
scroll-padding-block-end: var(--provider-models-save-bar-space, 0px);
}

/* The settings pane remains the only scroll owner. Sticky bottom keeps Save
reachable even when the model catalog extends far below the viewport. */
.providerModelsSaveBar {
Expand Down
Loading