FN-5932: fix research settings containment layout

Keep research settings controls inside their containment wrappers so inputs stay onscreen across breakpoints.

- wrap advanced provider controls in a dedicated research settings body container
- tighten research grid and field min-width behavior in desktop and mobile styles
- add regression coverage for global and project research sections on desktop and mobile

Files changed:
 packages/dashboard/app/components/SettingsModal.css                  |  37 +++++++-
 packages/dashboard/app/components/SettingsModal.tsx                  | 104 +++++++++++----------
 packages/dashboard/app/components/__tests__/SettingsModal.test.tsx   |  65 +++++++++++++
 packages/dashboard/app/components/__tests__/settings-mobile.test.tsx |  48 ++++++++++
 4 files changed, 201 insertions(+), 53 deletions(-)

Fusion-Task-Id: FN-5932

Fusion-Task-Lineage: 22dff41f-b563-40d0-97ea-1ca6d1e09466
This commit is contained in:
gsxdsm
2026-06-03 00:49:22 -07:00
parent de7847c44d
commit 38ff2ef86d
4 changed files with 201 additions and 53 deletions

View File

@@ -439,18 +439,29 @@
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-sm);
min-width: 0;
}
.settings-research-source-grid > * {
min-width: 0;
}
.settings-research-limits-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-sm);
min-width: 0;
}
.settings-research-limits-grid > * {
min-width: 0;
}
.settings-research-limit-field {
display: flex;
flex-direction: column;
gap: var(--space-xs);
min-width: 0;
}
.settings-research-limits-error {
@@ -1002,10 +1013,31 @@
display: flex;
flex-direction: column;
gap: var(--space-sm);
min-width: 0;
}
.settings-research-provider-advanced-details {
margin-left: var(--space-md);
min-width: 0;
padding-inline-start: var(--space-md);
}
.settings-research-provider-advanced-body {
display: flex;
flex-direction: column;
gap: var(--space-sm);
min-width: 0;
padding-top: var(--space-sm);
}
.settings-research-provider-advanced-body > .form-group {
margin-top: 0;
padding: 0;
min-width: 0;
}
.settings-research-provider-advanced-body .input {
width: 100%;
min-width: 0;
}
.settings-research-empty-state {
@@ -1013,11 +1045,12 @@
flex-wrap: wrap;
align-items: center;
gap: var(--space-sm);
min-width: 0;
}
@media (max-width: 768px) {
.settings-research-provider-advanced-details {
margin-left: 0;
padding-inline-start: 0;
}
}

View File

@@ -5615,57 +5615,59 @@ export function SettingsModal({
</small>
<details className="settings-option-details settings-research-provider-advanced-details">
<summary>Advanced — external search providers</summary>
<div className="form-group">
<label htmlFor="research-global-search-provider-advanced">Search Provider</label>
<select
id="research-global-search-provider-advanced"
className="input"
value={externalProvider ? resolvedProvider : "searxng"}
onChange={(event) =>
setSearchProvider(event.target.value as Settings["researchGlobalWebSearchProvider"])
}
>
<option value="searxng">SearXNG</option>
<option value="brave">Brave</option>
<option value="google">Google Custom Search</option>
<option value="tavily">Tavily</option>
</select>
</div>
<div className="form-group">
<label htmlFor="research-global-searxng-url">SearXNG URL</label>
<input
id="research-global-searxng-url"
className="input"
value={form.researchGlobalSearxngUrl ?? ""}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalSearxngUrl: event.target.value || undefined,
}))
}
placeholder="https://searx.example.com"
/>
</div>
<div className="form-group">
<label htmlFor="research-global-google-cx">Google Search CX</label>
<input
id="research-global-google-cx"
className="input"
value={form.researchGlobalGoogleSearchCx ?? ""}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalGoogleSearchCx: event.target.value || undefined,
}))
}
placeholder="custom-search-engine-id"
/>
</div>
<div className="settings-empty-state settings-research-empty-state" role="note">
Configure Brave, Tavily, and Google API keys in Authentication.
<button type="button" className="btn btn-sm" onClick={() => setActiveSection("authentication")}>
Open Authentication Settings
</button>
<div className="settings-research-provider-advanced-body">
<div className="form-group">
<label htmlFor="research-global-search-provider-advanced">Search Provider</label>
<select
id="research-global-search-provider-advanced"
className="input"
value={externalProvider ? resolvedProvider : "searxng"}
onChange={(event) =>
setSearchProvider(event.target.value as Settings["researchGlobalWebSearchProvider"])
}
>
<option value="searxng">SearXNG</option>
<option value="brave">Brave</option>
<option value="google">Google Custom Search</option>
<option value="tavily">Tavily</option>
</select>
</div>
<div className="form-group">
<label htmlFor="research-global-searxng-url">SearXNG URL</label>
<input
id="research-global-searxng-url"
className="input"
value={form.researchGlobalSearxngUrl ?? ""}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalSearxngUrl: event.target.value || undefined,
}))
}
placeholder="https://searx.example.com"
/>
</div>
<div className="form-group">
<label htmlFor="research-global-google-cx">Google Search CX</label>
<input
id="research-global-google-cx"
className="input"
value={form.researchGlobalGoogleSearchCx ?? ""}
onChange={(event) =>
setForm((current) => ({
...current,
researchGlobalGoogleSearchCx: event.target.value || undefined,
}))
}
placeholder="custom-search-engine-id"
/>
</div>
<div className="settings-empty-state settings-research-empty-state" role="note">
Configure Brave, Tavily, and Google API keys in Authentication.
<button type="button" className="btn btn-sm" onClick={() => setActiveSection("authentication")}>
Open Authentication Settings
</button>
</div>
</div>
</details>
</div>

