move settings into app shell (#9047)
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> Signed-off-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
+119
-46
@@ -5,8 +5,11 @@ import { Sidebar } from "@/features/sidebar/ui/Sidebar";
|
|||||||
import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog";
|
import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog";
|
||||||
import { archiveProject } from "@/features/projects/api/projects";
|
import { archiveProject } from "@/features/projects/api/projects";
|
||||||
import type { ProjectInfo } from "@/features/projects/api/projects";
|
import type { ProjectInfo } from "@/features/projects/api/projects";
|
||||||
import { SettingsModal } from "@/features/settings/ui/SettingsModal";
|
import {
|
||||||
import type { SectionId } from "@/features/settings/ui/SettingsModal";
|
DEFAULT_SETTINGS_SECTION,
|
||||||
|
isSettingsSection,
|
||||||
|
type SectionId,
|
||||||
|
} from "@/features/settings/ui/settingsSections";
|
||||||
import { OPEN_SETTINGS_EVENT } from "@/features/settings/lib/settingsEvents";
|
import { OPEN_SETTINGS_EVENT } from "@/features/settings/lib/settingsEvents";
|
||||||
import { TopBar } from "./ui/TopBar";
|
import { TopBar } from "./ui/TopBar";
|
||||||
import { useChatStore } from "@/features/chat/stores/chatStore";
|
import { useChatStore } from "@/features/chat/stores/chatStore";
|
||||||
@@ -56,7 +59,8 @@ export type AppView =
|
|||||||
| "extensions"
|
| "extensions"
|
||||||
| "agents"
|
| "agents"
|
||||||
| "projects"
|
| "projects"
|
||||||
| "session-history";
|
| "session-history"
|
||||||
|
| "settings";
|
||||||
|
|
||||||
const SIDEBAR_OUTER_GUTTER_WIDTH = 12;
|
const SIDEBAR_OUTER_GUTTER_WIDTH = 12;
|
||||||
const SIDEBAR_RESIZE_HANDLE_WIDTH = 12;
|
const SIDEBAR_RESIZE_HANDLE_WIDTH = 12;
|
||||||
@@ -72,24 +76,38 @@ const COLLAPSED_WINDOW_MIN_WIDTH =
|
|||||||
SIDEBAR_COLLAPSED_WIDTH +
|
SIDEBAR_COLLAPSED_WIDTH +
|
||||||
APP_SHELL_HORIZONTAL_CHROME_WIDTH +
|
APP_SHELL_HORIZONTAL_CHROME_WIDTH +
|
||||||
MIN_MAIN_CONTENT_WIDTH;
|
MIN_MAIN_CONTENT_WIDTH;
|
||||||
const SETTINGS_SECTIONS = new Set<SectionId>([
|
|
||||||
"appearance",
|
|
||||||
"providers",
|
|
||||||
"compaction",
|
|
||||||
"voice",
|
|
||||||
"general",
|
|
||||||
"projects",
|
|
||||||
"chats",
|
|
||||||
"doctor",
|
|
||||||
"about",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function getExpandedSidebarFitWidth(sidebarWidth: number) {
|
function getExpandedSidebarFitWidth(sidebarWidth: number) {
|
||||||
return (
|
return (
|
||||||
sidebarWidth + APP_SHELL_HORIZONTAL_CHROME_WIDTH + MIN_MAIN_CONTENT_WIDTH
|
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) {
|
async function ensureWindowWidth(minWidth: number) {
|
||||||
if (!window.__TAURI_INTERNALS__ || window.innerWidth >= minWidth) {
|
if (!window.__TAURI_INTERNALS__ || window.innerWidth >= minWidth) {
|
||||||
return;
|
return;
|
||||||
@@ -121,16 +139,19 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
|||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(SIDEBAR_DEFAULT_WIDTH);
|
const [sidebarWidth, setSidebarWidth] = useState(SIDEBAR_DEFAULT_WIDTH);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const initialSettingsSection = getInitialSettingsSection();
|
||||||
const [settingsInitialSection, setSettingsInitialSection] =
|
const [activeSettingsSection, setActiveSettingsSection] = useState<SectionId>(
|
||||||
useState<SectionId>("appearance");
|
initialSettingsSection ?? DEFAULT_SETTINGS_SECTION,
|
||||||
|
);
|
||||||
const [createProjectOpen, setCreateProjectOpen] = useState(false);
|
const [createProjectOpen, setCreateProjectOpen] = useState(false);
|
||||||
const [createProjectInitialWorkingDir, setCreateProjectInitialWorkingDir] =
|
const [createProjectInitialWorkingDir, setCreateProjectInitialWorkingDir] =
|
||||||
useState<string | null>(null);
|
useState<string | null>(null);
|
||||||
const [editingProject, setEditingProject] = useState<ProjectInfo | null>(
|
const [editingProject, setEditingProject] = useState<ProjectInfo | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [activeView, setActiveView] = useState<AppView>("home");
|
const [activeView, setActiveView] = useState<AppView>(
|
||||||
|
initialSettingsSection ? "settings" : "home",
|
||||||
|
);
|
||||||
const [homeSessionId, setHomeSessionId] = useState<string | null>(() =>
|
const [homeSessionId, setHomeSessionId] = useState<string | null>(() =>
|
||||||
loadStoredHomeSessionId(),
|
loadStoredHomeSessionId(),
|
||||||
);
|
);
|
||||||
@@ -156,6 +177,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
|||||||
const pendingProjectCreatedRef = useRef<((projectId: string) => void) | null>(
|
const pendingProjectCreatedRef = useRef<((projectId: string) => void) | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const lastNonSettingsViewRef = useRef<AppView>("home");
|
||||||
const homeSessionRequestRef = useRef<Promise<ChatSession | null> | null>(
|
const homeSessionRequestRef = useRef<Promise<ChatSession | null> | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -214,6 +236,12 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [activeSessionId, activeView]);
|
}, [activeSessionId, activeView]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeView !== "settings") {
|
||||||
|
lastNonSettingsViewRef.current = activeView;
|
||||||
|
}
|
||||||
|
}, [activeView]);
|
||||||
|
|
||||||
const activeSession = activeSessionId
|
const activeSession = activeSessionId
|
||||||
? sessions.find((session) => session.id === activeSessionId)
|
? sessions.find((session) => session.id === activeSessionId)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -396,6 +424,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
|||||||
|
|
||||||
if (existingDraft) {
|
if (existingDraft) {
|
||||||
setActiveSession(existingDraft.id);
|
setActiveSession(existingDraft.id);
|
||||||
|
clearSettingsSectionUrl();
|
||||||
setActiveView("chat");
|
setActiveView("chat");
|
||||||
setChatActiveSession(existingDraft.id);
|
setChatActiveSession(existingDraft.id);
|
||||||
perfLog(
|
perfLog(
|
||||||
@@ -414,6 +443,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
|||||||
modelName: sessionModelPreference.modelName,
|
modelName: sessionModelPreference.modelName,
|
||||||
});
|
});
|
||||||
setActiveSession(session.id);
|
setActiveSession(session.id);
|
||||||
|
clearSettingsSectionUrl();
|
||||||
setActiveView("chat");
|
setActiveView("chat");
|
||||||
setChatActiveSession(session.id);
|
setChatActiveSession(session.id);
|
||||||
perfLog(
|
perfLog(
|
||||||
@@ -482,21 +512,55 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
|||||||
(sessionId: string) => {
|
(sessionId: string) => {
|
||||||
cleanupChatSession(sessionId);
|
cleanupChatSession(sessionId);
|
||||||
setActiveSession(null);
|
setActiveSession(null);
|
||||||
|
clearSettingsSectionUrl();
|
||||||
setActiveView("home");
|
setActiveView("home");
|
||||||
},
|
},
|
||||||
[cleanupChatSession, setActiveSession],
|
[cleanupChatSession, setActiveSession],
|
||||||
);
|
);
|
||||||
const openSettings = useCallback((section: SectionId = "appearance") => {
|
|
||||||
setSettingsInitialSection(section);
|
const expandSidebar = useCallback(async () => {
|
||||||
setSettingsOpen(true);
|
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(() => {
|
useEffect(() => {
|
||||||
const handleOpenSettingsEvent = (event: Event) => {
|
const handleOpenSettingsEvent = (event: Event) => {
|
||||||
const section = (event as CustomEvent<{ section?: string }>).detail
|
const section = (event as CustomEvent<{ section?: string }>).detail
|
||||||
?.section;
|
?.section;
|
||||||
if (section && SETTINGS_SECTIONS.has(section as SectionId)) {
|
if (section && isSettingsSection(section)) {
|
||||||
openSettings(section as SectionId);
|
openSettings(section);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,6 +678,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
|||||||
setHomeSessionId(null);
|
setHomeSessionId(null);
|
||||||
}
|
}
|
||||||
setActiveSession(sessionId);
|
setActiveSession(sessionId);
|
||||||
|
clearSettingsSectionUrl();
|
||||||
setActiveView("chat");
|
setActiveView("chat");
|
||||||
setChatActiveSession(sessionId);
|
setChatActiveSession(sessionId);
|
||||||
useChatStore.getState().markSessionRead(sessionId);
|
useChatStore.getState().markSessionRead(sessionId);
|
||||||
@@ -624,6 +689,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
|||||||
const handleSelectSession = useCallback(
|
const handleSelectSession = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
setActiveSession(id);
|
setActiveSession(id);
|
||||||
|
clearSettingsSectionUrl();
|
||||||
setActiveView("chat");
|
setActiveView("chat");
|
||||||
setChatActiveSession(id);
|
setChatActiveSession(id);
|
||||||
useChatStore.getState().markSessionRead(id);
|
useChatStore.getState().markSessionRead(id);
|
||||||
@@ -646,12 +712,17 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
|||||||
|
|
||||||
const handleNavigate = useCallback(
|
const handleNavigate = useCallback(
|
||||||
(view: AppView) => {
|
(view: AppView) => {
|
||||||
|
if (view === "settings") {
|
||||||
|
openSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (view !== "chat") {
|
if (view !== "chat") {
|
||||||
setActiveSession(null);
|
setActiveSession(null);
|
||||||
}
|
}
|
||||||
|
clearSettingsSectionUrl();
|
||||||
setActiveView(view);
|
setActiveView(view);
|
||||||
},
|
},
|
||||||
[setActiveSession],
|
[openSettings, setActiveSession],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreatePersona = useCreatePersonaNavigation(() =>
|
const handleCreatePersona = useCreatePersonaNavigation(() =>
|
||||||
@@ -662,18 +733,6 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
|||||||
setSidebarCollapsed(true);
|
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(() => {
|
const toggleSidebar = useCallback(() => {
|
||||||
if (sidebarCollapsed) {
|
if (sidebarCollapsed) {
|
||||||
void expandSidebar();
|
void expandSidebar();
|
||||||
@@ -766,7 +825,11 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
|||||||
// Cmd+, for settings
|
// Cmd+, for settings
|
||||||
if (e.key === "," && e.metaKey) {
|
if (e.key === "," && e.metaKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSettingsOpen((prev) => !prev);
|
if (activeView === "settings") {
|
||||||
|
leaveSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openSettings();
|
||||||
}
|
}
|
||||||
// Cmd+B for sidebar toggle
|
// Cmd+B for sidebar toggle
|
||||||
if (e.key === "b" && e.metaKey) {
|
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
|
// Cmd+W returns to home instead of closing the window
|
||||||
if (e.key === "w" && e.metaKey) {
|
if (e.key === "w" && e.metaKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (activeView === "settings") {
|
||||||
|
leaveSettings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { activeSessionId } = useChatSessionStore.getState();
|
const { activeSessionId } = useChatSessionStore.getState();
|
||||||
if (activeSessionId) {
|
if (activeSessionId) {
|
||||||
clearActiveSession(activeSessionId);
|
clearActiveSession(activeSessionId);
|
||||||
@@ -785,12 +852,20 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
|||||||
if (e.key === "n" && e.metaKey) {
|
if (e.key === "n" && e.metaKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setActiveSession(null);
|
setActiveSession(null);
|
||||||
|
clearSettingsSectionUrl();
|
||||||
setActiveView("home");
|
setActiveView("home");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", handler);
|
window.addEventListener("keydown", handler);
|
||||||
return () => window.removeEventListener("keydown", handler);
|
return () => window.removeEventListener("keydown", handler);
|
||||||
}, [clearActiveSession, setActiveSession, toggleSidebar]);
|
}, [
|
||||||
|
activeView,
|
||||||
|
clearActiveSession,
|
||||||
|
leaveSettings,
|
||||||
|
openSettings,
|
||||||
|
setActiveSession,
|
||||||
|
toggleSidebar,
|
||||||
|
]);
|
||||||
|
|
||||||
if (!startup.ready) {
|
if (!startup.ready) {
|
||||||
return (
|
return (
|
||||||
@@ -832,10 +907,13 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
|||||||
isResizing={isResizing}
|
isResizing={isResizing}
|
||||||
onCollapse={toggleSidebar}
|
onCollapse={toggleSidebar}
|
||||||
onSettingsClick={() => openSettings()}
|
onSettingsClick={() => openSettings()}
|
||||||
|
onSettingsBack={leaveSettings}
|
||||||
|
onSettingsSectionChange={selectSettingsSection}
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
onNewChatInProject={handleNewChatInProject}
|
onNewChatInProject={handleNewChatInProject}
|
||||||
onNewChat={() => {
|
onNewChat={() => {
|
||||||
setActiveSession(null);
|
setActiveSession(null);
|
||||||
|
clearSettingsSectionUrl();
|
||||||
setActiveView("home");
|
setActiveView("home");
|
||||||
}}
|
}}
|
||||||
onCreateProject={() => openCreateProjectDialog()}
|
onCreateProject={() => openCreateProjectDialog()}
|
||||||
@@ -848,6 +926,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
|||||||
onSelectSession={handleSelectSession}
|
onSelectSession={handleSelectSession}
|
||||||
onSelectSearchResult={handleSelectSearchResult}
|
onSelectSearchResult={handleSelectSearchResult}
|
||||||
activeView={activeView}
|
activeView={activeView}
|
||||||
|
activeSettingsSection={activeSettingsSection}
|
||||||
activeSessionId={activeSessionId}
|
activeSessionId={activeSessionId}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
className="h-full rounded-xl"
|
className="h-full rounded-xl"
|
||||||
@@ -868,6 +947,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
|||||||
{children ?? (
|
{children ?? (
|
||||||
<AppShellContent
|
<AppShellContent
|
||||||
activeView={activeView}
|
activeView={activeView}
|
||||||
|
activeSettingsSection={activeSettingsSection}
|
||||||
activeSession={activeSession}
|
activeSession={activeSession}
|
||||||
homeSessionId={homeSessionId}
|
homeSessionId={homeSessionId}
|
||||||
onCreatePersona={handleCreatePersona}
|
onCreatePersona={handleCreatePersona}
|
||||||
@@ -884,13 +964,6 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{settingsOpen && (
|
|
||||||
<SettingsModal
|
|
||||||
initialSection={settingsInitialSection}
|
|
||||||
onClose={() => setSettingsOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CreateProjectDialog
|
<CreateProjectDialog
|
||||||
isOpen={createProjectOpen}
|
isOpen={createProjectOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ import { ExtensionsView } from "@/features/extensions/ui/ExtensionsView";
|
|||||||
import { AgentsView } from "@/features/agents/ui/AgentsView";
|
import { AgentsView } from "@/features/agents/ui/AgentsView";
|
||||||
import { ProjectsView } from "@/features/projects/ui/ProjectsView";
|
import { ProjectsView } from "@/features/projects/ui/ProjectsView";
|
||||||
import { SessionHistoryView } from "@/features/sessions/ui/SessionHistoryView";
|
import { SessionHistoryView } from "@/features/sessions/ui/SessionHistoryView";
|
||||||
|
import { SettingsView } from "@/features/settings/ui/SettingsView";
|
||||||
import type { ChatSession } from "@/features/chat/stores/chatSessionStore";
|
import type { ChatSession } from "@/features/chat/stores/chatSessionStore";
|
||||||
import type { SkillInfo } from "@/features/skills/api/skills";
|
import type { SkillInfo } from "@/features/skills/api/skills";
|
||||||
import type { ProjectInfo } from "@/features/projects/api/projects";
|
import type { ProjectInfo } from "@/features/projects/api/projects";
|
||||||
import type { AppView } from "../AppShell";
|
import type { AppView } from "../AppShell";
|
||||||
|
import type { SectionId } from "@/features/settings/ui/settingsSections";
|
||||||
|
|
||||||
interface AppShellContentProps {
|
interface AppShellContentProps {
|
||||||
activeView: AppView;
|
activeView: AppView;
|
||||||
|
activeSettingsSection: SectionId;
|
||||||
activeSession?: ChatSession;
|
activeSession?: ChatSession;
|
||||||
homeSessionId: string | null;
|
homeSessionId: string | null;
|
||||||
onCreatePersona: () => void;
|
onCreatePersona: () => void;
|
||||||
@@ -34,6 +37,7 @@ interface AppShellContentProps {
|
|||||||
|
|
||||||
export function AppShellContent({
|
export function AppShellContent({
|
||||||
activeView,
|
activeView,
|
||||||
|
activeSettingsSection,
|
||||||
activeSession,
|
activeSession,
|
||||||
homeSessionId,
|
homeSessionId,
|
||||||
onCreatePersona,
|
onCreatePersona,
|
||||||
@@ -47,6 +51,8 @@ export function AppShellContent({
|
|||||||
onStartChatWithSkill,
|
onStartChatWithSkill,
|
||||||
}: AppShellContentProps) {
|
}: AppShellContentProps) {
|
||||||
switch (activeView) {
|
switch (activeView) {
|
||||||
|
case "settings":
|
||||||
|
return <SettingsView activeSection={activeSettingsSection} />;
|
||||||
case "skills":
|
case "skills":
|
||||||
return <SkillsView onStartChatWithSkill={onStartChatWithSkill} />;
|
return <SkillsView onStartChatWithSkill={onStartChatWithSkill} />;
|
||||||
case "extensions":
|
case "extensions":
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ export function ChatInputToolbar({
|
|||||||
|
|
||||||
const handleOpenAutoCompactSettings = () => {
|
const handleOpenAutoCompactSettings = () => {
|
||||||
setIsContextPopoverOpen(false);
|
setIsContextPopoverOpen(false);
|
||||||
requestOpenSettings("compaction");
|
requestOpenSettings("general");
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ describe("ChatInput", () => {
|
|||||||
expect(dispatchEventSpy).toHaveBeenCalledWith(
|
expect(dispatchEventSpy).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: OPEN_SETTINGS_EVENT,
|
type: OPEN_SETTINGS_EVENT,
|
||||||
detail: { section: "compaction" },
|
detail: { section: "general" },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<div className="flex items-center justify-between gap-4 py-2">
|
|
||||||
<span className="text-sm text-muted-foreground">{label}</span>
|
|
||||||
<span className="min-w-0 truncate text-right text-sm font-medium">
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AboutSettings() {
|
|
||||||
const { t } = useTranslation("settings");
|
|
||||||
const [appInfo, setAppInfo] = useState<AboutAppInfo | null>(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 (
|
|
||||||
<SettingsPage title={t("about.title")}>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
<AboutInfoRow
|
|
||||||
label={t("about.fields.name")}
|
|
||||||
value={appInfo?.name ?? "Goose"}
|
|
||||||
/>
|
|
||||||
<AboutInfoRow
|
|
||||||
label={t("about.fields.version")}
|
|
||||||
value={appInfo?.version ?? fallback}
|
|
||||||
/>
|
|
||||||
<AboutInfoRow
|
|
||||||
label={t("about.fields.buildMode")}
|
|
||||||
value={
|
|
||||||
import.meta.env.DEV
|
|
||||||
? t("about.buildModes.development")
|
|
||||||
: t("about.buildModes.production")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<AboutInfoRow
|
|
||||||
label={t("about.fields.tauriVersion")}
|
|
||||||
value={appInfo?.tauriVersion ?? fallback}
|
|
||||||
/>
|
|
||||||
<AboutInfoRow
|
|
||||||
label={t("about.fields.identifier")}
|
|
||||||
value={appInfo?.identifier ?? fallback}
|
|
||||||
/>
|
|
||||||
<AboutInfoRow label={t("about.fields.license")} value="Apache-2.0" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SettingsPage>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<div className="flex items-start justify-between gap-8 py-3">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="text-sm font-medium">{label}</p>
|
|
||||||
{description && (
|
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">{description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-shrink-0">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppearanceSettings() {
|
|
||||||
const { t } = useTranslation("settings");
|
|
||||||
const {
|
|
||||||
theme,
|
|
||||||
setTheme,
|
|
||||||
accentColorPreference,
|
|
||||||
resetAccentColor,
|
|
||||||
setAccentColor,
|
|
||||||
density,
|
|
||||||
setDensity,
|
|
||||||
} = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsPage title={t("appearance.title")}>
|
|
||||||
<SettingRow
|
|
||||||
label={t("appearance.theme.label")}
|
|
||||||
description={t("appearance.theme.description")}
|
|
||||||
>
|
|
||||||
<ToggleGroup
|
|
||||||
type="single"
|
|
||||||
value={theme}
|
|
||||||
onValueChange={(v) => v && setTheme(v as typeof theme)}
|
|
||||||
className="gap-1 rounded-lg bg-muted p-1"
|
|
||||||
>
|
|
||||||
{THEME_OPTIONS.map((option) => (
|
|
||||||
<ToggleGroupItem
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
className="gap-1.5 rounded-md px-3 py-1.5 text-sm data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-none"
|
|
||||||
>
|
|
||||||
<option.icon className="h-3.5 w-3.5" />
|
|
||||||
{t(`appearance.theme.options.${option.value}`)}
|
|
||||||
</ToggleGroupItem>
|
|
||||||
))}
|
|
||||||
</ToggleGroup>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<Separator className="my-4" />
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
label={t("appearance.accent.label")}
|
|
||||||
description={t("appearance.accent.description")}
|
|
||||||
>
|
|
||||||
<div className="flex max-w-36 flex-wrap justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={t("appearance.accent.colors.default")}
|
|
||||||
aria-label={t("appearance.accent.colors.default")}
|
|
||||||
onClick={resetAccentColor}
|
|
||||||
className={cn(
|
|
||||||
"relative flex h-7 w-7 items-center justify-center overflow-hidden rounded-full border border-border transition-transform hover:scale-110",
|
|
||||||
accentColorPreference === "default" &&
|
|
||||||
"ring-2 ring-ring ring-offset-2 ring-offset-background",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="absolute inset-0 bg-[linear-gradient(135deg,#1a1a1a_0_50%,#ffffff_50%_100%)]" />
|
|
||||||
{accentColorPreference === "default" && (
|
|
||||||
<Check className="relative h-4 w-4 rounded-full bg-background p-0.5 text-foreground shadow-none" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{ACCENT_COLORS.map((color) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={color.value}
|
|
||||||
title={t(`appearance.accent.colors.${color.name}`)}
|
|
||||||
aria-label={t(`appearance.accent.colors.${color.name}`)}
|
|
||||||
onClick={() => setAccentColor(color.value)}
|
|
||||||
className={cn(
|
|
||||||
"flex h-7 w-7 items-center justify-center rounded-full transition-transform hover:scale-110",
|
|
||||||
accentColorPreference === color.value &&
|
|
||||||
"ring-2 ring-ring ring-offset-2 ring-offset-background",
|
|
||||||
)}
|
|
||||||
style={{ backgroundColor: color.value }}
|
|
||||||
>
|
|
||||||
{accentColorPreference === color.value && (
|
|
||||||
<Check className="h-3.5 w-3.5 text-white" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<Separator className="my-4" />
|
|
||||||
|
|
||||||
<SettingRow
|
|
||||||
label={t("appearance.density.label")}
|
|
||||||
description={t("appearance.density.description")}
|
|
||||||
>
|
|
||||||
<ToggleGroup
|
|
||||||
type="single"
|
|
||||||
value={density}
|
|
||||||
onValueChange={(v) => v && setDensity(v as typeof density)}
|
|
||||||
className="gap-1 rounded-lg bg-muted p-1"
|
|
||||||
>
|
|
||||||
{DENSITY_OPTIONS.map((option) => (
|
|
||||||
<ToggleGroupItem
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
className="rounded-md px-3 py-1.5 text-sm data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-none"
|
|
||||||
>
|
|
||||||
{t(`appearance.density.options.${option.value}`)}
|
|
||||||
</ToggleGroupItem>
|
|
||||||
))}
|
|
||||||
</ToggleGroup>
|
|
||||||
</SettingRow>
|
|
||||||
</SettingsPage>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<SettingsPage title={t("compaction.title")}>
|
|
||||||
<div className="rounded-lg border bg-background p-4">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex size-6 items-center justify-center [&>*]:size-6">
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
<span className="mt-2 block text-sm font-medium">
|
|
||||||
{t("compaction.goose.label")}
|
|
||||||
</span>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{t("compaction.goose.description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-1 text-xxs font-medium text-success">
|
|
||||||
<IconCheck className="size-3.5" />
|
|
||||||
<span>{t("compaction.goose.builtIn")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 border-t pt-4">
|
|
||||||
<GooseAutoCompactSettings />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SettingsPage>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { type LocalePreference, useLocale } from "@/shared/i18n";
|
import { type LocalePreference, useLocale } from "@/shared/i18n";
|
||||||
|
import { cn } from "@/shared/lib/cn";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -10,19 +11,61 @@ import {
|
|||||||
} from "@/shared/ui/select";
|
} from "@/shared/ui/select";
|
||||||
import { SettingsPage } from "@/shared/ui/SettingsPage";
|
import { SettingsPage } from "@/shared/ui/SettingsPage";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from "@/shared/ui/toggle-group";
|
||||||
import { resetOnboardingCompletion } from "@/features/onboarding/hooks/useOnboardingGate";
|
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({
|
function SettingRow({
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
children,
|
children,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-between gap-8 py-3">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between gap-8 px-4 py-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium">{label}</p>
|
<p className="text-sm font-medium">{label}</p>
|
||||||
{description ? (
|
{description ? (
|
||||||
@@ -34,60 +77,285 @@ function SettingRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SettingsSection({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
{title ? <h4 className="text-sm font-semibold">{title}</h4> : null}
|
||||||
|
<div className="overflow-hidden rounded-xl border border-border bg-background divide-y divide-border">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AboutInfoRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4 px-4 py-4">
|
||||||
|
<span className="text-sm text-muted-foreground">{label}</span>
|
||||||
|
<span className="min-w-0 truncate text-right text-sm font-medium">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function GeneralSettings() {
|
export function GeneralSettings() {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const { preference, setLocalePreference, systemLocaleLabel } = useLocale();
|
const { preference, setLocalePreference, systemLocaleLabel } = useLocale();
|
||||||
const [onboardingReset, setOnboardingReset] = useState(false);
|
const [onboardingReset, setOnboardingReset] = useState(false);
|
||||||
|
const [appInfo, setAppInfo] = useState<AboutAppInfo | null>(null);
|
||||||
|
const {
|
||||||
|
theme,
|
||||||
|
setTheme,
|
||||||
|
accentColorPreference,
|
||||||
|
resetAccentColor,
|
||||||
|
setAccentColor,
|
||||||
|
density,
|
||||||
|
setDensity,
|
||||||
|
} = useTheme();
|
||||||
|
const gooseIcon = getProviderIcon("goose", "size-6");
|
||||||
|
|
||||||
function resetOnboarding() {
|
function resetOnboarding() {
|
||||||
resetOnboardingCompletion();
|
resetOnboardingCompletion();
|
||||||
setOnboardingReset(true);
|
setOnboardingReset(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<SettingsPage title={t("general.title")}>
|
let cancelled = false;
|
||||||
<SettingRow
|
|
||||||
label={t("general.language.label")}
|
|
||||||
description={t("general.language.description")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
value={preference}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
void setLocalePreference(value as LocalePreference)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full min-w-64">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="system">
|
|
||||||
{t("general.language.system", {
|
|
||||||
language: systemLocaleLabel,
|
|
||||||
})}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="en">{t("general.language.english")}</SelectItem>
|
|
||||||
<SelectItem value="es">{t("general.language.spanish")}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<SettingRow
|
async function loadAppInfo() {
|
||||||
label={t("general.onboarding.label")}
|
if (!window.__TAURI_INTERNALS__) {
|
||||||
description={t(
|
return;
|
||||||
onboardingReset
|
}
|
||||||
? "general.onboarding.resetDescription"
|
|
||||||
: "general.onboarding.description",
|
try {
|
||||||
)}
|
const { getIdentifier, getName, getTauriVersion, getVersion } =
|
||||||
>
|
await import("@tauri-apps/api/app");
|
||||||
<Button
|
const [name, version, tauriVersion, identifier] = await Promise.all([
|
||||||
type="button"
|
getName(),
|
||||||
variant="outline"
|
getVersion(),
|
||||||
size="sm"
|
getTauriVersion(),
|
||||||
onClick={resetOnboarding}
|
getIdentifier(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setAppInfo({ name, version, tauriVersion, identifier });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setAppInfo(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadAppInfo();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const aboutFallback = t("about.unavailable");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsPage title={t("general.title")} contentClassName="space-y-8 pt-8">
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingRow
|
||||||
|
label={t("general.language.label")}
|
||||||
|
description={t("general.language.description")}
|
||||||
>
|
>
|
||||||
{t("general.onboarding.reset")}
|
<Select
|
||||||
</Button>
|
value={preference}
|
||||||
</SettingRow>
|
onValueChange={(value) =>
|
||||||
|
void setLocalePreference(value as LocalePreference)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full min-w-64">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="system">
|
||||||
|
{t("general.language.system", {
|
||||||
|
language: systemLocaleLabel,
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="en">
|
||||||
|
{t("general.language.english")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="es">
|
||||||
|
{t("general.language.spanish")}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
label={t("general.onboarding.label")}
|
||||||
|
description={t(
|
||||||
|
onboardingReset
|
||||||
|
? "general.onboarding.resetDescription"
|
||||||
|
: "general.onboarding.description",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={resetOnboarding}
|
||||||
|
>
|
||||||
|
{t("general.onboarding.reset")}
|
||||||
|
</Button>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection title={t("appearance.title")}>
|
||||||
|
<SettingRow
|
||||||
|
label={t("appearance.theme.label")}
|
||||||
|
description={t("appearance.theme.description")}
|
||||||
|
>
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={theme}
|
||||||
|
onValueChange={(v) => v && setTheme(v as typeof theme)}
|
||||||
|
className="gap-1 rounded-lg bg-muted p-1"
|
||||||
|
>
|
||||||
|
{THEME_OPTIONS.map((option) => (
|
||||||
|
<ToggleGroupItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
className="gap-1.5 rounded-md px-3 py-1.5 text-sm data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-none"
|
||||||
|
>
|
||||||
|
<option.icon className="h-3.5 w-3.5" />
|
||||||
|
{t(`appearance.theme.options.${option.value}`)}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
label={t("appearance.accent.label")}
|
||||||
|
description={t("appearance.accent.description")}
|
||||||
|
>
|
||||||
|
<div className="flex max-w-36 flex-wrap justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t("appearance.accent.colors.default")}
|
||||||
|
aria-label={t("appearance.accent.colors.default")}
|
||||||
|
onClick={resetAccentColor}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-7 w-7 items-center justify-center overflow-hidden rounded-full border border-border transition-transform hover:scale-110",
|
||||||
|
accentColorPreference === "default" &&
|
||||||
|
"ring-2 ring-ring ring-offset-2 ring-offset-background",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="absolute inset-0 bg-[linear-gradient(135deg,#1a1a1a_0_50%,#ffffff_50%_100%)]" />
|
||||||
|
{accentColorPreference === "default" && (
|
||||||
|
<Check className="relative h-4 w-4 rounded-full bg-background p-0.5 text-foreground shadow-none" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{ACCENT_COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={color.value}
|
||||||
|
title={t(`appearance.accent.colors.${color.name}`)}
|
||||||
|
aria-label={t(`appearance.accent.colors.${color.name}`)}
|
||||||
|
onClick={() => setAccentColor(color.value)}
|
||||||
|
className={cn(
|
||||||
|
"flex h-7 w-7 items-center justify-center rounded-full transition-transform hover:scale-110",
|
||||||
|
accentColorPreference === color.value &&
|
||||||
|
"ring-2 ring-ring ring-offset-2 ring-offset-background",
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: color.value }}
|
||||||
|
>
|
||||||
|
{accentColorPreference === color.value && (
|
||||||
|
<Check className="h-3.5 w-3.5 text-white" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
label={t("appearance.density.label")}
|
||||||
|
description={t("appearance.density.description")}
|
||||||
|
>
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={density}
|
||||||
|
onValueChange={(v) => v && setDensity(v as typeof density)}
|
||||||
|
className="gap-1 rounded-lg bg-muted p-1"
|
||||||
|
>
|
||||||
|
{DENSITY_OPTIONS.map((option) => (
|
||||||
|
<ToggleGroupItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
className="rounded-md px-3 py-1.5 text-sm data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-none"
|
||||||
|
>
|
||||||
|
{t(`appearance.density.options.${option.value}`)}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection title={t("compaction.title")}>
|
||||||
|
<div className="flex items-start justify-between gap-4 px-4 py-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex size-6 items-center justify-center [&>*]:size-6">
|
||||||
|
{gooseIcon}
|
||||||
|
</div>
|
||||||
|
<span className="mt-2 block text-sm font-medium">
|
||||||
|
{t("compaction.goose.label")}
|
||||||
|
</span>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{t("compaction.goose.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inline-flex items-center gap-1 rounded-full bg-success/10 px-2 py-1 text-xxs font-medium text-success">
|
||||||
|
<IconCheck className="size-3.5" />
|
||||||
|
<span>{t("compaction.goose.builtIn")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-4">
|
||||||
|
<GooseAutoCompactSettings />
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection title={t("about.title")}>
|
||||||
|
<AboutInfoRow
|
||||||
|
label={t("about.fields.name")}
|
||||||
|
value={appInfo?.name ?? "Goose"}
|
||||||
|
/>
|
||||||
|
<AboutInfoRow
|
||||||
|
label={t("about.fields.version")}
|
||||||
|
value={appInfo?.version ?? aboutFallback}
|
||||||
|
/>
|
||||||
|
<AboutInfoRow
|
||||||
|
label={t("about.fields.buildMode")}
|
||||||
|
value={
|
||||||
|
import.meta.env.DEV
|
||||||
|
? t("about.buildModes.development")
|
||||||
|
: t("about.buildModes.production")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AboutInfoRow
|
||||||
|
label={t("about.fields.tauriVersion")}
|
||||||
|
value={appInfo?.tauriVersion ?? aboutFallback}
|
||||||
|
/>
|
||||||
|
<AboutInfoRow
|
||||||
|
label={t("about.fields.identifier")}
|
||||||
|
value={appInfo?.identifier ?? aboutFallback}
|
||||||
|
/>
|
||||||
|
<AboutInfoRow label={t("about.fields.license")} value="Apache-2.0" />
|
||||||
|
</SettingsSection>
|
||||||
</SettingsPage>
|
</SettingsPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<SectionId>(initialSection);
|
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
|
||||||
const modalRootRef = useRef<HTMLDivElement>(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.
|
|
||||||
<div
|
|
||||||
ref={modalRootRef}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={activeSectionLabel}
|
|
||||||
className={cn(
|
|
||||||
"fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm transition-opacity duration-300",
|
|
||||||
isLoaded ? "opacity-100" : "opacity-0",
|
|
||||||
)}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
{/* 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 */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex h-[min(600px,calc(100vh-4rem))] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden rounded-xl border bg-background shadow-modal transition-opacity duration-300 ease-out max-[640px]:h-[calc(100vh-1rem)] max-[640px]:w-[calc(100vw-1rem)] max-[640px]:flex-col",
|
|
||||||
isLoaded ? "opacity-100" : "opacity-0",
|
|
||||||
)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex min-h-0 w-44 flex-shrink-0 flex-col border-r bg-muted/50 transition-all duration-700 ease-out max-[640px]:max-h-32 max-[640px]:w-full max-[640px]:border-b max-[640px]:border-r-0",
|
|
||||||
isLoaded ? "opacity-100 translate-x-0" : "opacity-0 -translate-x-2",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"px-4 py-4 transition-all duration-500 ease-out max-[640px]:px-3 max-[640px]:py-3",
|
|
||||||
isLoaded
|
|
||||||
? "opacity-100 translate-x-0"
|
|
||||||
: "opacity-0 -translate-x-2",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<h2 className="text-sm font-semibold">{t("title")}</h2>
|
|
||||||
</div>
|
|
||||||
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-3 max-[640px]:overflow-x-auto max-[640px]:overflow-y-hidden max-[640px]:pb-2">
|
|
||||||
<div className="flex flex-col gap-1 max-[640px]:min-w-max max-[640px]:flex-row">
|
|
||||||
{navItems.map((item, index) => (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
key={item.id}
|
|
||||||
onClick={() => setActiveSection(item.id)}
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-start rounded-lg px-3 py-2 transition-all duration-600 ease-out max-[640px]:w-auto max-[640px]:shrink-0 max-[640px]:px-2.5",
|
|
||||||
activeSection === item.id
|
|
||||||
? "bg-background text-foreground shadow-none hover:bg-background"
|
|
||||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground duration-300",
|
|
||||||
isLoaded
|
|
||||||
? "opacity-100 translate-x-0"
|
|
||||||
: "opacity-0 translate-x-4",
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
transitionDelay: isLoaded ? "0ms" : `${index * 40 + 300}ms`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<item.icon className="size-4" />
|
|
||||||
{item.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="relative flex min-w-0 flex-1 flex-col">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-xs"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label={t("common:actions.close")}
|
|
||||||
className="absolute right-4 top-4 z-30 rounded-md text-muted-foreground hover:text-foreground max-[640px]:right-3 max-[640px]:top-3"
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
||||||
<div className="px-6 pb-4 max-[640px]:px-4 max-[640px]:pt-1">
|
|
||||||
{activeSection === "appearance" && <AppearanceSettings />}
|
|
||||||
{activeSection === "providers" && <ProvidersSettings />}
|
|
||||||
{activeSection === "compaction" && <CompactionSettings />}
|
|
||||||
{activeSection === "voice" && <VoiceInputSettings />}
|
|
||||||
{activeSection === "doctor" && <DoctorSettings />}
|
|
||||||
{activeSection === "general" && <GeneralSettings />}
|
|
||||||
{activeSection === "projects" && <ProjectsSettings />}
|
|
||||||
{activeSection === "chats" && <ChatsSettings />}
|
|
||||||
{activeSection === "about" && <AboutSettings />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
||||||
|
<PageShell contentWidth="narrow" contentClassName="gap-0">
|
||||||
|
{activeSection === "providers" && <ProvidersSettings />}
|
||||||
|
{activeSection === "voice" && <VoiceInputSettings />}
|
||||||
|
{activeSection === "doctor" && <DoctorSettings />}
|
||||||
|
{activeSection === "general" && <GeneralSettings />}
|
||||||
|
{activeSection === "projects" && <ProjectsSettings />}
|
||||||
|
{activeSection === "chats" && <ChatsSettings />}
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
+3
-3
@@ -4,9 +4,9 @@ import { beforeEach, describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
|
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
|
||||||
import { renderWithProviders } from "@/test/render";
|
import { renderWithProviders } from "@/test/render";
|
||||||
import { AppearanceSettings } from "../AppearanceSettings";
|
import { GeneralSettings } from "../GeneralSettings";
|
||||||
|
|
||||||
describe("AppearanceSettings", () => {
|
describe("GeneralSettings appearance section", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
document.documentElement.removeAttribute("data-density");
|
document.documentElement.removeAttribute("data-density");
|
||||||
@@ -19,7 +19,7 @@ describe("AppearanceSettings", () => {
|
|||||||
|
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AppearanceSettings />
|
<GeneralSettings />
|
||||||
</ThemeProvider>,
|
</ThemeProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
IconLayoutSidebar,
|
IconLayoutSidebar,
|
||||||
IconLayoutSidebarFilled,
|
IconLayoutSidebarFilled,
|
||||||
IconApps,
|
IconApps,
|
||||||
|
IconArrowLeft,
|
||||||
IconRobotFace,
|
IconRobotFace,
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
@@ -37,6 +38,11 @@ import { SIDE_PANEL_DEFAULT_WIDTH } from "@/shared/constants/panels";
|
|||||||
import { SidebarProjectsSection } from "./SidebarProjectsSection";
|
import { SidebarProjectsSection } from "./SidebarProjectsSection";
|
||||||
import { SidebarNavItem } from "./SidebarNavItem";
|
import { SidebarNavItem } from "./SidebarNavItem";
|
||||||
import { SidebarSearchResults } from "./SidebarSearchResults";
|
import { SidebarSearchResults } from "./SidebarSearchResults";
|
||||||
|
import {
|
||||||
|
DEFAULT_SETTINGS_SECTION,
|
||||||
|
SETTINGS_SECTIONS,
|
||||||
|
type SectionId,
|
||||||
|
} from "@/features/settings/ui/settingsSections";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
@@ -44,6 +50,8 @@ interface SidebarProps {
|
|||||||
isResizing?: boolean;
|
isResizing?: boolean;
|
||||||
onCollapse: () => void;
|
onCollapse: () => void;
|
||||||
onSettingsClick?: () => void;
|
onSettingsClick?: () => void;
|
||||||
|
onSettingsBack?: () => void;
|
||||||
|
onSettingsSectionChange?: (section: SectionId) => void;
|
||||||
onNewChatInProject?: (projectId: string) => void;
|
onNewChatInProject?: (projectId: string) => void;
|
||||||
onNewChat?: () => void;
|
onNewChat?: () => void;
|
||||||
onCreateProject?: () => void;
|
onCreateProject?: () => void;
|
||||||
@@ -61,6 +69,7 @@ interface SidebarProps {
|
|||||||
query?: string,
|
query?: string,
|
||||||
) => void;
|
) => void;
|
||||||
activeView?: AppView;
|
activeView?: AppView;
|
||||||
|
activeSettingsSection?: SectionId;
|
||||||
activeSessionId?: string | null;
|
activeSessionId?: string | null;
|
||||||
className?: string;
|
className?: string;
|
||||||
projects: ProjectInfo[];
|
projects: ProjectInfo[];
|
||||||
@@ -100,6 +109,8 @@ export function Sidebar({
|
|||||||
isResizing = false,
|
isResizing = false,
|
||||||
onCollapse,
|
onCollapse,
|
||||||
onSettingsClick,
|
onSettingsClick,
|
||||||
|
onSettingsBack,
|
||||||
|
onSettingsSectionChange,
|
||||||
onNewChatInProject,
|
onNewChatInProject,
|
||||||
onNewChat,
|
onNewChat,
|
||||||
onCreateProject,
|
onCreateProject,
|
||||||
@@ -113,6 +124,7 @@ export function Sidebar({
|
|||||||
onSelectSession,
|
onSelectSession,
|
||||||
onSelectSearchResult,
|
onSelectSearchResult,
|
||||||
activeView,
|
activeView,
|
||||||
|
activeSettingsSection = DEFAULT_SETTINGS_SECTION,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
className,
|
className,
|
||||||
projects,
|
projects,
|
||||||
@@ -162,6 +174,7 @@ export function Sidebar({
|
|||||||
|
|
||||||
const labelTransition = "transition-[opacity,width] duration-300 ease-out";
|
const labelTransition = "transition-[opacity,width] duration-300 ease-out";
|
||||||
const labelVisible = expanded && !collapsed;
|
const labelVisible = expanded && !collapsed;
|
||||||
|
const isSettingsSurface = activeView === "settings";
|
||||||
const defaultTitle = t("common:session.defaultTitle");
|
const defaultTitle = t("common:session.defaultTitle");
|
||||||
const navItems: readonly {
|
const navItems: readonly {
|
||||||
id: AppView;
|
id: AppView;
|
||||||
@@ -352,84 +365,278 @@ export function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex-1 min-h-0 overflow-hidden">
|
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||||
<nav
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative h-full overflow-y-auto overflow-x-hidden px-1.5 py-1 pt-1 scrollbar-none",
|
"absolute inset-0 flex flex-col transition-[transform,opacity] duration-200 ease-out motion-reduce:transition-none",
|
||||||
collapsed ? "pb-16" : "pb-[72px]",
|
isSettingsSurface
|
||||||
|
? "pointer-events-none -translate-x-full opacity-0"
|
||||||
|
: "translate-x-0 opacity-100",
|
||||||
)}
|
)}
|
||||||
|
inert={isSettingsSurface ? true : undefined}
|
||||||
|
aria-hidden={isSettingsSurface}
|
||||||
>
|
>
|
||||||
<div className="relative z-10 space-y-0.5">
|
<nav
|
||||||
{collapsed && (
|
className={cn(
|
||||||
<button
|
"relative h-full overflow-y-auto overflow-x-hidden px-1.5 py-1 pt-1 scrollbar-none",
|
||||||
type="button"
|
collapsed ? "pb-16" : "pb-[72px]",
|
||||||
onClick={onCollapse}
|
|
||||||
title={t("actions.expand")}
|
|
||||||
className="flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors duration-200 hover:text-foreground"
|
|
||||||
aria-label={t("actions.expand")}
|
|
||||||
>
|
|
||||||
<IconLayoutSidebar className="size-4 flex-shrink-0" />
|
|
||||||
<span className="sr-only">{t("actions.expand")}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
aria-label={t("navigation.main")}
|
||||||
<div
|
>
|
||||||
className={cn(
|
<div className="relative z-10 space-y-0.5">
|
||||||
"mb-3 flex items-center w-full rounded-md transition-all duration-300 ease-out",
|
{collapsed && (
|
||||||
collapsed
|
<button
|
||||||
? "justify-center p-3 text-foreground"
|
type="button"
|
||||||
: "gap-2 border border-border px-2.5 py-1.5 text-xs text-foreground hover:text-foreground hover:bg-transparent",
|
onClick={onCollapse}
|
||||||
|
title={t("actions.expand")}
|
||||||
|
className="flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors duration-200 hover:text-foreground"
|
||||||
|
aria-label={t("actions.expand")}
|
||||||
|
>
|
||||||
|
<IconLayoutSidebar className="size-4 flex-shrink-0" />
|
||||||
|
<span className="sr-only">{t("actions.expand")}</span>
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<IconSearch className="size-3.5 flex-shrink-0 text-placeholder" />
|
<div
|
||||||
{!collapsed && (
|
className={cn(
|
||||||
<input
|
"mb-3 flex items-center w-full rounded-md transition-all duration-300 ease-out",
|
||||||
ref={searchInputRef}
|
collapsed
|
||||||
type="text"
|
? "justify-center p-3 text-foreground"
|
||||||
enterKeyHint="search"
|
: "gap-2 border border-border px-2.5 py-1.5 text-xs text-foreground hover:text-foreground hover:bg-transparent",
|
||||||
value={sidebarSearch.query}
|
)}
|
||||||
onChange={(e) => sidebarSearch.setQuery(e.target.value)}
|
>
|
||||||
onKeyDown={(e) => {
|
<IconSearch className="size-3.5 flex-shrink-0 text-placeholder" />
|
||||||
if (e.key === "Enter") {
|
{!collapsed && (
|
||||||
e.preventDefault();
|
<input
|
||||||
void sidebarSearch.search();
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
enterKeyHint="search"
|
||||||
|
value={sidebarSearch.query}
|
||||||
|
onChange={(e) => sidebarSearch.setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
void sidebarSearch.search();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t("search.placeholder")}
|
||||||
|
className={cn(
|
||||||
|
"focus-override appearance-none bg-transparent border-none text-xs flex-1 min-w-0 placeholder:text-placeholder outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||||
|
labelTransition,
|
||||||
|
labelVisible
|
||||||
|
? "opacity-100 w-auto"
|
||||||
|
: "opacity-0 w-0 overflow-hidden",
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SidebarNavItem
|
||||||
|
testId="nav-home"
|
||||||
|
icon={IconHome}
|
||||||
|
label={t("navigation.home")}
|
||||||
|
collapsed={collapsed}
|
||||||
|
labelTransition={labelTransition}
|
||||||
|
labelVisible={labelVisible}
|
||||||
|
isActive={activeView === "home"}
|
||||||
|
onClick={() => onNavigate?.("home")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{navItems.map((item, index) => {
|
||||||
|
const isActive = activeView === item.id;
|
||||||
|
return (
|
||||||
|
<SidebarNavItem
|
||||||
|
key={item.id}
|
||||||
|
icon={item.icon}
|
||||||
|
label={item.label}
|
||||||
|
collapsed={collapsed}
|
||||||
|
labelTransition={labelTransition}
|
||||||
|
labelVisible={labelVisible}
|
||||||
|
isActive={isActive}
|
||||||
|
onClick={() => onNavigate?.(item.id)}
|
||||||
|
itemTransitionDelay={
|
||||||
|
!collapsed && expanded ? `${index * 30}ms` : "0ms"
|
||||||
}
|
}
|
||||||
}}
|
labelTransitionDelay={
|
||||||
placeholder={t("search.placeholder")}
|
labelVisible ? `${index * 30 + 60}ms` : "0ms"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!collapsed &&
|
||||||
|
(sidebarSearch.submittedQuery ? (
|
||||||
|
<div className="relative z-10 space-y-2">
|
||||||
|
{sidebarSearch.error && (
|
||||||
|
<p className="px-1 text-xs text-danger">
|
||||||
|
{t("search.error")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sidebarSearch.isSearching &&
|
||||||
|
sidebarSearch.results.length === 0 && (
|
||||||
|
<div className="rounded-lg border border-dashed border-border px-3 py-6 text-center text-xs text-muted-foreground">
|
||||||
|
{t("search.searching")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(!sidebarSearch.isSearching ||
|
||||||
|
sidebarSearch.results.length > 0) && (
|
||||||
|
<SidebarSearchResults
|
||||||
|
results={sidebarSearch.results}
|
||||||
|
activeSessionId={activeSessionId}
|
||||||
|
onSelectResult={(sessionId, messageId) => {
|
||||||
|
if (messageId) {
|
||||||
|
onSelectSearchResult?.(
|
||||||
|
sessionId,
|
||||||
|
messageId,
|
||||||
|
sidebarSearch.submittedQuery,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSelectSession?.(sessionId);
|
||||||
|
}}
|
||||||
|
getPersonaName={sidebarResolvers.getPersonaName}
|
||||||
|
getProjectName={sidebarResolvers.getProjectName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SidebarProjectsSection
|
||||||
|
projects={projects}
|
||||||
|
projectSessions={projectSessions}
|
||||||
|
expandedProjects={expandedProjects}
|
||||||
|
toggleProject={toggleProject}
|
||||||
|
collapsed={collapsed}
|
||||||
|
labelTransition={labelTransition}
|
||||||
|
labelVisible={labelVisible}
|
||||||
|
activeSessionId={activeSessionId}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
onSelectSession={onSelectSession}
|
||||||
|
onNewChatInProject={onNewChatInProject}
|
||||||
|
onNewChat={onNewChat}
|
||||||
|
onCreateProject={onCreateProject}
|
||||||
|
onEditProject={onEditProject}
|
||||||
|
onArchiveProject={onArchiveProject}
|
||||||
|
onArchiveChat={onArchiveChat}
|
||||||
|
onRenameChat={onRenameChat}
|
||||||
|
onMoveToProject={onMoveToProject}
|
||||||
|
onReorderProject={onReorderProject}
|
||||||
|
projectsSectionOpen={sectionVisibility.projects}
|
||||||
|
recentsSectionOpen={sectionVisibility.recents}
|
||||||
|
onToggleProjectsSection={() => toggleSection("projects")}
|
||||||
|
onToggleRecentsSection={() => toggleSection("recents")}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-x-0 bottom-0 z-20 bg-background",
|
||||||
|
"px-1.5 py-1.5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size={collapsed ? "icon-sm" : "default"}
|
||||||
|
onClick={onSettingsClick}
|
||||||
|
className={cn(
|
||||||
|
"h-10 w-full rounded-md bg-transparent text-muted-foreground/85 hover:bg-transparent hover:text-foreground active:bg-transparent",
|
||||||
|
collapsed
|
||||||
|
? "justify-center p-3"
|
||||||
|
: "justify-start gap-2.5 px-3 py-2.5",
|
||||||
|
)}
|
||||||
|
title={t("settings:title")}
|
||||||
|
aria-label={t("settings:title")}
|
||||||
|
>
|
||||||
|
<IconSettings className="size-4 flex-shrink-0" />
|
||||||
|
{!collapsed && (
|
||||||
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus-override appearance-none bg-transparent border-none text-xs flex-1 min-w-0 placeholder:text-placeholder outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
"whitespace-nowrap text-sm",
|
||||||
labelTransition,
|
labelTransition,
|
||||||
labelVisible
|
labelVisible
|
||||||
? "opacity-100 w-auto"
|
? "opacity-100 w-auto"
|
||||||
: "opacity-0 w-0 overflow-hidden",
|
: "opacity-0 w-0 overflow-hidden",
|
||||||
)}
|
)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
>
|
||||||
/>
|
{t("settings:title")}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SidebarNavItem
|
<div
|
||||||
testId="nav-home"
|
className={cn(
|
||||||
icon={IconHome}
|
"absolute inset-0 flex flex-col transition-[transform,opacity] duration-200 ease-out motion-reduce:transition-none",
|
||||||
label={t("navigation.home")}
|
isSettingsSurface
|
||||||
collapsed={collapsed}
|
? "translate-x-0 opacity-100"
|
||||||
labelTransition={labelTransition}
|
: "pointer-events-none translate-x-full opacity-0",
|
||||||
labelVisible={labelVisible}
|
)}
|
||||||
isActive={activeView === "home"}
|
inert={!isSettingsSurface ? true : undefined}
|
||||||
onClick={() => onNavigate?.("home")}
|
aria-hidden={!isSettingsSurface}
|
||||||
/>
|
>
|
||||||
|
<nav
|
||||||
|
className="h-full overflow-y-auto overflow-x-hidden px-1.5 py-1 scrollbar-none"
|
||||||
|
aria-label={t("settings:navigationLabel")}
|
||||||
|
>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{collapsed && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCollapse}
|
||||||
|
title={t("actions.expand")}
|
||||||
|
className="flex w-full items-center justify-center rounded-md px-3 py-1.5 text-sm text-foreground transition-colors duration-200 hover:text-foreground"
|
||||||
|
aria-label={t("actions.expand")}
|
||||||
|
>
|
||||||
|
<IconLayoutSidebar className="size-4 flex-shrink-0" />
|
||||||
|
<span className="sr-only">{t("actions.expand")}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{navItems.map((item, index) => {
|
<button
|
||||||
const isActive = activeView === item.id;
|
type="button"
|
||||||
return (
|
onClick={onSettingsBack}
|
||||||
|
title={
|
||||||
|
collapsed ? t("actions.backToMainNavigation") : undefined
|
||||||
|
}
|
||||||
|
aria-label={t("actions.backToMainNavigation")}
|
||||||
|
className={cn(
|
||||||
|
"mb-3 flex w-full items-center rounded-md text-sm text-foreground transition-colors duration-200 hover:bg-background-alt hover:text-foreground",
|
||||||
|
collapsed
|
||||||
|
? "justify-center px-3 py-1.5"
|
||||||
|
: "gap-2.5 px-3 py-1.5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IconArrowLeft className="size-4 flex-shrink-0" />
|
||||||
|
{!collapsed && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"whitespace-nowrap",
|
||||||
|
labelTransition,
|
||||||
|
labelVisible
|
||||||
|
? "opacity-100 w-auto"
|
||||||
|
: "opacity-0 w-0 overflow-hidden",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("actions.backToMainNavigation")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{SETTINGS_SECTIONS.map((item, index) => (
|
||||||
<SidebarNavItem
|
<SidebarNavItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
icon={item.icon}
|
icon={item.icon}
|
||||||
label={item.label}
|
label={t(`settings:${item.labelKey}`)}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
labelTransition={labelTransition}
|
labelTransition={labelTransition}
|
||||||
labelVisible={labelVisible}
|
labelVisible={labelVisible}
|
||||||
isActive={isActive}
|
isActive={activeSettingsSection === item.id}
|
||||||
onClick={() => onNavigate?.(item.id)}
|
onClick={() => onSettingsSectionChange?.(item.id)}
|
||||||
itemTransitionDelay={
|
itemTransitionDelay={
|
||||||
!collapsed && expanded ? `${index * 30}ms` : "0ms"
|
!collapsed && expanded ? `${index * 30}ms` : "0ms"
|
||||||
}
|
}
|
||||||
@@ -437,111 +644,9 @@ export function Sidebar({
|
|||||||
labelVisible ? `${index * 30 + 60}ms` : "0ms"
|
labelVisible ? `${index * 30 + 60}ms` : "0ms"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
))}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</nav>
|
||||||
|
|
||||||
{!collapsed &&
|
|
||||||
(sidebarSearch.submittedQuery ? (
|
|
||||||
<div className="relative z-10 space-y-2">
|
|
||||||
{sidebarSearch.error && (
|
|
||||||
<p className="px-1 text-xs text-danger">
|
|
||||||
{t("search.error")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{sidebarSearch.isSearching &&
|
|
||||||
sidebarSearch.results.length === 0 && (
|
|
||||||
<div className="rounded-lg border border-dashed border-border px-3 py-6 text-center text-xs text-muted-foreground">
|
|
||||||
{t("search.searching")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(!sidebarSearch.isSearching ||
|
|
||||||
sidebarSearch.results.length > 0) && (
|
|
||||||
<SidebarSearchResults
|
|
||||||
results={sidebarSearch.results}
|
|
||||||
activeSessionId={activeSessionId}
|
|
||||||
onSelectResult={(sessionId, messageId) => {
|
|
||||||
if (messageId) {
|
|
||||||
onSelectSearchResult?.(
|
|
||||||
sessionId,
|
|
||||||
messageId,
|
|
||||||
sidebarSearch.submittedQuery,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSelectSession?.(sessionId);
|
|
||||||
}}
|
|
||||||
getPersonaName={sidebarResolvers.getPersonaName}
|
|
||||||
getProjectName={sidebarResolvers.getProjectName}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<SidebarProjectsSection
|
|
||||||
projects={projects}
|
|
||||||
projectSessions={projectSessions}
|
|
||||||
expandedProjects={expandedProjects}
|
|
||||||
toggleProject={toggleProject}
|
|
||||||
collapsed={collapsed}
|
|
||||||
labelTransition={labelTransition}
|
|
||||||
labelVisible={labelVisible}
|
|
||||||
activeSessionId={activeSessionId}
|
|
||||||
onNavigate={onNavigate}
|
|
||||||
onSelectSession={onSelectSession}
|
|
||||||
onNewChatInProject={onNewChatInProject}
|
|
||||||
onNewChat={onNewChat}
|
|
||||||
onCreateProject={onCreateProject}
|
|
||||||
onEditProject={onEditProject}
|
|
||||||
onArchiveProject={onArchiveProject}
|
|
||||||
onArchiveChat={onArchiveChat}
|
|
||||||
onRenameChat={onRenameChat}
|
|
||||||
onMoveToProject={onMoveToProject}
|
|
||||||
onReorderProject={onReorderProject}
|
|
||||||
projectsSectionOpen={sectionVisibility.projects}
|
|
||||||
recentsSectionOpen={sectionVisibility.recents}
|
|
||||||
onToggleProjectsSection={() => toggleSection("projects")}
|
|
||||||
onToggleRecentsSection={() => toggleSection("recents")}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-x-0 bottom-0 z-20 bg-background",
|
|
||||||
"px-1.5 py-1.5",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size={collapsed ? "icon-sm" : "default"}
|
|
||||||
onClick={onSettingsClick}
|
|
||||||
className={cn(
|
|
||||||
"h-10 w-full rounded-md bg-transparent text-muted-foreground/85 hover:bg-transparent hover:text-foreground active:bg-transparent",
|
|
||||||
collapsed
|
|
||||||
? "justify-center p-3"
|
|
||||||
: "justify-start gap-2.5 px-3 py-2.5",
|
|
||||||
)}
|
|
||||||
title={t("settings:title")}
|
|
||||||
aria-label={t("settings:title")}
|
|
||||||
>
|
|
||||||
<IconSettings className="size-4 flex-shrink-0" />
|
|
||||||
{!collapsed && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"whitespace-nowrap text-sm",
|
|
||||||
labelTransition,
|
|
||||||
labelVisible
|
|
||||||
? "opacity-100 w-auto"
|
|
||||||
: "opacity-0 w-0 overflow-hidden",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t("settings:title")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -168,4 +168,62 @@ describe("Sidebar", () => {
|
|||||||
|
|
||||||
mockSessions.splice(0, mockSessions.length);
|
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(
|
||||||
|
<Sidebar
|
||||||
|
collapsed={false}
|
||||||
|
activeView="settings"
|
||||||
|
activeSettingsSection="providers"
|
||||||
|
onCollapse={vi.fn()}
|
||||||
|
onNavigate={vi.fn()}
|
||||||
|
onSettingsBack={onSettingsBack}
|
||||||
|
onSettingsSectionChange={onSettingsSectionChange}
|
||||||
|
projects={[]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<Sidebar
|
||||||
|
collapsed
|
||||||
|
activeView="settings"
|
||||||
|
activeSettingsSection="general"
|
||||||
|
onCollapse={onCollapse}
|
||||||
|
onNavigate={vi.fn()}
|
||||||
|
onSettingsBack={vi.fn()}
|
||||||
|
onSettingsSectionChange={vi.fn()}
|
||||||
|
projects={[]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /expand sidebar/i }));
|
||||||
|
|
||||||
|
expect(onCollapse).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"navigationLabel": "Settings navigation",
|
||||||
"about": {
|
"about": {
|
||||||
"buildModes": {
|
"buildModes": {
|
||||||
"development": "Development",
|
"development": "Development",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"actions": {
|
"actions": {
|
||||||
|
"backToMainNavigation": "Back",
|
||||||
"collapse": "Collapse sidebar",
|
"collapse": "Collapse sidebar",
|
||||||
"expand": "Expand sidebar",
|
"expand": "Expand sidebar",
|
||||||
"newChat": "New chat",
|
"newChat": "New chat",
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
"agents": "Agents",
|
"agents": "Agents",
|
||||||
"extensions": "Extensions",
|
"extensions": "Extensions",
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
|
"main": "Main navigation",
|
||||||
"sessionHistory": "Session history",
|
"sessionHistory": "Session history",
|
||||||
"skills": "Skills"
|
"skills": "Skills"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"navigationLabel": "Navegación de configuración",
|
||||||
"about": {
|
"about": {
|
||||||
"buildModes": {
|
"buildModes": {
|
||||||
"development": "Desarrollo",
|
"development": "Desarrollo",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"actions": {
|
"actions": {
|
||||||
|
"backToMainNavigation": "Atrás",
|
||||||
"collapse": "Contraer barra lateral",
|
"collapse": "Contraer barra lateral",
|
||||||
"expand": "Expandir barra lateral",
|
"expand": "Expandir barra lateral",
|
||||||
"newChat": "Nuevo chat",
|
"newChat": "Nuevo chat",
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
"agents": "Agentes",
|
"agents": "Agentes",
|
||||||
"extensions": "Extensiones",
|
"extensions": "Extensiones",
|
||||||
"home": "Inicio",
|
"home": "Inicio",
|
||||||
|
"main": "Navegación principal",
|
||||||
"sessionHistory": "Historial de sesiones",
|
"sessionHistory": "Historial de sesiones",
|
||||||
"skills": "Habilidades"
|
"skills": "Habilidades"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function SettingsPage({
|
|||||||
return (
|
return (
|
||||||
<div className={cn("min-h-full", className)}>
|
<div className={cn("min-h-full", className)}>
|
||||||
<div className="sticky top-0 z-20 -mx-6 border-b bg-background px-6 py-4">
|
<div className="sticky top-0 z-20 -mx-6 border-b bg-background px-6 py-4">
|
||||||
<div className="flex items-center justify-between gap-3 pr-12">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="max-w-prose truncate font-display text-sm font-semibold leading-5 tracking-tight">
|
<h3 className="max-w-prose truncate font-display text-sm font-semibold leading-5 tracking-tight">
|
||||||
{title}
|
{title}
|
||||||
@@ -40,7 +40,7 @@ export function SettingsPage({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{controls ? <div className="mt-3 pr-12">{controls}</div> : null}
|
{controls ? <div className="mt-3">{controls}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
{children ? (
|
{children ? (
|
||||||
<div className={cn("py-3", contentClassName)}>{children}</div>
|
<div className={cn("py-3", contentClassName)}>{children}</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ interface ShellProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
contentClassName?: string;
|
contentClassName?: string;
|
||||||
|
contentWidth?: "default" | "narrow";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageHeaderProps {
|
interface PageHeaderProps {
|
||||||
@@ -27,13 +28,17 @@ export function PageShell({
|
|||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
contentClassName,
|
contentClassName,
|
||||||
|
contentWidth = "default",
|
||||||
}: ShellProps) {
|
}: ShellProps) {
|
||||||
|
const widthClassName = contentWidth === "narrow" ? "max-w-3xl" : "max-w-5xl";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainPanelLayout className={className}>
|
<MainPanelLayout className={className}>
|
||||||
<div className="min-h-0 flex-1 overflow-y-scroll [scrollbar-gutter:stable]">
|
<div className="min-h-0 flex-1 overflow-y-scroll [scrollbar-gutter:stable]">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-auto flex w-full max-w-5xl flex-col gap-8 px-6 py-8 page-transition",
|
"mx-auto flex w-full flex-col gap-8 px-6 py-8 page-transition",
|
||||||
|
widthClassName,
|
||||||
contentClassName,
|
contentClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -48,13 +53,17 @@ export function DetailPageShell({
|
|||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
contentClassName,
|
contentClassName,
|
||||||
|
contentWidth = "default",
|
||||||
}: ShellProps) {
|
}: ShellProps) {
|
||||||
|
const widthClassName = contentWidth === "narrow" ? "max-w-3xl" : "max-w-5xl";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainPanelLayout className={className}>
|
<MainPanelLayout className={className}>
|
||||||
<div className="min-h-0 flex-1 overflow-y-scroll [scrollbar-gutter:stable]">
|
<div className="min-h-0 flex-1 overflow-y-scroll [scrollbar-gutter:stable]">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-auto flex w-full max-w-5xl flex-col gap-8 px-6 py-8 page-transition",
|
"mx-auto flex w-full flex-col gap-8 px-6 py-8 page-transition",
|
||||||
|
widthClassName,
|
||||||
contentClassName,
|
contentClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user