Files
multica/packages/views/settings/components/settings-page.tsx
Jiayuan Zhang 82906bd9e9 feat(settings): move create-issue field config into a Settings Issue tab (#5556)
The quick-create field toggles used to live inside the quick create
popover; per feedback on MUL-4873 they belong in the Settings page.
Adds a My Account 'Issue' tab with one group per create mode backed by
a new per-workspace issue-create-settings store, extends field
visibility to the manual dialog (status/priority/assignee/labels/
project/due date/start date with value-reveal and overflow re-entry),
and routes both dialogs' 'Customize fields' items to the tab.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 13:36:13 +08:00

234 lines
8.4 KiB
TypeScript

"use client";
import React from "react";
import {
User,
SlidersHorizontal,
Key,
Settings,
Users,
FolderGit2,
FlaskConical,
Bell,
Plug,
MessageCircle,
Tags,
Keyboard,
ListTodo,
} from "lucide-react";
import { GitHubMark } from "./github-mark";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@multica/ui/components/ui/tabs";
import { useIsMobile } from "@multica/ui/hooks/use-mobile";
import { useCurrentWorkspace } from "@multica/core/paths";
import { useNavigation } from "../../navigation";
import { AccountTab } from "./account-tab";
import { PreferencesTab } from "./preferences-tab";
import { ChatTab } from "./chat-tab";
import { IssueTab } from "./issue-tab";
import { TokensTab } from "./tokens-tab";
import { WorkspaceTab } from "./workspace-tab";
import { MembersTab } from "./members-tab";
import { RepositoriesTab } from "./repositories-tab";
import { GitHubTab } from "./github-tab";
import { IntegrationsTab } from "./integrations-tab";
import { LabsTab } from "./labs-tab";
import { NotificationsTab } from "./notifications-tab";
import { LabelsTab } from "./labels-tab";
import { PropertiesTab } from "./properties-tab";
import { KeyboardShortcutsTab } from "./keyboard-shortcuts-tab";
import { useT } from "../../i18n";
const ACCOUNT_TAB_KEYS = ["profile", "preferences", "shortcuts", "issue", "chat", "notifications", "tokens"] as const;
const ACCOUNT_TAB_ICONS = {
profile: User,
preferences: SlidersHorizontal,
shortcuts: Keyboard,
issue: ListTodo,
chat: MessageCircle,
notifications: Bell,
tokens: Key,
} as const;
const WORKSPACE_TAB_KEYS = [
"general",
"repositories",
"github",
"integrations",
"labs",
"members",
"labels",
"properties",
] as const;
const WORKSPACE_TAB_VALUES = {
general: "workspace",
repositories: "repositories",
github: "github",
integrations: "integrations",
labs: "labs",
members: "members",
labels: "labels",
properties: "properties",
} as const;
const WORKSPACE_TAB_ICONS = {
general: Settings,
repositories: FolderGit2,
github: GitHubMark,
integrations: Plug,
labs: FlaskConical,
members: Users,
labels: Tags,
properties: SlidersHorizontal,
} as const;
const DEFAULT_TAB = "profile";
const TAB_QUERY_KEY = "tab";
// Legacy `?tab=…` values that have been collapsed into another tab. Old
// bookmarks still land on the correct surface without us preserving a
// dead TabsContent entry. Lark used to be its own top-level workspace
// tab; it now lives inside Integrations.
const LEGACY_WORKSPACE_TAB_REDIRECTS: Record<string, string> = {
lark: "integrations",
};
const SETTINGS_TAB_TRIGGER_CLASS =
"h-8 shrink-0 px-2.5 hover:bg-surface-hover data-active:!bg-surface-selected data-active:!text-surface-selected-foreground data-active:hover:!bg-surface-selected md:!w-full md:px-2 md:after:hidden";
export interface ExtraSettingsTab {
value: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
content: React.ReactNode;
}
interface SettingsPageProps {
/** Additional tabs injected by platform (e.g. desktop daemon settings) */
extraAccountTabs?: ExtraSettingsTab[];
}
export function SettingsPage({ extraAccountTabs }: SettingsPageProps = {}) {
const { t } = useT("settings");
const workspaceName = useCurrentWorkspace()?.name;
const navigation = useNavigation();
const isMobile = useIsMobile();
// Whitelist of valid tab values; unknown ?tab=… values silently fall back to
// the default. Whitelisting also blocks junk like ?tab=<script> from
// surfacing in the DOM via Radix Tabs internals.
const validTabs = React.useMemo(
() =>
new Set<string>([
...ACCOUNT_TAB_KEYS,
...Object.values(WORKSPACE_TAB_VALUES),
...(extraAccountTabs?.map((tab) => tab.value) ?? []),
]),
[extraAccountTabs],
);
const tabFromUrl = navigation.searchParams.get(TAB_QUERY_KEY);
const candidateTab = tabFromUrl
? LEGACY_WORKSPACE_TAB_REDIRECTS[tabFromUrl] ?? tabFromUrl
: null;
const activeTab =
candidateTab && validTabs.has(candidateTab) ? candidateTab : DEFAULT_TAB;
// replace (not push) so settings tab switches don't pollute browser history.
// Preserve any other query params the page may carry.
const handleTabChange = (next: string) => {
const params = new URLSearchParams(navigation.searchParams);
params.set(TAB_QUERY_KEY, next);
navigation.replace(`${navigation.pathname}?${params.toString()}`);
};
return (
<Tabs
value={activeTab}
onValueChange={handleTabChange}
orientation={isMobile ? "horizontal" : "vertical"}
className="flex flex-1 min-h-0 flex-col gap-0 overflow-y-auto md:flex-row md:overflow-hidden"
>
{/* Structural navigation; bounded setting groups remain in the content surface.
Stays on the content surface color (no shell tint): the desktop's active
tab merges into the card top, and a tinted panel under the first tabs
breaks that seam (MUL-4439). Zoning comes from the divider instead. */}
<div className="shrink-0 overflow-x-auto border-b border-surface-border p-2 md:w-56 md:overflow-y-auto md:border-b-0 md:border-r md:p-4">
<h1 className="sr-only text-sm font-semibold md:not-sr-only md:mb-4 md:px-2">{t(($) => $.page.title)}</h1>
<TabsList
variant="line"
className="flex w-max min-w-full flex-row items-center gap-1 p-0 md:w-full md:flex-col md:items-stretch"
>
{/* My Account group */}
<span className="hidden px-2 pb-1 pt-2 text-xs font-medium text-muted-foreground md:block">
{t(($) => $.page.my_account)}
</span>
{ACCOUNT_TAB_KEYS.map((key) => {
const Icon = ACCOUNT_TAB_ICONS[key];
return (
<TabsTrigger
key={key}
value={key}
className={SETTINGS_TAB_TRIGGER_CLASS}
>
<Icon className="h-4 w-4" />
{t(($) => $.page.tabs[key])}
</TabsTrigger>
);
})}
{extraAccountTabs?.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className={SETTINGS_TAB_TRIGGER_CLASS}
>
<tab.icon className="h-4 w-4" />
{tab.label}
</TabsTrigger>
))}
{/* Workspace group */}
<span className="hidden truncate px-2 pb-1 pt-4 text-xs font-medium text-muted-foreground md:block">
{workspaceName ?? t(($) => $.page.workspace_fallback)}
</span>
{WORKSPACE_TAB_KEYS.map((key) => {
const Icon = WORKSPACE_TAB_ICONS[key];
return (
<TabsTrigger
key={key}
value={WORKSPACE_TAB_VALUES[key]}
className={SETTINGS_TAB_TRIGGER_CLASS}
>
<Icon className="h-4 w-4" />
{t(($) => $.page.tabs[key])}
</TabsTrigger>
);
})}
</TabsList>
</div>
{/* Right content */}
<div className="min-w-0 flex-1 md:overflow-y-auto">
<div className={`mx-auto w-full p-4 sm:p-6 md:p-8 ${activeTab === "labels" || activeTab === "properties" ? "max-w-5xl" : "max-w-3xl"}`}>
<TabsContent value="profile"><AccountTab /></TabsContent>
<TabsContent value="preferences"><PreferencesTab /></TabsContent>
<TabsContent value="shortcuts"><KeyboardShortcutsTab /></TabsContent>
<TabsContent value="issue"><IssueTab /></TabsContent>
<TabsContent value="chat"><ChatTab /></TabsContent>
<TabsContent value="notifications"><NotificationsTab /></TabsContent>
<TabsContent value="tokens"><TokensTab /></TabsContent>
<TabsContent value="workspace"><WorkspaceTab /></TabsContent>
<TabsContent value="repositories"><RepositoriesTab /></TabsContent>
<TabsContent value="github"><GitHubTab /></TabsContent>
<TabsContent value="integrations"><IntegrationsTab /></TabsContent>
<TabsContent value="labs"><LabsTab /></TabsContent>
<TabsContent value="members"><MembersTab /></TabsContent>
<TabsContent value="labels"><LabelsTab /></TabsContent>
<TabsContent value="properties"><PropertiesTab /></TabsContent>
{extraAccountTabs?.map((tab) => (
<TabsContent key={tab.value} value={tab.value}>{tab.content}</TabsContent>
))}
</div>
</div>
</Tabs>
);
}