feat(web-shell): refresh settings page with shadcn (#6817)

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
This commit is contained in:
ytahdn 2026-07-13 19:50:46 +08:00 committed by GitHub
parent 79ae054bb8
commit d98c371cc5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 427 additions and 584 deletions

View file

@ -1,295 +0,0 @@
.panel {
display: flex;
flex-direction: column;
gap: 10px;
margin: 0 0 12px;
padding: 10px 14px;
max-width: min(var(--chat-regular-content-width, 1000px), calc(100vw - 64px));
border: 1px solid var(--border);
border-radius: 4px;
background: var(--background);
color: var(--foreground);
font-family: var(--font-mono);
font-size: 13px;
}
.embeddedPanel {
margin: 0;
padding: 0;
max-width: none;
border: 0;
border-radius: 0;
background: transparent;
font-size: 12px;
}
.header {
display: flex;
align-items: baseline;
gap: 8px;
}
.title {
font-weight: 700;
}
.secondary,
.hint,
.empty,
.busy {
color: var(--muted-foreground);
}
.hint {
padding: 8px;
border-radius: 6px;
background: var(--background);
}
.scopeTabs {
display: inline-flex;
align-self: flex-start;
gap: 4px;
padding: 3px;
border-radius: 8px;
background: var(--background);
}
.scopeTab {
min-height: 26px;
padding: 4px 10px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--muted-foreground);
cursor: pointer;
font: inherit;
font-size: 12px;
}
.scopeTab:hover,
.scopeTabActive {
background: var(--secondary);
color: var(--foreground);
}
.settingsPage {
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: 12px;
min-height: 420px;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
padding-right: 8px;
border-right: 1px solid var(--border);
}
.categoryButton {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-height: 32px;
padding: 5px 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--muted-foreground);
cursor: pointer;
font: inherit;
text-align: left;
}
.categoryButton:hover,
.categoryButtonActive {
background: var(--secondary);
color: var(--foreground);
}
.categoryCount {
flex: 0 0 auto;
color: var(--muted-foreground);
font-size: 11px;
}
.content {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
}
.card {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(170px, max-content);
gap: 16px;
align-items: center;
padding: 8px;
border-radius: 8px;
background: var(--background);
}
.card:hover {
background: var(--secondary);
}
.cardBody {
min-width: 0;
}
.cardTitle {
display: flex;
align-items: center;
gap: 6px;
color: var(--foreground);
font-size: 12px;
font-weight: 500;
}
.cardDescription,
.scopeHint {
margin-top: 3px;
color: var(--muted-foreground);
font-size: 12px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.scopeBadge {
flex: 0 0 auto;
padding: 1px 5px;
border-radius: 999px;
background: var(--secondary);
color: var(--muted-foreground);
font-size: 11px;
font-weight: 400;
}
.cardControl {
display: flex;
justify-content: flex-end;
min-width: 0;
}
.select,
.actionButton,
.editInput {
min-height: 28px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--background);
color: var(--foreground);
font: inherit;
font-size: 12px;
}
.select {
max-width: 260px;
padding: 4px 28px 4px 8px;
}
.actionButton {
max-width: 260px;
padding: 4px 9px;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actionButton:hover {
background: var(--secondary);
}
.actionButton:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.actionButton:disabled:hover {
background: var(--background);
}
.editor {
display: flex;
gap: 6px;
max-width: 320px;
}
.editInput {
min-width: 120px;
padding: 4px 8px;
outline: none;
}
.switch {
position: relative;
width: 38px;
height: 22px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--secondary);
cursor: pointer;
}
.switchOn {
background: var(--agent-blue-500);
border-color: var(--agent-blue-500);
}
.switchKnob {
position: absolute;
top: 3px;
left: 3px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--background);
transition: transform 120ms ease;
}
.switchOn .switchKnob {
transform: translateX(16px);
}
.footer {
padding-top: 8px;
border-top: 1px solid var(--border);
color: var(--muted-foreground);
}
@media (max-width: 720px) {
.settingsPage {
grid-template-columns: 1fr;
min-height: 0;
}
.sidebar {
flex-direction: row;
overflow-x: auto;
padding-right: 0;
padding-bottom: 8px;
border-right: 0;
border-bottom: 1px solid var(--border);
}
.categoryButton {
width: auto;
flex: 0 0 auto;
}
.card {
grid-template-columns: 1fr;
gap: 8px;
}
.cardControl {
justify-content: flex-start;
}
}

