diff --git a/apps/web/app/settings/general/appearance/page.tsx b/apps/web/app/settings/general/appearance/page.tsx new file mode 100644 index 0000000000..b5a8a97686 --- /dev/null +++ b/apps/web/app/settings/general/appearance/page.tsx @@ -0,0 +1,13 @@ +import { AppearanceSettings } from "@/components/settings/general-settings"; +import { StateProvider } from "@/components/state-provider"; +import { getUserSettingsInitialState } from "../user-settings-state"; + +export default async function GeneralAppearancePage() { + const initialState = await getUserSettingsInitialState(); + + return ( + + + + ); +} diff --git a/apps/web/app/settings/general/changes-panel/page.tsx b/apps/web/app/settings/general/changes-panel/page.tsx new file mode 100644 index 0000000000..94c190e169 --- /dev/null +++ b/apps/web/app/settings/general/changes-panel/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function GeneralChangesPanelPage() { + redirect("/settings/general/appearance"); +} diff --git a/apps/web/app/settings/general/chat-input/page.tsx b/apps/web/app/settings/general/chat-input/page.tsx new file mode 100644 index 0000000000..6ee441fe42 --- /dev/null +++ b/apps/web/app/settings/general/chat-input/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function GeneralChatInputPage() { + redirect("/settings/general/keyboard-shortcuts"); +} diff --git a/apps/web/app/settings/general/keyboard-shortcuts/page.tsx b/apps/web/app/settings/general/keyboard-shortcuts/page.tsx new file mode 100644 index 0000000000..e3f9e50bfa --- /dev/null +++ b/apps/web/app/settings/general/keyboard-shortcuts/page.tsx @@ -0,0 +1,13 @@ +import { KeyboardShortcutsSettings } from "@/components/settings/general-settings"; +import { StateProvider } from "@/components/state-provider"; +import { getUserSettingsInitialState } from "../user-settings-state"; + +export default async function GeneralKeyboardShortcutsPage() { + const initialState = await getUserSettingsInitialState(); + + return ( + + + + ); +} diff --git a/apps/web/app/settings/general/page.tsx b/apps/web/app/settings/general/page.tsx index ff252c33b4..70770bf3d4 100644 --- a/apps/web/app/settings/general/page.tsx +++ b/apps/web/app/settings/general/page.tsx @@ -1,21 +1,5 @@ import { GeneralSettings } from "@/components/settings/general-settings"; -import { StateProvider } from "@/components/state-provider"; -import { fetchUserSettings } from "@/lib/api"; -import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; -export default async function GeneralSettingsPage() { - let initialState = {}; - try { - const response = await fetchUserSettings({ cache: "no-store" }); - const mapped = mapUserSettingsResponse(response); - initialState = { userSettings: mapped.loaded ? mapped : undefined }; - } catch { - initialState = {}; - } - - return ( - - - - ); +export default function GeneralSettingsPage() { + return ; } diff --git a/apps/web/app/settings/general/resource-metrics/page.tsx b/apps/web/app/settings/general/resource-metrics/page.tsx new file mode 100644 index 0000000000..4bf6da688b --- /dev/null +++ b/apps/web/app/settings/general/resource-metrics/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function GeneralResourceMetricsPage() { + redirect("/settings/general/appearance"); +} diff --git a/apps/web/app/settings/general/shell/page.tsx b/apps/web/app/settings/general/shell/page.tsx new file mode 100644 index 0000000000..66d1dfd54c --- /dev/null +++ b/apps/web/app/settings/general/shell/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function GeneralShellPage() { + redirect("/settings/general/terminal"); +} diff --git a/apps/web/app/settings/general/terminal/page.tsx b/apps/web/app/settings/general/terminal/page.tsx new file mode 100644 index 0000000000..abec4dda6e --- /dev/null +++ b/apps/web/app/settings/general/terminal/page.tsx @@ -0,0 +1,13 @@ +import { TerminalSettings } from "@/components/settings/terminal-settings"; +import { StateProvider } from "@/components/state-provider"; +import { getUserSettingsInitialState } from "../user-settings-state"; + +export default async function GeneralTerminalPage() { + const initialState = await getUserSettingsInitialState(); + + return ( + + + + ); +} diff --git a/apps/web/app/settings/general/user-settings-state.ts b/apps/web/app/settings/general/user-settings-state.ts new file mode 100644 index 0000000000..91e0c30aea --- /dev/null +++ b/apps/web/app/settings/general/user-settings-state.ts @@ -0,0 +1,12 @@ +import { fetchUserSettings } from "@/lib/api"; +import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; + +export async function getUserSettingsInitialState() { + try { + const response = await fetchUserSettings({ cache: "no-store" }); + const mapped = mapUserSettingsResponse(response); + return { userSettings: mapped.loaded ? mapped : undefined }; + } catch { + return {}; + } +} diff --git a/apps/web/components/app-sidebar/sections/settings/general-group.tsx b/apps/web/components/app-sidebar/sections/settings/general-group.tsx index ac0bad8e37..469c3b6c86 100644 --- a/apps/web/components/app-sidebar/sections/settings/general-group.tsx +++ b/apps/web/components/app-sidebar/sections/settings/general-group.tsx @@ -1,11 +1,10 @@ "use client"; -import { IconBell, IconCode, IconSettings } from "@tabler/icons-react"; +import { IconSettings } from "@tabler/icons-react"; +import { GENERAL_NAV_ITEMS } from "@/components/settings/general-nav"; import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives"; const GENERAL_HREF = "/settings/general"; -const NOTIFICATIONS_HREF = "/settings/general/notifications"; -const EDITORS_HREF = "/settings/general/editors"; type GeneralGroupProps = { pathname: string; @@ -23,20 +22,16 @@ export function GeneralGroup({ pathname, expanded, onToggle }: GeneralGroupProps expanded={expanded} onToggle={onToggle} > - - + {GENERAL_NAV_ITEMS.map(({ href, label, icon }) => ( + + ))} ); } diff --git a/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx b/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx index e685bd2ece..4950ca3b69 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx +++ b/apps/web/components/app-sidebar/sections/settings/settings-nav-primitives.tsx @@ -24,6 +24,8 @@ type SettingsLeafProps = { const LEAF_DEPTH_PADDING = ["px-2.5", "pl-7 pr-2.5", "pl-10 pr-2.5"] as const; const GROUP_DEPTH_PADDING = ["pl-2.5 pr-1", "pl-7 pr-1", "pl-10 pr-1"] as const; +const NAV_FOCUS_CLASS = + "outline-none focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:ring-offset-1"; function clampDepth(depth: number, max: number): number { if (depth < 0) return 0; @@ -44,6 +46,7 @@ export function SettingsLeaf({ href={href} className={cn( "flex items-center gap-2 py-1.5 text-[13px] font-medium rounded-md cursor-pointer", + NAV_FOCUS_CLASS, LEAF_DEPTH_PADDING[clampDepth(depth, LEAF_DEPTH_PADDING.length - 1)], isActive ? ACTIVE_CLASS : INACTIVE_CLASS, )} @@ -112,7 +115,10 @@ export function SettingsGroup({ {href ? ( {labelInner} @@ -120,7 +126,10 @@ export function SettingsGroup({ @@ -130,7 +139,10 @@ export function SettingsGroup({ onClick={toggle} aria-label={expanded ? `Collapse ${label}` : `Expand ${label}`} aria-expanded={expanded} - className="shrink-0 flex h-5 w-5 items-center justify-center text-muted-foreground/60 hover:text-foreground/80 cursor-pointer transition-colors" + className={cn( + "shrink-0 flex h-5 w-5 items-center justify-center text-muted-foreground/60 hover:text-foreground/80 cursor-pointer transition-colors", + NAV_FOCUS_CLASS, + )} > { expect(settingsGroupIdForPath("/settings/workspace/ws-1/repositories")).toBe("workspaces"); expect(settingsGroupIdForPath("/settings/system/logs")).toBe("system"); expect(settingsGroupIdForPath("/settings/system/feature-toggles")).toBe("system"); - // Editors/Secrets live under /settings/general so they belong to General. + // General subpages stay under /settings/general so they belong to General. + expect(settingsGroupIdForPath("/settings/general/appearance")).toBe("general"); + expect(settingsGroupIdForPath("/settings/general/terminal")).toBe("general"); + expect(settingsGroupIdForPath("/settings/general/keyboard-shortcuts")).toBe("general"); expect(settingsGroupIdForPath("/settings/general/editors")).toBe("general"); }); diff --git a/apps/web/components/settings/general-nav.ts b/apps/web/components/settings/general-nav.ts new file mode 100644 index 0000000000..6a20d0f60c --- /dev/null +++ b/apps/web/components/settings/general-nav.ts @@ -0,0 +1,42 @@ +import { IconBell, IconCommand, IconCode, IconPalette, IconTerminal2 } from "@tabler/icons-react"; +import type { Icon as TablerIcon } from "@tabler/icons-react"; + +export type GeneralNavItem = { + href: string; + label: string; + description: string; + icon: TablerIcon; +}; + +export const GENERAL_NAV_ITEMS: GeneralNavItem[] = [ + { + href: "/settings/general/appearance", + label: "Appearance", + description: "Theme, metrics, and changes panel preferences", + icon: IconPalette, + }, + { + href: "/settings/general/terminal", + label: "Terminal", + description: "Shell, terminal fonts, and link behavior", + icon: IconTerminal2, + }, + { + href: "/settings/general/notifications", + label: "Notifications", + description: "Providers and notification events", + icon: IconBell, + }, + { + href: "/settings/general/editors", + label: "Editors", + description: "Editor integrations and defaults", + icon: IconCode, + }, + { + href: "/settings/general/keyboard-shortcuts", + label: "Keyboard Shortcuts", + description: "Chat input and command shortcuts", + icon: IconCommand, + }, +]; diff --git a/apps/web/components/settings/general-settings.tsx b/apps/web/components/settings/general-settings.tsx index bea34b80e4..37fb5f9d8f 100644 --- a/apps/web/components/settings/general-settings.tsx +++ b/apps/web/components/settings/general-settings.tsx @@ -1,40 +1,25 @@ "use client"; import { useState } from "react"; +import Link from "next/link"; import { useTheme } from "next-themes"; import { + IconActivity, IconCommand, IconPalette, - IconServer, IconKeyboard, - IconTerminal2, IconGitBranch, - IconActivity, } from "@tabler/icons-react"; -import { Badge } from "@kandev/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Label } from "@kandev/ui/label"; -import { Input } from "@kandev/ui/input"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectSeparator, - SelectTrigger, - SelectValue, -} from "@kandev/ui/select"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { Separator } from "@kandev/ui/separator"; import { SettingsSection } from "@/components/settings/settings-section"; -import { ShellSettingsCard } from "@/components/settings/shell-settings-card"; import { KeyboardShortcutsCard } from "@/components/settings/keyboard-shortcuts-card"; import { SystemMetricsSettingsCard } from "@/components/settings/system-metrics-settings-card"; -import { getBackendConfig } from "@/lib/config"; +import { GENERAL_NAV_ITEMS } from "@/components/settings/general-nav"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { updateUserSettings } from "@/lib/api"; -import { TERMINAL_FONT_PRESETS } from "@/lib/terminal/terminal-font"; -import type { FontCategory } from "@/lib/terminal/terminal-font"; import type { Theme } from "@/lib/settings/types"; function ThemeSettingsCard() { @@ -178,304 +163,33 @@ function ChangesPanelLayoutCard() { ); } -function TerminalLinksCard() { - const userSettings = useAppStore((state) => state.userSettings); - const setUserSettings = useAppStore((state) => state.setUserSettings); - const storeApi = useAppStoreApi(); - const [isSaving, setIsSaving] = useState(false); - - const handleChange = async (value: "new_tab" | "browser_panel") => { - if (isSaving) return; - setIsSaving(true); - const current = storeApi.getState().userSettings; - const previous = current.terminalLinkBehavior; - try { - setUserSettings({ ...current, terminalLinkBehavior: value }); - await updateUserSettings({ - workspace_id: current.workspaceId || "", - repository_ids: current.repositoryIds || [], - terminal_link_behavior: value, - }); - } catch { - setUserSettings({ - ...storeApi.getState().userSettings, - terminalLinkBehavior: previous, - }); - } finally { - setIsSaving(false); - } - }; - - return ( - - - Terminal Links - - -
- - -

Click a URL in the terminal to open it.

-
-
-
- ); -} - -const CUSTOM_VALUE = "__custom__"; -const CATEGORY_LABELS: Record = { - icons: "Nerd Fonts", - ligatures: "Programming", - system: "System", -}; -const CATEGORY_BADGES: Partial> = { - icons: "Icons", - ligatures: "Ligatures", -}; -const FONT_GROUPS: Record = TERMINAL_FONT_PRESETS.reduce( - (acc, p) => { - (acc[p.category] ??= []).push(p); - return acc; - }, - {} as Record, -); -const FONT_CATEGORIES: FontCategory[] = ["icons", "ligatures", "system"]; - -function FontGroupOptions() { - return FONT_CATEGORIES.map((category) => ( - - - {CATEGORY_LABELS[category]} - {CATEGORY_BADGES[category] && ( - - {CATEGORY_BADGES[category]} - - )} - - {(FONT_GROUPS[category] ?? []).map((preset) => ( - - {preset.label} - - ))} - - )); -} - -function TerminalFontSizeCard() { - const storeApi = useAppStoreApi(); - const userSettings = useAppStore((state) => state.userSettings); - const setUserSettings = useAppStore((state) => state.setUserSettings); - const [isSaving, setIsSaving] = useState(false); - const [fontSize, setFontSize] = useState(() => userSettings.terminalFontSize ?? 13); - - const saveFontSize = async (value: number) => { - if (isSaving) return; - if (value < 8 || value > 24) return; - setIsSaving(true); - const current = storeApi.getState().userSettings; - const previous = current.terminalFontSize; - try { - setUserSettings({ ...current, terminalFontSize: value }); - await updateUserSettings({ - workspace_id: current.workspaceId || "", - repository_ids: current.repositoryIds || [], - terminal_font_size: value, - }); - } catch { - setUserSettings({ ...storeApi.getState().userSettings, terminalFontSize: previous }); - } finally { - setIsSaving(false); - } - }; - - const handleFontSizeBlur = () => { - const v = Math.min(24, Math.max(8, fontSize)); - setFontSize(v); - saveFontSize(v); - }; - - return ( - - - Terminal Font Size - - -
- -
- setFontSize(Number(e.target.value))} - onBlur={handleFontSizeBlur} - onKeyDown={(e) => { - if (e.key === "Enter") handleFontSizeBlur(); - }} - className="w-20" - disabled={isSaving} - data-testid="terminal-font-size-input" - /> - px (8-24) -
-

- Set the font size for the terminal. Default is 13px. -

-
-
-
- ); -} - -function TerminalFontCard() { - const storeApi = useAppStoreApi(); - const userSettings = useAppStore((state) => state.userSettings); - const setUserSettings = useAppStore((state) => state.setUserSettings); - const [isSaving, setIsSaving] = useState(false); - const [isCustom, setIsCustom] = useState(() => { - const current = userSettings.terminalFontFamily; - if (!current) return false; - return !TERMINAL_FONT_PRESETS.some((p) => p.value === current); - }); - const [customValue, setCustomValue] = useState( - () => (isCustom ? userSettings.terminalFontFamily : "") ?? "", - ); - - const saveFontFamily = async (value: string) => { - if (isSaving) return; - setIsSaving(true); - const current = storeApi.getState().userSettings; - const previous = current.terminalFontFamily; - try { - setUserSettings({ ...current, terminalFontFamily: value || null }); - await updateUserSettings({ - workspace_id: current.workspaceId || "", - repository_ids: current.repositoryIds || [], - terminal_font_family: value, - }); - } catch { - setUserSettings({ - ...storeApi.getState().userSettings, - terminalFontFamily: previous, - }); - } finally { - setIsSaving(false); - } - }; - - const handleSelectChange = (value: string) => { - if (value === CUSTOM_VALUE) { - setIsCustom(true); - return; - } - setIsCustom(false); - setCustomValue(""); - saveFontFamily(value === "default" ? "" : value); - }; - - const handleCustomBlur = () => { - const trimmed = customValue.trim(); - if (trimmed) saveFontFamily(trimmed); - }; - - const selectValue = isCustom ? CUSTOM_VALUE : userSettings.terminalFontFamily || "default"; - - return ( - - - Terminal Font - - -
- - - {isCustom && ( - setCustomValue(e.target.value)} - onBlur={handleCustomBlur} - onKeyDown={(e) => { - if (e.key === "Enter") handleCustomBlur(); - }} - data-testid="terminal-font-custom-input" - /> - )} -

- Choose a monospace font for the terminal. Nerd Fonts include icons for CLI tools. -

-
-
-
- ); -} - -function BackendConnectionCard() { - const [backendUrl] = useState(() => getBackendConfig().apiBaseUrl); - const displayBackendUrl = backendUrl.replace(/^https?:\/\//, "").replace(/\/$/, ""); - - return ( - - - Backend Server URL - - -
- - -

- Backend URL is provided at runtime for SSR and WebSocket connections. -

-
-
-
- ); -} - export function GeneralSettings() { return (
-
-

General Settings

-

- Manage your application preferences and notifications -

+
+ {GENERAL_NAV_ITEMS.map(({ href, label, description, icon: Icon }) => ( + + + + + + {label} + + + +

{description}

+
+
+ + ))}
+
+ ); +} - - +export function AppearanceSettings() { + return ( +
} title="Appearance" @@ -486,42 +200,6 @@ export function GeneralSettings() { - - - - - } - title="Terminal" - description="Configure terminal appearance and behavior" - > - - - - - - - - } - title="Resource Metrics" - description="Configure backend and execution resource sampling" - > - - - - - - } - title="Chat Input" - description="Configure chat input behavior" - > - - - - - } title="Changes Panel" @@ -532,6 +210,30 @@ export function GeneralSettings() { + } + title="Resource Metrics" + description="Configure backend and execution resource sampling" + > + + +
+ ); +} + +export function KeyboardShortcutsSettings() { + return ( +
+ } + title="Chat Input" + description="Configure chat input behavior" + > + + + + + } title="Keyboard Shortcuts" @@ -539,16 +241,6 @@ export function GeneralSettings() { > - - - - } - title="Backend Connection" - description="Configure the backend server URL" - > - -
); } diff --git a/apps/web/components/settings/settings-layout-client.tsx b/apps/web/components/settings/settings-layout-client.tsx index 4e8ef689ec..ce3048a3b4 100644 --- a/apps/web/components/settings/settings-layout-client.tsx +++ b/apps/web/components/settings/settings-layout-client.tsx @@ -112,13 +112,13 @@ function SettingsShell({ backHref={backHref} backLabel={backLabel} parents={parents} - className="h-16 border-b-0" + className="h-10" /> {/* Scroll the content, not the topbar: min-h-0 lets this flex child shrink below its content height so overflow-y-auto can take effect. */}
{children}
diff --git a/apps/web/components/settings/terminal-settings.test.ts b/apps/web/components/settings/terminal-settings.test.ts new file mode 100644 index 0000000000..7c178135f2 --- /dev/null +++ b/apps/web/components/settings/terminal-settings.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { normalizeTerminalFontSize } from "./terminal-settings"; + +describe("normalizeTerminalFontSize", () => { + it("clamps finite font sizes to the supported range", () => { + expect(normalizeTerminalFontSize(6, 13)).toBe(8); + expect(normalizeTerminalFontSize(18, 13)).toBe(18); + expect(normalizeTerminalFontSize(30, 13)).toBe(24); + }); + + it("uses the fallback when the input is not finite", () => { + expect(normalizeTerminalFontSize(Number.NaN, 15)).toBe(15); + expect(normalizeTerminalFontSize(Number.POSITIVE_INFINITY, 15)).toBe(15); + }); +}); diff --git a/apps/web/components/settings/terminal-settings.tsx b/apps/web/components/settings/terminal-settings.tsx new file mode 100644 index 0000000000..0ce6035d23 --- /dev/null +++ b/apps/web/components/settings/terminal-settings.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { useState } from "react"; +import { IconTerminal2 } from "@tabler/icons-react"; +import { Badge } from "@kandev/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; +import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, + SelectValue, +} from "@kandev/ui/select"; +import { Separator } from "@kandev/ui/separator"; +import { SettingsSection } from "@/components/settings/settings-section"; +import { ShellSettingsCard } from "@/components/settings/shell-settings-card"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { updateUserSettings } from "@/lib/api"; +import { TERMINAL_FONT_PRESETS } from "@/lib/terminal/terminal-font"; +import type { FontCategory } from "@/lib/terminal/terminal-font"; + +const CUSTOM_VALUE = "__custom__"; +const CATEGORY_LABELS: Record = { + icons: "Nerd Fonts", + ligatures: "Programming", + system: "System", +}; +const CATEGORY_BADGES: Partial> = { + icons: "Icons", + ligatures: "Ligatures", +}; +const FONT_GROUPS: Record = TERMINAL_FONT_PRESETS.reduce( + (acc, p) => { + (acc[p.category] ??= []).push(p); + return acc; + }, + {} as Record, +); +const FONT_CATEGORIES: FontCategory[] = ["icons", "ligatures", "system"]; + +export function normalizeTerminalFontSize(value: number, fallback: number): number { + const base = Number.isFinite(value) ? value : fallback; + return Math.min(24, Math.max(8, base)); +} + +function FontGroupOptions() { + return FONT_CATEGORIES.map((category) => ( + + + {CATEGORY_LABELS[category]} + {CATEGORY_BADGES[category] && ( + + {CATEGORY_BADGES[category]} + + )} + + {(FONT_GROUPS[category] ?? []).map((preset) => ( + + {preset.label} + + ))} + + )); +} + +function TerminalFontSizeCard() { + const storeApi = useAppStoreApi(); + const userSettings = useAppStore((state) => state.userSettings); + const setUserSettings = useAppStore((state) => state.setUserSettings); + const [isSaving, setIsSaving] = useState(false); + const [fontSize, setFontSize] = useState(() => userSettings.terminalFontSize ?? 13); + + const saveFontSize = async (value: number) => { + if (isSaving) return; + if (!Number.isFinite(value)) return; + if (value < 8 || value > 24) return; + setIsSaving(true); + const current = storeApi.getState().userSettings; + const previous = current.terminalFontSize; + try { + setUserSettings({ ...current, terminalFontSize: value }); + await updateUserSettings({ + workspace_id: current.workspaceId || "", + repository_ids: current.repositoryIds || [], + terminal_font_size: value, + }); + } catch { + const latest = storeApi.getState().userSettings; + if (latest.workspaceId === current.workspaceId && latest.terminalFontSize === value) { + setUserSettings({ ...latest, terminalFontSize: previous }); + } + } finally { + setIsSaving(false); + } + }; + + const handleFontSizeBlur = () => { + const value = normalizeTerminalFontSize(fontSize, userSettings.terminalFontSize ?? 13); + setFontSize(value); + void saveFontSize(value); + }; + + return ( + + + Terminal Font Size + + +
+ +
+ setFontSize(Number(e.target.value))} + onBlur={handleFontSizeBlur} + onKeyDown={(e) => { + if (e.key === "Enter") handleFontSizeBlur(); + }} + className="w-20" + disabled={isSaving} + data-testid="terminal-font-size-input" + /> + px (8-24) +
+

+ Set the font size for the terminal. Default is 13px. +

+
+
+
+ ); +} + +function TerminalFontCard() { + const storeApi = useAppStoreApi(); + const userSettings = useAppStore((state) => state.userSettings); + const setUserSettings = useAppStore((state) => state.setUserSettings); + const [isSaving, setIsSaving] = useState(false); + const [isCustom, setIsCustom] = useState(() => { + const current = userSettings.terminalFontFamily; + if (!current) return false; + return !TERMINAL_FONT_PRESETS.some((p) => p.value === current); + }); + const [customValue, setCustomValue] = useState( + () => (isCustom ? userSettings.terminalFontFamily : "") ?? "", + ); + + const saveFontFamily = async (value: string) => { + if (isSaving) return; + setIsSaving(true); + const current = storeApi.getState().userSettings; + const previous = current.terminalFontFamily; + const nextValue = value || null; + try { + setUserSettings({ ...current, terminalFontFamily: nextValue }); + await updateUserSettings({ + workspace_id: current.workspaceId || "", + repository_ids: current.repositoryIds || [], + terminal_font_family: value, + }); + } catch { + const latest = storeApi.getState().userSettings; + if (latest.workspaceId === current.workspaceId && latest.terminalFontFamily === nextValue) { + setUserSettings({ ...latest, terminalFontFamily: previous }); + } + } finally { + setIsSaving(false); + } + }; + + const handleSelectChange = (value: string) => { + if (value === CUSTOM_VALUE) { + setIsCustom(true); + return; + } + setIsCustom(false); + setCustomValue(""); + void saveFontFamily(value === "default" ? "" : value); + }; + + const handleCustomBlur = () => { + const trimmed = customValue.trim(); + if (trimmed) void saveFontFamily(trimmed); + }; + + const selectValue = isCustom ? CUSTOM_VALUE : userSettings.terminalFontFamily || "default"; + + return ( + + + Terminal Font + + +
+ + + {isCustom && ( + setCustomValue(e.target.value)} + onBlur={handleCustomBlur} + onKeyDown={(e) => { + if (e.key === "Enter") handleCustomBlur(); + }} + data-testid="terminal-font-custom-input" + /> + )} +

+ Choose a monospace font for the terminal. Nerd Fonts include icons for CLI tools. +

+
+
+
+ ); +} + +function TerminalLinksCard() { + const userSettings = useAppStore((state) => state.userSettings); + const setUserSettings = useAppStore((state) => state.setUserSettings); + const storeApi = useAppStoreApi(); + const [isSaving, setIsSaving] = useState(false); + + const handleChange = async (value: "new_tab" | "browser_panel") => { + if (isSaving) return; + setIsSaving(true); + const current = storeApi.getState().userSettings; + const previous = current.terminalLinkBehavior; + try { + setUserSettings({ ...current, terminalLinkBehavior: value }); + await updateUserSettings({ + workspace_id: current.workspaceId || "", + repository_ids: current.repositoryIds || [], + terminal_link_behavior: value, + }); + } catch { + const latest = storeApi.getState().userSettings; + if (latest.workspaceId === current.workspaceId && latest.terminalLinkBehavior === value) { + setUserSettings({ ...latest, terminalLinkBehavior: previous }); + } + } finally { + setIsSaving(false); + } + }; + + return ( + + + Terminal Links + + +
+ + +

Click a URL in the terminal to open it.

+
+
+
+ ); +} + +export function TerminalSettings() { + return ( +
+ + + + + } + title="Terminal" + description="Configure terminal appearance and behavior" + > + + + + +
+ ); +} diff --git a/apps/web/e2e/tests/settings/keyboard-shortcuts.spec.ts b/apps/web/e2e/tests/settings/keyboard-shortcuts.spec.ts index d32203a41b..47ba7c9450 100644 --- a/apps/web/e2e/tests/settings/keyboard-shortcuts.spec.ts +++ b/apps/web/e2e/tests/settings/keyboard-shortcuts.spec.ts @@ -1,13 +1,17 @@ import { test, expect } from "../../fixtures/test-base"; +const KEYBOARD_SETTINGS_PATH = "/settings/general/keyboard-shortcuts"; + test.describe("Keyboard Shortcuts Settings", () => { test.describe.configure({ retries: 1 }); test("settings page shows all configurable shortcuts", async ({ testPage }) => { - await testPage.goto("/settings/general"); + await testPage.goto(KEYBOARD_SETTINGS_PATH); + + await expect(testPage.locator("#chat-submit-key")).toBeVisible({ timeout: 10_000 }); // Original 3 shortcuts - await expect(testPage.getByTestId("shortcut-recorder-SEARCH")).toBeVisible({ timeout: 10_000 }); + await expect(testPage.getByTestId("shortcut-recorder-SEARCH")).toBeVisible(); await expect(testPage.getByTestId("shortcut-recorder-FILE_SEARCH")).toBeVisible(); await expect(testPage.getByTestId("shortcut-recorder-QUICK_CHAT")).toBeVisible(); @@ -30,7 +34,7 @@ test.describe("Keyboard Shortcuts Settings", () => { keyboard_shortcuts: {}, }); - await testPage.goto("/settings/general"); + await testPage.goto(KEYBOARD_SETTINGS_PATH); // Find the BOTTOM_TERMINAL recorder and verify it shows the default (Cmd+J or Ctrl+J) const recorder = testPage.getByTestId("shortcut-recorder-BOTTOM_TERMINAL"); @@ -49,7 +53,7 @@ test.describe("Keyboard Shortcuts Settings", () => { await expect(recorder).toContainText("T", { timeout: 3_000 }); // Reload the page and verify the shortcut persisted - await testPage.goto("/settings/general"); + await testPage.goto(KEYBOARD_SETTINGS_PATH); const recorderAfterReload = testPage.getByTestId("shortcut-recorder-BOTTOM_TERMINAL"); await expect(recorderAfterReload).toBeVisible({ timeout: 10_000 }); await expect(recorderAfterReload).toContainText("T"); @@ -68,7 +72,7 @@ test.describe("Keyboard Shortcuts Settings", () => { }, }); - await testPage.goto("/settings/general"); + await testPage.goto(KEYBOARD_SETTINGS_PATH); const recorder = testPage.getByTestId("shortcut-recorder-TOGGLE_SIDEBAR"); await expect(recorder).toBeVisible({ timeout: 10_000 }); diff --git a/apps/web/e2e/tests/settings/mobile-general-settings.spec.ts b/apps/web/e2e/tests/settings/mobile-general-settings.spec.ts new file mode 100644 index 0000000000..a4a9597f13 --- /dev/null +++ b/apps/web/e2e/tests/settings/mobile-general-settings.spec.ts @@ -0,0 +1,18 @@ +import { test, expect } from "../../fixtures/test-base"; + +test.describe("Mobile general settings", () => { + test("opens a dedicated General settings page from the overview", async ({ testPage }) => { + await testPage.goto("/settings/general"); + + await expect(testPage.getByRole("link", { name: /Terminal/ })).toBeVisible({ + timeout: 15_000, + }); + + await testPage.getByRole("link", { name: /Terminal/ }).click(); + + await expect(testPage).toHaveURL(/\/settings\/general\/terminal$/); + await expect(testPage.getByRole("heading", { name: "Terminal", exact: true })).toBeVisible(); + await expect(testPage.getByTestId("terminal-font-select")).toBeVisible(); + await expect(testPage.getByTestId("terminal-font-size-input")).toBeVisible(); + }); +}); diff --git a/apps/web/e2e/tests/terminal/terminal-settings.spec.ts b/apps/web/e2e/tests/terminal/terminal-settings.spec.ts index f8b1a99ba4..43f035be22 100644 --- a/apps/web/e2e/tests/terminal/terminal-settings.spec.ts +++ b/apps/web/e2e/tests/terminal/terminal-settings.spec.ts @@ -4,6 +4,8 @@ import type { SeedData } from "../../fixtures/test-base"; import type { ApiClient } from "../../helpers/api-client"; import { SessionPage } from "../../pages/session-page"; +const TERMINAL_SETTINGS_PATH = "/settings/general/terminal"; + // The full font stack value for "JetBrains Mono" preset const JB_MONO_STACK = '"JetBrains Mono", "Fira Code", Menlo, Consolas, monospace'; @@ -116,11 +118,13 @@ test.describe("Terminal font settings", () => { }); test("settings page shows font and size controls", async ({ testPage }) => { - await testPage.goto("/settings/general"); + await testPage.goto(TERMINAL_SETTINGS_PATH); + + await expect(testPage.getByText("Preferred Shell")).toBeVisible({ timeout: 10_000 }); // Font family selector const fontSelect = testPage.getByTestId("terminal-font-select"); - await expect(fontSelect).toBeVisible({ timeout: 10_000 }); + await expect(fontSelect).toBeVisible(); await fontSelect.click(); // Verify a preset is listed (exact match to avoid matching Nerd Font variant) @@ -172,7 +176,7 @@ test.describe("Terminal link settings", () => { }); test("settings page shows terminal links card and allows toggling", async ({ testPage }) => { - await testPage.goto("/settings"); + await testPage.goto(TERMINAL_SETTINGS_PATH); // Terminal Links section visible await expect(testPage.locator("text=Terminal Links").first()).toBeVisible({ timeout: 10_000 });