feat(FN-887): widen model menu on mobile viewports

- Widen quick-entry Models menu to fill viewport on mobile (≤640px) with side padding
- Keep menu viewport-clamped to prevent horizontal crowding on small screens
- Update dashboard README with mobile model menu documentation
This commit is contained in:
gsxdsm
2026-04-04 16:31:30 -07:00
parent e012abb637
commit 2363500ea5
4 changed files with 114 additions and 6 deletions

View File

@@ -48,7 +48,7 @@ AI-guided interactive planning for creating well-specified tasks from high-level
- **List View**: Alternative tabular view for tasks with sorting and filtering. The "Hide Done" toggle hides both Done and Archived tasks for an active-work-only view.
- **Model Selection at Creation**: Choose executor and validator AI models while creating tasks from the board or list view, or leave them unset to use the global defaults. Quick-add model dropdowns in both the board triage column and the list view honor saved favorite providers and pinned models, matching the rest of the dashboard model UI. Saved model presets are available in every new-task model-selection surface — the full New Task form (`TaskForm`/`NewTaskModal`), the board inline create card (`InlineCreateCard`), and the list-view quick entry box (`QuickEntryBox`). Users can choose between default behavior, a saved preset (which applies its executor/validator values in one click), or custom per-model overrides; returning to default clears all overrides, and manual model selection exits preset mode cleanly.
- **AI-Assisted Creation Controls**: Plan, Subtask, and Refine buttons appear directly below the description textarea in all task creation surfaces (quick entry box, inline create card, and task form modal). These description-adjacent controls make AI-assisted creation and refinement feel directly associated with the text being edited. Deps, Models, and Save actions remain in the expanded controls footer.
- **Layered Model Dropdowns**: Shared model combobox menus render in a top-level portal attached to `document.body`, so they stay above board columns and scrollable modal content instead of being clipped behind surrounding dashboard surfaces. The dropdown constrains horizontal overflow — long model IDs and provider labels truncate with ellipsis rather than creating sideways scrolling, keeping the menu usable on smaller viewports.
- **Layered Model Dropdowns**: Shared model combobox menus render in a top-level portal attached to `document.body`, so they stay above board columns and scrollable modal content instead of being clipped behind surrounding dashboard surfaces. The dropdown constrains horizontal overflow — long model IDs and provider labels truncate with ellipsis rather than creating sideways scrolling, keeping the menu usable on smaller viewports. On mobile viewports (≤640px), the quick-entry Models menu widens to fill the viewport (minus side padding) and remains viewport-clamped for comfortable model selection without horizontal crowding.
- **Fuzzy Model Search**: Model dropdown search (`filterModels`) supports fuzzy matching so users can find models despite minor typing imperfections. Three matching strategies are applied in order (first match wins): (1) **separator-insensitive substring** — hyphens, underscores, dots, and slashes are stripped before comparison, so `gpt4o` finds `gpt-4o`; (2) **subsequence matching** (≥ 3 chars) — characters must appear in order within a single token but need not be contiguous, so `cld` finds `claude`; (3) **typo tolerance** (≥ 4 chars) — Damerau-Levenshtein edit distance ≤ 1 supports single-character insertion, deletion, substitution, and adjacent transposition, so `sonet` finds `sonnet`. Multi-term space-separated queries use AND logic. Result ordering is stable (input-array order, no score re-sorting). Exact and substring matches from the original implementation continue to work unchanged.
- **Bulk Model Editing**: Update AI model configuration for multiple tasks at once in the list view. Select tasks via checkboxes (archived tasks excluded), then use the "Bulk Edit Models" toolbar to apply executor and/or validator model changes to all selected tasks. Selection persists in localStorage across page reloads.
- **Task Details**: View full task specifications, agent logs, and attachments. The task detail modal uses a top-level tab bar with the following tabs: **Definition**, **Logs**, **Changes** (for in-progress/in-review/done tasks), **Commits** (for done tasks with `mergeDetails.commitSha`), **Comments**, **Model**, and **Workflow** (when workflow steps are configured or the task has previous workflow results). **Activity** and **Agent Log** are subviews within the unified **Logs** tab — click Logs, then toggle between Activity (task lifecycle events, default) and Agent Log (live agent output). The Agent Log subview expands to fill the full modal body height above the action bar, providing maximum vertical space for watching live agent output. The Agent Log header shows the effective executor, validator, and planning/triage model names resolved from task-level overrides or project/global settings fallbacks, matching the same resolution order the engine uses at runtime. A **Markdown/Plain toggle** in the Agent Log header switches between formatted markdown rendering (default) and literal plain-text display — useful for debugging raw agent output, checking escaped markdown syntax, or inspecting exactly what the agent emitted without formatting. The toggle applies to `text` and `thinking` entries only; tool entries always render as plain text. React-markdown handles sanitization in markdown mode (no raw HTML is executed); plain-text mode uses React's built-in text escaping for safe literal output. The refinement modal positions the "Create Refinement Task" button adjacent to the feedback textarea alongside the character count, creating a tight input group that connects the submit action directly to the text being edited. The **Changes** tab for done tasks loads the diff from the recorded merge commit (`mergeDetails.commitSha`) via `fetchCommitDiff` rather than requiring a live worktree — changes remain visible even after the worktree is cleaned up. The tab shows commit metadata (short SHA, merge commit message, merged timestamp) alongside the file-level diff with addition/deletion totals. In-progress and in-review tasks continue to use the worktree-based diff path. Changed-file status indicators (added, modified, deleted, unknown) use semantic CSS classes and theme-aware color variables, ensuring readable contrast across all dashboard themes and light/dark modes.

