diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index 551d04e6c4..28d7c140ee 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -5,8 +5,11 @@ import { Sidebar } from "@/features/sidebar/ui/Sidebar"; import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog"; import { archiveProject } from "@/features/projects/api/projects"; import type { ProjectInfo } from "@/features/projects/api/projects"; -import { SettingsModal } from "@/features/settings/ui/SettingsModal"; -import type { SectionId } from "@/features/settings/ui/SettingsModal"; +import { + DEFAULT_SETTINGS_SECTION, + isSettingsSection, + type SectionId, +} from "@/features/settings/ui/settingsSections"; import { OPEN_SETTINGS_EVENT } from "@/features/settings/lib/settingsEvents"; import { TopBar } from "./ui/TopBar"; import { useChatStore } from "@/features/chat/stores/chatStore"; @@ -56,7 +59,8 @@ export type AppView = | "extensions" | "agents" | "projects" - | "session-history"; + | "session-history" + | "settings"; const SIDEBAR_OUTER_GUTTER_WIDTH = 12; const SIDEBAR_RESIZE_HANDLE_WIDTH = 12; @@ -72,24 +76,38 @@ const COLLAPSED_WINDOW_MIN_WIDTH = SIDEBAR_COLLAPSED_WIDTH + APP_SHELL_HORIZONTAL_CHROME_WIDTH + MIN_MAIN_CONTENT_WIDTH; -const SETTINGS_SECTIONS = new Set([ - "appearance", - "providers", - "compaction", - "voice", - "general", - "projects", - "chats", - "doctor", - "about", -]); - function getExpandedSidebarFitWidth(sidebarWidth: number) { return ( sidebarWidth + APP_SHELL_HORIZONTAL_CHROME_WIDTH + MIN_MAIN_CONTENT_WIDTH ); } +function getInitialSettingsSection(): SectionId | null { + if (typeof window === "undefined") return null; + if (window.location.pathname !== "/settings") return null; + const section = new URLSearchParams(window.location.search).get("section"); + if (!section) return DEFAULT_SETTINGS_SECTION; + return isSettingsSection(section) ? section : DEFAULT_SETTINGS_SECTION; +} + +function setSettingsSectionUrl(section: SectionId) { + if (typeof window === "undefined") return; + const url = new URL(window.location.href); + url.pathname = "/settings"; + url.searchParams.set("section", section); + window.history.replaceState(window.history.state, "", url); +} + +function clearSettingsSectionUrl() { + if (typeof window === "undefined") return; + const url = new URL(window.location.href); + if (url.pathname === "/settings") { + url.pathname = "/"; + } + url.searchParams.delete("section"); + window.history.replaceState(window.history.state, "", url); +} + async function ensureWindowWidth(minWidth: number) { if (!window.__TAURI_INTERNALS__ || window.innerWidth >= minWidth) { return; @@ -121,16 +139,19 @@ export function AppShell({ children }: { children?: React.ReactNode }) { const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarWidth, setSidebarWidth] = useState(SIDEBAR_DEFAULT_WIDTH); const [isResizing, setIsResizing] = useState(false); - const [settingsOpen, setSettingsOpen] = useState(false); - const [settingsInitialSection, setSettingsInitialSection] = - useState("appearance"); + const initialSettingsSection = getInitialSettingsSection(); + const [activeSettingsSection, setActiveSettingsSection] = useState( + initialSettingsSection ?? DEFAULT_SETTINGS_SECTION, + ); const [createProjectOpen, setCreateProjectOpen] = useState(false); const [createProjectInitialWorkingDir, setCreateProjectInitialWorkingDir] = useState(null); const [editingProject, setEditingProject] = useState( null, ); - const [activeView, setActiveView] = useState("home"); + const [activeView, setActiveView] = useState( + initialSettingsSection ? "settings" : "home", + ); const [homeSessionId, setHomeSessionId] = useState(() => loadStoredHomeSessionId(), ); @@ -156,6 +177,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { const pendingProjectCreatedRef = useRef<((projectId: string) => void) | null>( null, ); + const lastNonSettingsViewRef = useRef("home"); const homeSessionRequestRef = useRef | null>( null, ); @@ -214,6 +236,12 @@ export function AppShell({ children }: { children?: React.ReactNode }) { } }, [activeSessionId, activeView]); + useEffect(() => { + if (activeView !== "settings") { + lastNonSettingsViewRef.current = activeView; + } + }, [activeView]); + const activeSession = activeSessionId ? sessions.find((session) => session.id === activeSessionId) : undefined; @@ -396,6 +424,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { if (existingDraft) { setActiveSession(existingDraft.id); + clearSettingsSectionUrl(); setActiveView("chat"); setChatActiveSession(existingDraft.id); perfLog( @@ -414,6 +443,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { modelName: sessionModelPreference.modelName, }); setActiveSession(session.id); + clearSettingsSectionUrl(); setActiveView("chat"); setChatActiveSession(session.id); perfLog( @@ -482,21 +512,55 @@ export function AppShell({ children }: { children?: React.ReactNode }) { (sessionId: string) => { cleanupChatSession(sessionId); setActiveSession(null); + clearSettingsSectionUrl(); setActiveView("home"); }, [cleanupChatSession, setActiveSession], ); - const openSettings = useCallback((section: SectionId = "appearance") => { - setSettingsInitialSection(section); - setSettingsOpen(true); + + const expandSidebar = useCallback(async () => { + const expandedFitWidth = getExpandedSidebarFitWidth(sidebarWidth); + + try { + await ensureWindowWidth(expandedFitWidth); + } catch (error) { + console.warn("Failed to resize window before expanding sidebar:", error); + } + + setSidebarCollapsed(false); + }, [sidebarWidth]); + + const openSettings = useCallback( + (section: SectionId = DEFAULT_SETTINGS_SECTION) => { + if (activeView !== "settings") { + lastNonSettingsViewRef.current = activeView; + } + setActiveSettingsSection(section); + setSettingsSectionUrl(section); + setActiveView("settings"); + if (sidebarCollapsed) { + void expandSidebar(); + } + }, + [activeView, expandSidebar, sidebarCollapsed], + ); + + const leaveSettings = useCallback(() => { + clearSettingsSectionUrl(); + setActiveView(lastNonSettingsViewRef.current); + }, []); + + const selectSettingsSection = useCallback((section: SectionId) => { + setActiveSettingsSection(section); + setSettingsSectionUrl(section); }, []); useEffect(() => { const handleOpenSettingsEvent = (event: Event) => { const section = (event as CustomEvent<{ section?: string }>).detail ?.section; - if (section && SETTINGS_SECTIONS.has(section as SectionId)) { - openSettings(section as SectionId); + if (section && isSettingsSection(section)) { + openSettings(section); return; } @@ -614,6 +678,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { setHomeSessionId(null); } setActiveSession(sessionId); + clearSettingsSectionUrl(); setActiveView("chat"); setChatActiveSession(sessionId); useChatStore.getState().markSessionRead(sessionId); @@ -624,6 +689,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { const handleSelectSession = useCallback( (id: string) => { setActiveSession(id); + clearSettingsSectionUrl(); setActiveView("chat"); setChatActiveSession(id); useChatStore.getState().markSessionRead(id); @@ -646,12 +712,17 @@ export function AppShell({ children }: { children?: React.ReactNode }) { const handleNavigate = useCallback( (view: AppView) => { + if (view === "settings") { + openSettings(); + return; + } if (view !== "chat") { setActiveSession(null); } + clearSettingsSectionUrl(); setActiveView(view); }, - [setActiveSession], + [openSettings, setActiveSession], ); const handleCreatePersona = useCreatePersonaNavigation(() => @@ -662,18 +733,6 @@ export function AppShell({ children }: { children?: React.ReactNode }) { setSidebarCollapsed(true); }, []); - const expandSidebar = useCallback(async () => { - const expandedFitWidth = getExpandedSidebarFitWidth(sidebarWidth); - - try { - await ensureWindowWidth(expandedFitWidth); - } catch (error) { - console.warn("Failed to resize window before expanding sidebar:", error); - } - - setSidebarCollapsed(false); - }, [sidebarWidth]); - const toggleSidebar = useCallback(() => { if (sidebarCollapsed) { void expandSidebar(); @@ -766,7 +825,11 @@ export function AppShell({ children }: { children?: React.ReactNode }) { // Cmd+, for settings if (e.key === "," && e.metaKey) { e.preventDefault(); - setSettingsOpen((prev) => !prev); + if (activeView === "settings") { + leaveSettings(); + return; + } + openSettings(); } // Cmd+B for sidebar toggle if (e.key === "b" && e.metaKey) { @@ -776,6 +839,10 @@ export function AppShell({ children }: { children?: React.ReactNode }) { // Cmd+W returns to home instead of closing the window if (e.key === "w" && e.metaKey) { e.preventDefault(); + if (activeView === "settings") { + leaveSettings(); + return; + } const { activeSessionId } = useChatSessionStore.getState(); if (activeSessionId) { clearActiveSession(activeSessionId); @@ -785,12 +852,20 @@ export function AppShell({ children }: { children?: React.ReactNode }) { if (e.key === "n" && e.metaKey) { e.preventDefault(); setActiveSession(null); + clearSettingsSectionUrl(); setActiveView("home"); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [clearActiveSession, setActiveSession, toggleSidebar]); + }, [ + activeView, + clearActiveSession, + leaveSettings, + openSettings, + setActiveSession, + toggleSidebar, + ]); if (!startup.ready) { return ( @@ -832,10 +907,13 @@ export function AppShell({ children }: { children?: React.ReactNode }) { isResizing={isResizing} onCollapse={toggleSidebar} onSettingsClick={() => openSettings()} + onSettingsBack={leaveSettings} + onSettingsSectionChange={selectSettingsSection} onNavigate={handleNavigate} onNewChatInProject={handleNewChatInProject} onNewChat={() => { setActiveSession(null); + clearSettingsSectionUrl(); setActiveView("home"); }} onCreateProject={() => openCreateProjectDialog()} @@ -848,6 +926,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { onSelectSession={handleSelectSession} onSelectSearchResult={handleSelectSearchResult} activeView={activeView} + activeSettingsSection={activeSettingsSection} activeSessionId={activeSessionId} projects={projects} className="h-full rounded-xl" @@ -868,6 +947,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { {children ?? ( - {settingsOpen && ( - setSettingsOpen(false)} - /> - )} - { diff --git a/ui/goose2/src/app/ui/AppShellContent.tsx b/ui/goose2/src/app/ui/AppShellContent.tsx index ec952554ef..94e584b984 100644 --- a/ui/goose2/src/app/ui/AppShellContent.tsx +++ b/ui/goose2/src/app/ui/AppShellContent.tsx @@ -5,13 +5,16 @@ import { ExtensionsView } from "@/features/extensions/ui/ExtensionsView"; import { AgentsView } from "@/features/agents/ui/AgentsView"; import { ProjectsView } from "@/features/projects/ui/ProjectsView"; import { SessionHistoryView } from "@/features/sessions/ui/SessionHistoryView"; +import { SettingsView } from "@/features/settings/ui/SettingsView"; import type { ChatSession } from "@/features/chat/stores/chatSessionStore"; import type { SkillInfo } from "@/features/skills/api/skills"; import type { ProjectInfo } from "@/features/projects/api/projects"; import type { AppView } from "../AppShell"; +import type { SectionId } from "@/features/settings/ui/settingsSections"; interface AppShellContentProps { activeView: AppView; + activeSettingsSection: SectionId; activeSession?: ChatSession; homeSessionId: string | null; onCreatePersona: () => void; @@ -34,6 +37,7 @@ interface AppShellContentProps { export function AppShellContent({ activeView, + activeSettingsSection, activeSession, homeSessionId, onCreatePersona, @@ -47,6 +51,8 @@ export function AppShellContent({ onStartChatWithSkill, }: AppShellContentProps) { switch (activeView) { + case "settings": + return ; case "skills": return ; case "extensions": diff --git a/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx b/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx index 0a02c2c176..731e1cf51c 100644 --- a/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx +++ b/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx @@ -195,7 +195,7 @@ export function ChatInputToolbar({ const handleOpenAutoCompactSettings = () => { setIsContextPopoverOpen(false); - requestOpenSettings("compaction"); + requestOpenSettings("general"); }; useEffect(() => { diff --git a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx index 39be5e01b2..2bfb4af0db 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/ChatInput.test.tsx @@ -335,7 +335,7 @@ describe("ChatInput", () => { expect(dispatchEventSpy).toHaveBeenCalledWith( expect.objectContaining({ type: OPEN_SETTINGS_EVENT, - detail: { section: "compaction" }, + detail: { section: "general" }, }), ); diff --git a/ui/goose2/src/features/settings/ui/AboutSettings.tsx b/ui/goose2/src/features/settings/ui/AboutSettings.tsx deleted file mode 100644 index f754ab4aa8..0000000000 --- a/ui/goose2/src/features/settings/ui/AboutSettings.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { SettingsPage } from "@/shared/ui/SettingsPage"; - -interface AboutAppInfo { - name: string; - version: string; - tauriVersion: string; - identifier: string; -} - -function AboutInfoRow({ label, value }: { label: string; value: string }) { - return ( -
- {label} - - {value} - -
- ); -} - -export function AboutSettings() { - const { t } = useTranslation("settings"); - const [appInfo, setAppInfo] = useState(null); - - useEffect(() => { - let cancelled = false; - - async function loadAppInfo() { - if (!window.__TAURI_INTERNALS__) { - return; - } - - try { - const { getIdentifier, getName, getTauriVersion, getVersion } = - await import("@tauri-apps/api/app"); - const [name, version, tauriVersion, identifier] = await Promise.all([ - getName(), - getVersion(), - getTauriVersion(), - getIdentifier(), - ]); - - if (!cancelled) { - setAppInfo({ name, version, tauriVersion, identifier }); - } - } catch { - if (!cancelled) { - setAppInfo(null); - } - } - } - - void loadAppInfo(); - - return () => { - cancelled = true; - }; - }, []); - - const fallback = t("about.unavailable"); - - return ( - -
-
- - - - - - -
-
-
- ); -} diff --git a/ui/goose2/src/features/settings/ui/AppearanceSettings.tsx b/ui/goose2/src/features/settings/ui/AppearanceSettings.tsx deleted file mode 100644 index 00cb0c0a9f..0000000000 --- a/ui/goose2/src/features/settings/ui/AppearanceSettings.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { cn } from "@/shared/lib/cn"; -import { Separator } from "@/shared/ui/separator"; -import { ToggleGroup, ToggleGroupItem } from "@/shared/ui/toggle-group"; -import { useTheme } from "@/shared/theme/ThemeProvider"; -import { Sun, Moon, Monitor, Check } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { SettingsPage } from "@/shared/ui/SettingsPage"; - -const THEME_OPTIONS = [ - { value: "light", icon: Sun }, - { value: "dark", icon: Moon }, - { value: "system", icon: Monitor }, -] as const; - -const ACCENT_COLORS = [ - { name: "blue", value: "#3b82f6" }, - { name: "cyan", value: "#06b6d4" }, - { name: "green", value: "#22c55e" }, - { name: "orange", value: "#f97316" }, - { name: "red", value: "#ef4444" }, - { name: "pink", value: "#ec4899" }, - { name: "purple", value: "#a855f7" }, -]; - -const DENSITY_OPTIONS = [ - { value: "compact" }, - { value: "comfortable" }, - { value: "spacious" }, -] as const; - -function SettingRow({ - label, - description, - children, -}: { - label: string; - description?: string; - children: React.ReactNode; -}) { - return ( -
-
-

{label}

- {description && ( -

{description}

- )} -
-
{children}
-
- ); -} - -export function AppearanceSettings() { - const { t } = useTranslation("settings"); - const { - theme, - setTheme, - accentColorPreference, - resetAccentColor, - setAccentColor, - density, - setDensity, - } = useTheme(); - - return ( - - - v && setTheme(v as typeof theme)} - className="gap-1 rounded-lg bg-muted p-1" - > - {THEME_OPTIONS.map((option) => ( - - - {t(`appearance.theme.options.${option.value}`)} - - ))} - - - - - - -
- - {ACCENT_COLORS.map((color) => ( - - ))} -
-
- - - - - v && setDensity(v as typeof density)} - className="gap-1 rounded-lg bg-muted p-1" - > - {DENSITY_OPTIONS.map((option) => ( - - {t(`appearance.density.options.${option.value}`)} - - ))} - - -
- ); -} diff --git a/ui/goose2/src/features/settings/ui/CompactionSettings.tsx b/ui/goose2/src/features/settings/ui/CompactionSettings.tsx deleted file mode 100644 index f2416139ed..0000000000 --- a/ui/goose2/src/features/settings/ui/CompactionSettings.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { IconCheck } from "@tabler/icons-react"; -import { getProviderIcon } from "@/shared/ui/icons/ProviderIcons"; -import { GooseAutoCompactSettings } from "./GooseAutoCompactSettings"; -import { SettingsPage } from "@/shared/ui/SettingsPage"; - -export function CompactionSettings() { - const { t } = useTranslation("settings"); - const icon = getProviderIcon("goose", "size-6"); - - return ( - -
-
-
-
- {icon} -
- - {t("compaction.goose.label")} - -

- {t("compaction.goose.description")} -

-
- -
- - {t("compaction.goose.builtIn")} -
-
- -
- -
-
-
- ); -} diff --git a/ui/goose2/src/features/settings/ui/GeneralSettings.tsx b/ui/goose2/src/features/settings/ui/GeneralSettings.tsx index ea885c8940..b6ce3ff233 100644 --- a/ui/goose2/src/features/settings/ui/GeneralSettings.tsx +++ b/ui/goose2/src/features/settings/ui/GeneralSettings.tsx @@ -1,6 +1,7 @@ import { useTranslation } from "react-i18next"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { type LocalePreference, useLocale } from "@/shared/i18n"; +import { cn } from "@/shared/lib/cn"; import { Select, SelectContent, @@ -10,19 +11,61 @@ import { } from "@/shared/ui/select"; import { SettingsPage } from "@/shared/ui/SettingsPage"; import { Button } from "@/shared/ui/button"; +import { ToggleGroup, ToggleGroupItem } from "@/shared/ui/toggle-group"; import { resetOnboardingCompletion } from "@/features/onboarding/hooks/useOnboardingGate"; +import { useTheme } from "@/shared/theme/ThemeProvider"; +import { Moon, Monitor, Sun, Check } from "lucide-react"; +import { IconCheck } from "@tabler/icons-react"; +import { getProviderIcon } from "@/shared/ui/icons/ProviderIcons"; +import { GooseAutoCompactSettings } from "./GooseAutoCompactSettings"; + +const THEME_OPTIONS = [ + { value: "light", icon: Sun }, + { value: "dark", icon: Moon }, + { value: "system", icon: Monitor }, +] as const; + +const ACCENT_COLORS = [ + { name: "blue", value: "#3b82f6" }, + { name: "cyan", value: "#06b6d4" }, + { name: "green", value: "#22c55e" }, + { name: "orange", value: "#f97316" }, + { name: "red", value: "#ef4444" }, + { name: "pink", value: "#ec4899" }, + { name: "purple", value: "#a855f7" }, +]; + +const DENSITY_OPTIONS = [ + { value: "compact" }, + { value: "comfortable" }, + { value: "spacious" }, +] as const; + +interface AboutAppInfo { + name: string; + version: string; + tauriVersion: string; + identifier: string; +} function SettingRow({ label, description, children, + className, }: { label: string; description?: string; children: React.ReactNode; + className?: string; }) { return ( -
+

{label}

{description ? ( @@ -34,60 +77,285 @@ function SettingRow({ ); } +function SettingsSection({ + title, + children, +}: { + title?: string; + children: React.ReactNode; +}) { + return ( +
+ {title ?

{title}

: null} +
+ {children} +
+
+ ); +} + +function AboutInfoRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + + {value} + +
+ ); +} + export function GeneralSettings() { const { t } = useTranslation("settings"); const { preference, setLocalePreference, systemLocaleLabel } = useLocale(); const [onboardingReset, setOnboardingReset] = useState(false); + const [appInfo, setAppInfo] = useState(null); + const { + theme, + setTheme, + accentColorPreference, + resetAccentColor, + setAccentColor, + density, + setDensity, + } = useTheme(); + const gooseIcon = getProviderIcon("goose", "size-6"); function resetOnboarding() { resetOnboardingCompletion(); setOnboardingReset(true); } - return ( - - - - + useEffect(() => { + let cancelled = false; - - - + + + + + + + + + + + v && setTheme(v as typeof theme)} + className="gap-1 rounded-lg bg-muted p-1" + > + {THEME_OPTIONS.map((option) => ( + + + {t(`appearance.theme.options.${option.value}`)} + + ))} + + + + +
+ + {ACCENT_COLORS.map((color) => ( + + ))} +
+
+ + + v && setDensity(v as typeof density)} + className="gap-1 rounded-lg bg-muted p-1" + > + {DENSITY_OPTIONS.map((option) => ( + + {t(`appearance.density.options.${option.value}`)} + + ))} + + +
+ + +
+
+
+ {gooseIcon} +
+ + {t("compaction.goose.label")} + +

+ {t("compaction.goose.description")} +

+
+ +
+ + {t("compaction.goose.builtIn")} +
+
+ +
+ +
+
+ + + + + + + + +
); } diff --git a/ui/goose2/src/features/settings/ui/SettingsModal.tsx b/ui/goose2/src/features/settings/ui/SettingsModal.tsx deleted file mode 100644 index 5d26f86bb7..0000000000 --- a/ui/goose2/src/features/settings/ui/SettingsModal.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import { useState, useEffect, useRef } from "react"; -import { useTranslation } from "react-i18next"; -import { cn } from "@/shared/lib/cn"; -import { Button } from "@/shared/ui/button"; -import { - Mic, - Minimize2, - Palette, - Settings2, - FolderKanban, - Info, - MessageSquare, - Stethoscope, - X, -} from "lucide-react"; -import { IconPlug } from "@tabler/icons-react"; -import { AppearanceSettings } from "./AppearanceSettings"; -import { DoctorSettings } from "./DoctorSettings"; -import { ProvidersSettings } from "./ProvidersSettings"; -import { VoiceInputSettings } from "./VoiceInputSettings"; -import { GeneralSettings } from "./GeneralSettings"; -import { CompactionSettings } from "./CompactionSettings"; -import { ProjectsSettings } from "./ProjectsSettings"; -import { ChatsSettings } from "./ChatsSettings"; -import { AboutSettings } from "./AboutSettings"; - -const NAV_ITEMS = [ - { id: "appearance", labelKey: "nav.appearance", icon: Palette }, - { id: "providers", labelKey: "nav.providers", icon: IconPlug }, - { id: "compaction", labelKey: "nav.compaction", icon: Minimize2 }, - { id: "voice", labelKey: "nav.voice", icon: Mic }, - { id: "general", labelKey: "nav.general", icon: Settings2 }, - { id: "projects", labelKey: "nav.projects", icon: FolderKanban }, - { id: "chats", labelKey: "nav.chats", icon: MessageSquare }, - { id: "doctor", labelKey: "nav.doctor", icon: Stethoscope }, - { id: "about", labelKey: "nav.about", icon: Info }, -] as const; - -export type SectionId = (typeof NAV_ITEMS)[number]["id"]; - -interface SettingsModalProps { - onClose: () => void; - initialSection?: SectionId; -} - -export function SettingsModal({ - onClose, - initialSection = "appearance", -}: SettingsModalProps) { - const { t } = useTranslation(["settings", "common"]); - const [activeSection, setActiveSection] = useState(initialSection); - const [isLoaded, setIsLoaded] = useState(false); - const modalRootRef = useRef(null); - - // Trigger entrance animations after mount - useEffect(() => { - const timer = setTimeout(() => setIsLoaded(true), 50); - return () => clearTimeout(timer); - }, []); - - useEffect(() => { - setActiveSection(initialSection); - }, [initialSection]); - - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if ( - event.key === "Escape" && - !event.defaultPrevented && - event.target instanceof Node && - modalRootRef.current?.contains(event.target) - ) { - onClose(); - } - }; - - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [onClose]); - - const navItems = NAV_ITEMS.map((item) => ({ - ...item, - label: t(item.labelKey), - })); - const activeSectionLabel = - navItems.find((item) => item.id === activeSection)?.label ?? t("title"); - - return ( - // biome-ignore lint/a11y/useKeyWithClickEvents: Escape is handled by the document listener while the backdrop only handles pointer dismissal. -
- {/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation on inner container is not a meaningful interaction */} - {/* biome-ignore lint/a11y/noStaticElementInteractions: click handler only prevents backdrop dismiss propagation */} -
e.stopPropagation()} - > - {/* Sidebar */} -
-
-

{t("title")}

-
- -
- - {/* Content */} -
- - -
-
- {activeSection === "appearance" && } - {activeSection === "providers" && } - {activeSection === "compaction" && } - {activeSection === "voice" && } - {activeSection === "doctor" && } - {activeSection === "general" && } - {activeSection === "projects" && } - {activeSection === "chats" && } - {activeSection === "about" && } -
-
-
-
-
- ); -} diff --git a/ui/goose2/src/features/settings/ui/SettingsView.tsx b/ui/goose2/src/features/settings/ui/SettingsView.tsx new file mode 100644 index 0000000000..1cb2c6bcf8 --- /dev/null +++ b/ui/goose2/src/features/settings/ui/SettingsView.tsx @@ -0,0 +1,25 @@ +import { DoctorSettings } from "./DoctorSettings"; +import { ProvidersSettings } from "./ProvidersSettings"; +import { VoiceInputSettings } from "./VoiceInputSettings"; +import { GeneralSettings } from "./GeneralSettings"; +import { ProjectsSettings } from "./ProjectsSettings"; +import { ChatsSettings } from "./ChatsSettings"; +import type { SectionId } from "./settingsSections"; +import { PageShell } from "@/shared/ui/page-shell"; + +interface SettingsViewProps { + activeSection: SectionId; +} + +export function SettingsView({ activeSection }: SettingsViewProps) { + return ( + + {activeSection === "providers" && } + {activeSection === "voice" && } + {activeSection === "doctor" && } + {activeSection === "general" && } + {activeSection === "projects" && } + {activeSection === "chats" && } + + ); +} diff --git a/ui/goose2/src/features/settings/ui/__tests__/AppearanceSettings.test.tsx b/ui/goose2/src/features/settings/ui/__tests__/GeneralSettings.test.tsx similarity index 90% rename from ui/goose2/src/features/settings/ui/__tests__/AppearanceSettings.test.tsx rename to ui/goose2/src/features/settings/ui/__tests__/GeneralSettings.test.tsx index 398b3c10e4..114ca1da64 100644 --- a/ui/goose2/src/features/settings/ui/__tests__/AppearanceSettings.test.tsx +++ b/ui/goose2/src/features/settings/ui/__tests__/GeneralSettings.test.tsx @@ -4,9 +4,9 @@ import { beforeEach, describe, expect, it } from "vitest"; import { ThemeProvider } from "@/shared/theme/ThemeProvider"; import { renderWithProviders } from "@/test/render"; -import { AppearanceSettings } from "../AppearanceSettings"; +import { GeneralSettings } from "../GeneralSettings"; -describe("AppearanceSettings", () => { +describe("GeneralSettings appearance section", () => { beforeEach(() => { localStorage.clear(); document.documentElement.removeAttribute("data-density"); @@ -19,7 +19,7 @@ describe("AppearanceSettings", () => { renderWithProviders( - + , ); diff --git a/ui/goose2/src/features/settings/ui/settingsSections.ts b/ui/goose2/src/features/settings/ui/settingsSections.ts new file mode 100644 index 0000000000..361cc0b6d0 --- /dev/null +++ b/ui/goose2/src/features/settings/ui/settingsSections.ts @@ -0,0 +1,30 @@ +import type { ComponentType } from "react"; +import { + Mic, + Settings2, + FolderKanban, + MessageSquare, + Stethoscope, +} from "lucide-react"; +import { IconPlug } from "@tabler/icons-react"; + +export const SETTINGS_SECTIONS = [ + { id: "general", labelKey: "nav.general", icon: Settings2 }, + { id: "providers", labelKey: "nav.providers", icon: IconPlug }, + { id: "voice", labelKey: "nav.voice", icon: Mic }, + { id: "projects", labelKey: "nav.projects", icon: FolderKanban }, + { id: "chats", labelKey: "nav.chats", icon: MessageSquare }, + { id: "doctor", labelKey: "nav.doctor", icon: Stethoscope }, +] as const satisfies readonly { + id: string; + labelKey: string; + icon: ComponentType<{ className?: string }>; +}[]; + +export type SectionId = (typeof SETTINGS_SECTIONS)[number]["id"]; + +export const DEFAULT_SETTINGS_SECTION: SectionId = "general"; + +export function isSettingsSection(section: string): section is SectionId { + return SETTINGS_SECTIONS.some((item) => item.id === section); +} diff --git a/ui/goose2/src/features/sidebar/ui/Sidebar.tsx b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx index 8a47ebc285..cc599e3e05 100644 --- a/ui/goose2/src/features/sidebar/ui/Sidebar.tsx +++ b/ui/goose2/src/features/sidebar/ui/Sidebar.tsx @@ -6,6 +6,7 @@ import { IconLayoutSidebar, IconLayoutSidebarFilled, IconApps, + IconArrowLeft, IconRobotFace, IconSearch, IconSettings, @@ -37,6 +38,11 @@ import { SIDE_PANEL_DEFAULT_WIDTH } from "@/shared/constants/panels"; import { SidebarProjectsSection } from "./SidebarProjectsSection"; import { SidebarNavItem } from "./SidebarNavItem"; import { SidebarSearchResults } from "./SidebarSearchResults"; +import { + DEFAULT_SETTINGS_SECTION, + SETTINGS_SECTIONS, + type SectionId, +} from "@/features/settings/ui/settingsSections"; interface SidebarProps { collapsed: boolean; @@ -44,6 +50,8 @@ interface SidebarProps { isResizing?: boolean; onCollapse: () => void; onSettingsClick?: () => void; + onSettingsBack?: () => void; + onSettingsSectionChange?: (section: SectionId) => void; onNewChatInProject?: (projectId: string) => void; onNewChat?: () => void; onCreateProject?: () => void; @@ -61,6 +69,7 @@ interface SidebarProps { query?: string, ) => void; activeView?: AppView; + activeSettingsSection?: SectionId; activeSessionId?: string | null; className?: string; projects: ProjectInfo[]; @@ -100,6 +109,8 @@ export function Sidebar({ isResizing = false, onCollapse, onSettingsClick, + onSettingsBack, + onSettingsSectionChange, onNewChatInProject, onNewChat, onCreateProject, @@ -113,6 +124,7 @@ export function Sidebar({ onSelectSession, onSelectSearchResult, activeView, + activeSettingsSection = DEFAULT_SETTINGS_SECTION, activeSessionId, className, projects, @@ -162,6 +174,7 @@ export function Sidebar({ const labelTransition = "transition-[opacity,width] duration-300 ease-out"; const labelVisible = expanded && !collapsed; + const isSettingsSurface = activeView === "settings"; const defaultTitle = t("common:session.defaultTitle"); const navItems: readonly { id: AppView; @@ -352,84 +365,278 @@ export function Sidebar({
-
- onNavigate?.("home")} - /> +
+ - -
- + ))} +
+
diff --git a/ui/goose2/src/features/sidebar/ui/__tests__/Sidebar.test.tsx b/ui/goose2/src/features/sidebar/ui/__tests__/Sidebar.test.tsx index 18905f33d8..c86d236662 100644 --- a/ui/goose2/src/features/sidebar/ui/__tests__/Sidebar.test.tsx +++ b/ui/goose2/src/features/sidebar/ui/__tests__/Sidebar.test.tsx @@ -168,4 +168,62 @@ describe("Sidebar", () => { mockSessions.splice(0, mockSessions.length); }); + + it("renders settings navigation as the active sidebar surface", async () => { + const user = userEvent.setup(); + const onSettingsBack = vi.fn(); + const onSettingsSectionChange = vi.fn(); + + render( + , + ); + + expect( + screen.getByRole("navigation", { name: /settings navigation/i }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /providers/i })).toHaveAttribute( + "aria-current", + "page", + ); + expect( + screen.queryByRole("button", { name: /^home$/i }), + ).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /^back$/i })); + expect(onSettingsBack).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole("button", { name: /general/i })); + expect(onSettingsSectionChange).toHaveBeenCalledWith("general"); + }); + + it("shows an expand control in collapsed settings navigation", async () => { + const user = userEvent.setup(); + const onCollapse = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: /expand sidebar/i })); + + expect(onCollapse).toHaveBeenCalledTimes(1); + }); }); diff --git a/ui/goose2/src/shared/i18n/locales/en/settings.json b/ui/goose2/src/shared/i18n/locales/en/settings.json index 050bed1dbc..6b165d8c0b 100644 --- a/ui/goose2/src/shared/i18n/locales/en/settings.json +++ b/ui/goose2/src/shared/i18n/locales/en/settings.json @@ -1,4 +1,5 @@ { + "navigationLabel": "Settings navigation", "about": { "buildModes": { "development": "Development", diff --git a/ui/goose2/src/shared/i18n/locales/en/sidebar.json b/ui/goose2/src/shared/i18n/locales/en/sidebar.json index b5816e8c2d..38a5c5c652 100644 --- a/ui/goose2/src/shared/i18n/locales/en/sidebar.json +++ b/ui/goose2/src/shared/i18n/locales/en/sidebar.json @@ -1,5 +1,6 @@ { "actions": { + "backToMainNavigation": "Back", "collapse": "Collapse sidebar", "expand": "Expand sidebar", "newChat": "New chat", @@ -14,6 +15,7 @@ "agents": "Agents", "extensions": "Extensions", "home": "Home", + "main": "Main navigation", "sessionHistory": "Session history", "skills": "Skills" }, diff --git a/ui/goose2/src/shared/i18n/locales/es/settings.json b/ui/goose2/src/shared/i18n/locales/es/settings.json index 1a9e44b0dd..ff161af140 100644 --- a/ui/goose2/src/shared/i18n/locales/es/settings.json +++ b/ui/goose2/src/shared/i18n/locales/es/settings.json @@ -1,4 +1,5 @@ { + "navigationLabel": "Navegación de configuración", "about": { "buildModes": { "development": "Desarrollo", diff --git a/ui/goose2/src/shared/i18n/locales/es/sidebar.json b/ui/goose2/src/shared/i18n/locales/es/sidebar.json index eb2d6e795c..e8dc2ebe4f 100644 --- a/ui/goose2/src/shared/i18n/locales/es/sidebar.json +++ b/ui/goose2/src/shared/i18n/locales/es/sidebar.json @@ -1,5 +1,6 @@ { "actions": { + "backToMainNavigation": "Atrás", "collapse": "Contraer barra lateral", "expand": "Expandir barra lateral", "newChat": "Nuevo chat", @@ -14,6 +15,7 @@ "agents": "Agentes", "extensions": "Extensiones", "home": "Inicio", + "main": "Navegación principal", "sessionHistory": "Historial de sesiones", "skills": "Habilidades" }, diff --git a/ui/goose2/src/shared/ui/SettingsPage.tsx b/ui/goose2/src/shared/ui/SettingsPage.tsx index 73b2a94586..433f46e127 100644 --- a/ui/goose2/src/shared/ui/SettingsPage.tsx +++ b/ui/goose2/src/shared/ui/SettingsPage.tsx @@ -23,7 +23,7 @@ export function SettingsPage({ return (
-
+

{title} @@ -40,7 +40,7 @@ export function SettingsPage({

) : null}
- {controls ?
{controls}
: null} + {controls ?
{controls}
: null}
{children ? (
{children}
diff --git a/ui/goose2/src/shared/ui/page-shell.tsx b/ui/goose2/src/shared/ui/page-shell.tsx index c98054c3db..c4c9dbd744 100644 --- a/ui/goose2/src/shared/ui/page-shell.tsx +++ b/ui/goose2/src/shared/ui/page-shell.tsx @@ -6,6 +6,7 @@ interface ShellProps { children: ReactNode; className?: string; contentClassName?: string; + contentWidth?: "default" | "narrow"; } interface PageHeaderProps { @@ -27,13 +28,17 @@ export function PageShell({ children, className, contentClassName, + contentWidth = "default", }: ShellProps) { + const widthClassName = contentWidth === "narrow" ? "max-w-3xl" : "max-w-5xl"; + return (
@@ -48,13 +53,17 @@ export function DetailPageShell({ children, className, contentClassName, + contentWidth = "default", }: ShellProps) { + const widthClassName = contentWidth === "narrow" ? "max-w-3xl" : "max-w-5xl"; + return (