From d7b7016cc98da02ce5110fb218c70cac419657f3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 26 Sep 2026 20:26:04 +0200 Subject: [PATCH] feat(settings): polish navigation and controls (#5459) * feat(settings): polish navigation and controls * test(settings): update navigation assertions --- docs/glossary.md | 2 +- .../app/e2e/support/helpers/chat-outline.ts | 2 +- .../e2e/support/helpers/plugin-settings.ts | 2 +- packages/app/e2e/support/helpers/settings.ts | 4 + .../support/helpers/sidebar-nav-settings.ts | 2 +- .../app/src/components/settings/index.tsx | 8 +- .../components/sidebar/sidebar-header-row.tsx | 28 +- .../app/src/components/ui/control-geometry.ts | 14 +- .../src/components/ui/dropdown-trigger.tsx | 110 +++- .../ui/status-badge.browser.test.tsx | 27 +- .../app/src/components/ui/status-badge.tsx | 23 +- .../browser/settings/browser-data-section.tsx | 2 +- packages/app/src/i18n/resources/ar.ts | 9 +- packages/app/src/i18n/resources/en.ts | 24 +- packages/app/src/i18n/resources/es.ts | 9 +- packages/app/src/i18n/resources/fr.ts | 9 +- packages/app/src/i18n/resources/ja.ts | 9 +- packages/app/src/i18n/resources/ko.ts | 9 +- packages/app/src/i18n/resources/pt-BR.ts | 9 +- packages/app/src/i18n/resources/ru.ts | 9 +- packages/app/src/i18n/resources/zh-CN.ts | 9 +- packages/app/src/screens/settings-screen.tsx | 546 ++++++------------ .../appearance/appearance-section.tsx | 204 +------ .../screens/settings/chat/chat-section.tsx | 74 +++ .../settings/general/sending-section.tsx | 36 ++ .../settings/host-appearance-section.tsx | 53 +- .../app/src/screens/settings/host-page.tsx | 18 - .../open-location-section.tsx} | 49 +- .../sidebar-nav-section.tsx | 0 .../settings/terminal/terminal-section.tsx | 67 +++ packages/app/src/styles/theme.ts | 16 + packages/app/src/utils/host-routes.ts | 5 +- .../app/test-stubs/react-native-unistyles.ts | 4 + .../desktop/e2e/settings-memory.electron.mjs | 5 +- 34 files changed, 661 insertions(+), 736 deletions(-) create mode 100644 packages/app/src/screens/settings/chat/chat-section.tsx create mode 100644 packages/app/src/screens/settings/general/sending-section.tsx rename packages/app/src/screens/settings/{layout/layout-section.tsx => open-location/open-location-section.tsx} (60%) rename packages/app/src/screens/settings/{appearance => sidebar}/sidebar-nav-section.tsx (100%) create mode 100644 packages/app/src/screens/settings/terminal/terminal-section.tsx diff --git a/docs/glossary.md b/docs/glossary.md index c542c32974..2c09d04dce 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -19,7 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo. - **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym. - **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label. -- **Sidebar items** — The top-level rows at the head of the app sidebar: New workspace, History, Search, Schedules, and every plugin sidebar contribution. Order and visibility are one per-device preference (`AppSettings.sidebarNavItems`); all of them may be off. UI: the "Sidebar" section on the Appearance settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves the list, and both the sidebar (`components/sidebar/sidebar-nav-rows.tsx`) and the settings section (`screens/settings/appearance/sidebar-nav-section.tsx`) read it through `useSidebarNavItems`. Never hardcode one of these rows or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**. +- **Sidebar items** — The top-level rows at the head of the app sidebar: New workspace, History, Search, Schedules, and every plugin sidebar contribution. Order and visibility are one per-device preference (`AppSettings.sidebarNavItems`); all of them may be off. UI: the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves the list, and both the sidebar (`components/sidebar/sidebar-nav-rows.tsx`) and the settings section (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems`. Never hardcode one of these rows or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**. - **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md). - **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel". - **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). diff --git a/packages/app/e2e/support/helpers/chat-outline.ts b/packages/app/e2e/support/helpers/chat-outline.ts index 8ace116ec7..09e28cd3af 100644 --- a/packages/app/e2e/support/helpers/chat-outline.ts +++ b/packages/app/e2e/support/helpers/chat-outline.ts @@ -83,7 +83,7 @@ export async function splitCurrentPanelRight(page: Page): Promise { export async function disableChatOutlineFromAppearance(page: Page): Promise { const timelineUrl = page.url(); await openSettings(page); - await openSettingsSection(page, "appearance"); + await openSettingsSection(page, "chat"); await page.getByRole("switch", { name: "Chat outline" }).click(); await page.goto(timelineUrl); } diff --git a/packages/app/e2e/support/helpers/plugin-settings.ts b/packages/app/e2e/support/helpers/plugin-settings.ts index 507cd8ea35..94143f31a2 100644 --- a/packages/app/e2e/support/helpers/plugin-settings.ts +++ b/packages/app/e2e/support/helpers/plugin-settings.ts @@ -47,7 +47,7 @@ async function openPluginScreen(page: Page) { await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible(); } export async function groupByWorkspace(page: Page) { - await page.getByRole("button", { name: "Group agents by", exact: true }).click(); + await page.getByRole("button", { name: /^Group agents by:/ }).click(); await page.getByRole("menuitem", { name: "Workspace", exact: true }).click(); await expect(page.getByText("Grouped by workspace", { exact: true })).toBeVisible(); } diff --git a/packages/app/e2e/support/helpers/settings.ts b/packages/app/e2e/support/helpers/settings.ts index 51df25fc89..ecfa2a4b09 100644 --- a/packages/app/e2e/support/helpers/settings.ts +++ b/packages/app/e2e/support/helpers/settings.ts @@ -20,8 +20,12 @@ interface SavedSettingsHostInput { const SECTION_LABELS = { general: "General", + chat: "Chat", appearance: "Appearance", + sidebar: "Sidebar", editor: "Editor", + terminal: "Terminal", + browser: "Browser", shortcuts: "Shortcuts", integrations: "Integrations", permissions: "Permissions", diff --git a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts index bfe6b2c8f5..b5f1a77683 100644 --- a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts +++ b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts @@ -57,7 +57,7 @@ export async function seedSidebarNavPreferences( export async function openSidebarNavSettings(page: Page): Promise { await openSettings(page); - await openSettingsSection(page, "appearance"); + await openSettingsSection(page, "sidebar"); await expect(page.getByTestId("sidebar-nav-section")).toBeVisible({ timeout: 30_000 }); } diff --git a/packages/app/src/components/settings/index.tsx b/packages/app/src/components/settings/index.tsx index 149e197bd0..00ac273bf0 100644 --- a/packages/app/src/components/settings/index.tsx +++ b/packages/app/src/components/settings/index.tsx @@ -117,17 +117,16 @@ export function SettingsSelect({ disabled, ...row }: SettingsSelectProps) { + const selectedLabel = options.find((option) => option.value === value)?.label ?? value; return ( - - {options.find((option) => option.value === value)?.label ?? value} - + {selectedLabel} {options.map((option) => ( @@ -203,6 +202,5 @@ const styles = StyleSheet.create((theme) => ({ }, accessoryLabel: { flexShrink: 1, minWidth: 0 }, control: { flexShrink: 1, maxWidth: "100%" }, - value: { color: theme.colors.foreground, fontSize: theme.fontSize.base }, input: { minWidth: 180 }, })); diff --git a/packages/app/src/components/sidebar/sidebar-header-row.tsx b/packages/app/src/components/sidebar/sidebar-header-row.tsx index 22d48c2c4e..e30604ac9a 100644 --- a/packages/app/src/components/sidebar/sidebar-header-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-header-row.tsx @@ -24,7 +24,7 @@ interface SidebarHeaderRowProps { /** * "header" (default): a sidebar-height row with its own bottom separator — * the lone header at the top of a sidebar (settings "Back to workspace"). - * "compact": a workspace-row-height row with no separator, for entries that + * "compact": a row with no separator, for entries that * sit in a header group whose wrapper owns the single divider. */ variant?: SidebarHeaderRowVariant; @@ -52,10 +52,9 @@ export function SidebarHeaderRow({ const buttonStyle = useCallback( ({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.button, - variant === "compact" && styles.buttonCompact, (Boolean(hovered) || isActive) && styles.buttonHovered, ], - [isActive, variant], + [isActive], ); const renderChildren = useCallback( @@ -64,7 +63,7 @@ export function SidebarHeaderRow({ return ( <> @@ -74,7 +73,7 @@ export function SidebarHeaderRow({ ); }, - [ThemedIcon, isActive, label, shortcutKeys], + [ThemedIcon, isActive, label, shortcutKeys, variant], ); return ( @@ -129,21 +128,14 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", gap: theme.spacing[2], - // Match the sidebar workspace-row shape (height, padding, radius) so the - // compact header entries sit tight against the workspace list below. - minHeight: 36, - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - }, - // Compact header entries (New workspace / History) sit tighter than the - // workspace-row shape the base button mirrors. - buttonCompact: { - minHeight: 32, - paddingVertical: theme.spacing[1.5], + // Same row geometry as the settings sidebar items. Shorter than the header + // strip so the hover highlight clears the strip's bottom separator. + minHeight: 28, + paddingVertical: theme.spacing[1], // Match the project rows' inner padding so the icons align on one vertical - // edge with the workspace list below (base button uses a wider spacing[3]). + // edge with the list below. paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius.lg, }, buttonHovered: { backgroundColor: theme.colors.surfaceSidebarHover, diff --git a/packages/app/src/components/ui/control-geometry.ts b/packages/app/src/components/ui/control-geometry.ts index adbd8099cd..d56200887e 100644 --- a/packages/app/src/components/ui/control-geometry.ts +++ b/packages/app/src/components/ui/control-geometry.ts @@ -228,22 +228,22 @@ export function createControlGeometry(theme: Theme) { buttonXs: { minHeight: buttonControlHeight.xs, paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.md, + borderRadius: theme.borderRadius.xl, }, buttonSm: { minHeight: buttonControlHeight.sm, paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.md, + borderRadius: theme.borderRadius.xl, }, buttonMd: { minHeight: buttonControlHeight.md, paddingHorizontal: theme.spacing[4], - borderRadius: theme.borderRadius.lg, + borderRadius: theme.borderRadius["2xl"], }, buttonLg: { minHeight: buttonControlHeight.lg, paddingHorizontal: theme.spacing[6], - borderRadius: theme.borderRadius.xl, + borderRadius: theme.borderRadius["2xl"], }, buttonText: { fontSize: theme.fontSize.base, @@ -306,17 +306,17 @@ export function createControlGeometry(theme: Theme) { segmentedSegmentXs: { minHeight: CONTROL_HEIGHTS.tight - SEGMENTED_TIGHT_INSET * 2, paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.md, + borderRadius: theme.borderRadius.xl, }, segmentedSegmentSm: { minHeight: CONTROL_HEIGHTS.compact - SEGMENTED_COMPACT_INSET * 2, paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.md, + borderRadius: theme.borderRadius.xl, }, segmentedSegmentMd: { minHeight: CONTROL_HEIGHTS.field - SEGMENTED_FIELD_INSET * 2, paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.lg, + borderRadius: theme.borderRadius["2xl"], }, segmentedLabelXs: { fontSize: theme.fontSize.sm, diff --git a/packages/app/src/components/ui/dropdown-trigger.tsx b/packages/app/src/components/ui/dropdown-trigger.tsx index 8c88ca84a4..2fc1eb26ac 100644 --- a/packages/app/src/components/ui/dropdown-trigger.tsx +++ b/packages/app/src/components/ui/dropdown-trigger.tsx @@ -1,48 +1,104 @@ -import type { ReactElement, ReactNode } from "react"; -import { View } from "react-native"; +import { useCallback, useMemo, type ReactElement, type ReactNode } from "react"; +import { Text, View, type ViewStyle } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { ChevronDown } from "lucide-react-native"; -import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { + buttonIconSize, + createControlGeometry, + type ButtonControlSize, +} from "@/components/ui/control-geometry"; +import type { Theme } from "@/styles/theme"; import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from "@/components/ui/dropdown-menu"; const ThemedChevronDown = withUnistyles(ChevronDown); -interface DropdownTriggerProps extends Omit { - children?: ReactNode; - chevron?: ReactNode | null; -} - const chevronColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); +interface DropdownTriggerProps extends Omit { + /** The selected option's label. */ + children: string; + /** Icon or swatch before the label. */ + leading?: ReactNode; + size?: ButtonControlSize; +} + +/** + * A select-style menu trigger: the selected value plus a chevron, drawn with the + * outline Button's chrome and size scale so a dropdown and a button in the same + * row share one geometry. Callers pick a size; they don't style it. + */ export function DropdownTrigger({ children, - chevron, + leading, + size = "sm", + disabled, ...props }: DropdownTriggerProps): ReactElement { + let sizeStyle: ViewStyle; + if (size === "xs") { + sizeStyle = styles.xs; + } else if (size === "md") { + sizeStyle = styles.md; + } else if (size === "lg") { + sizeStyle = styles.lg; + } else { + sizeStyle = styles.sm; + } + const triggerStyle = useCallback( + ({ pressed }: { pressed: boolean }) => [ + styles.base, + sizeStyle, + pressed ? styles.pressed : null, + disabled ? styles.disabled : null, + ], + [disabled, sizeStyle], + ); + const labelStyle = useMemo(() => [styles.label, size === "xs" && styles.labelXs], [size]); + return ( - - + + {leading} + {children} - {chevron !== null && - (chevron ?? ( - - - - ))} + + + ); } -const styles = StyleSheet.create((theme) => ({ - row: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - }, - chevronContainer: { - transform: [{ translateY: 1 }], - }, -})); +const styles = StyleSheet.create((theme) => { + const geometry = createControlGeometry(theme); + return { + base: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + borderWidth: 1, + borderColor: theme.colors.borderAccent, + backgroundColor: "transparent", + }, + xs: geometry.buttonXs, + sm: geometry.buttonSm, + md: geometry.buttonMd, + lg: geometry.buttonLg, + pressed: { + opacity: 0.85, + }, + disabled: { + opacity: theme.opacity[50], + }, + label: { + flexShrink: 1, + color: theme.colors.foreground, + ...geometry.buttonText, + }, + labelXs: geometry.buttonTextXs, + chevron: { + transform: [{ translateY: 1 }], + }, + }; +}); diff --git a/packages/app/src/components/ui/status-badge.browser.test.tsx b/packages/app/src/components/ui/status-badge.browser.test.tsx index a47b5036e5..858ded8173 100644 --- a/packages/app/src/components/ui/status-badge.browser.test.tsx +++ b/packages/app/src/components/ui/status-badge.browser.test.tsx @@ -32,15 +32,32 @@ afterEach(() => { } }); +function parseColor(value: string): { rgb: string; alpha: number } { + const channels = value.match(/[\d.]+/g)?.map(Number) ?? []; + return { rgb: channels.slice(0, 3).join(","), alpha: channels[3] ?? 1 }; +} + describe("StatusBadge", () => { - it.each(["success", "warning", "error", "muted"] as const)( - "uses the semantic badge shell for the %s variant", + it("uses the neutral badge shell for the muted variant", () => { + const style = getComputedStyle(mountBadge("muted")); + + expect(style.backgroundColor).toBe("rgb(228, 228, 231)"); + expect(style.borderColor).toBe("rgb(228, 228, 231)"); + }); + + it.each(["success", "warning", "error"] as const)( + "fills the %s variant with a translucent tint of its own status color", (variant) => { const badge = mountBadge(variant); - const style = getComputedStyle(badge); + const text = badge.lastElementChild; + if (!(text instanceof HTMLElement)) { + throw new Error("StatusBadge did not render its label"); + } + const fill = parseColor(getComputedStyle(badge).backgroundColor); - expect(style.backgroundColor).toBe("rgb(228, 228, 231)"); - expect(style.borderColor).toBe("rgb(228, 228, 231)"); + expect(fill.rgb).toBe(parseColor(getComputedStyle(text).color).rgb); + expect(fill.alpha).toBeGreaterThan(0); + expect(fill.alpha).toBeLessThan(0.5); }, ); diff --git a/packages/app/src/components/ui/status-badge.tsx b/packages/app/src/components/ui/status-badge.tsx index 28035d9294..f2330e1b70 100644 --- a/packages/app/src/components/ui/status-badge.tsx +++ b/packages/app/src/components/ui/status-badge.tsx @@ -11,6 +11,15 @@ interface StatusBadgeProps { } export function StatusBadge({ label, variant = "muted", leading }: StatusBadgeProps) { + const pillStyle = useMemo( + () => [ + styles.pill, + variant === "success" && styles.pillSuccess, + variant === "warning" && styles.pillWarning, + variant === "error" && styles.pillError, + ], + [variant], + ); const textStyle = useMemo( () => [ styles.pillText, @@ -22,7 +31,7 @@ export function StatusBadge({ label, variant = "muted", leading }: StatusBadgePr ); return ( - + {leading} {label} @@ -41,6 +50,18 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[2], paddingVertical: 3, }, + pillSuccess: { + backgroundColor: theme.colors.statusSuccessTint, + borderColor: "transparent", + }, + pillWarning: { + backgroundColor: theme.colors.statusWarningTint, + borderColor: "transparent", + }, + pillError: { + backgroundColor: theme.colors.statusDangerTint, + borderColor: "transparent", + }, pillText: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, diff --git a/packages/app/src/desktop/browser/settings/browser-data-section.tsx b/packages/app/src/desktop/browser/settings/browser-data-section.tsx index 743bd2a737..ad4140d683 100644 --- a/packages/app/src/desktop/browser/settings/browser-data-section.tsx +++ b/packages/app/src/desktop/browser/settings/browser-data-section.tsx @@ -65,7 +65,7 @@ export function BrowserDataSection() {