feat: split general settings pages (#1383)

This commit is contained in:
Carlos Florêncio
2026-06-15 21:13:04 +01:00
committed by GitHub
parent 92ed76b438
commit 1f4aecc7d5
20 changed files with 558 additions and 407 deletions
@@ -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 (
<StateProvider initialState={initialState}>
<AppearanceSettings />
</StateProvider>
);
}
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function GeneralChangesPanelPage() {
redirect("/settings/general/appearance");
}
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function GeneralChatInputPage() {
redirect("/settings/general/keyboard-shortcuts");
}
@@ -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 (
<StateProvider initialState={initialState}>
<KeyboardShortcutsSettings />
</StateProvider>
);
}
+2 -18
View File
@@ -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 (
<StateProvider initialState={initialState}>
<GeneralSettings />
</StateProvider>
);
export default function GeneralSettingsPage() {
return <GeneralSettings />;
}
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function GeneralResourceMetricsPage() {
redirect("/settings/general/appearance");
}
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function GeneralShellPage() {
redirect("/settings/general/terminal");
}
@@ -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 (
<StateProvider initialState={initialState}>
<TerminalSettings />
</StateProvider>
);
}
@@ -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 {};
}
}
@@ -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}
>
<SettingsLeaf
href={NOTIFICATIONS_HREF}
label="Notifications"
icon={IconBell}
isActive={pathname === NOTIFICATIONS_HREF}
depth={1}
/>
<SettingsLeaf
href={EDITORS_HREF}
label="Editors"
icon={IconCode}
isActive={pathname === EDITORS_HREF}
depth={1}
/>
{GENERAL_NAV_ITEMS.map(({ href, label, icon }) => (
<SettingsLeaf
key={href}
href={href}
label={label}
icon={icon}
isActive={pathname === href}
depth={1}
/>
))}
</SettingsGroup>
);
}
@@ -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 ? (
<Link
href={href}
className="flex flex-1 min-w-0 items-center gap-2 py-1.5 text-[13px] font-medium cursor-pointer"
className={cn(
"flex flex-1 min-w-0 items-center gap-2 py-1.5 text-[13px] font-medium cursor-pointer",
NAV_FOCUS_CLASS,
)}
>
{labelInner}
</Link>
@@ -120,7 +126,10 @@ export function SettingsGroup({
<button
type="button"
onClick={toggle}
className="flex flex-1 min-w-0 items-center gap-2 py-1.5 text-[13px] font-medium cursor-pointer text-left"
className={cn(
"flex flex-1 min-w-0 items-center gap-2 py-1.5 text-[13px] font-medium cursor-pointer text-left",
NAV_FOCUS_CLASS,
)}
>
{labelInner}
</button>
@@ -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,
)}
>
<IconChevronRight
className={cn("h-3 w-3 transition-transform", expanded && "rotate-90")}
@@ -16,7 +16,10 @@ describe("settingsGroupIdForPath", () => {
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");
});
@@ -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,
},
];
+50 -358
View File
@@ -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 (
<Card>
<CardHeader>
<CardTitle className="text-base">Terminal Links</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="terminal-link-behavior">Open links in</Label>
<Select
value={userSettings.terminalLinkBehavior}
onValueChange={(v) => handleChange(v as "new_tab" | "browser_panel")}
disabled={isSaving}
>
<SelectTrigger id="terminal-link-behavior">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="new_tab">New browser tab</SelectItem>
<SelectItem value="browser_panel">Built-in browser panel</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">Click a URL in the terminal to open it.</p>
</div>
</CardContent>
</Card>
);
}
const CUSTOM_VALUE = "__custom__";
const CATEGORY_LABELS: Record<FontCategory, string> = {
icons: "Nerd Fonts",
ligatures: "Programming",
system: "System",
};
const CATEGORY_BADGES: Partial<Record<FontCategory, string>> = {
icons: "Icons",
ligatures: "Ligatures",
};
const FONT_GROUPS: Record<string, typeof TERMINAL_FONT_PRESETS> = TERMINAL_FONT_PRESETS.reduce(
(acc, p) => {
(acc[p.category] ??= []).push(p);
return acc;
},
{} as Record<string, typeof TERMINAL_FONT_PRESETS>,
);
const FONT_CATEGORIES: FontCategory[] = ["icons", "ligatures", "system"];
function FontGroupOptions() {
return FONT_CATEGORIES.map((category) => (
<SelectGroup key={category}>
<SelectLabel className="flex items-center gap-2">
{CATEGORY_LABELS[category]}
{CATEGORY_BADGES[category] && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{CATEGORY_BADGES[category]}
</Badge>
)}
</SelectLabel>
{(FONT_GROUPS[category] ?? []).map((preset) => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label}
</SelectItem>
))}
</SelectGroup>
));
}
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 (
<Card>
<CardHeader>
<CardTitle className="text-base">Terminal Font Size</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="terminal-font-size">Font Size</Label>
<div className="flex items-center gap-3">
<Input
id="terminal-font-size"
type="number"
min={8}
max={24}
value={fontSize}
onChange={(e) => 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"
/>
<span className="text-xs text-muted-foreground">px (8-24)</span>
</div>
<p className="text-xs text-muted-foreground">
Set the font size for the terminal. Default is 13px.
</p>
</div>
</CardContent>
</Card>
);
}
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 (
<Card>
<CardHeader>
<CardTitle className="text-base">Terminal Font</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<Label htmlFor="terminal-font">Font Family</Label>
<Select value={selectValue} onValueChange={handleSelectChange} disabled={isSaving}>
<SelectTrigger id="terminal-font" data-testid="terminal-font-select">
<SelectValue placeholder="Default" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default (Menlo / Monaco)</SelectItem>
<FontGroupOptions />
<SelectSeparator />
<SelectItem value={CUSTOM_VALUE}>Custom...</SelectItem>
</SelectContent>
</Select>
{isCustom && (
<Input
placeholder='e.g. "My Custom Font"'
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
onBlur={handleCustomBlur}
onKeyDown={(e) => {
if (e.key === "Enter") handleCustomBlur();
}}
data-testid="terminal-font-custom-input"
/>
)}
<p className="text-xs text-muted-foreground">
Choose a monospace font for the terminal. Nerd Fonts include icons for CLI tools.
</p>
</div>
</CardContent>
</Card>
);
}
function BackendConnectionCard() {
const [backendUrl] = useState<string>(() => getBackendConfig().apiBaseUrl);
const displayBackendUrl = backendUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Backend Server URL</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="backend-url">Server URL</Label>
<Input
id="backend-url"
type="url"
value={displayBackendUrl}
readOnly
disabled
placeholder="http://localhost:38429"
className="cursor-default"
/>
<p className="text-xs text-muted-foreground">
Backend URL is provided at runtime for SSR and WebSocket connections.
</p>
</div>
</CardContent>
</Card>
);
}
export function GeneralSettings() {
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold">General Settings</h2>
<p className="text-sm text-muted-foreground mt-1">
Manage your application preferences and notifications
</p>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{GENERAL_NAV_ITEMS.map(({ href, label, description, icon: Icon }) => (
<Link key={href} href={href} className="cursor-pointer">
<Card className="h-full transition-colors hover:bg-muted/40">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Icon className="h-4 w-4 text-muted-foreground" />
{label}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{description}</p>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
);
}
<Separator />
export function AppearanceSettings() {
return (
<div className="space-y-8">
<SettingsSection
icon={<IconPalette className="h-5 w-5" />}
title="Appearance"
@@ -486,42 +200,6 @@ export function GeneralSettings() {
<Separator />
<ShellSettingsCard />
<Separator />
<SettingsSection
icon={<IconTerminal2 className="h-5 w-5" />}
title="Terminal"
description="Configure terminal appearance and behavior"
>
<TerminalFontCard />
<TerminalFontSizeCard />
<TerminalLinksCard />
</SettingsSection>
<Separator />
<SettingsSection
icon={<IconActivity className="h-5 w-5" />}
title="Resource Metrics"
description="Configure backend and execution resource sampling"
>
<SystemMetricsSettingsCard />
</SettingsSection>
<Separator />
<SettingsSection
icon={<IconKeyboard className="h-5 w-5" />}
title="Chat Input"
description="Configure chat input behavior"
>
<ChatSubmitKeyCard />
</SettingsSection>
<Separator />
<SettingsSection
icon={<IconGitBranch className="h-5 w-5" />}
title="Changes Panel"
@@ -532,6 +210,30 @@ export function GeneralSettings() {
<Separator />
<SettingsSection
icon={<IconActivity className="h-5 w-5" />}
title="Resource Metrics"
description="Configure backend and execution resource sampling"
>
<SystemMetricsSettingsCard />
</SettingsSection>
</div>
);
}
export function KeyboardShortcutsSettings() {
return (
<div className="space-y-8">
<SettingsSection
icon={<IconKeyboard className="h-5 w-5" />}
title="Chat Input"
description="Configure chat input behavior"
>
<ChatSubmitKeyCard />
</SettingsSection>
<Separator />
<SettingsSection
icon={<IconCommand className="h-5 w-5" />}
title="Keyboard Shortcuts"
@@ -539,16 +241,6 @@ export function GeneralSettings() {
>
<KeyboardShortcutsCard />
</SettingsSection>
<Separator />
<SettingsSection
icon={<IconServer className="h-5 w-5" />}
title="Backend Connection"
description="Configure the backend server URL"
>
<BackendConnectionCard />
</SettingsSection>
</div>
);
}
@@ -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. */}
<div
data-testid="settings-scroll-container"
className="flex min-w-0 min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain p-4 pt-0 pb-20"
className="flex min-w-0 min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain p-4 pb-20"
>
{children}
</div>
@@ -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);
});
});
@@ -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<FontCategory, string> = {
icons: "Nerd Fonts",
ligatures: "Programming",
system: "System",
};
const CATEGORY_BADGES: Partial<Record<FontCategory, string>> = {
icons: "Icons",
ligatures: "Ligatures",
};
const FONT_GROUPS: Record<string, typeof TERMINAL_FONT_PRESETS> = TERMINAL_FONT_PRESETS.reduce(
(acc, p) => {
(acc[p.category] ??= []).push(p);
return acc;
},
{} as Record<string, typeof TERMINAL_FONT_PRESETS>,
);
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) => (
<SelectGroup key={category}>
<SelectLabel className="flex items-center gap-2">
{CATEGORY_LABELS[category]}
{CATEGORY_BADGES[category] && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{CATEGORY_BADGES[category]}
</Badge>
)}
</SelectLabel>
{(FONT_GROUPS[category] ?? []).map((preset) => (
<SelectItem key={preset.value} value={preset.value}>
{preset.label}
</SelectItem>
))}
</SelectGroup>
));
}
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 (
<Card>
<CardHeader>
<CardTitle className="text-base">Terminal Font Size</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="terminal-font-size">Font Size</Label>
<div className="flex items-center gap-3">
<Input
id="terminal-font-size"
type="number"
min={8}
max={24}
value={fontSize}
onChange={(e) => 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"
/>
<span className="text-xs text-muted-foreground">px (8-24)</span>
</div>
<p className="text-xs text-muted-foreground">
Set the font size for the terminal. Default is 13px.
</p>
</div>
</CardContent>
</Card>
);
}
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 (
<Card>
<CardHeader>
<CardTitle className="text-base">Terminal Font</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<Label htmlFor="terminal-font">Font Family</Label>
<Select value={selectValue} onValueChange={handleSelectChange} disabled={isSaving}>
<SelectTrigger id="terminal-font" data-testid="terminal-font-select">
<SelectValue placeholder="Default" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default (Menlo / Monaco)</SelectItem>
<FontGroupOptions />
<SelectSeparator />
<SelectItem value={CUSTOM_VALUE}>Custom...</SelectItem>
</SelectContent>
</Select>
{isCustom && (
<Input
placeholder='e.g. "My Custom Font"'
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
onBlur={handleCustomBlur}
onKeyDown={(e) => {
if (e.key === "Enter") handleCustomBlur();
}}
data-testid="terminal-font-custom-input"
/>
)}
<p className="text-xs text-muted-foreground">
Choose a monospace font for the terminal. Nerd Fonts include icons for CLI tools.
</p>
</div>
</CardContent>
</Card>
);
}
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 (
<Card>
<CardHeader>
<CardTitle className="text-base">Terminal Links</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="terminal-link-behavior">Open links in</Label>
<Select
value={userSettings.terminalLinkBehavior}
onValueChange={(v) => void handleChange(v as "new_tab" | "browser_panel")}
disabled={isSaving}
>
<SelectTrigger id="terminal-link-behavior">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="new_tab">New browser tab</SelectItem>
<SelectItem value="browser_panel">Built-in browser panel</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">Click a URL in the terminal to open it.</p>
</div>
</CardContent>
</Card>
);
}
export function TerminalSettings() {
return (
<div className="space-y-8">
<ShellSettingsCard />
<Separator />
<SettingsSection
icon={<IconTerminal2 className="h-5 w-5" />}
title="Terminal"
description="Configure terminal appearance and behavior"
>
<TerminalFontCard />
<TerminalFontSizeCard />
<TerminalLinksCard />
</SettingsSection>
</div>
);
}
@@ -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 });
@@ -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();
});
});
@@ -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 });