From a1aae30e6663de7e80d55c74c17b61747312a16a Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Thu, 9 Jul 2026 13:54:05 +0300 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Bohdan Triapitsyn Co-authored-by: Cursor Agent Co-authored-by: Serhii Dziupin --- .../components/layout/ProjectEditDialog.tsx | 433 +------------- .../openchamber/WorktreeSectionContent.tsx | 261 +++++---- .../projects/ProjectActionsSection.tsx | 543 +++++++++--------- .../projects/ProjectIdentityFields.tsx | 286 +++++++++ .../projects/ProjectSettingsPanel.tsx | 60 ++ .../projects/ProjectSettingsSubsection.tsx | 47 ++ .../sections/projects/ProjectsPage.tsx | 468 +-------------- .../projects/useProjectIdentityAutoSave.ts | 76 +++ .../projects/useProjectIdentityForm.ts | 293 ++++++++++ .../src/components/session/SessionSidebar.tsx | 44 +- .../session/sidebar/sortableItems.tsx | 2 +- packages/ui/src/lib/api/types.ts | 1 + .../ui/src/lib/i18n/messages/en.settings.ts | 4 + packages/ui/src/lib/i18n/messages/en.ts | 1 + .../ui/src/lib/i18n/messages/es.settings.ts | 4 + packages/ui/src/lib/i18n/messages/es.ts | 1 + .../ui/src/lib/i18n/messages/fr.settings.ts | 4 + packages/ui/src/lib/i18n/messages/fr.ts | 1 + .../ui/src/lib/i18n/messages/ja.settings.ts | 4 + packages/ui/src/lib/i18n/messages/ja.ts | 1 + .../ui/src/lib/i18n/messages/ko.settings.ts | 4 + packages/ui/src/lib/i18n/messages/ko.ts | 1 + .../ui/src/lib/i18n/messages/pl.settings.ts | 4 + packages/ui/src/lib/i18n/messages/pl.ts | 1 + .../src/lib/i18n/messages/pt-BR.settings.ts | 4 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 1 + .../ui/src/lib/i18n/messages/uk.settings.ts | 4 + packages/ui/src/lib/i18n/messages/uk.ts | 1 + .../src/lib/i18n/messages/zh-CN.settings.ts | 4 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 1 + .../src/lib/i18n/messages/zh-TW.settings.ts | 4 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 1 + packages/ui/src/stores/useConfigStore.ts | 19 +- packages/ui/src/stores/useProjectsStore.ts | 44 +- packages/ui/src/sync/session-ui-store.ts | 4 +- .../settings-normalization-runtime.js | 2 + 36 files changed, 1362 insertions(+), 1271 deletions(-) create mode 100644 packages/ui/src/components/sections/projects/ProjectIdentityFields.tsx create mode 100644 packages/ui/src/components/sections/projects/ProjectSettingsPanel.tsx create mode 100644 packages/ui/src/components/sections/projects/ProjectSettingsSubsection.tsx create mode 100644 packages/ui/src/components/sections/projects/useProjectIdentityAutoSave.ts create mode 100644 packages/ui/src/components/sections/projects/useProjectIdentityForm.ts diff --git a/packages/ui/src/components/layout/ProjectEditDialog.tsx b/packages/ui/src/components/layout/ProjectEditDialog.tsx index 4fb3aff0..3bc54862 100644 --- a/packages/ui/src/components/layout/ProjectEditDialog.tsx +++ b/packages/ui/src/components/layout/ProjectEditDialog.tsx @@ -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; } -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 = ({ 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(initialIcon); - const [color, setColor] = React.useState(initialColor); - const [iconBackground, setIconBackground] = React.useState(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(null); - const [pendingUploadIconPreviewUrl, setPendingUploadIconPreviewUrl] = React.useState(null); - const [previewImageFailed, setPreviewImageFailed] = React.useState(false); - const fileInputRef = React.useRef(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 ( - - - {t('projectEditDialog.title')} - - -
- {/* Name */} -
- - setName(e.target.value)} - placeholder={t('projectEditDialog.field.namePlaceholder')} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleSave(); - } - }} - autoFocus - /> -

- {projectPath} -

+ + +
+ {open && project ? ( + + ) : null}
- - {/* Color */} -
- -
- {/* No color option */} - - {PROJECT_COLORS.map((c) => ( -
-
- - {/* Icon */} -
- - { - const file = event.target.files?.[0] ?? null; - void handleUploadIcon(file); - event.currentTarget.value = ''; - }} - /> -
- {/* No icon option */} - - {PROJECT_ICONS.map((i) => { - const iconName = i.Icon; - return ( - - ); - })} -
- {effectiveHasImageIcon && showImagePreview && ( -
- {t('projectEditDialog.field.preview')} - - - {hasPendingUploadImageIcon && pendingUploadIconPreviewUrl ? ( - setPreviewImageFailed(true)} - /> - ) : ( - setPreviewImageFailed(true)} - /> - )} - - -
- )} -
- {!hasCustomIcon && ( - <> - - - - )} - {hasRemovableImageIcon && ( - - )} - {pendingRemoveImageIcon && ( - - )} -
-
- - {effectiveHasImageIcon && ( -
- -
- 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')} - /> - setIconBackground(event.target.value)} - placeholder="#000000" - className="h-8 w-[8.5rem]" - /> - -
-
- )} -
- - - - - +
); diff --git a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx index 1fee25b1..da033e90 100644 --- a/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx +++ b/packages/ui/src/components/sections/openchamber/WorktreeSectionContent.tsx @@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { toast } from '@/components/ui'; import { Icon } from "@/components/icon/Icon"; import type { Session } from '@opencode-ai/sdk/v2'; import { useProjectsStore } from '@/stores/useProjectsStore'; @@ -22,12 +23,18 @@ import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager'; import { sessionEvents } from '@/lib/sessionEvents'; import type { WorktreeMetadata } from '@/types/worktree'; import { formatPathForDisplay, cn } from '@/lib/utils'; +import { + PROJECT_SETTINGS_CONTROL_WIDTH, + ProjectSettingsSubsection, +} from '@/components/sections/projects/ProjectSettingsSubsection'; import { useI18n } from '@/lib/i18n'; export interface WorktreeSectionContentProps { projectRef?: { id: string; path: string } | null; } +const SETUP_COMMANDS_SAVE_DELAY_MS = 450; + export const WorktreeSectionContent: React.FC = ({ projectRef: projectRefProp = null }) => { const { t } = useI18n(); const { isMobile, isTablet } = useDeviceInfo(); @@ -37,15 +44,17 @@ export const WorktreeSectionContent: React.FC = ({ const projectPath = projectRefProp?.path ?? activeProject?.path ?? null; const getWorktreeMetadata = useSessionUIStore((s) => s.getWorktreeMetadata); - const sessions = useSessions(); + const sessions = useSessions(); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const [setupCommands, setSetupCommands] = React.useState([]); const [waitForSetupCommands, setWaitForSetupCommands] = React.useState(false); const [isLoadingCommands, setIsLoadingCommands] = React.useState(false); + const [commandsSnapshot, setCommandsSnapshot] = React.useState(null); const [isGitRepoLocal, setIsGitRepoLocal] = React.useState(null); const [availableWorktrees, setAvailableWorktrees] = React.useState([]); const [isLoadingWorktrees, setIsLoadingWorktrees] = React.useState(false); + const isSavingCommandsRef = React.useRef(false); const projectRef = React.useMemo(() => { if (projectRefProp?.id && projectRefProp?.path) { @@ -68,7 +77,6 @@ export const WorktreeSectionContent: React.FC = ({ } }, [projectRef, isGitRepoLocal]); - // Load repo info React.useEffect(() => { if (!projectPath) return; @@ -90,7 +98,6 @@ export const WorktreeSectionContent: React.FC = ({ }; }, [projectPath]); - // Load existing worktrees React.useEffect(() => { if (!projectRef) { setAvailableWorktrees([]); @@ -127,7 +134,6 @@ export const WorktreeSectionContent: React.FC = ({ }; }, [projectRef, isGitRepoLocal]); - // Load setup commands React.useEffect(() => { if (!projectRef) return; @@ -141,12 +147,15 @@ export const WorktreeSectionContent: React.FC = ({ getWorktreeSetupWaitEnabled(projectRef), ]); if (!cancelled) { - setSetupCommands(commands.length > 0 ? commands : ['']); + const nextCommands = commands.length > 0 ? commands : ['']; + setSetupCommands(nextCommands); + setCommandsSnapshot(JSON.stringify(nextCommands)); setWaitForSetupCommands(waitForSetup); } } catch { if (!cancelled) { setSetupCommands(['']); + setCommandsSnapshot(JSON.stringify([''])); setWaitForSetupCommands(false); } } finally { @@ -161,6 +170,56 @@ export const WorktreeSectionContent: React.FC = ({ }; }, [projectRef]); + const persistSetupCommands = React.useCallback(async (commands: string[]): Promise => { + if (!projectRef) { + return false; + } + const filtered = commands.filter((cmd) => cmd.trim().length > 0); + try { + const ok = await saveWorktreeSetupCommands(projectRef, filtered); + if (!ok) { + toast.error(t('settings.openchamber.worktrees.setup.toast.saveFailed')); + return false; + } + setCommandsSnapshot(JSON.stringify(commands)); + return true; + } catch { + toast.error(t('settings.openchamber.worktrees.setup.toast.saveFailed')); + return false; + } + }, [projectRef, t]); + + const commandsHaveChanges = React.useMemo(() => { + if (commandsSnapshot === null) { + return false; + } + return commandsSnapshot !== JSON.stringify(setupCommands); + }, [commandsSnapshot, setupCommands]); + + React.useEffect(() => { + if (!commandsHaveChanges || isLoadingCommands || isSavingCommandsRef.current) { + return; + } + + const timer = window.setTimeout(() => { + if (isSavingCommandsRef.current) { + return; + } + isSavingCommandsRef.current = true; + void (async () => { + try { + await persistSetupCommands(setupCommands); + } finally { + isSavingCommandsRef.current = false; + } + })(); + }, SETUP_COMMANDS_SAVE_DELAY_MS); + + return () => { + window.clearTimeout(timer); + }; + }, [commandsHaveChanges, isLoadingCommands, persistSetupCommands, setupCommands]); + const handleSetupCommandChange = React.useCallback((index: number, value: string) => { setSetupCommands((prev) => { const next = [...prev]; @@ -173,25 +232,26 @@ export const WorktreeSectionContent: React.FC = ({ setSetupCommands((prev) => [...prev, '']); }, []); - const persistSetupCommands = React.useCallback(async (commands: string[]) => { - if (!projectRef) return; - const filtered = commands.filter((cmd) => cmd.trim().length > 0); - await saveWorktreeSetupCommands(projectRef, filtered); - }, [projectRef]); - const handleRemoveCommand = React.useCallback((index: number) => { setSetupCommands((prev) => { const next = prev.filter((_, i) => i !== index); - // Keep at least 1 row in UI, but persist empty config when all removed. - void persistSetupCommands(next); return next.length > 0 ? next : ['']; }); - }, [persistSetupCommands]); + }, []); - // Save setup commands on blur const handleCommandBlur = React.useCallback(() => { - void persistSetupCommands(setupCommands); - }, [persistSetupCommands, setupCommands]); + if (!commandsHaveChanges || isSavingCommandsRef.current) { + return; + } + isSavingCommandsRef.current = true; + void (async () => { + try { + await persistSetupCommands(setupCommands); + } finally { + isSavingCommandsRef.current = false; + } + })(); + }, [commandsHaveChanges, persistSetupCommands, setupCommands]); const handleWaitForSetupCommandsChange = React.useCallback((enabled: boolean) => { setWaitForSetupCommands(enabled); @@ -200,22 +260,16 @@ export const WorktreeSectionContent: React.FC = ({ } }, [projectRef]); - // Delete worktree handler const handleDeleteWorktree = React.useCallback((worktree: WorktreeMetadata) => { const normalize = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, ''); const normalizedWorktreePath = normalize(worktree.path); - // Find sessions linked to this worktree by: - // 1. Worktree metadata path match - // 2. Session directory match const directSessions = sessions.filter((session) => { - // Check worktree metadata const metadata = getWorktreeMetadata(session.id); if (metadata?.path && normalize(metadata.path) === normalizedWorktreePath) { return true; } - // Check session directory const sessionDir = (session as { directory?: string }).directory; if (sessionDir) { const normalizedSessionDir = normalize(sessionDir); @@ -227,12 +281,8 @@ export const WorktreeSectionContent: React.FC = ({ return false; }); - // Build a set of session IDs that are directly linked const directSessionIds = new Set(directSessions.map((s) => s.id)); - // Search subsessions across all directories, not just the current sync - // context, so subagent sessions created in other worktrees/project roots - // are still included in the delete list. const allKnownSessions = [ ...useGlobalSessionsStore.getState().activeSessions, ...useGlobalSessionsStore.getState().archivedSessions, @@ -252,7 +302,6 @@ export const WorktreeSectionContent: React.FC = ({ const allSubsessions = findSubsessions(directSessionIds); - // Dedupe sessions (in case same session matched both ways) const seenIds = new Set(); const allSessions = [...directSessions, ...allSubsessions].filter((session) => { if (seenIds.has(session.id)) { @@ -269,7 +318,6 @@ export const WorktreeSectionContent: React.FC = ({ }); }, [sessions, getWorktreeMetadata]); - // Refresh worktrees when sessions change (after deletion) const sessionsKey = React.useMemo(() => sessions.map(s => s.id).join(','), [sessions]); React.useEffect(() => { if (isGitRepoLocal && projectPath) { @@ -277,48 +325,69 @@ export const WorktreeSectionContent: React.FC = ({ } }, [sessionsKey, isGitRepoLocal, projectPath, refreshWorktrees]); + const setupTooltip = ( + + + + + + {t('settings.openchamber.worktrees.setup.tooltipPrefix')} + {' '} + $ROOT_PROJECT_PATH + {' '} + {t('settings.openchamber.worktrees.setup.tooltipSuffix')} + + + ); + + const listTooltip = ( + + + + + + {t('settings.openchamber.worktrees.list.tooltip')} + + + ); + if (!projectPath) { return ( -

