Skip to content
Merged
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
2 changes: 1 addition & 1 deletion spire-ui/src/components/SettingsLlmModelForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export default function SettingsLlmModelForm({ initial, onClose, onSaved }: Sett

return (
<div className="modal-overlay">
<div className="modal" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
<div className="modal tall" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
<h3>{editing ? 'Edit model' : 'Add model'}</h3>
<form className="modal-body" onSubmit={submit}>
<div className="field-row-2">
Expand Down
6 changes: 3 additions & 3 deletions spire-ui/src/components/SettingsLlmModelRateFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,9 @@ interface RateFieldProps {
function RateField({ type, value, onChange, notBilled, onNotBilled }: RateFieldProps) {
const optional = !MANDATORY_RATE_TYPES.includes(type);
return (
<div className="field">
<div className="rate-field">
<label className="field">
<span>
<span className="rate-label">
{TOKEN_TYPE_LABEL[type]} rate $ / 1M tokens {optional && <span className="field-optional">optional</span>}
</span>
<input
Expand Down Expand Up @@ -132,7 +132,7 @@ interface ModelRateFieldsProps {
export default function ModelRateFields({ rates, onChange, notBilled, onNotBilled }: ModelRateFieldsProps) {
return (
<>
<div className="field-row-2">
<div className="rate-grid">
{RATE_TYPES.map((type) => (
<RateField key={type} type={type} value={rates[type]} onChange={(v) => onChange(type, v)}
notBilled={!!notBilled[type]} onNotBilled={(v) => onNotBilled(type, v)} />
Expand Down
2 changes: 1 addition & 1 deletion spire-ui/src/components/SettingsLlmProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ function LlmProviderForm({

return (
<div className="modal-overlay">
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal tall" onClick={(e) => e.stopPropagation()}>
<h3>{editing ? 'Edit LLM provider' : 'Add LLM provider'}</h3>
<form className="modal-body" onSubmit={submit}>
<label className="field">
Expand Down
19 changes: 18 additions & 1 deletion spire-ui/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,15 @@
}
/* Roomier variant for content-heavy dialogs (e.g. the webhook-created reveal + setup checklist). */
.modal.wide { max-width: 600px; }
/* A dialog that can be taller than the window: it is capped at the window, its fields scroll, and the
buttons stay pinned at the bottom. The model dialog ran off screen with its Save button half hidden. */
.modal.tall { max-height: calc(100vh - 40px); display: flex; flex-direction: column; }
.modal.tall > h3 { flex: none; }
.modal.tall > .modal-body { overflow-y: auto; min-height: 0; }
.modal.tall .modal-actions {
position: sticky; bottom: -18px; margin: 0 -18px -18px; padding: 12px 18px;
background: var(--panel); border-top: 1px solid var(--border);
}
.modal-head {
display: flex; align-items: center; gap: 12px;
padding: 15px 18px; border-bottom: 1px solid var(--border);
Expand Down Expand Up @@ -1138,6 +1147,14 @@
border: 1px solid var(--border); border-radius: 6px; padding: 7px 9px; word-break: break-all;
}
.field-optional { color: var(--text-3); font-weight: 400; text-transform: none; letter-spacing: 0; font-size: 11px; }
/* The price fields line up across both columns whether or not a label wraps for its "optional" marker:
each field spans three rows of the grid (label, input, check box) as a subgrid, so the two labels of a
row share one track and sit at its bottom. The label element is display: contents so its text and its
input can each take a row; it still labels the input. */
.rate-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.rate-field { display: grid; grid-row: span 3; grid-template-rows: subgrid; row-gap: 6px; }
.rate-field > label.field { display: contents; }
.rate-label { align-self: end; display: flex; flex-wrap: wrap; align-items: baseline; column-gap: 6px; }
.field-row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.field-row-12 { display: grid; grid-template-columns: 1fr 2fr; gap: 10px; align-items: start; }

Expand Down Expand Up @@ -1381,7 +1398,7 @@
.chip-x button:hover { color: var(--crit); }

@media (max-width: 560px) {
.field-row-2, .field-row-12, .field-row-13 { grid-template-columns: 1fr; }
.field-row-2, .field-row-12, .field-row-13, .rate-grid { grid-template-columns: 1fr; }
}

/* Settings → Prompts: list of prompt kinds, each a link into its detail/edit page */
Expand Down
34 changes: 34 additions & 0 deletions spire-ui/src/llmDialogs.contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';

/**
* The LLM dialogs are taller than a laptop window: the model dialog ran off screen with its buttons half
* hidden, and its price inputs sat at different heights (feedback, 2026-09-27). Layout cannot be measured
* in jsdom, so this holds the two rules that fix it and the markup that uses them.
*/
const SRC = join(__dirname);
const read = (file: string) => readFileSync(join(SRC, file), 'utf8');

it('caps a tall dialog at the window and keeps its buttons in view', () => {
const css = read('index.css');
expect(css.length).toBeGreaterThan(0);
expect(css).toMatch(/\.modal\.tall \{[^}]*max-height: calc\(100vh - 40px\)/);
expect(css).toMatch(/\.modal\.tall > \.modal-body \{[^}]*overflow-y: auto/);
expect(css).toMatch(/\.modal\.tall \.modal-actions \{[^}]*position: sticky/);
expect(read('components/SettingsLlmModelForm.tsx')).toContain('className="modal tall"');
expect(read('components/SettingsLlmProviders.tsx')).toContain('className="modal tall"');
});

it('lets the two price labels of a row share one height so the inputs line up', () => {
const css = read('index.css');
expect(css).toMatch(/\.rate-field \{[^}]*grid-row: span 3;[^}]*grid-template-rows: subgrid/);
expect(css).toMatch(/\.rate-field > label\.field \{[^}]*display: contents/);
expect(css).toMatch(/\.rate-label \{[^}]*align-self: end/);
// On a narrow screen the price fields stack in one column, like the other field rows (review of PR #180).
expect(css).toMatch(/@media \(max-width: 560px\) \{\s*[^}]*\.rate-grid \{ grid-template-columns: 1fr; \}/);
const fields = read('components/SettingsLlmModelRateFields.tsx');
expect(fields).toContain('<div className="rate-grid">');
expect(fields).toContain('<div className="rate-field">');
expect(fields).toContain('<span className="rate-label">');
});
Loading