View file

@ -1,4 +1,21 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import {
BotIcon,
DatabaseIcon,
FlaskConicalIcon,
PaletteIcon,
ServerIcon,
Settings2Icon,
ShieldIcon,
SlidersHorizontalIcon,
WrenchIcon,
} from 'lucide-react';
import type {
DaemonSettingDescriptor,
DaemonSettingUpdateResult,
@ -17,10 +34,41 @@ import {
THEME_SETTING_KEY,
LANGUAGE_SETTING_KEY,
themeSettingToWebShellTheme,
useTheme,
webShellThemeToSettingValue,
type WebShellTheme,
} from '../../themeContext';
import styles from './SettingsMessage.module.css';
import { Alert, AlertDescription } from '../ui/alert';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '../ui/empty';
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldTitle,
} from '../ui/field';
import { Input } from '../ui/input';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
import { Separator } from '../ui/separator';
import { Spinner } from '../ui/spinner';
import { Switch } from '../ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
type ChatWidthMode = '1000' | 'wide';
@ -137,7 +185,7 @@ function formatValue(
: String(effective);
}
const s = String(effective);
return s.length > 24 ? s.slice(0, 21) + '...' : s;
return s.length > 24 ? `${s.slice(0, 21)}` : s;
}
function scopeHasValue(
@ -186,10 +234,7 @@ interface SettingsPageCategory {
items: SettingsPageItem[];
}
function groupByCategory(
settings: DaemonSettingDescriptor[],
t: Translator,
): CategoryGroup[] {
function groupByCategory(settings: DaemonSettingDescriptor[]): CategoryGroup[] {
const map = new Map<string, DaemonSettingDescriptor[]>();
for (const s of settings) {
let group = map.get(s.category);
@ -200,11 +245,126 @@ function groupByCategory(
group.push(s);
}
return Array.from(map.entries()).map(([category, items]) => ({
category: formatSettingCategory(category, t),
category,
items,
}));
}
function CategoryIcon({ category }: { category: string }) {
const normalized = category.toLowerCase();
const Icon = normalized.includes('ui')
? PaletteIcon
: normalized.includes('tool')
? WrenchIcon
: normalized.includes('context')
? DatabaseIcon
: normalized.includes('privacy')
? ShieldIcon
: normalized.includes('model')
? BotIcon
: normalized.includes('daemon')
? ServerIcon
: normalized.includes('advanced')
? SlidersHorizontalIcon
: normalized.includes('experimental')
? FlaskConicalIcon
: Settings2Icon;
return <Icon data-icon="inline-start" aria-hidden="true" />;
}
function SettingsRow({
title,
description,
metadata,
control,
}: {
title: string;
description?: string;
metadata?: ReactNode;
control: ReactNode;
}) {
return (
<Field
orientation="responsive"
className="min-h-20 gap-6 px-5 py-4 max-md:px-4"
>
<FieldContent className="min-w-0">
<FieldTitle>
{title}
{metadata}
</FieldTitle>
{description && (
<FieldDescription className="max-w-3xl">
{description}
</FieldDescription>
)}
</FieldContent>
<div className="flex min-w-0 justify-end max-md:justify-start">
{control}
</div>
</Field>
);
}
function SettingInput({
name,
label,
type,
value,
disabled,
onCommit,
onInvalid,
}: {
name: string;
label: string;
type: 'number' | 'text';
value: unknown;
disabled: boolean;
onCommit: (value: unknown) => void;
onInvalid: () => void;
}) {
const currentValue = String(value ?? '');
const [draft, setDraft] = useState(currentValue);
useEffect(() => setDraft(currentValue), [currentValue]);
const commit = () => {
if (type === 'number') {
const trimmed = draft.trim();
const parsed = Number(trimmed);
if (!trimmed || !Number.isFinite(parsed)) {
setDraft(currentValue);
onInvalid();
return;
}
if (parsed !== value) onCommit(parsed);
return;
}
if (draft !== currentValue) onCommit(draft);
};
return (
<Input
type={type}
name={name}
autoComplete="off"
aria-label={label}
value={draft}
disabled={disabled}
className="w-[min(80px,50vw)] max-md:w-full"
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
if (event.key === 'Escape') {
setDraft(currentValue);
event.currentTarget.blur();
}
}}
/>
);
}
export type FlatRow =
| { type: 'header'; category: string }
| { type: 'setting'; setting: DaemonSettingDescriptor }
@ -236,44 +396,30 @@ export function SettingsMessage({
onChatWidthModeChange,
embedded = false,
}: SettingsMessageProps) {
const { t } = useI18n();
const { language: selectedLanguage, t } = useI18n();
const selectedTheme = useTheme();
const { status, settings, loading, error, reload, setValue } = settingsState;
const [scope, setScope] = useState<Scope>('workspace');
const [activeCategory, setActiveCategory] = useState('');
const [busyKey, setBusyKey] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [editMode, setEditMode] = useState<{
key: string;
draft: string;
} | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [restartPending, setRestartPending] = useState(false);
const showInitialLoading = loading && !status;
const themeSetting = settings.find((s) => s.key === THEME_SETTING_KEY);
const themeValue = themeSettingToWebShellTheme(
themeSetting?.values.effective,
);
const languageSetting = settings.find((s) => s.key === LANGUAGE_SETTING_KEY);
const languageValue = languageSettingToWebShellLanguage(
languageSetting?.values.effective,
);
const categories = useMemo(() => {
const visibleSettings = settings.filter(
(setting) => !HIDDEN_SETTING_KEYS.has(setting.key),
);
const groups: SettingsPageCategory[] = groupByCategory(
visibleSettings,
t,
).map((group) => ({
id: group.category,
label: group.category,
items: group.items.map((setting) => ({
type: 'setting' as const,
setting,
})),
}));
const groups: SettingsPageCategory[] = groupByCategory(visibleSettings).map(
(group) => ({
id: group.category,
label: formatSettingCategory(group.category, t),
items: group.items.map((setting) => ({
type: 'setting' as const,
setting,
})),
}),
);
const localItem = {
type: 'local' as const,
localKey: 'chatWidth' as const,
@ -319,14 +465,8 @@ export function SettingsMessage({
)
.join('; '),
);
else if (settings.length > 0 && !restartPending) setMessage(null);
}, [error, settings, status, t, restartPending]);
useEffect(() => {
if (editMode) {
setTimeout(() => inputRef.current?.focus(), 0);
}
}, [editMode]);
else if (settings.length > 0) setMessage(null);
}, [error, settings, status, t]);
const handleSetValue = useCallback(
(key: string, value: unknown) => {
@ -341,7 +481,6 @@ export function SettingsMessage({
}
if (result?.requiresRestart && key !== LANGUAGE_SETTING_KEY) {
setRestartPending(true);
setMessage(t('settings.requiresRestart'));
}
})
.catch((err: unknown) => {
@ -349,31 +488,9 @@ export function SettingsMessage({
})
.finally(() => setBusyKey(null));
},
[reload, restartPending, setValue, t],
[reload, restartPending, setValue],
);
const handleEditSubmit = useCallback(() => {
if (!editMode) return;
const setting = settings.find(
(candidate) => candidate.key === editMode.key,
);
if (!setting) {
setEditMode(null);
return;
}
let parsed: unknown = editMode.draft;
if (setting.type === 'number') {
const trimmed = editMode.draft.trim();
if (trimmed === '' || !Number.isFinite(Number(trimmed))) {
setMessage(t('settings.invalidNumber'));
return;
}
parsed = Number(trimmed);
}
setEditMode(null);
handleSetValue(setting.key, parsed);
}, [editMode, settings, handleSetValue, t]);
const activeGroup =
categories.find((category) => category.id === activeCategory) ??
categories[0];
@ -382,44 +499,42 @@ export function SettingsMessage({
value: string,
onChange: (value: string) => void,
options: Array<{ value: string; label: string }>,
ariaLabel: string,
disabled = false,
) => (
<select
className={styles.select}
value={value}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<Select value={value} disabled={disabled} onValueChange={onChange}>
<SelectTrigger
size="sm"
aria-label={ariaLabel}
className="w-[min(160px,50vw)] bg-background max-md:w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" align="end">
<SelectGroup>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);
const renderSettingControl = (setting: DaemonSettingDescriptor) => {
const value = resolveValue(setting, scope);
const isBusy = busyKey === setting.key;
const isEditing = editMode?.key === setting.key;
const readOnly = scope !== 'workspace';
const disabled = isBusy || readOnly;
if (readOnly) {
return (
<button
type="button"
className={styles.actionButton}
disabled
title={t('settings.readOnly')}
>
{formatValue(setting, scope, t) || t('settings.readOnly')}
</button>
);
}
// Theme is a daemon-backed workspace setting, so update both the live
// shell and the settings API. Language is a Web Shell preference owned by
// the host (standalone persists it to localStorage), so it only uses the
// language callback and must not be written through the daemon API.
if (setting.key === THEME_SETTING_KEY) {
return renderSelect(
themeValue ?? WebShellThemeId.Dark,
selectedTheme,
(next) => {
const theme = next as WebShellTheme;
onThemeChange(theme);
@ -429,46 +544,48 @@ export function SettingsMessage({
value: theme,
label: t(`theme.${theme}`),
})),
isBusy,
formatSettingLabel(setting, t),
disabled,
);
}
if (setting.key === LANGUAGE_SETTING_KEY) {
return renderSelect(
languageValue ?? 'zh-CN',
selectedLanguage,
(next) => onLanguageChange(next as WebShellLanguage),
WEB_SHELL_LANGUAGES.map((language) => ({
value: language,
label: languageLabel(language),
})),
isBusy,
formatSettingLabel(setting, t),
disabled,
);
}
if (SUB_DIALOG_KEYS.has(setting.key)) {
return (
<button
<Button
type="button"
className={styles.actionButton}
variant="outline"
size="sm"
disabled={disabled}
className="max-w-[260px] truncate"
onClick={() => onSubDialog(setting.key)}
>
{formatValue(setting, scope, t) || t('settings.action.edit')}
</button>
</Button>
);
}
if (setting.type === 'boolean') {
const checked = value === true;
return (
<button
type="button"
className={`${styles.switch} ${checked ? styles.switchOn : ''}`}
disabled={isBusy}
onClick={() => handleSetValue(setting.key, !checked)}
aria-pressed={checked}
>
<span className={styles.switchKnob} />
</button>
<Switch
checked={checked}
disabled={disabled}
onCheckedChange={(next) => handleSetValue(setting.key, next)}
aria-label={formatSettingLabel(setting, t)}
/>
);
}
@ -486,211 +603,228 @@ export function SettingsMessage({
value: String(index),
label: formatSettingOption(setting, option.value, option.label, t),
})),
isBusy,
);
}
if (isEditing) {
return (
<div className={styles.editor}>
<input
ref={inputRef}
className={styles.editInput}
type={setting.type === 'number' ? 'number' : 'text'}
value={editMode.draft}
onChange={(event) =>
setEditMode({ key: editMode.key, draft: event.target.value })
}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
handleEditSubmit();
}
if (event.key === 'Escape') {
event.preventDefault();
setEditMode(null);
}
}}
/>
<button
type="button"
className={styles.actionButton}
onClick={handleEditSubmit}
>
{t('settings.action.save')}
</button>
</div>
formatSettingLabel(setting, t),
disabled,
);
}
return (
<button
type="button"
className={styles.actionButton}
disabled={isBusy}
onClick={() =>
setEditMode({
key: setting.key,
draft: String(value ?? ''),
})
}
>
{formatValue(setting, scope, t) || t('settings.action.edit')}
</button>
<SettingInput
name={setting.key}
label={formatSettingLabel(setting, t)}
type={setting.type === 'number' ? 'number' : 'text'}
value={value}
disabled={disabled}
onCommit={(next) => handleSetValue(setting.key, next)}
onInvalid={() => setMessage(t('settings.invalidNumber'))}
/>
);
};
return (
<div
className={`${styles.panel} ${embedded ? styles.embeddedPanel : ''}`}
className={
embedded
? 'flex min-h-0 flex-1 flex-col text-sm text-foreground'
: 'flex max-w-[min(var(--chat-regular-content-width,1000px),calc(100vw-64px))] flex-col overflow-hidden rounded-xl border border-border bg-background text-sm text-foreground'
}
data-keyboard-scope
>
{!embedded && (
<div className={styles.header}>
<span className={styles.title}>{t('settings.title')}</span>
<span className={styles.secondary}>
{t('settings.scope.workspace')}
</span>
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<div>
<h2 className="text-base font-semibold text-balance">
{t('settings.title')}
</h2>
<div className="mt-0.5 text-xs text-muted-foreground">
{t('settings.scope.workspace')}
</div>
</div>
</div>
)}
{(message || showInitialLoading) && (
<div className={styles.hint}>{message || t('settings.loading')}</div>
<Alert className="mx-4 mt-3 w-auto">
{showInitialLoading && <Spinner />}
<AlertDescription>
{message || t('settings.loading')}
</AlertDescription>
</Alert>
)}
<div className={styles.scopeTabs} role="tablist">
<button
type="button"
role="tab"
aria-selected={scope === 'workspace'}
className={`${styles.scopeTab} ${
scope === 'workspace' ? styles.scopeTabActive : ''
}`}
onClick={() => setScope('workspace')}
>
{t('settings.scope.workspace')}
</button>
<button
type="button"
role="tab"
aria-selected={scope === 'user'}
className={`${styles.scopeTab} ${
scope === 'user' ? styles.scopeTabActive : ''
}`}
onClick={() => {
setEditMode(null);
setScope('user');
}}
>
{t('settings.scope.user')}
</button>
</div>
<div className={styles.settingsPage}>
<nav className={styles.sidebar} aria-label={t('settings.title')}>
{categories.map((category) => (
<button
key={category.id}
type="button"
className={`${styles.categoryButton} ${
category.id === activeCategory
? styles.categoryButtonActive
: ''
}`}
onClick={() => setActiveCategory(category.id)}
>
<span>{category.label}</span>
<span className={styles.categoryCount}>
{category.items.length}
</span>
</button>
))}
</nav>
<section className={styles.content}>
{!loading && !activeGroup && (
<div className={styles.empty}>{t('settings.empty')}</div>
<Tabs
value={scope}
className="flex min-h-0 flex-1 flex-col gap-0"
onValueChange={(next) => {
setScope(next as Scope);
}}
>
<div className="flex items-center justify-between gap-4 border-b border-border px-3 py-2">
<TabsList className="p-0">
<TabsTrigger value="workspace">
{t('settings.scope.workspace')}
</TabsTrigger>
<TabsTrigger value="user">{t('settings.scope.user')}</TabsTrigger>
</TabsList>
{restartPending && (
<Badge variant="secondary">{t('settings.requiresRestart')}</Badge>
)}
{activeGroup?.items.map((item) => {
if (item.type === 'local') {
return (
<div className={styles.card} key={item.localKey}>
<div className={styles.cardBody}>
<div className={styles.cardTitle}>
{t('settings.label.ui.chatWidth')}
</div>
<div className={styles.cardDescription}>
{t('settings.description.ui.chatWidth')}
</div>
</div>
<div className={styles.cardControl}>
{renderSelect(
chatWidthMode,
(next) => onChatWidthModeChange(next as ChatWidthMode),
[
{
value: '1000',
label: t('settings.option.ui.chatWidth.1000'),
},
{
value: 'wide',
label: t('settings.option.ui.chatWidth.wide'),
},
],
)}
</div>
</div>
);
}
</div>
const setting = item.setting;
const description = formatSettingDescription(setting, t);
const hintKey = scopeHintKey(setting, scope);
const hasScopeValue = scopeHasValue(setting, scope);
return (
<div className={styles.card} key={setting.key}>
<div className={styles.cardBody}>
<div className={styles.cardTitle}>
{formatSettingLabel(setting, t)}
{hasScopeValue && (
<span className={styles.scopeBadge}>
{scope === 'workspace'
? t('settings.scope.workspace')
: t('settings.scope.user')}
</span>
)}
</div>
{description && (
<div className={styles.cardDescription}>{description}</div>
)}
{hintKey && (
<div className={styles.scopeHint}>
{t(hintKey, {
scope: t(
scope === 'workspace'
? 'settings.scope.user'
: 'settings.scope.workspace',
),
<TabsContent
value={scope}
forceMount
className="grid min-h-0 flex-1 grid-cols-[190px_minmax(0,1fr)] outline-none max-md:grid-cols-1"
>
<nav
className="flex min-h-0 flex-col gap-1 overflow-y-auto border-r border-border bg-muted/20 p-3 max-md:flex-row max-md:overflow-x-auto max-md:border-r-0 max-md:border-b"
aria-label={t('settings.title')}
>
{categories.map((category) => (
<Button
key={category.id}
type="button"
variant={category.id === activeCategory ? 'secondary' : 'ghost'}
size="sm"
aria-current={
category.id === activeCategory ? 'page' : undefined
}
className="w-full justify-start gap-2 px-2.5 max-md:w-auto max-md:shrink-0"
onClick={() => setActiveCategory(category.id)}
>
<CategoryIcon category={category.id} />
<span className="min-w-0 flex-1 truncate text-left">
{category.label}
</span>
<span className="text-xs tabular-nums text-muted-foreground">
{category.items.length}
</span>
</Button>
))}
</nav>
<section className="min-h-0 min-w-0 overflow-y-auto bg-background p-5 max-md:p-3">
{!loading && !activeGroup && (
<Empty className="min-h-60">
<EmptyHeader>
<EmptyMedia variant="icon">
<Settings2Icon />
</EmptyMedia>
<EmptyTitle>{t('settings.empty')}</EmptyTitle>
<EmptyDescription>{t('settings.empty')}</EmptyDescription>
</EmptyHeader>
</Empty>
)}
{activeGroup && (
<div className="mx-auto w-full max-w-5xl">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CategoryIcon category={activeGroup.id} />
{activeGroup.label}
</CardTitle>
</CardHeader>
<CardContent className="-mb-(--card-spacing) p-0">
<FieldGroup className="gap-0">
{activeGroup.items.map((item, index) => {
const separator = index > 0 && (
<Separator className="mx-5 w-auto max-md:mx-4" />
);
if (item.type === 'local') {
return (
<div key={item.localKey}>
{separator}
<SettingsRow
title={t('settings.label.ui.chatWidth')}
description={t(
'settings.description.ui.chatWidth',
)}
control={renderSelect(
chatWidthMode,
(next) =>
onChatWidthModeChange(
next as ChatWidthMode,
),
[
{
value: '1000',
label: t(
'settings.option.ui.chatWidth.1000',
),
},
{
value: 'wide',
label: t(
'settings.option.ui.chatWidth.wide',
),
},
],
t('settings.label.ui.chatWidth'),
scope !== 'workspace',
)}
/>
</div>
);
}
const setting = item.setting;
const description = formatSettingDescription(
setting,
t,
);
const hintKey = scopeHintKey(setting, scope);
const hasScopeValue = scopeHasValue(setting, scope);
const scopeHint = hintKey
? t(hintKey, {
scope: t(
scope === 'workspace'
? 'settings.scope.user'
: 'settings.scope.workspace',
),
})
: undefined;
return (
<div key={setting.key}>
{separator}
<SettingsRow
title={formatSettingLabel(setting, t)}
description={
[description, scopeHint]
.filter(Boolean)
.join(' · ') || undefined
}
metadata={
hasScopeValue ? (
<Badge variant="secondary">
{scope === 'workspace'
? t('settings.scope.workspace')
: t('settings.scope.user')}
</Badge>
) : undefined
}
control={
busyKey === setting.key ? (
<Spinner />
) : (
renderSettingControl(setting)
)
}
/>
</div>
);
})}
</div>
)}
</div>
<div className={styles.cardControl}>
{busyKey === setting.key ? (
<span className={styles.busy}>...</span>
) : (
renderSettingControl(setting)
)}
</div>
</FieldGroup>
</CardContent>
</Card>
</div>
);
})}
</section>
</div>
)}
</section>
</TabsContent>
</Tabs>
{!embedded && (
<div className={styles.footer}>
{editMode ? t('settings.footer.edit') : t('settings.footer')}
<div className="border-t border-border px-5 py-3 text-xs text-muted-foreground">
{t('settings.footer')}
</div>
)}
</div>

View file

@ -55,6 +55,10 @@
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:where(