- {t('settings.openchamber.worktrees.state.selectProject')} -

+ +

+ {t('settings.openchamber.worktrees.state.selectProject')} +

+
); } if (isGitRepoLocal === false) { return ( -

- {t('settings.openchamber.worktrees.state.gitOnly')} -

+ +

+ {t('settings.openchamber.worktrees.state.gitOnly')} +

+
); } return ( -
- {/* Setup commands */} -
-
-
-

{t('settings.openchamber.worktrees.setup.title')}

- - - - - - {t('settings.openchamber.worktrees.setup.tooltipPrefix')} - {' '} - $ROOT_PROJECT_PATH - {' '} - {t('settings.openchamber.worktrees.setup.tooltipSuffix')} - - -
-
- + <> + {isLoadingCommands ? ( -

{t('settings.openchamber.worktrees.setup.loading')}

+

{t('settings.openchamber.worktrees.setup.loading')}

) : ( -
+
{setupCommands.map((command, index) => (
= ({ onChange={(e) => handleSetupCommandChange(index, e.target.value)} onBlur={handleCommandBlur} placeholder={t('settings.openchamber.worktrees.setup.commandPlaceholder')} - className="h-7 w-[30rem] max-w-full font-mono text-xs" + className="h-7 min-w-0 flex-1 font-mono text-xs" /> -
@@ -352,7 +419,7 @@ export const WorktreeSectionContent: React.FC = ({
)} -
- - {/* Existing worktrees */} -
-
-
-

{t('settings.openchamber.worktrees.list.title')}

- - - - - - {t('settings.openchamber.worktrees.list.tooltip')} - - -
-
+ + {isLoadingWorktrees ? ( -

{t('settings.openchamber.worktrees.list.loading')}

+

{t('settings.openchamber.worktrees.list.loading')}

) : availableWorktrees.length === 0 ? ( -

+

{t('settings.openchamber.worktrees.list.empty')}

) : ( -
+
{availableWorktrees.map((worktree) => (
-
-
-

+

+
+

{worktree.label || worktree.branch || t('settings.openchamber.worktrees.list.detachedHead')}

- + OpenCode
-

+

{formatPathForDisplay(worktree.path, homeDirectory)}

- @@ -427,7 +482,7 @@ export const WorktreeSectionContent: React.FC = ({ ))}
)} -
-
+ + ); }; diff --git a/packages/ui/src/components/sections/projects/ProjectActionsSection.tsx b/packages/ui/src/components/sections/projects/ProjectActionsSection.tsx index 96643562..c400f296 100644 --- a/packages/ui/src/components/sections/projects/ProjectActionsSection.tsx +++ b/packages/ui/src/components/sections/projects/ProjectActionsSection.tsx @@ -37,11 +37,17 @@ import { PROJECT_ACTION_ICONS, PROJECT_ACTIONS_UPDATED_EVENT, } from '@/lib/projectActions'; +import { + PROJECT_SETTINGS_CONTROL_WIDTH, + ProjectSettingsSubsection, +} from '@/components/sections/projects/ProjectSettingsSubsection'; import { useI18n } from '@/lib/i18n'; import { cn } from '@/lib/utils'; type EditableProjectAction = OpenChamberProjectAction; +const AUTO_SAVE_DELAY_MS = 450; + const createActionId = (): string => { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); @@ -68,9 +74,10 @@ export const ProjectActionsSection: React.FC = ({ pr const [actions, setActions] = React.useState([]); const [isLoading, setIsLoading] = React.useState(false); - const [isSaving, setIsSaving] = React.useState(false); const [initialSnapshot, setInitialSnapshot] = React.useState(null); const [expandedActions, setExpandedActions] = React.useState>({}); + const isSavingRef = React.useRef(false); + const validationToastShownRef = React.useRef(null); React.useEffect(() => { if (!isDesktopShellApp) { @@ -133,6 +140,69 @@ export const ProjectActionsSection: React.FC = ({ pr return initialSnapshot !== JSON.stringify({ actions }); }, [actions, initialSnapshot]); + const persistActions = React.useCallback(async (nextActions: EditableProjectAction[]) => { + const ok = await saveProjectActionsState(projectRef, { + actions: nextActions, + primaryActionId: null, + }); + if (!ok) { + toast.error(t('settings.projects.actions.toast.saveFailed')); + return false; + } + setInitialSnapshot(JSON.stringify({ actions: nextActions })); + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(PROJECT_ACTIONS_UPDATED_EVENT, { + detail: { projectId: projectRef.id }, + })); + } + return true; + }, [projectRef, t]); + + React.useEffect(() => { + if (!hasChanges || isLoading || validationError || isSavingRef.current) { + return; + } + + const timer = window.setTimeout(() => { + if (isSavingRef.current) { + return; + } + isSavingRef.current = true; + void (async () => { + try { + await persistActions(actions); + } finally { + isSavingRef.current = false; + } + })(); + }, AUTO_SAVE_DELAY_MS); + + return () => { + window.clearTimeout(timer); + }; + }, [actions, hasChanges, isLoading, persistActions, validationError]); + + React.useEffect(() => { + if (!hasChanges || !validationError || isLoading) { + if (!validationError) { + validationToastShownRef.current = null; + } + return; + } + + const timer = window.setTimeout(() => { + if (validationToastShownRef.current === validationError) { + return; + } + validationToastShownRef.current = validationError; + toast.error(validationError); + }, 1000); + + return () => { + window.clearTimeout(timer); + }; + }, [hasChanges, isLoading, validationError]); + const handleAddAction = React.useCallback(() => { const nextAction = createEmptyAction(); setActions((prev) => [...prev, nextAction]); @@ -155,275 +225,224 @@ export const ProjectActionsSection: React.FC = ({ pr setActions((prev) => prev.map((entry) => (entry.id === id ? updater(entry) : entry))); }, []); - const handleSave = React.useCallback(async () => { - if (validationError) { - toast.error(validationError); - return; - } - setIsSaving(true); - try { - const ok = await saveProjectActionsState(projectRef, { - actions, - primaryActionId: null, - }); - if (!ok) { - toast.error(t('settings.projects.actions.toast.saveFailed')); - return; - } - setInitialSnapshot(JSON.stringify({ actions })); - if (typeof window !== 'undefined') { - window.dispatchEvent(new CustomEvent(PROJECT_ACTIONS_UPDATED_EVENT, { - detail: { projectId: projectRef.id }, - })); - } - toast.success(t('settings.projects.actions.toast.saved')); - } catch { - toast.error(t('settings.projects.actions.toast.saveFailed')); - } finally { - setIsSaving(false); - } - }, [actions, projectRef, t, validationError]); - - const canSave = !isSaving && !isLoading && hasChanges && !validationError; - return ( -
-
-
-

{t('settings.projects.actions.title')}

-

{t('settings.projects.actions.description')}

-
+ {t('settings.projects.actions.actions.add')} -
- -
- {isLoading ? ( -

{t('settings.projects.actions.state.loading')}

- ) : actions.length === 0 ? ( -
-

{t('settings.projects.actions.state.empty')}

-
- ) : ( -
- {actions.map((action) => { - const selectedIconKey = (action.icon as keyof typeof PROJECT_ACTION_ICON_MAP) || 'play'; - const selectedIconName = PROJECT_ACTION_ICON_MAP[selectedIconKey] || 'play'; - const isOpen = expandedActions[action.id] ?? false; - const title = action.name.trim() || t('settings.projects.actions.state.untitled'); - - return ( - { - setExpandedActions((prev) => ({ - ...prev, - [action.id]: open, - })); - }} - className={cn( - 'py-1.5' - )} - > -
- - {isOpen ? ( - - ) : ( - - )} - -
-
- {title} -
-
-
- - -
- - -
-
- - - - - -
- {PROJECT_ACTION_ICONS.map((entry) => { - const iconName = entry.Icon; - const selected = (action.icon || 'play') === entry.key; - return ( - - ); - })} -
-
-
- - updateAction(action.id, (current) => ({ ...current, name: event.target.value }))} - placeholder={t('settings.projects.actions.field.actionNamePlaceholder')} - className="h-7 max-w-[14rem]" - /> -
- -
-

{t('settings.projects.actions.field.command')}

-