feat: goose2 compact settings modal headers (#8950)
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { IconPlus, IconSearch } from "@tabler/icons-react";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { SearchBar } from "@/shared/ui/SearchBar";
|
||||
import {
|
||||
listExtensions,
|
||||
addExtension,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "../types";
|
||||
import { ExtensionItem } from "./ExtensionItem";
|
||||
import { ExtensionModal } from "./ExtensionModal";
|
||||
import { SettingsPage } from "@/shared/ui/SettingsPage";
|
||||
|
||||
export function ExtensionsSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
@@ -140,26 +141,33 @@ export function ExtensionsSettings() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-display text-lg font-semibold tracking-tight">
|
||||
{t("extensions.title")}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("extensions.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<IconSearch className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
<SettingsPage
|
||||
title={t("extensions.title")}
|
||||
description={t("extensions.description")}
|
||||
actions={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xxs"
|
||||
onClick={() => {
|
||||
setEditingExtension(null);
|
||||
setModalMode("add");
|
||||
}}
|
||||
>
|
||||
<IconPlus className="size-4" />
|
||||
{t("extensions.addExtension")}
|
||||
</Button>
|
||||
}
|
||||
controls={
|
||||
<SearchBar
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onChange={setSearchTerm}
|
||||
placeholder={t("extensions.search")}
|
||||
className="pl-9"
|
||||
aria-label={t("extensions.search")}
|
||||
size="compact"
|
||||
/>
|
||||
</div>
|
||||
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="divide-y divide-border">
|
||||
{[1, 2, 3].map((i) => (
|
||||
@@ -212,19 +220,6 @@ export function ExtensionsSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingExtension(null);
|
||||
setModalMode("add");
|
||||
}}
|
||||
>
|
||||
<IconPlus className="size-4" />
|
||||
{t("extensions.addExtension")}
|
||||
</Button>
|
||||
|
||||
{modalMode === "add" && (
|
||||
<ExtensionModal onSubmit={handleSubmit} onClose={handleModalClose} />
|
||||
)}
|
||||
@@ -237,6 +232,6 @@ export function ExtensionsSettings() {
|
||||
onClose={handleModalClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsPage } from "@/shared/ui/SettingsPage";
|
||||
|
||||
export function AboutSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
return (
|
||||
<SettingsPage
|
||||
title={t("about.title")}
|
||||
description={t("about.description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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 },
|
||||
@@ -56,13 +57,7 @@ export function AppearanceSettings() {
|
||||
useTheme();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold font-display tracking-tight">
|
||||
{t("appearance.title")}
|
||||
</h3>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<SettingsPage title={t("appearance.title")}>
|
||||
<SettingRow
|
||||
label={t("appearance.theme.label")}
|
||||
description={t("appearance.theme.description")}
|
||||
@@ -137,6 +132,6 @@ export function AppearanceSettings() {
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsPage } from "@/shared/ui/SettingsPage";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
type ChatSession,
|
||||
useChatSessionStore,
|
||||
} from "@/features/chat/stores/chatSessionStore";
|
||||
import { getDisplaySessionTitle } from "@/features/chat/lib/sessionTitle";
|
||||
|
||||
export function ChatsSettings() {
|
||||
const { t } = useTranslation(["settings", "common"]);
|
||||
const [archivedChats, setArchivedChats] = useState<ChatSession[]>([]);
|
||||
const [loadingArchivedChats, setLoadingArchivedChats] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setArchivedChats(useChatSessionStore.getState().getArchivedSessions());
|
||||
setLoadingArchivedChats(false);
|
||||
}, []);
|
||||
|
||||
async function handleRestoreChat(id: string) {
|
||||
await useChatSessionStore.getState().unarchiveSession(id);
|
||||
setArchivedChats((prev) => prev.filter((session) => session.id !== id));
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsPage title={t("chats.title")} description={t("chats.description")}>
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">{t("chats.sectionTitle")}</h4>
|
||||
{!loadingArchivedChats && archivedChats.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t("chats.empty")}</p>
|
||||
) : null}
|
||||
{archivedChats.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm">
|
||||
{getDisplaySessionTitle(
|
||||
session.title,
|
||||
t("common:session.defaultTitle"),
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{session.projectId
|
||||
? t("chats.types.project")
|
||||
: t("chats.types.standalone")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => handleRestoreChat(session.id)}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{t("common:actions.restore")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
@@ -2,22 +2,17 @@ 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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold font-display tracking-tight">
|
||||
{t("compaction.title")}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("compaction.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsPage
|
||||
title={t("compaction.title")}
|
||||
description={t("compaction.description")}
|
||||
>
|
||||
<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">
|
||||
@@ -42,6 +37,6 @@ export function CompactionSettings() {
|
||||
<GooseAutoCompactSettings />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { RefreshCw, ClipboardCopy, Check, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
import {
|
||||
runDoctor,
|
||||
type DoctorCheck,
|
||||
type DoctorReport,
|
||||
} from "@/shared/api/doctor";
|
||||
import { DoctorCheckRow } from "./DoctorCheckRow";
|
||||
import { SettingsPage } from "@/shared/ui/SettingsPage";
|
||||
|
||||
function formatDebugReport(report: DoctorReport): string {
|
||||
const STATUS_ICONS: Record<DoctorCheck["status"], string> = {
|
||||
@@ -93,20 +93,15 @@ export function DoctorSettings() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-4 pr-8">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold font-display tracking-tight">
|
||||
{t("doctor.title")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<SettingsPage
|
||||
title={t("doctor.title")}
|
||||
actions={
|
||||
<>
|
||||
{report && !loading && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
size="xxs"
|
||||
onClick={copyDebugInfo}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
@@ -123,7 +118,7 @@ export function DoctorSettings() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
size="xxs"
|
||||
onClick={runChecksAndRefresh}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
@@ -131,11 +126,9 @@ export function DoctorSettings() {
|
||||
{t("doctor.rerun")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
</>
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex min-h-[160px] items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
@@ -180,6 +173,6 @@ export function DoctorSettings() {
|
||||
{t("doctor.empty")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
import { SettingsPage } from "@/shared/ui/SettingsPage";
|
||||
|
||||
function SettingRow({
|
||||
label,
|
||||
@@ -36,16 +36,10 @@ export function GeneralSettings() {
|
||||
const { preference, setLocalePreference, systemLocaleLabel } = useLocale();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold font-display tracking-tight">
|
||||
{t("general.title")}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("general.description")}
|
||||
</p>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<SettingsPage
|
||||
title={t("general.title")}
|
||||
description={t("general.description")}
|
||||
>
|
||||
<SettingRow
|
||||
label={t("general.language.label")}
|
||||
description={t("general.language.description")}
|
||||
@@ -70,6 +64,6 @@ export function GeneralSettings() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsPage } from "@/shared/ui/SettingsPage";
|
||||
import { Button, buttonVariants } from "@/shared/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/ui/alert-dialog";
|
||||
import {
|
||||
deleteProject,
|
||||
listArchivedProjects,
|
||||
restoreProject,
|
||||
type ProjectInfo,
|
||||
} from "@/features/projects/api/projects";
|
||||
import { ProjectIcon } from "@/features/projects/ui/ProjectIcon";
|
||||
import { useProjectStore } from "@/features/projects/stores/projectStore";
|
||||
|
||||
export function ProjectsSettings() {
|
||||
const { t } = useTranslation(["settings", "common"]);
|
||||
const [archivedProjects, setArchivedProjects] = useState<ProjectInfo[]>([]);
|
||||
const [loadingArchived, setLoadingArchived] = useState(true);
|
||||
const [deletingProject, setDeletingProject] = useState<ProjectInfo | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
listArchivedProjects()
|
||||
.then(setArchivedProjects)
|
||||
.catch(() => setArchivedProjects([]))
|
||||
.finally(() => setLoadingArchived(false));
|
||||
}, []);
|
||||
|
||||
async function handleRestoreProject(id: string) {
|
||||
try {
|
||||
await restoreProject(id);
|
||||
await useProjectStore.getState().fetchProjects();
|
||||
setArchivedProjects((prev) => prev.filter((p) => p.id !== id));
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await deleteProject(id);
|
||||
setArchivedProjects((prev) => prev.filter((p) => p.id !== id));
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsPage
|
||||
title={t("projects.title")}
|
||||
description={t("projects.description")}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("projects.sectionTitle")}
|
||||
</h4>
|
||||
{!loadingArchived && archivedProjects.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("projects.empty")}
|
||||
</p>
|
||||
) : null}
|
||||
{archivedProjects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ProjectIcon
|
||||
icon={project.icon}
|
||||
className="size-4 shrink-0 text-foreground"
|
||||
imageClassName="size-4 shrink-0 rounded-[4px]"
|
||||
/>
|
||||
<span className="truncate text-sm">{project.name}</span>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => handleRestoreProject(project.id)}
|
||||
>
|
||||
{t("common:actions.restore")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => setDeletingProject(project)}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
{t("common:actions.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingsPage>
|
||||
|
||||
<AlertDialog
|
||||
open={!!deletingProject}
|
||||
onOpenChange={(open) => !open && setDeletingProject(null)}
|
||||
>
|
||||
<AlertDialogContent className="max-w-sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("deleteProject.title", {
|
||||
name: deletingProject?.name ?? "",
|
||||
})}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("deleteProject.description", {
|
||||
name: deletingProject?.name ?? "",
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common:actions.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={buttonVariants({ variant: "destructive" })}
|
||||
onClick={() => {
|
||||
if (deletingProject) {
|
||||
void handleDelete(deletingProject.id);
|
||||
setDeletingProject(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("common:actions.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore";
|
||||
import { AgentProviderCard } from "./AgentProviderCard";
|
||||
import { ModelProviderRow } from "./ModelProviderRow";
|
||||
import { SettingsPage } from "@/shared/ui/SettingsPage";
|
||||
import {
|
||||
catalogEntryToTemplate,
|
||||
formValueToDraft,
|
||||
@@ -306,13 +307,21 @@ export function ProvidersSettings() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold font-display tracking-tight">
|
||||
{t("providers.title")}
|
||||
</h3>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<SettingsPage
|
||||
title={t("providers.title")}
|
||||
actions={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xxs"
|
||||
onClick={() => void openCreateCustomProvider()}
|
||||
leftIcon={<IconPlus />}
|
||||
className="shrink-0"
|
||||
>
|
||||
{t("providers.custom.addButton")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<section>
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold">
|
||||
@@ -334,34 +343,20 @@ export function ProvidersSettings() {
|
||||
|
||||
<section>
|
||||
<div className="mb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("providers.models.title")}
|
||||
</h4>
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Spinner className="size-3 text-accent" />
|
||||
{t("providers.models.checkingStatus")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("providers.models.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void openCreateCustomProvider()}
|
||||
leftIcon={<IconPlus />}
|
||||
className="shrink-0"
|
||||
>
|
||||
{t("providers.custom.addButton")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("providers.models.title")}
|
||||
</h4>
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Spinner className="size-3 text-accent" />
|
||||
{t("providers.models.checkingStatus")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("providers.models.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{customProviderError ? (
|
||||
@@ -487,6 +482,6 @@ export function ProvidersSettings() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button, buttonVariants } from "@/shared/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/ui/alert-dialog";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Mic,
|
||||
Minimize2,
|
||||
@@ -31,18 +21,9 @@ import { ExtensionsSettings } from "@/features/extensions/ui/ExtensionsSettings"
|
||||
import { VoiceInputSettings } from "./VoiceInputSettings";
|
||||
import { GeneralSettings } from "./GeneralSettings";
|
||||
import { CompactionSettings } from "./CompactionSettings";
|
||||
import {
|
||||
listArchivedProjects,
|
||||
restoreProject,
|
||||
deleteProject,
|
||||
type ProjectInfo,
|
||||
} from "@/features/projects/api/projects";
|
||||
import { ProjectIcon } from "@/features/projects/ui/ProjectIcon";
|
||||
import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
|
||||
import { useProjectStore } from "@/features/projects/stores/projectStore";
|
||||
import { getDisplaySessionTitle } from "@/features/chat/lib/sessionTitle";
|
||||
|
||||
import type { Session } from "@/shared/types/chat";
|
||||
import { ProjectsSettings } from "./ProjectsSettings";
|
||||
import { ChatsSettings } from "./ChatsSettings";
|
||||
import { AboutSettings } from "./AboutSettings";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: "appearance", labelKey: "nav.appearance", icon: Palette },
|
||||
@@ -71,14 +52,7 @@ export function SettingsModal({
|
||||
const { t } = useTranslation(["settings", "common"]);
|
||||
const [activeSection, setActiveSection] = useState<SectionId>(initialSection);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const [archivedProjects, setArchivedProjects] = useState<ProjectInfo[]>([]);
|
||||
const [archivedChats, setArchivedChats] = useState<Session[]>([]);
|
||||
const [loadingArchived, setLoadingArchived] = useState(true);
|
||||
const [loadingArchivedChats, setLoadingArchivedChats] = useState(true);
|
||||
const [deletingProject, setDeletingProject] = useState<ProjectInfo | null>(
|
||||
null,
|
||||
);
|
||||
const modalRootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Trigger entrance animations after mount
|
||||
useEffect(() => {
|
||||
@@ -86,69 +60,41 @@ export function SettingsModal({
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Load archived projects on mount
|
||||
useEffect(() => {
|
||||
listArchivedProjects()
|
||||
.then(setArchivedProjects)
|
||||
.catch(() => setArchivedProjects([]))
|
||||
.finally(() => setLoadingArchived(false));
|
||||
}, []);
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === "Escape" &&
|
||||
!event.defaultPrevented &&
|
||||
event.target instanceof Node &&
|
||||
modalRootRef.current?.contains(event.target)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Load archived chats from the session store (persisted in localStorage)
|
||||
useEffect(() => {
|
||||
const archived = useChatSessionStore.getState().getArchivedSessions();
|
||||
setArchivedChats(archived as unknown as Session[]);
|
||||
setLoadingArchivedChats(false);
|
||||
}, []);
|
||||
|
||||
const handleRestoreProject = async (id: string) => {
|
||||
try {
|
||||
await restoreProject(id);
|
||||
await useProjectStore.getState().fetchProjects();
|
||||
setArchivedProjects((prev) => prev.filter((p) => p.id !== id));
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreChat = async (id: string) => {
|
||||
await useChatSessionStore.getState().unarchiveSession(id);
|
||||
setArchivedChats((prev) => prev.filter((session) => session.id !== id));
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteProject(id);
|
||||
setArchivedProjects((prev) => prev.filter((p) => p.id !== id));
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
};
|
||||
|
||||
// Content transition on section change
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: activeSection triggers the transition effect intentionally
|
||||
useEffect(() => {
|
||||
setIsTransitioning(true);
|
||||
const timer = setTimeout(() => setIsTransitioning(false), 150);
|
||||
return () => clearTimeout(timer);
|
||||
}, [activeSection]);
|
||||
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}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") 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 */}
|
||||
@@ -207,37 +153,20 @@ export function SettingsModal({
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative flex-1 overflow-y-auto">
|
||||
<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-10 rounded-md text-muted-foreground hover:text-foreground"
|
||||
className="absolute right-4 top-4 z-30 rounded-md text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"px-6 py-4 transition-all duration-400 ease-out",
|
||||
isTransitioning
|
||||
? "opacity-0 translate-y-2"
|
||||
: "opacity-100 translate-y-0",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-600 ease-out",
|
||||
isLoaded
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-4",
|
||||
)}
|
||||
style={{
|
||||
transitionDelay: isLoaded ? "400ms" : "0ms",
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="px-6 pb-4">
|
||||
{activeSection === "appearance" && <AppearanceSettings />}
|
||||
{activeSection === "providers" && <ProvidersSettings />}
|
||||
{activeSection === "compaction" && <CompactionSettings />}
|
||||
@@ -245,166 +174,13 @@ export function SettingsModal({
|
||||
{activeSection === "voice" && <VoiceInputSettings />}
|
||||
{activeSection === "doctor" && <DoctorSettings />}
|
||||
{activeSection === "general" && <GeneralSettings />}
|
||||
{activeSection === "projects" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold font-display tracking-tight">
|
||||
{t("projects.title")}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("projects.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Archived Projects */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("projects.sectionTitle")}
|
||||
</h3>
|
||||
{!loadingArchived && archivedProjects.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("projects.empty")}
|
||||
</p>
|
||||
)}
|
||||
{archivedProjects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ProjectIcon
|
||||
icon={project.icon}
|
||||
className="size-4 shrink-0 text-foreground"
|
||||
imageClassName="size-4 shrink-0 rounded-[4px]"
|
||||
/>
|
||||
<span className="text-sm truncate">
|
||||
{project.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => handleRestoreProject(project.id)}
|
||||
>
|
||||
{t("common:actions.restore")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => setDeletingProject(project)}
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{t("common:actions.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeSection === "chats" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold font-display tracking-tight">
|
||||
{t("chats.title")}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("chats.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("chats.sectionTitle")}
|
||||
</h3>
|
||||
{!loadingArchivedChats && archivedChats.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("chats.empty")}
|
||||
</p>
|
||||
)}
|
||||
{archivedChats.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm">
|
||||
{getDisplaySessionTitle(
|
||||
session.title,
|
||||
t("common:session.defaultTitle"),
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{session.projectId
|
||||
? t("chats.types.project")
|
||||
: t("chats.types.standalone")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => handleRestoreChat(session.id)}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{t("common:actions.restore")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeSection === "about" && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold font-display tracking-tight">
|
||||
{t("about.title")}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("about.description")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{activeSection === "projects" && <ProjectsSettings />}
|
||||
{activeSection === "chats" && <ChatsSettings />}
|
||||
{activeSection === "about" && <AboutSettings />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
open={!!deletingProject}
|
||||
onOpenChange={(open) => !open && setDeletingProject(null)}
|
||||
>
|
||||
<AlertDialogContent className="max-w-sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("deleteProject.title", {
|
||||
name: deletingProject?.name ?? "",
|
||||
})}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("deleteProject.description", {
|
||||
name: deletingProject?.name ?? "",
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common:actions.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={buttonVariants({ variant: "destructive" })}
|
||||
onClick={() => {
|
||||
if (deletingProject) {
|
||||
handleDelete(deletingProject.id);
|
||||
setDeletingProject(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("common:actions.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import { SettingsPage } from "@/shared/ui/SettingsPage";
|
||||
|
||||
const DISABLED_PROVIDER = "__disabled__";
|
||||
|
||||
@@ -221,28 +222,23 @@ export function VoiceInputSettings() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("general.voiceInput.label")}
|
||||
</h4>
|
||||
<SettingsPage
|
||||
title={t("general.voiceInput.label")}
|
||||
description={t("general.voiceInput.description")}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("common:labels.loading")}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("general.voiceInput.label")}
|
||||
</h4>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("general.voiceInput.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsPage
|
||||
title={t("general.voiceInput.label")}
|
||||
description={t("general.voiceInput.description")}
|
||||
contentClassName="space-y-4"
|
||||
>
|
||||
<div className="space-y-2 rounded-lg border border-border px-3 py-3">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{t("general.voiceInput.providerLabel")}
|
||||
@@ -484,6 +480,6 @@ export function VoiceInputSettings() {
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,14 @@ import { cn } from "@/shared/lib/cn";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
|
||||
const searchBarSizes = {
|
||||
compact: {
|
||||
wrapper:
|
||||
"rounded-md border border-border-soft px-2 py-1 text-xs text-muted-foreground hover:bg-transparent hover:text-foreground",
|
||||
icon: "left-2.5 size-3",
|
||||
input:
|
||||
"h-auto border-none bg-transparent px-0 pl-5 pr-0 text-[11px] font-normal shadow-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||
inputVariant: "ghost" as const,
|
||||
},
|
||||
small: {
|
||||
wrapper:
|
||||
"rounded-md border border-border-soft px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-transparent hover:text-foreground",
|
||||
@@ -36,6 +44,8 @@ interface SearchBarProps {
|
||||
size?: keyof typeof searchBarSizes;
|
||||
/** Optional ref for the underlying input */
|
||||
inputRef?: React.Ref<HTMLInputElement>;
|
||||
/** Accessible label for the search input */
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
@@ -46,6 +56,7 @@ export function SearchBar({
|
||||
className,
|
||||
size = "default",
|
||||
inputRef,
|
||||
"aria-label": ariaLabel,
|
||||
}: SearchBarProps) {
|
||||
const styles = searchBarSizes[size];
|
||||
|
||||
@@ -62,11 +73,14 @@ export function SearchBar({
|
||||
variant={styles.inputVariant}
|
||||
type="search"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
className={cn("w-full placeholder:text-placeholder", styles.input)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SettingsPage } from "./SettingsPage";
|
||||
|
||||
describe("SettingsPage", () => {
|
||||
it("renders title-only headers", () => {
|
||||
render(<SettingsPage title="General" />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "General" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders description, actions, controls, and children", () => {
|
||||
render(
|
||||
<SettingsPage
|
||||
title="Extensions"
|
||||
description="Manage extensions"
|
||||
actions={<button type="button">Add</button>}
|
||||
controls={<input aria-label="Search extensions" />}
|
||||
contentClassName="custom-content"
|
||||
>
|
||||
<div>Extension list</div>
|
||||
</SettingsPage>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Manage extensions")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Search extensions")).toBeInTheDocument();
|
||||
expect(screen.getByText("Extension list").parentElement).toHaveClass(
|
||||
"custom-content",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
interface SettingsPageProps {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
controls?: ReactNode;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
export function SettingsPage({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
controls,
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
}: SettingsPageProps) {
|
||||
return (
|
||||
<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="flex items-center justify-between gap-3 pr-12">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="max-w-prose truncate font-display text-sm font-semibold leading-5 tracking-tight">
|
||||
{title}
|
||||
</h3>
|
||||
{description ? (
|
||||
<p className="mt-0.5 max-w-prose text-xs leading-4 text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex flex-shrink-0 items-center gap-1.5">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{controls ? <div className="mt-3 pr-12">{controls}</div> : null}
|
||||
</div>
|
||||
{children ? (
|
||||
<div className={cn("py-3", contentClassName)}>{children}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ const buttonVariants = cva(
|
||||
link: "text-brand underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
xxs: "h-6 gap-1.5 px-2 text-[11px]",
|
||||
xs: "h-7 px-2.5 text-xs",
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 px-3 text-xs",
|
||||
@@ -97,6 +98,7 @@ const buttonVariants = cva(
|
||||
);
|
||||
|
||||
const buttonIconSizeClasses = {
|
||||
xxs: "size-3",
|
||||
xs: "size-3",
|
||||
default: "size-3.5",
|
||||
sm: "size-3",
|
||||
|
||||
Reference in New Issue
Block a user