View File

@@ -4793,6 +4793,13 @@ describe("SettingsModal", () => {
expect(details).toHaveAttribute("open");
expect(await screen.findByLabelText("SearXNG URL")).toBeInTheDocument();
expect(screen.getByText(/Open Authentication Settings/i)).toBeInTheDocument();
const advancedBody = details?.querySelector(".settings-research-provider-advanced-body");
expect(advancedBody).toBeTruthy();
expect(advancedBody?.querySelectorAll(".form-group")).toHaveLength(3);
expect(screen.getByLabelText("Search Provider")).toHaveClass("input");
expect(screen.getByLabelText("SearXNG URL")).toHaveClass("input");
expect(screen.getByLabelText("Google Search CX")).toHaveClass("input");
});
it("keeps default max sources outside advanced details and groups provider controls", async () => {
@@ -4813,6 +4820,54 @@ describe("SettingsModal", () => {
expect(providerGroup).toContainElement(screen.getByText(/No API key required\./i));
});
it("keeps research limits and source controls inside desktop containment grids for both sections", async () => {
renderModal();
await waitForSettingsModalReady();
await openResearchGlobalSection();
const defaultMaxConcurrent = screen.getByLabelText("Default Max Concurrent Runs");
const defaultMaxSources = screen.getByLabelText("Default Max Sources Per Run");
const defaultMaxDuration = screen.getByLabelText("Default Max Duration (ms)");
const defaultRequestTimeout = screen.getByLabelText("Request Timeout (ms)");
const globalLimitsGrid = defaultMaxConcurrent.closest(".settings-research-limits-grid");
expect(globalLimitsGrid).toBeTruthy();
expect(defaultMaxSources.closest(".settings-research-limits-grid")).toBe(globalLimitsGrid);
expect(defaultMaxDuration.closest(".settings-research-limits-grid")).toBe(globalLimitsGrid);
expect(defaultRequestTimeout.closest(".settings-research-limits-grid")).toBe(globalLimitsGrid);
expect(defaultMaxConcurrent).toHaveClass("input");
expect(defaultMaxSources).toHaveClass("input");
expect(defaultMaxDuration).toHaveClass("input");
expect(defaultRequestTimeout).toHaveClass("input");
const globalSourceGrid = screen.getByRole("checkbox", { name: "GitHub" }).closest(".settings-research-source-grid");
expect(globalSourceGrid).toBeTruthy();
expect(screen.getByRole("checkbox", { name: "Local Docs" }).closest(".settings-research-source-grid")).toBe(globalSourceGrid);
await openResearchProjectSection();
const projectMaxConcurrent = screen.getByLabelText("Max Concurrent Runs");
const projectMaxSources = screen.getByLabelText("Max Sources Per Run");
const projectMaxDuration = screen.getByLabelText("Max Duration (ms)");
const projectRequestTimeout = screen.getByLabelText("Request Timeout (ms)");
const projectLimitsGrid = projectMaxConcurrent.closest(".settings-research-limits-grid");
expect(projectLimitsGrid).toBeTruthy();
expect(projectMaxSources.closest(".settings-research-limits-grid")).toBe(projectLimitsGrid);
expect(projectMaxDuration.closest(".settings-research-limits-grid")).toBe(projectLimitsGrid);
expect(projectRequestTimeout.closest(".settings-research-limits-grid")).toBe(projectLimitsGrid);
expect(projectMaxConcurrent).toHaveClass("input");
expect(projectMaxSources).toHaveClass("input");
expect(projectMaxDuration).toHaveClass("input");
expect(projectRequestTimeout).toHaveClass("input");
const projectSourceGrid = screen.getByRole("checkbox", { name: "Page Fetch" }).closest(".settings-research-source-grid");
expect(projectSourceGrid).toBeTruthy();
expect(screen.getByRole("checkbox", { name: "GitHub" }).closest(".settings-research-source-grid")).toBe(projectSourceGrid);
expect(screen.getByRole("checkbox", { name: "Local Docs" }).closest(".settings-research-source-grid")).toBe(projectSourceGrid);
expect(screen.getByRole("checkbox", { name: "LLM Synthesis" }).closest(".settings-research-source-grid")).toBe(projectSourceGrid);
});
it("groups project limits fields in one grid and keeps validation error visible", async () => {
renderModal();
await waitForSettingsModalReady();
@@ -4828,6 +4883,16 @@ describe("SettingsModal", () => {
expect(maxSources.closest(".settings-research-limits-grid")).toBe(limitsGrid);
expect(maxDuration.closest(".settings-research-limits-grid")).toBe(limitsGrid);
expect(requestTimeout.closest(".settings-research-limits-grid")).toBe(limitsGrid);
expect(maxConcurrent).toHaveClass("input");
expect(maxSources).toHaveClass("input");
expect(maxDuration).toHaveClass("input");
expect(requestTimeout).toHaveClass("input");
const sourceGrid = screen.getByRole("checkbox", { name: "Page Fetch" }).closest(".settings-research-source-grid");
expect(sourceGrid).toBeTruthy();
expect(screen.getByRole("checkbox", { name: "GitHub" }).closest(".settings-research-source-grid")).toBe(sourceGrid);
expect(screen.getByRole("checkbox", { name: "Local Docs" }).closest(".settings-research-source-grid")).toBe(sourceGrid);
expect(screen.getByRole("checkbox", { name: "LLM Synthesis" }).closest(".settings-research-source-grid")).toBe(sourceGrid);
fireEvent.change(maxConcurrent, { target: { value: "0" } });
await userEvent.click(screen.getByText("Save"));

View File

@@ -261,6 +261,48 @@ describe("SettingsModal mobile adaptations", () => {
expect(getByLabelText("Memory File")).toBeTruthy();
});
it("keeps research settings controls inside mobile containment wrappers", async () => {
vi.mocked(fetchSettings).mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: { researchView: true },
});
mockSettingsViewport(true);
const user = userEvent.setup();
const { getByLabelText } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
const picker = getByLabelText("Settings Section");
await user.selectOptions(picker, "research-global");
const details = await within(document.body).findByText(/Advanced — external search providers/i);
await user.click(details);
const advancedPanel = document.querySelector(".settings-research-provider-advanced-body");
expect(advancedPanel).toBeTruthy();
expect(document.querySelector(".settings-research-provider-advanced-details")).toBeTruthy();
expect(document.querySelector(".settings-research-limits-grid")).toBeTruthy();
expect(document.querySelector(".settings-research-source-grid")).toBeTruthy();
await user.selectOptions(picker, "research-project");
const maxConcurrent = await within(document.body).findByLabelText("Max Concurrent Runs");
expect(maxConcurrent).toHaveClass("input");
expect(document.querySelectorAll(".settings-research-limit-field").length).toBeGreaterThan(0);
const projectLimitsGrid = maxConcurrent.closest(".settings-research-limits-grid");
expect(projectLimitsGrid).toBeTruthy();
expect(within(document.body).getByLabelText("Max Sources Per Run").closest(".settings-research-limits-grid")).toBe(projectLimitsGrid);
expect(within(document.body).getByLabelText("Max Duration (ms)").closest(".settings-research-limits-grid")).toBe(projectLimitsGrid);
expect(within(document.body).getByLabelText("Request Timeout (ms)").closest(".settings-research-limits-grid")).toBe(projectLimitsGrid);
const projectSourceGrid = within(document.body).getByRole("checkbox", { name: "Page Fetch" }).closest(".settings-research-source-grid");
expect(projectSourceGrid).toBeTruthy();
expect(within(document.body).getByRole("checkbox", { name: "GitHub" }).closest(".settings-research-source-grid")).toBe(projectSourceGrid);
expect(within(document.body).getByRole("checkbox", { name: "Local Docs" }).closest(".settings-research-source-grid")).toBe(projectSourceGrid);
expect(within(document.body).getByRole("checkbox", { name: "LLM Synthesis" }).closest(".settings-research-source-grid")).toBe(projectSourceGrid);
});
it("renders settings nav items with active class for touch styling", async () => {
const { container } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
@@ -354,10 +396,16 @@ describe("SettingsModal mobile adaptations", () => {
// Remote Access header elements must use the same mobile gutter as other remote blocks
expectMobileRule(css, ".remote-status-bar", "margin: 0 var(--space-lg) var(--space-md);");
expectMobileRule(css, ".remote-share-block", "margin: 0 var(--space-lg) var(--space-md);");
expectMobileRule(css, ".settings-research-provider-advanced-details", "padding-inline-start: 0;");
expectMobileRule(css, ".settings-research-source-grid", "grid-template-columns: 1fr;");
expectMobileRule(css, ".settings-research-limits-grid", "grid-template-columns: 1fr;");
// Base rules: desktop uses --space-xl horizontal margin for remote header elements
expectBaseRule(css, ".remote-status-bar", "margin: 0 var(--space-xl) var(--space-md);");
expectBaseRule(css, ".remote-share-block", "margin: 0 var(--space-xl) var(--space-md);");
expectBaseRule(css, ".settings-research-provider-advanced-details", "padding-inline-start: var(--space-md);");
expectBaseRule(css, ".settings-research-provider-advanced-body > .form-group", "padding: 0;");
expectBaseRule(css, ".settings-research-limits-grid", "min-width: 0;");
// Settings header actions keep compact controls on a shared height contract on desktop; mobile inherits this height (FN-4354 reverted prior mobile inflation).
expectBaseRule(css, ".settings-header-actions", "--settings-header-action-height: calc(var(--space-md) * 2 + var(--space-xs) / 2);");