Files
openchamber/packages/ui/src/components/sections/projects/ProjectIdentityFields.tsx
T
Bohdan Triapitsyn 2740c7b3a2 feat(projects): pin a thinking level next to a project's model
A project could pin the model new chats start on, but not the level to
run it at: the default cascade dropped any variant as soon as a project
model won, and only ever considered the global one — which belongs to
the global model.

Projects now carry `defaultVariant` alongside `defaultModel`, stored and
sanitized only next to that model, and the cascade passes it through.
Both controls sit in one "Defaults for new chats" group laid out like the
Sessions defaults, and the level appears only for models that offer them.
2026-08-22 20:31:31 +03:00

349 lines
13 KiB
TypeScript

import React from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
SettingsFieldRow,
SETTINGS_CUSTOM_TRIGGER_CLASS,
SETTINGS_SELECT_ROW_TRIGGER_CLASS,
SETTINGS_SELECT_SIZE,
} from '@/components/sections/shared/SettingsSection';
import { useConfigStore } from '@/stores/useConfigStore';
import { modelVariantNames } from '@/lib/modelVariants';
import { PROJECT_COLORS, PROJECT_ICONS, PROJECT_COLOR_MAP as COLOR_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import {
PROJECT_SETTINGS_CONTROL_WIDTH,
ProjectSettingsSubsection,
} from '@/components/sections/projects/ProjectSettingsSubsection';
import type { useProjectIdentityForm } from './useProjectIdentityForm';
type ProjectIdentityFormState = ReturnType<typeof useProjectIdentityForm>;
type ProjectIdentityFieldsProps = {
form: ProjectIdentityFormState;
};
const NO_VARIANT_VALUE = '__default__';
const formatVariantLabel = (variant: string): string => variant.charAt(0).toUpperCase() + variant.slice(1);
export const ProjectIdentityFields: React.FC<ProjectIdentityFieldsProps> = ({ form }) => {
const { t } = useI18n();
const { currentTheme } = useThemeSystem();
const providers = useConfigStore((state) => state.providers);
const {
name,
setName,
icon,
setIcon,
color,
setColor,
iconBackground,
setIconBackground,
parsedDefaultModel,
defaultVariant,
handleDefaultModelChange,
handleDefaultVariantChange,
isUploadingIcon,
isRemovingCustomIcon,
isDiscoveringIcon,
pendingRemoveImageIcon,
setPendingRemoveImageIcon,
pendingUploadIconPreviewUrl,
setPreviewImageFailed,
hasPendingUploadImageIcon,
hasCustomIcon,
effectiveHasImageIcon,
hasRemovableImageIcon,
showImagePreview,
fileInputRef,
handleUploadIcon,
handleRemoveImageIcon,
handleDiscoverIcon,
currentIconImage,
project,
} = form;
const availableVariants = React.useMemo(() => {
const { providerId, modelId } = parsedDefaultModel;
if (!providerId || !modelId) return [];
const model = providers
.find((provider) => provider.id === providerId)
?.models.find((entry) => entry.id === modelId);
return modelVariantNames(model);
}, [parsedDefaultModel, providers]);
if (!project) {
return null;
}
const currentColorVar = color ? (COLOR_MAP[color] ?? null) : null;
return (
<>
<ProjectSettingsSubsection
title={t('settings.projects.page.field.projectName')}
settingsItem="projects.name"
divider={false}
>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={t('settings.projects.page.field.projectNamePlaceholder')}
className={cn('h-8 rounded-md px-3', PROJECT_SETTINGS_CONTROL_WIDTH)}
/>
</ProjectSettingsSubsection>
<ProjectSettingsSubsection
title={t('settings.projects.page.section.chatDefaults')}
info={t('settings.projects.page.section.chatDefaultsDescription')}
contentClassName="space-y-0"
>
<SettingsFieldRow
settingsItem="projects.default-model"
label={t('settings.projects.page.field.projectModel')}
>
<ModelSelector
providerId={parsedDefaultModel.providerId}
modelId={parsedDefaultModel.modelId}
onChange={handleDefaultModelChange}
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
/>
</SettingsFieldRow>
{availableVariants.length > 0 ? (
<SettingsFieldRow
settingsItem="projects.default-thinking"
label={t('settings.projects.page.field.projectThinking')}
>
<Select
value={defaultVariant ?? NO_VARIANT_VALUE}
onValueChange={(value) => handleDefaultVariantChange(value === NO_VARIANT_VALUE ? undefined : value)}
>
<SelectTrigger
size={SETTINGS_SELECT_SIZE}
className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}
aria-label={t('settings.projects.page.field.projectThinking')}
>
<SelectValue>
{defaultVariant
? formatVariantLabel(defaultVariant)
: t('settings.projects.page.option.thinkingDefault')}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_VARIANT_VALUE}>{t('settings.projects.page.option.thinkingDefault')}</SelectItem>
{availableVariants.map((variant) => (
<SelectItem key={variant} value={variant}>{formatVariantLabel(variant)}</SelectItem>
))}
</SelectContent>
</Select>
</SettingsFieldRow>
) : null}
</ProjectSettingsSubsection>
<ProjectSettingsSubsection
title={t('settings.projects.page.field.accentColor')}
settingsItem="projects.accent-color"
>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setColor(null)}
className={cn(
'h-7 w-7 rounded-md border transition-colors flex items-center justify-center',
color === null
? 'border-2 border-foreground bg-[var(--primary-base)]/10'
: 'border-border/40 hover:border-border hover:bg-[var(--surface-muted)]',
)}
title={t('settings.projects.page.field.none')}
>
<Icon name="close" className="h-4 w-4 text-muted-foreground" />
</button>
{PROJECT_COLORS.map((entry) => (
<button
key={entry.key}
type="button"
onClick={() => setColor(entry.key)}
className={cn(
'h-7 w-7 rounded-md border transition-colors',
color === entry.key
? 'border-2 border-foreground ring-1 ring-[var(--primary-base)]/40'
: 'border-transparent hover:border-border/70',
)}
style={{ backgroundColor: entry.cssVar }}
title={entry.label}
/>
))}
</div>
</ProjectSettingsSubsection>
<ProjectSettingsSubsection
title={t('settings.projects.page.field.projectIcon')}
settingsItem="projects.icon"
contentClassName="space-y-3"
>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/svg+xml,.png,.jpg,.jpeg,.svg"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0] ?? null;
void handleUploadIcon(file);
event.currentTarget.value = '';
}}
/>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setIcon(null)}
className={cn(
'h-7 w-7 rounded-md border transition-colors flex items-center justify-center',
icon === null
? 'border-2 border-foreground bg-[var(--primary-base)]/10'
: 'border-border/40 hover:border-border hover:bg-[var(--surface-muted)]',
)}
title={t('settings.projects.page.field.none')}
>
<Icon name="close" className="h-4 w-4 text-muted-foreground" />
</button>
{PROJECT_ICONS.map((entry) => {
const iconName = entry.Icon;
return (
<button
key={entry.key}
type="button"
onClick={() => setIcon(entry.key)}
className={cn(
'h-7 w-7 rounded-md border transition-colors flex items-center justify-center',
icon === entry.key
? 'border-2 border-foreground bg-[var(--primary-base)]/10'
: 'border-transparent hover:border-border hover:bg-[var(--surface-muted)]',
)}
title={entry.label}
>
<Icon
name={iconName}
className="w-4 h-4"
style={currentColorVar && icon === entry.key ? { color: currentColorVar } : undefined}
/>
</button>
);
})}
</div>
{effectiveHasImageIcon && showImagePreview && (
<div className="flex items-center gap-2">
<span className="typography-meta text-muted-foreground">{t('settings.projects.page.field.preview')}</span>
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-border/60 bg-[var(--surface-elevated)] p-1">
<span
className="inline-flex h-4 w-4 items-center justify-center overflow-hidden rounded-[2px]"
style={iconBackground ? { backgroundColor: iconBackground } : undefined}
>
{hasPendingUploadImageIcon && pendingUploadIconPreviewUrl ? (
<img
src={pendingUploadIconPreviewUrl}
alt=""
className="h-full w-full object-contain"
draggable={false}
onError={() => setPreviewImageFailed(true)}
/>
) : (
<ProjectIconImage
project={{ ...project, iconImage: currentIconImage ?? project.iconImage }}
options={{
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
}}
className="h-full w-full object-contain"
onError={() => setPreviewImageFailed(true)}
/>
)}
</span>
</span>
</div>
)}
{effectiveHasImageIcon && (
<div className="flex flex-wrap items-center gap-2">
<input
type="color"
value={iconBackground ?? '#000000'}
onChange={(event) => setIconBackground(event.target.value)}
className="h-7 w-9 cursor-pointer rounded border border-border bg-transparent p-1"
aria-label={t('settings.projects.page.field.projectIconBackgroundAria')}
/>
<Input
value={iconBackground ?? ''}
onChange={(event) => setIconBackground(event.target.value)}
placeholder="#000000"
className="h-7 w-[8rem]"
/>
<Button
type="button"
size="xs"
variant="outline"
onClick={() => setIconBackground(null)}
className="h-7 w-7 p-0"
aria-label={t('settings.projects.page.field.clearIconBackgroundAria')}
title={t('settings.projects.page.field.clearBackground')}
disabled={!iconBackground}
>
<Icon name="close" className="h-3.5 w-3.5" />
</Button>
</div>
)}
<div className="flex flex-wrap items-center gap-2">
{!hasCustomIcon && (
<>
<Button
size="xs"
className="h-6 !font-normal"
onClick={() => fileInputRef.current?.click()}
disabled={isUploadingIcon}
>
{isUploadingIcon ? t('settings.projects.page.actions.uploading') : t('settings.projects.page.actions.uploadIcon')}
</Button>
<Button
size="xs"
className="h-6 !font-normal"
variant="outline"
onClick={() => void handleDiscoverIcon()}
disabled={isDiscoveringIcon}
>
{isDiscoveringIcon ? t('settings.projects.page.actions.discovering') : t('settings.projects.page.actions.discoverFavicon')}
</Button>
</>
)}
{hasRemovableImageIcon && (
<Button
size="xs"
className="!font-normal"
variant="outline"
onClick={() => void handleRemoveImageIcon()}
disabled={isRemovingCustomIcon}
>
{isRemovingCustomIcon ? t('settings.projects.page.actions.removing') : t('settings.projects.page.actions.removeProjectIcon')}
</Button>
)}
{pendingRemoveImageIcon && (
<Button
size="xs"
className="!font-normal"
variant="outline"
onClick={() => setPendingRemoveImageIcon(false)}
disabled={isRemovingCustomIcon}
>
{t('settings.projects.page.actions.undoRemove')}
</Button>
)}
</div>
</ProjectSettingsSubsection>
</>
);
};