Share project edit form; add per-project default model (#2015)

* feat(chat): migrate history list to @tanstack/react-virtual with deterministic mobile history loading

- Replace virtua with @tanstack/react-virtual for chat history on all
  surfaces: bottom anchoring (anchorTo: end), key-stable prepend
  preservation, and native iOS touch/momentum deferral live in the core
- Patch virtual-core to clamp the render range to real scroll bounds
  during transient adjustments (OpenCode upstream parity)
- Rows render in normal flow inside a translated wrapper so sticky user
  headers keep working; measurement snapshots cached per session
- Pre-write container height in scrollToFn so the browser cannot clamp
  anchor corrections to the stale height; hold the prepend anchor for up
  to 180 frames on mobile while fresh rows settle (cancelled by user
  input; desktop relies on core anchoring alone)
- Adaptive row-size estimate from per-session measured averages; disable
  reveal fade-in for virtualized history rows
- Mobile loads older history only through an explicit localized top
  button: no scroll-position trigger and no post-mount background
  prepend, so every insert happens from a resting state; a quiet-window
  hold defers any stray prepend commit while a touch gesture is active
- Desktop/VS Code keep the seamless scroll-up trigger and progressive
  background prepend

* Share project edit form between settings and sidebar dialog

Extract ProjectIdentityFields and useProjectIdentityForm so the projects
settings page and sidebar Edit dialog share the same layout and behavior.
Rename the project menu action from Rename to Edit, and add per-project
default model selection for new chats with persistence and draft-session
resolution ahead of global defaults.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Unify project edit UI with shared ProjectIdentityEditor shell

Wrap header, fields, and inline Save changes button in one editor
component used identically by settings projects page and sidebar
dialog. Remove dialog-specific footer, title, and padding so both
surfaces render the same layout.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Include Actions and Worktree sections in project Edit dialog

Extract ProjectSettingsPanel with the full settings=projects content
(identity, actions, worktree) and render it from both the settings page
and sidebar Edit dialog. Keep the dialog open after identity save so
users can configure actions and worktrees without reopening.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Narrow project Edit dialog to modal-appropriate width

Use max-w-2xl instead of max-w-4xl so the popup does not inherit the
full settings page width.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Unify project settings subsections and auto-save all fields

- Add shared ProjectSettingsSubsection with consistent titles and dividers
- Auto-save identity, actions, and worktree setup commands (debounced)
- Remove Save changes and Save Actions buttons
- Split worktree into Worktree and Existing worktrees subsections
- Align controls to shared max width across all subsections

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Harden project settings auto-save error handling

- Only update worktree setup snapshot after successful save; toast on failure
- Toast when actions auto-save is blocked by validation for >1s

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Show toast when project identity auto-save fails

Wrap onSave in try/catch and surface settings.projects.page.toast.saveFailed
so rejected parent callbacks are not silently swallowed.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* Fix clearing project default model from settings

Send null instead of undefined when no default model is selected so
updateProjectMeta enters the defaultModel branch and deletes the field.
Apply consistently in prepareSaveData, ProjectsPage, and SessionSidebar.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Serhii Dziupin
2026-07-09 13:54:05 +03:00
committed by GitHub
co-authored by Serhii Dziupin Bohdan Triapitsyn Cursor Agent
parent 57ebaedada
commit a1aae30e66
36 changed files with 1362 additions and 1271 deletions
@@ -1,436 +1,33 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
import { PROJECT_ICONS, PROJECT_COLORS, PROJECT_COLOR_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useI18n } from '@/lib/i18n';
import { Icon } from "@/components/icon/Icon";
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { ProjectSettingsPanel } from '@/components/sections/projects/ProjectSettingsPanel';
import type { ProjectIdentitySaveData } from '@/components/sections/projects/useProjectIdentityForm';
import type { ProjectEntry } from '@/lib/api/types';
interface ProjectEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectId: string;
projectName: string;
projectPath: string;
initialIcon?: string | null;
initialColor?: string | null;
initialIconBackground?: string | null;
onSave: (data: { label: string; icon: string | null; color: string | null; iconBackground: string | null }) => void;
project: ProjectEntry | null;
onSave: (data: ProjectIdentitySaveData) => void | Promise<void>;
}
const HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/;
const normalizeIconBackground = (value: string | null): string | null => {
if (!value) {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
return HEX_COLOR_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null;
};
export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
open,
onOpenChange,
projectId,
projectName,
projectPath,
initialIcon = null,
initialColor = null,
initialIconBackground = null,
project,
onSave,
}) => {
const { t } = useI18n();
const uploadProjectIcon = useProjectsStore((state) => state.uploadProjectIcon);
const removeProjectIcon = useProjectsStore((state) => state.removeProjectIcon);
const discoverProjectIcon = useProjectsStore((state) => state.discoverProjectIcon);
const currentIconImage = useProjectsStore((state) => state.projects.find((project) => project.id === projectId)?.iconImage ?? null);
const { currentTheme } = useThemeSystem();
const [name, setName] = React.useState(projectName);
const [icon, setIcon] = React.useState<string | null>(initialIcon);
const [color, setColor] = React.useState<string | null>(initialColor);
const [iconBackground, setIconBackground] = React.useState<string | null>(normalizeIconBackground(initialIconBackground));
const [isUploadingIcon, setIsUploadingIcon] = React.useState(false);
const [isRemovingCustomIcon, setIsRemovingCustomIcon] = React.useState(false);
const [isDiscoveringIcon, setIsDiscoveringIcon] = React.useState(false);
const [pendingRemoveImageIcon, setPendingRemoveImageIcon] = React.useState(false);
const [pendingUploadIconFile, setPendingUploadIconFile] = React.useState<File | null>(null);
const [pendingUploadIconPreviewUrl, setPendingUploadIconPreviewUrl] = React.useState<string | null>(null);
const [previewImageFailed, setPreviewImageFailed] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const clearPendingUploadIcon = React.useCallback(() => {
setPendingUploadIconFile(null);
setPendingUploadIconPreviewUrl((previousUrl) => {
if (previousUrl) {
URL.revokeObjectURL(previousUrl);
}
return null;
});
}, []);
React.useEffect(() => {
if (open) {
setName(projectName);
setIcon(initialIcon);
setColor(initialColor);
setIconBackground(normalizeIconBackground(initialIconBackground));
setPendingRemoveImageIcon(false);
clearPendingUploadIcon();
setPreviewImageFailed(false);
}
}, [open, projectName, initialIcon, initialColor, initialIconBackground, clearPendingUploadIcon]);
React.useEffect(() => {
return () => {
clearPendingUploadIcon();
};
}, [clearPendingUploadIcon]);
const handleSave = async () => {
const trimmed = name.trim();
if (!trimmed) return;
if (pendingUploadIconFile) {
setIsUploadingIcon(true);
const uploadResult = await uploadProjectIcon(projectId, pendingUploadIconFile);
setIsUploadingIcon(false);
if (!uploadResult.ok) {
toast.error(uploadResult.error || t('projectEditDialog.toast.failedToUploadIcon'));
return;
}
toast.success(t('projectEditDialog.toast.iconUpdated'));
clearPendingUploadIcon();
setPendingRemoveImageIcon(false);
}
const willRemoveImageIcon = pendingRemoveImageIcon && hasStoredImageIcon;
if (willRemoveImageIcon) {
setIsRemovingCustomIcon(true);
const result = await removeProjectIcon(projectId);
setIsRemovingCustomIcon(false);
if (!result.ok) {
toast.error(result.error || t('projectEditDialog.toast.failedToRemoveIcon'));
return;
}
toast.success(t('projectEditDialog.toast.iconRemoved'));
setPendingRemoveImageIcon(false);
setIconBackground(null);
}
onSave({
label: trimmed,
icon,
color,
iconBackground: normalizeIconBackground(willRemoveImageIcon ? null : iconBackground),
});
onOpenChange(false);
};
const currentColorVar = color ? (PROJECT_COLOR_MAP[color] ?? null) : null;
const hasStoredImageIcon = Boolean(currentIconImage);
const hasPendingUploadImageIcon = Boolean(pendingUploadIconFile && pendingUploadIconPreviewUrl);
const hasCustomIcon = currentIconImage?.source === 'custom';
const effectiveHasImageIcon = (hasStoredImageIcon && !pendingRemoveImageIcon) || hasPendingUploadImageIcon;
const hasRemovableImageIcon = effectiveHasImageIcon;
const showStoredImagePreview = hasStoredImageIcon && !pendingRemoveImageIcon;
const showImagePreview = !previewImageFailed && (hasPendingUploadImageIcon || showStoredImagePreview);
React.useEffect(() => {
setPreviewImageFailed(false);
}, [projectId, currentIconImage?.updatedAt]);
const handleUploadIcon = React.useCallback((file: File | null) => {
if (!projectId || !file || isUploadingIcon) {
return;
}
setPendingRemoveImageIcon(false);
setPreviewImageFailed(false);
setPendingUploadIconFile(file);
setPendingUploadIconPreviewUrl((previousUrl) => {
if (previousUrl) {
URL.revokeObjectURL(previousUrl);
}
return URL.createObjectURL(file);
});
}, [isUploadingIcon, projectId]);
const handleRemoveImageIcon = React.useCallback(() => {
if (!projectId || !hasRemovableImageIcon || isRemovingCustomIcon) {
return;
}
if (hasPendingUploadImageIcon) {
clearPendingUploadIcon();
}
if (hasStoredImageIcon) {
setPendingRemoveImageIcon(true);
} else {
setPendingRemoveImageIcon(false);
}
setPreviewImageFailed(false);
}, [
clearPendingUploadIcon,
hasPendingUploadImageIcon,
hasRemovableImageIcon,
hasStoredImageIcon,
isRemovingCustomIcon,
projectId,
]);
const handleDiscoverIcon = React.useCallback(async () => {
if (!projectId || isDiscoveringIcon) {
return;
}
clearPendingUploadIcon();
setPendingRemoveImageIcon(false);
setPreviewImageFailed(false);
setIsDiscoveringIcon(true);
void discoverProjectIcon(projectId)
.then((result) => {
if (!result.ok) {
toast.error(result.error || t('projectEditDialog.toast.failedToDiscoverIcon'));
return;
}
if (result.skipped) {
toast.success(t('projectEditDialog.toast.customIconAlreadySet'));
return;
}
toast.success(t('projectEditDialog.toast.iconDiscovered'));
})
.finally(() => {
setIsDiscoveringIcon(false);
});
}, [clearPendingUploadIcon, discoverProjectIcon, isDiscoveringIcon, projectId, t]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader className="min-w-0">
<DialogTitle>{t('projectEditDialog.title')}</DialogTitle>
</DialogHeader>
<div className="min-w-0 space-y-5 py-1">
{/* Name */}
<div className="min-w-0 space-y-1.5">
<label className="typography-ui-label font-medium text-foreground">
{t('projectEditDialog.field.name')}
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('projectEditDialog.field.namePlaceholder')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSave();
}
}}
autoFocus
/>
<p className="typography-meta text-muted-foreground truncate" title={projectPath}>
{projectPath}
</p>
<DialogContent className="w-full max-w-2xl gap-0 overflow-hidden p-0">
<ScrollableOverlay outerClassName="max-h-[min(90vh,48rem)]" className="w-full bg-background">
<div className="w-full p-3 sm:p-6 sm:pt-8">
{open && project ? (
<ProjectSettingsPanel project={project} onIdentitySave={onSave} />
) : null}
</div>
{/* Color */}
<div className="min-w-0 space-y-2">
<label className="typography-ui-label font-medium text-foreground">
{t('projectEditDialog.field.color')}
</label>
<div className="flex gap-2 flex-wrap">
{/* No color option */}
<button
type="button"
onClick={() => setColor(null)}
className={cn(
'w-8 h-8 rounded-lg border-2 transition-all flex items-center justify-center',
color === null
? 'border-foreground scale-110'
: 'border-border hover:border-border/80'
)}
title={t('projectEditDialog.option.none')}
>
<span className="w-4 h-0.5 bg-muted-foreground/40 rotate-45 rounded-full" />
</button>
{PROJECT_COLORS.map((c) => (
<button
key={c.key}
type="button"
onClick={() => setColor(c.key)}
className={cn(
'w-8 h-8 rounded-lg border-2 transition-all',
color === c.key
? 'border-foreground scale-110'
: 'border-transparent hover:border-border'
)}
style={{ backgroundColor: c.cssVar }}
title={c.label}
/>
))}
</div>
</div>
{/* Icon */}
<div className="min-w-0 space-y-2">
<label className="typography-ui-label font-medium text-foreground">
{t('projectEditDialog.field.icon')}
</label>
<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 gap-2 flex-wrap">
{/* No icon option */}
<button
type="button"
onClick={() => setIcon(null)}
className={cn(
'w-8 h-8 rounded-lg border-2 transition-all flex items-center justify-center',
icon === null
? 'border-foreground scale-110 bg-[var(--surface-elevated)]'
: 'border-border hover:border-border/80'
)}
title={t('projectEditDialog.option.none')}
>
<span className="w-4 h-0.5 bg-muted-foreground/40 rotate-45 rounded-full" />
</button>
{PROJECT_ICONS.map((i) => {
const iconName = i.Icon;
return (
<button
key={i.key}
type="button"
onClick={() => setIcon(i.key)}
className={cn(
'w-8 h-8 rounded-lg border-2 transition-all flex items-center justify-center',
icon === i.key
? 'border-foreground scale-110 bg-[var(--surface-elevated)]'
: 'border-border hover:border-border/80'
)}
title={i.label}
>
<Icon name={iconName}
className="w-4 h-4"
style={currentColorVar ? { color: currentColorVar } : undefined}
/>
</button>
);
})}
</div>
{effectiveHasImageIcon && showImagePreview && (
<div className="flex items-center gap-2 pt-1">
<span className="typography-meta text-muted-foreground">{t('projectEditDialog.field.preview')}</span>
<span className="inline-flex h-8 w-8 items-center justify-center rounded-lg 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={{ id: projectId, iconImage: currentIconImage }}
options={{
themeVariant: currentTheme.metadata.variant,
iconColor: currentTheme.colors.surface.foreground,
}}
className="h-full w-full object-contain"
onError={() => setPreviewImageFailed(true)}
/>
)}
</span>
</span>
</div>
)}
<div className="flex flex-wrap items-center gap-2 pt-1">
{!hasCustomIcon && (
<>
<Button size="sm" variant="outline" onClick={() => fileInputRef.current?.click()} disabled={isUploadingIcon}>
{isUploadingIcon ? t('projectEditDialog.actions.uploading') : t('projectEditDialog.actions.uploadIcon')}
</Button>
<Button size="sm" variant="outline" onClick={() => void handleDiscoverIcon()} disabled={isDiscoveringIcon}>
{isDiscoveringIcon ? t('projectEditDialog.actions.discovering') : t('projectEditDialog.actions.discoverFavicon')}
</Button>
</>
)}
{hasRemovableImageIcon && (
<Button size="sm" variant="outline" onClick={() => void handleRemoveImageIcon()} disabled={isRemovingCustomIcon}>
{isRemovingCustomIcon ? t('projectEditDialog.actions.removing') : t('projectEditDialog.actions.removeProjectIcon')}
</Button>
)}
{pendingRemoveImageIcon && (
<Button size="sm" variant="outline" onClick={() => setPendingRemoveImageIcon(false)} disabled={isRemovingCustomIcon}>
{t('projectEditDialog.actions.undoRemove')}
</Button>
)}
</div>
</div>
{effectiveHasImageIcon && (
<div className="min-w-0 space-y-2">
<label className="typography-ui-label font-medium text-foreground">
{t('projectEditDialog.field.iconBackground')}
</label>
<div className="flex flex-wrap items-center gap-2">
<input
type="color"
value={iconBackground ?? '#000000'}
onChange={(event) => setIconBackground(event.target.value)}
className="h-8 w-10 cursor-pointer rounded border border-border bg-transparent p-1"
aria-label={t('projectEditDialog.field.iconBackgroundAria')}
/>
<Input
value={iconBackground ?? ''}
onChange={(event) => setIconBackground(event.target.value)}
placeholder="#000000"
className="h-8 w-[8.5rem]"
/>
<Button size="sm" variant="outline" onClick={() => setIconBackground(null)}>
{t('projectEditDialog.actions.clear')}
</Button>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t('projectEditDialog.actions.cancel')}
</Button>
<Button onClick={handleSave} disabled={!name.trim() || isUploadingIcon || isRemovingCustomIcon}>
{t('projectEditDialog.actions.save')}
</Button>
</DialogFooter>
</ScrollableOverlay>
</DialogContent>
</Dialog>
);