View File

@@ -457,11 +457,25 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
setModelMenuPosition({
top: rect.bottom + 4,
left: rect.left,
width: Math.max(rect.width, 240),
});
const viewportWidth = window.innerWidth;
const isMobile = viewportWidth <= 640;
if (isMobile) {
// On mobile: use a wider menu that fills most of the viewport (32px side margins)
const mobileWidth = Math.min(viewportWidth - 32, 360);
const left = Math.max((viewportWidth - mobileWidth) / 2, 16);
setModelMenuPosition({
top: rect.bottom + 4,
left,
width: mobileWidth,
});
} else {
setModelMenuPosition({
top: rect.bottom + 4,
left: rect.left,
width: Math.max(rect.width, 240),
});
}
}, []);
const toggleModelMenu = useCallback(() => {

View File

@@ -1957,4 +1957,91 @@ describe("QuickEntryBox", () => {
expect(menu.style.position).toBe("fixed");
});
});
describe("Model menu mobile viewport width", () => {
it("uses wider width on mobile viewports (≤640px)", () => {
// Simulate a narrow mobile viewport
vi.spyOn(window, "innerWidth", "get").mockReturnValue(375);
renderQuickEntryBox({});
expandQuickEntry();
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
const menu = screen.getByTestId("model-nested-menu");
// On mobile, width should be viewport width minus padding (375 - 32 = 343)
const menuWidth = parseFloat(menu.style.width);
expect(menuWidth).toBe(375 - 32);
});
it("left position is clamped to horizontal padding on mobile", () => {
vi.spyOn(window, "innerWidth", "get").mockReturnValue(375);
renderQuickEntryBox({});
expandQuickEntry();
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
const menu = screen.getByTestId("model-nested-menu");
// Left should be clamped to at least 16px (horizontal padding)
const menuLeft = parseFloat(menu.style.left);
expect(menuLeft).toBeGreaterThanOrEqual(16);
});
it("menu stays fully within viewport on mobile", () => {
const viewportWidth = 375;
vi.spyOn(window, "innerWidth", "get").mockReturnValue(viewportWidth);
renderQuickEntryBox({});
expandQuickEntry();
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
const menu = screen.getByTestId("model-nested-menu");
const menuLeft = parseFloat(menu.style.left);
const menuWidth = parseFloat(menu.style.width);
// Right edge should not exceed viewport minus horizontal padding
expect(menuLeft + menuWidth).toBeLessThanOrEqual(viewportWidth - 16);
});
it("uses desktop width (trigger-based) on non-mobile viewports", () => {
// Default test environment has a wider viewport
vi.spyOn(window, "innerWidth", "get").mockReturnValue(1024);
renderQuickEntryBox({});
expandQuickEntry();
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
const menu = screen.getByTestId("model-nested-menu");
const menuWidth = parseFloat(menu.style.width);
// On desktop, width should be at least 240 (minimum) and at most 360 (max-width)
expect(menuWidth).toBeGreaterThanOrEqual(240);
expect(menuWidth).toBeLessThanOrEqual(360);
});
it("repositions with mobile width on resize from desktop to mobile", () => {
const innerWidthSpy = vi.spyOn(window, "innerWidth", "get").mockReturnValue(1024);
renderQuickEntryBox({});
expandQuickEntry();
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
const menu = screen.getByTestId("model-nested-menu");
// Desktop width
const desktopWidth = parseFloat(menu.style.width);
expect(desktopWidth).toBeGreaterThanOrEqual(240);
// Simulate resize to mobile
innerWidthSpy.mockReturnValue(375);
fireEvent.resize(window);
// Width should now be the mobile-optimized width
const mobileWidth = parseFloat(menu.style.width);
expect(mobileWidth).toBe(375 - 32);
expect(mobileWidth).toBeGreaterThan(desktopWidth);
});
});
});

View File

@@ -11685,6 +11685,8 @@ html .column.drag-over * {
max-width: 360px;
}
/* Mobile: portaled model menu uses wider viewport-clamped width set via inline styles */
.model-menu-items {
display: flex;
flex-direction: column;
@@ -11783,6 +11785,11 @@ html .column.drag-over * {
max-width: unset;
width: calc(100vw - 32px);
}
/* Portaled variant on mobile: remove max-width constraint so JS-driven width takes full effect */
.model-nested-menu--portal {
max-width: none;
}
}
/* Responsive layout for quick entry controls */