Skip to content
Closed
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
26 changes: 26 additions & 0 deletions renderer/components/composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,29 @@ test("workspace access keyboard navigation moves focus without changing permissi
assert.match(composer, /radios\?\.\[nextIndex\]\?\.focus\(\)/u);
assert.doesNotMatch(composer, /requestPermission\(nextPermission\)/u);
});

test("model picker details sit beside the menu without overlapping the pad", () => {
const modelPicker = source("./model-picker.tsx");
const pad = source("./model-picker-pad.tsx");
const styles = source("../styles.css");

assert.match(
modelPicker,
/className="flex w-max max-w-\[calc\(100vw-1\.5rem\)\] items-start gap-2 overflow-visible bg-transparent p-0 shadow-none"/u,
);
assert.match(
modelPicker,
/className="relative w-\[min\(19\.75rem,calc\(100vw-1\.5rem\)\)\] overflow-hidden rounded-popover bg-popover shadow-popover"/u,
);
assert.match(
modelPicker,
/className="pointer-events-none w-56 shrink-0 rounded-popover bg-popover p-3 text-primary shadow-popover"/u,
);
assert.doesNotMatch(modelPicker, /left-\[calc\(100%\+0\.5rem\)\]/u);
Comment on lines +262 to +274

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The green assertions all still pass if <ModelHoverDetails> moves back inside the chrome wrapper — nesting it under the overflow-hidden box (model-picker.tsx:549) reintroduces exactly the clipping this PR fixes without tripping a single pin. Anchoring the sibling relation — the details conditional appearing after the chrome box's closing </div> — would lock the layout contract the change actually relies on.

Technical details
# Test does not pin the aside as a sibling of the chrome box

## Affected sites
- renderer/components/composer.test.tsx:262-281 — the new "details sit beside the menu" test

## Required outcome
- The guard should fail if `ModelHoverDetails` is ever nested inside the `overflow-hidden` chrome wrapper at renderer/components/model-picker.tsx:549, which would clip the details card without re-introducing the abs-positioning classes the current `doesNotMatch` pins look for.

## Suggested approach (optional)
- Assert adjacency between the chrome box close and the details conditional, e.g.:
  `assert.match(modelPicker, /<\/div>\s*\{showExternalDetails && \(view === "list" \|\| hasPadModels\) \? \(/u);`

assert.doesNotMatch(modelPicker, /right: showExternalDetails/u);
assert.doesNotMatch(pad, /focus-visible:bg-list-selection/u);
assert.match(
styles,
/\.model-pad:focus-visible\s*\{\s*outline: none !important;\s*box-shadow:\s*inset 0 0 0 2px var\(--focus-ring\)/u,
);
});
2 changes: 1 addition & 1 deletion renderer/components/model-picker-pad.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ export function ModelPickerPad({
aria-describedby={helpId}
aria-activedescendant={active ? modelOptionId(active.value) : undefined}
data-dragging={dragging ? "true" : "false"}
className="model-pad relative aspect-square w-full touch-none overflow-hidden rounded-card outline-none focus-visible:bg-list-selection focus-visible:outline-none "
className="model-pad relative aspect-square w-full touch-none overflow-hidden rounded-card outline-none"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
Expand Down
20 changes: 9 additions & 11 deletions renderer/components/model-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ function ModelHoverDetails({
].filter((row): row is [string, string] => Boolean(row));

return (
<aside className="pointer-events-none absolute left-[calc(100%+0.5rem)] top-0 w-56 rounded-popover bg-popover p-3 text-primary shadow-popover">
<aside className="pointer-events-none w-56 shrink-0 rounded-popover bg-popover p-3 text-primary shadow-popover">
<div className="flex min-w-0 items-start gap-2">
<span className="mt-0.5 shrink-0 text-tertiary">
<ProviderIcon
Expand Down Expand Up @@ -530,13 +530,8 @@ export function ModelPicker({
side="top"
align="end"
sideOffset={8}
collisionPadding={{
top: 40,
right: showExternalDetails && (view === "list" || hasPadModels) ? 244 : 12,
bottom: 12,
left: 12,
}}
className="relative w-[min(19.75rem,calc(100vw-1.5rem))] overflow-visible p-0"
collisionPadding={{ top: 40, right: 12, bottom: 12, left: 12 }}
className="flex w-max max-w-[calc(100vw-1.5rem)] items-start gap-2 overflow-visible bg-transparent p-0 shadow-none"
onOpenAutoFocus={(event) => {
event.preventDefault();
requestAnimationFrame(() => {
Expand All @@ -551,6 +546,7 @@ export function ModelPicker({
});
}}
>
<div className="relative w-[min(19.75rem,calc(100vw-1.5rem))] overflow-hidden rounded-popover bg-popover shadow-popover">
<div className="p-1.5 pb-0">
<div
className="grid grid-cols-2 rounded-control bg-control/60 p-0.5"
Expand Down Expand Up @@ -707,9 +703,7 @@ export function ModelPicker({
</section>
)}

{showExternalDetails && (view === "list" || hasPadModels) ? (
<ModelHoverDetails model={activePosition} metadataLoading={metadataLoading} />
) : activeAttribution ? (
{!(showExternalDetails && (view === "list" || hasPadModels)) && activeAttribution ? (
<div className="border-t border-separator px-3 py-1.5 text-mini leading-4">
<a
href={activeAttribution.url}
Expand All @@ -721,6 +715,10 @@ export function ModelPicker({
</a>
</div>
) : null}
</div>
{showExternalDetails && (view === "list" || hasPadModels) ? (
<ModelHoverDetails model={activePosition} metadataLoading={metadataLoading} />
) : null}
</PopoverContent>
</Popover>
);
Expand Down
10 changes: 10 additions & 0 deletions renderer/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,16 @@ textarea {
inset 0 1px 0 rgb(255 255 255 / 7%);
}

/* The pad is a large listbox. An offset outline sits in the picker padding and
reads as a second chrome ring around the canvas; keep keyboard focus inside. */
.model-pad:focus-visible {
outline: none !important;
box-shadow:
inset 0 0 0 2px var(--focus-ring),
inset 0 0 0 0.5px var(--border-separator),
inset 0 1px 0 rgb(255 255 255 / 7%);
}

.model-pad::after {
position: absolute;
inset: 0;
Expand Down